서버리스에서의 멱등성 구현

Seongwoo Choi
10 min readJun 12, 2023

--

Werner Vogels 아마존 CTO는 AWS에서의 10년 동안 배웠던 교훈에 대해 돌아보며, 다음과 같이 말했다.

Expect the unexpected.

그리고 실패를 자연스러운 것으로 받아들이는 시스템을 구축하는 것이 중요하다고 덧붙였다. 마이크로서비스에서도 이러한 “깨질 수 있는 속성”을 고려한 아키텍처를 설계할 필요성이 있는 것은 두 말할 필요가 없다.

서버와 클라이언트 사이의 API 요청이 오고 가는 과정에서 이러한 실패는 때때로 발생할 수 있다. 클라이언트가 서버에게 요청을 보내고 네트워크 이슈 등 서버에 어떠한 문제가 생겨 클라이언트에 응답을 반환하지 못하게 된다고 가정해보자. Client 입장에서는 재요청을 보내기 전까지 서버에 어떤 일이 발생했는지 알 수가 없다.
그렇다고 단순히 여러 번 재요청을 하는 방법도 근본 해결책은 아니다. 결제 완료 메시지를 못 받았다고 해서 서버 단에서 결제를 두 번 한다면, 고객에게 컴플레인을 받는 것은 물론 비즈니스 크리티컬한 side effect가 발생할 것이다. 그렇다면 어떻게 해결해야 할까?

Idempotency

클라이언트는 여러 번 요청해도 상관없도록 하고, 언제나 그렇듯 서버 단에서 검증을 진행한다. 서버에서 중복 요청을 다루는 로직을 삽입하여 동일한 이벤트에 대해 실제로 한 번(Effectively once)만 수행하도록 하면 된다. 분산 서비스에서 이러한 “안전한 재시도”를 보장하는 것이 Idempotency, 멱등성이라는 개념이다.

백엔드의 작업이 성공했는지 확인하기 위해서 다양한 방법이 있을 수 있다. 일반적으로는 Idempotency Key(멱등성 키)를 활용한다. 멱등성 키는 클라이언트 요청에 대한 고유한 값을 포함하며 이전에 동일한 요청이 인입했었는지 검증한다.
이전 요청이 성공적으로 완료되었는지를 Idempotency Record(멱등성 레코드)로 표현하며, Complete / In-progress 로 나누어 Database에 상태를 저장한다. 또한 멱등성 키에 중복 요청을 검증하는 기간을 무한히 길게 줄 수는 없으므로, 일정 시간이 지나면 만료되도록 해야 한다. 중복 요청 검증 과정은 아래와 같으며, 결제를 두 번 처리하는 일은 없이 어떠한 경우에서도 Client는 동일한 응답을 받을 수 있게 된다.

  1. 멱등성 키와 페이로드의 hash 값이 같은지 비교
  2. 초기 요청의 경우 멱등성 record를 In-Progress로 설정
  3. 초기 요청에 대한 작업이 성공적으로 실행되면, Complete 으로 record 변경
    3–1. 초기 요청에 대한 작업 완료 시 Client에게 응답 반환
    3–2. 요청 재시도 시, 레코드 만료된 경우 작업 재시도 후 record를 Complete 변경
    3–3. 레코드 만료되지 않고, Complete record가 발견되면, 동일 응답 반환
  4. 초기 요청에 대한 작업이 실패한 경우(timeout 등), Record는 In-Progress로 유지
    4–1. 재시도 시에 멱등성 키와 페이로드의 hash 값이 같은지 비교
    4–2. 동일한 페이로드의 요청이며 In-Progress 상태이기 때문에, 작업 재시도
    4–3. 작업 재시도가 완료되면, Record를 Complete으로 변경
    4–4. Client에게 응답 반환

Idempotency with AWS Lambda Powertools

이벤트 기반 서비스 등 서버리스 아키텍처는 서비스 간 수많은 API 요청으로 이루어진다. 대표적인 서버리스 서비스인 AWS Lambda의 공식 문서에 따르면, Lambda는 내결함성을 가지고 있어 함수를 호출하고 오류가 있는 경우 함수가 재실행된다.
Lambda를 사용하는 모든 워크로드에서 이러한 재실행이 크리티컬한 것은 아니지만, 주문 결제, 재고 차감 등 실제로 한 번만 작동해야 하는 워크로드에서 안전한 재실행을 위한 멱등성 구현은 필수적이다.

다행히도 AWS에서는 멱등성의 구현을 추상화하여 쉽게 멱등성 작업으로 변환할 수 있도록 오픈소스 개발자 라이브러리인 AWS Lambda Powertools을 통해 멱등성 유틸리티를 제공하고 있다. Python, Java, TypeScript, .NET에서 사용 가능하며, 본 포스팅에서는 Python을 기준으로 설명한다. 멱등성 구현 레이어가 아래와 같이 각 요소로 대응된다.

  • Client -> Client
  • Server -> AWS Lambda
  • Database -> Persistent Layer (DynamoDB)

