PR 리뷰를 하다가 이런 코드를 보면 멈춘다.

DB::transaction(function () use ($user, $url) {
    $user->update(['avatar_url' => $url]);

    // 외부 이미지 메타데이터 조회
    $headers = get_headers($url, 1);
    $contentType = $headers['Content-Type'] ?? null;

    AvatarMeta::create([
        'user_id'      => $user->id,
        'content_type' => $contentType,
        'fetched_at'   => now(),
    ]);
});

테스트 통과. PR 머지. 며칠 동안 멀쩡. 그러다 어느 날 갑자기 DB 커넥션 풀 고갈, deadlock, 슬로우 쿼리 알람이 새벽 3시에 울린다.

원인: 트랜잭션 안에 HTTP 호출이 들어있다.


왜 터지는가

트랜잭션의 핵심은 ACID지만, 시야에서 가장 자주 빠지는 건 시간이다.

  • 트랜잭션이 열려있는 동안, 그 트랜잭션이 건드린 행에는 락이 걸려있다 (격리 수준에 따라 정도가 다르다).
  • 그 락은 commit 또는 rollback 전까지 풀리지 않는다.
  • 그 트랜잭션이 점유한 DB 커넥션 1개도 그동안 풀로 돌아가지 못한다.

여기에 HTTP 호출 1초가 들어가면:

  • 행 락 1초 유지
  • DB 커넥션 1개 1초 점유
  • 동시에 100명이 같은 엔드포인트를 치면 DB 커넥션 100개가 1초씩 묶인다

만약 외부 API 타임아웃이 30초라면? 30초짜리 트랜잭션이 풀에 깔린다. DB는 멀쩡한데 앱 전체가 멈춘다. 외부 시스템의 가용성에 우리 앱의 동시성 한도가 묶이는 셈이다.


로컬에서는 왜 안 터지나

이게 함정의 핵심이다.

  • 로컬 DB: 동시 접속자 1~2명
  • 외부 API: 빠르거나 mock으로 대체
  • 트랜잭션 격리 수준: SQLite, Postgres, MySQL 모두 단일 사용자에서는 락 충돌이 일어나지 않음

테스트는 통과한다. PR 리뷰도 통과한다. 머지 후 며칠 동안 그래프도 평온하다. 그러다 트래픽이 어떤 임계점을 넘는 순간, 또는 외부 API가 한 번 느려지는 순간 지수적으로 무너진다.

장애가 학습을 만든다. 다만 학습 비용이 크다.


분리 패턴 (Laravel 기준)

1. Job + afterCommit

가장 흔한 패턴. 외부 호출을 큐로 빼고, 트랜잭션이 commit된 후에 실행되도록 보장한다.

DB::transaction(function () use ($user, $url) {
    $user->update(['avatar_url' => $url]);
});

FetchAvatarMeta::dispatch($user, $url);

또는 트랜잭션 안에서 dispatch하더라도 afterCommit을 붙이면 commit 후에야 큐에 들어간다.

DB::transaction(function () use ($user, $url) {
    $user->update(['avatar_url' => $url]);
    FetchAvatarMeta::dispatch($user, $url)->afterCommit();
});

config/queue.php에서 커넥션 단위로 'after_commit' => true로 설정해두면 매번 붙이지 않아도 된다. 팀에 사람이 많을수록 글로벌 설정 + opt-out이 안전하다.

2. 트랜잭션 분리 — 작은 트랜잭션 두 개

큐를 쓸 수 없는 상황이라면 트랜잭션을 쪼갠다.

DB::transaction(function () use ($user, $url) {
    $user->update(['avatar_url' => $url]);
});

$headers = get_headers($url);  // 트랜잭션 밖

DB::transaction(function () use ($user, $headers) {
    AvatarMeta::create([
        'user_id'      => $user->id,
        'content_type' => $headers['Content-Type'] ?? null,
    ]);
});

문제는 정합성이다. 두 번째 트랜잭션이 실패하면 첫 번째 결과는 그대로 남는다. “외부 호출 실패 시 어떻게 복구할 것인가”를 미리 정해두지 않으면 데이터 일관성이 무너진다.

