Spring

트랜잭션을 쪼갰더니 잠들어 있던 버그가 깨어났다

  • #AI
  • #Claude Code
  • #트랜잭션
  • #동시성
  • #Spring

문제: 최악 41시간짜리 단일 트랜잭션

운송 구간의 거리를 외부 API로 받아오는 배치가 있다. 이 배치의 트랜잭션 경계가 잘못돼 있었다.

  • 구조 — resolveAll@Transactional 하나로 전량을 감쌈
  • 상한 — run 당 최대 10,000건
  • 외부 API read timeout — 15초
  • 이론상 최악 — 10,000 × 15초 = 41시간짜리 단일 트랜잭션

그동안 DB 커넥션 1개를 계속 붙잡는다. 실제로 41시간이 걸릴 일은 없지만, 문제는 길이 자체가 아니라 실패 시 전량 롤백이다. 9,999건을 처리하고 마지막에 죽으면 전부 날아간다.

조치: 200건 청크 커밋

트랜잭션을 200건 단위로 쪼갰다. 여기서 두 가지 함정을 만났다.

함정 1 — 같은 클래스의 @Transactional은 안 걸린다

// 이렇게 하면 트랜잭션이 아예 안 걸린다
public void resolveAll() {
    processChunk(ids);   // 같은 클래스 메서드 직접 호출
}

@Transactional
void processChunk(List<Long> ids) { ... }

Spring의 @Transactional프록시로 동작한다. 외부에서 빈을 통해 호출될 때만 프록시를 거치고, 같은 클래스 안에서 직접 부르면 프록시를 우회한다.

무서운 건 에러가 안 난다는 점이다. 트랜잭션이 안 걸린 채 그냥 실행되고, 그러면 더티체킹(영속 상태 엔티티의 변경을 감지해 자동 반영하는 기능)이 죽는다.

  • 증상 — 좌표 backfill이 조용히 유실
  • 예외 — 없음
  • 로그 — 없음

TransactionTemplate을 써서 프로그래밍 방식으로 트랜잭션을 열었다. 같은 이유로 다른 배치가 이미 쓰던 패턴이라 그대로 따랐다.

함정 2 — 청크가 바뀌면 엔티티가 detached된다

// run 전체에서 한 번만 로드하면?
var siteIndex = loadSiteIndex();
for (var chunk : chunks) {
    process(chunk, siteIndex);   // 2번째 청크부터 detached
}

ensureCoordinatessave() 없이 더티체킹에 기대는 구조였다. 그런데 청크마다 트랜잭션이 새로 열리므로, 첫 청크의 트랜잭션이 끝나는 순간 그 엔티티들은 detached(영속성 컨텍스트에서 분리된) 상태가 된다.

detached 엔티티를 고쳐도 DB에는 반영되지 않는다. 그리고 이것도 에러가 안 난다.

  • 조치 — siteIndex청크마다 재로드
  • 같은 이유로 — side행도 청크 트랜잭션 안에서 다시 로드
  • 오케스트레이터가 들고 있는 것 — id 목록만

두 함정의 공통점이 있다. 둘 다 예외를 던지지 않고 조용히 아무 일도 하지 않는다. 테스트가 없으면 “왜 저장이 안 되지”를 한참 헤맨다.

얻은 것

원자성 단위가 바뀌었다.

  • 이전 — run 전체가 전부 성공하거나 전부 실패
  • 이후 — 청크 단위. 중간 장애 시 커밋된 청크는 남는다
  • 재실행 — 남은 pending만 처리하므로 멱등

그런데 이 최적화가 버그를 깨웠다

청크 분할을 배포하고 나서 이중 과금이 발생했다.

시나리오는 이렇다.

  • 사용자가 “거리 취득” 버튼을 두 번 누른다
  • run A와 run B가 각각 시작
  • pending 필터는 트랜잭션 밖에서 1회 도는 스냅샷 — A와 B가 같은 id 집합을 잡는다
  • A가 청크를 커밋
  • B가 findAllById로 그 행을 다시 로드 — 이미 success인데 상태를 확인하지 않는다
  • B가 또 처리 → Google/EcoTransiT에 같은 구간을 두 번 과금하고 A의 결과를 덮어쓴다

여기까지는 흔한 동시성 버그다. 흥미로운 건 왜 지금 터졌는가이다. 커밋 메시지에 이렇게 적었다.

이중 과금 자체는 전부터 가능했지만(외부 호출은 늘 트랜잭션 밖이었다) 예전엔 긴 단일 트랜잭션의 쓰기 경합이 두 번째 run 을 넘어뜨려 주었다. Task 17 의 빠른 청크 커밋이 그 마찰을 없애면서 이제는 두 run 이 조용히 끝까지 돈다 — 그래서 지금 고친다.

