7주차 코드 뒤풀이

WEEK 07 · CODE AFTERPARTY

7주차 코드 뒤풀이 · STARRY 선불 공연카드 송금편

학습 범위: 1일차부터 6일차까지 · Java 원문 11개 · JUnit 테스트 7개 · SQL Q05/Q06

이 문서는 이야기를 먼저 읽은 사람이 “아까 카드 두 장과 복식 영수증으로 설명한 장면이 실제 코드에서는 바로 이것이구나” 하고 원문에 착지하도록 만든다. 의도적으로 실패시키던 학습용 중간 코드는 넣지 않았다. 월요일·화요일처럼 소설 흐름을 끊지 않고, 한 번의 송금이 들어와 검증되고 저장되고 시험되는 순서로 읽는다.

먼저 잡는 전체 지도

전체 실행 흐름 · text
JSON 신청서
  → TransferRequest의 칸 검사
  → TransferController가 인증 사용자 이름을 결합
  → 트랜잭션 프록시가 TransferService 공식 입구를 감쌈
  → 두 계좌를 번호순으로 잠금
  → 잔액 2곳 + business_tx 1건 + ledger 2줄 저장
  → TransferResponse와 HTTP 201

중간 RuntimeException
  → 위 저장 전부 rollback
기술 시각 마커 1 · 한 송금은 카드 두 장과 한 쌍의 원장 영수증을 함께 움직인다.
기술 시각 마커 1 · 한 송금은 카드 두 장과 한 쌍의 원장 영수증을 함께 움직인다.

원문 인벤토리와 시험 수

  • 최종/지원 Java 파일: 11개
  • 원문에 실제로 선언된 @Test: 7개
  • SQL 연습: Q05, Q06 두 문제
  • Q05/Q06은 제공 자료에 학습자용 canonical 답안 파일이 없으므로, 아래 SQL은 반드시 예시 정답이라고 표시한다.
  • 각 Java 파일은 먼저 정확한 전체 원문을 읽고, 블록별 해설 뒤 직접 다시 쓸 전제 여섯 가지와 정확한 전체 코드를 바로 대조한다.
기술 시각 마커 2 · 가장 바깥 입력 규칙과 안쪽 업무 규칙을 섞지 않는다.
기술 시각 마커 2 · 가장 바깥 입력 규칙과 안쪽 업무 규칙을 섞지 않는다.

1. TransferRequest · 창구에 내는 송금 신청서

한 문장 역할: JSON에서 들어온 거래 표식, 출발·도착 계좌 ID, 금액을 한 묶음으로 보관하고 각 칸의 바깥 모양 규칙을 표시한다.

정확한 저장 경로: day-1/solution/src/main/java/com/example/financialcore/transfer/api/TransferRequest.java
원문 SHA-256: a95e387d24d320e3ace11582565847b8a0a5a98ec316758de4a4daf1c67c24f1

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JSON을 객체로 바인딩하는 Spring MVC, 직접 new하는 Java 코드와 테스트
무엇을 받나 String transactionId, long fromAccountId, long toAccountId, long amount
무엇이 바뀌나 아무 저장값도 바꾸지 않는다. 생성된 record가 네 값을 보관한다.
무엇을 돌려주나 new/JSON binding은 TransferRequest 객체를 만들고 transactionId() 등 accessor가 각 값을 돌려준다. 제약 위반 Set은 record가 아니라 Validator가 별도로 돌려준다.

정확한 전체 원문

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

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record TransferRequest(
    @NotBlank String transactionId,
    @Positive long fromAccountId,
    @Positive long toAccountId,
    @Positive long amount
) {}
기술 시각 마커 3 · 칸별 최소 규칙과 계좌 사이의 업무 규칙은 서로 다른 문에서 검사한다.
기술 시각 마커 3 · 칸별 최소 규칙과 계좌 사이의 업무 규칙은 서로 다른 문에서 검사한다.

코드 블록 1 · 정식 주소와 두 검사표 이름

해설 바로 옆 코드 · java
package com.example.financialcore.transfer.api;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
문법을 한 줄씩 풀면
package는 타입의 정식 주소를 정한다. import는 @NotBlank와 @Positive라는 짧은 이름을 이 파일에서 쓸 수 있게 한다. import는 실행 명령이 아니다.
실제 값 추적
컴파일러는 이 타입을 com.example.financialcore.transfer.api.TransferRequest로 구분하고 두 어노테이션 타입을 jakarta.validation.constraints에서 찾는다.
정상 예
다른 패키지에서 이 record를 쓰려면 전체 이름을 쓰거나 import한 뒤 짧은 이름으로 생성한다.
반례·경계 예
package와 실제 소스 구조가 어긋나거나 의존성이 없으면 컴파일이 실패한다. import만 추가하고 Validator를 호출하지 않으면 값 검사는 일어나지 않는다.
착각 방지
import는 공연 장비 이름표를 가져오는 일이지 장비를 자동 작동시키는 버튼이 아니다.
이 블록이 하지 않는 일
계좌 존재, 소유권, 잔액, 같은 계좌 여부를 확인하지 않는다.
다음 코드와의 연결
다음 record 구성요소에 두 어노테이션을 붙여 어떤 칸을 검사할지 선언한다.

코드 블록 2 · record 네 칸과 칸별 최소 규칙

해설 바로 옆 코드 · java
public record TransferRequest(
    @NotBlank String transactionId,
    @Positive long fromAccountId,
    @Positive long toAccountId,
    @Positive long amount
) {}
문법을 한 줄씩 풀면
record는 괄호 안 구성요소로 생성자와 transactionId(), fromAccountId(), toAccountId(), amount() accessor 등을 만든다. @NotBlank는 null·빈 문자열·공백뿐인 문자열, @Positive는 0 이하를 거부하는 제약 선언이다.
실제 값 추적
new TransferRequest("W7-HTTP", 1L, 2L, 1_000L)이면 네 accessor 결과는 차례로 W7-HTTP, 1, 2, 1000이다.
정상 예
거래 표식이 W7-HTTP이고 두 ID와 금액이 양수면 이 네 필드 제약은 통과한다.
반례·경계 예
new TransferRequest(" ", 0, -1, 0)은 네 건의 위반 후보를 만든다. 반면 1→1은 두 숫자가 각각 양수라 이 단계만 보면 통과한다.
착각 방지
fromAccountId와 toAccountId에 같은 어노테이션이 붙었다고 서로 다르다는 뜻은 아니다. '각 칸 양수'와 '두 칸의 관계'는 다른 규칙이다.
이 블록이 하지 않는 일
record를 생성하는 순간 예외 목록을 반환하지 않는다. Validator가 검사할 때 위반 Set이 만들어진다. 계좌 행이나 잔액도 조회하지 않는다.
다음 코드와의 연결
ValidationTest가 네 나쁜 칸을 직접 검사하고, Controller의 @Valid가 HTTP 입구에 이 선언을 연결한다.

직접 다시 써보기

저장 경로
day-1/solution/src/main/java/com/example/financialcore/transfer/api/TransferRequest.java
전제조건
Java record 문법과 jakarta.validation 의존성을 준비한다.
반드시 지킬 계약
record 이름, 네 구성요소 순서·타입, transactionId의 @NotBlank와 세 long의 @Positive를 유지한다.
추천 입력 순서
package → import 두 줄 → public record → 네 구성요소를 순서대로 입력한다.
자기 점검
정상값 accessor 네 개를 확인하고 Validator로 공백/0/-1/0이 네 위반인지 확인한다.
이번 파일의 범위 밖
같은 계좌, 계좌 존재·소유권·잔액 규칙을 이 DTO에 억지로 넣지 않는다.

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

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

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record TransferRequest(
    @NotBlank String transactionId,
    @Positive long fromAccountId,
    @Positive long toAccountId,
    @Positive long amount
) {}

2. TransferRequestValidationTest · 신청서 네 칸 검사 시험

한 문장 역할: 표준 Bean Validation을 직접 호출해 나쁜 네 입력이 정확히 네 건의 제약 위반을 만드는지 확인한다.

정확한 저장 경로: day-1/scaffold/src/test/java/com/example/financialcore/transfer/api/TransferRequestValidationTest.java
원문 SHA-256: 7b94582917b6a8ec497f22bbb9b9a1d897d0c57fa9f1e29f5fb610fa5f56747e

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5 테스트 실행기
무엇을 받나 테스트 메서드는 인자를 받지 않고, 본문에서 ValidatorFactory와 잘못된 TransferRequest를 만든다.
무엇이 바뀌나 운영 DB나 Spring 상태는 바꾸지 않는다. try 블록이 끝나면 ValidatorFactory를 닫는다.
무엇을 돌려주나 테스트 메서드 자체는 void다. validate가 violation Set을 돌려주며 AssertJ 단언 실패/성공이 테스트 결과가 된다.

정확한 전체 원문

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

import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;

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

class TransferRequestValidationTest {
    @Test
    void blankTransactionAndNonPositiveIdsAndAmountAreRejected() {
        try (var factory = Validation.buildDefaultValidatorFactory()) {
            var violations = factory.getValidator().validate(new TransferRequest(" ", 0, -1, 0));
            assertThat(violations)
                .as("W7D1_RED_EXPECTED_TRANSFER_CONSTRAINTS")
                .hasSize(4);
        }
    }
}
기술 시각 마커 4 · 같은 신청서 그림을 다시 보되, 이번에는 Validator가 네 칸을 실제로 훑는 순간에 초점을 둔다.
기술 시각 마커 4 · 같은 신청서 그림을 다시 보되, 이번에는 Validator가 네 칸을 실제로 훑는 순간에 초점을 둔다.

코드 블록 1 · 검증·JUnit·AssertJ 도구와 테스트 그릇

해설 바로 옆 코드 · java
package com.example.financialcore.transfer.api;

import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;

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

class TransferRequestValidationTest {
문법을 한 줄씩 풀면
Validation은 기본 ValidatorFactory를 만드는 표준 진입점이고 @Test는 JUnit 실행 대상을 표시한다. static import 덕분에 Assertions.assertThat 대신 assertThat이라고 짧게 쓴다.
실제 값 추적
JUnit이 package-private 클래스의 @Test 메서드를 찾아 실행한다. 이 줄들만으로 Spring 컨테이너는 시작하지 않는다.
정상 예
Bean Validation 구현체와 JUnit/AssertJ가 test classpath에 있으면 작은 단위 시험으로 실행된다.
반례·경계 예
validation API만 있고 실제 구현체가 없으면 기본 factory 생성에서 실패할 수 있다.
착각 방지
import jakarta.validation.Validation은 @Valid와 다르다. 여기서는 검사기를 직접 만드는 API다.
이 블록이 하지 않는 일
아직 신청서를 만들거나 검사하지 않는다.
다음 코드와의 연결
다음 @Test 본문이 factory를 열고 한 번 검사한 뒤 자동으로 닫는다.

코드 블록 2 · try-with-resources 안에서 네 위반 세기

해설 바로 옆 코드 · java
    @Test
    void blankTransactionAndNonPositiveIdsAndAmountAreRejected() {
        try (var factory = Validation.buildDefaultValidatorFactory()) {
            var violations = factory.getValidator().validate(new TransferRequest(" ", 0, -1, 0));
            assertThat(violations)
                .as("W7D1_RED_EXPECTED_TRANSFER_CONSTRAINTS")
                .hasSize(4);
        }
    }
}
문법을 한 줄씩 풀면
try (var factory = ...)는 AutoCloseable factory를 블록 종료 때 닫는다. factory.getValidator().validate(...)는 ConstraintViolation Set을 만들고 hasSize(4)가 정확한 개수를 확인한다. as(...)는 실패할 때 보일 진단 설명일 뿐 결과를 바꾸지 않는다.
실제 값 추적
입력은 transactionId=공백 한 칸, from=0, to=-1, amount=0이다. 각각 @NotBlank 또는 @Positive에 걸려 총 4를 기대한다.
정상 예
현재 DTO의 네 구성요소별 최소 제약이 모두 동작하면 통과한다.
반례·경계 예
hasSizeGreaterThan(0)이면 일부 제약이 사라져도 통과할 수 있다. 반대로 총 개수 4만으로 어느 property가 잡혔는지는 확정 못 한다.
착각 방지
문자열 W7D1_RED_EXPECTED_TRANSFER_CONSTRAINTS는 AssertJ 설명 라벨이다. 메서드 호출이나 특수한 테스트 단계가 아니다.
이 블록이 하지 않는 일
정상 요청이 위반 0인지, 같은 계좌인지, HTTP 400인지, 서비스가 쓰기를 막는지는 확인하지 않는다.
다음 코드와의 연결
Controller와 최종 통합 시험이 이 작은 규칙 밖의 연결을 이어서 확인한다.

JUnit 한 메서드 해부 · blankTransactionAndNonPositiveIdsAndAmountAreRejected

