10주차 코드 뒤풀이

WEEK 10 · CODE AFTERPARTY

W10 코드 뒤풀이 · 기다릴까, 충돌로 멈출까

학습 범위: 월요일부터 토요일까지 · exact source 6개 · 고유 @Test 4개 · day-run 6회 · SQL Q11/Q12 전체 예시 정답

먼저 잡는 전체 실행 지도

W10에 새로 생긴 원문은 통합 테스트 2파일이다. 그러나 월~토 selector를 설명하려면 W9의 비교 시험과 W6/W7 production 정본, 실제 @Version 선언까지 함께 읽어야 한다.

W10 신규 · 2파일 / @Test 2개

이번 주에 새로 생긴 원문

SortedLockTransferIT · OptimisticAccountIT

이번 주 누적 정본·지원 · 4파일 / carried @Test 2개

이전 단계에서 가져와 함께 읽는 원문

LostUpdateBaselineIT · AccountRepository · TransferService · Account

요일exact selector실행 @Test읽을 source
LostUpdateBaselineIT.test_only_versionless...1LostUpdateBaselineIT
LostUpdateBaselineIT.production_pessimistic...1LostUpdateBaselineIT + AccountRepository
SortedLockTransferIT1SortedLockTransferIT + AccountRepository + TransferService
SortedLockTransferIT1 (같은 메서드 재실행)SortedLockTransferIT + AccountRepository
SortedLockTransferIT1 (같은 메서드 재실행)SortedLockTransferIT + TransferService
OptimisticAccountIT1OptimisticAccountIT + Account

W10 신규 원문 · 2파일

1. SortedLockTransferIT

W10 신규 · 수요일 제공 통합 테스트수·목·금 selector가 같은 @Test를 재실행src/test/java/com/example/financialcore/transfer/SortedLockTransferIT.javaSHA-256 30d5696d56f0e6bbc3207386b65a09941b75dd9a82ecdbb64e8231668813a810

한 문장 역할: 서로 반대 방향인 20개 이체가 제한 시간 안에 끝나고 전체 돈과 원장 signed 합이 보존되는지 확인

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나W10 수·목·금 exact selector com.example.financialcore.transfer.SortedLockTransferIT
무엇을 받나각 10,000원인 두 계좌, A→B 10건과 B→A 10건, 건당 100원
무엇이 바뀌나20개 TransferService.transfer가 잔액·거래·원장을 각 transaction에서 변경
무엇을 돌려주나모든 Future 완료, 두 잔액 합 20,000, 이체 원장 signed 합 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 com.example.financialcore.account.AccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;

import java.util.ArrayList;
import java.util.List;
import java.util.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 SortedLockTransferIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired TransferService transfers;
    Account first;
    Account second;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        first = openings.open("customer-1", "SORT-A", 10_000);
        second = openings.open("customer-1", "SORT-B", 10_000);
    }

    @Test
    void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {
        int pairs = 10;
        int tasks = pairs * 2;
        var ready = new CountDownLatch(tasks);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(tasks);
        try {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < pairs; i++) {
                int sequence = i;
                futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,
                    first.getId(), second.getId())));
                futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,
                    second.getId(), first.getId())));
            }
            assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);
            long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();
            long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();
            assertThat(firstBalance + secondBalance).isEqualTo(20_000);
            assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
                .query(Long.class).single()).isZero();
        } finally {
            start.countDown();
            pool.shutdownNow();
        }
    }

    private Object invoke(
        CountDownLatch ready, CountDownLatch start, String key, long from, long to
    ) {
        try {
            ready.countDown();
            start.await();
            return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(interrupted);
        }
    }
}

코드 조각 1 · 주소와 Spring 시험 도구

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

import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.account.Account;
import com.example.financialcore.account.AccountOpeningService;
import com.example.financialcore.account.AccountRepository;
import 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;

한 줄 읽기: 이 파일이 실제 Spring/PostgreSQL 통합 시험임을 선언한다.

문법을 한 줄씩 풀면
package는 주소이고 import는 Account, repository, JUnit, Spring 이름을 짧게 쓰게 한다.
실제 값 추적
아직 계좌는 0개이고 잠금도 0개다.
정상 예
정상 흐름에서는 아직 계좌는 0개이고 잠금도 0개다.
반례·경계 예
import를 지워 실제 아래 타입 이름이 해석되지 않으면 compile 단계에서 멈춘다.
착각 방지
@SpringBootTest import만으로 context가 시작된다고 보면 안 된다.
이 블록이 하지 않는 일
DB를 조회하거나 이체를 실행하지 않는다.
다음 코드와의 연결
다음 조각이 JDBC와 동시 실행 도구를 준비한다.

코드 조각 2 · JDBC·동시 실행·assert 도구

해설 바로 앞 원문 조각 · java
import org.springframework.jdbc.core.simple.JdbcClient;

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;

한 줄 읽기: 스레드 20개의 출발과 완료를 관찰할 도구를 모은다.

문법을 한 줄씩 풀면
CountDownLatch는 출발 신호, Future는 worker 결과, TimeUnit은 제한 시간을 표현한다.
실제 값 추적
이 시점의 Future 수는 0이고 latch도 아직 만들어지지 않았다.
정상 예
정상 흐름에서는 이 시점의 Future 수는 0이고 latch도 아직 만들어지지 않았다.
반례·경계 예
Future를 저장하지 않으면 worker 안 예외를 메인 테스트가 놓칠 수 있다.
착각 방지
latch가 DB row lock을 대신한다고 착각하면 안 된다.
이 블록이 하지 않는 일
잠금 순서를 정하거나 timeout을 시작하지 않는다.
다음 코드와의 연결
다음 조각이 실제 Spring bean과 두 계좌 필드를 선언한다.

코드 조각 3 · context와 네 관찰 대상

해설 바로 앞 원문 조각 · java

@SpringBootTest
class SortedLockTransferIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired TransferService transfers;
    Account first;
    Account second;

한 줄 읽기: 실제 bean 네 개와 나중에 채울 계좌 두 개를 필드로 둔다.

문법을 한 줄씩 풀면
@Autowired는 context의 JdbcClient, 개설 서비스, repository, 이체 서비스를 주입한다.
실제 값 추적
fixture 전에는 firstsecond가 아직 비어 있다.
정상 예
정상 흐름에서는 fixture 전에는 firstsecond가 아직 비어 있다.
반례·경계 예
new TransferService(...)로 바꾸면 Spring transaction proxy를 거치지 않는다.
착각 방지
필드가 많다고 여러 transaction이 자동으로 열리는 것은 아니다.
이 블록이 하지 않는 일
아직 어떤 메서드도 호출하지 않는다.
다음 코드와의 연결
다음 조각이 매 시험을 10,000원 두 계좌에서 시작시킨다.

코드 조각 4 · 20,000원 출발점

해설 바로 앞 원문 조각 · java

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        first = openings.open("customer-1", "SORT-A", 10_000);
        second = openings.open("customer-1", "SORT-B", 10_000);
    }

한 줄 읽기: 관련 네 table을 비우고 같은 고객의 두 계좌를 각각 10,000원으로 연다.

문법을 한 줄씩 풀면
@BeforeEach는 단일 @Test 실행 직전에 fixture를 재설정한다.
실제 값 추적
first=10,000, second=10,000, 거래0, 원장0이라 전체 돈은 20,000이다.
정상 예
정상 흐름에서는 first=10,000, second=10,000, 거래0, 원장0이라 전체 돈은 20,000이다.
반례·경계 예
TRUNCATE 없이 재실행하면 이전 거래와 잔액이 불변식 계산에 섞인다.
착각 방지
SORT-A/B라는 번호가 정렬 잠금을 만드는 것은 아니다.
이 블록이 하지 않는 일
동시 이체를 시작하지 않는다.
다음 코드와의 연결
다음 조각이 반대 방향 작업 수와 출발 장치를 만든다.

코드 조각 5 · 10쌍·20작업 준비

해설 바로 앞 원문 조각 · java

    @Test
    void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {
        int pairs = 10;
        int tasks = pairs * 2;
        var ready = new CountDownLatch(tasks);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(tasks);
        try {

한 줄 읽기: 반대 방향 이체 10쌍을 동시에 출발시킬 공간을 만든다.

문법을 한 줄씩 풀면
tasks=pairs*2이고 ready(20)는 모든 worker 준비, start(1)는 한 번의 출발 신호다.
실제 값 추적
pairs=10, tasks=20, pool thread=20, ready count=20, start count=1이다.
정상 예
정상 흐름에서는 pairs=10, tasks=20, pool thread=20, ready count=20, start count=1이다.
반례·경계 예
pool이 1칸이면 첫 worker가 start를 기다리는 동안 나머지가 ready에 도달하지 못할 수 있다.
착각 방지
스레드 20개를 만들었다고 실제 deadlock을 재현한 것은 아니다.
이 블록이 하지 않는 일
아직 start를 0으로 내리지 않는다.
다음 코드와의 연결
다음 조각이 A→B와 B→A Future를 정확히 10개씩 등록한다.

코드 조각 6 · A→B 10개와 B→A 10개

해설 바로 앞 원문 조각 · java
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < pairs; i++) {
                int sequence = i;
                futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,
                    first.getId(), second.getId())));
                futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,
                    second.getId(), first.getId())));
            }
            assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();

한 줄 읽기: 매 반복에서 방향이 반대인 Future 두 개를 만든다.

문법을 한 줄씩 풀면
sequence는 lambda가 쓸 고유 key를 고정하고 submit은 결과를 Future로 돌려준다.
실제 값 추적
key는 ab-0..9와 ba-0..9, Future는 총 20개다.
정상 예
정상 흐름에서는 key는 ab-0..9와 ba-0..9, Future는 총 20개다.
반례·경계 예
from/to ID를 두 호출에서 같은 순서로 넣으면 역방향 위험을 시험하지 못한다.
착각 방지
고유 key는 retry 횟수가 아니라 transactionId 구분자다.
이 블록이 하지 않는 일
등록 순서가 실제 실행 순서를 보장하지 않는다.
다음 코드와의 연결
다음 조각이 20개 준비를 확인하고 제한 시간 안 완료와 두 불변식을 검사한다.

코드 조각 7 · 완료·총액·원장 합

해설 바로 앞 원문 조각 · java
            start.countDown();
            for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);
            long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();
            long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();
            assertThat(firstBalance + secondBalance).isEqualTo(20_000);
            assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
                .query(Long.class).single()).isZero();
        } finally {

한 줄 읽기: 모든 작업이 끝난 뒤 돈의 전체 합과 원장 signed 합을 확인한다.

문법을 한 줄씩 풀면
ready는 10초, 각 Future는 30초 제한으로 기다리고 COALESCE(SUM(...),0)은 행이 없어도 0을 만든다.
실제 값 추적
20개 Future가 예외 없이 끝나고 first+second=20,000, TRANSFER 원장 signed 합=0이다.
정상 예
정상 흐름에서는 20개 Future가 예외 없이 끝나고 first+second=20,000, TRANSFER 원장 signed 합=0이다.
반례·경계 예
개별 잔액이 10,000/10,000이 아니어도 두 assertion은 통과할 수 있다.
착각 방지
메서드 이름의 WithoutDeadlock을 실제 deadlock을 일으켜 관찰했다는 뜻으로 넓히면 안 된다.
이 블록이 하지 않는 일
거래 행이 정확히 20개인지, 각 방향 성공 수가 10인지 직접 assert하지 않는다.
다음 코드와의 연결
다음 조각이 실패해도 latch와 pool을 정리하고 invoke 입력을 받는다.

코드 조각 8 · 항상 정리하고 호출 인자를 받기

해설 바로 앞 원문 조각 · java
            start.countDown();
            pool.shutdownNow();
        }
    }

    private Object invoke(
        CountDownLatch ready, CountDownLatch start, String key, long from, long to
    ) {
        try {

한 줄 읽기: 시험이 중간 실패해도 대기 중 thread를 풀고 pool을 닫는다.

문법을 한 줄씩 풀면
finally는 성공·실패 모두 실행되고 invoke는 두 latch, key, from/to ID를 받는다.
실제 값 추적
start가 아직 1이면 finally에서 0이 되고 pool에는 즉시 중단 신호가 간다.
정상 예
정상 흐름에서는 start가 아직 1이면 finally에서 0이 되고 pool에는 즉시 중단 신호가 간다.
반례·경계 예
정리를 빼면 실패한 테스트 뒤 worker가 남아 다음 테스트를 오염시킬 수 있다.
착각 방지
shutdownNow가 이미 commit된 DB transaction을 되돌리는 것은 아니다.
이 블록이 하지 않는 일
이 조각은 반환 Future의 업무 결과를 검사하지 않는다.
다음 코드와의 연결
다음 조각이 각 worker의 실제 100원 이체와 interrupt 처리를 수행한다.

코드 조각 9 · 준비 신호 뒤 100원 이체

해설 바로 앞 원문 조각 · java
            ready.countDown();
            start.await();
            return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(interrupted);
        }
    }
}