버그는 처음부터 있었다. 다만 긴 트랜잭션이 만들던 쓰기 경합(lock 충돌)이 두 번째 run을 중간에 죽여서, 우연히 방어막 역할을 하고 있었다.

성능을 고쳤더니 그 성능 문제가 감춰주던 정확성 문제가 드러난 것이다. 이런 종류의 방어막은 세 가지 성질을 갖는다.

  • 의도된 게 아니다 — 아무도 “락 경합으로 중복을 막자”고 설계하지 않았다
  • 문서에 없다 — 그래서 제거할 때 아무도 위험을 모른다
  • 제거하는 변경이 좋은 변경이다 — 긴 트랜잭션은 어차피 고쳐야 했다

수정: 확인을 트랜잭션 안으로

// 청크 안에서 pending 인 행만 처리한다

한 줄로 요약되지만 핵심은 위치다. 스냅샷 시점의 pending 목록을 믿지 않고, 청크 트랜잭션 안에서 다시 확인한다.

청크 안에서 pending 인 행만 처리한다. 확인이 트랜잭션 안이어야 의미가 있다.

트랜잭션 밖에서 “이 행은 아직 처리 안 됐네”를 확인해봐야, 그 사이에 다른 run이 처리해버리면 소용없다. 확인과 처리가 같은 트랜잭션 안에 있어야 그 사이에 끼어들 틈이 없다.

부수 문제: 카운터가 조용히 안 맞는다

수정하면서 하나 더 나왔다. findAllById의 성질 때문이다.

  • 동작 — 존재하지 않는 id는 예외 없이 그냥 빠뜨린다
  • 발생 경로 — 재업로드 시 side행을 지우고 다시 만드는 로직이 있다
  • 결과 — 요청한 건수보다 처리된 건수가 적다

여기에 위의 “pending 아니라서 스킵”까지 겹치면, 요약 카운터 합계가 대상 건수보다 작아지는데 이유를 알 방법이 없다.

이상적인 해법은 카운터를 세분화하는 것이다. 하지만 못 했다.

  • 제약 — TransportDistanceSummary프론트엔드가 소비하는 타입
  • 필드를 늘리면 — FE 계약 변경이 따라온다
  • 판단 — 이번 범위를 넘어섬

대신 관측 가능성만 확보했다.

  • 요청 건수 / 처리 건수와 그 내역(행 없음 · pending 아님)을 WARN으로 기록
  • 합계가 안 맞을 때 로그를 보면 어느 쪽으로 빠졌는지 알 수 있다

완전한 해법은 아니지만, “숫자가 이상한데 원인 불명”에서 “숫자가 이상하고 원인은 로그에 있음”으로 옮겼다.

로그를 테스트하기

여기서 한 가지 더 있었다. 로그 자체가 검증 대상이 됐다. 요약 응답으로는 관측이 안 되니, 로그가 제대로 남는지 테스트해야 한다.

테스트가 appender를 직접 붙였고, 그 과정에서 스택 문제를 만났다.

  • 스택 — logback이 아니라 log4j2
  • 이 단위 테스트 — 스프링 컨텍스트를 띄우지 않음 → log4j2-spring.xml이 안 먹는다
  • 조치 — 테스트에서 레벨도 함께 낮춤
  • 의존성 — log4j-core가 공통 모듈에 implementation 스코프라 전이되지 않음 → 이 모듈에 testImplementation으로 선언

implementation으로 선언된 의존성은 그 모듈을 쓰는 쪽으로 전파되지 않는다. 캡슐화 관점에선 맞는 설정이지만, 테스트에서 그 라이브러리를 직접 만져야 할 때는 따로 선언해야 한다.

남는 교훈

이번 작업을 순서대로 보면 이렇다.

단계 한 일 결과
1 41시간 트랜잭션을 200건 청크로 분할 원자성·복구성 개선
2 배포 이중 과금 발생
3 원인 추적 버그는 원래 있었고 락 경합이 가리고 있었음
4 확인을 트랜잭션 안으로 이동 해결

1단계는 옳은 변경이었다. 되돌리지 않았고, 되돌려서도 안 된다. 문제는 그 변경이 무엇을 노출시키는지 예측하지 못한 것이다.

지금은 성능 최적화를 할 때 이걸 같이 본다.

  • 이 최적화가 없애는 대기·경합·직렬화가 무엇인가
  • 그 대기가 우연히 보호하고 있던 것은 없는가
  • 특히 외부 호출·결제·알림처럼 되돌릴 수 없는 부수효과가 그 안에 있는가

“느려서 안 터지던 것”은 빨라지면 터진다. 트랜잭션을 짧게 만들고, 순차를 병렬로 바꾸고, 락을 줄이는 변경은 전부 여기 해당한다. 성능 작업의 리뷰 포인트가 성능이 아니라 동시성인 이유다.