8주차 코드 뒤풀이

WEEK 08 · CODE AFTERPARTY

W08 코드 뒤풀이 — STARRY PASS 4 — 여섯 장의 시험표와 되돌아온 1,000원

학습 범위: 월요일부터 토요일까지 · 정확 원문 6개 · 직접/참조 테스트 계약 10개 · SQL Q07/Q08

새 부품이나 단일 customer-flow 통합 테스트를 만드는 주가 아니라, 이미 만든 HTTP·원장 조회·대사·이체 rollback selector를 여섯 날에 나누어 지연 회수하고 다시 실행하는 주입니다. 아래 원고는 W8 월~토 원문에 실제로 실린 고유 learner target 다섯 파일과, selector 동작을 이해하려 함께 싣는 이전 지원 테스트 한 파일을 합쳐 정확한 원문 여섯 파일로 재구성했습니다. starter, 의도적 실패 절차, Gate·evidence 명령, 일요일 누적 본문은 싣지 않습니다.

먼저 숫자부터 정확히

정확한 전체 원문
6파일 — 운영 소스 4, 통합 테스트 2
본문에 원문이 직접 실린 @Test
3개 — LedgerQueryIT 2, TransferFailurePointIT 1
요일 범위 밖에서 만들어져 원문은 제외하지만 W8 selector가 실행하는 @Test
7개 — 계좌 HTTP 4, 이체 HTTP 3
W8 월~토가 다루는 고유 테스트 계약
10개
요일별 selector 실행 수
12회 — LedgerQueryIT 두 method를 따로 실행한 뒤 class 전체에서 다시 실행하므로 2회가 중복된다.
SQL
Q07·Q08 두 문제. 저장할 evidence 경로는 제시되지만 canonical learner answer 파일은 없다. 따라서 아래 정답은 반드시 전체 예시 정답으로 표시한다.
그림으로 다시 보기: 여섯 날은 새 단일 통합 테스트가 아니라 계좌 HTTP, keyset, 이체 HTTP, 대사, 조회 class 전체, rollback selector를 차례로 재실행한다.
그림으로 다시 보기: 여섯 날은 새 단일 통합 테스트가 아니라 계좌 HTTP, keyset, 이체 HTTP, 대사, 조회 class 전체, rollback selector를 차례로 재실행한다.

여섯 날 selector를 같은 기준선 위에 놓는 길

순서 입구·중심·검증 이번 리허설에서 확인하는 것
1 AccountController 인증 이름과 계좌 요청을 Service로 옮기고 생성 201·조회 body를 만든다.
2 LedgerQueryService created_at+id 책갈피와 strict 비교로 원장을 나누고 잔액-원장 불일치 수를 센다.
3 TransferController 인증된 이체 요청을 Command로 바꾸고 신규 완료 응답 201을 만든다.
4 LedgerQueryIT 실제 PostgreSQL에서 첫2/다음1 무중복과 mismatch 0→1을 확인한다.
5 TransferService 두 계좌를 고정 순서로 잠그고 잔액·거래·원장을 한 transaction에서 바꾼다.
6 TransferFailurePointIT 업무 변경 직후 예외에서 DB 최종값 10000/5000/0/0을 다시 읽는다.

1. AccountController · 계좌 만들기와 한 건 조회를 HTTP에 연결하는 입구

한 문장 역할: W8 첫 리허설에서 인증된 손님의 계좌 생성·조회 요청을 AccountService에 넘기고 HTTP 응답 모양을 정한다.

다시 쓸 저장 경로: src/main/java/com/example/financialcore/account/api/AccountController.java

분류: 최종 운영 소스

원문 SHA-256: c59baae4bd413a36549e1eb48b912843154bef5aea0ec9b874c839f54753130d

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 Spring MVC가 POST /api/accounts 또는 GET /api/accounts/{accountId} 요청을 찾았을 때 호출한다.
무엇을 받나 생성은 검증된 CreateAccountRequest와 인증 Principal, 조회는 URL의 long accountId와 Principal을 받는다.
무엇이 바뀌나 Controller가 직접 잔액을 고치지는 않는다. create가 AccountService를 부르면 서비스 아래 DB 상태가 바뀔 수 있다.
무엇을 돌려주나 생성은 201·Location·AccountResponse, 조회는 AccountResponse를 돌려준다.

정확한 전체 원문

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

import com.example.financialcore.account.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.security.Principal;

@RestController
@RequestMapping("/api/accounts")
public class AccountController {
    private final AccountService service;

    public AccountController(AccountService service) { this.service = service; }

    @PostMapping
    ResponseEntity<AccountResponse> create(
        @Valid @RequestBody CreateAccountRequest request, Principal principal
    ) {
        var account = service.create(principal.getName(), request.accountNo(), request.openingBalance());
        return ResponseEntity.created(URI.create("/api/accounts/" + account.getId()))
            .body(AccountResponse.from(account));
    }

    @GetMapping("/{accountId}")
    AccountResponse get(@PathVariable long accountId, Principal principal) {
        return AccountResponse.from(service.get(principal.getName(), accountId));
    }
}

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 POST /api/accounts JSON accountNo=A-100, openingBalance=10000과 인증 이름 customer-1이 들어온다.
2 @Valid @RequestBody JSON을 CreateAccountRequest로 묶고 선언된 입력 제약을 먼저 검사한다.
3 principal.getName() 본문이 주장한 소유자가 아니라 인증된 customer-1을 actor로 읽는다.
4 service.create(...) customer-1, A-100, 10000이 서비스로 이동하고 저장된 Account가 돌아온다.
5 URI.create(...) 저장 뒤 생긴 account.id가 17이라면 /api/accounts/17이 된다.
6 ResponseEntity.created 상태 201과 Location을 만들고 AccountResponse를 body로 붙인다.
7 GET /api/accounts/17 17과 customer-1을 service.get에 넘기고 반환 Account를 응답 DTO로 바꾼다.

코드 블록 1 · 주소와 Spring MVC 표지판

해설 바로 옆 코드 · java
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
문법을 한 줄씩 풀면
@RestController는 반환값을 HTTP body로 직렬화하는 MVC component임을 알리고, @RequestMapping은 method 경로 앞에 공통 주소를 붙인다.
실제 값 추적
class 자체를 new 하는 요청이 오는 것이 아니다. Spring이 이 bean을 만들고 /api/accounts 경로표에 두 method를 등록한다.
정상 예
POST /api/accounts와 GET /api/accounts/17은 각각 아래 @PostMapping과 @GetMapping에 연결된다.
반례·경계 예
POST /accounts처럼 class 경로를 빼거나 GET /api/accounts처럼 accountId를 빼면 이 두 method 계약과 맞지 않는다.
착각 방지
annotation은 설명 주석이 아니다. 다만 annotation만 붙였다고 인증·DB·검증이 모두 자동으로 생기는 것도 아니다.
이 블록이 하지 않는 일
계좌를 저장하거나 조회하지 않는다. 오직 어떤 HTTP 요청을 어떤 Java method에 연결할지 선언한다.
다음 코드와의 연결
다음 생성자에서 실제 업무를 맡을 AccountService를 주입받는다.

코드 블록 2 · final 필드와 생성자 주입

해설 바로 옆 코드 · java
private final AccountService service;

public AccountController(AccountService service) { this.service = service; }
문법을 한 줄씩 풀면
왼쪽 this.service는 지금 만드는 Controller 객체의 필드이고, 오른쪽 service는 생성자가 받은 매개변수다. 이름은 같지만 저장 위치가 다르다.
실제 값 추적
Spring이 AccountService bean S를 생성자 인자로 넘기면 오른쪽 service가 S를 가리키고, 대입 뒤 this.service도 같은 S를 계속 가리킨다.
정상 예
생성자 주입 뒤 두 HTTP method가 같은 final service 참조를 사용한다.
반례·경계 예
대입을 빼면 필드가 초기화되지 않아 컴파일되지 않는다. this를 빼고 service = service라고 쓰면 매개변수 자기 자신에 대입하는 실수가 된다.
착각 방지
final은 AccountService 내부 상태가 불변이라는 뜻이 아니라 Controller의 service 참조를 다른 객체로 갈아끼우지 않는다는 뜻이다.
이 블록이 하지 않는 일
AccountService를 생성하거나 DB 연결을 구성하지 않는다. 이미 만들어진 bean을 받는다.
다음 코드와의 연결
다음 create method가 이 필드를 이용해 실제 생성 업무를 부탁한다.

코드 블록 3 · 생성 요청을 검증하고 인증 이름을 서비스로 이동

