Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

28장. 트랜잭션과 동시성 — 경계 · Lock · Race Condition

이 장의 주제는 Agent가 가장 자주 틀리는 영역이다.

이유는 분명하다.

트랜잭션과 동시성 문제는
코드를 읽어서는 보이지 않는다.

컴파일도 되고, 테스트도 통과하고,
로컬에서 잘 돈다.

그리고 운영에서 터진다.


Agent는 순차 실행을 가정한다

이것이 이 장 전체의 전제다.

fun usePoint(userId: Long, amount: Long) {
    val point = pointRepository.findByUserId(userId)
    if (point.balance < amount) throw InsufficientPointException()
    point.balance -= amount
    pointRepository.save(point)
}

Agent에게 이 코드는 완벽하다.

잔액을 확인하고, 부족하면 예외를 던지고, 차감한다.
논리적으로 흠이 없다.

⚠️ 동시에 두 요청이 들어오면 둘 다 통과한다.

Agent가 이 문제를 못 보는 것이 아니다.
물어보면 정확히 설명한다.

묻지 않으면 떠올리지 않을 뿐이다.

동시성은 지시하지 않으면 고려되지 않는다.


전제를 문서로 박아둔다

가장 값싼 방어책이다.

# CLAUDE.md

## 실행 환경 전제

- 이 서비스는 항상 2대 이상의 인스턴스로 뜬다
- 따라서 JVM 단위 동기화(`synchronized`, `ReentrantLock`)와
  로컬 캐시는 정합성 보장 수단이 될 수 없다
- 잔액·재고·상태 전이는 DB 락 또는 분산 락으로 보호한다

이 세 줄이 없으면 Agent는
synchronized 로 해결한 코드를 자신 있게 내놓는다.

단일 인스턴스에서는 실제로 통과한다.
그래서 더 위험하다.


@Transactional 4대 실수

Kotlin + Spring에서 반복되는 것들이다.

1️⃣ 자기 호출

fun cancel(orderId: Long) {
    this.doCancel(orderId)      // 프록시를 안 거친다
}

@Transactional
fun doCancel(orderId: Long) { ... }

트랜잭션이 아예 걸리지 않는다.
조용히.

2️⃣ 외부 호출을 트랜잭션 안에 둔다

@Transactional
fun pay(request: PayRequest) {
    val payment = paymentRepository.save(...)
    pgClient.request(payment)          // ← 3초 대기
    payment.status = PAID
}

15장에서 예시로 든 그 규칙이다.

커넥션을 물고 외부 응답을 기다린다.
PG가 느려지면 커넥션 풀이 마른다.

3️⃣ 조회에 readOnly 를 빠뜨린다

성능뿐 아니라 실수 방지 효과도 잃는다.

4️⃣ 경계를 너무 넓게 잡는다

@Transactional
fun processDailySettlement() {
    orders.forEach { ... }    // 10만 건
}

락 유지 시간이 길어지고, 실패하면 전부 롤백된다.


규칙으로 고정하기

네 가지 모두 문장으로 막을 수 있다.

## Transaction

- `@Transactional` 은 Facade 계층에만 붙인다
- 같은 클래스 내부 호출로 트랜잭션을 기대하지 않는다
- 외부 API·Redis·파일 I/O 는 트랜잭션 밖에서 한다
  (커넥션 풀 고갈 장애 이력 있음)
- 조회 전용은 `@Transactional(readOnly = true)`
- 배치성 반복 처리는 건별 트랜잭션으로 쪼갠다

15장에서 말한 대로 이유를 한 줄 붙인다.
세 번째 규칙이 Redis와 파일 I/O에도 응용된다.


Race Condition을 테스트로 증명한다

23장의 재현 테스트를 동시성에 적용하면 이렇게 된다.