단계 이 테스트에서 실제로 하는 일
준비(Arrange) 기본 ValidatorFactory를 열고 공백 transactionId, 0/-1 계좌 ID, 0 금액인 TransferRequest를 준비한다.
행동(Act) factory.getValidator().validate(request)를 호출한다.
확인(Assert) 돌아온 violation Set의 크기가 정확히 4인지 확인한 뒤 factory를 닫는다.
직접 보장하는 것
이 나쁜 객체에서 네 필드 최소 제약이 총 네 건 동작함을 보장한다.
보장하지 않는 것
각 violation의 propertyPath, 정상값 0건, 같은 계좌, @Valid의 HTTP 연결은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: violation propertyPath를 문자열 Set으로 바꿔 transactionId/fromAccountId/toAccountId/amount를 정확히 포함하는지 확인한다.

직접 다시 써보기

저장 경로
day-1/scaffold/src/test/java/com/example/financialcore/transfer/api/TransferRequestValidationTest.java
전제조건
TransferRequest, Bean Validation 구현체, JUnit 5, AssertJ를 test classpath에 둔다.
반드시 지킬 계약
@Test, try-with-resources, 공백/0/-1/0 요청, .as(...)와 hasSize(4)를 정확히 보존한다.
추천 입력 순서
package/import → 테스트 클래스 → @Test 메서드 → factory try → validate → assert 순으로 쓴다.
자기 점검
테스트 1개 통과와 factory 자동 close를 확인하고, 강화 시 violation propertyPath 네 개를 대조한다.
이번 파일의 범위 밖
Spring/HTTP/DB와 같은 계좌 업무 규칙을 이 단위 시험에 끌어오지 않는다.

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

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

import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;

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

class TransferRequestValidationTest {
    @Test
    void blankTransactionAndNonPositiveIdsAndAmountAreRejected() {
        try (var factory = Validation.buildDefaultValidatorFactory()) {
            var violations = factory.getValidator().validate(new TransferRequest(" ", 0, -1, 0));
            assertThat(violations)
                .as("W7D1_RED_EXPECTED_TRANSFER_CONSTRAINTS")
                .hasSize(4);
        }
    }
}

3. TransferBalanceIT · 두 카드 잔액과 원장 합계 통합 시험

한 문장 역할: 실제 Spring/PostgreSQL 시험 환경에서 한 번의 서비스 호출이 두 잔액, 거래 한 행, 균형 원장을 함께 만드는지 확인한다.

정확한 저장 경로: day-2/scaffold/src/test/java/com/example/financialcore/transfer/TransferBalanceIT.java
원문 SHA-256: e565cda3337bcfc0db5b21d4093d1dd7106a4ebdf2851edaa2471e238998ffd8

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5가 실행하는 SpringBootTest
무엇을 받나 주입된 JdbcClient·AccountOpeningService·TransferService와 clean()이 만든 Account 두 개
무엇이 바뀌나 account 두 잔액, business_tx 한 TRANSFER 행, ledger_entry의 TRANSFER 행들을 실제 시험 DB에 쓴다.
무엇을 돌려주나 @Test와 clean은 void다. helper balance는 DB의 long 잔액을 돌려주고, 각 assertion이 관찰값을 판정한다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferBalanceIT 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", "FROM", 10_000);
        to = openings.open("customer-2", "TO", 5_000);
    }

    @Test
    void oneTransactionMovesBothBalancesAndWritesOneBalancedPair() {
        transfers.transfer(new TransferService.Command(
            "customer-1", "W7-T-100", from.getId(), to.getId(), 1_000));
        long fromBalance = balance(from.getId());
        long toBalance = balance(to.getId());
        assertThat(fromBalance).isEqualTo(9_000);
        assertThat(toBalance)
            .as("W7D2_RED_EXPECTED_TWO_BALANCES")
            .isEqualTo(6_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }
}
기술 시각 마커 5 · 두 카드 잔액과 TRANSFER 거래·원장을 한 요청의 영수증으로 함께 대조한다.
기술 시각 마커 5 · 두 카드 잔액과 TRANSFER 거래·원장을 한 요청의 영수증으로 함께 대조한다.

코드 블록 1 · PostgreSQL 통합 시험의 주입 부품

해설 바로 옆 코드 · 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferBalanceIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired TransferService transfers;

    Account from;
    Account to;
문법을 한 줄씩 풀면
@SpringBootTest는 애플리케이션 컨텍스트를 띄운다. extends PostgresIntegrationTestSupport는 이 시험이 공용 PostgreSQL 통합 기반을 물려받는다는 뜻이다. @Autowired가 JdbcClient, 계좌 개설 서비스, 송금 서비스를 주입한다.
실제 값 추적
openings가 실제 Account를 만들고 transfers가 실제 Repository/JPA 경로를 사용하며 jdbc가 같은 시험 DB를 관찰한다.
정상 예
단순 mock이 아니라 객체·트랜잭션·DB 스키마 연결을 한 번에 확인할 수 있다.
반례·경계 예
시험 datasource가 격리되지 않으면 TRUNCATE가 위험하다. PostgresIntegrationTestSupport의 시험 환경이 전제다.
착각 방지
@Autowired 필드 타입이 곧 반환값은 아니다. JUnit이 테스트 객체를 만들고 Spring이 협력자를 채운다.
이 블록이 하지 않는 일
이 블록은 DB를 초기화하거나 송금을 실행하지 않는다.
다음 코드와의 연결
clean()이 매 시험의 기준 Account 두 개를 만든다.

코드 블록 2 · TRUNCATE 후 두 계좌를 실제로 개설

해설 바로 옆 코드 · java
    @BeforeEach void clean() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "FROM", 10_000);
        to = openings.open("customer-2", "TO", 5_000);
    }
문법을 한 줄씩 풀면
@BeforeEach는 각 @Test 직전에 clean을 호출한다. JdbcClient.update()로 네 테이블을 비우고 RESTART IDENTITY로 자동 증가 번호를 초기화하며 CASCADE로 관련 의존성을 함께 처리한다. openings.open이 Account를 저장한다.
실제 값 추적
from은 owner customer-1/accountNo FROM/10000, to는 customer-2/TO/5000으로 시작한다. ID는 하드코딩하지 않고 반환 객체에서 읽는다.
정상 예
테스트 실행 순서와 무관하게 같은 잔액과 빈 TRANSFER 기록에서 출발한다.
반례·경계 예
CASCADE와 TRUNCATE는 매우 강한 명령이다. 시험용 스키마에서만 써야 하며, RESTART IDENTITY가 있어도 ID=1/2라고 단언하지 않는 편이 계약에 안전하다.
착각 방지
@BeforeEach는 테스트 뒤 자동 rollback을 뜻하지 않는다. 다음 시험 전에 다시 판을 지우는 격리 방식이다.
이 블록이 하지 않는 일
송금 결과를 아직 만들지 않는다. 계좌 개설 자체의 OPENING 거래/원장이 생길 수 있어 아래 SQL은 TRANSFER만 필터링한다.
다음 코드와의 연결
테스트 본문이 새 Command로 두 계좌 사이 1000을 옮긴다.

코드 블록 3 · Command 한 장을 보내고 DB 네 가지 관찰

해설 바로 옆 코드 · java
    @Test
    void oneTransactionMovesBothBalancesAndWritesOneBalancedPair() {
        transfers.transfer(new TransferService.Command(
            "customer-1", "W7-T-100", from.getId(), to.getId(), 1_000));
        long fromBalance = balance(from.getId());
        long toBalance = balance(to.getId());
        assertThat(fromBalance).isEqualTo(9_000);
        assertThat(toBalance)
            .as("W7D2_RED_EXPECTED_TWO_BALANCES")
            .isEqualTo(6_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }
문법을 한 줄씩 풀면
new TransferService.Command(...)는 actorId, transactionId, fromId, toId, amount를 묶는다. JdbcClient의 query(Long.class).single()은 단일 집계 셀을 Long으로 읽는다. SQL LIKE 'TRANSFER_%'는 TRANSFER_OUT/IN을 고른다.
실제 값 추적
customer-1이 W7-T-100으로 1000을 보내면 DB 잔액은 9000/6000, TRANSFER business_tx count는 1, 두 signed_amount의 합은 -1000+1000=0을 기대한다.
정상 예
서비스 반환 Result를 보지 않아도 실제 저장 잔액과 거래/원장 집계를 확인한다.
반례·경계 예
원장 행이 네 개인데 값이 우연히 상쇄돼도 SUM=0은 통과한다. 또 개별 금액이 -500/+500이어도 합계만 보면 놓친다.
착각 방지
.as(...) 라벨은 두 번째 잔액 단언 실패 때 보여 줄 설명일 뿐 동작을 바꾸지 않는다. 메서드 이름의 Pair도 assert가 행 수 2를 직접 보진 않는다.
이 블록이 하지 않는 일
HTTP 201, 서비스 Result, 실패 rollback, 동시 송금은 직접 확인하지 않는다.
다음 코드와의 연결
최종 TransferIntegrationTest가 원장 count=2까지 추가하고, 실패 시험이 rollback을 본다.

코드 블록 4 · 이름 있는 SQL 매개변수로 한 잔액 읽기

해설 바로 옆 코드 · java
    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }
}
문법을 한 줄씩 풀면
private balance는 :id 이름 매개변수에 param("id", id)를 바인딩하고 account 표의 balance 단일 셀을 long으로 돌려준다.
실제 값 추적
balance(from.getId())는 from 행, balance(to.getId())는 to 행의 최신 DB 값을 읽는다.
정상 예
문자열로 ID를 이어 붙이지 않아 SQL 문법과 값을 분리한다.
반례·경계 예
해당 행이 0개거나 여러 개면 single() 계약이 깨져 조회 예외가 난다. helper가 null을 조용히 돌려주는 계약은 아니다.
착각 방지
여기 balance 메서드는 Account.getBalance()도 TransferService 계산도 아니다. DB를 다시 읽는 테스트용 관찰창이다.
이 블록이 하지 않는 일
잔액을 변경하거나 잠그지 않는다.
다음 코드와의 연결
실패/최종 통합 시험에서도 같은 JdbcClient 관찰 방식이 이어진다.

JUnit 한 메서드 해부 · oneTransactionMovesBothBalancesAndWritesOneBalancedPair

단계 이 테스트에서 실제로 하는 일
준비(Arrange) 네 표를 비우고 customer-1의 10000 계좌와 customer-2의 5000 계좌를 실제로 개설한다.
행동(Act) Command(customer-1, W7-T-100, fromId, toId, 1000)으로 transfer를 호출한다.
확인(Assert) DB 잔액 9000/6000, TRANSFER 거래 1행, TRANSFER 원장 signed_amount 합 0을 확인한다.
직접 보장하는 것
정상 서비스 호출 한 번의 두 저장 잔액, 송금 거래 수, 원장 합계 균형을 보장한다.
보장하지 않는 것
서비스 Result, 원장 정확히 2행·개별 부호/금액, HTTP, rollback은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: SELECT signed_amount ... ORDER BY signed_amount 결과가 [-1000, 1000]이고 COUNT(*)=2인지 확인한다.

직접 다시 써보기

