STARRY PASS 코드 뒤풀이 · W13 Ver2 전체
13주차 코드 10항목 — 같은 조회를 인덱스 전후로 재고 증거로 남기기
환경과 10만 행 fixture를 고정하고, 같은 top-N 조회를 인덱스 전후로 측정해 무엇이 빨라졌는지와 아직 증명하지 못한 범위를 실제 source 값으로 따라갑니다.
01compose.yaml — 같은 PostgreSQL 실험실 한 개를 준비하는 설정
compose.yaml
YAML 정본 · 정본 · W13-F0118줄 연결18줄 번역5 chunks
compose.yaml — 같은 PostgreSQL 실험실 한 개를 준비하는 설정
compose.yaml
YAML 정본 · 정본 · W13-F01STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W13 성능 실험이 PostgreSQL image tag·database·user·healthcheck·volume 설정을 공유하도록 한 개의 db service를 선언한다.
- 왜 SQL보다 먼저 container 설정을 확인할까?
- FCL_DB_PORT가 없을 때 어느 port를 쓸까?
- 비밀번호가 없으면 어느 단계에서 멈출까?
- healthy는 query가 빠르다는 뜻일까?
- 왜 이 파일은 다섯 SourceHashes 밖에 있을까?
service=dbpostgres:17.10-alpinehost=${FCL_DB_PORT:-5432}container=5432database=financial_coreuser=appinterval=2s timeout=2s retries=30volume=financial-core-dbSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 같은 조건으로 SQL 실험을 시작할 작은 DB 방을 준비한다.
한 개짜리 PostgreSQL 실험실
이 파일은 PostgreSQL 실험실 하나를 Docker로 띄울 때 쓸 이미지·포트·데이터베이스 이름·사용자를 정한다.
비밀번호가 비어 있으면 시작하지 않고 2초마다 준비 상태를 확인한다. 데이터는 financial-core-db volume에 연결한다.
딱 여기까지만 이 설정은 seed·query·index를 실행하지 않으며 SourceHashes 다섯 파일에도 들어가지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
DB 방 하나
services 아래 db 하나만 두어 runner가 찾을 이름을 고정한다.
- 코드 연결
1~3줄- 비유
- 연습실 열쇠에 db라는 방 번호와 PostgreSQL 상자 이름을 붙인다.
- 비유의 끝
- 방 이름만으로 database가 준비되지는 않는다.
밖 문과 안 문
host port는 환경값 또는 5432이고 container 안 PostgreSQL port는 5432다.
- 코드 연결
4~5줄- 비유
- 건물 밖 초인종 번호를 방 안 5432번 전화로 연결한다.
- 비유의 끝
- 이미 쓰는 host port면 충돌할 수 있다.
필수 비밀번호
FCL_DB_PASSWORD가 없으면 Compose config 자체가 오류가 되어 빈 비밀번호로 시작하지 않는다.
- 코드 연결
6~9줄- 비유
- 세 번째 출입 쪽지가 비어 있으면 접수대가 열쇠를 내주지 않는다.
- 비유의 끝
- 환경변수는 secret vault가 아니다.
준비 확인과 임시 저장
pg_isready로 server의 accepting-connections 상태를 반복 확인하고 data directory를 named volume에 연결한다.
- 코드 연결
10~19줄- 비유
- 문이 방문 요청을 받는 상태인지 확인하고 기록 상자는 별도 창고 선반에 둔다.
- 비유의 끝
- runner의 down -v 뒤에는 volume이 삭제될 수 있다.
왜 먼저 보는 파일인가히토리 → 니지카 → 료 → 키타
-
히토리
SQL부터 읽으면 되는 것 아닌가요?
-
니지카
SQL을 받을 PostgreSQL 방의 버전과 이름이 먼저 같아야 비교 조건이 맞아.
-
료
이 파일은 실행 dependency이고 측정 source 다섯 hash와는 다른 범위다.
-
키타
service가 db 하나인지부터 확인할게요.
두 개의 5432히토리 → 니지카 → 료 → 키타
-
히토리
5432가 두 번 나오면 DB가 두 개인가요?
-
니지카
왼쪽은 host 문 번호, 오른쪽은 container 안 PostgreSQL 문 번호야.
-
료
${FCL_DB_PORT:-5432}는 변수가 미설정이거나 빈 문자열일 때 왼쪽 기본값 5432를 쓴다.
-
키타
미설정·빈 문자열·55432 세 경우를 나눠 port를 적어 볼게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 18줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F01-L01 | services: |
STARRY 임시 연습실 목록의 맨 표지를 펼친다. | Compose 문서에서 services 최상위 mapping을 시작한다.
|
| 2줄F01-L02 | db: |
목록에 `db`라고 적힌 한 칸짜리 방 열쇠를 건다. | services 아래에 이름이 db인 service 정의를 연다.
|
| 3줄F01-L03 | image: |
DB 방의 재료 상자에 PostgreSQL 17.10 Alpine 꼬리표를 붙인다. | db container image를 postgres:17.10-alpine으로 지정한다.
|
| 4줄F01-L04 | ports: |
건물 밖과 DB 방 안을 잇는 문 번호 묶음을 펼친다. | db service의 ports 배열을 시작한다.
|
| 5줄F01-L05 | - "${ |
밖 번호표가 미설정이거나 빈칸이면 5432를 쓰고 안쪽 5432번 문에 연결한다. | FCL_DB_PORT가 미설정이거나 빈 문자열이면 기본 5432를, 값이 있으면 그 값을 host port로 써 container 5432에 publish한다.
|
| 6줄F01-L06 | environment: |
DB가 시작할 때 받을 설정 쪽지 봉투를 연다. | db container에 전달할 environment mapping을 시작한다.
|
| 7줄F01-L07 | POSTGRES_DB: |
첫 쪽지에 만들 database 이름 `financial_core`를 적는다. | POSTGRES_DB 환경값을 financial_core로 설정한다.
|
| 8줄F01-L08 | POSTGRES_USER: |
둘째 쪽지에는 접속 사용자 이름 `app`을 쓴다. | POSTGRES_USER 환경값을 app으로 설정한다.
|
| 9줄F01-L09 | POSTGRES_PASSWORD: |
셋째 쪽지는 terminal에 비밀번호가 없으면 접수대에서 돌려보낸다. | FCL_DB_PASSWORD가 반드시 있어야 POSTGRES_PASSWORD 값으로 치환되게 한다.
|
| 10줄F01-L10 | healthcheck: |
문을 열어도 되는지 확인할 건강 점검표 칸을 펼친다. | db service의 healthcheck 설정을 시작한다.
|
| 11줄F01-L11 | test: |
`app`·`financial_core` 목적지표를 달고 서버가 연결을 받는지만 묻는 초인종을 단다. | CMD-SHELL에서 -U app과 -d financial_core를 probe target으로 붙여 pg_isready를 실행한다.
|
| 12줄F01-L12 | interval: |
준비 확인 벨이 2초마다 다시 울리도록 간격을 맞춘다. | healthcheck 실행 간격을 2초로 지정한다.
|
| 13줄F01-L13 | timeout: |
한 번 문을 두드리고 기다릴 시간은 2초로 자른다. | 각 healthcheck 시도의 timeout을 2초로 지정한다.
|
| 14줄F01-L14 | retries: |
준비 확인이 30번 연속 실패하면 `unhealthy` 표를 붙이는 기준을 둔다. | 연속 healthcheck 실패 30회를 unhealthy 판정 임계값으로 지정한다.
|
| 15줄F01-L15 | volumes: |
DB 방 바닥과 기록 창고를 이을 끈 목록을 펼친다. | db service에 연결할 volumes 배열을 시작한다.
|
| 16줄F01-L16 | - financial-core-db: |
`financial-core-db` 창고를 PostgreSQL 자료 선반에 바로 잇는다. | named volume financial-core-db를 /var/lib/postgresql/data에 mount한다.
|
| 18줄F01-L18 | volumes: |
서비스 밖에서도 부를 공용 기록 창고 명부를 연다. | 문서 최상위 volumes mapping을 시작한다.
|
| 19줄F01-L19 | financial-core-db: |
명부에 `financial-core-db` 창고 이름만 등록한다. | financial-core-db named volume을 기본 옵션으로 선언한다.
|
필수 비밀번호히토리 → 니지카 → 료 → 키타
-
히토리
비밀번호가 비어도 실험용이면 그냥 켜지지 않나요?
-
니지카
물음표 보간식이 config 단계에서 빈 값을 거절해.
-
료
존재 검사는 보안 저장이 아니라 fail-fast configuration이다.
-
키타
terminal에 값을 둔 경우와 안 둔 경우를 나눠 보겠습니다.
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} | FCL_DB_PASSWORD가 반드시 있어야 POSTGRES_PASSWORD 값으로 치환되게 한다. |
| 10 | healthcheck: | db service의 healthcheck 설정을 시작한다. |
| 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는 PostgreSQL 17.10 Alpine db service 하나에 port·init 환경·healthcheck·named volume 계약을 적용한다.
문법 해부
- YAML 들여쓰기는 services→db→각 설정의 소속을 결정한다.
- ${FCL_DB_PORT:-5432}는 변수가 미설정이거나 빈 문자열일 때 5432를 쓰는 Compose 보간식이다.
- ${FCL_DB_PASSWORD:?message}는 값이 없으면 config 오류를 내는 필수 보간식이다.
- host:container port와 volume:container-path는 콜론 양쪽 역할이 다르다.
실행 순서
- YAML parse
- 환경변수 치환
- image·port·environment config
- container start
- pg_isready healthcheck
- named volume data 사용
원래 W6 수준의 조각별 정밀 해설
F01-C01 · db service, image, and port
- 문법 해부
- YAML `services.db` 아래에 image와 ports를 들여써 하나의 db service로 묶는다.
- 실제 값 추적
- 기본값이면 postgres:17.10-alpine의 5432를 host 5432에 연결한다.
- 정상 예
- FCL_DB_PORT=55432이면 host 55432→container 5432로 열린다.
- 틀린 예·반례
- ports 들여쓰기를 services와 같은 높이에 두면 db 속성이 아니어서 compose 해석이 달라진다.
- 착각 방지
- 17.10-alpine은 명시 tag일 뿐 immutable digest pin이 아니므로 같은 문자열이 image bytes나 성능을 고정했다는 증거가 아니다.
- 하지 않는 일
- 이 조각은 restart 정책, 계정 값, health 판정, 데이터 volume을 정하지 않는다.
- 다음 연결
- 다음 environment 조각이 db 이름·사용자·초기화 비밀번호를 공급한다.
F01-C02 · database environment
- 문법 해부
- `environment` mapping의 세 key가 POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD를 컨테이너에 전달한다.
- 실제 값 추적
- 빈 PostgreSQL data directory를 처음 초기화할 때 financial_core DB와 app 사용자를 만들고 FCL_DB_PASSWORD 값을 초기 비밀번호로 쓴다.
- 정상 예
- fresh named volume에서 FCL_DB_PASSWORD=w13-disposable-password를 주면 세 초기화 값이 entrypoint에 전달된다.
- 틀린 예·반례
- FCL_DB_PASSWORD가 unset이거나 empty면 `${...:?...}`가 container 시작 전 Compose interpolation/config 단계에서 실패한다.
- 착각 방지
- 기존에 초기화된 data volume에 값을 다시 전달한다고 role·DB·비밀번호가 자동 재생성되거나 회전되지는 않는다.
- 하지 않는 일
- 비밀 보관, port 충돌, query fixture 생성은 이 mapping의 책임이 아니다.
- 다음 연결
- healthcheck가 -U app과 -d financial_core를 probe 인자로 쓰지만 이 비밀번호 값을 직접 전달하지는 않는다.
F01-C03 · health check
- 문법 해부
- CMD-SHELL healthcheck가 `pg_isready -U app -d financial_core`를 실행하고 interval=2s, timeout=2s, retries=30을 적용한다.
- 실제 값 추적
- 각 probe는 최대 2초 안에 PostgreSQL server의 connection status를 받고, 30회 연속 실패하면 container가 unhealthy가 된다.
- 정상 예
- server가 accepting-connections 상태를 반환하면 probe exit 0으로 healthy가 되어 runner의 Compose `up --wait`가 끝날 수 있다.
- 틀린 예·반례
- rejecting/no-response가 30회 연속 이어지면 unhealthy가 되지만, 이를 영구히 probe를 딱 30번만 실행한다는 뜻으로 읽으면 안 된다.
- 착각 방지
- pg_isready 성공은 app 사용자·비밀번호가 인증됐거나 financial_core DB가 실제 존재함을 증명하지 않는다.
- 하지 않는 일
- schema seed, query 성공, Docker engine 가용성, cleanup은 이 healthcheck가 책임지지 않는다.
- 다음 연결
- healthy 뒤 PostgreSQL cluster 파일은 다음 named-volume mount에 기록된다.
F01-C04 · service volume mount
- 문법 해부
- service의 `volumes` 배열이 named volume financial-core-db를 PostgreSQL data directory에 mount한다.
- 실제 값 추적
- 컨테이너가 /var/lib/postgresql/data에 쓴 cluster 파일이 named volume에 놓인다.
- 정상 예
- 같은 Compose project를 유지해 restart하면 데이터 directory를 다시 붙일 수 있다.
- 틀린 예·반례
- mount target 철자를 바꾸면 PostgreSQL 데이터가 의도한 volume 밖에 생길 수 있다.
- 착각 방지
- mount는 transaction 정확성이나 benchmark 격리를 자동 보장하지 않는다.
- 하지 않는 일
- volume 자체의 선언과 삭제 시점은 다른 범위다.
- 다음 연결
- 마지막 YAML 조각이 참조한 named volume을 top-level에 선언한다.
F01-C05 · named volume declaration
- 문법 해부
- top-level `volumes` 아래 빈 mapping으로 financial-core-db라는 named volume을 선언한다.
- 실제 값 추적
- Compose가 project 이름을 붙인 volume을 만들고 db mount 참조를 해석한다.
- 정상 예
- runner가 `down -v`를 실행하면 해당 disposable project의 volume도 제거된다.
- 틀린 예·반례
- `down`에서 -v를 빼면 이전 seed 데이터가 다음 실행에 남을 수 있다.
- 착각 방지
- 선언 존재가 SourceHashes 다섯 파일에 compose.yaml을 포함시키지는 않는다.
- 하지 않는 일
- 이 두 줄은 누가 cleanup을 호출하는지 정하지 않는다.
- 다음 연결
- seed.sql이 깨끗한 schema와 10만 행 fixture를 채운다.
healthy의 범위히토리 → 니지카 → 료 → 키타
-
히토리
healthy면 app 인증과 index 선택도 끝났다는 말인가요?
-
니지카
아니, -U app과 -d financial_core는 probe target이고 server의 accepting-connections 상태만 봐.
-
료
pg_isready는 인증 성공·database 존재·query 성공을 증명하지 않고 plan node도 확인하지 않는다.
-
키타
server health와 인증·EXPLAIN 결과를 서로 다른 증거 칸에 둘게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | FCL_DB_PORT 미지정 또는 빈 문자열, FCL_DB_PASSWORD=lab-secret | 두 Compose 보간식을 평가한다. | host 5432→container 5432, password lab-secret 설정이 된다. | password가 없으면 container 시작 전 config가 실패한다. |
| 2 | postgres:17.10-alpine과 database/user 환경값 | 새 db container와 빈 volume을 초기화한다. | financial_core database와 app 사용자가 준비된다. | 기존 data volume이면 init 환경값이 재적용되지 않을 수 있다. |
| 3 | process는 실행 중이지만 아직 connection을 받지 않는 PostgreSQL | 2초 간격·2초 timeout으로 pg_isready를 반복한다. | pg_isready가 accepting 상태를 exit 0으로 알리면 db service가 healthy로 바뀐다. | 이 결과는 app 인증·financial_core 존재를 확인하지 않으며 retries=30 뒤에도 점검은 계속되고 정확한 60초 상한도 아니다. |
| 4 | PostgreSQL이 /var/lib/postgresql/data에 쓰는 page | financial-core-db mount로 저장한다. | container layer 밖의 named volume에 data files가 놓인다. | 이 W13 runner의 Compose 정리는 down -v라 실험 뒤 volume도 제거한다. |
volume과 hash 경계히토리 → 니지카 → 료 → 키타
-
히토리
창고가 있으니 결과가 영원히 보관되고 hash에도 들어가겠죠?
-
니지카
runner가 down -v로 정리하면 이 실험 창고도 없어질 수 있어.
-
료
SourceHashes 목록은 runner와 SQL 네 개뿐이라 Compose 환경은 봉인하지 않는다.
-
키타
volume 수명과 source hash 수를 따로 적겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
들여쓰기된 mapping과 list를 service model로 바꾸고 환경 보간을 먼저 검증한다.
YAML parse 성공은 Docker engine 사용 가능성을 보장하지 않는다.image에서 container를 만들고 host port·환경값·mount·healthcheck를 적용한다.
host port와 volume lifecycle은 project 이름·실행 명령의 영향도 받는다.빈 data directory일 때 POSTGRES_DB·USER·PASSWORD로 초기 cluster를 만든다.
이미 채운 data directory는 같은 init 과정을 반복하지 않는다.pg_isready exit code를 주기적으로 읽어 container health 상태를 바꾼다.
schema·seed·index·latency를 검사하지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ healthcheck가 Green이면 W13 query가 빠르다는 뜻이다.
왜 틀리나 pg_isready는 -U/-d를 probe target으로 붙이지만 server가 connection을 받을 상태인지만 보고한다.
바르게 읽기 준비 상태와 EXPLAIN·benchmark 결과를 분리한다.
반례 잘못된 user·database 이름이어도 server 상태 응답은 받을 수 있고 index가 없어도 성공할 수 있다.
❌ named volume은 runner가 끝난 뒤에도 반드시 남는다.
왜 틀리나 Compose DB phase의 finally가 down -v를 호출한다.
바르게 읽기 이 문서의 volume은 disposable 실험 저장소로 읽는다.
반례 down만 쓰면 남을 수 있지만 down -v는 volume 제거를 요청한다.
❌ SourceHashes가 W13 환경 전체를 봉인하니 compose.yaml도 hash된다.
왜 틀리나 runner의 다섯 경로 목록에는 이 YAML이 없다.
바르게 읽기 packaged dependency와 five-file hash closure를 따로 센다.
반례 Compose image tag를 바꿔도 SourceHashes 배열의 다섯 결과는 그대로일 수 있다.
❌ 필수 환경변수면 비밀번호가 안전하게 비밀 저장된다.
왜 틀리나 보간식은 값 존재만 확인해 container 환경으로 전달한다.
바르게 읽기 실험용 fail-fast와 운영 secret 관리를 구분한다.
반례 Compose config나 process 환경 취급에 따라 값이 노출될 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
compose.yaml이 SourceHashes 다섯 digest에 포함됨
이 책임을 맡는 곳: 별도 environment manifest 또는 Compose hash bindingperf_w13 schema·10만 행·index가 존재함
이 책임을 맡는 곳: seed.sql, create-index.sql, assert.sqlquery plan 선택·상대 개선·운영 latency
이 책임을 맡는 곳: measure.sql과 runner의 plan/benchmark 단계backup·HA·TLS·최소권한·production 배포 보안
이 책임을 맡는 곳: 운영 인프라·보안·복구 설계STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
db 한 개→image→port→세 환경값→healthcheck→data volume 순서로 말한다.
2단계 · 코드 조각 재조립
- services/db/image/ports
- POSTGRES_DB/USER/PASSWORD
- pg_isready/2s/2s/30
- service mount/top-level volume
3단계 · 파일 전체 다시 쓰기
실제 blank 한 줄을 포함한 19개 물리 줄을 다시 쓰고 canonical SHA-256과 대조한다.
자가 점검
- service 이름은 db 하나인지 본다.
- image tag는 17.10-alpine인지 본다.
- password 보간식의 물음표 계약을 지킨다.
- host와 container port 방향을 바꾸지 않는다.
- SourceHashes 포함이라고 쓰지 않는다.
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:
02seed.sql — 90% hot 계좌가 있는 결정적 10만 행 fixture
sql/w13/seed.sql
SQL 정본 · 정본 · W13-F0213줄 연결13줄 번역10 chunks
seed.sql — 90% hot 계좌가 있는 결정적 10만 행 fixture
sql/w13/seed.sql
SQL 정본 · 정본 · W13-F02STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
매 측정 전에 perf_w13을 다시 만들고 계좌 1,000·거래 100,000·원장 100,000행을 같은 값과 같은 쏠림으로 채운다.
- 왜 지난 schema를 먼저 지울까?
- 계좌 1에는 왜 정확히 90,000행을 넣을까?
- 나머지 10,000행은 999계좌에 어떻게 나뉠까?
- MD5 UUID는 보안 증거일까?
- ANALYZE는 query를 측정하는 명령일까?
schema=perf_w13accounts=1000business_tx=100000ledger=100000account1=90000 rowsaccounts2..11=11 rows eachaccounts12..1000=10 rows eachPERF_CREDIT/signed_amount=1base time=2026-01-01T00:00:00ZSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 인덱스 전후를 공정하게 비교하려고 같은 영수증 더미를 다시 만든다.
언제나 같은 10만 건 연습 장부
이 SQL은 perf_w13 공간을 새로 만들고 계좌 1,000개, 거래 100,000개, 원장 100,000개를 같은 규칙으로 넣는다.
원장 90,000개는 계좌 1에 몰고 나머지는 계좌 2~1,000에 나눈다. 끝에 원장 합계를 잔액으로 옮기고 통계를 만든다.
딱 여기까지만 전부 +1인 합성 자료라 실제 돈 흐름·고객 분포·운영 성능을 대신하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
매번 새 연습장
지난 perf_w13을 지우고 같은 이름의 빈 schema를 다시 만든다.
- 코드 연결
1~3줄- 비유
- 전에 풀던 공책을 치우고 같은 칸 수의 새 공책을 편다.
- 비유의 끝
- 운영 schema에서 실행할 cleanup 문장이 아니다.
연결된 세 표
원장 한 줄은 존재하는 거래 UUID와 계좌 ID를 각각 가리킨다.
- 코드 연결
4~6줄- 비유
- 원장표에 거래 영수증과 계좌 서랍으로 가는 두 끈을 묶는다.
- 비유의 끝
- 이 단순 표에는 운영 domain 전체 열이 없다.
90% 쏠림
100,000 원장 중 90,000은 계좌 1, 나머지는 계좌 2~1,000에 순환 배치한다.
- 코드 연결
7~11줄- 비유
- 영수증 열 장 중 아홉 장꼴을 한 손님 상자에 몰아 넣는다.
- 비유의 끝
- 일부러 만든 성능 반례이지 실제 고객 비율이 아니다.
합계와 통계
원장 합계를 balance로 옮기고 planner가 분포를 보도록 ANALYZE한다.
- 코드 연결
12~13줄- 비유
- 상자별 장수를 표지에 적고 길찾기 담당에게 새 재고표를 준다.
- 비유의 끝
- 통계만으로 빠른 plan 선택이 보장되지는 않는다.
첫 오류에서 멈추기히토리 → 니지카 → 료 → 키타
-
히토리
중간 INSERT가 실패해도 뒤 ANALYZE까지 가면 안 되나요?
-
니지카
부분 fixture를 정상처럼 쓰지 않도록 psql을 첫 SQL 오류에서 멈춰.
-
료
ON_ERROR_STOP은 뒤 statement를 막을 뿐 transaction이 아니어서 앞서 성공한 변경을 자동 rollback하지 않는다.
-
키타
중단 지점과 이미 반영된 statement를 따로 확인하겠습니다.
schema를 다시 만드는 이유히토리 → 니지카 → 료 → 키타
-
히토리
지난 실험 표를 그대로 쓰면 더 빠르지 않나요?
-
니지카
남은 index나 행이 before 조건에 섞이면 전후 비교가 깨져.
-
료
DROP CASCADE는 그래서 disposable perf_w13에만 허용되는 강한 초기화다.
-
키타
실행 DB와 schema 이름을 먼저 확인할게요.
90,000 경계히토리 → 니지카 → 료 → 키타
-
히토리
g=90001도 계좌 1로 갈 것 같아요.
-
니지카
조건이 g<=90000이라 90001부터 else 식으로 넘어가.
-
료
첫 cold 값은 2+((90001-90001)%999)=2다.
-
키타
90000과 90001을 나란히 계산하겠습니다.
남은 만 행 나누기히토리 → 니지카 → 료 → 키타
-
히토리
10,000을 999로 나누면 모두 10개씩 아닌가요?
-
니지카
9,990개 뒤 열 개가 더 돌아서 계좌 2부터 11이 하나씩 더 받아.
-
료
그래서 cold 분포도 11행과 10행 두 집단이다.
-
키타
2~11과 12~1000의 개수를 따로 기록할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 13줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F02-L01 | \set ON_ERROR_STOP on |
psql이 첫 잘못에서 바로 빨간 종을 울리게 한다. | psql meta-command로 ON_ERROR_STOP을 켜 SQL 오류에서 즉시 중단한다.
|
| 2줄F02-L02 | DROP SCHEMA IF EXISTS perf_w13 CASCADE; |
이전 `perf_w13` 연습장을 안의 물건과 함께 깨끗이 비운다. | 존재하면 perf_w13 schema와 의존 객체를 CASCADE로 삭제한다.
|
| 3줄F02-L03 | CREATE SCHEMA perf_w13; |
비운 자리에 새 `perf_w13` 연습장을 세운다. | 빈 perf_w13 schema를 새로 생성한다.
|
| 4줄F02-L04 | CREATE TABLE perf_w13. |
계좌 번호·주인표·음수 방지 잔액칸이 있는 계좌 서랍을 만든다. | perf_w13.account에 bigint PK id, 필수 owner_id, 0 이상 필수 balance를 선언한다.
|
| 5줄F02-L05 | CREATE TABLE perf_w13. |
UUID 번호와 상태칸만 가진 업무 영수증 서랍을 놓는다. | perf_w13.business_tx에 UUID PK id와 필수 status 열을 선언한다.
|
| 6줄F02-L06 | CREATE TABLE perf_w13. |
거래표와 계좌표에 끈을 묶은 원장 한 줄 서랍을 조립한다. | perf_w13.ledger_entry에 PK, 두 FK, type, signed amount, timestamp 필수 열을 선언한다.
|
| 7줄F02-L07 | INSERT INTO perf_w13. |
1부터 1,000까지 번호표를 뽑아 빈 잔액 계좌 서랍을 채운다. | generate_series(1,1000)으로 1,000계좌와 perf-owner-g, balance 0을 삽입한다.
|
| 8줄F02-L08 | INSERT INTO perf_w13. |
1부터 100,000까지 같은 조리법으로 UUID 영수증을 찍는다. | generate_series(1,100000)의 perf-g MD5를 UUID로 바꿔 COMPLETED 거래를 삽입한다.
|
| 9줄F02-L09 | INSERT INTO perf_w13. |
이제 100,000줄 원장을 넣을 서랍 이름과 투입구를 연다. | perf_w13.ledger_entry 대상의 INSERT INTO 문을 시작한다.
|
| 10줄F02-L10 | SELECT g, |
앞 90,000장은 1번 손님 더미에, 남은 장은 999개 상자에 돌려 담는다. | g로 id와 거래 UUID를 만들고 90,000행은 account 1, 나머지는 account 2~1000에 순환 배치한다.
|
| 11줄F02-L11 | FROM generate_series( |
원장 번호표를 정확히 1부터 100,000까지 흘려보낸다. | ledger SELECT의 입력 g를 generate_series(1,100000)에서 만든다.
|
| 12줄F02-L12 | UPDATE perf_w13. |
각 계좌 원장 합계를 세어 계좌 서랍의 잔액표에 옮겨 적는다. | account_id별 signed_amount 합계를 account.balance로 update한다.
|
| 13줄F02-L13 | ANALYZE perf_w13. |
세 서랍의 크기와 쏠림을 PostgreSQL 길찾기 담당에게 새로 알려준다. | account, business_tx, ledger_entry 세 table에 ANALYZE를 실행해 planner 통계를 수집한다.
|
계좌 표의 안전턱히토리 → 니지카 → 료 → 키타
-
히토리
balance가 음수인 행도 성능 자료면 넣어도 되나요?
-
니지카
이 fixture의 account 정의는 balance>=0 CHECK를 둬.
-
료
하지만 통화·상태·version 같은 application 규칙은 생략돼 있다.
-
키타
PK·NOT NULL·CHECK 세 가지를 줄에서 찾겠습니다.
통계와 실제 계획히토리 → 니지카 → 료 → 키타
-
히토리
ANALYZE까지 했으니 index plan도 정해졌나요?
-
니지카
통계 재료만 준비했고 어떤 길을 고를지는 query와 index 상태에서 planner가 결정해.
-
료
실제 node는 EXPLAIN JSON과 runner의 FindIndex가 확인한다.
-
키타
seed 증거와 plan 증거를 다른 칸에 두겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 10개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
\set ON_ERROR_STOP on
DROP SCHEMA IF EXISTS perf_w13 CASCADE;
CREATE SCHEMA perf_w13;
CREATE TABLE perf_w13.account(id bigint PRIMARY KEY,owner_id varchar(64) NOT NULL,balance bigint NOT NULL CHECK(balance>=0));
CREATE TABLE perf_w13.business_tx(id uuid PRIMARY KEY,status varchar(16) NOT NULL);
CREATE TABLE perf_w13.ledger_entry(id bigint PRIMARY KEY,business_tx_id uuid NOT NULL REFERENCES perf_w13.business_tx(id),account_id bigint NOT NULL REFERENCES perf_w13.account(id),entry_type varchar(32) NOT NULL,signed_amount bigint NOT NULL,created_at timestamptz NOT NULL);
INSERT INTO perf_w13.account SELECT g,'perf-owner-'||g,0 FROM generate_series(1,1000) g;
INSERT INTO perf_w13.business_tx SELECT md5('perf-'||g)::uuid,'COMPLETED' FROM generate_series(1,100000) g;
INSERT INTO perf_w13.ledger_entry
SELECT g,md5('perf-'||g)::uuid,CASE WHEN g<=90000 THEN 1 ELSE 2+((g-90001)%999) END,'PERF_CREDIT',1,timestamptz '2026-01-01 00:00:00+00'+g*interval '1 second'
FROM generate_series(1,100000) g;
UPDATE perf_w13.account a SET balance=x.n FROM (SELECT account_id,sum(signed_amount)::bigint n FROM perf_w13.ledger_entry GROUP BY account_id)x WHERE x.account_id=a.id;
ANALYZE perf_w13.account; ANALYZE perf_w13.business_tx; ANALYZE perf_w13.ledger_entry;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 13줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | \set ON_ERROR_STOP on | psql meta-command로 ON_ERROR_STOP을 켜 SQL 오류에서 즉시 중단한다. |
| 2 | DROP SCHEMA IF EXISTS perf_w13 CASCADE; | 존재하면 perf_w13 schema와 의존 객체를 CASCADE로 삭제한다. |
| 3 | CREATE SCHEMA perf_w13; | 빈 perf_w13 schema를 새로 생성한다. |
| 4 | CREATE TABLE perf_w13.account(id bigint PRIMARY KEY,owner_id varchar(64) NOT NULL,balance bigint NOT NULL CHECK(balance>=0)); | perf_w13.account에 bigint PK id, 필수 owner_id, 0 이상 필수 balance를 선언한다. |
| 5 | CREATE TABLE perf_w13.business_tx(id uuid PRIMARY KEY,status varchar(16) NOT NULL); | perf_w13.business_tx에 UUID PK id와 필수 status 열을 선언한다. |
| 6 | CREATE TABLE perf_w13.ledger_entry(id bigint PRIMARY KEY,business_tx_id uuid NOT NULL REFERENCES perf_w13.business_tx(id),account_id bigint NOT NULL REFERENCES perf_w13.account(id),entry_type varchar(32) NOT NULL,signed_amount bigint NOT NULL,created_at timestamptz NOT NULL); | perf_w13.ledger_entry에 PK, 두 FK, type, signed amount, timestamp 필수 열을 선언한다. |
| 7 | INSERT INTO perf_w13.account SELECT g,'perf-owner-'||g,0 FROM generate_series(1,1000) g; | generate_series(1,1000)으로 1,000계좌와 perf-owner-g, balance 0을 삽입한다. |
| 8 | INSERT INTO perf_w13.business_tx SELECT md5('perf-'||g)::uuid,'COMPLETED' FROM generate_series(1,100000) g; | generate_series(1,100000)의 perf-g MD5를 UUID로 바꿔 COMPLETED 거래를 삽입한다. |
| 9 | INSERT INTO perf_w13.ledger_entry | perf_w13.ledger_entry 대상의 INSERT INTO 문을 시작한다. |
| 10 | SELECT g,md5('perf-'||g)::uuid,CASE WHEN g<=90000 THEN 1 ELSE 2+((g-90001)%999) END,'PERF_CREDIT',1,timestamptz '2026-01-01 00:00:00+00'+g*interval '1 second' | g로 id와 거래 UUID를 만들고 90,000행은 account 1, 나머지는 account 2~1000에 순환 배치한다. |
| 11 | FROM generate_series(1,100000) g; | ledger SELECT의 입력 g를 generate_series(1,100000)에서 만든다. |
| 12 | UPDATE perf_w13.account a SET balance=x.n FROM (SELECT account_id,sum(signed_amount)::bigint n FROM perf_w13.ledger_entry GROUP BY account_id)x WHERE x.account_id=a.id; | account_id별 signed_amount 합계를 account.balance로 update한다. |
| 13 | ANALYZE perf_w13.account; ANALYZE perf_w13.business_tx; ANALYZE perf_w13.ledger_entry; | account, business_tx, ledger_entry 세 table에 ANALYZE를 실행해 planner 통계를 수집한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기psql fail-fast로 perf_w13을 재생성하고 FK로 연결된 1k/100k/100k fixture와 0.9 hot 분포·대사 잔액·planner 통계를 만든다.
문법 해부
- generate_series는 시작과 끝을 모두 포함해 결정적 행 수를 만든다.
- CASE는 g<=90000이면 account 1, 아니면 modulo 식으로 2~1000을 고른다.
- md5(text)::uuid는 같은 g에 같은 UUID를 제공하지만 보안 목적이 아니다.
- UPDATE ... FROM 집계 subquery는 account_id별 SUM을 balance에 복사한다.
- ANALYZE는 table 통계를 모으는 명령이며 EXPLAIN ANALYZE와 다르다.
실행 순서
- 오류 즉시 중단
- schema 삭제·생성
- 세 table 생성
- 계좌 1,000행
- 거래 100,000행
- 쏠린 원장 100,000행
- 잔액 대사
- 통계 수집
원래 W6 수준의 조각별 정밀 해설
F02-C01 · psql fail-fast
- 문법 해부
- psql meta-command `\set ON_ERROR_STOP on`이 SQL 오류 뒤 실행 중단을 켠다.
- 실제 값 추적
- DROP·CREATE·INSERT 중 하나가 실패하면 뒤 statement로 진행하지 않는다.
- 정상 예
- ledger insert 제약 위반이 나면 balance UPDATE와 ANALYZE가 실행되지 않는다.
- 틀린 예·반례
- 이 명령을 빼면 psql 호출 방식에 따라 뒤 statement가 이어져 반쪽 evidence가 생길 수 있다.
- 착각 방지
- fail-fast는 전체 파일을 하나의 transaction으로 묶지 않는다.
- 하지 않는 일
- Docker process exit와 evidence JSON 처리는 runner가 맡는다.
- 다음 연결
- 다음 조각이 perf_w13 schema를 제거하고 다시 만든다.
F02-C02 · recreate disposable schema
- 문법 해부
- DROP SCHEMA ... CASCADE 뒤 CREATE SCHEMA를 실행해 동일 이름의 격리 공간을 새로 만든다.
- 실제 값 추적
- 이전 perf_w13 표·index가 있으면 모두 지운 뒤 빈 schema로 시작한다.
- 정상 예
- 재실행 시 오래된 100000행과 새 100000행이 합쳐지지 않는다.
- 틀린 예·반례
- CASCADE 대상을 운영 schema로 바꾸면 복구하기 어려운 파괴가 된다.
- 착각 방지
- schema 재생성은 Docker volume 전체를 지우지 않는다.
- 하지 않는 일
- 표 구조와 데이터 분포는 아직 없다.
- 다음 연결
- 세 table DDL 조각이 account, business_tx, ledger_entry를 만든다.
F02-C03 · account table
- 문법 해부
- 한 줄 CREATE TABLE이 bigint `id` PK, `owner_id` 문자열, 0 이상 CHECK가 붙은 bigint `balance`를 정의한다.
- 실제 값 추적
- 계좌 한 행은 id·owner_id·balance 세 값을 반드시 가진다.
- 정상 예
- 뒤 insert가 id=1, owner_id=perf-owner-1, balance=0인 행을 넣을 수 있다.
- 틀린 예·반례
- balance가 음수이거나 id가 중복이면 table constraint가 seed를 중단시킨다.
- 착각 방지
- owner_id 문자열은 실제 customer FK나 인증 소유권을 모델링하지 않는다.
- 하지 않는 일
- 통화·version·계좌 상태는 이 세 열에 없다.
- 다음 연결
- business_tx가 UUID와 COMPLETED 상태만 가진 합성 거래 identity를 준비한다.
F02-C04 · business transaction table
- 문법 해부
- business_tx DDL은 UUID `id` primary key와 NOT NULL `status` 두 열만 정의한다.
- 실제 값 추적
- 뒤 insert가 md5 기반 UUID마다 COMPLETED 상태 한 값을 저장할 수 있다.
- 정상 예
- 같은 UUID를 다시 넣으면 primary-key 중복으로 실패한다.
- 틀린 예·반례
- account_id나 amount 열이 있다고 가정한 query는 이 실제 두 열 schema에서 실패한다.
- 착각 방지
- 이 표 자체는 거래를 계좌에 직접 연결하지 않는다.
- 하지 않는 일
- 금액·상대 계좌·멱등 key는 이 합성 schema의 business_tx에 없다.
- 다음 연결
- ledger_entry의 business_tx_id FK가 거래 UUID를 account 원장 행과 연결한다.
F02-C05 · ledger table and foreign keys
- 문법 해부
- ledger_entry DDL이 bigint `id` PK와 business_tx_id/account_id FK, entry_type, signed_amount, created_at을 정의한다.
- 실제 값 추적
- 원장 100000행이 거래 UUID 100000개와 계좌 1000개에 각각 연결된다.
- 정상 예
- created_at은 measure.sql의 최신 50행 정렬 key가 된다.
- 틀린 예·반례
- business_tx_id나 account_id가 부모에 없으면 FK 위반으로 insert가 멈춘다.
- 착각 방지
- 단일 PERF_CREDIT 종류는 운영 복식 원장 모델을 재현하지 않는다.
- 하지 않는 일
- hot 계좌 90% 분포는 DDL이 아니라 insert 식에서 만들어진다.
- 다음 연결
- generate_series 계좌 insert가 account FK 부모를 먼저 채운다.
F02-C06 · 1,000 accounts
- 문법 해부
- generate_series(1,1000)이 account의 id를 만들고 `perf-owner-` 접두사 owner_id와 balance 0을 함께 넣는다.
- 실제 값 추적
- 정확히 1000개 PK 행이 생긴다.
- 정상 예
- 첫 행은 id=1,owner_id=perf-owner-1,balance=0이고 마지막 id는 1000이다.
- 틀린 예·반례
- 상한을 999로 바꾸면 assert의 account count가 실패한다.
- 착각 방지
- 초기 0은 최종 원장 합과 일치하도록 뒤 UPDATE가 바꿀 값이다.
- 하지 않는 일
- 고객 FK·실제 owner entity·hot skew는 이 insert가 만들지 않는다.
- 다음 연결
- 다음 insert가 100000개의 UUID/COMPLETED business_tx 행을 만든다.
F02-C07 · 100,000 transactions
- 문법 해부
- generate_series(1,100000)의 g를 `md5('perf-'||g)::uuid`로 바꾸고 status COMPLETED와 함께 business_tx에 넣는다.
- 실제 값 추적
- 서로 다른 합성 UUID를 가진 100000개 거래 identity가 생긴다.
- 정상 예
- g=1은 문자열 perf-1의 MD5 UUID와 COMPLETED 상태를 만든다.
- 틀린 예·반례
- UUID 식을 상수로 바꾸면 두 번째 행부터 primary-key 중복으로 실패한다.
- 착각 방지
- 이 두 열 insert는 account_id나 amount를 생성·배분하지 않는다.
- 하지 않는 일
- created_at과 hot 계좌 분포는 ledger_entry insert에서만 정해진다.
- 다음 연결
- 세 줄 ledger insert가 각 UUID를 원장에 연결하고 account 1에 90000행을 몰아준다.
F02-C08 · 100,000 skewed ledger rows
- 문법 해부
- ledger INSERT-SELECT가 CASE로 account skew를 만들고 UUID·PERF_CREDIT·+1·초 단위 created_at과 함께 generate_series 100000행을 넣는다.
- 실제 값 추적
- g<=90000은 account_id=1, 나머지는 2..1000에 배분되어 1번 계좌가 정확히 90000행을 가진다.
- 정상 예
- g=90001은 account 2이고 g=100000은 modulus 계산상 account 11에 들어간다.
- 틀린 예·반례
- CASE 경계나 modulus 999를 바꾸면 hot=90000과 나머지 10000 분포가 깨진다.
- 착각 방지
- 전부 PERF_CREDIT,+1인 합성 분포는 운영 selectivity를 대표하지 않는다.
- 하지 않는 일
- 계좌 balance는 insert 시점에 여전히 0이다.
- 다음 연결
- 다음 UPDATE가 각 account의 signed_amount 합으로 balance를 맞춘다.
F02-C09 · balance reconciliation
- 문법 해부
- derived table x가 ledger_entry를 account_id별로 GROUP BY해 sum(signed_amount) n을 만들고, UPDATE ... FROM이 `x.account_id=a.id`인 account의 balance를 x.n으로 바꾼다.
- 실제 값 추적
- 모든 원장이 +1이므로 account 1은 90000, account 2~11은 각각 11, account 12~1000은 각각 10으로 갱신된다.
- 정상 예
- 현재 fixture처럼 모든 account에 원장이 있으면 각 target row가 정확히 자기 x 집계행 하나와 연결된다.
- 틀린 예·반례
- WHERE x.account_id=a.id를 빼면 각 account가 여러 x 행과 결합되어 임의의 한 group 합을 받을 수 있고, 전체 100000 합을 일괄 대입하는 동작도 아니다.
- 착각 방지
- 이 statement는 COALESCE를 쓰지 않으므로 원장이 없는 account는 0으로 설정되는 대신 UPDATE 대상에서 빠져 기존 balance를 유지한다.
- 하지 않는 일
- balance 일치는 합성 fixture invariant일 뿐 실제 회계 정책·index 존재·planner 통계를 증명하지 않는다.
- 다음 연결
- 세 ANALYZE statement가 방금 만든 세 table 분포를 planner에 알린다.
F02-C10 · planner statistics
- 문법 해부
- 한 physical line의 세 ANALYZE statement가 account, business_tx, ledger_entry를 차례로 통계 수집한다.
- 실제 값 추적
- 세 table의 1000/100000/100000행 분포와 ledger hot account 빈도가 planner 통계에 반영될 수 있다.
- 정상 예
- BeforePlan은 세 table 분석이 끝난 뒤 같은 measure.sql을 실행한다.
- 틀린 예·반례
- ANALYZE를 생략하면 planner가 stale/default 통계로 access path를 고를 수 있다.
- 착각 방지
- 통계 수집은 composite index를 생성하거나 query 시간을 측정하지 않는다.
- 하지 않는 일
- 정확한 estimate나 특정 plan 선택은 세 ANALYZE 성공만으로 보장되지 않는다.
- 다음 연결
- measure.sql이 분석된 ledger_entry에서 hot 계좌 최신 50행 plan을 요청한다.
거래 표가 작은 까닭히토리 → 니지카 → 료 → 키타
-
히토리
business_tx에 금액과 계좌가 왜 없나요?
-
니지카
여기서는 ledger FK가 가리킬 UUID와 status만 필요한 최소 부모 표야.
-
료
workbook business_tx나 앱 V001과 같은 schema라고 보면 안 된다.
-
키타
perf_w13 전용 열 두 개만 적겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 지난 perf_w13이 있거나 없는 database | DROP IF EXISTS CASCADE 후 CREATE한다. | 이전 table·index·행이 없는 빈 perf_w13이 된다. | CASCADE는 대상 schema 의존 객체까지 지우므로 disposable DB 전제다. |
| 2 | g=1..1000 | account id, perf-owner-g, balance 0을 만든다. | account 1,000행이 정확히 생긴다. | owner 문자열은 권한·개인정보·실제 관계가 아니다. |
| 3 | g=1..100000 | perf-g MD5 UUID와 COMPLETED를 만든다. | ledger가 참조할 transaction 100,000행이 생긴다. | MD5를 암호학적 안전성 증거로 사용하지 않는다. |
| 4 | ledger g=90000과 g=90001 | CASE와 modulo 계좌 식을 적용한다. | g=90000은 account1, g=90001은 account2로 간다. | 경계 하나가 맞아도 전체 100,000 분포는 별도 count로 확인해야 한다. |
| 5 | 남은 g=90001..100000 10,000개 | 999계좌에 modulo 순환 배치한다. | account2~11은 11행씩, account12~1000은 10행씩 받는다. | cold 계좌도 완전 균등하지 않아 row estimate를 분포와 함께 읽어야 한다. |
| 6 | 각 ledger의 signed_amount=1 | account_id별 SUM을 balance에 update한다. | account1 balance90000, 나머지는 각 10 또는11, mismatch0 기반이 된다. | 모든 행이 credit인 단순 합성 계산이다. |
| 7 | 완성된 세 table | 세 ANALYZE를 실행한다. | 다음 planner가 규모와 값 분포 통계를 사용할 수 있다. | 실제 선택 plan과 시간은 measure·runner에서 봐야 한다. |
원장의 두 연결히토리 → 니지카 → 료 → 키타
-
히토리
원장 ID만 있으면 계좌와 거래를 나중에 문자열로 찾으면 되지 않나요?
-
니지카
두 FK가 없는 거래·없는 계좌를 가리키는 행을 막아 줘.
-
료
다만 signed_amount 부호 균형까지 FK가 검사하지는 않는다.
-
키타
business_tx_id와 account_id 참조 대상을 각각 확인할게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
ON_ERROR_STOP이 server SQL error를 받으면 script의 뒤 문장을 중단시킨다.
BEGIN·single-transaction이 없어 앞서 성공한 statement를 자동 취소하지 않으며 Docker failure는 runner가 맡는다.schema·table·PK·FK·CHECK 정의를 relation과 constraint metadata로 등록한다.
fixture table은 application migration table과 별도 perf_w13 namespace다.generate_series가 g 값을 순서대로 만들어 INSERT SELECT 입력 행이 된다.
동시 요청이나 실제 event arrival을 흉내 내지 않는다.ANALYZE가 sample을 읽고 row count·분포 추정 자료를 pg_statistic 계열에 갱신한다.
통계는 추정 자료이므로 actual rows와 항상 같지 않다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 10만 행을 1,000계좌에 거의 똑같이 나눴다.
왜 틀리나 계좌 1 하나가 90,000행을 독점한다.
바르게 읽기 hot account와 999 cold accounts를 따로 센다.
반례 account1은 90,000행이지만 account12는 10행뿐이다.
❌ MD5 UUID를 썼으니 transaction ID가 보안상 안전함을 증명한다.
왜 틀리나 여기서는 perf-g를 같은 UUID로 재생성하려는 fixture 도구다.
바르게 읽기 결정적 식별자와 보안 hash 요구를 분리한다.
반례 source에는 secret·salt·threat model이 없다.
❌ ANALYZE 세 문장이 query 시간을 이미 측정한다.
왜 틀리나 table statistics 수집 명령이고 뒤 SELECT의 Execution Time을 내지 않는다.
바르게 읽기 ANALYZE table과 EXPLAIN (ANALYZE ...)를 구분한다.
반례 seed.sql에는 EXPLAIN이나 LIMIT 50이 없다.
❌ 잔액과 원장 합계가 맞으니 실제 송금 domain도 검증됐다.
왜 틀리나 모든 ledger가 PERF_CREDIT +1인 성능용 자료다.
바르게 읽기 fixture arithmetic과 업무 debit/credit 규칙을 나눈다.
반례 business_tx에는 amount·account·time 열조차 없다.
1,000계좌 만들기히토리 → 니지카 → 료 → 키타
-
히토리
generate_series의 1000은 999개를 뜻하나요?
-
니지카
PostgreSQL의 시작·끝을 모두 포함하니 1부터 1000까지 천 개야.
-
료
owner도 perf-owner-g라는 합성 문자열일 뿐 실제 actor가 아니다.
-
키타
첫 id1과 끝 id1000을 대입해 보겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
0.9 hot 비율이 실제 고객 traffic을 대표함
이 책임을 맡는 곳: 운영 통계·익명화 표본·용량 모델debit/credit 균형·상태 전이·업무 금액의 정당성
이 책임을 맡는 곳: application schema·service·domain integration testsSeq Scan·Index Scan 선택 또는 index 후 개선
이 책임을 맡는 곳: measure.sql, create-index.sql, runner plan/benchmark운영 schema data 보존
이 책임을 맡는 곳: disposable Compose lifecycle과 실행 환경 분리STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
fail-fast→새 schema→세 표→1k/100k/100k→90k hot→balance 합계→통계 순서로 말한다.
2단계 · 코드 조각 재조립
- ON_ERROR_STOP/DROP/CREATE
- account/business_tx/ledger_entry
- generate_series account/tx
- ledger CASE/modulo/time
- balance UPDATE/three ANALYZE
3단계 · 파일 전체 다시 쓰기
13개 물리 줄을 긴 SQL까지 그대로 다시 쓰고 source SHA-256·행 수·쏠림 식을 대조한다.
자가 점검
- g 범위 끝값을 포함한다.
- hot 경계는 <=90000이다.
- cold 식의 +2와 %999를 지킨다.
- created_at 기준시각과 g초를 지킨다.
- MD5를 보안 증명이라고 쓰지 않는다.
- ANALYZE를 측정 결과라고 쓰지 않는다.
결정적 UUID히토리 → 니지카 → 료 → 키타
-
히토리
MD5면 안전한 UUID를 만든다는 뜻인가요?
-
니지카
여기서는 같은 perf-g가 매번 같은 UUID가 되게 하려는 선택이야.
-
료
재현성과 암호학적 안전성은 서로 다른 주장이다.
-
키타
SourceHashes와 transaction UUID 용도를 섞지 않겠습니다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
\set ON_ERROR_STOP on
DROP SCHEMA IF EXISTS perf_w13 CASCADE;
CREATE SCHEMA perf_w13;
CREATE TABLE perf_w13.account(id bigint PRIMARY KEY,owner_id varchar(64) NOT NULL,balance bigint NOT NULL CHECK(balance>=0));
CREATE TABLE perf_w13.business_tx(id uuid PRIMARY KEY,status varchar(16) NOT NULL);
CREATE TABLE perf_w13.ledger_entry(id bigint PRIMARY KEY,business_tx_id uuid NOT NULL REFERENCES perf_w13.business_tx(id),account_id bigint NOT NULL REFERENCES perf_w13.account(id),entry_type varchar(32) NOT NULL,signed_amount bigint NOT NULL,created_at timestamptz NOT NULL);
INSERT INTO perf_w13.account SELECT g,'perf-owner-'||g,0 FROM generate_series(1,1000) g;
INSERT INTO perf_w13.business_tx SELECT md5('perf-'||g)::uuid,'COMPLETED' FROM generate_series(1,100000) g;
INSERT INTO perf_w13.ledger_entry
SELECT g,md5('perf-'||g)::uuid,CASE WHEN g<=90000 THEN 1 ELSE 2+((g-90001)%999) END,'PERF_CREDIT',1,timestamptz '2026-01-01 00:00:00+00'+g*interval '1 second'
FROM generate_series(1,100000) g;
UPDATE perf_w13.account a SET balance=x.n FROM (SELECT account_id,sum(signed_amount)::bigint n FROM perf_w13.ledger_entry GROUP BY account_id)x WHERE x.account_id=a.id;
ANALYZE perf_w13.account; ANALYZE perf_w13.business_tx; ANALYZE perf_w13.ledger_entry;
03measure.sql — hot 계좌의 최신 50행 실제 plan을 JSON으로 측정
sql/w13/measure.sql
SQL 정본 · 정본 · W13-F033줄 연결3줄 번역1 chunks
measure.sql — hot 계좌의 최신 50행 실제 plan을 JSON으로 측정
sql/w13/measure.sql
SQL 정본 · 정본 · W13-F03STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
account_id 1의 최신 원장 50행 top-N query를 실제 실행하고 plan·buffer·Execution Time을 JSON 한 값으로 받아 runner가 읽게 한다.
- EXPLAIN에 ANALYZE가 붙으면 query를 실제로 실행할까?
- BUFFERS는 무엇을 더 보여 줄까?
- 왜 created_at 뒤에 id도 내림차순일까?
- LIMIT 50이면 90,000행을 전부 읽지 않는다고 보장할까?
- 이 source가 OFFSET과 keyset을 비교할까?
EXPLAIN ANALYZEBUFFERSFORMAT JSONaccount_id=1created_at DESCid DESCLIMIT 50STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 계좌 1의 최신 원장 50줄을 찾으며 PostgreSQL이 택한 길을 기록한다.
최신 50건을 찾은 길을 JSON으로 보기
이 SQL은 계좌 1의 원장을 최신 시각·큰 ID 순으로 정렬해 최신 50건을 결과로 내보내고, 실제 실행계획을 JSON으로 돌려준다.
ANALYZE는 조회를 실제로 실행하고 BUFFERS는 읽은 저장 페이지 정보를 보탠다. runner는 이 JSON에서 실행 시간을 꺼낸다.
딱 여기까지만 OFFSET·cursor/keyset·다른 계좌는 재지 않으므로 일반적인 pagination 비교 결과가 아니다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
실제로 걸어 본 길
ANALYZE가 붙어 planner 예상만 보지 않고 SELECT를 실행해 actual 값도 적는다.
- 코드 연결
1줄- 비유
- 지도만 그리는 대신 창고까지 직접 걸어가 걸린 시간을 함께 적는다.
- 비유의 끝
- 쓰기 query라면 side effect도 생길 수 있지만 여기서는 SELECT다.
읽은 저장칸
BUFFERS는 shared buffer hit/read 같은 page 이용 정보를 plan에 보탠다.
- 코드 연결
1줄- 비유
- 찾는 동안 어느 선반 칸을 이미 기억했고 새로 열었는지 표시한다.
- 비유의 끝
- buffer 수치 하나만으로 전체 I/O 원인을 확정하지 않는다.
최신 50줄
계좌 1만 고르고 시각·ID를 둘 다 역순으로 정해 앞 50행을 요청한다.
- 코드 연결
2~3줄- 비유
- 1번 상자에서 날짜가 새롭고 같은 날짜면 번호가 큰 영수증부터 50장 꺼낸다.
- 비유의 끝
- planner가 어떤 경로로 50장을 찾는지는 index 상태에 따라 달라진다.
ANALYZE는 실제 실행히토리 → 니지카 → 료 → 키타
-
히토리
EXPLAIN이면 query를 안 돌리고 예상만 보여 주나요?
-
니지카
ANALYZE가 붙었으니 이 SELECT를 실제로 실행하고 actual 값도 모아.
-
료
읽기 SELECT라 data 변경은 없지만 실행 비용과 cache 영향은 생긴다.
-
키타
plain EXPLAIN과 EXPLAIN ANALYZE를 구분해 적겠습니다.
cost와 시간히토리 → 니지카 → 료 → 키타
-
히토리
plan cost가 42면 42ms인가요?
-
니지카
cost는 planner가 경로를 비교하는 내부 숫자고 ms는 Execution Time 쪽이야.
-
료
estimated rows도 통계 추정이고 Actual Rows와 별개다.
-
키타
JSON에서 예상값과 실제값을 다른 칸에 놓겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 3줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F03-L01 | EXPLAIN ( |
길찾기 보고서에 실제 걸음·읽은 창고칸·JSON 양식을 모두 켠다. | EXPLAIN에 ANALYZE, BUFFERS, FORMAT JSON 옵션을 지정한다.
|
| 2줄F03-L02 | SELECT id, |
보고서에서 보여 줄 원장표 네 칸을 골라 든다. | perf_w13.ledger_entry에서 id, entry_type, signed_amount, created_at을 조회한다.
|
| 3줄F03-L03 | WHERE account_id= |
1번 손님 상자만 골라 최신 시각·큰 번호 순으로 50장을 잘라낸다. | account_id=1을 filter하고 created_at DESC, id DESC로 정렬한 뒤 LIMIT 50을 적용한다.
|
90,000에서 50히토리 → 니지카 → 료 → 키타
-
히토리
LIMIT 50이면 앞에서 딱 50개만 보면 되죠?
-
니지카
맞는 index가 있으면 일찍 멈추기 쉽지만 없으면 filter와 sort 때문에 더 훑을 수 있어.
-
료
output cardinality와 rows scanned는 같은 개념이 아니다.
-
키타
plan node의 실제 row와 buffer도 함께 보겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 1개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON)
SELECT id,entry_type,signed_amount,created_at FROM perf_w13.ledger_entry
WHERE account_id=1 ORDER BY created_at DESC,id DESC LIMIT 50;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 3줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) | EXPLAIN에 ANALYZE, BUFFERS, FORMAT JSON 옵션을 지정한다. |
| 2 | SELECT id,entry_type,signed_amount,created_at FROM perf_w13.ledger_entry | perf_w13.ledger_entry에서 id, entry_type, signed_amount, created_at을 조회한다. |
| 3 | WHERE account_id=1 ORDER BY created_at DESC,id DESC LIMIT 50; | account_id=1을 filter하고 created_at DESC, id DESC로 정렬한 뒤 LIMIT 50을 적용한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)이 account1의 created_at/id DESC LIMIT50 SELECT를 실행해 기계 판독 가능한 실제 plan을 반환한다.
문법 해부
- EXPLAIN 옵션 괄호는 실행·buffer·출력 형식을 한 statement에 적용한다.
- WHERE는 account_id=1 후보군을 정하고 ORDER BY 두 열은 total order를 만든다.
- LIMIT 50은 출력 행 수를 자르지만 접근 경로가 읽는 내부 행 수까지 고정하지 않는다.
- FORMAT JSON 결과는 runner가 ConvertFrom-Json으로 읽을 수 있는 구조다.
실행 순서
- SQL parse
- planner 경로 선택
- account filter와 정렬/top-N 실행
- actual·buffer 계측
- JSON plan 반환
원래 W6 수준의 조각별 정밀 해설
F03-C01 · one exact measured top-N statement
- 문법 해부
- EXPLAIN ANALYZE/BUFFERS/JSON이 account_id=1 predicate, created_at·id 내림차순, LIMIT 50인 한 SELECT를 감싼다.
- 실제 값 추적
- underlying SELECT를 실제 실행해 plan의 Limit node Actual Rows에서 최대 50을 관찰하고, client에는 ledger 50행 대신 JSON plan document와 Execution Time이 반환된다.
- 정상 예
- runner의 Benchmark variant만 이 exact 파일을 index 전 30회와 index 후 30회 실행하며, BeforePlan과 AfterPlan은 각각 plan 한 번만 실행한다.
- 틀린 예·반례
- BeforePlan·AfterPlan도 30회 분포를 만든다고 읽거나 EXPLAIN client가 ledger row 50개를 받는다고 말하면 source와 출력 형식을 모두 어긴다.
- 착각 방지
- LIMIT 50은 underlying SELECT plan node가 내보낼 row 한도이지 executor가 내부에서 방문·검사한 행도 항상 50개라는 보장이 아니다.
- 하지 않는 일
- 이 statement는 30-sample 분포, page 1000, keyset 안정성, production latency를 책임지지 않는다.
- 다음 연결
- create-index.sql이 이 predicate·order에 맞는 복합 index를 추가한다.
ID tie-breaker히토리 → 니지카 → 료 → 키타
-
히토리
created_at이 있는데 id DESC도 꼭 필요한가요?
-
니지카
시각이 같은 행이 생겨도 id로 순서를 하나로 정할 수 있어.
-
료
현재 seed는 g초가 달라 동률이 없지만 query 계약은 더 안정적이다.
-
키타
두 열 순서를 index와 나란히 비교할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | seed 뒤 account1 ledger 90,000행 | WHERE account_id=1 후보를 만든다. | 논리 후보군은 hot 행 90,000개다. | planner의 estimated rows는 통계 기반이라 actual 90,000과 다를 수 있다. |
| 2 | 서로 다른 created_at과 id 1..100000 | created_at DESC, id DESC로 정렬 의미를 정한다. | 가장 늦은 시각부터, 동률이면 큰 id부터라는 안정 순서가 된다. | 현재 seed는 시각 동률이 없지만 id tie-breaker 계약은 남는다. |
| 3 | 정렬된 account1 후보 | LIMIT 50을 적용한다. | query 출력은 최신 50행이다. | index가 없으면 50행만 반환해도 많은 행을 읽거나 sort할 수 있다. |
| 4 | 실제로 실행된 SELECT | actual timing·rows와 buffer 사용을 모아 JSON으로 직렬화한다. | runner가 object[0].Plan과 Execution Time을 읽을 수 있다. | 한 번의 Execution Time은 30회 분포나 운영 latency를 대표하지 않는다. |
pagination 과장 금지히토리 → 니지카 → 료 → 키타
-
히토리
OFFSET이 없으니 keyset을 이미 쓴 건가요?
-
니지카
아니, 이것은 첫 top-N이고 마지막 값을 받는 cursor predicate가 없어.
-
료
D5 gate도 OFFSET token 부재만 볼 뿐 cursor correctness를 증명하지 않는다.
-
키타
설명 제목을 최신 50건 측정으로 제한하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
table statistics·index 목록·query 조건을 보고 Seq Scan, Index Scan 같은 후보 비용을 비교한다.
cost 단위는 wall-clock millisecond가 아니다.고른 plan을 실제로 실행하며 Actual Rows·loops·timing을 수집한다.
ANALYZE overhead가 들어가고 같은 machine 상태에서도 값은 흔들릴 수 있다.query가 사용한 shared/local/temp buffer hit·read·write 정보를 node에 붙인다.
OS cache와 storage 전체 동작을 한 숫자로 모두 설명하지 않는다.plan tree와 Planning/Execution Time을 JSON array 구조로 출력한다.
format 변경은 query 결과 50행을 반환하는 API가 아니라 plan 설명을 반환한다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ EXPLAIN ANALYZE의 cost 100은 100ms라는 뜻이다.
왜 틀리나 cost는 planner 내부 비교 단위이고 실제 시간은 별도 Execution Time·actual time에 있다.
바르게 읽기 estimated cost와 measured milliseconds를 다른 열로 읽는다.
반례 cache 상태가 바뀌면 같은 cost plan의 actual time은 달라질 수 있다.
❌ LIMIT 50이 있으니 database도 정확히 50행만 읽는다.
왜 틀리나 정렬과 filter를 만족할 경로가 없으면 더 많은 행을 훑을 수 있다.
바르게 읽기 반환 행 수와 scan·sort 작업량을 plan node에서 따로 본다.
반례 index 전에는 Seq Scan과 Sort가 후보가 될 수 있다.
❌ id DESC가 있으니 keyset pagination을 측정한다.
왜 틀리나 cursor 값을 비교하는 WHERE predicate가 source에 없다.
바르게 읽기 stable top-N order와 keyset 다음-page 조건을 구분한다.
반례 WHERE에는 account_id=1만 있고 last_created_at·last_id 비교가 없다.
❌ account1 결과가 빠르면 모든 계좌에서도 같은 plan이 좋다.
왜 틀리나 account1은 전체 원장의 90%인 극단적 hot value다.
바르게 읽기 parameter와 분포 범위를 주장에 적는다.
반례 cold account는 10~11행이라 selectivity와 선택 plan이 다를 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
OFFSET·keyset·다음 page correctness 또는 성능
이 책임을 맡는 곳: 별도 pagination query·fixture·benchmarkcold account와 다른 LIMIT·정렬·filter의 plan
이 책임을 맡는 곳: 추가 representative parameter 측정estimated rows와 actual rows가 항상 일치함
이 책임을 맡는 곳: 통계 품질·분포·plan actual 비교운영 환경의 절대 latency나 지속적 개선
이 책임을 맡는 곳: 반복·warm-up·순서 통제·운영 관측 설계STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
실제 실행+buffers+JSON→네 출력 열→account1 filter→두 열 내림차순→50행 순서로 말한다.
2단계 · 코드 조각 재조립
- EXPLAIN option tuple
- SELECT four columns/FROM
- WHERE/ORDER BY/LIMIT
3단계 · 파일 전체 다시 쓰기
세 물리 줄의 긴 statement를 그대로 다시 쓰고 whitespace와 token을 canonical SHA-256으로 대조한다.
자가 점검
- ANALYZE와 BUFFERS를 빠뜨리지 않는다.
- FORMAT JSON을 유지한다.
- account_id는 1이다.
- created_at과 id 모두 DESC다.
- LIMIT는 50이다.
- OFFSET·cursor를 측정했다고 쓰지 않는다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON)
SELECT id,entry_type,signed_amount,created_at FROM perf_w13.ledger_entry
WHERE account_id=1 ORDER BY created_at DESC,id DESC LIMIT 50;
04create-index.sql — top-N 조회 순서와 맞춘 복합 인덱스
sql/w13/create-index.sql
SQL 정본 · 정본 · W13-F042줄 연결2줄 번역2 chunks
create-index.sql — top-N 조회 순서와 맞춘 복합 인덱스
sql/w13/create-index.sql
SQL 정본 · 정본 · W13-F04STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
account_id 한 계좌의 최신 원장 50건을 찾을 때 전체 원장을 다시 정렬하지 않도록 조회 조건과 정렬 순서에 맞는 접근 경로를 만든다.
- 복합 인덱스의 첫 열은 왜 account_id일까?
- created_at과 id에 DESC를 함께 둔 이유는 무엇일까?
- 인덱스가 존재하면 PostgreSQL이 반드시 사용할까?
index=idx_ledger_account_created_idkeys=account_id, created_at DESC, id DESCtable=perf_w13.ledger_entryANALYZE after CREATE INDEXSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY 원장 보관함에서 한 계좌의 최신 기록 50장만 빨리 찾으려 한다.
최신 티켓을 바로 꺼내는 찾아보기
먼저 계좌별 칸을 고르고 그 안에서 최신 시각과 큰 번호부터 보도록 세 열 인덱스를 만든다. 이 순서는 실제 top-N SQL의 조건과 정렬 순서를 따른다.
인덱스를 만든 뒤 ANALYZE로 통계를 갱신한다. 다만 존재만으로 사용을 보장하지 않으므로 runner가 after plan에서 실제 Index Scan을 따로 확인한다.
딱 여기까지만 찾아보기 비유는 접근 순서를 설명할 뿐 planner 선택·실제 latency·운영 부하를 보장하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
첫 열 account_id
WHERE account_id=1 같은 동등 조건을 먼저 좁힌다.
- 코드 연결
1줄 첫 key- 비유
- 계좌별 서랍부터 고르기
- 비유의 끝
- 모든 계좌를 동시에 조회하는 SQL에는 같은 이점이 아닐 수 있다.
두 DESC 정렬
created_at이 최신인 행부터, 같은 시각이면 id가 큰 행부터 읽는 순서를 인덱스에 넣는다.
- 코드 연결
1줄 두 번째·세 번째 key- 비유
- 최신 티켓과 큰 번호 순으로 꽂기
- 비유의 끝
- 동률 해결은 id 값의 의미와 query ORDER BY가 일치할 때만 성립한다.
ANALYZE
새 접근 경로를 만든 뒤 planner 통계를 다시 수집한다.
- 코드 연결
2줄- 비유
- 새 찾아보기의 분포표 갱신
- 비유의 끝
- 특정 scan을 강제하지 않는다.
열 순서히토리 → 니지카 → 료 → 키타
-
히토리
왜 account_id가 맨 앞이어야 하지?
-
니지카
한 계좌라는 동등 조건을 먼저 좁혀야 뒤 DESC 순서를 이어 쓰기 쉽다.
-
료
세 열 존재보다 왼쪽 순서가 핵심이야.
-
키타
WHERE와 ORDER BY를 나란히 놓고 읽을게요.
두 번째 DESC히토리 → 니지카 → 료 → 키타
-
히토리
created_at만 DESC면 충분하지 않아?
-
니지카
같은 시각이 생기면 id DESC가 안정적인 다음 순서를 준다.
-
료
동률을 방치하면 50번째 경계가 흔들릴 수 있어.
-
키타
두 정렬 key를 한 쌍으로 기억할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 2줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F04-L01 | CREATE INDEX idx_ledger_account_created_id ON perf_w13. |
STARRY 티켓 보관함을 먼저 계좌별로 나누고, 각 칸에서는 최신 시각과 큰 티켓 번호부터 꺼내도록 세 겹 찾아보기를 붙인다. | top-N 조회의 동등 조건 account_id를 선두 키로 두고 ORDER BY created_at DESC, id DESC를 뒤따르게 하는 idx_ledger_account_created_id를 생성한다.
|
| 2줄F04-L02 | ANALYZE perf_w13. |
새 찾아보기를 단 뒤 료가 보관함의 분포표를 다시 세어 안내판을 최신 상태로 바꾼다. | ANALYZE가 perf_w13.ledger_entry 표본 통계를 갱신해 이후 실행 계획 비용 계산에 사용할 정보를 제공한다.
|
ANALYZE 역할히토리 → 니지카 → 료 → 키타
-
히토리
ANALYZE가 index 사용을 켜 주는 명령이야?
-
니지카
통계를 갱신할 뿐 선택은 planner가 비용을 보고 한다.
-
료
사용 증거는 after plan의 node에서 찾아.
-
키타
통계 갱신과 선택 증거를 구분하겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 2개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
CREATE INDEX idx_ledger_account_created_id ON perf_w13.ledger_entry(account_id,created_at DESC,id DESC);
ANALYZE perf_w13.ledger_entry;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 2줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | CREATE INDEX idx_ledger_account_created_id ON perf_w13.ledger_entry(account_id,created_at DESC,id DESC); | perf_w13.ledger_entry에 account_id, created_at 내림차순, id 내림차순 순서의 복합 인덱스를 만든다. |
| 2 | ANALYZE perf_w13.ledger_entry; | 새 인덱스가 생긴 ledger_entry의 planner 통계를 다시 수집한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기account_id 동등 조건 뒤 created_at DESC·id DESC 정렬을 잇는 복합 인덱스를 만들고 ANALYZE하지만 실제 plan 선택은 runner가 별도로 증명한다.
문법 해부
- CREATE INDEX 이름 ON schema.table(columns)는 새 B-tree 접근 경로를 만든다.
- 복합 인덱스에서는 왼쪽 열부터 조건과 정렬에 맞는지가 중요하다.
- DESC는 해당 key의 내림차순 저장·탐색 방향을 명시한다.
- ANALYZE는 planner용 table 통계를 갱신한다.
실행 순서
- PostgreSQL이 ledger_entry를 읽어 세 key의 index를 구축한다.
- catalog에 idx_ledger_account_created_id가 등록된다.
- ANALYZE가 ledger_entry 통계를 새로 계산한다.
- AfterPlan/Benchmark query가 planner 선택을 별도로 관찰한다.
원래 W6 수준의 조각별 정밀 해설
F04-C01 · create the exact composite index
- 문법 해부
- CREATE INDEX가 account_id, created_at DESC, id DESC 순서의 idx_ledger_account_created_id를 만든다.
- 실제 값 추적
- account_id=1 equality 뒤 두 DESC key가 최신 50행 순서를 직접 제공할 후보가 된다.
- 정상 예
- seed가 schema를 재생성한 AfterPlan에서 named index 하나가 생긴다.
- 틀린 예·반례
- 같은 schema에서 index가 이미 있는데 다시 실행하면 IF NOT EXISTS가 없어 duplicate-object 오류가 난다.
- 착각 방지
- index 존재가 planner 선택이나 latency 개선을 무조건 보장하지 않는다.
- 하지 않는 일
- write/storage 비용은 이 DDL에서 측정하지 않는다.
- 다음 연결
- ANALYZE가 새 index와 분포를 planner에 다시 보인다.
F04-C02 · refresh planner statistics
- 문법 해부
- ANALYZE ledger_entry가 새 index 생성 뒤 ledger_entry 열 값 분포의 planner 통계를 다시 수집한다.
- 실제 값 추적
- planner는 catalog에 등록된 새 index와 갱신된 table 통계를 함께 보고 AfterPlan·Benchmark after query의 access path를 고른다.
- 정상 예
- 뒤 FindIndex가 JSON plan에서 정확한 index 이름을 실제 선택 결과로 확인할 수 있다.
- 틀린 예·반례
- ANALYZE를 생략해 통계가 부정확하면 planner가 새 index의 비용을 덜 적절하게 추정할 수 있다.
- 착각 방지
- ANALYZE 성공 자체는 별도의 index 전용 통계를 만들거나 Index Scan 선택을 증명하지 않는다.
- 하지 않는 일
- row-count invariant는 assert.sql이 검사한다.
- 다음 연결
- assert.sql이 다섯 count·mismatch 값과 별도 named-index count까지 여섯 검사값을 fail-closed로 확인한다.
존재와 사용히토리 → 니지카 → 료 → 키타
-
히토리
assert.sql이 index 이름을 찾으면 끝난 거 아냐?
-
니지카
그 검사는 catalog 존재만 본다. runner의 FindIndex가 실제 plan을 본다.
-
료
명찰이 창고에 있음과 공연에서 사용함은 다르지.
-
키타
두 gate의 증명 범위를 따로 적을게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | account_id=1, 최신 50건 | 선두 key로 계좌 1 범위를 좁히고 DESC key 방향으로 읽는다. | 정렬과 LIMIT에 맞는 후보 접근 경로가 생긴다. | 실제 선택 여부는 EXPLAIN plan에서 확인한다. |
| 2 | created_at 동률 두 행 | 두 번째 정렬 key가 같으면 id DESC를 적용한다. | 더 큰 id가 먼저 오는 안정 순서가 가능하다. | id가 업무상 시간 순서를 뜻한다는 보장은 없다. |
| 3 | CREATE 후 ANALYZE | 새 index가 있는 table 통계를 수집한다. | planner가 비용 계산에 쓸 최신 통계를 얻는다. | 비용 추정이 실제 시간과 같다는 뜻은 아니다. |
운영 일반화히토리 → 니지카 → 료 → 키타
-
히토리
합성 lab이 빨라지면 운영도 무조건 빨라지지?
-
니지카
아니다. 분포·부하·cache·hardware가 다른 운영은 별도 측정이 필요하다.
-
료
source verdict도 절대 latency claim을 거부해.
-
키타
이번 결과를 로컬 상대 개선으로만 말하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
ledger_entry의 세 key와 row 위치를 B-tree에 저장한다.
index 구축은 추가 저장공간과 write 비용을 만든다.ANALYZE 결과로 행 분포와 비용을 추정한다.
planner는 index가 있어도 sequential scan을 선택할 수 있다.FindIndex가 실제 JSON plan에서 목표 이름과 scan type을 확인한다.
assert.sql의 catalog count만으로는 plan selection이 증명되지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 인덱스만 만들면 query가 무조건 빨라진다.
왜 틀리나 planner 선택·cache·분포·쓰기 비용에 따라 결과가 달라진다.
바르게 읽기 AfterPlan의 실제 plan과 Benchmark의 두 metric을 함께 본다.
반례 작은 table에서는 sequential scan이 더 싸다고 판단될 수 있다.
❌ 열 세 개가 있으면 순서는 상관없다.
왜 틀리나 복합 index의 왼쪽 prefix가 WHERE와 ORDER BY 연결에 영향을 준다.
바르게 읽기 account_id를 먼저 두고 두 DESC key를 query 순서대로 둔다.
반례 created_at을 첫 열로 두면 특정 account 범위를 한 덩어리로 좁히기 어렵다.
❌ ANALYZE가 데이터를 정렬한다.
왜 틀리나 ANALYZE는 통계를 수집한다.
바르게 읽기 물리적 행 정렬과 planner 통계를 구분한다.
반례 ANALYZE 뒤 table row 순서 자체는 query ORDER BY 없이 보장되지 않는다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
목표 index가 실제 top-N plan에 쓰임
이 책임을 맡는 곳: run-w13-perf.ps1 AfterPlan/Benchmark FindIndexmedian·p95가 운영에서도 개선됨
이 책임을 맡는 곳: fixture-bound benchmark and production load testINSERT/UPDATE 비용과 disk 증가가 허용 범위임
이 책임을 맡는 곳: write benchmark and capacity reviewSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- WHERE와 ORDER BY를 보고 복합 key 순서를 말한다.
- index 이름과 세 열 방향을 정확히 다시 쓴다.
- CREATE INDEX·ANALYZE·plan 확인의 책임을 분리한다.
2단계 · 코드 조각 재조립
- 첫 열 account_id
- 두 DESC 정렬
- ANALYZE
3단계 · 파일 전체 다시 쓰기
정본을 보지 않고 2줄 전체를 다시 쓰고 비공백 mapping·번역 2행과 source-local test 0개를 대조한다.
자가 점검
- 비공백 원문 2줄이 mapping과 번역에 정확히 한 번씩 있는지 본다.
- 각 줄의 비유·실제 의미·입력·직접 결과·한계가 서로 다른 역할을 하는지 읽는다.
- 고정 수치·파일명·분기 조건을 canonical source와 다시 대조한다.
- 직접 보장과 다음 layer 책임을 반례로 설명한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
CREATE INDEX idx_ledger_account_created_id ON perf_w13.ledger_entry(account_id,created_at DESC,id DESC);
ANALYZE perf_w13.ledger_entry;
05assert.sql — fixture 숫자·잔액 대사·인덱스 존재를 막는 SQL gate
sql/w13/assert.sql
SQL 정본 · 정본 · W13-F057줄 연결7줄 번역2 chunks
assert.sql — fixture 숫자·잔액 대사·인덱스 존재를 막는 SQL gate
sql/w13/assert.sql
SQL 정본 · 정본 · W13-F05STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
성능 비교 전에 합성 DB의 행 수와 hot 분포, 계좌 잔액 대사, 목표 인덱스 이름이 예상과 다른 상태를 fail-closed로 거부한다.
- a·t·l·h·m 다섯 변수는 각각 무엇을 셀까?
- m=0은 어떤 불일치가 없다는 뜻일까?
- 인덱스 이름 존재 검사가 실제 plan 선택도 증명할까?
a=1000t=100000l=100000h=90000m=0index count=1marker=W13_ASSERT 1000|100000|100000|90000|0STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 초시계 결과를 발표하기 전에 장부가 처음 약속한 모양인지 확인한다.
성능표를 믿기 전 다섯 칸 대조
계좌 1천, 거래 10만, 원장 10만, 계좌 1의 hot 원장 9만을 센다. 이어 각 계좌 balance와 원장 signed_amount 합계가 다른 계좌 수가 0인지 본다.
목표 인덱스 이름도 catalog에 정확히 한 건 있어야 한다. 다만 여러 SELECT에 강한 isolation·writer lock이 없어서 동시 변경을 한 snapshot으로 묶지는 않으며, 실제 index plan 선택도 runner가 따로 본다.
딱 여기까지만 대조표 비유는 현재 synthetic fixture의 sentinel만 설명하며 모든 행 의미·동시성·운영 정합성을 포괄하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
네 개 count
a·t·l·h에 전체 3개 table과 account_id=1 원장 수를 저장한다.
- 코드 연결
2줄- 비유
- 네 장부의 종이 수 세기
- 비유의 끝
- 금액·시간·연결 관계는 세지 않는다.
잔액 대사 m
account.balance와 계좌별 signed_amount 합계가 다른 계좌 수를 센다.
- 코드 연결
3~4줄- 비유
- 잔액표와 원장 합계표 맞대기
- 비유의 끝
- m=0은 각 SELECT가 관찰한 값 조합의 대사이며 shared snapshot·동시 write 차단은 없다.
catalog gate
pg_indexes에서 schema와 이름이 맞는 index가 정확히 하나인지 본다.
- 코드 연결
5줄- 비유
- 장비 목록의 명찰 한 장 확인
- 비유의 끝
- 정의·열 순서·plan 사용은 확인하지 않는다.
다섯 변수와 snapshot히토리 → 니지카 → 료 → 키타
-
히토리
DO 안의 a·t·l·h count는 한순간을 동시에 찍나?
-
니지카
네 SELECT는 차례로 평가되고 stronger isolation이나 writer lock이 선언되지 않았다.
-
료
동시 commit이 있으면 서로 다른 상태를 볼 수 있어.
-
키타
변수 뜻과 함께 비원자 snapshot 경계를 적겠습니다.
m의 뜻히토리 → 니지카 → 료 → 키타
-
히토리
m=0이면 ledger 행이 0이라는 뜻이야?
-
니지카
아니다. balance와 계좌별 signed_amount 합계가 다른 계좌 수가 0이다.
-
료
행 수 l=100000과 전혀 다른 집계야.
-
키타
m을 mismatch account count로 기억할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 7줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F05-L01 | DO $gate$ DECLARE a bigint; |
니지카가 공연 전 대조표를 펼치고 계좌·거래·원장·몰린 행·잔액 불일치 수를 적을 다섯 빈칸을 만든다. | DO $gate$ 블록이 64비트 정수 변수 다섯 개의 범위를 시작하며 뒤의 SELECT와 IF를 한 실행 단위에 묶는다.
|
| 2줄F05-L02 | SELECT count( |
네 장부 질문을 차례로 던져 돌아온 수를 a·t·l·h 칸에 1천·10만·10만·9만 순서로 적는다. | 네 SELECT count(*)가 전체 계좌·업무 거래·원장과 hot 계좌 1의 원장을 서로 다른 변수에 저장한다.
|
| 3줄F05-L03 | SELECT count( |
료가 모든 계좌 잔액표를 원장 합계표와 맞대고 숫자가 다른 계좌에만 표시한 뒤 그 표시 수를 센다. | account를 계좌별 ledger 합계와 LEFT JOIN하고 coalesce로 원장 없는 계좌를 0으로 본 뒤 불일치 행을 집계한다.
|
| 4줄F05-L04 | IF a<>1000 OR t<>100000 OR l<>100000 OR h<>90000 OR m<>0 THEN RAISE EXCEPTION 'W13 counts a= |
다섯 검수칸 중 하나라도 목표 숫자와 다르면 키타가 현재 숫자를 모두 읽어 주고 리허설을 즉시 중단한다. | OR로 연결된 다섯 sentinel 비교가 true일 때 RAISE EXCEPTION으로 W13 counts 오류와 a·t·l·h·m을 출력한다.
|
| 5줄F05-L05 | IF ( |
장비 목록에서 세 겹 찾아보기 명찰을 세어 정확히 하나가 아니면 료가 ‘인덱스 없음’ 경고를 낸다. | catalog view의 schema와 indexname을 제한해 idx_ledger_account_created_id 존재 개수를 1과 비교한다.
|
| 6줄F05-L06 | END $gate$; |
다섯 숫자와 인덱스 명찰 검사를 모두 통과한 니지카가 검수표를 닫아 판정을 확정한다. | END $gate$가 DO 블록의 제어 범위를 끝내며 앞에서 발생한 예외가 없다면 다음 SQL 문장으로 넘긴다.
|
| 7줄F05-L07 | SELECT 'W13_ASSERT 1000|100000|100000|90000|0'; |
검수가 끝나면 키타가 다섯 합격 숫자가 적힌 초록 확인표 한 장을 관객에게 보여 준다. | 앞 gate가 예외 없이 끝났을 때 psql 출력에 정확한 W13_ASSERT sentinel 문자열을 남긴다.
|
LEFT JOIN히토리 → 니지카 → 료 → 키타
-
히토리
원장이 없는 계좌는 비교에서 사라지지 않아?
-
니지카
LEFT JOIN이 계좌를 남기고 coalesce가 없는 합계를 0으로 바꾼다.
-
료
INNER JOIN이면 그런 계좌가 빠질 수 있어.
-
키타
조인 방향까지 포함해 읽겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 2개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
DO $gate$ DECLARE a bigint;t bigint;l bigint;h bigint;m bigint; BEGIN
SELECT count(*) INTO a FROM perf_w13.account; SELECT count(*) INTO t FROM perf_w13.business_tx; SELECT count(*) INTO l FROM perf_w13.ledger_entry; SELECT count(*) INTO h FROM perf_w13.ledger_entry WHERE account_id=1;
SELECT count(*) INTO m FROM perf_w13.account x LEFT JOIN(SELECT account_id,sum(signed_amount) b FROM perf_w13.ledger_entry GROUP BY account_id)y ON y.account_id=x.id WHERE x.balance<>coalesce(y.b,0);
IF a<>1000 OR t<>100000 OR l<>100000 OR h<>90000 OR m<>0 THEN RAISE EXCEPTION 'W13 counts a=% t=% l=% h=% m=%',a,t,l,h,m; END IF;
IF (SELECT count(*) FROM pg_indexes WHERE schemaname='perf_w13' AND indexname='idx_ledger_account_created_id')<>1 THEN RAISE EXCEPTION 'W13 index missing'; END IF;
END $gate$;
SELECT 'W13_ASSERT 1000|100000|100000|90000|0';
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 7줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | DO $gate$ DECLARE a bigint;t bigint;l bigint;h bigint;m bigint; BEGIN | 익명 PL/pgSQL gate를 열고 다섯 bigint 변수 a, t, l, h, m을 선언한다. |
| 2 | SELECT count(*) INTO a FROM perf_w13.account; SELECT count(*) INTO t FROM perf_w13.business_tx; SELECT count(*) INTO l FROM perf_w13.ledger_entry; SELECT count(*) INTO h FROM perf_w13.ledger_entry WHERE account_id=1; | account, business_tx, ledger_entry 전체 행 수와 account_id=1 원장 행 수를 각각 a, t, l, h에 넣는다. |
| 3 | SELECT count(*) INTO m FROM perf_w13.account x LEFT JOIN(SELECT account_id,sum(signed_amount) b FROM perf_w13.ledger_entry GROUP BY account_id)y ON y.account_id=x.id WHERE x.balance<>coalesce(y.b,0); | 계좌 balance와 계좌별 signed_amount 합계가 다른 계좌 수를 m에 넣는다. |
| 4 | IF a<>1000 OR t<>100000 OR l<>100000 OR h<>90000 OR m<>0 THEN RAISE EXCEPTION 'W13 counts a=% t=% l=% h=% m=%',a,t,l,h,m; END IF; | a=1000, t=100000, l=100000, h=90000, m=0 중 하나라도 아니면 실제 다섯 값을 담아 실패한다. |
| 5 | IF (SELECT count(*) FROM pg_indexes WHERE schemaname='perf_w13' AND indexname='idx_ledger_account_created_id')<>1 THEN RAISE EXCEPTION 'W13 index missing'; END IF; | pg_indexes에서 perf_w13의 지정 인덱스 이름이 정확히 한 건이 아니면 실패한다. |
| 6 | END $gate$; | gate 익명 블록을 닫고 실행한다. |
| 7 | SELECT 'W13_ASSERT 1000|100000|100000|90000|0'; | 고정 marker W13_ASSERT 1000|100000|100000|90000|0을 한 행으로 반환한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기다섯 sentinel 1000|100000|100000|90000|0과 index 이름 한 건을 SQL 예외로 강제하되 실제 plan 선택은 증명하지 않는다.
문법 해부
- DO $gate$ ... END $gate$는 익명 PL/pgSQL 블록이다.
- SELECT count(*) INTO 변수는 집계값을 PL/pgSQL 변수에 넣는다.
- LEFT JOIN과 coalesce는 원장이 없는 계좌도 balance 0과 비교하게 한다.
- RAISE EXCEPTION은 조건 불일치 시 psql을 nonzero로 끝내게 한다.
실행 순서
- 다섯 bigint 변수를 만든다.
- 네 count와 balance mismatch count를 별도 SELECT로 계산한다; stronger isolation이나 writer lock은 선언하지 않는다.
- 고정 expected tuple과 대조한다.
- pg_indexes에서 target name count를 검사한다.
- 모두 통과하면 고정 W13_ASSERT marker를 반환한다.
원래 W6 수준의 조각별 정밀 해설
F05-C01 · DO block for counts, reconciliation, and index existence
- 문법 해부
- DO 블록이 a/t/l/h 네 count, m balance mismatch count, named index count까지 여섯 invariant를 두 IF에서 검사한다.
- 실제 값 추적
- accounts=1000, transactions=100000, ledger=100000, hot=90000, balance mismatch=0, named index count=1을 요구한다.
- 정상 예
- create-index 뒤 쓰기가 멈춘 정확한 fixture라면 두 RAISE EXCEPTION 경로를 모두 지나 블록이 정상 종료한다.
- 틀린 예·반례
- index 이름이 없거나 같은 이름 count가 1이 아니면 둘째 IF가 실패한다.
- 착각 방지
- a/t/l/h/m을 만드는 SELECT들은 따로 평가되며 REPEATABLE READ·SERIALIZABLE이나 writer lock을 설정하지 않아 동시 commit 중 하나의 atomic snapshot이라고 보장할 수 없다.
- 하지 않는 일
- plan 선택, 30개 latency·median, D5 OFFSET/cursor 범위는 이 SQL이 검사하지 않는다.
- 다음 연결
- 마지막 SELECT는 count/mismatch 다섯 숫자만 사람이 읽을 marker로 낸다.
F05-C02 · human-readable success row
- 문법 해부
- 상수 문자열 SELECT가 `W13_ASSERT 1000|100000|100000|90000|0` marker를 출력한다.
- 실제 값 추적
- psql 로그에 a/t/l/h/m 기대값 다섯 숫자가 정확한 구분자로 남는다.
- 정상 예
- runner PsqlFile의 `-v ON_ERROR_STOP=1` 아래에서는 앞 DO 블록이 실패하면 psql이 멈춰 이 marker에 도달하지 않는다.
- 틀린 예·반례
- assert.sql 자체에는 ON_ERROR_STOP 설정이 없으므로 fail-fast 옵션 없이 실행하면 DO 오류 뒤 marker SELECT가 계속될 수 있고, 문자열만 따로 실행해도 같은 글자가 나온다.
- 착각 방지
- marker 텍스트에는 index=1이 없으므로 index 검사는 앞 DO block 성공과 fail-fast 실행 문맥으로만 뒷받침된다.
- 하지 않는 일
- plan JSON과 benchmark CSV를 만들지 않는다.
- 다음 연결
- runner의 AfterPlan·Benchmark phase가 fail-fast 문맥에서 전체 assert.sql native exit를 확인한다.
인덱스 gate히토리 → 니지카 → 료 → 키타
-
히토리
이름 한 건이면 열 순서도 맞다는 뜻이지?
-
니지카
pg_indexes count 조건은 schema와 name만 제한한다.
-
료
정의 비교도 plan 확인도 아니다.
-
키타
존재 검사의 좁은 범위를 표시할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 정상 seed | 네 table count와 hot condition을 별도 SELECT로 집계한다. | a=1000, t=100000, l=100000, h=90000 | 동시 writer가 있으면 SELECT 사이 committed 상태가 달라질 수 있다. |
| 2 | account 1 balance=89999 | ledger sum 90000과 비교해 mismatch를 센다. | m=1이 되어 counts exception이 발생한다. | 어떤 ledger가 원인인지는 메시지에 나오지 않는다. |
| 3 | index 삭제 | pg_indexes target count가 0이 된다. | W13 index missing 예외로 marker 전에 중단된다. | plan query 자체는 이 SQL에 없다. |
marker히토리 → 니지카 → 료 → 키타
-
히토리
마지막 SELECT가 앞 검사를 다시 실행하나?
-
니지카
아니다. DO block이 끝난 뒤 고정 문자열 한 행을 내보낼 뿐이다.
-
료
예외가 났다면 marker까지 도달하지 못해.
-
키타
marker를 성공 신호로만 해석하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
DO block 변수가 같은 실행 안에서 count와 guard를 연결한다.
결과 변수를 외부 session에 남기지 않는다.계좌별 signed_amount SUM과 account balance를 LEFT JOIN한다.
명시적 repeatable-read/serializable이나 writer lock이 없어 여러 SELECT가 하나의 snapshot을 공유한다고 보장하지 않는다.pg_indexes view에서 schema와 이름을 센다.
index definition이나 scan adoption은 보지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 행 수가 맞으면 ledger도 올바르다.
왜 틀리나 10만 행이어도 signed_amount나 account 연결이 틀릴 수 있다.
바르게 읽기 m=0 대사와 필요한 별도 row-level 검사를 구분한다.
반례 두 ledger의 account_id를 바꾸되 합계가 우연히 같을 수도 있다.
❌ m=0이면 모든 금융 불변성이 증명된다.
왜 틀리나 이 gate는 balance와 계좌별 signed sum 하나만 비교한다.
바르게 읽기 현재 합성 schema의 직접 식만 주장한다.
반례 business_tx 상태나 entry_type이 틀려도 합계가 같으면 m은 0일 수 있다.
❌ pg_indexes count=1이면 target query가 index를 쓴다.
왜 틀리나 catalog 존재와 planner 선택은 다른 사실이다.
바르게 읽기 FindIndex로 JSON plan node를 확인한다.
반례 planner가 sequential scan을 골라도 catalog count는 1이다.
❌ DO block 안의 count는 모두 같은 snapshot이다.
왜 틀리나 여러 SELECT에 repeatable-read/serializable 선언이나 writer lock이 없다.
바르게 읽기 동시 변경 가능성이 있으면 비원자 집계라고 표시한다.
반례 첫 count 뒤 다른 transaction이 commit하면 다음 SELECT가 다른 committed 상태를 볼 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
target query가 목표 index로 실행됨
이 책임을 맡는 곳: runner AfterPlan/Benchmark JSON plan gate모든 ledger entry_type·business_tx link·timestamp가 올바름
이 책임을 맡는 곳: additional row-level assertions명시적 isolation·lock 없이 네 count와 mismatch count가 같은 committed 상태를 봄
이 책임을 맡는 곳: repeatable-read/serializable policy or writer lock plus concurrent testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- a·t·l·h·m의 뜻과 기대값을 순서대로 말한다.
- LEFT JOIN·SUM·coalesce가 m을 만드는 흐름을 다시 쓴다.
- catalog 존재와 execution plan 선택을 서로 다른 증거로 설명한다.
2단계 · 코드 조각 재조립
- 네 개 count
- 잔액 대사 m
- catalog gate
3단계 · 파일 전체 다시 쓰기
정본을 보지 않고 7줄 전체를 다시 쓰고 비공백 mapping·번역 7행과 source-local test 0개를 대조한다.
자가 점검
- 비공백 원문 7줄이 mapping과 번역에 정확히 한 번씩 있는지 본다.
- 각 줄의 비유·실제 의미·입력·직접 결과·한계가 서로 다른 역할을 하는지 읽는다.
- 고정 수치·파일명·분기 조건을 canonical source와 다시 대조한다.
- 직접 보장과 다음 layer 책임을 반례로 설명한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
DO $gate$ DECLARE a bigint;t bigint;l bigint;h bigint;m bigint; BEGIN
SELECT count(*) INTO a FROM perf_w13.account; SELECT count(*) INTO t FROM perf_w13.business_tx; SELECT count(*) INTO l FROM perf_w13.ledger_entry; SELECT count(*) INTO h FROM perf_w13.ledger_entry WHERE account_id=1;
SELECT count(*) INTO m FROM perf_w13.account x LEFT JOIN(SELECT account_id,sum(signed_amount) b FROM perf_w13.ledger_entry GROUP BY account_id)y ON y.account_id=x.id WHERE x.balance<>coalesce(y.b,0);
IF a<>1000 OR t<>100000 OR l<>100000 OR h<>90000 OR m<>0 THEN RAISE EXCEPTION 'W13 counts a=% t=% l=% h=% m=%',a,t,l,h,m; END IF;
IF (SELECT count(*) FROM pg_indexes WHERE schemaname='perf_w13' AND indexname='idx_ledger_account_created_id')<>1 THEN RAISE EXCEPTION 'W13 index missing'; END IF;
END $gate$;
SELECT 'W13_ASSERT 1000|100000|100000|90000|0';
06run-w13-perf.ps1 — 폐기형 DB에서 계획·분포·전후 30회를 증거로 남기는 orchestrator
scripts/run-w13-perf.ps1
PowerShell 정본 · 정본 · W13-F06143줄 연결143줄 번역16 chunks
run-w13-perf.ps1 — 폐기형 DB에서 계획·분포·전후 30회를 증거로 남기는 orchestrator
scripts/run-w13-perf.ps1
PowerShell 정본 · 정본 · W13-F06STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
같은 synthetic fixture와 top-N SQL을 phase별로 실행하고, 계획·count·30회 전후 시간·cleanup 상태를 fail-closed evidence 파일로 남긴다.
- Prepare와 네 db phase, D7 Report는 각각 무엇을 읽고 쓸까?
- IsPathRooted가 true면 입력이 fully-qualified absolute path일까?
- Quantile(.5)와 Quantile(.95)는 30개 중 정확히 몇 번째 값을 고를까?
- Benchmark의 AND 조건은 어떤 경우를 통과시킬까?
- try/finally와 owned flag는 언제 compose down -v를 실행할까?
strict phases=Prepare/BeforePlan/AfterPlan/Selectivity/Benchmarkexcluded shared branch=Report (D7)samples=30 before then 30 afternearest-rank .5=index14/15th; .95=index28/29thBenchmark fails only when am>=bm AND ap>=bptarget index=idx_ledger_account_created_idSourceHashes=records 5 current digests; expected comparisons=0; compose excludedcleanup=compose down -v when ownedSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 일회용 PostgreSQL 무대를 매 phase 새로 만들고 같은 top-N 곡을 인덱스 전후로 비교한다.
같은 무대를 전후로 재는 증거 진행표
Prepare는 포장과 Docker·compose 설정을 검사하고 다섯 파일의 현재 hash를 기록한다. expected digest 목록과 비교하지 않으므로 변경 bytes를 hash만으로 거절하지는 않는다.
가운데 값은 30개의 두 중앙 평균이 아니라 15번째 nearest-rank다. Benchmark는 median과 p95가 둘 다 개선되지 않을 때만 실패하므로, 통과해도 두 metric과 합성 lab 한계를 함께 읽어야 한다.
딱 여기까지만 공연 리허설 비유는 phase 순서와 evidence 이동을 설명할 뿐 warm-up·randomization·운영 latency·통계적 유의성을 추가하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
phase 하나씩
한 invocation은 Prepare 또는 DB phase 하나만 실행하고 자기 evidence를 쓴다.
- 코드 연결
2줄·54~70줄·93~148줄- 비유
- 하루에 결과표 한 종류만 만드는 진행표
- 비유의 끝
- Report는 같은 source지만 D7로 strict 범위 밖이다.
rooted와 full path
IsPathRooted로 root 없는 값을 거른 뒤 GetFullPath가 accepted input을 전체 경로로 해석한다.
- 코드 연결
10~13줄- 비유
- root 표식을 본 뒤 정식 주소로 펼치기
- 비유의 끝
- rooted가 fully-qualified를 뜻하지 않고 C:Documents 같은 값은 현재 drive 문맥에 의존할 수 있다.
30개 순위
before 30회 뒤 index를 만들고 after 30회를 재며 .5는 15번째, .95는 29번째 값을 고른다.
- 코드 연결
28~43줄·129~140줄- 비유
- 서른 초시계 표를 작은 순서로 세워 15·29번 뽑기
- 비유의 끝
- warm-up이나 교차 순서를 구현하지 않는다.
fail-closed 정리
native exit와 evidence sentinel을 검사하고 owned Compose는 성공·실패 모두 down -v한다.
- 코드 연결
95~148줄- 비유
- 장비를 빌렸다면 결과와 무관하게 반납하기
- 비유의 끝
- cleanup 오류가 원래 오류를 가릴 수 있고 Container mode는 소유하지 않는다.
Report 읽기 전용
D7 Report는 다섯 saved JSON을 읽어 perf-manifest를 만들 뿐 query를 다시 돌리지 않는다.
- 코드 연결
72~91줄- 비유
- 지난 결과표 다섯 장을 요약표에 옮기기
- 비유의 끝
- predecessor 파일의 현재 진실성을 다시 측정하지 않는다.
nearest-rank히토리 → 니지카 → 료 → 키타
-
히토리
30개면 median은 당연히 두 가운데 평균 아닌가?
-
니지카
이 함수는 Ceiling(30×.5)-1이라 index 14, 즉 15번째 하나를 반환한다.
-
료
함수 이름보다 실제 배열 식을 믿어.
-
키타
1..30 반례에서 15라고 계산하겠습니다.
Benchmark AND히토리 → 니지카 → 료 → 키타
-
히토리
통과하면 두 지표가 모두 빨라진 거지?
-
니지카
아니다. 둘 다 개선되지 않았을 때만 throw하므로 하나만 좋아져도 통과한다.
-
료
median과 p95 네 값을 보고서에 함께 남겨야 해.
-
키타
pass를 두 지표 개선으로 과장하지 않을게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 143줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F06-L01 | param( |
니지카가 W13 성능 리허설 접수대를 열어 이번 실행에 필요한 신청 칸을 펼친다. | param(이 Phase·Mode·Container·ComposeProject·EvidenceDir를 script 입력으로 선언하는 범위를 시작한다.
|
| 2줄F06-L02 | [ |
진행표에는 준비·전 계획·후 계획·분포·측정·보고 여섯 무대명만 적을 수 있고 빈칸은 허용하지 않는다. | Mandatory string $Phase에 Prepare, BeforePlan, AfterPlan, Selectivity, Benchmark, Report ValidateSet을 적용한다.
|
| 3줄F06-L03 | [ |
리허설 장비를 새로 빌리거나 이미 있는 장비를 쓰는 두 방식 중, 표시가 없으면 새 대여 방식을 고른다. | $Mode를 Compose·Container 두 값으로 제한하고 caller가 생략하면 Compose 문자열을 넣는다.
|
| 4줄F06-L04 | [ |
이미 마련된 DB 장비표를 꽂을 칸을 만들되 새 Compose 장비를 쓸 때는 비워 둔다. | 선택 parameter $Container의 기본값을 빈 문자열로 선언한다.
|
| 5줄F06-L05 | [ |
일회용 장비 세트에 다른 공연과 겹치지 않도록 w13-perf-lab이라는 대여표를 붙인다. | $ComposeProject를 선택 문자열로 선언해 compose -p 인수의 기본 project name을 고정한다.
|
| 6줄F06-L06 | [ |
각 리허설 결과를 학생이 소유한 증거함에 넣기 위해 비울 수 없는 주소 칸을 만든다. | Mandatory $EvidenceDir가 phase JSON·plan·CSV가 저장될 caller-supplied 경로를 받도록 선언된다.
|
| 7줄F06-L07 | ) |
다섯 입력 칸의 테두리를 닫아 접수표 한 장을 완성한다. | 닫는 괄호가 param 블록을 끝내고 실행 본문으로 제어를 넘긴다.
|
| 8줄F06-L08 | $ErrorActionPreference= |
작은 실수도 조용히 넘기지 않고 리허설을 멈추게 하는 빨간 중단 스위치를 켠다. | $ErrorActionPreference='Stop'이 비종료 PowerShell error도 catch/finally로 흐를 수 있는 terminating error로 승격한다.
|
| 9줄F06-L09 | $root= |
scripts 방에서 한 층 올라가 compose와 sql이 함께 있는 기준 무대 바닥을 찾는다. | Split-Path -Parent가 $PSScriptRoot의 부모 디렉터리를 $root에 저장한다.
|
| 10줄F06-L10 | if( |
주소에 drive나 root 표시가 전혀 없으면 접수를 거절하되, 표시가 있다고 완전한 주소라고 단정하지 않는다. | IsPathRooted 결과를 부정해 root 없는 경로인 경우에만 if 본문으로 진입한다.
|
| 11줄F06-L11 | throw 'EvidenceDir must be an absolute learner-owned path' |
root 표시가 없는 주소에는 ‘전체 주소가 필요하다’는 안내문을 보여 주지만 안내문 자체가 검사 강도를 높이지는 않는다. | throw가 EvidenceDir must be an absolute learner-owned path 문자열을 내지만 실제 선행 predicate는 IsPathRooted 하나다.
|
| 12줄F06-L12 | } |
root 표시가 전혀 없는 주소를 막는 검사문을 접고 통과값을 다음 해석 창구로 보낸다. | 닫는 중괄호가 root 없는 경로 오류 분기의 lexical scope를 끝낸다.
|
| 13줄F06-L13 | $e= |
rooted이지만 덜 완전할 수 있는 주소를 현재 drive·directory 문맥으로 해석해 정식 전체 주소표로 펴 쓴다. | IO.Path.GetFullPath가 rooted $EvidenceDir의 점·부모 구간을 해석한 문자열을 $e에 저장한다.
|
| 14줄F06-L14 | New-Item -ItemType Directory -Force $e|Out-Null |
정식 주소에 증거함이 없으면 만들되 ‘만들었다’는 안내 전표는 조용히 치운다. | New-Item -Directory -Force 결과 객체를 pipeline으로 Out-Null에 넘겨 $e 디렉터리를 보장한다.
|
| 15줄F06-L15 | $compose= |
기준 무대 바닥에서 일회용 DB 장비 설명서의 고정 자리를 표시한다. | Join-Path가 $root와 compose.yaml을 결합해 $compose에 저장한다.
|
| 16줄F06-L16 | $owned= |
아직 빌린 장비가 없으니 ‘내가 반납해야 함’ 표찰을 false로 놓는다. | $owned=$false가 finally에서 compose down을 실행할지 결정하는 ownership flag의 초기 상태를 만든다.
|
| 17줄F06-L17 | $dbPhases= |
실제 장비를 켜야 하는 전 계획·후 계획·분포·측정 무대 네 장의 표를 따로 모은다. | $dbPhases가 BeforePlan, AfterPlan, Selectivity, Benchmark 문자열 배열을 가진다.
|
| 19줄F06-L19 | function Write-JsonAtomic( |
니지카가 완성 전 봉투는 임시 칸에 쓰고 마지막에만 증거함 이름으로 바꾸는 포장대를 연다. | function 선언이 $Name과 $Value를 받는 JSON evidence writer의 body 범위를 시작한다.
|
| 20줄F06-L20 | $target= |
증거함 안 최종 칸과 바로 옆 임시 작성 칸을 한 번에 표시한다. | Join-Path로 $e/$Name을 $target에 만들고 문자열 보간으로 $target.tmp를 $temporary에 둔다.
|
| 21줄F06-L21 | $Value|ConvertTo-Json -Depth 6|Set-Content -Encoding utf8 $temporary |
확인표 값을 여섯 겹 안쪽까지 JSON으로 접어 임시 봉투에 먼저 적는다. | pipeline이 $Value를 ConvertTo-Json -Depth 6에 보내고 그 문자열을 Set-Content로 $temporary에 기록한다.
|
| 22줄F06-L22 | Move-Item -LiteralPath $temporary -Destination $target -Force |
봉투를 다 쓴 뒤 임시 꼬리표를 떼고 최종 증거 이름 칸에 한 번에 옮긴다. | Move-Item -LiteralPath가 $temporary를 $target로 rename하며 기존 target이 있으면 -Force로 교체한다.
|
| 23줄F06-L23 | } |
임시 작성과 최종 교체를 묶은 포장대의 셔터를 내린다. | 닫는 중괄호가 JSON writer 정의를 완성한다.
|
| 24줄F06-L24 | function PsqlFile( |
료가 기준 악보함의 SQL 한 장을 DB 장비에 그대로 전달하는 연주 통로를 만든다. | function PsqlFile이 $Relative 문자열을 입력으로 받는 native psql 실행 범위를 시작한다.
|
| 25줄F06-L25 | Get-Content -Raw -LiteralPath ( |
악보 한 장의 전체 내용을 읽어 app 연주자가 쓰는 financial_core 무대로 끊김 없이 전달한다. | Get-Content -Raw 출력이 pipeline을 통해 docker exec -i의 psql -X -v ON_ERROR_STOP=1 -U app -d financial_core 입력이 된다.
|
| 26줄F06-L26 | if( |
DB 연주가 실패 표를 돌려주면 어떤 악보였는지와 번호를 읽고 즉시 리허설을 멈춘다. | $LASTEXITCODE -ne 0 guard가 native psql 실패를 PowerShell terminating error로 바꾼다.
|
| 27줄F06-L27 | } |
SQL 전달과 종료표 확인을 묶은 연주 통로를 닫는다. | 닫는 중괄호가 PsqlFile 정의의 범위를 끝낸다.
|
| 28줄F06-L28 | function MeasurePlan( |
히토리가 전·후 이름표와 기록 파일을 받아 같은 곡을 서른 번 재는 초시계 책상을 편다. | function MeasurePlan이 $Variant와 $Output을 입력으로 하는 sampling 범위를 시작한다.
|
| 29줄F06-L29 | $rows= |
서른 번의 초시계 기록을 넣을 빈 표 묶음을 책상 위에 놓는다. | $rows=@()가 현재 MeasurePlan 호출만을 위한 빈 PowerShell array를 초기화한다.
|
| 30줄F06-L30 | for( |
초시계 기록지의 1번 칸부터 30번 칸까지 차례대로 돌겠다는 반복선을 긋는다. | for가 $sample=1에서 시작해 $sample -le 30인 동안 body를 실행하고 매회 증가시킨다.
|
| 31줄F06-L31 | $json= |
같은 top-N 악보를 DB에 연주시키고 EXPLAIN 시계표 전체를 한 장의 JSON으로 받아 든다. | Get-Content -Raw 출력이 pipeline으로 docker exec -i psql -X -qAt에 들어가고 Execution Time을 품은 stdout JSON이 $json에 대입된다.
|
| 32줄F06-L32 | if( |
서른 칸 중 어느 연주가 실패했는지 전·후 이름과 칸 번호, 종료표 번호를 함께 적어 중단한다. | $LASTEXITCODE guard가 직전 docker/psql 실패를 measure failed terminating error로 승격한다.
|
| 33줄F06-L33 | $object= |
DB가 돌려준 시계표 봉투를 열어 안쪽 칸을 이름으로 꺼낼 수 있는 표로 바꾼다. | $json pipeline이 ConvertFrom-Json으로 전달되어 parsed result가 $object에 저장된다.
|
| 34줄F06-L34 | $rows+ |
이번 초시계 표에 전·후 이름표, 몇 번째인지, 걸린 밀리초 세 칸을 적어 기록 묶음 뒤에 붙인다. | pscustomobject가 $Variant, $sample, $object[0].'Execution Time'을 double로 저장되고 +=로 $rows에 append된다.
|
| 35줄F06-L35 | } |
현재 초시계 칸을 마치고 다음 번호로 이동하거나 30번 뒤 반복선을 끝낸다. | 닫는 중괄호가 for body를 종료해 증분·조건 재평가로 제어를 돌린다.
|
| 36줄F06-L36 | $rows|Export-Csv -NoTypeInformation -Encoding utf8 -LiteralPath $Output |
서른 장의 초시계 표를 전·후·번호·밀리초 열을 가진 한 파일로 묶어 증거함에 넣는다. | $rows pipeline이 Export-Csv -NoTypeInformation -Encoding utf8 -LiteralPath $Output으로 전달된다.
|
| 37줄F06-L37 | return $rows |
파일에도 넣은 서른 장 표 묶음을 다음 계산대가 바로 쓰도록 손으로 다시 건넨다. | return $rows가 MeasurePlan pipeline output으로 측정 객체 배열을 보낸다.
|
| 38줄F06-L38 | } |
서른 번 측정·파일 기록·표 반환을 묶은 초시계 책상을 접는다. | 닫는 중괄호가 MeasurePlan 정의를 완성한다.
|
| 39줄F06-L39 | function Quantile( |
료가 서른 초시계 기록과 0.5 또는 0.95 위치표를 받아 순위를 고르는 계산판을 편다. | function Quantile이 $Rows와 double $P를 parameter로 하는 nearest-rank 계산 범위를 시작한다.
|
| 40줄F06-L40 | $values= |
서른 초시계 숫자를 작은 것부터 큰 것까지 한 줄로 다시 세운다. | $Rows.ms가 Sort-Object pipeline을 지나고 @()가 1개여도 array인 $values를 만든다.
|
| 41줄F06-L41 | if( |
순위표가 서른 칸이 아니면 몇 칸뿐인지 말하고 계산판을 닫는다. | $values.Count -ne 30 guard가 quantile requires 30 values 예외를 발생시킨다.
|
| 42줄F06-L42 | return $values[ |
위치표 0.5면 서른 기록 중 15번째, 0.95면 29번째 표를 집어 caller에게 건넨다. | 0-based index Min(29, Ceiling(30*$P)-1)로 nearest-rank 값을 골라 return한다.
|
| 43줄F06-L43 | } |
순위를 한 값으로 고르는 계산판을 접는다. | 닫는 중괄호가 Quantile 정의의 범위를 끝낸다.
|
| 44줄F06-L44 | function FindIndex( |
키타가 실행 계획 나무의 가지를 내려가며 첫 인덱스 명찰을 찾는 탐색대를 연다. | function FindIndex가 하나의 JSON plan $Node를 입력으로 받는 depth-first 탐색 범위를 시작한다.
|
| 45줄F06-L45 | if( |
현재 계획 상자에 인덱스 명찰이 붙어 있으면 이름과 연주 방식 두 칸만 베껴 탐색을 끝낸다. | truthy $Node.'Index Name' guard가 pscustomobject{name,type}을 만들어 early return한다.
|
| 46줄F06-L46 | foreach( |
명찰이 없으면 자식 상자를 차례로 열고, 어느 가지에서 찾자마자 나머지는 보지 않고 들고 나온다. | @($Node.Plans) foreach가 각 child에 FindIndex를 재귀 호출하고 truthy $found에서 early return한다.
|
| 47줄F06-L47 | return $null |
모든 자식 상자를 열어도 명찰이 없으면 빈손이라는 표를 위 탐색자에게 보낸다. | return $null이 current node와 descendants에서 Index Name을 찾지 못한 결과를 표시한다.
|
| 48줄F06-L48 | } |
계획 나무에서 첫 인덱스 명찰을 찾는 탐색대를 닫는다. | 닫는 중괄호가 recursive FindIndex 정의를 완성한다.
|
| 49줄F06-L49 | function SourceHashes( |
증거에 붙일 악보 지문 다섯 개를 모으는 확인대를 편다. | function SourceHashes가 고정 file list를 SHA-256 객체 배열로 바꾸는 범위를 시작한다.
|
| 50줄F06-L50 | $files= |
실행 대본 하나와 seed·measure·index·assert 악보 네 장만 지문 목록에 올린다. | $files가 scripts/run-w13-perf.ps1 및 sql/w13의 seed, measure, create-index, assert 경로를 순서대로 가진다.
|
| 51줄F06-L51 | return @( |
지문 목록의 각 악보를 한 장씩 스캔해 경로와 소문자 지문을 짝지은 표로 돌려준다. | $files pipeline의 ForEach-Object가 Get-FileHash SHA256을 실행하고 @()가 pscustomobject 배열을 보장한다.
|
| 52줄F06-L52 | } |
다섯 악보 지문 확인대를 접는다. | 닫는 중괄호가 SourceHashes 정의를 완성한다.
|
| 54줄F06-L54 | if( |
장비를 켜지 않고 포장 상태만 검사하는 첫날 리허설이면 별도 점검표를 펼친다. | $Phase -eq 'Prepare' 조건이 true인 호출만 55~69줄의 config-only 준비 절차로 보낸다.
|
| 55줄F06-L55 | if( |
장비 설명서가 정해진 서랍에 실제 종이로 없으면 대여 절차를 시작하기 전에 중단한다. | Test-Path -LiteralPath $compose -PathType Leaf를 부정해 missing compose dependency를 throw한다.
|
| 56줄F06-L56 | $futureFiles= |
아직 시작하지 않은 뒤 무대의 결과표 열 장 이름을 ‘미리 있으면 안 됨’ 목록에 적는다. | $futureFiles가 before/after plan·day JSON, selectivity, benchmark CSV/manifest/plan, perf-manifest 이름을 가진다.
|
| 57줄F06-L57 | $stale= |
미래 결과표 목록을 증거함 칸과 대조해 이미 꽂힌 종이만 따로 꺼낸다. | $futureFiles pipeline의 Where-Object가 Join-Path $e 경로에서 Leaf 존재를 확인하고 @()가 $stale 배열을 만든다.
|
| 58줄F06-L58 | if( |
뒤 무대 결과표가 한 장이라도 미리 꽂혀 있으면 그 이름을 모두 읽고 새 리허설과 섞이지 않게 멈춘다. | $stale.Count -ne 0 guard가 D2-D6 artifact exists 메시지에 -join ',' 목록을 넣어 throw한다.
|
| 59줄F06-L59 | if( |
설명서를 해석할 때 빈 비밀번호 칸 때문에 멈추지 않도록 실제 접속용이 아닌 점검 전용 표식을 채운다. | IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)일 때 w13-prepare-config-only를 현재 process environment에 대입한다.
|
| 60줄F06-L60 | $docker= |
장비 대여소에 서버가 살아 있는지 묻고 버전 번호 한 줄을 받아 적는다. | docker version --format '{{.Server.Version}}' stdout을 $docker에 저장한다.
|
| 61줄F06-L61 | if( |
대여소 응답 번호가 실패이거나 버전표가 비어 있으면 장비가 없다고 판정한다. | $LASTEXITCODE -ne 0 OR IsNullOrWhiteSpace($docker) 조건이 true면 terminating error를 낸다.
|
| 62줄F06-L62 | $services= |
장비 설명서를 실제로 펼쳐 무대 장비 이름 목록만 뽑아 한 칸짜리 표인지 본다. | docker compose -f $compose config --services의 각 output line을 @()로 감싸 $services 배열에 저장한다.
|
| 63줄F06-L63 | if( |
장비 목록이 정확히 한 줄 ‘db’가 아니면 다른 장비가 섞인 포장으로 보고 거절한다. | exit nonzero OR Count!=1 OR services[0] -cne 'db'이면 compose service contract mismatch를 throw한다.
|
| 64줄F06-L64 | $futureCount= |
설명서 점검만 했는데 뒤 무대 결과표가 생기지 않았는지 증거함을 한 번 더 센다. | $futureFiles를 다시 Where-Object로 필터하고 array Count를 $futureCount에 저장한다.
|
| 65줄F06-L65 | if( |
포장 점검만 했는데 결과표가 생겼다면 개수를 적고 config-only 약속 위반으로 중단한다. | $futureCount -ne 0 guard가 Prepare created a future W13 artifact 예외를 낸다.
|
| 66줄F06-L66 | $body= |
점검표에 준비 단계, 장비 버전, db 한 대, 악보 지문 다섯, 정상 종료, 미래표 0장을 정해진 순서로 적는다. | ordered hashtable이 SourceHashes를 호출해 실행 시점의 다섯 current SHA-256을 prepare.json field로 기록할 객체를 만든다.
|
| 67줄F06-L67 | Write-JsonAtomic 'prepare. |
완성한 준비 점검표를 임시 봉투에 쓴 뒤 prepare.json 이름으로 증거함에 넣는다. | Write-JsonAtomic이 $body를 $e/prepare.json에 기록한다.
|
| 68줄F06-L68 | 'W13_PREPARE_GREEN compose= |
키타가 db 하나·지문 다섯·미래표 0·종료 0을 초록 안내로 읽는다. | literal string W13_PREPARE_GREEN compose=db hashes=5 future_artifacts=0 native_exit=0이 success stream에 나온다.
|
| 69줄F06-L69 | exit 0 |
준비 무대만 맡았으니 초록표를 낸 뒤 다른 phase로 내려가지 않고 공연장을 정상 퇴장한다. | exit 0이 현재 PowerShell host의 남은 script 문장을 실행하지 않고 성공 code를 반환한다.
|
| 70줄F06-L70 | } |
준비 전용 점검표의 테두리를 닫아 다음 phase 분기와 구분한다. | 닫는 중괄호가 if($Phase-eq'Prepare') lexical scope를 끝낸다.
|
| 72줄F06-L72 | if( |
이미 끝난 다섯 무대 결과표만 모아 요약하는 일요일 보고대라면 별도 서랍을 연다. | $Phase -eq 'Report' 조건이 true인 호출을 73~90줄의 saved-evidence aggregation으로 보낸다.
|
| 73줄F06-L73 | $prepare= |
보고대가 준비 무대 점검표 봉투를 열어 필드로 읽을 수 있게 편다. | Get-Content -Raw와 ConvertFrom-Json pipeline이 $e/prepare.json을 $prepare 객체로 만든다.
|
| 74줄F06-L74 | $before= |
인덱스 전 계획 무대의 종료·정리표를 보고대 위에 펼친다. | Raw file text가 ConvertFrom-Json을 거쳐 $before evidence object가 된다.
|
| 75줄F06-L75 | $after= |
인덱스 후 계획 무대에서 남긴 정리표 봉투를 열어 보고대에 놓는다. | Get-Content→ConvertFrom-Json pipeline이 after-day.json을 $after로 복원한다.
|
| 76줄F06-L76 | $selectivity= |
계좌·거래·원장·hot 분포 숫자가 든 봉투를 다시 열어 요약 표의 재료로 둔다. | selectivity-day.json raw JSON이 parsed $selectivity object로 변환된다.
|
| 77줄F06-L77 | $benchmark= |
전·후 서른 번 결과와 선택 인덱스가 적힌 측정 봉투를 보고대에 펼친다. | benchmark-manifest.json이 ConvertFrom-Json으로 $benchmark 객체가 된다.
|
| 78줄F06-L78 | if( |
다섯 무대 표 가운데 미리 만든 결과가 있거나 장비 반납 도장이 빠진 표가 하나라도 있으면 보고서를 거절한다. | OR로 연결된 saved fields가 expected sentinel과 다르면 predecessor phase/cleanup contract invalid를 throw한다.
|
| 79줄F06-L79 | if( |
측정표에 전 30장·후 30장·세 겹 찾아보기 명찰이 정확히 적혀야 보고 요약을 허용한다. | before_rows/after_rows -ne30 또는 chosen_index -cne target name이면 benchmark contract invalid를 throw한다.
|
| 80줄F06-L80 | $body= |
검증된 다섯 봉투를 한 장의 최종 요약표에 정해진 순서로 옮길 큰 틀을 편다. | $body=[ordered]@{가 perf-manifest 필드 순서를 보존하는 ordered hashtable literal을 연다.
|
| 81줄F06-L81 | schema= |
요약표 첫 줄에 실험 무대 이름, top-N 표찰, 네 장부 숫자를 그대로 옮겨 적는다. | 고정 schema/query_kind와 $selectivity의 네 count를 ordered body fields에 할당한다.
|
| 82줄F06-L82 | before_rows= |
전·후 초시계 표가 각각 몇 장인지 최종 요약표의 두 칸에 베낀다. | $benchmark.before_rows와 after_rows가 같은 이름의 $body fields에 저장된다.
|
| 83줄F06-L83 | before_median_ms= |
두 무대의 가운데 순위와 95% 순위 초시계 숫자를 네 칸에 나란히 적는다. | $benchmark의 before_median_ms, before_p95_ms, after_median_ms, after_p95_ms를 $body에 할당한다.
|
| 84줄F06-L84 | chosen_index= |
측정 무대가 찾은 찾아보기 명찰과 연주 방식 표찰을 최종표에 옮긴다. | $benchmark.chosen_index와 plan_node가 ordered body의 같은 필드가 된다.
|
| 85줄F06-L85 | verdict= |
최종표에 ‘이 리허설에서만 상대 개선, 절대 속도 약속 없음’을 크게 적고 정상·반납 도장과 다섯 지문을 붙인다. | 고정 verdict, native_exit=0, cleanup=1, @($prepare.hashes)가 report body에 추가된다.
|
| 86줄F06-L86 | phase_evidence= |
요약표 맨 아래에 참고한 다섯 봉투의 이름을 목차처럼 붙인다. | phase_evidence가 prepare, before-day, after-day, selectivity-day, benchmark-manifest 이름 배열을 가진다.
|
| 87줄F06-L87 | } |
최종 요약표의 모든 칸을 채운 뒤 테두리를 닫는다. | 닫는 중괄호가 80줄에서 연 ordered hashtable을 완성한다.
|
| 88줄F06-L88 | Write-JsonAtomic 'perf-manifest. |
완성한 최종 요약표를 임시 봉투를 거쳐 perf-manifest.json 칸에 넣는다. | Write-JsonAtomic이 $body를 $e/perf-manifest.json으로 교체 기록한다.
|
| 89줄F06-L89 | "W13_REPORT_GREEN phases= |
키타가 다섯 증거 봉투와 전후 30장, 선택 명찰, 지문·반납 도장을 한 줄로 발표한다. | double-quoted string이 $body.chosen_index를 보간해 W13_REPORT_GREEN marker를 success stream에 보낸다.
|
| 90줄F06-L90 | exit 0 |
일요일 요약만 마쳤으므로 DB 장비 무대로 내려가지 않고 정상 퇴장한다. | exit 0이 93줄 이후 db-phase lifecycle 실행을 차단한다.
|
| 91줄F06-L91 | } |
저장된 결과만 읽는 보고대의 테두리를 닫는다. | 닫는 중괄호가 if($Phase-eq'Report') lexical scope를 끝낸다.
|
| 93줄F06-L93 | if( |
준비도 보고도 아닌 표찰이 실제 장비 무대 네 장에도 없으면 알 수 없는 순서표로 거절한다. | $Phase -notin $dbPhases guard가 unsupported W13 phase 메시지를 throw한다.
|
| 94줄F06-L94 | $body= |
이번 장비 무대가 결과표를 실제로 채웠는지 마지막에 확인하도록 빈 표찰로 시작한다. | $body=$null이 db phase 분기 이전 sentinel 상태를 만든다.
|
| 95줄F06-L95 | try{ |
장비를 켜고 SQL을 연주하는 모든 행동을, 성공·실패 뒤 반드시 반납대로 가는 큰 상자 안에 넣는다. | try 블록이 96~141줄의 startup·seed·phase logic을 142줄 finally와 연결한다.
|
| 96줄F06-L96 | if( |
새 장비 대여 방식을 골랐으면 전용 project로 DB를 켜는 절차를 펼친다. | $Mode -eq 'Compose' 조건이 true인 경우에만 97~104줄을 실행한다.
|
| 97줄F06-L97 | if( |
새 장비를 빌리면서 기존 장비표도 내면 어느 것을 쓸지 모호하므로 접수를 거절한다. | truthy $Container guard가 Compose mode rejects -Container terminating error를 낸다.
|
| 98줄F06-L98 | if( |
새로 빌릴 DB 장비의 빈 비밀번호 칸에 이 리허설 전용 w13-disposable-password를 채운다. | blank FCL_DB_PASSWORD에 w13-disposable-password를 대입해 compose startup interpolation을 준비한다.
|
| 99줄F06-L99 | # The unique Compose project is ours before startup; |
주석표가 ‘이 이름의 대여 장비는 켜기 전부터 우리 반납 책임’이라고 다음 줄의 이유를 알려 준다. | 첫 comment line이 partial compose up 실패도 finally 정리 대상으로 삼는 ownership contract를 문서화한다.
|
| 100줄F06-L100 | # failure must still be cleaned by finally. |
장비가 반쯤만 켜져도 반납대를 반드시 거친다는 안내문 둘째 줄이다. | second comment line이 99줄의 설명을 이어 partial resource cleanup 의도를 명시한다.
|
| 101줄F06-L101 | $owned= |
장비가 아직 완전히 켜지지 않았어도 이 project 반납표를 먼저 자신의 이름으로 돌린다. | $owned=$true가 이후 어떤 terminating error에서도 143줄 compose down 조건을 활성화한다.
|
| 102줄F06-L102 | & docker compose -f $compose -p $ComposeProject up -d --wait db |
전용 이름으로 DB 장비를 켠 뒤 준비등이 켜질 때까지 대기한다. | docker compose -f $compose -p $ComposeProject up -d --wait db를 native command로 실행한다.
|
| 103줄F06-L103 | if( |
장비 준비등이 실패 번호를 내면 어느 무대였는지 적어 중단하고 반납대로 이동한다. | $LASTEXITCODE guard가 compose up failed terminating error를 발생시킨다.
|
| 104줄F06-L104 | $Container= |
준비된 DB 장비의 실제 표찰 번호를 찾아 양끝 빈칸 없이 작업표에 적는다. | docker compose ps -q db stdout에 Trim()을 적용해 $Container에 대입한다.
|
| 105줄F06-L105 | }elseif( |
기존 장비 사용 방식에서 실제 장비표를 내지 않으면 무엇에 연주할지 몰라 거절한다. | Compose가 아닌 분기의 IsNullOrWhiteSpace($Container) guard가 Container mode requires -Container를 throw한다.
|
| 106줄F06-L106 | if( |
새 장비든 기존 장비든 최종 표찰 번호가 빈칸이면 SQL 악보를 보내기 전에 멈춘다. | 공통 IsNullOrWhiteSpace guard가 PostgreSQL container resolution failed 예외를 낸다.
|
| 107줄F06-L107 | PsqlFile 'sql/ |
매 무대마다 같은 10만 행 장부를 새로 만들고 연주 통로의 일반 출력표는 치운다. | PsqlFile 'sql/w13/seed.sql' 호출 결과를 Out-Null로 보내 deterministic perf_w13 fixture를 재생성한다.
|
| 109줄F06-L109 | if( |
새 찾아보기를 붙이기 전, 기본 장부에서 top-N 악보가 어떻게 연주되는지 기록하는 무대를 연다. | $Phase -eq 'BeforePlan' 조건이 110~114줄을 첫 db phase branch로 선택한다.
|
| 110줄F06-L110 | $path= |
인덱스 전 실행 계획표를 증거함의 before-plan.txt 칸에 둘 주소를 적는다. | Join-Path $e 'before-plan.txt' 결과를 $path에 저장한다.
|
| 111줄F06-L111 | Get-Content -Raw ( |
top-N 악보 전체를 DB 장비에 건네고 돌아온 실행 계획 한 장을 인덱스 전 증거 칸에 바로 적는다. | Get-Content -Raw | docker exec -i ... psql -qAt | Set-Content pipeline이 query 결과를 $path에 저장한다.
|
| 112줄F06-L112 | if( |
계획표를 받는 연주가 실패 번호를 남기면 인덱스 전 무대를 중단한다. | $LASTEXITCODE -ne 0 guard가 before plan query failed를 throw한다.
|
| 113줄F06-L113 | $plan= |
인덱스 전 계획 봉투를 다시 열어 첫 실행 계획 나무만 책상 위에 놓는다. | Get-Content -Raw→ConvertFrom-Json 결과의 [0].Plan을 $plan에 대입한다.
|
| 114줄F06-L114 | $body= |
계획 나무 맨 위 연주 방식, 실제 나온 행, 원문 지문, 정상·반납 약속을 결과표에 적는다. | $plan 속성과 $path SHA-256으로 BeforePlan evidence object를 구성한다.
|
| 115줄F06-L115 | }elseif( |
앞 무대가 아니고 찾아보기를 붙인 뒤 계획을 볼 차례면 다음 검수대를 펼친다. | elseif가 $Phase -eq 'AfterPlan'인 경우 116~123줄을 선택한다.
|
| 116줄F06-L116 | PsqlFile 'sql/ |
계좌·최신 시각·큰 번호 순서의 세 겹 찾아보기를 장부에 붙인다. | PsqlFile이 sql/w13/create-index.sql을 실행하고 pipeline 결과를 Out-Null로 보낸다.
|
| 117줄F06-L117 | $path= |
찾아보기를 붙인 뒤 실행 계획표를 after-plan.txt 칸에 둘 주소를 정한다. | Join-Path가 $e와 after-plan.txt를 결합해 $path에 대입한다.
|
| 118줄F06-L118 | Get-Content -Raw ( |
인덱스 전과 같은 top-N 악보를 다시 연주하고 새 계획 나무를 후 증거 칸에 기록한다. | Get-Content→docker exec psql→Set-Content pipeline이 query stdout을 $path에 저장한다.
|
| 119줄F06-L119 | if( |
찾아보기 후 계획 연주가 실패 번호를 돌려주면 검수 전 즉시 멈춘다. | $LASTEXITCODE guard가 after plan query failed terminating error를 발생시킨다.
|
| 120줄F06-L120 | PsqlFile 'sql/ |
계획을 읽기 전 네 장부 숫자·잔액 합계·찾아보기 명찰이 그대로인지 별도 검수표로 확인한다. | PsqlFile sql/w13/assert.sql 실행이 SQL gate를 통과해야 하고 출력은 Out-Null로 버린다.
|
| 121줄F06-L121 | $plan= |
후 계획 봉투를 열어 나무를 펼친 뒤 첫 찾아보기 명찰을 가지 아래에서 찾는다. | 한 줄에서 $plan=(...)[0].Plan을 만든 뒤 FindIndex $plan 결과를 $chosen에 대입한다.
|
| 122줄F06-L122 | if( |
찾은 명찰이 없거나 이름이 다르거나 연주 방식이 Index Scan 계열이 아니면 실제 두 표찰을 읽고 중단한다. | !$chosen OR name -cne target OR type -notin Index Scan/Index Only Scan 조건이 after plan mismatch를 throw한다.
|
| 123줄F06-L123 | $body= |
통과한 찾아보기 이름과 연주 방식, 실제 행 수, 계획표 지문, 정상·반납 도장을 결과표에 적는다. | $chosen, $plan, $path hash로 AfterPlan evidence object를 구성한다.
|
| 124줄F06-L124 | }elseif( |
앞 두 계획 무대가 아니고 hot 계좌 몰림을 셀 차례면 네 숫자 검수대를 펼친다. | elseif가 $Phase -eq 'Selectivity'인 경우 125~128줄을 선택한다.
|
| 125줄F06-L125 | $raw= |
계좌·거래·원장·계좌1 원장 네 장부 수를 한 번에 세어 세로 막대 사이 한 줄 숫자표로 받는다. | docker exec psql -qAt -F '|' -c SELECT의 stdout에 Trim()을 적용해 $raw에 저장한다.
|
| 126줄F06-L126 | if( |
네 숫자표 조회가 실패 번호를 내면 문자열 분해 전에 무대를 중단한다. | $LASTEXITCODE -ne 0 guard가 selectivity count query failed를 throw한다.
|
| 127줄F06-L127 | $values= |
세로 막대 숫자표를 네 칸으로 잘라 목표 숫자를 하나씩 대조하고 다르면 원문 표를 보여 준다. | $raw.Split('|') 결과 Count와 int 변환 네 값을 OR guard로 검사해 mismatch 시 throw한다.
|
| 128줄F06-L128 | $body= |
검증한 네 숫자와 9할 몰림 표찰, 정상·반납 도장을 결과표에 적는다. | ordered hashtable이 phase Selectivity와 exact constants를 evidence fields로 만든다.
|
| 129줄F06-L129 | }elseif( |
앞 세 무대가 아니고 찾아보기 전후 초시계를 비교할 차례면 측정대를 펼친다. | elseif가 $Phase -eq 'Benchmark'일 때 130~140줄을 선택한다.
|
| 130줄F06-L130 | $before= |
찾아보기 설치 전 같은 곡을 1번부터 30번까지 연속 재고 기록 묶음을 전 CSV 칸에 둔다. | MeasurePlan before 호출 결과가 $before에 대입되고 raw output은 $e/before-raw.csv로 지정된다.
|
| 131줄F06-L131 | PsqlFile 'sql/ |
전 기록 30장을 모두 적은 다음에야 세 겹 찾아보기를 장부에 붙인다. | PsqlFile create-index.sql이 target index를 생성·ANALYZE하고 output은 버린다.
|
| 132줄F06-L132 | $afterPath= |
측정 중 선택 인덱스를 확인할 별도 계획표를 benchmark-after-plan.txt 칸에 둘 주소를 정한다. | Join-Path $e 'benchmark-after-plan.txt' 결과를 $afterPath에 저장한다.
|
| 133줄F06-L133 | Get-Content -Raw ( |
후 초시계를 재기 전 같은 top-N 악보의 계획 나무를 한 번 꺼내 별도 증거표로 저장한다. | Get-Content | docker exec psql | Set-Content pipeline이 JSON result를 $afterPath에 기록한다.
|
| 134줄F06-L134 | if( |
후 계획표를 받는 연주가 실패하면 후 30회 측정을 시작하지 않고 반납대로 간다. | $LASTEXITCODE guard가 benchmark after plan failed terminating error를 발생시킨다.
|
| 135줄F06-L135 | $after= |
찾아보기 후 같은 곡을 30번 연속 재고 후 기록 묶음을 저장한 다음 장부 숫자와 명찰 검수를 한다. | MeasurePlan after 결과를 $after에 대입하고 세미콜론 뒤 PsqlFile assert.sql을 호출해 output을 버린다.
|
| 136줄F06-L136 | $plan= |
측정 전 보관한 계획 나무를 다시 펼쳐 첫 찾아보기 명찰을 재귀 탐색한다. | $afterPath JSON의 [0].Plan을 $plan에 넣고 FindIndex 결과를 $chosen에 저장한다.
|
| 137줄F06-L137 | $bm= |
전 기록에서 15번째·29번째, 후 기록에서도 15번째·29번째 초시계 값을 각각 뽑는다. | Quantile을 네 번 호출해 $bm, $bp, $am, $ap에 before median/p95와 after median/p95를 대입한다.
|
| 138줄F06-L138 | if( |
측정 전 계획표에서 세 겹 찾아보기 명찰을 못 찾거나 다른 이름이면 성능 숫자를 합격으로 쓰지 않는다. | !$chosen OR chosen.name -cne idx_ledger_account_created_id guard가 chosen index mismatch를 throw한다.
|
| 139줄F06-L139 | if( |
후 15번째와 29번째 초시계가 둘 다 전 기록보다 작아지지 않았을 때만 빨간 판정을 내린다. | $am -ge $bm AND $ap -ge $bp 조건이 true인 경우 네 metric을 포함한 no relative improvement 오류를 낸다.
|
| 140줄F06-L140 | $body= |
통과한 전후 표 수, 네 초시계 순위값, 찾아보기 이름·방식, 정상·반납 도장을 최종 측정표에 적는다. | $bm/$bp/$am/$ap와 $chosen으로 benchmark-manifest fields를 구성한다.
|
| 141줄F06-L141 | } |
네 실제 장비 무대 중 선택된 결과표 작성을 마치고 분기판을 접는다. | 닫는 중괄호가 마지막 elseif body를 종료해 try의 끝으로 제어를 보낸다.
|
| 142줄F06-L142 | }finally{ |
연주 결과와 상관없이 장비 반납대로 반드시 이어지는 마지막 문을 연다. | }finally{ 구문이 try completion 또는 terminating error를 받아 cleanup scope로 전환한다.
|
| 143줄F06-L143 | if( |
자신이 빌린 장비라면 DB와 저장 상자를 모두 반납하고, 반납표가 실패면 그 phase 이름으로 다시 중단한다. | $owned guard 안에서 docker compose down -v output을 버리고 직후 $LASTEXITCODE nonzero를 throw한다.
|
| 144줄F06-L144 | } |
장비 반납대를 닫고 성공 결과 또는 실패를 바깥 흐름으로 돌려보낸다. | 닫는 중괄호가 finally scope를 끝내며 pending exception이 있으면 전파한다.
|
| 145줄F06-L145 | if( |
장비를 반납한 뒤 결과표가 빈 종이이면 어떤 phase였는지 적어 초록표 생성을 막는다. | $null -eq $body guard가 W13 phase produced no evidence terminating error를 낸다.
|
| 146줄F06-L146 | $file= |
전 계획·후 계획·분포·측정 표찰을 각자 정해진 증거함 파일명에 연결한다. | hashtable literal을 $Phase로 index해 BeforePlan→before-day.json, AfterPlan→after-day.json, Selectivity→selectivity-day.json, Benchmark→benchmark-manifest.json을 $file에 저장한다.
|
| 147줄F06-L147 | Write-JsonAtomic $file $body |
완성된 결과표를 임시 봉투에 적고 현재 무대에 맞는 증거함 칸으로 옮긴다. | Write-JsonAtomic $file $body가 evidence object를 $e 아래 최종 이름으로 기록한다.
|
| 148줄F06-L148 | "W13_${ |
키타가 무대명·정상 종료·반납·결과표 파일명을 한 줄 초록 안내로 읽는다. | double-quoted W13_${Phase}_GREEN 문자열이 $Phase와 $file을 보간해 success stream에 보낸다.
|
측정 순서히토리 → 니지카 → 료 → 키타
-
히토리
서른 번이면 cache 영향이 자동으로 사라져?
-
니지카
before 30회를 전부 먼저 하고 after 30회를 나중에 한다. warm-up도 섞기도 없다.
-
료
표본 수와 실험 설계는 다른 문제야.
-
키타
순차 측정 한계를 결과 옆에 쓰겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 16개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
param(
[Parameter(Mandatory=$true)][ValidateSet('Prepare','BeforePlan','AfterPlan','Selectivity','Benchmark','Report')][string]$Phase,
[ValidateSet('Compose','Container')][string]$Mode='Compose',
[string]$Container='',
[string]$ComposeProject='w13-perf-lab',
[Parameter(Mandatory=$true)][string]$EvidenceDir
)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
if(-not [IO.Path]::IsPathRooted($EvidenceDir)){
throw 'EvidenceDir must be an absolute learner-owned path'
}
$e=[IO.Path]::GetFullPath($EvidenceDir)
New-Item -ItemType Directory -Force $e|Out-Null
$compose=Join-Path $root 'compose.yaml'
$owned=$false
$dbPhases=@('BeforePlan','AfterPlan','Selectivity','Benchmark')
function Write-JsonAtomic([string]$Name,$Value){
$target=Join-Path $e $Name;$temporary="$target.tmp"
$Value|ConvertTo-Json -Depth 6|Set-Content -Encoding utf8 $temporary
Move-Item -LiteralPath $temporary -Destination $target -Force
}
function PsqlFile([string]$Relative){
Get-Content -Raw -LiteralPath (Join-Path $root $Relative)|docker exec -i $Container psql -X -v ON_ERROR_STOP=1 -U app -d financial_core
if($LASTEXITCODE-ne0){throw "psql file failed: $Relative exit=$LASTEXITCODE"}
}
function MeasurePlan([string]$Variant,[string]$Output){
$rows=@()
for($sample=1;$sample-le30;$sample++){
$json=Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core
if($LASTEXITCODE-ne0){throw "measure failed: phase=$Variant sample=$sample exit=$LASTEXITCODE"}
$object=$json|ConvertFrom-Json
$rows+=[pscustomobject]@{variant=$Variant;sample=$sample;ms=[double]$object[0].'Execution Time'}
}
$rows|Export-Csv -NoTypeInformation -Encoding utf8 -LiteralPath $Output
return $rows
}
function Quantile($Rows,[double]$P){
$values=@($Rows.ms|Sort-Object)
if($values.Count-ne30){throw "quantile requires 30 values, got $($values.Count)"}
return $values[[Math]::Min(29,[Math]::Ceiling(30*$P)-1)]
}
function FindIndex($Node){
if($Node.'Index Name'){return [pscustomobject]@{name=[string]$Node.'Index Name';type=[string]$Node.'Node Type'}}
foreach($child in @($Node.Plans)){$found=FindIndex $child;if($found){return $found}}
return $null
}
function SourceHashes(){
$files=@('scripts/run-w13-perf.ps1','sql/w13/seed.sql','sql/w13/measure.sql','sql/w13/create-index.sql','sql/w13/assert.sql')
return @($files|ForEach-Object{[pscustomobject]@{file=$_;sha256=(Get-FileHash -Algorithm SHA256 (Join-Path $root $_)).Hash.ToLowerInvariant()}})
}
if($Phase-eq'Prepare'){
if(!(Test-Path -LiteralPath $compose -PathType Leaf)){throw 'packaged compose.yaml missing'}
$futureFiles=@('before-day.json','before-plan.txt','after-day.json','after-plan.txt','selectivity-day.json','benchmark-manifest.json','before-raw.csv','after-raw.csv','benchmark-after-plan.txt','perf-manifest.json')
$stale=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf})
if($stale.Count-ne0){throw "stale W13 D2-D6 artifact exists before Prepare: $($stale -join ',')"}
if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-prepare-config-only'}
$docker=& docker version --format '{{.Server.Version}}'
if($LASTEXITCODE-ne0-or[string]::IsNullOrWhiteSpace($docker)){throw 'Docker engine is unavailable'}
$services=@(& docker compose -f $compose config --services)
if($LASTEXITCODE-ne0-or$services.Count-ne1-or$services[0]-cne'db'){throw "compose service contract mismatch: $services"}
$futureCount=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf}).Count
if($futureCount-ne0){throw "Prepare created a future W13 artifact: count=$futureCount"}
$body=[ordered]@{phase='Prepare';docker_server=[string]$docker;compose_services=@($services);hashes=SourceHashes;native_exit=0;future_artifacts_created=$futureCount}
Write-JsonAtomic 'prepare.json' $body
'W13_PREPARE_GREEN compose=db hashes=5 future_artifacts=0 native_exit=0'
exit 0
}
if($Phase-eq'Report'){
$prepare=Get-Content -Raw (Join-Path $e 'prepare.json')|ConvertFrom-Json
$before=Get-Content -Raw (Join-Path $e 'before-day.json')|ConvertFrom-Json
$after=Get-Content -Raw (Join-Path $e 'after-day.json')|ConvertFrom-Json
$selectivity=Get-Content -Raw (Join-Path $e 'selectivity-day.json')|ConvertFrom-Json
$benchmark=Get-Content -Raw (Join-Path $e 'benchmark-manifest.json')|ConvertFrom-Json
if($prepare.future_artifacts_created-ne0-or$before.cleanup-ne1-or$after.cleanup-ne1-or$selectivity.cleanup-ne1-or$benchmark.cleanup-ne1){throw 'W13 predecessor phase/cleanup contract invalid'}
if($benchmark.before_rows-ne30-or$benchmark.after_rows-ne30-or$benchmark.chosen_index-cne'idx_ledger_account_created_id'){throw 'W13 benchmark contract invalid'}
$body=[ordered]@{
schema='perf_w13';query_kind='top-N';accounts=$selectivity.accounts;transactions=$selectivity.transactions;ledger=$selectivity.ledger;hot=$selectivity.hot
before_rows=$benchmark.before_rows;after_rows=$benchmark.after_rows
before_median_ms=$benchmark.before_median_ms;before_p95_ms=$benchmark.before_p95_ms;after_median_ms=$benchmark.after_median_ms;after_p95_ms=$benchmark.after_p95_ms
chosen_index=$benchmark.chosen_index;plan_node=$benchmark.plan_node
verdict='relative improvement observed on this deterministic lab; no absolute latency claim';native_exit=0;cleanup=1;hashes=@($prepare.hashes)
phase_evidence=@('prepare.json','before-day.json','after-day.json','selectivity-day.json','benchmark-manifest.json')
}
Write-JsonAtomic 'perf-manifest.json' $body
"W13_REPORT_GREEN phases=5 before=30 after=30 index=$($body.chosen_index) hashes=5 cleanup=1 native_exit=0"
exit 0
}
if($Phase-notin$dbPhases){throw "unsupported W13 phase=$Phase"}
$body=$null
try{
if($Mode-eq'Compose'){
if($Container){throw 'Compose mode rejects -Container'}
if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-disposable-password'}
# The unique Compose project is ours before startup; a partial `up`
# failure must still be cleaned by finally.
$owned=$true
& docker compose -f $compose -p $ComposeProject up -d --wait db
if($LASTEXITCODE-ne0){throw "compose up failed: phase=$Phase exit=$LASTEXITCODE"}
$Container=(& docker compose -f $compose -p $ComposeProject ps -q db).Trim()
}elseif([string]::IsNullOrWhiteSpace($Container)){throw 'Container mode requires -Container'}
if([string]::IsNullOrWhiteSpace($Container)){throw 'PostgreSQL container resolution failed'}
PsqlFile 'sql/w13/seed.sql'|Out-Null
if($Phase-eq'BeforePlan'){
$path=Join-Path $e 'before-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path
if($LASTEXITCODE-ne0){throw 'before plan query failed'}
$plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan
$body=[ordered]@{phase='BeforePlan';top_node=[string]$plan.'Node Type';actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1}
}elseif($Phase-eq'AfterPlan'){
PsqlFile 'sql/w13/create-index.sql'|Out-Null
$path=Join-Path $e 'after-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path
if($LASTEXITCODE-ne0){throw 'after plan query failed'}
PsqlFile 'sql/w13/assert.sql'|Out-Null
$plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan
if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'-or$chosen.type-notin@('Index Scan','Index Only Scan')){throw "after plan index mismatch: $($chosen.name)/$($chosen.type)"}
$body=[ordered]@{phase='AfterPlan';chosen_index=$chosen.name;plan_node=$chosen.type;actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1}
}elseif($Phase-eq'Selectivity'){
$raw=(& docker exec $Container psql -X -qAt -F '|' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT (SELECT count(*) FROM perf_w13.account),(SELECT count(*) FROM perf_w13.business_tx),(SELECT count(*) FROM perf_w13.ledger_entry),(SELECT count(*) FROM perf_w13.ledger_entry WHERE account_id=1)").Trim()
if($LASTEXITCODE-ne0){throw 'selectivity count query failed'}
$values=$raw.Split('|');if($values.Count-ne4-or[int]$values[0]-ne1000-or[int]$values[1]-ne100000-or[int]$values[2]-ne100000-or[int]$values[3]-ne90000){throw "selectivity counts mismatch: $raw"}
$body=[ordered]@{phase='Selectivity';accounts=1000;transactions=100000;ledger=100000;hot=90000;hot_ratio=.9;native_exit=0;cleanup=1}
}elseif($Phase-eq'Benchmark'){
$before=MeasurePlan before (Join-Path $e 'before-raw.csv')
PsqlFile 'sql/w13/create-index.sql'|Out-Null
$afterPath=Join-Path $e 'benchmark-after-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $afterPath
if($LASTEXITCODE-ne0){throw 'benchmark after plan failed'}
$after=MeasurePlan after (Join-Path $e 'after-raw.csv');PsqlFile 'sql/w13/assert.sql'|Out-Null
$plan=(Get-Content -Raw $afterPath|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan
$bm=Quantile $before .5;$bp=Quantile $before .95;$am=Quantile $after .5;$ap=Quantile $after .95
if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'){throw 'benchmark chosen index mismatch'}
if($am-ge$bm-and$ap-ge$bp){throw "no relative improvement: median=$bm->$am p95=$bp->$ap"}
$body=[ordered]@{phase='Benchmark';before_rows=30;after_rows=30;before_median_ms=$bm;before_p95_ms=$bp;after_median_ms=$am;after_p95_ms=$ap;chosen_index=$chosen.name;plan_node=$chosen.type;native_exit=0;cleanup=1}
}
}finally{
if($owned){& docker compose -f $compose -p $ComposeProject down -v|Out-Null;if($LASTEXITCODE-ne0){throw "compose cleanup failed: phase=$Phase"}}
}
if($null-eq$body){throw "W13 phase produced no evidence: $Phase"}
$file=@{BeforePlan='before-day.json';AfterPlan='after-day.json';Selectivity='selectivity-day.json';Benchmark='benchmark-manifest.json'}[$Phase]
Write-JsonAtomic $file $body
"W13_${Phase}_GREEN native_exit=0 cleanup=1 evidence=$file"
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 143줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 17개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | param( | run-w13-perf 스크립트의 parameter 블록을 연다. |
| 2 | [Parameter(Mandatory=$true)][ValidateSet('Prepare','BeforePlan','AfterPlan','Selectivity','Benchmark','Report')][string]$Phase, | 필수 Phase를 여섯 문자열 중 하나로 제한해 받는다. |
| 3 | [ValidateSet('Compose','Container')][string]$Mode='Compose', | Mode는 Compose 또는 Container만 받고 기본값은 Compose로 둔다. |
| 4 | [string]$Container='', | 외부 컨테이너 ID나 이름을 받을 Container 문자열을 빈 값으로 시작한다. |
| 5 | [string]$ComposeProject='w13-perf-lab', | Compose project 이름을 받고 기본값을 w13-perf-lab으로 둔다. |
| 6 | [Parameter(Mandatory=$true)][string]$EvidenceDir | 증거 디렉터리 경로를 필수 문자열로 받는다. |
| 7 | ) | script parameter 목록을 닫는다. |
| 8 | $ErrorActionPreference='Stop' | PowerShell 오류 기본 동작을 Stop으로 바꾼다. |
| 9 | $root=Split-Path -Parent $PSScriptRoot | 스크립트 폴더의 부모를 repository root로 계산한다. |
| 10 | if(-not [IO.Path]::IsPathRooted($EvidenceDir)){ | EvidenceDir에 root가 없으면 실패 분기를 연다. |
| 11 | throw 'EvidenceDir must be an absolute learner-owned path' | root 없는 EvidenceDir 호출을 absolute learner-owned path라는 오류 문구로 중단한다. |
| 12 | } | EvidenceDir root 유무 guard의 본문을 닫는다. |
| 13 | $e=[IO.Path]::GetFullPath($EvidenceDir) | EvidenceDir를 정규화된 전체 경로 e로 바꾼다. |
| 14 | New-Item -ItemType Directory -Force $e|Out-Null | 증거 디렉터리를 필요하면 만들고 생성 출력은 버린다. |
| 15 | $compose=Join-Path $root 'compose.yaml' | repository root 아래 compose.yaml 경로를 compose 변수에 만든다. |
| 16 | $owned=$false | 현재 실행이 Compose DB를 소유하지 않았다고 초기화한다. |
| 17 | $dbPhases=@('BeforePlan','AfterPlan','Selectivity','Benchmark') | DB를 필요로 하는 네 phase 이름을 배열로 묶는다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 19 | function Write-JsonAtomic([string]$Name,$Value){ | 이름과 값을 받아 JSON을 원자 교체하는 Write-JsonAtomic 함수를 연다. |
| 20 | $target=Join-Path $e $Name;$temporary="$target.tmp" | 최종 target과 같은 경로의 .tmp 임시 파일 이름을 계산한다. |
| 21 | $Value|ConvertTo-Json -Depth 6|Set-Content -Encoding utf8 $temporary | Value를 깊이 6 JSON으로 바꿔 UTF-8 임시 파일에 쓴다. |
| 22 | Move-Item -LiteralPath $temporary -Destination $target -Force | 임시 JSON 파일을 최종 target으로 강제 이동한다. |
| 23 | } | Write-JsonAtomic 함수 본문을 닫는다. |
| 24 | function PsqlFile([string]$Relative){ | repository 상대 SQL 파일을 psql로 실행하는 PsqlFile 함수를 연다. |
| 25 | Get-Content -Raw -LiteralPath (Join-Path $root $Relative)|docker exec -i $Container psql -X -v ON_ERROR_STOP=1 -U app -d financial_core | 상대 SQL 원문을 읽어 지정 container의 financial_core psql 표준입력으로 보낸다. |
| 26 | if($LASTEXITCODE-ne0){throw "psql file failed: $Relative exit=$LASTEXITCODE"} | 바로 앞 psql의 종료값이 0이 아니면 상대 경로와 code를 넣어 실패한다. |
| 27 | } | PsqlFile 함수 본문을 닫는다. |
| 28 | function MeasurePlan([string]$Variant,[string]$Output){ | variant와 CSV 출력 경로를 받아 계획을 30번 재는 MeasurePlan 함수를 연다. |
| 29 | $rows=@() | 측정 row를 모을 빈 배열을 만든다. |
| 30 | for($sample=1;$sample-le30;$sample++){ | sample을 1부터 30까지 하나씩 늘리는 반복을 연다. |
| 31 | $json=Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core | measure.sql을 psql로 실행해 JSON plan 문자열을 json 변수에 받는다. |
| 32 | if($LASTEXITCODE-ne0){throw "measure failed: phase=$Variant sample=$sample exit=$LASTEXITCODE"} | 측정 psql 종료값이 비zero면 variant·sample·exit를 포함해 실패한다. |
| 33 | $object=$json|ConvertFrom-Json | JSON 측정 문자열을 PowerShell 객체로 바꾼다. |
| 34 | $rows+=[pscustomobject]@{variant=$Variant;sample=$sample;ms=[double]$object[0].'Execution Time'} | variant, sample, Execution Time ms를 가진 custom row를 배열에 추가한다. |
| 35 | } | 30회 sample 반복 본문을 닫는다. |
| 36 | $rows|Export-Csv -NoTypeInformation -Encoding utf8 -LiteralPath $Output | 수집 row를 header가 있는 UTF-8 CSV로 Output 경로에 쓴다. |
| 37 | return $rows | 수집한 row 배열을 caller에게 반환한다. |
| 38 | } | MeasurePlan 함수 본문을 닫는다. |
| 39 | function Quantile($Rows,[double]$P){ | 30개 row와 확률 P를 받아 분위수를 구하는 Quantile 함수를 연다. |
| 40 | $values=@($Rows.ms|Sort-Object) | Rows의 ms 값만 오름차순 정렬해 배열로 고정한다. |
| 41 | if($values.Count-ne30){throw "quantile requires 30 values, got $($values.Count)"} | 정렬값 수가 정확히 30이 아니면 실제 개수를 넣어 실패한다. |
| 42 | return $values[[Math]::Min(29,[Math]::Ceiling(30*$P)-1)] | ceil(30×P)-1을 최대 29로 막은 배열 원소를 반환한다. |
| 43 | } | Quantile 함수 본문을 닫는다. |
| 44 | function FindIndex($Node){ | plan node에서 사용 인덱스를 재귀 탐색하는 FindIndex 함수를 연다. |
| 45 | if($Node.'Index Name'){return [pscustomobject]@{name=[string]$Node.'Index Name';type=[string]$Node.'Node Type'}} | 현재 node에 Index Name이 있으면 name과 Node Type 객체를 즉시 반환한다. |
| 46 | foreach($child in @($Node.Plans)){$found=FindIndex $child;if($found){return $found}} | Plans 자식들을 배열로 돌며 재귀 결과가 나오면 첫 값을 반환한다. |
| 47 | return $null | 현재 subtree에 인덱스가 없으면 null을 반환한다. |
| 48 | } | FindIndex 함수 본문을 닫는다. |
| 49 | function SourceHashes(){ | source 파일 SHA 목록을 만드는 SourceHashes 함수를 연다. |
| 50 | $files=@('scripts/run-w13-perf.ps1','sql/w13/seed.sql','sql/w13/measure.sql','sql/w13/create-index.sql','sql/w13/assert.sql') | runner와 W13 SQL 네 개의 상대 경로를 정확히 다섯 항목 배열로 만든다. |
| 51 | return @($files|ForEach-Object{[pscustomobject]@{file=$_;sha256=(Get-FileHash -Algorithm SHA256 (Join-Path $root $_)).Hash.ToLowerInvariant()}}) | 다섯 파일마다 소문자 SHA-256을 계산한 file·sha256 객체 배열을 반환한다. |
| 52 | } | SourceHashes 함수 본문을 닫는다. |
| 54 | if($Phase-eq'Prepare'){ | Phase가 Prepare일 때 준비 전용 분기를 연다. |
| 55 | if(!(Test-Path -LiteralPath $compose -PathType Leaf)){throw 'packaged compose.yaml missing'} | packaged compose.yaml이 일반 파일이 아니면 즉시 실패한다. |
| 56 | $futureFiles=@('before-day.json','before-plan.txt','after-day.json','after-plan.txt','selectivity-day.json','benchmark-manifest.json','before-raw.csv','after-raw.csv','benchmark-after-plan.txt','perf-manifest.json') | Prepare 전에 없어야 할 D2~D7 미래 증거 파일 열 개를 배열로 고정한다. |
| 57 | $stale=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf}) | 열 개 이름 중 EvidenceDir에 이미 있는 일반 파일만 stale 배열로 모은다. |
| 58 | if($stale.Count-ne0){throw "stale W13 D2-D6 artifact exists before Prepare: $($stale -join ',')"} | stale 파일이 하나라도 있으면 이름들을 쉼표로 묶어 실패한다. |
| 59 | if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-prepare-config-only'} | DB password 환경변수가 비었으면 Prepare config 전용 기본 문자열을 넣는다. |
| 60 | $docker=& docker version --format '{{.Server.Version}}' | Docker server version 문자열을 native command로 읽는다. |
| 61 | if($LASTEXITCODE-ne0-or[string]::IsNullOrWhiteSpace($docker)){throw 'Docker engine is unavailable'} | Docker command가 실패했거나 version이 비면 engine unavailable로 실패한다. |
| 62 | $services=@(& docker compose -f $compose config --services) | compose config가 선언한 service 이름들을 배열로 읽는다. |
| 63 | if($LASTEXITCODE-ne0-or$services.Count-ne1-or$services[0]-cne'db'){throw "compose service contract mismatch: $services"} | compose config가 성공하고 service가 대소문자까지 db 하나인지 강제한다. |
| 64 | $futureCount=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf}).Count | Prepare 검사 중 미래 파일이 새로 생겼는지 같은 목록을 다시 세어 futureCount에 넣는다. |
| 65 | if($futureCount-ne0){throw "Prepare created a future W13 artifact: count=$futureCount"} | futureCount가 0이 아니면 Prepare가 미래 증거를 만들었다고 실패한다. |
| 66 | $body=[ordered]@{phase='Prepare';docker_server=[string]$docker;compose_services=@($services);hashes=SourceHashes;native_exit=0;future_artifacts_created=$futureCount} | Prepare phase·Docker version·db service·다섯 hash·exit 0·future 0을 ordered body에 담는다. |
| 67 | Write-JsonAtomic 'prepare.json' $body | Prepare body를 prepare.json으로 원자 교체 저장한다. |
| 68 | 'W13_PREPARE_GREEN compose=db hashes=5 future_artifacts=0 native_exit=0' | Prepare 성공 계약을 고정 marker 한 줄로 출력한다. |
| 69 | exit 0 | Prepare 성공 후 process를 exit code 0으로 끝낸다. |
| 70 | } | Prepare 조건 블록을 닫는다. |
| 72 | if($Phase-eq'Report'){ | Phase가 Report일 때 D7 보고 분기를 연다. |
| 73 | $prepare=Get-Content -Raw (Join-Path $e 'prepare.json')|ConvertFrom-Json | 저장된 prepare.json을 읽어 JSON 객체 prepare로 바꾼다. |
| 74 | $before=Get-Content -Raw (Join-Path $e 'before-day.json')|ConvertFrom-Json | 저장된 before-day.json을 before 객체로 읽는다. |
| 75 | $after=Get-Content -Raw (Join-Path $e 'after-day.json')|ConvertFrom-Json | 저장된 after-day.json을 after 객체로 읽는다. |
| 76 | $selectivity=Get-Content -Raw (Join-Path $e 'selectivity-day.json')|ConvertFrom-Json | 저장된 selectivity-day.json을 selectivity 객체로 읽는다. |
| 77 | $benchmark=Get-Content -Raw (Join-Path $e 'benchmark-manifest.json')|ConvertFrom-Json | 저장된 benchmark-manifest.json을 benchmark 객체로 읽는다. |
| 78 | if($prepare.future_artifacts_created-ne0-or$before.cleanup-ne1-or$after.cleanup-ne1-or$selectivity.cleanup-ne1-or$benchmark.cleanup-ne1){throw 'W13 predecessor phase/cleanup contract invalid'} | Prepare future=0과 네 predecessor cleanup=1 중 하나라도 아니면 실패한다. |
| 79 | if($benchmark.before_rows-ne30-or$benchmark.after_rows-ne30-or$benchmark.chosen_index-cne'idx_ledger_account_created_id'){throw 'W13 benchmark contract invalid'} | benchmark의 before/after row가 각각 30이고 index 이름이 정확한지 검사한다. |
| 80 | $body=[ordered]@{ | Report의 ordered body 작성을 시작한다. |
| 81 | schema='perf_w13';query_kind='top-N';accounts=$selectivity.accounts;transactions=$selectivity.transactions;ledger=$selectivity.ledger;hot=$selectivity.hot | schema·top-N 종류와 selectivity의 계좌·거래·원장·hot 수를 report에 복사한다. |
| 82 | before_rows=$benchmark.before_rows;after_rows=$benchmark.after_rows | before_rows와 after_rows를 benchmark 저장값에서 report로 옮긴다. |
| 83 | before_median_ms=$benchmark.before_median_ms;before_p95_ms=$benchmark.before_p95_ms;after_median_ms=$benchmark.after_median_ms;after_p95_ms=$benchmark.after_p95_ms | 전·후 median과 p95 네 값을 benchmark에서 report로 복사한다. |
| 84 | chosen_index=$benchmark.chosen_index;plan_node=$benchmark.plan_node | 선택 인덱스 이름과 plan node type을 benchmark에서 report로 복사한다. |
| 85 | verdict='relative improvement observed on this deterministic lab; no absolute latency claim';native_exit=0;cleanup=1;hashes=@($prepare.hashes) | 합성 lab의 상대 개선만 관찰했다는 verdict와 exit·cleanup·prepare hashes를 담는다. |
| 86 | phase_evidence=@('prepare.json','before-day.json','after-day.json','selectivity-day.json','benchmark-manifest.json') | 보고서가 근거로 삼은 phase evidence 파일 다섯 이름을 배열로 적는다. |
| 87 | } | Report ordered body literal을 닫는다. |
| 88 | Write-JsonAtomic 'perf-manifest.json' $body | Report body를 perf-manifest.json으로 원자 저장한다. |
| 89 | "W13_REPORT_GREEN phases=5 before=30 after=30 index=$($body.chosen_index) hashes=5 cleanup=1 native_exit=0" | Report 성공 marker에 phase 5개·30/30·index·hash 5·cleanup 1을 출력한다. |
| 90 | exit 0 | Report 성공 후 process를 exit code 0으로 끝낸다. |
| 91 | } | Report 조건 블록을 닫는다. |
| 93 | if($Phase-notin$dbPhases){throw "unsupported W13 phase=$Phase"} | Phase가 네 db phase에 없으면 unsupported 오류로 실패한다. |
| 94 | $body=$null | phase evidence body를 null로 초기화한다. |
| 95 | try{ | DB 실행 본문을 try로 열어 finally cleanup과 묶는다. |
| 96 | if($Mode-eq'Compose'){ | Mode가 Compose일 때 일회용 DB startup 분기를 연다. |
| 97 | if($Container){throw 'Compose mode rejects -Container'} | Compose mode에서 Container 인수가 비어 있지 않으면 실패한다. |
| 98 | if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-disposable-password'} | DB password가 비었으면 일회용 접속용 기본 문자열을 process 환경에 넣는다. |
| 99 | # The unique Compose project is ours before startup; a partial `up` | 고유 Compose project는 startup 전부터 이 script 소유라는 cleanup 의도를 설명한다. |
| 100 | # failure must still be cleaned by finally. | 부분 up 실패도 finally에서 정리해야 한다는 주석을 마친다. |
| 101 | $owned=$true | compose up 전에 owned를 true로 바꾼다. |
| 102 | & docker compose -f $compose -p $ComposeProject up -d --wait db | compose project의 db service를 detached로 시작하고 health까지 기다린다. |
| 103 | if($LASTEXITCODE-ne0){throw "compose up failed: phase=$Phase exit=$LASTEXITCODE"} | compose up 종료값이 nonzero면 phase와 exit를 넣어 실패한다. |
| 104 | $Container=(& docker compose -f $compose -p $ComposeProject ps -q db).Trim() | Compose db service의 container ID를 조회해 공백을 제거한다. |
| 105 | }elseif([string]::IsNullOrWhiteSpace($Container)){throw 'Container mode requires -Container'} | Container mode인데 Container가 비어 있으면 필수 인수 오류를 낸다. |
| 106 | if([string]::IsNullOrWhiteSpace($Container)){throw 'PostgreSQL container resolution failed'} | 두 mode를 거친 Container 값이 여전히 비면 resolution 실패로 중단한다. |
| 107 | PsqlFile 'sql/w13/seed.sql'|Out-Null | 모든 db phase 시작에 seed.sql을 실행하고 출력은 버린다. |
| 109 | if($Phase-eq'BeforePlan'){ | Phase가 BeforePlan이면 인덱스 생성 전 계획 증거 분기를 연다. |
| 110 | $path=Join-Path $e 'before-plan.txt' | before plan JSON 원문을 저장할 evidence 경로를 만든다. |
| 111 | Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path | measure.sql을 psql로 실행하고 JSON stdout을 UTF-8 before-plan.txt에 보낸다. |
| 112 | if($LASTEXITCODE-ne0){throw 'before plan query failed'} | before plan native 실행이 실패했으면 오류를 낸다. |
| 113 | $plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan | 저장된 before plan JSON의 첫 원소 Plan을 꺼낸다. |
| 114 | $body=[ordered]@{phase='BeforePlan';top_node=[string]$plan.'Node Type';actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1} | BeforePlan top node·actual rows·plan SHA·exit 0·cleanup 1을 ordered body에 담는다. |
| 115 | }elseif($Phase-eq'AfterPlan'){ | 아니고 Phase가 AfterPlan이면 인덱스 후 계획 분기를 연다. |
| 116 | PsqlFile 'sql/w13/create-index.sql'|Out-Null | create-index.sql을 실행해 복합 인덱스를 만들고 일반 출력은 버린다. |
| 117 | $path=Join-Path $e 'after-plan.txt' | after plan JSON을 저장할 evidence 경로를 만든다. |
| 118 | Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path | 같은 measure.sql을 실행해 JSON plan을 after-plan.txt에 쓴다. |
| 119 | if($LASTEXITCODE-ne0){throw 'after plan query failed'} | after plan native 실행이 실패하면 오류를 낸다. |
| 120 | PsqlFile 'sql/w13/assert.sql'|Out-Null | assert.sql로 fixture count·잔액 대사·인덱스 이름 존재를 검사한다. |
| 121 | $plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan | after-plan JSON에서 Plan을 읽고 첫 인덱스 node를 재귀 탐색한다. |
| 122 | if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'-or$chosen.type-notin@('Index Scan','Index Only Scan')){throw "after plan index mismatch: $($chosen.name)/$($chosen.type)"} | 선택 index 이름과 node type이 목표 계약이 아니면 상세값으로 실패한다. |
| 123 | $body=[ordered]@{phase='AfterPlan';chosen_index=$chosen.name;plan_node=$chosen.type;actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1} | AfterPlan 선택 index·node·rows·plan SHA·exit·cleanup을 ordered body에 담는다. |
| 124 | }elseif($Phase-eq'Selectivity'){ | 아니고 Phase가 Selectivity이면 합성 분포 검사 분기를 연다. |
| 125 | $raw=(& docker exec $Container psql -X -qAt -F '|' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT (SELECT count(*) FROM perf_w13.account),(SELECT count(*) FROM perf_w13.business_tx),(SELECT count(*) FROM perf_w13.ledger_entry),(SELECT count(*) FROM perf_w13.ledger_entry WHERE account_id=1)").Trim() | 네 count를 한 SQL로 조회해 | 구분 raw 문자열로 받고 양끝 공백을 제거한다. |
| 126 | if($LASTEXITCODE-ne0){throw 'selectivity count query failed'} | selectivity count native 실행이 실패하면 오류를 낸다. |
| 127 | $values=$raw.Split('|');if($values.Count-ne4-or[int]$values[0]-ne1000-or[int]$values[1]-ne100000-or[int]$values[2]-ne100000-or[int]$values[3]-ne90000){throw "selectivity counts mismatch: $raw"} | raw를 네 칸으로 나누고 1000·100000·100000·90000과 정확히 대조한다. |
| 128 | $body=[ordered]@{phase='Selectivity';accounts=1000;transactions=100000;ledger=100000;hot=90000;hot_ratio=.9;native_exit=0;cleanup=1} | Selectivity 고정 count와 hot_ratio .9, exit·cleanup을 ordered body에 담는다. |
| 129 | }elseif($Phase-eq'Benchmark'){ | 아니고 Phase가 Benchmark이면 전후 30회 비교 분기를 연다. |
| 130 | $before=MeasurePlan before (Join-Path $e 'before-raw.csv') | 인덱스 전 계획을 30번 재고 before-raw.csv에 쓰며 rows를 받는다. |
| 131 | PsqlFile 'sql/w13/create-index.sql'|Out-Null | before 측정 뒤 create-index.sql을 실행한다. |
| 132 | $afterPath=Join-Path $e 'benchmark-after-plan.txt' | benchmark용 after plan JSON 출력 경로를 만든다. |
| 133 | Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $afterPath | 인덱스 후 measure.sql 계획을 실행해 benchmark after plan 파일에 쓴다. |
| 134 | if($LASTEXITCODE-ne0){throw 'benchmark after plan failed'} | benchmark after plan native 실행이 실패하면 오류를 낸다. |
| 135 | $after=MeasurePlan after (Join-Path $e 'after-raw.csv');PsqlFile 'sql/w13/assert.sql'|Out-Null | 인덱스 후 계획을 30번 재고 after CSV를 만든 뒤 assert.sql을 실행한다. |
| 136 | $plan=(Get-Content -Raw $afterPath|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan | 저장한 benchmark plan을 읽고 첫 인덱스 node를 찾는다. |
| 137 | $bm=Quantile $before .5;$bp=Quantile $before .95;$am=Quantile $after .5;$ap=Quantile $after .95 | before/after 각각 .5와 .95 nearest-rank 값을 네 변수로 계산한다. |
| 138 | if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'){throw 'benchmark chosen index mismatch'} | 선택 인덱스가 없거나 목표 이름이 아니면 benchmark를 실패시킨다. |
| 139 | if($am-ge$bm-and$ap-ge$bp){throw "no relative improvement: median=$bm->$am p95=$bp->$ap"} | after median과 p95가 각각 before 이상일 때만 no improvement로 실패한다. |
| 140 | $body=[ordered]@{phase='Benchmark';before_rows=30;after_rows=30;before_median_ms=$bm;before_p95_ms=$bp;after_median_ms=$am;after_p95_ms=$ap;chosen_index=$chosen.name;plan_node=$chosen.type;native_exit=0;cleanup=1} | Benchmark 30/30 rows·네 metric·index·node·exit·cleanup을 ordered body에 담는다. |
| 141 | } | BeforePlan부터 Benchmark까지 이어진 phase 조건 사슬을 닫는다. |
| 142 | }finally{ | try가 끝난 뒤 성공·실패 모두 실행할 finally 블록을 연다. |
| 143 | if($owned){& docker compose -f $compose -p $ComposeProject down -v|Out-Null;if($LASTEXITCODE-ne0){throw "compose cleanup failed: phase=$Phase"}} | owned이면 compose project와 volume을 내리고 cleanup 실패도 예외로 바꾼다. |
| 144 | } | finally cleanup 블록을 닫는다. |
| 145 | if($null-eq$body){throw "W13 phase produced no evidence: $Phase"} | body가 여전히 null이면 해당 phase가 evidence를 만들지 못했다고 실패한다. |
| 146 | $file=@{BeforePlan='before-day.json';AfterPlan='after-day.json';Selectivity='selectivity-day.json';Benchmark='benchmark-manifest.json'}[$Phase] | 현재 db phase를 네 evidence JSON 파일 이름 중 하나로 매핑한다. |
| 147 | Write-JsonAtomic $file $body | phase body를 선택한 JSON 파일로 원자 저장한다. |
| 148 | "W13_${Phase}_GREEN native_exit=0 cleanup=1 evidence=$file" | 현재 phase 이름과 evidence 파일을 넣은 고정 Green marker를 출력한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기phase별 disposable lifecycle로 동일 fixture의 plan·count·전후 30회 nearest-rank 지표를 저장하고 owned Compose를 정리하지만 통과 조건과 측정 설계의 좁은 한계를 그대로 가진다.
문법 해부
- PowerShell pipeline |은 왼쪽 success output을 오른쪽 command input으로 보낸다.
- @(...)는 결과가 하나여도 배열로 고정하고 [ordered]@{...}는 JSON field 순서를 보존한다.
- $LASTEXITCODE는 바로 앞 native executable의 종료 상태이므로 위치가 중요하다.
- try/finally는 try가 성공하거나 예외를 던져도 finally를 실행한다.
- double-quoted string은 $Phase 같은 변수를 보간하고 single-quoted string은 literal로 둔다.
- elseif 사슬은 네 db phase 중 하나의 body만 채운다.
실행 순서
- parameter binding 뒤 IsPathRooted로 root 없는 EvidenceDir를 막고 GetFullPath가 통과값을 full path로 해석한다.
- Prepare면 compose config와 stale evidence를 검사하고 비교하지 않은 current hash 다섯 개를 prepare.json에 기록한 뒤 exit한다.
- Report면 saved JSON 다섯 개를 읽어 perf-manifest를 쓰고 exit한다.
- DB phase면 Compose를 소유해 켜거나 외부 Container를 받고 매번 seed.sql을 실행한다.
- 선택 phase가 plan·selectivity·benchmark body를 만든다.
- finally가 owned Compose project와 volume을 내린다.
- body를 phase JSON에 원자 저장하고 exact Green marker를 출력한다.
원래 W6 수준의 조각별 정밀 해설
F06-C01 · parameters and shared paths
- 문법 해부
- param이 Phase와 EvidenceDir 전달을 Mandatory로 요구하고 Phase ValidateSet에 Prepare·BeforePlan·AfterPlan·Selectivity·Benchmark·Report를 허용하며 dbPhases에는 네 DB phase만 둔다.
- 실제 값 추적
- `C:\evidence\w13`처럼 rooted·fully-qualified 입력은 IsPathRooted를 통과하고 GetFullPath 결과가 $e에 들어간다.
- 정상 예
- Windows의 `C:Documents`나 `\Documents`는 rooted이면서 fully qualified가 아니어도 IsPathRooted를 통과하며, 뒤 GetFullPath가 현재 drive/directory 문맥을 써 $e를 해석한다.
- 틀린 예·반례
- `evidence\w13`처럼 root가 없는 입력은 L10에서 throw되어 L13 GetFullPath와 directory 생성에 도달하지 않는다.
- 착각 방지
- Mandatory binding은 빈 문자열을 거부해도 absolute·fully-qualified를 증명하지 않으며, rooted 판정은 fully-qualified 판정과 다르므로 오류문구의 absolute 주장이 실제 predicate보다 강하다.
- 하지 않는 일
- GetFullPath 결과는 호출 환경에 의존할 수 있고 이 입력 검사는 Docker/DB 상태, source hash 일치, evidence directory 소유권도 확인하지 않는다.
- 다음 연결
- Write-JsonAtomic이 resolve된 $e 아래 phase 결과를 tmp 후 rename으로 저장한다.
F06-C02 · atomic JSON writer
- 문법 해부
- 함수는 target.tmp에 Depth 6 JSON을 쓴 뒤 Move-Item -Force로 최종 이름에 교체한다.
- 실제 값 추적
- prepare.json 같은 evidence는 완성된 JSON 한 번으로 보이게 된다.
- 정상 예
- process가 Set-Content 전 실패하면 최종 target을 부분 JSON으로 직접 덮지 않는다.
- 틀린 예·반례
- temporary 이름 충돌이나 filesystem crash까지 완전한 durability를 보장하지 않는다.
- 착각 방지
- JSON body의 업무 정확성은 호출 phase가 만들어야 한다.
- 하지 않는 일
- source file hash 계산과 DB cleanup을 수행하지 않는다.
- 다음 연결
- PsqlFile이 canonical SQL을 container psql에 전달한다.
F06-C03 · psql file runner
- 문법 해부
- Get-Content -Raw 파이프와 docker exec psql ON_ERROR_STOP이 상대 SQL 파일을 실행하고 native exit를 검사한다.
- 실제 값 추적
- seed.sql/create-index.sql/assert.sql 중 전달받은 Relative가 financial_core DB에서 실행된다.
- 정상 예
- psql exit가 0이 아니면 파일명과 exit code를 넣어 즉시 throw한다.
- 틀린 예·반례
- Container가 틀린 DB를 가리키면 canonical SQL도 잘못된 곳에서 실패할 수 있다.
- 착각 방지
- 함수는 SQL 결과의 업무 의미를 자체 파싱하지 않는다.
- 하지 않는 일
- 컨테이너 생성과 제거는 바깥 lifecycle이 맡는다.
- 다음 연결
- MeasurePlan이 measure.sql을 정확히 30번 실행해 raw rows를 모은다.
F06-C04 · 30-sample plan measurement
- 문법 해부
- for sample=1..30 loop가 measure.sql JSON을 실행·parse하고 variant/sample/ms 객체를 CSV와 배열로 만든다.
- 실제 값 추적
- before 또는 after 각각 30개의 Execution Time double 값이 생긴다.
- 정상 예
- 17번째 실행 실패면 phase·sample=17·exit를 담은 예외로 전체 phase가 실패한다.
- 틀린 예·반례
- 30회는 독립 machine noise와 warmup bias를 제거하는 보편 표본 수가 아니다.
- 착각 방지
- 이 함수는 OFFSET/keyset이나 여러 account selectivity를 측정하지 않는다.
- 하지 않는 일
- median/p95 계산과 relative-improvement gate는 뒤 Benchmark가 맡는다.
- 다음 연결
- Quantile이 이 30개 ms 배열에서 위치 값을 고른다.
F06-C05 · nearest-rank Quantile implementation
- 문법 해부
- ms를 정렬하고 Count=30을 검사한 뒤 Ceiling(30*P)-1 위치를 반환하는 nearest-rank 식이다.
- 실제 값 추적
- 값 1..30에서 P=.5는 index14의 15, P=.95는 index28의 29를 낸다.
- 정상 예
- p95=29는 printed nearest-rank 예와 일치한다.
- 틀린 예·반례
- 짝수 표본 median 계약은 (15+16)/2=15.5인데 현재 함수는 15를 반환한다.
- 착각 방지
- 현재 before_median_ms/after_median_ms를 올바른 pair-average median이라고 가르치면 안 된다.
- 하지 않는 일
- 통계적 신뢰구간이나 outlier 정책은 계산하지 않는다.
- 다음 연결
- FindIndex가 after plan tree에서 실제 선택 index를 찾는다.
F06-C06 · recursive plan index finder
- 문법 해부
- 현재 Node의 Index Name을 먼저 보고 없으면 Plans children을 재귀 순회해 첫 index node를 반환한다.
- 실제 값 추적
- Index Scan 또는 Index Only Scan 아래에서 idx_ledger_account_created_id 이름과 Node Type을 찾는다.
- 정상 예
- top node가 Sort여도 하위 plan에 index가 있으면 재귀가 발견한다.
- 틀린 예·반례
- 여러 index node가 있으면 첫 발견만 반환해 전체 plan 구조를 대표하지 못할 수 있다.
- 착각 방지
- 함수는 cost·buffers·actual time을 평가하지 않는다.
- 하지 않는 일
- index가 query 의미를 보존하는지는 assert와 source 계약의 몫이다.
- 다음 연결
- SourceHashes가 phase evidence에 묶을 다섯 파일을 열거한다.
F06-C07 · five-file source hash list
- 문법 해부
- 문자열 배열 다섯 경로를 ForEach-Object로 돌며 SHA256 소문자 객체를 만든다.
- 실제 값 추적
- runner,seed,measure,create-index,assert의 file/hash 다섯 쌍이 prepare body에 들어간다.
- 정상 예
- 파일 한 byte가 바뀌면 해당 sha256 값이 달라진다.
- 틀린 예·반례
- compose.yaml과 inline D5 gate는 이 배열에 없어 다섯 hash closure 밖이다.
- 착각 방지
- 다섯 hash를 전체 환경 sealed manifest라고 부르면 안 된다.
- 하지 않는 일
- hash는 파일 내용의 올바름이나 Docker image digest를 증명하지 않는다.
- 다음 연결
- Prepare가 compose shape와 stale future artifacts를 검사하고 이 hashes를 저장한다.
F06-C08 · Prepare phase
- 문법 해부
- Prepare branch가 compose 존재·stale artifact 없음·Docker server/version·service=db·futureCount=0을 검사하고 prepare.json을 쓴다.
- 실제 값 추적
- DB를 올리거나 seed하지 않고 compose config를 확인하며 다섯 source의 현재 SHA를 계산해 기록한다.
- 정상 예
- future artifact가 하나라도 이미 있으면 stale 목록을 넣어 실패한다.
- 틀린 예·반례
- source 한 byte를 Prepare 전에 바꿔도 새 hash를 그대로 기록할 뿐 known-good expected baseline과 equality/mismatch 비교가 없어 그 변경만으로는 실패하지 않는다.
- 착각 방지
- 현재 SHA 기록은 무결성 승인이나 compose.yaml 포함 전체 closure가 아니며 current runner 한 파일은 다섯 목록 안에 포함될 뿐이다.
- 하지 않는 일
- D2-D6 결과의 current 실행을 대신 증명하지 않는다.
- 다음 연결
- 다음 Report branch는 같은 파일에 있지만 strict scope에서 호출되지 않는다.
F06-C09 · Report phase shared source, excluded from strict scope
- 문법 해부
- Report branch가 다섯 predecessor JSON을 읽고 cleanup/count/index를 검사해 perf-manifest를 조립한다.
- 실제 값 추적
- D7에서 hashes와 phase_evidence를 합쳐 W13_REPORT_GREEN marker를 낼 수 있다.
- 정상 예
- strict Monday-Saturday는 Phase Report를 호출하지 않아 perf-manifest를 만들지 않는다.
- 틀린 예·반례
- 이 source가 파일에 존재한다는 이유로 D1-D6 결과에 final manifest가 있다고 주장하면 안 된다.
- 착각 방지
- branch는 historical D2-D6 evidence를 current runner hash로 재실행하지 않는다.
- 하지 않는 일
- compose.yaml과 inline gate hash를 SourceHashes에 추가하지 않는다.
- 다음 연결
- strict DB phase validation이 BeforePlan/AfterPlan/Selectivity/Benchmark만 허용한다.
F06-C10 · database phase validation and disposable startup
- 문법 해부
- dbPhases membership을 확인하고 try에서 지정 ComposeProject 소유 간주·비밀번호·up --wait·container 해석 또는 Container 입력을 fail-closed로 처리한다.
- 실제 값 추적
- Compose mode는 기본 w13-perf-lab 또는 전달된 project를 이 run의 소유로 간주한 뒤 db를 띄우고 seed.sql을 실행한다.
- 정상 예
- partial `up` 실패도 owned=true라 finally cleanup 대상이 된다.
- 틀린 예·반례
- 같은 ComposeProject를 다른 실행이 쓰고 있어도 uniqueness나 collision을 사전 검사하지 않는다.
- 착각 방지
- startup 성공은 phase-specific evidence나 relative improvement를 아직 증명하지 않는다.
- 하지 않는 일
- Container mode의 외부 container는 삭제하지 않지만 seed.sql이 perf_w13 schema를 재생성한다.
- 다음 연결
- BeforePlan branch가 index 전 plan 한 번을 파일로 보존한다.
F06-C11 · BeforePlan phase
- 문법 해부
- BeforePlan이 measure.sql을 한 번 실행해 before-plan.txt를 쓰고 JSON top node·actual rows·file hash를 body에 담는다.
- 실제 값 추적
- seed.sql이 schema를 재생성한 직후 index 생성 전 plan 1개가 기록된다.
- 정상 예
- psql native exit가 nonzero면 before plan query failed로 중단한다.
- 틀린 예·반례
- 이 한 plan은 30회 benchmark before raw CSV가 아니며 Container mode가 container 자체의 disposable ownership을 뜻하지도 않는다.
- 착각 방지
- top_node만으로 전체 child plan과 성능 원인을 설명하지 않는다.
- 하지 않는 일
- cleanup 성공은 finally 뒤에 확정되며 이 branch 내부 출력만으로 끝나지 않는다.
- 다음 연결
- AfterPlan은 같은 fresh schema에 복합 index를 추가하고 selected node를 검증한다.
F06-C12 · AfterPlan phase
- 문법 해부
- AfterPlan이 create-index, one measure plan, assert.sql, FindIndex를 순서대로 실행하고 exact name/type을 gate한다.
- 실제 값 추적
- chosen index가 idx_ledger_account_created_id이며 Index Scan 계열이면 after-day body를 만든다.
- 정상 예
- 다른 index 이름이나 Seq Scan만 나오면 after plan index mismatch로 실패한다.
- 틀린 예·반례
- one after plan은 30개 after latency 분포가 아니다.
- 착각 방지
- index 선택이 모든 account 분포와 production query에 최적임을 보장하지 않는다.
- 하지 않는 일
- median bug와 D5 proof gap을 교정하지 않는다.
- 다음 연결
- Selectivity가 고정 fixture 네 count를 별도 fresh DB에서 확인한다.
F06-C13 · Selectivity phase
- 문법 해부
- 한 psql scalar query가 네 count를 |로 받고 정확히 1000/100000/100000/90000인지 검사해 hot_ratio=.9 body를 만든다.
- 실제 값 추적
- fresh seed에서 account 1의 ledger 비율이 90%임을 확인한다.
- 정상 예
- 값 하나라도 다르거나 field가 4개가 아니면 selectivity counts mismatch가 난다.
- 틀린 예·반례
- 여러 account predicate나 histogram selectivity query를 실행하지 않는다.
- 착각 방지
- 90% synthetic hot ratio는 운영 분포가 아니다.
- 하지 않는 일
- plan과 index latency를 이 phase에서 측정하지 않는다.
- 다음 연결
- Benchmark가 한 disposable DB에서 before30과 after30을 이어 측정한다.
F06-C14 · Benchmark phase
- 문법 해부
- Benchmark가 before 30회→index 생성과 ANALYZE→after plan 1회→after 30회→assert·FindIndex·Quantile·relative gate를 고정 순서로 실행한다.
- 실제 값 추적
- 예를 들어 before median/p95=10/20, after=9/25여도 median 하나가 좋아서 gate를 통과하고 네 통계 값과 chosen index가 body에 들어간다.
- 정상 예
- after median과 p95가 둘 다 before 미만일 필요는 없고, 둘 중 하나만 작으면 다른 하나가 악화돼도 현재 OR 성격의 gate는 통과한다.
- 틀린 예·반례
- after median=10, p95=21처럼 둘 다 before 이상이면 no relative improvement 예외가 나며, 현재 Quantile .5는 pair-average median도 아니다.
- 착각 방지
- pass를 median·p95가 모두 개선됐다는 증거로 읽으면 실제 `-and` 실패 조건과 다르다.
- 하지 않는 일
- warmup·순서 무작위화·before/after interleave가 없어 warm cache와 시간 경과 bias를 통제하지 않으며 absolute SLA, pagination, write/storage 비용도 측정하지 않는다.
- 다음 연결
- finally가 owned로 간주한 Compose project와 volume을 성공·실패 모두에서 내린다.
F06-C15 · finally cleanup
- 문법 해부
- finally가 owned일 때 지정 ComposeProject로 docker compose down -v를 호출하고 cleanup native exit도 검사한다.
- 실제 값 추적
- Compose phase가 성공하거나 중간 throw해도 그 project의 container와 volume 제거를 시도한다.
- 정상 예
- owned Compose phase에서 down -v exit가 0이면 finally를 빠져나와 뒤 evidence write 단계로 진행한다.
- 틀린 예·반례
- 같은 project 이름을 다른 실행이 공유하거나 down exit가 nonzero이면 자원 충돌 또는 cleanup failed가 생긴다.
- 착각 방지
- owned=true는 project 이름의 실제 독점성을 검증한 결과가 아니라 이 run의 소유권 가정이다.
- 하지 않는 일
- Container mode의 외부 container 비삭제, process hard-kill 뒤 cleanup, filesystem durability는 이 finally가 보장하지 않는다.
- 다음 연결
- cleanup 뒤 phase body와 filename을 검사해 JSON과 Green marker를 쓴다.
F06-C16 · phase evidence file and Green marker
- 문법 해부
- null body를 거부하고 phase→evidence filename map으로 Write-JsonAtomic 후 W13_${Phase}_GREEN 문자열을 출력한다.
- 실제 값 추적
- BeforePlan은 before-day.json, Benchmark는 benchmark-manifest.json과 cleanup=1 marker를 남긴다.
- 정상 예
- 지원 phase가 body를 만들지 못하면 evidence 파일 없이 예외가 난다.
- 틀린 예·반례
- Green 문자열만 복사해 붙여도 실제 JSON/hash/cleanup이 증명되지는 않는다.
- 착각 방지
- 이 끝부분은 Prepare와 Report branch의 earlier exit에는 도달하지 않는다.
- 하지 않는 일
- strict 전체 final manifest와 predecessor index는 excluded D7 책임이다.
- 다음 연결
- inline D5 gate가 measured query의 제한된 top-N source boundary를 별도로 기록한다.
SourceHashes히토리 → 니지카 → 료 → 키타
-
히토리
hashes=5면 known-good 값과 비교까지 끝난 거야?
-
니지카
아니다. 다섯 current SHA를 계산해 기록할 뿐 expected 목록·비교·mismatch branch가 없다.
-
료
compose도 목록 밖이라 changed bytes를 hashes=5만으로 거절 못 해.
-
키타
기록과 baseline 검증을 분리해서 설명하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | Prepare + 깨끗한 evidence dir | compose file·Docker engine·service db 하나·future file 0을 검사한다. | prepare.json과 W13_PREPARE_GREEN이 생긴다. | DB container나 seed는 실행하지 않는다. |
| 2 | Benchmark + Compose | seed→before 30→index/ANALYZE→after plan→after 30→assert 순서로 실행한다. | before/after CSV와 benchmark-manifest body가 준비된다. | warm-up·random order·interleaving이 없다. |
| 3 | before=1..30, P=.5 | 정렬 후 Ceiling(15)-1=index14를 고른다. | bm은 15가 된다. | 통상 even-n median 15.5와 다르다. |
| 4 | bm=10,bp=20,am=11,ap=19 | am>=bm은 true지만 ap>=bp는 false라 AND 전체가 false다. | Benchmark guard를 통과한다. | median이 악화돼도 p95 하나가 개선되면 pass 가능하다. |
| 5 | Compose up 중간 실패 | up 전에 owned=true이고 terminating error가 try를 빠져나간다. | finally가 compose down -v를 시도한다. | cleanup 자체 실패가 원래 오류를 가릴 수 있다. |
| 6 | Report | 저장된 prepare/before/after/selectivity/benchmark JSON만 읽는다. | perf-manifest.json을 새로 조립한다. | DB query와 raw CSV 계산을 다시 하지 않는다. |
assert와 plan히토리 → 니지카 → 료 → 키타
-
히토리
assert가 성공했으니 index scan도 확인된 거지?
-
니지카
assert는 index 이름 한 건만 센다. FindIndex가 captured plan을 따로 본다.
-
료
존재와 사용을 섞으면 안 돼.
-
키타
AfterPlan의 두 gate를 각각 설명하겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
rooted predicate·GetFullPath·array·ordered map·JSON/CSV writer와 branch control을 수행한다.
IsPathRooted는 fully-qualified/ownership 검사가 아니고 native failure는 각 $LASTEXITCODE guard가 있어야 예외가 된다.고유 project로 db를 up --wait하고 owned일 때 down -v한다.
project uniqueness는 caller 계약이며 이름 충돌을 조회하지 않는다.psql이 seed·measure·index·assert SQL을 disposable schema에서 실행한다.
Container mode DB lifecycle과 데이터 삭제는 script가 소유하지 않는다.phase body JSON은 temp write 후 rename하고 raw CSV/plan은 직접 쓴다.
모든 artifact가 원자 저장되는 것은 아니며 concurrent writer lock도 없다.FindIndex가 Plans를 재귀 탐색해 첫 Index Name을 찾는다.
전체 node와 여러 index를 보존하지 않는다.정렬된 30개에서 Ceiling(30P)-1 nearest-rank index를 반환한다.
일반 median 정의·confidence interval·outlier policy가 아니다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ median은 30개 중 15번째와 16번째 평균이다.
왜 틀리나 source는 nearest-rank 배열 index 14를 직접 반환한다.
바르게 읽기 이 runner에서는 .5를 15번째 값이라고 읽는다.
반례 1..30 표본에서 결과는 15이지 15.5가 아니다.
❌ Benchmark 통과는 median과 p95가 둘 다 좋아졌다는 뜻이다.
왜 틀리나 throw 조건이 두 after 값 모두 before 이상일 때만 true다.
바르게 읽기 둘 중 하나라도 작아지면 통과할 수 있다고 표시한다.
반례 median 11>10, p95 19<20도 pass다.
❌ 30번 측정했으니 warm-up과 순서 bias가 제거됐다.
왜 틀리나 source는 before 30회를 먼저, index 뒤 after 30회를 나중에 순차 실행한다.
바르게 읽기 측정 설계를 그대로 공개하고 운영 결론을 좁힌다.
반례 cache가 뒤 실행에 유리해도 이를 분리할 randomization이 없다.
❌ SourceHashes가 다섯 파일을 known-good baseline과 비교해 고정한다.
왜 틀리나 expected digest 목록·equality 비교·mismatch throw가 없고 current value만 계산한다.
바르게 읽기 Prepare는 다섯 current hash를 기록할 뿐 변경 bytes를 거절하지 못하며 compose도 목록 밖이다.
반례 runner bytes가 바뀌어도 새 digest가 prepare.json에 기록되고 hash mismatch만으로 실패하지 않는다.
❌ IsPathRooted가 true면 fully-qualified absolute path다.
왜 틀리나 .NET에서는 rooted와 fully qualified가 다르고 C:Documents나 \Documents 같은 값이 rooted일 수 있다.
바르게 읽기 line 10은 root 없음만 거르고 line 13 GetFullPath가 current drive/directory 문맥으로 해석한다고 읽는다.
반례 직접 fully-qualified 입력을 요구하려면 IsPathFullyQualified 같은 별도 predicate가 필요하다.
❌ assert.sql이 index plan 사용을 증명한다.
왜 틀리나 assert는 pg_indexes 이름 count만 본다.
바르게 읽기 AfterPlan/Benchmark의 FindIndex 결과를 plan evidence로 사용한다.
반례 catalog에 index가 있어도 planner가 sequential scan을 고를 수 있다.
❌ Report가 최신 성능을 다시 측정한다.
왜 틀리나 Report는 saved JSON을 읽고 필드를 복사한다.
바르게 읽기 읽기 전용 aggregation으로 설명한다.
반례 raw CSV나 DB가 바뀌어도 Report는 query를 재실행하지 않는다.
Report 경계히토리 → 니지카 → 료 → 키타
-
히토리
Report를 누르면 benchmark가 다시 도나?
-
니지카
아니다. saved JSON 다섯 개를 읽어 perf-manifest로 복사한다.
-
료
D7 shared branch이고 strict 다섯 owner command 밖이야.
-
키타
읽기 전용 요약이라고 표시하겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
관례적 even-n median이나 통계적 유의성
이 책임을 맡는 곳: separate statistical analysis with declared estimatorwarm-up·randomization·interleaving·cache isolation
이 책임을 맡는 곳: expanded benchmark harnessmedian과 p95가 모두 개선됨
이 책임을 맡는 곳: stricter predicate if both improvements are required다섯 current hash가 expected digest와 일치하고 compose.yaml bytes도 검증됨
이 책임을 맡는 곳: freeze expected digests, compare/throw on mismatch, and include compose dependencyIsPathRooted 통과값이 fully-qualified이고 learner-owned임
이 책임을 맡는 곳: IsPathFullyQualified plus ownership/reparse-point policyassert.sql 자체가 plan selection을 증명함
이 책임을 맡는 곳: FindIndex on captured EXPLAIN JSONcursor 또는 OFFSET 성능과 의미론
이 책임을 맡는 곳: separate pagination benchmark and correctness tests절대 latency·concurrency·hardware 일반화
이 책임을 맡는 곳: production-like load and observabilityReport 호출이 DB와 query를 재실행함
이 책임을 맡는 곳: rerun owner phases or add live verificationSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 여섯 Phase 중 strict 다섯과 D7 Report를 구분한다.
- MeasurePlan의 pipeline과 30회 loop를 값 흐름으로 다시 쓴다.
- Quantile(.5/.95)의 exact 0-based index와 실제 순위를 계산한다.
- Benchmark AND 조건의 통과·실패 반례를 각각 만든다.
- try/finally와 owned flag의 partial-up cleanup 순서를 그린다.
- Report가 읽는 다섯 파일과 쓰는 한 파일을 적고 재실행이 아님을 설명한다.
2단계 · 코드 조각 재조립
- phase 하나씩
- rooted와 full path
- 30개 순위
- fail-closed 정리
- Report 읽기 전용
3단계 · 파일 전체 다시 쓰기
정본을 보지 않고 148줄 전체를 다시 쓰고 비공백 mapping·번역 143행과 source-local test 0개를 대조한다.
자가 점검
- 비공백 원문 143줄이 mapping과 번역에 정확히 한 번씩 있는지 본다.
- 각 줄의 비유·실제 의미·입력·직접 결과·한계가 서로 다른 역할을 하는지 읽는다.
- 고정 수치·파일명·분기 조건을 canonical source와 다시 대조한다.
- 직접 보장과 다음 layer 책임을 반례로 설명한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
param(
[Parameter(Mandatory=$true)][ValidateSet('Prepare','BeforePlan','AfterPlan','Selectivity','Benchmark','Report')][string]$Phase,
[ValidateSet('Compose','Container')][string]$Mode='Compose',
[string]$Container='',
[string]$ComposeProject='w13-perf-lab',
[Parameter(Mandatory=$true)][string]$EvidenceDir
)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
if(-not [IO.Path]::IsPathRooted($EvidenceDir)){
throw 'EvidenceDir must be an absolute learner-owned path'
}
$e=[IO.Path]::GetFullPath($EvidenceDir)
New-Item -ItemType Directory -Force $e|Out-Null
$compose=Join-Path $root 'compose.yaml'
$owned=$false
$dbPhases=@('BeforePlan','AfterPlan','Selectivity','Benchmark')
function Write-JsonAtomic([string]$Name,$Value){
$target=Join-Path $e $Name;$temporary="$target.tmp"
$Value|ConvertTo-Json -Depth 6|Set-Content -Encoding utf8 $temporary
Move-Item -LiteralPath $temporary -Destination $target -Force
}
function PsqlFile([string]$Relative){
Get-Content -Raw -LiteralPath (Join-Path $root $Relative)|docker exec -i $Container psql -X -v ON_ERROR_STOP=1 -U app -d financial_core
if($LASTEXITCODE-ne0){throw "psql file failed: $Relative exit=$LASTEXITCODE"}
}
function MeasurePlan([string]$Variant,[string]$Output){
$rows=@()
for($sample=1;$sample-le30;$sample++){
$json=Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core
if($LASTEXITCODE-ne0){throw "measure failed: phase=$Variant sample=$sample exit=$LASTEXITCODE"}
$object=$json|ConvertFrom-Json
$rows+=[pscustomobject]@{variant=$Variant;sample=$sample;ms=[double]$object[0].'Execution Time'}
}
$rows|Export-Csv -NoTypeInformation -Encoding utf8 -LiteralPath $Output
return $rows
}
function Quantile($Rows,[double]$P){
$values=@($Rows.ms|Sort-Object)
if($values.Count-ne30){throw "quantile requires 30 values, got $($values.Count)"}
return $values[[Math]::Min(29,[Math]::Ceiling(30*$P)-1)]
}
function FindIndex($Node){
if($Node.'Index Name'){return [pscustomobject]@{name=[string]$Node.'Index Name';type=[string]$Node.'Node Type'}}
foreach($child in @($Node.Plans)){$found=FindIndex $child;if($found){return $found}}
return $null
}
function SourceHashes(){
$files=@('scripts/run-w13-perf.ps1','sql/w13/seed.sql','sql/w13/measure.sql','sql/w13/create-index.sql','sql/w13/assert.sql')
return @($files|ForEach-Object{[pscustomobject]@{file=$_;sha256=(Get-FileHash -Algorithm SHA256 (Join-Path $root $_)).Hash.ToLowerInvariant()}})
}
if($Phase-eq'Prepare'){
if(!(Test-Path -LiteralPath $compose -PathType Leaf)){throw 'packaged compose.yaml missing'}
$futureFiles=@('before-day.json','before-plan.txt','after-day.json','after-plan.txt','selectivity-day.json','benchmark-manifest.json','before-raw.csv','after-raw.csv','benchmark-after-plan.txt','perf-manifest.json')
$stale=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf})
if($stale.Count-ne0){throw "stale W13 D2-D6 artifact exists before Prepare: $($stale -join ',')"}
if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-prepare-config-only'}
$docker=& docker version --format '{{.Server.Version}}'
if($LASTEXITCODE-ne0-or[string]::IsNullOrWhiteSpace($docker)){throw 'Docker engine is unavailable'}
$services=@(& docker compose -f $compose config --services)
if($LASTEXITCODE-ne0-or$services.Count-ne1-or$services[0]-cne'db'){throw "compose service contract mismatch: $services"}
$futureCount=@($futureFiles|Where-Object{Test-Path -LiteralPath (Join-Path $e $_) -PathType Leaf}).Count
if($futureCount-ne0){throw "Prepare created a future W13 artifact: count=$futureCount"}
$body=[ordered]@{phase='Prepare';docker_server=[string]$docker;compose_services=@($services);hashes=SourceHashes;native_exit=0;future_artifacts_created=$futureCount}
Write-JsonAtomic 'prepare.json' $body
'W13_PREPARE_GREEN compose=db hashes=5 future_artifacts=0 native_exit=0'
exit 0
}
if($Phase-eq'Report'){
$prepare=Get-Content -Raw (Join-Path $e 'prepare.json')|ConvertFrom-Json
$before=Get-Content -Raw (Join-Path $e 'before-day.json')|ConvertFrom-Json
$after=Get-Content -Raw (Join-Path $e 'after-day.json')|ConvertFrom-Json
$selectivity=Get-Content -Raw (Join-Path $e 'selectivity-day.json')|ConvertFrom-Json
$benchmark=Get-Content -Raw (Join-Path $e 'benchmark-manifest.json')|ConvertFrom-Json
if($prepare.future_artifacts_created-ne0-or$before.cleanup-ne1-or$after.cleanup-ne1-or$selectivity.cleanup-ne1-or$benchmark.cleanup-ne1){throw 'W13 predecessor phase/cleanup contract invalid'}
if($benchmark.before_rows-ne30-or$benchmark.after_rows-ne30-or$benchmark.chosen_index-cne'idx_ledger_account_created_id'){throw 'W13 benchmark contract invalid'}
$body=[ordered]@{
schema='perf_w13';query_kind='top-N';accounts=$selectivity.accounts;transactions=$selectivity.transactions;ledger=$selectivity.ledger;hot=$selectivity.hot
before_rows=$benchmark.before_rows;after_rows=$benchmark.after_rows
before_median_ms=$benchmark.before_median_ms;before_p95_ms=$benchmark.before_p95_ms;after_median_ms=$benchmark.after_median_ms;after_p95_ms=$benchmark.after_p95_ms
chosen_index=$benchmark.chosen_index;plan_node=$benchmark.plan_node
verdict='relative improvement observed on this deterministic lab; no absolute latency claim';native_exit=0;cleanup=1;hashes=@($prepare.hashes)
phase_evidence=@('prepare.json','before-day.json','after-day.json','selectivity-day.json','benchmark-manifest.json')
}
Write-JsonAtomic 'perf-manifest.json' $body
"W13_REPORT_GREEN phases=5 before=30 after=30 index=$($body.chosen_index) hashes=5 cleanup=1 native_exit=0"
exit 0
}
if($Phase-notin$dbPhases){throw "unsupported W13 phase=$Phase"}
$body=$null
try{
if($Mode-eq'Compose'){
if($Container){throw 'Compose mode rejects -Container'}
if([string]::IsNullOrWhiteSpace($env:FCL_DB_PASSWORD)){$env:FCL_DB_PASSWORD='w13-disposable-password'}
# The unique Compose project is ours before startup; a partial `up`
# failure must still be cleaned by finally.
$owned=$true
& docker compose -f $compose -p $ComposeProject up -d --wait db
if($LASTEXITCODE-ne0){throw "compose up failed: phase=$Phase exit=$LASTEXITCODE"}
$Container=(& docker compose -f $compose -p $ComposeProject ps -q db).Trim()
}elseif([string]::IsNullOrWhiteSpace($Container)){throw 'Container mode requires -Container'}
if([string]::IsNullOrWhiteSpace($Container)){throw 'PostgreSQL container resolution failed'}
PsqlFile 'sql/w13/seed.sql'|Out-Null
if($Phase-eq'BeforePlan'){
$path=Join-Path $e 'before-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path
if($LASTEXITCODE-ne0){throw 'before plan query failed'}
$plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan
$body=[ordered]@{phase='BeforePlan';top_node=[string]$plan.'Node Type';actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1}
}elseif($Phase-eq'AfterPlan'){
PsqlFile 'sql/w13/create-index.sql'|Out-Null
$path=Join-Path $e 'after-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $path
if($LASTEXITCODE-ne0){throw 'after plan query failed'}
PsqlFile 'sql/w13/assert.sql'|Out-Null
$plan=(Get-Content -Raw $path|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan
if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'-or$chosen.type-notin@('Index Scan','Index Only Scan')){throw "after plan index mismatch: $($chosen.name)/$($chosen.type)"}
$body=[ordered]@{phase='AfterPlan';chosen_index=$chosen.name;plan_node=$chosen.type;actual_rows=[int]$plan.'Actual Rows';plan_sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant();native_exit=0;cleanup=1}
}elseif($Phase-eq'Selectivity'){
$raw=(& docker exec $Container psql -X -qAt -F '|' -v ON_ERROR_STOP=1 -U app -d financial_core -c "SELECT (SELECT count(*) FROM perf_w13.account),(SELECT count(*) FROM perf_w13.business_tx),(SELECT count(*) FROM perf_w13.ledger_entry),(SELECT count(*) FROM perf_w13.ledger_entry WHERE account_id=1)").Trim()
if($LASTEXITCODE-ne0){throw 'selectivity count query failed'}
$values=$raw.Split('|');if($values.Count-ne4-or[int]$values[0]-ne1000-or[int]$values[1]-ne100000-or[int]$values[2]-ne100000-or[int]$values[3]-ne90000){throw "selectivity counts mismatch: $raw"}
$body=[ordered]@{phase='Selectivity';accounts=1000;transactions=100000;ledger=100000;hot=90000;hot_ratio=.9;native_exit=0;cleanup=1}
}elseif($Phase-eq'Benchmark'){
$before=MeasurePlan before (Join-Path $e 'before-raw.csv')
PsqlFile 'sql/w13/create-index.sql'|Out-Null
$afterPath=Join-Path $e 'benchmark-after-plan.txt'
Get-Content -Raw (Join-Path $root 'sql/w13/measure.sql')|docker exec -i $Container psql -X -qAt -v ON_ERROR_STOP=1 -U app -d financial_core|Set-Content -Encoding utf8 $afterPath
if($LASTEXITCODE-ne0){throw 'benchmark after plan failed'}
$after=MeasurePlan after (Join-Path $e 'after-raw.csv');PsqlFile 'sql/w13/assert.sql'|Out-Null
$plan=(Get-Content -Raw $afterPath|ConvertFrom-Json)[0].Plan;$chosen=FindIndex $plan
$bm=Quantile $before .5;$bp=Quantile $before .95;$am=Quantile $after .5;$ap=Quantile $after .95
if(!$chosen-or$chosen.name-cne'idx_ledger_account_created_id'){throw 'benchmark chosen index mismatch'}
if($am-ge$bm-and$ap-ge$bp){throw "no relative improvement: median=$bm->$am p95=$bp->$ap"}
$body=[ordered]@{phase='Benchmark';before_rows=30;after_rows=30;before_median_ms=$bm;before_p95_ms=$bp;after_median_ms=$am;after_p95_ms=$ap;chosen_index=$chosen.name;plan_node=$chosen.type;native_exit=0;cleanup=1}
}
}finally{
if($owned){& docker compose -f $compose -p $ComposeProject down -v|Out-Null;if($LASTEXITCODE-ne0){throw "compose cleanup failed: phase=$Phase"}}
}
if($null-eq$body){throw "W13 phase produced no evidence: $Phase"}
$file=@{BeforePlan='before-day.json';AfterPlan='after-day.json';Selectivity='selectivity-day.json';Benchmark='benchmark-manifest.json'}[$Phase]
Write-JsonAtomic $file $body
"W13_${Phase}_GREEN native_exit=0 cleanup=1 evidence=$file"
07W13 D5 pagination scope gate — top-N 토큰만 확인하고 미측정 범위를 기록
inline/w13-d5-pagination-scope.ps1
PowerShell 인라인 Gate · 정본 · W13-F078줄 연결8줄 번역5 chunks
W13 D5 pagination scope gate — top-N 토큰만 확인하고 미측정 범위를 기록
inline/w13-d5-pagination-scope.ps1
PowerShell 인라인 Gate · 정본 · W13-F07STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
owner query가 latest top-N 모양인지 텍스트로 확인하고 OFFSET·cursor benchmark를 하지 않았다는 범위를 JSON과 marker로 명시한다.
- 이 gate가 찾는 세 필수 토큰은 무엇일까?
- OFFSET 부재 검사는 어떤 방식이며 무엇을 놓칠까?
- cursor_benchmark=false는 cursor가 느리다는 뜻일까?
- 이 코드는 SQL을 실제로 실행할까?
tokens=ORDER BY created_at DESC | id DESC | LIMIT 50forbidden regex=(?i)\bOFFSET\bverified=top-N onlyoffset_benchmark=falsecursor_benchmark=falseoutput=evidence\w13\pagination-scope.jsonSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY는 top-N 악보를 확인했지만 pagination 전체를 실험한 것처럼 말하지 않으려 한다.
이번 실험의 범위표 붙이기
measure.sql 텍스트에서 created_at DESC, id DESC, LIMIT 50 세 토큰을 찾고 OFFSET 단어가 있으면 실패한다. 이 검사는 SQL parser나 DB 실행이 아니라 문자열 gate다.
성공하면 top-N만 검증했고 offset·cursor benchmark는 하지 않았다는 false 값을 source hash와 함께 JSON에 쓴다. 미측정은 실패나 성능 열세를 뜻하지 않는다.
딱 여기까지만 악보 단어 찾기 비유는 텍스트 포함 여부만 설명하며 cursor 의미론·결과 정확성·성능 실행을 증명하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
세 토큰
정렬 두 조각과 LIMIT 50 문자열이 모두 있어야 통과한다.
- 코드 연결
3줄- 비유
- 악보에서 세 지시문 찾기
- 비유의 끝
- 올바른 SQL 위치나 account filter는 검사하지 않는다.
OFFSET 금지
대소문자 무관 단어 경계 정규식으로 OFFSET을 찾으면 실패한다.
- 코드 연결
4줄- 비유
- 금지 표찰 한 단어 찾기
- 비유의 끝
- 주석의 OFFSET도 잡을 수 있고 다른 pagination 문법은 모른다.
범위 JSON
검증한 top-N과 측정하지 않은 offset/cursor를 false로 기록한다.
- 코드 연결
5~7줄- 비유
- 한 일과 안 한 일을 확인표에 나눠 적기
- 비유의 끝
- false는 성능 판정이 아니다.
세 토큰히토리 → 니지카 → 료 → 키타
-
히토리
ORDER BY 한 줄 전체를 exact match하는 거야?
-
니지카
아니다. created_at DESC, id DESC, LIMIT 50 세 부분 문자열을 각각 찾는다.
-
료
문장 구조가 아니라 토큰 포함 gate야.
-
키타
검사 단위를 세 문자열로 적겠습니다.
OFFSET regex히토리 → 니지카 → 료 → 키타
-
히토리
소문자 offset이면 빠져나가지 않아?
-
니지카
(?i)가 대소문자를 무시하고 word boundary가 단어를 찾는다.
-
료
대신 주석 속 단어도 잡을 수 있어.
-
키타
regex의 장점과 오탐 경계를 같이 쓰겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 8줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F07-L01 | $Sql= |
히토리가 기준 악보함에서 이번 top-N 악보의 정확한 서랍 위치를 찾아 손에 든다. | Join-Path가 외부에서 주어진 ReferenceRoot와 Windows 상대 경로를 결합해 검사 대상 measure.sql을 가리킨다.
|
| 2줄F07-L02 | $Text= |
니지카가 top-N 악보를 줄마다 넘기지 않고 한 장의 긴 내용으로 펼친다. | Get-Content -Raw가 $Sql 파일의 모든 문자를 읽어 정규식 검사용 $Text 문자열을 만든다.
|
| 3줄F07-L03 | foreach( |
료가 악보에서 ‘최신 시각부터’, ‘큰 번호부터’, ‘50장만’이라는 세 지시를 하나씩 찾아 없으면 즉시 중단한다. | foreach가 세 고정 문자열을 순회하고 case-sensitive escaped regex로 $Text 포함 여부를 검사한다.
|
| 4줄F07-L04 | if( |
키타가 악보 어디든 ‘OFFSET’ 표찰이 보이면 이번 공연은 건너뛰기 실험이 아니라며 막는다. | 단어 경계가 있는 (?i) OFFSET 정규식으로 $Text를 검사해 발견 시 throw한다.
|
| 5줄F07-L05 | $Out= |
검사 결과를 학생 증거함의 W13 칸에 둘 정확한 파일 자리를 정한다. | Join-Path가 LearnerRoot와 evidence\w13\pagination-scope.json을 결합해 출력 대상을 계산한다.
|
| 6줄F07-L06 | $Body= |
니지카가 확인표에 ‘top-N만’, ‘OFFSET 측정 안 함’, ‘cursor 측정 안 함’과 악보 지문을 순서대로 적는다. | ordered hashtable이 verified, 두 false 플래그, measure.sql의 소문자 SHA-256을 JSON 필드 순서대로 준비한다.
|
| 7줄F07-L07 | $Body|ConvertTo-Json|Set-Content -Encoding utf8 $Out |
료가 네 칸 확인표를 JSON 봉투로 접어 앞에서 정한 증거함 자리에 넣는다. | ConvertTo-Json 출력이 pipeline으로 Set-Content에 전달되어 $Out 파일을 생성하거나 덮어쓴다.
|
| 8줄F07-L08 | 'W13_PAGINATION_SCOPE top-N-only offset= |
키타가 ‘이번 무대는 top-N뿐’이라는 짧은 초록 멘트를 마지막에 읽는다. | PowerShell success stream에 W13_PAGINATION_SCOPE top-N-only offset=false cursor=false 문자열을 보낸다.
|
cursor false히토리 → 니지카 → 료 → 키타
-
히토리
false니까 cursor 방식이 실패한 거지?
-
니지카
아니다. cursor benchmark를 하지 않았다는 기록이다.
-
료
미실행과 성능 실패는 전혀 달라.
-
키타
false를 측정 범위로 읽겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
$Sql=Join-Path $ReferenceRoot 'sql\w13\measure.sql'
$Text=Get-Content -Raw $Sql
foreach($Token in @('ORDER BY created_at DESC','id DESC','LIMIT 50')){if($Text-cnotmatch[regex]::Escape($Token)){throw "measure.sql missing $Token"}}
if($Text-cmatch'(?i)\bOFFSET\b'){throw 'owner query unexpectedly contains OFFSET'}
$Out=Join-Path $LearnerRoot 'evidence\w13\pagination-scope.json'
$Body=[ordered]@{verified='top-N only';offset_benchmark=$false;cursor_benchmark=$false;source_sha256=(Get-FileHash $Sql -Algorithm SHA256).Hash.ToLowerInvariant()}
$Body|ConvertTo-Json|Set-Content -Encoding utf8 $Out
'W13_PAGINATION_SCOPE top-N-only offset=false cursor=false'
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 8줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | $Sql=Join-Path $ReferenceRoot 'sql\w13\measure.sql' | ReferenceRoot 아래 sql\w13\measure.sql의 절대 후보 경로를 Sql에 만든다. |
| 2 | $Text=Get-Content -Raw $Sql | measure.sql 전체 텍스트를 한 문자열로 읽어 Text에 넣는다. |
| 3 | foreach($Token in @('ORDER BY created_at DESC','id DESC','LIMIT 50')){if($Text-cnotmatch[regex]::Escape($Token)){throw "measure.sql missing $Token"}} | ORDER BY created_at DESC, id DESC, LIMIT 50 세 토큰이 각각 없으면 해당 토큰 이름으로 실패한다. |
| 4 | if($Text-cmatch'(?i)\bOFFSET\b'){throw 'owner query unexpectedly contains OFFSET'} | 대소문자와 무관하게 OFFSET 단어가 있으면 owner query 범위 위반으로 실패한다. |
| 5 | $Out=Join-Path $LearnerRoot 'evidence\w13\pagination-scope.json' | LearnerRoot 아래 evidence\w13\pagination-scope.json 출력 경로를 만든다. |
| 6 | $Body=[ordered]@{verified='top-N only';offset_benchmark=$false;cursor_benchmark=$false;source_sha256=(Get-FileHash $Sql -Algorithm SHA256).Hash.ToLowerInvariant()} | top-N만 검증했고 offset·cursor benchmark는 하지 않았다는 값과 SQL SHA-256을 ordered body에 담는다. |
| 7 | $Body|ConvertTo-Json|Set-Content -Encoding utf8 $Out | Body를 JSON으로 바꿔 UTF-8 pagination-scope.json에 기록한다. |
| 8 | 'W13_PAGINATION_SCOPE top-N-only offset=false cursor=false' | top-N 전용이고 offset·cursor가 false인 고정 성공 marker를 출력한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기measure.sql의 top-N 세 문자열과 OFFSET 부재만 정적으로 확인해 top-N-only JSON을 쓰며 cursor·OFFSET 의미론이나 DB 성능은 의도적으로 증명하지 않는다.
문법 해부
- Get-Content -Raw는 파일 전체를 하나의 문자열로 읽는다.
- foreach는 세 Token을 순서대로 검사한다.
- [regex]::Escape는 token의 문자를 literal 정규식으로 만든다.
- -cnotmatch는 대소문자를 구분하고 -cmatch의 (?i)는 해당 regex를 대소문자 무관으로 만든다.
- [ordered]@{...}는 JSON field 순서를 안정적으로 유지한다.
실행 순서
- ReferenceRoot에서 measure.sql 경로를 만든다.
- 파일을 한 문자열로 읽는다.
- 세 case-sensitive token이 모두 있는지 검사한다.
- case-insensitive OFFSET word가 없는지 확인한다.
- source SHA와 세 scope field를 ordered body에 담는다.
- pagination-scope.json을 직접 쓰고 고정 marker를 출력한다.
원래 W6 수준의 조각별 정밀 해설
F07-C01 · locate and read measure.sql
- 문법 해부
- Join-Path로 canonical measure.sql을 찾고 Get-Content -Raw로 전체 text를 읽는다.
- 실제 값 추적
- D5 gate가 검사할 $Sql과 $Text가 exact measured source를 가리킨다.
- 정상 예
- measure.sql 한 byte 변경은 뒤 source_sha256에도 반영된다.
- 틀린 예·반례
- ReferenceRoot가 잘못되면 파일 읽기에서 실패한다.
- 착각 방지
- 읽기만으로 query 실행·plan·rows는 확인되지 않는다.
- 하지 않는 일
- account_id predicate 요구 여부는 다음 token gate가 결정해야 한다.
- 다음 연결
- 세 top-N token 존재 검사가 현재 source 모양을 확인한다.
F07-C02 · require three top-N tokens
- 문법 해부
- foreach가 ORDER BY created_at DESC, id DESC, LIMIT 50 세 literal token을 case-sensitive regex escape로 요구한다.
- 실제 값 추적
- 현재 measure.sql은 세 문자열을 모두 포함해 loop를 통과한다.
- 정상 예
- LIMIT 50을 LIMIT 500으로 바꾸면 missing LIMIT 50 예외가 난다.
- 틀린 예·반례
- account_id=1을 삭제해도 세 token이 남으면 이 gate는 통과할 수 있다.
- 착각 방지
- token 존재는 SQL parse tree나 query 실행 결과를 증명하지 않는다.
- 하지 않는 일
- cursor/keyset predicate 부재를 검사하지 않는다.
- 다음 연결
- OFFSET token rejection이 명시한 한 가지 금지 패턴을 확인한다.
F07-C03 · reject OFFSET token
- 문법 해부
- case-insensitive word-boundary regex가 OFFSET 단어가 있으면 즉시 throw한다.
- 실제 값 추적
- 현재 exact measure.sql에는 OFFSET이 없어 통과한다.
- 정상 예
- `OFFSET 50000`을 추가하면 owner query unexpectedly contains OFFSET이 난다.
- 틀린 예·반례
- cursor predicate `(created_at,id)<(...)`는 OFFSET 없이도 들어갈 수 있어 잡히지 않는다.
- 착각 방지
- OFFSET 부재를 pagination 전략 비교 benchmark로 해석하면 안 된다.
- 하지 않는 일
- DB plan이나 page 안정성은 이 정적 검사 책임이 아니다.
- 다음 연결
- evidence body가 제한된 검사 결과와 source hash를 JSON으로 쓴다.
F07-C04 · write scope evidence and source hash
- 문법 해부
- Out 경로와 ordered body를 만들고 ConvertTo-Json/Set-Content로 pagination-scope.json을 저장한다.
- 실제 값 추적
- verified=top-N only, offset_benchmark=false, cursor_benchmark=false와 measure source hash가 기록된다.
- 정상 예
- LearnerRoot/evidence/w13 directory가 미리 있으면 현재 source에서 JSON 파일이 생성된다.
- 틀린 예·반례
- 부모 directory가 없으면 Set-Content가 실패하고 이 inline body는 directory를 만들어 주지 않는다.
- 착각 방지
- cursor_benchmark=false는 cursor syntax를 검사한 결과가 아니라 hardcoded 값이다.
- 하지 않는 일
- 원자 tmp-rename, stale artifact 검사, account_id=1 요구는 이 세 줄에 없다.
- 다음 연결
- 마지막 문자열이 top-N-only Green marker를 표준 출력한다.
F07-C05 · Green marker
- 문법 해부
- 단일 문자열 expression이 W13_PAGINATION_SCOPE와 offset=false/cursor=false marker를 출력한다.
- 실제 값 추적
- 앞 token 검사와 JSON write가 성공했을 때 로그 마지막에 marker가 보인다.
- 정상 예
- 이 문자열만 수동 출력하면 source 검사 없이도 같은 글자가 나온다.
- 틀린 예·반례
- marker의 cursor=false를 실제 keyset benchmark 결과로 읽으면 안 된다.
- 착각 방지
- 표준 출력은 evidence file hash나 실행 환경을 seal하지 않는다.
- 하지 않는 일
- D5는 DB를 띄우거나 query latency를 측정하지 않는다.
- 다음 연결
- Q15 illustrative SQL은 별도 workbook fixture 가정 아래 집계 예를 보여 준다.
실행 여부히토리 → 니지카 → 료 → 키타
-
히토리
이 code가 measure.sql을 DB에 보내나?
-
니지카
Get-Content로 text와 hash만 읽고 JSON을 쓴다. docker나 psql 호출은 없다.
-
료
정적 gate이지 실행 test가 아니야.
-
키타
성능 증거로 과장하지 않겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 현재 canonical measure.sql | 세 token을 순서대로 찾고 OFFSET regex를 검사한다. | verified=top-N only JSON과 Green marker가 나온다. | DB query는 실행되지 않는다. |
| 2 | LIMIT 25로 변경 | LIMIT 50 token이 case-sensitive match에 실패한다. | measure.sql missing LIMIT 50 예외가 난다. | 다른 limit이 틀렸다는 일반 규칙이 아니라 owner contract 불일치다. |
| 3 | SQL 주석에 OFFSET 추가 | (?i) word-boundary regex가 주석 문자열도 찾는다. | owner query unexpectedly contains OFFSET으로 실패할 수 있다. | SQL parser가 아니므로 comment context를 구분하지 않는다. |
| 4 | cursor condition 추가 | 세 token이 남고 OFFSET이 없으면 text gate를 통과할 수 있다. | cursor_benchmark=false로 JSON이 기록된다. | cursor correctness나 실제 사용 여부를 판별하지 않는다. |
cursor 의미론히토리 → 니지카 → 료 → 키타
-
히토리
OFFSET이 없고 id DESC가 있으면 keyset 완성 아닌가?
-
니지카
cursor 값과 비교 predicate, 다음 page 결과를 전혀 검사하지 않는다.
-
료
D5 gate가 보장하는 건 top-N scope뿐이야.
-
키타
별도 correctness test 책임을 표시할게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
두 root를 기준으로 source·output path를 만들고 text/JSON을 읽고 쓴다.
output parent directory 생성과 atomic rename이 없다.escaped exact tokens와 OFFSET word-boundary pattern을 문자열에 적용한다.
SQL grammar와 comment/string literal을 이해하지 않는다.measure.sql bytes의 SHA-256을 lowercase로 저장한다.
ReferenceRoot 전체나 runner source는 hash하지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ gate 통과는 cursor pagination이 구현됐다는 뜻이다.
왜 틀리나 필수 token 세 개와 OFFSET 부재만 검사한다.
바르게 읽기 verified 값을 top-N only로 읽는다.
반례 cursor predicate가 없어도 canonical file은 통과한다.
❌ cursor_benchmark=false는 cursor가 더 느리다는 결과다.
왜 틀리나 false는 benchmark를 하지 않았다는 범위 표지다.
바르게 읽기 미측정과 실패 판정을 구분한다.
반례 측정값 없이 어느 방식이 빠른지 비교할 수 없다.
❌ 텍스트 gate가 SQL 결과 정확성을 보장한다.
왜 틀리나 DB parser나 psql을 호출하지 않는다.
바르게 읽기 정적 scope check라고 표시한다.
반례 토큰을 주석에만 넣어도 포함 검사는 통과할 수 있다.
❌ OFFSET만 없으면 모든 keyset 의미론이 맞다.
왜 틀리나 account filter·cursor predicate·동률 경계·중복/누락을 검사하지 않는다.
바르게 읽기 별도 pagination correctness test가 필요하다.
반례 id DESC 문자열만 있어도 cursor parameter가 없을 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
measure.sql이 PostgreSQL에서 성공하고 50행을 반환함
이 책임을 맡는 곳: runner psql execution and result assertionsnext page가 중복·누락 없이 이어짐
이 책임을 맡는 곳: fixture-bound cursor pagination testsOFFSET이 cursor보다 느리거나 빠름
이 책임을 맡는 곳: separate paired benchmarkaccount_id=1 predicate와 모든 ORDER BY 위치가 정확함
이 책임을 맡는 곳: SQL parser/AST or exact source assertionpagination-scope.json이 partial write 없이 교체됨
이 책임을 맡는 곳: temporary file plus atomic rename writerSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 세 필수 token과 OFFSET regex를 정확히 적는다.
- case-sensitive token 검사와 case-insensitive OFFSET 검사를 구분한다.
- verified·offset_benchmark·cursor_benchmark 세 field의 뜻을 말한다.
- text gate가 증명하지 않는 DB 실행·cursor correctness를 반례로 설명한다.
2단계 · 코드 조각 재조립
- 세 토큰
- OFFSET 금지
- 범위 JSON
3단계 · 파일 전체 다시 쓰기
정본을 보지 않고 8줄 전체를 다시 쓰고 비공백 mapping·번역 8행과 source-local test 0개를 대조한다.
자가 점검
- 비공백 원문 8줄이 mapping과 번역에 정확히 한 번씩 있는지 본다.
- 각 줄의 비유·실제 의미·입력·직접 결과·한계가 서로 다른 역할을 하는지 읽는다.
- 고정 수치·파일명·분기 조건을 canonical source와 다시 대조한다.
- 직접 보장과 다음 layer 책임을 반례로 설명한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
$Sql=Join-Path $ReferenceRoot 'sql\w13\measure.sql'
$Text=Get-Content -Raw $Sql
foreach($Token in @('ORDER BY created_at DESC','id DESC','LIMIT 50')){if($Text-cnotmatch[regex]::Escape($Token)){throw "measure.sql missing $Token"}}
if($Text-cmatch'(?i)\bOFFSET\b'){throw 'owner query unexpectedly contains OFFSET'}
$Out=Join-Path $LearnerRoot 'evidence\w13\pagination-scope.json'
$Body=[ordered]@{verified='top-N only';offset_benchmark=$false;cursor_benchmark=$false;source_sha256=(Get-FileHash $Sql -Algorithm SHA256).Hash.ToLowerInvariant()}
$Body|ConvertTo-Json|Set-Content -Encoding utf8 $Out
'W13_PAGINATION_SCOPE top-N-only offset=false cursor=false'
08Q15 학습용 SQL — 계좌별 입금·출금 방향을 한 번 정하고 두 합계를 나란히 보기
illustrative/sql/W13-SQL-Q15.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F0828줄 연결28줄 번역3 chunks
Q15 학습용 SQL — 계좌별 입금·출금 방향을 한 번 정하고 두 합계를 나란히 보기
illustrative/sql/W13-SQL-Q15.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F08STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
각 원장 행의 방향과 양수를 directed CTE에서 한 번 정한 뒤 계좌별 입금 합계와 출금 합계를 한 행에 놓는다.
- 왜 원장 방향을 CTE에서 한 번만 분류하나?
- 왜 account에서 LEFT JOIN해 원장 없는 계좌도 남기나?
- 왜 OTHER amount를 0으로 두고 두 FILTER에서 제외하나?
DEPOSIT/TRANSFER_IN=INWITHDRAW/TRANSFER_OUT=OUTaccount101 totals=1000/1000fixture accounts=8W13-SQL-Q15 · 방향을 한 번만 정하는 이유히토리 → 니지카 → 료 → 키타
-
히토리
입금 합계와 출금 합계를 바로 CASE 두 개로 더하면 안 되나요?
-
니지카
원장 한 행의 direction과 amount를 directed CTE에서 먼저 고정하면 두 합계가 같은 분류 규칙을 재사용해.
-
료
OPENING과 REVERSAL을 OTHER·0으로 둔다는 가정은 공식 정책이 아니라는 표찰이 필요해.
-
키타
fixture account 101에서 DEPOSIT 1000은 IN·1000, WITHDRAW -500은 OUT·500, OPENING 10000은 OTHER·0으로 추적돼요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
스타리 영수증 분류대와 여덟 계좌 상자
Q15 학습용 SQL — 계좌별 입금·출금 방향을 한 번 정하고 두 합계를 나란히 보기
니지카는 원장 영수증마다 입금·출금·기타 도장을 한 번만 찍어 directed 바구니에 넣는다.
키타는 여덟 계좌 상자를 먼저 늘어놓고 각 바구니 금액을 따로 더해 빈 상자에도 0원 표를 붙인다.
딱 여기까지만 공식 정답이 아니라 명시한 분류 가정의 예시다
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
방향 한 번 정하기
fixture의 DEPOSIT 1000·WITHDRAW -500도 두 CASE가 IN·1000과 OUT·500으로 한 번 정한다.
- 코드 연결
8~21줄- 비유
- 스타리 영수증 분류대와 여덟 계좌 상자에서 방향 한 번 정하기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 fixture의 DEPOSIT 1000·WITHDRAW -500도 두 CASE가 IN·1000과 OUT·500으로 한 번 정한다. 그 밖의 정책과 정본성은 증명하지 않는다.
빈 계좌 살리기
account에서 LEFT JOIN하므로 원장 0건인 103·104도 0·0 행으로 남는다.
- 코드 연결
22~28줄- 비유
- 스타리 영수증 분류대와 여덟 계좌 상자에서 빈 계좌 살리기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 account에서 LEFT JOIN하므로 원장 0건인 103·104도 0·0 행으로 남는다. 그 밖의 정책과 정본성은 증명하지 않는다.
입금·출금 따로 더하기
account 101에서 FILTER가 IN 1000과 OUT 500·300·200을 서로 다른 SUM에 넣는다.
- 코드 연결
24~25줄- 비유
- 스타리 영수증 분류대와 여덟 계좌 상자에서 입금·출금 따로 더하기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 account 101에서 FILTER가 IN 1000과 OUT 500·300·200을 서로 다른 SUM에 넣는다. 그 밖의 정책과 정본성은 증명하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 28줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F08-L01 | -- W13-SQL-Q15 illustrative example; |
Q15 연습 악보 겉면에 ‘학습용 편곡’ 도장을 찍는다. | 이 SQL이 W13-SQL-Q15를 위해 새로 만든 illustrative example이며 canonical answer가 아님을 선언한다.
|
| 2줄F08-L02 | -- Assumption: |
공연 규칙판에 OPENING·REVERSAL 표는 평범한 입출금 표에서 빼겠다고 적는다. | 격리 workbook schema를 쓰며 OPENING과 REVERSAL을 일반 현금 입출금으로 분류하지 않는다는 가정을 고정한다.
|
| 3줄F08-L03 | -- Expected cardinality: |
좌석표에 계좌마다 한 자리씩, 준비된 무대에는 여덟 자리라고 써 둔다. | 출력 cardinality를 계좌당 한 행으로 예상하고 제공 fixture에서는 8행이라고 밝힌다.
|
| 4줄F08-L04 | -- This is one auditable learning example, |
연습용 악보 봉투에 ‘교재가 나눠 준 정답지 아님’이라고 한 번 더 봉인한다. | 이 파일이 감사 가능한 학습 예시 하나일 뿐 workbook이 제공한 정답이 아님을 재확인한다.
|
| 5줄F08-L05 | \set ON_ERROR_STOP on |
음 하나가 틀리면 리허설을 즉시 멈추는 psql 정지 스위치를 켠다. | psql에서 뒤 명령 하나라도 실패하면 계속 진행하지 않도록 ON_ERROR_STOP을 켠다.
|
| 6줄F08-L06 | SET search_path TO : |
이번 합주가 사용할 격리 악보함을 workbook_schema, 그다음을 public 순서로 지정한다. | psql 변수 workbook_schema가 가리키는 schema를 첫 search_path로 두고 public을 보조 경로로 둔다.
|
| 8줄F08-L08 | WITH directed AS ( |
원장표를 한 번만 읽어 방향표를 붙일 directed 준비 무대를 연다. | directed라는 CTE를 시작해 각 ledger_entry의 방향과 양수를 한 번 계산한다.
|
| 9줄F08-L09 | SELECT account_id, |
각 영수증에 어느 계좌 상자에서 왔는지 account_id 꼬리표를 남긴다. | directed CTE 결과에 원장 행의 account_id를 그대로 투영한다.
|
| 10줄F08-L10 | CASE |
첫 번째 분류판을 펼쳐 영수증 종류를 IN·OUT·OTHER 중 하나로 보낼 준비를 한다. | entry_type을 방향 문자열로 바꾸는 첫 CASE 표현식을 시작한다.
|
| 11줄F08-L11 | WHEN entry_type IN ( |
DEPOSIT와 TRANSFER_IN 표를 받은 영수증을 입금함 IN으로 보낸다. | entry_type이 DEPOSIT 또는 TRANSFER_IN이면 direction을 IN으로 만든다.
|
| 12줄F08-L12 | WHEN entry_type IN ( |
WITHDRAW와 TRANSFER_OUT 표는 출금함 OUT으로 보낸다. | entry_type이 WITHDRAW 또는 TRANSFER_OUT이면 direction을 OUT으로 만든다.
|
| 13줄F08-L13 | ELSE 'OTHER' |
앞의 네 표가 아니면 폐기하지 않고 OTHER 바구니에 따로 둔다. | 앞 조건에 들지 않은 entry_type의 direction을 OTHER로 지정한다.
|
| 14줄F08-L14 | END AS direction, |
방향 분류판을 접고 결과 칸 이름을 direction으로 붙인다. | 첫 CASE를 닫아 계산 열의 별칭을 direction으로 정한다.
|
| 15줄F08-L15 | CASE |
두 번째 계산판을 펼쳐 출금도 양수 합계로 바꿀 준비를 한다. | 집계용 amount를 만드는 두 번째 CASE 표현식을 시작한다.
|
| 16줄F08-L16 | WHEN entry_type IN ( |
입금 표의 숫자는 영수증에 적힌 signed_amount를 그대로 옮긴다. | DEPOSIT 또는 TRANSFER_IN이면 signed_amount를 amount로 사용한다.
|
| 17줄F08-L17 | WHEN entry_type IN ( |
출금 영수증의 음수 표시는 방향 합계에서 양수로 세도록 부호를 뒤집는다. | WITHDRAW 또는 TRANSFER_OUT이면 -signed_amount를 amount로 사용한다.
|
| 18줄F08-L18 | ELSE 0 |
입출금이 아닌 표는 두 합계에 섞이지 않도록 금액표를 0으로 만든다. | 기타 entry_type에는 amount 0을 부여한다.
|
| 19줄F08-L19 | END AS amount |
금액 계산판을 닫고 새 숫자 칸에 amount 이름표를 붙인다. | 두 번째 CASE를 닫고 계산 결과 열을 amount라고 부른다.
|
| 20줄F08-L20 | FROM ledger_entry |
방향표를 붙일 원본 영수증을 ledger_entry 상자에서 한 장씩 꺼낸다. | directed CTE의 시작 테이블을 ledger_entry로 정한다.
|
| 21줄F08-L21 | ) |
원장 한 장당 방향·금액을 만든 directed 준비 무대를 닫는다. | directed CTE의 SELECT 범위를 닫아 뒤 main query에서 사용할 relation을 완성한다.
|
| 22줄F08-L22 | SELECT a. |
최종 좌석표의 첫 칸에 계좌 식별 번호를 놓는다. | 최종 SELECT가 account 테이블의 account_id를 출력한다.
|
| 23줄F08-L23 | a. |
두 번째 칸에는 사람이 알아볼 계좌 번호표 account_no를 나란히 둔다. | 각 결과 행에 account_no를 추가로 출력한다.
|
| 24줄F08-L24 | COALESCE( |
IN 바구니 금액만 더하고 빈 계좌는 0원 표를 붙여 total_deposit 칸에 둔다. | direction이 IN인 amount만 SUM하고 NULL이면 0으로 바꾼 뒤 bigint total_deposit으로 출력한다.
|
| 25줄F08-L25 | COALESCE( |
OUT 바구니도 따로 더해 빈 합계를 0으로 바꾸고 total_withdraw 칸에 둔다. | direction이 OUT인 amount만 SUM하고 NULL이면 0으로 바꾼 뒤 bigint total_withdraw로 출력한다.
|
| 26줄F08-L26 | FROM account AS a |
원장이 없는 계좌도 좌석표에 남도록 전체 account 명단에서 출발한다. | 최종 query의 기준 relation을 account a로 정한다.
|
| 27줄F08-L27 | LEFT JOIN directed AS d ON d. |
계좌 번호가 같은 directed 영수증을 왼쪽 연결해 빈 상자도 떨어뜨리지 않는다. | account_id가 같은 directed 행을 LEFT JOIN하여 원장 없는 account도 보존한다.
|
| 28줄F08-L28 | GROUP BY a. |
계좌 ID와 번호 한 쌍마다 합계 상자를 하나로 묶는다. | account_id와 account_no를 GROUP BY하여 계좌당 한 결과 그룹을 만든다.
|
| 29줄F08-L29 | ORDER BY a. |
완성된 여덟 좌석표를 account_id가 작은 순서로 정렬한다. | 최종 결과를 account_id 오름차순으로 정렬하고 query를 끝낸다.
|
W13-SQL-Q15 · 여덟 계좌가 모두 남는 경로히토리 → 니지카 → 료 → 키타
-
히토리
원장이 하나도 없는 계좌는 어느 부분에서 살아남아요?
-
니지카
account 여덟 행에서 시작해 directed를 LEFT JOIN하니 matching 원장이 없어도 계좌 row는 유지돼.
-
료
INNER JOIN으로 바꾸면 원장 0개 계좌가 사라져 계좌당 한 행이라는 목적을 깨뜨려.
-
키타
원장 0개 계좌의 두 SUM은 NULL이지만 최종 열은 COALESCE 때문에 0·0이고 행 자체는 남아요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- W13-SQL-Q15 illustrative example; canonical_answer=false
-- Assumption: isolated workbook schema; OPENING and REVERSAL are not ordinary cash in/out.
-- Expected cardinality: one row per account; the supplied fixture has 8 accounts.
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
WITH directed AS (
SELECT account_id,
CASE
WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN 'IN'
WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN 'OUT'
ELSE 'OTHER'
END AS direction,
CASE
WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN signed_amount
WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN -signed_amount
ELSE 0
END AS amount
FROM ledger_entry
)
SELECT a.account_id,
a.account_no,
COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'IN'), 0)::bigint AS total_deposit,
COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'OUT'), 0)::bigint AS total_withdraw
FROM account AS a
LEFT JOIN directed AS d ON d.account_id = a.account_id
GROUP BY a.account_id, a.account_no
ORDER BY a.account_id;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 28줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | -- W13-SQL-Q15 illustrative example; canonical_answer=false | 이 SQL이 W13-SQL-Q15를 위해 새로 만든 illustrative example이며 canonical answer가 아님을 선언한다. |
| 2 | -- Assumption: isolated workbook schema; OPENING and REVERSAL are not ordinary cash in/out. | 격리 workbook schema를 쓰며 OPENING과 REVERSAL을 일반 현금 입출금으로 분류하지 않는다는 가정을 고정한다. |
| 3 | -- Expected cardinality: one row per account; the supplied fixture has 8 accounts. | 출력 cardinality를 계좌당 한 행으로 예상하고 제공 fixture에서는 8행이라고 밝힌다. |
| 4 | -- This is one auditable learning example, not the workbook's shipped answer. | 이 파일이 감사 가능한 학습 예시 하나일 뿐 workbook이 제공한 정답이 아님을 재확인한다. |
| 5 | \set ON_ERROR_STOP on | psql에서 뒤 명령 하나라도 실패하면 계속 진행하지 않도록 ON_ERROR_STOP을 켠다. |
| 6 | SET search_path TO :"workbook_schema", public; | psql 변수 workbook_schema가 가리키는 schema를 첫 search_path로 두고 public을 보조 경로로 둔다. |
| 8 | WITH directed AS ( | directed라는 CTE를 시작해 각 ledger_entry의 방향과 양수를 한 번 계산한다. |
| 9 | SELECT account_id, | directed CTE 결과에 원장 행의 account_id를 그대로 투영한다. |
| 10 | CASE | entry_type을 방향 문자열로 바꾸는 첫 CASE 표현식을 시작한다. |
| 11 | WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN 'IN' | entry_type이 DEPOSIT 또는 TRANSFER_IN이면 direction을 IN으로 만든다. |
| 12 | WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN 'OUT' | entry_type이 WITHDRAW 또는 TRANSFER_OUT이면 direction을 OUT으로 만든다. |
| 13 | ELSE 'OTHER' | 앞 조건에 들지 않은 entry_type의 direction을 OTHER로 지정한다. |
| 14 | END AS direction, | 첫 CASE를 닫아 계산 열의 별칭을 direction으로 정한다. |
| 15 | CASE | 집계용 amount를 만드는 두 번째 CASE 표현식을 시작한다. |
| 16 | WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN signed_amount | DEPOSIT 또는 TRANSFER_IN이면 signed_amount를 amount로 사용한다. |
| 17 | WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN -signed_amount | WITHDRAW 또는 TRANSFER_OUT이면 -signed_amount를 amount로 사용한다. |
| 18 | ELSE 0 | 기타 entry_type에는 amount 0을 부여한다. |
| 19 | END AS amount | 두 번째 CASE를 닫고 계산 결과 열을 amount라고 부른다. |
| 20 | FROM ledger_entry | directed CTE의 시작 테이블을 ledger_entry로 정한다. |
| 21 | ) | directed CTE의 SELECT 범위를 닫아 뒤 main query에서 사용할 relation을 완성한다. |
| 22 | SELECT a.account_id, | 최종 SELECT가 account 테이블의 account_id를 출력한다. |
| 23 | a.account_no, | 각 결과 행에 account_no를 추가로 출력한다. |
| 24 | COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'IN'), 0)::bigint AS total_deposit, | direction이 IN인 amount만 SUM하고 NULL이면 0으로 바꾼 뒤 bigint total_deposit으로 출력한다. |
| 25 | COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'OUT'), 0)::bigint AS total_withdraw | direction이 OUT인 amount만 SUM하고 NULL이면 0으로 바꾼 뒤 bigint total_withdraw로 출력한다. |
| 26 | FROM account AS a | 최종 query의 기준 relation을 account a로 정한다. |
| 27 | LEFT JOIN directed AS d ON d.account_id = a.account_id | account_id가 같은 directed 행을 LEFT JOIN하여 원장 없는 account도 보존한다. |
| 28 | GROUP BY a.account_id, a.account_no | account_id와 account_no를 GROUP BY하여 계좌당 한 결과 그룹을 만든다. |
| 29 | ORDER BY a.account_id; | 최종 결과를 account_id 오름차순으로 정렬하고 query를 끝낸다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기각 원장 행의 방향과 양수를 directed CTE에서 한 번 정한 뒤 계좌별 입금 합계와 출금 합계를 한 행에 놓는다.
문법 해부
- fixture의 DEPOSIT 1000·WITHDRAW -500도 두 CASE가 IN·1000과 OUT·500으로 한 번 정한다.
- account에서 LEFT JOIN하므로 원장 0건인 103·104도 0·0 행으로 남는다.
- account 101에서 FILTER가 IN 1000과 OUT 500·300·200을 서로 다른 SUM에 넣는다.
실행 순서
- directed가 IN·1000으로 바꾼다
- 부호를 뒤집어 OUT·500으로 만든다
- LEFT JOIN의 NULL 방향은 두 FILTER를 모두 통과하지 못한다
- account_id로 묶어 방향별 FILTER SUM을 계산한다
원래 W6 수준의 조각별 정밀 해설
F08-C01 · scope, assumptions, and workbook search path
- 문법 해부
- 네 provenance 주석, ON_ERROR_STOP, workbook search_path가 예시 지위·가정·행 수·실행 schema를 고정한다.
- 실제 값 추적
- canonical_answer=false이고 OPENING/REVERSAL 제외, fixture 결과 8행을 전제로 한다.
- 정상 예
- 격리 schema 변수를 주고 psql로 실행하면 뒤 CTE가 workbook 표를 찾는다.
- 틀린 예·반례
- 이 여섯 줄을 빼면 query만 보고 공식 답안이나 perf_w13용으로 오해할 수 있다.
- 착각 방지
- 주석의 8행은 다른 fixture에서 유지되는 invariant가 아니다.
- 하지 않는 일
- 이 조각은 ledger 방향이나 합계를 아직 계산하지 않는다.
- 다음 연결
- directed CTE가 원장 행마다 direction과 양수 amount를 한 번 만든다.
F08-C02 · classify each ledger row once
- 문법 해부
- WITH directed의 두 CASE가 entry_type→IN/OUT/OTHER와 signed_amount→양수/0을 만들고 ledger_entry를 읽는다.
- 실제 값 추적
- fixture account 101의 DEPOSIT 1000은 IN/1000, WITHDRAW -500은 OUT/500, OPENING 10000은 OTHER/0이 된다.
- 정상 예
- 같은 direction·amount 열을 뒤 두 FILTER SUM이 재사용한다.
- 틀린 예·반례
- 출금 signed_amount가 잘못 양수 500으로 저장되면 unary minus가 -500을 만들어 이 예시의 음수-outgoing 가정과 충돌한다.
- 착각 방지
- CTE는 REVERSAL의 실제 회계 정책이나 잘못 저장된 부호 교정을 결정하지 않는다.
- 하지 않는 일
- 원장 없는 account를 보존하는 일은 main query가 맡는다.
- 다음 연결
- account LEFT JOIN과 GROUP BY가 계좌당 두 합계를 만든다.
F08-C03 · aggregate one output row per account
- 문법 해부
- account 기준 LEFT JOIN, 두 FILTER SUM+COALESCE, GROUP BY, ORDER BY가 계좌당 한 결과 행을 만든다.
- 실제 값 추적
- fixture account 101은 total_deposit=1000, total_withdraw=1000이고 ledger 0건인 103·104는 0·0으로 남아 전체 8행이 된다.
- 정상 예
- account 101의 IN 1000과 OUT 500·300·200은 서로 다른 bigint 합계로 나온다.
- 틀린 예·반례
- ledger_entry에서 INNER JOIN으로 시작하면 원장 없는 account 103·104가 사라진다.
- 착각 방지
- 이 projection은 순잔액이나 고객별 총액을 계산하지 않는다.
- 하지 않는 일
- 공식 workbook 답안·운영 성능을 보장하지 않는다.
- 다음 연결
- Q17 예시는 행 WHERE와 그룹 HAVING의 경계를 보여 준다.
W13-SQL-Q15 · fixture 출금 -500을 양수 500으로 바꾸기히토리 → 니지카 → 료 → 키타
-
히토리
account 101의 WITHDRAW signed_amount가 -500인데 왜 다시 마이너스를 붙여요?
-
니지카
출금 총액을 양수 크기로 보여 주려는 예시라서 -signed_amount가 500을 만들어.
-
료
출금 값이 양수로 잘못 저장된 데이터까지 고치는 규칙은 아니라는 경계도 함께 적어야 해.
-
키타
부호를 뒤집은 500은 total_withdraw에만 들어가고 total_deposit에는 합산되지 않아요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | account 101 DEPOSIT, signed_amount=1000 | directed가 IN·1000으로 바꾼다 | total_deposit에 1000을 더할 후보가 된다 | OPENING 10000은 OTHER라 제외 |
| 2 | account 101 WITHDRAW, signed_amount=-500 | 부호를 뒤집어 OUT·500으로 만든다 | total_withdraw에 500을 더할 후보가 된다 | 잘못된 양수 출금은 교정하지 않음 |
| 3 | ledger_entry가 0건인 account 103 | LEFT JOIN의 NULL 방향은 두 FILTER를 모두 통과하지 못한다 | 두 SUM의 NULL을 COALESCE해 0·0 행을 남긴다 | 계좌가 account에 존재해야 보존됨 |
| 4 | account 101의 IN 1000과 OUT 500·300·200 | account_id로 묶어 방향별 FILTER SUM을 계산한다 | total_deposit=1000, total_withdraw=1000이 된다 | fixture 전체 출력은 account 8행 |
W13-SQL-Q15 · FILTER 두 개가 섞이지 않는지 확인히토리 → 니지카 → 료 → 키타
-
히토리
IN과 OUT이 같은 group에 있으면 두 SUM이 섞이지 않나요?
-
니지카
각 SUM의 FILTER가 direction 값을 따로 골라 같은 account group 안에서도 서로 다른 행 집합을 더해.
-
료
fixture 전체 결과는 account_id 순서의 8행이고 원장 없는 계좌는 0·0으로 보이는지 확인하면 돼.
-
키타
account 101은 IN 1000, OUT 500·300·200이므로 total_deposit=1000과 total_withdraw=1000이 따로 나와요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
ON_ERROR_STOP과 격리 workbook search_path를 먼저 적용한다.
psql 변수와 fixture 준비는 실행 환경 책임이다.각 원장 행의 방향과 양수를 directed CTE에서 한 번 정한 뒤 계좌별 입금 합계와 출금 합계를 한 행에 놓는다.
이 예시는 EXPLAIN이나 성능 측정을 실행하지 않는다.예상값은 account101 totals=1000/1000, fixture accounts=8에 묶여 있다.
운영 데이터와 perf_w13 schema로 일반화하지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ signed_amount를 그대로 모두 더하면 입금·출금 합계가 된다
왜 틀리나 출금은 음수이고 OTHER도 섞인다
바르게 읽기 방향과 양수를 먼저 정한다
반례 fixture WITHDRAW -500을 그대로 더하면 그 출금 기여가 -500으로 잘못 보인다
❌ ledger_entry에서 시작해도 모든 계좌가 나온다
왜 틀리나 원장 없는 계좌는 시작 집합에 없다
바르게 읽기 account에서 LEFT JOIN한다
반례 fixture account 103과 104가 결과에서 사라진다
❌ 이 SQL이 workbook 공식 정답이다
왜 틀리나 source package에는 답안이 없다
바르게 읽기 가정을 밝힌 illustrative example로만 읽는다
반례 REVERSAL 정책이 다르면 다른 풀이가 필요하다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
공식 workbook 답안을 보장하지 않는다
이 책임을 맡는 곳: 학습자와 과제 채점 계약REVERSAL의 실제 회계 방향을 결정하지 않는다
이 책임을 맡는 곳: 업무 정책perf_w13 schema에서 그대로 실행됨을 보장하지 않는다
이 책임을 맡는 곳: 격리 workbook fixtureW13-SQL-Q15 · Q15 예시의 답안 지위히토리 → 니지카 → 료 → 키타
-
히토리
이 SQL을 Q15 정답으로 그대로 제출하면 되는 건가요?
-
니지카
아니야. prompt와 격리 fixture에 맞춘 감사 가능한 한 가지 학습 예시일 뿐이야.
-
료
REVERSAL 분류나 운영 schema가 다르면 학습자가 가정을 다시 쓰고 다른 query를 만들 책임이 있어.
-
키타
화면의 canonical_answer=false와 ‘정본 답안 아님’ 표지를 확인한 뒤 가정을 포함해 다시 써야 해요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 원장 방향을 CTE에서 한 번만 분류하나?
- 왜 account에서 LEFT JOIN해 원장 없는 계좌도 남기나?
- 왜 OTHER amount를 0으로 두고 두 FILTER에서 제외하나?
2단계 · 코드 조각 재조립
- fixture의 DEPOSIT 1000·WITHDRAW -500도 두 CASE가 IN·1000과 OUT·500으로 한 번 정한다.
- account에서 LEFT JOIN하므로 원장 0건인 103·104도 0·0 행으로 남는다.
- account 101에서 FILTER가 IN 1000과 OUT 500·300·200을 서로 다른 SUM에 넣는다.
3단계 · 파일 전체 다시 쓰기
W13-SQL-Q15 prompt와 fixture 가정을 먼저 적고 전체 SQL을 다시 작성한다.
자가 점검
- canonical_answer=false와 가정을 적었는가
- 모든 비어 있지 않은 원본 줄을 보존했는가
- 출력 cardinality와 반례를 설명했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- W13-SQL-Q15 illustrative example; canonical_answer=false
-- Assumption: isolated workbook schema; OPENING and REVERSAL are not ordinary cash in/out.
-- Expected cardinality: one row per account; the supplied fixture has 8 accounts.
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
WITH directed AS (
SELECT account_id,
CASE
WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN 'IN'
WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN 'OUT'
ELSE 'OTHER'
END AS direction,
CASE
WHEN entry_type IN ('DEPOSIT', 'TRANSFER_IN') THEN signed_amount
WHEN entry_type IN ('WITHDRAW', 'TRANSFER_OUT') THEN -signed_amount
ELSE 0
END AS amount
FROM ledger_entry
)
SELECT a.account_id,
a.account_no,
COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'IN'), 0)::bigint AS total_deposit,
COALESCE(SUM(d.amount) FILTER (WHERE d.direction = 'OUT'), 0)::bigint AS total_withdraw
FROM account AS a
LEFT JOIN directed AS d ON d.account_id = a.account_id
GROUP BY a.account_id, a.account_no
ORDER BY a.account_id;
09Q17 학습용 SQL — 성공 거래를 고객별로 묶고 HAVING 100만원 경계 적용하기
illustrative/sql/W13-SQL-Q17.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F0917줄 연결17줄 번역2 chunks
Q17 학습용 SQL — 성공 거래를 고객별로 묶고 HAVING 100만원 경계 적용하기
illustrative/sql/W13-SQL-Q17.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F09STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
성공한 비개설·비취소 거래만 남기고 고객별 amount를 합친 뒤 HAVING으로 100만원 이상 그룹만 고른다.
- 왜 status와 tx_type은 WHERE에서 거르나?
- 왜 100만원 비교는 WHERE가 아니라 HAVING인가?
- 왜 고객에서 계좌와 거래를 차례로 JOIN하나?
status=SUCCESSOPENING/REVERSAL excludedcustomer1 total=1201999fixture rows=1W13-SQL-Q17 · WHERE와 HAVING의 서로 다른 시간히토리 → 니지카 → 료 → 키타
-
히토리
100만원 조건을 status 조건 옆 WHERE에 쓰면 더 간단하지 않나요?
-
니지카
WHERE는 개별 거래를 먼저 거르고 HAVING은 customer group의 SUM이 생긴 뒤 1000000을 비교해.
-
료
aggregate가 만들어지기 전에는 SUM threshold를 판정할 값 자체가 없다는 순서 반례가 생겨.
-
키타
fixture customer 1의 통과 거래 7건 합은 1201999라 WHERE 뒤 HAVING >= 1000000을 통과해요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
스타리 고객별 매출 봉투와 100만원 통과선
Q17 학습용 SQL — 성공 거래를 고객별로 묶고 HAVING 100만원 경계 적용하기
니지카는 성공 도장이 찍힌 일반 거래 영수증만 고객 봉투에 넣어 customer 1의 일곱 금액을 모은다.
료는 합계 1201999에 100만원 통과선을 대고, 정확히 1000000인 별도 fixture는 없다는 한계도 표시한다.
딱 여기까지만 총 거래액 정의는 예시 가정이며 공식 정답이 아니다
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
행 먼저 거르기
WHERE가 fixture의 FAILED 700과 OPENING·REVERSAL을 그룹 전에 뺀다.
- 코드 연결
14~15줄- 비유
- 스타리 고객별 매출 봉투와 100만원 통과선에서 행 먼저 거르기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 WHERE가 fixture의 FAILED 700과 OPENING·REVERSAL을 그룹 전에 뺀다. 그 밖의 정책과 정본성은 증명하지 않는다.
고객별 합치기
customer 1의 account 101·105 거래를 customer→account→business_tx 연결로 한 그룹에 모은다.
- 코드 연결
11~16줄- 비유
- 스타리 고객별 매출 봉투와 100만원 통과선에서 고객별 합치기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 customer 1의 account 101·105 거래를 customer→account→business_tx 연결로 한 그룹에 모은다. 그 밖의 정책과 정본성은 증명하지 않는다.
합계 뒤 거르기
HAVING이 customer 1의 실제 합계 1201999를 1000000과 비교해 한 행을 남긴다.
- 코드 연결
17줄- 비유
- 스타리 고객별 매출 봉투와 100만원 통과선에서 합계 뒤 거르기 순서표를 확인한다.
- 비유의 끝
- 이 조각은 HAVING이 customer 1의 실제 합계 1201999를 1000000과 비교해 한 행을 남긴다. 그 밖의 정책과 정본성은 증명하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 17줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F09-L01 | -- W13-SQL-Q17 illustrative example; |
Q17 집계 악보에 학습용 편곡이라는 붉은 표찰을 붙인다. | 이 SQL이 W13-SQL-Q17용 illustrative example이고 canonical answer가 아님을 선언한다.
|
| 2줄F09-L02 | -- Assumption: |
총 거래액 규칙판에 성공한 OPENING·REVERSAL 이외의 amount만 센다고 적는다. | total의 의미를 성공한 비개설·비취소 business_tx.amount 합계로 가정한다.
|
| 3줄F09-L03 | -- Expected cardinality: |
제공 fixture에서는 기준을 넘는 고객 좌석이 하나라고 예상표를 붙인다. | 격리 workbook fixture에서 예상 출력 cardinality가 1행임을 밝힌다.
|
| 4줄F09-L04 | -- This is one auditable learning example, |
공연장 배포 정답지가 아니라 감사 가능한 연습본이라는 봉인을 찍는다. | 이 query가 workbook에 실려 있던 공식 답안이 아니라 새 학습 예시임을 재확인한다.
|
| 5줄F09-L05 | \set ON_ERROR_STOP on |
어느 SQL 음이 실패해도 다음 음으로 넘어가지 않는 psql 차단기를 올린다. | psql ON_ERROR_STOP을 켜 실행 오류에서 즉시 중단한다.
|
| 6줄F09-L06 | SET search_path TO : |
고객·계좌·거래표를 찾을 격리 workbook 악보함을 첫 경로로 지정한다. | workbook_schema와 public 순서로 search_path를 설정한다.
|
| 8줄F09-L08 | SELECT c. |
고객별 결과표 첫 칸에 customer_id 좌석 번호를 놓는다. | 최종 SELECT가 customer_id를 출력한다.
|
| 9줄F09-L09 | c. |
번호 옆에 관객이 읽을 customer_name 이름표를 붙인다. | 각 고객 결과 행에 customer_name을 추가한다.
|
| 10줄F09-L10 | SUM( |
남길 거래 금액을 모두 더해 bigint total_transaction_amount 표로 만든다. | 그룹 안의 t.amount를 SUM하고 bigint로 변환해 total_transaction_amount로 출력한다.
|
| 11줄F09-L11 | FROM customer AS c |
전체 고객 명단 customer c를 합계 여행의 출발점으로 삼는다. | FROM relation을 customer c로 정한다.
|
| 12줄F09-L12 | JOIN account AS a ON a. |
각 고객 소유의 account 표를 customer_id 끈으로 연결한다. | customer_id가 같은 account a를 INNER JOIN한다.
|
| 13줄F09-L13 | JOIN business_tx AS t ON t. |
각 계좌의 business_tx 영수증을 account_id로 이어 붙인다. | account_id가 같은 business_tx t를 INNER JOIN한다.
|
| 14줄F09-L14 | WHERE t. |
성공 도장이 찍힌 거래만 집계 줄에 세운다. | t.status가 SUCCESS인 개별 거래 행만 WHERE 단계에서 남긴다.
|
| 15줄F09-L15 | AND t. |
계좌 개설과 취소 표는 일반 거래 총액 바구니에서 뺀다. | tx_type이 OPENING 또는 REVERSAL인 행을 WHERE 단계에서 제외한다.
|
| 16줄F09-L16 | GROUP BY c. |
고객 ID와 이름별로 남은 거래 영수증을 한 묶음으로 묶는다. | customer_id와 customer_name으로 GROUP BY하여 고객별 합계를 만든다.
|
| 17줄F09-L17 | HAVING SUM( |
묶음 합계가 1,000,000 이상인 고객만 무대에 남긴다. | GROUP BY 뒤 HAVING으로 SUM(t.amount)가 1000000 이상인 그룹만 통과시킨다.
|
| 18줄F09-L18 | ORDER BY c. |
통과한 고객표를 customer_id 오름차순으로 줄 세운다. | 결과를 customer_id 오름차순으로 정렬하고 query를 끝낸다.
|
W13-SQL-Q17 · 고객에서 거래까지 이어지는 세 표히토리 → 니지카 → 료 → 키타
-
히토리
business_tx만 보고 고객 이름을 바로 얻을 수는 없나요?
-
니지카
예시 fixture에서는 거래가 account_id를 갖고 account가 customer_id를 가지므로 customer→account→business_tx 순서로 연결해.
-
료
INNER JOIN이라 계좌 없는 고객과 거래 없는 계좌는 결과에서 빠진다는 cardinality 손실을 숨기면 안 돼.
-
키타
customer_id와 customer_name으로 묶기 전 각 계좌의 거래 행이 고객 row에 연결되는지 확인해요.
STEP 05 / 13
원본 코드 조각
원본을 2개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- W13-SQL-Q17 illustrative example; canonical_answer=false
-- Assumption: total means successful non-OPENING/non-REVERSAL business_tx.amount.
-- Expected cardinality: 1 row on the supplied workbook fixture.
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
SELECT c.customer_id,
c.customer_name,
SUM(t.amount)::bigint AS total_transaction_amount
FROM customer AS c
JOIN account AS a ON a.customer_id = c.customer_id
JOIN business_tx AS t ON t.account_id = a.account_id
WHERE t.status = 'SUCCESS'
AND t.tx_type NOT IN ('OPENING', 'REVERSAL')
GROUP BY c.customer_id, c.customer_name
HAVING SUM(t.amount) >= 1000000
ORDER BY c.customer_id;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 17줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | -- W13-SQL-Q17 illustrative example; canonical_answer=false | 이 SQL이 W13-SQL-Q17용 illustrative example이고 canonical answer가 아님을 선언한다. |
| 2 | -- Assumption: total means successful non-OPENING/non-REVERSAL business_tx.amount. | total의 의미를 성공한 비개설·비취소 business_tx.amount 합계로 가정한다. |
| 3 | -- Expected cardinality: 1 row on the supplied workbook fixture. | 격리 workbook fixture에서 예상 출력 cardinality가 1행임을 밝힌다. |
| 4 | -- This is one auditable learning example, not the workbook's shipped answer. | 이 query가 workbook에 실려 있던 공식 답안이 아니라 새 학습 예시임을 재확인한다. |
| 5 | \set ON_ERROR_STOP on | psql ON_ERROR_STOP을 켜 실행 오류에서 즉시 중단한다. |
| 6 | SET search_path TO :"workbook_schema", public; | workbook_schema와 public 순서로 search_path를 설정한다. |
| 8 | SELECT c.customer_id, | 최종 SELECT가 customer_id를 출력한다. |
| 9 | c.customer_name, | 각 고객 결과 행에 customer_name을 추가한다. |
| 10 | SUM(t.amount)::bigint AS total_transaction_amount | 그룹 안의 t.amount를 SUM하고 bigint로 변환해 total_transaction_amount로 출력한다. |
| 11 | FROM customer AS c | FROM relation을 customer c로 정한다. |
| 12 | JOIN account AS a ON a.customer_id = c.customer_id | customer_id가 같은 account a를 INNER JOIN한다. |
| 13 | JOIN business_tx AS t ON t.account_id = a.account_id | account_id가 같은 business_tx t를 INNER JOIN한다. |
| 14 | WHERE t.status = 'SUCCESS' | t.status가 SUCCESS인 개별 거래 행만 WHERE 단계에서 남긴다. |
| 15 | AND t.tx_type NOT IN ('OPENING', 'REVERSAL') | tx_type이 OPENING 또는 REVERSAL인 행을 WHERE 단계에서 제외한다. |
| 16 | GROUP BY c.customer_id, c.customer_name | customer_id와 customer_name으로 GROUP BY하여 고객별 합계를 만든다. |
| 17 | HAVING SUM(t.amount) >= 1000000 | GROUP BY 뒤 HAVING으로 SUM(t.amount)가 1000000 이상인 그룹만 통과시킨다. |
| 18 | ORDER BY c.customer_id; | 결과를 customer_id 오름차순으로 정렬하고 query를 끝낸다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기성공한 비개설·비취소 거래만 남기고 고객별 amount를 합친 뒤 HAVING으로 100만원 이상 그룹만 고른다.
문법 해부
- WHERE가 fixture의 FAILED 700과 OPENING·REVERSAL을 그룹 전에 뺀다.
- customer 1의 account 101·105 거래를 customer→account→business_tx 연결로 한 그룹에 모은다.
- HAVING이 customer 1의 실제 합계 1201999를 1000000과 비교해 한 행을 남긴다.
실행 순서
- WHERE의 status와 tx_type 조건을 모두 통과시킨다
- status 조건에서 제외한다
- tx_type 조건에서 제외한다
- HAVING 1201999>=1000000을 평가한다
원래 W6 수준의 조각별 정밀 해설
F09-C01 · scope, assumptions, and workbook search path
- 문법 해부
- provenance 주석과 psql/search_path가 성공한 비개설·비취소 amount라는 total 정의와 fixture 1행 기대를 밝힌다.
- 실제 값 추적
- canonical answer가 아닌 W13-SQL-Q17 예시로 격리 workbook에서 실행한다.
- 정상 예
- source audit에 고정된 workbook fixture와 schema 변수를 준비하면 뒤 customer/account/business_tx 이름이 그 격리 schema에서 해석된다.
- 틀린 예·반례
- 가정 주석을 숨기면 transfer 금액 귀속과 OPENING 처리 정책이 불명확해진다.
- 착각 방지
- 1행 기대는 정확히 100만원인 별도 경계 fixture를 제공하지 않는다.
- 하지 않는 일
- 이 조각은 아직 JOIN·SUM·HAVING을 실행하지 않는다.
- 다음 연결
- main SELECT가 고객별 거래 행을 묶고 합계 threshold를 적용한다.
F09-C02 · join, filter, group, and HAVING boundary
- 문법 해부
- customer→account→business_tx INNER JOIN, row-level WHERE, customer GROUP BY, aggregate HAVING, ORDER BY 순서다.
- 실제 값 추적
- fixture customer 1의 SUCCESS 일반 거래 1000·500·300·200·99999·100000·1000000 합계 1201999가 threshold를 넘어 한 행으로 남는다.
- 정상 예
- 가상 고객 그룹의 합계가 정확히 1000000이어도 >= 조건이라 통과한다.
- 틀린 예·반례
- SUM 조건을 WHERE에 두면 그룹 값이 생기기 전이라 올바른 단계가 아니다.
- 착각 방지
- fixture의 FAILED WITHDRAW 700, OPENING 10000·100000, REVERSAL 600·600은 WHERE에서 제외되며 데이터 자체가 없다는 뜻은 아니다.
- 하지 않는 일
- 현재 fixture 1행과 1201999를 production cardinality·금액으로 일반화하거나 counterparty·통화 정책으로 쓰지 않는다.
- 다음 연결
- Q18 예시는 correlated EXISTS/NOT EXISTS로 존재·부재를 함께 판정한다.
W13-SQL-Q17 · 총 거래액에서 제외되는 두 종류히토리 → 니지카 → 료 → 키타
-
히토리
SUCCESS면 OPENING과 REVERSAL도 합계에 넣어야 하지 않아요?
-
니지카
이 예시는 일반 거래 총액이라는 가정을 택해 성공 여부와 별개로 두 tx_type을 제외했어.
-
료
다른 총액 정의가 필요하면 WHERE 목록을 바꾸고 그 정책을 provenance에 새로 밝혀야 해.
-
키타
fixture의 FAILED WITHDRAW 700과 SUCCESS OPENING 10000·100000, REVERSAL 600·600은 total_transaction_amount를 올리지 않아요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | customer 1의 SUCCESS 일반 거래 1000·500·300·200·99999·100000·1000000 | WHERE의 status와 tx_type 조건을 모두 통과시킨다 | 일곱 amount의 합계 후보가 1201999가 된다 | 통화 단위와 counterparty 귀속은 fixture 가정 |
| 2 | customer 1의 FAILED WITHDRAW 700 | status 조건에서 제외한다 | customer 1 합계는 1201999에서 변하지 않는다 | 실패 정의는 status 값에 의존 |
| 3 | SUCCESS OPENING 10000·100000과 REVERSAL 600·600 | tx_type 조건에서 제외한다 | 네 금액은 total_transaction_amount에 들어가지 않는다 | 다른 특수 종류는 포함될 수 있음 |
| 4 | customer 1의 남은 합계=1201999 | HAVING 1201999>=1000000을 평가한다 | customer_id=1 한 행과 total_transaction_amount=1201999가 남는다 | 정확히 1000000인 경계 fixture는 별도 보강 필요 |
W13-SQL-Q17 · 정확히 100만원인 그룹히토리 → 니지카 → 료 → 키타
-
히토리
고객 합계가 딱 1000000이면 남나요?
-
니지카
HAVING 연산자가 >=라서 경계값도 통과하고 결과 열에는 같은 합계가 bigint로 나와.
-
료
다만 제공 seed에 정확한 경계 전용 고객이 없으니 그 반례 fixture까지 증명했다고 말하면 안 돼.
-
키타
현재 fixture의 1행 기대와 별도로 exactly-1000000 반례 seed가 없다는 점을 체크리스트에 남겨요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
ON_ERROR_STOP과 격리 workbook search_path를 먼저 적용한다.
psql 변수와 fixture 준비는 실행 환경 책임이다.성공한 비개설·비취소 거래만 남기고 고객별 amount를 합친 뒤 HAVING으로 100만원 이상 그룹만 고른다.
이 예시는 EXPLAIN이나 성능 측정을 실행하지 않는다.예상값은 customer1 total=1201999, fixture rows=1에 묶여 있다.
운영 데이터와 perf_w13 schema로 일반화하지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ SUM 조건을 WHERE에 쓰면 된다
왜 틀리나 WHERE는 그룹 합계가 생기기 전 단계다
바르게 읽기 SUM threshold는 HAVING에 둔다
반례 WHERE SUM(t.amount)는 일반 SQL에서 유효하지 않다
❌ 모든 business_tx가 총 거래액이다
왜 틀리나 실패·OPENING·REVERSAL을 예시 정의에서 뺀다
바르게 읽기 WHERE 조건을 total 정의로 읽는다
반례 fixture의 FAILED 700이나 OPENING 10000을 섞으면 예시 정의와 다른 합계가 된다
❌ 1행 결과가 언제나 보장된다
왜 틀리나 1행은 제공 fixture의 기대일 뿐이다
바르게 읽기 데이터에 따라 0..N행으로 본다
반례 모든 고객 합계가 100만원 미만이면 0행이다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
counterparty 귀속이나 중복 회계 정책을 정하지 않는다
이 책임을 맡는 곳: 업무 집계 정책정확히 100만원인 별도 fixture를 제공하지 않는다
이 책임을 맡는 곳: 학습자 반례 fixture공식 workbook 답안이나 운영 query를 보장하지 않는다
이 책임을 맡는 곳: 과제 계약W13-SQL-Q17 · fixture 1행의 한계히토리 → 니지카 → 료 → 키타
-
히토리
지금 결과가 한 명이면 실제 데이터에서도 늘 한 명인가요?
-
니지카
1행은 supplied workbook fixture의 예상 cardinality라 데이터가 달라지면 0행이나 여러 행이 될 수 있어.
-
료
이 illustrative query는 counterparty 귀속·통화 환산·운영 성능을 결정하는 정본이 아니다.
-
키타
현재 결과는 customer 1 한 행과 total_transaction_amount=1201999인지 확인하고, 데이터가 바뀌면 0..N행임을 유지해요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 status와 tx_type은 WHERE에서 거르나?
- 왜 100만원 비교는 WHERE가 아니라 HAVING인가?
- 왜 고객에서 계좌와 거래를 차례로 JOIN하나?
2단계 · 코드 조각 재조립
- WHERE가 fixture의 FAILED 700과 OPENING·REVERSAL을 그룹 전에 뺀다.
- customer 1의 account 101·105 거래를 customer→account→business_tx 연결로 한 그룹에 모은다.
- HAVING이 customer 1의 실제 합계 1201999를 1000000과 비교해 한 행을 남긴다.
3단계 · 파일 전체 다시 쓰기
W13-SQL-Q17 prompt와 fixture 가정을 먼저 적고 전체 SQL을 다시 작성한다.
자가 점검
- canonical_answer=false와 가정을 적었는가
- 모든 비어 있지 않은 원본 줄을 보존했는가
- 출력 cardinality와 반례를 설명했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- W13-SQL-Q17 illustrative example; canonical_answer=false
-- Assumption: total means successful non-OPENING/non-REVERSAL business_tx.amount.
-- Expected cardinality: 1 row on the supplied workbook fixture.
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
SELECT c.customer_id,
c.customer_name,
SUM(t.amount)::bigint AS total_transaction_amount
FROM customer AS c
JOIN account AS a ON a.customer_id = c.customer_id
JOIN business_tx AS t ON t.account_id = a.account_id
WHERE t.status = 'SUCCESS'
AND t.tx_type NOT IN ('OPENING', 'REVERSAL')
GROUP BY c.customer_id, c.customer_name
HAVING SUM(t.amount) >= 1000000
ORDER BY c.customer_id;
10Q18 학습용 SQL — 입금 EXISTS와 출금 NOT EXISTS로 계좌 찾기
illustrative/sql/W13-SQL-Q18.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F1022줄 연결22줄 번역3 chunks
Q18 학습용 SQL — 입금 EXISTS와 출금 NOT EXISTS로 계좌 찾기
illustrative/sql/W13-SQL-Q18.sql
SQL 학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W13-F10STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
각 계좌에 입금 방향 원장이 하나 이상 있고 출금 방향 원장은 하나도 없는지 correlated EXISTS 두 개로 판정한다.
- 왜 입금은 EXISTS이고 출금은 NOT EXISTS인가?
- 왜 SELECT 1로도 존재 여부를 확인할 수 있나?
- 왜 NOT IN 대신 NOT EXISTS가 NULL 함정을 피하나?
EXISTS DEPOSIT/TRANSFER_INNOT EXISTS WITHDRAW/TRANSFER_OUTfixture accounts=102,105rows=2W13-SQL-Q18 · 입금 존재와 출금 부재를 함께 묻기히토리 → 니지카 → 료 → 키타
-
히토리
입금 행만 찾으면 문제 조건이 끝나는 것 아닌가요?
-
니지카
첫 EXISTS가 incoming 하나 이상을 요구하고 둘째 NOT EXISTS가 outgoing 0개를 요구해야 두 조건이 동시에 맞아.
-
료
account 101은 DEPOSIT 1000이 있어도 WITHDRAW -500과 TRANSFER_OUT 두 건이 있어 첫 조건만 쓰면 잘못 통과해.
-
키타
account 101은 DEPOSIT 1000으로 EXISTS=true지만 WITHDRAW -500과 TRANSFER_OUT -300·-200 때문에 NOT EXISTS=false라 탈락해요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
스타리 계좌별 입장·퇴장 도장 검사대
Q18 학습용 SQL — 입금 EXISTS와 출금 NOT EXISTS로 계좌 찾기
히토리는 account 102의 TRANSFER_IN 300처럼 입장 도장이 한 장이라도 있는지 첫 창구에서 확인한다.
료는 101의 WITHDRAW·TRANSFER_OUT은 거부하고 outgoing 0건인 102와 105만 남겨 NULL 목록 비교를 쓰지 않는다.
딱 여기까지만 OPENING과 REVERSAL을 입출금에서 제외한 예시 가정이다
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
입금 하나 이상
첫 EXISTS가 102의 TRANSFER_IN 300이나 105의 DEPOSIT처럼 같은 account_id의 incoming 행을 찾는다.
- 코드 연결
11~16줄- 비유
- 스타리 계좌별 입장·퇴장 도장 검사대에서 입금 하나 이상 순서표를 확인한다.
- 비유의 끝
- 이 조각은 첫 EXISTS가 102의 TRANSFER_IN 300이나 105의 DEPOSIT처럼 같은 account_id의 incoming 행을 찾는다. 그 밖의 정책과 정본성은 증명하지 않는다.
출금 하나도 없음
NOT EXISTS가 101의 WITHDRAW -500·TRANSFER_OUT -300·-200 같은 outgoing 행이 있는 계좌를 거부한다.
- 코드 연결
17~22줄- 비유
- 스타리 계좌별 입장·퇴장 도장 검사대에서 출금 하나도 없음 순서표를 확인한다.
- 비유의 끝
- 이 조각은 NOT EXISTS가 101의 WITHDRAW -500·TRANSFER_OUT -300·-200 같은 outgoing 행이 있는 계좌를 거부한다. 그 밖의 정책과 정본성은 증명하지 않는다.
값 대신 존재
SELECT 1은 금액이 아니라 행 존재를 뜻하므로 DEPOSIT 1000도 EXISTS를 만족한다.
- 코드 연결
12·18줄- 비유
- 스타리 계좌별 입장·퇴장 도장 검사대에서 값 대신 존재 순서표를 확인한다.
- 비유의 끝
- 이 조각은 SELECT 1은 금액이 아니라 행 존재를 뜻하므로 DEPOSIT 1000도 EXISTS를 만족한다. 그 밖의 정책과 정본성은 증명하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
비어 있지 않은 원본 22줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F10-L01 | -- W13-SQL-Q18 illustrative example; |
Q18 탐색 악보에 공식 답안이 아닌 학습용 예시 표찰을 단다. | 이 SQL이 W13-SQL-Q18용 illustrative example이며 canonical answer가 아님을 선언한다.
|
| 2줄F10-L02 | -- Assumption: |
입장 도장은 DEPOSIT·TRANSFER_IN, 퇴장 도장은 WITHDRAW·TRANSFER_OUT이라고 규칙판에 쓴다. | 입금과 출금으로 볼 네 entry_type을 예시 가정으로 고정한다.
|
| 3줄F10-L03 | -- Expected cardinality: |
제공 fixture의 합격 좌석은 102와 105 두 개라고 예상표를 붙인다. | 제공 workbook fixture에서 예상 결과가 account 102와 105의 2행임을 밝힌다.
|
| 4줄F10-L04 | -- This is one auditable learning example, |
교재가 배포한 정답지가 아니라 감사 가능한 한 가지 풀이임을 봉투에 새긴다. | 이 파일이 workbook의 shipped answer가 아닌 새 학습 예시임을 재확인한다.
|
| 5줄F10-L05 | \set ON_ERROR_STOP on |
부분 실행으로 잘못된 결과를 보지 않도록 psql 오류 정지 장치를 켠다. | psql ON_ERROR_STOP을 활성화한다.
|
| 6줄F10-L06 | SET search_path TO : |
격리된 workbook 악보함을 먼저, public을 다음 탐색 경로로 둔다. | workbook_schema와 public을 search_path로 지정한다.
|
| 8줄F10-L08 | SELECT a. |
합격표 첫 칸에 계좌의 account_id 번호를 적는다. | 최종 SELECT가 account_id를 출력한다.
|
| 9줄F10-L09 | a. |
두 번째 칸에 사람이 확인할 account_no를 함께 적는다. | 각 결과 행에 account_no를 추가한다.
|
| 10줄F10-L10 | FROM account AS a |
모든 계좌 명단 account a에서 후보를 한 장씩 꺼낸다. | query의 기준 relation을 account a로 정한다.
|
| 11줄F10-L11 | WHERE EXISTS ( |
후보 계좌에 입장 도장이 하나라도 있는지 보는 첫 검사창을 연다. | 현재 account와 연관된 입금 방향 원장 존재를 요구하는 EXISTS를 시작한다.
|
| 12줄F10-L12 | SELECT 1 |
입장 기록의 값은 필요 없으니 존재 신호 1만 고른다. | EXISTS subquery에서 행의 존재만 필요해 상수 1을 SELECT한다.
|
| 13줄F10-L13 | FROM ledger_entry AS incoming |
입장 기록 후보를 ledger_entry incoming 상자에서 찾는다. | 첫 subquery의 원본을 ledger_entry incoming으로 정한다.
|
| 14줄F10-L14 | WHERE incoming. |
바깥 후보와 같은 account_id 영수증만 첫 검사창에 넣는다. | incoming.account_id를 바깥 a.account_id와 같게 해 correlated subquery를 만든다.
|
| 15줄F10-L15 | AND incoming. |
DEPOSIT 또는 TRANSFER_IN 도장이 찍힌 영수증만 입장 증거로 인정한다. | incoming.entry_type을 DEPOSIT과 TRANSFER_IN으로 제한한다.
|
| 16줄F10-L16 | ) |
입장 증거가 하나라도 있어야 한다는 EXISTS 검사창을 닫는다. | 첫 correlated EXISTS 조건을 닫는다.
|
| 17줄F10-L17 | AND NOT EXISTS ( |
이번에는 퇴장 도장이 단 하나도 없어야 하는 NOT EXISTS 검사창을 연다. | 현재 account에 출금 방향 원장이 없음을 요구하는 NOT EXISTS를 시작한다.
|
| 18줄F10-L18 | SELECT 1 |
퇴장 기록도 값 대신 존재 신호 1만 조회한다. | NOT EXISTS subquery가 matching 행의 존재 여부만 보도록 상수 1을 SELECT한다.
|
| 19줄F10-L19 | FROM ledger_entry AS outgoing |
퇴장 기록 후보를 같은 ledger_entry에서 outgoing 이름으로 꺼낸다. | 둘째 subquery의 원본을 ledger_entry outgoing으로 정한다.
|
| 20줄F10-L20 | WHERE outgoing. |
바깥 후보 계좌와 account_id가 같은 퇴장 영수증만 살핀다. | outgoing.account_id를 바깥 a.account_id와 연결해 둘째 correlated subquery를 만든다.
|
| 21줄F10-L21 | AND outgoing. |
WITHDRAW 또는 TRANSFER_OUT 도장이 하나라도 보이면 후보를 탈락시킨다. | outgoing.entry_type을 WITHDRAW와 TRANSFER_OUT으로 제한한다.
|
| 22줄F10-L22 | ) |
출금 증거가 없을 때만 통과시키는 NOT EXISTS 검사창을 닫는다. | 둘째 correlated NOT EXISTS 조건을 닫는다.
|
| 23줄F10-L23 | ORDER BY a. |
합격한 102·105 계좌표를 account_id 오름차순으로 정렬한다. | 최종 결과를 account_id 오름차순으로 정렬하고 query를 끝낸다.
|
W13-SQL-Q18 · SELECT 1의 실제 의미히토리 → 니지카 → 료 → 키타
-
히토리
subquery의 SELECT 1은 금액이 1인 원장을 뜻하나요?
-
니지카
아니야. EXISTS는 projection 값을 반환하지 않고 matching row가 있는지만 보므로 상수 1이면 충분해.
-
료
SELECT 1을 금액 필터로 읽으면 signed_amount가 1000인 실제 DEPOSIT도 놓친다는 잘못된 해석이 돼.
-
키타
SELECT 1을 SELECT incoming.signed_amount로 바꿔도 matching 행 집합이 같으면 EXISTS boolean은 같아요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- W13-SQL-Q18 illustrative example; canonical_answer=false
-- Assumption: DEPOSIT/TRANSFER_IN are cash in; WITHDRAW/TRANSFER_OUT are cash out.
-- Expected cardinality: 2 rows on the supplied workbook fixture (accounts 102 and 105).
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
SELECT a.account_id,
a.account_no
FROM account AS a
WHERE EXISTS (
SELECT 1
FROM ledger_entry AS incoming
WHERE incoming.account_id = a.account_id
AND incoming.entry_type IN ('DEPOSIT', 'TRANSFER_IN')
)
AND NOT EXISTS (
SELECT 1
FROM ledger_entry AS outgoing
WHERE outgoing.account_id = a.account_id
AND outgoing.entry_type IN ('WITHDRAW', 'TRANSFER_OUT')
)
ORDER BY a.account_id;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 22줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | -- W13-SQL-Q18 illustrative example; canonical_answer=false | 이 SQL이 W13-SQL-Q18용 illustrative example이며 canonical answer가 아님을 선언한다. |
| 2 | -- Assumption: DEPOSIT/TRANSFER_IN are cash in; WITHDRAW/TRANSFER_OUT are cash out. | 입금과 출금으로 볼 네 entry_type을 예시 가정으로 고정한다. |
| 3 | -- Expected cardinality: 2 rows on the supplied workbook fixture (accounts 102 and 105). | 제공 workbook fixture에서 예상 결과가 account 102와 105의 2행임을 밝힌다. |
| 4 | -- This is one auditable learning example, not the workbook's shipped answer. | 이 파일이 workbook의 shipped answer가 아닌 새 학습 예시임을 재확인한다. |
| 5 | \set ON_ERROR_STOP on | psql ON_ERROR_STOP을 활성화한다. |
| 6 | SET search_path TO :"workbook_schema", public; | workbook_schema와 public을 search_path로 지정한다. |
| 8 | SELECT a.account_id, | 최종 SELECT가 account_id를 출력한다. |
| 9 | a.account_no | 각 결과 행에 account_no를 추가한다. |
| 10 | FROM account AS a | query의 기준 relation을 account a로 정한다. |
| 11 | WHERE EXISTS ( | 현재 account와 연관된 입금 방향 원장 존재를 요구하는 EXISTS를 시작한다. |
| 12 | SELECT 1 | EXISTS subquery에서 행의 존재만 필요해 상수 1을 SELECT한다. |
| 13 | FROM ledger_entry AS incoming | 첫 subquery의 원본을 ledger_entry incoming으로 정한다. |
| 14 | WHERE incoming.account_id = a.account_id | incoming.account_id를 바깥 a.account_id와 같게 해 correlated subquery를 만든다. |
| 15 | AND incoming.entry_type IN ('DEPOSIT', 'TRANSFER_IN') | incoming.entry_type을 DEPOSIT과 TRANSFER_IN으로 제한한다. |
| 16 | ) | 첫 correlated EXISTS 조건을 닫는다. |
| 17 | AND NOT EXISTS ( | 현재 account에 출금 방향 원장이 없음을 요구하는 NOT EXISTS를 시작한다. |
| 18 | SELECT 1 | NOT EXISTS subquery가 matching 행의 존재 여부만 보도록 상수 1을 SELECT한다. |
| 19 | FROM ledger_entry AS outgoing | 둘째 subquery의 원본을 ledger_entry outgoing으로 정한다. |
| 20 | WHERE outgoing.account_id = a.account_id | outgoing.account_id를 바깥 a.account_id와 연결해 둘째 correlated subquery를 만든다. |
| 21 | AND outgoing.entry_type IN ('WITHDRAW', 'TRANSFER_OUT') | outgoing.entry_type을 WITHDRAW와 TRANSFER_OUT으로 제한한다. |
| 22 | ) | 둘째 correlated NOT EXISTS 조건을 닫는다. |
| 23 | ORDER BY a.account_id; | 최종 결과를 account_id 오름차순으로 정렬하고 query를 끝낸다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기각 계좌에 입금 방향 원장이 하나 이상 있고 출금 방향 원장은 하나도 없는지 correlated EXISTS 두 개로 판정한다.
문법 해부
- 첫 EXISTS가 102의 TRANSFER_IN 300이나 105의 DEPOSIT처럼 같은 account_id의 incoming 행을 찾는다.
- NOT EXISTS가 101의 WITHDRAW -500·TRANSFER_OUT -300·-200 같은 outgoing 행이 있는 계좌를 거부한다.
- SELECT 1은 금액이 아니라 행 존재를 뜻하므로 DEPOSIT 1000도 EXISTS를 만족한다.
실행 순서
- incoming 종류와 account_id 상관 조건을 만족시킨다
- outgoing subquery가 matching row를 찾지 못한다
- incoming EXISTS=true지만 outgoing EXISTS도 true로 만든다
- 두 상관 subquery를 각 account_id에 적용하고 정렬한다
원래 W6 수준의 조각별 정밀 해설
F10-C01 · scope, assumptions, and workbook search path
- 문법 해부
- 네 provenance 주석과 psql/search_path가 네 입출금 종류, fixture 계좌 102·105, 비정본 지위를 고정한다.
- 실제 값 추적
- 격리 workbook에서 결과 2행을 기대하되 canonical_answer=false로 읽는다.
- 정상 예
- schema 변수가 준비되면 account와 ledger_entry를 뒤 correlated subquery가 찾는다.
- 틀린 예·반례
- perf_w13에 그대로 실행하면 workbook column/type 계약이 달라 실패할 수 있다.
- 착각 방지
- OPENING·REVERSAL 제외는 예시 정책일 뿐이다.
- 하지 않는 일
- 이 범위는 아직 어떤 account도 선택하지 않는다.
- 다음 연결
- 첫 EXISTS가 현재 계좌에 입금 방향 행이 하나 이상인지 요구한다.
F10-C02 · require at least one incoming row
- 문법 해부
- account 기준 SELECT와 correlated EXISTS가 같은 account_id의 DEPOSIT/TRANSFER_IN 행 존재를 검사한다.
- 실제 값 추적
- fixture account 102의 TRANSFER_IN 300 한 건이 첫 EXISTS를 true로 만든다.
- 정상 예
- SELECT 1은 금액이 아니라 matching row 존재만 요청하므로 account 101의 DEPOSIT 1000도 incoming match가 된다.
- 틀린 예·반례
- 입금 금액이 1이어야 한다고 해석하면 EXISTS projection을 오해한 것이다.
- 착각 방지
- 첫 EXISTS만으로 출금이 없다는 결론은 내릴 수 없다.
- 하지 않는 일
- business_tx status와 원장 금액 크기는 검사하지 않는다.
- 다음 연결
- NOT EXISTS가 같은 계좌의 출금 방향 행을 하나도 허용하지 않는다.
F10-C03 · reject every outgoing row and sort
- 문법 해부
- correlated NOT EXISTS가 WITHDRAW/TRANSFER_OUT 부재를 요구하고 마지막 ORDER BY가 account_id 순서를 고정한다.
- 실제 값 추적
- account 102는 outgoing 0건이라 통과하고, account 101은 WITHDRAW -500과 TRANSFER_OUT -300·-200이 있어 탈락하며 최종 결과는 102·105다.
- 정상 예
- account 102처럼 incoming은 있고 outgoing은 없는 계좌가 valid 결과가 된다.
- 틀린 예·반례
- ledger가 0건인 account 103·104는 첫 EXISTS=false라서 outgoing 부재만으로는 통과하지 못한다.
- 착각 방지
- 상관 NOT EXISTS는 NULL 목록 비교 함정을 피하지만 새 출금 종류를 두 값 목록에 자동 포함하지 않는다.
- 하지 않는 일
- 운영 query 최적성, business_tx status, 공식 workbook 답안을 증명하지 않는다.
- 다음 연결
- 이 illustrative 결과와 W13 native performance evidence는 서로 다른 fixture·책임으로 남는다.
W13-SQL-Q18 · 상관 조건이 바깥 계좌를 붙잡는 법히토리 → 니지카 → 료 → 키타
-
히토리
incoming과 outgoing은 어떻게 지금 검사 중인 계좌를 알아요?
-
니지카
각 subquery의 account_id를 바깥 a.account_id와 같게 둬 account마다 별도의 존재 검사를 실행해.
-
료
그 상관식을 빼면 다른 계좌의 입출금 한 건이 모든 후보 판정에 섞이는 심각한 반례가 생긴다.
-
키타
두 subquery 모두 `...account_id = a.account_id`가 있어야 102의 기록만 102 판정에 사용돼요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | account 102의 TRANSFER_IN 300 한 건 | incoming 종류와 account_id 상관 조건을 만족시킨다 | 첫 EXISTS=true가 되어 입금 조건을 통과한다 | 금액 크기와 business_tx status는 보지 않음 |
| 2 | account 102의 WITHDRAW·TRANSFER_OUT 0건 | outgoing subquery가 matching row를 찾지 못한다 | NOT EXISTS=true가 되어 account 102가 최종 통과한다 | 새 outgoing entry_type은 목록에 자동 포함되지 않음 |
| 3 | account 101의 DEPOSIT 1000, WITHDRAW -500, TRANSFER_OUT -300·-200 | incoming EXISTS=true지만 outgoing EXISTS도 true로 만든다 | NOT EXISTS=false라 account 101은 탈락한다 | 한 outgoing 행만 있어도 탈락 |
| 4 | fixture의 account 8개 | 두 상관 subquery를 각 account_id에 적용하고 정렬한다 | account 102와 105 두 행이 ID 오름차순으로 남는다 | account 103·104는 ledger 0건이라 첫 EXISTS=false |
W13-SQL-Q18 · 102는 통과하고 101은 탈락히토리 → 니지카 → 료 → 키타
-
히토리
fixture 계좌 102와 101은 각각 어떤 boolean 값을 얻나요?
-
니지카
102는 TRANSFER_IN 300 때문에 incoming=true이고 outgoing 0건이라 남지만, 101은 입금과 출금 방향이 모두 있어 NOT EXISTS=false가 돼.
-
료
최종 ORDER BY 뒤에는 조건을 모두 만족한 102와 105 두 행만 ID 순서로 보인다.
-
키타
fixture 결과는 account_id 102, 105 순서의 정확히 두 행인지 확인해요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
ON_ERROR_STOP과 격리 workbook search_path를 먼저 적용한다.
psql 변수와 fixture 준비는 실행 환경 책임이다.각 계좌에 입금 방향 원장이 하나 이상 있고 출금 방향 원장은 하나도 없는지 correlated EXISTS 두 개로 판정한다.
이 예시는 EXPLAIN이나 성능 측정을 실행하지 않는다.예상값은 fixture accounts=102,105, rows=2에 묶여 있다.
운영 데이터와 perf_w13 schema로 일반화하지 않는다.STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ SELECT 1은 금액 1인 행만 찾는다
왜 틀리나 EXISTS 안의 projection 값은 존재 판정에 쓰이지 않는다
바르게 읽기 SELECT 1을 존재 신호로 읽는다
반례 fixture의 금액 1000인 DEPOSIT도 EXISTS를 만족한다
❌ NOT IN도 언제나 같은 결과다
왜 틀리나 subquery에 NULL이 끼면 UNKNOWN 때문에 결과가 달라질 수 있다
바르게 읽기 상관 NOT EXISTS로 anti condition을 표현한다
반례 NULL 하나가 NOT IN 후보를 전부 막을 수 있다
❌ 출금 없음은 업무거래 실패까지 확인한다
왜 틀리나 이 query는 ledger_entry 종류만 본다
바르게 읽기 원장 관점의 예시라고 한정한다
반례 business_tx status가 필요하면 별도 JOIN 조건이 필요하다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
OPENING·REVERSAL의 입출금 취급을 결정하지 않는다
이 책임을 맡는 곳: 업무 정책business_tx 성공 여부를 검사하지 않는다
이 책임을 맡는 곳: 업무거래 조회공식 workbook 답안이나 운영 최적 query를 보장하지 않는다
이 책임을 맡는 곳: 학습 과제 계약W13-SQL-Q18 · NULL 함정과 원장 관점히토리 → 니지카 → 료 → 키타
-
히토리
NOT EXISTS를 썼으니 거래 상태까지 안전하게 확인한 건가요?
-
니지카
NULL 목록 비교 함정은 피하지만 이 source는 ledger_entry 종류만 보고 business_tx status는 전혀 JOIN하지 않아.
-
료
공식 workbook 답안이나 모든 새 entry_type을 아우르는 운영 정책으로 일반화하면 안 된다.
-
키타
business_tx 상태 조건이 필요하다면 현재 23줄 밖에 JOIN과 정책을 새로 설계해야 해요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 입금은 EXISTS이고 출금은 NOT EXISTS인가?
- 왜 SELECT 1로도 존재 여부를 확인할 수 있나?
- 왜 NOT IN 대신 NOT EXISTS가 NULL 함정을 피하나?
2단계 · 코드 조각 재조립
- 첫 EXISTS가 102의 TRANSFER_IN 300이나 105의 DEPOSIT처럼 같은 account_id의 incoming 행을 찾는다.
- NOT EXISTS가 101의 WITHDRAW -500·TRANSFER_OUT -300·-200 같은 outgoing 행이 있는 계좌를 거부한다.
- SELECT 1은 금액이 아니라 행 존재를 뜻하므로 DEPOSIT 1000도 EXISTS를 만족한다.
3단계 · 파일 전체 다시 쓰기
W13-SQL-Q18 prompt와 fixture 가정을 먼저 적고 전체 SQL을 다시 작성한다.
자가 점검
- canonical_answer=false와 가정을 적었는가
- 모든 비어 있지 않은 원본 줄을 보존했는가
- 출력 cardinality와 반례를 설명했는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- W13-SQL-Q18 illustrative example; canonical_answer=false
-- Assumption: DEPOSIT/TRANSFER_IN are cash in; WITHDRAW/TRANSFER_OUT are cash out.
-- Expected cardinality: 2 rows on the supplied workbook fixture (accounts 102 and 105).
-- This is one auditable learning example, not the workbook's shipped answer.
\set ON_ERROR_STOP on
SET search_path TO :"workbook_schema", public;
SELECT a.account_id,
a.account_no
FROM account AS a
WHERE EXISTS (
SELECT 1
FROM ledger_entry AS incoming
WHERE incoming.account_id = a.account_id
AND incoming.entry_type IN ('DEPOSIT', 'TRANSFER_IN')
)
AND NOT EXISTS (
SELECT 1
FROM ledger_entry AS outgoing
WHERE outgoing.account_id = a.account_id
AND outgoing.entry_type IN ('WITHDRAW', 'TRANSFER_OUT')
)
ORDER BY a.account_id;