STARRY PASS 코드 뒤풀이 · W15 Ver2 전체

15주차 코드 — schema·cardinality와 SQL 모델링

물리 schema와 W15 실행기의 실제 보장 범위를 읽고, 일별 집계·평균 비교·고객별 최대값 SQL을 fixture에 묶어 쉽게 확인합니다.

정본 물리 schema migration 1개누적 W6 JUnit 단계 참고본 2개정본 PostgreSQL runtime 1개정본 W15 schema 실행기 1개fixture 기반 학습용 SQL 예시 · 정본 답안 아님 3개항목마다 13단계연결 210줄번역 224줄
01

V001__common.sql — 네 core table의 물리 key·constraint·index 선언

schema/V001__common.sql

정본 물리 schema migration · 정본 · W15-F01
45줄 연결45줄 번역6 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

account·business_tx·ledger_entry·idempotency_request의 저장 모양과 관계를 실제 migration line에서 읽는다.

  1. 네 table의 PK·FK·UNIQUE·CHECK는 각각 어떤 값을 막을까?
  2. ledger_entry의 세 REFERENCES는 어떤 존재만 증명할까?
  3. amount와 signed_amount 사이에 빠진 constraint는 무엇일까?
  4. account_id FK가 owner authorization까지 보장할까?
  5. balance>=0 CHECK만으로 동시 출금이 안전할까?
  6. idempotency composite UNIQUE가 replay protocol 전체일까?
이 파일에서 끝까지 다시 쓰는 값tables=4PRIMARY KEY tokens=4REFERENCES tokens=3UNIQUE tokens=4CHECK tokens=5explicit CREATE INDEX=2account status=ACTIVE|CLOSEDcurrency=KRW
02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

STARRY가 공연 정산 전에 계좌·업무거래·원장·재요청 보관함을 만든다.

네 보관함의 열쇠와 연결끈 설계

V001은 네 table을 만들고 어떤 column이 필수인지, 어떤 값이 겹치면 안 되는지, 어느 parent가 존재해야 하는지 적는다.

원장에는 거래와 계좌 FK, 금액·잔액 CHECK, reversal self-FK가 있고 재요청에는 scope·actor·key 복합 UNIQUE가 있다.

딱 여기까지만 보관함의 자물쇠는 저장 가능한 모양만 제한하며 소유권·동시성·원자성·대사 절차까지 대신하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

PRIMARY KEY는 row 번호표

각 table에서 한 row를 겹치지 않게 가리키는 기본 번호다.

코드 연결
2·14·23·39줄
비유
공연 티켓의 일련번호처럼 같은 보관함 안에서 한 장을 찾는다.
비유의 끝
업무에서 의미 있는 account_no·correlation_id·복합 key와는 역할이 다르다.

FOREIGN KEY는 존재 확인 끈

ledger가 가리키는 거래·계좌·원장 row가 실제로 있는지 DB가 확인한다.

코드 연결
24·25·30줄
비유
없는 공연표 번호에 영수증을 묶지 못하게 한다.
비유의 끝
그 row를 볼 권한이나 금액이 맞다는 뜻은 아니다.

CHECK는 한 row의 문턱

status·currency·amount·balance 값이 지정 predicate를 통과해야 저장된다.

코드 연결
5~7·27·29줄
비유
한 표를 입구에서 재는 키 제한 장치다.
비유의 끝
여러 row와 여러 transaction 사이의 합계·경쟁까지 보지 않는다.

UNIQUE는 조합 중복 방지

한 column 또는 여러 column tuple이 같은 값을 다시 갖지 못하게 한다.

코드 연결
4·17·32·49줄
비유
같은 scope·actor·key 접수표를 두 장 받지 않는 접수 seal이다.
비유의 끝
request hash 비교와 replay response 반환 로직은 application 책임이다.
왜 migration부터 읽나히토리 → 니지카 → 료 → 키타
  1. 히토리

    table 이름만 맞으면 ERD도 맞는 것 아닌가요?

  2. 니지카

    이름은 상자 표찰이고 V001 column·constraint는 안쪽 칸과 연결끈이야.

  3. physical source truth는 DDL이지만 formal normalization과 업무 의미는 별도 해석이다.

  4. 키타

    네 table마다 PK·FK·UNIQUE·CHECK를 원문 줄에 연결해 볼게요.

identity와 업무 key히토리 → 니지카 → 료 → 키타
  1. 히토리

    자동 id가 있으면 account_no UNIQUE는 없어도 되나요?

  2. 니지카

    id는 내부 row 번호고 account_no는 외부 업무 식별 후보라 둘의 질문이 달라.

  3. surrogate PK와 candidate key는 서로 대체되는 단일 개념이 아니다.

  4. 키타

    2줄과 4줄이 막는 중복을 각각 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 45줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.정본 물리 schema migration에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.45 / 45 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F01-L01 CREATE TABLE account ( STARRY가 계좌 보관함의 빈 설계도를 작업대에 펼친다. `account` table 정의를 여는 CREATE TABLE 문장을 시작한다.
입력
PostgreSQL parser가 `CREATE TABLE account (` 토큰을 읽는다.
결과·효과
미완성 table definition에 account라는 이름이 잡히고 column clause를 받을 상태가 된다.
비유의 한계
닫는 `);`까지 실행되기 전에는 catalog에 완성된 table이 생겼다고 볼 수 없다.
2줄F01-L02 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 새 계좌표마다 큰 정수 자동 번호표와 중복 금지 도장을 붙인다. id를 BIGINT identity column이자 account의 PRIMARY KEY로 선언한다.
입력
열려 있는 account definition이 GENERATED BY DEFAULT AS IDENTITY와 PRIMARY KEY clause를 받는다.
결과·효과
statement가 완성되면 id는 기본 생성값을 쓸 수 있고 null·중복 PK 값은 거절되는 규칙 후보가 된다.
비유의 한계
BY DEFAULT라 명시적 id 입력은 가능하며 sequence 고갈·재설정 정책까지 정하지 않는다.
3줄F01-L03 owner_id VARCHAR(64) NOT NULL, 계좌표에 최대 64자의 주인 식별 띠를 빈칸 없이 매단다. owner_id를 길이 64 이하의 필수 VARCHAR column으로 선언한다.
입력
pending account definition에 `owner_id VARCHAR(64) NOT NULL` clause가 들어온다.
결과·효과
완성된 table은 owner_id가 null인 account row를 받지 않는 모양을 갖는다.
비유의 한계
owner_id가 실제 고객인지 또는 caller에게 그 계좌 권한이 있는지는 검사하지 않는다.
4줄F01-L04 account_no VARCHAR(32) NOT NULL UNIQUE, 외부에 보일 계좌번호는 32자 안에서 한 장만 존재하도록 봉인한다. account_no를 NOT NULL·UNIQUE인 VARCHAR(32) column으로 선언한다.
입력
account 설계에 account_no 길이·필수성·단일-column uniqueness가 추가된다.
결과·효과
DDL 성공 뒤 같은 비교값의 account_no를 가진 두 row는 unique constraint에 막힌다.
비유의 한계
대소문자·공백·업무 형식 정규화나 다른 시스템 전체의 전역 유일성은 보장하지 않는다.
5줄F01-L05 status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')), 계좌 상태등에는 ACTIVE와 CLOSED 두 색만 꽂을 수 있게 홈을 판다. status를 필수 VARCHAR(16)으로 두고 두 literal만 허용하는 CHECK를 건다.
입력
pending column clause가 null 금지와 `status IN ('ACTIVE','CLOSED')` predicate를 함께 읽는다.
결과·효과
table이 만들어지면 다른 status 문자열을 저장하려는 statement가 CHECK 위반으로 실패한다.
비유의 한계
ACTIVE에서 CLOSED로 가는 전이 순서나 누가 상태를 바꿀 수 있는지는 규정하지 않는다.
6줄F01-L06 currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'), 통화 칸은 세 글자 KRW 도장만 통과하는 좁은 슬롯으로 만든다. currency를 NOT NULL VARCHAR(3)으로 선언하고 값이 KRW인지 CHECK한다.
입력
account definition이 currency 길이와 equality predicate를 column metadata에 모은다.
결과·효과
DDL 적용 후 null 또는 KRW가 아닌 currency를 넣는 row는 저장되지 않는다.
비유의 한계
환율·다중 통화 계좌·통화 변경 같은 업무 규칙은 이 equality check 밖이다.
7줄F01-L07 balance BIGINT NOT NULL CHECK (balance >= 0), 현재 잔액 저울에는 0 아래로 내려가지 못하는 바닥턱을 둔다. balance를 필수 BIGINT로 선언하고 `balance >= 0` CHECK를 붙인다.
입력
parser가 account의 balance column과 nonnegative predicate를 pending definition에 더한다.
결과·효과
완성된 constraint는 한 statement가 음수 balance를 저장하려 할 때 그 row를 거절한다.
비유의 한계
동시 출금 안전, lost update 방지, ledger 합계와 current balance 대사는 증명하지 않는다.
8줄F01-L08 version BIGINT NOT NULL DEFAULT 0 변경 횟수표는 값을 안 적으면 0부터 시작하도록 기본 숫자를 놓는다. version을 NOT NULL BIGINT로 두고 누락 시 DEFAULT 0을 사용하게 한다.
입력
account column 목록이 version의 type·필수성·default expression을 받는다.
결과·효과
INSERT가 version을 생략하면 0이 채워지고 명시값을 주면 그 값이 저장될 수 있다.
비유의 한계
UPDATE 때 자동 증가하거나 optimistic locking이 작동한다는 규칙은 없다.
9줄F01-L09 ); 계좌 보관함 설계의 마지막 괄호를 닫고 제작 승인표를 낸다. account relation definition의 닫는 괄호와 semicolon을 적는다.
입력
PostgreSQL이 1~8줄에서 모은 하나의 완성된 DDL statement를 받는다.
결과·효과
statement 실행이 성공하면 account relation과 선언된 inline constraints가 catalog에 생긴다.
비유의 한계
뒤 migration statement 실패나 바깥 transaction rollback까지 이 종료 기호가 막지는 않는다.
11줄F01-L11 CREATE INDEX idx_account_owner_id ON account(owner_id, id); 주인 띠와 계좌 번호 순으로 서랍을 찾는 별도 색인을 만든다. account(owner_id, id)에 `idx_account_owner_id` index를 생성한다.
입력
이미 존재하는 account relation과 두 column을 CREATE INDEX 입력으로 사용한다.
결과·효과
성공하면 owner_id를 먼저, id를 다음 key로 둔 nonunique index가 catalog에 추가된다.
비유의 한계
query planner의 실제 사용·응답시간·owner 권한·값 uniqueness를 보장하지 않는다.
13줄F01-L13 CREATE TABLE business_tx ( 업무 거래표를 담을 새 보관함의 이름판만 먼저 세운다. `business_tx` table 정의를 여는 CREATE TABLE 문장을 시작한다.
입력
parser가 business_tx relation name과 여는 괄호를 입력받는다.
결과·효과
미완성 transaction table definition이 생겨 뒤 column clause를 받을 준비를 한다.
비유의 한계
이 opener만 실행 단위로 떼면 table이나 column은 아직 완성되지 않는다.
14줄F01-L14 id UUID PRIMARY KEY, 거래 한 건에는 겹칠 수 없는 UUID 봉인을 기본 열쇠로 붙인다. id를 UUID PRIMARY KEY로 선언한다.
입력
열린 business_tx definition이 UUID type과 PK constraint를 받는다.
결과·효과
table 생성 뒤 id는 null·중복을 허용하지 않는 row identity가 된다.
비유의 한계
DEFAULT UUID generator가 없으므로 이 줄 자체는 새 UUID를 만들어 주지 않는다.
15줄F01-L15 tx_type VARCHAR(32) NOT NULL, 거래 종류 표찰은 32자 안에서 반드시 적게 한다. business transaction category용 required text slot의 최대 길이를 32로 정한다.
입력
business_tx 설계에 tx_type의 길이와 필수성 clause가 들어간다.
결과·효과
완성된 relation은 null tx_type을 거절하고 최대 길이를 제한한다.
비유의 한계
OPENING·TRANSFER 같은 허용 vocabulary를 제한하는 CHECK는 없다.
16줄F01-L16 status VARCHAR(16) NOT NULL, 처리 상태 칸은 16자 표시판으로 두되 빈칸만 금지한다. business_tx 처리 상태용 필수 VARCHAR(16) column을 추가한다.
입력
pending business_tx definition이 status type과 nullability를 수집한다.
결과·효과
DDL 적용 후 모든 transaction row는 null이 아닌 status 문자열을 가져야 한다.
비유의 한계
허용 status 집합과 상태 전이 규칙은 schema에 적혀 있지 않다.
17줄F01-L17 correlation_id VARCHAR(64) NOT NULL UNIQUE, 추적 리본 번호는 64자 안에서 거래마다 하나만 쓰게 한다. correlation_id를 필수 VARCHAR(64)로 두고 UNIQUE를 건다.
입력
business_tx definition이 단일 correlation_id uniqueness를 입력받는다.
결과·효과
같은 비교값을 재사용한 두 business_tx row는 unique constraint에 막힌다.
비유의 한계
idempotency scope·actor·request hash와 묶인 중복 의미까지 나타내지는 않는다.
18줄F01-L18 requested_at TIMESTAMPTZ NOT NULL, 요청 시각 도장은 시간대가 있는 칸에 빠짐없이 찍게 한다. business_tx 요청 instant를 필수 TIMESTAMPTZ requested_at에 보관한다.
입력
열린 table definition이 요청 instant용 timestamp type과 필수성을 받는다.
결과·효과
business_tx row를 저장하려면 requested_at 값이 제공되어야 한다.
비유의 한계
DEFAULT now(), business_date, 표시 timezone 또는 clock 신뢰성은 정하지 않는다.
19줄F01-L19 completed_at TIMESTAMPTZ 완료 전 거래도 놓을 수 있도록 완료 시각 칸은 비워 둘 수 있게 한다. business_tx 완료 instant용 nullable TIMESTAMPTZ slot을 둔다.
입력
business_tx 설계가 null 허용 완료 instant slot을 입력받는다.
결과·효과
table이 만들어지면 미완료 row는 completed_at 없이 저장될 수 있다.
비유의 한계
completed_at이 requested_at 이후인지 또는 status와 일치하는지는 CHECK하지 않는다.
20줄F01-L20 ); 업무 거래 보관함의 설계 괄호를 닫아 하나의 DDL로 제출한다. business_tx relation DDL의 닫는 괄호와 semicolon을 적는다.
입력
PostgreSQL이 13~19줄의 relation definition 전체를 실행 단위로 받는다.
결과·효과
성공 시 business_tx table과 그 PK·UNIQUE constraint가 catalog에 기록된다.
비유의 한계
다른 table과의 관계나 업무 거래의 원자성은 아직 이 statement에 없다.
22줄F01-L22 CREATE TABLE ledger_entry ( 원장 사건표를 쌓을 세 번째 보관함 설계의 문을 연다. `ledger_entry` table 정의를 시작한다.
입력
parser가 CREATE TABLE과 ledger_entry identifier, 여는 괄호를 읽는다.
결과·효과
미완성 ledger relation definition이 만들어져 column과 relationship clause를 기다린다.
비유의 한계
다음 clause와 닫는 기호 없이는 실제 ledger table이 생성되지 않는다.
23줄F01-L23 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 원장표마다 자동 증가 가능한 BIGINT 일련번호 열쇠를 붙인다. ledger_entry row key로 generated identity BIGINT id를 둔다.
입력
ledger_entry definition이 generation policy와 row PK를 입력받는다.
결과·효과
DDL 완료 뒤 id는 기본 생성될 수 있고 null·중복 값은 차단된다.
비유의 한계
명시적 id 허용과 sequence 운영 경계는 account id와 마찬가지로 남는다.
24줄F01-L24 business_tx_id UUID NOT NULL REFERENCES business_tx(id), 각 원장표를 존재하는 업무 거래 UUID에 끊어지지 않는 끈으로 묶는다. business_tx_id를 필수 UUID로 두고 business_tx(id)를 REFERENCES한다.
입력
pending ledger definition이 target table·column을 포함한 foreign key clause를 받는다.
결과·효과
constraint 적용 뒤 없는 business_tx id를 가리키는 ledger row는 저장되지 않는다.
비유의 한계
거래와 원장 반영의 원자성·같은 tx_type·두 줄 pair 규칙은 FK가 증명하지 않는다.
25줄F01-L25 account_id BIGINT NOT NULL REFERENCES account(id), 원장표의 계좌 끈은 실제 account 번호표가 있는 곳에만 걸리게 한다. account_id를 필수 BIGINT로 두고 account(id)를 REFERENCES한다.
입력
ledger_entry definition이 account parent existence constraint를 입력받는다.
결과·효과
완성된 FK는 존재하지 않는 account id를 가진 ledger row를 거절한다.
비유의 한계
account 소유자와 요청 actor의 authorization 관계는 확인하지 않는다.
26줄F01-L26 entry_type VARCHAR(32) NOT NULL, 원장 사건 종류 칸은 32자 안에서 반드시 채우는 표찰로 둔다. 각 ledger event의 분류 label은 최대 32자이며 null을 허용하지 않게 한다.
입력
ledger 설계가 entry_type의 type·length·nullability clause를 받는다.
결과·효과
DDL 적용 후 null entry_type row는 들어갈 수 없다.
비유의 한계
OPENING·TRANSFER_OUT·TRANSFER_IN·REVERSAL 같은 허용값 CHECK는 없다.
27줄F01-L27 amount BIGINT NOT NULL CHECK (amount > 0), 금액의 절댓값 칸은 0보다 큰 BIGINT만 통과하는 문턱을 둔다. amount를 필수 BIGINT로 선언하고 `amount > 0`을 CHECK한다.
입력
parser가 ledger amount column과 positive predicate를 definition에 더한다.
결과·효과
constraint가 활성화되면 0이나 음수 amount를 저장하려는 row가 실패한다.
비유의 한계
출금·입금 방향이나 signed_amount와의 절댓값 일치는 검사하지 않는다.
28줄F01-L28 signed_amount BIGINT NOT NULL, 원장 화살표 숫자는 부호를 그대로 적되 빈칸만 허용하지 않는다. signed_amount를 NOT NULL BIGINT column으로 선언한다.
입력
ledger_entry definition이 signed amount slot의 type과 필수성을 받는다.
결과·효과
table 생성 뒤 모든 ledger row는 어떤 BIGINT signed_amount 값을 가져야 한다.
비유의 한계
0 금지, entry_type별 부호, `abs(signed_amount)=amount` 규칙은 없다.
29줄F01-L29 balance_after BIGINT NOT NULL CHECK (balance_after >= 0), 사건 뒤 잔액 칸에도 0 아래로 내려가지 않는 바닥선을 긋는다. balance_after를 필수 BIGINT로 두고 nonnegative CHECK를 건다.
입력
pending relation이 post-entry balance column과 predicate를 읽는다.
결과·효과
완성된 constraint는 음수 balance_after를 가진 ledger row를 차단한다.
비유의 한계
이 값이 이전 원장·signed_amount·account.balance와 계산상 맞는지는 증명하지 않는다.
30줄F01-L30 reversal_of BIGINT REFERENCES ledger_entry(id), 취소표에는 원래 원장표 번호로 돌아가는 선택적 되감기 끈을 둔다. reversal_of를 nullable BIGINT self-FK로 두어 ledger_entry(id)를 참조한다.
입력
ledger definition이 같은 table을 target으로 하는 optional foreign key를 받는다.
결과·효과
값이 있으면 실제 ledger_entry id여야 하고 null이면 참조 없이 저장될 수 있다.
비유의 한계
원거래와 반대 부호·금액 일치, 중복 reversal 금지, cycle 방지는 없다.
31줄F01-L31 created_at TIMESTAMPTZ NOT NULL, 원장표 작성 시각은 시간대 포함 칸에 반드시 적게 한다. ledger 사건 작성 instant를 필수 TIMESTAMPTZ created_at에 보관한다.
입력
pending ledger schema가 생성 instant slot과 필수성을 입력받는다.
결과·효과
row INSERT에는 created_at이 필요하며 null은 constraint에 막힌다.
비유의 한계
DEFAULT clock, 단조 증가, 실제 업무 발생 순서까지 보장하지 않는다.
32줄F01-L32 UNIQUE (business_tx_id, account_id, entry_type) 한 거래·한 계좌·한 사건종류 조합에는 원장표 한 장만 허용하는 삼중 봉인을 찍는다. business_tx_id·account_id·entry_type 세 column에 composite UNIQUE를 건다.
입력
ledger definition이 세 현재 column의 tuple uniqueness를 table constraint로 받는다.
결과·효과
같은 세 값 조합을 가진 두 ledger row는 unique comparison에서 거절된다.
비유의 한계
한 transfer가 반드시 OUT/IN 두 행을 만들거나 서로 다른 type의 중복을 막는 규칙은 아니다.
33줄F01-L33 ); 원장 보관함의 column·constraint 설계를 닫아 제작 명령을 완성한다. ledger_entry definition list를 `);`로 마감한다.
입력
PostgreSQL이 22~32줄에서 누적한 table definition 전체를 받는다.
결과·효과
statement 성공 뒤 ledger_entry relation과 PK·FK·CHECK·UNIQUE가 catalog에 생긴다.
비유의 한계
index와 실제 query 성능은 아직 이 닫는 줄의 결과에 포함되지 않는다.
35줄F01-L35 CREATE INDEX idx_ledger_account_created_id 원장 찾기 표지판에 `idx_ledger_account_created_id`라는 고유 이름만 먼저 적는다. 해당 이름의 CREATE INDEX statement를 시작한다.
입력
parser가 index identifier와 아직 끝나지 않은 index command를 입력받는다.
결과·효과
pending DDL에 새 index 이름이 잡히고 target relation·key expression을 기다린다.
비유의 한계
이 줄만으로 index가 생성되거나 전체 key 순서가 확정되지는 않는다.
36줄F01-L36 ON ledger_entry(account_id, created_at DESC, id DESC); 계좌를 먼저 찾고 최신 작성시각, 최신 원장번호 순으로 내려가는 탐색길을 완성한다. ledger_entry(account_id, created_at DESC, id DESC)에 앞서 이름 붙인 index를 생성한다.
입력
이 줄이 직전 CREATE INDEX opener와 결합되어 relation과 세 key order를 제공한다.
결과·효과
DDL 성공 시 지정 key 순서를 가진 nonunique index가 PostgreSQL catalog에 추가된다.
비유의 한계
planner 사용·속도 개선·SELECT 결과 정렬은 보장되지 않으며 query에는 별도 ORDER BY가 필요하다.
38줄F01-L38 CREATE TABLE idempotency_request ( 재요청 접수표를 보관할 네 번째 cabinet의 빈 설계도를 연다. `idempotency_request` table 정의를 시작한다.
입력
PostgreSQL parser가 relation name과 여는 괄호를 읽는다.
결과·효과
미완성 idempotency table definition이 column clause를 받을 수 있게 된다.
비유의 한계
아직 scope·actor·key나 replay 동작은 이 opener에 들어 있지 않다.
39줄F01-L39 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 접수표마다 자동 번호를 쓸 수 있는 BIGINT 기본 열쇠를 단다. idempotency_request surrogate key를 BY DEFAULT identity BIGINT로 만든다.
입력
열린 idempotency_request definition이 identity와 PK metadata를 받는다.
결과·효과
relation 완성 뒤 id는 기본 생성 가능하며 null·duplicate 값은 거절된다.
비유의 한계
이 surrogate key만으로 업무상 같은 요청인지 판정하지 않는다.
40줄F01-L40 scope VARCHAR(64) NOT NULL, 멱등 처리가 적용되는 작업 영역 이름을 64자 안에서 필수로 적는다. scope를 NOT NULL VARCHAR(64) column으로 선언한다.
입력
pending request definition에 scope length와 nullability가 입력된다.
결과·효과
DDL 적용 후 모든 idempotency_request row는 null이 아닌 scope를 가진다.
비유의 한계
허용 scope vocabulary나 서로 다른 operation의 동치성은 제한하지 않는다.
41줄F01-L41 actor_id VARCHAR(64) NOT NULL, 요청 주체 표찰도 64자 안에서 비워 둘 수 없게 한다. actor_id를 NOT NULL VARCHAR(64) column으로 선언한다.
입력
idempotency table schema가 actor identifier slot과 필수성을 받는다.
결과·효과
row를 넣으려면 actor_id 문자열이 필요하다.
비유의 한계
actor가 인증됐는지 또는 실제 고객 record가 존재하는지는 확인하지 않는다.
42줄F01-L42 idempotency_key VARCHAR(128) NOT NULL, 재시도 표의 핵심 key 칸은 128자까지 허용하되 반드시 채운다. idempotency_key를 NOT NULL VARCHAR(128) column으로 선언한다.
입력
pending definition이 key text의 최대 길이와 null 금지를 입력받는다.
결과·효과
table 생성 뒤 null key row는 거절되고 길이 제한이 적용된다.
비유의 한계
key 생성 품질·추측 방지·보존 기간은 이 column clause 밖이다.
43줄F01-L43 request_hash CHAR(64) NOT NULL, 요청 내용 지문은 고정 64칸짜리 필수 표찰에 저장한다. request_hash를 NOT NULL CHAR(64) column으로 선언한다.
입력
idempotency schema가 fixed-length character slot과 필수성을 받는다.
결과·효과
row마다 request_hash 값이 필요하고 PostgreSQL CHAR 길이 규칙이 적용된다.
비유의 한계
64-hex 형식·실제 hash 계산·같은 key의 semantic conflict 비교는 검증하지 않는다.
44줄F01-L44 status VARCHAR(16) NOT NULL, 접수 처리 상태는 16자 칸에 반드시 적게만 하고 색 목록은 열어 둔다. 멱등 요청 처리 상태용 필수 VARCHAR(16) column을 추가한다.
입력
pending idempotency definition이 status type과 nullability를 받는다.
결과·효과
완성된 table은 null status를 가진 request row를 차단한다.
비유의 한계
PROCESSING·COMPLETED 같은 허용값, 전이, timeout 상태는 CHECK하지 않는다.
45줄F01-L45 response_status INTEGER, 아직 응답하지 않은 접수표도 놓도록 응답 status 숫자 칸은 선택으로 둔다. response_status를 nullable INTEGER column으로 선언한다.
입력
request table definition이 null 허용 정수 slot을 입력받는다.
결과·효과
DDL 적용 뒤 응답 전 row는 response_status 없이 저장될 수 있다.
비유의 한계
100~599 같은 HTTP 범위나 idempotency status와의 일관성은 제한하지 않는다.
46줄F01-L46 response_body TEXT, 재전달할 응답 본문은 길이 제한 없는 선택적 종이에 보관한다. response_body를 nullable TEXT column으로 선언한다.
입력
pending table schema가 optional text payload slot을 받는다.
결과·효과
row는 response_body가 null이거나 PostgreSQL TEXT 값인 상태로 저장될 수 있다.
비유의 한계
JSON schema·content type·크기 상한·민감정보 암호화 정책은 없다.
47줄F01-L47 created_at TIMESTAMPTZ NOT NULL, 접수 생성 시각은 시간대 포함 도장을 빠짐없이 요구한다. 멱등 접수 생성 instant용 필수 TIMESTAMPTZ created_at을 둔다.
입력
idempotency definition이 creation instant type과 필수성을 입력받는다.
결과·효과
INSERT하려면 created_at이 제공되어야 하고 null은 거절된다.
비유의 한계
DEFAULT now(), clock source, retention 만료 시각은 정하지 않는다.
48줄F01-L48 completed_at TIMESTAMPTZ, 처리 중인 접수표를 위해 완료 시각 도장은 비워 둘 수 있게 한다. 멱등 접수 종료 instant를 선택적으로 저장하는 TIMESTAMPTZ column을 둔다.
입력
pending request schema가 optional completion instant slot을 받는다.
결과·효과
미완료 row는 completed_at null로 남을 수 있다.
비유의 한계
created_at 이후인지, status가 완료인지, timeout인지의 정합성 CHECK는 없다.
49줄F01-L49 UNIQUE (scope, actor_id, idempotency_key) 같은 작업영역·주체·멱등 key 조합에는 접수표 한 장만 두는 삼중 seal을 건다. scope·actor_id·idempotency_key에 composite UNIQUE를 선언한다.
입력
table definition이 세 column tuple을 database uniqueness input으로 받는다.
결과·효과
같은 triple을 다시 INSERT하면 unique conflict가 발생한다.
비유의 한계
다른 scope·actor의 같은 key, request_hash conflict, replay response, 동시 claim serialization은 구현하지 않는다.
50줄F01-L50 ); 멱등 접수 보관함의 마지막 괄호를 닫아 V001의 네 번째 table 명령을 끝낸다. idempotency_request relation definition의 마지막 괄호를 닫는다.
입력
PostgreSQL이 38~49줄의 relation definition을 하나의 실행 가능한 DDL로 받는다.
결과·효과
성공하면 idempotency_request table과 PK·composite UNIQUE가 catalog에 기록된다.
비유의 한계
이 종료는 네 table 전체가 migration transaction 밖에서도 영구 유지됐다는 증거가 아니다.
FK는 권한이 아니다히토리 → 니지카 → 료 → 키타
  1. 히토리

    ledger의 account_id가 존재하면 내 계좌라는 뜻인가요?

  2. 니지카

    연결끈은 그 번호표가 실제 account에 있는지만 확인해.

  3. authorization에는 authenticated actor와 account.owner_id 비교가 더 필요하다.

  4. 키타

    25줄 한계에 존재 O·소유권 X를 표시할게요.

05

STEP 05 / 13

원본 코드 조각

원본을 6개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.

F01-C01 · account table1–9줄
1–9줄 원본
CREATE TABLE account (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    owner_id VARCHAR(64) NOT NULL,
    account_no VARCHAR(32) NOT NULL UNIQUE,
    status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
    currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
    balance BIGINT NOT NULL CHECK (balance >= 0),
    version BIGINT NOT NULL DEFAULT 0
);
F01-C02 · account lookup index10–12줄
10–12줄 원본

CREATE INDEX idx_account_owner_id ON account(owner_id, id);
F01-C03 · business transaction table13–20줄
13–20줄 원본
CREATE TABLE business_tx (
    id UUID PRIMARY KEY,
    tx_type VARCHAR(32) NOT NULL,
    status VARCHAR(16) NOT NULL,
    correlation_id VARCHAR(64) NOT NULL UNIQUE,
    requested_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ
);
F01-C04 · ledger table and relationships21–33줄
21–33줄 원본

CREATE TABLE ledger_entry (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    business_tx_id UUID NOT NULL REFERENCES business_tx(id),
    account_id BIGINT NOT NULL REFERENCES account(id),
    entry_type VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    signed_amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
    reversal_of BIGINT REFERENCES ledger_entry(id),
    created_at TIMESTAMPTZ NOT NULL,
    UNIQUE (business_tx_id, account_id, entry_type)
);
F01-C05 · ledger history index34–36줄
34–36줄 원본

CREATE INDEX idx_ledger_account_created_id
    ON ledger_entry(account_id, created_at DESC, id DESC);
F01-C06 · idempotency request table37–50줄
37–50줄 원본

CREATE TABLE idempotency_request (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    scope VARCHAR(64) NOT NULL,
    actor_id VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL,
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    UNIQUE (scope, actor_id, idempotency_key)
);
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 45줄을 모두 한국어로 옮깁니다.

전체 번역 45 / 45

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1CREATE TABLE account (`account` table 정의를 여는 CREATE TABLE 문장을 시작한다.
2 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,id를 BIGINT identity column이자 account의 PRIMARY KEY로 선언한다.
3 owner_id VARCHAR(64) NOT NULL,owner_id를 길이 64 이하의 필수 VARCHAR column으로 선언한다.
4 account_no VARCHAR(32) NOT NULL UNIQUE,account_no를 NOT NULL·UNIQUE인 VARCHAR(32) column으로 선언한다.
5 status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),status를 필수 VARCHAR(16)으로 두고 두 literal만 허용하는 CHECK를 건다.
6 currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),currency를 NOT NULL VARCHAR(3)으로 선언하고 값이 KRW인지 CHECK한다.
7 balance BIGINT NOT NULL CHECK (balance >= 0),balance를 필수 BIGINT로 선언하고 `balance >= 0` CHECK를 붙인다.
8 version BIGINT NOT NULL DEFAULT 0version을 NOT NULL BIGINT로 두고 누락 시 DEFAULT 0을 사용하게 한다.
9);account relation definition의 닫는 괄호와 semicolon을 적는다.
11CREATE INDEX idx_account_owner_id ON account(owner_id, id);account(owner_id, id)에 `idx_account_owner_id` index를 생성한다.
13CREATE TABLE business_tx (`business_tx` table 정의를 여는 CREATE TABLE 문장을 시작한다.
14 id UUID PRIMARY KEY,id를 UUID PRIMARY KEY로 선언한다.
15 tx_type VARCHAR(32) NOT NULL,business transaction category용 required text slot의 최대 길이를 32로 정한다.
16 status VARCHAR(16) NOT NULL,business_tx 처리 상태용 필수 VARCHAR(16) column을 추가한다.
17 correlation_id VARCHAR(64) NOT NULL UNIQUE,correlation_id를 필수 VARCHAR(64)로 두고 UNIQUE를 건다.
18 requested_at TIMESTAMPTZ NOT NULL,business_tx 요청 instant를 필수 TIMESTAMPTZ requested_at에 보관한다.
19 completed_at TIMESTAMPTZbusiness_tx 완료 instant용 nullable TIMESTAMPTZ slot을 둔다.
20);business_tx relation DDL의 닫는 괄호와 semicolon을 적는다.
22CREATE TABLE ledger_entry (`ledger_entry` table 정의를 시작한다.
23 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,ledger_entry row key로 generated identity BIGINT id를 둔다.
24 business_tx_id UUID NOT NULL REFERENCES business_tx(id),business_tx_id를 필수 UUID로 두고 business_tx(id)를 REFERENCES한다.
25 account_id BIGINT NOT NULL REFERENCES account(id),account_id를 필수 BIGINT로 두고 account(id)를 REFERENCES한다.
26 entry_type VARCHAR(32) NOT NULL,각 ledger event의 분류 label은 최대 32자이며 null을 허용하지 않게 한다.
27 amount BIGINT NOT NULL CHECK (amount > 0),amount를 필수 BIGINT로 선언하고 `amount > 0`을 CHECK한다.
28 signed_amount BIGINT NOT NULL,signed_amount를 NOT NULL BIGINT column으로 선언한다.
29 balance_after BIGINT NOT NULL CHECK (balance_after >= 0),balance_after를 필수 BIGINT로 두고 nonnegative CHECK를 건다.
30 reversal_of BIGINT REFERENCES ledger_entry(id),reversal_of를 nullable BIGINT self-FK로 두어 ledger_entry(id)를 참조한다.
31 created_at TIMESTAMPTZ NOT NULL,ledger 사건 작성 instant를 필수 TIMESTAMPTZ created_at에 보관한다.
32 UNIQUE (business_tx_id, account_id, entry_type)business_tx_id·account_id·entry_type 세 column에 composite UNIQUE를 건다.
33);ledger_entry definition list를 `);`로 마감한다.
35CREATE INDEX idx_ledger_account_created_id해당 이름의 CREATE INDEX statement를 시작한다.
36 ON ledger_entry(account_id, created_at DESC, id DESC);ledger_entry(account_id, created_at DESC, id DESC)에 앞서 이름 붙인 index를 생성한다.
38CREATE TABLE idempotency_request (`idempotency_request` table 정의를 시작한다.
39 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,idempotency_request surrogate key를 BY DEFAULT identity BIGINT로 만든다.
40 scope VARCHAR(64) NOT NULL,scope를 NOT NULL VARCHAR(64) column으로 선언한다.
41 actor_id VARCHAR(64) NOT NULL,actor_id를 NOT NULL VARCHAR(64) column으로 선언한다.
42 idempotency_key VARCHAR(128) NOT NULL,idempotency_key를 NOT NULL VARCHAR(128) column으로 선언한다.
43 request_hash CHAR(64) NOT NULL,request_hash를 NOT NULL CHAR(64) column으로 선언한다.
44 status VARCHAR(16) NOT NULL,멱등 요청 처리 상태용 필수 VARCHAR(16) column을 추가한다.
45 response_status INTEGER,response_status를 nullable INTEGER column으로 선언한다.
46 response_body TEXT,response_body를 nullable TEXT column으로 선언한다.
47 created_at TIMESTAMPTZ NOT NULL,멱등 접수 생성 instant용 필수 TIMESTAMPTZ created_at을 둔다.
48 completed_at TIMESTAMPTZ,멱등 접수 종료 instant를 선택적으로 저장하는 TIMESTAMPTZ column을 둔다.
49 UNIQUE (scope, actor_id, idempotency_key)scope·actor_id·idempotency_key에 composite UNIQUE를 선언한다.
50);idempotency_request relation definition의 마지막 괄호를 닫는다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

V001은 네 physical relation과 inline constraints를 선언하지만 업무 의미·정규형·runtime correctness의 완전한 proof는 아니다.

문법 해부

  • GENERATED BY DEFAULT AS IDENTITY는 값 생략 시 생성하되 명시값도 허용한다.
  • inline REFERENCES는 지정 parent key 존재를 검사한다.
  • composite UNIQUE는 나열된 column tuple 전체를 비교한다.
  • DESC는 index key order이며 SELECT 결과 순서를 자동 보장하지 않는다.

실행 순서

  1. account table과 owner index를 만든다.
  2. business_tx table을 만든다.
  3. ledger_entry table과 account/time index를 만든다.
  4. idempotency_request table을 만든다.

원래 W6 수준의 조각별 정밀 해설

