bluetape4k-workshop · Visual Companion
설계·구현 시각화 · Issue #538

이벤트 소싱 바우처 캠페인

`commerce/event-sourced-promotion-voucher-campaign`은 캠페인과 바우처의 모든 변경 이력을 PostgreSQL 이벤트 저장소에 순서대로 기록한다. 쿠폰을 단순 CRUD로 구현하면 동시 요청, 재시도, 조회 모델 반영 지연, 운영 중 복구를 제대로 처리하기 어렵다. 이 예제는 명령 처리와 조회 모델 갱신을 분리해 이러한 문제를 해결하는 방법을 코드로 보여준다.

Spring Boot 4 MVC Java 25 가상 스레드 PostgreSQL 이벤트 저장소 예상 버전 + 멱등성 Fencing 기반 프로젝션 재구축
명령 처리 멱등성 확인, 애그리거트 복원, 예상 버전 검사, 응답 결과 저장
상태 변경의 최종 기록 스트림 헤드 잠금, 전역 순번을 예약하는 append fence, 수정불가한 이벤트
조회 모델 갱신 리스, 펜싱 토큰(fencing token), 중복 방지, 체크포인트, 조회 모델
장애 복구 실패 이벤트 재처리, 세대별 재구축, 결과 검증, 활성 세대 교체

예제 구상

하나의 프로모션 캠페인에서 바우처를 발급하고 사용하고 회수하면서, 실제 운영에서 부딪히는 네 가지 문제를 해결한다.

01

동시성 충돌

같은 캠페인과 바우처에 할당(claim), 사용(redeem), 회수(release) 요청이 겹쳐도 처리 순서를 하나로 확정해야 한다.

02

응답 유실 후 재시도

응답을 받지 못한 클라이언트가 같은 요청을 다시 보내도 이벤트를 중복 저장하지 않고 이전 결과를 반환해야 한다.

03

프로젝션 지연

쓰기 작업이 끝나도 조회 모델 반영은 지연될 수 있다. API는 현재 처리 위치와 지연 정도를 응답에 담아야 한다.

04

무중단 복구

처리 불가 이벤트, 프로젝터 재시작, 전체 재구축, 개인정보 삭제를 서비스 중단 없이 처리해야 한다.

캠페인 생성·활성화예산, 기간, 발급 정책을 이벤트 스트림에 기록한다.
바우처 할당·사용·회수업무 규칙과 예상 버전을 검사한 뒤 상태를 변경한다.
조회·감사·복구조회와 운영 기능도 같은 이벤트 기록을 기준으로 동작한다.
“할인 쿠폰”은 익숙한 업무 개념이지만, 실제 시스템에서는 낙관적 동시성 제어, 멱등성, 최종적 일관성, 프로젝션 재구축, 개인정보 삭제를 함께 고려해야 한다. 이 예제는 각 기술 요소가 하나의 업무 흐름에서 어떻게 연결되는지 보여준다.

설계 방향

여러 테이블에 동시에 입력해 일관성을 맞추는 방식 대신, 모든 상태 변경을 이벤트로 먼저 기록한다. 현재 상태와 조회 모델은 이 이벤트 스트림에서 계산한다.

단순 CRUD 방식의 한계

동시 갱신나중에 저장된 값이 앞서 처리한 상태 변경을 덮어쓴다.
이중 쓰기업무 상태와 조회 테이블 중 한쪽만 저장될 수 있다.
비멱등 재시도응답이 유실되면 같은 명령을 두 번 처리할 수 있다.
이력 직접 수정감사 이력을 보존하면서 개인정보만 삭제하기 어렵다.

설계 원칙

이벤트 우선 기록PostgreSQL 이벤트 스트림을 상태 변경의 최종 기록으로 사용한다.
원자적 저장이벤트와 멱등성 처리 결과를 같은 트랜잭션에서 저장한다.
비동기 프로젝션조회 모델은 이벤트로 재구성할 수 있으며 현재 반영 위치를 함께 제공한다.
세대별 재구축새 프로젝션 세대를 별도로 구축하고 검증을 통과한 뒤 활성 세대로 교체한다.
민감정보 분리수정불가한 이벤트에는 원본 사용자 정보와 바우처 코드를 남기지 않는다.

구현 방향

업무 규칙과 저장 메커니즘을 분리하되, 트랜잭션 경계와 이벤트 처리 위치는 코드에 명시한다.

도메인

애그리거트와 리듀서

이벤트를 재생해 현재 상태를 복원하고, 명령을 검사해 새로 기록할 이벤트를 결정한다.

응용 계층

명령 처리 서비스

멱등성 확인, 상태 복원, 업무 판단, 이벤트 저장, 최종 응답 저장을 한 유스케이스에서 순서대로 처리한다.

저장 계층

PostgreSQL 이벤트 저장소

