이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- DB update와 달리 외부 side effect는 왜 consumer 멱등성에서 가장 위험한 지점인가?
- 이메일, 포인트 지급, 외부 API 호출은 어떤 idempotency key와 발송 이력으로 보호해야 하는가?
- side effect를 DB transaction 안에서 바로 호출하면 어떤 실패 경계가 생기는가?
- outbox나 local state machine으로 side effect 중복을 줄이는 방법은 무엇인가?
개요
side effect는 consumer가 외부 세계에 남기는 결과다.
이메일 발송, 포인트 지급, 쿠폰 발급, 결제 취소 API 호출, push 알림이 대표적이다.
DB update는 transaction rollback으로 되돌릴 수 있지만 외부 side effect는 이미 나가면 되돌리기 어렵다.
consumer가 같은 message를 다시 받으면 side effect도 다시 실행될 수 있다.
그래서 side effect에는 별도의 idempotency가 필요하다.
위험한 코드
@Transactional
public void handle(OrderPaidEvent event) {
processedMessages.insert(event.eventId());
pointClient.givePoint(event.memberId(), event.amount() / 100);
settlementRepository.save(event.toSettlement());
}이 코드는 여러 경계가 섞여 있다.
외부 API 호출은 DB transaction과 함께 rollback되지 않는다.
포인트 API는 성공했지만 DB transaction이 rollback될 수 있다.
반대로 DB commit 후 ack 전에 죽으면 같은 event가 다시 들어와 포인트를 또 지급할 수 있다.
Provider Idempotency Key
외부 provider가 idempotency key를 지원하면 반드시 사용한다.
Idempotency-Key: settlement-service:evt-order-paid-10042같은 key로 같은 요청이 다시 오면 provider가 이전 결과를 반환하거나 중복 실행을 막는다.
key는 event id와 side effect 종류를 포함해야 한다.
같은 결제 event에서 포인트 지급과 이메일 발송이 둘 다 있다면 서로 다른 key가 필요하다.
point:evt-order-paid-10042
email:evt-order-paid-10042Local Side Effect Log
provider가 idempotency key를 지원하지 않으면 local log가 필요하다.
CREATE TABLE side_effect_requests (
side_effect_key varchar(120) PRIMARY KEY,
effect_type varchar(60) NOT NULL,
status varchar(20) NOT NULL,
request_payload jsonb NOT NULL,
response_payload jsonb,
last_error text,
created_at timestamp NOT NULL DEFAULT now(),
completed_at timestamp
);consumer는 side effect 요청을 먼저 기록하고 worker가 실행하게 할 수 있다.
이 구조는 side effect outbox와 비슷하다.
외부 호출 성공 여부와 재시도 상태를 DB에 남겨 중복을 줄인다.
Side Effect Outbox
consumer가 business DB를 갱신한 뒤 side effect outbox row를 저장한다.
consumer transaction
-> processed message insert
-> business update
-> side_effect_outbox insert
commit
side effect worker
-> pending side effect read
-> external API call with idempotency key
-> status update이렇게 하면 consumer ack와 외부 API 호출을 분리할 수 있다.
외부 API 장애가 consumer lag를 계속 키우는 것도 줄일 수 있다.
하지만 side effect worker도 at-least-once로 동작하므로 idempotency key는 여전히 필요하다.
이메일과 알림
이메일은 중복 발송이 사용자에게 바로 보인다.
발송 이력 key를 만들어야 한다.
email_type: ORDER_PAID
recipient: user-100
event_id: evt-order-paid-10042같은 key의 발송 완료 이력이 있으면 다시 보내지 않는다.
단, 재발송이 필요한 운영 상황도 있으므로 force resend는 별도 runbook과 감사 로그를 둔다.
포인트와 금액
포인트나 금액 side effect는 더 엄격하다.
ledger table을 append-only로 두고 business unique key를 잡는다.
CREATE UNIQUE INDEX uk_point_ledger_event
ON point_ledger (event_id, effect_type);잔액을 직접 더하는 방식보다 ledger entry를 하나만 남기고 잔액을 계산하거나 갱신하는 방식이 중복에 강하다.
금액 관련 side effect는 duplicate side effect count가 0이어야 한다.
운영 지표
- side effect request count: 외부 실행 요청 수다.
- side effect duplicate skip count: 중복으로 건너뛴 수다.
- provider idempotency conflict count: provider에서 중복 key를 본 수다.
- side effect failed count: 최종 실패 수다.
- pending side effect age: 오래 대기 중인 요청 시간이다.
- force resend count: 운영자가 강제 재발송한 수다.
side effect 지표는 consumer lag와 별도로 본다.
consumer는 정상이어도 side effect worker가 막히면 사용자 결과는 늦어진다.
개인 프로젝트 기준
개인 프로젝트에서는 이메일 대신 fake provider를 둔다.
같은 idempotency key로 두 번 호출하면 한 번만 처리되게 만든다.
consumer가 같은 event를 두 번 받아도 side effect log가 하나인지 확인한다.
force resend command를 만든다면 operator와 reason을 남긴다.
위험 신호!
- 외부 API를 DB transaction 안에서 바로 호출하고 실패 경계를 설명하지 않는다.
- provider idempotency key가 없다.
- 이메일 발송 이력이 없다.
- 포인트 지급이 단순 balance 증가로만 구현되어 있다.
- 강제 재발송이 감사 로그 없이 가능하다.
확인 질문
확인 질문
- 이 side effect는 같은 event가 다시 들어오면 어떤 key로 중복을 막는가?
- event id와 effect type이 포함된 idempotency key가 필요하다.
- 외부 API 성공 후 local DB update가 실패하면 어떻게 복구하는가?
- side effect log나 provider 조회로 상태를 재동기화해야 한다.
- side effect를 다시 보내야 할 때 일반 중복 방지와 어떻게 구분하는가?
- force resend runbook과 감사 로그가 있어야 한다.