잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정 - tech.kakao.com

 

잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정 - tech

상상해 보세요. 친구가 당신에게 편지를 보냈습니다. 보냈다는 친구의 말도 맞고, ...

tech.kakao.com

 

상상해본다. 친구가 당신에게 편지를 보냈다.

보냈다는 친구의 말도 맞고, 우체국의 발송 기록도 존재한다. 

집 앞에는 집배원이 다녀간 흔적까지 남아있다.

그런데 정작 우편함 안에는 편지가 없다.

지난달, KIMS(Kakao Integrated Messaging Service)에서 비슷한 일이 실제로 발생했다.

 

카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정

KIMS는 카카오 내부 서비스에서 사용되는 SMS 전송 플랫폼.

하루 약 100만 건의 메시지를 처리

다수의 IDC 환경에 분산된 MSA 기반 시스템으로 운영되고 있음.

  • IDC(Internet Data Center): 기업의 서버, 네트워크, 데이터 저장 장치 등을 안전하게 모아서 관리하는 물리적인 데이터 센터(서버실)을 의미
  • 분산됨: 여러 지역의 IDC나 여러 대의 서버 장비에 나누어져서(분산되어) 시스템이 설치되어 있다는 의미
  • 이유: 특정 서버나 한 지역의 IDC에 화재나 지진 같은 재해가 발생하더라도, 다른 지역의 서버가 살아있어 서비스가 24시간 중단 없이 안정적으로 운영(고가용성) 되도록 하기 위함이다.

 

  • MSA(Microservice Architecture): 시스템을 기능별로 작게 쪼개서 개발하고 운영하는 설계 방식
  • 쪼개진 각각의 서비스들은 서로 독립된 프로그램이며, 자기만의 데이터베이스(DB)를 따로 가진다. 이 작은 서비스들이 서로 API(통신)을 주고받으며 하나의 거대한 쇼핑몰처럼 작동하게 된다.
  • 이유
    • 빠른 업데이트: 배송 기능에 버그가 있어서 수정할 때, 전체 쇼핑몰을 껐다 켤 필요 없이 '배송 서비스'만 고쳐서 새로 배포하면 된다.
    • 유연한 확장: 블랙프라이데이터 같은 이벤트로 인해 '주문/결제'에 트래픽이 폭주하면, 전체 시스템을 늘릴 필요 없이 '주문/결제 서비스'용 서버만 집중적으로 늘려주면 된다.
  • 결론: 대형 데이터 센터(IDC)들이 여러 서버에 시스템을 나누어 배치(분산)해 두었고, 그 시스템은 기능별로 쪼개진 작은 서비스들(MSA)이 서로 협력하며 돌아가는 구조로 만들어져 있다.

또한, 메시지의 안정적인 전송을 위해 다수의 외부 SMS 벤더사와 연동되어 있다.

1. 외부 SMS 벤더사

  • 벤더사는 공급업체, 즉 서비스를 제공하는 기업을 의미함.
  • 외부 SMS 벤더사는 우리가 흔히 아는 대량 문자 발송 전문 업체를 의미. 통신사(SKT, KT, LGU+)와 직접 연결되어 대량으로 문자를 보내주는 통로 역할을 하는 회사들 의미

2. 다수의 벤더사와 연동되어 있다.

  • 문자 발송 시스템을 구축할 때 한 회사하고만 계약해서 쓰는 것이 아니라, A사, B사, C사 등 여러 업체와 시스템을 동시에 연결(연동)해 두었다는 의미

3. 메시지의 안정적인 전송을 위해

  • 안정성(백업 구조) 때문
  • 장애 대비(Failover): 만약 A사 시스템에 갑자기 화재가 나거나 서버 오류가 발생해서 문자가 안 보내진다면? 시스템이 즉시 B사나 C사로 경로를 틀어서(우회해서) 문자를 보낸다. 사용자는 서비스에 장애가 발생한지도 모르고 인증 번호 또는 알림 문자를 정상적으로 받게 된다.
  • 부하 분산(Traffic Load Balancing): 대량의 공지 문자나 광고 문자를 한 번에 보낼 때, 한 업체에만 밀어 넣으면 처리가 늦어질 수 있음. 이때 여러 벤더사로 문자를 나누어 보내면 훨씬 빠르고 안정적으로 전송가능.
  • 비용 절감 및 효율성: 벤더사마다 통신사별 또는 문자 종류별(단문 SMS/ 장문 LMS/ 이미지 MMS)로 가격이나 전송 성공률이 조금씩 다를 수 있다. 상황에 따라 가장 유리한 곳을 골라서 보낼 수 있는 기반이 된다.

 

 

