WEEK 07 · CODE AFTERPARTY
7주차 코드 뒤풀이 · STARRY 선불 공연카드 송금편
학습 범위: 1일차부터 6일차까지 · Java 원문 11개 · JUnit 테스트 7개 · SQL Q05/Q06
이 문서는 이야기를 먼저 읽은 사람이 “아까 카드 두 장과 복식 영수증으로 설명한 장면이 실제 코드에서는 바로 이것이구나” 하고 원문에 착지하도록 만든다. 의도적으로 실패시키던 학습용 중간 코드는 넣지 않았다. 월요일·화요일처럼 소설 흐름을 끊지 않고, 한 번의 송금이 들어와 검증되고 저장되고 시험되는 순서로 읽는다.
등장인물의 역할은 고정된 기술 번역표가 아니다. 장면에 맞춰 히토리는 처음 보는 사람이 할 법한 질문, 니지카는 순서 정리, 료는 경계와 반례, 키타는 눈에 보이는 결과를 맡는다.
먼저 잡는 전체 지도
JSON 신청서
→ TransferRequest의 칸 검사
→ TransferController가 인증 사용자 이름을 결합
→ 트랜잭션 프록시가 TransferService 공식 입구를 감쌈
→ 두 계좌를 번호순으로 잠금
→ 잔액 2곳 + business_tx 1건 + ledger 2줄 저장
→ TransferResponse와 HTTP 201
중간 RuntimeException
→ 위 저장 전부 rollback
원문 인벤토리와 시험 수
- 최종/지원 Java 파일: 11개
- 원문에 실제로 선언된
@Test: 7개 - SQL 연습: Q05, Q06 두 문제
- Q05/Q06은 제공 자료에 학습자용 canonical 답안 파일이 없으므로, 아래 SQL은 반드시 예시 정답이라고 표시한다.
- 각 Java 파일은 먼저 정확한 전체 원문을 읽고, 블록별 해설 뒤 직접 다시 쓸 전제 여섯 가지와 정확한 전체 코드를 바로 대조한다.
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가 별도로 돌려준다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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
) {}
코드 블록 1 · 정식 주소와 두 검사표 이름
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 네 칸과 칸별 최소 규칙
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에 억지로 넣지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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 단언 실패/성공이 테스트 결과가 된다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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);
}
}
}
코드 블록 1 · 검증·JUnit·AssertJ 도구와 테스트 그릇
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 안에서 네 위반 세기
@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와 같은 계좌 업무 규칙을 이 단위 시험에 끌어오지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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이 관찰값을 판정한다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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();
}
}
코드 블록 1 · PostgreSQL 통합 시험의 주입 부품
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 후 두 계좌를 실제로 개설
@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 네 가지 관찰
@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 매개변수로 한 잔액 읽기
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, 동시성 전체를 이 테스트 하나가 보장한다고 말하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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가 판정한다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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();
}
}
코드 블록 1 · 프록시·reflection·트랜잭션 어노테이션 준비
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 메서드 경계 확인
@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을 실행했다고 가정하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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가 돌려준다. |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer.api;
public record TransferResponse(
String transactionId,
String businessTransactionId,
String status,
long fromBalance,
long toBalance
) {}
코드 블록 1 · 요청 ID와 서버 거래 ID를 함께 담는 다섯 칸
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에 넣지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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 헤더는 만들지 않는다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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);
}
}
코드 블록 1 · 창구 주소와 생성자 주입
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
@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에 복제하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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를 판정한다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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 + "}";
}
}
코드 블록 1 · MockMvc·DB 관찰·계좌 개설 도구
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 계좌 두 개
@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와 본문 표지 확인
@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 문자열 만들기
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 헤더를 원문이 보장한다고 덧붙이지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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다. 정상 기본 구현은 조용히 끝나고 테스트 구현은 반환 대신 예외를 던질 수 있다. |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer;
public interface TransferFailureHook {
TransferFailureHook NONE = () -> {};
void afterBusinessMutation();
}
코드 블록 1 · 인자 없는 hook 계약과 NONE 기본값
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 전달, 실제 저장 로직을 이 인터페이스에 넣지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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)를 돌려준다. 검증/조회/업무 실패에서는 정상 반환 대신 예외가 밖으로 나간다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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");
}
}
코드 블록 1 · 외부 Command, 외부 Result, 네 Repository와 hook
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 기본값 정하기
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 오름차순으로 잠금
@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
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 기본 규칙
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 밖 부작용을 구현됐다고 가정하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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을 던진다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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");
};
}
}
}
코드 블록 1 · 실제 서비스에 테스트 hook과 flag만 추가
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, 기준 계좌 두 개
@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 관찰값
@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
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
@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됐다고 확대하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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을 낸다. |
정확한 전체 원문
정확한 전체 원문 펼치기
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();
}
}
코드 블록 1 · 실제 서비스/DB와 매 시험 fixture
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
@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 거래 쓰기 전에 거부
@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을 이 클래스 하나가 모두 보장한다고 말하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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은 원문 답안이라고 주장하지 않고, 문제 계약에 맞춘 예시 정답으로 제공한다.
Q05 · 완료 시간이 아직 없는 거래 찾기
문제 계약: business_tx에서 completed_at이 NULL인 거래를 찾는다.
전체 예시 정답 · canonical learner answer 없음
전체 예시 정답 펼치기
-- 입력 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을 임의 날짜로 채우지 않는다.
전체 예시 정답 · 들여쓰기까지 대조
전체 예시 정답 펼치기
-- 입력 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 · 실패했거나 결과를 모르는 거래 찾기
문제 계약: business_tx에서 status가 FAILED 또는 UNKNOWN인 거래를 찾는다.
전체 예시 정답 · canonical learner answer 없음
전체 예시 정답 펼치기
-- 입력 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 전체를 섞지 않는다.
전체 예시 정답 · 들여쓰기까지 대조
전체 예시 정답 펼치기
-- 입력 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 불변 직접 단언 |
마지막 자기 설명 체크
- 왜
@Positive만으로 1→1 송금을 막을 수 없는지 설명할 수 있는가? @Valid와 서비스의validate가 각각 어느 입구를 지키는지 설명할 수 있는가?- accountId를 작은 번호부터 잠그는 이유와 그 규칙이 하지 못하는 일을 구분하는가?
- 프록시 구조 시험과 rollback 행동 시험이 서로 대체되지 않는 이유를 말할 수 있는가?
- 원장
sum(amount)=0만으로 정확한 한 쌍을 완전히 증명하지 못하는 반례를 만들 수 있는가? - 실패 hook과 rollback 주체를 구분하는가?
- 각 JUnit 테스트에서 Arrange/Act/Assert를 코드 줄로 짚고, 보장하지 않는 것을 한 가지씩 말할 수 있는가?
- Q05의
IS NULL과 Q06의IN이 왜 다른 결과를 내는지 tx 213으로 설명할 수 있는가?
여덟 질문에 자기 말로 답하고, 각 파일의 직접 다시 써보기 → 전체 코드 정답을 들여쓰기까지 대조하면 7주차의 결과 코드 전체를 한 바퀴 돈 것이다.