STARRY PASS 코드 뒤풀이 · W14 Ver2 전체
14주차 코드 — 핵심 회귀 smoke와 SQL 연결·원장 수 점검
여섯 누적 테스트 참고본과 W14 smoke 실행기를 함께 읽고, 원거래 연결과 거래별 원장 cardinality를 fixture에 묶어 쉽게 확인합니다.
01CoreSchemaIT.java — public base table 이름 네 개를 exact set으로 확인
reference/w6/day-1/CoreSchemaIT.java
누적 Java 테스트 참고본 · 정본 · W14-F0116줄 연결22줄 번역4 chunks
CoreSchemaIT.java — public base table 이름 네 개를 exact set으로 확인
reference/w6/day-1/CoreSchemaIT.java
누적 Java 테스트 참고본 · 정본 · W14-F01STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W6D1 staged schema에서 Flyway history를 제외한 public BASE TABLE 이름이 네 core table과 정확히 같은지 확인한다.
- 왜 information_schema.tables에서 public과 BASE TABLE만 고를까?
- 왜 flyway_schema_history는 제외할까?
- ORDER BY가 있는데 assertion은 왜 순서를 무시할까?
- table 이름 네 개가 맞으면 PK·FK도 맞다고 할 수 있을까?
- W14 selector 이름이 이 staged source 실행을 직접 증명할까?
schema=publictype=BASE TABLEexclude=flyway_schema_historyaccountbusiness_txledger_entryidempotency_request@Test count=1stage=W6D1STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 공연장 창고에 꼭 필요한 네 서랍만 있는지 검사한다.
필요한 네 서랍만 있는지 이름표 대조
검사표는 public 방의 진짜 table 이름을 읽고 Flyway 작업 일지는 뺀다. 남은 이름은 String 목록에 담는다.
그 목록을 account·business_tx·ledger_entry·idempotency_request 네 장과 비교해 하나라도 빠지거나 더 있으면 실패한다.
딱 여기까지만 서랍 이름만 대조하므로 안쪽 열·열쇠·연결끈인 column·PK·FK·index까지 검사하지 않는다.
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 이름만으로 실행을 확정하지 않는다.
왜 네 이름만 보나
-
table 네 개면 schema 전체가 맞는 것 아닌가요?
-
이번 표는 창고 이름만 세고 서랍 안 column은 열지 않아.
-
projection과 matcher가 직접 관찰한 quantifier는 exact table-name set뿐이다.
-
PK·FK·index는 별도 확인 칸으로 남길게요.
Flyway 기록표
-
Flyway table도 public인데 왜 빼나요?
-
application core table이 아니라 migration history라서 이름 inventory에서 제외해.
-
제외 규칙은 그 한 literal뿐이라 다른 관리 table은 extra로 잡힌다.
-
actual list에 다섯 번째 이름을 넣어 fail을 확인하겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 16줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 10줄F01-L10 | @SpringBootTest |
STARRY의 네 서랍 검사대에 Spring 전체 전원 스위치를 단다. | CoreSchemaIT를 Spring Boot application context 기반 test로 표시한다.
|
| 11줄F01-L11 | class CoreSchemaIT extends PostgresIntegrationTestSupport { |
네 서랍 검사관을 PostgreSQL 공동 작업대 위에 세운다. | CoreSchemaIT가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
|
| 12줄F01-L12 | @Autowired JdbcClient jdbc; |
검사관 손에 SQL 질문기 `jdbc`를 자동 지급한다. | Spring이 JdbcClient bean을 jdbc field에 주입하도록 선언한다.
|
| 14줄F01-L14 | @Test |
네 서랍 검사를 실행 목록에 올리는 JUnit 도장을 찍는다. | 바로 다음 method를 JUnit test로 표시한다.
|
| 15줄F01-L15 | void v001CreatesExactlyTheRequiredCoreTables( |
정확히 필요한 표만 있는지 묻는 검사표를 펼친다. | v001CreatesExactlyTheRequiredCoreTables test method 본문을 시작한다.
|
| 16줄F01-L16 | var tables = |
찾은 table 이름을 담을 `tables` 바구니를 SQL 창구에 건다. | JdbcClient query 결과를 var tables에 대입하는 문장을 시작한다.
|
| 17줄F01-L17 | SELECT table_name FROM information_schema. |
재고표에서 각 서랍의 `table_name` 칸만 읽게 한다. | information_schema.tables에서 반환할 projection을 table_name으로 정한다.
|
| 18줄F01-L18 | WHERE table_schema= |
`public` 방의 진짜 기본 서랍만 검사선에 남긴다. | table_schema가 public이고 table_type이 BASE TABLE인 row만 filter한다.
|
| 19줄F01-L19 | AND table_name <> 'flyway_schema_history' |
Flyway 작업 일지 서랍은 제품 서랍 수에서 빼 둔다. | flyway_schema_history table name을 결과에서 제외한다.
|
| 20줄F01-L20 | ORDER BY table_name |
남은 서랍 이름을 가나다순 꼬리표로 정렬한다. | 조회 결과를 table_name 오름차순으로 정렬한다.
|
| 21줄F01-L21 | """). |
SQL 두루마리를 닫고 이름을 String 목록으로 한꺼번에 받아 온다. | text block을 닫고 query 결과를 String으로 mapping해 list로 실행한다.
|
| 22줄F01-L22 | assertThat( |
받아 온 서랍 목록을 AssertJ 저울 위에 올린다. | tables를 assertThat assertion subject로 만든다.
|
| 23줄F01-L23 | . |
저울이 틀릴 때 보일 W6D1 빨간 안내 문구를 붙인다. | assertion failure description을 W6D1_RED_EXPECTED_FOUR_CORE_TABLES로 설정한다.
|
| 24줄F01-L24 | . |
서랍 이름이 네 장 티켓과 정확히 같은 묶음인지 순서 없이 맞춘다. | 실제 list가 네 expected table name과 정확히 같은 원소인지 assert한다.
|
| 25줄F01-L25 | } |
네 이름 대조 시험표를 접어 method를 끝낸다. | v001CreatesExactlyTheRequiredCoreTables method block을 닫는다.
|
| 26줄F01-L26 | } |
CoreSchemaIT 검사실 외벽을 마지막으로 닫는다. | CoreSchemaIT class block을 닫는다.
|
정렬과 matcher
-
ORDER BY가 있으니 expected도 그 순서여야 하나요?
-
읽기 결과는 정렬하지만 matcher는 InAnyOrder라 membership을 봐.
-
query determinism과 assertion order requirement는 다른 계약이다.
-
네 이름을 섞은 예와 extra 한 개 예를 나눠 보겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 22줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 6개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore; | 이 Java 파일의 package 주소를 `com.example.financialcore`로 정한다. |
| 3 | import org.junit.jupiter.api.Test; | 뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 4 | import org.springframework.beans.factory.annotation.Autowired; | 뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 5 | import org.springframework.boot.test.context.SpringBootTest; | 뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 6 | import org.springframework.jdbc.core.simple.JdbcClient; | 뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 8 | import static org.assertj.core.api.Assertions.assertThat; | assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 10 | @SpringBootTest | CoreSchemaIT를 Spring Boot application context 기반 test로 표시한다. |
| 11 | class 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.tables | information_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을 닫는다. |
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 결과를 바꾸지 않는다.
실행 순서
- JUnit discovery
- Spring/PostgreSQL fixture bootstrap
- JdbcClient injection
- information_schema query
- Flyway row 제외
- String list materialize
- 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의 뜻
-
label에 RED가 있으면 항상 실패하는 test인가요?
-
아니, 틀렸을 때 찾기 쉬운 설명 이름이야.
-
pass/fail은 containsExactlyInAnyOrder가 실제 list를 비교해 정한다.
-
label과 matcher를 서로 다른 줄로 읽겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | W6D1 stage application과 PostgreSQL fixture | Spring context와 inherited support를 준비한다. | JdbcClient를 가진 CoreSchemaIT instance가 test 실행 후보가 된다. | reference project root의 기본 source set에서 이 stage file이 compile되는지는 별도 문제다. |
| 2 | information_schema.tables의 여러 schema·relation type | public·BASE TABLE을 남기고 flyway_schema_history를 제외한다. | application base-table 이름만 담은 rows가 만들어진다. | 다른 관리 table이 있으면 그대로 남아 exact assertion을 실패시킨다. |
| 3 | 예: account, business_tx, flyway history, ledger_entry, idempotency_request | projection을 String list로 materialize한다. | Flyway를 뺀 네 이름이 tables 변수에 들어간다. | query·connection failure는 assertion보다 먼저 test를 중단한다. |
| 4 | actual name list와 네 expected literals | containsExactlyInAnyOrder로 exact membership을 비교한다. | 정확히 네 이름이면 pass, 하나라도 더하거나 빼면 W6D1 label과 함께 fail한다. | table body의 column·constraint·index equality는 관찰하지 않는다. |
staged source와 runner
-
runner selector가 CoreSchemaIT면 이 파일 실행이 확정되죠?
-
이 파일은 W6D1 stage 폴더에 있어 root 기본 source set과 달라.
-
selector FQCN은 reference일 뿐 source path·SHA·fresh compilation 증거가 아니다.
-
stage 실행 증거가 없으면 reference-only라고 표시할게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
@Test method를 발견하고 Spring extension lifecycle 안에서 실행한다.
discovery된 source path나 SHA를 W14 smoke marker에 기록하지 않는다.application context와 JdbcClient dependency injection을 준비한다.
MockMvc나 전체 application endpoint를 이 class가 사용하지 않는다.information_schema view가 visible relation metadata를 row로 제공한다.
information_schema query는 DDL을 다시 적용하거나 schema를 고치지 않는다.actual list의 exact elements와 expected four strings를 순서 없이 비교한다.
failure description의 RED token은 test status를 조작하지 않는다.이 파일의 @Test가 실제로 고정하는 범위
v001CreatesExactlyTheRequiredCoreTables
- W6D1 staged Spring/PostgreSQL fixture를 준비한다.
- JdbcClient가 public information_schema를 읽을 수 있다.
- public BASE TABLE 이름을 조회한다.
- flyway_schema_history를 제외하고 String list로 만든다.
- 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에서 첫 값 불일치가 드러난다.
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하지 못한다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
column type, nullability, PK/FK/UNIQUE/CHECK, index definition
이 책임을 맡는 곳: 별도 catalog queries와 migration/schema integration testsW14 root runner가 이 staged SHA의 class를 compile·execute함
이 책임을 맡는 곳: stage source-set assembly, source hash manifest, fresh JUnit XML bindingproduction schema와 다른 database/schema의 동일성
이 책임을 맡는 곳: 배포 migration 검증과 운영 read-only catalog evidence이름 목록이 test 실행 뒤에도 계속 변하지 않음
이 책임을 맡는 곳: migration change control과 실행 시각이 결박된 evidenceSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
Spring fixture→public base table query→Flyway 제외→String list→네 이름 exact set 순서로 말한다.
2단계 · 코드 조각 재조립
- @SpringBootTest/class/JdbcClient
- information_schema SELECT/WHERE
- Flyway exclusion/ORDER BY
- 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를 구분한다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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");
}
}
02OpeningIntegrationTest.java — 개설 성공 뒤 account·transaction·ledger를 각각 count
reference/w6/day-3/OpeningIntegrationTest.java
누적 Java 테스트 참고본 · 정본 · W14-F0221줄 연결29줄 번역4 chunks
OpeningIntegrationTest.java — 개설 성공 뒤 account·transaction·ledger를 각각 count
reference/w6/day-3/OpeningIntegrationTest.java
누적 Java 테스트 참고본 · 정본 · W14-F02STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W6D3 staged service가 OPEN-100 개설을 정상 반환한 뒤 세 persistence 흔적이 각각 한 행인지 확인한다.
- 왜 매 test 전에 네 table을 TRUNCATE할까?
- customer-1·OPEN-100·12,345 중 무엇을 직접 다시 읽어 확인할까?
- account·business_tx·ledger의 count 1은 각각 무엇을 증명할까?
- method 이름의 Atomically가 rollback test를 대신할까?
- 세 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=W6D3STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY가 OPEN-100 개설표를 처리한 뒤 계좌·거래·원장 영수증을 각각 센다.
개설표 한 장 뒤 세 영수증 세기
먼저 네 장부를 비우고 customer-1의 OPEN-100 계좌를 12,345로 연다. 서비스가 돌려준 계좌 ID를 받는다.
그 ID의 계좌 한 행, OPENING 거래 한 행, 같은 ID의 OPENING 원장 한 행을 서로 다른 SQL로 세어 모두 1인지 본다.
딱 여기까지만 성공 뒤 영수증 수만 보므로 중간 고장 rollback, 금액·잔액, 세 조회의 단일 snapshot까지 증명하지 않는다.
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하지 않는다.
세 영수증의 범위
-
세 count가 1이면 개설이 완벽하다는 뜻인가요?
-
성공 뒤 account·OPENING tx·same-account ledger가 하나씩 있다는 뜻이야.
-
각 SQL이 읽지 않은 금액·연결·rollback은 직접 증명 밖이다.
-
세 query의 WHERE와 projection을 따로 표시하겠습니다.
청소의 이유
-
identity까지 왜 다시 1부터 시작하나요?
-
이전 test row와 generated ID 흔적을 제거해 fixture를 재현하려는 거야.
-
CASCADE는 의존 row까지 지우는 파괴적 test 명령이며 운영 절차가 아니다.
-
대상 네 table과 시험 환경 한계를 함께 적을게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 21줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 12줄F02-L12 | @SpringBootTest |
계좌 개설 보존 시험장에 Spring 전체 장비 호출표를 단다. | OpeningIntegrationTest를 Spring Boot context 기반 integration test로 표시한다.
|
| 13줄F02-L13 | class OpeningIntegrationTest extends PostgresIntegrationTestSupport { |
개설 시험관을 공용 PostgreSQL 바닥판에 연결한다. | OpeningIntegrationTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
|
| 14줄F02-L14 | @Autowired JdbcClient jdbc; |
행 수를 셀 SQL 계수기를 첫 손에 쥐여 준다. | Spring이 OpeningIntegrationTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
|
| 15줄F02-L15 | @Autowired AccountOpeningService openings; |
실제 개설 절차를 부를 `openings` 창구도 옆에 연결한다. | Spring이 AccountOpeningService bean을 openings field에 주입하도록 선언한다.
|
| 17줄F02-L17 | @BeforeEach |
매 시험 시작 전에 네 장부를 비우는 청소표를 붙인다. | 바로 다음 clean method를 JUnit BeforeEach fixture로 표시한다.
|
| 18줄F02-L18 | void clean( |
네 장부 청소 작업실의 문을 연다. | void clean method의 본문을 시작한다.
|
| 19줄F02-L19 | jdbc. |
멱등·원장·거래·계좌 장부를 비우고 번호표까지 처음으로 되감는다. | 네 table을 TRUNCATE하고 identity를 restart하며 의존 관계에 CASCADE를 적용한다.
|
| 20줄F02-L20 | } |
청소가 끝난 뒤 시험장 문을 닫는다. | OpeningIntegrationTest의 clean BeforeEach method block을 닫는다.
|
| 22줄F02-L22 | @Test |
OPEN-100 보존 검사를 JUnit 실행 목록에 올린다. | 바로 다음 opening method를 @Test로 표시한다.
|
| 23줄F02-L23 | void openingWritesAccountBusinessTransactionAndLedgerAtomically( |
계좌·거래·원장 세 영수증 검사표를 펼친다. | openingWritesAccountBusinessTransactionAndLedgerAtomically method 본문을 시작한다.
|
| 24줄F02-L24 | var account = |
customer-1이 OPEN-100 계좌를 12,345로 열고 반환표를 받는다. | openings.open에 owner, account number, opening amount를 넘겨 반환 Account를 저장한다.
|
| 25줄F02-L25 | assertThat( |
반환된 ID 계좌가 몇 개인지 SQL 자를 대기시킨다. | account에서 id=:id인 row의 COUNT query assertion을 시작한다.
|
| 26줄F02-L26 | . |
반환표 ID를 `:id`에 끼워 계좌 한 줄을 확인한다. | account.getId를 bind해 COUNT를 실행하고 결과가 1인지 assert한다.
|
| 27줄F02-L27 | assertThat( |
개설 거래 영수증 전체에서 OPENING 도장 수를 세기 시작한다. | business_tx에서 tx_type='OPENING'인 row의 COUNT assertion을 연다.
|
| 28줄F02-L28 | . |
OPENING 거래 수를 Long 한 개와 맞춘다. | business_tx COUNT를 실행해 single Long이 1인지 assert한다.
|
| 29줄F02-L29 | assertThat( |
반환 계좌에 붙은 OPENING 원장 줄 수를 세기 시작한다. | ledger_entry에서 account_id=:id이고 entry_type='OPENING'인 COUNT assertion을 연다.
|
| 30줄F02-L30 | . |
같은 계좌 ID를 넣어 원장 count 한 값을 꺼낸다. | ledger COUNT query에 account.getId를 bind하고 single Long을 조회한다.
|
| 31줄F02-L31 | . |
원장 누락이면 찾기 쉬운 W6D3 빨간 제목을 건다. | ledger assertion description을 W6D3_RED_EXPECTED_OPENING_LEDGER로 설정한다.
|
| 32줄F02-L32 | . |
OPENING 원장이 정확히 한 줄인지 최종 눈금과 맞춘다. | ledger count가 1인지 assert한다.
|
| 33줄F02-L33 | } |
세 count 검사표를 접어 opening test를 끝낸다. | opening test method block을 닫는다.
|
| 34줄F02-L34 | } |
OpeningIntegrationTest 시험장 외벽을 닫는다. | OpeningIntegrationTest class block을 닫는다.
|
12,345는 어디서 확인하나
-
open에 12,345를 줬으니 DB 금액도 확인한 거죠?
-
호출 입력에는 있지만 뒤 SQL은 COUNT만 읽어.
-
값 전달과 persisted amount equality assertion은 다른 관찰이다.
-
별도 amount SELECT가 필요하다고 오답 옆에 쓰겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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);
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 29줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 8개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.account; | 이 Java 파일의 package 주소를 `com.example.financialcore.account`로 정한다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 4 | import org.junit.jupiter.api.BeforeEach; | 뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 5 | import org.junit.jupiter.api.Test; | 뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 6 | import org.springframework.beans.factory.annotation.Autowired; | 뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 7 | import org.springframework.boot.test.context.SpringBootTest; | 뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 8 | import org.springframework.jdbc.core.simple.JdbcClient; | 뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 10 | import static org.assertj.core.api.Assertions.assertThat; | assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 12 | @SpringBootTest | OpeningIntegrationTest를 Spring Boot context 기반 integration test로 표시한다. |
| 13 | class 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을 닫는다. |
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)은 값 비교를 끝낸다.
실행 순서
- JUnit discovery
- Spring/PostgreSQL fixture bootstrap
- BeforeEach TRUNCATE
- openings.open
- account count
- business_tx count
- ledger count
- 세 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라는 이름
-
test 이름이 atomic이라고 선언하니 rollback도 Green 아닌가요?
-
이름은 의도이고 body는 정상 호출 뒤 세 번 센 것뿐이야.
-
failure injection이 없어서 all-or-nothing 실패 경로는 직접 관찰하지 않는다.
-
이름보다 arrange·act·assert를 기준으로 증명 범위를 정하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 이전 staged test가 남겼을 수 있는 네 table row | TRUNCATE ... RESTART IDENTITY CASCADE를 실행한다. | account·business_tx·ledger_entry·idempotency_request가 빈 fixture로 시작한다. | audit_event 등 source에 없는 table은 이 cleanup 범위가 아니다. |
| 2 | customer-1, OPEN-100, 12_345 | openings.open을 한 번 호출한다. | 정상 반환하면 generated ID를 가진 account 객체가 local variable에 저장된다. | 이 test는 중간 failure를 주입하지 않는다. |
| 3 | account.getId() | account WHERE id=:id의 COUNT를 읽는다. | 같은 ID의 account row가 하나면 첫 assertion이 통과한다. | owner_id·account_no·balance column 값을 projection하지 않는다. |
| 4 | clean 뒤 open 호출이 끝난 business_tx | tx_type='OPENING' 전체 COUNT를 읽는다. | OPENING transaction row가 하나면 둘째 assertion이 통과한다. | 그 row를 반환 account ID와 join하지 않는다. |
| 5 | account.getId()와 entry_type='OPENING' | ledger_entry COUNT를 읽고 W6D3 label을 붙여 1과 비교한다. | 같은 account의 OPENING ledger row가 하나면 셋째 assertion이 통과한다. | amount·business_tx_id·balance_after는 직접 비교하지 않는다. |
세 SELECT의 시점
-
연달아 조회했으니 같은 snapshot 아닌가요?
-
가깝게 실행됐어도 명시적 transaction·isolation이 source에 없어.
-
순차 execution order와 snapshot identity를 혼동하면 안 된다.
-
각 count를 independent postcondition read로 부르겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
BeforeEach clean을 실행한 뒤 한 @Test method를 호출한다.
lifecycle 순서가 다른 class의 database 격리까지 보장하지 않는다.injected AccountOpeningService bean이 개설 use case를 수행하고 Account를 반환한다.
service의 내부 transaction annotation과 실패 지점은 이 test source에 직접 나타나지 않는다.fixture cleanup과 세 COUNT statement를 실행한다.
세 SELECT를 하나의 명시적 transaction이나 snapshot으로 감싸는 code가 없다.세 Long count를 차례로 expected 1과 비교한다.
마지막 .as label은 ledger mismatch 진단만 돕고 atomicity를 검증하지 않는다.이 파일의 @Test가 실제로 고정하는 범위
openingWritesAccountBusinessTransactionAndLedgerAtomically
- BeforeEach에서 네 persistence table을 TRUNCATE하고 identity를 restart한다.
- W6D3 staged Spring/PostgreSQL fixture와 AccountOpeningService를 준비한다.
- openings.open(customer-1, OPEN-100, 12_345)을 한 번 정상 호출한다.
- 반환 account ID를 일부 count query에 bind한다.
- 반환 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에서 멈춘다.
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 실행이 시작되지 않는다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
account·business_tx·ledger 중간 failure에서 전부 rollback됨
이 책임을 맡는 곳: failure injection integration test와 transaction-boundary 검증owner, account number, opening amount, balance, ledger amount의 정확성
이 책임을 맡는 곳: column-value queries 또는 repository/API response assertions세 COUNT가 같은 transaction snapshot에서 읽힘
이 책임을 맡는 곳: 명시적 read transaction과 isolation evidencereference root가 W6D3 staged SHA를 기본 compile·execute함
이 책임을 맡는 곳: stage source-set assembly, source manifest, fresh execution recordSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
fixture cleanup→open(customer-1, OPEN-100, 12,345)→account count→OPENING tx count→same-account ledger count 순서로 말한다.
2단계 · 코드 조각 재조립
- @SpringBootTest/class/two beans
- @BeforeEach/TRUNCATE
- @Test/open
- account COUNT
- business_tx COUNT
- 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 증거로 바꾸지 않는다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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);
}
}
03AccountControllerTest.java — MockMvc로 생성·거절·owner 조회·404 네 경로 확인
reference/w6/day-7/AccountControllerTest.java
누적 Java 테스트 참고본 · 정본 · W14-F0351줄 연결65줄 번역6 chunks
AccountControllerTest.java — MockMvc로 생성·거절·owner 조회·404 네 경로 확인
reference/w6/day-7/AccountControllerTest.java
누적 Java 테스트 참고본 · 정본 · W14-F03STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W6D7 staged account API의 네 대표 요청을 MockMvc로 보내 status·일부 body token·제한된 DB count를 확인한다.
- MockMvc request는 실제 network port 호출과 무엇이 다를까?
- 201 생성 test는 response와 database의 어느 부분만 확인할까?
- 음수 거절에서 writesNothing은 정말 모든 table 0을 뜻할까?
- owner 조회 fixture를 왜 POST가 아니라 service.create로 만들까?
- contains 문자열 검사가 JSON 전체 contract를 증명할까?
- 같은 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=W6D7STEP 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를 보장하지 않는다.
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의 범위
-
MockMvc로 201이면 실제 서버도 열어 본 건가요?
-
Spring MVC 흐름은 돌리지만 실제 port와 network는 열지 않아.
-
controller integration 증거와 deployed transport 증거를 분리해야 한다.
-
표 제목에 가상 접수대 범위를 남기겠습니다.
생성 test가 보는 것
-
A-100 생성이면 ledger도 함께 생긴 게 확인되죠?
-
이 method는 status 201과 account_no A-100 count 1만 봐.
-
opening transaction·ledger·response body는 projection과 assertion에 없다.
-
직접 증명 두 개만 test 카드에 쓰겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 51줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 18줄F03-L18 | @SpringBootTest |
HTTP 계좌 시험실의 application 전원을 올리는 표지를 붙인다. | AccountControllerTest를 Spring Boot context 기반 test로 표시한다.
|
| 19줄F03-L19 | @AutoConfigureMockMvc |
진짜 포트 대신 쓸 가상 HTTP 접수대 MockMvc를 설치한다. | Spring Boot가 MockMvc test infrastructure를 자동 구성하도록 표시한다.
|
| 20줄F03-L20 | class AccountControllerTest extends PostgresIntegrationTestSupport { |
API 검사관을 PostgreSQL 공동 fixture 위에 세운다. | AccountControllerTest가 PostgresIntegrationTestSupport를 상속하도록 class를 연다.
|
| 21줄F03-L21 | @Autowired MockMvc mvc; |
가상 HTTP 요청기를 `mvc` 손잡이에 자동 연결한다. | Spring이 MockMvc bean을 mvc field에 주입하도록 선언한다.
|
| 22줄F03-L22 | @Autowired JdbcClient jdbc; |
DB side effect를 셀 `jdbc` 계수기도 옆에 둔다. | Spring이 AccountControllerTest의 jdbc field에 JdbcClient bean을 주입하도록 선언한다.
|
| 23줄F03-L23 | @Autowired AccountService service; |
조회용 계좌를 미리 만들 `service` 창구를 세 번째로 연다. | Spring이 AccountService bean을 service field에 주입하도록 선언한다.
|
| 25줄F03-L25 | @BeforeEach void clean( |
네 요청표마다 장부를 비우는 청소 예약과 작업실 문을 한 줄에 붙인다. | clean method를 BeforeEach로 표시하고 method body를 시작한다.
|
| 26줄F03-L26 | jdbc. |
멱등·원장·거래·계좌 장부와 identity 번호표를 매번 초기화한다. | 네 table을 TRUNCATE RESTART IDENTITY CASCADE하고 update를 실행한다.
|
| 27줄F03-L27 | } |
가상 접수대의 청소실 문을 닫는다. | AccountControllerTest의 clean BeforeEach method block을 닫는다.
|
| 29줄F03-L29 | @Test |
정상 계좌 생성 요청표에 첫 JUnit 실행 도장을 찍는다. | createReturns201AndPersistsOpening method를 @Test로 표시한다.
|
| 30줄F03-L30 | void createReturns201AndPersistsOpening( |
201과 A-100 저장을 볼 첫 접수 시험표를 펼친다. | createReturns201AndPersistsOpening test method 본문을 시작한다.
|
| 31줄F03-L31 | var response = |
`/api/accounts` 새 계좌 창구에 POST 봉투를 넣기 시작한다. | MockMvc perform에 /api/accounts POST request builder를 전달하는 표현을 연다.
|
| 32줄F03-L32 | . |
봉투에 customer-1과 password 기본 인증표를 붙인다. | POST request에 HTTP Basic credentials를 추가한다.
|
| 33줄F03-L33 | . |
접수 봉투 겉면을 application/json으로 표시한다. | POST request Content-Type을 application/json으로 설정한다.
|
| 34줄F03-L34 | . |
A-100과 openingBalance 10,000을 JSON 신청서에 적는다. | POST body를 accountNo A-100, openingBalance 10000 JSON으로 설정한다.
|
| 35줄F03-L35 | . |
완성한 신청서를 접수대에 보내고 servlet 응답표를 꺼낸다. | MockMvc request를 수행해 MvcResult의 response를 변수에 저장한다.
|
| 36줄F03-L36 | assertThat( |
응답표의 status가 생성 성공 201인지 맞춘다. | response.getStatus 결과가 201인지 assert한다.
|
| 37줄F03-L37 | assertThat( |
접수 뒤 DB에서 A-100 이름표가 붙은 계좌 수를 세기 시작한다. | account_no='A-100'인 account COUNT query assertion을 연다.
|
| 38줄F03-L38 | . |
A-100 계좌가 정확히 한 줄인지 DB 눈금과 맞춘다. | account COUNT를 실행해 single Long이 1인지 assert한다.
|
| 39줄F03-L39 | } |
정상 생성 접수 시험표를 닫는다. | createReturns201AndPersistsOpening method block을 닫는다.
|
| 41줄F03-L41 | @Test |
음수 개설 거절표에 둘째 JUnit 도장을 찍는다. | negativeOpeningReturns400AndWritesNothing method를 @Test로 표시한다.
|
| 42줄F03-L42 | void negativeOpeningReturns400AndWritesNothing( |
BAD -1 신청이 거절되는지 볼 두 번째 접수표를 펼친다. | negativeOpeningReturns400AndWritesNothing method 본문을 시작한다.
|
| 43줄F03-L43 | var response = |
같은 `/api/accounts` 창구에 거절용 POST 봉투를 새로 넣는다. | MockMvc perform에 두 번째 account POST request builder를 전달하기 시작한다.
|
| 44줄F03-L44 | . |
거절용 봉투에도 customer-1 기본 인증표를 붙인다. | negative POST request에 같은 HTTP Basic credentials를 추가한다.
|
| 45줄F03-L45 | . |
오류 응답을 추적할 `w6-invalid` 요청 번호표를 단다. | negative POST에 X-Request-Id header w6-invalid를 설정한다.
|
| 46줄F03-L46 | . |
음수 신청서도 JSON 봉투라고 표시한다. | negative POST Content-Type을 application/json으로 설정한다.
|
| 47줄F03-L47 | . |
BAD 계좌에 openingBalance -1을 적어 경계 밖 값을 넣는다. | request body를 accountNo BAD, openingBalance -1 JSON으로 설정한다.
|
| 48줄F03-L48 | . |
음수 신청서를 실행하고 error servlet 응답표를 받는다. | negative MockMvc request를 수행해 response를 변수에 저장한다.
|
| 49줄F03-L49 | assertThat( |
거절 응답 status가 400인지 확인한다. | negative response status가 400인지 assert한다.
|
| 50줄F03-L50 | assertThat( |
오류 본문에서 INVALID_REQUEST와 w6-invalid 두 표식을 찾는다. | response body String이 두 substring을 모두 포함하는지 assert한다.
|
| 51줄F03-L51 | assertThat( |
거절 뒤 account 장부가 0줄인지 마지막으로 센다. | account 전체 COUNT가 zero인지 assert한다.
|
| 52줄F03-L52 | } |
음수 거절 시험표를 닫는다. | negativeOpeningReturns400AndWritesNothing method block을 닫는다.
|
| 54줄F03-L54 | @Test |
주인 계좌 조회표에 셋째 JUnit 도장을 찍는다. | ownerReadsAccount method를 @Test로 표시한다.
|
| 55줄F03-L55 | void ownerReadsAccount( |
LOOKUP 계좌를 읽는 세 번째 접수표를 펼친다. | ownerReadsAccount test method 본문을 시작한다.
|
| 56줄F03-L56 | var account = |
service 창구에서 customer-1의 LOOKUP 계좌를 7,000으로 미리 만든다. | AccountService.create를 직접 호출해 fixture account를 저장한다.
|
| 57줄F03-L57 | var response = |
반환된 ID를 `/api/accounts/{id}` 조회 창구에 넣는다. | MockMvc perform에 account ID path variable을 가진 GET builder를 전달한다.
|
| 58줄F03-L58 | . |
조회 봉투에 같은 customer-1 기본 인증표를 붙인다. | GET request에 HTTP Basic customer-1/password를 추가한다.
|
| 59줄F03-L59 | . |
owner GET을 실행해 servlet 응답표를 꺼낸다. | authenticated GET을 수행하고 response를 변수에 저장한다.
|
| 60줄F03-L60 | assertThat( |
owner 조회 응답 status가 200인지 맞춘다. | GET response status가 200인지 assert한다.
|
| 61줄F03-L61 | assertThat( |
응답 본문 어딘가에 LOOKUP 이름표가 있는지 찾는다. | GET response body String이 LOOKUP substring을 포함하는지 assert한다.
|
| 62줄F03-L62 | } |
owner 조회 시험표를 닫는다. | ownerReadsAccount method block을 닫는다.
|
| 64줄F03-L64 | @Test |
없는 계좌 조회표에 넷째 JUnit 도장을 찍는다. | missingAccountReturns404 method를 @Test로 표시한다.
|
| 65줄F03-L65 | void missingAccountReturns404( |
999999가 없을 때의 네 번째 접수표를 펼친다. | missingAccountReturns404 test method 본문을 시작한다.
|
| 66줄F03-L66 | var response = |
빈 장부에서 `/api/accounts/999999` 조회 봉투를 만든다. | MockMvc perform에 fixed ID 999999를 가진 GET request builder를 전달한다.
|
| 67줄F03-L67 | . |
없는 계좌 요청에도 customer-1 기본 인증표를 붙인다. | missing GET request에 HTTP Basic credentials를 추가한다.
|
| 68줄F03-L68 | . |
404 오류를 추적할 `w6-missing` 요청 번호표를 단다. | missing GET에 X-Request-Id header w6-missing을 설정한다.
|
| 69줄F03-L69 | . |
없는 계좌 조회를 실행해 error servlet 응답표를 받는다. | authenticated missing GET을 수행하고 response를 변수에 저장한다.
|
| 70줄F03-L70 | assertThat( |
없는 계좌 응답 status가 404인지 확인한다. | missing GET response status가 404인지 assert한다.
|
| 71줄F03-L71 | assertThat( |
오류 본문에서 ACCOUNT_NOT_FOUND와 w6-missing 두 표식을 찾는다. | missing response body String이 error code와 request ID substring을 모두 포함하는지 assert한다.
|
| 72줄F03-L72 | } |
없는 계좌 시험표를 닫는다. | missingAccountReturns404 method block을 닫는다.
|
| 73줄F03-L73 | } |
AccountControllerTest 가상 접수대 외벽을 닫는다. | AccountControllerTest class block을 닫는다.
|
WritesNothing의 실제 양
-
이름이 WritesNothing이니 database 전체가 빈 거죠?
-
실제 마지막 SQL은 account table 전체 count zero야.
-
method 이름보다 query quantifier가 직접 관찰 범위를 정한다.
-
나머지 table은 미증명 칸으로 옮기겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 6개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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");
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 65줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 14개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.account.api; | 이 Java 파일의 package 주소를 `com.example.financialcore.account.api`로 정한다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | 뒤 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 4 | import com.example.financialcore.account.AccountService; | 뒤 코드에서 `com.example.financialcore.account.AccountService` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 5 | import org.junit.jupiter.api.BeforeEach; | 뒤 코드에서 `org.junit.jupiter.api.BeforeEach` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 6 | import org.junit.jupiter.api.Test; | 뒤 코드에서 `org.junit.jupiter.api.Test` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 7 | import org.springframework.beans.factory.annotation.Autowired; | 뒤 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 8 | import org.springframework.boot.test.context.SpringBootTest; | 뒤 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 9 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | 뒤 코드에서 `org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 10 | import org.springframework.jdbc.core.simple.JdbcClient; | 뒤 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 11 | import org.springframework.test.web.servlet.MockMvc; | 뒤 코드에서 `org.springframework.test.web.servlet.MockMvc` 타입·annotation을 짧은 이름으로 쓰려고 import한다. |
| 13 | import static org.assertj.core.api.Assertions.assertThat; | assertion·request helper `org.assertj.core.api.Assertions.assertThat`를 짧은 이름으로 쓰려고 static import한다. |
| 14 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; | assertion·request helper `org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic`를 짧은 이름으로 쓰려고 static import한다. |
| 15 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | assertion·request helper `org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get`를 짧은 이름으로 쓰려고 static import한다. |
| 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | assertion·request helper `org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post`를 짧은 이름으로 쓰려고 static import한다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 18 | @SpringBootTest | AccountControllerTest를 Spring Boot context 기반 test로 표시한다. |
| 19 | @AutoConfigureMockMvc | Spring Boot가 MockMvc test infrastructure를 자동 구성하도록 표시한다. |
| 20 | class 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 | @Test | createReturns201AndPersistsOpening 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 | @Test | negativeOpeningReturns400AndWritesNothing 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 | @Test | ownerReadsAccount 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 | @Test | missingAccountReturns404 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을 닫는다. |
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하지 않는다.
실행 순서
- JUnit/Spring/MockMvc bootstrap
- BeforeEach TRUNCATE
- 각 test fixture 준비
- mock request dispatch
- response materialize
- status assertion
- 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 우회
-
LOOKUP도 POST로 만들었나요?
-
아니, AccountService.create로 미리 만들고 GET만 MockMvc로 보내.
-
그래서 create endpoint와 read endpoint를 잇는 end-to-end test는 아니다.
-
act를 service fixture와 GET request 두 단계로 나누겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 각 @Test 전에 남아 있을 수 있는 네 table row | BeforeEach TRUNCATE로 fixture를 초기화한다. | 각 scenario가 빈 account 관련 persistence state에서 시작한다. | audit_event와 다른 side-effect store는 cleanup query에 없다. |
| 2 | POST /api/accounts, Basic customer-1/password, A-100/10000 JSON | MockMvc가 request를 MVC pipeline에 dispatch한다. | mock response 201과 account_no A-100 count 1이면 생성 test가 통과한다. | response payload·Location header·opening transaction/ledger는 assertion하지 않는다. |
| 3 | POST BAD/-1, Basic auth, X-Request-Id w6-invalid | invalid opening request를 dispatch하고 raw body와 account count를 읽는다. | 400, INVALID_REQUEST, w6-invalid, account zero가 모두 맞으면 거절 test가 통과한다. | 다른 세 persistence table이 0인지 직접 query하지 않는다. |
| 4 | service.create(customer-1, LOOKUP, 7_000)의 반환 ID | owner Basic auth로 /api/accounts/{id}를 GET한다. | response 200과 raw body의 LOOKUP token이 맞으면 owner 조회 test가 통과한다. | fixture 생성은 POST endpoint를 우회하고 non-owner path를 비교하지 않는다. |
| 5 | GET /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
-
오류 두 문자열이 있으면 JSON schema도 맞나요?
-
raw String 안에 token이 있는지만 확인해.
-
field name·type·nesting은 parse하지 않으니 exact contract가 아니다.
-
jsonPath가 필요한 반례를 적겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
application context, security filter chain, controller dependencies를 test fixture로 준비한다.
실제 process deployment·external reverse proxy·TLS는 시작하지 않는다.request builder를 DispatcherServlet pipeline에 넣고 mock response를 수집한다.
socket·port·real network client latency를 관찰하지 않는다.Basic credentials, request ID header, JSON content를 filter·controller 흐름에 전달한다.
네 scenario 외 credential·role·ownership matrix 전체를 탐색하지 않는다.fixture cleanup과 두 제한된 account COUNT를 실행한다.
생성 test는 account_no count만, 거절 test는 전체 account count만 본다.status integer, raw body substring, Long count를 expected 값과 비교한다.
contains는 JSON parser나 schema validator가 아니다.이 파일의 @Test가 실제로 고정하는 범위
createReturns201AndPersistsOpening
- BeforeEach로 네 table을 비운다.
- customer-1/password Basic auth와 A-100/10000 JSON을 준비한다.
- MockMvc로 POST /api/accounts를 dispatch한다.
- account_no='A-100'인 account COUNT를 읽는다.
- 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
- BeforeEach로 네 table을 비운다.
- BAD/-1 JSON, Basic auth, X-Request-Id w6-invalid를 준비한다.
- MockMvc로 POST /api/accounts를 dispatch한다.
- raw response body와 account 전체 COUNT를 읽는다.
- 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
- BeforeEach로 네 table을 비운다.
- AccountService.create(customer-1, LOOKUP, 7_000)로 fixture account를 직접 만든다.
- 반환 ID를 /api/accounts/{id}에 넣고 customer-1 Basic auth로 GET한다.
- 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
- BeforeEach로 네 table을 비운다.
- ID 999999, customer-1/password, X-Request-Id w6-missing을 준비한다.
- MockMvc로 GET /api/accounts/999999를 dispatch한다.
- 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의 포함 여부를 확인한다.
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 이름의 함정
-
AccountControllerTest라는 이름만 같으면 W6D7 파일이죠?
-
현재 root에는 같은 FQCN이지만 다른 method·assertion인 source가 있어.
-
selector 이름은 file path와 SHA identity를 자동 결박하지 않는다.
-
W6D7 path와 1d0333 SHA를 evidence 조건으로 확인하겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
real port, proxy, TLS, serialization over network, deployed routing
이 책임을 맡는 곳: deployed environment HTTP smoke 또는 end-to-end testexact JSON fields·types·nesting·headers·content type 전체
이 책임을 맡는 곳: jsonPath/JSON schema/exact header assertionsbusiness_tx·ledger_entry·idempotency_request·audit_event의 모든 scenario postcondition
이 책임을 맡는 곳: scenario별 explicit table/value assertionswrong password, anonymous, non-owner, roles, enumeration resistance 전체
이 책임을 맡는 곳: authentication·authorization matrix tests현재 root same-FQCN source와 W6D7 staged source가 동일하거나 staged SHA가 실행됨
이 책임을 맡는 곳: source path/hash manifest와 fresh class-to-source bindingSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
clean→A-100 POST 201+row1→BAD/-1 POST 400+tokens+account0→service LOOKUP+GET200→missing GET404 순서로 말한다.
2단계 · 코드 조각 재조립
- SpringBootTest/AutoConfigureMockMvc/dependencies
- BeforeEach TRUNCATE
- create request/status/account count
- negative request/status/body/account zero
- service fixture/owner GET
- 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로 취급하지 않는다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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");
}
}
04SortedLockTransferIT.java — 반대 방향 20 task의 한 번짜리 동시성 envelope
reference/w10/day-3/SortedLockTransferIT.java
누적 Java 테스트 참고본 · 정본 · W14-F0457줄 연결74줄 번역5 chunks
SortedLockTransferIT.java — 반대 방향 20 task의 한 번짜리 동시성 envelope
reference/w10/day-3/SortedLockTransferIT.java
누적 Java 테스트 참고본 · 정본 · W14-F04STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
A→B와 B→A가 서로 다른 순서로 계좌를 잠그면 교착 위험이 생긴다. 이 test는 정렬 잠금 구현을 10쌍·20 task로 한 번 동시에 출발시켜 종료와 두 보존식을 확인한다.
- 10쌍은 왜 20 task가 될까?
- 두 CountDownLatch는 준비와 출발을 어떻게 나눌까?
- 30초 안에 끝난 한 번의 실행이 deadlock 불가능성을 증명할까?
pairs=10tasks=20ready<=10seach future<=30sopening total=20000transfer signed sum=0STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY 계산대에서 열 명은 A에서 B로, 열 명은 B에서 A로 동시에 100원씩 옮긴다.
서로 마주 달리는 스무 장의 결제표
스무 작업이 모두 준비될 때까지 기다렸다가 한 번에 출발시킨다. 각 작업은 TransferService를 실제 호출하고 30초 안에 끝나야 한다.
끝난 뒤 두 계좌 합계 20,000과 이체 원장 합계 0을 확인한다. 빠르게 끝났다는 사실과 돈이 보존됐다는 사실을 따로 본다.
딱 여기까지만 스무 결제표 비유는 이 한 번의 workload를 설명할 뿐 모든 부하·계좌 조합에서 deadlock이 없다는 증명이 아니다.
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
-
pairs가 10이면 왜 작업은 스무 개지?
-
반복마다 A→B와 B→A 두 callable을 넣기 때문이야.
-
`tasks = pairs * 2`와 Future 추가 두 줄을 함께 봐.
-
10회와 20개를 구분해 적을게요.
두 latch
-
ready와 start는 같은 문이야?
-
ready는 준비 수를 세고 start는 test가 한 번 열어 주는 출발문이야.
-
준비가 20이 되기 전에 출발하지 않도록 역할을 나눈다.
-
countDown 위치를 따라가겠습니다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 57줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 23줄F04-L23 | @SpringBootTest |
Spring 시험 실행기에 전체 context 표찰 한 장을 건다. | `@SpringBootTest` class-level bootstrap metadata record를 만든다.
|
| 24줄F04-L24 | class SortedLockTransferIT extends PostgresIntegrationTestSupport { |
PostgreSQL 공용 열쇠를 들고 ‘번호순 잠금 송금 검수실’의 큰 문을 연다. | PostgresIntegrationTestSupport를 상속하는 SortedLockTransferIT 클래스 본문을 시작한다.
|
| 25줄F04-L25 | @Autowired JdbcClient jdbc; |
회계 장부에 직접 SQL을 적을 단말기 한 대를 시험 책상에 연결한다. | Spring JdbcClient bean을 jdbc 필드에 자동 주입한다.
|
| 26줄F04-L26 | @Autowired AccountOpeningService openings; |
SORT-A와 SORT-B 카드를 새로 발급할 창구 직원을 옆자리에 배치한다. | AccountOpeningService bean을 openings 필드에 주입한다.
|
| 27줄F04-L27 | @Autowired AccountRepository accounts; |
AccountRepository bean을 받을 accounts 연결 단자를 class에 단다. | Spring이 `AccountRepository` bean을 `accounts` field injection point에 연결한다.
|
| 28줄F04-L28 | @Autowired TransferService transfers; |
양방향 송금 신청서를 실제 처리 창구로 넘길 서비스 벨을 설치한다. | TransferService bean을 transfers 필드에 주입한다.
|
| 29줄F04-L29 | Account first; |
첫 번째 계좌 카드가 들어올 빈 받침대에 first 이름표를 붙인다. | 첫 번째 Account fixture를 보관할 package-private first 필드를 선언한다.
|
| 30줄F04-L30 | Account second; |
반대편 계좌 카드를 둘 second 받침대를 따로 마련한다. | 두 번째 Account fixture를 보관할 second 필드를 선언한다.
|
| 32줄F04-L32 | @BeforeEach |
각 검수 전에 부를 절차가 있음을 알리는 lifecycle 꼬리표를 단다. | `@BeforeEach` per-test lifecycle metadata를 선언한다.
|
| 33줄F04-L33 | void setUp( |
setUp lifecycle 절차가 실행될 method frame의 첫 boundary를 세운다. | 반환값 없는 `setUp` method declaration과 body scope를 시작한다.
|
| 34줄F04-L34 | jdbc. |
네 table을 비울 TRUNCATE SQL 명세를 JdbcClient 조립대에 올린다. | 네 persistence table과 identity를 초기화할 SQL text로 `jdbc.sql(...)` statement spec을 만든다.
|
| 35줄F04-L35 | . |
준비해 둔 대청소 지시서의 실행 단추를 눌러 빈 회계실을 만든다. | 앞 줄의 TRUNCATE SQL을 update로 실행한다.
|
| 36줄F04-L36 | first = |
customer-1의 SORT-A 계좌 한 개를 10,000으로 열어 first 표찰을 붙인다. | `openings.open` 반환 Account를 `first` field에 저장한다.
|
| 37줄F04-L37 | second = |
같은 주인의 SORT-B 카드도 10,000원으로 발급해 second 자리에 놓는다. | owner customer-1, accountNo SORT-B, balance 10_000인 두 번째 계좌를 열어 second에 저장한다.
|
| 38줄F04-L38 | } |
빈 장부와 두 10,000원 카드 준비를 마치고 setUp 서랍을 닫는다. | setUp 메서드 본문을 끝낸다.
|
| 40줄F04-L40 | @Test |
이 source의 실행 후보 하나 앞에 JUnit 검수 도장을 찍는다. | JUnit이 이어질 method declaration을 test descriptor로 수집하게 하는 annotation record다.
|
| 41줄F04-L41 | void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal( |
긴 이름을 가진 동시성 검수 method의 빈 무대를 연다. | `oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal` method를 선언하고 `throws Exception` body scope를 시작한다.
|
| 42줄F04-L42 | int pairs = |
A↔B 맞교환 묶음을 열 세트로 세겠다고 pairs 표에 10을 적는다. | 반대 방향 작업 쌍 수를 int pairs=10으로 고정한다.
|
| 43줄F04-L43 | int tasks = |
한 쌍에 신청서 두 장이므로 전체 작업표에 10×2=20을 계산해 적는다. | tasks를 pairs * 2로 계산해 20으로 만든다.
|
| 44줄F04-L44 | var ready = |
스무 연주자가 모두 준비석에 앉아야 알려 주는 20칸 준비 전광판을 세운다. | 초기 count가 tasks(20)인 ready CountDownLatch를 만든다.
|
| 45줄F04-L45 | var start = |
지휘봉을 한 번 내리기 전까지 모두 멈춰 있을 단일 출발문을 설치한다. | 초기 count 1인 start CountDownLatch를 만든다.
|
| 46줄F04-L46 | var pool = |
신청서 스무 장이 각자 앉을 수 있도록 작업 책상도 정확히 20개 펼친다. | tasks 크기 20의 fixed thread pool을 만든다.
|
| 47줄F04-L47 | try { |
성공해도 실패해도 책상을 치울 수 있게 큰 try 보호막을 펼친다. | 작업 제출·대기·assertion을 try 블록 안에서 시작한다.
|
| 48줄F04-L48 | List<Future<? |
스무 작업이 돌려줄 완료표나 예외표를 모을 빈 Future 철을 준비한다. | 와일드카드 Future를 담는 빈 ArrayList를 futures로 선언한다.
|
| 49줄F04-L49 | for ( |
번호 0부터 9까지 열 번 돌며 맞교환 신청서 한 쌍씩 작성한다. | i가 0 이상 pairs 미만일 동안 반복하는 for 루프를 시작한다.
|
| 50줄F04-L50 | int sequence = |
현재 반복 index를 sequence라는 새 번호표에 그대로 옮겨 적는다. | 현재 `i` 값을 새 int local `sequence`에 대입한다.
|
| 51줄F04-L51 | futures. |
ab-번호 신청서를 작업자에게 건네고 그 답장 약속을 Future 철에 끼우기 시작한다. | pool에 A→B invoke lambda를 submit하고 반환 Future를 futures에 추가하는 호출을 연다.
|
| 52줄F04-L52 | first. |
첫 카드 ID를 출발, 둘째 카드 ID를 도착으로 적어 ab 신청서를 완성한다. | A→B lambda에 first.getId()를 from, second.getId()를 to로 전달하고 submit/add를 닫는다.
|
| 53줄F04-L53 | futures. |
이번에는 ba-번호표를 단 반대 방향 신청서의 Future를 같은 철에 추가한다. | reverse-order invoke를 감싼 Callable을 executor에 넘기고 반환 Future를 list element로 보관한다.
|
| 54줄F04-L54 | second. |
둘째 카드 ID를 출발, 첫 카드 ID를 도착으로 적어 ba 신청서를 닫는다. | B→A lambda에 second.getId()를 from, first.getId()를 to로 넘기고 호출을 완성한다.
|
| 55줄F04-L55 | } |
열 번째 맞교환 쌍까지 Future 두 장씩 꽂고 신청서 작성 반복을 끝낸다. | for 루프 범위를 닫아 총 20개 Future 제출을 마친다.
|
| 56줄F04-L56 | assertThat( |
ready latch가 제한 시간 안에 0이 되었는지 boolean 검문을 통과시킨다. | `ready.await(10, TimeUnit.SECONDS)` 반환값이 true인지 AssertJ로 확인한다.
|
| 57줄F04-L57 | start. |
준비 완료 뒤 지휘봉을 내려 start 문을 영구히 열어 준다. | start.countDown으로 count 1을 0으로 만들어 기다리던 invoke들을 해제한다.
|
| 58줄F04-L58 | for ( |
Future 철을 앞에서부터 넘기며 각 답장을 그 차례부터 최대 30초 기다린다. | 각 Future에 대해 순차적으로 get(30 seconds)을 호출한다.
|
| 59줄F04-L59 | long firstBalance = |
모든 답장을 받은 뒤 first 카드의 최신 잔액을 계좌 서랍에서 다시 읽는다. | accounts.findById(first.id)로 첫 Account를 조회하고 없으면 예외, 있으면 balance를 firstBalance에 저장한다.
|
| 60줄F04-L60 | long secondBalance = |
second 카드도 같은 방식으로 새로 꺼내 두 번째 최종 잔액을 기록한다. | second.id로 Account를 조회해 없으면 실패하고 balance를 secondBalance에 저장한다.
|
| 61줄F04-L61 | assertThat( |
두 카드의 남은 토큰을 합쳐 처음 총액 20,000과 같은지 저울에 올린다. | firstBalance + secondBalance가 20_000인지 assertion한다.
|
| 62줄F04-L62 | assertThat( |
TRANSFER_로 시작하는 원장표의 ±금액을 모두 더하되 빈 철이면 0으로 보라는 SQL을 준비한다. | ledger_entry에서 entry_type LIKE 'TRANSFER_%'인 signed_amount 합을 COALESCE로 0 처리하는 SELECT를 만든다.
|
| 63줄F04-L63 | . |
SQL이 준 Long 합계 한 개가 정확히 0인지 두 번째 보존 저울로 확인한다. | 앞 SELECT를 Long 단일 값으로 실행하고 결과가 zero인지 assertion한다.
|
| 64줄F04-L64 | } finally { |
try 통로가 끝나는 자리에 무조건 진입할 정리실 문을 연다. | 앞선 `try`에 대응하는 `finally` block scope를 시작한다.
|
| 65줄F04-L65 | start. |
준비 단계에서 실패했더라도 출발문을 한 번 더 눌러 기다리는 worker가 갇히지 않게 한다. | finally에서 start.countDown을 다시 호출한다.
|
| 66줄F04-L66 | pool. |
스무 작업 책상에 즉시 정리 요청을 보내 남은 worker들을 interrupt한다. | pool.shutdownNow로 executor에 즉시 종료를 요청한다.
|
| 67줄F04-L67 | } |
출발문 해제와 책상 정리 요청을 끝내고 finally 정리실의 문을 닫는다. | finally 블록 범위를 끝낸다.
|
| 68줄F04-L68 | } |
현재 JUnit method의 마지막 brace를 닫아 실행 범위를 봉인한다. | 이 `}`가 `@Test` method body scope를 닫는다.
|
| 70줄F04-L70 | private Object invoke( |
invoke라는 private helper의 아직 비어 있는 signature 입구를 연다. | `private Object invoke(` method declaration을 시작하고 parameter list를 연다.
|
| 71줄F04-L71 | CountDownLatch ready, |
준비 전광판·출발문·고유표·두 계좌 번호를 보조 창구 접수칸에 모두 적는다. | invoke의 CountDownLatch 두 개, String key, long from, long to parameter를 선언한다.
|
| 72줄F04-L72 | ) { |
다섯 칸짜리 접수 서명을 닫고 invoke 작업 본문을 연다. | 여러 줄 method signature를 닫고 중괄호로 본문을 시작한다.
|
| 73줄F04-L73 | try { |
대기 중 interrupt가 와도 정해진 방식으로 바꾸기 위한 try 울타리를 친다. | invoke의 ready/start/transfer 흐름을 try 블록에서 시작한다.
|
| 74줄F04-L74 | ready. |
이 worker가 준비석에 도착했다는 불 하나를 20칸 전광판에서 끈다. | ready.countDown으로 준비 latch count를 1 감소시킨다.
|
| 75줄F04-L75 | start. |
현재 worker가 start 문이 열릴 때까지 그 자리에서 기다린다. | `start.await()`가 latch count가 0이 될 때까지 현재 thread를 block한다.
|
| 76줄F04-L76 | return transfers. |
customer-1 이름과 방향별 key·from·to, 100원 신청서를 실제 송금 창구에 내고 결과를 돌려준다. | TransferService.Command를 만들어 transfers.transfer를 호출하고 반환 객체를 invoke의 결과로 반환한다.
|
| 77줄F04-L77 | } catch ( |
InterruptedException만 받는 catch 처리실의 문을 연다. | 앞 try에서 나온 `InterruptedException`을 local `interrupted`로 받는 catch scope를 시작한다.
|
| 78줄F04-L78 | Thread. |
잡았던 interrupt 표식을 지우지 않도록 현재 thread 깃발을 다시 세운다. | Thread.currentThread().interrupt()로 interrupt status를 복원한다.
|
| 79줄F04-L79 | throw new IllegalStateException( |
중단 원인을 안쪽에 넣은 IllegalStateException 표를 만들어 Future 쪽으로 던진다. | InterruptedException을 cause로 가진 IllegalStateException을 throw한다.
|
| 80줄F04-L80 | } |
interrupt 처리 두 단계를 마치고 catch 접수대를 닫는다. | InterruptedException catch 블록을 끝낸다.
|
| 81줄F04-L81 | } |
준비 신호부터 실제 송금까지 묶은 invoke 보조 창구의 셔터를 내린다. | 마지막 `}`가 private invoke helper의 body와 call-frame declaration 범위를 종료한다.
|
| 82줄F04-L82 | } |
SortedLockTransferIT 설계도 전체를 덮어 하나의 완결된 시험 타입으로 끝낸다. | SortedLockTransferIT 클래스 본문을 닫는다.
|
30초 경계
-
Future가 30초 안에만 오면 값은 상관없어?
-
task 예외도 get에서 전파되므로 정상 반환까지 필요해.
-
timeout과 업무 예외 모두 이 test를 Red로 만든다.
-
종료와 성공을 함께 확인하겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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);
}
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 74줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 17개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 2 | package com.example.financialcore.transfer; | 이 테스트 파일의 정식 주소를 com.example.financialcore.transfer 패키지로 정한다. |
| 4 | import com.example.financialcore.PostgresIntegrationTestSupport; | 실제 PostgreSQL 통합 시험의 공통 준비를 상속하려고 지원 클래스를 가져온다. |
| 5 | import com.example.financialcore.account.Account; | 두 fixture 계좌와 최종 잔액을 표현하는 Account 타입을 가져온다. |
| 6 | import com.example.financialcore.account.AccountOpeningService; | 10,000원 계좌 두 개를 여는 AccountOpeningService를 가져온다. |
| 7 | import com.example.financialcore.account.AccountRepository; | 최종 계좌 잔액을 다시 읽는 AccountRepository를 가져온다. |
| 8 | import org.junit.jupiter.api.BeforeEach; | 각 테스트 전 fixture 준비를 표시하는 BeforeEach를 가져온다. |
| 9 | import org.junit.jupiter.api.Test; | JUnit이 실행할 직접 테스트를 표시하는 Test를 가져온다. |
| 10 | import org.springframework.beans.factory.annotation.Autowired; | Spring bean을 테스트 필드에 주입하는 Autowired를 가져온다. |
| 11 | import org.springframework.boot.test.context.SpringBootTest; | 전체 Spring Boot context를 띄우는 SpringBootTest를 가져온다. |
| 12 | import org.springframework.jdbc.core.simple.JdbcClient; | TRUNCATE와 원장 합계 SQL을 실행할 JdbcClient를 가져온다. |
| 14 | import java.util.ArrayList; | Future들을 빈 가변 목록으로 만들 ArrayList를 가져온다. |
| 15 | import java.util.List; | 스무 Future를 순서대로 담는 List를 가져온다. |
| 16 | import java.util.concurrent.CountDownLatch; | 준비와 출발 시점을 맞추는 CountDownLatch를 가져온다. |
| 17 | import java.util.concurrent.Executors; | 고정 크기 20-thread pool을 만드는 Executors를 가져온다. |
| 18 | import java.util.concurrent.Future; | worker 완료나 예외를 나중에 회수하는 Future를 가져온다. |
| 19 | import java.util.concurrent.TimeUnit; | ready 10초와 Future별 30초 단위를 나타내는 TimeUnit을 가져온다. |
| 21 | import static org.assertj.core.api.Assertions.assertThat; | 실제 값과 기대값을 읽기 좋게 비교하는 AssertJ assertThat을 정적 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 23 | @SpringBootTest | SortedLockTransferIT를 전체 Spring Boot context 통합 테스트로 실행하게 표시한다. |
| 24 | class 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 | @BeforeEach | setUp을 각 @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 to | invoke의 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 클래스 본문을 닫는다. |
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한다.
실행 순서
- 두 계좌를 각각 10,000으로 연다.
- 20 task가 ready를 줄이고 start에서 대기한다.
- test thread가 start를 열고 모든 Future를 30초 제한으로 회수한다.
- DB에서 두 잔액 합계와 TRANSFER 원장 합계를 읽어 assertion한다.
- 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에 있다.
두 보존식
-
잔액 합계만 20,000이면 충분하지?
-
원장 TRANSFER signed sum도 0인지 따로 본다.
-
두 식이 같은 오류를 항상 잡는 건 아니야.
-
잔액과 원장을 두 줄로 설명할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | pairs=10 | tasks=pairs*2를 계산한다. | tasks=20 | 반복 10회와 task 20개를 혼동하지 않는다. |
| 2 | ready count=20 | 각 task가 ready.countDown 후 start.await한다. | ready.await(10s)가 true여야 출발한다. | thread scheduling은 완전 동시를 보장하지 않는다. |
| 3 | A,B=10,000 | 서로 반대 방향으로 각 100원을 열 번 보낸다. | 최종 합계 20,000, transfer signed sum 0 | 개별 최종 잔액은 이 test가 10,000으로 assert하지 않는다. |
한 번의 한계
-
이 실행이 Green이면 deadlock은 불가능하다고 말해도 돼?
-
아니, 20 task 한 번에서 관찰되지 않았다는 뜻이야.
-
반복 stress와 다양한 schedule은 다음 책임이다.
-
envelope 숫자를 답변에 붙이겠습니다.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
barrier 준비와 Future 결과 회수를 소유한다.
test timeout 정책 전체가 아니라 코드에 적힌 await/get 제한만 보인다.고정 20-thread pool이 두 방향 callable을 실행한다.
실제 CPU 병렬도와 DB connection 수는 환경에 따라 다르다.TransferService가 계좌 잠금과 잔액·원장을 처리한다.
test는 lock SQL이나 retry 횟수를 직접 관찰하지 않는다.이 파일의 @Test가 실제로 고정하는 범위
oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal
- BeforeEach가 SORT-A와 SORT-B를 각 10,000원으로 만들고 이전 네 table 행을 지운다.
- pairs10/tasks20, ready20, start1, 20-thread pool, Future 목록을 준비한다.
- ab-0..9는 first→second, ba-0..9는 second→first로 각각 100원 transfer를 제출한다.
- 모든 invoke가 준비되면 start를 열고 각 Future를 순서대로 최대 30초 회수한다.
- 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으로 간다.
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은 만족한다.
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 dataSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- pairs와 tasks의 관계를 손으로 계산한다.
- ready와 start의 역할을 바꾸지 않고 barrier를 다시 쓴다.
- Future timeout, 잔액 합계, signed sum assertion을 서로 다른 보장으로 설명한다.
2단계 · 코드 조각 재조립
- 준비 문과 출발 문
- 반대 방향 10쌍
- 두 보존식
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를 섞지 않는다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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);
}
}
}
05TransferFailurePointIT.java — 두 RuntimeException 지점의 행 효과 rollback
reference/w12/day-4/TransferFailurePointIT.java
누적 Java 테스트 참고본 · 정본 · W14-F0548줄 연결62줄 번역7 chunks
TransferFailurePointIT.java — 두 RuntimeException 지점의 행 효과 rollback
reference/w12/day-4/TransferFailurePointIT.java
누적 Java 테스트 참고본 · 정본 · W14-F05STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
멱등 claim 뒤와 business mutation 뒤에 예외가 나도 transfer 관련 DB 효과가 남지 않아야 한다. 두 test가 각 지점을 주입하고 세 table 범위를 0으로 확인한다.
- AFTER_CLAIM과 AFTER_BUSINESS는 어디서 다를까?
- 이 test가 직접 0으로 확인하는 세 효과는 무엇일까?
- 잔액까지 rollback됐다고 이 파일만으로 말할 수 있을까?
from=10000to=5000amount=1000points=AFTER_CLAIM/AFTER_BUSINESSidempotency rows=0transfer business_tx rows=0transfer ledger rows=0STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
STARRY 송금표에 접수 도장을 찍은 직후, 또는 돈 기록을 바꾼 직후 고장을 일부러 낸다.
도장 직후와 장부 변경 직후의 두 비상 정지
각 test는 failure hook의 지점을 하나 고르고 1,000원 이체를 호출한다. RuntimeException과 `injected` 메시지가 실제로 나와야 한다.
실패 뒤 멱등 요청, TRANSFER 업무거래, TRANSFER 원장 행이 모두 0인지 센다. 세 행 효과가 남지 않았다는 좁은 범위만 직접 확인한다.
딱 여기까지만 비상 정지 비유는 두 RuntimeException 지점과 세 행 집계만 설명하며 account balance·외부 메시지·process crash rollback을 추가 증명하지 않는다.
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
-
AFTER_CLAIM과 AFTER_BUSINESS는 같은 순간이야?
-
첫째는 claim 뒤, 둘째는 업무 변경 뒤에 예외를 낸다.
-
enum 값과 hook callback 이름을 붙여 읽어.
-
두 시점을 섞지 않겠습니다.
claim 직후
-
claim 행이 잠깐 생겼다가 남을 수도 있지?
-
test는 실패 뒤 idempotency_request count가 0인지 본다.
-
잠깐의 중간 상태가 아니라 transaction 종료 뒤 결과를 읽는 거야.
-
관찰 시점을 명시할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 48줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 18줄F05-L18 | @SpringBootTest |
Spring 전체 시험 환경을 요청하는 class-level 표찰을 먼저 건다. | `@SpringBootTest` bootstrap metadata를 선언한다.
|
| 19줄F05-L19 | @Import( |
ControlledFailureHook을 만드는 시험 전용 설정을 Spring context에 끼운다. | test context에 TransferFailurePointIT.FailureConfiguration.class 설정을 추가한다.
|
| 20줄F05-L20 | class TransferFailurePointIT extends PostgresIntegrationTestSupport { |
공용 Postgres 받침대를 이어받은 TransferFailurePointIT 훈련장을 펼친다. | PostgresIntegrationTestSupport를 상속한 TransferFailurePointIT class 본문을 연다.
|
| 21줄F05-L21 | @Autowired JdbcClient jdbc; |
rollback 뒤 세 table 행 수를 셀 JdbcClient를 연결받는다. | Autowired resolution이 JdbcClient instance를 jdbc field에 주입한다.
|
| 22줄F05-L22 | @Autowired AccountOpeningService openings; |
실패 전 출발·도착 계좌를 만들 개설 서비스를 연결받는다. | AccountOpeningService reference가 openings injection point에 wiring된다.
|
| 23줄F05-L23 | @Autowired TransferService transfers; |
두 고장 지점을 지나갈 실제 TransferService를 연결받는다. | production TransferService bean을 transfers member가 받도록 선언한다.
|
| 24줄F05-L24 | @Autowired ControlledFailureHook hook; |
이번 시험이 누를 failure point를 바꿀 ControlledFailureHook을 연결받는다. | ControlledFailureHook bean candidate를 hook field에 연결하는 injection metadata다.
|
| 25줄F05-L25 | Account from; |
매 시험의 출발 계좌 객체를 넣어 둘 from 칸을 준비한다. | Account from 값을 보관할 field 자리를 선언한다.
|
| 26줄F05-L26 | Account to; |
매 시험의 도착 계좌 객체를 넣어 둘 to 칸을 준비한다. | initializer 없는 두 번째 Account member `to`를 outer test instance layout에 추가한다.
|
| 28줄F05-L28 | @BeforeEach void clean( |
clean이라는 per-test lifecycle method의 빈 정리실 문을 연다. | `@BeforeEach void clean() {`가 clean method를 before-each callback으로 선언하고 body scope를 시작한다.
|
| 29줄F05-L29 | hook. |
이전 시험의 고장 선택을 지우고 hook 바늘을 NONE 위치로 되돌린다. | hook의 현재 failure point를 NONE으로 초기화한다.
|
| 30줄F05-L30 | jdbc. |
멱등·원장·거래·계좌 표를 모두 비우고 identity 번호를 처음으로 돌린다. | 멱등·원장·업무거래·계좌 table을 비우고 identity를 다시 시작하는 SQL을 실제 실행한다.
|
| 31줄F05-L31 | from = |
customer-1의 ROLL-FROM 계좌를 만 원으로 열어 출발점에 둔다. | customer-1의 ROLL-FROM 계좌를 balance 10,000으로 열어 from에 저장한다.
|
| 32줄F05-L32 | to = |
customer-2의 ROLL-TO 계좌를 오천 원으로 열어 도착점에 둔다. | customer-2의 ROLL-TO 계좌를 balance 5,000으로 열어 to에 저장한다.
|
| 33줄F05-L33 | } |
고장 없음과 깨끗한 DB·두 계좌가 준비되어 clean fixture를 닫는다. | clean fixture 메서드 본문을 닫는다.
|
| 35줄F05-L35 | @Test void afterClaimRuntimeExceptionRollsBackClaim( |
claim 직후 runtime 고장이 선점 행까지 되돌리는지 볼 첫 JUnit 시험을 연다. | afterClaimRuntimeExceptionRollsBackClaim을 JUnit @Test로 선언하고 본문을 연다.
|
| 36줄F05-L36 | hook. |
hook 바늘을 AFTER_CLAIM 홈으로 옮겨 첫 고장 버튼만 활성화한다. | hook의 failure point를 AFTER_CLAIM으로 바꾼다.
|
| 37줄F05-L37 | assertFailureAndNoEffect( |
after-claim key를 공통 helper에 넘겨 예외와 세 COUNT 0을 검사한다. | after-claim key로 공통 실패·무효과 검사 helper를 실행한다.
|
| 38줄F05-L38 | } |
claim 직후 rollback 시나리오 호출을 마치고 첫 시험 본문을 닫는다. | 이 brace가 afterClaimRuntimeExceptionRollsBackClaim JUnit method의 execution scope를 종료한다.
|
| 40줄F05-L40 | @Test void afterBusinessRuntimeExceptionRollsBackEveryEffect( |
업무 변경 직후 runtime 고장이 DB 효과를 되돌리는 둘째 JUnit 시험을 연다. | afterBusinessRuntimeExceptionRollsBackEveryEffect를 JUnit @Test로 선언하고 본문을 연다.
|
| 41줄F05-L41 | hook. |
hook 바늘을 AFTER_BUSINESS 홈으로 옮겨 둘째 고장 버튼을 활성화한다. | hook의 failure point를 AFTER_BUSINESS로 바꾼다.
|
| 42줄F05-L42 | assertFailureAndNoEffect( |
after-business 문자열 표를 공통 helper에 넘겨 예외와 무효과 검사를 요청한다. | `after-business` 문자열 argument로 실패·무효과 검사 helper를 호출한다.
|
| 43줄F05-L43 | } |
업무 변경 뒤 rollback 시나리오 호출을 마치고 둘째 시험 본문을 닫는다. | afterBusinessRuntimeExceptionRollsBackEveryEffect body의 final boundary를 이 `}`가 닫는다.
|
| 45줄F05-L45 | private void assertFailureAndNoEffect( |
key 하나로 실패 이체와 멱등·거래·원장 COUNT를 검사할 공통 helper를 연다. | 실패 예외와 세 DB 무효과 COUNT를 함께 검사하는 private helper를 연다.
|
| 46줄F05-L46 | assertThatThrownBy( |
예외 포착 그물·service call·Command 조립틀을 한꺼번에 펼치기 시작한다. | `assertThatThrownBy` lambda 안에서 `transfer(new Command(` 호출 chain을 연다.
|
| 47줄F05-L47 | "customer-1", |
현재 key·두 계좌 id·천 원을 Command에 채우고 transfer 호출 괄호를 닫는다. | 공통 실패 helper의 Command에 actor·현재 key·두 account ID·amount 1,000을 넣고 호출을 닫는다.
|
| 48줄F05-L48 | . |
던져진 값이 RuntimeException이고 문구에 injected가 있는지 연달아 확인한다. | 바로 앞 호출 결과에 .isInstanceOf(RuntimeException.class).hasMessageContaining("injected") 단계를 이어 붙인다.
|
| 49줄F05-L49 | assertThat( |
rollback 뒤 idempotency_request 전체 COUNT가 0인지 첫 번째로 센다. | idempotency_request 전체 행 수를 읽어 0인지 확인한다.
|
| 50줄F05-L50 | assertThat( |
TRANSFER business_tx 행만 세는 둘째 SQL assertion을 시작한다. | TRANSFER business_tx 행 수를 세는 SQL 결과를 assertThat에 올린다.
|
| 51줄F05-L51 | . |
업무 거래 COUNT 단일 값이 0인지 확인한다. | business_tx COUNT 단일 값이 0인지 확인한다.
|
| 52줄F05-L52 | assertThat( |
TRANSFER_ 원장 행만 세는 셋째 SQL assertion을 시작한다. | TRANSFER_ ledger_entry 행 수를 세는 SQL 결과를 assertThat에 올린다.
|
| 53줄F05-L53 | . |
이체 원장 COUNT 단일 값이 0인지 확인한다. | ledger_entry COUNT 단일 값이 0인지 확인한다.
|
| 54줄F05-L54 | } |
예외 type·message와 세 무효과 COUNT 검사를 마쳐 helper를 닫는다. | assertFailureAndNoEffect helper 메서드 본문을 닫는다.
|
| 56줄F05-L56 | @TestConfiguration( |
다음 type이 시험 전용 설정임을 알릴 proxy-disabled 표찰을 놓는다. | `proxyBeanMethods=false`를 가진 `@TestConfiguration` metadata를 선언한다.
|
| 57줄F05-L57 | static class FailureConfiguration { |
FailureConfiguration이라는 비어 있는 중첩 설정실 문을 연다. | static nested class `FailureConfiguration`의 type body scope를 시작한다.
|
| 58줄F05-L58 | @Bean ControlledFailureHook controlledFailureHook( |
새 ControlledFailureHook을 만들어 Spring test bean으로 돌려주는 한 줄 공장을 둔다. | 새 ControlledFailureHook을 반환하는 test용 Spring bean 메서드를 한 줄로 선언한다.
|
| 59줄F05-L59 | } |
failure hook bean 한 개를 정의한 시험 설정 상자를 닫는다. | FailureConfiguration nested type의 member-declaration region이 closing brace에서 끝난다.
|
| 61줄F05-L61 | static final class ControlledFailureHook implements TransferFailureHook { |
세 지점 중 하나를 기억하며 hook 약속을 구현할 최종 ControlledFailureHook을 연다. | TransferFailureHook을 구현하는 static final ControlledFailureHook class 본문을 연다.
|
| 62줄F05-L62 | enum Point { |
고장 없음·claim 뒤·업무 변경 뒤라는 세 바늘 위치를 Point 눈금으로 만든다. | test hook이 고를 NONE·AFTER_CLAIM·AFTER_BUSINESS 세 실패 지점을 enum으로 선언한다.
|
| 63줄F05-L63 | volatile Point point = |
여러 thread가 읽을 현재 바늘을 volatile로 두고 시작 위치를 NONE으로 잡는다. | 현재 failure point를 여러 thread에 보이도록 volatile로 선언하고 NONE으로 초기화한다.
|
| 64줄F05-L64 | @Override public void afterClaim( |
afterClaim이라는 override callback의 빈 실행실을 연다. | `@Override public void afterClaim() {` declaration과 body scope를 시작한다.
|
| 65줄F05-L65 | if ( |
바늘이 AFTER_CLAIM이면 ‘injected after claim’ runtime 고장을 즉시 낸다. | point가 AFTER_CLAIM이면 RuntimeException("injected after claim")을 던진다.
|
| 66줄F05-L66 | } |
claim 뒤 조건부 고장 동작을 마치고 afterClaim 구현을 닫는다. | afterClaim callback body를 닫아 non-throw path가 caller에 return할 수 있게 한다.
|
| 67줄F05-L67 | @Override public void afterBusinessMutation( |
TransferService가 업무 변경 뒤 부르는 afterBusinessMutation 구현을 연다. | TransferFailureHook.afterBusinessMutation을 재정의하는 메서드 본문을 연다.
|
| 68줄F05-L68 | if ( |
바늘이 AFTER_BUSINESS이면 ‘injected after business’ runtime 고장을 즉시 낸다. | point가 AFTER_BUSINESS이면 RuntimeException("injected after business")을 던진다.
|
| 69줄F05-L69 | } |
업무 변경 뒤 조건부 고장 동작을 마치고 둘째 hook 구현을 닫는다. | 이 method terminator가 afterBusinessMutation callback의 control scope를 끝낸다.
|
| 70줄F05-L70 | } |
Point 바늘과 두 override를 품은 ControlledFailureHook class를 닫는다. | ControlledFailureHook nested implementation type 전체를 closing brace로 종료한다.
|
| 71줄F05-L71 | } |
fixture·두 시험·helper·설정을 품은 TransferFailurePointIT 훈련장을 닫는다. | TransferFailurePointIT outer test class의 최종 type boundary다.
|
business 직후
-
업무 변경 뒤면 transfer 원장이 남지 않아?
-
예외 뒤 TRANSFER business_tx와 ledger count를 모두 0으로 요구한다.
-
두 query는 서로 다른 table 효과를 본다.
-
사건과 원장을 따로 세겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 7개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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");
}
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 62줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 14개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | package com.example.financialcore.transfer; | 이 파일의 주소를 com.example.financialcore.transfer 패키지로 정한다. |
| 3 | import com.example.financialcore.PostgresIntegrationTestSupport; | com.example.financialcore.PostgresIntegrationTestSupport type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 4 | import com.example.financialcore.account.Account; | com.example.financialcore.account.Account type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 5 | import com.example.financialcore.account.AccountOpeningService; | com.example.financialcore.account.AccountOpeningService type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 6 | import org.junit.jupiter.api.BeforeEach; | org.junit.jupiter.api.BeforeEach type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 7 | import org.junit.jupiter.api.Test; | org.junit.jupiter.api.Test type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 8 | import org.springframework.beans.factory.annotation.Autowired; | org.springframework.beans.factory.annotation.Autowired type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 9 | import org.springframework.boot.test.context.SpringBootTest; | org.springframework.boot.test.context.SpringBootTest type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 10 | import org.springframework.boot.test.context.TestConfiguration; | org.springframework.boot.test.context.TestConfiguration type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 11 | import org.springframework.context.annotation.Bean; | org.springframework.context.annotation.Bean type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 12 | import org.springframework.context.annotation.Import; | org.springframework.context.annotation.Import type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 13 | import org.springframework.jdbc.core.simple.JdbcClient; | org.springframework.jdbc.core.simple.JdbcClient type을 이 파일에서 짧은 이름으로 쓰게 불러온다. |
| 15 | import static org.assertj.core.api.Assertions.assertThat; | org.assertj.core.api.Assertions.assertThat의 static 검사 도구를 짧은 이름으로 쓰게 불러온다. |
| 16 | import 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 설정을 추가한다. |
| 20 | class 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 본문을 닫는다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기claim 뒤와 business mutation 뒤 RuntimeException을 각각 주입해 예외를 확인하고 세 transfer 효과 집계가 모두 0인지 본다.
문법 해부
- @TestConfiguration과 @Bean은 test context 전용 failure hook을 등록한다.
- volatile point는 worker가 읽을 현재 주입 지점을 보이게 한다.
- assertThatThrownBy는 예외 종류와 메시지를 함께 검사한다.
- JdbcClient count query는 세 종류의 남은 DB 행을 각각 센다.
실행 순서
- BeforeEach가 hook을 NONE으로 돌리고 table을 비운다.
- 출발 계좌 10,000과 도착 계좌 5,000을 연다.
- 각 test가 한 failure point를 선택한다.
- TransferService가 1,000원 command 처리 중 hook에서 RuntimeException을 받는다.
- 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이 다루지 않는다.
예외 종류
-
IOException도 여기서 검증했어?
-
아니, hook이 던지는 건 RuntimeException이다.
-
메시지에 injected가 있는지도 확인한다.
-
종류와 메시지를 정확히 쓰겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | AFTER_CLAIM | claim 직후 hook이 injected after claim 예외를 던진다. | idempotency/transfer tx/transfer ledger count=0 | balance는 이 source가 직접 읽지 않는다. |
| 2 | AFTER_BUSINESS | business mutation 직후 hook이 예외를 던진다. | 동일한 세 count=0 | audit_event나 외부 전송은 세 query에 없다. |
| 3 | key 두 개 | after-claim과 after-business를 별도 idempotency key로 호출한다. | 각 test fixture에서 독립 실패를 만든다. | 두 test 사이 DB는 BeforeEach로 다시 초기화된다. |
세 개의 0
-
COUNT 세 줄은 같은 뜻을 세 번 쓴 거야?
-
멱등 claim, 업무 사건, 원장 효과를 각각 센다.
-
한 table이 0이어도 다른 table 잔여를 숨기면 안 돼.
-
세 책임을 나눠 적을게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
FailureConfiguration이 ControlledFailureHook bean을 주입한다.
production bean wiring 전체를 검증하는 목적은 아니다.service가 예외를 밖으로 전파하면 transaction rollback 후보가 된다.
test는 proxy boundary 자체를 introspection하지 않는다.세 COUNT query가 rollback 뒤 남은 transfer 효과를 관찰한다.
각 query가 확인하지 않은 balance·audit·외부 상태는 자동 포함되지 않는다.이 파일의 @Test가 실제로 고정하는 범위
afterClaimRuntimeExceptionRollsBackClaim
- BeforeEach가 point NONE, 깨끗한 네 table, balances 10,000/5,000을 만든다.
- 현재 test는 point를 AFTER_CLAIM으로 바꾸고 key after-claim을 쓴다.
- TransferService가 claim insert 뒤 failureHook.afterClaim에서 injected RuntimeException을 던지게 한다.
- 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
- BeforeEach가 point NONE, 깨끗한 네 table, balances 10,000/5,000을 만든다.
- 현재 test는 point를 AFTER_BUSINESS로 바꾸고 key after-business를 쓴다.
- withdraw/deposit·business_tx·ledger 두 행 뒤 failureHook.afterBusinessMutation에서 injected RuntimeException을 던지게 한다.
- 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 순으로 첫 실패 지점이 이동한다.
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에 남아 있어도 조건 밖이다.
잔액 빈칸
-
from과 to 잔액도 다시 읽었지?
-
이 stage source에는 실패 뒤 balance assertion이 없다.
-
setup 값이 곧 검증값은 아니야.
-
직접 본 것만 보장하겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
실패 뒤 두 account balance 불변
이 책임을 맡는 곳: explicit balance assertions or reconciliation모든 exception/failure point rollback
이 책임을 맡는 곳: additional parameterized failure injectionaudit/message/remote call까지 exactly once
이 책임을 맡는 곳: outbox and external-effect testsSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 두 enum point와 두 test method를 연결한다.
- 공유 helper의 예외 assertion과 세 count query를 다시 쓴다.
- 직접 확인한 0과 확인하지 않은 balance를 분리해 말한다.
2단계 · 코드 조각 재조립
- 두 고장 지점
- 공유 rollback 검사
- 세 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
-
ControlledFailureHook은 운영 bean이야?
-
@TestConfiguration이 test context에 넣는 주입 장치다.
-
실패 재현을 위한 도구와 운영 기능을 구분해.
-
test 전용 배선이라고 표시할게요.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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");
}
}
}
06TransferIntegrationTest.java — exact amount-conflict selector와 넓은 누적 test 원문
reference/w12/day-5/TransferIntegrationTest.java
누적 Java 테스트 참고본 · 정본 · W14-F06227줄 연결252줄 번역17 chunks
TransferIntegrationTest.java — exact amount-conflict selector와 넓은 누적 test 원문
reference/w12/day-5/TransferIntegrationTest.java
누적 Java 테스트 참고본 · 정본 · W14-F06STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
W14 smoke는 이 294줄 class 전체가 아니라 같은 key에서 amount만 바뀐 한 메서드를 exact selector로 고른다. 첫 요청 효과는 한 번 남고 두 번째 요청은 IDEMPOTENCY_CONFLICT여야 한다.
- 파일의 @Test 9개 중 smoke가 실제 실행하는 것은 몇 개일까?
- 1,000원 첫 요청과 2,000원 변경 요청 뒤 어떤 값이 남을까?
- source에 same-key20 test가 있다는 사실만으로 W14 smoke가 그것을 실행할까?
local @Test=9selected @Test=1key=conflict-keyfirst amount=1000changed amount=2000balances=9000/11000transfer tx=1transfer ledger=2idempotency claim=1STEP 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가 실행했다고 뜻하지 않는다.
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
-
파일에 @Test가 아홉인데 왜 한 개만 센다고 해?
-
runner가 class 뒤에 exact method 이름을 붙였기 때문이야.
-
source inventory와 Gradle selection은 다른 숫자다.
-
9 local, 1 selected로 적겠습니다.
failure hook config
-
이 class의 test bean도 선택 method에서 꼭 실패를 내?
-
selected method는 failureHook을 켜지 않는다.
-
배선이 존재하는 것과 그 branch가 실행되는 것을 구분해.
-
호출된 경로만 설명할게요.
반대 방향 test
-
이 class의 반대 방향 20개가 SortedLock test를 대신해?
-
별도 메서드이고 이번 exact selector 밖이다.
-
F04의 class selector와 F06의 method selector를 구분해.
-
같은 숫자라도 owner를 붙이겠습니다.
금액 conflict
-
첫 1,000원 뒤 같은 key로 2,000원을 보내면 replay야?
-
의미가 달라 IDEMPOTENCY_CONFLICT다.
-
같은 key는 같은 semantic request일 때만 replay 후보야.
-
key와 payload를 함께 비교하겠습니다.
semanticLong regex
-
JSON parser helper가 conflict amount를 읽어?
-
selected method는 Command를 직접 만들어 helper를 거치지 않는다.
-
옆 test용 helper를 현재 경로에 끼워 넣지 마.
-
실행하지 않은 helper라고 적겠습니다.
한 효과 helper
-
assertOneTransferEffect가 selected method의 세 count를 대신하지?
-
selected method는 같은 세 count를 본문에 직접 쓴다.
-
helper 호출 여부도 source에서 확인해야 해.
-
직접 assertion과 helper를 구분할게요.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 227줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 31줄F06-L31 | @SpringBootTest |
Spring Boot 시험 context를 요청하는 class 표찰을 건다. | Spring TestContext bootstrap을 요구하는 type-level marker가 annotation table에 기록된다.
|
| 32줄F06-L32 | @Import( |
claim 뒤와 돈 이동 뒤에 누를 시험용 고장 버튼 상자를 context 안으로 들여온다. | 시험에서만 쓸 failure hook 설정 class를 Spring context에 추가한다.
|
| 33줄F06-L33 | class TransferIntegrationTest extends PostgresIntegrationTestSupport { |
PostgreSQL 금고를 연결한 아홉 장짜리 이체 검수실의 바깥 벽을 세운다. | PostgreSQL 시험 지원을 상속한 `TransferIntegrationTest` class 본문을 연다.
|
| 35줄F06-L35 | @TestConfiguration |
운영 설정과 구분할 시험 전용 type 표식을 대기대에 둔다. | Spring Test가 이어질 type을 test-only configuration source로 분류할 metadata다.
|
| 36줄F06-L36 | static class FailureHookConfiguration { |
고장 갈고리 하나만 조립할 작은 시험 장비실의 문을 연다. | 고장을 넣을 bean만 제공하는 시험 전용 중첩 설정 class를 연다.
|
| 37줄F06-L37 | @Bean |
Spring이 수집할 factory method 앞에 bean 생산 표식을 놓는다. | 바로 이어질 factory method를 Spring bean producer로 등록할 annotation marker다.
|
| 38줄F06-L38 | ControlledFailureHook controlledFailureHook( |
새 고장 갈고리를 만들어 내보낼 조립대의 입구를 연다. | 새 `ControlledFailureHook` bean을 만들어 돌려주는 설정 메서드를 연다.
|
| 39줄F06-L39 | return new ControlledFailureHook( |
아직 어느 곳에서도 끊어지지 않는 새 갈고리 한 개를 조립해 건넨다. | 아직 고장 지점이 NONE인 시험용 hook 객체를 새로 만들어 반환한다.
|
| 40줄F06-L40 | } |
시험용 갈고리 한 개를 내보낸 뒤 `controlledFailureHook` 조립대의 작은 덮개를 닫는다. | `}`는 `controlledFailureHook` @Bean factory 메서드의 본문을 닫는다.
|
| 41줄F06-L41 | } |
갈고리 조립대를 품은 `FailureHookConfiguration` 시험 장비실의 바깥 문을 닫는다. | `}`는 시험 bean을 선언한 `FailureHookConfiguration` 중첩 class를 닫는다.
|
| 43줄F06-L43 | static final class ControlledFailureHook implements TransferFailureHook { |
어느 고장 버튼을 눌렀는지 기억하고 두 지점에서 확인할 갈고리 몸체를 만든다. | 실패 지점을 골라 RuntimeException을 던질 시험용 hook class를 연다.
|
| 44줄F06-L44 | enum Point { |
고장 없음·claim 뒤·업무 변경 뒤 세 칸을 가진 Point 선택 다이얼을 한 줄에서 완성한다. | 고장 없음·claim 뒤·업무 변경 뒤의 세 값을 가진 `Point` enum 선언을 한 줄에서 완성한다.
|
| 46줄F06-L46 | private volatile Point point = |
여러 worker가 볼 수 있는 다이얼 바늘을 처음 NONE 눈금에 놓는다. | 여러 thread가 읽을 현재 실패 지점을 처음에는 NONE으로 두고 volatile로 보이게 한다.
|
| 48줄F06-L48 | void failAt( |
시험 손잡이를 돌려 전달받은 실패 지점에 다이얼을 놓고 `failAt` 손잡이 덮개까지 한 번에 닫는다. | 한 줄짜리 `failAt` 메서드는 입력받은 Point를 volatile `point` field에 대입하고 그 메서드까지 닫는다.
|
| 49줄F06-L49 | void reset( |
다음 시험을 위해 다이얼을 NONE으로 되감고 `reset` 손잡이 덮개까지 같은 줄에서 닫는다. | 한 줄짜리 `reset` 메서드는 `point`를 `NONE`으로 되돌리고 그 메서드까지 닫는다.
|
| 51줄F06-L51 | @Override |
첫 interface 구현 method 앞에 override 검인표를 놓는다. | 첫 standalone `@Override` method-contract metadata를 선언한다.
|
| 52줄F06-L52 | public void afterClaim( |
claim 직후 다이얼을 읽는 첫 경보기의 덮개를 연다. | public void afterClaim callback의 declaration과 empty-at-entry body frame을 연다.
|
| 53줄F06-L53 | if ( |
다이얼이 AFTER_CLAIM이면 ‘claim 뒤 고장’ 비상벨을 즉시 울린다. | 현재 지점이 AFTER_CLAIM이면 정해진 message의 RuntimeException을 던진다.
|
| 54줄F06-L54 | } |
afterClaim callback의 현재 method brace를 닫는다. | 이 `}`가 `afterClaim` method body scope를 종료한다.
|
| 56줄F06-L56 | @Override |
둘째 구현 method 자리에도 별도의 override 확인 도장을 둔다. | 두 번째 standalone `@Override` contract marker를 선언한다.
|
| 57줄F06-L57 | public void afterBusinessMutation( |
업무 변경 직후 다이얼을 읽는 둘째 경보기의 덮개를 연다. | afterBusinessMutation public callback signature를 구현하고 execution scope를 시작한다.
|
| 58줄F06-L58 | if ( |
다이얼이 AFTER_BUSINESS_MUTATION 눈금인지 확인하는 안전 핀을 건다. | 현재 지점이 AFTER_BUSINESS_MUTATION인지 검사하는 실패 분기를 연다.
|
| 59줄F06-L59 | throw new RuntimeException( |
핀 조건이 맞으면 ‘업무 변경 뒤 고장’ 비상벨을 울린다. | `injected after business mutation`라는 시험용 RuntimeException을 던진다.
|
| 60줄F06-L60 | } |
업무 변경 뒤 고장 조건이 참일 때만 들어가는 `if` 칸막이를 닫는다. | `}`는 point가 `AFTER_BUSINESS_MUTATION`일 때 예외를 던지는 `if` block을 닫는다.
|
| 61줄F06-L61 | } |
고장 분기 칸막이까지 확인한 뒤 `afterBusinessMutation` 경보기 전체를 닫는다. | `}`는 업무 변경 직후 실패를 검사하는 `afterBusinessMutation` 메서드를 닫는다.
|
| 62줄F06-L62 | } |
claim 뒤와 업무 변경 뒤 경보기를 담은 `ControlledFailureHook` 장비함을 잠근다. | `}`는 두 failure point를 구현한 `ControlledFailureHook` 중첩 class를 닫는다.
|
| 64줄F06-L64 | @Autowired AccountRepository accounts; |
시험실 첫 선반에 계좌 카드를 다시 읽을 저장소 열쇠를 둔다. | Spring에서 `AccountRepository accounts`에 맞는 계좌 저장소 bean을 찾아 이 시험 필드에 주입한다.
|
| 65줄F06-L65 | @Autowired AccountOpeningService openings; |
둘째 선반에는 매 시험 두 계좌를 열 개설 도구를 둔다. | Spring에서 `AccountOpeningService openings`에 맞는 계좌 개설 도구 bean을 찾아 이 시험 필드에 주입한다.
|
| 66줄F06-L66 | @Autowired LedgerEntryRepository ledger; |
셋째 선반에는 전체 원장 행을 셀 장부 보관함 열쇠를 둔다. | Spring에서 `LedgerEntryRepository ledger`에 맞는 원장 저장소 bean을 찾아 이 시험 필드에 주입한다.
|
| 67줄F06-L67 | @Autowired TransferService transfers; |
넷째 선반에는 실제 hash·claim·돈 이동을 맡길 이체 담당자를 세운다. | Spring에서 `TransferService transfers`에 맞는 이체 업무 서비스 bean을 찾아 이 시험 필드에 주입한다.
|
| 68줄F06-L68 | @Autowired JdbcClient jdbc; |
다섯째 선반에는 세 업무 표를 exact SQL로 셀 조회기를 둔다. | Spring에서 `JdbcClient jdbc`에 맞는 DB 조회 도구 bean을 찾아 이 시험 필드에 주입한다.
|
| 69줄F06-L69 | @Autowired ControlledFailureHook failureHook; |
마지막 선반에는 시험 중 고장을 고를 controlled hook 손잡이를 둔다. | Spring에서 `ControlledFailureHook failureHook`에 맞는 고장 주입 갈고리 bean을 찾아 이 시험 필드에 주입한다.
|
| 71줄F06-L71 | private Account from; |
from이라는 private Account reference 칸 하나를 type에 만든다. | 초기화 expression 없는 `private Account from` instance field를 선언한다.
|
| 72줄F06-L72 | private Account to; |
to라는 둘째 private Account reference 칸을 별도로 만든다. | initializer가 없는 `private Account to` instance field를 선언한다.
|
| 74줄F06-L74 | @BeforeEach |
각 test 직전에 호출할 lifecycle slot을 알리는 표식을 단다. | standalone `@BeforeEach` per-test callback metadata를 선언한다.
|
| 75줄F06-L75 | void setUp( |
네 장부를 비우고 두 금고 카드를 새로 꽂을 준비실 문을 연다. | 반환값 없는 setUp lifecycle method의 body가 이 declaration에서 열린다.
|
| 76줄F06-L76 | jdbc. |
이전 시험의 네 표를 파쇄하고 identity 번호표도 처음으로 되감는다. | 멱등 요청·원장·업무 거래·계좌 표를 비우고 identity까지 다시 시작한다.
|
| 77줄F06-L77 | failureHook. |
이전 시험이 눌렀던 고장 버튼을 NONE 위치로 돌려놓는다. | 이전 시험이 골랐던 고장 지점을 NONE으로 초기화한다.
|
| 78줄F06-L78 | from = |
customer-1의 A 금고 카드에 10,000원 눈금을 찍어 출발 꽂이에 넣는다. | 계좌 열기 service를 불러 `from`라는 출발 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
|
| 79줄F06-L79 | to = |
두 번째 10,000 fixture를 B 표찰과 함께 destination 보관함에 넣는다. | B fixture creation이 돌려준 entity reference를 destination field `to`에 대입한다.
|
| 80줄F06-L80 | } |
장부 청소·다이얼 reset·A/B 계좌 준비를 마친 `setUp` 준비실 문을 닫는다. | `}`는 DB를 비우고 hook과 두 계좌를 준비하는 `setUp` @BeforeEach 메서드를 닫는다.
|
| 82줄F06-L82 | @Test |
첫 실행 후보 앞에 JUnit 검수 인장 하나를 놓는다. | source에서 첫 번째 standalone `@Test` classification metadata를 선언한다.
|
| 83줄F06-L83 | void transfer_and_same_key_replay_once( |
transfer_and_same_key_replay_once라는 첫 test method의 빈 기록지를 펼친다. | 해당 이름의 void test method declaration과 body scope를 시작한다.
|
| 84줄F06-L84 | var command = |
첫 replay 시험용 신청표에 손님·같은 열쇠·두 금고·3,000원을 한 줄로 적는다. | actor·key·두 계좌 ID·3,000을 Command로 묶어 `command` 변수에 저장한다.
|
| 85줄F06-L85 | var first = |
현재 command를 service에 건네 받은 첫 Result를 first 칸에 둔다. | `transfers.transfer(command)` 반환값을 local `first`에 저장한다.
|
| 86줄F06-L86 | var replay = |
같은 3,000원 신청표를 다시 접수해 저장 결과 영수증을 `replay` 칸에 둔다. | 실제 `TransferService.transfer`를 호출하고 반환된 Result를 `replay` 변수에 저장한다.
|
| 88줄F06-L88 | assertThat( |
첫 영수증의 replay 칸에 새 처리임을 뜻하는 빈 도장이 찍혔는지 본다. | `first.replayed()` 값이 false인지 확인한다.
|
| 89줄F06-L89 | assertThat( |
같은 신청표로 받은 둘째 영수증에 재발급 도장이 찍혔는지 본다. | `replay.replayed()` 값이 true인지 확인한다.
|
| 90줄F06-L90 | assertThat( |
3,000원을 보낸 뒤 출발 금고 눈금이 10,000에서 7,000으로 내려갔는지 읽는다. | repository에서 from Account를 찾아 balance actual을 7,000 expected와 비교한다.
|
| 91줄F06-L91 | assertThat( |
3,000원을 받은 뒤 도착 금고 눈금이 10,000에서 13,000으로 올랐는지 읽는다. | to identifier로 destination record를 load한 뒤 balance가 13,000인지 단언한다.
|
| 92줄F06-L92 | assertThat( |
계좌 개설 쪽지 둘과 실제 이체 쪽지 둘을 합쳐 원장에 네 장만 있는지 센다. | sequential replay scenario의 ledger repository total을 4와 맞춘다.
|
| 93줄F06-L93 | assertThat( |
TRANSFER 원장의 signed 합계를 읽을 SQL 측정틀을 펼친다. | TRANSFER `signed_amount` SUM SQL expression을 AssertJ chain의 actual로 시작한다.
|
| 94줄F06-L94 | . |
TRANSFER signed_amount SUM의 단일 long 결과를 0 눈금과 맞댄다. | 원장표 query의 단일 long 결과가 0인지 확인한다.
|
| 95줄F06-L95 | } |
첫 처리와 같은-key 재발급 검사를 적은 첫 시험 기록지를 덮는다. | `}`는 첫 실행과 같은-key replay를 확인하는 `transfer_and_same_key_replay_once` 시험 메서드를 닫는다.
|
| 97줄F06-L97 | @Test |
둘째 JUnit 실행표 자리에 새 Test 스티커를 붙인다. | source의 두 번째 standalone `@Test` metadata record를 만든다.
|
| 98줄F06-L98 | void json_field_order_and_whitespace_variant_replays_without_extra_effect( |
정순서 JSON과 표현만 바꾼 JSON을 나란히 시험할 기록지를 펼친다. | JSON 순서와 공백이 달라도 같은 뜻으로 보는 시험 본문을 시작한다.
|
| 99줄F06-L99 | String firstJson = |
from·to·amount 순서의 JSON text 한 장을 firstJson 칸에 조립한다. | 현재 expression의 account IDs와 amount literal로 기준 JSON String을 만들고 `firstJson`에 저장한다.
|
| 100줄F06-L100 | String variantJson = |
둘째 종이에는 amount→to→from 순서와 다른 공백으로 같은 세 숫자를 적는다. | 같은 세 숫자를 amount·to·from 순서와 다른 공백으로 적은 둘째 JSON을 만든다.
|
| 102줄F06-L102 | var first = |
정순서 firstJson을 Command로 바꿔 service에 보내 첫 Result를 `first`에 둔다. | `firstJson`을 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `first`에 저장한다.
|
| 103줄F06-L103 | var replay = |
순서·공백을 바꾼 variantJson을 같은 key의 Command로 보내 `replay` Result를 받는다. | `variantJson`을 같은 `representation-key` Command로 바꿔 service를 호출하고 반환 Result를 `replay`에 저장한다.
|
| 105줄F06-L105 | assertThat( |
정순서 JSON으로 받은 첫 영수증이 새 처리라고 표시됐는지 확인한다. | representation-key 첫 호출 Result가 replay가 아님을 false matcher로 확인한다.
|
| 106줄F06-L106 | assertThat( |
순서와 공백을 바꾼 JSON 영수증이 재생 결과라고 표시됐는지 확인한다. | field-order variant 호출에서 받은 replay flag가 true인지 검증한다.
|
| 107줄F06-L107 | assertThat( |
표기만 다른 두 JSON 뒤 출발 금고가 한 번치 1,000원만 줄었는지 읽는다. | JSON representation replay 뒤 source Account persisted balance를 9,000과 대조한다.
|
| 108줄F06-L108 | assertThat( |
variant representation 처리 뒤 destination 쪽 계기판이 11,000을 가리키는지 확인한다. | variant JSON path의 destination balance가 11,000으로 남았는지 repository로 읽는다.
|
| 109줄F06-L109 | assertOneTransferEffect( |
표현만 다른 재요청 뒤에도 거래1·원장2·claim1인지 세 표 검수함을 지금 연다. | 업무 거래1·TRANSFER 원장2·멱등 요청1을 묶어 확인하는 helper를 부른다.
|
| 110줄F06-L110 | } |
정순서 JSON과 순서·공백 변형 JSON을 비교한 둘째 시험 기록지를 덮는다. | `}`는 JSON field 순서·공백 변형도 replay되는지 확인하는 `json_field_order_and_whitespace_variant_replays_without_extra_effect` 시험 메서드를 닫는다.
|
| 112줄F06-L112 | @Test |
세 번째 검수 칸에 JUnit 수집 도장만 먼저 찍는다. | 세 번째 standalone `@Test` method marker를 선언한다.
|
| 113줄F06-L113 | void same_key_concurrent_requests_change_business_once( |
same_key_concurrent_requests_change_business_once라는 빈 시험실 문을 연다. | 해당 이름과 `throws Exception`을 가진 test method declaration·body scope를 시작한다.
|
| 114줄F06-L114 | int n = |
동시에 신청할 worker의 자리표를 정확히 스무 칸으로 정한다. | 동시에 보낼 같은-key service 요청 수를 20으로 고정한다.
|
| 115줄F06-L115 | var ready = |
스무 worker가 준비됐다고 알릴 때마다 하나씩 줄어드는 ready 자물쇠를 건다. | worker n명이 모두 준비됐는지 셀 `ready` latch를 만든다.
|
| 116줄F06-L116 | var start = |
모인 worker를 한 번에 풀어 줄 count1 start 신호등을 세운다. | 모든 worker를 한 번에 출발시킬 count1 `start` latch를 만든다.
|
| 117줄F06-L117 | var pool = |
스무 Callable을 실행할 고정 크기 thread 작업대를 조립한다. | same-key scenario가 n-sized fixed ExecutorService를 새로 생성한다.
|
| 118줄F06-L118 | try { |
현재 method 안에 guarded statement를 받을 try 울타리만 연다. | `try {`가 새 try block scope를 시작한다.
|
| 119줄F06-L119 | var command = |
command 변수에 넣을 새 Command constructor의 빈 조립틀을 연다. | `var command = new TransferService.Command(` allocation·assignment chain을 시작한다.
|
| 120줄F06-L120 | "customer-1", |
열린 Command constructor의 다섯 slots를 현재 line 값으로 채우고 command에 꽂는다. | 이 continuation이 `TransferService.Command` construction과 `command` assignment를 닫는다.
|
| 121줄F06-L121 | List<Future<TransferService. |
Future<Result> reference를 담을 빈 ArrayList 바구니를 만든다. | 새 빈 `ArrayList<Future<TransferService.Result>>`를 `futures`에 저장한다.
|
| 122줄F06-L122 | for ( |
i=0부터 n-1까지 same-key worker 등록을 반복할 회전판을 연다. | `i`가 0부터 `n-1`까지 변하는 for loop를 열어 same-key worker 등록을 반복한다.
|
| 123줄F06-L123 | futures. |
현재 same-key worker의 submit 봉투와 lambda 행동표를 펼쳐 Future 바구니 연결을 시작한다. | `futures.add(pool.submit(...))` 호출을 시작하고 same-key worker lambda 본문을 연다.
|
| 124줄F06-L124 | ready. |
현재 same-key worker가 출발선에 왔다고 ready 자물쇠 구슬 하나를 뺀다. | same-key lambda가 ready latch count를 한 단계 감소시켜 도착을 알린다.
|
| 125줄F06-L125 | start. |
ready를 알린 worker를 start 신호가 열릴 때까지 자기 자리에서 기다리게 한다. | same-key worker thread가 start latch가 열릴 때까지 await한다.
|
| 126줄F06-L126 | return transfers. |
start를 통과한 same-key worker가 공용 Command를 service에 넘겨 Result를 반환한다. | shared command를 TransferService에 넘기고 해당 Result를 worker callable 반환값으로 낸다.
|
| 127줄F06-L127 | })); |
현재 same-key worker의 행동 쪽지를 접고 submit 봉투와 Future 바구니 뚜껑을 차례로 닫는다. | `}));`는 same-key worker lambda 본문, `pool.submit` 호출, `futures.add` 호출을 차례로 닫는다.
|
| 128줄F06-L128 | } |
same-key worker 쪽지를 n장 만든 뒤 그 등록 회전판의 테두리를 닫는다. | `}`는 same-key worker를 `n`개 등록하는 `for (int i = 0; i < n; i++)` loop를 닫는다.
|
| 129줄F06-L129 | assertThat( |
같은 key 스무 명이 모두 5초 안에 출발선에 섰는지 준비판을 확인한다. | 모든 worker가 5초 안에 준비됐는지 true로 확인한다.
|
| 130줄F06-L130 | start. |
same-key 준비판이 꽉 차자 출발등을 내려 스무 호출을 동시에 놓아준다. | start latch를 0으로 내려 준비된 worker를 함께 출발시킨다.
|
| 131줄F06-L131 | List<TransferService. |
Future 봉투에서 꺼낸 Result를 담을 빈 둘째 바구니를 `results` 이름으로 놓는다. | 빈 `ArrayList<TransferService.Result>`를 만들어 `results` 변수에 저장한다.
|
| 132줄F06-L132 | for ( |
same-key Future 접수증을 하나씩 열어 15초 안의 Result를 두 번째 바구니에 모두 옮기고 회전을 끝낸다. | 한 줄짜리 enhanced for loop는 모든 Future를 돌며 각 결과를 최대 15초 기다려 `results` 목록에 넣고 그 loop를 끝낸다.
|
| 134줄F06-L134 | assertThat( |
스무 영수증에서 replay 도장이 없는 새 실행표만 골라 한 장인지 센다. | 20개 결과 중 replay가 아닌 새 실행 결과 수가 정확히 1인지 센다.
|
| 135줄F06-L135 | assertThat( |
스무 동시 접수가 끝난 뒤 출발 금고에서 1,000원만 빠졌는지 다시 꺼내 본다. | same-key concurrent calls 종료 뒤 source account actual을 9,000으로 검증한다.
|
| 136줄F06-L136 | assertThat( |
스무 동시 접수가 끝난 뒤 도착 금고에 1,000원만 들어왔는지 다시 꺼내 본다. | burst-key workload 이후 destination Account balance가 11,000임을 확인한다.
|
| 137줄F06-L137 | assertThat( |
동시 스무 번 뒤에도 개설 두 장과 단 한 번의 이체 두 장뿐인지 원장 묶음을 센다. | same-key concurrency fixture가 남긴 전체 ledger row 수를 4와 비교한다.
|
| 138줄F06-L138 | assertOneTransferEffect( |
스무 동시 요청 뒤 세 업무 표가 거래1·원장2·claim1인지 helper를 즉시 호출한다. | `assertOneTransferEffect` helper를 호출해 same-key 20개 요청 뒤 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
|
| 139줄F06-L139 | } finally { |
same-key 동시 시험이 성공하든 깨지든 작업대를 닫는 finally 통로로 들어간다. | same-key try의 exit를 받아 executor cleanup용 finally block을 시작한다.
|
| 140줄F06-L140 | pool. |
same-key worker가 남지 않도록 첫 thread pool의 비상 종료 버튼을 누른다. | 남은 worker에게 중단과 종료를 요청한다; 완료 대기 자체는 하지 않는다.
|
| 141줄F06-L141 | } |
same-key 동시 시험의 pool 종료 스위치를 누른 `finally` 안전 구역을 닫는다. | `}`는 same-key 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
|
| 142줄F06-L142 | } |
owner 한 명과 replay 열아홉 명을 검수한 same-key 동시 시험 기록지를 덮는다. | `}`는 `same_key_concurrent_requests_change_business_once` 시험 메서드를 닫는다.
|
| 144줄F06-L144 | @Test |
네 번째 method 수집 위치에 JUnit 검사 표식을 세운다. | source의 네 번째 standalone `@Test` metadata를 선언한다.
|
| 145줄F06-L145 | void opposite_direction_transfers_preserve_total_balance( |
opposite_direction_transfers_preserve_total_balance라는 빈 method 무대를 연다. | 그 이름과 `throws Exception`을 가진 test method scope를 시작한다.
|
| 146줄F06-L146 | int perDirection = |
A→B 줄과 B→A 줄에 각각 열 자리씩 놓도록 방향별 수를 정한다. | A→B와 B→A 각각 보낼 요청 수를 10으로 고정한다.
|
| 147줄F06-L147 | int n = |
방향별 열 자리를 두 배해 전체 worker 수 n=20을 계산한다. | 두 방향 worker 수를 10×2인 20으로 계산한다.
|
| 148줄F06-L148 | var ready = |
양방향 worker 스무 명의 준비 도착을 셀 ready 자물쇠를 건다. | opposite-direction workload의 n arrivals를 셀 새 ready CountDownLatch를 만든다.
|
| 149줄F06-L149 | var start = |
A→B와 B→A worker를 함께 풀어 줄 count1 start 신호등을 세운다. | 왕복 workers가 공유할 count-one start latch를 생성한다.
|
| 150줄F06-L150 | var pool = |
양방향 스무 Callable을 받을 고정 크기 thread 작업대를 조립한다. | opposite-direction method 전용으로 n-thread fixed pool을 할당한다.
|
| 151줄F06-L151 | try { |
양방향 동시 작업이 끝나면 별도 pool을 반드시 닫도록 try 안전 울타리를 친다. | `try` block을 열어 양방향 동시 시험의 본 작업 뒤에 `finally`로 pool을 정리하게 한다.
|
| 152줄F06-L152 | List<Future<TransferService. |
A→B와 B→A Future 접수증을 번갈아 담을 빈 왕복 바구니를 놓는다. | futures local variable이 size-zero generic ArrayList instance를 가리키도록 초기화한다.
|
| 153줄F06-L153 | for ( |
i가 0에서 perDirection 직전까지 움직일 빈 반복 구역을 연다. | `for (int i = 0; i < perDirection; i++) {` loop scope를 시작한다.
|
| 154줄F06-L154 | int seq = |
현재 왕복 차례의 i를 두 lambda가 안전하게 잡을 seq 이름표로 복사한다. | lambda가 현재 반복 번호를 안전하게 쓰도록 i 값을 seq에 복사한다.
|
| 155줄F06-L155 | futures. |
현재 iteration의 첫 비동기 호출을 감쌀 submit·add 연결을 펼친다. | 첫 번째 `futures.add(pool.submit(() -> invokeAfterBarrier(...` multi-line 호출을 시작한다.
|
| 156줄F06-L156 | ready, |
A→B 100원 표에 `ab-`와 현재 seq를 붙여 barrier 통로·작업대·Future 바구니까지 한 줄로 연결한다. | `ab-`와 seq로 key를 만든 A→B Command 생성을 끝낸 뒤 `invokeAfterBarrier`·`pool.submit`·`futures.add` 호출을 차례로 닫는다.
|
| 157줄F06-L157 | futures. |
같은 iteration의 둘째 비동기 호출을 감쌀 submit·add 연결을 다시 펼친다. | iteration 안의 두 번째 asynchronous submission pipeline을 열린 호출 상태로 만든다.
|
| 158줄F06-L158 | ready, |
B→A 100원 표에 `ba-`와 같은 seq를 붙여 barrier 통로·작업대·Future 바구니까지 닫아 넣는다. | reverse-direction Command를 barrier helper로 보내고 얻은 Future를 collection에 append한다.
|
| 159줄F06-L159 | } |
각 번호마다 A→B 한 장과 B→A 한 장을 만든 왕복 등록 회전판을 닫는다. | `}`는 A→B와 B→A 작업을 한 쌍씩 등록하는 `for (int i = 0; i < perDirection; i++)` loop를 닫는다.
|
| 160줄F06-L160 | assertThat( |
양방향 열 쌍이 모두 5초 안에 왕복 출발선에 모였는지 확인한다. | 두 방향 workers가 5초 안에 ready count zero를 만들었는지 boolean으로 확인한다.
|
| 161줄F06-L161 | start. |
왕복 준비판이 꽉 차자 A→B와 B→A 작업을 같은 순간에 놓아준다. | 왕복 workload의 start latch를 countDown해 blocked workers를 release한다.
|
| 162줄F06-L162 | for ( |
왕복 Future 접수증을 하나씩 열어 각 작업이 20초 안에 끝나는지 확인하고 회전을 마친다. | 한 줄짜리 enhanced for loop는 모든 양방향 Future를 돌며 각각 최대 20초 안에 끝나는지 기다리고 그 loop를 끝낸다.
|
| 164줄F06-L164 | long fromBalance = |
DB 서랍에서 출발 계좌 카드의 현재 잔액을 읽어 별도 계기판에 옮긴다. | DB에서 출발 계좌를 다시 읽은 현재 잔액을 `fromBalance`에 저장한다.
|
| 165줄F06-L165 | long toBalance = |
destination record에서 얻은 snapshot을 toBalance라는 별도 숫자칸에 기록한다. | DB에서 도착 계좌를 다시 읽은 현재 잔액을 `toBalance`에 저장한다.
|
| 166줄F06-L166 | assertThat( |
fromBalance 계기판을 `10_000` 눈금과 나란히 맞댄다. | 출발 잔액 변수 값을 `10_000` 기대값과 비교해 같은지 확인한다.
|
| 167줄F06-L167 | assertThat( |
오른쪽 계좌 snapshot을 expected 10,000 checkpoint에 통과시킨다. | AssertJ가 toBalance long actual을 10,000 expected literal과 equality 비교한다.
|
| 168줄F06-L168 | assertThat( |
fromBalance + toBalance 계기판을 `20_000` 눈금과 나란히 맞댄다. | 두 계좌 잔액 합계 값을 `20_000` 기대값과 비교해 같은지 확인한다.
|
| 169줄F06-L169 | assertThat( |
왕복 이십 번의 쌍쪽지 마흔 장에 개설 두 장을 더해 원장이 마흔두 장인지 센다. | opposite-direction transfers 뒤 ledger total row count가 42인지 단언한다.
|
| 170줄F06-L170 | } finally { |
왕복 이체 검사가 끝나면 결과와 상관없이 별도 작업대를 닫는 통로로 간다. | 왕복 transfer try의 모든 exit path가 진입할 finally control region을 연다.
|
| 171줄F06-L171 | pool. |
왕복 worker가 남지 않도록 둘째 thread pool의 비상 종료 버튼을 누른다. | opposite-direction executor에 shutdownNow를 보내 interrupt와 task rejection을 요청한다.
|
| 172줄F06-L172 | } |
양방향 이체용 pool을 끈 `finally` 안전 구역의 출구를 닫는다. | `}`는 양방향 동시 시험에서 pool을 종료하는 `finally` block을 닫는다.
|
| 173줄F06-L173 | } |
opposite_direction_transfers_preserve_total_balance method의 마지막 brace를 닫는다. | 이 `}`가 현재 opposite-direction test method body를 종료한다.
|
| 175줄F06-L175 | @Test |
다섯 번째 JUnit 수집 후보 앞에 Test 배지를 단다. | source의 다섯째 standalone `@Test` classification record를 선언한다.
|
| 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를 시작한다.
|
| 177줄F06-L177 | var first = |
현재 line의 actor·key·account IDs·amount로 first Command 한 장을 만든다. | 완결된 `new TransferService.Command(...)`를 local `first`에 저장한다.
|
| 178줄F06-L178 | var changed = |
같은 conflict-key의 금액만 2,000원으로 바꾼 `changed` 신청표를 만든다. | amount만 바뀐 comparison Command를 changed local에 완결해 저장한다.
|
| 180줄F06-L180 | transfers. |
금액 충돌의 기준을 남기려고 원본 1,000원 신청을 먼저 정상 처리한다. | 원본 `first` Command를 service에 먼저 실행하며 반환된 Result는 변수에 저장하지 않는다.
|
| 181줄F06-L181 | assertThatThrownBy( |
changed 호출을 예외 포착 그물 안에 넣는 AssertJ chain을 펼친다. | `assertThatThrownBy(() -> transfers.transfer(changed))` exception assertion을 시작한다.
|
| 182줄F06-L182 | . |
포착한 예외 봉투에 BusinessException type 검사표를 대고 consumer 칸을 연다. | `isInstanceOfSatisfying(BusinessException.class,` type constraint와 consumer slot을 시작한다.
|
| 183줄F06-L183 | failure -> assertThat( |
금액 바꿔치기 경보의 code가 멱등 충돌 이름과 같은지 맞댄다. | amount-conflict consumer가 받은 failure code를 IDEMPOTENCY_CONFLICT와 비교한다.
|
| 185줄F06-L185 | assertThat( |
금액 바꿔치기를 거절한 뒤 출발 금고가 첫 이체 뒤의 9,000원에 머무는지 본다. | amount-change conflict 뒤 original source balance가 9,000인지 읽어 확인한다.
|
| 186줄F06-L186 | assertThat( |
금액 바꿔치기를 거절한 뒤 도착 금고가 첫 이체 뒤의 11,000원에 머무는지 본다. | 금액 충돌이 거절된 시점의 original destination balance를 11,000으로 검증한다.
|
| 187줄F06-L187 | assertThat( |
금액 충돌 뒤 업무 거래표에 첫 성공 행 한 개만 있는지 SQL로 센다. | 업무 거래표의 TRANSFER 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
|
| 188줄F06-L188 | . |
금액 충돌 뒤 업무 거래 count가 첫 성공 하나를 뜻하는 1인지 확인한다. | amount-conflict business_tx COUNT terminal value를 1 expected에 맞춘다.
|
| 189줄F06-L189 | assertThat( |
금액 충돌 뒤 TRANSFER 원장 쪽지가 첫 처리의 두 장만 남았는지 세는 SQL 자를 댄다. | TRANSFER entry types로 filter한 ledger_entry COUNT statement specification을 시작한다.
|
| 190줄F06-L190 | . |
금액 충돌 뒤 원장 행 수를 읽어 첫 이체의 출금·입금 두 장과 맞춘다. | 금액 변경 scenario의 transfer-ledger scalar count가 2인지 확인한다.
|
| 191줄F06-L191 | assertThat( |
금액 충돌 뒤 멱등 요청표에 원래 claim 한 행만 있는지 SQL로 센다. | 멱등 요청표의 전체 행 수를 세는 SQL 결과에 AssertJ 검사를 시작한다.
|
| 192줄F06-L192 | . |
금액 충돌 뒤 멱등 요청 count가 원래 claim 하나를 뜻하는 1인지 확인한다. | 동일 key amount conflict 이후 idempotency_request total이 1인지 단언한다.
|
| 193줄F06-L193 | } |
같은 key에서 금액만 바꾼 표를 거절한 시험 기록지를 덮는다. | `}`는 금액 변경 conflict를 확인하는 `same_key_with_different_semantic_request_conflicts_without_extra_effect` 시험 메서드를 닫는다.
|
| 195줄F06-L195 | @Test |
여섯 번째 실행 후보 자리에 JUnit Test 깃발을 세운다. | source의 여섯째 standalone `@Test` metadata record를 선언한다.
|
| 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를 시작한다.
|
| 197줄F06-L197 | Account other = |
출발지 반례에 쓸 customer-1의 C 계좌를 5,000원으로 열어 `other`에 둔다. | 계좌 열기 service를 불러 `other`라는 세 번째 비교 시험 계좌를 원본의 소유자·번호·잔액으로 만든다.
|
| 198줄F06-L198 | var first = |
from-conflict-key를 가진 기준 Command를 first 칸에 완성한다. | 현재 constructor line의 components로 새 TransferService.Command를 만들고 `first`에 대입한다.
|
| 199줄F06-L199 | var changedFrom = |
같은 key에서 출발지만 C로 갈아 끼운 `changedFrom` 표를 만든다. | source account만 교체한 changedFrom Command object를 구성한다.
|
| 201줄F06-L201 | transfers. |
출발지 충돌의 기준을 남기려고 A 출발 원본을 먼저 정상 처리한다. | 출발 계좌 변경 반례 전에 원본 `first` Command를 실행하며 반환 Result는 저장하지 않는다.
|
| 202줄F06-L202 | assertThatThrownBy( |
changedFrom service call을 예외 assertion 그물에 넣기 시작한다. | changedFrom service invocation을 Throwable-catching AssertJ assertion에 넣는다.
|
| 203줄F06-L203 | . |
changedFrom 예외에 BusinessException type 자를 대고 빈 consumer slot을 남긴다. | `isInstanceOfSatisfying(BusinessException.class,` constraint를 chain에 추가한다.
|
| 204줄F06-L204 | failure -> assertThat( |
출발지 바꿔치기 경보에서 꺼낸 code를 멱등 충돌 표와 비교한다. | changed-from BusinessException의 ErrorCode가 IDEMPOTENCY_CONFLICT인지 검사한다.
|
| 206줄F06-L206 | assertThat( |
출발지 바꿔치기를 거절한 뒤 원래 A 금고가 9,000원을 유지하는지 확인한다. | source 변경 충돌 뒤 original from record가 9,000을 유지하는지 확인한다.
|
| 207줄F06-L207 | assertThat( |
changedFrom 거절 뒤 original destination 봉인이 11,000 그대로인지 repository로 검사한다. | changedFrom rejection 이후 original to Account의 11,000 balance를 읽는다.
|
| 208줄F06-L208 | assertThat( |
출발지 바꿔치기를 거절한 뒤 반례용 C 금고가 5,000원 그대로인지 본다. | alternate source candidate other의 persisted balance가 5,000인지 단언한다.
|
| 209줄F06-L209 | assertOneTransferEffect( |
출발지 충돌 뒤 첫 이체 효과만 남았는지 세 표 검사를 바로 실행한다. | `assertOneTransferEffect` helper를 지금 호출해 거래1·TRANSFER 원장2·멱등 요청1을 확인한다.
|
| 210줄F06-L210 | } |
같은 key의 출발 금고 바꿔치기를 막은 시험 기록지를 덮는다. | `}`는 출발 계좌 변경 conflict를 확인하는 `same_key_with_changed_from_conflicts_without_extra_effect` 시험 메서드를 닫는다.
|
| 212줄F06-L212 | @Test |
일곱 번째 JUnit 검사 위치에 Test 표찰 한 장을 예약한다. | 일곱 번째 marker가 즉시 이어질 declaration을 JUnit executable method로 태깅한다.
|
| 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으로 진입한다.
|
| 214줄F06-L214 | Account other = |
customer-1의 C Account를 5,000으로 열어 other reference에 둔다. | `openings.open` 반환 Account를 local `other`에 저장한다.
|
| 215줄F06-L215 | var first = |
to-conflict-key를 담은 기준 Command 한 장을 first 칸에 놓는다. | 현재 line의 five-component constructor 결과를 local `first`에 저장한다.
|
| 216줄F06-L216 | var changedTo = |
같은 key에서 도착지만 C로 갈아 끼운 `changedTo` 표를 만든다. | destination만 다른 changedTo semantic Command를 새 instance로 만든다.
|
| 218줄F06-L218 | transfers. |
도착지 충돌의 기준을 남기려고 B 도착 원본을 먼저 정상 처리한다. | baseline request `first`를 TransferService에 전달하고 반환 Result는 discard한다.
|
| 219줄F06-L219 | assertThatThrownBy( |
changedTo 호출을 Throwable 포착 assertion 안에 넣기 시작한다. | changedTo call에서 발생할 Throwable을 assertThatThrownBy chain으로 포착한다.
|
| 220줄F06-L220 | . |
changedTo 예외 봉투에 BusinessException label을 확인할 consumer 입구를 붙인다. | BusinessException runtime-class check를 추가하면서 instance consumer argument 자리는 열린 채 둔다.
|
| 221줄F06-L221 | failure -> assertThat( |
도착지 바꿔치기 경보의 code 칸이 멱등 충돌로 적혔는지 읽는다. | changed-to exception consumer가 failure.code를 IDEMPOTENCY_CONFLICT에 맞춘다.
|
| 223줄F06-L223 | assertThat( |
도착지 바꿔치기를 거절한 뒤 원래 출발 금고가 9,000원 그대로인지 확인한다. | destination 변경 conflict 후 original source balance 9,000을 repository에서 검증한다.
|
| 224줄F06-L224 | assertThat( |
도착지 바꿔치기를 거절한 뒤 원래 도착 금고가 11,000원 그대로인지 확인한다. | changedTo가 거절된 뒤 original destination record가 11,000인지 확인한다.
|
| 225줄F06-L225 | assertThat( |
도착지 바꿔치기를 거절한 뒤 잘못 지정한 C 금고가 손대지 않은 5,000원인지 본다. | alternate destination Account의 5,000 balance가 변경되지 않았음을 읽는다.
|
| 226줄F06-L226 | assertOneTransferEffect( |
도착지 충돌 뒤 새 효과가 붙지 않았는지 세 표 검사를 바로 실행한다. | `assertOneTransferEffect` helper를 지금 호출해 새 DB 효과가 붙지 않았는지 확인한다.
|
| 227줄F06-L227 | } |
destination 교체 conflict를 다룬 JUnit execution ledger를 마지막 brace로 봉인한다. | closing brace가 destination-change JUnit execution을 끝내고 caller framework로 control을 돌린다.
|
| 229줄F06-L229 | @Test |
여덟 번째 source-local 실행 후보 앞에 Test 배지를 단다. | JUnit discovery용 여덟 번째 test tag를 다음 declaration 앞에 배치한다.
|
| 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를 연다.
|
| 231줄F06-L231 | failureHook. |
controlled failure 다이얼을 AFTER_CLAIM 눈금에 맞춘다. | 이번 시험에서는 claim 직후에 RuntimeException을 던지도록 hook을 맞춘다.
|
| 232줄F06-L232 | var command = |
fail-claim key와 A·B·1,000원을 담은 rollback Command를 만든다. | fail-claim key와 현재 account IDs·amount로 TransferService.Command를 만들어 `command`에 저장한다.
|
| 234줄F06-L234 | assertThatThrownBy( |
command service call 주위에 Throwable 포착용 AssertJ 그물을 펼친다. | `assertThatThrownBy(() -> transfers.transfer(command))` chain을 시작한다.
|
| 235줄F06-L235 | . |
claim 뒤 경보 봉투가 시험용 RuntimeException 종류인지 확인한다. | claim-failure path에서 captured Throwable이 RuntimeException인지 type-check한다.
|
| 236줄F06-L236 | . |
경보 쪽지에 `injected after claim` 문구가 정확히 찍혔는지 읽는다. | 던져진 exception message가 현재 line의 `injected after claim` literal과 같은지 확인한다.
|
| 237줄F06-L237 | assertOnlyOpeningStateRemains( |
claim 직후 실패가 끝난 다음 두 잔액과 세 업무 표가 처음 상태인지 종합 검사를 바로 실행한다. | `assertOnlyOpeningStateRemains` helper를 지금 호출해 두 잔액 원복과 세 업무 표 0행을 확인한다.
|
| 238줄F06-L238 | } |
claim 도장 직후 고장을 낸 첫 rollback 시험 기록지를 덮는다. | `}`는 claim 직후 예외의 rollback을 확인하는 `runtime_exception_after_claim_rolls_back_every_database_effect` 시험 메서드를 닫는다.
|
| 240줄F06-L240 | @Test |
아홉 번째이자 마지막 source marker 자리에 JUnit Test 인장을 둔다. | source test inventory의 마지막 annotation이 아홉 번째 executable declaration slot을 표시한다.
|
| 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을 시작한다.
|
| 242줄F06-L242 | failureHook. |
controlled failure 다이얼을 AFTER_BUSINESS_MUTATION 눈금에 맞춘다. | 이번 시험에서는 업무 변경 직후에 RuntimeException을 던지도록 hook을 맞춘다.
|
| 243줄F06-L243 | var command = |
fail-business key와 A·B·1,000원을 담은 rollback Command를 만든다. | fail-business key와 현재 from/to IDs·amount를 새 Command로 묶어 `command`에 둔다.
|
| 245줄F06-L245 | assertThatThrownBy( |
현재 command service call을 Throwable assertion 그물 안에 넣는다. | business-mutation command invocation을 exception-catching assertion chain에 건다.
|
| 246줄F06-L246 | . |
돈 이동 뒤 경보 봉투도 주입한 RuntimeException 종류인지 맞춘다. | mutation-after failure가 던진 object의 runtime type을 RuntimeException으로 제한한다.
|
| 247줄F06-L247 | . |
경보 쪽지에 `injected after business mutation` 문구가 정확히 찍혔는지 읽는다. | captured exception message를 `injected after business mutation` literal과 정확히 비교한다.
|
| 248줄F06-L248 | assertOnlyOpeningStateRemains( |
업무 변경 직후 실패가 끝난 다음 모든 DB 변경이 사라졌는지 종합 검사를 바로 실행한다. | `assertOnlyOpeningStateRemains` helper를 지금 호출해 업무 변경 직후 예외의 DB 원복을 확인한다.
|
| 249줄F06-L249 | } |
돈·거래·원장을 고친 직후 고장을 낸 둘째 rollback 시험 기록지를 덮는다. | `}`는 업무 변경 직후 예외의 rollback을 확인하는 `runtime_exception_after_business_mutation_rolls_back_every_database_effect` 시험 메서드를 닫는다.
|
| 251줄F06-L251 | private void assertOnlyOpeningStateRemains( |
rollback 뒤 처음 연 두 계좌와 빈 세 업무 표를 확인할 종합 검수함을 연다. | 처음 연 계좌 상태만 남았는지 확인하는 helper 본문을 시작한다.
|
| 252줄F06-L252 | assertThat( |
고장 rollback 뒤 출발 금고 카드를 다시 꺼내 처음의 10,000원이 복원됐는지 본다. | DB에서 출발 계좌를 다시 읽어 잔액이 `10_000`인지 확인한다.
|
| 253줄F06-L253 | assertThat( |
고장 rollback 뒤 도착 금고 카드도 처음의 10,000원으로 돌아왔는지 본다. | to ID repository lookup 뒤 얻은 balance를 expected 10,000과 equality check한다.
|
| 254줄F06-L254 | assertThat( |
rollback 검수의 첫 표로 멱등 claim 장부가 비었는지 한 줄 SQL 저울에 올린다. | `idempotency_request` 전체 행 수를 한 줄 SQL로 구해 0인지 확인한다.
|
| 255줄F06-L255 | assertThat( |
business_tx의 TRANSFER 행 수를 읽을 SQL 측정틀을 펼친다. | `business_tx` TRANSFER COUNT query를 AssertJ chain에 올리기 시작한다.
|
| 256줄F06-L256 | . |
rollback 뒤 업무 거래 count가 0인지 읽어 변경이 사라졌음을 확인한다. | 업무 거래표 query의 단일 long 결과가 0인지 확인한다.
|
| 257줄F06-L257 | assertThat( |
ledger_entry의 TRANSFER 계열 행을 셀 둘째 SQL 틀을 연다. | `ledger_entry` TRANSFER COUNT query assertion을 시작한다.
|
| 258줄F06-L258 | . |
rollback 뒤 원장 행 수를 읽어 아무 변경도 없다는 0과 맞춘다. | opening-only rollback helper에서 transfer-ledger COUNT scalar가 zero인지 확인한다.
|
| 259줄F06-L259 | } |
두 금고10,000과 claim·거래·원장0을 묶어 보는 초기상태 검수함을 닫는다. | `}`는 두 잔액과 세 업무 표의 초기 상태를 검사하는 `assertOnlyOpeningStateRemains` helper를 닫는다.
|
| 261줄F06-L261 | private TransferService. |
평평한 시험 JSON을 service Command로 바꿀 작은 조립실을 연다. | 평평한 JSON에서 Command를 만드는 시험 helper 본문을 시작한다.
|
| 262줄F06-L262 | return new TransferService. |
caller에게 돌려줄 Command의 빈 constructor와 return 통로를 연다. | `return new TransferService.Command(` allocation expression을 시작한다.
|
| 263줄F06-L263 | "customer-1", |
Command 첫 칸에 이 fixture의 actor `customer-1` 명찰을 꽂는다. | Command의 actor 칸에 이 시험의 로그인 주체 `customer-1`을 넣는다.
|
| 264줄F06-L264 | key, |
Command 둘째 칸에 caller가 건넨 idempotency key를 꽂는다. | Command의 idempotency key 칸에 helper가 입력받은 `key`를 넣는다.
|
| 265줄F06-L265 | semanticLong( |
JSON의 `from` 숫자를 찾아 Command의 출발 계좌 칸에 적는다. | JSON에서 `from`에 적힌 출발 계좌 숫자를 찾아 Command 칸에 넣는다.
|
| 266줄F06-L266 | semanticLong( |
같은 JSON의 `to` 숫자를 찾아 Command의 도착 계좌 칸에 적는다. | JSON에서 `to`에 적힌 도착 계좌 숫자를 찾아 Command 칸에 넣는다.
|
| 267줄F06-L267 | semanticLong( |
JSON의 `amount` 숫자를 찾아 Command의 마지막 금액 칸에 적는다. | JSON에서 `amount`에 적힌 이체 금액 숫자를 찾아 Command 칸에 넣는다.
|
| 268줄F06-L268 | ); |
actor·key·from·to·amount 다섯 칸을 채운 Command 상자를 닫고 반환 표를 붙인다. | `);`는 `commandFromJson`이 반환할 `new TransferService.Command(...)` 생성자 호출과 return 문을 닫는다.
|
| 269줄F06-L269 | } |
commandFromJson helper의 마지막 brace를 닫아 caller로 돌아갈 경계를 만든다. | 이 `}`가 `commandFromJson` method body scope를 닫는다.
|
| 271줄F06-L271 | private long semanticLong( |
json과 field를 받아 long을 돌려줄 private helper의 control region을 시작한다. | `private long semanticLong(String json, String field) {` signature와 body scope를 시작한다.
|
| 272줄F06-L272 | var matcher = |
인용한 field 이름 뒤의 0 이상 십진 숫자를 찾는 Pattern과 matcher를 만든다. | 요청한 field 이름 뒤의 0 이상 십진 숫자를 찾는 시험용 정규식을 만들고 matcher를 얻는다.
|
| 273줄F06-L273 | if ( |
matcher가 아무 항목도 못 찾은 경우에만 예외 경보를 울리는 분기표를 둔다. | `matcher.find()`가 false면 `IllegalArgumentException`을 즉시 던진다.
|
| 274줄F06-L274 | return Long. |
matcher의 첫 숫자 capture를 long으로 바꿔 caller에게 반환한다. | 정규식 첫 capture의 숫자 문자열을 long으로 바꿔 반환한다.
|
| 275줄F06-L275 | } |
요청한 JSON field의 숫자를 long으로 바꾸는 `semanticLong` 돋보기 상자를 닫는다. | `}`는 JSON에서 한 field의 long 값을 찾는 `semanticLong` helper를 닫는다.
|
| 277줄F06-L277 | private void assertOneTransferEffect( |
assertOneTransferEffect라는 검수 helper의 빈 작업실을 연다. | 반환값 없는 private helper `assertOneTransferEffect`의 body scope를 시작한다.
|
| 278줄F06-L278 | assertThat( |
단일 효과 검수함의 첫 칸에서 업무 거래표에 TRANSFER 한 행만 있는지 묻는다. | `business_tx`의 TRANSFER 행 수를 세는 첫 단일-effect assertion을 시작한다.
|
| 279줄F06-L279 | . |
단일 효과 helper의 업무 거래 count를 정확한 한 행과 맞춘다. | single-effect helper의 business transaction count를 terminal expected 1에 맞춘다.
|
| 280줄F06-L280 | assertThat( |
ledger_entry TRANSFER 행 수를 읽을 다음 SQL assertion 틀을 연다. | `ledger_entry` TRANSFER COUNT query를 AssertJ chain에 올리기 시작한다.
|
| 281줄F06-L281 | . |
단일 효과 helper의 원장 행 수를 읽어 한 쌍이라는 2와 맞춘다. | assertOneTransferEffect가 transfer ledger row 수 2를 equality assertion으로 확인한다.
|
| 282줄F06-L282 | assertThat( |
idempotency_request 전체 행을 셀 마지막 SQL 측정틀을 펼친다. | `idempotency_request` COUNT query assertion을 시작한다.
|
| 283줄F06-L283 | . |
단일 효과 helper의 claim count를 정확한 한 행과 맞춘다. | effect helper의 idempotency_request scalar count를 1로 검증한다.
|
| 284줄F06-L284 | } |
거래1·TRANSFER 원장2·멱등 요청1을 세는 `assertOneTransferEffect` 검수함을 닫는다. | `}`는 거래1·원장2·claim1을 검사하는 `assertOneTransferEffect` helper를 닫는다.
|
| 286줄F06-L286 | private TransferService. |
barrier 뒤 service를 부를 helper의 반환형과 `invokeAfterBarrier` 이름을 첫 줄에 적는다. | `invokeAfterBarrier` helper의 반환 type과 메서드 이름을 선언하고 parameter 목록을 열기 시작한다.
|
| 287줄F06-L287 | CountDownLatch ready, |
helper 입력표에 `ready` latch·`start` latch·`command` 세 이름을 잘리지 않게 적는다. | `invokeAfterBarrier`의 세 parameter `CountDownLatch ready`, `CountDownLatch start`, `TransferService.Command command`를 선언한다.
|
| 288줄F06-L288 | ) throws Exception { |
세 입력표를 닫고 대기 예외를 caller에게 알린 뒤 worker 통로의 문을 연다. | parameter 목록을 닫고 `throws Exception`을 선언한 뒤 `{`로 `invokeAfterBarrier` helper 본문을 연다.
|
| 289줄F06-L289 | ready. |
barrier helper에 들어온 worker가 준비됐다고 ready count를 하나 내린다. | invokeAfterBarrier helper가 자신의 ready latch arrival을 countDown으로 기록한다.
|
| 290줄F06-L290 | start. |
ready를 알린 worker를 caller의 start latch가 열릴 때까지 기다리게 한다. | barrier helper 내부 thread가 supplied start signal이 열릴 때까지 대기한다.
|
| 291줄F06-L291 | return transfers. |
start 신호를 통과한 worker가 전달받은 Command를 service에 넘겨 Result를 그대로 반환한다. | invokeAfterBarrier가 command transfer Result를 그대로 caller Future에 반환한다.
|
| 292줄F06-L292 | } |
ready 도착과 start 대기 뒤 service를 부르는 `invokeAfterBarrier` 통로를 닫는다. | `}`는 barrier 뒤 service를 호출하는 `invokeAfterBarrier` helper를 닫는다.
|
| 293줄F06-L293 | } |
아홉 시험과 준비·검수 helper를 모두 담은 `TransferIntegrationTest` 큰 서류함을 닫는다. | `}`는 아홉 시험과 준비·실패주입·검수 helper들을 담은 `TransferIntegrationTest` class를 닫는다.
|
volatile point
-
point가 volatile이면 transaction도 안전해져?
-
아니, test thread 사이에 hook 상태를 보이게 하는 필드다.
-
DB atomicity는 transaction과 assertion이 담당한다.
-
메모리 가시성과 DB 보장을 나누겠습니다.
changed from
-
출발 계좌가 달라도 이번 test가 확인해?
-
그건 다음 별도 @Test이고 smoke 선택 대상이 아니다.
-
선택 method는 amount만 바꾼다.
-
변경 field를 exact하게 적을게요.
barrier helper
-
invokeAfterBarrier가 amount conflict 호출 순서를 만들지?
-
아니, 반대 방향 동시성 test 전용이다.
-
selected method는 순차로 first 뒤 changed를 호출한다.
-
동시성 없는 경로로 설명하겠습니다.
STEP 05 / 13
원본 코드 조각
원본을 17개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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);
}
}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 252줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
준비·설명 줄 25개도 번역해서 보기
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 2 | package com.example.financialcore.transfer; | 이 파일의 소속 주소를 `com.example.financialcore.transfer`로 정한다. |
| 4 | import com.example.financialcore.PostgresIntegrationTestSupport; | 아래 코드에서 `com.example.financialcore.PostgresIntegrationTestSupport` 타입을 짧게 부르려고 가져온다. |
| 5 | import com.example.financialcore.account.Account; | 아래 코드에서 `com.example.financialcore.account.Account` 타입을 짧게 부르려고 가져온다. |
| 6 | import com.example.financialcore.account.AccountOpeningService; | 아래 코드에서 `com.example.financialcore.account.AccountOpeningService` 타입을 짧게 부르려고 가져온다. |
| 7 | import com.example.financialcore.account.AccountRepository; | 아래 코드에서 `com.example.financialcore.account.AccountRepository` 타입을 짧게 부르려고 가져온다. |
| 8 | import com.example.financialcore.api.BusinessException; | 아래 코드에서 `com.example.financialcore.api.BusinessException` 타입을 짧게 부르려고 가져온다. |
| 9 | import com.example.financialcore.api.ErrorCode; | 아래 코드에서 `com.example.financialcore.api.ErrorCode` 타입을 짧게 부르려고 가져온다. |
| 10 | import com.example.financialcore.ledger.LedgerEntryRepository; | 아래 코드에서 `com.example.financialcore.ledger.LedgerEntryRepository` 타입을 짧게 부르려고 가져온다. |
| 11 | import org.junit.jupiter.api.BeforeEach; | 아래 코드에서 `org.junit.jupiter.api.BeforeEach` 타입을 짧게 부르려고 가져온다. |
| 12 | import org.junit.jupiter.api.Test; | 아래 코드에서 `org.junit.jupiter.api.Test` 타입을 짧게 부르려고 가져온다. |
| 13 | import org.springframework.beans.factory.annotation.Autowired; | 아래 코드에서 `org.springframework.beans.factory.annotation.Autowired` 타입을 짧게 부르려고 가져온다. |
| 14 | import org.springframework.boot.test.context.SpringBootTest; | 아래 코드에서 `org.springframework.boot.test.context.SpringBootTest` 타입을 짧게 부르려고 가져온다. |
| 15 | import org.springframework.jdbc.core.simple.JdbcClient; | 아래 코드에서 `org.springframework.jdbc.core.simple.JdbcClient` 타입을 짧게 부르려고 가져온다. |
| 16 | import org.springframework.boot.test.context.TestConfiguration; | 아래 코드에서 `org.springframework.boot.test.context.TestConfiguration` 타입을 짧게 부르려고 가져온다. |
| 17 | import org.springframework.context.annotation.Bean; | 아래 코드에서 `org.springframework.context.annotation.Bean` 타입을 짧게 부르려고 가져온다. |
| 18 | import org.springframework.context.annotation.Import; | 아래 코드에서 `org.springframework.context.annotation.Import` 타입을 짧게 부르려고 가져온다. |
| 20 | import static org.assertj.core.api.Assertions.assertThat; | 시험에서 `org.assertj.core.api.Assertions.assertThat` 기능을 짧은 이름으로 쓰려고 가져온다. |
| 21 | import static org.assertj.core.api.Assertions.assertThatThrownBy; | 시험에서 `org.assertj.core.api.Assertions.assertThatThrownBy` 기능을 짧은 이름으로 쓰려고 가져온다. |
| 23 | import java.util.ArrayList; | 아래 코드에서 `java.util.ArrayList` 타입을 짧게 부르려고 가져온다. |
| 24 | import java.util.List; | 아래 코드에서 `java.util.List` 타입을 짧게 부르려고 가져온다. |
| 25 | import java.util.concurrent.CountDownLatch; | 아래 코드에서 `java.util.concurrent.CountDownLatch` 타입을 짧게 부르려고 가져온다. |
| 26 | import java.util.concurrent.Executors; | 아래 코드에서 `java.util.concurrent.Executors` 타입을 짧게 부르려고 가져온다. |
| 27 | import java.util.concurrent.Future; | 아래 코드에서 `java.util.concurrent.Future` 타입을 짧게 부르려고 가져온다. |
| 28 | import java.util.concurrent.TimeUnit; | 아래 코드에서 `java.util.concurrent.TimeUnit` 타입을 짧게 부르려고 가져온다. |
| 29 | import java.util.regex.Pattern; | 아래 코드에서 `java.util.regex.Pattern` 타입을 짧게 부르려고 가져온다. |
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 31 | @SpringBootTest | 실제 Spring bean과 PostgreSQL 경로를 함께 쓰는 통합 시험임을 표시한다. |
| 32 | @Import(TransferIntegrationTest.FailureHookConfiguration.class) | 시험에서만 쓸 failure hook 설정 class를 Spring context에 추가한다. |
| 33 | class 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를 닫는다. |
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을 재사용한다.
실행 순서
- BeforeEach가 네 table을 비우고 A/B를 각각 10,000으로 연다.
- selected test가 key=conflict-key, amount=1,000 command를 실행한다.
- 같은 key와 계좌에서 amount=2,000 command를 호출한다.
- BusinessException의 code가 IDEMPOTENCY_CONFLICT인지 본다.
- 잔액 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 두 계좌
-
fixture의 A와 B는 왜 둘 다 10,000이야?
-
변경 전후를 손계산하기 쉬운 대칭 시작값이다.
-
선택 test에서는 1,000 이동 뒤 9,000/11,000을 기대한다.
-
초기값과 결과를 연결하겠습니다.
changed to
-
도착 계좌 변경도 같은 assertion으로 실행됐지?
-
source에는 있지만 exact selector에는 포함되지 않는다.
-
존재와 실행을 또 구분해야 해.
-
비선택 test로 표시하겠습니다.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | 첫 command 1,000 | TransferService가 from A에서 to B로 한 번 반영한다. | A=9,000, B=11,000 | 첫 호출 자체의 replay flag는 selected method가 assert하지 않는다. |
| 2 | 같은 key, amount 2,000 | semantic hash 불일치 경로가 BusinessException을 낸다. | ErrorCode.IDEMPOTENCY_CONFLICT | changed-from/to는 별도 test이고 이번 selector 밖이다. |
| 3 | 충돌 뒤 DB | 첫 효과 외 추가 row가 생겼는지 세 count로 확인한다. | transfer tx=1, transfer ledger=2, idempotency=1 | audit_event·HTTP response·stale PROCESSING은 읽지 않는다. |
기본 replay test
-
첫 test가 replay를 확인하니 이번 selector도 replay test지?
-
그 메서드는 source에만 있고 이번에 고른 것은 amount conflict다.
-
메서드 이름을 selector와 정확히 맞춰.
-
옆 test 결과를 빌려오지 않을게요.
after claim rollback
-
failureHook의 AFTER_CLAIM도 이번 smoke에서 켜져?
-
아니, 별도 rollback method가 켠다.
-
selected conflict test에서는 point가 NONE이다.
-
hook branch를 과장하지 않을게요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
class.method exact selector가 이 class의 한 test method를 선택한다.
source-local test 개수와 실제 smoke 실행 개수는 다르다.첫 claim과 business effect를 저장하고 changed payload를 conflict로 분기한다.
selected test는 내부 hash 문자열이나 SQL statement를 직접 읽지 않는다.balance 두 값과 세 count가 추가 효과 없음의 관찰값이다.
이 다섯 관찰값 밖의 부작용은 별도 test가 필요하다.이 파일의 @Test가 실제로 고정하는 범위
transfer_and_same_key_replay_once
- both accounts customer-1 and balance 10000; key-1; amount 3000; two opening ledger rows
- `transfer_and_same_key_replay_once` 원본 본문을 실행한다.
- 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
- two flat numeric JSON strings with reordered from/to/amount and whitespace; regex helper extracts digits; amount 1000
- `json_field_order_and_whitespace_variant_replays_without_extra_effect` 원본 본문을 실행한다.
- 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
- 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
- `same_key_concurrent_requests_change_business_once` 원본 본문을 실행한다.
- 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
- 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
- `opposite_direction_transfers_preserve_total_balance` 원본 본문을 실행한다.
- 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
- same key; first amount 1000, changed amount 2000; starting balances 10000/10000
- `same_key_with_different_semantic_request_conflicts_without_extra_effect` 원본 본문을 실행한다.
- 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
- same key; changed source is a third customer-1 account with balance 5000
- `same_key_with_changed_from_conflicts_without_extra_effect` 원본 본문을 실행한다.
- 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
- same key; changed destination is a third customer-1 account with balance 5000
- `same_key_with_changed_to_conflicts_without_extra_effect` 원본 본문을 실행한다.
- 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
- both balances 10000; amount 1000; failure after claim
- `runtime_exception_after_claim_rolls_back_every_database_effect` 원본 본문을 실행한다.
- 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
- both balances 10000; amount 1000; failure after business mutation
- `runtime_exception_after_business_mutation_rolls_back_every_database_effect` 원본 본문을 실행한다.
- 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행 순서다.
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 표현 차이
-
공백과 field 순서 variant도 이번 smoke가 실행해?
-
아니, 별도 메서드다.
-
source 설명에는 둘 수 있어도 Green 범위에는 넣지 마.
-
누적 원문과 실행 범위를 표시하겠습니다.
after business rollback
-
업무 변경 뒤 예외도 이 selector가 검증해?
-
그 역시 다른 @Test다.
-
F05 class selector와 F06 exact method 범위를 비교해 봐.
-
rollback owner를 구분하겠습니다.
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
나머지 8개 @Test도 이번 smoke에서 Green
이 책임을 맡는 곳: separate selectors or class-wide suite모든 field·actor·operation 변화가 conflict
이 책임을 맡는 곳: parameterized semantic-difference matrixstale PROCESSING과 burst 100 exactly-once
이 책임을 맡는 곳: recovery policy and dedicated stress testsSTEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- source-local 9와 selected 1을 먼저 적는다.
- first/changed command에서 key·from·to는 같고 amount만 다른 형태를 쓴다.
- conflict code와 다섯 최종 관찰값을 정본 없이 재현한다.
2단계 · 코드 조각 재조립
- class와 method selector
- 첫 효과 한 번
- 바뀐 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
-
20개 동시 요청 효과 1도 이번 결과에 포함되지?
-
exact selector는 그 메서드를 실행하지 않는다.
-
W14 답변의 prior evidence와 smoke evidence를 섞지 마.
-
20은 비선택 경계로 남길게요.
opening state helper
-
assertOnlyOpeningStateRemains가 있으니 selected test도 호출해?
-
아니, 두 rollback test만 호출한다.
-
private helper는 호출자가 있어야 움직여.
-
호출선을 따라가겠습니다.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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);
}
}
07W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기
scripts/run-w14-smoke.ps1
정본 PowerShell 실행기 · 정본 · W14-F079줄 연결9줄 번역7 chunks
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기
scripts/run-w14-smoke.ps1
정본 PowerShell 실행기 · 정본 · W14-F07STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
여섯 exact selector를 Gradle에 넘긴 뒤 JUnit XML class 집합과 tests·failures·errors·skipped 합계를 검사해 한 줄 evidence를 남긴다.
- 여섯 selector 중 하나는 왜 method 하나만 고르나?
- XML에서 어떤 숫자가 모두 Green이어야 하나?
- 이 marker가 staged source 실행까지 증명하지 못하는 이유는 무엇인가?
selectors=6expected classes=6implemented minimum tests=5failures/errors/skipped=0marker=W14_SMOKE_GREENW14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · 두 입력 경로
-
EvidencePath를 생략하면 어디에 쓰나요?
-
root 아래 evidence/w14/smoke.txt가 기본값이야.
-
ProjectRoot override만 Resolve-Path하고 기본 referenceRoot가 실행 가능한 stage root인지는 확인하지 않아.
-
호출 예에서 EvidencePath만 생략했을 때 target이 root/evidence/w14/smoke.txt인지 확인하면 돼요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
여섯 장의 점검표와 결과 봉투
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기
니지카는 여섯 점검표 이름을 Gradle에게 정확히 건네고, 실행이 끝나면 XML 결과 봉투를 모은다.
료는 봉투 속 class와 숫자를 확인하되, 이 실행기가 tests=5도 통과시키고 staged source hash는 확인하지 않는다고 짧게 경고한다.
딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
이름 여섯 개 전달
다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.
- 코드 연결
4~5줄- 비유
- 여섯 장의 점검표와 결과 봉투에서 이름 여섯 개 전달 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
결과 봉투 검사
XML class 집합과 네 숫자를 읽어 Green인지 판단한다.
- 코드 연결
6~7줄- 비유
- 여섯 장의 점검표와 결과 봉투에서 결과 봉투 검사 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
증거 한 줄 쓰기
통과하면 smoke.txt에 marker 한 줄을 쓴다.
- 코드 연결
8줄- 비유
- 여섯 장의 점검표와 결과 봉투에서 증거 한 줄 쓰기 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 9줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F07-L01 | param( |
접수대에 결과 봉투 주소와 실행할 프로젝트 주소 두 칸을 마련한다. | EvidencePath 기본값과 선택 ProjectRoot를 받는 실행기 매개변수 선언이다.
|
| 2줄F07-L02 | $ErrorActionPreference= |
실수하면 즉시 멈추는 스위치를 켜고 오늘 쓸 작업대까지 한 번에 이동한다. | PowerShell 오류를 즉시 중단하게 하고, override가 있으면 Resolve-Path한 경로를 아니면 script 부모를 root로 삼아 그 위치로 이동한다.
|
| 3줄F07-L03 | try{ |
작업대를 옮긴 뒤 돌아올 수 있도록 안전줄이 달린 구역을 연다. | 어떤 분기에서 끝나도 현재 위치를 되돌리기 위한 try 블록을 시작한다.
|
| 4줄F07-L04 | $selectors= |
다섯 묶음 악보와 한 곡의 특정 마디를 골라 여섯 장의 연주 목록을 만든다. | 다섯 class selector와 TransferIntegrationTest의 한 method selector를 합쳐 exact selector 여섯 개를 고정한다.
|
| 5줄F07-L05 | $args= |
여섯 이름표를 Gradle 표에 하나씩 붙여 검사실로 보내고 빨간 종료표면 즉시 막는다. | Gradle test와 no-daemon 인자에 selector를 하나씩 붙여 gradlew.bat을 실행하고 native exit가 0이 아니면 실패시킨다.
|
| 6줄F07-L06 | $dir= |
결과 보관함의 모든 XML 봉투를 열어 반 이름과 응시·실패·오류·결석 수를 적는다. | 기존 TEST-*.xml을 모두 읽어 suite class와 tests·failures·errors·skipped 숫자를 행으로 만든다.
|
| 7줄F07-L07 | $classes= |
명단 두 장을 set으로 맞춘 뒤 네 숫자가 초록 기준선을 넘는지 판정한다. | XML class 집합이 selector에서 만든 기대 집합과 같은지 보고, 합계 tests가 5 이상이며 실패·오류·skip이 0인지 검사한다.
|
| 8줄F07-L08 | $line= |
검사 통과 문장을 봉투에 바로 적고 같은 문장을 안내 방송으로 읽는다. | Green marker를 만들고 evidence 경로를 정한 뒤 부모 폴더를 만들고 Set-Content로 marker를 기록·출력한다.
|
| 9줄F07-L09 | }finally{ |
검사 결과와 상관없이 빌려 쓴 작업대를 반납하고 원래 자리로 돌아간다. | 성공과 실패 모두에서 Push-Location 전 위치로 돌아가도록 finally에서 Pop-Location한다.
|
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · root와 위치 이동
-
왜 Push-Location을 먼저 하나요?
-
Gradle과 build 결과의 상대 경로 기준을 한 root로 맞추려는 거야.
-
Pop-Location은 directory만 복구하고 test 산출물이나 evidence를 정리하지는 않아.
-
실패 전후 Get-Location을 비교하면 finally가 위치만 되돌렸는지 분리해 볼 수 있어요.
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · 여섯 selector
-
F06 class는 왜 method 하나만 적었어요?
-
그 selector만 exact method라 TransferIntegrationTest의 나머지 여덟 @Test는 이 smoke가 고르지 않아.
-
선택된 10 staged tests와 실제 XML tests 합계는 source identity가 다르면 같다고 장담할 수 없어.
-
selector 문자열 여섯 개와 expected class 여섯 개를 따로 세고 F06 method suffix를 표시해 둘게요.
STEP 05 / 13
원본 코드 조각
원본을 7개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 hash로 고정한 native 정본 source에서 그대로 잘랐습니다.
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}
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 9줄을 모두 한국어로 옮깁니다.
비어 있지 않은 원본 줄은 하나도 생략하지 않습니다.
| 줄 | 원본 | 한국어 번역 |
|---|---|---|
| 1 | param([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 $root | PowerShell 오류를 즉시 중단하게 하고, override가 있으면 Resolve-Path한 경로를 아니면 script 부모를 root로 삼아 그 위치로 이동한다. |
| 3 | try{ | 어떤 분기에서 끝나도 현재 위치를 되돌리기 위한 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;$line | Green marker를 만들고 evidence 경로를 정한 뒤 부모 폴더를 만들고 Set-Content로 marker를 기록·출력한다. |
| 9 | }finally{Pop-Location} | 성공과 실패 모두에서 Push-Location 전 위치로 돌아가도록 finally에서 Pop-Location한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기여섯 exact selector를 Gradle에 넘긴 뒤 JUnit XML class 집합과 tests·failures·errors·skipped 합계를 검사해 한 줄 evidence를 남긴다.
문법 해부
- 다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.
- XML class 집합과 네 숫자를 읽어 Green인지 판단한다.
- 통과하면 smoke.txt에 marker 한 줄을 쓴다.
실행 순서
- Gradle --tests filter로 전달
- expected set과 exact 비교
- 7줄 조건 평가
- 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
-
Gradle이 실패해도 원래 폴더로 돌아오나요?
-
try 안의 throw 뒤에도 finally가 실행되어 Pop-Location해.
-
PowerShell process가 강제 종료되면 finally 실행 자체는 보장할 수 없어.
-
일반 throw 반례에서는 원래 directory로 복귀하지만 hard-kill은 같은 실험으로 증명되지 않아요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | selector 여섯 개 | Gradle --tests filter로 전달 | 해당 filter 실행을 요청 | F06 전체 9 tests가 아니라 한 method |
| 2 | JUnit XML class 집합 | expected set과 exact 비교 | 여섯 class여야 통과 | 오래된 XML을 먼저 지우지 않음 |
| 3 | tests=5, failures=errors=skipped=0 | 7줄 조건 평가 | 현재 구현은 통과 | PDF tests>=6와 불일치 |
| 4 | Green marker | Set-Content로 target 기록 | UTF-8 한 줄 evidence 생성 | 원자 replace·source SHA 없음 |
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · XML Green 조건
-
tests=5면 실패하는 것 아닌가요?
-
현재 코드는 tests-lt 5만 throw하므로 정확히 5는 통과해.
-
PDF의 tests>=6 문구와 구현 경계가 다르다는 사실을 숨기면 안 돼.
-
tests=5, failures=errors=skipped=0을 대입하면 throw 조건 전체가 거짓인지 직접 계산할 수 있어요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
root와 selector array를 만들고 native Gradle을 호출한다.
Resolve-Path는 override가 있을 때만 실행된다.filtered tests가 TEST-*.xml을 만든다.
default root가 stage-only F01/F02를 compile하는지는 별도 문제다.Set-Content가 marker를 target에 쓴다.
atomic rename과 stale evidence 제거가 없다.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에 나타나지 않는다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
staged source SHA를 증명하지 않는다
이 책임을 맡는 곳: 조립된 learner root/manifest 검증stale 삭제와 원자 replace를 보장하지 않는다
이 책임을 맡는 곳: evidence writer전체 suite Green을 보장하지 않는다
이 책임을 맡는 곳: 별도 full-suite gateW14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · evidence 쓰기
-
Green 파일은 안전하게 교체되나요?
-
부모 폴더를 만들고 Set-Content로 한 줄을 바로 써.
-
stale 파일 사전 삭제와 tmp-rename 원자 교체가 없어서 durability seal은 아니야.
-
target 쓰기 직전 crash와 이전 smoke.txt가 남은 경우를 separate counterexample로 기록할게요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 여섯 selector 중 하나는 왜 method 하나만 고르나?
- XML에서 어떤 숫자가 모두 Green이어야 하나?
- 이 marker가 staged source 실행까지 증명하지 못하는 이유는 무엇인가?
2단계 · 코드 조각 재조립
- 다섯 class와 F06의 한 method 이름을 --tests로 넘긴다.
- XML class 집합과 네 숫자를 읽어 Green인지 판단한다.
- 통과하면 smoke.txt에 marker 한 줄을 쓴다.
3단계 · 파일 전체 다시 쓰기
scripts/run-w14-smoke.ps1 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture·selector 숫자를 정확히 적었는가
- 보장하지 않는 범위를 함께 적었는가
W14 smoke 실행기 — 여섯 selector와 XML Green 조건을 정확히 읽기 · source identity
-
class 이름이 맞으면 staged byte도 맞나요?
-
아니야. runner는 class 집합만 보고 source SHA를 기록하지 않아.
-
F01/F02는 default root에 active class가 없고 F03 root에는 같은 FQCN의 다른 source가 있어.
-
XML class 이름 옆에 source SHA 필드가 없음을 확인해야 staged byte 증거가 아니라는 결론이 정확해요.
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
정본 전체 코드 확인하기
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}
08Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기
illustrative/sql/W14-SQL-Q19.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F0823줄 연결23줄 번역4 chunks
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기
illustrative/sql/W14-SQL-Q19.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F08STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
모든 current business_tx를 보존하면서 original_tx_id로 같은 table을 LEFT JOIN해 reversal과 원거래 정보를 나란히 보여 준다.
- 왜 business_tx를 자기 자신과 LEFT JOIN하나?
- 왜 ordinary transaction 19행도 결과에 남나?
- BROKEN_REFERENCE가 fixture에서 나오지 않는 이유는 무엇인가?
fixture rows=21REQ-211/REQ-212 -> REQ-201linked=2no-original=19Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · current grain
-
왜 reversal 두 행만 시작하지 않나요?
-
Q19 예시는 모든 current transaction을 한 행씩 보여 주려는 표라 21행 전체에서 시작해.
-
reversal-only 보고서라면 별도 WHERE가 필요해.
-
결과에서 LINKED 2행과 NO_ORIGINAL 19행을 더해 current grain 21행이 보존되는지 볼게요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
현재 영수증과 원본 영수증 연결판
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기
히토리는 현재 거래 영수증 21장을 왼쪽 판에 모두 놓고, original_tx_id가 있는 두 장만 원본 영수증과 끈으로 연결한다.
키타는 연결 없는 19장은 NO_ORIGINAL로 남기고, FK가 살아 있는 이 fixture에서는 BROKEN_REFERENCE가 나오지 않는다고 확인한다.
딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.
STEP 03 / 13
초등학생도 이해하는 설명
생활 비유와 실제 코드의 경계를 함께 확인합니다.
현재 거래 모두 놓기
current_tx 21행을 기준으로 시작한다.
- 코드 연결
21줄- 비유
- 현재 영수증과 원본 영수증 연결판에서 현재 거래 모두 놓기 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
원거래 끈 연결
original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.
- 코드 연결
22~23줄- 비유
- 현재 영수증과 원본 영수증 연결판에서 원거래 끈 연결 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
연결 상태 이름 붙이기
NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.
- 코드 연결
16~20줄- 비유
- 현재 영수증과 원본 영수증 연결판에서 연결 상태 이름 붙이기 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 23줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F08-L01 | -- W14-SQL-Q19 illustrative example, |
연결표 맨 위에 ‘연습용, 공식 정답 아님’ 도장을 찍는다. | Q19를 위해 만든 학습용 SQL이며 workbook이 배포한 정본 답안이 아님을 밝힌다.
|
| 2줄F08-L02 | -- Input grain: |
왼쪽 영수증 한 장이 완성 표 한 줄이라는 좌석 규칙을 적는다. | 입력은 business_tx 한 행, 출력은 현재 transaction 한 행이라는 grain을 선언한다.
|
| 3줄F08-L03 | -- LEFT JOIN keeps ordinary transactions whose original_tx_id is NULL. |
연결 끈이 없는 보통 영수증도 전시판에서 빼지 않는다고 표시한다. | original_tx_id가 NULL인 보통 transaction도 LEFT JOIN으로 보존한다고 설명한다.
|
| 4줄F08-L04 | -- Supplied fixture oracle: |
정답 대조표에 전체 스물한 장과 두 연결·열아홉 미연결을 써 둔다. | fixture 기대값을 전체 21행, 연결 reversal 2행, 원거래 없는 19행으로 고정한다.
|
| 5줄F08-L05 | SET search_path TO : |
오늘 사용할 격리 서랍을 먼저 열고 공용 서랍은 보조로 둔다. | psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
|
| 7줄F08-L07 | SELECT |
현재 거래와 원거래 정보를 나란히 놓을 빈 결과판을 펼친다. | 현재 transaction과 원거래 정보를 한 행에 내보낼 SELECT를 시작한다.
|
| 8줄F08-L08 | current_tx. |
현재 영수증의 내부 일련번호를 결과판 첫 식별표로 옮긴다. | 현재 transaction의 tx_id를 current_tx_id로 출력한다.
|
| 9줄F08-L09 | current_tx. |
기계 ID 옆에 REQ로 시작하는 현재 요청 이름표를 붙인다. | 현재 transaction의 request_id를 current_request_id로 출력한다.
|
| 10줄F08-L10 | current_tx. |
현재 영수증이 입금인지 취소인지 종류 스티커를 보인다. | 현재 transaction의 tx_type을 current_type으로 출력한다.
|
| 11줄F08-L11 | current_tx. |
현재 거래에 적힌 금액표를 가공하지 않고 옆 칸에 둔다. | 현재 transaction의 amount를 current_amount로 출력한다.
|
| 12줄F08-L12 | current_tx. |
연결 끈 끝에 적힌 원본 UUID를 그대로 공개한다. | 현재 transaction에 기록된 original_tx_id를 그대로 출력한다.
|
| 13줄F08-L13 | original_tx. |
연결된 과거 영수증에서 사람이 읽을 REQ 이름을 가져온다. | self join으로 찾은 원거래의 request_id를 original_request_id로 출력한다.
|
| 14줄F08-L14 | original_tx. |
과거 영수증의 거래 종류표를 현재 종류표 옆에 놓는다. | 원거래의 tx_type을 original_type으로 출력한다.
|
| 15줄F08-L15 | original_tx. |
과거 거래 금액표까지 가져와 현재 금액과 나란히 둔다. | 원거래의 amount를 original_amount로 출력한다.
|
| 16줄F08-L16 | CASE |
연결 상태를 세 칸 중 하나로 보내는 분류판을 연다. | 원거래 연결 상태 문자열을 고를 CASE를 시작한다.
|
| 17줄F08-L17 | WHEN current_tx. |
처음부터 연결 번호가 없는 영수증을 NO_ORIGINAL 바구니로 보낸다. | 현재 행의 original_tx_id가 NULL이면 NO_ORIGINAL로 분류한다.
|
| 18줄F08-L18 | WHEN original_tx. |
번호표는 있는데 맞는 과거 영수증이 없으면 깨진 끈 상자에 넣는다. | ID는 있는데 join한 원거래가 없으면 BROKEN_REFERENCE로 분류한다.
|
| 19줄F08-L19 | ELSE 'LINKED' |
연결 번호와 과거 영수증이 모두 있으면 LINKED 도장을 찍는다. | 앞 두 조건이 아니면 실제 원거래가 연결된 LINKED로 분류한다.
|
| 20줄F08-L20 | END AS link_state |
분류판을 닫아 세 문자열을 link_state 한 칸으로 모은다. | CASE를 닫고 계산 열 이름을 link_state로 정한다.
|
| 21줄F08-L21 | FROM business_tx AS current_tx |
전시판 왼쪽에 현재 거래 스물한 장을 먼저 전부 펼친다. | 모든 현재 transaction을 보존할 왼쪽 relation을 business_tx current_tx로 정한다.
|
| 22줄F08-L22 | LEFT JOIN business_tx AS original_tx |
같은 거래 보관함을 원본 역할로 한 번 더 열되 연결 실패 자리도 비워 둔다. | 같은 business_tx table을 original_tx 별칭으로 LEFT JOIN한다.
|
| 23줄F08-L23 | ON original_tx. |
현재 끈의 UUID와 과거 영수증 UUID가 같은 경우만 매듭짓는다. | 원거래 tx_id와 현재 행의 original_tx_id가 같은 행을 연결한다.
|
| 24줄F08-L24 | ORDER BY current_tx. |
발생 시각 순으로 놓고 동시각이면 UUID를 두 번째 정렬표로 쓴다. | 현재 transaction의 occurred_at과 tx_id 오름차순으로 결과를 결정적으로 정렬한다.
|
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · self LEFT JOIN
-
같은 table을 두 번 쓰는 이유가 뭐예요?
-
current_tx는 지금 행, original_tx는 original_tx_id가 가리킨 과거 행 역할을 맡아.
-
별칭은 역할만 나누며 원거래가 실제로 유효한 reversal인지 판정하지 않아.
-
REQ-211 current row와 REQ-201 original row의 tx_id equality를 한 쌍으로 추적하면 역할 차이가 보여요.
STEP 05 / 13
원본 코드 조각
원본을 4개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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;
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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행으로 고정한다. |
| 5 | SET search_path TO :"workbook_schema", public; | psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다. |
| 7 | SELECT | 현재 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_state | CASE를 닫고 계산 열 이름을 link_state로 정한다. |
| 21 | FROM business_tx AS current_tx | 모든 현재 transaction을 보존할 왼쪽 relation을 business_tx current_tx로 정한다. |
| 22 | LEFT 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가 같은 행을 연결한다. |
| 24 | ORDER BY current_tx.occurred_at, current_tx.tx_id; | 현재 transaction의 occurred_at과 tx_id 오름차순으로 결과를 결정적으로 정렬한다. |
STEP 07 / 13
기존 수준의 한 줄 읽기·문법 해부
쉬운 설명 다음에 문법과 실행 순서를 정밀하게 읽습니다.
한 줄로 읽기모든 current business_tx를 보존하면서 original_tx_id로 같은 table을 LEFT JOIN해 reversal과 원거래 정보를 나란히 보여 준다.
문법 해부
- current_tx 21행을 기준으로 시작한다.
- original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.
- NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.
실행 순서
- original_tx_id로 REQ-201을 찾음
- 같은 REQ-201을 찾음
- original_tx_id NULL
- 없는 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 상태
-
original_tx_id가 NULL이면 깨진 참조인가요?
-
아니야. 애초에 원거래가 필요 없는 보통 transaction이므로 NO_ORIGINAL이야.
-
ID가 있는데 대상 행이 없을 때만 BROKEN_REFERENCE branch로 가.
-
NULL id와 non-NULL id/NULL joined row 두 입력을 CASE에 각각 넣어 다른 label인지 확인할게요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | REQ-211 reversal | original_tx_id로 REQ-201을 찾음 | LINKED와 original columns 생성 | 원거래 정책의 정당성은 미검증 |
| 2 | REQ-212 reversal | 같은 REQ-201을 찾음 | 두 번째 LINKED 행 생성 | 두 reversal의 업무 허용 여부는 미검증 |
| 3 | ordinary tx 19행 | original_tx_id NULL | NO_ORIGINAL로 그대로 보존 | reversal-only 보고서가 아님 |
| 4 | fixture FK | 없는 tx_id 참조를 막음 | BROKEN_REFERENCE 0행 기대 | FK가 없거나 훼손된 데이터는 다를 수 있음 |
Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · fixture 두 연결
-
어떤 요청이 원거래와 연결돼요?
-
REQ-211과 REQ-212가 모두 REQ-201 transaction을 가리켜 LINKED 두 행이 돼.
-
나머지 19행은 삭제되지 않고 NO_ORIGINAL로 남아.
-
두 linked row의 original_request_id가 모두 REQ-201인지 결과표에서 대조하면 돼요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
business_tx를 current/original 두 별칭으로 읽어 UUID equality로 연결한다.
planner 선택과 성능은 이 예시가 측정하지 않는다.21 current rows 중 두 행만 REQ-201을 가리킨다.
운영 데이터 cardinality로 일반화하지 않는다.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라면 달라진다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
workbook 정본 답안을 보장하지 않는다
이 책임을 맡는 곳: 학습 과제 계약어떤 reversal이 유효한지 판정하지 않는다
이 책임을 맡는 곳: 도메인 규칙reversal만 필터링한 보고서를 만들지 않는다
이 책임을 맡는 곳: 후속 WHERE 정책Q19 학습용 SQL — reversal과 원거래를 self LEFT JOIN으로 한 행에 놓기 · BROKEN_REFERENCE
-
왜 현재 결과에는 깨진 참조가 없나요?
-
제공 fixture와 FK가 없는 원거래 ID를 허용하지 않기 때문이야.
-
손상 데이터를 일부러 만들거나 FK가 없으면 결과가 달라질 수 있어.
-
현재 oracle에서는 BROKEN_REFERENCE count가 0이라는 fixture-bound 기대만 적고 보편 결론은 빼 둘게요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 business_tx를 자기 자신과 LEFT JOIN하나?
- 왜 ordinary transaction 19행도 결과에 남나?
- BROKEN_REFERENCE가 fixture에서 나오지 않는 이유는 무엇인가?
2단계 · 코드 조각 재조립
- current_tx 21행을 기준으로 시작한다.
- original_tx_id와 원거래 tx_id가 같을 때 같은 table의 행을 붙인다.
- NULL·깨진 참조·정상 연결을 세 문자열로 구분한다.
3단계 · 파일 전체 다시 쓰기
illustrative/sql/W14-SQL-Q19.sql 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture·selector 숫자를 정확히 적었는가
- 보장하지 않는 범위를 함께 적었는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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;
09Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기
illustrative/sql/W14-SQL-Q20.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F0934줄 연결34줄 번역5 chunks
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기
illustrative/sql/W14-SQL-Q20.sql
fixture 기반 학습용 SQL 예시 · 정본 답안 아님 · 학습용 예시 · 정본 답안 아님 · W14-F09STEP 01 / 13
오늘 이 코드에서 해결할 문제
무엇을 이해해야 하는지 질문부터 잡습니다.
business_tx를 기준으로 ledger를 LEFT JOIN해 0건 transaction까지 세고, 명시한 fixture 정책의 기대 개수와 실제 개수를 비교한다.
- 왜 COUNT(*) 대신 COUNT(ledger.entry_id)를 쓰나?
- 왜 business_tx에서 LEFT JOIN하나?
- cardinality_ok가 금액 보존까지 증명하지 못하는 이유는 무엇인가?
fixture tx rows=21success transfer expected=2other supported success expected=1non-success expected=0REQ-302 actual=1 expected=2Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · 입력과 출력 grain
-
ledger가 두 장이면 transaction도 두 행으로 나오나요?
-
join 중에는 늘어나지만 tx별 GROUP BY가 최종 한 행으로 다시 묶어.
-
GROUP BY 열을 빠뜨리면 SQL 오류나 다른 grain이 생길 수 있어.
-
join 중간 row와 counted 최종 row를 나눠 세면 21 transaction grain을 확인할 수 있어요.
STEP 02 / 13
아주 짧게: 이 코드는 왜 필요할까?
웹소설 대신 이 코드가 필요한 이유만 두 문단으로 쉽게 봅니다.
거래별 원장 장수 세기 표
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기
니지카는 거래 21개마다 원장 종이를 붙이고, 종이가 없는 거래도 빈 칸째 남겨 실제 장수를 센다.
료는 성공 이체 2장·다른 지원 성공 1장·그 밖 0장이라는 규칙이 fixture용이며, REQ-302 하나만 1 대 2로 어긋난다고 표시한다.
딱 여기까지만 이 장면은 코드 목적만 쉽게 보여 주며 실제 source와 fixture 경계가 최종 기준이다.
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줄- 비유
- 거래별 원장 장수 세기 표에서 기대와 비교 순서만 본다.
- 비유의 끝
- 이 쉬운 비유는 코드의 입력·결과·한계를 대신하지 않는다.
STEP 04 / 13
비유 ↔ 코드 전체 연결표
감사 규칙상 연결 대상인 원본 34줄을 빠짐없이 연결합니다.
| 줄 | 정확한 원본 줄 | STARRY 비유 | 실제 뜻·입력·결과·한계 |
|---|---|---|---|
| 1줄F09-L01 | -- W14-SQL-Q20 illustrative example, |
원장 장수 점검표에 ‘fixture 연습본’ 표찰을 붙인다. | Q20을 위해 만든 학습용 SQL이며 workbook 정본 답안이 아님을 밝힌다.
|
| 2줄F09-L02 | -- Input grain: |
거래 한 장에 원장 종이가 여러 장 붙을 수 있는 입력 묶음을 그린다. | 입력은 business_tx와 0개 이상의 ledger_entry를 join한 행이라고 선언한다.
|
| 3줄F09-L03 | -- Output grain: |
빈 원장 칸이 있어도 거래별 결과표 한 줄을 남긴다고 약속한다. | 출력은 ledger가 0건인 transaction도 포함해 transaction당 한 행이라고 선언한다.
|
| 4줄F09-L04 | -- The expected count below is an explicit fixture-bound teaching policy, |
2장·1장·0장 기대표에 ‘이 연습 데이터 전용’ 스티커를 붙인다. | 아래 기대 개수는 보편 원장 법칙이 아니라 fixture 전용 교육 정책이라고 제한한다.
|
| 5줄F09-L05 | SET search_path TO : |
원장과 거래를 찾을 격리 workbook 서랍을 검색 순서 맨 앞에 둔다. | psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다.
|
| 7줄F09-L07 | WITH counted AS ( |
실제 장수와 기대 장수를 계산할 중간 계산대 counted를 연다. | transaction별 실제·기대 원장 수를 만들 counted CTE를 시작한다.
|
| 8줄F09-L08 | SELECT |
중간 계산대에 거래 정보와 두 숫자를 놓을 선택판을 펼친다. | counted CTE의 SELECT 목록을 시작한다.
|
| 9줄F09-L09 | tx. |
각 계산 묶음에 변하지 않는 transaction UUID 꼬리표를 붙인다. | 그룹 식별자이자 뒤 출력용인 tx_id를 고른다.
|
| 10줄F09-L10 | tx. |
사람이 REQ-302를 바로 찾도록 요청 번호표도 남긴다. | 사람이 fixture 행을 찾기 쉬운 request_id를 고른다.
|
| 11줄F09-L11 | tx. |
기대 장수표의 이체·개설·입출금 분기를 고를 종류 카드를 놓는다. | 기대 원장 수 정책에 쓸 tx_type을 고른다.
|
| 12줄F09-L12 | tx. |
거래 종류 카드 옆에 SUCCESS인지 알려 주는 상태 신호를 둔다. | 기대 원장 수 정책에 쓸 status를 고른다.
|
| 13줄F09-L13 | COUNT( |
붙어 있는 원장 종이 중 진짜 entry_id가 있는 장만 계수기에 넣는다. | NULL placeholder를 빼고 실제 ledger.entry_id 개수를 actual_ledger_count로 센다.
|
| 14줄F09-L14 | CASE |
거래마다 2·1·0 기대 숫자를 고르는 규칙판을 연다. | fixture 정책의 expected_ledger_count를 고를 CASE를 시작한다.
|
| 15줄F09-L15 | WHEN tx. |
성공 이체 카드는 양쪽 posting을 뜻하는 ‘2’ 칸으로 보낸다. | SUCCESS TRANSFER라면 기대 원장 수를 2로 정한다.
|
| 16줄F09-L16 | WHEN tx. |
첫 조건을 통과하지 못한 성공 거래용 두 번째 검사문을 펼친다. | 그 밖의 SUCCESS 종류를 검사하는 두 번째 WHEN을 시작한다.
|
| 17줄F09-L17 | AND tx. |
개설·입금·출금·취소 성공 카드를 ‘1’ 칸으로 모은다. | SUCCESS인 OPENING·DEPOSIT·WITHDRAW·REVERSAL이면 기대 원장 수를 1로 정한다.
|
| 18줄F09-L18 | ELSE 0 |
성공 조건 밖의 카드는 나머지 바구니 ‘0’으로 보낸다. | 앞 조건 밖의 transaction은 기대 원장 수를 0으로 정한다.
|
| 19줄F09-L19 | END AS expected_ledger_count |
규칙판을 닫아 고른 숫자를 expected_ledger_count 이름표로 내보낸다. | CASE를 닫고 계산 열 이름을 expected_ledger_count로 정한다.
|
| 20줄F09-L20 | FROM business_tx AS tx |
거래 명부 스물한 장을 장수 점검의 기준 목록으로 먼저 펼친다. | 전체 transaction을 보존할 기준 relation을 business_tx tx로 정한다.
|
| 21줄F09-L21 | LEFT JOIN ledger_entry AS ledger |
원장 종이를 왼쪽 방식으로 붙여 빈 거래에도 빈 자리 하나를 남긴다. | ledger가 없는 transaction도 남기도록 ledger_entry를 LEFT JOIN한다.
|
| 22줄F09-L22 | ON ledger. |
거래 UUID와 원장에 적힌 tx UUID가 같은 종이만 해당 묶음에 붙인다. | ledger.tx_id와 transaction tx_id가 같은 원장 행을 붙인다.
|
| 23줄F09-L23 | GROUP BY tx. |
늘어난 종이를 거래의 네 표찰별로 묶어 계산대 한 칸으로 접는다. | transaction 식별·표시·정책 열로 묶어 transaction당 한 집계 group을 만든다.
|
| 24줄F09-L24 | ) |
거래별 실제·기대 장수 계산을 마치고 counted 계산대를 닫는다. | counted CTE를 닫는다.
|
| 25줄F09-L25 | SELECT |
중간 계산표에서 최종 검토표에 옮길 일곱 칸을 새로 연다. | counted 결과를 보여 줄 최종 SELECT를 시작한다.
|
| 26줄F09-L26 | tx_id, |
검토표 첫 칸에 transaction UUID를 옮겨 원본 행을 추적하게 한다. | 최종 결과에 tx_id를 출력한다.
|
| 27줄F09-L27 | request_id, |
기계 UUID 옆에 REQ 요청 이름을 붙여 fixture 대조를 쉽게 만든다. | 최종 결과에 request_id를 출력한다.
|
| 28줄F09-L28 | tx_type, |
각 결과줄에 TRANSFER 같은 거래 종류 카드를 다시 보여 준다. | 최종 결과에 tx_type을 출력한다.
|
| 29줄F09-L29 | status, |
종류 옆에 SUCCESS·FAILED 상태표를 함께 표시한다. | 최종 결과에 status를 출력한다.
|
| 30줄F09-L30 | actual_ledger_count, |
계수기가 센 실제 원장 장수를 검토표에 적는다. | 최종 결과에 실제 원장 수를 출력한다.
|
| 31줄F09-L31 | expected_ledger_count, |
fixture 규칙판이 고른 기대 장수를 실제 숫자 옆에 놓는다. | 최종 결과에 fixture 정책상 기대 원장 수를 출력한다.
|
| 32줄F09-L32 | actual_ledger_count = |
두 장수표가 같으면 초록, 다르면 빨강인 Boolean 램프를 켠다. | 실제 수와 기대 수가 같은지 Boolean cardinality_ok로 계산한다.
|
| 33줄F09-L33 | FROM counted |
완성된 counted 계산표를 최종 검토표의 유일한 공급원으로 삼는다. | 최종 출력의 입력 relation을 counted CTE로 정한다.
|
| 34줄F09-L34 | ORDER BY request_id; |
REQ 이름표 순서로 줄을 세워 반복 대조가 흔들리지 않게 한다. | request_id 오름차순으로 결과를 결정적으로 정렬한다.
|
| 35줄F09-L35 | -- Supplied fixture oracle: |
검토표 아래에 REQ-302 하나가 빨간 칸이라는 정답 메모를 단다. | fixture 21행 중 REQ-302만 actual 1, expected 2인 mismatch라고 oracle을 기록한다.
|
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · COUNT 열 선택
-
왜 COUNT(*)를 쓰지 않나요?
-
LEFT JOIN의 NULL placeholder를 원장으로 세지 않으려고 non-NULL entry_id만 세는 거야.
-
COUNT(*)는 ledger 0건 transaction도 join row 하나로 세어 1이 될 수 있어.
-
ledger 없는 tx 하나에서 COUNT(*)=1과 COUNT(entry_id)=0을 나란히 계산해 볼게요.
STEP 05 / 13
원본 코드 조각
원본을 5개 의미 조각으로 나누어 그대로 확인합니다.
파일을 한꺼번에 외우지 않고 실행 의미가 이어지는 작은 조각으로 봅니다. 아래 코드는 prompt·fixture·가정을 밝힌 학습용 예시 source이며, 제공 정본 답안이 아닙니다.
-- 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).
STEP 06 / 13
코드 한 줄씩 한국어로 번역
비어 있지 않은 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 전용 교육 정책이라고 제한한다. |
| 5 | SET search_path TO :"workbook_schema", public; | psql의 workbook_schema를 첫 search_path로, public을 다음 경로로 둔다. |
| 7 | WITH counted AS ( | transaction별 실제·기대 원장 수를 만들 counted CTE를 시작한다. |
| 8 | SELECT | counted 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 | CASE | fixture 정책의 expected_ledger_count를 고를 CASE를 시작한다. |
| 15 | WHEN tx.status = 'SUCCESS' AND tx.tx_type = 'TRANSFER' THEN 2 | SUCCESS TRANSFER라면 기대 원장 수를 2로 정한다. |
| 16 | WHEN tx.status = 'SUCCESS' | 그 밖의 SUCCESS 종류를 검사하는 두 번째 WHEN을 시작한다. |
| 17 | AND tx.tx_type IN ('OPENING', 'DEPOSIT', 'WITHDRAW', 'REVERSAL') THEN 1 | SUCCESS인 OPENING·DEPOSIT·WITHDRAW·REVERSAL이면 기대 원장 수를 1로 정한다. |
| 18 | ELSE 0 | 앞 조건 밖의 transaction은 기대 원장 수를 0으로 정한다. |
| 19 | END AS expected_ledger_count | CASE를 닫고 계산 열 이름을 expected_ledger_count로 정한다. |
| 20 | FROM business_tx AS tx | 전체 transaction을 보존할 기준 relation을 business_tx tx로 정한다. |
| 21 | LEFT JOIN ledger_entry AS ledger | ledger가 없는 transaction도 남기도록 ledger_entry를 LEFT JOIN한다. |
| 22 | ON ledger.tx_id = tx.tx_id | ledger.tx_id와 transaction tx_id가 같은 원장 행을 붙인다. |
| 23 | GROUP BY tx.tx_id, tx.request_id, tx.tx_type, tx.status | transaction 식별·표시·정책 열로 묶어 transaction당 한 집계 group을 만든다. |
| 24 | ) | counted CTE를 닫는다. |
| 25 | SELECT | counted 결과를 보여 줄 최종 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로 계산한다. |
| 33 | FROM counted | 최종 출력의 입력 relation을 counted CTE로 정한다. |
| 34 | ORDER 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을 기록한다. |
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으로 비교한다.
실행 순서
- CASE가 expected=2 선택
- LEFT JOIN NULL placeholder 보존
- actual 1과 expected 2 비교
- 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
-
성공 이체는 언제나 원장 두 장인가요?
-
이 SQL에서는 제공 fixture를 가르치기 위한 기대값 2로 정했어.
-
운영 posting model의 보편 규칙이라고 일반화하면 안 돼.
-
SUCCESS TRANSFER 입력을 넣어 expected=2가 되는 것은 확인하되 source 주석의 fixture 표찰도 함께 남겨요.
STEP 08 / 13
실제 값 따라가기
같은 입력값이 어느 줄을 지나 어떤 결과가 되는지 추적합니다.
| 순서 | 들어온 값 | 코드가 하는 일 | 나온 값·상태 | 경계 |
|---|---|---|---|---|
| 1 | SUCCESS TRANSFER | CASE가 expected=2 선택 | 두 ledger면 true | 모든 금융 시스템의 보편 규칙 아님 |
| 2 | ledger 0건 transaction | LEFT JOIN NULL placeholder 보존 | COUNT(entry_id)=0 | COUNT(*)면 1로 잘못 셀 수 있음 |
| 3 | REQ-302 | actual 1과 expected 2 비교 | cardinality_ok=false | 금액·방향 mismatch 원인은 미판정 |
| 4 | fixture transaction 21개 | tx별 GROUP BY | 최종 21행 | 운영 cardinality가 아님 |
Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · REQ-302 mismatch
-
실제 fixture에서 어디가 어긋나요?
-
REQ-302는 actual_ledger_count 1, expected_ledger_count 2라 false야.
-
이 비교만으로 빠진 원장의 금액이나 방향은 알 수 없어.
-
REQ-302 결과의 1·2·false 세 값을 한 행에서 대조하면 oracle과 정확히 맞아요.
STEP 09 / 13
PowerShell·SQL·DB 내부에서 벌어지는 일
PowerShell·SQL·DB에서 실제로 일어나는 일과 증명 범위를 구분합니다.
LEFT JOIN 뒤 tx별 group에서 non-NULL entry_id만 센다.
중복 원장 원인과 금액은 COUNT가 설명하지 않는다.21개 tx 중 REQ-302만 actual 1/expected 2다.
새 fixture가 들어오면 oracle도 다시 계산해야 한다.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행일 수 있다
STEP 11 / 13
이 코드가 보장하지 않는 것
이 코드가 책임지지 않는 일을 분리합니다.
workbook 정본 답안을 보장하지 않는다
이 책임을 맡는 곳: 학습 과제 계약금액 보존과 차변·대변 균형을 보장하지 않는다
이 책임을 맡는 곳: 별도 ledger invariant2·1·0 기대 수를 운영 규칙으로 보장하지 않는다
이 책임을 맡는 곳: 도메인 posting 정책Q20 학습용 SQL — transaction별 실제 원장 수와 fixture 기대 수 비교하기 · true의 한계
-
cardinality_ok가 true면 안심해도 되나요?
-
행 수가 fixture 기대와 같다는 한 가지 사실만 맞아.
-
금액 보존·계정 방향·status 정합성은 별도 검사로 증명해야 해.
-
원장 두 장이 모두 잘못된 금액이어도 count 2는 true라는 반례를 self-check에 둘게요.
STEP 12 / 13
직접 다시 써보기
뜻 → 조각 → 전체 코드 순서로 다시 씁니다.
1단계 · 뜻부터 복원
- 왜 COUNT(*) 대신 COUNT(ledger.entry_id)를 쓰나?
- 왜 business_tx에서 LEFT JOIN하나?
- cardinality_ok가 금액 보존까지 증명하지 못하는 이유는 무엇인가?
2단계 · 코드 조각 재조립
- business_tx에서 LEFT JOIN해 ledger 0건도 결과에 남긴다.
- NULL placeholder를 빼는 COUNT(entry_id)로 0·1·2를 센다.
- fixture CASE의 2·1·0과 실제 수를 Boolean으로 비교한다.
3단계 · 파일 전체 다시 쓰기
illustrative/sql/W14-SQL-Q20.sql 전체를 원본과 같은 순서로 다시 쓰고 truth trap을 한 줄 덧붙인다.
자가 점검
- 원본 줄을 바꾸지 않았는가
- fixture·selector 숫자를 정확히 적었는가
- 보장하지 않는 범위를 함께 적었는가
STEP 13 / 13
전체 원본 정답
감사로 고정한 전체 source를 가감 없이 확인합니다.
학습용 예시 전체 확인하기 · 정본 답안 아님
-- 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).