해설 바로 옆 코드 · java
@PostMapping
ResponseEntity<AccountResponse> create(
    @Valid @RequestBody CreateAccountRequest request, Principal principal
) {
    var account = service.create(principal.getName(), request.accountNo(), request.openingBalance());
문법을 한 줄씩 풀면
@RequestBody는 JSON을 request로 묶고 @Valid는 그 DTO에 선언된 제약을 실행한다. Principal은 인증된 호출 주체다. var의 실제 type은 service.create의 반환 type으로 정해진다.
실제 값 추적
customer-1과 A-100, 10000이 차례로 service.create 인자로 이동한다. 성공하면 ID와 잔액을 가진 account 지역변수가 생긴다.
정상 예
인증된 customer-1이 유효한 양수 개설 잔액으로 요청하면 서비스까지 도달한다.
반례·경계 예
공백 계좌번호나 음수 잔액이 DTO 제약을 어기면 method 본문에 들어오기 전 거절될 수 있다. 인증이 없다면 principal을 정상값처럼 사용할 수 없다.
착각 방지
@Valid는 계좌번호 중복이나 소유권 같은 DB 업무 규칙까지 자동으로 검사하지 않는다. DTO에 선언된 제약을 연결할 뿐이다.
이 블록이 하지 않는 일
HTTP 오류 형식을 만들거나 실제 INSERT SQL을 직접 실행하지 않는다.
다음 코드와의 연결
다음 줄은 서비스가 돌려준 ID를 Location과 응답 body에 사용한다.

코드 블록 4 · 201·Location·응답 DTO

해설 바로 옆 코드 · java
return ResponseEntity.created(URI.create("/api/accounts/" + account.getId()))
    .body(AccountResponse.from(account));
문법을 한 줄씩 풀면
created는 HTTP 201 응답 builder를 만들며 URI를 Location header로 쓴다. body는 Account를 공개 응답 DTO로 바꿔 붙인다.
실제 값 추적
account.id=17이면 문자열 /api/accounts/17이 URI가 되고, AccountResponse.from은 accountNo·balance 같은 공개 필드를 새 body에 담는다.
정상 예
새 계좌가 실제로 만들어진 뒤 201과 새 자원 주소를 돌려주는 흐름이 맞다.
반례·경계 예
DB 저장이 실패했는데도 이 줄만 실행해 201을 만들면 거짓 성공이다. 현재는 앞 service 호출이 예외를 내면 이 return까지 오지 않는다.
착각 방지
201은 ‘잘 됨’ 점수가 아니라 새 자원이 만들어졌다는 HTTP 계약이다. 모든 성공에 무조건 쓰는 상태가 아니다.
이 블록이 하지 않는 일
응답 DTO가 어떤 필드를 숨길지 정의하지 않는다. 그 책임은 AccountResponse에 있다.
다음 코드와의 연결
다음 GET method는 이미 있는 ID를 받아 200 body를 돌려준다.

코드 블록 5 · 경로 ID와 인증 주체로 한 건 조회

해설 바로 옆 코드 · java
@GetMapping("/{accountId}")
AccountResponse get(@PathVariable long accountId, Principal principal) {
    return AccountResponse.from(service.get(principal.getName(), accountId));
}
문법을 한 줄씩 풀면
@PathVariable은 URL의 {accountId} 문자열을 long으로 바인딩한다. principal 이름과 ID를 서비스에 넘긴 뒤 entity를 응답 DTO로 변환한다.
실제 값 추적
GET /api/accounts/17과 customer-1이면 service.get(customer-1, 17)이 호출된다.
정상 예
17번 계좌가 customer-1 소유라면 AccountResponse가 정상 반환된다.
반례·경계 예
존재하지 않는 999999나 다른 소유자의 계좌라면 서비스가 예외를 낼 수 있다. 이 method는 그 예외를 성공값으로 바꾸지 않는다.
착각 방지
반환 type에 ResponseEntity가 없다고 반드시 상태가 없다는 뜻은 아니다. 정상 반환은 MVC 기본 200이 되고 예외는 handler가 별도로 바꾼다.
이 블록이 하지 않는 일
목록 조회·페이지네이션·원장 조회는 하지 않는다.
다음 코드와의 연결
계좌 한 건을 확인한 다음 파일에서는 원장 여러 행을 cursor로 나눠 읽는다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상 생성: 인증 customer-1, accountNo=A-100, openingBalance=10000이면 서비스가 계좌를 만들고 Controller는 201과 새 주소를 돌려준다.
  • 정상 조회: customer-1이 자신의 17번 계좌를 읽으면 서비스 결과를 AccountResponse로 변환한다.

반례·경계 예

  • 반례: request에 ownerId 필드를 억지로 추가해도 이 Controller는 그 값을 actor로 쓰지 않는다. 인증 Principal이 기준이다.
  • 경계: 음수 openingBalance는 @Valid가 연결된 DTO 제약에서 거절되어 서비스 호출 전 400으로 끝날 수 있다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: @PostMapping은 모든 POST를 받는 표시가 아니다. class의 /api/accounts와 합쳐진 경로만 받는다.
  • 착각 방지: ResponseEntity.created(...)가 DB를 저장하는 것이 아니다. 저장은 그보다 앞의 service.create가 담당한다.
  • 착각 방지: principal.getName()은 표시용 닉네임을 임의로 읽는 코드가 아니라 인증 계층이 넘긴 주체 이름을 사용한다.
  • 착각 방지: AccountResponse.from(account)는 Account entity 자체를 그대로 JSON으로 노출하는 것과 다르다.
  • 하지 않는 일: 비밀번호를 검사하거나 Principal을 만드는 일
  • 하지 않는 일: 중복 계좌번호·잔액 규칙을 직접 구현하는 일
  • 하지 않는 일: 예외를 400/404 JSON으로 바꾸는 전역 예외 처리
  • 하지 않는 일: transaction 시작·commit·rollback을 직접 제어하는 일

다음 연결

이 입구가 통과한 뒤에는 W8의 조회 리허설로 넘어가, 저장된 원장 행을 안정된 순서로 나누어 읽는다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/account/api/AccountController.java
전제조건
CreateAccountRequest, AccountResponse, AccountService가 같은 프로젝트에 있고 Spring Web·Validation·Security 설정이 준비되어 있어야 한다.
반드시 지킬 계약
class 경로 /api/accounts, POST/GET 두 method, Principal 기반 actor, 생성 201·Location, DTO 변환을 바꾸지 않는다.
추천 입력 순서
package → import → class annotation → final service와 생성자 → create → get 순서로 입력한다.
자기 점검
POST 정상값이 201인지, Location이 /api/accounts/{id}인지, 음수가 서비스 호출 전에 거절되는지, 소유자가 자기 계좌를 GET 하는지 확인한다.
이번 파일의 범위 밖
인증 설정, 서비스의 업무 규칙, 저장소, 전역 예외 JSON을 Controller 안에 다시 구현하지 않는다.

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

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

import com.example.financialcore.account.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.security.Principal;

@RestController
@RequestMapping("/api/accounts")
public class AccountController {
    private final AccountService service;

    public AccountController(AccountService service) { this.service = service; }

    @PostMapping
    ResponseEntity<AccountResponse> create(
        @Valid @RequestBody CreateAccountRequest request, Principal principal
    ) {
        var account = service.create(principal.getName(), request.accountNo(), request.openingBalance());
        return ResponseEntity.created(URI.create("/api/accounts/" + account.getId()))
            .body(AccountResponse.from(account));
    }

    @GetMapping("/{accountId}")
    AccountResponse get(@PathVariable long accountId, Principal principal) {
        return AccountResponse.from(service.get(principal.getName(), accountId));
    }
}

W8 selector가 실행하지만 W8 월~토 본문에 원문을 복제하지 않는 이전 시험

아래 method는 이번 주 exact selector가 실제 실행합니다. 다만 만들어진 파일의 전체 원문은 요청 범위 밖이므로 다시 싣지 않고, 실제 fixture·HTTP/SQL assertion·실행 순서·보장 경계만 자세히 풉니다. 한 method 안의 AssertJ assertion은 위에서 아래로 실행되며, 앞 assertion이 실패해 AssertionError가 발생하면 그 아래 assertion은 같은 실행에서 수행되지 않습니다. 강화 예시는 canonical 원문이 아니라 추가 학습 제안입니다.

참조 시험 1 · createReturns201AndPersistsOpening

준비(Arrange) · 실제 fixture
@BeforeEach가 네 관련 table을 비운다. HTTP Basic customer-1/password, JSON accountNo=A-100·openingBalance=10000을 사용한다.
행동(Act) · 실제 HTTP
POST /api/accounts를 수행하고 MockHttpServletResponse를 받는다.
확인(Assert) · 실제 실행 순서
  1. response.getStatus()가 201인지 먼저 확인한다.
  2. 그 뒤 SQL SELECT COUNT(*) FROM account WHERE account_no='A-100'의 단일 long이 1인지 확인한다.
직접 보장
인증된 정상 생성이 HTTP 201이고 A-100 계좌 행이 정확히 1개 남는다는 두 사실을 직접 보장한다.
직접 보장하지 않음
Location header, response JSON의 accountNo·balance, OPENING business_tx/ledger_entry, 중복 요청을 직접 확인하지 않는다.
원문 아닌 강화 예시
원문을 바꾸지 않는 추가 테스트라면 Location이 /api/accounts/{숫자}인지, body의 accountNo=A-100·balance=10000인지, OPENING 원장 signed 합=10000인지 각각 assertion한다.

참조 시험 2 · negativeOpeningReturns400AndWritesNothing

준비(Arrange) · 실제 fixture
깨끗한 DB에서 HTTP Basic customer-1/password, X-Request-Id=w6-invalid, JSON accountNo=BAD·openingBalance=-1을 사용한다.
행동(Act) · 실제 HTTP
POST /api/accounts를 수행해 validation 실패 응답을 받는다.
확인(Assert) · 실제 실행 순서
  1. response status가 400인지 먼저 확인한다.
  2. 응답 문자열이 INVALID_REQUEST와 w6-invalid를 모두 포함하는지 확인한다.
  3. 마지막으로 SELECT COUNT(*) FROM account가 0인지 확인한다.
직접 보장
음수 개설 잔액이 400으로 거절되고 지정 오류 표식·request ID가 노출되며 account 행이 쓰이지 않는다는 사실을 보장한다.
직접 보장하지 않음
business_tx·ledger_entry·idempotency_request까지 모두 0인지, 오류 JSON의 정확한 field 순서·전체 shape를 직접 보장하지 않는다.
원문 아닌 강화 예시
추가 예시는 jsonPath로 errorCode/requestId를 정확히 비교하고, business_tx·ledger_entry count도 0인지 확인한다. 공백 accountNo도 별도 경계 사례로 둔다.

참조 시험 3 · ownerReadsAccount

준비(Arrange) · 실제 fixture
깨끗한 DB에서 service.create(customer-1, LOOKUP, 7000)으로 읽을 계좌를 먼저 만든다.
행동(Act) · 실제 HTTP
HTTP Basic customer-1/password로 GET /api/accounts/{생성된 ID}를 수행한다.
확인(Assert) · 실제 실행 순서
  1. response status가 200인지 먼저 확인한다.
  2. 그 뒤 response 문자열에 LOOKUP이 들어 있는지 확인한다.
직접 보장
소유자가 실제 생성된 ID를 조회하면 200이고 응답에 계좌번호 LOOKUP이 포함된다는 사실을 보장한다.
직접 보장하지 않음
balance=7000, accountId field, JSON 전체 shape, 타인 소유 계좌의 거절 상태를 직접 확인하지 않는다.
원문 아닌 강화 예시
추가 테스트에서는 jsonPath로 accountId·accountNo·balance를 각각 확인하고 customer-2 인증으로 같은 ID를 요청해 접근 거절 계약을 별도 검증한다.

참조 시험 4 · missingAccountReturns404

준비(Arrange) · 실제 fixture
깨끗한 DB에서 존재하지 않도록 큰 ID 999999를 사용하고, HTTP Basic customer-1/password와 X-Request-Id=w6-missing을 붙인다.
행동(Act) · 실제 HTTP
GET /api/accounts/999999를 수행한다.
확인(Assert) · 실제 실행 순서
  1. response status가 404인지 먼저 확인한다.
  2. 그 뒤 response 문자열이 ACCOUNT_NOT_FOUND와 w6-missing을 모두 포함하는지 확인한다.
직접 보장
없는 계좌 조회가 404이고 오류 코드 표식과 요청 ID가 응답에 연결된다는 사실을 보장한다.
직접 보장하지 않음
존재하지만 다른 소유자의 계좌, error timestamp·path 등 전체 JSON, DB 무변경을 직접 확인하지 않는다.
원문 아닌 강화 예시
추가 예시는 jsonPath로 errorCode·requestId를 정확히 비교하고, 별도 fixture로 타인 소유 ID의 보안상 404/403 정책을 명시적으로 고정한다.

2. LedgerQueryService · 시간과 ID 책갈피로 원장을 나누고 잔액 차이를 세는 조회 서비스

한 문장 역할: W8 조회 리허설에서 원장 첫 묶음·다음 묶음과 계좌-원장 불일치 수를 읽기 전용 transaction으로 제공한다.

다시 쓸 저장 경로: src/main/java/com/example/financialcore/ledger/LedgerQueryService.java

분류: 최종 운영 소스

원문 SHA-256: 97a72800cce51d221c3cf61350c09274cb90342d9374d6c57fa45b180b6ae1c6

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 원장 조회 API나 통합 테스트가 firstPage, nextPage, reconciliationMismatchCount를 호출한다.
무엇을 받나 페이지 조회는 accountId와 1..100 limit, 다음 페이지는 occurredAt+id Cursor를 추가로 받는다.
무엇이 바뀌나 readOnly transaction에서 SELECT만 수행하며 계좌나 원장 행을 수정하지 않는다.
무엇을 돌려주나 원장 조회는 LedgerRow 목록, 대사는 불일치 계좌 수 long을 돌려준다.

정확한 전체 원문

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

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;

@Service
public class LedgerQueryService {
    public record LedgerRow(
        long id, String entryType, long amount, long signedAmount,
        long balanceAfter, Instant occurredAt
    ) {}

    public record Cursor(Instant occurredAt, long id) {}

    private final JdbcClient jdbc;

    public LedgerQueryService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(readOnly = true)
    public List<LedgerRow> firstPage(long accountId, int limit) {
        requireLimit(limit);
        return base("", accountId, null, 0, limit);
    }

    @Transactional(readOnly = true)
    public List<LedgerRow> nextPage(long accountId, Cursor before, int limit) {
        if (before == null) throw new IllegalArgumentException("cursor");
        requireLimit(limit);
        return base("AND (created_at,id) < (:occurredAt,:id)",
            accountId, before.occurredAt(), before.id(), limit);
    }

    @Transactional(readOnly = true)
    public long reconciliationMismatchCount() {
        return jdbc.sql("""
                SELECT COUNT(*) FROM (
                  SELECT a.id
                  FROM account a LEFT JOIN ledger_entry e ON e.account_id=a.id
                  GROUP BY a.id,a.balance
                  HAVING a.balance <> COALESCE(SUM(e.signed_amount),0)
                ) mismatch
                """)
            .query(Long.class)
            .single();
    }

    private List<LedgerRow> base(
        String cursorPredicate, long accountId, Instant occurredAt, long id, int limit
    ) {
        String sql = """
            SELECT id,entry_type,amount,signed_amount,balance_after,created_at
            FROM ledger_entry
            WHERE account_id=:accountId
            %s
            ORDER BY created_at DESC,id DESC
            LIMIT :limit
            """.formatted(cursorPredicate);
        var statement = jdbc.sql(sql)
            .param("accountId", accountId)
            .param("limit", limit);
        if (!cursorPredicate.isBlank()) {
            statement = statement
                .param("occurredAt", OffsetDateTime.ofInstant(occurredAt, ZoneOffset.UTC))
                .param("id", id);
        }
        return statement.query((rs, rowNum) -> new LedgerRow(
            rs.getLong("id"), rs.getString("entry_type"), rs.getLong("amount"),
            rs.getLong("signed_amount"), rs.getLong("balance_after"),
            rs.getTimestamp("created_at").toInstant()
        )).list();
    }

    private static void requireLimit(int limit) {
        if (limit < 1 || limit > 100) throw new IllegalArgumentException("limit must be 1..100");
    }
}

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 firstPage(accountId, 2) limit 2를 검사하고 cursor 조건 없는 base를 부른다.
2 ORDER BY created_at DESC,id DESC IN(+500, 시각+2), OUT(-500, 시각+1), OPENING(+10000)의 순서가 된다.
3 LIMIT 2 첫 묶음에는 최신 IN과 OUT 두 행만 들어간다.
4 Cursor(first[1].occurredAt, first[1].id) 첫 묶음 마지막인 OUT의 시간과 ID를 책갈피로 저장한다.
5 tuple < cursor OUT보다 과거인 OPENING만 다음 후보가 되고 OUT 자신은 다시 포함되지 않는다.
6 row mapper 각 SQL 열을 id·entryType·amount·signedAmount·balanceAfter·occurredAt record 칸으로 옮긴다.
7 reconciliation account.balance 10000과 signed_amount 합 10000이면 mismatch 0이다.
8 balance + 1 계좌만 10001로 바꾸면 합 10000과 달라져 mismatch 1이 된다.

코드 블록 1 · 조회 결과 여섯 칸과 cursor 두 칸

해설 바로 옆 코드 · java
public record LedgerRow(
    long id, String entryType, long amount, long signedAmount,
    long balanceAfter, Instant occurredAt
) {}

public record Cursor(Instant occurredAt, long id) {}
문법을 한 줄씩 풀면
LedgerRow는 SQL 한 행을 여섯 값으로 묶고, Cursor는 정렬 열과 같은 두 값만 묶는다. record accessor는 id(), occurredAt()처럼 괄호를 붙여 호출한다.
실제 값 추적
TRANSFER_IN 행이 id=3, amount=500, signed=500, balanceAfter=10000이면 그대로 여섯 칸에 들어간다. cursor는 첫 묶음 마지막 행의 시간과 id=2를 복사한다.
정상 예
같은 시각의 두 행도 id가 다르면 cursor가 어느 행 뒤인지 정확히 표현할 수 있다.
반례·경계 예
Cursor에 시간만 두면 같은 시간의 여러 행 중 어디까지 읽었는지 잃는다. LedgerRow의 amount와 signedAmount를 하나로 합치면 절댓값과 방향을 구분하지 못한다.
착각 방지
record가 DB 행을 스스로 조회하는 것은 아니다. 단순한 값 묶음이다.
이 블록이 하지 않는 일
HTTP용 base64 cursor 변환이나 entity 저장을 하지 않는다.
다음 코드와의 연결
다음 method들이 이 두 record를 입력과 출력 계약으로 사용한다.

코드 블록 2 · 첫 묶음과 limit 방어

해설 바로 옆 코드 · java
@Transactional(readOnly = true)
public List<LedgerRow> firstPage(long accountId, int limit) {
    requireLimit(limit);
    return base("", accountId, null, 0, limit);
}
문법을 한 줄씩 풀면
readOnly transaction 안에서 limit을 검사한 뒤 공통 base에 빈 cursor 조건과 dummy 값 null,0을 넘긴다.
실제 값 추적
accountId=1, limit=2라면 requireLimit을 통과하고 cursorPredicate가 빈 SQL로 최신 두 행을 읽는다.
정상 예
limit=1 또는 100은 허용 경계다.
반례·경계 예
limit=0 또는 101은 SQL까지 가지 않고 IllegalArgumentException을 낸다.
착각 방지
null과 0은 이 호출에서 cursor 조건이 없기 때문에 사용되지 않는 자리표시자다. 모든 null이 위험한 DB 값이라는 뜻은 아니다.
이 블록이 하지 않는 일
전체 행 수나 다음 페이지 존재 여부를 계산하지 않는다.
다음 코드와의 연결
다음 nextPage는 실제 cursor 값과 strict 조건을 base에 전달한다.

코드 블록 3 · 다음 묶음의 strict tuple 비교

해설 바로 옆 코드 · java
if (before == null) throw new IllegalArgumentException("cursor");
requireLimit(limit);
return base("AND (created_at,id) < (:occurredAt,:id)",
    accountId, before.occurredAt(), before.id(), limit);
문법을 한 줄씩 풀면
PostgreSQL row-value 비교 (created_at,id) < (...)는 먼저 시간, 동률이면 id를 비교한다. DESC 정렬에서 다음 페이지는 cursor보다 작은 tuple이다.
실제 값 추적
cursor가 OUT 행(time+1,id=2)이면 그보다 최신 IN(time+2,id=3)과 cursor 자신은 제외되고 더 과거 OPENING이 남는다.
정상 예
strict <와 ORDER BY created_at DESC,id DESC가 같은 두 열·같은 우선순위를 사용한다.
반례·경계 예
<=는 cursor 자신을 포함하고, created_at < :time만 쓰면 같은 시간에서 id가 더 작은 미조회 행을 건너뛸 수 있다.
착각 방지
‘before’는 Java 객체의 메모리 주소를 비교하지 않는다. 그 안 두 값을 SQL parameter로 넘긴다.
이 블록이 하지 않는 일
요청 cursor가 실제로 해당 계좌에서 나온 것인지 서명·검증하지 않는다.
다음 코드와의 연결
공통 base가 조건을 SQL에 넣고 시간 parameter를 OffsetDateTime으로 바꾼다.

코드 블록 4 · 대사 SQL: 현재 잔액과 signed 합

해설 바로 옆 코드 · java
SELECT COUNT(*) FROM (
  SELECT a.id
  FROM account a LEFT JOIN ledger_entry e ON e.account_id=a.id
  GROUP BY a.id,a.balance
  HAVING a.balance <> COALESCE(SUM(e.signed_amount),0)
) mismatch
문법을 한 줄씩 풀면
LEFT JOIN은 모든 account를 남기고, GROUP BY는 계좌별 한 그룹을 만든다. HAVING은 집계 뒤 잔액과 signed 합이 다른 그룹만 남기며 바깥 COUNT가 그 수를 센다.
실제 값 추적
opening +10000, out -500, in +500의 합은 10000이다. account.balance 10000이면 0건, balance만 10001이면 한 그룹이 남아 1이다.
정상 예
원장 없는 잔액 0 계좌는 SUM이 null이지만 COALESCE로 0이 되어 정상으로 비교된다.
반례·경계 예
INNER JOIN은 원장 없는 계좌를 지우고, SUM(amount)는 출금도 양수 500으로 더해 틀린 합을 만들 수 있다.
착각 방지
대사는 repair가 아니라 차이를 ‘찾는’ query다. COUNT 0이 query가 옳다는 단독 증거도 아니다. 오류 fixture에서 1이 되는지 함께 봐야 한다.
이 블록이 하지 않는 일
어느 행이 틀렸는지 상세 목록이나 원인을 반환하지 않는다.
다음 코드와의 연결
LedgerQueryIT가 정상 0과 의도적으로 만든 +1 오류를 모두 검증한다.

코드 블록 5 · 동적 cursor 절과 안전한 named parameter

해설 바로 옆 코드 · java
String sql = """
    SELECT id,entry_type,amount,signed_amount,balance_after,created_at
    FROM ledger_entry
    WHERE account_id=:accountId
    %s
    ORDER BY created_at DESC,id DESC
    LIMIT :limit
    """.formatted(cursorPredicate);
문법을 한 줄씩 풀면
text block은 여러 줄 SQL 문자열이고 formatted는 미리 정해 둔 cursor 절만 삽입한다. 실제 값은 :accountId·:occurredAt·:id·:limit named parameter로 바인딩한다.
실제 값 추적
첫 페이지에는 %s가 빈 문자열, 다음 페이지에는 tuple predicate가 들어간다. accountId와 limit은 문자열 연결이 아니라 parameter다.
정상 예
현재 호출자가 넘기는 cursorPredicate가 두 고정 문자열 중 하나라 SQL 구조가 예측 가능하다.
반례·경계 예
HTTP 입력을 cursorPredicate 자체로 그대로 넣으면 SQL injection 위험이 생긴다. 이 private method를 임의 SQL 조각 API로 바꾸면 안 된다.
착각 방지
formatted가 모든 값을 안전하게 parameterize하는 것은 아니다. 여기서는 SQL 구조만 삽입하고 사용자 값은 별도 param으로 묶기 때문에 안전하다.
이 블록이 하지 않는 일
전체 count query나 OFFSET을 만들지 않는다.
다음 코드와의 연결
다음 블록에서 필요할 때만 cursor 시간과 id를 parameter로 추가한다.

코드 블록 6 · 조건부 cursor binding과 한 행 매핑

해설 바로 옆 코드 · java
if (!cursorPredicate.isBlank()) {
    statement = statement
        .param("occurredAt", OffsetDateTime.ofInstant(occurredAt, ZoneOffset.UTC))
        .param("id", id);
}
return statement.query((rs, rowNum) -> new LedgerRow(
    rs.getLong("id"), rs.getString("entry_type"), rs.getLong("amount"),
    rs.getLong("signed_amount"), rs.getLong("balance_after"),
    rs.getTimestamp("created_at").toInstant()
)).list();
문법을 한 줄씩 풀면
첫 페이지 SQL에는 cursor parameter 이름이 없으므로 빈 조건일 때는 두 값을 bind하지 않는다. 다음 페이지 시간은 UTC OffsetDateTime으로 바꿔 PostgreSQL 시간 값에 전달한다. row mapper는 ResultSet 한 행씩 record를 만든다.
실제 값 추적
IN 행의 여섯 열이 LedgerRow(3, TRANSFER_IN, 500, 500, 10000, instant)로 이동한다.
정상 예
SQL SELECT 열과 getter 이름, LedgerRow component 순서가 일치한다.
반례·경계 예
signed_amount와 balance_after 순서를 바꾸면 type이 모두 long이라 컴파일은 되지만 의미가 뒤집힌다. created_at을 현재 시각으로 새로 만들면 DB 사실을 잃는다.
착각 방지
rowNum은 현재 mapper에서 사용하지 않는 보조 인자다. Instant와 OffsetDateTime은 같은 객체가 아니라 시간대를 안전하게 전달하기 위한 서로 다른 표현이다.
이 블록이 하지 않는 일
domain entity를 영속화하거나 lazy association을 건드리지 않는다.
다음 코드와의 연결
마지막 requireLimit은 비정상 page 크기가 base에 도달하지 않게 막는다.

코드 블록 7 · 1..100 limit 계약

해설 바로 옆 코드 · java
private static void requireLimit(int limit) {
    if (limit < 1 || limit > 100) throw new IllegalArgumentException("limit must be 1..100");
}
문법을 한 줄씩 풀면
두 비교를 ||로 묶어 허용 구간 밖이면 하나의 IllegalArgumentException을 낸다. static private helper라 객체 상태를 쓰지 않는다.
실제 값 추적
1과 100은 통과하고 0, -1, 101은 예외다.
정상 예
페이지 크기를 명시적으로 제한해 실수로 너무 큰 결과를 요청하는 것을 막는다.
반례·경계 예
&&로 바꾸면 한 정수가 동시에 1보다 작고 100보다 클 수 없어 조건이 사실상 작동하지 않는다.
착각 방지
1..100은 배열 index가 아니라 허용하는 행 수다. 100을 거부하는 < 또는 > 경계 실수에 주의한다.
이 블록이 하지 않는 일
accountId 양수·존재 여부나 HTTP status를 검사하지 않는다.
다음 코드와의 연결
테스트는 실제 두 페이지 결과를 보지만 0/101 예외는 현재 두 @Test가 직접 보장하지 않는다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상 keyset: 정렬과 cursor 비교가 모두 created_at DESC,id DESC 기준이고 비교는 strict <다.
  • 정상 대사: 원장이 없는 계좌도 LEFT JOIN과 COALESCE(...,0)로 비교 대상에 남는다.

반례·경계 예

  • 반례: <=를 쓰면 cursor 행이 다음 묶음에 다시 나타날 수 있다.
  • 반례: created_at만 cursor에 저장하면 같은 시각 행의 순서와 경계를 하나로 결정하지 못한다.
  • 반례: INNER JOIN이면 원장 0행 계좌가 대사 대상에서 사라질 수 있다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: LIMIT만 붙였다고 안정된 페이지가 되는 것이 아니다. 총순서와 strict cursor 조건이 함께 있어야 한다.
  • 착각 방지: amount는 절댓값, signedAmount는 입출 방향까지 담은 값이다. 합계 대사에는 signed_amount를 쓴다.
  • 착각 방지: balanceAfter는 각 원장 행 당시 스냅샷이고, 대사 query는 전체 signed_amount 합과 현재 account.balance를 비교한다.
  • 착각 방지: readOnly=true는 DB가 어떤 상황에서도 쓰기를 물리적으로 금지한다는 보편 보장이 아니라 Spring transaction 의도를 명시한다.
  • 하지 않는 일: 잘못된 잔액을 자동 수정하는 일
  • 하지 않는 일: HTTP cursor 문자열을 파싱하거나 응답 JSON을 만드는 일
  • 하지 않는 일: 중간 삽입·삭제가 있는 모든 격리수준에서 snapshot을 보장하는 일
  • 하지 않는 일: limit 초과를 HTTP 400 body로 바꾸는 일

다음 연결

이 서비스의 계약은 LedgerQueryIT가 실제 PostgreSQL 행을 준비해 첫/다음 묶음과 0→1 대사를 직접 확인한다.

그림으로 다시 보기: 첫 묶음 2행의 마지막 (createdat,id)을 책갈피로 삼고 strict <를 써 cursor 행을 제외한 다음 1행을 읽는다.
그림으로 다시 보기: 첫 묶음 2행의 마지막 (created_at,id)을 책갈피로 삼고 strict <를 써 cursor 행을 제외한 다음 1행을 읽는다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/ledger/LedgerQueryService.java
전제조건
JdbcClient bean과 account·ledger_entry schema가 있고 created_at은 PostgreSQL timestamptz로 읽을 수 있어야 한다.
반드시 지킬 계약
LedgerRow 여섯 칸, Cursor 두 칸, 1..100 limit, tuple strict <, DESC/DESC 정렬, LEFT JOIN 대사 query를 유지한다.
추천 입력 순서
record 두 개 → JdbcClient 주입 → firstPage → nextPage → reconciliation → 공통 base → requireLimit 순서로 입력한다.
자기 점검
limit 0/101 거절, null cursor 거절, 첫 2행·다음 1행 무중복, 정상 mismatch 0·잔액 +1 뒤 1을 확인한다.
이번 파일의 범위 밖
cursor를 단일 시간으로 축약하거나 대사 결과를 자동 UPDATE로 고치지 않는다.

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

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

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;

@Service
public class LedgerQueryService {
    public record LedgerRow(
        long id, String entryType, long amount, long signedAmount,
        long balanceAfter, Instant occurredAt
    ) {}

    public record Cursor(Instant occurredAt, long id) {}

    private final JdbcClient jdbc;

    public LedgerQueryService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(readOnly = true)
    public List<LedgerRow> firstPage(long accountId, int limit) {
        requireLimit(limit);
        return base("", accountId, null, 0, limit);
    }

    @Transactional(readOnly = true)
    public List<LedgerRow> nextPage(long accountId, Cursor before, int limit) {
        if (before == null) throw new IllegalArgumentException("cursor");
        requireLimit(limit);
        return base("AND (created_at,id) < (:occurredAt,:id)",
            accountId, before.occurredAt(), before.id(), limit);
    }

    @Transactional(readOnly = true)
    public long reconciliationMismatchCount() {
        return jdbc.sql("""
                SELECT COUNT(*) FROM (
                  SELECT a.id
                  FROM account a LEFT JOIN ledger_entry e ON e.account_id=a.id
                  GROUP BY a.id,a.balance
                  HAVING a.balance <> COALESCE(SUM(e.signed_amount),0)
                ) mismatch
                """)
            .query(Long.class)
            .single();
    }

    private List<LedgerRow> base(
        String cursorPredicate, long accountId, Instant occurredAt, long id, int limit
    ) {
        String sql = """
            SELECT id,entry_type,amount,signed_amount,balance_after,created_at
            FROM ledger_entry
            WHERE account_id=:accountId
            %s
            ORDER BY created_at DESC,id DESC
            LIMIT :limit
            """.formatted(cursorPredicate);
        var statement = jdbc.sql(sql)
            .param("accountId", accountId)
            .param("limit", limit);
        if (!cursorPredicate.isBlank()) {
            statement = statement
                .param("occurredAt", OffsetDateTime.ofInstant(occurredAt, ZoneOffset.UTC))
                .param("id", id);
        }
        return statement.query((rs, rowNum) -> new LedgerRow(
            rs.getLong("id"), rs.getString("entry_type"), rs.getLong("amount"),
            rs.getLong("signed_amount"), rs.getLong("balance_after"),
            rs.getTimestamp("created_at").toInstant()
        )).list();
    }

    private static void requireLimit(int limit) {
        if (limit < 1 || limit > 100) throw new IllegalArgumentException("limit must be 1..100");
    }
}

3. TransferController · 인증된 HTTP 이체를 Command로 바꾸고 201 응답을 조립하는 입구

한 문장 역할: W8 HTTP 재검증에서 요청 DTO와 인증 Principal을 TransferService Command로 옮기고 완료 응답을 만든다.

다시 쓸 저장 경로: src/main/java/com/example/financialcore/transfer/api/TransferController.java

분류: 최종 운영 소스

원문 SHA-256: 9f56f1d1583f8407f898ba50db32f6745b1b3c216ddd7d083198d8f7dbacd41d

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 Spring MVC가 인증된 POST /api/transfers 요청을 연결한다.
무엇을 받나 검증된 TransferRequest와 Principal을 받는다.
무엇이 바뀌나 Controller는 직접 변경하지 않지만 transfers.transfer 호출 아래에서 두 잔액·거래·원장이 바뀔 수 있다.
무엇을 돌려주나 거래 표식·서버 거래 ID·COMPLETED·두 잔액을 담은 TransferResponse와 HTTP 201을 돌려준다.

정확한 전체 원문

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

import com.example.financialcore.transfer.TransferService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;

@RestController
@RequestMapping("/api/transfers")
public class TransferController {
    private final TransferService transfers;

    public TransferController(TransferService transfers) { this.transfers = transfers; }

    @PostMapping
    ResponseEntity<TransferResponse> transfer(
        @Valid @RequestBody TransferRequest request, Principal principal
    ) {
        var result = transfers.transfer(new TransferService.Command(
            principal.getName(), request.transactionId(),
            request.fromAccountId(), request.toAccountId(), request.amount()));
        var body = new TransferResponse(
            request.transactionId(), result.businessTransactionId(), "COMPLETED",
            result.fromBalance(), result.toBalance());
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 POST /api/transfers transactionId=R8-001, from=1, to=2, amount=1000과 인증 customer-1이 들어온다.
2 @Valid 공백 표식·0 이하 ID/금액 같은 요청 모양 위반을 DTO 제약으로 먼저 거른다.
3 new Command customer-1, R8-001, 1, 2, 1000을 Service용 다섯 칸에 복사한다.
4 transfers.transfer 서비스가 원자적 이체를 마치면 businessTransactionId와 9000·6000을 돌려준다.
5 new TransferResponse 클라이언트 transactionId와 서버 transaction ID를 구분해 넣고 상태 COMPLETED를 붙인다.
6 HttpStatus.CREATED body와 함께 201을 응답한다.

코드 블록 1 · HTTP 주소와 Service 의존성

해설 바로 옆 코드 · java
@RestController
@RequestMapping("/api/transfers")
public class TransferController {
    private final TransferService transfers;

    public TransferController(TransferService transfers) { this.transfers = transfers; }
문법을 한 줄씩 풀면
class-level mapping이 이체 API 공통 주소를 정하고 생성자 주입이 실제 업무 객체를 final 필드에 보관한다.
실제 값 추적
Spring이 TransferService bean T를 넘기면 매개변수 transfers에서 this.transfers로 참조가 저장된다.
정상 예
하나의 Controller bean이 이후 정상 요청마다 같은 Service bean 참조를 사용한다.
반례·경계 예
생성자에서 new TransferService(...)를 직접 만들면 Spring이 붙이는 transaction proxy와 의존성 구성을 우회할 수 있다.
착각 방지
final은 이체 결과가 불변이라는 뜻이 아니라 필드 참조를 바꾸지 않는다는 뜻이다.
이 블록이 하지 않는 일
인증·검증·transaction을 이 블록에서 실행하지 않는다.
다음 코드와의 연결
다음 method가 실제 요청을 Command로 변환한다.

코드 블록 2 · 요청 body와 Principal의 서로 다른 출처

해설 바로 옆 코드 · java
ResponseEntity<TransferResponse> transfer(
    @Valid @RequestBody TransferRequest request, Principal principal
) {
문법을 한 줄씩 풀면
request는 HTTP JSON에서, principal은 인증 context에서 온다. @Valid는 TransferRequest의 field 제약을 Service 호출 전에 검사한다.
실제 값 추적
R8-001·1·2·1000은 request에, customer-1은 principal에 있다.
정상 예
두 출처를 구분해 인증된 actor와 클라이언트 이체 입력을 조합한다.
반례·경계 예
principal이 없는데 method를 직접 정상 호출하거나, body 안 임의 사용자 문자열을 actor로 쓰면 보안 경계가 흐려진다.
착각 방지
Principal은 계좌 소유권을 이미 보장한 결과가 아니다. 누구인지 알려 주고 Service가 from 소유권을 확인한다.
이 블록이 하지 않는 일
DB 행을 읽거나 잔액을 검사하지 않는다.
다음 코드와의 연결
다음 Command 생성 줄에서 두 출처의 값이 하나의 업무 요청으로 합쳐진다.

코드 블록 3 · HTTP DTO에서 Service Command로 값 복사

해설 바로 옆 코드 · java
var result = transfers.transfer(new TransferService.Command(
    principal.getName(), request.transactionId(),
    request.fromAccountId(), request.toAccountId(), request.amount()));
문법을 한 줄씩 풀면
중첩 생성식은 Command를 만든 직후 Service에 전달한다. record accessor에는 괄호가 필요하고 인자 순서가 Command 선언과 맞아야 한다.
실제 값 추적
customer-1, R8-001, 1, 2, 1000이 actorId, transactionId, from, to, amount 다섯 칸으로 이동한다.
정상 예
각 값의 출처와 순서를 정확히 유지하면 Service가 올바른 출금·입금 역할을 복원한다.
반례·경계 예
from과 to 순서를 뒤집으면 반대 방향 이체가 된다. 둘 다 long이라 컴파일러가 의미 실수를 잡지 못한다.
착각 방지
var result는 아무 type이나 되는 동적 변수 아니다. transfers.transfer 반환 type인 TransferService.Result로 컴파일 시 고정된다.
이 블록이 하지 않는 일
Command 자체가 이체를 실행하지 않는다. Service method 호출이 실행 경계다.
다음 코드와의 연결
다음 응답 조립에서 request 값과 result 값을 구분해 쓴다.

코드 블록 4 · 클라이언트 표식과 서버 결과를 응답 한 장에

해설 바로 옆 코드 · java
var body = new TransferResponse(
    request.transactionId(), result.businessTransactionId(), "COMPLETED",
    result.fromBalance(), result.toBalance());
문법을 한 줄씩 풀면
TransferResponse 생성자 순서에 맞춰 요청 표식, 서버 거래 ID, 공개 상태, 두 최종 잔액을 넣는다.
실제 값 추적
R8-001과 서버 UUID, COMPLETED, 9000, 6000이 서로 다른 다섯 칸에 들어간다.
정상 예
클라이언트가 자신의 요청과 응답을 연결하면서 서버 저장 ID와 최종 잔액도 확인할 수 있다.
반례·경계 예
businessTransactionId 자리에 request.transactionId를 복사하면 서로 다른 두 식별자를 같은 값처럼 노출한다.
착각 방지
COMPLETED 문자열은 Service Result에서 계산한 status가 아니라 현재 Controller가 성공 경로에서 정한 공개 값이다.
이 블록이 하지 않는 일
실패 응답이나 오류 코드를 만들지 않는다. 이 줄은 Service가 정상 반환한 뒤에만 실행된다.
다음 코드와의 연결
다음 return이 이 body에 HTTP 201을 붙인다.

코드 블록 5 · 신규 이체의 201 응답

해설 바로 옆 코드 · java
return ResponseEntity.status(HttpStatus.CREATED).body(body);
문법을 한 줄씩 풀면
status builder에 HttpStatus.CREATED를 넣고 앞에서 만든 TransferResponse를 body로 붙인다.
실제 값 추적
Service 성공 뒤 응답 status=201, body=R8-001/서버ID/COMPLETED/9000/6000이 된다.
정상 예
이번 기준선은 첫 신규 이체가 생성되었다는 계약을 201로 표현한다.
반례·경계 예
입력 오류나 미인증에 이 줄을 억지로 실행하면 400/401 계약을 깨뜨린다. 예외가 나면 정상 return에 도달하지 않는다.
착각 방지
CREATED는 Java 객체가 new 되었다는 뜻이 아니라 HTTP 의미다.
이 블록이 하지 않는 일
응답 serialization 세부와 error handler는 담당하지 않는다.
다음 코드와의 연결
통합 selector가 201·400·401 세 경계를 밖에서 확인한다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상: customer-1이 자신의 from 계좌에서 양수 1000을 다른 계좌로 보내고 새 이체가 생성되면 201·COMPLETED다.
  • 정상 책임 분리: Controller는 잔액을 계산하지 않고 Service Result를 응답 DTO로 옮긴다.

반례·경계 예

  • 반례: actorId를 request body에서 받으면 인증된 사용자와 다른 사람을 주장할 수 있다.
  • 반례: service 예외를 잡아 COMPLETED body를 만들어 버리면 DB 실패를 성공으로 거짓말한다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: transactionId는 손님 요청 표식이고 businessTransactionId는 서버가 저장한 거래 ID다. 이름이 비슷해도 같은 출처가 아니다.
  • 착각 방지: 201은 새 이체 성공 계약이며, 입력 검증 실패·미인증에도 항상 반환되는 값이 아니다.
  • 착각 방지: @Valid가 같은 계좌·잔액 부족·소유권까지 모두 판단하지 않는다. 관계·DB 규칙은 Service 책임이다.
  • 착각 방지: Principal을 import했다고 인증이 실행되는 것이 아니다. Security가 만든 값을 method가 받는다.
  • 하지 않는 일: 두 계좌를 잠그거나 잔액을 직접 가감하는 일
  • 하지 않는 일: business_tx와 ledger_entry를 직접 저장하는 일
  • 하지 않는 일: 인증 자격증명을 확인해 Principal을 만드는 일
  • 하지 않는 일: 모든 오류를 HTTP 오류 body로 변환하는 일

다음 연결

Controller가 넘긴 다섯 값은 TransferService의 validate→잠금→가감→거래·원장 저장 순서로 이어진다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/transfer/api/TransferController.java
전제조건
TransferRequest, TransferResponse, TransferService와 Spring MVC/Security/Validation이 준비되어 있어야 한다.
반드시 지킬 계약
/api/transfers POST, Principal actor, 요청 다섯 값의 Command 이동, COMPLETED 응답, 201을 유지한다.
추천 입력 순서
package/import → annotation → final service/생성자 → method signature → Command → Result → response body → 201 순서로 쓴다.
자기 점검
정상 201과 transactionId·COMPLETED, amount 0의 400과 transfer row 0, 인증 없는 401을 확인한다.
이번 파일의 범위 밖
Controller에 잠금·잔액 계산·repository save를 넣지 않고, 요청 body의 actor 주장을 신뢰하지 않는다.

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

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

import com.example.financialcore.transfer.TransferService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;

@RestController
@RequestMapping("/api/transfers")
public class TransferController {
    private final TransferService transfers;

    public TransferController(TransferService transfers) { this.transfers = transfers; }

    @PostMapping
    ResponseEntity<TransferResponse> transfer(
        @Valid @RequestBody TransferRequest request, Principal principal
    ) {
        var result = transfers.transfer(new TransferService.Command(
            principal.getName(), request.transactionId(),
            request.fromAccountId(), request.toAccountId(), request.amount()));
        var body = new TransferResponse(
            request.transactionId(), result.businessTransactionId(), "COMPLETED",
            result.fromBalance(), result.toBalance());
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}

W8 selector가 실행하지만 W8 월~토 본문에 원문을 복제하지 않는 이전 시험

아래 method는 이번 주 exact selector가 실제 실행합니다. 다만 만들어진 파일의 전체 원문은 요청 범위 밖이므로 다시 싣지 않고, 실제 fixture·HTTP/SQL assertion·실행 순서·보장 경계만 자세히 풉니다. 한 method 안의 AssertJ assertion은 위에서 아래로 실행되며, 앞 assertion이 실패해 AssertionError가 발생하면 그 아래 assertion은 같은 실행에서 수행되지 않습니다. 강화 예시는 canonical 원문이 아니라 추가 학습 제안입니다.

참조 시험 1 · firstTransferIs201

준비(Arrange) · 실제 fixture
@BeforeEach가 DB를 비우고 from=customer-1/10000, to=customer-2/5000을 만든다. HTTP Basic customer-1/password, canonical 역사적 요청 표식 W7-FIRST, amount=1000을 사용한다.
행동(Act) · 실제 HTTP
POST /api/transfers를 수행한다.
확인(Assert) · 실제 실행 순서
  1. response status가 201인지 먼저 확인한다.
  2. 그 뒤 response 문자열이 요청 표식 W7-FIRST와 COMPLETED를 모두 포함하는지 확인한다.
직접 보장
인증된 첫 신규 이체의 HTTP status가 201이고 응답이 해당 요청 표식·COMPLETED를 포함한다는 사실을 보장한다.
직접 보장하지 않음
from/to 최종 잔액, business_tx 1행, ledger 2행·signed 합0, response의 서버 거래 ID를 직접 확인하지 않는다.
원문 아닌 강화 예시
추가 예시는 JSON field별 transactionId/status/fromBalance/toBalance를 확인하고 SQL로 잔액 9000/6000·TRANSFER 거래1·원장2·signed 합0을 확인한다.

참조 시험 2 · zeroAmountIs400WithoutTransferWrite

준비(Arrange) · 실제 fixture
같은 from 10000·to 5000 fixture에서 HTTP Basic customer-1/password, canonical 역사적 요청 표식 W7-ZERO, amount=0을 사용한다.
행동(Act) · 실제 HTTP
POST /api/transfers를 수행한다.
확인(Assert) · 실제 실행 순서
  1. response status가 400인지 먼저 확인한다.
  2. 그 뒤 SQL SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'가 0인지 확인한다.
직접 보장
0원 이체가 400이고 TRANSFER 업무 거래 행을 남기지 않는다는 두 사실을 보장한다.
직접 보장하지 않음
두 계좌 잔액, TRANSFER_ ledger_entry, 응답 errorCode, 다른 table 무변경을 직접 확인하지 않는다.
원문 아닌 강화 예시
추가 예시는 from/to가 10000/5000인지, TRANSFER_ 원장 count가 0인지, errorCode=INVALID_REQUEST인지 확인한다. 음수 금액도 별도 경계로 둔다.

참조 시험 3 · missingAuthenticationIs401

준비(Arrange) · 실제 fixture
from 10000·to 5000 fixture는 만들지만 HTTP Basic을 붙이지 않는다. canonical 역사적 요청 표식 W7-ANON, amount=100을 body에 넣는다.
행동(Act) · 실제 HTTP
미인증 상태로 POST /api/transfers를 수행한다.
확인(Assert) · 실제 실행 순서
  1. response status가 401인지 단 하나의 assertion으로 확인한다.
직접 보장
인증 자격 없이 이체 endpoint를 호출하면 401이라는 사실만 직접 보장한다.
직접 보장하지 않음
오류 body, Service 미호출, 두 잔액 불변, 거래·원장 0행을 직접 확인하지 않는다.
원문 아닌 강화 예시
추가 예시는 error JSON을 확인하고 모든 이체 row count와 두 잔액을 재조회한다. Service mock을 쓴 별도 slice라면 호출 0회도 확인할 수 있다.

4. LedgerQueryIT · 실제 PostgreSQL에서 책갈피와 0→1 대사를 검증하는 통합 테스트

한 문장 역할: W8 조회 기준선을 실제 DB fixture로 만들고 keyset 무중복과 불일치 탐지 능력을 두 @Test로 확인한다.

다시 쓸 저장 경로: src/test/java/com/example/financialcore/ledger/LedgerQueryIT.java

분류: 지원/JUnit 통합 테스트

원문 SHA-256: aa79b92aa308ad3ffcd9d3e51ec66c13cdf8d571bb093f8665b9151f75cd281a

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5와 SpringBootTest가 각 @Test를 실행한다.
무엇을 받나 method 인자는 없고 setUp이 실제 DB에 계좌 1행과 OPENING·OUT·IN 원장 흐름을 준비한다.
무엇이 바뀌나 매 테스트 전 관련 table을 비우고 fixture를 INSERT하며, 두 번째 테스트는 account.balance만 +1로 오류를 주입한다.
무엇을 돌려주나 method는 void다. AssertJ assertion의 성공·실패가 테스트 결과다.

정확한 전체 원문

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

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

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;

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

@SpringBootTest
class LedgerQueryIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired LedgerQueryService queries;

    Account account;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        account = openings.open("customer-1", "LQ-100", 10_000);
        append("LQ-OUT", "TRANSFER_OUT", 500, -500, 9_500, Instant.now().plusSeconds(1));
        append("LQ-IN", "TRANSFER_IN", 500, 500, 10_000, Instant.now().plusSeconds(2));
    }

    @Test
    void keysetPagesAreStableAndDoNotRepeatTheCursorRow() {
        var first = queries.firstPage(account.getId(), 2);
        assertThat(first).hasSize(2);
        var cursor = first.get(1);
        var next = queries.nextPage(account.getId(),
            new LedgerQueryService.Cursor(cursor.occurredAt(), cursor.id()), 2);
        assertThat(next).hasSize(1);
        assertThat(next).extracting(LedgerQueryService.LedgerRow::id)
            .doesNotContain(first.get(0).id(), first.get(1).id());
    }

    @Test
    void reconciliationReturnsZeroThenDetectsOneInjectedMismatch() {
        assertThat(queries.reconciliationMismatchCount()).isZero();
        jdbc.sql("UPDATE account SET balance=balance+1 WHERE id=:id")
            .param("id", account.getId()).update();
        assertThat(queries.reconciliationMismatchCount()).isEqualTo(1);
    }

    private void append(
        String correlationId, String type, long amount, long signedAmount,
        long balanceAfter, Instant createdAt
    ) {
        UUID businessId = UUID.randomUUID();
        jdbc.sql("""
                INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at)
                VALUES (:id,'TRANSFER','COMPLETED',:correlation,:at,:at)
                """)
            .param("id", businessId).param("correlation", correlationId)
            .param("at", createdAt.atOffset(ZoneOffset.UTC)).update();
        jdbc.sql("""
                INSERT INTO ledger_entry(
                  business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at
                ) VALUES (:tx,:account,:type,:amount,:signed,:balance,:at)
                """)
            .param("tx", businessId).param("account", account.getId()).param("type", type)
            .param("amount", amount).param("signed", signedAmount).param("balance", balanceAfter)
            .param("at", createdAt.atOffset(ZoneOffset.UTC)).update();
    }
}

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 TRUNCATE ... RESTART IDENTITY 관련 table을 비우고 ID sequence를 초기화한다.
2 open(...,10000) account와 OPENING 원장 +10000을 만든다.
3 append OUT 500, -500, balanceAfter 9500, 기준시각+1의 행을 넣는다.
4 append IN 500, +500, balanceAfter 10000, 기준시각+2의 행을 넣는다.
5 firstPage(...,2) 최신 IN과 OUT 두 행을 받는다.
6 cursor=first[1] 첫 묶음 마지막 OUT의 시간+ID가 다음 경계가 된다.
7 nextPage(...,2) 남은 OPENING 한 행만 받아 size 1과 앞 ID 무중복을 확인한다.
8 mismatch 0 현재 잔액 10000과 signed 합 10000이 맞는다.
9 balance=balance+1 현재 잔액만 10001로 만들어 원장 합과 1 차이를 만든다.
10 mismatch 1 query가 오류 계좌 한 건을 찾는지 확인한다.

코드 블록 1 · 실제 Spring·PostgreSQL 통합 경계

해설 바로 옆 코드 · java
@SpringBootTest
class LedgerQueryIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired LedgerQueryService queries;
문법을 한 줄씩 풀면
@SpringBootTest는 전체 application context를 올리고, 지원 class는 PostgreSQL 테스트 환경을 제공한다. @Autowired 세 필드는 fixture SQL·계좌 개설·조회 대상 역할로 나뉜다.
실제 값 추적
JUnit이 class를 만들면 Spring이 jdbc, openings, queries bean을 주입한다.
정상 예
실제 migration과 실제 JdbcClient query를 함께 검증하는 통합 테스트다.
반례·경계 예
단순 new LedgerQueryService(mock)만 쓰는 unit test로 바꾸면 SQL dialect·schema·mapping 오류를 놓칠 수 있다.
착각 방지
IT라는 이름만으로 실제 DB라는 사실이 생기는 게 아니다. 상속 지원과 Spring context 구성이 실제 경계를 만든다.
이 블록이 하지 않는 일
운영 DB를 사용하거나 외부 HTTP 서버를 호출하지 않는다.
다음 코드와의 연결
setUp에서 매 테스트가 공유하지 않는 깨끗한 fixture를 만든다.

코드 블록 2 · 매 테스트 전 계좌·원장 세 행 준비

해설 바로 옆 코드 · java
@BeforeEach
void setUp() {
    jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    account = openings.open("customer-1", "LQ-100", 10_000);
    append("LQ-OUT", "TRANSFER_OUT", 500, -500, 9_500, Instant.now().plusSeconds(1));
    append("LQ-IN", "TRANSFER_IN", 500, 500, 10_000, Instant.now().plusSeconds(2));
}
문법을 한 줄씩 풀면
@BeforeEach는 두 @Test 각각 직전에 실행된다. TRUNCATE는 행과 ID 상태를 초기화하고 openings.open은 opening 원장까지 만든다. append 두 번은 의도한 시각·부호를 직접 넣는다.
실제 값 추적
초기 balance 10000, OUT 뒤 9500, IN 뒤 10000이라는 세 단계가 DB에 표현된다.
정상 예
두 테스트가 어느 순서로 실행돼도 같은 출발 상태를 가진다.
반례·경계 예
초기화를 빼면 앞 테스트의 +1 update가 뒤 테스트에 남아 순서 의존 실패가 생길 수 있다.
착각 방지
account 1행만 있다고 원장도 1행이라 생각하면 안 된다. AccountOpeningService가 OPENING entry를 만든다.
이 블록이 하지 않는 일
실제 TransferService의 소유권·transaction 흐름을 테스트하지 않는다. 조회에 맞춘 fixture다.
다음 코드와의 연결
첫 @Test가 이 세 행을 최신순 두 묶음으로 나눠 본다.

코드 블록 3 · keyset 첫 2행과 다음 1행

해설 바로 옆 코드 · java
var first = queries.firstPage(account.getId(), 2);
assertThat(first).hasSize(2);
var cursor = first.get(1);
var next = queries.nextPage(account.getId(),
    new LedgerQueryService.Cursor(cursor.occurredAt(), cursor.id()), 2);
assertThat(next).hasSize(1);
assertThat(next).extracting(LedgerQueryService.LedgerRow::id)
    .doesNotContain(first.get(0).id(), first.get(1).id());
문법을 한 줄씩 풀면
Arrange는 setUp, Act는 firstPage와 nextPage, Assert는 size 2·1과 ID 무중복이다. first.get(1)은 0부터 세는 두 번째, 즉 첫 묶음 마지막 행이다.
실제 값 추적
최신 IN과 OUT이 first, OPENING이 next다. next ID는 first[0], first[1] 어느 것과도 같지 않다.
정상 예
cursor row를 다시 포함하지 않는 strict 경계를 실제 결과로 확인한다.
반례·경계 예
next size만 1이면 cursor 중복 한 행이 우연히 들어오고 OPENING이 빠진 구현도 가능하므로 ID 무중복 assertion이 함께 필요하다.
착각 방지
이 assertion은 next가 정확히 OPENING type인지, first 내부의 정확한 ID 순서인지 직접 확인하지 않는다.
이 블록이 하지 않는 일
세 번째·네 번째 페이지, 동시 insert, 잘못된 limit을 검증하지 않는다.
다음 코드와의 연결
두 번째 @Test는 같은 fixture에서 페이지 대신 합계 대사를 본다.

코드 블록 4 · 정상 0과 오류 주입 1

해설 바로 옆 코드 · java
assertThat(queries.reconciliationMismatchCount()).isZero();
jdbc.sql("UPDATE account SET balance=balance+1 WHERE id=:id")
    .param("id", account.getId()).update();
assertThat(queries.reconciliationMismatchCount()).isEqualTo(1);
문법을 한 줄씩 풀면
첫 query로 정상 기준선을 확인하고, 같은 account의 현재 잔액만 +1 한 뒤 다시 query해 탐지 수를 확인한다.
실제 값 추적
signed 합 10000/잔액10000에서 0, signed 합10000/잔액10001에서 1로 바뀐다.
정상 예
정상과 오류 두 방향을 한 fixture에서 검증해 ‘늘 0’ query를 막는다.
반례·경계 예
원장까지 같이 +1 하면 다시 같아져 mismatch가 생기지 않는다. 오류 주입은 한쪽만 바꿔야 한다.
착각 방지
isEqualTo(1)은 금액 차이가 1원이라는 assertion이 아니라 불일치 계좌 수가 1개라는 뜻이다. 차액과 count를 구분한다.
이 블록이 하지 않는 일
어느 ledger entry가 원인인지, 자동 수리 후 0이 되는지는 확인하지 않는다.
다음 코드와의 연결
이 대사 기준선은 W8 v0 보장/미보장 표의 핵심 근거가 된다.

코드 블록 5 · append가 거래 1행과 원장 1행을 맞춰 넣는 순서

해설 바로 옆 코드 · java
UUID businessId = UUID.randomUUID();
jdbc.sql("""
        INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at)
        VALUES (:id,'TRANSFER','COMPLETED',:correlation,:at,:at)
        """)
    .param("id", businessId).param("correlation", correlationId)
    .param("at", createdAt.atOffset(ZoneOffset.UTC)).update();
문법을 한 줄씩 풀면
각 append는 새 UUID를 만들고 부모 business_tx를 먼저 INSERT한다. Instant는 UTC offset을 붙여 timestamptz parameter가 된다.
실제 값 추적
OUT 호출과 IN 호출은 서로 다른 businessId와 correlationId, 시각을 가진 거래 두 행을 만든다.
정상 예
부모 거래가 먼저 있어 ledger_entry의 foreign key가 참조할 수 있다.
반례·경계 예
ledger를 먼저 넣거나 존재하지 않는 UUID를 참조하면 foreign key 위반이 난다.
착각 방지
두 append가 하나의 이체 쌍을 뜻하는 것은 아니다. 조회 순서를 만들기 위한 독립 fixture 거래다.
이 블록이 하지 않는 일
BusinessTransaction domain factory나 운영 repository를 검증하지 않는다.
다음 코드와의 연결
다음 INSERT는 같은 businessId와 accountId로 원장 한 행을 연결한다.

코드 블록 6 · 원장 열에 절댓값·부호·당시 잔액을 분리 저장

해설 바로 옆 코드 · java
INSERT INTO ledger_entry(
  business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at
) VALUES (:tx,:account,:type,:amount,:signed,:balance,:at)
문법을 한 줄씩 풀면
한 행에 부모 거래, 계좌, 유형, 양수 amount, 방향을 포함한 signed, 처리 뒤 잔액, 발생 시각을 넣는다.
실제 값 추적
OUT은 amount=500/signed=-500/balance=9500, IN은 amount=500/signed=500/balance=10000이다.
정상 예
amount와 signed_amount를 분리해 표시 금액과 합계 방향을 동시에 보존한다.
반례·경계 예
OUT의 signed를 +500으로 넣으면 합계와 대사가 틀어진다. balanceAfter를 10000으로 유지하면 스냅샷 의미가 틀린다.
착각 방지
balanceAfter는 현재 account.balance를 자동 변경하지 않는다. 이 helper는 ledger 행만 직접 넣는다.
이 블록이 하지 않는 일
원장 pair 균형, transaction atomicity, 소유권을 검증하지 않는다.
다음 코드와의 연결
이 정확한 fixture 덕분에 Service query가 읽은 값의 출처를 한 줄씩 역추적할 수 있다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상 keyset: 첫 페이지 2행, 다음 페이지 1행이며 다음 ID 목록에 앞 두 ID가 없다.
  • 정상 대사: 정상 fixture 0과 오류 fixture 1을 한 테스트 안에서 차례로 확인한다.

반례·경계 예

  • 반례: 첫 페이지 size만 보면 cursor 행 중복을 놓친다.
  • 반례: 정상 0만 보면 query가 항상 0을 돌려주는 버그도 통과할 수 있다.
  • 경계: 시간 값을 Instant.now()로 서로 따로 만들지만 +1,+2라 최신 순서는 명확하다. 모든 동률 조건은 이 fixture가 만들지 않는다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: @BeforeEach는 class 전체에서 한 번이 아니라 각 @Test 전에 실행된다.
  • 착각 방지: opening 한 건도 ledger_entry를 만들기 때문에 총 원장 행은 세 건이다.
  • 착각 방지: private append는 운영 이체 Service가 아니다. 테스트가 원하는 정밀 시각과 signed 값을 직접 준비하는 fixture helper다.
  • 착각 방지: 테스트 이름이 보장 범위를 정하지 않는다. 실제 assertion만 직접 보장한다.
  • 하지 않는 일: 대규모 성능과 실행 계획을 측정하는 일
  • 하지 않는 일: 동시 삽입·삭제 중 모든 격리수준을 검증하는 일
  • 하지 않는 일: 불일치 원인을 자동 수리하는 일
  • 하지 않는 일: HTTP limit·cursor 오류 응답을 검증하는 일

다음 연결

조회 재검증 뒤에는 이체 Service가 업무 변경 중 예외를 만났을 때 동일 DB 전체가 원복되는지 확인한다.

그림으로 다시 보기: signed 합 10,000과 account.balance 10,000일 때 불일치 0건, 잔액만 10,001로 바꾸면 불일치 계좌 1건이다.
그림으로 다시 보기: signed 합 10,000과 account.balance 10,000일 때 불일치 0건, 잔액만 10,001로 바꾸면 불일치 계좌 1건이다.

직접 다시 써보기

저장 경로
src/test/java/com/example/financialcore/ledger/LedgerQueryIT.java
전제조건
Testcontainers 기반 PostgresIntegrationTestSupport, SpringBootTest context, AccountOpeningService, LedgerQueryService, schema migration이 준비되어야 한다.
반드시 지킬 계약
각 테스트 전 초기화, 10000 opening, -500/+500 두 append, 첫2/다음1 무중복, mismatch 0→+1→1을 유지한다.
추천 입력 순서
annotation/주입 → setUp → keyset test → reconciliation test → append helper 순서로 입력한다.
자기 점검
@Test 정확히 2개인지, 첫/다음 size가 2/1인지, doesNotContain이 두 앞 ID를 모두 받는지, update 뒤 count가 정확히 1인지 확인한다.
이번 파일의 범위 밖
실행 계획·동시성·자동 복구·HTTP 응답까지 이 두 테스트가 보장한다고 쓰지 않는다.

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

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

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

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;

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

@SpringBootTest
class LedgerQueryIT extends PostgresIntegrationTestSupport {
    @Autowired JdbcClient jdbc;
    @Autowired AccountOpeningService openings;
    @Autowired LedgerQueryService queries;

    Account account;

    @BeforeEach
    void setUp() {
        jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
        account = openings.open("customer-1", "LQ-100", 10_000);
        append("LQ-OUT", "TRANSFER_OUT", 500, -500, 9_500, Instant.now().plusSeconds(1));
        append("LQ-IN", "TRANSFER_IN", 500, 500, 10_000, Instant.now().plusSeconds(2));
    }

    @Test
    void keysetPagesAreStableAndDoNotRepeatTheCursorRow() {
        var first = queries.firstPage(account.getId(), 2);
        assertThat(first).hasSize(2);
        var cursor = first.get(1);
        var next = queries.nextPage(account.getId(),
            new LedgerQueryService.Cursor(cursor.occurredAt(), cursor.id()), 2);
        assertThat(next).hasSize(1);
        assertThat(next).extracting(LedgerQueryService.LedgerRow::id)
            .doesNotContain(first.get(0).id(), first.get(1).id());
    }

    @Test
    void reconciliationReturnsZeroThenDetectsOneInjectedMismatch() {
        assertThat(queries.reconciliationMismatchCount()).isZero();
        jdbc.sql("UPDATE account SET balance=balance+1 WHERE id=:id")
            .param("id", account.getId()).update();
        assertThat(queries.reconciliationMismatchCount()).isEqualTo(1);
    }

    private void append(
        String correlationId, String type, long amount, long signedAmount,
        long balanceAfter, Instant createdAt
    ) {
        UUID businessId = UUID.randomUUID();
        jdbc.sql("""
                INSERT INTO business_tx(id,tx_type,status,correlation_id,requested_at,completed_at)
                VALUES (:id,'TRANSFER','COMPLETED',:correlation,:at,:at)
                """)
            .param("id", businessId).param("correlation", correlationId)
            .param("at", createdAt.atOffset(ZoneOffset.UTC)).update();
        jdbc.sql("""
                INSERT INTO ledger_entry(
                  business_tx_id,account_id,entry_type,amount,signed_amount,balance_after,created_at
                ) VALUES (:tx,:account,:type,:amount,:signed,:balance,:at)
                """)
            .param("tx", businessId).param("account", account.getId()).param("type", type)
            .param("amount", amount).param("signed", signedAmount).param("balance", balanceAfter)
            .param("at", createdAt.atOffset(ZoneOffset.UTC)).update();
    }
}

5. TransferService · 두 계좌를 같은 transaction에서 잠그고 잔액·거래·원장을 함께 바꾸는 중심

한 문장 역할: W8 rollback 리허설의 운영 대상이다. 입력을 검사하고 계좌를 고정 순서로 잠근 뒤 출금·입금·거래 1행·원장 2행을 하나로 처리한다.

다시 쓸 저장 경로: src/main/java/com/example/financialcore/transfer/TransferService.java

분류: 최종 운영 소스

원문 SHA-256: e44994db58afe3ba53008de7dda7116881de1caa81553505af13571ad3273ec6

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 TransferController나 내부 업무 코드가 public transfer(Command)를 Spring bean을 통해 호출한다.
무엇을 받나 actorId·transactionId·fromAccountId·toAccountId·양수 amount를 가진 Command를 받는다.
무엇이 바뀌나 from 잔액 감소, to 잔액 증가, business_tx 1행, 반대 부호 ledger_entry 2행이 같은 transaction에서 바뀐다.
무엇을 돌려주나 서버 거래 ID와 두 최종 잔액을 Result로 돌려준다.

정확한 전체 원문

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

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

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

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

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

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

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

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

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 Command customer-1, R8-001, from=2, to=1, amount=1000이 들어와도 역할은 그대로 보존된다.
2 validate 공백·0 이하·같은 계좌·0 이하 금액을 DB 조회 전 거른다.
3 sorted IDs [2,1]을 [1,2]로 정렬해 잠금 획득 순서를 고정한다.
4 HashMap byId 잠긴 결과를 ID로 다시 찾아 from=2, to=1 역할을 복원한다.
5 owner check from.ownerId와 customer-1이 같은지 확인한다.
6 withdraw/deposit from 10000→9000, to 5000→6000이 된다.
7 business_tx correlationId=R8-001인 완료 이체 한 행을 저장한다.
8 ledger pair from에 -1000, to에 +1000 두 행을 같은 시각·거래에 연결한다.
9 failure hook 정상이면 지나가고, 예외가 나면 public transaction 밖으로 전파된다.
10 Result 거래 ID, 9000, 6000을 돌려준다.

코드 블록 1 · Command 다섯 칸과 Result 세 칸

해설 바로 옆 코드 · java
public record Command(String actorId, String transactionId, long fromAccountId, long toAccountId, long amount) {}
public record Result(String businessTransactionId, long fromBalance, long toBalance) {}
문법을 한 줄씩 풀면
Command는 호출 입력, Result는 성공 출력의 불변 값 묶음이다. 각 component는 같은 이름의 accessor를 만든다.
실제 값 추적
customer-1/R8-001/2/1/1000이 Command에, 서버UUID/9000/6000이 Result에 들어간다.
정상 예
입력과 출력 type을 분리해 클라이언트 표식과 서버 저장 ID, 시작 ID와 최종 잔액을 섞지 않는다.
반례·경계 예
from/to 순서를 바꾸거나 Result에 요청 transactionId를 넣어도 type은 맞을 수 있어 의미 검사가 필요하다.
착각 방지
record는 component를 final처럼 보관하지만 account DB 상태를 불변으로 만들지는 않는다.
이 블록이 하지 않는 일
검증·잠금·저장을 스스로 실행하지 않는다.
다음 코드와의 연결
생성자 의존성과 transfer method가 이 두 계약을 실제 업무에 연결한다.

코드 블록 2 · 네 저장소와 선택 가능한 실패 hook

해설 바로 옆 코드 · java
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);
}
문법을 한 줄씩 풀면
생성자는 계좌·업무 거래·원장 repository와 선택적 hook provider를 받는다. hook bean이 없으면 NONE을 사용한다.
실제 값 추적
운영 기본 구성에서는 failureHook=NONE, 테스트 구성에서는 예외를 던지는 hook bean이 선택될 수 있다.
정상 예
같은 운영 코드를 바꾸지 않고 테스트에서 실패 지점을 관찰할 수 있다.
반례·경계 예
hooks.getObject()로 필수 bean처럼 요구하면 기본 구성에 hook이 없을 때 시작이 실패한다. 반대로 항상 NONE을 직접 대입하면 테스트 주입이 불가능하다.
착각 방지
ObjectProvider는 여러 bean 우선순위를 자동으로 해결하는 만능 규칙이 아니다. 현재 계약은 사용 가능한 하나 또는 fallback이다.
이 블록이 하지 않는 일
repository 구현을 만들거나 transaction을 시작하지 않는다.
다음 코드와의 연결
public transfer가 이 의존성을 하나의 transaction에서 사용한다.

코드 블록 3 · public transaction과 입력 선검사

해설 바로 옆 코드 · java
@Transactional
public Result transfer(Command command) {
    validate(command);
문법을 한 줄씩 풀면
Spring transaction annotation은 public bean 호출을 하나의 DB transaction 경계로 감싼다. validate는 SQL·잠금 전에 값 자체의 잘못을 거른다.
실제 값 추적
정상 Command면 다음 잠금으로 진행하고, amount=0이나 same account면 즉시 예외로 끝난다.
정상 예
값 오류가 DB 작업 전에 실패해 불필요한 잠금과 write를 피한다.
반례·경계 예
이 method를 같은 객체 내부에서 직접 self-invocation하면 proxy 경계를 우회할 수 있다. 예외를 내부에서 삼키는 것도 rollback 판단을 흐린다.
착각 방지
annotation을 붙였다고 외부 API·파일·메시지까지 DB와 한 transaction이 되는 것은 아니다.
이 블록이 하지 않는 일
HTTP 400 status를 직접 만들지 않는다.
다음 코드와의 연결
검사를 통과한 두 ID를 다음 블록에서 고정 순서로 잠근다.

코드 블록 4 · ID 정렬 잠금과 역할 복원

해설 바로 옆 코드 · java
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());
문법을 한 줄씩 풀면
두 ID를 오름차순으로 정렬해 repository의 FOR UPDATE 잠금 순서를 통일한다. 결과는 ID map으로 바꾼 뒤 원래 Command ID로 from/to를 다시 찾는다.
실제 값 추적
요청 2→1도 잠금은 [1,2], 역할은 from=2/to=1이다. 두 행이 아니면 존재하지 않는 계좌로 실패한다.
정상 예
반대 방향 동시 요청도 같은 ID 순서로 잠금을 시도해 교착 가능성을 줄인다.
반례·경계 예
locked.get(0)을 from으로 쓰면 요청 방향이 역순일 때 역할이 뒤집힌다. size 검사를 빼면 null dereference나 부분 이체 위험이 생긴다.
착각 방지
정렬이 deadlock을 모든 상황에서 없앤다는 증명은 아니다. 이 Service가 잡는 두 account lock 순서를 통일하는 기법이다.
이 블록이 하지 않는 일
계좌 소유권·잔액을 아직 바꾸지 않는다.
다음 코드와의 연결
다음 줄에서 from 소유자를 actor와 비교한 뒤 실제 가감을 시작한다.

코드 블록 5 · 출금 계좌 소유권과 두 잔액 가감

해설 바로 옆 코드 · java
if (!from.getOwnerId().equals(command.actorId())) throw new BusinessException(ErrorCode.ACCESS_DENIED, "access denied");
from.withdraw(command.amount());
to.deposit(command.amount());
문법을 한 줄씩 풀면
출금 계좌의 ownerId를 인증 actorId와 비교한다. 통과 뒤 같은 양의 amount를 from에서는 빼고 to에는 더한다.
실제 값 추적
customer-1 소유 from 10000에서 1000을 빼 9000, to 5000에 1000을 더해 6000이 된다.
정상 예
출금 주체를 확인하고 같은 금액을 반대 방향으로 적용한다.
반례·경계 예
to 소유권까지 customer-1과 같아야 한다고 억지로 검사하면 다른 사람에게 보내는 정상 이체를 막을 수 있다. amount를 두 번 다 빼면 총액이 줄어든다.
착각 방지
from.withdraw 안의 잔액 부족 규칙은 이 파일 한 줄에 펼쳐져 있지 않다. domain method가 담당한다.
이 블록이 하지 않는 일
아직 DB transaction row와 ledger pair를 저장하지 않았다. 메모리 entity 변경만 생긴 단계다.
다음 코드와의 연결
다음 블록이 업무 거래와 반대 부호 원장 두 행을 연결한다.

코드 블록 6 · 업무 거래 한 행과 원장 반대 부호 두 행

해설 바로 옆 코드 · java
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));
문법을 한 줄씩 풀면
한 시각 now와 한 BusinessTransaction을 만들고 두 LedgerEntry가 같은 tx를 참조한다. factory 이름이 OUT/IN 부호와 balanceAfter를 정한다.
실제 값 추적
R8-001 완료 거래 1행, from -1000/balance9000, to +1000/balance6000 두 원장 행이 생기며 signed 합은 0이다.
정상 예
잔액 변화와 장부 근거를 같은 transaction에 남긴다.
반례·경계 예
OUT과 IN에 서로 다른 tx를 쓰거나 시각을 제각각 생성하면 한 이체 쌍 추적이 약해진다. 둘 다 +1000이면 균형이 깨진다.
착각 방지
repository save 호출 순서는 보이지만 실제 INSERT 시점은 JPA flush 전략에 따라 commit 전 달라질 수 있다. 그래도 transaction 원자성 계약은 유지된다.
이 블록이 하지 않는 일
수수료·환율·외부 송금 메시지를 만들지 않는다.
다음 코드와의 연결
다음 hook이 바로 이 업무 변경 뒤에 예외를 주입할 수 있다.