F01-C01 · account table
문법 해부
CREATE TABLE account 안에서 identity BIGINT PK와 owner·account number·status·currency·balance·version 열을 선언하고 UNIQUE·CHECK·DEFAULT를 각 열에 붙인다.
실제 값 추적
id를 생략하면 identity 값이 생성되지만 BY DEFAULT라 explicit ID도 공급할 수 있고, account_no·status·currency·balance·version 제약은 각 입력을 판정한다.
정상 예
owner-1, ACC-1, ACTIVE, KRW, balance 0을 넣고 version을 생략하는 행은 이 table 선언의 허용 예다.
틀린 예·반례
같은 account_no를 다시 쓰거나 status를 SUSPENDED로, currency를 USD로, balance를 음수로 넣으면 해당 제약에 걸린다.
착각 방지
GENERATED BY DEFAULT는 explicit ID override를 허용하며, version DEFAULT 0도 숫자 초기값일 뿐 optimistic locking 자동 증가를 구현하지 않는다.
하지 않는 일
owner_id의 customer FK, 통화별 scale, account 상태 전이, 조회 성능은 이 table 조각이 책임지지 않는다.
다음 연결
다음 ‘account lookup index’ 범위가 owner_id와 id를 함께 쓰는 명시 index를 추가한다.
F01-C02 · account lookup index
문법 해부
빈 구분 줄 뒤 CREATE INDEX가 account(owner_id, id) 순서의 idx_account_owner_id를 만든다.
실제 값 추적
PostgreSQL catalog에는 owner_id가 선두이고 id가 뒤따르는 두 열 B-tree index 정의가 생긴다.
정상 예
owner_id equality로 후보를 좁히고 id 순서를 이용하는 조회는 이 열 순서와 맞는 사용 예가 될 수 있다.
틀린 예·반례
id만 조건으로 쓰는 모든 query가 반드시 이 composite index를 고른다고 단정하면 planner 선택을 과장한다.
착각 방지
명시 index 한 줄과 UNIQUE가 만드는 implicit index를 같은 개수로 세지 않는다.
하지 않는 일
실제 실행 계획·selectivity·write 비용·필요 index 전체성은 여기서 검증하지 않는다.
다음 연결
다음 ‘business transaction table’ 범위는 UUID transaction과 type·status·correlation·두 시각을 담는 relation을 연다.
F01-C03 · business transaction table
문법 해부
business_tx는 UUID primary key, 필수 tx_type·status·unique correlation_id·requested_at과 선택 completed_at으로 구성된다.
실제 값 추적
한 transaction 행에는 중복 불가 correlation string과 요청 시각이 반드시 들어가고 완료 시각은 NULL일 수 있다.
정상 예
새 UUID와 OPENING, COMPLETED, 고유 correlation 값, requested_at을 가진 행은 선언된 nullability와 key에 맞는다.
틀린 예·반례
동일 correlation_id를 두 행에 쓰거나 id·requested_at을 빼면 constraint violation이 난다.
착각 방지
status와 tx_type이 VARCHAR라는 사실만으로 허용 enum 값이나 상태 전이가 제한되지는 않는다.
하지 않는 일
account 연결, 금액, idempotency scope, original transaction 관계는 이 table 정의에 없다.
다음 연결
다음 ‘ledger table and relationships’ 범위가 transaction과 account를 참조하는 원장 행 구조를 만든다.
F01-C04 · ledger table and relationships
문법 해부
ledger_entry는 identity PK, business_tx/account FK, entry_type, 양수 amount, signed_amount, 0 이상 balance_after, optional self-FK reversal_of, created_at과 세 열 UNIQUE를 선언한다.
실제 값 추적
각 원장 행은 transaction과 account를 가리키며 같은 business_tx_id·account_id·entry_type 조합은 한 번만 저장될 수 있다.
정상 예
기존 transaction/account를 참조해 amount 20, signed_amount -20, balance_after 100으로 만드는 TRANSFER_OUT 행은 제약 모양상 허용된다.
틀린 예·반례
amount 0, balance_after -1, 존재하지 않는 account_id 또는 같은 세 열 조합의 두 번째 행은 각각 해당 gate에서 거부된다.
착각 방지
signed_amount에는 CHECK가 없으므로 entry_type과 부호가 업무상 맞다는 사실을 DDL이 직접 강제한다고 보면 안 된다.
하지 않는 일
double-entry 합계 0, transaction당 기대 원장 수, reversal 승인 정책, 동시 update 순서는 별도 책임이다.
다음 연결
다음 ‘ledger history index’ 범위가 account별 created_at·id 내림차순 조회를 위한 명시 index를 둔다.
F01-C05 · ledger history index
문법 해부
두 줄 CREATE INDEX 문이 ledger_entry(account_id, created_at DESC, id DESC)에 idx_ledger_account_created_id 이름을 붙인다.
실제 값 추적
catalog에는 account_id로 묶고 최신 created_at, 이어 최신 id 방향으로 정렬된 composite index가 기록된다.
정상 예
한 account의 최신 ledger page를 created_at과 id 역순으로 읽는 query는 선언 열 순서와 맞는다.
틀린 예·반례
created_at만으로 모든 ledger를 검색하는 workload가 이 index를 항상 사용한다고 말할 수 없다.
착각 방지
DESC 표시는 결과를 자동 정렬해 주는 application 계약이 아니라 index key direction이다.
하지 않는 일
조회 latency, pagination correctness, timestamp 동률 분포, index-only scan 가능성은 이 DDL만으로 알 수 없다.
다음 연결
다음 ‘idempotency request table’ 범위는 scope·actor·key 조합과 request/response 상태를 저장한다.
F01-C06 · idempotency request table
문법 해부
idempotency_request는 identity PK와 필수 scope·actor_id·idempotency_key·64자 request_hash·status·created_at, 선택 response_status/body/completed_at, 세 열 UNIQUE를 가진다.
실제 값 추적
동일 scope·actor·key 조합은 한 행으로 제한되고 처리 전에는 response와 completed_at을 NULL로 둘 수 있다.
정상 예
TRANSFER scope, customer-1 actor, request-1 key, 64자 hash와 진행 상태를 넣는 첫 요청은 구조상 유효하다.
틀린 예·반례
같은 scope·actor·key를 다시 INSERT하거나 request_hash·created_at을 빼면 저장 제약을 만족하지 못한다.
착각 방지
UNIQUE는 같은 key의 동시 삽입 경합을 막는 DB 사실이지만 replay 응답·hash conflict 정책까지 구현하지 않는다.
하지 않는 일
TTL, stale PROCESSING 회복, response JSON 형식, scope별 authorization은 application layer 책임이다.
다음 연결
이 migration의 마지막 범위다. 전체를 다시 볼 때는 table 4·PRIMARY KEY 4·REFERENCES 3·UNIQUE 4·CHECK 5·explicit CREATE INDEX 2를 DDL 사실로만 묶는다.
CHECK와 동시성히토리 → 니지카 → 료 → 키타
  1. 히토리

    balance가 음수가 아니면 동시 출금도 안전하죠?

  2. 니지카

    두 요청이 같은 옛 balance를 읽는 경쟁은 한 row의 최종 숫자 검사와 달라.

  3. constraint proof와 isolation/lock proof의 관찰 범위를 분리해야 한다.

  4. 키타

    동시성 test와 대사 규칙을 별도 책임 칸에 남기겠습니다.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
account rowowner_id=customer-1, account_no=A-100, status=ACTIVE, currency=KRW, balance=0id·version을 생략해 INSERT한다.identity id와 default version=0을 사용할 수 있고 inline checks를 통과한다.owner 존재나 actor 권한은 확인하지 않는다.
ledger parent links존재하는 business_tx_id와 account_idledger_entry FK 세 개 중 두 parent FK를 평가한다.두 parent row가 있으면 existence check를 통과할 수 있다.같은 업무 주체·정상 pair·atomic write는 별도 규칙이다.
ledger amountsamount=20, signed_amount=0, balance_after=100현재 V001의 CHECK만 평가한다.amount>0과 balance_after>=0이라 schema상 signed_amount=0도 거절되지 않는다.application test나 추가 CHECK가 부호·절댓값 invariant를 맡아야 한다.
idempotency tuplescope=TRANSFER, actor_id=customer-1, key=req-7같은 triple을 두 번째로 INSERT한다.composite UNIQUE conflict가 발생한다.기존 response replay와 다른 request_hash conflict 처리는 자동으로 일어나지 않는다.
signed_amount 빈 규칙히토리 → 니지카 → 료 → 키타
  1. 히토리

    amount가 양수라서 signed_amount 부호도 맞을 것 같아요.

  2. 니지카

    27줄은 amount만 보고 28줄은 signed_amount가 null 아닌지만 봐.

  3. 현재 DDL에는 abs equality와 entry_type별 sign predicate가 없다.

  4. 키타

    amount=20·signed_amount=0 반례로 확인할게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

PostgreSQL parser/catalog

각 CREATE TABLE statement를 parse해 relation·column·constraint metadata를 catalog에 기록한다.

뒤 statement나 migration transaction이 실패하면 최종 지속 여부가 달라질 수 있다.
constraint enforcement

INSERT·UPDATE 시 NOT NULL·CHECK·UNIQUE·FK predicate를 평가한다.

constraint가 표현하지 않은 cross-row business invariant는 평가하지 않는다.
index storage

explicit index 두 개 외에 PK·UNIQUE 구현을 위한 implicit index가 생길 수 있다.

source의 CREATE INDEX token 두 개를 전체 physical index count로 읽으면 안 된다.
transaction/application

service transaction과 lock이 여러 statement의 원자성·경쟁 순서를 맡는다.

V001 text만으로 특정 isolation·lock order·rollback path를 증명할 수 없다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ account_id FK가 있으면 caller가 그 account의 owner다.

왜 틀리나 FK는 account(id) row 존재만 본다.

바르게 읽기 authorization은 authenticated actor와 owner_id를 application/query에서 비교한다.

반례 다른 고객 account의 실제 id도 FK는 통과한다.

❌ balance>=0 CHECK가 있으면 동시 출금에 안전하다.

왜 틀리나 두 transaction의 read-modify-write 경쟁은 한 최종 row predicate와 다른 문제다.

바르게 읽기 transaction isolation·lock·version과 concurrency test를 따로 확인한다.

반례 두 요청이 같은 old balance를 읽고 각각 nonnegative 값으로 덮어쓸 수 있다.

❌ amount>0이면 signed_amount 방향도 자동으로 맞다.

왜 틀리나 signed_amount column에는 NOT NULL 외 CHECK가 없다.

바르게 읽기 entry_type별 sign과 abs equality를 service 또는 추가 constraint로 검증한다.

반례 amount=20, signed_amount=0도 현재 두 relevant clauses를 통과할 수 있다.

❌ 복합 UNIQUE가 멱등 replay를 완성한다.

왜 틀리나 DB는 duplicate triple을 막을 뿐 기존 response 선택·hash 비교를 하지 않는다.

바르게 읽기 claim, semantic conflict, stored response replay protocol을 application transaction으로 둔다.

반례 같은 triple·다른 request_hash는 unique conflict만 주고 어떤 response를 낼지 말하지 않는다.

UNIQUE와 멱등 protocol히토리 → 니지카 → 료 → 키타
  1. 히토리

    복합 UNIQUE가 있으면 replay response도 자동인가요?

  2. 니지카

    DB는 같은 세 표찰의 두 번째 접수만 막고 첫 응답을 돌려주지는 않아.

  3. request_hash conflict·claim serialization·response replay는 application transaction 책임이다.

  4. 키타

    49줄 직접 효과와 미증명을 분리해 적겠습니다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

authorization

ledger account_id가 요청 actor 소유 account임

이 책임을 맡는 곳: authentication/authorization service와 owner predicate
concurrency

balance>=0 CHECK만으로 lost update·overspend가 없음

이 책임을 맡는 곳: transaction isolation, lock/version, concurrency integration test
reconciliation

account.balance가 ledger signed sum과 항상 일치함

이 책임을 맡는 곳: source-of-truth 정책, atomic write, reconciliation job/evidence
normalization

3NF·BCNF와 모든 functional dependency 준수

이 책임을 맡는 곳: ERD/FD 분석과 사람 의미 검토
performance

두 explicit index가 모든 운영 query를 빠르게 함

이 책임을 맡는 곳: 실제 workload EXPLAIN ANALYZE와 index 운영
12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

각 constraint line 옆에 '직접 막는 값'과 '막지 못하는 업무 의미'를 한 줄씩 쓴다.

2단계 · 코드 조각 재조립

  1. account_id FK → 존재 O / 소유권 X
  2. balance CHECK → 저장 음수 X / 동시성 proof X
  3. ledger composite UNIQUE → 동일 tuple 중복 X / transfer pair 강제 X
  4. idempotency UNIQUE → duplicate triple X / replay policy X

3단계 · 파일 전체 다시 쓰기

빈 종이에 네 table의 PK·FK·UNIQUE·CHECK·두 explicit index를 다시 그리고 source line과 대조한다.

자가 점검
  • REFERENCES 세 개를 business_tx/account/self-reversal로 정확히 구분했는가?
  • amount와 signed_amount 사이에 빠진 invariant를 말했는가?
  • FK와 authorization, CHECK와 concurrency를 분리했는가?
  • explicit index 두 개와 implicit PK/UNIQUE index를 혼동하지 않았는가?
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourceschema/V001__common.sqlSHA-256 e286a2d994bde069c3f2f9889f79258d0cecff1fef702a2ad26330246e355ce6
V001__common.sql — 네 core table의 물리 key·constraint·index 선언 전체
CREATE TABLE account (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    owner_id VARCHAR(64) NOT NULL,
    account_no VARCHAR(32) NOT NULL UNIQUE,
    status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
    currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
    balance BIGINT NOT NULL CHECK (balance >= 0),
    version BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_account_owner_id ON account(owner_id, id);

CREATE TABLE business_tx (
    id UUID PRIMARY KEY,
    tx_type VARCHAR(32) NOT NULL,
    status VARCHAR(16) NOT NULL,
    correlation_id VARCHAR(64) NOT NULL UNIQUE,
    requested_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ
);

CREATE TABLE ledger_entry (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    business_tx_id UUID NOT NULL REFERENCES business_tx(id),
    account_id BIGINT NOT NULL REFERENCES account(id),
    entry_type VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    signed_amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
    reversal_of BIGINT REFERENCES ledger_entry(id),
    created_at TIMESTAMPTZ NOT NULL,
    UNIQUE (business_tx_id, account_id, entry_type)
);

CREATE INDEX idx_ledger_account_created_id
    ON ledger_entry(account_id, created_at DESC, id DESC);

CREATE TABLE idempotency_request (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    scope VARCHAR(64) NOT NULL,
    actor_id VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL,
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    UNIQUE (scope, actor_id, idempotency_key)
);
02

CoreSchemaIT.java — public base table 네 이름의 exact set test

reference/w6/day-1/CoreSchemaIT.java

누적 W6 JUnit 단계 참고본 · 정본 · W15-F02
16줄 연결22줄 번역4 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

W6D1 staged test가 Flyway history를 뺀 public BASE TABLE 이름을 네 expected name과 비교하는 범위를 읽는다.

  1. 왜 public과 BASE TABLE만 filter할까?
  2. 왜 flyway_schema_history를 제외할까?
  3. ORDER BY 뒤에 순서 없는 matcher를 쓰는 이유는 무엇일까?
  4. exact four names가 column·key까지 증명할까?
  5. runner selector가 이 staged source SHA 실행을 뜻할까?
이 파일에서 끝까지 다시 쓰는 값schema=publictype=BASE TABLEexclude=flyway_schema_historyexpected=account,business_tx,ledger_entry,idempotency_request@Test=1stage=W6D1
02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

STARRY가 제품 서랍과 Flyway 작업 일지를 구분해 재고표를 만든다.

public 창고의 네 이름표 대조

SQL은 public의 BASE TABLE 이름만 읽고 flyway_schema_history는 뺀 뒤 String 목록을 만든다.

목록이 네 core table 이름과 정확히 같아야 하므로 하나가 빠지거나 다른 table이 더 있으면 test가 실패한다.

딱 여기까지만 이름표만 보는 검사라 column·PK·FK·CHECK·UNIQUE·index·cardinality는 확인하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

검사할 방 좁히기

다른 schema와 view를 빼고 public의 실제 base table만 남긴다.

코드 연결
17~18줄
비유
건물 전체가 아니라 public 창고의 실제 서랍만 센다.
비유의 끝
다른 schema의 잘못은 이 test에 보이지 않는다.

작업 일지 제외

Flyway 자체 history table은 application core table 수에서 뺀다.

코드 연결
19줄
비유
창고 공사 일지는 제품 서랍 네 개와 따로 센다.
비유의 끝
다른 관리 table을 자동 판별하는 일반 규칙은 아니다.

exact set 비교

containsExactlyInAnyOrder는 기대한 원소 외의 추가·누락도 실패시킨다.

코드 연결
22~24줄
비유
네 이름표를 순서 없이 맞추되 다섯 번째 표찰도 허용하지 않는다.
비유의 끝
서랍 안 구조가 같은지는 열어 보지 않는다.

stage reference

이 파일은 learning_stages/w6/day-1의 누적 reference다.

코드 연결
source binding
비유
예전 리허설 검사표를 현재 주차에서 다시 읽는 것이다.
비유의 끝
active packaged root가 이 exact file을 compile했다는 증거는 별도다.
네 이름이면 충분한가히토리 → 니지카 → 료 → 키타
  1. 히토리

    table 네 개가 보이면 schema 전체가 맞는 것 아닌가요?

  2. 니지카

    이 query는 table_name 칸만 가져와서 서랍 안 구조를 열지 않아.

  3. 직접 관찰 quantifier는 filtered public base-table name set뿐이다.

  4. 키타

    PK·FK·CHECK·index는 V001 줄로 따로 확인하겠습니다.

Flyway history 제외히토리 → 니지카 → 료 → 키타
  1. 히토리

    Flyway table을 빼면 관리 table은 전부 제외되나요?

  2. 니지카

    아니, 이름이 정확히 flyway_schema_history인 row 한 종류만 빠져.

  3. 다른 public base table은 extra actual element가 된다.

  4. 키타

    audit_shadow 반례를 예상 결과에 넣어 볼게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 16줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.누적 W6 JUnit 단계 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.16 / 16 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
10줄F02-L10 @SpringBootTest STARRY의 schema 이름 검사표에 Spring test 조명을 켜는 표찰을 붙인다. 다음 type declaration에 Spring Boot test context metadata를 부여하는 annotation이다.
입력
Java compiler와 test discovery가 standalone `@SpringBootTest` modifier를 읽는다.
결과·효과
뒤 type은 Spring Boot integration-test bootstrap 대상이 될 수 있는 metadata를 갖게 된다.
비유의 한계
annotation 발견만으로 context·container·migration이 실제 성공했다고 말할 수 없다.
11줄F02-L11 class CoreSchemaIT extends PostgresIntegrationTestSupport { 네 table 이름 검사관을 공용 PostgreSQL 실습 받침대 위에 세운다. CoreSchemaIT class를 열고 PostgresIntegrationTestSupport를 상속한다.
입력
JVM type loader가 class 이름과 superclass 관계를 입력받는다.
결과·효과
CoreSchemaIT instance는 support가 제공하는 protected lifecycle·설정을 상속할 수 있다.
비유의 한계
superclass 구현과 active source-set 포함 여부는 이 declaration 안에 보이지 않는다.
12줄F02-L12 @Autowired JdbcClient jdbc; 검사관 손에 SQL 질문기 `jdbc`를 자동 연결하는 socket을 둔다. JdbcClient type의 jdbc field를 Spring autowiring injection point로 선언한다.
입력
application context가 CoreSchemaIT instance와 matching JdbcClient bean을 준비하려 한다.
결과·효과
bean resolution이 성공하면 field가 SQL fluent API를 호출할 참조를 가진다.
비유의 한계
field 선언은 connection·query·assertion 성공을 보장하지 않는다.
14줄F02-L14 @Test 다음 검사 절차를 JUnit 실행 목록에 올릴 표식만 먼저 놓는다. 바로 뒤 method declaration에 JUnit test metadata를 붙이는 annotation이다.
입력
JUnit discovery가 standalone `@Test` modifier를 scan한다.
결과·효과
이어지는 method가 test descriptor 후보로 해석될 준비를 한다.
비유의 한계
아직 method 이름·본문·통과 여부는 이 annotation 한 줄의 결과가 아니다.
15줄F02-L15 void v001CreatesExactlyTheRequiredCoreTables() { 필요한 네 core table만 있는지 확인하는 검사 절차의 작업 공간을 연다. v001CreatesExactlyTheRequiredCoreTables test method body를 시작한다.
입력
JUnit이 준비된 test instance에서 이 void method를 호출한다.
결과·효과
query와 assertion이 순서대로 실행될 새 call frame이 생긴다.
비유의 한계
method 이름은 V001의 column·constraint까지 맞다는 proof가 아니다.
16줄F02-L16 var tables = jdbc.sql(""" 조회 결과를 받을 `tables` 바구니와 SQL 질문기의 빈 두루마리를 연결한다. var tables 대입문의 오른쪽에서 `jdbc.sql` text block 호출을 시작한다.
입력
현재 JdbcClient reference와 아직 닫히지 않은 Java text block opener가 입력이다.
결과·효과
fluent query expression과 local-variable assignment가 미완성 상태로 열린다.
비유의 한계
SQL 본문·result type·실행 terminal은 아직 이 줄에 없다.
17줄F02-L17 SELECT table_name FROM information_schema.tables catalog 재고표에서 각 row의 table_name 칸만 꺼내도록 적는다. information_schema.tables에서 table_name을 SELECT한다.
입력
열린 SQL text에 projection과 source relation clause가 추가된다.
결과·효과
query가 실행되면 candidate row마다 table name 값 하나를 반환할 모양이 된다.
비유의 한계
column·constraint·index metadata는 projection에 포함되지 않는다.
18줄F02-L18 WHERE table_schema='public' AND table_type='BASE TABLE' public 방의 실제 BASE TABLE 표찰만 검사선에 남긴다. table_schema가 public이고 table_type이 BASE TABLE인 row로 filter한다.
입력
information_schema.tables row에 두 equality predicate를 AND로 적용한다.
결과·효과
view와 다른 schema의 relation name은 candidate set에서 빠진다.
비유의 한계
public의 관리용 base table 전부를 일반적으로 제외하는 조건은 아니다.
19줄F02-L19 AND table_name <> 'flyway_schema_history' Flyway가 쓰는 작업 일지 서랍 이름은 제품 재고에서 따로 뺀다. table_name이 flyway_schema_history인 row를 제외한다.
입력
앞 WHERE를 통과한 각 name에 inequality predicate를 추가로 평가한다.
결과·효과
해당 migration-history table만 결과 후보에서 제거된다.
비유의 한계
다른 framework history·audit table을 자동 제외하지 않는다.
20줄F02-L20 ORDER BY table_name 남은 table 이름표를 문자 오름차순으로 정렬해 전달하게 한다. SQL 결과를 table_name ascending order로 정렬한다.
입력
filtered candidate rows가 PostgreSQL ORDER BY 입력으로 들어간다.
결과·효과
driver가 받는 name sequence는 database collation 기준의 deterministic order를 갖는다.
비유의 한계
뒤 exact-set assertion은 순서를 무시하므로 정렬이 membership proof는 아니다.
21줄F02-L21 """).query(String.class).list(); SQL 두루마리를 닫고 각 이름을 String으로 읽어 list를 완성한다. text block을 닫고 query(String.class).list()로 SQL을 실행한다.
입력
완성된 SQL과 String row mapper가 JdbcClient terminal에 입력된다.
결과·효과
성공하면 조회된 table name List가 tables local variable에 대입된다.
비유의 한계
connection·syntax·mapping 오류는 assertion보다 먼저 method를 끝낼 수 있다.
22줄F02-L22 assertThat(tables) 가져온 이름 목록을 AssertJ collection 저울 위에 올린다. tables를 `assertThat`에 넘겨 collection assertion chain을 시작한다.
입력
실제 List<String> 값이 assertion subject 입력으로 들어간다.
결과·효과
terminal matcher가 아직 없는 fluent assertion object가 만들어진다.
비유의 한계
이 opener만으로 pass·fail이나 collection comparison rule은 결정되지 않는다.
23줄F02-L23 .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES") 저울이 틀릴 때 보일 W6D1 진단 이름표를 매단다. assertion description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
입력
앞 collection assertion object와 지정 diagnostic String이 입력이다.
결과·효과
뒤 terminal assertion 실패 메시지에 이 description이 붙을 상태가 된다.
비유의 한계
RED라는 단어가 test를 일부러 실패시키거나 evidence 파일을 만들지는 않는다.
24줄F02-L24 .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request"); 실제 이름 바구니가 네 장의 예상표와 정확히 같은 묶음인지 순서 없이 맞춘다. 네 expected table name과 actual collection의 크기·membership을 exact하게 assert한다.
입력
tables와 account·business_tx·ledger_entry·idempotency_request literal 네 개가 비교 입력이다.
결과·효과
누락·추가·중복이 있으면 실패하고 exact four-name multiset이면 terminal assertion이 통과한다.
비유의 한계
각 table의 column, PK, FK, CHECK, UNIQUE, index, cardinality는 읽지 않는다.
25줄F02-L25 } 네 이름 대조 절차의 작업 공간을 닫는다. closing brace로 v001CreatesExactlyTheRequiredCoreTables의 lexical scope를 마감한다.
입력
앞 query와 terminal assertion을 지나온 control flow가 닫는 brace에 도달한다.
결과·효과
예외가 없었다면 JUnit이 이 method invocation을 successful completion 후보로 본다.
비유의 한계
method 종료는 다른 test나 runner XML·source SHA 증거를 추가하지 않는다.
26줄F02-L26 } CoreSchemaIT 검사실의 바깥벽을 닫아 staged type 정의를 끝낸다. CoreSchemaIT type definition의 마지막 brace를 적는다.
입력
compiler가 field와 한 test method를 포함한 lexical type scope를 닫는다.
결과·효과
하나의 staged Java class definition이 완성된다.
비유의 한계
class가 완성됐어도 active packaged root의 기본 test source set에 포함됐다는 뜻은 아니다.
정렬과 matcher히토리 → 니지카 → 료 → 키타
  1. 히토리

    ORDER BY가 있는데 왜 InAnyOrder를 쓰죠?

  2. 니지카

    query 출력은 읽기 좋게 정렬하고 test 목적은 네 원소의 exact membership이기 때문이야.

  3. deterministic sequence와 assertion predicate를 혼동하면 안 된다.

  4. 키타

    순서를 바꾼 같은 set도 통과한다고 기록할게요.

05

STEP 05 / 13

원본 코드 조각

원본을 4개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.

F02-C01 · package and imports1–8줄
1–8줄 원본
package com.example.financialcore;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
F02-C02 · test class and JDBC dependency9–12줄
9–12줄 원본

@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
F02-C03 · schema inventory query and assertion13–23줄
13–23줄 원본

    @Test
    void v001CreatesExactlyTheRequiredCoreTables() {
        var tables = jdbc.sql("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema='public' AND table_type='BASE TABLE'
                  AND table_name <> 'flyway_schema_history'
                ORDER BY table_name
                """).query(String.class).list();
        assertThat(tables)
            .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
F02-C04 · exact table names and closes24–26줄
24–26줄 원본
            .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 22줄을 모두 한국어로 옮깁니다.

전체 번역 22 / 22

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

준비·설명 줄 6개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore;이 Java 파일의 package 주소를 `com.example.financialcore`로 정한다.
3import org.junit.jupiter.api.Test;뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
4import org.springframework.beans.factory.annotation.Autowired;뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
5import org.springframework.boot.test.context.SpringBootTest;뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
6import org.springframework.jdbc.core.simple.JdbcClient;뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
8import static org.assertj.core.api.Assertions.assertThat;assertion helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다.
원본한국어 번역
10@SpringBootTest다음 type declaration에 Spring Boot test context metadata를 부여하는 annotation이다.
11class CoreSchemaIT extends PostgresIntegrationTestSupport {CoreSchemaIT class를 열고 PostgresIntegrationTestSupport를 상속한다.
12 @Autowired JdbcClient jdbc;JdbcClient type의 jdbc field를 Spring autowiring injection point로 선언한다.
14 @Test바로 뒤 method declaration에 JUnit test metadata를 붙이는 annotation이다.
15 void v001CreatesExactlyTheRequiredCoreTables() {v001CreatesExactlyTheRequiredCoreTables test method body를 시작한다.
16 var tables = jdbc.sql("""var tables 대입문의 오른쪽에서 `jdbc.sql` text block 호출을 시작한다.
17 SELECT table_name FROM information_schema.tablesinformation_schema.tables에서 table_name을 SELECT한다.
18 WHERE table_schema='public' AND table_type='BASE TABLE'table_schema가 public이고 table_type이 BASE TABLE인 row로 filter한다.
19 AND table_name <> 'flyway_schema_history'table_name이 flyway_schema_history인 row를 제외한다.
20 ORDER BY table_nameSQL 결과를 table_name ascending order로 정렬한다.
21 """).query(String.class).list();text block을 닫고 query(String.class).list()로 SQL을 실행한다.
22 assertThat(tables)tables를 `assertThat`에 넘겨 collection assertion chain을 시작한다.
23 .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")assertion description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
24 .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");네 expected table name과 actual collection의 크기·membership을 exact하게 assert한다.
25 }closing brace로 v001CreatesExactlyTheRequiredCoreTables의 lexical scope를 마감한다.
26}CoreSchemaIT type definition의 마지막 brace를 적는다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

information_schema name projection과 AssertJ exact-membership assertion을 결합한 staged schema smoke test다.

문법 해부

  • Java text block은 여러 줄 SQL을 String으로 만든다.
  • JdbcClient.query(String.class).list()는 각 첫 column을 String list로 읽는다.
  • containsExactlyInAnyOrder는 order를 무시하지만 size와 membership은 exact하게 본다.

실행 순서

  1. Spring/inherited PostgreSQL support가 test fixture를 준비한다.
  2. catalog query가 filtered table names를 읽는다.
  3. AssertJ가 four-name exact collection을 비교한다.

원래 W6 수준의 조각별 정밀 해설

F02-C01 · package and imports
문법 해부
package는 com.example.financialcore namespace를 정하고, JUnit·SpringBootTest·JdbcClient·AssertJ import는 이 compilation unit이 사용할 type과 assertion 진입점을 준비한다.
실제 값 추적
compiler가 com.example.financialcore 주소와 Test, Autowired, SpringBootTest, JdbcClient, assertThat symbol을 해석할 수 있게 된다.
정상 예
이 범위 뒤에서 @Test와 assertThat을 qualified name 없이 참조할 수 있는 상태가 이 import set의 유효한 결과다.
틀린 예·반례
import가 있다는 이유만으로 Spring context가 이미 시작됐거나 PostgreSQL schema가 Green이라고 결론 내리면 틀리다.
착각 방지
package/import는 실행 assertion이 아니므로 mapping 표에서는 제외하지만, 모든 비공백 줄 번역에는 남긴다.
하지 않는 일
이 범위는 compile 문맥만 제공하며 table 존재·column·constraint·migration 성공을 검증하지 않는다.
다음 연결
다음 ‘test class and JDBC dependency’ 범위에서는 @SpringBootTest가 application context test를 요청하고, CoreSchemaIT는 PostgresIntegrationTestSupport를 상속하며 JdbcClient field를 주입받는다.
F02-C02 · test class and JDBC dependency
문법 해부
@SpringBootTest가 application context test를 요청하고, CoreSchemaIT는 PostgresIntegrationTestSupport를 상속하며 JdbcClient field를 주입받는다.
실제 값 추적
test instance를 만들 때 inherited PostgreSQL support와 Spring bean resolution이 먼저 준비되고 jdbc field가 query 통로가 된다.
정상 예
context bootstrap과 type-compatible bean resolution이 성공해 test instance의 jdbc field가 실제 reference를 받는 것이 이 범위의 유효 상태다.
틀린 예·반례
annotation과 field 선언만 보고 migration 결과나 schema 구조가 정확하다고 읽을 수는 없다.
착각 방지
이 파일은 W6D1 stage reference라 default project root가 이 exact byte를 active test로 compile한다고 가정하면 안 된다.
하지 않는 일
class wiring은 DB inventory assertion과 분리되며, 연결 성공만으로 네 core table의 구조까지 보장하지 않는다.
다음 연결
다음 ‘schema inventory query and assertion’ 범위에서는 @Test method가 information_schema.tables에서 public BASE TABLE을 읽고 Flyway history를 제외해 이름순 list를 만든다.
F02-C03 · schema inventory query and assertion
문법 해부
@Test method가 information_schema.tables에서 public BASE TABLE을 읽고 flyway_schema_history를 제외한 뒤 이름순 list로 만들며 AssertJ 설명 label을 붙인다.
실제 값 추적
public BASE TABLE 가운데 Flyway history가 아닌 이름들이 ORDER BY table_name 순서의 String list로 materialize된다.
정상 예
Flyway history 한 table만 제외한 clean schema에서 네 업무 table 이름이 list에 모이는 흐름이 정상 예다.
틀린 예·반례
public에 audit_event 같은 추가 BASE TABLE이 있으면 현재 tables list에 그 이름도 한 행으로 materialize된다.
착각 방지
`.as(...)`는 실패 메시지 label이며 이 range 자체는 table-name equality를 수행하지 않는다.
하지 않는 일
이 query는 table 이름 inventory만 보며 column type, PK/FK, index, row data를 검사하지 않는다.
다음 연결
다음 ‘exact table names and closes’ 범위가 네 core table 이름의 정확한 집합을 요구하고 method와 class brace를 닫는다.
F02-C04 · exact table names and closes
문법 해부
containsExactlyInAnyOrder가 account·business_tx·ledger_entry·idempotency_request의 exact collection을 요구하고 두 닫는 brace가 method와 class를 끝낸다.
실제 값 추적
tables가 네 값으로만 구성되면 순서와 무관하게 assertion이 통과한 뒤 Java scope 둘이 닫힌다.
정상 예
필수 이름이 모두 한 번씩 있고 extra business table이 없는 public schema가 이 matcher의 Green 예다.
틀린 예·반례
필수 table 하나가 빠지거나 다섯 번째 업무 table이 들어오면 exact set 조건과 맞지 않는다.
착각 방지
이 assertion을 ‘모든 schema 제약이 맞다’로 확대하지 말고 이름 집합 direct proof로 한정한다.
하지 않는 일
closing braces는 새 검증을 추가하지 않으며 Flyway history는 앞 query에서 비교 대상에서 빠졌다.
다음 연결
이 파일의 끝이다. selector가 stage source를 실제 compile한 경우에만 이 한 test의 direct evidence가 생긴다.
첫 실패 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    assertion만 실패 지점인가요?

  2. 니지카

    Spring bootstrap·주입·catalog query·String mapping도 먼저 실패할 수 있어.

  3. terminal matcher까지 도달해야 four-name comparison이 실제 관찰된다.

  4. 키타

    실행 순서대로 failure boundary를 test 카드에 쓰겠습니다.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
catalog candidatepublic account, four core tables, flyway_schema_history, optional viewschema/type/name predicates를 적용한다.public BASE TABLE 중 Flyway history를 뺀 names만 남는다.다른 관리 base table은 그대로 남아 extra-name failure를 만들 수 있다.
row mappingSELECT table_name 결과 rowsString.class mapper로 list()를 호출한다.tables 변수에 String names가 저장된다.table_name 이외 catalog columns는 읽지 않는다.
exact pass[account,business_tx,idempotency_request,ledger_entry]containsExactlyInAnyOrder 네 literals와 비교한다.같은 원소·같은 크기라 assertion이 통과한다.query ORDER BY 순서는 terminal matcher의 pass 조건이 아니다.
extra counterexample위 네 이름 + audit_shadowexact collection matcher를 실행한다.expected에 없는 extra table 때문에 실패한다.실패 description은 원인을 label할 뿐 schema를 고치지 않는다.
stage source와 runner히토리 → 니지카 → 료 → 키타
  1. 히토리

    selector 이름이 같으면 이 file을 실행한 거죠?

  2. 니지카

    FQCN 표찰만 같고 active source set에 staged bytes가 없을 수 있어.

  3. runner XML은 class/count를 보지만 source SHA를 기록하지 않는다.

  4. 키타

    stage path·SHA·fresh report를 별도 binding으로 요구하겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

JUnit/Spring

@SpringBootTest class와 inherited support를 통해 integration fixture bootstrap을 시도한다.

source만 읽는 것으로 실제 bootstrap success를 관찰할 수 없다.
PostgreSQL catalog

information_schema.tables가 visible relation metadata row를 제공한다.

projection에 없는 column/constraint/index metadata는 assertion으로 전달되지 않는다.
JdbcClient

SQL을 실행하고 table_name first column을 List<String>으로 materialize한다.

query failure는 AssertJ 단계 전에 test를 중단한다.
AssertJ

collection multiplicity·size·membership을 expected four names와 비교한다.

W6D1_RED label은 diagnostic text이지 별도 Red artifact가 아니다.

이 파일의 @Test가 실제로 고정하는 범위

v001CreatesExactlyTheRequiredCoreTables

Arrange · 준비
  • Spring Boot test context와 inherited PostgreSQL support가 준비된다는 전제가 있다.
  • JdbcClient bean이 field에 주입돼야 한다.
Act · 행동
  • information_schema.tables에서 public BASE TABLE table_name을 읽는다.
  • flyway_schema_history를 빼고 table_name으로 정렬해 String list로 만든다.
Assert · 확인
  • actual list가 account·business_tx·ledger_entry·idempotency_request와 exactly in any order다.
직접 보장
  • 이 exact staged source가 해당 database에서 실행·통과했다면 filtered public base-table name multiset이 네 expected names와 같다.
  • 추가 또는 누락 name이 있으면 terminal assertion이 실패한다.
보장하지 않음
  • column·type·PK·FK·UNIQUE·CHECK·index·cardinality
  • V001 source SHA 또는 formal ERD/normalization 일치
  • active packaged root에서 이 staged class를 compile·execute함
  • 전체 test suite Green

첫 실패 경계 Spring bootstrap·support·field injection이 먼저 실패할 수 있고, 이후 catalog query/mapping이 성공해야 마지막 exact collection assertion에 도달한다.

10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ 네 table name이 맞으면 V001 전체가 맞다.

왜 틀리나 SELECT projection은 table_name 하나뿐이다.

바르게 읽기 column·constraint·index는 V001 또는 별도 catalog assertion으로 본다.

반례 account table 이름은 같지만 balance CHECK가 없어도 이 test는 통과할 수 있다.

❌ ORDER BY 때문에 expected literal 순서도 같아야 한다.

왜 틀리나 terminal matcher가 InAnyOrder다.

바르게 읽기 정렬은 deterministic output 편의이고 assertion은 membership exactness를 본다.

반례 같은 네 이름의 다른 순서도 matcher 조건상 통과한다.

❌ flyway_schema_history만 제외했으니 모든 관리 table도 빠진다.

왜 틀리나 19줄은 한 literal만 inequality로 뺀다.

바르게 읽기 다른 public base table은 결과에 남아 extra-name failure가 된다.

반례 public.audit_shadow는 BASE TABLE이면 포함된다.

❌ runner가 CoreSchemaIT를 선택했으니 이 staged SHA가 실행됐다.

왜 틀리나 selector FQCN과 stage file bytes 사이 hash binding이 runner에 없다.

바르게 읽기 source-set composition과 SHA, fresh execution evidence를 별도로 묶는다.

반례 active packaged root에는 이 exact selector class가 없다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

schema depth

column type·nullability·PK/FK/UNIQUE/CHECK/index 일치

이 책임을 맡는 곳: V001 source audit와 deeper catalog tests
execution binding

active root가 this staged SHA를 compile·execute함

이 책임을 맡는 곳: source composition manifest, hash binding, fresh test report
cardinality

ACCOUNT/BUSINESS_TX 1:N LEDGER_ENTRY 관계

이 책임을 맡는 곳: FK DDL, ERD, fixture join evidence
normalization

functional dependency와 1NF~BCNF 판정

이 책임을 맡는 곳: modeling analysis and human review
12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

SQL의 projection·filter·terminal matcher가 각각 관찰하는 정보만 한 줄씩 적는다.

2단계 · 코드 조각 재조립

  1. projection=table_name only
  2. filter=public + BASE TABLE - flyway history
  3. actual=List<String>
  4. assertion=exact membership, order ignored

3단계 · 파일 전체 다시 쓰기

추가 public table과 빠진 core table counterexample를 각각 예상하고 matcher 결과를 설명한다.

자가 점검
  • four expected names를 source 철자 그대로 썼는가?
  • ORDER BY와 InAnyOrder 역할을 분리했는가?
  • name-set proof를 key/index proof로 넓히지 않았는가?
  • stage SHA와 runner selector를 같은 증거로 취급하지 않았는가?
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w6/day-1/CoreSchemaIT.javaSHA-256 30d21e5fd028dab32aa797263a8b2793a8e397ce663aaef08000304e70cbbb48
CoreSchemaIT.java — public base table 네 이름의 exact set test 전체
package com.example.financialcore;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;

    @Test
    void v001CreatesExactlyTheRequiredCoreTables() {
        var tables = jdbc.sql("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema='public' AND table_type='BASE TABLE'
                  AND table_name <> 'flyway_schema_history'
                ORDER BY table_name
                """).query(String.class).list();
        assertThat(tables)
            .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
            .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
    }
}
03

