콘텐츠로 이동

Product Domain Modeling

분류: Layer 13 - Product Engineering & Growth Systems

왜 제품의 화면과 테이블이 모두 멀쩡한데도 가격 변경, 팀 초대, 권한 회수, 지표 집계는 서로 다른 답을 낼까? 대개 기능을 저장 구조와 화면 흐름으로만 나누고, 사업이 쓰는 언어와 규칙, 상태 전이, 규칙의 소유자를 하나의 모델로 맞추지 않았기 때문이다.

1. 한 줄 정의: 사업 결정을 실행 가능한 계약으로 만든다

섹션 제목: “1. 한 줄 정의: 사업 결정을 실행 가능한 계약으로 만든다”

Product Domain Model은 제품에서 중요한 사람·조직·자원과 그 관계, 허용되는 상태 변화, 반드시 지켜야 할 규칙, 각 규칙의 소유권을 사업 언어로 표현한 모델이다. Product Domain Modeling은 이 모델을 DB에만 가두지 않고 API, 이벤트, 권한 판정, 분석 지표까지 같은 의미로 전파하는 작업이다.

화면은 이 계약의 한 표현이고 테이블은 이 계약을 저장하는 한 방식이다. 따라서 members 화면이 있다고 해서 Member가 곧 핵심 모델인 것은 아니며, subscriptions 테이블이 있다고 해서 구독 상태가 기능 접근 권한을 완전히 설명하는 것도 아니다. 먼저 “누가 어떤 조직에서 어떤 행동을 할 수 있으며, 어떤 사건이 그 사실을 바꾸는가”를 답해야 한다.

2. 최소 용어: DDD를 제품 결정에 연결하는 언어

섹션 제목: “2. 최소 용어: DDD를 제품 결정에 연결하는 언어”

L9의 DDD(Domain-Driven Design, 도메인 주도 설계) 문서가 설계 원리를 자세히 다뤘으므로 여기서는 제품 결정을 추적하는 데 필요한 뜻만 잡는다.

  • **보편 언어(Ubiquitous Language)**는 기획, 디자인, 개발, 데이터, 지원 조직이 같은 개념을 같은 뜻으로 부르는 업무 언어다. 문서에서는 workspace, 코드에서는 team, 분석에서는 account라고 부르면 세 이름이 정말 동의어인지부터 합의해야 한다.
  • **경계 컨텍스트(Bounded Context)**는 한 용어와 규칙이 모순 없이 같은 뜻을 유지하는 경계다. Account가 Identity 컨텍스트에서는 로그인 주체, Billing 컨텍스트에서는 청구 계약을 뜻할 수 있다. 경계를 넘을 때는 같은 단어를 공유하기보다 명시적으로 번역한다.
  • **엔티티(Entity)**는 속성이 바뀌어도 식별자로 동일성을 추적하는 객체다. 이메일이나 역할이 바뀌어도 User u-17, Workspace w-3은 같은 대상을 가리킨다.
  • **값 객체(Value Object)**는 식별자보다 값 자체가 의미를 결정하는 객체다. EmailAddress, 통화가 포함된 Money, 기능 키와 한도를 묶은 FeatureAllowance가 예다. 같은 값이면 같은 것으로 취급하고, 보통 변경 대신 새 값으로 교체한다.
  • **애그리게이트(Aggregate)**는 하나의 트랜잭션에서 함께 일관성을 지켜야 하는 객체 묶음이다. 외부 변경은 애그리게이트 루트(Aggregate Root)를 통과시켜 규칙 검증과 사건 기록을 한곳에 모은다. 애그리게이트는 테이블 묶음이나 서비스 이름이 아니라 “즉시 함께 지켜야 하는 규칙”의 경계다.
  • **불변식(Invariant)**은 어떤 명령 전후에도 항상 참이어야 하는 사업 규칙이다. “workspace에는 owner가 최소 한 명 있어야 한다”가 대표적이다. 정상 경로에서만 확인하는 UI validation이 아니라 모든 쓰기 경로가 지켜야 한다.
  • **생명주기(Lifecycle)**는 객체가 생성되고 바뀌고 종료되는 전체 과정이다. **상태 기계(State Machine)**는 그 생명주기를 허용 상태와 전이로 명시한 모델이다. pending -> accepted는 허용하지만 expired -> accepted는 금지하는 식이다.
  • **기능 권한(Entitlement)**은 특정 주체가 어떤 기능이나 수량을 사용할 수 있다는 계산된 권리다. plan = pro라는 판매 이름과 exports.enabled = true, members.limit = 20 같은 실제 사용 권리를 분리한다.

이 용어의 목적은 패턴 이름을 많이 쓰는 데 있지 않다. “가격 정책을 바꾸면 어느 규칙이 바뀌고, 어느 API와 이벤트와 지표가 영향을 받는가”를 빠짐없이 설명하는 데 있다.

3. 선행 방식의 한계: 왜 화면·테이블 모음만으로는 부족한가

섹션 제목: “3. 선행 방식의 한계: 왜 화면·테이블 모음만으로는 부족한가”

Product Engineer가 자주 만나는 기능은 처음에는 CRUD(Create, Read, Update, Delete), 즉 생성·조회·수정·삭제처럼 보인다.

  • 팀 생성
  • 멤버 초대와 role 변경
  • trial 시작과 종료
  • 유료 기능 접근
  • project archive
  • workspace 삭제

