9주차 코드 뒤풀이

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
TransactionProxyIT1TransferService + TransactionProxyIT
LostUpdateBaselineIT2LostUpdateBaselineIT
TransferFailurePointIT1TransferService + TransferFailurePointIT
TransactionPropagationIT1TransactionPropagationIT
TransferFailurePointIT1 (수요일과 같은 @Test 재실행)TransferService + TransferFailurePointIT
CoreSchemaIT1V001__common.sql + CoreSchemaIT
두 worker가 모두 10,000원을 읽고 각각 9,000원을 쓰면 commit은 두 번이어도 최종 잔액은 9,000원이다.
두 worker가 모두 10,000원을 읽고 각각 9,000원을 쓰면 commit은 두 번이어도 최종 잔액은 9,000원이다.

1. TransferService

누적 canonical 운영 소스월·수·금src/main/java/com/example/financialcore/transfer/TransferService.javaSHA-256 e44994db58afe3ba53008de7dda7116881de1caa81553505af13571ad3273ec6

한 문장 역할: 한 transaction 안에서 두 계좌, 거래 한 행, 원장 두 행을 함께 바꾸는 이체 서비스

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나HTTP controller와 W9의 TransactionProxyIT·TransferFailurePointIT
무엇을 받나actorId·transactionId·출금/입금 계좌 ID·양수 amount를 담은 Command
무엇이 바뀌나두 계좌 잔액, business_tx 한 행, ledger_entry 두 행
무엇을 돌려주나거래 ID와 변경 뒤 두 잔액을 담은 Result

정확한 전체 원문

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

import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

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

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

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

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

    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
        if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
        if (command.amount() <= 0) throw new IllegalArgumentException("amount");
    }
}

코드 조각 1 · 주소와 필요한 부품

해설 바로 앞 원문 조각 · java
package com.example.financialcore.transfer;

import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;
문법을 한 줄씩 풀면
package는 클래스의 전체 주소이고 import는 짧은 이름을 허용한다. import 자체는 객체를 만들거나 SQL을 실행하지 않는다.
실제 값 추적
아직 잔액은 움직이지 않는다. Account·거래·원장 repository와 transaction annotation을 쓸 이름만 준비한다.
정상 예
각 import가 실제 아래 코드에서 한 번 이상 쓰이는 상태다.
반례·경계 예
사용하지 않는 import가 남아 있으면 읽는 사람이 실제 의존성으로 오해할 수 있다.
착각 방지
import가 많다고 여러 transaction이 생기는 것은 아니다.
이 블록이 하지 않는 일
이 조각은 이체 규칙이나 DB 변경을 실행하지 않는다.
다음 코드와의 연결
다음 조각에서 서비스와 입력·출력 모양을 선언한다.

코드 조각 2 · 서비스와 입력·출력 record

해설 바로 앞 원문 조각 · java

@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

해설 바로 앞 원문 조각 · java

    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 시작과 정렬 잠금

해설 바로 앞 원문 조각 · java

    @Transactional
    public Result transfer(Command command) {
        validate(command);
        var locked = accounts.findAllForUpdateOrderById(
            List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList());
        if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
문법을 한 줄씩 풀면
@Transactional은 Spring proxy를 통해 public 메서드가 호출될 때 하나의 transaction 경계를 만든다. sorted는 두 ID를 작은 것부터 고정한다.
실제 값 추적
from=8, to=3이면 조회 인자는 [3, 8]이 된다. 두 행을 잠근 뒤 정확히 두 행인지 확인한다.
정상 예
모든 이체가 계좌 ID 오름차순으로 잠그면 서로 반대 방향 이체도 같은 순서로 열쇠를 잡는다.
반례·경계 예
같은 ID를 두 번 넣으면 결과 행은 두 개가 아니므로 ACCOUNT_NOT_FOUND 전에 validate의 same account가 먼저 막는다.
착각 방지
정렬은 돈의 방향을 바꾸지 않는다. 잠금 순서만 바꾼다.
이 블록이 하지 않는 일
이 조각은 아직 어느 계좌가 출금 계좌인지 결정하거나 잔액을 바꾸지 않는다.
다음 코드와의 연결
다음 조각에서 잠근 두 행을 ID로 다시 찾고 소유자를 검사한다.

코드 조각 5 · 잠긴 행을 역할에 맞게 되찾기

해설 바로 앞 원문 조각 · java
        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 · 잔액·거래·원장과 실패 지점

해설 바로 앞 원문 조각 · java
        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 · 입력 다섯 가지 선검사

해설 바로 앞 원문 조각 · java

    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
        if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
        if (command.amount() <= 0) throw new IllegalArgumentException("amount");
    }
}
문법을 한 줄씩 풀면
각 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 예외 때 모두 원복되는지 확인한다.
이번 파일의 범위 밖
재시도 멱등성, 외부 메시지 발행, 환율, 수수료 계산은 이 파일이 맡지 않는다.

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

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