메시지 전송 요청이 들어오면, 내부적으로 여러 단계를 거쳐 메시지를 처리하게 된다.

카카오 및 카카오 공동체의 다양한 서비스로부터 메시지 전송 요청이 들어오면,

1. API Server는 실시간 품질 지표를 기준으로, 여러 벤더사 중 가장 적합한 곳으로 메시지 전송 요청을 라우팅한다.

2. 벤더사 호출 이후, 메시지를 SENT 상태로 DB에 기록한다.

3. 메시지는 실제 단말 사용자에게 전달된다.

4. 이후 벤더사는 어떤 메시지가 언제, 어떤 결과로 전달되었는지에 대한 전송 결과 리포트를 우리에게 보내준다.

5. 리포트를 수신하면, 메시지의 상태를 REPORTED 상태로 DB에 업데이트한다.

 

KIMS의 메시지 처리 흐름은

1. 전송요청

2. 벤더 호출

3. 리포트 수신

4. 상태 확정

단계를 거치며, 각 단계는 비동기적으로(독립적으로) 분리된 컴포넌트에서 수행됨.

이 구조는, 모든 단계가 우리가 기대한 순서대로 동작한다면, 문제없이 잘 동작한다.

 

리포트가 어디갔지?

일부 메시지의 상태가 REPORTED로 갱신되지 않은 채, SENT 상태로 그대로 머물러 있었다.

처음에는 벤더사로부터 리포트를 받지 못했을 가능성을 의심했지만, Report Server 로그를 확인한 결과, 리포트 수신 로그는 남아 있었다.

즉, 벤더사로부터 리포트는 정상적으로 수신되었지만, 그 결과를 회사의 DB에 반영되지 못한 채, 메시지 상태가 SENT 상태로 멈춰 있던 상황이었다.

 

실종된 리포트의 특징?

 

동시성(Concurrency) 문제의 원인을 규명하는 일은 본질적으로 어렵다.

특히 문제 발생 빈도가 낮고, 특정 조건에서만 간헐적으로 드러나는 경우라면 분석 난이도는 더욱 높아진다.

이러한 동시성 버그는 실행 순서와 타이밍에 강하게 의존한다.

그래서 단위 테스트나 로컬 환경에서는 재현되기 어렵고, 운영 환경에서 여러 조건이 우연히 맞아떨어질 때에만 관측되는 경우가 많다.

 

다행히 분석이 가능할 정도의 누락 건이 충분히 축적되어 있었고, 누락 건들 사이에 몇 가지 공통 패턴이 있었다.

문제 1. 리포트 누락 건은 특정 벤더사로 전송된 메세지에 한정되어 관측됨.

문제 2. 메시지 유형 중에서는 무료 메시지가 아닌, 과금 대상 유료 메시지에서 주로 발생함.

 

리포트 누락은 전체의 극히 일부인 약 0.02% 에서만 발생했음.

전체가 아닌, 제한된 조건에서, 극히 일부에서만 이슈가 발생하고 있다는 점이 원인 분석과 디버깅을 더욱 어렵게 만듦

 

왜 특정 조건에서만, 이렇게 선택적으로 리포트 누락이 발생했을까?

 

로그의 단서 찾기

문제 1: 리포트 누락 건은 특정 벤더사로 전송된 메시지에 한정되어 관측되었음.

 

애플리케이션 로그를 분석해본다.

아래는 리포트 누락이 발생한 시점의 로그를 시간 순으로 정리한 것이다.

// (API Server) 벤더 API 호출 성공, 토큰 수신 완료 (아직 DB에 영속화되지 않음)

2025-11-21T15:43:21.362+09:00 INFO API Server : [VENDOR_RESPONSE] SMS sent successfully, token acquired (token=aaaa, persistence=PENDING)

// (API Server) 과금 단계: Kafka 이벤트 발행 완료 (여전히 DB 미영속 상태)

2025-11-21T15:43:21.370+09:00 INFO API Server : [POST_PROCESS] Kafka event published for SENT message (token=aaaa, persistence=PENDING)

// ✅ 21.370초 이후의 어느 시점에 레코드 영속화가 이루어짐