그러나 Invite 행 하나를 추가하는 순간에도 초대 가능한 역할, 좌석 한도, 토큰 만료, 수락 시점의 중복 사용자, 결제 좌석, 감사 로그, 활성화 지표가 연결된다. 저장 성공만으로 제품 규칙이 성공한 것은 아니다.

CRUD 중심 모델은 status 컬럼을 자유롭게 수정하는 API를 만들기 쉽다. 반면 제품 모델은 acceptInvite, suspendWorkspace, changeMembershipRole처럼 사업 행동을 명령으로 드러내고, 각 행동이 허용되는 조건과 결과 사건을 함께 정의한다. 핵심 철학은 데이터 모양보다 사업 결정과 책임의 위치를 먼저 고정하는 것이다.

제품 모델이 약하면 같은 질문에 층마다 다른 답을 낸다.

질문화면의 답서버의 답데이터의 답실제 문제
이 사용자는 admin인가user.rolemembership.role최근 role 변경 이벤트권한의 소유 단위가 불명확하다
좌석이 하나 남았는가초대 버튼 활성active member만 집계invite까지 포함해 집계좌석 소비 시점이 합의되지 않았다
Pro 기능을 쓸 수 있는가plan label 확인subscription 상태 확인plan_upgraded 확인판매 상품과 실제 권리가 섞였다
workspace가 삭제됐는가목록에서 숨김deleted_pendingworkspace_deleted 수신UI 노출과 도메인 종료가 섞였다

많은 B2B SaaS(기업용 구독형 소프트웨어) 제품은 아래 객체를 중심으로 돌아간다. 이름보다 중요한 것은 어떤 사실과 규칙을 누가 소유하는지다.

객체의미소유하는 사실과 규칙
User로그인 가능한 개인 엔티티본인 식별, 인증 연결, 개인 프로필
Workspace협업과 자원 소유의 기본 단위운영 상태, 자원 소유, 조직 정책
MembershipUser와 Workspace의 관계 엔티티role, 관계 상태, 가입·탈퇴 시점
Invite아직 성립하지 않은 가입 제안수신 주소, 제안 role, 만료, 수락·철회
Project/Resource제품의 핵심 작업 단위소유 workspace, 공개 범위, 생명주기
Subscription고객과 판매 상품의 결제 계약trial, 갱신, 미납, 취소 상태
Entitlement기능 접근에 사용되는 권리기능 키, 허용 여부, 수량 한도, 유효 기간

협업 제품에서 user.role = admin보다 membership.role = admin이 안전한 이유는 권한이 사람 자체의 속성이 아니라 사람과 workspace 관계의 속성이기 때문이다. 같은 사용자는 w-1에서는 owner, w-2에서는 viewer일 수 있다. 반대로 개인용 단일 계정 제품이라면 별도 Membership이 복잡도만 늘릴 수 있으므로, 다중 조직·초대·관계별 권한이 실제 요구인지 먼저 확인한다.

Plan과 Entitlement도 같은 이유로 분리한다. Starter, Pro는 가격과 마케팅에 따라 이름과 구성이 바뀌는 판매 상품이다. 기능 코드는 plan === "pro"보다 entitlements.can("project.export") 또는 entitlements.limit("members")를 묻는 편이 안정적이다. 구독이 past_due여도 7일 유예 동안 권리를 유지하는 정책처럼 결제 상태와 접근 권한이 즉시 일치하지 않는 경우를 표현할 수 있기 때문이다.

좌석 상품은 같은 members.limit을 보여도 청구 단위가 다를 수 있으므로 먼저 과금 모델을 분리한다.

모델Entitlement과 가입 제한Billing의 청구 수량
선구매 좌석제purchased_seats가 그대로 members.limit, 즉 권리 capacity가 된다구매한 좌석 수가 청구 수량이다. Membership이 2→3으로 활성화돼도 변하지 않는다
active-seat meteredplan/entitlement가 팀 가입 가능 여부나 최대 한도를 정한다활성 Membership 수가 meter다. 2→3 활성화를 Billing이 2→3으로 반영한다
무료 planmembers.limit = 3의 active/consumed seat hard cap을 적용한다청구하지 않으며 billable seat meter를 만들지 않는다

선구매 모델에서는 사용량 3이 권리 capacity 안에 들어왔는지를 판정할 뿐이고, 청구 수량은 고객이 좌석을 추가 구매할 때만 변한다. active-seat metered 모델은 권리와 사용량을 더 엄격히 구분한다. Entitlement는 “가입할 수 있는가, 최대 몇 명인가”를 답하고, Billing meter는 “현재 활성 Membership이 몇 개인가”를 답한다.

5. 상태 전이와 불변식: 허용된 변화만 이름 붙인다

섹션 제목: “5. 상태 전이와 불변식: 허용된 변화만 이름 붙인다”

상태 기계는 화면에 표시할 label 목록이 아니다. 어떤 명령이 현재 상태에서 허용되고, 성공하면 어떤 상태와 사건을 만드는지 정의하는 규칙이다.

