포인트 도메인의 원장 기반 설계 전략

포인트 도메인의 원장 기반 설계 전략

초기 스타트업에서 포인트 도메인을 구축하고, 한달 간 총 1억+ 원, 4,000+ 개의 포인트 관련 적립 및 결제를 처리하며 겪은 문제 상황과 해결 전략에 대해 소개합니다.

이 시리즈에서는 초기 스타트업에서 포인트 도메인을 구축하고, 한달 간 총 1억+ 원, 4,000+ 개의 포인트 관련 적립 및 결제를 처리하며 겪은 문제 상황과 해결 전략에 대해 소개합니다.

포인트의 세부 약관에 따라 요구사항이 달라지며, 스타트업 규모에서 필요한 구조 설계 경험을 바탕으로 작성하기에 대규모 서비스에서 필요한 전략과는 다를 수 있습니다.


단일 잔액 모델의 한계

가장 기초적이고 처음 떠올릴 수 있는 모델은 users 테이블에 pointBalance 칼럼을 하나 추가하는 것입니다. 다만 포인트의 기본적인 요구사항들을 만족하기 위해서는 해당 모델로는 부족합니다.

요구사항의 네 가지 제약

제가 구현해야 했던 서비스의 약관에 따른 제약은 네 가지였습니다. 1-3번 제약들은 포인트를 도입하는 모든 서비스의 공통적인 제약이라고 보실 수 있을 것 같습니다.

첫째, 시점 의존적 만료. 포인트는 적립일로부터 일정 기간 후에 소멸하는데, 적립이 여러 번 일어나면 각 적립분마다 만료일이 달라집니다.

둘째, 확정 시점의 지연. 결제 즉시 사용 가능한 것이 아니라 실제 상품을 소비 또는 구매가 확정된 이후에 사용할 수 있어야 했습니다.

셋째, FIFO 소비. 먼저 적립된(=먼저 만료될) 포인트가 먼저 소비되어야 사용자 손실이 최소화됩니다.

넷째, 부분 환불의 비례 계산. 결제 중 일부만 환불되는 경우, 이미 사용된 포인트와 아직 확정되지 않은 포인트를 각각 어떻게 처리할지 결정해야 했습니다.

아까 언급한 단일 컬럼은 현재 상태만 기록하기에 시간, 환불, 만료 등이 교차하는 복합 상태를 모두 표현하기에는 근본적으로 부족합니다. 그래서 잔액을 원천으로 두는 대신, 불변 원장을 원천으로 두고 잔액을 그로부터 파생시키는 것으로 설계했습니다. 즉, 잔액이 아닌 거래 로그가 진실의 원천이고 잔액은 로그에서 파생되는 뷰가 되는 구조입니다.


엔티티 구성과 책임 분리

원장 구조을 채택한 뒤 설계는 아래 4개의 엔티티로 구현하였습니다.

  • PointWallet — 사용자당 1개, 현재 스냅샷
  • PointLedger — 모든 거래의 불변 기록
  • PointLot — 한 번의 적립 건, FIFO 배분의 최소 단위
  • PointLedgerAllocation — 거래와 Lot의 M:N 매핑

엔티티별 역할

Wallet은 실시간 조회 성능과 불변식 보호를 책임집니다. 위에서 언급한 거래 로그에서 파생되는 잔액 뷰라고 보시면 될 것 같습니다.

Ledger는 감사 추적, 임의 시점 재계산, 멱등성 경계를 담당합니다. 여기서 중요한 부분은 한 번 기록된 ledger는 절대 수정되면 안됩니다. 또한 각 ledger row에는 거래 내용만이 아니라 거래 직후의 Wallet 스냅샷까지 함께 기록됩니다. 이를 통해 원장만 보고 임의 시점의 잔액을 재현할 수 있고, Wallet과 Ledger가 어긋났을 때 어느 쪽이 틀렸는지 판별할 수 있습니다.

Lot은 만료와 FIFO의 단위입니다. Ledger와 별도로 존재하는 이유는 거래의 단위와 잔액의 단위가 다르기 때문입니다. 예를 들어 한 번의 거래로 1000 포인트를 적립했을 때, 이는 3번의 거래를 걸쳐 소비가 될 수 있습니다.

Allocation은 소비 및 회수 시 어떠한 Lot에서 어느정도 사용했는가를 기록합니다. 사후 취소가 가능하려면 이 정보가 반드시 보존되어야 합니다.

