STARRY PASS 코드 뒤풀이 · W14 Ver2 전체

14주차 코드 — 핵심 회귀 smoke와 SQL 연결·원장 수 점검

여섯 누적 테스트 참고본과 W14 smoke 실행기를 함께 읽고, 원거래 연결과 거래별 원장 cardinality를 fixture에 묶어 쉽게 확인합니다.

누적 Java 테스트 참고본 6개정본 PowerShell 실행기 1개fixture 기반 학습용 SQL 예시 · 정본 답안 아님 2개항목마다 13단계연결 486줄번역 570줄
01

CoreSchemaIT.java — public base table 이름 네 개를 exact set으로 확인

reference/w6/day-1/CoreSchemaIT.java

누적 Java 테스트 참고본 · 정본 · W14-F01
16줄 연결22줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

W6D1 staged schema에서 Flyway history를 제외한 public BASE TABLE 이름이 네 core table과 정확히 같은지 확인한다.

  1. 왜 information_schema.tables에서 public과 BASE TABLE만 고를까?
  2. 왜 flyway_schema_history는 제외할까?
  3. ORDER BY가 있는데 assertion은 왜 순서를 무시할까?
  4. table 이름 네 개가 맞으면 PK·FK도 맞다고 할 수 있을까?
  5. W14 selector 이름이 이 staged source 실행을 직접 증명할까?
이 파일에서 끝까지 다시 쓰는 값schema=publictype=BASE TABLEexclude=flyway_schema_historyaccountbusiness_txledger_entryidempotency_request@Test count=1stage=W6D1
02

STEP 02 / 13

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

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

STARRY가 공연장 창고에 꼭 필요한 네 서랍만 있는지 검사한다.

필요한 네 서랍만 있는지 이름표 대조

검사표는 public 방의 진짜 table 이름을 읽고 Flyway 작업 일지는 뺀다. 남은 이름은 String 목록에 담는다.

그 목록을 account·business_tx·ledger_entry·idempotency_request 네 장과 비교해 하나라도 빠지거나 더 있으면 실패한다.

딱 여기까지만 서랍 이름만 대조하므로 안쪽 열·열쇠·연결끈인 column·PK·FK·index까지 검사하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

검사할 방 좁히기

다른 schema와 view를 빼고 public의 base table만 남긴다.

코드 연결
17~19줄
비유
건물 전체가 아니라 public 창고의 실제 서랍만 재고표에 올린다.
비유의 끝
다른 schema의 문제는 이 목록에 보이지 않는다.

관리 일지 제외

Flyway가 migration 이력을 적는 table은 application core table 수에서 제외한다.

코드 연결
19줄
비유
창고 물품이 아니라 점검 기록철은 네 서랍 수에서 빼는 것과 같다.
비유의 끝
이 이름 하나 외의 관리 table은 자동 제외하지 않는다.

정확히 네 이름

containsExactlyInAnyOrder는 순서는 무시하지만 누락·추가·중복은 허용하지 않는다.

코드 연결
22~24줄
비유
네 장 티켓의 순서를 섞어도 종류와 장수는 정확히 같아야 한다.
비유의 끝
이름이 같아도 각 table 내부 설계가 같다는 뜻은 아니다.

누적 reference

이 파일은 W14 신규 구현이 아니라 W6D1 stage에 고정된 역사적 test source다.

코드 연결
전체 26줄
비유
예전 공연의 체크리스트를 이번 주 빠른 점검에서 다시 참고한다.
비유의 끝
reference root가 stage 폴더를 기본 compile하지 않으므로 selector 이름만으로 실행을 확정하지 않는다.
왜 네 이름만 보나히토리 → 니지카 → 료 → 키타
  1. 히토리

    table 네 개면 schema 전체가 맞는 것 아닌가요?

  2. 니지카

    이번 표는 창고 이름만 세고 서랍 안 column은 열지 않아.

  3. projection과 matcher가 직접 관찰한 quantifier는 exact table-name set뿐이다.

  4. 키타

    PK·FK·index는 별도 확인 칸으로 남길게요.

Flyway 기록표히토리 → 니지카 → 료 → 키타
  1. 히토리

    Flyway table도 public인데 왜 빼나요?

  2. 니지카

    application core table이 아니라 migration history라서 이름 inventory에서 제외해.

  3. 제외 규칙은 그 한 literal뿐이라 다른 관리 table은 extra로 잡힌다.

  4. 키타

    actual list에 다섯 번째 이름을 넣어 fail을 확인하겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.16 / 16 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
10줄F01-L10 @SpringBootTest STARRY의 네 서랍 검사대에 Spring 전체 전원 스위치를 단다. CoreSchemaIT를 Spring Boot application context 기반 test로 표시한다.
입력
JUnit이 CoreSchemaIT class metadata를 읽어 @SpringBootTest를 발견한다.
결과·효과
application bootstrap과 inherited PostgreSQL support 초기화가 test method보다 먼저 예약된다.
비유의 한계
annotation만으로 context·container·migration 성공은 확정되지 않는다.
11줄F01-L11 class CoreSchemaIT extends PostgresIntegrationTestSupport { 네 서랍 검사관을 PostgreSQL 공동 작업대 위에 세운다. CoreSchemaIT가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
입력
JVM이 test class type과 superclass 관계를 load한다.
결과·효과
하위 test가 support의 database lifecycle과 설정을 사용할 수 있는 type이 된다.
비유의 한계
superclass 구현은 이 파일에 없어 실제 준비 절차를 이 줄만으로 알 수 없다.
12줄F01-L12 @Autowired JdbcClient jdbc; 검사관 손에 SQL 질문기 `jdbc`를 자동 지급한다. Spring이 JdbcClient bean을 jdbc field에 주입하도록 선언한다.
입력
application context가 JdbcClient bean과 CoreSchemaIT instance를 준비한다.
결과·효과
test body가 `jdbc.sql(...)`을 호출할 객체 참조를 얻는다.
비유의 한계
주입 성공은 뒤 SQL이나 assertion의 성공을 보장하지 않는다.
14줄F01-L14 @Test 네 서랍 검사를 실행 목록에 올리는 JUnit 도장을 찍는다. 바로 다음 method를 JUnit test로 표시한다.
입력
JUnit discovery가 method annotation을 scan한다.
결과·효과
v001CreatesExactlyTheRequiredCoreTables가 executable test descriptor로 등록된다.
비유의 한계
발견됐다는 것과 실제 실행·통과했다는 것은 다르다.
15줄F01-L15 void v001CreatesExactlyTheRequiredCoreTables() { 정확히 필요한 표만 있는지 묻는 검사표를 펼친다. v001CreatesExactlyTheRequiredCoreTables test method 본문을 시작한다.
입력
Spring context와 JdbcClient 준비 뒤 test runner가 이 method를 호출한다.
결과·효과
table 이름 query와 exact-set assertion을 수행할 call frame이 열린다.
비유의 한계
method 이름은 V001 전체 schema를 증명하는 assertion이 아니다.
16줄F01-L16 var tables = jdbc.sql(""" 찾은 table 이름을 담을 `tables` 바구니를 SQL 창구에 건다. JdbcClient query 결과를 var tables에 대입하는 문장을 시작한다.
입력
injected jdbc가 뒤 Java text block SQL을 받을 준비를 한다.
결과·효과
query terminal이 끝나면 List<String>을 받을 변수 type이 추론될 자리가 생긴다.
비유의 한계
이 줄 시점에는 SQL text와 database 실행이 완성되지 않았다.
17줄F01-L17 SELECT table_name FROM information_schema.tables 재고표에서 각 서랍의 `table_name` 칸만 읽게 한다. information_schema.tables에서 반환할 projection을 table_name으로 정한다.
입력
PostgreSQL parser가 SELECT list를 구성한다.
결과·효과
각 결과 row가 table 이름 String 하나로 mapping될 모양을 갖는다.
비유의 한계
column·constraint·index 정보는 projection에 없다.
18줄F01-L18 WHERE table_schema='public' AND table_type='BASE TABLE' `public` 방의 진짜 기본 서랍만 검사선에 남긴다. table_schema가 public이고 table_type이 BASE TABLE인 row만 filter한다.
입력
information_schema.tables row들에 두 equality predicate를 적용한다.
결과·효과
view와 다른 schema relation이 candidate set에서 빠진다.
비유의 한계
public의 모든 base table은 다음 제외 조건 외에는 계속 포함된다.
19줄F01-L19 AND table_name <> 'flyway_schema_history' Flyway 작업 일지 서랍은 제품 서랍 수에서 빼 둔다. flyway_schema_history table name을 결과에서 제외한다.
입력
앞 filter를 통과한 public base table 이름마다 불일치 조건을 평가한다.
결과·효과
migration history row가 product table 목록에 들어가지 않는다.
비유의 한계
다른 관리용 table을 자동 제외하는 일반 규칙은 아니다.
20줄F01-L20 ORDER BY table_name 남은 서랍 이름을 가나다순 꼬리표로 정렬한다. 조회 결과를 table_name 오름차순으로 정렬한다.
입력
PostgreSQL이 filtered table rows에 ORDER BY를 수행한다.
결과·효과
driver로 전달되는 name sequence가 deterministic alphabetical order가 된다.
비유의 한계
뒤 assertion은 순서를 무시하므로 정렬 자체가 exact-set 조건은 아니다.
21줄F01-L21 """).query(String.class).list(); SQL 두루마리를 닫고 이름을 String 목록으로 한꺼번에 받아 온다. text block을 닫고 query 결과를 String으로 mapping해 list로 실행한다.
입력
JdbcClient가 완성된 SQL을 PostgreSQL에 보내 result rows를 읽는다.
결과·효과
tables 변수에 조회된 public base-table names list가 저장된다.
비유의 한계
connection·SQL 오류면 assertion 전에 method가 예외로 끝난다.
22줄F01-L22 assertThat(tables) 받아 온 서랍 목록을 AssertJ 저울 위에 올린다. tables를 assertThat assertion subject로 만든다.
입력
Java가 List<String>을 AssertJ collection assertion 객체에 넘긴다.
결과·효과
다음 description과 exact-membership matcher를 연결할 fluent assertion이 생긴다.
비유의 한계
terminal assertion을 호출하기 전에는 pass/fail이 판정되지 않는다.
23줄F01-L23 .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES") 저울이 틀릴 때 보일 W6D1 빨간 안내 문구를 붙인다. assertion failure description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
입력
AssertJ assertion object에 diagnostic label을 저장한다.
결과·효과
뒤 exact comparison이 실패하면 이 label이 오류 메시지에 포함된다.
비유의 한계
RED 문자열은 test를 일부러 실패시키거나 별도 evidence를 만들지 않는다.
24줄F01-L24 .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request"); 서랍 이름이 네 장 티켓과 정확히 같은 묶음인지 순서 없이 맞춘다. 실제 list가 네 expected table name과 정확히 같은 원소인지 assert한다.
입력
조회 list와 account·business_tx·ledger_entry·idempotency_request의 크기·membership을 비교한다.
결과·효과
누락이나 추가 table이 있으면 실패하고 exact four-name set이면 통과한다.
비유의 한계
각 table의 columns, PK/FK, constraints, indexes는 검사하지 않는다.
25줄F01-L25 } 네 이름 대조 시험표를 접어 method를 끝낸다. v001CreatesExactlyTheRequiredCoreTables method block을 닫는다.
입력
앞 query와 assertion이 예외 없이 끝난 control flow가 여기 도달한다.
결과·효과
JUnit이 이 method execution을 successful completion 후보로 기록한다.
비유의 한계
닫는 brace는 새로운 schema 검증을 추가하지 않는다.
26줄F01-L26 } CoreSchemaIT 검사실 외벽을 마지막으로 닫는다. CoreSchemaIT class block을 닫는다.
입력
compiler가 class declaration의 lexical scope를 종료한다.
결과·효과
한 test method와 한 injected field를 가진 staged type 정의가 완성된다.
비유의 한계
class 완성은 W14 reference root가 이 stage file을 기본 compile한다는 뜻이 아니다.
정렬과 matcher히토리 → 니지카 → 료 → 키타
  1. 히토리

    ORDER BY가 있으니 expected도 그 순서여야 하나요?

  2. 니지카

    읽기 결과는 정렬하지만 matcher는 InAnyOrder라 membership을 봐.

  3. query determinism과 assertion order requirement는 다른 계약이다.

  4. 키타

    네 이름을 섞은 예와 extra 한 개 예를 나눠 보겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F01-C01 · package and imports1–8줄
1–8줄 원본
package com.example.financialcore;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
F01-C02 · test class and JDBC dependency9–12줄
9–12줄 원본

@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
F01-C03 · schema inventory query and exact assertion13–23줄
13–23줄 원본

    @Test
    void v001CreatesExactlyTheRequiredCoreTables() {
        var tables = jdbc.sql("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema='public' AND table_type='BASE TABLE'
                  AND table_name <> 'flyway_schema_history'
                ORDER BY table_name
                """).query(String.class).list();
        assertThat(tables)
            .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
F01-C04 · test and class close24–26줄
24–26줄 원본
            .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 22 / 22

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

준비·설명 줄 6개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore;이 Java 파일의 package 주소를 `com.example.financialcore`로 정한다.
3import org.junit.jupiter.api.Test;뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
4import org.springframework.beans.factory.annotation.Autowired;뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
5import org.springframework.boot.test.context.SpringBootTest;뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
6import org.springframework.jdbc.core.simple.JdbcClient;뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
8import static org.assertj.core.api.Assertions.assertThat;assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다.
원본한국어 번역
10@SpringBootTestCoreSchemaIT를 Spring Boot application context 기반 test로 표시한다.
11class CoreSchemaIT extends PostgresIntegrationTestSupport {CoreSchemaIT가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
12 @Autowired JdbcClient jdbc;Spring이 JdbcClient bean을 jdbc field에 주입하도록 선언한다.
14 @Test바로 다음 method를 JUnit test로 표시한다.
15 void v001CreatesExactlyTheRequiredCoreTables() {v001CreatesExactlyTheRequiredCoreTables test method 본문을 시작한다.
16 var tables = jdbc.sql("""JdbcClient query 결과를 var tables에 대입하는 문장을 시작한다.
17 SELECT table_name FROM information_schema.tablesinformation_schema.tables에서 반환할 projection을 table_name으로 정한다.
18 WHERE table_schema='public' AND table_type='BASE TABLE'table_schema가 public이고 table_type이 BASE TABLE인 row만 filter한다.
19 AND table_name <> 'flyway_schema_history'flyway_schema_history table name을 결과에서 제외한다.
20 ORDER BY table_name조회 결과를 table_name 오름차순으로 정렬한다.
21 """).query(String.class).list();text block을 닫고 query 결과를 String으로 mapping해 list로 실행한다.
22 assertThat(tables)tables를 assertThat assertion subject로 만든다.
23 .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")assertion failure description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
24 .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");실제 list가 네 expected table name과 정확히 같은 원소인지 assert한다.
25 }v001CreatesExactlyTheRequiredCoreTables method block을 닫는다.
26}CoreSchemaIT class block을 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

Spring/PostgreSQL staged fixture에서 public BASE TABLE name set minus Flyway history가 exact four core names인지 AssertJ로 검증한다.

문법 해부

  • @SpringBootTest와 support 상속은 application context·database fixture를 요구한다.
  • Java text block은 여러 줄 information_schema query를 한 String으로 만든다.
  • JdbcClient.query(String.class).list()는 첫 projection을 String list로 materialize한다.
  • containsExactlyInAnyOrder는 collection 순서 대신 exact multiplicity와 membership을 본다.
  • .as(...)는 failure description일 뿐 assertion 결과를 바꾸지 않는다.

실행 순서

  1. JUnit discovery
  2. Spring/PostgreSQL fixture bootstrap
  3. JdbcClient injection
  4. information_schema query
  5. Flyway row 제외
  6. String list materialize
  7. exact-set assertion

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

F01-C01 · package and imports
문법 해부
package는 com.example.financialcore namespace를 정하고, JUnit·SpringBootTest·JdbcClient·AssertJ import는 이 compilation unit이 사용할 type과 assertion 진입점을 준비한다.
실제 값 추적
compiler가 com.example.financialcore 주소와 Test, Autowired, SpringBootTest, JdbcClient, assertThat symbol을 해석할 수 있게 된다.
정상 예
이 범위 뒤에서 @Test와 assertThat을 qualified name 없이 참조할 수 있는 상태가 이 import set의 유효한 결과다.
틀린 예·반례
import가 있다는 이유만으로 Spring context가 이미 시작됐거나 PostgreSQL schema가 Green이라고 결론 내리면 틀리다.
착각 방지
package/import는 실행 assertion이 아니므로 mapping 표에서는 제외하지만, 모든 비공백 줄 번역에는 남긴다.
하지 않는 일
이 범위는 compile 문맥만 제공하며 table 존재·column·constraint·migration 성공을 검증하지 않는다.
다음 연결
다음 ‘test class and JDBC dependency’ 범위에서는 @SpringBootTest가 application context test를 요청하고, CoreSchemaIT는 PostgresIntegrationTestSupport를 상속하며 JdbcClient field를 주입받는다.
F01-C02 · test class and JDBC dependency
문법 해부
@SpringBootTest가 application context test를 요청하고, CoreSchemaIT는 PostgresIntegrationTestSupport를 상속하며 JdbcClient field를 주입받는다.
실제 값 추적
test instance를 만들 때 inherited PostgreSQL support와 Spring bean resolution이 먼저 준비되고 jdbc field가 query 통로가 된다.
정상 예
context bootstrap과 type-compatible bean resolution이 성공해 test instance의 jdbc field가 실제 reference를 받는 것이 이 범위의 유효 상태다.
틀린 예·반례
annotation과 field 선언만 보고 migration 결과나 schema 구조가 정확하다고 읽을 수는 없다.
착각 방지
이 파일은 W6D1 stage reference라 default project root가 이 exact byte를 active test로 compile한다고 가정하면 안 된다.
하지 않는 일
class wiring은 DB inventory assertion과 분리되며, 연결 성공만으로 네 core table의 구조까지 보장하지 않는다.
다음 연결
다음 ‘schema inventory query and exact assertion’ 범위에서는 @Test method가 information_schema.tables에서 public BASE TABLE을 읽고 flyway_schema_history를 제외한 뒤 이름순 list로 만들며 AssertJ 설명 label을 붙인다.
F01-C03 · schema inventory query and exact assertion
문법 해부
@Test method가 information_schema.tables에서 public BASE TABLE을 읽고 flyway_schema_history를 제외한 뒤 이름순 list로 만들며 AssertJ 설명 label을 붙인다.
실제 값 추적
public BASE TABLE 가운데 flyway_schema_history가 아닌 이름들이 ORDER BY table_name 순서의 String list로 materialize된다.
정상 예
Flyway history 한 table만 제외한 clean schema에서 네 업무 table 이름이 list에 모이는 흐름이 정상 예다.
틀린 예·반례
public에 audit_event 같은 추가 BASE TABLE이 있으면 다음 exact collection assertion에서 실패할 수 있다.
착각 방지
`.as(...)`는 실패 메시지 label일 뿐 네 이름을 비교하는 최종 matcher는 다음 범위에 있다.
하지 않는 일
이 query는 table 이름 inventory만 보며 column type, PK/FK, index, row data를 검사하지 않는다.
다음 연결
다음 ‘test and class close’ 범위에서는 containsExactlyInAnyOrder가 네 core table 이름의 정확한 집합을 요구하고 method와 class brace를 닫는다.
F01-C04 · test and class close
문법 해부
containsExactlyInAnyOrder가 네 core table 이름의 정확한 집합을 요구하고 method와 class brace를 닫는다.
실제 값 추적
tables가 account, business_tx, ledger_entry, idempotency_request 네 값으로만 구성되면 assertion이 통과한다.
정상 예
순서가 달라도 네 이름이 정확히 한 번씩 존재하고 extra business table이 없으면 Green이다.
틀린 예·반례
필수 table 하나가 빠지거나 다섯 번째 업무 table이 들어오면 exact set 조건과 맞지 않는다.
착각 방지
이 assertion을 ‘모든 schema 제약이 맞다’로 확대하지 말고 이름 집합 direct proof로 한정한다.
하지 않는 일
test close는 새 검증을 추가하지 않으며, Flyway history는 앞 query에서 의도적으로 비교 대상에서 빠졌다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 test close는 새 검증을 추가하지 않으며, Flyway history는 앞 query에서 의도적으로 비교 대상에서 빠졌다.
RED label의 뜻히토리 → 니지카 → 료 → 키타
  1. 히토리

    label에 RED가 있으면 항상 실패하는 test인가요?

  2. 니지카

    아니, 틀렸을 때 찾기 쉬운 설명 이름이야.

  3. pass/fail은 containsExactlyInAnyOrder가 실제 list를 비교해 정한다.

  4. 키타

    label과 matcher를 서로 다른 줄로 읽겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1W6D1 stage application과 PostgreSQL fixtureSpring context와 inherited support를 준비한다.JdbcClient를 가진 CoreSchemaIT instance가 test 실행 후보가 된다.reference project root의 기본 source set에서 이 stage file이 compile되는지는 별도 문제다.
2information_schema.tables의 여러 schema·relation typepublic·BASE TABLE을 남기고 flyway_schema_history를 제외한다.application base-table 이름만 담은 rows가 만들어진다.다른 관리 table이 있으면 그대로 남아 exact assertion을 실패시킨다.
3예: account, business_tx, flyway history, ledger_entry, idempotency_requestprojection을 String list로 materialize한다.Flyway를 뺀 네 이름이 tables 변수에 들어간다.query·connection failure는 assertion보다 먼저 test를 중단한다.
4actual name list와 네 expected literalscontainsExactlyInAnyOrder로 exact membership을 비교한다.정확히 네 이름이면 pass, 하나라도 더하거나 빼면 W6D1 label과 함께 fail한다.table body의 column·constraint·index equality는 관찰하지 않는다.
staged source와 runner히토리 → 니지카 → 료 → 키타
  1. 히토리

    runner selector가 CoreSchemaIT면 이 파일 실행이 확정되죠?

  2. 니지카

    이 파일은 W6D1 stage 폴더에 있어 root 기본 source set과 달라.

  3. selector FQCN은 reference일 뿐 source path·SHA·fresh compilation 증거가 아니다.

  4. 키타

    stage 실행 증거가 없으면 reference-only라고 표시할게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

JUnit Platform

@Test method를 발견하고 Spring extension lifecycle 안에서 실행한다.

discovery된 source path나 SHA를 W14 smoke marker에 기록하지 않는다.
Spring Test

application context와 JdbcClient dependency injection을 준비한다.

MockMvc나 전체 application endpoint를 이 class가 사용하지 않는다.
PostgreSQL catalog

information_schema view가 visible relation metadata를 row로 제공한다.

information_schema query는 DDL을 다시 적용하거나 schema를 고치지 않는다.
AssertJ

actual list의 exact elements와 expected four strings를 순서 없이 비교한다.

failure description의 RED token은 test status를 조작하지 않는다.

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

v001CreatesExactlyTheRequiredCoreTables

Arrange · 준비
  • W6D1 staged Spring/PostgreSQL fixture를 준비한다.
  • JdbcClient가 public information_schema를 읽을 수 있다.
Act · 행동
  • public BASE TABLE 이름을 조회한다.
  • flyway_schema_history를 제외하고 String list로 만든다.
Assert · 확인
  • actual list가 account·business_tx·ledger_entry·idempotency_request와 정확히 같은 원소를 가진다.
직접 보장
  • 이 staged execution에서 Flyway history를 뺀 public base-table name set이 네 이름이다.
  • 누락·추가 table name이 없다.
보장하지 않음
  • column·constraint·index definition
  • 모든 schema·view
  • production database
  • W14 reference-root fresh execution
  • runner가 staged SHA를 사용함

첫 실패 경계 Spring context 또는 inherited PostgreSQL fixture가 먼저 실패할 수 있다. method에 들어오면 21줄 query execution이 assertion보다 앞서고, 반환되면 24줄 exact-set comparison에서 첫 값 불일치가 드러난다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 네 table 이름이 맞으니 전체 schema가 맞다.

왜 틀리나 SELECT projection에는 table_name만 있다.

바르게 읽기 이 test의 직접 증명을 public base-table name set으로 한정한다.

반례 account.balance CHECK가 없어도 table 이름은 account로 남아 이 assertion이 통과할 수 있다.

❌ ORDER BY를 했으니 expected 순서도 반드시 같아야 한다.

왜 틀리나 terminal matcher는 containsExactlyInAnyOrder다.

바르게 읽기 query 결과 안정화와 assertion의 order semantics를 따로 읽는다.

반례 actual 네 이름의 순서를 바꿔도 exact members가 같으면 matcher는 통과한다.

❌ W6D1_RED 문자열은 이 test가 의도적으로 실패한다는 뜻이다.

왜 틀리나 .as는 실패할 때 붙일 설명만 설정한다.

바르게 읽기 assertion matcher의 실제 값 비교가 pass/fail을 정한다.

반례 네 이름이 맞으면 RED label이 있어도 method는 정상 종료한다.

❌ W14 runner가 CoreSchemaIT 이름을 썼으니 이 staged file을 current root에서 실행했다.

왜 틀리나 stage path는 root 기본 test source set 밖이고 runner는 source SHA를 기록하지 않는다.

바르게 읽기 staged direct proof와 runner name reference를 분리한다.

반례 class selector가 source set에 없으면 Gradle은 이 file을 compile·execute하지 못한다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

schema depth

column type, nullability, PK/FK/UNIQUE/CHECK, index definition

이 책임을 맡는 곳: 별도 catalog queries와 migration/schema integration tests
execution binding

W14 root runner가 이 staged SHA의 class를 compile·execute함

이 책임을 맡는 곳: stage source-set assembly, source hash manifest, fresh JUnit XML binding
environment

production schema와 다른 database/schema의 동일성

이 책임을 맡는 곳: 배포 migration 검증과 운영 read-only catalog evidence
temporal scope

이름 목록이 test 실행 뒤에도 계속 변하지 않음

이 책임을 맡는 곳: migration change control과 실행 시각이 결박된 evidence
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

Spring fixture→public base table query→Flyway 제외→String list→네 이름 exact set 순서로 말한다.

2단계 · 코드 조각 재조립

  1. @SpringBootTest/class/JdbcClient
  2. information_schema SELECT/WHERE
  3. Flyway exclusion/ORDER BY
  4. query list/assertThat/as/containsExactlyInAnyOrder

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

정본을 보지 않고 26개 물리 줄을 다시 쓰고 mapping 16행·번역 22행·@Test 1개와 SHA-256을 대조한다.

자가 점검
  • package/import는 번역에 있고 mapping에서만 제외한다.
  • public과 BASE TABLE 두 조건을 모두 적는다.
  • flyway_schema_history만 제외한다.
  • expected 네 이름을 빠짐없이 적는다.
  • table 내부 schema까지 증명한다고 쓰지 않는다.
  • stage source와 W14 runner reference를 구분한다.
13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w6/day-1/CoreSchemaIT.javaSHA-256 30d21e5fd028dab32aa797263a8b2793a8e397ce663aaef08000304e70cbbb48
CoreSchemaIT.java — public base table 이름 네 개를 exact set으로 확인 전체
package com.example.financialcore;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;

    @Test
    void v001CreatesExactlyTheRequiredCoreTables() {
        var tables = jdbc.sql("""
                SELECT table_name FROM information_schema.tables
                WHERE table_schema='public' AND table_type='BASE TABLE'
                  AND table_name <> 'flyway_schema_history'
                ORDER BY table_name
                """).query(String.class).list();
        assertThat(tables)
            .as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
            .containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
    }
}
02

OpeningIntegrationTest.java — 개설 성공 뒤 account·transaction·ledger를 각각 count

reference/w6/day-3/OpeningIntegrationTest.java

누적 Java 테스트 참고본 · 정본 · W14-F02
21줄 연결29줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

W6D3 staged service가 OPEN-100 개설을 정상 반환한 뒤 세 persistence 흔적이 각각 한 행인지 확인한다.

  1. 왜 매 test 전에 네 table을 TRUNCATE할까?
  2. customer-1·OPEN-100·12,345 중 무엇을 직접 다시 읽어 확인할까?
  3. account·business_tx·ledger의 count 1은 각각 무엇을 증명할까?
  4. method 이름의 Atomically가 rollback test를 대신할까?
  5. 세 SELECT가 하나의 atomic snapshot이라는 근거가 source에 있을까?
이 파일에서 끝까지 다시 쓰는 값owner=customer-1accountNo=OPEN-100opening=12_345account id count=1OPENING business_tx count=1same-account OPENING ledger count=1@Test count=1stage=W6D3
02

STEP 02 / 13

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

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

STARRY가 OPEN-100 개설표를 처리한 뒤 계좌·거래·원장 영수증을 각각 센다.

개설표 한 장 뒤 세 영수증 세기

먼저 네 장부를 비우고 customer-1의 OPEN-100 계좌를 12,345로 연다. 서비스가 돌려준 계좌 ID를 받는다.

그 ID의 계좌 한 행, OPENING 거래 한 행, 같은 ID의 OPENING 원장 한 행을 서로 다른 SQL로 세어 모두 1인지 본다.

딱 여기까지만 성공 뒤 영수증 수만 보므로 중간 고장 rollback, 금액·잔액, 세 조회의 단일 snapshot까지 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

빈 시험장 만들기

BeforeEach가 네 table을 비우고 identity를 되감아 이전 test 흔적을 없앤다.

코드 연결
17~20줄
비유
새 공연 정산 전에 어제 영수증과 번호표를 모두 치운다.
비유의 끝
운영 data 정리 절차가 아니라 이 staged test fixture의 파괴적 초기화다.

서비스 한 번 호출

owner·account number·opening amount를 open에 넘기고 반환 Account를 받는다.

코드 연결
24줄
비유
customer-1의 OPEN-100 개설표와 12,345 시작 금액을 창구에 한 번 낸다.
비유의 끝
정상 반환만으로 database의 모든 field가 입력과 같다는 뜻은 아니다.

세 영수증 독립 count

account, OPENING business_tx, same-account OPENING ledger를 세 SELECT로 각각 센다.

코드 연결
25~32줄
비유
계좌함·거래함·원장함을 차례로 열어 영수증이 한 장씩인지 본다.
비유의 끝
세 함을 같은 순간에 찍은 사진이라고 source가 보장하지 않는다.

누적 reference

이 source는 W14 신규 구현이 아니라 W6D3 stage의 역사적 integration test다.

코드 연결
전체 34줄
비유
예전 공연 개설 체크표를 이번 smoke 목록에서 다시 가리킨다.
비유의 끝
reference root 기본 source set은 이 stage file을 자동 compile하지 않는다.
세 영수증의 범위히토리 → 니지카 → 료 → 키타
  1. 히토리

    세 count가 1이면 개설이 완벽하다는 뜻인가요?

  2. 니지카

    성공 뒤 account·OPENING tx·same-account ledger가 하나씩 있다는 뜻이야.

  3. 각 SQL이 읽지 않은 금액·연결·rollback은 직접 증명 밖이다.

  4. 키타

    세 query의 WHERE와 projection을 따로 표시하겠습니다.

청소의 이유히토리 → 니지카 → 료 → 키타
  1. 히토리

    identity까지 왜 다시 1부터 시작하나요?

  2. 니지카

    이전 test row와 generated ID 흔적을 제거해 fixture를 재현하려는 거야.

  3. CASCADE는 의존 row까지 지우는 파괴적 test 명령이며 운영 절차가 아니다.

  4. 키타

    대상 네 table과 시험 환경 한계를 함께 적을게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.21 / 21 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
12줄F02-L12 @SpringBootTest 계좌 개설 보존 시험장에 Spring 전체 장비 호출표를 단다. OpeningIntegrationTest를 Spring Boot context 기반 integration test로 표시한다.
입력
JUnit이 staged OpeningIntegrationTest class의 annotation을 해석한다.
결과·효과
account opening service와 PostgreSQL support를 준비할 application bootstrap이 시작된다.
비유의 한계
annotation만으로 migration·bean 주입·test 성공을 보장하지 않는다.
13줄F02-L13 class OpeningIntegrationTest extends PostgresIntegrationTestSupport { 개설 시험관을 공용 PostgreSQL 바닥판에 연결한다. OpeningIntegrationTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
입력
JVM이 test class와 database-support superclass 관계를 load한다.
결과·효과
test instance가 inherited PostgreSQL fixture lifecycle을 사용할 수 있게 된다.
비유의 한계
support 구현과 container 상태는 이 source 한 줄에 나타나지 않는다.
14줄F02-L14 @Autowired JdbcClient jdbc; 행 수를 셀 SQL 계수기를 첫 손에 쥐여 준다. Spring이 OpeningIntegrationTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
입력
application context에 JdbcClient bean과 test instance가 준비된다.
결과·효과
뒤 account·business_tx·ledger_entry COUNT query를 보낼 참조가 생긴다.
비유의 한계
field injection만으로 세 SELECT가 한 transaction에 묶이지 않는다.
15줄F02-L15 @Autowired AccountOpeningService openings; 실제 개설 절차를 부를 `openings` 창구도 옆에 연결한다. Spring이 AccountOpeningService bean을 openings field에 주입하도록 선언한다.
입력
context가 이 staged test에 맞는 account-opening service bean을 resolve한다.
결과·효과
test body가 openings.open을 호출할 application service 참조를 얻는다.
비유의 한계
bean 존재는 open 호출의 transaction·rollback 결과를 미리 증명하지 않는다.
17줄F02-L17 @BeforeEach 매 시험 시작 전에 네 장부를 비우는 청소표를 붙인다. 바로 다음 clean method를 JUnit BeforeEach fixture로 표시한다.
입력
JUnit이 각 @Test 실행 전에 호출할 lifecycle method를 discovery한다.
결과·효과
opening test 본문보다 먼저 clean을 실행하는 hook이 등록된다.
비유의 한계
이 hook은 이 class 밖의 test나 다른 database를 자동 청소하지 않는다.
18줄F02-L18 void clean() { 네 장부 청소 작업실의 문을 연다. void clean method의 본문을 시작한다.
입력
JUnit이 test instance의 BeforeEach phase에서 clean을 호출한다.
결과·효과
TRUNCATE statement를 실행할 call frame이 열린다.
비유의 한계
method 진입만으로 기존 row가 아직 지워진 것은 아니다.
19줄F02-L19 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update(); 멱등·원장·거래·계좌 장부를 비우고 번호표까지 처음으로 되감는다. 네 table을 TRUNCATE하고 identity를 restart하며 의존 관계에 CASCADE를 적용한다.
입력
stage database에 이전 test가 남긴 idempotency_request·ledger_entry·business_tx·account row가 있을 수 있다.
결과·효과
네 table이 비고 generated identity sequence가 초기 상태로 돌아간다.
비유의 한계
destructive test fixture이며 audit_event를 지우거나 운영 data를 보존하는 명령이 아니다.
20줄F02-L20 } 청소가 끝난 뒤 시험장 문을 닫는다. OpeningIntegrationTest의 clean BeforeEach method block을 닫는다.
입력
TRUNCATE update가 정상 반환한 control flow가 brace에 도달한다.
결과·효과
JUnit lifecycle이 본 test method 실행 단계로 넘어갈 수 있다.
비유의 한계
TRUNCATE 실패 시 test body는 시작하지 않는다.
22줄F02-L22 @Test OPEN-100 보존 검사를 JUnit 실행 목록에 올린다. 바로 다음 opening method를 @Test로 표시한다.
입력
JUnit discovery가 method annotation을 읽는다.
결과·효과
openingWritesAccountBusinessTransactionAndLedgerAtomically가 test descriptor가 된다.
비유의 한계
이름의 Atomically는 failure-injection assertion을 자동 추가하지 않는다.
23줄F02-L23 void openingWritesAccountBusinessTransactionAndLedgerAtomically() { 계좌·거래·원장 세 영수증 검사표를 펼친다. openingWritesAccountBusinessTransactionAndLedgerAtomically method 본문을 시작한다.
입력
BeforeEach clean이 끝난 test instance에서 runner가 이 method를 호출한다.
결과·효과
service call과 세 independent count assertion을 수행할 frame이 열린다.
비유의 한계
method 선언 자체는 transaction boundary가 아니다.
24줄F02-L24 var account = openings.open("customer-1", "OPEN-100", 12_345); customer-1이 OPEN-100 계좌를 12,345로 열고 반환표를 받는다. openings.open에 owner, account number, opening amount를 넘겨 반환 Account를 저장한다.
입력
빈 네 table과 입력 customer-1·OPEN-100·12_345를 service에 전달한다.
결과·효과
service가 정상 반환하면 account 변수에 생성된 account 객체가 들어간다.
비유의 한계
정상 반환만으로 중간 failure rollback이나 반환 객체의 모든 field를 검증하지 않는다.
25줄F02-L25 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id") 반환된 ID 계좌가 몇 개인지 SQL 자를 대기시킨다. account에서 id=:id인 row의 COUNT query assertion을 시작한다.
입력
openings.open이 반환한 account와 clean fixture 뒤 database를 사용한다.
결과·효과
named parameter binding과 single Long 조회를 받을 assertion chain이 만들어진다.
비유의 한계
owner_id·account_no·balance 값은 이 projection에 없다.
26줄F02-L26 .param("id", account.getId()).query(Long.class).single()).isEqualTo(1); 반환표 ID를 `:id`에 끼워 계좌 한 줄을 확인한다. account.getId를 bind해 COUNT를 실행하고 결과가 1인지 assert한다.
입력
account variable의 generated ID와 앞 COUNT SQL specification을 사용한다.
결과·효과
그 ID를 가진 account row가 정확히 하나면 첫 persistence check가 통과한다.
비유의 한계
OPEN-100·customer-1·12,345가 그 row에 저장됐는지는 직접 비교하지 않는다.
27줄F02-L27 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'") 개설 거래 영수증 전체에서 OPENING 도장 수를 세기 시작한다. business_tx에서 tx_type='OPENING'인 row의 COUNT assertion을 연다.
입력
clean 뒤 한 번의 openings.open 호출이 끝난 database를 조회한다.
결과·효과
OPENING transaction row count를 Long으로 받을 query chain이 준비된다.
비유의 한계
account ID나 correlation ID와 이 transaction을 join하지 않는다.
28줄F02-L28 .query(Long.class).single()).isEqualTo(1); OPENING 거래 수를 Long 한 개와 맞춘다. business_tx COUNT를 실행해 single Long이 1인지 assert한다.
입력
앞 줄의 OPENING filter와 현재 database state를 입력으로 쓴다.
결과·효과
OPENING business_tx가 정확히 한 행이면 둘째 persistence check가 통과한다.
비유의 한계
transaction status·requested time·account 연계는 확인하지 않는다.
29줄F02-L29 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'") 반환 계좌에 붙은 OPENING 원장 줄 수를 세기 시작한다. ledger_entry에서 account_id=:id이고 entry_type='OPENING'인 COUNT assertion을 연다.
입력
앞에서 생성한 account ID와 clean fixture 뒤 ledger table을 사용한다.
결과·효과
account와 type 두 조건을 가진 count query specification이 생긴다.
비유의 한계
business_tx_id·amount·signed_amount·balance_after는 projection에 없다.
30줄F02-L30 .param("id", account.getId()).query(Long.class).single()) 같은 계좌 ID를 넣어 원장 count 한 값을 꺼낸다. ledger COUNT query에 account.getId를 bind하고 single Long을 조회한다.
입력
account 변수의 ID와 앞 ledger SQL을 JdbcClient에 넘긴다.
결과·효과
조건에 맞는 OPENING ledger row 수가 AssertJ chain으로 전달된다.
비유의 한계
아직 expected 1 비교는 다음 줄에서 수행한다.
31줄F02-L31 .as("W6D3_RED_EXPECTED_OPENING_LEDGER") 원장 누락이면 찾기 쉬운 W6D3 빨간 제목을 건다. ledger assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
입력
앞 count assertion object에 diagnostic label 문자열을 전달한다.
결과·효과
다음 equality가 실패하면 W6D3 label이 failure message에 나타난다.
비유의 한계
label의 RED는 실패를 강제하거나 rollback 증거를 만들지 않는다.
32줄F02-L32 .isEqualTo(1); OPENING 원장이 정확히 한 줄인지 최종 눈금과 맞춘다. ledger count가 1인지 assert한다.
입력
account ID와 OPENING type으로 조회된 single Long을 expected 1과 비교한다.
결과·효과
조건에 맞는 ledger row가 정확히 하나면 셋째 persistence check가 통과한다.
비유의 한계
12,345 amount·balance·transaction 연결은 직접 assert하지 않는다.
33줄F02-L33 } 세 count 검사표를 접어 opening test를 끝낸다. opening test method block을 닫는다.
입력
service call과 세 assertions가 예외 없이 반환한 control flow가 도달한다.
결과·효과
JUnit이 이 success-path method의 정상 종료를 기록할 수 있다.
비유의 한계
서로 다른 세 SELECT가 한 atomic snapshot에서 읽혔다는 뜻은 아니다.
34줄F02-L34 } OpeningIntegrationTest 시험장 외벽을 닫는다. OpeningIntegrationTest class block을 닫는다.
입력
compiler가 staged test class의 lexical scope를 종료한다.
결과·효과
두 injected fields, 한 BeforeEach, 한 @Test를 가진 type 정의가 완성된다.
비유의 한계
이 stage-only type은 reference project root의 기본 test source set에 자동 포함되지 않는다.
12,345는 어디서 확인하나히토리 → 니지카 → 료 → 키타
  1. 히토리

    open에 12,345를 줬으니 DB 금액도 확인한 거죠?

  2. 니지카

    호출 입력에는 있지만 뒤 SQL은 COUNT만 읽어.

  3. 값 전달과 persisted amount equality assertion은 다른 관찰이다.

  4. 키타

    별도 amount SELECT가 필요하다고 오답 옆에 쓰겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F02-C01 · package and imports1–10줄
1–10줄 원본
package com.example.financialcore.account;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
F02-C02 · test class and dependencies11–16줄
11–16줄 원본

@SpringBootTest
class OpeningIntegrationTest extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
F02-C03 · fixture reset17–20줄
17–20줄 원본
    @BeforeEach
    void clean() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    }
F02-C04 · opening call and three persistence assertions21–34줄
21–34줄 원본

    @Test
    void openingWritesAccountBusinessTransactionAndLedgerAtomically() {
        var account = openings.open("customer-1", "OPEN-100", 12_345);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")
            .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")
            .param("id", account.getId()).query(Long.class).single())
            .as("W6D3_RED_EXPECTED_OPENING_LEDGER")
            .isEqualTo(1);
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 29 / 29

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

준비·설명 줄 8개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.account;이 Java 파일의 package 주소를 `com.example.financialcore.account`로 정한다.
3import com.example.financialcore.PostgresIntegrationTestSupport;뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
4import org.junit.jupiter.api.BeforeEach;뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
5import org.junit.jupiter.api.Test;뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
6import org.springframework.beans.factory.annotation.Autowired;뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
7import org.springframework.boot.test.context.SpringBootTest;뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
8import org.springframework.jdbc.core.simple.JdbcClient;뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
10import static org.assertj.core.api.Assertions.assertThat;assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다.
원본한국어 번역
12@SpringBootTestOpeningIntegrationTest를 Spring Boot context 기반 integration test로 표시한다.
13class OpeningIntegrationTest extends PostgresIntegrationTestSupport {OpeningIntegrationTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
14 @Autowired JdbcClient jdbc;Spring이 OpeningIntegrationTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
15 @Autowired AccountOpeningService openings;Spring이 AccountOpeningService bean을 openings field에 주입하도록 선언한다.
17 @BeforeEach바로 다음 clean method를 JUnit BeforeEach fixture로 표시한다.
18 void clean() {void clean method의 본문을 시작한다.
19 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();네 table을 TRUNCATE하고 identity를 restart하며 의존 관계에 CASCADE를 적용한다.
20 }OpeningIntegrationTest의 clean BeforeEach method block을 닫는다.
22 @Test바로 다음 opening method를 @Test로 표시한다.
23 void openingWritesAccountBusinessTransactionAndLedgerAtomically() {openingWritesAccountBusinessTransactionAndLedgerAtomically method 본문을 시작한다.
24 var account = openings.open("customer-1", "OPEN-100", 12_345);openings.open에 owner, account number, opening amount를 넘겨 반환 Account를 저장한다.
25 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")account에서 id=:id인 row의 COUNT query assertion을 시작한다.
26 .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);account.getId를 bind해 COUNT를 실행하고 결과가 1인지 assert한다.
27 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")business_tx에서 tx_type='OPENING'인 row의 COUNT assertion을 연다.
28 .query(Long.class).single()).isEqualTo(1);business_tx COUNT를 실행해 single Long이 1인지 assert한다.
29 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")ledger_entry에서 account_id=:id이고 entry_type='OPENING'인 COUNT assertion을 연다.
30 .param("id", account.getId()).query(Long.class).single())ledger COUNT query에 account.getId를 bind하고 single Long을 조회한다.
31 .as("W6D3_RED_EXPECTED_OPENING_LEDGER")ledger assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
32 .isEqualTo(1);ledger count가 1인지 assert한다.
33 }opening test method block을 닫는다.
34}OpeningIntegrationTest class block을 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

clean fixture에서 AccountOpeningService.open을 정상 호출한 뒤 세 independent COUNT 결과가 각각 1인지 AssertJ로 확인한다.

문법 해부

  • @BeforeEach는 각 @Test보다 먼저 fixture cleanup method를 호출한다.
  • TRUNCATE ... RESTART IDENTITY CASCADE는 row와 identity state를 함께 초기화한다.
  • 12_345는 Java numeric separator를 쓴 int literal이며 값은 12345다.
  • JdbcClient의 :id는 .param으로 반환 account ID와 binding된다.
  • .single()은 COUNT의 단일 Long을 읽고 isEqualTo(1)은 값 비교를 끝낸다.

실행 순서

  1. JUnit discovery
  2. Spring/PostgreSQL fixture bootstrap
  3. BeforeEach TRUNCATE
  4. openings.open
  5. account count
  6. business_tx count
  7. ledger count
  8. 세 assertion 종료

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

F02-C01 · package and imports
문법 해부
account package와 PostgreSQL support, BeforeEach/Test, Spring wiring, JdbcClient, AssertJ import가 이 Java unit의 compile 문맥을 만든다.
실제 값 추적
compiler는 PostgresIntegrationTestSupport, lifecycle annotations, JdbcClient, Spring test metadata와 assertion API symbols를 연결한다.
정상 예
나열된 types와 static assertThat을 뒤 source에서 짧은 이름으로 참조할 수 있는 상태가 이 범위의 유효 결과다.
틀린 예·반례
import 목록만으로 어떤 application bean이나 migration이 실제로 성공했다고 판단하면 안 된다.
착각 방지
준비 선언은 mapping body에서 제외해도 translation 대상이며 F01과 다른 account namespace를 보존해야 한다.
하지 않는 일
이 부분은 compile-time 이름 준비만 담당하며 application operation이나 persistence 결과를 직접 관찰하지 않는다.
다음 연결
다음 ‘test class and dependencies’ 범위에서는 @SpringBootTest class가 PostgreSQL support를 상속하고 JdbcClient와 AccountOpeningService 두 dependency를 Autowired field로 받는다.
F02-C02 · test class and dependencies
문법 해부
@SpringBootTest class가 PostgreSQL support를 상속하고 JdbcClient와 AccountOpeningService 두 dependency를 Autowired field로 받는다.
실제 값 추적
Spring test instance가 만들어질 때 jdbc와 openings 두 injection point에 type-compatible bean references가 연결될 준비가 된다.
정상 예
context가 기동되고 두 fields가 각각 JdbcClient와 AccountOpeningService instance를 가리키는 상태가 정상 wiring이다.
틀린 예·반례
field가 선언됐다는 사실만으로 어느 service method의 내부 transaction이나 persistence effect가 증명되지는 않는다.
착각 방지
OpeningIntegrationTest 역시 stage-only source라 selector 이름과 default root byte identity를 같은 것으로 보면 안 된다.
하지 않는 일
여기서는 dependency graph만 보며 인증, HTTP controller, idempotency replay를 다루지 않는다.
다음 연결
다음 ‘fixture reset’ 범위에서는 @BeforeEach clean은 네 table을 CASCADE TRUNCATE하고 identity를 다시 시작해 각 test를 빈 fixture에서 출발시킨다.
F02-C03 · fixture reset
문법 해부
@BeforeEach clean은 네 table을 CASCADE TRUNCATE하고 identity를 다시 시작해 각 test를 빈 fixture에서 출발시킨다.
실제 값 추적
test 직전 account·business_tx·ledger_entry·idempotency_request row가 제거되고 sequence 기준도 초기화된다.
정상 예
이 test class를 단독으로 순차 실행하면 이전 method의 persistence가 다음 assertion에 섞이지 않는다.
틀린 예·반례
같은 database를 다른 process가 동시에 사용하면 TRUNCATE가 그 실행의 data까지 건드릴 수 있다.
착각 방지
fixture reset을 production cleanup이나 transaction isolation proof로 읽지 않는다.
하지 않는 일
이 범위는 빈 시작 상태만 만들며 어떤 application operation의 성공이나 atomic rollback도 검사하지 않는다.
다음 연결
다음 ‘opening call and three persistence assertions’ 범위에서는 test가 customer-1, OPEN-100, 12345로 openings.open을 호출하고 반환 account id를 이용해 account 1행, OPENING business_tx 1행, OPENING ledger_entry 1행을 각각 센다.
F02-C04 · opening call and three persistence assertions
문법 해부
test가 customer-1, OPEN-100, 12345로 openings.open을 호출하고 반환 account id를 이용해 account 1행, OPENING business_tx 1행, OPENING ledger_entry 1행을 각각 센다.
실제 값 추적
성공 호출 뒤 세 SELECT COUNT 결과가 모두 1이면 `.isEqualTo(1)` assertion 세 개가 통과한다.
정상 예
빈 fixture에서 한 번 opening한 뒤 account·business transaction·ledger가 각 한 행인 상태가 이 source의 직접 Green 예다.
틀린 예·반례
중간 write 뒤 예외를 주입하지 않으므로 성공 후 세 count만으로 실패 시 atomic rollback을 증명할 수 없다.
착각 방지
method 이름의 ‘Atomically’보다 실제 assertion 범위를 우선해 성공 persistence 세 효과라고 읽는다.
하지 않는 일
balance 값, idempotency_request row, column 내용, 동시성은 이 세 count assertion의 책임 밖이다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 balance 값, idempotency_request row, column 내용, 동시성은 이 세 count assertion의 책임 밖이다.
Atomically라는 이름히토리 → 니지카 → 료 → 키타
  1. 히토리

    test 이름이 atomic이라고 선언하니 rollback도 Green 아닌가요?

  2. 니지카

    이름은 의도이고 body는 정상 호출 뒤 세 번 센 것뿐이야.

  3. failure injection이 없어서 all-or-nothing 실패 경로는 직접 관찰하지 않는다.

  4. 키타

    이름보다 arrange·act·assert를 기준으로 증명 범위를 정하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1이전 staged test가 남겼을 수 있는 네 table rowTRUNCATE ... RESTART IDENTITY CASCADE를 실행한다.account·business_tx·ledger_entry·idempotency_request가 빈 fixture로 시작한다.audit_event 등 source에 없는 table은 이 cleanup 범위가 아니다.
2customer-1, OPEN-100, 12_345openings.open을 한 번 호출한다.정상 반환하면 generated ID를 가진 account 객체가 local variable에 저장된다.이 test는 중간 failure를 주입하지 않는다.
3account.getId()account WHERE id=:id의 COUNT를 읽는다.같은 ID의 account row가 하나면 첫 assertion이 통과한다.owner_id·account_no·balance column 값을 projection하지 않는다.
4clean 뒤 open 호출이 끝난 business_txtx_type='OPENING' 전체 COUNT를 읽는다.OPENING transaction row가 하나면 둘째 assertion이 통과한다.그 row를 반환 account ID와 join하지 않는다.
5account.getId()와 entry_type='OPENING'ledger_entry COUNT를 읽고 W6D3 label을 붙여 1과 비교한다.같은 account의 OPENING ledger row가 하나면 셋째 assertion이 통과한다.amount·business_tx_id·balance_after는 직접 비교하지 않는다.
세 SELECT의 시점히토리 → 니지카 → 료 → 키타
  1. 히토리

    연달아 조회했으니 같은 snapshot 아닌가요?

  2. 니지카

    가깝게 실행됐어도 명시적 transaction·isolation이 source에 없어.

  3. 순차 execution order와 snapshot identity를 혼동하면 안 된다.

  4. 키타

    각 count를 independent postcondition read로 부르겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

JUnit Jupiter

BeforeEach clean을 실행한 뒤 한 @Test method를 호출한다.

lifecycle 순서가 다른 class의 database 격리까지 보장하지 않는다.
Spring service

injected AccountOpeningService bean이 개설 use case를 수행하고 Account를 반환한다.

service의 내부 transaction annotation과 실패 지점은 이 test source에 직접 나타나지 않는다.
PostgreSQL

fixture cleanup과 세 COUNT statement를 실행한다.

세 SELECT를 하나의 명시적 transaction이나 snapshot으로 감싸는 code가 없다.
AssertJ

세 Long count를 차례로 expected 1과 비교한다.

마지막 .as label은 ledger mismatch 진단만 돕고 atomicity를 검증하지 않는다.

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

openingWritesAccountBusinessTransactionAndLedgerAtomically

Arrange · 준비
  • BeforeEach에서 네 persistence table을 TRUNCATE하고 identity를 restart한다.
  • W6D3 staged Spring/PostgreSQL fixture와 AccountOpeningService를 준비한다.
Act · 행동
  • openings.open(customer-1, OPEN-100, 12_345)을 한 번 정상 호출한다.
  • 반환 account ID를 일부 count query에 bind한다.
Assert · 확인
  • 반환 ID의 account COUNT가 1이다.
  • tx_type OPENING인 business_tx COUNT가 1이다.
  • 반환 account ID와 entry_type OPENING인 ledger_entry COUNT가 1이다.
직접 보장
  • 이 clean staged fixture의 성공 실행에서 세 지정 count가 각각 1이다.
  • 서비스는 이 입력에 exception 없이 Account를 반환했다.
보장하지 않음
  • 중간 failure의 all-or-nothing rollback
  • 12,345 amount·balance equality
  • business_tx와 ledger의 exact relational linkage
  • 세 SELECT의 single snapshot
  • W14 reference-root fresh execution 또는 staged SHA binding

첫 실패 경계 BeforeEach TRUNCATE나 Spring fixture가 먼저 실패할 수 있다. method에 들어오면 open 호출, account count, business_tx count, ledger count 순서로 진행되어 처음 불일치한 assertion에서 멈춘다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ method 이름에 Atomically가 있으니 중간 실패 rollback도 확인했다.

왜 틀리나 source는 성공 호출 뒤 count만 하고 failure hook이나 assertThrows를 쓰지 않는다.

바르게 읽기 직접 증명을 성공 경로의 세 persistence count로 제한한다.

반례 account insert 뒤 exception에서 transaction row만 남는 버그는 이 정상 호출 하나로 재현하지 못한다.

❌ 입력 12,345가 account와 ledger에 정확히 저장됐다고 assert한다.

왜 틀리나 세 SQL projection은 모두 COUNT(*)이고 amount·balance column을 읽지 않는다.

바르게 읽기 입력값과 row 존재를 분리해 설명한다.

반례 잘못된 opening amount로 row 세 개가 생겨도 각 count가 1이면 이 assertion들은 통과할 수 있다.

❌ 세 count가 1이면 account·transaction·ledger가 서로 올바르게 연결됐다.

왜 틀리나 business_tx query에는 account ID join이 없고 ledger query도 business_tx_id를 보지 않는다.

바르게 읽기 각 WHERE가 관찰한 relation만 따로 적는다.

반례 무관한 OPENING business_tx 한 행이 있어도 둘째 count는 1이 될 수 있다.

❌ 세 SELECT는 한 시점의 atomic database 사진이다.

왜 틀리나 test method에 transaction boundary나 isolation snapshot을 선언한 code가 없다.

바르게 읽기 순차적인 독립 사후 조회라고 부른다.

반례 다른 transaction이 조회 사이에 commit하면 서로 다른 상태를 볼 수 있다.

❌ W14 runner selector가 이 W6D3 file의 실행 성공을 보장한다.

왜 틀리나 stage file은 root 기본 test source set 밖이고 marker에 source hash가 없다.

바르게 읽기 selector reference와 staged SHA-bound execution evidence를 구분한다.

반례 같은 FQCN을 못 찾는 root에서는 selector 실행이 시작되지 않는다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

failure atomicity

account·business_tx·ledger 중간 failure에서 전부 rollback됨

이 책임을 맡는 곳: failure injection integration test와 transaction-boundary 검증
field values

owner, account number, opening amount, balance, ledger amount의 정확성

이 책임을 맡는 곳: column-value queries 또는 repository/API response assertions
snapshot

세 COUNT가 같은 transaction snapshot에서 읽힘

이 책임을 맡는 곳: 명시적 read transaction과 isolation evidence
execution binding

reference root가 W6D3 staged SHA를 기본 compile·execute함

이 책임을 맡는 곳: stage source-set assembly, source manifest, fresh execution record
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

fixture cleanup→open(customer-1, OPEN-100, 12,345)→account count→OPENING tx count→same-account ledger count 순서로 말한다.

2단계 · 코드 조각 재조립

  1. @SpringBootTest/class/two beans
  2. @BeforeEach/TRUNCATE
  3. @Test/open
  4. account COUNT
  5. business_tx COUNT
  6. ledger COUNT/as/isEqualTo

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

정본 없이 34개 물리 줄을 다시 쓰고 mapping 21행·번역 29행·@Test 1개·SHA-256을 대조한다.

자가 점검
  • TRUNCATE 네 table과 RESTART IDENTITY CASCADE를 빠뜨리지 않는다.
  • 12_345 literal을 12345 값으로 읽는다.
  • account와 ledger에는 반환 ID를 bind한다.
  • business_tx는 OPENING type 전체 count다.
  • 금액·잔액·row 관계를 직접 assert했다고 쓰지 않는다.
  • Atomically 이름을 failure rollback 증거로 바꾸지 않는다.
13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w6/day-3/OpeningIntegrationTest.javaSHA-256 8c872765463ecbb40fe503b7c43355dee140371aa5d89e2fb7d90e89c3c49571
OpeningIntegrationTest.java — 개설 성공 뒤 account·transaction·ledger를 각각 count 전체
package com.example.financialcore.account;

import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class OpeningIntegrationTest extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;

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

    @Test
    void openingWritesAccountBusinessTransactionAndLedgerAtomically() {
        var account = openings.open("customer-1", "OPEN-100", 12_345);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE id=:id")
            .param("id", account.getId()).query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='OPENING'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE account_id=:id AND entry_type='OPENING'")
            .param("id", account.getId()).query(Long.class).single())
            .as("W6D3_RED_EXPECTED_OPENING_LEDGER")
            .isEqualTo(1);
    }
}
03

AccountControllerTest.java — MockMvc로 생성·거절·owner 조회·404 네 경로 확인

reference/w6/day-7/AccountControllerTest.java

누적 Java 테스트 참고본 · 정본 · W14-F03
51줄 연결65줄 번역6 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

W6D7 staged account API의 네 대표 요청을 MockMvc로 보내 status·일부 body token·제한된 DB count를 확인한다.

  1. MockMvc request는 실제 network port 호출과 무엇이 다를까?
  2. 201 생성 test는 response와 database의 어느 부분만 확인할까?
  3. 음수 거절에서 writesNothing은 정말 모든 table 0을 뜻할까?
  4. owner 조회 fixture를 왜 POST가 아니라 service.create로 만들까?
  5. contains 문자열 검사가 JSON 전체 contract를 증명할까?
  6. 같은 FQCN의 현재 root test와 W6D7 stage source를 어떻게 구분할까?
이 파일에서 끝까지 다시 쓰는 값POST /api/accountscustomer-1/passwordA-100/10000 -> 201BAD/-1 + w6-invalid -> 400INVALID_REQUESTaccount count=0LOOKUP/7000 -> GET 200GET 999999 + w6-missing -> 404ACCOUNT_NOT_FOUND@Test count=4stage=W6D7
02

STEP 02 / 13

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

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

STARRY가 MockMvc 접수대에 생성·음수 거절·주인 조회·없는 계좌 요청을 차례로 낸다.

가상 접수대의 네 요청표

A-100 생성은 201과 account 한 행을 보고, BAD/-1 생성은 400·오류 두 문자열·account 0행을 확인한다.

LOOKUP 계좌는 service로 미리 만든 뒤 GET 200과 이름 문자열을 보고, 999999 GET은 404와 오류 두 문자열을 본다.

딱 여기까지만 MockMvc·부분 문자열·제한된 count만 보므로 실제 network, JSON 전체 schema, 모든 권한·audit·side effect를 보장하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

가상 HTTP 접수대

MockMvc가 실제 port를 열지 않고 Spring MVC pipeline에 request를 넣어 response를 받는다.

코드 연결
18~23줄, 31~35줄
비유
STARRY 내부 연습 창구에서 접수 절차를 돌리되 거리의 실제 출입문은 통과하지 않는다.
비유의 끝
배포 proxy·TLS·network routing은 이 fixture에 없다.

성공 생성

A-100/10000 JSON POST의 status 201과 account_no A-100 row count 1을 확인한다.

코드 연결
29~39줄
비유
접수 완료 도장과 A-100 계좌함의 종이 한 장만 대조한다.
비유의 끝
response body·header와 transaction·ledger row는 직접 확인하지 않는다.

음수 거절

BAD/-1 POST가 400이고 body에 error code·request ID가 있으며 account 전체 count가 0인지 본다.

코드 연결
41~52줄
비유
음수 개설표에 거절 도장을 찍고 계좌함이 비었는지 확인한다.
비유의 끝
business_tx·ledger_entry·idempotency_request를 직접 세지 않는다.

owner 조회

service.create로 LOOKUP fixture를 만들고 owner 자격으로 GET해 200과 LOOKUP 문자열을 본다.

코드 연결
54~62줄
비유
창구 뒤에서 표를 미리 꽂아 둔 뒤 주인이 열람할 수 있는지 본다.
비유의 끝
POST 생성 endpoint와 GET을 한 end-to-end 흐름으로 연결한 test가 아니다.

없는 계좌

ID 999999를 GET해 404와 ACCOUNT_NOT_FOUND·w6-missing token을 확인한다.

코드 연결
64~72줄
비유
없는 번호표를 내면 못 찾음 도장과 추적 번호가 돌아오는지 본다.
비유의 끝
response JSON의 field type·exact equality는 검사하지 않는다.
MockMvc의 범위히토리 → 니지카 → 료 → 키타
  1. 히토리

    MockMvc로 201이면 실제 서버도 열어 본 건가요?

  2. 니지카

    Spring MVC 흐름은 돌리지만 실제 port와 network는 열지 않아.

  3. controller integration 증거와 deployed transport 증거를 분리해야 한다.

  4. 키타

    표 제목에 가상 접수대 범위를 남기겠습니다.

생성 test가 보는 것히토리 → 니지카 → 료 → 키타
  1. 히토리

    A-100 생성이면 ledger도 함께 생긴 게 확인되죠?

  2. 니지카

    이 method는 status 201과 account_no A-100 count 1만 봐.

  3. opening transaction·ledger·response body는 projection과 assertion에 없다.

  4. 키타

    직접 증명 두 개만 test 카드에 쓰겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.51 / 51 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
18줄F03-L18 @SpringBootTest HTTP 계좌 시험실의 application 전원을 올리는 표지를 붙인다. AccountControllerTest를 Spring Boot context 기반 test로 표시한다.
입력
JUnit이 staged account API test class annotation을 해석한다.
결과·효과
controller·security·service·database bean bootstrap이 test methods보다 먼저 시작된다.
비유의 한계
context annotation은 실제 network port나 배포 server를 열지 않는다.
19줄F03-L19 @AutoConfigureMockMvc 진짜 포트 대신 쓸 가상 HTTP 접수대 MockMvc를 설치한다. Spring Boot가 MockMvc test infrastructure를 자동 구성하도록 표시한다.
입력
application context가 web MVC controller와 filter chain을 가진 test 환경을 만든다.
결과·효과
MockMvc bean이 servlet request를 process할 준비를 한다.
비유의 한계
socket·reverse proxy·TLS·실제 container network는 이 annotation 범위가 아니다.
20줄F03-L20 class AccountControllerTest extends PostgresIntegrationTestSupport { API 검사관을 PostgreSQL 공동 fixture 위에 세운다. AccountControllerTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
입력
JVM이 staged test type과 database-support superclass를 load한다.
결과·효과
네 HTTP tests가 inherited PostgreSQL fixture lifecycle을 사용할 수 있게 된다.
비유의 한계
현재 root의 같은 FQCN source와 이 W6D7 staged source는 별도 파일이다.
21줄F03-L21 @Autowired MockMvc mvc; 가상 HTTP 요청기를 `mvc` 손잡이에 자동 연결한다. Spring이 MockMvc bean을 mvc field에 주입하도록 선언한다.
입력
AutoConfigureMockMvc가 만든 bean과 test instance를 context가 연결한다.
결과·효과
각 test가 mvc.perform으로 servlet request를 보낼 참조를 얻는다.
비유의 한계
주입 성공은 특정 request status나 security 결과를 보장하지 않는다.
22줄F03-L22 @Autowired JdbcClient jdbc; DB side effect를 셀 `jdbc` 계수기도 옆에 둔다. Spring이 AccountControllerTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
입력
application context가 database client와 staged test를 연결한다.
결과·효과
생성·거절 뒤 account row count를 직접 query할 참조가 생긴다.
비유의 한계
이 field는 business_tx·ledger·audit_event를 자동 검사하지 않는다.
23줄F03-L23 @Autowired AccountService service; 조회용 계좌를 미리 만들 `service` 창구를 세 번째로 연다. Spring이 AccountService bean을 service field에 주입하도록 선언한다.
입력
context가 W6D7 stage의 AccountService implementation을 resolve한다.
결과·효과
ownerReadsAccount가 HTTP POST를 거치지 않고 fixture account를 만들 수 있다.
비유의 한계
direct service seed는 create endpoint 자체의 동작 증거가 아니다.
25줄F03-L25 @BeforeEach void clean() { 네 요청표마다 장부를 비우는 청소 예약과 작업실 문을 한 줄에 붙인다. clean method를 BeforeEach로 표시하고 method body를 시작한다.
입력
JUnit이 각 @Test 전에 호출할 lifecycle method를 discovery한다.
결과·효과
네 test가 같은 empty-table 시작 상태를 받도록 cleanup hook이 등록된다.
비유의 한계
cleanup 호출이 실패하면 해당 test body는 실행되지 않는다.
26줄F03-L26 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update(); 멱등·원장·거래·계좌 장부와 identity 번호표를 매번 초기화한다. 네 table을 TRUNCATE RESTART IDENTITY CASCADE하고 update를 실행한다.
입력
앞 test가 남긴 네 table rows와 generated identities를 입력으로 받는다.
결과·효과
다음 test 전에 네 table이 비고 identity가 초기 상태로 돌아간다.
비유의 한계
audit_event는 목록에 없고 운영 database에서 실행할 보존 명령이 아니다.
27줄F03-L27 } 가상 접수대의 청소실 문을 닫는다. AccountControllerTest의 clean BeforeEach method block을 닫는다.
입력
TRUNCATE가 정상 반환한 lifecycle control flow가 brace에 도달한다.
결과·효과
JUnit이 선택한 다음 account API test로 진행할 수 있다.
비유의 한계
닫는 brace는 empty state를 별도로 assertion하지 않는다.
29줄F03-L29 @Test 정상 계좌 생성 요청표에 첫 JUnit 실행 도장을 찍는다. createReturns201AndPersistsOpening method를 @Test로 표시한다.
입력
JUnit discovery가 첫 account API method annotation을 읽는다.
결과·효과
class selector가 이 staged source를 compile하면 첫 test descriptor가 등록된다.
비유의 한계
runner의 class 이름만으로 이 exact staged byte가 선택됐다고 단정할 수 없다.
30줄F03-L30 void createReturns201AndPersistsOpening() throws Exception { 201과 A-100 저장을 볼 첫 접수 시험표를 펼친다. createReturns201AndPersistsOpening test method 본문을 시작한다.
입력
clean 뒤 prepared MockMvc·JdbcClient를 가진 runner가 method를 호출한다.
결과·효과
POST request와 response·DB count assertion을 수행할 throws-capable frame이 열린다.
비유의 한계
method 이름의 Opening은 business_tx·ledger count assertion을 뜻하지 않는다.
31줄F03-L31 var response = mvc.perform(post("/api/accounts") `/api/accounts` 새 계좌 창구에 POST 봉투를 넣기 시작한다. MockMvc perform에 /api/accounts POST request builder를 전달하는 표현을 연다.
입력
mvc와 계좌 생성 URI를 사용해 servlet request를 조립한다.
결과·효과
뒤 authentication·content headers·body를 붙일 builder chain이 생긴다.
비유의 한계
이 줄만으로 request가 아직 실행되거나 response가 반환되지 않는다.
32줄F03-L32 .with(httpBasic("customer-1", "password")) 봉투에 customer-1과 password 기본 인증표를 붙인다. POST request에 HTTP Basic credentials를 추가한다.
입력
request builder와 고정 username customer-1·password를 입력으로 쓴다.
결과·효과
Spring Security filter가 읽을 Authorization header가 request에 설정된다.
비유의 한계
유효한 한 credential만 시험하며 잘못된 인증·다른 owner 권한은 다루지 않는다.
33줄F03-L33 .contentType("application/json") 접수 봉투 겉면을 application/json으로 표시한다. POST request Content-Type을 application/json으로 설정한다.
입력
앞 account request builder에 media type metadata를 추가한다.
결과·효과
controller의 JSON body converter가 선택될 요청 모양이 된다.
비유의 한계
Accept header나 response Content-Type은 assert하지 않는다.
34줄F03-L34 .content("{\"accountNo\":\"A-100\",\"openingBalance\":10000}")) A-100과 openingBalance 10,000을 JSON 신청서에 적는다. POST body를 accountNo A-100, openingBalance 10000 JSON으로 설정한다.
입력
application/json request builder에 exact literal bytes를 전달한다.
결과·효과
controller가 deserialize할 두 business fields가 request body에 들어간다.
비유의 한계
다른 field·whitespace·malformed JSON 경계는 이 fixture에 없다.
35줄F03-L35 .andReturn().getResponse(); 완성한 신청서를 접수대에 보내고 servlet 응답표를 꺼낸다. MockMvc request를 수행해 MvcResult의 response를 변수에 저장한다.
입력
Basic auth·JSON content type·A-100 body가 붙은 POST request를 실행한다.
결과·효과
security→controller→service 경로가 끝난 뒤 MockHttpServletResponse가 response에 들어간다.
비유의 한계
실제 TCP connection이나 외부 server response는 아니다.
36줄F03-L36 assertThat(response.getStatus()).isEqualTo(201); 응답표의 status가 생성 성공 201인지 맞춘다. response.getStatus 결과가 201인지 assert한다.
입력
앞 MockMvc POST가 돌려준 integer HTTP status를 읽는다.
결과·효과
status가 201이면 첫 HTTP 관찰값이 통과하고 아니면 이 줄에서 실패한다.
비유의 한계
Location header·response JSON·request ID는 확인하지 않는다.
37줄F03-L37 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE account_no='A-100'") 접수 뒤 DB에서 A-100 이름표가 붙은 계좌 수를 세기 시작한다. account_no='A-100'인 account COUNT query assertion을 연다.
입력
POST processing이 끝난 stage database와 injected JdbcClient를 사용한다.
결과·효과
A-100 filter의 single Long result를 받을 query chain이 준비된다.
비유의 한계
owner·balance·transaction·ledger columns는 projection에 없다.
38줄F03-L38 .query(Long.class).single()).isEqualTo(1); A-100 계좌가 정확히 한 줄인지 DB 눈금과 맞춘다. account COUNT를 실행해 single Long이 1인지 assert한다.
입력
앞 A-100 query와 request 뒤 database state를 입력으로 쓴다.
결과·효과
A-100 account row가 하나면 정상 생성의 DB side-effect check가 통과한다.
비유의 한계
business_tx·OPENING ledger가 생성됐는지는 이 test가 직접 세지 않는다.
39줄F03-L39 } 정상 생성 접수 시험표를 닫는다. createReturns201AndPersistsOpening method block을 닫는다.
입력
POST·status201·A-100 count1이 모두 정상 종료한 flow가 도달한다.
결과·효과
JUnit이 첫 test의 successful completion을 기록할 수 있다.
비유의 한계
추가 response 또는 relational assertions는 생기지 않는다.
41줄F03-L41 @Test 음수 개설 거절표에 둘째 JUnit 도장을 찍는다. negativeOpeningReturns400AndWritesNothing method를 @Test로 표시한다.
입력
JUnit discovery가 둘째 method annotation을 읽는다.
결과·효과
negative-opening test descriptor가 class execution plan에 들어간다.
비유의 한계
발견 자체는 400·zero-account 결과를 미리 보장하지 않는다.
42줄F03-L42 void negativeOpeningReturns400AndWritesNothing() throws Exception { BAD -1 신청이 거절되는지 볼 두 번째 접수표를 펼친다. negativeOpeningReturns400AndWritesNothing method 본문을 시작한다.
입력
BeforeEach clean 뒤 MockMvc·JdbcClient를 가진 runner가 method를 호출한다.
결과·효과
negative POST와 error response·account zero assertion을 수행할 frame이 열린다.
비유의 한계
method 이름의 WritesNothing은 account 외 table을 직접 세는 assertion이 아니다.
43줄F03-L43 var response = mvc.perform(post("/api/accounts") 같은 `/api/accounts` 창구에 거절용 POST 봉투를 새로 넣는다. MockMvc perform에 두 번째 account POST request builder를 전달하기 시작한다.
입력
empty fixture와 account creation endpoint URI를 입력으로 쓴다.
결과·효과
negative request metadata를 이어 붙일 builder chain이 열린다.
비유의 한계
아직 request body와 execution이 완성되지 않았다.
44줄F03-L44 .with(httpBasic("customer-1", "password")) 거절용 봉투에도 customer-1 기본 인증표를 붙인다. negative POST request에 같은 HTTP Basic credentials를 추가한다.
입력
request builder와 customer-1/password fixture를 사용한다.
결과·효과
security filter가 인증할 Authorization header가 설정된다.
비유의 한계
unauthenticated negative request의 처리 순서는 시험하지 않는다.
45줄F03-L45 .header("X-Request-Id", "w6-invalid") 오류 응답을 추적할 `w6-invalid` 요청 번호표를 단다. negative POST에 X-Request-Id header w6-invalid를 설정한다.
입력
현재 MockMvc request builder에 고정 request ID를 전달한다.
결과·효과
error response가 같은 token을 포함하는지 뒤에서 대조할 입력이 생긴다.
비유의 한계
header echo의 exact JSON field나 response header는 확인하지 않는다.
46줄F03-L46 .contentType("application/json") 음수 신청서도 JSON 봉투라고 표시한다. negative POST Content-Type을 application/json으로 설정한다.
입력
BAD body를 받을 request builder에 media type을 추가한다.
결과·효과
JSON message conversion 경로가 선택될 request가 된다.
비유의 한계
response Content-Type·charset은 assertion하지 않는다.
47줄F03-L47 .content("{\"accountNo\":\"BAD\",\"openingBalance\":-1}")) BAD 계좌에 openingBalance -1을 적어 경계 밖 값을 넣는다. request body를 accountNo BAD, openingBalance -1 JSON으로 설정한다.
입력
application/json builder에 exact negative fixture literal을 전달한다.
결과·효과
controller validation에 들어갈 account number와 음수 금액이 body에 실린다.
비유의 한계
0·overflow·missing field 같은 다른 validation 경계는 다루지 않는다.
48줄F03-L48 .andReturn().getResponse(); 음수 신청서를 실행하고 error servlet 응답표를 받는다. negative MockMvc request를 수행해 response를 변수에 저장한다.
입력
Basic auth·request ID·JSON -1 body가 완성된 POST를 실행한다.
결과·효과
validation/error handling 경로가 끝난 MockHttpServletResponse가 반환된다.
비유의 한계
예외 class·controller 내부 branch는 response만으로 직접 관찰하지 않는다.
49줄F03-L49 assertThat(response.getStatus()).isEqualTo(400); 거절 응답 status가 400인지 확인한다. negative response status가 400인지 assert한다.
입력
앞 BAD -1 request의 integer status를 읽는다.
결과·효과
Bad Request 400이면 negative HTTP outcome check가 통과한다.
비유의 한계
어떤 validation rule이 400을 만들었는지는 이 숫자만으로 확정하지 않는다.
50줄F03-L50 assertThat(response.getContentAsString()).contains("INVALID_REQUEST", "w6-invalid"); 오류 본문에서 INVALID_REQUEST와 w6-invalid 두 표식을 찾는다. response body String이 두 substring을 모두 포함하는지 assert한다.
입력
negative request가 돌려준 raw response text를 UTF-8-aware accessor로 읽는다.
결과·효과
error code token과 request ID token이 모두 보이면 content check가 통과한다.
비유의 한계
structured JSON field 이름·순서·전체 body equality는 검사하지 않는다.
51줄F03-L51 assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero(); 거절 뒤 account 장부가 0줄인지 마지막으로 센다. account 전체 COUNT가 zero인지 assert한다.
입력
BeforeEach로 빈 account에서 음수 POST가 끝난 database를 조회한다.
결과·효과
account row가 하나도 없으면 negative account-side-effect check가 통과한다.
비유의 한계
business_tx·ledger_entry·idempotency_request zero는 직접 query하지 않는다.
52줄F03-L52 } 음수 거절 시험표를 닫는다. negativeOpeningReturns400AndWritesNothing method block을 닫는다.
입력
status400·두 body tokens·account zero가 통과한 flow가 도달한다.
결과·효과
JUnit이 둘째 test의 정상 종료를 기록할 수 있다.
비유의 한계
다른 table side effect나 log 검증은 추가되지 않는다.
54줄F03-L54 @Test 주인 계좌 조회표에 셋째 JUnit 도장을 찍는다. ownerReadsAccount method를 @Test로 표시한다.
입력
JUnit discovery가 owner read method annotation을 읽는다.
결과·효과
authenticated GET success test descriptor가 class plan에 추가된다.
비유의 한계
다른 owner·anonymous 접근은 이 descriptor에 없다.
55줄F03-L55 void ownerReadsAccount() throws Exception { LOOKUP 계좌를 읽는 세 번째 접수표를 펼친다. ownerReadsAccount test method 본문을 시작한다.
입력
clean 뒤 AccountService와 MockMvc를 가진 runner가 method를 호출한다.
결과·효과
direct service seed와 authenticated GET·response assertions를 수행할 frame이 열린다.
비유의 한계
이 method는 create endpoint를 다시 시험하지 않는다.
56줄F03-L56 var account = service.create("customer-1", "LOOKUP", 7_000); service 창구에서 customer-1의 LOOKUP 계좌를 7,000으로 미리 만든다. AccountService.create를 직접 호출해 fixture account를 저장한다.
입력
empty database와 customer-1·LOOKUP·7_000 입력을 service에 전달한다.
결과·효과
정상 반환하면 account 변수에 GET target ID를 가진 객체가 들어간다.
비유의 한계
HTTP POST·request validation·201 response를 우회한 test setup이다.
57줄F03-L57 var response = mvc.perform(get("/api/accounts/{id}", account.getId()) 반환된 ID를 `/api/accounts/{id}` 조회 창구에 넣는다. MockMvc perform에 account ID path variable을 가진 GET builder를 전달한다.
입력
service가 반환한 account.getId와 account detail URI template을 사용한다.
결과·효과
owner authentication을 이어 붙일 GET request chain이 열린다.
비유의 한계
없는 ID나 다른 owner ID는 이 줄의 fixture가 아니다.
58줄F03-L58 .with(httpBasic("customer-1", "password"))) 조회 봉투에 같은 customer-1 기본 인증표를 붙인다. GET request에 HTTP Basic customer-1/password를 추가한다.
입력
LOOKUP account의 owner와 일치하는 fixed credentials를 입력으로 쓴다.
결과·효과
security filter가 owner request로 처리할 Authorization header를 얻는다.
비유의 한계
cross-owner denial이나 잘못된 password는 직접 시험하지 않는다.
59줄F03-L59 .andReturn().getResponse(); owner GET을 실행해 servlet 응답표를 꺼낸다. authenticated GET을 수행하고 response를 변수에 저장한다.
입력
account ID와 customer-1 credentials가 붙은 MockMvc request를 실행한다.
결과·효과
controller 조회 경로가 끝난 MockHttpServletResponse가 반환된다.
비유의 한계
실제 network transport와 production authorization policy는 관찰하지 않는다.
60줄F03-L60 assertThat(response.getStatus()).isEqualTo(200); owner 조회 응답 status가 200인지 맞춘다. GET response status가 200인지 assert한다.
입력
앞 LOOKUP owner request의 integer status를 읽는다.
결과·효과
OK 200이면 authenticated-read status check가 통과한다.
비유의 한계
response JSON schema·headers·balance는 이 assertion에 없다.
61줄F03-L61 assertThat(response.getContentAsString()).contains("LOOKUP"); 응답 본문 어딘가에 LOOKUP 이름표가 있는지 찾는다. GET response body String이 LOOKUP substring을 포함하는지 assert한다.
입력
200 response의 raw content text를 읽어 고정 token과 비교한다.
결과·효과
LOOKUP 문자열이 존재하면 최소 content check가 통과한다.
비유의 한계
그 token이 accountNo field에 정확히 한 번 있다는 structured 보장은 없다.
62줄F03-L62 } owner 조회 시험표를 닫는다. ownerReadsAccount method block을 닫는다.
입력
direct seed·GET·200·LOOKUP checks가 정상 종료한 flow가 도달한다.
결과·효과
JUnit이 셋째 test completion을 기록할 수 있다.
비유의 한계
non-owner·missing·closed-account branch는 이 method가 다루지 않는다.
64줄F03-L64 @Test 없는 계좌 조회표에 넷째 JUnit 도장을 찍는다. missingAccountReturns404 method를 @Test로 표시한다.
입력
JUnit discovery가 missing-account method annotation을 읽는다.
결과·효과
fixed absent ID error-path descriptor가 class plan에 추가된다.
비유의 한계
발견 자체는 999999가 실제로 비어 있음을 확인하지 않는다.
65줄F03-L65 void missingAccountReturns404() throws Exception { 999999가 없을 때의 네 번째 접수표를 펼친다. missingAccountReturns404 test method 본문을 시작한다.
입력
BeforeEach clean 뒤 MockMvc를 가진 runner가 method를 호출한다.
결과·효과
authenticated missing GET과 status·body assertions를 수행할 frame이 열린다.
비유의 한계
database count나 side effect assertion은 이 method에 없다.
66줄F03-L66 var response = mvc.perform(get("/api/accounts/999999") 빈 장부에서 `/api/accounts/999999` 조회 봉투를 만든다. MockMvc perform에 fixed ID 999999를 가진 GET request builder를 전달한다.
입력
clean account table과 missing ID 999999를 입력으로 쓴다.
결과·효과
authentication과 request ID를 이어 붙일 missing request chain이 열린다.
비유의 한계
다른 absent ID·malformed path·overflow ID는 시험하지 않는다.
67줄F03-L67 .with(httpBasic("customer-1", "password")) 없는 계좌 요청에도 customer-1 기본 인증표를 붙인다. missing GET request에 HTTP Basic credentials를 추가한다.
입력
request builder와 fixed customer-1/password를 사용한다.
결과·효과
security filter가 인증된 missing lookup으로 처리할 header를 얻는다.
비유의 한계
인증 실패가 404보다 먼저 오는 policy는 확인하지 않는다.
68줄F03-L68 .header("X-Request-Id", "w6-missing")) 404 오류를 추적할 `w6-missing` 요청 번호표를 단다. missing GET에 X-Request-Id header w6-missing을 설정한다.
입력
현재 GET request builder에 고정 request ID를 전달한다.
결과·효과
error body에서 같은 token을 찾을 입력이 request에 들어간다.
비유의 한계
response header echo나 logging persistence는 직접 보지 않는다.
69줄F03-L69 .andReturn().getResponse(); 없는 계좌 조회를 실행해 error servlet 응답표를 받는다. authenticated missing GET을 수행하고 response를 변수에 저장한다.
입력
ID999999·Basic auth·request ID가 붙은 MockMvc request를 실행한다.
결과·효과
not-found handling이 끝난 MockHttpServletResponse가 반환된다.
비유의 한계
controller가 어떤 repository query를 썼는지는 response만으로 확정하지 않는다.
70줄F03-L70 assertThat(response.getStatus()).isEqualTo(404); 없는 계좌 응답 status가 404인지 확인한다. missing GET response status가 404인지 assert한다.
입력
앞 fixed-ID lookup의 integer response status를 읽는다.
결과·효과
Not Found 404이면 missing status check가 통과한다.
비유의 한계
권한 숨김 정책과 실제 부재 원인을 이 숫자만으로 구분하지 않는다.
71줄F03-L71 assertThat(response.getContentAsString()).contains("ACCOUNT_NOT_FOUND", "w6-missing"); 오류 본문에서 ACCOUNT_NOT_FOUND와 w6-missing 두 표식을 찾는다. missing response body String이 error code와 request ID substring을 모두 포함하는지 assert한다.
입력
404 response의 raw content text를 두 fixed tokens와 비교한다.
결과·효과
두 문자열이 모두 있으면 최소 missing-error content check가 통과한다.
비유의 한계
JSON field type·순서·추가 정보·exact body는 검증하지 않는다.
72줄F03-L72 } 없는 계좌 시험표를 닫는다. missingAccountReturns404 method block을 닫는다.
입력
GET·404·두 body token checks가 정상 종료한 flow가 도달한다.
결과·효과
JUnit이 넷째 test completion을 기록할 수 있다.
비유의 한계
DB 무변경이나 audit_event 생성은 이 method가 assert하지 않는다.
73줄F03-L73 } AccountControllerTest 가상 접수대 외벽을 닫는다. AccountControllerTest class block을 닫는다.
입력
compiler가 W6D7 staged test class의 lexical scope를 종료한다.
결과·효과
세 injected fields, 한 cleanup hook, 네 @Test를 가진 historical type이 완성된다.
비유의 한계
현재 reference root의 same FQCN evolved class와 byte·method가 동일하다는 뜻은 아니다.
WritesNothing의 실제 양히토리 → 니지카 → 료 → 키타
  1. 히토리

    이름이 WritesNothing이니 database 전체가 빈 거죠?

  2. 니지카

    실제 마지막 SQL은 account table 전체 count zero야.

  3. method 이름보다 query quantifier가 직접 관찰 범위를 정한다.

  4. 키타

    나머지 table은 미증명 칸으로 옮기겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F03-C01 · package, imports, and test class1–18줄
1–18줄 원본
package com.example.financialcore.account.api;

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

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

@SpringBootTest
F03-C02 · dependencies and fixture reset19–25줄
19–25줄 원본
@AutoConfigureMockMvc
class AccountControllerTest extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountService service;

    @BeforeEach void clean() {
F03-C03 · create account 201 and persistence26–38줄
26–38줄 원본
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    }

    @Test
    void createReturns201AndPersistsOpening() throws Exception {
        var response = mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"A-100\",\"openingBalance\":10000}"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(201);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE account_no='A-100'")
            .query(Long.class).single()).isEqualTo(1);
F03-C04 · negative opening 400 and no write39–52줄
39–52줄 원본
    }

    @Test
    void negativeOpeningReturns400AndWritesNothing() throws Exception {
        var response = mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .header("X-Request-Id", "w6-invalid")
                .contentType("application/json")
                .content("{\"accountNo\":\"BAD\",\"openingBalance\":-1}"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(400);
        assertThat(response.getContentAsString()).contains("INVALID_REQUEST", "w6-invalid");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();
    }
F03-C05 · owner account lookup53–63줄
53–63줄 원본

    @Test
    void ownerReadsAccount() throws Exception {
        var account = service.create("customer-1", "LOOKUP", 7_000);
        var response = mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-1", "password")))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(200);
        assertThat(response.getContentAsString()).contains("LOOKUP");
    }
F03-C06 · missing account 40464–73줄
64–73줄 원본
    @Test
    void missingAccountReturns404() throws Exception {
        var response = mvc.perform(get("/api/accounts/999999")
                .with(httpBasic("customer-1", "password"))
                .header("X-Request-Id", "w6-missing"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(404);
        assertThat(response.getContentAsString()).contains("ACCOUNT_NOT_FOUND", "w6-missing");
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 65 / 65

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

준비·설명 줄 14개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.account.api;이 Java 파일의 package 주소를 `com.example.financialcore.account.api`로 정한다.
3import com.example.financialcore.PostgresIntegrationTestSupport;뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
4import com.example.financialcore.account.AccountService;뒤 코드에서 `com.example.financialcore.account.AccountService` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
5import org.junit.jupiter.api.BeforeEach;뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
6import org.junit.jupiter.api.Test;뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
7import org.springframework.beans.factory.annotation.Autowired;뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
8import org.springframework.boot.test.context.SpringBootTest;뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
9import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;뒤 코드에서 `org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
10import org.springframework.jdbc.core.simple.JdbcClient;뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
11import org.springframework.test.web.servlet.MockMvc;뒤 코드에서 `org.springframework.test.web.servlet.MockMvc` 타입·annotation을 짧은 이름으로 쓰려고 import한다.
13import static org.assertj.core.api.Assertions.assertThat;assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다.
14import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;assertion·request helper `org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic`를 짧은 이름으로 쓰려고 static import한다.
15import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;assertion·request helper `org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get`를 짧은 이름으로 쓰려고 static import한다.
16import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;assertion·request helper `org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post`를 짧은 이름으로 쓰려고 static import한다.
원본한국어 번역
18@SpringBootTestAccountControllerTest를 Spring Boot context 기반 test로 표시한다.
19@AutoConfigureMockMvcSpring Boot가 MockMvc test infrastructure를 자동 구성하도록 표시한다.
20class AccountControllerTest extends PostgresIntegrationTestSupport {AccountControllerTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
21 @Autowired MockMvc mvc;Spring이 MockMvc bean을 mvc field에 주입하도록 선언한다.
22 @Autowired JdbcClient jdbc;Spring이 AccountControllerTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
23 @Autowired AccountService service;Spring이 AccountService bean을 service field에 주입하도록 선언한다.
25 @BeforeEach void clean() {clean method를 BeforeEach로 표시하고 method body를 시작한다.
26 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();네 table을 TRUNCATE RESTART IDENTITY CASCADE하고 update를 실행한다.
27 }AccountControllerTest의 clean BeforeEach method block을 닫는다.
29 @TestcreateReturns201AndPersistsOpening method를 @Test로 표시한다.
30 void createReturns201AndPersistsOpening() throws Exception {createReturns201AndPersistsOpening test method 본문을 시작한다.
31 var response = mvc.perform(post("/api/accounts")MockMvc perform에 /api/accounts POST request builder를 전달하는 표현을 연다.
32 .with(httpBasic("customer-1", "password"))POST request에 HTTP Basic credentials를 추가한다.
33 .contentType("application/json")POST request Content-Type을 application/json으로 설정한다.
34 .content("{\"accountNo\":\"A-100\",\"openingBalance\":10000}"))POST body를 accountNo A-100, openingBalance 10000 JSON으로 설정한다.
35 .andReturn().getResponse();MockMvc request를 수행해 MvcResult의 response를 변수에 저장한다.
36 assertThat(response.getStatus()).isEqualTo(201);response.getStatus 결과가 201인지 assert한다.
37 assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE account_no='A-100'")account_no='A-100'인 account COUNT query assertion을 연다.
38 .query(Long.class).single()).isEqualTo(1);account COUNT를 실행해 single Long이 1인지 assert한다.
39 }createReturns201AndPersistsOpening method block을 닫는다.
41 @TestnegativeOpeningReturns400AndWritesNothing method를 @Test로 표시한다.
42 void negativeOpeningReturns400AndWritesNothing() throws Exception {negativeOpeningReturns400AndWritesNothing method 본문을 시작한다.
43 var response = mvc.perform(post("/api/accounts")MockMvc perform에 두 번째 account POST request builder를 전달하기 시작한다.
44 .with(httpBasic("customer-1", "password"))negative POST request에 같은 HTTP Basic credentials를 추가한다.
45 .header("X-Request-Id", "w6-invalid")negative POST에 X-Request-Id header w6-invalid를 설정한다.
46 .contentType("application/json")negative POST Content-Type을 application/json으로 설정한다.
47 .content("{\"accountNo\":\"BAD\",\"openingBalance\":-1}"))request body를 accountNo BAD, openingBalance -1 JSON으로 설정한다.
48 .andReturn().getResponse();negative MockMvc request를 수행해 response를 변수에 저장한다.
49 assertThat(response.getStatus()).isEqualTo(400);negative response status가 400인지 assert한다.
50 assertThat(response.getContentAsString()).contains("INVALID_REQUEST", "w6-invalid");response body String이 두 substring을 모두 포함하는지 assert한다.
51 assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();account 전체 COUNT가 zero인지 assert한다.
52 }negativeOpeningReturns400AndWritesNothing method block을 닫는다.
54 @TestownerReadsAccount method를 @Test로 표시한다.
55 void ownerReadsAccount() throws Exception {ownerReadsAccount test method 본문을 시작한다.
56 var account = service.create("customer-1", "LOOKUP", 7_000);AccountService.create를 직접 호출해 fixture account를 저장한다.
57 var response = mvc.perform(get("/api/accounts/{id}", account.getId())MockMvc perform에 account ID path variable을 가진 GET builder를 전달한다.
58 .with(httpBasic("customer-1", "password")))GET request에 HTTP Basic customer-1/password를 추가한다.
59 .andReturn().getResponse();authenticated GET을 수행하고 response를 변수에 저장한다.
60 assertThat(response.getStatus()).isEqualTo(200);GET response status가 200인지 assert한다.
61 assertThat(response.getContentAsString()).contains("LOOKUP");GET response body String이 LOOKUP substring을 포함하는지 assert한다.
62 }ownerReadsAccount method block을 닫는다.
64 @TestmissingAccountReturns404 method를 @Test로 표시한다.
65 void missingAccountReturns404() throws Exception {missingAccountReturns404 test method 본문을 시작한다.
66 var response = mvc.perform(get("/api/accounts/999999")MockMvc perform에 fixed ID 999999를 가진 GET request builder를 전달한다.
67 .with(httpBasic("customer-1", "password"))missing GET request에 HTTP Basic credentials를 추가한다.
68 .header("X-Request-Id", "w6-missing"))missing GET에 X-Request-Id header w6-missing을 설정한다.
69 .andReturn().getResponse();authenticated missing GET을 수행하고 response를 변수에 저장한다.
70 assertThat(response.getStatus()).isEqualTo(404);missing GET response status가 404인지 assert한다.
71 assertThat(response.getContentAsString()).contains("ACCOUNT_NOT_FOUND", "w6-missing");missing response body String이 error code와 request ID substring을 모두 포함하는지 assert한다.
72 }missingAccountReturns404 method block을 닫는다.
73}AccountControllerTest class block을 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

SpringBootTest+MockMvc staged fixture에서 네 HTTP scenarios의 status와 일부 body/DB postcondition을 순차 assertion한다.

문법 해부

  • @AutoConfigureMockMvc는 web application context의 MockMvc bean을 구성한다.
  • post/get builder에 httpBasic, header, contentType, JSON content를 fluent하게 붙인다.
  • perform(...).andReturn().getResponse()는 MvcResult에서 mock response를 꺼낸다.
  • get('/api/accounts/{id}', value)는 URI template에 generated account ID를 대입한다.
  • contains는 raw response String에 token이 포함됐는지만 확인하고 JSON structure를 parse하지 않는다.

실행 순서

  1. JUnit/Spring/MockMvc bootstrap
  2. BeforeEach TRUNCATE
  3. 각 test fixture 준비
  4. mock request dispatch
  5. response materialize
  6. status assertion
  7. body 또는 DB assertion

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

F03-C01 · package, imports, and test class
문법 해부
account.api package와 service·JUnit·Spring·MockMvc·security request builder·AssertJ import를 준비하고 마지막 @SpringBootTest가 full context test를 요청한다.
실제 값 추적
compiler가 POST/GET builder, httpBasic, MockMvc, JdbcClient, AccountService symbol을 연결하고 Spring bootstrap metadata를 읽는다.
정상 예
MockMvc, httpBasic, get, post와 assertThat을 qualified name 없이 참조할 수 있게 되는 것이 이 준비 범위의 유효 결과다.
틀린 예·반례
type import와 @SpringBootTest만 보고 MockMvc client configuration이나 endpoint 실행이 완료됐다고 결론 내리면 안 된다.
착각 방지
W6D7 staged AccountControllerTest와 active root의 같은 FQCN은 byte가 다르므로 이름 일치가 source identity proof는 아니다.
하지 않는 일
import와 context annotation은 HTTP outcome, database effect, authorization 결과를 직접 검증하지 않는다.
다음 연결
다음 ‘dependencies and fixture reset’ 범위에서는 @AutoConfigureMockMvc가 web test client 구성을 요청하고 class는 support를 상속하며 mvc·jdbc·service를 주입받고 @BeforeEach clean method를 연다.
F03-C02 · dependencies and fixture reset
문법 해부
@AutoConfigureMockMvc가 web test client 구성을 요청하고 class는 support를 상속하며 mvc·jdbc·service를 주입받고 @BeforeEach clean method를 연다.
실제 값 추적
Spring은 mvc·jdbc·service 세 field의 injection point를 채울 수 있고 JUnit은 clean을 per-test callback으로 분류한다.
정상 예
MockMvc request와 direct service fixture setup, JDBC count를 한 class에서 사용할 준비가 된 상태가 정상이다.
틀린 예·반례
clean method의 여는 brace까지 읽었다고 database state가 이미 바뀌었다고 보면 아직 없는 body 실행을 앞당긴 것이다.
착각 방지
controller test wiring과 authentication policy 전체를 동일시하지 말고 source가 넣은 httpBasic 사례만 추적한다.
하지 않는 일
이 범위는 wiring과 callback declaration만 다루며 어떤 HTTP response도 아직 발생시키지 않는다.
다음 연결
다음 ‘create account 201 and persistence’ 범위에서는 clean body가 네 table을 truncate한 뒤 POST /api/accounts에 basic auth와 JSON A-100/10000을 보내고 response 201과 account row 1을 검사한다.
F03-C03 · create account 201 and persistence
문법 해부
clean body가 네 table을 truncate한 뒤 POST /api/accounts에 basic auth와 JSON A-100/10000을 보내고 response 201과 account row 1을 검사한다.
실제 값 추적
빈 DB에서 customer-1 요청이 처리되면 HTTP status는 201이고 account_no A-100 count는 1이 된다.
정상 예
올바른 openingBalance 10000과 인증을 가진 create request가 한 account를 persistence하는 것이 direct success example이다.
틀린 예·반례
201과 account count만으로 opening business_tx·ledger·idempotency row까지 모두 맞다고 주장할 수 없다.
착각 방지
이 test가 직접 확인한 것은 create response status와 account row count이며 다른 endpoint나 ownership 조회로 확대하지 않는다.
하지 않는 일
response body shape, headers, duplicate request, authorization matrix는 여기서 assertion하지 않는다.
다음 연결
다음 ‘negative opening 400 and no write’ 범위에서는 negativeOpening test가 X-Request-Id w6-invalid와 openingBalance -1을 POST하고 400, INVALID_REQUEST·request id body, account 전체 count 0을 확인한다.
F03-C04 · negative opening 400 and no write
문법 해부
negativeOpening test가 X-Request-Id w6-invalid와 openingBalance -1을 POST하고 400, INVALID_REQUEST·request id body, account 전체 count 0을 확인한다.
실제 값 추적
BAD/-1 payload가 validation에서 거절되면 response status 400과 두 error token이 보이고 account table은 빈 상태로 남는다.
정상 예
음수 opening 요청 하나가 account row를 만들지 않고 거절되는 경로는 이 assertion 묶음으로 직접 확인된다.
틀린 예·반례
account count 0만으로 business_tx·ledger_entry·idempotency_request도 반드시 0이라고 증명되지는 않는다.
착각 방지
‘writes nothing’이라는 method 이름을 모든 table zero proof로 확대하지 말고 실제 account assertion과 구분한다.
하지 않는 일
다른 invalid field, unauthenticated request, error JSON 전체 schema는 별도 test가 필요하다.
다음 연결
다음 ‘owner account lookup’ 범위에서는 ownerReadsAccount는 service.create로 LOOKUP/7000 account를 먼저 만들고 owner basic auth로 GET /api/accounts/{id}를 호출해 200과 LOOKUP 문자열을 검사한다.
F03-C05 · owner account lookup
문법 해부
ownerReadsAccount는 service.create로 LOOKUP/7000 account를 먼저 만들고 owner basic auth로 GET /api/accounts/{id}를 호출해 200과 LOOKUP 문자열을 검사한다.
실제 값 추적
direct service fixture가 account id를 주면 GET path variable에 그 id가 들어가고 owner response에 account number가 나타난다.
정상 예
customer-1이 자기 account를 조회해 200을 받는 positive owner 경로가 정상 예다.
틀린 예·반례
fixture 생성이 HTTP POST를 우회하므로 create endpoint와 GET endpoint를 한 번에 통합 검증한다고 볼 수 없다.
착각 방지
LOOKUP substring assertion은 response JSON의 모든 field·type·masking을 확정하지 않는다.
하지 않는 일
다른 고객의 접근 거절과 role 정책은 이 owner-only success test가 보장하지 않는다.
다음 연결
다음 ‘missing account 404’ 범위에서는 missingAccountReturns404가 존재하지 않는 999999를 owner basic auth와 w6-missing request id로 GET하고 404와 ACCOUNT_NOT_FOUND·request id를 확인한다.
F03-C06 · missing account 404
문법 해부
missingAccountReturns404가 존재하지 않는 999999를 owner basic auth와 w6-missing request id로 GET하고 404와 ACCOUNT_NOT_FOUND·request id를 확인한다.
실제 값 추적
clean fixture에서 해당 id를 찾지 못하면 response는 404가 되고 error body에 code와 correlation 값이 남는다.
정상 예
없는 account 한 건에 대한 status와 두 핵심 token을 검증하는 것이 이 method의 직접 범위다.
틀린 예·반례
999999 하나의 사례로 모든 malformed id나 다른 owner의 account를 같은 404로 처리한다고 일반화할 수 없다.
착각 방지
ACCOUNT_NOT_FOUND 문자열 존재를 full error payload exact match로 오해하지 않는다.
하지 않는 일
DB write zero, timing side channel, authentication failure 우선순위는 이 assertion들이 다루지 않는다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 DB write zero, timing side channel, authentication failure 우선순위는 이 assertion들이 다루지 않는다.
owner fixture 우회히토리 → 니지카 → 료 → 키타
  1. 히토리

    LOOKUP도 POST로 만들었나요?

  2. 니지카

    아니, AccountService.create로 미리 만들고 GET만 MockMvc로 보내.

  3. 그래서 create endpoint와 read endpoint를 잇는 end-to-end test는 아니다.

  4. 키타

    act를 service fixture와 GET request 두 단계로 나누겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1각 @Test 전에 남아 있을 수 있는 네 table rowBeforeEach TRUNCATE로 fixture를 초기화한다.각 scenario가 빈 account 관련 persistence state에서 시작한다.audit_event와 다른 side-effect store는 cleanup query에 없다.
2POST /api/accounts, Basic customer-1/password, A-100/10000 JSONMockMvc가 request를 MVC pipeline에 dispatch한다.mock response 201과 account_no A-100 count 1이면 생성 test가 통과한다.response payload·Location header·opening transaction/ledger는 assertion하지 않는다.
3POST BAD/-1, Basic auth, X-Request-Id w6-invalidinvalid opening request를 dispatch하고 raw body와 account count를 읽는다.400, INVALID_REQUEST, w6-invalid, account zero가 모두 맞으면 거절 test가 통과한다.다른 세 persistence table이 0인지 직접 query하지 않는다.
4service.create(customer-1, LOOKUP, 7_000)의 반환 IDowner Basic auth로 /api/accounts/{id}를 GET한다.response 200과 raw body의 LOOKUP token이 맞으면 owner 조회 test가 통과한다.fixture 생성은 POST endpoint를 우회하고 non-owner path를 비교하지 않는다.
5GET /api/accounts/999999, Basic auth, w6-missing존재하지 않는 ID request를 dispatch한다.404와 ACCOUNT_NOT_FOUND·w6-missing token이 있으면 missing test가 통과한다.DB 무변경과 audit row는 assertion하지 않는다.
6네 @Test의 독립 결과JUnit이 class selector 실행에서 각 method status를 모은다.stage가 실제 조립·실행됐을 때 네 method가 각각 통과해야 class가 Green이 된다.W14 runner marker는 staged file SHA를 기록하지 않고 현재 root same-FQCN은 다른 source다.
contains와 JSON히토리 → 니지카 → 료 → 키타
  1. 히토리

    오류 두 문자열이 있으면 JSON schema도 맞나요?

  2. 니지카

    raw String 안에 token이 있는지만 확인해.

  3. field name·type·nesting은 parse하지 않으니 exact contract가 아니다.

  4. 키타

    jsonPath가 필요한 반례를 적겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

Spring TestContext

application context, security filter chain, controller dependencies를 test fixture로 준비한다.

실제 process deployment·external reverse proxy·TLS는 시작하지 않는다.
MockMvc

request builder를 DispatcherServlet pipeline에 넣고 mock response를 수집한다.

socket·port·real network client latency를 관찰하지 않는다.
Spring Security/MVC

Basic credentials, request ID header, JSON content를 filter·controller 흐름에 전달한다.

네 scenario 외 credential·role·ownership matrix 전체를 탐색하지 않는다.
PostgreSQL/JdbcClient

fixture cleanup과 두 제한된 account COUNT를 실행한다.

생성 test는 account_no count만, 거절 test는 전체 account count만 본다.
AssertJ

status integer, raw body substring, Long count를 expected 값과 비교한다.

contains는 JSON parser나 schema validator가 아니다.

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

createReturns201AndPersistsOpening

Arrange · 준비
  • BeforeEach로 네 table을 비운다.
  • customer-1/password Basic auth와 A-100/10000 JSON을 준비한다.
Act · 행동
  • MockMvc로 POST /api/accounts를 dispatch한다.
  • account_no='A-100'인 account COUNT를 읽는다.
Assert · 확인
  • response status가 201이다.
  • A-100 account COUNT가 1이다.
직접 보장
  • 이 staged MockMvc fixture가 지정 request에 201을 반환한다.
  • 그 실행 뒤 account table에 A-100 row가 정확히 하나 있다.
보장하지 않음
  • response body·headers·exact JSON
  • business_tx·ledger_entry·idempotency row
  • opening balance/owner field equality
  • real network deployment

첫 실패 경계 fixture cleanup/bootstrap 뒤 POST dispatch가 예외로 끝날 수 있다. response가 오면 201 assertion이 먼저이고, 그다음 A-100 count 1 assertion이 실행된다.

negativeOpeningReturns400AndWritesNothing

Arrange · 준비
  • BeforeEach로 네 table을 비운다.
  • BAD/-1 JSON, Basic auth, X-Request-Id w6-invalid를 준비한다.
Act · 행동
  • MockMvc로 POST /api/accounts를 dispatch한다.
  • raw response body와 account 전체 COUNT를 읽는다.
Assert · 확인
  • response status가 400이다.
  • raw body에 INVALID_REQUEST와 w6-invalid가 포함된다.
  • account COUNT가 zero다.
직접 보장
  • 이 fixture의 negative opening request가 400을 반환한다.
  • 두 token이 raw body에 있고 account table에는 row가 없다.
보장하지 않음
  • 다른 persistence table과 audit store 무변경
  • error JSON exact structure
  • 모든 invalid input 종류
  • real network response

첫 실패 경계 request dispatch 뒤 400, 두 body token, account zero 순서로 확인하므로 앞 assertion 실패 시 뒤 관찰은 실행되지 않는다.

ownerReadsAccount

Arrange · 준비
  • BeforeEach로 네 table을 비운다.
  • AccountService.create(customer-1, LOOKUP, 7_000)로 fixture account를 직접 만든다.
Act · 행동
  • 반환 ID를 /api/accounts/{id}에 넣고 customer-1 Basic auth로 GET한다.
Assert · 확인
  • response status가 200이다.
  • raw body에 LOOKUP 문자열이 포함된다.
직접 보장
  • service fixture로 만든 account를 owner credential의 MockMvc GET이 200으로 읽는다.
  • response raw String에는 LOOKUP token이 있다.
보장하지 않음
  • POST create endpoint와 GET의 end-to-end 연결
  • non-owner·anonymous authorization
  • response JSON 전체 field equality
  • real network transport

첫 실패 경계 service.create가 먼저 실패할 수 있다. fixture가 생기면 GET dispatch, 200 assertion, LOOKUP substring assertion 순서로 진행한다.

missingAccountReturns404

Arrange · 준비
  • BeforeEach로 네 table을 비운다.
  • ID 999999, customer-1/password, X-Request-Id w6-missing을 준비한다.
Act · 행동
  • MockMvc로 GET /api/accounts/999999를 dispatch한다.
Assert · 확인
  • response status가 404다.
  • raw body에 ACCOUNT_NOT_FOUND와 w6-missing이 포함된다.
직접 보장
  • 빈 staged fixture에서 ID 999999 owner GET이 404를 반환한다.
  • response raw String에는 지정 error code와 request ID token이 있다.
보장하지 않음
  • error JSON exact schema
  • DB·audit 무변경
  • 다른 missing ID와 caller matrix
  • current root same-FQCN source 실행

첫 실패 경계 GET dispatch 뒤 404 assertion을 먼저 하고, 통과하면 두 raw-body token의 포함 여부를 확인한다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ MockMvc 201은 실제 배포 URL에서도 같은 network 결과를 보장한다.

왜 틀리나 MockMvc는 web context 안에서 dispatch하고 실제 port·proxy·TLS를 통과하지 않는다.

바르게 읽기 controller integration fixture의 201이라고 범위를 쓴다.

반례 proxy route가 잘못돼도 in-process MockMvc test는 통과할 수 있다.

❌ create test가 opening의 모든 database effect를 검증한다.

왜 틀리나 database assertion은 account_no='A-100' COUNT 하나뿐이다.

바르게 읽기 201과 A-100 account 한 행만 직접 증명한다고 적는다.

반례 business_tx나 ledger가 누락돼도 이 두 assertions는 통과할 수 있다.

❌ negative test의 WritesNothing은 네 table 모두 0을 뜻한다.

왜 틀리나 source가 직접 query하는 것은 SELECT COUNT(*) FROM account 하나다.

바르게 읽기 no account row라는 관찰로 제한한다.

반례 잘못된 business_tx 한 행이 남아도 account count는 zero일 수 있다.

❌ ownerReadsAccount는 POST로 만들고 GET하는 완전한 API 흐름이다.

왜 틀리나 fixture는 AccountService.create를 직접 호출해 controller POST를 우회한다.

바르게 읽기 GET owner path만 HTTP로 시험했다고 적는다.

반례 POST mapping이 깨져도 service.create와 GET controller가 정상이라 이 test는 통과할 수 있다.

❌ body contains 두 token이면 error JSON 전체 contract가 정확하다.

왜 틀리나 getContentAsString과 contains는 field name·type·nesting·exact body를 검증하지 않는다.

바르게 읽기 raw body에 두 문자열이 있다는 최소 content check로 부른다.

반례 두 token이 debug text에만 있어도 contains는 통과할 수 있다.

❌ W14 selector의 AccountControllerTest는 이 W6D7 source와 현재 root source를 자동 구분한다.

왜 틀리나 같은 FQCN의 evolved root file은 method·assertion이 다르고 selector에 path·SHA가 없다.

바르게 읽기 source path와 SHA를 execution evidence에 함께 결박한다.

반례 root에서 같은 FQCN의 다른 four-test class가 실행돼도 selector 이름은 동일하게 보인다.

같은 class 이름의 함정히토리 → 니지카 → 료 → 키타
  1. 히토리

    AccountControllerTest라는 이름만 같으면 W6D7 파일이죠?

  2. 니지카

    현재 root에는 같은 FQCN이지만 다른 method·assertion인 source가 있어.

  3. selector 이름은 file path와 SHA identity를 자동 결박하지 않는다.

  4. 키타

    W6D7 path와 1d0333 SHA를 evidence 조건으로 확인하겠습니다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

transport

real port, proxy, TLS, serialization over network, deployed routing

이 책임을 맡는 곳: deployed environment HTTP smoke 또는 end-to-end test
response contract

exact JSON fields·types·nesting·headers·content type 전체

이 책임을 맡는 곳: jsonPath/JSON schema/exact header assertions
persistence breadth

business_tx·ledger_entry·idempotency_request·audit_event의 모든 scenario postcondition

이 책임을 맡는 곳: scenario별 explicit table/value assertions
security matrix

wrong password, anonymous, non-owner, roles, enumeration resistance 전체

이 책임을 맡는 곳: authentication·authorization matrix tests
source identity

현재 root same-FQCN source와 W6D7 staged source가 동일하거나 staged SHA가 실행됨

이 책임을 맡는 곳: source path/hash manifest와 fresh class-to-source binding
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

clean→A-100 POST 201+row1→BAD/-1 POST 400+tokens+account0→service LOOKUP+GET200→missing GET404 순서로 말한다.

2단계 · 코드 조각 재조립

  1. SpringBootTest/AutoConfigureMockMvc/dependencies
  2. BeforeEach TRUNCATE
  3. create request/status/account count
  4. negative request/status/body/account zero
  5. service fixture/owner GET
  6. missing GET/status/body

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

정본 없이 73개 물리 줄을 다시 쓰고 mapping 51행·번역 65행·@Test 4개·SHA-256을 대조한다.

자가 점검
  • Basic auth user와 password를 request마다 맞춘다.
  • A-100/10000과 BAD/-1을 섞지 않는다.
  • w6-invalid와 w6-missing request ID를 구분한다.
  • LOOKUP fixture는 service.create로 만든다.
  • contains를 exact JSON equality라고 쓰지 않는다.
  • W6D7 stage와 current root same-FQCN을 동일 source로 취급하지 않는다.
13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w6/day-7/AccountControllerTest.javaSHA-256 1d03331f49a8e869c0afd96792976187f972fde6d283133f22854130828b1614
AccountControllerTest.java — MockMvc로 생성·거절·owner 조회·404 네 경로 확인 전체
package com.example.financialcore.account.api;

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

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

@SpringBootTest
@AutoConfigureMockMvc
class AccountControllerTest extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountService service;

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

    @Test
    void createReturns201AndPersistsOpening() throws Exception {
        var response = mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json")
                .content("{\"accountNo\":\"A-100\",\"openingBalance\":10000}"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(201);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account WHERE account_no='A-100'")
            .query(Long.class).single()).isEqualTo(1);
    }

    @Test
    void negativeOpeningReturns400AndWritesNothing() throws Exception {
        var response = mvc.perform(post("/api/accounts")
                .with(httpBasic("customer-1", "password"))
                .header("X-Request-Id", "w6-invalid")
                .contentType("application/json")
                .content("{\"accountNo\":\"BAD\",\"openingBalance\":-1}"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(400);
        assertThat(response.getContentAsString()).contains("INVALID_REQUEST", "w6-invalid");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM account").query(Long.class).single()).isZero();
    }

    @Test
    void ownerReadsAccount() throws Exception {
        var account = service.create("customer-1", "LOOKUP", 7_000);
        var response = mvc.perform(get("/api/accounts/{id}", account.getId())
                .with(httpBasic("customer-1", "password")))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(200);
        assertThat(response.getContentAsString()).contains("LOOKUP");
    }

    @Test
    void missingAccountReturns404() throws Exception {
        var response = mvc.perform(get("/api/accounts/999999")
                .with(httpBasic("customer-1", "password"))
                .header("X-Request-Id", "w6-missing"))
            .andReturn().getResponse();
        assertThat(response.getStatus()).isEqualTo(404);
        assertThat(response.getContentAsString()).contains("ACCOUNT_NOT_FOUND", "w6-missing");
    }
}
04

SortedLockTransferIT.java — 반대 방향 20 task의 한 번짜리 동시성 envelope

reference/w10/day-3/SortedLockTransferIT.java

누적 Java 테스트 참고본 · 정본 · W14-F04
57줄 연결74줄 번역5 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

A→B와 B→A가 서로 다른 순서로 계좌를 잠그면 교착 위험이 생긴다. 이 test는 정렬 잠금 구현을 10쌍·20 task로 한 번 동시에 출발시켜 종료와 두 보존식을 확인한다.

  1. 10쌍은 왜 20 task가 될까?
  2. 두 CountDownLatch는 준비와 출발을 어떻게 나눌까?
  3. 30초 안에 끝난 한 번의 실행이 deadlock 불가능성을 증명할까?
이 파일에서 끝까지 다시 쓰는 값pairs=10tasks=20ready<=10seach future<=30sopening total=20000transfer signed sum=0
02

STEP 02 / 13

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

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

STARRY 계산대에서 열 명은 A에서 B로, 열 명은 B에서 A로 동시에 100원씩 옮긴다.

서로 마주 달리는 스무 장의 결제표

스무 작업이 모두 준비될 때까지 기다렸다가 한 번에 출발시킨다. 각 작업은 TransferService를 실제 호출하고 30초 안에 끝나야 한다.

끝난 뒤 두 계좌 합계 20,000과 이체 원장 합계 0을 확인한다. 빠르게 끝났다는 사실과 돈이 보존됐다는 사실을 따로 본다.

딱 여기까지만 스무 결제표 비유는 이 한 번의 workload를 설명할 뿐 모든 부하·계좌 조합에서 deadlock이 없다는 증명이 아니다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

준비 문과 출발 문

ready는 20 task가 준비됐음을 세고 start는 동시에 통과시키는 문이다.

코드 연결
L44-L52
비유
연주자 스무 명이 무대 뒤에서 모인 뒤 같은 박자에 시작
비유의 끝
OS가 실제 CPU 실행을 완전히 같은 순간에 배치한다는 뜻은 아니다.

반대 방향 10쌍

각 반복에서 A→B와 B→A 한 개씩 만들어 총 20 Future를 모은다.

코드 연결
L53-L60
비유
왕복 표 두 장을 열 번 발급
비유의 끝
다른 금액·계좌 수·반복 횟수는 이 test가 다루지 않는다.

두 보존식

작업 종료 뒤 잔액 합계와 TRANSFER 원장 signed_amount 합계를 각각 확인한다.

코드 연결
L64-L68
비유
현금통 총액과 영수증 플러스·마이너스를 따로 대조
비유의 끝
개별 이체 순서나 처리량은 assertion에 없다.
10쌍과 20 task히토리 → 니지카 → 료 → 키타
  1. 히토리

    pairs가 10이면 왜 작업은 스무 개지?

  2. 니지카

    반복마다 A→B와 B→A 두 callable을 넣기 때문이야.

  3. `tasks = pairs * 2`와 Future 추가 두 줄을 함께 봐.

  4. 키타

    10회와 20개를 구분해 적을게요.

두 latch히토리 → 니지카 → 료 → 키타
  1. 히토리

    ready와 start는 같은 문이야?

  2. 니지카

    ready는 준비 수를 세고 start는 test가 한 번 열어 주는 출발문이야.

  3. 준비가 20이 되기 전에 출발하지 않도록 역할을 나눈다.

  4. 키타

    countDown 위치를 따라가겠습니다.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.57 / 57 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
23줄F04-L23 @SpringBootTest Spring 시험 실행기에 전체 context 표찰 한 장을 건다. `@SpringBootTest` class-level bootstrap metadata record를 만든다.
입력
Spring annotation scanner가 이 class-level marker line을 읽으면
결과·효과
이어질 type declaration에 결합될 Spring Boot test metadata가 대기 상태로 기록된다.
비유의 한계
이 annotation line만으로 type identity·context startup·test body 실행은 정해지지 않는다.
24줄F04-L24 class SortedLockTransferIT extends PostgresIntegrationTestSupport { PostgreSQL 공용 열쇠를 들고 ‘번호순 잠금 송금 검수실’의 큰 문을 연다. PostgresIntegrationTestSupport를 상속하는 SortedLockTransferIT 클래스 본문을 시작한다.
입력
JUnit class discovery와 공통 PostgreSQL 지원
결과·효과
fixture·한 test·invoke helper를 담는 타입 범위가 열린다.
비유의 한계
상속은 실제 deadlock을 재현하거나 제거했다는 결과가 아니다.
25줄F04-L25 @Autowired JdbcClient jdbc; 회계 장부에 직접 SQL을 적을 단말기 한 대를 시험 책상에 연결한다. Spring JdbcClient bean을 jdbc 필드에 자동 주입한다.
입력
application context의 JdbcClient
결과·효과
TRUNCATE와 원장 합계 SELECT에 쓸 참조가 생긴다.
비유의 한계
주입만으로 SQL이 실행되거나 표가 비워지지 않는다.
26줄F04-L26 @Autowired AccountOpeningService openings; SORT-A와 SORT-B 카드를 새로 발급할 창구 직원을 옆자리에 배치한다. AccountOpeningService bean을 openings 필드에 주입한다.
입력
Spring이 관리하는 계좌 개설 서비스
결과·효과
setUp에서 두 10,000원 계좌를 열 수 있게 된다.
비유의 한계
아직 어느 계좌도 만들지 않았고 ID도 알 수 없다.
27줄F04-L27 @Autowired AccountRepository accounts; AccountRepository bean을 받을 accounts 연결 단자를 class에 단다. Spring이 `AccountRepository` bean을 `accounts` field injection point에 연결한다.
입력
application context가 type-compatible AccountRepository bean을 제공하면
결과·효과
accounts field가 해당 repository bean reference를 보관한다.
비유의 한계
이 injection declaration은 특정 Account variable이나 repository call 결과를 만들지 않는다.
28줄F04-L28 @Autowired TransferService transfers; 양방향 송금 신청서를 실제 처리 창구로 넘길 서비스 벨을 설치한다. TransferService bean을 transfers 필드에 주입한다.
입력
Spring transaction proxy로 주입되는 TransferService
결과·효과
invoke helper가 실제 transfer 메서드를 호출할 수 있다.
비유의 한계
주입되었다는 사실만으로 proxy 여부나 잠금 SQL 실행을 이 줄이 assert하지 않는다.
29줄F04-L29 Account first; 첫 번째 계좌 카드가 들어올 빈 받침대에 first 이름표를 붙인다. 첫 번째 Account fixture를 보관할 package-private first 필드를 선언한다.
입력
outer test instance layout에 첫 번째 Account reference member가 필요할 때
결과·효과
test와 invoke가 같은 첫 계좌 ID를 읽을 자리가 생긴다.
비유의 한계
null 초기값은 유효 계좌가 이미 존재한다는 뜻이 아니다.
30줄F04-L30 Account second; 반대편 계좌 카드를 둘 second 받침대를 따로 마련한다. 두 번째 Account fixture를 보관할 second 필드를 선언한다.
입력
first Account assignment에 이어 second field가 보관한 SORT-B reference를 조회할 때
결과·효과
양방향 작업에서 목적지·출발지로 바꿔 쓸 둘째 참조 칸이 생긴다.
비유의 한계
second reference declaration은 두 Account slots가 가리킬 object나 persisted value를 정하지 않는다.
32줄F04-L32 @BeforeEach 각 검수 전에 부를 절차가 있음을 알리는 lifecycle 꼬리표를 단다. `@BeforeEach` per-test lifecycle metadata를 선언한다.
입력
JUnit annotation scanner가 lifecycle marker를 만나면
결과·효과
바로 이어질 method declaration을 각 test 전 callback으로 분류할 metadata가 기록된다.
비유의 한계
annotation 자체는 method body나 DB 준비 statement를 실행하지 않는다.
33줄F04-L33 void setUp() { setUp lifecycle 절차가 실행될 method frame의 첫 boundary를 세운다. 반환값 없는 `setUp` method declaration과 body scope를 시작한다.
입력
직전 BeforeEach metadata가 method declaration을 기다리는 상태에서
결과·효과
setUp call frame이 열렸지만 body statement는 아직 하나도 실행되지 않았다.
비유의 한계
여는 brace는 뒤 body의 구체적 작업이나 값까지 확정하지 않는다.
34줄F04-L34 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE") 네 table을 비울 TRUNCATE SQL 명세를 JdbcClient 조립대에 올린다. 네 persistence table과 identity를 초기화할 SQL text로 `jdbc.sql(...)` statement spec을 만든다.
입력
setUp body가 첫 database statement를 구성하는 시점에
결과·효과
TRUNCATE statement specification이 생겼지만 terminal database operation은 아직 호출되지 않았다.
비유의 한계
이 line에는 statement 실행을 끝내는 terminal method가 없으므로 DB 행 상태는 아직 바뀌지 않는다.
35줄F04-L35 .update(); 준비해 둔 대청소 지시서의 실행 단추를 눌러 빈 회계실을 만든다. 앞 줄의 TRUNCATE SQL을 update로 실행한다.
입력
JdbcClient가 보관한 TRUNCATE 명령
결과·효과
네 table의 기존 행과 identity 진행값이 fixture 시작 상태로 정리된다.
비유의 한계
운영 중 자료를 지우는 일반 서비스 동작이 아니라 테스트 준비다.
36줄F04-L36 first = openings.open("customer-1", "SORT-A", 10_000); customer-1의 SORT-A 계좌 한 개를 10,000으로 열어 first 표찰을 붙인다. `openings.open` 반환 Account를 `first` field에 저장한다.
입력
현재 line의 owner·account number·opening amount와 AccountOpeningService를 받으면
결과·효과
새 Account 반환 reference가 first에 대입되고 이 statement가 끝난다.
비유의 한계
이 한 줄은 다른 Account declaration이나 transfer invocation을 수행하지 않는다.
37줄F04-L37 second = openings.open("customer-1", "SORT-B", 10_000); 같은 주인의 SORT-B 카드도 10,000원으로 발급해 second 자리에 놓는다. owner customer-1, accountNo SORT-B, balance 10_000인 두 번째 계좌를 열어 second에 저장한다.
입력
문자열 customer-1·SORT-B와 금액 10,000
결과·효과
서로 반대 방향으로 쓸 둘째 Account fixture가 준비된다.
비유의 한계
두 계좌 합 20,000은 시작값이며 아직 test assertion을 통과한 결과가 아니다.
38줄F04-L38 } 빈 장부와 두 10,000원 카드 준비를 마치고 setUp 서랍을 닫는다. setUp 메서드 본문을 끝낸다.
입력
TRUNCATE 완료와 first·second 참조
결과·효과
다음 @Test가 독립 출발 상태를 사용할 수 있다.
비유의 한계
이 중괄호가 transaction commit이나 thread 시작을 뜻하지 않는다.
40줄F04-L40 @Test 이 source의 실행 후보 하나 앞에 JUnit 검수 도장을 찍는다. JUnit이 이어질 method declaration을 test descriptor로 수집하게 하는 annotation record다.
입력
JUnit scanner가 setUp scope 뒤의 Test marker를 읽으면
결과·효과
다음 method declaration 하나에 결합될 JUnit test metadata가 기록된다.
비유의 한계
이 line은 method body·workload·외부 runner 선택 횟수를 실행하거나 증명하지 않는다.
41줄F04-L41 void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception { 긴 이름을 가진 동시성 검수 method의 빈 무대를 연다. `oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal` method를 선언하고 `throws Exception` body scope를 시작한다.
입력
직전 Test metadata가 이어질 method declaration을 기다리는 상태에서
결과·효과
이 test method의 call frame만 열렸고 내부 statement는 아직 실행되지 않았다.
비유의 한계
method 이름은 뒤 body의 task 수·assertion 구성·실행 성공을 대신하지 않는다.
42줄F04-L42 int pairs = 10; A↔B 맞교환 묶음을 열 세트로 세겠다고 pairs 표에 10을 적는다. 반대 방향 작업 쌍 수를 int pairs=10으로 고정한다.
입력
정수 상수 10
결과·효과
A→B 열 개와 B→A 열 개를 만들 반복 상한이 정해진다.
비유의 한계
10쌍만 시험하며 더 큰 부하나 다른 계좌 수를 대표하지 않는다.
43줄F04-L43 int tasks = pairs * 2; 한 쌍에 신청서 두 장이므로 전체 작업표에 10×2=20을 계산해 적는다. tasks를 pairs * 2로 계산해 20으로 만든다.
입력
pairs 10과 방향 수 2
결과·효과
pairs 값의 두 배가 계산되어 tasks 정수 local에 20으로 저장된다.
비유의 한계
계산된 20은 business_tx 행 수 assertion이 아니다.
44줄F04-L44 var ready = new CountDownLatch(tasks); 스무 연주자가 모두 준비석에 앉아야 알려 주는 20칸 준비 전광판을 세운다. 초기 count가 tasks(20)인 ready CountDownLatch를 만든다.
입력
동시 작업 수 20
결과·효과
각 invoke가 countDown할 준비 동기화 장치가 생긴다.
비유의 한계
ready latch는 DB account 행을 잠그지 않는다.
45줄F04-L45 var start = new CountDownLatch(1); 지휘봉을 한 번 내리기 전까지 모두 멈춰 있을 단일 출발문을 설치한다. 초기 count 1인 start CountDownLatch를 만든다.
입력
한 번만 열 출발 신호
결과·효과
20개 invoke가 await할 공통 시작 장벽이 생긴다.
비유의 한계
동시에 CPU를 배정받거나 같은 나노초에 SQL을 실행함을 보장하지 않는다.
46줄F04-L46 var pool = Executors.newFixedThreadPool(tasks); 신청서 스무 장이 각자 앉을 수 있도록 작업 책상도 정확히 20개 펼친다. tasks 크기 20의 fixed thread pool을 만든다.
입력
tasks=20
결과·효과
최대 20 worker가 제출된 양방향 작업을 병렬 실행할 수 있다.
비유의 한계
pool 크기만으로 모든 worker가 동시에 DB connection을 얻는 것은 아니다.
47줄F04-L47 try { 성공해도 실패해도 책상을 치울 수 있게 큰 try 보호막을 펼친다. 작업 제출·대기·assertion을 try 블록 안에서 시작한다.
입력
생성된 pool과 두 latch
결과·효과
어느 경로에서도 finally 정리로 이동할 수 있는 범위가 열린다.
비유의 한계
try 자체는 Spring transaction이나 DB rollback을 만들지 않는다.
48줄F04-L48 List<Future<?>> futures = new ArrayList<>(); 스무 작업이 돌려줄 완료표나 예외표를 모을 빈 Future 철을 준비한다. 와일드카드 Future를 담는 빈 ArrayList를 futures로 선언한다.
입력
아직 제출 전인 20개 작업
결과·효과
반환값 타입과 무관하게 모든 비동기 결과를 추적할 목록이 생긴다.
비유의 한계
fresh collection이라 아직 완료 표식이나 대기 표식을 가진 element가 없다.
49줄F04-L49 for (int i = 0; i < pairs; i++) { 번호 0부터 9까지 열 번 돌며 맞교환 신청서 한 쌍씩 작성한다. i가 0 이상 pairs 미만일 동안 반복하는 for 루프를 시작한다.
입력
pairs=10
결과·효과
각 반복에서 A→B와 B→A Future 두 개를 추가하게 된다.
비유의 한계
반복 순서가 DB lock 획득 순서를 결정하지 않는다.
50줄F04-L50 int sequence = i; 현재 반복 index를 sequence라는 새 번호표에 그대로 옮겨 적는다. 현재 `i` 값을 새 int local `sequence`에 대입한다.
입력
for loop의 현재 iteration이 가진 i 값을 받으면
결과·효과
sequence local variable이 현재 i와 같은 정수 값을 가진다.
비유의 한계
이 대입 statement는 task 제출·key 조립·transfer 호출을 아직 수행하지 않는다.
51줄F04-L51 futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence, ab-번호 신청서를 작업자에게 건네고 그 답장 약속을 Future 철에 끼우기 시작한다. pool에 A→B invoke lambda를 submit하고 반환 Future를 futures에 추가하는 호출을 연다.
입력
ready·start·ab-sequence와 첫/둘째 계좌
결과·효과
첫 방향 비동기 작업을 추적할 Future 추가가 시작된다.
비유의 한계
호출이 다음 줄까지 이어져 아직 from/to 인자가 완성되지 않았다.
52줄F04-L52 first.getId(), second.getId()))); 첫 카드 ID를 출발, 둘째 카드 ID를 도착으로 적어 ab 신청서를 완성한다. A→B lambda에 first.getId()를 from, second.getId()를 to로 전달하고 submit/add를 닫는다.
입력
first ID와 second ID
결과·효과
ab-0..9 작업이 SORT-A에서 SORT-B로 100원을 보낼 준비를 마친다.
비유의 한계
이 줄은 Future 완료나 실제 transfer commit을 기다리지 않는다.
53줄F04-L53 futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence, 이번에는 ba-번호표를 단 반대 방향 신청서의 Future를 같은 철에 추가한다. reverse-order invoke를 감싼 Callable을 executor에 넘기고 반환 Future를 list element로 보관한다.
입력
첫 submission completion 뒤 reverse-order worker call의 current arguments를 받을 때
결과·효과
같은 반복의 두 번째 비동기 작업이 등록되기 시작한다.
비유의 한계
다음 줄의 뒤집힌 ID를 읽기 전에는 방향이 완성되지 않는다.
54줄F04-L54 second.getId(), first.getId()))); 둘째 카드 ID를 출발, 첫 카드 ID를 도착으로 적어 ba 신청서를 닫는다. B→A lambda에 second.getId()를 from, first.getId()를 to로 넘기고 호출을 완성한다.
입력
second ID와 first ID
결과·효과
ba-0..9 작업이 SORT-B에서 SORT-A로 100원을 보내도록 등록된다.
비유의 한계
두 방향 key가 다르다고 멱등 처리나 행 수가 이 test에서 assert되는 것은 아니다.
55줄F04-L55 } 열 번째 맞교환 쌍까지 Future 두 장씩 꽂고 신청서 작성 반복을 끝낸다. for 루프 범위를 닫아 총 20개 Future 제출을 마친다.
입력
i=0..9에서 추가된 Future 20개
결과·효과
futures 목록이 A→B 10개와 B→A 10개를 가진다.
비유의 한계
작업 제출 완료와 작업 실행 완료는 서로 다른 시점이다.
56줄F04-L56 assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); ready latch가 제한 시간 안에 0이 되었는지 boolean 검문을 통과시킨다. `ready.await(10, TimeUnit.SECONDS)` 반환값이 true인지 AssertJ로 확인한다.
입력
이미 생성된 ready latch와 이 line의 10-second timeout을 받으면
결과·효과
await가 true를 반환한 경우에만 이 assertion statement가 정상 종료한다.
비유의 한계
이 boolean assertion은 후속 control statement나 database outcome을 실행하지 않는다.
57줄F04-L57 start.countDown(); 준비 완료 뒤 지휘봉을 내려 start 문을 영구히 열어 준다. start.countDown으로 count 1을 0으로 만들어 기다리던 invoke들을 해제한다.
입력
ready assertion을 통과한 start latch
결과·효과
20개 worker가 transfer 호출 단계로 진행할 수 있다.
비유의 한계
실제 실행 순서·공정성·동시 lock 획득 시각은 정하지 않는다.
58줄F04-L58 for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS); Future 철을 앞에서부터 넘기며 각 답장을 그 차례부터 최대 30초 기다린다. 각 Future에 대해 순차적으로 get(30 seconds)을 호출한다.
입력
제출 순서의 Future 20개
결과·효과
모든 get이 반환하면 이 실행의 20개 transfer 호출이 예외 없이 끝났음을 알 수 있다.
비유의 한계
Future마다 30초이며 전체 20개를 하나의 30초 stopwatch로 제한하지 않는다.
59줄F04-L59 long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance(); 모든 답장을 받은 뒤 first 카드의 최신 잔액을 계좌 서랍에서 다시 읽는다. accounts.findById(first.id)로 첫 Account를 조회하고 없으면 예외, 있으면 balance를 firstBalance에 저장한다.
입력
first.getId()와 현재 DB account 행
결과·효과
첫 계좌의 post-transfer 잔액 long 값이 지역 변수에 담긴다.
비유의 한계
이 줄만으로 그 값이 10,000이라고 검사하지 않는다.
60줄F04-L60 long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance(); second 카드도 같은 방식으로 새로 꺼내 두 번째 최종 잔액을 기록한다. second.id로 Account를 조회해 없으면 실패하고 balance를 secondBalance에 저장한다.
입력
second.getId()와 DB의 둘째 계좌 행
결과·효과
두 잔액의 합을 계산할 두 번째 long 값이 준비된다.
비유의 한계
firstBalance와 같거나 정확히 10,000인지는 확인하지 않는다.
61줄F04-L61 assertThat(firstBalance + secondBalance).isEqualTo(20_000); 두 카드의 남은 토큰을 합쳐 처음 총액 20,000과 같은지 저울에 올린다. firstBalance + secondBalance가 20_000인지 assertion한다.
입력
두 repository 조회에서 얻은 최종 잔액
결과·효과
두 account에 남은 총 balance가 20,000이면 보존식 하나가 통과한다.
비유의 한계
개별 잔액, 송금 횟수, 거래 행 수와 중간 상태는 이 합만으로 보장되지 않는다.
62줄F04-L62 assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") TRANSFER_로 시작하는 원장표의 ±금액을 모두 더하되 빈 철이면 0으로 보라는 SQL을 준비한다. ledger_entry에서 entry_type LIKE 'TRANSFER_%'인 signed_amount 합을 COALESCE로 0 처리하는 SELECT를 만든다.
입력
이번 fixture의 ledger_entry 행들
결과·효과
이체 원장 signed 합계를 한 값으로 조회할 SQL이 JdbcClient에 설정된다.
비유의 한계
entry 개수나 각 transfer의 OUT/IN 한 쌍 존재는 아직 세지 않는다.
63줄F04-L63 .query(Long.class).single()).isZero(); SQL이 준 Long 합계 한 개가 정확히 0인지 두 번째 보존 저울로 확인한다. 앞 SELECT를 Long 단일 값으로 실행하고 결과가 zero인지 assertion한다.
입력
TRANSFER_ 원장 signed_amount 총합
결과·효과
출금 음수와 입금 양수의 전체 합이 0이면 원장 보존식이 통과한다.
비유의 한계
행 수 40, business_tx 20, 계좌별 원장 균형은 직접 assertion하지 않는다.
64줄F04-L64 } finally { try 통로가 끝나는 자리에 무조건 진입할 정리실 문을 연다. 앞선 `try`에 대응하는 `finally` block scope를 시작한다.
입력
try control flow가 정상 또는 예외 경로로 빠져나오면
결과·효과
finally scope가 열렸지만 그 안의 cleanup statement는 아직 실행되지 않았다.
비유의 한계
finally 진입 자체는 어떤 자원을 어떻게 정리할지나 업무 변경의 rollback을 정하지 않는다.
65줄F04-L65 start.countDown(); 준비 단계에서 실패했더라도 출발문을 한 번 더 눌러 기다리는 worker가 갇히지 않게 한다. finally에서 start.countDown을 다시 호출한다.
입력
count가 1일 수도 이미 0일 수도 있는 CountDownLatch
결과·효과
아직 닫혀 있었다면 열리고, 이미 0이면 안전한 no-op이 된다.
비유의 한계
worker의 DB 작업 성공이나 graceful 종료를 보장하는 호출은 아니다.
66줄F04-L66 pool.shutdownNow(); 스무 작업 책상에 즉시 정리 요청을 보내 남은 worker들을 interrupt한다. pool.shutdownNow로 executor에 즉시 종료를 요청한다.
입력
fixed thread pool과 실행 중·대기 중일 수 있는 작업
결과·효과
미종료 작업에 interrupt가 전달되고 새 작업 수락이 중단된다.
비유의 한계
shutdownNow도 즉시 모든 thread가 끝났다는 확정이나 awaitTermination 검증은 아니다.
67줄F04-L67 } 출발문 해제와 책상 정리 요청을 끝내고 finally 정리실의 문을 닫는다. finally 블록 범위를 끝낸다.
입력
start.countDown과 shutdownNow 실행
결과·효과
test의 자원 정리 절차가 종료된다.
비유의 한계
닫는 괄호는 보존식 assertion 결과를 새로 만들지 않는다.
68줄F04-L68 } 현재 JUnit method의 마지막 brace를 닫아 실행 범위를 봉인한다. 이 `}`가 `@Test` method body scope를 닫는다.
입력
method 안의 try/finally control flow가 모두 닫힌 뒤
결과·효과
JUnit에 반환될 method execution scope가 종료된다.
비유의 한계
이 brace는 다른 runner·다른 source·추가 실행 횟수에 관한 계약이 아니다.
70줄F04-L70 private Object invoke( invoke라는 private helper의 아직 비어 있는 signature 입구를 연다. `private Object invoke(` method declaration을 시작하고 parameter list를 연다.
입력
test method scope가 닫힌 뒤 class가 다음 member declaration을 받을 때
결과·효과
invoke helper의 return type·name·열린 parameter list만 compiler state에 들어간다.
비유의 한계
parameter identity와 method body는 이 declaration 첫 줄만으로 정해지지 않는다.
71줄F04-L71 CountDownLatch ready, CountDownLatch start, String key, long from, long to 준비 전광판·출발문·고유표·두 계좌 번호를 보조 창구 접수칸에 모두 적는다. invoke의 CountDownLatch 두 개, String key, long from, long to parameter를 선언한다.
입력
호출자가 넘긴 동기화 장치와 방향별 값
결과·효과
각 비동기 작업이 자기 key와 from/to를 사용할 수 있다.
비유의 한계
amount는 parameter가 아니라 76줄에서 100으로 고정된다.
72줄F04-L72 ) { 다섯 칸짜리 접수 서명을 닫고 invoke 작업 본문을 연다. 여러 줄 method signature를 닫고 중괄호로 본문을 시작한다.
입력
완성된 invoke parameter 목록
결과·효과
ready 신호·start 대기·service 호출·interrupt 처리를 담는 범위가 열린다.
비유의 한계
이 문법 줄은 어떤 latch도 줄이거나 기다리지 않는다.
73줄F04-L73 try { 대기 중 interrupt가 와도 정해진 방식으로 바꾸기 위한 try 울타리를 친다. invoke의 ready/start/transfer 흐름을 try 블록에서 시작한다.
입력
현재 worker의 다섯 parameter
결과·효과
InterruptedException을 아래 catch로 모을 수 있다.
비유의 한계
RuntimeException이나 BusinessException을 여기서 catch하지 않는다.
74줄F04-L74 ready.countDown(); 이 worker가 준비석에 도착했다는 불 하나를 20칸 전광판에서 끈다. ready.countDown으로 준비 latch count를 1 감소시킨다.
입력
초기 20에서 남아 있는 ready count
결과·효과
20개 invoke가 모두 실행되면 메인 thread의 ready.await가 풀릴 수 있다.
비유의 한계
countDown은 start 문을 열거나 DB row를 잠그지 않는다.
75줄F04-L75 start.await(); 현재 worker가 start 문이 열릴 때까지 그 자리에서 기다린다. `start.await()`가 latch count가 0이 될 때까지 현재 thread를 block한다.
입력
현재 worker가 ready 도착을 알린 뒤 start latch를 받으면
결과·효과
await가 반환하기 전에는 이 worker의 다음 statement가 실행 가능 상태가 되지 않는다.
비유의 한계
latch 해제 뒤의 구체적 statement나 scheduler 재개 순서는 이 line이 보장하지 않는다.
76줄F04-L76 return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100)); customer-1 이름과 방향별 key·from·to, 100원 신청서를 실제 송금 창구에 내고 결과를 돌려준다. TransferService.Command를 만들어 transfers.transfer를 호출하고 반환 객체를 invoke의 결과로 반환한다.
입력
actor customer-1, key ab/ba-0..9, from/to ID, amount 100
결과·효과
각 worker가 Spring proxy의 실제 정렬 잠금 이체 경로를 실행하고 Result를 Future에 전달한다.
비유의 한계
이 줄의 성공만으로 retry, 개별 잔액 동일, ledger 40행을 주장할 수 없다.
77줄F04-L77 } catch (InterruptedException interrupted) { InterruptedException만 받는 catch 처리실의 문을 연다. 앞 try에서 나온 `InterruptedException`을 local `interrupted`로 받는 catch scope를 시작한다.
입력
try body가 InterruptedException을 던져 정상 path를 벗어나면
결과·효과
interrupted exception reference가 catch parameter에 binding되고 빈 catch body scope가 열린다.
비유의 한계
catch opener 자체는 body의 recovery statement나 다른 exception type 처리를 실행하지 않는다.
78줄F04-L78 Thread.currentThread().interrupt(); 잡았던 interrupt 표식을 지우지 않도록 현재 thread 깃발을 다시 세운다. Thread.currentThread().interrupt()로 interrupt status를 복원한다.
입력
catch에 들어온 interrupted 신호
결과·효과
상위 실행기나 진단 코드가 중단 상태를 볼 수 있다.
비유의 한계
깃발 복원은 worker를 재시도하거나 transfer를 완료시키지 않는다.
79줄F04-L79 throw new IllegalStateException(interrupted); 중단 원인을 안쪽에 넣은 IllegalStateException 표를 만들어 Future 쪽으로 던진다. InterruptedException을 cause로 가진 IllegalStateException을 throw한다.
입력
복원된 interrupt 상태와 원래 예외
결과·효과
Future.get이 ExecutionException 경로로 이 작업 실패를 드러낼 수 있다.
비유의 한계
checked 예외를 숨겨 성공값으로 바꾸지 않으며 retry도 수행하지 않는다.
80줄F04-L80 } interrupt 처리 두 단계를 마치고 catch 접수대를 닫는다. InterruptedException catch 블록을 끝낸다.
입력
status 복원과 IllegalStateException throw
결과·효과
invoke의 중단 변환 규칙이 완결된다.
비유의 한계
정상 worker는 이 블록을 지나지 않는다.
81줄F04-L81 } 준비 신호부터 실제 송금까지 묶은 invoke 보조 창구의 셔터를 내린다. 마지막 `}`가 private invoke helper의 body와 call-frame declaration 범위를 종료한다.
입력
정상 transfer 반환 또는 중단 runtime failure
결과·효과
pool에 제출된 Callable의 최종 결과가 Future에 기록될 수 있다.
비유의 한계
helper 종료 자체는 DB transaction 경계를 소유하지 않는다.
82줄F04-L82 } SortedLockTransferIT 설계도 전체를 덮어 하나의 완결된 시험 타입으로 끝낸다. SortedLockTransferIT 클래스 본문을 닫는다.
입력
필드·setUp·한 @Test·invoke helper
결과·효과
Java compiler가 완결된 test class로 읽을 수 있다.
비유의 한계
파일 마지막 중괄호는 test를 실행하거나 deadlock 부재를 증명하지 않는다.
30초 경계히토리 → 니지카 → 료 → 키타
  1. 히토리

    Future가 30초 안에만 오면 값은 상관없어?

  2. 니지카

    task 예외도 get에서 전파되므로 정상 반환까지 필요해.

  3. timeout과 업무 예외 모두 이 test를 Red로 만든다.

  4. 키타

    종료와 성공을 함께 확인하겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F04-C01 · package and concurrency imports1–21줄
1–21줄 원본

package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import com.example.financialcore.account.AccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
F04-C02 · test class dependencies and accounts22–32줄
22–32줄 원본

@SpringBootTest
class SortedLockTransferIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired TransferService transfers;
    Account first;
    Account second;

    @BeforeEach
F04-C03 · two-account fixture33–40줄
33–40줄 원본
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        first = openings.open("customer-1", "SORT-A", 10_000);
        second = openings.open("customer-1", "SORT-B", 10_000);
    }

    @Test
F04-C04 · twenty opposite-direction tasks and invariants41–68줄
41–68줄 원본
    void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {
        int pairs = 10;
        int tasks = pairs * 2;
        var ready = new CountDownLatch(tasks);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(tasks);
        try {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < pairs; i++) {
                int sequence = i;
                futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,
                    first.getId(), second.getId())));
                futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,
                    second.getId(), first.getId())));
            }
            assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);
            long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();
            long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();
            assertThat(firstBalance + secondBalance).isEqualTo(20_000);
            assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
                .query(Long.class).single()).isZero();
        } finally {
            start.countDown();
            pool.shutdownNow();
        }
    }
F04-C05 · barrier invocation helper and cleanup69–83줄
69–83줄 원본

    private Object invoke(
        CountDownLatch ready, CountDownLatch start, String key, long from, long to
    ) {
        try {
            ready.countDown();
            start.await();
            return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(interrupted);
        }
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 74 / 74

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

준비·설명 줄 17개도 번역해서 보기
원본한국어 번역
2package com.example.financialcore.transfer;이 테스트 파일의 정식 주소를 com.example.financialcore.transfer 패키지로 정한다.
4import com.example.financialcore.PostgresIntegrationTestSupport;실제 PostgreSQL 통합 시험의 공통 준비를 상속하려고 지원 클래스를 가져온다.
5import com.example.financialcore.account.Account;두 fixture 계좌와 최종 잔액을 표현하는 Account 타입을 가져온다.
6import com.example.financialcore.account.AccountOpeningService;10,000원 계좌 두 개를 여는 AccountOpeningService를 가져온다.
7import com.example.financialcore.account.AccountRepository;최종 계좌 잔액을 다시 읽는 AccountRepository를 가져온다.
8import org.junit.jupiter.api.BeforeEach;각 테스트 전 fixture 준비를 표시하는 BeforeEach를 가져온다.
9import org.junit.jupiter.api.Test;JUnit이 실행할 직접 테스트를 표시하는 Test를 가져온다.
10import org.springframework.beans.factory.annotation.Autowired;Spring bean을 테스트 필드에 주입하는 Autowired를 가져온다.
11import org.springframework.boot.test.context.SpringBootTest;전체 Spring Boot context를 띄우는 SpringBootTest를 가져온다.
12import org.springframework.jdbc.core.simple.JdbcClient;TRUNCATE와 원장 합계 SQL을 실행할 JdbcClient를 가져온다.
14import java.util.ArrayList;Future들을 빈 가변 목록으로 만들 ArrayList를 가져온다.
15import java.util.List;스무 Future를 순서대로 담는 List를 가져온다.
16import java.util.concurrent.CountDownLatch;준비와 출발 시점을 맞추는 CountDownLatch를 가져온다.
17import java.util.concurrent.Executors;고정 크기 20-thread pool을 만드는 Executors를 가져온다.
18import java.util.concurrent.Future;worker 완료나 예외를 나중에 회수하는 Future를 가져온다.
19import java.util.concurrent.TimeUnit;ready 10초와 Future별 30초 단위를 나타내는 TimeUnit을 가져온다.
21import static org.assertj.core.api.Assertions.assertThat;실제 값과 기대값을 읽기 좋게 비교하는 AssertJ assertThat을 정적 가져온다.
원본한국어 번역
23@SpringBootTestSortedLockTransferIT를 전체 Spring Boot context 통합 테스트로 실행하게 표시한다.
24class SortedLockTransferIT extends PostgresIntegrationTestSupport {PostgresIntegrationTestSupport를 상속하는 SortedLockTransferIT 클래스 본문을 시작한다.
25 @Autowired JdbcClient jdbc;Spring JdbcClient bean을 jdbc 필드에 자동 주입한다.
26 @Autowired AccountOpeningService openings;AccountOpeningService bean을 openings 필드에 주입한다.
27 @Autowired AccountRepository accounts;AccountRepository bean을 accounts 필드에 주입한다.
28 @Autowired TransferService transfers;TransferService bean을 transfers 필드에 주입한다.
29 Account first;첫 번째 Account fixture를 보관할 package-private first 필드를 선언한다.
30 Account second;두 번째 Account fixture를 보관할 second 필드를 선언한다.
32 @BeforeEachsetUp을 각 @Test 전에 실행하도록 JUnit @BeforeEach로 표시한다.
33 void setUp() {반환값 없는 setUp 메서드 본문을 시작한다.
34 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")idempotency_request·ledger_entry·business_tx·account를 RESTART IDENTITY CASCADE로 TRUNCATE할 SQL을 준비한다.
35 .update();앞 줄의 TRUNCATE SQL을 update로 실행한다.
36 first = openings.open("customer-1", "SORT-A", 10_000);owner customer-1, accountNo SORT-A, balance 10_000인 계좌를 열어 first에 저장한다.
37 second = openings.open("customer-1", "SORT-B", 10_000);owner customer-1, accountNo SORT-B, balance 10_000인 두 번째 계좌를 열어 second에 저장한다.
38 }setUp 메서드 본문을 끝낸다.
40 @Test아래 메서드를 직접 실행되는 @Test로 표시한다.
41 void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal test를 선언하고 checked 예외 전파를 허용한다.
42 int pairs = 10;반대 방향 작업 쌍 수를 int pairs=10으로 고정한다.
43 int tasks = pairs * 2;tasks를 pairs * 2로 계산해 20으로 만든다.
44 var ready = new CountDownLatch(tasks);초기 count가 tasks(20)인 ready CountDownLatch를 만든다.
45 var start = new CountDownLatch(1);초기 count 1인 start CountDownLatch를 만든다.
46 var pool = Executors.newFixedThreadPool(tasks);tasks 크기 20의 fixed thread pool을 만든다.
47 try {작업 제출·대기·assertion을 try 블록 안에서 시작한다.
48 List<Future<?>> futures = new ArrayList<>();와일드카드 Future를 담는 빈 ArrayList를 futures로 선언한다.
49 for (int i = 0; i < pairs; i++) {i가 0 이상 pairs 미만일 동안 반복하는 for 루프를 시작한다.
50 int sequence = i;현재 i를 effectively final인 int sequence에 복사한다.
51 futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,pool에 A→B invoke lambda를 submit하고 반환 Future를 futures에 추가하는 호출을 연다.
52 first.getId(), second.getId())));A→B lambda에 first.getId()를 from, second.getId()를 to로 전달하고 submit/add를 닫는다.
53 futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,pool에 B→A invoke lambda를 submit하고 그 Future를 futures에 추가하는 호출을 시작한다.
54 second.getId(), first.getId())));B→A lambda에 second.getId()를 from, first.getId()를 to로 넘기고 호출을 완성한다.
55 }for 루프 범위를 닫아 총 20개 Future 제출을 마친다.
56 assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();ready.await(10 seconds)가 true인지 AssertJ로 검사한다.
57 start.countDown();start.countDown으로 count 1을 0으로 만들어 기다리던 invoke들을 해제한다.
58 for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);각 Future에 대해 순차적으로 get(30 seconds)을 호출한다.
59 long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();accounts.findById(first.id)로 첫 Account를 조회하고 없으면 예외, 있으면 balance를 firstBalance에 저장한다.
60 long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();second.id로 Account를 조회해 없으면 실패하고 balance를 secondBalance에 저장한다.
61 assertThat(firstBalance + secondBalance).isEqualTo(20_000);firstBalance + secondBalance가 20_000인지 assertion한다.
62 assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")ledger_entry에서 entry_type LIKE 'TRANSFER_%'인 signed_amount 합을 COALESCE로 0 처리하는 SELECT를 만든다.
63 .query(Long.class).single()).isZero();앞 SELECT를 Long 단일 값으로 실행하고 결과가 zero인지 assertion한다.
64 } finally {try에 대응하는 finally 블록을 시작한다.
65 start.countDown();finally에서 start.countDown을 다시 호출한다.
66 pool.shutdownNow();pool.shutdownNow로 executor에 즉시 종료를 요청한다.
67 }finally 블록 범위를 끝낸다.
68 }@Test 메서드 본문을 닫는다.
70 private Object invoke(private Object invoke 메서드 선언의 첫 줄을 시작한다.
71 CountDownLatch ready, CountDownLatch start, String key, long from, long toinvoke의 CountDownLatch 두 개, String key, long from, long to parameter를 선언한다.
72 ) {여러 줄 method signature를 닫고 중괄호로 본문을 시작한다.
73 try {invoke의 ready/start/transfer 흐름을 try 블록에서 시작한다.
74 ready.countDown();ready.countDown으로 준비 latch count를 1 감소시킨다.
75 start.await();start.await로 latch count가 0이 될 때까지 현재 worker를 기다리게 한다.
76 return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));TransferService.Command를 만들어 transfers.transfer를 호출하고 반환 객체를 invoke의 결과로 반환한다.
77 } catch (InterruptedException interrupted) {InterruptedException을 interrupted 변수로 catch한다.
78 Thread.currentThread().interrupt();Thread.currentThread().interrupt()로 interrupt status를 복원한다.
79 throw new IllegalStateException(interrupted);InterruptedException을 cause로 가진 IllegalStateException을 throw한다.
80 }InterruptedException catch 블록을 끝낸다.
81 }invoke 메서드 본문을 닫는다.
82}SortedLockTransferIT 클래스 본문을 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

10쌍·20 task를 barrier 뒤 동시에 실행해 각 Future의 제한 시간 종료, 잔액 합계 20,000, 이체 signed sum 0을 한 번 확인한다.

문법 해부

  • CountDownLatch는 정해진 횟수의 준비 신호를 모은다.
  • ExecutorService는 20개 callable을 thread pool에 제출한다.
  • Future.get(30, SECONDS)는 결과 또는 예외를 회수하며 시간 초과도 실패로 만든다.
  • finally는 성공·실패 모두 start를 열고 pool을 shutdownNow한다.

실행 순서

  1. 두 계좌를 각각 10,000으로 연다.
  2. 20 task가 ready를 줄이고 start에서 대기한다.
  3. test thread가 start를 열고 모든 Future를 30초 제한으로 회수한다.
  4. DB에서 두 잔액 합계와 TRANSFER 원장 합계를 읽어 assertion한다.
  5. finally가 기다리는 task를 풀고 executor를 정리한다.

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

F04-C01 · package and concurrency imports
문법 해부
transfer package와 account/opening/repository, JUnit·Spring·JdbcClient, collection, latch, executor, future, timeout, AssertJ import가 opposite-direction concurrency test를 준비한다.
실제 값 추적
compiler는 Account services, JdbcClient, ArrayList·List, CountDownLatch·Executors·Future·TimeUnit과 assertThat symbols를 해석할 수 있다.
정상 예
CountDownLatch·Executors·Future·TimeUnit을 짧은 이름으로 참조할 수 있는 compile 상태가 이 import set의 유효 결과다.
틀린 예·반례
concurrency type import만으로 lock ordering이나 deadlock freedom이 구현됐다고 결론 내릴 수 없다.
착각 방지
package/import 준비줄은 translation에는 포함하지만 실행 mapping에서는 제외한다.
하지 않는 일
이 범위는 concurrency workload를 시작하거나 database outcome을 관찰하지 않는다.
다음 연결
다음 ‘test class dependencies and accounts’ 범위에서는 @SpringBootTest class가 PostgreSQL support를 상속하고 jdbc·openings·accounts·transfers를 주입받으며 first와 second account field를 선언하고 BeforeEach를 표시한다.
F04-C02 · test class dependencies and accounts
문법 해부
@SpringBootTest class가 PostgreSQL support를 상속하고 jdbc·openings·accounts·transfers를 주입받으며 first와 second account field를 선언하고 BeforeEach를 표시한다.
실제 값 추적
test instance는 fixture 생성과 transfer 호출, balance reload, ledger query에 필요한 네 dependency와 두 account slot을 갖는다.
정상 예
Spring wiring 뒤 네 dependency fields가 bean references를 갖고 first·second 두 slots가 존재하는 상태가 이 범위의 유효 결과다.
틀린 예·반례
initializer 없는 first와 second field는 아직 account를 가리키지 않으므로 persisted fixture가 생겼다고 읽으면 안 된다.
착각 방지
BeforeEach annotation은 이어질 method declaration을 lifecycle callback으로 분류할 뿐 body statement를 자체 실행하지 않는다.
하지 않는 일
여기서는 sorted lock을 호출하거나 thread scheduling을 시작하지 않는다.
다음 연결
다음 ‘two-account fixture’ 범위에서는 setUp이 네 table을 truncate하고 SORT-A와 SORT-B를 각각 10000으로 열어 first·second에 저장한 뒤 다음 @Test를 표시한다.
F04-C03 · two-account fixture
문법 해부
setUp이 네 table을 truncate하고 SORT-A와 SORT-B를 각각 10000으로 열어 first·second에 저장한 뒤 다음 @Test를 표시한다.
실제 값 추적
매 test 시작 시 두 account balance 합은 20000이고 transfer business rows는 없는 fixture가 준비된다.
정상 예
customer-1 소유 SORT-A와 SORT-B가 각각 10000 balance로 생성되어 두 fields가 채워지는 것이 이 fixture 범위의 정상 결과다.
틀린 예·반례
두 opening은 application persistence effect를 만들 수 있으므로 table truncate 직후와 같은 완전한 zero-row 상태라고 단정하면 안 된다.
착각 방지
마지막 @Test는 이어질 method를 test로 표시할 metadata일 뿐 이 범위에서 workload를 실행하지 않는다.
하지 않는 일
fixture는 한 고객·두 account·고정 금액에 묶이며 다른 ownership과 insufficient balance를 다루지 않는다.
다음 연결
다음 ‘twenty opposite-direction tasks and invariants’ 범위에서는 test는 pairs 10으로 반대 방향 task 20개를 만들고 ready/start latch로 동시에 풀어 각 Future.get에 30초 timeout을 적용해 회수한 뒤 balance 합 20000과 TRANSFER ledger signed sum 0을 검사한다.
F04-C04 · twenty opposite-direction tasks and invariants
문법 해부
test는 pairs 10으로 반대 방향 task 20개를 만들고 ready/start latch로 동시에 풀어 각 Future.get에 30초 timeout을 적용해 회수한 뒤 balance 합 20000과 TRANSFER ledger signed sum 0을 검사한다.
실제 값 추적
ab와 ba 각각 열 번의 반대 방향 호출이 성공하면 first/second의 이동은 상쇄되고 총합과 transfer 원장 부호합이 보존된다.
정상 예
한 실행에서 ready가 10초 내 모이고 20개 Future가 각각 자기 30초 get 제한 안에 반환하며 두 invariant가 맞는 것이 Green 경로다.
틀린 예·반례
scheduler가 우연히 직렬화한 한 번의 성공은 가능한 모든 interleaving에서 deadlock이 없다는 수학적 증명이 아니다.
착각 방지
method 이름의 FinishWithoutDeadlock은 timeout 관찰 한 회로 한정하고 반복 stress나 DB lock graph proof로 읽지 않는다.
하지 않는 일
finally의 shutdown은 worker cleanup을 시도하지만 thread leak·transaction retry·개별 transfer row 수는 assertion하지 않는다.
다음 연결
다음 ‘barrier invocation helper and cleanup’ 범위에서는 invoke helper는 worker가 ready latch를 줄이고 start latch를 기다린 뒤 customer-1, unique key, from/to, 100 command를 transfer하며 interrupt를 복원해 IllegalStateException으로 바꾼다.
F04-C05 · barrier invocation helper and cleanup
문법 해부
invoke helper는 worker가 ready latch를 줄이고 start latch를 기다린 뒤 customer-1, unique key, from/to, 100 command를 transfer하며 interrupt를 복원해 IllegalStateException으로 바꾼다.
실제 값 추적
모든 worker가 준비되기 전 transfer를 막고 start.countDown 뒤 각 방향 command가 service에 들어간다.
정상 예
InterruptedException을 잡으면 current thread interrupt flag를 다시 세운 후 unchecked failure로 Future에 전달하는 흐름은 source와 맞다.
틀린 예·반례
barrier 동기화가 database row lock 획득 순서를 직접 강제한다고 해석하면 안 된다.
착각 방지
unique key가 각 task의 idempotency 충돌을 피하지만 key uniqueness 자체를 query로 검증하지는 않는다.
하지 않는 일
helper는 balance·ledger invariant를 검사하지 않고 실제 proof는 앞 test의 Future와 assertion에 있다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 helper는 balance·ledger invariant를 검사하지 않고 실제 proof는 앞 test의 Future와 assertion에 있다.
두 보존식히토리 → 니지카 → 료 → 키타
  1. 히토리

    잔액 합계만 20,000이면 충분하지?

  2. 니지카

    원장 TRANSFER signed sum도 0인지 따로 본다.

  3. 두 식이 같은 오류를 항상 잡는 건 아니야.

  4. 키타

    잔액과 원장을 두 줄로 설명할게요.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1pairs=10tasks=pairs*2를 계산한다.tasks=20반복 10회와 task 20개를 혼동하지 않는다.
2ready count=20각 task가 ready.countDown 후 start.await한다.ready.await(10s)가 true여야 출발한다.thread scheduling은 완전 동시를 보장하지 않는다.
3A,B=10,000서로 반대 방향으로 각 100원을 열 번 보낸다.최종 합계 20,000, transfer signed sum 0개별 최종 잔액은 이 test가 10,000으로 assert하지 않는다.
한 번의 한계히토리 → 니지카 → 료 → 키타
  1. 히토리

    이 실행이 Green이면 deadlock은 불가능하다고 말해도 돼?

  2. 니지카

    아니, 20 task 한 번에서 관찰되지 않았다는 뜻이야.

  3. 반복 stress와 다양한 schedule은 다음 책임이다.

  4. 키타

    envelope 숫자를 답변에 붙이겠습니다.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

JUnit test thread

barrier 준비와 Future 결과 회수를 소유한다.

test timeout 정책 전체가 아니라 코드에 적힌 await/get 제한만 보인다.
JVM executor

고정 20-thread pool이 두 방향 callable을 실행한다.

실제 CPU 병렬도와 DB connection 수는 환경에 따라 다르다.
Spring transaction/DB

TransferService가 계좌 잠금과 잔액·원장을 처리한다.

test는 lock SQL이나 retry 횟수를 직접 관찰하지 않는다.

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

oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal

Arrange · 준비
  • BeforeEach가 SORT-A와 SORT-B를 각 10,000원으로 만들고 이전 네 table 행을 지운다.
  • pairs10/tasks20, ready20, start1, 20-thread pool, Future 목록을 준비한다.
Act · 행동
  • ab-0..9는 first→second, ba-0..9는 second→first로 각각 100원 transfer를 제출한다.
  • 모든 invoke가 준비되면 start를 열고 각 Future를 순서대로 최대 30초 회수한다.
Assert · 확인
  • ready.await(10초)가 true다.
  • 20개 Future.get이 예외 없이 반환한다.
  • firstBalance + secondBalance가 20,000이다.
  • TRANSFER_ ledger_entry signed_amount 합이 0이다.
직접 보장
  • 이 fixture/run의 실제 정렬 잠금 service 경로에서 반대 방향 transfer 20개가 Future로 반환했다.
  • 실행 뒤 두 계좌 총잔액과 이체 원장 signed 합 보존식이 각각 20,000과 0이다.
보장하지 않음
  • unsafe 잠금 순서의 실제 deadlock 재현 또는 모든 부하의 deadlock 자유
  • 각 계좌의 최종 잔액이 10,000
  • business_tx 20행·ledger_entry 40행
  • 전체 20개 작업의 단일 30초 deadline
  • retry/backoff/fairness/throughput

첫 실패 경계 20 worker가 ready 지점에 10초 안에 모이지 않으면 첫 assertion에서 실패한다. start 뒤에는 어느 transfer의 예외나 per-Future 30초 초과가 Future.get에서 먼저 드러나고, 모두 반환한 뒤에만 총잔액·원장합 assertion으로 간다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 20 task가 끝났으니 deadlock은 영원히 없다.

왜 틀리나 한 fixture의 한 번 실행일 뿐 가능한 schedule을 모두 탐색하지 않았다.

바르게 읽기 10쌍·20 task·1회 envelope라고 말한다.

반례 다른 connection pool이나 더 긴 부하에서 아직 보지 못한 cycle이 나타날 수 있다.

❌ Future가 30초 안에 끝나면 모두 성공 assertion을 통과하지 않아도 된다.

왜 틀리나 Future.get은 task 예외를 test thread로 다시 던진다.

바르게 읽기 정상 반환과 timeout·예외를 함께 fail boundary로 읽는다.

반례 한 task가 BusinessException을 던지면 get에서 ExecutionException으로 test가 실패한다.

❌ signed sum 0이면 각 계좌 잔액도 모두 정확하다.

왜 틀리나 전체 합계는 분배 오류를 숨길 수 있다.

바르게 읽기 이 test가 직접 보는 합계만 주장하고 개별 계좌 assertion은 별도로 둔다.

반례 A=19,000·B=1,000도 합계 20,000은 만족한다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

반복성

여러 회 반복에서도 deadlock 0

이 책임을 맡는 곳: 반복·randomized stress runner
개별 정확성

각 task별 tx/ledger pair와 각 최종 balance

이 책임을 맡는 곳: per-task identity assertions and reconciliation
성능

처리량·median·p95가 운영 목표 충족

이 책임을 맡는 곳: load test with production-like pool and data
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • pairs와 tasks의 관계를 손으로 계산한다.
  • ready와 start의 역할을 바꾸지 않고 barrier를 다시 쓴다.
  • Future timeout, 잔액 합계, signed sum assertion을 서로 다른 보장으로 설명한다.

2단계 · 코드 조각 재조립

  1. 준비 문과 출발 문
  2. 반대 방향 10쌍
  3. 두 보존식

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

정본을 닫고 83줄 전체를 다시 쓴 뒤 mapping 57행, translation 74행, @Test 1개를 대조한다.

자가 점검
  • 비공백 translation과 package/import 제외 mapping line 번호가 source 순서와 맞는지 본다.
  • 각 mapping의 실제 뜻·입력·직후 상태·한계가 서로 다른 역할인지 읽는다.
  • 고정 숫자와 timeout, key, balance, row count를 stage source에 다시 대조한다.
  • source-local @Test와 W14 smoke selected test를 섞지 않는다.
13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w10/day-3/SortedLockTransferIT.javaSHA-256 30d5696d56f0e6bbc3207386b65a09941b75dd9a82ecdbb64e8231668813a810
SortedLockTransferIT.java — 반대 방향 20 task의 한 번짜리 동시성 envelope 전체

package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import com.example.financialcore.account.AccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class SortedLockTransferIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired TransferService transfers;
    Account first;
    Account second;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        first = openings.open("customer-1", "SORT-A", 10_000);
        second = openings.open("customer-1", "SORT-B", 10_000);
    }

    @Test
    void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {
        int pairs = 10;
        int tasks = pairs * 2;
        var ready = new CountDownLatch(tasks);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(tasks);
        try {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < pairs; i++) {
                int sequence = i;
                futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,
                    first.getId(), second.getId())));
                futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,
                    second.getId(), first.getId())));
            }
            assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);
            long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();
            long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();
            assertThat(firstBalance + secondBalance).isEqualTo(20_000);
            assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
                .query(Long.class).single()).isZero();
        } finally {
            start.countDown();
            pool.shutdownNow();
        }
    }

    private Object invoke(
        CountDownLatch ready, CountDownLatch start, String key, long from, long to
    ) {
        try {
            ready.countDown();
            start.await();
            return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(interrupted);
        }
    }
}

05

TransferFailurePointIT.java — 두 RuntimeException 지점의 행 효과 rollback

reference/w12/day-4/TransferFailurePointIT.java

누적 Java 테스트 참고본 · 정본 · W14-F05
48줄 연결62줄 번역7 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

멱등 claim 뒤와 business mutation 뒤에 예외가 나도 transfer 관련 DB 효과가 남지 않아야 한다. 두 test가 각 지점을 주입하고 세 table 범위를 0으로 확인한다.

  1. AFTER_CLAIM과 AFTER_BUSINESS는 어디서 다를까?
  2. 이 test가 직접 0으로 확인하는 세 효과는 무엇일까?
  3. 잔액까지 rollback됐다고 이 파일만으로 말할 수 있을까?
이 파일에서 끝까지 다시 쓰는 값from=10000to=5000amount=1000points=AFTER_CLAIM/AFTER_BUSINESSidempotency rows=0transfer business_tx rows=0transfer ledger rows=0
02

STEP 02 / 13

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

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

STARRY 송금표에 접수 도장을 찍은 직후, 또는 돈 기록을 바꾼 직후 고장을 일부러 낸다.

도장 직후와 장부 변경 직후의 두 비상 정지

각 test는 failure hook의 지점을 하나 고르고 1,000원 이체를 호출한다. RuntimeException과 `injected` 메시지가 실제로 나와야 한다.

실패 뒤 멱등 요청, TRANSFER 업무거래, TRANSFER 원장 행이 모두 0인지 센다. 세 행 효과가 남지 않았다는 좁은 범위만 직접 확인한다.

딱 여기까지만 비상 정지 비유는 두 RuntimeException 지점과 세 행 집계만 설명하며 account balance·외부 메시지·process crash rollback을 추가 증명하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

두 고장 지점

enum이 NONE, AFTER_CLAIM, AFTER_BUSINESS 중 하나를 고른다.

코드 연결
L60-L70
비유
접수 도장 뒤 또는 장부 변경 뒤 빨간 버튼
비유의 끝
두 지점 밖의 예외는 다루지 않는다.

공유 rollback 검사

두 test가 같은 helper에 key만 바꿔 전달한다.

코드 연결
L32-L51
비유
두 비상훈련이 같은 정리 체크리스트 사용
비유의 끝
helper는 account balance를 읽지 않는다.

세 table 0

idempotency_request, TRANSFER business_tx, TRANSFER ledger_entry를 각각 센다.

코드 연결
L46-L50
비유
접수표·사건표·이체 영수증 칸 비우기
비유의 끝
다른 tx_type이나 audit side effect는 범위 밖이다.
두 failure point히토리 → 니지카 → 료 → 키타
  1. 히토리

    AFTER_CLAIM과 AFTER_BUSINESS는 같은 순간이야?

  2. 니지카

    첫째는 claim 뒤, 둘째는 업무 변경 뒤에 예외를 낸다.

  3. enum 값과 hook callback 이름을 붙여 읽어.

  4. 키타

    두 시점을 섞지 않겠습니다.

claim 직후히토리 → 니지카 → 료 → 키타
  1. 히토리

    claim 행이 잠깐 생겼다가 남을 수도 있지?

  2. 니지카

    test는 실패 뒤 idempotency_request count가 0인지 본다.

  3. 잠깐의 중간 상태가 아니라 transaction 종료 뒤 결과를 읽는 거야.

  4. 키타

    관찰 시점을 명시할게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.48 / 48 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
18줄F05-L18 @SpringBootTest Spring 전체 시험 환경을 요청하는 class-level 표찰을 먼저 건다. `@SpringBootTest` bootstrap metadata를 선언한다.
입력
Spring test scanner가 이 annotation record를 읽으면
결과·효과
뒤따를 type declaration에 연결될 application-context bootstrap metadata가 기록된다.
비유의 한계
이 marker line만으로 type 이름·fixture·test body는 실행되지 않는다.
19줄F05-L19 @Import(TransferFailurePointIT.FailureConfiguration.class) ControlledFailureHook을 만드는 시험 전용 설정을 Spring context에 끼운다. test context에 TransferFailurePointIT.FailureConfiguration.class 설정을 추가한다.
입력
Spring test context가 추가 FailureConfiguration class를 등록하려는 시점이다.
결과·효과
FailureConfiguration이 이 test context가 읽을 추가 bean-definition source에 포함된다.
비유의 한계
Import metadata는 configuration source를 연결하지만 그 안의 bean factory를 이 line에서 호출하지 않는다.
20줄F05-L20 class TransferFailurePointIT extends PostgresIntegrationTestSupport { 공용 Postgres 받침대를 이어받은 TransferFailurePointIT 훈련장을 펼친다. PostgresIntegrationTestSupport를 상속한 TransferFailurePointIT class 본문을 연다.
입력
Postgres integration support를 상속하는 outer test type declaration을 compiler가 처리할 때
결과·효과
PostgresIntegrationTestSupport 기능을 물려받는 TransferFailurePointIT type scope가 열린다.
비유의 한계
TransferFailurePointIT type scope를 연 사실만으로 field injection이나 test invocation은 일어나지 않는다.
21줄F05-L21 @Autowired JdbcClient jdbc; rollback 뒤 세 table 행 수를 셀 JdbcClient를 연결받는다. Autowired resolution이 JdbcClient instance를 jdbc field에 주입한다.
입력
Spring context가 raw SQL access용 JdbcClient candidate를 field injection에 제공하면
결과·효과
test instance의 jdbc field가 JdbcClient bean을 받을 injection point로 등록된다.
비유의 한계
JdbcClient reference 주입은 SQL statement 구성·실행과 별개의 단계다.
22줄F05-L22 @Autowired AccountOpeningService openings; 실패 전 출발·도착 계좌를 만들 개설 서비스를 연결받는다. AccountOpeningService reference가 openings injection point에 wiring된다.
입력
account-opening use case를 호출할 service bean을 test instance에 연결할 차례가 되면
결과·효과
openings field가 계좌 fixture를 만들 AccountOpeningService bean 참조를 받게 된다.
비유의 한계
openings field가 bean을 가리켜도 Account row는 service method를 부르기 전까지 생기지 않는다.
23줄F05-L23 @Autowired TransferService transfers; 두 고장 지점을 지나갈 실제 TransferService를 연결받는다. production TransferService bean을 transfers member가 받도록 선언한다.
입력
failure scenario가 호출할 production TransferService reference를 wiring할 때
결과·효과
transfers field가 실패 지점을 통과할 실제 TransferService bean과 연결된다.
비유의 한계
TransferService injection point 선언은 어떤 Command도 실행하지 않는다.
24줄F05-L24 @Autowired ControlledFailureHook hook; 이번 시험이 누를 failure point를 바꿀 ControlledFailureHook을 연결받는다. ControlledFailureHook bean candidate를 hook field에 연결하는 injection metadata다.
입력
imported test configuration이 만든 controlled hook bean을 injection 대상으로 찾으면
결과·효과
hook field가 test configuration의 ControlledFailureHook bean을 가리키게 된다.
비유의 한계
hook reference를 연결하는 것만으로 failure point가 선택되지는 않는다.
25줄F05-L25 Account from; 매 시험의 출발 계좌 객체를 넣어 둘 from 칸을 준비한다. Account from 값을 보관할 field 자리를 선언한다.
입력
outer test instance에 아직 초기화 expression 없는 source Account slot이 필요할 때
결과·효과
from이라는 Account instance field가 선언되고 명시적 대입 전 reference 기본값은 null이다.
비유의 한계
from field 선언은 Account object 생성이나 database identity 할당을 포함하지 않는다.
26줄F05-L26 Account to; 매 시험의 도착 계좌 객체를 넣어 둘 to 칸을 준비한다. initializer 없는 두 번째 Account member `to`를 outer test instance layout에 추가한다.
입력
from field 다음에 별도 destination Account reference member를 선언할 때
결과·효과
to라는 별도 Account instance field가 선언되며 아직 어떤 Account도 가리키지 않는다.
비유의 한계
to reference slot은 별도 대입 전까지 어느 destination도 가리키지 않는다.
28줄F05-L28 @BeforeEach void clean() { clean이라는 per-test lifecycle method의 빈 정리실 문을 연다. `@BeforeEach void clean() {`가 clean method를 before-each callback으로 선언하고 body scope를 시작한다.
입력
JUnit이 이 combined annotation·method declaration line을 처리하면
결과·효과
clean callback metadata와 call frame이 생겼지만 body statement는 아직 실행되지 않았다.
비유의 한계
combined annotation과 opener는 lifecycle metadata와 statement가 아직 없는 method scope까지만 만든다.
29줄F05-L29 hook.point = ControlledFailureHook.Point.NONE; 이전 시험의 고장 선택을 지우고 hook 바늘을 NONE 위치로 되돌린다. hook의 현재 failure point를 NONE으로 초기화한다.
입력
clean body가 previous test의 hook selection을 기본 상태로 돌리려 하면
결과·효과
이전 test가 남긴 failure 선택이 지워져 hook.point가 NONE이 된다.
비유의 한계
NONE 대입은 hook 선택값만 바꾸며 persistence table을 청소하지 않는다.
30줄F05-L30 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update(); 멱등·원장·거래·계좌 표를 모두 비우고 identity 번호를 처음으로 돌린다. 멱등·원장·업무거래·계좌 table을 비우고 identity를 다시 시작하는 SQL을 실제 실행한다.
입력
두 failure point rollback 훈련용 test DB와 현재 SQL/조회 식 `jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();`를 받는다.
결과·효과
네 persistence table의 행이 사라지고 identity sequence가 초기값부터 다시 시작한다.
비유의 한계
이 호출은 적힌 SQL/조회 한 단계만 다루며 다른 table이나 최종 commit 상태까지 대신 검증하지 않는다.
31줄F05-L31 from = openings.open("customer-1", "ROLL-FROM", 10_000); customer-1의 ROLL-FROM 계좌를 만 원으로 열어 출발점에 둔다. customer-1의 ROLL-FROM 계좌를 balance 10,000으로 열어 from에 저장한다.
입력
TRUNCATE completion 뒤 rollback fixture의 source account를 열 차례가 되면
결과·효과
customer-1의 ROLL-FROM 10,000 계좌가 생성되고 반환 Account가 from에 저장된다.
비유의 한계
ROLL-FROM opening은 from 한 계좌만 만들고 to field에는 손대지 않는다.
32줄F05-L32 to = openings.open("customer-2", "ROLL-TO", 5_000); customer-2의 ROLL-TO 계좌를 오천 원으로 열어 도착점에 둔다. customer-2의 ROLL-TO 계좌를 balance 5,000으로 열어 to에 저장한다.
입력
from Account가 준비된 다음 destination fixture를 AccountOpeningService에 요청하면
결과·효과
customer-2의 ROLL-TO 5,000 계좌가 생성되고 반환 Account가 to에 저장된다.
비유의 한계
ROLL-TO Account 생성은 transfer transaction을 수행하지 않는다.
33줄F05-L33 } 고장 없음과 깨끗한 DB·두 계좌가 준비되어 clean fixture를 닫는다. clean fixture 메서드 본문을 닫는다.
입력
두 failure point rollback 훈련 중 clean fixture 메서드 본문 안쪽의 마지막 문장이 끝난 제어 상태를 받는다.
결과·효과
clean call frame이 닫혀 두 계좌와 NONE hook을 가진 fixture가 test body에 넘겨진다.
비유의 한계
clean scope를 닫는 brace는 이어질 test method를 대신 실행하지 않는다.
35줄F05-L35 @Test void afterClaimRuntimeExceptionRollsBackClaim() { claim 직후 runtime 고장이 선점 행까지 되돌리는지 볼 첫 JUnit 시험을 연다. afterClaimRuntimeExceptionRollsBackClaim을 JUnit @Test로 선언하고 본문을 연다.
입력
JUnit이 `afterClaimRuntimeExceptionRollsBackClaim`를 direct test로 등록하려 한다.
결과·효과
claim 직후 rollback method가 JUnit test로 등록되고 실행 body가 시작된다.
비유의 한계
combined Test declaration은 body scope를 열 뿐 helper call의 outcome을 미리 확정하지 않는다.
36줄F05-L36 hook.point = ControlledFailureHook.Point.AFTER_CLAIM; hook 바늘을 AFTER_CLAIM 홈으로 옮겨 첫 고장 버튼만 활성화한다. hook의 failure point를 AFTER_CLAIM으로 바꾼다.
입력
첫 rollback test body가 hook의 current Point를 claim-after option으로 바꿀 때
결과·효과
현재 controlled hook의 선택값이 AFTER_CLAIM으로 바뀐다.
비유의 한계
AFTER_CLAIM assignment만으로 callback이나 service invocation은 시작되지 않는다.
37줄F05-L37 assertFailureAndNoEffect("after-claim"); after-claim key를 공통 helper에 넘겨 예외와 세 COUNT 0을 검사한다. after-claim key로 공통 실패·무효과 검사 helper를 실행한다.
입력
hook point AFTER_CLAIM과 key after-claim이 준비되어 공통 helper를 호출한다.
결과·효과
after-claim key를 받은 assertFailureAndNoEffect 호출이 정상 반환해야 이 test statement가 끝난다.
비유의 한계
helper call이 정상 반환해도 account balance를 이 call site가 직접 읽는 것은 아니다.
38줄F05-L38 } claim 직후 rollback 시나리오 호출을 마치고 첫 시험 본문을 닫는다. 이 brace가 afterClaimRuntimeExceptionRollsBackClaim JUnit method의 execution scope를 종료한다.
입력
after-claim helper call이 반환해 첫 JUnit method body의 끝에 도달하면
결과·효과
첫 rollback test scope가 닫혀 JUnit이 이 method의 실행을 끝낼 수 있다.
비유의 한계
첫 test method 종료는 다른 lifecycle execution의 성공을 보장하지 않는다.
40줄F05-L40 @Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() { 업무 변경 직후 runtime 고장이 DB 효과를 되돌리는 둘째 JUnit 시험을 연다. afterBusinessRuntimeExceptionRollsBackEveryEffect를 JUnit @Test로 선언하고 본문을 연다.
입력
JUnit이 `afterBusinessRuntimeExceptionRollsBackEveryEffect`를 direct test로 등록하려 한다.
결과·효과
business mutation 직후 rollback method가 둘째 JUnit test로 등록되어 body를 연다.
비유의 한계
둘째 Test opener는 body 안 failure selection과 assertion을 아직 수행하지 않는다.
41줄F05-L41 hook.point = ControlledFailureHook.Point.AFTER_BUSINESS; hook 바늘을 AFTER_BUSINESS 홈으로 옮겨 둘째 고장 버튼을 활성화한다. hook의 failure point를 AFTER_BUSINESS로 바꾼다.
입력
둘째 rollback path에서 callback selector를 business-after option으로 설정할 때
결과·효과
현재 controlled hook이 AFTER_BUSINESS callback에서 고장을 내도록 선택된다.
비유의 한계
AFTER_BUSINESS 선택은 hook field 상태만 바꾸고 business mutation을 자체 생성하지 않는다.
42줄F05-L42 assertFailureAndNoEffect("after-business"); after-business 문자열 표를 공통 helper에 넘겨 예외와 무효과 검사를 요청한다. `after-business` 문자열 argument로 실패·무효과 검사 helper를 호출한다.
입력
hook point AFTER_BUSINESS와 현재 문자열 literal이 준비되어 helper call을 평가할 때
결과·효과
after-business 인자를 받은 assertFailureAndNoEffect call이 종료되어 control이 복귀한다.
비유의 한계
이 call site는 assertFailureAndNoEffect 내부 SQL의 개별 결과를 별도 field에 저장하지 않는다.
43줄F05-L43 } 업무 변경 뒤 rollback 시나리오 호출을 마치고 둘째 시험 본문을 닫는다. afterBusinessRuntimeExceptionRollsBackEveryEffect body의 final boundary를 이 `}`가 닫는다.
입력
after-business helper invocation 뒤 둘째 test의 closing brace를 만나면
결과·효과
둘째 rollback test의 call frame이 닫히고 다음 test lifecycle 단계로 복귀한다.
비유의 한계
둘째 test scope closing은 class나 nested configuration scope까지 닫지 않는다.
45줄F05-L45 private void assertFailureAndNoEffect(String key) { key 하나로 실패 이체와 멱등·거래·원장 COUNT를 검사할 공통 helper를 연다. 실패 예외와 세 DB 무효과 COUNT를 함께 검사하는 private helper를 연다.
입력
두 tests가 공통으로 부를 key-parameter helper member를 compiler가 선언할 때
결과·효과
key 하나를 입력받아 공통 실패 호출과 DB 무효과를 검사할 helper scope가 열린다.
비유의 한계
helper declaration을 열어도 호출 전에는 exception assertion과 SQL count가 실행되지 않는다.
46줄F05-L46 assertThatThrownBy(() -> transfers.transfer(new TransferService.Command( 예외 포착 그물·service call·Command 조립틀을 한꺼번에 펼치기 시작한다. `assertThatThrownBy` lambda 안에서 `transfer(new Command(` 호출 chain을 연다.
입력
assertFailureAndNoEffect helper body가 첫 exception assertion에 들어오면
결과·효과
assertThatThrownBy·lambda·transfer·Command constructor가 모두 열린 채 arguments를 기다린다.
비유의 한계
argument와 closing delimiter가 없으므로 Command·service call·exception assertion은 아직 완성되지 않았다.
47줄F05-L47 "customer-1", key, from.getId(), to.getId(), 1_000))) 현재 key·두 계좌 id·천 원을 Command에 채우고 transfer 호출 괄호를 닫는다. 공통 실패 helper의 Command에 actor·현재 key·두 account ID·amount 1,000을 넣고 호출을 닫는다.
입력
Command에 넣을 customer-1·현재 key·from id·to id·amount 1,000을 받는다.
결과·효과
customer-1·현재 key·from/to id·1,000 Command가 완성되어 lambda 안에서 transfer가 호출된다.
비유의 한계
현재 line은 Command argument tuple과 invocation closure만 완성하며 호출 outcome을 관찰하지 않는다.
48줄F05-L48 .isInstanceOf(RuntimeException.class).hasMessageContaining("injected"); 던져진 값이 RuntimeException이고 문구에 injected가 있는지 연달아 확인한다. 바로 앞 호출 결과에 .isInstanceOf(RuntimeException.class).hasMessageContaining("injected") 단계를 이어 붙인다.
입력
assertThatThrownBy가 transfer 호출에서 잡은 Throwable과 기대 RuntimeException·injected 문구를 받는다.
결과·효과
포착한 Throwable이 RuntimeException이고 message에 injected가 있는지 확인된다.
비유의 한계
이 chain은 RuntimeException membership과 message 포함 여부만 보고 persistence를 읽지 않는다.
49줄F05-L49 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero(); rollback 뒤 idempotency_request 전체 COUNT가 0인지 첫 번째로 센다. idempotency_request 전체 행 수를 읽어 0인지 확인한다.
입력
test DB의 idempotency_request 전체 행을 COUNT 대상으로 삼는다.
결과·효과
실패 종료 뒤 idempotency_request 전체 COUNT가 0임이 직접 확정된다.
비유의 한계
idempotency_request total zero는 business_tx나 ledger_entry zero를 대신 증명하지 않는다.
50줄F05-L50 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'") TRANSFER business_tx 행만 세는 둘째 SQL assertion을 시작한다. TRANSFER business_tx 행 수를 세는 SQL 결과를 assertThat에 올린다.
입력
injected RuntimeException을 확인한 뒤 business_tx의 TRANSFER 행들을 rollback COUNT 대상으로 삼는다.
결과·효과
TRANSFER business_tx 행을 셀 SQL assertion chain이 열리고 terminal query를 기다린다.
비유의 한계
business_tx SQL opener에는 terminal long 조회와 zero comparison이 아직 없다.
51줄F05-L51 .query(Long.class).single()).isZero(); 업무 거래 COUNT 단일 값이 0인지 확인한다. business_tx COUNT 단일 값이 0인지 확인한다.
입력
business_tx COUNT 단일 값과 기대값 0을 받는다.
결과·효과
business_tx의 TRANSFER COUNT 단일 값이 0과 일치함이 확인된다.
비유의 한계
TRANSFER business count zero assertion은 ledger row 상태를 확인하지 않는다.
52줄F05-L52 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") TRANSFER_ 원장 행만 세는 셋째 SQL assertion을 시작한다. TRANSFER_ ledger_entry 행 수를 세는 SQL 결과를 assertThat에 올린다.
입력
injected RuntimeException을 확인한 뒤 TRANSFER_ ledger_entry 행들을 rollback COUNT 대상으로 삼는다.
결과·효과
TRANSFER_ 유형 ledger_entry 행을 셀 다음 SQL assertion chain이 시작된다.
비유의 한계
ledger_entry query를 연 이 line만으로 actual count나 expected relation은 결정되지 않는다.
53줄F05-L53 .query(Long.class).single()).isZero(); 이체 원장 COUNT 단일 값이 0인지 확인한다. ledger_entry COUNT 단일 값이 0인지 확인한다.
입력
ledger_entry COUNT 단일 값과 기대값 0을 받는다.
결과·효과
transfer ledger COUNT 단일 값도 0임이 확인되어 세 직접 DB 검사가 끝난다.
비유의 한계
transfer-ledger zero는 account balance 원복을 직접 관찰한 assertion이 아니다.
54줄F05-L54 } 예외 type·message와 세 무효과 COUNT 검사를 마쳐 helper를 닫는다. assertFailureAndNoEffect helper 메서드 본문을 닫는다.
입력
두 failure point rollback 훈련 중 assertFailureAndNoEffect helper 메서드 본문 안쪽의 마지막 문장이 끝난 제어 상태를 받는다.
결과·효과
공유 helper scope가 닫혀 caller에는 예외·claim0·tx0·ledger0 검사의 통과 여부만 돌아간다.
비유의 한계
assertFailureAndNoEffect scope를 닫아도 caller 밖 HTTP mapping까지 검증되지는 않는다.
56줄F05-L56 @TestConfiguration(proxyBeanMethods = false) 다음 type이 시험 전용 설정임을 알릴 proxy-disabled 표찰을 놓는다. `proxyBeanMethods=false`를 가진 `@TestConfiguration` metadata를 선언한다.
입력
Spring annotation scanner가 test-configuration marker를 읽으면
결과·효과
test-only configuration metadata가 pending 상태로 기록되고 아직 bean factory는 실행되지 않는다.
비유의 한계
annotation은 이어질 type 이름이나 member declaration을 미리 만들지 않는다.
57줄F05-L57 static class FailureConfiguration { FailureConfiguration이라는 비어 있는 중첩 설정실 문을 연다. static nested class `FailureConfiguration`의 type body scope를 시작한다.
입력
직전 TestConfiguration metadata가 type declaration을 기다리는 상태에서
결과·효과
FailureConfiguration scope가 열렸고 member declaration은 아직 처리되지 않았다.
비유의 한계
class opener는 뒤 member의 type·이름·생성 객체를 선취하지 않는다.
58줄F05-L58 @Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); } 새 ControlledFailureHook을 만들어 Spring test bean으로 돌려주는 한 줄 공장을 둔다. 새 ControlledFailureHook을 반환하는 test용 Spring bean 메서드를 한 줄로 선언한다.
입력
Spring test context가 ControlledFailureHook bean 인스턴스를 필요로 한다.
결과·효과
ControlledFailureHook 새 instance를 반환하는 bean factory 정의가 한 줄에서 완성된다.
비유의 한계
inline bean factory definition은 service call이나 failure callback invocation을 포함하지 않는다.
59줄F05-L59 } failure hook bean 한 개를 정의한 시험 설정 상자를 닫는다. FailureConfiguration nested type의 member-declaration region이 closing brace에서 끝난다.
입력
inline bean-factory member가 끝나 FailureConfiguration의 outer brace에 도달하면
결과·효과
FailureConfiguration scope가 닫혀 test bean 선언 집합이 완결된다.
비유의 한계
FailureConfiguration closing brace만으로 Spring context가 즉시 refresh되지는 않는다.
61줄F05-L61 static final class ControlledFailureHook implements TransferFailureHook { 세 지점 중 하나를 기억하며 hook 약속을 구현할 최종 ControlledFailureHook을 연다. TransferFailureHook을 구현하는 static final ControlledFailureHook class 본문을 연다.
입력
test configuration scope 뒤 TransferFailureHook 구현 type을 새 member로 선언할 때
결과·효과
TransferFailureHook을 구현하는 static final controlled-hook type scope가 열린다.
비유의 한계
ControlledFailureHook type opener는 instance 생성이나 bean registration을 수행하지 않는다.
62줄F05-L62 enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS } 고장 없음·claim 뒤·업무 변경 뒤라는 세 바늘 위치를 Point 눈금으로 만든다. test hook이 고를 NONE·AFTER_CLAIM·AFTER_BUSINESS 세 실패 지점을 enum으로 선언한다.
입력
ControlledFailureHook body가 선택 상태를 표현할 nested enum declaration을 받을 때
결과·효과
NONE·AFTER_CLAIM·AFTER_BUSINESS 세 선택지를 가진 Point enum type이 만들어진다.
비유의 한계
Point enum declaration은 선택 가능한 constants만 만들고 current point 값을 변경하지 않는다.
63줄F05-L63 volatile Point point = Point.NONE; 여러 thread가 읽을 현재 바늘을 volatile로 두고 시작 위치를 NONE으로 잡는다. 현재 failure point를 여러 thread에 보이도록 volatile로 선언하고 NONE으로 초기화한다.
입력
Point constants가 만들어진 다음 visibility가 필요한 current-state field를 초기화할 때
결과·효과
volatile point field가 생성되고 최초 관찰값은 Point.NONE이 된다.
비유의 한계
volatile initialization은 visibility 대상 field를 만들 뿐 concurrent callback 실행을 재현하지 않는다.
64줄F05-L64 @Override public void afterClaim() { afterClaim이라는 override callback의 빈 실행실을 연다. `@Override public void afterClaim() {` declaration과 body scope를 시작한다.
입력
TransferFailureHook contract의 afterClaim signature를 구현할 때
결과·효과
afterClaim callback frame이 열렸지만 body condition은 아직 평가되지 않았다.
비유의 한계
combined annotation·method opener는 뒤 condition·field value·exception object를 선취하지 않는다.
65줄F05-L65 if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim"); 바늘이 AFTER_CLAIM이면 ‘injected after claim’ runtime 고장을 즉시 낸다. point가 AFTER_CLAIM이면 RuntimeException("injected after claim")을 던진다.
입력
hook.point가 AFTER_CLAIM과 같아 claim 뒤 고장 guard가 true인 경우를 받는다.
결과·효과
point가 AFTER_CLAIM이면 exact message RuntimeException이 즉시 전파되고, 아니면 계속 진행한다.
비유의 한계
after-claim throw line은 HTTP 변환이나 transaction rollback의 최종 관찰값을 직접 검사하지 않는다.
66줄F05-L66 } claim 뒤 조건부 고장 동작을 마치고 afterClaim 구현을 닫는다. afterClaim callback body를 닫아 non-throw path가 caller에 return할 수 있게 한다.
입력
afterClaim conditional statement가 끝나 callback method scope를 반환할 때
결과·효과
afterClaim callback scope가 닫혀 예외가 없던 경로는 caller로 돌아간다.
비유의 한계
afterClaim method closing은 다른 callback body의 scope를 닫지 않는다.
67줄F05-L67 @Override public void afterBusinessMutation() { TransferService가 업무 변경 뒤 부르는 afterBusinessMutation 구현을 연다. TransferFailureHook.afterBusinessMutation을 재정의하는 메서드 본문을 연다.
입력
TransferFailureHook이 약속한 `afterBusinessMutation` 구현 자리를 받는다.
결과·효과
TransferFailureHook.afterBusinessMutation 구현 method가 별도 callback scope를 연다.
비유의 한계
afterBusinessMutation override opener 자체는 point comparison이나 exception allocation을 실행하지 않는다.
68줄F05-L68 if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business"); 바늘이 AFTER_BUSINESS이면 ‘injected after business’ runtime 고장을 즉시 낸다. point가 AFTER_BUSINESS이면 RuntimeException("injected after business")을 던진다.
입력
hook.point가 AFTER_BUSINESS와 같아 업무 변경 뒤 고장 guard가 true인 경우를 받는다.
결과·효과
point가 AFTER_BUSINESS이면 injected-after-business RuntimeException이 밖으로 전달된다.
비유의 한계
after-business conditional throw는 외부 system effect나 checked-exception path를 다루지 않는다.
69줄F05-L69 } 업무 변경 뒤 조건부 고장 동작을 마치고 둘째 hook 구현을 닫는다. 이 method terminator가 afterBusinessMutation callback의 control scope를 끝낸다.
입력
두 failure point rollback 훈련 중 ControlledFailureHook.afterBusinessMutation 메서드 본문 안쪽의 마지막 문장이 끝난 제어 상태를 받는다.
결과·효과
afterBusinessMutation callback이 닫혀 비선택 경로는 정상 반환한다.
비유의 한계
afterBusinessMutation brace는 enclosing ControlledFailureHook type까지 종료하지 않는다.
70줄F05-L70 } Point 바늘과 두 override를 품은 ControlledFailureHook class를 닫는다. ControlledFailureHook nested implementation type 전체를 closing brace로 종료한다.
입력
두 callback method가 모두 닫혀 ControlledFailureHook type boundary에 도달하면
결과·효과
ControlledFailureHook 중첩 type scope가 닫혀 두 callback 구현이 고정된다.
비유의 한계
nested hook class를 닫는 것은 Spring bean instance 생성과 다른 compiler event다.
71줄F05-L71 } fixture·두 시험·helper·설정을 품은 TransferFailurePointIT 훈련장을 닫는다. TransferFailurePointIT outer test class의 최종 type boundary다.
입력
nested hook implementation까지 끝난 뒤 outer integration-test type을 닫을 때
결과·효과
TransferFailurePointIT 바깥 scope가 닫혀 이 source의 두 test와 test bean 선언이 끝난다.
비유의 한계
outer class closing brace는 source-local tests를 추가 실행하거나 runner 범위를 넓히지 않는다.
business 직후히토리 → 니지카 → 료 → 키타
  1. 히토리

    업무 변경 뒤면 transfer 원장이 남지 않아?

  2. 니지카

    예외 뒤 TRANSFER business_tx와 ledger count를 모두 0으로 요구한다.

  3. 두 query는 서로 다른 table 효과를 본다.

  4. 키타

    사건과 원장을 따로 세겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F05-C01 · package, imports, and test class1–18줄
1–18줄 원본
package com.example.financialcore.transfer;

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.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
F05-C02 · dependencies and fixture19–31줄
19–31줄 원본
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired TransferService transfers;
    @Autowired ControlledFailureHook hook;
    Account from;
    Account to;

    @BeforeEach void clean() {
        hook.point = ControlledFailureHook.Point.NONE;
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "ROLL-FROM", 10_000);
F05-C03 · after-claim failure test32–36줄
32–36줄 원본
        to = openings.open("customer-2", "ROLL-TO", 5_000);
    }

    @Test void afterClaimRuntimeExceptionRollsBackClaim() {
        hook.point = ControlledFailureHook.Point.AFTER_CLAIM;
F05-C04 · after-business failure test37–41줄
37–41줄 원본
        assertFailureAndNoEffect("after-claim");
    }

    @Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() {
        hook.point = ControlledFailureHook.Point.AFTER_BUSINESS;
F05-C05 · shared exception and zero-effect assertions42–51줄
42–51줄 원본
        assertFailureAndNoEffect("after-business");
    }

    private void assertFailureAndNoEffect(String key) {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", key, from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessageContaining("injected");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
F05-C06 · test failure-hook bean52–59줄
52–59줄 원본
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); }
    }
F05-C07 · two-point controlled failure hook60–71줄
60–71줄 원본

    static final class ControlledFailureHook implements TransferFailureHook {
        enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS }
        volatile Point point = Point.NONE;
        @Override public void afterClaim() {
            if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
        }
        @Override public void afterBusinessMutation() {
            if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business");
        }
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 62 / 62

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

준비·설명 줄 14개도 번역해서 보기
원본한국어 번역
1package com.example.financialcore.transfer;이 파일의 주소를 com.example.financialcore.transfer 패키지로 정한다.
3import com.example.financialcore.PostgresIntegrationTestSupport;com.example.financialcore.PostgresIntegrationTestSupport type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
4import com.example.financialcore.account.Account;com.example.financialcore.account.Account type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
5import com.example.financialcore.account.AccountOpeningService;com.example.financialcore.account.AccountOpeningService type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
6import org.junit.jupiter.api.BeforeEach;org.junit.jupiter.api.BeforeEach type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
7import org.junit.jupiter.api.Test;org.junit.jupiter.api.Test type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
8import org.springframework.beans.factory.annotation.Autowired;org.springframework.beans.factory.annotation.Autowired type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
9import org.springframework.boot.test.context.SpringBootTest;org.springframework.boot.test.context.SpringBootTest type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
10import org.springframework.boot.test.context.TestConfiguration;org.springframework.boot.test.context.TestConfiguration type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
11import org.springframework.context.annotation.Bean;org.springframework.context.annotation.Bean type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
12import org.springframework.context.annotation.Import;org.springframework.context.annotation.Import type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
13import org.springframework.jdbc.core.simple.JdbcClient;org.springframework.jdbc.core.simple.JdbcClient type을 이 파일에서 짧은 이름으로 쓰게 불러온다.
15import static org.assertj.core.api.Assertions.assertThat;org.assertj.core.api.Assertions.assertThat의 static 검사 도구를 짧은 이름으로 쓰게 불러온다.
16import static org.assertj.core.api.Assertions.assertThatThrownBy;org.assertj.core.api.Assertions.assertThatThrownBy의 static 검사 도구를 짧은 이름으로 쓰게 불러온다.
원본한국어 번역
18@SpringBootTest실제 Spring Boot context를 올리는 통합 시험이라고 표시한다.
19@Import(TransferFailurePointIT.FailureConfiguration.class)test context에 TransferFailurePointIT.FailureConfiguration.class 설정을 추가한다.
20class TransferFailurePointIT extends PostgresIntegrationTestSupport {PostgresIntegrationTestSupport를 상속한 TransferFailurePointIT class 본문을 연다.
21 @Autowired JdbcClient jdbc;Spring이 실제 bean을 찾아 JdbcClient jdbc 필드에 넣는다.
22 @Autowired AccountOpeningService openings;Spring이 실제 bean을 찾아 AccountOpeningService openings 필드에 넣는다.
23 @Autowired TransferService transfers;Spring이 실제 bean을 찾아 TransferService transfers 필드에 넣는다.
24 @Autowired ControlledFailureHook hook;Spring이 실제 bean을 찾아 ControlledFailureHook hook 필드에 넣는다.
25 Account from;Account from 값을 보관할 field 자리를 선언한다.
26 Account to;Account to 값을 보관할 field 자리를 선언한다.
28 @BeforeEach void clean() {clean을 각 @Test 전에 실행할 @BeforeEach fixture로 선언하고 본문을 연다.
29 hook.point = ControlledFailureHook.Point.NONE;hook의 현재 failure point를 NONE으로 초기화한다.
30 jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();멱등·원장·업무거래·계좌 table을 비우고 identity를 다시 시작하는 SQL을 실제 실행한다.
31 from = openings.open("customer-1", "ROLL-FROM", 10_000);customer-1의 ROLL-FROM 계좌를 balance 10,000으로 열어 from에 저장한다.
32 to = openings.open("customer-2", "ROLL-TO", 5_000);customer-2의 ROLL-TO 계좌를 balance 5,000으로 열어 to에 저장한다.
33 }clean fixture 메서드 본문을 닫는다.
35 @Test void afterClaimRuntimeExceptionRollsBackClaim() {afterClaimRuntimeExceptionRollsBackClaim을 JUnit @Test로 선언하고 본문을 연다.
36 hook.point = ControlledFailureHook.Point.AFTER_CLAIM;hook의 failure point를 AFTER_CLAIM으로 바꾼다.
37 assertFailureAndNoEffect("after-claim");after-claim key로 공통 실패·무효과 검사 helper를 실행한다.
38 }afterClaimRuntimeExceptionRollsBackClaim test 본문을 닫는다.
40 @Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() {afterBusinessRuntimeExceptionRollsBackEveryEffect를 JUnit @Test로 선언하고 본문을 연다.
41 hook.point = ControlledFailureHook.Point.AFTER_BUSINESS;hook의 failure point를 AFTER_BUSINESS로 바꾼다.
42 assertFailureAndNoEffect("after-business");after-business key로 공통 실패·무효과 검사 helper를 실행한다.
43 }afterBusinessRuntimeExceptionRollsBackEveryEffect test 본문을 닫는다.
45 private void assertFailureAndNoEffect(String key) {실패 예외와 세 DB 무효과 COUNT를 함께 검사하는 private helper를 연다.
46 assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(새 Transfer Command 호출이 던질 예외의 type과 message 검사를 시작한다.
47 "customer-1", key, from.getId(), to.getId(), 1_000)))공통 실패 helper의 Command에 actor·현재 key·두 account ID·amount 1,000을 넣고 호출을 닫는다.
48 .isInstanceOf(RuntimeException.class).hasMessageContaining("injected");바로 앞 호출 결과에 .isInstanceOf(RuntimeException.class).hasMessageContaining("injected") 단계를 이어 붙인다.
49 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();idempotency_request 전체 행 수를 읽어 0인지 확인한다.
50 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")TRANSFER business_tx 행 수를 세는 SQL 결과를 assertThat에 올린다.
51 .query(Long.class).single()).isZero();business_tx COUNT 단일 값이 0인지 확인한다.
52 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")TRANSFER_ ledger_entry 행 수를 세는 SQL 결과를 assertThat에 올린다.
53 .query(Long.class).single()).isZero();ledger_entry COUNT 단일 값이 0인지 확인한다.
54 }assertFailureAndNoEffect helper 메서드 본문을 닫는다.
56 @TestConfiguration(proxyBeanMethods = false)아래 클래스를 proxyBeanMethods 없이 쓰는 test 전용 Spring 설정으로 표시한다.
57 static class FailureConfiguration {failure hook test bean을 제공하는 static FailureConfiguration class 본문을 연다.
58 @Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); }새 ControlledFailureHook을 반환하는 test용 Spring bean 메서드를 한 줄로 선언한다.
59 }FailureConfiguration class 본문을 닫는다.
61 static final class ControlledFailureHook implements TransferFailureHook {TransferFailureHook을 구현하는 static final ControlledFailureHook class 본문을 연다.
62 enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS }test hook이 고를 NONE·AFTER_CLAIM·AFTER_BUSINESS 세 실패 지점을 enum으로 선언한다.
63 volatile Point point = Point.NONE;현재 failure point를 여러 thread에 보이도록 volatile로 선언하고 NONE으로 초기화한다.
64 @Override public void afterClaim() {TransferFailureHook.afterClaim을 재정의하는 메서드 본문을 연다.
65 if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");point가 AFTER_CLAIM이면 RuntimeException("injected after claim")을 던진다.
66 }ControlledFailureHook.afterClaim 메서드 본문을 닫는다.
67 @Override public void afterBusinessMutation() {TransferFailureHook.afterBusinessMutation을 재정의하는 메서드 본문을 연다.
68 if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business");point가 AFTER_BUSINESS이면 RuntimeException("injected after business")을 던진다.
69 }ControlledFailureHook.afterBusinessMutation 메서드 본문을 닫는다.
70 }ControlledFailureHook class 본문을 닫는다.
71}TransferFailurePointIT class 본문을 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

claim 뒤와 business mutation 뒤 RuntimeException을 각각 주입해 예외를 확인하고 세 transfer 효과 집계가 모두 0인지 본다.

문법 해부

  • @TestConfiguration과 @Bean은 test context 전용 failure hook을 등록한다.
  • volatile point는 worker가 읽을 현재 주입 지점을 보이게 한다.
  • assertThatThrownBy는 예외 종류와 메시지를 함께 검사한다.
  • JdbcClient count query는 세 종류의 남은 DB 행을 각각 센다.

실행 순서

  1. BeforeEach가 hook을 NONE으로 돌리고 table을 비운다.
  2. 출발 계좌 10,000과 도착 계좌 5,000을 연다.
  3. 각 test가 한 failure point를 선택한다.
  4. TransferService가 1,000원 command 처리 중 hook에서 RuntimeException을 받는다.
  5. helper가 세 DB count를 0과 비교한다.

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

F05-C01 · package, imports, and test class
문법 해부
transfer failure test의 package, PostgreSQL/account/JUnit/Spring test configuration, bean/import, JDBC, AssertJ exception assertion을 준비하고 @SpringBootTest를 표시한다.
실제 값 추적
compiler와 Spring metadata reader가 Postgres support, Account, opening service, lifecycle, TestConfiguration, JDBC와 AssertJ symbols를 찾을 수 있게 된다.
정상 예
나열된 annotations·types·assertThat·assertThatThrownBy를 qualified name 없이 참조할 수 있는 상태가 이 범위의 유효 결과다.
틀린 예·반례
@SpringBootTest 한 줄과 imports만으로 임의의 test-specific bean이 등록됐다고 보면 안 된다.
착각 방지
setup import는 mapping body에서 빼되 source byte와 전체 translation에는 보존한다.
하지 않는 일
이 범위는 rollback effect, balance, transaction count 가운데 아무것도 아직 측정하지 않는다.
다음 연결
다음 ‘dependencies and fixture’ 범위에서는 @Import가 FailureConfiguration을 넣고 class가 jdbc·openings·transfers·hook과 from/to field를 준비한 뒤 clean에서 hook NONE, business_tx 포함 네 table truncate, ROLL-FROM 10000 opening까지 수행한다.
F05-C02 · dependencies and fixture
문법 해부
@Import가 FailureConfiguration을 넣고 class가 jdbc·openings·transfers·hook과 from/to field를 준비한 뒤 clean에서 hook NONE, business_tx 포함 네 table truncate, ROLL-FROM 10000 opening까지 수행한다.
실제 값 추적
각 test 전 failure hook이 꺼지고 빈 fixture에 from account 하나가 10000으로 만들어진 상태까지 도달한다.
정상 예
from field가 실제 persisted account를 가리키고 transfer rows가 없는 시작 상태가 이 범위의 유효 결과다.
틀린 예·반례
to field에는 이 범위 안에서 assignment가 없으므로 destination fixture까지 완성됐다고 쓰면 안 된다.
착각 방지
fixture preparation을 failure-path assertion으로 오해하지 말고 현재 범위가 만든 NONE hook과 source account 상태만 센다.
하지 않는 일
from opening의 자체 persistence effect는 존재할 수 있으며 이 범위는 failure rollback을 직접 검증하지 않는다.
다음 연결
다음 ‘after-claim failure test’ 범위에서는 clean의 마지막 statement가 ROLL-TO 5000 account를 to에 저장하고 clean을 닫은 뒤, afterClaimRuntimeExceptionRollsBackClaim test를 열어 hook point를 AFTER_CLAIM으로 설정한다.
F05-C03 · after-claim failure test
문법 해부
clean의 마지막 statement가 ROLL-TO 5000 account를 to에 저장하고 clean을 닫은 뒤, afterClaimRuntimeExceptionRollsBackClaim test를 열어 hook point를 AFTER_CLAIM으로 설정한다.
실제 값 추적
from 10000에 이어 to 5000 fixture가 완성되고 현재 controlled hook의 선택 상태가 NONE에서 AFTER_CLAIM으로 바뀐다.
정상 예
to field가 ROLL-TO account를 가리키고 열린 test body에서 hook.point가 AFTER_CLAIM인 상태까지가 이 범위의 유효 결과다.
틀린 예·반례
hook point assignment만 읽고 exception이 실제로 던져졌거나 persistence effect가 rollback됐다고 단정하면 안 된다.
착각 방지
test method 이름은 검증 의도를 말하지만 이 범위에는 service invocation이나 exception assertion이 없다.
하지 않는 일
balance 10000/5000 유지 여부는 이 test 전체에서도 직접 assert하지 않는 중요한 비책임이다.
다음 연결
다음 ‘after-business failure test’ 범위에서는 after-claim test가 assertFailureAndNoEffect에 문자열 literal argument를 전달하고 닫힌 뒤, afterBusinessRuntimeExceptionRollsBackEveryEffect test가 열려 hook point를 AFTER_BUSINESS로 설정한다.
F05-C04 · after-business failure test
문법 해부
after-claim test가 assertFailureAndNoEffect에 문자열 literal argument를 전달하고 닫힌 뒤, afterBusinessRuntimeExceptionRollsBackEveryEffect test가 열려 hook point를 AFTER_BUSINESS로 설정한다.
실제 값 추적
첫 test는 현재 범위 첫 줄에서 helper call을 수행하고, 둘째 test는 현재 범위 끝에서 business-after hook selection까지만 완료한다.
정상 예
after-claim 문자열을 받은 helper call이 정상 반환하고 둘째 test의 hook.point가 AFTER_BUSINESS인 상태까지가 이 범위의 유효 흐름이다.
틀린 예·반례
둘째 hook assignment만으로 exception 발생이나 database rollback assertion까지 실행됐다고 보면 안 된다.
착각 방지
첫 helper call의 결과와 둘째 test의 준비 상태를 하나의 동일한 failure timing으로 합치지 않는다.
하지 않는 일
이 범위는 둘째 test의 service call, exception observation, persistence outcome을 아직 보여 주지 않는다.
다음 연결
다음 ‘shared exception and zero-effect assertions’ 범위에서는 assertFailureAndNoEffect는 amount 1000 transfer가 injected RuntimeException을 던지는지 확인하고 idempotency_request count 0과 TRANSFER business_tx count 0을 검사한다.
F05-C05 · shared exception and zero-effect assertions
문법 해부
assertFailureAndNoEffect는 amount 1000 transfer가 injected RuntimeException을 던지는지 확인하고 idempotency_request count 0과 TRANSFER business_tx count 0을 검사한다.
실제 값 추적
after-claim 또는 after-business key가 service에 들어가면 exception message에 injected가 있고 claim·transfer business row가 남지 않아야 한다.
정상 예
두 hook point 모두 이 helper에서 RuntimeException과 두 zero count를 만족하는 것이 유효 failure example이다.
틀린 예·반례
이 범위에 적힌 두 count query 밖의 다른 persistence table도 0이라고 확대하면 source보다 강한 결론이 된다.
착각 방지
method 이름의 NoEffect를 balance unchanged proof로 바꾸지 말고 실제 query한 table만 direct proof로 센다.
하지 않는 일
from/to balance와 opening rows는 검사하지 않으므로 rollback 후 금액 보존 전체를 보장하지 않는다.
다음 연결
다음 ‘test failure-hook bean’ 범위에서는 shared helper의 TRANSFER ledger_entry count 0 assertion을 마친 뒤 TestConfiguration이 ControlledFailureHook bean을 한 개 제공한다.
F05-C06 · test failure-hook bean
문법 해부
shared helper의 TRANSFER ledger_entry count 0 assertion을 마친 뒤 TestConfiguration이 ControlledFailureHook bean을 한 개 제공한다.
실제 값 추적
failure run 뒤 transfer ledger row가 없고 Spring test context에서 hook interface 구현체를 주입할 bean 정의가 생긴다.
정상 예
TRANSFER ledger_entry zero와 test-only hook bean 등록을 각각 확인하는 것이 이 혼합 range의 정확한 두 역할이다.
틀린 예·반례
ledger count 0을 account balance unchanged나 모든 ledger row zero로 확대하면 opening entry를 무시하게 된다.
착각 방지
proxyBeanMethods=false는 configuration proxy를 줄이는 설정이지 rollback 의미를 바꾸지 않는다.
하지 않는 일
bean 정의는 production configuration이 아니며 test context 밖의 hook wiring을 보장하지 않는다.
다음 연결
다음 ‘two-point controlled failure hook’ 범위에서는 ControlledFailureHook는 NONE·AFTER_CLAIM·AFTER_BUSINESS enum과 volatile point를 두고 각 callback에서 선택한 지점이면 injected RuntimeException을 던진다.
F05-C07 · two-point controlled failure hook
문법 해부
ControlledFailureHook는 NONE·AFTER_CLAIM·AFTER_BUSINESS enum과 volatile point를 두고 각 callback에서 선택한 지점이면 injected RuntimeException을 던진다.
실제 값 추적
clean이 NONE으로 초기화한 뒤 test가 point를 바꾸면 service callback 하나가 정확한 message로 실패를 발생시킨다.
정상 예
AFTER_CLAIM은 afterClaim에서, AFTER_BUSINESS는 afterBusinessMutation에서만 throw하는 분리는 정상 hook 동작이다.
틀린 예·반례
volatile은 visibility를 돕지만 callback 횟수·ordering·transaction boundary 자체를 검증하지 않는다.
착각 방지
hook이 exception을 던진다는 사실과 database가 실제 rollback된다는 사실은 서로 다른 책임층이다.
하지 않는 일
두 지점 이외의 failure, checked exception, process crash, external side effect 보상은 이 hook이 다루지 않는다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 두 지점 이외의 failure, checked exception, process crash, external side effect 보상은 이 hook이 다루지 않는다.
예외 종류히토리 → 니지카 → 료 → 키타
  1. 히토리

    IOException도 여기서 검증했어?

  2. 니지카

    아니, hook이 던지는 건 RuntimeException이다.

  3. 메시지에 injected가 있는지도 확인한다.

  4. 키타

    종류와 메시지를 정확히 쓰겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1AFTER_CLAIMclaim 직후 hook이 injected after claim 예외를 던진다.idempotency/transfer tx/transfer ledger count=0balance는 이 source가 직접 읽지 않는다.
2AFTER_BUSINESSbusiness mutation 직후 hook이 예외를 던진다.동일한 세 count=0audit_event나 외부 전송은 세 query에 없다.
3key 두 개after-claim과 after-business를 별도 idempotency key로 호출한다.각 test fixture에서 독립 실패를 만든다.두 test 사이 DB는 BeforeEach로 다시 초기화된다.
세 개의 0히토리 → 니지카 → 료 → 키타
  1. 히토리

    COUNT 세 줄은 같은 뜻을 세 번 쓴 거야?

  2. 니지카

    멱등 claim, 업무 사건, 원장 효과를 각각 센다.

  3. 한 table이 0이어도 다른 table 잔여를 숨기면 안 돼.

  4. 키타

    세 책임을 나눠 적을게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

Spring test context

FailureConfiguration이 ControlledFailureHook bean을 주입한다.

production bean wiring 전체를 검증하는 목적은 아니다.
Transaction proxy

service가 예외를 밖으로 전파하면 transaction rollback 후보가 된다.

test는 proxy boundary 자체를 introspection하지 않는다.
PostgreSQL

세 COUNT query가 rollback 뒤 남은 transfer 효과를 관찰한다.

각 query가 확인하지 않은 balance·audit·외부 상태는 자동 포함되지 않는다.

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

afterClaimRuntimeExceptionRollsBackClaim

Arrange · 준비
  • BeforeEach가 point NONE, 깨끗한 네 table, balances 10,000/5,000을 만든다.
  • 현재 test는 point를 AFTER_CLAIM으로 바꾸고 key after-claim을 쓴다.
Act · 행동
  • TransferService가 claim insert 뒤 failureHook.afterClaim에서 injected RuntimeException을 던지게 한다.
Assert · 확인
  • RuntimeException이며 message에 injected가 있다.
  • idempotency_request 0, TRANSFER business_tx 0, TRANSFER_ ledger 0이다.
직접 보장
  • 이 RuntimeException fixture에서 claim insert가 transaction과 함께 rollback되어 세 직접 COUNT가 모두 0이다.
보장하지 않음
  • balance 복원 직접 assertion
  • process hard-kill
  • checked exception rollback
  • 외부 시스템 effect
  • 모든 ledger 유형 0

첫 실패 경계 @BeforeEach의 29줄 hook reset, 30줄 TRUNCATE, 31~32줄 계좌 개설 중 하나가 실패하면 test body 전에 가장 먼저 중단된다. 준비가 끝나면 36줄 point 지정 뒤 37줄 helper 안의 46~48줄 transfer 호출·예외 type·message 검사가 먼저이고, 통과하면 49줄 idempotency COUNT, 50~51줄 거래 COUNT, 52~53줄 원장 COUNT 순으로 첫 실패 지점이 이동한다.

afterBusinessRuntimeExceptionRollsBackEveryEffect

Arrange · 준비
  • BeforeEach가 point NONE, 깨끗한 네 table, balances 10,000/5,000을 만든다.
  • 현재 test는 point를 AFTER_BUSINESS로 바꾸고 key after-business를 쓴다.
Act · 행동
  • withdraw/deposit·business_tx·ledger 두 행 뒤 failureHook.afterBusinessMutation에서 injected RuntimeException을 던지게 한다.
Assert · 확인
  • RuntimeException이며 message에 injected가 있다.
  • idempotency_request 0, TRANSFER business_tx 0, TRANSFER_ ledger 0이다.
직접 보장
  • 이 after-business RuntimeException fixture에서 직접 센 멱등·거래·이체 원장 효과가 모두 rollback된다.
보장하지 않음
  • 이 class의 balance 복원 직접 assertion
  • process hard-kill
  • checked exception rollback
  • 외부 시스템 effect
  • 모든 failure point

첫 실패 경계 @BeforeEach의 29줄 hook reset, 30줄 TRUNCATE, 31~32줄 계좌 개설 중 하나가 실패하면 test body 전에 가장 먼저 중단된다. 준비가 끝나면 41줄 point 지정 뒤 42줄 helper 안의 46~48줄 transfer 호출·예외 type·message 검사가 먼저이고, 통과하면 49줄 idempotency COUNT, 50~51줄 거래 COUNT, 52~53줄 원장 COUNT 순으로 첫 실패 지점이 이동한다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ 이 test가 잔액 10,000과 5,000을 직접 확인한다.

왜 틀리나 setup에는 값이 있지만 실패 뒤 balance SELECT/assertion이 없다.

바르게 읽기 세 count 0만 직접 assertion했다고 말한다.

반례 잔액이 잘못 바뀌어도 세 table count가 0이면 이 파일의 assertion은 잡지 못할 수 있다.

❌ 모든 예외에서 rollback을 증명했다.

왜 틀리나 hook은 RuntimeException 두 지점만 만든다.

바르게 읽기 예외 종류와 지점을 exact하게 붙인다.

반례 process kill이나 checked exception은 실행되지 않는다.

❌ business_tx count 0이면 어떤 거래도 없다.

왜 틀리나 WHERE tx_type='TRANSFER'만 센다.

바르게 읽기 OPENING 행과 TRANSFER 행을 구분한다.

반례 두 개설 transaction은 fixture에 남아 있어도 조건 밖이다.

잔액 빈칸히토리 → 니지카 → 료 → 키타
  1. 히토리

    from과 to 잔액도 다시 읽었지?

  2. 니지카

    이 stage source에는 실패 뒤 balance assertion이 없다.

  3. setup 값이 곧 검증값은 아니야.

  4. 키타

    직접 본 것만 보장하겠습니다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

잔액

실패 뒤 두 account balance 불변

이 책임을 맡는 곳: explicit balance assertions or reconciliation
예외 범위

모든 exception/failure point rollback

이 책임을 맡는 곳: additional parameterized failure injection
외부 효과

audit/message/remote call까지 exactly once

이 책임을 맡는 곳: outbox and external-effect tests
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • 두 enum point와 두 test method를 연결한다.
  • 공유 helper의 예외 assertion과 세 count query를 다시 쓴다.
  • 직접 확인한 0과 확인하지 않은 balance를 분리해 말한다.

2단계 · 코드 조각 재조립

  1. 두 고장 지점
  2. 공유 rollback 검사
  3. 세 table 0

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

정본을 닫고 71줄 전체를 다시 쓴 뒤 mapping 48행, translation 62행, @Test 2개를 대조한다.

자가 점검
  • 비공백 translation과 package/import 제외 mapping line 번호가 source 순서와 맞는지 본다.
  • 각 mapping의 실제 뜻·입력·직후 상태·한계가 서로 다른 역할인지 읽는다.
  • 고정 숫자와 timeout, key, balance, row count를 stage source에 다시 대조한다.
  • source-local @Test와 W14 smoke selected test를 섞지 않는다.
test bean히토리 → 니지카 → 료 → 키타
  1. 히토리

    ControlledFailureHook은 운영 bean이야?

  2. 니지카

    @TestConfiguration이 test context에 넣는 주입 장치다.

  3. 실패 재현을 위한 도구와 운영 기능을 구분해.

  4. 키타

    test 전용 배선이라고 표시할게요.

13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w12/day-4/TransferFailurePointIT.javaSHA-256 02b4b98c21c32ccf65c70caa99067faba04cc21c10afb9680fce72d346bad981
TransferFailurePointIT.java — 두 RuntimeException 지점의 행 효과 rollback 전체
package com.example.financialcore.transfer;

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.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired TransferService transfers;
    @Autowired ControlledFailureHook hook;
    Account from;
    Account to;

    @BeforeEach void clean() {
        hook.point = ControlledFailureHook.Point.NONE;
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "ROLL-FROM", 10_000);
        to = openings.open("customer-2", "ROLL-TO", 5_000);
    }

    @Test void afterClaimRuntimeExceptionRollsBackClaim() {
        hook.point = ControlledFailureHook.Point.AFTER_CLAIM;
        assertFailureAndNoEffect("after-claim");
    }

    @Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() {
        hook.point = ControlledFailureHook.Point.AFTER_BUSINESS;
        assertFailureAndNoEffect("after-business");
    }

    private void assertFailureAndNoEffect(String key) {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", key, from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessageContaining("injected");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); }
    }

    static final class ControlledFailureHook implements TransferFailureHook {
        enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS }
        volatile Point point = Point.NONE;
        @Override public void afterClaim() {
            if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
        }
        @Override public void afterBusinessMutation() {
            if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business");
        }
    }
}
06

TransferIntegrationTest.java — exact amount-conflict selector와 넓은 누적 test 원문

reference/w12/day-5/TransferIntegrationTest.java

누적 Java 테스트 참고본 · 정본 · W14-F06
227줄 연결252줄 번역17 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

W14 smoke는 이 294줄 class 전체가 아니라 같은 key에서 amount만 바뀐 한 메서드를 exact selector로 고른다. 첫 요청 효과는 한 번 남고 두 번째 요청은 IDEMPOTENCY_CONFLICT여야 한다.

  1. 파일의 @Test 9개 중 smoke가 실제 실행하는 것은 몇 개일까?
  2. 1,000원 첫 요청과 2,000원 변경 요청 뒤 어떤 값이 남을까?
  3. source에 same-key20 test가 있다는 사실만으로 W14 smoke가 그것을 실행할까?
이 파일에서 끝까지 다시 쓰는 값local @Test=9selected @Test=1key=conflict-keyfirst amount=1000changed amount=2000balances=9000/11000transfer tx=1transfer ledger=2idempotency claim=1
02

STEP 02 / 13

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

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

STARRY 계산대가 `conflict-key`로 1,000원 표를 먼저 처리한 뒤 같은 번호의 2,000원 표를 받는다.

같은 접수번호에 금액만 바꾼 두 번째 표

첫 1,000원 이체는 실행되어 잔액이 9,000과 11,000이 된다. 같은 key의 2,000원 요청은 같은 요청의 재전송이 아니므로 conflict로 거절한다.

DB에는 첫 transfer 한 건, 원장 두 줄, 멱등 claim 한 건만 남아야 한다. 이 exact method 밖의 여덟 test는 파일에 있어도 smoke 실행 범위가 아니다.

딱 여기까지만 접수번호 비유는 amount 변경 conflict 한 메서드를 설명하며 changed-from/to·동시 20요청·rollback test까지 이번 smoke가 실행했다고 뜻하지 않는다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

class와 method selector

runner가 class명 뒤에 exact method명을 붙여 이 한 test만 고른다.

코드 연결
선택 대상 L175-L193
비유
두꺼운 시험지에서 문제 번호 하나만 지정
비유의 끝
같은 class의 나머지 @Test는 자동 포함되지 않는다.

첫 효과 한 번

1,000원 command가 먼저 성공해 잔액과 세 종류 row count를 만든다.

코드 연결
L177-L180, L185-L192
비유
첫 주문표만 계산대에 반영
비유의 끝
응답 body replay나 HTTP status는 이 method에 없다.

바뀐 amount conflict

같은 key에서 amount 2,000은 semantic request가 달라 BusinessException code를 확인한다.

코드 연결
L178-L183
비유
같은 번호에 다른 금액이면 빨간 충돌표
비유의 끝
다른 actor·operation scope는 이 fixture가 바꾸지 않는다.
아홉 test와 한 selector히토리 → 니지카 → 료 → 키타
  1. 히토리

    파일에 @Test가 아홉인데 왜 한 개만 센다고 해?

  2. 니지카

    runner가 class 뒤에 exact method 이름을 붙였기 때문이야.

  3. source inventory와 Gradle selection은 다른 숫자다.

  4. 키타

    9 local, 1 selected로 적겠습니다.

failure hook config히토리 → 니지카 → 료 → 키타
  1. 히토리

    이 class의 test bean도 선택 method에서 꼭 실패를 내?

  2. 니지카

    selected method는 failureHook을 켜지 않는다.

  3. 배선이 존재하는 것과 그 branch가 실행되는 것을 구분해.

  4. 키타

    호출된 경로만 설명할게요.

반대 방향 test히토리 → 니지카 → 료 → 키타
  1. 히토리

    이 class의 반대 방향 20개가 SortedLock test를 대신해?

  2. 니지카

    별도 메서드이고 이번 exact selector 밖이다.

  3. F04의 class selector와 F06의 method selector를 구분해.

  4. 키타

    같은 숫자라도 owner를 붙이겠습니다.

금액 conflict히토리 → 니지카 → 료 → 키타
  1. 히토리

    첫 1,000원 뒤 같은 key로 2,000원을 보내면 replay야?

  2. 니지카

    의미가 달라 IDEMPOTENCY_CONFLICT다.

  3. 같은 key는 같은 semantic request일 때만 replay 후보야.

  4. 키타

    key와 payload를 함께 비교하겠습니다.

semanticLong regex히토리 → 니지카 → 료 → 키타
  1. 히토리

    JSON parser helper가 conflict amount를 읽어?

  2. 니지카

    selected method는 Command를 직접 만들어 helper를 거치지 않는다.

  3. 옆 test용 helper를 현재 경로에 끼워 넣지 마.

  4. 키타

    실행하지 않은 helper라고 적겠습니다.

한 효과 helper히토리 → 니지카 → 료 → 키타
  1. 히토리

    assertOneTransferEffect가 selected method의 세 count를 대신하지?

  2. 니지카

    selected method는 같은 세 count를 본문에 직접 쓴다.

  3. helper 호출 여부도 source에서 확인해야 해.

  4. 키타

    직접 assertion과 helper를 구분할게요.

04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.누적 Java 테스트 참고본에서 package/import를 뺀 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.227 / 227 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
31줄F06-L31 @SpringBootTest Spring Boot 시험 context를 요청하는 class 표찰을 건다. Spring TestContext bootstrap을 요구하는 type-level marker가 annotation table에 기록된다.
입력
Spring test annotation scanner가 이 marker를 만나면
결과·효과
이어질 type declaration에 적용할 Spring Boot test metadata가 pending record로 남는다.
비유의 한계
annotation 자체는 type 이름·bean 목록·database 연결을 시작하지 않는다.
32줄F06-L32 @Import(TransferIntegrationTest.FailureHookConfiguration.class) claim 뒤와 돈 이동 뒤에 누를 시험용 고장 버튼 상자를 context 안으로 들여온다. 시험에서만 쓸 failure hook 설정 class를 Spring context에 추가한다.
입력
기본 production bean 대신 ControlledFailureHook이 필요할 때
결과·효과
FailureHookConfiguration이 이 integration test context의 추가 configuration source가 된다.
비유의 한계
Import는 production 환경에 고장 bean을 배포하지 않는다.
33줄F06-L33 class TransferIntegrationTest extends PostgresIntegrationTestSupport { PostgreSQL 금고를 연결한 아홉 장짜리 이체 검수실의 바깥 벽을 세운다. PostgreSQL 시험 지원을 상속한 `TransferIntegrationTest` class 본문을 연다.
입력
Spring과 Postgres test support가 준비된 뒤
결과·효과
PostgresIntegrationTestSupport를 상속한 TransferIntegrationTest type scope가 열린다.
비유의 한계
class 선언은 HTTP controller를 시험하지 않는다.
35줄F06-L35 @TestConfiguration 운영 설정과 구분할 시험 전용 type 표식을 대기대에 둔다. Spring Test가 이어질 type을 test-only configuration source로 분류할 metadata다.
입력
Spring scanner가 test-configuration annotation line을 읽으면
결과·효과
이어질 type declaration에 붙을 test-only configuration metadata가 기록된다.
비유의 한계
이 line은 type name·member count·bean instance를 아직 정하지 않는다.
36줄F06-L36 static class FailureHookConfiguration { 고장 갈고리 하나만 조립할 작은 시험 장비실의 문을 연다. 고장을 넣을 bean만 제공하는 시험 전용 중첩 설정 class를 연다.
입력
FailureHookConfiguration을 context에 import한 뒤
결과·효과
failure-hook bean factory를 담을 static FailureHookConfiguration scope가 열린다.
비유의 한계
설정 class 자체는 실패를 던지지 않는다.
37줄F06-L37 @Bean Spring이 수집할 factory method 앞에 bean 생산 표식을 놓는다. 바로 이어질 factory method를 Spring bean producer로 등록할 annotation marker다.
입력
configuration annotation scanner가 Bean marker를 만나면
결과·효과
다음 method declaration을 bean producer로 분류할 metadata가 pending 상태가 된다.
비유의 한계
이 annotation은 method 이름·return type·생성 instance를 선취하지 않는다.
38줄F06-L38 ControlledFailureHook controlledFailureHook() { 새 고장 갈고리를 만들어 내보낼 조립대의 입구를 연다. 새 `ControlledFailureHook` bean을 만들어 돌려주는 설정 메서드를 연다.
입력
Spring이 controlledFailureHook factory를 호출하면
결과·효과
ControlledFailureHook을 반환할 bean-factory call frame이 열려 body 실행을 받을 수 있다.
비유의 한계
factory 선언만으로 service를 실행하지 않는다.
39줄F06-L39 return new ControlledFailureHook(); 아직 어느 곳에서도 끊어지지 않는 새 갈고리 한 개를 조립해 건넨다. 아직 고장 지점이 NONE인 시험용 hook 객체를 새로 만들어 반환한다.
입력
failure point의 초기값이 NONE이어야 할 때
결과·효과
새 ControlledFailureHook instance가 생성되어 이 factory method의 반환값으로 정해진다.
비유의 한계
객체 생성은 AFTER_CLAIM이나 AFTER_BUSINESS를 선택하지 않는다.
40줄F06-L40 } 시험용 갈고리 한 개를 내보낸 뒤 `controlledFailureHook` 조립대의 작은 덮개를 닫는다. `}`는 `controlledFailureHook` @Bean factory 메서드의 본문을 닫는다.
입력
@Bean factory가 새 ControlledFailureHook을 반환하면
결과·효과
controlledFailureHook factory scope가 닫혀 bean definition 하나가 완성된다.
비유의 한계
이 괄호는 바깥 FailureHookConfiguration class까지 닫지 않는다.
41줄F06-L41 } 갈고리 조립대를 품은 `FailureHookConfiguration` 시험 장비실의 바깥 문을 닫는다. `}`는 시험 bean을 선언한 `FailureHookConfiguration` 중첩 class를 닫는다.
입력
설정 class의 유일한 @Bean 메서드가 끝나면
결과·효과
FailureHookConfiguration scope가 닫혀 test context가 읽을 설정 class가 완결된다.
비유의 한계
이 문은 이어지는 ControlledFailureHook 구현 class의 문이 아니다.
43줄F06-L43 static final class ControlledFailureHook implements TransferFailureHook { 어느 고장 버튼을 눌렀는지 기억하고 두 지점에서 확인할 갈고리 몸체를 만든다. 실패 지점을 골라 RuntimeException을 던질 시험용 hook class를 연다.
입력
TransferService가 TransferFailureHook interface를 필요로 할 때
결과·효과
TransferFailureHook을 구현하는 ControlledFailureHook nested type scope가 열린다.
비유의 한계
test hook은 process crash나 외부 시스템 장애가 아니다.
44줄F06-L44 enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS_MUTATION } 고장 없음·claim 뒤·업무 변경 뒤 세 칸을 가진 Point 선택 다이얼을 한 줄에서 완성한다. 고장 없음·claim 뒤·업무 변경 뒤의 세 값을 가진 `Point` enum 선언을 한 줄에서 완성한다.
입력
시험이 주입할 실패 시점을 표현해야 할 때
결과·효과
NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION 세 failure state가 enum 상수로 고정된다.
비유의 한계
enum 선언은 세 값만 고정하며 재시도나 복구 정책을 만들지 않는다.
46줄F06-L46 private volatile Point point = Point.NONE; 여러 worker가 볼 수 있는 다이얼 바늘을 처음 NONE 눈금에 놓는다. 여러 thread가 읽을 현재 실패 지점을 처음에는 NONE으로 두고 volatile로 보이게 한다.
입력
동시 test 호출에서 현재 point를 읽을 수 있어야 할 때
결과·효과
여러 thread가 볼 volatile point field가 생성되고 최초 값은 NONE이다.
비유의 한계
volatile은 복합 업무 원자성이나 DB lock을 만들지 않는다.
48줄F06-L48 void failAt(Point point) { this.point = point; } 시험 손잡이를 돌려 전달받은 실패 지점에 다이얼을 놓고 `failAt` 손잡이 덮개까지 한 번에 닫는다. 한 줄짜리 `failAt` 메서드는 입력받은 Point를 volatile `point` field에 대입하고 그 메서드까지 닫는다.
입력
시험이 AFTER_CLAIM 또는 AFTER_BUSINESS_MUTATION Point를 건네면
결과·효과
failAt 호출이 끝나면 volatile field가 caller가 넘긴 Point를 보관한다.
비유의 한계
이 한 줄은 예외를 던지지 않고 volatile field 값만 바꾼다.
49줄F06-L49 void reset() { this.point = Point.NONE; } 다음 시험을 위해 다이얼을 NONE으로 되감고 `reset` 손잡이 덮개까지 같은 줄에서 닫는다. 한 줄짜리 `reset` 메서드는 `point`를 `NONE`으로 되돌리고 그 메서드까지 닫는다.
입력
@BeforeEach가 이전 시험의 failure point를 지워야 할 때
결과·효과
reset 호출 직후 point는 다시 NONE으로 돌아간다.
비유의 한계
DB 행이나 계좌 field는 이 한 줄이 초기화하지 않는다.
51줄F06-L51 @Override 첫 interface 구현 method 앞에 override 검인표를 놓는다. 첫 standalone `@Override` method-contract metadata를 선언한다.
입력
compiler annotation 처리기가 첫 Override marker를 읽으면
결과·효과
이어질 method signature가 상위 contract와 맞는지 검사할 metadata가 기록된다.
비유의 한계
marker는 method 이름·callback 시점·body 조건을 아직 말하지 않는다.
52줄F06-L52 public void afterClaim() { claim 직후 다이얼을 읽는 첫 경보기의 덮개를 연다. public void afterClaim callback의 declaration과 empty-at-entry body frame을 연다.
입력
멱등 owner claim이 만들어진 직후
결과·효과
claim 직후 호출될 public callback scope가 열린다.
비유의 한계
메서드 입구는 account나 ledger를 건드리지 않는다.
53줄F06-L53 if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim"); 다이얼이 AFTER_CLAIM이면 ‘claim 뒤 고장’ 비상벨을 즉시 울린다. 현재 지점이 AFTER_CLAIM이면 정해진 message의 RuntimeException을 던진다.
입력
현재 volatile point가 AFTER_CLAIM과 같을 때
결과·효과
현재 point가 AFTER_CLAIM이면 exact RuntimeException이 던져지고 아니면 통과한다.
비유의 한계
다른 point에서는 이 한 줄이 예외를 던지지 않는다.
54줄F06-L54 } afterClaim callback의 현재 method brace를 닫는다. 이 `}`가 `afterClaim` method body scope를 종료한다.
입력
afterClaim body의 conditional statement가 끝난 뒤
결과·효과
afterClaim call frame이 닫혀 control이 caller로 복귀한다.
비유의 한계
이 closing brace는 이어질 다른 member declaration을 이름 붙이거나 실행하지 않는다.
56줄F06-L56 @Override 둘째 구현 method 자리에도 별도의 override 확인 도장을 둔다. 두 번째 standalone `@Override` contract marker를 선언한다.
입력
첫 override method scope가 닫힌 뒤 compiler가 다음 marker를 만나면
결과·효과
바로 이어질 method declaration용 override-consistency check가 예약된다.
비유의 한계
annotation 한 줄은 다음 signature나 body의 동작을 선취하지 않는다.
57줄F06-L57 public void afterBusinessMutation() { 업무 변경 직후 다이얼을 읽는 둘째 경보기의 덮개를 연다. afterBusinessMutation public callback signature를 구현하고 execution scope를 시작한다.
입력
계좌 잔액과 거래·원장이 변경된 직후
결과·효과
business mutation 뒤 진입할 public callback scope가 열린다.
비유의 한계
메서드 입구만으로 commit 여부는 정해지지 않는다.
58줄F06-L58 if (point == Point.AFTER_BUSINESS_MUTATION) { 다이얼이 AFTER_BUSINESS_MUTATION 눈금인지 확인하는 안전 핀을 건다. 현재 지점이 AFTER_BUSINESS_MUTATION인지 검사하는 실패 분기를 연다.
입력
현재 failure point가 업무 변경 뒤로 설정됐을 때
결과·효과
point가 AFTER_BUSINESS_MUTATION인 경로만 들어갈 conditional block이 열린다.
비유의 한계
조건이 false면 정상 service 흐름을 방해하지 않는다.
59줄F06-L59 throw new RuntimeException("injected after business mutation"); 핀 조건이 맞으면 ‘업무 변경 뒤 고장’ 비상벨을 울린다. `injected after business mutation`라는 시험용 RuntimeException을 던진다.
입력
AFTER_BUSINESS_MUTATION 분기 안에 들어오면
결과·효과
선택된 business-mutation 고장 경로에서 exact RuntimeException이 즉시 전파된다.
비유의 한계
RuntimeException은 외부 결제망 실패를 흉내 내지 않는다.
60줄F06-L60 } 업무 변경 뒤 고장 조건이 참일 때만 들어가는 `if` 칸막이를 닫는다. `}`는 point가 `AFTER_BUSINESS_MUTATION`일 때 예외를 던지는 `if` block을 닫는다.
입력
AFTER_BUSINESS_MUTATION 분기에서 예외를 던진 뒤
결과·효과
conditional scope가 닫혀 point 불일치 경로는 예외 없이 이어진다.
비유의 한계
이 괄호 하나는 바깥 afterBusinessMutation 메서드를 닫지 않는다.
61줄F06-L61 } 고장 분기 칸막이까지 확인한 뒤 `afterBusinessMutation` 경보기 전체를 닫는다. `}`는 업무 변경 직후 실패를 검사하는 `afterBusinessMutation` 메서드를 닫는다.
입력
업무 변경 직후 point 검사가 끝나면
결과·효과
afterBusinessMutation method scope가 닫혀 callback 구현이 끝난다.
비유의 한계
ControlledFailureHook class 자체는 다음 괄호까지 계속된다.
62줄F06-L62 } claim 뒤와 업무 변경 뒤 경보기를 담은 `ControlledFailureHook` 장비함을 잠근다. `}`는 두 failure point를 구현한 `ControlledFailureHook` 중첩 class를 닫는다.
입력
두 override 메서드 선언이 모두 끝나면
결과·효과
ControlledFailureHook nested type이 닫혀 point 제어와 두 callback 정의가 완결된다.
비유의 한계
바깥 TransferIntegrationTest class는 이 뒤 fixture와 시험을 더 가진다.
64줄F06-L64 @Autowired AccountRepository accounts; 시험실 첫 선반에 계좌 카드를 다시 읽을 저장소 열쇠를 둔다. Spring에서 `AccountRepository accounts`에 맞는 계좌 저장소 bean을 찾아 이 시험 필드에 주입한다.
입력
잔액 assertion에 AccountRepository가 필요할 때
결과·효과
accounts field가 balance 조회에 쓸 AccountRepository bean injection point가 된다.
비유의 한계
주입만으로 account query가 실행되지 않는다.
65줄F06-L65 @Autowired AccountOpeningService openings; 둘째 선반에는 매 시험 두 계좌를 열 개설 도구를 둔다. Spring에서 `AccountOpeningService openings`에 맞는 계좌 개설 도구 bean을 찾아 이 시험 필드에 주입한다.
입력
fixture가 A와 B account를 새로 만들어야 할 때
결과·효과
Spring injection이 AccountOpeningService bean reference를 openings member에 기록한다.
비유의 한계
개설 도구 주입은 잔액을 아직 만들지 않는다.
66줄F06-L66 @Autowired LedgerEntryRepository ledger; 셋째 선반에는 전체 원장 행을 셀 장부 보관함 열쇠를 둔다. Spring에서 `LedgerEntryRepository ledger`에 맞는 원장 저장소 bean을 찾아 이 시험 필드에 주입한다.
입력
opening과 transfer ledger count를 확인할 때
결과·효과
ledger field가 전체 원장 행 수를 읽을 LedgerEntryRepository bean을 받는다.
비유의 한계
repository 주입이 signed sum을 계산하지 않는다.
67줄F06-L67 @Autowired TransferService transfers; 넷째 선반에는 실제 hash·claim·돈 이동을 맡길 이체 담당자를 세운다. Spring에서 `TransferService transfers`에 맞는 이체 업무 서비스 bean을 찾아 이 시험 필드에 주입한다.
입력
각 test가 TransferService를 호출해야 할 때
결과·효과
transfers field가 각 scenario가 호출할 실제 TransferService bean을 가리킨다.
비유의 한계
service 주입은 어떤 command도 실행하지 않는다.
68줄F06-L68 @Autowired JdbcClient jdbc; 다섯째 선반에는 세 업무 표를 exact SQL로 셀 조회기를 둔다. Spring에서 `JdbcClient jdbc`에 맞는 DB 조회 도구 bean을 찾아 이 시험 필드에 주입한다.
입력
count와 signed sum을 PostgreSQL에서 읽어야 할 때
결과·효과
jdbc field가 raw COUNT·SUM query를 보낼 JdbcClient bean을 받는다.
비유의 한계
JdbcClient는 service transaction을 대신하지 않는다.
69줄F06-L69 @Autowired ControlledFailureHook failureHook; 마지막 선반에는 시험 중 고장을 고를 controlled hook 손잡이를 둔다. Spring에서 `ControlledFailureHook failureHook`에 맞는 고장 주입 갈고리 bean을 찾아 이 시험 필드에 주입한다.
입력
두 rollback test가 failure point를 설정할 때
결과·효과
failureHook field가 imported configuration의 ControlledFailureHook instance와 연결된다.
비유의 한계
hook 주입은 기본적으로 point NONE이다.
71줄F06-L71 private Account from; from이라는 private Account reference 칸 하나를 type에 만든다. 초기화 expression 없는 `private Account from` instance field를 선언한다.
입력
class member declaration 영역이 Account field 한 개를 받을 때
결과·효과
from field가 생기고 explicit assignment 전 reference 기본값은 null이다.
비유의 한계
field declaration만으로 Account object·database row·fixture value는 생기지 않는다.
72줄F06-L72 private Account to; to라는 둘째 private Account reference 칸을 별도로 만든다. initializer가 없는 `private Account to` instance field를 선언한다.
입력
바로 앞 from field와 나란히 또 하나의 Account slot이 필요할 때
결과·효과
to field가 선언되고 explicit assignment 전에는 null reference를 가진다.
비유의 한계
이 line은 Account 생성·field 대입·두 reference의 관계를 정하지 않는다.
74줄F06-L74 @BeforeEach 각 test 직전에 호출할 lifecycle slot을 알리는 표식을 단다. standalone `@BeforeEach` per-test callback metadata를 선언한다.
입력
JUnit scanner가 field declarations 뒤 lifecycle marker에 도달하면
결과·효과
이어질 method declaration을 before-each callback으로 분류할 record가 생긴다.
비유의 한계
이 annotation은 callback 이름·fixture 값·body statement를 실행하지 않는다.
75줄F06-L75 void setUp() { 네 장부를 비우고 두 금고 카드를 새로 꽂을 준비실 문을 연다. 반환값 없는 setUp lifecycle method의 body가 이 declaration에서 열린다.
입력
각 시험을 독립된 DB 상태에서 시작하려 할 때
결과·효과
공통 fixture를 만들 setUp call frame이 열린다.
비유의 한계
setup 순서가 test 실행 순서를 보장하지 않는다.
76줄F06-L76 jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE").update(); 이전 시험의 네 표를 파쇄하고 identity 번호표도 처음으로 되감는다. 멱등 요청·원장·업무 거래·계좌 표를 비우고 identity까지 다시 시작한다.
입력
멱등·원장·거래·계좌 흔적이 남아 있을 수 있을 때
결과·효과
멱등·원장·거래·계좌 table 행이 삭제되고 identity sequence도 재시작한다.
비유의 한계
TRUNCATE는 운영 기능이나 retry 정책이 아니다.
77줄F06-L77 failureHook.reset(); 이전 시험이 눌렀던 고장 버튼을 NONE 위치로 돌려놓는다. 이전 시험이 골랐던 고장 지점을 NONE으로 초기화한다.
입력
새 @Test가 정상 hook 상태에서 시작해야 할 때
결과·효과
이전 scenario가 선택한 failure point가 NONE으로 초기화된다.
비유의 한계
reset은 database cleanup을 대신하지 않는다.
78줄F06-L78 from = openings.open("customer-1", "A", 10_000); customer-1의 A 금고 카드에 10,000원 눈금을 찍어 출발 꽂이에 넣는다. 계좌 열기 service를 불러 `from`라는 출발 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
입력
깨끗한 account 표에서 source fixture를 만들 때
결과·효과
customer-1의 A 계좌가 10,000으로 열리고 반환 Account가 from에 저장된다.
비유의 한계
이 opening 행은 transfer 업무 효과가 아니다.
79줄F06-L79 to = openings.open("customer-1", "B", 10_000); 두 번째 10,000 fixture를 B 표찰과 함께 destination 보관함에 넣는다. B fixture creation이 돌려준 entity reference를 destination field `to`에 대입한다.
입력
출발 계좌 다음으로 destination fixture가 필요할 때
결과·효과
B account-opening call의 반환 Account reference가 to field에 대입된다.
비유의 한계
두 opening ledger 행을 transfer ledger로 세지 않는다.
80줄F06-L80 } 장부 청소·다이얼 reset·A/B 계좌 준비를 마친 `setUp` 준비실 문을 닫는다. `}`는 DB를 비우고 hook과 두 계좌를 준비하는 `setUp` @BeforeEach 메서드를 닫는다.
입력
각 시험 직전의 두 Account field가 새 행을 가리키면
결과·효과
setUp scope가 닫혀 깨끗한 DB·NONE hook·두 10,000 계좌가 test에 전달된다.
비유의 한계
이 괄호는 첫 @Test를 실행하거나 닫지 않는다.
82줄F06-L82 @Test 첫 실행 후보 앞에 JUnit 검수 인장 하나를 놓는다. source에서 첫 번째 standalone `@Test` classification metadata를 선언한다.
입력
fixture method scope가 닫힌 뒤 JUnit scanner가 첫 Test marker를 읽으면
결과·효과
첫 Test metadata가 이어질 method declaration 하나와 결합하도록 대기한다.
비유의 한계
marker 자체는 다음 method 이름·scenario·statement를 선취하거나 실행하지 않는다.
83줄F06-L83 void transfer_and_same_key_replay_once() { transfer_and_same_key_replay_once라는 첫 test method의 빈 기록지를 펼친다. 해당 이름의 void test method declaration과 body scope를 시작한다.
입력
setUp closing brace와 첫 @Test record 뒤에 이 named method declaration이 나타나면
결과·효과
첫 test call frame이 열렸고 body statement는 아직 하나도 실행되지 않았다.
비유의 한계
method opener는 뒤 local variable·service result·assertion을 선취하지 않는다.
84줄F06-L84 var command = new TransferService.Command("customer-1", "key-1", from.getId(), to.getId(), 3_000); 첫 replay 시험용 신청표에 손님·같은 열쇠·두 금고·3,000원을 한 줄로 적는다. actor·key·두 계좌 ID·3,000을 Command로 묶어 `command` 변수에 저장한다.
입력
fixture의 from·to ID와 actor·key·amount가 준비되면
결과·효과
key-1·from/to id·3,000을 가진 immutable Command가 command 변수에 저장된다.
비유의 한계
여기서는 Command만 만들며 service 호출과 Result 생성은 하지 않는다.
85줄F06-L85 var first = transfers.transfer(command); 현재 command를 service에 건네 받은 첫 Result를 first 칸에 둔다. `transfers.transfer(command)` 반환값을 local `first`에 저장한다.
입력
직전 statement에서 완성한 command reference를 service call에 넘기면
결과·효과
첫 service call의 반환 Result reference가 first variable에 binding된다.
비유의 한계
이 assignment는 다른 service invocation이나 뒤 assertion을 실행하지 않는다.
86줄F06-L86 var replay = transfers.transfer(command); 같은 3,000원 신청표를 다시 접수해 저장 결과 영수증을 `replay` 칸에 둔다. 실제 `TransferService.transfer`를 호출하고 반환된 Result를 `replay` 변수에 저장한다.
입력
first Result를 받은 뒤 같은 command를 두 번째로 호출할 때
결과·효과
같은 command를 받은 둘째 service call의 반환 Result가 replay 변수에 저장된다.
비유의 한계
이 줄에서 replay Result가 생겨 두 Result가 준비되지만 flag·잔액·원장은 아직 검사하지 않는다.
88줄F06-L88 assertThat(first.replayed()).isFalse(); 첫 영수증의 replay 칸에 새 처리임을 뜻하는 빈 도장이 찍혔는지 본다. `first.replayed()` 값이 false인지 확인한다.
입력
first Result가 이미 local에 저장된 상태에서 its replay flag accessor를 평가하면
결과·효과
first.replayed actual 값이 false와 일치함이 확인된다.
비유의 한계
first.replayed false 한 값은 replay Result의 flag나 account state를 판정하지 않는다.
89줄F06-L89 assertThat(replay.replayed()).isTrue(); 같은 신청표로 받은 둘째 영수증에 재발급 도장이 찍혔는지 본다. `replay.replayed()` 값이 true인지 확인한다.
입력
second Result reference의 replayed boolean을 true matcher에 넣을 때
결과·효과
replay.replayed actual 값이 true와 일치함이 확인된다.
비유의 한계
boolean replay flag 한 값만 판정하며 다른 state accessor는 이 statement 범위 밖이다.
90줄F06-L90 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(7_000); 3,000원을 보낸 뒤 출발 금고 눈금이 10,000에서 7,000으로 내려갔는지 읽는다. repository에서 from Account를 찾아 balance actual을 7,000 expected와 비교한다.
입력
두 replay flags 확인을 시작하기 전 from account persisted value를 assertion chain이 읽으면
결과·효과
출발 계좌의 persisted balance가 7,000임이 직접 확인된다.
비유의 한계
from balance 7,000 equality는 to account와 idempotency record를 직접 읽지 않는다.
91줄F06-L91 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(13_000); 3,000원을 받은 뒤 도착 금고 눈금이 10,000에서 13,000으로 올랐는지 읽는다. to identifier로 destination record를 load한 뒤 balance가 13,000인지 단언한다.
입력
source balance assertion 다음 destination Account의 current balance를 repository에서 얻으면
결과·효과
도착 계좌의 persisted balance가 13,000임이 직접 확인된다.
비유의 한계
destination balance equality는 이 Account의 단일 numeric snapshot 밖의 aggregate를 계산하지 않는다.
92줄F06-L92 assertThat(ledger.count()).isEqualTo(4); 계좌 개설 쪽지 둘과 실제 이체 쪽지 둘을 합쳐 원장에 네 장만 있는지 센다. sequential replay scenario의 ledger repository total을 4와 맞춘다.
입력
첫 실행·same-key replay 시험에서 실제 opening을 포함한 원장 전체 장수 값을 얻은 뒤
결과·효과
opening 두 줄과 transfer 두 줄을 합친 ledger 전체 count가 4로 확정된다.
비유의 한계
repository가 돌려준 total cardinality만 보며 개별 persisted record payload는 검사하지 않는다.
93줄F06-L93 assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") TRANSFER 원장의 signed 합계를 읽을 SQL 측정틀을 펼친다. TRANSFER `signed_amount` SUM SQL expression을 AssertJ chain의 actual로 시작한다.
입력
앞선 replay·balance·ledger assertions가 끝난 상태에서
결과·효과
signed_amount SUM assertion은 열렸지만 terminal scalar와 expected comparison은 아직 없다.
비유의 한계
continuation이 닫히기 전에는 actual 값이나 expected 값 비교가 성립하지 않는다.
94줄F06-L94 .query(Long.class).single()).isZero(); TRANSFER signed_amount SUM의 단일 long 결과를 0 눈금과 맞댄다. 원장표 query의 단일 long 결과가 0인지 확인한다.
입력
바로 앞 SUM query가 long 값 하나를 돌려주면
결과·효과
SUM query의 single long 값이 zero와 일치해 transfer signed 합 보존이 확인된다.
비유의 한계
합계0만 확인하며 각 원장 행의 모든 column 값까지 비교하지 않는다.
95줄F06-L95 } 첫 처리와 같은-key 재발급 검사를 적은 첫 시험 기록지를 덮는다. `}`는 첫 실행과 같은-key replay를 확인하는 `transfer_and_same_key_replay_once` 시험 메서드를 닫는다.
입력
signed_amount 합계가 0인지 보는 마지막 assertion까지 끝나면
결과·효과
순차 replay test scope가 닫혀 여섯 assertion의 실행 결과가 JUnit에 돌아간다.
비유의 한계
이 closing brace는 현재 JUnit method scope만 닫고 이후 member declaration을 규정하지 않는다.
97줄F06-L97 @Test 둘째 JUnit 실행표 자리에 새 Test 스티커를 붙인다. source의 두 번째 standalone `@Test` metadata record를 만든다.
입력
첫 test method scope가 닫힌 뒤 둘째 marker를 만나면
결과·효과
두 번째 Test metadata가 바로 이어질 declaration의 JUnit 분류를 기다린다.
비유의 한계
이 line에는 다음 method 이름·data representation·service call이 없다.
98줄F06-L98 void json_field_order_and_whitespace_variant_replays_without_extra_effect() { 정순서 JSON과 표현만 바꾼 JSON을 나란히 시험할 기록지를 펼친다. JSON 순서와 공백이 달라도 같은 뜻으로 보는 시험 본문을 시작한다.
입력
setUp이 DB와 from·to 계좌를 준비한 뒤 JUnit이 둘째 시험을 호출하면
결과·효과
json_field_order_and_whitespace_variant_replays_without_extra_effect method의 실행 scope가 열린다.
비유의 한계
메서드 본문을 여는 선언이므로 두 JSON과 Result는 아직 만들어지지 않았다.
99줄F06-L99 String firstJson = "{\"from\":" + from.getId() + ",\"to\":" + to.getId() + ",\"amount\":1000}"; from·to·amount 순서의 JSON text 한 장을 firstJson 칸에 조립한다. 현재 expression의 account IDs와 amount literal로 기준 JSON String을 만들고 `firstJson`에 저장한다.
입력
fixture가 이미 가진 from·to identifiers와 이 line의 문자열 fragments를 받으면
결과·효과
완성된 기준 JSON String reference가 firstJson local에 대입된다.
비유의 한계
이 statement는 다른 JSON local·Command·service Result를 만들지 않는다.
100줄F06-L100 String variantJson = "{ \"amount\" : 1000, \n \"to\" : " + to.getId() + ", \"from\" : " + from.getId() + " }"; 둘째 종이에는 amount→to→from 순서와 다른 공백으로 같은 세 숫자를 적는다. 같은 세 숫자를 amount·to·from 순서와 다른 공백으로 적은 둘째 JSON을 만든다.
입력
첫 JSON과 의미가 같고 글자 배치만 다른 반례가 필요할 때
결과·효과
같은 세 숫자를 다른 field 순서와 공백으로 쓴 문자열이 variantJson에 저장된다.
비유의 한계
두 JSON text만 준비한다. parse·service 호출·replay flag·DB count는 아직 보지 않는다.
102줄F06-L102 var first = transfers.transfer(commandFromJson("representation-key", firstJson)); 정순서 firstJson을 Command로 바꿔 service에 보내 첫 Result를 `first`에 둔다. `firstJson`을 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `first`에 저장한다.
입력
두 JSON 문자열을 만든 뒤 기준 표현을 먼저 실행할 때
결과·효과
firstJson에서 만든 representation-key Command의 첫 실행 Result가 first에 저장된다.
비유의 한계
first Result 하나만 생기며 variant replay 호출과 flag·DB assertion은 아직 남는다.
103줄F06-L103 var replay = transfers.transfer(commandFromJson("representation-key", variantJson)); 순서·공백을 바꾼 variantJson을 같은 key의 Command로 보내 `replay` Result를 받는다. `variantJson`을 같은 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `replay`에 저장한다.
입력
firstJson 호출이 끝난 뒤 의미가 같은 variantJson을 실행할 때
결과·효과
variantJson에서 만든 같은-key Command의 반환값이 replay에 저장된다.
비유의 한계
두 Result가 준비되지만 replay flag·잔액·세 row count는 뒤 assertion이 판정한다.
105줄F06-L105 assertThat(first.replayed()).isFalse(); 정순서 JSON으로 받은 첫 영수증이 새 처리라고 표시됐는지 확인한다. representation-key 첫 호출 Result가 replay가 아님을 false matcher로 확인한다.
입력
representation-key first call Result가 반환된 뒤 its replay marker를 false와 비교하면
결과·효과
기준 표현의 Result가 non-replay였음이 false assertion으로 확인된다.
비유의 한계
first Result의 non-replay flag만 확인하며 JSON parser의 문법 허용 범위를 일반화하지 않는다.
106줄F06-L106 assertThat(replay.replayed()).isTrue(); 순서와 공백을 바꾼 JSON 영수증이 재생 결과라고 표시됐는지 확인한다. field-order variant 호출에서 받은 replay flag가 true인지 검증한다.
입력
variant representation call이 준 Result에서 replayed accessor를 실행할 때
결과·효과
표현만 바꾼 둘째 Result가 replay였음이 true assertion으로 확인된다.
비유의 한계
variant call의 replay flag true는 duplicate JSON field 처리 규칙을 증명하지 않는다.
107줄F06-L107 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000); 표기만 다른 두 JSON 뒤 출발 금고가 한 번치 1,000원만 줄었는지 읽는다. JSON representation replay 뒤 source Account persisted balance를 9,000과 대조한다.
입력
두 JSON calls의 flags가 확인된 다음 source account balance query를 수행하면
결과·효과
표현 변형 replay 뒤 from balance가 9,000임이 DB에서 확인된다.
비유의 한계
representation scenario의 from balance 한 값은 to balance나 row counts를 대신하지 않는다.
108줄F06-L108 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000); variant representation 처리 뒤 destination 쪽 계기판이 11,000을 가리키는지 확인한다. variant JSON path의 destination balance가 11,000으로 남았는지 repository로 읽는다.
입력
representation scenario의 from value 확인 뒤 destination persisted amount를 읽으면
결과·효과
같은 scenario의 to balance가 11,000임이 DB에서 확인된다.
비유의 한계
to balance 11,000 assertion은 임의 JSON whitespace·number format 전체를 검증하지 않는다.
109줄F06-L109 assertOneTransferEffect(); 표현만 다른 재요청 뒤에도 거래1·원장2·claim1인지 세 표 검수함을 지금 연다. 업무 거래1·TRANSFER 원장2·멱등 요청1을 묶어 확인하는 helper를 부른다.
입력
first/replay flag와 두 계좌 잔액 assertion을 마친 뒤
결과·효과
assertOneTransferEffect 호출이 내부 persistence assertions를 마쳐야 정상 반환한다.
비유의 한계
helper는 DB의 세 count만 확인하며 범용 JSON 문법이나 HTTP 응답은 검사하지 않는다.
110줄F06-L110 } 정순서 JSON과 순서·공백 변형 JSON을 비교한 둘째 시험 기록지를 덮는다. `}`는 JSON field 순서·공백 변형도 replay되는지 확인하는 `json_field_order_and_whitespace_variant_replays_without_extra_effect` 시험 메서드를 닫는다.
입력
replay flag·두 잔액·단일 effect 검사가 모두 끝나면
결과·효과
JSON 표현 replay test scope가 닫혀 해당 fixture의 결과가 JUnit으로 돌아간다.
비유의 한계
이 메서드는 일반 JSON parser의 모든 문법을 닫아 정의하지 않는다.
112줄F06-L112 @Test 세 번째 검수 칸에 JUnit 수집 도장만 먼저 찍는다. 세 번째 standalone `@Test` method marker를 선언한다.
입력
둘째 test scope가 끝나고 scanner가 다음 Test annotation을 읽으면
결과·효과
셋째 source-local test metadata가 이어질 declaration과의 결합을 기다린다.
비유의 한계
annotation은 method identity·worker count·body resource를 선취하지 않는다.
113줄F06-L113 void same_key_concurrent_requests_change_business_once() throws Exception { same_key_concurrent_requests_change_business_once라는 빈 시험실 문을 연다. 해당 이름과 `throws Exception`을 가진 test method declaration·body scope를 시작한다.
입력
JSON-variant test closure 다음에 세 번째 annotated method signature를 compiler가 받으면
결과·효과
concurrency test call frame이 열리고 body program counter는 첫 statement 전 위치에 놓인다.
비유의 한계
method opener는 뒤 body의 숫자·latch·pool 구성을 선취하지 않는다.
114줄F06-L114 int n = 20; 동시에 신청할 worker의 자리표를 정확히 스무 칸으로 정한다. 동시에 보낼 같은-key service 요청 수를 20으로 고정한다.
입력
same-key 동시 시험에서 고정 fixture 크기를 먼저 정할 때
결과·효과
이 fixture에서 제출할 same-key worker 수가 정확히 20으로 고정된다.
비유의 한계
n 값 20을 저장한 사실만으로 worker coordination object나 execution resource는 생기지 않는다.
115줄F06-L115 var ready = new CountDownLatch(n); 스무 worker가 준비됐다고 알릴 때마다 하나씩 줄어드는 ready 자물쇠를 건다. worker n명이 모두 준비됐는지 셀 `ready` latch를 만든다.
입력
n=20이 정해진 뒤 모든 worker의 출발선 도착을 세려 할 때
결과·효과
20번의 준비 도착을 셀 ready latch가 count 20으로 생성된다.
비유의 한계
ready latch를 만들 뿐 worker를 제출하거나 출발시키지 않는다.
116줄F06-L116 var start = new CountDownLatch(1); 모인 worker를 한 번에 풀어 줄 count1 start 신호등을 세운다. 모든 worker를 한 번에 출발시킬 count1 `start` latch를 만든다.
입력
ready latch 다음으로 공통 출발 문이 필요할 때
결과·효과
공통 출발을 한 번 열 start latch가 count 1로 생성된다.
비유의 한계
start latch는 이 줄에서 생성될 뿐 countDown되지 않았다.
117줄F06-L117 var pool = Executors.newFixedThreadPool(n); 스무 Callable을 실행할 고정 크기 thread 작업대를 조립한다. same-key scenario가 n-sized fixed ExecutorService를 새로 생성한다.
입력
n=20 worker를 병렬로 받을 executor가 필요할 때
결과·효과
동시에 최대 20 Callable을 실행할 fixed thread pool이 생성된다.
비유의 한계
pool 생성만 하며 아직 Callable 제출이나 Future 생성은 없다.
118줄F06-L118 try { 현재 method 안에 guarded statement를 받을 try 울타리만 연다. `try {`가 새 try block scope를 시작한다.
입력
선행 local declarations가 끝나고 protected control region에 들어오면
결과·효과
빈 try body scope가 열렸으며 내부 statement는 아직 처리되지 않았다.
비유의 한계
try opener는 뒤 local variable·collection·cleanup clause를 선취하지 않는다.
119줄F06-L119 var command = new TransferService.Command( command 변수에 넣을 새 Command constructor의 빈 조립틀을 연다. `var command = new TransferService.Command(` allocation·assignment chain을 시작한다.
입력
현재 try scope가 첫 local assignment를 받을 때
결과·효과
Command constructor와 variable assignment가 열린 채 component arguments를 기다린다.
비유의 한계
arguments와 closing delimiter가 오기 전에는 object나 assignment가 완성되지 않는다.
120줄F06-L120 "customer-1", "burst-key", from.getId(), to.getId(), 1_000); 열린 Command constructor의 다섯 slots를 현재 line 값으로 채우고 command에 꽂는다. 이 continuation이 `TransferService.Command` construction과 `command` assignment를 닫는다.
입력
직전 constructor opener가 component arguments를 기다리는 상태에서
결과·효과
완성된 Command reference가 command local variable에 저장된다.
비유의 한계
이 constructor completion은 collection 생성이나 asynchronous submission을 수행하지 않는다.
121줄F06-L121 List<Future<TransferService.Result>> futures = new ArrayList<>(); Future<Result> reference를 담을 빈 ArrayList 바구니를 만든다. 새 빈 `ArrayList<Future<TransferService.Result>>`를 `futures`에 저장한다.
입력
현재 try scope가 collection local declaration을 받을 때
결과·효과
futures variable이 size 0인 새 list를 가리킨다.
비유의 한계
빈 list declaration은 element 생성·추가·task 실행을 포함하지 않는다.
122줄F06-L122 for (int i = 0; i < n; i++) { i=0부터 n-1까지 same-key worker 등록을 반복할 회전판을 연다. `i`가 0부터 `n-1`까지 변하는 for loop를 열어 same-key worker 등록을 반복한다.
입력
빈 futures 목록과 n=20이 준비된 뒤
결과·효과
i=0..19의 각 worker를 등록할 loop scope가 열린다.
비유의 한계
for 문을 열었을 뿐 현재 worker의 Callable 등록은 이어지는 줄에서 완성된다.
123줄F06-L123 futures.add(pool.submit(() -> { 현재 same-key worker의 submit 봉투와 lambda 행동표를 펼쳐 Future 바구니 연결을 시작한다. `futures.add(pool.submit(...))` 호출을 시작하고 same-key worker lambda 본문을 연다.
입력
same-key worker registration loop가 현재 lambda submission statement에 들어오면
결과·효과
현재 worker의 lambda·submit·futures.add 호출 chain이 열린다.
비유의 한계
lambda·submit·add 호출은 아직 열려 있고 Future 등록은 닫는 줄에서 완성된다.
124줄F06-L124 ready.countDown(); 현재 same-key worker가 출발선에 왔다고 ready 자물쇠 구슬 하나를 뺀다. same-key lambda가 ready latch count를 한 단계 감소시켜 도착을 알린다.
입력
제출된 Callable이 실제 실행되어 barrier 첫 단계에 도달하면
결과·효과
현재 worker 도착으로 ready count가 한 칸 줄어든다.
비유의 한계
countDown은 준비 사실만 알리며 start 신호를 열거나 service를 부르지 않는다.
125줄F06-L125 start.await(); ready를 알린 worker를 start 신호가 열릴 때까지 자기 자리에서 기다리게 한다. same-key worker thread가 start latch가 열릴 때까지 await한다.
입력
현재 Callable이 ready.countDown을 마친 뒤
결과·효과
현재 worker thread가 start count가 0이 될 때까지 대기한다.
비유의 한계
await는 이 worker를 대기시킬 뿐 다른 worker의 준비 완료를 보장하지 않는다.
126줄F06-L126 return transfers.transfer(command); start를 통과한 same-key worker가 공용 Command를 service에 넘겨 Result를 반환한다. shared command를 TransferService에 넘기고 해당 Result를 worker callable 반환값으로 낸다.
입력
현재 Callable이 ready를 알리고 start.await에서 풀려난 뒤
결과·효과
start를 통과한 worker가 shared Command를 실행하고 그 Result를 lambda 반환값으로 낸다.
비유의 한계
이 return은 lambda 결과를 돌려주며 replay 수·잔액·DB count는 검사하지 않는다.
127줄F06-L127 })); 현재 same-key worker의 행동 쪽지를 접고 submit 봉투와 Future 바구니 뚜껑을 차례로 닫는다. `}));`는 same-key worker lambda 본문, `pool.submit` 호출, `futures.add` 호출을 차례로 닫는다.
입력
lambda 안의 ready·await·service return 세 단계가 완성되면
결과·효과
submit이 만든 Future 하나가 futures 목록에 추가되고 lambda call chain이 닫힌다.
비유의 한계
이 닫힘은 바깥 n회 for loop를 끝내지 않는다.
128줄F06-L128 } same-key worker 쪽지를 n장 만든 뒤 그 등록 회전판의 테두리를 닫는다. `}`는 same-key worker를 `n`개 등록하는 `for (int i = 0; i < n; i++)` loop를 닫는다.
입력
i가 0부터 n-1까지 Future 등록을 마치면
결과·효과
worker 등록 loop가 닫혀 futures에는 20개 항목이 있어야 한다.
비유의 한계
출발 신호 해제와 결과 assertion은 loop 밖에서 이어진다.
129줄F06-L129 assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); 같은 key 스무 명이 모두 5초 안에 출발선에 섰는지 준비판을 확인한다. 모든 worker가 5초 안에 준비됐는지 true로 확인한다.
입력
same-key 20개 동시 시험에서 worker를 pool에 모두 제출한 뒤
결과·효과
모든 worker가 5초 안에 ready에 도착했다는 boolean assertion이 통과한다.
비유의 한계
ready await true는 worker 도착만 관찰하며 service outcome이나 owner count를 아직 말하지 않는다.
130줄F06-L130 start.countDown(); same-key 준비판이 꽉 차자 출발등을 내려 스무 호출을 동시에 놓아준다. start latch를 0으로 내려 준비된 worker를 함께 출발시킨다.
입력
same-key ready assertion이 true로 끝나 공통 start latch를 해제할 차례가 되면
결과·효과
start count가 0이 되어 대기 중인 same-key worker들이 진행 가능해진다.
비유의 한계
start latch 해제 statement는 각 worker가 언제 끝날지와 persistence 결과를 보장하지 않는다.
131줄F06-L131 List<TransferService.Result> results = new ArrayList<>(); Future 봉투에서 꺼낸 Result를 담을 빈 둘째 바구니를 `results` 이름으로 놓는다. 빈 `ArrayList<TransferService.Result>`를 만들어 `results` 변수에 저장한다.
입력
start 신호를 내린 뒤 결과 회수를 시작하기 직전
결과·효과
회수한 TransferService.Result를 담을 빈 results 목록이 생성된다.
비유의 한계
빈 results 목록만 만들며 Future 결과는 다음 enhanced for loop가 채운다.
132줄F06-L132 for (var future : futures) results.add(future.get(15, TimeUnit.SECONDS)); same-key Future 접수증을 하나씩 열어 15초 안의 Result를 두 번째 바구니에 모두 옮기고 회전을 끝낸다. 한 줄짜리 enhanced for loop는 모든 Future를 돌며 각 결과를 최대 15초 기다려 `results` 목록에 넣고 그 loop를 끝낸다.
입력
start 신호를 내린 뒤 futures 목록 전체를 순회할 때
결과·효과
각 Future가 15초 안에 반환한 Result가 results에 순서대로 추가된다.
비유의 한계
15초는 Future마다 적용되며 전체 시험 하나의 15초 deadline은 아니다.
134줄F06-L134 assertThat(results.stream().filter(r -> !r.replayed()).count()).isEqualTo(1); 스무 영수증에서 replay 도장이 없는 새 실행표만 골라 한 장인지 센다. 20개 결과 중 replay가 아닌 새 실행 결과 수가 정확히 1인지 센다.
입력
20개 Future 결과를 전부 회수한 뒤
결과·효과
20개 Result 중 replayed=false인 owner 결과 수가 정확히 1로 확인된다.
비유의 한계
non-replayed result count 1은 individual thread schedule이나 latency distribution을 측정하지 않는다.
135줄F06-L135 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000); 스무 동시 접수가 끝난 뒤 출발 금고에서 1,000원만 빠졌는지 다시 꺼내 본다. same-key concurrent calls 종료 뒤 source account actual을 9,000으로 검증한다.
입력
owner-count assertion을 통과한 same-key run에서 source Account를 다시 조회하면
결과·효과
동시 same-key 실행 뒤 from balance가 9,000임이 확인된다.
비유의 한계
same-key run의 from balance 9,000 확인은 destination 값과 claim row를 포함하지 않는다.
136줄F06-L136 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000); 스무 동시 접수가 끝난 뒤 도착 금고에 1,000원만 들어왔는지 다시 꺼내 본다. burst-key workload 이후 destination Account balance가 11,000임을 확인한다.
입력
same-key source balance가 확인된 후 destination record의 balance accessor를 평가하면
결과·효과
동일 fixture의 to balance가 11,000임이 확인된다.
비유의 한계
to balance 11,000 equality만으로 replayed flags의 분포는 다시 검증되지 않는다.
137줄F06-L137 assertThat(ledger.count()).isEqualTo(4); 동시 스무 번 뒤에도 개설 두 장과 단 한 번의 이체 두 장뿐인지 원장 묶음을 센다. same-key concurrency fixture가 남긴 전체 ledger row 수를 4와 비교한다.
입력
same-key 20개 동시 시험에서 실제 opening을 포함한 원장 전체 장수 값을 얻은 뒤
결과·효과
opening 포함 ledger 전체 행 수가 4임이 확인된다.
비유의 한계
ledger repository count 4는 entry ordering·column payload·lock behavior를 확인하지 않는다.
138줄F06-L138 assertOneTransferEffect(); 스무 동시 요청 뒤 세 업무 표가 거래1·원장2·claim1인지 helper를 즉시 호출한다. `assertOneTransferEffect` helper를 호출해 same-key 20개 요청 뒤 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
입력
non-replay1과 두 잔액·원장4 assertion을 통과한 뒤
결과·효과
assertOneTransferEffect helper가 가진 persistence assertions를 모두 마치고 돌아온다.
비유의 한계
helper는 세 row count를 보며 운영 throughput이나 다른 JVM의 exactly-once까지 증명하지 않는다.
139줄F06-L139 } finally { same-key 동시 시험이 성공하든 깨지든 작업대를 닫는 finally 통로로 들어간다. same-key try의 exit를 받아 executor cleanup용 finally block을 시작한다.
입력
same-key worker 수집·assertion 구간이 정상 또는 exceptional path로 try를 벗어나면
결과·효과
정상·예외 경로 모두 executor cleanup으로 들어갈 finally scope가 열린다.
비유의 한계
finally opener는 cleanup scope만 만들고 executor termination 완료를 기다리지 않는다.
140줄F06-L140 pool.shutdownNow(); same-key worker가 남지 않도록 첫 thread pool의 비상 종료 버튼을 누른다. 남은 worker에게 중단과 종료를 요청한다; 완료 대기 자체는 하지 않는다.
입력
same-key test control이 열린 finally body의 executor cleanup statement에 도달하면
결과·효과
pool의 실행 중 작업에 interrupt가 요청되고 새 작업 접수가 중단된다.
비유의 한계
shutdownNow 요청은 이미 끝난 task를 되돌리거나 모든 worker 종료를 join하지 않는다.
141줄F06-L141 } same-key 동시 시험의 pool 종료 스위치를 누른 `finally` 안전 구역을 닫는다. `}`는 same-key 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
입력
shutdownNow 호출이 정상·예외 경로 모두에서 실행되면
결과·효과
try/finally scope가 닫혀 executor 정리 경로가 완결된다.
비유의 한계
시험 메서드 전체는 바로 다음 괄호가 닫는다.
142줄F06-L142 } owner 한 명과 replay 열아홉 명을 검수한 same-key 동시 시험 기록지를 덮는다. `}`는 `same_key_concurrent_requests_change_business_once` 시험 메서드를 닫는다.
입력
try/finally와 모든 결과 assertion이 끝나면
결과·효과
same-key 20요청 test call frame이 닫혀 결과가 JUnit으로 반환된다.
비유의 한계
다음 양방향 시험의 pool과 timeout은 별도 범위다.
144줄F06-L144 @Test 네 번째 method 수집 위치에 JUnit 검사 표식을 세운다. source의 네 번째 standalone `@Test` metadata를 선언한다.
입력
셋째 test method scope가 닫힌 뒤 annotation scanner가 이 line에 오면
결과·효과
넷째 Test record가 이어질 method declaration을 분류할 준비를 마친다.
비유의 한계
standalone marker 자체는 이어질 declaration identity나 body statement를 실행하지 않는다.
145줄F06-L145 void opposite_direction_transfers_preserve_total_balance() throws Exception { opposite_direction_transfers_preserve_total_balance라는 빈 method 무대를 연다. 그 이름과 `throws Exception`을 가진 test method scope를 시작한다.
입력
same-key concurrency method가 종료되고 네 번째 annotated declaration을 시작할 때
결과·효과
opposite-direction test frame만 열렸고 body statement는 아직 실행되지 않았다.
비유의 한계
method opener는 뒤 body의 방향별 수·worker 수·amount를 정하지 않는다.
146줄F06-L146 int perDirection = 10; A→B 줄과 B→A 줄에 각각 열 자리씩 놓도록 방향별 수를 정한다. A→B와 B→A 각각 보낼 요청 수를 10으로 고정한다.
입력
양방향 요청 수를 같은 크기로 고정할 때
결과·효과
각 방향에서 제출할 transfer 수가 10으로 고정된다.
비유의 한계
여기서는 perDirection=10만 저장하며 전체 worker 수는 다음 계산이 만든다.
147줄F06-L147 int n = perDirection * 2; 방향별 열 자리를 두 배해 전체 worker 수 n=20을 계산한다. 두 방향 worker 수를 10×2인 20으로 계산한다.
입력
perDirection 값이 10으로 정해진 뒤
결과·효과
전체 worker 수 n이 10×2인 20으로 계산된다.
비유의 한계
정수 n만 계산하며 latch나 executor는 아직 생성하지 않는다.
148줄F06-L148 var ready = new CountDownLatch(n); 양방향 worker 스무 명의 준비 도착을 셀 ready 자물쇠를 건다. opposite-direction workload의 n arrivals를 셀 새 ready CountDownLatch를 만든다.
입력
전체 worker 수 n=20이 준비되면
결과·효과
양방향 worker 20개의 준비를 셀 ready latch가 생성된다.
비유의 한계
ready latch만 만들며 두 방향 작업은 아직 등록하지 않는다.
149줄F06-L149 var start = new CountDownLatch(1); A→B와 B→A worker를 함께 풀어 줄 count1 start 신호등을 세운다. 왕복 workers가 공유할 count-one start latch를 생성한다.
입력
양방향 ready latch 다음으로 공통 출발 신호가 필요할 때
결과·효과
두 방향 worker를 함께 풀 count-1 start latch가 생성된다.
비유의 한계
start latch 생성은 신호 해제나 service 실행이 아니다.
150줄F06-L150 var pool = Executors.newFixedThreadPool(n); 양방향 스무 Callable을 받을 고정 크기 thread 작업대를 조립한다. opposite-direction method 전용으로 n-thread fixed pool을 할당한다.
입력
n=20에 맞춘 executor가 필요할 때
결과·효과
양방향 Callable 20개를 받을 fixed thread pool이 만들어진다.
비유의 한계
pool만 만들며 futures 목록과 왕복 Callable은 아직 없다.
151줄F06-L151 try { 양방향 동시 작업이 끝나면 별도 pool을 반드시 닫도록 try 안전 울타리를 친다. `try` block을 열어 양방향 동시 시험의 본 작업 뒤에 `finally`로 pool을 정리하게 한다.
입력
두 방향 수·latch·thread pool을 만든 직후
결과·효과
opposite-direction concurrency body의 guarded try scope가 열려 내부 작업을 받을 수 있다.
비유의 한계
왕복 futures 목록과 각 작업은 try 안에서 뒤이어 만들므로 아직 준비되지 않았다.
152줄F06-L152 List<Future<TransferService.Result>> futures = new ArrayList<>(); A→B와 B→A Future 접수증을 번갈아 담을 빈 왕복 바구니를 놓는다. futures local variable이 size-zero generic ArrayList instance를 가리키도록 초기화한다.
입력
양방향 try block에 들어온 직후
결과·효과
Future<Result> 항목을 받을 빈 futures 목록이 생성된다.
비유의 한계
목록만 만들며 두 방향 Future는 이어지는 for loop에서 등록한다.
153줄F06-L153 for (int i = 0; i < perDirection; i++) { i가 0에서 perDirection 직전까지 움직일 빈 반복 구역을 연다. `for (int i = 0; i < perDirection; i++) {` loop scope를 시작한다.
입력
선행 statements가 perDirection과 futures list를 준비한 상태에서
결과·효과
loop control variable i와 비어 있는 iteration body scope가 만들어진다.
비유의 한계
for opener는 body local·submission call·Command argument를 선취하지 않는다.
154줄F06-L154 int seq = i; 현재 왕복 차례의 i를 두 lambda가 안전하게 잡을 seq 이름표로 복사한다. lambda가 현재 반복 번호를 안전하게 쓰도록 i 값을 seq에 복사한다.
입력
opposite-direction for loop가 현재 i 값을 body local에 복사하는 statement를 실행하면
결과·효과
현재 반복 index 값이 새 int 지역 변수 seq에 복사된다.
비유의 한계
seq 저장만 하며 A→B나 B→A Future는 아직 등록하지 않는다.
155줄F06-L155 futures.add(pool.submit(() -> invokeAfterBarrier( 현재 iteration의 첫 비동기 호출을 감쌀 submit·add 연결을 펼친다. 첫 번째 `futures.add(pool.submit(() -> invokeAfterBarrier(...` multi-line 호출을 시작한다.
입력
현재 loop iteration에서 첫 Future submission slot에 들어오면
결과·효과
첫 번째 futures.add·pool.submit·invokeAfterBarrier multi-line chain이 열린 채 argument를 기다린다.
비유의 한계
호출은 아직 열려 있으며 argument와 닫는 delimiter가 오기 전에는 Future 등록이 완성되지 않는다.
156줄F06-L156 ready, start, new TransferService.Command("customer-1", "ab-" + seq, from.getId(), to.getId(), 100)))); A→B 100원 표에 `ab-`와 현재 seq를 붙여 barrier 통로·작업대·Future 바구니까지 한 줄로 연결한다. `ab-`와 seq로 key를 만든 A→B Command 생성을 끝낸 뒤 `invokeAfterBarrier`·`pool.submit`·`futures.add` 호출을 차례로 닫는다.
입력
왕복 loop의 현재 seq에서 A 출발과 B 도착 ID가 준비되면
결과·효과
ab-seq·100 Command를 가진 A→B Future 하나가 목록에 추가된다.
비유의 한계
반대 B→A 작업은 바로 다음 줄에서 별도 Future로 등록한다.
157줄F06-L157 futures.add(pool.submit(() -> invokeAfterBarrier( 같은 iteration의 둘째 비동기 호출을 감쌀 submit·add 연결을 다시 펼친다. iteration 안의 두 번째 asynchronous submission pipeline을 열린 호출 상태로 만든다.
입력
바로 앞 첫 submission chain이 닫힌 뒤 둘째 Future slot에 들어오면
결과·효과
같은 iteration의 두 번째 add·submit·invokeAfterBarrier chain이 열린 채 argument를 기다린다.
비유의 한계
이 호출도 열린 상태라 argument와 closing delimiter 없이는 Future가 목록에 추가되지 않는다.
158줄F06-L158 ready, start, new TransferService.Command("customer-1", "ba-" + seq, to.getId(), from.getId(), 100)))); B→A 100원 표에 `ba-`와 같은 seq를 붙여 barrier 통로·작업대·Future 바구니까지 닫아 넣는다. reverse-direction Command를 barrier helper로 보내고 얻은 Future를 collection에 append한다.
입력
같은 loop 차례에서 B 출발과 A 도착 ID가 준비되면
결과·효과
ba-seq·100 Command를 가진 반대 방향 Future가 이어서 등록된다.
비유의 한계
이 pair 등록만으로 worker가 start 신호를 통과한 것은 아니다.
159줄F06-L159 } 각 번호마다 A→B 한 장과 B→A 한 장을 만든 왕복 등록 회전판을 닫는다. `}`는 A→B와 B→A 작업을 한 쌍씩 등록하는 `for (int i = 0; i < perDirection; i++)` loop를 닫는다.
입력
열 개 seq에 대해 두 방향 Future를 모두 추가하면
결과·효과
10회 loop가 닫혀 두 방향 Future 20개 등록이 끝난다.
비유의 한계
ready 확인과 start 해제는 이 loop 다음에 한 번만 실행된다.
160줄F06-L160 assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); 양방향 열 쌍이 모두 5초 안에 왕복 출발선에 모였는지 확인한다. 두 방향 workers가 5초 안에 ready count zero를 만들었는지 boolean으로 확인한다.
입력
양방향 10쌍 동시 시험에서 worker를 pool에 모두 제출한 뒤
결과·효과
양방향 worker 모두 5초 안에 ready에 도착했음이 assertion으로 확인된다.
비유의 한계
opposite-direction ready check는 준비 도착만 보고 balance conservation은 검사하지 않는다.
161줄F06-L161 start.countDown(); 왕복 준비판이 꽉 차자 A→B와 B→A 작업을 같은 순간에 놓아준다. 왕복 workload의 start latch를 countDown해 blocked workers를 release한다.
입력
opposite-direction ready check가 통과해 양쪽 worker용 start signal을 내릴 때
결과·효과
start latch가 열려 A→B와 B→A worker가 service 호출로 진행한다.
비유의 한계
start signal을 내린 사실은 two-direction Future completion이나 timeout 성공을 뜻하지 않는다.
162줄F06-L162 for (var future : futures) future.get(20, TimeUnit.SECONDS); 왕복 Future 접수증을 하나씩 열어 각 작업이 20초 안에 끝나는지 확인하고 회전을 마친다. 한 줄짜리 enhanced for loop는 모든 양방향 Future를 돌며 각각 최대 20초 안에 끝나는지 기다리고 그 loop를 끝낸다.
입력
양방향 start 신호 뒤 futures 목록 전체를 순회할 때
결과·효과
모든 양방향 Future가 각 20초 제한 안에서 정상 반환해야 loop가 끝난다.
비유의 한계
Result를 별도 목록에 저장하거나 20초 global deadline을 만들지 않는다.
164줄F06-L164 long fromBalance = accounts.findById(from.getId()).orElseThrow().getBalance(); DB 서랍에서 출발 계좌 카드의 현재 잔액을 읽어 별도 계기판에 옮긴다. DB에서 출발 계좌를 다시 읽은 현재 잔액을 `fromBalance`에 저장한다.
입력
모든 opposite-direction Future 회수가 끝나 from account의 persisted balance를 읽을 때
결과·효과
DB에서 읽은 출발 계좌 balance가 fromBalance에 저장된다.
비유의 한계
fromBalance read는 값을 local에 담을 뿐 equality assertion이나 to account 조회를 수행하지 않는다.
165줄F06-L165 long toBalance = accounts.findById(to.getId()).orElseThrow().getBalance(); destination record에서 얻은 snapshot을 toBalance라는 별도 숫자칸에 기록한다. DB에서 도착 계좌를 다시 읽은 현재 잔액을 `toBalance`에 저장한다.
입력
fromBalance read 다음에 to account의 별도 persisted value를 조회할 때
결과·효과
DB에서 읽은 도착 계좌 balance가 toBalance에 별도로 저장된다.
비유의 한계
toBalance assignment는 두 잔액의 합계 또는 ledger size를 판정하지 않는다.
166줄F06-L166 assertThat(fromBalance).isEqualTo(10_000); fromBalance 계기판을 `10_000` 눈금과 나란히 맞댄다. 출발 잔액 변수 값을 `10_000` 기대값과 비교해 같은지 확인한다.
입력
opposite-direction task results를 모두 회수해 fromBalance local을 expected value와 맞출 때
결과·효과
fromBalance actual 값이 10,000과 일치함이 확인된다.
비유의 한계
fromBalance 10,000 comparison 하나로 opposite-direction task 전체 순서는 드러나지 않는다.
167줄F06-L167 assertThat(toBalance).isEqualTo(10_000); 오른쪽 계좌 snapshot을 expected 10,000 checkpoint에 통과시킨다. AssertJ가 toBalance long actual을 10,000 expected literal과 equality 비교한다.
입력
fromBalance equality 다음에 separately read toBalance를 AssertJ actual로 올리면
결과·효과
AssertJ equality가 toBalance long actual과 10,000 expected의 일치를 확인한다.
비유의 한계
toBalance equality는 from·to 합 보존식을 별도로 평가하지 않는다.
168줄F06-L168 assertThat(fromBalance + toBalance).isEqualTo(20_000); fromBalance + toBalance 계기판을 `20_000` 눈금과 나란히 맞댄다. 두 계좌 잔액 합계 값을 `20_000` 기대값과 비교해 같은지 확인한다.
입력
양방향 10쌍 동시 시험에서 실제 fromBalance + toBalance 값을 얻은 뒤
결과·효과
두 balance 합계가 20,000이라는 보존식이 확인된다.
비유의 한계
20,000 sum assertion은 두 개별 balance가 각각 어떤 값인지 단독으로 보장하지 않는다.
169줄F06-L169 assertThat(ledger.count()).isEqualTo(42); 왕복 이십 번의 쌍쪽지 마흔 장에 개설 두 장을 더해 원장이 마흔두 장인지 센다. opposite-direction transfers 뒤 ledger total row count가 42인지 단언한다.
입력
양방향 10쌍 동시 시험에서 실제 opening을 포함한 원장 전체 장수 값을 얻은 뒤
결과·효과
ledger repository의 전체 count actual 값이 42와 일치함이 확인된다.
비유의 한계
ledger count 42는 각 transfer entry의 signed amount와 pairing을 검사하지 않는다.
170줄F06-L170 } finally { 왕복 이체 검사가 끝나면 결과와 상관없이 별도 작업대를 닫는 통로로 간다. 왕복 transfer try의 모든 exit path가 진입할 finally control region을 연다.
입력
opposite-direction assertions 또는 worker wait가 try scope를 빠져나오는 모든 path에서
결과·효과
양방향 scenario가 어떤 경로로 끝나도 cleanup할 finally scope에 들어간다.
비유의 한계
이 finally boundary는 cleanup body가 무엇인지나 scheduler fairness를 규정하지 않는다.
171줄F06-L171 pool.shutdownNow(); 왕복 worker가 남지 않도록 둘째 thread pool의 비상 종료 버튼을 누른다. opposite-direction executor에 shutdownNow를 보내 interrupt와 task rejection을 요청한다.
입력
왕복 transfer test의 finally body가 전용 pool 종료 statement를 실행할 때
결과·효과
양방향 전용 pool의 실행 task에 interrupt가 요청되고 executor가 정리된다.
비유의 한계
왕복 pool에 보낸 shutdownNow는 graceful completion·retry·deadlock 부재의 일반 증명이 아니다.
172줄F06-L172 } 양방향 이체용 pool을 끈 `finally` 안전 구역의 출구를 닫는다. `}`는 양방향 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
입력
왕복 시험의 shutdownNow가 실행되면
결과·효과
양방향 try/finally scope가 닫혀 cleanup control flow가 끝난다.
비유의 한계
바깥 양방향 시험 메서드는 다음 괄호까지 남는다.
173줄F06-L173 } opposite_direction_transfers_preserve_total_balance method의 마지막 brace를 닫는다. 이 `}`가 현재 opposite-direction test method body를 종료한다.
입력
method 안의 assertions와 try/finally scopes가 모두 끝난 뒤
결과·효과
현재 test call frame이 닫혀 execution outcome이 JUnit으로 돌아간다.
비유의 한계
closing brace는 뒤에 올 별도 test declaration이나 fixture 흐름을 선취하지 않는다.
175줄F06-L175 @Test 다섯 번째 JUnit 수집 후보 앞에 Test 배지를 단다. source의 다섯째 standalone `@Test` classification record를 선언한다.
입력
넷째 test scope가 종료된 뒤 scanner가 다섯째 marker를 만나면
결과·효과
다섯째 Test metadata가 뒤따를 method declaration 하나와 결합하도록 대기한다.
비유의 한계
annotation에는 method 이름·Command 값·selected runner 범위가 없다.
176줄F06-L176 void same_key_with_different_semantic_request_conflicts_without_extra_effect() { same_key_with_different_semantic_request_conflicts_without_extra_effect 이름의 빈 frame을 연다. same-key semantic request 불일치를 이름에 둔 void test method declaration과 body scope를 시작한다.
입력
opposite-direction method closure 뒤 fifth Test descriptor의 named declaration을 읽으면
결과·효과
의미 불일치 test call frame은 열렸고 body local은 아직 선언되지 않았다.
비유의 한계
method name은 뒤 Command의 구체적 field 값이나 assertion 결과를 선취하지 않는다.
177줄F06-L177 var first = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 1_000); 현재 line의 actor·key·account IDs·amount로 first Command 한 장을 만든다. 완결된 `new TransferService.Command(...)`를 local `first`에 저장한다.
입력
이 constructor line에 적힌 다섯 component와 fixture references를 받으면
결과·효과
first variable이 새 immutable Command object를 가리킨다.
비유의 한계
이 assignment는 다른 Command local이나 service invocation을 수행하지 않는다.
178줄F06-L178 var changed = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 2_000); 같은 conflict-key의 금액만 2,000원으로 바꾼 `changed` 신청표를 만든다. amount만 바뀐 comparison Command를 changed local에 완결해 저장한다.
입력
원본과 key·계좌는 같고 amount만 다른 반례가 필요할 때
결과·효과
같은 key·같은 계좌지만 amount2,000인 changed Command가 별도로 생성된다.
비유의 한계
두 Command 준비만 끝나며 첫 service 호출과 conflict 검사는 뒤에서 실행한다.
180줄F06-L180 transfers.transfer(first); 금액 충돌의 기준을 남기려고 원본 1,000원 신청을 먼저 정상 처리한다. 원본 `first` Command를 service에 먼저 실행하며 반환된 Result는 변수에 저장하지 않는다.
입력
first Command와 changed Command를 모두 만든 뒤
결과·효과
first Command가 TransferService에 전달되고 service 반환값은 이 statement에서 사용하지 않는다.
비유의 한계
반환 Result를 버리므로 이 줄 자체는 replay flag나 잔액을 검사하지 않는다.
181줄F06-L181 assertThatThrownBy(() -> transfers.transfer(changed)) changed 호출을 예외 포착 그물 안에 넣는 AssertJ chain을 펼친다. `assertThatThrownBy(() -> transfers.transfer(changed))` exception assertion을 시작한다.
입력
first service call이 반환하고 changed Command가 이미 준비된 상태에서
결과·효과
changed 호출의 Throwable을 받을 assertion chain이 열렸지만 type consumer는 아직 없다.
비유의 한계
continuation이 닫히기 전에는 exception type·code·persistence 결과를 판정하지 않는다.
182줄F06-L182 .isInstanceOfSatisfying(BusinessException.class, 포착한 예외 봉투에 BusinessException type 검사표를 대고 consumer 칸을 연다. `isInstanceOfSatisfying(BusinessException.class,` type constraint와 consumer slot을 시작한다.
입력
직전 assertThatThrownBy chain이 Throwable을 포착한 상태에서
결과·효과
BusinessException constraint가 추가됐지만 instance consumer argument는 아직 비어 있다.
비유의 한계
consumer body와 closing delimiter가 없으므로 내부 property assertion은 아직 없다.
183줄F06-L183 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)); 금액 바꿔치기 경보의 code가 멱등 충돌 이름과 같은지 맞댄다. amount-conflict consumer가 받은 failure code를 IDEMPOTENCY_CONFLICT와 비교한다.
입력
amount-changed call에서 포착한 BusinessException instance가 consumer parameter로 전달되면
결과·효과
BusinessException.code actual 값이 IDEMPOTENCY_CONFLICT와 일치함이 확인된다.
비유의 한계
message·잔액·DB count는 이 span이 직접 확인하지 않는다.
185줄F06-L185 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000); 금액 바꿔치기를 거절한 뒤 출발 금고가 첫 이체 뒤의 9,000원에 머무는지 본다. amount-change conflict 뒤 original source balance가 9,000인지 읽어 확인한다.
입력
amount-changed exception consumer가 끝난 후 original source Account를 repository에서 읽으면
결과·효과
conflict 뒤 from의 persisted balance가 9,000으로 유지됨이 확인된다.
비유의 한계
amount-conflict 뒤 from balance 9,000 assertion은 to balance와 table counts를 직접 확인하지 않는다.
186줄F06-L186 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000); 금액 바꿔치기를 거절한 뒤 도착 금고가 첫 이체 뒤의 11,000원에 머무는지 본다. 금액 충돌이 거절된 시점의 original destination balance를 11,000으로 검증한다.
입력
conflict 뒤 source balance가 유지됨을 본 다음 original destination value를 확인하면
결과·효과
같은 시점의 to balance가 11,000으로 유지됨이 확인된다.
비유의 한계
to account 11,000 equality에는 business_tx·ledger·claim cardinality가 포함되지 않는다.
187줄F06-L187 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'") 금액 충돌 뒤 업무 거래표에 첫 성공 행 한 개만 있는지 SQL로 센다. 업무 거래표의 TRANSFER 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
입력
amount-conflict exception과 두 balance 확인 뒤 business_tx measurement를 시작할 때
결과·효과
TRANSFER business_tx COUNT를 구할 SQL assertion chain이 시작된다.
비유의 한계
business_tx COUNT query opener만 있고 terminal long extraction과 expected comparison은 아직 없다.
188줄F06-L188 .query(Long.class).single()).isEqualTo(1); 금액 충돌 뒤 업무 거래 count가 첫 성공 하나를 뜻하는 1인지 확인한다. amount-conflict business_tx COUNT terminal value를 1 expected에 맞춘다.
입력
amount-conflict용 business_tx COUNT query가 terminal long actual을 돌려주면
결과·효과
business_tx COUNT 단일 값이 첫 효과 한 건인 1로 확인된다.
비유의 한계
TRANSFER business row 1은 ledger rows나 idempotency_request count를 대신 세지 않는다.
189줄F06-L189 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") 금액 충돌 뒤 TRANSFER 원장 쪽지가 첫 처리의 두 장만 남았는지 세는 SQL 자를 댄다. TRANSFER entry types로 filter한 ledger_entry COUNT statement specification을 시작한다.
입력
business transaction count assertion 다음에 transfer-ledger query를 구성할 때
결과·효과
TRANSFER_ ledger_entry COUNT를 구할 둘째 SQL assertion chain이 열린다.
비유의 한계
ledger_entry COUNT SQL을 연 상태라 actual scalar와 equality result는 정해지지 않았다.
190줄F06-L190 .query(Long.class).single()).isEqualTo(2); 금액 충돌 뒤 원장 행 수를 읽어 첫 이체의 출금·입금 두 장과 맞춘다. 금액 변경 scenario의 transfer-ledger scalar count가 2인지 확인한다.
입력
amount-conflict ledger_entry COUNT의 single long value를 받으면
결과·효과
transfer ledger COUNT가 출금·입금 두 행인 2로 확인된다.
비유의 한계
transfer-ledger count 2는 각 debit·credit amount나 balance outcome을 비교하지 않는다.
191줄F06-L191 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request") 금액 충돌 뒤 멱등 요청표에 원래 claim 한 행만 있는지 SQL로 센다. 멱등 요청표의 전체 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
입력
ledger count 확인 뒤 idempotency_request total query를 열 차례가 되면
결과·효과
idempotency_request 전체 COUNT를 구할 마지막 SQL chain이 열린다.
비유의 한계
idempotency_request query specification에는 terminal value와 expected relation이 아직 붙지 않았다.
192줄F06-L192 .query(Long.class).single()).isEqualTo(1); 금액 충돌 뒤 멱등 요청 count가 원래 claim 하나를 뜻하는 1인지 확인한다. 동일 key amount conflict 이후 idempotency_request total이 1인지 단언한다.
입력
amount-conflict idempotency COUNT query가 terminal scalar를 반환하면
결과·효과
idempotency claim COUNT가 하나뿐인 1로 확인된다.
비유의 한계
claim row count 1은 HTTP conflict mapping이나 response representation을 다루지 않는다.
193줄F06-L193 } 같은 key에서 금액만 바꾼 표를 거절한 시험 기록지를 덮는다. `}`는 금액 변경 conflict를 확인하는 `same_key_with_different_semantic_request_conflicts_without_extra_effect` 시험 메서드를 닫는다.
입력
conflict code와 잔액·거래·원장·claim 수를 모두 확인하면
결과·효과
amount-conflict test scope가 닫혀 앞서 수행한 exception·persistence assertions의 결과가 JUnit으로 돌아간다.
비유의 한계
출발 계좌나 도착 계좌 변경 conflict는 뒤의 별도 시험이다.
195줄F06-L195 @Test 여섯 번째 실행 후보 자리에 JUnit Test 깃발을 세운다. source의 여섯째 standalone `@Test` metadata record를 선언한다.
입력
다섯째 test method가 닫힌 뒤 scanner가 여섯째 marker를 읽으면
결과·효과
여섯째 Test metadata가 바로 이어질 method declaration을 기다린다.
비유의 한계
이 annotation line은 다음 method 이름·fixture object·exception type을 포함하지 않는다.
196줄F06-L196 void same_key_with_changed_from_conflicts_without_extra_effect() { source account 교체 불일치를 검사하는 method descriptor에서 execution frame으로 진입한다. 해당 changed-from test method declaration과 body scope를 시작한다.
입력
amount-conflict JUnit scope가 닫힌 위치에서 sixth annotated method body를 열 때
결과·효과
changed-from method frame이 열렸지만 body local이나 service call은 아직 없다.
비유의 한계
opener는 뒤 fixture의 이름·값·Command component를 선취하지 않는다.
197줄F06-L197 Account other = openings.open("customer-1", "C", 5_000); 출발지 반례에 쓸 customer-1의 C 계좌를 5,000원으로 열어 `other`에 둔다. 계좌 열기 service를 불러 `other`라는 세 번째 비교 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
입력
from 대신 넣을 세 번째 계좌가 필요할 때
결과·효과
비교용 C 계좌가 5,000으로 열리고 other에 저장된다.
비유의 한계
C 계좌 개설만 하며 원본·변경 Command와 conflict 호출은 하지 않는다.
198줄F06-L198 var first = new TransferService.Command("customer-1", "from-conflict-key", from.getId(), to.getId(), 1_000); from-conflict-key를 가진 기준 Command를 first 칸에 완성한다. 현재 constructor line의 components로 새 TransferService.Command를 만들고 `first`에 대입한다.
입력
선행 fixture references와 이 line의 actor·key·amount를 받으면
결과·효과
first local이 현재 source에 적힌 기준 Command instance를 보관한다.
비유의 한계
이 statement는 별도 comparison Command나 service call을 아직 만들지 않는다.
199줄F06-L199 var changedFrom = new TransferService.Command("customer-1", "from-conflict-key", other.getId(), to.getId(), 1_000); 같은 key에서 출발지만 C로 갈아 끼운 `changedFrom` 표를 만든다. source account만 교체한 changedFrom Command object를 구성한다.
입력
원본의 actor·key·to·amount는 유지하고 from만 바꿀 때
결과·효과
같은 key에서 출발지만 C로 바꾼 changedFrom Command가 생성된다.
비유의 한계
두 Command 준비가 끝날 뿐 첫 처리와 conflict assertion은 뒤에서 실행된다.
201줄F06-L201 transfers.transfer(first); 출발지 충돌의 기준을 남기려고 A 출발 원본을 먼저 정상 처리한다. 출발 계좌 변경 반례 전에 원본 `first` Command를 실행하며 반환 Result는 저장하지 않는다.
입력
first와 changedFrom objects가 준비되어 baseline service call statement를 실행할 때
결과·효과
first Command가 TransferService에 한 번 전달되고 반환값은 따로 저장되지 않는다.
비유의 한계
반환 Result는 저장하지 않으며 다음 호출의 conflict와 뒤 잔액 assertion이 결과를 판정한다.
202줄F06-L202 assertThatThrownBy(() -> transfers.transfer(changedFrom)) changedFrom service call을 예외 assertion 그물에 넣기 시작한다. changedFrom service invocation을 Throwable-catching AssertJ assertion에 넣는다.
입력
baseline call이 반환된 후 changedFrom invocation을 exception assertion에 넣을 때
결과·효과
changedFrom 호출의 Throwable을 받을 assertion chain이 열린다.
비유의 한계
열린 chain의 continuation이 없으므로 type·consumer property·closing 상태는 아직 정해지지 않는다.
203줄F06-L203 .isInstanceOfSatisfying(BusinessException.class, changedFrom 예외에 BusinessException type 자를 대고 빈 consumer slot을 남긴다. `isInstanceOfSatisfying(BusinessException.class,` constraint를 chain에 추가한다.
입력
changedFrom exception assertion이 Throwable을 포착한 상태에서
결과·효과
BusinessException type 조건이 연결됐지만 instance consumer는 아직 주어지지 않았다.
비유의 한계
consumer argument와 closing delimiter 전에는 내부 field assertion을 실행하지 않는다.
204줄F06-L204 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)); 출발지 바꿔치기 경보에서 꺼낸 code를 멱등 충돌 표와 비교한다. changed-from BusinessException의 ErrorCode가 IDEMPOTENCY_CONFLICT인지 검사한다.
입력
changedFrom exception consumer가 받은 BusinessException의 code property를 읽을 때
결과·효과
이 scenario에서 받은 exception code가 IDEMPOTENCY_CONFLICT와 일치함이 확인된다.
비유의 한계
세 잔액과 단일 DB effect는 다음 조각이 직접 확인한다.
206줄F06-L206 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000); 출발지 바꿔치기를 거절한 뒤 원래 A 금고가 9,000원을 유지하는지 확인한다. source 변경 충돌 뒤 original from record가 9,000을 유지하는지 확인한다.
입력
changedFrom code assertion 이후 original source account balance를 확인하려 하면
결과·효과
from 계좌 balance가 첫 효과 뒤 9,000임이 확인된다.
비유의 한계
changed-from scenario의 original from balance만 보며 to·other accounts는 이 assertion 범위 밖이다.
207줄F06-L207 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000); changedFrom 거절 뒤 original destination 봉인이 11,000 그대로인지 repository로 검사한다. changedFrom rejection 이후 original to Account의 11,000 balance를 읽는다.
입력
changed-from path의 source assertion 다음 original destination record를 조회하면
결과·효과
원래 to 계좌 balance가 11,000임이 확인된다.
비유의 한계
original destination balance equality는 alternate source account의 unchanged value를 확인하지 않는다.
208줄F06-L208 assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000); 출발지 바꿔치기를 거절한 뒤 반례용 C 금고가 5,000원 그대로인지 본다. alternate source candidate other의 persisted balance가 5,000인지 단언한다.
입력
original pair의 balances를 본 뒤 alternate source Account가 보존됐는지 읽을 때
결과·효과
변경 후보 other 계좌 balance가 5,000 그대로임이 확인된다.
비유의 한계
other account 5,000 assertion에는 transaction·ledger·idempotency counts가 들어 있지 않다.
209줄F06-L209 assertOneTransferEffect(); 출발지 충돌 뒤 첫 이체 효과만 남았는지 세 표 검사를 바로 실행한다. `assertOneTransferEffect` helper를 지금 호출해 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
입력
A·B·C 잔액 assertion을 모두 통과한 뒤
결과·효과
assertOneTransferEffect 호출이 자신의 persistence assertions를 모두 끝내고 정상 반환한다.
비유의 한계
helper는 이 줄에서 실행되며 거래·원장·claim 수만 보고 잔액이나 HTTP 상태는 보지 않는다.
210줄F06-L210 } 같은 key의 출발 금고 바꿔치기를 막은 시험 기록지를 덮는다. `}`는 출발 계좌 변경 conflict를 확인하는 `same_key_with_changed_from_conflicts_without_extra_effect` 시험 메서드를 닫는다.
입력
A·B·C 잔액과 단일 이체 효과 검사가 끝나면
결과·효과
changed-from test scope가 닫혀 그 scenario 결과가 JUnit으로 돌아간다.
비유의 한계
이 범위는 도착 금고 바꿔치기 결과를 보장하지 않는다.
212줄F06-L212 @Test 일곱 번째 JUnit 검사 위치에 Test 표찰 한 장을 예약한다. 일곱 번째 marker가 즉시 이어질 declaration을 JUnit executable method로 태깅한다.
입력
여섯째 test scope가 닫힌 뒤 일곱째 marker를 만나면
결과·효과
일곱째 Test record가 이어질 declaration의 JUnit 분류를 기다린다.
비유의 한계
annotation만으로 다음 method identity·fixture·assertion chain은 생기지 않는다.
213줄F06-L213 void same_key_with_changed_to_conflicts_without_extra_effect() { destination account 차이 경로를 맡은 JUnit routine의 body boundary를 시작한다. destination-change conflict를 이름에 담은 method의 compiler body region으로 진입한다.
입력
changed-from test가 반환된 다음 seventh Test record 아래 method declaration에 도달하면
결과·효과
changed-to call frame이 열렸고 body variable은 아직 하나도 선언되지 않았다.
비유의 한계
이 line은 뒤 fixture 이름·초기값·Command argument를 선취하지 않는다.
214줄F06-L214 Account other = openings.open("customer-1", "C", 5_000); customer-1의 C Account를 5,000으로 열어 other reference에 둔다. `openings.open` 반환 Account를 local `other`에 저장한다.
입력
changed-to method body가 comparison용 third Account를 현재 statement에서 열 때
결과·효과
other variable이 새로 열린 Account reference를 보관한다.
비유의 한계
이 account-opening statement는 Command allocation이나 transfer execution을 하지 않는다.
215줄F06-L215 var first = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), to.getId(), 1_000); to-conflict-key를 담은 기준 Command 한 장을 first 칸에 놓는다. 현재 line의 five-component constructor 결과를 local `first`에 저장한다.
입력
이미 준비된 account references와 이 statement의 actor·key·amount를 사용하면
결과·효과
first variable이 완성된 기준 TransferService.Command를 가리킨다.
비유의 한계
이 한 줄은 추가 Command allocation이나 transfer execution을 포함하지 않는다.
216줄F06-L216 var changedTo = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), other.getId(), 1_000); 같은 key에서 도착지만 C로 갈아 끼운 `changedTo` 표를 만든다. destination만 다른 changedTo semantic Command를 새 instance로 만든다.
입력
원본의 actor·key·from·amount는 유지하고 to만 바꿀 때
결과·효과
같은 key에서 도착지만 C로 바꾼 changedTo Command가 만들어진다.
비유의 한계
두 Command를 만들었을 뿐 원본 처리와 conflict 검사는 아직 실행하지 않았다.
218줄F06-L218 transfers.transfer(first); 도착지 충돌의 기준을 남기려고 B 도착 원본을 먼저 정상 처리한다. baseline request `first`를 TransferService에 전달하고 반환 Result는 discard한다.
입력
first와 changedTo Command가 이미 준비되어 baseline transfer statement에 도달하면
결과·효과
first Command service call이 수행되고 그 반환 Result는 이 줄에서 버려진다.
비유의 한계
반환 Result는 저장하지 않으며 conflict 호출과 잔액·effect assertion은 뒤에서 수행한다.
219줄F06-L219 assertThatThrownBy(() -> transfers.transfer(changedTo)) changedTo 호출을 Throwable 포착 assertion 안에 넣기 시작한다. changedTo call에서 발생할 Throwable을 assertThatThrownBy chain으로 포착한다.
입력
baseline destination request 완료 뒤 changedTo call을 Throwable assertion으로 감쌀 때
결과·효과
changedTo 호출에서 나온 Throwable을 받을 AssertJ chain이 열린다.
비유의 한계
continuation 전에는 exception subtype·consumer field·terminal assertion을 판단하지 않는다.
220줄F06-L220 .isInstanceOfSatisfying(BusinessException.class, changedTo 예외 봉투에 BusinessException label을 확인할 consumer 입구를 붙인다. BusinessException runtime-class check를 추가하면서 instance consumer argument 자리는 열린 채 둔다.
입력
changedTo assertion chain이 Throwable을 포착한 뒤
결과·효과
BusinessException predicate가 연결됐고 instance consumer slot은 아직 비어 있다.
비유의 한계
뒤 consumer와 closing delimiter 없이는 exception property를 검사하지 않는다.
221줄F06-L221 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)); 도착지 바꿔치기 경보의 code 칸이 멱등 충돌로 적혔는지 읽는다. changed-to exception consumer가 failure.code를 IDEMPOTENCY_CONFLICT에 맞춘다.
입력
changedTo path의 typed exception consumer가 failure instance를 넘겨받으면
결과·효과
to 변경 BusinessException code가 IDEMPOTENCY_CONFLICT와 일치한다.
비유의 한계
세 잔액과 single-effect count는 다음 조각이 맡는다.
223줄F06-L223 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000); 도착지 바꿔치기를 거절한 뒤 원래 출발 금고가 9,000원 그대로인지 확인한다. destination 변경 conflict 후 original source balance 9,000을 repository에서 검증한다.
입력
changedTo exception code 검사가 끝나 original source persisted value를 읽으면
결과·효과
원래 from balance가 9,000임이 확인된다.
비유의 한계
changed-to path의 from balance check는 original destination이나 alternate account를 읽지 않는다.
224줄F06-L224 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000); 도착지 바꿔치기를 거절한 뒤 원래 도착 금고가 11,000원 그대로인지 확인한다. changedTo가 거절된 뒤 original destination record가 11,000인지 확인한다.
입력
source Account 보존 확인 다음 original to record의 balance를 가져오면
결과·효과
changed-to conflict 이후 repository가 돌려준 original to balance가 11,000과 일치한다.
비유의 한계
to balance 11,000 확인은 other destination candidate의 persistence state를 대신하지 않는다.
225줄F06-L225 assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000); 도착지 바꿔치기를 거절한 뒤 잘못 지정한 C 금고가 손대지 않은 5,000원인지 본다. alternate destination Account의 5,000 balance가 변경되지 않았음을 읽는다.
입력
original destination까지 확인한 후 alternate destination candidate를 조회할 때
결과·효과
변경 destination 후보 C의 balance가 5,000 그대로임이 확인된다.
비유의 한계
alternate Account 5,000 equality만으로 single business effect cardinality는 증명되지 않는다.
226줄F06-L226 assertOneTransferEffect(); 도착지 충돌 뒤 새 효과가 붙지 않았는지 세 표 검사를 바로 실행한다. `assertOneTransferEffect` helper를 지금 호출해 새 DB 효과가 붙지 않았는지 확인한다.
입력
원래 두 계좌와 C 계좌 잔액 assertion을 모두 통과한 뒤
결과·효과
assertOneTransferEffect helper call이 내부 assertions를 통과한 뒤 caller로 돌아온다.
비유의 한계
helper는 거래1·원장2·claim1만 검사하며 잔액이나 HTTP 상태는 다시 보지 않는다.
227줄F06-L227 } destination 교체 conflict를 다룬 JUnit execution ledger를 마지막 brace로 봉인한다. closing brace가 destination-change JUnit execution을 끝내고 caller framework로 control을 돌린다.
입력
원래 두 계좌와 C 계좌 잔액, 단일 효과를 확인하면
결과·효과
changed-to test frame이 닫혀 JUnit에 scenario 결과가 반환된다.
비유의 한계
이 메서드는 이어지는 failure-hook rollback 시험을 포함하지 않는다.
229줄F06-L229 @Test 여덟 번째 source-local 실행 후보 앞에 Test 배지를 단다. JUnit discovery용 여덟 번째 test tag를 다음 declaration 앞에 배치한다.
입력
일곱째 test method scope가 끝난 뒤 여덟째 marker를 읽으면
결과·효과
여덟째 Test metadata가 다음 method declaration 하나에 붙도록 대기한다.
비유의 한계
이 annotation은 다음 method 이름·hook state·Command·exception을 선취하지 않는다.
230줄F06-L230 void runtime_exception_after_claim_rolls_back_every_database_effect() { claim 뒤 RuntimeException rollback을 관찰할 test invocation scope를 배치한다. claim 이후 database-effect rollback을 이름에 둔 void test method declaration과 body를 연다.
입력
changed-to scope가 닫히고 eighth JUnit descriptor의 method signature를 시작할 때
결과·효과
claim-rollback method frame만 열렸고 body statement는 아직 실행되지 않았다.
비유의 한계
method opener는 뒤 hook 선택·Command 값·assertion 결과를 미리 정하지 않는다.
231줄F06-L231 failureHook.failAt(ControlledFailureHook.Point.AFTER_CLAIM); controlled failure 다이얼을 AFTER_CLAIM 눈금에 맞춘다. 이번 시험에서는 claim 직후에 RuntimeException을 던지도록 hook을 맞춘다.
입력
claim 생성 직후 예외를 던지는 경로를 고를 때
결과·효과
failureHook의 volatile point가 AFTER_CLAIM으로 설정된다.
비유의 한계
hook point만 바꾸며 Command 생성과 service 호출은 다음 단계다.
232줄F06-L232 var command = new TransferService.Command("customer-1", "fail-claim", from.getId(), to.getId(), 1_000); fail-claim key와 A·B·1,000원을 담은 rollback Command를 만든다. fail-claim key와 현재 account IDs·amount로 TransferService.Command를 만들어 `command`에 저장한다.
입력
failureHook이 AFTER_CLAIM으로 설정된 뒤
결과·효과
fail-claim key·1,000을 가진 rollback Command가 생성된다.
비유의 한계
Command construction은 failure-path request value 하나를 만들 뿐 invocation outcome은 관찰하지 않는다.
234줄F06-L234 assertThatThrownBy(() -> transfers.transfer(command)) command service call 주위에 Throwable 포착용 AssertJ 그물을 펼친다. `assertThatThrownBy(() -> transfers.transfer(command))` chain을 시작한다.
입력
선행 statement가 failure point와 command를 준비한 뒤
결과·효과
service call의 Throwable을 받을 exception assertion이 열렸다.
비유의 한계
Throwable-catching chain opener에는 아직 terminal constraint나 추가 persistence observation이 없다.
235줄F06-L235 .isInstanceOf(RuntimeException.class) claim 뒤 경보 봉투가 시험용 RuntimeException 종류인지 확인한다. claim-failure path에서 captured Throwable이 RuntimeException인지 type-check한다.
입력
AFTER_CLAIM scenario의 assertThatThrownBy chain이 포착한 Throwable을 type-check할 때
결과·효과
포착한 값이 RuntimeException instance임이 확인된다.
비유의 한계
현재 type constraint는 포착 object의 class membership만 판정하고 추가 chain 조건은 실행하지 않는다.
236줄F06-L236 .hasMessage("injected after claim"); 경보 쪽지에 `injected after claim` 문구가 정확히 찍혔는지 읽는다. 던져진 exception message가 현재 line의 `injected after claim` literal과 같은지 확인한다.
입력
claim-path Throwable이 RuntimeException constraint를 통과한 직후 exact message를 비교할 때
결과·효과
RuntimeException message가 injected after claim과 정확히 일치한다.
비유의 한계
claim-path exact message 확인은 account balances나 persistence counts를 직접 조회하지 않는다.
237줄F06-L237 assertOnlyOpeningStateRemains(); claim 직후 실패가 끝난 다음 두 잔액과 세 업무 표가 처음 상태인지 종합 검사를 바로 실행한다. `assertOnlyOpeningStateRemains` helper를 지금 호출해 두 잔액 원복과 세 업무 표 0행을 확인한다.
입력
RuntimeException 타입과 `injected after claim` message를 확인한 뒤
결과·효과
assertOnlyOpeningStateRemains 호출이 내부 opening-state assertions를 마쳐야 정상 반환한다.
비유의 한계
helper는 이 줄에서 실행되며 process hard kill이나 외부 효과 rollback까지 증명하지 않는다.
238줄F06-L238 } claim 도장 직후 고장을 낸 첫 rollback 시험 기록지를 덮는다. `}`는 claim 직후 예외의 rollback을 확인하는 `runtime_exception_after_claim_rolls_back_every_database_effect` 시험 메서드를 닫는다.
입력
예외 타입·문구와 opening-only DB 상태 검사가 끝나면
결과·효과
claim-after rollback test scope가 닫혀 결과가 JUnit으로 돌아간다.
비유의 한계
업무 변경 뒤 고장 지점은 다음 시험에서 따로 누른다.
240줄F06-L240 @Test 아홉 번째이자 마지막 source marker 자리에 JUnit Test 인장을 둔다. source test inventory의 마지막 annotation이 아홉 번째 executable declaration slot을 표시한다.
입력
여덟째 test scope가 닫힌 뒤 마지막 Test marker에 도달하면
결과·효과
아홉째 Test record가 바로 이어질 method declaration을 기다린다.
비유의 한계
annotation은 다음 method identity·hook setting·service call을 포함하지 않는다.
241줄F06-L241 void runtime_exception_after_business_mutation_rolls_back_every_database_effect() { runtime_exception_after_business_mutation_rolls_back_every_database_effect 이름의 빈 scope를 연다. 해당 test method declaration과 body frame을 시작한다.
입력
claim-rollback test closure 직후 ninth annotated method declaration을 처리할 때
결과·효과
business-mutation rollback method scope가 열렸고 내부 statement는 아직 없다.
비유의 한계
opener는 뒤 hook point·Command component·assertion 결과를 선취하지 않는다.
242줄F06-L242 failureHook.failAt(ControlledFailureHook.Point.AFTER_BUSINESS_MUTATION); controlled failure 다이얼을 AFTER_BUSINESS_MUTATION 눈금에 맞춘다. 이번 시험에서는 업무 변경 직후에 RuntimeException을 던지도록 hook을 맞춘다.
입력
업무 변경 직후 RuntimeException을 낼 경로를 고를 때
결과·효과
failureHook의 현재 point가 AFTER_BUSINESS_MUTATION으로 바뀐다.
비유의 한계
hook point만 설정하며 Command 생성과 service 실행은 아직 하지 않는다.
243줄F06-L243 var command = new TransferService.Command("customer-1", "fail-business", from.getId(), to.getId(), 1_000); fail-business key와 A·B·1,000원을 담은 rollback Command를 만든다. fail-business key와 현재 from/to IDs·amount를 새 Command로 묶어 `command`에 둔다.
입력
failureHook이 AFTER_BUSINESS_MUTATION으로 설정된 뒤
결과·효과
fail-business key·1,000을 가진 rollback Command가 생성된다.
비유의 한계
Command만 만들며 service의 중간 변경이나 rollback 결과는 아직 확인하지 않는다.
245줄F06-L245 assertThatThrownBy(() -> transfers.transfer(command)) 현재 command service call을 Throwable assertion 그물 안에 넣는다. business-mutation command invocation을 exception-catching assertion chain에 건다.
입력
선행 body statement가 failure point와 command를 준비한 상태에서
결과·효과
command 호출의 Throwable을 받을 AssertJ exception chain이 시작된다.
비유의 한계
열린 chain만으로 exception type·message·database state를 아직 판정하지 않는다.
246줄F06-L246 .isInstanceOf(RuntimeException.class) 돈 이동 뒤 경보 봉투도 주입한 RuntimeException 종류인지 맞춘다. mutation-after failure가 던진 object의 runtime type을 RuntimeException으로 제한한다.
입력
AFTER_BUSINESS_MUTATION path에서 captured Throwable의 runtime type을 판정할 때
결과·효과
포착 객체가 RuntimeException type임이 확인된다.
비유의 한계
business-mutation Throwable의 RuntimeException 판정에는 exact text와 DB state assertion이 없다.
247줄F06-L247 .hasMessage("injected after business mutation"); 경보 쪽지에 `injected after business mutation` 문구가 정확히 찍혔는지 읽는다. captured exception message를 `injected after business mutation` literal과 정확히 비교한다.
입력
business-mutation Throwable의 RuntimeException check가 끝난 뒤 message equality를 볼 때
결과·효과
예외 message가 injected after business mutation과 정확히 일치한다.
비유의 한계
injected-after-business message equality는 rollback된 row cardinality를 관찰하지 않는다.
248줄F06-L248 assertOnlyOpeningStateRemains(); 업무 변경 직후 실패가 끝난 다음 모든 DB 변경이 사라졌는지 종합 검사를 바로 실행한다. `assertOnlyOpeningStateRemains` helper를 지금 호출해 업무 변경 직후 예외의 DB 원복을 확인한다.
입력
RuntimeException 타입과 `injected after business mutation` message를 확인한 뒤
결과·효과
assertOnlyOpeningStateRemains helper call이 가진 assertions를 모두 통과하고 반환한다.
비유의 한계
helper는 지금 두 잔액과 세 표를 검사하며 checked exception 정책까지 일반화하지 않는다.
249줄F06-L249 } 돈·거래·원장을 고친 직후 고장을 낸 둘째 rollback 시험 기록지를 덮는다. `}`는 업무 변경 직후 예외의 rollback을 확인하는 `runtime_exception_after_business_mutation_rolls_back_every_database_effect` 시험 메서드를 닫는다.
입력
예외 타입·문구와 DB 원복 검사가 끝나면
결과·효과
business-mutation rollback test scope가 닫혀 그 결과가 JUnit에 전달된다.
비유의 한계
process hard kill이나 외부 시스템 보상까지 닫아 증명하지 않는다.
251줄F06-L251 private void assertOnlyOpeningStateRemains() { rollback 뒤 처음 연 두 계좌와 빈 세 업무 표를 확인할 종합 검수함을 연다. 처음 연 계좌 상태만 남았는지 확인하는 helper 본문을 시작한다.
입력
두 rollback 시험이 opening-only 상태를 공통으로 검사하려 할 때
결과·효과
opening-only DB 상태를 검사할 private helper scope가 열린다.
비유의 한계
helper 선언은 query를 실행하지 않고 어떤 failure point를 선택할지도 정하지 않는다.
252줄F06-L252 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(10_000); 고장 rollback 뒤 출발 금고 카드를 다시 꺼내 처음의 10,000원이 복원됐는지 본다. DB에서 출발 계좌를 다시 읽어 잔액이 `10_000`인지 확인한다.
입력
opening-only helper가 첫 assertion으로 from Account의 current balance를 가져오면
결과·효과
from 계좌 persisted balance가 10,000으로 복원됐음이 확인된다.
비유의 한계
opening-only helper의 from balance 10,000 check는 to balance나 table zero를 포함하지 않는다.
253줄F06-L253 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(10_000); 고장 rollback 뒤 도착 금고 카드도 처음의 10,000원으로 돌아왔는지 본다. to ID repository lookup 뒤 얻은 balance를 expected 10,000과 equality check한다.
입력
source opening value를 확인한 뒤 같은 helper가 to Account를 조회할 때
결과·효과
opening-only state에서 to Account lookup의 balance actual이 10,000 expected를 만족한다.
비유의 한계
to account 10,000 equality는 idempotency claim과 transfer rows를 별도로 세지 않는다.
254줄F06-L254 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero(); rollback 검수의 첫 표로 멱등 claim 장부가 비었는지 한 줄 SQL 저울에 올린다. `idempotency_request` 전체 행 수를 한 줄 SQL로 구해 0인지 확인한다.
입력
helper 안에서 from·to 잔액이 각각 10,000임을 확인한 뒤
결과·효과
idempotency_request COUNT가 inline query에서 0으로 확인된다.
비유의 한계
이 assertion은 idempotency_request 전체 count만 0과 비교한다.
255줄F06-L255 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'") business_tx의 TRANSFER 행 수를 읽을 SQL 측정틀을 펼친다. `business_tx` TRANSFER COUNT query를 AssertJ chain에 올리기 시작한다.
입력
바로 앞 persistence assertion이 완료된 상태에서
결과·효과
business_tx COUNT assertion은 열렸지만 terminal long 값은 아직 없다.
비유의 한계
terminal query operation이 붙지 않은 statement spec만으로는 business transaction row cardinality를 판정할 수 없다.
256줄F06-L256 .query(Long.class).single()).isZero(); rollback 뒤 업무 거래 count가 0인지 읽어 변경이 사라졌음을 확인한다. 업무 거래표 query의 단일 long 결과가 0인지 확인한다.
입력
opening-only helper의 business_tx COUNT chain이 single long actual을 반환하면
결과·효과
business_tx의 transfer 행 수가 zero임이 확인된다.
비유의 한계
business_tx transfer count zero는 ledger_entry와 account values에 관한 assertion이 아니다.
257줄F06-L257 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") ledger_entry의 TRANSFER 계열 행을 셀 둘째 SQL 틀을 연다. `ledger_entry` TRANSFER COUNT query assertion을 시작한다.
입력
선행 business_tx assertion이 끝난 뒤
결과·효과
ledger COUNT chain이 열렸고 query result는 아직 terminal assertion에 도달하지 않았다.
비유의 한계
다음 continuation 전에는 단일 값이나 기대 비교를 확정하지 않는다.
258줄F06-L258 .query(Long.class).single()).isZero(); rollback 뒤 원장 행 수를 읽어 아무 변경도 없다는 0과 맞춘다. opening-only rollback helper에서 transfer-ledger COUNT scalar가 zero인지 확인한다.
입력
rollback-state ledger_entry query에서 terminal count value를 얻으면
결과·효과
ledger의 transfer 행 수도 zero임이 확인된다.
비유의 한계
transfer-ledger zero 확인은 process crash·external side effect·checked exception path를 재현하지 않는다.
259줄F06-L259 } 두 금고10,000과 claim·거래·원장0을 묶어 보는 초기상태 검수함을 닫는다. `}`는 두 잔액과 세 업무 표의 초기 상태를 검사하는 `assertOnlyOpeningStateRemains` helper를 닫는다.
입력
assertOnlyOpeningStateRemains의 다섯 assertion이 끝나면
결과·효과
assertOnlyOpeningStateRemains scope가 닫혀 다섯 검사의 통과 여부가 caller에 돌아간다.
비유의 한계
이 helper는 어떤 failure point를 선택할지 정하지 않는다.
261줄F06-L261 private TransferService.Command commandFromJson(String key, String json) { 평평한 시험 JSON을 service Command로 바꿀 작은 조립실을 연다. 평평한 JSON에서 Command를 만드는 시험 helper 본문을 시작한다.
입력
representation test가 key와 JSON 문자열을 helper에 건네면
결과·효과
JSON과 idempotency key를 Command로 바꿀 private helper frame이 열린다.
비유의 한계
이 helper는 범용 JSON parser가 아니며 service 업무 validation도 실행하지 않는다.
262줄F06-L262 return new TransferService.Command( caller에게 돌려줄 Command의 빈 constructor와 return 통로를 연다. `return new TransferService.Command(` allocation expression을 시작한다.
입력
commandFromJson method가 이미 받은 parameters를 이용해 return expression에 들어오면
결과·효과
Command allocation·return frame이 열린 채 argument list를 기다린다.
비유의 한계
arguments와 closing delimiter 전에는 component identity나 반환 object가 완성되지 않는다.
263줄F06-L263 "customer-1", Command 첫 칸에 이 fixture의 actor `customer-1` 명찰을 꽂는다. Command의 actor 칸에 이 시험의 로그인 주체 `customer-1`을 넣는다.
입력
열린 Command constructor의 첫 component를 채울 때
결과·효과
새 Command의 actor component가 customer-1로 채워진다.
비유의 한계
actor component 하나만 채워졌으므로 constructor allocation은 미완성 상태다.
264줄F06-L264 key, Command 둘째 칸에 caller가 건넨 idempotency key를 꽂는다. Command의 idempotency key 칸에 helper가 입력받은 `key`를 넣는다.
입력
actor component 다음으로 helper 입력 key를 채울 때
결과·효과
idempotency-key component가 helper parameter key 값으로 채워진다.
비유의 한계
key를 넣어도 세 숫자 component와 constructor 닫힘은 아직 남는다.
265줄F06-L265 semanticLong(json, "from"), JSON의 `from` 숫자를 찾아 Command의 출발 계좌 칸에 적는다. JSON에서 `from`에 적힌 출발 계좌 숫자를 찾아 Command 칸에 넣는다.
입력
actor와 key 뒤 첫 semantic number를 채울 때
결과·효과
JSON의 from 숫자가 long으로 추출되어 fromAccountId component에 들어간다.
비유의 한계
현재 component에는 source-account numeric value 하나만 놓였고 constructor completion은 아직 열려 있다.
266줄F06-L266 semanticLong(json, "to"), 같은 JSON의 `to` 숫자를 찾아 Command의 도착 계좌 칸에 적는다. JSON에서 `to`에 적힌 도착 계좌 숫자를 찾아 Command 칸에 넣는다.
입력
from component 다음 목적지 숫자를 채울 때
결과·효과
semanticLong(json, to)의 반환 long이 Command destination-account component position에 놓인다.
비유의 한계
to까지 채워도 amount와 constructor 닫힘은 아직 남아 있다.
267줄F06-L267 semanticLong(json, "amount") JSON의 `amount` 숫자를 찾아 Command의 마지막 금액 칸에 적는다. JSON에서 `amount`에 적힌 이체 금액 숫자를 찾아 Command 칸에 넣는다.
입력
actor·key·from·to 다음 마지막 component를 채울 때
결과·효과
JSON의 amount 숫자가 long으로 추출되어 마지막 금액 component에 들어간다.
비유의 한계
component는 다 채웠지만 constructor와 return 문은 다음 `);`에서 닫힌다.
268줄F06-L268 ); actor·key·from·to·amount 다섯 칸을 채운 Command 상자를 닫고 반환 표를 붙인다. `);`는 `commandFromJson`이 반환할 `new TransferService.Command(...)` 생성자 호출과 return 문을 닫는다.
입력
semanticLong으로 세 숫자 component까지 모두 채우면
결과·효과
다섯 component를 가진 Command가 완성되어 helper 반환값으로 정해진다.
비유의 한계
이 괄호는 commandFromJson helper 본문 자체를 닫지 않는다.
269줄F06-L269 } commandFromJson helper의 마지막 brace를 닫아 caller로 돌아갈 경계를 만든다. 이 `}`가 `commandFromJson` method body scope를 닫는다.
입력
앞 return expression이 Command object를 caller에 넘긴 뒤
결과·효과
commandFromJson call frame이 종료되고 control이 caller로 복귀한다.
비유의 한계
이 closing brace는 이어질 다른 member declaration의 이름·규칙을 선취하지 않는다.
271줄F06-L271 private long semanticLong(String json, String field) { json과 field를 받아 long을 돌려줄 private helper의 control region을 시작한다. `private long semanticLong(String json, String field) {` signature와 body scope를 시작한다.
입력
class가 두 String parameters를 받는 다음 private member를 선언할 때
결과·효과
json·field parameters가 있는 semanticLong call frame이 열리고 body는 아직 비어 있다.
비유의 한계
method opener는 뒤 local variable·pattern·parsing expression을 선취하지 않는다.
272줄F06-L272 var matcher = Pattern.compile("\\\"" + Pattern.quote(field) + "\\\"\\s*:\\s*(\\d+)").matcher(json); 인용한 field 이름 뒤의 0 이상 십진 숫자를 찾는 Pattern과 matcher를 만든다. 요청한 field 이름 뒤의 0 이상 십진 숫자를 찾는 시험용 정규식을 만들고 matcher를 얻는다.
입력
json 문자열과 찾을 field 이름을 입력받은 뒤
결과·효과
field 이름을 quote한 숫자 regex가 compile되고 json에 대한 Matcher가 생성된다.
비유의 한계
첫 일치만 찾는 시험 regex이며 중복 field·음수·소수·overflow 정책을 완전하게 다루지 않는다.
273줄F06-L273 if (!matcher.find()) throw new IllegalArgumentException("missing semantic field: " + field); matcher가 아무 항목도 못 찾은 경우에만 예외 경보를 울리는 분기표를 둔다. `matcher.find()`가 false면 `IllegalArgumentException`을 즉시 던진다.
입력
직전 statement가 만든 matcher를 받아 match 존재 여부를 검사하면
결과·효과
match가 없으면 예외가 전파되고, match가 있으면 이 conditional throw를 건너뛴다.
비유의 한계
조건 통과 뒤의 구체적 next expression이나 반환값은 이 line에 없다.
274줄F06-L274 return Long.parseLong(matcher.group(1)); matcher의 첫 숫자 capture를 long으로 바꿔 caller에게 반환한다. 정규식 첫 capture의 숫자 문자열을 long으로 바꿔 반환한다.
입력
matcher.find가 true이고 group1 숫자 문자열을 얻은 뒤
결과·효과
첫 capture group의 십진 문자열이 long으로 변환되어 helper 반환값이 된다.
비유의 한계
parseLong overflow는 예외가 될 수 있고 범용 JSON 숫자 문법을 보장하지 않는다.
275줄F06-L275 } 요청한 JSON field의 숫자를 long으로 바꾸는 `semanticLong` 돋보기 상자를 닫는다. `}`는 JSON에서 한 field의 long 값을 찾는 `semanticLong` helper를 닫는다.
입력
첫 matcher group을 parseLong으로 반환하면
결과·효과
semanticLong scope가 닫혀 parse 결과 또는 예외가 caller에 전달된다.
비유의 한계
중복 field·음수·소수·overflow를 일반 parser처럼 처리하지 않는다.
277줄F06-L277 private void assertOneTransferEffect() { assertOneTransferEffect라는 검수 helper의 빈 작업실을 연다. 반환값 없는 private helper `assertOneTransferEffect`의 body scope를 시작한다.
입력
앞선 helper scope들이 닫힌 뒤 이 method declaration에 도달하면
결과·효과
assertOneTransferEffect call frame만 열렸고 assertion statement는 아직 없다.
비유의 한계
method name과 brace는 뒤 body의 table·count·expected 값을 선취하지 않는다.
278줄F06-L278 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'") 단일 효과 검수함의 첫 칸에서 업무 거래표에 TRANSFER 한 행만 있는지 묻는다. `business_tx`의 TRANSFER 행 수를 세는 첫 단일-effect assertion을 시작한다.
입력
caller가 `assertOneTransferEffect` helper를 호출해 첫 assertion에 들어오면
결과·효과
TRANSFER business_tx COUNT multi-line assertion이 시작된다.
비유의 한계
service 호출이나 예외 검사는 helper 밖의 각 시험이 맡고 여기서는 business_tx count만 시작한다.
279줄F06-L279 .query(Long.class).single()).isEqualTo(1); 단일 효과 helper의 업무 거래 count를 정확한 한 행과 맞춘다. single-effect helper의 business transaction count를 terminal expected 1에 맞춘다.
입력
assertOneTransferEffect 첫 business_tx query의 scalar result를 expected value와 비교할 때
결과·효과
transfer business_tx 행 수가 정확히 1로 확인된다.
비유의 한계
business_tx count 1은 ledger pair와 idempotency row를 이 terminal line에서 확인하지 않는다.
280줄F06-L280 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'") ledger_entry TRANSFER 행 수를 읽을 다음 SQL assertion 틀을 연다. `ledger_entry` TRANSFER COUNT query를 AssertJ chain에 올리기 시작한다.
입력
선행 business_tx terminal assertion이 완료된 뒤
결과·효과
ledger_entry COUNT chain이 열렸지만 terminal long actual은 아직 없다.
비유의 한계
continuation 전에는 expected 값 비교나 helper 전체 결론이 성립하지 않는다.
281줄F06-L281 .query(Long.class).single()).isEqualTo(2); 단일 효과 helper의 원장 행 수를 읽어 한 쌍이라는 2와 맞춘다. assertOneTransferEffect가 transfer ledger row 수 2를 equality assertion으로 확인한다.
입력
single-effect ledger_entry COUNT chain이 terminal long을 내놓으면
결과·효과
transfer ledger 행 수가 정확히 2로 확인된다.
비유의 한계
ledger count 2 assertion은 entry contents·ordering·HTTP behavior를 비교하지 않는다.
282줄F06-L282 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request") idempotency_request 전체 행을 셀 마지막 SQL 측정틀을 펼친다. `idempotency_request` COUNT query assertion을 시작한다.
입력
선행 ledger terminal assertion이 닫힌 뒤
결과·효과
idempotency_request COUNT chain이 열리고 terminal comparison을 기다린다.
비유의 한계
다음 continuation 전에는 expected count나 helper 반환 상태를 확정하지 않는다.
283줄F06-L283 .query(Long.class).single()).isEqualTo(1); 단일 효과 helper의 claim count를 정확한 한 행과 맞춘다. effect helper의 idempotency_request scalar count를 1로 검증한다.
입력
effect helper의 마지막 idempotency_request query가 scalar count를 반환하면
결과·효과
idempotency_request 행 수가 정확히 1로 확인된다.
비유의 한계
idempotency_request count 1은 운영 환경의 exactly-once 또는 cross-process retry를 보장하지 않는다.
284줄F06-L284 } 거래1·TRANSFER 원장2·멱등 요청1을 세는 `assertOneTransferEffect` 검수함을 닫는다. `}`는 거래1·원장2·claim1을 검사하는 `assertOneTransferEffect` helper를 닫는다.
입력
세 COUNT 결과를 각각 1·2·1과 맞추면
결과·효과
assertOneTransferEffect scope가 닫혀 세 count 검사의 통과 여부가 caller에 반환된다.
비유의 한계
잔액과 HTTP 응답까지 이 helper가 검사하지 않는다.
286줄F06-L286 private TransferService.Result invokeAfterBarrier( barrier 뒤 service를 부를 helper의 반환형과 `invokeAfterBarrier` 이름을 첫 줄에 적는다. `invokeAfterBarrier` helper의 반환 type과 메서드 이름을 선언하고 parameter 목록을 열기 시작한다.
입력
class의 앞 helper 선언들이 모두 끝나 마지막 helper signature를 시작할 때
결과·효과
invokeAfterBarrier의 Result 반환형·이름과 multi-line parameter 목록이 열린다.
비유의 한계
아직 parameter 목록도 닫히지 않았고 `{`가 없으므로 helper 본문은 시작되지 않았다.
287줄F06-L287 CountDownLatch ready, CountDownLatch start, TransferService.Command command helper 입력표에 `ready` latch·`start` latch·`command` 세 이름을 잘리지 않게 적는다. `invokeAfterBarrier`의 세 parameter `CountDownLatch ready`, `CountDownLatch start`, `TransferService.Command command`를 선언한다.
입력
앞줄에서 invokeAfterBarrier parameter 목록을 연 뒤
결과·효과
ready·start latch와 Command 세 parameter가 helper signature에 선언된다.
비유의 한계
이 줄은 세 parameter를 선언할 뿐 throws 절과 본문 여는 괄호는 다음 줄에 있다.
288줄F06-L288 ) throws Exception { 세 입력표를 닫고 대기 예외를 caller에게 알린 뒤 worker 통로의 문을 연다. parameter 목록을 닫고 `throws Exception`을 선언한 뒤 `{`로 `invokeAfterBarrier` helper 본문을 연다.
입력
ready·start·command parameter 선언이 끝나면
결과·효과
throws Exception 계약과 method body scope가 완성된다.
비유의 한계
throws Exception은 대기 중 예외 전파만 선언하며 pool 생성이나 timeout을 추가하지 않는다.
289줄F06-L289 ready.countDown(); barrier helper에 들어온 worker가 준비됐다고 ready count를 하나 내린다. invokeAfterBarrier helper가 자신의 ready latch arrival을 countDown으로 기록한다.
입력
invokeAfterBarrier 본문이 실제 실행되기 시작하면
결과·효과
현재 worker의 준비 도착으로 ready latch count가 하나 감소한다.
비유의 한계
준비 신호만 보내며 start를 열거나 service를 호출하지 않는다.
290줄F06-L290 start.await(); ready를 알린 worker를 caller의 start latch가 열릴 때까지 기다리게 한다. barrier helper 내부 thread가 supplied start signal이 열릴 때까지 대기한다.
입력
ready.countDown 뒤 동시 출발 신호를 기다릴 때
결과·효과
worker thread가 공통 start latch가 열릴 때까지 대기한다.
비유의 한계
await에는 timeout이 없고 pool 종료·결과 assertion은 이 helper 밖의 책임이다.
291줄F06-L291 return transfers.transfer(command); start 신호를 통과한 worker가 전달받은 Command를 service에 넘겨 Result를 그대로 반환한다. invokeAfterBarrier가 command transfer Result를 그대로 caller Future에 반환한다.
입력
ready.countDown을 마치고 start.await가 풀린 뒤
결과·효과
start를 통과한 Command가 TransferService에 전달되고 그 Result가 caller로 반환된다.
비유의 한계
service Result만 caller에게 돌려주며 timeout·assertion·pool 종료는 수행하지 않는다.
292줄F06-L292 } ready 도착과 start 대기 뒤 service를 부르는 `invokeAfterBarrier` 통로를 닫는다. `}`는 barrier 뒤 service를 호출하는 `invokeAfterBarrier` helper를 닫는다.
입력
전달받은 Command의 Result를 그대로 반환하면
결과·효과
invokeAfterBarrier scope가 닫혀 worker helper 실행이 끝난다.
비유의 한계
이 helper는 pool 생성·timeout·assertion을 맡지 않는다.
293줄F06-L293 } 아홉 시험과 준비·검수 helper를 모두 담은 `TransferIntegrationTest` 큰 서류함을 닫는다. `}`는 아홉 시험과 준비·실패주입·검수 helper들을 담은 `TransferIntegrationTest` class를 닫는다.
입력
마지막 invokeAfterBarrier 메서드까지 선언이 끝나면
결과·효과
TransferIntegrationTest 바깥 scope가 닫혀 아홉 test와 모든 fixture·helper 선언이 완결된다.
비유의 한계
이 class 괄호는 controller HTTP 시험이나 production 코드를 닫지 않는다.
volatile point히토리 → 니지카 → 료 → 키타
  1. 히토리

    point가 volatile이면 transaction도 안전해져?

  2. 니지카

    아니, test thread 사이에 hook 상태를 보이게 하는 필드다.

  3. DB atomicity는 transaction과 assertion이 담당한다.

  4. 키타

    메모리 가시성과 DB 보장을 나누겠습니다.

changed from히토리 → 니지카 → 료 → 키타
  1. 히토리

    출발 계좌가 달라도 이번 test가 확인해?

  2. 니지카

    그건 다음 별도 @Test이고 smoke 선택 대상이 아니다.

  3. 선택 method는 amount만 바꾼다.

  4. 키타

    변경 field를 exact하게 적을게요.

barrier helper히토리 → 니지카 → 료 → 키타
  1. 히토리

    invokeAfterBarrier가 amount conflict 호출 순서를 만들지?

  2. 니지카

    아니, 반대 방향 동시성 test 전용이다.

  3. selected method는 순차로 first 뒤 changed를 호출한다.

  4. 키타

    동시성 없는 경로로 설명하겠습니다.

05

STEP 05 / 13

원본 코드 조각

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

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

F06-C01 · package and imports1–30줄
1–30줄 원본

package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
F06-C02 · test configuration bean31–42줄
31–42줄 원본
@SpringBootTest
@Import(TransferIntegrationTest.FailureHookConfiguration.class)
class TransferIntegrationTest extends PostgresIntegrationTestSupport {

    @TestConfiguration
    static class FailureHookConfiguration {
        @Bean
        ControlledFailureHook controlledFailureHook() {
            return new ControlledFailureHook();
        }
    }
F06-C03 · controlled failure hook43–63줄
43–63줄 원본
    static final class ControlledFailureHook implements TransferFailureHook {
        enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS_MUTATION }

        private volatile Point point = Point.NONE;

        void failAt(Point point) { this.point = point; }
        void reset() { this.point = Point.NONE; }

        @Override
        public void afterClaim() {
            if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
        }

        @Override
        public void afterBusinessMutation() {
            if (point == Point.AFTER_BUSINESS_MUTATION) {
                throw new RuntimeException("injected after business mutation");
            }
        }
    }
F06-C04 · dependencies and two-account fixture64–81줄
64–81줄 원본
    @Autowired AccountRepository accounts;
    @Autowired AccountOpeningService openings;
    @Autowired LedgerEntryRepository ledger;
    @Autowired TransferService transfers;
    @Autowired JdbcClient jdbc;
    @Autowired ControlledFailureHook failureHook;

    private Account from;
    private Account to;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE").update();
        failureHook.reset();
        from = openings.open("customer-1", "A", 10_000);
        to = openings.open("customer-1", "B", 10_000);
    }
F06-C05 · same-key replay test82–96줄
82–96줄 원본
    @Test
    void transfer_and_same_key_replay_once() {
        var command = new TransferService.Command("customer-1", "key-1", from.getId(), to.getId(), 3_000);
        var first = transfers.transfer(command);
        var replay = transfers.transfer(command);

        assertThat(first.replayed()).isFalse();
        assertThat(replay.replayed()).isTrue();
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(7_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(13_000);
        assertThat(ledger.count()).isEqualTo(4);
        assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }
F06-C06 · JSON representation replay test97–111줄
97–111줄 원본
    @Test
    void json_field_order_and_whitespace_variant_replays_without_extra_effect() {
        String firstJson = "{\"from\":" + from.getId() + ",\"to\":" + to.getId() + ",\"amount\":1000}";
        String variantJson = "{  \"amount\" : 1000, \n \"to\" : " + to.getId() + ", \"from\" : " + from.getId() + " }";

        var first = transfers.transfer(commandFromJson("representation-key", firstJson));
        var replay = transfers.transfer(commandFromJson("representation-key", variantJson));

        assertThat(first.replayed()).isFalse();
        assertThat(replay.replayed()).isTrue();
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertOneTransferEffect();
    }
F06-C07 · same-key twenty-request test112–143줄
112–143줄 원본
    @Test
    void same_key_concurrent_requests_change_business_once() throws Exception {
        int n = 20;
        var ready = new CountDownLatch(n);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(n);
        try {
            var command = new TransferService.Command(
                "customer-1", "burst-key", from.getId(), to.getId(), 1_000);
            List<Future<TransferService.Result>> futures = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                futures.add(pool.submit(() -> {
                    ready.countDown();
                    start.await();
                    return transfers.transfer(command);
                }));
            }
            assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            List<TransferService.Result> results = new ArrayList<>();
            for (var future : futures) results.add(future.get(15, TimeUnit.SECONDS));

            assertThat(results.stream().filter(r -> !r.replayed()).count()).isEqualTo(1);
            assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
            assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
            assertThat(ledger.count()).isEqualTo(4);
            assertOneTransferEffect();
        } finally {
            pool.shutdownNow();
        }
    }
F06-C08 · opposite-direction transfer test144–174줄
144–174줄 원본
    @Test
    void opposite_direction_transfers_preserve_total_balance() throws Exception {
        int perDirection = 10;
        int n = perDirection * 2;
        var ready = new CountDownLatch(n);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(n);
        try {
            List<Future<TransferService.Result>> futures = new ArrayList<>();
            for (int i = 0; i < perDirection; i++) {
                int seq = i;
                futures.add(pool.submit(() -> invokeAfterBarrier(
                    ready, start, new TransferService.Command("customer-1", "ab-" + seq, from.getId(), to.getId(), 100))));
                futures.add(pool.submit(() -> invokeAfterBarrier(
                    ready, start, new TransferService.Command("customer-1", "ba-" + seq, to.getId(), from.getId(), 100))));
            }
            assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (var future : futures) future.get(20, TimeUnit.SECONDS);

            long fromBalance = accounts.findById(from.getId()).orElseThrow().getBalance();
            long toBalance = accounts.findById(to.getId()).orElseThrow().getBalance();
            assertThat(fromBalance).isEqualTo(10_000);
            assertThat(toBalance).isEqualTo(10_000);
            assertThat(fromBalance + toBalance).isEqualTo(20_000);
            assertThat(ledger.count()).isEqualTo(42);
        } finally {
            pool.shutdownNow();
        }
    }
F06-C09 · selected amount-conflict test175–194줄
175–194줄 원본
    @Test
    void same_key_with_different_semantic_request_conflicts_without_extra_effect() {
        var first = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 1_000);
        var changed = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 2_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changed))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isEqualTo(2);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
            .query(Long.class).single()).isEqualTo(1);
    }
F06-C10 · changed-from conflict test195–211줄
195–211줄 원본
    @Test
    void same_key_with_changed_from_conflicts_without_extra_effect() {
        Account other = openings.open("customer-1", "C", 5_000);
        var first = new TransferService.Command("customer-1", "from-conflict-key", from.getId(), to.getId(), 1_000);
        var changedFrom = new TransferService.Command("customer-1", "from-conflict-key", other.getId(), to.getId(), 1_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changedFrom))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);
        assertOneTransferEffect();
    }
F06-C11 · changed-to conflict test212–228줄
212–228줄 원본
    @Test
    void same_key_with_changed_to_conflicts_without_extra_effect() {
        Account other = openings.open("customer-1", "C", 5_000);
        var first = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), to.getId(), 1_000);
        var changedTo = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), other.getId(), 1_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changedTo))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);
        assertOneTransferEffect();
    }
F06-C12 · after-claim rollback test229–239줄
229–239줄 원본
    @Test
    void runtime_exception_after_claim_rolls_back_every_database_effect() {
        failureHook.failAt(ControlledFailureHook.Point.AFTER_CLAIM);
        var command = new TransferService.Command("customer-1", "fail-claim", from.getId(), to.getId(), 1_000);

        assertThatThrownBy(() -> transfers.transfer(command))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("injected after claim");
        assertOnlyOpeningStateRemains();
    }
F06-C13 · after-business rollback test240–250줄
240–250줄 원본
    @Test
    void runtime_exception_after_business_mutation_rolls_back_every_database_effect() {
        failureHook.failAt(ControlledFailureHook.Point.AFTER_BUSINESS_MUTATION);
        var command = new TransferService.Command("customer-1", "fail-business", from.getId(), to.getId(), 1_000);

        assertThatThrownBy(() -> transfers.transfer(command))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("injected after business mutation");
        assertOnlyOpeningStateRemains();
    }
F06-C14 · opening-state rollback assertions251–260줄
251–260줄 원본
    private void assertOnlyOpeningStateRemains() {
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(10_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(10_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }
F06-C15 · JSON semantic helpers261–276줄
261–276줄 원본
    private TransferService.Command commandFromJson(String key, String json) {
        return new TransferService.Command(
            "customer-1",
            key,
            semanticLong(json, "from"),
            semanticLong(json, "to"),
            semanticLong(json, "amount")
        );
    }

    private long semanticLong(String json, String field) {
        var matcher = Pattern.compile("\\\"" + Pattern.quote(field) + "\\\"\\s*:\\s*(\\d+)").matcher(json);
        if (!matcher.find()) throw new IllegalArgumentException("missing semantic field: " + field);
        return Long.parseLong(matcher.group(1));
    }
F06-C16 · single-transfer effect helper277–285줄
277–285줄 원본
    private void assertOneTransferEffect() {
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isEqualTo(2);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
            .query(Long.class).single()).isEqualTo(1);
    }
F06-C17 · barrier invocation helper286–294줄
286–294줄 원본
    private TransferService.Result invokeAfterBarrier(
        CountDownLatch ready, CountDownLatch start, TransferService.Command command
    ) throws Exception {
        ready.countDown();
        start.await();
        return transfers.transfer(command);
    }
}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 252 / 252

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

준비·설명 줄 25개도 번역해서 보기
원본한국어 번역
2package com.example.financialcore.transfer;이 파일의 소속 주소를 `com.example.financialcore.transfer`로 정한다.
4import com.example.financialcore.PostgresIntegrationTestSupport;아래 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입을 짧게 부르려고 가져온다.
5import com.example.financialcore.account.Account;아래 코드에서 `com.example.financialcore.account.Account` 타입을 짧게 부르려고 가져온다.
6import com.example.financialcore.account.AccountOpeningService;아래 코드에서 `com.example.financialcore.account.AccountOpeningService` 타입을 짧게 부르려고 가져온다.
7import com.example.financialcore.account.AccountRepository;아래 코드에서 `com.example.financialcore.account.AccountRepository` 타입을 짧게 부르려고 가져온다.
8import com.example.financialcore.api.BusinessException;아래 코드에서 `com.example.financialcore.api.BusinessException` 타입을 짧게 부르려고 가져온다.
9import com.example.financialcore.api.ErrorCode;아래 코드에서 `com.example.financialcore.api.ErrorCode` 타입을 짧게 부르려고 가져온다.
10import com.example.financialcore.ledger.LedgerEntryRepository;아래 코드에서 `com.example.financialcore.ledger.LedgerEntryRepository` 타입을 짧게 부르려고 가져온다.
11import org.junit.jupiter.api.BeforeEach;아래 코드에서 `org.junit.jupiter.api.BeforeEach` 타입을 짧게 부르려고 가져온다.
12import org.junit.jupiter.api.Test;아래 코드에서 `org.junit.jupiter.api.Test` 타입을 짧게 부르려고 가져온다.
13import org.springframework.beans.factory.annotation.Autowired;아래 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입을 짧게 부르려고 가져온다.
14import org.springframework.boot.test.context.SpringBootTest;아래 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입을 짧게 부르려고 가져온다.
15import org.springframework.jdbc.core.simple.JdbcClient;아래 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입을 짧게 부르려고 가져온다.
16import org.springframework.boot.test.context.TestConfiguration;아래 코드에서 `org.springframework.boot.test.context.TestConfiguration` 타입을 짧게 부르려고 가져온다.
17import org.springframework.context.annotation.Bean;아래 코드에서 `org.springframework.context.annotation.Bean` 타입을 짧게 부르려고 가져온다.
18import org.springframework.context.annotation.Import;아래 코드에서 `org.springframework.context.annotation.Import` 타입을 짧게 부르려고 가져온다.
20import static org.assertj.core.api.Assertions.assertThat;시험에서 `org.assertj.core.api.Assertions.assertThat` 기능을 짧은 이름으로 쓰려고 가져온다.
21import static org.assertj.core.api.Assertions.assertThatThrownBy;시험에서 `org.assertj.core.api.Assertions.assertThatThrownBy` 기능을 짧은 이름으로 쓰려고 가져온다.
23import java.util.ArrayList;아래 코드에서 `java.util.ArrayList` 타입을 짧게 부르려고 가져온다.
24import java.util.List;아래 코드에서 `java.util.List` 타입을 짧게 부르려고 가져온다.
25import java.util.concurrent.CountDownLatch;아래 코드에서 `java.util.concurrent.CountDownLatch` 타입을 짧게 부르려고 가져온다.
26import java.util.concurrent.Executors;아래 코드에서 `java.util.concurrent.Executors` 타입을 짧게 부르려고 가져온다.
27import java.util.concurrent.Future;아래 코드에서 `java.util.concurrent.Future` 타입을 짧게 부르려고 가져온다.
28import java.util.concurrent.TimeUnit;아래 코드에서 `java.util.concurrent.TimeUnit` 타입을 짧게 부르려고 가져온다.
29import java.util.regex.Pattern;아래 코드에서 `java.util.regex.Pattern` 타입을 짧게 부르려고 가져온다.
원본한국어 번역
31@SpringBootTest실제 Spring bean과 PostgreSQL 경로를 함께 쓰는 통합 시험임을 표시한다.
32@Import(TransferIntegrationTest.FailureHookConfiguration.class)시험에서만 쓸 failure hook 설정 class를 Spring context에 추가한다.
33class TransferIntegrationTest extends PostgresIntegrationTestSupport {PostgreSQL 시험 지원을 상속한 `TransferIntegrationTest` class 본문을 연다.
35 @TestConfiguration바로 아래 중첩 class가 시험 전용 Spring 설정임을 표시한다.
36 static class FailureHookConfiguration {고장을 넣을 bean만 제공하는 시험 전용 중첩 설정 class를 연다.
37 @Bean바로 아래 반환 객체를 시험 context의 Spring bean으로 등록한다.
38 ControlledFailureHook controlledFailureHook() {새 `ControlledFailureHook` bean을 만들어 돌려주는 설정 메서드를 연다.
39 return new ControlledFailureHook();아직 고장 지점이 NONE인 시험용 hook 객체를 새로 만들어 반환한다.
40 }`}`는 `controlledFailureHook` @Bean factory 메서드의 본문을 닫는다.
41 }`}`는 시험 bean을 선언한 `FailureHookConfiguration` 중첩 class를 닫는다.
43 static final class ControlledFailureHook implements TransferFailureHook {실패 지점을 골라 RuntimeException을 던질 시험용 hook class를 연다.
44 enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS_MUTATION }고장 없음·claim 뒤·업무 변경 뒤의 세 값을 가진 `Point` enum 선언을 한 줄에서 완성한다.
46 private volatile Point point = Point.NONE;여러 thread가 읽을 현재 실패 지점을 처음에는 NONE으로 두고 volatile로 보이게 한다.
48 void failAt(Point point) { this.point = point; }한 줄짜리 `failAt` 메서드는 입력받은 Point를 volatile `point` field에 대입하고 그 메서드까지 닫는다.
49 void reset() { this.point = Point.NONE; }한 줄짜리 `reset` 메서드는 `point`를 `NONE`으로 되돌리고 그 메서드까지 닫는다.
51 @Override바로 아래 메서드가 interface의 약속을 실제로 구현한다고 표시한다.
52 public void afterClaim() {`afterClaim` 메서드 본문을 시작한다.
53 if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");현재 지점이 AFTER_CLAIM이면 정해진 message의 RuntimeException을 던진다.
54 }`}`는 claim 직후 실패 조건을 검사하는 `afterClaim` 메서드를 닫는다.
56 @Override바로 아래 메서드가 interface의 약속을 실제로 구현한다고 표시한다.
57 public void afterBusinessMutation() {`afterBusinessMutation` 메서드 본문을 시작한다.
58 if (point == Point.AFTER_BUSINESS_MUTATION) {현재 지점이 AFTER_BUSINESS_MUTATION인지 검사하는 실패 분기를 연다.
59 throw new RuntimeException("injected after business mutation");`injected after business mutation`라는 시험용 RuntimeException을 던진다.
60 }`}`는 point가 `AFTER_BUSINESS_MUTATION`일 때 예외를 던지는 `if` block을 닫는다.
61 }`}`는 업무 변경 직후 실패를 검사하는 `afterBusinessMutation` 메서드를 닫는다.
62 }`}`는 두 failure point를 구현한 `ControlledFailureHook` 중첩 class를 닫는다.
64 @Autowired AccountRepository accounts;Spring에서 `AccountRepository accounts`에 맞는 계좌 저장소 bean을 찾아 이 시험 필드에 주입한다.
65 @Autowired AccountOpeningService openings;Spring에서 `AccountOpeningService openings`에 맞는 계좌 개설 도구 bean을 찾아 이 시험 필드에 주입한다.
66 @Autowired LedgerEntryRepository ledger;Spring에서 `LedgerEntryRepository ledger`에 맞는 원장 저장소 bean을 찾아 이 시험 필드에 주입한다.
67 @Autowired TransferService transfers;Spring에서 `TransferService transfers`에 맞는 이체 업무 서비스 bean을 찾아 이 시험 필드에 주입한다.
68 @Autowired JdbcClient jdbc;Spring에서 `JdbcClient jdbc`에 맞는 DB 조회 도구 bean을 찾아 이 시험 필드에 주입한다.
69 @Autowired ControlledFailureHook failureHook;Spring에서 `ControlledFailureHook failureHook`에 맞는 고장 주입 갈고리 bean을 찾아 이 시험 필드에 주입한다.
71 private Account from;매 시험에 새로 열 `from` 계좌를 보관할 필드를 선언한다.
72 private Account to;매 시험에 새로 열 `to` 계좌를 보관할 필드를 선언한다.
74 @BeforeEach각 @Test보다 먼저 새 DB 상태를 만들 준비 메서드임을 표시한다.
75 void setUp() {`setUp` 메서드 본문을 시작한다.
76 jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE").update();멱등 요청·원장·업무 거래·계좌 표를 비우고 identity까지 다시 시작한다.
77 failureHook.reset();이전 시험이 골랐던 고장 지점을 NONE으로 초기화한다.
78 from = openings.open("customer-1", "A", 10_000);계좌 열기 service를 불러 `from`라는 출발 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
79 to = openings.open("customer-1", "B", 10_000);계좌 열기 service를 불러 `to`라는 도착 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
80 }`}`는 DB를 비우고 hook과 두 계좌를 준비하는 `setUp` @BeforeEach 메서드를 닫는다.
82 @Test`@Test`는 바로 다음 `transfer_and_same_key_replay_once` 메서드를 JUnit direct test로 표시한다.
83 void transfer_and_same_key_replay_once() {`transfer_and_same_key_replay_once` 시험 메서드의 본문을 연다.
84 var command = new TransferService.Command("customer-1", "key-1", from.getId(), to.getId(), 3_000);actor·key·두 계좌 ID·3,000을 Command로 묶어 `command` 변수에 저장한다.
85 var first = transfers.transfer(command);`transfer(command)`를 처음 호출하고 반환된 Result를 `first` 변수에 저장한다.
86 var replay = transfers.transfer(command);실제 `TransferService.transfer`를 호출하고 반환된 Result를 `replay` 변수에 저장한다.
88 assertThat(first.replayed()).isFalse();`first.replayed()` 값이 false인지 확인한다.
89 assertThat(replay.replayed()).isTrue();`replay.replayed()` 값이 true인지 확인한다.
90 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(7_000);DB에서 출발 계좌를 다시 읽어 잔액이 `7_000`인지 확인한다.
91 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(13_000);DB에서 도착 계좌를 다시 읽어 잔액이 `13_000`인지 확인한다.
92 assertThat(ledger.count()).isEqualTo(4);원장 전체 행 수가 `4`인지 확인한다; opening 행도 포함한다.
93 assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")TRANSFER 원장 `signed_amount` 합계를 구하는 SQL assertion을 시작한다.
94 .query(Long.class).single()).isZero();원장표 query의 단일 long 결과가 0인지 확인한다.
95 }`}`는 첫 실행과 같은-key replay를 확인하는 `transfer_and_same_key_replay_once` 시험 메서드를 닫는다.
97 @Test`@Test`는 바로 다음 JSON field 순서·공백 변형 replay 메서드를 JUnit direct test로 표시한다.
98 void json_field_order_and_whitespace_variant_replays_without_extra_effect() {JSON 순서와 공백이 달라도 같은 뜻으로 보는 시험 본문을 시작한다.
99 String firstJson = "{\"from\":" + from.getId() + ",\"to\":" + to.getId() + ",\"amount\":1000}";from·to·amount 순서의 첫 평평한 JSON 문자열을 실제 계좌 ID로 만든다.
100 String variantJson = "{ \"amount\" : 1000, \n \"to\" : " + to.getId() + ", \"from\" : " + from.getId() + " }";같은 세 숫자를 amount·to·from 순서와 다른 공백으로 적은 둘째 JSON을 만든다.
102 var first = transfers.transfer(commandFromJson("representation-key", firstJson));`firstJson`을 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `first`에 저장한다.
103 var replay = transfers.transfer(commandFromJson("representation-key", variantJson));`variantJson`을 같은 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `replay`에 저장한다.
105 assertThat(first.replayed()).isFalse();`first.replayed()` 값이 false인지 확인한다.
106 assertThat(replay.replayed()).isTrue();`replay.replayed()` 값이 true인지 확인한다.
107 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);DB에서 출발 계좌를 다시 읽어 잔액이 `9_000`인지 확인한다.
108 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);DB에서 도착 계좌를 다시 읽어 잔액이 `11_000`인지 확인한다.
109 assertOneTransferEffect();업무 거래1·TRANSFER 원장2·멱등 요청1을 묶어 확인하는 helper를 부른다.
110 }`}`는 JSON field 순서·공백 변형도 replay되는지 확인하는 `json_field_order_and_whitespace_variant_replays_without_extra_effect` 시험 메서드를 닫는다.
112 @Test`@Test`는 바로 다음 same-key 20개 동시 요청 메서드를 JUnit direct test로 표시한다.
113 void same_key_concurrent_requests_change_business_once() throws Exception {같은 열쇠 20개 동시 요청에서 새 실행 하나를 찾는 시험 본문을 시작한다.
114 int n = 20;동시에 보낼 같은-key service 요청 수를 20으로 고정한다.
115 var ready = new CountDownLatch(n);worker n명이 모두 준비됐는지 셀 `ready` latch를 만든다.
116 var start = new CountDownLatch(1);모든 worker를 한 번에 출발시킬 count1 `start` latch를 만든다.
117 var pool = Executors.newFixedThreadPool(n);n개 Java worker를 실행할 고정 thread pool을 만든다.
118 try {`try` block을 열어 same-key 동시 시험의 본 작업 뒤에 `finally`로 pool을 정리하게 한다.
119 var command = new TransferService.Command(`command`에 저장할 shared `TransferService.Command`의 constructor 호출을 시작한다.
120 "customer-1", "burst-key", from.getId(), to.getId(), 1_000);`customer-1`·`burst-key`·두 계좌 ID·1,000으로 shared Command 생성을 끝내고 그 객체를 `command` 변수에 저장한다.
121 List<Future<TransferService.Result>> futures = new ArrayList<>();빈 `ArrayList<Future<TransferService.Result>>`를 만들어 `futures` 변수에 저장한다.
122 for (int i = 0; i < n; i++) {`i`가 0부터 `n-1`까지 변하는 for loop를 열어 same-key worker 등록을 반복한다.
123 futures.add(pool.submit(() -> {`futures.add(pool.submit(...))` 호출을 시작하고 same-key worker lambda 본문을 연다.
124 ready.countDown();현재 worker가 출발선에 도착했음을 ready latch에 한 명 알린다.
125 start.await();공통 start 신호가 열릴 때까지 현재 worker를 기다리게 한다.
126 return transfers.transfer(command);준비 장벽을 통과한 worker가 service 이체 결과를 그대로 반환한다.
127 }));`}));`는 same-key worker lambda 본문, `pool.submit` 호출, `futures.add` 호출을 차례로 닫는다.
128 }`}`는 same-key worker를 `n`개 등록하는 `for (int i = 0; i < n; i++)` loop를 닫는다.
129 assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();모든 worker가 5초 안에 준비됐는지 true로 확인한다.
130 start.countDown();start latch를 0으로 내려 준비된 worker를 함께 출발시킨다.
131 List<TransferService.Result> results = new ArrayList<>();빈 `ArrayList<TransferService.Result>`를 만들어 `results` 변수에 저장한다.
132 for (var future : futures) results.add(future.get(15, TimeUnit.SECONDS));한 줄짜리 enhanced for loop는 모든 Future를 돌며 각 결과를 최대 15초 기다려 `results` 목록에 넣고 그 loop를 끝낸다.
134 assertThat(results.stream().filter(r -> !r.replayed()).count()).isEqualTo(1);20개 결과 중 replay가 아닌 새 실행 결과 수가 정확히 1인지 센다.
135 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);DB에서 출발 계좌를 다시 읽어 잔액이 `9_000`인지 확인한다.
136 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);DB에서 도착 계좌를 다시 읽어 잔액이 `11_000`인지 확인한다.
137 assertThat(ledger.count()).isEqualTo(4);원장 전체 행 수가 `4`인지 확인한다; opening 행도 포함한다.
138 assertOneTransferEffect();`assertOneTransferEffect` helper를 호출해 same-key 20개 요청 뒤 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
139 } finally {정상 완료든 예외든 executor 종료 요청을 보낼 finally 블록으로 넘어간다.
140 pool.shutdownNow();남은 worker에게 중단과 종료를 요청한다; 완료 대기 자체는 하지 않는다.
141 }`}`는 same-key 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
142 }`}`는 `same_key_concurrent_requests_change_business_once` 시험 메서드를 닫는다.
144 @Test`@Test`는 바로 다음 양방향 10쌍 동시 이체 메서드를 JUnit direct test로 표시한다.
145 void opposite_direction_transfers_preserve_total_balance() throws Exception {양방향 열 건씩 보내 두 계좌 합계를 지키는 시험 본문을 시작한다.
146 int perDirection = 10;A→B와 B→A 각각 보낼 요청 수를 10으로 고정한다.
147 int n = perDirection * 2;두 방향 worker 수를 10×2인 20으로 계산한다.
148 var ready = new CountDownLatch(n);worker n명이 모두 준비됐는지 셀 `ready` latch를 만든다.
149 var start = new CountDownLatch(1);모든 worker를 한 번에 출발시킬 count1 `start` latch를 만든다.
150 var pool = Executors.newFixedThreadPool(n);n개 Java worker를 실행할 고정 thread pool을 만든다.
151 try {`try` block을 열어 양방향 동시 시험의 본 작업 뒤에 `finally`로 pool을 정리하게 한다.
152 List<Future<TransferService.Result>> futures = new ArrayList<>();빈 `ArrayList<Future<TransferService.Result>>`를 만들어 `futures` 변수에 저장한다.
153 for (int i = 0; i < perDirection; i++) {`i`가 0부터 `perDirection-1`까지 변하는 for loop를 열어 A→B와 B→A worker 한 쌍씩 등록한다.
154 int seq = i;lambda가 현재 반복 번호를 안전하게 쓰도록 i 값을 seq에 복사한다.
155 futures.add(pool.submit(() -> invokeAfterBarrier(A→B worker를 위한 `futures.add(pool.submit(() -> invokeAfterBarrier(...` 호출을 시작한다.
156 ready, start, new TransferService.Command("customer-1", "ab-" + seq, from.getId(), to.getId(), 100))));`ab-`와 seq로 key를 만든 A→B Command 생성을 끝낸 뒤 `invokeAfterBarrier`·`pool.submit`·`futures.add` 호출을 차례로 닫는다.
157 futures.add(pool.submit(() -> invokeAfterBarrier(B→A worker를 위한 `futures.add(pool.submit(() -> invokeAfterBarrier(...` 호출을 시작한다.
158 ready, start, new TransferService.Command("customer-1", "ba-" + seq, to.getId(), from.getId(), 100))));`ba-`와 seq로 key를 만든 B→A Command 생성을 끝낸 뒤 `invokeAfterBarrier`·`pool.submit`·`futures.add` 호출을 차례로 닫는다.
159 }`}`는 A→B와 B→A 작업을 한 쌍씩 등록하는 `for (int i = 0; i < perDirection; i++)` loop를 닫는다.
160 assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();모든 worker가 5초 안에 준비됐는지 true로 확인한다.
161 start.countDown();start latch를 0으로 내려 준비된 worker를 함께 출발시킨다.
162 for (var future : futures) future.get(20, TimeUnit.SECONDS);한 줄짜리 enhanced for loop는 모든 양방향 Future를 돌며 각각 최대 20초 안에 끝나는지 기다리고 그 loop를 끝낸다.
164 long fromBalance = accounts.findById(from.getId()).orElseThrow().getBalance();DB에서 출발 계좌를 다시 읽은 현재 잔액을 `fromBalance`에 저장한다.
165 long toBalance = accounts.findById(to.getId()).orElseThrow().getBalance();DB에서 도착 계좌를 다시 읽은 현재 잔액을 `toBalance`에 저장한다.
166 assertThat(fromBalance).isEqualTo(10_000);출발 잔액 변수 값을 `10_000` 기대값과 비교해 같은지 확인한다.
167 assertThat(toBalance).isEqualTo(10_000);도착 잔액 변수 값을 `10_000` 기대값과 비교해 같은지 확인한다.
168 assertThat(fromBalance + toBalance).isEqualTo(20_000);두 계좌 잔액 합계 값을 `20_000` 기대값과 비교해 같은지 확인한다.
169 assertThat(ledger.count()).isEqualTo(42);원장 전체 행 수가 `42`인지 확인한다; opening 행도 포함한다.
170 } finally {정상 완료든 예외든 executor 종료 요청을 보낼 finally 블록으로 넘어간다.
171 pool.shutdownNow();남은 worker에게 중단과 종료를 요청한다; 완료 대기 자체는 하지 않는다.
172 }`}`는 양방향 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
173 }`}`는 `opposite_direction_transfers_preserve_total_balance` 시험 메서드를 닫는다.
175 @Test`@Test`는 바로 다음 same-key 금액 변경 conflict 메서드를 JUnit direct test로 표시한다.
176 void same_key_with_different_semantic_request_conflicts_without_extra_effect() {같은 열쇠의 금액 변경을 충돌로 막는 시험 본문을 시작한다.
177 var first = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `first`에 둔다.
178 var changed = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 2_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `changed`에 둔다.
180 transfers.transfer(first);원본 `first` Command를 service에 먼저 실행하며 반환된 Result는 변수에 저장하지 않는다.
181 assertThatThrownBy(() -> transfers.transfer(changed))`changed` service 호출이 정상 반환하지 않고 기대한 예외를 던지는지 검사를 시작한다.
182 .isInstanceOfSatisfying(BusinessException.class,던져진 예외가 BusinessException인지 확인하고 내부 ErrorCode 검사도 이어 간다.
183 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));잡은 BusinessException의 code가 IDEMPOTENCY_CONFLICT인지 확인한다.
185 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);DB에서 출발 계좌를 다시 읽어 잔액이 `9_000`인지 확인한다.
186 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);DB에서 도착 계좌를 다시 읽어 잔액이 `11_000`인지 확인한다.
187 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")업무 거래표의 TRANSFER 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
188 .query(Long.class).single()).isEqualTo(1);업무 거래표 query의 단일 long 결과가 `1`인지 확인한다.
189 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")원장표의 TRANSFER 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
190 .query(Long.class).single()).isEqualTo(2);원장표 query의 단일 long 결과가 `2`인지 확인한다.
191 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")멱등 요청표의 전체 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
192 .query(Long.class).single()).isEqualTo(1);멱등 요청표 query의 단일 long 결과가 `1`인지 확인한다.
193 }`}`는 금액 변경 conflict를 확인하는 `same_key_with_different_semantic_request_conflicts_without_extra_effect` 시험 메서드를 닫는다.
195 @Test`@Test`는 바로 다음 same-key 출발 계좌 변경 conflict 메서드를 JUnit direct test로 표시한다.
196 void same_key_with_changed_from_conflicts_without_extra_effect() {같은 열쇠의 출발 계좌 변경을 막는 시험 본문을 시작한다.
197 Account other = openings.open("customer-1", "C", 5_000);계좌 열기 service를 불러 `other`라는 세 번째 비교 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
198 var first = new TransferService.Command("customer-1", "from-conflict-key", from.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `first`에 둔다.
199 var changedFrom = new TransferService.Command("customer-1", "from-conflict-key", other.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `changedFrom`에 둔다.
201 transfers.transfer(first);출발 계좌 변경 반례 전에 원본 `first` Command를 실행하며 반환 Result는 저장하지 않는다.
202 assertThatThrownBy(() -> transfers.transfer(changedFrom))`changedFrom` service 호출이 정상 반환하지 않고 기대한 예외를 던지는지 검사를 시작한다.
203 .isInstanceOfSatisfying(BusinessException.class,던져진 예외가 BusinessException인지 확인하고 내부 ErrorCode 검사도 이어 간다.
204 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));잡은 BusinessException의 code가 IDEMPOTENCY_CONFLICT인지 확인한다.
206 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);DB에서 출발 계좌를 다시 읽어 잔액이 `9_000`인지 확인한다.
207 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);DB에서 도착 계좌를 다시 읽어 잔액이 `11_000`인지 확인한다.
208 assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);DB에서 세 번째 계좌를 다시 읽어 잔액이 `5_000`인지 확인한다.
209 assertOneTransferEffect();`assertOneTransferEffect` helper를 지금 호출해 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
210 }`}`는 출발 계좌 변경 conflict를 확인하는 `same_key_with_changed_from_conflicts_without_extra_effect` 시험 메서드를 닫는다.
212 @Test`@Test`는 바로 다음 same-key 도착 계좌 변경 conflict 메서드를 JUnit direct test로 표시한다.
213 void same_key_with_changed_to_conflicts_without_extra_effect() {같은 열쇠의 도착 계좌 변경을 막는 시험 본문을 시작한다.
214 Account other = openings.open("customer-1", "C", 5_000);계좌 열기 service를 불러 `other`라는 세 번째 비교 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
215 var first = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `first`에 둔다.
216 var changedTo = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), other.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `changedTo`에 둔다.
218 transfers.transfer(first);도착 계좌 변경 반례 전에 원본 `first` Command를 실행하며 반환 Result는 저장하지 않는다.
219 assertThatThrownBy(() -> transfers.transfer(changedTo))`changedTo` service 호출이 정상 반환하지 않고 기대한 예외를 던지는지 검사를 시작한다.
220 .isInstanceOfSatisfying(BusinessException.class,던져진 예외가 BusinessException인지 확인하고 내부 ErrorCode 검사도 이어 간다.
221 failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));잡은 BusinessException의 code가 IDEMPOTENCY_CONFLICT인지 확인한다.
223 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);DB에서 출발 계좌를 다시 읽어 잔액이 `9_000`인지 확인한다.
224 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);DB에서 도착 계좌를 다시 읽어 잔액이 `11_000`인지 확인한다.
225 assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);DB에서 세 번째 계좌를 다시 읽어 잔액이 `5_000`인지 확인한다.
226 assertOneTransferEffect();`assertOneTransferEffect` helper를 지금 호출해 새 DB 효과가 붙지 않았는지 확인한다.
227 }`}`는 도착 계좌 변경 conflict를 확인하는 `same_key_with_changed_to_conflicts_without_extra_effect` 시험 메서드를 닫는다.
229 @Test`@Test`는 바로 다음 claim 직후 RuntimeException rollback 메서드를 JUnit direct test로 표시한다.
230 void runtime_exception_after_claim_rolls_back_every_database_effect() {claim 직후 고장에서 전부 되돌리는 시험 본문을 시작한다.
231 failureHook.failAt(ControlledFailureHook.Point.AFTER_CLAIM);이번 시험에서는 claim 직후에 RuntimeException을 던지도록 hook을 맞춘다.
232 var command = new TransferService.Command("customer-1", "fail-claim", from.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `command`에 둔다.
234 assertThatThrownBy(() -> transfers.transfer(command))`command` service 호출이 정상 반환하지 않고 기대한 예외를 던지는지 검사를 시작한다.
235 .isInstanceOf(RuntimeException.class)던져진 예외 타입이 원본에 적힌 RuntimeException인지 확인한다.
236 .hasMessage("injected after claim");던져진 예외 message가 고장 지점의 exact 문구와 같은지 확인한다.
237 assertOnlyOpeningStateRemains();`assertOnlyOpeningStateRemains` helper를 지금 호출해 두 잔액 원복과 세 업무 표 0행을 확인한다.
238 }`}`는 claim 직후 예외의 rollback을 확인하는 `runtime_exception_after_claim_rolls_back_every_database_effect` 시험 메서드를 닫는다.
240 @Test`@Test`는 바로 다음 업무 변경 직후 RuntimeException rollback 메서드를 JUnit direct test로 표시한다.
241 void runtime_exception_after_business_mutation_rolls_back_every_database_effect() {돈 이동 직후 고장에서 전부 되돌리는 시험 본문을 시작한다.
242 failureHook.failAt(ControlledFailureHook.Point.AFTER_BUSINESS_MUTATION);이번 시험에서는 업무 변경 직후에 RuntimeException을 던지도록 hook을 맞춘다.
243 var command = new TransferService.Command("customer-1", "fail-business", from.getId(), to.getId(), 1_000);현재 시험의 actor·key·두 계좌·금액을 `TransferService.Command`로 묶어 `command`에 둔다.
245 assertThatThrownBy(() -> transfers.transfer(command))`command` service 호출이 정상 반환하지 않고 기대한 예외를 던지는지 검사를 시작한다.
246 .isInstanceOf(RuntimeException.class)던져진 예외 타입이 원본에 적힌 RuntimeException인지 확인한다.
247 .hasMessage("injected after business mutation");던져진 예외 message가 고장 지점의 exact 문구와 같은지 확인한다.
248 assertOnlyOpeningStateRemains();`assertOnlyOpeningStateRemains` helper를 지금 호출해 업무 변경 직후 예외의 DB 원복을 확인한다.
249 }`}`는 업무 변경 직후 예외의 rollback을 확인하는 `runtime_exception_after_business_mutation_rolls_back_every_database_effect` 시험 메서드를 닫는다.
251 private void assertOnlyOpeningStateRemains() {처음 연 계좌 상태만 남았는지 확인하는 helper 본문을 시작한다.
252 assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(10_000);DB에서 출발 계좌를 다시 읽어 잔액이 `10_000`인지 확인한다.
253 assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(10_000);DB에서 도착 계좌를 다시 읽어 잔액이 `10_000`인지 확인한다.
254 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();`idempotency_request` 전체 행 수를 한 줄 SQL로 구해 0인지 확인한다.
255 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")`business_tx`의 TRANSFER 행 수를 세는 multi-line SQL assertion을 시작한다.
256 .query(Long.class).single()).isZero();업무 거래표 query의 단일 long 결과가 0인지 확인한다.
257 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")`ledger_entry`의 TRANSFER 행 수를 세는 multi-line SQL assertion을 시작한다.
258 .query(Long.class).single()).isZero();원장표 query의 단일 long 결과가 0인지 확인한다.
259 }`}`는 두 잔액과 세 업무 표의 초기 상태를 검사하는 `assertOnlyOpeningStateRemains` helper를 닫는다.
261 private TransferService.Command commandFromJson(String key, String json) {평평한 JSON에서 Command를 만드는 시험 helper 본문을 시작한다.
262 return new TransferService.Command(caller에게 반환할 새 `TransferService.Command` constructor 호출을 시작한다.
263 "customer-1",Command의 actor 칸에 이 시험의 로그인 주체 `customer-1`을 넣는다.
264 key,Command의 idempotency key 칸에 helper가 입력받은 `key`를 넣는다.
265 semanticLong(json, "from"),JSON에서 `from`에 적힌 출발 계좌 숫자를 찾아 Command 칸에 넣는다.
266 semanticLong(json, "to"),JSON에서 `to`에 적힌 도착 계좌 숫자를 찾아 Command 칸에 넣는다.
267 semanticLong(json, "amount")JSON에서 `amount`에 적힌 이체 금액 숫자를 찾아 Command 칸에 넣는다.
268 );`);`는 `commandFromJson`이 반환할 `new TransferService.Command(...)` 생성자 호출과 return 문을 닫는다.
269 }`}`는 JSON의 세 의미 숫자를 Command로 만드는 `commandFromJson` helper를 닫는다.
271 private long semanticLong(String json, String field) {시험 JSON의 숫자 field 하나를 꺼내는 regex helper 본문을 시작한다.
272 var matcher = Pattern.compile("\\\"" + Pattern.quote(field) + "\\\"\\s*:\\s*(\\d+)").matcher(json);요청한 field 이름 뒤의 0 이상 십진 숫자를 찾는 시험용 정규식을 만들고 matcher를 얻는다.
273 if (!matcher.find()) throw new IllegalArgumentException("missing semantic field: " + field);요청한 숫자 field를 못 찾으면 빠르게 IllegalArgumentException을 던진다.
274 return Long.parseLong(matcher.group(1));정규식 첫 capture의 숫자 문자열을 long으로 바꿔 반환한다.
275 }`}`는 JSON에서 한 field의 long 값을 찾는 `semanticLong` helper를 닫는다.
277 private void assertOneTransferEffect() {이체 업무 효과가 한 번뿐인지 세 표로 확인하는 helper 본문을 시작한다.
278 assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")`business_tx`의 TRANSFER 행 수를 세는 첫 단일-effect assertion을 시작한다.
279 .query(Long.class).single()).isEqualTo(1);업무 거래표 query의 단일 long 결과가 `1`인지 확인한다.
280 assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")`ledger_entry`의 TRANSFER 행 수를 세는 둘째 단일-effect assertion을 시작한다.
281 .query(Long.class).single()).isEqualTo(2);원장표 query의 단일 long 결과가 `2`인지 확인한다.
282 assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")`idempotency_request` 전체 행 수를 세는 셋째 단일-effect assertion을 시작한다.
283 .query(Long.class).single()).isEqualTo(1);멱등 요청표 query의 단일 long 결과가 `1`인지 확인한다.
284 }`}`는 거래1·원장2·claim1을 검사하는 `assertOneTransferEffect` helper를 닫는다.
286 private TransferService.Result invokeAfterBarrier(`invokeAfterBarrier` helper의 반환 type과 메서드 이름을 선언하고 parameter 목록을 열기 시작한다.
287 CountDownLatch ready, CountDownLatch start, TransferService.Command command`invokeAfterBarrier`의 세 parameter `CountDownLatch ready`, `CountDownLatch start`, `TransferService.Command command`를 선언한다.
288 ) throws Exception {parameter 목록을 닫고 `throws Exception`을 선언한 뒤 `{`로 `invokeAfterBarrier` helper 본문을 연다.
289 ready.countDown();현재 worker가 출발선에 도착했음을 ready latch에 한 명 알린다.
290 start.await();공통 start 신호가 열릴 때까지 현재 worker를 기다리게 한다.
291 return transfers.transfer(command);준비 장벽을 통과한 worker가 service 이체 결과를 그대로 반환한다.
292 }`}`는 barrier 뒤 service를 호출하는 `invokeAfterBarrier` helper를 닫는다.
293}`}`는 아홉 시험과 준비·실패주입·검수 helper들을 담은 `TransferIntegrationTest` class를 닫는다.
07

STEP 07 / 13

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

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

한 줄로 읽기

294줄·@Test 9개 class에서 exact amount-conflict 한 메서드만 골라 1,000원 첫 효과 하나와 2,000원 변경 요청의 IDEMPOTENCY_CONFLICT를 확인한다.

문법 해부

  • @Import는 test failure hook configuration을 Spring context에 합친다.
  • assertThatThrownBy(...).isInstanceOfSatisfying은 예외 type 안에서 ErrorCode도 검사한다.
  • CountDownLatch와 Future를 쓰는 다른 test도 같은 class에 있지만 selector가 아니면 실행되지 않는다.
  • helper method는 여러 test의 DB count assertion을 재사용한다.

실행 순서

  1. BeforeEach가 네 table을 비우고 A/B를 각각 10,000으로 연다.
  2. selected test가 key=conflict-key, amount=1,000 command를 실행한다.
  3. 같은 key와 계좌에서 amount=2,000 command를 호출한다.
  4. BusinessException의 code가 IDEMPOTENCY_CONFLICT인지 본다.
  5. 잔액 9,000/11,000과 transfer tx 1, ledger 2, idempotency 1을 확인한다.

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

F06-C01 · package and imports
문법 해부
transfer integration test의 package와 account·API error·ledger repository, JUnit/Spring/JDBC, test bean, AssertJ, collections, concurrency, regex import를 준비한다.
실제 값 추적
compiler는 Account repositories, API errors, Spring/JUnit/JDBC, collections, concurrency, regex와 static assertion symbols를 해석할 수 있게 된다.
정상 예
AccountRepository, LedgerEntryRepository, CountDownLatch, Pattern과 assertThat을 qualified name 없이 참조할 수 있는 상태가 유효한 import 결과다.
틀린 예·반례
많은 import를 보고 이 compilation unit의 tests가 실행됐거나 어떤 external runner에 선택됐다고 판단하면 안 된다.
착각 방지
package/import 25개 비공백 준비줄은 translation에는 남지만 mapping 대상에서는 제외된다.
하지 않는 일
이 범위는 executable statement를 수행하지 않고 source identity나 build composition도 증명하지 않는다.
다음 연결
다음 ‘test configuration bean’ 범위에서는 @SpringBootTest와 @Import가 TransferIntegrationTest context에 FailureHookConfiguration을 넣고 @Bean method가 ControlledFailureHook instance를 제공한다.
F06-C02 · test configuration bean
문법 해부
@SpringBootTest와 @Import가 TransferIntegrationTest context에 FailureHookConfiguration을 넣고 @Bean method가 ControlledFailureHook instance를 제공한다.
실제 값 추적
Spring이 test class를 만들 때 production hook 자리에 조절 가능한 test hook bean을 주입할 후보가 생긴다.
정상 예
controlledFailureHook bean method가 새 ControlledFailureHook instance를 반환해 test context 후보로 제공하는 흐름이 유효하다.
틀린 예·반례
test bean 등록만으로 callback이 service transaction 안의 올바른 시점에 호출된다고 증명되지는 않는다.
착각 방지
이 configuration은 test 전용이며 application production wiring과 동일하다고 단정하지 않는다.
하지 않는 일
bean factory 범위는 database row·balance·idempotency effect를 직접 검사하지 않는다.
다음 연결
다음 ‘controlled failure hook’ 범위에서는 ControlledFailureHook는 NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION state를 volatile field에 두고 failAt/reset으로 바꾸며 두 callback에서 선택 지점이면 message가 다른 RuntimeException을 던진다.
F06-C03 · controlled failure hook
문법 해부
ControlledFailureHook는 NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION state를 volatile field에 두고 failAt/reset으로 바꾸며 두 callback에서 선택 지점이면 message가 다른 RuntimeException을 던진다.
실제 값 추적
failAt은 point를 지정값으로 바꾸고 reset은 NONE으로 돌리며, 각 callback은 자신의 matching point에서 서로 다른 injected message로 예외를 낸다.
정상 예
AFTER_CLAIM 설정은 afterClaim만, AFTER_BUSINESS_MUTATION 설정은 afterBusinessMutation만 실패시키는 것이 정상 제어 흐름이다.
틀린 예·반례
volatile visibility를 transaction rollback guarantee나 callback exactly-once guarantee로 읽으면 안 된다.
착각 방지
hook이 예외를 던지는 동작만으로 surrounding transaction의 database rollback까지 증명되지는 않는다.
하지 않는 일
process crash, network failure, 두 callback 밖의 예외, external side effect는 이 test hook 책임이 아니다.
다음 연결
다음 ‘dependencies and two-account fixture’ 범위에서는 accounts·openings·ledger·transfers·jdbc·failureHook을 주입하고 from/to field를 둔 뒤 setUp이 네 table truncate, hook reset, A와 B account를 각각 10000으로 연다.
F06-C04 · dependencies and two-account fixture
문법 해부
accounts·openings·ledger·transfers·jdbc·failureHook을 주입하고 from/to field를 둔 뒤 setUp이 네 table truncate, hook reset, A와 B account를 각각 10000으로 연다.
실제 값 추적
각 test는 balance 합 20000, opening account 두 개, failure point NONE인 deterministic fixture에서 시작한다.
정상 예
A=10000과 B=10000이 persisted되고 repository·ledger·service 접근 통로가 모두 준비된 상태가 valid baseline이다.
틀린 예·반례
opening 두 번은 application persistence effect를 만들 수 있으므로 setUp 종료 상태를 모든 table이 빈 상태라고 오해하면 안 된다.
착각 방지
setUp은 test isolation을 돕지만 같은 DB를 병렬 process와 공유할 때 안전한 namespace를 만들지는 않는다.
하지 않는 일
fixture 준비만으로 이후 test method의 업무 결과나 concurrency outcome은 보장되지 않는다.
다음 연결
다음 ‘same-key replay test’ 범위에서는 same-key replay test는 amount 3000 command를 두 번 호출해 first.replayed false, replay true, balances 7000/13000, ledger total count 4, TRANSFER signed sum 0을 검사한다.
F06-C05 · same-key replay test
문법 해부
same-key replay test는 amount 3000 command를 두 번 호출해 first.replayed false, replay true, balances 7000/13000, ledger total count 4, TRANSFER signed sum 0을 검사한다.
실제 값 추적
첫 transfer가 A에서 B로 3000을 한 번 옮기고 같은 key 재호출은 추가 effect 없이 stored result를 replay한다.
정상 예
두 opening ledger와 transfer ledger 두 개를 합친 count 4, transfer 부호합 0이 source assertion과 맞는 정상 예다.
틀린 예·반례
ledger count 4를 transfer ledger 네 개로 읽거나 replay가 새 business transaction을 쓴다고 보면 안 된다.
착각 방지
이 test는 same key·same Command object를 순차로 두 번 호출한 경우만 직접 다루며 다른 request variation으로 일반화하지 않는다.
하지 않는 일
request fingerprint 저장 형식, response full equality, crash recovery는 여기서 검증하지 않는다.
다음 연결
다음 ‘JSON representation replay test’ 범위에서는 JSON representation test는 field 순서와 whitespace가 다른 firstJson·variantJson을 같은 representation-key로 commandFromJson에 각각 넘기고 replay flags, balances 9000/11000, effect helper를 검사한다.
F06-C06 · JSON representation replay test
문법 해부
JSON representation test는 field 순서와 whitespace가 다른 firstJson·variantJson을 같은 representation-key로 commandFromJson에 각각 넘기고 replay flags, balances 9000/11000, effect helper를 검사한다.
실제 값 추적
첫 호출의 replayed는 false, 둘째는 true이고 persisted balances가 9000과 11000인 뒤 assertOneTransferEffect가 정상 반환해야 한다.
정상 예
source에 적힌 두 JSON literals가 같은 representation-key 아래 첫 처리와 replay로 관찰되는 경우가 이 range의 Green example이다.
틀린 예·반례
이 두 inputs의 관찰만으로 escaped duplicate fields, negative number, nested object 같은 다른 JSON forms까지 같은 결과라고 주장하면 안 된다.
착각 방지
commandFromJson 내부 mechanism은 이 range에 없으므로 text 차이의 observable replay와 parser 구현 원리를 같은 proof로 합치지 않는다.
하지 않는 일
smoke runner가 선택한 method가 아니어서 W14 exact selector 실행만으로 이 test가 수행됐다고 볼 수 없다.
다음 연결
다음 ‘same-key twenty-request test’ 범위에서는 same-key concurrency test는 같은 command를 worker 20개에 barrier로 동시에 보내고 결과를 모아 non-replayed exactly 1, balances 9000/11000, ledger count 4, single transfer effect를 검사한다.
F06-C07 · same-key twenty-request test
문법 해부
same-key concurrency test는 같은 command를 worker 20개에 barrier로 동시에 보내고 결과를 모아 non-replayed exactly 1, balances 9000/11000, ledger count 4, single transfer effect를 검사한다.
실제 값 추적
20 request가 같은 burst-key와 동일 payload를 사용하면 한 호출만 business mutation을 하고 나머지 19개는 replay result가 되어야 한다.
정상 예
ready가 5초 안에 모이고 각 Future.get이 자기 15초 제한 안에 반환한 한 run에서 original count 1과 final effect 한 번을 확인하는 것이 valid observation이다.
틀린 예·반례
한 scheduler run 성공을 모든 interleaving·node·database에서 exactly-once가 보장된 formal proof로 확대할 수 없다.
착각 방지
ledger count 4에는 opening entries가 포함되며 마지막 helper call은 별도의 transfer-effect cardinality contract를 검사한다.
하지 않는 일
이 local test도 W14 runner의 F06 method selector 밖인 unselected 8개 중 하나다.
다음 연결
다음 ‘opposite-direction transfer test’ 범위에서는 opposite-direction test는 perDirection=10으로 각 방향 10개씩 task 20개를 barrier로 풀고 future를 회수한 뒤 from/to 각각 10000, 합 20000, ledger 전체 count 42를 검사한다.
F06-C08 · opposite-direction transfer test
문법 해부
opposite-direction test는 perDirection=10으로 각 방향 10개씩 task 20개를 barrier로 풀고 future를 회수한 뒤 from/to 각각 10000, 합 20000, ledger 전체 count 42를 검사한다.
실제 값 추적
100씩 A→B 열 번과 B→A 열 번이 모두 성공하면 개별 balance가 원점으로 돌아오고 opening 2 + transfer 40 ledger row가 남는다.
정상 예
ready가 5초 안에 모이고 각 Future.get이 자기 20초 제한 안에 반환한 뒤 네 assertion이 맞는 경우가 Green이다.
틀린 예·반례
한 번의 balanced workload는 보편 deadlock freedom이나 ledger signed sum 0을 직접 assertion하지 않는다.
착각 방지
F04와 달리 이 source는 개별 balance와 ledger count 42를 보지만 TRANSFER signed_amount SUM query는 없다.
하지 않는 일
이 method의 source-local assertion과 external runner가 실제로 선택한 execution 범위는 별도 evidence로 확인해야 한다.
다음 연결
다음 ‘selected amount-conflict test’ 범위에서는 selected conflict test는 같은 conflict-key로 amount 1000 first와 amount 2000 changed command를 만들고 첫 호출 뒤 두 번째가 IDEMPOTENCY_CONFLICT인지 검사하며 balances 9000/11000, transfer business 1, transfer ledger 2, request 1을 센다.
F06-C09 · selected amount-conflict test
문법 해부
selected conflict test는 같은 conflict-key로 amount 1000 first와 amount 2000 changed command를 만들고 첫 호출 뒤 두 번째가 IDEMPOTENCY_CONFLICT인지 검사하며 balances 9000/11000, transfer business 1, transfer ledger 2, request 1을 센다.
실제 값 추적
첫 semantic request만 effect를 만들고 amount만 바뀐 재사용은 BusinessException conflict로 거절되어 extra row가 생기지 않는다.
정상 예
이 method가 W14 runner에 exact method selector로 적힌 F06의 유일한 selected test이며 source-local assertion 다섯 묶음을 제공한다.
틀린 예·반례
class의 local @Test가 9개라는 사실을 runner가 아홉 개 모두 실행한다는 뜻으로 바꾸면 안 된다.
착각 방지
conflict exception code와 no-extra-effect를 직접 보지만 source SHA가 runner evidence에 기록되지는 않는다.
하지 않는 일
changed from/to, JSON equivalence, concurrency, rollback 8개 test는 이 selector의 proof scope 밖이다.
다음 연결
다음 ‘changed-from conflict test’ 범위에서는 changed-from test는 third account C=5000을 열고 같은 key·to·amount에서 from만 original A에서 C로 바꿔 second call conflict를 요구하며 A/B/C balances 9000/11000/5000과 single effect를 검사한다.
F06-C10 · changed-from conflict test
문법 해부
changed-from test는 third account C=5000을 열고 같은 key·to·amount에서 from만 original A에서 C로 바꿔 second call conflict를 요구하며 A/B/C balances 9000/11000/5000과 single effect를 검사한다.
실제 값 추적
first A→B 1000만 적용되고 changedFrom C→B는 거절되어 C balance가 5000에 머문다.
정상 예
from account id가 semantic idempotency fingerprint에 포함된다는 direct counterexample가 source assertion과 맞다.
틀린 예·반례
C account opening이 만든 별도 opening row까지 single transfer effect로 세면 count 해석이 틀어진다.
착각 방지
이 test의 conflict는 amount-conflict selected method와 다른 field 변경을 다루며 W14 smoke에서 선택되지 않는다.
하지 않는 일
owner 변경, currency, account status 같은 다른 semantic field 포함 여부는 보장하지 않는다.
다음 연결
다음 ‘changed-to conflict test’ 범위에서는 changed-to test는 C=5000을 만들고 같은 key·from·amount에서 destination만 B에서 C로 바꿔 conflict를 기대하며 A/B/C balances와 single effect를 확인한다.
F06-C11 · changed-to conflict test
문법 해부
changed-to test는 C=5000을 만들고 같은 key·from·amount에서 destination만 B에서 C로 바꿔 conflict를 기대하며 A/B/C balances와 single effect를 확인한다.
실제 값 추적
첫 A→B 1000은 적용되고 changedTo A→C는 거절되어 B=11000, C=5000이 유지된다.
정상 예
to account id가 semantic fingerprint에 포함되어야 같은 key의 다른 destination을 막는다는 사례가 Green이다.
틀린 예·반례
from-conflict test와 구조가 비슷해도 바뀐 field와 balance counterexample를 서로 바꾸어 쓰면 안 된다.
착각 방지
이 method는 local source에는 존재하지만 exact runner selector가 가리키는 amount-conflict method가 아니다.
하지 않는 일
destination authorization과 beneficiary validation은 idempotency conflict assertion의 책임 밖이다.
다음 연결
다음 ‘after-claim rollback test’ 범위에서는 after-claim rollback test는 hook을 AFTER_CLAIM으로 설정하고 fail-claim amount 1000 command가 RuntimeException injected after claim을 던지는지 확인한 뒤 assertOnlyOpeningStateRemains를 호출한다.
F06-C12 · after-claim rollback test
문법 해부
after-claim rollback test는 hook을 AFTER_CLAIM으로 설정하고 fail-claim amount 1000 command가 RuntimeException injected after claim을 던지는지 확인한 뒤 assertOnlyOpeningStateRemains를 호출한다.
실제 값 추적
hook은 AFTER_CLAIM으로 바뀌고 amount 1000 command 호출에서 expected RuntimeException message가 확인된 뒤 opening-state helper가 호출된다.
정상 예
RuntimeException type과 injected after claim message가 맞고 assertOnlyOpeningStateRemains 호출이 정상 반환하는 것이 이 범위에 보이는 valid flow다.
틀린 예·반례
helper call을 읽지 않고 이 range 자체에 zero query가 있다고 쓰면 assertion 위치를 잘못 옮긴다.
착각 방지
injected failure는 controlled callback이며 process kill이나 external message publish rollback과 동일하지 않다.
하지 않는 일
이 rollback method는 W14 runner의 selected amount-conflict test 밖이라 targeted smoke로 실행됐다고 주장하지 않는다.
다음 연결
다음 ‘after-business rollback test’ 범위에서는 after-business rollback test는 AFTER_BUSINESS_MUTATION을 선택하고 fail-business command가 injected after business mutation message의 RuntimeException을 내는지 본 뒤 같은 opening-state helper를 호출한다.
F06-C13 · after-business rollback test
문법 해부
after-business rollback test는 AFTER_BUSINESS_MUTATION을 선택하고 fail-business command가 injected after business mutation message의 RuntimeException을 내는지 본 뒤 같은 opening-state helper를 호출한다.
실제 값 추적
hook은 AFTER_BUSINESS_MUTATION으로 바뀌고 amount 1000 command에서 expected RuntimeException message를 확인한 뒤 opening-state helper를 호출한다.
정상 예
injected after business mutation message가 맞고 assertOnlyOpeningStateRemains 호출이 정상 반환하는 것이 이 범위에 보이는 valid flow다.
틀린 예·반례
business mutation 뒤라는 이름만으로 external side effect까지 취소됐다고 확대할 수 없다.
착각 방지
exception message가 다른 두 hook test를 바꾸어 적으면 first failure boundary를 잃는다.
하지 않는 일
database-local rollback 이외의 retry policy·error response·outbox는 이 test가 다루지 않는다.
다음 연결
다음 ‘opening-state rollback assertions’ 범위에서는 assertOnlyOpeningStateRemains는 from/to balance가 각각 10000인지 확인하고 idempotency_request 0, TRANSFER business_tx 0, TRANSFER ledger_entry 0을 검사한다.
F06-C14 · opening-state rollback assertions
문법 해부
assertOnlyOpeningStateRemains는 from/to balance가 각각 10000인지 확인하고 idempotency_request 0, TRANSFER business_tx 0, TRANSFER ledger_entry 0을 검사한다.
실제 값 추적
failure test 뒤 두 opening balance와 opening effects만 남고 transfer 관련 세 table effect는 하나도 없어야 한다.
정상 예
C12와 C13에서 이 helper가 통과하면 관찰 시점의 balances와 세 transfer-related counts가 opening baseline이라는 직접 evidence가 된다.
틀린 예·반례
ledger 전체 count 0을 요구하는 것이 아니라 TRANSFER_% entry만 0이므로 opening ledger는 남아도 정상이다.
착각 방지
F05 shared helper와 달리 이 helper는 balances까지 직접 assertion한다는 차이를 보존한다.
하지 않는 일
account metadata, sequence gaps, logs, external notification은 opening-state 네 assertion 묶음에 포함되지 않는다.
다음 연결
다음 ‘JSON semantic helpers’ 범위에서는 commandFromJson은 from·to·amount를 semanticLong으로 뽑아 Transfer Command를 만들고, semanticLong은 quoted field 뒤 digits를 regex로 찾아 long으로 parse하며 없으면 IllegalArgumentException을 던진다.
F06-C15 · JSON semantic helpers
문법 해부
commandFromJson은 from·to·amount를 semanticLong으로 뽑아 Transfer Command를 만들고, semanticLong은 quoted field 뒤 digits를 regex로 찾아 long으로 parse하며 없으면 IllegalArgumentException을 던진다.
실제 값 추적
field 순서와 whitespace가 달라도 각 이름에 대응하는 첫 numeric capture가 같은 long이면 두 command의 semantic values가 같아진다.
정상 예
`{"from":1,"to":2,"amount":1000}`처럼 세 양의 정수 field가 있으면 command 생성이 성공한다.
틀린 예·반례
negative amount, decimal, quoted number, nested duplicate field는 이 regex가 일반 JSON parser처럼 처리한다고 보장할 수 없다.
착각 방지
semantic equality proof는 세 numeric field에 한정되고 raw JSON canonicalization 전체를 의미하지 않는다.
하지 않는 일
malformed JSON syntax 자체, overflow, duplicate-key policy, escaping은 이 helper의 책임 밖이다.
다음 연결
다음 ‘single-transfer effect helper’ 범위에서는 assertOneTransferEffect는 TRANSFER business_tx count 1, TRANSFER_% ledger_entry count 2, idempotency_request count 1을 검사한다.
F06-C16 · single-transfer effect helper
문법 해부
assertOneTransferEffect는 TRANSFER business_tx count 1, TRANSFER_% ledger_entry count 2, idempotency_request count 1을 검사한다.
실제 값 추적
JSON representation·same-key concurrency·changed-from·changed-to callers의 첫 성공 transfer가 business 1·ledger 2·claim 1 footprint를 남겼는지 센다.
정상 예
그 네 callers가 이 helper를 통과하면 각 scenario에서 추가 transfer effect가 없다는 세-count evidence가 된다.
틀린 예·반례
세 count가 맞아도 from/to balance나 ledger amount·direction이 옳다는 보장은 없고 호출 test의 별도 assertion이 필요하다.
착각 방지
opening business/ledger row는 WHERE tx_type과 entry_type filter 때문에 이 helper의 count에서 제외된다.
하지 않는 일
source-local footprint를 production exactly-once·cross-service delivery guarantee로 확대하지 않는다.
다음 연결
다음 ‘barrier invocation helper’ 범위에서는 invokeAfterBarrier는 ready latch를 감소시키고 start latch를 기다린 뒤 받은 Transfer Command를 service에 전달해 Result를 반환한다.
F06-C17 · barrier invocation helper
문법 해부
invokeAfterBarrier는 ready latch를 감소시키고 start latch를 기다린 뒤 받은 Transfer Command를 service에 전달해 Result를 반환한다.
실제 값 추적
worker들이 모두 준비 signal을 낸 후 caller가 start를 열면 opposite-direction command들이 비슷한 시점에 진입한다.
정상 예
barrier helper가 command를 바꾸지 않고 transfer result를 Future에 돌려주는 흐름은 concurrent test와 맞다.
틀린 예·반례
latch 동시 출발이 실제 database lock 획득 순서나 완전한 simultaneous execution을 강제하지는 않는다.
착각 방지
이 helper는 F04 invoke와 달리 checked Exception을 caller로 전달하며 interrupt를 자체 변환하지 않는다.
하지 않는 일
balance·ledger·deadlock 판정은 caller test의 future timeout과 assertions가 맡는다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 balance·ledger·deadlock 판정은 caller test의 future timeout과 assertions가 맡는다.
10,000 두 계좌히토리 → 니지카 → 료 → 키타
  1. 히토리

    fixture의 A와 B는 왜 둘 다 10,000이야?

  2. 니지카

    변경 전후를 손계산하기 쉬운 대칭 시작값이다.

  3. 선택 test에서는 1,000 이동 뒤 9,000/11,000을 기대한다.

  4. 키타

    초기값과 결과를 연결하겠습니다.

changed to히토리 → 니지카 → 료 → 키타
  1. 히토리

    도착 계좌 변경도 같은 assertion으로 실행됐지?

  2. 니지카

    source에는 있지만 exact selector에는 포함되지 않는다.

  3. 존재와 실행을 또 구분해야 해.

  4. 키타

    비선택 test로 표시하겠습니다.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1첫 command 1,000TransferService가 from A에서 to B로 한 번 반영한다.A=9,000, B=11,000첫 호출 자체의 replay flag는 selected method가 assert하지 않는다.
2같은 key, amount 2,000semantic hash 불일치 경로가 BusinessException을 낸다.ErrorCode.IDEMPOTENCY_CONFLICTchanged-from/to는 별도 test이고 이번 selector 밖이다.
3충돌 뒤 DB첫 효과 외 추가 row가 생겼는지 세 count로 확인한다.transfer tx=1, transfer ledger=2, idempotency=1audit_event·HTTP response·stale PROCESSING은 읽지 않는다.
기본 replay test히토리 → 니지카 → 료 → 키타
  1. 히토리

    첫 test가 replay를 확인하니 이번 selector도 replay test지?

  2. 니지카

    그 메서드는 source에만 있고 이번에 고른 것은 amount conflict다.

  3. 메서드 이름을 selector와 정확히 맞춰.

  4. 키타

    옆 test 결과를 빌려오지 않을게요.

after claim rollback히토리 → 니지카 → 료 → 키타
  1. 히토리

    failureHook의 AFTER_CLAIM도 이번 smoke에서 켜져?

  2. 니지카

    아니, 별도 rollback method가 켠다.

  3. selected conflict test에서는 point가 NONE이다.

  4. 키타

    hook branch를 과장하지 않을게요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

Gradle test filtering

class.method exact selector가 이 class의 한 test method를 선택한다.

source-local test 개수와 실제 smoke 실행 개수는 다르다.
Spring transaction/IdempotencyStore

첫 claim과 business effect를 저장하고 changed payload를 conflict로 분기한다.

selected test는 내부 hash 문자열이나 SQL statement를 직접 읽지 않는다.
PostgreSQL assertions

balance 두 값과 세 count가 추가 효과 없음의 관찰값이다.

이 다섯 관찰값 밖의 부작용은 별도 test가 필요하다.

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

transfer_and_same_key_replay_once

Arrange · 준비
  • both accounts customer-1 and balance 10000; key-1; amount 3000; two opening ledger rows
Act · 행동
  • `transfer_and_same_key_replay_once` 원본 본문을 실행한다.
Assert · 확인
  • first replayed=false
  • second replayed=true
  • balances 7000 and 13000
  • ledger total 4
  • TRANSFER signed sum 0
직접 보장
  • 이 고정 fixture에서 first replayed=false.
  • 이 고정 fixture에서 second replayed=true.
  • 이 고정 fixture에서 balances 7000 and 13000.
  • 이 고정 fixture에서 ledger total 4.
  • 이 고정 fixture에서 TRANSFER signed sum 0.
보장하지 않음
  • HTTP status
  • exact response-body equality
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → failureHook.reset → 두 계좌 open이 가장 먼저 실패할 수 있다 → act: 첫 service 호출과 같은 Command 재호출 → assert: false·true replay 표시, 두 잔액, 원장 수와 부호 합 순서로 실패 경계를 만난다.

json_field_order_and_whitespace_variant_replays_without_extra_effect

Arrange · 준비
  • two flat numeric JSON strings with reordered from/to/amount and whitespace; regex helper extracts digits; amount 1000
Act · 행동
  • `json_field_order_and_whitespace_variant_replays_without_extra_effect` 원본 본문을 실행한다.
Assert · 확인
  • first replayed=false
  • variant replayed=true
  • balances 9000 and 11000
  • TRANSFER business_tx 1
  • TRANSFER ledger 2
  • idempotency rows 1
직접 보장
  • 이 고정 fixture에서 first replayed=false.
  • 이 고정 fixture에서 variant replayed=true.
  • 이 고정 fixture에서 balances 9000 and 11000.
  • 이 고정 fixture에서 TRANSFER business_tx 1.
  • 이 고정 fixture에서 TRANSFER ledger 2.
  • 이 고정 fixture에서 idempotency rows 1.
보장하지 않음
  • a production JSON canonicalizer
  • nested/string/negative/missing-field JSON support
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: 정순서 JSON 변환·호출 뒤 변형 JSON 변환·호출 → assert: false·true, 두 잔액, 단일 DB effect 순서이며 regex가 숫자를 못 찾으면 act에서 멈춘다.

same_key_concurrent_requests_change_business_once

Arrange · 준비
  • 20 tasks, fixed pool 20, same command/customer-1/burst-key/amount 1000, two opening rows
  • ready.await: 5 seconds
  • Future.get: 15 seconds per Future, not one global deadline
Act · 행동
  • `same_key_concurrent_requests_change_business_once` 원본 본문을 실행한다.
Assert · 확인
  • all 20 Futures return
  • non-replayed count 1 and therefore replayed count 19
  • balances 9000 and 11000
  • ledger total 4
  • TRANSFER business_tx 1
  • TRANSFER ledger 2
  • idempotency rows 1
직접 보장
  • 이 고정 fixture에서 all 20 Futures return.
  • 이 고정 fixture에서 non-replayed count 1 and therefore replayed count 19.
  • 이 고정 fixture에서 balances 9000 and 11000.
  • 이 고정 fixture에서 ledger total 4.
  • 이 고정 fixture에서 TRANSFER business_tx 1.
  • 이 고정 fixture에서 TRANSFER ledger 2.
  • 이 고정 fixture에서 idempotency rows 1.
보장하지 않음
  • 50- or 100-request service burst
  • IN_PROGRESS classification
  • HTTP behavior
  • production-scale exactly-once
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: worker20 제출, ready 5초 확인, start 해제, Future별 15초 회수 → assert: non-replay1, 두 잔액, 원장4, 거래1·원장2·claim1 순서다.

opposite_direction_transfers_preserve_total_balance

Arrange · 준비
  • 10 A-to-B plus 10 B-to-A tasks, each amount 100; pool 20; both starting balances 10000
  • ready.await: 5 seconds
  • Future.get: 20 seconds per Future, not one global deadline
Act · 행동
  • `opposite_direction_transfers_preserve_total_balance` 원본 본문을 실행한다.
Assert · 확인
  • both final balances 10000
  • combined balance 20000
  • ledger total 42: two opening plus forty transfer entries
직접 보장
  • 이 고정 fixture에서 both final balances 10000.
  • 이 고정 fixture에서 combined balance 20000.
  • 이 고정 fixture에서 ledger total 42: two opening plus forty transfer entries.
보장하지 않음
  • absolute deadlock freedom
  • arbitrary load
  • latency or p95
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: 양방향20 제출, ready 5초 확인, start 해제, Future별 20초 회수 → assert: 두 잔액, 합계20,000, opening 포함 원장42 순서다.

same_key_with_different_semantic_request_conflicts_without_extra_effect

Arrange · 준비
  • same key; first amount 1000, changed amount 2000; starting balances 10000/10000
Act · 행동
  • `same_key_with_different_semantic_request_conflicts_without_extra_effect` 원본 본문을 실행한다.
Assert · 확인
  • IDEMPOTENCY_CONFLICT
  • balances 9000/11000
  • TRANSFER business_tx 1
  • TRANSFER ledger 2
  • idempotency rows 1
직접 보장
  • 이 고정 fixture에서 IDEMPOTENCY_CONFLICT.
  • 이 고정 fixture에서 balances 9000/11000.
  • 이 고정 fixture에서 TRANSFER business_tx 1.
  • 이 고정 fixture에서 TRANSFER ledger 2.
  • 이 고정 fixture에서 idempotency rows 1.
보장하지 않음
  • HTTP 409 body
  • 같은 class의 나머지 8개 source-local @Test가 W14 smoke에서 실행됨
  • same-key20·AtomicClaim50·ConcurrentWithdraw20 envelope 실행
  • amount 외 모든 semantic field·actor·operation matrix

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: 원본 금액 호출 뒤 바꾼 금액 호출 → assert: BusinessException과 IDEMPOTENCY_CONFLICT를 먼저 확인한 뒤 잔액과 tx1·ledger2·claim1을 검사한다.

same_key_with_changed_from_conflicts_without_extra_effect

Arrange · 준비
  • same key; changed source is a third customer-1 account with balance 5000
Act · 행동
  • `same_key_with_changed_from_conflicts_without_extra_effect` 원본 본문을 실행한다.
Assert · 확인
  • IDEMPOTENCY_CONFLICT
  • balances 9000/11000/5000
  • TRANSFER business_tx 1
  • TRANSFER ledger 2
  • idempotency rows 1
직접 보장
  • 이 고정 fixture에서 IDEMPOTENCY_CONFLICT.
  • 이 고정 fixture에서 balances 9000/11000/5000.
  • 이 고정 fixture에서 TRANSFER business_tx 1.
  • 이 고정 fixture에서 TRANSFER ledger 2.
  • 이 고정 fixture에서 idempotency rows 1.
보장하지 않음
  • HTTP 409 body
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: C 계좌 open, 원본 호출, 출발지만 C로 바꾼 호출 → assert: exact conflict code, A·B·C 잔액, 단일 DB effect 순서다.

same_key_with_changed_to_conflicts_without_extra_effect

Arrange · 준비
  • same key; changed destination is a third customer-1 account with balance 5000
Act · 행동
  • `same_key_with_changed_to_conflicts_without_extra_effect` 원본 본문을 실행한다.
Assert · 확인
  • IDEMPOTENCY_CONFLICT
  • balances 9000/11000/5000
  • TRANSFER business_tx 1
  • TRANSFER ledger 2
  • idempotency rows 1
직접 보장
  • 이 고정 fixture에서 IDEMPOTENCY_CONFLICT.
  • 이 고정 fixture에서 balances 9000/11000/5000.
  • 이 고정 fixture에서 TRANSFER business_tx 1.
  • 이 고정 fixture에서 TRANSFER ledger 2.
  • 이 고정 fixture에서 idempotency rows 1.
보장하지 않음
  • HTTP 409 body
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: C 계좌 open, 원본 호출, 도착지만 C로 바꾼 호출 → assert: exact conflict code, A·B·C 잔액, 단일 DB effect 순서다.

runtime_exception_after_claim_rolls_back_every_database_effect

Arrange · 준비
  • both balances 10000; amount 1000; failure after claim
Act · 행동
  • `runtime_exception_after_claim_rolls_back_every_database_effect` 원본 본문을 실행한다.
Assert · 확인
  • exact message injected after claim
  • balances restored to 10000/10000
  • idempotency rows 0
  • TRANSFER business_tx rows 0
  • TRANSFER ledger rows 0
직접 보장
  • 이 고정 fixture에서 exact message injected after claim.
  • 이 고정 fixture에서 balances restored to 10000/10000.
  • 이 고정 fixture에서 idempotency rows 0.
  • 이 고정 fixture에서 TRANSFER business_tx rows 0.
  • 이 고정 fixture에서 TRANSFER ledger rows 0.
보장하지 않음
  • process hard-kill
  • external systems
  • checked exceptions
  • completed-then-network-loss recovery
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: AFTER_CLAIM 선택 뒤 service 호출 → assert: RuntimeException 타입, injected after claim message, 두 잔액10,000과 세 업무 표0행 순서다.

runtime_exception_after_business_mutation_rolls_back_every_database_effect

Arrange · 준비
  • both balances 10000; amount 1000; failure after business mutation
Act · 행동
  • `runtime_exception_after_business_mutation_rolls_back_every_database_effect` 원본 본문을 실행한다.
Assert · 확인
  • exact message injected after business mutation
  • balances restored to 10000/10000
  • idempotency rows 0
  • TRANSFER business_tx rows 0
  • TRANSFER ledger rows 0
직접 보장
  • 이 고정 fixture에서 exact message injected after business mutation.
  • 이 고정 fixture에서 balances restored to 10000/10000.
  • 이 고정 fixture에서 idempotency rows 0.
  • 이 고정 fixture에서 TRANSFER business_tx rows 0.
  • 이 고정 fixture에서 TRANSFER ledger rows 0.
보장하지 않음
  • process hard-kill
  • external systems
  • checked exceptions
  • completed-then-network-loss recovery
  • W14 exact method selector가 이 source-local @Test를 실행함

첫 실패 경계 @BeforeEach setUp의 TRUNCATE → reset → 두 계좌 open이 먼저다 → act: AFTER_BUSINESS_MUTATION 선택 뒤 service 호출 → assert: RuntimeException 타입, exact message, 두 잔액10,000과 세 업무 표0행 순서다.

10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ class에 @Test 9개가 있으니 smoke도 9개를 실행한다.

왜 틀리나 selector에 exact method명이 붙어 있다.

바르게 읽기 source-local 9와 selected 1을 따로 센다.

반례 same_key_concurrent_requests_change_business_once는 파일에 있지만 선택되지 않는다.

❌ 같은 key면 amount가 달라도 첫 응답을 replay한다.

왜 틀리나 semantic request가 달라 conflict여야 한다.

바르게 읽기 같은 key+같은 의미와 같은 key+다른 의미를 구분한다.

반례 1,000원 뒤 2,000원이 replay되면 잘못된 금액 요청을 숨긴다.

❌ 추가 효과 0은 DB 전체 행이 0이라는 뜻이다.

왜 틀리나 opening과 첫 transfer 효과는 의도적으로 남는다.

바르게 읽기 첫 transfer tx 1·ledger 2·claim 1이라는 정확한 수치를 말한다.

반례 COUNT(*)=0을 기대하면 정상 첫 요청까지 지워진다.

❌ same-key20과 AtomicClaim50도 W14 smoke가 검증한다.

왜 틀리나 runner의 six selector에 그 method/class가 없다.

바르게 읽기 그 숫자는 이전 evidence 설명과 W14 smoke 실행 claim을 분리한다.

반례 이 exact selector가 Green이어도 20 concurrent path는 실행되지 않을 수 있다.

JSON 표현 차이히토리 → 니지카 → 료 → 키타
  1. 히토리

    공백과 field 순서 variant도 이번 smoke가 실행해?

  2. 니지카

    아니, 별도 메서드다.

  3. source 설명에는 둘 수 있어도 Green 범위에는 넣지 마.

  4. 키타

    누적 원문과 실행 범위를 표시하겠습니다.

after business rollback히토리 → 니지카 → 료 → 키타
  1. 히토리

    업무 변경 뒤 예외도 이 selector가 검증해?

  2. 니지카

    그 역시 다른 @Test다.

  3. F05 class selector와 F06 exact method 범위를 비교해 봐.

  4. 키타

    rollback owner를 구분하겠습니다.

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

선택 범위

나머지 8개 @Test도 이번 smoke에서 Green

이 책임을 맡는 곳: separate selectors or class-wide suite
semantic 범위

모든 field·actor·operation 변화가 conflict

이 책임을 맡는 곳: parameterized semantic-difference matrix
운영 복구

stale PROCESSING과 burst 100 exactly-once

이 책임을 맡는 곳: recovery policy and dedicated stress tests
12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • source-local 9와 selected 1을 먼저 적는다.
  • first/changed command에서 key·from·to는 같고 amount만 다른 형태를 쓴다.
  • conflict code와 다섯 최종 관찰값을 정본 없이 재현한다.

2단계 · 코드 조각 재조립

  1. class와 method selector
  2. 첫 효과 한 번
  3. 바뀐 amount conflict

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

정본을 닫고 294줄 전체를 다시 쓴 뒤 mapping 227행, translation 252행, @Test 9개를 대조한다.

자가 점검
  • 비공백 translation과 package/import 제외 mapping line 번호가 source 순서와 맞는지 본다.
  • 각 mapping의 실제 뜻·입력·직후 상태·한계가 서로 다른 역할인지 읽는다.
  • 고정 숫자와 timeout, key, balance, row count를 stage source에 다시 대조한다.
  • source-local @Test와 W14 smoke selected test를 섞지 않는다.
same-key 20히토리 → 니지카 → 료 → 키타
  1. 히토리

    20개 동시 요청 효과 1도 이번 결과에 포함되지?

  2. 니지카

    exact selector는 그 메서드를 실행하지 않는다.

  3. W14 답변의 prior evidence와 smoke evidence를 섞지 마.

  4. 키타

    20은 비선택 경계로 남길게요.

opening state helper히토리 → 니지카 → 료 → 키타
  1. 히토리

    assertOnlyOpeningStateRemains가 있으니 selected test도 호출해?

  2. 니지카

    아니, 두 rollback test만 호출한다.

  3. private helper는 호출자가 있어야 움직여.

  4. 키타

    호출선을 따라가겠습니다.

13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcereference/w12/day-5/TransferIntegrationTest.javaSHA-256 1e0ce05254457d8a73de1b820c11745642290779f0a6fea72a6144320590d7e9
TransferIntegrationTest.java — exact amount-conflict selector와 넓은 누적 test 원문 전체

package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

@SpringBootTest
@Import(TransferIntegrationTest.FailureHookConfiguration.class)
class TransferIntegrationTest extends PostgresIntegrationTestSupport {

    @TestConfiguration
    static class FailureHookConfiguration {
        @Bean
        ControlledFailureHook controlledFailureHook() {
            return new ControlledFailureHook();
        }
    }

    static final class ControlledFailureHook implements TransferFailureHook {
        enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS_MUTATION }

        private volatile Point point = Point.NONE;

        void failAt(Point point) { this.point = point; }
        void reset() { this.point = Point.NONE; }

        @Override
        public void afterClaim() {
            if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
        }

        @Override
        public void afterBusinessMutation() {
            if (point == Point.AFTER_BUSINESS_MUTATION) {
                throw new RuntimeException("injected after business mutation");
            }
        }
    }

    @Autowired AccountRepository accounts;
    @Autowired AccountOpeningService openings;
    @Autowired LedgerEntryRepository ledger;
    @Autowired TransferService transfers;
    @Autowired JdbcClient jdbc;
    @Autowired ControlledFailureHook failureHook;

    private Account from;
    private Account to;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE").update();
        failureHook.reset();
        from = openings.open("customer-1", "A", 10_000);
        to = openings.open("customer-1", "B", 10_000);
    }

    @Test
    void transfer_and_same_key_replay_once() {
        var command = new TransferService.Command("customer-1", "key-1", from.getId(), to.getId(), 3_000);
        var first = transfers.transfer(command);
        var replay = transfers.transfer(command);

        assertThat(first.replayed()).isFalse();
        assertThat(replay.replayed()).isTrue();
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(7_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(13_000);
        assertThat(ledger.count()).isEqualTo(4);
        assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    @Test
    void json_field_order_and_whitespace_variant_replays_without_extra_effect() {
        String firstJson = "{\"from\":" + from.getId() + ",\"to\":" + to.getId() + ",\"amount\":1000}";
        String variantJson = "{  \"amount\" : 1000, \n \"to\" : " + to.getId() + ", \"from\" : " + from.getId() + " }";

        var first = transfers.transfer(commandFromJson("representation-key", firstJson));
        var replay = transfers.transfer(commandFromJson("representation-key", variantJson));

        assertThat(first.replayed()).isFalse();
        assertThat(replay.replayed()).isTrue();
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertOneTransferEffect();
    }

    @Test
    void same_key_concurrent_requests_change_business_once() throws Exception {
        int n = 20;
        var ready = new CountDownLatch(n);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(n);
        try {
            var command = new TransferService.Command(
                "customer-1", "burst-key", from.getId(), to.getId(), 1_000);
            List<Future<TransferService.Result>> futures = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                futures.add(pool.submit(() -> {
                    ready.countDown();
                    start.await();
                    return transfers.transfer(command);
                }));
            }
            assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            List<TransferService.Result> results = new ArrayList<>();
            for (var future : futures) results.add(future.get(15, TimeUnit.SECONDS));

            assertThat(results.stream().filter(r -> !r.replayed()).count()).isEqualTo(1);
            assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
            assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
            assertThat(ledger.count()).isEqualTo(4);
            assertOneTransferEffect();
        } finally {
            pool.shutdownNow();
        }
    }

    @Test
    void opposite_direction_transfers_preserve_total_balance() throws Exception {
        int perDirection = 10;
        int n = perDirection * 2;
        var ready = new CountDownLatch(n);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(n);
        try {
            List<Future<TransferService.Result>> futures = new ArrayList<>();
            for (int i = 0; i < perDirection; i++) {
                int seq = i;
                futures.add(pool.submit(() -> invokeAfterBarrier(
                    ready, start, new TransferService.Command("customer-1", "ab-" + seq, from.getId(), to.getId(), 100))));
                futures.add(pool.submit(() -> invokeAfterBarrier(
                    ready, start, new TransferService.Command("customer-1", "ba-" + seq, to.getId(), from.getId(), 100))));
            }
            assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (var future : futures) future.get(20, TimeUnit.SECONDS);

            long fromBalance = accounts.findById(from.getId()).orElseThrow().getBalance();
            long toBalance = accounts.findById(to.getId()).orElseThrow().getBalance();
            assertThat(fromBalance).isEqualTo(10_000);
            assertThat(toBalance).isEqualTo(10_000);
            assertThat(fromBalance + toBalance).isEqualTo(20_000);
            assertThat(ledger.count()).isEqualTo(42);
        } finally {
            pool.shutdownNow();
        }
    }

    @Test
    void same_key_with_different_semantic_request_conflicts_without_extra_effect() {
        var first = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 1_000);
        var changed = new TransferService.Command("customer-1", "conflict-key", from.getId(), to.getId(), 2_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changed))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isEqualTo(2);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
            .query(Long.class).single()).isEqualTo(1);
    }

    @Test
    void same_key_with_changed_from_conflicts_without_extra_effect() {
        Account other = openings.open("customer-1", "C", 5_000);
        var first = new TransferService.Command("customer-1", "from-conflict-key", from.getId(), to.getId(), 1_000);
        var changedFrom = new TransferService.Command("customer-1", "from-conflict-key", other.getId(), to.getId(), 1_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changedFrom))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);
        assertOneTransferEffect();
    }

    @Test
    void same_key_with_changed_to_conflicts_without_extra_effect() {
        Account other = openings.open("customer-1", "C", 5_000);
        var first = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), to.getId(), 1_000);
        var changedTo = new TransferService.Command("customer-1", "to-conflict-key", from.getId(), other.getId(), 1_000);

        transfers.transfer(first);
        assertThatThrownBy(() -> transfers.transfer(changedTo))
            .isInstanceOfSatisfying(BusinessException.class,
                failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));

        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(9_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(11_000);
        assertThat(accounts.findById(other.getId()).orElseThrow().getBalance()).isEqualTo(5_000);
        assertOneTransferEffect();
    }

    @Test
    void runtime_exception_after_claim_rolls_back_every_database_effect() {
        failureHook.failAt(ControlledFailureHook.Point.AFTER_CLAIM);
        var command = new TransferService.Command("customer-1", "fail-claim", from.getId(), to.getId(), 1_000);

        assertThatThrownBy(() -> transfers.transfer(command))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("injected after claim");
        assertOnlyOpeningStateRemains();
    }

    @Test
    void runtime_exception_after_business_mutation_rolls_back_every_database_effect() {
        failureHook.failAt(ControlledFailureHook.Point.AFTER_BUSINESS_MUTATION);
        var command = new TransferService.Command("customer-1", "fail-business", from.getId(), to.getId(), 1_000);

        assertThatThrownBy(() -> transfers.transfer(command))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("injected after business mutation");
        assertOnlyOpeningStateRemains();
    }

    private void assertOnlyOpeningStateRemains() {
        assertThat(accounts.findById(from.getId()).orElseThrow().getBalance()).isEqualTo(10_000);
        assertThat(accounts.findById(to.getId()).orElseThrow().getBalance()).isEqualTo(10_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    private TransferService.Command commandFromJson(String key, String json) {
        return new TransferService.Command(
            "customer-1",
            key,
            semanticLong(json, "from"),
            semanticLong(json, "to"),
            semanticLong(json, "amount")
        );
    }

    private long semanticLong(String json, String field) {
        var matcher = Pattern.compile("\\\"" + Pattern.quote(field) + "\\\"\\s*:\\s*(\\d+)").matcher(json);
        if (!matcher.find()) throw new IllegalArgumentException("missing semantic field: " + field);
        return Long.parseLong(matcher.group(1));
    }

    private void assertOneTransferEffect() {
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isEqualTo(2);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
            .query(Long.class).single()).isEqualTo(1);
    }

    private TransferService.Result invokeAfterBarrier(
        CountDownLatch ready, CountDownLatch start, TransferService.Command command
    ) throws Exception {
        ready.countDown();
        start.await();
        return transfers.transfer(command);
    }
}

07

W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기

scripts/run-w14-smoke.ps1

정본 PowerShell 실행기 · 정본 · W14-F07
9줄 연결9줄 번역7 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

여섯 exact selector를 Gradle에 넘긴 뒤 JUnit XML class 집합과 tests·failures·errors·skipped 합계를 검사해 한 줄 evidence를 남긴다.

  1. 여섯 selector 중 하나는 왜 method 하나만 고르나?
  2. XML에서 어떤 숫자가 모두 Green이어야 하나?
  3. 이 marker가 staged source 실행까지 증명하지 못하는 이유는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값selectors=6expected classes=6implemented minimum tests=5failures/errors/skipped=0marker=W14_SMOKE_GREEN
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · 두 입력 경로히토리 → 니지카 → 료 → 키타
  1. 히토리

    EvidencePath를 생략하면 어디에 쓰나요?

  2. 니지카

    root 아래 evidence/w14/smoke.txt가 기본값이야.

  3. ProjectRoot override만 Resolve-Path하고 기본 referenceRoot가 실행 가능한 stage root인지는 확인하지 않아.

  4. 키타

    호출 예에서 EvidencePath만 생략했을 때 target이 root/evidence/w14/smoke.txt인지 확인하면 돼요.

02

STEP 02 / 13

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

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

여섯 장의 점검표와 결과 봉투

W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기

니지카는 여섯 점검표 이름을 Gradle에게 정확히 건네고, 실행이 끝나면 XML 결과 봉투를 모은다.

료는 봉투 속 class와 숫자를 확인하되, 이 실행기가 tests=5도 통과시키고 staged source hash는 확인하지 않는다고 짧게 경고한다.

딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

이름 여섯 개 전달

다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.

코드 연결
4~5줄
비유
여섯 장의 점검표와 결과 봉투에서 이름 여섯 개 전달 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

결과 봉투 검사

XML class 집합과 네 숫자를 읽어 Green인지 판단한다.

코드 연결
6~7줄
비유
여섯 장의 점검표와 결과 봉투에서 결과 봉투 검사 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

증거 한 줄 쓰기

통과하면 smoke.txt에 marker 한 줄을 쓴다.

코드 연결
8줄
비유
여섯 장의 점검표와 결과 봉투에서 증거 한 줄 쓰기 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.정본 PowerShell 실행기에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.9 / 9 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F07-L01 param([string]$EvidencePath='evidence/w14/smoke.txt',[string]$ProjectRoot='') 접수대에 결과 봉투 주소와 실행할 프로젝트 주소 두 칸을 마련한다. EvidencePath 기본값과 선택 ProjectRoot를 받는 실행기 매개변수 선언이다.
입력
호출자가 evidence 위치만 바꾸거나 별도 project root를 지정하려 할 때
결과·효과
호출자는 생략 시 evidence/w14/smoke.txt와 빈 root override를 얻는다.
비유의 한계
기본 문자열은 경로가 실제로 존재하거나 완전한 절대 경로임을 확인하지 않는다.
2줄F07-L02 $ErrorActionPreference='Stop';$referenceRoot=Split-Path -Parent $PSScriptRoot;$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot};Push-Location $root 실수하면 즉시 멈추는 스위치를 켜고 오늘 쓸 작업대까지 한 번에 이동한다. PowerShell 오류를 즉시 중단하게 하고, override가 있으면 Resolve-Path한 경로를 아니면 script 부모를 root로 삼아 그 위치로 이동한다.
입력
빈 ProjectRoot와 명시한 override 중 이번 Gradle 기준 directory를 고를 때
결과·효과
이후 Gradle·XML·evidence 상대 경로의 기준과 복귀할 location stack이 정해진다.
비유의 한계
기본 referenceRoot에는 stage-only F01·F02가 active source로 들어 있다는 보장이 없다.
3줄F07-L03 try{ 작업대를 옮긴 뒤 돌아올 수 있도록 안전줄이 달린 구역을 연다. 어떤 분기에서 끝나도 현재 위치를 되돌리기 위한 try 블록을 시작한다.
입력
Push-Location 뒤 Gradle이나 XML 검사에서 예외가 날 수 있을 때
결과·효과
아래 본문 예외 뒤에도 9줄 cleanup으로 제어가 이동할 틀이 생긴다.
비유의 한계
try 입구만으로 process 강제 종료 때의 복귀까지 보장하지는 못한다.
4줄F07-L04 $selectors=@('com.example.financialcore.account.api.AccountControllerTest','com.example.financialcore.transfer.TransferFailurePointIT','com.example.financialcore.transfer.SortedLockTransferIT','com.example.financialcore.transfer.TransferIntegrationTest.same_key_with_different_semantic_request_conflicts_without_extra_effect','com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest') 다섯 묶음 악보와 한 곡의 특정 마디를 골라 여섯 장의 연주 목록을 만든다. 다섯 class selector와 TransferIntegrationTest의 한 method selector를 합쳐 exact selector 여섯 개를 고정한다.
입력
W6·W10·W12 참고 test 가운데 W14 smoke 대상만 고정할 때
결과·효과
Gradle 필터 입력이 six-selector 목록으로 확정되며 F06은 한 method만 선택된다.
비유의 한계
F06 selector는 class 전체가 아니라 same_key 한 method라 나머지 여덟 test를 실행하지 않는다.
5줄F07-L05 $args=@('test','--no-daemon');foreach($s in $selectors){$args+=@('--tests',$s)};& .\gradlew.bat @args;if($LASTEXITCODE-ne 0){throw "W14 Gradle exit=$LASTEXITCODE"} 여섯 이름표를 Gradle 표에 하나씩 붙여 검사실로 보내고 빨간 종료표면 즉시 막는다. Gradle test와 no-daemon 인자에 selector를 하나씩 붙여 gradlew.bat을 실행하고 native exit가 0이 아니면 실패시킨다.
입력
test --no-daemon 호출에 각 selector를 --tests 쌍으로 추가한 뒤
결과·효과
selector별 --tests가 전달되고 nonzero native exit는 XML 판독 전에 run을 멈춘다.
비유의 한계
native exit 0은 XML class 집합·stale 여부·source byte가 옳다는 결론이 아니다.
6줄F07-L06 $dir=Join-Path $root 'build/test-results/test';$xml=@(Get-ChildItem $dir -Filter 'TEST-*.xml');$rows=@($xml|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}}) 결과 보관함의 모든 XML 봉투를 열어 반 이름과 응시·실패·오류·결석 수를 적는다. 기존 TEST-*.xml을 모두 읽어 suite class와 tests·failures·errors·skipped 숫자를 행으로 만든다.
입력
build/test-results/test에 TEST-*.xml이 이미 있거나 새로 생겼을 때
결과·효과
찾힌 모든 XML suite가 class와 네 합계 열을 가진 메모리 rows로 바뀐다.
비유의 한계
directory를 먼저 비우지 않아 과거 suite XML이 현재 rows에 섞일 수 있다.
7줄F07-L07 $classes=@($rows.class|Sort-Object -Unique);$expected=@($selectors|ForEach-Object{if($_ -match '^(.*)\.[^.]+$' -and $_ -like '*.same_key*'){$Matches[1]}else{$_}}|Sort-Object -Unique);if(Compare-Object $expected $classes){throw "W14 XML classes mismatch expected=$expected actual=$classes"};$tests=($rows|Measure-Object tests -Sum).Sum;$f=($rows|Measure-Object failures -Sum).Sum;$er=($rows|Measure-Object errors -Sum).Sum;$sk=($rows|Measure-Object skipped -Sum).Sum;if($tests-lt 5-or$f-ne 0-or$er-ne 0-or$sk-ne 0){throw "W14 XML not Green tests=$tests failures=$f errors=$er skipped=$sk"} 명단 두 장을 set으로 맞춘 뒤 네 숫자가 초록 기준선을 넘는지 판정한다. XML class 집합이 selector에서 만든 기대 집합과 같은지 보고, 합계 tests가 5 이상이며 실패·오류·skip이 0인지 검사한다.
입력
XML rows에서 unique class와 tests·failures·errors·skipped 합계를 얻은 다음
결과·효과
class set exact와 tests>=5·failures/errors/skipped=0을 동시에 만족해야 다음 줄로 간다.
비유의 한계
구현은 tests가 5일 때도 통과하므로 PDF의 tests>=6 문구를 그대로 강제하지 않는다.
8줄F07-L08 $line="W14_SMOKE_GREEN classes=$($classes.Count) tests=$tests failures=0 errors=0 skipped=0";$target=if([IO.Path]::IsPathRooted($EvidencePath)){$EvidencePath}else{Join-Path $root $EvidencePath};$parent=Split-Path -Parent $target;if($parent){New-Item -ItemType Directory -Force $parent|Out-Null};$line|Set-Content -Encoding utf8 $target;$line 검사 통과 문장을 봉투에 바로 적고 같은 문장을 안내 방송으로 읽는다. Green marker를 만들고 evidence 경로를 정한 뒤 부모 폴더를 만들고 Set-Content로 marker를 기록·출력한다.
입력
class set과 합계 gate를 모두 통과해 smoke marker를 남길 때
결과·효과
대상 파일에는 W14_SMOKE_GREEN과 class/test 합계 한 줄이 UTF-8로 남는다.
비유의 한계
Set-Content는 stale target 삭제나 tmp 파일 원자 교체를 하지 않고 source SHA도 기록하지 않는다.
9줄F07-L09 }finally{Pop-Location} 검사 결과와 상관없이 빌려 쓴 작업대를 반납하고 원래 자리로 돌아간다. 성공과 실패 모두에서 Push-Location 전 위치로 돌아가도록 finally에서 Pop-Location한다.
입력
try 본문이 정상 종료하거나 throw로 빠져나온 직후
결과·효과
호출자의 PowerShell 현재 directory가 원래 위치로 복구된다.
비유의 한계
Pop-Location은 directory만 복원하며 Gradle process·XML·evidence의 정리는 맡지 않는다.
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · root와 위치 이동히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 Push-Location을 먼저 하나요?

  2. 니지카

    Gradle과 build 결과의 상대 경로 기준을 한 root로 맞추려는 거야.

  3. Pop-Location은 directory만 복구하고 test 산출물이나 evidence를 정리하지는 않아.

  4. 키타

    실패 전후 Get-Location을 비교하면 finally가 위치만 되돌렸는지 분리해 볼 수 있어요.

W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · 여섯 selector히토리 → 니지카 → 료 → 키타
  1. 히토리

    F06 class는 왜 method 하나만 적었어요?

  2. 니지카

    그 selector만 exact method라 TransferIntegrationTest의 나머지 여덟 @Test는 이 smoke가 고르지 않아.

  3. 선택된 10 staged tests와 실제 XML tests 합계는 source identity가 다르면 같다고 장담할 수 없어.

  4. 키타

    selector 문자열 여섯 개와 expected class 여섯 개를 따로 세고 F06 method suffix를 표시해 둘게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F07-C01 · parameters1–1줄
1–1줄 원본
param([string]$EvidencePath='evidence/w14/smoke.txt',[string]$ProjectRoot='')
F07-C02 · strict error mode and project root2–2줄
2–2줄 원본
$ErrorActionPreference='Stop';$referenceRoot=Split-Path -Parent $PSScriptRoot;$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot};Push-Location $root
F07-C03 · try block3–3줄
3–3줄 원본
try{
F07-C04 · six selectors and Gradle execution4–5줄
4–5줄 원본
 $selectors=@('com.example.financialcore.account.api.AccountControllerTest','com.example.financialcore.transfer.TransferFailurePointIT','com.example.financialcore.transfer.SortedLockTransferIT','com.example.financialcore.transfer.TransferIntegrationTest.same_key_with_different_semantic_request_conflicts_without_extra_effect','com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')
 $args=@('test','--no-daemon');foreach($s in $selectors){$args+=@('--tests',$s)};& .\gradlew.bat @args;if($LASTEXITCODE-ne 0){throw "W14 Gradle exit=$LASTEXITCODE"}
F07-C05 · JUnit XML parsing and Green conditions6–7줄
6–7줄 원본
 $dir=Join-Path $root 'build/test-results/test';$xml=@(Get-ChildItem $dir -Filter 'TEST-*.xml');$rows=@($xml|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})
 $classes=@($rows.class|Sort-Object -Unique);$expected=@($selectors|ForEach-Object{if($_ -match '^(.*)\.[^.]+$' -and $_ -like '*.same_key*'){$Matches[1]}else{$_}}|Sort-Object -Unique);if(Compare-Object $expected $classes){throw "W14 XML classes mismatch expected=$expected actual=$classes"};$tests=($rows|Measure-Object tests -Sum).Sum;$f=($rows|Measure-Object failures -Sum).Sum;$er=($rows|Measure-Object errors -Sum).Sum;$sk=($rows|Measure-Object skipped -Sum).Sum;if($tests-lt 5-or$f-ne 0-or$er-ne 0-or$sk-ne 0){throw "W14 XML not Green tests=$tests failures=$f errors=$er skipped=$sk"}
F07-C06 · evidence marker write8–8줄
8–8줄 원본
 $line="W14_SMOKE_GREEN classes=$($classes.Count) tests=$tests failures=0 errors=0 skipped=0";$target=if([IO.Path]::IsPathRooted($EvidencePath)){$EvidencePath}else{Join-Path $root $EvidencePath};$parent=Split-Path -Parent $target;if($parent){New-Item -ItemType Directory -Force $parent|Out-Null};$line|Set-Content -Encoding utf8 $target;$line
F07-C07 · location cleanup9–9줄
9–9줄 원본
}finally{Pop-Location}
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 9 / 9

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

원본한국어 번역
1param([string]$EvidencePath='evidence/w14/smoke.txt',[string]$ProjectRoot='')EvidencePath 기본값과 선택 ProjectRoot를 받는 실행기 매개변수 선언이다.
2$ErrorActionPreference='Stop';$referenceRoot=Split-Path -Parent $PSScriptRoot;$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot};Push-Location $rootPowerShell 오류를 즉시 중단하게 하고, override가 있으면 Resolve-Path한 경로를 아니면 script 부모를 root로 삼아 그 위치로 이동한다.
3try{어떤 분기에서 끝나도 현재 위치를 되돌리기 위한 try 블록을 시작한다.
4 $selectors=@('com.example.financialcore.account.api.AccountControllerTest','com.example.financialcore.transfer.TransferFailurePointIT','com.example.financialcore.transfer.SortedLockTransferIT','com.example.financialcore.transfer.TransferIntegrationTest.same_key_with_different_semantic_request_conflicts_without_extra_effect','com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')다섯 class selector와 TransferIntegrationTest의 한 method selector를 합쳐 exact selector 여섯 개를 고정한다.
5 $args=@('test','--no-daemon');foreach($s in $selectors){$args+=@('--tests',$s)};& .\gradlew.bat @args;if($LASTEXITCODE-ne 0){throw "W14 Gradle exit=$LASTEXITCODE"}Gradle test와 no-daemon 인자에 selector를 하나씩 붙여 gradlew.bat을 실행하고 native exit가 0이 아니면 실패시킨다.
6 $dir=Join-Path $root 'build/test-results/test';$xml=@(Get-ChildItem $dir -Filter 'TEST-*.xml');$rows=@($xml|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})기존 TEST-*.xml을 모두 읽어 suite class와 tests·failures·errors·skipped 숫자를 행으로 만든다.
7 $classes=@($rows.class|Sort-Object -Unique);$expected=@($selectors|ForEach-Object{if($_ -match '^(.*)\.[^.]+$' -and $_ -like '*.same_key*'){$Matches[1]}else{$_}}|Sort-Object -Unique);if(Compare-Object $expected $classes){throw "W14 XML classes mismatch expected=$expected actual=$classes"};$tests=($rows|Measure-Object tests -Sum).Sum;$f=($rows|Measure-Object failures -Sum).Sum;$er=($rows|Measure-Object errors -Sum).Sum;$sk=($rows|Measure-Object skipped -Sum).Sum;if($tests-lt 5-or$f-ne 0-or$er-ne 0-or$sk-ne 0){throw "W14 XML not Green tests=$tests failures=$f errors=$er skipped=$sk"}XML class 집합이 selector에서 만든 기대 집합과 같은지 보고, 합계 tests가 5 이상이며 실패·오류·skip이 0인지 검사한다.
8 $line="W14_SMOKE_GREEN classes=$($classes.Count) tests=$tests failures=0 errors=0 skipped=0";$target=if([IO.Path]::IsPathRooted($EvidencePath)){$EvidencePath}else{Join-Path $root $EvidencePath};$parent=Split-Path -Parent $target;if($parent){New-Item -ItemType Directory -Force $parent|Out-Null};$line|Set-Content -Encoding utf8 $target;$lineGreen marker를 만들고 evidence 경로를 정한 뒤 부모 폴더를 만들고 Set-Content로 marker를 기록·출력한다.
9}finally{Pop-Location}성공과 실패 모두에서 Push-Location 전 위치로 돌아가도록 finally에서 Pop-Location한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

여섯 exact selector를 Gradle에 넘긴 뒤 JUnit XML class 집합과 tests·failures·errors·skipped 합계를 검사해 한 줄 evidence를 남긴다.

문법 해부

  • 다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.
  • XML class 집합과 네 숫자를 읽어 Green인지 판단한다.
  • 통과하면 smoke.txt에 marker 한 줄을 쓴다.

실행 순서

  1. Gradle --tests filter로 전달
  2. expected set과 exact 비교
  3. 7줄 조건 평가
  4. Set-Content로 target 기록

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

F07-C01 · parameters
문법 해부
param 선언은 EvidencePath 기본값 evidence/w14/smoke.txt와 선택 ProjectRoot override 두 입력을 받는다.
실제 값 추적
호출자가 인자를 생략하면 evidence는 relative 기본값이고 ProjectRoot는 빈 문자열로 시작한다.
정상 예
별도 root에서 실행하려면 `-ProjectRoot`와 필요한 경우 `-EvidencePath`를 명시하는 호출이 source 계약과 맞다.
틀린 예·반례
기본 EvidencePath 문자열을 완전한 절대 경로나 이미 존재하는 file이라고 가정하면 안 된다.
착각 방지
parameter 선언 자체는 받은 path가 존재하는지, absolute인지, 쓸 수 있는지 확인하지 않는다.
하지 않는 일
이 입력부는 두 문자열 입력과 defaults만 정하며 어떤 command 실행이나 evidence 생성을 보장하지 않는다.
다음 연결
다음 ‘strict error mode and project root’ 범위에서는 ErrorActionPreference Stop을 켜고 script 부모 referenceRoot를 구한 뒤 override가 있으면 Resolve-Path하고 아니면 referenceRoot를 root로 택해 Push-Location한다.
F07-C02 · strict error mode and project root
문법 해부
ErrorActionPreference Stop을 켜고 script 부모 referenceRoot를 구한 뒤 override가 있으면 Resolve-Path하고 아니면 referenceRoot를 root로 택해 Push-Location한다.
실제 값 추적
이후 relative path와 command는 선택한 root 기준으로 해석되고 location stack에는 caller의 원래 directory가 남는다.
정상 예
존재하는 ProjectRoot override를 주면 Resolve-Path의 canonical path로 이동하는 것이 정상 흐름이다.
틀린 예·반례
default referenceRoot에는 stage-only F01 CoreSchemaIT와 F02 OpeningIntegrationTest가 active source set에 없으므로 곧바로 Green package라고 주장할 수 없다.
착각 방지
F03도 default root에 같은 FQCN의 evolved source가 있어 selector 이름만으로 staged W6D7 byte를 bind하지 못한다.
하지 않는 일
Resolve-Path는 override branch에만 있고 source SHA·Gradle project composition·fully-qualified evidence path를 검사하지 않는다.
다음 연결
다음 ‘try block’ 범위에서는 `try{`는 뒤 statements를 protected block 안에서 실행할 control scope를 연다.
F07-C03 · try block
문법 해부
`try{`는 뒤 statements를 protected block 안에서 실행할 control scope를 연다.
실제 값 추적
try body가 정상 종료하거나 terminating error로 빠져나오면 control은 대응하는 finally clause로 전달될 수 있다.
정상 예
복구가 필요한 작업들을 try scope 안에 모아 대응 finally와 짝짓는 것이 이 한 줄의 유효한 control 구조다.
틀린 예·반례
process hard-kill이나 host crash 때 finally가 반드시 실행된다고 보장할 수 없다.
착각 방지
try 시작을 test transaction이나 filesystem atomicity와 혼동하지 않는다.
하지 않는 일
이 한 줄은 body operation이나 finally의 구체적 cleanup statement를 정의하지 않는다.
다음 연결
다음 ‘six selectors and Gradle execution’ 범위에서는 selectors array는 다섯 class와 TransferIntegrationTest의 amount-conflict method 하나를 고정하고, test --no-daemon 뒤 각 값을 --tests로 붙여 gradlew.bat을 호출하며 nonzero exit를 throw한다.
F07-C04 · six selectors and Gradle execution
문법 해부
selectors array는 다섯 class와 TransferIntegrationTest의 amount-conflict method 하나를 고정하고, test --no-daemon 뒤 각 값을 --tests로 붙여 gradlew.bat을 호출하며 nonzero exit를 throw한다.
실제 값 추적
Gradle filter에는 exact selector 6개가 들어가고 F06에서는 local 9 tests 중 selected 1개만 요청된다.
정상 예
native exit 0이면 line 5가 정상 종료하고 nonzero면 W14 Gradle exit message의 terminating error로 중단한다.
틀린 예·반례
다섯 class selectors는 frozen sources의 local @Test 9개를, exact method selector는 F06의 1개를 지정해 총 10개지만 actual root가 staged bytes를 compile했다는 증거는 없다.
착각 방지
이 실행은 전체 suite가 아니라 targeted six-selector smoke이며 F06의 unselected 8 tests를 Green이라고 말할 수 없다.
하지 않는 일
Gradle process exit 성공만으로 requested tests의 complete Green evidence가 성립하지는 않는다.
다음 연결
다음 ‘JUnit XML parsing and Green conditions’ 범위에서는 TEST-*.xml 전체를 읽어 class·tests·failures·errors·skipped rows를 만들고 expected selector class set과 exact 비교한 뒤 tests 합계 5 이상, 나머지 세 합계 0을 요구한다.
F07-C05 · JUnit XML parsing and Green conditions
문법 해부
TEST-*.xml 전체를 읽어 class·tests·failures·errors·skipped rows를 만들고 expected selector class set과 exact 비교한 뒤 tests 합계 5 이상, 나머지 세 합계 0을 요구한다.
실제 값 추적
method selector suffix는 expected class 이름에서 제거되어 여섯 unique class와 XML suite names가 같아야 한다.
정상 예
tests=5, failures=0, errors=0, skipped=0도 현재 `$tests-lt 5` 조건을 통과한다.
틀린 예·반례
PDF의 tests>=6 문구와 달리 구현은 정확히 5를 거절하지 않으므로 둘을 같은 계약이라고 쓰면 틀리다.
착각 방지
directory의 오래된 TEST XML을 사전 삭제하지 않아 stale suite가 class set이나 합계에 섞일 수 있다.
하지 않는 일
XML에는 staged source SHA가 기록되지 않으며 assertion 의미·full suite·test discovery 누락까지 검증하지 않는다.
다음 연결
다음 ‘evidence marker write’ 범위에서는 Green marker에 class count와 test 합계, zero failure/error/skip을 넣고 rooted 여부에 따라 target을 정해 parent directory를 만든 뒤 Set-Content UTF-8로 쓰고 stdout에도 출력한다.
F07-C06 · evidence marker write
문법 해부
Green marker에 class count와 test 합계, zero failure/error/skip을 넣고 rooted 여부에 따라 target을 정해 parent directory를 만든 뒤 Set-Content UTF-8로 쓰고 stdout에도 출력한다.
실제 값 추적
모든 gate 통과 뒤 target file은 `W14_SMOKE_GREEN classes=6 tests=... failures=0 errors=0 skipped=0` 한 줄을 갖는다.
정상 예
relative EvidencePath라면 selected root 아래 parent를 생성하고 marker를 기록하는 흐름이 정상이다.
틀린 예·반례
IsPathRooted는 rooted 여부만 보며 fully-qualified absolute path나 workspace containment를 보장하지 않는다.
착각 방지
Set-Content는 stale smoke.txt 사전 삭제와 tmp-rename 원자 교체를 하지 않아 crash-safe seal이 아니다.
하지 않는 일
marker에는 selector source hashes, XML hashes, Gradle command provenance, timestamp가 포함되지 않는다.
다음 연결
다음 ‘location cleanup’ 범위에서는 finally가 Pop-Location을 호출해 Push-Location 전 caller directory로 돌아간다.
F07-C07 · location cleanup
문법 해부
finally가 Pop-Location을 호출해 Push-Location 전 caller directory로 돌아간다.
실제 값 추적
정상 성공과 throw 실패 양쪽에서 PowerShell location stack 한 단계가 복구된다.
정상 예
Gradle failure 뒤에도 현재 directory가 runner root에 남지 않는 것이 intended cleanup이다.
틀린 예·반례
Pop-Location이 child process, build XML, evidence file, database를 정리한다고 보면 안 된다.
착각 방지
location cleanup은 smoke Green 판정과 별개이며 cleanup 성공 marker도 남기지 않는다.
하지 않는 일
host termination·stack corruption·외부 resource teardown은 이 finally 한 줄의 책임 밖이다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 host termination·stack corruption·외부 resource teardown은 이 finally 한 줄의 책임 밖이다.
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · try와 finally히토리 → 니지카 → 료 → 키타
  1. 히토리

    Gradle이 실패해도 원래 폴더로 돌아오나요?

  2. 니지카

    try 안의 throw 뒤에도 finally가 실행되어 Pop-Location해.

  3. PowerShell process가 강제 종료되면 finally 실행 자체는 보장할 수 없어.

  4. 키타

    일반 throw 반례에서는 원래 directory로 복귀하지만 hard-kill은 같은 실험으로 증명되지 않아요.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1selector 여섯 개Gradle --tests filter로 전달해당 filter 실행을 요청F06 전체 9 tests가 아니라 한 method
2JUnit XML class 집합expected set과 exact 비교여섯 class여야 통과오래된 XML을 먼저 지우지 않음
3tests=5, failures=errors=skipped=07줄 조건 평가현재 구현은 통과PDF tests>=6와 불일치
4Green markerSet-Content로 target 기록UTF-8 한 줄 evidence 생성원자 replace·source SHA 없음
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · XML Green 조건히토리 → 니지카 → 료 → 키타
  1. 히토리

    tests=5면 실패하는 것 아닌가요?

  2. 니지카

    현재 코드는 tests-lt 5만 throw하므로 정확히 5는 통과해.

  3. PDF의 tests>=6 문구와 구현 경계가 다르다는 사실을 숨기면 안 돼.

  4. 키타

    tests=5, failures=errors=skipped=0을 대입하면 throw 조건 전체가 거짓인지 직접 계산할 수 있어요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

PowerShell

root와 selector array를 만들고 native Gradle을 호출한다.

Resolve-Path는 override가 있을 때만 실행된다.
Gradle/JUnit

filtered tests가 TEST-*.xml을 만든다.

default root가 stage-only F01/F02를 compile하는지는 별도 문제다.
filesystem

Set-Content가 marker를 target에 쓴다.

atomic rename과 stale evidence 제거가 없다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ tests가 6 미만이면 반드시 실패한다

왜 틀리나 구현은 tests-lt 5만 거부한다

바르게 읽기 tests=5도 통과한다고 읽는다

반례 tests=5와 실패·오류·skip 0이면 조건식은 false다

❌ marker가 staged F01/F02/F03 byte 실행을 증명한다

왜 틀리나 runner는 source SHA를 기록하지 않는다

바르게 읽기 class 이름과 source provenance를 분리한다

반례 F03 root에는 같은 FQCN의 evolved source가 있다

❌ 전체 test suite가 Green이다

왜 틀리나 exact six-selector만 실행한다

바르게 읽기 targeted smoke라고 부른다

반례 선택 밖 test 실패는 이 evidence에 나타나지 않는다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

source identity

staged source SHA를 증명하지 않는다

이 책임을 맡는 곳: 조립된 learner root/manifest 검증
evidence durability

stale 삭제와 원자 replace를 보장하지 않는다

이 책임을 맡는 곳: evidence writer
suite scope

전체 suite Green을 보장하지 않는다

이 책임을 맡는 곳: 별도 full-suite gate
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · evidence 쓰기히토리 → 니지카 → 료 → 키타
  1. 히토리

    Green 파일은 안전하게 교체되나요?

  2. 니지카

    부모 폴더를 만들고 Set-Content로 한 줄을 바로 써.

  3. stale 파일 사전 삭제와 tmp-rename 원자 교체가 없어서 durability seal은 아니야.

  4. 키타

    target 쓰기 직전 crash와 이전 smoke.txt가 남은 경우를 separate counterexample로 기록할게요.

12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • 여섯 selector 중 하나는 왜 method 하나만 고르나?
  • XML에서 어떤 숫자가 모두 Green이어야 하나?
  • 이 marker가 staged source 실행까지 증명하지 못하는 이유는 무엇인가?

2단계 · 코드 조각 재조립

  1. 다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.
  2. XML class 집합과 네 숫자를 읽어 Green인지 판단한다.
  3. 통과하면 smoke.txt에 marker 한 줄을 쓴다.

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

scripts/run-w14-smoke.ps1 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture·selector 숫자를 정확히 적었는가
  • 보장하지 않는 범위를 함께 적었는가
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · source identity히토리 → 니지카 → 료 → 키타
  1. 히토리

    class 이름이 맞으면 staged byte도 맞나요?

  2. 니지카

    아니야. runner는 class 집합만 보고 source SHA를 기록하지 않아.

  3. F01/F02는 default root에 active class가 없고 F03 root에는 같은 FQCN의 다른 source가 있어.

  4. 키타

    XML class 이름 옆에 source SHA 필드가 없음을 확인해야 staged byte 증거가 아니라는 결론이 정확해요.

13

STEP 13 / 13

전체 원본 정답

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

정본 전체 코드 확인하기
hash로 고정한 native 정본 sourcescripts/run-w14-smoke.ps1SHA-256 fc7f1b908e6cf5673d499483df02c3e86b295849f96e7840f13f14fcf0a0240b
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 전체
param([string]$EvidencePath='evidence/w14/smoke.txt',[string]$ProjectRoot='')
$ErrorActionPreference='Stop';$referenceRoot=Split-Path -Parent $PSScriptRoot;$root=if($ProjectRoot){(Resolve-Path -LiteralPath $ProjectRoot).Path}else{$referenceRoot};Push-Location $root
try{
 $selectors=@('com.example.financialcore.account.api.AccountControllerTest','com.example.financialcore.transfer.TransferFailurePointIT','com.example.financialcore.transfer.SortedLockTransferIT','com.example.financialcore.transfer.TransferIntegrationTest.same_key_with_different_semantic_request_conflicts_without_extra_effect','com.example.financialcore.CoreSchemaIT','com.example.financialcore.account.OpeningIntegrationTest')
 $args=@('test','--no-daemon');foreach($s in $selectors){$args+=@('--tests',$s)};& .\gradlew.bat @args;if($LASTEXITCODE-ne 0){throw "W14 Gradle exit=$LASTEXITCODE"}
 $dir=Join-Path $root 'build/test-results/test';$xml=@(Get-ChildItem $dir -Filter 'TEST-*.xml');$rows=@($xml|ForEach-Object{[xml]$x=Get-Content -Raw $_.FullName;[pscustomobject]@{class=[string]$x.testsuite.name;tests=[int]$x.testsuite.tests;failures=[int]$x.testsuite.failures;errors=[int]$x.testsuite.errors;skipped=[int]$x.testsuite.skipped}})
 $classes=@($rows.class|Sort-Object -Unique);$expected=@($selectors|ForEach-Object{if($_ -match '^(.*)\.[^.]+$' -and $_ -like '*.same_key*'){$Matches[1]}else{$_}}|Sort-Object -Unique);if(Compare-Object $expected $classes){throw "W14 XML classes mismatch expected=$expected actual=$classes"};$tests=($rows|Measure-Object tests -Sum).Sum;$f=($rows|Measure-Object failures -Sum).Sum;$er=($rows|Measure-Object errors -Sum).Sum;$sk=($rows|Measure-Object skipped -Sum).Sum;if($tests-lt 5-or$f-ne 0-or$er-ne 0-or$sk-ne 0){throw "W14 XML not Green tests=$tests failures=$f errors=$er skipped=$sk"}
 $line="W14_SMOKE_GREEN classes=$($classes.Count) tests=$tests failures=0 errors=0 skipped=0";$target=if([IO.Path]::IsPathRooted($EvidencePath)){$EvidencePath}else{Join-Path $root $EvidencePath};$parent=Split-Path -Parent $target;if($parent){New-Item -ItemType Directory -Force $parent|Out-Null};$line|Set-Content -Encoding utf8 $target;$line
}finally{Pop-Location}
08

Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기

illustrative/sql/W14-SQL-Q19.sql

fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F08
23줄 연결23줄 번역4 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

모든 current business_tx를 보존하면서 original_tx_id로 같은 table을 LEFT JOIN해 reversal과 원거래 정보를 나란히 보여 준다.

  1. 왜 business_tx를 자기 자신과 LEFT JOIN하나?
  2. 왜 ordinary transaction 19행도 결과에 남나?
  3. BROKEN_REFERENCE가 fixture에서 나오지 않는 이유는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값fixture rows=21REQ-211/REQ-212 -> REQ-201linked=2no-original=19
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · current grain히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 reversal 두 행만 시작하지 않나요?

  2. 니지카

    Q19 예시는 모든 current transaction을 한 행씩 보여 주려는 표라 21행 전체에서 시작해.

  3. reversal-only 보고서라면 별도 WHERE가 필요해.

  4. 키타

    결과에서 LINKED 2행과 NO_ORIGINAL 19행을 더해 current grain 21행이 보존되는지 볼게요.

02

STEP 02 / 13

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

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

현재 영수증과 원본 영수증 연결판

Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기

히토리는 현재 거래 영수증 21장을 왼쪽 판에 모두 놓고, original_tx_id가 있는 두 장만 원본 영수증과 끈으로 연결한다.

키타는 연결 없는 19장은 NO_ORIGINAL로 남기고, FK가 살아 있는 이 fixture에서는 BROKEN_REFERENCE가 나오지 않는다고 확인한다.

딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

현재 거래 모두 놓기

current_tx 21행을 기준으로 시작한다.

코드 연결
21줄
비유
현재 영수증과 원본 영수증 연결판에서 현재 거래 모두 놓기 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

원거래 끈 연결

original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.

코드 연결
22~23줄
비유
현재 영수증과 원본 영수증 연결판에서 원거래 끈 연결 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

연결 상태 이름 붙이기

NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.

코드 연결
16~20줄
비유
현재 영수증과 원본 영수증 연결판에서 연결 상태 이름 붙이기 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.fixture 기반 학습용 SQL 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.23 / 23 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F08-L01 -- W14-SQL-Q19 illustrative example, not a shipped workbook answer. 연결표 맨 위에 ‘연습용, 공식 정답 아님’ 도장을 찍는다. Q19를 위해 만든 학습용 SQL이며 workbook이 배포한 정본 답안이 아님을 밝힌다.
입력
Q19 query의 출처와 답안 지위를 독자가 먼저 판단할 때
결과·효과
Q19 연결표의 지위가 제공 정답이 아니라 원거래 self-join 연습본으로 한정된다.
비유의 한계
주석은 self join의 결과나 fixture oracle을 실행해 확인하지 않는다.
2줄F08-L02 -- Input grain: one row per business_tx; output grain: one row per current transaction. 왼쪽 영수증 한 장이 완성 표 한 줄이라는 좌석 규칙을 적는다. 입력은 business_tx 한 행, 출력은 현재 transaction 한 행이라는 grain을 선언한다.
입력
join 전후 row 수가 무엇을 뜻하는지 정할 때
결과·효과
row multiplication을 해석할 기준이 current transaction당 한 결과 행으로 고정된다.
비유의 한계
grain 선언만으로 중복 original row나 잘못된 key를 막지는 않는다.
3줄F08-L03 -- LEFT JOIN keeps ordinary transactions whose original_tx_id is NULL. 연결 끈이 없는 보통 영수증도 전시판에서 빼지 않는다고 표시한다. original_tx_id가 NULL인 보통 transaction도 LEFT JOIN으로 보존한다고 설명한다.
입력
original_tx_id가 NULL인 transaction의 보존 방식을 선택할 때
결과·효과
원거래가 없는 19개 fixture 행도 결과 집합에서 탈락하지 않는 기대가 드러난다.
비유의 한계
LEFT JOIN 보존은 reversal-only 보고서에 필요한 필터를 대신하지 않는다.
4줄F08-L04 -- Supplied fixture oracle: 21 rows; REQ-211/REQ-212 link to REQ-201; 19 rows have no original. 정답 대조표에 전체 스물한 장과 두 연결·열아홉 미연결을 써 둔다. fixture 기대값을 전체 21행, 연결 reversal 2행, 원거래 없는 19행으로 고정한다.
입력
제공 seed를 실행한 Q19 결과 cardinality를 검토할 때
결과·효과
REQ-211과 REQ-212만 REQ-201 정보를 옆에 갖는 21행 oracle이 생긴다.
비유의 한계
21·2·19는 현재 fixture 값이며 데이터가 바뀌면 다시 계산해야 한다.
5줄F08-L05 SET search_path TO :"workbook_schema", public; 오늘 사용할 격리 서랍을 먼저 열고 공용 서랍은 보조로 둔다. psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
입력
schema 접두사 없는 business_tx를 어느 namespace에서 찾을지 정할 때
결과·효과
schema 접두사 없는 business_tx가 격리 workbook table로 해석된다.
비유의 한계
workbook_schema 변수의 존재와 올바른 fixture loading은 외부 실행 환경 책임이다.
7줄F08-L07 SELECT 현재 거래와 원거래 정보를 나란히 놓을 빈 결과판을 펼친다. 현재 transaction과 원거래 정보를 한 행에 내보낼 SELECT를 시작한다.
입력
FROM과 join으로 row를 모으기 전에 출력 열 목록을 정의할 때
결과·효과
뒤 아홉 projection과 link_state가 결과 열 후보가 된다.
비유의 한계
SELECT 시작만으로 보존 행 수나 link_state 값은 아직 정해지지 않는다.
8줄F08-L08 current_tx.tx_id AS current_tx_id, 현재 영수증의 내부 일련번호를 결과판 첫 식별표로 옮긴다. 현재 transaction의 tx_id를 current_tx_id로 출력한다.
입력
current side row를 UUID로 다시 찾을 수 있어야 할 때
결과·효과
각 결과 행 첫 식별 열에 current side tx_id가 놓인다.
비유의 한계
tx_id는 사람이 읽을 요청 번호나 원거래 연결 상태를 설명하지 않는다.
9줄F08-L09 current_tx.request_id AS current_request_id, 기계 ID 옆에 REQ로 시작하는 현재 요청 이름표를 붙인다. 현재 transaction의 request_id를 current_request_id로 출력한다.
입력
fixture에서 REQ-211 같은 거래를 눈으로 대조할 때
결과·효과
current side의 REQ-* 값이 표시 열에 놓인다.
비유의 한계
request_id 출력은 값의 유일성이나 idempotency 성공을 검사하지 않는다.
10줄F08-L10 current_tx.tx_type AS current_type, 현재 영수증이 입금인지 취소인지 종류 스티커를 보인다. 현재 transaction의 tx_type을 current_type으로 출력한다.
입력
current row의 tx_type을 원거래 종류와 비교하려 할 때
결과·효과
DEPOSIT·REVERSAL 같은 current side 종류가 표시된다.
비유의 한계
종류 열만으로 status·원장 생성·reversal 정당성은 알 수 없다.
11줄F08-L11 current_tx.amount AS current_amount, 현재 거래에 적힌 금액표를 가공하지 않고 옆 칸에 둔다. 현재 transaction의 amount를 current_amount로 출력한다.
입력
current와 original amount를 한 화면에서 비교할 때
결과·효과
current side의 bigint 금액이 결과에 보존된다.
비유의 한계
금액 projection은 통화·부호·보존식이 맞는지 검증하지 않는다.
12줄F08-L12 current_tx.original_tx_id, 연결 끈 끝에 적힌 원본 UUID를 그대로 공개한다. 현재 transaction에 기록된 original_tx_id를 그대로 출력한다.
입력
현재 행이 어떤 original transaction을 지목하는지 확인할 때
결과·효과
NULL 또는 REQ-201을 가리키는 UUID 값이 연결 단서로 남는다.
비유의 한계
NULL은 보통 거래일 수 있으며 그 자체를 broken reference로 보면 안 된다.
13줄F08-L13 original_tx.request_id AS original_request_id, 연결된 과거 영수증에서 사람이 읽을 REQ 이름을 가져온다. self join으로 찾은 원거래의 request_id를 original_request_id로 출력한다.
입력
original side가 실제 행으로 붙었는지 표시값으로 확인할 때
결과·효과
연결된 두 reversal 행에는 REQ-201이, 나머지에는 NULL이 놓인다.
비유의 한계
LEFT JOIN 실패 때 NULL이므로 그 원인이 NULL id인지 깨진 id인지는 link_state와 함께 봐야 한다.
14줄F08-L14 original_tx.tx_type AS original_type, 과거 영수증의 거래 종류표를 현재 종류표 옆에 놓는다. 원거래의 tx_type을 original_type으로 출력한다.
입력
reversal과 original의 type 관계를 검토할 때
결과·효과
연결된 원거래 종류가 두 linked 행의 original_type에 채워진다.
비유의 한계
두 type의 조합이 업무상 허용되는지는 이 projection이 판정하지 않는다.
15줄F08-L15 original_tx.amount AS original_amount, 과거 거래 금액표까지 가져와 현재 금액과 나란히 둔다. 원거래의 amount를 original_amount로 출력한다.
입력
연결된 REQ-201의 amount를 reversal 두 행에서 확인할 때
결과·효과
연결된 원거래 금액이 linked 행의 original_amount에 채워진다.
비유의 한계
같거나 반대 부호라는 회계 조건은 단순 출력만으로 강제되지 않는다.
16줄F08-L16 CASE 연결 상태를 세 칸 중 하나로 보내는 분류판을 연다. 원거래 연결 상태 문자열을 고를 CASE를 시작한다.
입력
original_tx_id와 joined original row의 NULL 조합을 해석하려 할 때
결과·효과
세 link state 가운데 하나를 계산할 분기 평가가 열린다.
비유의 한계
CASE 입구는 branch 순서만 시작하며 아직 어떤 label도 반환하지 않는다.
17줄F08-L17 WHEN current_tx.original_tx_id IS NULL THEN 'NO_ORIGINAL' 처음부터 연결 번호가 없는 영수증을 NO_ORIGINAL 바구니로 보낸다. 현재 행의 original_tx_id가 NULL이면 NO_ORIGINAL로 분류한다.
입력
current_tx.original_tx_id가 SQL NULL로 평가될 때
결과·효과
fixture의 원거래 없는 19행이 NO_ORIGINAL 값을 얻는다.
비유의 한계
NO_ORIGINAL은 오류가 아니라 이 query가 보존하는 ordinary transaction 상태다.
18줄F08-L18 WHEN original_tx.tx_id IS NULL THEN 'BROKEN_REFERENCE' 번호표는 있는데 맞는 과거 영수증이 없으면 깨진 끈 상자에 넣는다. ID는 있는데 join한 원거래가 없으면 BROKEN_REFERENCE로 분류한다.
입력
original_tx_id는 non-NULL이지만 LEFT JOIN original_tx.tx_id가 NULL일 때
결과·효과
FK가 깨진 별도 데이터라면 ID는 있지만 대상 없는 행이 BROKEN_REFERENCE가 된다.
비유의 한계
제공 FK fixture에서는 이 branch가 0행이며 손상 데이터 탐지 가능성만 보여 준다.
19줄F08-L19 ELSE 'LINKED' 연결 번호와 과거 영수증이 모두 있으면 LINKED 도장을 찍는다. 앞 두 조건이 아니면 실제 원거래가 연결된 LINKED로 분류한다.
입력
앞의 NO_ORIGINAL·BROKEN_REFERENCE 조건이 모두 거짓일 때
결과·효과
REQ-211·REQ-212 두 행이 LINKED 값을 얻는다.
비유의 한계
LINKED는 row 존재만 말하고 reversal 업무 규칙이나 금액 일치까지 증명하지 않는다.
20줄F08-L20 END AS link_state 분류판을 닫아 세 문자열을 link_state 한 칸으로 모은다. CASE를 닫고 계산 열 이름을 link_state로 정한다.
입력
각 current row의 CASE branch가 하나 선택된 다음
결과·효과
세 분기 결과가 link_state라는 한 열로 확정된다.
비유의 한계
별칭은 표현을 이름 붙일 뿐 값의 정확성에 새 검사를 추가하지 않는다.
21줄F08-L21 FROM business_tx AS current_tx 전시판 왼쪽에 현재 거래 스물한 장을 먼저 전부 펼친다. 모든 현재 transaction을 보존할 왼쪽 relation을 business_tx current_tx로 정한다.
입력
원거래가 없는 row도 최종 결과에 남아야 할 때
결과·효과
fixture의 business_tx 21행 모두가 join 전 후보 집합에 들어간다.
비유의 한계
FROM 시작은 fixture가 항상 21행이라는 운영 불변식을 만들지 않는다.
22줄F08-L22 LEFT JOIN business_tx AS original_tx 같은 거래 보관함을 원본 역할로 한 번 더 열되 연결 실패 자리도 비워 둔다. 같은 business_tx table을 original_tx 별칭으로 LEFT JOIN한다.
입력
current side마다 original 후보를 0개 또는 1개 붙일 때
결과·효과
각 current row에 0개 또는 1개의 original side가 NULL 보존 방식으로 붙는다.
비유의 한계
FK·unique key가 달라진 schema에서는 join cardinality 가정도 재검토해야 한다.
23줄F08-L23 ON original_tx.tx_id = current_tx.original_tx_id 현재 끈의 UUID와 과거 영수증 UUID가 같은 경우만 매듭짓는다. 원거래 tx_id와 현재 행의 original_tx_id가 같은 행을 연결한다.
입력
REQ-211·REQ-212를 REQ-201 row와 실제로 이어 줄 때
결과·효과
REQ-211·REQ-212의 original side가 REQ-201 transaction으로 일치한다.
비유의 한계
request_id가 아니라 tx_id equality를 쓰므로 두 식별자를 혼동하면 안 된다.
24줄F08-L24 ORDER BY current_tx.occurred_at, current_tx.tx_id; 발생 시각 순으로 놓고 동시각이면 UUID를 두 번째 정렬표로 쓴다. 현재 transaction의 occurred_at과 tx_id 오름차순으로 결과를 결정적으로 정렬한다.
입력
fixture 결과를 반복 실행해 같은 순서로 비교할 때
결과·효과
같은 timestamp에서도 tx_id가 tie-breaker가 되어 재실행 순서가 안정된다.
비유의 한계
ORDER BY는 link 상태·행 수·금액을 바꾸지 않고 표시 순서만 정한다.
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · self LEFT JOIN히토리 → 니지카 → 료 → 키타
  1. 히토리

    같은 table을 두 번 쓰는 이유가 뭐예요?

  2. 니지카

    current_tx는 지금 행, original_tx는 original_tx_id가 가리킨 과거 행 역할을 맡아.

  3. 별칭은 역할만 나누며 원거래가 실제로 유효한 reversal인지 판정하지 않아.

  4. 키타

    REQ-211 current row와 REQ-201 original row의 tx_id equality를 한 쌍으로 추적하면 역할 차이가 보여요.

05

STEP 05 / 13

원본 코드 조각

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

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

F08-C01 · provenance, grain, and search path1–5줄
1–5줄 원본
-- W14-SQL-Q19 illustrative example, not a shipped workbook answer.
-- Input grain: one row per business_tx; output grain: one row per current transaction.
-- LEFT JOIN keeps ordinary transactions whose original_tx_id is NULL.
-- Supplied fixture oracle: 21 rows; REQ-211/REQ-212 link to REQ-201; 19 rows have no original.
SET search_path TO :"workbook_schema", public;
F08-C02 · current and original columns6–15줄
6–15줄 원본

SELECT
    current_tx.tx_id AS current_tx_id,
    current_tx.request_id AS current_request_id,
    current_tx.tx_type AS current_type,
    current_tx.amount AS current_amount,
    current_tx.original_tx_id,
    original_tx.request_id AS original_request_id,
    original_tx.tx_type AS original_type,
    original_tx.amount AS original_amount,
F08-C03 · link-state classification16–20줄
16–20줄 원본
    CASE
        WHEN current_tx.original_tx_id IS NULL THEN 'NO_ORIGINAL'
        WHEN original_tx.tx_id IS NULL THEN 'BROKEN_REFERENCE'
        ELSE 'LINKED'
    END AS link_state
F08-C04 · self join and deterministic order21–24줄
21–24줄 원본
FROM business_tx AS current_tx
LEFT JOIN business_tx AS original_tx
  ON original_tx.tx_id = current_tx.original_tx_id
ORDER BY current_tx.occurred_at, current_tx.tx_id;
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 23 / 23

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

원본한국어 번역
1-- W14-SQL-Q19 illustrative example, not a shipped workbook answer.Q19를 위해 만든 학습용 SQL이며 workbook이 배포한 정본 답안이 아님을 밝힌다.
2-- Input grain: one row per business_tx; output grain: one row per current transaction.입력은 business_tx 한 행, 출력은 현재 transaction 한 행이라는 grain을 선언한다.
3-- LEFT JOIN keeps ordinary transactions whose original_tx_id is NULL.original_tx_id가 NULL인 보통 transaction도 LEFT JOIN으로 보존한다고 설명한다.
4-- Supplied fixture oracle: 21 rows; REQ-211/REQ-212 link to REQ-201; 19 rows have no original.fixture 기대값을 전체 21행, 연결 reversal 2행, 원거래 없는 19행으로 고정한다.
5SET search_path TO :"workbook_schema", public;psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
7SELECT현재 transaction과 원거래 정보를 한 행에 내보낼 SELECT를 시작한다.
8 current_tx.tx_id AS current_tx_id,현재 transaction의 tx_id를 current_tx_id로 출력한다.
9 current_tx.request_id AS current_request_id,현재 transaction의 request_id를 current_request_id로 출력한다.
10 current_tx.tx_type AS current_type,현재 transaction의 tx_type을 current_type으로 출력한다.
11 current_tx.amount AS current_amount,현재 transaction의 amount를 current_amount로 출력한다.
12 current_tx.original_tx_id,현재 transaction에 기록된 original_tx_id를 그대로 출력한다.
13 original_tx.request_id AS original_request_id,self join으로 찾은 원거래의 request_id를 original_request_id로 출력한다.
14 original_tx.tx_type AS original_type,원거래의 tx_type을 original_type으로 출력한다.
15 original_tx.amount AS original_amount,원거래의 amount를 original_amount로 출력한다.
16 CASE원거래 연결 상태 문자열을 고를 CASE를 시작한다.
17 WHEN current_tx.original_tx_id IS NULL THEN 'NO_ORIGINAL'현재 행의 original_tx_id가 NULL이면 NO_ORIGINAL로 분류한다.
18 WHEN original_tx.tx_id IS NULL THEN 'BROKEN_REFERENCE'ID는 있는데 join한 원거래가 없으면 BROKEN_REFERENCE로 분류한다.
19 ELSE 'LINKED'앞 두 조건이 아니면 실제 원거래가 연결된 LINKED로 분류한다.
20 END AS link_stateCASE를 닫고 계산 열 이름을 link_state로 정한다.
21FROM business_tx AS current_tx모든 현재 transaction을 보존할 왼쪽 relation을 business_tx current_tx로 정한다.
22LEFT JOIN business_tx AS original_tx같은 business_tx table을 original_tx 별칭으로 LEFT JOIN한다.
23 ON original_tx.tx_id = current_tx.original_tx_id원거래 tx_id와 현재 행의 original_tx_id가 같은 행을 연결한다.
24ORDER BY current_tx.occurred_at, current_tx.tx_id;현재 transaction의 occurred_at과 tx_id 오름차순으로 결과를 결정적으로 정렬한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

모든 current business_tx를 보존하면서 original_tx_id로 같은 table을 LEFT JOIN해 reversal과 원거래 정보를 나란히 보여 준다.

문법 해부

  • current_tx 21행을 기준으로 시작한다.
  • original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.
  • NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.

실행 순서

  1. original_tx_id로 REQ-201을 찾음
  2. 같은 REQ-201을 찾음
  3. original_tx_id NULL
  4. 없는 tx_id 참조를 막음

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

F08-C01 · provenance, grain, and search path
문법 해부
네 주석은 Q19가 noncanonical illustrative SQL임과 current transaction grain, NULL original 보존, fixture 21행·연결 2행·미연결 19행 oracle을 밝히고 search_path를 workbook schema로 둔다.
실제 값 추적
제공 fixture에서는 REQ-211과 REQ-212가 REQ-201을 가리키고 나머지 19 current rows는 original 없이 남는다는 대조 기준이 생긴다.
정상 예
격리 workbook_schema를 준비한 뒤 query 결과가 21 rows인지 확인하는 사용은 이 provenance와 맞다.
틀린 예·반례
주석 숫자를 실행 evidence나 shipped workbook answer로 취급하면 source 지위와 oracle 역할을 혼동한다.
착각 방지
Q19는 모든 current transaction을 보존하므로 reversal-only report에는 별도 filter가 필요하다.
하지 않는 일
search_path 변수 준비, fixture loading, production schema 호환성은 이 범위가 보장하지 않는다.
다음 연결
다음 ‘current and original columns’ 범위에서는 SELECT는 current_tx_id를 비롯한 current tx id/request/type/amount/original id와 joined original_request_id/type/original_amount를 나란히 projection한다.
F08-C02 · current and original columns
문법 해부
SELECT는 current_tx_id를 비롯한 current tx id/request/type/amount/original id와 joined original_request_id/type/original_amount를 나란히 projection한다.
실제 값 추적
각 output row에는 current side 다섯 aliases와 original side 세 aliases를 담을 projection slots가 생기며 실제 values는 row source가 공급한다.
정상 예
REQ-211 row에서 current request/type/amount와 REQ-201 original request/type/amount를 한 행에 비교하는 것이 intended view다.
틀린 예·반례
projection aliases가 있다는 이유만으로 current와 original 사이의 row relation이 이미 성립했다고 보면 안 된다.
착각 방지
current amount와 original amount를 출력해도 reversal 부호·금액 equality를 assertion하지 않는다.
하지 않는 일
status, customer, ledger effect, reversal validity는 이 column set 밖이다.
다음 연결
다음 ‘link-state classification’ 범위에서는 CASE는 current original_tx_id NULL이면 NO_ORIGINAL, id는 있으나 joined original tx_id가 NULL이면 BROKEN_REFERENCE, 그 밖은 LINKED로 분류해 link_state를 만든다.
F08-C03 · link-state classification
문법 해부
CASE는 current original_tx_id NULL이면 NO_ORIGINAL, id는 있으나 joined original tx_id가 NULL이면 BROKEN_REFERENCE, 그 밖은 LINKED로 분류해 link_state를 만든다.
실제 값 추적
한 row의 current original_tx_id와 original tx_id null 여부 조합에 따라 NO_ORIGINAL, BROKEN_REFERENCE, LINKED 셋 중 하나가 계산된다.
정상 예
non-NULL dangling id를 가진 손상 fixture를 따로 만들면 BROKEN_REFERENCE branch가 선택되는 것이 valid diagnostic example다.
틀린 예·반례
제공 FK fixture에서 BROKEN_REFERENCE 한 행이 반드시 나온다고 기대하면 oracle과 어긋난다.
착각 방지
LINKED는 target row 존재만 말하며 그 transaction이 허용된 original인지 업무 규칙을 판정하지 않는다.
하지 않는 일
CASE expression은 현재 row에 label 하나만 계산하며 전체 row 보존 방식이나 출력 순서를 정하지 않는다.
다음 연결
다음 ‘self join and deterministic order’ 범위에서는 business_tx current_tx를 왼쪽에 두고 같은 table original_tx를 original tx_id equality로 LEFT JOIN한 뒤 occurred_at과 tx_id로 정렬한다.
F08-C04 · self join and deterministic order
문법 해부
business_tx current_tx를 왼쪽에 두고 같은 table original_tx를 original tx_id equality로 LEFT JOIN한 뒤 occurred_at과 tx_id로 정렬한다.
실제 값 추적
current 21 rows가 기준 집합에 남고 두 reversal은 REQ-201 side를 얻으며 NULL original rows도 탈락하지 않는다.
정상 예
동일 occurred_at이 있어도 tx_id tie-breaker가 있어 반복 결과 순서를 안정시키는 것이 정상이다.
틀린 예·반례
INNER JOIN으로 바꾸면 original 없는 19 rows가 사라져 Q19 output grain과 oracle을 깨뜨린다.
착각 방지
join key는 request_id가 아니라 tx_id/original_tx_id UUID 관계라는 점을 바꾸어 읽지 않는다.
하지 않는 일
ORDER BY는 row content와 link_state를 검증하지 않고 display order만 결정한다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 ORDER BY는 row content와 link_state를 검증하지 않고 display order만 결정한다.
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · NULL 상태히토리 → 니지카 → 료 → 키타
  1. 히토리

    original_tx_id가 NULL이면 깨진 참조인가요?

  2. 니지카

    아니야. 애초에 원거래가 필요 없는 보통 transaction이므로 NO_ORIGINAL이야.

  3. ID가 있는데 대상 행이 없을 때만 BROKEN_REFERENCE branch로 가.

  4. 키타

    NULL id와 non-NULL id/NULL joined row 두 입력을 CASE에 각각 넣어 다른 label인지 확인할게요.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1REQ-211 reversaloriginal_tx_id로 REQ-201을 찾음LINKED와 original columns 생성원거래 정책의 정당성은 미검증
2REQ-212 reversal같은 REQ-201을 찾음두 번째 LINKED 행 생성두 reversal의 업무 허용 여부는 미검증
3ordinary tx 19행original_tx_id NULLNO_ORIGINAL로 그대로 보존reversal-only 보고서가 아님
4fixture FK없는 tx_id 참조를 막음BROKEN_REFERENCE 0행 기대FK가 없거나 훼손된 데이터는 다를 수 있음
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · fixture 두 연결히토리 → 니지카 → 료 → 키타
  1. 히토리

    어떤 요청이 원거래와 연결돼요?

  2. 니지카

    REQ-211과 REQ-212가 모두 REQ-201 transaction을 가리켜 LINKED 두 행이 돼.

  3. 나머지 19행은 삭제되지 않고 NO_ORIGINAL로 남아.

  4. 키타

    두 linked row의 original_request_id가 모두 REQ-201인지 결과표에서 대조하면 돼요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

PostgreSQL join

business_tx를 current/original 두 별칭으로 읽어 UUID equality로 연결한다.

planner 선택과 성능은 이 예시가 측정하지 않는다.
workbook fixture

21 current rows 중 두 행만 REQ-201을 가리킨다.

운영 데이터 cardinality로 일반화하지 않는다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ INNER JOIN으로도 21행이 나온다

왜 틀리나 원거래 없는 19행이 탈락한다

바르게 읽기 LEFT JOIN으로 current side를 보존한다

반례 fixture에서는 linked 두 행만 남는다

❌ 모든 결과가 reversal이다

왜 틀리나 current side에 모든 business_tx를 둔다

바르게 읽기 link_state로 상태를 읽는다

반례 NO_ORIGINAL 19행도 결과다

❌ BROKEN_REFERENCE가 정상 fixture에서도 한 행 나온다

왜 틀리나 FK가 원거래 없는 ID를 막는다

바르게 읽기 현재 oracle은 0행이라고 본다

반례 FK를 제거한 손상 fixture라면 달라진다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

정본성

workbook 정본 답안을 보장하지 않는다

이 책임을 맡는 곳: 학습 과제 계약
업무 정책

어떤 reversal이 유효한지 판정하지 않는다

이 책임을 맡는 곳: 도메인 규칙
범위

reversal만 필터링한 보고서를 만들지 않는다

이 책임을 맡는 곳: 후속 WHERE 정책
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · BROKEN_REFERENCE히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 현재 결과에는 깨진 참조가 없나요?

  2. 니지카

    제공 fixture와 FK가 없는 원거래 ID를 허용하지 않기 때문이야.

  3. 손상 데이터를 일부러 만들거나 FK가 없으면 결과가 달라질 수 있어.

  4. 키타

    현재 oracle에서는 BROKEN_REFERENCE count가 0이라는 fixture-bound 기대만 적고 보편 결론은 빼 둘게요.

12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • 왜 business_tx를 자기 자신과 LEFT JOIN하나?
  • 왜 ordinary transaction 19행도 결과에 남나?
  • BROKEN_REFERENCE가 fixture에서 나오지 않는 이유는 무엇인가?

2단계 · 코드 조각 재조립

  1. current_tx 21행을 기준으로 시작한다.
  2. original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.
  3. NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.

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

illustrative/sql/W14-SQL-Q19.sql 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture·selector 숫자를 정확히 적었는가
  • 보장하지 않는 범위를 함께 적었는가
13

STEP 13 / 13

전체 원본 정답

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님 · fixture-bound illustrative SQLillustrative/sql/W14-SQL-Q19.sqlSHA-256 b879b0ca0b5cb2e9b0afd37f1e90db14b50c8ed09f3e0ed2fbddd0d9516c9991
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 전체
-- W14-SQL-Q19 illustrative example, not a shipped workbook answer.
-- Input grain: one row per business_tx; output grain: one row per current transaction.
-- LEFT JOIN keeps ordinary transactions whose original_tx_id is NULL.
-- Supplied fixture oracle: 21 rows; REQ-211/REQ-212 link to REQ-201; 19 rows have no original.
SET search_path TO :"workbook_schema", public;

SELECT
    current_tx.tx_id AS current_tx_id,
    current_tx.request_id AS current_request_id,
    current_tx.tx_type AS current_type,
    current_tx.amount AS current_amount,
    current_tx.original_tx_id,
    original_tx.request_id AS original_request_id,
    original_tx.tx_type AS original_type,
    original_tx.amount AS original_amount,
    CASE
        WHEN current_tx.original_tx_id IS NULL THEN 'NO_ORIGINAL'
        WHEN original_tx.tx_id IS NULL THEN 'BROKEN_REFERENCE'
        ELSE 'LINKED'
    END AS link_state
FROM business_tx AS current_tx
LEFT JOIN business_tx AS original_tx
  ON original_tx.tx_id = current_tx.original_tx_id
ORDER BY current_tx.occurred_at, current_tx.tx_id;
09

Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기

illustrative/sql/W14-SQL-Q20.sql

fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F09
34줄 연결34줄 번역5 chunks
01

STEP 01 / 13

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

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

오늘의 한 문장

business_tx를 기준으로 ledger를 LEFT JOIN해 0건 transaction까지 세고, 명시한 fixture 정책의 기대 개수와 실제 개수를 비교한다.

  1. 왜 COUNT(*) 대신 COUNT(ledger.entry_id)를 쓰나?
  2. 왜 business_tx에서 LEFT JOIN하나?
  3. cardinality_ok가 금액 보존까지 증명하지 못하는 이유는 무엇인가?
이 파일에서 끝까지 다시 쓰는 값fixture tx rows=21success transfer expected=2other supported success expected=1non-success expected=0REQ-302 actual=1 expected=2
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · 입력과 출력 grain히토리 → 니지카 → 료 → 키타
  1. 히토리

    ledger가 두 장이면 transaction도 두 행으로 나오나요?

  2. 니지카

    join 중에는 늘어나지만 tx별 GROUP BY가 최종 한 행으로 다시 묶어.

  3. GROUP BY 열을 빠뜨리면 SQL 오류나 다른 grain이 생길 수 있어.

  4. 키타

    join 중간 row와 counted 최종 row를 나눠 세면 21 transaction grain을 확인할 수 있어요.

02

STEP 02 / 13

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

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

거래별 원장 장수 세기 표

Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기

니지카는 거래 21개마다 원장 종이를 붙이고, 종이가 없는 거래도 빈 칸째 남겨 실제 장수를 센다.

료는 성공 이체 2장·다른 지원 성공 1장·그 밖 0장이라는 규칙이 fixture용이며, REQ-302 하나만 1 대 2로 어긋난다고 표시한다.

딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.

03

STEP 03 / 13

초등학생도 이해하는 설명

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

빈 거래도 남기기

business_tx에서 LEFT JOIN해 ledger 0건도 결과에 남긴다.

코드 연결
20~22줄
비유
거래별 원장 장수 세기 표에서 빈 거래도 남기기 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

실제 장수 세기

NULL placeholder를 빼는 COUNT(entry_id)로 0·1·2를 센다.

코드 연결
13줄
비유
거래별 원장 장수 세기 표에서 실제 장수 세기 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.

기대와 비교

fixture CASE의 2·1·0과 실제 수를 Boolean으로 비교한다.

코드 연결
14~19·32줄
비유
거래별 원장 장수 세기 표에서 기대와 비교 순서만 본다.
비유의 끝
이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
04

STEP 04 / 13

비유 ↔ 코드 전체 연결표

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

단어 몇 개만 뽑은 표가 아닙니다.fixture 기반 학습용 SQL 예시 · 정본 답안 아님에서 비어 있지 않은 모든 줄을 원본 줄 번호 그대로 연결했습니다.34 / 34 연결
정확한 원본 줄STARRY 비유실제 뜻·입력·결과·한계
1줄F09-L01 -- W14-SQL-Q20 illustrative example, not a shipped workbook answer. 원장 장수 점검표에 ‘fixture 연습본’ 표찰을 붙인다. Q20을 위해 만든 학습용 SQL이며 workbook 정본 답안이 아님을 밝힌다.
입력
Q20 query가 공식 workbook 답안인지 구분할 때
결과·효과
Q20 장수표의 지위가 제공 정답이 아니라 transaction-ledger 비교 연습본으로 한정된다.
비유의 한계
출처 주석은 CASE 정책과 실제 DB 결과를 자동 검증하지 않는다.
2줄F09-L02 -- Input grain: business_tx joined to zero or more ledger_entry rows. 거래 한 장에 원장 종이가 여러 장 붙을 수 있는 입력 묶음을 그린다. 입력은 business_tx와 0개 이상의 ledger_entry를 join한 행이라고 선언한다.
입력
LEFT JOIN 직후 같은 tx가 0개 이상 ledger와 만나는 모양을 설명할 때
결과·효과
한 transaction에 원장 여러 행이 붙을 수 있다는 집계 입력 모양이 고정된다.
비유의 한계
입력 grain은 최종 출력이 중복되지 않는다는 보장이 아니며 GROUP BY가 필요하다.
3줄F09-L03 -- Output grain: one row per business transaction, including transactions with zero ledger rows. 빈 원장 칸이 있어도 거래별 결과표 한 줄을 남긴다고 약속한다. 출력은 ledger가 0건인 transaction도 포함해 transaction당 한 행이라고 선언한다.
입력
ledger 0건 transaction까지 audit 대상에 포함할 때
결과·효과
LEFT JOIN 뒤에도 결과 cardinality 목표가 transaction 21행으로 유지된다.
비유의 한계
output grain 주석만으로 실제 SQL의 LEFT JOIN·GROUP BY 정확성은 생기지 않는다.
4줄F09-L04 -- The expected count below is an explicit fixture-bound teaching policy, not a universal ledger rule. 2장·1장·0장 기대표에 ‘이 연습 데이터 전용’ 스티커를 붙인다. 아래 기대 개수는 보편 원장 법칙이 아니라 fixture 전용 교육 정책이라고 제한한다.
입력
expected_ledger_count CASE를 도메인 규칙으로 오해할 위험이 있을 때
결과·효과
CASE의 2·1·0을 운영 불변식이 아닌 비교용 fixture 기준으로 읽게 된다.
비유의 한계
posting 모델이 다른 시스템에는 같은 expected count를 적용할 수 없다.
5줄F09-L05 SET search_path TO :"workbook_schema", public; 원장과 거래를 찾을 격리 workbook 서랍을 검색 순서 맨 앞에 둔다. psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
입력
business_tx와 ledger_entry를 schema 접두사 없이 참조할 때
결과·효과
schema 접두사 없는 두 table 이름이 격리 workbook에서 해석된다.
비유의 한계
변수 설정·fixture schema 생성 실패는 이 SET 한 줄이 복구하지 못한다.
7줄F09-L07 WITH counted AS ( 실제 장수와 기대 장수를 계산할 중간 계산대 counted를 연다. transaction별 실제·기대 원장 수를 만들 counted CTE를 시작한다.
입력
최종 비교 전에 transaction별 집계 relation이 필요할 때
결과·효과
뒤 join과 GROUP BY 결과를 counted라는 중간 relation으로 만들 준비가 된다.
비유의 한계
CTE 이름만 선언한 시점에는 join·count·group 결과가 존재하지 않는다.
8줄F09-L08 SELECT 중간 계산대에 거래 정보와 두 숫자를 놓을 선택판을 펼친다. counted CTE의 SELECT 목록을 시작한다.
입력
counted row가 보존해야 할 열을 차례로 고르기 시작할 때
결과·효과
transaction 정보와 두 count를 계산할 projection 범위가 열린다.
비유의 한계
SELECT 표지만으로 transaction당 한 group은 아직 만들어지지 않는다.
9줄F09-L09 tx.tx_id, 각 계산 묶음에 변하지 않는 transaction UUID 꼬리표를 붙인다. 그룹 식별자이자 뒤 출력용인 tx_id를 고른다.
입력
join으로 늘어난 ledger 행을 원래 tx별로 다시 묶을 때
결과·효과
각 group은 원래 transaction UUID 하나에 묶일 식별 열을 갖는다.
비유의 한계
UUID 하나는 request 표시와 expected count 정책을 설명하지 않는다.
10줄F09-L10 tx.request_id, 사람이 REQ-302를 바로 찾도록 요청 번호표도 남긴다. 사람이 fixture 행을 찾기 쉬운 request_id를 고른다.
입력
mismatch 결과를 fixture oracle과 눈으로 맞출 때
결과·효과
REQ-302 같은 mismatch를 사람이 찾을 표시값이 group에 남는다.
비유의 한계
request_id를 표시한다고 중복 요청이나 idempotency를 검사하는 것은 아니다.
11줄F09-L11 tx.tx_type, 기대 장수표의 이체·개설·입출금 분기를 고를 종류 카드를 놓는다. 기대 원장 수 정책에 쓸 tx_type을 고른다.
입력
CASE가 TRANSFER와 네 단일원장 종류를 구분할 때
결과·효과
TRANSFER인지 다른 지원 종류인지 CASE가 판정할 값이 보존된다.
비유의 한계
tx_type만으로 성공 여부가 정해지지 않아 status 열도 함께 필요하다.
12줄F09-L12 tx.status, 거래 종류 카드 옆에 SUCCESS인지 알려 주는 상태 신호를 둔다. 기대 원장 수 정책에 쓸 status를 고른다.
입력
실패 거래의 expected count를 0으로 보내기 전에
결과·효과
SUCCESS인지 아닌지 CASE가 판정할 값이 보존된다.
비유의 한계
status가 실제 posting 완료를 진실하게 반영하는지는 이 query가 검증하지 않는다.
13줄F09-L13 COUNT(ledger.entry_id) AS actual_ledger_count, 붙어 있는 원장 종이 중 진짜 entry_id가 있는 장만 계수기에 넣는다. NULL placeholder를 빼고 실제 ledger.entry_id 개수를 actual_ledger_count로 센다.
입력
LEFT JOIN으로 NULL 확장행이 생긴 뒤 실제 ledger 개수를 구할 때
결과·효과
ledger 0건은 0, 한 건은 1, 두 건은 2인 actual count가 만들어진다.
비유의 한계
COUNT는 행 수만 세며 금액 합계·방향·중복 원인을 확인하지 않는다.
14줄F09-L14 CASE 거래마다 2·1·0 기대 숫자를 고르는 규칙판을 연다. fixture 정책의 expected_ledger_count를 고를 CASE를 시작한다.
입력
actual count 옆에 fixture 기준 expected count가 필요할 때
결과·효과
status와 type을 위에서부터 비교하는 fixture 정책 분기가 열린다.
비유의 한계
CASE 시작은 어느 branch도 선택하지 않았고 조건 순서가 결과를 좌우한다.
15줄F09-L15 WHEN tx.status = 'SUCCESS' AND tx.tx_type = 'TRANSFER' THEN 2 성공 이체 카드는 양쪽 posting을 뜻하는 ‘2’ 칸으로 보낸다. SUCCESS TRANSFER라면 기대 원장 수를 2로 정한다.
입력
status SUCCESS와 tx_type TRANSFER가 동시에 참일 때
결과·효과
성공 이체 group의 expected count가 2로 정해진다.
비유의 한계
두 행 기대는 교육 fixture 정책이며 금액 균형이 맞다는 뜻이 아니다.
16줄F09-L16 WHEN tx.status = 'SUCCESS' 첫 조건을 통과하지 못한 성공 거래용 두 번째 검사문을 펼친다. 그 밖의 SUCCESS 종류를 검사하는 두 번째 WHEN을 시작한다.
입력
TRANSFER가 아닌 SUCCESS row가 단일 원장 지원 종류인지 볼 때
결과·효과
성공이면서 단일 원장을 기대할 종류인지 다음 조건으로 평가가 이어진다.
비유의 한계
줄이 AND로 이어져 다음 IN 조건까지 읽어야 완전한 predicate가 된다.
17줄F09-L17 AND tx.tx_type IN ('OPENING', 'DEPOSIT', 'WITHDRAW', 'REVERSAL') THEN 1 개설·입금·출금·취소 성공 카드를 ‘1’ 칸으로 모은다. SUCCESS인 OPENING·DEPOSIT·WITHDRAW·REVERSAL이면 기대 원장 수를 1로 정한다.
입력
SUCCESS와 네 tx_type 목록 조건이 함께 참일 때
결과·효과
네 지원 SUCCESS 종류 group의 expected count가 1로 정해진다.
비유의 한계
목록 밖 새 transaction 종류는 자동으로 expected 1을 얻지 않는다.
18줄F09-L18 ELSE 0 성공 조건 밖의 카드는 나머지 바구니 ‘0’으로 보낸다. 앞 조건 밖의 transaction은 기대 원장 수를 0으로 정한다.
입력
FAILED 또는 지원 목록 밖 transaction이 앞 branch에 들지 않을 때
결과·효과
FAILED 등 나머지 group의 expected count가 0으로 정해진다.
비유의 한계
expected 0은 실제 ledger가 없어야 한다는 fixture 비교값일 뿐 원인 설명은 아니다.
19줄F09-L19 END AS expected_ledger_count 규칙판을 닫아 고른 숫자를 expected_ledger_count 이름표로 내보낸다. CASE를 닫고 계산 열 이름을 expected_ledger_count로 정한다.
입력
각 transaction에 CASE branch 하나가 결정된 다음
결과·효과
각 transaction group에 기대값 2·1·0 중 하나가 붙는다.
비유의 한계
계산 열 별칭은 actual count와 같음을 판정하지 않고 뒤 비교가 맡는다.
20줄F09-L20 FROM business_tx AS tx 거래 명부 스물한 장을 장수 점검의 기준 목록으로 먼저 펼친다. 전체 transaction을 보존할 기준 relation을 business_tx tx로 정한다.
입력
ledger가 전혀 없는 transaction도 후보에서 잃지 않으려 할 때
결과·효과
fixture의 business_tx 21행이 집계 전 기준 집합에 들어간다.
비유의 한계
fixture 21행은 현재 seed cardinality이며 production 건수를 뜻하지 않는다.
21줄F09-L21 LEFT JOIN ledger_entry AS ledger 원장 종이를 왼쪽 방식으로 붙여 빈 거래에도 빈 자리 하나를 남긴다. ledger가 없는 transaction도 남기도록 ledger_entry를 LEFT JOIN한다.
입력
각 business_tx에 ledger_entry가 0개 이상 존재할 때
결과·효과
ledger가 0건인 transaction도 NULL 확장행으로 group 후보에 남는다.
비유의 한계
LEFT JOIN만으로 중복 ledger나 잘못된 tx_id reference를 고치지는 않는다.
22줄F09-L22 ON ledger.tx_id = tx.tx_id 거래 UUID와 원장에 적힌 tx UUID가 같은 종이만 해당 묶음에 붙인다. ledger.tx_id와 transaction tx_id가 같은 원장 행을 붙인다.
입력
ledger row를 올바른 transaction group으로 배정할 때
결과·효과
각 transaction에는 자기 tx_id를 가진 ledger만 연결된다.
비유의 한계
equality는 원장의 account·금액·entry_type 정합성을 비교하지 않는다.
23줄F09-L23 GROUP BY tx.tx_id, tx.request_id, tx.tx_type, tx.status 늘어난 종이를 거래의 네 표찰별로 묶어 계산대 한 칸으로 접는다. transaction 식별·표시·정책 열로 묶어 transaction당 한 집계 group을 만든다.
입력
COUNT 결과를 transaction당 한 행으로 축약할 때
결과·효과
join으로 늘어난 행이 tx_id별 한 group으로 다시 줄어든다.
비유의 한계
선택 열이 바뀌면 GROUP BY 계약도 함께 바뀌며 현재 네 열만 기준이다.
24줄F09-L24 ) 거래별 실제·기대 장수 계산을 마치고 counted 계산대를 닫는다. counted CTE를 닫는다.
입력
GROUP BY가 모든 tx group의 count와 CASE 값을 만든 뒤
결과·효과
transaction별 actual/expected count를 가진 counted relation이 완성된다.
비유의 한계
CTE 종료만으로 최종 열 순서나 cardinality_ok는 아직 출력되지 않는다.
25줄F09-L25 SELECT 중간 계산표에서 최종 검토표에 옮길 일곱 칸을 새로 연다. counted 결과를 보여 줄 최종 SELECT를 시작한다.
입력
counted 결과를 사람이 읽을 보고서 형태로 투영할 때
결과·효과
최종 보고서에 실을 일곱 열의 선택이 시작된다.
비유의 한계
최종 SELECT 시작은 정렬과 Boolean 비교가 뒤에 남아 있다.
26줄F09-L26 tx_id, 검토표 첫 칸에 transaction UUID를 옮겨 원본 행을 추적하게 한다. 최종 결과에 tx_id를 출력한다.
입력
mismatch row를 DB의 tx 식별자로 다시 찾아야 할 때
결과·효과
각 결과 행에 transaction UUID가 표시된다.
비유의 한계
tx_id 표시는 request 맥락이나 원장 내용의 옳음을 말하지 않는다.
27줄F09-L27 request_id, 기계 UUID 옆에 REQ 요청 이름을 붙여 fixture 대조를 쉽게 만든다. 최종 결과에 request_id를 출력한다.
입력
REQ-302 같은 oracle row를 사람이 검색할 때
결과·효과
각 결과 행에 REQ-* 식별값이 표시된다.
비유의 한계
표시 열은 요청 중복·순서·idempotency를 검증하지 않는다.
28줄F09-L28 tx_type, 각 결과줄에 TRANSFER 같은 거래 종류 카드를 다시 보여 준다. 최종 결과에 tx_type을 출력한다.
입력
expected count가 어느 type branch에서 왔는지 해석할 때
결과·효과
TRANSFER·DEPOSIT 같은 type 값이 기대 장수 branch를 해석할 근거로 노출된다.
비유의 한계
tx_type projection은 CASE가 올바른 업무 정책인지 증명하지 않는다.
29줄F09-L29 status, 종류 옆에 SUCCESS·FAILED 상태표를 함께 표시한다. 최종 결과에 status를 출력한다.
입력
expected 2·1·0 선택의 status 조건을 결과에서 확인할 때
결과·효과
SUCCESS 또는 FAILED 상태가 type 옆에 남아 CASE 선택 과정을 되짚을 수 있다.
비유의 한계
상태 출력은 transaction과 ledger 사이의 상태 전이 정합성을 검사하지 않는다.
30줄F09-L30 actual_ledger_count, 계수기가 센 실제 원장 장수를 검토표에 적는다. 최종 결과에 실제 원장 수를 출력한다.
입력
각 tx group의 ledger.entry_id count를 확인할 때
결과·효과
각 결과 행에 COUNT로 얻은 원장 수가 표시된다.
비유의 한계
actual 숫자만으로 어떤 entry가 빠지거나 중복됐는지는 알 수 없다.
31줄F09-L31 expected_ledger_count, fixture 규칙판이 고른 기대 장수를 실제 숫자 옆에 놓는다. 최종 결과에 fixture 정책상 기대 원장 수를 출력한다.
입력
두 수를 비교하기 전에 CASE 결과를 투명하게 보여 줄 때
결과·효과
각 결과 행에 CASE로 얻은 fixture 기대 수가 표시된다.
비유의 한계
expected 숫자는 학습 정책이며 외부 ledger contract의 권위가 아니다.
32줄F09-L32 actual_ledger_count = expected_ledger_count AS cardinality_ok 두 장수표가 같으면 초록, 다르면 빨강인 Boolean 램프를 켠다. 실제 수와 기대 수가 같은지 Boolean cardinality_ok로 계산한다.
입력
actual_ledger_count와 expected_ledger_count를 같은 row에서 비교할 때
결과·효과
REQ-302만 false이고 나머지 fixture 행은 true인 비교 열이 생긴다.
비유의 한계
true는 cardinality 하나만 맞다는 뜻이고 금액 보존·차변대변 균형은 포함하지 않는다.
33줄F09-L33 FROM counted 완성된 counted 계산표를 최종 검토표의 유일한 공급원으로 삼는다. 최종 출력의 입력 relation을 counted CTE로 정한다.
입력
집계 전 join row가 아니라 transaction별 중간 결과를 출력할 때
결과·효과
완성된 21개 transaction 집계 행이 최종 projection 입력이 된다.
비유의 한계
FROM counted는 새 검증을 수행하지 않고 이미 계산한 열만 제공한다.
34줄F09-L34 ORDER BY request_id; REQ 이름표 순서로 줄을 세워 반복 대조가 흔들리지 않게 한다. request_id 오름차순으로 결과를 결정적으로 정렬한다.
입력
21개 fixture 결과를 oracle과 일정한 순서로 비교할 때
결과·효과
결과가 REQ-* 오름차순으로 안정되어 oracle 대조가 쉬워진다.
비유의 한계
정렬은 false 행을 걸러내지 않으며 mismatch도 모두 결과에 남긴다.
35줄F09-L35 -- Supplied fixture oracle: 21 rows; REQ-302 is the only mismatch (actual 1, expected 2). 검토표 아래에 REQ-302 하나가 빨간 칸이라는 정답 메모를 단다. fixture 21행 중 REQ-302만 actual 1, expected 2인 mismatch라고 oracle을 기록한다.
입력
실행 결과에서 fixture mismatch 개수와 값을 확인할 때
결과·효과
검토자는 mismatch가 REQ-302 하나인지 실제 결과와 바로 대조할 수 있다.
비유의 한계
주석 oracle은 query 실행 증거가 아니며 seed가 바뀌면 낡을 수 있다.
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · COUNT 열 선택히토리 → 니지카 → 료 → 키타
  1. 히토리

    왜 COUNT(*)를 쓰지 않나요?

  2. 니지카

    LEFT JOIN의 NULL placeholder를 원장으로 세지 않으려고 non-NULL entry_id만 세는 거야.

  3. COUNT(*)는 ledger 0건 transaction도 join row 하나로 세어 1이 될 수 있어.

  4. 키타

    ledger 없는 tx 하나에서 COUNT(*)=1과 COUNT(entry_id)=0을 나란히 계산해 볼게요.

05

STEP 05 / 13

원본 코드 조각

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

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

F09-C01 · provenance, grains, policy, and search path1–5줄
1–5줄 원본
-- W14-SQL-Q20 illustrative example, not a shipped workbook answer.
-- Input grain: business_tx joined to zero or more ledger_entry rows.
-- Output grain: one row per business transaction, including transactions with zero ledger rows.
-- The expected count below is an explicit fixture-bound teaching policy, not a universal ledger rule.
SET search_path TO :"workbook_schema", public;
F09-C02 · transaction identity and actual count6–13줄
6–13줄 원본

WITH counted AS (
    SELECT
        tx.tx_id,
        tx.request_id,
        tx.tx_type,
        tx.status,
        COUNT(ledger.entry_id) AS actual_ledger_count,
F09-C03 · fixture-bound expected count14–19줄
14–19줄 원본
        CASE
            WHEN tx.status = 'SUCCESS' AND tx.tx_type = 'TRANSFER' THEN 2
            WHEN tx.status = 'SUCCESS'
             AND tx.tx_type IN ('OPENING', 'DEPOSIT', 'WITHDRAW', 'REVERSAL') THEN 1
            ELSE 0
        END AS expected_ledger_count
F09-C04 · left join and one-row grouping20–24줄
20–24줄 원본
    FROM business_tx AS tx
    LEFT JOIN ledger_entry AS ledger
      ON ledger.tx_id = tx.tx_id
    GROUP BY tx.tx_id, tx.request_id, tx.tx_type, tx.status
)
F09-C05 · cardinality result and fixture oracle25–35줄
25–35줄 원본
SELECT
    tx_id,
    request_id,
    tx_type,
    status,
    actual_ledger_count,
    expected_ledger_count,
    actual_ledger_count = expected_ledger_count AS cardinality_ok
FROM counted
ORDER BY request_id;
-- Supplied fixture oracle: 21 rows; REQ-302 is the only mismatch (actual 1, expected 2).
06

STEP 06 / 13

코드 한 줄씩 한국어로 번역

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

전체 번역 34 / 34

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

원본한국어 번역
1-- W14-SQL-Q20 illustrative example, not a shipped workbook answer.Q20을 위해 만든 학습용 SQL이며 workbook 정본 답안이 아님을 밝힌다.
2-- Input grain: business_tx joined to zero or more ledger_entry rows.입력은 business_tx와 0개 이상의 ledger_entry를 join한 행이라고 선언한다.
3-- Output grain: one row per business transaction, including transactions with zero ledger rows.출력은 ledger가 0건인 transaction도 포함해 transaction당 한 행이라고 선언한다.
4-- The expected count below is an explicit fixture-bound teaching policy, not a universal ledger rule.아래 기대 개수는 보편 원장 법칙이 아니라 fixture 전용 교육 정책이라고 제한한다.
5SET search_path TO :"workbook_schema", public;psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
7WITH counted AS (transaction별 실제·기대 원장 수를 만들 counted CTE를 시작한다.
8 SELECTcounted CTE의 SELECT 목록을 시작한다.
9 tx.tx_id,그룹 식별자이자 뒤 출력용인 tx_id를 고른다.
10 tx.request_id,사람이 fixture 행을 찾기 쉬운 request_id를 고른다.
11 tx.tx_type,기대 원장 수 정책에 쓸 tx_type을 고른다.
12 tx.status,기대 원장 수 정책에 쓸 status를 고른다.
13 COUNT(ledger.entry_id) AS actual_ledger_count,NULL placeholder를 빼고 실제 ledger.entry_id 개수를 actual_ledger_count로 센다.
14 CASEfixture 정책의 expected_ledger_count를 고를 CASE를 시작한다.
15 WHEN tx.status = 'SUCCESS' AND tx.tx_type = 'TRANSFER' THEN 2SUCCESS TRANSFER라면 기대 원장 수를 2로 정한다.
16 WHEN tx.status = 'SUCCESS'그 밖의 SUCCESS 종류를 검사하는 두 번째 WHEN을 시작한다.
17 AND tx.tx_type IN ('OPENING', 'DEPOSIT', 'WITHDRAW', 'REVERSAL') THEN 1SUCCESS인 OPENING·DEPOSIT·WITHDRAW·REVERSAL이면 기대 원장 수를 1로 정한다.
18 ELSE 0앞 조건 밖의 transaction은 기대 원장 수를 0으로 정한다.
19 END AS expected_ledger_countCASE를 닫고 계산 열 이름을 expected_ledger_count로 정한다.
20 FROM business_tx AS tx전체 transaction을 보존할 기준 relation을 business_tx tx로 정한다.
21 LEFT JOIN ledger_entry AS ledgerledger가 없는 transaction도 남기도록 ledger_entry를 LEFT JOIN한다.
22 ON ledger.tx_id = tx.tx_idledger.tx_id와 transaction tx_id가 같은 원장 행을 붙인다.
23 GROUP BY tx.tx_id, tx.request_id, tx.tx_type, tx.statustransaction 식별·표시·정책 열로 묶어 transaction당 한 집계 group을 만든다.
24)counted CTE를 닫는다.
25SELECTcounted 결과를 보여 줄 최종 SELECT를 시작한다.
26 tx_id,최종 결과에 tx_id를 출력한다.
27 request_id,최종 결과에 request_id를 출력한다.
28 tx_type,최종 결과에 tx_type을 출력한다.
29 status,최종 결과에 status를 출력한다.
30 actual_ledger_count,최종 결과에 실제 원장 수를 출력한다.
31 expected_ledger_count,최종 결과에 fixture 정책상 기대 원장 수를 출력한다.
32 actual_ledger_count = expected_ledger_count AS cardinality_ok실제 수와 기대 수가 같은지 Boolean cardinality_ok로 계산한다.
33FROM counted최종 출력의 입력 relation을 counted CTE로 정한다.
34ORDER BY request_id;request_id 오름차순으로 결과를 결정적으로 정렬한다.
35-- Supplied fixture oracle: 21 rows; REQ-302 is the only mismatch (actual 1, expected 2).fixture 21행 중 REQ-302만 actual 1, expected 2인 mismatch라고 oracle을 기록한다.
07

STEP 07 / 13

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

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

한 줄로 읽기

business_tx를 기준으로 ledger를 LEFT JOIN해 0건 transaction까지 세고, 명시한 fixture 정책의 기대 개수와 실제 개수를 비교한다.

문법 해부

  • business_tx에서 LEFT JOIN해 ledger 0건도 결과에 남긴다.
  • NULL placeholder를 빼는 COUNT(entry_id)로 0·1·2를 센다.
  • fixture CASE의 2·1·0과 실제 수를 Boolean으로 비교한다.

실행 순서

  1. CASE가 expected=2 선택
  2. LEFT JOIN NULL placeholder 보존
  3. actual 1과 expected 2 비교
  4. tx별 GROUP BY

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

F09-C01 · provenance, grains, policy, and search path
문법 해부
주석은 Q20이 noncanonical illustrative SQL이며 tx-ledger join input, zero-ledger 포함 transaction output, fixture-bound expected count policy임을 밝히고 workbook search_path를 설정한다.
실제 값 추적
ledger row가 없는 transaction도 output에 남기고 expected count는 fixture-bound teaching policy로만 해석해야 한다는 기준이 생긴다.
정상 예
제공 workbook schema를 격리해 실행하고 transaction당 한 output row라는 grain을 확인하는 사용이 provenance와 맞다.
틀린 예·반례
expected count CASE를 모든 금융 원장의 보편 posting 법칙으로 가져가면 source 주석을 위반한다.
착각 방지
학습용 예시는 shipped answer가 아니며 다른 valid SQL이나 운영 정책을 배제하지 않는다.
하지 않는 일
fixture variable, schema creation, transaction type catalog의 완전성은 이 header가 보장하지 않는다.
다음 연결
다음 ‘transaction identity and actual count’ 범위에서는 counted CTE가 tx id/request/type/status를 projection하고 COUNT(ledger.entry_id)를 actual_ledger_count로 이름 붙여 실제 원장 행 수를 계산한다.
F09-C02 · transaction identity and actual count
문법 해부
counted CTE가 tx id/request/type/status를 projection하고 COUNT(ledger.entry_id)를 actual_ledger_count로 이름 붙여 실제 원장 행 수를 계산한다.
실제 값 추적
COUNT(ledger.entry_id)는 이 aggregate에 들어온 rows 가운데 entry_id가 non-NULL인 값의 개수를 actual_ledger_count로 만든다.
정상 예
입력 group에 non-NULL entry_id가 N개라면 actual_ledger_count도 N이 되는 것이 이 expression의 정상 예다.
틀린 예·반례
COUNT(*)는 null 여부와 무관하게 input rows를 세므로 COUNT(ledger.entry_id)와 같은 의미로 바꾸면 안 된다.
착각 방지
actual count는 행 수만 나타내며 amount·entry_type·account direction이 맞는지는 알려 주지 않는다.
하지 않는 일
CTE header와 projection만으로 grouping key나 final output grain은 아직 정해지지 않는다.
다음 연결
다음 ‘fixture-bound expected count’ 범위에서는 CASE는 SUCCESS TRANSFER expected 2, SUCCESS OPENING·DEPOSIT·WITHDRAW·REVERSAL expected 1, 그 밖 expected 0을 fixture policy로 계산한다.
F09-C03 · fixture-bound expected count
문법 해부
CASE는 SUCCESS TRANSFER expected 2, SUCCESS OPENING·DEPOSIT·WITHDRAW·REVERSAL expected 1, 그 밖 expected 0을 fixture policy로 계산한다.
실제 값 추적
현재 row가 SUCCESS이면서 TRANSFER면 expected_ledger_count 2, 나열된 다른 SUCCESS types면 1, 나머지는 0이 된다.
정상 예
FAILED transaction은 type과 관계없이 앞 SUCCESS branch를 통과하지 않아 expected 0이 되는 흐름이 source와 맞다.
틀린 예·반례
새 tx_type이나 다른 posting model에도 자동으로 2·1·0을 적용하면 explicit fixture-bound 제한을 어긴다.
착각 방지
CASE 조건은 expected teaching policy일 뿐 database constraint나 ledger generation 구현이 아니다.
하지 않는 일
금액 보존·double-entry balance·status transition은 expected count 숫자로 증명되지 않는다.
다음 연결
다음 ‘left join and one-row grouping’ 범위에서는 business_tx를 기준으로 ledger_entry를 tx_id로 LEFT JOIN하고 tx id/request/type/status별 GROUP BY한 뒤 counted CTE를 닫는다.
F09-C04 · left join and one-row grouping
문법 해부
business_tx를 기준으로 ledger_entry를 tx_id로 LEFT JOIN하고 tx id/request/type/status별 GROUP BY한 뒤 counted CTE를 닫는다.
실제 값 추적
join으로 늘어난 ledger rows가 transaction별 한 group으로 축약되고 ledger가 없는 transaction도 NULL 확장 row 덕분에 남는다.
정상 예
각 business_tx identity가 ledger 유무와 관계없이 하나의 counted group으로 축약되는 것이 이 range의 intended grain이다.
틀린 예·반례
ledger_entry에서 INNER JOIN으로 시작하면 ledger 0건 transaction이 사라져 output 계약이 깨진다.
착각 방지
GROUP BY 네 열은 transaction identity를 보존하지만 duplicate ledger 원인이나 content를 제거하지 않는다.
하지 않는 일
planner strategy, query cost, concurrent insert snapshot은 이 aggregation source가 보장하지 않는다.
다음 연결
다음 ‘cardinality result and fixture oracle’ 범위에서는 최종 SELECT가 transaction 식별·type·status·actual·expected와 equality Boolean cardinality_ok를 출력하고 request_id로 정렬하며 REQ-302 sole mismatch oracle을 주석으로 남긴다.
F09-C05 · cardinality result and fixture oracle
문법 해부
최종 SELECT가 transaction 식별·type·status·actual·expected와 equality Boolean cardinality_ok를 출력하고 request_id로 정렬하며 REQ-302 sole mismatch oracle을 주석으로 남긴다.
실제 값 추적
제공 fixture 21 rows에서 REQ-302는 actual 1, expected 2라 false이고 나머지 rows는 count equality가 true다.
정상 예
REQ-302 한 row만 cardinality_ok=false인지 확인하는 것이 이 fixture의 exact validation example다.
틀린 예·반례
cardinality_ok true라도 금액과 방향이 모두 틀린 두 ledger rows일 수 있어 원장 correctness 전체를 뜻하지 않는다.
착각 방지
sole mismatch는 current seed oracle이며 새 data에서도 REQ-302 하나뿐이라는 invariant가 아니다.
하지 않는 일
정렬과 Boolean projection은 mismatch 원인 수정, ledger balancing, production alerting을 수행하지 않는다.
다음 연결
이 파일의 마지막 범위다. 전체 source를 다시 읽을 때는 정렬과 Boolean projection은 mismatch 원인 수정, ledger balancing, production alerting을 수행하지 않는다.
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · fixture 기대 CASE히토리 → 니지카 → 료 → 키타
  1. 히토리

    성공 이체는 언제나 원장 두 장인가요?

  2. 니지카

    이 SQL에서는 제공 fixture를 가르치기 위한 기대값 2로 정했어.

  3. 운영 posting model의 보편 규칙이라고 일반화하면 안 돼.

  4. 키타

    SUCCESS TRANSFER 입력을 넣어 expected=2가 되는 것은 확인하되 source 주석의 fixture 표찰도 함께 남겨요.

08

STEP 08 / 13

실제 값 따라가기

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

순서들어온 값코드가 하는 일나온 값·상태경계
1SUCCESS TRANSFERCASE가 expected=2 선택두 ledger면 true모든 금융 시스템의 보편 규칙 아님
2ledger 0건 transactionLEFT JOIN NULL placeholder 보존COUNT(entry_id)=0COUNT(*)면 1로 잘못 셀 수 있음
3REQ-302actual 1과 expected 2 비교cardinality_ok=false금액·방향 mismatch 원인은 미판정
4fixture transaction 21개tx별 GROUP BY최종 21행운영 cardinality가 아님
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · REQ-302 mismatch히토리 → 니지카 → 료 → 키타
  1. 히토리

    실제 fixture에서 어디가 어긋나요?

  2. 니지카

    REQ-302는 actual_ledger_count 1, expected_ledger_count 2라 false야.

  3. 이 비교만으로 빠진 원장의 금액이나 방향은 알 수 없어.

  4. 키타

    REQ-302 결과의 1·2·false 세 값을 한 행에서 대조하면 oracle과 정확히 맞아요.

09

STEP 09 / 13

PowerShell·SQL·DB 내부에서 벌어지는 일

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

PostgreSQL aggregate

LEFT JOIN 뒤 tx별 group에서 non-NULL entry_id만 센다.

중복 원장 원인과 금액은 COUNT가 설명하지 않는다.
workbook fixture

21개 tx 중 REQ-302만 actual 1/expected 2다.

새 fixture가 들어오면 oracle도 다시 계산해야 한다.
10

STEP 10 / 13

흔한 착각과 틀린 예

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

❌ COUNT(*)도 ledger 0건을 0으로 센다

왜 틀리나 LEFT JOIN NULL 확장행 한 개를 센다

바르게 읽기 COUNT(ledger.entry_id)를 쓴다

반례 ledger 0건 transaction이 COUNT(*)=1이 될 수 있다

❌ cardinality_ok=true면 원장이 완전히 맞다

왜 틀리나 행 수만 비교한다

바르게 읽기 금액·방향·합계는 별도 invariant로 본다

반례 두 잘못된 원장도 개수만 2면 true다

❌ CASE는 보편 금융 규칙이다

왜 틀리나 source 주석이 fixture-bound 정책이라고 제한한다

바르게 읽기 과제 fixture의 기대표로만 읽는다

반례 다른 posting model은 이체 1행이나 4행일 수 있다

11

STEP 11 / 13

이 코드가 보장하지 않는 것

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

정본성

workbook 정본 답안을 보장하지 않는다

이 책임을 맡는 곳: 학습 과제 계약
불변식 범위

금액 보존과 차변·대변 균형을 보장하지 않는다

이 책임을 맡는 곳: 별도 ledger invariant
정책 범위

2·1·0 기대 수를 운영 규칙으로 보장하지 않는다

이 책임을 맡는 곳: 도메인 posting 정책
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · true의 한계히토리 → 니지카 → 료 → 키타
  1. 히토리

    cardinality_ok가 true면 안심해도 되나요?

  2. 니지카

    행 수가 fixture 기대와 같다는 한 가지 사실만 맞아.

  3. 금액 보존·계정 방향·status 정합성은 별도 검사로 증명해야 해.

  4. 키타

    원장 두 장이 모두 잘못된 금액이어도 count 2는 true라는 반례를 self-check에 둘게요.

12

STEP 12 / 13

직접 다시 써보기

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

1단계 · 뜻부터 복원

  • 왜 COUNT(*) 대신 COUNT(ledger.entry_id)를 쓰나?
  • 왜 business_tx에서 LEFT JOIN하나?
  • cardinality_ok가 금액 보존까지 증명하지 못하는 이유는 무엇인가?

2단계 · 코드 조각 재조립

  1. business_tx에서 LEFT JOIN해 ledger 0건도 결과에 남긴다.
  2. NULL placeholder를 빼는 COUNT(entry_id)로 0·1·2를 센다.
  3. fixture CASE의 2·1·0과 실제 수를 Boolean으로 비교한다.

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

illustrative/sql/W14-SQL-Q20.sql 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.

자가 점검
  • 원본 줄을 바꾸지 않았는가
  • fixture·selector 숫자를 정확히 적었는가
  • 보장하지 않는 범위를 함께 적었는가
13

STEP 13 / 13

전체 원본 정답

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

학습용 예시 전체 확인하기 · 정본 답안 아님
학습용 예시 · 정본 답안 아님 · fixture-bound illustrative SQLillustrative/sql/W14-SQL-Q20.sqlSHA-256 808625024a7417b6116ade9263bde00e11a35b3e447eca9de667d8c3c72337f4
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 전체
-- W14-SQL-Q20 illustrative example, not a shipped workbook answer.
-- Input grain: business_tx joined to zero or more ledger_entry rows.
-- Output grain: one row per business transaction, including transactions with zero ledger rows.
-- The expected count below is an explicit fixture-bound teaching policy, not a universal ledger rule.
SET search_path TO :"workbook_schema", public;

WITH counted AS (
    SELECT
        tx.tx_id,
        tx.request_id,
        tx.tx_type,
        tx.status,
        COUNT(ledger.entry_id) AS actual_ledger_count,
        CASE
            WHEN tx.status = 'SUCCESS' AND tx.tx_type = 'TRANSFER' THEN 2
            WHEN tx.status = 'SUCCESS'
             AND tx.tx_type IN ('OPENING', 'DEPOSIT', 'WITHDRAW', 'REVERSAL') THEN 1
            ELSE 0
        END AS expected_ledger_count
    FROM business_tx AS tx
    LEFT JOIN ledger_entry AS ledger
      ON ledger.tx_id = tx.tx_id
    GROUP BY tx.tx_id, tx.request_id, tx.tx_type, tx.status
)
SELECT
    tx_id,
    request_id,
    tx_type,
    status,
    actual_ledger_count,
    expected_ledger_count,
    actual_ledger_count = expected_ledger_count AS cardinality_ok
FROM counted
ORDER BY request_id;
-- Supplied fixture oracle: 21 rows; REQ-302 is the only mismatch (actual 1, expected 2).