이벤트 도메인에서 가장 오래 고민했던 부분은 상품과 조건의 경계였습니다.
같은 상품이라도 사용자에 따라 가격이 달라질 수 있습니다. 특정 임직원에게는 3만 원이 할인되고, 특정 제휴사 구성원에게는 2만 원이 할인될 수 있습니다. 어떤 사용자는 두 조건을 동시에 만족할 수도 있습니다.
이때 할인 조건마다 이벤트를 별도로 만들면 구현은 단순해 보입니다. 하지만 사용자에게는 같은 상품이 여러 개의 상품 카드로 보일 수 있습니다. 실제로는 하나의 상품인데, 조건이 여러 개 충족된 사람에게 여러 이벤트가 동시에 노출되는 구조가 되는 것입니다.
그래서 처음에는 event_template을 상품의 기준 모델로 두고, 실제 운영 이벤트와 분리하는 구조로 설계했습니다. 다만 이때의 시행착오는 template에 너무 많은 값을 넣었다는 점이었습니다. 이름, 설명, 가격 같은 값까지 template이 들고 있으면 모든 하위 event가 그 값을 공유하게 됩니다. 운영이 진행될수록 이 값들은 template보다 실제 event 아래에 있는 편이 맞다고 판단했습니다.
당시 구조
초기 구조를 단순화하면 다음과 같았습니다.
event_template
- 공통 상품 정의
- 이름
- 설명
- 가격
- 소요 시간
- VAT 포함 여부
- 이벤트 대상 유형
event
- 실제 운영 인스턴스
- event_template_id
- 노출 기간
- 상태
- 제휴사 조건
- 입점업체 연결
event_template은 사용자가 인식하는 상품의 정체성에 가까웠습니다. 초기에는 이름, 설명, 가격, 소요 시간처럼 상품을 설명하는 값도 template에 두었습니다. 반대로 event는 실제 운영 단위였습니다. 언제 노출할지, 어떤 상태인지, 어떤 입점업체나 제휴 조건과 연결되는지는 event가 들고 있었습니다.
이미지도 처음에는 template 기준으로 연결되어 있었습니다.
event_image.event_template_id
이 선택도 같은 이유였습니다. 이미지가 상품 자체를 설명한다면 운영 이벤트마다 중복해서 들고 있을 필요가 없습니다. 같은 상품의 대표 이미지가 여러 이벤트에 복사되면, 이미지를 교체할 때도 중복 수정이 생기고 어느 쪽이 원본인지 흐려집니다.
하지만 이 구조를 그대로 밀고 가기에는 문제가 있었습니다. template이 이름, 설명, 가격 같은 실제 노출 값을 가지면 하위 event들이 모두 같은 값을 공유합니다. 특정 입점업체의 이벤트만 설명을 다르게 쓰거나, 특정 제휴 조건에서만 가격 표현을 바꿔야 할 때 template을 수정하면 같은 template을 바라보는 다른 event까지 같이 영향을 받습니다.
그렇다고 event마다 값을 복사해 두면 template은 더 이상 단일 출처가 아닙니다. template에 있는 값과 event에 복사된 값 중 무엇이 최신인지 다시 판단해야 하고, 수정 시점마다 동기화 문제가 생깁니다.
이 경험 때문에 template은 상품을 묶는 얇은 기준으로 남기고, 실제 노출되는 설명과 가격은 event 또는 가격 정책 쪽으로 내려가는 편이 더 맞다고 봤습니다.
왜 템플릿이 필요했나
핵심은 재사용보다 상품 식별성이었습니다.
event_template을 단순히 “중복 컬럼을 줄이는 공통 테이블”로만 보면, 조인이 늘어나는 비용이 먼저 보입니다. 하지만 이 도메인에서는 템플릿이 같은 상품인지 판단하는 기준이었습니다.
할인 조건은 달라질 수 있지만 상품은 같습니다.
- 일반 고객에게 보이는 기본 상품
- 특정 임직원에게 할인되는 같은 상품
- 특정 제휴사 구성원에게 할인되는 같은 상품
- 특정 직급이나 조직 조건으로 할인되는 같은 상품
이 차이를 모두 독립 이벤트로 만들면 상품 정체성이 조건 수만큼 복제됩니다. 그러면 이후 요구사항이 들어올 때마다 같은 질문을 반복하게 됩니다.
- 리뷰는 어떤 이벤트에 붙어야 하는가?
- 예약 이력은 조건별 이벤트로 나눠 봐야 하는가, 상품 단위로 봐야 하는가?
- 같은 상품의 여러 조건이 동시에 만족되면 어떤 이벤트를 보여줘야 하는가?
- 운영자가 상품 설명을 바꾸면 조건별 이벤트를 모두 수정해야 하는가?
- 가격 재계산은 상품 기준인가, 이벤트 기준인가?
처음 템플릿을 둔 이유는 이 질문들에 대한 답을 상품 단위로 고정하기 위해서였습니다.
고민했던 지점
템플릿 중심 구조가 무조건 쉬운 것은 아니었습니다. 오히려 초기에 더 많은 결정을 요구했습니다.
첫 번째 고민은 상품 정보와 운영 정보의 경계였습니다. 처음에는 이름, 설명, 기본 가격, 소요 시간을 template에 두기 쉬워 보였습니다. 하지만 설명과 가격은 생각보다 운영 조건의 영향을 많이 받았습니다. 같은 상품이어도 입점업체, 제휴사, 임직원 조건, 노출 채널에 따라 문구나 가격 표현이 달라질 수 있었습니다. 이런 값이 template에 있으면 하위 event 전체가 같이 바뀌고, event에 복사하면 중복 데이터가 됩니다. 그래서 template은 상품을 묶는 기준만 갖고, 실제 표시값은 event나 policy에서 결정하는 쪽이 더 적절했습니다.
두 번째 고민은 이벤트 대상 유형이었습니다. 기존 데이터에서는 고객 유형이나 제휴사 존재 여부를 조합해 이벤트 대상 유형을 계산해야 했습니다. 이 값이 template에 있어야 하는지, event에 있어야 하는지, 아니면 별도의 조건 정책으로 빠져야 하는지가 중요했습니다. 대상 유형이 상품의 성격이라면 template에 둘 수 있지만, 특정 제휴사나 조직 조건에 따라 달라지는 값이라면 event보다 더 작은 정책 단위가 필요했습니다.
세 번째 고민은 검수 workflow였습니다. 입점업체가 이벤트 생성이나 수정을 요청할 수 있었고, 운영관리자 승인 전에는 실제 운영 데이터가 바뀌면 안 됐습니다. 최초 생성 요청에는 아직 eventId가 없기 때문에, 단순히 event row를 먼저 만들고 PENDING 상태로 두면 승인 전 데이터가 실제 운영 테이블에 섞입니다. 그래서 검수 요청은 EventApproval과 JSONB snapshot으로 분리하고, 승인 시점에만 실제 Event 생성/수정 command로 변환하는 구조가 필요했습니다.
네 번째 고민은 가격 재계산이었습니다. 이벤트 가격은 단일 값이 아니었습니다. 이벤트 기본 가격, 이벤트 할인, 직급 할인, 제휴사/입점업체 조합, B2B/B2C 정책, 의료/비의료 최대 할인율 제한이 함께 계산됐습니다. 이벤트가 변경될 때마다 EventPrice와 AppliedDiscount를 다시 계산해야 했고, 가격 계산 실패가 이벤트 저장 실패로 번지지 않도록 Outbox와 after-commit 처리도 필요했습니다.
다섯 번째 고민은 조회 모델이었습니다. template과 event를 분리하면 쓰기 모델은 도메인에 가까워지지만, 목록 조회는 복잡해집니다. 사용자별 조건을 계산하고, template별로 적용 가능한 event나 정책을 찾은 뒤, 최종적으로는 하나의 상품 카드만 만들어야 합니다. 이 조회 모델을 명확히 만들지 않으면 백엔드 모델은 맞는데 화면에서는 중복 상품이 뜨는 일이 생깁니다.
마지막에 왜 이벤트 중심으로 바뀌었나
마지막에는 운영 화면과 디자인 복잡도를 줄이기 위해 조건별 이벤트를 독립 상품처럼 다루는 방향으로 바뀌었습니다.
기획과 디자인 입장에서는 “하나의 상품 안에 여러 조건이 있고, 사용자마다 다른 혜택이 보인다”는 구조를 화면에 녹이는 일이 쉽지 않았습니다. 운영자에게도 조건 정책을 관리하게 하는 것보다 이벤트를 여러 개 만들어 관리하게 하는 편이 설명하기 쉬웠습니다.
백엔드 입장에서도 event 중심 구조는 단기적으로 장점이 있었습니다.
- 조회할 때 조인이 줄어듭니다.
- 운영자가 보는 단위와 event row가 일치합니다.
- 검수 workflow를 실제 event 단위로 연결하기 쉽습니다.
- 이미지, 옵션, 전문가 연결의 소유자가 단순해집니다.
- migration 이후에는 event 하나만 봐도 서비스 노출 정보를 알 수 있습니다.
하지만 이 선택은 복잡도를 없앤 것이 아니라 다른 곳으로 옮긴 것에 가까웠습니다. 데이터 모델에서는 단순해졌지만, 같은 상품이 조건별로 복제될 수 있고, 사용자가 여러 조건을 동시에 만족할 때 중복 노출을 막기 위한 별도 규칙이 필요해졌습니다.
더 나은 리팩토링 방향
다시 리팩토링한다면 event와 template 중 하나를 고르는 방식이 아니라, template을 더 얇게 만들고 책임을 명확히 나누겠습니다.
EventTemplate
- 상품 그룹 식별자
- 상품 분류 기준
- 동일 상품으로 묶을 수 있는 최소 메타데이터
EventOperation
- 운영 인스턴스
- template_id
- 입점업체
- 이름, 설명, 기본 가격, 소요 시간
- 노출 기간
- 운영 상태
- 검수 상태
EventPolicy
- template_id 또는 operation_id
- 적용 대상 조건
- 할인 규칙
- 우선순위
- 중복 적용 가능 여부
- 사용자에게 보여줄 혜택 라벨
EventPrice
- policy 적용 후 파생 가격
- 적용된 할인 이력
- 재계산 상태
여기서 중요한 것은 template이 값을 많이 갖지 않도록 하는 것입니다. template은 같은 상품을 묶는 기준이고, 사용자가 실제로 보는 이름, 설명, 기본 가격, 소요 시간은 운영 event가 갖습니다. 할인과 노출 조건은 상품도 아니고 운영 이벤트도 아니므로 EventPolicy로 분리합니다. 그래야 여러 조건이 동시에 충족될 때 한 곳에서 판단할 수 있습니다.
사용자 컨텍스트
-> 적용 가능한 EventPolicy 조회
-> template 기준으로 그룹핑
-> 우선순위, 최대 할인, 중복 적용 규칙 계산
-> template당 하나의 EventCard 생성
이렇게 하면 같은 상품은 하나로 보이면서도, 사용자마다 다른 가격과 혜택 라벨을 보여줄 수 있습니다.
templateId: 검진상품그룹-A
event:
name: 건강검진 A
basePrice: 100000
matchedPolicy:
type: EMPLOYEE_DISCOUNT
discountAmount: 30000
displayPrice: 70000
displayLabel: 임직원 혜택가
이미지, 옵션, 전문가 연결도 scope를 명확히 나누는 편이 좋습니다.
template scope
- 같은 상품 그룹으로 묶기 위한 최소 기준
- 검색과 통계를 위한 분류 정보
operation scope
- 사용자에게 노출되는 이름과 설명
- 할인 전 기준 가격과 소요 시간
- 특정 입점업체나 운영 기간에만 다른 이미지
- 특정 운영 이벤트에서만 가능한 옵션
- 특정 운영 이벤트에 연결된 전문가
policy scope
- 특정 조건에서만 노출되는 혜택 문구
- 특정 조건에서만 적용되는 할인
검수도 실제 Event를 직접 수정하는 대신 draft command를 승인 시점에 반영하는 구조가 더 안전합니다.
Vendor request
-> EventApproval snapshot
-> Admin approve
-> Apply command
-> Update Template / Operation / Policy
-> Publish price recalculation outbox
이렇게 하면 승인 전 데이터가 실제 운영 테이블에 섞이지 않고, 승인 이후에는 변경된 범위에 따라 가격 재계산 메시지를 발행할 수 있습니다.
마이그레이션한다면
이미 event 중심으로 합쳐진 상태에서 다시 템플릿 중심으로 되돌린다면 한 번에 테이블을 갈아엎기보다 단계적으로 옮기는 편이 안전합니다.
- 기존 event에
template_id를 다시 추가하고 nullable 상태로 둡니다. - 이름, 설명, 기본 가격, 소요 시간은 event에 유지하고, 같은 상품을 묶을 수 있는 최소 기준만 template으로 backfill합니다.
- 제휴사, 직급, 임직원 여부, 할인값은
EventPolicy로 분리합니다. - 이미지와 옵션은 template scope와 operation scope로 분류해 FK를 이동합니다.
- 기존 event 목록 조회와 새 template 기반 조회를 동시에 운영하며 결과를 비교합니다.
- 사용자 목록 화면을 template 기반
EventCardprojection으로 전환합니다. - 예약, 리뷰, 통계가 template 기준으로 묶여야 하는지 operation 기준으로 묶여야 하는지 명확히 나눕니다.
- 검증이 끝난 뒤 event에 중복 저장된 상품 컬럼을 제거하거나 read model 용도로만 남깁니다.
이 과정에서 rollback을 위해 기존 컬럼을 바로 삭제하지 않고, backfill 결과를 검증할 수 있는 기간을 두는 것이 중요합니다. 특히 이미지 FK나 가격 파생 데이터는 한 번 끊기면 복구가 어렵기 때문에, template ID와 event ID 매핑을 별도 테이블이나 migration 로그로 남기는 편이 안전합니다.
배운 점
이 사례에서 어려웠던 것은 테이블을 나누는 기술 자체가 아니었습니다. 진짜 어려웠던 것은 무엇이 같은 상품이고, 무엇이 사용자별로 달라지는 조건인지 정의하는 일이었습니다.
event_template은 단순 중복 제거용 테이블이 아니라 상품 정체성을 지키기 위한 기준 모델이었습니다. 하지만 template이 이름, 설명, 가격까지 소유하면 하위 event의 다양성을 막는 또 다른 결합이 됩니다. 반대로 할인, 제휴사, 임직원 여부, 직급 같은 값은 상품이 아니라 정책입니다.
처음부터 이 경계를 끝까지 밀고 가려면 백엔드 모델만으로는 부족합니다. 기획, 디자인, 운영 화면까지 “하나의 상품에 여러 정책이 붙는다”는 개념을 함께 받아들여야 합니다. 그렇지 않으면 백엔드에서는 템플릿 중심으로 설계해도, 화면과 운영 방식에서는 다시 조건별 상품 복제로 돌아가게 됩니다.
그래서 이 도메인의 더 나은 방향은 event 중심과 template 중심 중 하나를 고르는 것이 아니라, template은 얇은 상품 그룹으로 두고, 실제 노출 값은 event operation으로 내리고, 조건과 혜택은 policy로 독립시키는 것입니다. 그래야 같은 상품은 하나로 보이고, 사람마다 다른 혜택은 명확한 규칙으로 계산할 수 있습니다.