Invite:
pending -> accepted
pending -> expired
pending -> revoked
Workspace:
active -> suspended
active -> deleted_pending
deleted_pending -> active # 유예 기간 안의 restore
deleted_pending -> deleted
Subscription:
trialing -> active
active -> past_due
past_due -> active # 결제 복구
past_due -> canceled

상태 전이는 API endpoint보다 먼저 정한다. 다음 불변식은 특정 화면이 아니라 모든 쓰기 경로에서 지켜져야 한다.

  • expired invite는 accepted로 갈 수 없다.
  • owner가 한 명뿐인 workspace에서는 마지막 owner를 member로 낮출 수 없다.
  • suspended workspace에서는 새 project를 만들 수 없다.
  • past_due subscription은 정책에서 정한 유예 기간 뒤 entitlement를 제한한다.
  • deleted_pending workspace는 audit log와 export window를 보존한다.

여기서 loading, modal-open, selected-tab, 입력 폼의 dirty는 UI 상태다. 반면 invite.pending, workspace.suspended, subscription.past_due는 다른 클라이언트와 배치 작업, 지원 도구도 알아야 하는 도메인 상태다. 새로고침 뒤에도 유지되어야 하고 다른 행위의 허용 여부를 바꾼다면 도메인 상태일 가능성이 높다. 단지 한 화면의 표현만 바꾼다면 UI 상태일 가능성이 높다.

퀴즈

role을 user 테이블의 컬럼으로 두면 B2B workspace 제품에서 어떤 문제가 생기는가?

힌트: 권한은 사용자 자체가 아니라 사용자와 workspace의 관계일 수 있다.

정답 보기

한 사용자가 여러 workspace에서 서로 다른 role을 가져야 할 때 표현할 수 없다. 권한 변경, 초대, audit log, billing seat 계산도 관계 단위로 다뤄야 하므로 Membership 같은 관계 객체가 필요하다.

6. Worked Example: 좌석 가격 정책이 시스템 전체로 흐르는 과정

섹션 제목: “6. Worked Example: 좌석 가격 정책이 시스템 전체로 흐르는 과정”

시나리오

팀 초대와 active-seat metered 과금을 구현한다

유료 plan은 active-seat metered 모델이며 활성 Membership을 청구한다. plan은 팀 가입을 허용하고 Entitlement는 active member 최대 20명을 정한다. 무료 plan은 active/consumed seat 3명 hard cap이지만 청구하지 않는다. 미납 후 7일 동안은 기존 접근을 유지한다.

가격 문구를 구현하기 전에 어떤 객체, 상태 전이, 불변식, 이벤트를 정의해야 하는가?

6.1. 먼저 애매한 정책을 숫자로 드러낸다

섹션 제목: “6.1. 먼저 애매한 정책을 숫자로 드러낸다”

w-3은 active-seat metered 유료 workspace이고 active Membership 2개와 pending Invite 2개가 있다고 하자. Entitlement의 최대 한도는 20명이다. 이 예시의 핵심 사건은 초대 하나를 수락해 active Membership이 2→3이 되고, Billing이 그 3개를 metered quantity로 반영하는 것이다. 모델링 전에는 다음 결정을 해야 한다.

  1. 좌석은 초대 생성, 이메일 발송, 초대 수락 중 언제 소비되는가?
  2. pending invite가 좌석을 예약한다면 언제 반환하는가?
  3. 두 사람이 동시에 마지막 한 자리를 수락하면 누가 성공하는가?
  4. owner, service account, suspended member도 좌석에 포함하는가?

이 예에서는 active Membership이 생길 때 active seat를 소비하고 pending Invite는 예약하지 않는다고 정한다. 초대 발송은 덜 막히지만 한도의 마지막 자리에 대한 동시 수락이 생긴다. 따라서 acceptInvite는 Invite 상태만 수정해서는 안 된다. 같은 트랜잭션에서 현재 active seat 수가 Entitlement 한도보다 작은지 확인하고 Membership을 만든 뒤 Invite를 accepted로 바꿔야 한다. 두 요청이 동시에 20번째 좌석을 노리면 한 요청만 성공하고 다른 요청은 SEAT_LIMIT_REACHED를 받아야 한다.

무료 plan도 좌석 소비 시점은 같지만 의미가 다르다. active/consumed seat가 2→3이 되면 hard cap에 도달하여 다음 활성화를 막지만, Billing에 billable quantity를 전송하지 않는다. 선구매 좌석제라면 활성화 2→3은 구매한 capacity 내 사용량만 바꾸고, 청구 수량은 purchased_seats로 그대로이다. 이후 절은 둘 중 active-seat metered 유료 모델만 따른다.

반대로 초대 생성 시 좌석을 예약하면 동시 수락은 단순해지지만, 응답하지 않은 초대 20개가 실제 사용자 없이 좌석을 막을 수 있다. 어느 정책이 정답이라기보다 사용자가 이해할 수 있는 제품 행동, 결제 방식, 동시성 비용을 함께 비교해야 한다.

  • Identity 컨텍스트는 User와 로그인 가능 여부를 소유한다.
  • Collaboration 컨텍스트는 Workspace, Membership, Invite와 마지막 owner·좌석 수락 불변식을 소유한다.
  • Billing 컨텍스트는 Subscription, active-seat metering 규칙, 결제 상태를 소유한다.
  • Access 컨텍스트는 plan과 결제 상태를 members.enabled = true, members.limit = 20, project.export = enabled 같은 Entitlement로 번역한다. 이 예시에서 members.limit은 청구 수량이 아니라 가입 조건과 최대 한도다.

