WEEK 09 · CODE AFTERPARTY
W09 코드 뒤풀이 · 사라진 두 번째 1,000원
학습 범위: 월요일부터 토요일까지 · exact source 7개 · 고유 @Test 6개 · selector 실행 7회 · SQL Q09/Q10 전체 예시 정답
먼저 잡는 전체 실행 지도
W9가 새로 제공한 파일만 보면 2개지만, 월~토 PDF가 다시 싣는 누적 target 2개와 실제 selector의 introducedAt 지원 원문 3개를 함께 봐야 실행 결과를 빠짐없이 설명할 수 있다.
W9 신규 · 2파일 / @Test 3개
이번 주에 새로 생긴 원문
LostUpdateBaselineIT · TransactionPropagationIT
이번 주 누적 정본·지원 · 5파일 / carried @Test 3개
이전 주차에서 가져와 다시 읽는 원문
TransferService · TransactionProxyIT · TransferFailurePointIT · V001__common.sql · CoreSchemaIT
| 요일 | selector | 실행 @Test | 직접 읽을 source |
|---|---|---|---|
| 월 | TransactionProxyIT | 1 | TransferService + TransactionProxyIT |
| 화 | LostUpdateBaselineIT | 2 | LostUpdateBaselineIT |
| 수 | TransferFailurePointIT | 1 | TransferService + TransferFailurePointIT |
| 목 | TransactionPropagationIT | 1 | TransactionPropagationIT |
| 금 | TransferFailurePointIT | 1 (수요일과 같은 @Test 재실행) | TransferService + TransferFailurePointIT |
| 토 | CoreSchemaIT | 1 | V001__common.sql + CoreSchemaIT |