OpeningIntegrationTest.java — opening 성공 뒤 세 scalar count 확인

reference/w6/day-3/OpeningIntegrationTest.java

누적 W6 JUnit 단계 참고본 · 정본 · W15-F03
21줄 연결29줄 번역4 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

W6D3 staged opening test의 입력·정리·세 사후 SELECT와 method 이름보다 좁은 proof 범위를 읽는다.

  1. BeforeEach는 어떤 table과 identity를 정리할까?
  2. open에 들어가는 owner·account number·amount는 무엇일까?
  3. 세 COUNT는 어떤 predicate와 순서로 실행될까?
  4. method 이름의 Atomically를 failure rollback proof로 읽어도 될까?
  5. 세 SELECT가 한 snapshot이라는 근거가 있을까?
이 파일에서 끝까지 다시 쓰는 값owner=customer-1account_no=OPEN-100amount=12_345account count=1OPENING business_tx count=1OPENING ledger count=1@Test=1stage=W6D3
02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

STARRY가 깨끗한 test DB에서 OPEN-100 계좌 한 건을 열고 남은 표를 센다.

개설 뒤 세 장부 영수증 세기

BeforeEach는 네 table을 TRUNCATE한 뒤 customer-1·OPEN-100·12,345를 AccountOpeningService에 건넨다.

반환 account ID를 써서 account 1행, OPENING business_tx 1행, 같은 account의 OPENING ledger 1행을 순서대로 확인한다.

딱 여기까지만 성공 뒤 count 세 개만 보므로 중간 실패 rollback·single snapshot·amount와 balance 값 일치는 확인하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

시험 전 빈 장부

BeforeEach가 네 table row를 비우고 identity도 다시 시작한다.

코드 연결
17~20줄
비유
새 리허설 전에 이전 공연 영수증과 번호표를 치운다.
비유의 끝
이 class 밖의 database나 다른 schema까지 지우는 일반 청소는 아니다.

실제 service 호출

mock return이 아니라 injected AccountOpeningService.open을 concrete 값으로 호출한다.

코드 연결
24줄
비유
접수대에 실제 개설표를 내고 돌아온 계좌표를 받는다.
비유의 끝
call site에는 service 내부 transaction 순서가 보이지 않는다.

세 번의 사후 COUNT

account, business_tx, ledger_entry를 각각 별도 SQL로 센다.

코드 연결
25~32줄
비유
세 장부를 차례로 열어 한 장씩인지 확인한다.
비유의 끝
동시에 찍은 한 장의 snapshot 사진은 아니다.

이름보다 assertion

Atomically라는 method name이 아니라 실제 query와 matcher가 proof 범위를 정한다.

코드 연결
23~32줄
비유
검사표 제목보다 실제 체크한 칸을 믿는다.
비유의 끝
failure injection이나 assertThrows가 없으면 rollback path를 직접 보지 못한다.
무엇을 먼저 지우나히토리 → 니지카 → 료 → 키타
  1. 히토리

    test가 알아서 rollback되니 clean은 필요 없지 않나요?

  2. 니지카

    이 class에는 rollback annotation이 없고 BeforeEach가 네 table을 명시적으로 TRUNCATE해.

  3. fixture reset mechanism은 source line19의 update이지 추정 transaction rollback이 아니다.

  4. 키타

    네 table·RESTART IDENTITY·CASCADE를 그대로 적겠습니다.

Atomically라는 이름히토리 → 니지카 → 료 → 키타
  1. 히토리

    method 이름이 atomic이라고 선언했으니 충분한가요?

  2. 니지카

    제목은 의도이고 실제로 보는 것은 성공 뒤 세 COUNT뿐이야.

  3. failure hook과 실패 후 state assertion이 없으므로 rollback proof quantifier는 0이다.

  4. 키타

    직접 증명과 미증명 칸을 나눠 쓰겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.누적 W6 JUnit 단계 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.21 / 21 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
12줄F03-L12 @SpringBootTest STARRY의 계좌 개설 보존 시험표에 Spring integration 표찰을 먼저 붙인다. 다음 type declaration에 Spring Boot test context metadata를 부여한다.
입력
compiler와 Spring test discovery가 `@SpringBootTest` modifier를 읽는다.
결과·효과
이어지는 type이 application-context bootstrap 후보가 된다.
비유의 한계
annotation만으로 service bean·database·migration 성공은 확정되지 않는다.
13줄F03-L13 class OpeningIntegrationTest extends PostgresIntegrationTestSupport { 개설 시험실을 공용 PostgreSQL fixture 바닥판 위에 세운다. OpeningIntegrationTest class를 열고 PostgresIntegrationTestSupport를 상속한다.
입력
JVM이 staged class와 superclass type relationship을 입력받는다.
결과·효과
test instance가 support의 database lifecycle과 설정을 상속할 수 있게 된다.
비유의 한계
superclass가 실제로 어떤 container를 준비하는지는 이 source에 없다.
14줄F03-L14 @Autowired JdbcClient jdbc; 행 수를 물을 JdbcClient 계수기를 첫 번째 자동 주입 socket에 꽂는다. jdbc field를 autowired JdbcClient injection point로 선언한다.
입력
Spring context가 test instance에 맞는 JdbcClient bean을 resolve한다.
결과·효과
주입 성공 뒤 clean과 세 count query가 사용할 reference가 field에 놓인다.
비유의 한계
한 field를 공유한다고 세 SELECT가 같은 transaction snapshot을 쓰는 것은 아니다.
15줄F03-L15 @Autowired AccountOpeningService openings; 실제 계좌 개설 절차를 부를 openings 창구도 자동 연결한다. openings field를 autowired AccountOpeningService injection point로 선언한다.
입력
application context와 requested service type이 bean-resolution 입력이다.
결과·효과
matching bean이 있으면 test가 `openings.open`을 호출할 object reference를 얻는다.
비유의 한계
bean 존재는 open 내부 transaction·rollback 동작을 증명하지 않는다.
17줄F03-L17 @BeforeEach 각 시험 전에 한 번 실행할 준비 절차 표식만 먼저 놓는다. 바로 뒤 method declaration에 JUnit BeforeEach metadata를 붙인다.
입력
JUnit lifecycle discovery가 standalone `@BeforeEach` annotation을 scan한다.
결과·효과
이어지는 fixture method가 각 test 전 hook 후보가 된다.
비유의 한계
아직 method 이름·정리 대상·실행 성공은 이 한 줄에 나타나지 않는다.
18줄F03-L18 void clean() { fixture를 정리하는 clean 작업 공간을 연다. void clean method body를 시작한다.
입력
JUnit이 test invocation 전에 이 lifecycle method를 호출한다.
결과·효과
fixture-preparation SQL을 실행할 call frame이 생긴다.
비유의 한계
method 진입만으로 database row가 삭제된 것은 아니다.
19줄F03-L19 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update(); 네 table을 비우고 identity 번호도 되감으며 dependent row를 cascade 정리한다. TRUNCATE 네 table·RESTART IDENTITY·CASCADE SQL을 JdbcClient update로 실행한다.
입력
idempotency_request, ledger_entry, business_tx, account relation과 current connection이 입력이다.
결과·효과
성공하면 네 relation row가 제거되고 소유 sequence가 재시작되며 dependent truncate가 적용된다.
비유의 한계
다른 schema·table은 명시하지 않았고 transaction boundary는 이 호출 한 줄에 보이지 않는다.
20줄F03-L20 } fixture 청소 절차의 문을 닫는다. clean lifecycle routine의 closing brace를 적는다.
입력
TRUNCATE update 뒤 예외 없이 돌아온 control flow가 closing brace에 도달한다.
결과·효과
JUnit BeforeEach phase가 successful completion 후보가 되어 test body로 진행할 수 있다.
비유의 한계
뒤 test가 실패해도 이 brace가 자동 복구를 추가하지 않는다.
22줄F03-L22 @Test 다음 개설 검사를 JUnit 실행 목록에 올릴 표식만 둔다. 바로 뒤 method declaration에 JUnit test metadata를 붙인다.
입력
discovery engine이 standalone `@Test` modifier를 읽는다.
결과·효과
이어지는 method가 executable test descriptor 후보가 된다.
비유의 한계
method의 긴 이름이나 atomicity assertion은 아직 이 annotation에서 나오지 않는다.
23줄F03-L23 void openingWritesAccountBusinessTransactionAndLedgerAtomically() { 개설 뒤 account·business transaction·ledger를 살필 시험 작업 공간을 연다. openingWritesAccountBusinessTransactionAndLedgerAtomically method body를 시작한다.
입력
clean hook을 마친 test instance에서 JUnit이 이 method를 호출한다.
결과·효과
service 호출과 사후 count assertion을 순서대로 수행할 call frame이 생긴다.
비유의 한계
method 이름의 Atomically는 failure-injection proof나 DB isolation assertion이 아니다.
24줄F03-L24 var account = openings.open("customer-1", "OPEN-100", 12_345); customer-1·OPEN-100·12,345 개설표를 openings 창구에 내고 반환 계좌를 받는다. AccountOpeningService.open을 세 concrete argument로 호출해 반환값을 account에 대입한다.
입력
owner `customer-1`, account number `OPEN-100`, amount 12_345와 injected service가 입력이다.
결과·효과
호출이 성공하면 returned account object reference가 local variable에 저장된다.
비유의 한계
service 내부가 어느 row를 어떤 순서·transaction으로 썼는지는 이 call site만으로 알 수 없다.
25줄F03-L25 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id") 반환 id와 같은 account row 수를 셀 SQL을 assertion 저울에 걸기 시작한다. account table의 matching id COUNT query와 assertThat expression을 연다.
입력
현재 account object와 named parameter placeholder가 있는 SQL String이 입력이다.
결과·효과
JdbcClient fluent chain과 collection이 아닌 scalar assertion subject가 미완성 상태로 열린다.
비유의 한계
parameter binding·query terminal·expected count는 아직 이 줄에서 끝나지 않았다.
26줄F03-L26 .param("id", account.getId()).query(Long.class).single()).isEqualTo(1); account id를 SQL에 끼우고 Long 한 값을 읽어 1과 비교한다. `:id`에 account.getId()를 bind하고 scalar COUNT를 실행해 `isEqualTo(1)`로 assert한다.
입력
반환 account id, account COUNT SQL, Long mapper, expected 1이 현재 chain 입력이다.
결과·효과
matching account row가 정확히 하나면 첫 terminal assertion이 통과한다.
비유의 한계
account_no·owner_id·balance 값이나 다른 table의 상태는 이 COUNT가 읽지 않는다.
27줄F03-L27 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'") OPENING 종류 business transaction 수를 셀 두 번째 SQL 저울을 연다. tx_type이 OPENING인 business_tx COUNT query를 assertThat에 넣기 시작한다.
입력
literal predicate를 포함한 SQL String과 JdbcClient가 입력이다.
결과·효과
scalar query chain과 assertion subject가 아직 terminal comparison 없이 열린다.
비유의 한계
correlation_id·status·요청 account와의 관계는 WHERE에 없다.
28줄F03-L28 .query(Long.class).single()).isEqualTo(1); 두 번째 COUNT를 Long 한 값으로 읽어 정확히 1인지 판정한다. business_tx scalar query를 실행하고 결과를 expected 1과 비교한다.
입력
앞 SQL, Long.class mapper, single result와 integer literal 1이 입력이다.
결과·효과
OPENING tx_type row가 정확히 한 개일 때 두 번째 assertion이 통과한다.
비유의 한계
이 row가 직전 open 호출에서 생겼는지 source가 직접 join하거나 correlation하지 않는다.
29줄F03-L29 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'") 반환 account의 OPENING ledger 수를 셀 세 번째 SQL 저울을 연다. account_id parameter와 entry_type OPENING 조건의 ledger_entry COUNT query를 시작한다.
입력
current account object, named `:id` placeholder와 ledger SQL이 입력이다.
결과·효과
JdbcClient chain이 matching ledger scalar를 만들 준비를 하지만 아직 실행이 끝나지 않는다.
비유의 한계
amount·signed_amount·balance_after·business_tx_id는 SELECT나 predicate에 없다.
30줄F03-L30 .param("id", account.getId()).query(Long.class).single()) ledger SQL에 account id를 넣고 Long single 값을 assertion subject로 만든다. `:id`를 bind해 ledger COUNT를 실행하고 반환 Long을 assertThat에 넘긴다.
입력
account.getId(), 완성된 query, Long mapper와 single-result contract가 입력이다.
결과·효과
성공하면 세 번째 scalar assertion object가 만들어지고 terminal equality를 기다린다.
비유의 한계
scalar 값을 assertion subject로 만들었을 뿐 pass/fail predicate는 아직 없다.
31줄F03-L31 .as("W6D3_RED_EXPECTED_OPENING_LEDGER") 세 번째 저울 실패 시 보일 W6D3 ledger 진단표를 붙인다. assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
입력
ledger COUNT assertion object와 diagnostic String이 입력이다.
결과·효과
뒤 equality가 실패하면 지정 label이 message에 포함될 상태가 된다.
비유의 한계
description은 ledger row를 만들거나 assertion 결과를 바꾸지 않는다.
32줄F03-L32 .isEqualTo(1); 마지막 ledger COUNT가 정확히 1인지 terminal 판정을 내린다. 세 번째 scalar assertion에 `isEqualTo(1)`을 적용한다.
입력
앞에서 읽은 Long count와 expected integer 1이 비교 입력이다.
결과·효과
값이 1이면 마지막 assertion이 통과하고 아니면 diagnostic label과 함께 실패한다.
비유의 한계
ledger의 금액·부호·사후 잔액이나 전체 opening atomic rollback은 검증하지 않는다.
33줄F03-L33 } opening 성공 경로 시험의 작업 공간을 닫는다. opening test method의 lexical scope를 closing brace로 마감한다.
입력
service call과 세 terminal assertions를 통과한 control flow가 입력이다.
결과·효과
세 번째 assertion 뒤 throwable 없이 return하면 JUnit invocation이 정상 종료된다.
비유의 한계
closing brace는 failure path나 네 번째 idempotency table assertion을 추가하지 않는다.
34줄F03-L34 } OpeningIntegrationTest 시험실의 바깥 scope를 닫는다. OpeningIntegrationTest type definition의 최종 brace를 적는다.
입력
compiler가 두 injected fields, clean hook, 한 test method를 포함한 type scope를 닫는다.
결과·효과
하나의 staged integration-test class definition이 완성된다.
비유의 한계
active packaged root에서 이 exact staged file이 compile·execute됐다는 증거는 아니다.
세 COUNT의 연결히토리 → 니지카 → 료 → 키타
  1. 히토리

    business_tx 한 행도 이 account의 opening이라고 확인하죠?

  2. 니지카

    그 query는 tx_type='OPENING'만 보고 account id나 correlation을 join하지 않아.

  3. 세 assertions는 각각 존재 count를 보며 row 간 referential identity까지 assert하지 않는다.

  4. 키타

    unrelated OPENING row 반례를 남겨 둘게요.

05

STEP 05 / 13

원본 코드 조각

원본을 4개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.

F03-C01 · package and imports1–10줄
1–10줄 원본
package com.example.financialcore.account;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
F03-C02 · test class and dependencies11–16줄
11–16줄 원본

@SpringBootTest
class OpeningIntegrationTest extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
F03-C03 · fixture cleanup17–20줄
17–20줄 원본
    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    }
F03-C04 · opening call and three observed row counts21–34줄
21–34줄 원본

    @Test
    void openingWritesAccountBusinessTransactionAndLedgerAtomically() {
        var account = openings.open("customer-1", "OPEN-100", 12_345);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")
            .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")
            .param("id", account.getId()).query(Long.class).single())
            .as("W6D3_RED_EXPECTED_OPENING_LEDGER")
            .isEqualTo(1);
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 29줄을 모두 한국어로 옮깁니다.

전체 번역 29 / 29

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

준비·설명 줄 8개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.account;이 Java 파일의 package 주소를 `com.example.financialcore.account`로 정한다.
3import com.example.financialcore.PostgresIntegrationTestSupport;뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
4import org.junit.jupiter.api.BeforeEach;뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
5import org.junit.jupiter.api.Test;뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
6import org.springframework.beans.factory.annotation.Autowired;뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
7import org.springframework.boot.test.context.SpringBootTest;뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
8import org.springframework.jdbc.core.simple.JdbcClient;뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
10import static org.assertj.core.api.Assertions.assertThat;assertion helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다.
원본한국어 번역
12@SpringBootTest다음 type declaration에 Spring Boot test context metadata를 부여한다.
13class OpeningIntegrationTest extends PostgresIntegrationTestSupport {OpeningIntegrationTest class를 열고 PostgresIntegrationTestSupport를 상속한다.
14 @Autowired JdbcClient jdbc;jdbc field를 autowired JdbcClient injection point로 선언한다.
15 @Autowired AccountOpeningService openings;openings field를 autowired AccountOpeningService injection point로 선언한다.
17 @BeforeEach바로 뒤 method declaration에 JUnit BeforeEach metadata를 붙인다.
18 void clean() {void clean method body를 시작한다.
19 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();TRUNCATE 네 table·RESTART IDENTITY·CASCADE SQL을 JdbcClient update로 실행한다.
20 }clean lifecycle routine의 closing brace를 적는다.
22 @Test바로 뒤 method declaration에 JUnit test metadata를 붙인다.
23 void openingWritesAccountBusinessTransactionAndLedgerAtomically() {openingWritesAccountBusinessTransactionAndLedgerAtomically method body를 시작한다.
24 var account = openings.open("customer-1", "OPEN-100", 12_345);AccountOpeningService.open을 세 concrete argument로 호출해 반환값을 account에 대입한다.
25 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")account table의 matching id COUNT query와 assertThat expression을 연다.
26 .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);`:id`에 account.getId()를 bind하고 scalar COUNT를 실행해 `isEqualTo(1)`로 assert한다.
27 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")tx_type이 OPENING인 business_tx COUNT query를 assertThat에 넣기 시작한다.
28 .query(Long.class).single()).isEqualTo(1);business_tx scalar query를 실행하고 결과를 expected 1과 비교한다.
29 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")account_id parameter와 entry_type OPENING 조건의 ledger_entry COUNT query를 시작한다.
30 .param("id", account.getId()).query(Long.class).single())`:id`를 bind해 ledger COUNT를 실행하고 반환 Long을 assertThat에 넘긴다.
31 .as("W6D3_RED_EXPECTED_OPENING_LEDGER")assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
32 .isEqualTo(1);세 번째 scalar assertion에 `isEqualTo(1)`을 적용한다.
33 }opening test method의 lexical scope를 closing brace로 마감한다.
34}OpeningIntegrationTest type definition의 최종 brace를 적는다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

successful service call 후 세 독립 scalar COUNT가 1인지 순차 확인하는 staged integration test다.

문법 해부

  • TRUNCATE ... RESTART IDENTITY CASCADE는 row 삭제·sequence reset·dependency 처리를 요청한다.
  • JdbcClient named parameter `:id`는 account.getId()로 bind된다.
  • query(Long.class).single()은 정확히 한 scalar COUNT row를 요구한다.
  • AssertJ fluent chain은 earlier terminal failure에서 뒤 statement로 진행하지 않는다.

실행 순서

  1. BeforeEach clean이 네 table을 비운다.
  2. openings.open이 계좌 개설을 시도하고 account를 반환한다.
  3. account COUNT를 먼저 확인한다.
  4. business_tx COUNT, ledger_entry COUNT를 이어서 확인한다.

원래 W6 수준의 조각별 정밀 해설

F03-C01 · package and imports
문법 해부
account package와 PostgreSQL support, BeforeEach/Test, Spring wiring, JdbcClient, AssertJ import가 이 Java unit의 compile 문맥을 만든다.
실제 값 추적
compiler는 PostgresIntegrationTestSupport, lifecycle annotations, JdbcClient, Spring test metadata와 assertion API symbols를 연결한다.
정상 예
나열된 types와 static assertThat을 뒤 source에서 짧은 이름으로 참조할 수 있는 상태가 이 범위의 유효 결과다.
틀린 예·반례
import 목록만으로 어떤 application bean이나 migration이 실제로 성공했다고 판단하면 안 된다.
착각 방지
준비 선언은 mapping body에서 제외해도 translation 대상이며 F02와 다른 account namespace를 보존해야 한다.
하지 않는 일
이 부분은 compile-time 이름 준비만 담당하며 application operation이나 persistence 결과를 직접 관찰하지 않는다.
다음 연결
다음 ‘test class and dependencies’ 범위에서는 @SpringBootTest class가 PostgreSQL support를 상속하고 JdbcClient와 AccountOpeningService를 주입받는다.
F03-C02 · test class and dependencies
문법 해부
@SpringBootTest class가 PostgreSQL support를 상속하고 JdbcClient와 AccountOpeningService 두 dependency를 Autowired field로 받는다.
실제 값 추적
Spring test instance가 만들어질 때 jdbc와 openings 두 injection point에 type-compatible bean references가 연결될 준비가 된다.
정상 예
context가 기동되고 두 fields가 각각 JdbcClient와 AccountOpeningService instance를 가리키는 상태가 정상 wiring이다.
틀린 예·반례
field가 선언됐다는 사실만으로 어느 service method의 내부 transaction이나 persistence effect가 증명되지는 않는다.
착각 방지
OpeningIntegrationTest 역시 stage-only source라 selector 이름과 default root 바이트 동일성을 같은 것으로 보면 안 된다.
하지 않는 일
여기서는 dependency graph만 보며 인증, HTTP controller, idempotency replay를 다루지 않는다.
다음 연결
다음 ‘fixture cleanup’ 범위에서 @BeforeEach가 네 table을 CASCADE TRUNCATE하고 identity를 다시 시작한다.
F03-C03 · fixture cleanup
문법 해부
@BeforeEach clean은 네 table을 CASCADE TRUNCATE하고 identity를 다시 시작해 각 test를 빈 fixture에서 출발시킨다.
실제 값 추적
test 직전 account·business_tx·ledger_entry·idempotency_request row가 제거되고 sequence 기준도 초기화된다.
정상 예
이 test class를 단독 순차 실행하면 이전 method의 persistence가 다음 assertion에 섞이지 않는다.
틀린 예·반례
같은 database를 다른 process가 동시에 사용하면 TRUNCATE가 그 실행의 data까지 건드릴 수 있다.
착각 방지
fixture reset을 production cleanup이나 transaction isolation proof로 읽지 않는다.
하지 않는 일
이 범위는 빈 시작 상태만 만들며 어떤 application operation의 성공이나 atomic rollback도 검사하지 않는다.
다음 연결
다음 ‘opening call and three observed row counts’ 범위는 customer-1, OPEN-100, 12345로 open하고 세 persistence count를 본다.
F03-C04 · opening call and three observed row counts
문법 해부
test가 customer-1, OPEN-100, 12345로 openings.open을 호출하고 반환 account id를 써 account·OPENING business_tx·OPENING ledger_entry count를 각각 assert한다.
실제 값 추적
성공 호출 뒤 세 SELECT COUNT 결과가 모두 1이면 assertion 묶음이 통과한다.
정상 예
빈 fixture에서 한 번 opening한 뒤 account, business transaction, ledger가 각 한 행인 상태가 이 source의 직접 Green 예다.
틀린 예·반례
중간 write 뒤 예외를 주입하지 않으므로 성공 후 세 count만으로 실패 시 atomic rollback을 증명할 수 없다.
착각 방지
method 이름의 ‘Atomically’보다 실제 assertion 범위를 우선해 성공 persistence 세 효과라고 읽는다.
하지 않는 일
balance 값, idempotency_request row, column 내용, 동시성은 이 count 관찰의 책임 밖이다.
다음 연결
이 파일의 끝이다. 전체를 다시 볼 때는 stage-only selector와 성공 후 세 DB 효과의 범위를 함께 유지한다.
single snapshot 착각히토리 → 니지카 → 료 → 키타
  1. 히토리

    같은 jdbc field로 조회하니 한 snapshot 아닌가요?

  2. 니지카

    같은 client reference와 같은 transaction은 다른 개념이야.

  3. source에는 @Transactional이나 explicit connection transaction 경계가 없다.

  4. 키타

    순차 독립 사후 조회라고 표현하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
fixture resetidempotency_request, ledger_entry, business_tx, account의 기존 rowsTRUNCATE ... RESTART IDENTITY CASCADE update를 실행한다.성공하면 네 table이 비고 관련 identity가 재시작된다.clean 자체가 실패하면 test body는 시작하지 못한다.
opening callcustomer-1, OPEN-100, 12_345AccountOpeningService.open을 호출한다.성공하면 반환 Account reference와 id를 사용할 수 있다.source call site는 내부 transaction·SQL sequence를 드러내지 않는다.
account observationreturned account.getId()account WHERE id=:id COUNT를 읽는다.matching account row가 하나일 때 첫 assertion이 통과한다.owner·account_no·balance column 값은 SELECT하지 않는다.
tx and ledger observationstx_type=OPENING과 returned account id + entry_type=OPENING두 별도 COUNT query를 순서대로 실행한다.각 scalar가 1이면 test method가 끝까지 도달한다.두 row가 같은 business_tx로 연결됐는지와 amount·balance는 읽지 않는다.
첫 failure 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    ledger assertion까지 항상 실행되나요?

  2. 니지카

    clean이나 open이 실패할 수 있고 account count가 틀려도 뒤 SQL로 가지 않아.

  3. JUnit은 첫 thrown assertion error에서 method control flow를 중단한다.

  4. 키타

    clean→open→세 assertions 순으로 failure boundary를 기록할게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

JUnit lifecycle

한 @Test 전 clean @BeforeEach가 먼저 호출된다.

cleanup after test나 rollback annotation은 이 class에 없다.
Spring service

injected AccountOpeningService bean의 open method가 application logic을 실행한다.

transaction annotation과 repository code는 이 source 밖이라 call site에서 추론할 수 없다.
PostgreSQL/JdbcClient

TRUNCATE 뒤 세 COUNT statement가 각각 실행되고 scalar Long을 반환한다.

test source는 공통 transaction이나 isolation level을 선언하지 않는다.
AssertJ/JUnit

각 count를 1과 비교하고 첫 failure에서 exception으로 method를 중단한다.

뒤 assertion은 앞 assertion이 통과했을 때만 관찰된다.

이 파일의 @Test가 실제로 고정하는 범위

openingWritesAccountBusinessTransactionAndLedgerAtomically

Arrange · 준비
  • Spring context와 inherited PostgreSQL support, JdbcClient, AccountOpeningService가 준비돼야 한다.
  • BeforeEach가 네 table을 TRUNCATE RESTART IDENTITY CASCADE한다.
Act · 행동
  • openings.open('customer-1','OPEN-100',12_345)를 호출해 account를 받는다.
  • account id, OPENING tx_type, account id + OPENING entry_type로 세 scalar COUNT를 순차 조회한다.
Assert · 확인
  • 반환 id의 account COUNT가 1이다.
  • tx_type='OPENING'인 business_tx COUNT가 1이다.
  • 반환 account_id와 entry_type='OPENING'인 ledger_entry COUNT가 1이다.
직접 보장
  • 이 staged source가 실행·통과한 fixture에서 successful open call 뒤 세 query가 각각 count 1을 관찰했다.
  • returned account id와 matching account/ledger existence 및 전체 OPENING tx row count를 좁게 확인한다.
보장하지 않음
  • 중간 failure 뒤 세 table의 atomic rollback
  • 세 SELECT의 single transaction snapshot
  • business_tx row와 returned account의 direct identity link
  • amount·signed_amount·balance_after·account.balance 값 일치
  • idempotency_request state 또는 replay protocol
  • active packaged root에서 staged SHA를 fresh execute함

첫 실패 경계 BeforeEach clean, Spring/service call, account query/assertion, business_tx query/assertion, ledger query/assertion 순서이며 어느 단계든 먼저 실패하면 뒤 관찰은 실행되지 않는다.

10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ method 이름에 Atomically가 있으니 rollback이 증명됐다.

왜 틀리나 test에는 failure hook·assertThrows·실패 후 zero-row assertion이 없다.

바르게 읽기 method title이 아니라 executed failure path와 terminal assertions를 proof로 본다.

반례 성공 path 세 count는 1이지만 두 번째 write failure 뒤 partial row가 남는 bug를 잡지 못할 수 있다.

❌ 세 COUNT는 같은 순간의 snapshot이다.

왜 틀리나 source에는 test transaction이나 shared snapshot declaration이 없다.

바르게 읽기 순차적인 독립 사후 조회라고 표현하고 isolation 증거를 별도로 요구한다.

반례 첫 SELECT와 세 번째 SELECT 사이 다른 transaction이 row를 바꿀 수 있다.

❌ count 1이면 amount 12,345와 balance도 맞다.

왜 틀리나 SELECT는 COUNT(*)만 반환한다.

바르게 읽기 amount·signed_amount·balance_after·account.balance를 직접 projection/assert한다.

반례 잘못된 amount로 한 ledger row가 있어도 ledger count는 1이다.

❌ OPENING business_tx 한 행이 반환 account와 연결됐다고 증명한다.

왜 틀리나 business_tx query는 tx_type만 filter하고 account와 join하지 않는다.

바르게 읽기 business_tx_id를 ledger와 join하거나 correlation identifier를 직접 assert한다.

반례 unrelated OPENING row 한 개도 두 번째 COUNT 조건을 만족한다.

❌ runner selector가 이 staged file의 fresh execution을 보장한다.

왜 틀리나 active root에는 exact class가 없고 runner는 source SHA를 기록하지 않는다.

바르게 읽기 stage composition·SHA·JUnit report 생성 시각을 별도 결박한다.

반례 같은 FQCN을 다른 bytes로 제공해도 selector 문자열은 변하지 않는다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

failure atomicity

중간 write exception 뒤 account·business_tx·ledger가 전부 rollback됨

이 책임을 맡는 곳: failure injection integration test와 transaction implementation
snapshot

세 COUNT가 하나의 database snapshot을 공유함

이 책임을 맡는 곳: explicit test transaction/isolation or single aggregate observation
value correctness

account.balance·ledger amount/signed_amount/balance_after가 12_345와 일치

이 책임을 맡는 곳: value-selecting assertions and domain invariant tests
idempotency

idempotency_request row·replay·conflict 처리

이 책임을 맡는 곳: idempotency-specific tests and service protocol
source provenance

active packaged root가 W6D3 staged SHA를 실행함

이 책임을 맡는 곳: composed source manifest, hash, fresh JUnit evidence
12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

clean→open→account count→business_tx count→ledger count 순서를 숫자와 predicate까지 적는다.

2단계 · 코드 조각 재조립

  1. TRUNCATE four tables + restart identity
  2. open(customer-1, OPEN-100, 12_345)
  3. account id count=1
  4. OPENING tx count=1
  5. account id + OPENING ledger count=1

3단계 · 파일 전체 다시 쓰기

성공 path proof와 추가로 필요한 failure rollback/value/snapshot tests를 서로 다른 목록으로 작성한다.

자가 점검
  • 각 query의 WHERE predicate를 정확히 구분했는가?
  • business_tx query가 account와 연결되지 않는 점을 말했는가?
  • Atomically 이름을 failure proof로 사용하지 않았는가?
  • 세 SELECT를 single snapshot이라 부르지 않았는가?
  • 첫 failure 뒤 뒤 assertions가 실행되지 않음을 적었는가?
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w6/day-3/OpeningIntegrationTest.javaSHA-256 8c872765463ecbb40fe503b7c43355dee140371aa5d89e2fb7d90e89c3c49571
OpeningIntegrationTest.java — opening 성공 뒤 세 scalar count 확인 전체
package com.example.financialcore.account;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class OpeningIntegrationTest extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    }

    @Test
    void openingWritesAccountBusinessTransactionAndLedgerAtomically() {
        var account = openings.open("customer-1", "OPEN-100", 12_345);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")
            .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")
            .param("id", account.getId()).query(Long.class).single())
            .as("W6D3_RED_EXPECTED_OPENING_LEDGER")
            .isEqualTo(1);
    }
}
04

compose.yaml — W15 schema 실험용 PostgreSQL 방

runtime/compose.yaml

정본 PostgreSQL runtime · 정본 · W15-F04
18줄 연결18줄 번역5 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

W15 schema runner가 Compose mode에서 소유할 PostgreSQL service의 image·port·초기 환경·healthcheck·volume 계약을 읽는다.

  1. 왜 runner보다 먼저 runtime 설정을 읽어야 할까?
  2. FCL_DB_PORT가 없거나 빈 문자열이면 어느 host port를 쓸까?
  3. 필수 password가 없으면 container가 생기기 전에 어디서 멈출까?
  4. pg_isready exit 0이 schema와 인증까지 증명할까?
  5. named volume은 W15 실행 뒤 반드시 남을까?
이 파일에서 끝까지 다시 쓰는 값service=dbpostgres:17.10-alpinehost=${FCL_DB_PORT:-5432}container=5432database=financial_coreuser=apphealth=2s/2s/30volume=financial-core-db
02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

STARRY가 W15 열쇠와 관계 실험을 위해 버전과 출입 규칙이 고정된 PostgreSQL 방 하나를 준비한다.

스키마 실험을 받을 DB 방

이 설정은 runner가 쓸 PostgreSQL 17.10 방의 이름, 밖·안 port, database와 user를 한곳에 모은다.

서버가 연결 요청을 받을 때까지 healthcheck를 반복하고 데이터 선반은 named volume에 연결한다.

딱 여기까지만 방이 healthy여도 app 인증·V001 적용·key 수·cardinality 결과까지 맞았다는 뜻은 아니다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

DB 방 하나

services 아래 db 한 개와 image tag를 선언한다.

코드 연결
1~3줄
비유
연습실 열쇠에 db 방 번호와 PostgreSQL 상자표를 붙인다.
비유의 끝
image tag는 digest가 아니고 방을 즉시 실행하지도 않는다.

밖 문과 안 문

host 쪽 port는 환경값 또는 5432이고 container 안은 5432다.

코드 연결
4~5줄
비유
건물 초인종을 방 안 5432번 전화에 연결한다.
비유의 끝
이미 사용 중인 host port면 충돌한다.

초기 쪽지

database·user·필수 password를 container 환경으로 넘긴다.

코드 연결
6~9줄
비유
빈 방을 열 때 쓸 이름표 세 장을 접수대에 낸다.
비유의 끝
이미 채운 volume에는 초기값이 다시 적용되지 않을 수 있다.

준비 벨과 선반

pg_isready 설정과 data volume mount를 선언한다.

코드 연결
10~19줄
비유
문이 응답하는지 확인하고 기록 상자를 별도 선반에 둔다.
비유의 끝
준비 응답은 query 정답이 아니며 volume 수명은 실행 명령에 달렸다.
왜 먼저 보는가히토리 → 니지카 → 료 → 키타
  1. 히토리

    SQL부터 보면 안 되나요?

  2. 니지카

    SQL이 들어갈 PostgreSQL 방의 버전과 이름부터 같아야 해.

  3. 이 파일은 runtime 계약이고 schema Green은 runner가 따로 판단한다.

  4. 키타

    service가 db 하나인지부터 확인할게요.

두 개의 5432히토리 → 니지카 → 료 → 키타
  1. 히토리

    5432가 두 번이면 DB도 두 개인가요?

  2. 니지카

    왼쪽은 host 문, 오른쪽은 container 안 PostgreSQL 문이야.

  3. :-는 port 변수가 unset 또는 empty일 때 왼쪽 기본값을 고른다.

  4. 키타

    55432를 넣은 경우도 따로 적어 볼게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 18줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.정본 PostgreSQL runtime에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.18 / 18 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F04-L01 services: STARRY 임시 연습실 목록의 맨 표지를 펼친다. Compose 문서에서 services 최상위 mapping을 시작한다.
