← Back to list

분산 환경에서 멱등성 보장하기

경험해보기 전까지는 알 수 없었던 것들 feat. 실무에서 이슈를 겪으며 정리해본 설계 원칙

Eeeasycode in Monday9pm · 2026-04-19 17:11 · 51 claps · 17.2 min read
#멱등성 #idempotency #분산-시스템 #distributed-systems
Open on Medium ↗

분산 환경에서 멱등성 보장하기

실무에서 이슈를 겪으며 정리해본 설계 원칙

들어가며

분산 시스템의 정합성 문제를 이야기할 때는 흔히 분산 트랜잭션을 먼저 떠올립니다. 여러 서비스와 데이터 저장소에 걸친 작업을 어떻게 하나의 일관된 흐름으로 처리할 것인지, 일부만 성공했을 때는 어떻게 복구할 것인지가 대표적인 주제입니다.

분산 트랜잭션 vs 멱등성 처리

분산 트랜잭션 vs 멱등성 처리

반면 이 글이 다루는 주제는 멱등성입니다. 즉, 같은 요청이나 이벤트가 여러 번 들어오더라도 결과가 중복 반영되지 않도록 만드는 문제에 더 가깝습니다.

두 주제는 모두 분산 환경의 정합성과 연결되어 있지만, 바라보는 문제의 단위는 다릅니다. 분산 트랜잭션이 여러 단계에 걸친 전체 흐름의 일관성을 다룬다면, 멱등성은 각 처리 경계에서 동일한 요청이 다시 실행되더라도 결과가 한 번만 반영된 것처럼 유지되는지를 다룹니다.

처음 백엔드 개발을 시작했을 때는 이런 문제를 깊게 고민해본 적이 없었습니다. 단일 서버와 RDB 중심의 구조에서는 트랜잭션 안에서 데이터를 처리하고, 실패하면 롤백한 뒤 다시 시도하면 되는 경우가 많았기 때문입니다. 요청은 한 번만 처리된다는 전제가 비교적 자연스럽게 받아들여졌습니다.

그런데 SQS + Lambda, DynamoDB Streams, 외부 벤더 API 연동과 같은 분산 환경으로 넘어오면서 이 전제가 더 이상 당연하지 않다는 점을 경험했습니다. 실제로 포인트 이중 환불, 메시지 중복 발송, 데이터 정합성 훼손과 같은 문제를 겪었습니다.

이 글은 그러한 경험을 바탕으로, 분산 환경에서 왜 멱등성이 필요하며 어떤 지점에서 쉽게 무너질 수 있는지를 정리한 글입니다. 결론부터 말하면, 분산 환경에서 중복 전달은 예외적인 상황이 아니라 기본적으로 고려해야 할 전제에 가깝습니다. 중요한 것은 중복을 완전히 없애는 것이 아니라, 중복 실행이 발생하더라도 결과가 한 번 처리된 것과 같도록 시스템을 설계하는 일입니다.

멱등성이란 무엇인가

멱등성의 정의 자체는 단순합니다. 같은 연산을 여러 번 적용해도 결과가 최초 1회 적용한 것과 동일하게 유지되는 성질입니다.

user.point += 100   // 멱등하지 않음: 2번 실행하면 200
user.point = 100    // 멱등함: 몇 번을 실행해도 100

분산 환경에서 이 성질이 중요한 이유는, 시스템이 동일 요청을 정말로 한 번만 전달해준다고 기대하기 어렵기 때문입니다. 네트워크 지연, 컨슈머 장애, 확인 응답 유실, 타임아웃 이후 재시도와 같은 상황이 겹치면, 같은 이벤트나 같은 명령이 한 번 이상 실행되는 것은 충분히 현실적인 시나리오가 됩니다.

그래서 저는 멱등성을 다음과 같이 이해하게 되었습니다.