1. TransferService
한 문장 역할: 한 transaction 안에서 두 계좌, 거래 한 행, 원장 두 행을 함께 바꾸는 이체 서비스
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | HTTP controller와 W9의 TransactionProxyIT·TransferFailurePointIT |
| 무엇을 받나 | actorId·transactionId·출금/입금 계좌 ID·양수 amount를 담은 Command |
| 무엇이 바뀌나 | 두 계좌 잔액, business_tx 한 행, ledger_entry 두 행 |
| 무엇을 돌려주나 | 거래 ID와 변경 뒤 두 잔액을 담은 Result |
정확한 전체 원문
정확한 전체 원문 펼치기
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 · 주소와 필요한 부품
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;
- 문법을 한 줄씩 풀면
- package는 클래스의 전체 주소이고 import는 짧은 이름을 허용한다. import 자체는 객체를 만들거나 SQL을 실행하지 않는다.
- 실제 값 추적
- 아직 잔액은 움직이지 않는다. Account·거래·원장 repository와 transaction annotation을 쓸 이름만 준비한다.
- 정상 예
- 각 import가 실제 아래 코드에서 한 번 이상 쓰이는 상태다.
- 반례·경계 예
- 사용하지 않는 import가 남아 있으면 읽는 사람이 실제 의존성으로 오해할 수 있다.
- 착각 방지
- import가 많다고 여러 transaction이 생기는 것은 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 이체 규칙이나 DB 변경을 실행하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 서비스와 입력·출력 모양을 선언한다.
코드 조각 2 · 서비스와 입력·출력 record
@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) {}
- 문법을 한 줄씩 풀면
- @Service는 Spring이 관리할 bean으로 등록하고 record는 전달 값 묶음을 짧게 정의한다.
- 실제 값 추적
- 예를 들어 Command(customer-1, W9-1, 3, 8, 1000)는 다섯 값을 그대로 보관한다. 아직 3번·8번 계좌는 조회하지 않는다.
- 정상 예
- 요청 값과 응답 값을 record로 분리해 필드 뜻이 이름으로 보인다.
- 반례·경계 예
- Command와 Result를 같은 것으로 취급하면 입력 transactionId와 DB가 만든 businessTransactionId를 섞는다.
- 착각 방지
- record는 자동으로 DB 행을 저장해 주지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 금액의 양수 여부나 소유자를 검사하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 실제 저장소와 실패 hook을 주입한다.
코드 조각 3 · 네 의존성과 기본 실패 hook
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);
}
- 문법을 한 줄씩 풀면
- final 필드는 생성자 이후 바뀌지 않으며 ObjectProvider는 선택 bean이 없을 때 기본값을 고를 수 있게 한다.
- 실제 값 추적
- 별도 failureHook bean이 없으면 NONE이 들어가므로 정상 이체에서 afterBusinessMutation은 아무 예외도 내지 않는다.
- 정상 예
- 운영에서는 repository 세 개가 모두 주입되고 테스트에서만 실패 hook을 바꿔 끼울 수 있다.
- 반례·경계 예
- hooks.getIfAvailable을 hook 실행 자체로 착각하면 안 된다. 여기서는 객체만 선택한다.
- 착각 방지
- NONE은 rollback을 끄는 옵션이 아니라 아무 일도 하지 않는 hook이다.
- 이 블록이 하지 않는 일
- 이 조각은 아직 transaction을 열거나 계좌를 잠그지 않는다.
- 다음 코드와의 연결
- 다음 조각의 public transfer가 실제 transaction 경계를 연다.
코드 조각 4 · transaction 시작과 정렬 잠금
@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");
- 문법을 한 줄씩 풀면
- @Transactional은 Spring proxy를 통해 public 메서드가 호출될 때 하나의 transaction 경계를 만든다. sorted는 두 ID를 작은 것부터 고정한다.
- 실제 값 추적
- from=8, to=3이면 조회 인자는 [3, 8]이 된다. 두 행을 잠근 뒤 정확히 두 행인지 확인한다.
- 정상 예
- 모든 이체가 계좌 ID 오름차순으로 잠그면 서로 반대 방향 이체도 같은 순서로 열쇠를 잡는다.
- 반례·경계 예
- 같은 ID를 두 번 넣으면 결과 행은 두 개가 아니므로 ACCOUNT_NOT_FOUND 전에 validate의 same account가 먼저 막는다.
- 착각 방지
- 정렬은 돈의 방향을 바꾸지 않는다. 잠금 순서만 바꾼다.
- 이 블록이 하지 않는 일
- 이 조각은 아직 어느 계좌가 출금 계좌인지 결정하거나 잔액을 바꾸지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 잠근 두 행을 ID로 다시 찾고 소유자를 검사한다.
코드 조각 5 · 잠긴 행을 역할에 맞게 되찾기
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");
- 문법을 한 줄씩 풀면
- HashMap은 account ID를 key로 삼아 정렬된 조회 결과를 from/to 역할로 다시 배치한다.
- 실제 값 추적
- 잠금 결과가 [id3, id8]이어도 command.from=8이면 from은 id8 행이고 to는 id3 행이다. actorId가 from.ownerId와 달라지면 ACCESS_DENIED다.
- 정상 예
- 정렬 잠금과 업무 방향을 분리하므로 양방향 이체에서도 출금 주체가 뒤바뀌지 않는다.
- 반례·경계 예
- 잠긴 리스트의 첫 행을 무조건 from으로 쓰면 from ID가 더 클 때 반대로 출금한다.
- 착각 방지
- HashMap이 DB 잠금을 새로 만드는 것은 아니다. 이미 가져온 두 객체를 찾기 쉽게 묶을 뿐이다.
- 이 블록이 하지 않는 일
- 이 조각은 잔액 부족이나 금액 변경을 아직 수행하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 잔액·거래·원장이 같은 transaction 안에서 함께 바뀐다.
코드 조각 6 · 잔액·거래·원장과 실패 지점
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());
}
- 문법을 한 줄씩 풀면
- 도메인 메서드로 두 잔액을 바꾸고 동일한 now를 거래와 원장 두 행에 사용한 뒤 hook을 호출한다.
- 실제 값 추적
- 10,000에서 1,000 출금하면 9,000, 5,000에 입금하면 6,000이다. 거래 1행과 TRANSFER_OUT/IN 원장 2행이 생기고 Result도 9,000/6,000을 담는다.
- 정상 예
- hook이 조용하면 네 변경 묶음이 함께 commit된다.
- 반례·경계 예
- hook이 RuntimeException을 던지면 반환문에 도달하지 않고 transaction 전체가 rollback되어 10,000/5,000, 거래0, 원장0으로 돌아간다.
- 착각 방지
- failureHook 뒤에 있으니 테스트가 claim 직후 실패까지 확인한다고 넓혀 말하면 안 된다.
- 이 블록이 하지 않는 일
- 이 조각은 idempotency_request를 만들지 않고 외부 시스템에 사건을 보내지 않는다.
- 다음 코드와의 연결
- 다음 조각의 validate가 잘못된 요청을 DB 접근 전에 거른다.
코드 조각 7 · 입력 다섯 가지 선검사
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");
}
}
- 문법을 한 줄씩 풀면
- 각 if는 잘못된 값 하나를 즉시 IllegalArgumentException으로 거절한다.
- 실제 값 추적
- 빈 actor, 빈 transactionId, 0 이하 ID, 같은 계좌, 0 이하 금액이면 line 42의 잠금 조회 전에 끝난다.
- 정상 예
- amount=1이고 서로 다른 양수 계좌 ID면 이 선검사를 통과해 DB 업무 검사로 간다.
- 반례·경계 예
- amount가 양수여도 잔액보다 크면 여기서가 아니라 Account.withdraw에서 거절된다.
- 착각 방지
- validate 통과는 계좌 존재·소유권·잔액 충분을 보장한다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 에러를 HTTP 상태로 번역하지 않는다.
- 다음 코드와의 연결
- 다음 파일 TransactionProxyIT가 이 public 경계가 실제 proxy와 annotation을 가졌는지 확인한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/transfer/TransferService.java
- 전제조건
- Account/세 repository/BusinessException/ErrorCode/TransferFailureHook가 같은 학습 단계에 있어야 한다.
- 반드시 지킬 계약
- public transfer에 @Transactional, 계좌 ID 정렬 잠금, 소유자 검사, 잔액·거래·원장 변경, hook 호출 순서를 보존한다.
- 추천 입력 순서
- record 두 개 → 의존성 필드/생성자 → transfer 경계 → validate 순서로 입력한다.
- 자기 점검
- 1,000원을 10,000/5,000 계좌에 옮기면 반환 잔액 9,000/6,000이고 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;
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");
}
}
2. TransactionProxyIT
한 문장 역할: 주입된 TransferService가 Spring AOP proxy이고 public transfer가 @Transactional 경계를 소유하는지 확인
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W9 월요일 exact selector com.example.financialcore.transfer.TransactionProxyIT |
| 무엇을 받나 | Spring context가 주입한 TransferService bean과 reflection으로 찾은 transfer 메서드 |
| 무엇이 바뀌나 | 업무 DB 행은 바꾸지 않고 구조 두 가지를 관찰한다 |
| 무엇을 돌려주나 | proxy 여부 true, @Transactional 존재 true라는 두 assertion |
정확한 전체 원문
정확한 전체 원문 펼치기
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 · 테스트 도구 import
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;
- 문법을 한 줄씩 풀면
- JUnit @Test, SpringBootTest, AopUtils, reflection 대상 annotation, AssertJ를 불러온다.
- 실제 값 추적
- 아직 context도 bean도 만들지 않았다. 테스트에서 쓸 이름만 준비한다.
- 정상 예
- AopUtils와 Transactional을 서로 다른 assertion에 쓰는 구성이 분명하다.
- 반례·경계 예
- Transactional import만 있고 실제 reflection 검사가 빠지면 annotation 보장이 사라진다.
- 착각 방지
- AopUtils는 transaction을 commit시키는 도구가 아니라 proxy 여부를 묻는 도구다.
- 이 블록이 하지 않는 일
- 이 조각은 DB나 TransferService를 호출하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 Spring context와 주입 대상을 만든다.
코드 조각 2 · Spring 통합 테스트와 bean 주입
@SpringBootTest
class TransactionProxyIT extends PostgresIntegrationTestSupport {
@Autowired TransferService transfers;
- 문법을 한 줄씩 풀면
- @SpringBootTest가 전체 context를 띄우고 @Autowired가 그 context의 TransferService bean을 받는다.
- 실제 값 추적
- transfers에는 new TransferService로 직접 만든 객체가 아니라 Spring이 넘긴 객체가 들어간다.
- 정상 예
- 실제 애플리케이션과 같은 bean 후처리 경로를 거친 대상을 검사한다.
- 반례·경계 예
- 테스트 안에서 new TransferService를 만들면 proxy가 아니어서 다른 질문을 검사하게 된다.
- 착각 방지
- @Autowired가 있다는 사실만으로 transaction이 실제 rollback됐다고 증명되지는 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 잔액 fixture를 만들지 않는다.
- 다음 코드와의 연결
- 다음 조각의 단일 @Test가 proxy와 annotation을 각각 확인한다.
코드 조각 3 · proxy와 public 경계 두 assertion
@Test
void transferBeanIsProxiedAndPublicMethodOwnsTheBoundary() throws Exception {
assertThat(AopUtils.isAopProxy(transfers)).isTrue();
assertThat(TransferService.class.getMethod("transfer", TransferService.Command.class)
.isAnnotationPresent(Transactional.class)).isTrue();
}
}
- 문법을 한 줄씩 풀면
- AopUtils.isAopProxy는 주입 객체를 보고, getMethod는 public transfer(Command)를 찾아 annotation 존재 여부를 읽는다.
- 실제 값 추적
- 첫 assertion 결과 true, 두 번째 결과 true여야 selector가 Green이다.
- 정상 예
- service가 proxy이고 public method가 @Transactional이면 W9가 요구한 구조적 경계가 그대로 보인다.
- 반례·경계 예
- annotation을 private helper에만 옮기거나 bean을 직접 생성하면 둘 중 하나가 실패한다.
- 착각 방지
- 이 두 true는 실제 실패 상황에서 DB 네 효과가 원복된다는 실행 증거와 같지 않다.
- 이 블록이 하지 않는 일
- 이 테스트는 transfer 본문을 호출하지 않는다.
- 다음 코드와의 연결
- 다음 파일 LostUpdateBaselineIT가 실제로 독립 transaction 두 개를 동시에 움직인다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransactionProxyIT.java
- 전제조건
- PostgreSQL 통합 테스트 기반과 TransferService bean이 로드되어야 한다.
- 반드시 지킬 계약
- 주입 bean에는 AopUtils.isAopProxy, public transfer(Command)에는 reflection으로 @Transactional을 각각 검사한다.
- 추천 입력 순서
- import → @SpringBootTest/class → TransferService 주입 → @Test의 proxy assertion → annotation assertion 순서다.
- 자기 점검
- @Test 1개와 assertion 2개가 있고 메서드 이름/파라미터가 실제 TransferService와 정확히 맞는지 확인한다.
- 이번 파일의 범위 밖
- 실제 이체 commit·rollback, advisor 순서, transaction manager 종류는 이 테스트가 직접 실행하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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();
}
}
3. LostUpdateBaselineIT
한 문장 역할: 잠금 없는 의도적 lost update와 FOR UPDATE 직렬화를 같은 10,000원 fixture에서 비교
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W9 화요일 exact selector com.example.financialcore.account.LostUpdateBaselineIT |
| 무엇을 받나 | 10,000원 계좌 한 개, thread 두 개, 각자 REQUIRES_NEW transaction |
| 무엇이 바뀌나 | 잠금 없음은 두 commit 뒤 9,000/version0, 잠금 있음은 두 commit 뒤 8,000 |
| 무엇을 돌려주나 | Future 성공 수와 새 DB 조회값에 대한 assertion 결과 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class LostUpdateBaselineIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired PlatformTransactionManager transactionManager;
private long accountId;
@BeforeEach
void setUp() {
jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE")
.update();
accountId = openings.open("LOST-UPDATE", 10_000).getId();
}
@Test
void test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update() throws Exception {
var readBarrier = new CountDownLatch(2);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
try {
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
readBarrier.countDown();
await(readBarrier);
jdbc.sql("UPDATE account SET balance=:balance WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
}
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(9_000);
assertThat(version()).isZero();
} finally {
pool.shutdownNow();
}
}
@Test
void production_pessimistic_lock_path_commits_twice_and_preserves_both_updates() throws Exception {
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
try {
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
start.await();
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id FOR UPDATE")
.param("id", accountId).query(Long.class).single();
jdbc.sql("UPDATE account SET balance=:balance, version=version+1 WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
}
start.countDown();
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(8_000);
} finally {
start.countDown();
pool.shutdownNow();
}
}
private TransactionTemplate requiresNew() {
TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
tx.setTimeout(10);
return tx;
}
private long balance() {
return jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
private long version() {
return jdbc.sql("SELECT version FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("read barrier timeout");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", interrupted);
}
}
}
코드 조각 1 · 동시성 실험에 필요한 도구
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
- 문법을 한 줄씩 풀면
- TransactionTemplate은 transaction을 코드로 만들고 CountDownLatch·Executor·Future는 두 작업의 시점을 맞추고 결과를 회수한다.
- 실제 값 추적
- 아직 잔액은 0번 읽혔다. 여기서는 클래스 이름만 준비한다.
- 정상 예
- 각 동시성 도구가 뒤의 한 역할과 연결된다.
- 반례·경계 예
- Future 없이 submit만 하면 worker 안 예외가 메인 테스트에 전달되지 않을 수 있다.
- 착각 방지
- CountDownLatch는 DB row lock이 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 transaction이나 thread를 시작하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 매 테스트용 10,000원 fixture를 만든다.
코드 조각 2 · Spring bean과 10,000원 fixture
@SpringBootTest
class LostUpdateBaselineIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired PlatformTransactionManager transactionManager;
private long accountId;
@BeforeEach
void setUp() {
jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE")
.update();
accountId = openings.open("LOST-UPDATE", 10_000).getId();
}
- 문법을 한 줄씩 풀면
- @BeforeEach는 각 @Test 직전에 네 table을 비우고 새 계좌 ID를 저장한다.
- 실제 값 추적
- 각 테스트 시작값은 accountId의 balance=10,000이며 이전 테스트의 잔액과 행은 남지 않는다.
- 정상 예
- 독립된 동일 출발점 덕분에 9,000과 8,000 결과를 바로 비교할 수 있다.
- 반례·경계 예
- TRUNCATE 순서나 CASCADE가 빠져 FK 행이 남으면 fixture 생성이 실패할 수 있다.
- 착각 방지
- RESTART IDENTITY는 테스트의 핵심 금액 보장이 아니라 ID 재설정 편의다.
- 이 블록이 하지 않는 일
- 이 조각은 두 thread를 아직 실행하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 잠금 없는 실험의 barrier와 pool을 준비한다.
코드 조각 3 · 잠금 없는 시험의 두 자리 barrier
@Test
void test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update() throws Exception {
var readBarrier = new CountDownLatch(2);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
- 문법을 한 줄씩 풀면
- CountDownLatch(2)는 두 작업이 각각 읽은 뒤 countDown해야 0이 되어 둘 다 진행하게 한다.
- 실제 값 추적
- pool 크기 2, 반복 2회, Future 목록 0개에서 시작한다.
- 정상 예
- 두 worker가 실제로 겹칠 자리를 마련한다.
- 반례·경계 예
- pool 크기가 1이면 첫 작업이 barrier에서 둘째를 기다리며 timeout된다.
- 착각 방지
- barrier는 두 read를 겹치게 할 뿐 UPDATE를 안전하게 직렬화하지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 balance를 아직 SELECT하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 두 transaction이 같은 10,000을 읽고 각각 9,000을 쓴다.
코드 조각 4 · 같은 값을 읽고 같은 9,000 쓰기
try {
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
readBarrier.countDown();
await(readBarrier);
jdbc.sql("UPDATE account SET balance=:balance WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
- 문법을 한 줄씩 풀면
- 각 submit 안에서 requiresNew를 만들고 transaction 내부에서 SELECT한 뒤 barrier를 통과해 무조건 UPDATE한다.
- 실제 값 추적
- T1 observed=10,000, T2 observed=10,000이다. 둘 다 observed-1,000=9,000을 같은 행에 쓴다.
- 정상 예
- 의도적으로 version 조건 없는 raw SQL을 써 lost update 기준선을 재현한다.
- 반례·경계 예
- 둘을 순서대로 실행하면 둘째가 9,000을 읽어 8,000을 쓰므로 이 현상이 나오지 않는다.
- 착각 방지
- UPDATE 두 번 성공과 금액 변화 2,000은 같은 말이 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 생산 JPA 경로가 취약하다고 주장하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 두 Future와 최종 DB 값을 회수한다.
코드 조각 5 · 두 commit인데 최종 9,000
}
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(9_000);
assertThat(version()).isZero();
} finally {
pool.shutdownNow();
}
}
- 문법을 한 줄씩 풀면
- Future.get(20초)는 worker 결과를 회수하고 finally는 성공·실패와 관계없이 pool을 닫는다.
- 실제 값 추적
- true Future 두 개로 commits=2, 새 SELECT balance=9,000, version=0이 된다.
- 정상 예
- 세 assertion이 함께 맞아야 '두 번 끝났지만 한 변화가 사라짐'을 말할 수 있다.
- 반례·경계 예
- balance만 9,000이고 Future 하나가 실패했다면 lost update 증거가 아니다.
- 착각 방지
- commits 변수 이름은 DB 로그를 읽은 것이 아니라 worker가 true를 반환한 수다.
- 이 블록이 하지 않는 일
- 이 조각은 어느 transaction이 마지막 UPDATE였는지 보장하지 않는다.
- 다음 코드와의 연결
- 다음 조각은 FOR UPDATE 경로의 시작 신호와 worker를 준비한다.
코드 조각 6 · 잠금 있는 시험의 동시 시작
@Test
void production_pessimistic_lock_path_commits_twice_and_preserves_both_updates() throws Exception {
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
try {
- 문법을 한 줄씩 풀면
- CountDownLatch(1)는 메인 thread가 한 번 countDown하면 두 worker가 거의 함께 출발하게 한다.
- 실제 값 추적
- start=1, worker=2, Future 목록=0에서 시작한다.
- 정상 예
- 잠금 전후 비교도 같은 두 worker 수를 사용한다.
- 반례·경계 예
- start.countDown을 빼면 두 Future 모두 진행하지 못하고 timeout된다.
- 착각 방지
- 동시 출발 신호와 row lock은 다른 장치다.
- 이 블록이 하지 않는 일
- 이 조각은 아직 계좌 행을 잠그지 않는다.
- 다음 코드와의 연결
- 다음 조각의 SELECT FOR UPDATE가 한 번에 한 transaction만 행을 읽게 한다.
코드 조각 7 · FOR UPDATE로 10,000 다음 9,000 읽기
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
start.await();
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id FOR UPDATE")
.param("id", accountId).query(Long.class).single();
jdbc.sql("UPDATE account SET balance=:balance, version=version+1 WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
- 문법을 한 줄씩 풀면
- SELECT ... FOR UPDATE는 읽은 account 행에 배타적 잠금을 잡고 같은 transaction 안 UPDATE까지 유지한다.
- 실제 값 추적
- 먼저 잠근 worker는 10,000→9,000, commit 뒤 둘째 worker는 9,000→8,000이다. version도 각 UPDATE에서 1씩 더한다.
- 정상 예
- 읽기와 쓰기를 같은 REQUIRES_NEW 안에 묶어 두 변화가 모두 잔액에 반영된다.
- 반례·경계 예
- 잠금 전에 별도로 balance를 읽어 계산하면 낡은 observed로 UPDATE할 수 있다.
- 착각 방지
- FOR UPDATE가 DB의 모든 table이나 모든 계좌를 잠그는 것은 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 잠금 공정성이나 deadlock 방지를 검증하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 commits=2와 최종 8,000만 직접 assert한다.
코드 조각 8 · 잠금 경로의 최종 8,000
}
start.countDown();
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(8_000);
} finally {
start.countDown();
pool.shutdownNow();
}
}
- 문법을 한 줄씩 풀면
- 두 Future를 같은 20초 제한으로 회수하고 finally에서 start와 pool을 반드시 정리한다.
- 실제 값 추적
- commits=2, balance=8,000이다. 첫 1,000과 둘째 1,000이 모두 남는다.
- 정상 예
- 잠금 없는 9,000과 잠금 있는 8,000을 같은 fixture 기준으로 비교할 수 있다.
- 반례·경계 예
- version=2가 예상되더라도 이 메서드는 그것을 assert하지 않는다.
- 착각 방지
- 코드가 version을 증가시킨다는 사실과 테스트가 version 값을 보장한다는 말은 다르다.
- 이 블록이 하지 않는 일
- 이 조각은 높은 동시 요청에서의 처리량을 측정하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 두 worker가 공통으로 쓴 새 transaction 설정을 확인한다.
코드 조각 9 · 각 worker의 독립 transaction과 10초 제한
private TransactionTemplate requiresNew() {
TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
tx.setTimeout(10);
return tx;
}
- 문법을 한 줄씩 풀면
- TransactionTemplate에 PROPAGATION_REQUIRES_NEW와 timeout 10을 설정해 호출자 transaction과 독립된 경계를 만든다.
- 실제 값 추적
- 각 worker마다 새 template 객체와 새 transaction 하나가 생긴다.
- 정상 예
- 두 작업이 서로 다른 commit 단위를 가져 lost update/잠금 차이를 관찰할 수 있다.
- 반례·경계 예
- 기존 transaction 안 REQUIRED로 합쳐지면 두 독립 commit 실험이 아니다.
- 착각 방지
- timeout 10은 쿼리가 반드시 10초 정확히 실행된다는 뜻이 아니다.
- 이 블록이 하지 않는 일
- 이 helper는 commit 성공 수를 기록하지 않는다.
- 다음 코드와의 연결
- 다음 조각의 조회 helper가 transaction 종료 뒤 최종 값을 새로 읽는다.
코드 조각 10 · 최종 balance와 version 재조회
private long balance() {
return jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
private long version() {
return jdbc.sql("SELECT version FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
- 문법을 한 줄씩 풀면
- named parameter id를 넣고 Long 한 행을 single로 꺼내 각각 primitive long으로 반환한다.
- 실제 값 추적
- accountId=1이라면 SELECT ... WHERE id=1 한 행에서 balance 또는 version 값을 읽는다.
- 정상 예
- worker가 끝난 뒤 DB에 실제 남은 값을 assertion에 전달한다.
- 반례·경계 예
- 행이 없거나 둘 이상이면 single 계약이 깨져 테스트가 조용히 잘못된 값을 쓰지 않는다.
- 착각 방지
- 영속성 context cache를 읽는 helper가 아니라 raw SQL 재조회다.
- 이 블록이 하지 않는 일
- 이 helper는 어떤 worker가 값을 썼는지 설명하지 않는다.
- 다음 코드와의 연결
- 마지막 helper가 barrier timeout과 interrupt를 명시적 실패로 바꾼다.
코드 조각 11 · barrier 대기 실패를 숨기지 않기
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("read barrier timeout");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", interrupted);
}
}
}
- 문법을 한 줄씩 풀면
- await(10초)가 false면 timeout 예외, interrupt면 thread 상태를 복원한 뒤 원인을 담은 예외를 던진다.
- 실제 값 추적
- 둘째 worker가 오지 않으면 10초 뒤 read barrier timeout으로 테스트가 실패한다.
- 정상 예
- 무한 대기 대신 실패 이유를 메인 Future로 전달한다.
- 반례·경계 예
- InterruptedException을 삼키고 계속 UPDATE하면 취소 신호를 무시한다.
- 착각 방지
- 이 10초는 업무 transaction timeout과 별개의 latch 대기 제한이다.
- 이 블록이 하지 않는 일
- 이 helper는 DB lock wait 자체를 측정하지 않는다.
- 다음 코드와의 연결
- 다음 파일 TransferFailurePointIT가 이체 중 RuntimeException 뒤 네 최종 상태를 확인한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/account/LostUpdateBaselineIT.java
- 전제조건
- 실제 PostgreSQL, AccountOpeningService, JdbcClient, PlatformTransactionManager가 필요하다.
- 반드시 지킬 계약
- 각 테스트 전에 10,000원 한 계좌를 만들고, 두 작업을 독립 REQUIRES_NEW로 실행하며 Future 결과를 모두 회수한다.
- 추천 입력 순서
- fixture → 잠금 없는 @Test → 잠금 있는 @Test → requiresNew → balance/version → await helper 순서다.
- 자기 점검
- @Test 2개, commits=2, 잠금 없음 9,000/version0, 잠금 있음 8,000 assertion을 빠짐없이 대조한다.
- 이번 파일의 범위 밖
- 모든 DB/격리수준의 재현 확률, deadlock 부재, 처리량, JPA @Version 생산 경로는 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class LostUpdateBaselineIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired PlatformTransactionManager transactionManager;
private long accountId;
@BeforeEach
void setUp() {
jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE")
.update();
accountId = openings.open("LOST-UPDATE", 10_000).getId();
}
@Test
void test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update() throws Exception {
var readBarrier = new CountDownLatch(2);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
try {
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
readBarrier.countDown();
await(readBarrier);
jdbc.sql("UPDATE account SET balance=:balance WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
}
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(9_000);
assertThat(version()).isZero();
} finally {
pool.shutdownNow();
}
}
@Test
void production_pessimistic_lock_path_commits_twice_and_preserves_both_updates() throws Exception {
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(2);
List<Future<Boolean>> futures = new ArrayList<>();
try {
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
start.await();
TransactionTemplate tx = requiresNew();
tx.executeWithoutResult(status -> {
long observed = jdbc.sql("SELECT balance FROM account WHERE id=:id FOR UPDATE")
.param("id", accountId).query(Long.class).single();
jdbc.sql("UPDATE account SET balance=:balance, version=version+1 WHERE id=:id")
.param("balance", observed - 1_000)
.param("id", accountId)
.update();
});
return true;
}));
}
start.countDown();
int commits = 0;
for (Future<Boolean> future : futures) if (future.get(20, TimeUnit.SECONDS)) commits++;
assertThat(commits).isEqualTo(2);
assertThat(balance()).isEqualTo(8_000);
} finally {
start.countDown();
pool.shutdownNow();
}
}
private TransactionTemplate requiresNew() {
TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
tx.setTimeout(10);
return tx;
}
private long balance() {
return jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
private long version() {
return jdbc.sql("SELECT version FROM account WHERE id=:id")
.param("id", accountId).query(Long.class).single();
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("read barrier timeout");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", interrupted);
}
}
}