한 줄 읽기: worker 하나가 준비를 알리고 공통 출발 뒤 실제 이체를 한 번 호출한다.

문법을 한 줄씩 풀면
countDownawait하고, command는 customer-1·고유 key·방향 ID·100원을 담는다.
실제 값 추적
한 invoke당 100원 이체 1회이며 정상 완료 값이 Future에 전달된다.
정상 예
정상 흐름에서는 한 invoke당 100원 이체 1회이며 정상 완료 값이 Future에 전달된다.
반례·경계 예
interrupt를 삼키면 취소된 worker가 계속 업무를 할 수 있어 상태가 불분명해진다.
착각 방지
catch는 retry가 아니라 interrupt 상태 복원 뒤 실패 전달이다.
이 블록이 하지 않는 일
실패한 이체를 자동으로 다시 시도하지 않는다.
다음 코드와의 연결
이 파일의 끝이며 다음 누적 파일들이 정렬 잠금이 만들어지는 production 경로를 보여 준다.

직접 다시 써보기

저장 경로
src/test/java/com/example/financialcore/transfer/SortedLockTransferIT.java
전제조건
실제 PostgreSQL, W10D3 단계의 TransferService·AccountRepository, 계좌 개설 서비스가 필요하다.
반드시 지킬 계약
pairs=10/tasks=20, ready 10초, 각 Future 30초, 잔액 총합 20,000, 원장 signed 합 0을 그대로 둔다.
추천 입력 순서
import → 주입/두 계좌 → fixture → @Test의 20개 Future → 두 불변식 → invoke helper 순서로 쓴다.
자기 점검
@Test 1개이며 개별 잔액 동일, 거래 행 20개, retry, 실제 deadlock 발생을 assert하지 않는지 대조한다.
이번 파일의 범위 밖
모든 스케줄의 deadlock 부재, 공정성, 처리량, 자동 재시도, 두 계좌가 각각 10,000원인지는 보장하지 않는다.

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

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

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

import java.util.ArrayList;
import java.util.List;
import java.util.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 SortedLockTransferIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired TransferService transfers;
    Account first;
    Account second;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
            .update();
        first = openings.open("customer-1", "SORT-A", 10_000);
        second = openings.open("customer-1", "SORT-B", 10_000);
    }

    @Test
    void oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal() throws Exception {
        int pairs = 10;
        int tasks = pairs * 2;
        var ready = new CountDownLatch(tasks);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(tasks);
        try {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < pairs; i++) {
                int sequence = i;
                futures.add(pool.submit(() -> invoke(ready, start, "ab-" + sequence,
                    first.getId(), second.getId())));
                futures.add(pool.submit(() -> invoke(ready, start, "ba-" + sequence,
                    second.getId(), first.getId())));
            }
            assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
            start.countDown();
            for (Future<?> future : futures) future.get(30, TimeUnit.SECONDS);
            long firstBalance = accounts.findById(first.getId()).orElseThrow().getBalance();
            long secondBalance = accounts.findById(second.getId()).orElseThrow().getBalance();
            assertThat(firstBalance + secondBalance).isEqualTo(20_000);
            assertThat(jdbc.sql("SELECT COALESCE(SUM(signed_amount),0) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
                .query(Long.class).single()).isZero();
        } finally {
            start.countDown();
            pool.shutdownNow();
        }
    }

    private Object invoke(
        CountDownLatch ready, CountDownLatch start, String key, long from, long to
    ) {
        try {
            ready.countDown();
            start.await();
            return transfers.transfer(new TransferService.Command("customer-1", key, from, to, 100));
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(interrupted);
        }
    }
}
canonical test가 직접 확인하는 값은 요청20, 잔액 총합20,000, 원장 signed 합0이다.

2. OptimisticAccountIT

W10 신규 · 토요일 제공 통합 테스트토 selectorsrc/test/java/com/example/financialcore/account/OptimisticAccountIT.javaSHA-256 e6c1f5ff4931db6f440021be0463e6021ba47adf85205444b2a6884660d9bad1