import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountRepository;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
import com.example.financialcore.ledger.BusinessTransaction;
import com.example.financialcore.ledger.BusinessTransactionRepository;
import com.example.financialcore.ledger.LedgerEntry;
import com.example.financialcore.ledger.LedgerEntryRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

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

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

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

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

    private static void validate(Command command) {
        if (command.actorId() == null || command.actorId().isBlank()) throw new IllegalArgumentException("actorId");
        if (command.transactionId() == null || command.transactionId().isBlank()) throw new IllegalArgumentException("transactionId");
        if (command.fromAccountId() <= 0 || command.toAccountId() <= 0) throw new IllegalArgumentException("account id");
        if (command.fromAccountId() == command.toAccountId()) throw new IllegalArgumentException("same account");
        if (command.amount() <= 0) throw new IllegalArgumentException("amount");
    }
}

2. TransactionProxyIT

월요일 selector 지원/JUnit 원문src/test/java/com/example/financialcore/transfer/TransactionProxyIT.javaSHA-256 cc0d773a21127c04fa455a12f46dcc510f2521b963f141966d937f3a6c488c8f

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

정확한 전체 원문

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

import com.example.financialcore.PostgresIntegrationTestSupport;

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

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

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

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

코드 조각 1 · 테스트 도구 import

해설 바로 앞 원문 조각 · java
package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;

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

import static org.assertj.core.api.Assertions.assertThat;
문법을 한 줄씩 풀면
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 주입

해설 바로 앞 원문 조각 · java

@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

해설 바로 앞 원문 조각 · java
    @Test
    void transferBeanIsProxiedAndPublicMethodOwnsTheBoundary() throws Exception {
        assertThat(AopUtils.isAopProxy(transfers)).isTrue();
        assertThat(TransferService.class.getMethod("transfer", TransferService.Command.class)
            .isAnnotationPresent(Transactional.class)).isTrue();
    }
}
문법을 한 줄씩 풀면
AopUtils.isAopProxy는 주입 객체를 보고, 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 종류는 이 테스트가 직접 실행하지 않는다.

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

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

import com.example.financialcore.PostgresIntegrationTestSupport;

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

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

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

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

3. LostUpdateBaselineIT

화요일 W9 신규 지원/JUnit 원문src/test/java/com/example/financialcore/account/LostUpdateBaselineIT.javaSHA-256 61b1f145bc0427a3f4fa4f8b8108e01da54aacf1b8a9b85d3784ae536b64db24

한 문장 역할: 잠금 없는 의도적 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 결과

정확한 전체 원문

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

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

해설 바로 앞 원문 조각 · java

@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

해설 바로 앞 원문 조각 · java

    @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 쓰기

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

해설 바로 앞 원문 조각 · java
            }
            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 · 잠금 있는 시험의 동시 시작

해설 바로 앞 원문 조각 · java

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

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

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

해설 바로 앞 원문 조각 · java

    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 재조회

해설 바로 앞 원문 조각 · java

    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 대기 실패를 숨기지 않기

해설 바로 앞 원문 조각 · java

    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 생산 경로는 보장하지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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);
        }
    }
}
잠금 없음은 최종 9,000원, FOR UPDATE 경로는 최종 8,000원이다.
잠금 없음은 최종 9,000원, FOR UPDATE 경로는 최종 8,000원이다.

4. TransferFailurePointIT

수·금 selector 지원/JUnit 원문수·금src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.javaSHA-256 b1e66ae8ae82ca562af73dc364e14a98ec8c0da16fbb8f49acc9734bc8ae79c5

한 문장 역할: 업무 변경 뒤 단일 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

정확한 전체 원문

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

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

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

@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired TransferService transfers;
    @Autowired AtomicBoolean invoked;

    Account from;
    Account to;

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

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

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

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

코드 조각 1 · 통합 시험과 실패 주입 도구

해설 바로 앞 원문 조각 · java
package com.example.financialcore.transfer;

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
문법을 한 줄씩 풀면
SpringBootTest·Import·TestConfiguration은 실제 context에 테스트 전용 bean을 추가하고 AssertJ가 예외와 값을 검사한다.
실제 값 추적
아직 계좌·거래 행은 없다. AtomicBoolean과 List도 이름만 준비한다.
정상 예
실패를 일으키는 bean과 최종 DB를 읽는 JdbcClient를 한 테스트에서 함께 쓸 수 있다.
반례·경계 예
assertThatThrownBy만 있고 최종 상태 조회가 없으면 rollback 범위를 알 수 없다.
착각 방지
TestConfiguration은 운영 설정 파일을 영구 수정하지 않는다.
이 블록이 하지 않는 일
이 조각은 hook을 만들거나 이체를 호출하지 않는다.
다음 코드와의 연결
다음 조각이 테스트 설정을 context에 연결하고 필요한 bean을 주입한다.