저장 경로
day-2/scaffold/src/test/java/com/example/financialcore/transfer/TransferBalanceIT.java
전제조건
격리된 PostgreSQL 시험 환경, 스키마, AccountOpeningService, TransferService, JdbcClient를 준비한다.
반드시 지킬 계약
TRUNCATE 대상 네 표, customer-1/customer-2 계좌와 10000/5000, Command W7-T-100·1000, 기대 9000/6000·TRANSFER 1·SUM 0을 유지한다.
추천 입력 순서
주입 필드와 Account 필드 → clean fixture → @Test Command/DB assert → balance helper 순으로 쓴다.
자기 점검
단독/전체 실행 모두 통과시키고 강화 시 TRANSFER ledger count=2와 개별 signed_amount -1000/+1000을 확인한다.
이번 파일의 범위 밖
HTTP와 실패 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferBalanceIT 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", "FROM", 10_000);
        to = openings.open("customer-2", "TO", 5_000);
    }

    @Test
    void oneTransactionMovesBothBalancesAndWritesOneBalancedPair() {
        transfers.transfer(new TransferService.Command(
            "customer-1", "W7-T-100", from.getId(), to.getId(), 1_000));
        long fromBalance = balance(from.getId());
        long toBalance = balance(to.getId());
        assertThat(fromBalance).isEqualTo(9_000);
        assertThat(toBalance)
            .as("W7D2_RED_EXPECTED_TWO_BALANCES")
            .isEqualTo(6_000);
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isEqualTo(1);
        assertThat(jdbc.sql("SELECT SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }
}

4. TransactionProxyIT · 트랜잭션 공식 입구 구조 시험

한 문장 역할: 주입된 TransferService가 AOP 프록시인지, public transfer(Command)에 @Transactional이 실제 선언됐는지 확인한다.

정확한 저장 경로: day-3/scaffold/src/test/java/com/example/financialcore/transfer/TransactionProxyIT.java
원문 SHA-256: cc0d773a21127c04fa455a12f46dcc510f2521b963f141966d937f3a6c488c8f

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5와 SpringBootTest
무엇을 받나 Spring이 주입한 TransferService bean; 테스트 메서드는 인자를 받지 않는다.
무엇이 바뀌나 업무 DB를 바꾸지 않는다. reflection으로 메서드 메타데이터만 읽는다.
무엇을 돌려주나 테스트 메서드는 void다. AopUtils와 reflection이 boolean을 만들고 assert가 판정한다.

정확한 전체 원문

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

import com.example.financialcore.PostgresIntegrationTestSupport;

import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest
class TransactionProxyIT extends PostgresIntegrationTestSupport {
    @Autowired TransferService transfers;

    @Test
    void transferBeanIsProxiedAndPublicMethodOwnsTheBoundary() throws Exception {
        assertThat(AopUtils.isAopProxy(transfers)).isTrue();
        assertThat(TransferService.class.getMethod("transfer", TransferService.Command.class)
            .isAnnotationPresent(Transactional.class)).isTrue();
    }
}
기술 시각 마커 6 · 외부 호출이 프록시 공식 입구를 지나야 트랜잭션 부가기능이 끼어드는 구조다.
기술 시각 마커 6 · 외부 호출이 프록시 공식 입구를 지나야 트랜잭션 부가기능이 끼어드는 구조다.

코드 블록 1 · 프록시·reflection·트랜잭션 어노테이션 준비

해설 바로 옆 코드 · java
package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;

import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest
class TransactionProxyIT extends PostgresIntegrationTestSupport {
    @Autowired TransferService transfers;
문법을 한 줄씩 풀면
PostgresIntegrationTestSupport 기반의 @SpringBootTest가 실제 bean을 주입한다. AopUtils는 프록시 여부를, Class.getMethod는 public 메서드 정보를 찾는 데 쓴다.
실제 값 추적
transfers 변수의 선언 타입은 TransferService지만 실제 참조 객체는 Spring이 세운 프록시일 수 있다.
정상 예
공식 외부 호출이 프록시를 지나도록 구성됐는지 실제 컨테이너 bean으로 확인할 수 있다.
반례·경계 예
new TransferService(...)로 직접 만든 객체는 컨테이너 후처리를 지나지 않아 같은 어노테이션이 있어도 프록시가 아니다.
착각 방지
주입 타입과 런타임 실제 클래스는 다를 수 있다. 그러나 호출자는 같은 transfer 메서드처럼 사용한다.
이 블록이 하지 않는 일
송금이나 rollback을 아직 실행하지 않는다.
다음 코드와의 연결
다음 @Test가 프록시 boolean과 메서드 어노테이션 boolean을 각각 본다.

코드 블록 2 · 프록시 true와 public 메서드 경계 확인

해설 바로 옆 코드 · java
    @Test
    void transferBeanIsProxiedAndPublicMethodOwnsTheBoundary() throws Exception {
        assertThat(AopUtils.isAopProxy(transfers)).isTrue();
        assertThat(TransferService.class.getMethod("transfer", TransferService.Command.class)
            .isAnnotationPresent(Transactional.class)).isTrue();
    }
}
문법을 한 줄씩 풀면
AopUtils.isAopProxy(transfers)는 주입 객체를 검사한다. TransferService.class.getMethod("transfer", TransferService.Command.class)는 정확한 public 시그니처를 찾고 isAnnotationPresent가 @Transactional 직접 선언을 확인한다. throws Exception은 reflection 조회 예외를 호출자인 JUnit에 넘긴다.
실제 값 추적
찾는 시그니처는 transfer(Command) 한 개다. 문자열·long 다섯 인자를 받는 메서드가 아니다.
정상 예
두 assert가 true면 프록시 공식 입구와 public 트랜잭션 표지가 함께 있는 정적 구조다.
반례·경계 예
클래스 레벨로 어노테이션을 옮기면 실제 적용은 가능해도 두 번째 '메서드 직접 선언' 단언은 실패할 수 있다. 어노테이션만 있고 트랜잭션 후처리가 꺼지면 첫 단언이 실패한다.
착각 방지
구조가 있다는 사실은 실제 RuntimeException 때 DB가 원복됐다는 행동 증거와 다르다.
이 블록이 하지 않는 일
활성 트랜잭션 여부, propagation/isolation, self-invocation, 실제 commit/rollback은 확인하지 않는다.
다음 코드와의 연결
TransferFailurePointIT가 중간 예외의 실제 전부 rollback을 이어서 증명한다.

JUnit 한 메서드 해부 · transferBeanIsProxiedAndPublicMethodOwnsTheBoundary

단계 이 테스트에서 실제로 하는 일
준비(Arrange) Spring 컨텍스트에서 TransferService를 주입받는다.
행동(Act) AopUtils로 프록시 여부를 묻고 Class.getMethod로 public transfer(Command)를 찾는다.
확인(Assert) 주입 bean이 AOP 프록시이며 메서드에 @Transactional이 직접 선언됐는지 확인한다.
직접 보장하는 것
외부 public transfer(Command) 호출에 트랜잭션 advice를 적용할 구조를 보장한다.
보장하지 않는 것
실제 트랜잭션 활성/rollback, self-invocation, propagation/isolation은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: 프록시로 호출한 작은 probe 메서드 내부에서 TransactionSynchronizationManager.isActualTransactionActive()를 기록해 true인지 본다.

직접 다시 써보기

저장 경로
day-3/scaffold/src/test/java/com/example/financialcore/transfer/TransactionProxyIT.java
전제조건
Spring AOP/트랜잭션 관리가 활성화된 통합 컨텍스트와 TransferService bean을 준비한다.
반드시 지킬 계약
isAopProxy true와 public transfer(TransferService.Command)의 메서드 직접 @Transactional true를 유지한다.
추천 입력 순서
package/import → SpringBootTest 클래스 → 주입 필드 → @Test → 두 boolean assert 순으로 쓴다.
자기 점검
주입 bean 실제 클래스와 reflection 대상 시그니처를 확인하고 rollback 행동 시험도 함께 통과시킨다.
이번 파일의 범위 밖
이 구조 시험에서 DB 쓰기나 rollback을 실행했다고 가정하지 않는다.

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

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

import com.example.financialcore.PostgresIntegrationTestSupport;

import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest
class TransactionProxyIT extends PostgresIntegrationTestSupport {
    @Autowired TransferService transfers;

    @Test
    void transferBeanIsProxiedAndPublicMethodOwnsTheBoundary() throws Exception {
        assertThat(AopUtils.isAopProxy(transfers)).isTrue();
        assertThat(TransferService.class.getMethod("transfer", TransferService.Command.class)
            .isAnnotationPresent(Transactional.class)).isTrue();
    }
}

5. TransferResponse · 새 송금 완료 영수증

한 문장 역할: 클라이언트가 받은 요청 표식과 서버가 만든 업무 거래 ID, 상태, 두 새 잔액을 외부 응답 한 묶음으로 보관한다.

정확한 저장 경로: day-4/scaffold/src/main/java/com/example/financialcore/transfer/api/TransferResponse.java
원문 SHA-256: 0cd236350b190ac758064b66e5a0fb5151b1983999f07497e6413aedf3bee736

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 TransferController와 JSON 직렬화기
무엇을 받나 String transactionId, String businessTransactionId, String status, long fromBalance, long toBalance
무엇이 바뀌나 아무 DB 값도 바꾸지 않는다. 생성된 record가 응답 다섯 값을 보관한다.
무엇을 돌려주나 생성자는 TransferResponse 객체를 만들고 다섯 accessor가 각 값을 돌려준다. HTTP 201은 이 record가 아니라 Controller의 ResponseEntity가 돌려준다.

정확한 전체 원문

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

public record TransferResponse(
    String transactionId,
    String businessTransactionId,
    String status,
    long fromBalance,
    long toBalance
) {}
기술 시각 마커 7 · 완료 영수증에는 호출자의 요청 표식과 서버가 만든 업무 거래 ID가 각각 따로 들어간다.
기술 시각 마커 7 · 완료 영수증에는 호출자의 요청 표식과 서버가 만든 업무 거래 ID가 각각 따로 들어간다.

코드 블록 1 · 요청 ID와 서버 거래 ID를 함께 담는 다섯 칸

해설 바로 옆 코드 · java
package com.example.financialcore.transfer.api;

public record TransferResponse(
    String transactionId,
    String businessTransactionId,
    String status,
    long fromBalance,
    long toBalance
) {}
문법을 한 줄씩 풀면
public record는 다섯 구성요소로 canonical 생성자와 transactionId(), businessTransactionId(), status(), fromBalance(), toBalance() accessor 등을 만든다. 별도 검증 어노테이션은 없다.
실제 값 추적
요청 W7-HTTP가 처리되고 저장된 BusinessTransaction UUID가 7f...라면 응답은 W7-HTTP와 7f...를 서로 다른 두 칸에 담고 COMPLETED, 9000, 6000을 잇는다.
정상 예
클라이언트는 자신이 보낸 transactionId와 서버 내부 업무 거래 ID를 동시에 받아 요청-저장 결과를 연결할 수 있다.
반례·경계 예
두 ID를 같은 값으로 넣어도 컴파일되고 status에 오타를 넣어도 컴파일된다. 이 record는 값 의미를 스스로 검사하지 않는다.
착각 방지
transactionId는 요청이 준 correlation 표식이고 businessTransactionId는 저장 엔티티의 생성 UUID 문자열이다. 이름이 비슷해도 출처가 다르다.
이 블록이 하지 않는 일
HTTP status, Location 헤더, 송금 실행, 인증, DB 저장을 하지 않는다.
다음 코드와의 연결
Controller가 request에서 첫 ID, service Result에서 둘째 ID와 잔액, 고정 문자열에서 status를 조립한다.

직접 다시 써보기

저장 경로
day-4/scaffold/src/main/java/com/example/financialcore/transfer/api/TransferResponse.java
전제조건
Java record와 응답 다섯 필드의 자료형·의미를 준비한다.
반드시 지킬 계약
transactionId, businessTransactionId, status, fromBalance, toBalance의 이름·순서·타입을 정확히 유지한다.
추천 입력 순서
package → public record → 다섯 구성요소 순서로 입력한다.
자기 점검
Controller가 만든 객체에서 요청 ID와 업무 거래 UUID가 구분되고 status/두 잔액이 accessor로 그대로 나오는지 확인한다.
이번 파일의 범위 밖
HTTP 201, 인증, 업무 처리나 값 검증을 이 응답 record에 넣지 않는다.

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

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

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

6. TransferController · 인증 이름을 Command에 붙이는 HTTP 공식 창구

한 문장 역할: POST /api/transfers JSON을 검증하고 Principal 이름과 합쳐 Command로 서비스에 넘긴 뒤 201과 완료 영수증을 만든다.

정확한 저장 경로: day-4/solution/src/main/java/com/example/financialcore/transfer/api/TransferController.java
원문 SHA-256: 9f56f1d1583f8407f898ba50db32f6745b1b3c216ddd7d083198d8f7dbacd41d

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 Spring MVC DispatcherServlet
무엇을 받나 @Valid @RequestBody TransferRequest와 인증된 Principal
무엇이 바뀌나 직접 Repository를 만지지 않지만 TransferService를 호출해 트랜잭션 안의 송금 저장을 일으킨다.
무엇을 돌려주나 ResponseEntity<TransferResponse>; 정상 경로에서 HTTP 201과 다섯 필드 본문을 돌려준다. Location 헤더는 만들지 않는다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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());
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}
기술 시각 마커 8 · 공식 HTTP 창구는 검증된 신청서에 인증 사용자 이름을 붙여 Command로 바꾼다.
기술 시각 마커 8 · 공식 HTTP 창구는 검증된 신청서에 인증 사용자 이름을 붙여 Command로 바꾼다.

코드 블록 1 · 창구 주소와 생성자 주입

해설 바로 옆 코드 · 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; }
문법을 한 줄씩 풀면
@RestController는 반환 객체를 응답 본문으로 다룰 MVC 컴포넌트다. @RequestMapping("/api/transfers")는 클래스 공통 주소다. final 필드와 생성자는 TransferService 참조를 한 번 받아 보관한다.
실제 값 추적
Spring이 Controller를 만들 때 컨테이너의 TransferService bean, 보통 프록시 참조를 transfers에 넣는다.
정상 예
클라이언트가 /api/transfers로 POST하면 아래 @PostMapping 메서드가 후보가 된다.
반례·경계 예
/transfers로만 호출하면 이 코드의 주소와 다르다. 생성자에 null을 직접 넣으면 컴파일은 돼도 호출 시 실패한다.
착각 방지
this.transfers는 객체 필드, 생성자 매개변수 transfers는 들어온 값이다. final은 참조를 갈아 끼우지 않는다는 뜻이다.
이 블록이 하지 않는 일
인증 자체, JSON 파싱, DB 트랜잭션 구현을 이 블록이 직접 하지 않는다.
다음 코드와의 연결
아래 transfer 메서드가 요청 객체와 Principal을 Command 하나로 번역한다.