멱등성은 중복 전달을 없애는 기술이 아닙니다. 중복 전달이 발생하더라도, 같은 요청이 여러 번 실행된 결과가 최초 한 번 실행된 결과와 동일하게 유지되도록 만드는 성질이자 설계 원칙에 가깝습니다.

즉, 멱등성은 “중복 전달을 막는 방법”이라기보다, 중복 전달이 발생하는 것을 전제로 시스템을 안전하게 만드는 방법에 가깝습니다.

멱등성 vs 원자성

멱등성과 원자성은 비슷해 보이지만 서로 다른 개념입니다.

멱등성은 같은 요청이 여러 번 실행되어도 최종 결과가 같게 유지되는 성질입니다. 반면 원자성은 하나의 작업이 전부 성공하거나 전부 실패해야 한다는 성질입니다. 전자는 중복 실행을 견디기 위한 개념이고, 후자는 중간 실패로 인해 불완전한 상태가 남는 것을 막기 위한 개념입니다.

예를 들어 같은 환불 요청이 두 번 들어와도 한 번만 환불되게 만드는 것은 멱등성의 문제입니다. 반면 환불 반영과 “이미 환불됨” 기록 사이에 중간 실패 상태가 남지 않게 만드는 것은 원자성의 문제입니다.

실무에서는 이 둘이 함께 필요한 경우가 많습니다. 멱등성만 있어도 중간 실패 상태가 남을 수 있고, 원자성만 있어도 같은 요청이 두 번 실행되면 중복 반영이 발생할 수 있기 때문입니다.

중복 처리 문제는 어디에서 발생했는가

실무에서 경험한 중복 처리 문제를 돌아보면, 원인은 한 곳에만 있지 않았습니다. 어떤 경우에는 인프라의 전달 특성을 잘못 이해한 데서 시작됐고, 어떤 경우에는 코드 경로에서 멱등성 가드가 우회되고 있었습니다. 또 어떤 경우에는 멱등성 키 자체가 잘못 설계되어, 중복 방지 로직이 존재하더라도 실제로는 동작하지 않았습니다.

다음으로는 실제로 겪었던 사례를 중심으로, 중복이 어떻게 발생했고 어떤 방식으로 대응했는지 정리해보겠습니다.

인프라의 전달 보장 착각 이슈 - 포인트 이중 환불

상황

포인트에 유효기간이 존재하는 시스템이 있었습니다. 유효기간이 만료된 포인트를 자동으로 환불 처리하기 위해 DynamoDB TTL을 활용했습니다. TTL이 만료되면 해당 레코드의 삭제 이벤트가 DynamoDB Streams로 흘러들어가고, 이를 트리거로 Lambda가 환불 처리를 수행하는 구조였습니다.

[DynamoDB TTL 만료] → [DynamoDB Streams] → [Lambda] → 포인트 환불 처리

포인트 환불 파이프라인

포인트 환불 파이프라인

그런데 어느 날, 특정 사용자의 포인트 환불이 두 번 처리된 것이 발견되었습니다. 같은 포인트에 대해 환불 Lambda가 두 번 실행된 것입니다.

원인

처음에는 DynamoDB Streams를 사용하고 있으니 같은 이벤트가 두 번 오지 않을 것이라고 판단했습니다. 실제로 DynamoDB Streams는 각 스트림 레코드가 스트림에 한 번 기록되고, 같은 아이템에 대한 변경 레코드의 순서가 유지된다고 명시합니다.

다만 이것은 어디까지나 스트림에 기록되는 시점까지의 보장입니다. Lambda event source mapping은 별개의 레이어이고, AWS는 이 경로에 대해 각 이벤트가 최소 한 번 처리되며 중복 처리가 발생할 수 있다고 명시합니다.

결국 “스트림에 한 번 기록된다”와 “컨슈머가 한 번만 처리한다”는 같은 말이 아니었습니다. 중복은 바로 이 간극에서 발생했습니다.

대응

