WEEK 12 · CODE AFTERPARTY
W12 코드 뒤풀이 · 같은 요청을 한 번만 바꾸는 법
학습 범위: PDF p362 overview부터 p415 토요일 끝까지 · exact source 16파일 · 고유 @Test 18개 · SQL 과제 0개
먼저 잡는 source 경계와 실행 지도
이번 주에 완성되는 production 정본 4파일, selector가 직접 실행하는 test body 7파일, 그 실행을 잇는 staged support 4파일, 화요일이 명시적으로 읽는 W6D1 V001 정본 1파일을 합쳐 16파일을 싣는다. starter와 일요일 source는 정답에 넣지 않는다.
final solution · 4파일
이번 주 production 정본
RequestHasher · IdempotencyStore · TransferService · TransferController
direct selector body · 7파일
월∼토가 실제 실행
고유 @Test 18개를 가진 test class 7개
staged support · 4파일
오류·hook·response 연결
ErrorCode · handler · hook · response
carried canonical · 1파일
화요일 explicit target
W6D1 solution V001__common.sql
| 요일 | exact selector | Green @Test | Red 추가 |
|---|---|---|---|
| 월 | RequestHasherTest | 2 | 2 |
| 화 | IdempotencySchemaIT | 1 | 0 |
| 수 | AtomicClaim50IT | 2 | 2 |
| 목 | TransferIdempotencyIT + TransferFailurePointIT | 1 + 2 | 1 + 2 |
| 금 | TransferIntegrationTest | 9 | 0 |
| 토 | TransferControllerReplayTest | 1 | 1 |
월 · semantic hash 계약
1. RequestHasher
한 문장 역할: from·to·amount 세 의미 값을 v1 고정 순서 UTF-8 bytes로 만든 뒤 SHA-256 소문자 64자리 hash로 바꾼다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | TransferService와 RequestHasherTest |
| 무엇을 받나 | 양수 fromAccountId, toAccountId, amount |
| 무엇이 바뀌나 | 메모리의 canonical 문자열·digest만 만들고 DB는 바꾸지 않음 |
| 무엇을 돌려주나 | UTF-8 byte[] 또는 64자리 lowercase hex String |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.idempotency;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Component
public final class RequestHasher {
public static final String VERSION = "v1";
public byte[] canonicalBytes(long fromAccountId, long toAccountId, long amount) {
if (fromAccountId <= 0 || toAccountId <= 0 || amount <= 0) {
throw new IllegalArgumentException("hash fields must be positive");
}
String canonical = VERSION
+ "\nfrom=" + Long.toString(fromAccountId)
+ "\nto=" + Long.toString(toAccountId)
+ "\namount=" + Long.toString(amount)
+ "\n";
return canonical.getBytes(StandardCharsets.UTF_8);
}
public String hash(long fromAccountId, long toAccountId, long amount) {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256")
.digest(canonicalBytes(fromAccountId, toAccountId, amount))
);
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is required by the JDK", impossible);
}
}
}
코드 조각 1 · 패키지와 네 가지 해시 도구
package com.example.financialcore.idempotency;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
한 줄 읽기: RequestHasher가 Spring 표시, UTF-8, SHA-256, 16진수 변환에 쓸 타입 이름을 연결한다.
- 문법을 한 줄씩 풀면
- package는 이 타입의 이름 공간을 정하고, 각 import는 뒤에서 쓸 Component·StandardCharsets·MessageDigest·NoSuchAlgorithmException·HexFormat의 전체 이름을 줄인다.
- 실제 값 추적
- 이 아홉 줄을 읽는 시점에는 digest나 byte 배열이 생기지 않는다. 컴파일러가 아래 짧은 타입 이름을 각각의 선언으로 해석할 준비만 한다.
- 정상 예
- 모든 import가 있으면 아래 annotation, UTF-8 변환, SHA-256 호출, 예외 catch, hex 출력이 짧은 이름으로 컴파일된다.
- 반례·경계 예
- HexFormat import만 없애면 아래 HexFormat.of()를 찾지 못해 컴파일이 실패한다. hash 결과가 대문자로 바뀌는 런타임 분기가 아니다.
- 착각 방지
- Component를 import했다고 Spring bean이 만들어지는 것은 아니다. 실제 후보 표시는 다음 구간의 @Component가 맡는다.
- 이 블록이 하지 않는 일
- 입력값을 검증하거나 canonical 문자열을 만들거나 SHA-256을 실행하지 않는다.
- 다음 코드와의 연결
- 다음 구간이 @Component와 final class를 선언하고 VERSION을 v1로 고정한다.
코드 조각 2 · Spring 후보와 canonical 버전
@Component
public final class RequestHasher {
public static final String VERSION = "v1";
한 줄 읽기: RequestHasher를 Spring component 후보로 표시하고 모든 호출이 공유할 버전 문자열을 v1로 둔다.
- 문법을 한 줄씩 풀면
- @Component는 type-level annotation이고, final은 상속을 막으며, public static final VERSION은 클래스 하나에 속한 변경 불가 참조다.
- 실제 값 추적
- component scan이 이 클래스를 발견하면 기본 생성자로 bean을 만들 수 있다. canonical 조립은 instance와 무관하게 VERSION 값 v1을 읽는다.
- 정상 예
- Spring 주입 경로와 직접 new 경로 모두 같은 VERSION v1을 사용한다.
- 반례·경계 예
- @Component를 제거한 채 별도 @Bean도 두지 않으면 자동 주입 후보가 사라진다. 직접 new RequestHasher()까지 금지되는 것은 아니다.
- 착각 방지
- final class는 hash 결과를 불변으로 보장한다는 뜻이 아니다. 단지 RequestHasher의 subclass 생성을 막는다.
- 이 블록이 하지 않는 일
- 아직 from·to·amount를 받지 않고 byte나 digest를 만들지 않는다.
- 다음 코드와의 연결
- canonicalBytes가 세 숫자를 받아 0 이하 입력을 먼저 차단한다.
코드 조각 3 · 세 hash 필드의 양수 guard
public byte[] canonicalBytes(long fromAccountId, long toAccountId, long amount) {
if (fromAccountId <= 0 || toAccountId <= 0 || amount <= 0) {
throw new IllegalArgumentException("hash fields must be positive");
}
한 줄 읽기: fromAccountId·toAccountId·amount 중 하나라도 0 이하이면 canonical text를 만들기 전에 예외로 끝낸다.
- 문법을 한 줄씩 풀면
- 세 long parameter를 받은 뒤 OR 조건으로 각각의 <= 0을 묶고, 참이면 IllegalArgumentException을 즉시 throw한다.
- 실제 값 추적
- 예를 들어 (10, 20, 0)은 세 번째 비교가 true라 hash fields must be positive 예외가 발생하고 아래 문자열 조립은 실행되지 않는다.
- 정상 예
- (10, 20, 3000)은 세 비교가 모두 false여서 다음 canonical 조립 줄로 진행한다.
- 반례·경계 예
- fromAccountId가 -1이면 나머지 두 값이 양수여도 OR 전체가 true다.
- 착각 방지
- 양수 검사는 계좌가 실제 존재하거나 송금이 허용됐음을 확인하지 않는다.
- 이 블록이 하지 않는 일
- actor, idempotency key, 통화, 계좌 소유권은 입력에도 검증에도 포함하지 않는다.
- 다음 코드와의 연결
- 통과한 세 숫자를 VERSION·고정 field 이름·newline 순서로 문자열에 붙인다.
코드 조각 4 · 고정 순서 canonical UTF-8 bytes
String canonical = VERSION
+ "\nfrom=" + Long.toString(fromAccountId)
+ "\nto=" + Long.toString(toAccountId)
+ "\namount=" + Long.toString(amount)
+ "\n";
return canonical.getBytes(StandardCharsets.UTF_8);
}
한 줄 읽기: v1, from, to, amount를 정해진 줄 순서와 마지막 newline까지 붙여 UTF-8 byte 배열로 돌려준다.
- 문법을 한 줄씩 풀면
- 문자열 연결식은 Long.toString으로 세 long을 10진 표기로 바꾸고, getBytes(StandardCharsets.UTF_8)는 플랫폼 기본 charset 대신 UTF-8을 명시한다.
- 실제 값 추적
- 입력 (10,20,3000)은 v1↵from=10↵to=20↵amount=3000↵ 한 문자열이 된 뒤 그 정확한 문자의 UTF-8 bytes로 바뀐다.
- 정상 예
- 같은 세 숫자와 VERSION이면 실행 환경의 기본 문자셋과 관계없이 같은 byte sequence를 얻는다.
- 반례·경계 예
- 마지막 newline 하나를 빼거나 from과 to 줄을 바꾸면 사람이 같은 숫자로 보더라도 digest 입력 bytes가 달라진다.
- 착각 방지
- 문자열 연결은 JSON 정규화가 아니다. 여기서 선언한 네 줄짜리 전용 표현만 만든다.
- 이 블록이 하지 않는 일
- 이 구간 자체는 SHA-256을 호출하지 않고 hash 문자열도 만들지 않는다.
- 다음 코드와의 연결
- hash 메서드가 이 byte 배열을 SHA-256에 넣고 lowercase hex로 표현한다.
코드 조각 5 · SHA-256 digest를 lowercase hex로
public String hash(long fromAccountId, long toAccountId, long amount) {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256")
.digest(canonicalBytes(fromAccountId, toAccountId, amount))
);
한 줄 읽기: canonicalBytes 결과를 SHA-256으로 압축하고 각 byte를 두 자리 16진수로 바꿔 문자열을 반환한다.
- 문법을 한 줄씩 풀면
- MessageDigest.getInstance가 SHA-256 구현을 구하고 digest가 byte[]를 처리하며, HexFormat.of().formatHex가 digest bytes를 소문자 hex로 직렬화한다.
- 실제 값 추적
- canonicalBytes가 낸 입력은 SHA-256의 32byte 결과가 되고, formatHex를 거치면 64개의 [0-9a-f] 문자로 돌아온다.
- 정상 예
- 같은 VERSION과 세 양수 입력은 같은 canonical bytes를 만들므로 이 메서드도 같은 64글자 결과를 낸다.
- 반례·경계 예
- amount가 3000에서 3001로 바뀌면 canonical bytes가 달라져 이 호출이 다른 digest를 계산한다.
- 착각 방지
- 64글자 형식이 원문을 복호화할 수 있다는 뜻은 아니며, 서로 다른 모든 입력의 충돌 불가능성을 증명하지도 않는다.
- 이 블록이 하지 않는 일
- DB에 hash를 저장하거나 idempotency scope·actor·key와 결합하지 않는다.
- 다음 코드와의 연결
- JDK가 요청한 algorithm을 제공하지 못한 경우를 catch 구간이 상태 예외로 바꾼다.
코드 조각 6 · SHA-256 부재를 상태 예외로 변환
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is required by the JDK", impossible);
}
}
}
한 줄 읽기: SHA-256 algorithm lookup이 실패하면 checked 예외를 원인으로 보존한 IllegalStateException을 던진다.
- 문법을 한 줄씩 풀면
- catch는 NoSuchAlgorithmException만 잡고, 새 IllegalStateException의 두 번째 인자로 impossible을 넘겨 cause chain을 유지한다.
- 실제 값 추적
- 정상 JDK에서는 이 분기를 지나지 않는다. provider가 SHA-256을 찾지 못할 때 메시지와 원래 예외가 함께 호출자에게 전달된다.
- 정상 예
- SHA-256을 제공하는 JDK에서는 앞 return이 끝나므로 catch body는 실행되지 않는다.
- 반례·경계 예
- canonicalBytes의 양수 guard가 던진 IllegalArgumentException은 이 catch 타입이 아니어서 그대로 전파된다.
- 착각 방지
- 변수 이름 impossible은 컴파일러가 실패 불가능을 증명했다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- 실패 시 대체 algorithm을 선택하거나 재시도하지 않는다.
- 다음 코드와의 연결
- RequestHasherTest가 canonical 문자열과 hex 모양, field별 변화만 실제 assertion으로 고정한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/idempotency/RequestHasher.java
- 전제조건
- JDK의 UTF-8, SHA-256, HexFormat과 Spring Component가 필요하다.
- 반드시 지킬 계약
- v1 + from/to/amount 고정 순서 + 끝 newline, 세 값 양수, SHA-256 32byte→hex64를 보존한다.
- 추천 입력 순서
- package/import → VERSION → canonicalBytes guard/문자열 → hash try/catch 순서로 쓴다.
- 자기 점검
- 10/20/3000이 정확히 v1\nfrom=10\nto=20\namount=3000\n이고 hash가 [0-9a-f]{64}인지 본다.
- 이번 파일의 범위 밖
- 암호화·원문 복호화·수학적 무충돌·actor/key scope·DB claim은 하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.idempotency;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Component
public final class RequestHasher {
public static final String VERSION = "v1";
public byte[] canonicalBytes(long fromAccountId, long toAccountId, long amount) {
if (fromAccountId <= 0 || toAccountId <= 0 || amount <= 0) {
throw new IllegalArgumentException("hash fields must be positive");
}
String canonical = VERSION
+ "\nfrom=" + Long.toString(fromAccountId)
+ "\nto=" + Long.toString(toAccountId)
+ "\namount=" + Long.toString(amount)
+ "\n";
return canonical.getBytes(StandardCharsets.UTF_8);
}
public String hash(long fromAccountId, long toAccountId, long amount) {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256")
.digest(canonicalBytes(fromAccountId, toAccountId, amount))
);
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is required by the JDK", impossible);
}
}
}
2. RequestHasherTest
한 문장 역할: canonical text와 hex 모양, from/to/amount 각각 1 증가가 hash를 바꾸는지를 두 @Test로 고정한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 월요일 exact selector |
| 무엇을 받나 | RequestHasher와 10/20/3000 기준값 |
| 무엇이 바뀌나 | 테스트 메모리 값만 만들며 DB·Spring context 없음 |
| 무엇을 돌려주나 | @Test2의 assertion Green/Red |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.idempotency;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class RequestHasherTest {
private final RequestHasher hasher = new RequestHasher();
@Test
void canonicalContractIsVersionedFixedOrderUtf8Decimal() {
assertThat(new String(hasher.canonicalBytes(10, 20, 3000), StandardCharsets.UTF_8))
.as("W12D1_RED_EXPECTED_VERSIONED_CANONICAL_HASH")
.isEqualTo("v1\nfrom=10\nto=20\namount=3000\n");
assertThat(hasher.hash(10, 20, 3000)).hasSize(64).matches("[0-9a-f]{64}");
}
@Test
void eachSemanticFieldChangesTheHash() {
String canonical = hasher.hash(10, 20, 3000);
assertThat(hasher.hash(11, 20, 3000)).isNotEqualTo(canonical);
assertThat(hasher.hash(10, 21, 3000)).isNotEqualTo(canonical);
assertThat(hasher.hash(10, 20, 3001)).isNotEqualTo(canonical);
}
}
코드 조각 1 · JUnit·UTF-8·AssertJ 시험 도구
package com.example.financialcore.idempotency;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
한 줄 읽기: 두 RequestHasher 단위 시험에 필요한 @Test, UTF-8 해석, AssertJ 진입점을 import한다.
- 문법을 한 줄씩 풀면
- JUnit Test annotation은 실행 대상을 표시하고, static import한 assertThat은 클래스 이름 없이 검증 chain을 시작하게 한다.
- 실제 값 추적
- 이 구간에는 fixture나 assertion 실행이 없다. 아래 메서드를 컴파일할 세 종류의 이름만 준비된다.
- 정상 예
- StandardCharsets.UTF_8을 사용해 byte[]를 String으로 읽으므로 구현과 시험이 같은 charset 계약을 쓴다.
- 반례·경계 예
- Test import를 없애면 @Test를 해석하지 못해 이 소스가 컴파일되지 않는다.
- 착각 방지
- assertThat static import 자체가 assertion을 수행하는 것은 아니다. 실제 값이 전달되는 아래 호출부터 검사가 시작된다.
- 이 블록이 하지 않는 일
- Spring context나 PostgreSQL을 띄우지 않고 transaction도 만들지 않는다.
- 다음 코드와의 연결
- 시험 클래스가 RequestHasher를 직접 생성해 두 test가 공유할 fixture로 둔다.
코드 조각 2 · 두 시험이 공유하는 직접 생성 fixture
class RequestHasherTest {
private final RequestHasher hasher = new RequestHasher();
한 줄 읽기: Spring 주입 없이 RequestHasher 한 개를 만들어 이 test instance의 field에 보관한다.
- 문법을 한 줄씩 풀면
- package-private test class 안의 private final field가 new RequestHasher() 결과 참조를 한 번 대입받는다.
- 실제 값 추적
- JUnit이 test class instance를 만들 때 hasher field가 초기화되고 두 @Test body는 그 참조로 메서드를 호출한다.
- 정상 예
- RequestHasher에 주입 의존성이 없으므로 기본 생성자를 직접 호출한 fixture로 단위 시험이 가능하다.
- 반례·경계 예
- hasher를 null로 두면 첫 메서드 호출에서 assertion 전에 NullPointerException이 난다.
- 착각 방지
- final field는 JUnit이 두 test method에 같은 test instance를 반드시 재사용한다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- @Component 등록 여부나 Spring bean lifecycle을 검증하지 않는다.
- 다음 코드와의 연결
- 첫 test가 입력 10·20·3000의 canonical text와 64글자 hex 형식을 확인한다.
코드 조각 3 · v1 canonical text와 hex 모양 assertion
@Test
void canonicalContractIsVersionedFixedOrderUtf8Decimal() {
assertThat(new String(hasher.canonicalBytes(10, 20, 3000), StandardCharsets.UTF_8))
.as("W12D1_RED_EXPECTED_VERSIONED_CANONICAL_HASH")
.isEqualTo("v1\nfrom=10\nto=20\namount=3000\n");
assertThat(hasher.hash(10, 20, 3000)).hasSize(64).matches("[0-9a-f]{64}");
}
한 줄 읽기: 입력 10·20·3000의 정확한 네 줄 문자열과 hash 길이 64·소문자 hex 형식을 한 test에서 고정한다.
- 문법을 한 줄씩 풀면
- 첫 AssertJ chain은 byte[]를 UTF-8 String으로 바꿔 isEqualTo로 exact 비교하고, 둘째 chain은 hasSize와 matches를 연속 적용한다.
- 실제 값 추적
- canonicalBytes(10,20,3000)는 v1↵from=10↵to=20↵amount=3000↵와 같아야 하고 hash 결과는 [0-9a-f]{64}를 만족해야 Green이다.
- 정상 예
- VERSION, field 순서, 10진 숫자, 마지막 newline, UTF-8, lowercase hex 모양이 구현과 기대값대로면 두 assertion이 통과한다.
- 반례·경계 예
- 마지막 newline이 빠져도 첫 비교가 실패하며, 대문자 A-F가 섞이면 길이가 64여도 정규식 검사가 실패한다.
- 착각 방지
- 정규식과 길이 검사는 내부 algorithm이 실제 SHA-256인지 단독으로 증명하지 않는다.
- 이 블록이 하지 않는 일
- 다른 숫자, 0 이하 guard, hash 충돌, actor·key scope를 확인하지 않는다.
- 다음 코드와의 연결
- 둘째 test는 기준 hash를 잡고 from과 to를 하나씩 바꿔 결과 변화를 대조한다.
코드 조각 4 · from·to 변화와 기준 hash 비교
@Test
void eachSemanticFieldChangesTheHash() {
String canonical = hasher.hash(10, 20, 3000);
assertThat(hasher.hash(11, 20, 3000)).isNotEqualTo(canonical);
assertThat(hasher.hash(10, 21, 3000)).isNotEqualTo(canonical);
한 줄 읽기: 기준 (10,20,3000) hash를 저장하고 from 또는 to 하나만 바꾼 두 결과가 기준과 다른지 확인한다.
- 문법을 한 줄씩 풀면
- 지역 변수 canonical이 기준 String을 보관하며, 두 isNotEqualTo chain은 각각 한 parameter만 바꾼 호출 결과를 같은 기준과 비교한다.
- 실제 값 추적
- 기준 hash H에 대해 hash(11,20,3000) != H와 hash(10,21,3000) != H가 모두 true여야 이 구간의 assertion이 통과한다.
- 정상 예
- from과 to가 canonical text의 서로 다른 줄에 포함되므로 각 변경 표본은 기준과 다른 digest를 낸다.
- 반례·경계 예
- 구현이 from 줄을 누락했다면 첫 변경 결과가 H와 같아져 첫 isNotEqualTo가 실패한다.
- 착각 방지
- 두 비교 결과가 서로 다른지 직접 검사한 것은 아니다. 각각 기준과 다름만 확인한다.
- 이 블록이 하지 않는 일
- 이 구간만으로 amount 변화나 모든 가능한 입력쌍의 uniqueness를 보장하지 않는다.
- 다음 코드와의 연결
- 마지막 span이 amount를 3001로 바꾼 세 번째 비교를 완성한다.
코드 조각 5 · amount 변화 assertion과 test 종료
assertThat(hasher.hash(10, 20, 3001)).isNotEqualTo(canonical);
}
}
한 줄 읽기: amount만 3000에서 3001로 바꾼 hash가 앞서 저장한 기준과 달라야 한다.
- 문법을 한 줄씩 풀면
- 세 번째 isNotEqualTo가 기존 canonical 변수와 새 호출 결과를 비교하고 두 닫는 brace가 test method와 class를 끝낸다.
- 실제 값 추적
- hash(10,20,3001)가 기준 hash(10,20,3000)와 같으면 실패하고, 다르면 둘째 test의 세 field 표본이 모두 Green이다.
- 정상 예
- amount 줄이 digest 입력에 포함된 구현에서는 3001 표본이 기준과 다른 결과를 만든다.
- 반례·경계 예
- 구현이 amount를 고정값으로 쓰거나 누락하면 이 assertion이 동일 hash를 관찰해 실패한다.
- 착각 방지
- 한 칸 변화 표본 하나는 모든 amount 쌍이 서로 다른 hash를 낸다는 전수 증명이 아니다.
- 이 블록이 하지 않는 일
- 음수·0 입력 예외와 최대 long 경계는 실행하지 않는다.
- 다음 코드와의 연결
- 다음 canonical 파일 V001__common은 이 hash와 key를 저장할 PostgreSQL 제약을 선언한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/idempotency/RequestHasherTest.java
- 전제조건
- JUnit5·AssertJ와 final RequestHasher가 필요하며 순수 unit test다.
- 반드시 지킬 계약
- 정확 문자열, hex64 regex, 세 의미 필드 각각 변경 시 기준 hash와 다름을 유지한다.
- 추천 입력 순서
- import → hasher field → canonical/hash 계약 test → 세 필드 변화 test 순서다.
- 자기 점검
- @Test2, v1 newline4개, hash 길이64, 11/21/3001 세 반례를 대조한다.
- 이번 파일의 범위 밖
- SHA-256 충돌 불가능, actor/key scope, 미래 필드 자동 포함은 증명하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.idempotency;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class RequestHasherTest {
private final RequestHasher hasher = new RequestHasher();
@Test
void canonicalContractIsVersionedFixedOrderUtf8Decimal() {
assertThat(new String(hasher.canonicalBytes(10, 20, 3000), StandardCharsets.UTF_8))
.as("W12D1_RED_EXPECTED_VERSIONED_CANONICAL_HASH")
.isEqualTo("v1\nfrom=10\nto=20\namount=3000\n");
assertThat(hasher.hash(10, 20, 3000)).hasSize(64).matches("[0-9a-f]{64}");
}
@Test
void eachSemanticFieldChangesTheHash() {
String canonical = hasher.hash(10, 20, 3000);
assertThat(hasher.hash(11, 20, 3000)).isNotEqualTo(canonical);
assertThat(hasher.hash(10, 21, 3000)).isNotEqualTo(canonical);
assertThat(hasher.hash(10, 20, 3001)).isNotEqualTo(canonical);
}
}
화 · V001 복합 UNIQUE
3. V001__common
한 문장 역할: account·business_tx·ledger_entry·idempotency_request 네 표와 핵심 제약·index를 만드는 실제 V001 migration이다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | Flyway와 IdempotencySchemaIT |
| 무엇을 받나 | 빈 PostgreSQL schema |
| 무엇이 바뀌나 | 표4·index2·PK/FK/CHECK/UNIQUE 제약을 영구 생성 |
| 무엇을 돌려주나 | 다음 migration과 repository가 사용할 schema |
정확한 전체 원문
정확한 전체 원문 펼치기
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);
코드 조각 1 · account 행의 식별자와 허용값 제약
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
한 줄 읽기: account 표에 identity PK, 계좌번호 UNIQUE, 상태·통화·잔액 CHECK, version 기본값을 선언한다.
- 문법을 한 줄씩 풀면
- GENERATED BY DEFAULT AS IDENTITY가 id 생성을 맡고, NOT NULL·UNIQUE·CHECK·DEFAULT가 각 column에 저장 가능한 모양을 제한한다.
- 실제 값 추적
- id를 생략한 ACTIVE/KRW/balance 10000 행은 version 0으로 시작한다. 같은 account_no의 둘째 행은 UNIQUE에서 거절된다.
- 정상 예
- 서로 다른 account_no, ACTIVE 또는 CLOSED, KRW, 0 이상 balance를 가진 행은 이 표 제약을 만족한다.
- 반례·경계 예
- currency USD나 balance -1은 애플리케이션 검사를 지나왔더라도 각각의 CHECK가 DB insert를 막는다.
- 착각 방지
- owner_id가 있다고 고객 표와 자동 연결되는 것은 아니다. 이 column에는 FOREIGN KEY 선언이 없다.
- 이 블록이 하지 않는 일
- 계좌 소유자 존재, 잔액 변경 원인, version 증가 방식을 보장하지 않는다.
- 다음 코드와의 연결
- owner_id와 id 순으로 account를 찾을 때 쓸 비고유 index를 별도로 만든다.
코드 조각 2 · owner_id·id account 조회 index
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
한 줄 읽기: account의 owner_id를 먼저, id를 다음 key로 둔 idx_account_owner_id를 생성한다.
- 문법을 한 줄씩 풀면
- CREATE INDEX는 table 행을 복제하는 대신 두 column 조합의 탐색 구조를 추가하며 UNIQUE keyword가 없으므로 중복을 허용한다.
- 실제 값 추적
- 같은 owner_id에 account가 여러 개 있어도 index entry가 각각 생기고 id 순서를 두 번째 key로 구분한다.
- 정상 예
- owner_id equality와 id를 함께 쓰는 조회는 planner가 이 index를 선택할 후보가 된다.
- 반례·경계 예
- owner_id가 같은 두 행을 insert해도 이 index 때문에 constraint 위반이 나지 않는다.
- 착각 방지
- index 선언은 특정 query가 반드시 index scan을 사용한다는 명령이 아니다. 최종 plan은 PostgreSQL planner가 정한다.
- 이 블록이 하지 않는 일
- owner별 account 개수나 owner_id의 referential integrity를 제한하지 않는다.
- 다음 코드와의 연결
- 이어지는 business_tx 표가 거래 UUID와 correlation_id를 저장한다.
코드 조각 3 · business transaction 머리 행
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
한 줄 읽기: UUID 거래 ID와 고유 correlation_id, 유형·상태·요청/완료 시각을 business_tx 한 행에 담는다.
- 문법을 한 줄씩 풀면
- UUID PRIMARY KEY와 correlation_id UNIQUE가 서로 다른 식별 축을 만들고, completed_at만 NULL을 허용한다.
- 실제 값 추적
- 요청 시 id·tx_type·status·correlation_id·requested_at은 모두 필요하며 완료 전에는 completed_at을 비워 둘 수 있다.
- 정상 예
- 새 UUID와 새 correlation_id를 가진 PROCESSING 성격의 행은 completed_at NULL 상태로 insert할 수 있다.
- 반례·경계 예
- 다른 UUID를 써도 기존 correlation_id를 재사용하면 UNIQUE 제약이 둘째 행을 거절한다.
- 착각 방지
- status와 tx_type이 NOT NULL이라고 허용 문자열까지 제한되는 것은 아니다. 이 둘에는 CHECK가 없다.
- 이 블록이 하지 않는 일
- 거래 금액, 출금·입금 계좌, 상태 전이 순서를 저장하거나 검증하지 않는다.
- 다음 코드와의 연결
- ledger_entry가 이 UUID를 외래키로 참조해 계좌별 원장 행을 붙인다.
코드 조각 4 · ledger_entry 금액·잔액·참조 열
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
한 줄 읽기: 원장 행을 거래와 계좌에 연결하고 양수 amount, signed amount, 비음수 balance_after, 선택적 reversal 참조를 둔다.
- 문법을 한 줄씩 풀면
- 두 NOT NULL FOREIGN KEY가 business_tx와 account를 가리키고, reversal_of는 같은 ledger_entry id를 가리키는 nullable self-reference다.
- 실제 값 추적
- 정상 원장 한 행은 새 identity id, 존재하는 거래·계좌, entry_type, amount>0, signed_amount, balance_after>=0을 가져야 한다.
- 정상 예
- 기존 transaction과 account에 amount 300, signed_amount -300, balance_after 9700인 행은 이 구간의 제약을 만족한다.
- 반례·경계 예
- amount 0 또는 존재하지 않는 account_id는 각각 CHECK나 FOREIGN KEY에서 거절된다.
- 착각 방지
- amount가 양수라고 signed_amount의 부호와 절댓값이 자동 일치하지 않는다. 그 관계를 묶는 CHECK는 없다.
- 이 블록이 하지 않는 일
- 한 거래가 반드시 debit·credit 두 행을 갖거나 signed_amount 합이 0임을 보장하지 않는다.
- 다음 코드와의 연결
- 다음 세 줄이 created_at과 거래·계좌·entry_type 조합 UNIQUE를 완성한다.
코드 조각 5 · 원장 생성 시각과 세 열 UNIQUE
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
한 줄 읽기: created_at을 필수로 받고 같은 거래·계좌·entry_type 조합의 중복 원장 행을 막는다.
- 문법을 한 줄씩 풀면
- created_at은 DEFAULT 없는 NOT NULL이고 table-level UNIQUE가 business_tx_id, account_id, entry_type 세 NOT NULL 열을 한 key로 묶는다.
- 실제 값 추적
- 거래 T의 계좌 A에 entry_type OUT인 첫 행 뒤 같은 T/A/OUT 둘째 행은 다른 id여도 UNIQUE 위반이다.
- 정상 예
- 같은 거래라도 계좌나 entry_type이 다르면 이 조합 제약만으로는 별도 행을 허용한다.
- 반례·경계 예
- created_at을 생략하면 DB가 시간을 자동 채우지 않고 NOT NULL 위반이 난다.
- 착각 방지
- 이 UNIQUE는 거래 하나당 원장 행을 정확히 두 개로 제한하지 않는다.
- 이 블록이 하지 않는 일
- OUT과 IN 한 쌍의 존재, 두 signed_amount 합 0, 생성 시각 순서를 검증하지 않는다.
- 다음 코드와의 연결
- account별 최신 원장을 읽기 위한 내림차순 composite index가 이어진다.
코드 조각 6 · 계좌별 최신 원장 정렬 index
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
한 줄 읽기: account_id 뒤에 created_at과 id를 내림차순으로 둔 원장 조회 index를 만든다.
- 문법을 한 줄씩 풀면
- 첫 key는 계좌를 묶고 DESC 두 key는 최신 시각 우선, 같은 시각이면 큰 identity id 우선 순서를 표현한다.
- 실제 값 추적
- 한 account의 두 행이 같은 created_at을 가져도 id DESC가 두 번째 정렬 기준이 되어 순서를 결정할 수 있다.
- 정상 예
- account_id를 고정하고 created_at DESC, id DESC로 읽는 query는 이 index 순서와 맞는다.
- 반례·경계 예
- created_at만 조건으로 모든 계좌를 찾는 query가 이 composite index를 항상 효율적으로 쓰는 것은 아니다.
- 착각 방지
- index의 DESC 선언이 SELECT에 ORDER BY를 쓰지 않아도 반환 순서를 계약해 주지는 않는다.
- 이 블록이 하지 않는 일
- 원장 행을 생성하거나 오래된 행을 삭제하거나 최신 balance가 정확한지 검증하지 않는다.
- 다음 코드와의 연결
- 마지막 표 idempotency_request가 요청 key·hash·처리 상태와 응답을 저장한다.
코드 조각 7 · idempotency 요청과 응답 저장 열
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
한 줄 읽기: scope·actor·key·64칸 hash·status를 필수로, response status/body를 선택적으로 저장하는 요청 표를 연다.
- 문법을 한 줄씩 풀면
- identity PK 외에 scope, actor_id, idempotency_key, request_hash, status, created_at은 NOT NULL이고 두 response 열은 NULL을 허용한다.
- 실제 값 추적
- 처리 시작 행은 필수 key와 hash·status·created_at을 넣고 response_status와 response_body는 아직 NULL로 둘 수 있다.
- 정상 예
- TRANSFER/customer-1/k1, 64글자 hash, PROCESSING 성격의 상태, 현재 시각을 넣은 행은 이 열 제약을 만족한다.
- 반례·경계 예
- request_hash를 NULL로 넣거나 idempotency_key를 생략하면 다음 UNIQUE를 보기 전 NOT NULL에서 실패한다.
- 착각 방지
- CHAR(64)는 길이 자리만 정할 뿐 값이 lowercase SHA-256 hex인지 검사하지 않는다.
- 이 블록이 하지 않는 일
- status 허용 집합·전이, response JSON 형식, PROCESSING 만료 시간을 제한하지 않는다.
- 다음 코드와의 연결
- completed_at과 scope·actor·key 복합 UNIQUE가 표 선언을 닫는다.
코드 조각 8 · scope·actor·key 복합 UNIQUE
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);
한 줄 읽기: 완료 시각은 선택적으로 두고 scope·actor_id·idempotency_key가 같은 둘째 행을 DB에서 거절한다.
- 문법을 한 줄씩 풀면
- completed_at은 nullable TIMESTAMPTZ이고 table-level UNIQUE는 hash를 제외한 세 NOT NULL column을 복합 key로 묶는다.
- 실제 값 추적
- TRANSFER/customer-1/same-key에 hash a 행이 있으면 hash b로 바꾼 둘째 insert도 같은 세 key라 충돌한다.
- 정상 예
- 같은 idempotency_key라도 actor_id 또는 scope가 다르면 이 UNIQUE 조합은 다른 key로 본다.
- 반례·경계 예
- 같은 세 key에 request_hash만 다르게 넣어도 새 행으로 허용되지 않는다.
- 착각 방지
- UNIQUE 위반 자체가 기존 payload와 새 payload가 같은지 판정해 replay와 conflict를 나눠 주지는 않는다.
- 이 블록이 하지 않는 일
- 기존 행을 반환하거나 owner를 선출하는 SQL, 완료 처리, stale row 회수를 구현하지 않는다.
- 다음 코드와의 연결
- IdempotencySchemaIT가 실제 PostgreSQL에서 hash가 다른 둘째 insert와 최종 COUNT 1을 확인한다.
직접 다시 써보기
- 저장 경로
- src/main/resources/db/migration/V001__common.sql
- 전제조건
- PostgreSQL/Flyway가 필요하고 기존 V001과 checksum 충돌 없이 새 DB에서 실행해야 한다.
- 반드시 지킬 계약
- account/business_tx/ledger_entry/idempotency_request와 복합 UNIQUE(scope, actor_id, idempotency_key)를 그대로 보존한다.
- 추천 입력 순서
- account → owner index → business_tx → ledger_entry → ledger index → idempotency_request 순서다.
- 자기 점검
- 표4, index2, account balance/version, ledger FK·signed_amount, hash CHAR(64), 복합 UNIQUE를 대조한다.
- 이번 파일의 범위 밖
- stale PROCESSING 정리·status enum CHECK·HTTP replay·seed data는 이 migration에 없다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);
첫 INSERT
TRANSFER · customer-1 · same-keyhash a×64 → row 1둘째 INSERT
TRANSFER · customer-1 · same-keyhash b×64 → UNIQUE 거절4. IdempotencySchemaIT
한 문장 역할: 동일 scope·actor·key의 두 번째 INSERT가 실제 PostgreSQL V001 복합 UNIQUE에 막히고 행이 1개만 남는지 확인한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 화요일 exact selector |
| 무엇을 받나 | 빈 idempotency_request와 hash a×64, b×64 |
| 무엇이 바뀌나 | 첫 INSERT 1행 commit; 둘째는 constraint 예외 |
| 무엇을 돌려주나 | DataIntegrityViolationException과 최종 COUNT 1 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.idempotency;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
class IdempotencySchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@BeforeEach void clean() { jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update(); }
@Test
void uniqueScopeActorAndKeyAreEnforcedByV001() {
insert("a".repeat(64));
assertThatThrownBy(() -> insert("b".repeat(64)))
.isInstanceOf(DataIntegrityViolationException.class);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
}
private void insert(String hash) {
jdbc.sql("""
INSERT INTO idempotency_request(
scope,actor_id,idempotency_key,request_hash,status,created_at
) VALUES ('TRANSFER','customer-1','same-key',:hash,'PROCESSING',now())
""").param("hash", hash).update();
}
}
코드 조각 1 · PostgreSQL schema 통합 시험 의존성
package com.example.financialcore.idempotency;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
한 줄 읽기: 실제 PostgreSQL 기반 class, JUnit lifecycle, Spring context, JDBC, 무결성 예외 타입을 연결한다.
- 문법을 한 줄씩 풀면
- 일반 import들이 base test, annotation, injected client, DataIntegrityViolationException의 전체 package 이름을 이 소스의 짧은 이름으로 가져온다.
- 실제 값 추적
- 아직 container나 context가 시작되지 않는다. 아래 class annotation과 test body가 이 타입들을 실제로 사용할 때 의미가 생긴다.
- 정상 예
- PostgresIntegrationTestSupport와 @SpringBootTest를 함께 쓴 class가 JdbcClient로 migration 적용 DB를 직접 조회할 준비를 한다.
- 반례·경계 예
- JdbcClient import가 없으면 @Autowired field의 type을 찾지 못해 test 실행 전에 컴파일이 실패한다.
- 착각 방지
- DataIntegrityViolationException을 import한 것만으로 특정 UNIQUE 이름까지 식별되는 것은 아니다.
- 이 블록이 하지 않는 일
- V001을 생성하거나 table을 비우거나 insert를 실행하지 않는다.
- 다음 코드와의 연결
- 두 static AssertJ import가 정상 값과 예외 경로의 assertion 진입점을 제공한다.
코드 조각 2 · 값 assertion과 예외 assertion 진입점
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
한 줄 읽기: COUNT 값을 비교할 assertThat과 둘째 insert 실패를 포착할 assertThatThrownBy를 static import한다.
- 문법을 한 줄씩 풀면
- static import는 Assertions class의 두 factory method를 class qualifier 없이 호출하게 한다.
- 실제 값 추적
- 아래 test에서 assertThatThrownBy는 lambda 실행 중 던진 예외를 받고, assertThat은 SELECT COUNT의 Long 1을 받는다.
- 정상 예
- 예외 경로와 최종 행 수를 별도 chain으로 써서 서로 다른 관찰값을 판정한다.
- 반례·경계 예
- assertThatThrownBy 안의 lambda가 아무 예외도 던지지 않으면 isInstanceOf 비교까지 성공할 수 없다.
- 착각 방지
- 두 assertion 도구가 transaction을 rollback하거나 DB 상태를 복구해 주지는 않는다.
- 이 블록이 하지 않는 일
- 어떤 SQL을 실행할지나 기대 count를 이 import 구간에서 정하지 않는다.
- 다음 코드와의 연결
- test class가 Spring context와 PostgreSQL support를 결합하고 JdbcClient를 주입받는다.
코드 조각 3 · Spring PostgreSQL 통합 시험 class
@SpringBootTest
class IdempotencySchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
한 줄 읽기: SpringBootTest context와 공통 PostgreSQL 지원을 사용하고 JdbcClient를 field injection으로 받는다.
- 문법을 한 줄씩 풀면
- class는 PostgresIntegrationTestSupport를 extends하며, @Autowired가 package-private JdbcClient field를 context의 bean으로 채운다.
- 실제 값 추적
- JUnit이 이 class를 실행하면 공통 PostgreSQL 환경 위에 application context가 올라오고 jdbc가 실제 datasource에 연결된다.
- 정상 예
- migration이 적용된 integration datasource를 JdbcClient query와 update가 함께 사용한다.
- 반례·경계 예
- JdbcClient bean을 만들 수 없는 context라면 test body 전에 application context 초기화가 실패한다.
- 착각 방지
- extends만으로 각 test가 자동 rollback되는 것은 아니다. 이 세 줄에는 @Transactional이 없다.
- 이 블록이 하지 않는 일
- idempotency_request를 비우거나 UNIQUE를 실제로 건드리지 않는다.
- 다음 코드와의 연결
- BeforeEach fixture가 매 test 전에 대상 표만 TRUNCATE하고 identity를 되감는다.
코드 조각 4 · idempotency_request 단독 초기화
@BeforeEach void clean() { jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update(); }
한 줄 읽기: 각 test 전에 idempotency_request의 모든 행을 지우고 identity sequence를 처음으로 되돌린다.
- 문법을 한 줄씩 풀면
- @BeforeEach가 한 줄 fixture method를 JUnit lifecycle에 등록하고 JdbcClient update가 TRUNCATE ... RESTART IDENTITY를 실행한다.
- 실제 값 추적
- 이 class의 test가 시작될 때 이전 요청 행 수는 0이 되고 다음 자동 id는 초기 sequence 값에서 다시 배정된다.
- 정상 예
- 빈 idempotency_request에서 첫 insert를 owner 후보 한 행으로 관찰할 수 있다.
- 반례·경계 예
- cleanup을 빼면 앞 실행의 same-key 행이 남아 첫 insert부터 UNIQUE 위반이 날 수 있다.
- 착각 방지
- TRUNCATE 대상은 이 표 하나다. account·business_tx·ledger_entry까지 지운다고 읽으면 안 된다.
- 이 블록이 하지 않는 일
- V001 constraint를 다시 만들거나 migration checksum을 검증하지 않는다.
- 다음 코드와의 연결
- test가 hash a 첫 행을 넣고 같은 key의 hash b 둘째 행이 거절되는지 확인한다.
코드 조각 5 · hash가 달라도 같은 복합 key는 한 행
@Test
void uniqueScopeActorAndKeyAreEnforcedByV001() {
insert("a".repeat(64));
assertThatThrownBy(() -> insert("b".repeat(64)))
.isInstanceOf(DataIntegrityViolationException.class);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
}
한 줄 읽기: hash a 첫 insert는 성공시키고 hash b 둘째 insert의 무결성 예외와 최종 전체 COUNT 1을 확인한다.
- 문법을 한 줄씩 풀면
- 첫 helper 호출은 바로 실행되고, 둘째는 lambda로 감싸 assertThatThrownBy에 넘기며, 마지막 JdbcClient scalar query를 isEqualTo(1)과 비교한다.
- 실제 값 추적
- 빈 표에 a×64 행이 1개 생긴 뒤 같은 scope·actor·key의 b×64 insert가 DataIntegrityViolationException을 내고 SELECT COUNT(*)는 1을 반환해야 한다.
- 정상 예
- V001의 UNIQUE(scope, actor_id, idempotency_key)가 적용된 PostgreSQL에서는 세 assertion 관찰이 모두 맞는다.
- 반례·경계 예
- UNIQUE에 request_hash까지 잘못 포함했다면 hash b 행도 들어가 예외가 없고 COUNT가 2가 된다.
- 착각 방지
- DataIntegrityViolationException 타입만으로 정확히 어느 DB constraint가 원인인지 직접 식별한 것은 아니다.
- 이 블록이 하지 않는 일
- 다른 actor나 key의 insert 성공, 기존 행의 hash 값, concurrent owner 수를 검사하지 않는다.
- 다음 코드와의 연결
- insert helper가 고정 scope·actor·key와 parameter hash를 어떤 SQL로 보내는지 펼친다.
코드 조각 6 · 중복 fixture INSERT 문장
private void insert(String hash) {
jdbc.sql("""
INSERT INTO idempotency_request(
scope,actor_id,idempotency_key,request_hash,status,created_at
) VALUES ('TRANSFER','customer-1','same-key',:hash,'PROCESSING',now())
한 줄 읽기: 고정 TRANSFER·customer-1·same-key·PROCESSING과 전달받은 hash를 idempotency_request에 넣는 SQL text를 만든다.
- 문법을 한 줄씩 풀면
- private helper는 String hash를 받고 Java text block 안에 여섯 target column과 VALUES를 적으며 :hash만 named parameter 자리로 남긴다.
- 실제 값 추적
- 두 호출 모두 scope, actor_id, key, status는 같고 request_hash 자리만 각각 a×64와 b×64가 된다. created_at은 PostgreSQL now()가 계산한다.
- 정상 예
- 첫 호출의 hash가 binding되면 필수 여섯 열을 채운 PROCESSING 행 하나를 insert할 문장이 된다.
- 반례·경계 예
- 두 번째 호출도 key literal이 same-key라 hash만 달라도 V001의 세 열 UNIQUE와 충돌한다.
- 착각 방지
- :hash는 text block 안의 문자열 치환이 아니다. JdbcClient가 다음 구간에서 별도 parameter로 묶는다.
- 이 블록이 하지 않는 일
- response_status·response_body·completed_at을 채우거나 기존 행을 SELECT하지 않는다.
- 다음 코드와의 연결
- 마지막 구간이 hash parameter를 binding하고 update를 실제 실행한다.
코드 조각 7 · hash binding과 INSERT 실행
""").param("hash", hash).update();
}
}
한 줄 읽기: helper parameter hash를 :hash에 값으로 묶고 update를 호출해 INSERT를 DB에 보낸다.
- 문법을 한 줄씩 풀면
- param(name,value)이 named placeholder와 Java String을 연결하고 update()가 준비된 DML을 실행하며 반환 row count는 여기서 사용하지 않는다.
- 실제 값 추적
- 첫 호출에서는 a×64, 둘째에서는 b×64가 request_hash 값으로 전달되고 둘째 update가 UNIQUE 위반 예외를 호출자에게 올린다.
- 정상 예
- 64글자 hash와 비어 있는 표의 첫 호출은 update count 1을 내지만 helper는 그 숫자를 버리고 종료한다.
- 반례·경계 예
- hash 값에 따옴표가 있어도 SQL 문법으로 합쳐지지 않고 하나의 bound value로 전달된다. 다만 CHAR(64) 길이 제약은 별도로 적용된다.
- 착각 방지
- update()를 호출했다고 둘째 실패가 자동으로 catch되는 것은 아니다. helper에는 예외 처리 구간이 없다.
- 이 블록이 하지 않는 일
- insert 성공 여부를 boolean으로 반환하거나 conflict를 replay 응답으로 바꾸지 않는다.
- 다음 코드와의 연결
- 다음 파일 IdempotencyStore는 INSERT ... ON CONFLICT DO NOTHING으로 owner와 existing을 Optional로 구분한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/idempotency/IdempotencySchemaIT.java
- 전제조건
- SpringBootTest, PostgreSQL Testcontainers, JdbcClient, 적용된 V001이 필요하다.
- 반드시 지킬 계약
- TRUNCATE → 같은 세 key 첫 insert → 다른 hash 두 번째 insert 예외 → count1을 지킨다.
- 추천 입력 순서
- import/annotation → clean → @Test → private insert text block/param 순서로 쓴다.
- 자기 점검
- 같은-key 값, hash a/b 각64, 예외 type, row count1을 대조한다.
- 이번 파일의 범위 밖
- 다른 actor/key 허용, status CHECK, concurrent owner 선출은 직접 시험하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.idempotency;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
class IdempotencySchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@BeforeEach void clean() { jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update(); }
@Test
void uniqueScopeActorAndKeyAreEnforcedByV001() {
insert("a".repeat(64));
assertThatThrownBy(() -> insert("b".repeat(64)))
.isInstanceOf(DataIntegrityViolationException.class);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
}
private void insert(String hash) {
jdbc.sql("""
INSERT INTO idempotency_request(
scope,actor_id,idempotency_key,request_hash,status,created_at
) VALUES ('TRANSFER','customer-1','same-key',:hash,'PROCESSING',now())
""").param("hash", hash).update();
}
}
수 · PostgreSQL atomic claim
5. IdempotencyStore
한 문장 역할: 한 SQL statement로 claim owner를 뽑고 기존 row를 읽으며 PROCESSING 한 행만 COMPLETED로 바꾼다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | TransferService와 AtomicClaim50IT |
| 무엇을 받나 | scope·actorId·key·requestHash, 완료 시 id/status/body |
| 무엇이 바뀌나 | idempotency_request의 새 PROCESSING insert 또는 기존 행 조회/완료 update |
| 무엇을 돌려주나 | 새 owner id Optional, Existing record, 또는 completion 성공/예외 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.idempotency;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public class IdempotencyStore {
public record Existing(String requestHash, String status, Integer responseStatus, String responseBody) {}
@PersistenceContext
private EntityManager entityManager;
@Transactional
public Optional<Long> claim(String scope, String actorId, String key, String requestHash) {
List<?> rows = entityManager.createNativeQuery("""
INSERT INTO idempotency_request
(scope, actor_id, idempotency_key, request_hash, status, created_at)
VALUES (:scope, :actorId, :key, :requestHash, 'PROCESSING', :now)
ON CONFLICT (scope, actor_id, idempotency_key) DO NOTHING
RETURNING id
""")
.setParameter("scope", scope)
.setParameter("actorId", actorId)
.setParameter("key", key)
.setParameter("requestHash", requestHash)
.setParameter("now", Instant.now())
.getResultList();
if (rows.isEmpty()) return Optional.empty();
return Optional.of(((Number) rows.getFirst()).longValue());
}
@Transactional(readOnly = true)
public Existing find(String scope, String actorId, String key) {
Object[] row = (Object[]) entityManager.createNativeQuery("""
SELECT request_hash, status, response_status, response_body
FROM idempotency_request
WHERE scope=:scope AND actor_id=:actorId AND idempotency_key=:key
""")
.setParameter("scope", scope)
.setParameter("actorId", actorId)
.setParameter("key", key)
.getSingleResult();
return new Existing(
(String) row[0],
(String) row[1],
row[2] == null ? null : ((Number) row[2]).intValue(),
(String) row[3]
);
}
@Transactional
public void complete(long id, int responseStatus, String responseBody) {
int updated = entityManager.createNativeQuery("""
UPDATE idempotency_request
SET status='COMPLETED', response_status=:responseStatus,
response_body=:responseBody, completed_at=:now
WHERE id=:id AND status='PROCESSING'
""")
.setParameter("responseStatus", responseStatus)
.setParameter("responseBody", responseBody)
.setParameter("now", Instant.now())
.setParameter("id", id)
.executeUpdate();
if (updated != 1) throw new IllegalStateException("idempotency completion failed");
}
}
코드 조각 1 · JPA 저장소와 transaction annotation 도구
package com.example.financialcore.idempotency;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
한 줄 읽기: native SQL을 실행할 EntityManager, persistence-context 주입, repository 표시, transaction 경계 타입을 연결한다.
- 문법을 한 줄씩 풀면
- jakarta.persistence import 둘은 JPA API이고 Spring import 둘은 stereotype와 method transaction annotation을 짧은 이름으로 쓴다.
- 실제 값 추적
- 이 구간에서는 EntityManager가 생성되거나 transaction이 열리지 않는다. 아래 annotation과 field·method 선언을 해석할 타입만 준비된다.
- 정상 예
- Spring이 관리하는 IdempotencyStore에서 @PersistenceContext field와 @Transactional method가 각각 해당 infrastructure에 연결된다.
- 반례·경계 예
- Transactional import를 빼면 아래 annotation을 해석하지 못해 컴파일이 실패한다. method가 non-transactional로 조용히 실행되는 변화가 아니다.
- 착각 방지
- EntityManager import는 database connection 하나를 즉시 열어 field에 고정하지 않는다.
- 이 블록이 하지 않는 일
- claim·find·complete SQL을 실행하거나 rollback 규칙을 이 일곱 줄에서 설정하지 않는다.
- 다음 코드와의 연결
- Instant, List, Optional이 시간 parameter와 claim 결과 모양을 지원한다.
코드 조각 2 · 현재 시각과 claim 결과 container
import java.time.Instant;
import java.util.List;
import java.util.Optional;
한 줄 읽기: DB 시각 parameter용 Instant, RETURNING 행 목록용 List, owner id 유무용 Optional을 가져온다.
- 문법을 한 줄씩 풀면
- java.time과 java.util의 세 generic-capable type을 import해 아래 signature와 지역 변수에서 짧은 이름으로 쓴다.
- 실제 값 추적
- claim은 List<?>로 native query 결과를 받고 그 목록이 비었는지에 따라 Optional.empty 또는 Optional<Long>을 만든다. complete는 Instant.now를 쓴다.
- 정상 예
- 새 행을 얻은 호출은 숫자 id가 든 Optional을, conflict로 RETURNING 행이 없는 호출은 empty를 표현할 수 있다.
- 반례·경계 예
- Optional.empty는 null owner id와 다르게 명시적인 no-value 결과이며 get을 바로 호출하면 NoSuchElementException이다.
- 착각 방지
- List가 있다는 이유로 claim이 여러 owner id를 정상 결과로 처리하는 것은 아니다. 구현은 첫 원소만 읽는다.
- 이 블록이 하지 않는 일
- 동시성, SQL conflict, transaction을 이 import 구간 자체가 제공하지 않는다.
- 다음 코드와의 연결
- Repository class와 Existing record가 저장소 API의 두 반환 모양을 선언한다.
코드 조각 3 · 저장소 bean과 Existing snapshot
@Repository
public class IdempotencyStore {
public record Existing(String requestHash, String status, Integer responseStatus, String responseBody) {}
한 줄 읽기: IdempotencyStore를 repository 후보로 표시하고 기존 요청의 hash·상태·응답 세부를 Existing record에 묶는다.
- 문법을 한 줄씩 풀면
- @Repository는 class stereotype이고 nested public record는 네 component와 accessor, 값 기반 equals/hashCode를 자동 선언한다.
- 실제 값 추적
- find가 한 DB row를 읽으면 requestHash, status, nullable responseStatus, nullable responseBody 순서로 Existing 값을 만든다.
- 정상 예
- 완료 전 행은 Existing(hash, PROCESSING 성격의 status, null, null)처럼 응답 두 자리를 비워 표현할 수 있다.
- 반례·경계 예
- responseStatus가 Integer인 이유는 primitive int와 달리 DB NULL을 보존하기 위해서다.
- 착각 방지
- @Repository가 native SQL의 논리 오류를 자동 수정하거나 모든 DB exception을 domain error로 바꾸지는 않는다.
- 이 블록이 하지 않는 일
- Existing record는 row를 갱신하거나 responseBody를 JSON object로 해석하지 않는다.
- 다음 코드와의 연결
- persistence context가 실제 native query 실행 창구인 EntityManager field를 제공한다.
코드 조각 4 · transaction-bound EntityManager 주입 지점
@PersistenceContext
private EntityManager entityManager;
한 줄 읽기: @PersistenceContext가 Spring/JPA가 관리하는 EntityManager 접근 객체를 private field에 공급한다.
- 문법을 한 줄씩 풀면
- annotation은 field injection 지점을 표시하고 field type은 EntityManager다. 코드가 new EntityManager를 직접 만들지 않는다.
- 실제 값 추적
- Spring bean에서 method transaction이 시작되면 주입된 접근 객체가 그 transaction에 연결된 persistence context로 native query를 위임한다.
- 정상 예
- claim·find·complete가 같은 field를 사용해도 각 호출은 활성 transaction context에 맞는 EntityManager 동작을 얻는다.
- 반례·경계 예
- Spring container 밖에서 IdempotencyStore를 직접 new하면 이 private field가 자동으로 채워지지 않는다.
- 착각 방지
- @PersistenceContext가 annotation 위치에서 transaction을 시작하거나 하나의 physical connection을 영구 보관하는 것은 아니다.
- 이 블록이 하지 않는 일
- SQL을 실행하거나 flush·commit 시점을 이 field 선언만으로 확정하지 않는다.
- 다음 코드와의 연결
- claim method가 transaction 안에서 conflict-safe INSERT ... RETURNING을 구성한다.
코드 조각 5 · 복합 key 한 번만 INSERT하고 id 반환
@Transactional
public Optional<Long> claim(String scope, String actorId, String key, String requestHash) {
List<?> rows = entityManager.createNativeQuery("""
INSERT INTO idempotency_request
(scope, actor_id, idempotency_key, request_hash, status, created_at)
VALUES (:scope, :actorId, :key, :requestHash, 'PROCESSING', :now)
ON CONFLICT (scope, actor_id, idempotency_key) DO NOTHING
RETURNING id
""")
.setParameter("scope", scope)
한 줄 읽기: claim transaction에서 PROCESSING 행을 넣고 같은 scope·actor·key 충돌이면 행 없이 새 id만 RETURNING한다.
- 문법을 한 줄씩 풀면
- @Transactional method가 Optional<Long>을 반환하고, native INSERT의 ON CONFLICT target이 V001 복합 UNIQUE 세 열과 일치하며 DO NOTHING 뒤 RETURNING id를 둔다.
- 실제 값 추적
- 첫 호출은 six-column 행을 넣어 id 한 행을 반환하고, 이미 같은 세 key가 있으면 PostgreSQL이 insert를 건너뛰어 결과 목록이 빈다.
- 정상 예
- 새 TRANSFER/customer-1/k1 조합은 status PROCESSING과 전달 hash·now로 생성되고 생성 id를 owner 표지로 얻는다.
- 반례·경계 예
- 같은 key라도 actorId가 다르면 conflict target 조합이 달라 새 행을 만들 수 있다.
- 착각 방지
- DO NOTHING은 기존 row를 SELECT하거나 requestHash 일치 여부를 검사하지 않는다.
- 이 블록이 하지 않는 일
- 이 구간은 기존 결과 replay, 다른 hash conflict, status 완료 갱신을 수행하지 않는다.
- 다음 코드와의 연결
- 나머지 parameter를 묶고 빈 RETURNING 목록을 Optional.empty로 변환한다.
코드 조각 6 · claim parameter binding과 owner Optional
.setParameter("actorId", actorId)
.setParameter("key", key)
.setParameter("requestHash", requestHash)
.setParameter("now", Instant.now())
.getResultList();
if (rows.isEmpty()) return Optional.empty();
return Optional.of(((Number) rows.getFirst()).longValue());
}
한 줄 읽기: actor·key·hash·현재 시각을 묶어 query를 실행하고 반환 행 유무를 owner id Optional로 바꾼다.
- 문법을 한 줄씩 풀면
- 연쇄 setParameter 뒤 getResultList가 SQL을 실행하며, empty guard는 Optional.empty를 즉시 반환하고 첫 Number는 longValue로 변환된다.
- 실제 값 추적
- 새 insert 결과 [id]는 Optional.of(id)가 되고 conflict 결과 []는 Optional.empty가 된다.
- 정상 예
- 한 행을 RETURNING한 정상 owner 호출은 rows.getFirst()의 숫자 id를 long으로 받는다.
- 반례·경계 예
- scope parameter는 앞 span에서 이미 묶였고 이 구간의 네 binding 중 하나라도 이름이 SQL placeholder와 다르면 query 실행이 실패한다.
- 착각 방지
- Instant.now()는 DB now()가 아니라 application process에서 parameter 값을 한 번 만든다.
- 이 블록이 하지 않는 일
- empty 결과일 때 기존 row의 status·hash·response를 가져오지 않는다.
- 다음 코드와의 연결
- find가 정확한 복합 key로 기존 행 네 column을 읽는다.
코드 조각 7 · 복합 key로 기존 요청 네 값 조회
@Transactional(readOnly = true)
public Existing find(String scope, String actorId, String key) {
Object[] row = (Object[]) entityManager.createNativeQuery("""
SELECT request_hash, status, response_status, response_body
FROM idempotency_request
WHERE scope=:scope AND actor_id=:actorId AND idempotency_key=:key
""")
.setParameter("scope", scope)
.setParameter("actorId", actorId)
한 줄 읽기: read-only transaction에서 scope·actor·key가 모두 같은 행의 hash·status·response status/body를 선택한다.
- 문법을 한 줄씩 풀면
- @Transactional(readOnly=true)는 transaction hint를 주고 native SELECT는 네 column 순서를 정한 뒤 scope와 actorId parameter를 묶는다.
- 실제 값 추적
- TRANSFER/customer-1/k1을 찾으면 결과 배열 index 0..3에 request_hash, status, response_status, response_body가 그 순서로 놓인다.
- 정상 예
- claim conflict 뒤 같은 복합 key가 실제로 존재하면 하나의 row를 Existing으로 바꿀 입력이 준비된다.
- 반례·경계 예
- scope만 같고 actor_id가 다르면 WHERE의 AND 조건을 만족하지 않아 그 행은 결과에 포함되지 않는다.
- 착각 방지
- readOnly=true가 PostgreSQL에서 모든 쓰기를 물리적으로 불가능하게 만든다는 보장은 이 코드만으로 할 수 없다.
- 이 블록이 하지 않는 일
- response JSON을 parse하거나 status에 따라 기다림·replay 분기를 선택하지 않는다.
- 다음 코드와의 연결
- key를 마지막으로 binding하고 single row를 네 component Existing으로 변환한다.
코드 조각 8 · single native row를 Existing으로 변환
.setParameter("key", key)
.getSingleResult();
return new Existing(
(String) row[0],
(String) row[1],
row[2] == null ? null : ((Number) row[2]).intValue(),
(String) row[3]
);
}
한 줄 읽기: key를 묶어 단일 결과를 받고 네 column을 String·nullable Integer·String 순서로 Existing에 넣는다.
- 문법을 한 줄씩 풀면
- getSingleResult 결과를 Object[]로 cast하고 response_status만 null ternary 뒤 Number.intValue로 변환한다.
- 실제 값 추적
- row [hash, COMPLETED, 201, body]는 Existing(hash, COMPLETED, 201, body)가 되고 처리 중 [hash, PROCESSING, null, null]도 보존된다.
- 정상 예
- V001 복합 UNIQUE 아래 정확한 key 행 하나가 있으면 SELECT column 순서와 record constructor 순서가 맞아 snapshot이 생성된다.
- 반례·경계 예
- 행이 없으면 getSingleResult가 정상 null을 돌려주는 것이 아니라 no-result 예외를 낼 수 있으며 이 method는 catch하지 않는다.
- 착각 방지
- Object[] cast는 compile-time type safety를 주지 않는다. SELECT 순서나 driver 반환 타입이 바뀌면 runtime 변환이 깨질 수 있다.
- 이 블록이 하지 않는 일
- Existing을 managed entity로 만들거나 이후 DB update를 자동 반영하지 않는다.
- 다음 코드와의 연결
- complete가 owner id의 PROCESSING 행 하나만 COMPLETED와 응답 값으로 갱신한다.
코드 조각 9 · PROCESSING 한 행의 완료 update
@Transactional
public void complete(long id, int responseStatus, String responseBody) {
int updated = entityManager.createNativeQuery("""
UPDATE idempotency_request
SET status='COMPLETED', response_status=:responseStatus,
response_body=:responseBody, completed_at=:now
WHERE id=:id AND status='PROCESSING'
""")
.setParameter("responseStatus", responseStatus)
한 줄 읽기: transaction 안에서 지정 id가 아직 PROCESSING일 때만 status·HTTP 응답·body·완료 시각을 한 번에 갱신한다.
- 문법을 한 줄씩 풀면
- native UPDATE의 SET은 네 값을 바꾸고 WHERE id=:id AND status='PROCESSING'이 대상 상태를 제한하며 responseStatus parameter binding을 시작한다.
- 실제 값 추적
- id 7의 PROCESSING 행에 responseStatus 201과 body를 주면 COMPLETED, 201, body, now 값으로 바뀔 후보가 된다.
- 정상 예
- 존재하는 PROCESSING id 하나는 PK 조건과 status 조건을 모두 만족해 update count 1을 낼 수 있다.
- 반례·경계 예
- 이미 COMPLETED인 같은 id는 status predicate에서 제외돼 응답을 덮어쓰지 않고 update count 0이 된다.
- 착각 방지
- @Transactional은 idempotency_request update를 다른 service DB 변경과 자동으로 같은 transaction에 넣지 않는다. 실제 호출 경계와 propagation도 맞아야 한다.
- 이 블록이 하지 않는 일
- request_hash를 바꾸거나 id 대신 복합 key를 다시 확인하지 않는다.
- 다음 코드와의 연결
- body·now·id를 binding하고 executeUpdate의 실제 변경 행 수를 받는다.
코드 조각 10 · 완료 값 binding과 변경 행 수
.setParameter("responseBody", responseBody)
.setParameter("now", Instant.now())
.setParameter("id", id)
.executeUpdate();
한 줄 읽기: response body·application 시각·id를 묶고 UPDATE를 실행해 실제 변경 행 수를 updated에 저장한다.
- 문법을 한 줄씩 풀면
- 세 setParameter가 남은 placeholder를 채우고 executeUpdate()의 int 반환값이 matched-and-updated row count가 된다.
- 실제 값 추적
- PROCESSING id 하나가 맞으면 updated=1, 없는 id나 이미 완료된 id면 updated=0이다.
- 정상 예
- responseBody가 JSON 문자열이어도 TEXT parameter 한 값으로 binding되어 그대로 저장된다.
- 반례·경계 예
- responseBody가 null이면 schema column은 nullable이라 SQL 자체는 허용할 수 있지만 이 method는 내용 유효성을 검사하지 않는다.
- 착각 방지
- Instant.now()를 binding한 시각은 commit 완료 시각과 정확히 같다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- updated 값을 아직 판정하거나 실패 이유를 0행·다중행으로 구분하지 않는다.
- 다음 코드와의 연결
- 마지막 guard가 updated가 정확히 1이 아니면 상태 예외로 transaction을 실패시킨다.
코드 조각 11 · 완료 update 정확히 한 행 guard
if (updated != 1) throw new IllegalStateException("idempotency completion failed");
}
}
한 줄 읽기: 완료 UPDATE 결과가 1행이 아니면 IllegalStateException을 던지고 저장소 class를 닫는다.
- 문법을 한 줄씩 풀면
- updated != 1 조건의 단일-line throw가 0과 2 이상을 같은 실패 메시지로 묶는다.
- 실제 값 추적
- 정상 owner 완료는 updated 1이라 반환하고, 없는 id나 이미 완료된 id의 updated 0은 idempotency completion failed 예외가 된다.
- 정상 예
- PK id와 PROCESSING predicate가 한 행을 바꾼 경우 guard가 false라 complete가 정상 종료한다.
- 반례·경계 예
- 두 번째 complete 호출은 첫 호출이 이미 status를 COMPLETED로 바꿨다면 updated 0이 되어 예외다.
- 착각 방지
- 이 예외는 실패 원인이 없는 id인지 이미 완료인지 구분해 주지 않는다.
- 이 블록이 하지 않는 일
- 재시도, stale PROCESSING 회수, 기존 response 반환을 구현하지 않는다.
- 다음 코드와의 연결
- AtomicClaim50IT가 claim의 present 한 개·empty 49개와 bound actor 문자열을 실제 동시 fixture에서 검사한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/idempotency/IdempotencyStore.java
- 전제조건
- EntityManager, PostgreSQL ON CONFLICT/RETURNING, Spring transaction과 V001이 필요하다.
- 반드시 지킬 계약
- claim 단일 INSERT, named parameter5, empty=existing, find 복합 key, complete PROCESSING→COMPLETED updated1을 보존한다.
- 추천 입력 순서
- record/EntityManager → claim SQL/params/result → find SQL/map → complete SQL/rowcount 순서다.
- 자기 점검
- 50 claim이 present1/empty49, actor probe가 값으로 저장, complete updated!=1 예외인지 본다.
- 이번 파일의 범위 밖
- 업무 이체·stale PROCESSING 복구·response body 일반 JSON schema·분산 DB는 다루지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.idempotency;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public class IdempotencyStore {
public record Existing(String requestHash, String status, Integer responseStatus, String responseBody) {}
@PersistenceContext
private EntityManager entityManager;
@Transactional
public Optional<Long> claim(String scope, String actorId, String key, String requestHash) {
List<?> rows = entityManager.createNativeQuery("""
INSERT INTO idempotency_request
(scope, actor_id, idempotency_key, request_hash, status, created_at)
VALUES (:scope, :actorId, :key, :requestHash, 'PROCESSING', :now)
ON CONFLICT (scope, actor_id, idempotency_key) DO NOTHING
RETURNING id
""")
.setParameter("scope", scope)
.setParameter("actorId", actorId)
.setParameter("key", key)
.setParameter("requestHash", requestHash)
.setParameter("now", Instant.now())
.getResultList();
if (rows.isEmpty()) return Optional.empty();
return Optional.of(((Number) rows.getFirst()).longValue());
}
@Transactional(readOnly = true)
public Existing find(String scope, String actorId, String key) {
Object[] row = (Object[]) entityManager.createNativeQuery("""
SELECT request_hash, status, response_status, response_body
FROM idempotency_request
WHERE scope=:scope AND actor_id=:actorId AND idempotency_key=:key
""")
.setParameter("scope", scope)
.setParameter("actorId", actorId)
.setParameter("key", key)
.getSingleResult();
return new Existing(
(String) row[0],
(String) row[1],
row[2] == null ? null : ((Number) row[2]).intValue(),
(String) row[3]
);
}
@Transactional
public void complete(long id, int responseStatus, String responseBody) {
int updated = entityManager.createNativeQuery("""
UPDATE idempotency_request
SET status='COMPLETED', response_status=:responseStatus,
response_body=:responseBody, completed_at=:now
WHERE id=:id AND status='PROCESSING'
""")
.setParameter("responseStatus", responseStatus)
.setParameter("responseBody", responseBody)
.setParameter("now", Instant.now())
.setParameter("id", id)
.executeUpdate();
if (updated != 1) throw new IllegalStateException("idempotency completion failed");
}
}
6. AtomicClaim50IT
한 문장 역할: 같은 복합 key로 50개 claim을 동시에 보내 owner1·existing49·DB1행과 actor named parameter 저장을 확인한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 수요일 exact selector |
| 무엇을 받나 | task50, 같은 TRANSFER/customer-50/same-key/hash a×64; 별도 quote probe |
| 무엇이 바뀌나 | 동시 claim은 idempotency row1; probe test는 row1 추가 |
| 무엇을 돌려주나 | present owner1·empty49·count1, probe 이외 actor count0 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.idempotency;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
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 AtomicClaim50IT extends PostgresIntegrationTestSupport {
@Autowired IdempotencyStore store;
@Autowired JdbcClient jdbc;
@BeforeEach
void clean() {
jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update();
}
@Test
void fifty_concurrent_claims_have_one_owner_and_forty_nine_existing_results() throws Exception {
int tasks = 50;
var ready = new CountDownLatch(tasks);
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<Optional<Long>>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
return store.claim("TRANSFER", "customer-50", "same-key", "a".repeat(64));
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
int owners = 0;
int existing = 0;
for (Future<Optional<Long>> future : futures) {
if (future.get(30, TimeUnit.SECONDS).isPresent()) owners++; else existing++;
}
assertThat(owners).as("W12D3_RED_EXPECTED_ATOMIC_CLAIM")
.isEqualTo(1);
assertThat(existing).isEqualTo(49);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
} finally {
start.countDown();
pool.shutdownNow();
}
}
@Test
void named_parameters_do_not_turn_actor_text_into_sql() {
String probe = "owner' OR '1'='1";
assertThat(store.claim("TRANSFER", probe, "probe-key", "b".repeat(64))).isPresent();
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request WHERE actor_id <> :probe")
.param("probe", probe).query(Long.class).single()).isZero();
}
}
코드 조각 1 · PostgreSQL 동시 claim 시험 기반
package com.example.financialcore.idempotency;
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;
한 줄 읽기: PostgreSQL support, JUnit lifecycle, Spring context, 주입, JdbcClient 타입을 동시 claim 통합 시험에 연결한다.
- 문법을 한 줄씩 풀면
- base class와 @SpringBootTest가 integration 환경을 가리키고, BeforeEach·Test annotation과 Autowired·JdbcClient가 아래 fixture와 검증 코드를 지원한다.
- 실제 값 추적
- 이 import 구간에서는 thread나 SQL이 실행되지 않는다. class가 시작된 뒤 주입된 store와 jdbc가 같은 integration datasource를 사용한다.
- 정상 예
- migration이 적용된 PostgreSQL에서 IdempotencyStore claim과 직접 COUNT query를 한 test가 함께 관찰할 수 있다.
- 반례·경계 예
- PostgresIntegrationTestSupport가 빠져 test datasource가 준비되지 않으면 claim 경쟁을 보기 전에 context 구성이 실패할 수 있다.
- 착각 방지
- SpringBootTest import만으로 50개 호출이 동시에 실행되지는 않는다. 동시 출발은 아래 executor와 latch가 만든다.
- 이 블록이 하지 않는 일
- thread 수, timeout, 기대 owner 수를 아직 정하지 않는다.
- 다음 코드와의 연결
- ArrayList·Optional과 executor/latch/future/time unit 타입이 동시 작업의 자료 구조를 준비한다.
코드 조각 2 · 50개 작업의 latch·executor·future 도구
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
한 줄 읽기: 작업 목록과 Optional 결과, 동시 출발 latch, fixed pool, Future, timeout 단위를 가져온다.
- 문법을 한 줄씩 풀면
- java.util collection 세 타입과 java.util.concurrent의 CountDownLatch·Executors·Future·TimeUnit을 import한다.
- 실제 값 추적
- 아래 test는 Future<Optional<Long>> 50개를 List에 모으고 ready/start latch로 출발을 맞춘 뒤 초 단위 timeout으로 기다린다.
- 정상 예
- pool worker가 claim 결과를 Optional로 돌려주면 Future가 그 값을 test thread에 전달한다.
- 반례·경계 예
- Future.get timeout은 작업을 성공으로 바꾸지 않고 제한 시간 안에 끝나지 않으면 예외를 낸다.
- 착각 방지
- fixed thread pool 크기 50은 운영 처리량이나 공정성 보장이 아니라 이 fixture의 실행 자원 선택이다.
- 이 블록이 하지 않는 일
- 각 worker가 아직 어떤 key로 claim할지 또는 winner가 누구일지 결정하지 않는다.
- 다음 코드와의 연결
- AssertJ assertThat import가 latch·count·Optional 관찰값을 판정한다.
코드 조각 3 · AssertJ 값 검증 진입점
import static org.assertj.core.api.Assertions.assertThat;
한 줄 읽기: assertThat을 static import해 boolean, 정수, Optional, DB count를 같은 스타일의 chain으로 검사한다.
- 문법을 한 줄씩 풀면
- Assertions.assertThat 정적 메서드를 class qualifier 없이 호출하도록 한 줄 import한다.
- 실제 값 추적
- 뒤에서 ready.await 결과는 isTrue, owners·existing·COUNT는 isEqualTo, claim Optional은 isPresent와 연결된다.
- 정상 예
- 각 실제 값 type에 맞는 AssertJ assertion object가 선택된다.
- 반례·경계 예
- assertThat을 호출하지 않은 Future 결과는 test 성공 조건에 자동 포함되지 않는다.
- 착각 방지
- 이 import가 thread 예외를 수집하지 않는다. future.get이 예외를 test thread로 올린다.
- 이 블록이 하지 않는 일
- 기대값 1·49나 SQL을 이 줄에서 정의하지 않는다.
- 다음 코드와의 연결
- SpringBootTest class가 실제 PostgreSQL integration scope를 연다.
코드 조각 4 · AtomicClaim50 PostgreSQL test class
@SpringBootTest
class AtomicClaim50IT extends PostgresIntegrationTestSupport {
한 줄 읽기: AtomicClaim50IT를 full Spring context와 공통 PostgreSQL integration support 위에서 실행한다.
- 문법을 한 줄씩 풀면
- @SpringBootTest가 class-level context 구성을 표시하고 class는 PostgresIntegrationTestSupport를 상속한다.
- 실제 값 추적
- JUnit이 이 class의 두 test를 실행할 때 application bean과 실제 migration schema를 함께 사용할 환경이 열린다.
- 정상 예
- 저장소 native SQL과 JdbcClient 검증 query가 같은 PostgreSQL instance를 향한다.
- 반례·경계 예
- in-memory 대체 DB로 실행하면 PostgreSQL ON CONFLICT·RETURNING 동작을 같은 증거로 볼 수 없다.
- 착각 방지
- class 이름 AtomicClaim50은 production에서 항상 50개를 처리한다는 설정이 아니다.
- 이 블록이 하지 않는 일
- thread pool이나 clean fixture를 이 class header만으로 생성하지 않는다.
- 다음 코드와의 연결
- IdempotencyStore와 JdbcClient 두 bean을 field로 주입한다.
코드 조각 5 · claim 저장소와 검증 JDBC 주입
@Autowired IdempotencyStore store;
@Autowired JdbcClient jdbc;
한 줄 읽기: 동시 호출 대상 IdempotencyStore와 최종 DB count 조회용 JdbcClient를 Spring에서 주입받는다.
- 문법을 한 줄씩 풀면
- 두 @Autowired package-private field가 서로 다른 역할의 bean 참조를 test instance에 공급한다.
- 실제 값 추적
- worker thread는 store.claim을 호출하고 test thread는 jdbc로 TRUNCATE와 SELECT COUNT를 실행한다.
- 정상 예
- 두 field가 같은 application datasource 설정을 사용해 저장소 결과와 직접 SQL 관찰이 한 DB를 가리킨다.
- 반례·경계 예
- store를 mock으로 대체하면 Optional count는 만들 수 있어도 실제 composite UNIQUE 한 행 증거는 잃는다.
- 착각 방지
- JdbcClient를 별도로 주입했다고 독립 transaction에서 항상 최신 값을 못 본다는 뜻은 아니다. 실제 query 시점과 commit 완료가 중요하다.
- 이 블록이 하지 않는 일
- 각 worker에 별도 store instance를 만들거나 connection 하나를 thread마다 고정하지 않는다.
- 다음 코드와의 연결
- BeforeEach가 idempotency_request를 비워 각 경쟁을 0행에서 시작시킨다.
코드 조각 6 · 동시 claim 표 초기화
@BeforeEach
void clean() {
jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update();
}
한 줄 읽기: 각 test 전에 idempotency_request를 TRUNCATE하고 identity를 재시작한다.
- 문법을 한 줄씩 풀면
- @BeforeEach method가 JdbcClient DDL command를 update()로 실행한다.
- 실제 값 추적
- 50-way test와 actor probe test는 각각 시작할 때 요청 행 0개를 관찰한다.
- 정상 예
- 이전 test의 same-key나 probe-key가 남지 않아 첫 claim이 새 insert를 시도할 수 있다.
- 반례·경계 예
- cleanup이 실패하면 이전 owner 행 때문에 present count가 0이 되는 등 현재 경쟁 결과가 오염될 수 있다.
- 착각 방지
- RESTART IDENTITY가 executor, latch, Spring bean 상태까지 초기화하지는 않는다.
- 이 블록이 하지 않는 일
- 다른 세 business table을 truncate하거나 connection pool을 재시작하지 않는다.
- 다음 코드와의 연결
- 첫 test가 tasks 50, ready 50, start 1, thread pool 50과 Future 목록을 만든다.
코드 조각 7 · 50개 claim 작업과 두 단계 latch 준비
@Test
void fifty_concurrent_claims_have_one_owner_and_forty_nine_existing_results() throws Exception {
int tasks = 50;
var ready = new CountDownLatch(tasks);
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<Optional<Long>>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
한 줄 읽기: 작업 수 50, ready latch 50, start latch 1, thread pool 50, Future 목록을 만들고 50번 submit할 loop를 연다.
- 문법을 한 줄씩 풀면
- test는 checked concurrency 예외를 throws하고 try/finally 안에서 ArrayList<Future<Optional<Long>>>를 채우는 for loop를 구성한다.
- 실제 값 추적
- i=0..49마다 worker 하나가 pool에 제출될 자리를 만들며, start count가 1인 동안 실제 claim 호출은 뒤 lambda에서 대기한다.
- 정상 예
- pool 크기와 task 수가 모두 50이라 각 task가 ready 신호를 낼 worker slot을 가질 수 있다.
- 반례·경계 예
- pool 크기가 ready latch count보다 작고 worker가 start를 기다리면 아직 schedule되지 못한 task 때문에 ready가 0에 도달하지 않는 fixture deadlock이 생길 수 있다.
- 착각 방지
- try block을 열었다고 worker가 자동 정리되는 것은 아니다. finally의 shutdownNow가 필요하다.
- 이 블록이 하지 않는 일
- 이 span에서는 Future를 목록에 추가하기 전이며 DB claim도 아직 호출하지 않는다.
- 다음 코드와의 연결
- 각 submitted lambda가 ready를 내리고 공통 start 신호 뒤 같은 네 claim 값을 보낸다.
코드 조각 8 · 같은 key claim worker 50개 제출
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
return store.claim("TRANSFER", "customer-50", "same-key", "a".repeat(64));
}));
}
한 줄 읽기: 각 worker가 ready를 알리고 start를 기다린 뒤 동일한 scope·actor·key·hash로 store.claim을 호출한다.
- 문법을 한 줄씩 풀면
- pool.submit lambda가 Optional<Long>을 반환해 Future에 담기고, countDown은 준비 신호를 줄이며 await는 공통 release 전 실행을 막는다.
- 실제 값 추적
- 50개 lambda 모두 TRANSFER/customer-50/same-key/a×64를 사용하므로 PostgreSQL conflict target은 완전히 같다.
- 정상 예
- start가 열린 뒤 한 worker insert는 id를 반환하고 conflict를 만난 나머지 호출은 empty를 반환할 후보가 된다.
- 반례·경계 예
- worker마다 key를 다르게 만들면 50개가 서로 충돌하지 않아 owner 한 명 기대와 다른 시험이 된다.
- 착각 방지
- start latch는 CPU 명령이 정확히 같은 nanosecond에 실행됨을 보장하지 않고 공통 문턱만 제공한다.
- 이 블록이 하지 않는 일
- 어느 worker가 winner인지, 각 호출 latency, 업무 transfer 완료를 기록하지 않는다.
- 다음 코드와의 연결
- test thread가 ready 10초를 확인하고 start를 연 뒤 Future 결과를 owner와 existing으로 센다.
코드 조각 9 · ready 확인·동시 release·Optional 집계
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
int owners = 0;
int existing = 0;
for (Future<Optional<Long>> future : futures) {
if (future.get(30, TimeUnit.SECONDS).isPresent()) owners++; else existing++;
}
한 줄 읽기: 50 worker가 10초 안에 준비됐는지 확인하고 출발시킨 뒤 각 Future를 최대 30초 기다려 present와 empty를 센다.
- 문법을 한 줄씩 풀면
- ready.await의 boolean을 isTrue로 검사하고 start.countDown으로 문턱을 열며 enhanced for에서 Future.get 결과 Optional의 present 여부로 두 counter를 증가시킨다.
- 실제 값 추적
- 준비 성공 후 50개 Future 각각이 Optional<Long>을 내고 present면 owners++, empty면 existing++가 되어 두 합은 회수된 결과 50개다.
- 정상 예
- 모든 claim이 끝나면 loop가 50번 완료되고 다음 assertion이 owners와 existing 정확값을 비교한다.
- 반례·경계 예
- 한 Future가 30초 안에 끝나지 않으면 get이 timeout 예외를 던져 count assertion까지 도달하지 않는다.
- 착각 방지
- 30초는 전체 50개를 위한 하나의 global deadline이 아니라 loop에서 각 Future.get 호출에 적용되는 상한이다.
- 이 블록이 하지 않는 일
- present Optional 안의 id들이 DB row id와 같은지 또는 empty 호출이 어떤 status를 읽었는지 검사하지 않는다.
- 다음 코드와의 연결
- owners 1, existing 49, DB 전체 행 1을 exact assertion하고 finally에서 pool을 정리한다.
코드 조각 10 · owner 1·existing 49·DB 1행 assertion
assertThat(owners).as("W12D3_RED_EXPECTED_ATOMIC_CLAIM")
.isEqualTo(1);
assertThat(existing).isEqualTo(49);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
} finally {
start.countDown();
pool.shutdownNow();
}
}
한 줄 읽기: Optional 집계가 1 대 49이고 idempotency_request 전체 행도 1인지 확인한 뒤 어떤 종료 경로에서도 latch와 pool을 정리한다.
- 문법을 한 줄씩 풀면
- 세 AssertJ chain이 owners, existing, scalar COUNT를 각각 exact 비교하고 finally가 start.countDown과 shutdownNow를 항상 실행한다.
- 실제 값 추적
- 정상 경쟁은 owners=1, existing=49, SELECT COUNT(*)=1을 만들며 assertion 실패나 예외가 있어도 blocked worker release와 interrupt 요청이 수행된다.
- 정상 예
- 한 INSERT만 RETURNING id를 받고 나머지 49개가 conflict empty를 받으면 세 DB·Java 관찰이 일치한다.
- 반례·경계 예
- 두 owner가 present를 받으면 첫 assertion이 2 대 1로 실패하며, 행 수가 1이어도 Optional 분포 오류를 숨기지 못한다.
- 착각 방지
- 이 fixture 성공은 모든 schedule에서 deadlock이 절대 없거나 production 처리량이 충분하다는 증명이 아니다.
- 이 블록이 하지 않는 일
- winner가 business effect를 완료했는지, stale PROCESSING을 회수하는지, retry가 있는지 확인하지 않는다.
- 다음 코드와의 연결
- 둘째 test가 따옴표가 든 actor 문자열이 SQL 구문이 아니라 bound value로 저장되는지 본다.
코드 조각 11 · 따옴표 포함 actor를 bound value로 claim
@Test
void named_parameters_do_not_turn_actor_text_into_sql() {
String probe = "owner' OR '1'='1";
assertThat(store.claim("TRANSFER", probe, "probe-key", "b".repeat(64))).isPresent();
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request WHERE actor_id <> :probe")
한 줄 읽기: SQL처럼 보이는 따옴표 문자열을 actor로 claim해 새 행 id가 present인지 확인하고 다른 actor 행 count query를 준비한다.
- 문법을 한 줄씩 풀면
- probe String에는 작은따옴표와 OR text가 들어가며, 첫 assertion은 store.claim Optional을 isPresent로 검사하고 둘째 JdbcClient query는 :probe placeholder를 둔다.
- 실제 값 추적
- 빈 표에서 actor 값 owner' OR '1'='1, key probe-key, hash b×64가 한 parameter 값으로 insert되어 claim 결과가 present여야 한다.
- 정상 예
- IdempotencyStore의 named binding은 probe 전체를 actor_id 값 하나로 전달하므로 SQL 구조가 바뀌지 않는다.
- 반례·경계 예
- 문자열을 SQL에 직접 이어 붙이는 구현이라면 따옴표가 문장 구조에 개입할 위험이 있지만 현재 claim SQL은 setParameter를 쓴다.
- 착각 방지
- 이 한 probe가 가능한 모든 SQL injection payload와 application query를 전수 검사하지 않는다.
- 이 블록이 하지 않는 일
- HTTP 입력 validation, actor 길이 초과, 다른 repository의 parameter 사용을 검증하지 않는다.
- 다음 코드와의 연결
- 마지막 구간이 SELECT의 :probe도 binding해 probe와 다른 actor 행이 0인지 확인한다.
코드 조각 12 · probe와 다른 actor 행 0 assertion
.param("probe", probe).query(Long.class).single()).isZero();
}
}
한 줄 읽기: SELECT의 :probe를 같은 문자열로 binding하고 actor_id가 probe와 다른 행 수가 0인지 확인한다.
- 문법을 한 줄씩 풀면
- JdbcClient param이 named placeholder에 probe 값을 묶고 scalar Long query 뒤 isZero가 결과를 판정한다.
- 실제 값 추적
- 앞 claim이 만든 유일한 행의 actor_id가 probe와 같으면 WHERE actor_id <> :probe에 맞는 행이 없어 COUNT 0이다.
- 정상 예
- 빈 fixture에서 present claim 한 건과 다른 actor count 0이 함께 성립한다.
- 반례·경계 예
- probe가 SQL syntax로 해석되거나 다른 actor 값으로 저장됐다면 이 query 관찰이 기대와 달라질 수 있다.
- 착각 방지
- COUNT 0은 table 전체가 0행이라는 뜻이 아니다. probe와 다른 actor 행만 세지 않았다는 뜻이다.
- 이 블록이 하지 않는 일
- 저장된 scope·key·hash·status exact 값이나 table 전체 COUNT 1을 이 assertion에서 직접 비교하지 않는다.
- 다음 코드와의 연결
- 다음 canonical 파일 ErrorCode가 idempotency conflict와 in-progress 상태의 API 오류 이름을 선언한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/idempotency/AtomicClaim50IT.java
- 전제조건
- 실제 PostgreSQL, SpringBootTest, IdempotencyStore와 pool50 실행 자원이 필요하다.
- 반드시 지킬 계약
- ready50/start1, ready10초, Future별30초, owner1/existing49/row1, quote probe 저장을 보존한다.
- 추천 입력 순서
- import → clean → 50 Future barrier → 결과 bucket/DB count → named parameter probe 순서다.
- 자기 점검
- Future50, owners1, existing49, DB1, probe present, actor<>probe count0을 대조한다.
- 이번 파일의 범위 밖
- 업무 effect once, replay body, PROCESSING recovery, 모든 SQL injection 입력은 증명하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.idempotency;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
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 AtomicClaim50IT extends PostgresIntegrationTestSupport {
@Autowired IdempotencyStore store;
@Autowired JdbcClient jdbc;
@BeforeEach
void clean() {
jdbc.sql("TRUNCATE idempotency_request RESTART IDENTITY").update();
}
@Test
void fifty_concurrent_claims_have_one_owner_and_forty_nine_existing_results() throws Exception {
int tasks = 50;
var ready = new CountDownLatch(tasks);
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<Optional<Long>>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
return store.claim("TRANSFER", "customer-50", "same-key", "a".repeat(64));
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
int owners = 0;
int existing = 0;
for (Future<Optional<Long>> future : futures) {
if (future.get(30, TimeUnit.SECONDS).isPresent()) owners++; else existing++;
}
assertThat(owners).as("W12D3_RED_EXPECTED_ATOMIC_CLAIM")
.isEqualTo(1);
assertThat(existing).isEqualTo(49);
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request")
.query(Long.class).single()).isEqualTo(1);
} finally {
start.countDown();
pool.shutdownNow();
}
}
@Test
void named_parameters_do_not_turn_actor_text_into_sql() {
String probe = "owner' OR '1'='1";
assertThat(store.claim("TRANSFER", probe, "probe-key", "b".repeat(64))).isPresent();
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request WHERE actor_id <> :probe")
.param("probe", probe).query(Long.class).single()).isZero();
}
}
목 · replay·conflict·rollback transaction
7. ErrorCode
한 문장 역할: 업무 실패 이름 집합에 IDEMPOTENCY_CONFLICT와 IDEMPOTENCY_IN_PROGRESS를 포함한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | BusinessException, TransferService, ApiExceptionHandler |
| 무엇을 받나 | 코드가 참조할 enum 상수 이름 |
| 무엇이 바뀌나 | runtime state나 DB를 바꾸지 않음 |
| 무엇을 돌려주나 | 컴파일 시 제한된 ErrorCode 값 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.api;
public enum ErrorCode {
INVALID_REQUEST,
ACCOUNT_NOT_FOUND,
DUPLICATE_ACCOUNT,
INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT,
IDEMPOTENCY_IN_PROGRESS,
ACCESS_DENIED,
INTERNAL_ERROR
}
코드 조각 1 · API 오류 이름 여섯 개와 enum 시작
package com.example.financialcore.api;
public enum ErrorCode {
INVALID_REQUEST,
ACCOUNT_NOT_FOUND,
DUPLICATE_ACCOUNT,
INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT,
IDEMPOTENCY_IN_PROGRESS,
한 줄 읽기: HTTP 오류 응답과 BusinessException이 함께 쓸 안정된 오류 이름 여섯 개를 enum 값으로 선언한다.
- 문법을 한 줄씩 풀면
- enum 본문에서 쉼표로 나열한 각 식별자는 ErrorCode 타입의 한 상수다. package 줄은 이 타입의 전체 이름을 com.example.financialcore.api.ErrorCode로 정한다.
- 실제 값 추적
- 예를 들어 멱등 키가 같은데 요청 의미가 다르면 서비스가 IDEMPOTENCY_CONFLICT를 담을 수 있고, 처리 중인 같은 키라면 IDEMPOTENCY_IN_PROGRESS를 담을 수 있다. 이 구간은 이름만 만들며 상태 숫자는 아직 정하지 않는다.
- 정상 예
- 다른 코드가 ErrorCode.INVALID_REQUEST처럼 오타 없는 제한된 값 하나를 선택하고 switch가 그 상수를 HTTP 상태로 번역한다.
- 반례·경계 예
- IDEMPOTENCY_CONFLICT 문자열을 임의로 직접 적으면 컴파일러가 철자를 검사하지 못하지만 enum 상수를 잘못 적으면 컴파일 단계에서 막힌다.
- 착각 방지
- 상수 순서가 HTTP 400·404·409 순서를 뜻하지 않는다. 실제 상태 매핑은 ApiExceptionHandler의 switch가 별도로 결정한다.
- 이 블록이 하지 않는 일
- 예외를 던지거나 응답 JSON을 만들거나 DB 제약 위반 원인을 판별하지 않는다.
- 다음 코드와의 연결
- 마지막 두 상수 ACCESS_DENIED와 INTERNAL_ERROR를 더한 뒤 enum 범위를 닫는다.
코드 조각 2 · 권한 거부·서버 오류 상수와 enum 종료
ACCESS_DENIED,
INTERNAL_ERROR
}
한 줄 읽기: ACCESS_DENIED와 INTERNAL_ERROR를 오류 목록에 더하고 ErrorCode 선언을 끝낸다.
- 문법을 한 줄씩 풀면
- 마지막 enum 상수는 뒤에 쉼표나 세미콜론 없이 올 수 있고, 닫는 중괄호가 타입 범위를 끝낸다.
- 실제 값 추적
- 권한 예외를 번역할 때 선택할 값은 ACCESS_DENIED, 예상하지 못한 RuntimeException에 쓸 값은 INTERNAL_ERROR다.
- 정상 예
- handler가 각각 ErrorCode.ACCESS_DENIED와 ErrorCode.INTERNAL_ERROR를 ApiError의 errorCode 문자열로 보낸다.
- 반례·경계 예
- ACCESS_DENIED를 목록에서 지우면 handler의 해당 상수 참조가 컴파일되지 않는다.
- 착각 방지
- INTERNAL_ERROR가 있다고 모든 RuntimeException의 원인을 안전하게 복구하거나 기록해 주는 것은 아니다.
- 이 블록이 하지 않는 일
- 로그를 남기지 않고 requestId를 만들지 않으며 예외 메시지를 숨기는 정책도 여기서는 실행하지 않는다.
- 다음 코드와의 연결
- ApiExceptionHandler가 이 여덟 상수를 실제 HTTP status·ApiError body와 연결한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/api/ErrorCode.java
- 전제조건
- Java enum과 이를 참조하는 exception/handler가 필요하다.
- 반드시 지킬 계약
- 기존 여섯 코드와 W12 두 멱등 코드를 exact 이름으로 보존한다.
- 추천 입력 순서
- package → enum 선언 → 여덟 상수 순서다.
- 자기 점검
- IDEMPOTENCY_CONFLICT/IN_PROGRESS 철자와 comma, 마지막 INTERNAL_ERROR를 대조한다.
- 이번 파일의 범위 밖
- HTTP status·message·rollback·재시도 정책은 enum이 결정하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.api;
public enum ErrorCode {
INVALID_REQUEST,
ACCOUNT_NOT_FOUND,
DUPLICATE_ACCOUNT,
INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT,
IDEMPOTENCY_IN_PROGRESS,
ACCESS_DENIED,
INTERNAL_ERROR
}
8. ApiExceptionHandler
한 문장 역할: exception type과 ErrorCode를 안정된 HTTP status/ApiError로 바꾸며 두 멱등 오류를 409에 매핑한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | Spring MVC exception resolution |
| 무엇을 받나 | BusinessException·validation·constraint·access denied·RuntimeException과 request |
| 무엇이 바뀌나 | DB는 바꾸지 않고 response 객체만 생성 |
| 무엇을 돌려주나 | status, stable code/message, requestId를 담은 ResponseEntity<ApiError> |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public final class ApiExceptionHandler {
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException failure, HttpServletRequest request) {
HttpStatus status = switch (failure.code()) {
case INVALID_REQUEST -> HttpStatus.BAD_REQUEST;
case ACCOUNT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACCESS_DENIED -> HttpStatus.FORBIDDEN;
case DUPLICATE_ACCOUNT, INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS -> HttpStatus.CONFLICT;
case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
};
return response(status, failure.code(), failure.getMessage(), request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> validation(MethodArgumentNotValidException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request validation failed", request);
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> illegalArgument(IllegalArgumentException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request is invalid", request);
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiError> constraint(DataIntegrityViolationException ignored, HttpServletRequest request) {
return response(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_ACCOUNT, "resource already exists", request);
}
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ApiError> denied(AccessDeniedException ignored, HttpServletRequest request) {
return response(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, "access denied", request);
}
@ExceptionHandler(RuntimeException.class)
ResponseEntity<ApiError> unexpected(RuntimeException ignored, HttpServletRequest request) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR,
"unexpected server error", request);
}
private static ResponseEntity<ApiError> response(
HttpStatus status, ErrorCode code, String message, HttpServletRequest request
) {
return ResponseEntity.status(status)
.body(new ApiError(code.name(), message, RequestIdFilter.current(request)));
}
}
코드 조각 1 · 예외 번역에 필요한 요청·HTTP·Spring 타입 연결
package com.example.financialcore.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
한 줄 읽기: 요청 객체, 세 Spring 예외 타입, HTTP 상태·응답, 두 advice annotation의 타입 이름을 준비한다.
- 문법을 한 줄씩 풀면
- 각 import는 아래 짧은 타입 이름을 실제 package 선언에 연결한다. ExceptionHandler는 메서드 선택 annotation이고 RestControllerAdvice는 여러 controller에 적용할 advice 표시다.
- 실제 값 추적
- request는 requestId 조회에, HttpStatus·ResponseEntity는 응답 조립에 쓰인다. 세 Spring 예외 타입은 서로 다른 handler 입구가 된다.
- 정상 예
- 모든 import가 있으면 아래 annotation, parameter, return type을 짧은 이름으로 컴파일할 수 있다.
- 반례·경계 예
- AccessDeniedException import만 제거하면 denied 메서드의 annotation과 parameter 타입을 찾지 못해 컴파일이 실패한다.
- 착각 방지
- import 줄이 예외를 잡거나 HTTP 응답을 보내는 것은 아니다. 실제 선택은 annotation이 붙은 메서드에서 일어난다.
- 이 블록이 하지 않는 일
- 어떤 URL도 호출하지 않고 Spring context를 시작하지 않으며 상태 코드나 JSON 값을 아직 만들지 않는다.
- 다음 코드와의 연결
- @RestControllerAdvice가 이 클래스를 controller 예외 번역 후보로 표시한다.
코드 조각 2 · 모든 REST controller에 적용할 advice 타입
@RestControllerAdvice
public final class ApiExceptionHandler {
한 줄 읽기: ApiExceptionHandler를 REST controller의 예외를 응답 body로 번역할 전역 advice 후보로 선언한다.
- 문법을 한 줄씩 풀면
- @RestControllerAdvice는 @ControllerAdvice와 response-body 동작을 합친 type annotation이고, final class는 이 handler 타입의 상속을 막는다.
- 실제 값 추적
- Spring이 이 타입을 발견하면 아래 @ExceptionHandler 메서드들을 예외 종류별 후보로 등록한다. 이 세 줄 자체가 요청을 처리하지는 않는다.
- 정상 예
- controller 호출 중 아래에 등록된 예외가 밖으로 나오면 가장 맞는 handler 메서드가 ResponseEntity를 만든다.
- 반례·경계 예
- @RestControllerAdvice를 제거하고 별도 등록도 하지 않으면 아래 메서드는 평범한 package-private 메서드로 남아 전역 번역 후보가 아니다.
- 착각 방지
- final은 예외가 더 이상 전파되지 않는다는 뜻이 아니라 subclass 생성을 막는 Java 제한이다.
- 이 블록이 하지 않는 일
- filter에서 requestId를 생성하거나 인증·권한 검사를 수행하지 않는다.
- 다음 코드와의 연결
- 첫 handler가 BusinessException의 ErrorCode를 HTTP status로 바꾸는 switch를 연다.
코드 조각 3 · BusinessException 오류 코드별 HTTP 상태 선택
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException failure, HttpServletRequest request) {
HttpStatus status = switch (failure.code()) {
case INVALID_REQUEST -> HttpStatus.BAD_REQUEST;
case ACCOUNT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACCESS_DENIED -> HttpStatus.FORBIDDEN;
case DUPLICATE_ACCOUNT, INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS -> HttpStatus.CONFLICT;
case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
};
한 줄 읽기: BusinessException 안의 ErrorCode를 읽어 400·404·403·409·500 중 하나의 HttpStatus로 바꾼다.
- 문법을 한 줄씩 풀면
- @ExceptionHandler(BusinessException.class)는 이 메서드의 대상 타입을 지정하고, switch expression은 모든 ErrorCode case가 HttpStatus 값을 yield하도록 강제한다.
- 실제 값 추적
- INVALID_REQUEST→400, ACCOUNT_NOT_FOUND→404, ACCESS_DENIED→403, 네 conflict 계열→409, INTERNAL_ERROR→500이다.
- 정상 예
- failure.code()가 IDEMPOTENCY_CONFLICT이면 status 변수는 CONFLICT, 즉 HTTP 409가 된다.
- 반례·경계 예
- IDEMPOTENCY_IN_PROGRESS도 현재는 409다. 처리 중이라는 이름만 보고 자동으로 202나 425가 선택되지 않는다.
- 착각 방지
- 이 switch는 DB 제약 이름이나 exception message를 분석하지 않는다. 이미 BusinessException에 담긴 code만 읽는다.
- 이 블록이 하지 않는 일
- 아직 ResponseEntity나 ApiError를 반환하지 않고 requestId도 조회하지 않는다.
- 다음 코드와의 연결
- 선택한 status와 원래 code·message·request를 공통 response helper에 넘긴다.
코드 조각 4 · BusinessException의 code·message를 공통 응답으로 전달
return response(status, failure.code(), failure.getMessage(), request);
}
한 줄 읽기: 선택한 status와 예외의 code·message, 현재 request를 공통 response helper에 그대로 넘겨 반환한다.
- 문법을 한 줄씩 풀면
- return은 helper가 만든 ResponseEntity<ApiError>를 추가 가공 없이 호출자에게 돌려주며 failure.getMessage()는 RuntimeException에 저장된 message를 읽는다.
- 실제 값 추적
- 예를 들어 IDEMPOTENCY_CONFLICT와 message 'idempotency key reused with different request'가 들어오면 앞서 고른 409와 두 값, request가 helper 입력이 된다.
- 정상 예
- BusinessException 생성 때 넣은 ErrorCode와 message가 body의 errorCode·message로 이어진다.
- 반례·경계 예
- failure.getMessage()가 null이면 이 메서드가 새 기본 문구를 채우지 않는다. null이 helper로 그대로 간다.
- 착각 방지
- request를 넘긴다고 전체 HttpServletRequest가 응답 body에 직렬화되는 것은 아니다. helper는 그 안의 requestId만 읽는다.
- 이 블록이 하지 않는 일
- 예외를 다시 던지거나 transaction을 rollback시키는 코드를 실행하지 않는다.
- 다음 코드와의 연결
- validation handler는 구체 field 오류 대신 고정 400·INVALID_REQUEST 문구를 선택한다.
코드 조각 5 · Bean Validation 실패를 고정 400으로 번역
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> validation(MethodArgumentNotValidException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request validation failed", request);
}
한 줄 읽기: MethodArgumentNotValidException을 받으면 field 세부 내용 대신 400·INVALID_REQUEST·고정 문구를 반환한다.
- 문법을 한 줄씩 풀면
- ExceptionHandler 대상과 메서드 parameter 타입이 같고, ignored 이름은 예외 객체의 세부 field를 본문에서 사용하지 않음을 드러낸다.
- 실제 값 추적
- @Valid request body 검증이 이 예외로 나오면 status=BAD_REQUEST, code=INVALID_REQUEST, message='request validation failed'가 helper로 간다.
- 정상 예
- openingBalance나 accountNo 검증이 실패해 이 예외가 선택되면 클라이언트는 HTTP 400 계열 ApiError를 받는다.
- 반례·경계 예
- 깨진 JSON 문법이나 숫자 type 변환 실패가 반드시 MethodArgumentNotValidException이라는 보장은 없다. 그 경우 이 메서드가 선택되지 않을 수 있다.
- 착각 방지
- ignored라고 validation을 무시한 것이 아니다. 검증은 이미 실패했고 이 handler가 예외 상세만 읽지 않는다.
- 이 블록이 하지 않는 일
- 어느 field가 왜 틀렸는지, rejected value가 무엇인지 body에 넣지 않는다.
- 다음 코드와의 연결
- 서비스·도메인이 직접 던진 IllegalArgumentException은 별도의 400 문구로 번역한다.
코드 조각 6 · IllegalArgumentException을 일반 입력 오류 400으로 번역
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> illegalArgument(IllegalArgumentException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request is invalid", request);
}
한 줄 읽기: IllegalArgumentException을 400·INVALID_REQUEST·'request is invalid' 응답으로 바꾼다.
- 문법을 한 줄씩 풀면
- annotation의 class literal과 parameter 타입이 처리 대상을 고정하고, response helper 호출은 이 경로의 세 상수를 한 줄에서 정한다.
- 실제 값 추적
- 예를 들어 양수가 아닌 송금 amount 때문에 서비스가 IllegalArgumentException을 던지면 이 handler가 선택될 수 있고 고정 문구가 body로 간다.
- 정상 예
- controller 호출 경계 밖으로 나온 IllegalArgumentException은 HTTP 400 ResponseEntity<ApiError>로 번역된다.
- 반례·경계 예
- BusinessException은 IllegalArgumentException의 subclass가 아니므로 이 메서드 대신 앞의 business handler가 code별 상태를 고른다.
- 착각 방지
- 모든 IllegalArgumentException이 실제로 클라이언트 잘못이라는 사실을 이 코드가 증명하지는 않는다. 현재 정책이 그렇게 분류할 뿐이다.
- 이 블록이 하지 않는 일
- 원래 exception message를 노출하지 않고 어느 parameter가 잘못됐는지도 기록하지 않는다.
- 다음 코드와의 연결
- DB constraint 계열 DataIntegrityViolationException은 409·DUPLICATE_ACCOUNT로 묶는다.
코드 조각 7 · DB 무결성 위반을 중복 자원 409로 번역
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiError> constraint(DataIntegrityViolationException ignored, HttpServletRequest request) {
return response(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_ACCOUNT, "resource already exists", request);
}
한 줄 읽기: DataIntegrityViolationException을 409·DUPLICATE_ACCOUNT·고정 중복 문구로 바꾼다.
- 문법을 한 줄씩 풀면
- Spring DAO 예외 타입을 전용 @ExceptionHandler 대상으로 두고 CONFLICT status와 ErrorCode.DUPLICATE_ACCOUNT를 helper에 넘긴다.
- 실제 값 추적
- 예를 들어 account unique constraint 위반이 이 Spring 예외로 번역돼 올라오면 HTTP 409와 'resource already exists'가 만들어진다.
- 정상 예
- 중복 계좌 저장처럼 이 정책이 예상한 무결성 위반은 클라이언트가 구분 가능한 409 응답이 된다.
- 반례·경계 예
- foreign key·not-null·다른 unique constraint도 같은 DataIntegrityViolationException이면 전부 DUPLICATE_ACCOUNT로 보일 수 있다.
- 착각 방지
- 이 메서드는 constraint 이름을 확인해 정말 계좌 중복인지 판별하지 않는다.
- 이 블록이 하지 않는 일
- DB write를 재시도하거나 이미 열린 transaction을 복구하지 않으며 원본 SQL 오류를 body에 노출하지 않는다.
- 다음 코드와의 연결
- Spring Security의 AccessDeniedException은 403·ACCESS_DENIED로 분리한다.
코드 조각 8 · 권한 거부를 403·ACCESS_DENIED로 번역
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ApiError> denied(AccessDeniedException ignored, HttpServletRequest request) {
return response(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, "access denied", request);
}
한 줄 읽기: AccessDeniedException을 HTTP 403과 ACCESS_DENIED·고정 문구로 바꾼다.
- 문법을 한 줄씩 풀면
- Spring Security의 AccessDeniedException class literal이 handler 선택 범위를 정하고 HttpStatus.FORBIDDEN이 상태 403을 나타낸다.
- 실제 값 추적
- 인증 정보는 있지만 현재 작업 권한이 없어 이 예외가 올라오면 code=ACCESS_DENIED, message='access denied'와 requestId가 응답된다.
- 정상 예
- 권한 거부가 controller 밖으로 전파되면 내부 stack trace 대신 안정된 403 ApiError body를 돌려준다.
- 반례·경계 예
- 인증 자체가 없는 요청이 어떤 예외·entry point로 처리되는지는 이 AccessDeniedException handler만으로 정해지지 않는다.
- 착각 방지
- 403을 반환한다고 이 메서드가 소유권이나 role을 직접 검사한 것은 아니다. 검사는 앞 단계가 수행한다.
- 이 블록이 하지 않는 일
- 사용자를 로그인시키거나 권한을 부여하거나 SecurityContext를 수정하지 않는다.
- 다음 코드와의 연결
- 아직 분류되지 않은 RuntimeException은 내부 문구를 숨긴 500으로 모은다.
코드 조각 9 · 남은 RuntimeException을 안전한 500 문구로 축약
@ExceptionHandler(RuntimeException.class)
ResponseEntity<ApiError> unexpected(RuntimeException ignored, HttpServletRequest request) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR,
"unexpected server error", request);
}
한 줄 읽기: 더 구체적인 handler에 걸리지 않은 RuntimeException을 500·INTERNAL_ERROR·고정 문구로 바꾼다.
- 문법을 한 줄씩 풀면
- RuntimeException은 넓은 상위 타입이므로 구체 handler가 선택되지 않은 unchecked 예외의 fallback이 되고, response 호출은 세 줄로 나뉘어도 인자 하나의 목록이다.
- 실제 값 추적
- 예상하지 못한 NullPointerException 등이 여기로 오면 status=500, code=INTERNAL_ERROR, message='unexpected server error'가 helper에 전달된다.
- 정상 예
- 내부 예외의 실제 message 대신 고정 문구를 사용해 응답 형식을 유지한다.
- 반례·경계 예
- Error나 checked exception까지 모두 잡는 catch-all은 아니다. annotation 대상은 RuntimeException 하나다.
- 착각 방지
- ignored parameter가 로그에도 절대 남지 않는다는 보장은 아니다. 이 메서드 본문이 원본 예외를 사용하지 않는다는 사실만 보인다.
- 이 블록이 하지 않는 일
- 예외 원인을 고치거나 재시도하거나 transaction rollback 규칙을 선언하지 않는다.
- 다음 코드와의 연결
- 공통 response helper가 status·code·message에 현재 requestId를 더해 최종 ResponseEntity를 만든다.
코드 조각 10 · 공통 ResponseEntity 조립 helper 입구
private static ResponseEntity<ApiError> response(
HttpStatus status, ErrorCode code, String message, HttpServletRequest request
) {
return ResponseEntity.status(status)
한 줄 읽기: status·ErrorCode·message·request 네 값을 받아 해당 status의 ResponseEntity builder를 시작한다.
- 문법을 한 줄씩 풀면
- private static 메서드는 instance 상태 없이 호출된다. 네 parameter 타입을 고정하고 ResponseEntity.status(status)로 body 전 builder를 만든다.
- 실제 값 추적
- 예를 들어 409, IDEMPOTENCY_CONFLICT, 충돌 문구, 현재 request가 들어오면 builder의 HTTP status가 먼저 409로 정해진다.
- 정상 예
- 앞의 여섯 handler가 같은 helper를 사용하므로 상태 선택은 달라도 body shape 조립은 한 곳으로 모인다.
- 반례·경계 예
- status와 ErrorCode를 서로 다르게 넘길 수 있는 구조라 500+INVALID_REQUEST 같은 조합도 컴파일은 된다. 호출부 정책이 일관성을 지켜야 한다.
- 착각 방지
- static helper라고 thread마다 새 전역 상태를 보관하는 것은 아니다. 전달받은 값으로 builder를 만들 뿐이다.
- 이 블록이 하지 않는 일
- 이 구간만으로 body를 완성하지 않고 request header를 직접 읽지도 않는다.
- 다음 코드와의 연결
- 마지막 body 호출이 code.name·message·RequestIdFilter.current(request)를 ApiError 세 칸에 넣는다.
코드 조각 11 · ApiError 세 필드와 requestId를 붙여 응답 완성
.body(new ApiError(code.name(), message, RequestIdFilter.current(request)));
}
}
한 줄 읽기: ErrorCode 이름·message·현재 requestId로 ApiError를 만들고 앞서 정한 HTTP status의 응답 body로 반환한다.
- 문법을 한 줄씩 풀면
- code.name()은 enum 식별자를 String으로 바꾸고, new ApiError(...)는 record의 errorCode·message·requestId 순서로 값을 넣는다. body 호출이 ResponseEntity<ApiError>를 완성한다.
- 실제 값 추적
- code가 IDEMPOTENCY_CONFLICT면 같은 문자열이 body에 간다. current(request)는 filter 값이 있으면 그 값을, 없으면 'unavailable'을 준다.
- 정상 예
- filter를 정상 통과한 요청은 응답 header와 같은 requestId를 ApiError body에서도 추적할 수 있다.
- 반례·경계 예
- handler를 filter 밖에서 직접 호출해 request attribute가 없으면 requestId가 새 UUID로 만들어지지 않고 'unavailable'이 된다.
- 착각 방지
- code.name()은 사용자 친화 번역문이 아니라 Java enum 상수 철자를 그대로 노출한다.
- 이 블록이 하지 않는 일
- 응답을 이미 전송하거나 로그를 기록하지 않으며 requestId의 진위·중복 여부를 검증하지 않는다.
- 다음 코드와의 연결
- 이 파일의 전체 정답 뒤에는 직접 다시 쓰기에서 예외별 상태·문구·requestId 순서를 처음부터 재현한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/api/ApiExceptionHandler.java
- 전제조건
- Spring MVC/Security, ApiError, ErrorCode, RequestIdFilter가 필요하다.
- 반드시 지킬 계약
- 구체 handler 우선, business switch의 두 멱등 409, safe message, requestId helper를 보존한다.
- 추천 입력 순서
- imports/advice → business switch → validation/illegal/constraint/denied/unexpected → response helper 순서다.
- 자기 점검
- CONFLICT/IN_PROGRESS 둘 다 409 코드 mapping은 존재하되 월~토 HTTP test가 직접 증명하지 않는다고 구분한다.
- 이번 파일의 범위 밖
- transaction rollback·constraint 원인 세분화·retry·로그 저장은 하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public final class ApiExceptionHandler {
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException failure, HttpServletRequest request) {
HttpStatus status = switch (failure.code()) {
case INVALID_REQUEST -> HttpStatus.BAD_REQUEST;
case ACCOUNT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACCESS_DENIED -> HttpStatus.FORBIDDEN;
case DUPLICATE_ACCOUNT, INSUFFICIENT_BALANCE,
IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS -> HttpStatus.CONFLICT;
case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR;
};
return response(status, failure.code(), failure.getMessage(), request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> validation(MethodArgumentNotValidException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request validation failed", request);
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> illegalArgument(IllegalArgumentException ignored, HttpServletRequest request) {
return response(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST, "request is invalid", request);
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiError> constraint(DataIntegrityViolationException ignored, HttpServletRequest request) {
return response(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_ACCOUNT, "resource already exists", request);
}
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ApiError> denied(AccessDeniedException ignored, HttpServletRequest request) {
return response(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, "access denied", request);
}
@ExceptionHandler(RuntimeException.class)
ResponseEntity<ApiError> unexpected(RuntimeException ignored, HttpServletRequest request) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR,
"unexpected server error", request);
}
private static ResponseEntity<ApiError> response(
HttpStatus status, ErrorCode code, String message, HttpServletRequest request
) {
return ResponseEntity.status(status)
.body(new ApiError(code.name(), message, RequestIdFilter.current(request)));
}
}
9. TransferFailureHook
한 문장 역할: claim 직후와 업무 변경 직후에 테스트 예외를 주입할 두 지점을 제공하고 production에서는 NONE no-op을 쓴다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | TransferService와 test 전용 ControlledFailureHook |
| 무엇을 받나 | 두 callback 시점; 별도 인자 없음 |
| 무엇이 바뀌나 | default/NONE은 아무것도 바꾸지 않고 test override만 예외 가능 |
| 무엇을 돌려주나 | void 정상 반환 또는 test RuntimeException |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer;
/** Optional test hook. Production has no bean and therefore uses the no-op instance. */
public interface TransferFailureHook {
TransferFailureHook NONE = new TransferFailureHook() {};
default void afterClaim() {}
default void afterBusinessMutation() {}
}
코드 조각 1 · Failure hook 계약과 NONE 객체
package com.example.financialcore.transfer;
/** Optional test hook. Production has no bean and therefore uses the no-op instance. */
public interface TransferFailureHook {
TransferFailureHook NONE = new TransferFailureHook() {};
한 줄 읽기: TransferFailureHook interface와 아무 method도 override하지 않는 no-op NONE instance를 선언한다.
- 문법을 한 줄씩 풀면
- interface 안의 static final 성격 field NONE은 빈 anonymous implementation 한 개를 즉시 만든다.
- 실제 값 추적
- production hook bean이 없으면 service fallback이 NONE을 선택하고 뒤 default callback 두 개는 빈 body로 끝난다.
- 정상 예
- 별도 hook이 없는 정상 실행에서는 NONE이 claim 뒤와 business mutation 뒤에 아무 예외도 만들지 않는다.
- 반례·경계 예
- NONE fallback을 제거하면 optional hook bean이 없는 production constructor가 사용할 기본값이 사라진다.
- 착각 방지
- interface와 NONE 선언은 예외를 던지지 않는다. test 구현체의 override만 선택된 지점에서 throw한다.
- 이 블록이 하지 않는 일
- 이 선언은 savepoint·retry·process failure를 만들거나 transaction을 직접 rollback하지 않는다.
- 다음 코드와의 연결
- 뒤의 두 default method가 claim 뒤와 business mutation 뒤 callback을 빈 body로 제공한다.
코드 조각 2 · claim 뒤·business 뒤 no-op callback
default void afterClaim() {}
default void afterBusinessMutation() {}
}
한 줄 읽기: afterClaim과 afterBusinessMutation을 default 빈 body로 제공해 구현체가 override하지 않으면 즉시 반환한다.
- 문법을 한 줄씩 풀면
- 7–10행: interface의 default body 덕분에 구현체가 override하지 않으면 no-op으로 끝난다.
- 실제 값 추적
- NONE은 두 default method를 그대로 상속하므로 어느 callback에서도 상태 변경이나 RuntimeException이 없다.
- 정상 예
- service가 두 지점을 호출해도 production 기본 객체에서는 다음 statement로 바로 진행한다.
- 반례·경계 예
- test hook이 해당 method를 override해 RuntimeException을 던져야만 의도한 rollback 지점이 활성화된다.
- 착각 방지
- default no-op hook은 savepoint나 retry 장치가 아니다. bean이 없을 때 아무 일도 하지 않는 연결점이다.
- 이 블록이 하지 않는 일
- TransferFailureHook 7–10행은 process hard kill·외부 시스템 부분 성공을 재현하지 않는다.
- 다음 코드와의 연결
- TransferService는 hook bean이 없을 때 NONE을 고르고 두 callback을 transaction 안의 지정 지점에서 호출한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/transfer/TransferFailureHook.java
- 전제조건
- Java interface/default method와 optional Spring bean 주입 경로가 필요하다.
- 반드시 지킬 계약
- NONE anonymous instance, afterClaim, afterBusinessMutation 두 no-op default를 보존한다.
- 추천 입력 순서
- 주석 → interface → NONE → 두 default method 순서다.
- 자기 점검
- production bean 부재 시 둘 다 no-op, test override 시 exact 지점 예외인지 본다.
- 이번 파일의 범위 밖
- DB savepoint·hard kill·외부 시스템 장애·운영 retry를 만들지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer;
/** Optional test hook. Production has no bean and therefore uses the no-op instance. */
public interface TransferFailureHook {
TransferFailureHook NONE = new TransferFailureHook() {};
default void afterClaim() {}
default void afterBusinessMutation() {}
}
10. TransferService
한 문장 역할: validate→semantic hash→owner 확인→atomic claim→정렬 잠금→이체/원장→complete를 한 transaction에 묶고 기존 요청은 replay/conflict/in-progress로 나눈다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | Controller와 D4/D5/D6 integration tests |
| 무엇을 받나 | actor/key/from/to/amount와 선택 requestId Command |
| 무엇이 바뀌나 | owner claim, 두 account balance, business_tx1, ledger2, 완료 response snapshot |
| 무엇을 돌려주나 | 첫 실행 Result(replayed=false) 또는 저장 결과 replayed=true; conflict/in-progress 예외 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.idempotency.IdempotencyStore;
import com.example.financialcore.idempotency.RequestHasher;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class TransferService {
public record Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount,
String requestId
) {
public Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount
) {
this(actorId, idempotencyKey, fromAccountId, toAccountId, amount,
"internal:" + idempotencyKey);
}
}
public record Result(String businessTransactionId, long fromBalance, long toBalance, boolean replayed) {}
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
private final IdempotencyStore idempotency;
private final RequestHasher requestHasher;
private final TransferFailureHook failureHook;
public TransferService(
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger,
IdempotencyStore idempotency,
RequestHasher requestHasher,
ObjectProvider<TransferFailureHook> failureHooks
) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
this.idempotency = idempotency;
this.requestHasher = requestHasher;
this.failureHook = failureHooks.getIfAvailable(() -> TransferFailureHook.NONE);
}
@Transactional
public Result transfer(Command command) {
validate(command);
String requestHash = requestHasher.hash(
command.fromAccountId(), command.toAccountId(), command.amount()
);
String ownerId = accounts.findOwnerId(command.fromAccountId())
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!ownerId.equals(command.actorId())) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
var claim = idempotency.claim("TRANSFER", command.actorId(), command.idempotencyKey(), requestHash);
if (claim.isEmpty()) return replay(command, requestHash);
failureHook.afterClaim();
List<Long> ids = List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList();
List<Account> locked = accounts.findAllForUpdateOrderById(ids);
if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
Map<Long, Account> byId = new HashMap<>();
locked.forEach(a -> byId.put(a.getId(), a));
Account from = byId.get(command.fromAccountId());
Account to = byId.get(command.toAccountId());
if (from == null || to == null) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
from.withdraw(command.amount());
to.deposit(command.amount());
Instant now = Instant.now();
String correlationId = command.actorId() + ":" + command.idempotencyKey();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedTransfer(correlationId, now));
ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
failureHook.afterBusinessMutation();
String body = tx.getId() + "," + from.getBalance() + "," + to.getBalance();
idempotency.complete(claim.orElseThrow(), 201, body);
return new Result(tx.getId().toString(), from.getBalance(), to.getBalance(), false);
}
private Result replay(Command command, String requestHash) {
IdempotencyStore.Existing existing = idempotency.find("TRANSFER", command.actorId(), command.idempotencyKey());
if (!existing.requestHash().equals(requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_CONFLICT, "idempotency key conflicts with another request");
}
if (!"COMPLETED".equals(existing.status()) || existing.responseBody() == null) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_IN_PROGRESS, "idempotent request is still processing");
}
String[] values = existing.responseBody().split(",");
return new Result(values[0], Long.parseLong(values[1]), Long.parseLong(values[2]), true);
}
private static void validate(Command command) {
if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
if (command.idempotencyKey() == null || command.idempotencyKey().isBlank()) throw new IllegalArgumentException("key");
if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
if (command.amount() <= 0) throw new IllegalArgumentException("amount");
}
}
코드 조각 1 · Account, AccountRepository, BusinessException 도구 준비
package com.example.financialcore.transfer;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.idempotency.IdempotencyStore;
import com.example.financialcore.idempotency.RequestHasher;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
한 줄 읽기: 계좌 조회·잔액 객체, 업무 오류, 멱등 저장소·hash, 거래 머리 저장에 쓰는 여덟 type을 연결한다.
- 문법을 한 줄씩 풀면
- 각 import는 account, api, idempotency, ledger package의 class를 TransferService 안에서 짧은 이름으로 참조하게 한다.
- 실제 값 추적
- 아직 객체나 DB row는 생기지 않는다. 아래 field와 transfer body가 Account부터 BusinessTransactionRepository까지 역할별로 사용한다.
- 정상 예
- 계좌 owner·lock은 AccountRepository, claim은 IdempotencyStore, hash는 RequestHasher, 거래 머리는 BusinessTransactionRepository가 맡는다.
- 반례·경계 예
- AccountRepository가 빠지면 field와 owner 조회·ordered lock 호출의 type을 해석할 수 없다.
- 착각 방지
- 이 묶음은 계좌·멱등·거래 type을 연결할 뿐 owner 조회·claim·거래 저장을 시작하지 않는다.
- 이 블록이 하지 않는 일
- package/import 구간은 Account 잔액이나 idempotency_request·business_tx 행을 읽고 쓰지 않는다.
- 다음 코드와의 연결
- 다음 import 구간이 원장 entity·repository와 Service, ObjectProvider, Transactional을 연결한다.
코드 조각 2 · LedgerEntry, LedgerEntryRepository, Service 도구 준비
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.transaction.annotation.Transactional;
한 줄 읽기: 원장 entity·repository, service stereotype, optional hook provider, transaction annotation을 연결한다.
- 문법을 한 줄씩 풀면
- LedgerEntry 두 type은 원장 저장에, Service는 bean 표시에, ObjectProvider는 optional hook 조회에, Transactional은 transfer 경계에 쓰인다.
- 실제 값 추적
- ObjectProvider는 constructor에서 hook bean 유무를 확인하고, @Transactional은 public transfer method에 붙는다.
- 정상 예
- LedgerEntry·repository는 원장 생성·저장에, @Service는 bean 표지에, ObjectProvider는 optional hook에, @Transactional은 transfer 경계에 쓰인다.
- 반례·경계 예
- LedgerEntryRepository가 빠지면 ledger field와 두 ledger.save 호출의 type을 해석할 수 없다.
- 착각 방지
- @Service와 @Transactional은 각각 bean 표지와 method 경계에 쓰이며 import 줄이 실행을 시작하지 않는다.
- 이 블록이 하지 않는 일
- 이 구간은 원장 행을 insert하지 않고 optional hook을 선택하거나 transaction을 열지도 않는다.
- 다음 코드와의 연결
- Instant와 collection 타입이 시각, 정렬 ID 목록, 잠긴 계좌 map을 표현한다.
코드 조각 3 · Instant, HashMap, List 도구 준비
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
한 줄 읽기: 업무 시각 Instant, 정렬 ID List, 잠긴 Account를 ID로 되찾을 HashMap·Map type을 연결한다.
- 문법을 한 줄씩 풀면
- java.time 한 type과 java.util collection 세 type을 아래 지역 변수 선언에서 짧은 이름으로 쓴다.
- 실제 값 추적
- transfer는 Instant.now 한 값과 두 ID List, ID→Account Map을 만들며 이 import 자체는 값을 생성하지 않는다.
- 정상 예
- 용도: Instant=현재 시각; HashMap=ID map; List=순서 목록; Map=ID 조회.
- 반례·경계 예
- List·Map 연결을 빼면 정렬 ID 목록과 ID→Account 복원 변수의 선언을 컴파일할 수 없다.
- 착각 방지
- Instant import는 시각을 캡처하지 않는다. Instant.now()는 잔액 mutation 뒤에 한 번 호출된다.
- 이 블록이 하지 않는 일
- 이 span에서는 시각 생성·ID 정렬·HashMap 할당이 없고 java.time·util type만 연결한다.
- 다음 코드와의 연결
- Command record가 actor·key·계좌·금액·requestId 여섯 입력을 고정된 이름으로 묶는다.
코드 조각 4 · Command 값 묶음
@Service
public class TransferService {
public record Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount,
String requestId
) {
한 줄 읽기: Command가 actorId·key·from/to account ID·amount·requestId 여섯 입력을 이름 붙여 묶는다.
- 문법을 한 줄씩 풀면
- 22–28행: class TransferService가 type scope를 연다; type 위 Spring annotation은 component scan의 bean 후보로 표시한다.
- 실제 값 추적
- Command는 actorId·idempotencyKey·from/to account ID·amount·requestId 여섯 입력을 순서대로 보관한다.
- 정상 예
- 여섯 값을 선언 순서대로 넘기면 같은 이름 accessor로 읽을 수 있는 변경 불가 Command 하나가 생긴다.
- 반례·경계 예
- Command component 순서나 type을 바꾸면 positional constructor와 accessor/JSON 모양이 달라진다.
- 착각 방지
- Command record는 여섯 component를 재대입하지 못하게 묶지만 JPA entity나 DB row가 아니다.
- 이 블록이 하지 않는 일
- TransferService 22–28행은 component 값을 계산·검증·저장하지 않는다.
- 다음 코드와의 연결
- 다섯 인자 보조 constructor가 requestId를 internal:key로 채워 canonical constructor에 위임한다.
코드 조각 5 · 5인자 Command의 6인자 위임
public Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount
) {
this(actorId, idempotencyKey, fromAccountId, toAccountId, amount,
"internal:" + idempotencyKey);
}
}
한 줄 읽기: 5개 인자 Command가 requestId를 internal:key로 채워 canonical constructor에 위임한다.
- 문법을 한 줄씩 풀면
- 29–36행: Command(...) constructor가 인자를 받아 field 초기화를 시작한다; this(...)는 같은 record의 canonical constructor에 빠진 requestId까지 채워 위임한다.
- 실제 값 추적
- 5개 인자 Command는 requestId를 internal:+idempotencyKey로 채워 6개 component canonical constructor에 넘긴다.
- 정상 예
- actor·key·from·to·amount 다섯 값을 받으면 requestId=internal:+key를 여섯째 값으로 채운 Command가 생긴다.
- 반례·경계 예
- internal: prefix나 idempotencyKey를 빼면 내부 requestId가 같은 key와 연결되지 않는다.
- 착각 방지
- this(...)는 새 이체를 실행하지 않고 같은 record의 canonical constructor로 값 여섯 개를 넘긴다.
- 이 블록이 하지 않는 일
- 이 보조 constructor는 validation·hash·claim·잔액 변경을 하지 않는다.
- 다음 코드와의 연결
- Result record가 거래 ID·두 최종 잔액·replayed flag를 서비스 반환값으로 묶는다.
코드 조각 6 · Result 값 묶음
public record Result(String businessTransactionId, long fromBalance, long toBalance, boolean replayed) {}
한 줄 읽기: Result가 businessTransactionId·두 잔액·replayed flag 네 결과를 이름 붙여 묶는다.
- 문법을 한 줄씩 풀면
- 37–39행: record Result의 businessTransactionId·long·long·boolean component가 같은 이름 accessor와 immutable 생성자 인자가 된다.
- 실제 값 추적
- Result는 business transaction ID, 두 최종 잔액, replayed flag 네 값을 호출자에게 전달한다.
- 정상 예
- service가 네 값을 넘기면 transaction ID·from/to balance·replay 여부를 accessor로 읽는 Result가 생긴다.
- 반례·경계 예
- Result component 순서나 type을 바꾸면 positional constructor와 accessor/JSON 모양이 달라진다.
- 착각 방지
- Result의 replayed boolean은 서비스 결과 값일 뿐 HTTP status를 이 record가 직접 선택하지 않는다.
- 이 블록이 하지 않는 일
- TransferService 37–39행은 component 값을 계산·검증·저장하지 않는다.
- 다음 코드와의 연결
- 여섯 final field가 계좌·거래·원장·멱등·hash·failure hook 의존성을 보관한다.
코드 조각 7 · private final AccountRepository accounts; 읽기
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
private final IdempotencyStore idempotency;
private final RequestHasher requestHasher;
private final TransferFailureHook failureHook;
한 줄 읽기: 계좌·거래·원장·멱등·hash·failure hook용 final 참조 여섯 칸을 선언한다.
- 문법을 한 줄씩 풀면
- private final로 repository 셋·멱등 store·hasher·hook 참조를 constructor에서 한 번만 배정하게 한다.
- 실제 값 추적
- accounts부터 failureHook까지 여섯 참조를 선언한다. 실제 객체 대입은 뒤 constructor가 맡는다.
- 정상 예
- constructor가 반환되기 전에 여섯 blank-final 참조가 모두 배정되어 service instance가 완성된다.
- 반례·경계 예
- 여섯 참조 중 하나라도 constructor에서 빠지면 Java의 blank-final 초기화 검사가 컴파일을 막는다.
- 착각 방지
- final field는 nullable fixture가 아니라 service가 평생 쓸 필수 dependency 자리다.
- 이 블록이 하지 않는 일
- 여섯 field 선언은 보관할 자리만 만들며 constructor 대입·SQL·업무 호출은 수행하지 않는다.
- 다음 코드와의 연결
- constructor 앞부분이 여섯 의존성을 받고 account와 transaction repository를 먼저 field에 둔다.
코드 조각 8 · 여섯 dependency를 받는 service constructor
public TransferService(
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger,
IdempotencyStore idempotency,
RequestHasher requestHasher,
ObjectProvider<TransferFailureHook> failureHooks
) {
this.accounts = accounts;
this.transactions = transactions;
한 줄 읽기: service constructor가 repository·store·hasher·optional hook 여섯 의존성을 받는다.
- 문법을 한 줄씩 풀면
- 47–56행: TransferService(...) constructor가 인자를 받아 field 초기화를 시작한다; this.field = parameter가 인자를 instance field에 저장한다.
- 실제 값 추적
- constructor가 여섯 dependency를 받고 이 조각에서 accounts와 transactions field부터 같은 객체로 연결한다.
- 정상 예
- Spring이 여섯 dependency를 넘기면 이 조각은 accounts와 transactions를 같은 이름 field에 먼저 저장한다.
- 반례·경계 예
- accounts와 transactions를 서로 바꾸거나 하나를 저장하지 않으면 뒤 owner 조회·transaction 저장이 엉뚱한 dependency를 쓴다.
- 착각 방지
- constructor parameter를 받는 일은 repository query나 transaction 업무를 실행하는 일이 아니다.
- 이 블록이 하지 않는 일
- 이 구간은 accounts와 transactions만 field에 대입하며 ledger·멱등·hasher·hook 대입이나 transfer 실행은 아직 하지 않는다.
- 다음 코드와의 연결
- constructor 뒷부분이 ledger·idempotency·hasher를 저장하고 optional hook 선택을 준비한다.
코드 조각 9 · 남은 네 field와 no-op hook 선택
this.ledger = ledger;
this.idempotency = idempotency;
this.requestHasher = requestHasher;
this.failureHook = failureHooks.getIfAvailable(() -> TransferFailureHook.NONE);
}
한 줄 읽기: 남은 repository·hasher field를 연결하고 hook 부재 시 no-op NONE을 고른다.
- 문법을 한 줄씩 풀면
- 57–61행: this.field = parameter가 인자를 instance field에 저장한다.
- 실제 값 추적
- accounts/transactions/ledger/idempotency/hasher 다섯 의존성은 그대로 저장되고 hook bean 부재 시 NONE이 여섯째 field가 된다.
- 정상 예
- ledger·idempotency·requestHasher를 그대로 저장하고, hook bean이 없으면 TransferFailureHook.NONE을 failureHook에 넣는다.
- 반례·경계 예
- getIfAvailable의 NONE supplier를 빼면 production에 hook bean이 없을 때 failureHook을 안전하게 호출할 수 없다.
- 착각 방지
- NONE은 retry나 savepoint가 아니라 두 callback을 아무 일 없이 끝내는 기본 구현이다.
- 이 블록이 하지 않는 일
- field 연결만 끝내며 claim·계좌 잠금·transaction 시작은 다음 public method의 일이다.
- 다음 코드와의 연결
- transfer는 입력 guard, semantic hash, 출금 계좌 owner 조회·actor 비교로 시작한다.
코드 조각 10 · transfer 메서드의 값 흐름
@Transactional
public Result transfer(Command command) {
validate(command);
String requestHash = requestHasher.hash(
command.fromAccountId(), command.toAccountId(), command.amount()
);
String ownerId = accounts.findOwnerId(command.fromAccountId())
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!ownerId.equals(command.actorId())) {
한 줄 읽기: 입력을 검증·hash한 뒤 source account owner를 읽어 actor와 비교한다.
- 문법을 한 줄씩 풀면
- 62–71행: transfer(...)가 parameter와 method body를 연다; public Spring bean method의 @Transactional은 정상 반환 시 commit, unchecked 예외 시 rollback 경계다.
- 실제 값 추적
- validate 뒤 from/to/amount hash와 source ownerId를 구한다. owner가 actor와 다르면 이어지는 branch가 ACCESS_DENIED를 던진다.
- 정상 예
- 양수 계좌·금액과 actor/key가 유효하고 source owner가 actor와 같으면 hash를 들고 claim 단계로 간다.
- 반례·경계 예
- actor가 source account owner와 다르면 ACCESS_DENIED에서 멈춰야 하며 claim·잔액 변경으로 넘어가면 안 된다.
- 착각 방지
- request hash는 from·to·amount의 뜻을 고정할 뿐 account 소유권이나 잔액을 증명하지 않는다.
- 이 블록이 하지 않는 일
- TransferService 62–71행은 계좌 잠금·잔액 이동·transaction·ledger 저장을 아직 하지 않는다.
- 다음 코드와의 연결
- owner가 actor와 같을 때만 복합 key claim으로 가고, 다르면 ACCESS_DENIED로 transaction을 중단한다.
코드 조각 11 · claim 직후 rollback 시험 지점
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
var claim = idempotency.claim("TRANSFER", command.actorId(), command.idempotencyKey(), requestHash);
if (claim.isEmpty()) return replay(command, requestHash);
failureHook.afterClaim();
한 줄 읽기: 소유자를 확인하고 key를 claim해 기존 요청은 replay, 새 owner는 afterClaim까지 보낸다.
- 문법을 한 줄씩 풀면
- ACCESS_DENIED throw 뒤 claim Optional을 검사해 empty면 replay 결과를 즉시 return하고, present면 afterClaim callback을 호출한다.
- 실제 값 추적
- owner 불일치는 ACCESS_DENIED; 통과하면 claim한다. empty는 즉시 replay, present owner는 afterClaim hook까지 간다.
- 정상 예
- actor가 owner이고 claim present면 afterClaim을 통과해 잠금으로 가며, empty면 잔액을 건드리지 않고 replay로 돌아선다.
- 반례·경계 예
- claim empty인데 새 업무 경로로 계속 가면 같은 요청이 돈을 두 번 옮길 수 있다. present owner에서 hook 예외가 나면 claim INSERT도 rollback돼야 한다.
- 착각 방지
- claim empty는 전체 실패가 아니라 기존 row의 hash·status·body를 검사하라는 신호다.
- 이 블록이 하지 않는 일
- TransferService 72–77행은 계좌를 잠그거나 잔액·business_tx·ledger를 바꾸지 않는다.
- 다음 코드와의 연결
- present claim은 afterClaim을 지나고 empty claim은 잔액을 건드리지 않은 채 replay method로 빠진다.
코드 조각 12 · 두 account ID 정렬 잠금
List<Long> ids = List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList();
List<Account> locked = accounts.findAllForUpdateOrderById(ids);
if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
Map<Long, Account> byId = new HashMap<>();
locked.forEach(a -> byId.put(a.getId(), a));
Account from = byId.get(command.fromAccountId());
Account to = byId.get(command.toAccountId());
if (from == null || to == null) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
한 줄 읽기: 두 account ID를 같은 오름차순으로 잠근 뒤 원래 from·to 역할을 Map으로 복원한다.
- 문법을 한 줄씩 풀면
- 두 ID를 stream.sorted로 정렬해 FOR UPDATE 조회에 넘기고 size 2를 확인한 뒤 HashMap으로 원래 from·to를 복원한다.
- 실제 값 추적
- from/to 두 ID를 오름차순으로 조회 잠금한 뒤 Map에서 원래 from ID와 to ID 역할을 다시 찾는다.
- 정상 예
- 서로 다른 두 계좌가 모두 있으면 작은 ID부터 잠기고 Map이 원래 from·to 객체를 정확히 돌려준다.
- 반례·경계 예
- 조회가 2행이 아니거나 Map에서 원래 ID 하나를 못 찾으면 ACCOUNT_NOT_FOUND로 mutation 전에 끝난다.
- 착각 방지
- 정렬된 첫 row가 언제나 from 계좌인 것은 아니다. Map에서 원래 ID로 역할을 복원한다.
- 이 블록이 하지 않는 일
- TransferService 78–86행은 실제 모든 scheduler에서 deadlock이 절대 없다고 증명하지 않는다.
- 다음 코드와의 연결
- 잠긴 두 Account를 원래 from·to ID로 복원한 뒤 withdraw·deposit과 거래·원장 저장을 수행한다.
코드 조각 13 · 업무 변경 직후 rollback 시험 지점
from.withdraw(command.amount());
to.deposit(command.amount());
Instant now = Instant.now();
String correlationId = command.actorId() + ":" + command.idempotencyKey();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedTransfer(correlationId, now));
ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
failureHook.afterBusinessMutation();
한 줄 읽기: 출금·입금 뒤 거래 1행과 OUT·IN 원장 2행을 저장하고 failure hook을 호출한다.
- 문법을 한 줄씩 풀면
- 87–95행: domain의 withdraw/deposit 호출이 두 account 객체 잔액을 반대 방향으로 바꾼다; 두 ledger.save가 같은 transaction에 OUT과 IN posting을 각각 한 행 저장한다.
- 실제 값 추적
- amount만큼 from 잔액은 줄고 to 잔액은 늘며, 같은 now로 완료 거래와 TRANSFER_OUT·TRANSFER_IN을 저장한다.
- 정상 예
- 유효한 입력이면 업무 변경 직후 rollback 시험 지점 단계가 끝나고 첫 요청만 돈을 옮기고 기존 요청은 replay·conflict·in-progress로 갈린다.
- 반례·경계 예
- withdraw·deposit 또는 OUT·IN 저장 하나를 빼면 코드가 표현한 잔액 이동과 쌍 원장 기록이 불완전해진다.
- 착각 방지
- 같은 transaction 안의 ledger 두 행은 외부 결제망 effect까지 exactly-once로 만들지 않는다.
- 이 블록이 하지 않는 일
- TransferService 87–95행은 retry·메시지 발행·외부 시스템 보상을 구현하지 않는다.
- 다음 코드와의 연결
- 업무 mutation 뒤 hook을 통과하면 거래 ID와 두 잔액을 완료 snapshot으로 저장한다.
코드 조각 14 · response snapshot과 COMPLETED 전환
String body = tx.getId() + "," + from.getBalance() + "," + to.getBalance();
idempotency.complete(claim.orElseThrow(), 201, body);
return new Result(tx.getId().toString(), from.getBalance(), to.getBalance(), false);
}
한 줄 읽기: 거래 ID·두 잔액 snapshot으로 claim을 201 COMPLETED 처리하고 첫 Result(false)를 반환한다.
- 문법을 한 줄씩 풀면
- 96–99행: 문자열 txId,fromBalance,toBalance를 만들고 complete(ownerId, 201, body)를 호출한 뒤 같은 세 값과 false를 Result constructor에 넘긴다.
- 실제 값 추적
- body는 실제 tx ID·from 잔액·to 잔액을 comma 순서로 담고, complete에는 201을, Result에는 false를 넘긴다.
- 정상 예
- complete가 owner 행 하나를 갱신하면 tx ID·두 잔액·replayed=false Result를 호출자에게 돌려준다.
- 반례·경계 예
- body의 txId/fromBalance/toBalance 순서나 Result의 false flag를 바꾸면 저장 snapshot과 첫 response 의미가 달라진다.
- 착각 방지
- 201은 저장 snapshot의 responseStatus다. replay HTTP 200 선택은 뒤 controller가 수행한다.
- 이 블록이 하지 않는 일
- 새 claim·두 번째 mutation·HTTP response 전송·retry를 수행하지 않는다.
- 다음 코드와의 연결
- 기존 요청 경로는 저장 hash를 현재 hash와 먼저 비교해 다른 payload를 conflict로 차단한다.
코드 조각 15 · 기존 요청의 conflict·in-progress·replay
private Result replay(Command command, String requestHash) {
IdempotencyStore.Existing existing = idempotency.find("TRANSFER", command.actorId(), command.idempotencyKey());
if (!existing.requestHash().equals(requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_CONFLICT, "idempotency key conflicts with another request");
}
한 줄 읽기: 기존 request hash를 비교해 다른 요청이면 IDEMPOTENCY_CONFLICT로 막는다.
- 문법을 한 줄씩 풀면
- replay가 복합 key의 Existing을 읽고 저장 requestHash와 현재 requestHash가 다르면 IDEMPOTENCY_CONFLICT를 던진다.
- 실제 값 추적
- 기존 scope·actor·key 행을 읽고 requestHash가 다르면 IDEMPOTENCY_CONFLICT를 던진다. 같을 때만 다음 status/body guard로 간다.
- 정상 예
- 저장 hash와 현재 hash가 같을 때만 status·body 검사로 이어지고 다르면 conflict 예외로 끝난다.
- 반례·경계 예
- 기존 hash·status·body 확인을 건너뛰면 conflict와 in-progress를 성공 replay로 오인한다.
- 착각 방지
- replayed=true는 저장된 결과를 다시 읽었다는 뜻이지 돈을 두 번째로 옮겼다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- status·responseBody와 snapshot 형식은 아직 검사하지 않고 account·transaction·ledger도 새로 만들지 않는다.
- 다음 코드와의 연결
- hash가 같아도 status가 COMPLETED가 아니거나 body가 없으면 IDEMPOTENCY_IN_PROGRESS를 던진다.
코드 조각 16 · COMPLETED·response body replay guard
if (!"COMPLETED".equals(existing.status()) || existing.responseBody() == null) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_IN_PROGRESS, "idempotent request is still processing");
}
한 줄 읽기: 기존 행이 COMPLETED이고 response body가 있을 때만 성공 replay를 허용한다.
- 문법을 한 줄씩 풀면
- status가 COMPLETED가 아니거나 responseBody가 null인 두 조건을 OR로 묶어 IDEMPOTENCY_IN_PROGRESS를 던진다.
- 실제 값 추적
- 기존 status가 COMPLETED가 아니거나 responseBody가 null이면 replay하지 않고 IDEMPOTENCY_IN_PROGRESS 예외다.
- 정상 예
- status가 COMPLETED이고 body가 null이 아니면 저장 결과 parse로 가고, 둘 중 하나라도 아니면 in-progress 예외다.
- 반례·경계 예
- hash가 같아도 PROCESSING 행이나 body 없는 완료 행을 성공 Result로 만들면 아직 없는 결과를 replay하게 된다.
- 착각 방지
- hash 일치는 성공 완료를 뜻하지 않는다. 상태와 저장 body를 별도로 확인해야 한다.
- 이 블록이 하지 않는 일
- 기다림, polling, retry, stale owner recovery를 시작하지 않고 한 번의 예외로 끝낸다.
- 다음 코드와의 연결
- COMPLETED와 non-null body를 모두 만족한 행만 comma snapshot parsing 단계로 간다.
코드 조각 17 · comma snapshot을 replay Result로 복원
String[] values = existing.responseBody().split(",");
return new Result(values[0], Long.parseLong(values[1]), Long.parseLong(values[2]), true);
}
한 줄 읽기: 저장된 tx ID·두 잔액을 읽어 replayed=true Result로 돌려준다.
- 문법을 한 줄씩 풀면
- responseBody를 comma로 나누고 index 0은 transaction ID, 1·2는 long 잔액으로 parse해 replayed=true Result를 반환한다.
- 실제 값 추적
- 저장 body를 comma로 나눠 values[0]=txId, [1]=fromBalance, [2]=toBalance로 parse하고 replayed=true를 붙인다.
- 정상 예
- txId,fromBalance,toBalance 세 조각을 parse해 같은 값에 replayed=true를 붙인 Result가 된다.
- 반례·경계 예
- body가 세 조각이 아니거나 잔액 칸이 숫자가 아니면 index 또는 number parsing 예외가 날 수 있고 별도 형식 guard는 없다.
- 착각 방지
- replayed=true는 저장 snapshot 재사용 표시이며 두 번째 withdraw·deposit 성공 표시가 아니다.
- 이 블록이 하지 않는 일
- snapshot signature·JSON schema·responseStatus를 검증하거나 새 DB effect를 만들지 않는다.
- 다음 코드와의 연결
- validate는 actor·key의 null/blank와 두 account ID의 0 이하를 각각 즉시 거절한다.
코드 조각 18 · actor·key·계좌 ID 입력 guard
private static void validate(Command command) {
if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
if (command.idempotencyKey() == null || command.idempotencyKey().isBlank()) throw new IllegalArgumentException("key");
if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
한 줄 읽기: actor·key가 비었거나 계좌 ID가 0 이하인 요청을 업무 시작 전에 막는다.
- 문법을 한 줄씩 풀면
- validate를 열고 actor null/blank, key null/blank, from/to ID 0 이하를 세 if로 나눠 예외를 던진다.
- 실제 값 추적
- actorId·key가 null/blank이거나 account ID가 0 이하이면 해당 이름의 IllegalArgumentException으로 끝난다.
- 정상 예
- actor와 key가 null도 blank도 아니고 두 account ID가 모두 양수면 마지막 두 업무 guard로 넘어간다.
- 반례·경계 예
- 빈 actor·key나 0 이하 ID를 허용하면 잘못된 값이 hash·owner 조회·claim 단계로 들어간다.
- 착각 방지
- actor·key 모양과 ID 양수 검사는 실제 account 존재나 source owner 일치를 대신하지 않는다.
- 이 블록이 하지 않는 일
- TransferService 112–116행은 validation 실패의 HTTP body를 만들거나 retry하지 않는다.
- 다음 코드와의 연결
- 남은 guard가 동일 account와 0 이하 amount를 막고 validate를 끝낸다.
코드 조각 19 · same-account·amount 입력 guard
if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
if (command.amount() <= 0) throw new IllegalArgumentException("amount");
}
}
한 줄 읽기: 같은 계좌이거나 amount가 0 이하인 이체를 업무 시작 전에 막는다.
- 문법을 한 줄씩 풀면
- 117–120행: 첫 if는 fromAccountId==toAccountId를, 둘째는 amount<=0을 검사하고 각 조건에 맞는 IllegalArgumentException을 던진다.
- 실제 값 추적
- from과 to ID가 같으면 same account, amount가 0 이하이면 amount 예외를 던진다.
- 정상 예
- from과 to ID가 서로 다르고 amount가 양수면 validate가 예외 없이 끝나 transfer 본문으로 돌아간다.
- 반례·경계 예
- 같은 계좌나 0 이하 금액을 허용하면 의미 없는 이체가 hash·claim·mutation 단계로 들어간다.
- 착각 방지
- 서로 다른 ID와 양수 amount는 입력 shape만 통과시킬 뿐 잔액 충분 여부는 Account.withdraw가 판단한다.
- 이 블록이 하지 않는 일
- TransferService 117–120행은 validation 실패의 HTTP body를 만들거나 retry하지 않는다.
- 다음 코드와의 연결
- TransferFailurePointIT는 afterClaim과 afterBusinessMutation 예외에서 신규 DB effect가 0인지 검사한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/transfer/TransferService.java
- 전제조건
- Account/ledger repositories, IdempotencyStore/Hasher, Spring transaction과 optional failure hook가 필요하다.
- 반드시 지킬 계약
- public transaction 하나, source owner 선확인, composite claim, ID 정렬 lock, 거래1/원장2, complete201, 세 replay 분기를 보존한다.
- 추천 입력 순서
- record/field/constructor → transfer validate/hash/owner/claim → lock/mutate/save/complete → replay → validate 순서다.
- 자기 점검
- 10,000→7,000과 10,000→13,000, ledger baseline4, same key replay true, changed semantic conflict, rollback zero effects를 test와 대조한다.
- 이번 파일의 범위 밖
- retry/backoff·stale PROCESSING recovery·외부 exactly-once·일반 JSON response store는 구현하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.idempotency.IdempotencyStore;
import com.example.financialcore.idempotency.RequestHasher;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class TransferService {
public record Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount,
String requestId
) {
public Command(
String actorId, String idempotencyKey,
long fromAccountId, long toAccountId, long amount
) {
this(actorId, idempotencyKey, fromAccountId, toAccountId, amount,
"internal:" + idempotencyKey);
}
}
public record Result(String businessTransactionId, long fromBalance, long toBalance, boolean replayed) {}
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
private final IdempotencyStore idempotency;
private final RequestHasher requestHasher;
private final TransferFailureHook failureHook;
public TransferService(
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger,
IdempotencyStore idempotency,
RequestHasher requestHasher,
ObjectProvider<TransferFailureHook> failureHooks
) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
this.idempotency = idempotency;
this.requestHasher = requestHasher;
this.failureHook = failureHooks.getIfAvailable(() -> TransferFailureHook.NONE);
}
@Transactional
public Result transfer(Command command) {
validate(command);
String requestHash = requestHasher.hash(
command.fromAccountId(), command.toAccountId(), command.amount()
);
String ownerId = accounts.findOwnerId(command.fromAccountId())
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!ownerId.equals(command.actorId())) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
var claim = idempotency.claim("TRANSFER", command.actorId(), command.idempotencyKey(), requestHash);
if (claim.isEmpty()) return replay(command, requestHash);
failureHook.afterClaim();
List<Long> ids = List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList();
List<Account> locked = accounts.findAllForUpdateOrderById(ids);
if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
Map<Long, Account> byId = new HashMap<>();
locked.forEach(a -> byId.put(a.getId(), a));
Account from = byId.get(command.fromAccountId());
Account to = byId.get(command.toAccountId());
if (from == null || to == null) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
from.withdraw(command.amount());
to.deposit(command.amount());
Instant now = Instant.now();
String correlationId = command.actorId() + ":" + command.idempotencyKey();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedTransfer(correlationId, now));
ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
failureHook.afterBusinessMutation();
String body = tx.getId() + "," + from.getBalance() + "," + to.getBalance();
idempotency.complete(claim.orElseThrow(), 201, body);
return new Result(tx.getId().toString(), from.getBalance(), to.getBalance(), false);
}
private Result replay(Command command, String requestHash) {
IdempotencyStore.Existing existing = idempotency.find("TRANSFER", command.actorId(), command.idempotencyKey());
if (!existing.requestHash().equals(requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_CONFLICT, "idempotency key conflicts with another request");
}
if (!"COMPLETED".equals(existing.status()) || existing.responseBody() == null) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_IN_PROGRESS, "idempotent request is still processing");
}
String[] values = existing.responseBody().split(",");
return new Result(values[0], Long.parseLong(values[1]), Long.parseLong(values[2]), true);
}
private static void validate(Command command) {
if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
if (command.idempotencyKey() == null || command.idempotencyKey().isBlank()) throw new IllegalArgumentException("key");
if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
if (command.amount() <= 0) throw new IllegalArgumentException("amount");
}
}
11. TransferFailurePointIT
한 문장 역할: claim 뒤와 업무 변경 뒤 RuntimeException 두 지점에서 idempotency·TRANSFER transaction·TRANSFER ledger가 모두 0인지 확인한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 목요일 exact selector 두 번째 |
| 무엇을 받나 | 10,000/5,000 계좌, 1,000 이체, 두 hook point |
| 무엇이 바뀌나 | 각 호출은 중간 변경 뒤 RuntimeException으로 rollback |
| 무엇을 돌려주나 | 예외 injected + idempotency0/business_tx0/transfer ledger0 |
정확한 전체 원문
정확한 전체 원문 펼치기
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");
}
}
}
코드 조각 1 · rollback 시험의 PostgreSQL·계좌·JUnit 도구
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;
한 줄 읽기: PostgreSQL base, Account·opening service, JUnit, SpringBootTest와 test configuration 타입을 연결한다.
- 문법을 한 줄씩 풀면
- domain fixture와 JUnit/Spring annotation import가 아래 two-point rollback integration test class를 구성한다.
- 실제 값 추적
- Account는 from/to field type, openings는 fixture 생성, TestConfiguration은 nested hook bean 구성에 쓰인다.
- 정상 예
- 실제 PostgreSQL에서 계좌를 열고 두 RuntimeException 지점 뒤 신규 DB effect count를 읽을 도구 조합이다.
- 반례·경계 예
- PostgresIntegrationTestSupport 연결을 빼면 이 test class가 상속할 실제 PostgreSQL 기반 type을 찾지 못한다.
- 착각 방지
- 이 import 목록은 failure point를 선택하지 않는다. hook.point 대입은 각 test body가 수행한다.
- 이 블록이 하지 않는 일
- 이 package/import 묶음은 hook point를 고르거나 PostgreSQL을 비우고 계좌를 여는 Arrange를 수행하지 않는다.
- 다음 코드와의 연결
- 추가 import 구간이 test configuration bean, JdbcClient, 값·예외 AssertJ 진입점을 연결한다.
코드 조각 2 · test hook bean·JDBC·두 AssertJ 도구
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;
한 줄 읽기: nested bean import, JdbcClient, 값 assertion과 thrown-exception assertion 진입점을 연결한다.
- 문법을 한 줄씩 풀면
- Bean·Import는 test hook 등록에, JdbcClient는 cleanup/count에, assertThat·assertThatThrownBy는 결과 판정에 쓰인다.
- 실제 값 추적
- Bean·Import는 test hook 구성에, JdbcClient는 SQL count에, 두 AssertJ 진입점은 값·예외 판정에 사용된다.
- 정상 예
- 용도: Bean=bean factory; Import=test 설정 추가; JdbcClient=SQL 실행·count; assertThat=값 비교; assertThatThrownBy=예외 비교.
- 반례·경계 예
- JdbcClient가 빠지면 주입 field와 cleanup·세 SELECT COUNT 호출의 type을 해석할 수 없다.
- 착각 방지
- @Bean·@Import는 test hook을 context에 잇지만 예외를 던지거나 rollback 결과를 판정하지 않는다.
- 이 블록이 하지 않는 일
- 구성·JDBC·AssertJ import만으로 TRUNCATE·service 호출·세 isZero assertion은 실행되지 않는다.
- 다음 코드와의 연결
- class가 SpringBootTest와 imported FailureConfiguration을 PostgreSQL support 위에 결합한다.
코드 조각 3 · TransferFailurePointIT type 경계
@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
한 줄 읽기: TransferFailurePointIT의 선언 범위를 열 뿐, method나 test를 지금 실행하지 않는다.
- 문법을 한 줄씩 풀면
- @SpringBootTest·@Import를 붙인 class가 PostgreSQL support와 test 전용 hook configuration을 함께 연다.
- 실제 값 추적
- @SpringBootTest가 full context를 열고 @Import가 이 test의 nested FailureConfiguration을 연결한다.
- 정상 예
- JUnit이 이 class를 실행하면 application bean과 ControlledFailureHook이 같은 test context에 들어온다.
- 반례·경계 예
- @SpringBootTest를 빼면 TransferFailurePointIT의 full Spring integration context 계약이 사라진다.
- 착각 방지
- @Import는 FailureConfiguration을 context에 더하지만 두 @Test method를 선언 시점에 실행하지 않는다.
- 이 블록이 하지 않는 일
- class header는 두 @Test body·TRUNCATE·service 호출·COUNT assertion을 아직 실행하지 않는다.
- 다음 코드와의 연결
- JdbcClient·AccountOpeningService·TransferService를 주입해 fixture, 실행, DB count 역할을 나눈다.
코드 조각 4 · @Autowired JdbcClient jdbc; 읽기
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired TransferService transfers;
한 줄 읽기: jdbc, openings, transfers는 Spring test context가 주입한다.
- 문법을 한 줄씩 풀면
- 세 @Autowired field가 JDBC 관찰, 계좌 fixture 생성, transfer 실행 bean을 각각 받는다.
- 실제 값 추적
- Spring test context가 JdbcClient·AccountOpeningService·TransferService 세 협력 객체를 각 field에 주입한다.
- 정상 예
- test context가 시작되면 jdbc, openings, transfers에 같은 context의 bean이 주입된다.
- 반례·경계 예
- test context에 jdbc, openings, transfers bean이 없으면 주입 단계에서 이 integration test가 시작하지 못한다.
- 착각 방지
- @Autowired 선언은 bean을 새로 만드는 코드가 아니라 기존 context bean을 받을 자리다.
- 이 블록이 하지 않는 일
- 이 span은 주입 대상을 선언할 뿐 SQL cleanup·계좌 개설·service 호출은 수행하지 않는다.
- 다음 코드와의 연결
- ControlledFailureHook과 from·to field가 선택할 failure point와 계좌 fixture를 보관한다.
코드 조각 5 · @Autowired ControlledFailureHook hook; 읽기
@Autowired ControlledFailureHook hook;
Account from;
Account to;
한 줄 읽기: hook은 Spring이 주입하고 from·to는 @BeforeEach가 연 계좌를 매 test 새로 넣는다.
- 문법을 한 줄씩 풀면
- @Autowired hook은 context bean을 받고 annotation 없는 from·to field는 BeforeEach 계좌를 보관한다.
- 실제 값 추적
- context가 ControlledFailureHook을 주입하고, from·to는 뒤 BeforeEach가 새 Account로 대입한다.
- 정상 예
- Spring이 hook을 주입하고 @BeforeEach가 from, to를 실제 계좌로 채운다.
- 반례·경계 예
- hook 주입이나 from, to fixture 대입을 빼면 context 시작 또는 test Act가 실패한다.
- 착각 방지
- hook은 context가 주입하고 from·to는 BeforeEach가 대입하므로 세 field의 준비 시점이 같지 않다.
- 이 블록이 하지 않는 일
- field 선언만으로 hook point나 from·to 값이 정해지지 않으며 DB effect도 만들지 않는다.
- 다음 코드와의 연결
- BeforeEach가 hook을 NONE으로 되돌리고 네 표를 비운 뒤 10,000/5,000 계좌를 연다.
코드 조각 6 · hook NONE·네 표 cleanup·10,000/5,000 fixture
@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 전에 hook을 NONE으로 되돌리고 네 표를 비운 뒤 from 10,000·to 5,000 계좌를 연다.
- 문법을 한 줄씩 풀면
- 28–33행: @BeforeEach는 각 test 전에 fixture를 초기화해 이전 행의 영향을 차단한다.
- 실제 값 추적
- customer-1 ROLL-FROM은 10,000, customer-2 ROLL-TO는 5,000에서 두 rollback scenario를 각각 시작한다.
- 정상 예
- hook을 NONE으로 되돌리고 네 table을 비운 뒤 from 10,000·to 5,000 계좌를 열어 두 test를 격리한다.
- 반례·경계 예
- hook reset을 빼면 지난 failure point가, TRUNCATE를 빼면 지난 row가 이번 zero-count assertion에 섞인다.
- 착각 방지
- hook reset·TRUNCATE·계좌 개설은 rollback 보장이 아니라 두 scenario가 공유하는 Arrange다.
- 이 블록이 하지 않는 일
- TransferFailurePointIT 28–33행의 TRUNCATE/open은 test 전용이며 production 데이터 정리 절차를 뜻하지 않는다.
- 다음 코드와의 연결
- 첫 test는 AFTER_CLAIM을 선택하고 key after-claim으로 공통 zero-effect helper를 호출한다.
코드 조각 7 · claim 직후 예외를 고르는 첫 @Test
@Test void afterClaimRuntimeExceptionRollsBackClaim() {
hook.point = ControlledFailureHook.Point.AFTER_CLAIM;
assertFailureAndNoEffect("after-claim");
}
한 줄 읽기: AFTER_CLAIM 예외 scenario를 골라 zero-effect helper를 실행한다.
- 문법을 한 줄씩 풀면
- 34–38행: @Test가 afterClaimRuntimeExceptionRollsBackClaim method를 JUnit selector의 실행 대상으로 표시한다.
- 실제 값 추적
- hook point를 AFTER_CLAIM으로 바꾼 뒤 helper에 key after-claim을 넘겨 실패 호출과 무효과 검사를 맡긴다.
- 정상 예
- AFTER_CLAIM과 key after-claim을 고르면 helper가 injected 예외와 신규 DB effect 0을 확인한다.
- 반례·경계 예
- @Test를 빼면 afterClaimRuntimeExceptionRollsBackClaim method는 selector 목록에서 빠지고 JUnit은 afterClaimRuntimeExceptionRollsBackClaim body를 호출하지 않는다.
- 착각 방지
- method 이름이 아니라 helper의 RuntimeException·message·세 COUNT 0 matcher가 직접 증거다.
- 이 블록이 하지 않는 일
- 이 method는 point와 key만 정하며 예외·세 DB count assertion은 공통 helper가 수행한다.
- 다음 코드와의 연결
- 둘째 test는 AFTER_BUSINESS를 선택하고 key after-business로 같은 helper를 실행한다.
코드 조각 8 · 업무 변경 직후 예외를 고르는 둘째 @Test
@Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() {
hook.point = ControlledFailureHook.Point.AFTER_BUSINESS;
assertFailureAndNoEffect("after-business");
}
한 줄 읽기: 업무 변경 직후 예외 scenario를 골라 같은 rollback helper로 확인한다.
- 문법을 한 줄씩 풀면
- 39–43행: @Test가 afterBusinessRuntimeExceptionRollsBackEveryEffect method를 JUnit selector의 실행 대상으로 표시한다.
- 실제 값 추적
- hook point를 AFTER_BUSINESS로 바꾼 뒤 helper에 key after-business를 넘긴다. rollback assertion은 helper 안에 있다.
- 정상 예
- AFTER_BUSINESS와 key after-business를 고르면 helper가 업무 변경 뒤 rollback state를 확인한다.
- 반례·경계 예
- @Test를 빼면 이 scenario가 JUnit selector에서 빠져 AFTER_BUSINESS rollback helper가 실행되지 않는다.
- 착각 방지
- 이 짧은 method 자체는 count를 읽지 않고 공통 helper에 Assert를 맡긴다.
- 이 블록이 하지 않는 일
- 이 method는 point와 key만 정하며 commit 이후 장애나 process hard kill을 재현하지 않는다.
- 다음 코드와의 연결
- 공통 helper는 injected RuntimeException과 idempotency·TRANSFER transaction·TRANSFER ledger count 0을 검사한다.
코드 조각 9 · 주입 예외와 세 COUNT 0을 묶은 helper
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();
한 줄 읽기: injected RuntimeException과 멱등·거래·원장 신규 행 0개를 한 helper에서 확인한다.
- 문법을 한 줄씩 풀면
- assertThatThrownBy가 예외 type·message를 보고, 세 isZero()가 각 SELECT COUNT 결과를 0과 비교한다.
- 실제 값 추적
- 호출은 injected RuntimeException이어야 하고 idempotency·TRANSFER 거래·TRANSFER 원장 COUNT가 모두 0이어야 한다.
- 정상 예
- 예외 message에 injected가 있고 claim·TRANSFER 거래·TRANSFER 원장 COUNT가 모두 0이면 Green이다.
- 반례·경계 예
- 예외가 맞아도 count 하나가 1이면 같은 transaction 전체 rollback 계약은 실패한다.
- 착각 방지
- matcher가 rollback을 실행하지 않는다. service transaction이 예외를 보고 원복한 결과를 읽는다.
- 이 블록이 하지 않는 일
- 두 opening balance나 process hard kill은 이 helper가 확인하지 않는다.
- 다음 코드와의 연결
- nested FailureConfiguration은 이 test context에만 ControlledFailureHook bean 정의를 제공한다.
코드 조각 10 · FailureConfiguration type 경계
}
@TestConfiguration(proxyBeanMethods = false)
static class FailureConfiguration {
한 줄 읽기: test context 안에서만 failure hook bean definition을 제공하는 configuration을 연다.
- 문법을 한 줄씩 풀면
- @TestConfiguration(proxyBeanMethods=false)인 nested class가 test 전용 bean definition scope를 연다.
- 실제 값 추적
- 이 nested test configuration은 main application이 아니라 이 test context에 failure hook bean을 추가할 범위를 연다.
- 정상 예
- nested FailureConfiguration이 test context 안에서 controlledFailureHook bean을 제공할 범위를 연다.
- 반례·경계 예
- @TestConfiguration annotation을 빼면 FailureConfiguration은 test context 전용 configuration으로 자동 인식되지 않는다.
- 착각 방지
- proxyBeanMethods=false는 이 configuration의 @Bean method 간 프록시 호출을 끄는 설정이지 test를 실행하는 표지가 아니다.
- 이 블록이 하지 않는 일
- configuration header는 ControlledFailureHook을 직접 만들거나 rollback assertion을 실행하지 않는다.
- 다음 코드와의 연결
- @Bean method가 context가 공유할 새 ControlledFailureHook instance를 반환한다.
코드 조각 11 · test context가 공유할 ControlledFailureHook bean instance를 돌려준다
@Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); }
}
한 줄 읽기: test context가 공유할 ControlledFailureHook bean instance를 돌려준다.
- 문법을 한 줄씩 풀면
- @Bean factory method가 new ControlledFailureHook 한 개를 만들어 test application context에 등록한다.
- 실제 값 추적
- Spring은 ControlledFailureHook 새 instance 하나를 test bean으로 등록하고 뒤 test와 service가 함께 쓴다.
- 정상 예
- Spring이 factory method를 호출하면 test context가 공유할 ControlledFailureHook 새 객체 하나가 bean이 된다.
- 반례·경계 예
- 매 호출마다 서로 다른 객체를 test field와 service에 따로 쓰면 test가 바꾼 point를 service가 보지 못한다.
- 착각 방지
- @Bean 반환은 business Result나 record component를 조립하는 코드가 아니다.
- 이 블록이 하지 않는 일
- hook bean 하나만 만들며 failure point를 고르거나 예외·DB effect를 아직 만들지 않는다.
- 다음 코드와의 연결
- ControlledFailureHook은 NONE·AFTER_CLAIM·AFTER_BUSINESS 상태를 volatile field로 보관한다.
코드 조각 12 · test용 세 지점과 volatile 상태 holder
static final class ControlledFailureHook implements TransferFailureHook {
enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS }
volatile Point point = Point.NONE;
한 줄 읽기: ControlledFailureHook이 세 Point와 초기값 NONE인 volatile point를 선언한다.
- 문법을 한 줄씩 풀면
- final nested class가 TransferFailureHook을 구현하고 enum Point와 volatile 상태 field를 연다.
- 실제 값 추적
- hook state는 NONE·AFTER_CLAIM·AFTER_BUSINESS 중 하나이며 처음에는 NONE이다.
- 정상 예
- ControlledFailureHook 객체는 NONE에서 시작하고 뒤 override 두 개가 AFTER_CLAIM·AFTER_BUSINESS를 읽는다.
- 반례·경계 예
- Point 상수 이름을 override의 비교값과 다르게 쓰면 고른 failure 지점에서 예외가 나지 않는다.
- 착각 방지
- 이 nested Point enum은 test hook 상태이며 HTTP status·ErrorCode와 관계없다.
- 이 블록이 하지 않는 일
- type 선언만으로 hook이 호출되거나 RuntimeException이 발생하지 않는다.
- 다음 코드와의 연결
- afterClaim은 point가 AFTER_CLAIM일 때만 injected after claim RuntimeException을 던진다.
코드 조각 13 · AFTER_CLAIM에서만 던지는 callback
@Override public void afterClaim() {
if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
}
한 줄 읽기: afterClaim은 point가 AFTER_CLAIM일 때만 injected after claim 예외를 던진다.
- 문법을 한 줄씩 풀면
- afterClaim override는 volatile point를 읽고 AFTER_CLAIM과 같을 때 exact RuntimeException을 던지며 아니면 반환한다.
- 실제 값 추적
- point가 AFTER_CLAIM과 같을 때만 injected after claim RuntimeException을 던지고, 아니면 아무 일 없이 돌아온다.
- 정상 예
- point == Point.AFTER_CLAIM이 false면 예외 없이 다음 statement로 간다.
- 반례·경계 예
- hook point가 AFTER_CLAIM가 아니면 이 RuntimeException은 발생하지 않는다.
- 착각 방지
- afterClaim은 예외만 던지고 rollback 자체는 이 callback을 둘러싼 TransferService transaction이 수행한다.
- 이 블록이 하지 않는 일
- TransferFailurePointIT 64–66행은 process hard kill·checked exception·외부 시스템 실패를 재현하지 않는다.
- 다음 코드와의 연결
- afterBusinessMutation은 AFTER_BUSINESS일 때만 injected after business RuntimeException을 던진다.
코드 조각 14 · AFTER_BUSINESS에서만 던지는 callback
@Override public void afterBusinessMutation() {
if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business");
}
}
한 줄 읽기: afterBusinessMutation은 AFTER_BUSINESS에서만 injected after business 예외를 던진다.
- 문법을 한 줄씩 풀면
- afterBusinessMutation override는 point가 AFTER_BUSINESS일 때 exact RuntimeException을 던지고 다른 상태에서는 no-op이다.
- 실제 값 추적
- point가 AFTER_BUSINESS일 때만 exact message injected after business RuntimeException을 던지고, 아니면 no-op이다.
- 정상 예
- point == Point.AFTER_BUSINESS가 false면 예외 없이 다음 statement로 간다.
- 반례·경계 예
- hook point가 AFTER_BUSINESS가 아니면 이 RuntimeException은 발생하지 않는다.
- 착각 방지
- afterBusinessMutation도 DB를 직접 되감지 않고 unchecked 예외로 바깥 transaction을 실패시킨다.
- 이 블록이 하지 않는 일
- TransferFailurePointIT 67–70행은 process hard kill·checked exception·외부 시스템 실패를 재현하지 않는다.
- 다음 코드와의 연결
- 마지막 brace가 ControlledFailureHook과 TransferFailurePointIT의 열린 scope를 닫는다.
코드 조각 15 · TransferFailurePointIT class 닫기
}
한 줄 읽기: 마지막 brace 하나가 rollback integration test의 outer class를 닫는다.
- 문법을 한 줄씩 풀면
- 이 closing brace는 새 statement 없이 TransferFailurePointIT의 lexical scope를 끝낸다.
- 실제 값 추적
- 두 @Test, 공통 helper, nested configuration, controlled hook 정의가 이 지점에서 모두 class 안에 포함된 채 끝난다.
- 정상 예
- 앞에서 만든 반환값·assertion 결과는 유지된 채 } 읽기의 scope만 여기서 끝난다.
- 반례·경계 예
- 마지막 중괄호를 앞당기면 직전 method/type의 scope가 끊겨 뒤 statement가 바깥으로 밀려난다.
- 착각 방지
- } 자체가 commit·rollback·return을 실행하는 것은 아니다. 이미 열린 scope를 닫는다.
- 이 블록이 하지 않는 일
- TransferFailurePointIT 71–71행은 새 값이나 assertion을 더하지 않고 원문 구조만 끝낸다.
- 다음 코드와의 연결
- TransferIdempotencyIT는 동일 payload replay와 changed amount conflict가 추가 effect를 만들지 않는지 확인한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java
- 전제조건
- 실제 PostgreSQL, final TransferService, test hook configuration이 필요하다.
- 반드시 지킬 계약
- 매 test truncate/open, AFTER_CLAIM/AFTER_BUSINESS 두 @Test, 공통 helper의 세 DB count0을 보존한다.
- 추천 입력 순서
- imports/config → fixture → test2 → failure helper → bean config/controlled hook 순서다.
- 자기 점검
- @Test2, key2, RuntimeException message injected, 세 table effect0을 대조한다.
- 이번 파일의 범위 밖
- 잔액 직접 assertion·process kill·외부 효과·checked exception rollback은 증명하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired TransferService transfers;
@Autowired ControlledFailureHook hook;
Account from;
Account to;
@BeforeEach void clean() {
hook.point = ControlledFailureHook.Point.NONE;
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "ROLL-FROM", 10_000);
to = openings.open("customer-2", "ROLL-TO", 5_000);
}
@Test void afterClaimRuntimeExceptionRollsBackClaim() {
hook.point = ControlledFailureHook.Point.AFTER_CLAIM;
assertFailureAndNoEffect("after-claim");
}
@Test void afterBusinessRuntimeExceptionRollsBackEveryEffect() {
hook.point = ControlledFailureHook.Point.AFTER_BUSINESS;
assertFailureAndNoEffect("after-business");
}
private void assertFailureAndNoEffect(String key) {
assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
"customer-1", key, from.getId(), to.getId(), 1_000)))
.isInstanceOf(RuntimeException.class).hasMessageContaining("injected");
assertThat(jdbc.sql("SELECT COUNT(*) FROM idempotency_request").query(Long.class).single()).isZero();
assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
.query(Long.class).single()).isZero();
assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
.query(Long.class).single()).isZero();
}
@TestConfiguration(proxyBeanMethods = false)
static class FailureConfiguration {
@Bean ControlledFailureHook controlledFailureHook() { return new ControlledFailureHook(); }
}
static final class ControlledFailureHook implements TransferFailureHook {
enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS }
volatile Point point = Point.NONE;
@Override public void afterClaim() {
if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
}
@Override public void afterBusinessMutation() {
if (point == Point.AFTER_BUSINESS) throw new RuntimeException("injected after business");
}
}
}
지점 A
afterClaimRuntimeException지점 B
afterBusinessMutationRuntimeException12. TransferIdempotencyIT
한 문장 역할: 같은 payload/key는 false 뒤 true replay, 같은 key의 amount 변경은 conflict이며 transfer1·ledger2만 남는지 확인한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 목요일 exact selector 첫 번째 |
| 무엇을 받나 | 10,000/5,000 계좌, key same-key, amount1000 후2000 |
| 무엇이 바뀌나 | 첫 요청만 잔액/거래/원장을 변경; replay/changed는 추가 effect 없음 |
| 무엇을 돌려주나 | false, true, BusinessException IDEMPOTENCY_CONFLICT, tx1/ledger2 |
정확한 전체 원문
정확한 전체 원문 펼치기
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.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
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;
import static org.assertj.core.api.Assertions.catchThrowable;
@SpringBootTest
class TransferIdempotencyIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired TransferService transfers;
Account from;
Account to;
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "IDEM-FROM", 10_000);
to = openings.open("customer-2", "IDEM-TO", 5_000);
}
@Test
void samePayloadReplaysAndChangedPayloadConflictsWithoutExtraEffect() {
var first = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 1_000);
var changed = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 2_000);
assertThat(transfers.transfer(first).replayed()).isFalse();
assertThat(transfers.transfer(first).replayed()).isTrue();
Throwable failure = catchThrowable(() -> transfers.transfer(changed));
assertThat(failure)
.as("W12D4_RED_EXPECTED_SEMANTIC_CONFLICT")
.isInstanceOf(BusinessException.class);
assertThat(((BusinessException) failure).code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT);
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);
}
}
코드 조각 1 · replay·conflict 시험의 계좌·오류·JUnit 도구
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.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
한 줄 읽기: PostgreSQL base, 계좌 fixture, BusinessException·ErrorCode, JUnit과 주입 타입을 연결한다.
- 문법을 한 줄씩 풀면
- domain/API class와 BeforeEach·Test·Autowired import가 한 service-level idempotency integration test를 구성한다.
- 실제 값 추적
- BusinessException과 ErrorCode는 changed payload 실패를, Account와 opening service는 잔액 fixture를 표현한다.
- 정상 예
- 실제 PostgreSQL에서 10,000/5,000 계좌를 만들고 replay flag, BusinessException code, 거래·원장 count를 검사할 준비다.
- 반례·경계 예
- PostgresIntegrationTestSupport 연결을 빼면 이 class가 상속하는 integration base type을 컴파일할 수 없다.
- 착각 방지
- ErrorCode import가 conflict를 일으키지 않는다. changed command 호출 결과의 code를 test가 나중에 읽는다.
- 이 블록이 하지 않는 일
- 이 package/import 묶음은 Command를 만들거나 BusinessException을 잡고 DB 행 수를 세지 않는다.
- 다음 코드와의 연결
- 다음 import 구간이 SpringBootTest·JdbcClient와 assertThat·catchThrowable을 연결한다.
코드 조각 2 · Spring context·JdbcClient·exception capture 도구
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
한 줄 읽기: SpringBootTest와 JdbcClient, assertThat, catchThrowable을 service replay·conflict 검증에 연결한다.
- 문법을 한 줄씩 풀면
- catchThrowable은 changed 호출 예외를 값으로 보존하고 JdbcClient는 transaction·ledger exact count를 읽는다.
- 실제 값 추적
- SpringBootTest는 context를 열고 JdbcClient는 SQL count를, assertThat·catchThrowable은 값과 예외 판정을 맡는다.
- 정상 예
- 용도: SpringBootTest=통합 context; JdbcClient=SQL 실행·count; assertThat=값 비교; catchThrowable=예외를 값으로 받기.
- 반례·경계 예
- catchThrowable 연결이 빠지면 changed command 예외를 failure 값으로 잡는 호출을 해석할 수 없다.
- 착각 방지
- @SpringBootTest import는 context type을 연결할 뿐 동일 Command 두 호출을 자동 실행하지 않는다.
- 이 블록이 하지 않는 일
- 이 span은 transfer를 호출하거나 transaction·ledger COUNT query를 실행하지 않는다.
- 다음 코드와의 연결
- class가 PostgreSQL support 위에서 Spring context를 열고 JdbcClient를 주입받는다.
코드 조각 3 · TransferIdempotencyIT type 경계
@SpringBootTest
class TransferIdempotencyIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
한 줄 읽기: TransferIdempotencyIT의 선언 범위를 열 뿐, method나 test를 지금 실행하지 않는다.
- 문법을 한 줄씩 풀면
- @SpringBootTest class가 PostgreSQL support를 상속하고 첫 @Autowired field로 JdbcClient를 받는다.
- 실제 값 추적
- SpringBootTest, Autowired가 이 class를 해당 Spring context의 관리 대상으로 읽게 한다. 선언만으로 method는 실행되지 않는다.
- 정상 예
- JUnit이 class를 실행할 때 full context의 JdbcClient가 SQL cleanup·count 관찰 창구로 주입된다.
- 반례·경계 예
- @SpringBootTest를 빼면 TransferIdempotencyIT의 full Spring integration context 계약이 사라진다.
- 착각 방지
- SpringBootTest class와 jdbc field 선언만으로 replay test body가 실행되지는 않는다.
- 이 블록이 하지 않는 일
- 이 class header와 jdbc field는 same-key Command를 만들거나 transfer를 호출하지 않는다.
- 다음 코드와의 연결
- AccountOpeningService·TransferService와 from·to fixture field를 선언한다.
코드 조각 4 · @Autowired AccountOpeningService openings; 읽기
@Autowired AccountOpeningService openings;
@Autowired TransferService transfers;
Account from;
Account to;
한 줄 읽기: openings, transfers는 Spring test context가 주입한다; from, to는 @BeforeEach가 실제 계좌 값으로 채운다.
- 문법을 한 줄씩 풀면
- 두 @Autowired field는 계좌 개설·transfer bean을 받고, from·to field는 BeforeEach 결과를 보관한다.
- 실제 값 추적
- context가 opening service와 transfer service를 주입하고, from·to는 뒤 BeforeEach가 새 Account로 채운다.
- 정상 예
- Spring이 openings, transfers를 주입하고 @BeforeEach가 from, to를 실제 계좌로 채운다.
- 반례·경계 예
- openings, transfers 주입이나 from, to fixture 대입을 빼면 context 시작 또는 test Act가 실패한다.
- 착각 방지
- openings·transfers는 context bean이고 from·to는 BeforeEach 결과라 같은 방식으로 주입되는 field가 아니다.
- 이 블록이 하지 않는 일
- 이 span은 협력 객체와 fixture field만 선언하며 같은-key 호출이나 SQL count를 실행하지 않는다.
- 다음 코드와의 연결
- BeforeEach가 네 표를 비우고 customer-1/2의 10,000/5,000 계좌를 새로 연다.
코드 조각 5 · idempotency 시험의 10,000/5,000 시작 상태
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "IDEM-FROM", 10_000);
to = openings.open("customer-2", "IDEM-TO", 5_000);
}
한 줄 읽기: 네 표를 비우고 customer-1 from 10,000·customer-2 to 5,000 계좌를 각 test에 만든다.
- 문법을 한 줄씩 풀면
- 25–29행: @BeforeEach는 각 test 전에 fixture를 초기화해 이전 행의 영향을 차단한다.
- 실제 값 추적
- 네 표를 비운 뒤 IDEM-FROM 10,000원과 IDEM-TO 5,000원 계좌를 새로 열어 replay·conflict test의 시작값으로 둔다.
- 정상 예
- IDEM-FROM 10,000원·IDEM-TO 5,000원을 새 identity로 열어 replay·conflict test의 시작 상태를 고정한다.
- 반례·경계 예
- TRUNCATE나 계좌 opening을 빼면 이전 claim·transaction·ledger 또는 null account가 assertion에 들어간다.
- 착각 방지
- 이 cleanup과 계좌 개설은 effect-once 결론이 아니라 해당 test의 Arrange 조건이다.
- 이 블록이 하지 않는 일
- TransferIdempotencyIT 25–29행의 TRUNCATE/open은 test 전용이며 production 데이터 정리 절차를 뜻하지 않는다.
- 다음 코드와의 연결
- test는 same-key의 amount 1,000 command와 amount만 2,000으로 바꾼 command를 준비한다.
코드 조각 6 · 첫 false와 동일 Command replay true
@Test
void samePayloadReplaysAndChangedPayloadConflictsWithoutExtraEffect() {
var first = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 1_000);
var changed = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 2_000);
assertThat(transfers.transfer(first).replayed()).isFalse();
assertThat(transfers.transfer(first).replayed()).isTrue();
한 줄 읽기: 첫 같은-key 결과는 false, 동일 Command 재호출은 true인지 먼저 확인한다.
- 문법을 한 줄씩 풀면
- @Test가 method를 selector에 노출하고 isFalse·isTrue가 first와 replay Result의 replayed flag를 각각 비교한다.
- 실제 값 추적
- amount 1,000인 first와 amount 2,000인 changed를 만들고, first의 첫 호출 false·둘째 호출 true를 확인한다.
- 정상 예
- amount 1,000인 first Result가 replayed=false이고 같은 Command 재호출이 true면 두 flag assertion이 통과한다.
- 반례·경계 예
- 첫 결과가 true이거나 동일 재호출이 false면 owner/replay 분기가 뒤집힌 것이다.
- 착각 방지
- 두 flag는 amount 2,000인 changed 호출의 conflict를 아직 확인하지 않는다.
- 이 블록이 하지 않는 일
- 예외 code와 DB effect count는 다음 두 조각이 맡는다.
- 다음 코드와의 연결
- 동일 command 첫 호출 false와 둘째 호출 true 뒤 changed 호출의 예외를 값으로 잡는다.
코드 조각 7 · changed amount conflict와 거래 1행
Throwable failure = catchThrowable(() -> transfers.transfer(changed));
assertThat(failure)
.as("W12D4_RED_EXPECTED_SEMANTIC_CONFLICT")
.isInstanceOf(BusinessException.class);
assertThat(((BusinessException) failure).code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT);
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_%'")
한 줄 읽기: amount만 바꾼 호출의 conflict code와 기존 TRANSFER 거래 1행을 확인한다.
- 문법을 한 줄씩 풀면
- catchThrowable이 changed 호출 예외를 보존하고 type·ErrorCode를 확인한 뒤 SQL scalar count를 1과 비교한다.
- 실제 값 추적
- changed 호출은 BusinessException·IDEMPOTENCY_CONFLICT여야 하고 TRANSFER business_tx COUNT는 1이어야 한다.
- 정상 예
- changed 호출의 type·code가 맞고 TRANSFER business_tx COUNT가 1이면 이 span의 assertion이 통과한다.
- 반례·경계 예
- changed 호출이 replay되거나 transaction COUNT가 2면 다른 semantic 요청이 추가 반영된 것이다.
- 착각 방지
- exception matcher는 첫 transaction을 지우거나 새 transaction을 막는 장치가 아니다.
- 이 블록이 하지 않는 일
- 이 구간은 conflict type·code와 TRANSFER transaction 1행까지 보며 ledger 2행 비교는 마지막 span, 잔액 exact 값은 test 전체에서 직접 읽지 않는다.
- 다음 코드와의 연결
- 마지막 assertion이 TRANSFER ledger count를 2와 비교해 첫 업무의 OUT·IN 외 추가 posting이 없음을 확인한다.
코드 조각 8 · TRANSFER ledger 두 행 assertion
.query(Long.class).single()).isEqualTo(2);
}
}
한 줄 읽기: 앞 query의 결과를 2와 비교해 첫 송금의 OUT·IN 외 추가 transfer ledger가 없음을 확인한다.
- 문법을 한 줄씩 풀면
- query(Long.class).single()이 scalar count를 받고 isEqualTo(2)가 exact 기대값과 비교한 뒤 method와 class를 닫는다.
- 실제 값 추적
- 첫 amount 1,000 업무가 만든 TRANSFER_OUT·TRANSFER_IN 두 행만 남아 count 2여야 한다.
- 정상 예
- TRANSFER ledger COUNT가 2이면 changed 요청이 첫 이체의 두 posting 외 행을 더 만들지 않았다.
- 반례·경계 예
- changed amount 호출이 업무를 한 번 더 수행하면 transfer ledger count가 4가 되어 이 assertion이 실패한다.
- 착각 방지
- ledger 2행만으로 각 entry amount·signed sum·잔액 값을 모두 확인한 것은 아니다.
- 이 블록이 하지 않는 일
- business_tx 1행은 앞 span이 확인했고 여기서는 TRANSFER 원장 행 수만 본다.
- 다음 코드와의 연결
- TransferResponse는 controller가 반환할 transaction IDs·status·잔액·replayed 여섯 값을 묶는다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransferIdempotencyIT.java
- 전제조건
- 실제 PostgreSQL, final TransferService와 repository fixture가 필요하다.
- 반드시 지킬 계약
- truncate/open → first1000 false → same true → changed2000 conflict → tx1/ledger2를 지킨다.
- 추천 입력 순서
- imports/fields → fixture → command2 → 세 호출 결과 → exception code → DB counts 순서다.
- 자기 점검
- replayed false/true, failure type/code, TRANSFER count1, ledger count2를 대조한다.
- 이번 파일의 범위 밖
- HTTP status409·동시 같은 key·from/to 변경·idempotency row count는 직접 검사하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
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;
import static org.assertj.core.api.Assertions.catchThrowable;
@SpringBootTest
class TransferIdempotencyIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired TransferService transfers;
Account from;
Account to;
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "IDEM-FROM", 10_000);
to = openings.open("customer-2", "IDEM-TO", 5_000);
}
@Test
void samePayloadReplaysAndChangedPayloadConflictsWithoutExtraEffect() {
var first = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 1_000);
var changed = new TransferService.Command("customer-1", "same-key", from.getId(), to.getId(), 2_000);
assertThat(transfers.transfer(first).replayed()).isFalse();
assertThat(transfers.transfer(first).replayed()).isTrue();
Throwable failure = catchThrowable(() -> transfers.transfer(changed));
assertThat(failure)
.as("W12D4_RED_EXPECTED_SEMANTIC_CONFLICT")
.isInstanceOf(BusinessException.class);
assertThat(((BusinessException) failure).code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT);
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);
}
}
금 · 서비스 통합 계약 아홉 개
13. TransferIntegrationTest
한 문장 역할: 실제 PostgreSQL 경로에서 replay·표현 차이·동시 same-key·반대 방향·세 semantic conflict·두 rollback을 아홉 @Test로 묶는다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 금요일 exact selector class 전체 |
| 무엇을 받나 | 매 test 10,000/10,000 두 계좌와 test별 key/amount/concurrency/hook |
| 무엇이 바뀌나 | 첫 요청의 balance/tx/ledger/idempotency 변경 또는 예외 시 opening 상태 유지 |
| 무엇을 돌려주나 | @Test9의 exact balance/count/replay/error/timeout assertions |
정확한 전체 원문
정확한 전체 원문 펼치기
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);
}
}
코드 조각 1 · PostgresIntegrationTestSupport, Account, AccountOpeningService 도구 준비
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;
한 줄 읽기: PostgresIntegrationTestSupport는 실제 PostgreSQL test base, Account는 계좌 domain 객체 이름을 준비한다.
- 문법을 한 줄씩 풀면
- 1–10행: 일반 import 여덟 개가 DB test base·계좌·업무 예외·원장·BeforeEach type의 긴 package 이름을 줄인다.
- 실제 값 추적
- 여덟 이름만 이 파일에 연결한다. 이 import 줄에서 생기는 객체·DB 행·HTTP 응답은0개다.
- 정상 예
- PostgresIntegrationTestSupport=DB base; Account/openings/accounts=계좌; BusinessException/ErrorCode=업무 오류; ledger=원장; BeforeEach=준비다.
- 반례·경계 예
- AccountRepository import가 없으면 accounts field의 type을 못 찾는다. 계좌 opening·예외·원장 이름은 영향받지 않는다.
- 착각 방지
PostgresIntegrationTestSupport를 import해도 실제 PostgreSQL base 동작은 시작되지 않는다. 이름만 연결한다.- 이 블록이 하지 않는 일
- 이 묶음은 계좌를 열거나 조회하지 않고 BusinessException을 던지거나 원장 수를 세지도 않는다.
- 다음 코드와의 연결
- 다음
Test, Autowired, SpringBootTest 도구 준비:Test는 JUnit test method 표시,Autowired는 Spring dependency 주입 이름을 준비한다.
코드 조각 2 · Test, Autowired, SpringBootTest 도구 준비
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;
한 줄 읽기: Test는 JUnit test method 표시, Autowired는 Spring dependency 주입 이름을 준비한다.
- 문법을 한 줄씩 풀면
- 11–18행: 일반 import 일곱 개가 JUnit Test, Spring 주입·context·test 설정, JdbcClient 이름을 짧게 쓴다.
- 실제 값 추적
- Test·Autowired·SpringBootTest·JdbcClient·TestConfiguration·Bean·Import만 연결하며 test 실행은0회다.
- 정상 예
- Test=실행 method 표시; Autowired=bean 주입; SpringBootTest=context; JdbcClient=SQL; 나머지 셋=test hook 설정이다.
- 반례·경계 예
- Test import가 없으면 아래 @Test annotation만 해석하지 못한다. JdbcClient와 Spring 설정 type은 그대로 남는다.
- 착각 방지
Test를 import해도 JUnit method 동작은 시작되지 않는다. 이름만 연결한다.- 이 블록이 하지 않는 일
- annotation·SQL type 이름을 연결할 뿐 context 시작·bean 생성·query 실행은 이 일곱 줄에서 일어나지 않는다.
- 다음 코드와의 연결
- 다음
assertThat, assertThatThrownBy 도구 준비:assertThat는 값 matcher 시작,assertThatThrownBy는 던진 예외 matcher 이름을 준비한다.
코드 조각 3 · assertThat, assertThatThrownBy 도구 준비
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
한 줄 읽기: assertThat는 값 matcher 시작, assertThatThrownBy는 던진 예외 matcher 이름을 준비한다.
- 문법을 한 줄씩 풀면
- 19–21행: 두 static import가 AssertJ의 assertThat·assertThatThrownBy method를 class 이름 없이 부르게 한다.
- 실제 값 추적
- 값 비교와 예외 비교 method 이름만 연결하며 matcher가 읽을 실제값은 아직 없다.
- 정상 예
- 아래 test는 assertThat으로 값·count를, assertThatThrownBy로 실패 type·message·code를 읽는다.
- 반례·경계 예
- assertThatThrownBy static import가 없으면 예외 matcher 시작점만 못 찾고 값용 assertThat은 계속 해석된다.
- 착각 방지
assertThat를 import해도 값 비교 동작은 시작되지 않는다. 이름만 연결한다.- 이 블록이 하지 않는 일
- 두 이름을 연결하는 단계라 assertion lambda·matcher·service 호출은 아직 한 번도 평가되지 않는다.
- 다음 코드와의 연결
- 다음
ArrayList, List, CountDownLatch 도구 준비:ArrayList는 가변 결과 목록,List는 순서 있는 값 목록 이름을 준비한다.
코드 조각 4 · ArrayList, List, CountDownLatch 도구 준비
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;
한 줄 읽기: ArrayList는 가변 결과 목록, List는 순서 있는 값 목록 이름을 준비한다.
- 문법을 한 줄씩 풀면
- 22–29행: 일반 import 일곱 개가 list, latch, thread pool, Future, timeout, regex type의 긴 주소를 줄인다.
- 실제 값 추적
- 동시 실행과 JSON 숫자 추출에 쓸 일곱 이름만 연결한다. worker·thread·Future는 아직0개다.
- 정상 예
- ArrayList/List=Future 보관; CountDownLatch=barrier; Executors=pool; Future/TimeUnit=회수 제한; Pattern=숫자 찾기다.
- 반례·경계 예
- CountDownLatch import가 빠지면 ready/start 선언이 compile되지 않는다. list·pool·Future·regex 이름은 그대로다.
- 착각 방지
ArrayList를 import해도 가변 결과 목록 동작은 시작되지 않는다. 이름만 연결한다.- 이 블록이 하지 않는 일
- 이 import 묶음은 thread pool·latch·Future·regex 객체를 만들지 않고 긴 type 주소만 줄인다.
- 다음 코드와의 연결
- 다음
TransferIntegrationTest type 경계: TransferIntegrationTest의 선언 범위를 열 뿐, method나 test를 지금 실행하지 않는다.
코드 조각 5 · TransferIntegrationTest type 경계
@SpringBootTest
@Import(TransferIntegrationTest.FailureHookConfiguration.class)
class TransferIntegrationTest extends PostgresIntegrationTestSupport {
한 줄 읽기: TransferIntegrationTest의 선언 범위를 열 뿐, method나 test를 지금 실행하지 않는다.
- 문법을 한 줄씩 풀면
- 30–33행:
class TransferIntegrationTest가 type scope를 연다;@SpringBootTest는 실제 Spring bean과 PostgreSQL support를 잇는 통합 시험 경계다. - 실제 값 추적
- SpringBootTest가 실제 bean context를, Import가 nested hook 설정을 이 integration class에 연결한다. 선언 시 test 실행은0회다.
- 정상 예
- compile되면
TransferIntegrationTest의 선언 범위가 생기고 실제 method는 호출될 때만 돈다. - 반례·경계 예
@SpringBootTest를 빼면TransferIntegrationTest의 full Spring integration context 계약이 사라진다.- 착각 방지
- @SpringBootTest는 context를 준비하지만 class 선언만으로 아홉 @Test body를 호출하지 않는다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 30–33행은 runtime 호출·DB mutation·assertion을 아직 수행하지 않는다.
- 다음 코드와의 연결
- 다음
test context용 failure hook bean 설정: @TestConfiguration 안에서 service와 test가 함께 쓸 ControlledFailureHook bean 하나를 만든다.
코드 조각 6 · test context용 failure hook bean 설정
@TestConfiguration
static class FailureHookConfiguration {
@Bean
ControlledFailureHook controlledFailureHook() {
return new ControlledFailureHook();
}
}
한 줄 읽기: @TestConfiguration 안에서 service와 test가 함께 쓸 ControlledFailureHook bean 하나를 만든다.
- 문법을 한 줄씩 풀면
- 34–40행:
class FailureHookConfiguration가 type scope를 연다;@TestConfiguration은 이 nested configuration을 test context 전용으로 제한한다. - 실제 값 추적
- test 전용 configuration이 ControlledFailureHook 새 객체를 bean으로 반환해 service와 test가 같은 hook을 쓴다.
- 정상 예
- Spring test context가 factory method를 부르면 같은 ControlledFailureHook 객체가 service와 test field에 주입된다.
- 반례·경계 예
@TestConfigurationannotation을 빼면FailureHookConfiguration은 test context 전용 configuration으로 자동 인식되지 않는다.- 착각 방지
- @TestConfiguration은 JUnit test method 표시가 아니라 test context 전용 bean 설정이다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 34–40행은 runtime 호출·DB mutation·assertion을 아직 수행하지 않는다.
- 다음 코드와의 연결
- 다음
통합 시험용 failure state 세 가지: ControlledFailureHook type과 NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION 세 상태를 선언한다.
코드 조각 7 · 통합 시험용 failure state 세 가지
static final class ControlledFailureHook implements TransferFailureHook {
enum Point { NONE, AFTER_CLAIM, AFTER_BUSINESS_MUTATION }
한 줄 읽기: ControlledFailureHook type과 NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION 세 상태를 선언한다.
- 문법을 한 줄씩 풀면
- 41–44행:
class ControlledFailureHook가 상속할 수 없는 type scope를 연다. - 실제 값 추적
- hook의 세 상태는 NONE·AFTER_CLAIM·AFTER_BUSINESS_MUTATION이며 뒤 methods가 현재 상태를 읽는다.
- 정상 예
- hook은 NONE에서 시작하고 두 override가 AFTER_CLAIM·AFTER_BUSINESS_MUTATION 상태를 각각 읽는다.
- 반례·경계 예
- Point 상수와 failAt 입력·override 비교를 다르게 쓰면 test가 고른 지점과 실제 throw 지점이 갈린다.
- 착각 방지
- 이 enum은 rollback 주입 위치를 나타내며 ApiExceptionHandler나 HTTP status를 고르지 않는다.
- 이 블록이 하지 않는 일
- class·enum 선언만으로 service 호출·예외·rollback은 시작되지 않는다.
- 다음 코드와의 연결
- 다음
hook 상태 지정과 test 사이 reset: test hook point를 지정 지점으로 바꾸거나 다음 test 전에 NONE으로 되돌린다.
코드 조각 8 · hook 상태 지정과 test 사이 reset
private volatile Point point = Point.NONE;
void failAt(Point point) { this.point = point; }
void reset() { this.point = Point.NONE; }
한 줄 읽기: test hook point를 지정 지점으로 바꾸거나 다음 test 전에 NONE으로 되돌린다.
- 문법을 한 줄씩 풀면
- 45–49행:
this.field = parameter가 인자를 instance field에 저장한다; 작은 setter 두 개가 volatile point를 지정 지점 또는 NONE으로 바꾼다. - 실제 값 추적
- point는 처음 NONE이고 failAt은 지정 지점으로 바꾸며 reset은 다음 test 전에 다시 NONE으로 돌린다.
- 정상 예
- 새 hook은 NONE으로 시작하고 failAt은 받은 Point를 저장하며 reset은 다음 test 전에 NONE으로 되돌린다.
- 반례·경계 예
- reset을 빼면 앞 test의 AFTER_CLAIM/AFTER_BUSINESS_MUTATION이 다음 test에 남을 수 있다.
- 착각 방지
- volatile은 thread 사이 최신 point 관찰을 돕지만 transaction rollback을 실행하지 않는다.
- 이 블록이 하지 않는 일
- 여기서는 예외를 던지지 않는다. 실제 throw 조건은 두 override method에 있다.
- 다음 코드와의 연결
- 다음
AFTER_CLAIM 통합 hook의 exact 예외: 통합 hook은 AFTER_CLAIM일 때만injected after claimRuntimeException을 던진다.
코드 조각 9 · AFTER_CLAIM 통합 hook의 exact 예외
@Override
public void afterClaim() {
if (point == Point.AFTER_CLAIM) throw new RuntimeException("injected after claim");
}
한 줄 읽기: 통합 hook은 AFTER_CLAIM일 때만 injected after claim RuntimeException을 던진다.
- 문법을 한 줄씩 풀면
- 50–53행:
afterClaim(...)가 parameter와 method body를 연다;if (point == Point.AFTER_CLAIM)가 정상 진행과 즉시 중단을 가른다. - 실제 값 추적
- point가 AFTER_CLAIM과 같을 때만
injected after claimRuntimeException을 던지고, 아니면 아무 일 없이 돌아온다. - 정상 예
point == Point.AFTER_CLAIM가 false면 예외 없이 다음 statement로 간다.- 반례·경계 예
- hook point가
AFTER_CLAIM가 아니면 이 RuntimeException은 발생하지 않는다. - 착각 방지
- afterClaim은 예외만 던지고, claim 취소는 그 예외를 받은 transaction 경계가 처리한다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 50–53행은 process hard kill·checked exception·외부 시스템 실패를 재현하지 않는다.
- 다음 코드와의 연결
- 다음
업무 변경 직후 통합 hook의 exact 예외: hook state가 AFTER_BUSINESS_MUTATION일 때만 exact injected RuntimeException을 던진다.
코드 조각 10 · 업무 변경 직후 통합 hook의 exact 예외
@Override
public void afterBusinessMutation() {
if (point == Point.AFTER_BUSINESS_MUTATION) {
throw new RuntimeException("injected after business mutation");
}
}
}
한 줄 읽기: hook state가 AFTER_BUSINESS_MUTATION일 때만 exact injected RuntimeException을 던진다.
- 문법을 한 줄씩 풀면
- 54–62행:
afterBusinessMutation(...)가 parameter와 method body를 연다;if (point == Point.AFTER_BUSINESS_MUTATION)가 정상 진행과 즉시 중단을 가른다. - 실제 값 추적
- point가 AFTER_BUSINESS_MUTATION일 때만 exact message
injected after business mutationRuntimeException을 던진다. - 정상 예
point == Point.AFTER_BUSINESS_MUTATION가 false면 예외 없이 다음 statement로 간다.- 반례·경계 예
- hook point가
AFTER_BUSINESS_MUTATION가 아니면 이 RuntimeException은 발생하지 않는다. - 착각 방지
- afterBusinessMutation은 예외 신호만 만들며 잔액·거래·원장을 되돌리는 주체는 transaction interceptor다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 54–62행은 process hard kill·checked exception·외부 시스템 실패를 재현하지 않는다.
- 다음 코드와의 연결
- 다음
@Autowired AccountRepository accounts; 읽기: accounts, openings, ledger는 Spring test context가 주입한다.
코드 조각 11 · @Autowired AccountRepository accounts; 읽기
@Autowired AccountRepository accounts;
@Autowired AccountOpeningService openings;
@Autowired LedgerEntryRepository ledger;
한 줄 읽기: accounts, openings, ledger는 Spring test context가 주입한다.
- 문법을 한 줄씩 풀면
- 63–65행:
@Autowired는 accounts, openings, ledger를 test context bean 주입 지점으로 표시한다. - 실제 값 추적
- 대입 주체는 Spring test context→accounts,openings,ledger다. 선언 시점의 DB effect는0이다.
- 정상 예
- test context가 시작되면 accounts, openings, ledger에 같은 context의 bean이 주입된다.
- 반례·경계 예
- test context에 accounts, openings, ledger bean이 없으면 주입 단계에서 이 integration test가 시작하지 못한다.
- 착각 방지
- accounts는 계좌 조회·잠금, openings는 계좌 생성, ledger는 원장 count 역할이며 서로 바꿔 쓸 수 없다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 63-65은 field의 대입 주체만 구분한다. TransferIntegrationTest:63-65에서는 SQL·service·HTTP 호출이0회다.
- 다음 코드와의 연결
- 다음
@Autowired TransferService transfers; 읽기: transfers, jdbc, failureHook는 Spring test context가 주입한다.
코드 조각 12 · @Autowired TransferService transfers; 읽기
@Autowired TransferService transfers;
@Autowired JdbcClient jdbc;
@Autowired ControlledFailureHook failureHook;
한 줄 읽기: transfers, jdbc, failureHook는 Spring test context가 주입한다.
- 문법을 한 줄씩 풀면
- 66–69행:
@Autowired는 transfers, jdbc, failureHook를 test context bean 주입 지점으로 표시한다. - 실제 값 추적
- 대입 주체는 Spring test context→transfers,jdbc,failureHook다. 선언 시점의 DB effect는0이다.
- 정상 예
- test context가 시작되면 transfers, jdbc, failureHook에 같은 context의 bean이 주입된다.
- 반례·경계 예
- test context에 transfers, jdbc, failureHook bean이 없으면 주입 단계에서 이 integration test가 시작하지 못한다.
- 착각 방지
- failureHook은 service가 쓰는 것과 같은 bean이어야 test의 failAt 변경이 실제 callback에 보인다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 66-69은 field의 대입 주체만 구분한다. TransferIntegrationTest:66-69에서는 SQL·service·HTTP 호출이0회다.
- 다음 코드와의 연결
- 다음
private Account from; 읽기: from, to는 @BeforeEach가 실제 계좌 값으로 채운다.
코드 조각 13 · private Account from; 읽기
private Account from;
private Account to;
한 줄 읽기: from, to는 @BeforeEach가 실제 계좌 값으로 채운다.
- 문법을 한 줄씩 풀면
- 70–72행: annotation 없는 from, to field는 @BeforeEach에서 넣을 fixture 값을 보관한다.
- 실제 값 추적
- 대입 주체는 @BeforeEach→from,to다. 선언 시점의 DB effect는0이다.
- 정상 예
- 각 test 전에 @BeforeEach가 from, to를 새 fixture 값으로 바꾼다.
- 반례·경계 예
- @BeforeEach가 from, to를 열어 넣지 않으면 test body가 null fixture를 읽는다.
- 착각 방지
- 이 account field는 Spring injection 대상이 아니라 매 test가 새로 여는 fixture다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 70-72은 field의 대입 주체만 구분한다. TransferIntegrationTest:70-72에서는 SQL·service·HTTP 호출이0회다.
- 다음 코드와의 연결
- 다음
매 test의 깨끗한 출발 상태: 매 test의 깨끗한 출발 상태에서 이전 시험의 흔적을 지우고 이번 값만 보이게 한다.
코드 조각 14 · 매 test의 깨끗한 출발 상태
@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의 깨끗한 출발 상태에서 이전 시험의 흔적을 지우고 이번 값만 보이게 한다.
- 문법을 한 줄씩 풀면
- 73–79행:
@BeforeEach는 각 test 전에 fixture를 초기화해 이전 행의 영향을 차단한다. - 실제 값 추적
- TRUNCATE가 네 table의 이전 행을0으로 만들고 hook은 NONE, from/to는 각각10,000으로 다시 시작한다.
- 정상 예
- 네 table을 비우고 failureHook를 reset한 뒤 from/to를 각각10,000으로 열면 아홉 integration test의 공통 출발선이다.
- 반례·경계 예
- hook reset·TRUNCATE·두 opening 중 하나를 빼면 앞 test의 failure/state 또는 비대칭 잔액이 다음 계약에 섞인다.
- 착각 방지
@BeforeEach는 보장 결과가 아니라 출발 조건이다. Green 여부는 뒤의 Act와 Assert가 정한다.- 이 블록이 하지 않는 일
- TransferIntegrationTest 73–79행의 TRUNCATE/open은 test 전용이며 production 데이터 정리 절차를 뜻하지 않는다.
- 다음 코드와의 연결
- 다음
@Test transfer_and_same_key_replay_once의 입구: key-1 Command를 두 번 호출해 첫 결과와 replay 결과를 나란히 받는다.
코드 조각 15 · @Test transfer_and_same_key_replay_once의 입구
@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);
한 줄 읽기: key-1 Command를 두 번 호출해 첫 결과와 replay 결과를 나란히 받는다.
- 문법을 한 줄씩 풀면
- 80–86행:
var두 지역변수는 같은 key의 첫 결과와 표기만 다른 replay 결과 type을 추론해 받는다;@Test가transfer_and_same_key_replay_once를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- customer-1/key-1/amount3000 Command 하나를 만들고 같은 객체를 두 번 호출해 first와 replay 변수에 결과를 받는다.
- 정상 예
- key-1 Command를 두 번 실행해 받은 first·replay를 다음 카드의 flag·잔액·원장 matcher로 넘긴다.
- 반례·경계 예
- 둘째 호출의 key나 amount를 바꾸면 같은 요청 replay를 보는 test가 아니다.
- 착각 방지
- 두 Result를 받았다는 사실만으로 replay single effect가 증명되지는 않는다.
- 이 블록이 하지 않는 일
- 여기서는 Result 두 개만 받는다. false/true·잔액·원장 합은 다음 matcher가 판정한다.
- 다음 코드와의 연결
- 다음
첫 false·replay true, 잔액7000/13000, ledger4와 signed 합0을 한 번에 판정한다: 첫 false·replay true, 잔액7000/13000, ledger4와 signed 합0을 한 번에 판정한다.
코드 조각 16 · 첫 false·replay true, 잔액7000/13000, ledger4와 signed 합0을 한 번에 판정한다
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();
}
한 줄 읽기: 첫 false·replay true, 잔액7000/13000, ledger4와 signed 합0을 한 번에 판정한다.
- 문법을 한 줄씩 풀면
- 87–94행: AssertJ
isFalse, isTrue, isEqualTo, isZero가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- first.replayed=false, replay.replayed=true, 잔액7,000/13,000, 전체 ledger4, TRANSFER signed sum0을 모두 요구한다.
- 정상 예
- opening ledger2 뒤 이체 ledger2가 더해져4행이고 TRANSFER signed 합0이면3,000원 이동이 보존된다.
- 반례·경계 예
- 둘째 호출 뒤 잔액이4,000/16,000이거나 ledger6이면 replay가 effect를 반복한 것이다.
- 착각 방지
- ledger4는 opening 두 행을 포함하며 이체만4행이라는 뜻이 아니다.
- 이 블록이 하지 않는 일
- HTTP status와 response JSON은 이 service test가 확인하지 않는다.
- 다음 코드와의 연결
- 다음
@Test json_field_order_and_whitespace_variant_replays_without_extra_effect의 입구: 뜻은 같고 표기만 다른 JSON 두 개를 replay 입력으로 준비한다.
코드 조각 17 · @Test json_field_order_and_whitespace_variant_replays_without_extra_effect의 입구
@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() + " }";
한 줄 읽기: 뜻은 같고 표기만 다른 JSON 두 개를 replay 입력으로 준비한다.
- 문법을 한 줄씩 풀면
- 95–100행:
@Test가json_field_order_and_whitespace_variant_replays_without_extra_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- from→to→amount 순 JSON과 amount→to→from 순서·공백 variant JSON 두 문자열을 같은 숫자로 만든다.
- 정상 예
- 글자 순서와 공백만 다른 JSON 두 개를 준비하고 다음 조각에서 같은 key로 실행한다.
- 반례·경계 예
- variantJson의 숫자 하나를 바꾸면 표기 차이가 아니라 다른 semantic 요청이 된다.
- 착각 방지
- 이 준비 span은 parser 결과나 DB count를 아직 assertion하지 않는다.
- 이 블록이 하지 않는 일
- 두 JSON text만 준비한다. parse·service 호출·replay flag·DB count는 아직 보지 않는다.
- 다음 코드와의 연결
- 다음
표기만 다른 JSON 두 번의 service 호출: 표기만 다른 두 JSON을 같은 representation-key로 각각 한 번 service에 보낸다.
코드 조각 18 · 표기만 다른 JSON 두 번의 service 호출
var first = transfers.transfer(commandFromJson("representation-key", firstJson));
var replay = transfers.transfer(commandFromJson("representation-key", variantJson));
한 줄 읽기: 표기만 다른 두 JSON을 같은 representation-key로 각각 한 번 service에 보낸다.
- 문법을 한 줄씩 풀면
- 101–103행:
var두 지역변수는 같은 key의 첫 결과와 표기만 다른 replay 결과 type을 추론해 받는다. - 실제 값 추적
- firstJson과 variantJson을 같은 representation-key로 각각 호출해 first와 replay Result를 받는다. assertion은 다음 조각이다.
- 정상 예
- 같은 representation-key로 firstJson과 variantJson을 차례로 Command로 바꿔 first·replay Result를 받는다.
- 반례·경계 예
- 같은 key에서 세 숫자 중 하나를 바꾸면 semantic conflict다. key까지 바꾸면 별도 신규 요청이 된다.
- 착각 방지
- 두 JSON text가 같아서 replay되는 것이 아니라 commandFromJson이 읽은 from·to·amount가 같아서다.
- 이 블록이 하지 않는 일
- 이 조각은 두 Result를 받기만 하며 flag·잔액·DB count 판정은 다음 assertion이 맡는다.
- 다음 코드와의 연결
- 다음
표기만 다른 JSON의 false/true, 잔액9000/11000과 단일 effect를 확인한다: 표기만 다른 JSON의 false/true, 잔액9000/11000과 단일 effect를 확인한다.
코드 조각 19 · 표기만 다른 JSON의 false/true, 잔액9000/11000과 단일 effect를 확인한다
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();
}
한 줄 읽기: 표기만 다른 JSON의 false/true, 잔액9000/11000과 단일 effect를 확인한다.
- 문법을 한 줄씩 풀면
- 104–109행: AssertJ
isFalse, isTrue, isEqualTo가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- 표기 다른 두 JSON 결과는 first=false/replay=true, 잔액9,000/11,000이며 helper가 tx1·ledger2·claim1을 확인한다.
- 정상 예
- field 순서·공백이 달라도 first=false, replay=true, 두 잔액과 tx1·ledger2·claim1이면 Green이다.
- 반례·경계 예
- variant가 새 effect를 만들면 잔액8000/12000 또는 helper count 증가로 Red다.
- 착각 방지
- JSON 문자열 동일을 비교한 것이 아니라 parser가 얻은 세 숫자의 뜻을 비교한다.
- 이 블록이 하지 않는 일
- 일반 JSON parser의 모든 문법·중복 field 정책을 검증하지 않는다.
- 다음 코드와의 연결
- 다음
@Test same_key_concurrent_requests_change_business_once의 입구: burst-key Command20개를 함께 보낼 pool·barrier를 준비한다.
코드 조각 20 · @Test same_key_concurrent_requests_change_business_once의 입구
@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);
한 줄 읽기: burst-key Command20개를 함께 보낼 pool·barrier를 준비한다.
- 문법을 한 줄씩 풀면
- 110–119행:
@Test가 same-key burst case를 표시하고 n=20·pool20·ready/start latch20·공통 Command를 local 변수로 만든다. - 실제 값 추적
- n20으로 ready20·start1·pool20을 만들고 customer-1/burst-key/amount1000 Command 하나를 준비한다.
- 정상 예
- n20·pool20·두 latch와 같은 burst-key Command를 준비해 worker 등록 단계로 넘긴다.
- 반례·경계 예
- pool 수가20보다 작거나 worker마다 key가 다르면 같은-key20 동시 요청 조건이 깨진다.
- 착각 방지
- 준비값만으로 owner1·잔액·행 수 결과를 미리 확정하면 안 된다.
- 이 블록이 하지 않는 일
- n·pool·latch·Command만 준비한다. Future 등록·gate 해제·owner1 확인은 뒤 조각의 일이다.
- 다음 코드와의 연결
- 다음
worker가 ready를 알리고 start 뒤 service Result를 한 번 돌려주는 barrier helper다: 같은 burst-key Command를 실행할 Future20개를 pool에 등록하고 start barrier 앞에 세운다.
코드 조각 21 · worker가 ready를 알리고 start 뒤 service Result를 한 번 돌려주는 barrier helper다
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);
}));
}
한 줄 읽기: 같은 burst-key Command를 실행할 Future20개를 pool에 등록하고 start barrier 앞에 세운다.
- 문법을 한 줄씩 풀면
- 120–127행: 빈 Future list를 만들고 loop20회에서
pool.submitlambda를 등록한다; worker는 ready를 줄이고 start를 기다린 뒤 service Result를 return한다. - 실제 값 추적
- worker20개가 ready를1씩 줄이고 start 신호를 받은 뒤 같은 burst-key command 결과를 각 Future에 저장한다.
- 정상 예
- Future20개가 등록되면 각 worker는 공통 start 앞에서 대기하며, 실제 회수와 timeout은 다음 조각에서 시작한다.
- 반례·경계 예
- Future를20개보다 적게 등록하거나 ready.countDown을 빼면 ready20이 모이지 않아 다음 await가 timeout된다.
- 착각 방지
return transfers.transfer(command);의 동시 출발은 요청을 겹치게 할 뿐 DB operation을 원자적으로 만들어 주지 않는다.- 이 블록이 하지 않는 일
- TransferIntegrationTest 120–127행은 winner 공정성·throughput·운영 부하 한계를 측정하지 않는다.
- 다음 코드와의 연결
- 다음
worker20이5초 안에 준비됐는지 보고 gate를 열어 각 결과를15초 안에 회수한다: worker20이5초 안에 준비됐는지 보고 gate를 열어 각 결과를15초 안에 회수한다.
코드 조각 22 · worker20이5초 안에 준비됐는지 보고 gate를 열어 각 결과를15초 안에 회수한다
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));
한 줄 읽기: worker20이5초 안에 준비됐는지 보고 gate를 열어 각 결과를15초 안에 회수한다.
- 문법을 한 줄씩 풀면
- 128–132행:
Future.get(시간, 단위)는 worker 결과를 받거나 시간 초과로 실패한다; AssertJisTrue가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- ready20이5초 안에 모이면 start를 열고 Future20개를 각각15초 안에 results list로 회수한다.
- 정상 예
- ready=true 뒤 start를 열고 Future20개가 모두15초 안에 Result를 주면 집계 단계로 간다.
- 반례·경계 예
- 준비 timeout이나 Future 하나의 timeout·예외면 non-replay 수를 세기 전에 Red다.
- 착각 방지
- ready assertion은 DB 원자성을 만들지 않고 worker가 출발선에 모였는지만 본다.
- 이 블록이 하지 않는 일
- non-replay1·잔액·row count는 다음 조각에서 판정한다.
- 다음 코드와의 연결
- 다음
동시20요청의 non-replay1, 잔액9000/11000과 tx1·ledger2·claim1을 확인한다: 동시20요청의 non-replay1, 잔액9000/11000과 tx1·ledger2·claim1을 확인한다.
코드 조각 23 · 동시20요청의 non-replay1, 잔액9000/11000과 tx1·ledger2·claim1을 확인한다
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();
}
}
한 줄 읽기: 동시20요청의 non-replay1, 잔액9000/11000과 tx1·ledger2·claim1을 확인한다.
- 문법을 한 줄씩 풀면
- 133–141행: AssertJ matcher들이 non-replay 수·두 잔액·ledger 행 수를 비교한다;
finally는 assertion 실패 여부와 무관하게pool.shutdownNow()를 호출한다. - 실제 값 추적
- 20 결과 중 non-replay는1, 잔액9,000/11,000, 전체 ledger4이고 helper의 tx1·ledger2·claim1도 맞아야 한다.
- 정상 예
- 첫 결과 하나만 false이고 한 번의1,000원 이동과 세 DB count가 맞으면 Green이다.
- 반례·경계 예
- false가2개거나 잔액8000/12000이면 같은 key가 두 번 업무 effect를 냈다.
- 착각 방지
- non-replay1은 어떤 worker가 winner인지나 공정성을 보장하지 않는다.
- 이 블록이 하지 않는 일
- 운영 throughput·30회 반복·다른 JVM 동시성은 측정하지 않는다.
- 다음 코드와의 연결
- 다음
@Test opposite_direction_transfers_preserve_total_balance의 입구: A→B10건과 B→A10건을 겹칠20-worker fixture를 연다.
코드 조각 24 · @Test opposite_direction_transfers_preserve_total_balance의 입구
@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 {
한 줄 읽기: A→B10건과 B→A10건을 겹칠20-worker fixture를 연다.
- 문법을 한 줄씩 풀면
- 142–150행:
@Test가 양방향 case를 표시하고 perDirection10·n20·pool20·ready/start latch20을 준비한다. - 실제 값 추적
- perDirection10에서 n20을 계산하고 ready20·start1·pool20을 준비한다. 방향별 Future 등록은 다음 조각이다.
- 정상 예
- A→B10건·B→A10건을 위한 n20·pool20·barrier를 만들고 방향별 submit으로 이어 간다.
- 반례·경계 예
- A→B나 B→A 한 방향 준비를 빼면10쌍 대칭 이체 scenario가 아니다.
- 착각 방지
- 대칭 fixture라 최종 두 잔액이 같을 뿐 모든 이체의 일반 계약은 총액 보존이다.
- 이 블록이 하지 않는 일
- pool과 barrier만 만든다. submit20·완료 회수·잔액10000·ledger42는 아직 확인하지 않는다.
- 다음 코드와의 연결
- 다음
A→B10건과 B→A10건 Future를 같은 start barrier 뒤에 등록한다: A→B10건과 B→A10건 Future를 같은 start barrier 뒤에 등록한다.
코드 조각 25 · A→B10건과 B→A10건 Future를 같은 start barrier 뒤에 등록한다
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))));
}
한 줄 읽기: A→B10건과 B→A10건 Future를 같은 start barrier 뒤에 등록한다.
- 문법을 한 줄씩 풀면
- 151–158행: loop10회가 seq별
ab-i와ba-iCommand를 만들어 Future 두 개씩 pool에 등록한다; 이 span에는Future.get이 없다. - 실제 값 추적
- seq0..9마다 A→B key ab-seq와 B→A key ba-seq Future를 하나씩, 총20개 등록한다. 실행은 barrier helper가 맡는다.
- 정상 예
- seq0..9마다 A→B와 B→A가 하나씩 등록돼 Future 총20개가 공통 barrier를 기다린다.
- 반례·경계 예
- 한 방향 submit을 빼면 대칭20건 fixture가 깨져 뒤 pair-equal 결과를 같은 의미로 읽을 수 없다.
- 착각 방지
- pool.submit은 양방향 호출을 겹치게 등록할 뿐 각 DB 변경을 원자적으로 만드는 장치가 아니다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 151–158행은 winner 공정성·throughput·운영 부하 한계를 측정하지 않는다.
- 다음 코드와의 연결
- 다음
양방향 worker20의 준비를 확인하고 gate를 열어 각 Future를20초 안에 회수한다: 양방향 worker20의 준비를 확인하고 gate를 열어 각 Future를20초 안에 회수한다.
코드 조각 26 · 양방향 worker20의 준비를 확인하고 gate를 열어 각 Future를20초 안에 회수한다
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
for (var future : futures) future.get(20, TimeUnit.SECONDS);
한 줄 읽기: 양방향 worker20의 준비를 확인하고 gate를 열어 각 Future를20초 안에 회수한다.
- 문법을 한 줄씩 풀면
- 159–162행:
Future.get(시간, 단위)는 worker 결과를 받거나 시간 초과로 실패한다; AssertJisTrue가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- 양방향 worker20이5초 안에 준비되면 start를 열고 각 Future를20초 안에 끝까지 회수한다.
- 정상 예
- ready=true이고 A→B10·B→A10 결과가 모두20초 안에 끝나면 보존식 assertion으로 간다.
- 반례·경계 예
- 한 worker라도 timeout·예외면 최종 잔액을 읽기 전에 concurrency scenario가 실패한다.
- 착각 방지
- 완료 회수는 deadlock이 절대 없다는 일반 증명이 아니라 이 한 실행의 결과다.
- 이 블록이 하지 않는 일
- 잔액10000/10000·합20000·ledger42는 다음 조각에서 확인한다.
- 다음 코드와의 연결
- 다음
양방향 이체 뒤 두 잔액10000, 합20000과 opening 포함 ledger42를 확인한다: 양방향 이체 뒤 두 잔액10000, 합20000과 opening 포함 ledger42를 확인한다.
코드 조각 27 · 양방향 이체 뒤 두 잔액10000, 합20000과 opening 포함 ledger42를 확인한다
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();
}
}
한 줄 읽기: 양방향 이체 뒤 두 잔액10000, 합20000과 opening 포함 ledger42를 확인한다.
- 문법을 한 줄씩 풀면
- 163–172행: 네
isEqualTo가 두 잔액·합계·ledger42를 비교한다;finally는 latch를 건드리지 않고pool.shutdownNow()만 호출한다. - 실제 값 추적
- fromBalance=10,000, toBalance=10,000, 합=20,000, opening2+transfer40 ledger=42를 차례로 읽고 마지막에 pool 종료를 요청한다.
- 정상 예
- A→B10건과 B→A10건이 상쇄돼 각10,000·총20,000, ledger2+40=42면 Green이다.
- 반례·경계 예
- 합이20,000이 아니거나 ledger가42가 아니면 돈 보존 또는 posting 수 계약이 깨진다.
- 착각 방지
- 각 잔액 동일은 일반 요구사항이 아니라 이 대칭 입력에서만 나온 결과다.
- 이 블록이 하지 않는 일
- 실제 deadlock 재현·retry·scheduler 공정성은 직접 검증하지 않는다.
- 다음 코드와의 연결
- 다음
@Test same_key_with_different_semantic_request_conflicts_without_extra_effect의 입구: 같은 key에서 amount만1000→2000인 두 Command를 만든다.
코드 조각 28 · @Test same_key_with_different_semantic_request_conflicts_without_extra_effect의 입구
@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);
한 줄 읽기: 같은 key에서 amount만1000→2000인 두 Command를 만든다.
- 문법을 한 줄씩 풀면
- 173–178행:
@Test가same_key_with_different_semantic_request_conflicts_without_extra_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- 같은 conflict-key와 from/to를 쓰되 amount만1000과2000으로 다른 first·changed Command를 만든다.
- 정상 예
- 같은 conflict-key에서 amount1000 first와 amount2000 changed Command를 준비한다.
- 반례·경계 예
- changed amount도1000이면 충돌 입력이 아니라 첫 요청과 같은 payload다.
- 착각 방지
- 여기서는 입력 차이만 만들고 예외 type·code·state는 뒤 assertion이 판정한다.
- 이 블록이 하지 않는 일
- 두 Command만 만든다. BusinessException·conflict code·단일 effect는 다음 두 카드가 본다.
- 다음 코드와의 연결
- 다음
amount 변경 호출의 예외 type이 BusinessException이고 code가 conflict인지 확인한다: amount 변경 호출의 예외 type이 BusinessException이고 code가 conflict인지 확인한다.
코드 조각 29 · amount 변경 호출의 예외 type이 BusinessException이고 code가 conflict인지 확인한다
transfers.transfer(first);
assertThatThrownBy(() -> transfers.transfer(changed))
.isInstanceOfSatisfying(BusinessException.class,
failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));
한 줄 읽기: amount 변경 호출의 예외 type이 BusinessException이고 code가 conflict인지 확인한다.
- 문법을 한 줄씩 풀면
- 179–183행:
isInstanceOfSatisfying(BusinessException.class, ...)가 예외 type을 좁히고 nestedisEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)가 code field를 비교한다. - 실제 값 추적
- first amount1000은 성공하고 changed amount2000 호출은 BusinessException을 던지며 code는 IDEMPOTENCY_CONFLICT다.
- 정상 예
- changed amount Act가 BusinessException을 던지고 code=IDEMPOTENCY_CONFLICT이면 Green이다.
- 반례·경계 예
- 다른 예외 type이거나 code가 IN_PROGRESS면 semantic mismatch 분기가 틀린 것이다.
- 착각 방지
- nested matcher는 예외나 ErrorCode를 만들어 내지 않고 실제 field를 읽는다.
- 이 블록이 하지 않는 일
- message·잔액·DB count는 이 span이 직접 확인하지 않는다.
- 다음 코드와의 연결
- 다음
amount conflict 뒤 잔액9000/11000과 tx1·ledger2·claim1만 남았는지 본다: amount conflict 뒤 잔액9000/11000과 tx1·ledger2·claim1만 남았는지 본다.
코드 조각 30 · amount conflict 뒤 잔액9000/11000과 tx1·ledger2·claim1만 남았는지 본다
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);
}
한 줄 읽기: amount conflict 뒤 잔액9000/11000과 tx1·ledger2·claim1만 남았는지 본다.
- 문법을 한 줄씩 풀면
- 184–192행: AssertJ
isEqualTo가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- conflict 뒤 잔액9,000/11,000, TRANSFER tx1·ledger2, idempotency claim1만 남아야 한다.
- 정상 예
- 첫1,000원 effect 하나의 두 잔액과 세 count가 유지되면 changed 요청은 무효과다.
- 반례·경계 예
- tx2·ledger4·잔액8000/12000이면 conflict가 업무를 한 번 더 반영했다.
- 착각 방지
- count1은 idempotency row의 status/body 내용 전체를 검사한 뜻이 아니다.
- 이 블록이 하지 않는 일
- HTTP409와 response body는 이 service test가 직접 확인하지 않는다.
- 다음 코드와의 연결
- 다음
@Test same_key_with_changed_from_conflicts_without_extra_effect의 입구: 같은 key에서 from account만 바꾼 conflict 입력을 만든다.
코드 조각 31 · @Test same_key_with_changed_from_conflicts_without_extra_effect의 입구
@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);
한 줄 읽기: 같은 key에서 from account만 바꾼 conflict 입력을 만든다.
- 문법을 한 줄씩 풀면
- 193–199행:
@Test가same_key_with_changed_from_conflicts_without_extra_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- 잔액5000 other 계좌를 열고 같은 from-conflict-key에서 from만 기존 계좌→other로 바꾼 두 Command를 만든다.
- 정상 예
- other5,000을 열고 from만 바꾼 changedFrom을 만들어 conflict Act로 넘긴다.
- 반례·경계 예
- changedFrom에 기존 from ID를 다시 넣으면 from 변경 conflict를 만들지 못한다.
- 착각 방지
- other 계좌가 유지되는지는 다음 balance matcher 전에는 보장되지 않는다.
- 이 블록이 하지 않는 일
- other와 두 Command만 준비한다. 예외 code·세 잔액·행 수는 아직 보장하지 않는다.
- 다음 코드와의 연결
- 다음
from 변경 호출이 BusinessException·conflict code인지 확인한다: from 변경 호출이 BusinessException·conflict code인지 확인한다.
코드 조각 32 · from 변경 호출이 BusinessException·conflict code인지 확인한다
transfers.transfer(first);
assertThatThrownBy(() -> transfers.transfer(changedFrom))
.isInstanceOfSatisfying(BusinessException.class,
failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));
한 줄 읽기: from 변경 호출이 BusinessException·conflict code인지 확인한다.
- 문법을 한 줄씩 풀면
- 200–204행:
isInstanceOfSatisfying(BusinessException.class, ...)가 예외 type을 좁히고 nestedisEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)가 code field를 비교한다. - 실제 값 추적
- 기존 from으로 첫 이체한 뒤 other를 from으로 바꾼 호출은 BusinessException·IDEMPOTENCY_CONFLICT로 끝난다.
- 정상 예
- changedFrom Act의 type과 code가 BusinessException·IDEMPOTENCY_CONFLICT이면 Green이다.
- 반례·경계 예
- from ID가 달라졌는데 replay 성공하면 semantic hash가 from을 빠뜨린 것이다.
- 착각 방지
- 이 matcher는 other 계좌 잔액을 읽지 않는다.
- 이 블록이 하지 않는 일
- 세 잔액과 단일 DB effect는 다음 조각이 직접 확인한다.
- 다음 코드와의 연결
- 다음
from conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다: from conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다.
코드 조각 33 · from conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다
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();
}
한 줄 읽기: from conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다.
- 문법을 한 줄씩 풀면
- 205–209행: AssertJ
isEqualTo가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- from 변경 conflict 뒤 기존 from/to는9,000/11,000, 출금에 쓰지 않은 other는5,000이며 helper count도1/2/1이다.
- 정상 예
- 첫 effect 뒤 from9000·to11000, 사용되지 않은 other5000이면 Green이다.
- 반례·경계 예
- other가4000이거나 기존 잔액이 더 움직였으면 changedFrom이 부분 반영된 것이다.
- 착각 방지
- 세 잔액만으로 transaction·ledger·claim 행 수까지 알 수는 없다.
- 이 블록이 하지 않는 일
- 단일 effect row count는 바로 뒤 helper 호출이 맡는다.
- 다음 코드와의 연결
- 다음
@Test same_key_with_changed_to_conflicts_without_extra_effect의 입구: 같은 key에서 to account만 바꾼 conflict 입력을 만든다.
코드 조각 34 · @Test same_key_with_changed_to_conflicts_without_extra_effect의 입구
@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);
한 줄 읽기: 같은 key에서 to account만 바꾼 conflict 입력을 만든다.
- 문법을 한 줄씩 풀면
- 210–216행:
@Test가same_key_with_changed_to_conflicts_without_extra_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- 잔액5000 other 계좌를 열고 같은 to-conflict-key에서 to만 기존 계좌→other로 바꾼 두 Command를 만든다.
- 정상 예
- other5,000을 열고 to만 바꾼 changedTo을 만들어 conflict Act로 넘긴다.
- 반례·경계 예
- changedTo에 기존 to ID를 넣으면 to 변경 conflict를 만들지 못한다.
- 착각 방지
- 같은 amount라는 사실은 to ID가 달라진 semantic 요청을 replay로 만들지 않는다.
- 이 블록이 하지 않는 일
- other와 두 Command만 준비한다. conflict type·other 잔액·행 수는 뒤 matcher가 본다.
- 다음 코드와의 연결
- 다음
to 변경 호출이 BusinessException·conflict code인지 확인한다: to 변경 호출이 BusinessException·conflict code인지 확인한다.
코드 조각 35 · to 변경 호출이 BusinessException·conflict code인지 확인한다
transfers.transfer(first);
assertThatThrownBy(() -> transfers.transfer(changedTo))
.isInstanceOfSatisfying(BusinessException.class,
failure -> assertThat(failure.code()).isEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT));
한 줄 읽기: to 변경 호출이 BusinessException·conflict code인지 확인한다.
- 문법을 한 줄씩 풀면
- 217–221행:
isInstanceOfSatisfying(BusinessException.class, ...)가 예외 type을 좁히고 nestedisEqualTo(ErrorCode.IDEMPOTENCY_CONFLICT)가 code field를 비교한다. - 실제 값 추적
- 기존 to로 첫 이체한 뒤 other를 to로 바꾼 호출은 BusinessException·IDEMPOTENCY_CONFLICT로 끝난다.
- 정상 예
- changedTo Act의 type과 code가 BusinessException·IDEMPOTENCY_CONFLICT이면 Green이다.
- 반례·경계 예
- to ID가 달라졌는데 replay 성공하면 semantic hash가 to를 빠뜨린 것이다.
- 착각 방지
- 예외 code 비교는 other 계좌 state를 대신 확인하지 않는다.
- 이 블록이 하지 않는 일
- 세 잔액과 single-effect count는 다음 조각이 맡는다.
- 다음 코드와의 연결
- 다음
to conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다: to conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다.
코드 조각 36 · to conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다
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();
}
한 줄 읽기: to conflict 뒤 기존 두 잔액9000/11000과 other5000이 유지되는지 본다.
- 문법을 한 줄씩 풀면
- 222–226행: AssertJ
isEqualTo가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- to 변경 conflict 뒤 기존 from/to는9,000/11,000, 입금되지 않은 other는5,000이며 helper count도1/2/1이다.
- 정상 예
- 첫 effect 외에 changedTo가 입금하지 않아 other5000과 기존 두 잔액이 유지되면 Green이다.
- 반례·경계 예
- other가6000이거나 기존 잔액이 더 움직이면 conflict 전에 mutation이 새어 나온 것이다.
- 착각 방지
- balance 세 개만으로 row count 계약까지 자동 확인되지는 않는다.
- 이 블록이 하지 않는 일
- tx1·ledger2·claim1은 이어지는 helper가 확인한다.
- 다음 코드와의 연결
- 다음
@Test runtime_exception_after_claim_rolls_back_every_database_effect의 입구: AFTER_CLAIM hook과 fail-claim Command로 rollback Act를 준비한다.
코드 조각 37 · @Test runtime_exception_after_claim_rolls_back_every_database_effect의 입구
@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);
한 줄 읽기: AFTER_CLAIM hook과 fail-claim Command로 rollback Act를 준비한다.
- 문법을 한 줄씩 풀면
- 227–232행:
@Test가runtime_exception_after_claim_rolls_back_every_database_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- failureHook을 AFTER_CLAIM으로 맞추고 customer-1/fail-claim/amount1000 Command를 준비한다. 호출은 다음 조각이다.
- 정상 예
- hook을 AFTER_CLAIM으로 두고 fail-claim Command를 만들어 다음 lambda에서 service를 호출한다.
- 반례·경계 예
- hook을 NONE이나 AFTER_BUSINESS_MUTATION에 두면 AFTER_CLAIM 실패 scenario가 아니다.
- 착각 방지
- point 선택만으로 rollback은 끝나지 않으며 다음 예외·state assertion이 필요하다.
- 이 블록이 하지 않는 일
- failure point와 Command만 정한다. 예외 message·잔액 복원·신규 행0은 다음 조각이 확인한다.
- 다음 코드와의 연결
- 다음
AFTER_CLAIM 호출의 RuntimeException 계열·exact message: AFTER_CLAIM 호출이 RuntimeException 계열이고 message가 정확히injected after claim인지 본다.
코드 조각 38 · AFTER_CLAIM 호출의 RuntimeException 계열·exact message
assertThatThrownBy(() -> transfers.transfer(command))
.isInstanceOf(RuntimeException.class)
.hasMessage("injected after claim");
assertOnlyOpeningStateRemains();
}
한 줄 읽기: AFTER_CLAIM 호출이 RuntimeException 계열이고 message가 정확히 injected after claim인지 본다.
- 문법을 한 줄씩 풀면
- 233–237행:
isInstanceOf(RuntimeException.class)는 RuntimeException과 하위 type을 허용하고hasMessage는 exact 문구를 비교한다. - 실제 값 추적
- 호출은 RuntimeException이고 message는
injected after claim; 뒤 helper가 opening 잔액과 신규 effect0을 확인한다. - 정상 예
- type=RuntimeException, message=
injected after claim이면 지정 hook 지점에서 멈춘 것이다. - 반례·경계 예
- 예외가 없거나 RuntimeException 계열이 아니거나 message가 한 글자라도 다르면 이 matcher는 Red다.
- 착각 방지
- 예외 matcher 자체가 claim row를 rollback하지 않는다.
- 이 블록이 하지 않는 일
- opening 잔액과 세 신규 effect0은 뒤 helper가 확인한다.
- 다음 코드와의 연결
- 다음
@Test runtime_exception_after_business_mutation_rolls_back_every_database_effect의 입구: AFTER_BUSINESS_MUTATION hook과 fail-business Command를 준비한다.
코드 조각 39 · @Test runtime_exception_after_business_mutation_rolls_back_every_database_effect의 입구
@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);
한 줄 읽기: AFTER_BUSINESS_MUTATION hook과 fail-business Command를 준비한다.
- 문법을 한 줄씩 풀면
- 238–243행:
@Test가runtime_exception_after_business_mutation_rolls_back_every_database_effect를 JUnit selector의 실행 대상으로 표시한다. - 실제 값 추적
- failureHook을 AFTER_BUSINESS_MUTATION으로 맞추고 fail-business/amount1000 Command를 준비한다. 호출은 다음 조각이다.
- 정상 예
- hook을 AFTER_BUSINESS_MUTATION으로 두고 fail-business Command를 다음 실패 Act에 넘긴다.
- 반례·경계 예
- hook을 AFTER_CLAIM에 두면 업무 mutation 직후 rollback을 시험하지 못한다.
- 착각 방지
- 이 지점은 DB transaction 내부 failure이며 외부 시스템·commit 이후 실패는 아니다.
- 이 블록이 하지 않는 일
- failure point와 Command만 준비한다. exact message와 DB effect0은 뒤 assertion이 판정한다.
- 다음 코드와의 연결
- 다음
업무 변경 직후 호출의 RuntimeException 계열·exact message: 업무 변경 직후 호출이 RuntimeException 계열이고 exact injected message를 내는지 본다.
코드 조각 40 · 업무 변경 직후 호출의 RuntimeException 계열·exact message
assertThatThrownBy(() -> transfers.transfer(command))
.isInstanceOf(RuntimeException.class)
.hasMessage("injected after business mutation");
assertOnlyOpeningStateRemains();
}
한 줄 읽기: 업무 변경 직후 호출이 RuntimeException 계열이고 exact injected message를 내는지 본다.
- 문법을 한 줄씩 풀면
- 244–248행: RuntimeException 본인·하위 type 여부와
injected after business mutationexact message를 차례로 비교한다. - 실제 값 추적
- 호출은 RuntimeException이고 message는
injected after business mutation; 뒤 helper가 opening 상태를 확인한다. - 정상 예
- type=RuntimeException, message=
injected after business mutation이면 지정 지점 실패다. - 반례·경계 예
- 예외가 없거나 RuntimeException 계열이 아니거나 business-mutation message가 다르면 rollback hook 계약이 Red다.
- 착각 방지
- message 일치만으로 DB state가 원복됐다는 결론은 아직 낼 수 없다.
- 이 블록이 하지 않는 일
- 두 잔액과 claim·transaction·ledger0은 다음 helper가 확인한다.
- 다음 코드와의 연결
- 다음
실패 뒤 두 잔액10000과 claim·transaction·ledger 신규 행0을 확인한다: 실패 뒤 두 잔액10000과 claim·transaction·ledger 신규 행0을 확인한다.
코드 조각 41 · 실패 뒤 두 잔액10000과 claim·transaction·ledger 신규 행0을 확인한다
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();
}
한 줄 읽기: 실패 뒤 두 잔액10000과 claim·transaction·ledger 신규 행0을 확인한다.
- 문법을 한 줄씩 풀면
- 249–258행:
assertOnlyOpeningStateRemains(...)가 parameter와 method body를 연다; AssertJisEqualTo, isZero가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- 두 잔액은10,000/10,000이고 idempotency·TRANSFER business_tx·TRANSFER ledger COUNT는 모두0이어야 한다.
- 정상 예
- opening 잔액10,000/10,000, idempotency0·TRANSFER tx0·TRANSFER ledger0이면 전체 DB rollback이다.
- 반례·경계 예
- 잔액은 돌아왔어도 claim1이 남거나 ledger1이 남으면 원자 rollback 계약은 Red다.
- 착각 방지
- COUNT0 matcher가 table을 지우는 것이 아니라 rollback 뒤 state를 읽는다.
- 이 블록이 하지 않는 일
- process hard kill·외부 system effect·checked exception은 재현하지 않는다.
- 다음 코드와의 연결
- 다음
commandFromJson 메서드의 값 흐름: JSON의 from·to·amount 세 숫자로 service Command를 만들어 돌려준다.
코드 조각 42 · commandFromJson 메서드의 값 흐름
private TransferService.Command commandFromJson(String key, String json) {
return new TransferService.Command(
"customer-1",
key,
semanticLong(json, "from"),
semanticLong(json, "to"),
semanticLong(json, "amount")
);
}
한 줄 읽기: JSON의 from·to·amount 세 숫자로 service Command를 만들어 돌려준다.
- 문법을 한 줄씩 풀면
- 259–268행:
commandFromJson은 semanticLong을 fromAccountId·toAccountId·amount에 각각 호출하고, 세 long과 actor·key를 Command constructor에 넘긴다. - 실제 값 추적
- semanticLong을 from·to·amount에 각각 호출해 customer-1/key와 함께 TransferService.Command를 만든다.
- 정상 예
- 세 semanticLong 호출이 from·to·amount를 얻으면 customer-1·key와 함께 Command 한 개가 반환된다.
- 반례·경계 예
- from·to·amount field 이름이나 constructor 위치를 바꾸면 같은 JSON도 다른 semantic Command가 된다.
- 착각 방지
- 이 helper는 JSON text 자체를 hash하지 않고 찾아낸 세 숫자로 Command를 만든다.
- 이 블록이 하지 않는 일
- 중복 field·음수·소수·overflow 정책과 service validation은 semanticLong·TransferService가 각각 맡는다.
- 다음 코드와의 연결
- 다음
semanticLong 메서드의 값 흐름: JSON에서 지정 숫자 field를 찾지 못하면 missing semantic field 오류로 멈춘다.
코드 조각 43 · semanticLong 메서드의 값 흐름
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));
}
한 줄 읽기: JSON에서 지정 숫자 field를 찾지 못하면 missing semantic field 오류로 멈춘다.
- 문법을 한 줄씩 풀면
- 269–274행:
semanticLong(...)가 parameter와 method body를 연다;return은 오른쪽 표현식의 결과를 이 method 호출자에게 돌려준다. - 실제 값 추적
- regex
\d+가 field 뒤의 숫자 한 자리 이상(0 포함)을 찾아 long으로 바꾸며, field가 없으면 missing semantic field 예외다. - 정상 예
- field가 있고 숫자 text가 long 범위면 값을 반환한다. 0은 regex에 맞지만 뒤 TransferService 양수 guard에서 거절된다.
- 반례·경계 예
- 요청 JSON에서 해당 field의 숫자를 못 찾으면 parse를 계속하지 않고 missing semantic field 예외다.
- 착각 방지
- 이 helper는 전체 JSON validator가 아니며
\d+자체는0도 읽는다. - 이 블록이 하지 않는 일
- TransferIntegrationTest 269–274행은 중복 field·음수·소수·overflow를 별도 정책으로 처리하지 않는다.
- 다음 코드와의 연결
- 다음
단일 이체 effect를 transaction1·ledger2·claim1 세 count로 정의한다: 단일 이체 effect를 transaction1·ledger2·claim1 세 count로 정의한다.
코드 조각 44 · 단일 이체 effect를 transaction1·ledger2·claim1 세 count로 정의한다
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);
}
한 줄 읽기: 단일 이체 effect를 transaction1·ledger2·claim1 세 count로 정의한다.
- 문법을 한 줄씩 풀면
- 275–283행:
assertOneTransferEffect(...)가 parameter와 method body를 연다; AssertJisEqualTo가 실제값과 matcher 기대값을 비교한다. - 실제 값 추적
- 한 business effect를 TRANSFER business_tx1·TRANSFER ledger2·idempotency_request1로 정의해 모두 assert한다.
- 정상 예
- 세 table COUNT가1·2·1이면 첫 업무 effect 하나와 idempotency row 하나가 남았다.
- 반례·경계 예
- tx2·ledger4·claim2 가운데 하나라도 나오면 같은 semantic 요청이 중복 반영된 것이다.
- 착각 방지
- 이 helper는 balance·entry content·status/body를 읽지 않고 행 수만 본다.
- 이 블록이 하지 않는 일
- HTTP status·응답 동일성·운영 exactly-once는 확인하지 않는다.
- 다음 코드와의 연결
- 다음
ready 신호와 start 대기를 맡는 worker helper: ready를 알리고 start를 기다린 뒤 받은 Command를 한 번 실행하는 worker helper의 입구다.
코드 조각 45 · ready 신호와 start 대기를 맡는 worker helper
private TransferService.Result invokeAfterBarrier(
CountDownLatch ready, CountDownLatch start, TransferService.Command command
) throws Exception {
ready.countDown();
start.await();
한 줄 읽기: ready를 알리고 start를 기다린 뒤 받은 Command를 한 번 실행하는 worker helper의 입구다.
- 문법을 한 줄씩 풀면
- 284–289행:
invokeAfterBarrier(...)가 parameter와 method body를 연다; ready latch는 worker 도착 수를, start latch는 한 번의 공통 출발 신호를 나타낸다. - 실제 값 추적
- worker는 ready를1 줄여 도착을 알리고 start 신호가 올 때까지 기다린다. service 호출은 다음 return 줄에 있다.
- 정상 예
- worker가 ready를 한 번 줄이고 start 신호를 받으면 다음 줄의 service 호출로 넘어간다.
- 반례·경계 예
- ready가 목표 수에 못 미친 채 start를 기다리면 timeout 또는 교착된 test setup이 된다.
- 착각 방지
- ready/start latch는 출발 시점을 가깝게 맞출 뿐 계좌 잠금·claim 원자성을 대신하지 않는다.
- 이 블록이 하지 않는 일
- TransferIntegrationTest 284–289행은 winner 공정성·throughput·운영 부하 한계를 측정하지 않는다.
- 다음 코드와의 연결
- 다음
return transfers.transfer(command); 읽기: barrier를 지난 service Result를 Future 호출자에게 그대로 돌려준다.
코드 조각 46 · return transfers.transfer(command); 읽기
return transfers.transfer(command);
}
}
한 줄 읽기: barrier를 지난 service Result를 Future 호출자에게 그대로 돌려준다.
- 문법을 한 줄씩 풀면
- 290–292행:
return은 오른쪽 표현식의 결과를 이 method 호출자에게 돌려준다. - 실제 값 추적
- barrier를 지난 worker가 service Result 또는 예외를 Future 호출자에게 그대로 돌려주고 helper가 닫힌다.
- 정상 예
- barrier를 통과한 worker가 service 호출에 성공하면 그 Result가 Future의 결과로 그대로 전달된다.
- 반례·경계 예
- Result를 삼키거나 다른 값으로 바꾸면 호출자가 replayed flag와 transaction 결과를 집계할 수 없다.
- 착각 방지
- 이 return은 service를 재시도하거나 Result component 순서를 바꾸지 않는다.
- 이 블록이 하지 않는 일
- worker helper는 assertion·timeout 설정·pool 종료를 수행하지 않는다.
- 다음 코드와의 연결
- TransferIntegrationTest 설명 끝. 저장 경로와 여섯 조건을 확인한 뒤 exact 전체 정답을 다시 쓴다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransferIntegrationTest.java
- 전제조건
- SpringBootTest, 실제 PostgreSQL, final TransferService, failure hook bean, pool20이 필요하다.
- 반드시 지킬 계약
- @Test9와 helper4, same-key20 non-replay1, opposite20 exact balances/ledger42, 세 conflict, 두 rollback을 보존한다.
- 추천 입력 순서
- config/hook → fixture → replay2 → concurrency2 → conflict3 → rollback2 → helper4 순서다.
- 자기 점검
- @Test9 method 이름과 각 exact assertion을 아래 AAA 9장과 하나씩 대조한다.
- 이번 파일의 범위 밖
- HTTP status/body, 운영 burst100, 글로벌 deadline, crash recovery, 모든 JSON parser 입력은 증명하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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);
}
}
토 · 첫 요청201과 replay200
14. TransferResponse
한 문장 역할: HTTP 응답의 transactionId·businessTransactionId·status·두 잔액·replayed 여섯 필드를 immutable record로 고정한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | TransferController와 JSON serializer |
| 무엇을 받나 | 여섯 component 값 |
| 무엇이 바뀌나 | DB를 바꾸지 않고 response 객체만 생성 |
| 무엇을 돌려주나 | 동명 accessor와 JSON object로 직렬화될 record |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer.api;
public record TransferResponse(
String transactionId,
String businessTransactionId,
String status,
long fromBalance,
long toBalance,
boolean replayed
) {}
코드 조각 1 · TransferResponse 값 묶음
package com.example.financialcore.transfer.api;
public record TransferResponse(
String transactionId,
String businessTransactionId,
String status,
long fromBalance,
long toBalance,
boolean replayed
) {}
한 줄 읽기: TransferResponse가 transactionId·businessTransactionId·status·두 잔액·replayed를 HTTP body 값으로 묶는다.
- 문법을 한 줄씩 풀면
- record가 transactionId·businessTransactionId·status·두 잔액·replayed 여섯 component와 accessor를 만든다.
- 실제 값 추적
- TransferResponse는 transactionId·businessTransactionId·status·fromBalance·toBalance·replayed 여섯 응답값을 묶는다.
- 정상 예
- 여섯 값을 선언 순서대로 넘기면 같은 이름 accessor로 읽는 변경 불가 HTTP response 값 하나가 생긴다.
- 반례·경계 예
- TransferResponse component 순서나 type을 바꾸면 positional constructor와 accessor/JSON 모양이 달라진다.
- 착각 방지
- TransferResponse는 여섯 값을 담는 HTTP body record이며 JPA entity도 status 선택 로직도 아니다.
- 이 블록이 하지 않는 일
- TransferResponse 1–10행은 component 값을 계산·검증·저장하지 않는다.
- 다음 코드와의 연결
- TransferController는 이 record를 service Result와 request transactionId로 채우고 201 또는 200 body로 보낸다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/transfer/api/TransferResponse.java
- 전제조건
- Java record와 Spring JSON serialization이 필요하다.
- 반드시 지킬 계약
- 필드6의 이름/type/order를 controller 생성자 호출과 맞춘다.
- 추천 입력 순서
- package → record 이름 → 여섯 component → 닫기 순서다.
- 자기 점검
- transactionId 두 문자열, status 문자열, balance long2, replayed boolean을 대조한다.
- 이번 파일의 범위 밖
- HTTP status·업무 실행·replay 판단·body equality assertion은 record가 하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer.api;
public record TransferResponse(
String transactionId,
String businessTransactionId,
String status,
long fromBalance,
long toBalance,
boolean replayed
) {}
15. TransferController
한 문장 역할: 인증 principal과 validated request를 service Command로 바꾸고 첫 결과는201, replay 결과는200인 TransferResponse를 만든다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | POST /api/transfers |
| 무엇을 받나 | @Valid TransferRequest와 Principal |
| 무엇이 바뀌나 | 직접 DB를 만지지 않고 TransferService 한 번 호출 |
| 무엇을 돌려주나 | COMPLETED TransferResponse; replayed false→201, true→200 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer.api;
import com.example.financialcore.transfer.TransferService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.Principal;
@RestController
@RequestMapping("/api/transfers")
public class TransferController {
private final TransferService transfers;
public TransferController(TransferService transfers) { this.transfers = transfers; }
@PostMapping
ResponseEntity<TransferResponse> transfer(
@Valid @RequestBody TransferRequest request, Principal principal
) {
var result = transfers.transfer(new TransferService.Command(
principal.getName(), request.transactionId(),
request.fromAccountId(), request.toAccountId(), request.amount()));
var body = new TransferResponse(
request.transactionId(), result.businessTransactionId(), "COMPLETED",
result.fromBalance(), result.toBalance(), result.replayed());
return ResponseEntity.status(result.replayed() ? HttpStatus.OK : HttpStatus.CREATED).body(body);
}
}
코드 조각 1 · TransferService, Valid, HttpStatus 도구 준비
package com.example.financialcore.transfer.api;
import com.example.financialcore.transfer.TransferService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
한 줄 읽기: 서비스·검증·HTTP 응답과 POST·body·base-path·controller annotation을 연결한다.
- 문법을 한 줄씩 풀면
- service type과 Jakarta Valid, Spring HTTP/MVC type을 아래 constructor·endpoint signature에서 짧은 이름으로 쓴다.
- 실제 값 추적
- Valid는 request parameter, HttpStatus·ResponseEntity는 마지막 status/body, 네 MVC annotation은 path와 binding에 쓰인다.
- 정상 예
- 검증된 request를 service Command로 보내고 Result를 HTTP status와 TransferResponse body로 돌려줄 type 조합이다.
- 반례·경계 예
- ResponseEntity·HttpStatus가 빠지면 마지막 status/body 반환식의 type과 상수를 해석할 수 없다.
- 착각 방지
- @Valid import는 request parameter의 검증 표지에 쓰이며 JSON을 읽거나 service를 호출하지 않는다.
- 이 블록이 하지 않는 일
- 이 import 묶음 자체는 /api/transfers route를 등록하거나 TransferService.transfer를 실행하지 않는다.
- 다음 코드와의 연결
- Principal import가 인증된 사용자 이름을 method parameter로 받을 타입을 더한다.
코드 조각 2 · Principal 도구 준비
import java.security.Principal;
한 줄 읽기: java.security.Principal을 가져와 인증된 actor 이름을 endpoint parameter에서 읽는다.
- 문법을 한 줄씩 풀면
- 일반 import 하나가 Principal의 전체 package 이름을 줄이고 아래 principal.getName 호출을 컴파일하게 한다.
- 실제 값 추적
- Principal import가 아래 endpoint parameter와 principal.getName() 호출의 type 이름을 연결한다.
- 정상 예
- 인증 subsystem이 제공한 Principal을 endpoint가 받아 actor 이름을 읽을 수 있다.
- 반례·경계 예
- Principal 연결이 빠지면 endpoint parameter type과 principal.getName() actor binding을 컴파일할 수 없다.
- 착각 방지
- Principal을 import해도 인증이 실행되지는 않는다. 실제 주체는 요청 시 Spring Security가 제공한다.
- 이 블록이 하지 않는 일
- Principal import는 사용자 identity를 생성·인증·인가하지 않고 요청 parameter의 type만 연결한다.
- 다음 코드와의 연결
- @RestController와 /api/transfers class mapping이 TransferService field를 가진 HTTP bean을 연다.
코드 조각 3 · /api/transfers controller와 service field
@RestController
@RequestMapping("/api/transfers")
public class TransferController {
private final TransferService transfers;
한 줄 읽기: TransferController를 /api/transfers 요청을 받는 MVC controller bean 후보로 등록한다.
- 문법을 한 줄씩 풀면
- @RestController가 MVC bean 후보를, @RequestMapping이 /api/transfers class base path를 정한다.
- 실제 값 추적
- Spring MVC가 TransferController를 bean으로 등록하고 class base path를 /api/transfers로 연결한다. method 호출은 POST 요청이 와야 시작된다.
- 정상 예
- application context가 controller를 만들면 transfers field와 뒤 POST method를 가진 route owner가 된다.
- 반례·경계 예
- @RestController annotation을 빼면 TransferController는 MVC controller bean 후보에서 빠진다.
- 착각 방지
- @RestController와 class path는 bean·base route를 정하지만 POST method의 body mapping은 뒤 annotation과 parameter가 맡는다.
- 이 블록이 하지 않는 일
- class annotation은 JSON을 역직렬화하거나 TransferService를 호출하고 HTTP status를 반환하지 않는다.
- 다음 코드와의 연결
- constructor가 TransferService를 저장하고 @PostMapping transfer method의 반환 type·입구를 연다.
코드 조각 4 · TransferService 주입과 POST method 입구
public TransferController(TransferService transfers) { this.transfers = transfers; }
@PostMapping
ResponseEntity<TransferResponse> transfer(
한 줄 읽기: 주입받은 TransferService를 저장하고 POST transfer method의 입구를 연다.
- 문법을 한 줄씩 풀면
- 18–21행: constructor가 TransferService 인자를 field에 저장한다; @PostMapping은 바로 뒤 transfer method를 POST endpoint로 연결한다.
- 실제 값 추적
- constructor가 받은 TransferService를 field에 저장하고 바로 뒤 POST method가 그 같은 bean을 호출한다.
- 정상 예
- constructor는 service bean을 보관하고 POST가 들어오면 바로 뒤 transfer method가 같은 field를 쓴다.
- 반례·경계 예
- constructor가 transfers field를 저장하지 않으면 final field 초기화가 성립하지 않고, @PostMapping을 빼면 이 method가 POST endpoint로 노출되지 않는다.
- 착각 방지
- constructor와 @PostMapping 입구는 replay 여부나 HTTP 200/201을 아직 결정하지 않는다.
- 이 블록이 하지 않는 일
- 이 구간은 request body를 읽거나 service를 호출하지 않으며 replay 여부·HTTP status도 고르지 않는다.
- 다음 코드와의 연결
- request·Principal을 Command로 바꾸고 service Result를 TransferResponse 여섯 값으로 옮긴다.
코드 조각 5 · 인증 actor와 request 네 값을 Command로 보내 Result를 HTTP body 값으로 옮긴다
@Valid @RequestBody TransferRequest request, Principal principal
) {
var result = transfers.transfer(new TransferService.Command(
principal.getName(), request.transactionId(),
request.fromAccountId(), request.toAccountId(), request.amount()));
var body = new TransferResponse(
request.transactionId(), result.businessTransactionId(), "COMPLETED",
result.fromBalance(), result.toBalance(), result.replayed());
한 줄 읽기: 인증 actor와 request 네 값을 Command로 보내 Result를 HTTP body 값으로 옮긴다.
- 문법을 한 줄씩 풀면
- 22–29행: @RequestBody는 JSON을 request로, @Valid는 제약 검사를, Principal은 인증 이름을 제공한다.
- 실제 값 추적
- 인증 principal 이름은 actorId, request.transactionId는 idempotencyKey, 세 숫자는 service Command로 전달된다.
- 정상 예
- 인증 이름과 request key·계좌 IDs·amount가 Command로 들어가고 service Result가 TransferResponse 여섯 값으로 옮겨진다.
- 반례·경계 예
- principal.getName 대신 request 값을 actor로 쓰면 인증 주체 binding이 깨지고, transactionId를 빼면 idempotency key가 달라진다.
- 착각 방지
- TransferResponse body를 만든 시점에는 status가 아직 선택되지 않았고 replayed 값도 service Result에서 온다.
- 이 블록이 하지 않는 일
- 이 구간은 HTTP status를 아직 고르지 않으며 DB row 수·409 mapping·두 응답 전체 동등성도 검증하지 않는다.
- 다음 코드와의 연결
- 마지막 ternary는 replayed=true면 200 OK, false면 201 CREATED를 선택해 같은 body를 반환한다.
코드 조각 6 · replay flag로 201·200 선택
return ResponseEntity.status(result.replayed() ? HttpStatus.OK : HttpStatus.CREATED).body(body);
}
}
한 줄 읽기: replayed=false면 201, true면 200을 고르고 같은 TransferResponse body를 반환한다.
- 문법을 한 줄씩 풀면
- 삼항식 condition은 result.replayed()다. true branch는 OK(200), false branch는 CREATED(201)이고 둘 다 같은 body를 ResponseEntity에 넣는다.
- 실제 값 추적
- 첫 결과 replayed=false는 201, 저장 결과 재사용 replayed=true는 200을 선택한다.
- 정상 예
- 첫 결과 replayed=false는 201, 저장 결과 재사용 true는 200이며 둘 다 계산된 body를 반환한다.
- 반례·경계 예
- 삼항식 branch를 뒤집으면 첫 실행 replayed=false가 200, replay replayed=true가 201이 되어 공개 계약과 반대가 된다.
- 착각 방지
- 삼항식은 false→201, true→200이다. 왼쪽·오른쪽 status를 요청 순서로 오해하면 반대로 읽게 된다.
- 이 블록이 하지 않는 일
- 이 ternary는 status와 body만 반환하며 DB effect 1회·409·두 body 전체 동등성을 assert하지 않는다.
- 다음 코드와의 연결
- TransferControllerReplayTest는 동일 body 두 POST에서 201·200·둘째 replayed:true를 직접 assertion한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/transfer/api/TransferController.java
- 전제조건
- Spring MVC, 인증 Principal, TransferRequest/Response, final TransferService가 필요하다.
- 반드시 지킬 계약
- principal name을 actor로, transactionId를 idempotency key로 전달하고 replay flag로 201/200을 선택한다.
- 추천 입력 순서
- mapping/import → constructor injection → POST method → Command → response → status ternary 순서다.
- 자기 점검
- 과제 공개 계약은 replay body의 businessTransactionId/fromBalance/toBalance 동일까지 요구하지만 현재 D6 test 직접 assertion과 구분한다.
- 이번 파일의 범위 밖
- DB effect once·409 handler mapping·인증 실패·일요일 네 메서드 HTTP suite는 이 파일만으로 증명하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer.api;
import com.example.financialcore.transfer.TransferService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.Principal;
@RestController
@RequestMapping("/api/transfers")
public class TransferController {
private final TransferService transfers;
public TransferController(TransferService transfers) { this.transfers = transfers; }
@PostMapping
ResponseEntity<TransferResponse> transfer(
@Valid @RequestBody TransferRequest request, Principal principal
) {
var result = transfers.transfer(new TransferService.Command(
principal.getName(), request.transactionId(),
request.fromAccountId(), request.toAccountId(), request.amount()));
var body = new TransferResponse(
request.transactionId(), result.businessTransactionId(), "COMPLETED",
result.fromBalance(), result.toBalance(), result.replayed());
return ResponseEntity.status(result.replayed() ? HttpStatus.OK : HttpStatus.CREATED).body(body);
}
}
16. TransferControllerReplayTest
한 문장 역할: 같은 authenticated POST body를 두 번 보내 첫 status201·replay status200·두 번째 body의 replayed:true만 직접 확인한다.
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 토요일 exact selector |
| 무엇을 받나 | basic customer-1/password, W12-REPLAY, from/to, amount1000 JSON |
| 무엇이 바뀌나 | 첫 POST가 실제 이체/claim을 만들고 둘째는 replay 경로를 호출 |
| 무엇을 돌려주나 | 첫201, 둘째200, 둘째 JSON에 replayed:true |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer.api;
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.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.post;
@SpringBootTest
@AutoConfigureMockMvc
class TransferControllerReplayTest extends PostgresIntegrationTestSupport {
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
Account from;
Account to;
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "REPLAY-FROM", 10_000);
to = openings.open("customer-2", "REPLAY-TO", 5_000);
}
@Test
void firstIs201AndReplayIs200() throws Exception {
String body = body("W12-REPLAY", 1_000);
assertThat(performPost(body).getStatus()).isEqualTo(201);
var replay = performPost(body);
assertThat(replay.getStatus())
.as("W12D6_RED_EXPECTED_REPLAY_200")
.isEqualTo(200);
assertThat(replay.getContentAsString()).contains("\"replayed\":true");
}
private org.springframework.mock.web.MockHttpServletResponse performPost(String body) throws Exception {
return mvc.perform(post("/api/transfers")
.with(httpBasic("customer-1", "password"))
.contentType("application/json").content(body))
.andReturn().getResponse();
}
private String body(String key, long amount) {
return "{\"transactionId\":\"" + key + "\",\"fromAccountId\":" + from.getId()
+ ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
}
}
코드 조각 1 · HTTP replay 시험의 PostgreSQL·계좌·MockMvc 기반
package com.example.financialcore.transfer.api;
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
한 줄 읽기: PostgreSQL base, 계좌 fixture, JUnit, SpringBootTest와 AutoConfigureMockMvc 타입을 연결한다.
- 문법을 한 줄씩 풀면
- integration base와 account/JUnit/Spring import가 실제 DB를 쓰는 MockMvc replay test의 class·fixture에 사용된다.
- 실제 값 추적
- AutoConfigureMockMvc는 다음 class annotation, Account는 from/to field, openings는 BeforeEach 계좌 생성에 쓰인다.
- 정상 예
- 실제 PostgreSQL 계좌 fixture와 Spring MVC 전체 경로를 MockMvc로 호출할 integration test 기반이다.
- 반례·경계 예
- PostgresIntegrationTestSupport 연결을 빼면 이 class가 상속하는 실제 DB support type을 찾지 못한다.
- 착각 방지
- AutoConfigureMockMvc import는 request를 보내지 않는다. 실제 POST는 performPost helper가 수행한다.
- 이 블록이 하지 않는 일
- 이 package/import 묶음은 Spring context·MockMvc 요청을 시작하거나 계좌 fixture를 만들지 않는다.
- 다음 코드와의 연결
- 다음 import 구간이 JdbcClient·MockMvc·AssertJ·basic auth·POST request builder를 연결한다.
코드 조각 2 · MockMvc POST·basic auth·AssertJ 도구
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.post;
한 줄 읽기: JdbcClient·MockMvc, assertThat, httpBasic, post builder를 같은-body HTTP test에 연결한다.
- 문법을 한 줄씩 풀면
- JdbcClient는 cleanup, MockMvc는 request 실행, static imports는 status assertion과 basic-auth POST 조립에 쓰인다.
- 실제 값 추적
- JdbcClient는 cleanup에, MockMvc는 요청 실행에, AssertJ·httpBasic·post는 판정과 요청 조립에 쓰인다.
- 정상 예
- 용도: JdbcClient=SQL 실행·count; MockMvc=HTTP test; assertThat=값 비교; httpBasic=Basic 인증; post=POST builder.
- 반례·경계 예
- httpBasic 연결이 빠지면 performPost의 인증 request post-processor 호출을 해석할 수 없다.
- 착각 방지
- JdbcClient는 BeforeEach cleanup에 쓰이고 row count를 assert하지 않으며 MockMvc는 helper 호출 때만 POST한다.
- 이 블록이 하지 않는 일
- 이 span은 TRUNCATE나 HTTP 요청을 실행하지 않고 status·body assertion도 평가하지 않는다.
- 다음 코드와의 연결
- class가 SpringBootTest와 AutoConfigureMockMvc를 PostgreSQL support 위에 결합한다.
코드 조각 3 · TransferControllerReplayTest type 경계
@SpringBootTest
@AutoConfigureMockMvc
class TransferControllerReplayTest extends PostgresIntegrationTestSupport {
한 줄 읽기: TransferControllerReplayTest의 선언 범위를 열 뿐, method나 test를 지금 실행하지 않는다.
- 문법을 한 줄씩 풀면
- @SpringBootTest가 application context를 열고 @AutoConfigureMockMvc가 HTTP test client 구성을 더한다.
- 실제 값 추적
- SpringBootTest, AutoConfigureMockMvc가 이 class를 해당 Spring context의 관리 대상으로 읽게 한다. 선언만으로 method는 실행되지 않는다.
- 정상 예
- JUnit 실행 시 PostgreSQL support 위 full context와 MockMvc가 함께 준비될 class 경계다.
- 반례·경계 예
- @SpringBootTest를 빼면 TransferControllerReplayTest의 full Spring integration context 계약이 사라진다.
- 착각 방지
- SpringBootTest와 AutoConfigureMockMvc는 context를 구성하지만 같은 body 두 POST를 자동 실행하지 않는다.
- 이 블록이 하지 않는 일
- class header만으로 cleanup·계좌 개설·두 POST·세 response assertion이 실행되지는 않는다.
- 다음 코드와의 연결
- MockMvc·JdbcClient·AccountOpeningService를 주입하고 from·to fixture field를 둔다.
코드 조각 4 · @Autowired MockMvc mvc; 읽기
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
Account from;
Account to;
한 줄 읽기: mvc, jdbc, openings는 Spring test context가 주입한다; from, to는 @BeforeEach가 실제 계좌 값으로 채운다.
- 문법을 한 줄씩 풀면
- MockMvc·JdbcClient·opening service는 @Autowired로 받고 from·to는 BeforeEach가 채울 Account field다.
- 실제 값 추적
- context가 MockMvc·JdbcClient·opening service를 주입하고 from·to는 뒤 BeforeEach가 채운다.
- 정상 예
- Spring이 mvc, jdbc, openings를 주입하고 @BeforeEach가 from, to를 실제 계좌로 채운다.
- 반례·경계 예
- mvc, jdbc, openings 주입이나 from, to fixture 대입을 빼면 context 시작 또는 test Act가 실패한다.
- 착각 방지
- mvc·jdbc·openings는 bean 주입이고 from·to는 BeforeEach 대입이므로 준비 경로가 서로 다르다.
- 이 블록이 하지 않는 일
- 이 span은 협력 객체와 fixture field만 선언하며 POST·assertion·계좌 개설은 수행하지 않는다.
- 다음 코드와의 연결
- BeforeEach가 네 표를 비우고 REPLAY-FROM 10,000·REPLAY-TO 5,000 계좌를 연다.
코드 조각 5 · HTTP replay 시험의 10,000/5,000 fixture
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "REPLAY-FROM", 10_000);
to = openings.open("customer-2", "REPLAY-TO", 5_000);
}
한 줄 읽기: 네 표를 비우고 REPLAY-FROM 10,000·REPLAY-TO 5,000 계좌를 연다.
- 문법을 한 줄씩 풀면
- 27–31행: @BeforeEach는 각 test 전에 fixture를 초기화해 이전 행의 영향을 차단한다.
- 실제 값 추적
- 네 표를 비운 뒤 REPLAY-FROM 10,000원과 REPLAY-TO 5,000원 계좌를 열어 두 HTTP 호출의 공통 fixture로 둔다.
- 정상 예
- REPLAY-FROM 10,000원·REPLAY-TO 5,000원을 열어 동일 POST 두 번의 HTTP fixture를 고정한다.
- 반례·경계 예
- TRUNCATE나 opening을 빼면 이전 key row 또는 null account 때문에 201→200 replay 경로가 달라진다.
- 착각 방지
- 이 fixture는 201·200 결과가 아니라 그 결과를 관찰하기 위한 Arrange 단계다.
- 이 블록이 하지 않는 일
- TransferControllerReplayTest 27–31행의 TRUNCATE/open은 test 전용이며 production 데이터 정리 절차를 뜻하지 않는다.
- 다음 코드와의 연결
- test가 W12-REPLAY·amount 1,000 JSON을 만들고 같은 body를 두 번 POST한다.
코드 조각 6 · 첫 201·replay 200·둘째 replayed:true
@Test
void firstIs201AndReplayIs200() throws Exception {
String body = body("W12-REPLAY", 1_000);
assertThat(performPost(body).getStatus()).isEqualTo(201);
var replay = performPost(body);
assertThat(replay.getStatus())
.as("W12D6_RED_EXPECTED_REPLAY_200")
.isEqualTo(200);
assertThat(replay.getContentAsString()).contains("\"replayed\":true");
한 줄 읽기: 같은 POST의 첫 status 201·둘째 200과 둘째 body의 replayed:true를 확인한다.
- 문법을 한 줄씩 풀면
- @Test가 method를 selector에 노출하고 isEqualTo 두 개는 status를, contains는 둘째 body flag를 비교한다.
- 실제 값 추적
- 같은 W12-REPLAY/1,000 body의 첫 POST status는 201, 둘째는 200이며 둘째 문자열에 replayed:true가 있다.
- 정상 예
- 첫 status가 201, 둘째가 200이고 둘째 JSON text에 "replayed":true가 있으면 Green이다.
- 반례·경계 예
- 둘째 status가 201이거나 body가 replayed:false면 controller의 replay 계약이 깨진다.
- 착각 방지
- 201·200은 숫자 비교이고 replayed:true만 문자열 포함 검사다.
- 이 블록이 하지 않는 일
- DB effect 1회와 두 body의 transactionId·fromBalance·toBalance 동일은 직접 assert하지 않는다.
- 다음 코드와의 연결
- performPost는 basic customer-1/password와 JSON content type을 붙여 /api/transfers 응답을 돌려준다.
코드 조각 7 · performPost 메서드의 값 흐름
}
private org.springframework.mock.web.MockHttpServletResponse performPost(String body) throws Exception {
return mvc.perform(post("/api/transfers")
.with(httpBasic("customer-1", "password"))
.contentType("application/json").content(body))
.andReturn().getResponse();
}
한 줄 읽기: 인증된 POST 실행 결과 MockHttpServletResponse를 helper 호출자에게 돌려준다.
- 문법을 한 줄씩 풀면
- performPost는 /api/transfers POST builder에 basic auth, application/json, body를 붙이고 MockMvc response를 반환한다.
- 실제 값 추적
- POST /api/transfers에 basic customer-1/password와 application/json body를 실어 MockHttpServletResponse 하나를 받는다.
- 정상 예
- basic 인증·JSON content type을 붙인 POST가 끝나면 그 한 response 객체를 test method에 돌려준다.
- 반례·경계 예
- path·인증·content type 가운데 하나를 빼면 controller replay가 아니라 404·401·media type 실패를 볼 수 있다.
- 착각 방지
- MockMvc response 반환은 status나 body를 원하는 값으로 보정하지 않는다.
- 이 블록이 하지 않는 일
- helper는 response를 돌려줄 뿐 201·200·replayed:true assertion은 호출자가 한다.
- 다음 코드와의 연결
- body helper는 transactionId와 fromAccountId를 JSON 문자열 앞부분에 붙인다.
코드 조각 8 · body 메서드의 값 흐름
private String body(String key, long amount) {
return "{\"transactionId\":\"" + key + "\",\"fromAccountId\":" + from.getId()
한 줄 읽기: key·from ID를 JSON 앞부분의 transactionId·fromAccountId 값으로 붙인다.
- 문법을 한 줄씩 풀면
- body helper가 key와 amount를 받고 transactionId 문자열과 from account의 numeric id를 JSON 앞부분에 연결한다.
- 실제 값 추적
- helper는 key와 amount, fixture from/to ID를 transactionId/fromAccountId/toAccountId/amount JSON에 연결한다.
- 정상 예
- W12-REPLAY와 fixture from ID가 JSON 앞부분에 들어가고, 이어지는 연결식이 to ID·amount·닫는 brace를 붙인다.
- 반례·경계 예
- transactionId field명·따옴표·fromAccountId colon을 틀리면 controller가 같은 request를 읽지 못한다.
- 착각 방지
- 이 문자열 helper는 Principal·HTTP status를 선택하거나 JSON schema 전체를 검증하지 않는다.
- 이 블록이 하지 않는 일
- 아직 JSON을 닫지 않으며 POST 실행·replay 판정·DB 조회를 하지 않는다.
- 다음 코드와의 연결
- 이어지는 연결식이 toAccountId·amount와 닫는 brace를 붙여 네 field JSON을 완성한다.
코드 조각 9 · toAccountId·amount·닫는 중괄호를 붙여 네 field JSON body를 완성한다
+ ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
}
}
한 줄 읽기: toAccountId·amount·닫는 중괄호를 붙여 네 field JSON body를 완성한다.
- 문법을 한 줄씩 풀면
- 53–55행: + 연산이 toAccountId와 amount를 붙이고 마지막 } 문자로 JSON 문자열을 닫는다.
- 실제 값 추적
- 앞에서 연 JSON 문자열에 fixture to ID와 amount, 마지막 닫는 중괄호가 붙어 POST body 한 개가 완성된다.
- 정상 예
- fixture의 to ID와 amount가 붙고 마지막 중괄호가 닫히면 네 field JSON body가 완성된다.
- 반례·경계 예
- toAccountId·amount 조각이나 마지막 JSON 중괄호를 빼면 POST body가 빠진 field 또는 잘못된 문법이 된다.
- 착각 방지
- 문자열 덧셈 helper는 범용 JSON serializer가 아니다. 이 test의 네 field body 하나만 만든다.
- 이 블록이 하지 않는 일
- TransferControllerReplayTest 53–55행은 HTTP 호출·service 실행·DB row count assertion을 수행하지 않는다.
- 다음 코드와의 연결
- 이 class의 직접 HTTP 증거는 첫 201, 둘째 200, 둘째 문자열의 replayed:true 세 항목으로 끝난다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/api/TransferControllerReplayTest.java
- 전제조건
- SpringBootTest, MockMvc/Security, 실제 PostgreSQL, final Controller/Service가 필요하다.
- 반드시 지킬 계약
- truncate/open → 같은 body POST2 → status201/200 → replay body contains true를 그대로 보존한다.
- 추천 입력 순서
- imports/fixture → @Test body/두 POST/assert → performPost → body helper 순서다.
- 자기 점검
- 현재 test assertion은 정확히 201, 200, replayed:true 세 개뿐임을 대조한다.
- 이번 파일의 범위 밖
- DB effect count1·첫 body false·두 body의 businessTransactionId/fromBalance/toBalance 동일·409 mapping은 직접 assertion하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer.api;
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.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.post;
@SpringBootTest
@AutoConfigureMockMvc
class TransferControllerReplayTest extends PostgresIntegrationTestSupport {
@Autowired MockMvc mvc;
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
Account from;
Account to;
@BeforeEach void clean() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
from = openings.open("customer-1", "REPLAY-FROM", 10_000);
to = openings.open("customer-2", "REPLAY-TO", 5_000);
}
@Test
void firstIs201AndReplayIs200() throws Exception {
String body = body("W12-REPLAY", 1_000);
assertThat(performPost(body).getStatus()).isEqualTo(201);
var replay = performPost(body);
assertThat(replay.getStatus())
.as("W12D6_RED_EXPECTED_REPLAY_200")
.isEqualTo(200);
assertThat(replay.getContentAsString()).contains("\"replayed\":true");
}
private org.springframework.mock.web.MockHttpServletResponse performPost(String body) throws Exception {
return mvc.perform(post("/api/transfers")
.with(httpBasic("customer-1", "password"))
.contentType("application/json").content(body))
.andReturn().getResponse();
}
private String body(String key, long amount) {
return "{\"transactionId\":\"" + key + "\",\"fromAccountId\":" + from.getId()
+ ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
}
}
현재 @Test 직접 확인
first 201replay 200replay body contains true과제 공개 계약 · 현재 직접 미assert
businessTransactionId 동일fromBalance 동일toBalance 동일JUnit 18개 · AAA와 직접 보장선
아래 18장은 class 이름이나 설명 문장이 아니라 실제 Arrange·Act·Assert에서 시작한다. method 7개는 W12에서 처음 소개된 class에, method 11개는 이전 class를 W12 형태로 진화시킨 두 class에 들어 있지만, 이것은 class provenance 분류이지 method 신구 판정이 아니다.
canonicalContractIsVersionedFixedOrderUtf8Decimal
월 · RequestHasherTest · W12-introduced class provenance · direct
- 준비(Arrange)
- new RequestHasher와 숫자 from=10, to=20, amount=3000을 준비한다.
- 행동(Act)
- canonicalBytes를 UTF-8 String으로 읽고 같은 세 값의 hash를 한 번 계산한다.
- 확인(Assert)
- 문자열이 정확히 v1↵from=10↵to=20↵amount=3000↵이고 hash 길이64·정규식 [0-9a-f]{64}여야 한다.
- 직접 보장
- version·field 순서·decimal 표기·마지막 newline·UTF-8 읽기 결과와 lowercase hex 모양을 이 한 예에서 고정한다.
- 직접 보장하지 않음
- SHA-256 충돌 불가능, actor/key scope, 0·음수 guard, 미래 의미 필드는 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 0, 음수, Long 경계가 IllegalArgumentException인지 각각 추가할 수 있다.
eachSemanticFieldChangesTheHash
월 · RequestHasherTest · W12-introduced class provenance · direct
- 준비(Arrange)
- 기준 hash(10,20,3000)를 canonical 변수에 저장한다.
- 행동(Act)
- from만11, to만21, amount만3001로 바꾼 세 hash를 계산한다.
- 확인(Assert)
- 세 변경 hash가 모두 기준 canonical hash와 달라야 한다.
- 직접 보장
- 현재 세 semantic field 각각의 변화가 hash input에 반영됨을 네 표본에서 직접 보장한다.
- 직접 보장하지 않음
- 모든 입력쌍의 hash uniqueness, 다른 통화/메모/actor 같은 미포함 필드, 충돌 불가능은 증명하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 각 field 경계값과 VERSION 변경 시 hash 변화도 별도 case로 넣을 수 있다.
uniqueScopeActorAndKeyAreEnforcedByV001
화 · IdempotencySchemaIT · W12-introduced class provenance · direct
- 준비(Arrange)
- idempotency_request를 비운 뒤 고정 TRANSFER/customer-1/same-key에 hash a×64와 b×64를 준비한다.
- 행동(Act)
- 첫 INSERT를 성공시키고 같은 scope·actor·key에 hash만 다른 두 번째 INSERT를 실행한다.
- 확인(Assert)
- 둘째가 DataIntegrityViolationException이고 table 전체 COUNT가1이어야 한다.
- 직접 보장
- 실제 PostgreSQL V001의 composite UNIQUE가 request_hash 차이와 무관하게 같은 세 key 중복을 막음을 보장한다.
- 직접 보장하지 않음
- 정확히 어느 constraint가 예외 원인인지, 다른 actor/key 성공, status CHECK, concurrent claim owner는 직접 보지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 다른 actor와 다른 key는 각각 insert 성공해 count2가 되는 case를 추가할 수 있다.
fifty_concurrent_claims_have_one_owner_and_forty_nine_existing_results
수 · AtomicClaim50IT · W12-introduced class provenance · direct
- 준비(Arrange)
- 빈 table, tasks50, ready50/start1, pool50, 같은 TRANSFER/customer-50/same-key/a×64를 준비한다.
- 행동(Act)
- 50 Future가 ready를 알린 뒤 함께 store.claim을 호출하고 각 결과를 Future별 최대30초에 회수한다.
- 확인(Assert)
- ready가10초 안에 true, Optional present owners=1, empty existing=49, DB COUNT=1이어야 한다.
- 직접 보장
- 이 fixture에서 단일 PostgreSQL INSERT+UNIQUE가 50 competing claim 중 owner 한 명과 기존 결과49개를 만든다.
- 직접 보장하지 않음
- owner가 업무를 한 번 완료함, global 30초 deadline, 공정한 winner, stale recovery, 50보다 큰 운영 처리량은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 winner id와 table id 일치, 다른 key의 독립 owner, 더 작은 pool의 barrier 전략을 분리해 시험할 수 있다.
named_parameters_do_not_turn_actor_text_into_sql
수 · AtomicClaim50IT · W12-introduced class provenance · direct
- 준비(Arrange)
- actor probe를 owner' OR '1'='1로 두고 별도 probe-key/hash b×64를 준비한다.
- 행동(Act)
- probe를 그대로 store.claim actorId parameter로 전달하고 actor_id<>:probe 행 수를 읽는다.
- 확인(Assert)
- claim 결과가 present이고 probe와 다른 actor 행 COUNT가0이어야 한다.
- 직접 보장
- 이 경로의 named parameter가 따옴표 포함 actor text를 SQL 문법이 아닌 한 값으로 저장함을 보장한다.
- 직접 보장하지 않음
- 모든 SQL injection payload·모든 query·HTTP validation·문자열 길이 제한을 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 저장된 actor_id exact equality와 scope/key/hash도 한 행에서 함께 읽을 수 있다.
afterClaimRuntimeExceptionRollsBackClaim
목 · TransferFailurePointIT · evolved class provenance · direct
- 준비(Arrange)
- from10,000/to5,000을 열고 hook point를 AFTER_CLAIM, key를 after-claim, amount를1,000으로 둔다.
- 행동(Act)
- TransferService.transfer를 호출해 claim insert 직후 hook RuntimeException을 발생시킨다.
- 확인(Assert)
- RuntimeException message에 injected가 있고 idempotency_request0, TRANSFER business_tx0, TRANSFER ledger0이어야 한다.
- 직접 보장
- claim과 이후 업무가 같은 transaction에 참여해 after-claim unchecked 예외에서 새 DB effect가 남지 않음을 보장한다.
- 직접 보장하지 않음
- 두 account 잔액 원복을 직접 읽지 않고 process kill·외부 system·checked exception rollback도 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 두 잔액 10,000/5,000과 동일 key 재시도 성공 가능성을 별도 assertion할 수 있다.
afterBusinessRuntimeExceptionRollsBackEveryEffect
목 · TransferFailurePointIT · evolved class provenance · direct
- 준비(Arrange)
- 같은 fixture에서 hook point를 AFTER_BUSINESS, key after-business, amount1,000으로 둔다.
- 행동(Act)
- 잔액·transaction·ledger 저장 뒤 hook RuntimeException을 일으키는 transfer를 호출한다.
- 확인(Assert)
- RuntimeException message injected와 idempotency0, TRANSFER business_tx0, TRANSFER ledger0을 확인한다.
- 직접 보장
- 업무 mutation 뒤 unchecked 예외도 claim·transaction·transfer ledger를 한 transaction에서 원복함을 직접 보장한다.
- 직접 보장하지 않음
- 두 balance를 직접 assertion하지 않고 외부 message 발행·hard kill·checked exception은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 balance와 opening ledger baseline까지 함께 확인해 DB 관찰 범위를 넓힐 수 있다.
samePayloadReplaysAndChangedPayloadConflictsWithoutExtraEffect
목 · TransferIdempotencyIT · W12-introduced class provenance · direct
- 준비(Arrange)
- from10,000/to5,000, same-key의 amount1000 command와 같은 key의 amount2000 changed command를 만든다.
- 행동(Act)
- first를 두 번 호출한 뒤 changed 호출 예외를 catchThrowable로 받는다.
- 확인(Assert)
- 첫 replayed=false, 둘째=true, changed는 BusinessException/IDEMPOTENCY_CONFLICT, TRANSFER tx1·ledger2여야 한다.
- 직접 보장
- 순차 동일 semantic request는 replay되고 amount가 달라지면 추가 business effect 없이 conflict임을 보장한다.
- 직접 보장하지 않음
- HTTP 201/200/409, concurrent same-key, from/to 변경, exact 잔액과 idempotency row count는 직접 보지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 balance9,000/6,000과 idempotency_request1을 추가로 읽을 수 있다.
transfer_and_same_key_replay_once
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- from/to 각10,000, key-1, amount3,000인 동일 Command 하나를 준비한다.
- 행동(Act)
- 같은 command로 service를 두 번 호출해 first와 replay Result를 받는다.
- 확인(Assert)
- false/true, 잔액7,000/13,000, 전체 ledger4, TRANSFER signed_amount 합0이어야 한다.
- 직접 보장
- opening ledger2를 포함한 최종 ledger4와 잔액·signed 보존식을 함께 보아 3,000원 effect가 한 번임을 보장한다.
- 직접 보장하지 않음
- business_tx/idempotency exact row count, 두 Result의 모든 field equality, HTTP status는 직접 보지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 first/replay의 txId·두 balance 동일과 tx1/claim1을 추가할 수 있다.
json_field_order_and_whitespace_variant_replays_without_extra_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- 같은 from/to/1000을 첫 JSON과 field 순서·공백·newline이 다른 variant JSON으로 만든다.
- 행동(Act)
- 두 문자열을 commandFromJson으로 semantic long 세 개로 바꾼 뒤 같은 representation-key로 transfer한다.
- 확인(Assert)
- 첫 false, variant true, 잔액9,000/11,000, helper의 tx1/ledger2/claim1이어야 한다.
- 직접 보장
- 이 두 표현은 semantic 값이 같아 byte 표현 차이가 추가 업무 effect를 만들지 않음을 보장한다.
- 직접 보장하지 않음
- 범용 JSON 문법·escaped field·negative/decimal number·모든 whitespace/order 조합은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 정식 JSON parser로 같은 semantic command를 만드는 controller-level case를 추가할 수 있다.
same_key_concurrent_requests_change_business_once
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- 같은 burst-key/from/to/1000 Command, n20, ready20/start1, pool20을 준비한다.
- 행동(Act)
- 20 worker를 함께 출발시켜 service 결과를 Future별 최대15초에 모두 회수한다.
- 확인(Assert)
- non-replayed count1, 잔액9,000/11,000, 전체 ledger4, helper의 tx1/transfer ledger2/claim1이어야 한다.
- 직접 보장
- 이 20-thread fixture에서 새 owner 한 명만 업무를 바꾸고 나머지는 replay result를 받음을 보장한다.
- 직접 보장하지 않음
- global 15초 deadline, winner fairness, 운영100건 burst, request latency, crash recovery는 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 replayed true19와 모든 Result business values 동일을 직접 세어 볼 수 있다.
opposite_direction_transfers_preserve_total_balance
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- 각10,000인 A/B, perDirection10, task20, 고유 ab-0..9와 ba-0..9, amount100을 준비한다.
- 행동(Act)
- 반대 방향 Future20을 barrier 뒤 호출하고 ready5초·각 Future20초 제한으로 모두 회수한다.
- 확인(Assert)
- A balance10,000, B balance10,000, 합20,000, opening ledger2+transfer ledger40=전체42여야 한다.
- 직접 보장
- 이 exact symmetric workload의 모든 호출 완료와 두 exact 잔액·총액·ledger 행 수를 보장한다.
- 직접 보장하지 않음
- 모든 schedule deadlock 부재·공정성·retry·global deadline·다른 금액 조합은 증명하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 TRANSFER signed 합0과 business_tx20을 별도로 읽을 수 있다.
same_key_with_different_semantic_request_conflicts_without_extra_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- conflict-key의 same from/to에서 amount1000 first와 amount2000 changed를 준비한다.
- 행동(Act)
- first를 반영한 뒤 changed를 호출해 BusinessException을 잡는다.
- 확인(Assert)
- code IDEMPOTENCY_CONFLICT, 잔액9,000/11,000, TRANSFER tx1, ledger2, claim1이어야 한다.
- 직접 보장
- amount가 hash 의미 필드라 같은 key에서 값 변경은 추가 effect 없이 conflict임을 보장한다.
- 직접 보장하지 않음
- HTTP409 mapping·response body·동시 changed payload 경쟁·다른 actor scope는 직접 보지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 기존 claim의 request_hash와 COMPLETED status를 함께 확인할 수 있다.
same_key_with_changed_from_conflicts_without_extra_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- 기본 from/to10,000과 other C5,000, from-conflict-key amount1000을 준비한다.
- 행동(Act)
- 기본 from으로 first를 실행한 뒤 from만 other로 바꾼 같은-key command를 호출한다.
- 확인(Assert)
- code IDEMPOTENCY_CONFLICT, balances9,000/11,000/5,000, helper tx1/ledger2/claim1이어야 한다.
- 직접 보장
- fromAccountId도 semantic hash 필드라 같은 key에서 계좌 변경이 추가 effect를 만들지 않음을 보장한다.
- 직접 보장하지 않음
- 다른 actor가 같은 key를 쓸 수 있는 scope 분리, HTTP409, owner error 우선순위 전부는 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 other owner 변경과 actor scope를 분리한 case를 추가할 수 있다.
same_key_with_changed_to_conflicts_without_extra_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- 기본 from/to10,000과 other C5,000, to-conflict-key amount1000을 준비한다.
- 행동(Act)
- 기본 to로 first를 실행한 뒤 to만 other로 바꾼 같은-key command를 호출한다.
- 확인(Assert)
- code IDEMPOTENCY_CONFLICT, balances9,000/11,000/5,000, helper tx1/ledger2/claim1이어야 한다.
- 직접 보장
- toAccountId 역시 semantic hash 필드라 같은 key에서 목적지 변경은 conflict임을 보장한다.
- 직접 보장하지 않음
- HTTP response, different actor scope, 모든 계좌 존재/권한 조합의 우선 오류는 직접 보지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 to가 없는 id일 때 ACCOUNT_NOT_FOUND와 conflict 우선순위를 명시할 수 있다.
runtime_exception_after_claim_rolls_back_every_database_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- fixture를 10,000/10,000으로 만들고 ControlledFailureHook을 AFTER_CLAIM, key fail-claim로 둔다.
- 행동(Act)
- service transfer를 호출해 claim 직후 RuntimeException message injected after claim을 받는다.
- 확인(Assert)
- 두 balance10,000/10,000, idempotency0, TRANSFER business_tx0, TRANSFER ledger0이어야 한다.
- 직접 보장
- D5 통합 fixture에서는 D4 test보다 더 넓게 두 balance까지 포함한 after-claim DB 원복을 직접 보장한다.
- 직접 보장하지 않음
- process crash, 외부 side effect, checked exception, transaction proxy 우회는 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 같은 key 재호출이 새 owner로 정상 성공하는지도 이어서 확인할 수 있다.
runtime_exception_after_business_mutation_rolls_back_every_database_effect
금 · TransferIntegrationTest · evolved class provenance · direct
- 준비(Arrange)
- fixture를 10,000/10,000으로 만들고 hook을 AFTER_BUSINESS_MUTATION, key fail-business로 둔다.
- 행동(Act)
- 잔액·transaction·ledger 저장 뒤 RuntimeException message injected after business mutation을 받는다.
- 확인(Assert)
- 두 balance10,000/10,000, idempotency0, TRANSFER business_tx0, TRANSFER ledger0이어야 한다.
- 직접 보장
- claim부터 두 잔액·transaction·ledger까지 같은 transaction의 unchecked 예외 원복을 직접 보장한다.
- 직접 보장하지 않음
- 외부 메시지/결제, hard kill, commit 뒤 failure, checked exception rollback은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화라면 opening ledger baseline2가 그대로인지와 같은 key 재시도를 추가할 수 있다.
firstIs201AndReplayIs200
토 · TransferControllerReplayTest · W12-introduced class provenance · direct
- 준비(Arrange)
- 실제 DB에 from10,000/to5,000을 열고 basic customer-1/password, W12-REPLAY, amount1000 JSON body를 만든다.
- 행동(Act)
- 동일 body로 POST /api/transfers를 두 번 수행한다.
- 확인(Assert)
- 첫 response status201, 둘째 status200, 둘째 content 문자열에 replayed:true가 포함돼야 한다.
- 직접 보장
- 현재 D6 월~토 HTTP test가 직접 보장하는 것은 이 세 assertion뿐이다.
- 직접 보장하지 않음
- DB effect count1, 첫 body replayed:false, 두 body의 businessTransactionId/fromBalance/toBalance 동일, conflict409·무인증은 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 과제 공개 계약을 강화하려면 두 JSON을 parse해 businessTransactionId/fromBalance/toBalance 동일과 DB row counts를 별도 assertion해야 한다.
이번 주 SQL workbook 과제는 0개
p362∼p415에는 Q번호 SQL 문제와 learner answer 경로가 없다. 따라서 존재하지 않는 SQL 예시 정답이나 다시 쓰기 문제를 만들어 넣지 않았으며, 이후 주차의 SQL을 앞당겨 섞지도 않았다.
마지막 재점검
원문·정답: canonical 16파일은 168개 연속 조각으로 모든 정규화1000줄을 한 번씩만 싣고, 각 파일 직접 다시 쓰기 바로 뒤에 같은 전체 정답을 들여쓰기까지 다시 싣는다.
직접 보장: versioned semantic hash, V001 composite unique, owner1/existing49, sequential/concurrent replay once, semantic conflict, 두 rollback, 서비스 통합9개, 토요일 201/200/true까지다.
직접 보장하지 않음: retry·stale owner recovery·외부 exactly-once·운영 stress·409 HTTP assertion·토요일 두 body의 세 business 값 동일·일요일 suite는 이 범위 밖이다.