transaction-management

1 개의 포스트

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 같은 보완책을 함께 고려해야 한다.