STARRY PASS 11 — 네 개 서랍과 꼬리표 전쟁
같은 사실을 한곳에 두면 장부가 덜 싸운다
이 이야기는 W15 개요와 D1–D6만 다룹니다. 정확한 범위는 p487 L10–27 + p488–512 + p513 L5–12이고, p487 상단 W14 복구 문장과 p513 L14부터의 D7·p517 주간 통합·p518 이후는 제외합니다. 공통 코어이며 트랙 선택 시 증권 기본으로 표시하지만 증권 전용 기능 주차는 아닙니다. source audit은 PASS여도 testsExecutedByAudit=0이므로 상태는 W15 RUNNER NOT_RUN입니다.
STARRY 앞 골목에 벼룩시장이 섰다. 료는 바퀴 하나가 빠진 네 칸 캐비닛을 천 원에 사 왔다. 첫 서랍에는 계좌 카드, 둘째에는 돈이 움직인 업무 사건, 셋째에는 사건이 계좌에 남긴 돈줄, 넷째에는 같은 요청이 다시 들어왔을 때 자리를 지키는 접수표가 뒤섞여 있었다. 손님 이름과 번호와 잔액이 여러 종이에 복사되어 어느 것이 원본인지 알 수 없었다.
“종이가 많아서 풍성한 게 아니라 같은 사실이 서로 다른 말을 하고 있어.” 니지카가 말했다.
히토리는 종이를 한 줄씩 읽다가 손님 이름 하나가 세 철자로 적힌 것을 찾았다. 키타는 먼저 색깔별로 나누자고 했고, 료는 색보다 각 줄이 무엇 한 건을 뜻하는지부터 정해야 한다고 했다. 네 사람은 캐비닛을 공연장 한가운데로 옮겨, 한 줄의 의미와 한 줄을 다시 찾는 방법부터 적기로 했다.

첫째 서랍 이름은 account, 둘째는 business_tx, 셋째는 ledger_entry, 넷째는 idempotency_request였다. 이것들은 현재 물리 schema가 저장하는 네 종류의 entity였다. 각 열은 그 entity를 설명하는 attribute이고, 각 행은 실제 instance 한 건이었다. 니지카는 네 서랍을 그리되, 표 이름만 예쁘게 적는 ERD로 끝내지 말자고 했다.
금색 스티커는 한 행을 딱 하나로 찾는 기본키였다. 빨간 스티커는 후보가 될 수 있는 유일 값이나 업무상 중복되면 안 되는 값을 표시했다. account_no 같은 업무 번호와 idempotency_request의 복합 UNIQUE는 유일성을 주지만, 모든 후보를 자동으로 기본키로 만들지는 않았다. candidate key 가운데 대표로 선택한 것이 PK이고, business key는 업무 의미를 가진 유일 값일 수 있었다.
히토리는 owner 이름을 account의 식별자로 쓰면 친절하지 않겠느냐고 물었다. 료는 동명이인 둘을 적고, 이름을 바꾼 한 사람을 그 옆에 적었다. 사람이 같아도 이름이 달라지고 사람이 달라도 이름이 같을 수 있으므로 안정적인 식별자가 아니었다. 또 business transaction ID 하나에는 ledger 줄이 여러 개 붙을 수 있어 그 ID만으로 개별 ledger 행을 식별할 수도 없었다.
료는 V001__common.sql을 열어 스티커 수를 셌다. source에는 table 4, PK 4, REFERENCES 3, UNIQUE 4, CHECK 5, explicit index 2가 있었다. 이 수는 문자열 장식이 아니라 실제 migration의 저장 규칙을 읽은 결과였다. 그러나 숫자를 센 것만으로 ERD 의미가 맞다거나 3NF가 형식적으로 증명되지는 않았다.
캐비닛 옆에는 자동 검사기가 있었다. CoreSchemaIT은 public BASE TABLE 이름이 네 개와 정확히 같은지만 보았다. column·key·index·cardinality는 읽지 않았다. OpeningIntegrationTest는 customer-1, OPEN-100, 12_345로 정상 개설한 뒤 account 한 행, OPENING transaction 한 행, OPENING ledger 한 행을 각각 셌다. 중간 실패 rollback, 세 조회의 single snapshot, amount와 balance 일치는 직접 검사하지 않았다.
검사기를 움직이는 run-w15-schema.ps1은 두 class selector와 tests>=2, failure·error·skip 0을 기대했다. 하지만 두 selector는 stage-only 참조이고 active packaged root에는 exact class가 둘 다 없었다. selector 문자열이 있다고 현재 learner root가 Green인 것은 아니었다. 감사가 확인한 것은 source 폐쇄였고, 실제 Gradle·Docker·PostgreSQL 실행은 영 건이었다.
runner는 isolated w15_schema에 두 account를 넣고 첫째의 ledger는 2행, 둘째는 0행인지 확인했다. key gate는 PK>=4, FK>=2, UNIQUE>=2라는 하한이고 index는 기록만 했다. keys.txt, cardinality.csv, schema.txt를 minimum key 검사보다 먼저 각각 쓸 수 있어 실패 시 부분 evidence가 남을 수 있었다. 파일이 존재한다고 Green이 되는 구조가 아니었다.
Compose mode는 자신이 만든 project와 volume을 정리하지만, Container mode는 사용자가 넘긴 PostgreSQL container 안의 w15_schema를 drop/recreate하고 끝난 뒤 남겼다. 히토리는 “연습 서랍 하나뿐이어도 남의 창고 안에서 갈아엎는 일은 미리 알려야 하네요”라고 말했다. 니지카는 고개를 끄덕이며, 오늘은 runner를 실행하지 않고 source가 말하는 영향만 정확히 표시했다.
고양이 한 마리가 캐비닛 뒤에서 나와 파란 연결끈을 물고 달렸다. 되찾은 끈으로 ledger_entry의 account_id와 business_tx_id를 다시 이었다. 두 FK 열은 NOT NULL이므로 ledger 한 행은 account 하나와 business transaction 하나를 반드시 가리켰다. 반대 방향은 달랐다. account는 ledger가 하나도 없을 수 있고 여러 개일 수도 있었다.
“아이 한 명이 부모 한 명을 가리킨다고 모든 부모가 아이를 반드시 가진다는 뜻은 아니구나.” 히토리가 말했다.
고정 fixture에서 account 1은 ledger 2개, account 2는 0개였다. 1,2와 2,0은 optionality를 눈으로 확인하기 좋은 반례였지만 모든 운영 데이터의 일반 1:N을 증명하는 표본은 아니었다. LEFT JOIN을 쓰면 둘째 account도 0과 함께 남고, INNER JOIN을 쓰면 ledger 없는 account가 사라졌다.
idempotency_request와 business_tx 사이에는 현재 V001의 직접 FK가 없었다. 업무상 관련되어 보이는 것과 database가 referential integrity로 연결한 것은 달랐다. 삭제·보존 정책 역시 선 하나만 보고 결정할 수 없었다.