스트림 잠금, `append fence`, 이벤트 본문, 전역 처리 위치를 한 트랜잭션에서 기록한다.

조회 모델

리스와 체크포인트

저장된 이벤트를 묶음으로 읽고, 중복 제거와 조회 모델 갱신, 체크포인트 이동을 한 트랜잭션에서 처리한다.

운영

복구와 관측성

실패 이벤트 재처리와 정합성 검사, 세대별 재구축 기능을 제공하고 커넥션 풀 사용량과 처리 지연을 측정한다.

스냅숏은 이벤트 재생 비용을 줄이는 최적화 수단이다. 메타데이터 검증에 실패했거나 HMAC 키 버전을 사용할 수 없는 스냅숏은 폐기하고 전체 이벤트를 재생한다. 스냅숏은 최종 데이터가 아니다.

예제 실행과 확인

먼저 Testcontainers 통합 테스트로 핵심 동작을 검증한다. 전체 흐름은 애플리케이션을 PostgreSQL에 연결하고 HTTP 변경 요청과 조회 요청을 차례로 보내 확인한다.

통합 테스트 실행

로컬 PostgreSQL을 따로 구성하지 않아도 명령 처리, 멱등성, 조회 모델 갱신, 장애 복구 동작을 검증할 수 있다.

./gradlew \ :commerce-event-sourced-promotion-voucher-campaign:integrationTest \ --console=plain

애플리케이션 실행

PostgreSQL 데이터소스와 HMAC 비밀 키를 환경 변수로 설정한다. 예제에 포함된 기본 키는 로컬 실행용이며 운영 환경에서 사용해서는 안 된다.

export SPRING_DATASOURCE_URL='jdbc:postgresql://localhost:5432/<database>' export SPRING_DATASOURCE_USERNAME='<username>' export SPRING_DATASOURCE_PASSWORD='<password>' export VOUCHER_HMAC_ACTIVE_VERSION=2 export VOUCHER_HMAC_ACTIVE_KEY_BASE64='<base64-secret>' ./gradlew :commerce-event-sourced-promotion-voucher-campaign:bootRun
모든 API 요청에는 `X-Workshop-Tenant`와 `X-Workshop-Principal` 헤더가 필요하다. 상태를 바꾸는 요청에는 `Idempotency-Key`도 포함한다. `/operator/**` API는 localhost에서 호출해야 하며, `X-Workshop-Operator-Secret`, `X-Workshop-Guard`, `X-Workshop-Operator-Role: OPERATOR` 헤더를 추가로 요구한다.
01 · 생성

캠페인 생성

운영자 API에 공통 인증 헤더, 멱등성 키, `If-None-Match: *`를 넣어 새 캠페인을 만든다.

POST /operator/api/v1/campaigns
{ campaignId, startsAt, endsAt, capacity, perUserLimit, redemptionTtlSeconds }
02 · 활성화

캠페인 활성화

생성 응답의 `revision`을 `expectedRevision`으로 보내 다른 요청이 먼저 상태를 바꿨는지 확인한다.

POST /operator/api/v1/campaigns/{campaignId}/activate
{ "expectedRevision": 0 }
03 · 할당

바우처 할당

사용자 참조를 HMAC 기반 대체 식별자로 변환하고, 애그리거트에서 전체 발급 한도와 사용자별 한도를 검사한다.

POST /api/v1/campaigns/{campaignId}/claims
{ "userRef": "member-42" }
04 · 조회

조회 모델 확인

변경 요청 응답의 스트림 위치를 최소 반영 위치로 보낸다. 직전 변경 사항을 조회 모델에서 확인하기 위한 조건이다.

GET /api/v1/campaigns/{campaignId}
X-Min-Stream-Position: {position}
05 · 복구

복구 상태 확인

조회 모델 반영이 지연되면 GET만 재시도한다. 조회 모델 전체가 이벤트 기록과 어긋났으면 운영자 API로 재구축하고 정합성을 검사한다.

POST /operator/api/v1/projections/{projection}/rebuilds
GET .../rebuilds/{generation}
명령 처리 결과`Idempotency-Replayed`와 `X-Stream-Position`으로 새 요청을 처리했는지 이전 결과를 반환했는지 확인한다.
조회 결과`200`이면 최신 상태다. `202`이면 `Retry-After`와 `X-Projection-Lag`를 확인하고 GET 요청만 다시 보낸다.
운영 상태`/actuator/health`, Prometheus의 처리 지연·실패 이벤트·커넥션 풀 사용량 메트릭, 재구축 상태와 감사 기록을 함께 확인한다.

실제 코드의 처리 흐름

명령 처리, 조회 모델 갱신, 재구축, 보안·운영 기능을 분리해 구현했다. 모든 상태 변경은 PostgreSQL 이벤트 기록을 거친다.

명령 처리와 조회 모델의 시간 차