코드 블록 7 · 업무 변경 뒤 hook과 성공 Result

해설 바로 옆 코드 · java
failureHook.afterBusinessMutation();
return new Result(tx.getId().toString(), from.getBalance(), to.getBalance());
문법을 한 줄씩 풀면
hook은 저장 호출 뒤, 정상 반환 전 실행된다. 아무 일 없으면 서버 거래 ID와 현재 두 잔액을 Result로 만든다. RuntimeException이 나면 return에 도달하지 않는다.
실제 값 추적
정상은 Result(UUID,9000,6000), 실패 주입은 예외 전파와 transaction rollback이다.
정상 예
업무 변경이 이미 일어난 가장 위험한 지점에서 원자성을 시험할 수 있다.
반례·경계 예
예외를 catch해 로그만 남기고 Result를 반환하면 transaction이 성공 종료로 보일 수 있다.
착각 방지
hook이 실행됐다는 사실만으로 rollback이 증명되지 않는다. 실패 뒤 DB 값을 새 query로 확인해야 한다.
이 블록이 하지 않는 일
고객 오류 응답이나 재시도 정책을 정하지 않는다.
다음 코드와의 연결
TransferFailurePointIT가 예외·hook 실행·잔액·거래·원장 네 범주를 다시 읽는다.

코드 블록 8 · DB 전에 끝나는 다섯 입력 방어