입력
docker compose가 이 YAML에서 실행할 service 목록을 찾기 시작한다.
결과·효과
YAML parser가 service 정의들을 담을 최상위 객체를 얻는다.
비유의 한계
이 표지만으로 service가 생성되거나 실행되지는 않는다.
2줄F04-L02 db: 목록에 `db`라고 적힌 한 칸짜리 방 열쇠를 건다. services 아래에 이름이 db인 service 정의를 연다.
입력
Compose가 services 안의 첫 이름을 읽어 service 식별자를 정한다.
결과·효과
최종 Compose service 집합에 이름이 db인 항목이 하나 잡힌다.
비유의 한계
db라는 이름은 PostgreSQL 접속 성공이나 데이터 생성을 뜻하지 않는다.
3줄F04-L03 image: postgres:17.10-alpine DB 방의 재료 상자에 PostgreSQL 17.10 Alpine 꼬리표를 붙인다. db container image를 postgres:17.10-alpine으로 지정한다.
입력
Docker가 db container를 만들 때 가져올 image reference를 해석한다.
결과·효과
container 생성 단계에서 postgres:17.10-alpine tag reference가 pull·실행 대상으로 해석된다.
비유의 한계
image tag는 immutable digest가 아니며 host·kernel·운영 부하까지 동일하게 만들지도 않는다.
4줄F04-L04 ports: 건물 밖과 DB 방 안을 잇는 문 번호 묶음을 펼친다. db service의 ports 배열을 시작한다.
입력
Compose가 container port를 host에 공개할 규칙을 받을 준비를 한다.
결과·효과
다음 list item을 port binding 문자열로 해석할 자리가 열린다.
비유의 한계
ports key만 있고 항목이 없으면 실제 port publish 규칙은 생기지 않는다.
5줄F04-L05 - "${FCL_DB_PORT:-5432}:5432" 밖 번호표가 미설정이거나 빈칸이면 5432를 쓰고 안쪽 5432번 문에 연결한다. FCL_DB_PORT가 미설정이거나 빈 문자열이면 기본 5432를, 값이 있으면 그 값을 host port로 써 container 5432에 publish한다.
입력
Compose가 FCL_DB_PORT의 미설정·빈 문자열·비어 있지 않은 값 세 경우에 `:-` 치환을 적용한다.
결과·효과
미설정·빈 문자열은 5432:5432가 되고, 예를 들어 55432는 55432:5432 binding이 된다.
비유의 한계
port 공개는 인증·방화벽·충돌 회피를 보장하지 않으며 이미 쓰는 port면 시작이 실패할 수 있다.
6줄F04-L06 environment: DB가 시작할 때 받을 설정 쪽지 봉투를 연다. db container에 전달할 environment mapping을 시작한다.
입력
PostgreSQL image의 초기화 변수를 묶어 전달하려고 Compose가 environment를 읽는다.
결과·효과
db service 아래에 environment child mapping scope가 열리고 이 줄만으로는 아직 key/value가 하나도 등록되지 않는다.
비유의 한계
environment 표지 자체는 값의 비밀 보관이나 암호화를 수행하지 않는다.
7줄F04-L07 POSTGRES_DB: financial_core 첫 쪽지에 만들 database 이름 `financial_core`를 적는다. POSTGRES_DB 환경값을 financial_core로 설정한다.
입력
새 PostgreSQL data directory가 초기화될 때 기본 database 이름을 전달한다.
결과·효과
새 volume 초기화 시 financial_core database 생성 요청이 image entrypoint에 전달된다.
비유의 한계
이미 초기화된 volume에서는 이 값이 기존 database를 다시 만들지 않을 수 있다.
8줄F04-L08 POSTGRES_USER: app 둘째 쪽지에는 접속 사용자 이름 `app`을 쓴다. POSTGRES_USER 환경값을 app으로 설정한다.
입력
PostgreSQL image가 초기 superuser와 psql 접속 이름으로 app을 받는다.
결과·효과
초기 PostgreSQL 접속 주체 이름이 app으로 준비된다.
비유의 한계
app 이름만으로 최소 권한·role 분리·운영 권한 정책이 완성되지는 않는다.
9줄F04-L09 POSTGRES_PASSWORD: ${FCL_DB_PASSWORD:?set FCL_DB_PASSWORD in this terminal} 셋째 쪽지는 terminal에 없거나 내용이 빈 봉투면 접수대에서 막고, 공백 글자만 든 봉투는 일단 값이 있는 것으로 본다. Compose의 ${FCL_DB_PASSWORD:?message} 보간은 변수가 unset 또는 empty string이면 config error를 내고 whitespace-only nonempty 값은 치환을 통과시킨다.
입력
FCL_DB_PASSWORD가 unset·빈 문자열·공백만·일반 nonempty 중 어느 상태인지 Compose interpolation이 읽는다.
결과·효과
unset/empty이면 지정 message로 config가 실패하고, 공백만을 포함한 nonempty 값까지 POSTGRES_PASSWORD에 그대로 치환된다.
비유의 한계
보간 통과는 password가 실제 인증에 유효하거나 안전한 secret이라는 증명이 아니며 whitespace-only도 별도 거절하지 않는다.
10줄F04-L10 healthcheck: DB service 안에 아직 내용이 없는 건강 점검표 칸을 펼친다. db service 아래에 healthcheck child mapping scope를 시작한다.
입력
앞서 열린 db service mapping과 현재 healthcheck YAML key를 parser가 읽는다.
결과·효과
healthcheck child scope만 열리고 이 줄에서는 test·timing key/value가 아직 하나도 등록되지 않는다.
비유의 한계
scope opener만으로 probe 종류·readiness 결과·인증·schema 상태는 정해지지 않는다.
11줄F04-L11 test: ["CMD-SHELL", "pg_isready -U app -d financial_core"] `app`·`financial_core` 목적지표를 달고 서버가 연결을 받는지만 묻는 초인종을 단다. CMD-SHELL에서 -U app과 -d financial_core를 probe target으로 붙여 pg_isready를 실행한다.
입력
Docker health monitor가 container 안 shell에서 PostgreSQL의 accepting-connections 상태를 확인한다.
결과·효과
server가 connection을 받을 상태면 probe가 exit 0을 내고 Docker health 판정에 사용된다.
비유의 한계
pg_isready 성공은 app 인증·financial_core 존재·SQL query·index·성능 성공을 보장하지 않는다.
12줄F04-L12 interval: 2s 준비 확인 벨이 2초마다 다시 울리도록 간격을 맞춘다. healthcheck 실행 간격을 2초로 지정한다.
입력
한 번의 health test가 끝난 뒤 다음 점검까지 Docker가 2초 주기를 사용한다.
결과·효과
health test 재호출 주기가 2초로 scheduling된다.
비유의 한계
2초 간격은 전체 startup 최대시간이나 SQL statement 실행 제한을 정하지 않는다.
13줄F04-L13 timeout: 2s 한 번 문을 두드리고 기다릴 시간은 2초로 자른다. 각 healthcheck 시도의 timeout을 2초로 지정한다.
입력
pg_isready 한 번이 2초 안에 끝나지 않을 때 해당 시도를 실패로 처리한다.
결과·효과
한 시도가 2초를 넘기면 해당 health result가 실패로 기록된다.
비유의 한계
health timeout은 psql query나 runner phase의 실행 제한이 아니다.
14줄F04-L14 retries: 30 준비 확인이 30번 연속 실패하면 `unhealthy` 표를 붙이는 기준을 둔다. 연속 healthcheck 실패 30회를 unhealthy 판정 임계값으로 지정한다.
입력
Docker가 연속 실패 수가 30에 닿으면 service를 unhealthy로 표시하고 이후에도 healthcheck를 계속 실행한다.
결과·효과
연속 실패 허용 횟수가 30으로 설정되어 그 뒤 unhealthy 판정 근거가 된다.
비유의 한계
retries=30은 전체 점검을 30회로 끝내는 상한이나 정확한 60초 startup 보장이 아니다.
15줄F04-L15 volumes: DB 방 바닥과 기록 창고를 이을 끈 목록을 펼친다. db service에 연결할 volumes 배열을 시작한다.
입력
Compose가 이 service의 filesystem mount 항목을 받을 준비를 한다.
결과·효과
다음 list item이 db container의 mount 규칙으로 처리된다.
비유의 한계
volumes key만으로 backup·replication·삭제 방지가 생기지 않는다.
16줄F04-L16 - financial-core-db:/var/lib/postgresql/data `financial-core-db` 창고를 PostgreSQL 자료 선반에 바로 잇는다. named volume financial-core-db를 /var/lib/postgresql/data에 mount한다.
입력
PostgreSQL이 쓰는 data directory를 container layer 대신 named volume에 연결한다.
결과·효과
PostgreSQL data files가 financial-core-db resource 쪽에 기록된다.
비유의 한계
named volume도 immutable backup이 아니며 실제 보존·삭제는 이후 Compose lifecycle 명령에 달려 있다.
18줄F04-L18 volumes: 서비스 밖에서도 부를 공용 기록 창고 명부를 연다. 문서 최상위 volumes mapping을 시작한다.
입력
Compose가 service mount에서 참조한 named volume 정의를 찾는다.
결과·효과
service 밖에서 참조할 named volume registry가 YAML 객체에 생긴다.
비유의 한계
최상위 선언은 volume 내용·용량·backup 정책을 지정하지 않는다.
19줄F04-L19 financial-core-db: 명부에 `financial-core-db` 창고 이름만 등록한다. financial-core-db named volume을 기본 옵션으로 선언한다.
입력
Compose project가 사용할 volume resource를 기본 driver 설정으로 준비한다.
결과·효과
Compose가 project 범위의 financial-core-db resource를 생성·재사용할 수 있다.
비유의 한계
기본 옵션 선언은 volume 내용·용량·backup·수명 정책을 고정하지 않는다.
필수 password히토리 → 니지카 → 료 → 키타
  1. 히토리

    실험용이면 password가 비어도 켜지나요?

  2. 니지카

    물음표 보간식이 config 단계에서 빈 값을 거절해.

  3. 존재 검사는 secret storage가 아니라 fail-fast 설정이다.

  4. 키타

    값이 있는 경우와 없는 경우를 나눠 보겠습니다.

05

STEP 05 / 13

원본 코드 조각

원본을 5개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.

F04-C01 · database service and image1–3줄
1–3줄 원본
services:
  db:
    image: postgres:17.10-alpine
F04-C02 · host port mapping4–5줄
4–5줄 원본
    ports:
      - "${FCL_DB_PORT:-5432}:5432"
F04-C03 · database credentials6–9줄
6–9줄 원본
    environment:
      POSTGRES_DB: financial_core
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${FCL_DB_PASSWORD:?set FCL_DB_PASSWORD in this terminal}
F04-C04 · readiness healthcheck10–14줄
10–14줄 원본
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d financial_core"]
      interval: 2s
      timeout: 2s
      retries: 30
F04-C05 · persistent named volume15–19줄
15–19줄 원본
    volumes:
      - financial-core-db:/var/lib/postgresql/data

volumes:
  financial-core-db:
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 18줄을 모두 한국어로 옮깁니다.

전체 번역 18 / 18

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1services:Compose 문서에서 services 최상위 mapping을 시작한다.
2 db:services 아래에 이름이 db인 service 정의를 연다.
3 image: postgres:17.10-alpinedb container image를 postgres:17.10-alpine으로 지정한다.
4 ports:db service의 ports 배열을 시작한다.
5 - "${FCL_DB_PORT:-5432}:5432"FCL_DB_PORT가 미설정이거나 빈 문자열이면 기본 5432를, 값이 있으면 그 값을 host port로 써 container 5432에 publish한다.
6 environment:db container에 전달할 environment mapping을 시작한다.
7 POSTGRES_DB: financial_corePOSTGRES_DB 환경값을 financial_core로 설정한다.
8 POSTGRES_USER: appPOSTGRES_USER 환경값을 app으로 설정한다.
9 POSTGRES_PASSWORD: ${FCL_DB_PASSWORD:?set FCL_DB_PASSWORD in this terminal}Compose의 ${FCL_DB_PASSWORD:?message} 보간은 변수가 unset 또는 empty string이면 config error를 내고 whitespace-only nonempty 값은 치환을 통과시킨다.
10 healthcheck:db service 아래에 healthcheck child mapping scope를 시작한다.
11 test: ["CMD-SHELL", "pg_isready -U app -d financial_core"]CMD-SHELL에서 -U app과 -d financial_core를 probe target으로 붙여 pg_isready를 실행한다.
12 interval: 2shealthcheck 실행 간격을 2초로 지정한다.
13 timeout: 2s각 healthcheck 시도의 timeout을 2초로 지정한다.
14 retries: 30연속 healthcheck 실패 30회를 unhealthy 판정 임계값으로 지정한다.
15 volumes:db service에 연결할 volumes 배열을 시작한다.
16 - financial-core-db:/var/lib/postgresql/datanamed volume financial-core-db를 /var/lib/postgresql/data에 mount한다.
18volumes:문서 최상위 volumes mapping을 시작한다.
19 financial-core-db:financial-core-db named volume을 기본 옵션으로 선언한다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

Compose가 db service에 PostgreSQL image·port·init 환경·healthcheck·named volume을 적용한다.

문법 해부

  • YAML 들여쓰기가 최상위 services와 db 속성의 소속을 정한다.
  • ${VAR:-default}는 unset 또는 empty에 default를 쓰고 ${VAR:?message}는 값을 요구한다.
  • host:container와 volume:container-path의 콜론 양쪽 역할은 다르다.

실행 순서

  1. YAML parse
  2. environment interpolation
  3. container configuration
  4. PostgreSQL initialization when data directory is empty
  5. health monitoring
  6. volume-backed writes

원래 W6 수준의 조각별 정밀 해설

F04-C01 · database service and image
문법 해부
YAML top-level services 아래 db service를 열고 image를 postgres:17.10-alpine으로 지정한다.
실제 값 추적
Compose model에는 db라는 service와 tag로 식별된 PostgreSQL image reference가 생긴다.
정상 예
올바른 들여쓰기에서 docker compose config가 services.db.image를 문자열로 읽는 상태가 유효하다.
틀린 예·반례
db를 services와 같은 높이에 두면 service mapping이 아니며 compose schema 해석이 실패하거나 달라진다.
착각 방지
17.10-alpine tag는 immutable digest가 아니므로 같은 문자열이 image bytes를 영구 고정하지 않는다.
하지 않는 일
host port·credential·health·volume은 이 세 줄이 아직 선언하지 않는다.
다음 연결
다음 ‘host port mapping’ 범위가 FCL_DB_PORT 기본값과 container 5432 연결을 추가한다.
F04-C02 · host port mapping
문법 해부
ports 배열 한 항목이 host `${FCL_DB_PORT:-5432}`를 container 5432에 publish한다.
실제 값 추적
FCL_DB_PORT가 unset 또는 empty면 host 5432, 값이 55432면 host 55432가 PostgreSQL 5432로 연결된다.
정상 예
다른 local PostgreSQL을 피해 FCL_DB_PORT=55432를 주는 것은 이 interpolation의 정상 사용 예다.
틀린 예·반례
선택한 host port가 이미 점유돼 있으면 YAML이 맞아도 container publish는 성공하지 못한다.
착각 방지
`:-5432`는 unset뿐 아니라 empty에도 기본값을 쓰며 container 내부 port는 바꾸지 않는다.
하지 않는 일
network 방화벽·외부 접근 정책·DB 인증은 이 mapping의 책임이 아니다.
다음 연결
다음 ‘database credentials’ 범위가 database 이름과 user, 필수 password 환경값을 entrypoint에 넘긴다.
F04-C03 · database credentials
문법 해부
environment mapping이 POSTGRES_DB=financial_core, POSTGRES_USER=app, 필수 FCL_DB_PASSWORD 값을 container에 전달한다.
실제 값 추적
빈 PostgreSQL 저장 directory의 entrypoint는 financial_core DB와 app role을 초기화하며 password 문자열은 Compose interpolation source에서 얻는다.
정상 예
비어 있는 PostgreSQL storage와 nonempty FCL_DB_PASSWORD로 첫 초기화를 하는 경우가 세 environment 값의 정상 경로다.
틀린 예·반례
password 변수가 unset 또는 empty면 `${...:?...}`가 Compose interpolation 단계에서 명시 오류를 낸다.
착각 방지
whitespace-only password는 nonempty라 interpolation을 통과하며, 이미 초기화된 저장 영역은 환경값 변경만으로 role·DB가 재생성되지 않는다.
하지 않는 일
secret storage·rotation·application credential 일치는 이 environment block 밖의 책임이다.
다음 연결
다음 ‘readiness healthcheck’ 범위가 app과 financial_core를 인자로 pg_isready probe를 구성한다.
F04-C04 · readiness healthcheck
문법 해부
CMD-SHELL healthcheck가 pg_isready -U app -d financial_core를 실행하고 interval 2s, timeout 2s, retries 30을 적용한다.
실제 값 추적
각 probe는 최대 2초 동안 server connection status를 받고 연속 실패가 30회면 container가 unhealthy로 판정된다.
정상 예
server가 accepting-connections 상태로 exit 0을 주면 healthy가 되어 Compose wait 조건을 만족할 수 있다.
틀린 예·반례
30 retries를 container 수명 동안 probe가 정확히 30번만 실행된다는 뜻으로 읽으면 틀리다.
착각 방지
pg_isready 성공은 password 인증, schema apply, application query Green을 직접 증명하지 않는다.
하지 않는 일
Docker engine 가용성·fixture seed·cleanup은 이 probe가 다루지 않는다.
다음 연결
다음 ‘persistent named volume’ 범위가 PostgreSQL data directory mount와 top-level volume 이름을 잇는다.
F04-C05 · persistent named volume
문법 해부
db service가 financial-core-db를 /var/lib/postgresql/data에 mount하고 top-level volumes mapping이 같은 이름을 선언한다.
실제 값 추적
PostgreSQL cluster 파일은 Compose project 이름이 붙은 named volume에 저장돼 container 교체와 분리된다.
정상 예
같은 project에서 db를 restart할 때 선언된 volume을 다시 붙이는 흐름이 유효하다.
틀린 예·반례
`down`만 실행하고 -v를 빼면 이전 cluster와 seed가 다음 실행에 남을 수 있다.
착각 방지
volume 선언은 누가 소유했는지나 언제 삭제할지 스스로 결정하지 않는다.
하지 않는 일
backup·encryption·production durability·free disk 감시는 이 YAML 조각 책임이 아니다.
다음 연결
이 file의 끝이다. W15 runner Compose branch가 소유한 project만 finally에서 down -v로 정리한다.
healthy의 작은 범위히토리 → 니지카 → 료 → 키타
  1. 히토리

    healthy면 V001과 key도 맞았나요?

  2. 니지카

    아니, 여기서는 server가 연결을 받을 상태인지 본 거야.

  3. 인증·schema·index·성능은 pg_isready exit가 직접 증명하지 않는다.

  4. 키타

    readiness와 runner gate를 다른 칸에 적을게요.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
1FCL_DB_PORT unset, FCL_DB_PASSWORD=lab-secret두 보간식을 평가한다.5432:5432와 password 환경값이 config에 들어간다.password가 없거나 빈 값이면 config 단계에서 실패한다.
2빈 financial-core-db volumePostgreSQL image entrypoint가 init 환경을 읽는다.financial_core database와 app user를 만들 초기화 요청이 수행된다.기존 data directory에는 같은 init 과정이 다시 적용되지 않을 수 있다.
3server process는 떴지만 아직 connection을 받지 않는 상태2초 간격·시도당 2초 timeout으로 pg_isready를 반복한다.accepting 상태 응답이 Docker health 판정에 반영된다.retries=30은 정확한 60초 상한이나 총 검사 횟수가 아니다.
4/var/lib/postgresql/data에 기록할 pagenamed volume mount로 보낸다.container writable layer 밖 volume에 DB data가 놓인다.W15 Compose owner가 down -v를 실행하면 이 disposable volume 제거를 요청한다.
volume의 수명히토리 → 니지카 → 료 → 키타
  1. 히토리

    named volume이면 실험 결과가 계속 남죠?

  2. 니지카

    Compose owner가 down -v를 부르면 제거 요청이 간다.

  3. mount 선언은 backup이나 영구 보존 계약이 아니다.

  4. 키타

    실행 mode와 cleanup flag를 함께 확인하겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

Compose parser

YAML mapping/list와 환경 보간을 service model로 만든다.

parse 성공은 Docker engine 사용 가능성을 보장하지 않는다.
Docker engine

image·port·environment·mount·healthcheck를 container에 적용한다.

host port와 volume lifecycle은 project 이름과 명령에도 좌우된다.
PostgreSQL image

빈 data directory에서 POSTGRES_* 초기값으로 cluster를 준비한다.

기존 volume의 schema를 자동 교정하지 않는다.
Health monitor

pg_isready exit를 주기적으로 읽어 starting/healthy/unhealthy를 갱신한다.

V001·seed·key·index·latency는 검사하지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ healthy면 app 인증과 schema query도 Green이다.

왜 틀리나 pg_isready는 server가 연결 요청을 받는 상태인지 묻는 readiness 도구다.

바르게 읽기 server readiness와 인증·V001·SQL 결과를 별도 gate로 본다.

반례 index가 없거나 schema가 비어도 server는 accepting 상태일 수 있다.

❌ postgres:17.10-alpine이면 image bytes가 영원히 같다.

왜 틀리나 tag는 registry에서 가리키는 manifest가 바뀔 수 있고 digest가 아니다.

바르게 읽기 재현에 immutable image가 필요하면 digest를 별도로 고정한다.

반례 같은 tag를 나중에 pull했을 때 다른 image digest를 받을 수 있다.

❌ retries=30이면 healthcheck가 정확히 30번 뒤 영원히 멈춘다.

왜 틀리나 값은 연속 실패를 unhealthy로 바꾸는 임계이며 이후 monitoring은 계속될 수 있다.

바르게 읽기 interval·timeout·retries를 startup 절대시간과 분리한다.

반례 성공이 끼면 연속 실패 수가 달라지고 scheduling overhead도 있다.

❌ 필수 password 보간식이 secret vault 역할까지 한다.

왜 틀리나 보간식은 값 존재를 확인해 container 환경으로 넘길 뿐이다.

바르게 읽기 실험용 fail-fast와 운영 secret 관리를 나눈다.

반례 process environment나 config 취급을 통해 값이 노출될 수 있다.

❌ named volume은 runner가 끝나도 반드시 보존된다.

왜 틀리나 W15 Compose mode는 소유 project를 down -v로 정리한다.

바르게 읽기 이 volume을 disposable schema 실험 저장소로 읽는다.

반례 Container mode와 Compose mode는 소유권·정리 경계가 다르다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

readiness

app 인증·database 존재·V001 적용·query 성공

이 책임을 맡는 곳: runner psql and schema gates
image integrity

immutable PostgreSQL image bytes

이 책임을 맡는 곳: digest-pinned deployment manifest
schema truth

PK·FK·UNIQUE·index·cardinality 결과

이 책임을 맡는 곳: run-w15-schema.ps1 discovery and checks
data durability

backup·restore·HA·volume 영구 보존

이 책임을 맡는 곳: operations and backup design
12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

db service→image→port→세 환경값→healthcheck→mount→top-level volume 순서로 말한다.

2단계 · 코드 조각 재조립

  1. services/db/image/ports
  2. POSTGRES_DB/USER/PASSWORD
  3. pg_isready/2s/2s/30
  4. service mount/top-level named volume

3단계 · 파일 전체 다시 쓰기

blank 한 줄을 포함한 19개 물리 줄을 다시 쓰고 canonical SHA-256과 대조한다.

자가 점검
  • image tag와 service 이름을 확인한다.
  • 두 port의 방향을 바꾸지 않는다.
  • password 보간식의 물음표를 유지한다.
  • pg_isready를 인증·schema proof라고 쓰지 않는다.
  • volume 수명을 실행 mode와 분리한다.
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourceruntime/compose.yamlSHA-256 3ef6ca229dfa8d54120fb66f2215b906ba8155784d32791d7f93f3be879ade8d
compose.yaml — W15 schema 실험용 PostgreSQL 방 전체
services:
  db:
    image: postgres:17.10-alpine
    ports:
      - "${FCL_DB_PORT:-5432}:5432"
    environment:
      POSTGRES_DB: financial_core
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${FCL_DB_PASSWORD:?set FCL_DB_PASSWORD in this terminal}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d financial_core"]
      interval: 2s
      timeout: 2s
      retries: 30
    volumes:
      - financial-core-db:/var/lib/postgresql/data

volumes:
  financial-core-db:
05

run-w15-schema.ps1 — targeted tests와 격리 schema 관찰 owner

scripts/run-w15-schema.ps1

정본 W15 schema 실행기 · 정본 · W15-F05
55줄 연결55줄 번역9 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

두 staged selector의 XML Green과 w15_schema의 constraint·index·두-account cardinality 관찰을 한 owner 흐름으로 결속한다.

  1. 왜 bare packaged root는 그대로 Green이라고 할 수 없을까?
  2. key gate는 exact인가 minimum인가?
  3. index count는 조건인가 기록인가?
  4. evidence file 존재만으로 Green일까?
  5. Container mode가 supplied container에 무엇을 남길까?
이 파일에서 끝까지 다시 쓰는 값selectors=2tests>=2failures/errors/skipped=0schema=w15_schemacardinality=1,2|2,0PK>=4FK>=2UNIQUE>=2indexes=record-onlymarker=W15_SCHEMA_GREEN
02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

STARRY가 두 시험표와 새 schema 관찰표를 한 검수대에 올리되 각 표가 증명하는 범위를 따로 적는다.

열쇠·관계 검수대를 한 번에 돌리기

먼저 두 exact JUnit class의 XML class 집합과 합계가 Green인지 확인한다.

그다음 전용 w15_schema를 만들고 key·index·두 account의 ledger 수를 관찰해 evidence와 marker를 남긴다.

딱 여기까지만 이 검수대는 source SHA·ERD 의미·3NF/BCNF·full suite·production 성능을 직접 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

두 시험표

두 class selector와 XML aggregate를 exact set/zero failure로 검사한다.

코드 연결
10~17줄
비유
허용 명단 두 이름과 결과 봉투 이름을 맞춘다.
비유의 끝
bare root의 active source 존재와 hash는 별도다.

두 실행 통로

Compose mode는 project를 소유하고 Container mode는 supplied container를 검사한다.

코드 연결
18~34줄
비유
새 방을 빌리는 통로와 남의 전용 방을 받는 통로를 나눈다.
비유의 끝
Container mode도 w15_schema를 교체한다.

격리 schema

V001과 fixed seed를 w15_schema에 적용한다.

코드 연결
35~39줄
비유
기존 W15 서랍을 비우고 같은 이름으로 새 서랍을 조립한다.
비유의 끝
명시적 transaction이나 source hash check가 없다.

관찰과 gate

constraint/index/cardinality를 읽고 최소치·exact fixture 값을 구분한다.

코드 연결
40~51줄
비유
열쇠 표, index 표, 두 account 영수증 수를 따로 센다.
비유의 끝
index count는 기록만 하고 key counts는 lower bound다.

정리 소유권

Compose-owned project만 down -v하고 location을 되돌린다.

코드 연결
52~55줄
비유
빌린 방만 창고까지 반납하고 supplied room은 남긴다.
비유의 끝
cleanup native failure와 Container schema 잔존이 가능하다.
어느 root를 읽는가히토리 → 니지카 → 료 → 키타
  1. 히토리

    ProjectRoot를 주면 모든 dependency도 거기서 찾나요?

  2. 니지카

    test와 V001 기준 root는 바뀌지만 Compose file은 packaged referenceRoot를 써.

  3. evidence path는 rooted 여부를 나눠 full path로 만들고 source hash는 runner가 확인하지 않는다.

  4. 키타

    root·compose·evidence 세 주소를 따로 표시할게요.

두 selector의 실제 범위히토리 → 니지카 → 료 → 키타
  1. 히토리

    tests가 둘 이상이면 bare package도 바로 Green인가요?

  2. 니지카

    기본 active src/test에는 두 exact class가 없어서 stage 조립이 먼저 필요해.

  3. XML set equality와 aggregate tests>=2는 source SHA나 class별 한 개 조건까지 증명하지 않는다.

  4. 키타

    selector file 위치와 XML class를 함께 대조하겠습니다.

부분 evidence히토리 → 니지카 → 료 → 키타
  1. 히토리

    keys.txt가 있으면 그 run은 Green이었나요?

  2. 니지카

    세 파일은 minimum-key check보다 먼저 따로 쓰여.

  3. 후속 실패나 중간 write 실패가 있으면 일부 파일만 남을 수 있어 marker와 exit를 같이 봐야 한다.

  4. 키타

    file presence만으로 합격 처리하지 않겠습니다.

Green marker의 한계히토리 → 니지카 → 료 → 키타
  1. 히토리

    W15_SCHEMA_GREEN이면 ERD와 3NF도 증명됐나요?

  2. 니지카

    marker는 targeted tests와 isolated schema의 정해진 gate만 요약해.

  3. ERD 의미·formal normalization·full suite·production 성능은 다른 검토 책임이다.

  4. 키타

    직접 보장과 보장하지 않음을 두 칸으로 답할게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 55줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.정본 W15 schema 실행기에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.55 / 55 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F05-L01 param([ValidateSet('Compose','Container')][string]$Mode='Compose',[string]$Container='',[string]$ComposeProject='w15-schema-lab',[string]$EvidenceDir='evidence/w15',[string]$ProjectRoot='') 두 실행 mode와 증거 주소를 한 번에 고르는 W15 조작판을 펼친다. Compose/Container mode, container, project, evidence, project-root parameters와 defaults를 선언한다.