가장 먼저 적용한 것은 RefundRecords에 pointId를 조건부로 기록하는 간단한 멱등성 가드였습니다. DynamoDB는 PutItem에서 attribute_not_exists(…) 조건을 사용해, 동일 키가 이미 존재할 경우 쓰기를 실패시키는 방식을 지원합니다.

다만 이것만으로는 충분하지 않았습니다. 환불 반영과 처리 이력 기록이 분리되어 있으면, 환불은 성공했지만 이력 기록 전에 장애가 나는 중간 실패 구간이 남기 때문입니다.

그래서 최종적으로는 환불 반영과 환불 이력 기록을 하나의 TransactWriteItems로 묶는 방식이 더 안전했습니다. TransactWriteItems는 여러 쓰기를 하나의 all-or-nothing 연산으로 처리하고, ClientRequestToken을 사용하면 동일한 트랜잭션 요청 자체도 멱등하게 만들 수 있습니다.

// 예시: 환불 반영과 환불 이력 기록을 하나의 트랜잭션으로 묶기
await dynamodb.transactWrite({
  // ClientRequestToken은 AWS 기준 10분 동안 동일 요청에 대한 멱등성을 보장한다.
  // 더 긴 시간 범위의 재시도는 아래 attribute_not_exists 조건이 방어한다.
  ClientRequestToken: `refund:${event.pointId}`,
  TransactItems: [
    {
      Put: {
        TableName: 'RefundRecords',
        Item: {
          pointId: event.pointId,
          userId: event.userId,
          amount: event.amount,
          refundedAt: Date.now(),
        },
        ConditionExpression: 'attribute_not_exists(pointId)',
      },
    },
    {
      Update: {
        TableName: 'UserPointBalance',
        Key: { userId: event.userId },
        UpdateExpression: 'ADD availablePoints :amount',
        ExpressionAttributeValues: {
          ':amount': event.amount,
        },
      },
    },
  ],
});

정리

이 사례에서 배운 점은 두 가지였습니다.

첫째, 인프라의 전달 특성을 그대로 비즈니스 처리 보장으로 해석하면 안 된다는 점입니다. 스트림이나 큐가 어떤 전달 특성을 설명하더라도, 최종적으로 비즈니스 처리를 수행하는 컨슈머 레벨에서는 별도의 멱등성 보장이 필요합니다.

둘째, 멱등성 키를 기록하는 것만으로는 충분하지 않고, 실제 상태 변경과 그 기록을 가능한 한 같은 원자적 단위로 묶어야 한다는 점입니다.

멱등성 가드 우회 이슈 - 분기문이 만든 중복 발송

상황

메시지 발송 시스템에는 멱등성 가드가 구현되어 있었습니다. 같은 메시지 ID로 발송 요청이 들어오면 중복 발송을 차단하는 구조였고, 정상 경로에서는 잘 동작하고 있었습니다. 테스트도 통과하고 있었기 때문에 한동안 문제가 없다고 생각했습니다.

그런데 어느 날, 특정 사용자들에게 같은 메시지가 두 번 발송되는 장애가 발생했습니다.

원인

문제는 단순한 if 문 하나가 아니라, 멱등성 로직을 통과하지 않는 별도 처리 경로가 존재했다는 점이었습니다.

정상 발송 API는 중복 체크를 거쳤지만, 재시도 워커나 관리자 수동 발송 경로는 별도 핸들러를 통해 곧바로 발송 로직으로 들어가고 있었습니다.

실무에서는 이런 형태가 더 흔합니다. 한 함수 안의 조건문보다, 서로 다른 진입점이 공통 규칙을 공유하지 못하는 구조에서 멱등성이 자주 무너집니다.

// 정상 발송 API
async function sendMessage(request: SendRequest) {
  await assertNotDuplicate(request.messageId);
  await doSend(request);
}

// 실패 재시도 워커
async function retryFailedMessage(job: RetryJob) {
  const originalRequest = await loadFailedRequest(job.messageId);
  return await doSend(originalRequest); // 멱등성 로직 우회
}