책임 분리의 이점

네 테이블로 쪼갰을 때 얻는 이점은 두 가지입니다.

우선 각 테이블의 불변식이 명확해지므로 테스트 및 검증이 가능해집니다. 가령 Wallet에는 debt > 0 이면 available = 0 같은 불변식이, Lot에는 remainingAmount는 단방향으로 감소한다 같은 불변식이 붙습니다. 이를 통해 포인트 wallet 정합성 스케줄러 등을 구현할 수도 있습니다. 또한 스키마가 도메인 언어를 그대로 표현하게 되어 비즈니스 원칙 중심으로 설계가 가능해집니다.


Debt Points 모델

지금까지의 설계는 꽤 직관적이었으나, 유저가 일명 포인트 빚을 만드는 것을 고려할 때부터 복잡도가 올라갑니다.

문제 상황

다음 시나리오를 고려해보겠습니다.

  1. 사용자가 결제 A를 통해 100P를 적립합니다.
  2. 사용자가 결제 B를 통해 결제 A에서 적립한 100P를 소진합니다.
  3. 사용자가 결제 A를 취소하며 적립된 100P를 회수해야하나 이미 결제 B에서 모두 소진된 상태입니다.

이러한 케이스는 구매 확정 이후 포인트 적립 확정 등의 약관으로 최대한 막아야 하는 케이스이지만 개발자 입장에서는 고려하지 않을 수 없습니다.

양의 부채 모델

여기서는 음의 잔액을 부채라는 양의 값으로 표현했습니다.

Wallet에 debtPoints 컬럼을 별도로 두고, 이 값은 항상 0 이상을 유지합니다. 환불로 인해 사용 가능 잔액이 음수가 되어야 할 상황이 생기면, 초과분을 debt로 옮기는 방식입니다. 즉, 위 시나리오에서 3번 단계 이후 상태는 availablePoints = 0, debtPoints = 100이 됩니다.

이 모델에는 부채가 있는데 쓸 수 있는 포인트가 함께 존재하는 상태는 논리적으로 불가능하기 때문에 포인트 빚이 있는 동안 사용 가능한 포인트는 0이어야 한다는 불변식이 따라붙습니다.

별도의 부채 칼럼을 두지 않고 사용 가능한 포인트의 값을 음수도 허용하는 방식도 가능하나, 이는 도메인 언어가 오염되고 화면 표시 및 계산 로직이 지저분해지기에 권장드리지 않습니다.

부채의 자동 상계

부채는 이후 새 포인트가 적립될 때 자동으로 상계되도록 설계했습니다.

새 적립이 발생하면 먼저 빚을 차감하고, 남은 금액만 사용 가능한 포인트 금액에 반영합니다. 예를 들어 debt가 100P 쌓여 있는 상태에서 150P 적립이 들어오면, 100P는 debt 상계에 쓰이고 50P만 실제 사용 가능 포인트로 반영되는 식입니다.

이때 상계 자체를 별도의 ledger entry로 독립 기록하는 것이 중요합니다. 즉, 단일 적립 이벤트가 두 개의 ledger entry(빚 상계, 포인트 적립)를 낳는 경우가 생깁니다.

Debt Points 수명 주기 시퀀스 다이어그램


동시성 제어와 멱등성 보장

포인트 시스템은 이벤트 기반 아키텍처 위에서 동작하기에, 결제 Webhook의 재시도, 메시지 큐의 at-least-once 전달, 유저의 새로고침 등으로 동일 유저의 여러 요청이 동시에 혹은 중복으로 들어오는 상황을 항상 고려해야 합니다. 특히 포인트는 실제 금전과 직결되기에 이를 제대로 풀어주지 못하면 곧바로 회사의 손실로 이어집니다.

흔히 한 덩어리로 묶여 이야기되지만 동시성 제어멱등성 보장은 서로 다른 문제를 해결합니다. 전자는 서로 다른 요청이 동시에 처리될 때 상태 일관성을 지키는 것이고, 후자는 동일한 요청이 중복으로 들어와도 한 번만 효력을 갖도록 보장하는 것입니다. 저는 이 둘을 비관적 락과 스키마 레벨 UNIQUE 제약으로 각각 풀었습니다.

비관적 락을 통한 동시성 제어