멱등성 레코드는 이전 요청이 성공적으로 작업되었는지를 나타내며 Persistent Layer인 DynamoDB에 아래와 같은 Key-Value 값으로 저장된다.

  • id : idempotency_key로서, 이전에 동일한 요청이 인입했는지 검증
  • data : 이전 요청에서 반환한 응답 값을 JSON으로 저장 (동일 응답 반환을 위함)
  • expiration : COMPLELTE 상태의 레코드의 만료 기한
  • in_progress_expiration : (INPROGRESS 상태에서의) timeout 기한
  • status : INPROGRESS, COMPLETE, EXPIRED 의 레코드 상태를 표시
  • validation : payload의 hash 값

Powertools에서 제공하는 @idempotent decorator를 통해 기존 코드를 아래와 같이 간단하게 멱등성 작업으로 변환할 수 있다. 전체 코드는 GitHub에서 확인할 수 있다.

import uuid
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent, IdempotencyConfig


persistent_layer = DynamoDBPersistenceLayer(table_name="persistent-storage-lambda-idempotent")
config = IdempotencyConfig(
event_key_jmespath="powertools_json(body)",
expires_after_seconds=5*60
)

@idempotent(persistence_store=persistent_layer, config=config)
def lambda_handler(event: dict, context: LambdaContext) -> dict:
amount = int(json.loads(event['body'])['amount'])

ddb_client.put_item(
TableName='test-duplication-table',
Item={
'user_id': {
'S': str(uuid.uuid4())
},
'amount': {
'S': str(amount)
}
},
ConditionExpression='attribute_not_exists(user_id) AND attribute_not_exists(amount)'
)

return {}

persistent_layer에 DynamoDB 테이블 이름을 명시하여, 멱등성 레코드가 저장될 장소를 지정한다. 현재는 멱등성 레코드 저장소로 DynamoDB만을 지원하고 있으며, Redis나 기타 Database로 지정하려면 커스텀하게 멱등성 로직을 구현해야 한다.

event_key_jmespath를 통해서 멱등성 키를 지정할 수 있다. 결제 상황을 예시로, 한 유저에게 동일한 물품에 대한 동일 금액 요청이 들어왔는지 검증을 위해 event의 body 값을 멱등성 키로 지정하였다. 이렇게 구성함으로써 함수 실행 중 발생한 오류로 인해 결제가 두 번 되는 등의 부작용 없이 안전한 재실행을 보장할 수 있게 된다.

Idempotency Test

아래 쉘 스크립트를 통해 Lambda와 연결된 Amazon API Gateway URL을 호출한다. 총 7개의 request가 발생하며, 그중 마지막 3개의 request는 동일한 body 값으로 호출한다.

curl -X POST https://apigw-url -d '{"amount": "20000", "user_id":"1"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "10000", "user_id":"2"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "30000", "user_id":"3"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "40000", "user_id":"4"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "50000", "user_id":"5"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "50000", "user_id":"5"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'
curl -X POST https://apigw-url -d '{"amount": "50000", "user_id":"5"}' -H "x-api-key: ${apikey}" -H 'Content-Type: application/json'

먼저, @idempotent 가 포함된 라인을 주석 처리하여 멱등성이 적용되지 않은 코드의 결과를 살펴보겠다.

Item of DynamoDB is duplicated

중복 요청이 포함되었음에도 불구하고 7개의 요청이 모두 DynamoDB에 저장되었다. 멱등성 작업이었다면 중복된 레코드를 제외하고 5개의 요청만이 DynamoDB에 저장되었을 것이다.

반면에, @idempotent 를 다시 주석 해제하여 멱등성이 구현된 코드로 전환하고 쉘 스크립트를 실행하여 동일 요청을 테스트해 보면 아래와 같이 중복 요청은 멱등성 검증 로직에 의해 실행되지 않은 것을 확인할 수 있다.

Item of DynamoDB table is not duplicated

Conclusion

수익이 발생하는 모든 서비스는 비즈니스 연속성을 저해하는 위험에 노출되어 있고, 프로덕션 준비가 된 API라면 최소한 해당 워크로드에 대해서는 멱등성이 구현되어야만 한다.
AWS Lambda Powertools을 통해서 멱등성 구현에 드는 개발 및 운영 소요를 최소화할 수 있으며, 멱등성 작업을 통해 클라이언트-서버 구조 및 서버리스 환경에서 장애가 발생했을 때 서비스에 대한 안정성을 보완할 수 있을 것이다.

--

--