// (Report Server) 벤더사 Report 진입 시점 (API Server 후처리와 동일 타임스탬프)

2025-11-21T15:43:21.370+09:00 INFO Report Server : [ENTRY] DeliveryReportController invoked (token=aaaa)

// (Report Server) 메시지 조회 실패, 저장 실패

2025-11-21T15:43:21.372+09:00 WARN Report Server : [LOOKUP_FAIL] Delivery report received but message not found (vendor=B, token=aaaa)

 

일반적으로 다른 벤더사들은 메시지 처리를 마치고 리포트를 전달하기까지 평균 1초 이상이 걸리는 반면,

문제가 생긴 벤더사는 평균 약 20ms 로 리포트 전달 시점이 상대적으로 매우 빠른 편이었다.

특히 리포트 누락이 발생한 사례들만 모아보면, 리포트 유입까지의 평균 지연 시간은 약 8ms로, 전체 평균보다도 훨씬 짧았다.

그 결과, 메시지에 대한 후처리와 DB 영속화를 완료되기도 이전에, 리포트가 먼저 시스템에 도착하는 상황이 발생함.

이로 인해 메시지의 Write 경로와 Read 경로가 동일한 시점에 교차하게 되었고, 극단적으로 짧은 타이밍에서 경쟁 조건이 발생하게 되었던 것이다.

 

문제 2: 메시지 유형 중에서는 무료 메시지가 아닌, 과금 대상 유료 메시지에서 주로 발생했다.

과금 대상인 유료 메시지의 경우, 과금 이벤트 발행을 위한 추가 후처리 로직이 존재했다.

벤더 호출부터 해당 후처리 단계까지의 전 과정이 하나의 @Transactional 범위로 묶여 단일 트랜잭션 내에서 실행되고 있었다.

그 결과 무료 메시지에 비해 트랜잭션 수행 시간이 길어졌고, DB Commit 시점도 자연스럽게 지연되고 있었다.

이로 인해 유료 메시지에서는 메시지가 DB에 영속화되기 전에 전송 결과 리포트가 먼저 도착할 가능성이 더 커지고 있었다.

아이러니하게도, 과금을 정확히 처리하기 위해 추가한 로직이 오히려 과금 대상 메시지에서 문제를 유발하고 있었다.

 

1. API Server: 실시간 품질 지표를 기준으로, 여러 벤더사 중 가장 적합한 곳으로 메시지 전송 요청을 라우팅한다.

2. 여기서 과금을 위한 후처리 작업이 수행

3과 4 메시지에는 실제 단말 사용자에게 전달되고, 벤더사는 전송 결과 리포트를 우리에게 보내준다.

5. 리포트를 수신한 Report server는 메시지 상태를 REPORTED 로 업데이트하려고 시도한다.

-그러나 이 시점에는 아직 메시지 레코드가 DB에 존재하지 않아, 해당 리포트는 유효하지 않은 리포트로 판단되어 Drop 된다.

6. API Server는 과금 이벤트 발행을 마친 뒤에 비로소 메시지 레코드를 DB에 영속화한다.

 

그렇다면 이제 이 구조적인 경쟁 조건을 어떻게 제거 ?

 

첫번째 조치: 트랜잭션을 다이어트해보자.

트랜잭션을 가볍게 만드는 것.

기존 구조에서는 Kafka 이벤트 발행과 같은 비즈니스 로직이 트랜잭션 내부에서 함께 실행되며 Long-lived Transaction을 유발했고, 그 결과 DB 영속화 시점이 불필요하게 지연되고 있었다. 이를 개선하기 위해, 트랜잭션 내에서 반드시 필요하지 않은 로직을 분리한다.

 

이 변경으로 트랜잭션의 책임 범위는 상태변경과 DB 영속화(커밋)으로 한정되어, 보다 가벼워질 수 있었다.

부수적인 효과로, 이벤트 발행 실패 시 트랜잭션 자체가 롤백되는 Dual-write 문제 역시 함께 해소되었다.

개념상 롤백될 수 없는 외부 이벤트 발행을 트랜잭션 내부에 두고 있었던 이 구조 자체도 부적절했던 것이다.

 

우리는 정말 트랜잭션이 필요?

문제를 해결하는 과정에서 한걸음 물러나, 트랜잭션 자체가 우리의 비즈니스 요구사항에 적절한 선택이었는지를 처음부터 검토해보았다.