코드 조각 2 · 실패 설정 연결과 관찰 대상

해설 바로 앞 원문 조각 · java

@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 · 매번 같은 두 계좌 준비

해설 바로 앞 원문 조각 · java

    @BeforeEach void clean() {
        invoked.set(false);
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        from = openings.open("customer-1", "FAIL-FROM", 10_000);
        to = openings.open("customer-2", "FAIL-TO", 5_000);
    }
문법을 한 줄씩 풀면
@BeforeEach에서 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 확인

해설 바로 앞 원문 조각 · java

    @Test
    void runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect() {
        assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
            "customer-1", "W7-FAIL", from.getId(), to.getId(), 1_000)))
            .isInstanceOf(RuntimeException.class).hasMessage("injected after business mutation");
        assertThat(invoked).isTrue();
문법을 한 줄씩 풀면
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]

해설 바로 앞 원문 조각 · java
        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별 잔액 재조회

해설 바로 앞 원문 조각 · java

    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

해설 바로 앞 원문 조각 · java

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
        @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
            return () -> {
                invoked.set(true);
                throw new RuntimeException("injected after business mutation");
            };
        }
    }
}
문법을 한 줄씩 풀면
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은 보장하지 않는다.

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

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

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;

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

@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired TransferService transfers;
    @Autowired AtomicBoolean invoked;

    Account from;
    Account to;

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

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

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

    @TestConfiguration(proxyBeanMethods = false)
    static class FailureConfiguration {
        @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
        @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
            return () -> {
                invoked.set(true);
                throw new RuntimeException("injected after business mutation");
            };
        }
    }
}
canonical W7D5 test는 업무 변경 뒤 단일 실패 지점과 네 최종 값만 확인한다.

5. TransactionPropagationIT

목요일 W9 신규 지원/JUnit 원문src/test/java/com/example/financialcore/transfer/TransactionPropagationIT.javaSHA-256 db4454ca3ad2601e55857df977c5bc6d51a5251517a4aca8acc544613b821a6d

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

정확한 전체 원문

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

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

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

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

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

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

해설 바로 앞 원문 조각 · java

    @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 원자성은 보장하지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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");
    }
}
안쪽 새 transaction은 먼저 commit되고 바깥 transaction 행만 rollback된다.
안쪽 새 transaction은 먼저 commit되고 바깥 transaction 행만 rollback된다.

6. V001__common

토요일 누적 canonical migrationsrc/main/resources/db/migration/V001__common.sqlSHA-256 4e795fd45ce36852480abf25b941b84541e5e200229044469b5d9a7c7a18c222