동시성 제어의 핵심은 경합되는 자원에 대한 락처리입니다. 저는 모든 포인트 상태 변경 연산에서 비관적 쓰기 락(SELECT ... FOR UPDATE)을 사용했습니다. 그러나 락을 여러 엔티티에 걸기 시작하면 필연적으로 데드락 이슈가 발생하기에 락을 획득하는 순서가 중요합니다.

포인트에서 경합되는 자원은 Wallet과 Lot입니다. Ledger와 Allocation은 append-only 감사 레코드이므로 INSERT 간 경합이 없기에 고려하지 않아도 됩니다. 저는 모든 포인트 연산에서 Wallet → Lot 순서로 락을 획득하도록 통일했고, 이 순서를 코드 전체에서 지키는 것만으로 순환 대기가 구조적으로 발생할 수 없어 데드락은 코드 컨벤션만으로 완전히 차단됩니다.

단순하게만 생각하면 Wallet이 포인트의 Aggregate Root이기에 Wallet만 락을 걸면 될 것으로 보이지만, Lot은 자체 상태 머신을 가지기에 Wallet 락만으로는 Lot의 상태 전이 경합을 막을 수 없는 경우가 생깁니다. 가령 적립 예정 Lot이 확정되려는 찰나에 동일 Lot을 무효화하려는 다른 트랜잭션이 동시에 들어오는 상황을 생각해 보겠습니다. 두 트랜잭션은 각기 다른 ledger 이벤트이기에 Wallet 락은 제각기 풀렸다 다시 걸리는 과정을 거치는데, Lot에 락이 없으면 두 트랜잭션 모두 여전히 PENDING인 Lot을 읽고 서로 다른 상태로 업데이트를 시도할 수 있습니다. 실제로 이 경합은 프로덕션에서 한 번 재현되어 Lot에 독립 락을 추가하는 식으로 해결된 바 있습니다.

비관적 락 획득 순서와 Lot 경합 해소 시퀀스 다이어그램

idempotency_key UNIQUE 제약을 통한 멱등성 보장

락은 동시 실행되는 서로 다른 요청을 직렬화할 뿐, 이미 처리가 완료된 동일 요청이 재전송되는 상황은 막지 못합니다. 가령 결제 완료 Webhook이 몇 초 뒤 재발송되면 이전 트랜잭션은 이미 커밋되어 락도 해제된 상태이기에, 새 트랜잭션은 정상 경로를 타고 또 한 번 포인트를 적립하게 됩니다. 이는 곧 회사의 금전적 손실로 이어집니다.

이 문제를 막기 위한 가장 흔한 접근은 애플리케이션 레벨 체크로, 트랜잭션 시작 시점에 요청 처리 여부를 SELECT한 뒤 분기하는 방식입니다. 다만 해당 방식은 멱등 체크 코드를 모든 호출 경로에 일관되게 삽입해야 하는데 한 곳에서라도 누락되면 바로 금전 사고로 이어지게 되어 애플리케이션의 책임으로 두기엔 부담이 큽니다.

저는 이 책임을 DB로 내리는 접근을 택했습니다. Ledger 테이블에 idempotency_key 컬럼과 UNIQUE 인덱스를 추가하여, 중복 요청이 오면 DB가 INSERT 시점에 제약 위반으로 거절하고 애플리케이션은 이 예외를 catch해 이미 처리됨으로 해석하는 식입니다. DB가 단일 진실 공급원이 되기에 중복 처리가 원천 차단되고, 애플리케이션 코드에서 처리 여부 분기 자체가 사라져 모든 경로는 단순히 INSERT를 시도하고 실패 시 그 결과를 수용하면 됩니다.

이때 스키마 제약으로 멱등성을 보장하려면 키 자체가 제대로 설계되어야 합니다. 저는 키 생성을 도메인 단일 지점에 모아 중앙화를 이루었고, 키를 구성하는 식별자 조합만으로 멱등 경계를 확인할 수 있는 명세 역할을 할 수 있도록 설계했습니다.

idempotency_key UNIQUE 제약을 통한 중복 이벤트 처리 흐름 시퀀스 다이어그램


마무리

지금까지 포인트 도메인 설계와 동시성 제어 및 멱등성 보장 전략에 대해 알아보았습니다.

다음으로는 실제 프로덕션 환경에서 마주한 에러들과 해결 방안들에 대해 살펴보겠습니다.