12주차 코드 뒤풀이

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 selectorGreen @TestRed 추가
RequestHasherTest22
IdempotencySchemaIT10
AtomicClaim50IT22
TransferIdempotencyIT + TransferFailurePointIT1 + 21 + 2
TransferIntegrationTest90
TransferControllerReplayTest11
요일별 Green method 수는 2·1·2·3·9·1=18이고, Red 단계에서 같은 selector body의 method8개가 추가 실행된다.

월 · semantic hash 계약

1. RequestHasher

W12 final solution · production target 1/4src/main/java/com/example/financialcore/idempotency/RequestHasher.javaSHA-256 cd41b22f549e5c5dcbc2ef7dca0b853e104d14aec81096a9b604bfc573222afd

한 문장 역할: 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 · 패키지와 네 가지 해시 도구

해설 바로 앞 원문 조각 · java
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 버전

해설 바로 앞 원문 조각 · java
@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

해설 바로 앞 원문 조각 · java
    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

해설 바로 앞 원문 조각 · java
        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로

해설 바로 앞 원문 조각 · java

    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 부재를 상태 예외로 변환

해설 바로 앞 원문 조각 · java
        } 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은 하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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);
        }
    }
}
hash는 세 의미 숫자를 versioned canonical bytes로 고정한 뒤 SHA-256 hex로 표시하며 암호화나 DB claim은 하지 않는다.

2. RequestHasherTest

W12 direct selector body 1/7 · provided testsrc/test/java/com/example/financialcore/idempotency/RequestHasherTest.javaSHA-256 fdd74b976407c78cc41215313c2d6cc02daced1f568766d30d8fa617ea2b0849

한 문장 역할: canonical text와 hex 모양, from/to/amount 각각 1 증가가 hash를 바꾸는지를 두 @Test로 고정한다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나월요일 exact selector
무엇을 받나RequestHasher와 10/20/3000 기준값
무엇이 바뀌나테스트 메모리 값만 만들며 DB·Spring context 없음
무엇을 돌려주나@Test2의 assertion Green/Red

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 시험 도구

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
    @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 비교

해설 바로 앞 원문 조각 · java

    @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 종료

해설 바로 앞 원문 조각 · java
        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, 미래 필드 자동 포함은 증명하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

누적 canonical · W6D1 solution / W12D2 explicit learner targetsrc/main/resources/db/migration/V001__common.sqlSHA-256 4e795fd45ce36852480abf25b941b84541e5e200229044469b5d9a7c7a18c222

한 문장 역할: account·business_tx·ledger_entry·idempotency_request 네 표와 핵심 제약·index를 만드는 실제 V001 migration이다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나Flyway와 IdempotencySchemaIT
무엇을 받나빈 PostgreSQL schema
무엇이 바뀌나표4·index2·PK/FK/CHECK/UNIQUE 제약을 영구 생성
무엇을 돌려주나다음 migration과 repository가 사용할 schema

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · sql
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 행의 식별자와 허용값 제약

해설 바로 앞 원문 조각 · sql
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

해설 바로 앞 원문 조각 · sql
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 머리 행

해설 바로 앞 원문 조각 · sql
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 금액·잔액·참조 열

해설 바로 앞 원문 조각 · sql

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

해설 바로 앞 원문 조각 · sql
    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

해설 바로 앞 원문 조각 · sql
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 요청과 응답 저장 열

해설 바로 앞 원문 조각 · sql
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

해설 바로 앞 원문 조각 · sql
    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에 없다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · sql
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)
);
화요일 test의 두 hash가 달라도 같은 scope·actor·key라면 V001 복합 UNIQUE가 둘째 행을 막는다.

4. IdempotencySchemaIT

W12 direct selector body 2/7 · provided integration testsrc/test/java/com/example/financialcore/idempotency/IdempotencySchemaIT.javaSHA-256 f218aec1c843ee861f14ff2884bdca5804c8face29b1837f9e1eb3f22eb19011

