Software Architecture/Distributed Systems

Distributed Systems - 08. 멱등성·정확히 한 번

멱등성과 정확히 한 번 — "exactly-once" 함정 파헤치기

2020년 한 결제 시스템에서 같은 결제가 두 번 처리된 사고가 터졌다. 사용자는 한 번 결제했는데, 계좌에서 두 번 빠져나갔다. 조사해보니 — 네트워크 지연으로 결제 서비스가 응답을 못 받은 클라이언트가 재시도했고, 결제 서비스는 같은 요청을 두 번 처리했다. "이런 건 왜 생기나. exactly-once 보장하면 되지 않나?"라는 질문이 자연스럽다. 이 질문에 대한 답이 이 글의 핵심이다. 결론부터 말하면 — end-to-end exactly-once는 매우 어렵고, 대부분 at-least-once + 멱등성으로 달성한다. "exactly-once"를 마케팅 문구처럼 쓰는 시스템은 사실 이 조합을 숨기는 것이다.

비유로 감 잡기 — 우편물 등록부

우체부가 편지를 배달하는데, 가끔 같은 편지를 두 번 배달한다고 하자 (at-least-once). 받는 쪽이 "이미 받은 편지"를 또 처리하면 안 된다 — 청구서를 두 번 내는 식의 문제가 생긴다. 이를 막으려면 — 받는 쪽이 "어떤 편지를 이미 받았나"를 적어둔 등록부를 둬서, 같은 편지가 오면 무시한다.

이 등록부가 멱등키(idempotency key)다. 송신자가 각 요청에 고유 ID를 붙이고, 수신자는 그 ID를 기억해서 같은 ID가 오면 이전 결과를 반환한다. 편지가 한 번 배달되든 두 번 배달되든, 결과적으로 한 번 처리된다. 이게 "exactly-once를 at-least-once 위에 올리는" 패턴이다.

비유의 한계 — 우편물 비유는 '송신자와 수신자' 둘만 다루지만, 실제 시스템은 클라이언트, 메시지 브로커, 여러 서비스, DB를 거치는 긴 사슬이다. 등록부도 어디에 둘지가 문제다 — 각 단계마다 두면 비용이 크다. 비유는 '사슬의 길이'에서 무너진다.

메시지 전달의 세 가지 보장

분산 시스템 담론에서 메시지 전달 보장은 세 가지로 나뉜다.

  • at-most-once — 메시지가 한 번 전달되거나 아예 안 전달됨. 손실 가능. UDP가 이 형태.
  • at-least-once — 메시지는 반드시 전달되지만, 두 번 이상 전달될 수 있음. TCP, Kafka 기본 모드, SQS.
  • exactly-once — 메시지가 정확히 한 번만 전달됨. 가장 강한 보장이지만 — 구현이 매우 어렵고, end-to-end로는 거의 불가능.

이 세 가지 사이에 "stronger is always better"는 아니다. 강한 보장은 비용이 크다. at-most-once는 손실을 감수하지만 빠르고 단순하다 (예: 메트릭 전송 — 일부 손실은 괜찮다). at-least-once는 손실은 막지만 중복을 다뤄야 한다. exactly-once는 중복까지 막지만 — 구현 비용이 크고, end-to-end로는 한계가 있다.

"exactly-once" 함정 — 어디서부터 어디까지인가

"exactly-once"의 가장 흔한 혼동 — 어디서부터 어디까지의 exactly-once인가.

flowchart LR
    P[Producer] -->|1| B[Broker]
    B -->|2| C1[Consumer 1]
    B -->|3| C2[Consumer 2]
    C1 -->|4| DB1[(DB 1)]
    C2 -->|5| DB2[(DB 2)]
    DB2 -.-|외부 API| EXT[External Service]