EmailAddress("member@example.com")는 Invite 안의 값 객체다. Invite i-9는 이메일이 같아도 재발송·만료 이력이 다른 엔티티다. Workspace 전체를 거대한 애그리게이트 하나로 잠그기보다, “마지막 owner”와 “좌석 수락”처럼 즉시 함께 지킬 규칙이 어디에 있는지에 따라 Membership 변경 경계를 잡는다. Billing의 Subscription까지 같은 애그리게이트에 넣지 않고 entitlement snapshot 또는 조회 계약으로 참조한다.

active Membership 전체를 Workspace 애그리게이트 안에 적재할 필요도 없다. 좌석 수가 커지면 SeatAllocation처럼 좌석 소비만 원자적으로 소유하는 작은 모델을 일관성 권위로 둘 수 있다. 중요한 것은 여러 요청이 각자 COUNT(*) < limit를 확인한 뒤 모두 통과하는 구조를 피하고, 마지막 좌석의 승자를 한 트랜잭션 경계에서 결정하는 것이다.

쉽게 말하면 남은 좌석 확인과 사용량 증가를 하나의 직렬화된 DB 경계로 묶는다. 한 요청이 이 경계에서 확인하고 증가하는 동안 다른 요청은 같은 결정을 동시에 확정할 수 없어야 한다. BEGIN부터 COMMIT까지의 기본 감각이 필요하면 Transaction Basics를 먼저 참고한다.

우선안은 workspace당 권위 있는 workspace_seat_usage counter row를 두는 것이다. SELECT ... FOR UPDATE로 행을 잠그고 consumed_count < applied_limit를 확인한 뒤, 같은 트랜잭션에서 counter 증가·Membership 생성·Invite 수락을 완료한다. 유한 한도가 정해져 있다면 대안으로 한도만큼의 slot row를 미리 생성하고 추가 insert를 금지할 수 있다. 수락 명령은 빈 slot을 SELECT ... FOR UPDATE SKIP LOCKED로 고르거나 claimed_by IS NULL을 조건으로 점유하고, 점유한 slot이 있을 때만 Membership을 만든다. UNIQUE (workspace_id, slot_no)는 slot 번호 중복만 막으며, 자유로운 slot insert를 허용하면 총 slot 수나 한도를 보장하지 못한다.

6.3. 제품 명령과 API 계약으로 내린다

섹션 제목: “6.3. 제품 명령과 API 계약으로 내린다”

초대 수락 진입은 계정 유무에 따라 갈린다. 신규 사용자는 인증과 계정 생성을 마치고, 기존 계정 사용자는 로그인한 뒤 acceptance로 간다. 그러나 두 경로 모두 Membership을 만들기 전에 같은 보안 계약을 통과한다. 초대 링크에는 예측 불가능한 일회성 signed 또는 opaque token을 사용하고, 서버는 원문 대신 token_hashexpires_at, status를 저장한다. 첫 수락은 token hash 일치, 서명 유효성(signed 방식), 만료 시각, pending 상태를 검증한 뒤 한 번만 accepted로 전이한다. 이미 accepted라면 같은 token과 target identity의 안전한 재시도에만 기존 결과를 반환한다.

기본 정책은 target-bound invite다. 인증된 identity의 검증된 이메일이 Invite의 정규화된 target email과 같거나, Invite가 특정 account_id에 명시적으로 binding되어 있다면 그 account와 일치해야 한다. 로그인하지 않은 브라우저가 링크를 열었다는 사실이나 token 소지만으로는 Membership을 만들지 않는다. 계정 생성 전 이메일을 입력받았다면 반드시 해당 이메일의 소유권 검증까지 끝낸 뒤 비교한다.

전달 가능한 bearer invite를 제품 요구로 허용할 수도 있지만 이는 별도 정책이다. 이 경우 token을 받은 인증 사용자는 원래 수신자가 아니어도 수락할 수 있으므로 전달·메일함 탈취·로그 유출이 곧 가입 권한 이전으로 이어진다. 허용 범위, 부여 가능한 role, 짧은 만료 시간, 단일 사용, 감사 기록을 별도로 정하고 UI에도 수신자 제한이 없음을 드러낸다. 이 예시와 기본 구현은 bearer 방식이 아니라 target-bound 방식이다.

resendInviteacceptInvite의 멱등성도 분리한다. resend의 idempotency key는 재시도 때문에 이메일이 중복 발송되거나 token rotation이 여러 번 일어나지 않게 한다. 새 token을 발급하면 같은 트랜잭션에서 이전 token을 폐기한다. accept의 멱등성은 같은 Invite와 target identity의 성공 재시도가 기존 Membership을 반환하고 좌석 증가·InviteAccepted 발행을 반복하지 않게 한다. 다른 identity의 재시도, 폐기·만료된 token, 이미 다른 주체가 수락한 Invite는 성공으로 간주하지 않는다. 이 Identity 진입 절차와 재시도 방식이 달라도 두 계정 경로는 같은 InviteAccepted 이벤트, Membership invariant, 좌석 원자화 계약으로 수렴한다.