한 문장 역할: 같은 @Version 계좌를 동시에 수정하면 한 transaction만 commit되고 다른 하나는 optimistic failure가 되는지 확인

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나W10 토요일 exact selector com.example.financialcore.account.OptimisticAccountIT
무엇을 받나10,000원/version 0 계좌, worker 2개, 두 read를 맞추는 barrier
무엇이 바뀌나두 worker가 각각 1,000원을 출금하려 하지만 version 조건을 통과한 한 transaction만 commit
무엇을 돌려주나[COMMIT, OPTIMISTIC_FAILURE], balance 9,000, version 1 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.dao.OptimisticLockingFailureException;
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 OptimisticAccountIT extends PostgresIntegrationTestSupport {
    enum Outcome { COMMIT, OPTIMISTIC_FAILURE, TECHNICAL_FAILURE }

    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @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("OPTIMISTIC", 10_000).getId();
    }

    @Test
    void production_versioned_entity_has_one_commit_and_one_optimistic_failure() throws Exception {
        var readBarrier = new CountDownLatch(2);
        var pool = Executors.newFixedThreadPool(2);
        List<Future<Outcome>> futures = new ArrayList<>();
        try {
            for (int i = 0; i < 2; i++) {
                futures.add(pool.submit(() -> runWithdrawal(readBarrier)));
            }
            List<Outcome> outcomes = new ArrayList<>();
            for (Future<Outcome> future : futures) outcomes.add(future.get(20, TimeUnit.SECONDS));
            assertThat(outcomes).containsExactlyInAnyOrder(Outcome.COMMIT, Outcome.OPTIMISTIC_FAILURE);
            assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(9_000);
            assertThat(jdbc.sql("SELECT version FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(1);
        } finally {
            pool.shutdownNow();
        }
    }

    private Outcome runWithdrawal(CountDownLatch readBarrier) {
        try {
            TransactionTemplate tx = new TransactionTemplate(transactionManager);
            tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
            tx.setTimeout(10);
            tx.executeWithoutResult(status -> {
                Account account = accounts.findById(accountId).orElseThrow();
                readBarrier.countDown();
                await(readBarrier);
                account.withdraw(1_000);
                accounts.flush();
            });
            return Outcome.COMMIT;
        } catch (OptimisticLockingFailureException expected) {
            return Outcome.OPTIMISTIC_FAILURE;
        } catch (RuntimeException failure) {
            if (hasOptimisticCause(failure)) return Outcome.OPTIMISTIC_FAILURE;
            return Outcome.TECHNICAL_FAILURE;
        }
    }

    private static boolean hasOptimisticCause(Throwable failure) {
        for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
            if (cause.getClass().getSimpleName().contains("OptimisticLock")) return true;
        }
        return false;
    }

    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 · 주소와 optimistic 예외 도구

해설 바로 앞 원문 조각 · 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.dao.OptimisticLockingFailureException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.PlatformTransactionManager;

한 줄 읽기: 생산 entity의 version 충돌을 실제 통합 시험에서 분류할 준비를 한다.

문법을 한 줄씩 풀면
OptimisticLockingFailureException은 Spring이 번역한 낙관 충돌 계열 예외이고 JdbcClient는 최종 값을 새로 읽는다.
실제 값 추적
아직 계좌와 transaction은 0개다.
정상 예
정상 흐름에서는 아직 계좌와 transaction은 0개다.
반례·경계 예
예외 import를 빼면 직접 catch 분기가 compile되지 않는다.
착각 방지
예외 타입을 불러왔다고 @Version이 생기는 것은 아니다.
이 블록이 하지 않는 일
충돌을 일으키거나 retry하지 않는다.
다음 코드와의 연결
다음 조각이 transaction·thread·assert 도구를 더한다.

코드 조각 2 · 독립 transaction과 두 worker 도구

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

한 줄 읽기: 각 worker를 별도 transaction으로 실행하고 결과를 회수할 이름을 준비한다.

문법을 한 줄씩 풀면
TransactionDefinition, TransactionTemplate, latch, executor, Future가 경계·동시점·결과를 나눠 맡는다.
실제 값 추적
pool·barrier·Future는 아직 만들어지지 않았다.
정상 예
정상 흐름에서는 pool·barrier·Future는 아직 만들어지지 않았다.
반례·경계 예
Future 없이 작업을 던지면 TECHNICAL_FAILURE 분류가 메인 assertion까지 오지 않을 수 있다.
착각 방지
transaction timeout과 latch timeout은 서로 다른 제한이다.
이 블록이 하지 않는 일
어떤 결과가 나올지 아직 결정하지 않는다.
다음 코드와의 연결
다음 조각이 outcome 세 종류와 실제 Spring bean을 선언한다.

코드 조각 3 · 세 결과와 주입 대상

해설 바로 앞 원문 조각 · java
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class OptimisticAccountIT extends PostgresIntegrationTestSupport {
    enum Outcome { COMMIT, OPTIMISTIC_FAILURE, TECHNICAL_FAILURE }

    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @Autowired PlatformTransactionManager transactionManager;

한 줄 읽기: worker 한 번의 끝을 COMMIT·낙관 충돌·기술 실패 중 하나로 기록한다.

문법을 한 줄씩 풀면
중첩 enum Outcome은 문자열 대신 세 허용 상태를 type으로 고정하고 @Autowired는 실제 bean을 받는다.
실제 값 추적
accountId는 아직 0이며 결과 목록도 없다.
정상 예
정상 흐름에서는 accountId는 아직 0이며 결과 목록도 없다.
반례·경계 예
TECHNICAL_FAILURE가 enum에 있다고 정상 기대 결과에 포함되는 것은 아니다.
착각 방지
결과 enum이 retry 상태 머신이라는 뜻은 아니다.
이 블록이 하지 않는 일
예외를 HTTP 오류로 번역하지 않는다.
다음 코드와의 연결
다음 조각이 매 시험마다 10,000원 계좌 하나를 새로 만든다.

코드 조각 4 · 10,000원/version 0 출발

해설 바로 앞 원문 조각 · java

    private long accountId;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request, ledger_entry, business_tx, account RESTART IDENTITY CASCADE")
            .update();
        accountId = openings.open("OPTIMISTIC", 10_000).getId();
    }

한 줄 읽기: 관련 table을 비우고 optimistic 시험용 계좌 ID를 저장한다.

문법을 한 줄씩 풀면
@BeforeEach는 단일 @Test 직전에 실행되고 계좌 개설은 새 managed entity를 저장한다.
실제 값 추적
시작 balance=10,000이며 W6D2 @Version long의 첫 값은 0이다.
정상 예
정상 흐름에서는 시작 balance=10,000이며 W6D2 @Version long의 첫 값은 0이다.
반례·경계 예
이전 시험의 version1 행을 재사용하면 두 worker의 출발 version을 설명할 수 없다.
착각 방지
계좌 번호 OPTIMISTIC이 잠금 방식을 선택하는 설정은 아니다.
이 블록이 하지 않는 일
아직 출금하지 않는다.
다음 코드와의 연결
다음 조각이 두 worker가 같은 시점의 값을 읽게 할 barrier를 만든다.

코드 조각 5 · 두 worker를 같은 read 지점으로

해설 바로 앞 원문 조각 · java
    @Test
    void production_versioned_entity_has_one_commit_and_one_optimistic_failure() throws Exception {
        var readBarrier = new CountDownLatch(2);
        var pool = Executors.newFixedThreadPool(2);
        List<Future<Outcome>> futures = new ArrayList<>();
        try {
            for (int i = 0; i < 2; i++) {
                futures.add(pool.submit(() -> runWithdrawal(readBarrier)));
            }
            List<Outcome> outcomes = new ArrayList<>();

한 줄 읽기: 동일한 accountId를 처리하는 worker 두 개를 제출한다.

문법을 한 줄씩 풀면
readBarrier(2)는 두 transaction이 읽은 뒤에만 0이 되고 pool 크기2는 둘 다 barrier까지 갈 자리를 준다.
실제 값 추적
Future<Outcome>은 2개, barrier count는 2에서 시작한다.
정상 예
정상 흐름에서는 Future<Outcome>은 2개, barrier count는 2에서 시작한다.
반례·경계 예
pool 1칸이면 첫 worker가 둘째 read를 기다리다 timeout된다.
착각 방지
동시에 시작했다는 사실만으로 어느 worker가 commit할지는 정해지지 않는다.
이 블록이 하지 않는 일
이 조각은 worker 결과를 아직 꺼내지 않는다.
다음 코드와의 연결
다음 조각이 두 결과와 최종 DB 값 세 가지를 exact하게 검사한다.

코드 조각 6 · 한 commit·한 충돌·9,000/version1

해설 바로 앞 원문 조각 · java
            for (Future<Outcome> future : futures) outcomes.add(future.get(20, TimeUnit.SECONDS));
            assertThat(outcomes).containsExactlyInAnyOrder(Outcome.COMMIT, Outcome.OPTIMISTIC_FAILURE);
            assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(9_000);
            assertThat(jdbc.sql("SELECT version FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(1);
        } finally {
            pool.shutdownNow();
        }
    }

한 줄 읽기: 두 Future 결과와 계좌의 최종 balance/version을 직접 고정한다.

문법을 한 줄씩 풀면
containsExactlyInAnyOrder는 순서는 자유지만 COMMIT과 OPTIMISTIC_FAILURE가 각각 하나여야 하고 raw SQL 두 개가 9,000과 1을 읽는다.
실제 값 추적
결과=[COMMIT, OPTIMISTIC_FAILURE], balance=9,000, version=1이다.
정상 예
정상 흐름에서는 결과=[COMMIT, OPTIMISTIC_FAILURE], balance=9,000, version=1이다.
반례·경계 예
둘 다 COMMIT, TECHNICAL_FAILURE 포함, balance8,000, version0/2 중 하나라도 나오면 실패한다.
착각 방지
낙관 잠금이라 기다림이 절대 0초라고 이 assertion에서 말할 수는 없다.
이 블록이 하지 않는 일
실패 worker를 다시 실행하지 않는다.
다음 코드와의 연결
다음 조각이 pool을 정리하고 worker의 독립 transaction 설정을 시작한다.

코드 조각 7 · REQUIRES_NEW worker와 entity read

해설 바로 앞 원문 조각 · java

    private Outcome runWithdrawal(CountDownLatch readBarrier) {
        try {
            TransactionTemplate tx = new TransactionTemplate(transactionManager);
            tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
            tx.setTimeout(10);
            tx.executeWithoutResult(status -> {
                Account account = accounts.findById(accountId).orElseThrow();
                readBarrier.countDown();
                await(readBarrier);

한 줄 읽기: 각 withdrawal을 호출자와 분리된 새 transaction에서 실행하고 대상 entity를 읽는다.

문법을 한 줄씩 풀면
TransactionTemplate은 REQUIRES_NEW/timeout10을 설정하고 findById가 managed Account를 가져온다.
실제 값 추적
worker마다 transaction 하나가 생기고 둘 다 같은 accountId를 조회한다.
정상 예
정상 흐름에서는 worker마다 transaction 하나가 생기고 둘 다 같은 accountId를 조회한다.
반례·경계 예
두 작업이 하나의 transaction이면 한쪽만 version 충돌하는 실험이 아니다.
착각 방지
timeout10은 10초 뒤 성공한다는 보장이 아니다.
이 블록이 하지 않는 일
아직 충돌 worker를 재시도하지 않는다.
다음 코드와의 연결
다음 조각이 두 read를 맞춘 뒤 withdraw·flush와 결과 분류를 수행한다.

코드 조각 8 · 같은 version 경쟁과 직접 예외

해설 바로 앞 원문 조각 · java
                account.withdraw(1_000);
                accounts.flush();
            });
            return Outcome.COMMIT;
        } catch (OptimisticLockingFailureException expected) {
            return Outcome.OPTIMISTIC_FAILURE;
        } catch (RuntimeException failure) {
            if (hasOptimisticCause(failure)) return Outcome.OPTIMISTIC_FAILURE;
            return Outcome.TECHNICAL_FAILURE;
        }

한 줄 읽기: 두 transaction이 같은 version을 읽은 뒤 각각 1,000원을 빼고 flush한다.

문법을 한 줄씩 풀면
barrier 뒤 withdraw, flush, 정상 COMMIT 반환 순서이며 직접 낙관 예외는 OPTIMISTIC_FAILURE로 바뀐다.
실제 값 추적
둘 다 10,000/version0을 읽지만 update 성공은 하나뿐이다.
정상 예
정상 흐름에서는 둘 다 10,000/version0을 읽지만 update 성공은 하나뿐이다.
반례·경계 예
flush를 빼도 commit 때 충돌할 수 있으나 실패 시점이 뒤로 밀린다.
착각 방지
withdraw 산술이 낙관 잠금을 거는 것이 아니라 @Version update 조건이 충돌을 잡는다.
이 블록이 하지 않는 일
실패를 다시 실행하지 않는다.
다음 코드와의 연결
다음 조각이 감싼 낙관 원인과 다른 기술 실패를 구분한다.

코드 조각 9 · 감싼 optimistic과 기술 실패 분류

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

    private static boolean hasOptimisticCause(Throwable failure) {
        for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
            if (cause.getClass().getSimpleName().contains("OptimisticLock")) return true;
        }
        return false;
    }

    private static void await(CountDownLatch latch) {

한 줄 읽기: 원인 사슬에 optimistic 예외가 있을 때만 충돌 결과로 묶는다.

문법을 한 줄씩 풀면
RuntimeException은 hasOptimisticCause가 true면 충돌, 아니면 TECHNICAL_FAILURE이고 helper는 cause를 null까지 훑는다.
실제 값 추적
감싼 optimistic cause는 failure, unrelated SQL 오류는 technical failure다.
정상 예
정상 흐름에서는 감싼 optimistic cause는 failure, unrelated SQL 오류는 technical failure다.
반례·경계 예
모든 RuntimeException을 충돌로 처리하면 실제 버그가 숨는다.
착각 방지
TECHNICAL_FAILURE는 관찰용 분류이지 복구나 retry가 아니다.
이 블록이 하지 않는 일
provider별 모든 예외 이름을 영원히 보장하지 않는다.
다음 코드와의 연결
마지막 조각이 barrier timeout과 interrupt를 명시적 실패로 전달한다.

코드 조각 10 · barrier timeout과 interrupt 전달

해설 바로 앞 원문 조각 · java
        try {
            if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("read barrier timeout");
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", interrupted);
        }
    }
}

한 줄 읽기: 둘째 read가 오지 않거나 thread가 끊기면 조용히 계속하지 않고 실패시킨다.

문법을 한 줄씩 풀면
10초 await가 false면 timeout, interrupt면 flag를 복원하고 원인을 단 예외를 던진다.
실제 값 추적
정상은 두 worker가 모두 countDown해 barrier0이 되고 둘 다 withdraw로 진행한다.
정상 예
정상 흐름에서는 정상은 두 worker가 모두 countDown해 barrier0이 되고 둘 다 withdraw로 진행한다.
반례·경계 예
InterruptedException을 삼키면 취소된 worker가 DB를 계속 바꿀 수 있다.
착각 방지
이 helper 10초와 Future20초, transaction10초는 서로 다른 관찰 경계다.
이 블록이 하지 않는 일
DB lock wait 시간이나 성능을 측정하지 않는다.
다음 코드와의 연결
이 파일의 끝이며 누적 Account가 실제 @Version 선언 위치를 보여 준다.

직접 다시 써보기

저장 경로
src/test/java/com/example/financialcore/account/OptimisticAccountIT.java
전제조건
W6D2 canonical Account@Version, 실제 PostgreSQL, JPA repository와 transaction manager가 필요하다.
반드시 지킬 계약
두 worker 모두 같은 행을 먼저 읽고, 각자 REQUIRES_NEW에서 withdraw(1,000)+flush하며 결과·잔액·version을 검사한다.
추천 입력 순서
import → Outcome/주입 → fixture → @Test → runWithdrawal → optimistic 원인 탐색 → await helper 순서다.
자기 점검
결과는 COMMIT 1·OPTIMISTIC_FAILURE 1, 최종 9,000/version1이며 TECHNICAL_FAILURE와 retry가 없는지 본다.
이번 파일의 범위 밖
자동 재시도, 최대 재시도 수, backoff, 고충돌 처리량, starvation, 모든 예외 번역은 구현하지 않는다.

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

직접 쓴 뒤 전체 정답 펼치기
전체 코드 정답 · 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.dao.OptimisticLockingFailureException;
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 OptimisticAccountIT extends PostgresIntegrationTestSupport {
    enum Outcome { COMMIT, OPTIMISTIC_FAILURE, TECHNICAL_FAILURE }

    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired AccountRepository accounts;
    @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("OPTIMISTIC", 10_000).getId();
    }

    @Test
    void production_versioned_entity_has_one_commit_and_one_optimistic_failure() throws Exception {
        var readBarrier = new CountDownLatch(2);
        var pool = Executors.newFixedThreadPool(2);
        List<Future<Outcome>> futures = new ArrayList<>();
        try {
            for (int i = 0; i < 2; i++) {
                futures.add(pool.submit(() -> runWithdrawal(readBarrier)));
            }
            List<Outcome> outcomes = new ArrayList<>();
            for (Future<Outcome> future : futures) outcomes.add(future.get(20, TimeUnit.SECONDS));
            assertThat(outcomes).containsExactlyInAnyOrder(Outcome.COMMIT, Outcome.OPTIMISTIC_FAILURE);
            assertThat(jdbc.sql("SELECT balance FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(9_000);
            assertThat(jdbc.sql("SELECT version FROM account WHERE id=:id")
                .param("id", accountId).query(Long.class).single()).isEqualTo(1);
        } finally {
            pool.shutdownNow();
        }
    }

    private Outcome runWithdrawal(CountDownLatch readBarrier) {
        try {
            TransactionTemplate tx = new TransactionTemplate(transactionManager);
            tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
            tx.setTimeout(10);
            tx.executeWithoutResult(status -> {
                Account account = accounts.findById(accountId).orElseThrow();
                readBarrier.countDown();
                await(readBarrier);
                account.withdraw(1_000);
                accounts.flush();
            });
            return Outcome.COMMIT;
        } catch (OptimisticLockingFailureException expected) {
            return Outcome.OPTIMISTIC_FAILURE;
        } catch (RuntimeException failure) {
            if (hasOptimisticCause(failure)) return Outcome.OPTIMISTIC_FAILURE;
            return Outcome.TECHNICAL_FAILURE;
        }
    }

    private static boolean hasOptimisticCause(Throwable failure) {
        for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
            if (cause.getClass().getSimpleName().contains("OptimisticLock")) return true;
        }
        return false;
    }

    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);
        }
    }
}
기다리는 pessimistic 경로와 충돌을 검출하는 optimistic 경로의 선택 기준을 구분한다.

이번 주 누적 정본·지원 · 4파일

3. LostUpdateBaselineIT

누적 canonical · W9D2 제공 / W10 월·화 selector월·화src/test/java/com/example/financialcore/account/LostUpdateBaselineIT.javaSHA-256 61b1f145bc0427a3f4fa4f8b8108e01da54aacf1b8a9b85d3784ae536b64db24

한 문장 역할: 잠금 없는 의도적 lost update와 SELECT FOR UPDATE 직렬화를 같은 10,000원 fixture에서 비교

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나W10 월요일은 첫 @Test, 화요일은 둘째 @Test를 메서드 selector로 호출
무엇을 받나10,000원 계좌 하나, worker 두 개, 각자 REQUIRES_NEW transaction
무엇이 바뀌나무잠금은 같은 10,000을 두 번 읽어 9,000을 두 번 쓰고, 잠금은 10,000→9,000→8,000
무엇을 돌려주나무잠금 [commit2,balance9000,version0], 잠금 [commit2,balance8000] 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 · 통합 시험과 transaction import

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

한 줄 읽기: W10 월·화가 재사용하는 두 동시성 시험의 기반을 불러온다.

문법을 한 줄씩 풀면
SpringBootTest/JdbcClient는 실제 DB context를, manager/definition은 코드식 transaction을 준비한다.
실제 값 추적
아직 accountId도 thread도 없고 balance는 읽지 않았다.
정상 예
정상 흐름에서는 아직 accountId도 thread도 없고 balance는 읽지 않았다.
반례·경계 예
PlatformTransactionManager가 없으면 worker별 독립 경계를 만들 수 없다.
착각 방지
import가 실제 commit을 열었다는 뜻은 아니다.
이 블록이 하지 않는 일
DB 행을 만들거나 잠그지 않는다.
다음 코드와의 연결
다음 조각이 template·thread·assert 도구를 채운다.