Kafka가 "exactly-once"를 지원한다고 한다. 그 의미는 — Kafka 안에서 (Producer → Broker → Consumer까지) 메시지가 정확히 한 번 전달된다. Consumer가 밖으로 내보내는 순간 (DB 쓰기, 외부 API 호출)부터는 Kafka의 보장이 닿지 않는다.

  • Producer가 메시지를 보내고 ack를 못 받아 재전송 → Broker는 중복을 없앰 (transactional producer).
  • Consumer가 메시지를 처리하고 offset을 커밋하기 전에 죽음 → 재시작 시 같은 메시지를 또 받음. Kafka의 exactly-once는 "메시지를 받아서 Kafka에 다시 쓰는(offset 커밋 같은) 작업"을 원자적으로 묶는 것.
  • Consumer가 메시지를 받아서 외부 DB에 쓰는데 DB 쓰기 직전에 죽음 → Kafka는 몰라. 재시작하면 같은 메시지를 또 받아서 DB에 또 쓴다. 이건 end-to-end로는 at-least-once다.

그래서 — Kafka의 "exactly-once"는 Kafka 클러스터 안의 처리에 한정된다. 밖으로 나가는 모든 작업(DB, 외부 API)에 대해 exactly-once를 원하면, 사용자가 직접 멱등성을 구현해야 한다. Pat Helland이 2012년 논문에서 "exactly-once는 시스템 경계에서 끊긴다"고 지적한 게 이 현상이다.

멱등성 — exactly-once를 사실상 달성하는 패턴

멱등성(idempotency)은 — 같은 연산을 여러 번 해도 결과가 한 번 한 것과 같은 성질. f(f(x)) = f(x). HTTP GET, PUT(같은 값으로), DELETE(이미 삭제된 것 또 삭제)가 멱등이다. POST는 멱등이 아니다 — 두 번 부르면 두 번 생성될 수 있다.

멱등성을 쓰면 — at-least-once 메시지 전달 위에서 사용자 관점에서 exactly-once를 달성할 수 있다. 중복 메시지가 와도 결과는 같으니까.

// 안티패턴 — 멱등키 없는 결제
class BadPaymentService {
    void charge(PaymentRequest req) {
        // 매번 새 결제 생성 — 중복 요청 시 두 번 결제
        Payment p = new Payment(req.amount(), req.accountId());
        p.process();
        save(p);
    }
}

// 개선 — 멱등키로 중복 감지
class IdempotentPaymentService {
    private final IdempotencyKeyStore keyStore;

    Payment charge(PaymentRequest req) {
        String idempotencyKey = req.idempotencyKey();

        // 이미 처리한 키인지 확인
        Optional<Payment> existing = keyStore.find(idempotencyKey);
        if (existing.isPresent()) {
            return existing.get();  // 같은 결과 반환 — 멱등
        }

        // 새 결제 처리
        Payment p = new Payment(req.amount(), req.accountId());
        p.process();
        save(p);

        // 키 저장 (다음 중복 요청 시 같은 결과 반환)
        keyStore.save(idempotencyKey, p);
        return p;
    }
}

이 패턴에서 핵심 — 키 저장과 비즈니스 처리가 원자적이어야 한다. 그렇지 않으면 둘 사이에 크래시가 났을 때 문제가 생긴다.

설계 사례 — 멱등 결제의 안전한 구현

1단계 — 단순 키 저장 (원자성 부재)

// 안티패턴 — 처리와 키 저장이 분리
class UnsafeIdempotentPayment {
    Payment charge(PaymentRequest req) {
        if (keyStore.exists(req.idempotencyKey())) {
            return keyStore.find(req.idempotencyKey()).get();
        }
        Payment p = process(req);
        save(p);
        // 크래시 발생 가능 지점 1: 처리 후 저장 전
        keyStore.save(req.idempotencyKey(), p);
        // 크래시 발생 가능 지점 2: 키 저장 전
        return p;
    }
}

이 코드엔 두 가지 크래시 지점이 있다. 지점 1에서 크래시 나면 — 결제는 처리됐는데 키가 안 저장됐다. 재시도하면 같은 키로 또 결제 처리 → 중복 결제. 지점 2도 마찬가지.

2단계 — 한 트랜잭션에 묶기

