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... | 1 | LostUpdateBaselineIT |
| 화 | LostUpdateBaselineIT.production_pessimistic... | 1 | LostUpdateBaselineIT + AccountRepository |
| 수 | SortedLockTransferIT | 1 | SortedLockTransferIT + AccountRepository + TransferService |
| 목 | SortedLockTransferIT | 1 (같은 메서드 재실행) | SortedLockTransferIT + AccountRepository |
| 금 | SortedLockTransferIT | 1 (같은 메서드 재실행) | SortedLockTransferIT + TransferService |
| 토 | OptimisticAccountIT | 1 | OptimisticAccountIT + Account |
W10 신규 원문 · 2파일
1. SortedLockTransferIT
한 문장 역할: 서로 반대 방향인 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 |
정확한 전체 원문
정확한 전체 원문 펼치기
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 시험 도구
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 단계에서 멈춘다.
- 착각 방지
@SpringBootTestimport만으로 context가 시작된다고 보면 안 된다.- 이 블록이 하지 않는 일
- DB를 조회하거나 이체를 실행하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 JDBC와 동시 실행 도구를 준비한다.
코드 조각 2 · JDBC·동시 실행·assert 도구
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와 네 관찰 대상
@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 전에는
first와second가 아직 비어 있다. - 정상 예
- 정상 흐름에서는 fixture 전에는
first와second가 아직 비어 있다. - 반례·경계 예
new TransferService(...)로 바꾸면 Spring transaction proxy를 거치지 않는다.- 착각 방지
- 필드가 많다고 여러 transaction이 자동으로 열리는 것은 아니다.
- 이 블록이 하지 않는 일
- 아직 어떤 메서드도 호출하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 매 시험을 10,000원 두 계좌에서 시작시킨다.
코드 조각 4 · 20,000원 출발점
@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작업 준비
@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개
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 · 완료·총액·원장 합
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 · 항상 정리하고 호출 인자를 받기
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원 이체
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 하나가 준비를 알리고 공통 출발 뒤 실제 이체를 한 번 호출한다.
- 문법을 한 줄씩 풀면
countDown뒤await하고, 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원인지는 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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);
}
}
}
2. OptimisticAccountIT
한 문장 역할: 같은 @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 |
정확한 전체 원문
정확한 전체 원문 펼치기
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 예외 도구
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 도구
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 · 세 결과와 주입 대상
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 출발
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 지점으로
@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
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
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 경쟁과 직접 예외
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과 기술 실패 분류
}
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 전달
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, 모든 예외 번역은 구현하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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);
}
}
}
비관 경로
먼저 행을 잠금SELECT ... FOR UPDATE충돌 작업은 기다린 뒤 최신 값을 읽음낙관 경로
먼저 읽고 저장 때 검사UPDATE ... WHERE version = ?한 작업은 실패; 현재 코드는 retry 없음이번 주 누적 정본·지원 · 4파일
3. LostUpdateBaselineIT
한 문장 역할: 잠금 없는 의도적 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 |
정확한 전체 원문
정확한 전체 원문 펼치기
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
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
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
@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원 출발과 무잠금 장치
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 읽기
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 쓰기와 완료 회수
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과 잠금 시험 선언
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 입구
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 읽기
.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
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 재조회
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
}
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 복원과 파일 닫기
} 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 부재, 처리량은 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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
한 문장 역할: 단일 계좌 잠금과 여러 계좌 ID 오름차순 잠금을 JPQL과 PESSIMISTIC_WRITE로 선언
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 계좌 조회 기능, LostUpdateBaselineIT 설명, TransferService의 두 계좌 잠금 |
| 무엇을 받나 | 계좌 번호·ID 하나 또는 ID 목록 |
| 무엇이 바뀌나 | 조회 transaction 동안 대상 행에 pessimistic write lock을 잡지만 entity 값은 조회만으로 바꾸지 않음 |
| 무엇을 돌려주나 | Optional 계좌/owner 또는 ID 오름차순의 잠긴 Account 목록 |
정확한 전체 원문
정확한 전체 원문 펼치기
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
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도 잠금도 실행되지 않았다.
- 반례·경계 예
@Lockimport가 빠지면 아래 annotation을 해석하지 못한다.- 착각 방지
- import만으로 모든 find가 잠금 조회가 되지는 않는다.
- 이 블록이 하지 않는 일
- transaction을 열거나 account 값을 바꾸지 않는다.
- 다음 코드와의 연결
- 다음 조각이 기본 조회와 owner projection을 선언한다.
코드 조각 2 · 기본 조회와 owner projection
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 오름차순 잠금
@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
Accountentity와 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 실행계획, 모든 호출 경로의 정렬 일관성은 정하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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 요청
업무 방향 A → B잠금: min(A,B) → max(A,B)B → A 요청
업무 방향 B → A잠금: min(A,B) → max(A,B)5. TransferService
한 문장 역할: 두 계좌 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 잔액 |
정확한 전체 원문
정확한 전체 원문 펼치기
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
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 경계와 정렬 도구
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 · 입력·출력과 네 의존성
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
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 우선 잠금
@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 · 업무 방향 복원과 한 묶음 변경
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 · 다섯 입력 선검사
}
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, 모든 서비스의 공통 잠금 순서는 구현하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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
한 문장 역할: 계좌 상태·잔액 규칙과 JPA @Version 필드를 가진 production entity
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 계좌 개설 서비스, AccountRepository, TransferService, OptimisticAccountIT |
| 무엇을 받나 | owner/accountNo/초기 잔액 또는 deposit/withdraw 양수 금액 |
| 무엇이 바뀌나 | 활성 계좌의 balance가 바뀌고 JPA update 성공 때 version이 증가 |
| 무엇을 돌려주나 | factory가 Account를 만들고 domain method는 상태를 바꾸며 getter가 현재 값을 읽음 |
정확한 전체 원문
정확한 전체 원문 펼치기
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
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 시작
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·계좌번호·소유자
@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 표지
@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 생성자와 입력 선검사
@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
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 · 활성 계좌의 양수 입금
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 · 활성·양수·충분 잔액 출금
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
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 닫기
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,
accounttable의 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가 맡지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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; }
}
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으로 재조회·재시도하는 별도 정책 시험을 만들 수 있으나 현재 구현에는 없다.
직접 보장
9,000 / 8,000 / 20,000·0 / 9,000·v1네 @Test의 exact assertion직접 보장하지 않음
실제 deadlock · retry · 공정성 · 처리량이름이나 설명에서 넓혀 말하지 않기SQL workbook · Q11/Q12
두 evidence 경로는 학습자가 만들 위치일 뿐 canonical 답안 파일이 아니다. 앱 V001이 아니라 sql/workbook/fixtures의 customer6·account8·business_tx21 fixture에서 실행한다.
Q11 · 거래에서 출발
business_tx 21 → account → customerINNER JOIN · 거래 grain 21행Q12 · 계좌에서 출발
account 8 LEFT JOIN business_txt.tx_id IS NULL · 103/104 두 행Q11 · 거래와 고객명을 함께 조회
source 경계: reference project에는 canonical learner SQL answer 파일이 없다. 아래 코드는 PostgreSQL workbook schema/seed 계약을 만족하는 전체 예시 정답이다.
| 계약 질문 | 이 파일의 답 |
|---|---|
| 문제 원문 | W10-SQL-Q11 · 거래와 고객명을 함께 조회 |
| 어느 schema | 앱 V001이 아닌 PostgreSQL sql/workbook fixture |
| 입력→출력 grain | business_tx 거래 1행 → 거래 1행; account/customer는 FK로 이름을 붙임 |
| seed 결과 | business_tx 21건이 모두 유효한 account/customer를 가져 21행; 계좌 없는 customer5는 출발 거래가 없어 미포함 |
SQL 조각 1 · 입력과 출력 grain 먼저 고정
-- 입력 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와 고객명
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 · 거래에서 계좌, 계좌에서 고객
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 안정 정렬
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 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 입력 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 |
| 입력→출력 grain | account 계좌1행 → 거래0건 계좌1행; 중복0 |
| seed 결과 | account_id 103(A-103), 104(A-104) 정확히 2행 |
SQL 조각 1 · 계좌 grain과 예상 두 행
-- 입력 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 출발
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 뒤 매칭 실패만 남기기
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 순서 고정
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 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 입력 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, 공정성·처리량은 이 코드 밖이다.