4. TransferFailurePointIT
한 문장 역할: 업무 변경 뒤 단일 RuntimeException 지점에서 잔액·거래·원장이 모두 원복되는지 재검증
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W9 수요일과 금요일 exact selector com.example.financialcore.transfer.TransferFailurePointIT |
| 무엇을 받나 | 10,000/5,000원 두 계좌와 1,000원 이체, 테스트용 after-business failure hook |
| 무엇이 바뀌나 | 이체 도중 잠시 잔액·거래·원장을 바꾸지만 RuntimeException으로 모두 rollback |
| 무엇을 돌려주나 | 예외 문구, hook 호출 true, 최종 [10,000, 5,000, 0, 0] 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.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 · 통합 시험과 실패 주입 도구
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·TestConfiguration은 실제 context에 테스트 전용 bean을 추가하고 AssertJ가 예외와 값을 검사한다.
- 실제 값 추적
- 아직 계좌·거래 행은 없다. AtomicBoolean과 List도 이름만 준비한다.
- 정상 예
- 실패를 일으키는 bean과 최종 DB를 읽는 JdbcClient를 한 테스트에서 함께 쓸 수 있다.
- 반례·경계 예
- assertThatThrownBy만 있고 최종 상태 조회가 없으면 rollback 범위를 알 수 없다.
- 착각 방지
- TestConfiguration은 운영 설정 파일을 영구 수정하지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 hook을 만들거나 이체를 호출하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 테스트 설정을 context에 연결하고 필요한 bean을 주입한다.
코드 조각 2 · 실패 설정 연결과 관찰 대상
@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는 중첩 FailureConfiguration을 시험 context에 더하고 @Autowired는 업무 bean·hook flag를 받는다.
- 실제 값 추적
- from/to는 아직 null이고 invoked는 주입된 AtomicBoolean 하나를 가리킨다.
- 정상 예
- 운영 TransferService에 테스트 hook만 교체해 같은 transaction 본문을 실행한다.
- 반례·경계 예
- TransferService 자체를 mock으로 바꾸면 실제 rollback 경계를 검증하지 못한다.
- 착각 방지
- invoked=true는 rollback 성공이 아니라 hook 지점 도달만 뜻한다.
- 이 블록이 하지 않는 일
- 이 조각은 fixture를 초기화하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 네 table을 비우고 10,000/5,000원 계좌를 만든다.
코드 조각 3 · 매번 같은 두 계좌 준비
@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에서 flag를 false로 되돌리고 관련 table을 비운 뒤 from/to 계좌를 연다.
- 실제 값 추적
- from=10,000, to=5,000, 거래0, 원장0, invoked=false로 시작한다.
- 정상 예
- 이전 실행의 실패 행이나 잔액이 다음 selector 결과에 섞이지 않는다.
- 반례·경계 예
- invoked를 초기화하지 않으면 hook이 이번 호출에서 실행되지 않아도 true일 수 있다.
- 착각 방지
- 계좌 번호 FAIL-FROM/FAIL-TO는 실패를 만드는 값이 아니라 구분용 문자열이다.
- 이 블록이 하지 않는 일
- 이 조각은 아직 이체를 실행하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 1,000원 이체가 hook까지 간 뒤 예외를 낸다.
코드 조각 4 · 업무 변경 뒤 RuntimeException 확인
@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();
- 문법을 한 줄씩 풀면
- assertThatThrownBy는 lambda 호출에서 나온 예외의 type과 message를 연속 검사한다.
- 실제 값 추적
- customer-1이 from에서 to로 1,000원을 옮기려 하고 hook이 'injected after business mutation'을 던진다. invoked는 true다.
- 정상 예
- 예외 종류·문구·hook 도달을 함께 확인해 예상 지점에서 실패했음을 고정한다.
- 반례·경계 예
- 다른 RuntimeException 문구가 나오면 같은 rollback 결과처럼 보여도 이 assertion은 실패한다.
- 착각 방지
- 이 원문에는 claim 직후 두 번째 hook이 없다.
- 이 블록이 하지 않는 일
- 이 조각만으로 DB가 원복됐다고 아직 말할 수 없다.
- 다음 코드와의 연결
- 다음 조각에서 새 SQL 조회로 잔액과 row count를 확인한다.
코드 조각 5 · 최종 [10,000, 5,000, 0, 0]
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);
}
- 문법을 한 줄씩 풀면
- COUNT(*) 두 개와 balance helper 두 번의 결과를 List로 묶어 순서까지 정확히 비교한다.
- 실제 값 추적
- 예외 뒤 from=10,000, to=5,000, TRANSFER business_tx=0, TRANSFER_% ledger_entry=0이다.
- 정상 예
- 잔액과 두 종류의 기록이 한꺼번에 원복됐다는 직접 증거다.
- 반례·경계 예
- 잔액만 원복되고 거래 행이 남으면 containsExactly가 실패한다.
- 착각 방지
- 0 두 개에는 idempotency_request count가 포함되지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 다른 tx_type·entry_type 행의 존재를 검사하지 않는다.
- 다음 코드와의 연결
- 다음 helper가 각 계좌 잔액을 DB에서 새로 읽는다.
코드 조각 6 · 계좌 ID별 잔액 재조회
private long balance(long id) {
return jdbc.sql("SELECT balance FROM account WHERE id=:id")
.param("id", id).query(Long.class).single();
}
- 문법을 한 줄씩 풀면
- named parameter로 id를 바인딩하고 single Long을 반환한다.
- 실제 값 추적
- from.getId() 조회는 10,000, to.getId() 조회는 5,000을 돌려준다.
- 정상 예
- transaction 호출이 끝난 뒤 실제 DB 상태를 확인한다.
- 반례·경계 예
- 영속 객체의 메모리 값만 보면 rollback 후 새 DB 값과 다를 수 있다.
- 착각 방지
- single은 행이 정확히 하나라는 전제까지 검사한다.
- 이 블록이 하지 않는 일
- 이 helper는 거래나 원장 수를 세지 않는다.
- 다음 코드와의 연결
- 마지막 조각에서 실제 예외를 던지는 test bean을 만든다.
코드 조각 7 · 한 지점짜리 failure hook
@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");
};
}
}
}
- 문법을 한 줄씩 풀면
- proxyBeanMethods=false인 TestConfiguration이 AtomicBoolean과 TransferFailureHook 두 bean을 만들고 lambda hook이 flag 설정 뒤 예외를 던진다.
- 실제 값 추적
- hook 호출 전 false, 호출 직후 true, 이어서 RuntimeException으로 transfer 반환이 중단된다.
- 정상 예
- 작고 명확한 시험용 실패 지점으로 after-business rollback을 재현한다.
- 반례·경계 예
- lambda 안에서 flag만 바꾸고 예외를 빼면 이체가 commit되어 테스트가 실패한다.
- 착각 방지
- bean이 두 개 있다고 failure point가 두 곳인 것은 아니다. 실제 hook 메서드는 하나다.
- 이 블록이 하지 않는 일
- 이 조각은 운영에서 실패 hook을 활성화하지 않는다.
- 다음 코드와의 연결
- 다음 파일 TransactionPropagationIT는 반대로 안쪽 transaction만 의도적으로 남기는 실험이다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java
- 전제조건
- W7D5 단계의 TransferService와 TransferFailureHook, 실제 PostgreSQL 통합 테스트 기반이 필요하다.
- 반드시 지킬 계약
- failureHook은 업무 변경 뒤 한 지점에서만 예외를 던지며, invoked와 네 최종 값만 직접 assert한다.
- 추천 입력 순서
- import/config 연결 → 주입/fixture → 예외 호출 → DB count/잔액 assertion → helper → test bean 순서다.
- 자기 점검
- @Test 1개이고 idempotency_request count나 AFTER_CLAIM marker assertion이 없는지 원문과 대조한다.
- 이번 파일의 범위 밖
- claim 직후 실패, 두 failure point, idempotency 행 0, checked exception, 외부 시스템 rollback은 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import 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");
};
}
}
}
5. TransactionPropagationIT
한 문장 역할: 바깥 transaction rollback과 별개로 REQUIRES_NEW 안쪽 INSERT가 commit되는 부분 저장을 관찰
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W9 목요일 exact selector com.example.financialcore.transfer.TransactionPropagationIT |
| 무엇을 받나 | 별도 Spring bean인 OuterProbe와 InnerProbe, 시험 전용 w9_propagation_probe table |
| 무엇이 바뀌나 | OUTER_ROLLED_BACK INSERT는 취소되고 INNER_COMMITTED INSERT만 별도 commit |
| 무엇을 돌려주나 | rollback outer 예외와 최종 label 목록 [INNER_COMMITTED] assertion |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.PostgresIntegrationTestSupport;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
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 org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Import(TransactionPropagationIT.ProbeConfiguration.class)
class TransactionPropagationIT extends PostgresIntegrationTestSupport {
@TestConfiguration
static class ProbeConfiguration {
@Bean InnerProbe innerProbe() { return new InnerProbe(); }
@Bean OuterProbe outerProbe(InnerProbe inner) { return new OuterProbe(inner); }
}
static class InnerProbe {
@PersistenceContext EntityManager entityManager;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertCommittedInnerRow() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('INNER_COMMITTED')")
.executeUpdate();
}
}
static class OuterProbe {
@PersistenceContext EntityManager entityManager;
private final InnerProbe inner;
OuterProbe(InnerProbe inner) { this.inner = inner; }
@Transactional
public void insertOuterThenFail() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('OUTER_ROLLED_BACK')")
.executeUpdate();
inner.insertCommittedInnerRow();
throw new RuntimeException("rollback outer");
}
}
@Autowired JdbcClient jdbc;
@Autowired OuterProbe outer;
@BeforeEach
void prepareProbeTable() {
jdbc.sql("DROP TABLE IF EXISTS w9_propagation_probe").update();
jdbc.sql("CREATE TABLE w9_propagation_probe(id BIGINT GENERATED ALWAYS AS IDENTITY, label TEXT NOT NULL)")
.update();
}
@Test
void requiresNewCommitsWhileTheOuterTransactionRollsBack() {
assertThatThrownBy(outer::insertOuterThenFail)
.isInstanceOf(RuntimeException.class)
.hasMessage("rollback outer");
assertThat(jdbc.sql("SELECT label FROM w9_propagation_probe ORDER BY id")
.query(String.class).list()).containsExactly("INNER_COMMITTED");
}
}
코드 조각 1 · propagation 실험 도구
package com.example.financialcore.transfer;
import com.example.financialcore.PostgresIntegrationTestSupport;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
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 org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
- 문법을 한 줄씩 풀면
- EntityManager는 native INSERT를 실행하고 Propagation·Transactional은 두 transaction 경계를 구분한다.
- 실제 값 추적
- 아직 probe table도 transaction도 없다.
- 정상 예
- 예외 assertion과 최종 label assertion에 필요한 도구가 모두 준비된다.
- 반례·경계 예
- JdbcClient만으로 inner/outer 메서드를 같은 객체에서 호출하면 Spring proxy 경계를 놓칠 수 있다.
- 착각 방지
- REQUIRES_NEW import만으로 새 transaction이 자동 실행되지는 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 bean을 등록하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 inner와 outer를 서로 다른 Spring bean으로 만든다.
코드 조각 2 · 서로 다른 두 probe bean
@SpringBootTest
@Import(TransactionPropagationIT.ProbeConfiguration.class)
class TransactionPropagationIT extends PostgresIntegrationTestSupport {
@TestConfiguration
static class ProbeConfiguration {
@Bean InnerProbe innerProbe() { return new InnerProbe(); }
@Bean OuterProbe outerProbe(InnerProbe inner) { return new OuterProbe(inner); }
}
- 문법을 한 줄씩 풀면
- @TestConfiguration의 @Bean 메서드가 InnerProbe와 그 인스턴스를 받는 OuterProbe를 각각 등록한다.
- 실제 값 추적
- outer.inner는 Spring이 만든 InnerProbe bean을 가리킨다.
- 정상 예
- outer에서 inner public 메서드를 호출할 때 proxy interception이 가능한 구조다.
- 반례·경계 예
- OuterProbe 안에서 new InnerProbe를 만들면 REQUIRES_NEW annotation을 가로챌 proxy가 없다.
- 착각 방지
- 서로 다른 클래스라는 사실보다 Spring bean 경계를 통과하는지가 중요하다.
- 이 블록이 하지 않는 일
- 이 조각은 아직 INSERT하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 inner가 별도 transaction으로 한 행을 commit한다.
코드 조각 3 · INNER_COMMITTED 별도 봉투
static class InnerProbe {
@PersistenceContext EntityManager entityManager;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertCommittedInnerRow() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('INNER_COMMITTED')")
.executeUpdate();
}
}
- 문법을 한 줄씩 풀면
- @Transactional(propagation=REQUIRES_NEW)는 기존 transaction을 잠시 멈추고 새 transaction에서 native INSERT를 실행한다.
- 실제 값 추적
- w9_propagation_probe에 label=INNER_COMMITTED 한 행이 들어가고 inner 메서드가 정상 끝나면 먼저 commit된다.
- 정상 예
- 바깥 작업이 나중에 실패해도 안쪽 행을 남기는 의도적 부분 commit을 관찰할 수 있다.
- 반례·경계 예
- 같은 class의 this.insertCommittedInnerRow 호출이면 proxy를 우회할 수 있다.
- 착각 방지
- REQUIRES_NEW가 항상 더 안전하다는 뜻이 아니다. 서로 함께 사라져야 할 데이터에는 위험하다.
- 이 블록이 하지 않는 일
- 이 조각은 바깥 transaction을 만들지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 outer가 자기 행을 넣고 inner 호출 뒤 실패한다.
코드 조각 4 · OUTER_ROLLED_BACK 큰 봉투
static class OuterProbe {
@PersistenceContext EntityManager entityManager;
private final InnerProbe inner;
OuterProbe(InnerProbe inner) { this.inner = inner; }
@Transactional
public void insertOuterThenFail() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('OUTER_ROLLED_BACK')")
.executeUpdate();
inner.insertCommittedInnerRow();
throw new RuntimeException("rollback outer");
}
}
- 문법을 한 줄씩 풀면
- 기본 @Transactional outer 메서드는 자기 INSERT 후 inner bean을 호출하고 RuntimeException을 던진다.
- 실제 값 추적
- 먼저 OUTER_ROLLED_BACK을 넣고, inner의 별도 INSERT가 commit된 뒤 'rollback outer' 예외로 바깥 행만 취소된다.
- 정상 예
- 실패 순서를 코드로 고정해 두 transaction의 서로 다른 최종 상태를 만든다.
- 반례·경계 예
- inner 호출 전에 예외가 나면 INNER_COMMITTED도 생기지 않아 다른 실험이 된다.
- 착각 방지
- 문자열 OUTER_ROLLED_BACK은 예정된 최종 상태를 설명하는 label일 뿐 DB rollback 명령이 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 최종 table을 조회하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 매 테스트용 table을 새로 만들어 출발점을 0행으로 맞춘다.
코드 조각 5 · 0행 probe table fixture
@Autowired JdbcClient jdbc;
@Autowired OuterProbe outer;
@BeforeEach
void prepareProbeTable() {
jdbc.sql("DROP TABLE IF EXISTS w9_propagation_probe").update();
jdbc.sql("CREATE TABLE w9_propagation_probe(id BIGINT GENERATED ALWAYS AS IDENTITY, label TEXT NOT NULL)")
.update();
}
- 문법을 한 줄씩 풀면
- @BeforeEach가 기존 table을 버리고 identity ID와 NOT NULL label을 가진 작은 table을 다시 만든다.
- 실제 값 추적
- 테스트 시작 row count는 0이고 첫 INSERT id는 1이다.
- 정상 예
- 이전 실행의 INNER_COMMITTED가 남아 containsExactly를 오염시키지 않는다.
- 반례·경계 예
- DROP 없이 CREATE만 하면 두 번째 테스트 실행에서 table already exists가 난다.
- 착각 방지
- 이 임시 table은 V001 네 core table의 일부가 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 inner/outer transaction을 실행하지 않는다.
- 다음 코드와의 연결
- 마지막 조각이 outer 호출과 최종 label을 직접 assert한다.
코드 조각 6 · outer 예외와 inner 한 행 확인
@Test
void requiresNewCommitsWhileTheOuterTransactionRollsBack() {
assertThatThrownBy(outer::insertOuterThenFail)
.isInstanceOf(RuntimeException.class)
.hasMessage("rollback outer");
assertThat(jdbc.sql("SELECT label FROM w9_propagation_probe ORDER BY id")
.query(String.class).list()).containsExactly("INNER_COMMITTED");
}
}
- 문법을 한 줄씩 풀면
- assertThatThrownBy가 outer 호출의 type/message를 확인하고 다음 SQL이 ID 순 label 목록을 읽는다.
- 실제 값 추적
- 호출 중 RuntimeException('rollback outer'), 호출 뒤 결과 목록은 정확히 ['INNER_COMMITTED']다.
- 정상 예
- 바깥 행 0과 안쪽 행 1이라는 부분 commit 결과를 한 목록으로 직접 확인한다.
- 반례·경계 예
- INNER_COMMITTED와 OUTER_ROLLED_BACK 둘 다 남으면 containsExactly가 실패한다.
- 착각 방지
- 이 결과만으로 실패 감사 기록을 별도 commit하는 설계가 옳다고 결론내리면 안 된다.
- 이 블록이 하지 않는 일
- 이 테스트는 외부 시스템이나 nested/savepoint propagation을 검사하지 않는다.
- 다음 코드와의 연결
- 다음 파일 V001__common.sql은 W9 토요일 selector가 다시 확인하는 네 core table을 정의한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/TransactionPropagationIT.java
- 전제조건
- 실제 PostgreSQL과 Spring proxy가 필요하며 InnerProbe·OuterProbe가 서로 다른 bean이어야 한다.
- 반드시 지킬 계약
- inner public 메서드는 REQUIRES_NEW, outer public 메서드는 기본 transaction이고 outer가 inner 호출 뒤 RuntimeException을 던진다.
- 추천 입력 순서
- import → ProbeConfiguration → InnerProbe → OuterProbe → 주입/fixture → @Test 순서다.
- 자기 점검
- 최종 table에는 OUTER_ROLLED_BACK이 없고 INNER_COMMITTED 하나만 있는지 containsExactly를 확인한다.
- 이번 파일의 범위 밖
- 같은 class self-invocation, 감사로그의 업무 타당성, 메시지 브로커·외부 API 원자성은 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.transfer;
import com.example.financialcore.PostgresIntegrationTestSupport;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
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 org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Import(TransactionPropagationIT.ProbeConfiguration.class)
class TransactionPropagationIT extends PostgresIntegrationTestSupport {
@TestConfiguration
static class ProbeConfiguration {
@Bean InnerProbe innerProbe() { return new InnerProbe(); }
@Bean OuterProbe outerProbe(InnerProbe inner) { return new OuterProbe(inner); }
}
static class InnerProbe {
@PersistenceContext EntityManager entityManager;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertCommittedInnerRow() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('INNER_COMMITTED')")
.executeUpdate();
}
}
static class OuterProbe {
@PersistenceContext EntityManager entityManager;
private final InnerProbe inner;
OuterProbe(InnerProbe inner) { this.inner = inner; }
@Transactional
public void insertOuterThenFail() {
entityManager.createNativeQuery(
"INSERT INTO w9_propagation_probe(label) VALUES ('OUTER_ROLLED_BACK')")
.executeUpdate();
inner.insertCommittedInnerRow();
throw new RuntimeException("rollback outer");
}
}
@Autowired JdbcClient jdbc;
@Autowired OuterProbe outer;
@BeforeEach
void prepareProbeTable() {
jdbc.sql("DROP TABLE IF EXISTS w9_propagation_probe").update();
jdbc.sql("CREATE TABLE w9_propagation_probe(id BIGINT GENERATED ALWAYS AS IDENTITY, label TEXT NOT NULL)")
.update();
}
@Test
void requiresNewCommitsWhileTheOuterTransactionRollsBack() {
assertThatThrownBy(outer::insertOuterThenFail)
.isInstanceOf(RuntimeException.class)
.hasMessage("rollback outer");
assertThat(jdbc.sql("SELECT label FROM w9_propagation_probe ORDER BY id")
.query(String.class).list()).containsExactly("INNER_COMMITTED");
}
}