해설 바로 옆 코드 · java
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");
문법을 한 줄씩 풀면
null 검사 뒤 isBlank를 || 단락 평가로 안전하게 호출한다. ID 양수, 서로 다름, amount 양수를 차례로 검사한다.
실제 값 추적
정상 customer-1/R8-001/1/2/1000은 통과한다. 공백 actor, 공백 표식, 0 ID, 1→1, amount 0은 각 예외다.
정상 예
관계 규칙인 same account를 DTO의 개별 @Positive와 별도로 검사한다.
반례·경계 예
actorId.isBlank()를 null 검사보다 먼저 쓰면 null에서 NullPointerException이 난다. from/to 양수만 보고 같음을 빼면 1→1이 통과한다.
착각 방지
IllegalArgumentException message는 어떤 입력이 틀렸는지 내부 경계를 표시하지만 HTTP error JSON 전체 계약은 아니다.
이 블록이 하지 않는 일
계좌 존재·from 소유권·잔액 충분성을 검사하지 않는다. DB/domain 단계에서 확인한다.
다음 코드와의 연결
모든 선검사를 통과해야 정렬 잠금으로 이어진다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상: from 10000, to 5000, amount 1000이면 9000·6000, 거래 1행, 원장 2행, signed 합 0이다.
  • 정상 잠금: 요청 방향이 2→1이어도 잠금은 1→2 순서, 업무 역할은 다시 2→1로 복원한다.