코드 조각 2 · template·latch·Future·assert

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

한 줄 읽기: 두 worker의 읽기 시점과 완료 결과를 메인 시험에서 통제한다.

문법을 한 줄씩 풀면
ArrayList는 Future를 모으고 latch는 시점을 맞추며 TimeUnit은 대기 상한을 표현한다.
실제 값 추적
도구 이름만 있고 Future 0개, latch 0개인 상태다.
정상 예
정상 흐름에서는 도구 이름만 있고 Future 0개, latch 0개인 상태다.
반례·경계 예
Future를 버리면 worker 내부 예외가 assertion까지 전달되지 않을 수 있다.
착각 방지
CountDownLatch는 database row lock이 아니다.
이 블록이 하지 않는 일
동시성 스케줄을 아직 만들지 않는다.
다음 코드와의 연결
다음 조각이 Spring bean과 공유 accountId를 선언한다.

코드 조각 3 · context와 단일 계좌 ID

해설 바로 앞 원문 조각 · java

@SpringBootTest
class LostUpdateBaselineIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired PlatformTransactionManager transactionManager;

    private long accountId;

    @BeforeEach

한 줄 읽기: 실제 PostgreSQL context의 JDBC·개설 서비스·transaction manager를 한 테스트가 함께 쓴다.

문법을 한 줄씩 풀면
@SpringBootTest@Autowired가 세 bean을 연결하고 long 필드가 새 계좌 ID를 보관한다.
실제 값 추적
setUp 전 accountId는 0이다.
정상 예
정상 흐름에서는 setUp 전 accountId는 0이다.
반례·경계 예
가짜 JdbcClient를 쓰면 실제 row lock 실험이 아니다.
착각 방지
bean 주입만으로 lost update가 재현되는 것은 아니다.
이 블록이 하지 않는 일
계좌 balance를 아직 바꾸지 않는다.
다음 코드와의 연결
다음 조각이 10,000원 출발점과 첫 시험 장치를 만든다.

코드 조각 4 · 10,000원 출발과 무잠금 장치

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

한 줄 읽기: table을 비우고 계좌 하나를 연 뒤 readBarrier2와 pool2를 만든다.

문법을 한 줄씩 풀면
BeforeEach의 fixture와 첫 @Test의 latch/executor가 같은 10,000원 출발을 재현한다.
실제 값 추적
balance=10,000, version=0, barrier2, worker 자리2다.
정상 예
정상 흐름에서는 balance=10,000, version=0, barrier2, worker 자리2다.
반례·경계 예
이전 시험 행을 남기거나 pool1이면 기준선이 깨진다.
착각 방지
계좌 번호가 충돌을 만드는 것은 아니다.
이 블록이 하지 않는 일
아직 UPDATE하지 않는다.
다음 코드와의 연결
다음 조각이 두 worker의 Future와 같은 read 지점을 만든다.

코드 조각 5 · 두 worker가 같은 10,000 읽기

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

한 줄 읽기: Future 두 개가 독립 transaction에서 잔액을 읽고 barrier 앞에 모인다.

문법을 한 줄씩 풀면
각 lambda는 REQUIRES_NEW를 만들고 SELECT 후 countDown/await한다.
실제 값 추적
두 observed는 모두 10,000이 된다.
정상 예
정상 흐름에서는 두 observed는 모두 10,000이 된다.
반례·경계 예
Future를 모으지 않거나 barrier를 빼면 겹침과 예외 회수를 놓친다.
착각 방지
barrier는 row lock이 아니다.
이 블록이 하지 않는 일
생산 repository를 사용하지 않는다.
다음 코드와의 연결
다음 조각이 version 조건 없이 같은 9,000을 쓰고 Future를 회수한다.

코드 조각 6 · 조건 없는 9,000 쓰기와 완료 회수

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

한 줄 읽기: 두 worker 모두 observed-1,000을 id 조건만으로 쓰고 true를 반환한다.

문법을 한 줄씩 풀면
UPDATE의 WHERE에는 version이 없고 메인 thread는 Future.get(20초) true를 센다.
실제 값 추적
둘 다 9,000을 써 commits 후보가2가 된다.
정상 예
정상 흐름에서는 둘 다 9,000을 써 commits 후보가2가 된다.
반례·경계 예
한 worker가 실패하면 단순 한 작업 실패일 수 있다.
착각 방지
UPDATE 성공 두 번이 금액 변화2,000을 뜻하지 않는다.
이 블록이 하지 않는 일
마지막 writer를 식별하지 않는다.
다음 코드와의 연결
다음 조각이 commits2/balance9000/version0을 확정하고 둘째 시험을 연다.

코드 조각 7 · 무잠금 세 assertion과 잠금 시험 선언

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

한 줄 읽기: 첫 시험의 세 값을 고정하고 pool을 닫은 뒤 FOR UPDATE 시험을 선언한다.

문법을 한 줄씩 풀면
assertion은 commits2, balance9000, version0이고 finally는 executor를 정리한다.
실제 값 추적
두 반환은 성공했지만 최종 변화는1,000원이다.
정상 예
정상 흐름에서는 두 반환은 성공했지만 최종 변화는1,000원이다.
반례·경계 예
balance만9000이고 Future 하나가 실패하면 기준선이 아니다.
착각 방지
commits는 DB 로그가 아니라 true Future 수다.
이 블록이 하지 않는 일
production JPA @Version을 시험하지 않는다.
다음 코드와의 연결
다음 조각이 잠금 경로의 start/pool/Future와 transaction을 준비한다.

코드 조각 8 · 잠금 경로 worker와 SELECT 입구

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

한 줄 읽기: 두 worker를 start 신호로 풀고 각자 새 transaction에서 잠금 SELECT를 시작한다.

문법을 한 줄씩 풀면
start1, pool2, Future list와 REQUIRES_NEW가 독립 commit 두 개를 만든다.
실제 값 추적
worker2가 같은 accountId를 향한다.
정상 예
정상 흐름에서는 worker2가 같은 accountId를 향한다.
반례·경계 예
start를 내리지 않으면 둘 다 멈춘다.
착각 방지
동시 출발 신호가 row lock을 대신하지 않는다.
이 블록이 하지 않는 일
아직 최종 값을 assert하지 않는다.
다음 코드와의 연결
다음 조각이 FOR UPDATE의 현재 값으로 차례로 잔액을 줄인다.

코드 조각 9 · FOR UPDATE로 10,000 다음 9,000 읽기

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

한 줄 읽기: 먼저 잠근 worker가 끝난 뒤 둘째가 새 현재 값을 읽는다.

문법을 한 줄씩 풀면
SELECT FOR UPDATE와 UPDATE가 같은 transaction이고 version도1씩 올린다.
실제 값 추적
첫째 10,000→9,000, 둘째 9,000→8,000이다.
정상 예
정상 흐름에서는 첫째 10,000→9,000, 둘째 9,000→8,000이다.
반례·경계 예
잠금 밖 observed면 둘 다10,000을 쓸 수 있다.
착각 방지
이 행 잠금이 모든 table을 잠그는 것은 아니다.
이 블록이 하지 않는 일
deadlock 부재와 공정성을 측정하지 않는다.
다음 코드와의 연결
다음 조각이 Future 둘과 최종 8,000을 확인한다.

코드 조각 10 · 잠금 경로 commit2/balance8000

해설 바로 앞 원문 조각 · java
            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 둘을 회수해 두 감소가 모두 남았는지 확인하고 worker를 정리한다.

문법을 한 줄씩 풀면
commits2와 balance8000 assertion 뒤 finally가 start와 pool을 정리한다.
실제 값 추적
최종 balance=8,000이다.
정상 예
정상 흐름에서는 최종 balance=8,000이다.
반례·경계 예
version2가 예상되어도 이 @Test는 직접 assert하지 않는다.
착각 방지
코드의 version 증가와 테스트 보장은 다르다.
이 블록이 하지 않는 일
처리 시간과 version 값을 검사하지 않는다.
다음 코드와의 연결
다음 조각이 공통 REQUIRES_NEW helper와 balance 조회를 연다.

코드 조각 11 · 독립 transaction과 balance 재조회

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

한 줄 읽기: worker용 template를 만들고 최종 balance를 raw SQL로 새로 읽는다.

문법을 한 줄씩 풀면
PROPAGATION_REQUIRES_NEW/timeout10을 설정하고 named id query의 Long 한 행을 반환한다.
실제 값 추적
각 worker는 독립 commit이고 balance helper는 9,000 또는8,000을 읽는다.
정상 예
정상 흐름에서는 각 worker는 독립 commit이고 balance helper는 9,000 또는8,000을 읽는다.
반례·경계 예
REQUIRED로 합치거나 cache 값만 보면 다른 실험이 된다.
착각 방지
timeout10이 정확한 lock wait 시간은 아니다.
이 블록이 하지 않는 일
retry 정책을 구성하지 않는다.
다음 코드와의 연결
다음 조각이 balance 반환, version query, barrier timeout을 잇는다.

코드 조각 12 · version 재조회와 barrier timeout

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

    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");

한 줄 읽기: 무잠금 시험의 version을 새 SQL로 읽고 두 read가 10초 안에 모였는지 검사한다.

문법을 한 줄씩 풀면
version helper는 single Long을 반환하고 await false는 read barrier timeout 예외가 된다.
실제 값 추적
정상 무잠금 version=0, barrier=0이다.
정상 예
정상 흐름에서는 정상 무잠금 version=0, barrier=0이다.
반례·경계 예
행이 없거나 둘째 worker가 오지 않으면 조용히 통과하지 않는다.
착각 방지
barrier timeout과 transaction timeout은 다른 장치다.
이 블록이 하지 않는 일
어느 worker가 마지막 writer였는지 말하지 않는다.
다음 코드와의 연결
마지막 조각이 interrupt 상태를 복원해 Future 실패로 전달한다.

코드 조각 13 · interrupt 복원과 파일 닫기

해설 바로 앞 원문 조각 · java
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", interrupted);
        }
    }
}

한 줄 읽기: 대기 중 interrupt를 삼키지 않고 상태를 복원한 뒤 원인을 보존한다.

문법을 한 줄씩 풀면
catch가 Thread.currentThread().interrupt() 후 IllegalStateException을 던진다.
실제 값 추적
중단 신호는 Future.get에서 실패로 드러난다.
정상 예
정상 흐름에서는 중단 신호는 Future.get에서 실패로 드러난다.
반례·경계 예
interrupt를 무시하면 취소 뒤에도 DB 작업이 이어질 수 있다.
착각 방지
이 예외 변환은 retry가 아니다.
이 블록이 하지 않는 일
모든 운영 스케줄의 재현성을 보장하지 않는다.
다음 코드와의 연결
다음 누적 AccountRepository가 production pessimistic query 선언을 보여 준다.

직접 다시 써보기

저장 경로
src/test/java/com/example/financialcore/account/LostUpdateBaselineIT.java
전제조건
실제 PostgreSQL, AccountOpeningService, JdbcClient, PlatformTransactionManager가 필요하다.
반드시 지킬 계약
각 @Test 전에 10,000원 한 계좌, worker별 REQUIRES_NEW, 모든 Future 회수, 원문 assertion 값을 보존한다.
추천 입력 순서
import/fixture → 무잠금 @Test → FOR UPDATE @Test → requiresNew → balance/version → await 순서다.
자기 점검
@Test 2개, 무잠금 commit2/9,000/version0, 잠금 commit2/8,000이며 둘째가 version2를 assert하지 않는지 본다.
이번 파일의 범위 밖
생산 JPA @Version 경로, 모든 DB 격리수준, 평상시 재현 확률, deadlock 부재, 처리량은 보장하지 않는다.

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

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

4. AccountRepository

누적 canonical · W6D2 / W10 화·목 learnerTarget화·목, 수·금 실행 지원src/main/java/com/example/financialcore/account/AccountRepository.javaSHA-256 2fa992dd63190467f397f1c43a6d2f47cd9618b2b2a6f241e7c867f7bd768fac

한 문장 역할: 단일 계좌 잠금과 여러 계좌 ID 오름차순 잠금을 JPQL과 PESSIMISTIC_WRITE로 선언

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나계좌 조회 기능, LostUpdateBaselineIT 설명, TransferService의 두 계좌 잠금
무엇을 받나계좌 번호·ID 하나 또는 ID 목록
무엇이 바뀌나조회 transaction 동안 대상 행에 pessimistic write lock을 잡지만 entity 값은 조회만으로 바꾸지 않음
무엇을 돌려주나Optional 계좌/owner 또는 ID 오름차순의 잠긴 Account 목록

