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에 정답이 배포되지 않아 가정을 표시한 비정본 예시로만 제공합니다.

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

RequestIdFilter.java — 의도적으로 실패하는 requestId starter

learning_stages/w19/production/starter/src/main/java/com/example/financialcore/api/RequestIdFilter.java

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

STEP 01 / 13

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

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

오늘의 한 문장

의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.

  1. HEADER=X-Request-Id은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값HEADER=X-Request-Idraw header reflectedMDC absentcurrent fallback=unavailable
02

STEP 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 사실을 대신하지 않는다.

03

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을 구현하지 않는다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.

  3. 첫 관찰값은 ‘HEADER=X-Request-Id’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 실패를 먼저 드러내는 intentional Red starter야. F06의 Green solution과 역할을 섞으면 안 돼.

  3. raw header 복사는 관찰되지만 whitelist·UUID fallback·MDC lifecycle은 구현되어 있지 않아.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · intentional Red starter에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.1 / 1 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
3줄F01-L03 @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를 선언한다.
입력
검증되지 않은 X-Request-Id header와 빈 request attribute·response header 상태를 받는다.
결과·효과
raw header가 attribute와 response로 복사되지만 MDC·검증·fallback 상태는 생기지 않는다.
비유의 한계
MDC lifecycle을 구현하지 않는다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    X-Request-Id 원문이 request attribute와 response header로 그대로 복사되고, 이 filter가 MDC에는 값을 넣지 않는 순서를 따라가.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F01-C01 · package1–1줄
1–1줄 원본
package com.example.financialcore.api;
F01-C02 · compact imports2–2줄
2–2줄 원본
import jakarta.servlet.*;import jakarta.servlet.http.*;import java.io.IOException;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;
F01-C03 · intentionally incomplete filter3–3줄
3–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();}}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 3 / 3

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

준비·설명 줄 2개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.api;이 class가 속한 Java package namespace를 compiler에 알려 준다.
2import 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를 선언한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다. 다만 starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다

문법 해부

  • 한 줄에 여러 Java 문장이 있어도 semicolon·brace 순서와 bytes를 보존한다.
  • OncePerRequestFilter는 request당 한 번 실행될 extension point다.
  • starter에는 whitelist·UUID·MDC·finally가 의도적으로 없다.

실행 순서

  1. raw header 읽기
  2. attribute 복사
  3. response 반사
  4. 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의 첫 실패 위치를 관찰한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 공백과 newline이 든 header도 그대로 반사되므로 unsafe replacement test에서 UUID regex가 실패해.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1X-Request-Id=raw 또는 nullgetHeader가 외부 값을 검증 없이 읽는다.local id는 raw 또는 null 그대로다.starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
2local idsetAttribute와 setHeader가 같은 값을 복사한다.request와 response가 검증되지 않은 값을 반사한다.null·공백·개행·64자 경계를 검증하지 않는다
3filter chainMDC.put 없이 downstream을 호출한다.chain 안 log context에는 requestId가 생기지 않는다.MDC lifecycle을 구현하지 않는다
4attribute가 null인 requestcurrent helper가 attribute를 읽는다.value==null이면 unavailable을 반환한다.requestId는 인증 token이 아니다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘current fallback=unavailable’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

Java compiler

압축된 한 줄의 field·method·brace를 원래 Java 실행 순서로 compile한다.

starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다
Spring component scan

@Component class를 filter bean 후보로 등록한다.

null·공백·개행·64자 경계를 검증하지 않는다
OncePerRequestFilter

한 request에서 doFilterInternal을 호출하지만 외부 header를 검증하지 않는다.

MDC lifecycle을 구현하지 않는다
HTTP request/response

raw 또는 null ID를 attribute와 response header로 그대로 복사한다.

requestId는 인증 token이 아니다
10

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에 다른 사용자의 식별자처럼 보이는 안전 문자열을 넣어도 권한이 생겨서는 안 된다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

artifact role

starter는 실패해야 하는 학습 입력이지 Green 구현이 아니다

이 책임을 맡는 곳: curriculum/source labeling
external ID validation

null·공백·개행·64자 경계를 검증하지 않는다

이 책임을 맡는 곳: request filter policy and boundary tests
log-context lifecycle

MDC lifecycle을 구현하지 않는다

이 책임을 맡는 곳: MDC propagation and cleanup implementation
authorization

requestId는 인증 token이 아니다

이 책임을 맡는 곳: Spring Security identity and access policy
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

의도적으로 불완전한 Red RequestIdFilter가 외부 header를 검증·생성·MDC 정리 없이 그대로 반사하는 출발점을 보여준다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. package
  2. compact imports
  3. intentionally incomplete filter

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

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

자가 점검
  • intentional Red starter를 Green 해법으로 부르지 않는다.
  • raw header 반사와 request attribute·response header 복사를 찾는다.
  • 검증·UUID·MDC·finally가 없음을 말한다.
  • 560자 한 줄의 bytes와 문장 순서를 바꾸지 않는다.
  • requestId를 인증·권한 근거로 쓰지 않는다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · intentional Red starterlearning_stages/w19/production/starter/src/main/java/com/example/financialcore/api/RequestIdFilter.javaSHA-256 911230c739bb18ae706cae3f154bb74e10602b490ad63114d92eb6ec282522c9
RequestIdFilter.java — 의도적으로 실패하는 requestId starter 전체
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();}}
02

SensitiveDataMasker.java — 원문을 노출하는 masking starter

learning_stages/w19/production/starter/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java

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

STEP 01 / 13

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

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

오늘의 한 문장

의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.

  1. accountNumber(raw)=raw은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. starter는 정답이 아니다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값accountNumber(raw)=rawresourceId=type:rawcomponent bean
02

STEP 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를 그대로 반환한다.

03

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가 필요하다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.

  3. 첫 관찰값은 ‘accountNumber(raw)=raw’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 masking 실패를 재현하는 intentional Red starter야. class 이름만 보고 안전한 구현으로 분류하면 안 돼.

  3. accountNumber는 raw를 그대로 반환하고 resourceId도 type:raw를 조립할 뿐이야.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · intentional Red starter에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.1 / 1 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
3줄F02-L03 @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를 선언한다.
입력
masking되지 않은 raw 식별자와 caller가 준 resource type을 받는다.
결과·효과
호출자가 준 식별자가 masking 없이 그대로 반환될 수 있는 Red 관찰값이 생긴다.
비유의 한계
type도 신뢰 입력이면 별도 sanitize가 필요하다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    123-456-7890과 ACCOUNT/123456이 각각 원문과 ACCOUNT:123456으로 노출되는 두 return 경로를 따라가.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F02-C01 · package1–1줄
1–1줄 원본
package com.example.financialcore.security;
F02-C02 · component import2–2줄
2–2줄 원본
import org.springframework.stereotype.Component;
F02-C03 · intentionally raw masker3–3줄
3–3줄 원본
@Component public final class SensitiveDataMasker{public String accountNumber(String raw){return raw;}public String resourceId(String type,String raw){return type+":"+raw;}}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 3 / 3

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

준비·설명 줄 2개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
2import 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를 선언한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다. 다만 starter는 정답이 아니다

문법 해부

  • 두 public method가 각각 raw와 type:raw를 그대로 반환한다.
  • @Component는 bean 등록일 뿐 masking 안전성을 보장하지 않는다.
  • starter의 한 줄 구현을 final solution과 역할별로 분리한다.

실행 순서

  1. raw 입력
  2. 원문 반환
  3. 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 동작을 거절한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 123-456-7890을 넣으면 ******7890이 아니라 원문이 나와 첫 exact masking assertion에서 실패해.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1123-456-7890accountNumber가 별도 처리 없이 return raw를 실행한다.원문 123-456-7890이 그대로 노출된다.starter는 정답이 아니다
2ACCOUNT, 123456resourceId가 type + colon + raw를 조립한다.ACCOUNT:123456이 masking 없이 나온다.null·blank·구두점·Unicode를 다루지 않는다
3@Component classSpring component scan이 bean을 등록한다.주입 가능해질 뿐 반환값의 안전성은 바뀌지 않는다.type도 신뢰 입력이면 별도 sanitize가 필요하다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘component bean’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

Java compiler

두 return statement를 raw pass-through method로 compile한다.

starter는 정답이 아니다
Spring component scan

masker bean을 등록하지만 반환 정책을 검증하지 않는다.

null·blank·구두점·Unicode를 다루지 않는다
accountNumber call

nullable raw reference를 변환 없이 caller에게 반환한다.

type도 신뢰 입력이면 별도 sanitize가 필요하다
resourceId call

type:raw 문자열을 조립해 민감 식별자를 노출한다.

마스킹은 암호화나 접근통제가 아니다
10

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를 읽을 수 있다면 보안은 깨진다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

artifact role

starter는 정답이 아니다

이 책임을 맡는 곳: curriculum/source labeling
input normalization

null·blank·구두점·Unicode를 다루지 않는다

이 책임을 맡는 곳: masker policy and parameterized boundary tests
resource type trust

type도 신뢰 입력이면 별도 sanitize가 필요하다

이 책임을 맡는 곳: caller validation or dedicated type sanitizer
cryptographic protection

마스킹은 암호화나 접근통제가 아니다

이 책임을 맡는 곳: privacy architecture and key-management design
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

의도적으로 원문을 그대로 반환하는 Red SensitiveDataMasker가 민감 계좌·resource ID를 노출하는 실패 출발점을 만든다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. package
  2. component import
  3. intentionally raw masker

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

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

자가 점검
  • intentional Red starter를 final masking 해법으로 부르지 않는다.
  • accountNumber가 raw를 그대로 반환함을 찾는다.
  • resourceId가 type:raw를 노출함을 찾는다.
  • null·blank·Unicode·짧은 값 경계가 미구현임을 말한다.
  • masking과 암호화를 구분한다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · intentional Red starterlearning_stages/w19/production/starter/src/main/java/com/example/financialcore/security/SensitiveDataMasker.javaSHA-256 0ef3b2adff1042c789f0395aebdefef378c399d25e34b19e7a84d2b213364baa
SensitiveDataMasker.java — 원문을 노출하는 masking starter 전체
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;}}
03

RequestIdFilterTest.java — 전파·반사 방지·cleanup 계약

learning_stages/w19/production/tests/src/test/java/com/example/financialcore/api/RequestIdFilterTest.java

원문 정본 · 제공 test 계약 · 정본 · W19-F03
26줄 연결33줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.

  1. req-w19-1 echoed은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. 정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값req-w19-1 echoedMDC inside=req-w19-1MDC after=nullunsafe -> UUID 36
02

STEP 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를 증명하지 않는다.

03

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를 증명하지 않는다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.

  3. 첫 관찰값은 ‘req-w19-1 echoed’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 두 입력에 대한 제공 test 계약이지 production 구현 전체가 아니야.

  3. 유효 ID의 동기 전파·정상 cleanup과 한 위험 입력의 교체만 직접 검사해. 예외 chain과 async 전파는 미검증이야.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.26 / 26 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