트랜잭션은 물론 강력한 도구이지만, 모든 상황에서 항상 필요하지는 않으며, 때로는 오히려 성능 저하와 복잡성 증가를 초래하기에, 트랜잭션인 보장을 완화하거나 아예 사용하지 않는 것이 이득일 수 있다.

 

본 회사에서 시스템은 MySQL 의 기본 격리 수준인 REPEATEABLE READ 를 사용하고 있었고, 일반적으로 해당 격리 수준에서 트랜잭션을 도입하는 이유는 3가지로 정리 가능.

1. 원자성: 작업 도중 문제가 발생했을 때, 전체 변경을 롤백할 수 있는 보장이다.

2. 읽기 격리: 다른 트랜잭션의 중간 상태를 읽지 않도록 보호한다.

3. 쓰기 격리: 커밋되기 전까지는 다른 트랜잭션에서 해당 변경사항을 볼 수 없도록 한다.

 

이제 하나씩 질문을 던져본다.

 

<원자성>

Abortability가 필요한 상황인가?

여기서 말하는 원자성은, 여러 개의 쓰기 작업 중 일부만 수행된 상태에서 장애가 발생했을 때 전체 변경을 abort 하고, 지금까지 실행한 쓰기를 취소할 수 있는 것을 의미한다.

하지만 시스템의 특성을 다시 살펴보면, 이 트랜잭션 안에서 수행되던 쓰기 연산은 단 하나였다.

 

여러 테이블에 걸친 동기 쓰기나, 크로스 레코드 원자성을 보장해야 하는 구조는 아니었다. 즉, 부분 성공을 허용하지 않기 위해 반드시 트랜잭션이 필요한 시나리오는 아니었다.

 

<읽기 격리>

일관된 읽기가 필요한 상황인가?

해당 트랜잭션에서는 상태 변경에 앞서, 몇 개의 메타데이터 테이블을 조회하고 있었다.

예를 들어 현재 라우팅 대상 벤더의 정보나, 벤더별 실시간 품질 지표 같은 값들이었다.

다만 이 데이터들의 특성을 살펴보니, 강한 시점 일관성(strict freshness)이 요구되는 정보는 아니었다.

 

벤더 품질 지표는 분 단위로 갱신되고 있었고, 최악의 경우 1분 전 스냅샷이 사용되더라도 비즈니스적으로 문제가 없는 수준이었다.

 

또한 조회 대상 테이블들 간에도 강한 결합 관계가 없었다.

즉, 서로 독립적인 메타데이터를 읽는 구조였기 때문에, 동일한 스냅샷에서 일관되게 격리하여 읽어야 할 필요성이 크지 않았다.

우리의 상황에서 읽기 격리를 위해 트랜잭션을 유지해야 할 명확한 이유를 찾기 어려웠다.

 

<쓰기 격리>

이 변경이 정말 다른 트랜잭션에게 숨겨야 할 변경이었을까?

트랜잭션이 커밋되기 전까지 변경 사항을 다른 트랜잭션에서 관측하지 못하도록 하는 특성.

본 회사 시스템에서는 Hibernate 기반의 JPA를 사용하고 있었고, @Transactional 아래에서는 Dirty Checking 메커니즘을 따라, 변경 사항이 Persistence Context(1st-level Cache)에만 반영된 채, 트랜잭션 종료 시점까지 실제 DB Write가 지연되고 있었다.

 

리포트 누락은 Report Server가 리포트를 빠르게 수신한 상황에서, API Server에서 수행된 상태 변경(초기 상태->SENT)이 아직 DB에 커밋되지 않았을 때 발생했다.

 

@Transactional 으로 보호된 API Server의 상태 변경은 Dirty Checking에 의해 트랜잭션 종료 시점까지 DB에 반영되지 않았고, 이로 인해 리포트 수신 시점과 메시지 레코드 커밋 시점 사이의 간극이 더욱 벌어졌다.

그 결과, Report Server는 리포트를 정상적으로 수신했음에도 불구하고, 해당 시점에는 메시지 레코드가 아직 DB에 존재하지 않아 조회에 실패하게 되었다.

 

이 경로에서의 트랜잭션 경계와 커밋 지연은 리포트 수신 타이밍과 맞물리며, Write 경로와 Read 경로 간의 타이밍 충돌을 증폭시키는 요인으로 작용하고 있었다.

 