정상 경로에서는 중복 체크를 거치기 때문에 테스트에서 문제가 드러나지 않았습니다. 하지만 재시도 경로가 별도로 존재하고 그 경로가 공통 규칙을 공유하지 않으면, 인프라 레벨의 재전달이나 운영 경로를 통해 같은 요청이 다시 들어왔을 때 그대로 중복 발송이 일어납니다.

대응

처음에는 “모든 경로가 반드시 같은 중복 체크를 거쳐야 한다”고 생각했습니다. 그런데 조금 더 정리해보니, 핵심은 “모든 경로가 같은 키를 써야 한다”가 아니었습니다. 더 정확히는, 모든 경로가 자신의 비즈니스 의도에 맞는 멱등성 판단을 거쳐야 한다는 쪽에 가까웠습니다.

예를 들어

  • 재시도(retry)는 같은 요청의 재실행이므로 같은 멱등성 키를 사용해야 한다.
  • 수동 재발송은 운영자의 의도된 동작이므로 별도의 새로운 명령으로 모델링하고, 새로운 키를 가져야 한다.
async function sendWithIdempotency(request: SendRequest) {
  const idempotencyKey = resolveIdempotencyKey(request);

  // 실제 구현에서는 unique constraint / conditional write 등으로
  // 이 키의 "선점"을 원자적으로 처리해야 한다
  await reserveIdempotencyKey(idempotencyKey);

  await doSend(request);
}

function resolveIdempotencyKey(request: SendRequest): string {
  if (request.isManual) {
    // 수동 재발송은 새로운 의도이므로 새로운 명령 키를 사용한다
    return request.manualResendCommandId;
  }

  // 재시도는 원 요청과 같은 의도이므로 같은 키를 사용한다
  return request.messageId;
}

여기서 manualResendCommandId는 “운영자가 수행한 한 번의 수동 재발송 ” 자체를 식별하기 위한 키입니다. 따라서 같은 수동 재발송 명령이 네트워크 이슈나 UI 중복 클릭으로 다시 들어오더라도, 같은 commandId라면 한 번만 실행되도록 만들 수 있습니다.

한 가지 더 중요한 점은, isDuplicate() 확인 후 doSend()를 수행하는 식의 check-then-act 패턴만으로는 동시 요청 경쟁을 막기 어렵다는 점입니다.

두 요청이 거의 동시에 들어오면 양쪽 모두 중복 체크를 통과한 뒤 각자 발송을 수행할 수 있습니다. 실제 구현에서는 DynamoDB의 조건부 쓰기나 트랜잭션, 혹은 데이터베이스의 unique constraint처럼 키 선점 자체를 원자적으로 처리하는 메커니즘이 필요합니다. DynamoDB는 attribute_not_exists(…) 조건부 쓰기 TransactWriteItems를 모두 지원합니다.

정리

이 사례에서 중요한 점은, 멱등성이 “한 번 체크하는 유틸 함수”가 아니라는 것입니다. 정상 경로에서만 동작하는 멱등성은 멱등성이 아닙니다. 어떤 분기를 타든, 해당 요청을 같은 요청으로 볼 것인지 새로운 요청으로 볼 것인지가 먼저 정의되어야 하고, 그 정의가 코드 구조에 반영되어야 합니다.

멱등성 키 설계 이슈 - random UUID로 만든 키

상황

한 워커가 메시지 큐에서 메시지를 소비하는 구조가 있었습니다. 중복 소비가 발생할 수 있다는 사실은 이미 알고 있었고, uniqueId 기반의 dedup 로직도 구현되어 있었습니다. 처리된 uniqueId를 저장해두고, 같은 ID가 다시 들어오면 무시하는 방식이었습니다.

그런데 중복 처리가 계속 발생했습니다. 겉으로 보기에는 dedup 로직이 분명히 존재하는데도 같은 메시지가 두 번 처리되고 있었습니다.

원인

