@gj-kit/toss-payments-postgresql 핵심 개념
토스페이먼츠 저장소를 직접 운영하는 PostgreSQL 위에 올리되, 암호화 protector를 빠뜨리면 컴파일이 실패합니다.
왜 이 경계가 필요한가
섹션 제목: “왜 이 경계가 필요한가”토스페이먼츠를 자체 DB에 붙이려면 테이블 7종, 원자적이어야 하는 webhook dedupe 전이, 그리고 billing key·가상계좌 secret·취소 재시도 티켓의 at-rest 암호화를 직접 설계해야 합니다. 직접 짜면 조회 후 삽입 방식의 dedupe에서 같은 이벤트의 동시 재전송 2건이 모두 처리권을 얻고, protector를 빠뜨린 store는 billing key를 평문으로 남깁니다. 둘 다 프로덕션에 올라가기 전까지는 아무 신호도 주지 않습니다.
민감값에는 앱 protector가 필요합니다
섹션 제목: “민감값에는 앱 protector가 필요합니다”pool lifecycle과 key custody는 호스트 앱에 남습니다. 필수 protection은 빠뜨린 암호화가 billing 데이터를 plaintext로 저장하지 못하게 합니다.
migration은 명시적인 배포 작업입니다
섹션 제목: “migration은 명시적인 배포 작업입니다”store aggregate는 배포 자동화를 위한 migration 단계 하나를 노출합니다. request 처리와 application startup은 일반 트래픽에서도 반복될 수 있습니다.
패키지가 강제하는 것
섹션 제목: “패키지가 강제하는 것”- protector 없는 조립은 컴파일 실패 —
createTossPaymentsPostgres({ sql })은 타입 에러입니다. aggregate와 민감 store factory 3종이 모두sensitiveValueProtector를 필수 필드로 요구하므로, 평문 저장은 숨은 기본값이 될 수 없고 unsafePlaintextSensitiveValueProtector를 직접 적어 넣은 코드에만 남습니다. - raw string은 lock key가 아닙니다 — opaqueLocks.withLock은 branded OpaqueAdvisoryLockKey만 받습니다. customer ID를 raw string으로 넘기는 코드는 tsc가 TS2345로 거부합니다.
- 타입이 transaction 요건을 강제합니다 — createPgBillingKeyStore는 SqlExecutor를 거부합니다. SELECT … FOR UPDATE → 복호화 → constant-time 비교 → DELETE가 한 connection, 한 transaction 안에서 끝나야 하므로 withConnection을 가진 SqlClient만 통과합니다.
- webhook claim은 단일 문입니다 — src/stores/webhook-dedupe.ts의 claim은 INSERT … ON CONFLICT DO UPDATE 단일 CTE로 전이합니다. 그래서 동시 재전송 N건 중 정확히 1건만 ’claimed’를 받습니다.
- deadlock이 테스트에서 즉시 드러납니다 — ./testing 인메모리 대역은 PostgreSQL이라면 그대로 멈출 중첩에서 MemoryLockContractError(‘nested-lock-api’ · ‘reentrant-lock’)를 던집니다.
앱에 남는 책임
섹션 제목: “앱에 남는 책임”민감값에는 앱 소유 KMS 또는 key-management 경계를 사용하고, 명시적 migration은 한 번 실행하며, 정리 작업은 멱등적으로 유지하세요.
다음은 검증된 레시피 두 개입니다.