한 문장 역할: 계좌·업무 거래·원장·멱등 요청 네 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 구조

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · sql
CREATE TABLE account (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    owner_id VARCHAR(64) NOT NULL,
    account_no VARCHAR(32) NOT NULL UNIQUE,
    status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
    currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
    balance BIGINT NOT NULL CHECK (balance >= 0),
    version BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_account_owner_id ON account(owner_id, id);

CREATE TABLE business_tx (
    id UUID PRIMARY KEY,
    tx_type VARCHAR(32) NOT NULL,
    status VARCHAR(16) NOT NULL,
    correlation_id VARCHAR(64) NOT NULL UNIQUE,
    requested_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ
);

CREATE TABLE ledger_entry (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    business_tx_id UUID NOT NULL REFERENCES business_tx(id),
    account_id BIGINT NOT NULL REFERENCES account(id),
    entry_type VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    signed_amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
    reversal_of BIGINT REFERENCES ledger_entry(id),
    created_at TIMESTAMPTZ NOT NULL,
    UNIQUE (business_tx_id, account_id, entry_type)
);

CREATE INDEX idx_ledger_account_created_id
    ON ledger_entry(account_id, created_at DESC, id DESC);

CREATE TABLE idempotency_request (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    scope VARCHAR(64) NOT NULL,
    actor_id VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL,
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    UNIQUE (scope, actor_id, idempotency_key)
);

코드 조각 1 · account와 소유자 조회 index

해설 바로 앞 원문 조각 · sql
CREATE TABLE account (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    owner_id VARCHAR(64) NOT NULL,
    account_no VARCHAR(32) NOT NULL UNIQUE,
    status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
    currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
    balance BIGINT NOT NULL CHECK (balance >= 0),
    version BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_account_owner_id ON account(owner_id, id);
문법을 한 줄씩 풀면
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 업무 사건

해설 바로 앞 원문 조각 · sql

CREATE TABLE business_tx (
    id UUID PRIMARY KEY,
    tx_type VARCHAR(32) NOT NULL,
    status VARCHAR(16) NOT NULL,
    correlation_id VARCHAR(64) NOT NULL UNIQUE,
    requested_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ
);
문법을 한 줄씩 풀면
UUID 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와 금액 제약

해설 바로 앞 원문 조각 · sql

CREATE TABLE ledger_entry (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    business_tx_id UUID NOT NULL REFERENCES business_tx(id),
    account_id BIGINT NOT NULL REFERENCES account(id),
    entry_type VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    signed_amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
    reversal_of BIGINT REFERENCES ledger_entry(id),
    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

해설 바로 앞 원문 조각 · sql

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

해설 바로 앞 원문 조각 · sql

CREATE TABLE idempotency_request (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    scope VARCHAR(64) NOT NULL,
    actor_id VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL,
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    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에서 푼다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · sql
CREATE TABLE account (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    owner_id VARCHAR(64) NOT NULL,
    account_no VARCHAR(32) NOT NULL UNIQUE,
    status VARCHAR(16) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
    currency VARCHAR(3) NOT NULL CHECK (currency = 'KRW'),
    balance BIGINT NOT NULL CHECK (balance >= 0),
    version BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_account_owner_id ON account(owner_id, id);

CREATE TABLE business_tx (
    id UUID PRIMARY KEY,
    tx_type VARCHAR(32) NOT NULL,
    status VARCHAR(16) NOT NULL,
    correlation_id VARCHAR(64) NOT NULL UNIQUE,
    requested_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ
);

CREATE TABLE ledger_entry (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    business_tx_id UUID NOT NULL REFERENCES business_tx(id),
    account_id BIGINT NOT NULL REFERENCES account(id),
    entry_type VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    signed_amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL CHECK (balance_after >= 0),
    reversal_of BIGINT REFERENCES ledger_entry(id),
    created_at TIMESTAMPTZ NOT NULL,
    UNIQUE (business_tx_id, account_id, entry_type)
);

CREATE INDEX idx_ledger_account_created_id
    ON ledger_entry(account_id, created_at DESC, id DESC);

CREATE TABLE idempotency_request (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    scope VARCHAR(64) NOT NULL,
    actor_id VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL,
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    UNIQUE (scope, actor_id, idempotency_key)
);
V001의 table·제약은 전부 성공, 규칙 유지, 간섭 통제, 완료 뒤 보존을 위한 바닥을 만든다.
V001의 table·제약은 전부 성공, 규칙 유지, 간섭 통제, 완료 뒤 보존을 위한 바닥을 만든다.

7. CoreSchemaIT

토요일 selector 지원/JUnit 원문src/test/java/com/example/financialcore/CoreSchemaIT.javaSHA-256 30d21e5fd028dab32aa797263a8b2793a8e397ce663aaef08000304e70cbbb48

한 문장 역할: Flyway 적용 뒤 public schema의 업무 base table 이름이 정확히 네 개인지 확인

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나W9 토요일 exact selector com.example.financialcore.CoreSchemaIT
무엇을 받나Spring Boot가 migration을 적용한 PostgreSQL information_schema
무엇이 바뀌나테스트 본문은 table을 바꾸지 않고 metadata를 조회한다
무엇을 돌려주나네 table 이름의 순서 무관 exact assertion

정확한 전체 원문

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

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

해설 바로 앞 원문 조각 · java

@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 이름만 정확히 확인

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

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · java
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.sqlbusiness_tx(tx_id, failure_reason, occurred_at, ...)에서 실행한다.

Q09는 NULL만 NONE으로 표시하고, Q10은 시작을 포함하고 다음 날 시작을 제외해 7월 1일 세 거래를 고른다.

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 · 원래 값과 표시값 선택

해설 바로 앞 SQL 조각 · sql
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 한 행씩 읽기

해설 바로 앞 SQL 조각 · sql
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 부재를 구분

직접 쓴 뒤 전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 전체 예시 정답: 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

해설 바로 앞 SQL 조각 · sql
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 · 포함하는 시작 경계

해설 바로 앞 SQL 조각 · sql
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 · 제외하는 다음 날 경계와 정렬

해설 바로 앞 SQL 조각 · sql
  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 부재를 구분

직접 쓴 뒤 전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 전체 예시 정답: 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 회피가 다음 질문이 된다.