입력
호출자가 named argument를 주거나 선언된 default를 사용한다.
결과·효과
PowerShell binder가 다섯 parameter 값을 script 시작 scope에 배치한다.
비유의 한계
ValidateSet은 Mode 두 값만 제한하며 나머지 문자열의 존재·안전·절대경로는 아직 확인하지 않는다.
2줄F05-L02 $ErrorActionPreference='Stop' 작은 오류도 빨간 경보로 올리도록 스위치를 Stop에 둔다. $ErrorActionPreference를 Stop으로 설정해 PowerShell non-terminating error를 terminating error로 승격한다.
입력
새 script scope의 기본 error preference를 받는다.
결과·효과
이후 PowerShell cmdlet error가 catch 없는 흐름을 중단할 preference가 활성화된다.
비유의 한계
native executable의 nonzero exit는 자동 예외가 아니어서 LASTEXITCODE 검사가 여전히 필요하다.
3줄F05-L03 $referenceRoot=Split-Path -Parent $PSScriptRoot runner 파일이 든 scripts 서랍의 바로 위를 기준 책상으로 가리킨다. $PSScriptRoot의 parent를 계산해 packaged reference root 후보로 저장한다.
입력
현재 실행 중인 script directory path를 사용한다.
결과·효과
$referenceRoot가 scripts directory의 부모 path 문자열을 갖는다.
비유의 한계
부모 계산은 그 경로가 완전한 learner root인지나 selector source가 있는지 검사하지 않는다.
4줄F05-L04 $root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot} 호출자가 준 캠프가 있으면 찾아가고 없으면 기준 책상에 남는다. ProjectRoot가 truthy면 Resolve-Path 결과를, 아니면 referenceRoot를 $root로 선택한다.
입력
빈 값일 수 있는 ProjectRoot와 앞서 계산한 referenceRoot가 들어온다.
결과·효과
$root가 custom resolved path 또는 packaged default path 중 하나로 확정된다.
비유의 한계
custom path 존재만 Resolve-Path가 확인하며 필요한 Gradle·source 파일 구성까지 보장하지 않는다.
5줄F05-L05 $e=if([IO.Path]::IsPathRooted($EvidenceDir)){[IO.Path]::GetFullPath($EvidenceDir)}else{[IO.Path]::GetFullPath((Join-Path $root $EvidenceDir))} 증거 상자 주소가 이미 완전하면 그대로 다듬고 상대 주소면 캠프 아래에 붙인다. EvidenceDir의 rooted 여부에 따라 직접 또는 $root와 결합한 뒤 GetFullPath로 $e를 만든다.
입력
EvidenceDir 문자열과 선택된 $root path를 받는다.
결과·효과
$e가 evidence output에 사용할 정규화된 full path 문자열이 된다.
비유의 한계
full path 계산은 해당 위치가 허용된 보안 경계인지나 기존 파일이 안전한지 판정하지 않는다.
6줄F05-L06 New-Item -ItemType Directory -Force $e|Out-Null 증거 상자가 없으면 만들고 이미 있으면 같은 상자를 계속 쓴다. New-Item Directory -Force로 $e directory를 만들고 pipeline output을 버린다.
입력
계산된 evidence full path와 filesystem 권한을 사용한다.
결과·효과
성공하면 evidence directory가 존재하고 console에는 생성 object가 출력되지 않는다.
비유의 한계
기존 파일 내용은 지우지 않으며 여러 evidence file을 원자적으로 교체하는 동작도 없다.
7줄F05-L07 Push-Location $root 작업자가 선택된 project 방 안으로 발을 옮긴다. Push-Location으로 current location을 $root에 쌓아 전환한다.
입력
존재하는 root directory와 현재 location stack을 받는다.
결과·효과
상대 명령의 기준 directory가 $root로 바뀌고 이전 위치가 stack에 보관된다.
비유의 한계
이 이동은 source hash나 build 결과를 검증하지 않으며 실패하면 뒤 statement들의 실행도 이어지지 않을 수 있다.
8줄F05-L08 $ownedCompose=$false Compose 방을 아직 내가 열지 않았다는 소유권 불을 끈 상태로 둔다. $ownedCompose를 false로 초기화한다.
입력
새 boolean variable slot을 사용한다.
결과·효과
cleanup 소유권 flag가 false인 명시적 초기 상태를 가진다.
비유의 한계
false는 container나 schema가 없다는 뜻이 아니라 이 script가 Compose project를 소유했다는 기록이 없다는 뜻이다.
9줄F05-L09 try{ 검사 구간을 감쌀 안전 봉투의 앞면을 연다. try keyword가 protected statement scope의 시작을 선언한다.
입력
location 전환과 false ownership 상태가 준비돼 있다.
결과·효과
PowerShell parser가 보호할 statement scope를 열지만 내부 명령은 아직 실행하지 않는다.
비유의 한계
이 opener 자체는 내부 statement·실패 발생 여부·외부 observable을 실행하거나 결정하지 않는다.
10줄F05-L10 & .\gradlew.bat test --tests 'com.example.financialcore.CoreSchemaIT' --tests 'com.example.financialcore.account.OpeningIntegrationTest' --no-daemon CoreSchema와 Opening 두 장의 시험표만 Gradle 접수대에 낸다. Gradle test task를 두 exact class selector와 --no-daemon으로 실행한다.
입력
$root의 gradlew.bat, build 구성, 두 selector 이름을 native process에 넘긴다.
결과·효과
Gradle native process가 선택된 class tests를 실행하고 종료 code를 LASTEXITCODE에 남긴다.
비유의 한계
class selector는 발견된 method를 실행할 뿐 source SHA를 확인하지 않으며 bare packaged root에는 두 class가 active source로 없다.
11줄F05-L11 if($LASTEXITCODE-ne 0){throw "W15 Gradle exit=$LASTEXITCODE"} Gradle 접수 결과가 0이 아니면 즉시 실패 종을 울린다. LASTEXITCODE가 nonzero이면 exit 값을 담은 W15 Gradle 예외를 던진다.
입력
직전 native Gradle process의 integer exit code를 읽는다.
결과·효과
0이면 이 조건문이 아무 예외 없이 끝나고 nonzero이면 현재 try 흐름이 중단된다.
비유의 한계
exit 0은 selected execution의 상세 산출물·class 구성·assertion 내용을 이 줄에서 판정하지 않는다.
12줄F05-L12 $expected=@('com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest') 합격 명단에 허용할 두 class 이름을 정확히 적는다. $expected array에 CoreSchemaIT와 OpeningIntegrationTest FQCN 두 개를 저장한다.
입력
source에 적힌 두 literal class name을 평가한다.
결과·효과
비교 기준으로 쓸 길이 2의 expected selector set이 memory에 생긴다.
비유의 한계
이 명단은 class file 존재·source bytes·각 class의 method 수를 검증하지 않는다.
13줄F05-L13 $rows=@(Get-ChildItem build/test-results/test -Filter 'TEST-*.xml'|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}}) 시험 결과 봉투들을 열어 class와 네 개 숫자를 한 줄 표로 옮긴다. TEST-*.xml 각각을 XML로 읽어 suite name/tests/failures/errors/skipped object array를 만든다.
입력
build/test-results/test 아래 matching files와 XML testsuite attributes를 받는다.
결과·효과
$rows에 발견된 XML별 다섯 field가 정수 변환된 object로 모인다.
비유의 한계
directory를 먼저 청소하지 않고 XML semantic validity·source origin hash도 별도로 확인하지 않는다.
14줄F05-L14 $classes=@($rows.class|Sort-Object -Unique) 결과 표의 class 이름만 뽑아 중복을 없애고 사전순으로 놓는다. $rows.class를 Sort-Object -Unique로 정규화해 $classes array를 만든다.
입력
XML parsing에서 얻은 rows와 그 class field를 사용한다.
결과·효과
$classes가 실제 발견된 distinct suite name의 sorted set이 된다.
비유의 한계
중복 XML suite 수와 class별 test 분포 정보는 이 unique 목록에서 사라진다.
15줄F05-L15 if(Compare-Object ($expected|Sort-Object) $classes){throw "W15 XML classes mismatch expected=$expected actual=$classes"} 허용 명단과 실제 명단의 양쪽 차이를 한 글자도 허용하지 않는다. sorted expected set과 actual classes를 Compare-Object하고 차이가 있으면 mismatch 예외를 던진다.
입력
두 expected FQCN과 distinct actual suite-name set을 받는다.
결과·효과
두 집합이 같으면 이 조건문이 조용히 끝나고 extra·missing class가 있으면 exception이 발생한다.
비유의 한계
set equality는 class source SHA나 class마다 test가 한 개 이상이라는 조건을 따로 증명하지 않는다.
16줄F05-L16 $tests=($rows|Measure-Object tests -Sum).Sum;$failures=($rows|Measure-Object failures -Sum).Sum;$errors=($rows|Measure-Object errors -Sum).Sum;$skipped=($rows|Measure-Object skipped -Sum).Sum 모든 결과 봉투의 시험·실패·오류·건너뜀 숫자를 각 통에 합친다. rows의 tests/failures/errors/skipped field를 Measure-Object -Sum으로 각각 집계한다.
입력
class set이 일치한 XML row collection을 사용한다.
결과·효과
네 aggregate counter variable이 XML attribute 합계로 채워진다.
비유의 한계
합계는 class별 분포를 보존하지 않고 XML이 실제 source와 같은 bytes에서 왔는지도 말하지 않는다.
17줄F05-L17 if($tests-lt 2-or$failures-ne 0-or$errors-ne 0-or$skipped-ne 0){throw 'W15 XML not Green'} 시험은 둘 이상, 나쁜 숫자는 모두 0이라는 문턱을 세운다. tests<2 또는 failures/errors/skipped가 0이 아니면 W15 XML not Green 예외를 던진다.
입력
직전 단계에서 계산한 네 aggregate counter를 비교한다.
결과·효과
네 조건이 맞으면 현재 gate가 예외 없이 끝나고 하나라도 어기면 실행이 중단된다.
비유의 한계
tests>=2는 각 expected class에 한 test씩 있음을 직접 요구하지 않으며 constraint 의미도 증명하지 않는다.
18줄F05-L18 if($Mode-eq'Compose'){ Mode 표지가 Compose일 때만 들어가는 왼쪽 통로를 연다. $Mode가 대소문자 비민감 eq Compose이면 실행할 branch scope를 시작한다.
입력
ValidateSet을 통과한 Mode 값과 현재 script state를 받는다.
결과·효과
Compose인 경우 branch 내부로 control이 들어가고 아니면 대체 branch 선택까지 보류된다.
비유의 한계
branch opener 자체는 container를 만들거나 소유권 flag를 바꾸지 않는다.
19줄F05-L19 if($Container){throw 'W15 Compose mode does not accept -Container'} Compose 통로에서 별도 container 이름까지 들고 오면 입구에서 거절한다. Compose mode인데 Container 문자열이 truthy이면 예외를 던진다.
입력
현재 Mode branch와 caller-supplied Container value를 읽는다.
결과·효과
Container가 falsey이면 이 guard가 조용히 끝나고 truthy이면 즉시 exception이 발생한다.
비유의 한계
truthy 검사는 문자열을 trim하지 않으므로 whitespace-only 값까지 명시적으로 거르는 guard는 아니다.
20줄F05-L20 $composeFile=Join-Path $referenceRoot 'compose.yaml' 기준 project 옆 compose.yaml 주소표를 만든다. referenceRoot와 compose.yaml을 Join-Path해 $composeFile에 저장한다.
입력
script 위치에서 계산한 packaged referenceRoot를 사용한다.
결과·효과
$composeFile이 packaged Compose definition 후보 path를 갖는다.
비유의 한계
custom ProjectRoot를 골랐어도 Compose file은 referenceRoot 쪽을 가리키며 아직 존재 여부는 확인하지 않는다.
21줄F05-L21 if(!(Test-Path -LiteralPath $composeFile -PathType Leaf)){throw 'W15 compose.yaml missing'} 주소표가 실제 일반 파일을 가리키는지 문 앞에서 확인한다. Test-Path Leaf가 false이면 W15 compose.yaml missing 예외를 던진다.
입력
앞서 만든 composeFile path와 filesystem metadata를 읽는다.
결과·효과
일반 파일이면 guard가 예외 없이 끝나고 missing 또는 directory이면 실행이 중단된다.
비유의 한계
존재 검사는 compose YAML parse 성공·hash·image digest를 검증하지 않는다.
22줄F05-L22 if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w15-disposable-only-password'} 비밀번호 쪽지가 비었을 때만 폐기용 실험 암호를 채운다. FCL_DB_PASSWORD가 null/empty/whitespace이면 process environment에 disposable default를 설정한다.
입력
현재 process의 FCL_DB_PASSWORD 문자열을 검사한다.
결과·효과
빈 값이면 w15-disposable-only-password가 이후 child process 환경에 남고 값이 있으면 보존된다.
비유의 한계
실험 편의 default는 secret storage나 production credential 정책이 아니다.
23줄F05-L23 # Own this unique project before startup so partial Compose creation is 시동 전에 unique project를 소유할 이유를 적는 설명표의 첫 절반만 붙인다. startup 전 project ownership과 partial Compose creation을 언급하다 `is`에서 끝나는 미완성 comment fragment다.
입력
PowerShell parser가 현재 # 뒤의 첫 comment fragment만 읽는다.
결과·효과
runtime state는 바뀌지 않고 source에는 startup 전 ownership 이유 문장의 앞부분만 남는다.
비유의 한계
이 줄은 문장을 완결하지 않으며 partial creation에 무엇을 할지나 후속 control keyword를 아직 말하지 않는다.
24줄F05-L24 # removed by finally even when `up --wait` fails. up --wait 실패까지 정리 대상이라는 설명표의 뒷 절반을 붙인다. finally가 failed startup의 부분 resource도 제거한다는 comment를 완성한다.
입력
앞 comment에서 이어진 자연어 설명만 읽는다.
결과·효과
실행 가능한 statement 없이 cleanup 설계 이유가 source에 기록된다.
비유의 한계
comment의 약속은 실제 ownership flag와 finally command가 올바르게 실행될 때만 구현된다.
25줄F05-L25 $ownedCompose=$true 이 Compose project는 내가 치워야 한다는 소유권 불을 켠다. $ownedCompose를 true로 바꾼다.
입력
false로 초기화된 cleanup flag와 Compose branch를 사용한다.
결과·효과
$ownedCompose가 true인 현재-line state로 바뀐다.
비유의 한계
true 설정만으로 project나 container가 이미 생성됐다는 뜻은 아니다.
26줄F05-L26 & docker compose -f $composeFile -p $ComposeProject up -d --wait db 지정 compose·project로 db를 background 시동하고 준비까지 기다린다. docker compose -f composeFile -p ComposeProject up -d --wait db를 native 실행한다.
입력
존재하는 compose file, project name, environment password, Docker engine을 받는다.
결과·효과
Compose가 db service 생성·시작·health wait를 시도하고 native exit를 남긴다.
비유의 한계
partial resource가 생길 수 있고 exit 0도 application schema·selector test Green을 증명하지 않는다.
27줄F05-L27 if($LASTEXITCODE-ne 0){throw "W15 compose up exit=$LASTEXITCODE"} Compose 시동 native 결과가 0이 아니면 그 code로 실패한다. LASTEXITCODE nonzero를 W15 compose up exception으로 바꾼다.
입력
직전 docker compose up process의 exit code를 읽는다.
결과·효과
0이면 guard가 조용히 끝나고 nonzero이면 현재 try 흐름이 exception으로 바뀐다.
비유의 한계
exit 0은 어느 image digest나 DB data가 사용됐는지 hash-bound하지 않는다.
28줄F05-L28 $Container=(& docker compose -f $composeFile -p $ComposeProject ps -q db).Trim() 방금 project의 db container ID를 조회해 공백을 잘라 보관한다. docker compose ps -q db stdout을 Trim해 $Container에 대입한다.
입력
같은 compose file·project와 running Compose state를 사용한다.
결과·효과
$Container가 db service의 trimmed container identifier 후보를 갖는다.
비유의 한계
stdout 한 값은 image·running flag·schema 상태를 직접 검증하지 않는다.
29줄F05-L29 if($LASTEXITCODE-ne 0-or[string]::IsNullOrWhiteSpace($Container)){throw 'W15 compose container resolution failed'} 조회 실패나 빈 ID를 모두 container resolution 실패로 묶는다. LASTEXITCODE nonzero 또는 Container whitespace이면 예외를 던진다.
입력
ps native exit와 trimmed Container string을 함께 검사한다.
결과·효과
exit가 0이고 ID가 nonblank이면 guard가 끝나며 나머지 경우에는 exception이 발생한다.
비유의 한계
nonempty identifier는 해당 resource가 이후에도 계속 사용 가능하다는 보장이 아니다.
30줄F05-L30 }else{ 첫 mode 조건이 아니었을 때 들어갈 대체 통로를 연다. Compose 조건의 else branch scope를 시작한다.
입력
앞 if predicate가 false라는 branch decision을 받는다.
결과·효과
control이 대체 branch body의 시작점에 놓이지만 그 안의 statement는 아직 실행되지 않는다.
비유의 한계
else opener만으로 caller argument나 Docker object 상태는 확인되지 않는다.
31줄F05-L31 if([string]::IsNullOrWhiteSpace($Container)){throw 'W15 Container mode requires -Container'} 대체 통로에 container 이름표가 없으면 즉시 돌려보낸다. Container가 null/empty/whitespace이면 Container mode requires 예외를 던진다.
입력
caller가 전달한 Container string을 검사한다.
결과·효과
nonblank이면 guard가 끝나고 빈 값이면 현재 branch가 exception으로 중단된다.
비유의 한계
문자열 존재는 해당 Docker object가 실제로 있거나 사용 권한이 있다는 뜻이 아니다.
32줄F05-L32 $running=(& docker inspect -f '{{.State.Running}}|{{.Config.Image}}' $Container 2>$null).Trim() 지정 object의 running 불빛과 image 표지를 한 줄로 조회한다. docker inspect format으로 State.Running|Config.Image를 받고 Trim해 $running에 저장한다.
입력
nonblank Container identifier와 Docker engine을 사용한다.
결과·효과
$running이 inspect stdout의 boolean·image 조합 문자열 후보를 갖는다.
비유의 한계
inspect command 실패는 빈/부분 stdout을 남길 수 있고 아직 허용 pattern과 비교하지 않았다.
33줄F05-L33 if($LASTEXITCODE-ne 0-or$running-cnotmatch'^true\|postgres:'){throw 'W15 supplied container is not a running PostgreSQL container'} true와 postgres: 접두사가 함께 있는 supplied container만 통과시킨다. inspect exit가 nonzero이거나 output이 case-sensitive ^true|postgres: pattern과 다르면 예외를 던진다.
입력
직전 inspect native exit와 running|image 문자열을 받는다.
결과·효과
두 조건이 맞으면 guard가 예외 없이 끝나고 나머지는 supplied-container exception이 된다.
비유의 한계
postgres: tag prefix는 immutable digest·version·credential·전용 container를 보장하지 않는다.
34줄F05-L34 } 두 mode 중 선택된 container 준비 통로를 닫는다. Compose/Container conditional scope를 종료한다.
입력
선택된 branch가 예외 없이 끝난 control state를 받는다.
결과·효과
두 branch의 control이 동일한 다음 statement 위치로 합쳐진다.
비유의 한계
닫는 brace는 container를 정리하거나 schema를 변경하지 않는다.
35줄F05-L35 $v001=Get-Content -Raw (Join-Path $root 'src/main/resources/db/migration/V001__common.sql') 선택한 project root에서 V001 문서를 통째로 읽어 든다. root 아래 V001__common.sql을 Get-Content -Raw로 읽어 $v001 문자열에 저장한다.
입력
선택된 root path와 해당 migration file bytes를 사용한다.
결과·효과
$v001이 줄바꿈을 포함한 migration text를 memory에 갖는다.
비유의 한계
파일 존재·encoding은 read 성공에 의존하고 SHA-256이나 packaged inventory와 비교하지 않는다.
36줄F05-L36 $seed="INSERT INTO account(owner_id,account_no,status,currency,balance) VALUES('w15-a','W15-1','ACTIVE','KRW',100),('w15-b','W15-2','ACTIVE','KRW',0); INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at) VALUES('00000000-0000-0000-0000-000000000151','OPENING','COMPLETED','w15-1',now(),now()),('00000000-0000-0000-0000-000000000152','TRANSFER','COMPLETED','w15-2',now(),now()); INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000151',id,'OPENING',120,120,120,now() FROM account WHERE account_no='W15-1'; INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000152',id,'TRANSFER_OUT',20,-20,100,now() FROM account WHERE account_no='W15-1';" 두 account와 거래·원장 fixture를 적은 긴 SQL 두루마리를 memory 선반에 말아 둔다. account 두 tuple, business_tx 두 tuple, W15-1을 고르는 ledger INSERT 두 개가 든 SQL literal을 $seed에 저장한다.
입력
source 안의 fixed SQL string을 평가하고 아직 DB에는 보내지 않는다.
결과·효과
PowerShell memory에 semicolon으로 이은 네 INSERT statement text가 생기며 PostgreSQL row count는 이 줄에서 변하지 않는다.
비유의 한계
문자열 생성은 실행이 아니며 resulting row ID·count나 balance 업무 일관성을 검증하지 않는다.
37줄F05-L37 $setup="DO `$ddl`$ BEGIN IF EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='w15_schema') THEN EXECUTE 'DROP SCHEMA w15_schema CASCADE'; END IF; END `$ddl`$; CREATE SCHEMA w15_schema; SET search_path TO w15_schema;`n$v001`n$seed" 기존 w15_schema를 치우고 새 schema·V001·seed를 잇는 실행 대본을 조립한다. conditional DROP SCHEMA CASCADE, CREATE/SET search_path, v001, seed를 $setup string으로 결합한다.
입력
memory의 v001·seed text와 source에 적힌 DO/CREATE/SET literal을 사용한다.
결과·효과
$setup에 supplied schema를 교체할 전체 multi-statement SQL batch text가 완성된다.
비유의 한계
Container mode에서도 같은 DROP CASCADE가 들어가며 explicit transaction이나 최종 schema cleanup은 포함하지 않는다.
38줄F05-L38 $setup|docker exec -i $Container psql -v ON_ERROR_STOP=1 -U app -d financial_core|Out-Null 조립한 대본을 container psql 표준입력으로 밀어 넣는다. $setup을 docker exec -i psql ON_ERROR_STOP=1에 pipe하고 normal output을 버린다.
입력
Container ID, financial_core/app 접속값, setup SQL batch를 native process에 넘긴다.
결과·효과
psql이 fresh w15_schema 구성과 seed 실행을 시도하고 native exit를 LASTEXITCODE에 남긴다.
비유의 한계
여러 statement의 explicit transaction이 없어 실패 시 이미 실행된 DDL/DML의 rollback 전체를 보장하지 않는다.
39줄F05-L39 if($LASTEXITCODE-ne 0){throw 'W15 isolated V001 apply failed'} 격리 V001 적용 native code가 0이 아니면 즉시 실패 표를 붙인다. LASTEXITCODE nonzero를 W15 isolated V001 apply exception으로 바꾼다.
입력
직전 docker exec psql process의 exit code를 확인한다.
결과·효과
0이면 현재 apply guard가 끝나고 nonzero이면 try 흐름이 exception으로 중단된다.
비유의 한계
exit 0은 migration source hash·ERD 문서·정규형을 직접 증명하지 않는다.
40줄F05-L40 $keys=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT table_name||'|'||constraint_type||'|'||constraint_name FROM information_schema.table_constraints WHERE table_schema='w15_schema' AND table_name IN ('account','business_tx','ledger_entry','idempotency_request') ORDER BY 1") 네 table의 constraint 명찰을 type과 name까지 catalog에서 모은다. information_schema.table_constraints를 조회해 table|type|name strings를 정렬된 $keys array로 받는다.
입력
w15_schema에 적용된 catalog와 네 fixed table name을 대상으로 psql을 실행한다.
결과·효과
$keys에 PK·FK·UNIQUE뿐 아니라 query가 반환하는 다른 constraint type도 함께 모인다.
비유의 한계
변수 이름 keys는 모든 row가 key라는 뜻이 아니며 constraint 정의 column·action 의미까지 수집하지 않는다.
41줄F05-L41 if($LASTEXITCODE-ne 0){throw 'W15 keys query failed'} constraint catalog 조회 native 실패를 별도 오류로 막는다. 직전 keys psql LASTEXITCODE가 nonzero이면 keys query failed 예외를 던진다.
입력
catalog query process의 exit code를 읽는다.
결과·효과
0이면 이 guard가 조용히 끝나고 nonzero이면 discovery 흐름이 중단된다.
비유의 한계
exit 0만으로 expected constraint 최소치가 충족됐다는 판정은 내리지 않는다.
42줄F05-L42 $schema=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT indexname||'|'||indexdef FROM pg_indexes WHERE schemaname='w15_schema' ORDER BY indexname") w15_schema의 index 이름과 정의를 모두 사전순으로 모은다. pg_indexes에서 indexname|indexdef를 조회해 $schema array로 받는다.
입력
격리 schema의 PostgreSQL catalog와 현재 container connection을 사용한다.
결과·효과
$schema가 explicit·constraint-backed index definition rows를 담는다.
비유의 한계
이 statement는 rows를 수집할 뿐 threshold·exact inventory·query-plan 사용 여부를 평가하지 않는다.
43줄F05-L43 if($LASTEXITCODE-ne 0){throw 'W15 schema query failed'} index catalog 조회 native code가 나쁘면 schema query 실패로 중단한다. 직전 index psql LASTEXITCODE nonzero를 exception으로 바꾼다.
입력
pg_indexes query process의 exit code를 확인한다.
결과·효과
0이면 현재 guard가 끝나고 nonzero이면 try 흐름이 더 진행되지 않는다.
비유의 한계
조회 성공은 index definition이 성능 요구에 적합하다는 의미가 아니다.
44줄F05-L44 $card=@(docker exec $Container psql -At -F ',' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT a.id,count(l.id) FROM w15_schema.account a LEFT JOIN w15_schema.ledger_entry l ON l.account_id=a.id GROUP BY a.id ORDER BY a.id") account마다 연결된 ledger 수를 LEFT JOIN으로 세어 두 열 CSV처럼 받는다. account-left-ledger count query를 id 순서로 실행해 comma-separated $card array를 만든다.
입력
w15_schema의 두 seeded account와 ledger rows, psql -F comma를 사용한다.
결과·효과
$card에 account가 ledger 0건이어도 남는 id,count 문자열들이 순서대로 들어간다.
비유의 한계
현재 fixture 범위의 관찰이며 모든 account 관계 cardinality나 FK optionality를 일반 증명하지 않는다.
45줄F05-L45 if($LASTEXITCODE-ne 0){throw 'W15 cardinality query failed'} account-left-ledger 집계 호출 자체가 실패하면 그 자리에서 멈춘다. 직전 cardinality psql LASTEXITCODE nonzero를 query failed 예외로 바꾼다.
입력
LEFT JOIN count native process의 exit code를 읽는다.
결과·효과
0이면 이 guard가 끝나고 nonzero이면 현재 try 흐름이 중단된다.
비유의 한계
native 성공은 returned row 수·순서·literal 값의 correctness를 판정하지 않는다.
46줄F05-L46 if($card.Count-ne 2-or$card[0]-cne'1,2'-or$card[1]-cne'2,0'){throw "W15 exact cardinality mismatch: $card"} 두 행이 정확히 1,2와 2,0인지 순서까지 잠근다. card.Count=2와 case-sensitive card[0]/card[1] literal equality를 아니면 예외로 검사한다.
입력
id ascending cardinality strings array를 받는다.
결과·효과
정확한 두 문자열이면 이 guard가 끝나고 count·순서·값 차이는 모두 exception이 된다.
비유의 한계
이 oracle은 identity-reset fixture 두 account에만 해당하며 production 전체의 1:N 최대치나 delete policy를 증명하지 않는다.
47줄F05-L47 $keys|Set-Content -Encoding utf8 (Join-Path $e 'keys.txt');@('account_id,ledger_count')+$card|Set-Content -Encoding utf8 (Join-Path $e 'cardinality.csv');$schema|Set-Content -Encoding utf8 (Join-Path $e 'schema.txt') constraint·cardinality·index 관찰을 세 개 파일에 따로 적는다. keys.txt, header 포함 cardinality.csv, schema.txt를 세 Set-Content 호출로 UTF-8 기록한다.
입력
evidence directory와 memory의 keys/card/schema arrays를 사용한다.
결과·효과
성공한 각 호출의 시점에 해당 evidence file이 생성·덮어쓰기 된다.
비유의 한계
세 write는 하나의 atomic transaction이 아니어서 중간 오류가 나도 먼저 끝난 일부 file만 남을 수 있다.
48줄F05-L48 if(@($keys|Where-Object{$_-match'PRIMARY KEY'}).Count-lt 4-or@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count-lt 2-or@($keys|Where-Object{$_-match'UNIQUE'}).Count-lt 2){throw 'W15 key inventory incomplete'} PK 네 개·FK 두 개·UNIQUE 두 개보다 적으면 관찰표가 있어도 Red로 만든다. keys strings를 regex 분류해 PK>=4, FK>=2, UNIQUE>=2 lower-bound를 검사한다.
입력
이미 memory에 있는 $keys array에서 세 constraint-type substring count를 계산한다.
결과·효과
세 minimum이 맞으면 이 guard가 끝나고 하나라도 부족하면 key inventory exception이 발생한다.
비유의 한계
exact count를 요구하지 않고 CHECK 의미·index count·constraint column 조합도 이 조건에 포함하지 않는다.
49줄F05-L49 $gate=[ordered]@{schema='w15_schema';migration='V001_once_per_fresh_schema';seed_accounts=2;cardinality_rows=2;cardinality_exact=@('1,2','2,0');keys=$keys.Count;fk=@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count;unique=@($keys|Where-Object{$_-match'UNIQUE'}).Count;indexes=$schema.Count;tests=$tests;failures=0;errors=0;skipped=0} 현재 schema 이름과 관찰 숫자를 순서 있는 gate 표에 모은다. schema/migration/seed/cardinality/key/fk/unique/index/test counters를 ordered hashtable로 만든다.
입력
검증된 card와 발견 arrays 및 XML aggregate counters를 사용한다.
결과·효과
$gate가 현재 run summary 값을 key insertion order와 함께 memory에 보유한다.
비유의 한계
object 생성은 file write나 서명·hash 결속이 아니며 indexes 값은 기록용 count다.
50줄F05-L50 $gate|ConvertTo-Json|Set-Content -Encoding utf8 (Join-Path $e 'schema-gate.json') gate 표를 JSON으로 바꿔 schema-gate.json에 쓴다. $gate를 ConvertTo-Json하고 UTF-8 Set-Content로 evidence file에 기록한다.
입력
ordered gate object와 evidence directory path를 사용한다.
결과·효과
write 성공 시 schema-gate.json이 현재 summary text로 생성·덮어쓰기 된다.
비유의 한계
Set-Content는 원자 replace나 fsync·signature를 보장하지 않고 다른 세 evidence와 한 commit으로 묶이지 않는다.
51줄F05-L51 "W15_SCHEMA_GREEN keys=$($gate.keys) fk=$($gate.fk) unique=$($gate.unique) indexes=$($gate.indexes) tests=$($gate.tests) failures=0 errors=0 skipped=0" 모든 앞 조건을 지난 run만 W15_SCHEMA_GREEN 한 줄을 stdout에 남긴다. gate counters를 보간한 Green marker string을 pipeline output으로 방출한다.
입력
완성된 gate object와 zero failure/error/skipped literals를 사용한다.
결과·효과
caller가 읽을 marker에 keys/fk/unique/indexes/tests counts와 zero counters가 나타난다.
비유의 한계
marker는 현재 targeted run의 summary이며 full suite·source hash·ERD 의미 검토·formal normalization 증명이 아니다.
52줄F05-L52 }finally{ 성공과 실패 어느 쪽에서도 들어갈 정리 봉투를 연다. try와 결속된 finally block을 시작한다.
입력
try body가 정상 완료했거나 exception으로 빠져나온 control을 받는다.
결과·효과
cleanup statements가 실행될 scope에 control이 진입한다.
비유의 한계
finally opener만으로 어떤 resource를 소유했는지나 cleanup 성공은 정해지지 않는다.
53줄F05-L53 if($ownedCompose){& docker compose -f (Join-Path $referenceRoot 'compose.yaml') -p $ComposeProject down -v;if($LASTEXITCODE-ne 0){Write-Error 'W15 compose cleanup failed'}} 내가 연 Compose project만 volume까지 내리고 native 실패도 오류로 올린다. ownedCompose가 true이면 docker compose down -v를 실행하고 nonzero LASTEXITCODE에 Write-Error한다.
입력
ownership flag, packaged compose path, project name, Docker engine state를 사용한다.
결과·효과
Compose-owned resources의 stop/remove와 volume deletion이 요청되고 native 실패는 PowerShell error가 된다.
비유의 한계
Container mode의 supplied container와 그 안 w15_schema는 제거하지 않으며 cleanup 실패 시 일부 resource가 남을 수 있다.
54줄F05-L54 Pop-Location 작업자가 처음 서 있던 directory로 location stack을 한 칸 되돌린다. Pop-Location으로 Push-Location 이전 current directory를 복원한다.
입력
앞서 성공한 location stack entry를 사용한다.
결과·효과
location stack top이 제거되고 caller의 이전 working directory가 다시 current가 된다.
비유의 한계
stack이 없거나 앞 cleanup error가 흐름을 끊으면 복원이 성공한다고 보장할 수 없다.
55줄F05-L55 } W15 검사와 cleanup을 감싼 마지막 봉투를 닫는다. finally와 script의 outer statement scope를 종료한다.
입력
앞 cleanup statements가 끝난 control state를 받는다.
결과·효과
추가 statement 없이 script 구조가 완결되고 성공이면 caller에게 마지막 output이 반환된다.
비유의 한계
닫는 brace 자체는 Green을 만들거나 남은 schema·evidence를 검증하지 않는다.
Compose 소유권히토리 → 니지카 → 료 → 키타
  1. 히토리

    up --wait가 중간 실패하면 정리 flag도 못 켜나요?

  2. 니지카

    flag를 startup보다 먼저 true로 바꿔 partial project도 finally가 맡게 했어.

  3. down -v native 실패는 다시 오류가 되고 resource가 일부 남을 수 있다.

  4. 키타

    ownedCompose가 바뀌는 위치를 먼저 찾을게요.

05

STEP 05 / 13

원본 코드 조각

원본을 9개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.

F05-C01 · parameters and resolved paths1–8줄
1–8줄 원본
param([ValidateSet('Compose','Container')][string]$Mode='Compose',[string]$Container='',[string]$ComposeProject='w15-schema-lab',[string]$EvidenceDir='evidence/w15',[string]$ProjectRoot='')
$ErrorActionPreference='Stop'
$referenceRoot=Split-Path -Parent $PSScriptRoot
$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot}
$e=if([IO.Path]::IsPathRooted($EvidenceDir)){[IO.Path]::GetFullPath($EvidenceDir)}else{[IO.Path]::GetFullPath((Join-Path $root $EvidenceDir))}
New-Item -ItemType Directory -Force $e|Out-Null
Push-Location $root
$ownedCompose=$false
F05-C02 · two selectors and XML Green gate9–17줄
9–17줄 원본
try{
 & .\gradlew.bat test --tests 'com.example.financialcore.CoreSchemaIT' --tests 'com.example.financialcore.account.OpeningIntegrationTest' --no-daemon
 if($LASTEXITCODE-ne 0){throw "W15 Gradle exit=$LASTEXITCODE"}
 $expected=@('com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')
 $rows=@(Get-ChildItem build/test-results/test -Filter 'TEST-*.xml'|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})
 $classes=@($rows.class|Sort-Object -Unique)
 if(Compare-Object ($expected|Sort-Object) $classes){throw "W15 XML classes mismatch expected=$expected actual=$classes"}
 $tests=($rows|Measure-Object tests -Sum).Sum;$failures=($rows|Measure-Object failures -Sum).Sum;$errors=($rows|Measure-Object errors -Sum).Sum;$skipped=($rows|Measure-Object skipped -Sum).Sum
 if($tests-lt 2-or$failures-ne 0-or$errors-ne 0-or$skipped-ne 0){throw 'W15 XML not Green'}
F05-C03 · owned Compose startup18–29줄
18–29줄 원본
 if($Mode-eq'Compose'){
   if($Container){throw 'W15 Compose mode does not accept -Container'}
   $composeFile=Join-Path $referenceRoot 'compose.yaml'
   if(!(Test-Path -LiteralPath $composeFile -PathType Leaf)){throw 'W15 compose.yaml missing'}
   if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w15-disposable-only-password'}
   # Own this unique project before startup so partial Compose creation is
   # removed by finally even when `up --wait` fails.
   $ownedCompose=$true
   & docker compose -f $composeFile -p $ComposeProject up -d --wait db
   if($LASTEXITCODE-ne 0){throw "W15 compose up exit=$LASTEXITCODE"}
   $Container=(& docker compose -f $composeFile -p $ComposeProject ps -q db).Trim()
   if($LASTEXITCODE-ne 0-or[string]::IsNullOrWhiteSpace($Container)){throw 'W15 compose container resolution failed'}
F05-C04 · supplied container validation30–34줄
30–34줄 원본
 }else{
   if([string]::IsNullOrWhiteSpace($Container)){throw 'W15 Container mode requires -Container'}
   $running=(& docker inspect -f '{{.State.Running}}|{{.Config.Image}}' $Container 2>$null).Trim()
   if($LASTEXITCODE-ne 0-or$running-cnotmatch'^true\|postgres:'){throw 'W15 supplied container is not a running PostgreSQL container'}
 }
F05-C05 · isolated V001 and seed apply35–39줄
35–39줄 원본
 $v001=Get-Content -Raw (Join-Path $root 'src/main/resources/db/migration/V001__common.sql')
 $seed="INSERT INTO account(owner_id,account_no,status,currency,balance) VALUES('w15-a','W15-1','ACTIVE','KRW',100),('w15-b','W15-2','ACTIVE','KRW',0); INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at) VALUES('00000000-0000-0000-0000-000000000151','OPENING','COMPLETED','w15-1',now(),now()),('00000000-0000-0000-0000-000000000152','TRANSFER','COMPLETED','w15-2',now(),now()); INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000151',id,'OPENING',120,120,120,now() FROM account WHERE account_no='W15-1'; INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000152',id,'TRANSFER_OUT',20,-20,100,now() FROM account WHERE account_no='W15-1';"
 $setup="DO `$ddl`$ BEGIN IF EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='w15_schema') THEN EXECUTE 'DROP SCHEMA w15_schema CASCADE'; END IF; END `$ddl`$; CREATE SCHEMA w15_schema; SET search_path TO w15_schema;`n$v001`n$seed"
 $setup|docker exec -i $Container psql -v ON_ERROR_STOP=1 -U app -d financial_core|Out-Null
 if($LASTEXITCODE-ne 0){throw 'W15 isolated V001 apply failed'}
F05-C06 · key and index discovery40–43줄
40–43줄 원본
 $keys=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT table_name||'|'||constraint_type||'|'||constraint_name FROM information_schema.table_constraints WHERE table_schema='w15_schema' AND table_name IN ('account','business_tx','ledger_entry','idempotency_request') ORDER BY 1")
 if($LASTEXITCODE-ne 0){throw 'W15 keys query failed'}
 $schema=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT indexname||'|'||indexdef FROM pg_indexes WHERE schemaname='w15_schema' ORDER BY indexname")
 if($LASTEXITCODE-ne 0){throw 'W15 schema query failed'}
F05-C07 · exact two-account cardinality44–46줄
44–46줄 원본
 $card=@(docker exec $Container psql -At -F ',' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT a.id,count(l.id) FROM w15_schema.account a LEFT JOIN w15_schema.ledger_entry l ON l.account_id=a.id GROUP BY a.id ORDER BY a.id")
 if($LASTEXITCODE-ne 0){throw 'W15 cardinality query failed'}
 if($card.Count-ne 2-or$card[0]-cne'1,2'-or$card[1]-cne'2,0'){throw "W15 exact cardinality mismatch: $card"}
F05-C08 · evidence writes, minimum gate, and marker47–51줄
47–51줄 원본
 $keys|Set-Content -Encoding utf8 (Join-Path $e 'keys.txt');@('account_id,ledger_count')+$card|Set-Content -Encoding utf8 (Join-Path $e 'cardinality.csv');$schema|Set-Content -Encoding utf8 (Join-Path $e 'schema.txt')
 if(@($keys|Where-Object{$_-match'PRIMARY KEY'}).Count-lt 4-or@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count-lt 2-or@($keys|Where-Object{$_-match'UNIQUE'}).Count-lt 2){throw 'W15 key inventory incomplete'}
 $gate=[ordered]@{schema='w15_schema';migration='V001_once_per_fresh_schema';seed_accounts=2;cardinality_rows=2;cardinality_exact=@('1,2','2,0');keys=$keys.Count;fk=@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count;unique=@($keys|Where-Object{$_-match'UNIQUE'}).Count;indexes=$schema.Count;tests=$tests;failures=0;errors=0;skipped=0}
 $gate|ConvertTo-Json|Set-Content -Encoding utf8 (Join-Path $e 'schema-gate.json')
 "W15_SCHEMA_GREEN keys=$($gate.keys) fk=$($gate.fk) unique=$($gate.unique) indexes=$($gate.indexes) tests=$($gate.tests) failures=0 errors=0 skipped=0"
F05-C09 · owned Compose cleanup52–55줄
52–55줄 원본
}finally{
 if($ownedCompose){& docker compose -f (Join-Path $referenceRoot 'compose.yaml') -p $ComposeProject down -v;if($LASTEXITCODE-ne 0){Write-Error 'W15 compose cleanup failed'}}
 Pop-Location
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 55줄을 모두 한국어로 옮깁니다.

전체 번역 55 / 55

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1param([ValidateSet('Compose','Container')][string]$Mode='Compose',[string]$Container='',[string]$ComposeProject='w15-schema-lab',[string]$EvidenceDir='evidence/w15',[string]$ProjectRoot='')Compose/Container mode, container, project, evidence, project-root parameters와 defaults를 선언한다.
2$ErrorActionPreference='Stop'$ErrorActionPreference를 Stop으로 설정해 PowerShell non-terminating error를 terminating error로 승격한다.
3$referenceRoot=Split-Path -Parent $PSScriptRoot$PSScriptRoot의 parent를 계산해 packaged reference root 후보로 저장한다.
4$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot}ProjectRoot가 truthy면 Resolve-Path 결과를, 아니면 referenceRoot를 $root로 선택한다.
5$e=if([IO.Path]::IsPathRooted($EvidenceDir)){[IO.Path]::GetFullPath($EvidenceDir)}else{[IO.Path]::GetFullPath((Join-Path $root $EvidenceDir))}EvidenceDir의 rooted 여부에 따라 직접 또는 $root와 결합한 뒤 GetFullPath로 $e를 만든다.
6New-Item -ItemType Directory -Force $e|Out-NullNew-Item Directory -Force로 $e directory를 만들고 pipeline output을 버린다.
7Push-Location $rootPush-Location으로 current location을 $root에 쌓아 전환한다.
8$ownedCompose=$false$ownedCompose를 false로 초기화한다.
9try{try keyword가 protected statement scope의 시작을 선언한다.
10 & .\gradlew.bat test --tests 'com.example.financialcore.CoreSchemaIT' --tests 'com.example.financialcore.account.OpeningIntegrationTest' --no-daemonGradle test task를 두 exact class selector와 --no-daemon으로 실행한다.
11 if($LASTEXITCODE-ne 0){throw "W15 Gradle exit=$LASTEXITCODE"}LASTEXITCODE가 nonzero이면 exit 값을 담은 W15 Gradle 예외를 던진다.
12 $expected=@('com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')$expected array에 CoreSchemaIT와 OpeningIntegrationTest FQCN 두 개를 저장한다.
13 $rows=@(Get-ChildItem build/test-results/test -Filter 'TEST-*.xml'|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})TEST-*.xml 각각을 XML로 읽어 suite name/tests/failures/errors/skipped object array를 만든다.
14 $classes=@($rows.class|Sort-Object -Unique)$rows.class를 Sort-Object -Unique로 정규화해 $classes array를 만든다.
15 if(Compare-Object ($expected|Sort-Object) $classes){throw "W15 XML classes mismatch expected=$expected actual=$classes"}sorted expected set과 actual classes를 Compare-Object하고 차이가 있으면 mismatch 예외를 던진다.
16 $tests=($rows|Measure-Object tests -Sum).Sum;$failures=($rows|Measure-Object failures -Sum).Sum;$errors=($rows|Measure-Object errors -Sum).Sum;$skipped=($rows|Measure-Object skipped -Sum).Sumrows의 tests/failures/errors/skipped field를 Measure-Object -Sum으로 각각 집계한다.
17 if($tests-lt 2-or$failures-ne 0-or$errors-ne 0-or$skipped-ne 0){throw 'W15 XML not Green'}tests<2 또는 failures/errors/skipped가 0이 아니면 W15 XML not Green 예외를 던진다.
18 if($Mode-eq'Compose'){$Mode가 대소문자 비민감 eq Compose이면 실행할 branch scope를 시작한다.
19 if($Container){throw 'W15 Compose mode does not accept -Container'}Compose mode인데 Container 문자열이 truthy이면 예외를 던진다.
20 $composeFile=Join-Path $referenceRoot 'compose.yaml'referenceRoot와 compose.yaml을 Join-Path해 $composeFile에 저장한다.
21 if(!(Test-Path -LiteralPath $composeFile -PathType Leaf)){throw 'W15 compose.yaml missing'}Test-Path Leaf가 false이면 W15 compose.yaml missing 예외를 던진다.
22 if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w15-disposable-only-password'}FCL_DB_PASSWORD가 null/empty/whitespace이면 process environment에 disposable default를 설정한다.
23 # Own this unique project before startup so partial Compose creation isstartup 전 project ownership과 partial Compose creation을 언급하다 `is`에서 끝나는 미완성 comment fragment다.
24 # removed by finally even when `up --wait` fails.finally가 failed startup의 부분 resource도 제거한다는 comment를 완성한다.
25 $ownedCompose=$true$ownedCompose를 true로 바꾼다.
26 & docker compose -f $composeFile -p $ComposeProject up -d --wait dbdocker compose -f composeFile -p ComposeProject up -d --wait db를 native 실행한다.
27 if($LASTEXITCODE-ne 0){throw "W15 compose up exit=$LASTEXITCODE"}LASTEXITCODE nonzero를 W15 compose up exception으로 바꾼다.
28 $Container=(& docker compose -f $composeFile -p $ComposeProject ps -q db).Trim()docker compose ps -q db stdout을 Trim해 $Container에 대입한다.
29 if($LASTEXITCODE-ne 0-or[string]::IsNullOrWhiteSpace($Container)){throw 'W15 compose container resolution failed'}LASTEXITCODE nonzero 또는 Container whitespace이면 예외를 던진다.
30 }else{Compose 조건의 else branch scope를 시작한다.
31 if([string]::IsNullOrWhiteSpace($Container)){throw 'W15 Container mode requires -Container'}Container가 null/empty/whitespace이면 Container mode requires 예외를 던진다.
32 $running=(& docker inspect -f '{{.State.Running}}|{{.Config.Image}}' $Container 2>$null).Trim()docker inspect format으로 State.Running|Config.Image를 받고 Trim해 $running에 저장한다.
33 if($LASTEXITCODE-ne 0-or$running-cnotmatch'^true\|postgres:'){throw 'W15 supplied container is not a running PostgreSQL container'}inspect exit가 nonzero이거나 output이 case-sensitive ^true|postgres: pattern과 다르면 예외를 던진다.
34 }Compose/Container conditional scope를 종료한다.
35 $v001=Get-Content -Raw (Join-Path $root 'src/main/resources/db/migration/V001__common.sql')root 아래 V001__common.sql을 Get-Content -Raw로 읽어 $v001 문자열에 저장한다.
36 $seed="INSERT INTO account(owner_id,account_no,status,currency,balance) VALUES('w15-a','W15-1','ACTIVE','KRW',100),('w15-b','W15-2','ACTIVE','KRW',0); INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at) VALUES('00000000-0000-0000-0000-000000000151','OPENING','COMPLETED','w15-1',now(),now()),('00000000-0000-0000-0000-000000000152','TRANSFER','COMPLETED','w15-2',now(),now()); INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000151',id,'OPENING',120,120,120,now() FROM account WHERE account_no='W15-1'; INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000152',id,'TRANSFER_OUT',20,-20,100,now() FROM account WHERE account_no='W15-1';"account 두 tuple, business_tx 두 tuple, W15-1을 고르는 ledger INSERT 두 개가 든 SQL literal을 $seed에 저장한다.
37 $setup="DO `$ddl`$ BEGIN IF EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='w15_schema') THEN EXECUTE 'DROP SCHEMA w15_schema CASCADE'; END IF; END `$ddl`$; CREATE SCHEMA w15_schema; SET search_path TO w15_schema;`n$v001`n$seed"conditional DROP SCHEMA CASCADE, CREATE/SET search_path, v001, seed를 $setup string으로 결합한다.
38 $setup|docker exec -i $Container psql -v ON_ERROR_STOP=1 -U app -d financial_core|Out-Null$setup을 docker exec -i psql ON_ERROR_STOP=1에 pipe하고 normal output을 버린다.
39 if($LASTEXITCODE-ne 0){throw 'W15 isolated V001 apply failed'}LASTEXITCODE nonzero를 W15 isolated V001 apply exception으로 바꾼다.
40 $keys=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT table_name||'|'||constraint_type||'|'||constraint_name FROM information_schema.table_constraints WHERE table_schema='w15_schema' AND table_name IN ('account','business_tx','ledger_entry','idempotency_request') ORDER BY 1")information_schema.table_constraints를 조회해 table|type|name strings를 정렬된 $keys array로 받는다.
41 if($LASTEXITCODE-ne 0){throw 'W15 keys query failed'}직전 keys psql LASTEXITCODE가 nonzero이면 keys query failed 예외를 던진다.
42 $schema=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT indexname||'|'||indexdef FROM pg_indexes WHERE schemaname='w15_schema' ORDER BY indexname")pg_indexes에서 indexname|indexdef를 조회해 $schema array로 받는다.
43 if($LASTEXITCODE-ne 0){throw 'W15 schema query failed'}직전 index psql LASTEXITCODE nonzero를 exception으로 바꾼다.
44 $card=@(docker exec $Container psql -At -F ',' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT a.id,count(l.id) FROM w15_schema.account a LEFT JOIN w15_schema.ledger_entry l ON l.account_id=a.id GROUP BY a.id ORDER BY a.id")account-left-ledger count query를 id 순서로 실행해 comma-separated $card array를 만든다.
45 if($LASTEXITCODE-ne 0){throw 'W15 cardinality query failed'}직전 cardinality psql LASTEXITCODE nonzero를 query failed 예외로 바꾼다.
46 if($card.Count-ne 2-or$card[0]-cne'1,2'-or$card[1]-cne'2,0'){throw "W15 exact cardinality mismatch: $card"}card.Count=2와 case-sensitive card[0]/card[1] literal equality를 아니면 예외로 검사한다.
47 $keys|Set-Content -Encoding utf8 (Join-Path $e 'keys.txt');@('account_id,ledger_count')+$card|Set-Content -Encoding utf8 (Join-Path $e 'cardinality.csv');$schema|Set-Content -Encoding utf8 (Join-Path $e 'schema.txt')keys.txt, header 포함 cardinality.csv, schema.txt를 세 Set-Content 호출로 UTF-8 기록한다.
48 if(@($keys|Where-Object{$_-match'PRIMARY KEY'}).Count-lt 4-or@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count-lt 2-or@($keys|Where-Object{$_-match'UNIQUE'}).Count-lt 2){throw 'W15 key inventory incomplete'}keys strings를 regex 분류해 PK>=4, FK>=2, UNIQUE>=2 lower-bound를 검사한다.
49 $gate=[ordered]@{schema='w15_schema';migration='V001_once_per_fresh_schema';seed_accounts=2;cardinality_rows=2;cardinality_exact=@('1,2','2,0');keys=$keys.Count;fk=@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count;unique=@($keys|Where-Object{$_-match'UNIQUE'}).Count;indexes=$schema.Count;tests=$tests;failures=0;errors=0;skipped=0}schema/migration/seed/cardinality/key/fk/unique/index/test counters를 ordered hashtable로 만든다.
50 $gate|ConvertTo-Json|Set-Content -Encoding utf8 (Join-Path $e 'schema-gate.json')$gate를 ConvertTo-Json하고 UTF-8 Set-Content로 evidence file에 기록한다.
51 "W15_SCHEMA_GREEN keys=$($gate.keys) fk=$($gate.fk) unique=$($gate.unique) indexes=$($gate.indexes) tests=$($gate.tests) failures=0 errors=0 skipped=0"gate counters를 보간한 Green marker string을 pipeline output으로 방출한다.
52}finally{try와 결속된 finally block을 시작한다.
53 if($ownedCompose){& docker compose -f (Join-Path $referenceRoot 'compose.yaml') -p $ComposeProject down -v;if($LASTEXITCODE-ne 0){Write-Error 'W15 compose cleanup failed'}}ownedCompose가 true이면 docker compose down -v를 실행하고 nonzero LASTEXITCODE에 Write-Error한다.
54 Pop-LocationPop-Location으로 Push-Location 이전 current directory를 복원한다.
55}finally와 script의 outer statement scope를 종료한다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

