STARRY PASS 16 — 잠긴 사물함과 네 개의 도장
이름을 아는 것과 남의 것을 열어도 되는 것은 전혀 다른 문제였다
이 이야기는 p645–681 전부 — W20 개요 + D1–D6만 다룹니다. p682 D7부터, 주간 통합, 공식 참고, W21은 포함하지 않습니다. strict D1–D6 초회 예상시간은 7시간 50분–14시간 50분입니다. 공통 코어 · 트랙 선택 시 증권 기본이며, 증권 전용 기능을 구현하는 주차는 아닙니다. SOURCE AUDIT PASS는 PDF·package·display byte 폐쇄를 확인한 정적 판정일 뿐입니다. 현재 상태는 W20 D1–D6 INTEGRATION NOT_RUN이고 이 preview의 파생 표지는 testsExecutedByAudit=0입니다. source-local @Test 4개는 선언 수이지 실행 수가 아닙니다. 원문 감사의 정확한 실행 경계는 This source audit records source/test/seed oracles; it does not claim Docker/Testcontainers execution Green.이며 learner runtime Green, learner authorship, 모든 거절 audit을 주장하지 않습니다.
월요일 저녁, STARRY 카운터 뒤에 새 사물함 여섯 칸이 놓였다. 손님은 입구에서 이름표를 확인받고 자기 공연카드를 꺼내는 방식이었다. 키타가 시험용 이름표를 대자 화면에 “customer-2”가 떴다. 반짝이는 표시를 본 키타는 가장 가까운 칸의 손잡이를 잡았지만, 그 칸에는 “customer-1”이라고 적혀 있었다.
히토리가 키타의 소매를 잡았다.
“이름은 확인됐는데요. 그게 이 칸의 주인이라는 뜻은 아니지 않나요?”
니지카는 입구 표와 사물함 표를 나란히 놓았다. 첫 표는 authentication, 즉 요청자가 누구인지 확인하는 표였다. 둘째 표는 authorization, 그중에서도 요청한 account의 실제 owner인지 확인하는 object authorization 표였다. 로그인 성공은 첫 문을 통과했다는 뜻일 뿐, 다른 사람의 account를 조회하거나 돈을 움직여도 된다는 만능 열쇠가 아니었다. URL의 account id를 바꿔 남의 자원에 접근하는 위험은 BOLA라는 이름으로 불렸다.