정확한 전체 원문

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

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByAccountNo(String accountNo);

    boolean existsByAccountNo(String accountNo);

    @Query("select a.ownerId from Account a where a.id = :id")
    Optional<String> findOwnerId(@Param("id") long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findOneForUpdate(@Param("id") long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id in :ids order by a.id")
    List<Account> findAllForUpdateOrderById(@Param("ids") List<Long> ids);
}

코드 조각 1 · repository 주소와 필요한 annotation

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

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

한 줄 읽기: Spring Data repository에서 잠금과 JPQL을 선언할 이름을 준비한다.

문법을 한 줄씩 풀면
LockModeType, @Lock, @Query, @Param은 query metadata를, List/Optional은 반환 모양을 표현한다.
실제 값 추적
아직 SQL도 잠금도 실행되지 않았다.
정상 예
정상 흐름에서는 아직 SQL도 잠금도 실행되지 않았다.
반례·경계 예
@Lock import가 빠지면 아래 annotation을 해석하지 못한다.
착각 방지
import만으로 모든 find가 잠금 조회가 되지는 않는다.
이 블록이 하지 않는 일
transaction을 열거나 account 값을 바꾸지 않는다.
다음 코드와의 연결
다음 조각이 기본 조회와 owner projection을 선언한다.

코드 조각 2 · 기본 조회와 owner projection

해설 바로 앞 원문 조각 · java

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByAccountNo(String accountNo);

    boolean existsByAccountNo(String accountNo);

    @Query("select a.ownerId from Account a where a.id = :id")
    Optional<String> findOwnerId(@Param("id") long id);

한 줄 읽기: 계좌 번호 존재·조회와 ID별 소유자 문자열 조회를 제공한다.

문법을 한 줄씩 풀면
JpaRepository 상속이 CRUD를 주고 method name query와 JPQL projection이 각 반환값을 만든다.
실제 값 추적
없는 accountNo/id는 Optional.empty 또는 exists=false이고 있는 id는 ownerId 한 값을 돌려준다.
정상 예
정상 흐름에서는 없는 accountNo/id는 Optional.empty 또는 exists=false이고 있는 id는 ownerId 한 값을 돌려준다.
반례·경계 예
findOwnerId가 Account 전체를 돌려준다고 가정하면 type이 맞지 않는다.
착각 방지
이 세 메서드는 PESSIMISTIC_WRITE annotation이 없어 row lock 계약이 아니다.
이 블록이 하지 않는 일
두 계좌의 공통 잠금 순서를 아직 보장하지 않는다.
다음 코드와의 연결
다음 조각이 단일/다중 행 잠금과 오름차순을 붙인다.

코드 조각 3 · 단일 잠금과 ID 오름차순 잠금

해설 바로 앞 원문 조각 · java
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findOneForUpdate(@Param("id") long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id in :ids order by a.id")
    List<Account> findAllForUpdateOrderById(@Param("ids") List<Long> ids);
}

한 줄 읽기: 조회 transaction에서 대상 행을 쓰기 잠그고, 여러 ID는 작은 ID부터 받는다.

문법을 한 줄씩 풀면
두 메서드 모두 @Lock(PESSIMISTIC_WRITE)이며 다중 JPQL은 id in :ids order by a.id다.
실제 값 추적
ids=[8,3]을 넘겨도 결과/잠금 순서는 3,8을 의도하고 TransferService도 입력을 [3,8]로 정렬한다.
정상 예
정상 흐름에서는 ids=[8,3]을 넘겨도 결과/잠금 순서는 3,8을 의도하고 TransferService도 입력을 [3,8]로 정렬한다.
반례·경계 예
다른 호출 경로가 B부터 잠그거나 DB가 잠금 순서를 다르게 실행하면 이 파일 하나만으로 보편적 deadlock 부재를 증명할 수 없다.
착각 방지
Java의 method 이름 OrderById가 아니라 실제 JPQL의 ORDER BY가 핵심 계약이다.
이 블록이 하지 않는 일
lock timeout과 실패 후 retry를 구현하지 않는다.
다음 코드와의 연결
다음 누적 TransferService가 두 ID를 정렬해 이 query를 호출한다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/account/AccountRepository.java
전제조건
JPA Account entity와 Spring Data JPA가 같은 학습 단계에 있어야 한다.
반드시 지킬 계약
두 잠금 query 모두 PESSIMISTIC_WRITE, 다중 query는 where id in :ids order by a.id와 List<Long>을 보존한다.
추천 입력 순서
package/import → 기본 파생 query → owner projection → 단일 잠금 → 다중 정렬 잠금 순서다.
자기 점검
line20/24의 @Lock 두 개와 line25의 order by a.id, method 이름 findAllForUpdateOrderById를 대조한다.
이번 파일의 범위 밖
transaction 경계, lock timeout, retry, isolation level, DB 실행계획, 모든 호출 경로의 정렬 일관성은 정하지 않는다.

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

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

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByAccountNo(String accountNo);

    boolean existsByAccountNo(String accountNo);

    @Query("select a.ownerId from Account a where a.id = :id")
    Optional<String> findOwnerId(@Param("id") long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findOneForUpdate(@Param("id") long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id in :ids order by a.id")
    List<Account> findAllForUpdateOrderById(@Param("ids") List<Long> ids);
}
A→B와 B→A 모두 작은 ID부터 잠그고 실제 돈의 방향은 따로 보존한다.

5. TransferService

누적 canonical · W7D5 / W10 금 learnerTarget수·목·금 실행 경로src/main/java/com/example/financialcore/transfer/TransferService.javaSHA-256 e44994db58afe3ba53008de7dda7116881de1caa81553505af13571ad3273ec6

한 문장 역할: 두 계좌 ID를 정렬해 같은 순서로 잠근 뒤 업무 방향을 복원하고 잔액·거래·원장을 한 transaction에서 변경

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나이체 controller와 W10 SortedLockTransferIT의 worker 20개
무엇을 받나actorId, 고유 transactionId, from/to account ID, 양수 amount
무엇이 바뀌나두 잔액, business_tx 1행, ledger_entry 2행을 같은 transaction에서 변경
무엇을 돌려주나business transaction ID와 변경 뒤 from/to 잔액

정확한 전체 원문

정확한 전체 원문 펼치기
정확한 전체 원문 · 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 · 업무 entity와 repository import

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

한 줄 읽기: 이체가 함께 바꿀 계좌·거래·원장 부품을 불러온다.

문법을 한 줄씩 풀면
package는 주소이고 import는 서로 다른 domain type을 짧은 이름으로 사용하게 한다.
실제 값 추적
아직 balance·business_tx·ledger_entry 변화는 0이다.
정상 예
정상 흐름에서는 아직 balance·business_tx·ledger_entry 변화는 0이다.
반례·경계 예
원장 repository import를 빼면 아래 save가 compile되지 않는다.
착각 방지
import 순서가 DB 잠금 순서를 정하지 않는다.
이 블록이 하지 않는 일
transaction을 시작하지 않는다.
다음 코드와의 연결
다음 조각이 Spring transaction과 collection 도구를 준비하고 service를 연다.

코드 조각 2 · Spring 경계와 정렬 도구

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

한 줄 읽기: service bean·transaction·시각·목록·ID map을 쓸 이름을 준비한다.

문법을 한 줄씩 풀면
@Service가 bean 후보를, @Transactional이 public 경계를, List/HashMap이 잠금 순서와 업무 역할 복원을 지원한다.
실제 값 추적
아직 command도 service 객체도 없다.
정상 예
정상 흐름에서는 아직 command도 service 객체도 없다.
반례·경계 예
@Transactional import만 있고 실제 메서드 annotation이 없으면 경계가 열리지 않는다.
착각 방지
HashMap이 DB row lock을 만든다고 착각하면 안 된다.
이 블록이 하지 않는 일
계좌 ID를 아직 정렬하지 않는다.
다음 코드와의 연결
다음 조각이 입력/출력 record와 네 의존성을 선언한다.

코드 조각 3 · 입력·출력과 네 의존성

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

한 줄 읽기: 이체 요청값·응답값을 record로 고정하고 필요한 저장소·hook을 final 필드로 둔다.

문법을 한 줄씩 풀면
record는 component 접근자를 만들고 final field는 생성 뒤 참조가 바뀌지 않는다.
실제 값 추적
Command(고객1,key,8,3,100)는 다섯 값을 보관하지만 아직 조회하지 않는다.
정상 예
정상 흐름에서는 Command(고객1,key,8,3,100)는 다섯 값을 보관하지만 아직 조회하지 않는다.
반례·경계 예
Command의 transactionId와 저장 뒤 businessTransactionId를 같은 값으로 단정하면 안 된다.
착각 방지
record가 자동으로 검증하거나 저장하지 않는다.
이 블록이 하지 않는 일
잔액·소유권을 검사하지 않는다.
다음 코드와의 연결
다음 조각이 생성자 주입을 끝내고 public transaction 경계를 연다.

코드 조각 4 · 생성자 주입과 기본 hook

해설 바로 앞 원문 조각 · java
        AccountRepository accounts, BusinessTransactionRepository transactions,
        LedgerEntryRepository ledger, ObjectProvider<TransferFailureHook> hooks
    ) {
        this.accounts = accounts;
        this.transactions = transactions;
        this.ledger = ledger;
        this.failureHook = hooks.getIfAvailable(() -> TransferFailureHook.NONE);
    }

한 줄 읽기: 세 repository와 선택 failure hook을 받아 정상 기본값 NONE을 보관한다.

문법을 한 줄씩 풀면
ObjectProvider.getIfAvailable은 bean이 없을 때 supplier의 TransferFailureHook.NONE을 고른다.
실제 값 추적
정상 context에서 failureHook은 아무 예외도 던지지 않는 객체다.
정상 예
정상 흐름에서는 정상 context에서 failureHook은 아무 예외도 던지지 않는 객체다.
반례·경계 예
NONE을 rollback 비활성화 설정으로 오해하면 안 된다. 호출이 조용할 뿐 transaction 규칙은 그대로다.
착각 방지
생성자 실행이 이체 transaction을 시작하지 않는다.
이 블록이 하지 않는 일
failure hook을 실행하지 않는다.
다음 코드와의 연결
다음 조각의 @Transactional transfer가 입력 검사 뒤 두 ID를 정렬해 잠근다.

코드 조각 5 · transaction과 작은 ID 우선 잠금

해설 바로 앞 원문 조각 · java
    @Transactional
    public Result transfer(Command command) {
        validate(command);
        var locked = accounts.findAllForUpdateOrderById(
            List.of(command.fromAccountId(), command.toAccountId()).stream().sorted().toList());
        if (locked.size() != 2) throw new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found");
        var byId = new HashMap<Long, Account>();
        locked.forEach(account -> byId.put(account.getId(), account));
        Account from = byId.get(command.fromAccountId());

한 줄 읽기: 업무 방향과 관계없이 작은 계좌 ID부터 두 행을 잠근다.

문법을 한 줄씩 풀면
public method의 @Transactional 안에서 from/to ID list를 sorted한 뒤 findAllForUpdateOrderById에 넘기고 size2를 검사한다.
실제 값 추적
from=8,to=3이면 입력 list와 JPQL 결과 잠금 순서는 3,8이며 두 행이 있어야 진행한다.
정상 예
정상 흐름에서는 from=8,to=3이면 입력 list와 JPQL 결과 잠금 순서는 3,8이며 두 행이 있어야 진행한다.
반례·경계 예
한 호출이 정렬해도 다른 코드가 8→3으로 잠그면 시스템 전체 cycle 가능성을 제거했다고 단정할 수 없다.
착각 방지
정렬은 돈의 이동 방향을 뒤집는 동작이 아니다.
이 블록이 하지 않는 일
아직 잔액을 바꾸지 않고 retry하지 않는다.
다음 코드와의 연결
다음 조각이 ID map으로 from/to 역할을 되찾고 owner를 검사한다.

코드 조각 6 · 업무 방향 복원과 한 묶음 변경

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

한 줄 읽기: 잠금 결과를 원래 from/to 역할로 되찾아 출금·입금·거래·원장 두 행을 함께 바꾼다.

문법을 한 줄씩 풀면
owner 검사 뒤 domain method, 공통 Instant, transaction save, OUT/IN save, hook, Result 순서다.
실제 값 추적
8→3 100원이면 8에서100 출금, 3에100 입금하고 signed 원장 합은 -100+100=0이다.
정상 예
정상 흐름에서는 8→3 100원이면 8에서100 출금, 3에100 입금하고 signed 원장 합은 -100+100=0이다.
반례·경계 예
잠긴 list 첫 원소를 무조건 from으로 쓰면 from ID가 큰 요청에서 반대로 돈을 뺀다.
착각 방지
HashMap은 역할 복원용이며 새 잠금을 만들지 않는다.
이 블록이 하지 않는 일
idempotency_request, retry, 외부 전송을 수행하지 않는다.
다음 코드와의 연결
다음 조각이 잘못된 command를 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");
    }
}