반례·경계 예

  • 반례: 잠금 목록 첫 번째를 무조건 from으로 쓰면 ID가 역순인 요청에서 출금·입금 역할이 뒤집힌다.
  • 반례: 예외를 catch하고 정상 Result를 반환하면 Spring이 정상 종료로 판단해 일부 변경이 commit될 수 있다.
  • 반례: 같은 계좌를 허용하면 withdraw 뒤 deposit이 같은 객체에 적용되어 의미 없는 거래·원장이 생길 수 있다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: 정렬은 from/to 역할을 정하는 것이 아니라 잠금 획득 순서만 정한다.
  • 착각 방지: @Transactional은 이 method를 Spring proxy를 통해 바깥에서 호출할 때 경계를 만든다. private helper 내부호출과 같은 뜻이 아니다.
  • 착각 방지: failureHook은 운영 실패 기능이 아니라 bean이 없으면 NONE인 테스트 주입 지점이다.
  • 착각 방지: 메모리 객체 잔액이 원복되어 보이는지만으로 DB rollback을 증명하지 않는다. transaction 종료 뒤 JDBC로 다시 읽어야 한다.
  • 하지 않는 일: 같은 요청 재전송의 멱등 replay·payload conflict 처리
  • 하지 않는 일: DB 프로세스 강제 종료·network timeout의 고객 응답 정책
  • 하지 않는 일: 모든 동시성 interleaving의 정확성을 증명하는 일
  • 하지 않는 일: HTTP 상태·JSON을 조립하는 일