코드 블록 2 · Request+Principal → Command → 201 body

해설 바로 옆 코드 · java
    @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());
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}
문법을 한 줄씩 풀면
@PostMapping은 공통 주소의 POST를 연결한다. @RequestBody는 JSON을 Request로, @Valid는 DTO 제약을 검사한다. Principal.getName()은 인증 주체의 이름이다. ResponseEntity.status(HttpStatus.CREATED).body(body)는 status 201과 본문을 조립한다.
실제 값 추적
principal 이름 customer-1, request W7-HTTP/fromId/toId/1000이면 Command(customer-1,W7-HTTP,fromId,toId,1000)가 된다. Result의 UUID 문자열과 9000/6000을 request ID, COMPLETED와 합쳐 Response를 만든다.
정상 예
정상 요청은 service 공식 입구를 한 번 호출하고 201 본문에 요청 ID·업무 거래 ID·상태·두 잔액을 담는다.
반례·경계 예
Principal이 없으면 getName()에서 실패할 수 있으므로 Security가 앞에서 인증을 요구해야 한다. @Valid를 빼면 공백/0 입력이 서비스까지 갈 수 있다.
착각 방지
이 메서드는 package-private여도 Spring MVC가 매핑할 수 있다. public이 아닌 Controller 메서드와 public 서비스 트랜잭션 경계를 혼동하지 않는다.
이 블록이 하지 않는 일
Location 헤더를 만들지 않고 예외를 오류 JSON으로 변환하지 않는다. 같은 계좌·소유권·잔액은 서비스가 검사한다.
다음 코드와의 연결
MockMvc 정상 시험이 201/본문 표지를 보고, 서비스 통합 시험들이 DB 결과와 rollback을 이어서 본다.

직접 다시 써보기

저장 경로
day-4/solution/src/main/java/com/example/financialcore/transfer/api/TransferController.java
전제조건
Spring MVC/Validation/Security Principal, TransferService와 두 API record를 준비한다.
반드시 지킬 계약
경로 /api/transfers, @Valid @RequestBody, Principal actorId, Command 다섯 인자 순서, Response 다섯 인자 순서, CREATED body 반환을 유지한다.
추천 입력 순서
package/import → 클래스 어노테이션 → final field/constructor → @PostMapping 메서드 → Command → Response → 201 body 순으로 쓴다.
자기 점검
MockMvc로 201과 다섯 JSON 필드를 확인하고 Location 헤더가 원문 계약에 없음을 점검한다.
이번 파일의 범위 밖
Repository 업무와 예외 JSON 처리를 Controller에 복제하지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · 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());
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}

7. TransferControllerHappyPathTest · 인증된 정상 POST 시험

한 문장 역할: 동적으로 만든 두 계좌 ID를 JSON에 넣어 Basic 인증 POST를 보내고 응답 status 201과 두 문자열 표지를 확인한다.

정확한 저장 경로: day-4/scaffold/src/test/java/com/example/financialcore/transfer/api/TransferControllerHappyPathTest.java
원문 SHA-256: 9534ba4523e4bb72649c388a63457b2bdf436bf983dc5fcfd31ed43f3d5c100d

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5가 실행하는 SpringBootTest+MockMvc
무엇을 받나 주입된 MockMvc/JdbcClient/AccountOpeningService와 clean()이 만든 두 Account, body helper의 transactionId·amount
무엇이 바뀌나 실제 서비스 경로를 지나 시험 DB의 account·business_tx·ledger_entry를 바꾼다.
무엇을 돌려주나 테스트/helper는 각각 void/String이다. MockMvc가 MockHttpServletResponse를 돌려주고 assert가 status/body를 판정한다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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 TransferControllerHappyPathTest 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", "HTTP-FROM", 10_000);
        to = openings.open("customer-2", "HTTP-TO", 5_000);
    }

    @Test
    void newTransferUses201AndCompletedResponse() throws Exception {
        var response = mvc.perform(post("/api/transfers")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json").content(body("W7-HTTP", 1_000)))
            .andReturn().getResponse();
        assertThat(response.getStatus())
            .as("W7D4_RED_EXPECTED_TRANSFER_CREATED")
            .isEqualTo(201);
        assertThat(response.getContentAsString()).contains("W7-HTTP", "COMPLETED");
    }

    private String body(String transactionId, long amount) {
        return "{\"transactionId\":\"" + transactionId + "\",\"fromAccountId\":" + from.getId()
            + ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
    }
}
기술 시각 마커 9 · 시험은 현재 201과 두 표지만 보며, 다섯 칸 전체 검증은 강화 항목으로 남아 있다.
기술 시각 마커 9 · 시험은 현재 201과 두 표지만 보며, 다섯 칸 전체 검증은 강화 항목으로 남아 있다.

코드 블록 1 · MockMvc·DB 관찰·계좌 개설 도구

해설 바로 옆 코드 · 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 TransferControllerHappyPathTest extends PostgresIntegrationTestSupport {
    @Autowired MockMvc mvc;
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;

    Account from;
    Account to;
문법을 한 줄씩 풀면
@SpringBootTest는 전체 컨텍스트, @AutoConfigureMockMvc는 실제 포트를 열지 않고 필터/MVC를 호출할 MockMvc를 준비한다. 클래스는 PostgreSQL 시험 기반을 상속한다. httpBasic과 post는 요청 조립 도구다.
실제 값 추적
mvc는 Security→Controller→Service 경로를 타고, jdbc/openings는 같은 시험 DB fixture를 만든다.
정상 예
Controller 한 메서드만 new해서 시험하는 것이 아니라 보안·웹·서비스·DB가 연결된 정상 여행을 볼 수 있다.
반례·경계 예
Basic 인증 사용자 customer-1/password가 시험 Security 구성에 있어야 한다. 없으면 Controller 전에 401이다.
착각 방지
MockMvc는 네트워크 소켓을 열지 않지만 설정된 필터 체인을 지난다. 'mock'이라는 이름만 보고 service도 mock이라 생각하면 안 된다.
이 블록이 하지 않는 일
이 블록은 아직 계좌를 만들거나 POST하지 않는다.
다음 코드와의 연결
clean이 두 실제 Account를 만든 뒤 테스트가 getId()를 JSON에 넣는다.

코드 블록 2 · 테이블 청소와 동적 ID 계좌 두 개

해설 바로 옆 코드 · java
    @BeforeEach void clean() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "HTTP-FROM", 10_000);
        to = openings.open("customer-2", "HTTP-TO", 5_000);
    }
문법을 한 줄씩 풀면
@BeforeEach clean은 네 표를 TRUNCATE하고 두 계좌를 openings.open으로 만든다. Account 객체를 필드에 보관해 이후 실제 생성 ID를 쓴다.
실제 값 추적
customer-1/HTTP-FROM/10000, customer-2/HTTP-TO/5000에서 시작한다.
정상 예
자동 생성 ID가 무엇이든 body helper가 from.getId()/to.getId()를 써 올바른 행을 가리킨다.
반례·경계 예
ID를 1,2로 하드코딩하면 sequence/fixture 변화에 취약하다. 운영 DB에서 TRUNCATE하면 안 된다.
착각 방지
계좌 개설이 자체 거래/원장을 만들 수 있지만 이 테스트 본문은 응답만 직접 본다.
이 블록이 하지 않는 일
아직 송금 POST를 보내지 않는다.
다음 코드와의 연결
다음 테스트가 W7-HTTP와 1000을 body helper에 준다.

코드 블록 3 · 인증 POST 뒤 status와 본문 표지 확인

해설 바로 옆 코드 · java
    @Test
    void newTransferUses201AndCompletedResponse() throws Exception {
        var response = mvc.perform(post("/api/transfers")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json").content(body("W7-HTTP", 1_000)))
            .andReturn().getResponse();
        assertThat(response.getStatus())
            .as("W7D4_RED_EXPECTED_TRANSFER_CREATED")
            .isEqualTo(201);
        assertThat(response.getContentAsString()).contains("W7-HTTP", "COMPLETED");
    }
문법을 한 줄씩 풀면
mvc.perform(post(...).with(httpBasic(...)).contentType(...).content(...))가 요청을 수행한다. andReturn().getResponse()로 raw response를 받고 AssertJ로 int status와 String body를 본다.
실제 값 추적
customer-1/password로 /api/transfers에 W7-HTTP/동적 IDs/1000 JSON을 보내고 201, W7-HTTP, COMPLETED를 기대한다.
정상 예
정상 인증·DTO 검증·Controller→Service 연결이 최소 성공 응답 표지까지 도달함을 보여준다.
반례·경계 예
contains는 JSON 필드 위치·타입을 확인하지 않는다. body 어딘가에 문자열이 있기만 해도 통과하며 businessTransactionId와 두 잔액, DB 결과도 직접 단언하지 않는다.
착각 방지
as(...)는 status 단언의 진단 라벨이다. 테스트 동작을 추가하지 않는다.
이 블록이 하지 않는 일
Location, 전체 JSON schema, 오류 응답, DB row 수를 보장하지 않는다.
다음 코드와의 연결
TransferIntegrationTest가 서비스 Result와 거래/원장 수를 별도로 확인한다.

코드 블록 4 · 두 Account ID로 JSON 문자열 만들기

해설 바로 옆 코드 · java
    private String body(String transactionId, long amount) {
        return "{\"transactionId\":\"" + transactionId + "\",\"fromAccountId\":" + from.getId()
            + ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
    }
}
문법을 한 줄씩 풀면
private body는 transactionId와 amount를 받고 문자열 연결로 JSON을 만든다. from/to ID는 필드 Account에서 읽는다. 반환 타입 String이므로 호출자는 .content(...)에 바로 넣는다.
실제 값 추적
body("W7-HTTP",1000)은 현재 fixture ID를 포함한 {transactionId,fromAccountId,toAccountId,amount} 네 속성 문자열이 된다.
정상 예
테스트 fixture가 만든 동적 ID를 요청 본문에 정확히 반영한다.
반례·경계 예
transactionId에 따옴표 같은 특수문자를 넣으면 단순 문자열 연결이 유효한 JSON escaping을 하지 못한다. 현재 신뢰된 고정 테스트값 범위에서만 간단하다.
착각 방지
이 helper는 TransferRequest 객체를 만드는 것이 아니라 JSON 텍스트를 만든다. 따라서 JSON→record 바인딩까지 시험할 수 있다.
이 블록이 하지 않는 일
JSON 문법을 검증하거나 운영 요청을 안전하게 직렬화하지 않는다.
다음 코드와의 연결
강화할 때는 ObjectMapper나 jsonPath를 써 구조를 더 정확히 다룰 수 있다.

JUnit 한 메서드 해부 · newTransferUses201AndCompletedResponse

단계 이 테스트에서 실제로 하는 일
준비(Arrange) 네 표를 비우고 owner가 다른 두 계좌, customer-1 Basic 인증과 동적 ID JSON을 준비한다.
행동(Act) POST /api/transfers를 application/json으로 수행하고 raw response를 얻는다.
확인(Assert) status=201이며 body String이 W7-HTTP와 COMPLETED를 포함하는지 확인한다.
직접 보장하는 것
정상 인증 요청이 웹→서비스 경로를 지나 최소 201/완료 응답 표지를 만든다는 것을 보장한다.
보장하지 않는 것
다섯 JSON 필드의 경로·값, Location, DB 상태, 오류 응답은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: jsonPath로 transactionId, businessTransactionId 존재, status, fromBalance=9000, toBalance=6000을 각각 확인한다.

직접 다시 써보기