한 줄 읽기: 비어 있거나 같거나 0 이하인 입력을 잠금 조회 전에 즉시 거절한다.

문법을 한 줄씩 풀면
각 if는 한 계약 위반을 IllegalArgumentException으로 바꾸고 정상 command만 반환 없이 끝까지 통과한다.
실제 값 추적
actor/key가 있고 ID가 서로 다른 양수, amount=100이면 통과한다.
정상 예
정상 흐름에서는 actor/key가 있고 ID가 서로 다른 양수, amount=100이면 통과한다.
반례·경계 예
양수 amount여도 잔액보다 크면 이 helper가 아니라 Account.withdraw가 막는다.
착각 방지
validate 통과는 계좌 존재·소유권·충분한 잔액을 보장하지 않는다.
이 블록이 하지 않는 일
오류를 HTTP 상태로 번역하지 않는다.
다음 코드와의 연결
다음 누적 Account에서 withdraw 규칙과 @Version 선언을 확인한다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/transfer/TransferService.java
전제조건
Account/repository 세 개, BusinessException/ErrorCode, TransferFailureHook, Spring transaction proxy가 필요하다.
반드시 지킬 계약
public @Transactional, ID 정렬+ORDER BY 잠금, 업무 방향 복원, 소유권, 출금/입금/거래/원장/hook/Result 순서를 보존한다.
추천 입력 순서
import/record → 의존성/생성자 → transaction/정렬 잠금 → 방향 복원 → 변경 묶음 → validate 순서다.
자기 점검
from=8,to=3이어도 잠금은3→8이고 출금은8에서 되며, W10 test에서 20 Future·총액20,000·원장합0을 본다.
이번 파일의 범위 밖
retry, idempotency claim, 외부 메시지, lock timeout, 모든 서비스의 공통 잠금 순서는 구현하지 않는다.

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

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

6. Account

누적 production support · W6D2 solution (@Version 선언 근거)토요일 optimistic 실행 지원src/main/java/com/example/financialcore/account/Account.javaSHA-256 2eef343d2c3600782d0ac808b80ea1b68d58d163e03d323a7475022715d211a3

한 문장 역할: 계좌 상태·잔액 규칙과 JPA @Version 필드를 가진 production entity

네 칸 계약 카드

계약 질문이 파일의 답
누가 부르나계좌 개설 서비스, AccountRepository, TransferService, OptimisticAccountIT
무엇을 받나owner/accountNo/초기 잔액 또는 deposit/withdraw 양수 금액
무엇이 바뀌나활성 계좌의 balance가 바뀌고 JPA update 성공 때 version이 증가
무엇을 돌려주나factory가 Account를 만들고 domain method는 상태를 바꾸며 getter가 현재 값을 읽음

정확한 전체 원문

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

import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

@Entity
@Table(name = "account")
public class Account {
    public enum Status { ACTIVE, CLOSED }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "account_no", nullable = false, unique = true, length = 32)
    private String accountNo;

    @Column(name = "owner_id", nullable = false, length = 64)
    private String ownerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private Status status;

    @Column(nullable = false, length = 3)
    private String currency;

    @Column(nullable = false)
    private long balance;

    @Version
    private long version;

    protected Account() {}

    private Account(String ownerId, String accountNo, long initialBalance) {
        if (ownerId == null || ownerId.isBlank()) throw new IllegalArgumentException("ownerId");
        if (accountNo == null || accountNo.isBlank()) throw new IllegalArgumentException("accountNo");
        if (initialBalance < 0) throw new IllegalArgumentException("initialBalance");
        this.ownerId = ownerId;
        this.accountNo = accountNo;
        this.status = Status.ACTIVE;
        this.currency = "KRW";
        this.balance = initialBalance;
    }

    public static Account open(String accountNo, long initialBalance) {
        return new Account("system", accountNo, initialBalance);
    }

    public static Account open(String ownerId, String accountNo, long initialBalance) {
        return new Account(ownerId, accountNo, initialBalance);
    }

    public void deposit(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        this.balance = Math.addExact(this.balance, amount);
    }

    public void withdraw(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        if (balance < amount) {
            throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE, "insufficient balance");
        }
        this.balance -= amount;
    }

    private void requireActive() {
        if (status != Status.ACTIVE) throw new IllegalStateException("account is not active");
    }

    public Long getId() { return id; }
    public String getAccountNo() { return accountNo; }
    public String getOwnerId() { return ownerId; }
    public Status getStatus() { return status; }
    public String getCurrency() { return currency; }
    public long getBalance() { return balance; }
    public long getVersion() { return version; }
}

코드 조각 1 · 업무 예외와 JPA 기본 annotation

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

import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;

한 줄 읽기: 계좌를 DB entity로 매핑하고 업무 규칙 실패를 표현할 도구를 불러온다.

문법을 한 줄씩 풀면
Column/Entity/Enum/GeneratedValue는 table·column·ID·상태 저장 모양을 설명한다.
실제 값 추적
아직 Account class와 field는 선언되지 않았다.
정상 예
정상 흐름에서는 아직 Account class와 field는 선언되지 않았다.
반례·경계 예
BusinessException import가 없으면 잔액 부족 분기가 compile되지 않는다.
착각 방지
JPA import만으로 table이 자동 생성된다는 보장은 없다.
이 블록이 하지 않는 일
계좌를 생성하거나 update하지 않는다.
다음 코드와의 연결
다음 조각이 ID 전략과 @Version까지 필요한 나머지 annotation을 불러와 class를 연다.

코드 조각 2 · @Version import와 entity 시작

해설 바로 앞 원문 조각 · java
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

@Entity
@Table(name = "account")
public class Account {
    public enum Status { ACTIVE, CLOSED }

한 줄 읽기: account table에 매핑할 class와 상태 두 값을 선언한다.

문법을 한 줄씩 풀면
Id/Table/Version을 import하고 @Entity, @Table(name=account)가 class mapping을 고정한다.
실제 값 추적
Status는 ACTIVE 또는 CLOSED이며 id는 아직 null이다.
정상 예
정상 흐름에서는 Status는 ACTIVE 또는 CLOSED이며 id는 아직 null이다.
반례·경계 예
@Version import만 하고 field에 annotation을 붙이지 않으면 optimistic 조건이 없다.
착각 방지
enum 두 값이 상태 전이 메서드까지 자동 제공하는 것은 아니다.
이 블록이 하지 않는 일
version을 아직 선언하지 않는다.
다음 코드와의 연결
다음 조각이 ID·계좌번호·소유자·상태 field를 매핑한다.

코드 조각 3 · ID·계좌번호·소유자

해설 바로 앞 원문 조각 · java
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "account_no", nullable = false, unique = true, length = 32)
    private String accountNo;

    @Column(name = "owner_id", nullable = false, length = 64)
    private String ownerId;

한 줄 읽기: DB가 만든 ID와 유일 계좌번호, 필수 소유자를 field로 둔다.

문법을 한 줄씩 풀면
IDENTITY는 insert 때 DB가 ID를 만들고 nullable/unique/length는 column 계약이다.
실제 값 추적
새 Java 객체는 id=null이고 정상 저장 뒤 Long ID를 얻는다.
정상 예
정상 흐름에서는 새 Java 객체는 id=null이고 정상 저장 뒤 Long ID를 얻는다.
반례·경계 예
같은 accountNo 두 행은 DB unique에서 거절된다.
착각 방지
ownerId length64가 소유권 검사를 자동 수행하는 것은 아니다.
이 블록이 하지 않는 일
잔액을 변경하지 않는다.
다음 코드와의 연결
다음 조각이 상태·통화·잔액과 version을 선언한다.

코드 조각 4 · 상태·KRW·잔액과 @Version 표지

해설 바로 앞 원문 조각 · java
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private Status status;

    @Column(nullable = false, length = 3)
    private String currency;

    @Column(nullable = false)
    private long balance;

한 줄 읽기: 계좌의 업무 상태와 돈, 낙관 충돌용 세대 번호를 한 entity에 둔다.

문법을 한 줄씩 풀면
EnumType.STRING은 상태 이름을 저장하고 @Version은 update SQL의 이전 version 조건과 증가를 JPA에 맡긴다.
실제 값 추적
개설 직후 ACTIVE/KRW/balance10,000/version0이고 첫 성공 update 뒤 version1이다.
정상 예
정상 흐름에서는 개설 직후 ACTIVE/KRW/balance10,000/version0이고 첫 성공 update 뒤 version1이다.
반례·경계 예
@Version을 제거하면 OptimisticAccountIT 두 update가 둘 다 commit될 수 있어 기대 결과가 깨진다.
착각 방지
version은 시간이나 retry 횟수가 아니라 entity update 세대 번호다.
이 블록이 하지 않는 일
field 선언만으로 transaction을 열지 않는다.
다음 코드와의 연결
다음 조각이 JPA용 빈 생성자와 입력 검사를 시작한다.

코드 조각 5 · JPA 생성자와 입력 선검사

해설 바로 앞 원문 조각 · java
    @Version
    private long version;

    protected Account() {}

    private Account(String ownerId, String accountNo, long initialBalance) {
        if (ownerId == null || ownerId.isBlank()) throw new IllegalArgumentException("ownerId");
        if (accountNo == null || accountNo.isBlank()) throw new IllegalArgumentException("accountNo");
        if (initialBalance < 0) throw new IllegalArgumentException("initialBalance");
        this.ownerId = ownerId;

한 줄 읽기: JPA가 쓸 보호 생성자와 정상 계좌만 만드는 private 생성자를 분리한다.

문법을 한 줄씩 풀면
protected no-arg는 framework용이고 private 생성자는 owner/accountNo 공백과 음수 초기잔액을 즉시 거절한다.
실제 값 추적
owner=customer-1, accountNo=A-1, initial=10,000이면 검사를 통과한다.
정상 예
정상 흐름에서는 owner=customer-1, accountNo=A-1, initial=10,000이면 검사를 통과한다.
반례·경계 예
initialBalance=-1이나 blank owner면 field 대입 전에 예외다.
착각 방지
protected 생성자를 업무 코드에서 빈 계좌 factory처럼 쓰면 안 된다.
이 블록이 하지 않는 일
DB save나 version 증가를 실행하지 않는다.
다음 코드와의 연결
다음 조각이 정상 기본 상태를 대입하고 두 공개 factory를 제공한다.

코드 조각 6 · 기본 상태와 두 open factory

해설 바로 앞 원문 조각 · java
        this.accountNo = accountNo;
        this.status = Status.ACTIVE;
        this.currency = "KRW";
        this.balance = initialBalance;
    }

    public static Account open(String accountNo, long initialBalance) {
        return new Account("system", accountNo, initialBalance);
    }

한 줄 읽기: 새 계좌를 ACTIVE·KRW·지정 잔액으로 만들고 system/지정 owner 두 진입점을 연다.

문법을 한 줄씩 풀면
static factory는 private 생성자를 호출해 같은 검증을 재사용한다.
실제 값 추적
open(A-1,10000)은 owner=system, 세 인자 open은 지정 owner를 보관한다.
정상 예
정상 흐름에서는 open(A-1,10000)은 owner=system, 세 인자 open은 지정 owner를 보관한다.
반례·경계 예
두 factory의 인자 순서를 섞으면 owner와 accountNo 의미가 뒤바뀐다.
착각 방지
factory 반환은 아직 영속화된 row나 ID를 보장하지 않는다.
이 블록이 하지 않는 일
repository.save를 호출하지 않는다.
다음 코드와의 연결
다음 조각이 활성 계좌의 양수 입금을 안전한 덧셈으로 처리한다.

코드 조각 7 · 활성 계좌의 양수 입금

해설 바로 앞 원문 조각 · java
    public static Account open(String ownerId, String accountNo, long initialBalance) {
        return new Account(ownerId, accountNo, initialBalance);
    }

    public void deposit(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        this.balance = Math.addExact(this.balance, amount);
    }

한 줄 읽기: 닫힌 계좌와 0 이하 금액을 막고 overflow가 없는 범위에서 잔액을 더한다.