PowerShell owner가 targeted XML gate, isolated PostgreSQL schema, catalog 관찰, evidence, ownership cleanup을 순서대로 실행한다.

문법 해부

  • ValidateSet·IsNullOrWhiteSpace·Compare-Object가 입력과 set 경계를 만든다.
  • native process마다 LASTEXITCODE를 직접 검사해야 ErrorActionPreference와 결속된다.
  • @()는 scalar/empty output도 array로 정규화하고 ordered hashtable은 JSON field 순서를 보존한다.
  • try/finally cleanup 선택은 ownedCompose boolean에 달렸다.

실행 순서

  1. parameter/path binding
  2. two-selector Gradle and XML gate
  3. Compose or supplied-container selection
  4. DROP/CREATE w15_schema with V001+seed
  5. constraint/index/cardinality discovery
  6. partial evidence writes then lower-bound key gate
  7. schema-gate JSON and stdout marker
  8. owned Compose cleanup and Pop-Location

원래 W6 수준의 조각별 정밀 해설

F05-C01 · parameters and resolved paths
문법 해부
ValidateSet Mode와 Container·ComposeProject·EvidenceDir·ProjectRoot를 받고, stop-on-error·reference/root/evidence 절대 경로·directory·location·ownedCompose 초기값을 준비한다.
실제 값 추적
기본 호출은 Compose, w15-schema-lab, evidence/w15와 script 부모 root를 선택해 evidence directory를 만든 뒤 root로 이동한다.
정상 예
명시 ProjectRoot가 실제 directory이고 상대 EvidenceDir가 그 아래로 해석되는 호출은 준비 단계의 정상 예다.
틀린 예·반례
존재하지 않는 ProjectRoot는 Resolve-Path에서 중단되고 허용 목록 밖 Mode는 parameter binding에서 거절된다.
착각 방지
EvidenceDir가 절대 경로면 root와 결합하지 않으며 directory 생성은 기존 파일 내용을 지우지 않는다.
하지 않는 일
이 초기 범위는 Gradle·Docker·SQL을 실행하거나 Desktop에 파일을 쓰지 않는다.
다음 연결
다음 ‘two selectors and XML Green gate’ 범위가 try 안에서 두 exact JUnit class와 XML 합계를 검증한다.
F05-C02 · two selectors and XML Green gate
문법 해부
try 안에서 Gradle에 CoreSchemaIT와 OpeningIntegrationTest selector를 주고 native exit, XML class exact set, tests 합계 최소 2와 failures/errors/skipped 0을 차례로 gate한다.
실제 값 추적
XML rows의 distinct class set이 두 기대 이름과 같고 aggregate tests가 2 이상이며 failures·errors·skipped 합계가 0이면 현재 guard가 exception 없이 끝난다.
정상 예
stage source가 올바르게 조립된 project root에서 각 selected class test 한 개씩 Green인 실행은 threshold를 만족한다.
틀린 예·반례
default packaged root에는 두 active selector class가 없으므로 그 root에서 이름만으로 stage byte Green을 주장할 수 없다.
착각 방지
XML directory를 먼저 비우지 않으므로 stale TEST XML 가능성이 있고 runner는 selected source SHA를 기록하지 않는다.
하지 않는 일
이 검사는 두 선택 class의 실행 결과만 말하며 schema constraint 의미나 workbook query 정답은 증명하지 않는다.
다음 연결
다음 ‘owned Compose startup’ 범위는 Mode가 Compose일 때 파일·password·unique project를 준비하고 healthy db container ID를 얻는다.
F05-C03 · owned Compose startup
문법 해부
Compose branch는 Container 인자를 금지하고 compose.yaml 존재를 확인하며 disposable password default를 넣고 ownedCompose를 true로 한 뒤 up --wait와 ps -q로 db ID를 얻는다.
실제 값 추적
startup 전에 ownership flag가 설정돼 partial creation 뒤 실패해도 finally 제거 대상이 되며 성공하면 nonempty Container string이 남는다.
정상 예
unique ComposeProject와 유효 compose file로 db가 healthy가 되고 ps가 container ID를 반환하는 흐름이 정상이다.
틀린 예·반례
Compose mode에 -Container를 함께 주거나 compose file이 없거나 up/ps native exit가 nonzero면 즉시 throw한다.
착각 방지
fallback password는 disposable 실행용일 뿐 production secret 기본값으로 사용하면 안 된다.
하지 않는 일
ProjectRoot override가 있어도 composeFile은 referenceRoot에서 찾으며, 이 branch는 다른 mode의 외부 container 조건을 검사하지 않는다.
다음 연결
다음 ‘supplied container validation’ 범위는 Container mode에서 nonempty ID와 running postgres image 조건을 검사한다.
F05-C04 · supplied container validation
문법 해부
else branch가 Container 필수값을 확인하고 docker inspect의 Running|Image 문자열이 `true|postgres:`로 시작하는지 case-sensitive match한다.
실제 값 추적
running PostgreSQL 조건이 맞으면 guard가 exception 없이 끝나고 기존 nonblank Container ID 문자열은 변경되지 않는다.
정상 예
inspect가 true|postgres:17.10-alpine을 반환하는 supplied container는 이 좁은 type gate를 통과한다.
틀린 예·반례
빈 ID, inspect 실패, stopped 상태, postgres prefix가 아닌 image는 명시 오류로 중단된다.
착각 방지
image prefix 통과는 version·credential·database·안전한 전용성까지 확인한 결과가 아니다.
하지 않는 일
Container mode에서 runner는 외부 container의 수명이나 종료·제거를 소유하지 않는다.
다음 연결
다음 ‘isolated V001 and seed apply’ 범위가 migration text와 두 account·두 transaction·두 ledger 행 seed를 w15_schema에 적용한다.
F05-C05 · isolated V001 and seed apply
문법 해부
V001 text와 seed SQL을 만들고 기존 w15_schema를 DROP CASCADE 후 재생성·search_path 설정해 psql ON_ERROR_STOP으로 한 stream을 적용한다.
실제 값 추적
성공하면 격리 schema에 V001 네 table과 account 2행, business_tx 2행, ledger_entry 2행의 지정 fixture가 남는다.
정상 예
W15-1 account에는 OPENING과 TRANSFER_OUT 두 ledger가, W15-2에는 ledger 0개가 되는 seed가 정상 적용 예다.
틀린 예·반례
V001 Get-Content 실패는 Stop 설정의 PowerShell terminating error로 즉시 끝나고, docker/psql native 실패만 nonzero LASTEXITCODE 검사에 도달한다.
착각 방지
Container mode에서는 기존 w15_schema를 파괴적으로 교체하고 finally가 그 schema를 제거하지 않는다.
하지 않는 일
runner는 V001 source SHA를 대조하거나 seed 전체를 transaction wrapper로 감싼다는 증거를 기록하지 않는다.
다음 연결
다음 ‘key and index discovery’ 범위가 w15_schema의 table constraints와 pg_indexes 정의를 각각 배열로 읽는다.
F05-C06 · key and index discovery
문법 해부
information_schema.table_constraints에서 네 table의 name|type|constraint를 정렬해 keys로, pg_indexes에서 indexname|indexdef를 schema로 수집하고 각 native exit를 확인한다.
실제 값 추적
두 docker exec가 성공하면 constraint 문자열 배열과 index definition 문자열 배열이 메모리에 생긴다.
정상 예
w15_schema가 V001을 온전히 가진 경우 여러 constraint type rows와 explicit·implicit index rows가 조회된다.
틀린 예·반례
keys query나 index query의 psql exit가 nonzero이면 다음 관찰로 넘어가지 않고 전용 오류를 던진다.
착각 방지
이 범위는 index 수를 기록할 재료만 모으며 minimum이나 고정 개수 통과 조건을 아직 선언하지 않는다.
하지 않는 일
constraint 이름 목록은 ERD 일치·정규화 수준·참조 action 의미를 완전히 판정하지 않는다.
다음 연결
다음 ‘exact two-account cardinality’ 범위가 account별 ledger count를 두 행 1,2와 2,0으로 고정한다.
F05-C07 · exact two-account cardinality
문법 해부
account를 ledger_entry와 LEFT JOIN해 account id별 count(l.id)를 정렬한 뒤 rows가 정확히 `1,2`와 `2,0`인지 검사한다.
실제 값 추적
seed의 첫 account에는 ledger 두 행, 두 번째 account에는 0행이어서 card array length 2와 두 exact comma 문자열이 맞아야 한다.
정상 예
LEFT JOIN과 count(non-null id)가 ledger 없는 W15-2도 2,0으로 보존하는 상태가 이 검사의 핵심 정상 예다.
틀린 예·반례
INNER JOIN을 쓰거나 COUNT(*)를 쓰면 ledger 0인 account가 사라지거나 1로 세져 exact oracle이 깨진다.
착각 방지
이 cardinality는 1:N 모양의 작은 fixture 증거이며 모든 account에 ledger가 최대 두 개라는 제약이 아니다.
하지 않는 일
원장 금액·부호·transaction 관계의 업무 정확성은 두 count 행만으로 증명되지 않는다.
다음 연결
다음 ‘evidence writes, minimum gate, and marker’ 범위가 세 raw evidence 파일을 먼저 쓰고 key 최소치·JSON gate·Green marker를 만든다.
F05-C08 · evidence writes, minimum gate, and marker
문법 해부
keys.txt·cardinality.csv·schema.txt를 쓴 뒤 PK>=4, FK>=2, UNIQUE>=2를 gate하고 ordered summary를 schema-gate.json으로 저장해 W15_SCHEMA_GREEN 문장을 출력한다.
실제 값 추적
key 최소치가 맞으면 JSON에는 schema·migration label·seed/cardinality·key/fk/unique/index counts와 test 합계가 남고 marker 문자열이 stdout에 보인다.
정상 예
정상 fixture에서 cardinality.csv header 아래 1,2와 2,0이 있고 schema-gate.json failures/errors/skipped가 0인 상태가 유효하다.
틀린 예·반례
세 raw 파일은 key gate 전에 Set-Content되므로 minimum 실패에서도 부분 evidence가 남을 수 있다.
착각 방지
indexes는 `$schema.Count`로 기록만 하며 최소값이나 exact count 통과 조건이 아니다.
하지 않는 일
marker는 source SHA·ERD 문서 일치·3NF/BCNF·production query performance를 인증하지 않는다.
다음 연결
다음 ‘owned Compose cleanup’ 범위가 ownedCompose일 때만 project를 down -v하고 언제나 원래 location으로 돌아간다.
F05-C09 · owned Compose cleanup
문법 해부
finally가 ownedCompose true이면 reference compose file과 project 이름으로 down -v를 실행·exit 확인하며, 그 검사가 통과하면 Pop-Location까지 진행한다.
실제 값 추적
Compose cleanup이 성공하거나 ownedCompose가 false인 정상 경로에서는 호출자의 PowerShell directory가 원래 위치로 복구된다.
정상 예
Compose up가 중간 실패했어도 startup 전에 flag가 true였으므로 down -v 시도가 수행되는 흐름이 유효하다.
틀린 예·반례
Container mode에서는 ownedCompose가 false라 supplied container와 그 안의 w15_schema가 그대로 남는다.
착각 방지
down -v 실패는 Stop 설정 아래 Write-Error에서 중단될 수 있어 뒤 Pop-Location이 실행된다고 보장할 수 없다.
하지 않는 일
외부 evidence directory 삭제·Desktop 설치·supplied schema 복구는 cleanup 범위 밖이다.
다음 연결
runner의 끝이다. 재실행 전에는 mode별 소유권과 남을 수 있는 부분 evidence 또는 w15_schema를 먼저 확인한다.
Container mode의 위험히토리 → 니지카 → 료 → 키타
  1. 히토리

    이미 떠 있는 PostgreSQL을 주면 읽기만 하나요?

  2. 니지카

    아니, 그 안 w15_schema를 DROP CASCADE하고 다시 만들어.

  3. finally는 supplied container나 그 schema를 지우지 않으니 전용 disposable target이 필요하다.

  4. 키타

    실행 전 schema 이름과 container 소유자를 확인하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
1두 stage class가 조립된 learner rootGradle selectors와 XML exact class set/aggregate를 검사한다.tests>=2와 failures=errors=skipped=0일 때 DB phase로 간다.source SHA와 class별 test 최소는 직접 gate하지 않는다.
2Compose mode와 unique projectownership flag를 먼저 켜고 db up --wait와 container ID 조회를 수행한다.성공하면 owned PostgreSQL container ID가 준비된다.startup/cleanup native 실패 때 partial resource가 남을 수 있다.
3V001 text와 fixed two-account seedw15_schema를 DROP/CREATE하고 batch를 psql에 보낸다.fresh identity account 1/2와 ledger count 2/0 관찰 대상이 생긴다.source hash를 runner가 비교하지 않고 explicit transaction도 없다.
4catalog와 seeded rowsconstraints·indexes·LEFT JOIN count를 조회한다.keys/schema/card arrays와 exact 1,2|2,0 확인이 생긴다.key gate는 lower bound, index count는 record-only다.
5관찰 arrays와 XML counters세 evidence를 쓰고 key minimum을 검사한 뒤 gate JSON과 marker를 낸다.완전 성공 run은 W15_SCHEMA_GREEN summary를 출력한다.앞선 separate writes 때문에 실패 run에도 일부 evidence가 남을 수 있다.
V001과 seed히토리 → 니지카 → 료 → 키타
  1. 히토리

    V001 파일을 읽었으니 source가 canonical인지도 확인됐죠?

  2. 니지카

    Get-Content -Raw는 bytes를 읽지만 expected SHA와 비교하지 않아.

  3. audit가 별도로 hash-bound했고 runner는 두 account와 2/0 cardinality용 seed를 덧붙인다.

  4. 키타

    source hash 증거와 runtime exit를 다른 줄에 적을게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

PowerShell

path·branch·array·error preference·try/finally state를 관리한다.

native exit는 매번 LASTEXITCODE gate가 필요하다.
Gradle/JUnit

두 class selector를 실행하고 XML suite attributes를 만든다.

active source composition과 source SHA는 XML count 밖이다.
Docker Compose/Engine

owned project 또는 supplied container를 PostgreSQL execution target으로 제공한다.

tag prefix·running flag는 digest나 전용성 증거가 아니다.
PostgreSQL psql

w15_schema batch를 적용하고 information_schema/pg_indexes/LEFT JOIN 결과를 반환한다.

explicit transaction·formal normalization·query plan 검증은 없다.
Filesystem evidence

관찰 arrays와 gate object를 여러 Set-Content 파일로 기록한다.

atomic bundle·signature·failure-run cleanup을 제공하지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ runner가 두 selector를 적었으니 bare packaged root에서도 즉시 Green이다.

왜 틀리나 두 exact classes는 learning_stages/w6에만 있고 active src/test에는 없다.

바르게 읽기 stage를 조립한 learner root와 selector source hash를 별도 확인한다.

반례 bare root에서 Gradle filter가 matching tests를 찾지 못할 수 있다.

❌ tests>=2면 각 class에 정확히 한 test가 있다는 뜻이다.

왜 틀리나 aggregate sum만 검사하고 class별 최소·exact 분포를 보지 않는다.

바르게 읽기 XML set equality와 aggregate count를 서로 다른 조건으로 읽는다.

반례 한 class 2개, 다른 class 0개인 XML 분포도 aggregate 2가 될 수 있다.

❌ keys/fk/unique/indexes가 모두 exact schema contract다.

왜 틀리나 PK/FK/UNIQUE는 lower bound이고 indexes는 output count일 뿐 if 조건이 없다.

바르게 읽기 gate field와 record-only field를 구분한다.

반례 index rows가 예상과 달라도 세 key minimum과 cardinality가 맞으면 marker까지 갈 수 있다.

❌ keys.txt가 존재하면 그 실행은 Green이었다.

왜 틀리나 세 evidence write가 key minimum check보다 먼저 별도로 일어난다.

바르게 읽기 native exit·schema-gate·stdout marker와 현재 file hash를 함께 본다.

반례 key minimum에서 실패해도 앞선 keys/cardinality/schema files가 남을 수 있다.

❌ Container mode는 supplied DB를 읽기만 한다.

왜 틀리나 setup batch가 같은 container의 w15_schema를 DROP CASCADE 후 재생성한다.

바르게 읽기 전용 disposable container/schema에서만 실행한다.

반례 기존 w15_schema object는 setup 실행 시 제거된다.

❌ OpeningIntegrationTest method 이름 때문에 rollback·single snapshot 원자성까지 증명된다.

왜 틀리나 staged test는 성공 뒤 세 count query를 관찰하고 실패 주입이나 snapshot assert가 없다.

바르게 읽기 runner Green을 exact source assertions 범위로 제한한다.

반례 성공 path count가 모두 1이어도 중간 실패 rollback은 별도 test가 필요하다.

keys라는 이름의 함정히토리 → 니지카 → 료 → 키타
  1. 히토리

    $keys에는 PK·FK·UNIQUE만 들어 있나요?

  2. 니지카

    query는 information_schema의 constraint rows를 type 구분 없이 모아.

  3. gate만 문자열로 PK>=4, FK>=2, UNIQUE>=2를 세고 CHECK나 exact count는 요구하지 않는다.

  4. 키타

    keys.Count와 세 분류 count를 구분하겠습니다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

source composition

bare packaged root에 두 exact selector class가 active source로 존재

이 책임을 맡는 곳: learner-stage assembly and source audit
schema semantics

ERD equality·constraint business meaning·3NF/BCNF

이 책임을 맡는 곳: migration review and learner modeling analysis
evidence atomicity

모든 evidence가 final gate 뒤 한 번에 원자 commit

이 책임을 맡는 곳: atomic staging/rename evidence writer
container safety

supplied container의 기존 w15_schema 보존과 post-run 제거

이 책임을 맡는 곳: caller isolation and explicit schema cleanup
coverage

full Gradle suite·production performance·all cardinalities

이 책임을 맡는 곳: separate regression, benchmark, and data-quality suites
12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

path→JUnit XML→mode/container→V001+seed→catalog/cardinality→evidence/gate→cleanup 순서로 말한다.

2단계 · 코드 조각 재조립

  1. exact classes + aggregate XML counters
  2. ownedCompose before up --wait
  3. DROP/CREATE w15_schema + V001 + seed
  4. keys/schema/card queries
  5. pre-gate writes + lower bounds + marker
  6. Compose-only down -v + Pop-Location

3단계 · 파일 전체 다시 쓰기

55개 물리·nonblank 줄을 같은 순서로 다시 쓰고 canonical SHA-256과 selector literal을 대조한다.

자가 점검
  • 두 selector가 stage-only임을 적는다.
  • tests>=2를 class별 exact라고 쓰지 않는다.
  • PK/FK/UNIQUE lower bound와 index record-only를 나눈다.
  • partial evidence window를 표시한다.
  • Container mode의 DROP CASCADE와 schema 잔존을 경고한다.
  • runner 실행을 source audit PASS로 꾸미지 않는다.
index count의 지위히토리 → 니지카 → 료 → 키타
  1. 히토리

    indexes 숫자도 최소치를 통과해야 Green인가요?

  2. 니지카

    pg_indexes rows를 기록하지만 if 조건에는 index count가 없어.

  3. definition 목록과 count는 evidence이고 plan 사용·성능은 별도 문제다.

  4. 키타

    gate 조건과 record-only field를 색으로 나눌게요.