연결을 마치자 같은 owner 이름이 ledger 종이마다 반복된 문제가 보였다. 어떤 종이는 이름을 고쳤고 어떤 종이는 예전 이름을 남겼다. 새 owner를 먼저 등록하려면 억지 transaction 줄이 필요했고, 마지막 ledger 줄을 지우면 owner 정보까지 함께 사라질 위험도 있었다. 수정·삽입·삭제 anomaly였다.
료는 X→Y를 “같은 X를 알면 Y가 하나로 결정된다”라고 읽었다. account ID가 owner ID를 결정한다면 owner 사실은 account 쪽에 두고 ledger는 account ID를 참조하는 편이 낫다. 반복을 무조건 죄악으로 부르는 대신, 어떤 determinant가 어떤 dependent를 정하는지부터 적었다.
첫 번째 정리 단계인 1NF는 한 칸에 반복 목록을 넣지 않고 원자값으로 만드는 것이었다. 2NF는 복합 candidate key의 일부에만 기대는 non-key 사실을 분리했다. 복합 key가 없는 표에 부분 종속을 억지로 붙이지 않았다. 3NF는 key가 아닌 값이 또 다른 non-key 값을 정하는 이행 종속을 분리했다. BCNF는 모든 determinant가 candidate key인지 더 강하게 물었다.
키타는 종이를 많이 나누면 무조건 좋은지 물었다. 니지카는 분해 뒤 무손실 join과 필요한 종속 보존을 확인해야 한다고 답했다. 정규형 이름은 분해 주문이 아니라 주어진 함수 종속과 candidate key를 근거로 한 판정이었다.
그래도 공연 중 account balance를 매번 ledger 전체에서 계산하면 느릴 수 있었다. replay를 위해 idempotency response를 가까이 둘 수도 있었다. 이런 의도적 중복은 목적, 갱신 owner, 대사 주기, mismatch 감지와 monitoring을 함께 적어야 했다. “정규화했으니 중복 0”도 틀리고 “빠르니 복사”도 불완전했다.
네 사람은 물리 ERD에 열·type·nullable·PK·FK·UNIQUE·CHECK·index를 적었다. balance>=0 CHECK는 음수 저장 모양을 막을 뿐 동시 출금 안전이나 ledger 대사를 증명하지 않았다. account_id FK는 account 존재를 볼 뿐 요청자의 소유권을 판단하지 않았다. ledger의 amount와 signed_amount 사이에도 부호와 절댓값 일치를 강제하는 CHECK가 없었다. idempotency 복합 UNIQUE도 request hash 비교·replay response·동시 claim protocol 전체를 구현하지 않았다.
D5의 연습용 DDL 발췌는 전체 V001 정본이 아니었다. 완성한 project-erd.pdf와 review 문서는 실제 schema.txt hash에 묶고, PK·FK·UNIQUE·CHECK 검토 상태와 source of truth 설명을 남겨야 했다. PDF header·EOF·byte·hash가 맞아도 선 방향과 업무 의미까지 자동 판정되지는 않아 MANUAL_REVIEW_REQUIRED가 남았다.
키타가 세 장의 SQL 문제 카드를 꺼냈다. 첫 카드는 Q16, 날짜별 SUCCESS·FAILED 건수였다. 입력 한 행은 business_tx 한 건이고 출력 한 행은 business_date 하루였다. FILTER로 두 상태를 세고, 선택적 비율은 SUCCESS+FAILED가 0이면 NULLIF 때문에 NULL이 되게 했다. occurred_at::date를 쓰지 않고 이미 정의된 DATE 열을 써 session timezone 이동 함정을 피했다.
fixture에는 9일이 있었다. 2026-07-02는 0·0·NULL, 2026-07-01은 2·1·66.67, 2026-12-28은 0·3·0.00이었다. 핵심 계약은 count이고 비율은 0분모 경계를 보이는 선택 열이었다.
두 번째 카드 Q21은 전체 account 평균 balance보다 큰 account를 찾았다. 안쪽 aggregate subquery는 바깥 account와 상관없이 한 행의 평균 266287.375를 돌려주었다. strict > 결과 ID는 106·107·108, 3행이었다. status filter가 없어 CLOSED 107도 포함되고, 평균과 같은 행은 빠졌다. 입력이 비면 AVG는 NULL이며 비교 결과도 true가 되지 않았다.
세 번째 카드 Q22는 바깥 account의 customer가 바뀔 때마다 같은 customer의 MAX balance를 다시 물었다. 결과 표시 순서 ID는 105·106·107·104·108, 5행이었다. account가 없는 customer 5는 account에서 시작하는 query에 나타나지 않았다. query 모양은 최대 balance 동률을 모두 남기지만 현재 fixture의 같은-customer 최대 동률은 0행이었다.
세 SQL은 workbook 문제와 격리 fixture를 바탕으로 audit이 만든 fixture-bound 학습용 예시였다. shipped 정본 답안이 아니었다. 니지카는 결과 숫자를 외우기보다 시작 table, 입력·출력 grain, cardinality, NULL·동률·0건 반례를 먼저 말하게 했다.
마지막으로 네 사람은 40장의 모델링 진단 카드를 50분 타이머 옆에 놓았다. 틀린 카드는 entity/key, cardinality, normalization, reading 네 상자에 넣었다. 맞혔지만 근거 없이 고른 문제는 lucky로 따로 표시했다. 점수 하나보다 무엇을 왜 틀렸는지가 다음 복습을 정했다.
첫 시도 뒤에는 modeling-diagnostic.csv 40행과 정답 없는 prompt snapshot 40행을 남기고, 오답 상위 두 범주에서 48시간 뒤 다시 풀 10개를 item ID로 연결했다. D3·D4·D6의 간단한 validator는 file 40 byte 이상, nonblank 세 줄, placeholder 없음, SHA를 볼 뿐 내용의 정확성을 읽지 않았다.

밤이 되자 네 서랍은 조용해졌다. account와 business transaction, ledger, idempotency request가 각자 한 종류의 사실을 맡았다. 금색 열쇠와 파란 연결끈, 빨간 중복 도장은 저장 모양을 지켰고, application의 권한·transaction·대사는 별도의 책임으로 남았다.
히토리는 닫힌 캐비닛 손잡이에 작은 메모를 붙였다. source audit PASS는 정본을 정확히 읽었다는 뜻이고, current runner Green은 실제 learner root와 fresh XML·database 실행으로 별도 얻어야 한다. 오늘은 D6의 40장 분류까지 끝난 이야기였다. p513 L14에서 시작하는 D7의 ERD 구두 방어는 다음 문을 열 때까지 그대로 닫아 두었다.