Core DB 설계 · v3
PostgreSQL 17 · UTF-8 · 16개 테이블 / 94개 컬럼 / 17개 VIEW
기준: schema.sql. 기존 v3를 문서화했으며 스키마를 변경하지 않았다. PostgreSQL 실행 검증은 미완료다.
필요한 사실·의미·증거를 DB 안에 둔다. 그다음 중복 저장과 자료형의 폭을 줄인다.
1. 이 DB의 역할
Core는 사용자·Agent 연결·배송지·구매 진행 상태를 소유한다. Merchant의 상품·Cart·Checkout·Order, Pay의 승인·결제는 마지막으로 확인한 내용과 관측시각을 저장한다. 이 복사본이 Projection이며, 거래 당시 내용을 바꾸지 않는 보존본이 Snapshot이다.
외부 API·Redis·배포 설정 없이 보존된 거래를 조회하고 해석할 수 있다. 아직 전달받지 못한 상태를 알거나, 외부 시스템 없이 새 결제를 실행한다는 뜻은 아니다. 카드·PIN·생체 원본·PG Token·Payment Credential·서명 개인키는 Core에 보관하지 않는다.
현재 범위는 Google 단일 로그인, Merchant별 Cart, 단일 Merchant 구매, KRW, Checkout당 결제·주문 각 1건이다. Cart에서 Checkout으로 내용이 전달되지만 두 테이블 사이의 FK는 없다. 준비 오류와 입력 보완은 checkouts, 검증 후 승인 요청부터는 purchase_requests가 담당한다.
2. 자료형과 읽는 방법
아래 컬럼 사전에서 ?는 NULL 허용, PK는 기본키, →는 실제 FK, 자동은 Identity다. 복합 PK는 표 아래에 명시한다. DOMAIN은 기본 자료형에 검증 규칙과 의미를 붙인 이름이며 별도 테이블이 아니다.
| 저장 표현 | 규칙과 선택 이유 |
|---|---|
integer |
Core ID·FK·Revision. 4바이트. ID는 순환·재사용하지 않는다. |
smallint |
Merchant/Client 등록번호·코드. 2바이트. 거래 ID에 32,767건 상한을 도입하지 않는다. |
boolean |
실제 두 값인 정보만 저장한다. |
epoch_s → integer |
UTC Unix 초, 0 이상. 최대 2038-01-19 03:14:07 UTC. 외부 서명 시각은 원문 그대로 보존한다. |
hash256 → bytea |
정확히 32바이트. SHA-256·PKCE S256 전체 값이다. |
object_json → json |
이름 있는 키의 JSON 객체. 일반 문서는 compact 표현, 서명 문서는 원문을 보존한다. |
purchase_state, error_code, operation_code, receipt_kind, scope_mask → smallint |
허용값은 DOMAIN/CHECK, 의미는 DB 내부 코드표 VIEW가 보존한다. |
text, text[], bytea |
외부 식별자·URI·허용 URI 목록·정확한 바이트. 식별자는 DDL의 COLLATE "C"로 비교하며 잘라 저장하지 않는다. |
고정 코드표는 *_status_codes, scope_codes, error_codes, operation_codes, event_codes, actor_kinds, receipt_kinds다. 코드 의미를 재할당하거나 언어 enum의 나열 순서를 저장값으로 사용하지 않는다. 변경 가능한 Registry는 TABLE에 둔다. 단위·문서 규칙은 DOMAIN/COMMENT와 storage_contracts에 있으므로 코드 저장소가 없어도 해석 가능하다.
3. 컬럼 사전
users · 사용자
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
Core 사용자 식별자 |
google_sub |
text |
Google 계정의 정확한 subject |
google_sub는 유일하다. 이메일·이름·프로필은 저장하지 않는다.
profile_snapshots · 공유하는 불변 공개 Profile
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
Profile 버전 |
profile_url |
text |
실제로 채택한 Profile URI |
document |
bytea |
공개키·서비스·Capability·Handler를 포함한 전체 바이트 |
(profile_url, sha256(document))는 유일하다. 공개키 kid는 문서 안에서 유일해야 한다.
core_settings · 비민감 통합 설정
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
singleton |
boolean · PK |
true만 허용한다. 최대 한 행이며 초기 설정 시 채운다. |
platform_profile_id |
integer · → profile_snapshots |
현재 Platform Profile |
pay_profile_id |
integer · → profile_snapshots |
현재 Pay 공개키 Profile |
pay_base_url |
text |
Pay 호출 Base URL |
google_client_id |
text |
Google 로그인에서 허용할 audience |
merchants · 판매자 Registry
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
smallint · PK · 자동 |
다른 행에서 참조할 작은 등록번호 |
merchant_id |
text |
외부 Merchant 식별자 |
display_name |
text |
현재 판매자 표시명 |
allowed_origin |
text |
허용 Origin. 끝 슬래시 없이 저장한다. |
enabled |
boolean |
현재 사용 허용 여부. 기본 true. |
profile_id |
integer? · → profile_snapshots |
마지막으로 채택한 Profile |
profile_checked_at_s |
epoch_s? |
그 Profile을 성공적으로 확인한 시각 |
profile_valid_until_s |
epoch_s? |
현재 Profile의 캐시 사용 기한 |
merchant_id, allowed_origin은 각각 유일하다. Profile FK와 두 시각은 모두 NULL이거나 모두 존재한다. Profile URI는 allowed_origin + /.well-known/ucp다.
agent_clients · Agent OAuth Client
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
smallint · PK · 자동 |
Client 등록번호 |
client_id |
text |
외부 OAuth Client 식별자 |
display_name |
text |
연결 목록에 표시할 이름 |
redirect_uris |
text[] |
정확히 비교할 허용 URI 목록 |
enabled |
boolean |
현재 인가·Token 사용 허용 여부. 기본 true. |
client_id는 유일하다. URI 배열은 비어 있지 않은 1차원이며 NULL 원소를 금지한다.
auth_sessions · 앱 세션·Agent 연결·Token family
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
세션 ID이자 Agent connectionId / Refresh family 기준 |
user_id |
integer · → users |
세션 소유자 |
client_no |
smallint? · → agent_clients |
Agent Client. NULL이면 자사 모바일 앱. |
scopes |
scope_mask |
실제로 부여한 권한 비트마스크 |
revoked |
boolean |
세션 전체 폐기 여부. 기본 false. |
모바일은 scopes=1984, Agent는 1..63이다. 모바일 Scope도 DB에 저장한다. 권한 변경은 기존 행 수정이 아니라 새 세션 발급이다.
auth_tokens · 인가 Code와 Refresh Token
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
session_id |
integer · → auth_sessions |
Token이 속한 세션 |
expires_at_s |
epoch_s |
이 시각부터 사용 불가 |
used |
boolean |
이미 교환·회전했는가. 기본 false. |
token_hash |
hash256 · PK |
난수 Token 전체의 SHA-256. 원문은 저장하지 않는다. |
pkce_challenge |
hash256? |
존재하면 Code, NULL이면 Refresh |
redirect_uri |
text? |
인가 때 선택한 URI. Code일 때만 존재한다. |
Challenge와 URI는 함께 존재하거나 함께 NULL이다. 세션당 미사용 Refresh는 최대 1개다. 사용한 행은 재사용 탐지에 필요하므로 즉시 삭제하지 않는다.
addresses · 배송지 원본
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
user_id |
integer · PK · → users |
소유자 |
id |
integer · PK · 자동 |
배송지 선택·수정·삭제 식별자 |
is_default |
boolean |
기본 배송지 여부. 기본 false. |
destination |
object_json |
수취인·연락처·주소의 실제 값 |
복합 PK는 (user_id, id)다. 사용자별 기본 배송지는 최대 1개이며, 최소 1개를 강제하지 않는다.
catalog_items · 이미 관측한 상품
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
merchant_no |
smallint · PK · → merchants |
상품 ID의 Merchant 범위 |
external_id |
text · PK |
외부 상품 ID |
observed_at_s |
epoch_s |
저장 문서의 마지막 완전한 관측시각 |
document |
object_json |
관측한 이름·옵션·가격·통화·재고 표시 |
복합 PK는 (merchant_no, external_id)다. 미수집 상품과 품절은 다르며, 검색 가격은 승인 금액이 아니다.
carts · 현재 Merchant Cart
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
외부 생성 전에 확보하는 로컬 Cart ID |
user_id |
integer · → users |
Cart 소유자 |
revision |
integer |
확정된 변경 횟수. 기본 0, 음수 금지. |
merchant_no |
smallint · → merchants |
해당 Merchant |
external_id |
text? |
확인한 외부 Cart ID |
observed_at_s |
epoch_s? |
Cart 문서를 확인한 시각 |
document |
object_json? |
전체 교체 Update와 표시에 필요한 현재 Cart |
(user_id, merchant_no)는 유일하다. 알려진 (merchant_no, external_id)도 유일하다. 미해결 변경이 있으면 다음 변경을 앞질러 처리하지 않는다.
checkouts · 구매 준비 Context와 Order Projection
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
purchaseDraftId이며 로컬 orderProjectionId도 겸한다. |
user_id |
integer · → users |
Checkout / Order 소유자 |
merchant_no |
smallint · → merchants |
해당 Merchant |
business_profile_id |
integer · → profile_snapshots |
협상에 실제 사용한 Business Profile |
platform_profile_id |
integer · → profile_snapshots |
협상에 실제 사용한 Platform Profile |
error_code |
error_code? |
PurchaseRequest 생성 전 준비 오류 |
external_id |
text? |
확인한 Merchant Checkout ID |
external_order_id |
text? |
확인한 Merchant Order ID |
checkout_observed_at_s |
epoch_s? |
Checkout 문서를 확인한 시각 |
order_observed_at_s |
epoch_s? |
Order 상세를 확인한 시각 |
document |
object_json? |
현재 Checkout 내용 |
order_document |
object_json? |
현재 주문·배송 내용 |
negotiation |
object_json |
거래 당시 선택한 불변 협상 결과 |
알려진 (merchant_no, external_id)와 (merchant_no, external_order_id)는 각각 유일하다. Checkout당 주문 1건이므로 별도 Order 테이블을 만들지 않는다.
purchase_requests · 불변 승인 대상과 구매 오케스트레이션
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
한 논리적 구매 요청과 멱등키의 기준 |
checkout_id |
integer · → checkouts |
소유자·Merchant·외부 Checkout·Order를 찾는 연결 |
agent_session_id |
integer · → auth_sessions |
승인을 요청한 Agent 연결 |
status |
purchase_state |
Core 구매 상태. 기본 1. |
error_code |
error_code? |
현재 Core 오류 |
idempotency_key |
text |
Agent가 제출한 정확한 키 |
verification_profile_id |
integer · → profile_snapshots |
Merchant 서명 검증에 실제 사용한 Profile |
evidence |
bytea |
Merchant 서명을 포함한 fullCheckout의 JCS 바이트 |
authorization_observed_at_s |
epoch_s? |
승인 Summary를 확인한 시각 |
payment_observed_at_s |
epoch_s? |
결제 Summary를 확인한 시각 |
authorization_document |
object_json? |
별개의 Pay 승인 Summary |
payment_document |
object_json? |
별개의 Pay 결제 Summary |
(agent_session_id, idempotency_key), 알려진 승인 id, (checkout_id, sha256(evidence))는 각각 유일하다. 상태 1,2,3,4,5,9에 속하는 요청은 Checkout당 최대 1개다.
pay_receipts · 서명된 결정 영수증
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
purchase_id |
integer · PK · → purchase_requests |
이 Receipt가 증명하는 구매 |
kind |
receipt_kind · PK |
1=승인 결정, 2=최종 결제 결과 |
signing_profile_id |
integer · → profile_snapshots |
Receipt 검증에 사용한 Pay Profile |
document |
object_json |
원본 Receipt 본문 전체. URL·Hash만 저장하지 않는다. |
복합 PK는 (purchase_id, kind)다. 종류당 한 번 보존하며, 발급 후 무효화는 Summary·Event에 기록한다. 원래 Receipt를 수정하지 않는다.
inbox · 수신 중복 방지
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
merchant_no |
smallint? · → merchants |
발신 Merchant. NULL이면 단일 Pay 서비스. |
event_id |
text |
발신자가 부여한 이벤트 ID |
body_hash |
hash256 |
수신 Body 전체의 SHA-256 |
PK 대신 UNIQUE NULLS NOT DISTINCT (merchant_no, event_id)를 사용한다. Pay의 NULL도 중복 제거에 포함된다. 실제 업무 내용은 상태·증거 또는 후속 Outbox에 보존한다.
outbox · 미해결 외부 작업
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
재시도에서도 유지할 작업 ID |
aggregate_id |
integer |
대상 Cart 또는 Checkout ID. 직접 FK는 아니다. |
run_after_s |
epoch_s |
실행 가능 시각. 기본 현재 Unix 초. |
operation |
operation_code |
작업명·HTTP Method·대상 종류를 찾는 코드 |
request |
object_json |
고정 Body 또는 불변 참조·템플릿 |
작업 1..3은 Cart, 4..12는 Checkout이다. 순서 제어 키는 (operation IN (1,2,3), aggregate_id, id)다. Cart 42와 Checkout 42를 구분한다.
events · 감사와 사용자 SSE의 공통 로그
| 컬럼 | 자료형·키 | 의미 |
|---|---|---|
id |
integer · PK · 자동 |
감사 식별자 / SSE Last-Event-ID |
user_id |
integer? · → users |
사용자 범위. NULL이면 시스템 공통 감사. |
actor_id |
integer? |
Actor 종류 1..3의 실제 ID. 서비스 Actor는 NULL. |
resource_id |
integer |
이벤트 대상 ID. 대상 테이블은 event_codes가 정한다. |
created_at_s |
epoch_s |
기록 시각. 기본 현재 Unix 초. |
event_type |
smallint |
이벤트명과 대상 테이블을 찾는 코드 |
actor_kind |
smallint |
사용자·세션·Merchant·서비스 구분 |
payload |
object_json |
필요한 before/after·오류·검증·증거 연결 |
actor_id, resource_id는 직접 FK가 아니다. 주소 대상은 user_id와 함께 식별한다. 같은 사용자 SSE는 (user_id, id) 순서로 읽는다.
4. 저장 규칙: 값·시점·증거를 혼동하지 않는다
NULL과 관측시각
일반적으로 NULL 문서는 아직 미관측, Pay의 UNKNOWN은 결과 불명이라고 관측함이다. client_no, inbox.merchant_no, events.user_id/actor_id의 NULL은 컬럼 사전에 적힌 명시적 구분값이다.
Cart·Checkout·Order·승인·결제 문서는 각각 자신의 관측시각과 함께 존재하거나 함께 NULL이다. 외부 ID만 알려지고 상세 조회가 실패할 수 있다. 이때 ID를 유지하고 문서·관측시각을 NULL로 둔다. 늦게 도착한 이벤트의 도착시각만으로 최신 상태를 덮어쓰지 않는다.
현재 문서와 불변 증거
현재 상품·배송지·판매자명으로 과거 거래를 재구성하지 않는다. negotiation은 거래 당시 판매자명과 선택 계약, evidence는 승인 당시 실제 상품·옵션·수량·금액·통화·배송지·만료·Merchant 서명을 보존한다. 협상 때와 검증 때 Profile이 다르면 각각 실제 버전을 참조한다.
| 문서 | 최소 저장 계약 |
|---|---|
destination |
실제 수취·배송 정보. 주소 내부 필드 규격은 아직 미확정이다. |
| Catalog / Cart / Checkout / Order | 이미 관측한 업무 내용 한 벌. ID만으로 외부 조회에 맡기지 않고 raw/normalized 복사본도 중복 저장하지 않는다. 일반 Projection의 최상위 ID는 별도 컬럼에서 조립한다. |
negotiation |
필수 키: merchant_display_name, rest_endpoint, ap2_extension, capabilities 객체, payment_handler 객체. 선택한 Handler의 name/id/version·비민감 config를 보존한다. |
authorization_document |
필수 id, status. 관측한 만료·승인시각·인증방법·필요 행동·마스킹 결제수단도 보존한다. |
payment_document |
필수 paymentId, status. 관측한 금액·통화·Processor 거래 ID 등도 보존한다. Receipt 본문은 중복하지 않는다. |
pay_receipts.document |
실제 받은 서명된 결정 원문. 승인 종류는 APPROVED/REJECTED/EXPIRED/INVALIDATED, 결제 종류는 SUCCESS/FAILED다. 미수신 Receipt는 생성하지 않는다. |
outbox.request |
같은 작업을 재실행할 고정 입력. 수정 가능한 주소 ID만 저장한 뒤 현재 값으로 재구성하지 않는다. Complete의 비밀 Credential은 실행 직전에 동일 Pay Artifact로 취득한다. |
events.payload |
필요한 변경 사실·검증 메타데이터. 이미 컬럼에 있는 Actor·대상·시각, 주소·Token·Artifact 원문은 반복하지 않는다. |
검색 요약으로 상품 상세를 조용히 덮어쓰거나, 과거 상세의 모든 필드를 방금 재확인한 것처럼 표시하지 않는다. 실제 문서 규격·크기·깊이 검증은 서비스의 수집 계약이다. object_json만으로 전체 JSON Schema가 검증되지는 않는다.
서명 검증에 쓰는 바이트
evidence = JCS(fullCheckout)의 UTF-8 바이트
checkout_digest = base64url(SHA-256(evidence)), padding 없음
Merchant 서명 대상 = JCS(fullCheckout에서 negotiation.ap2_extension 전체 제외)
공개키 = verification_profile_id의 keys[] 중 서명 Header의 kid와 일치하는 JWK
Profile의 전체 바이트는 utf8_json()으로 읽고, 공개키는 profile_key()로 찾는다. 이 함수들은 복호화나 서명 검증을 수행하지 않는다. 실제 JCS·ES256·만료·금액·소유권 검증을 통과한 입력만 저장해야 한다.
Receipt는 v3가 새로 제안한 Pay 계약이다. signature는 ES256 Detached JWS, protected header는 alg=ES256과 kid, 서명 대상은 signature를 제외한 Receipt 객체의 JCS다. 기존 다른 형식의 Receipt를 이 형식으로 바꿔 원래 서명이 유지된다고 취급하지 않는다.
5. 쓰기 규칙: DB가 막는 것과 서비스가 해야 하는 것
DB 제약의 범위
| DB가 강제하는 조건 | 서비스가 추가로 검증·실행할 조건 |
|---|---|
| FK·허용 코드·NULL 짝·유일성 | 요청자 권한, 현재 Client/Merchant 허용 여부, Scope, URL/DNS/SSRF |
| 구매 INSERT에서 활성 Agent·동일 사용자·Merchant/Profile·Evidence ID/상태/KRW·키 종류 연결 | 실제 서명·JCS·만료·총액·Capability 검증과 구매 상태 전이 |
| Receipt의 로컬 Profile·구매 연결. 결제 Receipt는 Payment ID·Merchant·Checkout·금액/KRW·최종 상태 일치 | 실제 Receipt 서명, 신뢰된 수집 경로, 승인 결정의 의미적 일치 |
| Profile·Receipt·Event의 일반 UPDATE/DELETE 금지. 구매의 ID/연결/키/Evidence 불변 | 보존 종료 후 승인된 정리와 다형 참조의 대상 유효성 |
| 세션 ID/소유자/Client/Scope, Checkout 연결/협상·알려진 외부 ID, 알려진 Pay ID, Outbox 입력 불변 | Token 소비·회전/폐기의 조건부 갱신, 동시 실행 잠금, 이벤트 순서 처리 |
CHECK가 있는 것과 상태 머신이 구현된 것은 다르다. 런타임 계정에는 DDL·스키마 소유권·불변성 우회 권한을 주지 않는다. VIEW의 security_invoker 역시 사용자별 접근 제어를 대신하지 않는다.
구매 상태와 멱등성
승인 APPROVED ≠ 결제 SUCCESS ≠ Checkout completed ≠ Order 확인이다. 각 값은 독립적으로 읽는다. COMPLETED여도 Receipt가 아직 도착하지 않았을 수 있으므로 거래 완료와 증거 수집 완료를 구분한다.
| 요청 또는 사건 | 처리 규칙 |
|---|---|
| 같은 Agent 세션·같은 Key·같은 Checkout | 기존 PurchaseRequest를 반환한다. 현재 Checkout으로 Evidence를 다시 만들지 않는다. |
| 같은 세션·같은 Key·다른 Checkout | IDEMPOTENCY_KEY_CONFLICT. 다른 Key의 활성 중복은 APPROVAL_ALREADY_EXISTS. |
| 승인 세션 생성 / Complete | 각각 authorization-{purchase_id} / checkout-complete-{purchase_id}. 나머지 작업은 core-outbox-{outbox_id}. 실제 계산은 pending_work에 있다. |
| Checkout 변경 | 제출 전에는 INVALIDATING을 저장하고 Pay 무효화 확인 후 INVALIDATED로 바꾼다. 대기 중 새 승인은 금지한다. |
| Complete 응답 유실 / 재시작에 남은 SUBMITTING | 기존 Checkout·Order 조회로 복구한다. 새 구매·승인·Credential·멱등키로 해결하지 않는다. |
현재 Pay의 동일 Merchant·Checkout·Digest 유일성 때문에 같은 외부 Checkout·같은 Digest의 새 승인은 지원되지 않는다. 재승인은 미해결 거래가 없음을 확인하고 새 Merchant Checkout으로 진행한다. 로컬 Draft ID만 바꾸는 것은 해결이 아니다.
트랜잭션·순서·Token
사용자·대상 행을 잠그고 Revision/상태를 확인한 뒤 작업을 기록한다. Cart Revision은 외부 변경이 확정된 후 증가한다. 단일 활성 Outbox 실행기로 시작하며 같은 대상의 미해결 작업을 넘어가지 않는다. 외부 버전이 없거나 순서가 충돌하면 직렬화된 원본 조회를 예약한다.
사용자 Event ID는 해당 users 행을 잠근 뒤 발급하고 커밋까지 잠금을 유지한다. Sequence만으로 커밋 순서가 보장된다고 가정하지 않는다. SSE는 해당 사용자의 ID 이후 이벤트만, 공개 허용 필드로 변환해 전송한다.
Token 교환은 Hash뿐 아니라 종류·세션·만료·폐기·사용 여부도 확인한다. Refresh 경로는 pkce_challenge IS NULL, Code 경로는 IS NOT NULL이며 Client·선택 URI·PKCE를 검증한다. 사용 표기와 새 Token 발급은 원자적으로 처리한다. 재사용이 확인되면 해당 세션을 폐기한다. 순번 세션 ID는 비밀 Token이 아니다.
6. 읽기·인덱스·운영
DB 단독 조회 경로
| 필요한 정보 | 조회 대상 |
|---|---|
| 가벼운 앱·Agent 상태 | purchase_status — Evidence/Profile/Receipt 전체를 읽지 않는다. |
| 권한 있는 거래 상세·승인 내용·원문 증거 | purchase_detail — 배송지 포함 가능. 일반 Polling에 쓰지 않는다. |
| Receipt와 실제 검증 공개키 | receipt_evidence |
| 세션별 실제 Scope | session_grants |
| 해석된 감사 / 미해결 작업 / 수신 발신자 | event_details / pending_work / inbox_sources |
| 코드 의미·문서 계약 | 코드표 VIEW / storage_contracts |
-- $1: 인증된 사용자 ID, $2: 구매 ID. 실행 드라이버에서 바인딩한다.
SELECT * FROM core.purchase_status
WHERE user_id = $1 AND purchase_request_id = $2;
-- 승인 당시 정보가 필요한 권한 있는 상세 경로에서만 실행한다.
SELECT seller_at_checkout, approved_amount_minor_units, currency,
checkout_digest, authorization_receipt, payment_receipt
FROM core.purchase_detail
WHERE user_id = $1 AND purchase_request_id = $2;
approved_amount_minor_units는 JSON에서 추출한 정수 금액의 text 표현이다. 계산할 때는 계약에 맞게 명시적으로 변환한다. 상세 VIEW나 Event Payload 전체를 Agent/SSE에 공개하지 않는다.
DDL의 인덱스는 정의상 36개: PK/UNIQUE 22개 + 명시적 14개다. 주요 보조 경로는 활성 Agent (user_id,id), 주문 목록 (user_id,id), Outbox 대상 순서·실행시각, SSE (user_id,id)다. 나머지 유일성은 컬럼 사전에 정의했다. JSON GIN·모든 FK·모든 상태에 보조 인덱스를 일괄 추가하지 않았다.
Profile/Evidence Hash는 별도 컬럼이 없어도 표현식 인덱스에 저장·계산된다. JSON 파싱·코드표 JOIN·트리거의 비용도 남는다. 실제 용량과 응답시간은 측정 전 미확정이다.
초기화·보존·복원
빈 DB에 schema.sql을 적용한다. 신뢰된 공개 Profile을 먼저 저장하고 core_settings 한 행, Merchant/Agent Registry를 채운 뒤 거래를 받는다. 기존 v2 위에 실행하는 ALTER 마이그레이션이 아니다.
확정된 Outbox는 결과와 함께 삭제한다. 미해결 작업, 중복·재사용 탐지 기록, 거래에 필요한 Profile·Receipt·Event는 임의 TTL로 삭제하지 않는다. 직접 FK가 없는 감사/작업 참조까지 보존 관계를 확인해야 한다.
# 정의·데이터·VIEW·DOMAIN·함수·COMMENT·시퀀스를 함께 보존한다.
pg_dump -Fc --no-owner --no-acl "$CORE_DATABASE_URL" -f core.dump
pg_restore --no-owner --no-acl --dbname="$RESTORE_DATABASE_URL" core.dump
복원 후 외부 네트워크 없이 purchase_detail, receipt_evidence, session_grants를 읽어 내용·키·권한을 확인한다. 권한/소유자와 실행용 비밀 자격증명의 복원 정책은 별도다. 과거에 저장하지 않은 Profile·Receipt·표시명은 현재 값으로 메워 당시 기록이라고 표시하지 않는다.
7. 구현 전 확인할 빈칸과 검증 상태
| 항목 | 현재 상태 / 필요한 조치 |
|---|---|
| Pay 수집 계약 | 비민감 Receipt 원문과 검증 Profile을 Core에 전달하도록 합의·구현해야 한다. 서명 인코딩은 §4의 v3 제안이며 공식 AP2 호환 주장도 아니다. |
| 주소·문서 입력 계약 | 주소 내부 필드, 실제 입력 길이·크기·깊이 제한은 자료에 완전히 확정되어 있지 않다. 저장 전에 검증할 계약을 확정해야 한다. |
| 보존·Token 수명 | Inbox 재전송 기간·증거 삭제 기간·Token 재사용 탐지 정책을 확정해야 한다. Token 주석의 family absolute expiry 산출 방식은 현재 DDL에 명시되어 있지 않다. |
| 실행 검증 | 기존 패키지는 정적·암호 Fixture 검사만 보고했다. PostgreSQL DDL/통합·동시성·E2E·dump/restore·용량/지연 검증은 미완료다. |
동봉 tests/는 기존 v3의 검증 자료다. 이번 문서화에서는 SQL을 변경하지 않고 컬럼 사전·키·도식·파일 연결을 대조했다. 테스트가 끝난 운영 스키마라고 취급하지 않는다.
문서와 도식 사용
본문은 SVG 이미지 블록, SVG_Blocks.md는 편집용 svg 코드 블록이다. 코드 블록을 이미지 렌더러로 가정하지 않는다. 각 도식의 같은 이름 PNG도 제공한다.
Obsidian: 이 폴더와 assets/를 함께 Vault에 넣는다. 이미지가 연결되지 않으면 해당 SVG를 노트로 끌어 넣는다. Notion: Markdown을 가져온 뒤 도식 위치의 이미지 블록에 해당 SVG를 업로드한다. 가져오기에서 상대경로 자산이 자동 연결된다고 보장하지 않는다. SVG가 표시되지 않는 환경에서는 동봉 PNG를 사용한다. 표시 방식은 Notion 공식 도움말과 Obsidian 공식 도움말을 기준으로 확인했으며, 두 앱의 실제 가져오기 테스트는 하지 않았다.
근거: 첨부 v3 SQL의 TABLE/DOMAIN/VIEW/COMMENT/트리거 및 core_service_minimal_v3_design.md §1–12. 프로젝트 원문의 소유권 §8, 증거 §11, 복구 §17, MVP §24를 유지했다. 본문은 이 설계의 설명이며 미확정 항목을 새 요구사항으로 보충하지 않았다.