kakao

잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정 (새 탭에서 열림)

KIMS의 메시지 상태가 SENT에 멈춘 원인은 벤더사의 전송 결과 리포트가 메시지 레코드의 DB 커밋보다 먼저 도착하는 경쟁 조건이었다. 특히 응답이 빠른 특정 벤더와, 과금 후처리로 트랜잭션이 길어진 유료 메시지에서 문제가 집중되었으며, 전체 메시지의 약 0.02%에서 발생했다. 트랜잭션 범위를 줄이고 각 보장의 필요성을 재검토하는 과정에서, 불필요한 장기 트랜잭션과 외부 이벤트 발행을 분리해야 한다는 결론에 도달했다.

KIMS 메시지 처리 구조

  • KIMS는 카카오 내부 서비스용 SMS 전송 플랫폼으로, 하루 약 100만 건을 처리한다.
  • 다수의 IDC와 MSA 컴포넌트, 여러 외부 SMS 벤더로 구성되어 있다.
  • 기본 흐름은 다음과 같다.
    • API Server가 품질 지표를 기준으로 벤더를 선택한다.
    • 벤더 호출 후 메시지를 SENT 상태로 DB에 기록한다.
    • 벤더가 전송 결과 리포트를 전달한다.
    • Report Server가 메시지를 REPORTED 상태로 갱신한다.
  • 각 단계가 비동기적으로 분리되어 있어, 처리 순서가 뒤집힐 가능성이 존재했다.

SENT 상태에 멈춘 메시지

  • 일부 메시지가 리포트를 수신했음에도 REPORTED로 갱신되지 않았다.
  • Report Server 로그에는 벤더 리포트 수신 기록이 남아 있었다.
  • 즉, 리포트가 네트워크에서 유실된 것이 아니라 DB 반영 전에 폐기된 상황이었다.
  • 문제가 전체 메시지가 아닌 약 0.02%에서만 발생해 재현과 원인 분석이 어려웠다.

빠른 벤더와 긴 트랜잭션이 만든 경쟁 조건

  • 리포트 누락은 특정 벤더로 전송된 메시지에서 집중적으로 발생했다.
  • 해당 벤더는 API 호출 후 평균 약 20ms, 문제 사례에서는 약 8ms 만에 리포트를 보냈다.
  • 반면 API Server는 벤더 호출 이후 후처리와 DB 영속화를 진행하고 있었다.
  • 유료 메시지는 과금 이벤트 발행 로직까지 하나의 @Transactional 범위에 포함되어 트랜잭션이 더 오래 유지됐다.
  • 결과적으로 처리 순서가 다음처럼 역전될 수 있었다.
    • API Server가 벤더 호출
    • 과금 후처리와 이벤트 발행 수행
    • 벤더가 리포트 전송
    • Report Server가 상태 갱신 시도
    • 아직 메시지 레코드가 DB에 없어 리포트가 유효하지 않은 것으로 판단되어 Drop
    • API Server가 뒤늦게 메시지를 DB에 저장
  • 메시지의 초기 상태를 기록하는 Write 경로와 리포트를 읽어 상태를 갱신하는 Read 경로가 같은 시점에 교차하면서 Race Condition이 발생했다.

트랜잭션 범위 줄이기

  • 기존 트랜잭션에는 DB 상태 변경뿐 아니라 Kafka 이벤트 발행 등 비즈니스 로직도 포함되어 있었다.
  • 이로 인해 Long-lived Transaction이 발생하고 DB Commit 시점이 지연됐다.
  • 과금 이벤트 발행을 @Async, @TransactionalEventListener 기반으로 분리해 커밋 이후 별도 스레드에서 실행하도록 변경했다.
  • 트랜잭션 책임을 상태 변경과 DB 영속화까지로 제한했다.
  • 평균 커밋 시점이 약 10ms 앞당겨졌고, 리포트 누락도 눈에 띄게 감소했다.
  • 외부 이벤트 발행 실패 때문에 DB 트랜잭션 전체가 롤백되는 Dual-write 문제도 완화됐다.
  • 외부 시스템으로 보내는 이벤트는 DB 트랜잭션과 본질적으로 동시에 롤백할 수 없으므로, 트랜잭션 내부에 무리하게 포함하는 방식은 부적절했다.

트랜잭션 보장의 필요성 재검토

글은 단순히 트랜잭션을 짧게 만드는 데서 그치지 않고, 해당 업무에 트랜잭션 자체가 필요한지 질문한다. MySQL의 REPEATABLE READ에서 제공되는 보장을 세 가지로 나누어 검토했다.

  • 원자성
    • 여러 쓰기 작업 중 일부만 성공하는 상황을 막고 전체를 롤백하는 기능이다.
    • 해당 로직의 DB 쓰기는 사실상 단일 작업이었다.
    • 여러 테이블이나 레코드에 걸친 원자성이 필요하지 않다면 트랜잭션의 Abortability는 필수적이지 않을 수 있다.
  • 읽기 격리
    • 라우팅 벤더 정보와 실시간 품질 지표를 조회하고 있었다.
    • 품질 지표는 분 단위로 갱신되며, 1분 전 데이터가 사용되어도 문제가 없었다.
    • 서로 독립적인 메타데이터를 조회하므로 동일한 스냅샷에서 읽어야 할 필요도 크지 않았다.
  • 쓰기 격리
    • 변경 내용을 커밋 전까지 다른 트랜잭션에 노출하지 않는 보장이다.
    • JPA/Hibernate의 Dirty Checking에서는 변경 사항이 먼저 Persistence Context의 1차 캐시에 반영되고, 실제 DB Write는 트랜잭션 종료 시점까지 지연될 수 있다.
    • 이 지연이 리포트 처리보다 DB 저장이 늦어지는 직접적인 원인이 되었다.

실용적인 설계 교훈

  • 외부 시스템의 응답 시간이 매우 짧을 수 있다는 전제에서 비동기 흐름을 설계해야 한다.
  • “호출 후 DB 저장”처럼 순서를 가정한 구조는 벤더별 응답 편차 때문에 경쟁 조건을 만들 수 있다.
  • 트랜잭션에는 반드시 원자적으로 처리해야 하는 DB 작업만 포함하고, 이벤트 발행·외부 API 호출·긴 후처리는 분리하는 것이 좋다.
  • 트랜잭션의 원자성·읽기 격리·쓰기 격리가 실제 비즈니스 요구사항인지 각각 검토해야 한다.
  • 리포트 처리에서는 아직 원본 메시지가 저장되지 않은 경우를 단순 Drop하지 말고, 재시도·지연 큐·Transactional Outbox 같은 보완책을 함께 고려해야 한다.