저장 경로
day-4/scaffold/src/test/java/com/example/financialcore/transfer/api/TransferControllerHappyPathTest.java
전제조건
격리 PostgreSQL, test Security 사용자, MockMvc, JdbcClient, AccountOpeningService와 실제 송금 구성을 준비한다.
반드시 지킬 계약
네 표 TRUNCATE, customer-1/password, 동적 Account ID, /api/transfers, W7-HTTP·1000, status 201과 두 contains 단언을 유지한다.
추천 입력 순서
주입/Account 필드 → clean → @Test 요청/응답 assert → body String helper 순으로 쓴다.
자기 점검
단독/전체 실행 후 강화 시 jsonPath 다섯 필드와 업무 거래/원장 DB 행을 확인한다.
이번 파일의 범위 밖
오류 응답 모음, 동시성, Location 헤더를 원문이 보장한다고 덧붙이지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · 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 TransferControllerHappyPathTest 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", "HTTP-FROM", 10_000);
        to = openings.open("customer-2", "HTTP-TO", 5_000);
    }

    @Test
    void newTransferUses201AndCompletedResponse() throws Exception {
        var response = mvc.perform(post("/api/transfers")
                .with(httpBasic("customer-1", "password"))
                .contentType("application/json").content(body("W7-HTTP", 1_000)))
            .andReturn().getResponse();
        assertThat(response.getStatus())
            .as("W7D4_RED_EXPECTED_TRANSFER_CREATED")
            .isEqualTo(201);
        assertThat(response.getContentAsString()).contains("W7-HTTP", "COMPLETED");
    }

    private String body(String transactionId, long amount) {
        return "{\"transactionId\":\"" + transactionId + "\",\"fromAccountId\":" + from.getId()
            + ",\"toAccountId\":" + to.getId() + ",\"amount\":" + amount + "}";
    }
}

8. TransferFailureHook · 업무 변경 뒤 꽂는 시험용 경보기

한 문장 역할: 서비스가 두 계좌와 거래·원장을 만든 뒤 호출할 작은 확장 지점을 제공하며, 기본 구현은 아무것도 하지 않는다.

정확한 저장 경로: day-5/scaffold/src/main/java/com/example/financialcore/transfer/TransferFailureHook.java
원문 SHA-256: 9e5c1e81ed8fb7acbd1091afda23711ab5d5b4d0d01fba6ead7bd9757de6623d

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 TransferService
무엇을 받나 afterBusinessMutation()은 인자를 받지 않는다.
무엇이 바뀌나 NONE 구현은 아무것도 바꾸지 않는다. 테스트 구현은 AtomicBoolean을 바꾸고 RuntimeException을 던진다.
무엇을 돌려주나 메서드는 void다. 정상 기본 구현은 조용히 끝나고 테스트 구현은 반환 대신 예외를 던질 수 있다.

정확한 전체 원문

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

public interface TransferFailureHook {
    TransferFailureHook NONE = () -> {};
    void afterBusinessMutation();
}
기술 시각 마커 10 · 경보기는 중간 실패를 만들 뿐이고, 전부 되돌리는 주체는 트랜잭션 경계다.
기술 시각 마커 10 · 경보기는 중간 실패를 만들 뿐이고, 전부 되돌리는 주체는 트랜잭션 경계다.

코드 블록 1 · 인자 없는 hook 계약과 NONE 기본값

해설 바로 옆 코드 · java
package com.example.financialcore.transfer;

public interface TransferFailureHook {
    TransferFailureHook NONE = () -> {};
    void afterBusinessMutation();
}
문법을 한 줄씩 풀면
interface는 구현이 따라야 할 메서드 모양을 선언한다. NONE은 인자 없는 람다 () -> {}로 afterBusinessMutation을 구현한다. interface 필드는 암묵적으로 public static final이다.
실제 값 추적
운영 컨텍스트에 다른 hook bean이 없으면 TransferService가 NONE을 선택하고 호출 뒤 그대로 다음 줄로 간다.
정상 예
테스트는 같은 메서드 모양의 람다에 AtomicBoolean 변경과 RuntimeException을 넣어 정확한 중간 실패를 만든다.
반례·경계 예
NONE이 예외를 잡아 삼키는 것은 아니다. 스스로 아무 문장도 실행하지 않을 뿐이다. 메서드에 transactionId 인자는 없다.
착각 방지
hook 이름의 afterBusinessMutation은 호출 위치 계약을 말한다. rollback을 직접 수행하는 객체라는 뜻은 아니다.
이 블록이 하지 않는 일
트랜잭션 시작/commit/rollback이나 DB 저장을 하지 않는다.
다음 코드와의 연결
TransferService가 원장 두 행 save 뒤 Result를 만들기 전에 이 메서드를 호출한다.

직접 다시 써보기

저장 경로
day-5/scaffold/src/main/java/com/example/financialcore/transfer/TransferFailureHook.java
전제조건
Java interface/람다 문법과 서비스의 선택적 hook 주입 지점을 준비한다.
반드시 지킬 계약
TransferFailureHook NONE = () -> {}와 void afterBusinessMutation() 시그니처를 정확히 유지한다.
추천 입력 순서
package → interface → NONE 람다 → 인자 없는 메서드 선언 순으로 쓴다.
자기 점검
NONE 호출이 조용히 끝나고 테스트 구현 호출이 flag를 바꾼 뒤 예외를 밖으로 던지는지 확인한다.
이번 파일의 범위 밖
rollback 구현과 transactionId 전달, 실제 저장 로직을 이 인터페이스에 넣지 않는다.

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

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

public interface TransferFailureHook {
    TransferFailureHook NONE = () -> {};
    void afterBusinessMutation();
}

9. TransferService · 송금 전체를 한 트랜잭션에 묶는 금고 책임자

한 문장 역할: Command를 검증하고 두 계좌를 번호순 잠금 조회한 뒤 엔티티 잔액, 업무 거래, 원장 두 행을 한 트랜잭션 안에서 바꾸고 Result를 돌려준다.

정확한 저장 경로: day-5/solution/src/main/java/com/example/financialcore/transfer/TransferService.java
원문 SHA-256: e44994db58afe3ba53008de7dda7116881de1caa81553505af13571ad3273ec6

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 TransferController와 통합 테스트
무엇을 받나 TransferService.Command(actorId, transactionId, fromAccountId, toAccountId, amount)
무엇이 바뀌나 관리 상태 Account 두 객체의 잔액, business_tx의 완료 TRANSFER 한 행, ledger_entry의 TRANSFER_OUT/IN 두 행을 바꾼다. 정상 반환 시 commit되고 기본 unchecked 예외 시 rollback된다.
무엇을 돌려주나 Result(businessTransactionId, fromBalance, toBalance)를 돌려준다. 검증/조회/업무 실패에서는 정상 반환 대신 예외가 밖으로 나간다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;

@Service
public class TransferService {
    public record Command(String actorId, String transactionId, long fromAccountId, long toAccountId, long amount) {}
    public record Result(String businessTransactionId, long fromBalance, long toBalance) {}

    private final AccountRepository accounts;
    private final BusinessTransactionRepository transactions;
    private final LedgerEntryRepository ledger;
    private final TransferFailureHook failureHook;

    public TransferService(
        AccountRepository accounts, BusinessTransactionRepository transactions,
        LedgerEntryRepository ledger, ObjectProvider<TransferFailureHook> hooks
    ) {
        this.accounts = accounts;
        this.transactions = transactions;
        this.ledger = ledger;
        this.failureHook = hooks.getIfAvailable(() -> TransferFailureHook.NONE);
    }

    @Transactional
    public Result transfer(Command command) {
        validate(command);
        var locked = accounts.findAllForUpdateOrderById(
            List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList());
        if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        var byId = new HashMap<Long, Account>();
        locked.forEach(account -> byId.put(account.getId(), account));
        Account from = byId.get(command.fromAccountId());
        Account to = byId.get(command.toAccountId());
        if (!from.getOwnerId().equals(command.actorId())) throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
        from.withdraw(command.amount());
        to.deposit(command.amount());
        Instant now = Instant.now();
        var tx = transactions.save(BusinessTransaction.completedTransfer(command.transactionId(), now));
        ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
        ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
        failureHook.afterBusinessMutation();
        return new Result(tx.getId().toString(), from.getBalance(), to.getBalance());
    }

    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
        if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
        if (command.amount() <= 0) throw new IllegalArgumentException("amount");
    }
}
기술 시각 마커 11 · 두 Account 변경과 완료 거래 한 건, 부호가 반대인 원장 두 행이 한 트랜잭션 봉투에 들어간다.
기술 시각 마커 11 · 두 Account 변경과 완료 거래 한 건, 부호가 반대인 원장 두 행이 한 트랜잭션 봉투에 들어간다.

코드 블록 1 · 외부 Command, 외부 Result, 네 Repository와 hook

해설 바로 옆 코드 · 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.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;

@Service
public class TransferService {
    public record Command(String actorId, String transactionId, long fromAccountId, long toAccountId, long amount) {}
    public record Result(String businessTransactionId, long fromBalance, long toBalance) {}

    private final AccountRepository accounts;
    private final BusinessTransactionRepository transactions;
    private final LedgerEntryRepository ledger;
    private final TransferFailureHook failureHook;
문법을 한 줄씩 풀면
@Service는 Spring bean 후보를 표시한다. public record Command는 입력 다섯 값, Result는 생성된 업무 거래 UUID 문자열과 두 잔액을 묶는다. 네 final 필드는 Account/BusinessTransaction/LedgerEntry Repository와 실패 hook 참조다.
실제 값 추적
Controller는 request+Principal로 Command를 만들고, 서비스는 Result의 businessTransactionId를 HTTP 응답에 돌려준다. transactionId와 businessTransactionId는 서로 다른 값이다.
정상 예
Command 하나로 매개변수 순서 실수를 줄이고, Repository 역할을 분리해 엔티티 저장 계약을 사용한다.
반례·경계 예
new Command에서 from/to ID 순서를 바꾸면 반대 송금이 된다. Result 첫 칸에 command.transactionId를 넣으면 서버 UUID 계약과 달라진다.
착각 방지
record는 불변 운반 상자이지 명령을 실행하는 함수가 아니다. Repository 필드는 데이터를 다루는 협력자이고 아직 어떤 쿼리도 실행하지 않는다.
이 블록이 하지 않는 일
검증, 잠금, 잔액 변경이나 저장을 아직 수행하지 않는다.
다음 코드와의 연결
생성자가 네 협력자를 받아 필드에 보관하고 선택적 hook의 기본값을 정한다.

코드 블록 2 · ObjectProvider로 선택적 hook 기본값 정하기

해설 바로 옆 코드 · java
    public TransferService(
        AccountRepository accounts, BusinessTransactionRepository transactions,
        LedgerEntryRepository ledger, ObjectProvider<TransferFailureHook> hooks
    ) {
        this.accounts = accounts;
        this.transactions = transactions;
        this.ledger = ledger;
        this.failureHook = hooks.getIfAvailable(() -> TransferFailureHook.NONE);
    }
문법을 한 줄씩 풀면
생성자는 세 Repository와 ObjectProvider<TransferFailureHook>을 받는다. hooks.getIfAvailable(() -> TransferFailureHook.NONE)는 사용 가능한 hook bean이 있으면 그것을, 없으면 Supplier가 준 NONE을 선택한다. this.field는 객체 필드다.
실제 값 추적
일반 컨텍스트에 hook bean이 없으면 failureHook=NONE, 실패 시험이 bean을 제공하면 그 예외 람다가 들어간다.
정상 예
서비스 본문은 null 검사를 반복하지 않고 항상 failureHook.afterBusinessMutation()을 호출할 수 있다.
반례·경계 예
hook bean이 여러 개라 하나를 고를 수 없으면 단순 '없음'과 달라 후보 모호성 오류가 날 수 있다. getIfAvailable이 모든 주입 문제를 숨기지 않는다.
착각 방지
() -> TransferFailureHook.NONE은 지금 NONE 메서드를 실행하는 hook이 아니라, 필요할 때 기본 객체를 돌려주는 Supplier다.
이 블록이 하지 않는 일
Repository 객체를 새로 만들거나 트랜잭션을 시작하지 않는다.
다음 코드와의 연결
프록시가 아래 public transfer(Command)를 호출할 때 실제 업무가 시작된다.

코드 블록 3 · 검증 뒤 두 계좌를 항상 ID 오름차순으로 잠금

해설 바로 옆 코드 · java
    @Transactional
    public Result transfer(Command command) {
        validate(command);
        var locked = accounts.findAllForUpdateOrderById(
            List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList());
        if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        var byId = new HashMap<Long, Account>();
        locked.forEach(account -> byId.put(account.getId(), account));
        Account from = byId.get(command.fromAccountId());
        Account to = byId.get(command.toAccountId());
문법을 한 줄씩 풀면
@Transactional은 Spring 프록시가 public 메서드 앞뒤에서 트랜잭션을 관리하게 한다. List.of(...).stream().sorted().toList()로 두 ID를 정렬해 Repository의 pessimistic write 조회에 넘긴다. size가 2가 아니면 BusinessException(ACCOUNT_NOT_FOUND)이다. HashMap으로 실제 from/to 역할을 복원한다.
실제 값 추적
2→1 송금이어도 조회 ID 목록은 [1,2]다. 반환 Account 두 개를 id→Account map에 넣고 command.fromAccountId로 출발, toAccountId로 도착 객체를 다시 찾는다.
정상 예
반대 방향 동시 요청도 같은 ID 순서로 행 잠금을 잡아 서로 반대 순서로 자원을 쥐는 교착 위험을 줄인다.
반례·경계 예
입력 순서대로 잠그는 두 요청이 1→2와 2→1이면 각각 다른 첫 잠금을 쥘 수 있다. 같은 ID 두 개는 validate가 먼저 거부해 size 논리를 흐리지 않는다.
착각 방지
정렬 잠금은 교착 위험을 줄이지만 모든 동시성 문제를 없애는 마법은 아니다. Repository 쿼리의 @Lock, DB 격리 수준, 다른 코드의 잠금 순서가 함께 맞아야 한다.
이 블록이 하지 않는 일
아직 from.withdraw/to.deposit이나 거래 저장은 하지 않는다. size=2는 두 존재 행을 얻었다는 검사다.
다음 코드와의 연결
소유권을 확인한 뒤 Account 도메인 메서드로 잔액을 변경한다.

코드 블록 4 · 소유권·도메인 잔액 변경·세 Repository 저장·hook

해설 바로 옆 코드 · java
        if (!from.getOwnerId().equals(command.actorId())) throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
        from.withdraw(command.amount());
        to.deposit(command.amount());
        Instant now = Instant.now();
        var tx = transactions.save(BusinessTransaction.completedTransfer(command.transactionId(), now));
        ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
        ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
        failureHook.afterBusinessMutation();
        return new Result(tx.getId().toString(), from.getBalance(), to.getBalance());
    }
문법을 한 줄씩 풀면
from owner와 actorId가 다르면 ACCESS_DENIED BusinessException이다. Account.withdraw는 활성/양수/잔액을, deposit은 활성/양수/덧셈 overflow를 도메인에서 지킨다. 같은 Instant now로 완료 거래와 원장 두 행을 만들고 Repository.save한다. 관리 Account 변경은 JPA dirty checking으로 commit 때 반영된다.
실제 값 추적
customer-1, 10000→5000 계좌에 1000이면 from 9000, to 6000. completedTransfer는 새 UUID와 TRANSFER/COMPLETED 시각을 만들고 transferOut은 signed -1000, transferIn은 +1000, balanceAfter는 변경 뒤 잔액을 담는다.
정상 예
모든 줄과 hook이 정상 끝나면 Result는 tx UUID 문자열, 9000, 6000을 돌려주고 프록시가 commit한다.
반례·경계 예
from.withdraw에서 잔액 부족이면 이후 deposit/save는 실행되지 않는다. hook이 RuntimeException을 던지면 이미 변경된 관리 엔티티와 save 호출도 commit 전이므로 rollback 대상이다.
착각 방지
accounts.save가 없다고 잔액이 저장 안 되는 것이 아니다. 잠금 조회로 얻은 관리 엔티티를 트랜잭션 안에서 바꿔 dirty checking한다. 반대로 트랜잭션 밖 detached 객체라면 같은 가정이 안 맞을 수 있다.
이 블록이 하지 않는 일
HTTP 201/오류 JSON, 멱등 replay 성공 응답, 외부 메시지 원상복구는 이 코드가 직접 다루지 않는다.
다음 코드와의 연결
FailurePointIT가 hook 도달과 DB 전부 rollback을, IntegrationTest가 정상 행 수/합계를 확인한다.

코드 블록 5 · DB를 건드리기 전 Command 기본 규칙

해설 바로 옆 코드 · java
    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        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");
    }
}
문법을 한 줄씩 풀면
private static validate는 서비스 객체 상태를 쓰지 않고 Command 값만 본다. actorId/transactionId blank, 계좌 ID 0 이하, 같은 계좌, amount 0 이하 순으로 IllegalArgumentException을 던진다.
실제 값 추적
actorId=공백이면 첫 줄에서 종료한다. from=to이고 amount=-1이면 코드 순서상 same account가 amount보다 먼저 던져진다.
정상 예
HTTP가 아닌 다른 호출자가 직접 서비스를 불러도 최소 입력·관계 규칙을 Repository 접근 전에 지킨다.
반례·경계 예
command 자체가 null이면 command.actorId()에서 NullPointerException이다. 거래 ID 길이/중복, 계좌 존재, owner, 잔액은 이 작은 값 검사만으로 해결하지 않는다.
착각 방지
모든 나쁜 입력이 같은 예외 메시지를 갖는 것이 아니다. 외부 오류 계약으로 바꿀 전역 정책이 따로 필요하다.
이 블록이 하지 않는 일
계좌 존재·소유권·잔액은 Repository/Account 단계에서 확인한다.
다음 코드와의 연결
같은 계좌 통합 시험은 이 검사가 어떤 TRANSFER 쓰기보다 먼저 끝나는지 확인한다.