문법을 한 줄씩 풀면
requireActive 뒤 amount 검사, Math.addExact 순서라 long overflow는 ArithmeticException으로 드러난다.
실제 값 추적
balance10,000에 deposit100이면 10,100이다.
정상 예
정상 흐름에서는 balance10,000에 deposit100이면 10,100이다.
반례·경계 예
amount0/-1, CLOSED, long overflow면 정상 입금으로 끝나지 않는다.
착각 방지
deposit이 row lock이나 retry를 자동 제공하는 것은 아니다.
이 블록이 하지 않는 일
원장 행을 만들지 않는다.
다음 코드와의 연결
다음 조각이 출금의 양수·잔액 충분 조건과 실제 차감을 끝낸다.

코드 조각 8 · 활성·양수·충분 잔액 출금

해설 바로 앞 원문 조각 · java
    public void withdraw(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        if (balance < amount) {
            throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE, "insufficient balance");
        }
        this.balance -= amount;
    }

한 줄 읽기: 출금 전에 세 조건을 확인하고 충분할 때만 balance를 줄인다.

문법을 한 줄씩 풀면
balance<amount면 INSUFFICIENT_BALANCE BusinessException, 아니면 balance -= amount다.
실제 값 추적
10,000에서 withdraw1,000이면 9,000이며 OptimisticAccountIT의 성공 worker가 이 값을 만든다.
정상 예
정상 흐름에서는 10,000에서 withdraw1,000이면 9,000이며 OptimisticAccountIT의 성공 worker가 이 값을 만든다.
반례·경계 예
10,000에서 10,001을 빼려 하면 balance를 바꾸기 전에 업무 예외다.
착각 방지
withdraw 성공이 transaction commit 성공을 뜻하지 않는다. @Version 충돌이면 rollback될 수 있다.
이 블록이 하지 않는 일
다른 계좌 입금이나 원장 저장을 하지 않는다.
다음 코드와의 연결
다음 조각이 ACTIVE 공통 검사와 기본 getter를 제공한다.

코드 조각 9 · ACTIVE 검사와 식별·상태 getter

해설 바로 앞 원문 조각 · java
    private void requireActive() {
        if (status != Status.ACTIVE) throw new IllegalStateException("account is not active");
    }

    public Long getId() { return id; }
    public String getAccountNo() { return accountNo; }
    public String getOwnerId() { return ownerId; }
    public Status getStatus() { return status; }
    public String getCurrency() { return currency; }

한 줄 읽기: 닫힌 계좌를 공통으로 거절하고 ID부터 currency까지 현재 값을 읽게 한다.

문법을 한 줄씩 풀면
requireActive는 status를 비교하고 getter는 field를 그대로 반환한다.
실제 값 추적
활성 계좌에서 id/accountNo/owner/status/currency를 각각 읽을 수 있다.
정상 예
정상 흐름에서는 활성 계좌에서 id/accountNo/owner/status/currency를 각각 읽을 수 있다.
반례·경계 예
CLOSED에서 deposit/withdraw하면 getter가 아니라 requireActive가 예외를 낸다.
착각 방지
getter가 DB를 새로 조회하거나 lock을 잡는 것은 아니다.
이 블록이 하지 않는 일
field를 수정하거나 충돌을 해결하지 않는다.
다음 코드와의 연결
마지막 조각이 balance와 version getter로 optimistic 관찰값을 노출한다.

코드 조각 10 · balance·version getter와 class 닫기

해설 바로 앞 원문 조각 · java
    public long getBalance() { return balance; }
    public long getVersion() { return version; }
}

한 줄 읽기: 최종 잔액과 JPA 세대 번호를 읽는 두 getter로 entity를 닫는다.

문법을 한 줄씩 풀면
두 getter는 primitive long을 그대로 반환하며 version setter는 없다.
실제 값 추적
성공 optimistic commit 뒤 getBalance=9,000, getVersion=1이다.
정상 예
정상 흐름에서는 성공 optimistic commit 뒤 getBalance=9,000, getVersion=1이다.
반례·경계 예
영속성 context의 오래된 객체를 읽으면 새 DB 상태와 다를 수 있다.
착각 방지
application이 version을 수동 증가시켜야 한다는 뜻이 아니다.
이 블록이 하지 않는 일
retry나 conflict merge를 구현하지 않는다.
다음 코드와의 연결
다음 W10 신규 OptimisticAccountIT가 실제 충돌 결과를 검증한다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/account/Account.java
전제조건
JPA/Hibernate, account table의 version column, BusinessException/ErrorCode가 필요하다.
반드시 지킬 계약
ID identity, accountNo unique, owner/status/currency/balance, @Version long version, 생성 검증, 활성/양수/잔액 규칙을 보존한다.
추천 입력 순서
package/import → entity/field → @Version → 생성자/factory → deposit → withdraw → active 검사 → getter 순서다.
자기 점검
@Version이 정확히 version 필드 바로 위에 있고 OptimisticAccountIT 결과 9,000/version1과 연결되는지 본다.
이번 파일의 범위 밖
retry, conflict merge, 외부 lock, transaction 경계, 계좌 간 잠금 순서, HTTP 오류 번역은 entity가 맡지 않는다.

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

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

import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

@Entity
@Table(name = "account")
public class Account {
    public enum Status { ACTIVE, CLOSED }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "account_no", nullable = false, unique = true, length = 32)
    private String accountNo;

    @Column(name = "owner_id", nullable = false, length = 64)
    private String ownerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private Status status;

    @Column(nullable = false, length = 3)
    private String currency;

    @Column(nullable = false)
    private long balance;

    @Version
    private long version;

    protected Account() {}

    private Account(String ownerId, String accountNo, long initialBalance) {
        if (ownerId == null || ownerId.isBlank()) throw new IllegalArgumentException("ownerId");
        if (accountNo == null || accountNo.isBlank()) throw new IllegalArgumentException("accountNo");
        if (initialBalance < 0) throw new IllegalArgumentException("initialBalance");
        this.ownerId = ownerId;
        this.accountNo = accountNo;
        this.status = Status.ACTIVE;
        this.currency = "KRW";
        this.balance = initialBalance;
    }

    public static Account open(String accountNo, long initialBalance) {
        return new Account("system", accountNo, initialBalance);
    }

    public static Account open(String ownerId, String accountNo, long initialBalance) {
        return new Account(ownerId, accountNo, initialBalance);
    }

    public void deposit(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        this.balance = Math.addExact(this.balance, amount);
    }

    public void withdraw(long amount) {
        requireActive();
        if (amount <= 0) throw new IllegalArgumentException("amount");
        if (balance < amount) {
            throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE, "insufficient balance");
        }
        this.balance -= amount;
    }

    private void requireActive() {
        if (status != Status.ACTIVE) throw new IllegalStateException("account is not active");
    }

    public Long getId() { return id; }
    public String getAccountNo() { return accountNo; }
    public String getOwnerId() { return ownerId; }
    public Status getStatus() { return status; }
    public String getCurrency() { return currency; }
    public long getBalance() { return balance; }
    public long getVersion() { return version; }
}
같은 version0을 읽은 두 작업 중 한 update만 성공해 version1이 남는 optimistic 충돌 개념도다.

JUnit 네 메서드 · AAA와 보장 경계

고유 @Test는 4개다. SortedLockTransferIT 한 메서드를 수·목·금에 세 번 실행하므로 월~토 실행 합계는 6회다. 아래 보장 문장은 메서드 이름이 아니라 실제 assertion만 기준으로 썼다.

test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update

월 · LostUpdateBaselineIT · carried-direct

준비(Arrange)
10,000원/version0 계좌, worker2, readBarrier(2), worker별 REQUIRES_NEW를 준비한다.
행동(Act)
두 worker가 모두 balance를 먼저 읽은 뒤 version 조건 없는 raw UPDATE로 observed-1,000을 쓴다.
확인(Assert)
Future true 수 2, 새 DB balance 9,000, version 0을 exact하게 확인한다.
직접 보장
이 test-only versionless 경로에서 두 작업이 정상 반환해도 한 번의 1,000원 변화가 사라지는 기준선을 재현한다.
직접 보장하지 않음
production Account의 @Version 취약성, 모든 격리수준·스케줄, 평상시 발생 확률은 직접 보장하지 않는다.
원문 아닌 강화 예시
강화 예시라면 worker별 observed=[10000,10000]도 수집할 수 있으나 canonical assertion에는 없다.

production_pessimistic_lock_path_commits_twice_and_preserves_both_updates

화 · LostUpdateBaselineIT · carried-direct

준비(Arrange)
같은 10,000원 계좌, worker2, start latch(1), 각 worker의 REQUIRES_NEW를 준비한다.
행동(Act)
각 transaction이 SELECT ... FOR UPDATE로 현재 값을 읽고 balance=observed-1,000, version=version+1을 쓴다.
확인(Assert)
Future true 수 2와 최종 balance 8,000을 확인한다.
직접 보장
이 단일 행 pessimistic 경로에서 두 번의 1,000원 감소가 모두 잔액에 남는다.
직접 보장하지 않음
최종 version2, lock wait 시간, 처리량, 공정성, 여러 행 deadlock 부재는 직접 assert하지 않는다.
원문 아닌 강화 예시
강화 예시라면 version2와 각 worker 완료 시간을 별도 assertion으로 추가할 수 있지만 원문 보장과 분리한다.

oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal

수·목·금 동일 @Test 재실행 · SortedLockTransferIT · w10-new-direct

준비(Arrange)
각 10,000원 계좌2, pairs10/tasks20, ready20/start1, pool20을 준비한다.
행동(Act)
A→B와 B→A를 각각 10건, 건당100원으로 제출하고 ready10초 뒤 시작해 각 Future를 30초 안에 회수한다.
확인(Assert)
모든 Future 정상 완료, 두 잔액 합 20,000, TRANSFER 원장 signed 합 0을 확인한다.
직접 보장
이 fixture와 제한 시간에서 20개 반대 방향 호출이 끝났고 money/ledger 두 보존식이 유지된다.
직접 보장하지 않음
실제 deadlock 재현, 모든 스케줄의 deadlock 부재, 개별 잔액 동일, 거래/원장 행 수, retry는 보장하지 않는다.
원문 아닌 강화 예시
강화 예시라면 각 방향 성공 수·거래20·원장40·개별 잔액을 추가하되 canonical 세 assertion과 섞지 않는다.

production_versioned_entity_has_one_commit_and_one_optimistic_failure

토 · OptimisticAccountIT · w10-new-direct

준비(Arrange)
10,000원/version0 Account, worker2, readBarrier(2), worker별 REQUIRES_NEW를 준비한다.
행동(Act)
둘 다 같은 entity를 읽은 뒤 withdraw1,000과 repository.flush를 실행하고 예외를 Outcome으로 분류한다.
확인(Assert)
결과가 COMMIT 1·OPTIMISTIC_FAILURE 1이고 최종 balance 9,000, version 1인지 확인한다.
직접 보장
production @Version 경로에서 동시에 읽은 두 update 중 하나만 반영되고 충돌 하나가 관찰된다.
직접 보장하지 않음
자동 retry, backoff, 최대 시도, 고충돌 처리량, 대기시간0, 모든 provider 예외 번역은 보장하지 않는다.
원문 아닌 강화 예시
강화 예시라면 충돌 뒤 새 transaction으로 재조회·재시도하는 별도 정책 시험을 만들 수 있으나 현재 구현에는 없다.
네 @Test의 exact 결과와 그 결과가 아직 말하지 못하는 범위를 분리한다.

SQL workbook · Q11/Q12

두 evidence 경로는 학습자가 만들 위치일 뿐 canonical 답안 파일이 아니다. 앱 V001이 아니라 sql/workbook/fixtures의 customer6·account8·business_tx21 fixture에서 실행한다.

Q11은 거래에서 고객명으로 가는 INNER JOIN, Q12는 계좌에서 시작하는 LEFT anti-join이다.

Q11 · 거래와 고객명을 함께 조회

source 경계: reference project에는 canonical learner SQL answer 파일이 없다. 아래 코드는 PostgreSQL workbook schema/seed 계약을 만족하는 전체 예시 정답이다.

계약 질문이 파일의 답
문제 원문W10-SQL-Q11 · 거래와 고객명을 함께 조회
어느 schema앱 V001이 아닌 PostgreSQL sql/workbook fixture
입력→출력 grainbusiness_tx 거래 1행 → 거래 1행; account/customer는 FK로 이름을 붙임
seed 결과business_tx 21건이 모두 유효한 account/customer를 가져 21행; 계좌 없는 customer5는 출발 거래가 없어 미포함

SQL 조각 1 · 입력과 출력 grain 먼저 고정