한 문장 역할: 동일 scope·actor·key의 두 번째 INSERT가 실제 PostgreSQL V001 복합 UNIQUE에 막히고 행이 1개만 남는지 확인한다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나화요일 exact selector
무엇을 받나빈 idempotency_request와 hash a×64, b×64
무엇이 바뀌나첫 INSERT 1행 commit; 둘째는 constraint 예외
무엇을 돌려주나DataIntegrityViolationException과 최종 COUNT 1

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 통합 시험 의존성

해설 바로 앞 원문 조각 · java
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 진입점

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
@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 단독 초기화

해설 바로 앞 원문 조각 · java
    @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는 한 행

해설 바로 앞 원문 조각 · java
    @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 문장

해설 바로 앞 원문 조각 · java

    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 실행

해설 바로 앞 원문 조각 · java
                """).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 선출은 직접 시험하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 final solution · production target 2/4src/main/java/com/example/financialcore/idempotency/IdempotencyStore.javaSHA-256 ce070bd169a4f7a2042eba765cfef660019f88d48dfbe93396cf44e2dc014880

한 문장 역할: 한 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 성공/예외

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
@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 주입 지점

해설 바로 앞 원문 조각 · java
    @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 반환

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java
            .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로 기존 요청 네 값 조회

해설 바로 앞 원문 조각 · java

    @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으로 변환

해설 바로 앞 원문 조각 · java
            .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

해설 바로 앞 원문 조각 · java

    @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과 변경 행 수

해설 바로 앞 원문 조각 · java
            .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

해설 바로 앞 원문 조각 · java
        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는 다루지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 direct selector body 3/7 · provided integration testsrc/test/java/com/example/financialcore/idempotency/AtomicClaim50IT.javaSHA-256 bc48e20d71736346b350b581bcfed7937e7fb22b0a70bbe2888bc224c860ebf3

한 문장 역할: 같은 복합 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 시험 기반

해설 바로 앞 원문 조각 · java
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 도구

해설 바로 앞 원문 조각 · java
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 값 검증 진입점

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java

@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 주입

해설 바로 앞 원문 조각 · java
    @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 표 초기화

해설 바로 앞 원문 조각 · java
    @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 준비

해설 바로 앞 원문 조각 · java

    @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개 제출

해설 바로 앞 원문 조각 · java
                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 집계

해설 바로 앞 원문 조각 · java
            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

해설 바로 앞 원문 조각 · java
            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

해설 바로 앞 원문 조각 · java

    @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

해설 바로 앞 원문 조각 · java
            .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 입력은 증명하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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();
    }
}
동일 복합 key의 50개 claim에서 직접 관찰하는 값은 owner1·existing49·DB1행이다.

목 · replay·conflict·rollback transaction

7. ErrorCode

W12 staged support 1/4 · D4 scaffoldsrc/main/java/com/example/financialcore/api/ErrorCode.javaSHA-256 0fb7ae51fffc9a23b66a671ba1c1fda0079a5d032af4336996855de756097a75

한 문장 역할: 업무 실패 이름 집합에 IDEMPOTENCY_CONFLICT와 IDEMPOTENCY_IN_PROGRESS를 포함한다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나BusinessException, TransferService, ApiExceptionHandler
무엇을 받나코드가 참조할 enum 상수 이름
무엇이 바뀌나runtime state나 DB를 바꾸지 않음
무엇을 돌려주나컴파일 시 제한된 ErrorCode 값

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 시작

해설 바로 앞 원문 조각 · java
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 종료

해설 바로 앞 원문 조각 · java
    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이 결정하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 staged support 2/4 · D4 scaffoldsrc/main/java/com/example/financialcore/api/ApiExceptionHandler.javaSHA-256 0661feff6d3846ed377c0511d7e28975efea265834065f03e243362d94bbe598

한 문장 역할: 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>

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 타입 연결

해설 바로 앞 원문 조각 · java
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 타입

해설 바로 앞 원문 조각 · java

@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 상태 선택

해설 바로 앞 원문 조각 · java
    @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를 공통 응답으로 전달

해설 바로 앞 원문 조각 · java
        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으로 번역

해설 바로 앞 원문 조각 · java
    @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으로 번역

해설 바로 앞 원문 조각 · java

    @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로 번역

해설 바로 앞 원문 조각 · java

    @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로 번역

해설 바로 앞 원문 조각 · java

    @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 문구로 축약

해설 바로 앞 원문 조각 · java

    @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 입구

해설 바로 앞 원문 조각 · java

    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를 붙여 응답 완성

해설 바로 앞 원문 조각 · java
            .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·로그 저장은 하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 staged support 3/4 · D4 scaffoldsrc/main/java/com/example/financialcore/transfer/TransferFailureHook.javaSHA-256 f34b12c99bb00a772b4a206ee566d1ad2e81d4b3e123251020a7066ef0dd4bb6

한 문장 역할: claim 직후와 업무 변경 직후에 테스트 예외를 주입할 두 지점을 제공하고 production에서는 NONE no-op을 쓴다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나TransferService와 test 전용 ControlledFailureHook
무엇을 받나두 callback 시점; 별도 인자 없음
무엇이 바뀌나default/NONE은 아무것도 바꾸지 않고 test override만 예외 가능
무엇을 돌려주나void 정상 반환 또는 test RuntimeException

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 객체

해설 바로 앞 원문 조각 · java
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

해설 바로 앞 원문 조각 · java
    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를 만들지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 final solution · production target 3/4목·금·토src/main/java/com/example/financialcore/transfer/TransferService.javaSHA-256 97c73b137087c00c92591f66cfde1a84841ee2b2aa50edba3741e9d58830a4fe

한 문장 역할: 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 예외

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 값 묶음

해설 바로 앞 원문 조각 · java
@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인자 위임

해설 바로 앞 원문 조각 · java
        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 값 묶음

해설 바로 앞 원문 조각 · java

    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; 읽기

해설 바로 앞 원문 조각 · java
    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

해설 바로 앞 원문 조각 · java
    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 선택

해설 바로 앞 원문 조각 · java
        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 메서드의 값 흐름

해설 바로 앞 원문 조각 · java

    @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 시험 지점

해설 바로 앞 원문 조각 · java
            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 정렬 잠금

해설 바로 앞 원문 조각 · java
        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 시험 지점

해설 바로 앞 원문 조각 · java
        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 전환

해설 바로 앞 원문 조각 · java
        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

해설 바로 앞 원문 조각 · java

    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

해설 바로 앞 원문 조각 · java
        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로 복원

해설 바로 앞 원문 조각 · java
        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

해설 바로 앞 원문 조각 · java

    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

해설 바로 앞 원문 조각 · java
        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는 구현하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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");
    }
}
새 owner만 public transaction 안에서 validate부터 complete까지 진행하며 unchecked 예외는 이 경계를 rollback한다.
claim empty는 실패가 아니라 기존 row를 읽어 conflict·in-progress·성공 replay 세 갈래를 구분하는 신호다.

11. TransferFailurePointIT

W12 direct selector body 4/7 · evolved W7D5→W12D4src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.javaSHA-256 02b4b98c21c32ccf65c70caa99067faba04cc21c10afb9680fce72d346bad981

한 문장 역할: 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구

해설 바로 앞 원문 조각 · java
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 도구

해설 바로 앞 원문 조각 · java
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 경계

해설 바로 앞 원문 조각 · java
@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; 읽기

해설 바로 앞 원문 조각 · java
    @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; 읽기

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java

    @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

해설 바로 앞 원문 조각 · java

    @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

해설 바로 앞 원문 조각 · java

    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 경계

해설 바로 앞 원문 조각 · java
    }

    @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를 돌려준다

해설 바로 앞 원문 조각 · java
        @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

해설 바로 앞 원문 조각 · java
    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

해설 바로 앞 원문 조각 · java
        @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

해설 바로 앞 원문 조각 · java
        @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 닫기

해설 바로 앞 원문 조각 · java
}

한 줄 읽기: 마지막 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은 증명하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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");
        }
    }
}
두 injected RuntimeException 지점 모두 새 idempotency·TRANSFER transaction·TRANSFER ledger를0으로 되돌린다.

12. TransferIdempotencyIT

W12 direct selector body 5/7 · provided integration testsrc/test/java/com/example/financialcore/transfer/TransferIdempotencyIT.javaSHA-256 852ee32961a77160db94a0365ec3b20e9e2e8129239d09c87497347314188e26

한 문장 역할: 같은 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구

해설 바로 앞 원문 조각 · java
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 도구

해설 바로 앞 원문 조각 · java
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 경계

해설 바로 앞 원문 조각 · java
@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; 읽기

해설 바로 앞 원문 조각 · java
    @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 시작 상태

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java

    @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행

해설 바로 앞 원문 조각 · java
        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

해설 바로 앞 원문 조각 · java
            .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는 직접 검사하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 direct selector body 6/7 · evolved W7D6→W12D5src/test/java/com/example/financialcore/transfer/TransferIntegrationTest.javaSHA-256 1e0ce05254457d8a73de1b820c11745642290779f0a6fea72a6144320590d7e9

한 문장 역할: 실제 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 경계

해설 바로 앞 원문 조각 · java
@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 설정

해설 바로 앞 원문 조각 · java
    @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에 주입된다.
반례·경계 예
@TestConfiguration annotation을 빼면 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 세 가지

해설 바로 앞 원문 조각 · java

    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

해설 바로 앞 원문 조각 · java
        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 claim RuntimeException을 던진다.

코드 조각 9 · AFTER_CLAIM 통합 hook의 exact 예외

해설 바로 앞 원문 조각 · java
        @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 claim RuntimeException을 던지고, 아니면 아무 일 없이 돌아온다.
정상 예
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 예외

해설 바로 앞 원문 조각 · java

        @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 mutation RuntimeException을 던진다.
정상 예
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; 읽기

해설 바로 앞 원문 조각 · java
    @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; 읽기

해설 바로 앞 원문 조각 · java
    @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; 읽기

해설 바로 앞 원문 조각 · java
    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의 깨끗한 출발 상태

해설 바로 앞 원문 조각 · java
    @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의 입구

해설 바로 앞 원문 조각 · java

    @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을 추론해 받는다; @Testtransfer_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을 한 번에 판정한다

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testjson_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 호출

해설 바로 앞 원문 조각 · java
        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를 확인한다

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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다

해설 바로 앞 원문 조각 · java
            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.submit lambda를 등록한다; 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초 안에 회수한다

해설 바로 앞 원문 조각 · java
            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 결과를 받거나 시간 초과로 실패한다; AssertJ isTrue가 실제값과 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을 확인한다

해설 바로 앞 원문 조각 · java
            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의 입구

해설 바로 앞 원문 조각 · java

    @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 뒤에 등록한다

해설 바로 앞 원문 조각 · java
            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-iba-i Command를 만들어 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초 안에 회수한다

해설 바로 앞 원문 조각 · java
            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 결과를 받거나 시간 초과로 실패한다; AssertJ isTrue가 실제값과 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를 확인한다

해설 바로 앞 원문 조각 · java
            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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testsame_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인지 확인한다

해설 바로 앞 원문 조각 · java
        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을 좁히고 nested isEqualTo(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만 남았는지 본다

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testsame_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인지 확인한다

해설 바로 앞 원문 조각 · java
        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을 좁히고 nested isEqualTo(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이 유지되는지 본다

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testsame_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인지 확인한다

해설 바로 앞 원문 조각 · java
        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을 좁히고 nested isEqualTo(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이 유지되는지 본다

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testruntime_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

해설 바로 앞 원문 조각 · java
        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의 입구

해설 바로 앞 원문 조각 · java

    @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행: @Testruntime_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

해설 바로 앞 원문 조각 · java
        assertThatThrownBy(() -> transfers.transfer(command))
            .isInstanceOf(RuntimeException.class)
            .hasMessage("injected after business mutation");
        assertOnlyOpeningStateRemains();
    }

한 줄 읽기: 업무 변경 직후 호출이 RuntimeException 계열이고 exact injected message를 내는지 본다.

문법을 한 줄씩 풀면
244–248행: RuntimeException 본인·하위 type 여부와 injected after business mutation exact 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을 확인한다

해설 바로 앞 원문 조각 · java

    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를 연다; AssertJ isEqualTo, 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 메서드의 값 흐름

해설 바로 앞 원문 조각 · java

    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 메서드의 값 흐름

해설 바로 앞 원문 조각 · java

    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로 정의한다

해설 바로 앞 원문 조각 · java

    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를 연다; AssertJ isEqualTo가 실제값과 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

해설 바로 앞 원문 조각 · java

    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); 읽기

해설 바로 앞 원문 조각 · java
        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 입력은 증명하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 staged support 4/4 · D6 scaffoldsrc/main/java/com/example/financialcore/transfer/api/TransferResponse.javaSHA-256 bf4ef197a241241e8f94adc810c3dff1573849baaa0525df9fc598b51b18d2cd

한 문장 역할: HTTP 응답의 transactionId·businessTransactionId·status·두 잔액·replayed 여섯 필드를 immutable record로 고정한다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나TransferController와 JSON serializer
무엇을 받나여섯 component 값
무엇이 바뀌나DB를 바꾸지 않고 response 객체만 생성
무엇을 돌려주나동명 accessor와 JSON object로 직렬화될 record

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
package com.example.financialcore.transfer.api;

public record TransferResponse(
    String transactionId,
    String businessTransactionId,
    String status,
    long fromBalance,
    long toBalance,
    boolean replayed
) {}

코드 조각 1 · TransferResponse 값 묶음

해설 바로 앞 원문 조각 · java
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가 하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
package com.example.financialcore.transfer.api;

public record TransferResponse(
    String transactionId,
    String businessTransactionId,
    String status,
    long fromBalance,
    long toBalance,
    boolean replayed
) {}

15. TransferController

W12 final solution · production target 4/4src/main/java/com/example/financialcore/transfer/api/TransferController.javaSHA-256 bec3b60aa55fe13089412d48ac05b143aa98afd3322d5345566f4010a7b574ac

한 문장 역할: 인증 principal과 validated request를 service Command로 바꾸고 첫 결과는201, replay 결과는200인 TransferResponse를 만든다.

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나POST /api/transfers
무엇을 받나@Valid TransferRequest와 Principal
무엇이 바뀌나직접 DB를 만지지 않고 TransferService 한 번 호출
무엇을 돌려주나COMPLETED TransferResponse; replayed false→201, true→200

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 도구 준비

해설 바로 앞 원문 조각 · java
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 도구 준비

해설 바로 앞 원문 조각 · java

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

해설 바로 앞 원문 조각 · java
@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 입구

해설 바로 앞 원문 조각 · java
    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 값으로 옮긴다

해설 바로 앞 원문 조각 · java
        @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 선택

해설 바로 앞 원문 조각 · java
        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는 이 파일만으로 증명하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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

W12 direct selector body 7/7 · provided HTTP integration testsrc/test/java/com/example/financialcore/transfer/api/TransferControllerReplayTest.javaSHA-256 75985fc472b694a6fff19e0b3be203d63a2eefd93d45e101e7e7b8656b396c61

한 문장 역할: 같은 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

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · java
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 기반

해설 바로 앞 원문 조각 · java
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 도구

해설 바로 앞 원문 조각 · java
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 경계

해설 바로 앞 원문 조각 · java
@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; 읽기

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java
    @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

해설 바로 앞 원문 조각 · java

    @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 메서드의 값 흐름

해설 바로 앞 원문 조각 · java
    }

    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 메서드의 값 흐름

해설 바로 앞 원문 조각 · java

    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를 완성한다

해설 바로 앞 원문 조각 · java
            + ",\"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하지 않는다.

전체 코드 정답 · 들여쓰기까지 대조

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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 + "}";
    }
}
과제는 replay body의 세 business 값 동일까지 요구하지만 현재 토요일 test가 직접 assert하는 것은 왼쪽 세 항목이다.

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는 이 범위 밖이다.