먼저 네 사람은 reference 작업장과 learner 작업장을 서로 다른 절대경로로 나눴다. learner 쪽에 놓인 AccountAuthorization.java starter의 requireOwner는 아무것도 하지 않고 곧바로 돌아왔다. constructor도 repository와 event publisher를 보관하지 않았다. SecurityConfiguration.java starter는 local과 cloud에서 모두 anyRequest().permitAll()이었고 Basic 인증까지 켜 두었다. 두 파일은 안전한 예제가 아니라, 위험한 빈칸을 선명하게 보이게 만든 LEARNER_TARGET_STARTER였다.
료가 permitAll 표지 아래에서 남의 사물함을 열었다. 아무 경고도 울리지 않았다. 키타가 “설치는 잘됐네요!”라며 초록 도장을 들자 세이카가 막았다.
“설치와 안전은 다른 칸이야.”
D1의 expected output은 W20 Starter; provided tests + intentionally incomplete targets installed였다. evidence/w20/bridge-starter.json에는 phase, 실제 path와 source/target hash, ownership이 기록돼야 했다. 하지만 이 문구는 두 starter와 제공 test가 정확한 상대경로에 들어왔다는 뜻일 뿐, 401·403·400·200이나 cloud fail-closed가 Green이라는 뜻이 아니었다. ReferenceRoot와 LearnerRoot가 같다면 작성 주체와 복사 원본을 구분할 수 없으므로 즉시 중단해야 했다.
둘째 날, 니지카는 세 장의 제공 test 계약서를 펼쳤다. CloudFailClosedSecurityIT에는 cloud profile의 health가 200이어야 하고, 학습용 Basic 자격으로 account를 만들려는 POST는 403이어야 하며, WWW-Authenticate header가 없어야 하고 account row 수도 0이어야 한다는 test 한 개가 있었다.
ObjectAuthorizationIT에는 두 test가 있었다. 첫째는 customer-1의 자기 account GET은 200, customer-2의 같은 account GET은 403과 ACCESS_DENIED여야 했다. 둘째는 customer-2가 customer-1의 account에서 이체하려 하면 403이어야 하고, 원 balance 10000과 TRANSFER business_tx count=0이 그대로여야 했다.
SecurityStatusContractTest에는 같은 route set에서 익명 401, 인증된 타인 403, owner의 invalid opening request 400, owner GET 200을 구분하는 test 한 개가 있었다. 세 source에 선언된 @Test는 모두 4개였다. 그러나 그것은 source inventory였다. 이 미리보기 감사가 실행한 test는 0개였다.
D2의 exact Red marker는 ownerCanReadButAnotherAuthenticatedCustomerCannot와 anonymous401OtherOwner403Invalid400AndOwner200AreDistinct 두 개였다. tests>0, 두 marker, native_exit!=0이 함께 있어야 했다. import 오류나 문법 오류로 test에 도달하지 못한 실패는 intentional Red가 아니었다. cloud test는 읽어야 할 제공 계약이지만 learner target의 두 exact Red marker 중 하나는 아니었다.
전체 expected 문구는 W20 LearnerRed; native_exit!=0; exact markers=ownerCanReadButAnotherAuthenticatedCustomerCannot;anonymous401OtherOwner403Invalid400AndOwner200AreDistinct였다. 히토리는 길게 이어진 문자열을 종이에 옮겨 적다가 물었다.
“빨갛게 끝났으면 성공이라고 적으면 안 되나요?”
“어디서 넘어졌는지를 봐야 해.” 니지카가 답했다. “문 앞에서 신발끈에 걸린 것과, 잠긴 문이 정말 열리지 않은 건 다른 실패야.”
네 개의 HTTP status는 점수표가 아니었다. 401은 주체 확인 실패, 403은 확인된 주체의 권한 부족, 400은 권한을 통과한 요청의 입력 계약 위반, 200은 이 test가 고정한 owner 조회 성공이었다. 한 줄의 permitAll이나 한 handler가 네 원인을 섞어 버리면 사용자는 무엇을 고쳐야 하는지 모르고, 운영자는 어디서 정책이 빠졌는지 찾기 어려웠다.
evidence/w20/bridge-recordred.json은 실제 JUnit XML의 지정 실패와 ownership을 담는 주 증거였다. expected 문구를 읽은 것만으로 evidence가 생기지 않았다. 세 test source는 PROVIDED_TEST_CONTRACT였고, 학습자가 고칠 production target 두 개와 저작권 열을 섞을 수 없었다.
셋째 날, 네 사람은 starter 두 개를 직접 고치는 연습에 들어갔다. AccountAuthorization은 request body의 actor 문자열을 믿는 장치가 아니었다. 인증된 principal에서 얻은 actor id와 DB에서 읽은 account owner id를 비교해야 했다. account가 없다면 ACCOUNT_NOT_FOUND, owner가 맞으면 조용히 return, 다른 owner라면 DeniedAuditEvent를 publish한 뒤 ACCESS_DENIED를 던지는 흐름이었다.