CRUD API라면 PATCH /invites/i-9 { status: "accepted" }처럼 어떤 상태도 쓸 수 있다. 제품 명령은 의도를 제한한다.

POST /workspaces/w-3/invites/i-9/accept
성공:
membership_id: m-12
membership_status: active
billing_model: active_seat_metered
active_seats: 3
active_seat_limit: 20
도메인 오류:
INVITE_EXPIRED
INVITE_REVOKED
INVITE_TOKEN_INVALID
INVITE_TARGET_MISMATCH
SEAT_LIMIT_REACHED
WORKSPACE_SUSPENDED

오류 코드는 문구가 아니다. UI는 SEAT_LIMIT_REACHED를 업그레이드 안내로 표현하고, 다른 클라이언트는 별도 문구로 처리할 수 있다. 서버가 “Toast를 띄울지”를 소유하지 않고 “어떤 사업 규칙 때문에 거부됐는지”를 소유한다.

6.4. 사건을 이벤트와 분석으로 전파한다

섹션 제목: “6.4. 사건을 이벤트와 분석으로 전파한다”

명령이 성공하면 Collaboration 컨텍스트는 이미 일어난 사실인 InviteAccepted를 기록한다. 식별자뿐 아니라 수락 당시 적용한 정책과 판정 결과를 decision record로 함께 남겨야 현재 정책이 바뀐 뒤에도 과거 결정을 재구성할 수 있다.

InviteAccepted:
event_id: evt-41
workspace_id: w-3
invite_id: i-9
membership_id: m-12
actor_user_id: u-17
inviter_user_id: u-4
occurred_at: 2026-07-14T09:00:00Z
schema_version: 3
decision:
policy_version: members-v4
billing_model: active_seat_metered
applied_role: member
applied_seat_limit: 20
active_seat_count_before: 2
active_seat_count_after: 3

이 페이로드에서 actor_user_id는 초대를 수락한 사용자, inviter_user_id는 초대를 만든 사용자다. decision.applied_role은 수락 시점의 정책 검증을 통과해 실제 Membership에 적용한 role이므로, 초대 생성 시점의 제안과 구분해 보존한다.

여기서 active_seat_count는 Collaboration이 소유하는 active Membership 수다. 이 worked example의 Billing은 billing_model = active_seat_metered를 확인한 뒤 활성 수량을 2→3으로 meter한다. applied_seat_limit = 20은 Access가 가입을 허용할 때 쓴 Entitlement 최대치이지, 구매한 좌석 수나 고정 청구 수량이 아니다. 무료 plan에서는 같은 2→3 사건을 hard cap 판정과 Analytics에는 쓰지만 Billing meter에는 보내지 않는다. 선구매 모델이라면 이 사건으로 purchased_seats를 변경하지 않는다.

소비자번역 결과중요한 경계
Audit log누가 누구를 어떤 role로 가입시켰는지 기록사람이 읽을 설명과 원본 ID를 함께 보존한다
Billingactive-seat meter를 2→3으로 반영무료·선구매 모델은 제외하고 중복 수신에도 한 번만 반영한다
AnalyticsInviteAccepted를 product analytics의 invite_accepted로 매핑billing_model, applied_role을 함께 두고 성공한 수락만 세는다
Notification초대자에게 수락 알림 전송알림 실패가 Membership 생성을 되돌리지 않는다
Feature flagworkspace 규모 segment 갱신즉시 강한 일관성이 필요한 권한 판정에는 쓰지 않는다

이 예시의 canonical mapping은 도메인 이벤트 InviteAccepted 하나당 product analytics 이벤트 invite_accepted 하나다. workspace_member_activated를 별도 생명주기 이벤트로 운영한다면 초대 수락이 아니라 Membership이 실제로 active가 된 시점에 발행한다. 관리자 직접 추가나 SCIM provisioning처럼 Invite 없이도 발생할 수 있고, 승인 대기 정책에서는 invite_accepted보다 늦을 수 있으므로 두 이벤트를 같은 이름이나 같은 지표 분자로 혼용하지 않는다.

도메인 이벤트와 분석 이벤트를 반드시 같은 이름으로 만들 필요는 없다. 전자는 시스템에서 일어난 사업 사실이고, 후자는 제품 질문에 답하기 위한 관측 계약이다. 다만 InviteAccepted, member_joined, team_user_added가 설명 없이 같은 사건을 가리키면 event naming drift가 생긴다. 한 canonical 사건과 변환 규칙을 정하고, 이벤트 사전에 주체·발생 조건·중복 처리·버전을 남겨야 한다.

이후 무료 한도가 3명에서 5명으로 바뀌어도 acceptInvite 안의 plan === "free" ? 3 : ...를 여러 군데 고치지 않는다. Access 컨텍스트가 무료 workspace의 members.limit를 5로 계산하고, 명령은 Entitlement만 읽는다. 이 변경은 hard cap만 바꾸며 무료 workspace를 billable로 만들지 않는다. active-seat metered 유료 plan의 최대치를 20에서 50으로 늘려도 당장 청구 수량이 50이 되는 것은 아니며, Billing은 실제 active Membership 수를 meter한다.