처음에는 dedup 로직 자체에 버그가 있는 줄 알았습니다. 하지만 로그를 분석한 결과, 동일한 메시지가 두 번 처리된 사례에서 각각의 uniqueId가 서로 다르다는 사실을 확인했습니다. 같은 메시지인데 서로 다른 ID로 기록되고 있었기 때문에 중복 감지가 동작할 수 없었습니다.

원인은 uniqueId를 메시지 자체에서 추출한 것이 아니라, 처리 로직 안에서 매번 새로 생성하고 있었다는 점이었습니다.

async function processMessage(message: Message) {
  const uniqueId = uuid(); // 같은 메시지여도 실행할 때마다 다른 값
  if (await isProcessed(uniqueId)) return;

  await process(message);
  await markProcessed(uniqueId);
}

이 코드는 얼핏 보면 멱등성 로직이 있는 것처럼 보입니다. 하지만 같은 입력에 대해 항상 다른 키를 만든다는 점에서, 사실상 dedup이 불가능한 구조였습니다.

대응

멱등성 키는 실행 시점에서 생성되는 값이 아니라, 요청의 의도나 비즈니스 이벤트의 정체성에서 파생되는 값이어야 합니다. 그래서 대응 코드도 브로커가 부여한 전송 단위 식별자보다는, 상위 시스템이 부여한 eventId, commandId, orderId 같은 식별자를 사용하는 편이 더 안전합니다.

async function processMessage(message: Message) {
  const uniqueId = message.eventId; // 상위 시스템이 부여한 비즈니스 식별자

  if (await isProcessed(uniqueId)) return;

  await process(message);
  await markProcessed(uniqueId);
}

왜 이렇게 바꾸는지가 중요합니다. 큐나 브로커가 부여한 messageId는 대개 전송 단위의 식별자이지, 항상 비즈니스 이벤트의 식별자는 아닙니다.

예를 들어 Amazon SQS는 시스템이 부여하는 MessageId와, producer가 중복 방지를 위해 넣는 MessageDeduplicationId를 별도로 둡니다. 즉, “메시지를 식별하는 값”과 “중복 판단에 사용할 값”은 같은 개념이 아닐 수 있습니다. 그래서 가능한 경우에는 브로커 식별자보다 상위 시스템의 eventId / commandId / orderId를 멱등성 키로 잡는 편이 더 안전합니다.

여기에도 한 가지 주의할 점이 있습니다. 키 설계가 올바르더라도, process(message)와 markProcessed(uniqueId)가 분리되어 있으면 여전히 중간 실패 구간이 남습니다. 처리 자체는 성공했지만 완료 기록 전에 장애가 발생하면, 이후 재시도에서 동일 메시지를 다시 처리할 수 있습니다.

따라서 멱등성은 키 설계만의 문제가 아닙니다. 어떤 키를 쓰는가 뿐 아니라, 그 키를 언제 기록하는가, 처리와 기록을 어떤 방식으로 원자적으로 연결할 수 있는가까지 함께 고려해야 합니다.

정리

이 사례를 통해 확인한 것은 단순합니다. 멱등성 키는 실행에서 나오면 안 되고, 의도에서 나와야 한다는 점입니다. 그리고 그 의도는 가능하면 브로커가 아닌 상위 시스템이 정의한 비즈니스 식별자로 표현되어야 합니다.

왜 이런 일이 반복되는가

at-least-once가 기본값인 세계

앞선 사례들을 돌아보면 공통 질문이 하나 남습니다. 왜 분산 환경에서는 “한 번만 처리한다”는 것이 이렇게 어려운가 하는 점입니다.

이유는 분산 시스템에서 메시지가 producer, broker, consumer를 거치는 과정 어디에서든 실패가 발생할 수 있기 때문입니다. 네트워크 지연, 브로커 장애, 컨슈머 크래시, 처리 후 확인 응답 유실은 모두 현실적인 시나리오입니다.