13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcescripts/run-w15-schema.ps1SHA-256 a8aa5039a526d93f7b7dae44474d220a370bf3e88cd024454be7405a69d5a1c2
run-w15-schema.ps1 — targeted tests와 격리 schema 관찰 owner 전체
param([ValidateSet('Compose','Container')][string]$Mode='Compose',[string]$Container='',[string]$ComposeProject='w15-schema-lab',[string]$EvidenceDir='evidence/w15',[string]$ProjectRoot='')
$ErrorActionPreference='Stop'
$referenceRoot=Split-Path -Parent $PSScriptRoot
$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot}
$e=if([IO.Path]::IsPathRooted($EvidenceDir)){[IO.Path]::GetFullPath($EvidenceDir)}else{[IO.Path]::GetFullPath((Join-Path $root $EvidenceDir))}
New-Item -ItemType Directory -Force $e|Out-Null
Push-Location $root
$ownedCompose=$false
try{
 & .\gradlew.bat test --tests 'com.example.financialcore.CoreSchemaIT' --tests 'com.example.financialcore.account.OpeningIntegrationTest' --no-daemon
 if($LASTEXITCODE-ne 0){throw "W15 Gradle exit=$LASTEXITCODE"}
 $expected=@('com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')
 $rows=@(Get-ChildItem build/test-results/test -Filter 'TEST-*.xml'|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})
 $classes=@($rows.class|Sort-Object -Unique)
 if(Compare-Object ($expected|Sort-Object) $classes){throw "W15 XML classes mismatch expected=$expected actual=$classes"}
 $tests=($rows|Measure-Object tests -Sum).Sum;$failures=($rows|Measure-Object failures -Sum).Sum;$errors=($rows|Measure-Object errors -Sum).Sum;$skipped=($rows|Measure-Object skipped -Sum).Sum
 if($tests-lt 2-or$failures-ne 0-or$errors-ne 0-or$skipped-ne 0){throw 'W15 XML not Green'}
 if($Mode-eq'Compose'){
   if($Container){throw 'W15 Compose mode does not accept -Container'}
   $composeFile=Join-Path $referenceRoot 'compose.yaml'
   if(!(Test-Path -LiteralPath $composeFile -PathType Leaf)){throw 'W15 compose.yaml missing'}
   if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w15-disposable-only-password'}
   # Own this unique project before startup so partial Compose creation is
   # removed by finally even when `up --wait` fails.
   $ownedCompose=$true
   & docker compose -f $composeFile -p $ComposeProject up -d --wait db
   if($LASTEXITCODE-ne 0){throw "W15 compose up exit=$LASTEXITCODE"}
   $Container=(& docker compose -f $composeFile -p $ComposeProject ps -q db).Trim()
   if($LASTEXITCODE-ne 0-or[string]::IsNullOrWhiteSpace($Container)){throw 'W15 compose container resolution failed'}
 }else{
   if([string]::IsNullOrWhiteSpace($Container)){throw 'W15 Container mode requires -Container'}
   $running=(& docker inspect -f '{{.State.Running}}|{{.Config.Image}}' $Container 2>$null).Trim()
   if($LASTEXITCODE-ne 0-or$running-cnotmatch'^true\|postgres:'){throw 'W15 supplied container is not a running PostgreSQL container'}
 }
 $v001=Get-Content -Raw (Join-Path $root 'src/main/resources/db/migration/V001__common.sql')
 $seed="INSERT INTO account(owner_id,account_no,status,currency,balance) VALUES('w15-a','W15-1','ACTIVE','KRW',100),('w15-b','W15-2','ACTIVE','KRW',0); INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at) VALUES('00000000-0000-0000-0000-000000000151','OPENING','COMPLETED','w15-1',now(),now()),('00000000-0000-0000-0000-000000000152','TRANSFER','COMPLETED','w15-2',now(),now()); INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000151',id,'OPENING',120,120,120,now() FROM account WHERE account_no='W15-1'; INSERT INTO ledger_entry(business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at) SELECT '00000000-0000-0000-0000-000000000152',id,'TRANSFER_OUT',20,-20,100,now() FROM account WHERE account_no='W15-1';"
 $setup="DO `$ddl`$ BEGIN IF EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='w15_schema') THEN EXECUTE 'DROP SCHEMA w15_schema CASCADE'; END IF; END `$ddl`$; CREATE SCHEMA w15_schema; SET search_path TO w15_schema;`n$v001`n$seed"
 $setup|docker exec -i $Container psql -v ON_ERROR_STOP=1 -U app -d financial_core|Out-Null
 if($LASTEXITCODE-ne 0){throw 'W15 isolated V001 apply failed'}
 $keys=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT table_name||'|'||constraint_type||'|'||constraint_name FROM information_schema.table_constraints WHERE table_schema='w15_schema' AND table_name IN ('account','business_tx','ledger_entry','idempotency_request') ORDER BY 1")
 if($LASTEXITCODE-ne 0){throw 'W15 keys query failed'}
 $schema=@(docker exec $Container psql -At -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT indexname||'|'||indexdef FROM pg_indexes WHERE schemaname='w15_schema' ORDER BY indexname")
 if($LASTEXITCODE-ne 0){throw 'W15 schema query failed'}
 $card=@(docker exec $Container psql -At -F ',' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT a.id,count(l.id) FROM w15_schema.account a LEFT JOIN w15_schema.ledger_entry l ON l.account_id=a.id GROUP BY a.id ORDER BY a.id")
 if($LASTEXITCODE-ne 0){throw 'W15 cardinality query failed'}
 if($card.Count-ne 2-or$card[0]-cne'1,2'-or$card[1]-cne'2,0'){throw "W15 exact cardinality mismatch: $card"}
 $keys|Set-Content -Encoding utf8 (Join-Path $e 'keys.txt');@('account_id,ledger_count')+$card|Set-Content -Encoding utf8 (Join-Path $e 'cardinality.csv');$schema|Set-Content -Encoding utf8 (Join-Path $e 'schema.txt')
 if(@($keys|Where-Object{$_-match'PRIMARY KEY'}).Count-lt 4-or@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count-lt 2-or@($keys|Where-Object{$_-match'UNIQUE'}).Count-lt 2){throw 'W15 key inventory incomplete'}
 $gate=[ordered]@{schema='w15_schema';migration='V001_once_per_fresh_schema';seed_accounts=2;cardinality_rows=2;cardinality_exact=@('1,2','2,0');keys=$keys.Count;fk=@($keys|Where-Object{$_-match'FOREIGN KEY'}).Count;unique=@($keys|Where-Object{$_-match'UNIQUE'}).Count;indexes=$schema.Count;tests=$tests;failures=0;errors=0;skipped=0}
 $gate|ConvertTo-Json|Set-Content -Encoding utf8 (Join-Path $e 'schema-gate.json')
 "W15_SCHEMA_GREEN keys=$($gate.keys) fk=$($gate.fk) unique=$($gate.unique) indexes=$($gate.indexes) tests=$($gate.tests) failures=0 errors=0 skipped=0"
}finally{
 if($ownedCompose){& docker compose -f (Join-Path $referenceRoot 'compose.yaml') -p $ComposeProject down -v;if($LASTEXITCODE-ne 0){Write-Error 'W15 compose cleanup failed'}}
 Pop-Location
}
06

Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율

illustrative/sql/W15-SQL-Q16.sql

fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F06
21줄 연결21줄 번역5 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

business_date별 SUCCESS·FAILED 수를 세고, 두 수가 모두 0일 때 NULL을 내는 선택적 성공률을 계산한다.

  1. 왜 timestamp를 date로 cast하지 않나?
  2. FILTER 두 개는 어떤 status만 세나?
  3. 0/0 비율은 왜 NULL인가?
이 파일에서 끝까지 다시 쓰는 값9 business days2026-07-02=0/0/NULL2026-07-01=2/1/66.672026-12-28=0/3/0.00
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · 두 count가 핵심히토리 → 니지카 → 료 → 키타
  1. 히토리

    비율만 계산하면 Q16이 끝난 건가요?

  2. 니지카

    아니야. prompt의 중심은 날짜별 SUCCESS와 FAILED count 두 개야.

  3. rate는 0분모를 설명하려고 덧붙인 선택 열이지 필수 답을 대체하지 않아.

  4. 키타

    2026-07-02에서 0/0과 NULL을 따로 읽어 핵심 열과 보조 열을 구분할게요.

02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

날짜별 두 계수기와 보조 퍼센트 창

Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율

히토리는 거래 전표를 business_date별로 묶은 뒤 SUCCESS와 FAILED 도장만 각자 센다.

키타는 두 count가 모두 0이면 비율 창을 NULL로 두고, 핵심 답은 언제나 두 count라고 확인한다.

딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

날짜별 묶기

business_date가 같은 transaction을 한 group으로 모은다.

코드 연결
7~14줄
비유
날짜별 두 계수기와 보조 퍼센트 창에서 날짜별 묶기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

상태별 세기

FILTER로 SUCCESS와 FAILED 행을 따로 count한다.

코드 연결
10~11줄
비유
날짜별 두 계수기와 보조 퍼센트 창에서 상태별 세기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

0분모 막기

NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.

코드 연결
19줄
비유
날짜별 두 계수기와 보조 퍼센트 창에서 0분모 막기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.fixture 기반 학습용 SQL 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.21 / 21 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F06-L01 -- W15-SQL-Q16 illustrative example; not a shipped workbook answer. 연습장 표지에 ‘Q16 참고 풀이’ 도장을 찍어 정답지 묶음과 구별한다. 이 파일을 W15-SQL-Q16 학습용 예시로 표시하고 배포된 workbook 정본 답안이 아니라고 한정한다.
입력
파일의 권위와 사용 범위를 먼저 판단해야 할 때 이 주석을 읽는다.
결과·효과
독자는 아래 SQL을 공식 채점 답이 아니라 검토 가능한 교육용 제안으로 분류한다.
비유의 한계
주석만으로 query 결과나 fixture 값이 실행 검증되지는 않는다.
2줄F06-L02 -- Input grain: one business_tx row; output grain: one row per business_date. 낱장 거래 전표를 날짜별 서랍 하나로 모으겠다는 분류 규칙을 붙인다. 입력 한 행은 business_tx이고 출력 한 행은 business_date 하나라는 grain을 선언한다.
입력
집계 전후 행이 무엇을 나타내는지 혼동될 때 grain 선언을 기준으로 삼는다.
결과·효과
집계 결과를 거래별이 아니라 날짜별 묶음으로 해석할 기준이 생긴다.
비유의 한계
설명 문장은 GROUP BY가 실제로 올바른지 자동으로 보장하지 않는다.
3줄F06-L03 -- business_date is already DATE, so the result does not depend on session TimeZone. 시계가 아니라 달력 칸 자체를 받은 셈이라 시간대 눈금을 다시 맞추지 않는다. business_date가 이미 DATE이므로 session TimeZone에 따라 날짜가 이동하지 않는다고 설명한다.
입력
같은 저장 행을 다른 session TimeZone에서 읽을 때 날짜 기준이 흔들리는지 따질 때 쓴다.
결과·효과
날짜 묶음의 경계가 timestamp 변환이 아니라 저장된 DATE 값으로 고정된다.
비유의 한계
DATE 사용은 원본 데이터의 날짜가 업무상 옳다는 사실까지 입증하지 않는다.
4줄F06-L04 -- Optional rate column teaches the prompt's zero-denominator boundary; counts remain the core output. 필수 두 계기판 옆에 0으로 나눌 때 꺼지는 보조 퍼센트 창을 단다. 비율 열은 0분모를 가르치는 선택 열이고 성공·실패 count가 핵심 출력임을 밝힌다.
입력
count 요구와 선택적 rate를 구분해 결과표를 검토하려 할 때 적용한다.
결과·효과
비율이 NULL인 날에도 두 count를 우선 대조해야 한다는 읽기 순서가 정해진다.
비유의 한계
선택 열을 추가했다고 workbook prompt의 필수 열이 늘어난 것은 아니다.
5줄F06-L05 SET search_path TO :"workbook_schema", public; 오늘 쓸 자료 서랍을 맨 앞에 놓고 공용 서랍은 두 번째에 둔다. psql 변수 workbook_schema를 첫 search_path로 두고 public을 그다음 탐색 위치로 둔다.
입력
동일한 table 이름이 여러 schema에 있을 수 있어 탐색 순서를 고정할 때 실행한다.
결과·효과
schema 접두사 없는 이름은 우선 격리 workbook namespace에서 해석된다.
비유의 한계
변수가 정의되었는지와 schema가 실제 존재하는지는 이 SET 문이 확인하지 않는다.
7줄F06-L07 WITH daily AS ( 최종 표 전에 날짜별 숫자를 계산할 작은 작업대 daily를 펼친다. 날짜별 중간 결과를 만들 daily 공통 테이블 식을 시작한다.
입력
한 번 계산한 일별 집계를 최종 SELECT에서 다시 읽으려 할 때 CTE를 연다.
결과·효과
뒤의 집계 projection을 담을 이름 있는 임시 relation의 범위가 열린다.
비유의 한계
WITH 이름만 선언한 시점에는 열이나 행이 아직 만들어지지 않는다.
8줄F06-L08 SELECT 날짜별 계산대 위에 필요한 칸을 놓기 시작하는 빈 양식을 꺼낸다. daily CTE가 내보낼 열 목록을 고르는 SELECT를 연다.
입력
CTE의 출력 열을 source relation보다 먼저 나열할 때 이 키워드가 시작점이다.
결과·효과
이어지는 날짜와 두 조건부 count 표현식이 projection 후보가 된다.
비유의 한계
SELECT 시작만으로 grouping 기준이나 count 조건은 결정되지 않는다.
9줄F06-L09 business_date AS business_day, 원본 달력표의 business_date 칸에 보고서용 business_day 이름표를 붙인다. business_date 값을 business_day라는 결과 열 이름으로 바꿔 내보낸다.
입력
저장 열 이름과 출력 열 이름을 구분하면서 같은 DATE 값을 보존할 때 쓴다.
결과·효과
각 daily 행에 저장 날짜를 담은 business_day 열이 생긴다.
비유의 한계
별칭은 날짜를 변환하거나 누락된 날짜 행을 새로 만들지 않는다.
10줄F06-L10 COUNT(*) FILTER (WHERE status = 'SUCCESS') AS success_count, 날짜 서랍 안에서 SUCCESS 도장이 찍힌 전표만 통과시키는 계수기를 둔다. status가 SUCCESS인 행만 COUNT하도록 FILTER하고 결과를 success_count로 부른다.
입력
같은 business_date에 여러 status가 섞여 있을 때 성공 행만 세려 할 때 사용한다.
결과·효과
현재 날짜 group의 성공 transaction 개수가 하나의 정수 열로 계산된다.
비유의 한계
SUCCESS 이외 상태를 실패로 간주하지 않으며 중복 행 원인도 검사하지 않는다.
11줄F06-L11 COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count FAILED 스티커 전표 전용으로 두 번째 숫자 바퀴를 돌린다. status가 FAILED인 행만 골라 세고 failed_count라는 이름을 준다.
입력
실패 건수를 SUCCESS count와 섞지 않고 나란히 계산해야 할 때 적용한다.
결과·효과
날짜 group마다 실패 transaction 수가 성공 수와 별도 열에 놓인다.
비유의 한계
UNKNOWN이나 PROCESSING을 failed_count에 포함시키지 않는다.
12줄F06-L12 FROM business_tx 날짜별 계수기에 넣을 원본 전표 상자를 business_tx로 선택한다. 조건부 count의 입력 relation을 business_tx로 정한다.
입력
앞에서 선언한 두 COUNT가 어느 행 집합을 읽는지 연결할 때 쓴다.
결과·효과
business_tx의 각 행이 날짜 group과 status filter 평가 후보로 들어간다.
비유의 한계
FROM만으로 fixture가 로드되었거나 행 수가 정확하다고 알 수 없다.
13줄F06-L13 GROUP BY business_date 달력 날짜가 같은 전표를 같은 칸에 포개고 칸별 합계를 낸다. business_date가 같은 입력 행을 하나의 집계 group으로 묶는다.
입력
입력 grain을 transaction 행에서 날짜 행으로 바꿔야 할 때 이 grouping key를 쓴다.
결과·효과
서로 다른 날짜마다 success_count와 failed_count 한 쌍이 생성된다.
비유의 한계
거래가 전혀 없는 날짜를 연속 달력 행으로 채워 주지는 않는다.
14줄F06-L14 ) 일별 계산을 마친 작업대에 괄호 덮개를 씌워 다음 단계로 넘긴다. daily CTE 정의를 닫는다.
입력
WITH 내부 projection·source·grouping의 문법 범위를 끝낼 때 닫는 괄호를 둔다.
결과·효과
날짜와 두 count를 가진 중간 relation을 뒤 SELECT가 참조할 수 있게 된다.
비유의 한계
닫는 기호 자체는 CTE 행의 oracle 값이나 정렬을 검사하지 않는다.
15줄F06-L15 SELECT 중간 계산표에서 사용자에게 보여 줄 칸만 옮길 새 보고서를 펼친다. daily 결과를 최종 표로 투영할 SELECT를 시작한다.
입력
CTE 계산을 마치고 외부 query 결과 모양을 정하려 할 때 사용한다.
결과·효과
최종 출력에 실을 날짜·count·비율 열을 고르는 단계로 제어가 넘어간다.
비유의 한계
이 키워드만으로 열 순서나 비율 공식은 아직 확정되지 않는다.
16줄F06-L16 business_day, 완성표 첫 칸에 해당 집계 서랍의 달력 날짜를 적는다. daily가 가진 business_day를 최종 첫 열로 출력한다.
입력
count와 rate가 속한 날짜를 결과에서 함께 보여 줄 때 선택한다.
결과·효과
각 결과 행을 어느 업무 날짜의 수치인지 식별할 수 있게 된다.
비유의 한계
날짜 열 출력은 빠진 업무 날짜를 보간하거나 기간을 제한하지 않는다.
17줄F06-L17 success_count, 성공 계수기의 숫자를 다시 계산하지 않고 완성표로 옮겨 적는다. 중간 relation의 success_count를 최종 결과에 그대로 싣는다.
입력
daily에서 이미 센 SUCCESS 수를 사용자 결과에 노출할 때 고른다.
결과·효과
성공 건수 정수가 business_day 옆 출력 열에 보존된다.
비유의 한계
projection은 count 산식의 정확성을 새로 검증하지 않는다.
18줄F06-L18 failed_count, 실패 숫자 바퀴의 값을 완성표의 별도 칸으로 복사한다. daily의 failed_count 열을 다음 최종 열로 선택한다.
입력
FAILED count를 비율 분모와 결과 대조에 함께 쓰려 할 때 출력한다.
결과·효과
날짜별 실패 건수가 성공 건수와 나란히 조회된다.
비유의 한계
이 열은 비성공 상태 전체의 수가 아니라 정확히 FAILED 행 수다.
19줄F06-L19 ROUND(100.0 * success_count / NULLIF(success_count + failed_count, 0), 2) AS success_rate_percent 두 계수 합이 0일 때 계산기를 멈추고, 아니면 100배 비율을 두 자리 눈금에 맞춘다. 성공 수를 성공+실패 합으로 나누되 NULLIF로 0분모를 NULL로 바꾸고 백분율을 소수 둘째 자리까지 반올림한다.
입력
일별 성공률을 표시하면서 0/0 오류를 피해야 할 때 이 산식을 평가한다.
결과·효과
분모가 양수면 success_rate_percent 숫자가, 두 count가 모두 0이면 NULL이 나온다.
비유의 한계
분모에는 SUCCESS와 FAILED만 들어가므로 다른 status는 비율에서 제외된다.
20줄F06-L20 FROM daily 완성표 재료를 원본 상자가 아닌 앞서 만든 daily 계산판에서 가져온다. 최종 SELECT가 읽을 relation을 daily로 지정한다.
입력
날짜별 숫자와 rate를 한 행씩 만들 입력을 연결할 때 사용한다.
결과·효과
출력 후보가 원본 transaction 행이 아니라 날짜별 CTE 행으로 제한된다.
비유의 한계
CTE 이름 참조만으로 search_path나 seed의 존재가 확인되지는 않는다.
21줄F06-L21 ORDER BY business_day; 달력표를 과거 날짜부터 미래 날짜 방향으로 정돈한다. business_day 오름차순으로 최종 행 순서를 정한다.
입력
fixture oracle을 반복 실행 결과와 같은 순서로 비교하려 할 때 정렬한다.
결과·효과
날짜별 결과가 이른 날부터 늦은 날까지 안정적으로 나열된다.
비유의 한계
ORDER BY는 count·rate 값이나 누락 날짜의 집합을 바꾸지 않는다.
22줄F06-L22 -- Fixture oracle: 9 days; 2026-07-02 is 0/0/NULL, 2026-07-01 is 2/1/66.67, 2026-12-28 is 0/3/0.00. 실행표 옆에 전체 아홉 칸과 세 날짜의 정답 눈금을 적은 작은 검산표를 붙인다. fixture 기대를 9일과 세 대표 날짜의 성공/실패/비율 값으로 기록한다.
입력
현재 workbook seed로 query 결과의 대표 정상·0분모·전부 실패 사례를 확인할 때 쓴다.
결과·효과
검토자는 2026-07-02 0/0/NULL, 2026-07-01 2/1/66.67, 2026-12-28 0/3/0.00을 대조점으로 얻는다.
비유의 한계
이 숫자는 fixture가 바뀌면 다시 계산해야 하며 운영 데이터 규칙이 아니다.
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · DATE 그대로 묶기히토리 → 니지카 → 료 → 키타
  1. 히토리

    occurred_at을 date로 잘라야 날짜별 집계 아닌가요?

  2. 니지카

    이 예시는 이미 업무 DATE인 business_date를 사용해서 변환이 필요 없어.

  3. timestamp cast를 넣으면 session TimeZone 경계가 새로 생길 수도 있어.

  4. 키타

    저장된 business_date가 group key인지 source 9·13줄 의미로 확인하겠습니다.

05

STEP 05 / 13

원본 코드 조각

원본을 5개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.

F06-C01 · provenance, grain, and optional-rate boundary1–4줄
1–4줄 원본
-- W15-SQL-Q16 illustrative example; not a shipped workbook answer.
-- Input grain: one business_tx row; output grain: one row per business_date.
-- business_date is already DATE, so the result does not depend on session TimeZone.
-- Optional rate column teaches the prompt's zero-denominator boundary; counts remain the core output.
F06-C02 · isolated workbook schema5–5줄
5–5줄 원본
SET search_path TO :"workbook_schema", public;
F06-C03 · daily conditional counts6–14줄
6–14줄 원본

WITH daily AS (
    SELECT
        business_date AS business_day,
        COUNT(*) FILTER (WHERE status = 'SUCCESS') AS success_count,
        COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count
    FROM business_tx
    GROUP BY business_date
)
F06-C04 · zero-safe rate and stable order15–21줄
15–21줄 원본
SELECT
    business_day,
    success_count,
    failed_count,
    ROUND(100.0 * success_count / NULLIF(success_count + failed_count, 0), 2) AS success_rate_percent
FROM daily
ORDER BY business_day;
F06-C05 · fixture oracle22–22줄
22–22줄 원본
-- Fixture oracle: 9 days; 2026-07-02 is 0/0/NULL, 2026-07-01 is 2/1/66.67, 2026-12-28 is 0/3/0.00.
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 21줄을 모두 한국어로 옮깁니다.

전체 번역 21 / 21

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1-- W15-SQL-Q16 illustrative example; not a shipped workbook answer.이 파일을 W15-SQL-Q16 학습용 예시로 표시하고 배포된 workbook 정본 답안이 아니라고 한정한다.
2-- Input grain: one business_tx row; output grain: one row per business_date.입력 한 행은 business_tx이고 출력 한 행은 business_date 하나라는 grain을 선언한다.
3-- business_date is already DATE, so the result does not depend on session TimeZone.business_date가 이미 DATE이므로 session TimeZone에 따라 날짜가 이동하지 않는다고 설명한다.
4-- Optional rate column teaches the prompt's zero-denominator boundary; counts remain the core output.비율 열은 0분모를 가르치는 선택 열이고 성공·실패 count가 핵심 출력임을 밝힌다.
5SET search_path TO :"workbook_schema", public;psql 변수 workbook_schema를 첫 search_path로 두고 public을 그다음 탐색 위치로 둔다.
7WITH daily AS (날짜별 중간 결과를 만들 daily 공통 테이블 식을 시작한다.
8 SELECTdaily CTE가 내보낼 열 목록을 고르는 SELECT를 연다.
9 business_date AS business_day,business_date 값을 business_day라는 결과 열 이름으로 바꿔 내보낸다.
10 COUNT(*) FILTER (WHERE status = 'SUCCESS') AS success_count,status가 SUCCESS인 행만 COUNT하도록 FILTER하고 결과를 success_count로 부른다.
11 COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_countstatus가 FAILED인 행만 골라 세고 failed_count라는 이름을 준다.
12 FROM business_tx조건부 count의 입력 relation을 business_tx로 정한다.
13 GROUP BY business_datebusiness_date가 같은 입력 행을 하나의 집계 group으로 묶는다.
14)daily CTE 정의를 닫는다.
15SELECTdaily 결과를 최종 표로 투영할 SELECT를 시작한다.
16 business_day,daily가 가진 business_day를 최종 첫 열로 출력한다.
17 success_count,중간 relation의 success_count를 최종 결과에 그대로 싣는다.
18 failed_count,daily의 failed_count 열을 다음 최종 열로 선택한다.
19 ROUND(100.0 * success_count / NULLIF(success_count + failed_count, 0), 2) AS success_rate_percent성공 수를 성공+실패 합으로 나누되 NULLIF로 0분모를 NULL로 바꾸고 백분율을 소수 둘째 자리까지 반올림한다.
20FROM daily최종 SELECT가 읽을 relation을 daily로 지정한다.
21ORDER BY business_day;business_day 오름차순으로 최종 행 순서를 정한다.
22-- Fixture oracle: 9 days; 2026-07-02 is 0/0/NULL, 2026-07-01 is 2/1/66.67, 2026-12-28 is 0/3/0.00.fixture 기대를 9일과 세 대표 날짜의 성공/실패/비율 값으로 기록한다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

business_date별 SUCCESS·FAILED 수를 세고, 두 수가 모두 0일 때 NULL을 내는 선택적 성공률을 계산한다.

문법 해부

  • business_date가 같은 transaction을 한 group으로 모은다.
  • FILTER로 SUCCESS와 FAILED 행을 따로 count한다.
  • NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.

실행 순서

  1. SUCCESS 2와 FAILED 1을 조건부 count
  2. 두 filter가 모두 0을 반환
  3. FAILED 세 행만 count

원래 W6 수준의 조각별 정밀 해설

F06-C01 · provenance, grain, and optional-rate boundary
문법 해부
네 주석이 Q16의 illustrative 지위, transaction→business_date grain, DATE/TimeZone 경계, count 핵심과 optional rate 역할을 차례로 선언한다.
실제 값 추적
독자는 공식 답안이 아닌 날짜별 집계 예시이며 비율보다 성공·실패 두 count를 우선 읽어야 한다는 계약을 얻는다.
정상 예
business_date가 이미 DATE인 fixture에서 transaction 여러 행을 날짜 한 행으로 묶는 설명은 현재 주석과 맞는다.
틀린 예·반례
이 머리말만 보고 SQL이 실제로 group하거나 zero-denominator를 막는다고 판단하면 실행 본문을 건너뛴 셈이다.
착각 방지
optional rate는 prompt의 core output을 대체하지 않고 UNKNOWN·PROCESSING 포함 여부도 이 범위가 확정하지 않는다.
하지 않는 일
주석은 query 실행·fixture loading·전체 날짜 결과 재현을 맡지 않는다.
다음 연결
다음 ‘isolated workbook schema’ 범위가 schema 접두사 없는 relation의 search_path를 고정한다.
F06-C02 · isolated workbook schema
문법 해부
SET search_path가 psql workbook_schema 변수를 첫 namespace로, public을 다음 위치로 둔다.
실제 값 추적
뒤에서 schema qualifier 없이 쓰는 이름은 격리 workbook schema에서 우선 탐색된다.
정상 예
올바른 `workbook_schema` 변수가 주입된 psql session에서 SET이 성공하는 것이 유효한 예다.
틀린 예·반례
변수가 없거나 가리킨 schema가 잘못돼도 이 한 줄이 fixture를 만들거나 seed하지는 않는다.
착각 방지
search_path 순서는 이름 해석 계약이지 table content의 정답 보증이 아니다.
하지 않는 일
권한·schema lifecycle·public fallback의 보안 정책은 외부 실행 환경 책임이다.
다음 연결
다음 ‘daily conditional counts’ 범위가 daily CTE에서 business_date별 SUCCESS와 FAILED를 따로 센다.
F06-C03 · daily conditional counts
문법 해부
daily CTE가 business_date를 business_day로 이름 바꾸고 두 FILTER COUNT를 business_tx에서 계산해 날짜별 group을 만든다.
실제 값 추적
각 저장 날짜마다 SUCCESS 수와 FAILED 수가 한 행의 success_count·failed_count로 materialize된다.
정상 예
한 날짜에 SUCCESS와 FAILED가 함께 있으면 중간 relation은 두 상태의 count를 별도 열에 가진다.
틀린 예·반례
거래가 전혀 없는 날짜는 source row가 없으므로 이 GROUP BY만으로 빈 달력 행을 생성하지 못한다.
착각 방지
COUNT FILTER는 정확히 두 status만 세며 나머지 상태를 실패로 합치지 않는다.
하지 않는 일
이 CTE 범위는 비율 계산·최종 정렬·대표 fixture 숫자 대조를 아직 수행하지 않는다.
다음 연결
다음 ‘zero-safe rate and stable order’ 범위가 daily 두 count를 출력하고 NULLIF·ROUND 비율과 날짜 정렬을 붙인다.
F06-C04 · zero-safe rate and stable order
문법 해부
외부 SELECT가 business_day와 두 count를 내보내고 성공/(성공+실패)를 NULLIF 0분모·ROUND 2자리로 계산한 뒤 날짜 오름차순 정렬한다.
실제 값 추적
분모가 양수면 percent 숫자가 나오고 success_count와 failed_count가 둘 다 0이면 success_rate_percent는 NULL이 된다.
정상 예
분모가 양수인 count 조합에서 백분율이 소수 둘째 자리로 반올림되는 것이 산식의 정상 예다.
틀린 예·반례
zero-denominator 결과를 COALESCE로 숫자 영으로 바꾸면 ‘관측 분모 없음’과 ‘성공률 영’ 경계가 사라진다.
착각 방지
다른 status는 두 count 합에 없으므로 전체 transaction 대비 성공률로 이름을 확대하지 않는다.
하지 않는 일
ORDER BY는 결과 표시를 안정화할 뿐 빠진 날짜나 잘못된 fixture를 고치지 않는다.
다음 연결
다음 ‘fixture oracle’ 한 줄이 총 9일과 세 날짜의 exact count/rate를 검산점으로 제공한다.
F06-C05 · fixture oracle
문법 해부
마지막 주석이 9 days와 2026-07-02 0/0/NULL, 2026-07-01 2/1/66.67, 2026-12-28 0/3/0.00을 명시한다.
실제 값 추적
정상·0분모·전부 실패를 대표하는 세 행과 전체 날짜 cardinality가 회귀 대조표가 된다.
정상 예
현재 V900 workbook seed에서 query 결과가 이 세 tuple과 총 9행을 보이면 주석 oracle과 일치한다.
틀린 예·반례
seed 행이 바뀐 뒤에도 같은 숫자를 고정 기대하면 fixture-bound 전제를 어긴다.
착각 방지
세 대표 tuple은 나머지 여섯 날짜 값까지 각각 열거해 증명하지 않는다.
하지 않는 일
이 주석은 shipped canonical answer나 운영 SLA를 선언하지 않는다.
다음 연결
이 SQL의 끝이다. 전체를 복습할 때 DATE grain, 두 FILTER, NULLIF와 fixture 전용 숫자를 한 흐름으로 묶는다.
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · 상태 FILTER히토리 → 니지카 → 료 → 키타
  1. 히토리

    FAILED가 아니면 전부 SUCCESS count에 들어가나요?

  2. 니지카

    각 COUNT는 status가 정확히 SUCCESS 또는 FAILED인 행만 골라.

  3. UNKNOWN과 PROCESSING은 두 count에도 rate 분모에도 포함되지 않아.

  4. 키타

    같은 날짜의 status별 행을 두 filter에 넣어 서로 겹치지 않는지 추적할게요.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
12026-07-01 rowsSUCCESS 2와 FAILED 1을 조건부 count2/1/66.67다른 status는 분모 제외
22026-07-02 rows두 filter가 모두 0을 반환0/0/NULLNULL은 계산 오류가 아니라 명시한 경계
32026-12-28 rowsFAILED 세 행만 count0/3/0.00fixture 값에 한정
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · NULLIF 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    0건인 날 성공률을 0.00으로 두면 더 보기 쉽지 않나요?

  2. 니지카

    성공도 실패도 없으면 0/0이라 성공 0%와 다른 상태야.

  3. NULLIF가 분모 0을 NULL로 바꿔 division error도 막고 정보 부재도 보존해.

  4. 키타

    2026-07-02 결과가 0, 0, NULL인지 oracle과 맞춰 보겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

PostgreSQL aggregate

FILTER가 같은 group의 행을 두 조건으로 독립 집계한다.

planner나 index 성능은 측정하지 않는다.
numeric expression

NULLIF가 0을 NULL로 바꾸고 ROUND가 소수 둘째 자리에 맞춘다.

SUCCESS·FAILED 이외 status는 rate 분모에 없다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ occurred_at::date가 반드시 필요하다

왜 틀리나 source는 업무 DATE인 business_date를 이미 가진다

바르게 읽기 business_date로 직접 group한다

반례 session TimeZone 변환을 끼우면 날짜 의미가 달라질 수 있다

❌ 0건인 날의 성공률은 0이다

왜 틀리나 0/0은 성공 0%와 다른 정보 부재 상태다

바르게 읽기 NULLIF로 NULL을 반환한다

반례 2026-07-02는 0/0/NULL이다

❌ 모든 status가 비율 분모다

왜 틀리나 두 FILTER는 SUCCESS와 FAILED만 센다

바르게 읽기 분모도 두 count의 합만 쓴다

반례 UNKNOWN·PROCESSING은 제외된다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

정본성

workbook이 배포한 canonical answer를 뜻하지 않는다

이 책임을 맡는 곳: 학습 과제 계약
날짜 연속성

거래가 전혀 없는 달력 날짜를 생성하지 않는다

이 책임을 맡는 곳: calendar relation 또는 generate_series
운영 일반화

9일과 세 대표 값은 현재 fixture에만 맞는다

이 책임을 맡는 곳: fixture version
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · fixture 세 날짜히토리 → 니지카 → 료 → 키타
  1. 히토리

    세 대표 날짜 값이면 모든 9일 결과를 다 증명하나요?

  2. 니지카

    아니야. 주석은 전체 9일 수와 대표 정상·0분모·전부 실패 사례만 고정해.

  3. 나머지 날짜도 실제 query 결과로 대조해야 하고 seed 변경 뒤 숫자는 다시 계산해야 해.

  4. 키타

    7월 1일 2/1/66.67과 12월 28일 0/3/0.00을 우선 회귀점으로 삼을게요.

12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

  • 왜 timestamp를 date로 cast하지 않나?
  • FILTER 두 개는 어떤 status만 세나?
  • 0/0 비율은 왜 NULL인가?

2단계 · 코드 조각 재조립

  1. business_date가 같은 transaction을 한 group으로 모은다.
  2. FILTER로 SUCCESS와 FAILED 행을 따로 count한다.
  3. NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.

3단계 · 파일 전체 다시 쓰기

illustrative/sql/W15-SQL-Q16.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture 숫자와 ID를 정확히 적었는가
  • query shape와 실행 증거를 구분했는가
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님 · fixture-bound illustrative SQLillustrative/sql/W15-SQL-Q16.sqlSHA-256 ed469408c0d4d6ebef833d9e65c26a67c92d6316fd230df8b86bdb6bd73a3580
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 전체
-- W15-SQL-Q16 illustrative example; not a shipped workbook answer.
-- Input grain: one business_tx row; output grain: one row per business_date.
-- business_date is already DATE, so the result does not depend on session TimeZone.
-- Optional rate column teaches the prompt's zero-denominator boundary; counts remain the core output.
SET search_path TO :"workbook_schema", public;

WITH daily AS (
    SELECT
        business_date AS business_day,
        COUNT(*) FILTER (WHERE status = 'SUCCESS') AS success_count,
        COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count
    FROM business_tx
    GROUP BY business_date
)
SELECT
    business_day,
    success_count,
    failed_count,
    ROUND(100.0 * success_count / NULLIF(success_count + failed_count, 0), 2) AS success_rate_percent
FROM daily
ORDER BY business_day;
-- Fixture oracle: 9 days; 2026-07-02 is 0/0/NULL, 2026-07-01 is 2/1/66.67, 2026-12-28 is 0/3/0.00.
07

Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기

illustrative/sql/W15-SQL-Q21.sql

fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F07
15줄 연결15줄 번역5 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

전체 account의 AVG(balance)를 한 scalar로 계산하고 그 값보다 balance가 큰 account 행만 남긴다.

  1. 왜 subquery가 한 값만 반환하나?
  2. 빈 account table이면 비교 결과는 어떻게 되나?
  3. CLOSED account 107은 왜 포함되나?
이 파일에서 끝까지 다시 쓰는 값average=266287.375account_id=106/107/108rows=3status filter 없음
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · scalar AVG히토리 → 니지카 → 료 → 키타
  1. 히토리

    AVG subquery가 account 수만큼 행을 내놓나요?

  2. 니지카

    aggregate 결과는 전체 집합을 요약한 한 행이고 값은 평균 하나야.

  3. 외부 a 행들은 그 같은 scalar와 각자 balance를 비교해.

  4. 키타

    266287.375가 오른쪽 한 값으로 들어가는 흐름을 먼저 그려 볼게요.

02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

전체 잔액 평균선을 넘는 계좌 명단

Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기

니지카는 모든 account balance로 평균선 하나를 만든 다음 계좌 카드를 한 장씩 그 선과 비교한다.

료는 prompt에 status 조건이 없으므로 CLOSED 107도 제외하지 않고, 빈 table에서는 AVG가 NULL이라 어느 행도 TRUE가 되지 않는다고 적는다.

딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

평균 하나 만들기

상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.

코드 연결
12~15줄
비유
전체 잔액 평균선을 넘는 계좌 명단에서 평균 하나 만들기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

큰 값만 남기기

a.balance > average가 TRUE인 account만 통과한다.

코드 연결
12줄
비유
전체 잔액 평균선을 넘는 계좌 명단에서 큰 값만 남기기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

안정적으로 정렬

balance 뒤 account_id로 표시 순서를 고정한다.

코드 연결
16줄
비유
전체 잔액 평균선을 넘는 계좌 명단에서 안정적으로 정렬 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 15줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.fixture 기반 학습용 SQL 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.15 / 15 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F07-L01 -- W15-SQL-Q21 illustrative example; not a shipped workbook answer. 평균 비교 연습지에 ‘참고 풀이’ 표찰을 달아 정본 서류와 떼어 둔다. W15-SQL-Q21이 배포 정답이 아닌 학습용 예시임을 선언한다.
입력
SQL을 답안으로 인용하기 전에 provenance를 확인할 때 이 머리말을 본다.
결과·효과
아래 query의 권위가 공식 답안이 아니라 fixture-bound 설명으로 제한된다.
비유의 한계
표찰은 평균 값이나 선별 행을 실행으로 증명하지 않는다.
2줄F07-L02 -- The uncorrelated scalar subquery returns exactly one AVG row, including when account is empty. 계좌 상자가 비어도 평균 계산대는 빈칸 하나가 든 봉투를 돌려준다. 상관되지 않은 scalar subquery의 AVG는 account가 비어도 NULL 한 행을 반환한다고 설명한다.
입력
scalar aggregate가 외부 WHERE에 몇 개 값을 공급하는지 판단할 때 읽는다.
결과·효과
외부 비교의 오른쪽 입력은 다중 행이 아니라 한 개의 평균 값 또는 NULL이 된다.
비유의 한계
이 성질을 aggregate가 없는 일반 subquery의 행 수 규칙으로 확대하면 안 된다.
3줄F07-L03 -- Fixture oracle: average balance 266287.375; account_id 106, 107, and 108 qualify. 평균선 위에 서야 할 106, 107, 108 세 번호표를 검산 카드에 적는다. 현재 fixture 평균 266287.375와 조건을 통과하는 account_id 106·107·108을 oracle로 적는다.
입력
제공 seed에서 query 결과의 값과 cardinality를 대조할 때 사용한다.
결과·효과
검토할 exact 결과 집합이 세 account 행으로 고정된다.
비유의 한계
평균과 ID 목록은 현재 fixture 전용이며 balance 변경 뒤에는 달라진다.
4줄F07-L04 SET search_path TO :"workbook_schema", public; 평균을 낼 계좌 서랍으로 workbook 전용 칸을 검색대 첫 자리에 올린다. workbook_schema를 우선하고 public을 보조로 하는 psql search_path를 설정한다.
입력
다른 schema의 같은 이름을 잘못 읽지 않도록 namespace 순서를 정할 때 실행한다.
결과·효과
접두사 없는 account가 격리 workbook table로 먼저 해석된다.
비유의 한계
schema 변수 주입이나 fixture 적재 실패를 이 명령 하나가 해결하지 않는다.
6줄F07-L06 SELECT 평균선 통과자 명단에 어떤 신상 칸을 실을지 빈 양식을 펼친다. 평균보다 큰 account 행에서 보여 줄 열을 고르는 SELECT를 연다.
입력
필터와 source를 붙이기 전에 결과 열 목록을 선언하려 할 때 사용한다.
결과·효과
뒤의 네 account 속성이 최종 projection 후보가 된다.
비유의 한계
SELECT 시작만으로 평균 비교나 행 순서는 결정되지 않는다.
7줄F07-L07 a.account_id, 선발 명단 첫 칸에 각 계좌의 고유 번호표를 옮긴다. 별칭 a의 account_id를 결과 식별 열로 선택한다.
입력
oracle의 ID 목록과 실제 결과 행을 맞출 식별자가 필요할 때 출력한다.
결과·효과
통과한 각 계좌를 106 같은 내부 ID로 구별할 수 있다.
비유의 한계
ID 자체는 customer 소유권이나 balance가 평균보다 큰 이유를 설명하지 않는다.
8줄F07-L08 a.customer_id, 계좌 번호표 옆에 소유 고객의 번호 꼬리표를 붙인다. 각 결과 account의 customer_id를 두 번째 열에 싣는다.
입력
선별된 계좌를 customer별로 해석할 필요가 있을 때 projection에 포함한다.
결과·효과
평균 초과 계좌가 어느 customer에 속하는지 함께 보인다.
비유의 한계
customer_id 출력은 customer table 존재나 상태를 검증하지 않는다.
9줄F07-L09 a.account_no, 기계 번호 옆에 사람이 알아볼 계좌 명패를 달아 준다. 사람이 읽는 account_no 값을 결과에 포함한다.
입력
fixture 결과를 화면에서 계좌 번호로 확인하려 할 때 선택한다.
결과·효과
내부 ID 외에 A-* 형태의 계좌 식별 문자열이 표시된다.
비유의 한계
표시만으로 account_no의 UNIQUE 제약이 살아 있음을 증명하지 않는다.
10줄F07-L10 a.balance 선발된 계좌마다 심사에 사용한 잔액 숫자를 명단에 적는다. 비교 대상이자 표시값인 a.balance를 최종 열로 고른다.
입력
왜 해당 행이 평균보다 큰지 값으로 재검산하려 할 때 출력한다.
결과·효과
각 통과 행에 평균선과 대조할 실제 잔액이 노출된다.
비유의 한계
balance 열은 통화 단위나 잔액 생성 이력을 알려 주지 않는다.
11줄F07-L11 FROM account AS a 전체 계좌 상자를 a라는 심사용 선반에 올려놓는다. 외부 query의 후보 relation을 account 별칭 a로 정한다.
입력
projection과 WHERE가 참조할 외부 행 source를 연결할 때 사용한다.
결과·효과
account의 모든 행이 평균 초과 비교를 받을 초기 후보가 된다.
비유의 한계
FROM 단계는 status별 제외나 빈 table 처리를 따로 추가하지 않는다.
12줄F07-L12 WHERE a.balance > ( 각 잔액표를 오른쪽에서 받을 평균 기준선보다 위인지 검사한다. 외부 account balance가 괄호 안 scalar 값보다 큰 행만 남기는 조건을 시작한다.
입력
한 행의 a.balance와 전체 집합 요약값을 비교하려 할 때 WHERE를 평가한다.
결과·효과
비교가 TRUE인 외부 account만 결과 후보에 유지된다.
비유의 한계
같은 값은 > 조건을 통과하지 않으며 NULL 비교도 TRUE가 아니다.
13줄F07-L13 SELECT AVG(peer.balance) 모든 peer 잔액을 한 저울에 올려 하나의 평균 눈금을 만든다. 괄호 안에서 peer.balance 전체의 AVG를 한 값으로 계산한다.
입력
외부 계좌마다 공통으로 쓸 전체 account 평균이 필요할 때 aggregate를 실행한다.
결과·효과
외부 WHERE 오른쪽에 현재 fixture의 평균 scalar가 공급된다.
비유의 한계
AVG는 NULL balance를 제외하며 입력 없음에는 NULL을 반환한다.
14줄F07-L14 FROM account AS peer 심사 중인 한 계좌와 구분해 평균용 전체 상자에 peer 이름을 붙인다. 평균 계산의 입력을 같은 account table의 peer 별칭으로 지정한다.
입력
상관되지 않은 전체 평균의 source 역할을 명확히 나눌 때 별칭을 쓴다.
결과·효과
scalar subquery가 외부 a 한 행이 아니라 전체 peer 행 집합을 읽는다.
비유의 한계
별칭만으로 status filter나 customer별 partition이 생기지는 않는다.
15줄F07-L15 ) 평균 계산 봉투를 닫아 잔액 심사표의 오른쪽 칸에 꽂는다. 평균 scalar subquery의 괄호를 닫아 비교식을 완성한다.
입력
내부 aggregate 범위를 끝내고 외부 행 필터로 돌아갈 때 닫는다.
결과·효과
a.balance > AVG(peer.balance)가 하나의 Boolean WHERE 조건이 된다.
비유의 한계
괄호 종료는 oracle 세 행이나 정렬을 스스로 검사하지 않는다.
16줄F07-L16 ORDER BY a.balance, a.account_id; 평균선 위 명단을 잔액이 작은 순으로 놓고 동률이면 계좌 번호로 잇는다. balance 오름차순 뒤 account_id를 tie-breaker로 사용해 결과를 정렬한다.
입력
반복 실행 결과를 동일한 순서로 oracle과 대조하려 할 때 적용한다.
결과·효과
세 통과 행이 잔액 순서로 안정적으로 표시되고 같은 잔액은 ID로 정돈된다.
비유의 한계
ORDER BY는 어떤 account가 통과하는지나 평균 값을 바꾸지 않는다.
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · 빈 account히토리 → 니지카 → 료 → 키타
  1. 히토리

    table이 비면 scalar subquery가 0행이라 오류인가요?

  2. 니지카

    AVG aggregate는 한 행을 반환하지만 그 값이 NULL이야.

  3. balance > NULL은 UNKNOWN이라 WHERE를 통과하는 외부 행도 없어.

  4. 키타

    aggregate 한 행과 일반 다중행 subquery를 구분해 경계를 적겠습니다.

05

STEP 05 / 13

원본 코드 조각

원본을 5개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.

F07-C01 · provenance and scalar oracle1–3줄
1–3줄 원본
-- W15-SQL-Q21 illustrative example; not a shipped workbook answer.
-- The uncorrelated scalar subquery returns exactly one AVG row, including when account is empty.
-- Fixture oracle: average balance 266287.375; account_id 106, 107, and 108 qualify.
F07-C02 · isolated workbook schema4–4줄
4–4줄 원본
SET search_path TO :"workbook_schema", public;
F07-C03 · account projection5–11줄
5–11줄 원본

SELECT
    a.account_id,
    a.customer_id,
    a.account_no,
    a.balance
FROM account AS a
F07-C04 · one-row average subquery12–15줄
12–15줄 원본
WHERE a.balance > (
    SELECT AVG(peer.balance)
    FROM account AS peer
)
F07-C05 · stable result order16–16줄
16–16줄 원본
ORDER BY a.balance, a.account_id;
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 15줄을 모두 한국어로 옮깁니다.

전체 번역 15 / 15

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1-- W15-SQL-Q21 illustrative example; not a shipped workbook answer.W15-SQL-Q21이 배포 정답이 아닌 학습용 예시임을 선언한다.
2-- The uncorrelated scalar subquery returns exactly one AVG row, including when account is empty.상관되지 않은 scalar subquery의 AVG는 account가 비어도 NULL 한 행을 반환한다고 설명한다.
3-- Fixture oracle: average balance 266287.375; account_id 106, 107, and 108 qualify.현재 fixture 평균 266287.375와 조건을 통과하는 account_id 106·107·108을 oracle로 적는다.
4SET search_path TO :"workbook_schema", public;workbook_schema를 우선하고 public을 보조로 하는 psql search_path를 설정한다.
6SELECT평균보다 큰 account 행에서 보여 줄 열을 고르는 SELECT를 연다.
7 a.account_id,별칭 a의 account_id를 결과 식별 열로 선택한다.
8 a.customer_id,각 결과 account의 customer_id를 두 번째 열에 싣는다.
9 a.account_no,사람이 읽는 account_no 값을 결과에 포함한다.
10 a.balance비교 대상이자 표시값인 a.balance를 최종 열로 고른다.
11FROM account AS a외부 query의 후보 relation을 account 별칭 a로 정한다.
12WHERE a.balance > (외부 account balance가 괄호 안 scalar 값보다 큰 행만 남기는 조건을 시작한다.
13 SELECT AVG(peer.balance)괄호 안에서 peer.balance 전체의 AVG를 한 값으로 계산한다.
14 FROM account AS peer평균 계산의 입력을 같은 account table의 peer 별칭으로 지정한다.
15)평균 scalar subquery의 괄호를 닫아 비교식을 완성한다.
16ORDER BY a.balance, a.account_id;balance 오름차순 뒤 account_id를 tie-breaker로 사용해 결과를 정렬한다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

전체 account의 AVG(balance)를 한 scalar로 계산하고 그 값보다 balance가 큰 account 행만 남긴다.

문법 해부

  • 상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.
  • a.balance > average가 TRUE인 account만 통과한다.
  • balance 뒤 account_id로 표시 순서를 고정한다.

실행 순서

  1. AVG를 한 번 계산
  2. 각 balance를 평균과 비교
  3. AVG가 NULL인 scalar 한 행 반환

원래 W6 수준의 조각별 정밀 해설

F07-C01 · provenance and scalar oracle
문법 해부
세 주석이 Q21의 illustrative 지위, uncorrelated scalar AVG 한 행 성질, fixture 평균 266287.375와 qualifying ID 106·107·108을 밝힌다.
실제 값 추적
공식 답안이 아닌 전체 account 평균 비교 예시와 세 행 exact oracle이 독자에게 전달된다.
정상 예
account가 비어 AVG 값이 NULL이어도 aggregate 결과 행 하나라는 설명은 이 범위의 scalar 경계에 맞는다.
틀린 예·반례
평균 주석만 보고 외부 WHERE가 실제로 >를 쓰거나 status를 제외한다고 가정하면 안 된다.
착각 방지
106·107·108은 현재 fixture 값이며 CLOSED 107도 prompt에 status filter가 없어 포함된 결과다.
하지 않는 일
이 머리말은 execution plan·index 사용·balance 변동 뒤 새 평균을 책임지지 않는다.
다음 연결
다음 ‘isolated workbook schema’ 범위가 account 이름의 namespace 우선순위를 정한다.
F07-C02 · isolated workbook schema
문법 해부
SET search_path가 workbook_schema 변수를 public보다 앞에 둔다.
실제 값 추적
이후 unqualified account는 격리 workbook relation을 먼저 찾게 된다.
정상 예
fixture schema 이름이 올바르게 psql 변수에 들어간 session에서 SET이 적용되는 것이 정상이다.
틀린 예·반례
search_path를 설정했다고 account row나 V900 seed가 자동 생성되지는 않는다.
착각 방지
namespace 해석 성공과 query oracle 일치는 별개의 확인 단계다.
하지 않는 일
role 권한·schema 신뢰·동명 object 공격 방지는 이 예시 밖의 운영 책임이다.
다음 연결
다음 ‘account projection’ 범위가 외부 account 후보와 네 표시 열을 만든다.
F07-C03 · account projection
문법 해부
SELECT가 a.account_id·customer_id·account_no·balance를 고르고 FROM account AS a로 외부 후보 relation을 연다.
실제 값 추적
account의 각 행은 account_id·customer_id·account_no·balance 네 값을 가진 외부 candidate relation으로 구성된다.
정상 예
fixture의 모든 account를 a 역할로 한 번씩 읽고 ID와 소유자·번호·잔액을 보존하는 흐름이 유효하다.
틀린 예·반례
projection에 status가 없다고 CLOSED 행이 자동 제거되거나 ACTIVE로 바뀌는 것은 아니다.
착각 방지
별칭 a는 역할 이름이며 customer별 partition이나 average 계산을 자체로 만들지 않는다.
하지 않는 일
이 범위는 어느 account가 평균을 넘는지 아직 판정하지 않는다.
다음 연결
다음 ‘one-row average subquery’ 범위가 a.balance를 전체 peer.balance AVG scalar와 strict >로 비교한다.
F07-C04 · one-row average subquery
문법 해부
WHERE가 a.balance > (SELECT AVG(peer.balance) FROM account AS peer) 조건으로 외부 행을 전체 평균 scalar와 비교한다.
실제 값 추적
현재 fixture에서는 AVG 266287.375보다 큰 balance의 account만 Boolean TRUE가 된다.
정상 예
account 106·107·108의 balance가 평균선 위라 세 외부 행이 남는 것이 exact fixture 예다.
틀린 예·반례
입력 table이 비면 AVG 값은 NULL이고 `a.balance > NULL`은 TRUE가 아니어서 결과 행도 없다.
착각 방지
subquery에 outer alias 참조가 없으므로 customer별 평균이 아니라 전체 account 평균이다.
하지 않는 일
strict >는 평균과 같은 balance를 제외하며 status별 필터나 통화별 평균을 추가하지 않는다.
다음 연결
다음 ‘stable result order’ 범위가 balance와 account_id 순으로 세 결과 행의 표시를 고정한다.
F07-C05 · stable result order
문법 해부
ORDER BY a.balance, a.account_id가 잔액 오름차순과 ID tie-breaker를 적용한다.
실제 값 추적
qualifying account들은 작은 balance부터 나열되고 같은 balance라면 account_id 순서가 안정된다.
정상 예
동일 fixture를 반복 조회해도 oracle 행을 같은 정렬 기준으로 비교할 수 있는 상태가 정상이다.
틀린 예·반례
정렬 열을 바꿔도 membership 자체는 같을 수 있지만 source와 exact 표시 순서는 달라진다.
착각 방지
ORDER BY가 평균 계산을 한 번만 실행하게 하거나 index scan을 보장하지 않는다.
하지 않는 일
이 줄은 새 filter를 더하지 않으며 closed account 107의 포함 여부를 바꾸지 않는다.
다음 연결
이 query의 끝이다. 복습할 때는 전체 scalar AVG, strict >, 세 ID와 fixture-only 평균을 함께 확인한다.
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · CLOSED 107히토리 → 니지카 → 료 → 키타
  1. 히토리

    닫힌 account 107은 자동으로 제외되죠?

  2. 니지카

    이 SQL에는 status filter가 없고 prompt도 전체 account 평균 비교만 요구해.

  3. 따라서 107의 balance가 평균보다 크면 그대로 결과에 포함돼.

  4. 키타

    oracle ID 106·107·108 중 107을 지우지 않았는지 확인하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
1fixture account balancesAVG를 한 번 계산266287.375 scalarfixture가 바뀌면 평균 재계산
2account 106/107/108각 balance를 평균과 비교세 행 TRUEstatus 조건은 없음
3empty account relationAVG가 NULL인 scalar 한 행 반환WHERE comparison은 TRUE가 아님일반 nonaggregate subquery와 구분
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · strict greater-than히토리 → 니지카 → 료 → 키타
  1. 히토리

    평균과 balance가 같아도 평균 이상이니 남나요?

  2. 니지카

    연산자가 >라 equality는 FALSE야.

  3. >=로 바꾸면 다른 문제를 푸는 셈이고 현재 source와 달라.

  4. 키타

    경계 fixture에 평균과 같은 계좌를 넣었을 때 제외되는지 생각해 볼게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

scalar aggregate

상관 열이 없어 외부 a와 독립적으로 전체 peer balance를 요약한다.

optimizer 실행 횟수는 이 예시의 증명 범위가 아니다.
three-valued logic

a.balance > NULL은 UNKNOWN이라 WHERE를 통과하지 않는다.

NULL balance 자체의 도메인 정책은 다루지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ 평균 subquery가 여러 행을 반환한다

왜 틀리나 AVG aggregate는 결과를 한 행으로 줄인다

바르게 읽기 scalar 한 값을 > 오른쪽에 둔다

반례 account가 비어도 AVG 결과 행은 하나이며 값은 NULL이다

❌ CLOSED account는 자동 제외된다

왜 틀리나 SQL에 status predicate가 없다

바르게 읽기 prompt 그대로 모든 account를 비교한다

반례 account 107도 평균보다 크면 결과다

❌ 평균과 같은 balance도 포함된다

왜 틀리나 연산자는 >=가 아니라 >다

바르게 읽기 strict greater-than으로 읽는다

반례 동일값은 predicate가 FALSE다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

정본성

이 illustrative SQL을 shipped 답안으로 부르지 않는다

이 책임을 맡는 곳: workbook answer authority
필터 범위

ACTIVE만 보라는 조건을 추가하지 않는다

이 책임을 맡는 곳: prompt 또는 후속 WHERE
성능

AVG 계산 계획이나 table scan 비용을 보장하지 않는다

이 책임을 맡는 곳: EXPLAIN과 index 설계
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · fixture 평균히토리 → 니지카 → 료 → 키타
  1. 히토리

    266287.375는 앞으로도 변하지 않는 업무 상수인가요?

  2. 니지카

    현재 workbook seed의 여덟 account로 계산한 exact oracle일 뿐이야.

  3. balance나 행이 하나만 바뀌어도 평균과 통과 ID를 다시 구해야 해.

  4. 키타

    이 숫자를 운영 규칙이 아니라 회귀 fixture 값으로 표시하겠습니다.

12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

  • 왜 subquery가 한 값만 반환하나?
  • 빈 account table이면 비교 결과는 어떻게 되나?
  • CLOSED account 107은 왜 포함되나?

2단계 · 코드 조각 재조립

  1. 상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.
  2. a.balance > average가 TRUE인 account만 통과한다.
  3. balance 뒤 account_id로 표시 순서를 고정한다.

3단계 · 파일 전체 다시 쓰기

illustrative/sql/W15-SQL-Q21.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture 숫자와 ID를 정확히 적었는가
  • query shape와 실행 증거를 구분했는가
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님 · fixture-bound illustrative SQLillustrative/sql/W15-SQL-Q21.sqlSHA-256 787f0aa8f1ae08519e3149b8927f624b86567c88d4b55ce6232d7f74410ca9ec
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 전체
-- W15-SQL-Q21 illustrative example; not a shipped workbook answer.
-- The uncorrelated scalar subquery returns exactly one AVG row, including when account is empty.
-- Fixture oracle: average balance 266287.375; account_id 106, 107, and 108 qualify.
SET search_path TO :"workbook_schema", public;

SELECT
    a.account_id,
    a.customer_id,
    a.account_no,
    a.balance
FROM account AS a
WHERE a.balance > (
    SELECT AVG(peer.balance)
    FROM account AS peer
)
ORDER BY a.balance, a.account_id;
08

Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존

illustrative/sql/W15-SQL-Q22.sql

fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F08
19줄 연결19줄 번역5 chunks
01

STEP 01 / 13

오늘 이 코드에서 해결할 문제

무엇을 이해해야 하는지 질문부터 잡습니다.

오늘의 한 문장

각 account를 같은 customer의 MAX(balance)와 비교해 고객별 최대 balance인 모든 account 행을 남긴다.

  1. 왜 subquery가 customer_id로 상관되나?
  2. 동점 최대 account는 query shape상 몇 행 남나?
  3. customer 5가 결과에 없는 이유는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값rows=5account_id=105/106/107/104/108customer 5 has no accountfixture tie=none
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · customer correlation히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 account의 MAX 하나와 모두 비교하면 안 되나요?

  2. 니지카

    Q22는 고객마다 최고 계좌를 찾으므로 외부 a의 customer_id로 peer를 좁혀야 해.

  3. correlation이 빠지면 전사 최고 balance와 같은 행만 남는 다른 query가 돼.

  4. 키타

    각 a 행에서 peer.customer_id 조건이 바뀌는지 순서대로 추적할게요.

02

STEP 02 / 13

아주 짧게: 이 코드는 왜 필요할까?

웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.

고객별 계좌 카드에서 최고 잔액표 찾기

Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존

히토리는 account 카드 한 장을 들 때마다 customer_id가 같은 peer 카드만 모아 MAX balance를 계산한다.

키타는 equality라 동점이면 모두 남을 모양이지만 현재 fixture에는 동점이 없고, account가 없는 customer 5는 시작 후보에도 없다고 선을 긋는다.

딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

생활 비유와 실제 코드의 경계를 함께 확인합니다.

같은 고객만 모으기

peer.customer_id = a.customer_id로 비교 집합을 좁힌다.

코드 연결
13~17줄
비유
고객별 계좌 카드에서 최고 잔액표 찾기에서 같은 고객만 모으기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

최대값과 같기

a.balance가 고객별 MAX와 같은 account를 남긴다.

코드 연결
13~16줄
비유
고객별 계좌 카드에서 최고 잔액표 찾기에서 최대값과 같기 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.

tie 증거 구분

query shape는 tie를 보존하지만 현재 fixture는 tie를 실행하지 않는다.

코드 연결
3~4, 20줄
비유
고객별 계좌 카드에서 최고 잔액표 찾기에서 tie 증거 구분 단계만 아주 짧게 떠올린다.
비유의 끝
쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

감사 규칙상 연결 대상인 원본 19줄을 빠짐없이 연결합니다.

단어 몇 개만 뽑은 표가 아닙니다.fixture 기반 학습용 SQL 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.19 / 19 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F08-L01 -- W15-SQL-Q22 illustrative example; not a shipped workbook answer. 고객별 최고 잔액 연습표에 공식 답안과 다른 색의 참고표를 붙인다. W15-SQL-Q22가 shipped workbook 답안이 아닌 illustrative example이라고 밝힌다.
입력
이 SQL의 provenance와 답안 지위를 판단해야 할 때 첫 주석을 확인한다.
결과·효과
고객별 최대 계좌 query를 교육용 제안으로만 인용하게 된다.
비유의 한계
주석은 최대값 결과나 tie 보존을 실행으로 검증하지 않는다.
2줄F08-L02 -- Input and output grain are account rows; the subquery is correlated by customer_id. 계좌 카드 한 장을 들 때마다 같은 고객 이름의 카드 묶음을 따로 펼친다. 입력과 출력 grain은 account 행이며 subquery가 customer_id로 상관된다고 선언한다.
입력
전체 평균 query와 고객별 상관 query를 구분하려 할 때 이 grain을 읽는다.
결과·효과
각 외부 계좌가 자기 고객의 peer 집합과 비교된다는 해석 기준이 정해진다.
비유의 한계
설명만으로 correlation 조건이 실제 SQL에 빠짐없이 쓰였는지는 보장되지 않는다.
3줄F08-L03 -- Equality to each customer's MAX preserves every tied maximum by query shape. 최고 점수 한 숫자와 같은 카드는 한 장만 뽑지 않고 전부 남긴다. 각 고객의 MAX balance와 equality를 써 query 형태상 동률 최대 행을 모두 보존한다고 설명한다.
입력
고객별 최고 balance tie를 임의 한 행으로 축소하지 않아야 할 때 equality 모양을 본다.
결과·효과
동일 고객에게 최대 balance가 같은 계좌가 여러 개면 모두 TRUE 후보가 된다.
비유의 한계
query shape의 보존 가능성이지 현재 fixture에서 tie를 실행 관찰했다는 뜻은 아니다.
4줄F08-L04 -- The supplied fixture has no same-customer balance tie, so a mutation fixture is needed to execute that boundary. 동점 카드가 없는 연습 상자 옆에 ‘동점 검사는 별도 카드 추가 필요’ 메모를 둔다. 제공 fixture에는 같은 customer의 balance tie가 없어 mutation fixture가 필요하다고 제한한다.
입력
query shape 주장과 fixture 실행 증거를 분리해 검토할 때 사용한다.
결과·효과
현재 실행만으로는 tie가 둘 다 남는 사례를 직접 증명할 수 없다는 경계가 생긴다.
비유의 한계
현재 5행 oracle을 tie-retention의 실행 증명으로 과장하면 안 된다.
5줄F08-L05 SET search_path TO :"workbook_schema", public; 고객별 카드 상자를 공용 선반보다 먼저 검색하도록 workbook 서랍을 앞세운다. workbook_schema 다음 public 순서로 search_path를 설정한다.
입력
동명 account table 사이에서 fixture source를 고정해야 할 때 실행한다.
결과·효과
schema 접두사 없는 account 참조가 격리 fixture table을 우선 찾는다.
비유의 한계
workbook_schema 변수와 seed가 유효한지는 별도 실행 환경이 책임진다.
7줄F08-L07 SELECT 최고 잔액 카드 명단에 실을 칸을 정하려 빈 표를 펼친다. 고객별 최대 조건을 통과한 account에서 보여 줄 열의 SELECT를 연다.
입력
외부 account 후보의 표시 열을 FROM보다 앞에서 선언할 때 사용한다.
결과·효과
이어지는 네 account 속성이 결과 projection 자리를 얻는다.
비유의 한계
SELECT 입구는 customer correlation이나 maximum 계산을 아직 수행하지 않는다.
8줄F08-L08 a.customer_id, 최고 카드 왼쪽 위에 소유 고객 번호를 먼저 적는다. 외부 account의 customer_id를 첫 결과 열로 선택한다.
입력
고객별 결과를 묶어 읽을 식별자가 필요할 때 projection에 넣는다.
결과·효과
각 최대 계좌 행을 어느 customer group의 결과인지 구분할 수 있다.
비유의 한계
customer_id만으로 account 없는 customer를 결과에 생성하지 못한다.
9줄F08-L09 a.account_id, 고객 번호 옆에 최고 잔액을 가진 계좌 카드의 내부 번호를 붙인다. 선택된 account_id를 결과에 출력한다.
입력
동일 고객에서 어느 account 행이 남았는지 정확히 식별할 때 쓴다.
결과·효과
oracle의 105·106·107·104·108과 실제 행을 ID로 대조할 수 있다.
비유의 한계
ID projection은 그 행이 최대인지 독립적으로 검증하지 않는다.
10줄F08-L10 a.account_no, 내부 숫자 ID 옆에 알아보기 쉬운 계좌 명패를 단다. 사람이 읽는 a.account_no를 표시 열로 포함한다.
입력
fixture 결과를 account_no 기준으로 눈으로 확인하려 할 때 선택한다.
결과·효과
각 maximum account의 계좌 번호 문자열이 결과에 나타난다.
비유의 한계
출력된 번호가 UNIQUE라는 schema 제약 증거는 이 줄에 없다.
11줄F08-L11 a.balance 선발 카드에 최고 판정에 사용한 금액 숫자를 함께 적는다. 외부 후보 행의 balance를 최종 projection에 싣는다.
입력
남은 account가 고객별 최대값과 같은지 값으로 재확인할 때 출력한다.
결과·효과
고객별 MAX와 equality로 비교된 실제 잔액이 결과에 보인다.
비유의 한계
balance 표시는 통화·거래 이력·동점 존재 여부까지 설명하지 않는다.
12줄F08-L12 FROM account AS a 전체 계좌 카드를 a 선반에 펼쳐 한 장씩 최고 여부를 심사한다. 외부 query의 후보 집합을 account 별칭 a로 정한다.
입력
projection과 correlated WHERE가 참조할 현재 행 source를 연결할 때 사용한다.
결과·효과
account를 가진 모든 고객의 각 계좌가 상관 비교의 왼쪽 행이 된다.
비유의 한계
customer table에서 출발하지 않으므로 계좌 없는 고객 행은 후보에 없다.
13줄F08-L13 WHERE a.balance = ( 현재 카드의 잔액이 자기 고객 최고 눈금과 정확히 같은지 검사한다. 외부 balance가 괄호 안 customer-local 최대값과 같은 행만 남기는 조건을 연다.
입력
최고값보다 작은 계좌를 버리고 동률은 남길 Boolean 조건을 평가할 때 쓴다.
결과·효과
MAX scalar와 equality가 TRUE인 account가 최종 결과 후보가 된다.
비유의 한계
NULL equality는 TRUE가 아니며 비교 오른쪽 값은 아직 다음 내부 식에서 계산된다.
14줄F08-L14 SELECT MAX(peer.balance) 같은 고객 카드 묶음의 잔액 중 가장 높은 눈금 하나를 읽는다. peer.balance 중 가장 큰 값을 MAX aggregate로 하나 계산한다.
입력
현재 외부 account에 적용할 customer-local 기준 숫자가 필요할 때 계산한다.
결과·효과
상관된 peer 집합에서 외부 a.balance가 비교할 scalar 최고값이 나온다.
비유의 한계
MAX는 최고 account 행 전체를 선택하지 않고 balance 값만 반환한다.
15줄F08-L15 FROM account AS peer 현재 카드와 비교할 같은 상자 카드들에 peer라는 역할 이름을 붙인다. MAX 입력 relation을 account의 peer 별칭으로 지정한다.
입력
self-reference에서 심사 대상과 비교 대상의 열을 구분할 때 별칭을 사용한다.
결과·효과
내부 subquery가 account 행을 외부 a와 다른 역할로 다시 읽는다.
비유의 한계
peer source만으로는 아직 같은 customer 범위가 적용되지 않는다.
16줄F08-L16 WHERE peer.customer_id = a.customer_id 고객 번호가 현재 카드와 같은 카드만 최고값 바구니에 넣는다. peer.customer_id와 현재 a.customer_id가 같은 내부 행만 남긴다.
입력
각 외부 행마다 다른 customer-local maximum을 계산하도록 correlation할 때 쓴다.
결과·효과
MAX 입력이 전체 account가 아니라 현재 고객의 peer 계좌로 좁아진다.
비유의 한계
customer_id equality는 고객별 계좌가 최소 하나 더 있음을 보장하지 않는다.
17줄F08-L17 ) 고객별 최고 눈금 계산 봉투를 닫아 현재 카드 심사표에 꽂는다. correlated scalar subquery 괄호를 닫아 최대값 equality 조건을 완성한다.
입력
내부 peer 범위를 끝내고 외부 account 필터로 돌아올 때 닫는다.
결과·효과
현재 account와 자기 고객의 MAX balance 비교가 하나의 WHERE predicate가 된다.
비유의 한계
닫는 괄호는 fixture의 5행 oracle이나 tie 사례를 검사하지 않는다.
18줄F08-L18 ORDER BY a.customer_id, a.account_id; 최고 카드들을 고객 번호별 묶음으로 놓고 묶음 안은 계좌 번호로 정돈한다. customer_id 다음 account_id 순서로 최종 행을 정렬한다.
입력
반복 실행 결과를 고정 순서로 fixture oracle과 비교하려 할 때 사용한다.
결과·효과
결과가 고객별로 모이고 같은 고객 안에서는 account ID 순으로 안정된다.
비유의 한계
ORDER BY는 maximum 판정이나 tie 보존 cardinality를 바꾸지 않는다.
19줄F08-L19 -- Fixture oracle: 5 rows, account_id 105, 106, 107, 104, and 108; customer 5 has no account row. 실행표 옆에 다섯 장의 카드 번호와 빈손인 고객 5 메모를 붙인다. fixture 결과 5행과 account_id 105·106·107·104·108, account 없는 customer 5를 oracle로 기록한다.
입력
제공 fixture로 query 결과 행과 누락 이유를 대조할 때 이 주석을 쓴다.
결과·효과
현재 seed에서 기대할 exact ID 집합과 결과 cardinality가 검산 기준으로 남는다.
비유의 한계
계좌 없는 customer가 결과에 없는 것은 account에서 시작한 현재 query 모양의 결과다.
20줄F08-L20 -- Tie retention is a query-shape claim, not a tie-execution claim for this fixture. 동점 보존 설계표와 실제 동점 시험 성적표를 서로 다른 봉투에 넣는다. tie 보존은 query-shape 주장이지 현재 fixture의 tie 실행 주장과 다르다고 재확인한다.
입력
equality 문법의 가능성과 현재 seed에서 관찰한 사실을 최종적으로 구분할 때 읽는다.
결과·효과
검토 결과에는 동률 mutation을 실행하지 않았다는 증거 한계가 명시된다.
비유의 한계
tie를 실제로 증명하려면 같은 고객·같은 최대 balance 행을 추가한 별도 fixture가 필요하다.
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · MAX는 값 하나히토리 → 니지카 → 료 → 키타
  1. 히토리

    MAX가 그 고객의 account 행 하나를 골라 주나요?

  2. 니지카

    MAX는 balance 숫자 하나만 내고 행 선택은 외부 equality가 맡아.

  3. 그래서 같은 최대 balance인 outer account가 둘이면 둘 다 TRUE가 될 수 있어.

  4. 키타

    aggregate scalar와 보존할 account projection을 나눠 읽겠습니다.

05

STEP 05 / 13

원본 코드 조각

원본을 5개 의미 조각으로 나누어 그대로 확인합니다.

파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.

F08-C01 · provenance, correlation, and tie boundary1–4줄
1–4줄 원본
-- W15-SQL-Q22 illustrative example; not a shipped workbook answer.
-- Input and output grain are account rows; the subquery is correlated by customer_id.
-- Equality to each customer's MAX preserves every tied maximum by query shape.
-- The supplied fixture has no same-customer balance tie, so a mutation fixture is needed to execute that boundary.
F08-C02 · isolated workbook schema5–5줄
5–5줄 원본
SET search_path TO :"workbook_schema", public;
F08-C03 · account candidates6–12줄
6–12줄 원본

SELECT
    a.customer_id,
    a.account_id,
    a.account_no,
    a.balance
FROM account AS a
F08-C04 · customer-local maximum13–17줄
13–17줄 원본
WHERE a.balance = (
    SELECT MAX(peer.balance)
    FROM account AS peer
    WHERE peer.customer_id = a.customer_id
)
F08-C05 · stable order and fixture-only oracle18–20줄
18–20줄 원본
ORDER BY a.customer_id, a.account_id;
-- Fixture oracle: 5 rows, account_id 105, 106, 107, 104, and 108; customer 5 has no account row.
-- Tie retention is a query-shape claim, not a tie-execution claim for this fixture.
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

비어 있지 않은 19줄을 모두 한국어로 옮깁니다.

전체 번역 19 / 19

비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.

원본한국어 번역
1-- W15-SQL-Q22 illustrative example; not a shipped workbook answer.W15-SQL-Q22가 shipped workbook 답안이 아닌 illustrative example이라고 밝힌다.
2-- Input and output grain are account rows; the subquery is correlated by customer_id.입력과 출력 grain은 account 행이며 subquery가 customer_id로 상관된다고 선언한다.
3-- Equality to each customer's MAX preserves every tied maximum by query shape.각 고객의 MAX balance와 equality를 써 query 형태상 동률 최대 행을 모두 보존한다고 설명한다.
4-- The supplied fixture has no same-customer balance tie, so a mutation fixture is needed to execute that boundary.제공 fixture에는 같은 customer의 balance tie가 없어 mutation fixture가 필요하다고 제한한다.
5SET search_path TO :"workbook_schema", public;workbook_schema 다음 public 순서로 search_path를 설정한다.
7SELECT고객별 최대 조건을 통과한 account에서 보여 줄 열의 SELECT를 연다.
8 a.customer_id,외부 account의 customer_id를 첫 결과 열로 선택한다.
9 a.account_id,선택된 account_id를 결과에 출력한다.
10 a.account_no,사람이 읽는 a.account_no를 표시 열로 포함한다.
11 a.balance외부 후보 행의 balance를 최종 projection에 싣는다.
12FROM account AS a외부 query의 후보 집합을 account 별칭 a로 정한다.
13WHERE a.balance = (외부 balance가 괄호 안 customer-local 최대값과 같은 행만 남기는 조건을 연다.
14 SELECT MAX(peer.balance)peer.balance 중 가장 큰 값을 MAX aggregate로 하나 계산한다.
15 FROM account AS peerMAX 입력 relation을 account의 peer 별칭으로 지정한다.
16 WHERE peer.customer_id = a.customer_idpeer.customer_id와 현재 a.customer_id가 같은 내부 행만 남긴다.
17)correlated scalar subquery 괄호를 닫아 최대값 equality 조건을 완성한다.
18ORDER BY a.customer_id, a.account_id;customer_id 다음 account_id 순서로 최종 행을 정렬한다.
19-- Fixture oracle: 5 rows, account_id 105, 106, 107, 104, and 108; customer 5 has no account row.fixture 결과 5행과 account_id 105·106·107·104·108, account 없는 customer 5를 oracle로 기록한다.
20-- Tie retention is a query-shape claim, not a tie-execution claim for this fixture.tie 보존은 query-shape 주장이지 현재 fixture의 tie 실행 주장과 다르다고 재확인한다.
07

STEP 07 / 13

기존 수준의 한 줄 읽기·문법 해부

쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.

한 줄로 읽기

각 account를 같은 customer의 MAX(balance)와 비교해 고객별 최대 balance인 모든 account 행을 남긴다.

문법 해부

  • peer.customer_id = a.customer_id로 비교 집합을 좁힌다.
  • a.balance가 고객별 MAX와 같은 account를 남긴다.
  • query shape는 tie를 보존하지만 현재 fixture는 tie를 실행하지 않는다.

실행 순서

  1. 같은 customer의 peer 행만 correlation
  2. MAX scalar 계산
  3. MAX와 equality 비교

원래 W6 수준의 조각별 정밀 해설

F08-C01 · provenance, correlation, and tie boundary
문법 해부
네 주석이 Q22 illustrative 지위, account row grain, customer_id correlation, equality 기반 tie shape와 현재 fixture의 tie 부재를 선언한다.
실제 값 추적
독자는 고객별 최대 account 예시이되 tie 보존은 설계상 가능성이고 실행 증거에는 mutation이 더 필요하다는 계약을 얻는다.
정상 예
같은 고객의 최대 balance가 두 account에 있다면 query shape상 둘 다 남을 수 있다는 설명은 equality 의미와 맞는다.
틀린 예·반례
현재 결과만 보고 same-customer tie가 실행 검증됐다고 말하면 주석의 evidence boundary와 모순된다.
착각 방지
MAX가 account 행 하나를 임의 선택하는 것이 아니라 balance scalar를 계산한다는 역할 구분을 유지한다.
하지 않는 일
이 범위는 실제 candidate projection·correlation predicate·fixture ID를 아직 실행하지 않는다.
다음 연결
다음 ‘isolated workbook schema’ 범위가 unqualified account를 fixture namespace에 결속한다.
F08-C02 · isolated workbook schema
문법 해부
SET search_path가 workbook_schema, public 순서로 object lookup을 설정한다.
실제 값 추적
뒤 account self-reference 두 역할은 격리 workbook table을 우선 해석한다.
정상 예
유효한 schema 변수가 psql에 주어진 실행에서 설정이 성공하는 것이 expected path다.
틀린 예·반례
잘못된 변수나 미적재 fixture를 SET 한 줄이 스스로 복구하지 않는다.
착각 방지
같은 account 이름의 namespace 선택과 customer correlation 정확성은 다른 문제다.
하지 않는 일
권한·lifecycle·public fallback hardening은 이 교육 SQL의 책임 밖이다.
다음 연결
다음 ‘account candidates’ 범위가 외부 a의 customer·account·number·balance를 결과 후보로 펼친다.
F08-C03 · account candidates
문법 해부
SELECT가 a.customer_id·account_id·account_no·balance를 투영하고 FROM account AS a로 모든 outer account 후보를 연다.
실제 값 추적
계좌가 있는 각 customer의 account 행은 네 표시값을 가진 outer candidate relation에 들어간다.
정상 예
한 고객에게 account가 두 개면 둘 다 외부 a 후보로 들어가는 것이 이 source 범위의 정상 모양이다.
틀린 예·반례
customer table에서 시작하지 않으므로 account가 없는 customer를 NULL 계좌 행으로 보존하지 못한다.
착각 방지
projection은 최고 account를 이미 선택한 결과가 아니라 비교 전 candidate 목록이다.
하지 않는 일
이 구간만으로 MAX 값, tie retention, fixture 결과 cardinality를 판정하지 않는다.
다음 연결
다음 ‘customer-local maximum’ 범위가 각 a.balance를 같은 customer peer의 MAX balance와 비교한다.
F08-C04 · customer-local maximum
문법 해부
WHERE가 a.balance를 correlated subquery의 MAX(peer.balance)와 equality 비교하고 peer.customer_id=a.customer_id로 내부 집합을 제한한다.
실제 값 추적
각 outer account마다 자기 customer의 최고 balance scalar가 계산돼 같은 값인 행만 TRUE가 된다.
정상 예
한 고객의 두 account가 같은 maximum을 가지면 두 외부 행 모두 equality를 만족할 수 있다.
틀린 예·반례
correlation predicate를 빼면 전사 최대 balance와 비교하는 다른 query가 되어 고객별 결과를 잃는다.
착각 방지
MAX는 balance 값만 반환하고 어느 account row를 임의로 대표로 고르지 않는다.
하지 않는 일
현재 fixture에 tie가 없으므로 다중 TRUE tie path는 별도 mutation 없이는 직접 관찰되지 않는다.
다음 연결
다음 ‘stable order and fixture-only oracle’ 범위가 고객·account 순 정렬, exact 5 IDs와 tie nonproof를 기록한다.
F08-C05 · stable order and fixture-only oracle
문법 해부
ORDER BY customer_id·account_id가 결과 순서를 고정하고 두 주석이 5행 ID 105·106·107·104·108, customer 5 부재와 tie 실행 한계를 적는다.
실제 값 추적
현재 fixture에서 다섯 maximum account가 안정 순서로 나오며 계좌 없는 customer 5는 후보 source에 없어 제외된다.
정상 예
query 결과의 row count 5와 ID set이 주석 oracle과 맞는 것은 정상 fixture 회귀 예다.
틀린 예·반례
같은 고객의 동점 최대 account가 없는 현재 seed로 tie retention 실행까지 PASS라고 기록하면 과장이다.
착각 방지
query-shape claim과 tie-execution claim을 분리하고 mutation fixture 필요성을 남긴다.
하지 않는 일
fixture 숫자는 운영 불변식이나 shipped workbook answer를 뜻하지 않는다.
다음 연결
이 SQL의 끝이다. 전체를 다시 읽을 때는 outer account grain, customer correlation, equality tie shape와 증거 한계를 함께 본다.
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · tie query shape히토리 → 니지카 → 료 → 키타
  1. 히토리

    현재 결과가 5행이면 tie 보존도 검증된 거죠?

  2. 니지카

    fixture에는 같은 customer의 최대 balance tie가 없어서 그 실행 경로는 없었어.

  3. equality 문법은 보존 가능한 모양이지만 증거에는 mutation fixture가 더 필요해.

  4. 키타

    설계 주장과 실행 주장을 별도 boundary 카드에 기록할게요.

08

STEP 08 / 13

실제 값 따라가기

같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.

순서들어온 값코드가 하는 일나온 값·상태경계
1각 outer account a같은 customer의 peer 행만 correlationcustomer-local setaccount 없는 customer는 outer row가 없음
2peer balancesMAX scalar 계산고객별 최고 balance행 전체를 임의 하나로 고르지 않음
3a.balanceMAX와 equality 비교현재 fixture 5행tie 실행 증거는 없음
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · customer 5히토리 → 니지카 → 료 → 키타
  1. 히토리

    고객 5도 최고 계좌가 없다는 행으로 나와야 하지 않나요?

  2. 니지카

    외부 source가 account라 계좌 카드가 없는 customer는 시작 후보가 없어.

  3. 모든 customer를 보존하려면 customer에서 시작하는 LEFT JOIN 같은 다른 설계가 필요해.

  4. 키타

    5행 oracle에서 customer 5 누락을 버그로 오인하지 않겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.

correlated subquery

외부 a.customer_id마다 내부 peer 범위가 달라진다.

planner의 decorrelation 여부는 결과 의미와 별도다.
aggregate equality

MAX 값과 같은 모든 outer row가 TRUE가 될 수 있다.

현재 seed에는 같은 고객의 maximum tie가 없다.
10

STEP 10 / 13

흔한 착각과 틀린 예

그럴듯하지만 틀린 해석을 반례로 고칩니다.

❌ MAX가 account 행 하나를 반환한다

왜 틀리나 MAX는 balance scalar만 계산한다

바르게 읽기 외부 account 행을 equality로 남긴다

반례 동점이라면 여러 outer 행이 같은 MAX와 같다

❌ 현재 5행이 tie 보존을 증명한다

왜 틀리나 fixture에 같은 고객의 balance tie가 없다

바르게 읽기 mutation fixture로 별도 실행한다

반례 같은 customer에 같은 최대 balance를 추가해야 한다

❌ customer 5도 NULL 계좌 행으로 나온다

왜 틀리나 query는 customer가 아니라 account에서 출발한다

바르게 읽기 계좌가 있는 고객의 account만 후보로 삼는다

반례 customer 5에는 outer account row가 없다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

이 코드가 책임지지 않는 일을 분리합니다.

정본성

workbook canonical answer를 제공한다고 주장하지 않는다

이 책임을 맡는 곳: 학습 예시 provenance
tie 실행

현재 fixture로 tie retention을 직접 관찰하지 못한다

이 책임을 맡는 곳: mutation fixture
고객 포괄성

account 없는 customer를 결과에 보존하지 않는다

이 책임을 맡는 곳: customer LEFT JOIN 설계
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · exact five IDs히토리 → 니지카 → 료 → 키타
  1. 히토리

    기대 ID 순서가 105,106,107,104,108인 이유는 뭐예요?

  2. 니지카

    주석은 결과 집합을 고정하고 ORDER BY는 customer_id 뒤 account_id로 배열해.

  3. 다섯 ID는 현재 seed 전용이며 balance 변경 뒤에는 membership도 달라질 수 있어.

  4. 키타

    행 수 5와 ID set, 표시 순서를 나눠 회귀 검산하겠습니다.

12

STEP 12 / 13

직접 다시 써보기

뜻 → 조각 → 전체 코드 순서로 다시 씁니다.

1단계 · 뜻부터 복원

  • 왜 subquery가 customer_id로 상관되나?
  • 동점 최대 account는 query shape상 몇 행 남나?
  • customer 5가 결과에 없는 이유는 무엇인가?

2단계 · 코드 조각 재조립

  1. peer.customer_id = a.customer_id로 비교 집합을 좁힌다.
  2. a.balance가 고객별 MAX와 같은 account를 남긴다.
  3. query shape는 tie를 보존하지만 현재 fixture는 tie를 실행하지 않는다.

3단계 · 파일 전체 다시 쓰기

illustrative/sql/W15-SQL-Q22.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture 숫자와 ID를 정확히 적었는가
  • query shape와 실행 증거를 구분했는가
13

STEP 13 / 13

전체 원본 정답

감사로 고정한 전체 source를 가감 없이 확인합니다.

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님 · fixture-bound illustrative SQLillustrative/sql/W15-SQL-Q22.sqlSHA-256 1509ad6afed403f8627c45ce1a964ad150fa091cd619500db9dba485f30adf1e
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 전체
-- W15-SQL-Q22 illustrative example; not a shipped workbook answer.
-- Input and output grain are account rows; the subquery is correlated by customer_id.
-- Equality to each customer's MAX preserves every tied maximum by query shape.
-- The supplied fixture has no same-customer balance tie, so a mutation fixture is needed to execute that boundary.
SET search_path TO :"workbook_schema", public;

SELECT
    a.customer_id,
    a.account_id,
    a.account_no,
    a.balance
FROM account AS a
WHERE a.balance = (
    SELECT MAX(peer.balance)
    FROM account AS peer
    WHERE peer.customer_id = a.customer_id
)
ORDER BY a.customer_id, a.account_id;
-- Fixture oracle: 5 rows, account_id 105, 106, 107, 104, and 108; customer 5 has no account row.
-- Tie retention is a query-shape claim, not a tie-execution claim for this fixture.