변경 요청이 성공하면 이벤트와 멱등성 처리 결과가 먼저 저장된다. 조회 모델은 프로젝션 체크포인트가 해당 이벤트까지 이동한 뒤에 최신 상태가 된다.

이벤트 스트림

조회 모델의 최신 상태

조회 모델의 처리가 지연되면 `202 PROJECTION_PENDING`을 반환한다.

S X-Stream-Position 0
P X-Projection-Position 0
L X-Projection-Lag 0

실제 구현된 모습

다음 Kotlin 클래스는 앞에서 설명한 트랜잭션 범위와 상태 변경 규칙을 구현한다.

EventStoreRepository.kt

이벤트 저장

  • `StreamHeads`를 먼저 잠그고 예상 스트림 버전이 현재 값과 같은지 검사한다.
  • `AppendFences`로 커밋된 이벤트에 빈 구간이 없는 전역 순번을 부여한다.
  • 이벤트 추가와 스트림 헤드 갱신을 같은 트랜잭션에서 처리한다.
EventSourcedCommandService.kt

명령의 멱등성

  • 멱등성 키 선점과 최종 응답 저장을 명령 서비스의 트랜잭션 안에서 처리한다.
  • 요청 지문이 같으면 앞서 저장한 최종 응답을 그대로 반환한다.
  • 응답 복원에 필요한 키를 사용할 수 없으면 요청을 fail-closed 처리하고 `503`을 반환한다.
ProjectionWorker.kt

조회 모델 작업자

  • 프로젝션 리스는 소유자와 펜싱 토큰을 확인하며 갱신한다.
  • 이벤트 중복 제거, 조회 모델 갱신, 체크포인트 이동을 한 트랜잭션에서 처리한다.
  • 처리 불가 이벤트를 건너뛰지 않고 별도의 복구 대상으로 격리한다.
EventSourcedProjectionRuntime.kt

조회 모델 재구축

  • 새로 만든 조회 모델을 검증한 뒤에만 활성 세대로 전환한다.
  • 취소·재개와 유효하지 않은 작업자의 쓰기 차단에 리비전과 펜싱 토큰을 사용한다.
  • 현재 서비스 중인 조회 모델과 재구축 중인 조회 모델을 서로 독립적으로 운용한다.
CampaignProjectionQueryService.kt

조회 모델 반영 상태

이벤트 스트림 위치와 조회 모델 처리 위치를 함께 읽는다. 요청한 위치까지 반영되지 않았으면 설정된 대기 시간 동안 기다린 뒤 `202 PROJECTION_PENDING`과 지연 정보를 응답 헤더에 담는다.

SubjectIdentityRepository.kt · BoundedRehydrator.kt

개인정보 삭제와 상태 복원

원본 사용자 정보는 삭제할 수 있는 HMAC 기반 대체 식별자 매핑에 보관한다. 스냅숏은 유효성 검사를 통과한 경우에만 이벤트 재생 시작점으로 사용한다.

예제가 해결하는 기술적 문제

실패 상태를 API 응답과 운영 정보에 명시한다. 호출자는 응답 코드와 처리 위치로 상태를 판단하고, 운영자는 재시도와 재구축 절차로 복구할 수 있다.

동시 갱신 유실

동시에 들어온 요청이 서로의 변경을 덮어쓴다

예상 스트림 버전과 스트림 헤드 잠금으로 한 요청만 이벤트를 추가하고, 나머지는 충돌 응답을 반환한다.
중복 처리

응답 유실 뒤 재시도가 중복 적용된다

멱등성 처리 범위와 요청 지문, 최종 응답을 저장해 같은 요청에는 이전 결과를 반환한다.
갱신 전 조회 결과

변경 직후 조회하면 이전 상태가 보인다

이벤트와 조회 모델의 처리 위치, 지연 정도를 함께 제공하고 아직 반영되지 않은 조회에는 `202 PROJECTION_PENDING`을 반환한다.
프로젝터 장애

이벤트 중복 전달과 처리 실패로 체크포인트가 어긋난다

리스와 펜싱 토큰, 이벤트 중복 제거, 트랜잭션 체크포인트, 실패 이벤트 격리와 제한된 재시도로 복구한다.
안전하지 않은 재구축

검증하지 않은 조회 모델이 서비스에 노출된다

`BUILDING` 세대를 별도로 갱신하고 다이제스트 검증을 마친 뒤 CAS(Compare-and-Set)로 활성 세대를 전환한다.
이벤트에 남은 개인정보

수정불가한 이벤트 이력에 개인정보가 남는다

이벤트에는 HMAC 기반 대체 식별자만 남긴다. 원본 정보와 연결하는 매핑을 삭제하면 이벤트 이력을 다시 쓰지 않고도 원본 개인정보와의 연결을 제거할 수 있다.