이 글은 실제 회사 코드를 그대로 공개하지 않고, 문제와 설계 판단을 일반화한 기록입니다.
문제 상황
이벤트 도메인은 보기보다 상태가 많습니다. 이벤트 이름, 가격, 옵션, 이미지, 노출 대상, 제휴사 조건, 전문가 연결, 할인 정책이 함께 움직입니다. 여기에 입점업체가 직접 이벤트를 생성하거나 수정할 수 있어야 한다는 요구가 붙으면 더 조심해야 합니다.
가장 중요한 제약은 단순했습니다.
- 입점업체는 이벤트 생성을 요청할 수 있어야 합니다.
- 입점업체는 기존 이벤트 수정을 요청할 수 있어야 합니다.
- 운영관리자 승인 전에는 실제 이벤트가 바뀌면 안 됩니다.
- 반려와 재요청 이력이 하나의 업무 흐름으로 연결되어야 합니다.
- 최초 생성 요청에는 아직 실제
eventId가 없습니다.
단순히 event row를 먼저 만들고 상태만 PENDING으로 두는 방법도 있었지만, 이 방식은 실제 운영 데이터와 검수 데이터를 강하게 섞습니다. 승인되지 않은 요청이 실제 이벤트 테이블에 들어오면, 조회 조건이나 운영자 실수에 따라 노출/계산/연결 로직에 영향을 줄 가능성이 생깁니다.
그래서 검수 요청과 실제 도메인 변경을 분리했습니다.
핵심 설계
검수 요청은 실제 Event를 바로 수정하지 않고 별도의 approval aggregate에 저장합니다.
Vendor request
-> EventApproval
-> EventApprovalItem.data JSONB
-> Admin approve
-> Create or update Event
중요한 판단은 검수 입력값을 JSONB snapshot으로 저장한 점입니다.
검수 당시 입점업체가 제출한 값은 그 자체로 하나의 기록입니다. 이후 이벤트 도메인 모델이 바뀌더라도, “그때 어떤 요청이 들어왔는가”는 사라지면 안 됩니다. 그래서 EventApprovalDataV1처럼 version을 가진 snapshot을 두고, 승인 시점에만 실제 Event 생성/수정 command로 변환했습니다.
eventId가 없는 생성 요청
최초 생성 검수에는 아직 eventId가 없습니다. 반면 수정 검수에는 이미 eventId가 있습니다. 이 둘을 단순히 eventId 기준으로 묶으면 생성 요청, 반려, 재요청, 승인 이후 수정 요청을 하나의 흐름으로 추적하기 어렵습니다.
이를 위해 approvalGroupId를 도입했습니다.
approvalGroupId는 실제 이벤트 ID가 생기기 전부터 존재하는 업무 흐름 ID입니다. 최초 생성, 반려, 재요청, 승인, 이후 수정 요청까지 같은 검수 흐름을 이어갈 수 있습니다.
approvalGroupId = A
1. 생성 요청 eventId = null
2. 반려 eventId = null
3. 재요청 eventId = null
4. 승인 eventId = 123 생성
5. 수정 요청 eventId = 123
이렇게 하면 아직 eventId가 없는 상태와 승인 이후 eventId가 생긴 상태를 같은 workflow로 다룰 수 있습니다.
변경 필드 비교
운영관리자에게 중요한 것은 “전체 데이터”보다 “무엇이 바뀌었는가”입니다. 그래서 이전 승인본과 현재 요청 snapshot을 비교해 가격, 옵션, 이미지, 전문가, 기본 정보 변경점을 보여주는 비교 로직을 두었습니다.
이 비교는 단순 편의 기능이 아니라 운영 리스크를 줄이는 기능입니다.
- 가격 변경을 놓치지 않습니다.
- 옵션 추가/삭제를 빠르게 봅니다.
- 이미지 교체 여부를 확인합니다.
- 전문가 연결 변경을 검수합니다.
- 반려 후 재요청에서 어떤 부분이 수정됐는지 확인합니다.
검수 화면은 결국 의사결정 도구이기 때문에, 백엔드도 단순 저장이 아니라 운영자가 판단할 수 있는 데이터를 만들어줘야 합니다.
동시성 처리
수정 요청에서는 동시성 문제가 생깁니다. 특히 생성 검수가 승인되는 순간 eventId가 새로 생기기 때문에, 같은 approvalGroupId를 두고 여러 요청이 들어오면 “방금 전까지는 eventId가 없었는데 지금은 있는” 상태가 됩니다.
이를 막기 위해 decision lock과 actual processing lock을 분리했습니다.
1. approvalGroupId 기준 decision lock 획득
2. 최신 approval 상태 재조회
3. eventId 존재 여부 확인
4. eventId 또는 approvalGroupId 기준 actual lock 선택
5. 검수 요청 생성/수정 처리
락을 잡기 전의 오래된 상태를 믿지 않고, 락 안에서 최신 상태를 다시 읽는 것이 중요했습니다. 이 방식으로 중복 요청과 이중 승인에 가까운 race condition을 줄일 수 있었습니다.
결과
이 설계의 핵심 효과는 실제 Event를 보호한 것입니다.
입점업체는 자유롭게 이벤트 생성/수정을 요청할 수 있지만, 운영관리자 승인 전에는 실제 이벤트가 바뀌지 않습니다. 운영관리자는 변경점, 반려 사유, 재요청 이력을 추적할 수 있고, 승인 시점에만 snapshot이 실제 도메인으로 반영됩니다.
사용률이 높지 않은 초기 운영 단계에서도 이런 구조는 의미가 있습니다. 트래픽 숫자보다 중요한 것은, 나중에 사용량이 늘었을 때 사고로 이어질 수 있는 데이터 오염 경로를 미리 제거하는 일이기 때문입니다.
더 개선한다면
다음 단계에서는 상태 전이를 명시적인 state machine으로 분리할 수 있습니다.
REQUESTED -> IN_PROGRESS -> APPROVEDREQUESTED -> IN_PROGRESS -> REJECTEDREJECTED -> REQUESTED
허용 가능한 전이를 한 곳에 모으고, DB constraint나 optimistic locking을 함께 두면 운영자가 많아지는 상황에서도 더 강한 정합성을 가져갈 수 있습니다.