다음 연결

TransferFailurePointIT가 업무 변경 직후 RuntimeException을 주입하고 transaction 밖에서 DB를 재조회해 모두 원복되었는지 확인한다.

그림으로 다시 보기: 요청이 2→1이어도 잠금은 ID 1→2 순서로 얻고, map에서 다시 from=2·to=1 역할을 찾아 10,000→9,000과 5,000→6,000을 적용한다.
그림으로 다시 보기: 요청이 2→1이어도 잠금은 ID 1→2 순서로 얻고, map에서 다시 from=2·to=1 역할을 찾아 10,000→9,000과 5,000→6,000을 적용한다.

직접 다시 써보기

저장 경로
src/main/java/com/example/financialcore/transfer/TransferService.java
전제조건
Account/거래/원장 domain과 repository, ErrorCode/BusinessException, TransferFailureHook bean contract, Spring transaction 설정이 준비되어야 한다.
반드시 지킬 계약
다섯 칸 Command, 세 칸 Result, 입력 검사, ID 정렬 잠금, 역할 복원, from 소유권, 잔액·거래·원장, hook, Result 순서를 유지한다.
추천 입력 순서
record → 의존성/생성자 → @Transactional transfer → validate 순서로 입력하고 transfer 내부는 검사→잠금→역할→소유권→가감→저장→hook→반환 순서다.
자기 점검
10000/5000→9000/6000, 거래1·원장2·합0, 같은 계좌 거절, 업무 변경 뒤 예외에서 전체 원복을 확인한다.
이번 파일의 범위 밖
멱등성·replay·timeout·고가용성까지 현재 Service가 보장한다고 덧붙이지 않는다.

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

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

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

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

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

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

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

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

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

