WEEK 11 · CODE AFTERPARTY
W11 코드 뒤풀이 · 동시성은 숫자와 경계로 확인한다
학습 범위: PDF p309 overview부터 p353 일요일 제목 직전까지 · canonical final source 4개 · 고유 @Test 6개 · final Green 실행 9회 · SQL Q13/Q14 전체 예시 정답
먼저 잡는 전체 실행 지도
W11의 final 원문은 신규 3파일과 W10에서 가져온 direct test 1파일이다. selector 바디는 test 3파일이고 WithdrawalService는 화요일 Red를 Green으로 바꾸는 production target이다.
W11 신규 final · 3파일 / @Test 5개
이번 주에 새로 생긴 원문
ConcurrencyHarnessTest · ConcurrentWithdraw20IT · WithdrawalService
누적 carried direct · 1파일 / @Test 1개
W10에서 가져와 금요일에 다시 실행
SortedLockTransferIT
| 요일 | exact selector | final Green @Test | 읽을 source |
|---|---|---|---|
| 월 | ConcurrencyHarnessTest.fixed_pool_bounds_observed_activity_and_accounts_for_all_tasks | 1 | ConcurrencyHarnessTest |
| 화 | ConcurrentWithdraw20IT | 1 · 의도적 Red 1회 추가 | ConcurrentWithdraw20IT + WithdrawalService final |
| 수 | ConcurrentWithdraw20IT | 1 | ConcurrentWithdraw20IT + WithdrawalService final |
| 목 | ConcurrencyHarnessTest.ready_timeout_is_a_failure_and_never_a_green_run | 1 | ConcurrencyHarnessTest |
| 금 | SortedLockTransferIT | 1 | SortedLockTransferIT |
| 토 | ConcurrencyHarnessTest | 4 | ConcurrencyHarnessTest |
W11 신규 final 원문 · 3파일
1. ConcurrencyHarnessTest
한 문장 역할: 고정 thread pool의 관찰 동시 수·작업 결과 회수·ready fail-closed·통계/표본 모양 계약을 독립적으로 시험
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W11 월요일 첫 메서드, 목요일 timeout 메서드, 토요일 class 전체 selector |
| 무엇을 받나 | worker/task/readyTarget/timeout 또는 before·after Sample 목록 |
| 무엇이 바뀌나 | 시험 내부 atomic counter와 synthetic Future/표본만 바뀌며 업무 DB는 사용하지 않음 |
| 무엇을 돌려주나 | RunResult 또는 variant별 median/nearest-rank p95 Stats, 잘못된 계약에는 예외 |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrencyHarnessTest {
record RunResult(int success, int businessFailure, int technicalFailure, int observedMaxActive) {}
record Sample(String variant, double millis) {}
record Stats(double median, double nearestRankP95) {}
@Test
void fixed_pool_bounds_observed_activity_and_accounts_for_all_tasks() throws Exception {
for (int workers : List.of(10, 50, 200)) {
RunResult result = run(workers, 1_000, Math.min(workers, 1_000), Duration.ofSeconds(10));
assertThat(result.observedMaxActive()).isBetween(1, workers);
assertThat(result.success() + result.businessFailure() + result.technicalFailure())
.isEqualTo(1_000);
assertThat(result.technicalFailure()).isZero();
}
}
@Test
void ready_timeout_is_a_failure_and_never_a_green_run() {
assertThatThrownBy(() -> run(2, 2, 3, Duration.ofMillis(100)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ready timeout");
}
@Test
void even_median_and_nearest_rank_p95_match_the_independent_vector() {
List<Double> values = IntStream.rangeClosed(1, 30).mapToObj(i -> (double) i).toList();
Stats stats = statistics(values);
assertThat(stats.median()).isEqualTo(15.5);
assertThat(stats.nearestRankP95()).isEqualTo(29.0);
}
@Test
void benchmark_contract_requires_exactly_before_and_after_with_thirty_rows_each() {
List<Sample> valid = new ArrayList<>();
for (String variant : List.of("before", "after")) {
for (int value = 1; value <= 30; value++) valid.add(new Sample(variant, value));
}
assertThat(validateAndSummarize(valid)).containsOnlyKeys("before", "after");
assertThatThrownBy(() -> validateAndSummarize(valid.subList(0, 59)))
.isInstanceOf(IllegalArgumentException.class);
List<Sample> extra = new ArrayList<>(valid);
extra.add(new Sample("other", 1));
assertThatThrownBy(() -> validateAndSummarize(extra))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> validateAndSummarize(List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
static RunResult run(
int workers, int tasks, int readyTarget, Duration readyTimeout
) throws Exception {
if (workers <= 0 || tasks <= 0 || readyTarget <= 0) throw new IllegalArgumentException();
var ready = new CountDownLatch(readyTarget);
var start = new CountDownLatch(1);
var active = new AtomicInteger();
var maxActive = new AtomicInteger();
var pool = Executors.newFixedThreadPool(workers);
List<Future<String>> futures = new ArrayList<>();
try {
for (int i = 0; i < tasks; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
int now = active.incrementAndGet();
maxActive.accumulateAndGet(now, Math::max);
try {
LockSupport.parkNanos(100_000L);
return "SUCCESS";
} finally {
active.decrementAndGet();
}
}));
}
if (!ready.await(readyTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("ready timeout");
}
start.countDown();
int success = 0;
int business = 0;
int technical = 0;
for (Future<String> future : futures) {
try {
if ("SUCCESS".equals(future.get(30, TimeUnit.SECONDS))) success++;
else business++;
} catch (Exception failure) {
technical++;
}
}
return new RunResult(success, business, technical, maxActive.get());
} finally {
start.countDown();
pool.shutdownNow();
}
}
static Map<String, Stats> validateAndSummarize(List<Sample> samples) {
Map<String, List<Double>> byVariant = samples.stream().collect(Collectors.groupingBy(
Sample::variant, Collectors.mapping(Sample::millis, Collectors.toList())
));
if (!byVariant.keySet().equals(Set.of("before", "after"))) {
throw new IllegalArgumentException("variants must be exactly before and after");
}
if (byVariant.values().stream().anyMatch(values -> values.size() != 30 || values.isEmpty())) {
throw new IllegalArgumentException("each variant must contain exactly 30 rows");
}
return byVariant.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, entry -> statistics(entry.getValue())
));
}
static Stats statistics(List<Double> source) {
if (source.isEmpty()) throw new IllegalArgumentException("empty samples");
List<Double> values = source.stream().sorted(Comparator.naturalOrder()).toList();
int n = values.size();
double median = n % 2 == 0
? (values.get(n / 2 - 1) + values.get(n / 2)) / 2.0
: values.get(n / 2);
int nearestRank = Math.max(1, (int) Math.ceil(n * 0.95));
return new Stats(median, values.get(nearestRank - 1));
}
}
코드 조각 1 · 주소와 duration·collection 도구
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
한 줄 읽기: 동시 실행 결과와 표본을 메모리에서 다룰 타입을 불러온다.
- 문법을 한 줄씩 풀면
- Duration은 ready 제한, List/Map/Set은 작업 결과와 variant 묶음을 표현한다.
- 실제 값 추적
- 아직 task0, sample0이고 thread도 없다.
- 정상 예
- 정상 흐름에서는 아직 task0, sample0이고 thread도 없다.
- 반례·경계 예
- Map과 Set을 빼면 exact variant 검사를 표현할 수 없다.
- 착각 방지
- collection import가 실제 benchmark를 실행하지 않는다.
- 이 블록이 하지 않는 일
- Spring이나 DB를 시작하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 latch·pool·atomic·stream과 assertion을 준비한다.
코드 조각 2 · 동시 실행·atomic·stream·assert
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
한 줄 읽기: worker 출발과 결과 회수, 관찰 동시 수 계산에 필요한 도구를 준비한다.
- 문법을 한 줄씩 풀면
- CountDownLatch는 ready/start, Executor/Future는 작업, AtomicInteger는 active/max를 thread-safe하게 센다.
- 실제 값 추적
- latch·pool·counter는 아직 생성 전이라 값 변화가 없다.
- 정상 예
- 정상 흐름에서는 latch·pool·counter는 아직 생성 전이라 값 변화가 없다.
- 반례·경계 예
- plain int로 active를 공유하면 경쟁으로 관찰값을 잃을 수 있다.
- 착각 방지
- LockSupport.parkNanos는 업무 처리나 실제 I/O가 아니다.
- 이 블록이 하지 않는 일
- 처리량이나 로그 overhead를 측정하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 예외 assertion과 결과 record, 첫 @Test를 연다.
코드 조각 3 · 두 assertion 방식·세 record·첫 worker 반복
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrencyHarnessTest {
record RunResult(int success, int businessFailure, int technicalFailure, int observedMaxActive) {}
record Sample(String variant, double millis) {}
record Stats(double median, double nearestRankP95) {}
@Test
void fixed_pool_bounds_observed_activity_and_accounts_for_all_tasks() throws Exception {
for (int workers : List.of(10, 50, 200)) {
한 줄 읽기: 결과 모양 세 개를 선언하고 workers 10·50·200을 차례로 시험한다.
- 문법을 한 줄씩 풀면
- record는 immutable 결과 묶음이고
@Test첫 메서드는 List.of 세 worker 수를 순회한다. - 실제 값 추적
- RunResult 네 값, Sample variant/millis, Stats median/p95가 생길 수 있고 첫 workers=10이다.
- 정상 예
- 정상 흐름에서는 RunResult 네 값, Sample variant/millis, Stats median/p95가 생길 수 있고 첫 workers=10이다.
- 반례·경계 예
- worker 목록에서 200을 빼면 큰 pool 변형은 검증되지 않는다.
- 착각 방지
- Sample.millis가 실제 wall-clock 측정값이라고 단정하면 안 된다. 이 파일은 synthetic 값도 쓴다.
- 이 블록이 하지 않는 일
- 아직 1,000개 task를 제출하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 workers별 run 입력과 세 assertion을 끝낸다.
코드 조각 4 · 세 pool의 1,000 task와 timeout 시험 입구
RunResult result = run(workers, 1_000, Math.min(workers, 1_000), Duration.ofSeconds(10));
assertThat(result.observedMaxActive()).isBetween(1, workers);
assertThat(result.success() + result.businessFailure() + result.technicalFailure())
.isEqualTo(1_000);
assertThat(result.technicalFailure()).isZero();
}
}
@Test
void ready_timeout_is_a_failure_and_never_a_green_run() {
한 줄 읽기: 각 worker 수에서 1,000 task를 회수하고 관찰 상한·결과 합·기술 실패0을 검사한다.
- 문법을 한 줄씩 풀면
- run(workers,1000,workers,10초) 결과의 max는 1..workers, 세 outcome 합은1000, technical=0이어야 한다.
- 실제 값 추적
- workers10/50/200 각각 task1000이 accounting되고 관찰 max는 pool 크기를 넘지 않는다.
- 정상 예
- 정상 흐름에서는 workers10/50/200 각각 task1000이 accounting되고 관찰 max는 pool 크기를 넘지 않는다.
- 반례·경계 예
- observedMax가 반드시 workers와 같아야 한다고 assert하지 않는다.
- 착각 방지
- 1,000 synthetic park task를 운영 stress 결과로 부르면 안 된다.
- 이 블록이 하지 않는 일
- business failure 수나 throughput을 특정 값으로 고정하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 준비 목표가 worker보다 큰 경우를 100ms 안에 실패시키고 통계 시험을 시작한다.
코드 조각 5 · 불가능 ready3과 1..30 통계
assertThatThrownBy(() -> run(2, 2, 3, Duration.ofMillis(100)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ready timeout");
}
@Test
void even_median_and_nearest_rank_p95_match_the_independent_vector() {
List<Double> values = IntStream.rangeClosed(1, 30).mapToObj(i -> (double) i).toList();
Stats stats = statistics(values);
assertThat(stats.median()).isEqualTo(15.5);
한 줄 읽기: worker2로 ready3을 요구하면 Green이 아니라 timeout 예외여야 하고 독립 벡터 통계를 계산한다.
- 문법을 한 줄씩 풀면
- assertThatThrownBy는 IllegalStateException과 ready timeout 문구를, IntStream은 double1..30을 만든다.
- 실제 값 추적
- run(2,2,3,100ms)는 timeout; 통계 입력은 [1.0,...,30.0]이다.
- 정상 예
- 정상 흐름에서는 run(2,2,3,100ms)는 timeout; 통계 입력은 [1.0,...,30.0]이다.
- 반례·경계 예
- readyTarget=2면 두 worker가 도달해 정상 출발하므로 timeout 반례가 아니다.
- 착각 방지
- 1..30은 측정 30회가 아니라 계산식 확인용 독립 벡터다.
- 이 블록이 하지 않는 일
- scheduler가 언제 thread를 배치할지 측정하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 median15.5/p95=29와 before/after 60개 표본을 만든다.
코드 조각 6 · 통계 두 값과 before/after 각30
assertThat(stats.nearestRankP95()).isEqualTo(29.0);
}
@Test
void benchmark_contract_requires_exactly_before_and_after_with_thirty_rows_each() {
List<Sample> valid = new ArrayList<>();
for (String variant : List.of("before", "after")) {
for (int value = 1; value <= 30; value++) valid.add(new Sample(variant, value));
}
assertThat(validateAndSummarize(valid)).containsOnlyKeys("before", "after");
한 줄 읽기: 짝수 중앙값과 nearest-rank p95를 고정하고 두 variant의 계약용 표본을 만든다.
- 문법을 한 줄씩 풀면
- 1..30 median은 15·16 평균15.5, ceil(30×.95)=29번째 값29이며 중첩 loop가 30+30 Sample을 만든다.
- 실제 값 추적
- stats=(15.5,29.0), valid 크기60, variant마다 값1..30이다.
- 정상 예
- 정상 흐름에서는 stats=(15.5,29.0), valid 크기60, variant마다 값1..30이다.
- 반례·경계 예
- p95를 보간식으로 계산해 29.5 등으로 바꾸면 이 계약과 다르다.
- 착각 방지
- valid 60행은 실제 before/after 업무를 30번 실행한 기록이 아니다.
- 이 블록이 하지 않는 일
- before가 after보다 느리거나 빠르다고 비교하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 valid key와 59행·other·empty 세 반례를 검사한다.
코드 조각 7 · 정확 두 key와 세 잘못된 표본
assertThatThrownBy(() -> validateAndSummarize(valid.subList(0, 59)))
.isInstanceOf(IllegalArgumentException.class);
List<Sample> extra = new ArrayList<>(valid);
extra.add(new Sample("other", 1));
assertThatThrownBy(() -> validateAndSummarize(extra))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> validateAndSummarize(List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
한 줄 읽기: 정상은 before/after 두 key뿐이고 행 누락·제3 variant·빈 목록은 모두 거절한다.
- 문법을 한 줄씩 풀면
- subList(0,59)는 한 행 부족, extra는 other 한 행 추가, empty는 variant 자체가 없다.
- 실제 값 추적
- valid summary key={before,after}; 나머지 세 호출은 IllegalArgumentException이다.
- 정상 예
- 정상 흐름에서는 valid summary key={before,after}; 나머지 세 호출은 IllegalArgumentException이다.
- 반례·경계 예
- 59행을 자동 보정하거나 other를 조용히 버리면 test가 실패한다.
- 착각 방지
- 이 assertion은 latency 우열이나 structured log 비용을 보장하지 않는다.
- 이 블록이 하지 않는 일
- 표본 값을 파일이나 로그에서 읽지 않는다.
- 다음 코드와의 연결
- 다음 조각이 run helper 입력과 latch·counter·pool을 만든다.
코드 조각 8 · run 입력 검증과 실행 장치
static RunResult run(
int workers, int tasks, int readyTarget, Duration readyTimeout
) throws Exception {
if (workers <= 0 || tasks <= 0 || readyTarget <= 0) throw new IllegalArgumentException();
var ready = new CountDownLatch(readyTarget);
var start = new CountDownLatch(1);
var active = new AtomicInteger();
var maxActive = new AtomicInteger();
var pool = Executors.newFixedThreadPool(workers);
한 줄 읽기: 양수 입력만 받아 ready/start, active/max, 고정 pool을 만든다.
- 문법을 한 줄씩 풀면
- workers/tasks/readyTarget 중 하나라도0 이하면 즉시 IllegalArgumentException이고 pool 크기는 workers다.
- 실제 값 추적
- 정상 첫 호출은 ready10/start1/active0/max0/pool10이다.
- 정상 예
- 정상 흐름에서는 정상 첫 호출은 ready10/start1/active0/max0/pool10이다.
- 반례·경계 예
- readyTarget이 tasks보다 커도 선검사에서 막지 않고 timeout 시험으로 드러난다.
- 착각 방지
- pool 크기와 observedMax가 같은 값이라는 보장은 없다.
- 이 블록이 하지 않는 일
- DB transaction이나 업무 서비스를 호출하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 task 수만큼 Future를 제출하고 active를 관찰한다.
코드 조각 9 · task 제출과 active 최대값
List<Future<String>> futures = new ArrayList<>();
try {
for (int i = 0; i < tasks; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
int now = active.incrementAndGet();
maxActive.accumulateAndGet(now, Math::max);
try {
LockSupport.parkNanos(100_000L);
한 줄 읽기: 각 task가 준비를 알리고 공통 start 뒤 active를1 올려 최대값을 기록한다.
- 문법을 한 줄씩 풀면
- submit은 Future<String>, incrementAndGet은 현재 active, accumulateAndGet(Math::max)은 지금까지 최대를 보존한다.
- 실제 값 추적
- task1000이면 Future1000개가 쌓이고 동시에 실행 중인 수는 pool 크기를 넘지 않는다.
- 정상 예
- 정상 흐름에서는 task1000이면 Future1000개가 쌓이고 동시에 실행 중인 수는 pool 크기를 넘지 않는다.
- 반례·경계 예
- maxActive를 plain assignment로 덮으면 더 작은 나중 값이 최대를 지울 수 있다.
- 착각 방지
- 100,000ns park는 synthetic 겹침을 만들 뿐 실제 비즈니스 latency가 아니다.
- 이 블록이 하지 않는 일
- 업무 성공/실패를 실제 외부 시스템에서 만들지 않는다.
- 다음 코드와의 연결
- 다음 조각이 SUCCESS 반환 뒤 active를 반드시 줄이고 ready timeout을 검사한다.
코드 조각 10 · SUCCESS·active 복구·fail-closed start
return "SUCCESS";
} finally {
active.decrementAndGet();
}
}));
}
if (!ready.await(readyTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("ready timeout");
}
start.countDown();
한 줄 읽기: task는 SUCCESS를 반환하고 finally에서 active를 줄이며, ready가 시간 안에 안 모이면 start 전에 실패한다.
- 문법을 한 줄씩 풀면
- task finally는 예외에도 decrement하고 ready.await(false)는 IllegalStateException, true면 start를0으로 내린다.
- 실제 값 추적
- 정상 task는 active가 원래 값으로 돌아가고 불가능 ready는 ready timeout이다.
- 정상 예
- 정상 흐름에서는 정상 task는 active가 원래 값으로 돌아가고 불가능 ready는 ready timeout이다.
- 반례·경계 예
- timeout인데 start를 내려 Green으로 계속하면 fail-closed 계약이 깨진다.
- 착각 방지
- SUCCESS 문자열은 실제 업무 transaction 성공을 뜻하지 않는다.
- 이 블록이 하지 않는 일
- ready timeout 뒤 성공 결과를 반환하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 모든 Future를 30초 제한으로 분류한다.
코드 조각 11 · Future 결과 세 bucket
int success = 0;
int business = 0;
int technical = 0;
for (Future<String> future : futures) {
try {
if ("SUCCESS".equals(future.get(30, TimeUnit.SECONDS))) success++;
else business++;
} catch (Exception failure) {
technical++;
}
한 줄 읽기: 모든 Future를 회수해 SUCCESS·그 밖 문자열·예외를 각각 센다.
- 문법을 한 줄씩 풀면
- SUCCESS는 success++, 다른 정상 문자열은 business++, get 예외는 technical++이다.
- 실제 값 추적
- 현재 canonical task는 SUCCESS만 반환하므로 정상 run에서 success=tasks, business=0, technical=0이다.
- 정상 예
- 정상 흐름에서는 현재 canonical task는 SUCCESS만 반환하므로 정상 run에서 success=tasks, business=0, technical=0이다.
- 반례·경계 예
- 실제 BusinessException을 이 helper가 발생시키는 것으로 오해하면 안 된다.
- 착각 방지
- future.get30초는 전체 실행이 반드시 30초라는 하나의 deadline이 아니라 Future별 상한 호출이다.
- 이 블록이 하지 않는 일
- 예외 종류별 technical 원인을 보존하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 RunResult를 반환하고 실패에도 start/pool을 정리한다.
코드 조각 12 · RunResult와 항상 실행되는 정리
}
return new RunResult(success, business, technical, maxActive.get());
} finally {
start.countDown();
pool.shutdownNow();
}
}
static Map<String, Stats> validateAndSummarize(List<Sample> samples) {
Map<String, List<Double>> byVariant = samples.stream().collect(Collectors.groupingBy(
한 줄 읽기: 네 관찰값을 돌려주고 성공·실패와 무관하게 대기 thread를 풀어 pool을 닫는다.
- 문법을 한 줄씩 풀면
- finally의 start.countDown/shutdownNow가 cleanup을 보장하고 다음 helper는 Sample을 variant별로 묶기 시작한다.
- 실제 값 추적
- 결과는 success/business/technical/max 네 값이며 pool은 종료 신호를 받는다.
- 정상 예
- 정상 흐름에서는 결과는 success/business/technical/max 네 값이며 pool은 종료 신호를 받는다.
- 반례·경계 예
- shutdownNow가 이미 끝난 task나 외부 transaction을 되돌리는 것은 아니다.
- 착각 방지
- cleanup 성공이 harness 결과 Green을 뜻하지 않는다.
- 이 블록이 하지 않는 일
- executor 종료 완료까지 awaitTermination하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 정확한 variant set과 각30행을 검증한다.
코드 조각 13 · before/after exact set과 각30행
Sample::variant, Collectors.mapping(Sample::millis, Collectors.toList())
));
if (!byVariant.keySet().equals(Set.of("before", "after"))) {
throw new IllegalArgumentException("variants must be exactly before and after");
}
if (byVariant.values().stream().anyMatch(values -> values.size() != 30 || values.isEmpty())) {
throw new IllegalArgumentException("each variant must contain exactly 30 rows");
}
return byVariant.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, entry -> statistics(entry.getValue())
한 줄 읽기: 표본을 variant별 millis 목록으로 묶고 key·행 수 계약을 먼저 검사한다.
- 문법을 한 줄씩 풀면
- groupingBy+mapping이 Map<String,List<Double>>을 만들고 key가 exact set이 아니거나 size!=30이면 예외다.
- 실제 값 추적
- valid는 before30/after30; 59행·other·empty는 각각 계약을 깨뜨린다.
- 정상 예
- 정상 흐름에서는 valid는 before30/after30; 59행·other·empty는 각각 계약을 깨뜨린다.
- 반례·경계 예
- before만30행이 있어도 exact key set에서 거절된다.
- 착각 방지
- 각30행 검사는 실제 동일 환경 반복 측정이 수행됐음을 증명하지 않는다.
- 이 블록이 하지 않는 일
- variant 사이 성능 차이를 판정하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 각 variant 목록을 statistics로 요약하고 계산식을 연다.
코드 조각 14 · variant별 통계와 중앙값
));
}
static Stats statistics(List<Double> source) {
if (source.isEmpty()) throw new IllegalArgumentException("empty samples");
List<Double> values = source.stream().sorted(Comparator.naturalOrder()).toList();
int n = values.size();
double median = n % 2 == 0
? (values.get(n / 2 - 1) + values.get(n / 2)) / 2.0
: values.get(n / 2);
한 줄 읽기: 각 목록을 정렬해 빈 입력을 막고 홀수/짝수 중앙값을 계산한다.
- 문법을 한 줄씩 풀면
- toMap은 entry마다 statistics를 호출하고 짝수 n은 가운데 두 값 평균, 홀수 n은 가운데 한 값이다.
- 실제 값 추적
- 1..30 정렬에서 n30, median=(15+16)/2=15.5다.
- 정상 예
- 정상 흐름에서는 1..30 정렬에서 n30, median=(15+16)/2=15.5다.
- 반례·경계 예
- 정렬 전 위치를 중앙값으로 쓰면 입력 순서에 따라 잘못된다.
- 착각 방지
- median 계산은 wall-clock을 수집하지 않는다.
- 이 블록이 하지 않는 일
- 평균·표준편차·신뢰구간을 계산하지 않는다.
- 다음 코드와의 연결
- 마지막 조각이 nearest-rank 위치와 p95 값을 반환한다.
코드 조각 15 · nearest-rank p95와 종료
int nearestRank = Math.max(1, (int) Math.ceil(n * 0.95));
return new Stats(median, values.get(nearestRank - 1));
}
}
한 줄 읽기: ceil(n×0.95)의 1-based 순위를 0-based index로 바꿔 p95를 반환한다.
- 문법을 한 줄씩 풀면
- n30이면 nearestRank29, index28, 정렬값29.0이며 Stats(15.5,29.0)가 된다.
- 실제 값 추적
- nearestRank는 최소1이라 아주 작은 non-empty 목록도 음수 index가 되지 않는다.
- 정상 예
- 정상 흐름에서는 nearestRank는 최소1이라 아주 작은 non-empty 목록도 음수 index가 되지 않는다.
- 반례·경계 예
- 0.95를 내림해 28번째 값을 쓰면 canonical assertion29.0과 다르다.
- 착각 방지
- 이 p95는 synthetic vector 공식 확인이지 실제 운영 latency p95가 아니다.
- 이 블록이 하지 않는 일
- structured log의 수집·직렬화 overhead를 측정하지 않는다.
- 다음 코드와의 연결
- 다음 파일 ConcurrentWithdraw20IT가 실제 PostgreSQL 계좌20건 결과를 관찰한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/ConcurrencyHarnessTest.java
- 전제조건
- JUnit5와 AssertJ가 필요하며 이 파일은 Spring context나 PostgreSQL 없이 실행되는 unit harness다.
- 반드시 지킬 계약
- workers/tasks/ready 양수, ready timeout fail-closed, 모든 Future 30초 회수, outcome 합=tasks, before/after 각 30행, median·nearest-rank p95를 보존한다.
- 추천 입력 순서
- import/record → @Test4 → run helper → validateAndSummarize → statistics 순서로 쓴다.
- 자기 점검
- @Test4, worker10/50/200·task1000, 불가능 ready3/worker2, 벡터1..30, before/after 각30과 잘못된 세 입력을 대조한다.
- 이번 파일의 범위 밖
- 실제 업무 1,000건 stress, 30회 benchmark 실행, structured log 비용, throughput·공정성·운영 용량은 측정하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrencyHarnessTest {
record RunResult(int success, int businessFailure, int technicalFailure, int observedMaxActive) {}
record Sample(String variant, double millis) {}
record Stats(double median, double nearestRankP95) {}
@Test
void fixed_pool_bounds_observed_activity_and_accounts_for_all_tasks() throws Exception {
for (int workers : List.of(10, 50, 200)) {
RunResult result = run(workers, 1_000, Math.min(workers, 1_000), Duration.ofSeconds(10));
assertThat(result.observedMaxActive()).isBetween(1, workers);
assertThat(result.success() + result.businessFailure() + result.technicalFailure())
.isEqualTo(1_000);
assertThat(result.technicalFailure()).isZero();
}
}
@Test
void ready_timeout_is_a_failure_and_never_a_green_run() {
assertThatThrownBy(() -> run(2, 2, 3, Duration.ofMillis(100)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ready timeout");
}
@Test
void even_median_and_nearest_rank_p95_match_the_independent_vector() {
List<Double> values = IntStream.rangeClosed(1, 30).mapToObj(i -> (double) i).toList();
Stats stats = statistics(values);
assertThat(stats.median()).isEqualTo(15.5);
assertThat(stats.nearestRankP95()).isEqualTo(29.0);
}
@Test
void benchmark_contract_requires_exactly_before_and_after_with_thirty_rows_each() {
List<Sample> valid = new ArrayList<>();
for (String variant : List.of("before", "after")) {
for (int value = 1; value <= 30; value++) valid.add(new Sample(variant, value));
}
assertThat(validateAndSummarize(valid)).containsOnlyKeys("before", "after");
assertThatThrownBy(() -> validateAndSummarize(valid.subList(0, 59)))
.isInstanceOf(IllegalArgumentException.class);
List<Sample> extra = new ArrayList<>(valid);
extra.add(new Sample("other", 1));
assertThatThrownBy(() -> validateAndSummarize(extra))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> validateAndSummarize(List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
static RunResult run(
int workers, int tasks, int readyTarget, Duration readyTimeout
) throws Exception {
if (workers <= 0 || tasks <= 0 || readyTarget <= 0) throw new IllegalArgumentException();
var ready = new CountDownLatch(readyTarget);
var start = new CountDownLatch(1);
var active = new AtomicInteger();
var maxActive = new AtomicInteger();
var pool = Executors.newFixedThreadPool(workers);
List<Future<String>> futures = new ArrayList<>();
try {
for (int i = 0; i < tasks; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
int now = active.incrementAndGet();
maxActive.accumulateAndGet(now, Math::max);
try {
LockSupport.parkNanos(100_000L);
return "SUCCESS";
} finally {
active.decrementAndGet();
}
}));
}
if (!ready.await(readyTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("ready timeout");
}
start.countDown();
int success = 0;
int business = 0;
int technical = 0;
for (Future<String> future : futures) {
try {
if ("SUCCESS".equals(future.get(30, TimeUnit.SECONDS))) success++;
else business++;
} catch (Exception failure) {
technical++;
}
}
return new RunResult(success, business, technical, maxActive.get());
} finally {
start.countDown();
pool.shutdownNow();
}
}
static Map<String, Stats> validateAndSummarize(List<Sample> samples) {
Map<String, List<Double>> byVariant = samples.stream().collect(Collectors.groupingBy(
Sample::variant, Collectors.mapping(Sample::millis, Collectors.toList())
));
if (!byVariant.keySet().equals(Set.of("before", "after"))) {
throw new IllegalArgumentException("variants must be exactly before and after");
}
if (byVariant.values().stream().anyMatch(values -> values.size() != 30 || values.isEmpty())) {
throw new IllegalArgumentException("each variant must contain exactly 30 rows");
}
return byVariant.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, entry -> statistics(entry.getValue())
));
}
static Stats statistics(List<Double> source) {
if (source.isEmpty()) throw new IllegalArgumentException("empty samples");
List<Double> values = source.stream().sorted(Comparator.naturalOrder()).toList();
int n = values.size();
double median = n % 2 == 0
? (values.get(n / 2 - 1) + values.get(n / 2)) / 2.0
: values.get(n / 2);
int nearestRank = Math.max(1, (int) Math.ceil(n * 0.95));
return new Stats(median, values.get(nearestRank - 1));
}
}
독립 계산 벡터
1, 2, …, 30실제 benchmark 30회가 아님median
(15 + 16) / 2 = 15.5짝수 중앙 두 값nearest-rank p95
ceil(30×0.95) = 2929번째 값 29.02. ConcurrentWithdraw20IT
한 문장 역할: 10,000원 계좌에 1,000원 출금 20건을 동시에 보내 성공10·잔액부족10·기술실패0·잔액0·원장10을 확인
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W11 화요일 Red 뒤 solution Green, 수요일 final Green에서 같은 class selector |
| 무엇을 받나 | 10,000원 계좌, task20, 각 amount1,000과 고유 withdraw-0..19 requestId |
| 무엇이 바뀌나 | 성공10건이 잔액을0으로 만들고 WITHDRAWAL 원장10행을 남김; 나머지10건은 잔액 부족 |
| 무엇을 돌려주나 | SUCCESS10, BUSINESS_FAILURE10, TECHNICAL_FAILURE0, balance0, ledger10 assertion |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
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 ConcurrentWithdraw20IT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired AccountRepository accounts;
@Autowired WithdrawalService withdrawals;
Account account;
@BeforeEach
void setUp() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
.update();
account = openings.open("customer-1", "WITHDRAW-20", 10_000);
}
@Test
void twentyConcurrentWithdrawalsHaveTenSuccessTenBusinessFailureAndNoTechnicalFailure() throws Exception {
int tasks = 20;
var ready = new CountDownLatch(tasks);
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
int sequence = i;
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
withdrawals.withdraw("customer-1", account.getId(), 1_000,
"withdraw-" + sequence);
return "SUCCESS";
} catch (BusinessException failure) {
return failure.code() == ErrorCode.INSUFFICIENT_BALANCE
? "BUSINESS_FAILURE" : "TECHNICAL_FAILURE";
} catch (RuntimeException failure) {
return "TECHNICAL_FAILURE";
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<String> outcomes = new ArrayList<>();
for (Future<String> future : futures) outcomes.add(future.get(30, TimeUnit.SECONDS));
assertThat(outcomes).as("W11D2_RED_EXPECTED_ATOMIC_WITHDRAWAL")
.filteredOn("SUCCESS"::equals).hasSize(10);
assertThat(outcomes).filteredOn("BUSINESS_FAILURE"::equals).hasSize(10);
assertThat(outcomes).filteredOn("TECHNICAL_FAILURE"::equals).isEmpty();
assertThat(accounts.findById(account.getId()).orElseThrow().getBalance()).isZero();
assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type='WITHDRAWAL'")
.query(Long.class).single()).isEqualTo(10);
} finally {
start.countDown();
pool.shutdownNow();
}
}
}
코드 조각 1 · 통합 시험·업무 예외·JDBC import
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
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;
한 줄 읽기: 실제 Spring/PostgreSQL에서 업무 실패와 기술 실패를 나눌 도구를 불러온다.
- 문법을 한 줄씩 풀면
- BusinessException/ErrorCode는 잔액 부족을, JdbcClient는 최종 원장 수를 읽는다.
- 실제 값 추적
- 아직 계좌·thread·outcome은0개다.
- 정상 예
- 정상 흐름에서는 아직 계좌·thread·outcome은0개다.
- 반례·경계 예
- ErrorCode 비교를 빼면 다른 업무 예외를 잔액 부족으로 오분류할 수 있다.
- 착각 방지
- SpringBootTest import만으로 context가 시작되는 것은 아니다.
- 이 블록이 하지 않는 일
- 계좌를 열거나 출금하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 concurrent Future와 assertion 도구를 준비한다.
코드 조각 2 · 20 worker용 collection·latch·Future
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개의 출발과 결과 회수를 위한 도구를 준비한다.
- 문법을 한 줄씩 풀면
- ArrayList는 Future/outcome을 모으고 latch는 ready/start, TimeUnit은 대기 상한을 표현한다.
- 실제 값 추적
- 이 시점에는 Future0, latch0이다.
- 정상 예
- 정상 흐름에서는 이 시점에는 Future0, latch0이다.
- 반례·경계 예
- Future를 버리면 worker 예외가 메인 assertion에 전달되지 않을 수 있다.
- 착각 방지
- CountDownLatch는 DB row lock이 아니다.
- 이 블록이 하지 않는 일
- 운영 부하를 발생시키지 않는다.
- 다음 코드와의 연결
- 다음 조각이 실제 bean 네 개와 account fixture 필드를 선언한다.
코드 조각 3 · Spring bean 네 개와 계좌 field
@SpringBootTest
class ConcurrentWithdraw20IT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired AccountRepository accounts;
@Autowired WithdrawalService withdrawals;
Account account;
@BeforeEach
한 줄 읽기: 실제 JDBC·개설·repository·출금 service를 주입하고 매 시험 계좌를 준비할 자리를 둔다.
- 문법을 한 줄씩 풀면
- @SpringBootTest와 @Autowired가 production bean 경로를 연결하고 Account field가 새 행을 가리킨다.
- 실제 값 추적
- setUp 전 account는 비어 있고 @BeforeEach 선언까지만 보인다.
- 정상 예
- 정상 흐름에서는 setUp 전 account는 비어 있고 @BeforeEach 선언까지만 보인다.
- 반례·경계 예
- WithdrawalService를 mock으로 바꾸면 row lock/transaction 통합 시험이 아니다.
- 착각 방지
- bean 네 개가 thread 네 개라는 뜻은 아니다.
- 이 블록이 하지 않는 일
- 아직 출금을 호출하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 table을 비우고 10,000원 계좌와 task20을 만든다.
코드 조각 4 · 10,000원 출발과 task20
void setUp() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
.update();
account = openings.open("customer-1", "WITHDRAW-20", 10_000);
}
@Test
void twentyConcurrentWithdrawalsHaveTenSuccessTenBusinessFailureAndNoTechnicalFailure() throws Exception {
int tasks = 20;
var ready = new CountDownLatch(tasks);
한 줄 읽기: 매 시험을 balance10,000·거래0·원장0에서 시작하고 동시 출금20개를 선언한다.
- 문법을 한 줄씩 풀면
- BeforeEach의 TRUNCATE/open 뒤 @Test가 tasks20과 ready(20)를 만든다.
- 실제 값 추적
- account=10,000, task=20, ready count=20이다.
- 정상 예
- 정상 흐름에서는 account=10,000, task=20, ready count=20이다.
- 반례·경계 예
- 이전 원장이나 잔액을 남기면 최종 ledger10/balance0 해석이 깨진다.
- 착각 방지
- WITHDRAW-20 계좌 번호가 원자성을 제공하지 않는다.
- 이 블록이 하지 않는 일
- 아직 start를 내리거나 돈을 빼지 않는다.
- 다음 코드와의 연결
- 다음 조각이 start·pool20과 worker lambda를 만든다.
코드 조각 5 · pool20과 공통 출발
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
int sequence = i;
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
한 줄 읽기: 20개 Future를 등록하고 각 worker가 ready를 알린 뒤 같은 start를 기다린다.
- 문법을 한 줄씩 풀면
- newFixedThreadPool(tasks)는20자리, loop는 sequence0..19, submit은 Future<String>을 돌려준다.
- 실제 값 추적
- Future20개가 생기고 모든 worker가 start count1 앞에 모이려 한다.
- 정상 예
- 정상 흐름에서는 Future20개가 생기고 모든 worker가 start count1 앞에 모이려 한다.
- 반례·경계 예
- pool 크기1이면 첫 worker가 start에서 막혀 나머지가 ready에 못 온다.
- 착각 방지
- 동시 출발이 DB update를 원자적으로 만들지는 않는다.
- 이 블록이 하지 않는 일
- 이 조각은 성공 수를 아직 세지 않는다.
- 다음 코드와의 연결
- 다음 조각이 고유 requestId로 1,000원 출금하고 예외를 세 결과로 바꾼다.
코드 조각 6 · 출금과 업무·기술 실패 분리
withdrawals.withdraw("customer-1", account.getId(), 1_000,
"withdraw-" + sequence);
return "SUCCESS";
} catch (BusinessException failure) {
return failure.code() == ErrorCode.INSUFFICIENT_BALANCE
? "BUSINESS_FAILURE" : "TECHNICAL_FAILURE";
} catch (RuntimeException failure) {
return "TECHNICAL_FAILURE";
}
}));
한 줄 읽기: 각 worker가 1,000원을 한 번 요청하고 결과를 SUCCESS/BUSINESS/TECHNICAL 중 하나로 반환한다.
- 문법을 한 줄씩 풀면
- withdraw 정상 반환은 SUCCESS, INSUFFICIENT_BALANCE만 BUSINESS_FAILURE, 다른 BusinessException/RuntimeException은 TECHNICAL_FAILURE다.
- 실제 값 추적
- requestId는 withdraw-0..19이고 성공 가능한 잔액 단위는10개다.
- 정상 예
- 정상 흐름에서는 requestId는 withdraw-0..19이고 성공 가능한 잔액 단위는10개다.
- 반례·경계 예
- 모든 BusinessException을 업무 실패로 묶으면 ACCESS_DENIED 같은 결함이 숨는다.
- 착각 방지
- 고유 requestId가 replay 멱등성을 이 테스트에서 검증한다는 뜻은 아니다.
- 이 블록이 하지 않는 일
- 실패 요청을 retry하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 ready10초 뒤 모든 Future를30초 제한으로 회수한다.
코드 조각 7 · outcome20과 네 assertion
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<String> outcomes = new ArrayList<>();
for (Future<String> future : futures) outcomes.add(future.get(30, TimeUnit.SECONDS));
assertThat(outcomes).as("W11D2_RED_EXPECTED_ATOMIC_WITHDRAWAL")
.filteredOn("SUCCESS"::equals).hasSize(10);
assertThat(outcomes).filteredOn("BUSINESS_FAILURE"::equals).hasSize(10);
assertThat(outcomes).filteredOn("TECHNICAL_FAILURE"::equals).isEmpty();
assertThat(accounts.findById(account.getId()).orElseThrow().getBalance()).isZero();
한 줄 읽기: 20개 결과를 전부 모아 성공10·잔액부족10·기술0과 최종 balance0을 확인한다.
- 문법을 한 줄씩 풀면
- filteredOn은 같은 outcome 문자열만 남겨 크기를 재고 repository가 새 balance를 읽는다.
- 실제 값 추적
- outcomes 크기20, SUCCESS10, BUSINESS10, TECHNICAL0, balance0이다.
- 정상 예
- 정상 흐름에서는 outcomes 크기20, SUCCESS10, BUSINESS10, TECHNICAL0, balance0이다.
- 반례·경계 예
- SUCCESS9/BUSINESS11이 합20이어도 exact size 두 assertion 중 하나가 실패한다.
- 착각 방지
- assertion description의 RED marker는 화요일 첫 단계 식별자이며 final solution 결과가 Red라는 뜻이 아니다.
- 이 블록이 하지 않는 일
- 각 성공 requestId가 어느 ledger 행인지 직접 join하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 WITHDRAWAL 원장10행과 executor 정리를 확인한다.
코드 조각 8 · 원장10행과 항상 실행되는 정리
assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type='WITHDRAWAL'")
.query(Long.class).single()).isEqualTo(10);
} finally {
start.countDown();
pool.shutdownNow();
}
}
}
한 줄 읽기: 성공 출금 수와 같은 WITHDRAWAL 원장10행을 확인하고 실패에도 worker를 정리한다.
- 문법을 한 줄씩 풀면
- COUNT(*) 결과10 뒤 finally가 start를0으로 내리고 pool에 중단 신호를 보낸다.
- 실제 값 추적
- 최종 ledger count=10이며 남은 대기 thread는 풀린다.
- 정상 예
- 정상 흐름에서는 최종 ledger count=10이며 남은 대기 thread는 풀린다.
- 반례·경계 예
- 다른 entry_type 행이나 request별 상관관계는 이 count에 드러나지 않는다.
- 착각 방지
- 20건 시험을 1,000건 production stress로 부르면 안 된다.
- 이 블록이 하지 않는 일
- pool 종료 완료까지 awaitTermination하지 않는다.
- 다음 코드와의 연결
- 다음 파일 WithdrawalService final solution이 성공10/실패10을 만드는 잠금 경로를 보여 준다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/account/ConcurrentWithdraw20IT.java
- 전제조건
- 실제 PostgreSQL, final solution WithdrawalService, AccountOpeningService/Repository가 필요하다.
- 반드시 지킬 계약
- 20 worker·ready10초·Future별30초, 고유 requestId, 세 outcome 분리와 다섯 최종 assertion을 보존한다.
- 추천 입력 순서
- import/주입 → fixture → @Test의20 Future → 업무/기술 분류 → 다섯 assertion → finally 순서다.
- 자기 점검
- SUCCESS10, BUSINESS10, TECHNICAL0, balance0, WITHDRAWAL ledger10이며 test label의 RED 문구가 final 결과를 Red로 만들지 않는지 본다.
- 이번 파일의 범위 밖
- 1,000건 운영 stress, 성공 순서·공정성·throughput, idempotency replay, request별 원장 연결, 모든 예외 번역은 보장하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.account;
import com.example.financialcore.PostgresIntegrationTestSupport;
import com.example.financialcore.api.BusinessException;
import com.example.financialcore.api.ErrorCode;
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 ConcurrentWithdraw20IT extends PostgresIntegrationTestSupport {
@Autowired JdbcClient jdbc;
@Autowired AccountOpeningService openings;
@Autowired AccountRepository accounts;
@Autowired WithdrawalService withdrawals;
Account account;
@BeforeEach
void setUp() {
jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE")
.update();
account = openings.open("customer-1", "WITHDRAW-20", 10_000);
}
@Test
void twentyConcurrentWithdrawalsHaveTenSuccessTenBusinessFailureAndNoTechnicalFailure() throws Exception {
int tasks = 20;
var ready = new CountDownLatch(tasks);
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(tasks);
try {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < tasks; i++) {
int sequence = i;
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
withdrawals.withdraw("customer-1", account.getId(), 1_000,
"withdraw-" + sequence);
return "SUCCESS";
} catch (BusinessException failure) {
return failure.code() == ErrorCode.INSUFFICIENT_BALANCE
? "BUSINESS_FAILURE" : "TECHNICAL_FAILURE";
} catch (RuntimeException failure) {
return "TECHNICAL_FAILURE";
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<String> outcomes = new ArrayList<>();
for (Future<String> future : futures) outcomes.add(future.get(30, TimeUnit.SECONDS));
assertThat(outcomes).as("W11D2_RED_EXPECTED_ATOMIC_WITHDRAWAL")
.filteredOn("SUCCESS"::equals).hasSize(10);
assertThat(outcomes).filteredOn("BUSINESS_FAILURE"::equals).hasSize(10);
assertThat(outcomes).filteredOn("TECHNICAL_FAILURE"::equals).isEmpty();
assertThat(accounts.findById(account.getId()).orElseThrow().getBalance()).isZero();
assertThat(jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type='WITHDRAWAL'")
.query(Long.class).single()).isEqualTo(10);
} finally {
start.countDown();
pool.shutdownNow();
}
}
}
3. WithdrawalService
한 문장 역할: 계좌 한 행을 PESSIMISTIC_WRITE로 잠근 transaction 안에서 소유자·잔액을 검사하고 거래·원장과 함께 출금
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | 출금 API와 ConcurrentWithdraw20IT의 worker20개 |
| 무엇을 받나 | actorId, accountId, 양수 amount, requestId |
| 무엇이 바뀌나 | 잠긴 account balance, WITHDRAW business_tx1행, WITHDRAWAL ledger_entry1행 |
| 무엇을 돌려주나 | 성공 뒤 남은 balance; 잘못된 입력·없는 계좌·타인·잔액 부족에는 BusinessException |
정확한 전체 원문
정확한 전체 원문 펼치기
package com.example.financialcore.account;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@Service
public class WithdrawalService {
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
public WithdrawalService(
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger
) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
}
@Transactional
public long withdraw(String actorId, long accountId, long amount, String requestId) {
if (amount <= 0) throw new BusinessException(ErrorCode.INVALID_REQUEST, "amount must be positive");
Account account = accounts.findOneForUpdate(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!account.getOwnerId().equals(actorId)) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
account.withdraw(amount);
Instant now = Instant.now();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedWithdrawal(
"WITHDRAW:" + accountId + ":" + requestId, now));
ledger.save(LedgerEntry.withdrawal(tx, account, amount, now));
return account.getBalance();
}
}
코드 조각 1 · 업무 예외·거래·원장·transaction import
package com.example.financialcore.account;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
한 줄 읽기: 출금 한 건이 함께 바꿀 계좌 밖 업무 부품과 Spring 경계를 불러온다.
- 문법을 한 줄씩 풀면
- BusinessTransaction/LedgerEntry와 repository는 사건·원장 저장, @Service/@Transactional은 bean과 경계를 선언한다.
- 실제 값 추적
- 아직 잔액·거래·원장 변화는0이다.
- 정상 예
- 정상 흐름에서는 아직 잔액·거래·원장 변화는0이다.
- 반례·경계 예
- ledger repository import를 빼면 아래 WITHDRAWAL 저장이 compile되지 않는다.
- 착각 방지
- import 순서가 DB 잠금 순서를 정하는 것은 아니다.
- 이 블록이 하지 않는 일
- transaction을 아직 시작하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 시각과 service, 세 repository field, 생성자를 연다.
코드 조각 2 · service와 세 repository
import java.time.Instant;
@Service
public class WithdrawalService {
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
public WithdrawalService(
한 줄 읽기: 하나의 service가 계좌·거래·원장 repository를 함께 사용하도록 final field로 둔다.
- 문법을 한 줄씩 풀면
- @Service는 Spring bean 후보, final은 생성 뒤 참조 고정, 생성자는 의존성을 명시한다.
- 실제 값 추적
- 객체 생성 시 세 field는 전달 전까지 비어 있고 line20부터 값을 받는다.
- 정상 예
- 정상 흐름에서는 객체 생성 시 세 field는 전달 전까지 비어 있고 line20부터 값을 받는다.
- 반례·경계 예
- repository가 세 개라고 transaction이 세 개 생기는 것은 아니다.
- 착각 방지
- Instant import가 현재 시각을 자동 저장하지 않는다.
- 이 블록이 하지 않는 일
- 계좌를 조회하거나 잠그지 않는다.
- 다음 코드와의 연결
- 다음 조각이 생성자 주입을 끝내고 public @Transactional 경계를 선언한다.
코드 조각 3 · 생성자 주입과 public transaction
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger
) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
}
@Transactional
한 줄 읽기: 세 repository를 보관하고 외부 호출이 통과할 출금 transaction 경계를 연다.
- 문법을 한 줄씩 풀면
- 생성자 assignment 뒤 @Transactional이 public withdraw 전체를 한 commit/rollback 단위로 묶는다.
- 실제 값 추적
- 정상 Spring 호출 한 번에 transaction 하나가 시작될 준비가 된다.
- 정상 예
- 정상 흐름에서는 정상 Spring 호출 한 번에 transaction 하나가 시작될 준비가 된다.
- 반례·경계 예
- 같은 class 내부 self-invocation이면 proxy 경계를 우회할 수 있다.
- 착각 방지
- annotation 존재만으로 amount·소유자·잔액 규칙이 검증되지는 않는다.
- 이 블록이 하지 않는 일
- 아직 balance를 줄이지 않는다.
- 다음 코드와의 연결
- 다음 조각이 입력 금액과 잠금 계좌·owner를 확인한다.
코드 조각 4 · 양수 금액·행 잠금·소유자·출금
public long withdraw(String actorId, long accountId, long amount, String requestId) {
if (amount <= 0) throw new BusinessException(ErrorCode.INVALID_REQUEST, "amount must be positive");
Account account = accounts.findOneForUpdate(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!account.getOwnerId().equals(actorId)) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
account.withdraw(amount);
Instant now = Instant.now();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedWithdrawal(
한 줄 읽기: 유효한 요청만 잠긴 계좌 한 행에 적용해 잔액을 줄이고 공통 시각을 잡는다.
- 문법을 한 줄씩 풀면
- amount<=0은 INVALID_REQUEST, findOneForUpdate는 없으면 ACCOUNT_NOT_FOUND, owner 불일치는 ACCESS_DENIED, account.withdraw는 잔액 부족을 검사한다.
- 실제 값 추적
- 10,000원 계좌 첫 성공은9,000, 열 번째 성공은0, 열한 번째부터 잔액 부족이다.
- 정상 예
- 정상 흐름에서는 10,000원 계좌 첫 성공은9,000, 열 번째 성공은0, 열한 번째부터 잔액 부족이다.
- 반례·경계 예
- findById처럼 잠금 없는 조회로 바꾸면 여러 worker가 같은 잔액을 읽어 경계가 깨질 수 있다.
- 착각 방지
- 단일 행 잠금은 여러 계좌 deadlock 순서를 다루지 않는다.
- 이 블록이 하지 않는 일
- requestId 중복 claim이나 retry를 하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 성공 출금의 거래 ID·원장 한 행을 같은 시각으로 저장하고 잔액을 돌려준다.
코드 조각 5 · 거래·WITHDRAWAL 원장·남은 잔액
"WITHDRAW:" + accountId + ":" + requestId, now));
ledger.save(LedgerEntry.withdrawal(tx, account, amount, now));
return account.getBalance();
}
}
한 줄 읽기: 성공한 출금만 business transaction과 원장 한 행을 남기고 현재 balance를 반환한다.
- 문법을 한 줄씩 풀면
- correlation 문자열은 WITHDRAW:accountId:requestId이고 같은 tx/account/amount/now로 withdrawal entry를 저장한다.
- 실제 값 추적
- 성공10번이면 거래10·WITHDRAWAL ledger10, 마지막 반환 가능 balance0이다.
- 정상 예
- 정상 흐름에서는 성공10번이면 거래10·WITHDRAWAL ledger10, 마지막 반환 가능 balance0이다.
- 반례·경계 예
- requestId를 중복 보내면 이 파일 자체가 replay를 처리한다고 단정할 수 없다.
- 착각 방지
- ledger10 assertion은 거래10이나 correlation uniqueness를 직접 확인하지 않는다.
- 이 블록이 하지 않는 일
- 외부 알림·재시도·structured log를 만들지 않는다.
- 다음 코드와의 연결
- 다음 carried SortedLockTransferIT가 두 계좌 반대 방향 이체의 별도 보존식을 재검증한다.
직접 다시 써보기
- 저장 경로
- src/main/java/com/example/financialcore/account/WithdrawalService.java
- 전제조건
- AccountRepository.findOneForUpdate, Account.withdraw, 거래/원장 repository와 Spring transaction proxy가 필요하다.
- 반드시 지킬 계약
- public @Transactional, amount>0, 단일 행 잠금, 계좌 존재/owner, withdraw, 같은 now의 거래·원장, balance 반환 순서를 지킨다.
- 추천 입력 순서
- import/service → 세 repository 생성자 주입 → transaction 메서드 → amount → lock/owner → withdraw → 거래/원장 → 반환 순서다.
- 자기 점검
- 10,000원에 worker20×1,000이면 잠금 직렬화로 성공10·잔액부족10·balance0·ledger10이 되는지 본다.
- 이번 파일의 범위 밖
- idempotency claim/replay, requestId 중복 정책, retry, 외부 메시지, 여러 계좌 잠금 순서, 운영 부하 제어는 구현하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
package com.example.financialcore.account;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@Service
public class WithdrawalService {
private final AccountRepository accounts;
private final BusinessTransactionRepository transactions;
private final LedgerEntryRepository ledger;
public WithdrawalService(
AccountRepository accounts,
BusinessTransactionRepository transactions,
LedgerEntryRepository ledger
) {
this.accounts = accounts;
this.transactions = transactions;
this.ledger = ledger;
}
@Transactional
public long withdraw(String actorId, long accountId, long amount, String requestId) {
if (amount <= 0) throw new BusinessException(ErrorCode.INVALID_REQUEST, "amount must be positive");
Account account = accounts.findOneForUpdate(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.ACCOUNT_NOT_FOUND, "account not found"));
if (!account.getOwnerId().equals(actorId)) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
}
account.withdraw(amount);
Instant now = Instant.now();
BusinessTransaction tx = transactions.save(BusinessTransaction.completedWithdrawal(
"WITHDRAW:" + accountId + ":" + requestId, now));
ledger.save(LedgerEntry.withdrawal(tx, account, amount, now));
return account.getBalance();
}
}
누적 carried direct 원문 · 1파일
SortedLockTransferIT는 W11 신규로 부르지 않는다. W10D3 canonical을 금요일 selector가 그대로 다시 실행하는 carried direct test다.
4. SortedLockTransferIT
한 문장 역할: 반대 방향 이체20건이 제한 안에 끝난 뒤 계좌 총액20,000과 TRANSFER 원장 signed 합0을 재검증
네 칸 계약 카드
| 계약 질문 | 이 파일의 답 |
|---|---|
| 누가 부르나 | W11 금요일 exact selector com.example.financialcore.transfer.SortedLockTransferIT |
| 무엇을 받나 | 각10,000원 두 계좌, A→B10/B→A10, 건당100원 |
| 무엇이 바뀌나 | 20개 TransferService transaction이 두 잔액·거래·원장을 변경 |
| 무엇을 돌려주나 | 모든 Future 완료, 잔액 총합20,000, TRANSFER 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 상태 복원 뒤 실패 전달이다.
- 이 블록이 하지 않는 일
- 실패한 이체를 자동으로 다시 시도하지 않는다.
- 다음 코드와의 연결
- 이 파일의 끝이며 이어지는 AAA 카드에서 실제 여섯 @Test의 보장 경계를 분리한다.
직접 다시 써보기
- 저장 경로
- src/test/java/com/example/financialcore/transfer/SortedLockTransferIT.java
- 전제조건
- W10D3 canonical test와 정렬 잠금 production 경로, 실제 PostgreSQL이 필요하다.
- 반드시 지킬 계약
- pairs10/tasks20, ready10초, Future별30초, balance sum20,000, signed sum0을 보존한다.
- 추천 입력 순서
- import/fixture → 20 Future → 제한 대기 → 두 보존식 → invoke helper 순서다.
- 자기 점검
- 개별 잔액 동일, pair별 성공 수, 거래20·원장40, 실제 deadlock, retry를 assert하지 않는지 본다.
- 이번 파일의 범위 밖
- 모든 스케줄 deadlock 부재, pair count 보장, 공정성·throughput, 운영 stress는 검증하지 않는다.
전체 코드 정답 · 들여쓰기까지 대조
직접 쓴 뒤 전체 정답 펼치기
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);
}
}
}
JUnit 여섯 메서드 · AAA와 보장 경계
고유 @Test는 Harness4 + ConcurrentWithdraw1 + Sorted1 = 6개다. 토요일은 Harness class 전체를 실행하므로 final Green 실행 합계가 9회가 된다. 아래는 메서드 이름이 아니라 실제 assertion으로만 쓴 보장선이다.
fixed_pool_bounds_observed_activity_and_accounts_for_all_tasks
월·토 · ConcurrencyHarnessTest · w11-new-direct
- 준비(Arrange)
- workers를10/50/200으로 바꾸고 각 run에 tasks1000, readyTarget=workers, timeout10초를 준다.
- 행동(Act)
- synthetic task를 고정 pool에 제출해 active/max와 Future 결과 세 bucket을 회수한다.
- 확인(Assert)
- 각 변형에서 observedMaxActive가1..workers, 세 outcome 합1000, technicalFailure0인지 확인한다.
- 직접 보장
- 이 인공 harness에서 관찰 동시 수가 pool 크기를 넘지 않고 1,000 Future가 결과 bucket에 빠짐없이 들어간다.
- 직접 보장하지 않음
- 실제 업무1,000건 stress, 최대 동시 수가 정확히 workers, throughput·latency·공정성·운영 용량은 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 executor 종료 확인이나 원인별 technical 분류를 별도 추가하되 canonical 보장과 분리한다.
ready_timeout_is_a_failure_and_never_a_green_run
목·토 · ConcurrencyHarnessTest · w11-new-direct
- 준비(Arrange)
- workers2/tasks2인데 readyTarget3인 불가능한 조건과 timeout100ms를 준비한다.
- 행동(Act)
- run(2,2,3,100ms)을 호출해 ready latch가0이 될 수 없는 경로를 실행한다.
- 확인(Assert)
- IllegalStateException이며 message에 ready timeout이 들어가는지 확인한다.
- 직접 보장
- 준비 조건이 시간 안에 충족되지 않으면 성공으로 넘기지 않는 fail-closed 경계를 보장한다.
- 직접 보장하지 않음
- 모든 scheduler 지연, DB lock timeout, Future30초 timeout의 예외 종류는 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 readyTarget=2 정상 반례를 별도 시험해 timeout 경계 양쪽을 함께 볼 수 있다.
even_median_and_nearest_rank_p95_match_the_independent_vector
토 · ConcurrencyHarnessTest · w11-new-direct
- 준비(Arrange)
- 측정 결과가 아닌 독립 계산 벡터 double1..30을 준비한다.
- 행동(Act)
- statistics가 정렬 뒤 짝수 median과 ceil(n×0.95) nearest-rank p95를 계산한다.
- 확인(Assert)
- median15.5, nearestRankP95 29.0을 확인한다.
- 직접 보장
- 정확한 1..30 벡터에서 두 통계 공식과 0-based index 변환이 기대값을 낸다.
- 직접 보장하지 않음
- 실제 benchmark30회 실행, wall-clock 정밀도, 운영 latency 분포, 로그 overhead는 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 홀수 개수·중복·정렬되지 않은 입력도 추가할 수 있지만 현재 canonical은 1..30 하나다.
benchmark_contract_requires_exactly_before_and_after_with_thirty_rows_each
토 · ConcurrencyHarnessTest · w11-new-direct
- 준비(Arrange)
- synthetic before30 + after30 Sample과 59행·other추가·empty 세 잘못된 목록을 준비한다.
- 행동(Act)
- validateAndSummarize가 exact key set과 variant별 정확30행을 검사하고 통계를 만든다.
- 확인(Assert)
- 정상 map key가 before/after뿐이며 잘못된 세 목록은 모두 IllegalArgumentException인지 확인한다.
- 직접 보장
- 요약에 들어오는 표본 모양이 두 variant·각30행이라는 계약을 고정한다.
- 직접 보장하지 않음
- before/after를 실제30번 실행했음, after 개선, structured log overhead, 통계 유의성은 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 NaN/Infinity/음수 millis 정책을 별도 정의해야 하며 현재 파일은 검사하지 않는다.
twentyConcurrentWithdrawalsHaveTenSuccessTenBusinessFailureAndNoTechnicalFailure
화 final Green·수 · ConcurrentWithdraw20IT · w11-new-direct
- 준비(Arrange)
- balance10,000 계좌, task20, amount1,000, 고유 withdraw-0..19, ready20/start1/pool20을 준비한다.
- 행동(Act)
- 20 worker가 final WithdrawalService를 호출하고 업무/기술 예외를 문자열 outcome으로 분리한다.
- 확인(Assert)
- SUCCESS10, BUSINESS_FAILURE10, TECHNICAL_FAILURE0, balance0, WITHDRAWAL ledger10을 확인한다.
- 직접 보장
- final solution의 단일 행 잠금 경로에서 이 fixture의 10회 성공과 10회 잔액 부족이 일관된 DB 상태를 만든다.
- 직접 보장하지 않음
- 첫 의도적 Red 결과, 성공 순서·공정성, 1,000건 stress, idempotency replay, 거래10은 직접 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 성공 requestId와 ledger를 join해 1:1 상관관계를 확인할 수 있으나 canonical count assertion에는 없다.
oppositeDirectionsFinishWithoutDeadlockAndPreserveTotal
금 · SortedLockTransferIT · carried-direct
- 준비(Arrange)
- 각10,000원인 두 계좌, A→B10/B→A10, 건당100원, ready20/start1/pool20을 준비한다.
- 행동(Act)
- 20개 이체 Future를 제출하고 ready10초 뒤 시작해 각 Future를30초 안에 회수한다.
- 확인(Assert)
- 모든 Future 정상 완료, 두 balance 합20,000, TRANSFER ledger signed 합0을 확인한다.
- 직접 보장
- 이 fixture와 제한 시간에서 반대 방향20건이 끝나고 돈·원장 두 보존식이 유지된다.
- 직접 보장하지 않음
- 실제 deadlock 재현, 모든 스케줄 안전, pair count, 개별 잔액 동일, 거래/원장 행 수, retry는 보장하지 않는다.
- 원문 아닌 강화 예시
- 강화 예시라면 방향별 성공 수와 거래20·원장40을 추가하되 현재 세 assertion과 구분한다.
SQL workbook · Q13/Q14
두 evidence 경로는 학습자가 새로 만들 위치일 뿐 canonical answer 파일이 아니다. 앱 V001이 아니라 sql/workbook/fixtures의 customer6·account8·business_tx21·ledger_entry16 seed를 사용한다.
REQ-301
OUT -300IN +300연결 posting 2행REQ-302
OUT -200IN 없음연결 posting 1행Q13 · 거래별 debit·credit 원장 연결
source 경계: reference project에 canonical learner SQL answer 파일은 없다. 아래 코드는 PostgreSQL workbook schema·seed를 만족하는 전체 예시 정답이다.
| 계약 질문 | 이 파일의 답 |
|---|---|
| 문제 원문 | W11-SQL-Q13 · 거래별 debit·credit 원장 연결 |
| 어느 schema | 앱 V001이 아닌 PostgreSQL workbook fixture |
| 입력→출력 grain | TRANSFER2건 + 연결 원장3행 → 연결된 원장 posting1행씩 결과3행 |
| seed 반례 | REQ-301은 debit1/credit1=2행, REQ-302는 debit1/credit0=1행; 모든 transfer가 두 행은 아님 |
SQL 조각 1 · 두 거래·세 원장·출력 세 posting
-- 입력: TRANSFER 거래 2건, 연결 ledger_entry 3행
-- 출력 grain: 연결된 원장 posting 한 건당 한 행
한 줄 읽기: 거래 summary가 아니라 연결된 원장 posting 하나를 결과 한 행으로 본다.
- 문법을 한 줄씩 풀면
- 주석이 JOIN 전후 grain과 seed cardinality를 고정한다.
- 실제 값 추적
- REQ-301 원장2 + REQ-302 원장1 = 결과 posting3행이다.
- 정상 예
- 완전한 이체는 debit1/credit1 두 원장을 가진다.
- 반례·경계 예
- REQ-302는 credit이 없어 원장1행뿐이다.
- 착각 방지
- 문제 제목을 모든 transfer가 항상 두 행이라는 seed 사실로 바꾸면 안 된다.
- 이 블록이 하지 않는 일
- 아직 JOIN이나 count를 실행하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 거래 request와 각 원장 posting 열을 선택한다.
SQL 조각 2 · request와 원장 posting 다섯 열
SELECT t.request_id,
l.entry_id,
l.account_id,
l.entry_type,
l.signed_amount
한 줄 읽기: 어느 요청의 어느 계좌 posting인지와 부호 금액을 한 행에서 함께 본다.
- 문법을 한 줄씩 풀면
- 부모 거래의 request_id와 자식 원장의 PK·계좌·종류·signed 금액을 projection한다.
- 실제 값 추적
- REQ-301은 OUT -300과 IN +300 두 행, REQ-302는 OUT -200 한 행으로 보인다.
- 정상 예
- 한 transfer의 debit과 credit이 각각 별도 posting 행으로 보인다.
- 반례·경계 예
- COUNT로 먼저 접으면 REQ-302에서 어떤 계좌·부호 행이 있는지 세부를 잃는다.
- 착각 방지
- entry_type 이름만 보고 부호 보존까지 자동 보장된다고 생각하면 안 된다.
- 이 블록이 하지 않는 일
- 행 수나 signed 합을 집계하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 같은 tx_id를 가진 거래와 원장을 연결한다.
SQL 조각 3 · 거래에서 원장으로 INNER JOIN
FROM business_tx AS t
JOIN ledger_entry AS l
ON l.tx_id = t.tx_id
한 줄 읽기: tx_id가 같은 실제 ledger posting만 거래에 붙인다.
- 문법을 한 줄씩 풀면
- INNER JOIN은 tx_id PK/FK가 매칭된 거래–원장 쌍만 남긴다.
- 실제 값 추적
- 현재 seed는 REQ-301 두 match, REQ-302 한 match다.
- 정상 예
- 원장2건 거래는 join 결과에서도 posting2행으로 그대로 보인다.
- 반례·경계 예
- 원장0건 transfer는 INNER JOIN에서 사라지므로 이 query만으로 그런 결손은 탐지하지 못한다.
- 착각 방지
- request_id 문자열보다 canonical FK tx_id로 join한다.
- 이 블록이 하지 않는 일
- 아직 TRANSFER만 고르거나 완전성을 판정하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 부모 거래형을 TRANSFER로 제한한다.
SQL 조각 4 · 부모 거래형 TRANSFER만 선택
WHERE t.tx_type = 'TRANSFER'
한 줄 읽기: 원장 이름이 아니라 업무 transaction의 tx_type으로 이체 두 건만 고른다.
- 문법을 한 줄씩 풀면
- WHERE가 부모 business_tx를 TRANSFER로 제한한다.
- 실제 값 추적
- 부모 후보는 REQ-301과 REQ-302 두 건이고 연결 결과는3행이다.
- 정상 예
- REQ-301의 OUT/IN과 REQ-302의 OUT이 남는다.
- 반례·경계 예
- ledger entry_type 문자열만 filter하면 부모 업무형 계약을 대신하지 못한다.
- 착각 방지
- TRANSFER 두 거래와 결과 두 행은 같은 뜻이 아니다. 결과는 posting3행이다.
- 이 블록이 하지 않는 일
- 누락 credit을 채우거나 거래별 한 행으로 집계하지 않는다.
- 다음 코드와의 연결
- 마지막 조각이 request_id 다음 entry_id로 세 결과 행 순서를 고정한다.
SQL 조각 5 · request 다음 entry 순서
ORDER BY t.request_id, l.entry_id;
한 줄 읽기: 같은 요청의 posting끼리 모으고 entry ID로 내부 순서를 고정한다.
- 문법을 한 줄씩 풀면
- 복합 ORDER BY가 request 그룹과 그 안의 PK 순서를 모두 안정화한다.
- 실제 값 추적
- REQ-301 entry12/13 뒤 REQ-302 entry14로 정확히3행이다.
- 정상 예
- 완전 예와 부분 transfer 반례를 바로 비교할 수 있다.
- 반례·경계 예
- ORDER BY가 없어도 수치는 같지만 화면 순서는 계약이 아니다.
- 착각 방지
- 정렬은 REQ-302의 빠진 credit을 고치지 않는다.
- 이 블록이 하지 않는 일
- 결손 원장 복구 작업을 실행하지 않는다.
- 다음 코드와의 연결
- 직접 다시 쓴 뒤 전체 예시 정답의 두 거래·세 posting 계약을 대조한다.
직접 다시 써보기
- 저장 경로
- evidence/w11/sql-q13.sql
- 전제조건
- workbook fixture의 business_tx21·ledger_entry16과 tx_id FK, TRANSFER REQ-301/302를 확인한다.
- 반드시 지킬 계약
- TRANSFER 거래에서 시작해 tx_id로 원장을 INNER JOIN하고 원장 posting grain과 request_id·entry_id 안정 순서를 지킨다.
- 추천 입력 순서
- grain 주석 → 거래/request와 원장 열 → business_tx → JOIN ledger → TRANSFER filter → request/entry order 순서다.
- 자기 점검
- REQ-301 원장2행, REQ-302 원장1행, 출력3행이며 REQ-302에는 credit posting이 없음을 확인한다.
- 이번 파일의 범위 밖
- 누락 credit 자동 복구, 모든 transfer 완전성 단정, 원장0건 transfer 탐지, 앱 ledger schema는 이 문제 범위가 아니다.
전체 예시 정답 · canonical 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 입력: TRANSFER 거래 2건, 연결 ledger_entry 3행
-- 출력 grain: 연결된 원장 posting 한 건당 한 행
SELECT t.request_id,
l.entry_id,
l.account_id,
l.entry_type,
l.signed_amount
FROM business_tx AS t
JOIN ledger_entry AS l
ON l.tx_id = t.tx_id
WHERE t.tx_type = 'TRANSFER'
ORDER BY t.request_id, l.entry_id;
예시가 선택
LEFT JOIN · 6행[2, 2, 2, 1, 0, 1]customer5 = 0 포함다른 가능 정책
INNER JOIN · 5행[2, 2, 2, 1, 1]customer5 제외Q14 · 고객별 계좌 수
source 경계: reference project에 canonical learner SQL answer 파일은 없다. 아래 코드는 PostgreSQL workbook schema·seed를 만족하는 전체 예시 정답이다.
| 계약 질문 | 이 파일의 답 |
|---|---|
| 문제 원문 | W11-SQL-Q14 · 고객별 계좌 수 |
| 정책 결정 | PDF는 0계좌 고객 포함/제외를 명시하라고 요구; 아래 예시는 포함을 선택 |
| 입력→출력 grain | customer6 + account8 → 고객1행씩6행 |
| seed 결과 | customer1..6 count=[2,2,2,1,0,1], customer5 계좌없음도0으로 포함 |
SQL 조각 1 · 0계좌 고객 포함 정책을 먼저 선택
-- 정책 선택: 계좌 0개 고객도 포함한다
-- 출력 grain: 고객 한 명당 한 행, workbook seed 예상 6행
한 줄 읽기: PDF가 강제하지 않은 포함/제외 중 이 예시는 포함을 명시한다.
- 문법을 한 줄씩 풀면
- 주석은 실행값을 바꾸지 않지만 JOIN 선택의 업무 의미를 고정한다.
- 실제 값 추적
- customer6명 모두 출력 후보이고 customer5도 count0으로 남는다.
- 정상 예
- 포함 정책 결과는6행 [2,2,2,1,0,1]이다.
- 반례·경계 예
- 제외 정책도 가능하며 INNER JOIN이면 customer5 없이5행이다.
- 착각 방지
- PDF가 0계좌 고객을 반드시 포함하라고 정한 것은 아니다.
- 이 블록이 하지 않는 일
- 아직 실제 JOIN을 선택하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 고객 식별값과 non-null account ID count를 고른다.
SQL 조각 2 · 고객 식별값과 계좌 수
SELECT c.customer_id,
c.customer_name,
COUNT(a.account_id) AS account_count
한 줄 읽기: 고객 한 행마다 그 고객에게 매칭된 실제 account PK 수를 센다.
- 문법을 한 줄씩 풀면
- COUNT(non-null account_id)는 LEFT JOIN의 NULL 확장 행을0으로 센다.
- 실제 값 추적
- customer5는 join row가 있어도 a.account_id NULL이라 count0이다.
- 정상 예
- customer1은 account101/105 두 개라 count2다.
- 반례·경계 예
- COUNT(*)를 쓰면 customer5 NULL 확장 행도1로 세어 잘못된다.
- 착각 방지
- customer row 수를 세는 것이 아니라 account PK를 센다.
- 이 블록이 하지 않는 일
- 계좌 상태 ACTIVE/CLOSED를 구분하지 않는다.
- 다음 코드와의 연결
- 다음 조각이 포함 정책에 맞춰 customer를 왼쪽에 보존한다.
SQL 조각 3 · customer LEFT JOIN account
FROM customer AS c
LEFT JOIN account AS a
ON a.customer_id = c.customer_id
한 줄 읽기: 계좌가 없어도 고객을 남기도록 customer에서 출발한다.
- 문법을 한 줄씩 풀면
- LEFT JOIN은 왼쪽 customer6을 보존하고 FK로 account0개 이상을 붙인다.
- 실제 값 추적
- customer1/2/3은 각2, customer4/6은 각1, customer5는 NULL 확장1행이다.
- 정상 예
- 포함 정책에서 customer5가 사라지지 않는다.
- 반례·경계 예
- INNER JOIN은 제외 정책이므로 customer5가 사라진다. 틀렸다기보다 다른 정책이다.
- 착각 방지
- LEFT JOIN이 언제나 옳은 것이 아니라 이번 예시가 포함을 선택했기 때문에 쓴다.
- 이 블록이 하지 않는 일
- 고객별 count를 아직 한 행으로 접지 않는다.
- 다음 코드와의 연결
- 다음 조각이 customer별 group으로 계좌 여러 행을 합친다.
SQL 조각 4 · 고객 한 명당 한 group
GROUP BY c.customer_id, c.customer_name
한 줄 읽기: 고객 ID와 이름별로 계좌 행을 모아 출력 grain을 고객 한 행으로 되돌린다.
- 문법을 한 줄씩 풀면
- 집계하지 않은 SELECT 열 두 개를 모두 GROUP BY에 둔다.
- 실제 값 추적
- account8이 고객6 group으로 접히고 count 합은8이다.
- 정상 예
- 계좌2개 고객도 출력은 한 행이다.
- 반례·경계 예
- GROUP BY가 없으면 PostgreSQL은 비집계 column 오류를 낸다.
- 착각 방지
- group이 customer5의 count를1로 만드는 것이 아니다. COUNT(account_id)가0을 만든다.
- 이 블록이 하지 않는 일
- count가0인 고객을 HAVING으로 제거하지 않는다.
- 다음 코드와의 연결
- 마지막 조각이 customer1부터6까지 순서를 고정한다.
SQL 조각 5 · customer ID 순서와 여섯 count
ORDER BY c.customer_id;
한 줄 읽기: 여섯 고객을 ID 순서로 고정해 포함 정책 결과를 대조한다.
- 문법을 한 줄씩 풀면
- customer_id PK 오름차순이 결과 총순서를 만든다.
- 실제 값 추적
- ID1..6의 count는 [2,2,2,1,0,1]이다.
- 정상 예
- customer5=0이 다섯 번째 행에 보인다.
- 반례·경계 예
- 제외 정책 결과는 customer5 행 자체가 없고 count 배열도 [2,2,2,1,1]이다.
- 착각 방지
- 0을 보여 준 사실과 포함 정책이 유일한 정답이라는 주장은 다르다.
- 이 블록이 하지 않는 일
- 계좌 수가0인 이유를 설명하거나 계좌를 생성하지 않는다.
- 다음 코드와의 연결
- 직접 다시 쓴 뒤 전체 예시 정답의 정책 주석·LEFT JOIN·COUNT(account_id)를 대조한다.
직접 다시 써보기
- 저장 경로
- evidence/w11/sql-q14.sql
- 전제조건
- workbook customer6/account8과 account.customer_id FK를 확인하고 0계좌 고객 정책을 먼저 선택한다.
- 반드시 지킬 계약
- 선택 정책을 SQL 주석에 쓰며, 포함 선택이면 customer LEFT JOIN account와 COUNT(account_id)를 쓴다.
- 추천 입력 순서
- 정책/grain 주석 → customer 식별값/count → customer → 선택한 JOIN → group/order 순서다.
- 자기 점검
- 포함 예시는6행 [2,2,2,1,0,1]과 customer5=0; 제외 정책이면 INNER JOIN으로5행임을 구분한다.
- 이번 파일의 범위 밖
- ACTIVE 계좌만 세기, 계좌 balance 합, 거래 없는 계좌, 정책의 업무 타당성은 별도 요구사항이다.
전체 예시 정답 · canonical 부재를 구분
직접 쓴 뒤 전체 예시 정답 펼치기
-- 정책 선택: 계좌 0개 고객도 포함한다
-- 출력 grain: 고객 한 명당 한 행, workbook seed 예상 6행
SELECT c.customer_id,
c.customer_name,
COUNT(a.account_id) AS account_count
FROM customer AS c
LEFT JOIN account AS a
ON a.customer_id = c.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY c.customer_id;
마지막 재점검
원문·정답: Java 4파일은 canonical 원문과 직접 다시 쓰기 뒤 전체 정답이 normalized exact다. Q13/Q14는 canonical 부재를 밝힌 전체 예시 정답이다.
직접 보장: synthetic pool 상한·accounting, ready fail-closed, 독립 통계 공식, 표본 모양, 동시 출금 10/10/0·잔액0·원장10, carried 이체의 총액20,000·signed합0까지다.
직접 보장하지 않음: 실제 업무 30회 반복, DB 1,000건 stress, structured logging/overhead, retry, 실제 unsafe deadlock, 모든 transfer의 원장 두 행, Q14 포함 정책의 의무화는 이 코드 밖이다.