이 3가지 측면을 종합한 결과, 해당 경로의 트랜잭션 유지가 불필요하다고 결론지었다.

 

두번째 조치: 트랜잭션을 풀다

발생 가능성이 낮은 시나리오를 대비해 시스템을 과도하게 복잡하게 만들 경우, 그에 따른 비용과 제약은 빠르게 증가하는 반면 실제로 얻는 이점은 적을 수 있다.

 

결론적으로, 성공적으로 상태 값 영속화 시점을 앞당길 수 있었고, 그 결과 리포트 누락 건수가 약 40% 까지 감소했다.

 

한계는 존재했다.

트랜잭션을 아무리 가볍게 만들지라도 DB 영속화까지 소요되는 시간 자체를 0으로 만들 수는 없었고, 이 구조가 유지되는 한 Write 경로와 Read 경로가 경쟁하는 상황은 언제든지 다시 발생할 수 있다.

 

Race Condition의 발생 확률을 늦출 뿐, 문제를 구조적으로 제거하는 해결책으로 보기 어렵다.

 

이제는 확률을 낮추는 접근이 아닌, 경쟁 자체가 발생하지 않는 구조를 만들자.

 

세번째 조치: Transactional Outbox Pattern 도입하기

경쟁 자체가 발생하지 않는 구조 만들기가 목표이다.

결론은 아웃박스(Outbox) 이다.

 

Report Server는 리포트를 받는 즉시 모두 Outbox 테이블에 먼저 기록하고, 이후 잃어버린 리포트는 별도의 워커가 Outbox를 기반으로, 리포트 적용을 재시하도록 구성하는 것이었다.

 

이를 통해, 온라인 처리 경로에서 일시적인 실패가 발생하더라도, 오프라인 경로에서 리포트가 재처리되어 누락되지 않도록 하는 것이다.

 

목적은 Outbox 기반 재처리 구조가 리포트 유실(DB 누락)을 구조적으로 제거할 수 있는지를 확인하는 것이었다.

결과는, 구조 적용 이후 DB 기준 리포트 누락은 0%로 줄었지만, 과금 이벤트 누락은 오히려 이전보다 더 눈에 띄게 증가하기 시작했다.

 

멱등성 가드와의 충돌

벤더사는 네트워크 지연이나 장애 상황에서 동일한 리포트를 여러번 전송하는 경우가 종종 있다.

 

그러나 동일한 리포트를 본 회사에서 2번 수신했다고 해서, 사용자에게 과금이 2번 발생해서는 안된다.

이를 방지하기 위해 Report Server에는 과금 이벤트를 최대 한 번만 발행하도록 보장하는, 원자적 Compare-and-set 연산 기반의 멱등성 가드가 구현되어 있었다.

 

문제는 Outbox 도입 이후, Report Server와 Report Replayer라는 2개의 처리 경로가 동시에 같은 데이터를 바라보며 상태 전이를 시도하게 되었다는 점이다.

 

분산 환경에서는 '누가 먼저 실행될지'를 가정할 수 없다.

앞선 사례에서는 이 가정을 벤더사가 깨뜨렸다면, 이번에는 시스템 내부의 요소가 그 가정을 깨뜨렸다.

 

우리는 배치 처리를 '안전망'으로 추가했지만, 결과적으로 2개의 Writer(At-most-once 시맨틱을 지키기 위한 멱등성 가드와 리포트 복구를 위한 배치 처리 경로)가 정면으로 충돌하는 구조를 만들고 말았다.

 

이처럼 실시간 처리와 과거 데이터 재처리가 동일한 데이터를 수정하는 구조에서는, 두 경로가 서로 간섭하지 않도록 보장하는 메커니즘이 필수적이다.

 

네번째 조치: Single Writer Principle

 

"같은 데이터에 대해 쓰기를 수행하는 주체는 하나만 두자."

 

우리가 포기한 것과 얻은 것

동시성 문제는 코드의 미세한 조정만으로 해결될 수 없으며, 경쟁 자체가 발생하지 않는 아키텍처로 전환할 때만 근본적으로 제거할 수 있다는 점을 깨닫게 되었다.

아키텍처는 언제나 트레이트오프의 연속이며, 트랜잭션 또한 만능 해법은 아니다.

무엇을 얻기 위해 무엇을 포기할 수 있는지를 명확히 인식하며 설계하자!!

+ Recent posts