이미 active member가 5명인 workspace를 다시 3명 한도로 내리는 정책이라면 즉시 두 명을 삭제할지, 기존 사용자는 유지하고 새 초대만 막을지, 다음 갱신부터 적용할지를 제품 결정으로 명시해야 한다.

좌석처럼 쓰기 허용을 결정하는 entitlement는 “언젠가 갱신될 조회 캐시”로만 두면 안 된다. 명령이 어느 policy_version의 한도를 사용했는지 남기고, 새 한도가 반영되기 전 허용 가능한 지연을 정한다. 초과 허용이 금전 손실이나 보안 문제로 이어진다면 최신 버전을 동기 조회하거나 보수적인 한도를 적용하고, 단순 화면 배지는 수초 늦은 projection을 허용할 수 있다.

미납도 마찬가지다. SubscriptionPastDue가 발생했다고 기능을 즉시 끄는 대신, 7일 유예 정책이 members.limitproject.export entitlement의 valid_until을 계산한다. 이로써 결제 상태, 접근 권한, UI 경고 문구가 서로 다른 책임을 유지하면서도 같은 정책 버전을 추적할 수 있다.

7. 같은 언어를 API·UI·Analytics에 유지한다

섹션 제목: “7. 같은 언어를 API·UI·Analytics에 유지한다”

도메인 모델은 모든 층이 같은 객체를 그대로 복사하라는 뜻이 아니다. 각 층의 표현은 달라도 식별자와 의미 변환이 추적 가능해야 한다.

영역제품 모델이 제공하는 계약
APIworkspace_id, membership_id, entitlement_key와 도메인 오류가 분명하다
UIempty/error/success 표현이 실제 상태와 명령 결과를 따른다
Analytics주체와 집계 단위가 같아 cohort의 분모가 흔들리지 않는다
Feature flagrole, entitlement, workspace 상태의 snapshot을 targeting에 쓴다
Billingsubscription의 과금 모델에 따라 구매 좌석 또는 active-seat meter를 소유한다
Support고객 문의를 같은 객체 ID와 상태 전이로 추적한다

예를 들어 account_id, workspace_id, team_id가 같은 뜻인지 다른 뜻인지 불명확하면 workspace별 활성화율의 분모를 신뢰할 수 없다. 이름을 통일할 수 없다면 경계별 이름과 매핑을 명시한다. Analytics가 user_id만 받고 workspace_id를 받지 않으면 한 사용자가 여러 조직에 속할 때 어느 조직의 활성화인지 복원할 수 없다. 여기서 workspace_id는 이름·이메일 같은 direct PII가 아니라 조직을 대신 가리키는 pseudonymous ID다. 다만 다른 데이터와 결합하면 고객 조직이나 구성원을 식별할 수 있으므로 접근 통제와 보존 정책의 대상에서는 제외하지 않는다.

제품 결정에서 계약까지의 추적은 다음처럼 읽혀야 한다.

제품 결정모델 질문API·이벤트·분석 계약
초대 화면에 seat limit을 보여준다좌석은 어느 시점에 소비되며 과금 모델은 무엇인가응답의 active_seats, active_seat_limit; 실패의 SEAT_LIMIT_REACHED
마지막 owner를 내릴 수 없다Membership role 전이의 불변식은 무엇인가role 변경 명령이 LAST_OWNER_REQUIRED 반환
첫 project 생성을 activation으로 본다workspace와 project의 소유 관계는 무엇인가성공 뒤 ProjectCreated; 분석에서 최초 1회만 first_project_created
삭제 유예 상태를 보여준다deleted_pending에서 허용할 행동은 무엇인가restore와 final delete 명령, audit event를 분리
Pro export를 판매한다plan과 실제 기능 권리는 어떻게 분리되는가project.export entitlement와 정책 버전 기록

Bounded Context와 Aggregate를 크게 잡으면 일관성이 자동으로 좋아진다는 오해가 있다. 경계가 너무 크면 unrelated change까지 같은 모델과 배포, 잠금에 묶인다. 너무 작으면 하나의 불변식을 지키기 위해 여러 서비스와 이벤트를 왕복하고, 실패 중간 상태를 감당해야 한다.

같은 경계에 둘 가능성이 높은 신호

섹션 제목: “같은 경계에 둘 가능성이 높은 신호”
  • 같은 업무 문장에서 함께 등장하고 같은 팀이 규칙을 결정한다.
  • 한 명령이 성공할 때 규칙을 즉시 함께 지켜야 한다.
  • 한 객체 없이 다른 객체의 상태가 의미를 잃는다.
  • 변경 빈도와 보안·감사 요구가 비슷하다.

경계를 나눌 가능성이 높은 신호

섹션 제목: “경계를 나눌 가능성이 높은 신호”
  • 같은 단어가 부서마다 다른 뜻과 생명주기를 가진다.
  • 한쪽 장애가 다른 쪽의 핵심 쓰기를 막아서는 안 된다.
  • 데이터 보존, 접근 통제, 확장 특성이 크게 다르다.
  • 즉시 일관성 대신 이벤트 후 수초 내 반영으로도 제품 약속을 지킬 수 있다.

