동시성 충돌
같은 캠페인과 바우처에 할당(claim), 사용(redeem), 회수(release) 요청이 겹쳐도 처리 순서를 하나로 확정해야 한다.
`commerce/event-sourced-promotion-voucher-campaign`은 캠페인과 바우처의 모든 변경 이력을 PostgreSQL 이벤트 저장소에 순서대로 기록한다. 쿠폰을 단순 CRUD로 구현하면 동시 요청, 재시도, 조회 모델 반영 지연, 운영 중 복구를 제대로 처리하기 어렵다. 이 예제는 명령 처리와 조회 모델 갱신을 분리해 이러한 문제를 해결하는 방법을 코드로 보여준다.
하나의 프로모션 캠페인에서 바우처를 발급하고 사용하고 회수하면서, 실제 운영에서 부딪히는 네 가지 문제를 해결한다.
같은 캠페인과 바우처에 할당(claim), 사용(redeem), 회수(release) 요청이 겹쳐도 처리 순서를 하나로 확정해야 한다.
응답을 받지 못한 클라이언트가 같은 요청을 다시 보내도 이벤트를 중복 저장하지 않고 이전 결과를 반환해야 한다.
쓰기 작업이 끝나도 조회 모델 반영은 지연될 수 있다. API는 현재 처리 위치와 지연 정도를 응답에 담아야 한다.
처리 불가 이벤트, 프로젝터 재시작, 전체 재구축, 개인정보 삭제를 서비스 중단 없이 처리해야 한다.
여러 테이블에 동시에 입력해 일관성을 맞추는 방식 대신, 모든 상태 변경을 이벤트로 먼저 기록한다. 현재 상태와 조회 모델은 이 이벤트 스트림에서 계산한다.
업무 규칙과 저장 메커니즘을 분리하되, 트랜잭션 경계와 이벤트 처리 위치는 코드에 명시한다.
이벤트를 재생해 현재 상태를 복원하고, 명령을 검사해 새로 기록할 이벤트를 결정한다.
멱등성 확인, 상태 복원, 업무 판단, 이벤트 저장, 최종 응답 저장을 한 유스케이스에서 순서대로 처리한다.
스트림 잠금, `append fence`, 이벤트 본문, 전역 처리 위치를 한 트랜잭션에서 기록한다.
저장된 이벤트를 묶음으로 읽고, 중복 제거와 조회 모델 갱신, 체크포인트 이동을 한 트랜잭션에서 처리한다.
실패 이벤트 재처리와 정합성 검사, 세대별 재구축 기능을 제공하고 커넥션 풀 사용량과 처리 지연을 측정한다.
먼저 Testcontainers 통합 테스트로 핵심 동작을 검증한다. 전체 흐름은 애플리케이션을 PostgreSQL에 연결하고 HTTP 변경 요청과 조회 요청을 차례로 보내 확인한다.
로컬 PostgreSQL을 따로 구성하지 않아도 명령 처리, 멱등성, 조회 모델 갱신, 장애 복구 동작을 검증할 수 있다.
PostgreSQL 데이터소스와 HMAC 비밀 키를 환경 변수로 설정한다. 예제에 포함된 기본 키는 로컬 실행용이며 운영 환경에서 사용해서는 안 된다.
운영자 API에 공통 인증 헤더, 멱등성 키, `If-None-Match: *`를 넣어 새 캠페인을 만든다.
POST /operator/api/v1/campaigns
{ campaignId, startsAt, endsAt, capacity, perUserLimit, redemptionTtlSeconds }
생성 응답의 `revision`을 `expectedRevision`으로 보내 다른 요청이 먼저 상태를 바꿨는지 확인한다.
POST /operator/api/v1/campaigns/{campaignId}/activate
{ "expectedRevision": 0 }
사용자 참조를 HMAC 기반 대체 식별자로 변환하고, 애그리거트에서 전체 발급 한도와 사용자별 한도를 검사한다.
POST /api/v1/campaigns/{campaignId}/claims
{ "userRef": "member-42" }
변경 요청 응답의 스트림 위치를 최소 반영 위치로 보낸다. 직전 변경 사항을 조회 모델에서 확인하기 위한 조건이다.
GET /api/v1/campaigns/{campaignId}
X-Min-Stream-Position: {position}
조회 모델 반영이 지연되면 GET만 재시도한다. 조회 모델 전체가 이벤트 기록과 어긋났으면 운영자 API로 재구축하고 정합성을 검사한다.
POST /operator/api/v1/projections/{projection}/rebuilds
GET .../rebuilds/{generation}
명령 처리, 조회 모델 갱신, 재구축, 보안·운영 기능을 분리해 구현했다. 모든 상태 변경은 PostgreSQL 이벤트 기록을 거친다.
변경 요청이 성공하면 이벤트와 멱등성 처리 결과가 먼저 저장된다. 조회 모델은 프로젝션 체크포인트가 해당 이벤트까지 이동한 뒤에 최신 상태가 된다.
조회 모델의 처리가 지연되면 `202 PROJECTION_PENDING`을 반환한다.
다음 Kotlin 클래스는 앞에서 설명한 트랜잭션 범위와 상태 변경 규칙을 구현한다.
이벤트 스트림 위치와 조회 모델 처리 위치를 함께 읽는다. 요청한 위치까지 반영되지 않았으면 설정된 대기 시간 동안 기다린 뒤 `202 PROJECTION_PENDING`과 지연 정보를 응답 헤더에 담는다.
원본 사용자 정보는 삭제할 수 있는 HMAC 기반 대체 식별자 매핑에 보관한다. 스냅숏은 유효성 검사를 통과한 경우에만 이벤트 재생 시작점으로 사용한다.
실패 상태를 API 응답과 운영 정보에 명시한다. 호출자는 응답 코드와 처리 위치로 상태를 판단하고, 운영자는 재시도와 재구축 절차로 복구할 수 있다.