료가 denied event 종이를 경비 일지 상자에 넣으며 “이제 모든 거절이 영구 보관됐네”라고 말했다. 세이카가 즉시 종이를 다시 꺼냈다. source에는 event publish가 보였지만 W20 exact selector는 모든 denial의 durable audit 저장, exactly-once, 보관 기간, tamper resistance, 저장 실패 정책을 assert하지 않았다. D4에서도 AFTER_ROLLBACK audit 완료를 주장하면 안 됐다.
actor id가 request body에서 오면 공격자가 customer-1이라고 써서 owner 검사를 속일 수 있었다. check와 실제 mutation 사이에 account owner나 상태가 바뀌는 TOCTOU도 별도 transaction·concurrency 설계가 필요했다. receiver account의 소유권과 존재성은 from account의 출금 권한과 같은 규칙이 아니었다. 하나의 requireOwner가 모든 자원·행동 정책을 자동으로 해결하지 않았다.
다음은 SecurityConfiguration이었다. local/test에서는 health를 공개하고 /api/**는 인증을 요구하며 나머지는 거절했다. 학습용 customer-1·customer-2와 {noop}password는 local/test fixture일 뿐 운영 credential이 아니었다. solution source는 익명 요청의 401 JSON에 INVALID_REQUEST, 권한 없는 요청의 403 JSON에 ACCESS_DENIED를 쓰고 requestId를 포함했다. 다만 SecurityStatusContractTest는 401 status만 assert하며 401 body의 errorCode까지 assert하지 않는다.
cloud/prod에서는 health만 공개하고 나머지를 기본 거절했다. Basic은 비활성화했다. 운영 인증이 아직 준비되지 않았다면 연습용 사용자를 외부에 내보내는 대신 API 자체를 fail-closed로 닫는 선택이었다.
이 구성이 production authentication을 제공한다는 뜻은 아니었다. cloud API를 닫았다는 뜻이었다. local API의 CSRF ignore는 완전한 CORS 정책을 증명하지 않았고, 실제 test도 browser origin·preflight 전체를 검사하지 않았다. 문자열로 JSON을 쓰는 helper는 controlled message를 전제로 했으며 임의 사용자 입력 escaping의 일반 해답이 아니었다.
D3 expected output은 W20 LearnerSolutionVerified; exact target hashes=2였다. Red를 확보한 뒤 두 target을 직접 입력한 경로만 learner-authored가 될 수 있었다. 수동 복구 QaSolution --qa-recovery를 쓰면 QA_OVERLAY/VerifiedGreen으로 기록해야 하고 포트폴리오의 learner-authored Green으로 바꿔 적을 수 없었다.
같은 날 Q31 “고객별 잔액 순위”를 풀 계획도 세웠다. Q31 예상시간 35분–1시간은 D3 전체 1시간 40분–3시간 10분에 이미 포함돼 있으므로 주간 합계에 다시 더하지 않았다. shipped workbook 정본 답안은 없었다. illustrative SQL은 customer를 시작 table로 삼아 account가 없는 고객도 total_balance=0으로 남기고, ACTIVE와 CLOSED account를 모두 합산한다는 가정을 공개했다. customer 한 명이 output 한 행이었다.
RANK()와 DENSE_RANK()는 동점 이후가 달랐다. 100, 100, 50이면 둘 다 앞의 두 행을 공동 1등으로 두지만 다음 행은 RANK 3, DENSE_RANK 2가 됐다. main oracle은 6행, customer6이 total 1000000과 rank/dense rank 1/1, customer4와 customer5가 total 0과 5/5였다. 동점 내부 표시 순서는 customer_id로 안정화했다.
이 SQL은 학습용 예시 · 정본 답안 아님이었다. CLOSED account 포함, 계좌 없는 고객 보존, 순위 grain은 prompt가 고정하지 않은 부분을 예시가 명시한 가정이었다. evidence/w20/sql-q31.sql은 learner가 자기 query를 실제 PostgreSQL fixture에서 실행하고 table grain·cardinality·반례를 연결할 때 생기는 증거였다.
넷째 날에는 두 learner solution과 제공 cloud fixture를 함께 검증할 계획표가 나왔다. D4의 Green selector는 ObjectAuthorizationIT, SecurityStatusContractTest, CloudFailClosedSecurityIT 세 개였다. 실제 RecordGreen이라면 새 JUnit XML에서 tests>0, failures·errors·skipped=0, native exit=0을 확인해야 했다.
키타가 세 selector 옆에 “우리가 만든 세 파일”이라고 쓰려 하자 히토리가 지웠다. learner target은 AccountAuthorization.java와 SecurityConfiguration.java 두 개였다. 세 test는 제공 계약이고 cloud test와 build append는 provided fixture였다. selector Green은 실행 상태이고 ownership은 source 작성 주체였다. 두 열은 독립적이었다.
D4 expected output은 W20 LearnerGreen for learner-typed path OR VerifiedGreen source_mode=QA_OVERLAY for recovery path였다. 앞 경로는 Red 뒤 직접 입력한 두 target의 learner path, 뒤 경로는 수동 복구 source mode였다. evidence/w20/bridge-recordgreen.json에는 어느 길을 택했는지 숨기지 않아야 했다.
세 selector가 함께 Green이더라도 claim은 object authorization, 401·403·400·200 구분, cloud health-only fail-closed에 한정됐다. provided DeniedAuditEvent가 source에서 publish된다는 이유로 모든 denied audit 저장이 검증됐다고 넓힐 수 없었다. selector 목록에 없는 AFTER_ROLLBACK, dual control, 전체 endpoint enumeration, real cloud identity를 덧붙이지 않았다.
니지카는 “검사한 이름을 그대로 결과표 제목으로 쓰자”고 했다. 확인한 것보다 넓은 문구는 보기에는 든든하지만, 다음 사람이 실제 빈칸을 찾지 못하게 만들었다.
다섯째 날, 료는 히토리의 시험 account id를 URL에 넣고 조회했다. customer-2로 로그인했기 때문에 authentication은 성공했지만 owner는 customer-1이었다. GET은 403으로 멈췄다. 이어서 같은 account에서 1000을 보내려는 transfer도 403으로 멈췄다.

거절 화면만 확인하고 끝내지 않았다. 시작 balance는 10000이었고 거절 뒤에도 10000이어야 했다. business_tx에서 TRANSFER count도 0이어야 했다. 403을 반환한 뒤 내부에서 돈이나 거래 row가 바뀐다면 겉만 잠긴 거짓 Green이었다.
D5 exact selector는 ObjectAuthorizationIT 하나였다. expected 형식은 EXACT_SELECTOR_GREEN selectors=1 classes>=1 tests>=1 failures=0 errors=0 skipped=0 native_exit=0, 주 증거는 evidence/w20/day-5-selector-gate.json이었다. 이 두 test가 증명한 것은 owner read 200, other read/transfer 403, 특정 balance와 TRANSFER row 불변이다. 모든 downstream table, receiver 정책, concurrent owner change, owner transfer success를 전부 증명하지는 않았다.
같은 날 Q32 “계좌별 원장 누적합”도 계획했다. Q32 예상시간 35분–1시간은 D5 전체 1시간 30분–2시간 50분에 이미 포함돼 있으므로 주간 합계에 다시 더하지 않았다. illustrative SQL은 ledger_entry 한 행을 signed movement의 권위 grain으로 보고, account별로 SUM(signed_amount) OVER (...)를 계산했다. 정렬은 occurred_at, entry_id였고 frame은 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW였다. 같은 시각의 두 entry도 entry_id로 고정해야 결과가 흔들리지 않았다.
oracle은 전체 16행이었다. account101의 누계는 10000, 11000, 10500, 10200, 10000, 9400, 8800, account102는 20000, 20300이었다. account101의 같은 시각 reversal은 entry15 뒤 entry16 순서였다. ledger row가 없는 account는 나오지 않았고, 이 query 자체는 마지막 누계와 account.balance를 reconciliation하지 않았다.
Q32도 shipped workbook answer가 아니었다. ledger를 권위 원장으로 보는 가정, 0-row account 제외, tie order가 명시된 illustrative example이었다. evidence/w20/sql-q32.sql은 실제 실행 성공, opening entry부터의 누계, input/output grain, 예상 cardinality, 반례가 함께 있어야 했다.
여섯째 날, 네 사람은 네 가지 도장을 한 번 더 꺼냈다. no auth 요청에는 401, customer-2가 customer-1 account를 읽으면 403, customer-1이 opening balance 0을 보내면 400, customer-1이 자기 account를 읽으면 200이었다. D6 exact selector는 SecurityStatusContractTest 하나였다.
그런데 p676–679에 전문으로 인쇄된 source는 CloudFailClosedSecurityIT, ObjectAuthorizationIT, SecurityStatusContractTest 세 개였다. 인쇄된 세 source가 D6 exact selector 세 개라는 뜻은 아니었다. 반대로 D6 selector가 하나라고 나머지 두 source가 존재하지 않는다는 뜻도 아니었다. 실행 대상과 학습용 display 목록을 다른 표로 읽어야 했다.
D6 expected 형식은 EXACT_SELECTOR_GREEN selectors=1 classes>=1 tests>=1 failures=0 errors=0 skipped=0 native_exit=0이었다. 주 evidence는 evidence/w20/day-6-selector-gate.json이고, evidence/w20/security-status.md에는 실제 실행 날짜·report 경로·native exit·오늘 새로 쓴 파일을 기록해야 했다. source의 실제 상대경로와 package도 맞아야 했다.
그러나 SecurityStatusContractTest의 한 method가 직접 assert하는 것은 네 status와 두 errorCode뿐이었다. test는 invalid POST나 other GET 뒤 DB write count를 assert하지 않았다. 따라서 D6 selector가 “모든 거절 path write0”을 자동으로 증명한다고 쓰지 않았다. D5의 ObjectAuthorizationIT가 확인한 특정 transfer no-effect를 D6 status test의 proof로 옮겨 붙일 수도 없었다.
또 D6 핵심 개념·본문에 CORS·CSRF가 언급된다고 완전한 browser-origin 정책이 검증된 것은 아니었다. preflight, allowlist, credentialed origin, 모든 controller body schema, 모든 exception mapping은 이 selector의 직접 assertion 밖이었다. solution source는 401 JSON에 INVALID_REQUEST를 쓰지만 SecurityStatusContractTest는 401 status만 assert하고 그 errorCode는 assert하지 않는다. 따라서 source 구현 선택을 test assertion이나 모든 API의 보편 규칙으로 바꾸지 않았다.
여섯 장의 계획표 아래 시간을 더했다. D1 55분–1시간 45분, D2 1시간 5분–2시간 5분, D3 1시간 40분–3시간 10분, D4 1시간 5분–2시간 10분, D5 1시간 30분–2시간 50분, D6 1시간 35분–2시간 50분이었다. strict D1–D6 합계는 정확히 7시간 50분–14시간 50분이었다. W20 개요의 8시간 35분–16시간 20분은 이 범위 밖의 다음 모듈 45–90분까지 포함한 값이므로 서로 바꿔 쓰지 않았다.
modern 코드 뒤풀이의 display closure는 canonical 7개와 illustrative 2개였다. canonical은 intentional Red starter 2개, provided test source 3개, Green solution reference 2개였다. illustrative는 Q31·Q32 SQL 두 개였다. content aggregate vector 9/359/327/79/248/327/33/4는 teaching units 9 / physical lines 359 / nonblank lines 327 / setup lines 79 / mapping rows 248 / translation rows 327 / source chunks 33 / source-local tests 4를 뜻했다.
source-local @Test 4개는 세 제공 source에 선언된 method 수이지 이 감사의 실행 결과가 아니었다. SOURCE AUDIT PASS는 PDF/package/display closure가 byte로 닫혔다는 뜻이었다. 정확한 경계는 PDF/package/display closure is byte-verified; PASS does not claim Docker Green, learner authorship, or every denied audit.였다. 실제 runtime Green은 learner workspace의 fresh JUnit XML, native exit, source hash, source mode와 사람 검토가 필요했다.
니지카는 p681의 D6 최종 Gate에서 장부를 닫았다. 다음 페이지부터 시작되는 D7, 주간 통합, 공식 참고, W21의 내용은 이 미리보기 안으로 들이지 않았다. 히토리는 네 도장을 다시 바라봤다. 401·403·400·200은 사람의 등급이 아니라 요청이 멈춘 문을 알려 주는 표지였다.
잠긴 사물함은 불친절한 장치가 아니었다. 이름을 확인한 사람에게도 자기 것만 열어 주고, 거절된 요청은 돈과 거래를 바꾸지 않으며, 준비되지 않은 외부 문은 닫아 두는 약속이었다. 무엇을 test했고 무엇을 쓰지 않았는지까지 구분하자, STARRY의 열쇠표는 비로소 믿을 만해졌다.
이미지 출처와 이용 안내: 공식 장면컷은 TV 애니메이션 《봇치 더 록!》 공식 사이트 자료입니다. ©はまじあき/芳文社・アニプレックス. 이 문서는 개인 학습용 비공식 구성으로, 원작의 공식 설정과 별개의 학습 비유입니다. 개념도는 W20 source audit의 수치·ownership·미보장 경계에 맞춰 새로 만든 교육용 SVG입니다.