6. TransferFailurePointIT · 업무 변경 직후 예외를 넣고 DB 전체 원복을 다시 읽는 시험

한 문장 역할: W8 rollback 기준선에서 Service의 가장 늦은 실패 지점을 강제로 실행하고 두 잔액·거래·원장이 처음 상태인지 검증한다.

다시 쓸 저장 경로: src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java

분류: 지원/JUnit 통합 테스트

원문 SHA-256: b1e66ae8ae82ca562af73dc364e14a98ec8c0da16fbb8f49acc9734bc8ae79c5

네 칸 계약 카드

질문 이 파일의 정확한 답
누가 부르나 JUnit 5가 한 @Test를 실행하고 SpringBootTest가 테스트용 failure hook bean을 포함한 context를 만든다.
무엇을 받나 setUp이 from 10000·to 5000 계좌를 만들고, 테스트가 amount 1000 Command를 Service에 보낸다.
무엇이 바뀌나 Service 내부에서 잠시 잔액·거래·원장이 바뀐 뒤 hook RuntimeException으로 transaction 전체가 rollback되어야 한다.
무엇을 돌려주나 정상 Result는 없다. 예외 type/message와 실패 뒤 JDBC 조회 assertion이 테스트 결과를 만든다.

정확한 전체 원문

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

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

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

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

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

    Account from;
    Account to;

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

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

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

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

한 줄씩 값 추적

순서 지나가는 코드 실제 값·상태
1 @Import 예외 hook과 AtomicBoolean을 테스트 context에 추가한다.
2 clean invoked=false, DB 초기화, from=10000, to=5000으로 준비한다.
3 transfer amount 1000 Service가 from=9000, to=6000, 거래1, 원장2 상태를 transaction 안에서 만든다.
4 afterBusinessMutation invoked=true로 바꾸고 RuntimeException을 던진다.
5 assertThatThrownBy 예외 type과 정확한 message를 확인한다.
6 JDBC 재조회 transaction 종료 뒤 from=10000, to=5000, transfer transaction=0, transfer entry=0을 읽는다.

코드 블록 1 · 테스트 전용 구성 import

해설 바로 옆 코드 · java
@SpringBootTest
@Import(TransferFailurePointIT.FailureConfiguration.class)
class TransferFailurePointIT extends PostgresIntegrationTestSupport {
문법을 한 줄씩 풀면
전체 Spring context에 nested FailureConfiguration만 추가한다. 운영 설정 파일을 바꾸지 않고 같은 Service bean에 테스트 hook을 주입한다.
실제 값 추적
context 생성 시 FailureConfiguration의 AtomicBoolean과 TransferFailureHook bean이 발견된다.
정상 예
실제 repository·transaction manager와 테스트 전용 실패점이 함께 작동한다.
반례·경계 예
Service를 mock으로 바꾸면 실제 DB rollback을 볼 수 없고, 운영 config에 예외 bean을 넣으면 application 자체가 실패할 수 있다.
착각 방지
@Import는 Java import 문과 다르다. 전자는 Spring 구성 등록, 후자는 type 이름 단축이다.
이 블록이 하지 않는 일
HTTP server나 외부 장애 장비를 시작하지 않는다.
다음 코드와의 연결
다음 필드들이 DB 준비·Service 호출·hook 관찰 역할을 나눠 가진다.

코드 블록 2 · AtomicBoolean과 깨끗한 두 계좌

해설 바로 옆 코드 · java
@BeforeEach void clean() {
    invoked.set(false);
    jdbc.sql("TRUNCATE idempotency_request,ledger_entry,business_tx,account RESTART IDENTITY CASCADE").update();
    from = openings.open("customer-1", "FAIL-FROM", 10_000);
    to = openings.open("customer-2", "FAIL-TO", 5_000);
}
문법을 한 줄씩 풀면
매 테스트 전 관찰 flag를 false로 되돌리고 DB를 비운 뒤 서로 다른 소유자의 계좌 두 개를 만든다.
실제 값 추적
시작 상태는 invoked=false, from 10000, to 5000이다.
정상 예
앞 실행의 예외·행·flag가 다음 실행에 영향을 주지 않는다.
반례·경계 예
invoked 초기화를 빼면 hook가 이번 실행에서 호출되지 않아도 과거 true가 남을 수 있다. DB 초기화를 빼면 count 0 assertion이 불안정하다.
착각 방지
to가 customer-2 소유여도 정상이다. Service는 from 소유권을 customer-1과 비교한다.
이 블록이 하지 않는 일
동시 사용자나 여러 계좌 fixture를 만들지 않는다.
다음 코드와의 연결
한 @Test가 이 출발점에서 amount 1000 이체를 시도한다.

코드 블록 3 · 예외 type·message와 hook 실행 확인

해설 바로 옆 코드 · java
assertThatThrownBy(() -> transfers.transfer(new TransferService.Command(
    "customer-1", "W7-FAIL", from.getId(), to.getId(), 1_000)))
    .isInstanceOf(RuntimeException.class).hasMessage("injected after business mutation");
assertThat(invoked).isTrue();
문법을 한 줄씩 풀면
lambda 안 Service 호출이 예외를 던지는지 AssertJ가 잡고, type과 message를 확인한다. 별도 assertion이 hook 본문이 실제 실행됐는지 확인한다.
실제 값 추적
Command 1000 처리 뒤 hook가 invoked=true로 바꾸고 정해진 RuntimeException을 던진다.
정상 예
‘어떤 예외든’이 아니라 정확한 실패 지점의 예외가 밖으로 전파됨을 확인한다.
반례·경계 예
isInstanceOf만 두면 엉뚱한 RuntimeException도 통과할 수 있다. invoked만 보면 Service가 hook 뒤 예외를 잡아 버린 경우를 놓친다.
착각 방지
원문 문자열의 주차 표식은 canonical source 보존 대상이다. 설명의 현재 범위와 별개다.
이 블록이 하지 않는 일
아직 rollback 최종 상태를 증명하지 않는다.
다음 코드와의 연결
다음 JDBC count와 balance가 transaction 종료 뒤 실제 DB를 확인한다.

코드 블록 4 · transaction 밖 JDBC로 네 최종 값 확인

해설 바로 옆 코드 · java
long transferTransactions = jdbc.sql("SELECT COUNT(*) FROM business_tx WHERE tx_type='TRANSFER'")
    .query(Long.class).single();
long transferEntries = jdbc.sql("SELECT COUNT(*) FROM ledger_entry WHERE entry_type LIKE 'TRANSFER_%'")
    .query(Long.class).single();
assertThat(List.of(
    balance(from.getId()), balance(to.getId()), transferTransactions, transferEntries))
    .as("W7D5_RED_EXPECTED_FULL_ROLLBACK")
    .containsExactly(10_000L, 5_000L, 0L, 0L);
문법을 한 줄씩 풀면
Service 호출이 예외로 끝난 다음 테스트 thread에서 새 JDBC query로 두 잔액과 두 종류 row count를 읽고 순서 있는 List 전체를 비교한다.
실제 값 추적
기대 List는 from10000, to5000, 이체 business_tx0, TRANSFER_ ledger0이다.
정상 예
메모리 entity가 아니라 DB 최종 상태에서 네 효과가 모두 사라졌음을 확인한다.
반례·경계 예
잔액만 보면 orphan 거래·원장을 놓치고, count만 보면 잔액 일부 변경을 놓친다. List 순서를 바꾸면 의미가 달라진다.
착각 방지
as 문자열은 assertion 설명이지 조건이 아니다. 문자열이 있다고 rollback되는 것이 아니다.
이 블록이 하지 않는 일
다른 유형의 기존 거래·원장 행이나 sequence 값까지 원복되었는지는 직접 보장하지 않는다.
다음 코드와의 연결
balance helper가 각 ID의 단일 잔액을 named parameter로 읽는다.

코드 블록 5 · 한 계좌 잔액 재조회 helper

해설 바로 옆 코드 · java
private long balance(long id) {
    return jdbc.sql("SELECT balance FROM account WHERE id=:id")
        .param("id", id).query(Long.class).single();
}
문법을 한 줄씩 풀면
계좌 ID를 named parameter로 bind하고 정확히 한 long 결과를 기대한다.
실제 값 추적
from ID를 넣으면 10000, to ID를 넣으면 5000을 새 query로 읽는다.
정상 예
같은 읽기 코드를 중복하지 않고 서로 다른 두 ID에 재사용한다.
반례·경계 예
존재하지 않는 ID를 넣으면 single 계약이 실패할 수 있다. null 결과를 0으로 바꾸는 helper가 아니다.
착각 방지
Java의 from 객체 field를 읽는 것이 아니라 DB account table을 다시 조회한다.
이 블록이 하지 않는 일
lock 상태나 transaction log를 읽지 않는다.
다음 코드와의 연결
마지막 구성 class가 이 Service 호출을 실패시키는 hook를 정의한다.

코드 블록 6 · 호출 표시 뒤 RuntimeException을 던지는 bean

해설 바로 옆 코드 · java
@TestConfiguration(proxyBeanMethods = false)
static class FailureConfiguration {
    @Bean AtomicBoolean invoked() { return new AtomicBoolean(); }
    @Bean TransferFailureHook failureHook(AtomicBoolean invoked) {
        return () -> {
            invoked.set(true);
            throw new RuntimeException("injected after business mutation");
        };
    }
}
문법을 한 줄씩 풀면
테스트 구성은 공유 AtomicBoolean과 lambda hook bean을 만든다. lambda는 단일 추상 method 구현이며 flag 변경 뒤 예외를 던진다.
실제 값 추적
Service가 afterBusinessMutation을 부르면 false→true, 이어서 RuntimeException이 발생한다.
정상 예
hook 호출 여부와 실패 지점을 하나의 작은 bean으로 재현한다.
반례·경계 예
예외를 먼저 던지고 invoked.set을 뒤에 쓰면 뒤 코드는 실행되지 않아 호출 관찰이 false다. 운영 profile에 이 bean을 등록하면 정상 이체가 항상 실패한다.
착각 방지
proxyBeanMethods=false는 이 구성 method들 사이 수동 호출을 proxy로 가로채지 않는 최적화 힌트이며 transaction proxy와 같은 주제가 아니다.
이 블록이 하지 않는 일
DB process kill·timeout을 흉내 내지 않는다.
다음 코드와의 연결
이 파일의 한 테스트와 합쳐 Service transaction 원복 계약을 닫는다.

정상 예와 반례를 한 번에 대조

정상 예

  • 정상 테스트 성공: hook가 실제 호출되고 예외가 밖으로 나가며 네 최종 값이 10000,5000,0,0이다.
  • 정상 격리: @BeforeEach가 매 실행마다 DB와 invoked를 초기화한다.

반례·경계 예

  • 반례: Service가 hook 예외를 삼키면 assertThatThrownBy가 실패하거나 변경이 commit될 수 있다.
  • 반례: 메모리 from.getBalance만 보면 persistence context 상태 때문에 DB rollback 결과와 다를 수 있다. 이 테스트는 JDBC로 새로 읽는다.
  • 반례: hook 호출만 assert하고 잔액·row count를 안 보면 예외 주입 성공만 증명할 뿐 rollback은 증명하지 못한다.

착각 방지 · 이 파일이 하지 않는 일

  • 착각 방지: 이 파일에는 @Test 하나가 있지만 그 한 method 안에서 여러 assertion을 수행한다. assertion 수와 테스트 수는 다르다.
  • 착각 방지: transactions=0과 entries=0은 tx_type/entry_type 필터가 붙은 이체 효과 수다. 모든 table의 모든 행 수를 0으로 보장하지 않는다.
  • 착각 방지: RuntimeException 한 종류의 한 지점만 검증한다. DB process kill·checked exception·timeout을 일반화하면 안 된다.
  • 착각 방지: 원문 안의 역사적 marker 문자열은 exact source의 일부일 뿐 W8 학습 범위를 앞 주차로 되돌리는 표제가 아니다.
  • 하지 않는 일: DB 프로세스 강제 종료나 network 단절을 재현하는 일
  • 하지 않는 일: checked exception의 rollback rule을 검증하는 일
  • 하지 않는 일: 멱등 재요청·중복 transaction key를 검증하는 일
  • 하지 않는 일: 고객에게 보일 HTTP 5xx body를 검증하는 일

다음 연결

이 테스트까지 통과하면 W8 공통 거래 코어 v0는 단일 신규 이체의 HTTP·조회·대사·RuntimeException rollback 기준선을 갖는다. 동시성·멱등성은 다음 범위로 남는다.

그림으로 다시 보기: 업무 변경 뒤 RuntimeException이 밖으로 전파되면 JDBC 최종값은 from 10,000·to 5,000·TRANSFER 거래 0·TRANSFER 원장 0이다.
그림으로 다시 보기: 업무 변경 뒤 RuntimeException이 밖으로 전파되면 JDBC 최종값은 from 10,000·to 5,000·TRANSFER 거래 0·TRANSFER_ 원장 0이다.

직접 다시 써보기

저장 경로
src/test/java/com/example/financialcore/transfer/TransferFailurePointIT.java
전제조건
실제 PostgreSQL 지원, TransferService와 TransferFailureHook, AccountOpeningService, 관련 migration이 필요하다.
반드시 지킬 계약
테스트 hook 주입, invoked 초기화, 10000/5000 fixture, 업무 변경 뒤 RuntimeException, JDBC 최종 10000/5000/0/0을 유지한다.
추천 입력 순서
annotation/import → 주입/필드 → clean → @Test → balance helper → TestConfiguration bean 순서로 입력한다.
자기 점검
@Test 1개, 예외 type/message, invoked true, JDBC 잔액 둘, TRANSFER 거래/원장 count 0을 모두 확인한다.
이번 파일의 범위 밖
한 RuntimeException 결과를 timeout·process kill·checked exception·멱등성의 증거로 확대하지 않는다.

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

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

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

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

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

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