해설 바로 앞 SQL 조각 · sql
-- 입력 grain: business_tx 한 행 = 거래 한 건
-- 출력 grain: 거래 한 건당 한 행, workbook seed 예상 21행

한 줄 읽기: 거래 한 건을 출발점으로 고객명 하나를 붙이는 문제라고 먼저 적는다.

문법을 한 줄씩 풀면
SQL 주석은 실행 결과를 바꾸지 않지만 시작 table과 cardinality 계약을 눈앞에 둔다.
실제 값 추적
입력 business_tx=21행이고 각 행의 account_id는 한 account를 가리킨다.
정상 예
정상 seed에서는 출력도 거래별 21행이다.
반례·경계 예
customer에서 시작하면 거래가 없는 customer5까지 생각하게 되어 출력 grain 설명이 흐려진다.
착각 방지
고객 한 행이 아니라 거래 한 행이 출력 단위다.
이 블록이 하지 않는 일
아직 table을 join하거나 column을 출력하지 않는다.
다음 코드와의 연결
다음 조각이 거래 ID와 고객명을 출력 열로 고른다.

SQL 조각 2 · 거래 ID와 고객명

해설 바로 앞 SQL 조각 · sql
SELECT t.tx_id,
       c.customer_name

한 줄 읽기: 거래를 식별할 tx_id와 사람이 읽을 customer_name만 고른다.

문법을 한 줄씩 풀면
alias t/c가 동명 column 혼동을 막고 두 출력 열의 소속을 드러낸다.
실제 값 추적
거래 000...101 같은 tx_id 옆에 그 계좌 소유 고객명이 붙는다.
정상 예
각 출력 행이 어떤 거래와 어떤 고객인지 바로 읽힌다.
반례·경계 예
customer_name만 고르면 같은 고객의 여러 거래를 서로 구분하기 어렵다.
착각 방지
SELECT 열 두 개가 출력 행 수를 2배로 만들지는 않는다.
이 블록이 하지 않는 일
아직 t와 c 사이 경로를 연결하지 않는다.
다음 코드와의 연결
다음 조각이 거래→계좌→고객 FK 사슬을 잇는다.

SQL 조각 3 · 거래에서 계좌, 계좌에서 고객

해설 바로 앞 SQL 조각 · sql
FROM business_tx AS t
JOIN account AS a
  ON a.account_id = t.account_id
JOIN customer AS c
  ON c.customer_id = a.customer_id

한 줄 읽기: 두 FK를 따라 INNER JOIN 두 번으로 고객명에 도착한다.

문법을 한 줄씩 풀면
각 JOIN의 ON은 자식 FK와 부모 PK를 같은 값으로 연결한다.
실제 값 추적
거래21→계좌21 match→고객21 match라 출력21행이며 FK상 orphan은 없다.
정상 예
하나의 거래가 하나의 계좌, 그 계좌가 하나의 고객으로 이어져 행이 늘거나 줄지 않는다.
반례·경계 예
ON을 빼면 21×8×6 Cartesian product가 되어 1,008행 위험이 생긴다.
착각 방지
customer와 business_tx를 customer_id로 직접 join할 수 없다. business_tx에는 customer_id가 없다.
이 블록이 하지 않는 일
거래가 없는 고객을 보존하지 않는다.
다음 코드와의 연결
마지막 조각이 결과 순서를 동률까지 고정한다.

SQL 조각 4 · 발생시각·거래 ID 안정 정렬

해설 바로 앞 SQL 조각 · sql
ORDER BY t.occurred_at, t.tx_id;

한 줄 읽기: 시간이 같아도 tx_id로 순서를 끝까지 고정한다.

문법을 한 줄씩 풀면
첫 key가 시각, 둘째 key가 동률 해소용 총순서다.
실제 값 추적
21행 수는 그대로이고 같은 occurred_at끼리는 tx_id 오름차순이다.
정상 예
재실행해도 표시 순서를 비교하기 쉽다.
반례·경계 예
ORDER BY 없이 눈에 보인 현재 순서를 DB 계약으로 믿으면 안 된다.
착각 방지
정렬은 JOIN 누락이나 중복을 고치지 않는다.
이 블록이 하지 않는 일
최신 N건 제한이나 날짜 필터를 적용하지 않는다.
다음 코드와의 연결
직접 다시 쓴 뒤 아래 전체 예시 정답의 두 JOIN과 21행 계약을 대조한다.

직접 다시 써보기

저장 경로
evidence/w10/sql-q11.sql
전제조건
workbook_schema.sql과 V900 seed가 적용된 PostgreSQL에서 customer6/account8/business_tx21을 확인한다.
반드시 지킬 계약
business_tx에서 시작해 account_id, customer_id FK 순으로 INNER JOIN하고 출력 grain 거래1행·예상21행을 적는다.
추천 입력 순서
grain 주석 → SELECT tx_id/customer_name → business_tx → account JOIN → customer JOIN → 안정 정렬 순서다.
자기 점검
실행 성공, 21행, tx_id 중복0, 고객명 NULL0을 확인한다.
이번 파일의 범위 밖
계좌 없는 고객 목록, 거래 없는 계좌, 거래 집계, 앱 V001 business_tx는 이 문제 범위가 아니다.

전체 예시 정답 · canonical 부재를 구분

직접 쓴 뒤 전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: business_tx 한 행 = 거래 한 건
-- 출력 grain: 거래 한 건당 한 행, workbook seed 예상 21행
SELECT t.tx_id,
       c.customer_name
FROM business_tx AS t
JOIN account AS a
  ON a.account_id = t.account_id
JOIN customer AS c
  ON c.customer_id = a.customer_id
ORDER BY t.occurred_at, t.tx_id;

Q12 · 거래가 한 번도 없는 계좌

source 경계: reference project에는 canonical learner SQL answer 파일이 없다. 아래 코드는 PostgreSQL workbook schema/seed 계약을 만족하는 전체 예시 정답이다.

계약 질문이 파일의 답
문제 원문W10-SQL-Q12 · 거래가 한 번도 없는 계좌
어느 schema앱 V001이 아닌 PostgreSQL sql/workbook fixture
입력→출력 grainaccount 계좌1행 → 거래0건 계좌1행; 중복0
seed 결과account_id 103(A-103), 104(A-104) 정확히 2행

SQL 조각 1 · 계좌 grain과 예상 두 행

해설 바로 앞 SQL 조각 · sql
-- 입력 grain: account 한 행 = 계좌 하나
-- 출력 grain: 거래가 0건인 계좌 하나당 한 행, workbook seed 예상 2행

한 줄 읽기: 모든 계좌에서 출발해 거래가 0건인 계좌만 한 번씩 남긴다고 적는다.

문법을 한 줄씩 풀면
주석이 LEFT JOIN 뒤 중복 가능성과 최종 cardinality를 먼저 고정한다.
실제 값 추적
입력 account=8이고 정답 후보는 103,104 두 계좌다.
정상 예
출력은 계좌 하나당 최대 한 행이어야 한다.
반례·경계 예
business_tx에서 시작하면 거래가 0건인 계좌는 애초에 입력 행이 없어 찾을 수 없다.
착각 방지
거래가 없는 고객이 아니라 거래가 없는 계좌를 찾는다.
이 블록이 하지 않는 일
아직 outer join을 실행하지 않는다.
다음 코드와의 연결
다음 조각이 계좌를 식별할 두 열과 출발 table을 고른다.

SQL 조각 2 · 계좌 ID·번호와 account 출발

해설 바로 앞 SQL 조각 · sql
SELECT a.account_id,
       a.account_no
FROM account AS a

한 줄 읽기: 정답 계좌를 다시 찾을 수 있게 ID와 계좌번호를 출력한다.

문법을 한 줄씩 풀면
FROM account가 거래 0건 계좌까지 포함한 8행 전체를 출발 집합으로 만든다.
실제 값 추적
이 단계에는 account_id101~108 총8행이 있다.
정상 예
모든 계좌가 다음 LEFT JOIN의 왼쪽에 남을 자격을 갖는다.
반례·경계 예
INNER JOIN으로 시작하면 103/104가 사라진다.
착각 방지
SELECT 두 열 때문에 계좌가 중복되는 것이 아니라 오른쪽 거래 매칭 수가 중복을 만든다.
이 블록이 하지 않는 일
아직 거래가 있는 계좌도 제거하지 않는다.
다음 코드와의 연결
다음 조각이 거래를 왼쪽 보존 방식으로 붙이고 NULL 표지만 남긴다.

SQL 조각 3 · LEFT JOIN 뒤 매칭 실패만 남기기

해설 바로 앞 SQL 조각 · sql
LEFT JOIN business_tx AS t
  ON t.account_id = a.account_id
WHERE t.tx_id IS NULL

한 줄 읽기: 거래를 못 찾은 계좌의 오른쪽 PK가 NULL인 행만 고른다.

문법을 한 줄씩 풀면
LEFT JOIN은 왼쪽 계좌를 보존하고, NOT NULL PK인 tx_id의 NULL은 매칭 실패를 뜻한다.
실제 값 추적
거래가 있는 계좌는 한 건 이상 match되어 제거되고 103/104만 NULL 확장 행으로 남는다.
정상 예
무거래 계좌마다 match가 정확히 0이라 결과도 계좌당 한 행이다.
반례·경계 예
WHERE t.account_id IS NOT NULL은 반대로 거래가 있는 계좌를 남기고 거래 수만큼 중복시킨다.
착각 방지
business_tx.failure_reason NULL은 실패 이유 부재이지 거래 부재가 아니다.
이 블록이 하지 않는 일
특정 기간의 거래만 보거나 FAILED 거래를 제외하지 않는다.
다음 코드와의 연결
마지막 조각이 103 다음 104 순으로 결과를 고정한다.

SQL 조각 4 · 계좌 ID 순서 고정

해설 바로 앞 SQL 조각 · sql
ORDER BY a.account_id;

한 줄 읽기: 두 정답을 account_id 오름차순으로 안정되게 표시한다.

문법을 한 줄씩 풀면
PK 한 열 정렬이면 두 계좌의 총순서가 정해진다.
실제 값 추적
결과는 (103,A-103), (104,A-104) 정확히 두 행이다.
정상 예
행 수2와 중복0을 눈으로 대조하기 쉽다.
반례·경계 예
ORDER BY가 없어도 두 행일 수 있지만 표시 순서는 계약이 아니다.
착각 방지
DISTINCT로 중복을 가릴 필요가 없다. anti-join 조건상 무거래 계좌는 한 행이다.
이 블록이 하지 않는 일
계좌별 마지막 거래일을 계산하지 않는다.
다음 코드와의 연결
직접 다시 쓴 뒤 전체 예시 정답의 LEFT JOIN과 t.tx_id IS NULL을 대조한다.

직접 다시 써보기

저장 경로
evidence/w10/sql-q12.sql
전제조건
workbook fixture의 account8/business_tx21과 business_tx.account_id FK를 확인한다.
반드시 지킬 계약
account에서 시작해 LEFT JOIN하고, 매칭 실패 표지 t.tx_id IS NULL만 남기며 계좌1행 grain·중복0을 지킨다.
추천 입력 순서
grain 주석 → SELECT account 식별값 → account → LEFT JOIN business_tx → IS NULL → account_id 정렬 순서다.
자기 점검
정확히 103/A-103과 104/A-104 두 행, account_id 중복0을 확인한다.
이번 파일의 범위 밖
거래가 있는 계좌별 건수, customer 없는 계좌, 실패 거래 제외, 특정 기간 무거래는 이 문제 범위가 아니다.

전체 예시 정답 · canonical 부재를 구분

직접 쓴 뒤 전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 입력 grain: account 한 행 = 계좌 하나
-- 출력 grain: 거래가 0건인 계좌 하나당 한 행, workbook seed 예상 2행
SELECT a.account_id,
       a.account_no
FROM account AS a
LEFT JOIN business_tx AS t
  ON t.account_id = a.account_id
WHERE t.tx_id IS NULL
ORDER BY a.account_id;

마지막 재점검

원문과 답안: Java source 6개는 정확한 전체 원문과 바로 뒤 전체 코드 정답이 normalized exact다. Q11/Q12는 canonical 부재를 밝힌 전체 예시 정답이다.

직접 보장: 무잠금 9,000/version0, 단일 행 잠금 8,000, 반대 방향 이체 총액20,000·원장합0, optimistic 결과 [commit1, conflict1]·9,000/version1까지다.

직접 보장하지 않음: 실제 deadlock 재현, 모든 스케줄의 deadlock 부재, 개별 잔액 동일, retry/backoff, 공정성·처리량은 이 코드 밖이다.