W19 · 감사·masking·보안 기초
19주차 코드 뒤풀이: requestId·masking·denied audit를 값으로 따라가기
intentional Red starter 2개, 제공 test 3개, Green learner 해법 2개를 역할별로 분리해 읽습니다. requestId가 request·response·MDC·audit row 사이를 어떻게 이동하는지와 test가 직접 증명하지 않는 경계까지 연결합니다. Q29·Q30은 workbook에 정답이 배포되지 않아 가정을 표시한 비정본 예시로만 제공합니다.
01RequestIdFilter.java — 의도적으로 실패하는 requestId starter
learning_stages/w19/production/starter/src/main/java/com/example/financialcore/api/RequestIdFilter.java
원문 정본 · intentional Red starter · 정본 · W19-F011줄 연결3줄 번역3 chunks
RequestIdFilter.java — 의도적으로 실패하는 requestId starter
learning_stages/w19/production/starter/src/main/java/com/example/financialcore/api/RequestIdFilter.java
원문 정본 · intentional Red starter · 정본 · W19-F01STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.
- HEADER=X-Request-Id은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
HEADER=X-Request-Idraw header reflectedMDC absentcurrent fallback=unavailableSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
RequestIdFilter.java — 의도적으로 실패하는 requestId starter를 작은 검사표로 바꾸기
의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.
핵심값 HEADER=X-Request-Id, raw header reflected, MDC absent, current fallback=unavailable을 source 줄로 따라가되, starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 입구 안내원 비유는 raw header 반사 순서만 돕고 whitelist·UUID·MDC가 없다는 source 사실을 대신하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
package
1~1줄을 한 덩어리로 읽어 의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.의 1번째 움직임을 본다.
- 코드 연결
1~1줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
compact imports
2~2줄을 한 덩어리로 읽어 의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.의 2번째 움직임을 본다.
- 코드 연결
2~2줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- null·공백·개행·64자 경계를 검증하지 않는다
intentionally incomplete filter
3~3줄을 한 덩어리로 읽어 의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.의 3번째 움직임을 본다.
- 코드 연결
3~3줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- MDC lifecycle을 구현하지 않는다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.
-
료
첫 관찰값은 ‘HEADER=X-Request-Id’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 실패를 먼저 드러내는 intentional Red starter야. F06의 Green solution과 역할을 섞으면 안 돼.
-
료
raw header 복사는 관찰되지만 whitelist·UUID fallback·MDC lifecycle은 구현되어 있지 않아.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 1줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 3줄F01-L03 | @Component public final class RequestIdFilter extends OncePerRequestFilter{ |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | intentional Red raw 반사 filter를 선언한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
X-Request-Id 원문이 request attribute와 response header로 그대로 복사되고, 이 filter가 MDC에는 값을 넣지 않는 순서를 따라가.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.api;
import jakarta.servlet.*;import jakarta.servlet.http.*;import java.io.IOException;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;
@Component public final class RequestIdFilter extends OncePerRequestFilter{public static final String HEADER="X-Request-Id";public static final String ATTRIBUTE=RequestIdFilter.class.getName()+".requestId";protected void doFilterInternal(HttpServletRequest r,HttpServletResponse s,FilterChain c)throws ServletException,IOException{String id=r.getHeader(HEADER);r.setAttribute(ATTRIBUTE,id);s.setHeader(HEADER,id);c.doFilter(r,s);}public static String current(HttpServletRequest r){Object v=r.getAttribute(ATTRIBUTE);return v==null?"unavailable":v.toString();}}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 3줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 2개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.api; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 2 | import jakarta.servlet.*;import jakarta.servlet.http.*;import java.io.IOException;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 3 | @Component public final class RequestIdFilter extends OncePerRequestFilter{public static final String HEADER="X-Request-Id";public static final String ATTRIBUTE=RequestIdFilter.class.getName()+".requestId";protected void doFilterInternal(HttpServletRequest r,HttpServletResponse s,FilterChain c)throws ServletException,IOException{String id=r.getHeader(HEADER);r.setAttribute(ATTRIBUTE,id);s.setHeader(HEADER,id);c.doFilter(r,s);}public static String current(HttpServletRequest r){Object v=r.getAttribute(ATTRIBUTE);return v==null?"unavailable":v.toString();}} | intentional Red raw 반사 filter를 선언한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다. 다만 starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
문법 해부
- 한 줄에 여러 Java 문장이 있어도 semicolon·brace 순서와 bytes를 보존한다.
- OncePerRequestFilter는 request당 한 번 실행될 extension point다.
- starter에는 whitelist·UUID·MDC·finally가 의도적으로 없다.
실행 순서
- raw header 읽기
- attribute 복사
- response 반사
- chain 진행
원래 W6 수준의 조각별 정밀 해설
F01-C01 · package
- 문법 해부
- package declaration 한 줄이 class의 fully qualified namespace를 고정한다.
- 실제 값 추적
- compiler는 RequestIdFilter를 com.example.financialcore.api 아래에 둔다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): HEADER=X-Request-Id
- 정상 예
- 다른 source가 같은 package 또는 import로 이 class를 찾는다.
- 틀린 예·반례
- package 이름을 security로 바꾸면 기존 test import와 binary name이 어긋난다. 동결 경계: starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
- 착각 방지
- package는 실행 동작이나 Green 안전성을 만들지 않는다.
- 하지 않는 일
- header validation·MDC·cleanup은 이 줄의 책임이 아니다. 동결 경계: starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
- 다음 연결
- 다음 compact imports가 servlet과 Spring type을 해석하게 한다.
F01-C02 · compact imports
- 문법 해부
- 한 물리 줄의 wildcard servlet import와 Spring filter import를 semicolon 순서로 나눈다.
- 실제 값 추적
- compiler가 FilterChain·HTTP request/response·Component·OncePerRequestFilter 이름을 해석한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): raw header reflected
- 정상 예
- 원본 한 줄을 그대로 두면 starter class line 3이 compile에 필요한 type을 얻는다.
- 틀린 예·반례
- import 한 항목을 빼면 해당 짧은 type 이름에서 compile이 멈춘다. 동결 경계: null·공백·개행·64자 경계를 검증하지 않는다
- 착각 방지
- wildcard import 성공은 requestId 로직의 안전성과 무관하다.
- 하지 않는 일
- 실행 순서·header 값·MDC lifecycle은 아직 정의하지 않는다. 동결 경계: null·공백·개행·64자 경계를 검증하지 않는다
- 다음 연결
- 다음 한 줄 Red class가 이 type들로 raw 반사 흐름을 만든다.
F01-C03 · intentionally incomplete filter
- 문법 해부
- 상수·doFilterInternal·current helper가 한 줄에 압축된 순서를 brace와 semicolon로 복원한다.
- 실제 값 추적
- raw header가 id가 되고 attribute와 response에 복사된 뒤 chain이 실행된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): MDC absent
- 정상 예
- req-w19-1을 넣으면 같은 값이 request·response에 보이지만 MDC는 비어 있다.
- 틀린 예·반례
- 공백·newline header도 그대로 응답에 반사되어 unsafe replacement test가 실패한다. 동결 경계: MDC lifecycle을 구현하지 않는다
- 착각 방지
- 짧은 source를 Green final solution으로 오인하면 whitelist·UUID·finally 누락을 놓친다.
- 하지 않는 일
- MDC 전파·cleanup, null/unsafe fallback, async context를 보장하지 않는다. 동결 경계: MDC lifecycle을 구현하지 않는다
- 다음 연결
- F03 test가 이 Red source의 첫 실패 위치를 관찰한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
공백과 newline이 든 header도 그대로 반사되므로 unsafe replacement test에서 UUID regex가 실패해.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | X-Request-Id=raw 또는 null | getHeader가 외부 값을 검증 없이 읽는다. | local id는 raw 또는 null 그대로다. | starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다 |
| 2 | local id | setAttribute와 setHeader가 같은 값을 복사한다. | request와 response가 검증되지 않은 값을 반사한다. | null·공백·개행·64자 경계를 검증하지 않는다 |
| 3 | filter chain | MDC.put 없이 downstream을 호출한다. | chain 안 log context에는 requestId가 생기지 않는다. | MDC lifecycle을 구현하지 않는다 |
| 4 | attribute가 null인 request | current helper가 attribute를 읽는다. | value==null이면 unavailable을 반환한다. | requestId는 인증 token이 아니다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘current fallback=unavailable’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
압축된 한 줄의 field·method·brace를 원래 Java 실행 순서로 compile한다.
starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다@Component class를 filter bean 후보로 등록한다.
null·공백·개행·64자 경계를 검증하지 않는다한 request에서 doFilterInternal을 호출하지만 외부 header를 검증하지 않는다.
MDC lifecycle을 구현하지 않는다raw 또는 null ID를 attribute와 response header로 그대로 복사한다.
requestId는 인증 token이 아니다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ HEADER 상수가 있으면 외부 requestId는 이미 안전하게 검증된다.
왜 틀리나 상수는 header 이름만 고정하며 값의 문자집합·길이를 검사하지 않는다.
바르게 읽기 외부 값은 whitelist로 검증하고 실패하면 서버가 새 ID를 만들어야 한다.
반례 ‘bad request id with spaces and newline\n’도 starter에서는 그대로 response header로 복사된다.
❌ request와 response에 같은 ID가 보이면 correlation lifecycle이 완성된다.
왜 틀리나 starter는 MDC에 값을 넣거나 제거하지 않는다.
바르게 읽기 동기 log correlation까지 주장하려면 chain 내부 MDC와 종료 후 cleanup을 함께 관찰해야 한다.
반례 req-w19-1을 넣어도 starter의 chain callback에서 MDC.get("requestId")는 이 filter가 설정한 값이 아니다.
❌ header가 없으면 current()가 unavailable을 주므로 안전한 fallback이 된다.
왜 틀리나 current()의 fallback은 조회 helper 결과일 뿐 doFilterInternal이 새 correlation ID를 생성하는 로직이 아니다.
바르게 읽기 미입력 header도 request 처리 전에 서버 생성 ID로 치환해야 일관된 correlation이 생긴다.
반례 starter는 null header를 attribute와 response에 넘기며 UUID를 만들지 않는다.
❌ OncePerRequestFilter를 상속했으므로 이 starter는 production Green이다.
왜 틀리나 base class는 request당 호출 지점만 제공하고 검증·fallback·MDC cleanup을 구현하지 않는다.
바르게 읽기 artifact role과 method body를 함께 읽어 Green 여부를 판단해야 한다.
반례 unsafe replacement test는 상속 구조가 같아도 raw 반사 때문에 UUID regex에서 실패한다.
❌ requestId가 있으면 그 값을 사용자 인증이나 계좌 권한 판단에 써도 된다.
왜 틀리나 requestId는 caller가 보낼 수 있는 correlation label이며 authenticated principal이 아니다.
바르게 읽기 인증·인가는 Spring Security principal과 소유권 정책으로 결정하고 requestId는 추적에만 쓴다.
반례 공격자가 X-Request-Id에 다른 사용자의 식별자처럼 보이는 안전 문자열을 넣어도 권한이 생겨서는 안 된다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
이 책임을 맡는 곳: curriculum/source labelingnull·공백·개행·64자 경계를 검증하지 않는다
이 책임을 맡는 곳: request filter policy and boundary testsMDC lifecycle을 구현하지 않는다
이 책임을 맡는 곳: MDC propagation and cleanup implementationrequestId는 인증 token이 아니다
이 책임을 맡는 곳: Spring Security identity and access policySTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- package
- compact imports
- intentionally incomplete filter
3단계 · 파일 전체 다시 쓰기
3개 물리 줄을 원본 순서로 복원하고 SHA-256 911230c739bb18ae706cae3f154bb74e10602b490ad63114d92eb6ec282522c9와 대조한다.
자가 점검
- intentional Red starter를 Green 해법으로 부르지 않는다.
- raw header 반사와 request attribute·response header 복사를 찾는다.
- 검증·UUID·MDC·finally가 없음을 말한다.
- 560자 한 줄의 bytes와 문장 순서를 바꾸지 않는다.
- requestId를 인증·권한 근거로 쓰지 않는다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.api;
import jakarta.servlet.*;import jakarta.servlet.http.*;import java.io.IOException;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;
@Component public final class RequestIdFilter extends OncePerRequestFilter{public static final String HEADER="X-Request-Id";public static final String ATTRIBUTE=RequestIdFilter.class.getName()+".requestId";protected void doFilterInternal(HttpServletRequest r,HttpServletResponse s,FilterChain c)throws ServletException,IOException{String id=r.getHeader(HEADER);r.setAttribute(ATTRIBUTE,id);s.setHeader(HEADER,id);c.doFilter(r,s);}public static String current(HttpServletRequest r){Object v=r.getAttribute(ATTRIBUTE);return v==null?"unavailable":v.toString();}}
02SensitiveDataMasker.java — 원문을 노출하는 masking starter
learning_stages/w19/production/starter/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java
원문 정본 · intentional Red starter · 정본 · W19-F021줄 연결3줄 번역3 chunks
SensitiveDataMasker.java — 원문을 노출하는 masking starter
learning_stages/w19/production/starter/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java
원문 정본 · intentional Red starter · 정본 · W19-F02STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.
- accountNumber(raw)=raw은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- starter는 정답이 아니다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
accountNumber(raw)=rawresourceId=type:rawcomponent beanSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
SensitiveDataMasker.java — 원문을 노출하는 masking starter를 작은 검사표로 바꾸기
의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.
핵심값 accountNumber(raw)=raw, resourceId=type:raw, component bean을 source 줄로 따라가되, starter는 정답이 아니다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 별표 스티커 비유를 이 starter에 적용하면 안 된다. 현재 source는 실제로 raw를 그대로 반환한다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
package
1~1줄을 한 덩어리로 읽어 의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.의 1번째 움직임을 본다.
- 코드 연결
1~1줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- starter는 정답이 아니다
component import
2~2줄을 한 덩어리로 읽어 의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.의 2번째 움직임을 본다.
- 코드 연결
2~2줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- null·blank·구두점·Unicode를 다루지 않는다
intentionally raw masker
3~3줄을 한 덩어리로 읽어 의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.의 3번째 움직임을 본다.
- 코드 연결
3~3줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- type도 신뢰 입력이면 별도 sanitize가 필요하다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.
-
료
첫 관찰값은 ‘accountNumber(raw)=raw’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 masking 실패를 재현하는 intentional Red starter야. class 이름만 보고 안전한 구현으로 분류하면 안 돼.
-
료
accountNumber는 raw를 그대로 반환하고 resourceId도 type:raw를 조립할 뿐이야.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 1줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 3줄F02-L03 | @Component public final class SensitiveDataMasker{ |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 원문을 그대로 내보내는 intentional Red masker를 선언한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
123-456-7890과 ACCOUNT/123456이 각각 원문과 ACCOUNT:123456으로 노출되는 두 return 경로를 따라가.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.security;
import org.springframework.stereotype.Component;
@Component public final class SensitiveDataMasker{public String accountNumber(String raw){return raw;}public String resourceId(String type,String raw){return type+":"+raw;}}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 3줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 2개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 2 | import org.springframework.stereotype.Component; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 3 | @Component public final class SensitiveDataMasker{public String accountNumber(String raw){return raw;}public String resourceId(String type,String raw){return type+":"+raw;}} | 원문을 그대로 내보내는 intentional Red masker를 선언한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다. 다만 starter는 정답이 아니다
문법 해부
- 두 public method가 각각 raw와 type:raw를 그대로 반환한다.
- @Component는 bean 등록일 뿐 masking 안전성을 보장하지 않는다.
- starter의 한 줄 구현을 final solution과 역할별로 분리한다.
실행 순서
- raw 입력
- 원문 반환
- type:raw 조립
원래 W6 수준의 조각별 정밀 해설
F02-C01 · package
- 문법 해부
- package 선언이 masker의 security namespace를 고정한다.
- 실제 값 추적
- compiler가 SensitiveDataMasker의 binary name을 com.example.financialcore.security 아래에 만든다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): accountNumber(raw)=raw
- 정상 예
- MaskingTest가 같은 package에서 import 없이 class를 찾는다.
- 틀린 예·반례
- package를 api로 바꾸면 test와 solution overlay 경로가 맞지 않는다. 동결 경계: starter는 정답이 아니다
- 착각 방지
- namespace는 masking transformation이 아니다.
- 하지 않는 일
- raw 노출·null 처리·Unicode 정책은 이 줄이 책임지지 않는다. 동결 경계: starter는 정답이 아니다
- 다음 연결
- 다음 Component import가 bean annotation type을 제공한다.
F02-C02 · component import
- 문법 해부
- Component import 한 줄이 annotation의 fully qualified type을 해석한다.
- 실제 값 추적
- compiler가 @Component를 Spring stereotype으로 연결한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): resourceId=type:raw
- 정상 예
- starter masker가 component scan 후보로 등록될 수 있다.
- 틀린 예·반례
- import를 제거하면 @Component symbol을 찾지 못해 compile이 실패한다. 동결 경계: null·blank·구두점·Unicode를 다루지 않는다
- 착각 방지
- bean 등록은 반환 문자열의 안전성을 인증하지 않는다.
- 하지 않는 일
- masking 규칙과 input validation은 아직 없다. 동결 경계: null·blank·구두점·Unicode를 다루지 않는다
- 다음 연결
- 다음 Red class가 실제 raw 반환을 드러낸다.
F02-C03 · intentionally raw masker
- 문법 해부
- accountNumber와 resourceId의 두 return statement를 raw pass-through로 분해한다.
- 실제 값 추적
- accountNumber는 raw, resourceId는 type:raw를 그대로 caller에게 돌린다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): component bean
- 정상 예
- 123-456-7890은 그대로, ACCOUNT/123456은 ACCOUNT:123456으로 나온다.
- 틀린 예·반례
- null 입력은 ***가 아니라 null이고 긴 번호는 한 글자도 가려지지 않는다. 동결 경계: type도 신뢰 입력이면 별도 sanitize가 필요하다
- 착각 방지
- method 이름에 Masker가 있어도 source가 masking한다고 추정하면 안 된다.
- 하지 않는 일
- null·blank·Unicode·최소 별표·type sanitize를 보장하지 않는다. 동결 경계: type도 신뢰 입력이면 별도 sanitize가 필요하다
- 다음 연결
- F05의 exact 문자열 test가 이 Red 동작을 거절한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
123-456-7890을 넣으면 ******7890이 아니라 원문이 나와 첫 exact masking assertion에서 실패해.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 123-456-7890 | accountNumber가 별도 처리 없이 return raw를 실행한다. | 원문 123-456-7890이 그대로 노출된다. | starter는 정답이 아니다 |
| 2 | ACCOUNT, 123456 | resourceId가 type + colon + raw를 조립한다. | ACCOUNT:123456이 masking 없이 나온다. | null·blank·구두점·Unicode를 다루지 않는다 |
| 3 | @Component class | Spring component scan이 bean을 등록한다. | 주입 가능해질 뿐 반환값의 안전성은 바뀌지 않는다. | type도 신뢰 입력이면 별도 sanitize가 필요하다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘component bean’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
두 return statement를 raw pass-through method로 compile한다.
starter는 정답이 아니다masker bean을 등록하지만 반환 정책을 검증하지 않는다.
null·blank·구두점·Unicode를 다루지 않는다nullable raw reference를 변환 없이 caller에게 반환한다.
type도 신뢰 입력이면 별도 sanitize가 필요하다type:raw 문자열을 조립해 민감 식별자를 노출한다.
마스킹은 암호화나 접근통제가 아니다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ SensitiveDataMasker라는 이름과 @Component가 있으면 반환값도 masking되어 있다.
왜 틀리나 class 이름과 bean 등록은 두 return statement의 실제 문자열 변환을 바꾸지 않는다.
바르게 읽기 masking 여부는 accountNumber와 resourceId의 반환식을 직접 확인해야 한다.
반례 starter의 accountNumber("123-456-7890")은 별표 없이 원문을 반환한다.
❌ accountNumber만 raw를 반환하고 resourceId는 안전한 별도 표현을 만든다.
왜 틀리나 resourceId도 type + ':' + raw를 그대로 조립한다.
바르게 읽기 resource ID 역시 raw 부분을 masker 결과로 교체해야 한다.
반례 resourceId("ACCOUNT", "123456")은 starter에서 ACCOUNT:123456으로 노출된다.
❌ null을 그대로 반환해도 민감정보가 없으므로 masking 계약을 만족한다.
왜 틀리나 제공 test는 null의 표시 경계를 ***로 고정한다.
바르게 읽기 null·blank 입력은 정책상 명시된 placeholder로 정규화해야 한다.
반례 starter의 null 결과는 ***가 아니므로 blankAndShortValuesStillHaveAVisibleMaskBoundary의 첫 assertion에서 실패한다.
❌ resourceId가 만든 type prefix도 masker가 자동으로 sanitize한다.
왜 틀리나 starter와 Green solution 모두 type 문자열을 검증 없이 앞에 붙인다.
바르게 읽기 type은 caller 검증 또는 별도 allowlist·log encoding 경계에서 다뤄야 한다.
반례 type에 newline을 넣으면 그 newline이 resourceId prefix에 그대로 남는다.
❌ 표시용 masking은 암호화와 접근통제를 대신할 수 있다.
왜 틀리나 masking은 화면·로그 노출을 줄이는 문자열 표현일 뿐 원본 저장과 권한을 보호하지 않는다.
바르게 읽기 원본 보호는 접근통제·암호화·보관 정책으로 분리해야 한다.
반례 ACCOUNT:****3456을 보여 주더라도 권한 없는 caller가 원본 DB row를 읽을 수 있다면 보안은 깨진다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
starter는 정답이 아니다
이 책임을 맡는 곳: curriculum/source labelingnull·blank·구두점·Unicode를 다루지 않는다
이 책임을 맡는 곳: masker policy and parameterized boundary teststype도 신뢰 입력이면 별도 sanitize가 필요하다
이 책임을 맡는 곳: caller validation or dedicated type sanitizer마스킹은 암호화나 접근통제가 아니다
이 책임을 맡는 곳: privacy architecture and key-management designSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- package
- component import
- intentionally raw masker
3단계 · 파일 전체 다시 쓰기
3개 물리 줄을 원본 순서로 복원하고 SHA-256 0ef3b2adff1042c789f0395aebdefef378c399d25e34b19e7a84d2b213364baa와 대조한다.
자가 점검
- intentional Red starter를 final masking 해법으로 부르지 않는다.
- accountNumber가 raw를 그대로 반환함을 찾는다.
- resourceId가 type:raw를 노출함을 찾는다.
- null·blank·Unicode·짧은 값 경계가 미구현임을 말한다.
- masking과 암호화를 구분한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.security;
import org.springframework.stereotype.Component;
@Component public final class SensitiveDataMasker{public String accountNumber(String raw){return raw;}public String resourceId(String type,String raw){return type+":"+raw;}}
03RequestIdFilterTest.java — 전파·반사 방지·cleanup 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/api/RequestIdFilterTest.java
원문 정본 · 제공 test 계약 · 정본 · W19-F0326줄 연결33줄 번역3 chunks
RequestIdFilterTest.java — 전파·반사 방지·cleanup 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/api/RequestIdFilterTest.java
원문 정본 · 제공 test 계약 · 정본 · W19-F03STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.
- req-w19-1 echoed은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
req-w19-1 echoedMDC inside=req-w19-1MDC after=nullunsafe -> UUID 36STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
RequestIdFilterTest.java — 전파·반사 방지·cleanup 계약를 작은 검사표로 바꾸기
유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.
핵심값 req-w19-1 echoed, MDC inside=req-w19-1, MDC after=null, unsafe -> UUID 36을 source 줄로 따라가되, 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 검수표 비유는 response·attribute·MDC assertion을 기억하게 할 뿐 미작성 예외·async test를 증명하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
imports and fixture
1~13줄을 한 덩어리로 읽어 유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.의 1번째 움직임을 본다.
- 코드 연결
1~13줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
valid request lifecycle test
14~30줄을 한 덩어리로 읽어 유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.의 2번째 움직임을 본다.
- 코드 연결
14~30줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 미입력 header와 1·64자 경계는 없다
unsafe request replacement test
31~41줄을 한 덩어리로 읽어 유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.의 3번째 움직임을 본다.
- 코드 연결
31~41줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- UUID regex는 uniqueness나 entropy를 증명하지 않는다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.
-
료
첫 관찰값은 ‘req-w19-1 echoed’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 두 입력에 대한 제공 test 계약이지 production 구현 전체가 아니야.
-
료
유효 ID의 동기 전파·정상 cleanup과 한 위험 입력의 교체만 직접 검사해. 예외 chain과 async 전파는 미검증이야.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 26줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 12줄F03-L12 | class RequestIdFilterTest { |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 두 requestId 계약을 담는 JUnit unit-test fixture class를 연다.
|
| 13줄F03-L13 | private final RequestIdFilter filter = |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 각 test가 직접 호출할 대상 object를 만든다.
|
| 15줄F03-L15 | @Test |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 16줄F03-L16 | void validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 17줄F03-L17 | var request = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | servlet request를 흉내 내는 빈 mock request를 만든다.
|
| 18줄F03-L18 | var response = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 응답 header를 읽을 mock response를 만든다.
|
| 19줄F03-L19 | request. |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | test 입력 X-Request-Id를 mock request에 넣는다.
|
| 20줄F03-L20 | var seenInside = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | chain 내부 MDC 값을 assertion까지 옮길 상자를 만든다.
|
| 22줄F03-L22 | filter. |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 준비한 request·response·chain을 실제 filter에 통과시킨다.
|
| 23줄F03-L23 | seenInside. |
교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. | chain 안의 MDC requestId를 AtomicReference에 저장한다.
|
| 25줄F03-L25 | assertThat( |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 응답 X-Request-Id가 req-w19-1인지 비교한다.
|
| 26줄F03-L26 | assertThat( |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | request attribute에 저장된 값이 req-w19-1인지 비교한다.
|
| 27줄F03-L27 | assertThat( |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | chain 내부에서 포착한 MDC 값이 req-w19-1인지 비교한다.
|
| 28줄F03-L28 | assertThat( |
교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. | filter 반환 뒤 MDC requestId가 null인지 비교한다.
|
| 29줄F03-L29 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 31줄F03-L31 | @Test |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 32줄F03-L32 | void unsafeRequestIdIsReplacedInsteadOfReflected( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 33줄F03-L33 | var request = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | servlet request를 흉내 내는 빈 mock request를 만든다.
|
| 34줄F03-L34 | var response = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 응답 header를 읽을 mock response를 만든다.
|
| 35줄F03-L35 | request. |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | test 입력 X-Request-Id를 mock request에 넣는다.
|
| 36줄F03-L36 | filter. |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 준비한 request·response·chain을 실제 filter에 통과시킨다.
|
| 37줄F03-L37 | assertThat( |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 위험 입력 처리 뒤 응답 requestId를 AssertJ actual로 잡는다.
|
| 38줄F03-L38 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 대체 ID가 36자 소문자 hex/hyphen UUID 모양인지 검사한다.
|
| 39줄F03-L39 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 대체 ID에 위험 원문 bad request가 남지 않았는지 검사한다.
|
| 40줄F03-L40 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 41줄F03-L41 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
req-w19-1이 response·attribute·chain 내부 MDC에 나타난 뒤 filter 반환 후 null이 되는 네 관찰점을 순서대로 확인해.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.api;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class RequestIdFilterTest {
private final RequestIdFilter filter = new RequestIdFilter();
@Test
void validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc() throws Exception {
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
request.addHeader(RequestIdFilter.HEADER, "req-w19-1");
var seenInside = new AtomicReference<String>();
filter.doFilter(request, response, (req, res) ->
seenInside.set(MDC.get("requestId")));
assertThat(response.getHeader(RequestIdFilter.HEADER)).isEqualTo("req-w19-1");
assertThat(request.getAttribute(RequestIdFilter.ATTRIBUTE)).isEqualTo("req-w19-1");
assertThat(seenInside).hasValue("req-w19-1");
assertThat(MDC.get("requestId")).isNull();
}
@Test
void unsafeRequestIdIsReplacedInsteadOfReflected() throws Exception {
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
request.addHeader(RequestIdFilter.HEADER, "bad request id with spaces and newline\n");
filter.doFilter(request, response, (req, res) -> {});
assertThat(response.getHeader(RequestIdFilter.HEADER))
.matches("[0-9a-f-]{36}")
.doesNotContain("bad request");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 33줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 7개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.api; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import org.junit.jupiter.api.Test; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 4 | import org.slf4j.MDC; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 5 | import org.springframework.mock.web.MockHttpServletRequest; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 6 | import org.springframework.mock.web.MockHttpServletResponse; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 8 | import java.util.concurrent.atomic.AtomicReference; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 10 | import static org.assertj.core.api.Assertions.assertThat; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 12 | class RequestIdFilterTest { | 두 requestId 계약을 담는 JUnit unit-test fixture class를 연다. |
| 13 | private final RequestIdFilter filter = new RequestIdFilter(); | 각 test가 직접 호출할 대상 object를 만든다. |
| 15 | @Test | 바로 다음 method를 JUnit test case로 발견하게 표시한다. |
| 16 | void validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc() throws Exception { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 17 | var request = new MockHttpServletRequest(); | servlet request를 흉내 내는 빈 mock request를 만든다. |
| 18 | var response = new MockHttpServletResponse(); | 응답 header를 읽을 mock response를 만든다. |
| 19 | request.addHeader(RequestIdFilter.HEADER, "req-w19-1"); | test 입력 X-Request-Id를 mock request에 넣는다. |
| 20 | var seenInside = new AtomicReference<String>(); | chain 내부 MDC 값을 assertion까지 옮길 상자를 만든다. |
| 22 | filter.doFilter(request, response, (req, res) -> | 준비한 request·response·chain을 실제 filter에 통과시킨다. |
| 23 | seenInside.set(MDC.get("requestId"))); | chain 안의 MDC requestId를 AtomicReference에 저장한다. |
| 25 | assertThat(response.getHeader(RequestIdFilter.HEADER)).isEqualTo("req-w19-1"); | 응답 X-Request-Id가 req-w19-1인지 비교한다. |
| 26 | assertThat(request.getAttribute(RequestIdFilter.ATTRIBUTE)).isEqualTo("req-w19-1"); | request attribute에 저장된 값이 req-w19-1인지 비교한다. |
| 27 | assertThat(seenInside).hasValue("req-w19-1"); | chain 내부에서 포착한 MDC 값이 req-w19-1인지 비교한다. |
| 28 | assertThat(MDC.get("requestId")).isNull(); | filter 반환 뒤 MDC requestId가 null인지 비교한다. |
| 29 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 31 | @Test | 바로 다음 method를 JUnit test case로 발견하게 표시한다. |
| 32 | void unsafeRequestIdIsReplacedInsteadOfReflected() throws Exception { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 33 | var request = new MockHttpServletRequest(); | servlet request를 흉내 내는 빈 mock request를 만든다. |
| 34 | var response = new MockHttpServletResponse(); | 응답 header를 읽을 mock response를 만든다. |
| 35 | request.addHeader(RequestIdFilter.HEADER, "bad request id with spaces and newline\n"); | test 입력 X-Request-Id를 mock request에 넣는다. |
| 36 | filter.doFilter(request, response, (req, res) -> {}); | 준비한 request·response·chain을 실제 filter에 통과시킨다. |
| 37 | assertThat(response.getHeader(RequestIdFilter.HEADER)) | 위험 입력 처리 뒤 응답 requestId를 AssertJ actual로 잡는다. |
| 38 | .matches("[0-9a-f-]{36}") | 대체 ID가 36자 소문자 hex/hyphen UUID 모양인지 검사한다. |
| 39 | .doesNotContain("bad request"); | 대체 ID에 위험 원문 bad request가 남지 않았는지 검사한다. |
| 40 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 41 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다. 다만 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
문법 해부
- @Test는 두 독립 requestId test를 발견하게 한다.
- MockHttpServletRequest/Response는 servlet 입출력을 메모리에서 관찰한다.
- AssertJ chain은 response·attribute·MDC·UUID 형식의 서로 다른 oracle을 적용한다.
실행 순서
- mock 준비
- filter 호출
- chain 안 MDC 포착
- 전파·cleanup assertion
- 위험 입력 UUID assertion
원래 W6 수준의 조각별 정밀 해설
F03-C01 · imports and fixture
- 문법 해부
- JUnit·MDC·mock servlet·AtomicReference·AssertJ setup과 filter fixture를 분리한다.
- 실제 값 추적
- 두 test가 공유할 새 RequestIdFilter instance와 필요한 test type들이 준비된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): req-w19-1 echoed
- 정상 예
- package/import/fixture bytes가 맞으면 test method body가 mock 요청을 실행할 수 있다.
- 틀린 예·반례
- solution 대신 다른 filter class를 fixture에 넣으면 source-bound contract가 깨진다. 동결 경계: 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
- 착각 방지
- test setup 존재만으로 어떤 assertion도 통과하지 않는다.
- 하지 않는 일
- 실제 requestId 전파와 cleanup은 다음 두 test body가 책임진다. 동결 경계: 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
- 다음 연결
- F03-C02가 유효 ID의 동기 lifecycle을 실행한다.
F03-C02 · valid request lifecycle test
- 문법 해부
- 유효 header 준비, filter callback, response·attribute·MDC 네 oracle을 Arrange-Act-Assert로 나눈다.
- 실제 값 추적
- req-w19-1이 response·attribute·chain 내부 MDC로 이동하고 반환 뒤 null이 되는지 관찰한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): MDC inside=req-w19-1
- 정상 예
- Green filter면 세 내부 값은 req-w19-1이고 마지막 MDC.get은 null이다.
- 틀린 예·반례
- MDC.put 또는 finally remove가 없으면 seenInside 또는 cleanup assertion이 실패한다. 동결 경계: 미입력 header와 1·64자 경계는 없다
- 착각 방지
- 정상 반환 cleanup을 예외 경로 cleanup으로 확대 해석하면 안 된다.
- 하지 않는 일
- chain exception, 기존 MDC 복원, async dispatch를 직접 시험하지 않는다. 동결 경계: 미입력 header와 1·64자 경계는 없다
- 다음 연결
- F03-C03가 위험 외부 값을 UUID로 교체하는 별도 분기를 검사한다.
F03-C03 · unsafe request replacement test
- 문법 해부
- 공백·newline header, 빈 callback, UUID regex, 원문 비포함 oracle을 순서대로 읽는다.
- 실제 값 추적
- unsafe supplied 값이 응답에서 36자 UUID로 바뀌는지 관찰한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): MDC after=null
- 정상 예
- 응답은 [0-9a-f-]{36}과 일치하고 bad request를 포함하지 않는다.
- 틀린 예·반례
- starter처럼 raw header를 setHeader하면 regex에서 즉시 실패한다. 동결 경계: UUID regex는 uniqueness나 entropy를 증명하지 않는다
- 착각 방지
- UUID 모양 통과를 고유성·entropy 보장으로 해석하면 안 된다.
- 하지 않는 일
- header 없음·빈 값·1/64자 경계 전체를 시험하지 않는다. 동결 경계: UUID regex는 uniqueness나 entropy를 증명하지 않는다
- 다음 연결
- F06 solution에서 whitelist와 UUID 선택이 이 test를 만족시키는지 연결한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
정상 반환 때만 MDC를 지우는 잘못된 구현도 첫 test는 통과할 수 있으므로, chain이 예외를 던지는 별도 test가 필요해.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | req-w19-1 header | filter에 mock request·response를 통과시킨다. | response와 request attribute가 req-w19-1을 가진다. | 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다 |
| 2 | chain 실행 중 | AtomicReference가 MDC requestId를 포착한다. | seenInside 값은 req-w19-1이다. | 미입력 header와 1·64자 경계는 없다 |
| 3 | filter 정상 반환 뒤 | finally cleanup 뒤 현재 thread MDC를 다시 읽는다. | MDC.get(requestId)는 null이다. | UUID regex는 uniqueness나 entropy를 증명하지 않는다 |
| 4 | 공백·newline 위험 header | whitelist 불일치 경로를 실행한다. | 응답은 36자 UUID이고 bad request 원문을 포함하지 않는다. | async thread MDC 전파는 범위 밖이다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘unsafe -> UUID 36’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
두 @Test method를 별도 case로 발견한다.
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다header 입력과 response·attribute 관찰 저장소를 메모리에서 제공한다.
미입력 header와 1·64자 경계는 없다chain 안 같은 thread에서 MDC requestId를 AtomicReference에 포착한다.
UUID regex는 uniqueness나 entropy를 증명하지 않는다응답·attribute·MDC·UUID 형식·원문 비포함을 각각 비교한다.
async thread MDC 전파는 범위 밖이다filter 반환 뒤 현재 thread key가 null인지 관찰한다.
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다이 파일의 @Test가 실제로 고정하는 범위
validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc
- Mock request/response와 X-Request-Id=req-w19-1을 준비한다.
- chain 안 MDC 값을 받을 AtomicReference를 만든다.
- RequestIdFilter.doFilter를 한 번 호출한다.
- response header와 request attribute가 req-w19-1이다.
- chain 안 MDC는 req-w19-1이고 반환 뒤 MDC는 null이다.
- 허용 문자열의 request·response·동기 MDC 전파
- 정상 반환 경로의 MDC cleanup
- chain 예외 경로 cleanup
- 기존 MDC 값 복원
- async thread 전파
- requestId의 인증·권한 안전성
첫 실패 경계 starter는 MDC.put을 하지 않으므로 seenInside assertion에서 처음 깨진다.
unsafeRequestIdIsReplacedInsteadOfReflected
- 공백과 newline이 든 위험한 외부 header를 준비한다.
- 빈 filter chain으로 RequestIdFilter를 통과시킨다.
- 응답 ID가 36자 소문자 hex/hyphen UUID 모양이다.
- 응답에 bad request 원문이 없다.
- 현재 위험 입력이 그대로 반사되지 않음
- fallback 문자열의 모양
- UUID 고유성·entropy
- header 없음·빈 문자열·64자 경계 전부
- log injection의 모든 변형
첫 실패 경계 starter는 위험 header를 그대로 반사하므로 UUID regex assertion에서 처음 깨진다.
STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 정상 반환 뒤 MDC가 null이면 chain 예외 경로 cleanup도 증명된다.
왜 틀리나 첫 test의 callback은 예외를 던지지 않아 finally가 필요한 실패 경로를 실행하지 않는다.
바르게 읽기 예외를 던지는 chain을 추가하고 filter 밖 MDC 상태를 별도로 assert해야 한다.
반례 정상 종료에서만 MDC를 지우는 잘못된 구현도 현재 valid-request test는 통과할 수 있다.
❌ [0-9a-f-]{36} regex 통과는 RFC UUID와 고유성을 증명한다.
왜 틀리나 regex는 문자 종류와 길이만 보고 hyphen 위치·version·variant·중복 가능성을 검사하지 않는다.
바르게 읽기 이 test의 claim은 fallback 문자열 모양과 위험 원문 비반사로 제한해야 한다.
반례 hyphen 36개로 된 문자열도 이 regex에는 일치하지만 유효 UUID는 아니다.
❌ doesNotContain("bad request") 하나면 모든 header reflection 공격을 막았다고 볼 수 있다.
왜 틀리나 그 assertion은 특정 substring 하나만 배제하며 다른 위험 문자·인코딩 변형은 다루지 않는다.
바르게 읽기 format allowlist와 원문 비반사를 함께 검증하고 입력군을 확장해야 한다.
반례 tab·CR·Unicode control 문자를 포함한 다른 header는 이 test case에 등장하지 않는다.
❌ filter 반환 뒤 MDC가 null이면 기존 MDC 값이 올바르게 복원된 것이다.
왜 틀리나 null은 key 제거를 뜻하며 호출 전에 있던 값을 되살렸다는 증거가 아니다.
바르게 읽기 preexisting MDC를 보존해야 한다면 before 값을 저장하고 finally에서 복원하는 별도 계약이 필요하다.
반례 호출 전에 requestId=outer를 넣는 fixture가 없으므로 outer가 사라지는 구현도 현재 test는 발견하지 못한다.
❌ 두 @Test가 header 없음·빈 값·1자·64자·65자 경계를 모두 고정한다.
왜 틀리나 실제 입력은 req-w19-1과 공백/newline 문자열 두 개뿐이다.
바르게 읽기 길이와 누락 경계는 parameterized test로 별도 고정해야 한다.
반례 64자 안전 문자열과 65자 문자열 중 어느 쪽이 보존·교체되는지는 현재 test source가 직접 실행하지 않는다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
이 책임을 맡는 곳: RequestIdFilter exception-path test미입력 header와 1·64자 경계는 없다
이 책임을 맡는 곳: requestId boundary parameterized testsUUID regex는 uniqueness나 entropy를 증명하지 않는다
이 책임을 맡는 곳: ID generator uniqueness/entropy testsasync thread MDC 전파는 범위 밖이다
이 책임을 맡는 곳: async dispatch and context-propagation integration testsSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- imports and fixture
- valid request lifecycle test
- unsafe request replacement test
3단계 · 파일 전체 다시 쓰기
41개 물리 줄을 원본 순서로 복원하고 SHA-256 51738a6110b89c5993bf21512a3860c6b3cb2fe6f8405d8972fc40006c254da8와 대조한다.
자가 점검
- 두 @Test method 이름을 source와 exact 대조한다.
- response·attribute·chain 내부 MDC·반환 후 cleanup oracle을 구분한다.
- 위험 입력의 UUID 형식과 원문 비포함 oracle을 구분한다.
- 예외 chain cleanup과 기존 MDC 복원은 미증명임을 말한다.
- package/import는 setup으로 번역한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.api;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class RequestIdFilterTest {
private final RequestIdFilter filter = new RequestIdFilter();
@Test
void validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc() throws Exception {
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
request.addHeader(RequestIdFilter.HEADER, "req-w19-1");
var seenInside = new AtomicReference<String>();
filter.doFilter(request, response, (req, res) ->
seenInside.set(MDC.get("requestId")));
assertThat(response.getHeader(RequestIdFilter.HEADER)).isEqualTo("req-w19-1");
assertThat(request.getAttribute(RequestIdFilter.ATTRIBUTE)).isEqualTo("req-w19-1");
assertThat(seenInside).hasValue("req-w19-1");
assertThat(MDC.get("requestId")).isNull();
}
@Test
void unsafeRequestIdIsReplacedInsteadOfReflected() throws Exception {
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
request.addHeader(RequestIdFilter.HEADER, "bad request id with spaces and newline\n");
filter.doFilter(request, response, (req, res) -> {});
assertThat(response.getHeader(RequestIdFilter.HEADER))
.matches("[0-9a-f-]{36}")
.doesNotContain("bad request");
}
}
04AuditPropagationIT.java — 거절 감사 이벤트 통합 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/audit/AuditPropagationIT.java
원문 정본 · 제공 test 계약 · 정본 · W19-F0430줄 연결46줄 번역4 chunks
AuditPropagationIT.java — 거절 감사 이벤트 통합 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/audit/AuditPropagationIT.java
원문 정본 · 제공 test 계약 · 정본 · W19-F04STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.
- actor=customer-2은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- Testcontainers PostgreSQL과 전체 Spring context가 필요하다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
actor=customer-2status=403events=1result=DENIEDerror=ACCESS_DENIEDSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
AuditPropagationIT.java — 거절 감사 이벤트 통합 계약를 작은 검사표로 바꾸기
타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.
핵심값 actor=customer-2, status=403, events=1, result=DENIED, error=ACCESS_DENIED을 source 줄로 따라가되, Testcontainers PostgreSQL과 전체 Spring context가 필요하다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 장부 비유는 403과 audit 한 행 연결을 돕지만 Spring transaction·DB assertion·보안 운영정책을 대신하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
imports annotations and integration class
1~23줄을 한 덩어리로 읽어 타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.의 1번째 움직임을 본다.
- 코드 연결
1~23줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- Testcontainers PostgreSQL과 전체 Spring context가 필요하다
injected collaborators and deterministic cleanup
24~33줄을 한 덩어리로 읽어 타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.의 2번째 움직임을 본다.
- 코드 연결
24~33줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- clean은 다섯 table을 CASCADE TRUNCATE한다
cross-owner request and forbidden response
34~42줄을 한 덩어리로 읽어 타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.의 3번째 움직임을 본다.
- 코드 연결
34~42줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.
-
료
첫 관찰값은 ‘actor=customer-2’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 Spring·MockMvc·PostgreSQL을 잇는 제공 integration-test 계약이야. runtime 구현 자체와 구분해야 해.
-
료
이 한 case는 403과 같은 requestId의 DENIED 한 행만 증명해. SUCCESS·중복 요청·audit 장애는 다루지 않아.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 30줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 20줄F04-L20 | @SpringBootTest |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 실제 Spring application context를 띄우는 통합 test임을 선언한다.
|
| 21줄F04-L21 | @AutoConfigureMockMvc |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | servlet container 없이 HTTP 흐름을 검증할 MockMvc를 구성한다.
|
| 22줄F04-L22 | @ActiveProfiles( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | test profile의 설정과 datasource를 사용하도록 고정한다.
|
| 23줄F04-L23 | class AuditPropagationIT extends PostgresIntegrationTestSupport { |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | PostgreSQL support를 상속한 denied-audit 통합 test fixture class를 연다.
|
| 24줄F04-L24 | @Autowired MockMvc mvc; |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring context의 integration collaborator를 test field에 주입한다.
|
| 25줄F04-L25 | @Autowired JdbcClient jdbc; |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring context의 integration collaborator를 test field에 주입한다.
|
| 26줄F04-L26 | @Autowired AccountOpeningService openings; |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring context의 integration collaborator를 test field에 주입한다.
|
| 27줄F04-L27 | @Autowired AuditEventRepository audit; |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring context의 integration collaborator를 test field에 주입한다.
|
| 29줄F04-L29 | @BeforeEach |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 각 test 전에 DB 정리 method가 실행되도록 등록한다.
|
| 30줄F04-L30 | void clean( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 31줄F04-L31 | jdbc. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다.
|
| 32줄F04-L32 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다.
|
| 33줄F04-L33 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 35줄F04-L35 | @Test |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 36줄F04-L36 | void deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 37줄F04-L37 | Account account = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | customer-1 소유 계좌와 초기 잔액 5000을 DB에 만든다.
|
| 38줄F04-L38 | mvc. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
|
| 39줄F04-L39 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
|
| 40줄F04-L40 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
|
| 41줄F04-L41 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 다른 소유자의 계좌 요청이 HTTP 403 Forbidden인지 검사한다.
|
| 43줄F04-L43 | var events = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 거절 뒤 같은 requestId로 저장된 audit_event 목록을 읽는다.
|
| 44줄F04-L44 | assertThat( |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | 같은 requestId의 audit_event가 정확히 한 행인지 검사한다.
|
| 45줄F04-L45 | assertThat( |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | audit actor가 요청한 customer-2인지 검사한다.
|
| 46줄F04-L46 | assertThat( |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | audit result가 DENIED인지 검사한다.
|
| 47줄F04-L47 | assertThat( |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | audit error code가 ACCESS_DENIED인지 검사한다.
|
| 48줄F04-L48 | assertThat( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 첫 audit row의 masked resource ID를 AssertJ actual로 잡는다.
|
| 49줄F04-L49 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | masked resource가 ACCOUNT: prefix로 시작하는지 검사한다.
|
| 50줄F04-L50 | . |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | masked resource 전체가 ACCOUNT:raw-account-id와 같지 않은지 검사한다.
|
| 51줄F04-L51 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 52줄F04-L52 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
customer-2의 타인 계좌 GET이 403이 된 뒤, audit-denied-1로 조회한 row의 actor·result·error·mask를 확인해.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.audit;
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.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AuditPropagationIT extends PostgresIntegrationTestSupport {
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired AuditEventRepository audit;
@BeforeEach
void clean() {
jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
.update();
}
@Test
void deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId() throws Exception {
Account account = openings.open("customer-1", "AUDIT-OWNER", 5_000);
mvc.perform(get("/api/accounts/{id}", account.getId())
.with(httpBasic("customer-2", "password"))
.header("X-Request-Id", "audit-denied-1"))
.andExpect(status().isForbidden());
var events = audit.findByRequestIdOrderById("audit-denied-1");
assertThat(events).hasSize(1);
assertThat(events.getFirst().getActorId()).isEqualTo("customer-2");
assertThat(events.getFirst().getResult()).isEqualTo("DENIED");
assertThat(events.getFirst().getErrorCode()).isEqualTo("ACCESS_DENIED");
assertThat(events.getFirst().getMaskedResourceId())
.startsWith("ACCOUNT:")
.isNotEqualTo("ACCOUNT:" + account.getId());
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 46줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 16개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.audit; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 4 | import com.example.financialcore.account.Account; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 5 | import com.example.financialcore.account.AccountOpeningService; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 6 | import org.junit.jupiter.api.BeforeEach; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 7 | import org.junit.jupiter.api.Test; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 8 | import org.springframework.beans.factory.annotation.Autowired; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 9 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 10 | import org.springframework.boot.test.context.SpringBootTest; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 11 | import org.springframework.jdbc.core.simple.JdbcClient; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 12 | import org.springframework.test.context.ActiveProfiles; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 13 | import org.springframework.test.web.servlet.MockMvc; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 15 | import static org.assertj.core.api.Assertions.assertThat; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 16 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 20 | @SpringBootTest | 실제 Spring application context를 띄우는 통합 test임을 선언한다. |
| 21 | @AutoConfigureMockMvc | servlet container 없이 HTTP 흐름을 검증할 MockMvc를 구성한다. |
| 22 | @ActiveProfiles("test") | test profile의 설정과 datasource를 사용하도록 고정한다. |
| 23 | class AuditPropagationIT extends PostgresIntegrationTestSupport { | PostgreSQL support를 상속한 denied-audit 통합 test fixture class를 연다. |
| 24 | @Autowired MockMvc mvc; | Spring context의 integration collaborator를 test field에 주입한다. |
| 25 | @Autowired JdbcClient jdbc; | Spring context의 integration collaborator를 test field에 주입한다. |
| 26 | @Autowired AccountOpeningService openings; | Spring context의 integration collaborator를 test field에 주입한다. |
| 27 | @Autowired AuditEventRepository audit; | Spring context의 integration collaborator를 test field에 주입한다. |
| 29 | @BeforeEach | 각 test 전에 DB 정리 method가 실행되도록 등록한다. |
| 30 | void clean() { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 31 | jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") | 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다. |
| 32 | .update(); | 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다. |
| 33 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 35 | @Test | 바로 다음 method를 JUnit test case로 발견하게 표시한다. |
| 36 | void deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId() throws Exception { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 37 | Account account = openings.open("customer-1", "AUDIT-OWNER", 5_000); | customer-1 소유 계좌와 초기 잔액 5000을 DB에 만든다. |
| 38 | mvc.perform(get("/api/accounts/{id}", account.getId()) | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다. |
| 39 | .with(httpBasic("customer-2", "password")) | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다. |
| 40 | .header("X-Request-Id", "audit-denied-1")) | customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다. |
| 41 | .andExpect(status().isForbidden()); | 다른 소유자의 계좌 요청이 HTTP 403 Forbidden인지 검사한다. |
| 43 | var events = audit.findByRequestIdOrderById("audit-denied-1"); | 거절 뒤 같은 requestId로 저장된 audit_event 목록을 읽는다. |
| 44 | assertThat(events).hasSize(1); | 같은 requestId의 audit_event가 정확히 한 행인지 검사한다. |
| 45 | assertThat(events.getFirst().getActorId()).isEqualTo("customer-2"); | audit actor가 요청한 customer-2인지 검사한다. |
| 46 | assertThat(events.getFirst().getResult()).isEqualTo("DENIED"); | audit result가 DENIED인지 검사한다. |
| 47 | assertThat(events.getFirst().getErrorCode()).isEqualTo("ACCESS_DENIED"); | audit error code가 ACCESS_DENIED인지 검사한다. |
| 48 | assertThat(events.getFirst().getMaskedResourceId()) | 첫 audit row의 masked resource ID를 AssertJ actual로 잡는다. |
| 49 | .startsWith("ACCOUNT:") | masked resource가 ACCOUNT: prefix로 시작하는지 검사한다. |
| 50 | .isNotEqualTo("ACCOUNT:" + account.getId()); | masked resource 전체가 ACCOUNT:raw-account-id와 같지 않은지 검사한다. |
| 51 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 52 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다. 다만 Testcontainers PostgreSQL과 전체 Spring context가 필요하다
문법 해부
- @SpringBootTest·MockMvc·Testcontainers support가 HTTP부터 PostgreSQL까지 실제 경로를 연다.
- @BeforeEach TRUNCATE는 audit size=1의 결정성을 만든다.
- repository 조회 뒤 actor·result·error·masked resource를 각각 assert한다.
실행 순서
- DB clean
- 계좌 생성
- 타인 인증 GET
- 403 확인
- audit 조회
- 필드·mask 확인
원래 W6 수준의 조각별 정밀 해설
F04-C01 · imports annotations and integration class
- 문법 해부
- integration dependency imports, Spring annotations, test profile, support 상속을 bootstrap 순서로 나눈다.
- 실제 값 추적
- Spring context·MockMvc·PostgreSQL support를 가진 AuditPropagationIT type이 정의된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): actor=customer-2
- 정상 예
- test profile이 정상이라면 실제 security와 repository bean을 사용할 context가 열린다.
- 틀린 예·반례
- PostgreSQL 또는 migration이 없으면 method assertion 전에 context startup이 실패한다. 동결 경계: Testcontainers PostgreSQL과 전체 Spring context가 필요하다
- 착각 방지
- annotation 목록만으로 denied audit persistence가 증명되지는 않는다.
- 하지 않는 일
- DB schema 세부·transaction 경계·runtime field 값은 뒤 chunk의 책임이다. 동결 경계: Testcontainers PostgreSQL과 전체 Spring context가 필요하다
- 다음 연결
- F04-C02가 collaborator 주입과 deterministic DB 출발점을 만든다.
F04-C02 · injected collaborators and deterministic cleanup
- 문법 해부
- MockMvc·JdbcClient·opening service·audit repository 주입과 @BeforeEach TRUNCATE를 분리한다.
- 실제 값 추적
- 각 test 전에 다섯 table row와 identity가 초기화되어 events size=1 oracle가 결정적이 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): status=403
- 정상 예
- 빈 DB에서 openings.open을 호출할 준비가 끝난다.
- 틀린 예·반례
- audit_event table이 없으면 TRUNCATE에서 test body 전에 실패한다. 동결 경계: clean은 다섯 table을 CASCADE TRUNCATE한다
- 착각 방지
- destructive clean을 운영 DB에서 실행 가능한 일반 코드로 오인하면 안 된다.
- 하지 않는 일
- migration 성공·table 권한·병렬 test 격리를 자체 보장하지 않는다. 동결 경계: clean은 다섯 table을 CASCADE TRUNCATE한다
- 다음 연결
- F04-C03가 cross-owner HTTP request를 실제 security chain에 보낸다.
F04-C03 · cross-owner request and forbidden response
- 문법 해부
- denied test method 시작, owner 계좌 생성, customer-2 Basic Auth, requestId header, 403 oracle을 연결한다.
- 실제 값 추적
- customer-1 계좌를 customer-2가 조회해 audit-denied-1과 함께 403을 만든다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): events=1
- 정상 예
- 인가가 정상이라면 HTTP status assertion은 Forbidden에서 통과한다.
- 틀린 예·반례
- 본인 customer-1로 요청하면 403 전제가 무너져 이 scenario가 실패한다. 동결 경계: success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
- 착각 방지
- 403만 보고 audit row가 저장됐다고 결론 내리면 안 된다.
- 하지 않는 일
- DB event 수·actor·result·error·mask는 아직 확인하지 않는다. 동결 경계: success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
- 다음 연결
- F04-C04가 같은 requestId의 persisted audit row를 조회한다.
F04-C04 · same-request audit and masked resource oracle
- 문법 해부
- repository lookup, size, actor, result, error, masked resource oracle을 각각 분리한다.
- 실제 값 추적
- audit-denied-1로 한 행을 읽어 customer-2/DENIED/ACCESS_DENIED와 ACCOUNT: mask를 관찰한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): result=DENIED
- 정상 예
- 동결 fixture에서는 events=1이고 maskedResourceId는 ACCOUNT:로 시작하며 raw 전체와 다르다.
- 틀린 예·반례
- listener가 다른 requestId를 저장하면 events size가 0이 되어 첫 DB oracle에서 실패한다. 동결 경계: masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
- 착각 방지
- isNotEqualTo raw 전체는 raw 부분문자열 부재까지 증명하지 않는다.
- 하지 않는 일
- SUCCESS·FAILURE event, append-only 권한, retention, tamper evidence를 보장하지 않는다. 동결 경계: masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
- 다음 연결
- F07 masker 구현의 resourceId 조립과 이 약한 통합 oracle을 함께 비교한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
listener가 빠져도 HTTP 403은 나올 수 있지만 events는 0행이므로, status 하나만으로 audit persistence를 증명할 수 없어.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | customer-1 소유 계좌 + customer-2 인증 | audit-denied-1 header로 타인 계좌 GET을 보낸다. | security chain이 요청을 거절한다. | Testcontainers PostgreSQL과 전체 Spring context가 필요하다 |
| 2 | 거절된 HTTP 요청 | MockMvc status oracle을 적용한다. | 응답 status는 403 Forbidden이다. | clean은 다섯 table을 CASCADE TRUNCATE한다 |
| 3 | requestId=audit-denied-1 | repository로 audit_event를 조회한다. | events 목록은 정확히 한 행이다. | success audit·중복 denied·audit 실패 transaction은 검사하지 않는다 |
| 4 | 첫 audit row | actor·result·error field를 각각 읽는다. | customer-2 / DENIED / ACCESS_DENIED가 나온다. | masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다 |
| 5 | 첫 row maskedResourceId | ACCOUNT: prefix와 raw 전체 불일치를 검사한다. | ACCOUNT:로 시작하지만 ACCOUNT:raw-id와 같지 않다. | Testcontainers PostgreSQL과 전체 Spring context가 필요하다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘error=ACCESS_DENIED’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
test profile과 실제 application bean·PostgreSQL support를 구성한다.
Testcontainers PostgreSQL과 전체 Spring context가 필요하다customer-2의 타인 계좌 GET을 인증·인가 filter에 통과시켜 403을 만든다.
clean은 다섯 table을 CASCADE TRUNCATE한다거절 event를 request 처리 실패와 분리된 audit 저장 경로로 넘긴다.
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다requestId로 조회할 actor·result·error·masked resource row를 보존한다.
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다한 행과 다섯 관찰 조건을 순서대로 검사한다.
Testcontainers PostgreSQL과 전체 Spring context가 필요하다이 파일의 @Test가 실제로 고정하는 범위
deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId
- PostgreSQL 관련 table을 비우고 customer-1 계좌를 연다.
- customer-2 인증과 audit-denied-1 requestId를 준비한다.
- 다른 고객의 계좌 GET을 MockMvc로 호출한다.
- 같은 requestId의 audit_event를 다시 읽는다.
- HTTP status는 403이다.
- audit는 한 행이고 actor=customer-2, result=DENIED, error=ACCESS_DENIED다.
- resource는 ACCOUNT:로 시작하고 raw account ID와 같지 않다.
- 거절 request와 audit row의 requestId 연결
- 해당 fixture에서 denied event 한 행이 남음
- SUCCESS·FAILURE audit
- tamper evidence·retention·append-only 권한
- resource 문자열 내부 raw ID의 완전한 부재
첫 실패 경계 audit schema가 없으면 context migration 또는 @BeforeEach TRUNCATE에서 먼저 멈춘다. schema는 있고 filter·listener 전파가 빠지면 403 뒤 events size 또는 필드 assertion에서 드러난다.
STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ HTTP 403만 확인하면 denied audit가 저장됐다고 결론 내릴 수 있다.
왜 틀리나 인가 거절과 audit persistence는 서로 다른 관찰 경로다.
바르게 읽기 같은 requestId로 DB row를 조회해 size와 field를 함께 확인해야 한다.
반례 DeniedAuditListener가 빠져도 security chain은 403을 반환할 수 있지만 events는 0행이다.
❌ events size=1은 모든 재시도와 중복 요청에서 exactly-once audit를 증명한다.
왜 틀리나 test는 빈 DB에서 단일 요청 한 번만 실행한다.
바르게 읽기 중복·retry·동시 요청의 cardinality는 별도 반복·동시성 test로 검증해야 한다.
반례 같은 requestId 요청을 두 번 보냈을 때 두 행이 생기는 구현도 현재 단일-request case에서는 드러나지 않는다.
❌ maskedResourceId가 ACCOUNT:raw-id와 다르면 raw ID가 어디에도 포함되지 않는다.
왜 틀리나 isNotEqualTo는 전체 문자열 비동일성만 검사하며 부분문자열 포함을 배제하지 않는다.
바르게 읽기 민감 원문 부재를 주장하려면 contains 검사나 exact masking oracle이 필요하다.
반례 ACCOUNT:***<raw-id>처럼 prefix를 추가한 문자열은 raw 전체와 다르지만 raw ID를 그대로 포함한다.
❌ 한 DENIED row가 SUCCESS·FAILURE audit, retention, tamper resistance까지 증명한다.
왜 틀리나 현재 method는 한 거절 scenario와 네 field만 읽는다.
바르게 읽기 각 audit result와 운영 통제는 별도 integration·권한·retention evidence로 분리해야 한다.
반례 audit table update 권한이 열려 있어도 이 test의 assertion은 그대로 통과할 수 있다.
❌ @BeforeEach의 CASCADE TRUNCATE는 production에서도 안전한 초기화 방법이다.
왜 틀리나 이 SQL은 test 격리를 위해 다섯 table과 identity를 파괴적으로 초기화한다.
바르게 읽기 TRUNCATE는 격리된 test database lifecycle에만 두고 운영 경로와 분리해야 한다.
반례 production datasource에 같은 clean method가 실행되면 audit와 거래 원장이 모두 삭제될 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
이 책임을 맡는 곳: Testcontainers PostgreSQL and Spring context ownerclean은 다섯 table을 CASCADE TRUNCATE한다
이 책임을 맡는 곳: integration-test database lifecycle ownersuccess audit·중복 denied·audit 실패 transaction은 검사하지 않는다
이 책임을 맡는 곳: SUCCESS·FAILURE·transaction-boundary integration testsmasked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
이 책임을 맡는 곳: privacy oracle that rejects any raw-ID containmentSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- imports annotations and integration class
- injected collaborators and deterministic cleanup
- cross-owner request and forbidden response
- same-request audit and masked resource oracle
3단계 · 파일 전체 다시 쓰기
52개 물리 줄을 원본 순서로 복원하고 SHA-256 644ff0604fb12a8d9290a0fac6e475e10a7858d576edd76ee51992dd5d139b83와 대조한다.
자가 점검
- Spring·MockMvc·PostgreSQL 통합 test 범위를 말한다.
- customer-2의 타인 계좌 요청과 403을 연결한다.
- 같은 requestId audit 한 행의 actor·result·error를 각각 대조한다.
- masked assertion이 raw 부분문자열 부재까지 증명하지 않음을 말한다.
- SUCCESS·FAILURE audit는 이 test가 증명하지 않는다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.audit;
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.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AuditPropagationIT extends PostgresIntegrationTestSupport {
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired AuditEventRepository audit;
@BeforeEach
void clean() {
jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
.update();
}
@Test
void deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId() throws Exception {
Account account = openings.open("customer-1", "AUDIT-OWNER", 5_000);
mvc.perform(get("/api/accounts/{id}", account.getId())
.with(httpBasic("customer-2", "password"))
.header("X-Request-Id", "audit-denied-1"))
.andExpect(status().isForbidden());
var events = audit.findByRequestIdOrderById("audit-denied-1");
assertThat(events).hasSize(1);
assertThat(events.getFirst().getActorId()).isEqualTo("customer-2");
assertThat(events.getFirst().getResult()).isEqualTo("DENIED");
assertThat(events.getFirst().getErrorCode()).isEqualTo("ACCESS_DENIED");
assertThat(events.getFirst().getMaskedResourceId())
.startsWith("ACCOUNT:")
.isNotEqualTo("ACCOUNT:" + account.getId());
}
}
05MaskingTest.java — 계좌번호·resource ID 마스킹 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/security/MaskingTest.java
원문 정본 · 제공 test 계약 · 정본 · W19-F0516줄 연결19줄 번역3 chunks
MaskingTest.java — 계좌번호·resource ID 마스킹 계약
learning_stages/w19/production/tests/src/test/java/com/example/financialcore/security/MaskingTest.java
원문 정본 · 제공 test 계약 · 정본 · W19-F05STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.
- 123-456-7890 -> ******7890은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- 마지막 네 글자는 그대로 노출된다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
123-456-7890 -> ******7890null -> ***12 -> ****12ACCOUNT/123456 -> ACCOUNT:****3456STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
MaskingTest.java — 계좌번호·resource ID 마스킹 계약를 작은 검사표로 바꾸기
긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.
핵심값 123-456-7890 -> ******7890, null -> ***, 12 -> ****12, ACCOUNT/123456 -> ACCOUNT:****3456을 source 줄로 따라가되, 마지막 네 글자는 그대로 노출된다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 스티커 비유는 exact 문자열을 기억하게 할 뿐 Unicode·blank·type sanitize를 시험한 것으로 만들지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
imports and fixture
1~8줄을 한 덩어리로 읽어 긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.의 1번째 움직임을 본다.
- 코드 연결
1~8줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 마지막 네 글자는 그대로 노출된다
long account mask oracle
9~15줄을 한 덩어리로 읽어 긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.의 2번째 움직임을 본다.
- 코드 연결
9~15줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- Unicode·구두점-only·blank 문자열은 검사하지 않는다
blank short and resource ID oracles
16~24줄을 한 덩어리로 읽어 긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.의 3번째 움직임을 본다.
- 코드 연결
16~24줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- resource type의 개행·민감값은 검사하지 않는다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.
-
료
첫 관찰값은 ‘123-456-7890 -> ******7890’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 네 실제 입력의 문자열 계약을 고정하는 plain unit test야. 모든 masking 입력군을 포괄하지는 않아.
-
료
exact 문자열은 긴 숫자·null·12·ACCOUNT/123456에만 적용돼. 실제 blank·Unicode·악의적 type은 미검증이야.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 16줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 7줄F05-L07 | class MaskingTest { |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | masking 문자열 계약 두 개를 담는 JUnit unit-test fixture class를 연다.
|
| 8줄F05-L08 | private final SensitiveDataMasker masker = |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 각 test가 직접 호출할 대상 object를 만든다.
|
| 10줄F05-L10 | @Test |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 11줄F05-L11 | void accountNumberKeepsAtMostTheLastFourCharacters( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 12줄F05-L12 | assertThat( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 123-456-7890의 accountNumber 결과를 AssertJ actual로 잡는다.
|
| 13줄F05-L13 | . |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | 긴 번호 결과가 정확히 ******7890인지 검사한다.
|
| 14줄F05-L14 | . |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 결과에 연속 원문 123456이 없는지 검사한다.
|
| 15줄F05-L15 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 17줄F05-L17 | @Test |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 바로 다음 method를 JUnit test case로 발견하게 표시한다.
|
| 18줄F05-L18 | void blankAndShortValuesStillHaveAVisibleMaskBoundary( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 19줄F05-L19 | assertThat( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | null 입력 결과가 정확히 ***인지 검사한다.
|
| 20줄F05-L20 | assertThat( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 짧은 12의 결과가 정확히 ****12인지 검사한다.
|
| 21줄F05-L21 | assertThat( |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | ACCOUNT/123456 resourceId 결과를 AssertJ actual로 잡는다.
|
| 22줄F05-L22 | . |
실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. | resourceId가 정확히 ACCOUNT:****3456인지 검사한다.
|
| 23줄F05-L23 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 24줄F05-L24 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
123-456-7890→******7890, null→***, 12→****12, resourceId→ACCOUNT:****3456을 각각 계산해.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MaskingTest {
private final SensitiveDataMasker masker = new SensitiveDataMasker();
@Test
void accountNumberKeepsAtMostTheLastFourCharacters() {
assertThat(masker.accountNumber("123-456-7890"))
.isEqualTo("******7890")
.doesNotContain("123456");
}
@Test
void blankAndShortValuesStillHaveAVisibleMaskBoundary() {
assertThat(masker.accountNumber(null)).isEqualTo("***");
assertThat(masker.accountNumber("12")).isEqualTo("****12");
assertThat(masker.resourceId("ACCOUNT", "123456"))
.isEqualTo("ACCOUNT:****3456");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 19줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 3개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import org.junit.jupiter.api.Test; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 5 | import static org.assertj.core.api.Assertions.assertThat; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 7 | class MaskingTest { | masking 문자열 계약 두 개를 담는 JUnit unit-test fixture class를 연다. |
| 8 | private final SensitiveDataMasker masker = new SensitiveDataMasker(); | 각 test가 직접 호출할 대상 object를 만든다. |
| 10 | @Test | 바로 다음 method를 JUnit test case로 발견하게 표시한다. |
| 11 | void accountNumberKeepsAtMostTheLastFourCharacters() { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 12 | assertThat(masker.accountNumber("123-456-7890")) | 123-456-7890의 accountNumber 결과를 AssertJ actual로 잡는다. |
| 13 | .isEqualTo("******7890") | 긴 번호 결과가 정확히 ******7890인지 검사한다. |
| 14 | .doesNotContain("123456"); | 결과에 연속 원문 123456이 없는지 검사한다. |
| 15 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 17 | @Test | 바로 다음 method를 JUnit test case로 발견하게 표시한다. |
| 18 | void blankAndShortValuesStillHaveAVisibleMaskBoundary() { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 19 | assertThat(masker.accountNumber(null)).isEqualTo("***"); | null 입력 결과가 정확히 ***인지 검사한다. |
| 20 | assertThat(masker.accountNumber("12")).isEqualTo("****12"); | 짧은 12의 결과가 정확히 ****12인지 검사한다. |
| 21 | assertThat(masker.resourceId("ACCOUNT", "123456")) | ACCOUNT/123456 resourceId 결과를 AssertJ actual로 잡는다. |
| 22 | .isEqualTo("ACCOUNT:****3456"); | resourceId가 정확히 ACCOUNT:****3456인지 검사한다. |
| 23 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 24 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다. 다만 마지막 네 글자는 그대로 노출된다
문법 해부
- 두 @Test는 긴 번호와 null·짧은 값·resource ID 계약을 나눈다.
- AssertJ exact 문자열과 doesNotContain은 서로 다른 노출 경계를 검사한다.
- 이 unit test는 Spring context나 DB를 띄우지 않는다.
실행 순서
- masker 준비
- 긴 번호 호출
- 정확 문자열 확인
- null·짧은 값 호출
- resource ID 확인
원래 W6 수준의 조각별 정밀 해설
F05-C01 · imports and fixture
- 문법 해부
- JUnit·AssertJ setup과 plain Java SensitiveDataMasker fixture를 분리한다.
- 실제 값 추적
- Spring 없이 직접 호출할 masker instance가 두 test에 준비된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): 123-456-7890 -> ******7890
- 정상 예
- fixture 생성이 성공하면 문자열 transformation만 고립해 검사할 수 있다.
- 틀린 예·반례
- Spring bean proxy 동작을 기대하면 이 unit test 범위를 넘는다. 동결 경계: 마지막 네 글자는 그대로 노출된다
- 착각 방지
- plain fixture는 DB·MockMvc·transaction을 사용하지 않는다.
- 하지 않는 일
- 실제 mask 결과는 다음 두 @Test body에서만 증명된다. 동결 경계: 마지막 네 글자는 그대로 노출된다
- 다음 연결
- F05-C02가 긴 account number의 exact suffix 규칙을 검사한다.
F05-C02 · long account mask oracle
- 문법 해부
- 긴 번호 호출, exact ******7890, 123456 비포함을 하나의 oracle chain으로 읽는다.
- 실제 값 추적
- hyphen 제거 뒤 끝 4자만 보이고 앞 여섯 자리가 별표가 되는지 관찰한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): null -> ***
- 정상 예
- 123-456-7890 입력은 정확히 ******7890이다.
- 틀린 예·반례
- starter raw 반환은 첫 isEqualTo에서 실패한다. 동결 경계: Unicode·구두점-only·blank 문자열은 검사하지 않는다
- 착각 방지
- 한 ASCII 숫자 예제를 모든 Unicode·blank 입력에 일반화하면 안 된다.
- 하지 않는 일
- 짧은 값·null·resource type은 이 test가 직접 다루지 않는다. 동결 경계: Unicode·구두점-only·blank 문자열은 검사하지 않는다
- 다음 연결
- F05-C03가 null·short·resourceId 경계를 추가한다.
F05-C03 · blank short and resource ID oracles
- 문법 해부
- null, 12, ACCOUNT/123456 세 호출과 세 exact 문자열 oracle을 분리한다.
- 실제 값 추적
- guard fallback, 최소 별표 4개, resource prefix 합성을 한 test에서 관찰한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): 12 -> ****12
- 정상 예
- null→***, 12→****12, resourceId→ACCOUNT:****3456이 된다.
- 틀린 예·반례
- method 이름만 믿고 blank 문자열도 시험했다고 기록하면 틀린다. 동결 경계: resource type의 개행·민감값은 검사하지 않는다
- 착각 방지
- ****12는 짧은 원문 12 전체를 숨기지 않는다는 점을 놓치면 안 된다.
- 하지 않는 일
- Unicode·punctuation-only·악의적 type 값은 검사하지 않는다. 동결 경계: resource type의 개행·민감값은 검사하지 않는다
- 다음 연결
- F07 solution의 guard·정규화·조립 줄로 각 결과를 역추적한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
method 이름에는 blank가 있지만 공백 문자열 호출은 없으므로, blank 처리에 결함이 있어도 현재 두 test만으로는 발견하지 못해.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 123-456-7890 | ASCII 영숫자로 정규화하고 끝 네 글자만 보인다. | ******7890이 되고 123456 연속 원문은 없다. | 마지막 네 글자는 그대로 노출된다 |
| 2 | null | null guard clause를 실행한다. | ***를 반환한다. | Unicode·구두점-only·blank 문자열은 검사하지 않는다 |
| 3 | 12 | visible=2, stars=4로 조립한다. | ****12를 반환하며 짧은 원문은 전부 남는다. | resource type의 개행·민감값은 검사하지 않는다 |
| 4 | ACCOUNT, 123456 | resourceId가 type과 masked accountNumber를 합친다. | ACCOUNT:****3456을 반환한다. | 마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘ACCOUNT/123456 -> ACCOUNT:****3456’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
긴 번호와 null·짧은 값 계약을 두 test로 발견한다.
마지막 네 글자는 그대로 노출된다Spring context 없이 SensitiveDataMasker를 직접 생성한다.
Unicode·구두점-only·blank 문자열은 검사하지 않는다각 입력의 exact 반환 문자열을 실제 method call로 얻는다.
resource type의 개행·민감값은 검사하지 않는다별표 수·suffix·원문 일부 비포함·resource prefix를 비교한다.
마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다이 파일의 @Test가 실제로 고정하는 범위
accountNumberKeepsAtMostTheLastFourCharacters
- SensitiveDataMasker와 123-456-7890을 준비한다.
- accountNumber를 호출한다.
- 결과가 정확히 ******7890이다.
- 결과에 연속 원문 123456이 없다.
- ASCII 숫자·hyphen 예제의 정규화와 끝 4자 노출
- Unicode·공백-only·빈 문자열
- 짧은 입력의 비노출
- resource type sanitize
첫 실패 경계 starter는 raw를 반환하므로 exact masked 문자열 assertion에서 처음 깨진다.
blankAndShortValuesStillHaveAVisibleMaskBoundary
- null, 짧은 12, ACCOUNT/123456 세 입력을 준비한다.
- accountNumber 두 번과 resourceId 한 번을 호출한다.
- null은 ***, 12는 ****12다.
- resourceId는 ACCOUNT:****3456이다.
- null fallback
- 짧은 값의 최소 별표 4개
- 정상 type prefix와 masker 합성
- method 이름과 달리 실제 blank 문자열
- 짧은 원문 전체 노출의 정책 적합성
- 악의적 type 값
첫 실패 경계 starter는 null·짧은 값을 보호하지 않아 첫 null assertion부터 깨진다.
STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ method 이름에 blank가 있으므로 실제 blank 문자열도 검증한다.
왜 틀리나 두 번째 test의 실제 입력은 null, "12", ACCOUNT/123456뿐이다.
바르게 읽기 test 이름이 아니라 호출 인자를 기준으로 coverage를 판단해야 한다.
반례 " " 입력 처리에 결함이 있어도 현재 source에는 그 값을 호출하는 assertion이 없다.
❌ ******7890 결과는 계좌번호 전체가 숨겨졌음을 뜻한다.
왜 틀리나 정책은 마지막 네 글자 7890을 의도적으로 노출한다.
바르게 읽기 claim은 앞부분 masking과 최대 네 글자 suffix 노출로 제한해야 한다.
반례 관찰자는 결과만으로도 원문의 마지막 네 글자가 7890임을 안다.
❌ doesNotContain("123456") assertion 하나가 모든 원문 부분 노출을 막는다.
왜 틀리나 특정 연속 substring 하나만 검사하며 다른 긴 부분 노출은 배제하지 않는다.
바르게 읽기 이 fixture에서는 exact ******7890 assertion과 함께 읽고, 일반 정책은 추가 oracle로 검증해야 한다.
반례 결과가 234567890이라면 123456은 없지만 원문의 대부분이 노출된다.
❌ ****12는 짧은 값 12도 숨긴 안전한 mask다.
왜 틀리나 별표 뒤에 정규화된 원문 두 글자가 모두 남는다.
바르게 읽기 짧은 값 노출 허용 여부는 별도의 privacy policy로 결정해야 한다.
반례 입력 12의 모든 문자가 결과 ****12에서 그대로 보인다.
❌ plain unit test 통과는 resource type sanitize와 시스템 전체 노출 방지를 증명한다.
왜 틀리나 test는 type=ACCOUNT 한 값과 직접 생성한 masker만 검사한다.
바르게 읽기 악의적 type·로그·직렬화·권한 경계는 별도 test와 설계 검토가 필요하다.
반례 type에 newline이나 민감 문자열이 들어가는 caller는 현재 두 @Test로 차단되지 않는다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
마지막 네 글자는 그대로 노출된다
이 책임을 맡는 곳: privacy masking policyUnicode·구두점-only·blank 문자열은 검사하지 않는다
이 책임을 맡는 곳: Unicode·blank·punctuation parameterized testsresource type의 개행·민감값은 검사하지 않는다
이 책임을 맡는 곳: type validation or sanitizer마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
이 책임을 맡는 곳: logging/serialization/monitoring security reviewSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- imports and fixture
- long account mask oracle
- blank short and resource ID oracles
3단계 · 파일 전체 다시 쓰기
24개 물리 줄을 원본 순서로 복원하고 SHA-256 18e698134c0d8be9809dca0fe4730fe700089e8c0106a963241edb65cb3aefaa와 대조한다.
자가 점검
- 두 @Test method 이름과 실제 입력을 exact 대조한다.
- 123-456-7890→******7890을 재현한다.
- null→***, 12→****12, resourceId oracle을 구분한다.
- method 이름과 달리 실제 blank 문자열은 test하지 않음을 말한다.
- resource type sanitize는 범위 밖임을 말한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MaskingTest {
private final SensitiveDataMasker masker = new SensitiveDataMasker();
@Test
void accountNumberKeepsAtMostTheLastFourCharacters() {
assertThat(masker.accountNumber("123-456-7890"))
.isEqualTo("******7890")
.doesNotContain("123456");
}
@Test
void blankAndShortValuesStillHaveAVisibleMaskBoundary() {
assertThat(masker.accountNumber(null)).isEqualTo("***");
assertThat(masker.accountNumber("12")).isEqualTo("****12");
assertThat(masker.resourceId("ACCOUNT", "123456"))
.isEqualTo("ACCOUNT:****3456");
}
}
06RequestIdFilter.java — 검증·UUID·MDC finally 해법
learning_stages/w19/production/solution/src/main/java/com/example/financialcore/api/RequestIdFilter.java
원문 정본 · Green learner 해법 · 정본 · W19-F0630줄 연결43줄 번역4 chunks
RequestIdFilter.java — 검증·UUID·MDC finally 해법
learning_stages/w19/production/solution/src/main/java/com/example/financialcore/api/RequestIdFilter.java
원문 정본 · Green learner 해법 · 정본 · W19-F06STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.
- SAFE=[A-Za-z0-9._:-]{1,64}은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- 기존 MDC 값이 있으면 복원하지 않고 제거한다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
SAFE=[A-Za-z0-9._:-]{1,64}UUID fallbackOrder=HIGHEST_PRECEDENCEMDC remove in finallySTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
RequestIdFilter.java — 검증·UUID·MDC finally 해법를 작은 검사표로 바꾸기
안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.
핵심값 SAFE=[A-Za-z0-9._:-]{1,64}, UUID fallback, Order=HIGHEST_PRECEDENCE, MDC remove in finally을 source 줄로 따라가되, 기존 MDC 값이 있으면 복원하지 않고 제거한다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 방문표 비유는 같은 requestId 전파와 cleanup 순서를 돕지만 thread-local·async·인증 의미를 대신하지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
imports
1~15줄을 한 덩어리로 읽어 안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.의 1번째 움직임을 본다.
- 코드 연결
1~15줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 기존 MDC 값이 있으면 복원하지 않고 제거한다
component order and constants
16~23줄을 한 덩어리로 읽어 안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.의 2번째 움직임을 본다.
- 코드 연결
16~23줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 비동기 실행에는 자동 전파되지 않는다
validation propagation and cleanup
24~43줄을 한 덩어리로 읽어 안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.의 3번째 움직임을 본다.
- 코드 연결
24~43줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- response header는 downstream commit 시점과 별도 고려가 필요하다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.
-
료
첫 관찰값은 ‘SAFE=[A-Za-z0-9._:-]{1,64}’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 Red starter를 대체하는 Green learner solution이지만, 인증·async context까지 해결하는 보안 경계는 아니야.
-
료
whitelist·UUID·동기 MDC·finally cleanup은 구현하지만 기존 MDC 복원과 async 전파는 보장하지 않아.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 30줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 17줄F06-L17 | @Component |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
|
| 18줄F06-L18 | @Order( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | requestId filter를 가장 높은 우선순위로 실행하게 한다.
|
| 19줄F06-L19 | public final class RequestIdFilter extends OncePerRequestFilter { |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | 각 request당 한 번 실행되는 final Green filter를 선언한다.
|
| 20줄F06-L20 | public static final String HEADER = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 외부와 응답에서 공통으로 쓸 requestId header 이름을 고정한다.
|
| 21줄F06-L21 | public static final String ATTRIBUTE = |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | downstream code가 읽을 request-scoped attribute key를 만든다.
|
| 22줄F06-L22 | private static final Pattern SAFE = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 허용 ID를 ASCII 영숫자와 . _ : - 조합 1~64자로 제한한다.
|
| 24줄F06-L24 | @Override |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 부모 class의 extension point를 정확히 재정의한다고 compiler에 알린다.
|
| 25줄F06-L25 | protected void doFilterInternal( |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | doFilterInternal override의 여러 줄 method signature를 시작하며 body는 29줄에서 연다.
|
| 26줄F06-L26 | HttpServletRequest request, |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 요청 header와 attribute를 읽고 쓸 HttpServletRequest parameter를 받는다.
|
| 27줄F06-L27 | HttpServletResponse response, |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 선택한 requestId를 응답 header에 기록할 HttpServletResponse parameter를 받는다.
|
| 28줄F06-L28 | FilterChain chain |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | requestId 전파 뒤 호출할 나머지 servlet filter chain parameter를 받는다.
|
| 29줄F06-L29 | ) throws ServletException, |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
|
| 30줄F06-L30 | String supplied = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | caller가 보낸 X-Request-Id 값을 request에서 읽는다.
|
| 31줄F06-L31 | String requestId = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | header가 null이 아니고 whitelist 전체와 일치하는지 검사한다.
|
| 32줄F06-L32 | ? |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 안전하다고 판정한 외부 requestId를 그대로 선택한다.
|
| 33줄F06-L33 | : |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 외부 값이 없거나 위험하면 새 UUID를 fallback으로 선택한다.
|
| 34줄F06-L34 | request. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 21줄에서 이미 만든 ATTRIBUTE key 아래에 선택한 requestId 값을 저장한다.
|
| 35줄F06-L35 | response. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 선택한 ID를 응답 header에 써 caller에게 돌려준다.
|
| 36줄F06-L36 | MDC. |
교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. | 동기 처리 중 log context에 requestId를 넣는다.
|
| 37줄F06-L37 | try { |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | downstream chain과 cleanup을 한 lifecycle 범위로 묶기 시작한다.
|
| 38줄F06-L38 | chain. |
입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. | request와 response를 다음 filter·controller로 넘긴다.
|
| 39줄F06-L39 | } finally { |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 정상 반환뿐 아니라 예외에도 cleanup을 실행할 finally 범위를 연다.
|
| 40줄F06-L40 | MDC. |
교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. | thread 재사용 때 값이 새지 않도록 MDC key를 제거한다.
|
| 41줄F06-L41 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 42줄F06-L42 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 44줄F06-L44 | public static String current( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | request attribute에서 current requestId를 읽을 helper를 연다.
|
| 45줄F06-L45 | Object value = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | filter가 저장한 request-scoped ID object를 조회한다.
|
| 46줄F06-L46 | return value = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | attribute가 없으면 unavailable, 있으면 문자열로 반환한다.
|
| 47줄F06-L47 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 48줄F06-L48 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
외부 header를 SAFE regex로 판정하고, 실패하면 UUID를 골라 request·response·MDC에 기록한 뒤 finally에서 제거해.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.api;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public final class RequestIdFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Request-Id";
public static final String ATTRIBUTE = RequestIdFilter.class.getName() + ".requestId";
private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String supplied = request.getHeader(HEADER);
String requestId = supplied != null && SAFE.matcher(supplied).matches()
? supplied
: UUID.randomUUID().toString();
request.setAttribute(ATTRIBUTE, requestId);
response.setHeader(HEADER, requestId);
MDC.put("requestId", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
public static String current(HttpServletRequest request) {
Object value = request.getAttribute(ATTRIBUTE);
return value == null ? "unavailable" : value.toString();
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 43줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 13개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.api; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import jakarta.servlet.FilterChain; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 4 | import jakarta.servlet.ServletException; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 5 | import jakarta.servlet.http.HttpServletRequest; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 6 | import jakarta.servlet.http.HttpServletResponse; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 7 | import org.slf4j.MDC; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 8 | import org.springframework.stereotype.Component; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 9 | import org.springframework.core.Ordered; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 10 | import org.springframework.core.annotation.Order; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 11 | import org.springframework.web.filter.OncePerRequestFilter; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 13 | import java.io.IOException; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 14 | import java.util.UUID; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 15 | import java.util.regex.Pattern; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 17 | @Component | Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다. |
| 18 | @Order(Ordered.HIGHEST_PRECEDENCE) | requestId filter를 가장 높은 우선순위로 실행하게 한다. |
| 19 | public final class RequestIdFilter extends OncePerRequestFilter { | 각 request당 한 번 실행되는 final Green filter를 선언한다. |
| 20 | public static final String HEADER = "X-Request-Id"; | 외부와 응답에서 공통으로 쓸 requestId header 이름을 고정한다. |
| 21 | public static final String ATTRIBUTE = RequestIdFilter.class.getName() + ".requestId"; | downstream code가 읽을 request-scoped attribute key를 만든다. |
| 22 | private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); | 허용 ID를 ASCII 영숫자와 . _ : - 조합 1~64자로 제한한다. |
| 24 | @Override | 부모 class의 extension point를 정확히 재정의한다고 compiler에 알린다. |
| 25 | protected void doFilterInternal( | doFilterInternal override의 여러 줄 method signature를 시작하며 body는 29줄에서 연다. |
| 26 | HttpServletRequest request, | 현재 요청 header와 attribute를 읽고 쓸 HttpServletRequest parameter를 받는다. |
| 27 | HttpServletResponse response, | 선택한 requestId를 응답 header에 기록할 HttpServletResponse parameter를 받는다. |
| 28 | FilterChain chain | requestId 전파 뒤 호출할 나머지 servlet filter chain parameter를 받는다. |
| 29 | ) throws ServletException, IOException { | 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다. |
| 30 | String supplied = request.getHeader(HEADER); | caller가 보낸 X-Request-Id 값을 request에서 읽는다. |
| 31 | String requestId = supplied != null && SAFE.matcher(supplied).matches() | header가 null이 아니고 whitelist 전체와 일치하는지 검사한다. |
| 32 | ? supplied | 안전하다고 판정한 외부 requestId를 그대로 선택한다. |
| 33 | : UUID.randomUUID().toString(); | 외부 값이 없거나 위험하면 새 UUID를 fallback으로 선택한다. |
| 34 | request.setAttribute(ATTRIBUTE, requestId); | 21줄에서 이미 만든 ATTRIBUTE key 아래에 선택한 requestId 값을 저장한다. |
| 35 | response.setHeader(HEADER, requestId); | 선택한 ID를 응답 header에 써 caller에게 돌려준다. |
| 36 | MDC.put("requestId", requestId); | 동기 처리 중 log context에 requestId를 넣는다. |
| 37 | try { | downstream chain과 cleanup을 한 lifecycle 범위로 묶기 시작한다. |
| 38 | chain.doFilter(request, response); | request와 response를 다음 filter·controller로 넘긴다. |
| 39 | } finally { | 정상 반환뿐 아니라 예외에도 cleanup을 실행할 finally 범위를 연다. |
| 40 | MDC.remove("requestId"); | thread 재사용 때 값이 새지 않도록 MDC key를 제거한다. |
| 41 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 42 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 44 | public static String current(HttpServletRequest request) { | request attribute에서 current requestId를 읽을 helper를 연다. |
| 45 | Object value = request.getAttribute(ATTRIBUTE); | filter가 저장한 request-scoped ID object를 조회한다. |
| 46 | return value == null ? "unavailable" : value.toString(); | attribute가 없으면 unavailable, 있으면 문자열로 반환한다. |
| 47 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 48 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다. 다만 기존 MDC 값이 있으면 복원하지 않고 제거한다
문법 해부
- regex 전체 일치와 삼항 연산자가 외부 값 또는 UUID를 고른다.
- request attribute·response header·MDC에 같은 값을 기록한다.
- try/finally가 정상·예외 반환 모두에서 MDC key를 제거한다.
실행 순서
- header 읽기
- whitelist 검증
- 외부값/UUID 선택
- request·response·MDC 전파
- chain
- finally cleanup
원래 W6 수준의 조각별 정밀 해설
F06-C01 · imports
- 문법 해부
- servlet·MDC·Spring order·IO·UUID·Pattern imports를 runtime 책임별로 묶는다.
- 실제 값 추적
- compiler가 Green filter의 request, response, chain, regex, UUID, MDC type을 해석한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): SAFE=[A-Za-z0-9._:-]{1,64}
- 정상 예
- 모든 pinned import가 있으면 solution class body가 compile 가능한 상태가 된다.
- 틀린 예·반례
- MDC 또는 Pattern import를 제거하면 해당 Green statement에서 compile이 멈춘다. 동결 경계: 기존 MDC 값이 있으면 복원하지 않고 제거한다
- 착각 방지
- import 완전성을 runtime Green으로 착각하면 안 된다.
- 하지 않는 일
- whitelist 값·전파·cleanup 동작은 아직 실행하지 않는다. 동결 경계: 기존 MDC 값이 있으면 복원하지 않고 제거한다
- 다음 연결
- F06-C02가 component order와 세 상수를 고정한다.
F06-C02 · component order and constants
- 문법 해부
- @Component, highest precedence, class, HEADER, ATTRIBUTE, SAFE Pattern을 등록 순서로 읽는다.
- 실제 값 추적
- Spring은 가장 먼저 실행할 filter bean과 1~64자 whitelist를 얻는다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): UUID fallback
- 정상 예
- req-w19-1은 SAFE와 일치하고 공백·newline 값은 일치하지 않는다.
- 틀린 예·반례
- regex를 .*로 넓히면 위험 header 반사 방지 test가 무력화된다. 동결 경계: 비동기 실행에는 자동 전파되지 않는다
- 착각 방지
- 높은 filter 순서는 requestId를 인증 credential로 만들지 않는다.
- 하지 않는 일
- 기존 MDC·async·response commit 정책은 이 chunk 밖이다. 동결 경계: 비동기 실행에는 자동 전파되지 않는다
- 다음 연결
- F06-C03가 실제 ID 선택·세 위치 전파·finally cleanup을 수행한다.
F06-C03 · validation propagation and cleanup
- 문법 해부
- method signature, header read, whitelist/UUID 삼항, attribute/header/MDC, try/finally를 lifecycle 순서로 나눈다.
- 실제 값 추적
- 한 requestId가 세 위치에 기록되고 chain 정상·예외 종료 뒤 MDC key가 제거된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): Order=HIGHEST_PRECEDENCE
- 정상 예
- req-w19-1은 보존되고 위험 값은 UUID로 교체되며 반환 뒤 MDC는 null이다.
- 틀린 예·반례
- finally를 빼면 같은 worker thread의 다음 request log에 이전 ID가 남을 수 있다. 동결 경계: response header는 downstream commit 시점과 별도 고려가 필요하다
- 착각 방지
- MDC remove를 preexisting value 복원으로 오인하면 안 된다.
- 하지 않는 일
- async thread 전파·기존 MDC 복원·authorization을 보장하지 않는다. 동결 경계: response header는 downstream commit 시점과 별도 고려가 필요하다
- 다음 연결
- F06-C04가 request attribute를 downstream helper 결과로 바꾼다.
F06-C04 · current request helper
- 문법 해부
- current helper가 ATTRIBUTE 조회와 unavailable fallback 중 하나를 반환하는 삼항을 읽는다.
- 실제 값 추적
- downstream code가 filter가 저장한 requestId 또는 unavailable을 얻는다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): MDC remove in finally
- 정상 예
- filter를 지난 request는 저장된 ID를, attribute 없는 request는 unavailable을 반환한다.
- 틀린 예·반례
- attribute에 다른 object를 넣으면 toString 결과가 그대로 노출될 수 있다. 동결 경계: requestId는 권한 근거가 아니다
- 착각 방지
- unavailable을 인증 실패나 보안 decision으로 해석하면 안 된다.
- 하지 않는 일
- 값의 provenance·권한·format을 다시 검증하지 않는다. 동결 경계: requestId는 권한 근거가 아니다
- 다음 연결
- AuditRecorder·error handler가 이 helper를 어떤 context에서 쓰는지 별도 dependency로 남긴다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
호출 전에 MDC requestId가 outer였다면 filter 종료 후 outer로 복원되지 않고 null이 돼.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | req-w19-1 | SAFE regex 전체 일치를 검사한다. | 허용된 supplied 값이 requestId로 선택된다. | 기존 MDC 값이 있으면 복원하지 않고 제거한다 |
| 2 | 공백·newline 위험 값 | SAFE 불일치 뒤 UUID.randomUUID를 호출한다. | 위험 원문 대신 36자 UUID가 선택된다. | 비동기 실행에는 자동 전파되지 않는다 |
| 3 | 선택된 requestId | attribute·response header·MDC에 같은 값을 기록한다. | downstream request와 log가 같은 상관관계 ID를 읽는다. | response header는 downstream commit 시점과 별도 고려가 필요하다 |
| 4 | chain 정상 또는 예외 반환 | finally에서 MDC.remove를 실행한다. | 현재 thread의 requestId key가 제거된다. | requestId는 권한 근거가 아니다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘MDC remove in finally’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
X-Request-Id 외부 header를 읽는다.
기존 MDC 값이 있으면 복원하지 않고 제거한다1~64자 안전 문자열은 보존하고 나머지는 새 UUID로 교체한다.
비동기 실행에는 자동 전파되지 않는다선택한 값을 request attribute와 response header에 기록한다.
response header는 downstream commit 시점과 별도 고려가 필요하다같은 동기 thread의 downstream log context에 requestId를 넣는다.
requestId는 권한 근거가 아니다chain의 정상·예외 종료 모두에서 MDC key를 제거한다.
기존 MDC 값이 있으면 복원하지 않고 제거한다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ SAFE regex를 통과한 requestId는 인증된 신뢰 값이다.
왜 틀리나 regex는 문자와 길이만 검증하며 caller의 신원을 확인하지 않는다.
바르게 읽기 requestId는 correlation에만 쓰고 권한은 authenticated principal로 결정해야 한다.
반례 공격자가 victim-id처럼 보이는 1~64자 안전 문자열을 보내도 그 사용자 권한을 얻어서는 안 된다.
❌ HIGHEST_PRECEDENCE면 비동기 작업에도 MDC가 자동 전파된다.
왜 틀리나 filter order는 현재 servlet thread의 실행 순서이며 thread-local 복사를 수행하지 않는다.
바르게 읽기 async 경계에는 task decorator나 explicit context carrier가 필요하다.
반례 chain이 CompletableFuture 작업을 시작하면 worker thread의 MDC requestId는 자동으로 채워지지 않는다.
❌ finally의 MDC.remove는 호출 전 requestId를 복원한다.
왜 틀리나 remove는 현재 key를 삭제할 뿐 이전 값을 저장·복구하지 않는다.
바르게 읽기 중첩 context를 보존하려면 기존 값을 캡처하고 finally에서 복원해야 한다.
반례 호출 전 MDC requestId=outer였어도 filter 종료 후 조회 값은 outer가 아니라 null이다.
❌ UUID.randomUUID fallback이 있으므로 ID 고유성과 entropy가 이 source로 완전히 증명된다.
왜 틀리나 source는 generator를 호출하지만 중복률·entropy·운영 충돌 evidence를 측정하지 않는다.
바르게 읽기 claim은 위험 외부 값을 새 UUID 문자열로 교체하는 동작으로 제한해야 한다.
반례 제공 test도 한 번 생성한 문자열의 regex만 보고 여러 요청 간 중복 여부는 비교하지 않는다.
❌ current(request)는 attribute 값을 다시 검증해 안전한 ID만 반환한다.
왜 틀리나 helper는 attribute object를 읽어 null이면 unavailable, 아니면 toString을 그대로 반환한다.
바르게 읽기 attribute provenance와 format은 filter 또는 caller 계약에서 보장해야 한다.
반례 다른 code가 ATTRIBUTE에 newline이 든 object를 넣으면 current()는 그 toString 결과를 그대로 돌려준다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
기존 MDC 값이 있으면 복원하지 않고 제거한다
이 책임을 맡는 곳: filter context save/restore policy비동기 실행에는 자동 전파되지 않는다
이 책임을 맡는 곳: task decorator or explicit context carrierresponse header는 downstream commit 시점과 별도 고려가 필요하다
이 책임을 맡는 곳: servlet integration and error-response testsrequestId는 권한 근거가 아니다
이 책임을 맡는 곳: authenticated principal and access-control layerSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- imports
- component order and constants
- validation propagation and cleanup
- current request helper
3단계 · 파일 전체 다시 쓰기
48개 물리 줄을 원본 순서로 복원하고 SHA-256 a5e5e453a03460a67930e6e3534380735de8fa0ebb95017bb28a124f96ce8f2b와 대조한다.
자가 점검
- SAFE regex의 문자집합과 1~64자 경계를 읽는다.
- 외부 ID 또는 UUID fallback 분기를 따라간다.
- request attribute·response header·MDC가 같은 값을 받는지 확인한다.
- try/finally의 MDC.remove를 찾는다.
- 기존 MDC 복원·async 전파·인증 권한은 보장하지 않음을 말한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.api;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public final class RequestIdFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Request-Id";
public static final String ATTRIBUTE = RequestIdFilter.class.getName() + ".requestId";
private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String supplied = request.getHeader(HEADER);
String requestId = supplied != null && SAFE.matcher(supplied).matches()
? supplied
: UUID.randomUUID().toString();
request.setAttribute(ATTRIBUTE, requestId);
response.setHeader(HEADER, requestId);
MDC.put("requestId", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
public static String current(HttpServletRequest request) {
Object value = request.getAttribute(ATTRIBUTE);
return value == null ? "unavailable" : value.toString();
}
}
07SensitiveDataMasker.java — 정규화·마스킹 해법
learning_stages/w19/production/solution/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java
원문 정본 · Green learner 해법 · 정본 · W19-F0713줄 연결15줄 번역3 chunks
SensitiveDataMasker.java — 정규화·마스킹 해법
learning_stages/w19/production/solution/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java
원문 정본 · Green learner 해법 · 정본 · W19-F07STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.
- null/blank -> ***은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- 짧은 값은 원문 전체가 별표 뒤에 남는다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
null/blank -> ***non-alnum removedvisible=min(4,length)stars=max(4,length-visible)STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
SensitiveDataMasker.java — 정규화·마스킹 해법를 작은 검사표로 바꾸기
ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.
핵심값 null/blank -> ***, non-alnum removed, visible=min(4,length), stars=max(4,length-visible)을 source 줄로 따라가되, 짧은 값은 원문 전체가 별표 뒤에 남는다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 스티커 비유는 suffix 조립 순서를 돕지만 짧은 값 노출·Unicode 충돌·type 신뢰 경계를 숨기지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
component shell
1~6줄을 한 덩어리로 읽어 ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.의 1번째 움직임을 본다.
- 코드 연결
1~6줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 짧은 값은 원문 전체가 별표 뒤에 남는다
normalize and mask
7~14줄을 한 덩어리로 읽어 ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.의 2번째 움직임을 본다.
- 코드 연결
7~14줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- Unicode 식별자는 제거되어 서로 충돌할 수 있다
resource ID composition
15~18줄을 한 덩어리로 읽어 ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.의 3번째 움직임을 본다.
- 코드 연결
15~18줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 구두점-only 입력은 ****가 된다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.
-
료
첫 관찰값은 ‘null/blank -> ***’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 파일은 제공 test를 만족하는 Green masking solution이지만, 암호화나 충돌 없는 익명화 구현은 아니야.
-
료
ASCII suffix 규칙은 구현하지만 짧은 값 전체 노출·Unicode 충돌·type 미검증 경계가 남아 있어.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 13줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 5줄F07-L05 | @Component |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
|
| 6줄F07-L06 | public final class SensitiveDataMasker { |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 식별자를 정규화하고 가리는 final masker를 선언한다.
|
| 7줄F07-L07 | public String accountNumber( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | nullable 계좌 식별자를 표시용 문자열로 바꾸는 method를 연다.
|
| 8줄F07-L08 | if ( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | null 또는 blank 입력을 placeholder ***로 바꾼다.
|
| 9줄F07-L09 | String normalized = |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | ASCII 영숫자가 아닌 문자를 제거해 suffix 기준을 만든다.
|
| 10줄F07-L10 | int visible = |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 정규화 길이와 4 중 작은 값을 보일 suffix 길이로 정한다.
|
| 11줄F07-L11 | return "* |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 숨길 길이와 무관하게 별표를 최소 4개 만들기 시작한다.
|
| 12줄F07-L12 | + |
송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. | 정규화 문자열의 마지막 visible 글자를 별표 뒤에 붙인다.
|
| 13줄F07-L13 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 15줄F07-L15 | public String resourceId( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | resource type과 masking 결과를 합칠 helper를 연다.
|
| 16줄F07-L16 | return type + |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | type prefix, colon, accountNumber(raw)의 masking 결과를 합쳐 반환한다.
|
| 17줄F07-L17 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
| 18줄F07-L18 | } |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
123-456-7890을 1234567890으로 정규화하고 visible=4, stars=6을 계산해 ******7890을 만드는 순서를 따라가.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 3개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
package com.example.financialcore.security;
import org.springframework.stereotype.Component;
@Component
public final class SensitiveDataMasker {
public String accountNumber(String raw) {
if (raw == null || raw.isBlank()) return "***";
String normalized = raw.replaceAll("[^A-Za-z0-9]", "");
int visible = Math.min(4, normalized.length());
return "*".repeat(Math.max(4, normalized.length() - visible))
+ normalized.substring(normalized.length() - visible);
}
public String resourceId(String type, String raw) {
return type + ":" + accountNumber(raw);
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 15줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 2개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.security; | 이 class가 속한 Java package namespace를 compiler에 알려 준다. |
| 3 | import org.springframework.stereotype.Component; | 아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 5 | @Component | Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다. |
| 6 | public final class SensitiveDataMasker { | 식별자를 정규화하고 가리는 final masker를 선언한다. |
| 7 | public String accountNumber(String raw) { | nullable 계좌 식별자를 표시용 문자열로 바꾸는 method를 연다. |
| 8 | if (raw == null || raw.isBlank()) return "***"; | null 또는 blank 입력을 placeholder ***로 바꾼다. |
| 9 | String normalized = raw.replaceAll("[^A-Za-z0-9]", ""); | ASCII 영숫자가 아닌 문자를 제거해 suffix 기준을 만든다. |
| 10 | int visible = Math.min(4, normalized.length()); | 정규화 길이와 4 중 작은 값을 보일 suffix 길이로 정한다. |
| 11 | return "*".repeat(Math.max(4, normalized.length() - visible)) | 숨길 길이와 무관하게 별표를 최소 4개 만들기 시작한다. |
| 12 | + normalized.substring(normalized.length() - visible); | 정규화 문자열의 마지막 visible 글자를 별표 뒤에 붙인다. |
| 13 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 15 | public String resourceId(String type, String raw) { | resource type과 masking 결과를 합칠 helper를 연다. |
| 16 | return type + ":" + accountNumber(raw); | type prefix, colon, accountNumber(raw)의 masking 결과를 합쳐 반환한다. |
| 17 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
| 18 | } | 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다. 다만 짧은 값은 원문 전체가 별표 뒤에 남는다
문법 해부
- guard clause가 null·blank를 먼저 ***로 반환한다.
- replaceAll·min·repeat·substring이 정규화와 suffix 노출 길이를 계산한다.
- resourceId는 type을 sanitize하지 않고 masking 결과 앞에 붙인다.
실행 순서
- null·blank 분기
- 정규화
- visible 계산
- 별표와 suffix
- resource type 연결
원래 W6 수준의 조각별 정밀 해설
F07-C01 · component shell
- 문법 해부
- security package, Component import·annotation, final class shell을 compiler와 Spring 단계로 나눈다.
- 실제 값 추적
- SensitiveDataMasker Green bean type이 component scan 후보가 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): null/blank -> ***
- 정상 예
- caller는 하나의 stateless masker instance를 주입받을 수 있다.
- 틀린 예·반례
- @Component를 제거해도 direct unit test는 통과할 수 있지만 runtime injection은 실패한다. 동결 경계: 짧은 값은 원문 전체가 별표 뒤에 남는다
- 착각 방지
- bean 등록을 문자열 policy proof로 오인하면 안 된다.
- 하지 않는 일
- null·정규화·별표 규칙은 다음 chunk가 책임진다. 동결 경계: 짧은 값은 원문 전체가 별표 뒤에 남는다
- 다음 연결
- F07-C02가 accountNumber의 모든 transformation을 실행한다.
F07-C02 · normalize and mask
- 문법 해부
- null/blank guard, ASCII 정규화, visible 계산, 최소 별표, suffix 조립을 순서대로 나눈다.
- 실제 값 추적
- raw 입력이 placeholder 또는 별표+최대 4자 suffix로 바뀐다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): non-alnum removed
- 정상 예
- 123-456-7890은 normalized 1234567890, visible4, stars6, 결과 ******7890이다.
- 틀린 예·반례
- Unicode 문자만 있으면 normalized가 비어 ****가 되어 서로 다른 입력이 충돌할 수 있다. 동결 경계: Unicode 식별자는 제거되어 서로 충돌할 수 있다
- 착각 방지
- 끝 네 글자 노출을 암호화나 되돌릴 수 없는 익명화로 부르면 안 된다.
- 하지 않는 일
- 짧은 값은 원문 전체가 suffix에 남고 type은 다루지 않는다. 동결 경계: Unicode 식별자는 제거되어 서로 충돌할 수 있다
- 다음 연결
- F07-C03가 accountNumber 결과 앞에 resource type을 붙인다.
F07-C03 · resource ID composition
- 문법 해부
- resourceId signature와 type + colon + accountNumber(raw) 합성을 읽는다.
- 실제 값 추적
- ACCOUNT/123456 입력이 ACCOUNT:****3456 표시값이 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): visible=min(4,length)
- 정상 예
- 정상 type ACCOUNT와 숫자 raw는 MaskingTest exact oracle과 일치한다.
- 틀린 예·반례
- type에 newline이나 민감값이 들어오면 이 method는 그대로 prefix에 남긴다. 동결 경계: 구두점-only 입력은 ****가 된다
- 착각 방지
- raw가 masking됐다고 type까지 안전하다고 추정하면 안 된다.
- 하지 않는 일
- type validation·allowlist·log encoding은 caller나 별도 sanitizer 책임이다. 동결 경계: 구두점-only 입력은 ****가 된다
- 다음 연결
- F04 통합 test의 ACCOUNT: prefix와 raw 전체 불일치 oracle로 연결한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
서로 다른 Unicode-only 값 ‘가’와 ‘나’는 모두 정규화 후 빈 문자열이 되어 ****로 충돌할 수 있어.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | null 또는 blank | guard clause에서 즉시 분기한다. | ***를 반환한다. | 짧은 값은 원문 전체가 별표 뒤에 남는다 |
| 2 | 123-456-7890 | ASCII 영숫자 외 문자를 제거한다. | normalized는 1234567890이다. | Unicode 식별자는 제거되어 서로 충돌할 수 있다 |
| 3 | normalized 길이 10 | visible=min(4,10), stars=max(4,6)을 계산한다. | 별표 6개와 suffix 7890이 선택된다. | 구두점-only 입력은 ****가 된다 |
| 4 | ACCOUNT, 123456 | type + colon + accountNumber(raw)를 조립한다. | ACCOUNT:****3456이 된다. | resource type은 별도 검증 없이 앞에 붙는다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘stars=max(4,length-visible)’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
null·blank를 정규화 전에 ***로 종료한다.
짧은 값은 원문 전체가 별표 뒤에 남는다영숫자 외 문자를 제거해 suffix 기준 문자열을 만든다.
Unicode 식별자는 제거되어 서로 충돌할 수 있다보일 글자는 최대 4, 별표는 최소 4로 계산한다.
구두점-only 입력은 ****가 된다별표 뒤에 정규화 문자열의 마지막 visible 글자를 붙인다.
resource type은 별도 검증 없이 앞에 붙는다sanitize하지 않은 type과 masking 결과를 colon으로 연결한다.
짧은 값은 원문 전체가 별표 뒤에 남는다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 끝 네 글자만 남기는 규칙은 길이 4 이하 값도 일부 숨긴다.
왜 틀리나 visible=min(4,length)이므로 짧은 정규화 값은 전체가 suffix로 남는다.
바르게 읽기 짧은 값의 전체 노출 허용 여부를 privacy policy에서 별도로 결정해야 한다.
반례 입력 12는 ****12가 되어 원문 두 글자가 모두 보인다.
❌ [^A-Za-z0-9] 제거는 Unicode 식별자를 안전하게 정규화한다.
왜 틀리나 비ASCII 문자는 보존·구분되지 않고 모두 제거될 수 있다.
바르게 읽기 Unicode 지원 또는 충돌 처리 정책을 별도로 정의해야 한다.
반례 서로 다른 ‘가’와 ‘나’는 모두 normalized 빈 문자열이 되어 ****로 충돌한다.
❌ 구두점-only 입력은 blank guard에 걸려 ***가 된다.
왜 틀리나 raw.isBlank()는 구두점을 blank로 보지 않아 replaceAll 뒤 빈 문자열 경로로 간다.
바르게 읽기 raw blank와 normalized empty를 서로 다른 경계로 시험해야 한다.
반례 입력 ---은 guard를 지나 normalized=""가 되고 결과는 ****다.
❌ resourceId는 raw뿐 아니라 type도 sanitize한다.
왜 틀리나 method는 type을 변환 없이 colon 앞에 붙인다.
바르게 읽기 resource type은 allowlist 또는 별도 encoder로 검증해야 한다.
반례 type="ACCOUNT\nFORGED"이면 newline이 반환 문자열 prefix에 그대로 남는다.
❌ 별표 mask는 암호화되거나 충돌 없는 익명 식별자다.
왜 틀리나 suffix를 평문으로 노출하고 여러 원문이 같은 표시값을 만들 수 있다.
바르게 읽기 masking은 display 최소화로만 분류하고 암호화·tokenization과 구분해야 한다.
반례 서로 다른 긴 계좌번호가 같은 마지막 네 글자를 가지면 동일한 suffix를 노출한다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
짧은 값은 원문 전체가 별표 뒤에 남는다
이 책임을 맡는 곳: privacy masking policyUnicode 식별자는 제거되어 서로 충돌할 수 있다
이 책임을 맡는 곳: normalization policy and collision tests구두점-only 입력은 ****가 된다
이 책임을 맡는 곳: empty-normalized-value policyresource type은 별도 검증 없이 앞에 붙는다
이 책임을 맡는 곳: caller validation or type sanitizerSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- component shell
- normalize and mask
- resource ID composition
3단계 · 파일 전체 다시 쓰기
18개 물리 줄을 원본 순서로 복원하고 SHA-256 3001f01fbbd32d7931b1bdd4da7e1abd5d975febb7ccc1a8fc25f0a609448f06와 대조한다.
자가 점검
- null·blank guard를 먼저 읽는다.
- ASCII 영숫자 외 문자 제거를 확인한다.
- visible=min(4,length)와 stars=max(4,length-visible)를 계산한다.
- 짧은 값은 별표 뒤 원문 전체가 남음을 말한다.
- resource type은 sanitize하지 않음을 말한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
원문 정본 전체 source 확인하기
package com.example.financialcore.security;
import org.springframework.stereotype.Component;
@Component
public final class SensitiveDataMasker {
public String accountNumber(String raw) {
if (raw == null || raw.isBlank()) return "***";
String normalized = raw.replaceAll("[^A-Za-z0-9]", "");
int visible = Math.min(4, normalized.length());
return "*".repeat(Math.max(4, normalized.length() - visible))
+ normalized.substring(normalized.length() - visible);
}
public String resourceId(String type, String raw) {
return type + ":" + accountNumber(raw);
}
}
08W19-SQL-Q29.sql — 전일 거래액 차이 비정본 예시
illustrative/sql/W19-SQL-Q29.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W19-F0824줄 연결24줄 번역4 chunks
W19-SQL-Q29.sql — 전일 거래액 차이 비정본 예시
illustrative/sql/W19-SQL-Q29.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W19-F08STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.
- 2026-07-01 total=1500은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
2026-07-01 total=1500previous=2129999difference=-2128499missing prior SUCCESS date -> NULLSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
W19-SQL-Q29.sql — 전일 거래액 차이 비정본 예시를 작은 검사표로 바꾸기
SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.
핵심값 2026-07-01 total=1500, previous=2129999, difference=-2128499, missing prior SUCCESS date -> NULL을 source 줄로 따라가되, prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 날짜 장부 비유는 self join을 돕지만 calendar date-1을 실제 업무일 달력으로 바꾸어 주지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
provenance assumptions and schema
1~5줄을 한 덩어리로 읽어 SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.의 1번째 움직임을 본다.
- 코드 연결
1~5줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
daily SUCCESS aggregation
6~13줄을 한 덩어리로 읽어 SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.의 2번째 움직임을 본다.
- 코드 연결
6~13줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- 달력일 -1은 실제 영업일 달력을 모델링하지 않는다
self join and difference
14~22줄을 한 덩어리로 읽어 SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.의 3번째 움직임을 본다.
- 코드 연결
14~22줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- OPENING·REVERSAL을 포함한다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.
-
료
첫 관찰값은 ‘2026-07-01 total=1500’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 SQL은 Q29의 shipped answer가 아니라 가정을 드러낸 illustrative example이야.
-
료
SUCCESS의 모든 type을 합산하고 calendar date-1을 쓰는 선택이므로, 업무일·type 정책의 공식 답으로 일반화하면 안 돼.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 24줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F08-L01 | -- W19-SQL-Q29 illustrative example; |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | Q29 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다.
|
| 2줄F08-L02 | -- Assumption: |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | SUCCESS의 amount를 모두 세며 OPENING과 REVERSAL도 포함한다는 선택 가정을 선언한다.
|
| 3줄F08-L03 | -- A missing previous calendar date remains NULL; |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | 직전 달력일 집계가 없으면 NULL로 두며 업무일·휴일 달력을 모델링하지 않는다고 제한한다.
|
| 4줄F08-L04 | SET search_path TO : |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
|
| 6줄F08-L06 | WITH daily AS ( |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | SUCCESS 거래를 날짜 한 행으로 접을 daily CTE를 연다.
|
| 7줄F08-L07 | SELECT |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 단계의 출력 column 목록을 시작한다.
|
| 8줄F08-L08 | business_date, |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 일별 집계 grain을 business_date로 드러낸다.
|
| 9줄F08-L09 | SUM( |
같은 날짜 영수증을 모아 금액을 더한다. | 같은 날짜 SUCCESS 거래 amount를 total_amount로 합한다.
|
| 10줄F08-L10 | FROM business_tx |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다.
|
| 11줄F08-L11 | WHERE status = |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다.
|
| 12줄F08-L12 | GROUP BY business_date |
같은 날짜 영수증을 모아 금액을 더한다. | 날짜마다 한 total_amount가 나오도록 집계한다.
|
| 13줄F08-L13 | ) |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 CTE 또는 lateral subquery 범위를 닫는다.
|
| 14줄F08-L14 | SELECT |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 단계의 출력 column 목록을 시작한다.
|
| 15줄F08-L15 | current_day. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 일별 집계 grain을 business_date로 드러낸다.
|
| 16줄F08-L16 | current_day. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 날짜의 SUCCESS 합계 total_amount를 최종 결과 column으로 투영한다.
|
| 17줄F08-L17 | previous_day. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 전 달력일 합계를 previous_day_amount로 이름 붙인다.
|
| 18줄F08-L18 | current_day. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 합계에서 전 달력일 합계를 빼 변화액을 계산한다.
|
| 19줄F08-L19 | FROM daily AS current_day |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 모든 집계 날짜를 보존할 current_day에서 시작한다.
|
| 20줄F08-L20 | LEFT JOIN daily AS previous_day |
기준 명단을 남겨 둔 채 옆에 조건에 맞는 장부 한 줄을 붙인다. | 전 달력일 집계가 없어도 현재 날짜를 남기는 LEFT self join을 연다.
|
| 21줄F08-L21 | ON previous_day. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 정확히 calendar date - 1인 집계 행을 연결한다.
|
| 22줄F08-L22 | ORDER BY current_day. |
최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. | 결과를 business_date 오름차순으로 표시한다.
|
| 23줄F08-L23 | -- Oracle highlights: |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | 2026-07-01의 current·previous·difference 세 숫자 oracle을 기록한다.
|
| 24줄F08-L24 | -- 2026-12-29 has no 2026-12-28 SUCCESS total, |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | 2026-12-28에는 row가 있지만 SUCCESS 일계가 없어 12-29 previous·difference가 NULL임을 기록한다.
|
| 25줄F08-L25 | -- 2026-12-30. |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | 12-30부터 2027-01-02까지 이어지는 네 difference oracle을 순서대로 기록한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
07-01 SUCCESS 합계 1500과 06-30 합계 2129999를 self join해 difference -2128499를 만드는 순서를 따라가.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- W19-SQL-Q29 illustrative example; not a shipped workbook answer.
-- Assumption: every SUCCESS business_tx amount counts, including OPENING and REVERSAL.
-- A missing previous calendar date remains NULL; this does not model a holiday calendar.
SET search_path TO :"workbook_schema", public;
WITH daily AS (
SELECT
business_date,
SUM(amount) AS total_amount
FROM business_tx
WHERE status = 'SUCCESS'
GROUP BY business_date
)
SELECT
current_day.business_date,
current_day.total_amount,
previous_day.total_amount AS previous_day_amount,
current_day.total_amount - previous_day.total_amount AS amount_difference
FROM daily AS current_day
LEFT JOIN daily AS previous_day
ON previous_day.business_date = current_day.business_date - 1
ORDER BY current_day.business_date;
-- Oracle highlights: 2026-07-01=1500, previous=2129999, difference=-2128499.
-- 2026-12-29 has no 2026-12-28 SUCCESS total, so previous/difference are NULL.
-- 2026-12-30..2027-01-02 differences are 1, 900000, -999500, 700.
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 24줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | -- W19-SQL-Q29 illustrative example; not a shipped workbook answer. | Q29 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다. |
| 2 | -- Assumption: every SUCCESS business_tx amount counts, including OPENING and REVERSAL. | SUCCESS의 amount를 모두 세며 OPENING과 REVERSAL도 포함한다는 선택 가정을 선언한다. |
| 3 | -- A missing previous calendar date remains NULL; this does not model a holiday calendar. | 직전 달력일 집계가 없으면 NULL로 두며 업무일·휴일 달력을 모델링하지 않는다고 제한한다. |
| 4 | SET search_path TO :"workbook_schema", public; | workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다. |
| 6 | WITH daily AS ( | SUCCESS 거래를 날짜 한 행으로 접을 daily CTE를 연다. |
| 7 | SELECT | 현재 단계의 출력 column 목록을 시작한다. |
| 8 | business_date, | 일별 집계 grain을 business_date로 드러낸다. |
| 9 | SUM(amount) AS total_amount | 같은 날짜 SUCCESS 거래 amount를 total_amount로 합한다. |
| 10 | FROM business_tx | workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다. |
| 11 | WHERE status = 'SUCCESS' | 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다. |
| 12 | GROUP BY business_date | 날짜마다 한 total_amount가 나오도록 집계한다. |
| 13 | ) | 현재 CTE 또는 lateral subquery 범위를 닫는다. |
| 14 | SELECT | 현재 단계의 출력 column 목록을 시작한다. |
| 15 | current_day.business_date, | 일별 집계 grain을 business_date로 드러낸다. |
| 16 | current_day.total_amount, | 현재 날짜의 SUCCESS 합계 total_amount를 최종 결과 column으로 투영한다. |
| 17 | previous_day.total_amount AS previous_day_amount, | 전 달력일 합계를 previous_day_amount로 이름 붙인다. |
| 18 | current_day.total_amount - previous_day.total_amount AS amount_difference | 현재 합계에서 전 달력일 합계를 빼 변화액을 계산한다. |
| 19 | FROM daily AS current_day | 모든 집계 날짜를 보존할 current_day에서 시작한다. |
| 20 | LEFT JOIN daily AS previous_day | 전 달력일 집계가 없어도 현재 날짜를 남기는 LEFT self join을 연다. |
| 21 | ON previous_day.business_date = current_day.business_date - 1 | 정확히 calendar date - 1인 집계 행을 연결한다. |
| 22 | ORDER BY current_day.business_date; | 결과를 business_date 오름차순으로 표시한다. |
| 23 | -- Oracle highlights: 2026-07-01=1500, previous=2129999, difference=-2128499. | 2026-07-01의 current·previous·difference 세 숫자 oracle을 기록한다. |
| 24 | -- 2026-12-29 has no 2026-12-28 SUCCESS total, so previous/difference are NULL. | 2026-12-28에는 row가 있지만 SUCCESS 일계가 없어 12-29 previous·difference가 NULL임을 기록한다. |
| 25 | -- 2026-12-30..2027-01-02 differences are 1, 900000, -999500, 700. | 12-30부터 2027-01-02까지 이어지는 네 difference oracle을 순서대로 기록한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다. 다만 prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
문법 해부
- daily CTE가 SUCCESS amount를 business_date별 한 행으로 집계한다.
- LEFT self join은 current_day를 보존하고 calendar date-1만 연결한다.
- 전일 행이 없으면 subtraction 결과도 NULL이다.
실행 순서
- SUCCESS 일별 합계
- current day 보존
- calendar-1 self join
- difference
- oracle 대조
원래 W6 수준의 조각별 정밀 해설
F08-C01 · provenance assumptions and schema
- 문법 해부
- 배포된 정답이 아니라는 선언. 비정본 선언, SUCCESS·type 포함 가정, calendar-1 한계, search_path를 provenance와 실행 setup으로 나눈다.
- 실제 값 추적
- query가 어떤 row를 세고 어떤 전일을 뜻하는지 독자가 실행 전에 알게 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): 2026-07-01 total=1500
- 정상 예
- SUCCESS의 OPENING·REVERSAL도 포함하며 직전 달력일 집계가 없으면 NULL로 둔다.
- 틀린 예·반례
- 이 주석을 실제 업무일 달력이나 공식 workbook 답으로 바꾸어 읽으면 틀린다. 동결 경계: prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
- 착각 방지
- illustrative label을 제거하면 underspecified prompt에 단일 정답이 있는 것처럼 보인다.
- 하지 않는 일
- status·tx_type 정책과 business calendar는 workbook이 공식 고정하지 않았다. 동결 경계: prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
- 다음 연결
- F08-C02가 선택 가정대로 daily SUCCESS 합계를 만든다.
F08-C02 · daily SUCCESS aggregation
- 문법 해부
- daily CTE의 business_date key, SUM(amount), SUCCESS filter, GROUP BY를 row-grain 순서로 읽는다.
- 실제 값 추적
- 각 날짜의 SUCCESS amount가 한 total_amount row로 축약된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): previous=2129999
- 정상 예
- 2026-07-01의 daily total은 1500이다.
- 틀린 예·반례
- FAILED row까지 포함하면 07-01 total이 2200으로 바뀌어 oracle이 깨진다. 동결 경계: 달력일 -1은 실제 영업일 달력을 모델링하지 않는다
- 착각 방지
- daily가 날짜 빈칸을 생성한다고 생각하면 안 된다.
- 하지 않는 일
- 거래 없는 날짜와 business calendar row를 만들지 않는다. 동결 경계: 달력일 -1은 실제 영업일 달력을 모델링하지 않는다
- 다음 연결
- F08-C03가 daily를 current/previous 두 alias로 self join한다.
F08-C03 · self join and difference
- 문법 해부
- current projection, previous alias, subtraction, LEFT self join, date-1 조건, order를 연결한다.
- 실제 값 추적
- 현재 날짜를 보존하고 전 달력일 합계가 있으면 difference를 계산한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): difference=-2128499
- 정상 예
- 2026-07-01은 1500-2129999=-2128499다.
- 틀린 예·반례
- 전 날짜가 결과에 존재한다는 가정으로 INNER JOIN하면 12-29 행을 잃는다. 동결 경계: OPENING·REVERSAL을 포함한다
- 착각 방지
- calendar date-1을 이전 업무일로 부르면 휴일·누락일 의미가 달라진다.
- 하지 않는 일
- SUCCESS 일계가 없는 전 날짜에서는 previous와 difference가 NULL이다. 동결 경계: OPENING·REVERSAL을 포함한다
- 다음 연결
- F08-C04의 동결 숫자와 NULL oracle로 전체 query를 대조한다.
F08-C04 · pinned fixture oracle
- 문법 해부
- 세 주석을 첫 delta, missing SUCCESS date, 후속 네 delta oracle로 분리한다.
- 실제 값 추적
- 사람이 실제 query output의 특정 행과 숫자를 독립 대조할 기준을 얻는다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): missing prior SUCCESS date -> NULL
- 정상 예
- 07-01=-2128499, 12-29 previous/difference NULL, 이후 1/900000/-999500/700이다.
- 틀린 예·반례
- 12-28을 휴일이라고 기록하면 fixture의 FAILED 세 행을 무시한 거짓 설명이 된다. 동결 경계: psql 변수 workbook_schema가 필요하다
- 착각 방지
- 주석 oracle 존재를 SQL이 스스로 assertion한다고 오인하면 안 된다.
- 하지 않는 일
- database가 이 숫자를 자동 검증하거나 실패 exit로 바꾸지 않는다. 동결 경계: psql 변수 workbook_schema가 필요하다
- 다음 연결
- 실행 evidence에서는 SQL output을 이 oracle과 별도 비교해야 한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
12-28에는 FAILED 세 행이 있지만 SUCCESS 일계는 없어 12-29의 previous와 difference가 NULL이야. 이를 휴일이라고 부르면 fixture를 왜곡해.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 2026-07-01 SUCCESS rows | daily CTE가 amount를 날짜별 합산한다. | current total_amount는 1500이다. | prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다 |
| 2 | current_day=2026-07-01 | business_date-1로 daily를 self join한다. | previous_day_amount는 2129999다. | 달력일 -1은 실제 영업일 달력을 모델링하지 않는다 |
| 3 | current 1500, previous 2129999 | 두 합계를 뺀다. | amount_difference는 -2128499다. | OPENING·REVERSAL을 포함한다 |
| 4 | current_day=2026-12-29 | 2026-12-28 SUCCESS 일계와 LEFT join한다. | 해당 SUCCESS 일계가 없어 previous와 difference는 NULL이다. | psql 변수 workbook_schema가 필요하다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘missing prior SUCCESS date -> NULL’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
workbook schema의 business_tx를 우선 해석한다.
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다SUCCESS amount를 business_date별 total_amount 한 행으로 접는다.
달력일 -1은 실제 영업일 달력을 모델링하지 않는다각 current date에 calendar date-1 집계가 있으면 연결하고 없으면 NULL로 둔다.
OPENING·REVERSAL을 포함한다previous가 NULL이면 difference도 NULL로 전파한다.
psql 변수 workbook_schema가 필요하다business_date 오름차순으로 fixture oracle을 비교 가능하게 정렬한다.
shipped workbook 정답이 아니다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 이 SQL은 Q29의 shipped workbook 정답이다.
왜 틀리나 첫 주석과 source audit가 비정본 illustrative example로 명시한다.
바르게 읽기 답안이 아니라 선택 가정을 드러낸 학습 예시로 표시해야 한다.
반례 status·tx_type·금액 부호 정책을 다르게 정하면 같은 prompt에도 다른 query가 타당할 수 있다.
❌ calendar date-1 self join은 이전 업무일을 정확히 찾는다.
왜 틀리나 조건은 정확히 날짜 하루 전만 찾고 주말·휴일 calendar를 참조하지 않는다.
바르게 읽기 업무일 요구에는 calendar table과 이전 영업일 정책이 필요하다.
반례 월요일 current row 앞의 일요일 SUCCESS 일계가 없으면 금요일이 있어도 previous는 NULL이다.
❌ 2026-12-29의 previous가 NULL이면 12-28은 휴일이거나 거래가 없었다.
왜 틀리나 fixture에는 12-28 FAILED 거래 세 행이 있고 SUCCESS 일계만 없다.
바르게 읽기 NULL의 뜻을 ‘전 달력일 SUCCESS aggregate 없음’으로 제한해야 한다.
반례 12-28 row를 조회하면 거래는 존재하지만 WHERE status='SUCCESS'에서 모두 제거된다.
❌ SUCCESS 합계는 OPENING과 REVERSAL을 자동으로 제외한다.
왜 틀리나 query에는 status filter만 있고 tx_type filter가 없다.
바르게 읽기 현재 예시는 SUCCESS인 모든 type을 포함한다는 가정을 유지해야 한다.
반례 SUCCESS OPENING row도 daily SUM(amount)에 들어가 06-30 합계의 일부가 된다.
❌ oracle 주석이 있으므로 SQL이 결과 숫자를 자동 assertion하고 실패 exit를 낸다.
왜 틀리나 주석은 사람이 대조할 기대값이며 executable 검증식이 아니다.
바르게 읽기 runner에서 actual output을 oracle과 비교하고 mismatch를 non-zero로 바꿔야 한다.
반례 difference 식을 잘못 바꿔도 SQL 문법이 유효하면 query는 exit 0으로 끝날 수 있다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
이 책임을 맡는 곳: workbook requirement owner달력일 -1은 실제 영업일 달력을 모델링하지 않는다
이 책임을 맡는 곳: calendar table and holiday policyOPENING·REVERSAL을 포함한다
이 책임을 맡는 곳: business reporting definitionpsql 변수 workbook_schema가 필요하다
이 책임을 맡는 곳: workbook runner and schema bindingshipped workbook 정답이 아니다
이 책임을 맡는 곳: learner-owned solution and reviewerSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- provenance assumptions and schema
- daily SUCCESS aggregation
- self join and difference
- pinned fixture oracle
3단계 · 파일 전체 다시 쓰기
25개 물리 줄을 원본 순서로 복원하고 SHA-256 863c4c23c683c4557be76940d07c00ee6cc16de273cdafe949455b2c0dfb31ab와 대조한다.
자가 점검
- 비정본 예시·가정 주석을 유지한다.
- SUCCESS와 OPENING·REVERSAL 포함 가정을 말한다.
- daily CTE의 날짜별 합계를 계산한다.
- calendar date-1 self join이 업무일 달력을 모델링하지 않음을 말한다.
- 2026-07-01 수치와 2026-12-29의 전일 SUCCESS 일계 누락·NULL oracle을 대조한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- W19-SQL-Q29 illustrative example; not a shipped workbook answer.
-- Assumption: every SUCCESS business_tx amount counts, including OPENING and REVERSAL.
-- A missing previous calendar date remains NULL; this does not model a holiday calendar.
SET search_path TO :"workbook_schema", public;
WITH daily AS (
SELECT
business_date,
SUM(amount) AS total_amount
FROM business_tx
WHERE status = 'SUCCESS'
GROUP BY business_date
)
SELECT
current_day.business_date,
current_day.total_amount,
previous_day.total_amount AS previous_day_amount,
current_day.total_amount - previous_day.total_amount AS amount_difference
FROM daily AS current_day
LEFT JOIN daily AS previous_day
ON previous_day.business_date = current_day.business_date - 1
ORDER BY current_day.business_date;
-- Oracle highlights: 2026-07-01=1500, previous=2129999, difference=-2128499.
-- 2026-12-29 has no 2026-12-28 SUCCESS total, so previous/difference are NULL.
-- 2026-12-30..2027-01-02 differences are 1, 900000, -999500, 700.
09W19-SQL-Q30.sql — 계좌별 최근 성공 거래 비정본 예시
illustrative/sql/W19-SQL-Q30.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W19-F0922줄 연결22줄 번역4 chunks
W19-SQL-Q30.sql — 계좌별 최근 성공 거래 비정본 예시
illustrative/sql/W19-SQL-Q30.sql
학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W19-F09STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.
- accounts=8은 어느 줄에서 생길까?
- 입력→처리→결과는 어떤 lifecycle인가?
- counterparty_account_id로 들어온 거래는 포함하지 않는다에서 첫 실패는 어디인가?
- test가 직접 assert하지 않은 것은 무엇인가?
- starter·test·solution·예시의 provenance는 무엇인가?
accounts=8account101 -> tx ...212account103/104 -> NULLtie=occurred_at,tx_idSTEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 입력·처리·관찰값과 증명 경계를 한 장씩 분리한다.
W19-SQL-Q30.sql — 계좌별 최근 성공 거래 비정본 예시를 작은 검사표로 바꾸기
각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.
핵심값 accounts=8, account101 -> tx ...212, account103/104 -> NULL, tie=occurred_at,tx_id을 source 줄로 따라가되, counterparty_account_id로 들어온 거래는 포함하지 않는다까지 표시해 Green을 과대해석하지 않는다.
딱 여기까지만 줄 세우기 비유는 latest row 선택을 돕지만 counterparty 포함·zero-row 정책을 공식 답으로 만들지 않는다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
provenance assumptions and schema
1~5줄을 한 덩어리로 읽어 각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.의 1번째 움직임을 본다.
- 코드 연결
1~5줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- counterparty_account_id로 들어온 거래는 포함하지 않는다
account output grain
6~11줄을 한 덩어리로 읽어 각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.의 2번째 움직임을 본다.
- 코드 연결
6~11줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- OPENING·REVERSAL을 성공 거래로 포함한다
stable latest-row lateral lookup
12~20줄을 한 덩어리로 읽어 각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.의 3번째 움직임을 본다.
- 코드 연결
12~20줄- 비유
- 같은 일을 하는 부품만 한 봉투에 담아 순서대로 확인한다.
- 비유의 끝
- prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
출발점히토리 → 니지카 → 료 → 키타
-
히토리
왜 이 파일부터 보나요?
-
니지카
각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.
-
료
첫 관찰값은 ‘accounts=8’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.
-
키타
입력과 결과를 두 칸으로 나눌게요.
역할과 경계히토리 → 니지카 → 료 → 키타
-
히토리
source가 있으면 곧 최종 정답인가요?
-
니지카
이 SQL은 Q30의 shipped answer가 아니라 direct account와 zero-row 보존 가정을 택한 illustrative example이야.
-
료
direct account_id의 SUCCESS만 보고 OPENING·REVERSAL도 포함해. counterparty 참여 여부는 공식 요구로 확정되지 않았어.
-
키타
artifact label과 직접 증명 범위를 함께 적겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 22줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F09-L01 | -- W19-SQL-Q30 illustrative example; |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | Q30 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다.
|
| 2줄F09-L02 | -- Assumption: |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | direct business_tx.account_id와 SUCCESS 상태만 최신 거래 후보로 센다는 가정을 선언한다.
|
| 3줄F09-L03 | -- Accounts without a successful row remain; |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | 성공 거래 없는 account도 남기고 동률은 occurred_at DESC, tx_id DESC로 푼다고 선언한다.
|
| 4줄F09-L04 | SET search_path TO : |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
|
| 6줄F09-L06 | SELECT |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 단계의 출력 column 목록을 시작한다.
|
| 7줄F09-L07 | a. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | account 한 행이라는 최종 grain을 account_id로 표시한다.
|
| 8줄F09-L08 | latest. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
|
| 9줄F09-L09 | latest. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
|
| 10줄F09-L10 | latest. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
|
| 11줄F09-L11 | FROM account AS a |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 성공 거래가 없는 계좌도 남기려고 account에서 시작한다.
|
| 12줄F09-L12 | LEFT JOIN LATERAL ( |
기준 명단을 남겨 둔 채 옆에 조건에 맞는 장부 한 줄을 붙인다. | 각 account마다 latest 후보를 찾되 후보가 없어도 account를 남긴다.
|
| 13줄F09-L13 | SELECT t. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 최종 출력에 필요한 거래 ID·금액·시각을 고른다.
|
| 14줄F09-L14 | FROM business_tx AS t |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다.
|
| 15줄F09-L15 | WHERE t. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 바깥 account와 직접 연결된 거래만 남긴다.
|
| 16줄F09-L16 | AND t. |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다.
|
| 17줄F09-L17 | ORDER BY t. |
최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. | 최신 시각 우선, 동률이면 tx_id 큰 값 우선으로 total order를 만든다.
|
| 18줄F09-L18 | LIMIT 1 |
최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. | 각 account 후보를 정렬의 첫 한 행으로 줄인다.
|
| 19줄F09-L19 | ) AS latest ON TRUE |
조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. | 상관 조건은 subquery 안에 두고 lateral 결과를 붙인다.
|
| 20줄F09-L20 | ORDER BY a. |
최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. | 여덟 account 결과를 account_id 순으로 표시한다.
|
| 21줄F09-L21 | -- Oracle: |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | account101 동률에서 tx ...212가 ...211보다 먼저 선택되는 oracle을 기록한다.
|
| 22줄F09-L22 | -- account103 and account104 return NULL; |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | account103·104는 NULL이고 account105는 tx ...210인 oracle을 기록한다.
|
| 23줄F09-L23 | -- account102/ |
연습지 위에 가정과 예상 결과 포스트잇을 붙인다. | account102·106·107·108은 이 가정 아래 SUCCESS OPENING row를 고른다고 기록한다.
|
실제 값히토리 → 니지카 → 료 → 키타
-
히토리
문법보다 값을 먼저 볼 수 있나요?
-
니지카
account101의 동률 후보를 occurred_at DESC, tx_id DESC로 정렬해 ...212를 고르고, 후보 없는 103·104는 NULL로 남겨.
-
료
각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.
-
키타
값 추적표로 다시 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- W19-SQL-Q30 illustrative example; not a shipped workbook answer.
-- Assumption: only direct business_tx.account_id and status=SUCCESS count.
-- Accounts without a successful row remain; ties use occurred_at DESC, tx_id DESC.
SET search_path TO :"workbook_schema", public;
SELECT
a.account_id,
latest.tx_id,
latest.amount,
latest.occurred_at
FROM account AS a
LEFT JOIN LATERAL (
SELECT t.tx_id, t.amount, t.occurred_at
FROM business_tx AS t
WHERE t.account_id = a.account_id
AND t.status = 'SUCCESS'
ORDER BY t.occurred_at DESC, t.tx_id DESC
LIMIT 1
) AS latest ON TRUE
ORDER BY a.account_id;
-- Oracle: account101 chooses tx ...212 over ...211 at the same timestamp.
-- account103 and account104 return NULL; account105 chooses ...210.
-- account102/106/107/108 choose their SUCCESS OPENING rows under this assumption.
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 22줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | -- W19-SQL-Q30 illustrative example; not a shipped workbook answer. | Q30 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다. |
| 2 | -- Assumption: only direct business_tx.account_id and status=SUCCESS count. | direct business_tx.account_id와 SUCCESS 상태만 최신 거래 후보로 센다는 가정을 선언한다. |
| 3 | -- Accounts without a successful row remain; ties use occurred_at DESC, tx_id DESC. | 성공 거래 없는 account도 남기고 동률은 occurred_at DESC, tx_id DESC로 푼다고 선언한다. |
| 4 | SET search_path TO :"workbook_schema", public; | workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다. |
| 6 | SELECT | 현재 단계의 출력 column 목록을 시작한다. |
| 7 | a.account_id, | account 한 행이라는 최종 grain을 account_id로 표시한다. |
| 8 | latest.tx_id, | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다. |
| 9 | latest.amount, | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다. |
| 10 | latest.occurred_at | lateral subquery가 고른 최신 거래 column을 account 행에 붙인다. |
| 11 | FROM account AS a | 성공 거래가 없는 계좌도 남기려고 account에서 시작한다. |
| 12 | LEFT JOIN LATERAL ( | 각 account마다 latest 후보를 찾되 후보가 없어도 account를 남긴다. |
| 13 | SELECT t.tx_id, t.amount, t.occurred_at | 최종 출력에 필요한 거래 ID·금액·시각을 고른다. |
| 14 | FROM business_tx AS t | workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다. |
| 15 | WHERE t.account_id = a.account_id | 현재 바깥 account와 직접 연결된 거래만 남긴다. |
| 16 | AND t.status = 'SUCCESS' | 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다. |
| 17 | ORDER BY t.occurred_at DESC, t.tx_id DESC | 최신 시각 우선, 동률이면 tx_id 큰 값 우선으로 total order를 만든다. |
| 18 | LIMIT 1 | 각 account 후보를 정렬의 첫 한 행으로 줄인다. |
| 19 | ) AS latest ON TRUE | 상관 조건은 subquery 안에 두고 lateral 결과를 붙인다. |
| 20 | ORDER BY a.account_id; | 여덟 account 결과를 account_id 순으로 표시한다. |
| 21 | -- Oracle: account101 chooses tx ...212 over ...211 at the same timestamp. | account101 동률에서 tx ...212가 ...211보다 먼저 선택되는 oracle을 기록한다. |
| 22 | -- account103 and account104 return NULL; account105 chooses ...210. | account103·104는 NULL이고 account105는 tx ...210인 oracle을 기록한다. |
| 23 | -- account102/106/107/108 choose their SUCCESS OPENING rows under this assumption. | account102·106·107·108은 이 가정 아래 SUCCESS OPENING row를 고른다고 기록한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다. 다만 counterparty_account_id로 들어온 거래는 포함하지 않는다
문법 해부
- LEFT LATERAL이 바깥 account마다 상관 subquery를 다시 평가한다.
- occurred_at DESC, tx_id DESC가 동률까지 안정 정렬한다.
- LIMIT 1과 LEFT join이 최신 최대 한 행·거래 없는 계좌 보존을 함께 만든다.
실행 순서
- account 시작
- SUCCESS 후보
- 안정 정렬
- 한 행 제한
- zero-row 보존
원래 W6 수준의 조각별 정밀 해설
F09-C01 · provenance assumptions and schema
- 문법 해부
- 배포된 정답이 아니라는 선언. 비정본 선언, direct SUCCESS 가정, zero-row 보존·tie order, search_path를 분리한다.
- 실제 값 추적
- latest 거래의 참여 기준과 cardinality를 실행 전에 명시한다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): accounts=8
- 정상 예
- direct account_id의 SUCCESS만 세고 모든 account를 남기며 timestamp/UUID 역순을 쓴다.
- 틀린 예·반례
- counterparty participation이나 INNER LATERAL을 공식 요구로 바꾸면 다른 답이 된다. 동결 경계: counterparty_account_id로 들어온 거래는 포함하지 않는다
- 착각 방지
- illustrative label을 제거하면 prompt의 빈 정책을 숨기게 된다.
- 하지 않는 일
- counterparty·tx_type·zero-row 정책은 workbook이 단일 정답으로 고정하지 않았다. 동결 경계: counterparty_account_id로 들어온 거래는 포함하지 않는다
- 다음 연결
- F09-C02가 보존할 account output grain을 만든다.
F09-C02 · account output grain
- 문법 해부
- account_id와 latest 세 column projection 뒤 account relation에서 시작한다.
- 실제 값 추적
- 거래가 없는 계좌를 포함한 account 8행이 최종 결과의 바깥 grain이 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): account101 -> tx ...212
- 정상 예
- account103·104도 latest column이 아직 미정인 결과 후보로 남는다.
- 틀린 예·반례
- business_tx에서 query를 시작하면 zero-row account 두 개가 사라진다. 동결 경계: OPENING·REVERSAL을 성공 거래로 포함한다
- 착각 방지
- projection에 latest column이 있다고 모두 non-NULL이라고 추정하면 안 된다.
- 하지 않는 일
- latest 후보 선택·동률·status filter는 다음 lateral chunk 책임이다. 동결 경계: OPENING·REVERSAL을 성공 거래로 포함한다
- 다음 연결
- F09-C03가 각 account에 상관된 latest SUCCESS 한 행을 찾는다.
F09-C03 · stable latest-row lateral lookup
- 문법 해부
- LEFT LATERAL, direct account correlation, SUCCESS filter, stable DESC order, LIMIT 1, ON TRUE를 연결한다.
- 실제 값 추적
- 각 account마다 최대 한 latest 성공 거래가 붙고 후보가 없으면 NULL이 된다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): account103/104 -> NULL
- 정상 예
- account101 동률에서는 tx ...212, account103·104에서는 NULL이 선택된다.
- 틀린 예·반례
- tx_id tie-breaker를 빼면 account101 결과가 실행마다 달라질 수 있다. 동결 경계: prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
- 착각 방지
- LIMIT 1만으로 stable latest가 된다고 생각하면 안 된다.
- 하지 않는 일
- counterparty_account_id 참여와 OPENING·REVERSAL 제외를 보장하지 않는다. 동결 경계: prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
- 다음 연결
- F09-C04의 여덟 account oracle로 lateral 결과를 대조한다.
F09-C04 · pinned fixture oracle
- 문법 해부
- 세 주석을 동률 account101, NULL account103/104, 나머지 SUCCESS row oracle로 나눈다.
- 실제 값 추적
- 사람이 여덟 account 결과의 대표 ID와 NULL을 독립 대조할 수 있다. 항목 전체에서 대조할 동결 값(이 chunk 단독 산출이라는 뜻 아님): tie=occurred_at,tx_id
- 정상 예
- 101→...212, 103/104→NULL, 105→...210, 102/106/107/108→OPENING이다.
- 틀린 예·반례
- INNER LATERAL을 쓰면 103·104가 사라져 여덟 행 oracle이 깨진다. 동결 경계: psql 변수 workbook_schema가 필요하다
- 착각 방지
- 주석 oracle을 shipped answer 또는 executable assertion으로 부르면 안 된다.
- 하지 않는 일
- query exit 0과 이 결과값의 자동 비교는 별도 runner 책임이다. 동결 경계: psql 변수 workbook_schema가 필요하다
- 다음 연결
- 실행 evidence는 actual row set과 이 고정 oracle을 함께 보존해야 한다.
구체적 반례히토리 → 니지카 → 료 → 키타
-
히토리
관찰값 하나가 맞으면 전체 계약도 안전한가요?
-
니지카
아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.
-
료
계좌가 counterparty_account_id에만 나타나는 성공 거래는 이 subquery의 후보가 아니므로 ‘모든 참여 거래의 최신 행’은 보장하지 않아.
-
키타
직접 증명과 반례를 나누어 기록하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | account 8행 | account를 바깥 grain으로 LEFT LATERAL을 실행한다. | 성공 거래가 없는 103·104도 결과 후보에 남는다. | counterparty_account_id로 들어온 거래는 포함하지 않는다 |
| 2 | account101의 같은 시각 tx ...211/...212 | occurred_at DESC, tx_id DESC로 정렬한다. | tx ...212가 첫 후보가 된다. | OPENING·REVERSAL을 성공 거래로 포함한다 |
| 3 | 정렬된 account별 후보 | LIMIT 1을 적용한다. | 각 account의 latest row가 최대 한 행이 된다. | prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다 |
| 4 | 후보 없는 account103·104 | LEFT lateral 결과를 NULL-extend한다. | latest column들이 NULL인 account 행이 출력된다. | psql 변수 workbook_schema가 필요하다 |
전체 복원히토리 → 니지카 → 료 → 키타
-
히토리
전체 source가 길거나 한 줄이 길면 외워야 하나요?
-
니지카
의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.
-
료
마지막에는 ‘tie=occurred_at,tx_id’ 관찰값과 source SHA를 함께 대조해.
-
키타
설명→chunk→전체 source로 복원할게요.
STEP 09 / 13
Java·Spring·MDC·DB 내부에서 벌어지는 일
Java·Spring·MDC·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
workbook schema의 account와 business_tx를 해석한다.
counterparty_account_id로 들어온 거래는 포함하지 않는다거래 유무와 무관하게 account 8행을 기준으로 보존한다.
OPENING·REVERSAL을 성공 거래로 포함한다현재 a.account_id의 direct SUCCESS 거래만 후보로 읽는다.
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다occurred_at DESC 뒤 tx_id DESC로 동률을 깨고 LIMIT 1을 적용한다.
psql 변수 workbook_schema가 필요하다후보 없는 account103·104의 latest column을 NULL로 채운다.
shipped workbook 정답이 아니다STEP 10 / 13
흔한 착각과 틀린 예
그럴듯하지만 틀린 해석을 반례로 고칩니다.
❌ 이 SQL은 Q30의 shipped workbook 정답이다.
왜 틀리나 첫 주석과 source audit가 비정본 illustrative example로 분류한다.
바르게 읽기 direct account·zero-row·type 포함 가정을 붙인 학습 예시로 표시해야 한다.
반례 counterparty 참여를 포함하는 요구라면 다른 correlation 조건이 필요한 별도 답이 된다.
❌ latest 후보에는 counterparty_account_id로 참여한 성공 거래도 포함된다.
왜 틀리나 WHERE 절은 t.account_id = a.account_id만 비교한다.
바르게 읽기 counterparty 참여를 원하면 양쪽 역할을 포함하는 명시적 정책과 query가 필요하다.
반례 어떤 계좌가 성공 거래의 counterparty에만 나타나면 그 row는 현재 lateral 후보가 아니다.
❌ LIMIT 1만 있으면 동률에서도 deterministic latest row가 된다.
왜 틀리나 동일 occurred_at 후보의 순서는 tie-breaker 없이는 total order가 아니다.
바르게 읽기 occurred_at DESC 뒤 tx_id DESC 같은 안정 순서를 유지해야 한다.
반례 account101의 ...211과 ...212는 시각이 같아 tx_id 정렬을 빼면 선택이 고정되지 않는다.
❌ LEFT LATERAL과 zero-row account 보존은 prompt가 명시한 유일한 계약이다.
왜 틀리나 prompt는 성공 거래가 없는 account를 포함할지 고정하지 않는다.
바르게 읽기 LEFT 선택을 가정으로 표시하고 cardinality 요구가 달라지면 INNER 대안을 검토해야 한다.
반례 결과를 ‘성공 거래가 있는 계좌만’으로 정의하면 103·104를 제외하는 query도 가능하다.
❌ status=SUCCESS이면 OPENING·REVERSAL은 latest 후보에서 제외된다.
왜 틀리나 query에는 tx_type 조건이 없어 SUCCESS인 OPENING·REVERSAL도 후보가 된다.
바르게 읽기 type 포함 여부를 업무 정의로 분리하고 현재 예시 가정을 명시해야 한다.
반례 account102·106·107·108은 현재 fixture에서 SUCCESS OPENING row를 선택한다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
counterparty_account_id로 들어온 거래는 포함하지 않는다
이 책임을 맡는 곳: workbook requirement ownerOPENING·REVERSAL을 성공 거래로 포함한다
이 책임을 맡는 곳: business reporting definitionprompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
이 책임을 맡는 곳: result-cardinality requirementpsql 변수 workbook_schema가 필요하다
이 책임을 맡는 곳: workbook runner and schema bindingshipped workbook 정답이 아니다
이 책임을 맡는 곳: learner-owned solution and reviewerSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.를 고정값과 미보장 경계까지 말한다.
2단계 · 코드 조각 재조립
- provenance assumptions and schema
- account output grain
- stable latest-row lateral lookup
- pinned fixture oracle
3단계 · 파일 전체 다시 쓰기
23개 물리 줄을 원본 순서로 복원하고 SHA-256 1727bfc9d381e1629a6f1d86c5cd4134f93198583fc06b857d5409b72251e178와 대조한다.
자가 점검
- 비정본 예시·가정 주석을 유지한다.
- direct account_id와 SUCCESS만 세는 가정을 말한다.
- account에서 시작하는 LEFT LATERAL로 zero-row account를 보존한다.
- occurred_at DESC, tx_id DESC 동률 순서를 확인한다.
- account101·103·104·105 oracle을 대조한다.
STEP 13 / 13
전체 원본 source
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- W19-SQL-Q30 illustrative example; not a shipped workbook answer.
-- Assumption: only direct business_tx.account_id and status=SUCCESS count.
-- Accounts without a successful row remain; ties use occurred_at DESC, tx_id DESC.
SET search_path TO :"workbook_schema", public;
SELECT
a.account_id,
latest.tx_id,
latest.amount,
latest.occurred_at
FROM account AS a
LEFT JOIN LATERAL (
SELECT t.tx_id, t.amount, t.occurred_at
FROM business_tx AS t
WHERE t.account_id = a.account_id
AND t.status = 'SUCCESS'
ORDER BY t.occurred_at DESC, t.tx_id DESC
LIMIT 1
) AS latest ON TRUE
ORDER BY a.account_id;
-- Oracle: account101 chooses tx ...212 over ...211 at the same timestamp.
-- account103 and account104 return NULL; account105 chooses ...210.
-- account102/106/107/108 choose their SUCCESS OPENING rows under this assumption.