// 개선 — 처리와 키 저장을 한 DB 트랜잭션에
@Transactional
class SafeIdempotentPayment {
    Payment charge(PaymentRequest req) {
        // DB에 이미 같은 키가 있는지 확인 (FOR UPDATE로 잠금)
        Optional<PaymentRecord> existing = repo.findByKeyForUpdate(req.idempotencyKey());
        if (existing.isPresent()) {
            return existing.get().toPayment();
        }

        // 새 결제 처리 — 같은 트랜잭션
        Payment p = processExternal(req);
        PaymentRecord record = new PaymentRecord(req.idempotencyKey(), p);
        repo.save(record);
        // 트랜잭션 커밋 — 결제 기록과 키가 원자적으로 저장
        return p;
    }
}

이제 — 결제 처리(외부 API 호출)와 DB 저장이 같은 트랜잭션에 있다. 외부 API 호출이 실패하면 트랜잭션도 롤백. 크래시가 나도 DB엔 결제+키가 같이 있거나 같이 없다.

하지만 한계가 있다 — 외부 API 호출(네트워크)이 트랜잭션 안에 있어서 트랜잭션이 오래 열려 있다. DB 커넥션 고갈 위험.

3단계 — 키 선행 + 상태 기계

// 더 안전 — 키를 먼저 저장(PENDING), 처리 후 COMPLETED로 업데이트
class RobustIdempotentPayment {
    @Transactional
    Payment charge(PaymentRequest req) {
        // 1. 키 선행 저장 (PENDING 상태) — 같은 키가 이미 있으면 상태 확인
        PaymentRecord record = repo.findByKey(req.idempotencyKey())
            .orElseGet(() -> {
                PaymentRecord r = new PaymentRecord(req.idempotencyKey(), Status.PENDING);
                return repo.save(r);  // 트랜잭션 밖에서 처리될 수도
            });

        // 2. 이미 완료된 건이면 그 결과 반환 (멱등)
        if (record.status() == Status.COMPLETED) {
            return record.toPayment();
        }

        // 3. PENDING이면 처리 시도
        try {
            Payment p = processExternal(req);
            record.markCompleted(p);
            repo.save(record);
            return p;
        } catch (Exception e) {
            record.markFailed();
            repo.save(record);
            throw e;
        }
    }
}

이 패턴에선 — 키가 PENDING인 동안 같은 키로 들어온 또 다른 요청은 PENDING 상태를 보고 "처리 중"을 알 수 있다. 잠금을 잡거나, 대기하거나, 거부할 수 있다. 처리 중 크래시가 나면 PENDING인 레코드를 나중에 정리하는 배잡이 필요하다.

Stripe, Square 같은 결제 API가 이 패턴을 쓴다 — Idempotency-Key 헤더로 클라이언트가 키를 보내면, 서버가 그 키로 정확히 한 번만 처리를 보장한다.

멱등성 위반/오용 감지

신호 의미
POST 요청에 멱등키 없음 중복 시도 시 두 번 처리
멱등키 저장과 비즈니스 처리가 분리 크래시 시 중복 처리 위험
멱등키에 TTL이 없음 영원히 커지는 키 저장소
외부 API 호출이 트랜잭션 안에 DB 커넥션 장시간 점유
PENDING 상태 정리 작업 없음 크래시 후 영원히 PENDING
클라이언트가 키를 재사용 다른 요청이 같은 결과 반환 — 버그

멱등 키 설계 — 실용적 고려

멱등키를 어디서 만드나. 클라이언트가 만드는 게 일반적 — 서버가 만들면 재시도 시 다른 키가 돼서 의미가 없다.

// 클라이언트 코드
class PaymentClient {
    Payment pay(AccountId account, Money amount) {
        String key = "pay-" + account.value() + "-" + UUID.randomUUID();
        // 같은 요청을 재시도할 때 같은 key 사용
        return api.charge(new PaymentRequest(account, amount, key));
    }
}