    Account from;
    Account to;

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

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

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

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

JUnit 보장 경계 · 숫자를 겹쳐 세지 않기

그림으로 다시 보기: 정확 원문 @Test 3개와 원문을 복제하지 않는 참조 @Test 7개는 고유 계약 10개이며, 조회 2개가 class selector에서 다시 돌아 요일별 실행 합은 12회다.
그림으로 다시 보기: 정확 원문 @Test 3개와 원문을 복제하지 않는 참조 @Test 7개는 고유 계약 10개이며, 조회 2개가 class selector에서 다시 돌아 요일별 실행 합은 12회다.

W8 월~토는 같은 LedgerQueryIT를 method 단위와 class 단위로 되풀이합니다. 따라서 ‘고유 테스트 method 수’와 ‘selector가 실행한 횟수’를 구분해야 합니다. 고유 계약은 10개지만 요일별 실행을 더하면 12회입니다. 아래 첫 세 method만 정확한 테스트 원문을 이 문서에 실었습니다. 나머지 일곱 method는 범위 밖에 만들어진 파일이므로 원문 복제 없이 assertion 계약만 적습니다.

토요일 설명 카드와 canonical source가 어긋나는 지점

토요일 PDF의 재검증 카드에는 claim 직후와 업무 변경 뒤라는 두 실패점, 그리고 멱등 요청 행 0 확인이 적혀 있습니다. 그러나 manifest가 가리키는 당시 canonical TransferFailurePointIT.java에는 업무 변경 뒤 실패점 하나를 실행하는 @Test 한 개만 있습니다. 그 assertion은 hook 호출 true, 두 잔액 10000/5000, TRANSFER 업무 거래 0행, TRANSFER_ 원장 0행이며 멱등 요청 table 0행은 직접 확인하지 않습니다. 따라서 이 원고는 PDF 카드의 더 넓은 문구를 W8 보장으로 승격하지 않고 exact source와 실제 assertion을 우선합니다. 이후 주차에 등장하는 두 실패점 구현도 끌어오지 않습니다.

원문을 직접 실은 @Test 3개

1. keysetPagesAreStableAndDoNotRepeatTheCursorRow · LedgerQueryIT.java

준비(Arrange)
OPENING 10000 뒤 OUT -500·IN +500 두 행을 서로 다른 미래 시각으로 준비한다.
행동(Act)
첫 페이지 limit 2를 읽고 두 번째 행의 occurredAt+id로 다음 페이지를 읽는다.
직접 보장(Assert)
첫 목록 2행, 다음 목록 1행, 다음 ID 목록이 첫 두 ID를 포함하지 않는다.
직접 보장하지 않음
첫 목록의 정확한 entryType 순서, 동률 시간 fixture, 동시 삽입·삭제, 3페이지 이상, 성능, limit 오류를 직접 확인하지 않는다.
원문 아닌 강화 예시
원문 아닌 강화 예시로 first의 entryType을 IN/OUT 순서로 확인하고, 같은 occurredAt 두 행을 만들어 id 동률 기준을 확인하며, limit 0·101 예외를 별도 테스트한다.

2. reconciliationReturnsZeroThenDetectsOneInjectedMismatch · LedgerQueryIT.java

준비(Arrange)
account.balance와 signed 합이 모두 10000인 fixture를 준비한다.
행동(Act)
mismatch 0을 읽은 뒤 account.balance만 +1하고 다시 읽는다.
직접 보장(Assert)
정상 불일치 계좌 수 0, 오류 주입 뒤 불일치 계좌 수 1이다.
직접 보장하지 않음
차액 자체가 1인지 반환하지 않고, 원인 행 식별·자동 복구·다통화·여러 오류를 확인하지 않는다.
원문 아닌 강화 예시
원문 아닌 강화 예시로 mismatch 계좌 ID·account balance·ledger sum을 상세 query로 확인하고, 잔액을 되돌린 뒤 다시 0인지 검증한다.

3. runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect · TransferFailurePointIT.java

준비(Arrange)
from 10000, to 5000과 업무 변경 뒤 RuntimeException을 던지는 hook를 준비한다.
행동(Act)
1000 이체를 호출해 hook 예외를 transaction 밖으로 전파시킨다.
직접 보장(Assert)
예외 type/message, hook true, 최종 DB 잔액 10000/5000, TRANSFER 거래 0, TRANSFER_ 원장 0이다.
직접 보장하지 않음
checked exception, DB process kill, timeout, 다른 실패 지점, 멱등 재요청, HTTP 오류 body를 확인하지 않는다.
원문 아닌 강화 예시
원문 아닌 강화 예시로 AtomicInteger를 써 hook 호출이 정확히 1회인지 확인하고, 예외 뒤 두 account 행 자체가 남아 있는지와 다른 유형 opening 원장이 보존되는지 확인한다. 이후 주차의 다른 실패점을 현재 보장으로 가져오지는 않는다.

W8 selector가 실행하지만 원문은 복제하지 않는 참조 @Test 7개

# method 직접 보장 직접 보장하지 않음
1 createReturns201AndPersistsOpening 계좌 생성 HTTP 201과 account_no=A-100 행 1개 Location/body 전체, opening 원장, 미인증
2 negativeOpeningReturns400AndWritesNothing 음수 개설이 400이고 INVALID_REQUEST·request id를 포함하며 account 0행 다른 table 전체 무변경, JSON 전체 shape
3 ownerReadsAccount 소유자가 기존 계좌를 GET해 200과 LOOKUP 문자열을 받음 balance 전체 body, 타인 접근 거절
4 missingAccountReturns404 없는 ID가 404와 ACCOUNT_NOT_FOUND·request id를 포함 존재하지만 타인 소유인 경우
5 firstTransferIs201 인증된 첫 이체가 201이고 요청 표식·COMPLETED를 포함 두 잔액·거래/원장 row 수
6 zeroAmountIs400WithoutTransferWrite 0원 요청이 400이고 TRANSFER business_tx 0행 ledger·계좌·다른 table 전체 무변경
7 missingAuthenticationIs401 인증 없는 이체가 401 오류 body와 모든 DB 무변경

SQL workbook · Q07/Q08

두 문제 모두 원문은 수행 요구와 evidence 저장 경로를 제시하지만, reference project 안에 sql-q07.sql·sql-q08.sql canonical learner answer 파일은 없습니다. 따라서 아래 SQL은 schema와 통과 조건을 만족하도록 만든 교육용 전체 예시 정답입니다. 정답 source에서 복사한 코드라고 부르면 안 됩니다. 두 예시는 앱 migration의 business_tx가 아니라 sql/workbook/fixtures/workbook_schema.sqlbusiness_tx(tx_id, amount, fee_rate, occurred_at, ...)를 기준으로 합니다.

그림으로 다시 보기: Q07은 occurred_at DESC·tx_id DESC 뒤 LIMIT 10, Q08은 numeric amount × 0.01을 ROUND해 149→1·150→2로 만든다.

Q07 · 최근 거래 10건

계약 카드

질문
시작 table의 한 행 business_tx 한 거래
출력 한 행 최근 순서로 선택된 거래 한 건
예상 최대 행 수 10
핵심 occurred_at DESC 뒤 tx_id DESC 동률 기준, LIMIT 10
값 추적
같은 occurred_at인 두 거래가 있으면 더 큰 tx_id가 먼저 온다. 11건 이상이어도 정렬 뒤 앞 10건만 남는다.
정상 예
12건에서 최신순 10건을 얻고 같은 시각도 tx_id로 순서가 하나로 정해진다.
반례
ORDER BY occurred_at DESC LIMIT 10만 쓰면 동률 행의 순서가 고정되지 않는다. LIMIT 10만 쓰면 최근이라는 계약 자체가 없다.
착각 방지
UUID의 크기 순서는 업무 발생 순서를 뜻하지 않는다. 여기서는 occurred_at 동률을 깨는 결정적 보조 기준으로만 쓴다.
하지 않는 일
계좌별 최근 10건, pagination cursor, 특정 상태 필터는 이 문제의 계약이 아니다.
다음 연결
Q08은 행 수를 줄이지 않고 각 거래 금액에서 1% 수수료를 계산한다.

직접 다시 써보기

저장 경로
evidence/w8/sql-q07.sql
전제조건
workbook_schema와 seed를 PostgreSQL에 적용하고 business_tx에 10건 초과 및 occurred_at 동률 fixture를 준비한다. canonical answer 파일은 없다는 점을 기록한다.
반드시 지킬 계약
최근 거래 최대 10건, occurred_at 내림차순, tx_id 내림차순 동률 해소를 유지한다.
추천 입력 순서
grain 주석 → SELECT 열 → FROM → ORDER BY 두 열 → LIMIT 10 → 실행 결과 주석 순서로 쓴다.
자기 점검
결과 10행 이하, occurred_at 비증가, 동률 tx_id 비증가, 같은 fixture 재실행 순서 동일을 확인한다.
이번 문제의 범위 밖
계좌별 TOP-N, keyset 다음 페이지, 실행 계획 최적화를 정답에 억지로 넣지 않는다.

전체 예시 정답 · 들여쓰기까지 대조

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 전체 예시 정답: reference project에는 Q07 canonical learner answer 파일이 없다.
-- 입력 grain: business_tx 한 행 = 한 거래
-- 출력 grain: 최근순으로 고른 거래 한 행, 최대 10행
SELECT tx_id, tx_type, status, amount, occurred_at
FROM business_tx
ORDER BY occurred_at DESC, tx_id DESC
LIMIT 10;

Q08 · 금액의 1% fee를 원 단위 반올림

계약 카드

질문
시작 table의 한 행 business_tx 한 거래
출력 한 행 원 거래와 계산된 fee_amount 한 건
예상 행 수 business_tx 입력 행 수와 같음
핵심 amount * 0.01을 numeric으로 계산하고 ROUND로 원 단위 반올림
값 추적
amount=149이면 1%=1.49이고 ROUND 결과 1, amount=150이면 1.50이고 PostgreSQL numeric ROUND 결과 2다.
정상 예
1000은 10.00→10, 1550은 15.50→16이다.
반례
(amount * 1 / 100)을 정수 연산으로 먼저 계산하면 소수 부분이 사라져 반올림할 정보가 없다.
착각 방지
schema에는 fee_rate도 있지만 문제 문장은 고정 1%를 요구한다. 기존 열을 쓸 경우에도 0.01 fixture와 null/기본값 계약을 따로 밝혀야 한다.
하지 않는 일
계산 fee를 UPDATE로 저장하거나 통화별 소수 자릿수·세금 규칙을 정하지 않는다.
다음 연결
계산 query까지 마치면 v0 보장과 아직 보장하지 않는 경계를 최종 표로 닫는다.

직접 다시 써보기

저장 경로
evidence/w8/sql-q08.sql
전제조건
workbook business_tx의 amount가 BIGINT이고 PostgreSQL numeric ROUND를 사용한다. canonical answer 파일은 없다는 점을 기록한다.
반드시 지킬 계약
거래별 amount의 고정 1%, 원 단위 반올림, 입력 한 행당 출력 한 행을 유지한다.
추천 입력 순서
grain/반올림 주석 → SELECT 식별자·amount → ROUND(amount * 0.01) 별칭 → FROM → 확인용 안정 정렬 순서로 쓴다.
자기 점검
amount 149→1, 150→2, 1000→10, 입력/출력 행 수 동일을 확인한다.
이번 문제의 범위 밖
fee 저장 UPDATE, 통화별 scale, 세금·최소 수수료·상한 정책을 추가하지 않는다.

전체 예시 정답 · 들여쓰기까지 대조

전체 예시 정답 펼치기
전체 예시 정답 · sql
-- 전체 예시 정답: reference project에는 Q08 canonical learner answer 파일이 없다.
-- 입력 grain = 출력 grain: business_tx 한 거래
-- PostgreSQL numeric ROUND: amount의 1%를 원 단위로 반올림
SELECT
    tx_id,
    amount,
    ROUND(amount * 0.01::numeric) AS fee_amount
FROM business_tx
ORDER BY tx_id;

마지막 재점검 · W8 공통 거래 코어 v0

그림으로 다시 보기: v0의 직접 보장은 HTTP 상태 경계, 첫2/다음1 무중복, 대사 0→1, RuntimeException rollback까지이며 동시성 전체·멱등 replay·process kill·timeout은 아직 미보장이다.
그림으로 다시 보기: v0의 직접 보장은 HTTP 상태 경계, 첫2/다음1 무중복, 대사 0→1, RuntimeException rollback까지이며 동시성 전체·멱등 replay·process kill·timeout은 아직 미보장이다.
v0가 이번 원문과 테스트로 직접 확인한 것 아직 직접 확인하지 않은 것
인증 Principal을 계좌·이체 Service actor로 전달 모든 인가 정책과 관리자 예외
계좌 생성 201, 조회 200, 음수 400, 없음 404의 참조 계약 HTTP 문서 자동 replay·전체 JSON schema drift
이체 신규 201, 0원 400·write 0, 미인증 401의 참조 계약 같은 요청 replay 멱등성·payload conflict
created_at+id strict keyset의 첫2/다음1 무중복 동시 insert/delete 중 모든 isolation과 대규모 성능
대사 정상 0과 account balance +1 뒤 mismatch 1 자동 원인 분석·수리·다통화
업무 변경 뒤 RuntimeException에서 잔액10000/5000·거래0·원장0 checked exception·DB process kill·network timeout
Q07 안정 최근 10건, Q08 1% numeric 반올림 예시 canonical SQL answer source, fee 저장·통화 정책

· · ·

이미지 출처와 이용 안내

대화 앞의 작은 인물 이미지를 문서화 단계에서 넣는다면 TV 애니메이션 《봇치 더 록!》 공식 CHARACTER 자료의 이용 범위를 다시 확인해야 합니다. ©はまじあき/芳文社・アニプレックス. 개인 학습용 비공식 문서이며 재배포·상업 이용 권리를 뜻하지 않습니다. 개념도는 사용자 제공 학습 자료를 바탕으로 새로 만드는 교육용 도식입니다.