@Test
fun `동시에 포인트를 사용해도 잔액보다 많이 차감되지 않는다`() {
    val userId = 사용자_생성(balance = 1_000)
    val executor = Executors.newFixedThreadPool(10)
    val latch = CountDownLatch(10)

    repeat(10) {
        executor.submit {
            try { pointService.usePoint(userId, 1_000) }
            catch (e: Exception) { /* 실패는 정상 */ }
            finally { latch.countDown() }
        }
    }
    latch.await()

    val balance = pointRepository.findByUserId(userId).balance
    assertThat(balance).isEqualTo(0)   // -9,000 이 되면 안 된다
}

🔥 이 테스트가 이 장의 핵심 도구다.

동시성 문제는 말로 설명하면 흐지부지되지만
실패하는 테스트가 있으면 논쟁이 끝난다.

Agent에게 이렇게 지시한다.

포인트 차감에 동시성 문제가 있는지 확인하고,
있다면 먼저 재현 테스트를 만들어줘.
스레드 10개로 동시 요청하는 형태로.

수정은 테스트가 실패하는 걸 확인한 다음에 하자.

락 선택은 사람이 한다

재현이 됐으면 해결 방법을 고른다.

방법적합한 상황대가
낙관적 락 (@Version)충돌이 드물다충돌 시 재시도 필요
비관적 락 (FOR UPDATE)충돌이 잦다대기·데드락 위험
DB 원자 연산단순 증감표현력 제한
분산 락 (Redis)DB 밖의 자원락 유실·해제 실패
유니크 제약중복 생성 방지예외 처리 필요

⚠️ Agent는 이 중 하나를 골라 바로 적용한다.

대개 비관적 락이다.
가장 확실해 보이기 때문이다.

그런데 이 선택은 트래픽 패턴을 알아야 한다.
충돌 빈도, 트랜잭션 길이, 데드락 이력.

코드에 없는 정보다.

가능한 해결 방법을 세 가지 제시하고
각각의 단점을 알려줘. 적용은 내가 고른 다음에.

21장에서 대안을 물으라고 한 이유가 여기서도 작동한다.


데드락은 순서 문제다

여러 테이블을 잠그는 코드에서 나온다.

트랜잭션 A: orders → payments 순으로 잠금
트랜잭션 B: payments → orders 순으로 잠금

Agent에게 확인시킬 수 있다.

이 코드에서 여러 테이블을 잠그는 경로를 찾아줘.
잠그는 순서가 서로 다른 경로가 있으면 알려줘.

이런 정적 확인은 Agent가 사람보다 빠르다.
호출 경로를 전부 따라가는 일이기 때문이다.


완료 조건에 동시성을 넣는다

## Acceptance Criteria
- 동시 10요청 시 잔액이 음수가 되지 않는 테스트 통과
- 동시 취소 요청 시 환급이 1건만 발생하는 테스트 통과
- 트랜잭션 안에서 외부 호출이 없음을 확인
- 기존 포인트 테스트 18건 통과

⚠️ 세 번째 항목은 사람이 Diff로 확인한다.

42장에서 이런 규칙을 자동 검사로 만드는 방법을 다룬다.


이 장의 핵심

  • 트랜잭션과 동시성 문제는 코드를 읽어서는 보이지 않는다
  • Agent는 순차 실행을 가정한다 — 묻지 않으면 동시성을 떠올리지 않는다
  • 인스턴스가 2대 이상이라는 전제를 문서로 박아둔다
  • 그것이 없으면 synchronized 로 해결한 코드가 나온다 — 로컬에서는 통과한다
  • 자기 호출·외부 호출 포함·readOnly 누락·넓은 경계가 4대 실수다
  • 동시성 문제는 재현 테스트가 있으면 논쟁이 끝난다
  • 락 선택은 트래픽 패턴을 알아야 하므로 사람이 결정한다
  • Agent는 대개 비관적 락을 고른다 — 가장 확실해 보이기 때문이다
  • 잠금 순서가 다른 경로를 찾는 정적 확인은 Agent가 사람보다 빠르다