직접 다시 써보기

저장 경로
day-5/solution/src/main/java/com/example/financialcore/transfer/TransferService.java
전제조건
JPA Account/BusinessTransaction/LedgerEntry와 Repository 세 개, 트랜잭션/AOP, 선택적 TransferFailureHook을 준비한다.
반드시 지킬 계약
Command/Result 필드, ObjectProvider NONE 기본값, public @Transactional, ID 정렬 잠금, size/owner 검사, withdraw/deposit, 동일 now, 거래·원장 저장 순서, hook 위치와 Result를 유지한다.
추천 입력 순서
imports/type/records → final 협력자/생성자 → transfer의 validate·lock·map·owner·mutation·save·hook·Result → validate 순으로 쓴다.
자기 점검
프록시 구조, 정상 DB 결과, 중간 RuntimeException rollback, 같은 계좌 0-write 테스트를 모두 통과시키고 업무 거래 UUID와 요청 ID가 다른지 확인한다.
이번 파일의 범위 밖
HTTP 응답과 멱등 replay, checked 예외 정책, DB 밖 부작용을 구현됐다고 가정하지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · 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.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;

@Service
public class TransferService {
    public record Command(String actorId, String transactionId, long fromAccountId, long toAccountId, long amount) {}
    public record Result(String businessTransactionId, long fromBalance, long toBalance) {}

    private final AccountRepository accounts;
    private final BusinessTransactionRepository transactions;
    private final LedgerEntryRepository ledger;
    private final TransferFailureHook failureHook;

    public TransferService(
        AccountRepository accounts, BusinessTransactionRepository transactions,
        LedgerEntryRepository ledger, ObjectProvider<TransferFailureHook> hooks
    ) {
        this.accounts = accounts;
        this.transactions = transactions;
        this.ledger = ledger;
        this.failureHook = hooks.getIfAvailable(() -> TransferFailureHook.NONE);
    }

    @Transactional
    public Result transfer(Command command) {
        validate(command);
        var locked = accounts.findAllForUpdateOrderById(
            List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList());
        if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        var byId = new HashMap<Long, Account>();
        locked.forEach(account -> byId.put(account.getId(), account));
        Account from = byId.get(command.fromAccountId());
        Account to = byId.get(command.toAccountId());
        if (!from.getOwnerId().equals(command.actorId())) throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
        from.withdraw(command.amount());
        to.deposit(command.amount());
        Instant now = Instant.now();
        var tx = transactions.save(BusinessTransaction.completedTransfer(command.transactionId(), now));
        ledger.save(LedgerEntry.transferOut(tx, from, command.amount(), now));
        ledger.save(LedgerEntry.transferIn(tx, to, command.amount(), now));
        failureHook.afterBusinessMutation();
        return new Result(tx.getId().toString(), from.getBalance(), to.getBalance());
    }

    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        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");
    }
}

10. TransferFailurePointIT · 업무 변경 뒤 예외의 전부 rollback 시험

한 문장 역할: 두 계좌·거래·원장 변경 뒤 hook이 실제 호출되고 RuntimeException을 던질 때 네 관찰값이 원래 상태로 돌아가는지 확인한다.

정확한 저장 경로: day-5/scaffold/src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java
원문 SHA-256: b1e66ae8ae82ca562af73dc364e14a98ec8c0da16fbb8f49acc9734bc8ae79c5

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5가 실행하는 SpringBootTest와 @Import된 FailureConfiguration
무엇을 받나 JdbcClient·AccountOpeningService·실제 TransferService·공유 AtomicBoolean과 clean()의 두 Account
무엇이 바뀌나 호출 중 Account/BusinessTransaction/LedgerEntry 변경을 시도하고 AtomicBoolean은 true로 남긴다. DB 변경은 rollback되어야 한다.
무엇을 돌려주나 테스트/helper/config bean 메서드는 void/long/bean 객체를 각각 돌려주며, 송금 호출은 정상 Result 대신 RuntimeException을 던진다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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 java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

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 AtomicBoolean invoked;

    Account from;
    Account to;

    @BeforeEach void clean() {
        invoked.set(false);
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "FAIL-FROM", 10_000);
        to = openings.open("customer-2", "FAIL-TO", 5_000);
    }

    @Test
    void runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-FAIL", from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessage("injected after business mutation");
        assertThat(invoked).isTrue();
        long transferTransactions = jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single();
        long transferEntries = jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single();
        assertThat(List.of(
            balance(from.getId()), balance(to.getId()), transferTransactions, transferEntries))
            .as("W7D5_RED_EXPECTED_FULL_ROLLBACK")
            .containsExactly(10_000L, 5_000L, 0L, 0L);
    }

    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
        @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
            return () -> {
                invoked.set(true);
                throw new RuntimeException("injected after business mutation");
            };
        }
    }
}
기술 시각 마커 12 · AtomicBoolean 불빛은 켜지지만 DB의 두 잔액과 거래·원장은 전부 이전 모습이어야 한다.
기술 시각 마커 12 · AtomicBoolean 불빛은 켜지지만 DB의 두 잔액과 거래·원장은 전부 이전 모습이어야 한다.

코드 블록 1 · 실제 서비스에 테스트 hook과 flag만 추가

해설 바로 옆 코드 · 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 java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

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 AtomicBoolean invoked;

    Account from;
    Account to;
문법을 한 줄씩 풀면
@Import(TransferFailurePointIT.FailureConfiguration.class)는 중첩 TestConfiguration의 AtomicBoolean/hook bean을 컨텍스트에 더한다. Spring은 같은 AtomicBoolean을 테스트와 hook에 주입한다. 나머지 service/repository/DB는 실제 구성이다.
실제 값 추적
TransferService의 ObjectProvider가 NONE 대신 FailureConfiguration의 hook을 선택하고, invoked 필드는 그 hook이 호출됐는지 관찰한다.
정상 예
서비스를 mock으로 바꾸지 않고 정확한 실패 지점 하나만 시험용으로 바꿔 실제 rollback 범위를 볼 수 있다.
반례·경계 예
테스트 hook이 다른 컨텍스트로 새면 정상 시험도 실패할 수 있다. @TestConfiguration과 명시적 @Import로 범위를 제한한다.
착각 방지
AtomicBoolean은 트랜잭션 DB 값이 아니라 JVM 메모리 값이라 DB rollback되어도 true로 남는다. 그래서 hook 도달 표식으로 쓸 수 있다.
이 블록이 하지 않는 일
이 블록은 아직 flag reset/DB 청소/송금을 실행하지 않는다.
다음 코드와의 연결
clean이 flag와 DB를 기준 상태로 만든다.

코드 블록 2 · flag reset, TRUNCATE, 기준 계좌 두 개

해설 바로 옆 코드 · java
    @BeforeEach void clean() {
        invoked.set(false);
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "FAIL-FROM", 10_000);
        to = openings.open("customer-2", "FAIL-TO", 5_000);
    }
문법을 한 줄씩 풀면
BeforeEach clean은 먼저 invoked=false로 되돌리고 네 표를 비운 뒤 FAIL-FROM 10000, FAIL-TO 5000 계좌를 연다.
실제 값 추적
매 테스트가 false flag와 동일 잔액, 빈 TRANSFER 기록에서 시작한다.
정상 예
이전 테스트의 true flag나 저장 흔적이 다음 실행에 섞이지 않는다.
반례·경계 예
invoked.set(false)를 빼면 hook이 이번 호출에서 안 불려도 이전 true 때문에 통과할 수 있다.
착각 방지
초기 기준 잔액은 코드에 10000/5000으로 명시되어 아래 containsExactly와 직접 연결된다.
이 블록이 하지 않는 일
아직 실패 송금을 실행하지 않는다.
다음 코드와의 연결
테스트 본문이 Command를 보내고 예외·flag·DB 네 값을 확인한다.

코드 블록 3 · 의도한 예외, hook 도달, 네 rollback 관찰값

해설 바로 옆 코드 · java
    @Test
    void runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-FAIL", from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessage("injected after business mutation");
        assertThat(invoked).isTrue();
        long transferTransactions = jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single();
        long transferEntries = jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single();
        assertThat(List.of(
            balance(from.getId()), balance(to.getId()), transferTransactions, transferEntries))
            .as("W7D5_RED_EXPECTED_FULL_ROLLBACK")
            .containsExactly(10_000L, 5_000L, 0L, 0L);
    }
