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가 배포되지 않아 가정을 표시한 비정본 예시로만 제공합니다.
01AccountAuthorization.java — 아무도 막지 않는 소유권 Red starter
learning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/AccountAuthorization.java
원문 정본 · intentional Red starter · 정본 · W20-F011줄 연결3줄 번역3 chunks
AccountAuthorization.java — 아무도 막지 않는 소유권 Red starter
learning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/AccountAuthorization.java
원문 정본 · intentional Red starter · 정본 · W20-F01STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.
- actor=customer-2은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `starter는 실패해야 하는 입력이지 안전한 구현이 아니다`과 어떻게 연결되는가?
actor=customer-2owned account=customer-1requireOwner returnsforbidden expectedSTEP 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를 대신 증명하지 않는다.
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를 구분하지 않는다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? actor=customer-2부터 보면 될까?
-
소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.
-
source에서 관찰할 첫 값은 `actor=customer-2`이네.
-
그리고 `starter는 실패해야 하는 입력이지 안전한 구현이 아니다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `owned account=customer-1`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. Spring이 bean을 만든다. → service가 actor/account/action/requestId를 넘긴다.
-
다음 단계는 method가 비교 없이 반환한다. → 타인 요청도 다음 business code로 진행한다.
-
최종값 `owned account=customer-1`과 미보장 `constructor가 repository와 publisher를 저장하지 않는다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 1줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 3줄F01-L03 | @Component public class AccountAuthorization{ |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | Red @Component 안에 dependency를 저장하지 않는 constructor와 아무 검사도 하지 않는 requireOwner를 선언한다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `forbidden expected`을 source 줄과 test card로 대조하면 된다.
-
`missing account와 other-owner를 구분하지 않는다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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){}}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 3줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 2개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 2 | import 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를 선언한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다. 다만 starter는 실패해야 하는 입력이지 안전한 구현이 아니다
문법 해부
- 한 줄 compact source 안에 @Component, constructor, requireOwner가 함께 있다.
- 빈 `{}` body는 정상 종료를 뜻하므로 호출자가 접근 허용으로 이어 간다.
실행 순서
- Spring이 bean을 만든다.
- service가 actor/account/action/requestId를 넘긴다.
- method가 비교 없이 반환한다.
- 타인 요청도 다음 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
customer-2가 customer-1 account에 도달한다.
-
counterexample의 이유는 `authorization gate의 정상 반환은 보통 허용을 뜻한다.`이야.
-
고친 문장은 `타인은 예외로 중단해야 한다.`로 적으면 돼.
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 accountId | repository를 읽지 않음 | ACCOUNT_NOT_FOUND 없음 | 존재 여부 구분도 사라진다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `비어 있는 Red 소유권 gate`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `audit를 발행하지 않는다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
@Component가 class를 등록하지만 constructor dependency를 저장하지 않는다.
bean 등록은 authorization 구현이 아니다.호출은 일어나도 empty body가 policy decision을 만들지 않는다.
controller 앞단 인증만으로 BOLA를 막을 수 없다.ApplicationEventPublisher가 전달돼도 사용되지 않는다.
거절도 event도 발생하지 않는다.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가 우회할 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
starter는 실패해야 하는 입력이지 안전한 구현이 아니다
이 책임을 맡는 곳: caller와 principal 경계constructor가 repository와 publisher를 저장하지 않는다
이 책임을 맡는 곳: transaction·concurrency 설계missing account와 other-owner를 구분하지 않는다
이 책임을 맡는 곳: 운영 정책·runbookaudit를 발행하지 않는다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: 소유권 검사 method가 비어 있어 모든 actor가 통과하는 intentional Red 출발점을 드러낸다.
2단계 · 코드 조각 재조립
- package
- compact imports
- empty authorization starter
3단계 · 파일 전체 다시 쓰기
3개 물리 줄을 원본 순서로 복원하고 SHA-256 5f7b0539973b4f278502408f2747efa1095d0b1809dd27441f41219a2cc929dd와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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){}}
02SecurityConfiguration.java — local·cloud permitAll Red starter
learning_stages/w20/production/starter/src/main/java/com/example/financialcore/security/SecurityConfiguration.java
원문 정본 · intentional Red starter · 정본 · W20-F021줄 연결3줄 번역3 chunks
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-F02STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.
- local anyRequest=permitAll은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `starter는 정답이 아니다`과 어떻게 연결되는가?
local anyRequest=permitAllcloud anyRequest=permitAllcloud Basic=enabledunsafe POST writesSTEP 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를 대신 증명하지 않는다.
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를 받아들인다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? local anyRequest=permitAll부터 보면 될까?
-
local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.
-
source에서 관찰할 첫 값은 `local anyRequest=permitAll`이네.
-
그리고 `starter는 정답이 아니다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `cloud anyRequest=permitAll`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. profile에 맞는 chain bean이 선택된다. → 모든 URL이 permitAll에 걸린다.
-
다음 단계는 CSRF 검사가 꺼진다. → cloud에서도 Basic filter가 켜진다.
-
최종값 `cloud anyRequest=permitAll`과 미보장 `두 profile 모두 API를 공개한다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 1줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 3줄F02-L03 | @Configuration public class SecurityConfiguration{ |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 두 teaching user를 만들고 local/cloud 모두 permitAll·CSRF disable·Basic enable로 여는 intentional Red 설정 전체다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `unsafe POST writes`을 source 줄과 test card로 대조하면 된다.
-
`cloud가 local teaching user를 받아들인다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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();}}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 3줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 2개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 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; | 이 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 설정 전체다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다. 다만 starter는 정답이 아니다
문법 해부
- 두 @Profile chain이 모두 `anyRequest().permitAll()`을 사용한다.
- CSRF disable과 Basic enable이 local/cloud에 똑같이 복사돼 있다.
실행 순서
- profile에 맞는 chain bean이 선택된다.
- 모든 URL이 permitAll에 걸린다.
- CSRF 검사가 꺼진다.
- 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
anonymous local request도 controller에 도달한다.
-
counterexample의 이유는 `permitAll은 인증 없이도 authorization을 통과시킨다.`이야.
-
고친 문장은 `/api/**에 authenticated를 명시한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F02-T01 local anonymous | GET /api/accounts | permitAll | controller 진입 | 원래 401이어야 한다 |
| F02-T02 cloud Basic POST | customer-1 + /api/accounts | permitAll + CSRF off | 계좌 생성 가능 | cloud는 403/write0이어야 한다 |
| F02-T03 unknown path | /admin | anyRequest permitAll | 공개 | deny-by-default가 없다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `permitAll Red profile 설정`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `CSRF와 인증·인가를 같은 것으로 볼 수 없다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
default/local/test와 cloud/prod가 서로 다른 bean을 고른다.
profile 분리만으로 안전한 rule이 생기지 않는다.authorize rule이 모든 request를 허용한다.
Basic 성공 여부와 무관하게 permitAll이 접근을 연다.csrf.disable이 browser-origin write 방어를 제거한다.
CSRF는 object authorization을 대체하지 않는다.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 자체를 끈다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
starter는 정답이 아니다
이 책임을 맡는 곳: caller와 principal 경계두 profile 모두 API를 공개한다
이 책임을 맡는 곳: transaction·concurrency 설계cloud가 local teaching user를 받아들인다
이 책임을 맡는 곳: 운영 정책·runbookCSRF와 인증·인가를 같은 것으로 볼 수 없다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: local과 cloud가 모두 permitAll·CSRF disable·Basic enable인 intentional Red security 설정을 보여준다.
2단계 · 코드 조각 재조립
- package
- compact security imports
- permit-all local and cloud starter
3단계 · 파일 전체 다시 쓰기
3개 물리 줄을 원본 순서로 복원하고 SHA-256 b191483d1a7187ced1f3451c64ba15feeaf2cec61b312ec0e04a37ca0fb496a3와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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();}}
03CloudFailClosedSecurityIT.java — health 공개·API write 0 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/CloudFailClosedSecurityIT.java
원문 정본 · 제공 test 계약 · 정본 · W20-F0324줄 연결40줄 번역3 chunks
CloudFailClosedSecurityIT.java — health 공개·API write 0 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/CloudFailClosedSecurityIT.java
원문 정본 · 제공 test 계약 · 정본 · W20-F03STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.
- health=200은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `한 unsafe endpoint만 검사한다`과 어떻게 연결되는가?
health=200POST /api/accounts=403WWW-Authenticate absentaccount count=0STEP 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를 대신 증명하지 않는다.
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에 운영 인증을 제공한다는 뜻이 아니다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? health=200부터 보면 될까?
-
cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.
-
source에서 관찰할 첫 값은 `health=200`이네.
-
그리고 `한 unsafe endpoint만 검사한다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `POST /api/accounts=403`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. DB를 비운다. → health GET 200을 확인한다.
-
다음 단계는 Basic account POST를 보낸다. → 403·challenge 없음·account count 0을 확인한다.
-
최종값 `POST /api/accounts=403`과 미보장 `CORS 전체 정책은 검사하지 않는다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 24줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 20줄F03-L20 | @SpringBootTest |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 실제 Spring application context를 띄우는 통합 test임을 선언한다.
|
| 21줄F03-L21 | @AutoConfigureMockMvc |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
|
| 22줄F03-L22 | @ActiveProfiles( |
공연장 모드별로 다른 출입문 규칙표 | cloud profile 설정만 활성화한다.
|
| 23줄F03-L23 | class CloudFailClosedSecurityIT extends PostgresIntegrationTestSupport { |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | cloud fail-closed HTTP/DB 계약을 담는 PostgreSQL 통합 test class를 연다.
|
| 24줄F03-L24 | @Autowired MockMvc mvc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
|
| 25줄F03-L25 | @Autowired JdbcClient jdbc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
|
| 27줄F03-L27 | @BeforeEach |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test 전에 바로 아래 clean fixture를 실행한다.
|
| 28줄F03-L28 | void clean( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
|
| 29줄F03-L29 | jdbc. |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 관련 business/audit table을 FK 순서와 identity까지 비운다.
|
| 30줄F03-L30 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞의 TRUNCATE SQL을 실제로 실행한다.
|
| 31줄F03-L31 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 33줄F03-L33 | @Test |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 34줄F03-L34 | void healthIsPublicButBasicCannotOpenOrMutateTheCloudApi( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | health 공개와 cloud unsafe write 차단을 함께 검증할 test method를 연다.
|
| 35줄F03-L35 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | cloud health endpoint에 GET 요청을 만든다.
|
| 36줄F03-L36 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 현재 HTTP 결과가 200 OK인지 assertion한다.
|
| 37줄F03-L37 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | account 생성 API에 POST 요청을 만든다.
|
| 38줄F03-L38 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 39줄F03-L39 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청 body가 JSON임을 content type으로 선언한다.
|
| 40줄F03-L40 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고정 JSON input을 account 생성 요청 body에 넣는다.
|
| 41줄F03-L41 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
|
| 42줄F03-L42 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | cloud response가 Basic 재인증 challenge를 보내지 않는지 assertion한다.
|
| 43줄F03-L43 | assertThat( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 거절 뒤 account table row 수가 0인지 조회하고 assertion한다.
|
| 44줄F03-L44 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 45줄F03-L45 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `account count=0`을 source 줄과 test card로 대조하면 된다.
-
`cloud에 운영 인증을 제공한다는 뜻이 아니다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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();
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 40줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 16개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 4 | import org.junit.jupiter.api.BeforeEach; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 5 | import org.junit.jupiter.api.Test; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 6 | import org.springframework.beans.factory.annotation.Autowired; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 7 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 8 | import org.springframework.boot.test.context.SpringBootTest; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 9 | import org.springframework.jdbc.core.simple.JdbcClient; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 10 | import org.springframework.test.context.ActiveProfiles; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 11 | import org.springframework.test.web.servlet.MockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 13 | import static org.assertj.core.api.Assertions.assertThat; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 14 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 15 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 17 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 20 | @SpringBootTest | 실제 Spring application context를 띄우는 통합 test임을 선언한다. |
| 21 | @AutoConfigureMockMvc | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다. |
| 22 | @ActiveProfiles("cloud") | cloud profile 설정만 활성화한다. |
| 23 | class 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 범위를 닫는다. |
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를 확인한다.
실행 순서
- DB를 비운다.
- health GET 200을 확인한다.
- Basic account POST를 보낸다.
- 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
403 응답인데 account가 1이면 실패다.
-
counterexample의 이유는 `controller/service가 먼저 실행되는 잘못된 chain이면 side effect가 남을 수 있다.`이야.
-
고친 문장은 `DB count를 별도로 assert한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F03-T01 health | GET /actuator/health | cloud public matcher | 200 | health 정보 범위는 별도다 |
| F03-T02 unsafe write | Basic POST openingBalance=1000 | cloud denyAll | 403 | WWW-Authenticate도 없어야 한다 |
| F03-T03 DB effect | account table | COUNT(*) | 0 | audit는 이 test가 보지 않는다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `cloud fail-closed 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `audit row는 assert하지 않는다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
cloud SecurityFilterChain bean이 context에 올라간다.
mixed profile 중복 bean은 검사하지 않는다.실제 servlet security filter 흐름을 거쳐 status/header를 관찰한다.
모든 endpoint를 순회하지 않는다.거절 뒤 account table의 최종 cardinality를 읽는다.
다른 table invariant는 직접 assert하지 않는다.이 파일의 @Test가 실제로 고정하는 범위
healthIsPublicButBasicCannotOpenOrMutateTheCloudApi
- cloud profile과 빈 account table을 준비한다.
- Basic customer-1 credential과 account 생성 JSON을 준비한다.
- health GET 뒤 account POST를 MockMvc로 호출한다.
- 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에서 깨진다.
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다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
한 unsafe endpoint만 검사한다
이 책임을 맡는 곳: caller와 principal 경계CORS 전체 정책은 검사하지 않는다
이 책임을 맡는 곳: transaction·concurrency 설계cloud에 운영 인증을 제공한다는 뜻이 아니다
이 책임을 맡는 곳: 운영 정책·runbookaudit row는 assert하지 않는다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: cloud health만 공개하고 Basic API 쓰기는 403·challenge 없음·DB write 0이어야 함을 고정하는 제공 통합 test다.
2단계 · 코드 조각 재조립
- imports and static test tools
- cloud profile fixture and cleanup
- public health and fail-closed write proof
3단계 · 파일 전체 다시 쓰기
45개 물리 줄을 원본 순서로 복원하고 SHA-256 bcaac7140df52f009e0de0ef5592bdcfe4d9a3e4615a3cc010a1552ed2a26a56와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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();
}
}
04ObjectAuthorizationIT.java — BOLA read·transfer no-effect 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/ObjectAuthorizationIT.java
원문 정본 · 제공 test 계약 · 정본 · W20-F0443줄 연결61줄 번역4 chunks
ObjectAuthorizationIT.java — BOLA read·transfer no-effect 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/ObjectAuthorizationIT.java
원문 정본 · 제공 test 계약 · 정본 · W20-F04STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.
- owner GET=200은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `receiver ownership은 별도 규칙이다`과 어떻게 연결되는가?
owner GET=200other GET=403 ACCESS_DENIEDother transfer=403balance=10000TRANSFER count=0STEP 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를 대신 증명하지 않는다.
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하지 않는다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? owner GET=200부터 보면 될까?
-
본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.
-
source에서 관찰할 첫 값은 `owner GET=200`이네.
-
그리고 `receiver ownership은 별도 규칙이다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `other GET=403 ACCESS_DENIED`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. customer-1 owned=10000, customer-2 receiver=5000을 만든다. → owner read 200과 other read 403을 비교한다.
-
다음 단계는 other가 owned를 from으로 transfer한다. → 403 뒤 balance 10000·TRANSFER count 0을 확인한다.
-
최종값 `other GET=403 ACCESS_DENIED`과 미보장 `모든 denial invariant를 검사하지 않는다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 43줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 22줄F04-L22 | @SpringBootTest |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 실제 Spring application context를 띄우는 통합 test임을 선언한다.
|
| 23줄F04-L23 | @AutoConfigureMockMvc |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
|
| 24줄F04-L24 | @ActiveProfiles( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | test profile 설정을 활성화한다.
|
| 25줄F04-L25 | class ObjectAuthorizationIT extends PostgresIntegrationTestSupport { |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | read와 transfer BOLA 계약을 담는 PostgreSQL 통합 test class를 연다.
|
| 26줄F04-L26 | @Autowired MockMvc mvc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
|
| 27줄F04-L27 | @Autowired JdbcClient jdbc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
|
| 28줄F04-L28 | @Autowired AccountOpeningService openings; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 소유자가 정해진 account fixture를 열 service를 주입한다.
|
| 29줄F04-L29 | Account owned; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-1 소유 account fixture를 test field로 보관한다.
|
| 30줄F04-L30 | Account receiver; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | transfer 수취 account fixture를 test field로 보관한다.
|
| 32줄F04-L32 | @BeforeEach |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test 전에 바로 아래 clean fixture를 실행한다.
|
| 33줄F04-L33 | void clean( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
|
| 34줄F04-L34 | jdbc. |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 관련 business/audit table을 FK 순서와 identity까지 비운다.
|
| 35줄F04-L35 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞의 TRUNCATE SQL을 실제로 실행한다.
|
| 36줄F04-L36 | owned = |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-1의 OWNED account를 잔액 10000으로 만든다.
|
| 37줄F04-L37 | receiver = |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-2의 RECEIVER account를 잔액 5000으로 만든다.
|
| 38줄F04-L38 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 40줄F04-L40 | @Test |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 41줄F04-L41 | void ownerCanReadButAnotherAuthenticatedCustomerCannot( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | owner 200과 other-owner 403을 비교할 read test를 연다.
|
| 42줄F04-L42 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | fixture account ID를 path variable로 넣어 조회 요청을 만든다.
|
| 43줄F04-L43 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 44줄F04-L44 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 현재 HTTP 결과가 200 OK인지 assertion한다.
|
| 45줄F04-L45 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | fixture account ID를 path variable로 넣어 조회 요청을 만든다.
|
| 46줄F04-L46 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-2를 붙인다.
|
| 47줄F04-L47 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
|
| 48줄F04-L48 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
|
| 49줄F04-L49 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
|
| 50줄F04-L50 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 52줄F04-L52 | @Test |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 53줄F04-L53 | void anotherCustomerCannotTransferFromTheOwnersAccount( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | other actor의 from-account transfer와 DB no-effect를 검증할 test를 연다.
|
| 54줄F04-L54 | String body = |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | owned/receiver ID와 amount 1000을 포함한 transfer JSON을 조립한다.
|
| 55줄F04-L55 | + |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | transfer JSON에 receiver ID와 amount 1000을 이어 붙인다.
|
| 56줄F04-L56 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | transfer API에 POST 요청을 만든다.
|
| 57줄F04-L57 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-2를 붙인다.
|
| 58줄F04-L58 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 거절 흐름을 추적할 stable X-Request-Id를 요청에 붙인다.
|
| 59줄F04-L59 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청 body가 JSON임을 content type으로 선언한다.
|
| 60줄F04-L60 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
|
| 61줄F04-L61 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
|
| 62줄F04-L62 | assertThat( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | owned account의 현재 balance를 ID로 조회하기 시작한다.
|
| 63줄F04-L63 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | balance query의 named id parameter에 owned account ID를 넣고 10000인지 assertion한다.
|
| 64줄F04-L64 | assertThat( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | denial 뒤 TRANSFER business_tx row가 0인지 조회하기 시작한다.
|
| 65줄F04-L65 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞 SQL의 단일 Long 결과가 0인지 assertion한다.
|
| 66줄F04-L66 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 67줄F04-L67 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `TRANSFER count=0`을 source 줄과 test card로 대조하면 된다.
-
`audit persistence를 직접 assert하지 않는다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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();
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 61줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 18개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 4 | import com.example.financialcore.account.Account; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 5 | import com.example.financialcore.account.AccountOpeningService; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 6 | import org.junit.jupiter.api.BeforeEach; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 7 | import org.junit.jupiter.api.Test; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 8 | import org.springframework.beans.factory.annotation.Autowired; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 9 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 10 | import org.springframework.boot.test.context.SpringBootTest; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 11 | import org.springframework.jdbc.core.simple.JdbcClient; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 12 | import org.springframework.test.context.ActiveProfiles; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 13 | import org.springframework.test.web.servlet.MockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 15 | import static org.assertj.core.api.Assertions.assertThat; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 16 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 22 | @SpringBootTest | 실제 Spring application context를 띄우는 통합 test임을 선언한다. |
| 23 | @AutoConfigureMockMvc | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다. |
| 24 | @ActiveProfiles("test") | test profile 설정을 활성화한다. |
| 25 | class 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 범위를 닫는다. |
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를 함께 고정한다.
실행 순서
- customer-1 owned=10000, customer-2 receiver=5000을 만든다.
- owner read 200과 other read 403을 비교한다.
- other가 owned를 from으로 transfer한다.
- 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
customer-2 GET은 403이다.
-
counterexample의 이유는 `로그인은 신원, owner check는 resource 권한이다.`이야.
-
고친 문장은 `actor와 account owner를 비교한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F04-T01 owner read | customer-1 / owned | GET | 200 | response payload 상세는 고정하지 않는다 |
| F04-T02 other read | customer-2 / owned | GET bola-read | 403 ACCESS_DENIED | audit row는 assert하지 않는다 |
| F04-T03 other transfer | from owned to receiver amount1000 | POST bola-transfer | 403 | from balance=10000, tx count=0 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `객체 소유권 BOLA 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `controller만의 check로 충분하다는 뜻이 아니다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
httpBasic username이 authenticated actor identity가 된다.
request body의 actor를 신뢰하면 안 된다.from account owner check가 mutation 전에 예외를 던진다.
controller-only gate는 다른 호출 경로를 막지 못한다.denial 뒤 source balance와 TRANSFER row cardinality가 유지된다.
receiver balance·ledger·idempotency 전체는 직접 보지 않는다.이 파일의 @Test가 실제로 고정하는 범위
ownerCanReadButAnotherAuthenticatedCustomerCannot
- customer-1 owned account와 customer-2 credential을 준비한다.
- owner와 other가 같은 account를 GET한다.
- owner는 200이다.
- other는 403과 ACCESS_DENIED다.
- read object ownership 분리
- audit persistence
- 없는 account 처리
- operator policy
첫 실패 경계 빈 AccountAuthorization은 other 요청도 정상 진행시켜 403 assertion에서 깨진다.
anotherCustomerCannotTransferFromTheOwnersAccount
- owned=10000, receiver=5000과 customer-2 credential을 준비한다.
- other가 owned를 from으로 1000 transfer한다.
- 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에서 깨진다.
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를 겨냥할 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
receiver ownership은 별도 규칙이다
이 책임을 맡는 곳: caller와 principal 경계모든 denial invariant를 검사하지 않는다
이 책임을 맡는 곳: transaction·concurrency 설계audit persistence를 직접 assert하지 않는다
이 책임을 맡는 곳: 운영 정책·runbookcontroller만의 check로 충분하다는 뜻이 아니다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: 본인 read 200, 타인 read/transfer 403와 denied transfer의 balance·transaction write 0을 고정하는 BOLA 통합 test다.
2단계 · 코드 조각 재조립
- imports and assertions
- test profile fixture and two accounts
- owner versus other read contract
- denied transfer and no-effect contract
3단계 · 파일 전체 다시 쓰기
67개 물리 줄을 원본 순서로 복원하고 SHA-256 e43c8e248c2f12302c6021849a8189e47ed647016d893d5f5101dac62a676137와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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();
}
}
05SecurityStatusContractTest.java — 401·403·400·200 구분 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/SecurityStatusContractTest.java
원문 정본 · 제공 test 계약 · 정본 · W20-F0533줄 연결50줄 번역3 chunks
SecurityStatusContractTest.java — 401·403·400·200 구분 계약
learning_stages/w20/production/tests/src/test/java/com/example/financialcore/security/SecurityStatusContractTest.java
원문 정본 · 제공 test 계약 · 정본 · W20-F05STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.
- anonymous=401은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `상태 구분만으로 body schema 전체가 고정되지는 않는다`과 어떻게 연결되는가?
anonymous=401other owner=403 ACCESS_DENIEDinvalid opening=400 INVALID_REQUESTowner=200STEP 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를 대신 증명하지 않는다.
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 조합은 없다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? anonymous=401부터 보면 될까?
-
anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.
-
source에서 관찰할 첫 값은 `anonymous=401`이네.
-
그리고 `상태 구분만으로 body schema 전체가 고정되지는 않는다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `other owner=403 ACCESS_DENIED`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. customer-1 account를 1000으로 연다. → anonymous GET은 401이다.
-
다음 단계는 customer-2 GET은 403이다. → owner invalid POST는 400, owner GET은 200이다.
-
최종값 `other owner=403 ACCESS_DENIED`과 미보장 `401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 33줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 21줄F05-L21 | @SpringBootTest |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 실제 Spring application context를 띄우는 통합 test임을 선언한다.
|
| 22줄F05-L22 | @AutoConfigureMockMvc |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다.
|
| 23줄F05-L23 | @ActiveProfiles( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | test profile 설정을 활성화한다.
|
| 24줄F05-L24 | class SecurityStatusContractTest extends PostgresIntegrationTestSupport { |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 401·403·400·200 matrix를 담는 PostgreSQL 통합 test class를 연다.
|
| 25줄F05-L25 | @Autowired MockMvc mvc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | HTTP 요청과 security filter 흐름을 실행할 MockMvc를 주입한다.
|
| 26줄F05-L26 | @Autowired JdbcClient jdbc; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | DB cleanup과 no-effect oracle을 읽을 JdbcClient를 주입한다.
|
| 27줄F05-L27 | @Autowired AccountOpeningService openings; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 소유자가 정해진 account fixture를 열 service를 주입한다.
|
| 28줄F05-L28 | Account account; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | status matrix가 공유할 customer-1 account를 보관한다.
|
| 30줄F05-L30 | @BeforeEach |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test 전에 바로 아래 clean fixture를 실행한다.
|
| 31줄F05-L31 | void clean( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 각 test가 독립적인 DB 상태에서 시작하도록 fixture method를 연다.
|
| 32줄F05-L32 | jdbc. |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 관련 business/audit table을 FK 순서와 identity까지 비운다.
|
| 33줄F05-L33 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞의 TRUNCATE SQL을 실제로 실행한다.
|
| 34줄F05-L34 | account = |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-1의 STATUS account를 잔액 1000으로 만든다.
|
| 35줄F05-L35 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 37줄F05-L37 | @Test |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 38줄F05-L38 | void anonymous401OtherOwner403Invalid400AndOwner200AreDistinct( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | anonymous/other/invalid/owner 네 status를 한 흐름에서 비교할 test를 연다.
|
| 39줄F05-L39 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | fixture account ID를 path variable로 넣어 조회 요청을 만든다.
|
| 40줄F05-L40 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | anonymous 결과가 401 Unauthorized인지 assertion한다.
|
| 41줄F05-L41 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | fixture account ID를 path variable로 넣어 조회 요청을 만든다.
|
| 42줄F05-L42 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-2를 붙인다.
|
| 43줄F05-L43 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 권한/fail-closed 결과가 403 Forbidden인지 assertion한다.
|
| 44줄F05-L44 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | JSON errorCode가 정확히 ACCESS_DENIED인지 assertion한다.
|
| 45줄F05-L45 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | account 생성 API에 POST 요청을 만든다.
|
| 46줄F05-L46 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 47줄F05-L47 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청 body가 JSON임을 content type으로 선언한다.
|
| 48줄F05-L48 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고정 JSON input을 account 생성 요청 body에 넣는다.
|
| 49줄F05-L49 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 유효하지 않은 business input이 400 Bad Request인지 assertion한다.
|
| 50줄F05-L50 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | JSON errorCode가 정확히 INVALID_REQUEST인지 assertion한다.
|
| 51줄F05-L51 | mvc. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | fixture account ID를 path variable로 넣어 조회 요청을 만든다.
|
| 52줄F05-L52 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 53줄F05-L53 | . |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | 현재 HTTP 결과가 200 OK인지 assertion한다.
|
| 54줄F05-L54 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 55줄F05-L55 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `owner=200`을 source 줄과 test card로 대조하면 된다.
-
`operator 조합은 없다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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());
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 50줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 17개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 4 | import com.example.financialcore.account.Account; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 5 | import com.example.financialcore.account.AccountOpeningService; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 6 | import org.junit.jupiter.api.BeforeEach; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 7 | import org.junit.jupiter.api.Test; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 8 | import org.springframework.beans.factory.annotation.Autowired; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 9 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 10 | import org.springframework.boot.test.context.SpringBootTest; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 11 | import org.springframework.jdbc.core.simple.JdbcClient; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 12 | import org.springframework.test.context.ActiveProfiles; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 13 | import org.springframework.test.web.servlet.MockMvc; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 15 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 21 | @SpringBootTest | 실제 Spring application context를 띄우는 통합 test임을 선언한다. |
| 22 | @AutoConfigureMockMvc | servlet filter와 controller 흐름을 MockMvc로 검증할 환경을 만든다. |
| 23 | @ActiveProfiles("test") | test profile 설정을 활성화한다. |
| 24 | class 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 범위를 닫는다. |
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을 분리한다.
실행 순서
- customer-1 account를 1000으로 연다.
- anonymous GET은 401이다.
- customer-2 GET은 403이다.
- 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
anonymous와 customer-2 결과가 다르다.
-
counterexample의 이유는 `401은 신원 없음, 403은 신원은 있으나 권한 없음이다.`이야.
-
고친 문장은 `단계별 status를 분리한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F05-T01 anonymous | no credential | GET owned | 401 | 누구인지 모르는 단계 |
| F05-T02 other owner | customer-2 | GET customer-1 account | 403 ACCESS_DENIED | 인증은 됐지만 권한 없음 |
| F05-T03 invalid body | customer-1 openingBalance=0 | POST /api/accounts | 400 INVALID_REQUEST | 권한 이후 business validation 실패 |
| F05-T04 owner | customer-1 | GET own account | 200 | 성공 payload 전체는 범위 밖 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `HTTP status matrix 통합 test`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `CORS preflight는 검사하지 않는다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
anonymous API 요청을 controller 전에 401로 종료한다.
errorCode choice는 이 학습 계약의 convention이다.authenticated other actor를 owner gate가 403으로 바꾼다.
404 은닉 정책은 사용하지 않는다.인증된 owner의 openingBalance=0을 domain validation이 400으로 거절한다.
400을 권한 실패로 섞으면 안 된다.이 파일의 @Test가 실제로 고정하는 범위
anonymous401OtherOwner403Invalid400AndOwner200AreDistinct
- customer-1 owned account와 customer-1/2 credential을 준비한다.
- anonymous GET, other GET, owner invalid POST, owner GET을 순서대로 호출한다.
- 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에서 깨진다.
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에 없다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
상태 구분만으로 body schema 전체가 고정되지는 않는다
이 책임을 맡는 곳: caller와 principal 경계401에서 INVALID_REQUEST를 쓰는 것은 이 학습 계약의 선택이다
이 책임을 맡는 곳: transaction·concurrency 설계operator 조합은 없다
이 책임을 맡는 곳: 운영 정책·runbookCORS preflight는 검사하지 않는다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: anonymous 401·authenticated other-owner 403·invalid request 400·owner 200을 한 matrix로 분리하는 제공 test다.
2단계 · 코드 조각 재조립
- imports and HTTP matchers
- test profile fixture
- 401 403 400 200 matrix
3단계 · 파일 전체 다시 쓰기
55개 물리 줄을 원본 순서로 복원하고 SHA-256 032fc2a77a1fd7d08e06f1f2b67e946ddd82ff966bd116fac4fa0d9844c67688와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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());
}
}
06AccountAuthorization.java — DB owner 비교·거절 event Green 해법
learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/AccountAuthorization.java
원문 정본 · Green learner 해법 · 정본 · W20-F0621줄 연결29줄 번역4 chunks
AccountAuthorization.java — DB owner 비교·거절 event Green 해법
learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/AccountAuthorization.java
원문 정본 · Green learner 해법 · 정본 · W20-F06STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.
- missing -> ACCOUNT_NOT_FOUND은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `event 발행이 durable audit 저장을 자동 증명하지 않는다`과 어떻게 연결되는가?
missing -> ACCOUNT_NOT_FOUNDowner match -> returnother owner -> publish eventthen ACCESS_DENIEDSTEP 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를 대신 증명하지 않는다.
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는 별도 문제다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? missing -> ACCOUNT_NOT_FOUND부터 보면 될까?
-
DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.
-
source에서 관찰할 첫 값은 `missing -> ACCOUNT_NOT_FOUND`이네.
-
그리고 `event 발행이 durable audit 저장을 자동 증명하지 않는다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `owner match -> return`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. accountId로 ownerId를 읽는다. → 없으면 ACCOUNT_NOT_FOUND를 던진다.
-
다음 단계는 있고 actor와 같으면 정상 반환한다. → 다르면 denied event 발행 뒤 ACCESS_DENIED를 던진다.
-
최종값 `owner match -> return`과 미보장 `actorId는 trusted principal에서 와야 한다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 12줄F06-L12 | @Component |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | Spring component scan이 authorization bean을 등록하게 한다.
|
| 13줄F06-L13 | public class AccountAuthorization { |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | DB owner를 비교할 stateless authorization component class를 연다.
|
| 14줄F06-L14 | private final AccountRepository accounts; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | owner ID를 읽을 repository dependency를 immutable field로 둔다.
|
| 15줄F06-L15 | private final ApplicationEventPublisher events; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | denied event를 발행할 publisher를 immutable field로 둔다.
|
| 17줄F06-L17 | public AccountAuthorization( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | repository와 publisher를 constructor injection으로 받는다.
|
| 18줄F06-L18 | this. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | injected repository를 field에 저장한다.
|
| 19줄F06-L19 | this. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | injected event publisher를 field에 저장한다.
|
| 20줄F06-L20 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 22줄F06-L22 | public void requireOwner( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | actor/account/action/requestId를 받아 owner gate를 실행할 method를 연다.
|
| 23줄F06-L23 | Optional<String> ownerId = |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | accountId의 owner ID를 Optional scalar로 repository에서 읽는다.
|
| 24줄F06-L24 | if ( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | account가 존재하지 않는 branch를 검사한다.
|
| 25줄F06-L25 | throw new BusinessException( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | missing account를 ACCOUNT_NOT_FOUND BusinessException으로 중단한다.
|
| 26줄F06-L26 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 27줄F06-L27 | if ( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | DB owner 문자열이 authenticated actorId와 다른지 검사한다.
|
| 28줄F06-L28 | events. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | mismatch 정보를 담을 DeniedAuditEvent 발행을 시작한다.
|
| 29줄F06-L29 | actorId, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | denied event에 actor/action/resource type/raw account ID를 넣는다.
|
| 30줄F06-L30 | ErrorCode. |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | denied event에 ACCESS_DENIED와 같은 requestId를 마저 넣는다.
|
| 31줄F06-L31 | throw new BusinessException( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | event 발행 뒤 ACCESS_DENIED BusinessException으로 business 흐름을 중단한다.
|
| 32줄F06-L32 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 33줄F06-L33 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 34줄F06-L34 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `then ACCESS_DENIED`을 source 줄과 test card로 대조하면 된다.
-
`check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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");
}
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 29줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 8개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.account.AccountRepository; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 4 | import com.example.financialcore.api.BusinessException; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 5 | import com.example.financialcore.api.ErrorCode; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 6 | import com.example.financialcore.audit.DeniedAuditEvent; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 7 | import org.springframework.context.ApplicationEventPublisher; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 8 | import org.springframework.stereotype.Component; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 10 | import java.util.Optional; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 12 | @Component | Spring component scan이 authorization bean을 등록하게 한다. |
| 13 | public 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 범위를 닫는다. |
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 예외로 흐름을 끊는다.
실행 순서
- accountId로 ownerId를 읽는다.
- 없으면 ACCOUNT_NOT_FOUND를 던진다.
- 있고 actor와 같으면 정상 반환한다.
- 다르면 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
W20 세 selector는 audit table을 assert하지 않는다.
-
counterexample의 이유는 `listener 실패·transaction phase가 저장 결과를 좌우한다.`이야.
-
고친 문장은 `audit persistence를 별도 selector로 검증한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F06-T01 owner | actor=customer-1, owner=customer-1 | equals | return | 명시적 success 값은 없다 |
| F06-T02 other | actor=customer-2, owner=customer-1 | publish DeniedAuditEvent | ACCESS_DENIED | 저장 성공은 listener/transaction 책임 |
| F06-T03 missing | owner Optional.empty | first if | ACCOUNT_NOT_FOUND | denied event를 만들지 않는다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `Green account owner gate`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `to account 규칙은 이 method 책임이 아니다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
findOwnerId는 전체 Account 대신 owner scalar를 Optional로 돌려준다.
read와 이후 mutation의 동시성 일관성은 별도다.ApplicationEventPublisher가 in-process DeniedAuditEvent를 listener에 전달한다.
publish 호출 자체는 durable commit proof가 아니다.BusinessException ErrorCode가 HTTP handler에서 status/body로 변환된다.
이 class는 HTTP를 직접 알지 않는다.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가 다른 예외다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
event 발행이 durable audit 저장을 자동 증명하지 않는다
이 책임을 맡는 곳: caller와 principal 경계actorId는 trusted principal에서 와야 한다
이 책임을 맡는 곳: transaction·concurrency 설계check 뒤 상태가 바뀌는 TOCTOU는 별도 문제다
이 책임을 맡는 곳: 운영 정책·runbookto account 규칙은 이 method 책임이 아니다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: DB owner를 principal actor와 비교하고 missing/other-owner를 나누며 mismatch 때 denied event와 ACCESS_DENIED를 만드는 Green 해법이다.
2단계 · 코드 조각 재조립
- imports
- component fields and constructor
- owner lookup and missing account
- mismatch event and denial
3단계 · 파일 전체 다시 쓰기
34개 물리 줄을 원본 순서로 복원하고 SHA-256 3b1af7a91115ef61c47231596a342d607dbdcf61ae1df176cb110280f4d919f7와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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");
}
}
}
07SecurityConfiguration.java — profile별 인증·fail-closed Green 해법
learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/SecurityConfiguration.java
원문 정본 · Green learner 해법 · 정본 · W20-F0762줄 연결78줄 번역5 chunks
SecurityConfiguration.java — profile별 인증·fail-closed Green 해법
learning_stages/w20/production/solution/src/main/java/com/example/financialcore/security/SecurityConfiguration.java
원문 정본 · Green learner 해법 · 정본 · W20-F07STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.
- local /api/**=authenticated은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `{noop} user는 local/test 학습용이다`과 어떻게 연결되는가?
local /api/**=authenticatedanonymous local=401cloud API=403cloud Basic disabledJSON requestIdSTEP 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를 대신 증명하지 않는다.
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다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? local /api/**=authenticated부터 보면 될까?
-
local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.
-
source에서 관찰할 첫 값은 `local /api/**=authenticated`이네.
-
그리고 `{noop} user는 local/test 학습용이다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `anonymous local=401`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. local/test에는 두 in-memory user가 등록된다. → health 공개, /api 인증, 나머지 denyAll이 적용된다.
-
다음 단계는 cloud/prod는 health 외 denyAll·CSRF default·Basic disabled다. → 실패 handler가 JSON과 requestId를 쓴다.
-
최종값 `anonymous local=401`과 미보장 `API CSRF ignore는 CORS 완성을 뜻하지 않는다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 62줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 20줄F07-L20 | @Configuration |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 아래 @Bean method를 security configuration으로 등록한다.
|
| 21줄F07-L21 | public class SecurityConfiguration { |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | profile별 SecurityFilterChain bean을 구성할 class를 연다.
|
| 22줄F07-L22 | @Bean |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 바로 다음 반환 object를 Spring bean으로 등록한다.
|
| 23줄F07-L23 | @Profile( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | default/local/test에서만 바로 다음 bean을 활성화한다.
|
| 24줄F07-L24 | UserDetailsService learningUsers( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | local/test teaching user 저장소 bean을 만드는 method를 연다.
|
| 25줄F07-L25 | return new InMemoryUserDetailsManager( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 두 in-memory UserDetails를 보관할 manager를 만든다.
|
| 26줄F07-L26 | User. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-1 teaching user를 noop password와 CUSTOMER role로 만든다.
|
| 27줄F07-L27 | User. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer-2 teaching user를 noop password와 CUSTOMER role로 만든다.
|
| 28줄F07-L28 | ); |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 29줄F07-L29 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 31줄F07-L31 | @Bean |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 바로 다음 반환 object를 Spring bean으로 등록한다.
|
| 32줄F07-L32 | @Profile( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | default/local/test에서만 바로 다음 bean을 활성화한다.
|
| 33줄F07-L33 | SecurityFilterChain localTestSecurity( |
문이 제대로 잠겼는지 실제 손잡이를 당겨 보는 검사표 | local/test authorization·CSRF·Basic·error handler chain method를 연다.
|
| 34줄F07-L34 | return http |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | Green local/cloud security chain에서 34번째 문장을 앞뒤 단계와 연결한다.
|
| 35줄F07-L35 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | URL별 authorization matcher 규칙을 구성하기 시작한다.
|
| 36줄F07-L36 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | health 하위 경로만 anonymous에게 공개한다.
|
| 37줄F07-L37 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | local/test API 경로는 authenticated principal을 요구한다.
|
| 38줄F07-L38 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
|
| 39줄F07-L39 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | local/test 학습 API만 CSRF token 검사에서 제외한다.
|
| 40줄F07-L40 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 41줄F07-L41 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | authentication/authorization 실패 response handler 구성을 시작한다.
|
| 42줄F07-L42 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 인증되지 않은 request의 JSON 응답 lambda를 등록한다.
|
| 43줄F07-L43 | write( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | local/test anonymous 실패를 401 INVALID_REQUEST JSON으로 쓰기 시작한다.
|
| 44줄F07-L44 | "authentication required", |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 401 message와 현재 requestId를 JSON writer에 넘긴다.
|
| 45줄F07-L45 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
|
| 46줄F07-L46 | write( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
|
| 47줄F07-L47 | "access denied", |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | local 403 message와 현재 requestId를 JSON writer에 넘긴다.
|
| 48줄F07-L48 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
|
| 49줄F07-L49 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 51줄F07-L51 | @Bean |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 바로 다음 반환 object를 Spring bean으로 등록한다.
|
| 52줄F07-L52 | @Profile( |
공연장 모드별로 다른 출입문 규칙표 | cloud/prod에서만 바로 다음 bean을 활성화한다.
|
| 53줄F07-L53 | SecurityFilterChain cloudProdFailClosed( |
공연장 모드별로 다른 출입문 규칙표 | cloud/prod disabled-API fail-closed chain method를 연다.
|
| 54줄F07-L54 | return http |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | Green local/cloud security chain에서 54번째 문장을 앞뒤 단계와 연결한다.
|
| 55줄F07-L55 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | URL별 authorization matcher 규칙을 구성하기 시작한다.
|
| 56줄F07-L56 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | health 하위 경로만 anonymous에게 공개한다.
|
| 57줄F07-L57 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 앞에서 허용하지 않은 모든 URL을 default-deny로 닫는다.
|
| 58줄F07-L58 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | cloud/prod에서는 CSRF 기본 방어를 유지한다.
|
| 59줄F07-L59 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 요청에 local/test Basic principal customer-1를 붙인다.
|
| 60줄F07-L60 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | authentication/authorization 실패 response handler 구성을 시작한다.
|
| 61줄F07-L61 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 인증되지 않은 request의 JSON 응답 lambda를 등록한다.
|
| 62줄F07-L62 | write( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
|
| 63줄F07-L63 | "cloud API is disabled", |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
|
| 64줄F07-L64 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 인증됐지만 거절된 request의 JSON 응답 lambda를 등록한다.
|
| 65줄F07-L65 | write( |
티켓 이름과 좌석 주인을 대조하는 입장 담당자 | access denied 또는 cloud disabled 실패를 403 ACCESS_DENIED JSON으로 쓰기 시작한다.
|
| 66줄F07-L66 | "cloud API is disabled", |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | cloud 403 message와 현재 requestId를 JSON writer에 넘긴다.
|
| 67줄F07-L67 | . |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 누적한 HttpSecurity 규칙을 immutable SecurityFilterChain으로 만든다.
|
| 68줄F07-L68 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 70줄F07-L70 | private static void write( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 공통 JSON security error response helper signature를 시작한다.
|
| 71줄F07-L71 | HttpServletResponse response, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | status/header/body를 기록할 servlet response parameter를 받는다.
|
| 72줄F07-L72 | int status, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 호출자가 정한 HTTP status 숫자를 받는다.
|
| 73줄F07-L73 | ErrorCode code, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | JSON errorCode로 직렬화할 enum을 받는다.
|
| 74줄F07-L74 | String message, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | JSON message로 쓸 controlled 문자열을 받는다.
|
| 75줄F07-L75 | String requestId |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 같은 요청을 추적할 requestId를 받는다.
|
| 76줄F07-L76 | ) throws java. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 뒤의 설정·실행·검증 문장을 묶을 block 또는 호출 범위를 연다.
|
| 77줄F07-L77 | response. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | HTTP status를 response에 설정한다.
|
| 78줄F07-L78 | response. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 한글 message도 안전하게 쓰도록 UTF-8 encoding을 설정한다.
|
| 79줄F07-L79 | response. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | response media type을 application/json으로 설정한다.
|
| 80줄F07-L80 | response. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | errorCode부터 JSON 문자열을 response body에 쓰기 시작한다.
|
| 81줄F07-L81 | + |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | JSON에 message field를 이어 붙인다.
|
| 82줄F07-L82 | + |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | JSON에 requestId를 붙이고 object를 닫는다.
|
| 83줄F07-L83 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 84줄F07-L84 | } |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `JSON requestId`을 source 줄과 test card로 대조하면 된다.
-
`cloud는 운영 인증이 아니라 disabled API다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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 + "\"}");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 78줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 16개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.api.ErrorCode; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 4 | import com.example.financialcore.api.RequestIdFilter; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 5 | import jakarta.servlet.http.HttpServletResponse; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 6 | import org.springframework.context.annotation.Bean; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 7 | import org.springframework.context.annotation.Configuration; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 8 | import org.springframework.context.annotation.Profile; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 9 | import org.springframework.http.MediaType; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 10 | import org.springframework.security.config.Customizer; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 11 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 12 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 13 | import org.springframework.security.core.userdetails.User; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 14 | import org.springframework.security.core.userdetails.UserDetailsService; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 15 | import org.springframework.security.provisioning.InMemoryUserDetailsManager; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 16 | import org.springframework.security.web.SecurityFilterChain; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 18 | import java.nio.charset.StandardCharsets; | 이 source가 사용할 production/test type과 static matcher를 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 20 | @Configuration | 아래 @Bean method를 security configuration으로 등록한다. |
| 21 | public 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 http | Green local/cloud security chain에서 34번째 문장을 앞뒤 단계와 연결한다. |
| 35 | .authorizeHttpRequests(authorize -> authorize | URL별 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 -> errors | authentication/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 http | Green local/cloud security chain에서 54번째 문장을 앞뒤 단계와 연결한다. |
| 55 | .authorizeHttpRequests(authorize -> authorize | URL별 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 -> errors | authentication/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\":\"" + message | JSON에 message field를 이어 붙인다. |
| 82 | + "\",\"requestId\":\"" + requestId + "\"}"); | JSON에 requestId를 붙이고 object를 닫는다. |
| 83 | } | 현재 Java/SQL block·호출·CTE 범위를 닫는다. |
| 84 | } | 현재 Java/SQL block·호출·CTE 범위를 닫는다. |
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를 한 번에 기록한다.
실행 순서
- local/test에는 두 in-memory user가 등록된다.
- health 공개, /api 인증, 나머지 denyAll이 적용된다.
- cloud/prod는 health 외 denyAll·CSRF default·Basic disabled다.
- 실패 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
customer-2도 authenticated지만 타인 account는 403이다.
-
counterexample의 이유는 `인증은 actor만 확인한다.`이야.
-
고친 문장은 `F06에서 account owner를 비교한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F07-T01 local anonymous API | GET /api/account | authenticated matcher | 401 JSON | ErrorCode INVALID_REQUEST는 학습 convention |
| F07-T02 local other owner | Basic customer-2 | security auth then owner gate | 403 ACCESS_DENIED | 소유권은 F06 책임 |
| F07-T03 cloud Basic API | credential supplied | Basic disabled + denyAll | 403 no challenge | 운영 auth가 아니라 API disabled |
| F07-T04 health | local/cloud GET health | first matcher | permit | 노출 정보량은 별도 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `Green local/cloud security chain`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `문자열 JSON helper는 controlled message를 전제로 한다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
active profile이 하나의 chain과 local user service를 선택한다.
잘못된 복수 profile 조합은 별도 fail-fast가 필요하다.matcher는 위에서 아래로 첫 규칙을 적용하고 마지막 denyAll이 default를 닫는다.
method-level/object authorization을 대체하지 않는다.entry point/denied handler가 같은 JSON helper를 호출한다.
message/requestId가 외부 입력이면 JSON escaping이 추가로 필요하다.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이 깨질 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
{noop} user는 local/test 학습용이다
이 책임을 맡는 곳: caller와 principal 경계API CSRF ignore는 CORS 완성을 뜻하지 않는다
이 책임을 맡는 곳: transaction·concurrency 설계cloud는 운영 인증이 아니라 disabled API다
이 책임을 맡는 곳: 운영 정책·runbook문자열 JSON helper는 controlled message를 전제로 한다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: local/test는 health 공개·API 인증·나머지 거절, cloud/prod는 health 외 전부 거절하는 profile별 Green security chain이다.
2단계 · 코드 조각 재조립
- imports
- local learning users
- local and test security chain
- cloud and prod fail-closed chain
- JSON error writer
3단계 · 파일 전체 다시 쓰기
84개 물리 줄을 원본 순서로 복원하고 SHA-256 48d3aebec4a91cf44a07dde471ce38f1f5cbf8497c9a9be768c109555b559123와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
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 + "\"}");
}
}
08W20-SQL-Q31.sql — 고객별 잔액 RANK/DENSE_RANK 비정본 예시
illustrative/sql/W20-SQL-Q31.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F0841줄 연결41줄 번역4 chunks
W20-SQL-Q31.sql — 고객별 잔액 RANK/DENSE_RANK 비정본 예시
illustrative/sql/W20-SQL-Q31.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F08STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.
- main rows=6은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `ACTIVE/CLOSED 포함은 명시한 가정이다`과 어떻게 연결되는가?
main rows=6customer6 total=1000000 rank=1customer4/5 total=0 rank=5tie probe tail=RANK3/DENSE2STEP 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를 대신 증명하지 않는다.
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가 안정화한다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? main rows=6부터 보면 될까?
-
고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.
-
source에서 관찰할 첫 값은 `main rows=6`이네.
-
그리고 `ACTIVE/CLOSED 포함은 명시한 가정이다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `customer6 total=1000000 rank=1`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. customer 6행을 시작 grain으로 둔다. → account balance를 고객별 SUM하고 없으면 0으로 바꾼다.
-
다음 단계는 내림차순 RANK/DENSE_RANK를 계산한다. → customer_id로 동률 출력 순서를 안정화하고 tie probe를 실행한다.
-
최종값 `customer6 total=1000000 rank=1`과 미보장 `계좌 없는 고객을 0으로 보존한다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 41줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F08-L01 | -- W20-SQL-Q31 illustrative example; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
|
| 2줄F08-L02 | -- Assumption: |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
|
| 3줄F08-L03 | -- Customers without an account remain with total_balance= |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | stable order와 예시의 해석 한계를 주석으로 고정한다.
|
| 4줄F08-L04 | SET search_path TO : |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
|
| 6줄F08-L06 | WITH customer_balance AS ( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객별 balance 한 행을 만들 첫 CTE를 연다.
|
| 7줄F08-L07 | SELECT |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 8줄F08-L08 | c. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
|
| 9줄F08-L09 | c. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
|
| 10줄F08-L10 | COALESCE( |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | account balance 합계가 NULL이면 0으로 바꾼다.
|
| 11줄F08-L11 | FROM customer AS c |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
|
| 12줄F08-L12 | LEFT JOIN account AS a ON a. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer의 모든 account를 붙이고 0건 고객도 남긴다.
|
| 13줄F08-L13 | GROUP BY c. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | customer_id/name grain으로 여러 account row를 축약한다.
|
| 14줄F08-L14 | ), |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 14번째 문장을 앞뒤 단계와 연결한다.
|
| 15줄F08-L15 | ranked AS ( |
같은 점수를 받은 참가자의 등수표 | 집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
|
| 16줄F08-L16 | SELECT |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 17줄F08-L17 | customer_id, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 17번째 문장을 앞뒤 단계와 연결한다.
|
| 18줄F08-L18 | customer_name, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 18번째 문장을 앞뒤 단계와 연결한다.
|
| 19줄F08-L19 | total_balance, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 19번째 문장을 앞뒤 단계와 연결한다.
|
| 20줄F08-L20 | RANK( |
같은 점수를 받은 참가자의 등수표 | 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
|
| 21줄F08-L21 | DENSE_RANK( |
같은 점수를 받은 참가자의 등수표 | 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
|
| 22줄F08-L22 | FROM customer_balance |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 계좌가 없어도 보존할 customer를 기준 relation으로 읽는다.
|
| 23줄F08-L23 | ) |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 24줄F08-L24 | SELECT customer_id, |
같은 점수를 받은 참가자의 등수표 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 25줄F08-L25 | FROM ranked |
같은 점수를 받은 참가자의 등수표 | 직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
|
| 26줄F08-L26 | ORDER BY total_balance DESC, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
|
| 28줄F08-L28 | -- Main oracle: |
같은 점수를 받은 참가자의 등수표 | 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
|
| 29줄F08-L29 | -- Zero-account customer 5 is preserved; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
|
| 31줄F08-L31 | -- Executable tie diagnostic: |
같은 점수를 받은 참가자의 등수표 | RANK와 DENSE_RANK가 갈라지는 tie counterexample을 별도 실행한다고 알린다.
|
| 32줄F08-L32 | WITH tie_probe( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 100·100·50 세 행의 실행 가능한 tie counterexample을 연다.
|
| 33줄F08-L33 | VALUES ( |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 동률 두 행과 낮은 한 행을 diagnostic input으로 만든다.
|
| 34줄F08-L34 | ), |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 고객 grain rank window 예시에서 34번째 문장을 앞뒤 단계와 연결한다.
|
| 35줄F08-L35 | tie_ranked AS ( |
같은 점수를 받은 참가자의 등수표 | 집계 결과에 두 ranking window를 붙일 두 번째 CTE를 연다.
|
| 36줄F08-L36 | SELECT customer_id, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 37줄F08-L37 | RANK( |
같은 점수를 받은 참가자의 등수표 | 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
|
| 38줄F08-L38 | DENSE_RANK( |
같은 점수를 받은 참가자의 등수표 | 잔액 내림차순 RANK를 계산해 tie 뒤 번호를 건너뛴다.
|
| 39줄F08-L39 | FROM tie_probe |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | tie diagnostic 세 행을 window 입력으로 읽는다.
|
| 40줄F08-L40 | ) |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 현재 Java/SQL block·호출·CTE 범위를 닫는다.
|
| 41줄F08-L41 | SELECT customer_id, |
같은 점수를 받은 참가자의 등수표 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 42줄F08-L42 | FROM tie_ranked |
같은 점수를 받은 참가자의 등수표 | 직전 단계 relation을 현재 SELECT의 입력으로 읽는다.
|
| 43줄F08-L43 | ORDER BY total_balance DESC, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
|
| 44줄F08-L44 | -- Tie oracle: |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `tie probe tail=RANK3/DENSE2`을 source 줄과 test card로 대조하면 된다.
-
`동률 내부 출력 순서는 customer_id가 안정화한다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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).
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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와 예시의 해석 한계를 주석으로 고정한다. |
| 4 | SET search_path TO :"workbook_schema", public; | workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다. |
| 6 | WITH 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_balance | account balance 합계가 NULL이면 0으로 바꾼다. |
| 11 | FROM customer AS c | 계좌가 없어도 보존할 customer를 기준 relation으로 읽는다. |
| 12 | LEFT JOIN account AS a ON a.customer_id = c.customer_id | customer의 모든 account를 붙이고 0건 고객도 남긴다. |
| 13 | GROUP BY c.customer_id, c.customer_name | customer_id/name grain으로 여러 account row를 축약한다. |
| 14 | ), | 고객 grain rank window 예시에서 14번째 문장을 앞뒤 단계와 연결한다. |
| 15 | ranked 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 범위를 닫는다. |
| 24 | SELECT customer_id, customer_name, total_balance, balance_rank, dense_balance_rank | 최종 또는 diagnostic 결과에 필요한 column을 projection한다. |
| 25 | FROM ranked | 직전 단계 relation을 현재 SELECT의 입력으로 읽는다. |
| 26 | ORDER 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을 별도 실행한다고 알린다. |
| 32 | WITH 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번째 문장을 앞뒤 단계와 연결한다. |
| 35 | tie_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_probe | tie diagnostic 세 행을 window 입력으로 읽는다. |
| 40 | ) | 현재 Java/SQL block·호출·CTE 범위를 닫는다. |
| 41 | SELECT customer_id, total_balance, balance_rank, dense_balance_rank | 최종 또는 diagnostic 결과에 필요한 column을 projection한다. |
| 42 | FROM tie_ranked | 직전 단계 relation을 현재 SELECT의 입력으로 읽는다. |
| 43 | ORDER 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을 기록한다. |
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로 차이를 보인다.
실행 순서
- customer 6행을 시작 grain으로 둔다.
- account balance를 고객별 SUM하고 없으면 0으로 바꾼다.
- 내림차순 RANK/DENSE_RANK를 계산한다.
- 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
마지막 row가 rank3/dense2다.
-
counterexample의 이유는 `tie 뒤에 더 낮은 row가 있으면 번호가 달라진다.`이야.
-
고친 문장은 `100,100,50 probe로 비교한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F08-T01 customer-1 | accounts101+105 | 10000+100000 | 110000 rank4 | status를 필터하지 않는 가정 |
| F08-T02 customer-5 | no account | LEFT JOIN + COALESCE | 0 rank5 | INNER JOIN이면 사라진다 |
| F08-T03 tie probe | 100,100,50 | two windows | 1/1,1/1,3/2 | main seed 뒤에 lower row가 없어 차이를 따로 만든다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `고객 grain rank window 예시`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `배포된 workbook 정답이 아니다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
여러 account row를 customer_id 한 행으로 축약한다.
window 전에 grain을 고정해야 중복 rank를 피한다.RANK는 tie 수만큼 다음 번호를 건너뛰고 DENSE_RANK는 건너뛰지 않는다.
동률 내부 순서는 rank 값과 별개다.total_balance DESC, customer_id가 표시 순서를 고정한다.
window rank의 tie policy 자체를 바꾸지 않는다.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 정책도 가능한 해석이다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
ACTIVE/CLOSED 포함은 명시한 가정이다
이 책임을 맡는 곳: caller와 principal 경계계좌 없는 고객을 0으로 보존한다
이 책임을 맡는 곳: transaction·concurrency 설계동률 내부 출력 순서는 customer_id가 안정화한다
이 책임을 맡는 곳: 운영 정책·runbook배포된 workbook 정답이 아니다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: 고객별 잔액 합계를 한 행으로 만든 뒤 RANK와 DENSE_RANK의 tie 차이를 함께 보여주는 Q31 비정본 예시다.
2단계 · 코드 조각 재조립
- provenance assumptions and schema
- customer balance aggregation
- ranked main result and oracle
- executable tie diagnostic
3단계 · 파일 전체 다시 쓰기
44개 물리 줄을 원본 순서로 복원하고 SHA-256 92fada53b39a1dc4106181ef47fa9f48274f450323d416abf8ae525616a6c1f3와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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).
09W20-SQL-Q32.sql — 계좌별 원장 누적합 비정본 예시
illustrative/sql/W20-SQL-Q32.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F0922줄 연결22줄 번역4 chunks
W20-SQL-Q32.sql — 계좌별 원장 누적합 비정본 예시
illustrative/sql/W20-SQL-Q32.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W20-F09STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.
- rows=16은 어느 줄에서 생길까?
- 입력→판정→관찰값은 어떤 순서인가?
- starter/test/solution/예시의 provenance는 무엇인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- 첫 counterexample은 `ledger가 없는 account는 나오지 않는다`과 어떻게 연결되는가?
rows=16account101 opening=10000account101 final=8800same-time reversals entry15 then16STEP 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를 대신 증명하지 않는다.
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가 불안정하다
문제의 첫 장면
-
이 파일이 막으려는 첫 실패가 뭐야? rows=16부터 보면 될까?
-
ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.
-
source에서 관찰할 첫 값은 `rows=16`이네.
-
그리고 `ledger가 없는 account는 나오지 않는다`까지 같이 적을게.
입력에서 결과까지
-
입력이 들어온 뒤 어디로 흘러가는지 자꾸 놓쳐… `account101 opening=10000`은 언제 생겨?
-
호출 순서를 한 칸씩 놓으면 돼. ledger_entry 한 행을 그대로 읽는다. → account별 partition으로 나눈다.
-
다음 단계는 occurred_at·entry_id 순서까지 현재 행을 더한다. → 같은 순서로 출력해 running_balance를 대조한다.
-
최종값 `account101 opening=10000`과 미보장 `account.balance와의 reconciliation은 별도다`을 분리하자.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 22줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F09-L01 | -- W20-SQL-Q32 illustrative example; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 이 query가 배포 정답이 아닌 illustrative example임을 선언한다.
|
| 2줄F09-L02 | -- Assumption: |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 문제에서 비워 둔 입력 grain·포함 정책을 명시적 가정으로 고정한다.
|
| 3줄F09-L03 | -- Stable order is occurred_at then entry_id; |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | stable order와 예시의 해석 한계를 주석으로 고정한다.
|
| 4줄F09-L04 | SET search_path TO : |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다.
|
| 6줄F09-L06 | SELECT |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 최종 또는 diagnostic 결과에 필요한 column을 projection한다.
|
| 7줄F09-L07 | account_id, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | ledger grain running-sum window 예시에서 7번째 문장을 앞뒤 단계와 연결한다.
|
| 8줄F09-L08 | entry_id, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | ledger grain running-sum window 예시에서 8번째 문장을 앞뒤 단계와 연결한다.
|
| 9줄F09-L09 | entry_type, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | ledger grain running-sum window 예시에서 9번째 문장을 앞뒤 단계와 연결한다.
|
| 10줄F09-L10 | signed_amount, |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | ledger grain running-sum window 예시에서 10번째 문장을 앞뒤 단계와 연결한다.
|
| 11줄F09-L11 | occurred_at, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | ledger grain running-sum window 예시에서 11번째 문장을 앞뒤 단계와 연결한다.
|
| 12줄F09-L12 | SUM( |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | signed movement의 account별 running sum window를 연다.
|
| 13줄F09-L13 | PARTITION BY account_id |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | account가 바뀔 때 누적 state를 분리한다.
|
| 14줄F09-L14 | ORDER BY occurred_at, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 시간과 entry ID로 누적 순서를 안정화한다.
|
| 15줄F09-L15 | ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | partition 첫 row부터 현재 물리 row까지 frame을 명시한다.
|
| 16줄F09-L16 | ) AS running_balance |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | ledger grain running-sum window 예시에서 16번째 문장을 앞뒤 단계와 연결한다.
|
| 17줄F09-L17 | FROM ledger_entry |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 부호가 이미 정해진 ledger movement row를 입력으로 읽는다.
|
| 18줄F09-L18 | ORDER BY account_id, |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 사람과 QA가 재현할 수 있도록 최종 출력 순서를 안정화한다.
|
| 20줄F09-L20 | -- Oracle account101: |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
|
| 21줄F09-L21 | -- Oracle account102: |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | 동결 seed에서 사람이 실행 결과와 비교할 exact oracle을 기록한다.
|
| 22줄F09-L22 | -- Every OPENING row running_balance equals its signed_amount; |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | stable order와 예시의 해석 한계를 주석으로 고정한다.
|
| 23줄F09-L23 | -- Boundary: |
통장 한 줄을 읽을 때마다 잔액을 다시 적는 장부 | 이 query가 책임지지 않는 결과 집합·reconciliation 경계를 기록한다.
|
| 24줄F09-L24 | -- Same-time account101 reversals stay deterministic because entry_id 15 precedes 16. |
입력·판정·관찰값을 순서대로 놓는 STARRY 체크리스트 | stable order와 예시의 해석 한계를 주석으로 고정한다.
|
test가 말한 만큼만
-
Green이라는 말이 모든 보안을 증명한다는 뜻은 아니지?
-
맞아. assertion이 실제로 읽은 status·body·DB 값까지만 직접 증명해.
-
여기서는 `same-time reversals entry15 then16`을 source 줄과 test card로 대조하면 된다.
-
`occurred_at만으로는 tie가 불안정하다`는 별도 증거가 필요해.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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.
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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와 예시의 해석 한계를 주석으로 고정한다. |
| 4 | SET search_path TO :"workbook_schema", public; | workbook_schema를 우선 조회하도록 PostgreSQL search_path를 고정한다. |
| 6 | SELECT | 최종 또는 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_id | account가 바뀔 때 누적 state를 분리한다. |
| 14 | ORDER BY occurred_at, entry_id | 시간과 entry ID로 누적 순서를 안정화한다. |
| 15 | ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | partition 첫 row부터 현재 물리 row까지 frame을 명시한다. |
| 16 | ) AS running_balance | ledger grain running-sum window 예시에서 16번째 문장을 앞뒤 단계와 연결한다. |
| 17 | FROM ledger_entry | 부호가 이미 정해진 ledger movement row를 입력으로 읽는다. |
| 18 | ORDER 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와 예시의 해석 한계를 주석으로 고정한다. |
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이 누적 범위를 고정한다.
실행 순서
- ledger_entry 한 행을 그대로 읽는다.
- account별 partition으로 나눈다.
- occurred_at·entry_id 순서까지 현재 행을 더한다.
- 같은 순서로 출력해 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로 결과를 넘긴다.
틀린 예 찾기
-
그럼 어떤 구현이 겉보기에는 맞아도 깨질까?
-
16개 entry가 그대로 출력된다.
-
counterexample의 이유는 `GROUP BY는 원장 row를 축약한다.`이야.
-
고친 문장은 `window SUM으로 원본 row를 유지한다.`로 적으면 돼.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| F09-T01 account101 opening | entry1 +10000 | first frame | 10000 | opening entry와 일치 |
| F09-T02 account101 transfer/reversal | -300,-200,-600,-600 | row-by-row SUM | 10200,10000,9400,8800 | entry15/16 tie는 ID로 푼다 |
| F09-T03 account102 | 20000,+300 | same partition | 20000,20300 | counterparty business_tx가 아니라 ledger entry를 센다 |
책임 경계
-
이 파일 하나가 책임지지 않는 마지막 칸도 알려줘.
-
직접 책임은 `ledger grain running-sum window 예시`이고, 나머지는 호출자·transaction·별도 policy로 넘겨야 해.
-
대표 경계는 `배포된 workbook 정답이 아니다`이야.
-
source SHA와 실제 assertion, 그리고 ‘정본/비정본’ label까지 확인하면 마무리야.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
각 account의 window state를 독립적으로 유지한다.
account가 바뀌면 누적값이 reset된다.정렬된 물리 row를 하나씩 포함한다.
RANGE default와 같은 timestamp peer 처리 차이를 피한다.entry_id가 timestamp tie를 결정론적으로 푼다.
occurred_at만 쓰면 같은 시각 두 reversal의 중간값이 불안정하다.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 포함 정책은 다른 설계가 가능하다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
ledger가 없는 account는 나오지 않는다
이 책임을 맡는 곳: caller와 principal 경계account.balance와의 reconciliation은 별도다
이 책임을 맡는 곳: transaction·concurrency 설계occurred_at만으로는 tie가 불안정하다
이 책임을 맡는 곳: 운영 정책·runbook배포된 workbook 정답이 아니다
이 책임을 맡는 곳: 별도 selector/testSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
다음 의미를 고정값과 미보장 경계까지 말한다: ledger_entry signed movement를 account별 안정 순서로 누적하는 SUM OVER 기반 Q32 비정본 예시다.
2단계 · 코드 조각 재조립
- provenance assumptions and schema
- ledger output grain
- stable ROWS running sum
- fixture oracles and boundaries
3단계 · 파일 전체 다시 쓰기
24개 물리 줄을 원본 순서로 복원하고 SHA-256 b91c93d3510b4b938523f63de061849b6e4c71ed18f049fb6050f59c33c4e6c8와 대조한다.
자가 점검
- 정본 source 줄을 바꾸지 않았는가?
- 입력·판정·결과를 서로 다른 문장으로 설명했는가?
- status/body/DB assertion과 추론을 구분했는가?
- Red starter를 Green으로 부르지 않았는가?
- Q31/Q32라면 비정본 가정을 유지했는가?
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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.