애그리게이트는 “한 화면에서 같이 보인다”가 아니라 “한 트랜잭션에서 어떤 불변식을 지켜야 한다”로 정한다. Billing과 Workspace가 설정 화면에 함께 보여도 하나의 애그리게이트일 이유는 없다. 반대로 마지막 owner 강등과 새 owner 지정이 한 명령이라면 Membership 두 개를 함께 검증할 조정 지점이 필요하다.

반례 1: CRUD가 사업 행동을 대신한다

섹션 제목: “반례 1: CRUD가 사업 행동을 대신한다”

PATCH /memberships/m-12 { role: "member" }는 마지막 owner인지, 누가 변경했는지, 어떤 사건을 남길지 말하지 않는다. changeRole(targetRole, actor) 명령은 불변식, 권한, 감사 사건을 한곳에서 드러낸다. 단순 프로필 설명처럼 사업 규칙이 없는 값은 CRUD로 충분하지만 상태 전이에 규칙이 붙으면 명령 모델이 낫다.

반례 2: 화면 상태를 도메인 상태로 저장한다

섹션 제목: “반례 2: 화면 상태를 도메인 상태로 저장한다”

workspace.onboardingModalDismissed를 workspace 생명주기에 넣으면 특정 사용자의 UI 선택이 조직 전체 상태처럼 보인다. 반대로 서버에는 active만 저장하고 삭제 유예를 화면의 isDeleting으로만 표현하면 다른 클라이언트와 배치가 삭제 중인 workspace에 쓰기를 허용한다. 상태가 누구의 사실인지와 어떤 행동을 바꾸는지를 기준으로 소유자를 정한다.

반례 3: 판매 plan을 권한으로 직접 사용한다

섹션 제목: “반례 3: 판매 plan을 권한으로 직접 사용한다”

API, 배치, UI가 모두 plan === "pro"를 확인하면 Pro 구성 변경 때 누락이 생긴다. grandfathered plan(기존 고객에게 유지되는 옛 상품), 체험, 쿠폰, 미납 유예가 추가되면 plan 이름 하나로 접근 권리를 설명할 수 없다. Entitlement를 계산하는 한 지점을 두고 각 기능은 기능 키와 한도를 묻는다.

반례 4: 이벤트 이름이 화면 문구를 따라 변한다

섹션 제목: “반례 4: 이벤트 이름이 화면 문구를 따라 변한다”

초기 invite_accepted를 리디자인 뒤 member_joined로 조용히 바꾸면 같은 activation 지표가 배포일을 기준으로 둘로 갈라진다. 이름을 바꿔야 한다면 기존 사건과 새 사건의 의미가 같은지 먼저 판단하고, 같다면 schema version과 alias 기간을 두며, 다르다면 새로운 지표 정의와 전환일을 기록한다.

반례 5: 경계가 너무 크거나 너무 작다

섹션 제목: “반례 5: 경계가 너무 크거나 너무 작다”

User, Workspace, Subscription, 모든 Project를 하나의 ProductAccount 애그리게이트로 묶으면 project 하나를 archive할 때도 전체를 읽고 충돌시킨다. 반대로 각 테이블 행을 독립 애그리게이트로 두면 “마지막 owner 보호”나 “마지막 좌석 동시 수락”을 원자적으로 지키기 어렵다. 변경 충돌과 즉시 지켜야 할 불변식을 함께 보고 경계를 조정한다.

10. 모델은 출시 뒤에도 진화한다

섹션 제목: “10. 모델은 출시 뒤에도 진화한다”

제품 모델은 처음부터 완성되는 ERD(Entity-Relationship Diagram, 개체-관계 다이어그램)가 아니다. 가격 실험, 조직 구조, 규제, 지원 사례가 새 구분을 요구하면서 진화한다. 중요한 것은 새 필드 추가보다 기존 의미와 이력의 호환성을 관리하는 것이다.

10.1. 관찰된 차이를 모델로 승격한다

섹션 제목: “10.1. 관찰된 차이를 모델로 승격한다”

처음에는 Workspace.status = active 하나면 충분할 수 있다. 그러나 지원팀이 “결제 미납으로 쓰기만 막힌 상태”와 “보안 사고로 모든 접근이 중단된 상태”를 반복해서 구분한다면 past_due, suspended를 아무 곳에나 추가하기보다 원인이 Subscription인지 Workspace 운영 정책인지 나눈다. 화면 배지가 다르다는 이유만으로 상태를 늘리지 않고, 허용 행동·소유 팀·복구 전이가 달라질 때 승격한다.

10.2. 의미 변경은 데이터·API·이벤트를 함께 이행한다

섹션 제목: “10.2. 의미 변경은 데이터·API·이벤트를 함께 이행한다”

예를 들어 Membership의 status에 있던 invited를 Invite 엔티티로 분리한다면 다음 순서가 안전하다.

  1. 새 Invite 저장 구조와 읽기 경로를 추가하되 기존 필드를 계속 읽는다.
  2. 기존 invited 행을 Invite로 backfill하고 개수와 샘플 ID를 대조한다.
  3. 새 명령은 Invite만 쓰고, 구버전 소비자를 위해 일정 기간 호환 이벤트를 발행한다.
  4. API와 Analytics가 새 식별자·발생 조건으로 전환됐는지 확인한다.
  5. 관측 기간 뒤 기존 필드와 이벤트를 제거한다.