12줄F03-L12 class RequestIdFilterTest { 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 두 requestId 계약을 담는 JUnit unit-test fixture class를 연다.
입력
JUnit이 발견할 test class와 새 RequestIdFilter fixture 선언 상태를 받는다.
결과·효과
JUnit이 생성할 RequestIdFilterTest fixture type이 정의된다.
비유의 한계
async thread MDC 전파는 범위 밖이다
13줄F03-L13 private final RequestIdFilter filter = new RequestIdFilter(); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 각 test가 직접 호출할 대상 object를 만든다.
입력
JUnit이 발견할 test class와 새 RequestIdFilter fixture 선언 상태를 받는다.
결과·효과
두 test가 호출할 새 RequestIdFilter instance가 field에 준비된다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
15줄F03-L15 @Test 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
유효 ID 전파·cleanup case가 JUnit 발견 목록에 들어간다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
16줄F03-L16 void validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc() throws Exception { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
첫 test의 checked-exception 허용 범위와 body가 열린다.
비유의 한계
async thread MDC 전파는 범위 밖이다
17줄F03-L17 var request = new MockHttpServletRequest(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. servlet request를 흉내 내는 빈 mock request를 만든다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
header와 attribute를 담을 빈 mock request가 생긴다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
18줄F03-L18 var response = new MockHttpServletResponse(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 응답 header를 읽을 mock response를 만든다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
filter가 쓴 response header를 읽을 빈 mock response가 생긴다.
비유의 한계
미입력 header와 1·64자 경계는 없다
19줄F03-L19 request.addHeader(RequestIdFilter.HEADER, "req-w19-1"); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. test 입력 X-Request-Id를 mock request에 넣는다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
mock request의 X-Request-Id 값이 req-w19-1로 고정된다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
20줄F03-L20 var seenInside = new AtomicReference<String>(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. chain 내부 MDC 값을 assertion까지 옮길 상자를 만든다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
chain 내부 MDC를 받을 AtomicReference의 초기 값이 null로 준비된다.
비유의 한계
async thread MDC 전파는 범위 밖이다
22줄F03-L22 filter.doFilter(request, response, (req, res) -> 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 준비한 request·response·chain을 실제 filter에 통과시킨다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
mock request·response와 callback이 filter chain 실행에 전달된다.
비유의 한계
미입력 header와 1·64자 경계는 없다
23줄F03-L23 seenInside.set(MDC.get("requestId"))); 교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. chain 안의 MDC requestId를 AtomicReference에 저장한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
callback 순간 MDC requestId가 seenInside actual 값으로 포착된다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
25줄F03-L25 assertThat(response.getHeader(RequestIdFilter.HEADER)).isEqualTo("req-w19-1"); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 응답 X-Request-Id가 req-w19-1인지 비교한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
유효 요청의 response header actual이 req-w19-1과 같을 때 첫 oracle이 통과한다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
26줄F03-L26 assertThat(request.getAttribute(RequestIdFilter.ATTRIBUTE)).isEqualTo("req-w19-1"); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. request attribute에 저장된 값이 req-w19-1인지 비교한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
같은 request의 ATTRIBUTE actual이 req-w19-1과 같을 때 두 번째 oracle이 통과한다.
비유의 한계
미입력 header와 1·64자 경계는 없다
27줄F03-L27 assertThat(seenInside).hasValue("req-w19-1"); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. chain 내부에서 포착한 MDC 값이 req-w19-1인지 비교한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
chain 내부 AtomicReference actual이 req-w19-1일 때 MDC 전파 oracle이 통과한다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
28줄F03-L28 assertThat(MDC.get("requestId")).isNull(); 교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. filter 반환 뒤 MDC requestId가 null인지 비교한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
filter 반환 뒤 MDC actual이 null일 때 cleanup oracle이 통과한다.
비유의 한계
async thread MDC 전파는 범위 밖이다
29줄F03-L29 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
req-w19-1 header가 든 mock request·response와 비어 있는 MDC 관찰 상자를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
31줄F03-L31 @Test 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
위험 ID 반사 방지 case가 JUnit 발견 목록에 들어간다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
32줄F03-L32 void unsafeRequestIdIsReplacedInsteadOfReflected() throws Exception { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
두 번째 test의 checked-exception 허용 범위와 body가 열린다.
비유의 한계
async thread MDC 전파는 범위 밖이다
33줄F03-L33 var request = new MockHttpServletRequest(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. servlet request를 흉내 내는 빈 mock request를 만든다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
위험 header를 담을 새 mock request가 첫 test와 분리되어 생긴다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
34줄F03-L34 var response = new MockHttpServletResponse(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 응답 header를 읽을 mock response를 만든다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
대체 requestId를 관찰할 새 mock response가 생긴다.
비유의 한계
미입력 header와 1·64자 경계는 없다
35줄F03-L35 request.addHeader(RequestIdFilter.HEADER, "bad request id with spaces and newline\n"); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. test 입력 X-Request-Id를 mock request에 넣는다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
공백과 newline을 포함한 exact 위험 header가 request에 저장된다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
36줄F03-L36 filter.doFilter(request, response, (req, res) -> {}); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 준비한 request·response·chain을 실제 filter에 통과시킨다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
빈 callback까지 filter가 끝나 응답 대체 ID를 assertion할 수 있다.
비유의 한계
async thread MDC 전파는 범위 밖이다
37줄F03-L37 assertThat(response.getHeader(RequestIdFilter.HEADER)) 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 위험 입력 처리 뒤 응답 requestId를 AssertJ actual로 잡는다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
위험 입력 뒤 response header actual이 AssertJ chain에 들어간다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
38줄F03-L38 .matches("[0-9a-f-]{36}") 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 대체 ID가 36자 소문자 hex/hyphen UUID 모양인지 검사한다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
대체 ID가 36자 UUID regex와 일치할 때 format oracle이 통과한다.
비유의 한계
미입력 header와 1·64자 경계는 없다
39줄F03-L39 .doesNotContain("bad request"); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 대체 ID에 위험 원문 bad request가 남지 않았는지 검사한다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
대체 ID에 bad request가 없을 때 raw-reflection 방지 oracle이 통과한다.
비유의 한계
UUID regex는 uniqueness나 entropy를 증명하지 않는다
40줄F03-L40 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
async thread MDC 전파는 범위 밖이다
41줄F03-L41 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
공백·newline이 든 위험 header와 빈 mock response를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    req-w19-1이 response·attribute·chain 내부 MDC에 나타난 뒤 filter 반환 후 null이 되는 네 관찰점을 순서대로 확인해.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F03-C01 · imports and fixture1–13줄
1–13줄 원본
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();
F03-C02 · valid request lifecycle test14–30줄
14–30줄 원본

    @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();
    }
F03-C03 · unsafe request replacement test31–41줄
31–41줄 원본
    @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");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 33 / 33

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

준비·설명 줄 7개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.api;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import org.junit.jupiter.api.Test;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
4import org.slf4j.MDC;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
5import org.springframework.mock.web.MockHttpServletRequest;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
6import org.springframework.mock.web.MockHttpServletResponse;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
8import java.util.concurrent.atomic.AtomicReference;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
10import static org.assertj.core.api.Assertions.assertThat;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
원본한국어 번역
12class 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·표현식 범위를 닫아 다음 범위와 분리한다.
07

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을 적용한다.

실행 순서

  1. mock 준비
  2. filter 호출
  3. chain 안 MDC 포착
  4. 전파·cleanup assertion
  5. 위험 입력 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를 만족시키는지 연결한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 정상 반환 때만 MDC를 지우는 잘못된 구현도 첫 test는 통과할 수 있으므로, chain이 예외를 던지는 별도 test가 필요해.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1req-w19-1 headerfilter에 mock request·response를 통과시킨다.response와 request attribute가 req-w19-1을 가진다.정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
2chain 실행 중AtomicReference가 MDC requestId를 포착한다.seenInside 값은 req-w19-1이다.미입력 header와 1·64자 경계는 없다
3filter 정상 반환 뒤finally cleanup 뒤 현재 thread MDC를 다시 읽는다.MDC.get(requestId)는 null이다.UUID regex는 uniqueness나 entropy를 증명하지 않는다
4공백·newline 위험 headerwhitelist 불일치 경로를 실행한다.응답은 36자 UUID이고 bad request 원문을 포함하지 않는다.async thread MDC 전파는 범위 밖이다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘unsafe -> UUID 36’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

JUnit discovery

두 @Test method를 별도 case로 발견한다.

정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다
mock servlet fixture

header 입력과 response·attribute 관찰 저장소를 메모리에서 제공한다.

미입력 header와 1·64자 경계는 없다
filter callback

chain 안 같은 thread에서 MDC requestId를 AtomicReference에 포착한다.

UUID regex는 uniqueness나 entropy를 증명하지 않는다
AssertJ oracle

응답·attribute·MDC·UUID 형식·원문 비포함을 각각 비교한다.

async thread MDC 전파는 범위 밖이다
thread-local MDC

filter 반환 뒤 현재 thread key가 null인지 관찰한다.

정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다

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

validRequestIdIsReturnedStoredForTheRequestAndRemovedFromMdc

Arrange · 준비
  • Mock request/response와 X-Request-Id=req-w19-1을 준비한다.
  • chain 안 MDC 값을 받을 AtomicReference를 만든다.
Act · 행동
  • RequestIdFilter.doFilter를 한 번 호출한다.
Assert · 확인
  • 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

Arrange · 준비
  • 공백과 newline이 든 위험한 외부 header를 준비한다.
Act · 행동
  • 빈 filter chain으로 RequestIdFilter를 통과시킨다.
Assert · 확인
  • 응답 ID가 36자 소문자 hex/hyphen UUID 모양이다.
  • 응답에 bad request 원문이 없다.
직접 보장
  • 현재 위험 입력이 그대로 반사되지 않음
  • fallback 문자열의 모양
보장하지 않음
  • UUID 고유성·entropy
  • header 없음·빈 문자열·64자 경계 전부
  • log injection의 모든 변형

첫 실패 경계 starter는 위험 header를 그대로 반사하므로 UUID regex assertion에서 처음 깨진다.

10

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가 직접 실행하지 않는다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

exception cleanup

정상 경로 cleanup만 검사하고 chain 예외 경로는 직접 검사하지 않는다

이 책임을 맡는 곳: RequestIdFilter exception-path test
missing/length input

미입력 header와 1·64자 경계는 없다

이 책임을 맡는 곳: requestId boundary parameterized tests
UUID properties

UUID regex는 uniqueness나 entropy를 증명하지 않는다

이 책임을 맡는 곳: ID generator uniqueness/entropy tests
async context

async thread MDC 전파는 범위 밖이다

이 책임을 맡는 곳: async dispatch and context-propagation integration tests
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

유효 requestId의 response·request attribute·filter 내부 MDC 일치와 종료 후 cleanup, 위험 ID의 UUID 교체를 고정하는 제공 test 계약이다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. imports and fixture
  2. valid request lifecycle test
  3. 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으로 번역한다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w19/production/tests/src/test/java/com/example/financialcore/api/RequestIdFilterTest.javaSHA-256 51738a6110b89c5993bf21512a3860c6b3cb2fe6f8405d8972fc40006c254da8
RequestIdFilterTest.java — 전파·반사 방지·cleanup 계약 전체
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");
    }
}
04

AuditPropagationIT.java — 거절 감사 이벤트 통합 계약

learning_stages/w19/production/tests/src/test/java/com/example/financialcore/audit/AuditPropagationIT.java

원문 정본 · 제공 test 계약 · 정본 · W19-F04
30줄 연결46줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.

  1. actor=customer-2은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. Testcontainers PostgreSQL과 전체 Spring context가 필요하다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값actor=customer-2status=403events=1result=DENIEDerror=ACCESS_DENIED
02

STEP 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·보안 운영정책을 대신하지 않는다.

03

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은 검사하지 않는다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.

  3. 첫 관찰값은 ‘actor=customer-2’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 Spring·MockMvc·PostgreSQL을 잇는 제공 integration-test 계약이야. runtime 구현 자체와 구분해야 해.

  3. 이 한 case는 403과 같은 requestId의 DENIED 한 행만 증명해. SUCCESS·중복 요청·audit 장애는 다루지 않아.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.30 / 30 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
20줄F04-L20 @SpringBootTest 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 실제 Spring application context를 띄우는 통합 test임을 선언한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
전체 Spring application context를 사용하는 integration test bootstrap이 선택된다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
21줄F04-L21 @AutoConfigureMockMvc 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. servlet container 없이 HTTP 흐름을 검증할 MockMvc를 구성한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
MockMvc request 수행에 필요한 MVC test infrastructure가 자동 구성된다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
22줄F04-L22 @ActiveProfiles("test") 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. test profile의 설정과 datasource를 사용하도록 고정한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
test profile의 PostgreSQL·security 설정이 활성화된다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
23줄F04-L23 class AuditPropagationIT extends PostgresIntegrationTestSupport { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. PostgreSQL support를 상속한 denied-audit 통합 test fixture class를 연다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
PostgresIntegrationTestSupport를 상속한 AuditPropagationIT type이 정의된다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
24줄F04-L24 @Autowired MockMvc mvc; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring context의 integration collaborator를 test field에 주입한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
실제 security/MVC chain을 호출할 MockMvc field가 주입된다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
25줄F04-L25 @Autowired JdbcClient jdbc; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring context의 integration collaborator를 test field에 주입한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
TRUNCATE native SQL을 실행할 JdbcClient field가 주입된다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
26줄F04-L26 @Autowired AccountOpeningService openings; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring context의 integration collaborator를 test field에 주입한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
소유 계좌 fixture를 만들 AccountOpeningService field가 주입된다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
27줄F04-L27 @Autowired AuditEventRepository audit; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring context의 integration collaborator를 test field에 주입한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
requestId로 audit row를 조회할 repository field가 주입된다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
29줄F04-L29 @BeforeEach 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 각 test 전에 DB 정리 method가 실행되도록 등록한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
clean method가 각 test 직전 hook으로 등록된다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
30줄F04-L30 void clean() { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
DB 초기화를 소유하는 clean method body가 열린다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
31줄F04-L31 jdbc.sql("TRUNCATE audit_event,idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
다섯 table을 CASCADE truncate하고 identity를 재시작할 SQL이 준비된다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
32줄F04-L32 .update(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 다음 test가 빈 DB에서 시작하도록 관련 table과 identity를 초기화한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
TRUNCATE update가 실행되어 다음 audit size oracle의 출발 row 수가 0이 된다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
33줄F04-L33 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
Spring test context의 MockMvc·JdbcClient·opening service·audit repository와 이전 DB 상태를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
35줄F04-L35 @Test 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
denied audit propagation case가 JUnit 발견 목록에 들어간다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
36줄F04-L36 void deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId() throws Exception { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
403부터 DB assertion까지 한 integration-test method body가 열린다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
37줄F04-L37 Account account = openings.open("customer-1", "AUDIT-OWNER", 5_000); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. customer-1 소유 계좌와 초기 잔액 5000을 DB에 만든다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
customer-1 소유 AUDIT-OWNER 계좌와 초기잔액 5000이 DB에 생긴다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
38줄F04-L38 mvc.perform(get("/api/accounts/{id}", account.getId()) 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
방금 만든 account ID를 경로 변수로 넣은 GET request builder가 생긴다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
39줄F04-L39 .with(httpBasic("customer-2", "password")) 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
GET request principal이 customer-2/password로 인증된다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
40줄F04-L40 .header("X-Request-Id", "audit-denied-1")) 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. customer-2가 다른 고객 계좌를 requestId와 함께 조회하는 요청과 403 oracle을 조립한다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
같은 request에 X-Request-Id=audit-denied-1이 붙는다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
41줄F04-L41 .andExpect(status().isForbidden()); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 다른 소유자의 계좌 요청이 HTTP 403 Forbidden인지 검사한다.
입력
customer-1 계좌, customer-2 인증, audit-denied-1 header가 준비된 HTTP 시나리오를 받는다.
결과·효과
cross-owner GET actual status가 403일 때 HTTP 거절 oracle이 통과한다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
43줄F04-L43 var events = audit.findByRequestIdOrderById("audit-denied-1"); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 거절 뒤 같은 requestId로 저장된 audit_event 목록을 읽는다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
audit-denied-1로 조회한 event list가 후속 field oracle의 actual이 된다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
44줄F04-L44 assertThat(events).hasSize(1); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. 같은 requestId의 audit_event가 정확히 한 행인지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
event list size가 1일 때 중복·누락 방지 oracle이 통과한다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
45줄F04-L45 assertThat(events.getFirst().getActorId()).isEqualTo("customer-2"); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. audit actor가 요청한 customer-2인지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
첫 row actor가 customer-2일 때 principal 전파 oracle이 통과한다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
46줄F04-L46 assertThat(events.getFirst().getResult()).isEqualTo("DENIED"); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. audit result가 DENIED인지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
첫 row result가 DENIED일 때 거절 분류 oracle이 통과한다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
47줄F04-L47 assertThat(events.getFirst().getErrorCode()).isEqualTo("ACCESS_DENIED"); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. audit error code가 ACCESS_DENIED인지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
첫 row errorCode가 ACCESS_DENIED일 때 오류 분류 oracle이 통과한다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
48줄F04-L48 assertThat(events.getFirst().getMaskedResourceId()) 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 첫 audit row의 masked resource ID를 AssertJ actual로 잡는다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
첫 row maskedResourceId가 두 masking assertion의 actual이 된다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
49줄F04-L49 .startsWith("ACCOUNT:") 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. masked resource가 ACCOUNT: prefix로 시작하는지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
resource가 ACCOUNT: prefix를 가지면 type 표시 oracle이 통과한다.
비유의 한계
Testcontainers PostgreSQL과 전체 Spring context가 필요하다
50줄F04-L50 .isNotEqualTo("ACCOUNT:" + account.getId()); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. masked resource 전체가 ACCOUNT:raw-account-id와 같지 않은지 검사한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
resource 전체가 ACCOUNT:raw-id와 다르면 최소 비동일성 oracle이 통과한다.
비유의 한계
clean은 다섯 table을 CASCADE TRUNCATE한다
51줄F04-L51 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
52줄F04-L52 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
403 처리 뒤 audit-denied-1로 조회한 audit_event 목록을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    customer-2의 타인 계좌 GET이 403이 된 뒤, audit-denied-1로 조회한 row의 actor·result·error·mask를 확인해.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F04-C01 · imports annotations and integration class1–23줄
1–23줄 원본
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 {
F04-C02 · injected collaborators and deterministic cleanup24–33줄
24–33줄 원본
    @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();
    }
F04-C03 · cross-owner request and forbidden response34–42줄
34–42줄 원본

    @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());
F04-C04 · same-request audit and masked resource oracle43–52줄
43–52줄 원본
        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());
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 46 / 46

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

준비·설명 줄 16개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.audit;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import com.example.financialcore.PostgresIntegrationTestSupport;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
4import com.example.financialcore.account.Account;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
5import com.example.financialcore.account.AccountOpeningService;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
6import org.junit.jupiter.api.BeforeEach;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
7import org.junit.jupiter.api.Test;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
8import org.springframework.beans.factory.annotation.Autowired;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
9import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
10import org.springframework.boot.test.context.SpringBootTest;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
11import org.springframework.jdbc.core.simple.JdbcClient;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
12import org.springframework.test.context.ActiveProfiles;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
13import org.springframework.test.web.servlet.MockMvc;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
15import static org.assertj.core.api.Assertions.assertThat;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
16import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
원본한국어 번역
20@SpringBootTest실제 Spring application context를 띄우는 통합 test임을 선언한다.
21@AutoConfigureMockMvcservlet container 없이 HTTP 흐름을 검증할 MockMvc를 구성한다.
22@ActiveProfiles("test")test profile의 설정과 datasource를 사용하도록 고정한다.
23class 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·표현식 범위를 닫아 다음 범위와 분리한다.
07

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한다.

실행 순서

  1. DB clean
  2. 계좌 생성
  3. 타인 인증 GET
  4. 403 확인
  5. audit 조회
  6. 필드·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을 함께 비교한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. listener가 빠져도 HTTP 403은 나올 수 있지만 events는 0행이므로, status 하나만으로 audit persistence를 증명할 수 없어.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1customer-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한다
3requestId=audit-denied-1repository로 audit_event를 조회한다.events 목록은 정확히 한 행이다.success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
4첫 audit rowactor·result·error field를 각각 읽는다.customer-2 / DENIED / ACCESS_DENIED가 나온다.masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
5첫 row maskedResourceIdACCOUNT: prefix와 raw 전체 불일치를 검사한다.ACCOUNT:로 시작하지만 ACCOUNT:raw-id와 같지 않다.Testcontainers PostgreSQL과 전체 Spring context가 필요하다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘error=ACCESS_DENIED’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

JUnit/Spring context

test profile과 실제 application bean·PostgreSQL support를 구성한다.

Testcontainers PostgreSQL과 전체 Spring context가 필요하다
MockMvc/security chain

customer-2의 타인 계좌 GET을 인증·인가 filter에 통과시켜 403을 만든다.

clean은 다섯 table을 CASCADE TRUNCATE한다
denied listener transaction

거절 event를 request 처리 실패와 분리된 audit 저장 경로로 넘긴다.

success audit·중복 denied·audit 실패 transaction은 검사하지 않는다
PostgreSQL audit_event

requestId로 조회할 actor·result·error·masked resource row를 보존한다.

masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다
AssertJ oracle

한 행과 다섯 관찰 조건을 순서대로 검사한다.

Testcontainers PostgreSQL과 전체 Spring context가 필요하다

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

deniedActionSurvivesTheRejectedRequestAndCarriesTheSameRequestId

Arrange · 준비
  • PostgreSQL 관련 table을 비우고 customer-1 계좌를 연다.
  • customer-2 인증과 audit-denied-1 requestId를 준비한다.
Act · 행동
  • 다른 고객의 계좌 GET을 MockMvc로 호출한다.
  • 같은 requestId의 audit_event를 다시 읽는다.
Assert · 확인
  • 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에서 드러난다.

10

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와 거래 원장이 모두 삭제될 수 있다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

test environment

Testcontainers PostgreSQL과 전체 Spring context가 필요하다

이 책임을 맡는 곳: Testcontainers PostgreSQL and Spring context owner
destructive isolation

clean은 다섯 table을 CASCADE TRUNCATE한다

이 책임을 맡는 곳: integration-test database lifecycle owner
audit scenario coverage

success audit·중복 denied·audit 실패 transaction은 검사하지 않는다

이 책임을 맡는 곳: SUCCESS·FAILURE·transaction-boundary integration tests
mask assertion strength

masked 값이 원문과 다르다는 것만 보고 정확한 포맷은 고정하지 않는다

이 책임을 맡는 곳: privacy oracle that rejects any raw-ID containment
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

타인 계좌 조회 403 뒤 denied audit 한 행이 같은 requestId·actor·error와 masked resource를 보존하는 PostgreSQL 통합 test다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. imports annotations and integration class
  2. injected collaborators and deterministic cleanup
  3. cross-owner request and forbidden response
  4. 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가 증명하지 않는다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w19/production/tests/src/test/java/com/example/financialcore/audit/AuditPropagationIT.javaSHA-256 644ff0604fb12a8d9290a0fac6e475e10a7858d576edd76ee51992dd5d139b83
AuditPropagationIT.java — 거절 감사 이벤트 통합 계약 전체
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());
    }
}
05

MaskingTest.java — 계좌번호·resource ID 마스킹 계약

learning_stages/w19/production/tests/src/test/java/com/example/financialcore/security/MaskingTest.java

원문 정본 · 제공 test 계약 · 정본 · W19-F05
16줄 연결19줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.

  1. 123-456-7890 -> ******7890은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. 마지막 네 글자는 그대로 노출된다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값123-456-7890 -> ******7890null -> ***12 -> ****12ACCOUNT/123456 -> ACCOUNT:****3456
02

STEP 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를 시험한 것으로 만들지 않는다.

03

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의 개행·민감값은 검사하지 않는다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.

  3. 첫 관찰값은 ‘123-456-7890 -> ******7890’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 네 실제 입력의 문자열 계약을 고정하는 plain unit test야. 모든 masking 입력군을 포괄하지는 않아.

  3. exact 문자열은 긴 숫자·null·12·ACCOUNT/123456에만 적용돼. 실제 blank·Unicode·악의적 type은 미검증이야.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · 제공 test 계약에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.16 / 16 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
7줄F05-L07 class MaskingTest { 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. masking 문자열 계약 두 개를 담는 JUnit unit-test fixture class를 연다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
두 문자열 계약을 담을 MaskingTest fixture type이 정의된다.
비유의 한계
resource type의 개행·민감값은 검사하지 않는다
8줄F05-L08 private final SensitiveDataMasker masker = new SensitiveDataMasker(); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 각 test가 직접 호출할 대상 object를 만든다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
Spring 없이 직접 호출할 새 SensitiveDataMasker instance가 준비된다.
비유의 한계
마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
10줄F05-L10 @Test 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
긴 account number masking case가 JUnit 발견 목록에 들어간다.
비유의 한계
Unicode·구두점-only·blank 문자열은 검사하지 않는다
11줄F05-L11 void accountNumberKeepsAtMostTheLastFourCharacters() { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
긴 번호의 exact mask와 raw-prefix 부재를 검사할 method body가 열린다.
비유의 한계
resource type의 개행·민감값은 검사하지 않는다
12줄F05-L12 assertThat(masker.accountNumber("123-456-7890")) 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 123-456-7890의 accountNumber 결과를 AssertJ actual로 잡는다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
긴 accountNumber 반환값이 두 AssertJ oracle의 actual이 된다.
비유의 한계
마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
13줄F05-L13 .isEqualTo("******7890") 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. 긴 번호 결과가 정확히 ******7890인지 검사한다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
actual이 ******7890일 때 별표·suffix exact oracle이 통과한다.
비유의 한계
마지막 네 글자는 그대로 노출된다
14줄F05-L14 .doesNotContain("123456"); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 결과에 연속 원문 123456이 없는지 검사한다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
actual에 123456이 없을 때 앞쪽 원문 비포함 oracle이 통과한다.
비유의 한계
Unicode·구두점-only·blank 문자열은 검사하지 않는다
15줄F05-L15 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
새 SensitiveDataMasker와 123-456-7890 입력을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
resource type의 개행·민감값은 검사하지 않는다
17줄F05-L17 @Test 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 바로 다음 method를 JUnit test case로 발견하게 표시한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
null·짧은 값·resource ID 경계 case가 JUnit 발견 목록에 들어간다.
비유의 한계
마지막 네 글자는 그대로 노출된다
18줄F05-L18 void blankAndShortValuesStillHaveAVisibleMaskBoundary() { 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
세 경계 입력을 연속 호출할 두 번째 test body가 열린다.
비유의 한계
Unicode·구두점-only·blank 문자열은 검사하지 않는다
19줄F05-L19 assertThat(masker.accountNumber(null)).isEqualTo("***"); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. null 입력 결과가 정확히 ***인지 검사한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
null 반환값이 ***와 같을 때 fallback oracle이 통과한다.
비유의 한계
resource type의 개행·민감값은 검사하지 않는다
20줄F05-L20 assertThat(masker.accountNumber("12")).isEqualTo("****12"); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 짧은 12의 결과가 정확히 ****12인지 검사한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
12 반환값이 ****12와 같을 때 최소 별표 oracle이 통과한다.
비유의 한계
마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
21줄F05-L21 assertThat(masker.resourceId("ACCOUNT", "123456")) 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. ACCOUNT/123456 resourceId 결과를 AssertJ actual로 잡는다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
ACCOUNT/123456 resourceId 반환값이 final exact oracle의 actual이 된다.
비유의 한계
마지막 네 글자는 그대로 노출된다
22줄F05-L22 .isEqualTo("ACCOUNT:****3456"); 실제 칸과 기대 칸을 맞대 보고 다르면 빨간 표시를 한다. resourceId가 정확히 ACCOUNT:****3456인지 검사한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
actual이 ACCOUNT:****3456일 때 prefix+mask composition oracle이 통과한다.
비유의 한계
Unicode·구두점-only·blank 문자열은 검사하지 않는다
23줄F05-L23 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
resource type의 개행·민감값은 검사하지 않는다
24줄F05-L24 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
같은 masker와 null·12·ACCOUNT/123456 경계 입력을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    123-456-7890→******7890, null→***, 12→****12, resourceId→ACCOUNT:****3456을 각각 계산해.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F05-C01 · imports and fixture1–8줄
1–8줄 원본
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();
F05-C02 · long account mask oracle9–15줄
9–15줄 원본

    @Test
    void accountNumberKeepsAtMostTheLastFourCharacters() {
        assertThat(masker.accountNumber("123-456-7890"))
            .isEqualTo("******7890")
            .doesNotContain("123456");
    }
F05-C03 · blank short and resource ID oracles16–24줄
16–24줄 원본

    @Test
    void blankAndShortValuesStillHaveAVisibleMaskBoundary() {
        assertThat(masker.accountNumber(null)).isEqualTo("***");
        assertThat(masker.accountNumber("12")).isEqualTo("****12");
        assertThat(masker.resourceId("ACCOUNT", "123456"))
            .isEqualTo("ACCOUNT:****3456");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 19 / 19

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

준비·설명 줄 3개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import org.junit.jupiter.api.Test;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
5import static org.assertj.core.api.Assertions.assertThat;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
원본한국어 번역
7class 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·표현식 범위를 닫아 다음 범위와 분리한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다. 다만 마지막 네 글자는 그대로 노출된다

문법 해부

  • 두 @Test는 긴 번호와 null·짧은 값·resource ID 계약을 나눈다.
  • AssertJ exact 문자열과 doesNotContain은 서로 다른 노출 경계를 검사한다.
  • 이 unit test는 Spring context나 DB를 띄우지 않는다.

실행 순서

  1. masker 준비
  2. 긴 번호 호출
  3. 정확 문자열 확인
  4. null·짧은 값 호출
  5. 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·정규화·조립 줄로 각 결과를 역추적한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. method 이름에는 blank가 있지만 공백 문자열 호출은 없으므로, blank 처리에 결함이 있어도 현재 두 test만으로는 발견하지 못해.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1123-456-7890ASCII 영숫자로 정규화하고 끝 네 글자만 보인다.******7890이 되고 123456 연속 원문은 없다.마지막 네 글자는 그대로 노출된다
2nullnull guard clause를 실행한다.***를 반환한다.Unicode·구두점-only·blank 문자열은 검사하지 않는다
312visible=2, stars=4로 조립한다.****12를 반환하며 짧은 원문은 전부 남는다.resource type의 개행·민감값은 검사하지 않는다
4ACCOUNT, 123456resourceId가 type과 masked accountNumber를 합친다.ACCOUNT:****3456을 반환한다.마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘ACCOUNT/123456 -> ACCOUNT:****3456’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

JUnit discovery

긴 번호와 null·짧은 값 계약을 두 test로 발견한다.

마지막 네 글자는 그대로 노출된다
plain Java fixture

Spring context 없이 SensitiveDataMasker를 직접 생성한다.

Unicode·구두점-only·blank 문자열은 검사하지 않는다
string transformation

각 입력의 exact 반환 문자열을 실제 method call로 얻는다.

resource type의 개행·민감값은 검사하지 않는다
AssertJ oracle

별표 수·suffix·원문 일부 비포함·resource prefix를 비교한다.

마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다

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

accountNumberKeepsAtMostTheLastFourCharacters

Arrange · 준비
  • SensitiveDataMasker와 123-456-7890을 준비한다.
Act · 행동
  • accountNumber를 호출한다.
Assert · 확인
  • 결과가 정확히 ******7890이다.
  • 결과에 연속 원문 123456이 없다.
직접 보장
  • ASCII 숫자·hyphen 예제의 정규화와 끝 4자 노출
보장하지 않음
  • Unicode·공백-only·빈 문자열
  • 짧은 입력의 비노출
  • resource type sanitize

첫 실패 경계 starter는 raw를 반환하므로 exact masked 문자열 assertion에서 처음 깨진다.

blankAndShortValuesStillHaveAVisibleMaskBoundary

Arrange · 준비
  • null, 짧은 12, ACCOUNT/123456 세 입력을 준비한다.
Act · 행동
  • accountNumber 두 번과 resourceId 한 번을 호출한다.
Assert · 확인
  • null은 ***, 12는 ****12다.
  • resourceId는 ACCOUNT:****3456이다.
직접 보장
  • null fallback
  • 짧은 값의 최소 별표 4개
  • 정상 type prefix와 masker 합성
보장하지 않음
  • method 이름과 달리 실제 blank 문자열
  • 짧은 원문 전체 노출의 정책 적합성
  • 악의적 type 값

첫 실패 경계 starter는 null·짧은 값을 보호하지 않아 첫 null assertion부터 깨진다.

10

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로 차단되지 않는다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

short-value disclosure

마지막 네 글자는 그대로 노출된다

이 책임을 맡는 곳: privacy masking policy
untested input classes

Unicode·구두점-only·blank 문자열은 검사하지 않는다

이 책임을 맡는 곳: Unicode·blank·punctuation parameterized tests
resource type injection

resource type의 개행·민감값은 검사하지 않는다

이 책임을 맡는 곳: type validation or sanitizer
system-wide exposure

마스킹이 원본 시스템 보안이나 권한을 대체하지 않는다

이 책임을 맡는 곳: logging/serialization/monitoring security review
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

긴 계좌번호·null·짧은 값·resource ID에서 최소 mask 경계와 마지막 네 글자 보존을 exact 문자열로 고정하는 제공 단위 test다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. imports and fixture
  2. long account mask oracle
  3. 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는 범위 밖임을 말한다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · 제공 test 계약learning_stages/w19/production/tests/src/test/java/com/example/financialcore/security/MaskingTest.javaSHA-256 18e698134c0d8be9809dca0fe4730fe700089e8c0106a963241edb65cb3aefaa
MaskingTest.java — 계좌번호·resource ID 마스킹 계약 전체
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");
    }
}
06

RequestIdFilter.java — 검증·UUID·MDC finally 해법

learning_stages/w19/production/solution/src/main/java/com/example/financialcore/api/RequestIdFilter.java

원문 정본 · Green learner 해법 · 정본 · W19-F06
30줄 연결43줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.

  1. SAFE=[A-Za-z0-9._:-]{1,64}은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. 기존 MDC 값이 있으면 복원하지 않고 제거한다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값SAFE=[A-Za-z0-9._:-]{1,64}UUID fallbackOrder=HIGHEST_PRECEDENCEMDC remove in finally
02

STEP 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·인증 의미를 대신하지 않는다.

03

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 시점과 별도 고려가 필요하다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.

  3. 첫 관찰값은 ‘SAFE=[A-Za-z0-9._:-]{1,64}’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 Red starter를 대체하는 Green learner solution이지만, 인증·async context까지 해결하는 보안 경계는 아니야.

  3. whitelist·UUID·동기 MDC·finally cleanup은 구현하지만 기존 MDC 복원과 async 전파는 보장하지 않아.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · Green learner 해법에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.30 / 30 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
17줄F06-L17 @Component 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
RequestIdFilter가 Spring component 후보로 등록된다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
18줄F06-L18 @Order(Ordered.HIGHEST_PRECEDENCE) 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. requestId filter를 가장 높은 우선순위로 실행하게 한다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
filter ordering 값이 HIGHEST_PRECEDENCE로 고정된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
19줄F06-L19 public final class RequestIdFilter extends OncePerRequestFilter { 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. 각 request당 한 번 실행되는 final Green filter를 선언한다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
OncePerRequestFilter 기반 final class type이 compiler와 Spring에 정의된다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
20줄F06-L20 public static final String HEADER = "X-Request-Id"; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 외부와 응답에서 공통으로 쓸 requestId header 이름을 고정한다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
header 읽기와 쓰기가 공유할 상수 값이 X-Request-Id로 준비된다.
비유의 한계
requestId는 권한 근거가 아니다
21줄F06-L21 public static final String ATTRIBUTE = RequestIdFilter.class.getName() + ".requestId"; 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. downstream code가 읽을 request-scoped attribute key를 만든다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
request-scoped 저장과 조회가 공유할 attribute key가 준비된다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
22줄F06-L22 private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 허용 ID를 ASCII 영숫자와 . _ : - 조합 1~64자로 제한한다.
입력
Spring filter 등록·우선순위와 requestId 상수·whitelist를 구성할 class 상태를 받는다.
결과·효과
외부 requestId를 판정할 compiled whitelist Pattern이 준비된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
24줄F06-L24 @Override 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 부모 class의 extension point를 정확히 재정의한다고 compiler에 알린다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
doFilterInternal이 부모 extension point의 override로 compiler 검사를 받는다.
비유의 한계
requestId는 권한 근거가 아니다
25줄F06-L25 protected void doFilterInternal( 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. doFilterInternal override의 여러 줄 method signature를 시작하며 body는 29줄에서 연다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
request·response·chain 세 parameter를 받을 method signature가 시작된다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
26줄F06-L26 HttpServletRequest request, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 요청 header와 attribute를 읽고 쓸 HttpServletRequest parameter를 받는다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
현재 HttpServletRequest가 method의 첫 runtime parameter로 연결된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
27줄F06-L27 HttpServletResponse response, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 선택한 requestId를 응답 header에 기록할 HttpServletResponse parameter를 받는다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
현재 HttpServletResponse가 두 번째 runtime parameter로 연결된다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
28줄F06-L28 FilterChain chain 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. requestId 전파 뒤 호출할 나머지 servlet filter chain parameter를 받는다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
나머지 servlet pipeline인 FilterChain이 세 번째 parameter로 연결된다.
비유의 한계
requestId는 권한 근거가 아니다
29줄F06-L29 ) throws ServletException, IOException { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 뒤의 준비·실행·검증 문장을 묶을 Java block 또는 호출 범위를 연다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
세 parameter와 checked exception을 가진 filter method body가 열린다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
30줄F06-L30 String supplied = request.getHeader(HEADER); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. caller가 보낸 X-Request-Id 값을 request에서 읽는다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
supplied local variable에 외부 header 또는 null이 저장된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
31줄F06-L31 String requestId = supplied != null && SAFE.matcher(supplied).matches() 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. header가 null이 아니고 whitelist 전체와 일치하는지 검사한다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
requestId 선택 조건이 non-null과 whitelist 전체 일치의 conjunction으로 계산된다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
32줄F06-L32 ? supplied 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 안전하다고 판정한 외부 requestId를 그대로 선택한다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
조건이 true면 supplied가 선택 표현식의 결과가 된다.
비유의 한계
requestId는 권한 근거가 아니다
33줄F06-L33 : UUID.randomUUID().toString(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 외부 값이 없거나 위험하면 새 UUID를 fallback으로 선택한다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
위험 header 대신 36자 fallback 후보가 선택된다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
34줄F06-L34 request.setAttribute(ATTRIBUTE, requestId); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 21줄에서 이미 만든 ATTRIBUTE key 아래에 선택한 requestId 값을 저장한다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
controller·audit code가 current helper로 읽을 request-scoped 값이 생긴다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
35줄F06-L35 response.setHeader(HEADER, requestId); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 선택한 ID를 응답 header에 써 caller에게 돌려준다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
client가 응답에서 상관관계 ID를 읽을 header가 생긴다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
36줄F06-L36 MDC.put("requestId", requestId); 교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. 동기 처리 중 log context에 requestId를 넣는다.
입력
현재 HTTP request의 외부 header와 빈 attribute·response·MDC 상태를 받는다.
결과·효과
downstream chain 동안 현재 thread log context에서 같은 requestId를 읽을 수 있다.
비유의 한계
requestId는 권한 근거가 아니다
37줄F06-L37 try { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. downstream chain과 cleanup을 한 lifecycle 범위로 묶기 시작한다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
후속 문장이 공유할 lexical·호출 범위가 열려 실행 순서를 이어 받는다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
38줄F06-L38 chain.doFilter(request, response); 입구 안내원이 방문표를 확인해 안쪽과 출구에 같은 번호를 적는다. request와 response를 다음 filter·controller로 넘긴다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
같은 requestId context를 가진 상태로 downstream servlet pipeline이 실행된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
39줄F06-L39 } finally { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 정상 반환뿐 아니라 예외에도 cleanup을 실행할 finally 범위를 연다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
후속 문장이 공유할 lexical·호출 범위가 열려 실행 순서를 이어 받는다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
40줄F06-L40 MDC.remove("requestId"); 교대 직원이 임시 이름표를 달았다가 끝나면 반드시 떼는 것과 같다. thread 재사용 때 값이 새지 않도록 MDC key를 제거한다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
filter 반환 뒤 현재 thread의 requestId lookup 결과가 null이 된다.
비유의 한계
requestId는 권한 근거가 아니다
41줄F06-L41 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
42줄F06-L42 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
선택된 requestId가 request·response·MDC에 기록된 filter-chain 상태를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
44줄F06-L44 public static String current(HttpServletRequest request) { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. request attribute에서 current requestId를 읽을 helper를 연다.
입력
현재 request에 있을 수도 없는 ATTRIBUTE 값을 받는다.
결과·효과
downstream caller가 사용할 static current helper 호출점이 생긴다.
비유의 한계
requestId는 권한 근거가 아니다
45줄F06-L45 Object value = request.getAttribute(ATTRIBUTE); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. filter가 저장한 request-scoped ID object를 조회한다.
입력
현재 request에 있을 수도 없는 ATTRIBUTE 값을 받는다.
결과·효과
ATTRIBUTE key의 현재 request 값 또는 null이 local value에 저장된다.
비유의 한계
기존 MDC 값이 있으면 복원하지 않고 제거한다
46줄F06-L46 return value == null ? "unavailable" : value.toString(); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. attribute가 없으면 unavailable, 있으면 문자열로 반환한다.
입력
현재 request에 있을 수도 없는 ATTRIBUTE 값을 받는다.
결과·효과
helper 결과가 unavailable 또는 저장된 requestId 문자열 중 하나로 확정된다.
비유의 한계
비동기 실행에는 자동 전파되지 않는다
47줄F06-L47 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
현재 request에 있을 수도 없는 ATTRIBUTE 값을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
response header는 downstream commit 시점과 별도 고려가 필요하다
48줄F06-L48 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
현재 request에 있을 수도 없는 ATTRIBUTE 값을 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
requestId는 권한 근거가 아니다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    외부 header를 SAFE regex로 판정하고, 실패하면 UUID를 골라 request·response·MDC에 기록한 뒤 finally에서 제거해.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F06-C01 · imports1–15줄
1–15줄 원본
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;
F06-C02 · component order and constants16–23줄
16–23줄 원본

@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}");
F06-C03 · validation propagation and cleanup24–43줄
24–43줄 원본
    @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");
        }
    }
F06-C04 · current request helper44–48줄
44–48줄 원본
    public static String current(HttpServletRequest request) {
        Object value = request.getAttribute(ATTRIBUTE);
        return value == null ? "unavailable" : value.toString();
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 43 / 43

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

준비·설명 줄 13개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.api;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import jakarta.servlet.FilterChain;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
4import jakarta.servlet.ServletException;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
5import jakarta.servlet.http.HttpServletRequest;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
6import jakarta.servlet.http.HttpServletResponse;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
7import org.slf4j.MDC;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
8import org.springframework.stereotype.Component;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
9import org.springframework.core.Ordered;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
10import org.springframework.core.annotation.Order;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
11import org.springframework.web.filter.OncePerRequestFilter;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
13import java.io.IOException;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
14import java.util.UUID;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
15import java.util.regex.Pattern;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
원본한국어 번역
17@ComponentSpring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
18@Order(Ordered.HIGHEST_PRECEDENCE)requestId filter를 가장 높은 우선순위로 실행하게 한다.
19public 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 chainrequestId 전파 뒤 호출할 나머지 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·표현식 범위를 닫아 다음 범위와 분리한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다. 다만 기존 MDC 값이 있으면 복원하지 않고 제거한다

문법 해부

  • regex 전체 일치와 삼항 연산자가 외부 값 또는 UUID를 고른다.
  • request attribute·response header·MDC에 같은 값을 기록한다.
  • try/finally가 정상·예외 반환 모두에서 MDC key를 제거한다.

실행 순서

  1. header 읽기
  2. whitelist 검증
  3. 외부값/UUID 선택
  4. request·response·MDC 전파
  5. chain
  6. 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로 남긴다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 호출 전에 MDC requestId가 outer였다면 filter 종료 후 outer로 복원되지 않고 null이 돼.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1req-w19-1SAFE regex 전체 일치를 검사한다.허용된 supplied 값이 requestId로 선택된다.기존 MDC 값이 있으면 복원하지 않고 제거한다
2공백·newline 위험 값SAFE 불일치 뒤 UUID.randomUUID를 호출한다.위험 원문 대신 36자 UUID가 선택된다.비동기 실행에는 자동 전파되지 않는다
3선택된 requestIdattribute·response header·MDC에 같은 값을 기록한다.downstream request와 log가 같은 상관관계 ID를 읽는다.response header는 downstream commit 시점과 별도 고려가 필요하다
4chain 정상 또는 예외 반환finally에서 MDC.remove를 실행한다.현재 thread의 requestId key가 제거된다.requestId는 권한 근거가 아니다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘MDC remove in finally’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

servlet request

X-Request-Id 외부 header를 읽는다.

기존 MDC 값이 있으면 복원하지 않고 제거한다
whitelist/UUID fallback

1~64자 안전 문자열은 보존하고 나머지는 새 UUID로 교체한다.

비동기 실행에는 자동 전파되지 않는다
request/response

선택한 값을 request attribute와 response header에 기록한다.

response header는 downstream commit 시점과 별도 고려가 필요하다
SLF4J MDC

같은 동기 thread의 downstream log context에 requestId를 넣는다.

requestId는 권한 근거가 아니다
finally lifecycle

chain의 정상·예외 종료 모두에서 MDC key를 제거한다.

기존 MDC 값이 있으면 복원하지 않고 제거한다
10

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 결과를 그대로 돌려준다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

preexisting MDC

기존 MDC 값이 있으면 복원하지 않고 제거한다

이 책임을 맡는 곳: filter context save/restore policy
async propagation

비동기 실행에는 자동 전파되지 않는다

이 책임을 맡는 곳: task decorator or explicit context carrier
response commitment

response header는 downstream commit 시점과 별도 고려가 필요하다

이 책임을 맡는 곳: servlet integration and error-response tests
authorization

requestId는 권한 근거가 아니다

이 책임을 맡는 곳: authenticated principal and access-control layer
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

안전한 ASCII requestId만 받아들이고 나머지는 UUID로 교체한 뒤 request·response·MDC에 전파하고 finally에서 정리하는 Green 해법이다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. imports
  2. component order and constants
  3. validation propagation and cleanup
  4. 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 전파·인증 권한은 보장하지 않음을 말한다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · Green learner 해법learning_stages/w19/production/solution/src/main/java/com/example/financialcore/api/RequestIdFilter.javaSHA-256 a5e5e453a03460a67930e6e3534380735de8fa0ebb95017bb28a124f96ce8f2b
RequestIdFilter.java — 검증·UUID·MDC finally 해법 전체
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();
    }
}
07

SensitiveDataMasker.java — 정규화·마스킹 해법

learning_stages/w19/production/solution/src/main/java/com/example/financialcore/security/SensitiveDataMasker.java

원문 정본 · Green learner 해법 · 정본 · W19-F07
13줄 연결15줄 번역3 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.

  1. null/blank -> ***은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. 짧은 값은 원문 전체가 별표 뒤에 남는다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값null/blank -> ***non-alnum removedvisible=min(4,length)stars=max(4,length-visible)
02

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 신뢰 경계를 숨기지 않는다.

03

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 입력은 ****가 된다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.

  3. 첫 관찰값은 ‘null/blank -> ***’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 파일은 제공 test를 만족하는 Green masking solution이지만, 암호화나 충돌 없는 익명화 구현은 아니야.

  3. ASCII suffix 규칙은 구현하지만 짧은 값 전체 노출·Unicode 충돌·type 미검증 경계가 남아 있어.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.원문 정본 · Green learner 해법에서 package/import는 setup, 나머지 비공백 줄은 연결을 원본 줄 번호 그대로 연결했습니다.13 / 13 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
5줄F07-L05 @Component 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. Spring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
SensitiveDataMasker가 Spring component scan의 Green bean 후보가 된다.
비유의 한계
짧은 값은 원문 전체가 별표 뒤에 남는다
6줄F07-L06 public final class SensitiveDataMasker { 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 식별자를 정규화하고 가리는 final masker를 선언한다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
후속 문장이 공유할 lexical·호출 범위가 열려 실행 순서를 이어 받는다.
비유의 한계
Unicode 식별자는 제거되어 서로 충돌할 수 있다
7줄F07-L07 public String accountNumber(String raw) { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. nullable 계좌 식별자를 표시용 문자열로 바꾸는 method를 연다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
후속 문장이 공유할 lexical·호출 범위가 열려 실행 순서를 이어 받는다.
비유의 한계
구두점-only 입력은 ****가 된다
8줄F07-L08 if (raw == null || raw.isBlank()) return "***"; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. null 또는 blank 입력을 placeholder ***로 바꾼다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
null·blank 입력은 후속 정규화 없이 *** 반환으로 종료된다.
비유의 한계
resource type은 별도 검증 없이 앞에 붙는다
9줄F07-L09 String normalized = raw.replaceAll("[^A-Za-z0-9]", ""); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. ASCII 영숫자가 아닌 문자를 제거해 suffix 기준을 만든다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
hyphen·공백이 빠진 ASCII 영숫자 문자열이 suffix 계산 입력이 된다.
비유의 한계
짧은 값은 원문 전체가 별표 뒤에 남는다
10줄F07-L10 int visible = Math.min(4, normalized.length()); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 정규화 길이와 4 중 작은 값을 보일 suffix 길이로 정한다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
0~4 범위 visible 길이가 substring 시작점을 결정한다.
비유의 한계
Unicode 식별자는 제거되어 서로 충돌할 수 있다
11줄F07-L11 return "*".repeat(Math.max(4, normalized.length() - visible)) 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 숨길 길이와 무관하게 별표를 최소 4개 만들기 시작한다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
최소 네 별표와 최대 네 글자 suffix를 합칠 첫 조각이 생긴다.
비유의 한계
구두점-only 입력은 ****가 된다
12줄F07-L12 + normalized.substring(normalized.length() - visible); 송장에서 끝 네 글자만 남기고 앞부분을 별표 스티커로 덮는다. 정규화 문자열의 마지막 visible 글자를 별표 뒤에 붙인다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
별표와 마지막 visible substring이 결합된 최종 accountNumber가 완성된다.
비유의 한계
resource type은 별도 검증 없이 앞에 붙는다
13줄F07-L13 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
nullable raw 계좌 식별자를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
짧은 값은 원문 전체가 별표 뒤에 남는다
15줄F07-L15 public String resourceId(String type, String raw) { 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. resource type과 masking 결과를 합칠 helper를 연다.
입력
resource type과 raw 식별자를 받는다.
결과·효과
후속 문장이 공유할 lexical·호출 범위가 열려 실행 순서를 이어 받는다.
비유의 한계
구두점-only 입력은 ****가 된다
16줄F07-L16 return type + ":" + accountNumber(raw); 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. type prefix, colon, accountNumber(raw)의 masking 결과를 합쳐 반환한다.
입력
resource type과 raw 식별자를 받는다.
결과·효과
type:masked-account 형식의 resourceId 반환값이 완성된다.
비유의 한계
resource type은 별도 검증 없이 앞에 붙는다
17줄F07-L17 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
resource type과 raw 식별자를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
짧은 값은 원문 전체가 별표 뒤에 남는다
18줄F07-L18 } 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 method·class·표현식 범위를 닫아 다음 범위와 분리한다.
입력
resource type과 raw 식별자를 받는다.
결과·효과
현재 범위 effect가 확정되고 바깥 단계가 다음 문장을 받을 수 있다.
비유의 한계
Unicode 식별자는 제거되어 서로 충돌할 수 있다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    123-456-7890을 1234567890으로 정규화하고 visible=4, stars=6을 계산해 ******7890을 만드는 순서를 따라가.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F07-C01 · component shell1–6줄
1–6줄 원본
package com.example.financialcore.security;

import org.springframework.stereotype.Component;

@Component
public final class SensitiveDataMasker {
F07-C02 · normalize and mask7–14줄
7–14줄 원본
    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);
    }
F07-C03 · resource ID composition15–18줄
15–18줄 원본
    public String resourceId(String type, String raw) {
        return type + ":" + accountNumber(raw);
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 15 / 15

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

준비·설명 줄 2개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.security;이 class가 속한 Java package namespace를 compiler에 알려 준다.
3import org.springframework.stereotype.Component;아래 source가 짧은 type 이름으로 사용할 외부 class 또는 assertion을 가져온다.
원본한국어 번역
5@ComponentSpring이 이 구현을 component bean으로 찾아 주입하게 표시한다.
6public 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·표현식 범위를 닫아 다음 범위와 분리한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다. 다만 짧은 값은 원문 전체가 별표 뒤에 남는다

문법 해부

  • guard clause가 null·blank를 먼저 ***로 반환한다.
  • replaceAll·min·repeat·substring이 정규화와 suffix 노출 길이를 계산한다.
  • resourceId는 type을 sanitize하지 않고 masking 결과 앞에 붙인다.

실행 순서

  1. null·blank 분기
  2. 정규화
  3. visible 계산
  4. 별표와 suffix
  5. 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로 연결한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 서로 다른 Unicode-only 값 ‘가’와 ‘나’는 모두 정규화 후 빈 문자열이 되어 ****로 충돌할 수 있어.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1null 또는 blankguard clause에서 즉시 분기한다.***를 반환한다.짧은 값은 원문 전체가 별표 뒤에 남는다
2123-456-7890ASCII 영숫자 외 문자를 제거한다.normalized는 1234567890이다.Unicode 식별자는 제거되어 서로 충돌할 수 있다
3normalized 길이 10visible=min(4,10), stars=max(4,6)을 계산한다.별표 6개와 suffix 7890이 선택된다.구두점-only 입력은 ****가 된다
4ACCOUNT, 123456type + colon + accountNumber(raw)를 조립한다.ACCOUNT:****3456이 된다.resource type은 별도 검증 없이 앞에 붙는다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘stars=max(4,length-visible)’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

guard clause

null·blank를 정규화 전에 ***로 종료한다.

짧은 값은 원문 전체가 별표 뒤에 남는다
ASCII normalization

영숫자 외 문자를 제거해 suffix 기준 문자열을 만든다.

Unicode 식별자는 제거되어 서로 충돌할 수 있다
length arithmetic

보일 글자는 최대 4, 별표는 최소 4로 계산한다.

구두점-only 입력은 ****가 된다
string assembly

별표 뒤에 정규화 문자열의 마지막 visible 글자를 붙인다.

resource type은 별도 검증 없이 앞에 붙는다
resource prefix

sanitize하지 않은 type과 masking 결과를 colon으로 연결한다.

짧은 값은 원문 전체가 별표 뒤에 남는다
10

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를 노출한다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

short-value disclosure

짧은 값은 원문 전체가 별표 뒤에 남는다

이 책임을 맡는 곳: privacy masking policy
Unicode collision

Unicode 식별자는 제거되어 서로 충돌할 수 있다

이 책임을 맡는 곳: normalization policy and collision tests
punctuation-only input

구두점-only 입력은 ****가 된다

이 책임을 맡는 곳: empty-normalized-value policy
resource type trust

resource type은 별도 검증 없이 앞에 붙는다

이 책임을 맡는 곳: caller validation or type sanitizer
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

ASCII 영숫자만 정규화하고 마지막 네 글자 이하를 남기며 최소 네 별표를 붙이는 Green masking 해법이다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. component shell
  2. normalize and mask
  3. resource ID composition

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

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

자가 점검
  • null·blank guard를 먼저 읽는다.
  • ASCII 영숫자 외 문자 제거를 확인한다.
  • visible=min(4,length)와 stars=max(4,length-visible)를 계산한다.
  • 짧은 값은 별표 뒤 원문 전체가 남음을 말한다.
  • resource type은 sanitize하지 않음을 말한다.
13

STEP 13 / 13

전체 원본 source

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

원문 정본 전체 source 확인하기
원문 정본 · Green learner 해법learning_stages/w19/production/solution/src/main/java/com/example/financialcore/security/SensitiveDataMasker.javaSHA-256 3001f01fbbd32d7931b1bdd4da7e1abd5d975febb7ccc1a8fc25f0a609448f06
SensitiveDataMasker.java — 정규화·마스킹 해법 전체
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);
    }
}
08

W19-SQL-Q29.sql — 전일 거래액 차이 비정본 예시

illustrative/sql/W19-SQL-Q29.sql

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

STEP 01 / 13

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

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

오늘의 한 문장

SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.

  1. 2026-07-01 total=1500은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값2026-07-01 total=1500previous=2129999difference=-2128499missing prior SUCCESS date -> NULL
02

STEP 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을 실제 업무일 달력으로 바꾸어 주지 않는다.

03

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을 포함한다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.

  3. 첫 관찰값은 ‘2026-07-01 total=1500’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 SQL은 Q29의 shipped answer가 아니라 가정을 드러낸 illustrative example이야.

  3. SUCCESS의 모든 type을 합산하고 calendar date-1을 쓰는 선택이므로, 업무일·type 정책의 공식 답으로 일반화하면 안 돼.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.학습용 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.24 / 24 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F08-L01 -- W19-SQL-Q29 illustrative example; not a shipped workbook answer. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. Q29 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
독자는 이 SQL을 shipped answer가 아닌 illustrative artifact로 분류할 근거를 얻는다.
비유의 한계
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
2줄F08-L02 -- Assumption: every SUCCESS business_tx amount counts, including OPENING and REVERSAL. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. SUCCESS의 amount를 모두 세며 OPENING과 REVERSAL도 포함한다는 선택 가정을 선언한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
결과 해석에 SUCCESS 전부와 OPENING·REVERSAL 포함 정책이 적용된다.
비유의 한계
달력일 -1은 실제 영업일 달력을 모델링하지 않는다
3줄F08-L03 -- A missing previous calendar date remains NULL; this does not model a holiday calendar. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. 직전 달력일 집계가 없으면 NULL로 두며 업무일·휴일 달력을 모델링하지 않는다고 제한한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
전일 누락 NULL과 business-calendar 미모델링이 query의 명시적 한계가 된다.
비유의 한계
OPENING·REVERSAL을 포함한다
4줄F08-L04 SET search_path TO :"workbook_schema", public; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
unqualified table 이름이 workbook_schema의 relation으로 해석된다.
비유의 한계
psql 변수 workbook_schema가 필요하다
6줄F08-L06 WITH daily AS ( 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. SUCCESS 거래를 날짜 한 행으로 접을 daily CTE를 연다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
daily라는 재사용 가능한 날짜별 중간 relation의 scope가 열린다.
비유의 한계
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
7줄F08-L07 SELECT 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 단계의 출력 column 목록을 시작한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
daily CTE의 projection 목록이 시작된다.
비유의 한계
달력일 -1은 실제 영업일 달력을 모델링하지 않는다
8줄F08-L08 business_date, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 일별 집계 grain을 business_date로 드러낸다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
daily row의 grouping key column이 business_date로 정해진다.
비유의 한계
OPENING·REVERSAL을 포함한다
9줄F08-L09 SUM(amount) AS total_amount 같은 날짜 영수증을 모아 금액을 더한다. 같은 날짜 SUCCESS 거래 amount를 total_amount로 합한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
각 날짜의 SUCCESS amount 합계가 total_amount column으로 생긴다.
비유의 한계
psql 변수 workbook_schema가 필요하다
10줄F08-L10 FROM business_tx 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
집계 입력 row set이 workbook business_tx로 정해진다.
비유의 한계
shipped workbook 정답이 아니다
11줄F08-L11 WHERE status = 'SUCCESS' 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
FAILED row가 daily aggregate 입력에서 제거된다.
비유의 한계
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
12줄F08-L12 GROUP BY business_date 같은 날짜 영수증을 모아 금액을 더한다. 날짜마다 한 total_amount가 나오도록 집계한다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
같은 business_date의 남은 row가 하나의 daily row로 축약된다.
비유의 한계
달력일 -1은 실제 영업일 달력을 모델링하지 않는다
13줄F08-L13 ) 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 CTE 또는 lateral subquery 범위를 닫는다.
입력
workbook business_tx의 status·business_date·amount 행을 받는다.
결과·효과
business_date,total_amount 두 column을 가진 daily CTE가 완성된다.
비유의 한계
OPENING·REVERSAL을 포함한다
14줄F08-L14 SELECT 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 단계의 출력 column 목록을 시작한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
최종 current·previous·difference projection 목록이 시작된다.
비유의 한계
psql 변수 workbook_schema가 필요하다
15줄F08-L15 current_day.business_date, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 일별 집계 grain을 business_date로 드러낸다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
각 결과 row의 날짜 key가 current_day.business_date로 정해진다.
비유의 한계
shipped workbook 정답이 아니다
16줄F08-L16 current_day.total_amount, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 날짜의 SUCCESS 합계 total_amount를 최종 결과 column으로 투영한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
각 결과 row에 current SUCCESS total_amount가 실린다.
비유의 한계
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
17줄F08-L17 previous_day.total_amount AS previous_day_amount, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 전 달력일 합계를 previous_day_amount로 이름 붙인다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
join된 전 달력일 합계가 previous_day_amount column으로 실린다.
비유의 한계
달력일 -1은 실제 영업일 달력을 모델링하지 않는다
18줄F08-L18 current_day.total_amount - previous_day.total_amount AS amount_difference 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 합계에서 전 달력일 합계를 빼 변화액을 계산한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
2026-07-01은 -2128499, 전일 누락 행은 NULL이 된다.
비유의 한계
OPENING·REVERSAL을 포함한다
19줄F08-L19 FROM daily AS current_day 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 모든 집계 날짜를 보존할 current_day에서 시작한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
daily의 모든 current_day row가 최종 결과의 보존 측이 된다.
비유의 한계
psql 변수 workbook_schema가 필요하다
20줄F08-L20 LEFT JOIN daily AS previous_day 기준 명단을 남겨 둔 채 옆에 조건에 맞는 장부 한 줄을 붙인다. 전 달력일 집계가 없어도 현재 날짜를 남기는 LEFT self join을 연다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
전 달력일 합계가 없는 날짜도 NULL previous 값으로 남는다.
비유의 한계
shipped workbook 정답이 아니다
21줄F08-L21 ON previous_day.business_date = current_day.business_date - 1 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 정확히 calendar date - 1인 집계 행을 연결한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
previous 후보는 current business_date보다 정확히 하루 전인 row로 제한된다.
비유의 한계
prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
22줄F08-L22 ORDER BY current_day.business_date; 최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. 결과를 business_date 오름차순으로 표시한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
fixture 결과가 날짜 오름차순으로 배열되어 연속 delta를 대조할 수 있다.
비유의 한계
달력일 -1은 실제 영업일 달력을 모델링하지 않는다
23줄F08-L23 -- Oracle highlights: 2026-07-01=1500, previous=2129999, difference=-2128499. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. 2026-07-01의 current·previous·difference 세 숫자 oracle을 기록한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
07-01 actual을 total1500/previous2129999/difference-2128499와 대조할 수 있다.
비유의 한계
OPENING·REVERSAL을 포함한다
24줄F08-L24 -- 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임을 기록한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
12-29 actual의 previous와 difference가 NULL인지 대조할 수 있다.
비유의 한계
psql 변수 workbook_schema가 필요하다
25줄F08-L25 -- 2026-12-30..2027-01-02 differences are 1, 900000, -999500, 700. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. 12-30부터 2027-01-02까지 이어지는 네 difference oracle을 순서대로 기록한다.
입력
날짜별 SUCCESS total_amount를 가진 daily CTE를 current_day 입력으로 받는다.
결과·효과
12-30~01-02 actual delta를 1/900000/-999500/700 순서와 대조할 수 있다.
비유의 한계
shipped workbook 정답이 아니다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    07-01 SUCCESS 합계 1500과 06-30 합계 2129999를 self join해 difference -2128499를 만드는 순서를 따라가.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F08-C01 · provenance assumptions and schema1–5줄
1–5줄 원본
-- 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;
F08-C02 · daily SUCCESS aggregation6–13줄
6–13줄 원본
WITH daily AS (
    SELECT
        business_date,
        SUM(amount) AS total_amount
    FROM business_tx
    WHERE status = 'SUCCESS'
    GROUP BY business_date
)
F08-C03 · self join and difference14–22줄
14–22줄 원본
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;
F08-C04 · pinned fixture oracle23–25줄
23–25줄 원본
-- 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.
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 24 / 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로 두며 업무일·휴일 달력을 모델링하지 않는다고 제한한다.
4SET search_path TO :"workbook_schema", public;workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
6WITH 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_txworkbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다.
11 WHERE status = 'SUCCESS'현재 예시 가정에 따라 SUCCESS만 후보로 남긴다.
12 GROUP BY business_date날짜마다 한 total_amount가 나오도록 집계한다.
13)현재 CTE 또는 lateral subquery 범위를 닫는다.
14SELECT현재 단계의 출력 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현재 합계에서 전 달력일 합계를 빼 변화액을 계산한다.
19FROM daily AS current_day모든 집계 날짜를 보존할 current_day에서 시작한다.
20LEFT JOIN daily AS previous_day전 달력일 집계가 없어도 현재 날짜를 남기는 LEFT self join을 연다.
21 ON previous_day.business_date = current_day.business_date - 1정확히 calendar date - 1인 집계 행을 연결한다.
22ORDER 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을 순서대로 기록한다.
07

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이다.

실행 순서

  1. SUCCESS 일별 합계
  2. current day 보존
  3. calendar-1 self join
  4. difference
  5. 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과 별도 비교해야 한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 12-28에는 FAILED 세 행이 있지만 SUCCESS 일계는 없어 12-29의 previous와 difference가 NULL이야. 이를 휴일이라고 부르면 fixture를 왜곡해.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
12026-07-01 SUCCESS rowsdaily CTE가 amount를 날짜별 합산한다.current total_amount는 1500이다.prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
2current_day=2026-07-01business_date-1로 daily를 self join한다.previous_day_amount는 2129999다.달력일 -1은 실제 영업일 달력을 모델링하지 않는다
3current 1500, previous 2129999두 합계를 뺀다.amount_difference는 -2128499다.OPENING·REVERSAL을 포함한다
4current_day=2026-12-292026-12-28 SUCCESS 일계와 LEFT join한다.해당 SUCCESS 일계가 없어 previous와 difference는 NULL이다.psql 변수 workbook_schema가 필요하다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘missing prior SUCCESS date -> NULL’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

psql search_path

workbook schema의 business_tx를 우선 해석한다.

prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다
daily aggregate CTE

SUCCESS amount를 business_date별 total_amount 한 행으로 접는다.

달력일 -1은 실제 영업일 달력을 모델링하지 않는다
LEFT self join

각 current date에 calendar date-1 집계가 있으면 연결하고 없으면 NULL로 둔다.

OPENING·REVERSAL을 포함한다
NULL arithmetic

previous가 NULL이면 difference도 NULL로 전파한다.

psql 변수 workbook_schema가 필요하다
display order

business_date 오름차순으로 fixture oracle을 비교 가능하게 정렬한다.

shipped workbook 정답이 아니다
10

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으로 끝날 수 있다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

underspecified prompt

prompt가 status·tx_type·금액 부호를 고정하지 않아 가정을 주석으로 명시했다

이 책임을 맡는 곳: workbook requirement owner
business-calendar semantics

달력일 -1은 실제 영업일 달력을 모델링하지 않는다

이 책임을 맡는 곳: calendar table and holiday policy
transaction-type inclusion

OPENING·REVERSAL을 포함한다

이 책임을 맡는 곳: business reporting definition
psql variable dependency

psql 변수 workbook_schema가 필요하다

이 책임을 맡는 곳: workbook runner and schema binding
answer provenance

shipped workbook 정답이 아니다

이 책임을 맡는 곳: learner-owned solution and reviewer
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

SUCCESS 거래액을 business_date별로 모으고 전 달력일 집계와 self join해 차이를 계산하는 Q29 비정답 예시다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. provenance assumptions and schema
  2. daily SUCCESS aggregation
  3. self join and difference
  4. 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을 대조한다.
13

STEP 13 / 13

전체 원본 source

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님illustrative/sql/W19-SQL-Q29.sqlSHA-256 863c4c23c683c4557be76940d07c00ee6cc16de273cdafe949455b2c0dfb31ab
W19-SQL-Q29.sql — 전일 거래액 차이 비정본 예시 전체
-- 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.
09

W19-SQL-Q30.sql — 계좌별 최근 성공 거래 비정본 예시

illustrative/sql/W19-SQL-Q30.sql

학습용 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W19-F09
22줄 연결22줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.

  1. accounts=8은 어느 줄에서 생길까?
  2. 입력→처리→결과는 어떤 lifecycle인가?
  3. counterparty_account_id로 들어온 거래는 포함하지 않는다에서 첫 실패는 어디인가?
  4. test가 직접 assert하지 않은 것은 무엇인가?
  5. starter·test·solution·예시의 provenance는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값accounts=8account101 -> tx ...212account103/104 -> NULLtie=occurred_at,tx_id
02

STEP 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 정책을 공식 답으로 만들지 않는다.

03

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 가정을 썼다
출발점히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 이 파일부터 보나요?

  2. 니지카

    각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.

  3. 첫 관찰값은 ‘accounts=8’이야. 이 값이 source 어디에서 생기는지 먼저 표시해.

  4. 키타

    입력과 결과를 두 칸으로 나눌게요.

역할과 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    source가 있으면 곧 최종 정답인가요?

  2. 니지카

    이 SQL은 Q30의 shipped answer가 아니라 direct account와 zero-row 보존 가정을 택한 illustrative example이야.

  3. direct account_id의 SUCCESS만 보고 OPENING·REVERSAL도 포함해. counterparty 참여 여부는 공식 요구로 확정되지 않았어.

  4. 키타

    artifact label과 직접 증명 범위를 함께 적겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.학습용 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.22 / 22 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F09-L01 -- W19-SQL-Q30 illustrative example; not a shipped workbook answer. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. Q30 query가 shipped workbook 정답이 아닌 illustrative example임을 선언한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
독자는 이 SQL을 shipped answer가 아닌 illustrative artifact로 분류할 근거를 얻는다.
비유의 한계
counterparty_account_id로 들어온 거래는 포함하지 않는다
2줄F09-L02 -- Assumption: only direct business_tx.account_id and status=SUCCESS count. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. direct business_tx.account_id와 SUCCESS 상태만 최신 거래 후보로 센다는 가정을 선언한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
latest 후보 범위가 direct account_id의 SUCCESS row로 제한된다.
비유의 한계
OPENING·REVERSAL을 성공 거래로 포함한다
3줄F09-L03 -- Accounts without a successful row remain; ties use occurred_at DESC, tx_id DESC. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. 성공 거래 없는 account도 남기고 동률은 occurred_at DESC, tx_id DESC로 푼다고 선언한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
zero-row 보존과 occurred_at/tx_id 역순 정책이 결과 해석 계약이 된다.
비유의 한계
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
4줄F09-L04 SET search_path TO :"workbook_schema", public; 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
unqualified account와 business_tx가 workbook_schema relation으로 해석된다.
비유의 한계
psql 변수 workbook_schema가 필요하다
6줄F09-L06 SELECT 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 단계의 출력 column 목록을 시작한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
최종 account/latest projection 목록이 시작된다.
비유의 한계
counterparty_account_id로 들어온 거래는 포함하지 않는다
7줄F09-L07 a.account_id, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. account 한 행이라는 최종 grain을 account_id로 표시한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
각 결과 row의 보존 key가 account_id로 정해진다.
비유의 한계
OPENING·REVERSAL을 성공 거래로 포함한다
8줄F09-L08 latest.tx_id, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
선택된 latest transaction UUID가 결과 column에 실린다.
비유의 한계
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
9줄F09-L09 latest.amount, 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
선택된 latest amount가 결과 column에 실린다.
비유의 한계
psql 변수 workbook_schema가 필요하다
10줄F09-L10 latest.occurred_at 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. lateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
선택된 latest occurred_at이 결과 column에 실린다.
비유의 한계
shipped workbook 정답이 아니다
11줄F09-L11 FROM account AS a 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 성공 거래가 없는 계좌도 남기려고 account에서 시작한다.
입력
거래 유무와 무관한 workbook account 8행을 바깥 grain으로 받는다.
결과·효과
거래 유무와 무관한 account row가 바깥 보존 relation이 된다.
비유의 한계
counterparty_account_id로 들어온 거래는 포함하지 않는다
12줄F09-L12 LEFT JOIN LATERAL ( 기준 명단을 남겨 둔 채 옆에 조건에 맞는 장부 한 줄을 붙인다. 각 account마다 latest 후보를 찾되 후보가 없어도 account를 남긴다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
현재 account마다 다시 평가할 correlated lateral scope가 열린다.
비유의 한계
OPENING·REVERSAL을 성공 거래로 포함한다
13줄F09-L13 SELECT t.tx_id, t.amount, t.occurred_at 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 최종 출력에 필요한 거래 ID·금액·시각을 고른다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
latest 후보가 반환할 tx_id·amount·occurred_at column이 정해진다.
비유의 한계
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
14줄F09-L14 FROM business_tx AS t 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. workbook business_tx를 집계 또는 latest-row 후보 입력으로 읽는다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
latest 후보 입력 relation이 business_tx로 정해진다.
비유의 한계
psql 변수 workbook_schema가 필요하다
15줄F09-L15 WHERE t.account_id = a.account_id 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 바깥 account와 직접 연결된 거래만 남긴다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
후보 row가 현재 바깥 a.account_id의 direct 거래로 제한된다.
비유의 한계
shipped workbook 정답이 아니다
16줄F09-L16 AND t.status = 'SUCCESS' 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 현재 예시 가정에 따라 SUCCESS만 후보로 남긴다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
현재 account 후보에서 FAILED row가 제거된다.
비유의 한계
counterparty_account_id로 들어온 거래는 포함하지 않는다
17줄F09-L17 ORDER BY t.occurred_at DESC, t.tx_id DESC 최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. 최신 시각 우선, 동률이면 tx_id 큰 값 우선으로 total order를 만든다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
account101 동률에서 tx ...212가 ...211보다 앞선다.
비유의 한계
OPENING·REVERSAL을 성공 거래로 포함한다
18줄F09-L18 LIMIT 1 최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. 각 account 후보를 정렬의 첫 한 행으로 줄인다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
각 account마다 latest 성공 거래가 최대 한 행으로 고정된다.
비유의 한계
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
19줄F09-L19 ) AS latest ON TRUE 조립 설명서 한 문장을 실행해 다음 부품을 놓을 상태를 만든다. 상관 조건은 subquery 안에 두고 lateral 결과를 붙인다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
account당 최대 한 latest row가 LEFT join되고 없으면 세 column이 NULL이 된다.
비유의 한계
psql 변수 workbook_schema가 필요하다
20줄F09-L20 ORDER BY a.account_id; 최신 시각과 번호표로 줄을 세운 뒤 맨 앞 한 명을 고른다. 여덟 account 결과를 account_id 순으로 표시한다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
최종 account 행이 account_id 오름차순으로 배열된다.
비유의 한계
shipped workbook 정답이 아니다
21줄F09-L21 -- Oracle: account101 chooses tx ...212 over ...211 at the same timestamp. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. account101 동률에서 tx ...212가 ...211보다 먼저 선택되는 oracle을 기록한다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
account101 actual tx_id를 동률 승자 ...212와 대조할 수 있다.
비유의 한계
counterparty_account_id로 들어온 거래는 포함하지 않는다
22줄F09-L22 -- account103 and account104 return NULL; account105 chooses ...210. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. account103·104는 NULL이고 account105는 tx ...210인 oracle을 기록한다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
account103·104 NULL과 account105 ...210을 실제 결과와 대조할 수 있다.
비유의 한계
OPENING·REVERSAL을 성공 거래로 포함한다
23줄F09-L23 -- account102/106/107/108 choose their SUCCESS OPENING rows under this assumption. 연습지 위에 가정과 예상 결과 포스트잇을 붙인다. account102·106·107·108은 이 가정 아래 SUCCESS OPENING row를 고른다고 기록한다.
입력
현재 account_id와 그 계좌의 direct business_tx 후보 행을 받는다.
결과·효과
account102·106·107·108 actual을 각 SUCCESS OPENING row와 대조할 수 있다.
비유의 한계
prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
실제 값히토리 → 니지카 → 료 → 키타
  1. 히토리

    문법보다 값을 먼저 볼 수 있나요?

  2. 니지카

    account101의 동률 후보를 occurred_at DESC, tx_id DESC로 정렬해 ...212를 고르고, 후보 없는 103·104는 NULL로 남겨.

  3. 각 단계의 출력이 다음 단계의 입력으로 이어지는지 확인해.

  4. 키타

    값 추적표로 다시 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F09-C01 · provenance assumptions and schema1–5줄
1–5줄 원본
-- 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;
F09-C02 · account output grain6–11줄
6–11줄 원본
SELECT
    a.account_id,
    latest.tx_id,
    latest.amount,
    latest.occurred_at
FROM account AS a
F09-C03 · stable latest-row lateral lookup12–20줄
12–20줄 원본
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;
F09-C04 · pinned fixture oracle21–23줄
21–23줄 원본
-- 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.
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 22 / 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로 푼다고 선언한다.
4SET search_path TO :"workbook_schema", public;workbook_schema를 먼저, public을 다음 namespace로 조회하게 한다.
6SELECT현재 단계의 출력 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_atlateral subquery가 고른 최신 거래 column을 account 행에 붙인다.
11FROM account AS a성공 거래가 없는 계좌도 남기려고 account에서 시작한다.
12LEFT JOIN LATERAL (각 account마다 latest 후보를 찾되 후보가 없어도 account를 남긴다.
13 SELECT t.tx_id, t.amount, t.occurred_at최종 출력에 필요한 거래 ID·금액·시각을 고른다.
14 FROM business_tx AS tworkbook 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 결과를 붙인다.
20ORDER 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를 고른다고 기록한다.
07

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이 최신 최대 한 행·거래 없는 계좌 보존을 함께 만든다.

실행 순서

  1. account 시작
  2. SUCCESS 후보
  3. 안정 정렬
  4. 한 행 제한
  5. 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을 함께 보존해야 한다.
구체적 반례히토리 → 니지카 → 료 → 키타
  1. 히토리

    관찰값 하나가 맞으면 전체 계약도 안전한가요?

  2. 니지카

    아니야. 같은 관찰값을 유지하면서 미검증 경계가 깨지는 구체적 입력이나 구현을 찾아야 해.

  3. 계좌가 counterparty_account_id에만 나타나는 성공 거래는 이 subquery의 후보가 아니므로 ‘모든 참여 거래의 최신 행’은 보장하지 않아.

  4. 키타

    직접 증명과 반례를 나누어 기록하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1account 8행account를 바깥 grain으로 LEFT LATERAL을 실행한다.성공 거래가 없는 103·104도 결과 후보에 남는다.counterparty_account_id로 들어온 거래는 포함하지 않는다
2account101의 같은 시각 tx ...211/...212occurred_at DESC, tx_id DESC로 정렬한다.tx ...212가 첫 후보가 된다.OPENING·REVERSAL을 성공 거래로 포함한다
3정렬된 account별 후보LIMIT 1을 적용한다.각 account의 latest row가 최대 한 행이 된다.prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
4후보 없는 account103·104LEFT lateral 결과를 NULL-extend한다.latest column들이 NULL인 account 행이 출력된다.psql 변수 workbook_schema가 필요하다
전체 복원히토리 → 니지카 → 료 → 키타
  1. 히토리

    전체 source가 길거나 한 줄이 길면 외워야 하나요?

  2. 니지카

    의미 chunk로 나눈 뒤 원본 bytes로 돌아오면 돼.

  3. 마지막에는 ‘tie=occurred_at,tx_id’ 관찰값과 source SHA를 함께 대조해.

  4. 키타

    설명→chunk→전체 source로 복원할게요.

09

STEP 09 / 13

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

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

psql search_path

workbook schema의 account와 business_tx를 해석한다.

counterparty_account_id로 들어온 거래는 포함하지 않는다
account outer grain

거래 유무와 무관하게 account 8행을 기준으로 보존한다.

OPENING·REVERSAL을 성공 거래로 포함한다
correlated LATERAL

현재 a.account_id의 direct SUCCESS 거래만 후보로 읽는다.

prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다
stable latest order

occurred_at DESC 뒤 tx_id DESC로 동률을 깨고 LIMIT 1을 적용한다.

psql 변수 workbook_schema가 필요하다
LEFT NULL extension

후보 없는 account103·104의 latest column을 NULL로 채운다.

shipped workbook 정답이 아니다
10

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를 선택한다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

counterparty participation

counterparty_account_id로 들어온 거래는 포함하지 않는다

이 책임을 맡는 곳: workbook requirement owner
transaction-type inclusion

OPENING·REVERSAL을 성공 거래로 포함한다

이 책임을 맡는 곳: business reporting definition
zero-transaction accounts

prompt가 0건 account 보존을 고정하지 않아 LEFT LATERAL 가정을 썼다

이 책임을 맡는 곳: result-cardinality requirement
psql variable dependency

psql 변수 workbook_schema가 필요하다

이 책임을 맡는 곳: workbook runner and schema binding
answer provenance

shipped workbook 정답이 아니다

이 책임을 맡는 곳: learner-owned solution and reviewer
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

각 account에서 안정 정렬한 최신 SUCCESS business_tx 한 행을 LATERAL로 고르고 0건 account도 보존하는 Q30 비정답 예시다.를 고정값과 미보장 경계까지 말한다.

2단계 · 코드 조각 재조립

  1. provenance assumptions and schema
  2. account output grain
  3. stable latest-row lateral lookup
  4. 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을 대조한다.
13

STEP 13 / 13

전체 원본 source

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님illustrative/sql/W19-SQL-Q30.sqlSHA-256 1727bfc9d381e1629a6f1d86c5cd4134f93198583fc06b857d5409b72251e178
W19-SQL-Q30.sql — 계좌별 최근 성공 거래 비정본 예시 전체
-- 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.