W20 · 권한·API 안전성

20주차 코드 뒤풀이: BOLA·status matrix·cloud fail-closed를 값으로 따라가기

intentional Red starter 2개, 제공 test 3개, Green learner 해법 2개를 역할별로 분리해 읽습니다. principal과 account owner가 service 경계에서 어떻게 갈리는지, 401·403·400·200 및 cloud 403 뒤 DB write 0을 무엇이 직접 증명하는지 연결합니다. Q31·Q32는 workbook에 정답 query가 배포되지 않아 가정을 표시한 비정본 예시로만 제공합니다.

원문 정본 · intentional Red starter 2개원문 정본 · 제공 test 계약 3개원문 정본 · Green learner 해법 2개학습용 예시 · 정본 답안 아님 2개항목마다 13단계연결 248줄번역 327줄
01

AccountAuthorization.java — 아무도 막지 않는 소유권 Red starter

learning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/AccountAuthorization.java

원문 정본 · intentional Red starter · 정본 · W20-F01
1줄 연결3줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

  1. actor=customer-2은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `starter는 실패해야 하는 입력이지 안전한 구현이 아니다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값actor=customer-2owned account=customer-1requireOwner returnsforbidden expected
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

AccountAuthorization.java — 아무도 막지 않는 소유권 Red starter를 출입문 검사표로 바꾸기

소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

핵심값 actor=customer-2, owned account=customer-1, requireOwner returns, forbidden expected을 원본 줄로 따라가되, starter는 실패해야 하는 입력이지 안전한 구현이 아니다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

package

1~1줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

코드 연결
1~1줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
starter는 실패해야 하는 입력이지 안전한 구현이 아니다

compact imports

2~2줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

코드 연결
2~2줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
constructor가 repository와 publisher를 저장하지 않는다

empty authorization starter

3~3줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

코드 연결
3~3줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
missing account와 other-owner를 구분하지 않는다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? actor=customer-2부터 보면 될까?

  2. 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

  3. source에서 관찰할 첫 값은 `actor=customer-2`이네.

  4. 그리고 `starter는 실패해야 하는 입력이지 안전한 구현이 아니다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `owned account=customer-1`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. Spring이 bean을 만든다. → service가 actor/account/action/requestId를 넘긴다.

  3. 다음 단계는 method가 비교 없이 반환한다. → 타인 요청도 다음 business code로 진행한다.

  4. 최종값 `owned account=customer-1`과 미보장 `constructor가 repository와 publisher를 저장하지 않는다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · intentional Red starter에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.1 / 1 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
3줄F01-L03 @Component public class AccountAuthorization{public AccountAuthorization(AccountRepository a,ApplicationEventPublisher e){}public void requireOwner(String actorId,long accountId,String action,String requestId){}} 티켓 이름과 좌석 주인을 대조하는 입장 담당자 Red @Component 안에 dependency를 저장하지 않는 constructor와 아무 검사도 하지 않는 requireOwner를 선언한다.
입력
actorId, accountId, action, requestId 또는 Spring dependency
결과·효과
그 결과 F01의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: requireOwner returns.
비유의 한계
missing account와 other-owner를 구분하지 않는다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `forbidden expected`을 source 줄과 test card로 대조하면 된다.

  4. `missing account와 other-owner를 구분하지 않는다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F01-C01 · package1–1줄
1–1줄 원본
package com.example.financialcore.security;
F01-C02 · compact imports2–2줄
2–2줄 원본
import com.example.financialcore.account.AccountRepository;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Component;
F01-C03 · empty authorization starter3–3줄
3–3줄 원본
@Component public class AccountAuthorization{public AccountAuthorization(AccountRepository a,ApplicationEventPublisher e){}public void requireOwner(String actorId,long accountId,String action,String requestId){}}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 3 / 3

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

준비·설명 줄 2개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
2import com.example.financialcore.account.AccountRepository;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Component;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
3@Component public class AccountAuthorization{public AccountAuthorization(AccountRepository a,ApplicationEventPublisher e){}public void requireOwner(String actorId,long accountId,String action,String requestId){}}Red @Component 안에 dependency를 저장하지 않는 constructor와 아무 검사도 하지 않는 requireOwner를 선언한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다. 다만 starter는 실패해야 하는 입력이지 안전한 구현이 아니다

문법 해부

  • 한 줄 compact source 안에 @Component, constructor, requireOwner가 함께 있다.
  • 빈 `{}` body는 정상 종료를 뜻하므로 호출자가 접근 허용으로 이어 간다.

실행 순서

  1. Spring이 bean을 만든다.
  2. service가 actor/account/action/requestId를 넘긴다.
  3. method가 비교 없이 반환한다.
  4. 타인 요청도 다음 business code로 진행한다.

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

F01-C01 · package
문법 해부
1~1줄의 `package`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `actor=customer-2`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `actor=customer-2`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: starter는 실패해야 하는 입력이지 안전한 구현이 아니다.
착각 방지
`package`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: starter는 실패해야 하는 입력이지 안전한 구현이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F01-C02 · compact imports
문법 해부
2~2줄의 `compact imports`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `owned account=customer-1`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `owned account=customer-1`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: constructor가 repository와 publisher를 저장하지 않는다.
착각 방지
`compact imports`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: constructor가 repository와 publisher를 저장하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F01-C03 · empty authorization starter
문법 해부
3~3줄의 `empty authorization starter`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `requireOwner returns`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `requireOwner returns`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: missing account와 other-owner를 구분하지 않는다.
착각 방지
`empty authorization starter`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: missing account와 other-owner를 구분하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. customer-2가 customer-1 account에 도달한다.

  3. counterexample의 이유는 `authorization gate의 정상 반환은 보통 허용을 뜻한다.`이야.

  4. 고친 문장은 `타인은 예외로 중단해야 한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F01-T01 타인 조회customer-2 / customer-1 account빈 requireOwner 호출예외 없음원래는 403이어야 한다
F01-T02 타인 이체from=customer-1 account빈 requireOwner 호출mutation까지 도달 가능balance/tx write 0 계약이 깨진다
F01-T03 없는 계좌unknown accountIdrepository를 읽지 않음ACCOUNT_NOT_FOUND 없음존재 여부 구분도 사라진다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `비어 있는 Red 소유권 gate`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `audit를 발행하지 않는다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

Spring bean

@Component가 class를 등록하지만 constructor dependency를 저장하지 않는다.

bean 등록은 authorization 구현이 아니다.
service boundary

호출은 일어나도 empty body가 policy decision을 만들지 않는다.

controller 앞단 인증만으로 BOLA를 막을 수 없다.
audit

ApplicationEventPublisher가 전달돼도 사용되지 않는다.

거절도 event도 발생하지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 빈 method면 안전하게 아무것도 안 한다

왜 틀리나 authorization gate의 정상 반환은 보통 허용을 뜻한다.

바르게 읽기 타인은 예외로 중단해야 한다.

반례 customer-2가 customer-1 account에 도달한다.

❌ 인증된 고객이면 모든 계좌를 볼 수 있다

왜 틀리나 authentication은 신원만 확인한다.

바르게 읽기 resource owner를 별도로 비교한다.

반례 ObjectAuthorizationIT의 other-owner GET은 403이다.

❌ constructor parameter가 있으면 dependency가 쓰인다

왜 틀리나 저장하거나 호출하지 않으면 아무 효과가 없다.

바르게 읽기 field에 보존하고 repository/publisher를 실제 사용한다.

반례 starter constructor body가 비어 있다.

❌ 없는 계좌와 타인 계좌는 같은 오류다

왜 틀리나 존재하지 않음과 권한 거절은 서로 다른 domain 상태다.

바르게 읽기 missing은 ACCOUNT_NOT_FOUND, mismatch는 ACCESS_DENIED로 나눈다.

반례 Green F06의 두 if가 다르다.

❌ controller check 하나면 충분하다

왜 틀리나 다른 진입점이 service를 직접 호출할 수 있다.

바르게 읽기 소유권은 service/authorization boundary에 둔다.

반례 batch·다른 controller가 우회할 수 있다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

starter는 실패해야 하는 입력이지 안전한 구현이 아니다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

constructor가 repository와 publisher를 저장하지 않는다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

missing account와 other-owner를 구분하지 않는다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

audit를 발행하지 않는다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.

2단계 · 코드 조각 재조립

  1. package
  2. compact imports
  3. empty authorization starter

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

3개 물리 줄을 원본 순서로 복원하고 SHA-256 5f7b0539973b4f278502408f2747efa1095d0b1809dd27441f41219a2cc929dd와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · intentional Red starterlearning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/AccountAuthorization.javaSHA-256 5f7b0539973b4f278502408f2747efa1095d0b1809dd27441f41219a2cc929dd
AccountAuthorization.java — 아무도 막지 않는 소유권 Red starter 전체
package com.example.financialcore.security;
import com.example.financialcore.account.AccountRepository;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Component;
@Component public class AccountAuthorization{public AccountAuthorization(AccountRepository a,ApplicationEventPublisher e){}public void requireOwner(String actorId,long accountId,String action,String requestId){}}
02

SecurityConfiguration.java — local·cloud permitAll Red starter

learning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/SecurityConfiguration.java

원문 정본 · intentional Red starter · 정본 · W20-F02
1줄 연결3줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

  1. local anyRequest=permitAll은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `starter는 정답이 아니다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값local anyRequest=permitAllcloud anyRequest=permitAllcloud Basic=enabledunsafe POST writes
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

SecurityConfiguration.java — local·cloud permitAll Red starter를 출입문 검사표로 바꾸기

local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

핵심값 local anyRequest=permitAll, cloud anyRequest=permitAll, cloud Basic=enabled, unsafe POST writes을 원본 줄로 따라가되, starter는 정답이 아니다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

package

1~1줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

코드 연결
1~1줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
starter는 정답이 아니다

compact security imports

2~2줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

코드 연결
2~2줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
두 profile 모두 API를 공개한다

permit-all local and cloud starter

3~3줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

코드 연결
3~3줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
cloud가 local teaching user를 받아들인다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? local anyRequest=permitAll부터 보면 될까?

  2. local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

  3. source에서 관찰할 첫 값은 `local anyRequest=permitAll`이네.

  4. 그리고 `starter는 정답이 아니다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `cloud anyRequest=permitAll`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. profile에 맞는 chain bean이 선택된다. → 모든 URL이 permitAll에 걸린다.

  3. 다음 단계는 CSRF 검사가 꺼진다. → cloud에서도 Basic filter가 켜진다.

  4. 최종값 `cloud anyRequest=permitAll`과 미보장 `두 profile 모두 API를 공개한다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · intentional Red starter에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.1 / 1 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
3줄F02-L03 @Configuration public class SecurityConfiguration{@Bean @Profile({"default","local","test"}) UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build());}@Bean @Profile({"default","local","test"}) SecurityFilterChain local(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}@Bean @Profile({"cloud","prod"}) SecurityFilterChain cloud(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}} 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 두 teaching user를 만들고 local/cloud 모두 permitAll·CSRF disable·Basic enable로 여는 intentional Red 설정 전체다.
입력
active profile, URL, credential, CSRF state
결과·효과
그 결과 F02의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic=enabled.
비유의 한계
cloud가 local teaching user를 받아들인다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `unsafe POST writes`을 source 줄과 test card로 대조하면 된다.

  4. `cloud가 local teaching user를 받아들인다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F02-C01 · package1–1줄
1–1줄 원본
package com.example.financialcore.security;
F02-C02 · compact security imports2–2줄
2–2줄 원본
import org.springframework.context.annotation.*;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.*;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;
F02-C03 · permit-all local and cloud starter3–3줄
3–3줄 원본
@Configuration public class SecurityConfiguration{@Bean @Profile({"default","local","test"}) UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build());}@Bean @Profile({"default","local","test"}) SecurityFilterChain local(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}@Bean @Profile({"cloud","prod"}) SecurityFilterChain cloud(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 3 / 3

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

준비·설명 줄 2개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
2import org.springframework.context.annotation.*;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.*;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
3@Configuration public class SecurityConfiguration{@Bean @Profile({"default","local","test"}) UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build());}@Bean @Profile({"default","local","test"}) SecurityFilterChain local(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}@Bean @Profile({"cloud","prod"}) SecurityFilterChain cloud(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}}두 teaching user를 만들고 local/cloud 모두 permitAll·CSRF disable·Basic enable로 여는 intentional Red 설정 전체다.
07

STEP 07 / 13

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

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

한 줄로 읽기

local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다. 다만 starter는 정답이 아니다

문법 해부

  • 두 @Profile chain이 모두 `anyRequest().permitAll()`을 사용한다.
  • CSRF disable과 Basic enable이 local/cloud에 똑같이 복사돼 있다.

실행 순서

  1. profile에 맞는 chain bean이 선택된다.
  2. 모든 URL이 permitAll에 걸린다.
  3. CSRF 검사가 꺼진다.
  4. cloud에서도 Basic filter가 켜진다.

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

F02-C01 · package
문법 해부
1~1줄의 `package`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `local anyRequest=permitAll`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `local anyRequest=permitAll`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: starter는 정답이 아니다.
착각 방지
`package`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: starter는 정답이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F02-C02 · compact security imports
문법 해부
2~2줄의 `compact security imports`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `cloud anyRequest=permitAll`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `cloud anyRequest=permitAll`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 두 profile 모두 API를 공개한다.
착각 방지
`compact security imports`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 두 profile 모두 API를 공개한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F02-C03 · permit-all local and cloud starter
문법 해부
3~3줄의 `permit-all local and cloud starter`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `cloud Basic=enabled`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `cloud Basic=enabled`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: cloud가 local teaching user를 받아들인다.
착각 방지
`permit-all local and cloud starter`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: cloud가 local teaching user를 받아들인다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. anonymous local request도 controller에 도달한다.

  3. counterexample의 이유는 `permitAll은 인증 없이도 authorization을 통과시킨다.`이야.

  4. 고친 문장은 `/api/**에 authenticated를 명시한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F02-T01 local anonymousGET /api/accountspermitAllcontroller 진입원래 401이어야 한다
F02-T02 cloud Basic POSTcustomer-1 + /api/accountspermitAll + CSRF off계좌 생성 가능cloud는 403/write0이어야 한다
F02-T03 unknown path/adminanyRequest permitAll공개deny-by-default가 없다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `permitAll Red profile 설정`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `CSRF와 인증·인가를 같은 것으로 볼 수 없다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

profile

default/local/test와 cloud/prod가 서로 다른 bean을 고른다.

profile 분리만으로 안전한 rule이 생기지 않는다.
filter chain

authorize rule이 모든 request를 허용한다.

Basic 성공 여부와 무관하게 permitAll이 접근을 연다.
CSRF

csrf.disable이 browser-origin write 방어를 제거한다.

CSRF는 object authorization을 대체하지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ Basic을 켜면 API는 자동 보호된다

왜 틀리나 permitAll은 인증 없이도 authorization을 통과시킨다.

