트랜잭션의 경계는 어떻게 정해야할까: Outbox pattern으로 경계 줄이기

0. 테스트 환경 / 관측 지표

  • 부하 도구: k6 (ramping-vus)
  • DB: MySQL (InnoDB)
  • ORM: Spring Data JPA
  • 커넥션 풀: HikariCP (maximumPoolSize=10)
  • 관측 지표:
    • API latency: p50/p95/p99/max
    • HikariCP: active/pending counts

1. 문제 상황

리워드 적립 API(POST /rewards/earn)는 주문 완료 시 우리 서비스 DB에 기록하고, 로그를 남긴 뒤 파트너사에 API로 동기화하는 흐름입니다.

부하 테스트에서 VU를 50까지 올리자 응답 시간이 급격히 치솟았습니다.

평균 약 6.2초, 처리한 요청 수 3,510개.

활성 커넥션이 부하 시작 얼마 안 돼서 10개(최대치)에 도달했고, 그 직후부터 pending이 쌓이기 시작했습니다. pending은 35개까지 치솟았습니다.

"DB가 느리다"가 아니라, 애플리케이션이 커넥션을 오래 물고 있다는 신호입니다.


2. 왜 처음엔 이렇게 설계했나

지금 보면 "외부 HTTP 호출을 트랜잭션에 넣다니" 싶지만, 당시엔 나름 합리적이었습니다.

  • "적립 성공하면 감사 로그도 남기고, 파트너 동기화도 반드시 성공해야지."
  • "중간에 실패하면 전체 롤백하는 게 안전하잖아."

논리적으로 하나의 업무 흐름처럼 보여서 트랜잭션 하나로 묶었던 거죠.

그런데 트랜잭션이 '논리적 흐름'을 묶어주는 건 아닙니다. 결국 커넥션과 락을 얼마나 오래 잡느냐의 문제거든요.


3. 트랜잭션이 너무 오래 산다

커넥션 풀 고갈의 흔한 원인은 두 가지입니다.

  1. 쿼리 자체가 느려서 커넥션을 오래 점유
  2. 쿼리는 빠른데 트랜잭션 경계가 넓어서 커밋이 늦고, 커넥션 반환도 늦음

이번 건 2번이 99.99% 유력해 보였습니다. pending이 빠르게 쌓였고, "특정 구간에서 요청이 멈춘 느낌"이 있었거든요.


4. 트랜잭션 안의 외부 I/O

당시 코드입니다.

@Transactional
fun earnReward(request: EarnRewardRequest): EarnRewardResponse {

    // 1) X lock 획득
    val summary = userRewardSummaryRepository
        .findByUserIdAndYearMonthForUpdate(userId, yearMonth)

    // 2) 내부 상태 변경
    summary.totalAmount += request.amount

    // 3) ledger 기록
    val ledger = rewardLedgerRepository.save(
        RewardLedger(...)
    )

    // 4) audit 기록
    auditLogRepository.save(
        AuditLog(...)
    )

    // 5) 파트너사 동기화 (외부 HTTP)
    partnerApiClient.syncRewardPoints(
        userId = request.userId,
        amount = request.amount,
        ledgerId = ledger.id
    )
}

파트너 호출은 동기 방식이었습니다.

@Component
class PartnerApiClient(
    private val webClient: WebClient
) {
    fun syncRewardPoints(userId: Long, amount: Long, ledgerId: Long) {
        webClient.post()
            .uri("/api/v1/points/sync")
            .bodyValue(
                SyncRequest(
                    userId = userId,
                    amount = amount,
                    idempotencyKey = ledgerId.toString()
                )
            )
            .retrieve()
            .bodyToMono(Void::class.java)
            .block() // 응답이 올 때까지 대기
    }
}

핵심은 .block()입니다.

  • 네트워크 응답이 올 때까지 스레드 대기
  • 트랜잭션은 안 끝남
  • 커넥션과 X lock 계속 점유

외부 네트워크 지연이 그대로 커넥션 점유 시간, 락 점유 시간이 됩니다.


5. 상한 계산

풀 사이즈 10, 외부 호출로 트랜잭션이 평균 100ms 늘어난다면:

최대 처리량 상한 ≈ poolSize / holdingTime
                = 10 / 0.1
                = 100 tx/s

단순한 계산이지만 시사점은 큽니다.

  • 요청 몰리면 10개만 실행, 나머지는 커넥션 대기
  • 대기열(pending) 쌓이고, p95/p99 급등

6. 락 대기가 커넥션을 연쇄로 묶는다

더 나쁜 건 SELECT ... FOR UPDATE입니다.

  • 요청 A가 X lock 잡은 채 파트너 응답 대기
  • 요청 B~N은 같은 row의 X lock 풀릴 때까지 대기
  • B~N도 트랜잭션 안에서 대기하니까 자기 커넥션을 물고 기다림

동일 userId가 hot key가 되면:

  • 락 대기열이 커넥션 점유열로 바뀜
  • 풀 전체가 빠르게 소진
  • 관계없는 userId 요청까지 영향

단순히 "커넥션이 부족했다"가 아닙니다.

트랜잭션 경계 + 락 경합 + 외부 I/O가 결합해서 커넥션 풀이 폭발적으로 고갈된 케이스입니다.


7. 원자성 경계를 잘못 잡았다

각 작업이 정말 "같이 커밋돼야 하는가?"로 분해하면 답이 보입니다.

작업 내부 정합성에 원자적으로 필요? 트랜잭션에 묶을 실익
summary UPDATE + ledger INSERT
audit INSERT 낮음
파트너 API 호출 ❌ (애초에 원자성 불가) 없음

