59개 테이블 설계 (DBML)
// =====================================================================
// nestpay 데이터베이스 설계서 (schema.dbml)
// DB: MariaDB (버전 확정 대기 - LTS 기준) / 시간대: KST 단일 / 문자셋: utf8mb4
// ---------------------------------------------------------------------
// 설계 원칙 (요구사항 #29 원장 대원칙)
// 1. 원장(ledger_entries)·거래(transactions)는 append-only.
// 앱 DB 계정에 UPDATE/DELETE 권한 미부여 + 방어 트리거(routines.sql).
// 2. 모든 거래는 복식: 거래별 Σ차변 = Σ대변. 검증은 Java + 일 대사 배치.
// 3. 비즈니스 로직은 전부 Java. DB에는 선언적 제약(PK/FK/UNIQUE/CHECK),
// 파티셔닝, 시스템 버전 테이블, "로직 없는 방어 트리거"만 둔다.
// (유지보수 최우선 - 로직의 DB/앱 분산 금지)
// 4. 가변 마스터 테이블(users, merchants, cards, 정책류)은
// SYSTEM VERSIONING 적용(변경 이력 자동 보존) - 마이그레이션에서 지정.
// 5. 금액은 DECIMAL(15,0) KRW 정수. 시각은 DATETIME(KST).
// 6. 개인정보: *_enc = AES-256 암호문, *_hash = 검색/UNIQUE용 HMAC-SHA256.
// ---------------------------------------------------------------------
// 데이터 분류 정책 (필드 단위 기준 - 본 DBML이 개발 기준)
// [암호화 저장 *_enc (AES-256-GCM, 키는 KMS 상당 관리)]
// - 법적 암호화 의무: 계좌번호(account_enc), 카드번호(number_enc·CVC cvc_enc 준용)
// - 자체 상향: 전화번호(phone_enc), OTP 시크릿(otp_secret_enc), 패스키 공개키 외 비밀값 없음
// [단방향 해시 *_hash (HMAC-SHA256, char(64) 단일 규격)]
// - 검색·UNIQUE 필요하지만 원문 불요: ci/di, phone_hash(선물 대상 검색),
// account_hash(중복 검사), number_hash(재사용 차단), token_hash(선물 클레임)
// - 비밀번호·PIN·API시크릿: Argon2id (varchar(255), 파라미터 포함 문자열)
// [평문 저장 (암호화 의무 비대상 + 조회·표시 빈번)]
// - 이름/예금주명(+정규화본: 매칭 연산 필요), 생년월일, 주소, 사업자번호(공개정보),
// 은행코드, 금액·상태·시각 전부. 근거: 고유식별정보·계좌·카드·비밀번호만 암호화 의무.
// [JSON(text) 허용 기준 - 아래 3조건 전부 충족 시에만]
// ① SQL WHERE/JOIN으로 내용을 조회하지 않는다 ② write-once(증거·설정 스냅샷)
// ③ 행마다 스키마가 달라 컬럼화가 부자연스럽다
// → 해당: fds_rules.params(룰별 파라미터 상이), fds_alerts.detail·reject_logs.context·
// audit_logs.detail·integrity_findings.detail(증거 스냅샷), outbox_jobs.payload(작업 인자)
// → 금지: 위 조건을 하나라도 깨면 반드시 컬럼 또는 연결 테이블
// (예: 푸시 대상 회원 목록은 JSON 금지 → push_campaign_targets 연결 테이블)
// [연결 테이블 기준 - N:M 또는 1:N 반복값은 무조건 별도 테이블]
// → lot_allocations(차감:로트 N:M), policy_agreements, inquiry_attachments,
// integrity_findings, push_campaign_targets, admin_allowed_ips
// ---------------------------------------------------------------------
// DBML 표현 한계로 마이그레이션에서만 정의하는 것:
// - 월별 RANGE 파티셔닝: ledger_entries, notification_inbox, external_api_logs,
// reject_logs, app_error_logs, login_histories, pii_access_logs (복합 PK)
// ※ transactions·deposit_notices는 비파티션 확정: MariaDB는 파티션 테이블의
// 모든 UNIQUE 키에 파티션 컬럼 포함을 강제하는데, 멱등성 UNIQUE(멱등키·수신참조)에
// created_at을 넣으면 멱등 보장이 깨짐 → 무결성 > 파티셔닝 (실DB 검증으로 확정)
// - SYSTEM VERSIONING, CHECK 제약 일부, 방어 트리거/이벤트(routines.sql)
// ---------------------------------------------------------------------
// 보존 매트릭스 (100만건+·5년 보관 요구 반영 - 전수 감사 2026-07-21 확정)
// [영구/법정 5년+] transactions, ledger_entries, lot_allocations, point_lots,
// audit_logs, policy_agreements, users·cards·merchants(+버전 이력), unmatched_deposits
// [5년 후 파티션 DROP] external_api_logs, reject_logs, app_error_logs,
// login_histories, pii_access_logs, deposit_notices(아카이브), daily_wallet_snapshots
// [단기] notification_inbox 30일(파티션 DROP), outbox_jobs DONE/EXHAUSTED 90일(DELETE),
// webhook_deliveries DELIVERED 180일(DELETE), gift_links CLAIMED/EXPIRED 1년 후 아카이브 검토
// 삭제 수단: 파티션 테이블=DROP PARTITION(O(1)), 비파티션 소형=배치 DELETE.
// transactions·deposit_notices(비파티션 대형)=연 단위 아카이브 런북(routines.sql 6절).
// ---------------------------------------------------------------------
// 복식 분개 규약 (거래 유형별 leg 표 - queries.sql 0장에 전체 표)
// 시스템 지갑(SYSTEM)은 핫로우 방지를 위해 ①FOR UPDATE 잠금 제외 ②balance 동기 갱신 제외
// ③원장 balance_after = NULL (일 마감 배치가 SUM으로 balance 파생·검증).
// 사용자·매장 지갑만 잠금·balance_after 체인 유지. SYSTEM 지갑 잔액은 음수 허용
// (SETTLEMENT_CLEARING = 은행 실계좌 대사 계정).
// ---------------------------------------------------------------------
// 운영 규칙 (감사 확정)
// - 커서 페이징 표준: 정렬키+id 복합 키셋 (created_at < :ts OR (= AND id < :id)). OFFSET 금지.
// - audit_logs.detail·reject_logs.context에 개인정보 원문 금지(마스킹/PK 참조만).
// - *_enc 암호문은 [키버전 1B][nonce][ciphertext][tag] 직렬화 - 키 로테이션 대비(별도 컬럼 없음).
// - 진행중 상태 집합 상수 = ('PENDING','HOLD','UNKNOWN') - 한도·탈퇴·계좌변경 검증 공통.
// - 재발행 상호참조 = 입금→출금 단방향 저장 + ix_txn_related 역조회로 충족(트리거 불변 유지).
// ---------------------------------------------------------------------
// 거래상세 구조 (발주 답변 2026-07-22)
// - 원장 변동은 단일 transactions 테이블에 type/subtype으로 분기(거래 단위로 쪼개지 않음).
// "거래(거래타입, 상세PK) ← 거래상세": transactions.detail_id 가 거래타입별 상세 테이블 PK를 참조.
// - 업무 상세만 거래타입별 테이블로 분리: pg_orders(PG결제)·gift_links(선물링크)·
// deposit_notices(입금통지)·merchant_qrs(QR결제) 등. (type,subtype)이 상세 테이블을 결정.
// - ledger_entries(복식 원장)는 그대로 단일 append-only. 상세 설계는 재논의 예정.
// - 서비스 범위: 한국 내 전용(글로벌 미지원 확정) → 전 시스템 KST 단일.
// - 매장 정산: 대기(+N일) 구조·로직은 구현 유지. 매장 기본 delay=0(즉시 정산),
// 향후 값만 바꾸면 +1일/+2일 대기 적용 가능(발주 2026-07-22).
// ---------------------------------------------------------------------
// 확정 정책 (발주 회신 반영 - 2026-07-22 이후)
// - 결제·충전 채널: 발주사 자체 PG 사용 - 가상계좌 입금통지(웹훅) + 직접계좌 펌뱅킹 출금이체.
// (금결원 오픈뱅킹 직접 이용기관 등록 대신 발주사 PG 규격 연동. 규격서 확정 후 상세 매핑)
// - 본인인증: 쿠콘 또는 드림시큐리티. 1원인증·계좌실명조회: 쿠콘 또는 발주사 기존 계약분.
// - 상품권 충전 UX·상품권사(컬처랜드·해피머니 등) 연동: 범위 제외(발주 확정, 미구현).
// - 포인트 전환(외부 제휴 포인트 ↔ nestpay): 설계 유지·구현 보류(발주 2026-07-22).
// 실제 연동 요청 시 즉시 착수 가능하도록 스키마 완비 상태 유지:
// exchange_providers(공급자·환율), EXCHANGE 시스템지갑/거래유형(EXCHANGE_IN·OUT)/수수료유형(USER_EXCHANGE),
// point_lots.source=EXCHANGE, 복식분개 leg(queries 0.3)까지 정의. 구현은 어댑터(ConversionProvider)만 추가.
// → 전환 유입분도 유상(DEPOSIT) 로트로 계상 → 환급은 소스 무관 전체 DEPOSIT 잔액 기준 유지(소스별 차등 없음).
// ※ 전환 유입분의 선불충전금 별도관리 대상 여부는 법무 확인.
// - 유상(DEPOSIT)/무상(REWARD) 2구분: REWARD는 결제만(환급·출금 불가), DEPOSIT는 결제·선물·출금·환급.
// - 환급: 전체 충전(DEPOSIT) 잔액 기준(전자금융거래법 잔액 환급 규정 - 최종 비율·약관은 법무 확정).
// - 회원간 송금: 선불수단 내부 원장 이체(두 지갑 원자적 차감/증액). 양수도 방식 기준 -
// 전자자금이체업 채택 여부는 법률자문 확정 대상(원장 골격 동일, 한도·약관·실명확인만 상이).
// - 용어: DB 명칭 DEPOSIT = 충전(관리 편의). 사용자 대면 UI는 '충전'으로 표기. 은행 입금통지 이벤트는 deposit_notices(별도 테이블).
// =====================================================================
// ============================== 1. 계정 ==============================
// 요구 #35: 사용자/매장/관리자 계정 완전 분리 (단일 회원 테이블 금지)
Table users {
id bigint [pk, increment]
login_id varchar(20) [not null, unique, note: '영숫자 4~20, 소문자 정규화 저장(#45)']
password_hash varchar(255) [not null, note: 'Argon2id']
name varchar(100) [not null, note: '본인인증 실명']
name_norm varchar(100) [not null, note: '이름 비교용 정규화(공백·특수문자 제거, 로마자화)']
ci_hash char(64) [not null, note: '본인인증 CI HMAC']
active_ci char(64) [note: 'status=ACTIVE일 때만 ci_hash 복사, 그 외 NULL - UNIQUE로 1인 1활성계정 강제(#7)']
di_hash char(64)
phone_enc varbinary(64) [not null]
phone_hash char(64) [not null, note: '선물 대상 검색용(#5·#6)']
birth_date date [note: '본인인증 결과. 성인 전용 검증']
marketing_agree boolean [not null, default: false, note: '광고성 푸시 수신동의(#20)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|WITHDRAWN']
withdrawn_at datetime [note: '탈퇴 시각 - 재가입 대기(admin 설정) 판정 기준']
destroy_due_at date [note: '개인정보 파기 예정일 = 탈퇴+법정보존. 파기 배치 대상 선별']
anonymized_at datetime [note: '파기(익명화) 완료 시각 - phone_enc·name·birth_date 무효화']
created_at datetime [not null]
Indexes {
active_ci [unique, name: 'ux_users_active_ci']
ci_hash [name: 'ix_users_ci', note: '재가입 대기 판정(탈퇴 계정 포함 CI 조회)']
phone_hash [name: 'ix_users_phone_hash']
(status, created_at) [name: 'ix_users_status']
}
Note: 'SYSTEM VERSIONING. 탈퇴해도 행 유지(#39, 법정 보존)'
}
Table merchants {
id bigint [pk, increment]
biz_no varchar(10) [not null, unique, note: '사업자등록번호(국세청 진위확인 #28)']
biz_name varchar(200) [not null]
ceo_name varchar(100) [not null]
ceo_name_norm varchar(100) [not null]
ceo_ci_hash char(64) [not null, note: '대표자 본인인증']
biz_type varchar(20) [not null, note: 'CORP(법인)|SOLE(개인사업자)']
category varchar(50)
address varchar(300)
biz_cert_path varchar(300) [note: '사업자등록증 이미지(비공개 저장)']
vat_mode varchar(10) [not null, default: 'TAXED', note: 'TAXED|EXEMPT - 매장이 설정(#24)']
vat_rate decimal(5,2) [not null, default: 10.00, note: '결제 시점 스냅샷의 원천']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|ACTIVE|REJECTED|SUSPENDED|CLOSED']
reject_reason varchar(500)
approved_by bigint [ref: > admin_accounts.id, note: '승인 관리자(#28)']
approved_at datetime
created_at datetime [not null]
Indexes { status [name: 'ix_merchants_status', note: 'admin 심사 큐(PENDING)·상태별 목록'] }
Note: 'SYSTEM VERSIONING. 대표자 변경=재심사(2차 항목 3)'
}
Table merchant_accounts {
id bigint [pk, increment]
merchant_id bigint [not null, unique, ref: > merchants.id, note: '매장 1계정(하위계정 미도입)']
login_id varchar(20) [not null, unique]
password_hash varchar(255) [not null]
status varchar(20) [not null, default: 'ACTIVE']
created_at datetime [not null]
}
Table admin_accounts {
id bigint [pk, increment]
login_id varchar(20) [not null, unique]
password_hash varchar(255) [not null]
name varchar(100) [not null]
is_root boolean [not null, default: false, note: '계정 생성·삭제·OTP 초기화는 root만(#9 정책)']
otp_secret_enc varbinary(128) [note: 'TOTP 시크릿. 전 계정 의무화 - 미설정 시 기능 제한']
otp_enabled boolean [not null, default: false]
otp_fail_count int [not null, default: 0]
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|LOCKED|DISABLED']
created_at datetime [not null]
}
Table admin_allowed_ips {
id bigint [pk, increment]
cidr varchar(50) [not null, note: '단건 IP 또는 CIDR(#33)']
memo varchar(200)
created_by bigint [not null, ref: > admin_accounts.id]
created_at datetime [not null]
Note: '마지막 1건 삭제 불가·본인 IP 삭제 경고는 앱 로직'
}
// --- 인증 부속 (계정 3종 공용: principal_type = USER|MERCHANT|ADMIN) ---
Table auth_pins {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
pin_hash varchar(255) [not null, note: 'Argon2id. 간편로그인+거래인증 겸용(#16)']
fail_count int [not null, default: 0, note: '5회 초과 시 전체 로그인 강등']
locked_until datetime
updated_at datetime [not null]
Indexes { (principal_type, principal_id) [unique, name: 'ux_pin_principal'] }
}
Table auth_passkeys {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
credential_id varchar(255) [not null, unique, note: 'FIDO2/WebAuthn']
public_key varbinary(512) [not null]
device_label varchar(100)
created_at datetime [not null]
Indexes { (principal_type, principal_id) [name: 'ix_passkey_principal'] }
}
Table auth_devices {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
device_uid varchar(128) [not null, note: '앱 설치 식별자 - PIN 기기 바인딩']
platform varchar(10) [not null, note: 'IOS|ANDROID']
model varchar(100)
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED - 동시 1기기: 새 기기 등록 시 기존 REVOKED(2차 항목 4)']
last_login_at datetime
created_at datetime [not null]
Indexes {
(principal_type, principal_id, status) [name: 'ix_device_principal']
(principal_type, principal_id, device_uid) [unique, name: 'ux_device_uid']
device_uid [name: 'ix_device_uid', note: 'FDS 다계정기기 역조회(동일 기기→복수 계정)']
}
}
Table login_histories {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
method varchar(20) [not null, note: 'PASSWORD|PIN|PASSKEY|OTP']
ip varchar(45)
device_uid varchar(128)
result varchar(20) [not null, note: 'SUCCESS|FAIL_PW|FAIL_OTP|BLOCKED_IP 등']
created_at datetime [not null]
Indexes { (principal_type, principal_id, created_at) [name: 'ix_login_hist'] }
Note: '사용자 로그인 이력 화면(2차 항목 9) + 보안 감사. 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거'
}
// --- 은행 계좌 (사용자·매장 공용, 소유자 구분) ---
Table bank_accounts {
id bigint [pk, increment]
owner_type varchar(10) [not null, note: 'USER|MERCHANT']
owner_id bigint [not null]
bank_code varchar(10) [not null]
account_enc varbinary(128) [not null]
account_hash char(64) [not null, note: '중복 등록 검사용']
holder_name varchar(100) [not null, note: '실명인증 예금주']
holder_name_norm varchar(100) [not null, note: '정규화 prefix 비교(#15 확장 규칙)']
verified_at datetime [note: '실명+1원인증 완료 시각. 순서: 실명일치→1원인증(#15)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REMOVED - 변경 시 REMOVED 처리, 이력 보존(#10 정책)']
cooldown_until datetime [note: '계좌 변경 후 출금 냉각 24h']
created_at datetime [not null]
Indexes { (owner_type, owner_id, status) [name: 'ix_bank_owner'] }
Note: '출금은 ACTIVE 계좌 1건으로만(#36). ACTIVE 1건 보장은 앱 트랜잭션'
}
// ============================ 2. 카드·지갑 ============================
Table cards {
id bigint [pk, increment]
user_id bigint [not null, ref: > users.id]
number_hash char(64) [not null, unique, note: '재사용 영구 차단의 단일 진실(#2). 삭제 금지']
number_enc varbinary(64) [not null, note: 'BIN 972963 + 랜덤9 + Luhn, 16자리']
cvc_enc varbinary(32) [not null, note: '고정 CVC 확정. 표시용, 서버 암호화 보관']
issued_at datetime [not null]
expires_on date [not null, note: '발급+5년. 만료 시 재발행 절차 재사용']
design_code varchar(20) [not null, note: 'OCEAN|MIDNIGHT|SUNSET|PEARL(#10)']
color_code varchar(20) [note: '사용자 선택 색상(#7)']
is_primary boolean [not null, default: false, note: '대표 카드 = 기본 수취(#5 정책)']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|REISSUED|EXPIRED']
reissued_to bigint [ref: > cards.id, note: '재발행 체인 참조(#2)']
created_at datetime [not null]
Indexes { (user_id, status) [name: 'ix_cards_user'] }
Note: 'SYSTEM VERSIONING. 발급 수량 카운트 = ACTIVE만(#43). 카드번호 = 지갑 식별자(#41)'
}
Table wallets {
id bigint [pk, increment]
owner_type varchar(12) [not null, note: 'USER_CARD|MERCHANT|SYSTEM']
card_id bigint [unique, ref: > cards.id, note: 'USER_CARD일 때만, 카드 1:1']
merchant_id bigint [unique, ref: > merchants.id, note: 'MERCHANT일 때만. 정산 출금·취소 반환 전용(#8 정책)']
system_code varchar(30) [unique, note: 'SYSTEM일 때만: FEE_REVENUE(수수료수익)|EXPIRED(낙전)|GIFT_ESCROW(선물보류)|UNMATCHED(미매칭입금)|SETTLEMENT_CLEARING(대외청산)|FORFEITED(탈퇴 소액포기)|EXCHANGE_{제휴사}(포인트전환 청산, #49 구현보류)']
balance decimal(15,0) [not null, default: 0, note: 'CHECK: SYSTEM 제외 balance>=0. 사용자·매장=FOR UPDATE 잠금 지점, SYSTEM=일 배치 파생(핫로우 방지 규약)']
created_at datetime [not null]
Indexes { owner_type [name: 'ix_wallets_owner_type', note: '시스템 지갑 현황·유형별 집계'] }
Note: 'CHECK: owner_type별 해당 참조 컬럼만 NOT NULL(마이그레이션 정의)'
}
Table point_lots {
id bigint [pk, increment]
wallet_id bigint [not null, ref: > wallets.id]
lot_type varchar(10) [not null, note: 'DEPOSIT(유상 충전·포인트전환 유입: 결제·선물·출금·환급 가능)|REWARD(무상 적립: 결제만, 환급·출금 불가) - 유상/무상 2구분. 환급은 소스 무관 전체 DEPOSIT 잔액 기준']
source varchar(30) [not null, note: 'DEPOSIT(충전)|GIFT(선물수취)|REISSUE(재발행 이관)|EVENT(이벤트 적립)|EXCHANGE:{code}(포인트전환 유입, 구현보류) - 이력 메타(환급 판정에 미사용)']
amount_init decimal(15,0) [not null]
amount_remaining decimal(15,0) [not null, note: 'CHECK(0 <= remaining <= init). 감소만 허용(방어 트리거)']
expires_at datetime [not null, note: '모든 포인트 유효기간 필수(#42). 재발행 이관은 승계, 선물 수취는 리셋(승인 정책)']
origin_lot_id bigint [ref: > point_lots.id, note: '재발행 승계 시 원 로트 참조']
created_at datetime [not null]
Indexes {
(wallet_id, expires_at) [name: 'ix_lots_fifo', note: 'FIFO 소진(만료 임박 우선) 조회']
(expires_at, amount_remaining) [name: 'ix_lots_expiry', note: '만료 배치 스캔(low-watermark 하한 병용)']
(lot_type, amount_remaining) [name: 'ix_lots_type_remaining', note: '별도관리 대사(DEPOSIT 잔여 합산)']
}
}
// ============================== 3. 원장 ==============================
Table transactions {
id bigint [pk, increment]
txn_uid char(26) [not null, unique, note: '대외 노출용 ULID']
type varchar(12) [not null, note: 'DEPOSIT(충전)|WITHDRAW(출금·환급)|GIFT(선물)|PAYMENT(결제)|CANCEL(취소)|ADJUST(관리자 조정)|EXPIRE(소멸)']
subtype varchar(20) [not null, note: 'VACCT(가상계좌 입금)|FIRMBANK(펌뱅킹 이체)|REISSUE|GIFT_LINK|GIFT_PHONE|GIFT_QR|QR_STORE|QR_ORDER|CPM|PG_ONLINE|SETTLEMENT|FORFEIT(소액포기)|UNMATCHED_RETURN|EXCHANGE_IN|EXCHANGE_OUT(포인트전환, 구현보류) 등']
status varchar(12) [not null, note: 'PENDING|HOLD|UNKNOWN|CONFIRMED|FAILED|REVERSED|CANCELED - 선차감·리컨실러 상태머신(#3)']
initiator_type varchar(10) [not null, note: 'USER|MERCHANT|ADMIN|SYSTEM']
initiator_id bigint [not null, default: 0]
idempotency_key varchar(64) [note: 'UNIQUE(initiator_type, initiator_id, idempotency_key) - 사용자 스코프 멱등(#3)']
card_id bigint [ref: > cards.id]
counterparty_card_id bigint [ref: > cards.id, note: '선물 수신 카드 - 받은선물 내역·gift_recv 집계·FDS 선물집중의 키(감사 보완)']
merchant_id bigint [ref: > merchants.id]
amount decimal(15,0) [not null]
fee_amount decimal(15,0) [not null, default: 0]
fee_rate_snap decimal(7,4) [note: '적용 정률 스냅샷(#25)']
fee_fixed_snap decimal(15,0) [note: '적용 정액 스냅샷']
vat_amount decimal(15,0) [note: '결제 시점 부가세 스냅샷(#24)']
cancelable_until datetime [note: '결제 시점 취소기한 스냅샷']
scheduled_at datetime [note: '출금·정산 실행 예정 시각 - 신청 시점 payout_policies(+N일 HH시) 스냅샷. 정책 변경 소급 방지']
related_txn_id bigint [note: '역분개·이관·취소의 원거래 참조 - 추적 플로우차트(#32)의 간선']
bank_tran_ref varchar(64) [note: '펌뱅킹 거래 식별자 - 리컨실러 조회 키']
bank_account_id bigint [ref: > bank_accounts.id, note: '출금·정산 실행 대상 계좌 스냅샷 - 이후 계좌 변경해도 "어디로 나갔나" 불변(전수 대조에서 발견된 누락 보완)']
detail_id bigint [note: '거래상세 PK 참조 (발주 답변 2026-07-22): 원장=단일 거래 테이블(type 분기), 업무 상세만 거래타입별 테이블로 분리 — "거래(거래타입,상세PK) ← 거래상세". 상세 테이블 = pg_orders(PG결제)/gift_links(선물링크)/deposit_notices(입금)/merchant_qrs(QR결제) 등, (type,subtype)이 어느 상세 테이블인지 결정. 상세 설계는 재논의 예정']
pg_order_id bigint [note: 'PG 온라인 결제 주문 참조 (detail_id의 PG 사례 - 재편 시 detail_id로 통합)']
fail_reason varchar(30) [note: 'MERCHANT_INSUFFICIENT_BALANCE|CANCEL_WINDOW_EXPIRED 등 코드']
memo varchar(200) [note: '선물 메시지(50자·금칙어 필터) 등']
created_at datetime [not null]
confirmed_at datetime
Indexes {
(card_id, created_at) [name: 'ix_txn_card', note: '이용내역(#13)·월명세(#23) 주 쿼리']
(merchant_id, created_at) [name: 'ix_txn_merchant', note: '매장 매출 조회(#24)']
(status, created_at) [name: 'ix_txn_status', note: '리컨실러·장기체류 감시(#30)']
(initiator_type, initiator_id, idempotency_key) [unique, name: 'ux_txn_idem']
(initiator_type, initiator_id, type, created_at) [name: 'ix_txn_initiator_date', note: '한도 일/월 합산(#37 사용자 전 카드 합산)·통합 내역의 주 인덱스']
bank_tran_ref [name: 'ix_txn_bank_ref']
created_at [name: 'ix_txn_created', note: 'admin 기간 검색·일 마감 집계·FDS 배치 스캔(비파티션 보완)']
related_txn_id [name: 'ix_txn_related', note: '추적 CTE·역분개 역조회·재발행 상호참조']
(type, status, scheduled_at) [name: 'ix_txn_withdraw_sched', note: '출금 실행 배치 - filesort·과잠금 제거']
(initiator_type, initiator_id, created_at) [name: 'ix_txn_initiator_created', note: '통합 내역(type 무관 기간 조회)']
(counterparty_card_id, created_at) [name: 'ix_txn_counterparty', note: '받은 선물 조회']
}
Note: 'append-only(취소=CANCEL 신규 행). 비파티션(UNIQUE 멱등 보장 우선) - 장기 증가 대응은 연 단위 아카이브 절차로'
}
Table ledger_entries {
id bigint [pk, increment, note: '실PK는 (id, created_at) - 월 파티셔닝']
txn_id bigint [not null, note: 'FK(transactions) - 파티션 간 FK는 논리 참조(앱 강제)']
wallet_id bigint [not null, ref: > wallets.id]
direction char(2) [not null, note: 'DR|CR. 거래별 ΣDR=ΣCR (복식·Java 검증+일 대사)']
amount decimal(15,0) [not null, note: 'CHECK(amount > 0)']
balance_after decimal(15,0) [note: '사용자·매장 지갑=NOT NULL 체인(#29). SYSTEM 지갑 leg=NULL(핫로우 규약, 일 배치 SUM 검증) - NULL 규칙은 CHECK+대사로 강제']
created_at datetime [not null]
Indexes {
(wallet_id, id) [name: 'ix_ledger_wallet_chain', note: '잔액 체인 검증 순서']
txn_id [name: 'ix_ledger_txn']
}
Note: 'append-only 절대(#29): UPDATE/DELETE 방어 트리거 + 권한 미부여. 월 파티션'
}
Table lot_allocations {
id bigint [pk, increment]
ledger_entry_id bigint [not null, note: '차감(DR) 엔트리']
lot_id bigint [not null, ref: > point_lots.id]
amount decimal(15,0) [not null]
Indexes {
lot_id [name: 'ix_alloc_lot']
ledger_entry_id [name: 'ix_alloc_entry', note: '취소 시 원 소진 로트 역추적 - 풀스캔 방지(감사 보완)']
}
Note: '한 차감이 복수 로트에 걸친 배분 기록 - 유효기간 FIFO 소진·취소 복원 근거(환급은 소스 무관 전체 DEPOSIT 잔액 기준이라 유형판정 미사용)'
}
// ======================= 4. 충전 입금 (발주사 PG) =======================
// #36: 발주사 PG 가상계좌 입금 + 펌뱅킹. 입금통지 = PG 웹훅(즉시 감지) + 펌뱅킹 조회(확정) 이중 소스.
// 발주 확정(2026-07-22): 발주사가 PG사 - 가상계좌 발급·입금통지 웹훅·펌뱅킹 출금이체 규격 사용.
// 식별 방식(가상계좌 값 규격)은 발주사 PG 규격서 회신 후 kind/value 고정.
Table deposit_identifiers {
id bigint [pk, increment]
user_id bigint [not null, ref: > users.id]
kind varchar(20) [not null, note: 'VIRTUAL_ACCOUNT|DEPOSITOR_CODE - 펌뱅킹 계약 확정 후 단일화']
value varchar(64) [not null, unique, note: '가상계좌 식별자 평문 여부는 법무 확인 대기 - 의무 적용 시 value_hash 전환(질의서 반영)']
status varchar(20) [not null, default: 'ACTIVE']
created_at datetime [not null]
}
Table deposit_notices {
id bigint [pk, increment]
source varchar(10) [not null, note: 'PGVACCT(발주사 PG 가상계좌 입금통지 웹훅)|FIRMBANK(펌뱅킹 조회 확정)|SMS(보조 감지)']
raw_text text [note: 'SMS 원문 보존(#29 증거 보존) - 절단 방지 TEXT']
dedup_key char(64) [not null, note: 'SMS=HMAC(수신번호+원문+수신분), 펌뱅킹=bank_tran_ref 사본 - 소스별 결정적 중복키(SMS는 ref 부재)']
bank_tran_ref varchar(64) [note: '펌뱅킹 거래 식별자']
parsed_amount decimal(15,0)
parsed_name varchar(100)
parsed_name_norm varchar(100) [note: '정규화 prefix 매칭용']
identifier_value varchar(64) [note: '가상계좌/입금자코드 파싱값']
status varchar(20) [not null, default: 'NEW', note: 'NEW|MATCHED|UNMATCHED|IGNORED']
matched_txn_id bigint [note: '매칭 성사 시 DEPOSIT(충전) 거래 참조']
received_at datetime [not null]
Indexes {
(source, dedup_key) [unique, name: 'ux_notice_ref', note: 'INSERT IGNORE 금지 - 1062만 앱에서 멱등 처리']
(status, received_at) [name: 'ix_notice_status']
}
}
Table unmatched_deposits {
id bigint [pk, increment]
notice_id bigint [not null, ref: > deposit_notices.id]
amount decimal(15,0) [not null]
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|MATCHED|RETURNED|HOLD - 관리자 재량 처리(#48)']
resolved_by bigint [ref: > admin_accounts.id]
resolved_at datetime
resolution varchar(20) [note: 'MANUAL_MATCH|RETURN|HOLD']
resolution_note varchar(500) [note: '사유 필수 - 원장 역분개와 이중 기록']
return_method varchar(30) [note: '반환 방법(펌뱅킹 이체 등)']
return_bank_code varchar(10)
return_account_enc varbinary(128) [note: '반환 계좌 AES-256 - 계좌번호 암호화 의무(감사 보완)']
created_at datetime [not null]
Note: '입금 즉시 UNMATCHED 시스템 지갑에 원장 계상 - 장부 밖 돈 없음'
}
// ============================== 5. 선물 ==============================
Table gift_links {
id bigint [pk, increment]
sender_card_id bigint [not null, ref: > cards.id]
hold_txn_id bigint [not null, note: '선차감(GIFT_ESCROW 계상) 거래']
token_hash char(64) [not null, unique, note: '클레임 토큰(1회용) - 원문 미저장']
amount decimal(15,0) [not null]
message varchar(50) [note: '금칙어 필터(2차 항목 8)']
expires_at datetime [not null, note: 'TTL 24시간 확정 - 만료 시 자동 반환']
status varchar(20) [not null, default: 'CREATED', note: 'CREATED|CLAIMED|EXPIRED|CANCELED(송금인 회수)']
claimed_card_id bigint [ref: > cards.id, note: '수령 카드(수령자 대표 카드)']
claimed_at datetime
Indexes {
(status, expires_at) [name: 'ix_gift_expiry', note: '만료 반환 배치']
(sender_card_id, status) [name: 'ix_gift_sender', note: '보낸 선물 목록·취소기간 미경과 탈퇴검증']
}
}
// =========================== 6. QR·PG 결제 ===========================
Table merchant_qrs {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
qr_type varchar(15) [not null, note: 'STORE_STATIC|STORE_DYNAMIC|ORDER - USER_PAY(CPM)·USER_RECEIVE는 Redis 단명 토큰(DB 미저장)']
label varchar(50) [note: '카운터1 등 - 매장당 복수 QR(#24)']
amount decimal(15,0) [note: 'ORDER형만']
key_version int [not null, default: 1, note: 'AES-GCM 키 버전']
status varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED|USED(ORDER 1회용)']
expires_at datetime [note: 'DYNAMIC/ORDER TTL']
created_at datetime [not null]
Indexes { (merchant_id, status) [name: 'ix_qr_merchant'] }
}
Table merchant_api_credentials {
id bigint [pk, increment]
merchant_id bigint [not null, unique, ref: > merchants.id]
client_id varchar(32) [not null, unique]
api_key_hash varchar(255) [not null, note: '시크릿 해시만 보관(발급 시 1회 표시)']
prev_key_hash varchar(255) [note: '키 롤 유예(24h 신구 동시 유효)']
prev_key_expires_at datetime
webhook_url varchar(500) [note: '매장앱에서 입력(#26)']
webhook_secret_enc varbinary(512) [note: 'AES-256-GCM 암호화 - 발신 웹훅 HMAC 서명 생성에 원문 필요(해시 불가, 감사 보완)']
status varchar(15) [not null, default: 'REQUESTED', note: 'REQUESTED(신청)|APPROVED|REJECTED|REVOKED - admin 승인 큐(#28)']
requested_at datetime [not null]
approved_by bigint [ref: > admin_accounts.id]
mode varchar(10) [not null, default: 'SANDBOX', note: 'SANDBOX|LIVE - 테스트 통과 후 매장이 전환(#26·#28)']
test_create_ok_at datetime [note: '체크리스트: 결제 생성']
test_webhook_ok_at datetime [note: '체크리스트: 웹훅 2xx']
test_query_ok_at datetime [note: '체크리스트: 상태조회']
created_at datetime [not null]
Indexes { (status, requested_at) [name: 'ix_apicred_queue', note: 'admin 신청 심사 큐'] }
Note: 'LIVE 전환 조건 = status=APPROVED + 3개 test_*_ok_at 모두 존재(앱 강제). SYSTEM VERSIONING(웹훅 URL·모드 변경 이력)'
}
Table pg_orders {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
order_no varchar(64) [not null, note: '쇼핑몰 주문번호 - UNIQUE(merchant_id, order_no) 멱등']
amount decimal(15,0) [not null]
supply_amount decimal(15,0) [note: '공급가액']
vat_amount decimal(15,0) [not null, note: 'PG API로 수신·보관(#24)']
item_name varchar(200)
qr_id bigint [ref: > merchant_qrs.id]
txn_id bigint [note: '결제 성사 시 거래 참조']
status varchar(20) [not null, default: 'CREATED', note: 'CREATED|PAID|CANCELED|EXPIRED']
is_sandbox boolean [not null, default: false, note: '테스트 주문 실원장 완전 분리']
created_at datetime [not null]
Indexes {
(merchant_id, order_no) [unique, name: 'ux_pg_order']
(status, created_at) [name: 'ix_pg_status', note: '미결제 주문 만료 스윕']
}
}
Table webhook_deliveries {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
event_type varchar(30) [not null, note: 'PAYMENT_COMPLETED|PAYMENT_CANCELED|TEST 등']
pg_order_id bigint [ref: > pg_orders.id]
payload text [not null]
target_url varchar(500) [not null]
attempts int [not null, default: 0, note: '지수 백오프 최대 10회/24h']
next_retry_at datetime
last_http_code int
claimed_at datetime [note: '발송 클레임 시각 - SENDING 고아 복구 스위퍼 기준']
status varchar(20) [not null, default: 'PENDING', note: 'PENDING|SENDING(클레임-커밋 후 발송)|DELIVERED|EXHAUSTED(#30 감시)']
created_at datetime [not null]
Indexes { (status, next_retry_at) [name: 'ix_webhook_retry'] }
}
// ========================= 7. 정책 (admin 설정) =======================
// 공통 패턴: 전역 기본(scope=GLOBAL_*, target_id NULL) + 개별 오버라이드.
// 전부 SYSTEM VERSIONING - "그 시점 정책" 증빙. 거래엔 스냅샷 저장.
Table fee_policies {
id bigint [pk, increment]
fee_type varchar(20) [not null, note: 'MERCHANT_PAYMENT|MERCHANT_PAYOUT|USER_DEPOSIT|USER_WITHDRAW|USER_GIFT|USER_EXCHANGE(포인트전환, 구현보류)']
scope varchar(15) [not null, note: 'GLOBAL|USER|MERCHANT']
target_id bigint
rate decimal(7,4) [not null, default: 0, note: '정률% - 정액과 합산 조합(#25)']
fixed_amount decimal(15,0) [not null, default: 0]
Indexes { (fee_type, scope, target_id) [unique, name: 'ux_fee_policy'] }
}
Table limit_policies {
id bigint [pk, increment]
limit_type varchar(15) [not null, note: 'DEPOSIT|BALANCE|WITHDRAW|GIFT|PAYMENT(#37)']
window varchar(10) [not null, note: 'PER_TXN|DAILY|MONTHLY|CAP(보유)']
scope varchar(15) [not null]
target_id bigint
amount decimal(15,0) [not null, note: 'BALANCE는 법정 상한 초과 설정 차단(앱 검증)']
Indexes { (limit_type, window, scope, target_id) [unique, name: 'ux_limit_policy'] }
Note: '집행은 사용자 전 카드 합산(#37)'
}
Table payout_policies {
id bigint [pk, increment]
scope varchar(20) [not null, note: 'GLOBAL_USER|GLOBAL_MERCHANT|USER|MERCHANT']
target_id bigint
delay_days int [not null, note: '신청 +N일. **매장(GLOBAL_MERCHANT) 기본 0 = 즉시 정산**(발주 확정 2026-07-22, 가맹점 출금요청 시 즉각). 사용자 출금만 대기 가능']
execute_time time [not null, note: '실행 시각 HH:mm (KST). delay_days=0이면 무시(즉시)']
Indexes { (scope, target_id) [unique, name: 'ux_payout_policy'] }
Note: '매장 정산 대기(+N일) 구조·로직 완전 구현 유지(제거 금지 - 향후 추가 비용 방지, 발주 2026-07-22). 매장 기본 delay=0(즉시 정산). 값만 바꾸면 +1일/+2일 대기 즉시 적용. scheduled_at = 신청 + delay일 HH시(delay 0이면 NOW → 배치가 바로 집행)'
}
Table cancel_policies {
id bigint [pk, increment]
scope varchar(15) [not null, note: 'GLOBAL|MERCHANT']
target_id bigint
days int [not null, note: '결제 후 N일 - 거래에 cancelable_until 스냅샷']
Indexes { (scope, target_id) [unique, name: 'ux_cancel_policy'] }
}
Table card_quota_policies {
id bigint [pk, increment]
scope varchar(15) [not null, note: 'GLOBAL|USER(#43)']
target_id bigint
max_cards int [not null, note: 'ACTIVE 카드 기준']
Indexes { (scope, target_id) [unique, name: 'ux_quota_policy'] }
}
Table point_expiry_policies {
id bigint [pk, increment]
lot_type varchar(10) [not null, unique, note: 'DEPOSIT|REWARD']
months int [not null, note: '충전성 값은 법무 확인 후 설정(#42)']
}
Table global_settings {
id bigint [pk, increment]
skey varchar(50) [not null, unique, note: 'REJOIN_WAIT_DAYS|CPM_CONFIRM_THRESHOLD|MAINTENANCE_ON|MAINTENANCE_MSG|MAINTENANCE_UNTIL|MIN_APP_VERSION_IOS|MIN_APP_VERSION_ANDROID|BANK_COOLDOWN_HOURS|FORFEIT_MAX_AMOUNT(탈퇴 소액포기 상한) 등 단일값 설정']
svalue varchar(500) [not null]
updated_by bigint [ref: > admin_accounts.id]
updated_at datetime [not null]
Note: 'SYSTEM VERSIONING - 설정 변경 이력 자동 보존'
}
// ============================== 8. FDS ===============================
Table fds_rules {
id bigint [pk, increment]
code varchar(30) [not null, unique, note: 'PASSTHROUGH|GIFT_CONCENTRATION|SELF_PAYMENT|CANCEL_ABUSE|POST_CHANGE_WITHDRAW|ENUMERATION|MULTI_ACCOUNT_DEVICE|NIGHT_LARGE(#38 8종)']
name varchar(100) [not null]
params text [not null, note: 'JSON: 임계값·시간창 - admin에서 조정(배포 불필요)']
action varchar(10) [not null, note: 'BLOCK|HOLD|ALERT|MONITOR']
enabled boolean [not null, default: true]
updated_at datetime [not null]
Note: 'SYSTEM VERSIONING'
}
Table fds_alerts {
id bigint [pk, increment]
rule_id bigint [not null, ref: > fds_rules.id]
principal_type varchar(10) [not null]
principal_id bigint [not null]
txn_id bigint [note: '관련 거래']
detail text [note: '탐지 근거 JSON(증거 보존)']
status varchar(20) [not null, default: 'OPEN', note: 'OPEN|REVIEWED|ACTIONED|DISMISSED']
reviewed_by bigint [ref: > admin_accounts.id]
reviewed_at datetime
review_note varchar(500)
created_at datetime [not null]
Indexes { (status, created_at) [name: 'ix_fds_queue'] }
}
// ======================= 9. 콘텐츠·고객지원·약관 ======================
Table notices {
id bigint [pk, increment]
kind varchar(10) [not null, note: 'NOTICE|EVENT(#18)']
title varchar(200) [not null]
body text [not null]
starts_at datetime
ends_at datetime
status varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|PUBLISHED|HIDDEN']
created_by bigint [ref: > admin_accounts.id]
created_at datetime [not null]
Indexes { (kind, status, starts_at) [name: 'ix_notice_pub'] }
}
Table banners {
id bigint [pk, increment]
image_path varchar(300) [not null]
link_type varchar(10) [not null, note: 'NOTICE|EVENT|URL|NONE - 외부 URL 허용(승인 정책)']
link_target varchar(500)
sort_order int [not null, default: 0]
starts_at datetime
ends_at datetime
status varchar(10) [not null, default: 'DRAFT']
}
Table faqs {
id bigint [pk, increment]
category varchar(30) [not null]
question varchar(300) [not null]
answer text [not null]
sort_order int [not null, default: 0]
status varchar(10) [not null, default: 'PUBLISHED']
}
Table inquiries {
id bigint [pk, increment]
principal_type varchar(10) [not null, note: 'USER|MERCHANT']
principal_id bigint [not null]
title varchar(200) [not null]
body text [not null]
status varchar(10) [not null, default: 'OPEN', note: 'OPEN|ANSWERED|CLOSED']
answer text
answered_by bigint [ref: > admin_accounts.id]
answered_at datetime [note: '답변 시 푸시 통지']
created_at datetime [not null]
Indexes {
(principal_type, principal_id, created_at) [name: 'ix_inquiry_owner']
(status, created_at) [name: 'ix_inquiry_queue', note: 'admin 미답변 큐']
}
}
Table inquiry_attachments {
id bigint [pk, increment]
inquiry_id bigint [not null, ref: > inquiries.id]
file_path varchar(300) [not null, note: '웹루트 밖 비공개 저장. 서버 재인코딩·EXIF 제거 후']
created_at datetime [not null]
Note: '이미지만, 최대 5장/장당 10MB(#19 확정)'
}
Table policy_documents {
id bigint [pk, increment]
kind varchar(10) [not null, note: 'TERMS|PRIVACY(#21)']
version varchar(20) [not null]
body text [not null]
effective_at datetime [not null, note: '시행일 - 사전 고지·예약 게시']
requires_reconsent boolean [not null, default: false, note: '개정 건별 재동의 여부 admin 선택(승인 정책)']
status varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|ACTIVE|ARCHIVED - 과거 버전 영구 보존']
Indexes { (kind, version) [unique, name: 'ux_policy_ver'] }
}
Table policy_agreements {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
policy_id bigint [not null, ref: > policy_documents.id]
agreed_at datetime [not null, note: '분쟁 대비 증빙']
Indexes { (principal_type, principal_id, policy_id) [unique, name: 'ux_agreement'] }
}
// =========================== 10. 알림·푸시 ===========================
Table push_tokens {
id bigint [pk, increment]
principal_type varchar(10) [not null]
principal_id bigint [not null]
platform varchar(10) [not null]
token varchar(255) [not null, unique, note: 'FCM(#20)']
updated_at datetime [not null]
Indexes { (principal_type, principal_id) [name: 'ix_push_principal'] }
}
Table push_campaigns {
id bigint [pk, increment]
title varchar(100) [not null]
body varchar(500) [not null]
target varchar(20) [not null, note: 'ALL|IOS|ANDROID|SELECTED - SELECTED 시 대상은 push_campaign_targets(JSON 금지 원칙)']
is_ad boolean [not null, note: '광고성=수신동의자만+야간(21~08) 차단 자동']
scheduled_at datetime
sent_count int [default: 0]
fail_count int [default: 0]
status varchar(10) [not null, default: 'DRAFT']
created_by bigint [ref: > admin_accounts.id]
created_at datetime [not null]
}
Table push_campaign_targets {
id bigint [pk, increment]
campaign_id bigint [not null, ref: > push_campaigns.id]
principal_type varchar(10) [not null]
principal_id bigint [not null]
sent_at datetime
result varchar(20) [note: 'SENT|FAILED|NO_TOKEN|OPTED_OUT']
Indexes { (campaign_id, principal_type, principal_id) [unique, name: 'ux_campaign_target'] }
Note: '지정 발송 대상 + 개별 발송 결과 - 도달 분석'
}
Table notification_inbox {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션 - 30일 보존 배치 삭제(#46 확정)']
principal_type varchar(10) [not null]
principal_id bigint [not null]
ntype varchar(20) [not null, note: 'TXN|GIFT|SETTLEMENT|INQUIRY|CAMPAIGN 등']
title varchar(100) [not null]
body varchar(500)
deeplink varchar(200)
read_at datetime
created_at datetime [not null]
Indexes { (principal_type, principal_id, created_at) [name: 'ix_inbox_owner'] }
Note: '푸시 발송과 동일 outbox 작업에서 원자적 기록'
}
// ==================== 11. 명세·정산 (월말 스냅샷) =====================
Table monthly_statements {
id bigint [pk, increment]
card_id bigint [not null, ref: > cards.id]
yyyymm char(6) [not null]
deposit_sum decimal(15,0) [not null, default: 0]
withdraw_sum decimal(15,0) [not null, default: 0]
payment_sum decimal(15,0) [not null, default: 0]
gift_sent_sum decimal(15,0) [not null, default: 0]
gift_recv_sum decimal(15,0) [not null, default: 0]
expire_sum decimal(15,0) [not null, default: 0]
fee_sum decimal(15,0) [not null, default: 0]
closing_balance decimal(15,0) [not null, note: '월말 잔액 - 월 단위 대사 겸용(#23)']
created_at datetime [not null]
Indexes { (card_id, yyyymm) [unique, name: 'ux_stmt_card_month'] }
}
Table merchant_monthly_statements {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
yyyymm char(6) [not null]
sales_count int [not null, default: 0]
sales_sum decimal(15,0) [not null, default: 0]
cancel_count int [not null, default: 0]
cancel_sum decimal(15,0) [not null, default: 0]
supply_sum decimal(15,0) [not null, default: 0, note: '공급가액(#24 부가세 분리)']
vat_sum decimal(15,0) [not null, default: 0]
fee_sum decimal(15,0) [not null, default: 0, note: '결제+정산 수수료']
payout_sum decimal(15,0) [not null, default: 0]
closing_balance decimal(15,0) [not null]
created_at datetime [not null]
Indexes { (merchant_id, yyyymm) [unique, name: 'ux_mstmt_month'] }
Note: '월 정산서(#24-5) - 카드사 동일 수준, 부가세 포함'
}
// ===================== 12. 비동기(outbox)·감사·운영 ===================
Table outbox_jobs {
id bigint [pk, increment]
job_type varchar(30) [not null, note: 'PUSH|WEBHOOK|RECONCILE|GIFT_EXPIRE|LOT_EXPIRE|SETTLEMENT|STATEMENT|PARTITION 등 - 큐 미사용(#22) 단일 패턴']
payload text [not null]
run_after datetime [not null]
attempts int [not null, default: 0]
max_attempts int [not null, default: 10]
status varchar(10) [not null, default: 'PENDING', note: 'PENDING|RUNNING|DONE|EXHAUSTED']
last_error varchar(500)
created_at datetime [not null]
Indexes { (status, run_after) [name: 'ix_outbox_poll', note: '워커 폴링 주 쿼리'] }
Note: '원장 트랜잭션과 같은 커밋에 INSERT - 후속작업 유실 불가(#29)'
}
Table audit_logs {
id bigint [pk, increment]
actor_type varchar(10) [not null, note: 'ADMIN|SYSTEM']
actor_id bigint
action varchar(50) [not null, note: 'MERCHANT_APPROVE|FORCE_SUSPEND|POLICY_CHANGE|MANUAL_MATCH|OTP_RESET|IP_ADD 등']
target_type varchar(30)
target_id bigint
detail text [note: '변경 전후 값 JSON']
reason varchar(500) [note: '강제 조치·조정은 사유 필수(#40·#48)']
ip varchar(45)
created_at datetime [not null]
Indexes {
(actor_type, actor_id, created_at) [name: 'ix_audit_actor']
(target_type, target_id) [name: 'ix_audit_target']
}
Note: 'append-only. admin 전 조회·변경 행위 기록'
}
Table pii_access_logs {
id bigint [pk, increment]
admin_id bigint [not null, ref: > admin_accounts.id]
target_type varchar(10) [not null]
target_id bigint [not null]
fields varchar(200) [not null, note: '열람 항목(전화번호·계좌 등)']
purpose varchar(200)
created_at datetime [not null]
Indexes {
(admin_id, created_at) [name: 'ix_pii_admin', note: '관리자별 열람 조사']
(target_type, target_id, created_at) [name: 'ix_pii_target', note: '특정 고객 열람 조사(유출 대응)']
}
Note: '개인신용정보 접근기록(감독규정). 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거. 기록 지점=*_enc 복호화 표시 API 전부(카탈로그 17.9b 규칙)'
}
// ==================== 13. 운영 로그·분석·대사 이력 ====================
// 로그 계층 원칙(유지보수 우선):
// - "판단·운영·분쟁 대응에 쓰는 구조적 로그" = DB (아래 테이블)
// - "대량 원시 로그"(HTTP 액세스 전건·디버그) = 파일 로그(로테이션·보존·수집)
// → 원장 DB를 로그로 비대화시키지 않으면서 분석 가능성은 확보
Table external_api_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
provider varchar(30) [not null, note: 'FIRMBANK|NICE|NTS(국세청)|FCM|WEBHOOK_OUT 등']
operation varchar(50) [not null, note: 'TRANSFER|BALANCE_QUERY|VERIFY_NAME|SEND_PUSH 등']
txn_id bigint [note: '관련 거래 - 리컨실러·분쟁 추적의 핵심 연결고리']
request_body text [note: '민감정보 마스킹 후 저장']
response_body text [note: '계좌번호·CI류만 선별 마스킹 후 보존(결과코드·금액·거래참조는 원문 - 증거 가치 유지, 감사 확정)']
http_status int
result_code varchar(30) [note: '기관 응답 코드']
latency_ms int
created_at datetime [not null]
Indexes {
(provider, created_at) [name: 'ix_extapi_provider']
txn_id [name: 'ix_extapi_txn']
(result_code, created_at) [name: 'ix_extapi_result', note: '기관별 오류율 분석']
}
Note: '모든 대외 호출의 요청·응답 전건 기록 - "은행은 됐다는데 우리는 왜 실패?"의 판정 근거'
}
Table reject_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
principal_type varchar(10) [not null, note: 'USER|MERCHANT|ANON(비인증 시도)']
principal_id bigint [note: '비인증 시도는 NULL(감사 보완)']
ip varchar(45) [note: 'FDS 열거(ENUMERATION) 룰 - IP 기준 탐지']
action varchar(30) [not null, note: 'PAYMENT|WITHDRAW|GIFT|DEPOSIT_MATCH|QR_VERIFY|LOGIN|CARD_ISSUE 등']
reject_code varchar(40) [not null, note: 'LIMIT_EXCEEDED|INSUFFICIENT_BALANCE|FDS_BLOCK|QR_EXPIRED|CANCEL_WINDOW_EXPIRED|PIN_LOCKED 등']
context text [note: '판정 근거 JSON(한도값·잔액·룰ID 등 당시 상태)']
created_at datetime [not null]
Indexes {
(principal_type, principal_id, created_at) [name: 'ix_reject_principal']
(reject_code, created_at) [name: 'ix_reject_code', note: '거절 사유 통계 - UX·정책 개선 판단']
(ip, created_at) [name: 'ix_reject_ip', note: 'IP 열거 공격 탐지']
}
Note: '거래가 생성되기 전 거절된 시도의 기록 - transactions에 없는 "안 된 일"의 분석용. CS 문의("왜 안 돼요") 즉답 근거'
}
Table integrity_check_runs {
id bigint [pk, increment]
check_type varchar(30) [not null, note: 'DOUBLE_ENTRY|BALANCE_CHAIN|WALLET_CACHE|SEGREGATION|STALE_TXN|OUTBOX_HEALTH|ORPHAN_LEDGER(고아 원장)|SUMMARY_RECON(집계-원장 대사)|SYSTEM_WALLET(시스템 지갑 SUM 검증)']
scope varchar(100) [note: '검사 범위(일자·파티션)']
status varchar(10) [not null, note: 'PASS|FAIL|RUNNING']
checked_count bigint [note: '검사 행 수']
anomaly_count int [not null, default: 0]
started_at datetime [not null]
finished_at datetime
Indexes { (check_type, started_at) [name: 'ix_check_history'] }
Note: '#30 모니터링 대시보드의 데이터 원천 - 검증 실행 자체의 이력(언제 무엇을 검사했고 결과가 무엇이었나)'
}
Table integrity_findings {
id bigint [pk, increment]
run_id bigint [not null, ref: > integrity_check_runs.id]
target_type varchar(30) [not null, note: 'WALLET|TXN|LOT 등']
target_id bigint [not null]
detail text [not null, note: '기대값 vs 실제값']
status varchar(20) [not null, default: 'OPEN', note: 'OPEN|INVESTIGATING|RESOLVED(조정 역분개 참조)|FALSE_POSITIVE']
resolved_by bigint [ref: > admin_accounts.id]
resolved_at datetime
resolution_note varchar(500)
Indexes { (status) [name: 'ix_finding_open'] }
Note: '이상 건 개별 추적 - 발견부터 해소(역분개 조정)까지의 생애주기 기록'
}
Table daily_wallet_snapshots {
id bigint [pk, increment]
snap_date date [not null]
wallet_id bigint [not null, ref: > wallets.id]
balance decimal(15,0) [not null]
last_ledger_id bigint [not null, note: '스냅샷 시점의 마지막 원장 행 - 체인 검증 재개점']
Indexes {
(snap_date, wallet_id) [unique, name: 'ux_snap_day_wallet']
(wallet_id, snap_date) [name: 'ix_snap_wallet_latest', note: '지갑별 최신 스냅샷 조회(희소 방식)']
}
Note: '희소 스냅샷(감사 확정): 당일 원장 변동 지갑만 기록 + 월 1회 전량 베이스라인. 과거일 잔액 = 해당일 이전 최신 스냅샷. ODKU 멱등 적재'
}
Table daily_summaries {
id bigint [pk, increment]
summary_date date [not null]
metric varchar(40) [not null, note: 'DEPOSIT_SUM|WITHDRAW_SUM|PAYMENT_SUM|GIFT_SUM|CANCEL_SUM|FEE_SUM|EXPIRE_SUM|NEW_USERS|NEW_MERCHANTS|ACTIVE_USERS|FDS_ALERTS|REJECTS 등']
value decimal(18,0) [not null]
Indexes { (summary_date, metric) [unique, name: 'ux_summary_day_metric'] }
Note: 'admin 운영 대시보드·추이 그래프의 원천 - 원장 재집계 없이 즉시 조회'
}
Table app_error_logs {
id bigint [pk, increment, note: '실PK (id, created_at) 월 파티션']
severity varchar(10) [not null, note: 'ERROR|CRITICAL']
source varchar(30) [not null, note: 'API|WORKER|BATCH']
error_code varchar(50)
message varchar(1000) [not null]
trace_id varchar(64) [note: '파일 로그(전체 스택)와의 연결 키']
txn_id bigint
created_at datetime [not null]
Indexes { (severity, created_at) [name: 'ix_error_severity'] }
Note: '서버 예외 요약(운영자 화면·알림용). 전체 스택트레이스는 파일 로그 - trace_id로 연결'
}
Table merchant_daily_summaries {
id bigint [pk, increment]
merchant_id bigint [not null, ref: > merchants.id]
summary_date date [not null]
sales_count int [not null, default: 0]
sales_sum decimal(15,0) [not null, default: 0]
cancel_count int [not null, default: 0]
cancel_sum decimal(15,0) [not null, default: 0]
supply_sum decimal(15,0) [not null, default: 0]
vat_sum decimal(15,0) [not null, default: 0]
fee_sum decimal(15,0) [not null, default: 0]
Indexes { (merchant_id, summary_date) [unique, name: 'ux_mds_merchant_day', note: '매장앱 일/주/월 차트의 원천 - 1년 범위도 range 365행'] }
Note: '차트 집계 계층(감사 신설): 일 마감 배치가 최근 W일(=취소기한+출금대기 상한+1) 멱등 재계산(ODKU). 과거=본 테이블, 당일=ix_txn_merchant 실시간, 접합은 UNION(18장). 결측일 0채움은 Java'
}
Table hourly_summaries {
id bigint [pk, increment]
stat_hour datetime [not null, note: '정시 절단(KST)']
metric varchar(40) [not null]
value decimal(18,0) [not null]
Indexes { (stat_hour, metric) [unique, name: 'ux_hourly'] }
Note: 'admin 전역 당일 시간대 추이(감사 신설) - 시간 배치 적재, 90일 보존 DELETE'
}
// ================= 부록. 포인트 전환 (설계 유지·구현 보류) =================
// #49: 외부 제휴 포인트 ↔ nestpay 전환. 발주 2026-07-22 "설계는 해두고, 연동 요청 시 즉시 착수".
// 스키마·복식분개(queries 0.3 EXCHANGE leg)까지 정의 완료. 구현은 ConversionProvider 어댑터만 추가.
// 전환 유입분은 DEPOSIT 로트로 계상 → 환급은 소스 무관 전체 DEPOSIT 기준 유지.
Table exchange_providers {
id bigint [pk, increment]
code varchar(20) [not null, unique, note: '제휴 포인트 공급자 코드']
name varchar(100) [not null]
rate_in decimal(10,4) [note: '외부 포인트 → nestpay 전환율']
rate_out decimal(10,4) [note: 'nestpay → 외부 포인트 역전환율(허용 시)']
status varchar(10) [not null, default: 'INACTIVE', note: 'INACTIVE|ACTIVE - 구현 보류, 연동 요청 시 활성']
created_at datetime [not null]
Note: '#49 포인트 전환 - 설계 유지·구현 보류. 전환 유입분은 DEPOSIT 로트 계상, 별도관리 대상 여부 법무 확인'
}