특히 “처리는 끝났지만, 처리 완료를 알리는 확인이 상대에게 전달되지 않은 상황”은 생각보다 쉽게 발생합니다. 이 경우 상위 시스템 입장에서는 해당 메시지가 처리되었는지 확신할 수 없기 때문에, 재전송이나 재시도를 선택하게 됩니다.

이것이 at-least-once 방식이 널리 사용되는 이유이기도 합니다. 물론 일부 시스템은 제한된 범위에서 exactly-once에 가까운 보장을 제공합니다.

예를 들어 Kafka producer는 enable.idempotence를 통해 같은 producer session 내 재시도로 인한 중복 기록을 방지하고, transactional.id를 사용하면 트랜잭션 경계를 가진 쓰기를 구성할 수 있습니다.

또한, SQS FIFO는 MessageDeduplicationId를 기준으로 5분 deduplication interval 안에서 중복 메시지 전달을 억제합니다.

하지만 이런 기능들도 보장의 범위가 명확히 제한되어 있습니다. 외부 시스템 호출, 별도 저장소 갱신, 비동기 워커 처리까지 함께 얽히는 end-to-end 경계에서는 여전히 멱등성 설계가 필요합니다.

실무에서는 오히려 인프라의 보장에 모든 것을 위임하는 것이 아닌, 각 처리 경계에서 멱등한 처리를 설계하는 쪽이 더 현실적이었습니다.

핵심은 인프라가 중복을 절대 만들지 않는다고 기대하는 것이 아닙니다. 인프라가 중복을 만들 수 있다는 사실을 받아들이고, 각 컴포넌트가 자기 경계에서 그 중복을 안전하게 흡수하도록 만드는 것입니다.

마무리하며

이번 글에서 제가 정리한 설계 원칙은 크게 세 가지입니다.

첫째, 처리와 완료 기록 사이의 실패 구간까지 함께 설계해야 합니다. 멱등성 키를 기록하는 것만으로는 충분하지 않고, 실제 상태 변경과 기록을 가능한 한 같은 원자적 단위로 묶어야 합니다.

둘째, 모든 코드 경로는 자신의 비즈니스 의도에 맞는 멱등성 판단을 거쳐야 합니다. 정상 경로에서만 동작하는 멱등성은 멱등성이 아닙니다.

셋째, 멱등성 키는 실행이 아니라 의도에서 나와야 합니다. 같은 요청에 대해서는 항상 같은 키가, 다른 요청에 대해서는 항상 다른 키가 나오도록 설계해야 합니다.

분산 환경에서 개발할 때는 적어도 다음과 같은 질문에는 답할 수 있어야 합니다.

  • 내 컨슈머가 같은 이벤트를 두 번 받으면 어떤 일이 발생하는가?
  • 처리와 처리 완료 기록 사이에 중간 실패 구간은 없는가?
  • 모든 코드 경로가 멱등성 판단을 올바르게 거치는가?
  • 이 멱등성 키는 같은 요청에 대해 항상 같은 값을 반환하는가?

이 질문들 중 하나라도 답하기 어렵다면, 그 시스템은 중복 처리 사고에 취약할 가능성이 높습니다.

결국 분산 환경에서의 멱등성은 중복을 없애는 기술이라기보다, 중복이 발생하는 세계를 전제로 시스템을 안전하게 설계하는 방식에 더 가깝습니다.

참고 문서

이 글에서 사용한 기술적 설명과 일부 개념 프레이밍은 아래 문서를 참고했습니다. 문장과 코드는 글의 맥락에 맞게 재구성했습니다.


메타데이터
post_id
a3fac57671a5
slug
분산-환경에서-멱등성-보장하기-a3fac57671a5
url
https://monday9pm.com/%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%A9%B1%EB%93%B1%EC%84%B1-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0-a3fac57671a5
canonical_url
https://monday9pm.com/%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%A9%B1%EB%93%B1%EC%84%B1-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0-a3fac57671a5
author_url
https://medium.com/@eeeasycode
status
ok
fetched_at
2026-06-11 10:13:20