주의할 점 — 클라이언트가 같은 key로 정말 다른 결제를 시도하면 안 된다. 예: 같은 key로 두 번 클릭했는데 첫 번째가 실패해서 두 번째를 다른 금액으로 보내면, 서버는 첫 번째 금액을 반환할 수 있다. 키와 요청 내용을 짝지어 저장하고, 키 재사용 시 같은 내용인지 확인해야 한다.

// 키 재사용 시 요청 일치 확인
@Transactional
Payment charge(PaymentRequest req) {
    Optional<PaymentRecord> existing = repo.findByKey(req.idempotencyKey());
    if (existing.isPresent()) {
        if (!existing.get().matches(req)) {
            throw new ConflictException("같은 키로 다른 요청이 들어옴");
        }
        return existing.get().toPayment();
    }
    // ... 처리
}

at-least-once의 보편성 — 왜 exactly-once가 아닌가

분산 시스템이 at-least-once를 기본으로 삼는 이유 — 그게 구현 가능한 가장 강한 보장이기 때문. 네트워크가 신뢰할 수 없고(01편), 한 노드가 언제든 죽을 수 있는 환경에서, "메시지가 한 번만 갔다"를 보장하려면 — 보냈다는 확인과 처리했다는 확인이 모두 원자적이어야 한다. 이건 사실상 합의(04편)와 같은 비용이다.

그래서 실용적 접근은 at-least-once를 기본으로, 처리 쪽에서 멱등으로 만드는 것이다. 이 조합은 end-to-end exactly-once와 사실상 동등하다 — 사용자 관점에선 중복 효과가 안 보이므로. Kafka의 "exactly-once"도 사실 이 조합(idempotent producer + transactional consumer)을 가리킨다.

설계 사례 — 메시지 처리 파이프라인의 멱등

// Kafka consumer + DB 쓰기의 멱등 설계
class OrderEventConsumer {
    private final IdempotencyStore store;

    @KafkaListener(topics = "order-events")
    void handle(OrderEvent event, Acknowledgment ack) {
        String key = event.idempotencyKey();  // Producer가 부여한 고유 ID

        // 1. 이미 처리한 이벤트인지 확인
        if (store.alreadyProcessed(key)) {
            ack.acknowledge();  // 메시지 무시, ack만 보냄
            return;
        }

        // 2. 비즈니스 처리
        try {
            processOrder(event);
            store.markProcessed(key);  // 처리 완료 표시
            ack.acknowledge();
        } catch (Exception e) {
            // ack 안 보냄 — Kafka가 재전송
            // 재시작 시 같은 이벤트 또 받음 — 멱등이니 OK
        }
    }
}

이 패턴의 핵심 — store.markProcessed(key)ack.acknowledge()둘 다 성공해야 일관성이 유지된다. 하나라도 실패하면, 재시도 시 같은 이벤트를 또 받는다. 멱등 처리니까 결과는 같다. 트랜잭션 outbox 패턴(07편에서 깊이 다룸)이 이 일관성을 보장하는 방법이다.

다음으로 — 분산 트랜잭션

멱등성이 단일 메시지 처리의 중복 제거라면, 분산 트랜잭션(07편)은 여러 단계에 걸친 원자적 변경을 다룬다. 주문→결제→재고 감소가 모두 성공하거나 모두 실패해야 하는 흐름 — 이걸 분산 환경에서 어떻게 보장할까. 2PC의 블로킹 한계, Saga의 보상 패턴, outbox가 멱등성과 짝해 작동하는 형태가 다음 주제다. 멱등성은 분산 트랜잭션 안전성의 기본기다.

멱등성의 핵심 통찰 — "정확히 한 번"은 마케팅 문구일 때가 많다. 실제 분산 시스템은 at-least-once 위에 멱등성을 올려 end-to-end exactly-once를 흉내낸다. 이 점을 모르고 "시스템이 exactly-once니까 안전하다"고 믿으면 — 어느 날 중복 결제 사고를 만난다. 시스템의 보장이 어디서부터 어디까지인지, 그 사이에 내가 채워야 할 gap이 어딘지를 아는 게 분산 시스템 엔지니어의 기본기다.


참고