파트너 동기화는 트랜잭션에 넣는다고 원자성이 생기지 않습니다.

  • 외부 호출 성공 → 내부 DB 롤백: "외부는 적립, 내부는 미적립"
  • 외부 호출 실패 → 내부 DB 커밋: "내부는 적립, 외부는 미반영"

외부 시스템과의 정합성은 트랜잭션으로 해결되지 않습니다. 멱등성, 재시도, 비동기 처리로 풀어야 하는 문제입니다.


8. 왜 @Async가 아니라 Outbox인가

외부 호출을 트랜잭션 밖으로 빼는 방법은 여럿 있습니다. 가장 쉬운 건 @Async입니다.

옵션 A: @Async

장점:

  • 구현 쉽고 latency 즉각 개선

단점:

  • 프로세스 재시작 시 유실
  • 실패 재시도/상태 추적/운영 대응을 직접 구현해야 함

옵션 B: Transactional Outbox

장점:

  • "DB 커밋"과 "처리 예약"이 같은 트랜잭션 → 예약이 100% 보장되면 이후 수행은 어렵지 않음
  • 상태(PENDING/DONE/DEAD_LETTER)로 운영 가능
  • 서버 재시작해도 DB에 PENDING 이벤트가 남아서 재처리 가능

단점:

  • 폴링 지연 (유저에게 빠른 결과 못 줌)
  • outbox 테이블 운영 필요 (정리/인덱스/모니터링)
  • 기본적으로 at-least-once → 멱등성 구현 필요

이 시스템은 유실을 허용하기 어려웠고, 추적 가능성이 더 중요했습니다. 옵션 B를 선택했습니다.


9. 트랜잭션 경계 축소 + Outbox

메인 트랜잭션에서는 내부 정합성만 처리합니다.

@Transactional  // 커넥션 점유: 짧아짐
fun earnReward(request: EarnRewardRequest): EarnRewardResponse {

    val summary = userRewardSummaryRepository
        .findByUserIdAndYearMonthForUpdate(userId, yearMonth)

    summary.totalAmount += request.amount

    val ledger = rewardLedgerRepository.save(...)

    // "나중에 처리할 작업"을 같은 트랜잭션에 기록
    outboxEventRepository.save(
        OutboxEvent(
            type = "REWARD_EARNED",
            payload = toJson(ledger),
            status = "PENDING",
            retryCount = 0
        )
    )
}

처리는 별도 스케줄러에서.

@Scheduled(fixedDelay = 500)
fun processOutbox() {
    outboxEventRepository.findPending(MAX_RETRY).forEach { event ->
        try {
            val payload = parse(event.payload)

            partnerApiClient.syncRewardPoints(
                userId = payload.userId,
                amount = payload.amount,
                ledgerId = payload.ledgerId
            )

            auditLogRepository.save(...)
            event.status = "DONE"
        } catch (ex: Exception) {
            event.retryCount++
            if (event.retryCount >= MAX_RETRY) event.status = "DEAD_LETTER"
        }
        outboxEventRepository.save(event)
    }
}

10. 감수한 트레이드오프

10.1 즉시성 대신 "최대 지연"을 정의했다

폴링(500ms)을 쓰는 순간, 파트너 반영은 "즉시"가 아니게 됩니다.

  • 평균 지연: ~250ms
  • 최악 지연: ~500ms + 처리 시간 + 큐잉

즉시성 대신 상한이 있는 지연을 선택한 겁니다. 이건 기술 선택이 아니라 요구사항 선택이에요.

10.2 Exactly-once는 자동으로 생기지 않는다

Outbox는 대개 at-least-once 전송입니다. 중복 전송 가능성을 전제로 하므로 파트너 API에 멱등성 키가 필요합니다.

  • ledgerId를 idempotency key로 사용
  • 동일 key 재요청 시 중복 적립 방지

10.3 Outbox는 새로운 운영 대상이다

도입과 동시에 다음이 운영 이슈가 됩니다.

  • PENDING 누적 시 알림/모니터링
  • DONE 데이터 정리 (보관 주기)
  • 폴링 쿼리 효율 (인덱스, 배치 크기)
  • DEAD_LETTER 처리 프로세스 (수동 재처리/원인 분석)

11. 결과

지표 Before After 개선율
avg 624.1ms 15.9ms -97%
p50 477.7ms 4.7ms -99%
p95 1,483.2ms 58.7ms -96%
p99 1,746.3ms 239.2ms -86%
max 2,734.7ms 1,309.0ms -52%
처리량 3,510건 21,725건 +6.2배

평균 응답 속도가 대폭 줄었고, 시간당 처리량이 6배 늘었습니다.

  • 활성 커넥션이 최대치에 도달한 시점이 훨씬 늦어지고, 지속 시간도 짧아졌습니다.
  • pending도 기존 대비 10배 이상 줄었습니다.

대단한 쿼리 튜닝이나 아키텍처 변경이 아닙니다.

네트워크 대기 시간을 트랜잭션 자원(커넥션/락)에서 분리한 결과입니다.


12. 핵심 인사이트

  • 트랜잭션 경계 = 커넥션/락 점유 구간입니다. "코드 블록"이 아니에요. 뭘 묶고 뭘 빼야 하는지, 비즈니스 판단이 먼저입니다.
  • 외부 호출을 트랜잭션 안에 두면, 네트워크 지연이 고스란히 DB 자원 점유로 이어집니다.
  • Outbox가 마법처럼 다 해결해주진 않습니다. 유실 없는 비동기 처리를 위한 최소 장치일 뿐이에요.
  • 대신 즉시성, 운영 부담, 멱등성 설계 비용을 함께 지불해야 합니다.