문법을 한 줄씩 풀면
assertThatThrownBy 람다는 transfer가 던진 예외 타입/메시지를 확인한다. invoked true를 별도 확인하고, JdbcClient로 TRANSFER 거래/원장 count를 센다. List.of 네 long을 containsExactly로 순서까지 대조한다.
실제 값 추적
W7-FAIL 1000 송금은 객체 잔액 변경, 거래 save, 원장 두 save 뒤 hook에서 메시지 'injected after business mutation' RuntimeException을 던진다. 사후 목록은 [10000,5000,0,0]이어야 한다.
정상 예
hook에 도착했으면서도 두 DB 잔액과 TRANSFER 거래/원장 어느 것도 확정되지 않았다는 강한 전부 rollback 증거다.
반례·경계 예
@Transactional을 빼거나 예외를 catch해 정상 반환하면 일부/전부 변경이 남아 네 값 단언이 실패한다. invoked만 보면 rollback 여부는 알 수 없다.
착각 방지
containsExactly의 순서는 fromBalance, toBalance, transaction count, entry count다. 숫자 네 개만 같아도 위치가 다르면 실패한다.
이 블록이 하지 않는 일
checked exception 기본 정책, 다른 실패 시점, 외부 메시지/파일 효과는 보장하지 않는다.
다음 코드와의 연결
아래 helper와 FailureConfiguration이 관찰 SQL과 예외 발생기를 구체화한다.

코드 블록 4 · named parameter 잔액 관찰 helper

해설 바로 옆 코드 · java
    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }
문법을 한 줄씩 풀면
balance는 account id를 :id에 바인딩하고 단일 balance 셀을 long으로 돌려준다.
실제 값 추적
실패 뒤 from/to ID로 호출해 각각 10000/5000인지 읽는다.
정상 예
JPA 1차 캐시의 객체 값이 아니라 SQL로 DB 관찰값을 얻는다.
반례·경계 예
행이 없거나 여러 행이면 single()이 실패한다. null을 기본 0으로 바꾸지 않는다.
착각 방지
같은 이름의 Account.getBalance와 출처가 다르다.
이 블록이 하지 않는 일
값을 변경하거나 잠그지 않는다.
다음 코드와의 연결
두 조회값이 List 단언의 첫째·둘째 자리에 들어간다.

코드 블록 5 · 같은 AtomicBoolean을 닫아 두고 예외 던지는 bean

해설 바로 옆 코드 · java
    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
        @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
            return () -> {
                invoked.set(true);
                throw new RuntimeException("injected after business mutation");
            };
        }
    }
}
문법을 한 줄씩 풀면
@TestConfiguration(proxyBeanMethods=false)는 테스트 bean 모음이다. invoked()가 AtomicBoolean bean을, failureHook(invoked)가 그 같은 참조를 캡처한 람다를 만든다. 람다는 set(true) 뒤 RuntimeException을 던진다.
실제 값 추적
서비스가 afterBusinessMutation()을 호출하면 flag가 true가 되고 정확한 메시지의 RuntimeException이 프록시 밖으로 전파된다.
정상 예
예외 전에 flag를 켜므로 테스트는 목표 hook 본문이 실제 실행됐음을 구분한다.
반례·경계 예
순서를 throw→set으로 쓸 수는 없다. throw 뒤 문장은 실행되지 않아 flag가 false로 남는다.
착각 방지
proxyBeanMethods=false는 이 설정 클래스의 @Bean 메서드 간 프록시 호출이 필요 없다는 최적화 성격이며, 서비스 트랜잭션 프록시를 끄는 옵션이 아니다.
이 블록이 하지 않는 일
rollback을 직접 호출하거나 예외를 catch하지 않는다.
다음 코드와의 연결
밖으로 나온 RuntimeException을 @Transactional interceptor가 보고 DB 트랜잭션을 rollback한다.

JUnit 한 메서드 해부 · runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect

단계 이 테스트에서 실제로 하는 일
준비(Arrange) flag=false, 빈 TRANSFER 기록, 10000/5000 계좌와 업무 변경 뒤 예외 hook을 준비한다.
행동(Act) W7-FAIL Command로 1000 송금을 호출한다.
확인(Assert) 정확한 RuntimeException/메시지, invoked=true, DB 목록 [10000,5000,0,0]을 확인한다.
직접 보장하는 것
hook 도달 뒤 unchecked 예외가 Account 잔액·TRANSFER business_tx·TRANSFER ledger_entry를 전부 rollback함을 보장한다.
보장하지 않는 것
checked exception, 다른 실패 위치, DB 밖 효과, 동시 요청은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: 예외 전후 Account version이나 transaction correlation_id별 count도 조회해 목표 요청의 흔적이 0인지 확인한다.

직접 다시 써보기

저장 경로
day-5/scaffold/src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java
전제조건
격리 PostgreSQL, 실제 service/repository, @Import할 FailureConfiguration, 공유 AtomicBoolean을 준비한다.
반드시 지킬 계약
flag false 초기화, 네 표 TRUNCATE, 기준 10000/5000, W7-FAIL Command, 예외 타입/정확 문구, invoked true와 [10000,5000,0,0]을 유지한다.
추천 입력 순서
imports/주입/필드 → clean → assertThatThrownBy와 사후 네 값 → balance helper → FailureConfiguration 두 bean 순으로 쓴다.
자기 점검
단독/전체 실행에서 hook 설정 격리를 확인하고 예외 뒤 DB 세 영역과 JVM flag를 각각 관찰한다.
이번 파일의 범위 밖
checked exception·다른 실패 지점·DB 밖 효과까지 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 java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

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 AtomicBoolean invoked;

    Account from;
    Account to;

    @BeforeEach void clean() {
        invoked.set(false);
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "FAIL-FROM", 10_000);
        to = openings.open("customer-2", "FAIL-TO", 5_000);
    }

    @Test
    void runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-FAIL", from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessage("injected after business mutation");
        assertThat(invoked).isTrue();
        long transferTransactions = jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single();
        long transferEntries = jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single();
        assertThat(List.of(
            balance(from.getId()), balance(to.getId()), transferTransactions, transferEntries))
            .as("W7D5_RED_EXPECTED_FULL_ROLLBACK")
            .containsExactly(10_000L, 5_000L, 0L, 0L);
    }

    private long balance(long id) {
        return jdbc.sql("SELECT balance FROM account WHERE id=:id")
            .param("id", id).query(Long.class).single();
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
        @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
            return () -> {
                invoked.set(true);
                throw new RuntimeException("injected after business mutation");
            };
        }
    }
}

11. TransferIntegrationTest · 정상 한 건과 같은 계좌 거부 최종 시험

한 문장 역할: 실제 PostgreSQL에서 정상 Command의 Result·TRANSFER 거래/원장 집계와 같은 계좌 Command의 예외·거래 0을 확인한다.

정확한 저장 경로: day-6/scaffold/src/test/java/com/example/financialcore/transfer/TransferIntegrationTest.java
원문 SHA-256: d1ab350aaf3aa1d250b8d0c29cc5b7a63e352ec720d6e53b8d57a6bb6980722b

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5가 실행하는 SpringBootTest
무엇을 받나 JdbcClient·AccountOpeningService·TransferService와 clean()이 만든 from/to Account
무엇이 바뀌나 정상 테스트는 Account 잔액·업무 거래·원장을 바꾸고, 같은 계좌 테스트는 어떤 TRANSFER business_tx도 만들지 않아야 한다.
무엇을 돌려주나 두 @Test는 void다. 정상 transfer는 Result를, 거부 transfer는 정상 반환 대신 IllegalArgumentException을 낸다.

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferIntegrationTest 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", "IT-FROM", 10_000);
        to = openings.open("customer-2", "IT-TO", 5_000);
    }

    @Test
    void transferMovesTwoBalancesAndWritesBalancedLedgerPair() {
        var result = transfers.transfer(new TransferService.Command(
            "customer-1", "W7-IT", from.getId(), to.getId(), 1_000));
        assertThat(result.fromBalance()).isEqualTo(9_000);
        assertThat(result.toBalance()).isEqualTo(6_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 SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    @Test
    void sameAccountIsRejectedBeforeAnyTransferWrite() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-SAME", from.getId(), from.getId(), 100)))
            .isInstanceOf(IllegalArgumentException.class).hasMessage("same account");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
    }
}
기술 시각 마커 13 · 정상 경로는 원장 두 행과 합계 0을, 거부 경로는 최소 거래 0을 직접 관찰한다.
기술 시각 마커 13 · 정상 경로는 원장 두 행과 합계 0을, 거부 경로는 최소 거래 0을 직접 관찰한다.

코드 블록 1 · 실제 서비스/DB와 매 시험 fixture

해설 바로 옆 코드 · 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferIntegrationTest 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", "IT-FROM", 10_000);
        to = openings.open("customer-2", "IT-TO", 5_000);
    }
문법을 한 줄씩 풀면
@SpringBootTest와 PostgresIntegrationTestSupport가 통합 환경을 만들고, @Autowired로 세 도구를 주입한다. BeforeEach는 네 표를 비운 뒤 IT-FROM 10000, IT-TO 5000 Account를 연다.
실제 값 추적
정상/거부 두 테스트는 각각 새 DB 기준선과 동적 Account ID를 가진다.
정상 예
실행 순서에 의존하지 않고 서비스부터 PostgreSQL 저장까지 대표 두 경로를 볼 수 있다.
반례·경계 예
서비스를 직접 호출하므로 HTTP/Security filter를 지나지 않는다. 클래스 이름 Integration만 보고 브라우저 끝단이라고 생각하면 안 된다.
착각 방지
계좌 개설이 OPENING 기록을 만들 수 있어 아래 집계는 TRANSFER만 필터링한다.
이 블록이 하지 않는 일
이 블록은 송금을 아직 실행하지 않는다.
다음 코드와의 연결
첫 테스트가 정상 Result와 세 TRANSFER 집계를 확인한다.

코드 블록 2 · 정상 Result와 거래 1·원장 2·합계 0

해설 바로 옆 코드 · java
    @Test
    void transferMovesTwoBalancesAndWritesBalancedLedgerPair() {
        var result = transfers.transfer(new TransferService.Command(
            "customer-1", "W7-IT", from.getId(), to.getId(), 1_000));
        assertThat(result.fromBalance()).isEqualTo(9_000);
        assertThat(result.toBalance()).isEqualTo(6_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 SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }
문법을 한 줄씩 풀면
Command를 넘겨 Result를 받고 두 accessor를 단언한다. JdbcClient 집계는 모든 TRANSFER business_tx count, 모든 TRANSFER_% ledger_entry count와 SUM(signed_amount)를 읽는다. clean 덕분에 이 테스트의 송금 한 건만 있다.
실제 값 추적
W7-IT 1000 송금 결과는 Result from=9000,to=6000, TRANSFER 거래 1, 원장 2, signed 합 -1000+1000=0이다.
정상 예
앞의 BalanceIT보다 원장 count=2를 직접 추가해 한 쌍 조건을 더 강하게 잡는다.
반례·경계 예
두 원장 행이 -500/+500이어도 count=2와 sum=0은 통과한다. Result 잔액은 객체 결과이며 이 본문은 account 표를 fresh query하지 않는다.
착각 방지
business_tx/ledger SQL이 transactionId별로 필터링하지 않지만 clean fixture가 전체 TRANSFER 범위를 이 한 요청으로 제한한다.
이 블록이 하지 않는 일
HTTP 201, 인증, JSON, 실제 account 표 잔액 조회, 동시성은 직접 보장하지 않는다.
다음 코드와의 연결
다음 테스트는 값 모양은 양수지만 from=to인 관계 오류를 서비스에서 막는다.

코드 블록 3 · 같은 계좌는 TRANSFER 거래 쓰기 전에 거부

해설 바로 옆 코드 · java
    @Test
    void sameAccountIsRejectedBeforeAnyTransferWrite() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-SAME", from.getId(), from.getId(), 100)))
            .isInstanceOf(IllegalArgumentException.class).hasMessage("same account");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
    }
}
문법을 한 줄씩 풀면
assertThatThrownBy는 from ID를 출발과 도착에 모두 넣은 Command가 IllegalArgumentException과 정확한 메시지 same account를 내는지 본다. 이어 TRANSFER business_tx count가 0인지 확인한다.
실제 값 추적
customer-1/W7-SAME/fromId/fromId/100은 모두 양수지만 validate의 관계 조건에서 Repository 잠금 전에 멈춘다.
정상 예
같은 계좌 요청이 서비스 입구에서 거부되고 TRANSFER 업무 거래 행을 쓰지 않음을 보장한다.
반례·경계 예
ledger_entry 0, account 잔액 불변, hook 미호출을 직접 단언하지 않는다. 극단적 버그가 다른 표만 건드리면 이 count 하나는 놓칠 수 있다.
착각 방지
DTO @Positive만으로는 이 관계 오류를 잡지 못한다. 서비스 validate의 책임이라는 것을 보여준다.
이 블록이 하지 않는 일
HTTP 오류 status/JSON과 다른 나쁜 Command는 확인하지 않는다.
다음 코드와의 연결
TransferRequest 규칙에서 시작한 '각 칸'과 '칸 사이 관계' 구분이 여기서 완성된다.