3. Outbox 패턴

견고함이 필요한 상황에서 쓴다. 트랜잭션 안에는 DB 쓰기 + outbox 레코드만 넣고, 별도 워커가 outbox를 폴링해서 외부 호출을 처리한다.

DB::transaction(function () use ($user, $url) {
    $user->update(['avatar_url' => $url]);

    OutboxEvent::create([
        'type'    => 'avatar.uploaded',
        'payload' => ['user_id' => $user->id, 'url' => $url],
    ]);
});

// 워커가 outbox를 polling → 외부 호출 → status 업데이트

장점: 트랜잭션의 atomicity 안에 “외부 호출의 의도”까지 들어간다. 호출 실패는 자동 재시도. 메시지 유실 없음. 단점: 인프라 한 단계 추가. 워커, 모니터링, 중복 호출 방지(idempotency key)까지 챙겨야 한다.

소규모 프로젝트에서는 1번으로 충분하다. 결제·정산처럼 정합성이 곧 매출인 경우에만 3번을 꺼낸다.


어디서 배우는가

이 원칙은 입문서에 거의 안 나온다. 명시적으로 배우는 경로는 대체로 이렇다.

  1. 장애 회고. 가장 흔한 학습 경로. 한 번 새벽 3시에 외부 API 지연으로 DB 풀이 고갈되면 평생 안 잊는다.
  2. 시니어 코드 리뷰. “이거 트랜잭션 밖으로 빼주세요” 한 줄이 학습 사이클을 6개월 단축시킨다.
  3. 『데이터 중심 애플리케이션 설계』(Designing Data-Intensive Applications, Martin Kleppmann). 트랜잭션, 락, 분산 시스템 챕터.
  4. Microsoft Outbox 패턴 문서, AWS Well-Architected. 분산 환경에서의 정합성 보장 패턴.

언어와 프레임워크는 이 실수를 막아주지 않는다. DB::transaction() 안에서 Http::get()을 호출해도 컴파일 에러가 나지 않고, 정적 분석도 잡지 않는다. 컴파일러가 못 막아주는 영역의 지식이다.


시니어리티별 인지도

체감상의 분포다.

  • 0~2년차: 대부분 모른다. 트랜잭션은 “all or nothing”이라는 1차원 인식. 더 많이 감쌀수록 더 안전하다고 생각한다.
  • 2~4년차: 절반은 한 번 터져서 안다. 절반은 아직 모른다. 알고 있어도 “왜”를 설명하라면 막히는 구간.
  • 4~6년차: 본능적으로 분리한다. afterCommit이 자연스럽다. 코드 리뷰에서 가장 먼저 잡는 항목 중 하나.
  • 6년차+: 분리는 기본, outbox/saga까지 사고. 외부 I/O 실패 시의 정합성 전략을 설계 단계에서 결정한다.

PR을 작성한 사람이 이 부분을 놓친 건 이상한 일이 아니다. 코드 리뷰에서 짚어주고 패턴을 알려주는 것이 학습 사이클의 정상적인 부분이다. 다만 짚을 때 “이거 잘못됐다”가 아니라 **“왜 위험한지 + 일반 원칙”**을 같이 알려주는 게 효과가 크다.


마무리

“감싸면 안전하다”는 트랜잭션의 절반만 본 시야다. 트랜잭션은 데이터를 보호하는 동시에 시간을 점유한다. 그 시간 안에 외부 I/O가 들어가면, 점유 시간은 외부 시스템의 가용성에 묶인다. 우리 DB가 외부 API의 인질이 된다.

원칙은 한 줄로 줄어든다.

트랜잭션 안에는 DB 작업만. 외부 I/O(HTTP, 큐 dispatch, 이메일, 파일 업로드, 메시지 발송)는 트랜잭션이 끝난 후에.

이 한 줄이 코드 리뷰 체크리스트 가장 위에 핀으로 붙어있어야 한다. 그리고 가능하면 큐 커넥션 설정에 'after_commit' => true를 박아두자. 사람이 매번 기억하는 것보다 환경이 막아주는 게 항상 더 안전하다.