이때 “초대 수” 지표가 과거에는 membership row, 이후에는 invite row를 세면 시계열이 끊긴다. metric definition에 전환일과 계산 버전을 남기거나 과거 데이터를 새 의미로 재계산해야 한다.

10.3. 정책의 시간 축을 보존한다

섹션 제목: “10.3. 정책의 시간 축을 보존한다”

현재 entitlement만 저장하면 “지난달에는 왜 export가 가능했는가”를 설명할 수 없다. 가격·권한 정책에는 effective_from, effective_until, policy_version 같은 시간 정보를 두거나 변경 사건을 보존한다. 감사 로그에는 최소한 다음 맥락이 필요하다.

  • 누가 초대하거나 role을 바꿨는가
  • 어떤 role과 entitlement가 전후에 적용됐는가
  • 초대는 언제 만료되고 누가 수락했는가
  • active seat count가 몇에서 몇으로 바뀌었고 어떤 과금 모델이 적용됐는가
  • 어떤 정책 버전과 원인 사건이 결정을 만들었는가

감사 로그는 단순 보안 부록이 아니다. 고객 지원이 상태 전이를 재구성하고, 결제 이의와 권한 사고를 설명하게 하는 제품 운영 모델이다.

11. 실패 신호: 모델이 갈라지고 있음을 관찰한다

섹션 제목: “11. 실패 신호: 모델이 갈라지고 있음을 관찰한다”

아래 증상은 코드 정리 문제가 아니라 사업 언어·규칙·소유권이 어긋났다는 신호다.

관찰되는 증상모델의 실패 가능성먼저 확인할 질문
같은 고객을 account, team, workspace로 섞어 부른다보편 언어 또는 컨텍스트 번역 부재각 ID의 동일성·수명·소유자가 같은가
화면마다 admin 여부가 다르다role이 User와 Membership에 중복됨권한의 scope와 정본은 어디인가
구독 변경 뒤 일부 기능만 늦게 켜진다plan check가 흩어졌거나 entitlement 전파가 불명확함권리 계산과 적용 지연 약속은 무엇인가
마지막 좌석을 두 사용자가 동시에 차지한다불변식이 UI 확인이나 비원자적 count에만 있음어느 트랜잭션 경계에서 경쟁을 막는가
지원팀이 DB를 직접 고쳐야 복구된다허용 전이와 복구 명령이 모델에 없음어떤 상태에서 어떤 복구를 허용해야 하는가
이벤트 이름 변경일에 activation이 급변한다event naming drift 또는 지표 정의 변경실제 행동 변화인지 schema 변화인지 구분 가능한가
작은 기능 배포마다 여러 팀의 승인이 필요하다Bounded Context가 과대하거나 소유권이 겹침규칙을 독립 계약으로 번역할 수 있는가
보상 작업과 eventually consistent 상태가 과도하다경계가 과소 분할됨한 불변식을 불필요하게 여러 서비스가 나눠 지키는가
setStatus, updateType만 늘고 업무 동사가 없다CRUD가 상태 전이를 숨김사용자가 실제로 수행한 사업 행동은 무엇인가
과거 고객의 권한을 설명할 수 없다정책 버전과 시간 축이 사라짐당시 subscription·entitlement·정책을 재구성할 수 있는가

실패 신호를 고칠 때 테이블부터 합치거나 서비스를 먼저 나누지 않는다. 용어의 뜻, 불변식, 상태 전이, 규칙 소유자를 다시 합의한 뒤 저장 구조와 배포 경계를 조정한다.

긴 모델링 체크리스트보다 아래 질문에 구체적인 ID, 상태, 숫자, 사건으로 답할 수 있는지가 중요하다.

  1. 이 제품 결정의 주체와 소유 단위는 User, Membership, Workspace 중 무엇인가?
  2. 허용하는 명령과 금지하는 상태 전이는 무엇인가?
  3. 동시 요청에서도 반드시 지켜야 할 불변식은 무엇인가?
  4. plan, subscription, entitlement 중 각 기능이 실제로 참조할 계약은 무엇인가?
  5. 성공한 사실을 어떤 canonical event로 남기며 API와 Analytics는 어떻게 번역하는가?
  6. 6개월 뒤 지원팀이 당시 상태와 정책 버전을 재구성할 수 있는가?
  7. 규칙을 즉시 함께 지킬 경계와 이벤트로 늦게 전파해도 되는 경계는 어디인가?

Product Domain Modeling은 이어지는 billing/entitlement 문서의 기반이다. Subscription 상태와 Entitlement 변경은 제품 객체와 상태 전이 없이는 안전하게 구현하기 어렵다.

  • Billing, Subscription & Entitlement: 결제 생명주기와 기능 접근 권한을 모델링한다.
  • Privacy, Consent & Product Data Governance: 도메인 객체에 붙은 개인정보와 보존 정책을 다룬다.
  • 선택 참고 Experimentation & Feature Flags: 이 모델의 role, entitlement, workspace 상태를 후속 flag targeting 입력으로 소비한다. Product Domain Modeling의 hard prerequisite는 아니다.
  • Account model
  • Workspace model
  • Membership
  • Role and permission
  • Product lifecycle state
  • Domain invariant
  • Entitlement
  • Event taxonomy
  • Audit log
  • Supportability