JUnit 한 메서드 해부 · transferMovesTwoBalancesAndWritesBalancedLedgerPair

단계 이 테스트에서 실제로 하는 일
준비(Arrange) 네 표를 비우고 10000/5000 두 계좌와 실제 service/jdbc를 준비한다.
행동(Act) W7-IT Command로 customer-1이 1000을 송금한다.
확인(Assert) Result 9000/6000, TRANSFER business_tx 1, ledger_entry 2, signed SUM 0을 확인한다.
직접 보장하는 것
정상 서비스 호출의 계산 Result와 송금 거래 한 건·원장 정확히 두 행·합계 균형을 보장한다.
보장하지 않는 것
account 표 fresh 잔액, 개별 원장 -1000/+1000, HTTP 계약은 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: account balance 두 셀과 entry_type/signed_amount 두 행을 조회해 OUT=-1000, IN=+1000인지 확인한다.

JUnit 한 메서드 해부 · sameAccountIsRejectedBeforeAnyTransferWrite

단계 이 테스트에서 실제로 하는 일
준비(Arrange) 초기화 DB와 from ID를 양쪽에 넣은 W7-SAME Command를 준비한다.
행동(Act) service.transfer를 호출한다.
확인(Assert) IllegalArgumentException, 정확한 same account 메시지, TRANSFER business_tx count 0을 확인한다.
직접 보장하는 것
같은 계좌 관계가 서비스에서 거부되고 송금 업무 거래 행이 생기지 않음을 보장한다.
보장하지 않는 것
ledger 0, account 잔액 불변, HTTP 오류 응답, 다른 입력 오류는 보장하지 않는다.
강화 예시 · 제공 원문 아님
제공 원문에는 없는 강화 예: 호출 전후 balance 두 값과 TRANSFER ledger count, hook flag를 함께 확인해 모든 업무 변경 전 거부를 좁혀 증명한다.

직접 다시 써보기

저장 경로
day-6/scaffold/src/test/java/com/example/financialcore/transfer/TransferIntegrationTest.java
전제조건
격리 PostgreSQL, 실제 AccountOpeningService/TransferService/JdbcClient와 네 표를 준비한다.
반드시 지킬 계약
정상 W7-IT의 Result 9000/6000·TRANSFER 거래 1·원장 2·SUM 0, W7-SAME의 정확한 예외/메시지·TRANSFER 거래 0을 유지한다.
추천 입력 순서
주입/Account 필드 → clean → 정상 Arrange/Act/Assert → 같은 계좌 Arrange/Act/Assert 순으로 쓴다.
자기 점검
두 테스트 단독/전체 실행 후 강화 시 actual account DB 잔액, 개별 signed_amount, 거부 경로 ledger/잔액 불변을 확인한다.
이번 파일의 범위 밖
HTTP·Security·JSON과 중간 hook 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.jdbc.core.simple.JdbcClient;

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

@SpringBootTest
class TransferIntegrationTest 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", "IT-FROM", 10_000);
        to = openings.open("customer-2", "IT-TO", 5_000);
    }

    @Test
    void transferMovesTwoBalancesAndWritesBalancedLedgerPair() {
        var result = transfers.transfer(new TransferService.Command(
            "customer-1", "W7-IT", from.getId(), to.getId(), 1_000));
        assertThat(result.fromBalance()).isEqualTo(9_000);
        assertThat(result.toBalance()).isEqualTo(6_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 SUM(signed_amount) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
            .query(Long.class).single()).isZero();
    }

    @Test
    void sameAccountIsRejectedBeforeAnyTransferWrite() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-SAME", from.getId(), from.getId(), 100)))
            .isInstanceOf(IllegalArgumentException.class).hasMessage("same account");
        assertThat(jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
            .query(Long.class).single()).isZero();
    }
}

SQL 영수증 조회 · 제공 canonical 답안과 구분해서 읽기

아래 두 문제는 PDF와 workbook에 문제와 seed는 있지만 학습자가 대조할 별도 canonical answer 파일은 없다. 따라서 다음 SQL은 원문 답안이라고 주장하지 않고, 문제 계약에 맞춘 예시 정답으로 제공한다.

기술 시각 마커 14 · 완료 시간이 비었다는 조건과 실패/불명 상태라는 조건은 겹칠 수 있지만 같은 질문은 아니다.
기술 시각 마커 14 · 완료 시간이 비었다는 조건과 실패/불명 상태라는 조건은 겹칠 수 있지만 같은 질문은 아니다.

Q05 · 완료 시간이 아직 없는 거래 찾기

문제 계약: business_tx에서 completed_at이 NULL인 거래를 찾는다.

전체 예시 정답 · canonical learner answer 없음

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: business_tx 한 행 = 업무 거래 한 건
-- 출력 grain: completed_at이 없는 거래 한 건당 한 행
SELECT tx_id, status, completed_at
FROM business_tx
WHERE completed_at IS NULL
ORDER BY occurred_at, tx_id;
문법
SELECT는 보여 줄 열, FROM은 읽을 표, WHERE는 남길 행, ORDER BY는 결과 순서를 정한다.
실제 값 추적
제공 seed 기준으로 completed_at이 비어 있는 UUID 끝자리는 203, 204, 205, 206, 207, 213의 6행이다. FAILED/UNKNOWN뿐 아니라 아직 PROCESSING인 끝자리 213도 포함된다.
착각 방지
NULL은 빈 문자열이나 0이 아니다. completed_at = NULL은 참이 되지 않으므로 IS NULL을 써야 한다.
반례
status IN ('FAILED','UNKNOWN')로 대신하면 PROCESSING이지만 미완료인 213을 놓쳐 질문이 달라진다.
하지 않는 일
조회만 하므로 원본 행을 수정하거나 상태를 완료로 바꾸지 않는다.
다음 연결
이 조회는 거래 상태를 관찰하는 영수증이며 Java 송금 서비스의 트랜잭션 쓰기와 역할이 다르다.

직접 다시 써보기

저장 경로
sql/workbook/answers/q05_example.sql *(학습용으로 새로 만들 때의 권장 경로이며 제공 canonical 파일은 아님)*
전제조건
workbook의 business_tx(tx_id, occurred_at, status, completed_at)와 seed를 준비한다.
반드시 지킬 계약
WHERE completed_at IS NULL과 결정적인 ORDER BY occurred_at, tx_id를 유지한다.
추천 입력 순서
출력 grain 주석 → SELECT 열 → FROM → WHERE → ORDER BY 순서로 입력한다.
자기 점검
결과 grain이 거래 한 건당 한 행인지, seed에서 6행인지, completed_at이 모두 NULL인지 확인한다.
이번 문제의 범위 밖
실패 상태만 찾거나 NULL을 임의 날짜로 채우지 않는다.

전체 예시 정답 · 들여쓰기까지 대조

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: business_tx 한 행 = 업무 거래 한 건
-- 출력 grain: completed_at이 없는 거래 한 건당 한 행
SELECT tx_id, status, completed_at
FROM business_tx
WHERE completed_at IS NULL
ORDER BY occurred_at, tx_id;

Q06 · 실패했거나 결과를 모르는 거래 찾기

7주차 개념 그림 15

문제 계약: business_tx에서 status가 FAILED 또는 UNKNOWN인 거래를 찾는다.

전체 예시 정답 · canonical learner answer 없음

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: business_tx 한 행 = 업무 거래 한 건
-- 출력 grain: FAILED 또는 UNKNOWN 거래 한 건당 한 행
SELECT tx_id, status, failure_reason
FROM business_tx
WHERE status IN ('FAILED', 'UNKNOWN')
ORDER BY occurred_at, tx_id;
문법
SELECT는 보여 줄 열, FROM은 읽을 표, WHERE는 남길 행, ORDER BY는 결과 순서를 정한다.
실제 값 추적
제공 seed 기준 결과 UUID 끝자리는 203, 204, 205, 206, 207의 5행이다. PROCESSING인 끝자리 213은 완료 시간이 NULL이어도 상태 조건이 달라 제외된다.
착각 방지
IN ('FAILED','UNKNOWN')은 이 두 문자열 중 하나와 같은지를 묻는다. NULL status는 어느 값과도 같다고 판정되지 않아 포함되지 않는다.
반례
WHERE status = 'FAILED' AND status = 'UNKNOWN'은 한 행의 한 status가 동시에 두 문자열일 수 없어 결과가 없다. 여기서는 OR 관계다.
하지 않는 일
조회만 하므로 원본 행을 수정하거나 상태를 완료로 바꾸지 않는다.
다음 연결
이 조회는 거래 상태를 관찰하는 영수증이며 Java 송금 서비스의 트랜잭션 쓰기와 역할이 다르다.

직접 다시 써보기

저장 경로
sql/workbook/answers/q06_example.sql *(학습용으로 새로 만들 때의 권장 경로이며 제공 canonical 파일은 아님)*
전제조건
workbook의 business_tx(tx_id, occurred_at, status, failure_reason)와 seed를 준비한다.
반드시 지킬 계약
WHERE status IN ('FAILED', 'UNKNOWN')과 출력 세 열, 결정적인 정렬을 유지한다.
추천 입력 순서
출력 grain 주석 → SELECT 열 → FROM → WHERE → ORDER BY 순서로 입력한다.
자기 점검
seed에서 5행인지, 모든 status가 FAILED/UNKNOWN 중 하나인지, failure_reason이 함께 보이는지 확인한다.
이번 문제의 범위 밖
PROCESSING이나 completed_at NULL 전체를 섞지 않는다.

전체 예시 정답 · 들여쓰기까지 대조

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: business_tx 한 행 = 업무 거래 한 건
-- 출력 grain: FAILED 또는 UNKNOWN 거래 한 건당 한 행
SELECT tx_id, status, failure_reason
FROM business_tx
WHERE status IN ('FAILED', 'UNKNOWN')
ORDER BY occurred_at, tx_id;

일곱 테스트를 한 장으로 되짚기

테스트 메서드 시작점 → 관찰점 직접 보장하는 핵심 일부러 말하지 않는 것
blankTransactionAndNonPositiveIdsAndAmountAreRejected DTO → violation Set 나쁜 네 칸이 4개 위반 HTTP 연결, 같은 계좌
oneTransactionMovesBothBalancesAndWritesOneBalancedPair Service → Result/DB 두 잔액, 거래 1, 원장 합 0 정확한 원장 개별 금액
transferBeanIsProxiedAndPublicMethodOwnsTheBoundary Spring bean → reflection 프록시와 경계 어노테이션 실제 rollback
newTransferUses201AndCompletedResponse HTTP → response 201과 최소 본문 표지 Location/DB/전체 JSON
runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect 중간 실패 → DB 세 저장 영역 전부 rollback DB 밖 효과, checked 예외
transferMovesTwoBalancesAndWritesBalancedLedgerPair Service → Result/ledger Result와 원장 2행·합 0 실제 accounts 재조회
sameAccountIsRejectedBeforeAnyTransferWrite Service 거부 → DB 예외와 business_tx 0 ledger/accounts 불변 직접 단언
기술 시각 마커 15 · 테스트마다 비추는 손전등 범위가 다르므로 일곱 개의 증거를 합쳐 읽는다.
기술 시각 마커 15 · 테스트마다 비추는 손전등 범위가 다르므로 일곱 개의 증거를 합쳐 읽는다.

마지막 자기 설명 체크

  1. @Positive만으로 1→1 송금을 막을 수 없는지 설명할 수 있는가?
  2. @Valid와 서비스의 validate가 각각 어느 입구를 지키는지 설명할 수 있는가?
  3. accountId를 작은 번호부터 잠그는 이유와 그 규칙이 하지 못하는 일을 구분하는가?
  4. 프록시 구조 시험과 rollback 행동 시험이 서로 대체되지 않는 이유를 말할 수 있는가?
  5. 원장 sum(amount)=0만으로 정확한 한 쌍을 완전히 증명하지 못하는 반례를 만들 수 있는가?
  6. 실패 hook과 rollback 주체를 구분하는가?
  7. 각 JUnit 테스트에서 Arrange/Act/Assert를 코드 줄로 짚고, 보장하지 않는 것을 한 가지씩 말할 수 있는가?
  8. Q05의 IS NULL과 Q06의 IN이 왜 다른 결과를 내는지 tx 213으로 설명할 수 있는가?

여덟 질문에 자기 말로 답하고, 각 파일의 직접 다시 써보기 → 전체 코드 정답을 들여쓰기까지 대조하면 7주차의 결과 코드 전체를 한 바퀴 돈 것이다.