바르게 읽기 /api/**에 authenticated를 명시한다.

반례 anonymous local request도 controller에 도달한다.

❌ profile이 다르면 cloud는 자동 안전하다

왜 틀리나 cloud chain도 같은 permitAll이다.

바르게 읽기 health 외 anyRequest denyAll로 닫는다.

반례 cloud Basic POST가 write를 만들 수 있다.

❌ CSRF off가 REST 보안의 완성이다

왜 틀리나 CSRF와 인증·소유권·CORS는 다른 축이다.

바르게 읽기 각 threat를 별도 계약으로 다룬다.

반례 BOLA는 CSRF 설정과 무관하다.

❌ anyRequest는 마지막이라 영향이 작다

왜 틀리나 앞 matcher가 없으므로 전체 URL을 포괄한다.

바르게 읽기 필요 경로를 먼저 좁히고 마지막을 denyAll로 둔다.

반례 /admin도 허용된다.

❌ noop password는 운영에도 괜찮다

왜 틀리나 평문 teaching credential이다.

바르게 읽기 local/test에만 격리한다.

반례 cloud/prod에서는 Basic 자체를 끈다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

starter는 정답이 아니다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

두 profile 모두 API를 공개한다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

cloud가 local teaching user를 받아들인다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

CSRF와 인증·인가를 같은 것으로 볼 수 없다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.

2단계 · 코드 조각 재조립

  1. package
  2. compact security imports
  3. permit-all local and cloud starter

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

3개 물리 줄을 원본 순서로 복원하고 SHA-256 b191483d1a7187ced1f3451c64ba15feeaf2cec61b312ec0e04a37ca0fb496a3와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · intentional Red starterlearning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/SecurityConfiguration.javaSHA-256 b191483d1a7187ced1f3451c64ba15feeaf2cec61b312ec0e04a37ca0fb496a3
SecurityConfiguration.java — local·cloud permitAll Red starter 전체
package com.example.financialcore.security;
import org.springframework.context.annotation.*;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.*;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;
@Configuration public class SecurityConfiguration{@Bean @Profile({"default","local","test"}) UserDetailsService users(){return new InMemoryUserDetailsManager(User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build());}@Bean @Profile({"default","local","test"}) SecurityFilterChain local(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}@Bean @Profile({"cloud","prod"}) SecurityFilterChain cloud(HttpSecurity h)throws Exception{return h.authorizeHttpRequests(a->a.anyRequest().permitAll()).csrf(c->c.disable()).httpBasic(Customizer.withDefaults()).build();}}
03

CloudFailClosedSecurityIT.java — health 공개·API write 0 계약

learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/CloudFailClosedSecurityIT.java

원문 정본 · 제공 test 계약 · 정본 · W20-F03
24줄 연결40줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

  1. health=200은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `한 unsafe endpoint만 검사한다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값health=200POST /api/accounts=403WWW-Authenticate absentaccount count=0
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

CloudFailClosedSecurityIT.java — health 공개·API write 0 계약를 출입문 검사표로 바꾸기

cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

핵심값 health=200, POST /api/accounts=403, WWW-Authenticate absent, account count=0을 원본 줄로 따라가되, 한 unsafe endpoint만 검사한다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

imports and static test tools

1~18줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

코드 연결
1~18줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
한 unsafe endpoint만 검사한다

cloud profile fixture and cleanup

19~31줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

코드 연결
19~31줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
CORS 전체 정책은 검사하지 않는다

public health and fail-closed write proof

32~45줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

코드 연결
32~45줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
cloud에 운영 인증을 제공한다는 뜻이 아니다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? health=200부터 보면 될까?

  2. cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

  3. source에서 관찰할 첫 값은 `health=200`이네.

  4. 그리고 `한 unsafe endpoint만 검사한다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `POST /api/accounts=403`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. DB를 비운다. → health GET 200을 확인한다.

  3. 다음 단계는 Basic account POST를 보낸다. → 403·challenge 없음·account count 0을 확인한다.

  4. 최종값 `POST /api/accounts=403`과 미보장 `CORS 전체 정책은 검사하지 않는다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.24 / 24 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
20줄F03-L20 @SpringBootTest 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 실제 Spring application context를 띄우는 통합 test임을 선언한다.
입력
account count=0
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account count=0.
비유의 한계
audit row는 assert하지 않는다
21줄F03-L21 @AutoConfigureMockMvc 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
22줄F03-L22 @ActiveProfiles("cloud") 공연장 모드별로 다른 출입문 규칙표 cloud profile 설정만 활성화한다.
입력
POST /api/accounts=403
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: POST /api/accounts=403.
비유의 한계
CORS 전체 정책은 검사하지 않는다
23줄F03-L23 class CloudFailClosedSecurityIT extends PostgresIntegrationTestSupport { 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 cloud fail-closed HTTP/DB 계약을 담는 PostgreSQL 통합 test class를 연다.
입력
WWW-Authenticate absent
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: WWW-Authenticate absent.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
24줄F03-L24 @Autowired MockMvc mvc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
입력
account count=0
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account count=0.
비유의 한계
audit row는 assert하지 않는다
25줄F03-L25 @Autowired JdbcClient jdbc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
27줄F03-L27 @BeforeEach 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test 전에 바로 아래 clean fixture를 실행한다.
입력
WWW-Authenticate absent
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: WWW-Authenticate absent.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
28줄F03-L28 void clean() { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
입력
account count=0
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account count=0.
비유의 한계
audit row는 assert하지 않는다
29줄F03-L29 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 관련 business/audit table을 FK 순서와 identity까지 비운다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
30줄F03-L30 .update(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞의 TRUNCATE SQL을 실제로 실행한다.
입력
POST /api/accounts=403
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: POST /api/accounts=403.
비유의 한계
CORS 전체 정책은 검사하지 않는다
31줄F03-L31 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
WWW-Authenticate absent
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: WWW-Authenticate absent.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
33줄F03-L33 @Test 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
34줄F03-L34 void healthIsPublicButBasicCannotOpenOrMutateTheCloudApi() throws Exception { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 health 공개와 cloud unsafe write 차단을 함께 검증할 test method를 연다.
입력
POST /api/accounts=403
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: POST /api/accounts=403.
비유의 한계
CORS 전체 정책은 검사하지 않는다
35줄F03-L35 mvc.perform(get("/actuator/health")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 cloud health endpoint에 GET 요청을 만든다.
입력
WWW-Authenticate absent
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: WWW-Authenticate absent.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
36줄F03-L36 .andExpect(status().isOk()); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 현재 HTTP 결과가 200 OK인지 assertion한다.
입력
account count=0
결과·효과
그 결과 실제 HTTP 관찰 상태가 200으로 고정된다.
비유의 한계
audit row는 assert하지 않는다
37줄F03-L37 mvc.perform(post("/api/accounts") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 account 생성 API에 POST 요청을 만든다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
38줄F03-L38 .with(httpBasic("customer-1", "password")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: POST /api/accounts=403.
비유의 한계
CORS 전체 정책은 검사하지 않는다
39줄F03-L39 .contentType("application/json") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청 body가 JSON임을 content type으로 선언한다.
입력
WWW-Authenticate absent
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: WWW-Authenticate absent.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
40줄F03-L40 .content("{\"accountNo\":\"CLOUD\",\"openingBalance\":1000}")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고정 JSON input을 account 생성 요청 body에 넣는다.
입력
accountNo와 openingBalance JSON
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account count=0.
비유의 한계
audit row는 assert하지 않는다
41줄F03-L41 .andExpect(status().isForbidden()) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
입력
health=200
결과·효과
그 결과 실제 HTTP 관찰 상태가 403으로 고정된다.
비유의 한계
한 unsafe endpoint만 검사한다
42줄F03-L42 .andExpect(header().doesNotExist("WWW-Authenticate")); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 cloud response가 Basic 재인증 challenge를 보내지 않는지 assertion한다.
입력
POST /api/accounts=403
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: POST /api/accounts=403.
비유의 한계
CORS 전체 정책은 검사하지 않는다
43줄F03-L43 assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero(); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 거절 뒤 account table row 수가 0인지 조회하고 assertion한다.
입력
WWW-Authenticate absent
결과·효과
그 결과 거절 뒤 관련 DB row cardinality가 0으로 고정된다.
비유의 한계
cloud에 운영 인증을 제공한다는 뜻이 아니다
44줄F03-L44 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
account count=0
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account count=0.
비유의 한계
audit row는 assert하지 않는다
45줄F03-L45 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
health=200
결과·효과
그 결과 F03의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: health=200.
비유의 한계
한 unsafe endpoint만 검사한다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `account count=0`을 source 줄과 test card로 대조하면 된다.

  4. `cloud에 운영 인증을 제공한다는 뜻이 아니다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F03-C01 · imports and static test tools1–18줄
1–18줄 원본
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
F03-C02 · cloud profile fixture and cleanup19–31줄
19–31줄 원본

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("cloud")
class CloudFailClosedSecurityIT extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
    }
F03-C03 · public health and fail-closed write proof32–45줄
32–45줄 원본

    @Test
    void healthIsPublicButBasicCannotOpenOrMutateTheCloudApi() throws Exception {
        mvc.perform(get("/actuator/health"))
            .andExpect(status().isOk());
        mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"CLOUD\",\"openingBalance\":1000}"))
            .andExpect(status().isForbidden())
            .andExpect(header().doesNotExist("WWW-Authenticate"));
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 40 / 40

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

준비·설명 줄 16개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.PostgresIntegrationTestSupport;이 source가 사용할 production/test type과 static matcher를 가져온다.
4import org.junit.jupiter.api.BeforeEach;이 source가 사용할 production/test type과 static matcher를 가져온다.
5import org.junit.jupiter.api.Test;이 source가 사용할 production/test type과 static matcher를 가져온다.
6import org.springframework.beans.factory.annotation.Autowired;이 source가 사용할 production/test type과 static matcher를 가져온다.
7import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
8import org.springframework.boot.test.context.SpringBootTest;이 source가 사용할 production/test type과 static matcher를 가져온다.
9import org.springframework.jdbc.core.simple.JdbcClient;이 source가 사용할 production/test type과 static matcher를 가져온다.
10import org.springframework.test.context.ActiveProfiles;이 source가 사용할 production/test type과 static matcher를 가져온다.
11import org.springframework.test.web.servlet.MockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
13import static org.assertj.core.api.Assertions.assertThat;이 source가 사용할 production/test type과 static matcher를 가져온다.
14import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;이 source가 사용할 production/test type과 static matcher를 가져온다.
15import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;이 source가 사용할 production/test type과 static matcher를 가져온다.
16import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;이 source가 사용할 production/test type과 static matcher를 가져온다.
17import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;이 source가 사용할 production/test type과 static matcher를 가져온다.
18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
20@SpringBootTest실제 Spring application context를 띄우는 통합 test임을 선언한다.
21@AutoConfigureMockMvcservlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
22@ActiveProfiles("cloud")cloud profile 설정만 활성화한다.
23class CloudFailClosedSecurityIT extends PostgresIntegrationTestSupport {cloud fail-closed HTTP/DB 계약을 담는 PostgreSQL 통합 test class를 연다.
24 @Autowired MockMvc mvc;HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
25 @Autowired JdbcClient jdbc;DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
27 @BeforeEach각 test 전에 바로 아래 clean fixture를 실행한다.
28 void clean() {각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
29 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")관련 business/audit table을 FK 순서와 identity까지 비운다.
30 .update();앞의 TRUNCATE SQL을 실제로 실행한다.
31 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
33 @Test바로 다음 method를 JUnit test case로 발견하게 표시한다.
34 void healthIsPublicButBasicCannotOpenOrMutateTheCloudApi() throws Exception {health 공개와 cloud unsafe write 차단을 함께 검증할 test method를 연다.
35 mvc.perform(get("/actuator/health"))cloud health endpoint에 GET 요청을 만든다.
36 .andExpect(status().isOk());현재 HTTP 결과가 200 OK인지 assertion한다.
37 mvc.perform(post("/api/accounts")account 생성 API에 POST 요청을 만든다.
38 .with(httpBasic("customer-1", "password"))요청에 local/test Basic principal customer-1를 붙인다.
39 .contentType("application/json")요청 body가 JSON임을 content type으로 선언한다.
40 .content("{\"accountNo\":\"CLOUD\",\"openingBalance\":1000}"))고정 JSON input을 account 생성 요청 body에 넣는다.
41 .andExpect(status().isForbidden())권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
42 .andExpect(header().doesNotExist("WWW-Authenticate"));cloud response가 Basic 재인증 challenge를 보내지 않는지 assertion한다.
43 assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();거절 뒤 account table row 수가 0인지 조회하고 assertion한다.
44 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
45}현재 Java/SQL block·호출·CTE 범위를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다. 다만 한 unsafe endpoint만 검사한다

문법 해부

  • @ActiveProfiles("cloud")가 cloud chain만 선택한다.
  • MockMvc status/header assertion 뒤 JdbcClient count로 no-effect를 확인한다.

실행 순서

  1. DB를 비운다.
  2. health GET 200을 확인한다.
  3. Basic account POST를 보낸다.
  4. 403·challenge 없음·account count 0을 확인한다.

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

F03-C01 · imports and static test tools
문법 해부
1~18줄의 `imports and static test tools`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `health=200`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `health=200`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 한 unsafe endpoint만 검사한다.
착각 방지
`imports and static test tools`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 한 unsafe endpoint만 검사한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F03-C02 · cloud profile fixture and cleanup
문법 해부
19~31줄의 `cloud profile fixture and cleanup`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `POST /api/accounts=403`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `POST /api/accounts=403`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: CORS 전체 정책은 검사하지 않는다.
착각 방지
`cloud profile fixture and cleanup`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: CORS 전체 정책은 검사하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F03-C03 · public health and fail-closed write proof
문법 해부
32~45줄의 `public health and fail-closed write proof`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `WWW-Authenticate absent`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `WWW-Authenticate absent`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: cloud에 운영 인증을 제공한다는 뜻이 아니다.
착각 방지
`public health and fail-closed write proof`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: cloud에 운영 인증을 제공한다는 뜻이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. 403 응답인데 account가 1이면 실패다.

  3. counterexample의 이유는 `controller/service가 먼저 실행되는 잘못된 chain이면 side effect가 남을 수 있다.`이야.

  4. 고친 문장은 `DB count를 별도로 assert한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F03-T01 healthGET /actuator/healthcloud public matcher200health 정보 범위는 별도다
F03-T02 unsafe writeBasic POST openingBalance=1000cloud denyAll403WWW-Authenticate도 없어야 한다
F03-T03 DB effectaccount tableCOUNT(*)0audit는 이 test가 보지 않는다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `cloud fail-closed 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `audit row는 assert하지 않는다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

Spring profile

cloud SecurityFilterChain bean이 context에 올라간다.

mixed profile 중복 bean은 검사하지 않는다.
MockMvc

실제 servlet security filter 흐름을 거쳐 status/header를 관찰한다.

모든 endpoint를 순회하지 않는다.
PostgreSQL

거절 뒤 account table의 최종 cardinality를 읽는다.

다른 table invariant는 직접 assert하지 않는다.

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

healthIsPublicButBasicCannotOpenOrMutateTheCloudApi

Arrange · 준비
  • cloud profile과 빈 account table을 준비한다.
  • Basic customer-1 credential과 account 생성 JSON을 준비한다.
Act · 행동
  • health GET 뒤 account POST를 MockMvc로 호출한다.
Assert · 확인
  • health는 200, POST는 403이다.
  • WWW-Authenticate가 없고 account count는 0이다.
직접 보장
  • health 예외 공개
  • 한 unsafe write의 cloud fail-closed와 no-effect
보장하지 않음
  • 모든 endpoint
  • CORS/감사/운영 인증

첫 실패 경계 Red starter에서는 account POST가 permitAll을 통과해 403 assertion 또는 DB count에서 깨진다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 403이면 write 0도 자동이다

왜 틀리나 controller/service가 먼저 실행되는 잘못된 chain이면 side effect가 남을 수 있다.

바르게 읽기 DB count를 별도로 assert한다.

반례 403 응답인데 account가 1이면 실패다.

❌ no WWW-Authenticate면 익명 요청이다

왜 틀리나 Basic credential을 보냈지만 cloud는 인증 challenge를 제공하지 않는다.

바르게 읽기 disabled API의 response 계약으로 읽는다.

반례 credential 유무와 header 부재는 별개다.

❌ health 200이면 cloud API도 열어야 한다

왜 틀리나 health만 운영 probe용 예외다.

바르게 읽기 나머지는 denyAll이다.

반례 POST /api/accounts는 403이다.

❌ 이 test가 CORS를 증명한다

왜 틀리나 Origin/preflight assertion이 없다.

바르게 읽기 CORS는 별도 test가 필요하다.

반례 OPTIONS나 Access-Control-* header를 보지 않는다.

❌ cloud 인증 구현이 완성됐다

왜 틀리나 이 설계는 API를 닫는다.

바르게 읽기 운영 인증을 붙이기 전 fail-closed 상태다.

반례 Basic도 disabled다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

한 unsafe endpoint만 검사한다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

CORS 전체 정책은 검사하지 않는다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

cloud에 운영 인증을 제공한다는 뜻이 아니다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

audit row는 assert하지 않는다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.

2단계 · 코드 조각 재조립

  1. imports and static test tools
  2. cloud profile fixture and cleanup
  3. public health and fail-closed write proof

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

45개 물리 줄을 원본 순서로 복원하고 SHA-256 bcaac7140df52f009e0de0ef5592bdcfe4d9a3e4615a3cc010a1552ed2a26a56와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/CloudFailClosedSecurityIT.javaSHA-256 bcaac7140df52f009e0de0ef5592bdcfe4d9a3e4615a3cc010a1552ed2a26a56
CloudFailClosedSecurityIT.java — health 공개·API write 0 계약 전체
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("cloud")
class CloudFailClosedSecurityIT extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;

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

    @Test
    void healthIsPublicButBasicCannotOpenOrMutateTheCloudApi() throws Exception {
        mvc.perform(get("/actuator/health"))
            .andExpect(status().isOk());
        mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"CLOUD\",\"openingBalance\":1000}"))
            .andExpect(status().isForbidden())
            .andExpect(header().doesNotExist("WWW-Authenticate"));
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();
    }
}
04

ObjectAuthorizationIT.java — BOLA read·transfer no-effect 계약

learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/ObjectAuthorizationIT.java

원문 정본 · 제공 test 계약 · 정본 · W20-F04
43줄 연결61줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

  1. owner GET=200은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `receiver ownership은 별도 규칙이다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값owner GET=200other GET=403 ACCESS_DENIEDother transfer=403balance=10000TRANSFER count=0
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

ObjectAuthorizationIT.java — BOLA read·transfer no-effect 계약를 출입문 검사표로 바꾸기

본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

핵심값 owner GET=200, other GET=403 ACCESS_DENIED, other transfer=403, balance=10000, TRANSFER count=0을 원본 줄로 따라가되, receiver ownership은 별도 규칙이다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

imports and assertions

1~20줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

코드 연결
1~20줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
receiver ownership은 별도 규칙이다

test profile fixture and two accounts

21~38줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

코드 연결
21~38줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
모든 denial invariant를 검사하지 않는다

owner versus other read contract

39~50줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

코드 연결
39~50줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
audit persistence를 직접 assert하지 않는다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? owner GET=200부터 보면 될까?

  2. 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

  3. source에서 관찰할 첫 값은 `owner GET=200`이네.

  4. 그리고 `receiver ownership은 별도 규칙이다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `other GET=403 ACCESS_DENIED`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. customer-1 owned=10000, customer-2 receiver=5000을 만든다. → owner read 200과 other read 403을 비교한다.

  3. 다음 단계는 other가 owned를 from으로 transfer한다. → 403 뒤 balance 10000·TRANSFER count 0을 확인한다.

  4. 최종값 `other GET=403 ACCESS_DENIED`과 미보장 `모든 denial invariant를 검사하지 않는다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.43 / 43 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
22줄F04-L22 @SpringBootTest 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 실제 Spring application context를 띄우는 통합 test임을 선언한다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
모든 denial invariant를 검사하지 않는다
23줄F04-L23 @AutoConfigureMockMvc 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
audit persistence를 직접 assert하지 않는다
24줄F04-L24 @ActiveProfiles("test") 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 test profile 설정을 활성화한다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
25줄F04-L25 class ObjectAuthorizationIT extends PostgresIntegrationTestSupport { 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 read와 transfer BOLA 계약을 담는 PostgreSQL 통합 test class를 연다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
receiver ownership은 별도 규칙이다
26줄F04-L26 @Autowired MockMvc mvc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
모든 denial invariant를 검사하지 않는다
27줄F04-L27 @Autowired JdbcClient jdbc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
audit persistence를 직접 assert하지 않는다
28줄F04-L28 @Autowired AccountOpeningService openings; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 소유자가 정해진 account fixture를 열 service를 주입한다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
29줄F04-L29 Account owned; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-1 소유 account fixture를 test field로 보관한다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
receiver ownership은 별도 규칙이다
30줄F04-L30 Account receiver; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 transfer 수취 account fixture를 test field로 보관한다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
모든 denial invariant를 검사하지 않는다
32줄F04-L32 @BeforeEach 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test 전에 바로 아래 clean fixture를 실행한다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
33줄F04-L33 void clean() { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
receiver ownership은 별도 규칙이다
34줄F04-L34 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 관련 business/audit table을 FK 순서와 identity까지 비운다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
모든 denial invariant를 검사하지 않는다
35줄F04-L35 .update(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞의 TRUNCATE SQL을 실제로 실행한다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
audit persistence를 직접 assert하지 않는다
36줄F04-L36 owned = openings.open("customer-1", "OWNED", 10_000); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-1의 OWNED account를 잔액 10000으로 만든다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
37줄F04-L37 receiver = openings.open("customer-2", "RECEIVER", 5_000); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-2의 RECEIVER account를 잔액 5000으로 만든다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
receiver ownership은 별도 규칙이다
38줄F04-L38 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
모든 denial invariant를 검사하지 않는다
40줄F04-L40 @Test 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
41줄F04-L41 void ownerCanReadButAnotherAuthenticatedCustomerCannot() throws Exception { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 owner 200과 other-owner 403을 비교할 read test를 연다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
receiver ownership은 별도 규칙이다
42줄F04-L42 mvc.perform(get("/api/accounts/{id}", owned.getId()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 fixture account ID를 path variable로 넣어 조회 요청을 만든다.
입력
customer-1 소유 account ID
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
모든 denial invariant를 검사하지 않는다
43줄F04-L43 .with(httpBasic("customer-1", "password"))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
audit persistence를 직접 assert하지 않는다
44줄F04-L44 .andExpect(status().isOk()); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 현재 HTTP 결과가 200 OK인지 assertion한다.
입력
balance=10000
결과·효과
그 결과 실제 HTTP 관찰 상태가 200으로 고정된다.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
45줄F04-L45 mvc.perform(get("/api/accounts/{id}", owned.getId()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 fixture account ID를 path variable로 넣어 조회 요청을 만든다.
입력
customer-1 소유 account ID
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
receiver ownership은 별도 규칙이다
46줄F04-L46 .with(httpBasic("customer-2", "password")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-2를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
모든 denial invariant를 검사하지 않는다
47줄F04-L47 .header("X-Request-Id", "bola-read")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
audit persistence를 직접 assert하지 않는다
48줄F04-L48 .andExpect(status().isForbidden()) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
입력
other transfer=403
결과·효과
그 결과 실제 HTTP 관찰 상태가 403으로 고정된다.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
49줄F04-L49 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED")); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
receiver ownership은 별도 규칙이다
50줄F04-L50 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
모든 denial invariant를 검사하지 않는다
52줄F04-L52 @Test 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
53줄F04-L53 void anotherCustomerCannotTransferFromTheOwnersAccount() throws Exception { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 other actor의 from-account transfer와 DB no-effect를 검증할 test를 연다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
receiver ownership은 별도 규칙이다
54줄F04-L54 String body = "{\"transactionId\":\"BOLA-TX\",\"fromAccountId\":" + owned.getId() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 owned/receiver ID와 amount 1000을 포함한 transfer JSON을 조립한다.
입력
customer-1 소유 account ID
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
모든 denial invariant를 검사하지 않는다
55줄F04-L55 + ",\"toAccountId\":" + receiver.getId() + ",\"amount\":1000}"; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 transfer JSON에 receiver ID와 amount 1000을 이어 붙인다.
입력
TRANSFER count=0
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: TRANSFER count=0.
비유의 한계
audit persistence를 직접 assert하지 않는다
56줄F04-L56 mvc.perform(post("/api/transfers") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 transfer API에 POST 요청을 만든다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
57줄F04-L57 .with(httpBasic("customer-2", "password")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-2를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
receiver ownership은 별도 규칙이다
58줄F04-L58 .header("X-Request-Id", "bola-transfer") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
입력
other transfer=403
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other transfer=403.
비유의 한계
모든 denial invariant를 검사하지 않는다
59줄F04-L59 .contentType("application/json").content(body)) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청 body가 JSON임을 content type으로 선언한다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
audit persistence를 직접 assert하지 않는다
60줄F04-L60 .andExpect(status().isForbidden()) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
입력
TRANSFER count=0
결과·효과
그 결과 실제 HTTP 관찰 상태가 403으로 고정된다.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
61줄F04-L61 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED")); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
receiver ownership은 별도 규칙이다
62줄F04-L62 assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id") 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 owned account의 현재 balance를 ID로 조회하기 시작한다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
모든 denial invariant를 검사하지 않는다
63줄F04-L63 .param("id", owned.getId()).query(Long.class).single()).isEqualTo(10_000); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 balance query의 named id parameter에 owned account ID를 넣고 10000인지 assertion한다.
입력
customer-1 소유 account ID
결과·효과
그 결과 denied transfer 뒤 source balance가 10000으로 유지된다.
비유의 한계
audit persistence를 직접 assert하지 않는다
64줄F04-L64 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'") 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 denial 뒤 TRANSFER business_tx row가 0인지 조회하기 시작한다.
입력
balance=10000
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: balance=10000.
비유의 한계
controller만의 check로 충분하다는 뜻이 아니다
65줄F04-L65 .query(Long.class).single()).isZero(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞 SQL의 단일 Long 결과가 0인지 assertion한다.
입력
TRANSFER count=0
결과·효과
그 결과 거절 뒤 관련 DB row cardinality가 0으로 고정된다.
비유의 한계
receiver ownership은 별도 규칙이다
66줄F04-L66 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
owner GET=200
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner GET=200.
비유의 한계
모든 denial invariant를 검사하지 않는다
67줄F04-L67 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
other GET=403 ACCESS_DENIED
결과·효과
그 결과 F04의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other GET=403 ACCESS_DENIED.
비유의 한계
audit persistence를 직접 assert하지 않는다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `TRANSFER count=0`을 source 줄과 test card로 대조하면 된다.

  4. `audit persistence를 직접 assert하지 않는다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F04-C01 · imports and assertions1–20줄
1–20줄 원본
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
F04-C02 · test profile fixture and two accounts21–38줄
21–38줄 원본

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ObjectAuthorizationIT extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    Account owned;
    Account receiver;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        owned = openings.open("customer-1", "OWNED", 10_000);
        receiver = openings.open("customer-2", "RECEIVER", 5_000);
    }
F04-C03 · owner versus other read contract39–50줄
39–50줄 원본

    @Test
    void ownerCanReadButAnotherAuthenticatedCustomerCannot() throws Exception {
        mvc.perform(get("/api/accounts/{id}", owned.getId())
                .with(httpBasic("customer-1", "password")))
            .andExpect(status().isOk());
        mvc.perform(get("/api/accounts/{id}", owned.getId())
                .with(httpBasic("customer-2", "password"))
                .header("X-Request-Id", "bola-read"))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
    }
F04-C04 · denied transfer and no-effect contract51–67줄
51–67줄 원본

    @Test
    void anotherCustomerCannotTransferFromTheOwnersAccount() throws Exception {
        String body = "{\"transactionId\":\"BOLA-TX\",\"fromAccountId\":" + owned.getId()
            + ",\"toAccountId\":" + receiver.getId() + ",\"amount\":1000}";
        mvc.perform(post("/api/transfers")
                .with(httpBasic("customer-2", "password"))
                .header("X-Request-Id", "bola-transfer")
                .contentType("application/json").content(body))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
        assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", owned.getId()).query(Long.class).single()).isEqualTo(10_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 61 / 61

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

준비·설명 줄 18개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.PostgresIntegrationTestSupport;이 source가 사용할 production/test type과 static matcher를 가져온다.
4import com.example.financialcore.account.Account;이 source가 사용할 production/test type과 static matcher를 가져온다.
5import com.example.financialcore.account.AccountOpeningService;이 source가 사용할 production/test type과 static matcher를 가져온다.
6import org.junit.jupiter.api.BeforeEach;이 source가 사용할 production/test type과 static matcher를 가져온다.
7import org.junit.jupiter.api.Test;이 source가 사용할 production/test type과 static matcher를 가져온다.
8import org.springframework.beans.factory.annotation.Autowired;이 source가 사용할 production/test type과 static matcher를 가져온다.
9import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
10import org.springframework.boot.test.context.SpringBootTest;이 source가 사용할 production/test type과 static matcher를 가져온다.
11import org.springframework.jdbc.core.simple.JdbcClient;이 source가 사용할 production/test type과 static matcher를 가져온다.
12import org.springframework.test.context.ActiveProfiles;이 source가 사용할 production/test type과 static matcher를 가져온다.
13import org.springframework.test.web.servlet.MockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
15import static org.assertj.core.api.Assertions.assertThat;이 source가 사용할 production/test type과 static matcher를 가져온다.
16import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;이 source가 사용할 production/test type과 static matcher를 가져온다.
17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;이 source가 사용할 production/test type과 static matcher를 가져온다.
18import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;이 source가 사용할 production/test type과 static matcher를 가져온다.
19import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;이 source가 사용할 production/test type과 static matcher를 가져온다.
20import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
22@SpringBootTest실제 Spring application context를 띄우는 통합 test임을 선언한다.
23@AutoConfigureMockMvcservlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
24@ActiveProfiles("test")test profile 설정을 활성화한다.
25class ObjectAuthorizationIT extends PostgresIntegrationTestSupport {read와 transfer BOLA 계약을 담는 PostgreSQL 통합 test class를 연다.
26 @Autowired MockMvc mvc;HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
27 @Autowired JdbcClient jdbc;DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
28 @Autowired AccountOpeningService openings;소유자가 정해진 account fixture를 열 service를 주입한다.
29 Account owned;customer-1 소유 account fixture를 test field로 보관한다.
30 Account receiver;transfer 수취 account fixture를 test field로 보관한다.
32 @BeforeEach각 test 전에 바로 아래 clean fixture를 실행한다.
33 void clean() {각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
34 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")관련 business/audit table을 FK 순서와 identity까지 비운다.
35 .update();앞의 TRUNCATE SQL을 실제로 실행한다.
36 owned = openings.open("customer-1", "OWNED", 10_000);customer-1의 OWNED account를 잔액 10000으로 만든다.
37 receiver = openings.open("customer-2", "RECEIVER", 5_000);customer-2의 RECEIVER account를 잔액 5000으로 만든다.
38 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
40 @Test바로 다음 method를 JUnit test case로 발견하게 표시한다.
41 void ownerCanReadButAnotherAuthenticatedCustomerCannot() throws Exception {owner 200과 other-owner 403을 비교할 read test를 연다.
42 mvc.perform(get("/api/accounts/{id}", owned.getId())fixture account ID를 path variable로 넣어 조회 요청을 만든다.
43 .with(httpBasic("customer-1", "password")))요청에 local/test Basic principal customer-1를 붙인다.
44 .andExpect(status().isOk());현재 HTTP 결과가 200 OK인지 assertion한다.
45 mvc.perform(get("/api/accounts/{id}", owned.getId())fixture account ID를 path variable로 넣어 조회 요청을 만든다.
46 .with(httpBasic("customer-2", "password"))요청에 local/test Basic principal customer-2를 붙인다.
47 .header("X-Request-Id", "bola-read"))거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
48 .andExpect(status().isForbidden())권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
49 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
50 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
52 @Test바로 다음 method를 JUnit test case로 발견하게 표시한다.
53 void anotherCustomerCannotTransferFromTheOwnersAccount() throws Exception {other actor의 from-account transfer와 DB no-effect를 검증할 test를 연다.
54 String body = "{\"transactionId\":\"BOLA-TX\",\"fromAccountId\":" + owned.getId()owned/receiver ID와 amount 1000을 포함한 transfer JSON을 조립한다.
55 + ",\"toAccountId\":" + receiver.getId() + ",\"amount\":1000}";transfer JSON에 receiver ID와 amount 1000을 이어 붙인다.
56 mvc.perform(post("/api/transfers")transfer API에 POST 요청을 만든다.
57 .with(httpBasic("customer-2", "password"))요청에 local/test Basic principal customer-2를 붙인다.
58 .header("X-Request-Id", "bola-transfer")거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
59 .contentType("application/json").content(body))요청 body가 JSON임을 content type으로 선언한다.
60 .andExpect(status().isForbidden())권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
61 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
62 assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")owned account의 현재 balance를 ID로 조회하기 시작한다.
63 .param("id", owned.getId()).query(Long.class).single()).isEqualTo(10_000);balance query의 named id parameter에 owned account ID를 넣고 10000인지 assertion한다.
64 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")denial 뒤 TRANSFER business_tx row가 0인지 조회하기 시작한다.
65 .query(Long.class).single()).isZero();앞 SQL의 단일 Long 결과가 0인지 assertion한다.
66 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
67}현재 Java/SQL block·호출·CTE 범위를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다. 다만 receiver ownership은 별도 규칙이다

문법 해부

  • 두 @Test가 read와 transfer를 분리한다.
  • HTTP errorCode와 DB balance/TRANSFER count를 함께 고정한다.

실행 순서

  1. customer-1 owned=10000, customer-2 receiver=5000을 만든다.
  2. owner read 200과 other read 403을 비교한다.
  3. other가 owned를 from으로 transfer한다.
  4. 403 뒤 balance 10000·TRANSFER count 0을 확인한다.

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

F04-C01 · imports and assertions
문법 해부
1~20줄의 `imports and assertions`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `owner GET=200`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `owner GET=200`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: receiver ownership은 별도 규칙이다.
착각 방지
`imports and assertions`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: receiver ownership은 별도 규칙이다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F04-C02 · test profile fixture and two accounts
문법 해부
21~38줄의 `test profile fixture and two accounts`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `other GET=403 ACCESS_DENIED`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `other GET=403 ACCESS_DENIED`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 모든 denial invariant를 검사하지 않는다.
착각 방지
`test profile fixture and two accounts`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 모든 denial invariant를 검사하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F04-C03 · owner versus other read contract
문법 해부
39~50줄의 `owner versus other read contract`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `other transfer=403`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `other transfer=403`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: audit persistence를 직접 assert하지 않는다.
착각 방지
`owner versus other read contract`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: audit persistence를 직접 assert하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F04-C04 · denied transfer and no-effect contract
문법 해부
51~67줄의 `denied transfer and no-effect contract`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `balance=10000`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `balance=10000`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: controller만의 check로 충분하다는 뜻이 아니다.
착각 방지
`denied transfer and no-effect contract`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: controller만의 check로 충분하다는 뜻이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. customer-2 GET은 403이다.

  3. counterexample의 이유는 `로그인은 신원, owner check는 resource 권한이다.`이야.

  4. 고친 문장은 `actor와 account owner를 비교한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F04-T01 owner readcustomer-1 / ownedGET200response payload 상세는 고정하지 않는다
F04-T02 other readcustomer-2 / ownedGET bola-read403 ACCESS_DENIEDaudit row는 assert하지 않는다
F04-T03 other transferfrom owned to receiver amount1000POST bola-transfer403from balance=10000, tx count=0
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `객체 소유권 BOLA 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `controller만의 check로 충분하다는 뜻이 아니다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

principal

httpBasic username이 authenticated actor identity가 된다.

request body의 actor를 신뢰하면 안 된다.
service gate

from account owner check가 mutation 전에 예외를 던진다.

controller-only gate는 다른 호출 경로를 막지 못한다.
transaction/DB

denial 뒤 source balance와 TRANSFER row cardinality가 유지된다.

receiver balance·ledger·idempotency 전체는 직접 보지 않는다.

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

ownerCanReadButAnotherAuthenticatedCustomerCannot

Arrange · 준비
  • customer-1 owned account와 customer-2 credential을 준비한다.
Act · 행동
  • owner와 other가 같은 account를 GET한다.
Assert · 확인
  • owner는 200이다.
  • other는 403과 ACCESS_DENIED다.
직접 보장
  • read object ownership 분리
보장하지 않음
  • audit persistence
  • 없는 account 처리
  • operator policy

첫 실패 경계 빈 AccountAuthorization은 other 요청도 정상 진행시켜 403 assertion에서 깨진다.

anotherCustomerCannotTransferFromTheOwnersAccount

Arrange · 준비
  • owned=10000, receiver=5000과 customer-2 credential을 준비한다.
Act · 행동
  • other가 owned를 from으로 1000 transfer한다.
Assert · 확인
  • 403 ACCESS_DENIED다.
  • owned balance는 10000, TRANSFER row count는 0이다.
직접 보장
  • from-account ownership
  • 이 denial case의 business no-effect
보장하지 않음
  • receiver balance·ledger·idempotency 전체
  • 모든 denial audit

첫 실패 경계 빈 gate에서는 transfer가 진행돼 403 또는 balance/tx-count assertion에서 깨진다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 로그인했으면 모든 account를 읽는다

왜 틀리나 로그인은 신원, owner check는 resource 권한이다.

바르게 읽기 actor와 account owner를 비교한다.

반례 customer-2 GET은 403이다.

❌ to account도 반드시 caller 소유여야 한다

왜 틀리나 이 계약은 from ownership만 고정한다.

바르게 읽기 receiver는 존재·수취 가능성을 별도로 검사한다.

반례 customer-2 소유 receiver로 보내는 구조다.

❌ 403만 보면 side effect가 없다

왜 틀리나 응답과 DB 상태는 별도 관찰값이다.

바르게 읽기 balance와 business_tx count를 assert한다.

반례 balance가 9000이면 status 403이어도 실패다.

❌ 한 denied test가 모든 audit를 증명한다

왜 틀리나 audit_event 조회가 없다.

바르게 읽기 별도 denied-audit selector evidence와 구분한다.

반례 요청 ID만 보내도 durable audit이 자동 보장되지 않는다.

❌ BOLA는 SQL injection이다

왜 틀리나 BOLA는 object ID에 대한 권한 누락이다.

바르게 읽기 소유권 gate를 둔다.

반례 정상 숫자 ID로도 타인 resource를 겨냥할 수 있다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

receiver ownership은 별도 규칙이다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

모든 denial invariant를 검사하지 않는다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

audit persistence를 직접 assert하지 않는다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

controller만의 check로 충분하다는 뜻이 아니다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.

2단계 · 코드 조각 재조립

  1. imports and assertions
  2. test profile fixture and two accounts
  3. owner versus other read contract
  4. denied transfer and no-effect contract

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

67개 물리 줄을 원본 순서로 복원하고 SHA-256 e43c8e248c2f12302c6021849a8189e47ed647016d893d5f5101dac62a676137와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/ObjectAuthorizationIT.javaSHA-256 e43c8e248c2f12302c6021849a8189e47ed647016d893d5f5101dac62a676137
ObjectAuthorizationIT.java — BOLA read·transfer no-effect 계약 전체
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ObjectAuthorizationIT extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    Account owned;
    Account receiver;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        owned = openings.open("customer-1", "OWNED", 10_000);
        receiver = openings.open("customer-2", "RECEIVER", 5_000);
    }

    @Test
    void ownerCanReadButAnotherAuthenticatedCustomerCannot() throws Exception {
        mvc.perform(get("/api/accounts/{id}", owned.getId())
                .with(httpBasic("customer-1", "password")))
            .andExpect(status().isOk());
        mvc.perform(get("/api/accounts/{id}", owned.getId())
                .with(httpBasic("customer-2", "password"))
                .header("X-Request-Id", "bola-read"))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
    }

    @Test
    void anotherCustomerCannotTransferFromTheOwnersAccount() throws Exception {
        String body = "{\"transactionId\":\"BOLA-TX\",\"fromAccountId\":" + owned.getId()
            + ",\"toAccountId\":" + receiver.getId() + ",\"amount\":1000}";
        mvc.perform(post("/api/transfers")
                .with(httpBasic("customer-2", "password"))
                .header("X-Request-Id", "bola-transfer")
                .contentType("application/json").content(body))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
        assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", owned.getId()).query(Long.class).single()).isEqualTo(10_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
    }
}
05

SecurityStatusContractTest.java — 401·403·400·200 구분 계약

learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/SecurityStatusContractTest.java

원문 정본 · 제공 test 계약 · 정본 · W20-F05
33줄 연결50줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

  1. anonymous=401은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `상태 구분만으로 body schema 전체가 고정되지는 않는다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값anonymous=401other owner=403 ACCESS_DENIEDinvalid opening=400 INVALID_REQUESTowner=200
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

SecurityStatusContractTest.java — 401·403·400·200 구분 계약를 출입문 검사표로 바꾸기

anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

핵심값 anonymous=401, other owner=403 ACCESS_DENIED, invalid opening=400 INVALID_REQUEST, owner=200을 원본 줄로 따라가되, 상태 구분만으로 body schema 전체가 고정되지는 않는다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

imports and HTTP matchers

1~19줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

코드 연결
1~19줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
상태 구분만으로 body schema 전체가 고정되지는 않는다

test profile fixture

20~35줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

코드 연결
20~35줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다

401 403 400 200 matrix

36~55줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

코드 연결
36~55줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
operator 조합은 없다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? anonymous=401부터 보면 될까?

  2. anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

  3. source에서 관찰할 첫 값은 `anonymous=401`이네.

  4. 그리고 `상태 구분만으로 body schema 전체가 고정되지는 않는다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `other owner=403 ACCESS_DENIED`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. customer-1 account를 1000으로 연다. → anonymous GET은 401이다.

  3. 다음 단계는 customer-2 GET은 403이다. → owner invalid POST는 400, owner GET은 200이다.

  4. 최종값 `other owner=403 ACCESS_DENIED`과 미보장 `401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.33 / 33 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
21줄F05-L21 @SpringBootTest 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 실제 Spring application context를 띄우는 통합 test임을 선언한다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
22줄F05-L22 @AutoConfigureMockMvc 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
23줄F05-L23 @ActiveProfiles("test") 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 test profile 설정을 활성화한다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
24줄F05-L24 class SecurityStatusContractTest extends PostgresIntegrationTestSupport { 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 401·403·400·200 matrix를 담는 PostgreSQL 통합 test class를 연다.
입력
owner=200
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
25줄F05-L25 @Autowired MockMvc mvc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
26줄F05-L26 @Autowired JdbcClient jdbc; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
27줄F05-L27 @Autowired AccountOpeningService openings; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 소유자가 정해진 account fixture를 열 service를 주입한다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
28줄F05-L28 Account account; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 status matrix가 공유할 customer-1 account를 보관한다.
입력
owner=200
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
30줄F05-L30 @BeforeEach 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test 전에 바로 아래 clean fixture를 실행한다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
31줄F05-L31 void clean() { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
32줄F05-L32 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 관련 business/audit table을 FK 순서와 identity까지 비운다.
입력
owner=200
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
33줄F05-L33 .update(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞의 TRUNCATE SQL을 실제로 실행한다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
34줄F05-L34 account = openings.open("customer-1", "STATUS", 1_000); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-1의 STATUS account를 잔액 1000으로 만든다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
35줄F05-L35 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
37줄F05-L37 @Test 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
38줄F05-L38 void anonymous401OtherOwner403Invalid400AndOwner200AreDistinct() throws Exception { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 anonymous/other/invalid/owner 네 status를 한 흐름에서 비교할 test를 연다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
39줄F05-L39 mvc.perform(get("/api/accounts/{id}", account.getId())) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 fixture account ID를 path variable로 넣어 조회 요청을 만든다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
40줄F05-L40 .andExpect(status().isUnauthorized()); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 anonymous 결과가 401 Unauthorized인지 assertion한다.
입력
owner=200
결과·효과
그 결과 실제 HTTP 관찰 상태가 401로 고정된다.
비유의 한계
CORS preflight는 검사하지 않는다
41줄F05-L41 mvc.perform(get("/api/accounts/{id}", account.getId()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 fixture account ID를 path variable로 넣어 조회 요청을 만든다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
42줄F05-L42 .with(httpBasic("customer-2", "password"))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-2를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
43줄F05-L43 .andExpect(status().isForbidden()) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 실제 HTTP 관찰 상태가 403으로 고정된다.
비유의 한계
operator 조합은 없다
44줄F05-L44 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED")); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
입력
owner=200
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
45줄F05-L45 mvc.perform(post("/api/accounts") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 account 생성 API에 POST 요청을 만든다.
입력
anonymous=401
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous=401.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
46줄F05-L46 .with(httpBasic("customer-1", "password")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
47줄F05-L47 .contentType("application/json") 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청 body가 JSON임을 content type으로 선언한다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
48줄F05-L48 .content("{\"accountNo\":\"INVALID\",\"openingBalance\":0}")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고정 JSON input을 account 생성 요청 body에 넣는다.
입력
accountNo와 openingBalance JSON
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
49줄F05-L49 .andExpect(status().isBadRequest()) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 유효하지 않은 business input이 400 Bad Request인지 assertion한다.
입력
anonymous=401
결과·효과
그 결과 실제 HTTP 관찰 상태가 400으로 고정된다.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
50줄F05-L50 .andExpect(jsonPath("$.errorCode").value("INVALID_REQUEST")); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 JSON errorCode가 정확히 INVALID_REQUEST인지 assertion한다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
51줄F05-L51 mvc.perform(get("/api/accounts/{id}", account.getId()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 fixture account ID를 path variable로 넣어 조회 요청을 만든다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
52줄F05-L52 .with(httpBasic("customer-1", "password"))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner=200.
비유의 한계
CORS preflight는 검사하지 않는다
53줄F05-L53 .andExpect(status().isOk()); 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 현재 HTTP 결과가 200 OK인지 assertion한다.
입력
anonymous=401
결과·효과
그 결과 실제 HTTP 관찰 상태가 200으로 고정된다.
비유의 한계
상태 구분만으로 body schema 전체가 고정되지는 않는다
54줄F05-L54 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
other owner=403 ACCESS_DENIED
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner=403 ACCESS_DENIED.
비유의 한계
401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
55줄F05-L55 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
invalid opening=400 INVALID_REQUEST
결과·효과
그 결과 F05의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: invalid opening=400 INVALID_REQUEST.
비유의 한계
operator 조합은 없다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `owner=200`을 source 줄과 test card로 대조하면 된다.

  4. `operator 조합은 없다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F05-C01 · imports and HTTP matchers1–19줄
1–19줄 원본
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
F05-C02 · test profile fixture20–35줄
20–35줄 원본

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SecurityStatusContractTest extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    Account account;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        account = openings.open("customer-1", "STATUS", 1_000);
    }
F05-C03 · 401 403 400 200 matrix36–55줄
36–55줄 원본

    @Test
    void anonymous401OtherOwner403Invalid400AndOwner200AreDistinct() throws Exception {
        mvc.perform(get("/api/accounts/{id}", account.getId()))
            .andExpect(status().isUnauthorized());
        mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-2", "password")))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
        mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"INVALID\",\"openingBalance\":0}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errorCode").value("INVALID_REQUEST"));
        mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-1", "password")))
            .andExpect(status().isOk());
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 50 / 50

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

준비·설명 줄 17개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.PostgresIntegrationTestSupport;이 source가 사용할 production/test type과 static matcher를 가져온다.
4import com.example.financialcore.account.Account;이 source가 사용할 production/test type과 static matcher를 가져온다.
5import com.example.financialcore.account.AccountOpeningService;이 source가 사용할 production/test type과 static matcher를 가져온다.
6import org.junit.jupiter.api.BeforeEach;이 source가 사용할 production/test type과 static matcher를 가져온다.
7import org.junit.jupiter.api.Test;이 source가 사용할 production/test type과 static matcher를 가져온다.
8import org.springframework.beans.factory.annotation.Autowired;이 source가 사용할 production/test type과 static matcher를 가져온다.
9import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
10import org.springframework.boot.test.context.SpringBootTest;이 source가 사용할 production/test type과 static matcher를 가져온다.
11import org.springframework.jdbc.core.simple.JdbcClient;이 source가 사용할 production/test type과 static matcher를 가져온다.
12import org.springframework.test.context.ActiveProfiles;이 source가 사용할 production/test type과 static matcher를 가져온다.
13import org.springframework.test.web.servlet.MockMvc;이 source가 사용할 production/test type과 static matcher를 가져온다.
15import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;이 source가 사용할 production/test type과 static matcher를 가져온다.
16import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;이 source가 사용할 production/test type과 static matcher를 가져온다.
17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;이 source가 사용할 production/test type과 static matcher를 가져온다.
18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;이 source가 사용할 production/test type과 static matcher를 가져온다.
19import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
21@SpringBootTest실제 Spring application context를 띄우는 통합 test임을 선언한다.
22@AutoConfigureMockMvcservlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
23@ActiveProfiles("test")test profile 설정을 활성화한다.
24class SecurityStatusContractTest extends PostgresIntegrationTestSupport {401·403·400·200 matrix를 담는 PostgreSQL 통합 test class를 연다.
25 @Autowired MockMvc mvc;HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
26 @Autowired JdbcClient jdbc;DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
27 @Autowired AccountOpeningService openings;소유자가 정해진 account fixture를 열 service를 주입한다.
28 Account account;status matrix가 공유할 customer-1 account를 보관한다.
30 @BeforeEach각 test 전에 바로 아래 clean fixture를 실행한다.
31 void clean() {각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
32 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")관련 business/audit table을 FK 순서와 identity까지 비운다.
33 .update();앞의 TRUNCATE SQL을 실제로 실행한다.
34 account = openings.open("customer-1", "STATUS", 1_000);customer-1의 STATUS account를 잔액 1000으로 만든다.
35 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
37 @Test바로 다음 method를 JUnit test case로 발견하게 표시한다.
38 void anonymous401OtherOwner403Invalid400AndOwner200AreDistinct() throws Exception {anonymous/other/invalid/owner 네 status를 한 흐름에서 비교할 test를 연다.
39 mvc.perform(get("/api/accounts/{id}", account.getId()))fixture account ID를 path variable로 넣어 조회 요청을 만든다.
40 .andExpect(status().isUnauthorized());anonymous 결과가 401 Unauthorized인지 assertion한다.
41 mvc.perform(get("/api/accounts/{id}", account.getId())fixture account ID를 path variable로 넣어 조회 요청을 만든다.
42 .with(httpBasic("customer-2", "password")))요청에 local/test Basic principal customer-2를 붙인다.
43 .andExpect(status().isForbidden())권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
44 .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
45 mvc.perform(post("/api/accounts")account 생성 API에 POST 요청을 만든다.
46 .with(httpBasic("customer-1", "password"))요청에 local/test Basic principal customer-1를 붙인다.
47 .contentType("application/json")요청 body가 JSON임을 content type으로 선언한다.
48 .content("{\"accountNo\":\"INVALID\",\"openingBalance\":0}"))고정 JSON input을 account 생성 요청 body에 넣는다.
49 .andExpect(status().isBadRequest())유효하지 않은 business input이 400 Bad Request인지 assertion한다.
50 .andExpect(jsonPath("$.errorCode").value("INVALID_REQUEST"));JSON errorCode가 정확히 INVALID_REQUEST인지 assertion한다.
51 mvc.perform(get("/api/accounts/{id}", account.getId())fixture account ID를 path variable로 넣어 조회 요청을 만든다.
52 .with(httpBasic("customer-1", "password")))요청에 local/test Basic principal customer-1를 붙인다.
53 .andExpect(status().isOk());현재 HTTP 결과가 200 OK인지 assertion한다.
54 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
55}현재 Java/SQL block·호출·CTE 범위를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다. 다만 상태 구분만으로 body schema 전체가 고정되지는 않는다

문법 해부

  • 한 test가 같은 account를 네 actor/input 조합으로 호출한다.
  • status와 JSON errorCode를 함께 써 authentication·authorization·validation을 분리한다.

실행 순서

  1. customer-1 account를 1000으로 연다.
  2. anonymous GET은 401이다.
  3. customer-2 GET은 403이다.
  4. owner invalid POST는 400, owner GET은 200이다.

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

F05-C01 · imports and HTTP matchers
문법 해부
1~19줄의 `imports and HTTP matchers`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `anonymous=401`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `anonymous=401`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 상태 구분만으로 body schema 전체가 고정되지는 않는다.
착각 방지
`imports and HTTP matchers`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 상태 구분만으로 body schema 전체가 고정되지는 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F05-C02 · test profile fixture
문법 해부
20~35줄의 `test profile fixture`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `other owner=403 ACCESS_DENIED`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `other owner=403 ACCESS_DENIED`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다.
착각 방지
`test profile fixture`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F05-C03 · 401 403 400 200 matrix
문법 해부
36~55줄의 `401 403 400 200 matrix`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `invalid opening=400 INVALID_REQUEST`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `invalid opening=400 INVALID_REQUEST`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: operator 조합은 없다.
착각 방지
`401 403 400 200 matrix`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: operator 조합은 없다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. anonymous와 customer-2 결과가 다르다.

  3. counterexample의 이유는 `401은 신원 없음, 403은 신원은 있으나 권한 없음이다.`이야.

  4. 고친 문장은 `단계별 status를 분리한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F05-T01 anonymousno credentialGET owned401누구인지 모르는 단계
F05-T02 other ownercustomer-2GET customer-1 account403 ACCESS_DENIED인증은 됐지만 권한 없음
F05-T03 invalid bodycustomer-1 openingBalance=0POST /api/accounts400 INVALID_REQUEST권한 이후 business validation 실패
F05-T04 ownercustomer-1GET own account200성공 payload 전체는 범위 밖
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `HTTP status matrix 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `CORS preflight는 검사하지 않는다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

security entry point

anonymous API 요청을 controller 전에 401로 종료한다.

errorCode choice는 이 학습 계약의 convention이다.
authorization

authenticated other actor를 owner gate가 403으로 바꾼다.

404 은닉 정책은 사용하지 않는다.
validation

인증된 owner의 openingBalance=0을 domain validation이 400으로 거절한다.

400을 권한 실패로 섞으면 안 된다.

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

anonymous401OtherOwner403Invalid400AndOwner200AreDistinct

Arrange · 준비
  • customer-1 owned account와 customer-1/2 credential을 준비한다.
Act · 행동
  • anonymous GET, other GET, owner invalid POST, owner GET을 순서대로 호출한다.
Assert · 확인
  • status는 401/403/400/200이다.
  • 403은 ACCESS_DENIED, 400은 INVALID_REQUEST다.
직접 보장
  • 현재 fixture의 authentication/authorization/validation/success status 구분
보장하지 않음
  • 전체 error schema
  • operator/CORS/mixed-profile

첫 실패 경계 permitAll starter에서는 anonymous GET이 200으로 진행해 첫 401 assertion에서 깨진다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 401과 403은 같은 실패다

왜 틀리나 401은 신원 없음, 403은 신원은 있으나 권한 없음이다.

바르게 읽기 단계별 status를 분리한다.

반례 anonymous와 customer-2 결과가 다르다.

❌ 잘못된 입력은 항상 401이다

왜 틀리나 인증이 성공한 뒤 validation이 400을 만든다.

바르게 읽기 authentication과 payload validation을 분리한다.

반례 owner openingBalance=0은 400이다.

❌ owner면 invalid 요청도 200이다

왜 틀리나 권한과 business rule은 모두 통과해야 한다.

바르게 읽기 owner check 뒤 validation을 유지한다.

반례 balance 0은 INVALID_REQUEST다.

❌ 403이면 account가 없다는 뜻이다

왜 틀리나 이 test는 존재하는 타인 account다.

바르게 읽기 missing과 mismatch를 domain에서 구분한다.

반례 F06은 missing에 ACCOUNT_NOT_FOUND를 쓴다.

❌ operator도 matrix에 있다

왜 틀리나 fixture는 customer-1/2뿐이다.

바르게 읽기 없는 actor row를 주장하지 않는다.

반례 operator credential/test가 source에 없다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

상태 구분만으로 body schema 전체가 고정되지는 않는다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

operator 조합은 없다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

CORS preflight는 검사하지 않는다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.

2단계 · 코드 조각 재조립

  1. imports and HTTP matchers
  2. test profile fixture
  3. 401 403 400 200 matrix

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

55개 물리 줄을 원본 순서로 복원하고 SHA-256 032fc2a77a1fd7d08e06f1f2b67e946ddd82ff966bd116fac4fa0d9844c67688와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/SecurityStatusContractTest.javaSHA-256 032fc2a77a1fd7d08e06f1f2b67e946ddd82ff966bd116fac4fa0d9844c67688
SecurityStatusContractTest.java — 401·403·400·200 구분 계약 전체
package com.example.financialcore.security;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SecurityStatusContractTest extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    Account account;

    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        account = openings.open("customer-1", "STATUS", 1_000);
    }

    @Test
    void anonymous401OtherOwner403Invalid400AndOwner200AreDistinct() throws Exception {
        mvc.perform(get("/api/accounts/{id}", account.getId()))
            .andExpect(status().isUnauthorized());
        mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-2", "password")))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
        mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"INVALID\",\"openingBalance\":0}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errorCode").value("INVALID_REQUEST"));
        mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-1", "password")))
            .andExpect(status().isOk());
    }
}
06

AccountAuthorization.java — DB owner 비교·거절 event Green 해법

learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/AccountAuthorization.java

원문 정본 · Green learner 해법 · 정본 · W20-F06
21줄 연결29줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

  1. missing -> ACCOUNT_NOT_FOUND은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `event 발행이 durable audit 저장을 자동 증명하지 않는다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값missing -> ACCOUNT_NOT_FOUNDowner match -> returnother owner -> publish eventthen ACCESS_DENIED
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

AccountAuthorization.java — DB owner 비교·거절 event Green 해법를 출입문 검사표로 바꾸기

DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

핵심값 missing -> ACCOUNT_NOT_FOUND, owner match -> return, other owner -> publish event, then ACCESS_DENIED을 원본 줄로 따라가되, event 발행이 durable audit 저장을 자동 증명하지 않는다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

imports

1~10줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

코드 연결
1~10줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
event 발행이 durable audit 저장을 자동 증명하지 않는다

component fields and constructor

11~20줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

코드 연결
11~20줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
actorId는 trusted principal에서 와야 한다

owner lookup and missing account

21~26줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

코드 연결
21~26줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? missing -> ACCOUNT_NOT_FOUND부터 보면 될까?

  2. DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

  3. source에서 관찰할 첫 값은 `missing -> ACCOUNT_NOT_FOUND`이네.

  4. 그리고 `event 발행이 durable audit 저장을 자동 증명하지 않는다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `owner match -> return`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. accountId로 ownerId를 읽는다. → 없으면 ACCOUNT_NOT_FOUND를 던진다.

  3. 다음 단계는 있고 actor와 같으면 정상 반환한다. → 다르면 denied event 발행 뒤 ACCESS_DENIED를 던진다.

  4. 최종값 `owner match -> return`과 미보장 `actorId는 trusted principal에서 와야 한다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · Green learner 해법에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.21 / 21 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
12줄F06-L12 @Component 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 Spring component scan이 authorization bean을 등록하게 한다.
입력
then ACCESS_DENIED
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: then ACCESS_DENIED.
비유의 한계
to account 규칙은 이 method 책임이 아니다
13줄F06-L13 public class AccountAuthorization { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 DB owner를 비교할 stateless authorization component class를 연다.
입력
missing -> ACCOUNT_NOT_FOUND
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: missing -> ACCOUNT_NOT_FOUND.
비유의 한계
event 발행이 durable audit 저장을 자동 증명하지 않는다
14줄F06-L14 private final AccountRepository accounts; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 owner ID를 읽을 repository dependency를 immutable field로 둔다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
15줄F06-L15 private final ApplicationEventPublisher events; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 denied event를 발행할 publisher를 immutable field로 둔다.
입력
other owner -> publish event
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner -> publish event.
비유의 한계
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
17줄F06-L17 public AccountAuthorization(AccountRepository accounts, ApplicationEventPublisher events) { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 repository와 publisher를 constructor injection으로 받는다.
입력
missing -> ACCOUNT_NOT_FOUND
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: missing -> ACCOUNT_NOT_FOUND.
비유의 한계
event 발행이 durable audit 저장을 자동 증명하지 않는다
18줄F06-L18 this.accounts = accounts; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 injected repository를 field에 저장한다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
19줄F06-L19 this.events = events; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 injected event publisher를 field에 저장한다.
입력
other owner -> publish event
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner -> publish event.
비유의 한계
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
20줄F06-L20 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
then ACCESS_DENIED
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: then ACCESS_DENIED.
비유의 한계
to account 규칙은 이 method 책임이 아니다
22줄F06-L22 public void requireOwner(String actorId, long accountId, String action, String requestId) { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 actor/account/action/requestId를 받아 owner gate를 실행할 method를 연다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
23줄F06-L23 Optional<String> ownerId = accounts.findOwnerId(accountId); 티켓 이름과 좌석 주인을 대조하는 입장 담당자 accountId의 owner ID를 Optional scalar로 repository에서 읽는다.
입력
DB owner Optional과 authenticated actorId
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner -> publish event.
비유의 한계
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
24줄F06-L24 if (ownerId.isEmpty()) { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 account가 존재하지 않는 branch를 검사한다.
입력
DB owner Optional과 authenticated actorId
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: then ACCESS_DENIED.
비유의 한계
to account 규칙은 이 method 책임이 아니다
25줄F06-L25 throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 missing account를 ACCOUNT_NOT_FOUND BusinessException으로 중단한다.
입력
missing -> ACCOUNT_NOT_FOUND
결과·효과
그 결과 호출 흐름이 예외로 중단되고 error handler 단계로 이동한다.
비유의 한계
event 발행이 durable audit 저장을 자동 증명하지 않는다
26줄F06-L26 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
27줄F06-L27 if (!ownerId.orElseThrow().equals(actorId)) { 티켓 이름과 좌석 주인을 대조하는 입장 담당자 DB owner 문자열이 authenticated actorId와 다른지 검사한다.
입력
DB owner Optional과 authenticated actorId
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: other owner -> publish event.
비유의 한계
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
28줄F06-L28 events.publishEvent(new DeniedAuditEvent( 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 mismatch 정보를 담을 DeniedAuditEvent 발행을 시작한다.
입력
then ACCESS_DENIED
결과·효과
그 결과 in-process denied event 전달이 요청되지만 durable 저장 여부는 아직 미정이다.
비유의 한계
to account 규칙은 이 method 책임이 아니다
29줄F06-L29 actorId, action, "ACCOUNT", Long.toString(accountId), 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 denied event에 actor/action/resource type/raw account ID를 넣는다.
입력
missing -> ACCOUNT_NOT_FOUND
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: missing -> ACCOUNT_NOT_FOUND.
비유의 한계
event 발행이 durable audit 저장을 자동 증명하지 않는다
30줄F06-L30 ErrorCode.ACCESS_DENIED.name(), requestId)); 티켓 이름과 좌석 주인을 대조하는 입장 담당자 denied event에 ACCESS_DENIED와 같은 requestId를 마저 넣는다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
31줄F06-L31 throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied"); 티켓 이름과 좌석 주인을 대조하는 입장 담당자 event 발행 뒤 ACCESS_DENIED BusinessException으로 business 흐름을 중단한다.
입력
other owner -> publish event
결과·효과
그 결과 호출 흐름이 예외로 중단되고 error handler 단계로 이동한다.
비유의 한계
check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
32줄F06-L32 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
then ACCESS_DENIED
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: then ACCESS_DENIED.
비유의 한계
to account 규칙은 이 method 책임이 아니다
33줄F06-L33 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
missing -> ACCOUNT_NOT_FOUND
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: missing -> ACCOUNT_NOT_FOUND.
비유의 한계
event 발행이 durable audit 저장을 자동 증명하지 않는다
34줄F06-L34 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
owner match -> return
결과·효과
그 결과 F06의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: owner match -> return.
비유의 한계
actorId는 trusted principal에서 와야 한다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `then ACCESS_DENIED`을 source 줄과 test card로 대조하면 된다.

  4. `check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F06-C01 · imports1–10줄
1–10줄 원본
package com.example.financialcore.security;

import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.audit.DeniedAuditEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

import java.util.Optional;
F06-C02 · component fields and constructor11–20줄
11–20줄 원본

@Component
public class AccountAuthorization {
    private final AccountRepository accounts;
    private final ApplicationEventPublisher events;

    public AccountAuthorization(AccountRepository accounts, ApplicationEventPublisher events) {
        this.accounts = accounts;
        this.events = events;
    }
F06-C03 · owner lookup and missing account21–26줄
21–26줄 원본

    public void requireOwner(String actorId, long accountId, String action, String requestId) {
        Optional<String> ownerId = accounts.findOwnerId(accountId);
        if (ownerId.isEmpty()) {
            throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        }
F06-C04 · mismatch event and denial27–34줄
27–34줄 원본
        if (!ownerId.orElseThrow().equals(actorId)) {
            events.publishEvent(new DeniedAuditEvent(
                actorId, action, "ACCOUNT", Long.toString(accountId),
                ErrorCode.ACCESS_DENIED.name(), requestId));
            throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
        }
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 29 / 29

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

준비·설명 줄 8개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.account.AccountRepository;이 source가 사용할 production/test type과 static matcher를 가져온다.
4import com.example.financialcore.api.BusinessException;이 source가 사용할 production/test type과 static matcher를 가져온다.
5import com.example.financialcore.api.ErrorCode;이 source가 사용할 production/test type과 static matcher를 가져온다.
6import com.example.financialcore.audit.DeniedAuditEvent;이 source가 사용할 production/test type과 static matcher를 가져온다.
7import org.springframework.context.ApplicationEventPublisher;이 source가 사용할 production/test type과 static matcher를 가져온다.
8import org.springframework.stereotype.Component;이 source가 사용할 production/test type과 static matcher를 가져온다.
10import java.util.Optional;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
12@ComponentSpring component scan이 authorization bean을 등록하게 한다.
13public class AccountAuthorization {DB owner를 비교할 stateless authorization component class를 연다.
14 private final AccountRepository accounts;owner ID를 읽을 repository dependency를 immutable field로 둔다.
15 private final ApplicationEventPublisher events;denied event를 발행할 publisher를 immutable field로 둔다.
17 public AccountAuthorization(AccountRepository accounts, ApplicationEventPublisher events) {repository와 publisher를 constructor injection으로 받는다.
18 this.accounts = accounts;injected repository를 field에 저장한다.
19 this.events = events;injected event publisher를 field에 저장한다.
20 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
22 public void requireOwner(String actorId, long accountId, String action, String requestId) {actor/account/action/requestId를 받아 owner gate를 실행할 method를 연다.
23 Optional<String> ownerId = accounts.findOwnerId(accountId);accountId의 owner ID를 Optional scalar로 repository에서 읽는다.
24 if (ownerId.isEmpty()) {account가 존재하지 않는 branch를 검사한다.
25 throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");missing account를 ACCOUNT_NOT_FOUND BusinessException으로 중단한다.
26 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
27 if (!ownerId.orElseThrow().equals(actorId)) {DB owner 문자열이 authenticated actorId와 다른지 검사한다.
28 events.publishEvent(new DeniedAuditEvent(mismatch 정보를 담을 DeniedAuditEvent 발행을 시작한다.
29 actorId, action, "ACCOUNT", Long.toString(accountId),denied event에 actor/action/resource type/raw account ID를 넣는다.
30 ErrorCode.ACCESS_DENIED.name(), requestId));denied event에 ACCESS_DENIED와 같은 requestId를 마저 넣는다.
31 throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");event 발행 뒤 ACCESS_DENIED BusinessException으로 business 흐름을 중단한다.
32 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
33 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
34}현재 Java/SQL block·호출·CTE 범위를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다. 다만 event 발행이 durable audit 저장을 자동 증명하지 않는다

문법 해부

  • Optional owner lookup 뒤 empty/mismatch 두 if가 다른 ErrorCode를 만든다.
  • mismatch는 event publish를 먼저 호출하고 ACCESS_DENIED 예외로 흐름을 끊는다.

실행 순서

  1. accountId로 ownerId를 읽는다.
  2. 없으면 ACCOUNT_NOT_FOUND를 던진다.
  3. 있고 actor와 같으면 정상 반환한다.
  4. 다르면 denied event 발행 뒤 ACCESS_DENIED를 던진다.

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

F06-C01 · imports
문법 해부
1~10줄의 `imports`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `missing -> ACCOUNT_NOT_FOUND`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `missing -> ACCOUNT_NOT_FOUND`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: event 발행이 durable audit 저장을 자동 증명하지 않는다.
착각 방지
`imports`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: event 발행이 durable audit 저장을 자동 증명하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F06-C02 · component fields and constructor
문법 해부
11~20줄의 `component fields and constructor`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `owner match -> return`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `owner match -> return`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: actorId는 trusted principal에서 와야 한다.
착각 방지
`component fields and constructor`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: actorId는 trusted principal에서 와야 한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F06-C03 · owner lookup and missing account
문법 해부
21~26줄의 `owner lookup and missing account`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `other owner -> publish event`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `other owner -> publish event`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다.
착각 방지
`owner lookup and missing account`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F06-C04 · mismatch event and denial
문법 해부
27~34줄의 `mismatch event and denial`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `then ACCESS_DENIED`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `then ACCESS_DENIED`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: to account 규칙은 이 method 책임이 아니다.
착각 방지
`mismatch event and denial`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: to account 규칙은 이 method 책임이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. W20 세 selector는 audit table을 assert하지 않는다.

  3. counterexample의 이유는 `listener 실패·transaction phase가 저장 결과를 좌우한다.`이야.

  4. 고친 문장은 `audit persistence를 별도 selector로 검증한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F06-T01 owneractor=customer-1, owner=customer-1equalsreturn명시적 success 값은 없다
F06-T02 otheractor=customer-2, owner=customer-1publish DeniedAuditEventACCESS_DENIED저장 성공은 listener/transaction 책임
F06-T03 missingowner Optional.emptyfirst ifACCOUNT_NOT_FOUNDdenied event를 만들지 않는다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `Green account owner gate`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `to account 규칙은 이 method 책임이 아니다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

repository

findOwnerId는 전체 Account 대신 owner scalar를 Optional로 돌려준다.

read와 이후 mutation의 동시성 일관성은 별도다.
event bus

ApplicationEventPublisher가 in-process DeniedAuditEvent를 listener에 전달한다.

publish 호출 자체는 durable commit proof가 아니다.
exception mapping

BusinessException ErrorCode가 HTTP handler에서 status/body로 변환된다.

이 class는 HTTP를 직접 알지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ event를 publish하면 audit DB에 반드시 남는다

왜 틀리나 listener 실패·transaction phase가 저장 결과를 좌우한다.

바르게 읽기 audit persistence를 별도 selector로 검증한다.

반례 W20 세 selector는 audit table을 assert하지 않는다.

❌ actorId를 body에서 받아도 된다

왜 틀리나 공격자가 customer-1로 위조할 수 있다.

바르게 읽기 authenticated principal에서 actor를 꺼낸다.

반례 request JSON actorId는 신뢰 경계 밖이다.

❌ owner 조회 후 언제든 안전하게 mutate한다

왜 틀리나 check와 write 사이 소유권 상태가 변할 수 있다.

바르게 읽기 transaction/locking/version 정책을 설계한다.

반례 TOCTOU race가 남는다.

❌ to account도 requireOwner 해야 한다

왜 틀리나 이 method는 지정 account의 owner만 검사한다.

바르게 읽기 transfer는 from ownership과 to existence/receivability를 분리한다.

반례 수취인이 caller 소유일 필요는 없다.

❌ ACCOUNT_NOT_FOUND와 ACCESS_DENIED는 같다

왜 틀리나 source가 두 branch를 명시한다.

바르게 읽기 caller contract에 맞게 구분하되 정보 노출 정책을 별도 결정한다.

반례 Optional.empty와 mismatch가 다른 예외다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

event 발행이 durable audit 저장을 자동 증명하지 않는다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

actorId는 trusted principal에서 와야 한다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

to account 규칙은 이 method 책임이 아니다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.

2단계 · 코드 조각 재조립

  1. imports
  2. component fields and constructor
  3. owner lookup and missing account
  4. mismatch event and denial

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

34개 물리 줄을 원본 순서로 복원하고 SHA-256 3b1af7a91115ef61c47231596a342d607dbdcf61ae1df176cb110280f4d919f7와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · Green learner 해법learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/AccountAuthorization.javaSHA-256 3b1af7a91115ef61c47231596a342d607dbdcf61ae1df176cb110280f4d919f7
AccountAuthorization.java — DB owner 비교·거절 event Green 해법 전체
package com.example.financialcore.security;

import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.audit.DeniedAuditEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class AccountAuthorization {
    private final AccountRepository accounts;
    private final ApplicationEventPublisher events;

    public AccountAuthorization(AccountRepository accounts, ApplicationEventPublisher events) {
        this.accounts = accounts;
        this.events = events;
    }

    public void requireOwner(String actorId, long accountId, String action, String requestId) {
        Optional<String> ownerId = accounts.findOwnerId(accountId);
        if (ownerId.isEmpty()) {
            throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        }
        if (!ownerId.orElseThrow().equals(actorId)) {
            events.publishEvent(new DeniedAuditEvent(
                actorId, action, "ACCOUNT", Long.toString(accountId),
                ErrorCode.ACCESS_DENIED.name(), requestId));
            throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
        }
    }
}
07

SecurityConfiguration.java — profile별 인증·fail-closed Green 해법

learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/SecurityConfiguration.java

원문 정본 · Green learner 해법 · 정본 · W20-F07
62줄 연결78줄 번역5 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

  1. local /api/**=authenticated은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `{noop} user는 local/test 학습용이다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값local /api/**=authenticatedanonymous local=401cloud API=403cloud Basic disabledJSON requestId
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

SecurityConfiguration.java — profile별 인증·fail-closed Green 해법를 출입문 검사표로 바꾸기

local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

핵심값 local /api/**=authenticated, anonymous local=401, cloud API=403, cloud Basic disabled, JSON requestId을 원본 줄로 따라가되, {noop} user는 local/test 학습용이다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

imports

1~18줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

코드 연결
1~18줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
{noop} user는 local/test 학습용이다

local learning users

19~29줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

코드 연결
19~29줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
API CSRF ignore는 CORS 완성을 뜻하지 않는다

local and test security chain

30~49줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

코드 연결
30~49줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
cloud는 운영 인증이 아니라 disabled API다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? local /api/**=authenticated부터 보면 될까?

  2. local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

  3. source에서 관찰할 첫 값은 `local /api/**=authenticated`이네.

  4. 그리고 `{noop} user는 local/test 학습용이다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `anonymous local=401`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. local/test에는 두 in-memory user가 등록된다. → health 공개, /api 인증, 나머지 denyAll이 적용된다.

  3. 다음 단계는 cloud/prod는 health 외 denyAll·CSRF default·Basic disabled다. → 실패 handler가 JSON과 requestId를 쓴다.

  4. 최종값 `anonymous local=401`과 미보장 `API CSRF ignore는 CORS 완성을 뜻하지 않는다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · Green learner 해법에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.62 / 62 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
20줄F07-L20 @Configuration 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 아래 @Bean method를 security configuration으로 등록한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
21줄F07-L21 public class SecurityConfiguration { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 profile별 SecurityFilterChain bean을 구성할 class를 연다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
{noop} user는 local/test 학습용이다
22줄F07-L22 @Bean 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 바로 다음 반환 object를 Spring bean으로 등록한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
23줄F07-L23 @Profile({"default", "local", "test"}) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 default/local/test에서만 바로 다음 bean을 활성화한다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
24줄F07-L24 UserDetailsService learningUsers() { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 local/test teaching user 저장소 bean을 만드는 method를 연다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
25줄F07-L25 return new InMemoryUserDetailsManager( 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 두 in-memory UserDetails를 보관할 manager를 만든다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
{noop} user는 local/test 학습용이다
26줄F07-L26 User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(), 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-1 teaching user를 noop password와 CUSTOMER role로 만든다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
27줄F07-L27 User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer-2 teaching user를 noop password와 CUSTOMER role로 만든다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
28줄F07-L28 ); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
29줄F07-L29 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
{noop} user는 local/test 학습용이다
31줄F07-L31 @Bean 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 바로 다음 반환 object를 Spring bean으로 등록한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
32줄F07-L32 @Profile({"default", "local", "test"}) 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 default/local/test에서만 바로 다음 bean을 활성화한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
33줄F07-L33 SecurityFilterChain localTestSecurity(HttpSecurity http) throws Exception { 문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 local/test authorization·CSRF·Basic·error handler chain method를 연다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
{noop} user는 local/test 학습용이다
34줄F07-L34 return http 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 Green local/cloud security chain에서 34번째 문장을 앞뒤 단계와 연결한다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
35줄F07-L35 .authorizeHttpRequests(authorize -> authorize 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 URL별 authorization matcher 규칙을 구성하기 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
36줄F07-L36 .requestMatchers("/actuator/health/**").permitAll() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 health 하위 경로만 anonymous에게 공개한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
37줄F07-L37 .requestMatchers("/api/**").authenticated() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 local/test API 경로는 authenticated principal을 요구한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
{noop} user는 local/test 학습용이다
38줄F07-L38 .anyRequest().denyAll()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
39줄F07-L39 .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 local/test 학습 API만 CSRF token 검사에서 제외한다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
40줄F07-L40 .httpBasic(Customizer.withDefaults()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
41줄F07-L41 .exceptionHandling(errors -> errors 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 authentication/authorization 실패 response handler 구성을 시작한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
{noop} user는 local/test 학습용이다
42줄F07-L42 .authenticationEntryPoint((request, response, failure) -> 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 인증되지 않은 request의 JSON 응답 lambda를 등록한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
43줄F07-L43 write(response, 401, ErrorCode.INVALID_REQUEST, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 local/test anonymous 실패를 401 INVALID_REQUEST JSON으로 쓰기 시작한다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
44줄F07-L44 "authentication required", RequestIdFilter.current(request))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 401 message와 현재 requestId를 JSON writer에 넘긴다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
45줄F07-L45 .accessDeniedHandler((request, response, failure) -> 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
{noop} user는 local/test 학습용이다
46줄F07-L46 write(response, 403, ErrorCode.ACCESS_DENIED, 티켓 이름과 좌석 주인을 대조하는 입장 담당자 access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
47줄F07-L47 "access denied", RequestIdFilter.current(request)))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 local 403 message와 현재 requestId를 JSON writer에 넘긴다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
48줄F07-L48 .build(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
49줄F07-L49 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
{noop} user는 local/test 학습용이다
51줄F07-L51 @Bean 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 바로 다음 반환 object를 Spring bean으로 등록한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
52줄F07-L52 @Profile({"cloud", "prod"}) 공연장 모드별로 다른 출입문 규칙표 cloud/prod에서만 바로 다음 bean을 활성화한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
53줄F07-L53 SecurityFilterChain cloudProdFailClosed(HttpSecurity http) throws Exception { 공연장 모드별로 다른 출입문 규칙표 cloud/prod disabled-API fail-closed chain method를 연다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
{noop} user는 local/test 학습용이다
54줄F07-L54 return http 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 Green local/cloud security chain에서 54번째 문장을 앞뒤 단계와 연결한다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
55줄F07-L55 .authorizeHttpRequests(authorize -> authorize 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 URL별 authorization matcher 규칙을 구성하기 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
56줄F07-L56 .requestMatchers("/actuator/health/**").permitAll() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 health 하위 경로만 anonymous에게 공개한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
57줄F07-L57 .anyRequest().denyAll()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
{noop} user는 local/test 학습용이다
58줄F07-L58 .csrf(Customizer.withDefaults()) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 cloud/prod에서는 CSRF 기본 방어를 유지한다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
59줄F07-L59 .httpBasic(AbstractHttpConfigurer::disable) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 요청에 local/test Basic principal customer-1를 붙인다.
입력
username=customer-1/2, password=password
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
60줄F07-L60 .exceptionHandling(errors -> errors 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 authentication/authorization 실패 response handler 구성을 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
61줄F07-L61 .authenticationEntryPoint((request, response, failure) -> 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 인증되지 않은 request의 JSON 응답 lambda를 등록한다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
{noop} user는 local/test 학습용이다
62줄F07-L62 write(response, 403, ErrorCode.ACCESS_DENIED, 티켓 이름과 좌석 주인을 대조하는 입장 담당자 access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
63줄F07-L63 "cloud API is disabled", RequestIdFilter.current(request))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
64줄F07-L64 .accessDeniedHandler((request, response, failure) -> 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
65줄F07-L65 write(response, 403, ErrorCode.ACCESS_DENIED, 티켓 이름과 좌석 주인을 대조하는 입장 담당자 access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
{noop} user는 local/test 학습용이다
66줄F07-L66 "cloud API is disabled", RequestIdFilter.current(request)))) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
67줄F07-L67 .build(); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
68줄F07-L68 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
70줄F07-L70 private static void write( 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 공통 JSON security error response helper signature를 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
71줄F07-L71 HttpServletResponse response, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 status/header/body를 기록할 servlet response parameter를 받는다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
72줄F07-L72 int status, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 호출자가 정한 HTTP status 숫자를 받는다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
73줄F07-L73 ErrorCode code, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 JSON errorCode로 직렬화할 enum을 받는다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
{noop} user는 local/test 학습용이다
74줄F07-L74 String message, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 JSON message로 쓸 controlled 문자열을 받는다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
75줄F07-L75 String requestId 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 같은 요청을 추적할 requestId를 받는다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
76줄F07-L76 ) throws java.io.IOException { 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 뒤의 설정·실행·검증 문장을 묶을 block 또는 호출 범위를 연다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
77줄F07-L77 response.setStatus(status); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 HTTP status를 response에 설정한다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
{noop} user는 local/test 학습용이다
78줄F07-L78 response.setCharacterEncoding(StandardCharsets.UTF_8.name()); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 한글 message도 안전하게 쓰도록 UTF-8 encoding을 설정한다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
79줄F07-L79 response.setContentType(MediaType.APPLICATION_JSON_VALUE); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 response media type을 application/json으로 설정한다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
80줄F07-L80 response.getWriter().write("{\"errorCode\":\"" + code.name() 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 errorCode부터 JSON 문자열을 response body에 쓰기 시작한다.
입력
JSON requestId
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: JSON requestId.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
81줄F07-L81 + "\",\"message\":\"" + message 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 JSON에 message field를 이어 붙인다.
입력
local /api/**=authenticated
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: local /api/**=authenticated.
비유의 한계
{noop} user는 local/test 학습용이다
82줄F07-L82 + "\",\"requestId\":\"" + requestId + "\"}"); 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 JSON에 requestId를 붙이고 object를 닫는다.
입력
anonymous local=401
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: anonymous local=401.
비유의 한계
API CSRF ignore는 CORS 완성을 뜻하지 않는다
83줄F07-L83 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud API=403
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud API=403.
비유의 한계
cloud는 운영 인증이 아니라 disabled API다
84줄F07-L84 } 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
cloud Basic disabled
결과·효과
그 결과 F07의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: cloud Basic disabled.
비유의 한계
문자열 JSON helper는 controlled message를 전제로 한다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `JSON requestId`을 source 줄과 test card로 대조하면 된다.

  4. `cloud는 운영 인증이 아니라 disabled API다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F07-C01 · imports1–18줄
1–18줄 원본
package com.example.financialcore.security;

import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.api.RequestIdFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import java.nio.charset.StandardCharsets;
F07-C02 · local learning users19–29줄
19–29줄 원본

@Configuration
public class SecurityConfiguration {
    @Bean
    @Profile({"default", "local", "test"})
    UserDetailsService learningUsers() {
        return new InMemoryUserDetailsManager(
            User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),
            User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build()
        );
    }
F07-C03 · local and test security chain30–49줄
30–49줄 원본

    @Bean
    @Profile({"default", "local", "test"})
    SecurityFilterChain localTestSecurity(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll())
            .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
            .httpBasic(Customizer.withDefaults())
            .exceptionHandling(errors -> errors
                .authenticationEntryPoint((request, response, failure) ->
                    write(response, 401, ErrorCode.INVALID_REQUEST,
                        "authentication required", RequestIdFilter.current(request)))
                .accessDeniedHandler((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "access denied", RequestIdFilter.current(request))))
            .build();
    }
F07-C04 · cloud and prod fail-closed chain50–68줄
50–68줄 원본

    @Bean
    @Profile({"cloud", "prod"})
    SecurityFilterChain cloudProdFailClosed(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/actuator/health/**").permitAll()
                .anyRequest().denyAll())
            .csrf(Customizer.withDefaults())
            .httpBasic(AbstractHttpConfigurer::disable)
            .exceptionHandling(errors -> errors
                .authenticationEntryPoint((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "cloud API is disabled", RequestIdFilter.current(request)))
                .accessDeniedHandler((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "cloud API is disabled", RequestIdFilter.current(request))))
            .build();
    }
F07-C05 · JSON error writer69–84줄
69–84줄 원본

    private static void write(
        HttpServletResponse response,
        int status,
        ErrorCode code,
        String message,
        String requestId
    ) throws java.io.IOException {
        response.setStatus(status);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("{\"errorCode\":\"" + code.name()
            + "\",\"message\":\"" + message
            + "\",\"requestId\":\"" + requestId + "\"}");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 78 / 78

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

준비·설명 줄 16개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.api.ErrorCode;이 source가 사용할 production/test type과 static matcher를 가져온다.
4import com.example.financialcore.api.RequestIdFilter;이 source가 사용할 production/test type과 static matcher를 가져온다.
5import jakarta.servlet.http.HttpServletResponse;이 source가 사용할 production/test type과 static matcher를 가져온다.
6import org.springframework.context.annotation.Bean;이 source가 사용할 production/test type과 static matcher를 가져온다.
7import org.springframework.context.annotation.Configuration;이 source가 사용할 production/test type과 static matcher를 가져온다.
8import org.springframework.context.annotation.Profile;이 source가 사용할 production/test type과 static matcher를 가져온다.
9import org.springframework.http.MediaType;이 source가 사용할 production/test type과 static matcher를 가져온다.
10import org.springframework.security.config.Customizer;이 source가 사용할 production/test type과 static matcher를 가져온다.
11import org.springframework.security.config.annotation.web.builders.HttpSecurity;이 source가 사용할 production/test type과 static matcher를 가져온다.
12import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;이 source가 사용할 production/test type과 static matcher를 가져온다.
13import org.springframework.security.core.userdetails.User;이 source가 사용할 production/test type과 static matcher를 가져온다.
14import org.springframework.security.core.userdetails.UserDetailsService;이 source가 사용할 production/test type과 static matcher를 가져온다.
15import org.springframework.security.provisioning.InMemoryUserDetailsManager;이 source가 사용할 production/test type과 static matcher를 가져온다.
16import org.springframework.security.web.SecurityFilterChain;이 source가 사용할 production/test type과 static matcher를 가져온다.
18import java.nio.charset.StandardCharsets;이 source가 사용할 production/test type과 static matcher를 가져온다.
원본한국어 번역
20@Configuration아래 @Bean method를 security configuration으로 등록한다.
21public class SecurityConfiguration {profile별 SecurityFilterChain bean을 구성할 class를 연다.
22 @Bean바로 다음 반환 object를 Spring bean으로 등록한다.
23 @Profile({"default", "local", "test"})default/local/test에서만 바로 다음 bean을 활성화한다.
24 UserDetailsService learningUsers() {local/test teaching user 저장소 bean을 만드는 method를 연다.
25 return new InMemoryUserDetailsManager(두 in-memory UserDetails를 보관할 manager를 만든다.
26 User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),customer-1 teaching user를 noop password와 CUSTOMER role로 만든다.
27 User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build()customer-2 teaching user를 noop password와 CUSTOMER role로 만든다.
28 );현재 Java/SQL block·호출·CTE 범위를 닫는다.
29 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
31 @Bean바로 다음 반환 object를 Spring bean으로 등록한다.
32 @Profile({"default", "local", "test"})default/local/test에서만 바로 다음 bean을 활성화한다.
33 SecurityFilterChain localTestSecurity(HttpSecurity http) throws Exception {local/test authorization·CSRF·Basic·error handler chain method를 연다.
34 return httpGreen local/cloud security chain에서 34번째 문장을 앞뒤 단계와 연결한다.
35 .authorizeHttpRequests(authorize -> authorizeURL별 authorization matcher 규칙을 구성하기 시작한다.
36 .requestMatchers("/actuator/health/**").permitAll()health 하위 경로만 anonymous에게 공개한다.
37 .requestMatchers("/api/**").authenticated()local/test API 경로는 authenticated principal을 요구한다.
38 .anyRequest().denyAll())앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
39 .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))local/test 학습 API만 CSRF token 검사에서 제외한다.
40 .httpBasic(Customizer.withDefaults())요청에 local/test Basic principal customer-1를 붙인다.
41 .exceptionHandling(errors -> errorsauthentication/authorization 실패 response handler 구성을 시작한다.
42 .authenticationEntryPoint((request, response, failure) ->인증되지 않은 request의 JSON 응답 lambda를 등록한다.
43 write(response, 401, ErrorCode.INVALID_REQUEST,local/test anonymous 실패를 401 INVALID_REQUEST JSON으로 쓰기 시작한다.
44 "authentication required", RequestIdFilter.current(request)))401 message와 현재 requestId를 JSON writer에 넘긴다.
45 .accessDeniedHandler((request, response, failure) ->인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
46 write(response, 403, ErrorCode.ACCESS_DENIED,access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
47 "access denied", RequestIdFilter.current(request))))local 403 message와 현재 requestId를 JSON writer에 넘긴다.
48 .build();누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
49 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
51 @Bean바로 다음 반환 object를 Spring bean으로 등록한다.
52 @Profile({"cloud", "prod"})cloud/prod에서만 바로 다음 bean을 활성화한다.
53 SecurityFilterChain cloudProdFailClosed(HttpSecurity http) throws Exception {cloud/prod disabled-API fail-closed chain method를 연다.
54 return httpGreen local/cloud security chain에서 54번째 문장을 앞뒤 단계와 연결한다.
55 .authorizeHttpRequests(authorize -> authorizeURL별 authorization matcher 규칙을 구성하기 시작한다.
56 .requestMatchers("/actuator/health/**").permitAll()health 하위 경로만 anonymous에게 공개한다.
57 .anyRequest().denyAll())앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
58 .csrf(Customizer.withDefaults())cloud/prod에서는 CSRF 기본 방어를 유지한다.
59 .httpBasic(AbstractHttpConfigurer::disable)요청에 local/test Basic principal customer-1를 붙인다.
60 .exceptionHandling(errors -> errorsauthentication/authorization 실패 response handler 구성을 시작한다.
61 .authenticationEntryPoint((request, response, failure) ->인증되지 않은 request의 JSON 응답 lambda를 등록한다.
62 write(response, 403, ErrorCode.ACCESS_DENIED,access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
63 "cloud API is disabled", RequestIdFilter.current(request)))cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
64 .accessDeniedHandler((request, response, failure) ->인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
65 write(response, 403, ErrorCode.ACCESS_DENIED,access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
66 "cloud API is disabled", RequestIdFilter.current(request))))cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
67 .build();누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
68 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
70 private static void write(공통 JSON security error response helper signature를 시작한다.
71 HttpServletResponse response,status/header/body를 기록할 servlet response parameter를 받는다.
72 int status,호출자가 정한 HTTP status 숫자를 받는다.
73 ErrorCode code,JSON errorCode로 직렬화할 enum을 받는다.
74 String message,JSON message로 쓸 controlled 문자열을 받는다.
75 String requestId같은 요청을 추적할 requestId를 받는다.
76 ) throws java.io.IOException {뒤의 설정·실행·검증 문장을 묶을 block 또는 호출 범위를 연다.
77 response.setStatus(status);HTTP status를 response에 설정한다.
78 response.setCharacterEncoding(StandardCharsets.UTF_8.name());한글 message도 안전하게 쓰도록 UTF-8 encoding을 설정한다.
79 response.setContentType(MediaType.APPLICATION_JSON_VALUE);response media type을 application/json으로 설정한다.
80 response.getWriter().write("{\"errorCode\":\"" + code.name()errorCode부터 JSON 문자열을 response body에 쓰기 시작한다.
81 + "\",\"message\":\"" + messageJSON에 message field를 이어 붙인다.
82 + "\",\"requestId\":\"" + requestId + "\"}");JSON에 requestId를 붙이고 object를 닫는다.
83 }현재 Java/SQL block·호출·CTE 범위를 닫는다.
84}현재 Java/SQL block·호출·CTE 범위를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다. 다만 {noop} user는 local/test 학습용이다

문법 해부

  • @Profile별 SecurityFilterChain 두 개가 matcher와 filter option을 다르게 구성한다.
  • private write helper가 status·UTF-8·JSON errorCode/message/requestId를 한 번에 기록한다.

실행 순서

  1. local/test에는 두 in-memory user가 등록된다.
  2. health 공개, /api 인증, 나머지 denyAll이 적용된다.
  3. cloud/prod는 health 외 denyAll·CSRF default·Basic disabled다.
  4. 실패 handler가 JSON과 requestId를 쓴다.

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

F07-C01 · imports
문법 해부
1~18줄의 `imports`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `local /api/**=authenticated`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `local /api/**=authenticated`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: {noop} user는 local/test 학습용이다.
착각 방지
`imports`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: {noop} user는 local/test 학습용이다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F07-C02 · local learning users
문법 해부
19~29줄의 `local learning users`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `anonymous local=401`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `anonymous local=401`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: API CSRF ignore는 CORS 완성을 뜻하지 않는다.
착각 방지
`local learning users`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: API CSRF ignore는 CORS 완성을 뜻하지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F07-C03 · local and test security chain
문법 해부
30~49줄의 `local and test security chain`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `cloud API=403`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `cloud API=403`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: cloud는 운영 인증이 아니라 disabled API다.
착각 방지
`local and test security chain`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: cloud는 운영 인증이 아니라 disabled API다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F07-C04 · cloud and prod fail-closed chain
문법 해부
50~68줄의 `cloud and prod fail-closed chain`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `cloud Basic disabled`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `cloud Basic disabled`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 문자열 JSON helper는 controlled message를 전제로 한다.
착각 방지
`cloud and prod fail-closed chain`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 문자열 JSON helper는 controlled message를 전제로 한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F07-C05 · JSON error writer
문법 해부
69~84줄의 `JSON error writer`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `JSON requestId`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `JSON requestId`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: {noop} user는 local/test 학습용이다.
착각 방지
`JSON error writer`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: {noop} user는 local/test 학습용이다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. customer-2도 authenticated지만 타인 account는 403이다.

  3. counterexample의 이유는 `인증은 actor만 확인한다.`이야.

  4. 고친 문장은 `F06에서 account owner를 비교한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F07-T01 local anonymous APIGET /api/accountauthenticated matcher401 JSONErrorCode INVALID_REQUEST는 학습 convention
F07-T02 local other ownerBasic customer-2security auth then owner gate403 ACCESS_DENIED소유권은 F06 책임
F07-T03 cloud Basic APIcredential suppliedBasic disabled + denyAll403 no challenge운영 auth가 아니라 API disabled
F07-T04 healthlocal/cloud GET healthfirst matcherpermit노출 정보량은 별도
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `Green local/cloud security chain`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `문자열 JSON helper는 controlled message를 전제로 한다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

bean/profile

active profile이 하나의 chain과 local user service를 선택한다.

잘못된 복수 profile 조합은 별도 fail-fast가 필요하다.
Spring Security

matcher는 위에서 아래로 첫 규칙을 적용하고 마지막 denyAll이 default를 닫는다.

method-level/object authorization을 대체하지 않는다.
response writer

entry point/denied handler가 같은 JSON helper를 호출한다.

message/requestId가 외부 입력이면 JSON escaping이 추가로 필요하다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ /api authenticated면 BOLA도 해결된다

왜 틀리나 인증은 actor만 확인한다.

바르게 읽기 F06에서 account owner를 비교한다.

반례 customer-2도 authenticated지만 타인 account는 403이다.

❌ CSRF ignore가 CORS 설정이다

왜 틀리나 두 기능은 다른 browser security 메커니즘이다.

바르게 읽기 CORS allowlist/preflight를 별도로 구성·검증한다.

반례 source에 cors()나 Origin test가 없다.

❌ cloud 403은 운영 인증 성공이다

왜 틀리나 Basic이 disabled이고 API 자체가 denyAll이다.

바르게 읽기 운영 auth 준비 전 fail-closed로 표현한다.

반례 정상 cloud business call도 403이다.

❌ {noop} password를 배포해도 된다

왜 틀리나 평문 teaching credential이다.

바르게 읽기 local/test profile에만 둔다.

반례 cloud/prod chain에는 user bean도 없다.

❌ 문자열 결합 JSON은 모든 입력에 안전하다

왜 틀리나 quote/newline escape가 없다.

바르게 읽기 controlled enum/message를 전제로 하거나 serializer를 쓴다.

반례 외부 message가 `"`를 포함하면 JSON이 깨질 수 있다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

{noop} user는 local/test 학습용이다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

API CSRF ignore는 CORS 완성을 뜻하지 않는다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

cloud는 운영 인증이 아니라 disabled API다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

문자열 JSON helper는 controlled message를 전제로 한다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.

2단계 · 코드 조각 재조립

  1. imports
  2. local learning users
  3. local and test security chain
  4. cloud and prod fail-closed chain
  5. JSON error writer

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

84개 물리 줄을 원본 순서로 복원하고 SHA-256 48d3aebec4a91cf44a07dde471ce38f1f5cbf8497c9a9be768c109555b559123와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · Green learner 해법learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/SecurityConfiguration.javaSHA-256 48d3aebec4a91cf44a07dde471ce38f1f5cbf8497c9a9be768c109555b559123
SecurityConfiguration.java — profile별 인증·fail-closed Green 해법 전체
package com.example.financialcore.security;

import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.api.RequestIdFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import java.nio.charset.StandardCharsets;

@Configuration
public class SecurityConfiguration {
    @Bean
    @Profile({"default", "local", "test"})
    UserDetailsService learningUsers() {
        return new InMemoryUserDetailsManager(
            User.withUsername("customer-1").password("{noop}password").roles("CUSTOMER").build(),
            User.withUsername("customer-2").password("{noop}password").roles("CUSTOMER").build()
        );
    }

    @Bean
    @Profile({"default", "local", "test"})
    SecurityFilterChain localTestSecurity(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll())
            .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
            .httpBasic(Customizer.withDefaults())
            .exceptionHandling(errors -> errors
                .authenticationEntryPoint((request, response, failure) ->
                    write(response, 401, ErrorCode.INVALID_REQUEST,
                        "authentication required", RequestIdFilter.current(request)))
                .accessDeniedHandler((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "access denied", RequestIdFilter.current(request))))
            .build();
    }

    @Bean
    @Profile({"cloud", "prod"})
    SecurityFilterChain cloudProdFailClosed(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/actuator/health/**").permitAll()
                .anyRequest().denyAll())
            .csrf(Customizer.withDefaults())
            .httpBasic(AbstractHttpConfigurer::disable)
            .exceptionHandling(errors -> errors
                .authenticationEntryPoint((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "cloud API is disabled", RequestIdFilter.current(request)))
                .accessDeniedHandler((request, response, failure) ->
                    write(response, 403, ErrorCode.ACCESS_DENIED,
                        "cloud API is disabled", RequestIdFilter.current(request))))
            .build();
    }

    private static void write(
        HttpServletResponse response,
        int status,
        ErrorCode code,
        String message,
        String requestId
    ) throws java.io.IOException {
        response.setStatus(status);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("{\"errorCode\":\"" + code.name()
            + "\",\"message\":\"" + message
            + "\",\"requestId\":\"" + requestId + "\"}");
    }
}
08

W20-SQL-Q31.sql — 고객별 잔액 RANK/DENSE_RANK 비정본 예시

illustrative/sql/W20-SQL-Q31.sql

학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F08
41줄 연결41줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

  1. main rows=6은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `ACTIVE/CLOSED 포함은 명시한 가정이다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값main rows=6customer6 total=1000000 rank=1customer4/5 total=0 rank=5tie probe tail=RANK3/DENSE2
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

W20-SQL-Q31.sql — 고객별 잔액 RANK/DENSE_RANK 비정본 예시를 출입문 검사표로 바꾸기

고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

핵심값 main rows=6, customer6 total=1000000 rank=1, customer4/5 total=0 rank=5, tie probe tail=RANK3/DENSE2을 원본 줄로 따라가되, ACTIVE/CLOSED 포함은 명시한 가정이다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

provenance assumptions and schema

1~4줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

코드 연결
1~4줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
ACTIVE/CLOSED 포함은 명시한 가정이다

customer balance aggregation

5~14줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

코드 연결
5~14줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
계좌 없는 고객을 0으로 보존한다

ranked main result and oracle

15~30줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

코드 연결
15~30줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
동률 내부 출력 순서는 customer_id가 안정화한다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? main rows=6부터 보면 될까?

  2. 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

  3. source에서 관찰할 첫 값은 `main rows=6`이네.

  4. 그리고 `ACTIVE/CLOSED 포함은 명시한 가정이다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `customer6 total=1000000 rank=1`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. customer 6행을 시작 grain으로 둔다. → account balance를 고객별 SUM하고 없으면 0으로 바꾼다.

  3. 다음 단계는 내림차순 RANK/DENSE_RANK를 계산한다. → customer_id로 동률 출력 순서를 안정화하고 tie probe를 실행한다.

  4. 최종값 `customer6 total=1000000 rank=1`과 미보장 `계좌 없는 고객을 0으로 보존한다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.학습용 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.41 / 41 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F08-L01 -- W20-SQL-Q31 illustrative example; not a shipped workbook answer. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
2줄F08-L02 -- Assumption: output grain is one customer; every ACTIVE/CLOSED account balance counts. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
3줄F08-L03 -- Customers without an account remain with total_balance=0. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 stable order와 예시의 해석 한계를 주석으로 고정한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
4줄F08-L04 SET search_path TO :"workbook_schema", public; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
6줄F08-L06 WITH customer_balance AS ( 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객별 balance 한 행을 만들 첫 CTE를 연다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
7줄F08-L07 SELECT 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
8줄F08-L08 c.customer_id, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
9줄F08-L09 c.customer_name, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
10줄F08-L10 COALESCE(SUM(a.balance), 0) AS total_balance 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 account balance 합계가 NULL이면 0으로 바꾼다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
11줄F08-L11 FROM customer AS c 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
12줄F08-L12 LEFT JOIN account AS a ON a.customer_id = c.customer_id 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer의 모든 account를 붙이고 0건 고객도 남긴다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
13줄F08-L13 GROUP BY c.customer_id, c.customer_name 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 customer_id/name grain으로 여러 account row를 축약한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
14줄F08-L14 ), 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 14번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
15줄F08-L15 ranked AS ( 같은 점수를 받은 참가자의 등수표 집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
16줄F08-L16 SELECT 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
17줄F08-L17 customer_id, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 17번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
18줄F08-L18 customer_name, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 18번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
19줄F08-L19 total_balance, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 19번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
20줄F08-L20 RANK() OVER (ORDER BY total_balance DESC) AS balance_rank, 같은 점수를 받은 참가자의 등수표 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 tie 뒤의 다음 순번을 건너뛰는 balance_rank column이 생긴다.
비유의 한계
배포된 workbook 정답이 아니다
21줄F08-L21 DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank 같은 점수를 받은 참가자의 등수표 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 tie 뒤의 다음 순번을 건너뛰는 balance_rank column이 생긴다.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
22줄F08-L22 FROM customer_balance 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
23줄F08-L23 ) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
24줄F08-L24 SELECT customer_id, customer_name, total_balance, balance_rank, dense_balance_rank 같은 점수를 받은 참가자의 등수표 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
25줄F08-L25 FROM ranked 같은 점수를 받은 참가자의 등수표 직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
26줄F08-L26 ORDER BY total_balance DESC, customer_id; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
28줄F08-L28 -- Main oracle: six customers; 6/2/3/1 rank 1/2/3/4, customers 4 and 5 tie at rank 5. 같은 점수를 받은 참가자의 등수표 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
29줄F08-L29 -- Zero-account customer 5 is preserved; CLOSED account 107 contributes under this assumption. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
31줄F08-L31 -- Executable tie diagnostic: the row after a tie becomes RANK 3 but DENSE_RANK 2. 같은 점수를 받은 참가자의 등수표 RANK와 DENSE_RANK가 갈라지는 tie counterexample을 별도 실행한다고 알린다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
32줄F08-L32 WITH tie_probe(customer_id, total_balance) AS ( 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 100·100·50 세 행의 실행 가능한 tie counterexample을 연다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
33줄F08-L33 VALUES (91, 100), (92, 100), (93, 50) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 동률 두 행과 낮은 한 행을 diagnostic input으로 만든다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
34줄F08-L34 ), 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 고객 grain rank window 예시에서 34번째 문장을 앞뒤 단계와 연결한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
35줄F08-L35 tie_ranked AS ( 같은 점수를 받은 참가자의 등수표 집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
36줄F08-L36 SELECT customer_id, total_balance, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
37줄F08-L37 RANK() OVER (ORDER BY total_balance DESC) AS balance_rank, 같은 점수를 받은 참가자의 등수표 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 tie 뒤의 다음 순번을 건너뛰는 balance_rank column이 생긴다.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
38줄F08-L38 DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank 같은 점수를 받은 참가자의 등수표 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 tie 뒤의 다음 순번을 건너뛰는 balance_rank column이 생긴다.
비유의 한계
계좌 없는 고객을 0으로 보존한다
39줄F08-L39 FROM tie_probe 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 tie diagnostic 세 행을 window 입력으로 읽는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
40줄F08-L40 ) 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 현재 Java/SQL block·호출·CTE 범위를 닫는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
41줄F08-L41 SELECT customer_id, total_balance, balance_rank, dense_balance_rank 같은 점수를 받은 참가자의 등수표 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: main rows=6.
비유의 한계
ACTIVE/CLOSED 포함은 명시한 가정이다
42줄F08-L42 FROM tie_ranked 같은 점수를 받은 참가자의 등수표 직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer6 total=1000000 rank=1.
비유의 한계
계좌 없는 고객을 0으로 보존한다
43줄F08-L43 ORDER BY total_balance DESC, customer_id; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: customer4/5 total=0 rank=5.
비유의 한계
동률 내부 출력 순서는 customer_id가 안정화한다
44줄F08-L44 -- Tie oracle: (91,100,1,1), (92,100,1,1), (93,50,3,2). 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
입력
customer 6행과 account 8행 또는 tie_probe 3행
결과·효과
그 결과 F08의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: tie probe tail=RANK3/DENSE2.
비유의 한계
배포된 workbook 정답이 아니다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `tie probe tail=RANK3/DENSE2`을 source 줄과 test card로 대조하면 된다.

  4. `동률 내부 출력 순서는 customer_id가 안정화한다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F08-C01 · provenance assumptions and schema1–4줄
1–4줄 원본
-- W20-SQL-Q31 illustrative example; not a shipped workbook answer.
-- Assumption: output grain is one customer; every ACTIVE/CLOSED account balance counts.
-- Customers without an account remain with total_balance=0.
SET search_path TO :"workbook_schema", public;
F08-C02 · customer balance aggregation5–14줄
5–14줄 원본

WITH customer_balance AS (
    SELECT
        c.customer_id,
        c.customer_name,
        COALESCE(SUM(a.balance), 0) AS total_balance
    FROM customer AS c
    LEFT JOIN account AS a ON a.customer_id = c.customer_id
    GROUP BY c.customer_id, c.customer_name
),
F08-C03 · ranked main result and oracle15–30줄
15–30줄 원본
ranked AS (
    SELECT
        customer_id,
        customer_name,
        total_balance,
        RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,
        DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank
    FROM customer_balance
)
SELECT customer_id, customer_name, total_balance, balance_rank, dense_balance_rank
FROM ranked
ORDER BY total_balance DESC, customer_id;

-- Main oracle: six customers; 6/2/3/1 rank 1/2/3/4, customers 4 and 5 tie at rank 5.
-- Zero-account customer 5 is preserved; CLOSED account 107 contributes under this assumption.
F08-C04 · executable tie diagnostic31–44줄
31–44줄 원본
-- Executable tie diagnostic: the row after a tie becomes RANK 3 but DENSE_RANK 2.
WITH tie_probe(customer_id, total_balance) AS (
    VALUES (91, 100), (92, 100), (93, 50)
),
tie_ranked AS (
    SELECT customer_id, total_balance,
           RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,
           DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank
    FROM tie_probe
)
SELECT customer_id, total_balance, balance_rank, dense_balance_rank
FROM tie_ranked
ORDER BY total_balance DESC, customer_id;
-- Tie oracle: (91,100,1,1), (92,100,1,1), (93,50,3,2).
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 41 / 41

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

원본한국어 번역
1-- W20-SQL-Q31 illustrative example; not a shipped workbook answer.이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
2-- Assumption: output grain is one customer; every ACTIVE/CLOSED account balance counts.문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
3-- Customers without an account remain with total_balance=0.stable order와 예시의 해석 한계를 주석으로 고정한다.
4SET search_path TO :"workbook_schema", public;workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
6WITH customer_balance AS (고객별 balance 한 행을 만들 첫 CTE를 연다.
7 SELECT최종 또는 diagnostic 결과에 필요한 column을 projection한다.
8 c.customer_id,고객 grain rank window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
9 c.customer_name,고객 grain rank window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
10 COALESCE(SUM(a.balance), 0) AS total_balanceaccount balance 합계가 NULL이면 0으로 바꾼다.
11 FROM customer AS c계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
12 LEFT JOIN account AS a ON a.customer_id = c.customer_idcustomer의 모든 account를 붙이고 0건 고객도 남긴다.
13 GROUP BY c.customer_id, c.customer_namecustomer_id/name grain으로 여러 account row를 축약한다.
14),고객 grain rank window 예시에서 14번째 문장을 앞뒤 단계와 연결한다.
15ranked AS (집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
16 SELECT최종 또는 diagnostic 결과에 필요한 column을 projection한다.
17 customer_id,고객 grain rank window 예시에서 17번째 문장을 앞뒤 단계와 연결한다.
18 customer_name,고객 grain rank window 예시에서 18번째 문장을 앞뒤 단계와 연결한다.
19 total_balance,고객 grain rank window 예시에서 19번째 문장을 앞뒤 단계와 연결한다.
20 RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
21 DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
22 FROM customer_balance계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
23)현재 Java/SQL block·호출·CTE 범위를 닫는다.
24SELECT customer_id, customer_name, total_balance, balance_rank, dense_balance_rank최종 또는 diagnostic 결과에 필요한 column을 projection한다.
25FROM ranked직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
26ORDER BY total_balance DESC, customer_id;사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
28-- Main oracle: six customers; 6/2/3/1 rank 1/2/3/4, customers 4 and 5 tie at rank 5.동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
29-- Zero-account customer 5 is preserved; CLOSED account 107 contributes under this assumption.문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
31-- Executable tie diagnostic: the row after a tie becomes RANK 3 but DENSE_RANK 2.RANK와 DENSE_RANK가 갈라지는 tie counterexample을 별도 실행한다고 알린다.
32WITH tie_probe(customer_id, total_balance) AS (100·100·50 세 행의 실행 가능한 tie counterexample을 연다.
33 VALUES (91, 100), (92, 100), (93, 50)동률 두 행과 낮은 한 행을 diagnostic input으로 만든다.
34),고객 grain rank window 예시에서 34번째 문장을 앞뒤 단계와 연결한다.
35tie_ranked AS (집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
36 SELECT customer_id, total_balance,최종 또는 diagnostic 결과에 필요한 column을 projection한다.
37 RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
38 DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
39 FROM tie_probetie diagnostic 세 행을 window 입력으로 읽는다.
40)현재 Java/SQL block·호출·CTE 범위를 닫는다.
41SELECT customer_id, total_balance, balance_rank, dense_balance_rank최종 또는 diagnostic 결과에 필요한 column을 projection한다.
42FROM tie_ranked직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
43ORDER BY total_balance DESC, customer_id;사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
44-- Tie oracle: (91,100,1,1), (92,100,1,1), (93,50,3,2).동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다. 다만 ACTIVE/CLOSED 포함은 명시한 가정이다

문법 해부

  • customer LEFT JOIN account를 고객 한 행으로 GROUP BY한 뒤 window rank를 계산한다.
  • RANK와 DENSE_RANK를 같은 ORDER BY에 나란히 두고 별도 tie_probe로 차이를 보인다.

실행 순서

  1. customer 6행을 시작 grain으로 둔다.
  2. account balance를 고객별 SUM하고 없으면 0으로 바꾼다.
  3. 내림차순 RANK/DENSE_RANK를 계산한다.
  4. customer_id로 동률 출력 순서를 안정화하고 tie probe를 실행한다.

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

F08-C01 · provenance assumptions and schema
문법 해부
1~4줄의 `provenance assumptions and schema`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `main rows=6`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `main rows=6`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: ACTIVE/CLOSED 포함은 명시한 가정이다.
착각 방지
`provenance assumptions and schema`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: ACTIVE/CLOSED 포함은 명시한 가정이다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F08-C02 · customer balance aggregation
문법 해부
5~14줄의 `customer balance aggregation`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `customer6 total=1000000 rank=1`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `customer6 total=1000000 rank=1`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 계좌 없는 고객을 0으로 보존한다.
착각 방지
`customer balance aggregation`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 계좌 없는 고객을 0으로 보존한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F08-C03 · ranked main result and oracle
문법 해부
15~30줄의 `ranked main result and oracle`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `customer4/5 total=0 rank=5`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `customer4/5 total=0 rank=5`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 동률 내부 출력 순서는 customer_id가 안정화한다.
착각 방지
`ranked main result and oracle`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 동률 내부 출력 순서는 customer_id가 안정화한다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F08-C04 · executable tie diagnostic
문법 해부
31~44줄의 `executable tie diagnostic`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `tie probe tail=RANK3/DENSE2`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `tie probe tail=RANK3/DENSE2`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 배포된 workbook 정답이 아니다.
착각 방지
`executable tie diagnostic`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 배포된 workbook 정답이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. 마지막 row가 rank3/dense2다.

  3. counterexample의 이유는 `tie 뒤에 더 낮은 row가 있으면 번호가 달라진다.`이야.

  4. 고친 문장은 `100,100,50 probe로 비교한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F08-T01 customer-1accounts101+10510000+100000110000 rank4status를 필터하지 않는 가정
F08-T02 customer-5no accountLEFT JOIN + COALESCE0 rank5INNER JOIN이면 사라진다
F08-T03 tie probe100,100,50two windows1/1,1/1,3/2main seed 뒤에 lower row가 없어 차이를 따로 만든다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `고객 grain rank window 예시`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `배포된 workbook 정답이 아니다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

GROUP BY

여러 account row를 customer_id 한 행으로 축약한다.

window 전에 grain을 고정해야 중복 rank를 피한다.
window

RANK는 tie 수만큼 다음 번호를 건너뛰고 DENSE_RANK는 건너뛰지 않는다.

동률 내부 순서는 rank 값과 별개다.
outer order

total_balance DESC, customer_id가 표시 순서를 고정한다.

window rank의 tie policy 자체를 바꾸지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ RANK와 DENSE_RANK는 항상 같다

왜 틀리나 tie 뒤에 더 낮은 row가 있으면 번호가 달라진다.

바르게 읽기 100,100,50 probe로 비교한다.

반례 마지막 row가 rank3/dense2다.

❌ SUM 뒤 customer row는 자동 6개다

왜 틀리나 account에서 시작하거나 INNER JOIN하면 no-account 고객이 사라진다.

바르게 읽기 customer LEFT JOIN으로 시작한다.

반례 customer-5가 결과에 남는다.

❌ CLOSED 계좌는 자동 제외된다

왜 틀리나 WHERE status filter가 없다.

바르게 읽기 포함 가정을 주석으로 명시했다.

반례 customer-3 total에 account107=500000이 포함된다.

❌ 동률이면 출력 순서도 랜덤이어도 된다

왜 틀리나 검증 가능한 evidence에는 stable display order가 좋다.

바르게 읽기 outer ORDER BY에 customer_id를 더한다.

반례 customer4/5 순서가 고정된다.

❌ 이 SQL이 공식 정답이다

왜 틀리나 PDF는 query text를 배포하지 않았다.

바르게 읽기 illustrative/noncanonical label과 가정을 유지한다.

반례 다른 status/zero-row 정책도 가능한 해석이다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

ACTIVE/CLOSED 포함은 명시한 가정이다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

계좌 없는 고객을 0으로 보존한다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

동률 내부 출력 순서는 customer_id가 안정화한다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

배포된 workbook 정답이 아니다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.

2단계 · 코드 조각 재조립

  1. provenance assumptions and schema
  2. customer balance aggregation
  3. ranked main result and oracle
  4. executable tie diagnostic

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

44개 물리 줄을 원본 순서로 복원하고 SHA-256 92fada53b39a1dc4106181ef47fa9f48274f450323d416abf8ae525616a6c1f3와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님illustrative/sql/W20-SQL-Q31.sqlSHA-256 92fada53b39a1dc4106181ef47fa9f48274f450323d416abf8ae525616a6c1f3
W20-SQL-Q31.sql — 고객별 잔액 RANK/DENSE_RANK 비정본 예시 전체
-- W20-SQL-Q31 illustrative example; not a shipped workbook answer.
-- Assumption: output grain is one customer; every ACTIVE/CLOSED account balance counts.
-- Customers without an account remain with total_balance=0.
SET search_path TO :"workbook_schema", public;

WITH customer_balance AS (
    SELECT
        c.customer_id,
        c.customer_name,
        COALESCE(SUM(a.balance), 0) AS total_balance
    FROM customer AS c
    LEFT JOIN account AS a ON a.customer_id = c.customer_id
    GROUP BY c.customer_id, c.customer_name
),
ranked AS (
    SELECT
        customer_id,
        customer_name,
        total_balance,
        RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,
        DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank
    FROM customer_balance
)
SELECT customer_id, customer_name, total_balance, balance_rank, dense_balance_rank
FROM ranked
ORDER BY total_balance DESC, customer_id;

-- Main oracle: six customers; 6/2/3/1 rank 1/2/3/4, customers 4 and 5 tie at rank 5.
-- Zero-account customer 5 is preserved; CLOSED account 107 contributes under this assumption.

-- Executable tie diagnostic: the row after a tie becomes RANK 3 but DENSE_RANK 2.
WITH tie_probe(customer_id, total_balance) AS (
    VALUES (91, 100), (92, 100), (93, 50)
),
tie_ranked AS (
    SELECT customer_id, total_balance,
           RANK() OVER (ORDER BY total_balance DESC) AS balance_rank,
           DENSE_RANK() OVER (ORDER BY total_balance DESC) AS dense_balance_rank
    FROM tie_probe
)
SELECT customer_id, total_balance, balance_rank, dense_balance_rank
FROM tie_ranked
ORDER BY total_balance DESC, customer_id;
-- Tie oracle: (91,100,1,1), (92,100,1,1), (93,50,3,2).
09

W20-SQL-Q32.sql — 계좌별 원장 누적합 비정본 예시

illustrative/sql/W20-SQL-Q32.sql

학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F09
22줄 연결22줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

  1. rows=16은 어느 줄에서 생길까?
  2. 입력→판정→관찰값은 어떤 순서인가?
  3. starter/test/solution/예시의 provenance는 무엇인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. 첫 counterexample은 `ledger가 없는 account는 나오지 않는다`과 어떻게 연결되는가?
이 파일에서 끝까지 다시 쓰는 값rows=16account101 opening=10000account101 final=8800same-time reversals entry15 then16
02

STEP 02 / 13

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

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

STARRY가 신원표·좌석표·DB 결과표를 서로 다른 칸에 놓는다.

W20-SQL-Q32.sql — 계좌별 원장 누적합 비정본 예시를 출입문 검사표로 바꾸기

ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

핵심값 rows=16, account101 opening=10000, account101 final=8800, same-time reversals entry15 then16을 원본 줄로 따라가되, ledger가 없는 account는 나오지 않는다까지 표시해 증명을 부풀리지 않는다.

딱 여기까지만 비유는 흐름을 기억하게 할 뿐 실제 Spring filter order, transaction, SQL window semantics를 대신 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

provenance assumptions and schema

1~4줄을 한 덩어리로 읽어 이 파일 흐름의 1번째 움직임을 본다. 목표는 다음과 같다: ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

코드 연결
1~4줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
ledger가 없는 account는 나오지 않는다

ledger output grain

5~11줄을 한 덩어리로 읽어 이 파일 흐름의 2번째 움직임을 본다. 목표는 다음과 같다: ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

코드 연결
5~11줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
account.balance와의 reconciliation은 별도다

stable ROWS running sum

12~18줄을 한 덩어리로 읽어 이 파일 흐름의 3번째 움직임을 본다. 목표는 다음과 같다: ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

코드 연결
12~18줄
비유
출입문 앞에서 신분표·좌석표·기록표를 순서대로 확인하는 장면
비유의 끝
occurred_at만으로는 tie가 불안정하다
문제의 첫 장면
  1. 이 파일이 막으려는 첫 실패가 뭐야? rows=16부터 보면 될까?

  2. ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

  3. source에서 관찰할 첫 값은 `rows=16`이네.

  4. 그리고 `ledger가 없는 account는 나오지 않는다`까지 같이 적을게.

입력에서 결과까지
  1. 입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `account101 opening=10000`은 언제 생겨?

  2. 호출 순서를 한 칸씩 놓으면 돼. ledger_entry 한 행을 그대로 읽는다. → account별 partition으로 나눈다.

  3. 다음 단계는 occurred_at·entry_id 순서까지 현재 행을 더한다. → 같은 순서로 출력해 running_balance를 대조한다.

  4. 최종값 `account101 opening=10000`과 미보장 `account.balance와의 reconciliation은 별도다`을 분리하자.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.학습용 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.22 / 22 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F09-L01 -- W20-SQL-Q32 illustrative example; not a shipped workbook answer. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: rows=16.
비유의 한계
ledger가 없는 account는 나오지 않는다
2줄F09-L02 -- Assumption: ledger_entry is the authoritative signed movement grain. 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
3줄F09-L03 -- Stable order is occurred_at then entry_id; ROWS makes physical-row accumulation explicit. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 stable order와 예시의 해석 한계를 주석으로 고정한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 final=8800.
비유의 한계
occurred_at만으로는 tie가 불안정하다
4줄F09-L04 SET search_path TO :"workbook_schema", public; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: same-time reversals entry15 then16.
비유의 한계
배포된 workbook 정답이 아니다
6줄F09-L06 SELECT 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
7줄F09-L07 account_id, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 ledger grain running-sum window 예시에서 7번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 final=8800.
비유의 한계
occurred_at만으로는 tie가 불안정하다
8줄F09-L08 entry_id, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 ledger grain running-sum window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: same-time reversals entry15 then16.
비유의 한계
배포된 workbook 정답이 아니다
9줄F09-L09 entry_type, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 ledger grain running-sum window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: rows=16.
비유의 한계
ledger가 없는 account는 나오지 않는다
10줄F09-L10 signed_amount, 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 ledger grain running-sum window 예시에서 10번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
11줄F09-L11 occurred_at, 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 ledger grain running-sum window 예시에서 11번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 final=8800.
비유의 한계
occurred_at만으로는 tie가 불안정하다
12줄F09-L12 SUM(signed_amount) OVER ( 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 signed movement의 account별 running sum window를 연다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 각 ledger row에 현재 시점까지의 running_balance가 붙는다.
비유의 한계
배포된 workbook 정답이 아니다
13줄F09-L13 PARTITION BY account_id 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 account가 바뀔 때 누적 state를 분리한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: rows=16.
비유의 한계
ledger가 없는 account는 나오지 않는다
14줄F09-L14 ORDER BY occurred_at, entry_id 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 시간과 entry ID로 누적 순서를 안정화한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
15줄F09-L15 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 partition 첫 row부터 현재 물리 row까지 frame을 명시한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 final=8800.
비유의 한계
occurred_at만으로는 tie가 불안정하다
16줄F09-L16 ) AS running_balance 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 ledger grain running-sum window 예시에서 16번째 문장을 앞뒤 단계와 연결한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: same-time reversals entry15 then16.
비유의 한계
배포된 workbook 정답이 아니다
17줄F09-L17 FROM ledger_entry 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 부호가 이미 정해진 ledger movement row를 입력으로 읽는다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: rows=16.
비유의 한계
ledger가 없는 account는 나오지 않는다
18줄F09-L18 ORDER BY account_id, occurred_at, entry_id; 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
20줄F09-L20 -- Oracle account101: 10000,11000,10500,10200,10000,9400,8800 for entries 1,7,8,12,14,15,16. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: same-time reversals entry15 then16.
비유의 한계
배포된 workbook 정답이 아니다
21줄F09-L21 -- Oracle account102: 20000 then 20300; accounts105/106/107/108 end 1299999/499999/500000/1000000. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: rows=16.
비유의 한계
ledger가 없는 account는 나오지 않는다
22줄F09-L22 -- Every OPENING row running_balance equals its signed_amount; total output rows=16. 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 stable order와 예시의 해석 한계를 주석으로 고정한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 opening=10000.
비유의 한계
account.balance와의 reconciliation은 별도다
23줄F09-L23 -- Boundary: accounts with no ledger row do not appear, and this query does not reconcile account.balance. 통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 이 query가 책임지지 않는 결과 집합·reconciliation 경계를 기록한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: account101 final=8800.
비유의 한계
occurred_at만으로는 tie가 불안정하다
24줄F09-L24 -- Same-time account101 reversals stay deterministic because entry_id 15 precedes 16. 입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 stable order와 예시의 해석 한계를 주석으로 고정한다.
입력
ledger_entry 16행의 account_id·time·entry_id·signed_amount
결과·효과
그 결과 F09의 입력이 다음 단계에서 확인 가능한 상태로 바뀐다: same-time reversals entry15 then16.
비유의 한계
배포된 workbook 정답이 아니다
test가 말한 만큼만
  1. Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?

  2. 맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.

  3. 여기서는 `same-time reversals entry15 then16`을 source 줄과 test card로 대조하면 된다.

  4. `occurred_at만으로는 tie가 불안정하다`는 별도 증거가 필요해.

05

STEP 05 / 13

원본 코드 조각

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

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

F09-C01 · provenance assumptions and schema1–4줄
1–4줄 원본
-- W20-SQL-Q32 illustrative example; not a shipped workbook answer.
-- Assumption: ledger_entry is the authoritative signed movement grain.
-- Stable order is occurred_at then entry_id; ROWS makes physical-row accumulation explicit.
SET search_path TO :"workbook_schema", public;
F09-C02 · ledger output grain5–11줄
5–11줄 원본

SELECT
    account_id,
    entry_id,
    entry_type,
    signed_amount,
    occurred_at,
F09-C03 · stable ROWS running sum12–18줄
12–18줄 원본
    SUM(signed_amount) OVER (
        PARTITION BY account_id
        ORDER BY occurred_at, entry_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_balance
FROM ledger_entry
ORDER BY account_id, occurred_at, entry_id;
F09-C04 · fixture oracles and boundaries19–24줄
19–24줄 원본

-- Oracle account101: 10000,11000,10500,10200,10000,9400,8800 for entries 1,7,8,12,14,15,16.
-- Oracle account102: 20000 then 20300; accounts105/106/107/108 end 1299999/499999/500000/1000000.
-- Every OPENING row running_balance equals its signed_amount; total output rows=16.
-- Boundary: accounts with no ledger row do not appear, and this query does not reconcile account.balance.
-- Same-time account101 reversals stay deterministic because entry_id 15 precedes 16.
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 22 / 22

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

원본한국어 번역
1-- W20-SQL-Q32 illustrative example; not a shipped workbook answer.이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
2-- Assumption: ledger_entry is the authoritative signed movement grain.문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
3-- Stable order is occurred_at then entry_id; ROWS makes physical-row accumulation explicit.stable order와 예시의 해석 한계를 주석으로 고정한다.
4SET search_path TO :"workbook_schema", public;workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
6SELECT최종 또는 diagnostic 결과에 필요한 column을 projection한다.
7 account_id,ledger grain running-sum window 예시에서 7번째 문장을 앞뒤 단계와 연결한다.
8 entry_id,ledger grain running-sum window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
9 entry_type,ledger grain running-sum window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
10 signed_amount,ledger grain running-sum window 예시에서 10번째 문장을 앞뒤 단계와 연결한다.
11 occurred_at,ledger grain running-sum window 예시에서 11번째 문장을 앞뒤 단계와 연결한다.
12 SUM(signed_amount) OVER (signed movement의 account별 running sum window를 연다.
13 PARTITION BY account_idaccount가 바뀔 때 누적 state를 분리한다.
14 ORDER BY occurred_at, entry_id시간과 entry ID로 누적 순서를 안정화한다.
15 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWpartition 첫 row부터 현재 물리 row까지 frame을 명시한다.
16 ) AS running_balanceledger grain running-sum window 예시에서 16번째 문장을 앞뒤 단계와 연결한다.
17FROM ledger_entry부호가 이미 정해진 ledger movement row를 입력으로 읽는다.
18ORDER BY account_id, occurred_at, entry_id;사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
20-- Oracle account101: 10000,11000,10500,10200,10000,9400,8800 for entries 1,7,8,12,14,15,16.동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
21-- Oracle account102: 20000 then 20300; accounts105/106/107/108 end 1299999/499999/500000/1000000.동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
22-- Every OPENING row running_balance equals its signed_amount; total output rows=16.stable order와 예시의 해석 한계를 주석으로 고정한다.
23-- Boundary: accounts with no ledger row do not appear, and this query does not reconcile account.balance.이 query가 책임지지 않는 결과 집합·reconciliation 경계를 기록한다.
24-- Same-time account101 reversals stay deterministic because entry_id 15 precedes 16.stable order와 예시의 해석 한계를 주석으로 고정한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다. 다만 ledger가 없는 account는 나오지 않는다

문법 해부

  • SUM(signed_amount) OVER가 GROUP BY 없이 ledger row를 유지한다.
  • PARTITION BY account_id, stable ORDER BY, explicit ROWS frame이 누적 범위를 고정한다.

실행 순서

  1. ledger_entry 한 행을 그대로 읽는다.
  2. account별 partition으로 나눈다.
  3. occurred_at·entry_id 순서까지 현재 행을 더한다.
  4. 같은 순서로 출력해 running_balance를 대조한다.

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

F09-C01 · provenance assumptions and schema
문법 해부
1~4줄의 `provenance assumptions and schema`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `rows=16`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `rows=16`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: ledger가 없는 account는 나오지 않는다.
착각 방지
`provenance assumptions and schema`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: ledger가 없는 account는 나오지 않는다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F09-C02 · ledger output grain
문법 해부
5~11줄의 `ledger output grain`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `account101 opening=10000`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `account101 opening=10000`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: account.balance와의 reconciliation은 별도다.
착각 방지
`ledger output grain`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: account.balance와의 reconciliation은 별도다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F09-C03 · stable ROWS running sum
문법 해부
12~18줄의 `stable ROWS running sum`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `account101 final=8800`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `account101 final=8800`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: occurred_at만으로는 tie가 불안정하다.
착각 방지
`stable ROWS running sum`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: occurred_at만으로는 tie가 불안정하다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
F09-C04 · fixture oracles and boundaries
문법 해부
19~24줄의 `fixture oracles and boundaries`을 선언·입력·판정·관찰 단계로 나눈다.
실제 값 추적
이 chunk를 항목 전체 흐름에 연결해 동결 값 `same-time reversals entry15 then16`을 대조한다. 이 값이 chunk 단독 산출이라는 뜻은 아니다.
정상 예
정상 예에서는 source 순서와 test/seed oracle이 일치해 `same-time reversals entry15 then16`을 관찰한다.
틀린 예·반례
반례에서는 앞 단계를 생략하거나 rule을 넓혀 결과가 달라진다. 동결 경계: 배포된 workbook 정답이 아니다.
착각 방지
`fixture oracles and boundaries`이 항목 전체 보안을 단독 증명한다고 읽지 않는다.
하지 않는 일
이 chunk가 책임지지 않는 범위: 배포된 workbook 정답이 아니다.
다음 연결
다음 source chunk의 입력 또는 최종 test/SQL oracle로 결과를 넘긴다.
틀린 예 찾기
  1. 그럼 어떤 구현이 겉보기에는 맞아도 깨질까?

  2. 16개 entry가 그대로 출력된다.

  3. counterexample의 이유는 `GROUP BY는 원장 row를 축약한다.`이야.

  4. 고친 문장은 `window SUM으로 원본 row를 유지한다.`로 적으면 돼.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
F09-T01 account101 openingentry1 +10000first frame10000opening entry와 일치
F09-T02 account101 transfer/reversal-300,-200,-600,-600row-by-row SUM10200,10000,9400,8800entry15/16 tie는 ID로 푼다
F09-T03 account10220000,+300same partition20000,20300counterparty business_tx가 아니라 ledger entry를 센다
책임 경계
  1. 이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.

  2. 직접 책임은 `ledger grain running-sum window 예시`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.

  3. 대표 경계는 `배포된 workbook 정답이 아니다`이야.

  4. source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.

09

STEP 09 / 13

Java·Spring·MDC·DB 내부에서 벌어지는 일

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

partition

각 account의 window state를 독립적으로 유지한다.

account가 바뀌면 누적값이 reset된다.
ROWS frame

정렬된 물리 row를 하나씩 포함한다.

RANGE default와 같은 timestamp peer 처리 차이를 피한다.
stable key

entry_id가 timestamp tie를 결정론적으로 푼다.

occurred_at만 쓰면 같은 시각 두 reversal의 중간값이 불안정하다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ GROUP BY로도 row별 누적합이 된다

왜 틀리나 GROUP BY는 원장 row를 축약한다.

바르게 읽기 window SUM으로 원본 row를 유지한다.

반례 16개 entry가 그대로 출력된다.

❌ ORDER BY occurred_at만 충분하다

왜 틀리나 같은 timestamp의 peer 순서가 비결정적일 수 있다.

바르게 읽기 entry_id를 tie-breaker로 더한다.

반례 entry15와16이 같은 시각이다.

❌ 기본 frame은 항상 ROWS다

왜 틀리나 DB 기본은 ORDER BY 사용 시 RANGE 계열 semantics일 수 있다.

바르게 읽기 ROWS BETWEEN ...를 명시한다.

반례 peer row가 한꺼번에 포함될 수 있다.

❌ running sum은 account.balance와 반드시 같다

왜 틀리나 fixture는 ledger/book balance 불일치 가능성을 학습한다.

바르게 읽기 reconciliation은 별도 query다.

반례 account101 ledger final은 8800, account.balance는 10000이다.

❌ 이 SQL이 배포 정답이다

왜 틀리나 PDF는 핵심 window와 evidence path만 준다.

바르게 읽기 가정·비정본 label을 유지한다.

반례 zero-ledger account 포함 정책은 다른 설계가 가능하다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

직접 미보장

ledger가 없는 account는 나오지 않는다

이 책임을 맡는 곳: caller와 principal 경계
직접 미보장

account.balance와의 reconciliation은 별도다

이 책임을 맡는 곳: transaction·concurrency 설계
직접 미보장

occurred_at만으로는 tie가 불안정하다

이 책임을 맡는 곳: 운영 정책·runbook
직접 미보장

배포된 workbook 정답이 아니다

이 책임을 맡는 곳: 별도 selector/test
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

다음 의미를 고정값과 미보장 경계까지 말한다: ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.

2단계 · 코드 조각 재조립

  1. provenance assumptions and schema
  2. ledger output grain
  3. stable ROWS running sum
  4. fixture oracles and boundaries

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

24개 물리 줄을 원본 순서로 복원하고 SHA-256 b91c93d3510b4b938523f63de061849b6e4c71ed18f049fb6050f59c33c4e6c8와 대조한다.

자가 점검
  • 정본 source 줄을 바꾸지 않았는가?
  • 입력·판정·결과를 서로 다른 문장으로 설명했는가?
  • status/body/DB assertion과 추론을 구분했는가?
  • Red starter를 Green으로 부르지 않았는가?
  • Q31/Q32라면 비정본 가정을 유지했는가?
13

STEP 13 / 13

전체 원본 source

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님illustrative/sql/W20-SQL-Q32.sqlSHA-256 b91c93d3510b4b938523f63de061849b6e4c71ed18f049fb6050f59c33c4e6c8
W20-SQL-Q32.sql — 계좌별 원장 누적합 비정본 예시 전체
-- W20-SQL-Q32 illustrative example; not a shipped workbook answer.
-- Assumption: ledger_entry is the authoritative signed movement grain.
-- Stable order is occurred_at then entry_id; ROWS makes physical-row accumulation explicit.
SET search_path TO :"workbook_schema", public;

SELECT
    account_id,
    entry_id,
    entry_type,
    signed_amount,
    occurred_at,
    SUM(signed_amount) OVER (
        PARTITION BY account_id
        ORDER BY occurred_at, entry_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_balance
FROM ledger_entry
ORDER BY account_id, occurred_at, entry_id;

-- Oracle account101: 10000,11000,10500,10200,10000,9400,8800 for entries 1,7,8,12,14,15,16.
-- Oracle account102: 20000 then 20300; accounts105/106/107/108 end 1299999/499999/500000/1000000.
-- Every OPENING row running_balance equals its signed_amount; total output rows=16.
-- Boundary: accounts with no ledger row do not appear, and this query does not reconcile account.balance.
-- Same-time account101 reversals stay deterministic because entry_id 15 precedes 16.