6. V001__common
한 문장 역할: 계좌·업무 거래·원장·멱등 요청 네 core table과 핵심 제약·조회 index를 처음 생성
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | Flyway 시작 migration과 W9 토요일 CoreSchemaIT |
| 무엇을 받나 | 아직 V001이 적용되지 않은 PostgreSQL schema |
| 무엇이 바뀌나 | account, business_tx, ledger_entry, idempotency_request 네 table과 index 두 개 |
| 무엇을 돌려주나 | 후속 Java 코드가 참조할 column·PK/FK/UNIQUE/CHECK 구조 |
정확한 전체 원문
정확한 전체 원문 펼치기
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);
코드 조각 1 · account와 소유자 조회 index
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
- 문법을 한 줄씩 풀면
- IDENTITY PK가 id를 만들고 NOT NULL·UNIQUE·CHECK·DEFAULT가 계좌 한 행의 허용값을 제한한다.
- 실제 값 추적
- ACTIVE KRW 잔액10,000/version0 계좌는 들어가지만 balance=-1이나 같은 account_no 두 번은 DB가 거절한다.
- 정상 예
- application 검사를 놓쳐도 DB가 음수 잔액과 잘못된 통화를 마지막 방어선에서 막는다.
- 반례·경계 예
- CLOSED 상태를 허용하지 않거나 USD를 넣으면 CHECK 위반이다.
- 착각 방지
- index는 owner_id 조건 조회를 돕지만 결과 정렬을 무조건 보장하지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 거래나 원장 table을 만들지 않는다.
- 다음 코드와의 연결
- 다음 조각이 업무 사건 한 건을 담는 business_tx를 만든다.
코드 조각 2 · business_tx 업무 사건
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
- 문법을 한 줄씩 풀면
- UUID PK와 correlation_id UNIQUE가 거래 식별을 고정하고 TIMESTAMPTZ가 요청·완료 시각을 저장한다.
- 실제 값 추적
- 이체 한 건은 id 하나, tx_type=TRANSFER, status, correlation_id, requested_at, 선택 completed_at을 가진다.
- 정상 예
- 같은 correlation_id 중복 INSERT는 DB에서 거절된다.
- 반례·경계 예
- completed_at NULL은 아직 완료되지 않았을 수 있으므로 무조건 실패라고 단정할 수 없다.
- 착각 방지
- 이 앱 V001 table에는 workbook의 tx_id·amount·failure_reason·occurred_at column이 없다.
- 이 블록이 하지 않는 일
- 이 조각은 어느 계좌의 얼마가 변했는지 직접 담지 않는다.
- 다음 코드와의 연결
- 다음 ledger_entry가 거래와 계좌를 연결해 금액 흔적을 남긴다.
코드 조각 3 · ledger_entry의 두 FK와 금액 제약
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
- 문법을 한 줄씩 풀면
- business_tx_id와 account_id FK가 부모 행을 가리키고 CHECK·UNIQUE가 원장 행의 범위를 제한한다.
- 실제 값 추적
- 1,000원 이체면 OUT 행 amount=1,000/signed=-1,000과 IN 행 amount=1,000/signed=+1,000처럼 서로 다른 entry_type이 생긴다.
- 정상 예
- 존재하지 않는 거래·계좌를 가리키거나 amount=0인 행은 DB가 거절한다.
- 반례·경계 예
- signed_amount가 양수/음수인지에 대한 CHECK는 이 V001에 없으므로 Java 규칙이 필요하다.
- 착각 방지
- UNIQUE는 같은 거래·계좌·entry_type 조합만 막고 모든 중복 업무를 막지 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 잔액 합계 대사를 자동 수행하지 않는다.
- 다음 코드와의 연결
- 다음 index가 계좌별 최신 원장 조회 순서를 돕는다.
코드 조각 4 · 계좌별 최신 원장 index
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
- 문법을 한 줄씩 풀면
- 복합 index는 account_id 뒤 created_at DESC, id DESC 순으로 저장 경로를 만든다.
- 실제 값 추적
- 같은 시각 원장 두 행은 더 큰 id가 먼저 오도록 두 번째 정렬 key가 있다.
- 정상 예
- WHERE account_id와 같은 ORDER BY를 쓰는 최신 조회에 맞는다.
- 반례·경계 예
- account_id 없이 전체 table을 정렬하면 이 index가 항상 최선이라고 보장할 수 없다.
- 착각 방지
- index가 SQL 결과 순서를 자동 적용하지 않으므로 ORDER BY는 query에 써야 한다.
- 이 블록이 하지 않는 일
- 이 조각은 새 원장 행을 만들지 않는다.
- 다음 코드와의 연결
- 마지막 table은 같은 요청 key의 중복 처리를 막는다.
코드 조각 5 · idempotency_request의 세 칸 UNIQUE
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);
- 문법을 한 줄씩 풀면
- scope·actor_id·idempotency_key 복합 UNIQUE가 같은 업무 범위와 사용자에서 같은 key 중복을 막는다.
- 실제 값 추적
- TRANSFER_API/customer-1/K1 조합은 한 번만 들어가며 다른 actor의 K1은 별도 행이 될 수 있다.
- 정상 예
- 요청 hash·상태·응답을 보관해 같은 key 재요청 판정을 위한 기반을 준다.
- 반례·경계 예
- key만 같고 scope나 actor가 다르면 허용된다. 전역 유일 key가 아니다.
- 착각 방지
- table이 존재한다고 replay 응답 로직까지 완성된 것은 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 request_hash 비교나 상태 전이를 구현하지 않는다.
- 다음 코드와의 연결
- 다음 파일 CoreSchemaIT가 이 migration 결과 중 table 이름 네 개만 직접 확인한다.
직접 다시 써보기
- 저장 경로
- src/main/resources/db/migration/V001__common.sql
- 전제조건
- PostgreSQL 문법과 Flyway migration 경로가 필요하며 같은 schema에 동명 table이 없어야 한다.
- 반드시 지킬 계약
- table 생성 순서 account → business_tx → ledger_entry → idempotency_request와 FK 선행 관계를 지킨다.
- 추천 입력 순서
- account/index → business_tx → ledger_entry/index → idempotency_request 순으로 입력한다.
- 자기 점검
- 네 CREATE TABLE, 두 CREATE INDEX, account·business_tx FK, 금액 CHECK, 세 UNIQUE 계약을 대조한다.
- 이번 파일의 범위 밖
- workbook의 business_tx(tx_id, failure_reason, occurred_at) schema와 같지 않으며 Q09/Q10은 별도 fixture에서 푼다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
CREATE TABLE account (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
account_no VARCHAR(32) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
balance BIGINT NOT NULL CHECK (balance >= 0),
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_account_owner_id ON account(owner_id, id);
CREATE TABLE business_tx (
id UUID PRIMARY KEY,
tx_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
correlation_id VARCHAR(64) NOT NULL UNIQUE,
requested_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ
);
CREATE TABLE ledger_entry (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_tx_id UUID NOT NULL REFERENCES business_tx(id),
account_id BIGINT NOT NULL REFERENCES account(id),
entry_type VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
signed_amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
reversal_of BIGINT REFERENCES ledger_entry(id),
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (business_tx_id, account_id, entry_type)
);
CREATE INDEX idx_ledger_account_created_id
ON ledger_entry(account_id, created_at DESC, id DESC);
CREATE TABLE idempotency_request (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
UNIQUE (scope, actor_id, idempotency_key)
);

7. CoreSchemaIT
한 문장 역할: Flyway 적용 뒤 public schema의 업무 base table 이름이 정확히 네 개인지 확인
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W9 토요일 exact selector com.example.financialcore.CoreSchemaIT |
| 무엇을 받나 | Spring Boot가 migration을 적용한 PostgreSQL information_schema |
| 무엇이 바뀌나 | 테스트 본문은 table을 바꾸지 않고 metadata를 조회한다 |
| 무엇을 돌려주나 | 네 table 이름의 순서 무관 exact assertion |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Test
void v001CreatesExactlyTheRequiredCoreTables() {
var tables = jdbc.sql("""
SELECT table_name FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'
AND table_name <> 'flyway_schema_history'
ORDER BY table_name
""").query(String.class).list();
assertThat(tables)
.as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
.containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
}
}
코드 조각 1 · metadata 시험 도구
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
- 문법을 한 줄씩 풀면
- JUnit·SpringBootTest·JdbcClient·AssertJ를 불러온다.
- 실제 값 추적
- 아직 information_schema를 읽지 않는다.
- 정상 예
- 실제 PostgreSQL metadata를 한 assertion으로 비교할 준비가 된다.
- 반례·경계 예
- H2 같은 다른 DB로 바꾸면 metadata 동작을 그대로 일반화할 수 없다.
- 착각 방지
- import 목록은 네 table이 실제 존재한다는 증거가 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 schema를 만들거나 바꾸지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 Spring context와 JdbcClient를 연결한다.
코드 조각 2 · migration이 적용된 Spring context
@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
- 문법을 한 줄씩 풀면
- @SpringBootTest가 애플리케이션 시작과 Flyway 적용을 포함하고 JdbcClient가 같은 DB를 읽는다.
- 실제 값 추적
- jdbc는 migration이 끝난 public schema를 바라본다.
- 정상 예
- 실제 시작 경로에서 생긴 table을 검사한다.
- 반례·경계 예
- mock JdbcClient로 table 이름을 미리 돌려주면 migration 검증이 아니다.
- 착각 방지
- context 시작 성공만으로 table 이름 네 개가 정확하다는 뜻은 아니다.
- 이 블록이 하지 않는 일
- 이 조각은 metadata SQL을 아직 실행하지 않는다.
- 다음 코드와의 연결
- 마지막 조각이 public base table 목록과 exact set을 비교한다.
코드 조각 3 · 네 table 이름만 정확히 확인
@Test
void v001CreatesExactlyTheRequiredCoreTables() {
var tables = jdbc.sql("""
SELECT table_name FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'
AND table_name <> 'flyway_schema_history'
ORDER BY table_name
""").query(String.class).list();
assertThat(tables)
.as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
.containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
}
}
- 문법을 한 줄씩 풀면
- text block SQL이 public BASE TABLE에서 Flyway history를 빼고 이름을 읽으며 containsExactlyInAnyOrder가 추가·누락 모두 거절한다.
- 실제 값 추적
- 조회 목록은 account, business_tx, idempotency_request, ledger_entry 네 값이고 정렬 순서와 무관하게 assertion을 통과한다.
- 정상 예
- 필수 table 누락뿐 아니라 예상 밖 다섯 번째 업무 table도 실패시킨다.
- 반례·경계 예
- index나 view는 BASE TABLE 조건 밖이므로 목록에 들어오지 않는다.
- 착각 방지
- 이 테스트가 V001의 모든 column·constraint·index를 보장한다고 넓히면 안 된다.
- 이 블록이 하지 않는 일
- 이 조각은 table 내부 데이터를 조회하지 않는다.
- 다음 코드와의 연결
- 다음은 여섯 실제 @Test를 Arrange-Act-Assert와 보장 경계로 다시 묶는다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/CoreSchemaIT.java
- 전제조건
- V001 migration과 PostgresIntegrationTestSupport가 정상 적용된 Spring context가 필요하다.
- 반드시 지킬 계약
- public BASE TABLE만 조회하고 flyway_schema_history를 제외한 뒤 네 이름을 exactly-in-any-order로 검사한다.
- 추천 입력 순서
- import → @SpringBootTest/class → JdbcClient 주입 → metadata query → 네 table assertion 순서다.
- 자기 점검
- @Test 1개, table_type 조건, flyway 제외, account/business_tx/ledger_entry/idempotency_request 네 이름을 확인한다.
- 이번 파일의 범위 밖
- column, FK, CHECK, UNIQUE, index의 존재와 내용은 직접 assert하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class CoreSchemaIT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Test
void v001CreatesExactlyTheRequiredCoreTables() {
var tables = jdbc.sql("""
SELECT table_name FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'
AND table_name <> 'flyway_schema_history'
ORDER BY table_name
""").query(String.class).list();
assertThat(tables)
.as("W6D1_RED_EXPECTED_FOUR_CORE_TABLES")
.containsExactlyInAnyOrder("account", "business_tx", "ledger_entry", "idempotency_request");
}
}
JUnit 여섯 메서드 · AAA와 보장 경계
아래 여섯 메서드는 월~토 selector가 실행하는 고유 @Test다. TransferFailurePointIT가 수요일과 금요일에 한 번씩 실행되므로 고유 메서드는 6개지만 실행 합계는 7회다.
transferBeanIsProxiedAndPublicMethodOwnsTheBoundary
월 · TransactionProxyIT · carried-selector
- 준비(Arrange)
- @SpringBootTest가 만든 context에서 TransferService bean을 @Autowired로 받는다.
- 행동(Act)
- AopUtils로 bean을 검사하고 reflection으로 public transfer(Command)의 @Transactional을 읽는다.
- 확인(Assert)
- proxy=true, annotation present=true 두 assertion이 모두 통과한다.
- 직접 보장
- 월요일 W9가 회수한 구조에서 bean proxy와 public transaction annotation이 실제 존재한다.
- 직접 보장하지 않음
- 실제 이체 rollback, advisor 순서, 어떤 PlatformTransactionManager가 선택됐는지는 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화 예라면 failure test와 transaction log를 함께 보되 이 @Test의 보장으로 합치지 않는다.
test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update
화 · LostUpdateBaselineIT · w9-direct
- 준비(Arrange)
- 10,000원 계좌, worker 2개, readBarrier(2), 각 worker의 REQUIRES_NEW를 준비한다.
- 행동(Act)
- 두 worker가 barrier 앞에서 같은 balance를 읽은 뒤 version 조건 없는 UPDATE로 각각 observed-1,000을 쓴다.
- 확인(Assert)
- Future 성공 수 2, 새 balance 조회 9,000, version 0을 차례로 assert한다.
- 직접 보장
- 이 test-only raw SQL 경로에서는 두 작업이 끝나도 한 번의 1,000원 변화가 사라지는 기준선을 재현한다.
- 직접 보장하지 않음
- 생산 JPA @Version 경로의 취약성, 모든 DB·격리수준, 평상시 재현 확률은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화 예라면 각 worker의 observed 값을 별도 수집해 [10000,10000]도 확인할 수 있다.
production_pessimistic_lock_path_commits_twice_and_preserves_both_updates
화 · LostUpdateBaselineIT · w9-direct
- 준비(Arrange)
- 10,000원 계좌, worker 2개, start latch(1), 독립 REQUIRES_NEW 두 개를 준비한다.
- 행동(Act)
- 각 worker가 SELECT ... FOR UPDATE로 같은 행을 잠근 뒤 balance-1,000과 version+1을 UPDATE한다.
- 확인(Assert)
- Future 성공 수 2와 최종 balance 8,000을 assert한다.
- 직접 보장
- 이 PostgreSQL fixture에서 row lock 경로가 두 번의 1,000원 변화를 모두 보존한다.
- 직접 보장하지 않음
- version=2, 잠금 공정성, deadlock 부재, 고부하 처리량은 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화 예라면 version=2를 별도 assert하되 현재 canonical 보장과 구분한다.
runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect
수·금 · TransferFailurePointIT · carried-selector
- 준비(Arrange)
- 10,000/5,000원 두 계좌와 invoked=false인 단일 after-business failure hook을 준비한다.
- 행동(Act)
- 1,000원 transfer를 호출해 업무 변경 뒤 hook이 RuntimeException을 던지게 한다.
- 확인(Assert)
- 예외 type/message, invoked=true, 최종 [10,000,5,000,TRANSFER 거래0,TRANSFER_ 원장0]을 assert한다.
- 직접 보장
- W7D5 stage snapshot의 단일 after-business 실패 지점에서 이 네 transfer 효과가 함께 rollback된다.
- 직접 보장하지 않음
- AFTER_CLAIM, 두 failure point, idempotency_request=0, checked exception은 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- PDF의 두-point 문구가 아니라 introducedAt이 가리키는 이 한 @Test를 canonical 경계로 삼는다.
requiresNewCommitsWhileTheOuterTransactionRollsBack
목 · TransactionPropagationIT · w9-direct
- 준비(Arrange)
- 0행 probe table과 서로 다른 Spring bean OuterProbe/InnerProbe를 준비한다.
- 행동(Act)
- outer가 OUTER_ROLLED_BACK을 INSERT하고 inner REQUIRES_NEW가 INNER_COMMITTED를 commit한 뒤 outer가 예외를 낸다.
- 확인(Assert)
- rollback outer 예외와 최종 label 목록 [INNER_COMMITTED]를 assert한다.
- 직접 보장
- 안쪽 새 transaction은 남고 바깥 transaction 행은 취소되는 부분 commit을 실제 DB에서 보인다.
- 직접 보장하지 않음
- 같은 class self-invocation, 감사 기록의 업무 타당성, 외부 시스템 원자성은 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화 예라면 bean 두 개의 AOP proxy 여부를 별도 구조 테스트로 확인할 수 있다.
v001CreatesExactlyTheRequiredCoreTables
토 · CoreSchemaIT · carried-selector
- 준비(Arrange)
- Spring Boot가 V001 migration을 적용한 실제 PostgreSQL public schema를 준비한다.
- 행동(Act)
- information_schema에서 Flyway history를 뺀 BASE TABLE 이름을 읽는다.
- 확인(Assert)
- account, business_tx, ledger_entry, idempotency_request를 추가·누락 없이 containsExactlyInAnyOrder로 확인한다.
- 직접 보장
- 토요일 selector 시점의 업무 base table 이름 집합이 정확히 네 개다.
- 직접 보장하지 않음
- 각 column, FK, CHECK, UNIQUE, index 내용은 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 원문 밖 강화 예라면 information_schema.columns와 pg_indexes를 별도 테스트로 추가한다.
SQL workbook · Q09/Q10
두 문제의 evidence 경로는 학습자가 만들 위치일 뿐 reference project의 canonical answer가 아니다. 앱 V001이 아니라 sql/workbook/fixtures/workbook_schema.sql의 business_tx(tx_id, failure_reason, occurred_at, ...)에서 실행한다.
Q09 · 표시값
NULL → NONECOALESCE(failure_reason, 'NONE')원래 NETWORK 문구는 그대로Q10 · 하루 3건
[07-01 00:00, 07-02 00:00)>= 시작 · < 다음 날 시작...203 포함 · ...204 제외Q09 · NULL failure_reason을 NONE으로 표시
source 경계: reference project에는 canonical learner answer 파일이 없다. 아래 코드는 PostgreSQL workbook schema와 seed를 만족하는 전체 예시 정답이다.
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | PostgreSQL workbook에서 Q09를 직접 실행하는 학습자 |
| 무엇을 받나 | workbook business_tx의 모든 거래 행과 nullable failure_reason |
| 무엇이 바뀌나 | DB 행은 바꾸지 않고 출력 표시값에서 NULL만 NONE으로 대체 |
| 무엇을 돌려주나 | seed 기준 21행 그대로: NULL 17개는 NONE, 실제 사유 4개는 원문 그대로인 tx_id·failure_reason_display 목록 |
SQL 조각 1 · 원래 값과 표시값 선택
SELECT tx_id,
COALESCE(failure_reason, 'NONE') AS failure_reason_display
- 문법을 한 줄씩 풀면
- COALESCE(a,b)는 a가 NULL이 아니면 a, NULL이면 b를 돌려주고 AS가 출력 열 이름을 붙인다.
- 실제 값 추적
- REQ-201의 NULL은 NONE, REQ-203의 INSUFFICIENT_BALANCE는 그대로 INSUFFICIENT_BALANCE다.
- 정상 예
- 실패 이유가 있는 행의 실제 문구를 잃지 않으면서 NULL 표시만 없앤다.
- 반례·경계 예
- COALESCE('NETWORK','NONE')는 NETWORK이므로 모든 값을 NONE으로 덮지 않는다.
- 착각 방지
- COALESCE는 table 값을 UPDATE하지 않고 SELECT 결과만 바꾼다.
- 이 블록이 하지 않는 일
- 빈 문자열 ''을 NULL로 바꾸거나 번역하지 않는다.
- 다음 코드와의 연결
- 다음 조각에서 시작 table과 안정된 출력 순서를 고정한다.
SQL 조각 2 · business_tx 한 행씩 읽기
FROM business_tx
ORDER BY tx_id;
- 문법을 한 줄씩 풀면
- FROM은 입력 grain을 정하고 ORDER BY는 결과 표시 순서를 tx_id로 고정한다.
- 실제 값 추적
- seed의 business_tx 각 행이 출력 한 행이므로 JOIN·GROUP BY가 없고 행 수가 그대로 유지된다.
- 정상 예
- NULL과 non-NULL 행이 섞여도 모든 거래를 한 번씩 볼 수 있다.
- 반례·경계 예
- WHERE failure_reason IS NOT NULL을 넣으면 NULL 행을 NONE으로 보여 주라는 요구를 피해 버린다.
- 착각 방지
- 앱 V001 business_tx에는 failure_reason이 없으므로 workbook fixture에서 실행해야 한다.
- 이 블록이 하지 않는 일
- 업무 거래를 status별로 집계하지 않는다.
- 다음 코드와의 연결
- 직접 다시 쓴 뒤 전체 예시 정답과 들여쓰기를 대조한다.
직접 다시 써보기
- 저장 경로
- evidence/w9/sql-q09.sql
- 전제조건
- sql/workbook/fixtures/workbook_schema.sql과 V900 seed를 PostgreSQL에 적용한다.
- 반드시 지킬 계약
- FROM business_tx, COALESCE(failure_reason,'NONE'), 전체 행 유지, 안정된 ORDER BY를 포함한다.
- 추천 입력 순서
- grain 주석 → SELECT tx_id/COALESCE → FROM → ORDER BY 순서다.
- 자기 점검
- 출력 failure_reason_display의 NULL이 0건이고 실제 NETWORK/INSUFFICIENT_BALANCE는 유지되는지 확인한다.
- 이번 파일의 범위 밖
- 빈 문자열 정리, 상태별 집계, 앱 V001 migration 수정은 이 문제 범위가 아니다.
전체 예시 정답 · canonical 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 전체 예시 정답: reference project에는 Q09 canonical learner answer 파일이 없다.
-- 입력 grain: business_tx 한 행 = 한 업무 거래
-- 출력 grain: 업무 거래 한 행과 NULL 없는 실패 사유 표시값
SELECT tx_id,
COALESCE(failure_reason, 'NONE') AS failure_reason_display
FROM business_tx
ORDER BY tx_id;
Q10 · business_tx의 2026-07-01 하루 거래 3건
source 경계: reference project에는 canonical learner answer 파일이 없다. 아래 코드는 PostgreSQL workbook schema와 seed를 만족하는 전체 예시 정답이다.
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | PostgreSQL workbook에서 Q10을 직접 실행하는 학습자 |
| 무엇을 받나 | workbook business_tx의 occurred_at TIMESTAMPTZ와 Asia/Seoul 하루 경계 |
| 무엇이 바뀌나 | DB 행은 바꾸지 않고 반개구간에 속하는 거래만 거른다 |
| 무엇을 돌려주나 | 2026-07-01 00:00 포함, 2026-07-02 00:00 제외인 3행 |
SQL 조각 1 · 출력 열과 시작 table
SELECT tx_id, status, occurred_at
FROM business_tx
- 문법을 한 줄씩 풀면
- SELECT는 확인할 세 열을 고르고 FROM business_tx가 거래 한 행을 입력 한 행으로 정한다.
- 실제 값 추적
- seed의 REQ-201, 202, 203은 각각 00:00, 12:00, 23:59:59.999에 있다.
- 정상 예
- 거래 ID·상태·시각을 함께 보여 어떤 세 행인지 눈으로 검증할 수 있다.
- 반례·경계 예
- business_date만 고르면 시각 경계 연습이라는 핵심을 건너뛴다.
- 착각 방지
- 앱 V001 business_tx의 requested_at과 workbook occurred_at은 다른 column 계약이다.
- 이 블록이 하지 않는 일
- 아직 날짜 조건을 적용하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 7월 1일 00:00을 포함하는 아래 경계를 건다.
SQL 조각 2 · 포함하는 시작 경계
WHERE occurred_at >= TIMESTAMPTZ '2026-07-01 00:00:00+09'
- 문법을 한 줄씩 풀면
- >=는 시작 순간을 포함하고 TIMESTAMPTZ 리터럴의 +09가 Asia/Seoul 기준 시각을 명시한다.
- 실제 값 추적
- 정확히 2026-07-01 00:00:00+09인 REQ-201이 포함된다.
- 정상 예
- 하루의 첫 순간을 빠뜨리지 않는다.
- 반례·경계 예
- >를 쓰면 정확히 00:00인 첫 거래가 제외된다.
- 착각 방지
- 서버 기본 timezone에 맡기지 않고 리터럴에 +09를 쓴다.
- 이 블록이 하지 않는 일
- 이 조건 하나만으로 다음 날 이후를 막지 않는다.
- 다음 코드와의 연결
- 다음 조각이 7월 2일 00:00을 제외하는 위 경계와 순서를 더한다.
SQL 조각 3 · 제외하는 다음 날 경계와 정렬
AND occurred_at < TIMESTAMPTZ '2026-07-02 00:00:00+09'
ORDER BY occurred_at, tx_id;
- 문법을 한 줄씩 풀면
- <는 다음 날 시작을 제외해 [시작,끝) 반개구간을 만들고 두 열 ORDER BY가 동률도 고정한다.
- 실제 값 추적
- 23:59:59.999의 REQ-203은 포함되고 정확히 7월 2일 00:00의 REQ-204는 제외되어 3행이다.
- 정상 예
- fractional second가 아무리 촘촘해도 다음 날 시작 전이면 모두 포함한다.
- 반례·경계 예
- <= '2026-07-01 23:59:59'는 .999 행을 놓치고 timestamp 정밀도에 의존한다.
- 착각 방지
- 23:59:59를 하루 끝으로 하드코딩하지 않는다.
- 이 블록이 하지 않는 일
- 다른 timezone의 달력 날짜 의미를 자동 변환하지 않는다.
- 다음 코드와의 연결
- 직접 다시 쓴 뒤 전체 예시 정답에서 두 경계와 +09를 대조한다.
직접 다시 써보기
- 저장 경로
- evidence/w9/sql-q10.sql
- 전제조건
- workbook schema/seed와 TIMESTAMPTZ 비교가 가능한 PostgreSQL이 필요하다.
- 반드시 지킬 계약
- business_tx, +09 시작 포함, 다음 날 +09 시작 미포함, 23:59:59 하드코딩 금지, 예상 3행을 지킨다.
- 추천 입력 순서
- grain 주석 → SELECT/FROM → >= 아래 경계 → < 위 경계 → ORDER BY 순서다.
- 자기 점검
- REQ-201/202/203 포함, REQ-204 제외와 총 3행을 확인한다.
- 이번 파일의 범위 밖
- UTC 날짜 집계, 사용자별 timezone 변환, business_date column 비교는 이 문제 범위가 아니다.
전체 예시 정답 · canonical 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 전체 예시 정답: reference project에는 Q10 canonical learner answer 파일이 없다.
-- 입력 grain: business_tx 한 행 = 한 업무 거래
-- 출력 grain: Asia/Seoul 기준 2026-07-01에 발생한 거래 한 행, 예상 3행
SELECT tx_id, status, occurred_at
FROM business_tx
WHERE occurred_at >= TIMESTAMPTZ '2026-07-01 00:00:00+09'
AND occurred_at < TIMESTAMPTZ '2026-07-02 00:00:00+09'
ORDER BY occurred_at, tx_id;
마지막 재점검
원문과 답안: source 7개는 원문과 전체 정답이 byte-normalized exact다. Q09/Q10은 canonical이 없어 예시 정답으로만 표시한다.
테스트 문장: proxy 구조, lost update 9,000/version0, 잠금 8,000, 단일 rollback [10,000,5,000,0,0], inner-only commit, 네 table 이름까지만 직접 보장한다.
다음 주 연결: 같은 두 계좌를 반대 방향으로 잡는 요청이 겹치면 잠금 순서와 deadlock 회피가 다음 질문이 된다.