STARRY PASS 코드 뒤풀이 · W15 Ver2 전체
15주차 코드 — schema·cardinality와 SQL 모델링
물리 schema와 W15 실행기의 실제 보장 범위를 읽고, 일별 집계·평균 비교·고객별 최대값 SQL을 fixture에 묶어 쉽게 확인합니다.
01V001__common.sql — 네 core table의 물리 key·constraint·index 선언
schema/V001__common.sql
정본 물리 schema migration · 정본 · W15-F0145줄 연결45줄 번역6 chunks
V001__common.sql — 네 core table의 물리 key·constraint·index 선언
schema/V001__common.sql
정본 물리 schema migration · 정본 · W15-F01STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
account·business_tx·ledger_entry·idempotency_request의 저장 모양과 관계를 실제 migration line에서 읽는다.
- 네 table의 PK·FK·UNIQUE·CHECK는 각각 어떤 값을 막을까?
- ledger_entry의 세 REFERENCES는 어떤 존재만 증명할까?
- amount와 signed_amount 사이에 빠진 constraint는 무엇일까?
- account_id FK가 owner authorization까지 보장할까?
- balance>=0 CHECK만으로 동시 출금이 안전할까?
- idempotency composite UNIQUE가 replay protocol 전체일까?
tables=4PRIMARY KEY tokens=4REFERENCES tokens=3UNIQUE tokens=4CHECK tokens=5explicit CREATE INDEX=2account status=ACTIVE|CLOSEDcurrency=KRWSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 공연 정산 전에 계좌·업무거래·원장·재요청 보관함을 만든다.
네 보관함의 열쇠와 연결끈 설계
V001은 네 table을 만들고 어떤 column이 필수인지, 어떤 값이 겹치면 안 되는지, 어느 parent가 존재해야 하는지 적는다.
원장에는 거래와 계좌 FK, 금액·잔액 CHECK, reversal self-FK가 있고 재요청에는 scope·actor·key 복합 UNIQUE가 있다.
딱 여기까지만 보관함의 자물쇠는 저장 가능한 모양만 제한하며 소유권·동시성·원자성·대사 절차까지 대신하지 않는다.
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부터 읽나
-
table 이름만 맞으면 ERD도 맞는 것 아닌가요?
-
이름은 상자 표찰이고 V001 column·constraint는 안쪽 칸과 연결끈이야.
-
physical source truth는 DDL이지만 formal normalization과 업무 의미는 별도 해석이다.
-
네 table마다 PK·FK·UNIQUE·CHECK를 원문 줄에 연결해 볼게요.
identity와 업무 key
-
자동 id가 있으면 account_no UNIQUE는 없어도 되나요?
-
id는 내부 row 번호고 account_no는 외부 업무 식별 후보라 둘의 질문이 달라.
-
surrogate PK와 candidate key는 서로 대체되는 단일 개념이 아니다.
-
2줄과 4줄이 막는 중복을 각각 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 45줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F01-L01 | CREATE TABLE account ( |
STARRY가 계좌 보관함의 빈 설계도를 작업대에 펼친다. | `account` table 정의를 여는 CREATE TABLE 문장을 시작한다.
|
| 2줄F01-L02 | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
새 계좌표마다 큰 정수 자동 번호표와 중복 금지 도장을 붙인다. | id를 BIGINT identity column이자 account의 PRIMARY KEY로 선언한다.
|
| 3줄F01-L03 | owner_id VARCHAR( |
계좌표에 최대 64자의 주인 식별 띠를 빈칸 없이 매단다. | owner_id를 길이 64 이하의 필수 VARCHAR column으로 선언한다.
|
| 4줄F01-L04 | account_no VARCHAR( |
외부에 보일 계좌번호는 32자 안에서 한 장만 존재하도록 봉인한다. | account_no를 NOT NULL·UNIQUE인 VARCHAR(32) column으로 선언한다.
|
| 5줄F01-L05 | status VARCHAR( |
계좌 상태등에는 ACTIVE와 CLOSED 두 색만 꽂을 수 있게 홈을 판다. | status를 필수 VARCHAR(16)으로 두고 두 literal만 허용하는 CHECK를 건다.
|
| 6줄F01-L06 | currency VARCHAR( |
통화 칸은 세 글자 KRW 도장만 통과하는 좁은 슬롯으로 만든다. | currency를 NOT NULL VARCHAR(3)으로 선언하고 값이 KRW인지 CHECK한다.
|
| 7줄F01-L07 | balance BIGINT NOT NULL CHECK ( |
현재 잔액 저울에는 0 아래로 내려가지 못하는 바닥턱을 둔다. | balance를 필수 BIGINT로 선언하고 `balance >= 0` CHECK를 붙인다.
|
| 8줄F01-L08 | version BIGINT NOT NULL DEFAULT 0 |
변경 횟수표는 값을 안 적으면 0부터 시작하도록 기본 숫자를 놓는다. | version을 NOT NULL BIGINT로 두고 누락 시 DEFAULT 0을 사용하게 한다.
|
| 9줄F01-L09 | ); |
계좌 보관함 설계의 마지막 괄호를 닫고 제작 승인표를 낸다. | account relation definition의 닫는 괄호와 semicolon을 적는다.
|
| 11줄F01-L11 | CREATE INDEX idx_account_owner_id ON account( |
주인 띠와 계좌 번호 순으로 서랍을 찾는 별도 색인을 만든다. | account(owner_id, id)에 `idx_account_owner_id` index를 생성한다.
|
| 13줄F01-L13 | CREATE TABLE business_tx ( |
업무 거래표를 담을 새 보관함의 이름판만 먼저 세운다. | `business_tx` table 정의를 여는 CREATE TABLE 문장을 시작한다.
|
| 14줄F01-L14 | id UUID PRIMARY KEY, |
거래 한 건에는 겹칠 수 없는 UUID 봉인을 기본 열쇠로 붙인다. | id를 UUID PRIMARY KEY로 선언한다.
|
| 15줄F01-L15 | tx_type VARCHAR( |
거래 종류 표찰은 32자 안에서 반드시 적게 한다. | business transaction category용 required text slot의 최대 길이를 32로 정한다.
|
| 16줄F01-L16 | status VARCHAR( |
처리 상태 칸은 16자 표시판으로 두되 빈칸만 금지한다. | business_tx 처리 상태용 필수 VARCHAR(16) column을 추가한다.
|
| 17줄F01-L17 | correlation_id VARCHAR( |
추적 리본 번호는 64자 안에서 거래마다 하나만 쓰게 한다. | correlation_id를 필수 VARCHAR(64)로 두고 UNIQUE를 건다.
|
| 18줄F01-L18 | requested_at TIMESTAMPTZ NOT NULL, |
요청 시각 도장은 시간대가 있는 칸에 빠짐없이 찍게 한다. | business_tx 요청 instant를 필수 TIMESTAMPTZ requested_at에 보관한다.
|
| 19줄F01-L19 | completed_at TIMESTAMPTZ |
완료 전 거래도 놓을 수 있도록 완료 시각 칸은 비워 둘 수 있게 한다. | business_tx 완료 instant용 nullable TIMESTAMPTZ slot을 둔다.
|
| 20줄F01-L20 | ); |
업무 거래 보관함의 설계 괄호를 닫아 하나의 DDL로 제출한다. | business_tx relation DDL의 닫는 괄호와 semicolon을 적는다.
|
| 22줄F01-L22 | CREATE TABLE ledger_entry ( |
원장 사건표를 쌓을 세 번째 보관함 설계의 문을 연다. | `ledger_entry` table 정의를 시작한다.
|
| 23줄F01-L23 | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
원장표마다 자동 증가 가능한 BIGINT 일련번호 열쇠를 붙인다. | ledger_entry row key로 generated identity BIGINT id를 둔다.
|
| 24줄F01-L24 | business_tx_id UUID NOT NULL REFERENCES business_tx( |
각 원장표를 존재하는 업무 거래 UUID에 끊어지지 않는 끈으로 묶는다. | business_tx_id를 필수 UUID로 두고 business_tx(id)를 REFERENCES한다.
|
| 25줄F01-L25 | account_id BIGINT NOT NULL REFERENCES account( |
원장표의 계좌 끈은 실제 account 번호표가 있는 곳에만 걸리게 한다. | account_id를 필수 BIGINT로 두고 account(id)를 REFERENCES한다.
|
| 26줄F01-L26 | entry_type VARCHAR( |
원장 사건 종류 칸은 32자 안에서 반드시 채우는 표찰로 둔다. | 각 ledger event의 분류 label은 최대 32자이며 null을 허용하지 않게 한다.
|
| 27줄F01-L27 | amount BIGINT NOT NULL CHECK ( |
금액의 절댓값 칸은 0보다 큰 BIGINT만 통과하는 문턱을 둔다. | amount를 필수 BIGINT로 선언하고 `amount > 0`을 CHECK한다.
|
| 28줄F01-L28 | signed_amount BIGINT NOT NULL, |
원장 화살표 숫자는 부호를 그대로 적되 빈칸만 허용하지 않는다. | signed_amount를 NOT NULL BIGINT column으로 선언한다.
|
| 29줄F01-L29 | balance_after BIGINT NOT NULL CHECK ( |
사건 뒤 잔액 칸에도 0 아래로 내려가지 않는 바닥선을 긋는다. | balance_after를 필수 BIGINT로 두고 nonnegative CHECK를 건다.
|
| 30줄F01-L30 | reversal_of BIGINT REFERENCES ledger_entry( |
취소표에는 원래 원장표 번호로 돌아가는 선택적 되감기 끈을 둔다. | reversal_of를 nullable BIGINT self-FK로 두어 ledger_entry(id)를 참조한다.
|
| 31줄F01-L31 | created_at TIMESTAMPTZ NOT NULL, |
원장표 작성 시각은 시간대 포함 칸에 반드시 적게 한다. | ledger 사건 작성 instant를 필수 TIMESTAMPTZ created_at에 보관한다.
|
| 32줄F01-L32 | UNIQUE ( |
한 거래·한 계좌·한 사건종류 조합에는 원장표 한 장만 허용하는 삼중 봉인을 찍는다. | business_tx_id·account_id·entry_type 세 column에 composite UNIQUE를 건다.
|
| 33줄F01-L33 | ); |
원장 보관함의 column·constraint 설계를 닫아 제작 명령을 완성한다. | ledger_entry definition list를 `);`로 마감한다.
|
| 35줄F01-L35 | CREATE INDEX idx_ledger_account_created_id |
원장 찾기 표지판에 `idx_ledger_account_created_id`라는 고유 이름만 먼저 적는다. | 해당 이름의 CREATE INDEX statement를 시작한다.
|
| 36줄F01-L36 | ON ledger_entry( |
계좌를 먼저 찾고 최신 작성시각, 최신 원장번호 순으로 내려가는 탐색길을 완성한다. | ledger_entry(account_id, created_at DESC, id DESC)에 앞서 이름 붙인 index를 생성한다.
|
| 38줄F01-L38 | CREATE TABLE idempotency_request ( |
재요청 접수표를 보관할 네 번째 cabinet의 빈 설계도를 연다. | `idempotency_request` table 정의를 시작한다.
|
| 39줄F01-L39 | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
접수표마다 자동 번호를 쓸 수 있는 BIGINT 기본 열쇠를 단다. | idempotency_request surrogate key를 BY DEFAULT identity BIGINT로 만든다.
|
| 40줄F01-L40 | scope VARCHAR( |
멱등 처리가 적용되는 작업 영역 이름을 64자 안에서 필수로 적는다. | scope를 NOT NULL VARCHAR(64) column으로 선언한다.
|
| 41줄F01-L41 | actor_id VARCHAR( |
요청 주체 표찰도 64자 안에서 비워 둘 수 없게 한다. | actor_id를 NOT NULL VARCHAR(64) column으로 선언한다.
|
| 42줄F01-L42 | idempotency_key VARCHAR( |
재시도 표의 핵심 key 칸은 128자까지 허용하되 반드시 채운다. | idempotency_key를 NOT NULL VARCHAR(128) column으로 선언한다.
|
| 43줄F01-L43 | request_hash CHAR( |
요청 내용 지문은 고정 64칸짜리 필수 표찰에 저장한다. | request_hash를 NOT NULL CHAR(64) column으로 선언한다.
|
| 44줄F01-L44 | status VARCHAR( |
접수 처리 상태는 16자 칸에 반드시 적게만 하고 색 목록은 열어 둔다. | 멱등 요청 처리 상태용 필수 VARCHAR(16) column을 추가한다.
|
| 45줄F01-L45 | response_status INTEGER, |
아직 응답하지 않은 접수표도 놓도록 응답 status 숫자 칸은 선택으로 둔다. | response_status를 nullable INTEGER column으로 선언한다.
|
| 46줄F01-L46 | response_body TEXT, |
재전달할 응답 본문은 길이 제한 없는 선택적 종이에 보관한다. | response_body를 nullable TEXT column으로 선언한다.
|
| 47줄F01-L47 | created_at TIMESTAMPTZ NOT NULL, |
접수 생성 시각은 시간대 포함 도장을 빠짐없이 요구한다. | 멱등 접수 생성 instant용 필수 TIMESTAMPTZ created_at을 둔다.
|
| 48줄F01-L48 | completed_at TIMESTAMPTZ, |
처리 중인 접수표를 위해 완료 시각 도장은 비워 둘 수 있게 한다. | 멱등 접수 종료 instant를 선택적으로 저장하는 TIMESTAMPTZ column을 둔다.
|
| 49줄F01-L49 | UNIQUE ( |
같은 작업영역·주체·멱등 key 조합에는 접수표 한 장만 두는 삼중 seal을 건다. | scope·actor_id·idempotency_key에 composite UNIQUE를 선언한다.
|
| 50줄F01-L50 | ); |
멱등 접수 보관함의 마지막 괄호를 닫아 V001의 네 번째 table 명령을 끝낸다. | idempotency_request relation definition의 마지막 괄호를 닫는다.
|
FK는 권한이 아니다
-
ledger의 account_id가 존재하면 내 계좌라는 뜻인가요?
-
연결끈은 그 번호표가 실제 account에 있는지만 확인해.
-
authorization에는 authenticated actor와 account.owner_id 비교가 더 필요하다.
-
25줄 한계에 존재 O·소유권 X를 표시할게요.
STEP 05 / 13
원본 코드 조각
원본을 6개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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)
);
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 45줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | CREATE 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 0 | version을 NOT NULL BIGINT로 두고 누락 시 DEFAULT 0을 사용하게 한다. |
| 9 | ); | account relation definition의 닫는 괄호와 semicolon을 적는다. |
| 11 | CREATE INDEX idx_account_owner_id ON account(owner_id, id); | account(owner_id, id)에 `idx_account_owner_id` index를 생성한다. |
| 13 | CREATE 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 TIMESTAMPTZ | business_tx 완료 instant용 nullable TIMESTAMPTZ slot을 둔다. |
| 20 | ); | business_tx relation DDL의 닫는 괄호와 semicolon을 적는다. |
| 22 | CREATE 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를 `);`로 마감한다. |
| 35 | CREATE 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를 생성한다. |
| 38 | CREATE 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의 마지막 괄호를 닫는다. |
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 결과 순서를 자동 보장하지 않는다.
실행 순서
- account table과 owner index를 만든다.
- business_tx table을 만든다.
- ledger_entry table과 account/time index를 만든다.
- 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와 동시성
-
balance가 음수가 아니면 동시 출금도 안전하죠?
-
두 요청이 같은 옛 balance를 읽는 경쟁은 한 row의 최종 숫자 검사와 달라.
-
constraint proof와 isolation/lock proof의 관찰 범위를 분리해야 한다.
-
동시성 test와 대사 규칙을 별도 책임 칸에 남기겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| account row | owner_id=customer-1, account_no=A-100, status=ACTIVE, currency=KRW, balance=0 | id·version을 생략해 INSERT한다. | identity id와 default version=0을 사용할 수 있고 inline checks를 통과한다. | owner 존재나 actor 권한은 확인하지 않는다. |
| ledger parent links | 존재하는 business_tx_id와 account_id | ledger_entry FK 세 개 중 두 parent FK를 평가한다. | 두 parent row가 있으면 existence check를 통과할 수 있다. | 같은 업무 주체·정상 pair·atomic write는 별도 규칙이다. |
| ledger amounts | amount=20, signed_amount=0, balance_after=100 | 현재 V001의 CHECK만 평가한다. | amount>0과 balance_after>=0이라 schema상 signed_amount=0도 거절되지 않는다. | application test나 추가 CHECK가 부호·절댓값 invariant를 맡아야 한다. |
| idempotency tuple | scope=TRANSFER, actor_id=customer-1, key=req-7 | 같은 triple을 두 번째로 INSERT한다. | composite UNIQUE conflict가 발생한다. | 기존 response replay와 다른 request_hash conflict 처리는 자동으로 일어나지 않는다. |
signed_amount 빈 규칙
-
amount가 양수라서 signed_amount 부호도 맞을 것 같아요.
-
27줄은 amount만 보고 28줄은 signed_amount가 null 아닌지만 봐.
-
현재 DDL에는 abs equality와 entry_type별 sign predicate가 없다.
-
amount=20·signed_amount=0 반례로 확인할게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
각 CREATE TABLE statement를 parse해 relation·column·constraint metadata를 catalog에 기록한다.
뒤 statement나 migration transaction이 실패하면 최종 지속 여부가 달라질 수 있다.INSERT·UPDATE 시 NOT NULL·CHECK·UNIQUE·FK predicate를 평가한다.
constraint가 표현하지 않은 cross-row business invariant는 평가하지 않는다.explicit index 두 개 외에 PK·UNIQUE 구현을 위한 implicit index가 생길 수 있다.
source의 CREATE INDEX token 두 개를 전체 physical index count로 읽으면 안 된다.service transaction과 lock이 여러 statement의 원자성·경쟁 순서를 맡는다.
V001 text만으로 특정 isolation·lock order·rollback path를 증명할 수 없다.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
-
복합 UNIQUE가 있으면 replay response도 자동인가요?
-
DB는 같은 세 표찰의 두 번째 접수만 막고 첫 응답을 돌려주지는 않아.
-
request_hash conflict·claim serialization·response replay는 application transaction 책임이다.
-
49줄 직접 효과와 미증명을 분리해 적겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
ledger account_id가 요청 actor 소유 account임
이 책임을 맡는 곳: authentication/authorization service와 owner predicatebalance>=0 CHECK만으로 lost update·overspend가 없음
이 책임을 맡는 곳: transaction isolation, lock/version, concurrency integration testaccount.balance가 ledger signed sum과 항상 일치함
이 책임을 맡는 곳: source-of-truth 정책, atomic write, reconciliation job/evidence3NF·BCNF와 모든 functional dependency 준수
이 책임을 맡는 곳: ERD/FD 분석과 사람 의미 검토두 explicit index가 모든 운영 query를 빠르게 함
이 책임을 맡는 곳: 실제 workload EXPLAIN ANALYZE와 index 운영STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
각 constraint line 옆에 '직접 막는 값'과 '막지 못하는 업무 의미'를 한 줄씩 쓴다.
2단계 · 코드 조각 재조립
- account_id FK → 존재 O / 소유권 X
- balance CHECK → 저장 음수 X / 동시성 proof X
- ledger composite UNIQUE → 동일 tuple 중복 X / transfer pair 강제 X
- 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를 혼동하지 않았는가?
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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)
);
02CoreSchemaIT.java — public base table 네 이름의 exact set test
reference/w6/day-1/CoreSchemaIT.java
누적 W6 JUnit 단계 참고본 · 정본 · W15-F0216줄 연결22줄 번역4 chunks
CoreSchemaIT.java — public base table 네 이름의 exact set test
reference/w6/day-1/CoreSchemaIT.java
누적 W6 JUnit 단계 참고본 · 정본 · W15-F02STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W6D1 staged test가 Flyway history를 뺀 public BASE TABLE 이름을 네 expected name과 비교하는 범위를 읽는다.
- 왜 public과 BASE TABLE만 filter할까?
- 왜 flyway_schema_history를 제외할까?
- ORDER BY 뒤에 순서 없는 matcher를 쓰는 이유는 무엇일까?
- exact four names가 column·key까지 증명할까?
- runner selector가 이 staged source SHA 실행을 뜻할까?
schema=publictype=BASE TABLEexclude=flyway_schema_historyexpected=account,business_tx,ledger_entry,idempotency_request@Test=1stage=W6D1STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 제품 서랍과 Flyway 작업 일지를 구분해 재고표를 만든다.
public 창고의 네 이름표 대조
SQL은 public의 BASE TABLE 이름만 읽고 flyway_schema_history는 뺀 뒤 String 목록을 만든다.
목록이 네 core table 이름과 정확히 같아야 하므로 하나가 빠지거나 다른 table이 더 있으면 test가 실패한다.
딱 여기까지만 이름표만 보는 검사라 column·PK·FK·CHECK·UNIQUE·index·cardinality는 확인하지 않는다.
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했다는 증거는 별도다.
네 이름이면 충분한가
-
table 네 개가 보이면 schema 전체가 맞는 것 아닌가요?
-
이 query는 table_name 칸만 가져와서 서랍 안 구조를 열지 않아.
-
직접 관찰 quantifier는 filtered public base-table name set뿐이다.
-
PK·FK·CHECK·index는 V001 줄로 따로 확인하겠습니다.
Flyway history 제외
-
Flyway table을 빼면 관리 table은 전부 제외되나요?
-
아니, 이름이 정확히 flyway_schema_history인 row 한 종류만 빠져.
-
다른 public base table은 extra actual element가 된다.
-
audit_shadow 반례를 예상 결과에 넣어 볼게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 16줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 10줄F02-L10 | @SpringBootTest |
STARRY의 schema 이름 검사표에 Spring test 조명을 켜는 표찰을 붙인다. | 다음 type declaration에 Spring Boot test context metadata를 부여하는 annotation이다.
|
| 11줄F02-L11 | class CoreSchemaIT extends PostgresIntegrationTestSupport { |
네 table 이름 검사관을 공용 PostgreSQL 실습 받침대 위에 세운다. | CoreSchemaIT class를 열고 PostgresIntegrationTestSupport를 상속한다.
|
| 12줄F02-L12 | @Autowired JdbcClient jdbc; |
검사관 손에 SQL 질문기 `jdbc`를 자동 연결하는 socket을 둔다. | JdbcClient type의 jdbc field를 Spring autowiring injection point로 선언한다.
|
| 14줄F02-L14 | @Test |
다음 검사 절차를 JUnit 실행 목록에 올릴 표식만 먼저 놓는다. | 바로 뒤 method declaration에 JUnit test metadata를 붙이는 annotation이다.
|
| 15줄F02-L15 | void v001CreatesExactlyTheRequiredCoreTables( |
필요한 네 core table만 있는지 확인하는 검사 절차의 작업 공간을 연다. | v001CreatesExactlyTheRequiredCoreTables test method body를 시작한다.
|
| 16줄F02-L16 | var tables = |
조회 결과를 받을 `tables` 바구니와 SQL 질문기의 빈 두루마리를 연결한다. | var tables 대입문의 오른쪽에서 `jdbc.sql` text block 호출을 시작한다.
|
| 17줄F02-L17 | SELECT table_name FROM information_schema. |
catalog 재고표에서 각 row의 table_name 칸만 꺼내도록 적는다. | information_schema.tables에서 table_name을 SELECT한다.
|
| 18줄F02-L18 | WHERE table_schema= |
public 방의 실제 BASE TABLE 표찰만 검사선에 남긴다. | table_schema가 public이고 table_type이 BASE TABLE인 row로 filter한다.
|
| 19줄F02-L19 | AND table_name <> 'flyway_schema_history' |
Flyway가 쓰는 작업 일지 서랍 이름은 제품 재고에서 따로 뺀다. | table_name이 flyway_schema_history인 row를 제외한다.
|
| 20줄F02-L20 | ORDER BY table_name |
남은 table 이름표를 문자 오름차순으로 정렬해 전달하게 한다. | SQL 결과를 table_name ascending order로 정렬한다.
|
| 21줄F02-L21 | """). |
SQL 두루마리를 닫고 각 이름을 String으로 읽어 list를 완성한다. | text block을 닫고 query(String.class).list()로 SQL을 실행한다.
|
| 22줄F02-L22 | assertThat( |
가져온 이름 목록을 AssertJ collection 저울 위에 올린다. | tables를 `assertThat`에 넘겨 collection assertion chain을 시작한다.
|
| 23줄F02-L23 | . |
저울이 틀릴 때 보일 W6D1 진단 이름표를 매단다. | assertion description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
|
| 24줄F02-L24 | . |
실제 이름 바구니가 네 장의 예상표와 정확히 같은 묶음인지 순서 없이 맞춘다. | 네 expected table name과 actual collection의 크기·membership을 exact하게 assert한다.
|
| 25줄F02-L25 | } |
네 이름 대조 절차의 작업 공간을 닫는다. | closing brace로 v001CreatesExactlyTheRequiredCoreTables의 lexical scope를 마감한다.
|
| 26줄F02-L26 | } |
CoreSchemaIT 검사실의 바깥벽을 닫아 staged type 정의를 끝낸다. | CoreSchemaIT type definition의 마지막 brace를 적는다.
|
정렬과 matcher
-
ORDER BY가 있는데 왜 InAnyOrder를 쓰죠?
-
query 출력은 읽기 좋게 정렬하고 test 목적은 네 원소의 exact membership이기 때문이야.
-
deterministic sequence와 assertion predicate를 혼동하면 안 된다.
-
순서를 바꾼 같은 set도 통과한다고 기록할게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 22줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 6개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore; | 이 Java 파일의 package 주소를 `com.example.financialcore`로 정한다. |
| 3 | import org.junit.jupiter.api.Test; | 뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 4 | import org.springframework.beans.factory.annotation.Autowired; | 뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 5 | import org.springframework.boot.test.context.SpringBootTest; | 뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 6 | import org.springframework.jdbc.core.simple.JdbcClient; | 뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 8 | import 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이다. |
| 11 | class 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.tables | information_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_name | SQL 결과를 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를 적는다. |
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하게 본다.
실행 순서
- Spring/inherited PostgreSQL support가 test fixture를 준비한다.
- catalog query가 filtered table names를 읽는다.
- 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가 생긴다.
첫 실패 경계
-
assertion만 실패 지점인가요?
-
Spring bootstrap·주입·catalog query·String mapping도 먼저 실패할 수 있어.
-
terminal matcher까지 도달해야 four-name comparison이 실제 관찰된다.
-
실행 순서대로 failure boundary를 test 카드에 쓰겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| catalog candidate | public account, four core tables, flyway_schema_history, optional view | schema/type/name predicates를 적용한다. | public BASE TABLE 중 Flyway history를 뺀 names만 남는다. | 다른 관리 base table은 그대로 남아 extra-name failure를 만들 수 있다. |
| row mapping | SELECT table_name 결과 rows | String.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_shadow | exact collection matcher를 실행한다. | expected에 없는 extra table 때문에 실패한다. | 실패 description은 원인을 label할 뿐 schema를 고치지 않는다. |
stage source와 runner
-
selector 이름이 같으면 이 file을 실행한 거죠?
-
FQCN 표찰만 같고 active source set에 staged bytes가 없을 수 있어.
-
runner XML은 class/count를 보지만 source SHA를 기록하지 않는다.
-
stage path·SHA·fresh report를 별도 binding으로 요구하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
@SpringBootTest class와 inherited support를 통해 integration fixture bootstrap을 시도한다.
source만 읽는 것으로 실제 bootstrap success를 관찰할 수 없다.information_schema.tables가 visible relation metadata row를 제공한다.
projection에 없는 column/constraint/index metadata는 assertion으로 전달되지 않는다.SQL을 실행하고 table_name first column을 List<String>으로 materialize한다.
query failure는 AssertJ 단계 전에 test를 중단한다.collection multiplicity·size·membership을 expected four names와 비교한다.
W6D1_RED label은 diagnostic text이지 별도 Red artifact가 아니다.이 파일의 @Test가 실제로 고정하는 범위
v001CreatesExactlyTheRequiredCoreTables
- Spring Boot test context와 inherited PostgreSQL support가 준비된다는 전제가 있다.
- JdbcClient bean이 field에 주입돼야 한다.
- information_schema.tables에서 public BASE TABLE table_name을 읽는다.
- flyway_schema_history를 빼고 table_name으로 정렬해 String list로 만든다.
- 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에 도달한다.
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가 없다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
column type·nullability·PK/FK/UNIQUE/CHECK/index 일치
이 책임을 맡는 곳: V001 source audit와 deeper catalog testsactive root가 this staged SHA를 compile·execute함
이 책임을 맡는 곳: source composition manifest, hash binding, fresh test reportACCOUNT/BUSINESS_TX 1:N LEDGER_ENTRY 관계
이 책임을 맡는 곳: FK DDL, ERD, fixture join evidencefunctional dependency와 1NF~BCNF 판정
이 책임을 맡는 곳: modeling analysis and human reviewSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
SQL의 projection·filter·terminal matcher가 각각 관찰하는 정보만 한 줄씩 적는다.
2단계 · 코드 조각 재조립
- projection=table_name only
- filter=public + BASE TABLE - flyway history
- actual=List<String>
- 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를 같은 증거로 취급하지 않았는가?
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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");
}
}
03OpeningIntegrationTest.java — opening 성공 뒤 세 scalar count 확인
reference/w6/day-3/OpeningIntegrationTest.java
누적 W6 JUnit 단계 참고본 · 정본 · W15-F0321줄 연결29줄 번역4 chunks
OpeningIntegrationTest.java — opening 성공 뒤 세 scalar count 확인
reference/w6/day-3/OpeningIntegrationTest.java
누적 W6 JUnit 단계 참고본 · 정본 · W15-F03STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W6D3 staged opening test의 입력·정리·세 사후 SELECT와 method 이름보다 좁은 proof 범위를 읽는다.
- BeforeEach는 어떤 table과 identity를 정리할까?
- open에 들어가는 owner·account number·amount는 무엇일까?
- 세 COUNT는 어떤 predicate와 순서로 실행될까?
- method 이름의 Atomically를 failure rollback proof로 읽어도 될까?
- 세 SELECT가 한 snapshot이라는 근거가 있을까?
owner=customer-1account_no=OPEN-100amount=12_345account count=1OPENING business_tx count=1OPENING ledger count=1@Test=1stage=W6D3STEP 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 값 일치는 확인하지 않는다.
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를 직접 보지 못한다.
무엇을 먼저 지우나
-
test가 알아서 rollback되니 clean은 필요 없지 않나요?
-
이 class에는 rollback annotation이 없고 BeforeEach가 네 table을 명시적으로 TRUNCATE해.
-
fixture reset mechanism은 source line19의 update이지 추정 transaction rollback이 아니다.
-
네 table·RESTART IDENTITY·CASCADE를 그대로 적겠습니다.
Atomically라는 이름
-
method 이름이 atomic이라고 선언했으니 충분한가요?
-
제목은 의도이고 실제로 보는 것은 성공 뒤 세 COUNT뿐이야.
-
failure hook과 실패 후 state assertion이 없으므로 rollback proof quantifier는 0이다.
-
직접 증명과 미증명 칸을 나눠 쓰겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 12줄F03-L12 | @SpringBootTest |
STARRY의 계좌 개설 보존 시험표에 Spring integration 표찰을 먼저 붙인다. | 다음 type declaration에 Spring Boot test context metadata를 부여한다.
|
| 13줄F03-L13 | class OpeningIntegrationTest extends PostgresIntegrationTestSupport { |
개설 시험실을 공용 PostgreSQL fixture 바닥판 위에 세운다. | OpeningIntegrationTest class를 열고 PostgresIntegrationTestSupport를 상속한다.
|
| 14줄F03-L14 | @Autowired JdbcClient jdbc; |
행 수를 물을 JdbcClient 계수기를 첫 번째 자동 주입 socket에 꽂는다. | jdbc field를 autowired JdbcClient injection point로 선언한다.
|
| 15줄F03-L15 | @Autowired AccountOpeningService openings; |
실제 계좌 개설 절차를 부를 openings 창구도 자동 연결한다. | openings field를 autowired AccountOpeningService injection point로 선언한다.
|
| 17줄F03-L17 | @BeforeEach |
각 시험 전에 한 번 실행할 준비 절차 표식만 먼저 놓는다. | 바로 뒤 method declaration에 JUnit BeforeEach metadata를 붙인다.
|
| 18줄F03-L18 | void clean( |
fixture를 정리하는 clean 작업 공간을 연다. | void clean method body를 시작한다.
|
| 19줄F03-L19 | jdbc. |
네 table을 비우고 identity 번호도 되감으며 dependent row를 cascade 정리한다. | TRUNCATE 네 table·RESTART IDENTITY·CASCADE SQL을 JdbcClient update로 실행한다.
|
| 20줄F03-L20 | } |
fixture 청소 절차의 문을 닫는다. | clean lifecycle routine의 closing brace를 적는다.
|
| 22줄F03-L22 | @Test |
다음 개설 검사를 JUnit 실행 목록에 올릴 표식만 둔다. | 바로 뒤 method declaration에 JUnit test metadata를 붙인다.
|
| 23줄F03-L23 | void openingWritesAccountBusinessTransactionAndLedgerAtomically( |
개설 뒤 account·business transaction·ledger를 살필 시험 작업 공간을 연다. | openingWritesAccountBusinessTransactionAndLedgerAtomically method body를 시작한다.
|
| 24줄F03-L24 | var account = |
customer-1·OPEN-100·12,345 개설표를 openings 창구에 내고 반환 계좌를 받는다. | AccountOpeningService.open을 세 concrete argument로 호출해 반환값을 account에 대입한다.
|
| 25줄F03-L25 | assertThat( |
반환 id와 같은 account row 수를 셀 SQL을 assertion 저울에 걸기 시작한다. | account table의 matching id COUNT query와 assertThat expression을 연다.
|
| 26줄F03-L26 | . |
account id를 SQL에 끼우고 Long 한 값을 읽어 1과 비교한다. | `:id`에 account.getId()를 bind하고 scalar COUNT를 실행해 `isEqualTo(1)`로 assert한다.
|
| 27줄F03-L27 | assertThat( |
OPENING 종류 business transaction 수를 셀 두 번째 SQL 저울을 연다. | tx_type이 OPENING인 business_tx COUNT query를 assertThat에 넣기 시작한다.
|
| 28줄F03-L28 | . |
두 번째 COUNT를 Long 한 값으로 읽어 정확히 1인지 판정한다. | business_tx scalar query를 실행하고 결과를 expected 1과 비교한다.
|
| 29줄F03-L29 | assertThat( |
반환 account의 OPENING ledger 수를 셀 세 번째 SQL 저울을 연다. | account_id parameter와 entry_type OPENING 조건의 ledger_entry COUNT query를 시작한다.
|
| 30줄F03-L30 | . |
ledger SQL에 account id를 넣고 Long single 값을 assertion subject로 만든다. | `:id`를 bind해 ledger COUNT를 실행하고 반환 Long을 assertThat에 넘긴다.
|
| 31줄F03-L31 | . |
세 번째 저울 실패 시 보일 W6D3 ledger 진단표를 붙인다. | assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
|
| 32줄F03-L32 | . |
마지막 ledger COUNT가 정확히 1인지 terminal 판정을 내린다. | 세 번째 scalar assertion에 `isEqualTo(1)`을 적용한다.
|
| 33줄F03-L33 | } |
opening 성공 경로 시험의 작업 공간을 닫는다. | opening test method의 lexical scope를 closing brace로 마감한다.
|
| 34줄F03-L34 | } |
OpeningIntegrationTest 시험실의 바깥 scope를 닫는다. | OpeningIntegrationTest type definition의 최종 brace를 적는다.
|
세 COUNT의 연결
-
business_tx 한 행도 이 account의 opening이라고 확인하죠?
-
그 query는 tx_type='OPENING'만 보고 account id나 correlation을 join하지 않아.
-
세 assertions는 각각 존재 count를 보며 row 간 referential identity까지 assert하지 않는다.
-
unrelated OPENING row 반례를 남겨 둘게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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);
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 29줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 8개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.account; | 이 Java 파일의 package 주소를 `com.example.financialcore.account`로 정한다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 4 | import org.junit.jupiter.api.BeforeEach; | 뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 5 | import org.junit.jupiter.api.Test; | 뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 6 | import org.springframework.beans.factory.annotation.Autowired; | 뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 7 | import org.springframework.boot.test.context.SpringBootTest; | 뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 8 | import org.springframework.jdbc.core.simple.JdbcClient; | 뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 10 | import 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를 부여한다. |
| 13 | class 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를 적는다. |
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로 진행하지 않는다.
실행 순서
- BeforeEach clean이 네 table을 비운다.
- openings.open이 계좌 개설을 시도하고 account를 반환한다.
- account COUNT를 먼저 확인한다.
- 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 착각
-
같은 jdbc field로 조회하니 한 snapshot 아닌가요?
-
같은 client reference와 같은 transaction은 다른 개념이야.
-
source에는 @Transactional이나 explicit connection transaction 경계가 없다.
-
순차 독립 사후 조회라고 표현하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| fixture reset | idempotency_request, ledger_entry, business_tx, account의 기존 rows | TRUNCATE ... RESTART IDENTITY CASCADE update를 실행한다. | 성공하면 네 table이 비고 관련 identity가 재시작된다. | clean 자체가 실패하면 test body는 시작하지 못한다. |
| opening call | customer-1, OPEN-100, 12_345 | AccountOpeningService.open을 호출한다. | 성공하면 반환 Account reference와 id를 사용할 수 있다. | source call site는 내부 transaction·SQL sequence를 드러내지 않는다. |
| account observation | returned account.getId() | account WHERE id=:id COUNT를 읽는다. | matching account row가 하나일 때 첫 assertion이 통과한다. | owner·account_no·balance column 값은 SELECT하지 않는다. |
| tx and ledger observations | tx_type=OPENING과 returned account id + entry_type=OPENING | 두 별도 COUNT query를 순서대로 실행한다. | 각 scalar가 1이면 test method가 끝까지 도달한다. | 두 row가 같은 business_tx로 연결됐는지와 amount·balance는 읽지 않는다. |
첫 failure 경계
-
ledger assertion까지 항상 실행되나요?
-
clean이나 open이 실패할 수 있고 account count가 틀려도 뒤 SQL로 가지 않아.
-
JUnit은 첫 thrown assertion error에서 method control flow를 중단한다.
-
clean→open→세 assertions 순으로 failure boundary를 기록할게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
한 @Test 전 clean @BeforeEach가 먼저 호출된다.
cleanup after test나 rollback annotation은 이 class에 없다.injected AccountOpeningService bean의 open method가 application logic을 실행한다.
transaction annotation과 repository code는 이 source 밖이라 call site에서 추론할 수 없다.TRUNCATE 뒤 세 COUNT statement가 각각 실행되고 scalar Long을 반환한다.
test source는 공통 transaction이나 isolation level을 선언하지 않는다.각 count를 1과 비교하고 첫 failure에서 exception으로 method를 중단한다.
뒤 assertion은 앞 assertion이 통과했을 때만 관찰된다.이 파일의 @Test가 실제로 고정하는 범위
openingWritesAccountBusinessTransactionAndLedgerAtomically
- Spring context와 inherited PostgreSQL support, JdbcClient, AccountOpeningService가 준비돼야 한다.
- BeforeEach가 네 table을 TRUNCATE RESTART IDENTITY CASCADE한다.
- openings.open('customer-1','OPEN-100',12_345)를 호출해 account를 받는다.
- account id, OPENING tx_type, account id + OPENING entry_type로 세 scalar COUNT를 순차 조회한다.
- 반환 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 순서이며 어느 단계든 먼저 실패하면 뒤 관찰은 실행되지 않는다.
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 문자열은 변하지 않는다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
중간 write exception 뒤 account·business_tx·ledger가 전부 rollback됨
이 책임을 맡는 곳: failure injection integration test와 transaction implementation세 COUNT가 하나의 database snapshot을 공유함
이 책임을 맡는 곳: explicit test transaction/isolation or single aggregate observationaccount.balance·ledger amount/signed_amount/balance_after가 12_345와 일치
이 책임을 맡는 곳: value-selecting assertions and domain invariant testsidempotency_request row·replay·conflict 처리
이 책임을 맡는 곳: idempotency-specific tests and service protocolactive packaged root가 W6D3 staged SHA를 실행함
이 책임을 맡는 곳: composed source manifest, hash, fresh JUnit evidenceSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
clean→open→account count→business_tx count→ledger count 순서를 숫자와 predicate까지 적는다.
2단계 · 코드 조각 재조립
- TRUNCATE four tables + restart identity
- open(customer-1, OPEN-100, 12_345)
- account id count=1
- OPENING tx count=1
- 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가 실행되지 않음을 적었는가?
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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);
}
}
04compose.yaml — W15 schema 실험용 PostgreSQL 방
runtime/compose.yaml
정본 PostgreSQL runtime · 정본 · W15-F0418줄 연결18줄 번역5 chunks
compose.yaml — W15 schema 실험용 PostgreSQL 방
runtime/compose.yaml
정본 PostgreSQL runtime · 정본 · W15-F04STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W15 schema runner가 Compose mode에서 소유할 PostgreSQL service의 image·port·초기 환경·healthcheck·volume 계약을 읽는다.
- 왜 runner보다 먼저 runtime 설정을 읽어야 할까?
- FCL_DB_PORT가 없거나 빈 문자열이면 어느 host port를 쓸까?
- 필수 password가 없으면 container가 생기기 전에 어디서 멈출까?
- pg_isready exit 0이 schema와 인증까지 증명할까?
- named volume은 W15 실행 뒤 반드시 남을까?
service=dbpostgres:17.10-alpinehost=${FCL_DB_PORT:-5432}container=5432database=financial_coreuser=apphealth=2s/2s/30volume=financial-core-dbSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 W15 열쇠와 관계 실험을 위해 버전과 출입 규칙이 고정된 PostgreSQL 방 하나를 준비한다.
스키마 실험을 받을 DB 방
이 설정은 runner가 쓸 PostgreSQL 17.10 방의 이름, 밖·안 port, database와 user를 한곳에 모은다.
서버가 연결 요청을 받을 때까지 healthcheck를 반복하고 데이터 선반은 named volume에 연결한다.
딱 여기까지만 방이 healthy여도 app 인증·V001 적용·key 수·cardinality 결과까지 맞았다는 뜻은 아니다.
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 수명은 실행 명령에 달렸다.
왜 먼저 보는가
-
SQL부터 보면 안 되나요?
-
SQL이 들어갈 PostgreSQL 방의 버전과 이름부터 같아야 해.
-
이 파일은 runtime 계약이고 schema Green은 runner가 따로 판단한다.
-
service가 db 하나인지부터 확인할게요.
두 개의 5432
-
5432가 두 번이면 DB도 두 개인가요?
-
왼쪽은 host 문, 오른쪽은 container 안 PostgreSQL 문이야.
-
:-는 port 변수가 unset 또는 empty일 때 왼쪽 기본값을 고른다.
-
55432를 넣은 경우도 따로 적어 볼게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 18줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F04-L01 | services: |
STARRY 임시 연습실 목록의 맨 표지를 펼친다. | Compose 문서에서 services 최상위 mapping을 시작한다.
|
| 2줄F04-L02 | db: |
목록에 `db`라고 적힌 한 칸짜리 방 열쇠를 건다. | services 아래에 이름이 db인 service 정의를 연다.
|
| 3줄F04-L03 | image: |
DB 방의 재료 상자에 PostgreSQL 17.10 Alpine 꼬리표를 붙인다. | db container image를 postgres:17.10-alpine으로 지정한다.
|
| 4줄F04-L04 | ports: |
건물 밖과 DB 방 안을 잇는 문 번호 묶음을 펼친다. | db service의 ports 배열을 시작한다.
|
| 5줄F04-L05 | - "${ |
밖 번호표가 미설정이거나 빈칸이면 5432를 쓰고 안쪽 5432번 문에 연결한다. | FCL_DB_PORT가 미설정이거나 빈 문자열이면 기본 5432를, 값이 있으면 그 값을 host port로 써 container 5432에 publish한다.
|
| 6줄F04-L06 | environment: |
DB가 시작할 때 받을 설정 쪽지 봉투를 연다. | db container에 전달할 environment mapping을 시작한다.
|
| 7줄F04-L07 | POSTGRES_DB: |
첫 쪽지에 만들 database 이름 `financial_core`를 적는다. | POSTGRES_DB 환경값을 financial_core로 설정한다.
|
| 8줄F04-L08 | POSTGRES_USER: |
둘째 쪽지에는 접속 사용자 이름 `app`을 쓴다. | POSTGRES_USER 환경값을 app으로 설정한다.
|
| 9줄F04-L09 | POSTGRES_PASSWORD: |
셋째 쪽지는 terminal에 없거나 내용이 빈 봉투면 접수대에서 막고, 공백 글자만 든 봉투는 일단 값이 있는 것으로 본다. | Compose의 ${FCL_DB_PASSWORD:?message} 보간은 변수가 unset 또는 empty string이면 config error를 내고 whitespace-only nonempty 값은 치환을 통과시킨다.
|
| 10줄F04-L10 | healthcheck: |
DB service 안에 아직 내용이 없는 건강 점검표 칸을 펼친다. | db service 아래에 healthcheck child mapping scope를 시작한다.
|
| 11줄F04-L11 | test: |
`app`·`financial_core` 목적지표를 달고 서버가 연결을 받는지만 묻는 초인종을 단다. | CMD-SHELL에서 -U app과 -d financial_core를 probe target으로 붙여 pg_isready를 실행한다.
|
| 12줄F04-L12 | interval: |
준비 확인 벨이 2초마다 다시 울리도록 간격을 맞춘다. | healthcheck 실행 간격을 2초로 지정한다.
|
| 13줄F04-L13 | timeout: |
한 번 문을 두드리고 기다릴 시간은 2초로 자른다. | 각 healthcheck 시도의 timeout을 2초로 지정한다.
|
| 14줄F04-L14 | retries: |
준비 확인이 30번 연속 실패하면 `unhealthy` 표를 붙이는 기준을 둔다. | 연속 healthcheck 실패 30회를 unhealthy 판정 임계값으로 지정한다.
|
| 15줄F04-L15 | volumes: |
DB 방 바닥과 기록 창고를 이을 끈 목록을 펼친다. | db service에 연결할 volumes 배열을 시작한다.
|
| 16줄F04-L16 | - financial-core-db: |
`financial-core-db` 창고를 PostgreSQL 자료 선반에 바로 잇는다. | named volume financial-core-db를 /var/lib/postgresql/data에 mount한다.
|
| 18줄F04-L18 | volumes: |
서비스 밖에서도 부를 공용 기록 창고 명부를 연다. | 문서 최상위 volumes mapping을 시작한다.
|
| 19줄F04-L19 | financial-core-db: |
명부에 `financial-core-db` 창고 이름만 등록한다. | financial-core-db named volume을 기본 옵션으로 선언한다.
|
필수 password
-
실험용이면 password가 비어도 켜지나요?
-
물음표 보간식이 config 단계에서 빈 값을 거절해.
-
존재 검사는 secret storage가 아니라 fail-fast 설정이다.
-
값이 있는 경우와 없는 경우를 나눠 보겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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:
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 18줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | services: | Compose 문서에서 services 최상위 mapping을 시작한다. |
| 2 | db: | services 아래에 이름이 db인 service 정의를 연다. |
| 3 | image: postgres:17.10-alpine | db 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_core | POSTGRES_DB 환경값을 financial_core로 설정한다. |
| 8 | POSTGRES_USER: app | POSTGRES_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: 2s | healthcheck 실행 간격을 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/data | named volume financial-core-db를 /var/lib/postgresql/data에 mount한다. |
| 18 | volumes: | 문서 최상위 volumes mapping을 시작한다. |
| 19 | financial-core-db: | financial-core-db named volume을 기본 옵션으로 선언한다. |
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의 콜론 양쪽 역할은 다르다.
실행 순서
- YAML parse
- environment interpolation
- container configuration
- PostgreSQL initialization when data directory is empty
- health monitoring
- 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의 작은 범위
-
healthy면 V001과 key도 맞았나요?
-
아니, 여기서는 server가 연결을 받을 상태인지 본 거야.
-
인증·schema·index·성능은 pg_isready exit가 직접 증명하지 않는다.
-
readiness와 runner gate를 다른 칸에 적을게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | FCL_DB_PORT unset, FCL_DB_PASSWORD=lab-secret | 두 보간식을 평가한다. | 5432:5432와 password 환경값이 config에 들어간다. | password가 없거나 빈 값이면 config 단계에서 실패한다. |
| 2 | 빈 financial-core-db volume | PostgreSQL image entrypoint가 init 환경을 읽는다. | financial_core database와 app user를 만들 초기화 요청이 수행된다. | 기존 data directory에는 같은 init 과정이 다시 적용되지 않을 수 있다. |
| 3 | server process는 떴지만 아직 connection을 받지 않는 상태 | 2초 간격·시도당 2초 timeout으로 pg_isready를 반복한다. | accepting 상태 응답이 Docker health 판정에 반영된다. | retries=30은 정확한 60초 상한이나 총 검사 횟수가 아니다. |
| 4 | /var/lib/postgresql/data에 기록할 page | named volume mount로 보낸다. | container writable layer 밖 volume에 DB data가 놓인다. | W15 Compose owner가 down -v를 실행하면 이 disposable volume 제거를 요청한다. |
volume의 수명
-
named volume이면 실험 결과가 계속 남죠?
-
Compose owner가 down -v를 부르면 제거 요청이 간다.
-
mount 선언은 backup이나 영구 보존 계약이 아니다.
-
실행 mode와 cleanup flag를 함께 확인하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
YAML mapping/list와 환경 보간을 service model로 만든다.
parse 성공은 Docker engine 사용 가능성을 보장하지 않는다.image·port·environment·mount·healthcheck를 container에 적용한다.
host port와 volume lifecycle은 project 이름과 명령에도 좌우된다.빈 data directory에서 POSTGRES_* 초기값으로 cluster를 준비한다.
기존 volume의 schema를 자동 교정하지 않는다.pg_isready exit를 주기적으로 읽어 starting/healthy/unhealthy를 갱신한다.
V001·seed·key·index·latency는 검사하지 않는다.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는 소유권·정리 경계가 다르다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
app 인증·database 존재·V001 적용·query 성공
이 책임을 맡는 곳: runner psql and schema gatesimmutable PostgreSQL image bytes
이 책임을 맡는 곳: digest-pinned deployment manifestPK·FK·UNIQUE·index·cardinality 결과
이 책임을 맡는 곳: run-w15-schema.ps1 discovery and checksbackup·restore·HA·volume 영구 보존
이 책임을 맡는 곳: operations and backup designSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
db service→image→port→세 환경값→healthcheck→mount→top-level volume 순서로 말한다.
2단계 · 코드 조각 재조립
- services/db/image/ports
- POSTGRES_DB/USER/PASSWORD
- pg_isready/2s/2s/30
- service mount/top-level named volume
3단계 · 파일 전체 다시 쓰기
blank 한 줄을 포함한 19개 물리 줄을 다시 쓰고 canonical SHA-256과 대조한다.
자가 점검
- image tag와 service 이름을 확인한다.
- 두 port의 방향을 바꾸지 않는다.
- password 보간식의 물음표를 유지한다.
- pg_isready를 인증·schema proof라고 쓰지 않는다.
- volume 수명을 실행 mode와 분리한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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:
05run-w15-schema.ps1 — targeted tests와 격리 schema 관찰 owner
scripts/run-w15-schema.ps1
정본 W15 schema 실행기 · 정본 · W15-F0555줄 연결55줄 번역9 chunks
run-w15-schema.ps1 — targeted tests와 격리 schema 관찰 owner
scripts/run-w15-schema.ps1
정본 W15 schema 실행기 · 정본 · W15-F05STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
두 staged selector의 XML Green과 w15_schema의 constraint·index·두-account cardinality 관찰을 한 owner 흐름으로 결속한다.
- 왜 bare packaged root는 그대로 Green이라고 할 수 없을까?
- key gate는 exact인가 minimum인가?
- index count는 조건인가 기록인가?
- evidence file 존재만으로 Green일까?
- Container mode가 supplied container에 무엇을 남길까?
selectors=2tests>=2failures/errors/skipped=0schema=w15_schemacardinality=1,2|2,0PK>=4FK>=2UNIQUE>=2indexes=record-onlymarker=W15_SCHEMA_GREENSTEP 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 성능을 직접 증명하지 않는다.
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를 읽는가
-
ProjectRoot를 주면 모든 dependency도 거기서 찾나요?
-
test와 V001 기준 root는 바뀌지만 Compose file은 packaged referenceRoot를 써.
-
evidence path는 rooted 여부를 나눠 full path로 만들고 source hash는 runner가 확인하지 않는다.
-
root·compose·evidence 세 주소를 따로 표시할게요.
두 selector의 실제 범위
-
tests가 둘 이상이면 bare package도 바로 Green인가요?
-
기본 active src/test에는 두 exact class가 없어서 stage 조립이 먼저 필요해.
-
XML set equality와 aggregate tests>=2는 source SHA나 class별 한 개 조건까지 증명하지 않는다.
-
selector file 위치와 XML class를 함께 대조하겠습니다.
부분 evidence
-
keys.txt가 있으면 그 run은 Green이었나요?
-
세 파일은 minimum-key check보다 먼저 따로 쓰여.
-
후속 실패나 중간 write 실패가 있으면 일부 파일만 남을 수 있어 marker와 exit를 같이 봐야 한다.
-
file presence만으로 합격 처리하지 않겠습니다.
Green marker의 한계
-
W15_SCHEMA_GREEN이면 ERD와 3NF도 증명됐나요?
-
marker는 targeted tests와 isolated schema의 정해진 gate만 요약해.
-
ERD 의미·formal normalization·full suite·production 성능은 다른 검토 책임이다.
-
직접 보장과 보장하지 않음을 두 칸으로 답할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 55줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F05-L01 | param( |
두 실행 mode와 증거 주소를 한 번에 고르는 W15 조작판을 펼친다. | Compose/Container mode, container, project, evidence, project-root parameters와 defaults를 선언한다.
|
| 2줄F05-L02 | $ErrorActionPreference= |
작은 오류도 빨간 경보로 올리도록 스위치를 Stop에 둔다. | $ErrorActionPreference를 Stop으로 설정해 PowerShell non-terminating error를 terminating error로 승격한다.
|
| 3줄F05-L03 | $referenceRoot= |
runner 파일이 든 scripts 서랍의 바로 위를 기준 책상으로 가리킨다. | $PSScriptRoot의 parent를 계산해 packaged reference root 후보로 저장한다.
|
| 4줄F05-L04 | $root= |
호출자가 준 캠프가 있으면 찾아가고 없으면 기준 책상에 남는다. | ProjectRoot가 truthy면 Resolve-Path 결과를, 아니면 referenceRoot를 $root로 선택한다.
|
| 5줄F05-L05 | $e= |
증거 상자 주소가 이미 완전하면 그대로 다듬고 상대 주소면 캠프 아래에 붙인다. | EvidenceDir의 rooted 여부에 따라 직접 또는 $root와 결합한 뒤 GetFullPath로 $e를 만든다.
|
| 6줄F05-L06 | New-Item -ItemType Directory -Force $e|Out-Null |
증거 상자가 없으면 만들고 이미 있으면 같은 상자를 계속 쓴다. | New-Item Directory -Force로 $e directory를 만들고 pipeline output을 버린다.
|
| 7줄F05-L07 | Push-Location $root |
작업자가 선택된 project 방 안으로 발을 옮긴다. | Push-Location으로 current location을 $root에 쌓아 전환한다.
|
| 8줄F05-L08 | $ownedCompose= |
Compose 방을 아직 내가 열지 않았다는 소유권 불을 끈 상태로 둔다. | $ownedCompose를 false로 초기화한다.
|
| 9줄F05-L09 | try{ |
검사 구간을 감쌀 안전 봉투의 앞면을 연다. | try keyword가 protected statement scope의 시작을 선언한다.
|
| 10줄F05-L10 | & . |
CoreSchema와 Opening 두 장의 시험표만 Gradle 접수대에 낸다. | Gradle test task를 두 exact class selector와 --no-daemon으로 실행한다.
|
| 11줄F05-L11 | if( |
Gradle 접수 결과가 0이 아니면 즉시 실패 종을 울린다. | LASTEXITCODE가 nonzero이면 exit 값을 담은 W15 Gradle 예외를 던진다.
|
| 12줄F05-L12 | $expected= |
합격 명단에 허용할 두 class 이름을 정확히 적는다. | $expected array에 CoreSchemaIT와 OpeningIntegrationTest FQCN 두 개를 저장한다.
|
| 13줄F05-L13 | $rows= |
시험 결과 봉투들을 열어 class와 네 개 숫자를 한 줄 표로 옮긴다. | TEST-*.xml 각각을 XML로 읽어 suite name/tests/failures/errors/skipped object array를 만든다.
|
| 14줄F05-L14 | $classes= |
결과 표의 class 이름만 뽑아 중복을 없애고 사전순으로 놓는다. | $rows.class를 Sort-Object -Unique로 정규화해 $classes array를 만든다.
|
| 15줄F05-L15 | if( |
허용 명단과 실제 명단의 양쪽 차이를 한 글자도 허용하지 않는다. | sorted expected set과 actual classes를 Compare-Object하고 차이가 있으면 mismatch 예외를 던진다.
|
| 16줄F05-L16 | $tests= |
모든 결과 봉투의 시험·실패·오류·건너뜀 숫자를 각 통에 합친다. | rows의 tests/failures/errors/skipped field를 Measure-Object -Sum으로 각각 집계한다.
|
| 17줄F05-L17 | if( |
시험은 둘 이상, 나쁜 숫자는 모두 0이라는 문턱을 세운다. | tests<2 또는 failures/errors/skipped가 0이 아니면 W15 XML not Green 예외를 던진다.
|
| 18줄F05-L18 | if( |
Mode 표지가 Compose일 때만 들어가는 왼쪽 통로를 연다. | $Mode가 대소문자 비민감 eq Compose이면 실행할 branch scope를 시작한다.
|
| 19줄F05-L19 | if( |
Compose 통로에서 별도 container 이름까지 들고 오면 입구에서 거절한다. | Compose mode인데 Container 문자열이 truthy이면 예외를 던진다.
|
| 20줄F05-L20 | $composeFile= |
기준 project 옆 compose.yaml 주소표를 만든다. | referenceRoot와 compose.yaml을 Join-Path해 $composeFile에 저장한다.
|
| 21줄F05-L21 | if( |
주소표가 실제 일반 파일을 가리키는지 문 앞에서 확인한다. | Test-Path Leaf가 false이면 W15 compose.yaml missing 예외를 던진다.
|
| 22줄F05-L22 | if( |
비밀번호 쪽지가 비었을 때만 폐기용 실험 암호를 채운다. | FCL_DB_PASSWORD가 null/empty/whitespace이면 process environment에 disposable default를 설정한다.
|
| 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다.
|
| 24줄F05-L24 | # removed by finally even when `up --wait` fails. |
up --wait 실패까지 정리 대상이라는 설명표의 뒷 절반을 붙인다. | finally가 failed startup의 부분 resource도 제거한다는 comment를 완성한다.
|
| 25줄F05-L25 | $ownedCompose= |
이 Compose project는 내가 치워야 한다는 소유권 불을 켠다. | $ownedCompose를 true로 바꾼다.
|
| 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 실행한다.
|
| 27줄F05-L27 | if( |
Compose 시동 native 결과가 0이 아니면 그 code로 실패한다. | LASTEXITCODE nonzero를 W15 compose up exception으로 바꾼다.
|
| 28줄F05-L28 | $Container= |
방금 project의 db container ID를 조회해 공백을 잘라 보관한다. | docker compose ps -q db stdout을 Trim해 $Container에 대입한다.
|
| 29줄F05-L29 | if( |
조회 실패나 빈 ID를 모두 container resolution 실패로 묶는다. | LASTEXITCODE nonzero 또는 Container whitespace이면 예외를 던진다.
|
| 30줄F05-L30 | }else{ |
첫 mode 조건이 아니었을 때 들어갈 대체 통로를 연다. | Compose 조건의 else branch scope를 시작한다.
|
| 31줄F05-L31 | if( |
대체 통로에 container 이름표가 없으면 즉시 돌려보낸다. | Container가 null/empty/whitespace이면 Container mode requires 예외를 던진다.
|
| 32줄F05-L32 | $running= |
지정 object의 running 불빛과 image 표지를 한 줄로 조회한다. | docker inspect format으로 State.Running|Config.Image를 받고 Trim해 $running에 저장한다.
|
| 33줄F05-L33 | if( |
true와 postgres: 접두사가 함께 있는 supplied container만 통과시킨다. | inspect exit가 nonzero이거나 output이 case-sensitive ^true|postgres: pattern과 다르면 예외를 던진다.
|
| 34줄F05-L34 | } |
두 mode 중 선택된 container 준비 통로를 닫는다. | Compose/Container conditional scope를 종료한다.
|
| 35줄F05-L35 | $v001= |
선택한 project root에서 V001 문서를 통째로 읽어 든다. | root 아래 V001__common.sql을 Get-Content -Raw로 읽어 $v001 문자열에 저장한다.
|
| 36줄F05-L36 | $seed= |
두 account와 거래·원장 fixture를 적은 긴 SQL 두루마리를 memory 선반에 말아 둔다. | account 두 tuple, business_tx 두 tuple, W15-1을 고르는 ledger INSERT 두 개가 든 SQL literal을 $seed에 저장한다.
|
| 37줄F05-L37 | $setup= |
기존 w15_schema를 치우고 새 schema·V001·seed를 잇는 실행 대본을 조립한다. | conditional DROP SCHEMA CASCADE, CREATE/SET search_path, v001, seed를 $setup string으로 결합한다.
|
| 38줄F05-L38 | $setup|docker exec -i $Container psql -v ON_ERROR_STOP= |
조립한 대본을 container psql 표준입력으로 밀어 넣는다. | $setup을 docker exec -i psql ON_ERROR_STOP=1에 pipe하고 normal output을 버린다.
|
| 39줄F05-L39 | if( |
격리 V001 적용 native code가 0이 아니면 즉시 실패 표를 붙인다. | LASTEXITCODE nonzero를 W15 isolated V001 apply exception으로 바꾼다.
|
| 40줄F05-L40 | $keys= |
네 table의 constraint 명찰을 type과 name까지 catalog에서 모은다. | information_schema.table_constraints를 조회해 table|type|name strings를 정렬된 $keys array로 받는다.
|
| 41줄F05-L41 | if( |
constraint catalog 조회 native 실패를 별도 오류로 막는다. | 직전 keys psql LASTEXITCODE가 nonzero이면 keys query failed 예외를 던진다.
|
| 42줄F05-L42 | $schema= |
w15_schema의 index 이름과 정의를 모두 사전순으로 모은다. | pg_indexes에서 indexname|indexdef를 조회해 $schema array로 받는다.
|
| 43줄F05-L43 | if( |
index catalog 조회 native code가 나쁘면 schema query 실패로 중단한다. | 직전 index psql LASTEXITCODE nonzero를 exception으로 바꾼다.
|
| 44줄F05-L44 | $card= |
account마다 연결된 ledger 수를 LEFT JOIN으로 세어 두 열 CSV처럼 받는다. | account-left-ledger count query를 id 순서로 실행해 comma-separated $card array를 만든다.
|
| 45줄F05-L45 | if( |
account-left-ledger 집계 호출 자체가 실패하면 그 자리에서 멈춘다. | 직전 cardinality psql LASTEXITCODE nonzero를 query failed 예외로 바꾼다.
|
| 46줄F05-L46 | if( |
두 행이 정확히 1,2와 2,0인지 순서까지 잠근다. | card.Count=2와 case-sensitive card[0]/card[1] literal equality를 아니면 예외로 검사한다.
|
| 47줄F05-L47 | $keys|Set-Content -Encoding utf8 ( |
constraint·cardinality·index 관찰을 세 개 파일에 따로 적는다. | keys.txt, header 포함 cardinality.csv, schema.txt를 세 Set-Content 호출로 UTF-8 기록한다.
|
| 48줄F05-L48 | if( |
PK 네 개·FK 두 개·UNIQUE 두 개보다 적으면 관찰표가 있어도 Red로 만든다. | keys strings를 regex 분류해 PK>=4, FK>=2, UNIQUE>=2 lower-bound를 검사한다.
|
| 49줄F05-L49 | $gate= |
현재 schema 이름과 관찰 숫자를 순서 있는 gate 표에 모은다. | schema/migration/seed/cardinality/key/fk/unique/index/test counters를 ordered hashtable로 만든다.
|
| 50줄F05-L50 | $gate|ConvertTo-Json|Set-Content -Encoding utf8 ( |
gate 표를 JSON으로 바꿔 schema-gate.json에 쓴다. | $gate를 ConvertTo-Json하고 UTF-8 Set-Content로 evidence file에 기록한다.
|
| 51줄F05-L51 | "W15_SCHEMA_GREEN keys= |
모든 앞 조건을 지난 run만 W15_SCHEMA_GREEN 한 줄을 stdout에 남긴다. | gate counters를 보간한 Green marker string을 pipeline output으로 방출한다.
|
| 52줄F05-L52 | }finally{ |
성공과 실패 어느 쪽에서도 들어갈 정리 봉투를 연다. | try와 결속된 finally block을 시작한다.
|
| 53줄F05-L53 | if( |
내가 연 Compose project만 volume까지 내리고 native 실패도 오류로 올린다. | ownedCompose가 true이면 docker compose down -v를 실행하고 nonzero LASTEXITCODE에 Write-Error한다.
|
| 54줄F05-L54 | Pop-Location |
작업자가 처음 서 있던 directory로 location stack을 한 칸 되돌린다. | Pop-Location으로 Push-Location 이전 current directory를 복원한다.
|
| 55줄F05-L55 | } |
W15 검사와 cleanup을 감싼 마지막 봉투를 닫는다. | finally와 script의 outer statement scope를 종료한다.
|
Compose 소유권
-
up --wait가 중간 실패하면 정리 flag도 못 켜나요?
-
flag를 startup보다 먼저 true로 바꿔 partial project도 finally가 맡게 했어.
-
down -v native 실패는 다시 오류가 되고 resource가 일부 남을 수 있다.
-
ownedCompose가 바뀌는 위치를 먼저 찾을게요.
STEP 05 / 13
원본 코드 조각
원본을 9개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 55줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | param([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를 만든다. |
| 6 | New-Item -ItemType Directory -Force $e|Out-Null | New-Item Directory -Force로 $e directory를 만들고 pipeline output을 버린다. |
| 7 | Push-Location $root | Push-Location으로 current location을 $root에 쌓아 전환한다. |
| 8 | $ownedCompose=$false | $ownedCompose를 false로 초기화한다. |
| 9 | try{ | try keyword가 protected statement scope의 시작을 선언한다. |
| 10 | & .\gradlew.bat test --tests 'com.example.financialcore.CoreSchemaIT' --tests 'com.example.financialcore.account.OpeningIntegrationTest' --no-daemon | Gradle 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).Sum | rows의 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 is | startup 전 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 db | docker 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-Location | Pop-Location으로 Push-Location 이전 current directory를 복원한다. |
| 55 | } | finally와 script의 outer statement scope를 종료한다. |
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에 달렸다.
실행 순서
- parameter/path binding
- two-selector Gradle and XML gate
- Compose or supplied-container selection
- DROP/CREATE w15_schema with V001+seed
- constraint/index/cardinality discovery
- partial evidence writes then lower-bound key gate
- schema-gate JSON and stdout marker
- 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의 위험
-
이미 떠 있는 PostgreSQL을 주면 읽기만 하나요?
-
아니, 그 안 w15_schema를 DROP CASCADE하고 다시 만들어.
-
finally는 supplied container나 그 schema를 지우지 않으니 전용 disposable target이 필요하다.
-
실행 전 schema 이름과 container 소유자를 확인하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 두 stage class가 조립된 learner root | Gradle selectors와 XML exact class set/aggregate를 검사한다. | tests>=2와 failures=errors=skipped=0일 때 DB phase로 간다. | source SHA와 class별 test 최소는 직접 gate하지 않는다. |
| 2 | Compose mode와 unique project | ownership flag를 먼저 켜고 db up --wait와 container ID 조회를 수행한다. | 성공하면 owned PostgreSQL container ID가 준비된다. | startup/cleanup native 실패 때 partial resource가 남을 수 있다. |
| 3 | V001 text와 fixed two-account seed | w15_schema를 DROP/CREATE하고 batch를 psql에 보낸다. | fresh identity account 1/2와 ledger count 2/0 관찰 대상이 생긴다. | source hash를 runner가 비교하지 않고 explicit transaction도 없다. |
| 4 | catalog와 seeded rows | constraints·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
-
V001 파일을 읽었으니 source가 canonical인지도 확인됐죠?
-
Get-Content -Raw는 bytes를 읽지만 expected SHA와 비교하지 않아.
-
audit가 별도로 hash-bound했고 runner는 두 account와 2/0 cardinality용 seed를 덧붙인다.
-
source hash 증거와 runtime exit를 다른 줄에 적을게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
path·branch·array·error preference·try/finally state를 관리한다.
native exit는 매번 LASTEXITCODE gate가 필요하다.두 class selector를 실행하고 XML suite attributes를 만든다.
active source composition과 source SHA는 XML count 밖이다.owned project 또는 supplied container를 PostgreSQL execution target으로 제공한다.
tag prefix·running flag는 digest나 전용성 증거가 아니다.w15_schema batch를 적용하고 information_schema/pg_indexes/LEFT JOIN 결과를 반환한다.
explicit transaction·formal normalization·query plan 검증은 없다.관찰 arrays와 gate object를 여러 Set-Content 파일로 기록한다.
atomic bundle·signature·failure-run cleanup을 제공하지 않는다.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라는 이름의 함정
-
$keys에는 PK·FK·UNIQUE만 들어 있나요?
-
query는 information_schema의 constraint rows를 type 구분 없이 모아.
-
gate만 문자열로 PK>=4, FK>=2, UNIQUE>=2를 세고 CHECK나 exact count는 요구하지 않는다.
-
keys.Count와 세 분류 count를 구분하겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
bare packaged root에 두 exact selector class가 active source로 존재
이 책임을 맡는 곳: learner-stage assembly and source auditERD equality·constraint business meaning·3NF/BCNF
이 책임을 맡는 곳: migration review and learner modeling analysis모든 evidence가 final gate 뒤 한 번에 원자 commit
이 책임을 맡는 곳: atomic staging/rename evidence writersupplied container의 기존 w15_schema 보존과 post-run 제거
이 책임을 맡는 곳: caller isolation and explicit schema cleanupfull Gradle suite·production performance·all cardinalities
이 책임을 맡는 곳: separate regression, benchmark, and data-quality suitesSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
path→JUnit XML→mode/container→V001+seed→catalog/cardinality→evidence/gate→cleanup 순서로 말한다.
2단계 · 코드 조각 재조립
- exact classes + aggregate XML counters
- ownedCompose before up --wait
- DROP/CREATE w15_schema + V001 + seed
- keys/schema/card queries
- pre-gate writes + lower bounds + marker
- 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의 지위
-
indexes 숫자도 최소치를 통과해야 Green인가요?
-
pg_indexes rows를 기록하지만 if 조건에는 index count가 없어.
-
definition 목록과 count는 evidence이고 plan 사용·성능은 별도 문제다.
-
gate 조건과 record-only field를 색으로 나눌게요.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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
}
06Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율
illustrative/sql/W15-SQL-Q16.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F0621줄 연결21줄 번역5 chunks
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율
illustrative/sql/W15-SQL-Q16.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F06STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
business_date별 SUCCESS·FAILED 수를 세고, 두 수가 모두 0일 때 NULL을 내는 선택적 성공률을 계산한다.
- 왜 timestamp를 date로 cast하지 않나?
- FILTER 두 개는 어떤 status만 세나?
- 0/0 비율은 왜 NULL인가?
9 business days2026-07-02=0/0/NULL2026-07-01=2/1/66.672026-12-28=0/3/0.00Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · 두 count가 핵심
-
비율만 계산하면 Q16이 끝난 건가요?
-
아니야. prompt의 중심은 날짜별 SUCCESS와 FAILED count 두 개야.
-
rate는 0분모를 설명하려고 덧붙인 선택 열이지 필수 답을 대체하지 않아.
-
2026-07-02에서 0/0과 NULL을 따로 읽어 핵심 열과 보조 열을 구분할게요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
날짜별 두 계수기와 보조 퍼센트 창
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율
히토리는 거래 전표를 business_date별로 묶은 뒤 SUCCESS와 FAILED 도장만 각자 센다.
키타는 두 count가 모두 0이면 비율 창을 NULL로 두고, 핵심 답은 언제나 두 count라고 확인한다.
딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
날짜별 묶기
business_date가 같은 transaction을 한 group으로 모은다.
- 코드 연결
7~14줄- 비유
- 날짜별 두 계수기와 보조 퍼센트 창에서 날짜별 묶기 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
상태별 세기
FILTER로 SUCCESS와 FAILED 행을 따로 count한다.
- 코드 연결
10~11줄- 비유
- 날짜별 두 계수기와 보조 퍼센트 창에서 상태별 세기 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
0분모 막기
NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.
- 코드 연결
19줄- 비유
- 날짜별 두 계수기와 보조 퍼센트 창에서 0분모 막기 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F06-L01 | -- W15-SQL-Q16 illustrative example; |
연습장 표지에 ‘Q16 참고 풀이’ 도장을 찍어 정답지 묶음과 구별한다. | 이 파일을 W15-SQL-Q16 학습용 예시로 표시하고 배포된 workbook 정본 답안이 아니라고 한정한다.
|
| 2줄F06-L02 | -- Input grain: |
낱장 거래 전표를 날짜별 서랍 하나로 모으겠다는 분류 규칙을 붙인다. | 입력 한 행은 business_tx이고 출력 한 행은 business_date 하나라는 grain을 선언한다.
|
| 3줄F06-L03 | -- business_date is already DATE, |
시계가 아니라 달력 칸 자체를 받은 셈이라 시간대 눈금을 다시 맞추지 않는다. | business_date가 이미 DATE이므로 session TimeZone에 따라 날짜가 이동하지 않는다고 설명한다.
|
| 4줄F06-L04 | -- Optional rate column teaches the prompt's zero-denominator boundary; |
필수 두 계기판 옆에 0으로 나눌 때 꺼지는 보조 퍼센트 창을 단다. | 비율 열은 0분모를 가르치는 선택 열이고 성공·실패 count가 핵심 출력임을 밝힌다.
|
| 5줄F06-L05 | SET search_path TO : |
오늘 쓸 자료 서랍을 맨 앞에 놓고 공용 서랍은 두 번째에 둔다. | psql 변수 workbook_schema를 첫 search_path로 두고 public을 그다음 탐색 위치로 둔다.
|
| 7줄F06-L07 | WITH daily AS ( |
최종 표 전에 날짜별 숫자를 계산할 작은 작업대 daily를 펼친다. | 날짜별 중간 결과를 만들 daily 공통 테이블 식을 시작한다.
|
| 8줄F06-L08 | SELECT |
날짜별 계산대 위에 필요한 칸을 놓기 시작하는 빈 양식을 꺼낸다. | daily CTE가 내보낼 열 목록을 고르는 SELECT를 연다.
|
| 9줄F06-L09 | business_date AS business_day, |
원본 달력표의 business_date 칸에 보고서용 business_day 이름표를 붙인다. | business_date 값을 business_day라는 결과 열 이름으로 바꿔 내보낸다.
|
| 10줄F06-L10 | COUNT( |
날짜 서랍 안에서 SUCCESS 도장이 찍힌 전표만 통과시키는 계수기를 둔다. | status가 SUCCESS인 행만 COUNT하도록 FILTER하고 결과를 success_count로 부른다.
|
| 11줄F06-L11 | COUNT( |
FAILED 스티커 전표 전용으로 두 번째 숫자 바퀴를 돌린다. | status가 FAILED인 행만 골라 세고 failed_count라는 이름을 준다.
|
| 12줄F06-L12 | FROM business_tx |
날짜별 계수기에 넣을 원본 전표 상자를 business_tx로 선택한다. | 조건부 count의 입력 relation을 business_tx로 정한다.
|
| 13줄F06-L13 | GROUP BY business_date |
달력 날짜가 같은 전표를 같은 칸에 포개고 칸별 합계를 낸다. | business_date가 같은 입력 행을 하나의 집계 group으로 묶는다.
|
| 14줄F06-L14 | ) |
일별 계산을 마친 작업대에 괄호 덮개를 씌워 다음 단계로 넘긴다. | daily CTE 정의를 닫는다.
|
| 15줄F06-L15 | SELECT |
중간 계산표에서 사용자에게 보여 줄 칸만 옮길 새 보고서를 펼친다. | daily 결과를 최종 표로 투영할 SELECT를 시작한다.
|
| 16줄F06-L16 | business_day, |
완성표 첫 칸에 해당 집계 서랍의 달력 날짜를 적는다. | daily가 가진 business_day를 최종 첫 열로 출력한다.
|
| 17줄F06-L17 | success_count, |
성공 계수기의 숫자를 다시 계산하지 않고 완성표로 옮겨 적는다. | 중간 relation의 success_count를 최종 결과에 그대로 싣는다.
|
| 18줄F06-L18 | failed_count, |
실패 숫자 바퀴의 값을 완성표의 별도 칸으로 복사한다. | daily의 failed_count 열을 다음 최종 열로 선택한다.
|
| 19줄F06-L19 | ROUND( |
두 계수 합이 0일 때 계산기를 멈추고, 아니면 100배 비율을 두 자리 눈금에 맞춘다. | 성공 수를 성공+실패 합으로 나누되 NULLIF로 0분모를 NULL로 바꾸고 백분율을 소수 둘째 자리까지 반올림한다.
|
| 20줄F06-L20 | FROM daily |
완성표 재료를 원본 상자가 아닌 앞서 만든 daily 계산판에서 가져온다. | 최종 SELECT가 읽을 relation을 daily로 지정한다.
|
| 21줄F06-L21 | ORDER BY business_day; |
달력표를 과거 날짜부터 미래 날짜 방향으로 정돈한다. | business_day 오름차순으로 최종 행 순서를 정한다.
|
| 22줄F06-L22 | -- Fixture oracle: |
실행표 옆에 전체 아홉 칸과 세 날짜의 정답 눈금을 적은 작은 검산표를 붙인다. | fixture 기대를 9일과 세 대표 날짜의 성공/실패/비율 값으로 기록한다.
|
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · DATE 그대로 묶기
-
occurred_at을 date로 잘라야 날짜별 집계 아닌가요?
-
이 예시는 이미 업무 DATE인 business_date를 사용해서 변환이 필요 없어.
-
timestamp cast를 넣으면 session TimeZone 경계가 새로 생길 수도 있어.
-
저장된 business_date가 group key인지 source 9·13줄 의미로 확인하겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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.
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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가 핵심 출력임을 밝힌다. |
| 5 | SET search_path TO :"workbook_schema", public; | psql 변수 workbook_schema를 첫 search_path로 두고 public을 그다음 탐색 위치로 둔다. |
| 7 | WITH daily AS ( | 날짜별 중간 결과를 만들 daily 공통 테이블 식을 시작한다. |
| 8 | SELECT | daily 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_count | status가 FAILED인 행만 골라 세고 failed_count라는 이름을 준다. |
| 12 | FROM business_tx | 조건부 count의 입력 relation을 business_tx로 정한다. |
| 13 | GROUP BY business_date | business_date가 같은 입력 행을 하나의 집계 group으로 묶는다. |
| 14 | ) | daily CTE 정의를 닫는다. |
| 15 | SELECT | daily 결과를 최종 표로 투영할 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로 바꾸고 백분율을 소수 둘째 자리까지 반올림한다. |
| 20 | FROM daily | 최종 SELECT가 읽을 relation을 daily로 지정한다. |
| 21 | ORDER 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일과 세 대표 날짜의 성공/실패/비율 값으로 기록한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기business_date별 SUCCESS·FAILED 수를 세고, 두 수가 모두 0일 때 NULL을 내는 선택적 성공률을 계산한다.
문법 해부
- business_date가 같은 transaction을 한 group으로 모은다.
- FILTER로 SUCCESS와 FAILED 행을 따로 count한다.
- NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.
실행 순서
- SUCCESS 2와 FAILED 1을 조건부 count
- 두 filter가 모두 0을 반환
- 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
-
FAILED가 아니면 전부 SUCCESS count에 들어가나요?
-
각 COUNT는 status가 정확히 SUCCESS 또는 FAILED인 행만 골라.
-
UNKNOWN과 PROCESSING은 두 count에도 rate 분모에도 포함되지 않아.
-
같은 날짜의 status별 행을 두 filter에 넣어 서로 겹치지 않는지 추적할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 2026-07-01 rows | SUCCESS 2와 FAILED 1을 조건부 count | 2/1/66.67 | 다른 status는 분모 제외 |
| 2 | 2026-07-02 rows | 두 filter가 모두 0을 반환 | 0/0/NULL | NULL은 계산 오류가 아니라 명시한 경계 |
| 3 | 2026-12-28 rows | FAILED 세 행만 count | 0/3/0.00 | fixture 값에 한정 |
Q16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · NULLIF 경계
-
0건인 날 성공률을 0.00으로 두면 더 보기 쉽지 않나요?
-
성공도 실패도 없으면 0/0이라 성공 0%와 다른 상태야.
-
NULLIF가 분모 0을 NULL로 바꿔 division error도 막고 정보 부재도 보존해.
-
2026-07-02 결과가 0, 0, NULL인지 oracle과 맞춰 보겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
FILTER가 같은 group의 행을 두 조건으로 독립 집계한다.
planner나 index 성능은 측정하지 않는다.NULLIF가 0을 NULL로 바꾸고 ROUND가 소수 둘째 자리에 맞춘다.
SUCCESS·FAILED 이외 status는 rate 분모에 없다.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은 제외된다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
workbook이 배포한 canonical answer를 뜻하지 않는다
이 책임을 맡는 곳: 학습 과제 계약거래가 전혀 없는 달력 날짜를 생성하지 않는다
이 책임을 맡는 곳: calendar relation 또는 generate_series9일과 세 대표 값은 현재 fixture에만 맞는다
이 책임을 맡는 곳: fixture versionQ16 학습용 SQL — 날짜별 성공·실패 count와 0분모 안전 비율 · fixture 세 날짜
-
세 대표 날짜 값이면 모든 9일 결과를 다 증명하나요?
-
아니야. 주석은 전체 9일 수와 대표 정상·0분모·전부 실패 사례만 고정해.
-
나머지 날짜도 실제 query 결과로 대조해야 하고 seed 변경 뒤 숫자는 다시 계산해야 해.
-
7월 1일 2/1/66.67과 12월 28일 0/3/0.00을 우선 회귀점으로 삼을게요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 timestamp를 date로 cast하지 않나?
- FILTER 두 개는 어떤 status만 세나?
- 0/0 비율은 왜 NULL인가?
2단계 · 코드 조각 재조립
- business_date가 같은 transaction을 한 group으로 모은다.
- FILTER로 SUCCESS와 FAILED 행을 따로 count한다.
- NULLIF로 0인 분모를 NULL로 바꾼 뒤 비율을 계산한다.
3단계 · 파일 전체 다시 쓰기
illustrative/sql/W15-SQL-Q16.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture 숫자와 ID를 정확히 적었는가
- query shape와 실행 증거를 구분했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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.
07Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기
illustrative/sql/W15-SQL-Q21.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F0715줄 연결15줄 번역5 chunks
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기
illustrative/sql/W15-SQL-Q21.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F07STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
전체 account의 AVG(balance)를 한 scalar로 계산하고 그 값보다 balance가 큰 account 행만 남긴다.
- 왜 subquery가 한 값만 반환하나?
- 빈 account table이면 비교 결과는 어떻게 되나?
- CLOSED account 107은 왜 포함되나?
average=266287.375account_id=106/107/108rows=3status filter 없음Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · scalar AVG
-
AVG subquery가 account 수만큼 행을 내놓나요?
-
aggregate 결과는 전체 집합을 요약한 한 행이고 값은 평균 하나야.
-
외부 a 행들은 그 같은 scalar와 각자 balance를 비교해.
-
266287.375가 오른쪽 한 값으로 들어가는 흐름을 먼저 그려 볼게요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
전체 잔액 평균선을 넘는 계좌 명단
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기
니지카는 모든 account balance로 평균선 하나를 만든 다음 계좌 카드를 한 장씩 그 선과 비교한다.
료는 prompt에 status 조건이 없으므로 CLOSED 107도 제외하지 않고, 빈 table에서는 AVG가 NULL이라 어느 행도 TRUE가 되지 않는다고 적는다.
딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
평균 하나 만들기
상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.
- 코드 연결
12~15줄- 비유
- 전체 잔액 평균선을 넘는 계좌 명단에서 평균 하나 만들기 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
큰 값만 남기기
a.balance > average가 TRUE인 account만 통과한다.
- 코드 연결
12줄- 비유
- 전체 잔액 평균선을 넘는 계좌 명단에서 큰 값만 남기기 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
안정적으로 정렬
balance 뒤 account_id로 표시 순서를 고정한다.
- 코드 연결
16줄- 비유
- 전체 잔액 평균선을 넘는 계좌 명단에서 안정적으로 정렬 단계만 아주 짧게 떠올린다.
- 비유의 끝
- 쉬운 장면은 목적을 잡는 보조일 뿐 원본 SQL의 입력·결과·경계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 15줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F07-L01 | -- W15-SQL-Q21 illustrative example; |
평균 비교 연습지에 ‘참고 풀이’ 표찰을 달아 정본 서류와 떼어 둔다. | W15-SQL-Q21이 배포 정답이 아닌 학습용 예시임을 선언한다.
|
| 2줄F07-L02 | -- The uncorrelated scalar subquery returns exactly one AVG row, |
계좌 상자가 비어도 평균 계산대는 빈칸 하나가 든 봉투를 돌려준다. | 상관되지 않은 scalar subquery의 AVG는 account가 비어도 NULL 한 행을 반환한다고 설명한다.
|
| 3줄F07-L03 | -- Fixture oracle: |
평균선 위에 서야 할 106, 107, 108 세 번호표를 검산 카드에 적는다. | 현재 fixture 평균 266287.375와 조건을 통과하는 account_id 106·107·108을 oracle로 적는다.
|
| 4줄F07-L04 | SET search_path TO : |
평균을 낼 계좌 서랍으로 workbook 전용 칸을 검색대 첫 자리에 올린다. | workbook_schema를 우선하고 public을 보조로 하는 psql search_path를 설정한다.
|
| 6줄F07-L06 | SELECT |
평균선 통과자 명단에 어떤 신상 칸을 실을지 빈 양식을 펼친다. | 평균보다 큰 account 행에서 보여 줄 열을 고르는 SELECT를 연다.
|
| 7줄F07-L07 | a. |
선발 명단 첫 칸에 각 계좌의 고유 번호표를 옮긴다. | 별칭 a의 account_id를 결과 식별 열로 선택한다.
|
| 8줄F07-L08 | a. |
계좌 번호표 옆에 소유 고객의 번호 꼬리표를 붙인다. | 각 결과 account의 customer_id를 두 번째 열에 싣는다.
|
| 9줄F07-L09 | a. |
기계 번호 옆에 사람이 알아볼 계좌 명패를 달아 준다. | 사람이 읽는 account_no 값을 결과에 포함한다.
|
| 10줄F07-L10 | a. |
선발된 계좌마다 심사에 사용한 잔액 숫자를 명단에 적는다. | 비교 대상이자 표시값인 a.balance를 최종 열로 고른다.
|
| 11줄F07-L11 | FROM account AS a |
전체 계좌 상자를 a라는 심사용 선반에 올려놓는다. | 외부 query의 후보 relation을 account 별칭 a로 정한다.
|
| 12줄F07-L12 | WHERE a. |
각 잔액표를 오른쪽에서 받을 평균 기준선보다 위인지 검사한다. | 외부 account balance가 괄호 안 scalar 값보다 큰 행만 남기는 조건을 시작한다.
|
| 13줄F07-L13 | SELECT AVG( |
모든 peer 잔액을 한 저울에 올려 하나의 평균 눈금을 만든다. | 괄호 안에서 peer.balance 전체의 AVG를 한 값으로 계산한다.
|
| 14줄F07-L14 | FROM account AS peer |
심사 중인 한 계좌와 구분해 평균용 전체 상자에 peer 이름을 붙인다. | 평균 계산의 입력을 같은 account table의 peer 별칭으로 지정한다.
|
| 15줄F07-L15 | ) |
평균 계산 봉투를 닫아 잔액 심사표의 오른쪽 칸에 꽂는다. | 평균 scalar subquery의 괄호를 닫아 비교식을 완성한다.
|
| 16줄F07-L16 | ORDER BY a. |
평균선 위 명단을 잔액이 작은 순으로 놓고 동률이면 계좌 번호로 잇는다. | balance 오름차순 뒤 account_id를 tie-breaker로 사용해 결과를 정렬한다.
|
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · 빈 account
-
table이 비면 scalar subquery가 0행이라 오류인가요?
-
AVG aggregate는 한 행을 반환하지만 그 값이 NULL이야.
-
balance > NULL은 UNKNOWN이라 WHERE를 통과하는 외부 행도 없어.
-
aggregate 한 행과 일반 다중행 subquery를 구분해 경계를 적겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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로 적는다. |
| 4 | SET search_path TO :"workbook_schema", public; | workbook_schema를 우선하고 public을 보조로 하는 psql search_path를 설정한다. |
| 6 | SELECT | 평균보다 큰 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를 최종 열로 고른다. |
| 11 | FROM account AS a | 외부 query의 후보 relation을 account 별칭 a로 정한다. |
| 12 | WHERE a.balance > ( | 외부 account balance가 괄호 안 scalar 값보다 큰 행만 남기는 조건을 시작한다. |
| 13 | SELECT AVG(peer.balance) | 괄호 안에서 peer.balance 전체의 AVG를 한 값으로 계산한다. |
| 14 | FROM account AS peer | 평균 계산의 입력을 같은 account table의 peer 별칭으로 지정한다. |
| 15 | ) | 평균 scalar subquery의 괄호를 닫아 비교식을 완성한다. |
| 16 | ORDER BY a.balance, a.account_id; | balance 오름차순 뒤 account_id를 tie-breaker로 사용해 결과를 정렬한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기전체 account의 AVG(balance)를 한 scalar로 계산하고 그 값보다 balance가 큰 account 행만 남긴다.
문법 해부
- 상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.
- a.balance > average가 TRUE인 account만 통과한다.
- balance 뒤 account_id로 표시 순서를 고정한다.
실행 순서
- AVG를 한 번 계산
- 각 balance를 평균과 비교
- 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
-
닫힌 account 107은 자동으로 제외되죠?
-
이 SQL에는 status filter가 없고 prompt도 전체 account 평균 비교만 요구해.
-
따라서 107의 balance가 평균보다 크면 그대로 결과에 포함돼.
-
oracle ID 106·107·108 중 107을 지우지 않았는지 확인하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | fixture account balances | AVG를 한 번 계산 | 266287.375 scalar | fixture가 바뀌면 평균 재계산 |
| 2 | account 106/107/108 | 각 balance를 평균과 비교 | 세 행 TRUE | status 조건은 없음 |
| 3 | empty account relation | AVG가 NULL인 scalar 한 행 반환 | WHERE comparison은 TRUE가 아님 | 일반 nonaggregate subquery와 구분 |
Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · strict greater-than
-
평균과 balance가 같아도 평균 이상이니 남나요?
-
연산자가 >라 equality는 FALSE야.
-
>=로 바꾸면 다른 문제를 푸는 셈이고 현재 source와 달라.
-
경계 fixture에 평균과 같은 계좌를 넣었을 때 제외되는지 생각해 볼게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
상관 열이 없어 외부 a와 독립적으로 전체 peer balance를 요약한다.
optimizer 실행 횟수는 이 예시의 증명 범위가 아니다.a.balance > NULL은 UNKNOWN이라 WHERE를 통과하지 않는다.
NULL balance 자체의 도메인 정책은 다루지 않는다.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다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
이 illustrative SQL을 shipped 답안으로 부르지 않는다
이 책임을 맡는 곳: workbook answer authorityACTIVE만 보라는 조건을 추가하지 않는다
이 책임을 맡는 곳: prompt 또는 후속 WHEREAVG 계산 계획이나 table scan 비용을 보장하지 않는다
이 책임을 맡는 곳: EXPLAIN과 index 설계Q21 학습용 SQL — 전체 account 평균보다 큰 balance 찾기 · fixture 평균
-
266287.375는 앞으로도 변하지 않는 업무 상수인가요?
-
현재 workbook seed의 여덟 account로 계산한 exact oracle일 뿐이야.
-
balance나 행이 하나만 바뀌어도 평균과 통과 ID를 다시 구해야 해.
-
이 숫자를 운영 규칙이 아니라 회귀 fixture 값으로 표시하겠습니다.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 subquery가 한 값만 반환하나?
- 빈 account table이면 비교 결과는 어떻게 되나?
- CLOSED account 107은 왜 포함되나?
2단계 · 코드 조각 재조립
- 상관되지 않은 AVG subquery가 전체 balance의 scalar를 만든다.
- a.balance > average가 TRUE인 account만 통과한다.
- balance 뒤 account_id로 표시 순서를 고정한다.
3단계 · 파일 전체 다시 쓰기
illustrative/sql/W15-SQL-Q21.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture 숫자와 ID를 정확히 적었는가
- query shape와 실행 증거를 구분했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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;
08Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존
illustrative/sql/W15-SQL-Q22.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F0819줄 연결19줄 번역5 chunks
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존
illustrative/sql/W15-SQL-Q22.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W15-F08STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
각 account를 같은 customer의 MAX(balance)와 비교해 고객별 최대 balance인 모든 account 행을 남긴다.
- 왜 subquery가 customer_id로 상관되나?
- 동점 최대 account는 query shape상 몇 행 남나?
- customer 5가 결과에 없는 이유는 무엇인가?
rows=5account_id=105/106/107/104/108customer 5 has no accountfixture tie=noneQ22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · customer correlation
-
전체 account의 MAX 하나와 모두 비교하면 안 되나요?
-
Q22는 고객마다 최고 계좌를 찾으므로 외부 a의 customer_id로 peer를 좁혀야 해.
-
correlation이 빠지면 전사 최고 balance와 같은 행만 남는 다른 query가 돼.
-
각 a 행에서 peer.customer_id 조건이 바뀌는지 순서대로 추적할게요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
고객별 계좌 카드에서 최고 잔액표 찾기
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존
히토리는 account 카드 한 장을 들 때마다 customer_id가 같은 peer 카드만 모아 MAX balance를 계산한다.
키타는 equality라 동점이면 모두 남을 모양이지만 현재 fixture에는 동점이 없고, account가 없는 customer 5는 시작 후보에도 없다고 선을 긋는다.
딱 여기까지만 이 두 문장은 코드가 무엇을 위한 것인지 쉽게 잡아 주는 설명이며, 실제 SQL과 fixture 경계가 최종 기준이다.
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의 입력·결과·경계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 19줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F08-L01 | -- W15-SQL-Q22 illustrative example; |
고객별 최고 잔액 연습표에 공식 답안과 다른 색의 참고표를 붙인다. | W15-SQL-Q22가 shipped workbook 답안이 아닌 illustrative example이라고 밝힌다.
|
| 2줄F08-L02 | -- Input and output grain are account rows; |
계좌 카드 한 장을 들 때마다 같은 고객 이름의 카드 묶음을 따로 펼친다. | 입력과 출력 grain은 account 행이며 subquery가 customer_id로 상관된다고 선언한다.
|
| 3줄F08-L03 | -- Equality to each customer's MAX preserves every tied maximum by query shape. |
최고 점수 한 숫자와 같은 카드는 한 장만 뽑지 않고 전부 남긴다. | 각 고객의 MAX balance와 equality를 써 query 형태상 동률 최대 행을 모두 보존한다고 설명한다.
|
| 4줄F08-L04 | -- The supplied fixture has no same-customer balance tie, |
동점 카드가 없는 연습 상자 옆에 ‘동점 검사는 별도 카드 추가 필요’ 메모를 둔다. | 제공 fixture에는 같은 customer의 balance tie가 없어 mutation fixture가 필요하다고 제한한다.
|
| 5줄F08-L05 | SET search_path TO : |
고객별 카드 상자를 공용 선반보다 먼저 검색하도록 workbook 서랍을 앞세운다. | workbook_schema 다음 public 순서로 search_path를 설정한다.
|
| 7줄F08-L07 | SELECT |
최고 잔액 카드 명단에 실을 칸을 정하려 빈 표를 펼친다. | 고객별 최대 조건을 통과한 account에서 보여 줄 열의 SELECT를 연다.
|
| 8줄F08-L08 | a. |
최고 카드 왼쪽 위에 소유 고객 번호를 먼저 적는다. | 외부 account의 customer_id를 첫 결과 열로 선택한다.
|
| 9줄F08-L09 | a. |
고객 번호 옆에 최고 잔액을 가진 계좌 카드의 내부 번호를 붙인다. | 선택된 account_id를 결과에 출력한다.
|
| 10줄F08-L10 | a. |
내부 숫자 ID 옆에 알아보기 쉬운 계좌 명패를 단다. | 사람이 읽는 a.account_no를 표시 열로 포함한다.
|
| 11줄F08-L11 | a. |
선발 카드에 최고 판정에 사용한 금액 숫자를 함께 적는다. | 외부 후보 행의 balance를 최종 projection에 싣는다.
|
| 12줄F08-L12 | FROM account AS a |
전체 계좌 카드를 a 선반에 펼쳐 한 장씩 최고 여부를 심사한다. | 외부 query의 후보 집합을 account 별칭 a로 정한다.
|
| 13줄F08-L13 | WHERE a. |
현재 카드의 잔액이 자기 고객 최고 눈금과 정확히 같은지 검사한다. | 외부 balance가 괄호 안 customer-local 최대값과 같은 행만 남기는 조건을 연다.
|
| 14줄F08-L14 | SELECT MAX( |
같은 고객 카드 묶음의 잔액 중 가장 높은 눈금 하나를 읽는다. | peer.balance 중 가장 큰 값을 MAX aggregate로 하나 계산한다.
|
| 15줄F08-L15 | FROM account AS peer |
현재 카드와 비교할 같은 상자 카드들에 peer라는 역할 이름을 붙인다. | MAX 입력 relation을 account의 peer 별칭으로 지정한다.
|
| 16줄F08-L16 | WHERE peer. |
고객 번호가 현재 카드와 같은 카드만 최고값 바구니에 넣는다. | peer.customer_id와 현재 a.customer_id가 같은 내부 행만 남긴다.
|
| 17줄F08-L17 | ) |
고객별 최고 눈금 계산 봉투를 닫아 현재 카드 심사표에 꽂는다. | correlated scalar subquery 괄호를 닫아 최대값 equality 조건을 완성한다.
|
| 18줄F08-L18 | ORDER BY a. |
최고 카드들을 고객 번호별 묶음으로 놓고 묶음 안은 계좌 번호로 정돈한다. | customer_id 다음 account_id 순서로 최종 행을 정렬한다.
|
| 19줄F08-L19 | -- Fixture oracle: |
실행표 옆에 다섯 장의 카드 번호와 빈손인 고객 5 메모를 붙인다. | fixture 결과 5행과 account_id 105·106·107·104·108, account 없는 customer 5를 oracle로 기록한다.
|
| 20줄F08-L20 | -- Tie retention is a query-shape claim, |
동점 보존 설계표와 실제 동점 시험 성적표를 서로 다른 봉투에 넣는다. | tie 보존은 query-shape 주장이지 현재 fixture의 tie 실행 주장과 다르다고 재확인한다.
|
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · MAX는 값 하나
-
MAX가 그 고객의 account 행 하나를 골라 주나요?
-
MAX는 balance 숫자 하나만 내고 행 선택은 외부 equality가 맡아.
-
그래서 같은 최대 balance인 outer account가 둘이면 둘 다 TRUE가 될 수 있어.
-
aggregate scalar와 보존할 account projection을 나눠 읽겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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.
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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가 필요하다고 제한한다. |
| 5 | SET search_path TO :"workbook_schema", public; | workbook_schema 다음 public 순서로 search_path를 설정한다. |
| 7 | SELECT | 고객별 최대 조건을 통과한 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에 싣는다. |
| 12 | FROM account AS a | 외부 query의 후보 집합을 account 별칭 a로 정한다. |
| 13 | WHERE a.balance = ( | 외부 balance가 괄호 안 customer-local 최대값과 같은 행만 남기는 조건을 연다. |
| 14 | SELECT MAX(peer.balance) | peer.balance 중 가장 큰 값을 MAX aggregate로 하나 계산한다. |
| 15 | FROM account AS peer | MAX 입력 relation을 account의 peer 별칭으로 지정한다. |
| 16 | WHERE peer.customer_id = a.customer_id | peer.customer_id와 현재 a.customer_id가 같은 내부 행만 남긴다. |
| 17 | ) | correlated scalar subquery 괄호를 닫아 최대값 equality 조건을 완성한다. |
| 18 | ORDER 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 실행 주장과 다르다고 재확인한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기각 account를 같은 customer의 MAX(balance)와 비교해 고객별 최대 balance인 모든 account 행을 남긴다.
문법 해부
- peer.customer_id = a.customer_id로 비교 집합을 좁힌다.
- a.balance가 고객별 MAX와 같은 account를 남긴다.
- query shape는 tie를 보존하지만 현재 fixture는 tie를 실행하지 않는다.
실행 순서
- 같은 customer의 peer 행만 correlation
- MAX scalar 계산
- 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
-
현재 결과가 5행이면 tie 보존도 검증된 거죠?
-
fixture에는 같은 customer의 최대 balance tie가 없어서 그 실행 경로는 없었어.
-
equality 문법은 보존 가능한 모양이지만 증거에는 mutation fixture가 더 필요해.
-
설계 주장과 실행 주장을 별도 boundary 카드에 기록할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 각 outer account a | 같은 customer의 peer 행만 correlation | customer-local set | account 없는 customer는 outer row가 없음 |
| 2 | peer balances | MAX scalar 계산 | 고객별 최고 balance | 행 전체를 임의 하나로 고르지 않음 |
| 3 | a.balance | MAX와 equality 비교 | 현재 fixture 5행 | tie 실행 증거는 없음 |
Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · customer 5
-
고객 5도 최고 계좌가 없다는 행으로 나와야 하지 않나요?
-
외부 source가 account라 계좌 카드가 없는 customer는 시작 후보가 없어.
-
모든 customer를 보존하려면 customer에서 시작하는 LEFT JOIN 같은 다른 설계가 필요해.
-
5행 oracle에서 customer 5 누락을 버그로 오인하지 않겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
외부 a.customer_id마다 내부 peer 범위가 달라진다.
planner의 decorrelation 여부는 결과 의미와 별도다.MAX 값과 같은 모든 outer row가 TRUE가 될 수 있다.
현재 seed에는 같은 고객의 maximum tie가 없다.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가 없다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
workbook canonical answer를 제공한다고 주장하지 않는다
이 책임을 맡는 곳: 학습 예시 provenance현재 fixture로 tie retention을 직접 관찰하지 못한다
이 책임을 맡는 곳: mutation fixtureaccount 없는 customer를 결과에 보존하지 않는다
이 책임을 맡는 곳: customer LEFT JOIN 설계Q22 학습용 SQL — 고객마다 balance가 최대인 account 행 보존 · exact five IDs
-
기대 ID 순서가 105,106,107,104,108인 이유는 뭐예요?
-
주석은 결과 집합을 고정하고 ORDER BY는 customer_id 뒤 account_id로 배열해.
-
다섯 ID는 현재 seed 전용이며 balance 변경 뒤에는 membership도 달라질 수 있어.
-
행 수 5와 ID set, 표시 순서를 나눠 회귀 검산하겠습니다.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 subquery가 customer_id로 상관되나?
- 동점 최대 account는 query shape상 몇 행 남나?
- customer 5가 결과에 없는 이유는 무엇인가?
2단계 · 코드 조각 재조립
- peer.customer_id = a.customer_id로 비교 집합을 좁힌다.
- a.balance가 고객별 MAX와 같은 account를 남긴다.
- query shape는 tie를 보존하지만 현재 fixture는 tie를 실행하지 않는다.
3단계 · 파일 전체 다시 쓰기
illustrative/sql/W15-SQL-Q22.sql 전체를 원본과 같은 순서로 다시 쓰고 fixture 한계를 한 문장 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture 숫자와 ID를 정확히 적었는가
- query shape와 실행 증거를 구분했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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.