이슈와해결

사라진 혜택, 중복된 포인트? Kafka 메시지 처리, 어디까지 믿어야 할까 (이벤트 드리븐 트러블슈팅 ep.2)

Renechoi 2025. 6. 1. 11:59

 

0. 이 글의 탄생 배경

지난 〈이벤트 드리븐 트러블슈팅〉 첫 번째 에피소드에서는 비동기 파이프라인의 타이밍 불일치 이슈를 살펴보았습니다. 구체적으로는 외부 결제 API(Google Play 등)와의 연동 과정에서 발생할 수 있는 'NOT_CONSUMED 상태의 영수증'과 같은 오류를 중심으로, 그 원인과 다양한 현실적인 해결 전략들을 공유드렸습니다.

 

https://upcurvewave.tistory.com/752

 

비동기 결제 파이프라인에서 구글 API가 늦을 때 생기는 일 (이벤트 드리븐 트러블슈팅 ep.1)

0. 이 글의 탄생 배경이 글은 〈이벤트 드리븐 트러블슈팅〉 시리즈의 첫 번째 포스팅입니다. 결제팀 백엔드 엔지니어로 일하면서 비동기 방식으로 결제 이벤트를 처리하다 보면 재밌는 이슈를

upcurvewave.tistory.com

 

 

이번 두 번째 에피소드에서는 시선을 시스템 내부, 즉 우리가 직접 통제하고 설계하는 영역으로 돌려보고자 합니다. 바로 이벤트 파이프라인의 핵심 구성 요소인 Kafka 메시지 처리 자체의 신뢰성 문제입니다. 아무리 외부 API와의 연동을 견고하게 설계했더라도, 정작 우리 내부의 메시지 처리 과정에서 이벤트가 유실되거나, 중복으로 처리되거나, 혹은 데이터의 정합성이 깨진다면 시스템 전체의 안정성은 모래성과 같을 것입니다.

 

이 글에서는 "혜택콕(BenefitKok)" 이라는 가상의 실시간 사용자 활동 기반 혜택 지급 플랫폼을 가정합니다. 사용자가 특정 활동을 완료하면 실시간으로 포인트나 쿠폰 등의 혜택을 지급하는 과정을 전제로, 이벤트 유실과 중복 지급과 같은 문제들을 살펴보고자 합니다.

 

본 사례 역시 업무를 하며 실제 운영 환경에서 겪은 사례를 모티브로 하나, 본문에 등장하는 아키텍처, 컴포넌트 명칭, 코드 예시 그리고 일부 도메인 관련 프로세스들은 실제 사내 구현과는 다르게 철저히 익명화되고 재구성된 가공 사례들임을 밝힙니다. 독자 여러분의 시스템 환경과 문제 상황에 대입하여 아이디어를 얻어 가시는 데 초점을 맞춰주시길 바랍니다.

 

이번 편이 다룰 핵심 이슈 

Kafka auto commit과 미흡한 오류 처리로 인해
중요 이벤트가 유실되거나 처리가 지연되고,
분산 환경에서의 동시성 제어 부재로
동일 혜택이 중복 지급되어
시스템 신뢰성과 데이터 정합성이 깨지는 문제

 


1. 문제 상황: "분명 미션을 완료했는데, 왜 포인트가 안 쌓이죠?"

"혜택콕" 플랫폼은 사용자가 앱 내에서 특정 미션(예: 특정 페이지 3회 방문, 특정 기능 사용 완료, 특정 상품 N개 이상 장바구니 담기 등)을 완료하면, 이를 실시간으로 감지하여 사전에 정의된 혜택(포인트, 할인 쿠폰, 전용 콘텐츠 접근 권한 등)을 지급하는 것을 핵심 기능으로 합니다.

 

서비스 초기, 비교적 트래픽이 적고 시스템 요구사항이 단순했을 때의 아키텍처는 다음과 같았습니다.

1.1 시스템 아키텍처 (Before)

sequenceDiagram
    participant Kafka as 카프카
    participant UserActivityEventConsumer as 활동 이벤트 컨슈머
    participant InternalPublisher as 내부 이벤트 발행기
    participant InternalHandler as 내부 이벤트 핸들러
    participant BenefitLogic as 혜택 지급 로직
    participant PromotionEngine as 혜택 규칙 엔진

    rect rgba(255, 165, 0, 0.1)
    Kafka-->>UserActivityEventConsumer: Consume "미션 완료 이벤트" (auto commit)
    end

    note over InternalPublisher,InternalHandler: Spring ApplicationEventPublisher 활용

    rect rgba(144, 238, 144, 0.1)
    UserActivityEventConsumer->>+InternalPublisher: 내부 이벤트 발행 (혜택 처리 요청)
    InternalPublisher-->>InternalHandler: 내부 이벤트 수신 및 처리 위임
    deactivate InternalPublisher
    InternalHandler->>BenefitLogic: 혜택 지급 조건 검증 및 실행 요청

    BenefitLogic->>BenefitLogic: 사용자 정보 및 최근 활동 내역 조회 (내부 DB)
    BenefitLogic->>PromotionEngine: "미션 ID" 기반, 현재 적용 가능한 혜택 규칙 질의
    PromotionEngine-->>BenefitLogic: 혜택 지급 대상 여부 및 상세 내용 응답
    end

    rect rgba(255, 182, 193, 0.1)
    alt 혜택 지급 대상인 경우 (e.g., 포인트 적립 대상)
        BenefitLogic-->>BenefitLogic: 사용자 계정에 포인트 적립 처리 (내부 DB 업데이트)

    else 혜택 지급 대상이 아닌 경우
        BenefitLogic--xBenefitLogic: 추가 처리 없음 (로그 기록)
    end
    end

    rect rgba(255, 165, 0, 0.1)
    UserActivityEventConsumer-->>Kafka: Offset Acknowledge (auto commit 주기 도래 시 자동 커밋)
    end

 

UserActivityEventConsumer는 Kafka로부터 "미션 완료 이벤트"를 수신합니다. 당시에는 개발 편의성과 빠른 구현을 위해 Kafka 컨슈머의 기본 설정 중 하나인 auto commit (enable.auto.commit=true) 방식을 사용하고 있었습니다.

 

카프카의 auto commit 설정은 auto.commit.interval.ms 값을 따로 지정하지 않으면, **기본값은 5000 밀리초(5 초)**입니다. 이 주기는 enable.auto.commit=true (역시 기본값)일 때만 적용되며, 컨슈머가 마지막 커밋 이후 5초 이상 경과한 상태로 poll() 을 호출하면 백그라운드 스레드가 오프셋을 브로커에 자동으로 커밋합니다. [1]

 

즉, 컨슈머가 poll()을 통해 메시지를 가져오면, auto.commit.interval.ms (기본 5초) 간격으로 가장 마지막에 반환된 오프셋을 자동으로 커밋하는 구조였습니다. 수신된 이벤트는 내부 이벤트 발행/핸들러를 거쳐 BenefitLogic으로 전달되고, 여기서 "혜택 규칙 엔진"과의 연동을 통해 실제 혜택 지급 여부를 결정하고 사용자 DB에 반영하는 흐름이었습니다.

1.2 문제 정의

이러한 구조에서 발생할 수 있는 문제들은 무엇이 있을까요?

 

다음과 같은 문제의 시나리오들이 가능합니다.

시나리오 1: 사라진 "미션 완료" 이벤트와 지급되지 않은 혜택 (메시지 유실)

가장 빈번하고 치명적인 문제였습니다. 사용자가 앱에서 특정 미션을 성공적으로 완료하면, 앱 클라이언트는 사용자의 userId, missionId 등이 담긴 "미션 완료 이벤트"를 Kafka 토픽으로 발행합니다. 정상적이라면 UserActivityEventConsumer가 이 이벤트를 수신하여 처리해야 합니다.

 

하지만, 다음과 같은 상황이 발생했습니다.

  1. UserActivityEventConsumer가 Kafka로부터 특정 "미션 완료 이벤트" 메시지를 성공적으로 poll 합니다.
  2. 메시지 처리 로직이 진행되어 BenefitLogic 내부에서 "혜택 규칙 엔진"(PromotionEngine)을 호출하거나, 사용자 DB에 포인트 적립을 시도합니다.
  3. 바로 이 시점에, "혜택 규칙 엔진"과의 통신에서 일시적인 네트워크 오류(e.g., ConnectTimeoutException)가 발생하거나, 사용자 DB 접근 중 예상치 못한 RuntimeException (e.g., DeadlockLoserDataAccessException)이 발생하여 BenefitLogic 또는 이를 호출한 InternalHandler에서 예외가 던져집니다.
  4. 만약 이 예외가 컨슈머 레벨까지 제대로 전파되어 처리되지 못하고 해당 컨슈머 스레드나 프로세스가 비정상적으로 종료되거나, 혹은 예외는 로깅만 되고 무시된 채 다음 poll()을 기다리는 사이, Kafka의 auto commit 타이머(auto.commit.interval.ms)가 만료됩니다.
  5. 결과적으로, Kafka는 해당 메시지가 성공적으로 처리되었는지 여부와 관계없이, 컨슈머가 poll()을 통해 마지막으로 가져간 메시지의 오프셋을 기준으로 커밋해 버립니다.

결국, 시스템은 해당 "미션 완료 이벤트"가 정상적으로 처리되었다고 착각하게 되고, 이 메시지는 다시는 컨슘되지 않아 영원히 유실됩니다.

 

사용자는 당연히 약속된 혜택을 받지 못했고, 이는 직접적인 고객 불만으로 이어졌습니다. 내부적으로는 "월평균 N만 건의 '미션 완료' 이벤트 중, 약 0.X%가 원인 불명의 혜택 미지급으로 CS 접수 또는 내부 데이터 불일치로 식별됨"과 같은 형태로 문제의 심각성이 드러났습니다.

 

<svg width="900" height="450" viewBox="0 0 900 450" xmlns="http://www.w3.org/2000/svg" style="font-family: 'Nanum Gothic', Arial, sans-serif; font-size: 13px; background-color: #f9f9f9;">

  <defs>
    <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#333"/>
    </marker>
    <style>
      .text-label { font-size: 12px; fill: #333; }
      .text-title { font-size: 14px; font-weight: bold; fill: #2c3e50; }
      .text-highlight { font-size: 12px; fill: #e74c3c; font-weight: bold;}
      .box { stroke: #7f8c8d; stroke-width: 1.5; fill: #ecf0f1; rx: 5; ry: 5; }
      .message { stroke: #3498db; stroke-width: 1; fill: #e1f5fe; rx:3; ry:3; }
      .lost-message { stroke: #c0392b; stroke-width: 1; fill: #fadbd8; rx:3; ry:3; text-decoration: line-through;}
      .arrow { stroke: #34495e; stroke-width: 1.5; marker-end: url(#arrowhead); }
      .dashed-arrow { stroke: #95a5a6; stroke-width: 1.5; marker-end: url(#arrowhead); stroke-dasharray: 4 2; }
    </style>
  </defs>

  <text x="450" y="30" text-anchor="middle" class="text-title">시나리오 1: Auto Commit으로 인한 메시지 유실 과정</text>

  <rect x="50" y="70" width="200" height="120" class="box"/>
  <text x="150" y="90" text-anchor="middle" class="text-label" style="font-weight:bold;">카프카 토픽 (Kafka Topic)</text>

  <rect x="350" y="150" width="200" height="60" class="box"/>
  <text x="450" y="180" text-anchor="middle" class="text-label" style="font-weight:bold;">활동 이벤트 컨슈머</text>
  <text x="450" y="195" text-anchor="middle" class="text-label" style="font-size:11px;">(UserActivityEventConsumer)</text>

  <rect x="350" y="280" width="200" height="80" class="box"/>
  <text x="450" y="300" text-anchor="middle" class="text-label" style="font-weight:bold;">혜택 지급 로직</text>
  <text x="450" y="315" text-anchor="middle" class="text-label" style="font-size:11px;">(BenefitLogic)</text>
  <text x="450" y="330" text-anchor="middle" class="text-label" style="font-size:11px;">&amp; 혜택 규칙 엔진</text>
  <text x="450" y="345" text-anchor="middle" class="text-label" style="font-size:11px;">(PromotionEngine)</text>

  <rect x="650" y="70" width="200" height="120" class="box"/>
  <text x="750" y="90" text-anchor="middle" class="text-label" style="font-weight:bold;">카프카 토픽 (결과)</text>

  <text x="70" y="120" class="text-label">이전 커밋 오프셋: O-1</text>
  <rect x="70" y="130" width="160" height="25" class="message"/>
  <text x="150" y="147" text-anchor="middle" class="text-label">메시지 M1 (오프셋 O)</text>
  <rect x="70" y="160" width="160" height="25" class="message"/>
  <text x="150" y="177" text-anchor="middle" class="text-label">메시지 M2 (오프셋 O+1)</text>

  <text x="670" y="120" class="text-highlight">커밋된 오프셋: O</text>
  <rect x="670" y="130" width="160" height="25" class="lost-message"/>
  <text x="750" y="147" text-anchor="middle" class="text-label">메시지 M1 (오프셋 O)</text>
  <rect x="670" y="160" width="160" height="25" class="message"/>
  <text x="750" y="177" text-anchor="middle" class="text-label">메시지 M2 (오프셋 O+1)</text>

  <line x1="250" y1="150" x2="350" y2="170" class="arrow"/>
  <text x="300" y="155" text-anchor="middle" class="text-label">1. 메시지 M1 Poll</text>

  <line x1="450" y1="210" x2="450" y2="280" class="arrow"/>
  <text x="480" y="245" text-anchor="start" class="text-label">2. M1 처리 시도</text>

  <circle cx="450" cy="320" r="30" fill="none" stroke="#e74c3c" stroke-width="3"/>
  <line x1="430" y1="300" x2="470" y2="340" stroke="#e74c3c" stroke-width="3"/>
  <line x1="470" y1="300" x2="430" y2="340" stroke="#e74c3c" stroke-width="3"/>
  <text x="510" y="325" text-anchor="start" class="text-highlight">3. 처리 중 오류 발생!</text>

  <path d="M 200 190 Q 250 250 430 220" stroke="#2c3e50" stroke-width="1.5" fill="none" marker-end="url(#arrowhead)" stroke-dasharray="5,3"/>
  <text x="310" y="230" text-anchor="middle" class="text-label" style="font-size:11px; fill: #2c3e50;">4. Auto Commit (타이머)</text>
  <text x="310" y="245" text-anchor="middle" class="text-label" style="font-size:11px; fill: #2c3e50;">오류와 무관하게 오프셋 O 커밋</text>


  <line x1="550" y1="180" x2="650" y2="180" class="arrow"/>
   <text x="600" y="170" text-anchor="middle" class="text-label">5. 결과</text>
  <text x="750" y="210" text-anchor="middle" class="text-highlight" style="font-size:12px;">M1 유실!</text>
  <text x="750" y="225" text-anchor="middle" class="text-label" style="font-size:11px;">(재시작 시 M2부터 처리)</text>

</svg>


시나리오 2: "혜택 규칙 엔진" 장애 시, 연쇄적인 시스템 마비 현상 (Lag 누적 및 처리 지연)

"혜택 규칙 엔진"은 외부 시스템으로 구축되어 있거나, 내부 시스템이라도 다른 팀에서 관리하는 별도의 컴포넌트일 수 있습니다.

 

이 엔진이 정기 점검, 버전 업데이트, 내부 버그 또는 갑작스러운 트래픽 증가 등으로 인해 몇 분에서 몇 시간 동안 응답이 현저히 느려지거나 아예 실패하는 상황이 발생했습니다.

  1. UserActivityEventConsumer는 평소와 다름없이 Kafka로부터 이벤트를 poll 합니다.
  2. BenefitLogic에서 "혜택 규칙 엔진"을 호출하지만, 타임아웃이 발생하거나 오류 응답을 받습니다.
  3. 만약 컨슈머가 이러한 오류를 적절히 처리하지 못하고 (예: 무한 루프에 가까운 내부 재시도 로직 실행, 또는 단순히 예외를 로깅만 하고 다음 메시지 처리를 시도하지 못함), 해당 메시지에 계속 머물러 있다면, 해당 컨슈머(정확히는 해당 컨슈머가 할당받은 파티션)의 처리는 완전히 중단됩니다.
  4. 새로운 "미션 완료 이벤트"들은 Kafka 토픽의 해당 파티션에 계속 쌓여만 가고, 컨슈머 Lag은 걷잡을 수 없이 증가합니다.
  5. 설상가상으로, auto commit이 설정되어 있더라도 컨슈머가 poll()을 호출하여 새로운 메시지 배치를 가져오지 못하면 오프셋 커밋 역시 발생하지 않으므로 (또는 에러로 인해 poll() 호출 후 예외 발생 시 커밋되지 않는 경우), 장애가 해결된 후에도 이미 처리했던 메시지를 중복으로 다시 처리하려는 시도가 발생할 수 있습니다 (이는 시나리오 3과도 연결됩니다).

결국 "혜택 규칙 엔진"의 일시적인 장애가 "혜택콕" 플랫폼 전체의 혜택 지급 지연 및 중단으로 확산되었고, 특정 파티션의 Lag이 눈덩이처럼 불어나 다른 정상적인 이벤트 처리까지 심각하게 방해하는 상황을 초래했습니다.

 

여기서 한 가지 궁금증이 들 수 있습니다.

왜 auto commit 옵션을 사용하는데도 lag가 발생한다는 거지?

Kafka의 auto commit은 auto.commit.interval.ms (기본 5초) 간격마다 실행되지만, 이는 컨슈머가 poll()을 호출할 때 마지막으로 성공적으로 반환된 오프셋을 기준으로 이루어집니다. 만약 컨슈머 스레드가 현재 메시지 처리 로직(예: "혜택 규칙 엔진" 호출 부분)에 묶여 다음 poll()을 호출하지 못한다면, 아무리 자동 커밋 주기가 도래해도 현재 처리 중이거나 그 이후의 오프셋에 대한 커밋은 이루어질 수 없습니다. 설령 poll()이 간헐적으로 호출되더라도, 특정 메시지에서 계속 예외가 발생하여 처리가 완료되지 못하면 해당 오프셋은 커밋되지 않을 가능성이 높습니다.

 

 

그렇다면 interval 간격을 최저로 설정하면 안되나요? 최저 수치는 몇인가요?

 

auto.commit.interval.ms 값은 음수가 아니면 모두 허용되므로 **가장 작은 유효 값은 0 ms**입니다. 0으로 설정하면 소비자가 poll() 을 호출할 때마다 “가능한 한 즉시” 오프셋을 커밋하려고 하므로, 사실상 매 폴마다 커밋이 일어납니다. [2][3]

 

 

그러면 최저 수치 0으로 설정하면 lag가 안발생하는 거 아닌가요?

auto.commit.interval.ms를 0으로 설정하면 poll() 호출 시마다 커밋을 시도하여 빈도는 높아집니다. 하지만, 컨슈머 스레드가 현재 메시지 처리 로직(예: "혜택 규칙 엔진" 호출 후 응답 대기 또는 내부 재시도)에 묶여 다음 poll()을 호출하지 못하면, 아무리 커밋 주기가 짧아도 커밋은 실행되지 않습니다. 즉, 메시지 처리가 오래 걸리거나 실패하여 컨슈머가 다음 poll()로 나아가지 못하면, 뒤이어 들어오는 메시지들은 계속 쌓여 Lag이 발생하게 됩니다. 결국, 커밋 빈도보다 메시지의 실제 처리 완료와 다음 poll() 호출 가능 여부가 Lag 발생에 더 직접적인 영향을 미칩니다.



결국, auto commit 설정은 메시지 처리 흐름 자체의 병목이나 실패로 인한 Lag 문제를 근본적으로 해결해주지 못합니다.

 

오히려, 이렇게 개별 메시지 처리의 신뢰성 확보가 어려운 상황에서, 여러 이벤트가 동시에 처리될 때 또 다른 심각한 문제, 바로 데이터 정합성 훼손 문제가 발생할 수 있습니다.

<svg width="950" height="550" viewBox="0 0 950 550" xmlns="http://www.w3.org/2000/svg" style="font-family: 'Nanum Gothic', Arial, sans-serif; font-size: 13px; background-color: #f9f9f9;">

  <defs>
    <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#333"/>
    </marker>
    <style>
      .text-label { font-size: 12px; fill: #333; }
      .text-title { font-size: 14px; font-weight: bold; fill: #2c3e50; }
      .text-highlight { font-size: 12px; fill: #e74c3c; font-weight: bold;}
      .text-lag { font-size: 16px; fill: #c0392b; font-weight: bold;}
      .box { stroke: #7f8c8d; stroke-width: 1.5; fill: #ecf0f1; rx: 5; ry: 5; }
      .message { stroke: #3498db; stroke-width: 1; fill: #e1f5fe; rx:3; ry:3; }
      .stuck-message { stroke: #f39c12; stroke-width: 1.5; fill: #fef9e7; rx:3; ry:3; }
      .new-message { stroke: #2ecc71; stroke-width: 1; fill: #e8f8f5; rx:3; ry:3; }
      .arrow { stroke: #34495e; stroke-width: 1.5; marker-end: url(#arrowhead); }
      .error-line { stroke: #e74c3c; stroke-width: 2; marker-end: url(#arrowhead); }
      .dashed-line { stroke: #95a5a6; stroke-width: 1.5; stroke-dasharray: 4 2; }
    </style>
  </defs>

  <text x="475" y="30" text-anchor="middle" class="text-title">시나리오 2: 외부 시스템 장애로 인한 Kafka Lag 누적 과정</text>

  <rect x="50" y="70" width="220" height="400" class="box"/>
  <text x="160" y="90" text-anchor="middle" class="text-label" style="font-weight:bold;">카프카 토픽 (Kafka Topic)</text>
  <text x="160" y="105" text-anchor="middle" class="text-label" style="font-size:11px;">(특정 파티션)</text>

  <rect x="350" y="230" width="200" height="100" class="box"/>
  <text x="450" y="250" text-anchor="middle" class="text-label" style="font-weight:bold;">활동 이벤트 컨슈머</text>
  <text x="450" y="265" text-anchor="middle" class="text-label" style="font-size:11px;">(UserActivityEventConsumer)</text>
  <text x="450" y="295" text-anchor="middle" class="text-highlight">M1 처리 중 멈춤!</text>
  <text x="450" y="310" text-anchor="middle" class="text-label" style="font-size:11px;">(다음 poll() 호출 못함)</text>

  <rect x="630" y="230" width="200" height="100" class="box" style="fill: #fadbd8; stroke: #c0392b;"/>
  <text x="730" y="250" text-anchor="middle" class="text-label" style="font-weight:bold;">혜택 규칙 엔진</text>
  <text x="730" y="265" text-anchor="middle" class="text-label" style="font-size:11px;">(PromotionEngine)</text>
  <text x="730" y="295" text-anchor="middle" class="text-highlight" style="font-size:20px;">장애 발생!</text>
  <text x="730" y="310" text-anchor="middle" class="text-label" style="font-size:11px;">(응답 없음 / 오류)</text>


  <text x="70" y="140" class="text-label">이전 커밋 오프셋: O-1</text>

  <rect x="70" y="150" width="180" height="30" class="stuck-message"/>
  <text x="160" y="170" text-anchor="middle" class="text-label">메시지 M1 (오프셋 O)</text>

  <rect x="70" y="190" width="180" height="30" class="new-message"/>
  <text x="160" y="210" text-anchor="middle" class="text-label">메시지 M2 (오프셋 O+1)</text>
  <rect x="70" y="230" width="180" height="30" class="new-message"/>
  <text x="160" y="250" text-anchor="middle" class="text-label">메시지 M3 (오프셋 O+2)</text>
  <rect x="70" y="270" width="180" height="30" class="new-message"/>
  <text x="160" y="290" text-anchor="middle" class="text-label">메시지 M4 (오프셋 O+3)</text>

  <path d="M 70 310 L 250 310 L 250 350 L 70 350 Z" fill="#e8f8f5" stroke="#2ecc71" stroke-width="1"/>
  <text x="160" y="335" text-anchor="middle" class="text-label">...</text>
  <text x="160" y="370" text-anchor="middle" class="text-label">새 메시지 계속 유입</text>


  <rect x="70" y="400" width="180" height="50" style="fill:none; stroke:#c0392b; stroke-width:2; stroke-dasharray: 5 2;"/>
  <text x="160" y="430" text-anchor="middle" class="text-lag">Lag 증가!</text>
  <line x1="160" y1="180" x2="160" y2="400" class="dashed-line"/>
  <text x="190" y="390" text-anchor="start" class="text-label" style="font-size:11px; fill:#c0392b;">처리 못한</text>
  <text x="190" y="405" text-anchor="start" class="text-label" style="font-size:11px; fill:#c0392b;">메시지들</text>

  <line x1="270" y1="165" x2="350" y2="245" class="arrow"/>
  <text x="300" y="200" text-anchor="middle" class="text-label">1. M1 Poll</text>

  <line x1="550" y1="260" x2="630" y2="260" class="arrow"/>
  <text x="590" y="250" text-anchor="middle" class="text-label">2. 혜택 규칙 요청</text>

  <line x1="630" y1="290" x2="550" y2="290" class="error-line"/>
  <text x="590" y="305" text-anchor="middle" class="text-highlight">3. 응답 없음 / 오류!</text>

  <text x="450" y="380" text-anchor="middle" class="text-title">4. 컨슈머의 M1 처리 중단</text>
  <text x="450" y="400" text-anchor="middle" class="text-label">   - 내부 재시도 반복 또는 대기</text>
  <text x="450" y="415" text-anchor="middle" class="text-label">   - 다음 `poll()` 호출 불가</text>
  <text x="450" y="430" text-anchor="middle" class="text-label">   - `auto commit`도 의미 없어짐 (커밋할 새 오프셋 없음)</text>

  <text x="730" y="380" text-anchor="middle" class="text-title">5. 결과: Lag 누적</text>
   <text x="730" y="400" text-anchor="middle" class="text-label">   - 카프카 토픽에는 새 메시지가 계속 쌓임</text>
   <text x="730" y="415" text-anchor="middle" class="text-label">   - 컨슈머는 M1에서 진행 불가</text>
   <text x="730" y="430" text-anchor="middle" class="text-label" style="fill:#c0392b; font-weight:bold;">   ➔ 전체 시스템 처리 지연!</text>

</svg>

 

시나리오 3: 동일 사용자, 혜택 중복 지급의 아찔한 순간 (동시성 제어 부재)

 

서비스가 활성화되면서, 한 명의 사용자가 매우 짧은 시간 간격을 두고 여러 개의 미션을 동시에 완료하거나, 하나의 미션 완료 이벤트가 어떤 이유로든 Kafka에 중복으로 발행되는 (예: 클라이언트의 네트워크 불안정으로 인한 재시도 요청) 경우가 발생하기 시작했습니다.

  1. 예를 들어, 사용자 A가 미션 X와 미션 Y를 거의 동시에 완료했고, 두 미션 모두 '웰컴 포인트 100점 지급'이라는 동일한 혜택 캠페인에 해당한다고 가정해 봅시다.
  2. 이벤트 E1(사용자 A, 미션 X 완료)과 이벤트 E2(사용자 A, 미션 Y 완료)가 거의 동시에 Kafka 토픽으로 발행됩니다.
  3. 만약 UserActivityEventConsumer가 여러 인스턴스로 확장(Scale-out)되어 있거나, 단일 인스턴스 내에서도 여러 스레드로 파티션을 병렬 처리하고 있다면, E1과 E2는 서로 다른 컨슈머 인스턴스 또는 스레드에 의해 거의 동시에 처리될 수 있습니다.
  4. 컨슈머 C1이 E1을 받아 BenefitLogic을 실행하여 "사용자 A에게 '웰컴 포인트 100점'을 지급해야 하는가?"를 검증합니다. 이 시점에서 사용자 A의 DB에는 아직 포인트가 지급되기 전이므로, "지급 대상"으로 판단합니다.
  5. 거의 동시에, 컨슈머 C2가 E2를 받아 동일한 검증을 수행합니다. C1의 트랜잭션이 아직 DB에 커밋되기 전이라면, C2 역시 사용자 A의 현재 포인트 상태를 "미지급"으로 판단하고 "지급 대상"으로 결론 내립니다.
  6. 결국 C1과 C2 모두 각자의 트랜잭션 내에서 사용자 A에게 100 포인트를 지급하는 DB 업데이트를 실행합니다.
<svg width="1000" height="650" viewBox="0 0 1000 650" xmlns="http://www.w3.org/2000/svg" style="font-family: 'Nanum Gothic', Arial, sans-serif; font-size: 13px; background-color: #f9f9f9;">

  <defs>
    <marker id="arrowhead_svg_scenario3" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#333"/>
    </marker>
    <style>
      .text-label { font-size: 12px; fill: #333; }
      .text-label-bold { font-size: 12px; fill: #333; font-weight: bold; }
      .text-title { font-size: 14px; font-weight: bold; fill: #2c3e50; }
      .text-highlight { font-size: 12px; fill: #e74c3c; font-weight: bold;}
      .text-success { font-size: 12px; fill: #27ae60; font-weight: bold;}
      .box { stroke: #7f8c8d; stroke-width: 1.5; fill: #ecf0f1; rx: 5; ry: 5; }
      .message { stroke: #3498db; stroke-width: 1; fill: #e1f5fe; rx:3; ry:3; }
      .consumer-box { stroke: #8e44ad; stroke-width: 1.5; fill: #f5eef8; rx:5; ry:5; }
      .db-box { stroke: #16a085; stroke-width: 1.5; fill: #e8f6f3; rx:5; ry:5; }
      .arrow { stroke: #34495e; stroke-width: 1.5; marker-end: url(#arrowhead_svg_scenario3); }
      .time-dot { fill: #7f8c8d; }
      .time-line { stroke: #bdc3c7; stroke-width: 1;}
    </style>
  </defs>

  <text x="500" y="30" text-anchor="middle" class="text-title">시나리오 3: 동시성 제어 부재로 인한 혜택 중복 지급</text>

  <line x1="50" y1="70" x2="950" y2="70" class="time-line"/>
  <text x="50" y="60" class="text-label">시간 (Time →)</text>

  <rect x="30" y="100" width="180" height="100" class="box"/>
  <text x="120" y="120" text-anchor="middle" class="text-label" style="font-weight:bold;">카프카 토픽</text>
  <text x="120" y="135" text-anchor="middle" class="text-label" style="font-size:11px;">(Kafka Topic)</text>
  <rect x="40" y="150" width="160" height="25" class="message"/>
  <text x="120" y="167" text-anchor="middle" class="text-label">E1 (사용자A, 미션X)</text>
  <rect x="40" y="180" width="160" height="25" class="message"/>
  <text x="120" y="197" text-anchor="middle" class="text-label">E2 (사용자A, 미션Y)</text>

  <rect x="250" y="150" width="160" height="80" class="consumer-box"/>
  <text x="330" y="170" text-anchor="middle" class="text-label" style="font-weight:bold;">컨슈머 C1</text>
  <text x="330" y="185" text-anchor="middle" class="text-label" style="font-size:11px;">(Consumer 1)</text>

  <rect x="250" y="370" width="160" height="80" class="consumer-box"/>
  <text x="330" y="390" text-anchor="middle" class="text-label" style="font-weight:bold;">컨슈머 C2</text>
  <text x="330" y="405" text-anchor="middle" class="text-label" style="font-size:11px;">(Consumer 2)</text>

  <rect x="480" y="200" width="220" height="220" class="db-box"/>
  <text x="590" y="220" text-anchor="middle" class="text-label" style="font-weight:bold;">사용자 A 데이터베이스</text>
  <text x="590" y="235" text-anchor="middle" class="text-label" style="font-size:11px;">(User A DB State)</text>
  <text x="590" y="265" text-anchor="middle" class="text-label" id="db_state_initial_s3">초기: 웰컴 포인트 0점</text>
  <text x="590" y="305" text-anchor="middle" class="text-label" id="db_state_c1_write_s3" style="fill:#16a085;">DB: 100점 (C1 처리 후)</text>
  <text x="590" y="380" text-anchor="middle" class="text-highlight" style="font-size:16px;" id="db_state_final_s3">최종: 웰컴 포인트 200점!</text>
  <text x="590" y="400" text-anchor="middle" class="text-label" style="font-size:11px;">(비즈니스 규칙: 최대 100점)</text>

  <rect x="760" y="260" width="190" height="100" class="box" style="fill: #fef9e7; stroke: #f39c12;"/>
  <text x="855" y="280" text-anchor="middle" class="text-label" style="font-weight:bold;">혜택 지급 로직 (공유)</text>
  <text x="855" y="295" text-anchor="middle" class="text-label" style="font-size:11px;">(BenefitLogic - No Lock)</text>
  <text x="855" y="325" text-anchor="middle" class="text-label">동시성 제어 없음!</text>

  <circle cx="180" cy="70" r="5" class="time-dot"/>
  <text x="180" y="90" text-anchor="middle" class="text-label">T1 (거의 동시)</text>
  <line x1="210" y1="165" x2="250" y2="180" class="arrow"/>
  <text x="250" y="135" text-anchor="middle" class="text-label" style="font-size:11px;">C1, E1 Poll</text>
  <line x1="210" y1="190" x2="250" y2="400" class="arrow"/>
  <text x="200" y="380" text-anchor="middle" class="text-label" style="font-size:11px;">C2, E2 Poll</text>

  <circle cx="430" cy="70" r="5" class="time-dot"/>
  <text x="430" y="90" text-anchor="middle" class="text-label">T2</text>
  <line x1="410" y1="190" x2="480" y2="255" class="arrow"/>
  <text x="495" y="130" text-anchor="middle" class="text-label" style="font-size:11px;">1a. C1: DB 조회 (결과: 0점)</text>
  <line x1="410" y1="200" x2="760" y2="280" class="arrow" style="stroke-dasharray: 4 2;"/>
  <text x="520" y="150" text-anchor="middle" class="text-label" style="font-size:11px;">2a. C1: BenefitLogic 확인 (지급 결정)</text>

  <line x1="410" y1="410" x2="480" y2="315" class="arrow"/>
  <text x="355" y="300" text-anchor="middle" class="text-label" style="font-size:11px;">1b. C2: DB 조회 (결과: 0점)</text>
  <line x1="410" y1="420" x2="760" y2="320" class="arrow" style="stroke-dasharray: 4 2;"/>
  <text x="380" y="320" text-anchor="middle" class="text-label" style="font-size:11px;">2b. C2: BenefitLogic 확인 (지급 결정)</text>

  <circle cx="650" cy="70" r="5" class="time-dot"/>
  <text x="650" y="90" text-anchor="middle" class="text-label">T3</text>
  <line x1="410" y1="210" x2="480" y2="295" class="arrow" style="stroke: #27ae60;"/>
  <text x="485" y="170" text-anchor="middle" class="text-success" style="font-size:11px;">3a. C1: DB에 +100점</text>

  <circle cx="800" cy="70" r="5" class="time-dot"/>
  <text x="800" y="90" text-anchor="middle" class="text-label">T4</text>
  <line x1="410" y1="430" x2="480" y2="365" class="arrow" style="stroke: #27ae60;"/>
  <text x="345" y="340" text-anchor="middle" class="text-success" style="font-size:11px;">3b. C2: DB에 +100점</text>

  <text x="506" y="520" text-anchor="middle" class="text-label"> ▶︎ 각 컨슈머가 독립적으로 사용자 상태를 확인하고 업데이트 (동시성 제어 부재)</text>
  <text x="460" y="540" text-anchor="middle" class="text-label-bold"> ▶︎ 결과: 사용자 A, 웰컴 포인트 총 200점 획득 (중복 지급!)</text>

  <text x="412" y="560" text-anchor="middle" class="text-highlight"> ▶ 비즈니스 규칙 위반 및 어뷰징 가능성!</text>

</svg>


결과적으로 사용자 A는 '웰컴 포인트 100점'을 두 번 받아 총 200점을 획득하게 됩니다. 하지만 비즈니스 규칙상, 웰컴 포인트의 최대 지급 점수는 100점입니다. 따라서, 악의적인 사용자에게는 어뷰징의 빌미를 제공할 수 있는 심각한 결함이 됩니다.

 

"특정 캠페인 기간 동안 일부 사용자에게 과도한 포인트가 지급된 것으로 추정됨"과 같은 내부 감사 지적이나, "친구는 포인트 두 번 받았는데 나는 왜 한 번만 주냐"는 식의 황당한 고객 문의로 이어질 수 있는 문제였습니다.

 

이러한 문제들은 "혜택콕" 플랫폼의 핵심 가치인 '실시간 적시 혜택 지급'을 훼손하고, 데이터의 신뢰도를 떨어뜨리며, 결국 사용자 경험에 부정적인 영향을 미칠 수 있어, 단순한 버그를 넘어 시스템 아키텍처 수준에서의 근본적인 해결책 마련이 시급한 상황인 것입니다.


2. 문제 딥다이브

기존의 단순한 구조는 서비스 초기에는 빠르게 기능을 구현하고 배포하는 데 유리했을지 모르나, 시스템의 규모가 커지고 안정성에 대한 요구 수준이 높아짐에 따라 여러 한계를 드러냈습니다.

2.1 auto commit의 편리함, 그 이면의 치명적인 위험성

Kafka 컨슈머의 auto commit 기능은 그 이름에서 알 수 있듯이, 개발자가 오프셋(Offset) 관리에 많은 신경을 쓰지 않아도 시스템이 자동으로 처리해 준다는 점에서 초기에는 매우 편리해 보일 수 있습니다. 정기적으로 알아서 청소해 주는 로봇 청소기 같이요. 하지만 이 편리함 이면에는, 자칫 잘못하면 중요한 데이터를 영원히 잃어버릴 수 있는 "메시지 유실"이라는 치명적인 위험이 존재합니다.

 

auto commit의 핵심 동작 원리는, 컨슈머가 poll() 메서드를 통해 가져온 메시지 배치의 실제 처리 성공 여부와는 관계없이, 설정된 auto.commit.interval.ms(기본 5초)라는 시간 간격마다 가장 마지막으로 poll()된 메시지의 오프셋을 기준으로 "여기까지는 처리되었을 것이다"라고 간주하고 커밋을 시도한다는 점입니다.

 

이것이 왜 위험할까요? 카프카에서 '오프셋 커밋'이란, 컨슈머가 "이 지점(오프셋)까지의 메시지는 내가 성공적으로 다 처리했으니, 다음번에는 이 지점 이후의 메시지를 달라"고 카프카 브로커에게 일종의 '확인 도장'을 찍어 알리는 행위와 같습니다. 그런데 auto commit은 우리 애플리케이션의 실제 비즈니스 로직(예를 들어, "혜택콕" 플랫폼에서의 포인트 적립 로직)이 정말로 성공했는지, 아니면 중간에 오류가 나서 실패했는지를 확인하지 않은 채, 단순히 시간적인 간격에 따라 이 '확인 도장'을 찍어버리는 것입니다.

 

바로 이 지점에서 시나리오 1에서 묘사된 것과 같은 문제가 발생합니다. 컨슈머가 메시지를 성공적으로 가져와서 포인트 적립 로직을 수행하던 중, 데이터베이스 오류나 네트워크 예외 등으로 인해 로직이 완료되지 못하고 실패했음에도 불구하고, auto commit 타이머가 만료되어 해당 메시지를 포함한 오프셋이 먼저 커밋되어 버릴 수 있습니다. 이렇게 되면 카프카 브로커는 "아, 이 메시지는 이미 컨슈머가 잘 처리했구나"라고 인식하게 되고, 설령 컨슈머가 해당 오류로 인해 비정상 종료 후 재시작하더라도 이 메시지를 다시는 전달하지 않습니다.

 

결국, 사용자에게 지급되어야 할 혜택은 시스템에서 영원히 사라지게 되는 것입니다. 이러한 동작 방식은 카프카의 메시지 전달 보장 수준 중 at-most-once (최대 한 번 전달), 즉 메시지가 운 좋으면 한 번 제대로 처리되거나, 혹은 아예 처리되지 않을 수도 있음을 의미합니다. 이는, 데이터의 완전성과 정확성이 생명인 "혜택콕"과 같은 서비스에서는 용납할 수 없는 시나리오를 야기합니다.

 

따라서, 이러한 메시지 유실 위험을 원천적으로 차단하기 위해, 메시지 처리의 완전한 성공을 애플리케이션 코드 레벨에서 명확히 확인한 후, 개발자가 직접 오프셋을 커밋하는 수동 커밋 (enable.auto.commit=false) 방식으로의 전환을 선택했습니다.

 

그렇다면 수동 커밋은 auto commit의 문제를 정확히 어떻게 해결하는 걸까요? 수동 커밋 방식에서는 오프셋 커밋의 모든 제어권이 개발자에게 주어집니다. 개발자는 애플리케이션 코드 내에서, 예를 들어 "혜택콕"의 포인트 적립 로직, 관련된 데이터베이스 업데이트, 그리고 혹시 모를 후속 알림 발송까지 모든 핵심 비즈니스 로직이 단 하나의 오류도 없이 성공적으로 완료되었음을 프로그램적으로 확인한 바로 그 시점에만, acknowledgment.acknowledge() (Spring Kafka 사용 시)와 같은 명시적인 명령을 통해 오프셋을 커밋합니다.

 

만약 중요한 로직 처리 도중 어떤 이유로든 예외가 발생하여 처리가 중단된다면, 이 커밋 명령은 당연히 실행되지 않습니다. 그 결과, 해당 메시지의 오프셋은 카프카 브로커에 이전 상태 그대로(즉, 커밋되지 않은 상태로) 남아있게 됩니다. 이후 컨슈머가 (오류 상황이 해결된 후) 재시작하거나 다음 poll()을 통해 메시지를 다시 가져올 때, 카프카는 이전에 성공적으로 커밋되지 않았던 바로 그 메시지부터 다시 전달해 줍니다.

 

<svg width="900" height="550" viewBox="0 0 900 550" xmlns="http://www.w3.org/2000/svg" style="font-family: 'Nanum Gothic', Arial, sans-serif; font-size: 13px; background-color: #f9f9f9;">
  <defs>
    <marker id="arrowhead_comp" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#444"/>
    </marker>
    <style>
      .title { font-size: 16px; font-weight: bold; text-anchor: middle; fill: #333; }
      .scenario-title { font-size: 14px; font-weight: bold; text-anchor: middle; }
      .box { rx: 5; ry: 5; stroke-width: 1.5; }
      .kafka-box { fill: #e6f7ff; stroke: #91d5ff; }
      .consumer-box { fill: #fffbe6; stroke: #ffe58f; }
      .logic-box { fill: #f6ffed; stroke: #b7eb8f; }
      .commit-box { fill: #f0f0f0; stroke: #b8b8b8; }
      .text-label { font-size: 12px; text-anchor: middle; dominant-baseline: central; fill: #333; }
      .text-outcome { font-size: 13px; font-weight: bold; text-anchor: middle; }
      .arrow { stroke: #555; stroke-width: 1.5; marker-end: url(#arrowhead_comp); }
      .error-path { stroke: #e74c3c; stroke-width: 1.5; marker-end: url(#arrowhead_comp); stroke-dasharray: 4 2; }
      .success-path { stroke: #27ae60; stroke-width: 1.5; marker-end: url(#arrowhead_comp); }
    </style>
  </defs>

  <text x="450" y="40" class="title">Auto Commit vs 수동 Commit: 오류 발생 시 동작 비교</text>

  <text x="225" y="80" class="scenario-title" fill="#c0392b">Auto Commit 시나리오 (메시지 유실 위험)</text>
  <rect x="50" y="100" width="150" height="60" class="box kafka-box"/>
  <text x="125" y="130" class="text-label">1. Kafka: 메시지 M</text>

  <rect x="275" y="100" width="150" height="60" class="box consumer-box"/>
  <text x="350" y="130" class="text-label">2. Consumer: Poll(M)</text>

  <rect x="50" y="200" width="150" height="80" class="box logic-box"/>
  <text x="125" y="230" class="text-label">3. 애플리케이션 로직</text>
  <text x="125" y="250" class="text-label" style="fill:#e74c3c; font-weight:bold;">오류 발생!</text>

  <rect x="275" y="200" width="150" height="80" class="box commit-box"/>
  <text x="350" y="220" class="text-label">4. Auto Commit 타이머</text>
  <text x="350" y="240" class="text-label">(`auto.commit.interval.ms`)</text>
  <text x="350" y="260" class="text-label" style="font-weight:bold;">오프셋 커밋됨!</text>

  <text x="225" y="330" class="text-outcome" style="fill:#e74c3c;">결과: 메시지 M 유실 (재처리 불가)</text>

  <line x1="200" y1="130" x2="275" y2="130" class="arrow"/>
  <line x1="125" y1="160" x2="125" y2="200" class="arrow"/>
  <line x1="200" y1="240" x2="275" y2="240" class="error-path"/>
  <text x="230" y="230" class="text-label" style="font-size:10px; fill:#e74c3c">처리 실패</text>


  <text x="675" y="80" class="scenario-title" fill="#27ae60">수동 Commit 시나리오 (재처리 보장)</text>
  <rect x="500" y="100" width="150" height="60" class="box kafka-box"/>
  <text x="575" y="130" class="text-label">1. Kafka: 메시지 M</text>

  <rect x="725" y="100" width="150" height="60" class="box consumer-box"/>
  <text x="800" y="130" class="text-label">2. Consumer: Poll(M)</text>

  <rect x="500" y="200" width="150" height="80" class="box logic-box"/>
  <text x="575" y="230" class="text-label">3. 애플리케이션 로직</text>
  <text x="575" y="250" class="text-label" style="fill:#e74c3c; font-weight:bold;">오류 발생!</text>

  <rect x="725" y="200" width="150" height="80" class="box commit-box"/>
  <text x="800" y="220" class="text-label">4. 개발자 코드:</text>
  <text x="800" y="240" class="text-label">`acknowledge()` 호출</text>
  <text x="800" y="260" class="text-label" style="font-weight:bold; fill:#e74c3c;">실행 안됨 (오류로 인해)</text>

  <text x="675" y="330" class="text-outcome" style="fill:#27ae60;">결과: 메시지 M 재처리 가능 (유실 방지)</text>

  <line x1="650" y1="130" x2="725" y2="130" class="arrow"/>
  <line x1="575" y1="160" x2="575" y2="200" class="arrow"/>
  <line x1="650" y1="240" x2="725" y2="240" class="error-path"/>
  <text x="680" y="230" class="text-label" style="font-size:10px; fill:#e74c3c">처리 실패</text>

  <text x="450" y="380" text-anchor="middle" class="text-label" style="font-size:13px;">
    Auto Commit: 메시지 처리 성공 여부와 <tspan style="font-weight:bold; fill:#e74c3c;">관계없이</tspan> 타이머에 의해 오프셋이 커밋될 수 있어 메시지 유실 가능성 존재.
  </text>
  <text x="450" y="400" text-anchor="middle" class="text-label" style="font-size:13px;">
    수동 Commit: 메시지 처리가 <tspan style="font-weight:bold; fill:#27ae60;">성공했을 때만</tspan> 개발자가 명시적으로 커밋하므로, 오류 시 메시지 재처리 기회 보장.
  </text>

  <line x1="450" y1="90" x2="450" y2="350" stroke="#bdc3c7" stroke-width="1" stroke-dasharray="5 5"/>

  <text x="30" y="190" text-anchor="middle" class="text-label" style="writing-mode: tb; glyph-orientation-vertical: 0;">시간 흐름</text>
  <line x1="30" y1="100" x2="30" y2="280" class="arrow" style="marker-start: url(#arrowhead_comp); marker-end: url(#arrowhead_comp);" />

</svg>

 

이렇게 메시지가 애플리케이션에 의해 성공적으로 처리될 때까지 최소 한 번은 다시 전달될 가능성을 열어두기 때문에, 수동 커밋 방식은 at-least-once (최소 한 번 전달) 시맨틱을 효과적으로 구현할 수 있게 해 줍니다. 물론, 이 경우 동일한 메시지가 의도치 않게 두 번 이상 처리될 가능성도 함께 고려해야 하므로, 애플리케이션 레벨에서 멱등성(Idempotency)을 확보하는 것이 매우 중요해집니다(이 부분은 이어지는 Step 3.2에서 분산 락과 함께 다시 한번 중요하게 다루게 됩니다). 하지만 적어도 메시지가 아무도 모르게 소리 소문 없이 사라지는 최악의 상황은 막을 수 있게 된 것입니다. 따라서 이것은 메시지 유실 가능성을 원천적으로 차단하고 시스템 신뢰성을 확보하는 데 있어 매우 중요한 첫걸음이 됩니다.

 

이처럼 수동 커밋 방식은 메시지 유실이라는 가장 큰 화마는 막아주었지만, 그것이 곧 모든 문제의 완전한 해결을 의미하지는 않았습니다. 메시지가 사라지지 않고 다시 처리될 '기회'를 얻는다고 해서, 그 재시도가 항상 성공으로 이어진다는 보장은 없기 때문이죠. 특히 "혜택 규칙 엔진"과 같이 우리 시스템 외부의 요인으로 인해 메시지 처리가 반복적으로 실패한다면, 시스템은 단순히 메시지를 재전달하는 것만으로는 해결할 수 없는 또 다른 형태의 위기에 직면하게 됩니다. 이것이 바로 "혜택콕" 플랫폼이 마주했던 두 번째 심각한 난관이었습니다.

2.2 실패한 메시지의 운명은? - 견고한 오류 처리 및 재시도 전략의 부재

앞서 "시나리오 2"에서 상세히 묘사했듯이, "혜택 규칙 엔진"의 일시적인 장애는 "혜택콕" 플랫폼 전체의 혜택 지급 지연 및 시스템 마비 현상으로까지 이어졌습니다. 컨슈머는 계속해서 메시지를 가져오지만, 외부 시스템 호출 단계에서 타임아웃이 발생하거나 오류 응답을 받으면서 더 이상 앞으로 나아가지 못했습니다. 이는 기존 아키텍처에 실패한 메시지를 어떻게 다루고 관리할 것인가에 대한 체계적이고 견고한 전략이 부재했기 때문입니다.

 

우리가 개발하는 시스템은 수많은 외부 요인과 상호작용합니다. 외부 API 호출, 다른 마이크로서비스와의 통신, 데이터베이스 연결 등은 항상 일시적인 네트워크 불안정, 상대 시스템의 과부하 또는 점검 등으로 인한 오류 가능성을 내포하고 있습니다. 이러한 오류가 발생했을 때, 단순히 예외를 로깅만 하고 넘어가거나, 혹은 애플리케이션 코드 내에서 Thread.sleep()과 반복문을 사용한 어설픈 try-catch 블록으로 재시도를 구현하는 것은 여러 가지 부작용을 낳습니다.

 

가장 대표적인 문제가 바로 Head-of-Line Blocking 현상입니다. 특정 메시지 하나가 계속해서 처리에 실패하여 컨슈머 스레드가 해당 메시지에만 매달려 있다면, 그 메시지가 속한 파티션의 다른 모든 정상적인 메시지들은 하염없이 대기해야 합니다. 이는 마치 고속도로의 한 개 차선에서 사고가 나서 뒤따르는 모든 차들이 정체되는 것과 같습니다. 결국 시스템 전체의 처리량이 급격히 저하되고 사용자 경험에 악영향을 미치게 됩니다.

 

 

 

 

또한, 재시도 간격이나 최대 재시도 횟수에 대한 명확한 정책이 없다면 어떻게 될까요? 너무 짧은 간격으로 계속 재시도한다면, 이미 장애 상태인 외부 시스템에 불필요한 부하를 더욱 가중시켜 회복을 더디게 만들 수 있습니다. 반대로, 무한정 재시도하도록 방치한다면 특정 컨슈머 스레드는 영원히 그 오류 상황을 벗어나지 못하고 시스템 자원만 낭비하게 될 수도 있습니다.

 

따라서, 다음과 같은 질문에 답할 수 있는 체계적인 오류 처리 및 재시도 메커니즘이 필요합니다.

  • 어떤 종류의 오류를 재시도 대상으로 할 것인가? (e.g., 네트워크 타임아웃은 재시도, 데이터 포맷 오류는 즉시 실패)
  • 재시도 간격은 어떻게 설정할 것인가? (e.g., 고정 간격 vs. 지수 백오프)
  • 최대 몇 번까지 재시도할 것인가?
  • 최종적으로 재시도에 실패한 메시지는 어떻게 처리할 것인가? (e.g., 별도 로그 기록, Dead Letter Queue(DLQ)로 전송하여 사후 분석 및 수동 처리)

이러한 정교한 전략들을 시스템에 구현하기 위해서는, 단순히 메시지를 받고 처리하는 것을 넘어, 각 메시지의 처리 시도 이력(예: 현재 몇 번째 재시도인지)과 그 상태(예: 최초 시도 중, 재시도 대기 중, 최종 실패, 성공적으로 완료됨)를 어딘가에 체계적으로 기록하고 관리할 필요성이 자연스럽게 대두되었습니다. 이는 메시지 처리 상태를 저장하고 관리하는 별도의 데이터 저장소 도입의 필요성으로 이어졌고, 이어지는 Step 3에서 보다 구체적인 해결책으로 발전하게 됩니다.

2.3 분산 환경에서의 동시성은 숙명 - "먼저 잡는 사람이 임자?" 🤺

메시지 유실 문제와 불안정한 재처리 문제에 대한 고민을 넘어, "혜택콕" 플랫폼은 또 다른 중요한 도전에 직면했습니다. 바로 분산 환경에서의 동시성(Concurrency) 문제입니다.

 

서비스 규모가 확장됨에 따라, "혜택콕"의 UserActivityEventConsumer는 여러 인스턴스로 확장(Scale-out)되어 운영되었고, 각 인스턴스는 내부적으로 다수의 스레드를 사용하여 여러 파티션의 메시지를 병렬로 처리하고 있었습니다. 이러한 분산/병렬 처리는 시스템 전체의 처리량(Throughput)을 높이는 데 필수적이지만, 동시에 여러 컨슈머(또는 스레드)가 동일한 공유 자원(Shared Resource)에 동시에 접근하여 예기치 않은 문제를 일으킬 가능성을 내포하고 있습니다.

 

"시나리오 3"에서 묘사된 '혜택 중복 지급' 문제가 바로 이 동시성 제어 부재로 인해 발생한 대표적인 사례입니다. 특정 사용자 A에 대한 두 개의 다른 "미션 완료 이벤트"가 거의 동시에 서로 다른 컨슈머 인스턴스 C1과 C2에 의해 처리된다고 가정해 봅시다.

  1. C1은 사용자 A의 현재 혜택 상태를 데이터베이스에서 조회합니다. 이때 아직 혜택이 지급되지 않은 상태입니다.
  2. 거의 동시에 C2도 사용자 A의 혜택 상태를 조회합니다. C1의 트랜잭션이 아직 완료(커밋)되지 않았으므로, C2 역시 동일하게 "혜택 미지급" 상태로 인식합니다.
  3. C1과 C2는 각자 "아직 혜택이 지급되지 않았으니 지급해야겠다"고 판단하고, 사용자 A에게 혜택을 지급하는 로직(예: 포인트 적립 DB 업데이트)을 수행합니다.

결과적으로 사용자 A는 동일한 조건의 혜택을 두 번 받게 되는, 즉 데이터의 정합성이 깨지는 상황이 발생합니다. 이는 마치 은행 ATM 두 곳에서 동시에 같은 계좌의 잔액을 조회하고 각기 출금을 시도할 때, 적절한 동기화 처리가 없다면 잔액 이상의 금액이 출금될 수 있는 위험과 유사합니다.

 

따라서, 단순히 메시지를 안정적으로 받고 처리하는 것을 넘어, 여러 컨슈머가 동일한 사용자의 혜택 상태와 같은 공유 자원을 안전하게 변경할 수 있도록 보장하는 분산 환경에서의 동시성 제어 메커니즘 도입이 시급한 과제로 떠올랐습니다. 이는 "먼저 잡는 사람이 임자"가 아니라, "오직 한 번만, 정확하게 처리됨"을 보장하기 위한 고민이 필요해진 것입니다.


3. 가능한 해결책들 그리고 트레이드오프

앞서 "혜택콕" 플랫폼이 겪었던 메시지 유실, 시스템 마비, 데이터 부정합 등의 문제들은 더 이상 임시방편적인 조치로는 해결할 수 없는 한계에 봉착했음을 의미했습니다. 안정적이고 신뢰할 수 있는 혜택 지급 시스템을 구축하기 위해, 저희는 이벤트 처리 파이프라인의 핵심 로직들을 재검토하고 다음과 같은 단계적인 개선 프로젝트를 진행했습니다.

 

핵심 목표는 "어떠한 상황에서도 이벤트는 절대 유실되지 않아야 하며, 모든 처리는 예측 가능하고 일관된 결과를 보장해야 한다" 는 것입니다. 아래는 개선된 "혜택콕" 시스템의 전체적인 흐름을 보여주는 시퀀스입니다.

sequenceDiagram
    participant Kafka as 카프카
    participant InitialProcessor as "이벤트 초기 처리기<br>(컨슈머 + 유효성/자격 검사)"
    participant LockService as "분산 락 (Redis)"
    participant ActivityEventStore as "이벤트 저장소 (RDB)"
    participant AsyncBenefitHandler as "비동기 혜택 처리기"

    rect rgba(255, 165, 0, 0.1)
    Kafka->>InitialProcessor: 1. "미션 완료 이벤트" 수신
    InitialProcessor->>InitialProcessor: 2. 활동 유효성 및 지급 자격 검사
    end

    alt 검사 실패 또는 지급 불필요
        rect rgba(255, 0, 0, 0.1)
        InitialProcessor-->>Kafka: Nack 또는 Ack (상황에 맞게)
        end
    else 지급 필요 확인
        InitialProcessor->>LockService: 3. 분산 락 획득 시도
        alt 락 획득 실패
            rect rgba(255, 0, 0, 0.1)
            LockService-xInitialProcessor: 락 실패 응답
            InitialProcessor-->>Kafka: Nack (재시도 유도)
            end
        else 락 획득 성공
            rect rgba(0, 128, 0, 0.1)
            Note over InitialProcessor, LockService: 락 획득! (이하 로직은 락 내부에서 수행)
            InitialProcessor->>ActivityEventStore: 4. 이벤트 저장 (상태: PENDING)
            end
            alt RDB 저장 실패
                rect rgba(255, 0, 0, 0.1)
                ActivityEventStore-xInitialProcessor: 저장 실패 응답
                InitialProcessor-->>LockService: (중요!) 락 즉시 해제
                InitialProcessor-->>Kafka: Nack (재시도 유도)
                end
            else RDB 저장 성공
                rect rgba(0, 128, 0, 0.1)
                InitialProcessor-->>Kafka: 5. Kafka Ack (메시지 소비 완료)
                InitialProcessor->>AsyncBenefitHandler: 6. 비동기 혜택 지급 요청 (이벤트 ID 전달)
                end
                rect rgba(0, 128, 0, 0.1)
                Note over InitialProcessor, LockService: (중요!) 비동기 호출 후 락 해제
                InitialProcessor-->>LockService: 락 해제
                end

                rect rgba(144, 238, 144, 0.1)
                AsyncBenefitHandler->>ActivityEventStore: 7. 이벤트 상태 변경 (PROCESSING)
                AsyncBenefitHandler->>AsyncBenefitHandler: 8. 실제 혜택 지급 로직 수행<br>(외부 PromotionEngine 연동 등)
                AsyncBenefitHandler->>ActivityEventStore: 9. 최종 상태 업데이트 (SUCCESS/FAILED)
                end
            end
        end
    end

 

좀 복잡합니다. 좀 더 이해하기 쉬운 흐름도는 다음과 같아요.

 

graph TD
    A[사용자 활동 이벤트] --> B{활동 유효성 검증}
    B -->|실패| C[Nack/DLQ 처리]
    B -->|성공| D{혜택 지급 필요?}

    D -->|불필요| E[Ack 완료]
    D -->|필요| F[분산 락 획득]

    F -->|실패| G[재시도 대기]
    F -->|성공| H[이벤트 저장소 기록]

    H -->|실패| I[락 해제 & Nack]
    H -->|성공| J[Kafka Ack]

    J --> K[비동기 혜택 지급]
    K --> L[처리 결과 업데이트]

    G --> F
    I --> F

    M[정리 스케줄러] -.-> N[미처리 이벤트 정리]
    N -.-> H

    style A fill:#e1f5fe
    style E fill:#c8e6c9
    style J fill:#c8e6c9
    style L fill:#c8e6c9
    style C fill:#ffcdd2
    style G fill:#fff3e0
    style I fill:#ffcdd2

 

그럼 이제 도입한 주요 해결책들을 살펴보겠습니다.

3.1 Step 1 ― 메시지 유실 방지: "일단 받고, 안전하게 보관부터!" (수동 Ack + ActivityEventStore 도입)

가장 먼저 해결해야 할 문제는 메시지 유실이었습니다. 이를 위해 Kafka 컨슈머의 메시지 처리 방식을 근본적으로 변경했습니다.

 

아이디어:

  • Kafka 컨슈머(UserActivityEventConsumer)는 메시지를 poll 하자마자 바로 복잡한 비즈니스 로직을 수행하는 대신, 수신된 이벤트의 주요 정보와 초기 상태(예: "PENDING")를 ActivityEventStore라는 별도의 영속적인 데이터 저장소(RDB 사용)에 먼저 기록한다.
  • 이 RDB 저장 작업이 성공적으로 완료되었을 때만 Kafka 브로커에 해당 메시지의 오프셋을 수동으로 커밋(Acknowledge) 한다.
  • 실제 혜택 지급과 관련된 복잡한 로직은 ActivityEventStore에 저장된 이벤트를 기준으로, 후속 처리기(예: 내부 이벤트 리스너, 별도의 워커 스레드/프로세스, 또는 스케줄러)에 의해 비동기적으로 수행되도록 구조를 변경한다.
  • 이는 에피소드 1에서 잠시 언급했던 Inbox Pattern과 매우 유사한 접근 방식입니다.

목표:

  1. 메인 비즈니스 로직(혜택 지급)의 성공 여부와 관계없이 Kafka 오프셋이 자동으로 커밋되어 발생하는 메시지 유실을 원천적으로 방지한다.
  2. 모든 수신 이벤트를 ActivityEventStore에 일단 기록함으로써, 시스템 장애 발생 시에도 어떤 이벤트까지 안전하게 접수되었는지 명확히 추적하고, 이를 기반으로 안정적인 복구 및 재처리가 가능하도록 한다.
  3. Kafka 메시지 수신/커밋 로직과 실제 비즈니스 로직 처리 간의 결합도를 낮춘다.

슈도 코드 (핵심 로직 흐름):

 기능 UserActivityEventConsumer가 Kafka로부터 "사용자 활동 이벤트"를 받으면:

        1. 이벤트에서 고유한 "활동 ID"를 가져온다.
        2. (선택 사항) "활동 이벤트 저장소"에서 이 "활동 ID"가 이미 처리 중이거나 성공한 상태인지 확인한다.
           만약 그렇다면:
               "이미 처리된 이벤트이므로 건너뛴다"고 로그를 남긴다.
               Kafka에 "이 메시지는 이제 처리 완료됨(Ack)"이라고 알린다.
               함수를 종료한다.

        3. "활동 이벤트 저장소"에 현재 이벤트 정보를 "처리 대기 중(PENDING)" 상태로, 재시도 횟수는 0으로 저장한다.
           만약 저장 중 "이미 존재하는 활동 ID" 오류가 발생하면 (드문 경우지만, 중복 수신 시):
               "중복된 이벤트이므로 건너뛴다"고 로그를 남긴다.
               Kafka에 "처리 완료됨(Ack)"이라고 알린다.
               함수를 종료한다.
           만약 다른 이유로 저장에 실패하면 (예: 데이터베이스 연결 오류):
               "저장 실패! 이 메시지는 나중에 다시 시도해야 한다"고 심각한 오류 로그를 남긴다.
               Kafka에 "이 메시지 처리 실패했으니 나중에 다시 달라(Nack)"고 알린다. (주의: 무한 재시도 방지책 필요)
               함수를 종료한다.
        
        4. "활동 이벤트 저장소"에 저장이 성공했으므로, Kafka에 "이 메시지는 이제 처리 완료됨(Ack)"이라고 알린다.
        
        5. 후속 실제 혜택 지급 처리를 위해, 내부적으로 "혜택 지급 요청" 이벤트를 발행한다.
           (이때, "활동 ID"만 전달하여 실제 데이터는 "활동 이벤트 저장소"에서 다시 조회하도록 한다.)

    끝 기능

 

 

<svg width="800" height="950" viewBox="0 0 800 950" xmlns="http://www.w3.org/2000/svg" style="font-family: 'Nanum Gothic', Arial, sans-serif; font-size: 13px; background-color: #f9f9f9;">

  <defs>
    <marker id="arrowhead_flow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#333"/>
    </marker>
    <style>
      .flow-rect { fill: #e6f7ff; stroke: #91d5ff; stroke-width: 1.5; rx: 6; ry: 6; }
      .flow-decision { fill: #fffbe6; stroke: #ffe58f; stroke-width: 1.5; } /* 다이아몬드 형태는 path로 */
      .flow-start-end { fill: #f6ffed; stroke: #b7eb8f; stroke-width: 1.5; rx: 25; ry: 25; }
      .flow-error { fill: #fff1f0; stroke: #ffccc7; stroke-width: 1.5; rx: 6; ry: 6; }
      .text-node { font-size: 12px; fill: #333; text-anchor: middle; dominant-baseline: middle; }
      .text-arrow { font-size: 10px; fill: #555; text-anchor: middle; }
      .arrow-flow { stroke: #5494d3; stroke-width: 2; marker-end: url(#arrowhead_flow); }
      .arrow-branch { stroke: #597ef7; stroke-width: 1.5; marker-end: url(#arrowhead_flow); }
    </style>
  </defs>

  <text x="400" y="40" text-anchor="middle" style="font-size: 16px; font-weight: bold; fill: #2c3e50;">Step 3.1: 컨슈머 메시지 처리 및 Inbox 저장 흐름</text>

  <rect x="300" y="80" width="200" height="50" class="flow-start-end"/>
  <text x="400" y="105" class="text-node">Kafka "사용자 활동 이벤트" 수신</text>

  <rect x="300" y="160" width="200" height="50" class="flow-rect"/>
  <text x="400" y="185" class="text-node">1. "활동 ID" 가져오기</text>

  <path d="M 400 240 L 480 290 L 400 340 L 320 290 Z" class="flow-decision"/>
  <text x="400" y="290" class="text-node" dy="-5">2. 이미 처리/진행 중인</text>
  <text x="400" y="290" class="text-node" dy="10"> "활동 ID"인가?</text>

  <rect x="550" y="265" width="200" height="50" class="flow-rect"/>
  <text x="650" y="280" class="text-node">로그: "이미 처리됨, 건너뛰기"</text>
  <text x="650" y="295" class="text-node">Kafka Ack</text>

  <rect x="300" y="370" width="200" height="60" class="flow-rect"/>
  <text x="400" y="390" class="text-node">3. "활동 이벤트 저장소"에</text>
  <text x="400" y="405" class="text-node">저장 시도 (상태: PENDING)</text>

  <path d="M 400 460 L 480 510 L 400 560 L 320 510 Z" class="flow-decision"/>
  <text x="400" y="510" class="text-node">저장 성공 여부?</text>

  <rect x="550" y="400" width="200" height="50" class="flow-error"/>
  <text x="650" y="415" class="text-node">로그: "중복 이벤트, 건너뛰기"</text>
  <text x="650" y="430" class="text-node">Kafka Ack</text>

  <rect x="50" y="485" width="200" height="60" class="flow-error"/>
  <text x="150" y="505" class="text-node">로그: "DB 저장 실패!</text>
  <text x="150" y="520" class="text-node">재시도 필요"</text>
  <text x="150" y="535" class="text-node">Kafka Nack (주의!)</text>

  <rect x="300" y="590" width="200" height="50" class="flow-rect"/>
  <text x="400" y="615" class="text-node">4. Kafka Ack (저장 성공)</text>

  <rect x="300" y="670" width="200" height="60" class="flow-rect"/>
  <text x="400" y="690" class="text-node">5. 내부 "혜택 지급 요청"</text>
  <text x="400" y="705" class="text-node">이벤트 발행 ("활동 ID" 전달)</text>

  <rect x="300" y="760" width="200" height="50" class="flow-start-end"/>
  <text x="400" y="785" class="text-node">처리 완료 (후속 로직 시작)</text>

  <rect x="50" y="570" width="200" height="50" class="flow-start-end"/>
  <text x="150" y="595" class="text-node">컨슈머 로직 종료 (오류)</text>

  <rect x="550" y="325" width="200" height="50" class="flow-start-end"/>
  <text x="650" y="350" class="text-node">컨슈머 로직 종료 (건너뛰기)</text>


  <line x1="400" y1="130" x2="400" y2="160" class="arrow-flow"/>
  <line x1="400" y1="210" x2="400" y2="240" class="arrow-flow"/>

  <line x1="480" y1="290" x2="550" y2="290" class="arrow-branch"/>
  <text x="515" y="280" class="text-arrow">예 (Yes)</text>
  <line x1="400" y1="340" x2="400" y2="370" class="arrow-branch"/>
  <text x="420" y="355" class="text-arrow">아니요 (No)</text>
  <line x1="650" y1="315" x2="650" y2="325" class="arrow-branch"/>


  <line x1="480" y1="510" x2="550" y2="425" class="arrow-branch" />
  <text x="525" y="455" class="text-arrow">중복 키 오류</text>
  <line x1="650" y1="450" x2="650" y2="375" class="arrow-branch" />


  <line x1="320" y1="510" x2="250" y2="515" class="arrow-branch"/>
  <text x="280" y="500" class="text-arrow">기타 DB 오류</text>
  <line x1="150" y1="545" x2="150" y2="570" class="arrow-branch"/>


  <line x1="400" y1="560" x2="400" y2="590" class="arrow-branch"/>
  <text x="430" y="575" class="text-arrow">저장 성공!</text>

  <line x1="400" y1="640" x2="400" y2="670" class="arrow-flow"/>
  <line x1="400" y1="730" x2="400" y2="760" class="arrow-flow"/>

  <text x="180" y="850" text-anchor="middle" style="font-size:11px; fill:#777;">* "Kafka Nack (주의!)"는 실제 구현 시 Kafka Error Handler와 연동하여</text>
  <text x="210" y="865" text-anchor="middle" style="font-size:11px; fill:#777;">  최대 재시도 횟수 제한 및 DLQ(Dead Letter Queue) 처리가 필요함을 의미합니다.</text>


</svg>

 

그렇다면 이 전략은 과연 만능일까요? 🧐

 

그렇지 않죠. 


 

Q. 이 방식을 사용하면 가장 좋은 점이 뭔가요? 👍

 

A. 크게 네 가지를 꼽을 수 있습니다!

 

첫째, 메시지 유실 가능성이 거의 사라집니다. 실제 혜택 지급 로직에서 오류가 나더라도, 일단 ActivityEventStore에 이벤트가 안전하게 보관된 후 Kafka에 Ack를 보내기 때문에, 컨슈머가 갑자기 죽어도 이벤트가 사라지지 않아요. "어? 내 포인트 어디 갔지?" 하는 문의가 확 줄어들겠죠?

 

둘째, 모든 이벤트의 이력을 한눈에 볼 수 있습니다. ActivityEventStore라는 중앙 저장소가 생기면서, 어떤 이벤트가 언제 들어왔고 현재 어떤 상태(처리 대기, 처리 중, 성공, 실패 등)인지 쉽게 추적할 수 있게 됩니다. 마치 모든 택배의 배송 과정을 송장번호 하나로 조회하는 것처럼요!

 

셋째, 골치 아픈 재처리 문제를 해결할 실마리가 보입니다. 이전에는 실패한 메시지를 Kafka가 다시 주면 처음부터 모든 걸 다시 해야 했지만, 이제는 ActivityEventStore에 저장된 정보를 바탕으로 "아, 이 부분부터 다시 하면 되겠네!" 하고 좀 더 똑똑하게 재시도를 설계할 수 있는 기반이 마련됩니다.

 

넷째, 각자 역할에만 집중할 수 있게 됩니다. Kafka 컨슈머는 "메시지를 안전하게 받아서 잘 기록하는 문지기" 역할에만 충실하고, 실제 혜택 지급 로직은 "기록된 내용을 보고 꼼꼼하게 일하는 일꾼"처럼 각자의 책임을 명확히 나눌 수 있게 되죠. 이렇게 하면 코드가 더 깔끔해지고 관리하기도 쉬워집니다.

 

 

Q. 듣기만 하면 완벽해 보이네요. 단점은 없을까요? 🤔

 

A. 모든 기술 선택에는 빛과 그림자가 있죠. 이 방식도 몇 가지 고려해야 할 단점이 있습니다.

 

첫째, 데이터베이스(ActivityEventStore)가 조금 더 바빠집니다. 모든 메시지가 한 번씩 DB에 기록되니, 특히 이벤트가 쏟아질 때는 DB의 쓰기 성능(Write I/O)에 부담이 될 수 있습니다. DB가 느려지면 전체 시스템이 영향을 받을 수 있으니, DB 성능 관리가 중요해집니다.

 

둘째, 시스템이 살짝 더 복잡해집니다. DB 스키마도 설계해야 하고, 이벤트를 저장하고 상태를 바꾸는 로직도 만들어야 하고, 나중에 이 저장된 이벤트를 가져다가 실제 처리하는 부분도 따로 만들어야 하니… 개발하고 관리해야 할 부분이 늘어나는 건 사실입니다.

 

셋째, "완벽한 중복 제거"는 여전히 숙제입니다. 예를 들어, ActivityEventStore에 이벤트 저장까지는 성공했는데, Kafka에 "나 처리 끝났어요(Ack)!" 하고 알리기 직전에 컨슈머가 딱 멈춰버리면 어떻게 될까요? 컨슈머가 다시 살아나면 Kafka는 "어? 아까 그 메시지 아직 확인 못 받았는데?" 하면서 똑같은 메시지를 또 줄 수 있습니다. 그러면 ActivityEventStore에 같은 이벤트가 두 번 저장될 수도 있겠죠. (물론 슈도 코드에서처럼 이벤트 고유 ID를 DB의 기본 키(PK)로 잡아서 중복 저장을 막거나, 이미 있는지 확인하는 로직으로 어느 정도 방어는 가능합니다!) 중요한 건, 이 메시지가 결국 여러 번 처리되지 않도록 후속 로직에서 반드시 멱등성을 꼼꼼하게 챙겨야 한다는 점입니다.

 

넷째, 아주 약간, 정말 약간 느려질 수 있습니다. 메시지를 받자마자 바로 처리하는 게 아니라, DB에 한번 기록하는 과정이 추가되니 전체 처리 시간이 몇 밀리초(ms) 정도는 늘어날 수 있습니다. 대부분의 서비스에서는 크게 체감하기 어렵겠지만, 정말 극한의 실시간성이 중요한 시스템이라면 이 부분도 고려해야겠죠.


 

이처럼 ActivityEventStore를 도입하고 수동 Ack 방식으로 전환함으로써, "혜택콕" 시스템은 메시지 유실이라는 가장 큰 골칫거리로부터 한결 자유로워질 수 있었습니다. 하지만 이것만으로는 모든 문제가 해결된 것은 아니었습니다. 여전히 동시성 문제와 불안정한 재처리 문제가 남아있었죠.

3.2 Step 2 ― 동시성 제어: "혜택 지급, 한 번에 한 명씩만!" (분산 락 도입)

ActivityEventStore를 통해 메시지 유실은 막았지만, 여전히 동일 사용자에게 혜택이 중복 지급될 수 있는 동시성 문제는 남아있었습니다.

 

특히 "혜택콕" 플랫폼처럼 여러 컨슈머 인스턴스가 동시에 동일한 사용자의 이벤트를 처리할 가능성이 있는 분산 환경에서는, 공유 자원(예: 특정 사용자의 특정 캠페인에 대한 혜택 지급 자격)에 대한 접근을 통제하는 것이 필수적이었습니다. 이를 위해 저희는 분산 락(Distributed Lock) 메커니즘을 도입했습니다.

 

아이디어:

  • 실제 혜택 지급 로직(특히 사용자의 현재 혜택 상태를 확인하고, 그에 따라 DB를 업데이트하는 부분)을 실행하기 전에, 특정 사용자(userId)와 특정 혜택 캠페인(benefitCampaignId)의 조합을 고유 키로 하는 락(Lock)을 획득한다.
  • 락을 성공적으로 획득한 경우에만 해당 로직을 수행하고, 로직 완료 후에는 반드시 락을 해제한다.
  • 만약 락 획득에 실패하면 (즉, 다른 스레드나 인스턴스가 이미 해당 사용자+캠페인에 대한 작업을 진행 중이라면), 현재 요청은 대기하거나, 처리를 잠시 뒤로 미루고 재시도한다. 
  • 이때 Redis를 분산 락 구현체로 활용했습니다.

목표:

  1. 동일 userId + benefitCampaignId 조합에 대한 혜택 지급 로직이 동시에 여러 곳에서 실행되는 것을 방지하여, 데이터 부정합 및 중복 혜택 지급 문제를 원천적으로 차단한다.
  2. 혜택 지급 결정 및 실행 과정을 사실상 원자적(atomic)으로 만들어, 일관된 처리 결과를 보장한다.

 

슈도 코드 (핵심 로직 흐름):

 

기능 "혜택 지급 요청 처리기"가 "활동 ID"를 받아 혜택을 지급하려 할 때:

        1. "활동 이벤트 저장소"에서 "활동 ID"로 해당 활동 정보를 가져온다.
           만약 정보가 없으면, "활동 정보를 찾을 수 없다"는 오류를 내고 종료한다.
           만약 활동 정보의 상태가 이미 "성공"이나 "영구 실패" 등 최종 상태라면,
               "이미 최종 처리된 활동이다"라고 로그를 남기고 종료한다.

        2. 락을 잡기 위한 고유한 "잠금 키"를 만든다. (예: "잠금:혜택:{사용자ID}:{캠페인ID}")
        3. 이 "잠금 키"로 분산 락 시스템(예: Redis)에 락을 요청한다. 
           (예: 최대 5초 동안 락을 기다리고, 락을 잡으면 30초 동안 유효하도록 설정)

        4. 만약 락을 성공적으로 잡았다면:
            가. (매우 중요!) 락을 잡은 후, 다시 한번 "활동 이벤트 저장소"에서 "활동 ID"로 최신 활동 정보를 가져온다. 
                (아주 짧은 시간차로 다른 요청이 먼저 락을 잡고 처리했을 가능성에 대비)
                만약 다시 확인한 정보의 상태가 이미 최종 상태라면,
                    "다시 확인하니 이미 최종 처리된 활동이다" 로그를 남기고, (락 해제 후) 종료한다.

            나. "활동 이벤트 저장소"에 해당 활동의 상태를 "처리 중(PROCESSING)"으로 변경한다.

            다. (멱등성 확보를 위한 추가 확인) "혜택 지급 내역"을 확인하여, 이 활동과 동일한 조건으로 이미 혜택이 지급되었는지 다시 한번 꼼꼼히 확인한다.
                만약 이미 지급되었다면:
                    "멱등성 확인: 이미 혜택이 지급된 활동이다" 로그를 남긴다.
                    "활동 이벤트 저장소"의 상태를 "중복 건너뜀(SKIPPED_IDEMPOTENCY)"으로 변경하고, (락 해제 후) 종료한다.
            
            라. 드디어 실제 "혜택 지급 로직"을 실행한다! (예: 외부 "혜택 규칙 엔진" 호출, 사용자 DB에 포인트 적립)
            
            마. 혜택 지급 로직의 결과를 확인한다.
                만약 성공했다면:
                    "혜택 지급 성공!" 로그를 남긴다.
                    "활동 이벤트 저장소"의 상태를 "성공(SUCCESS)"으로 변경한다.
                만약 실패했다면:
                    "혜택 지급 실패. 원인: {실패 이유}" 로그를 남긴다.
                    실패 원인과 재시도 횟수에 따라 "활동 이벤트 저장소"의 상태를 적절히 변경한다. 
                    (예: 일시적 오류면 "재시도 필요(RETRY_BUSINESS_LOGIC)", 영구적 오류면 "영구 실패(FAILED_PERMANENT)")
            
            바. (필수!) 잡았던 락을 반드시 해제한다.

        5. 만약 락 잡기에 실패했다면 (다른 요청이 이미 락을 잡고 있거나 너무 오래 기다려서):
            "락 획득 실패. 나중에 다시 시도할 것이다" 로그를 남긴다.
            "활동 이벤트 저장소"의 상태를 "락 실패로 재시도 필요(RETRY_LOCK_FAILED)"로 변경하고, 재시도 횟수를 1 증가시킨다. 
            (또는 Kafka 컨슈머 레벨에서 Nack을 보내 재시도하도록 유도할 수도 있다.)

        (오류 처리) 만약 위 과정 중 얘기치 않은 오류가 발생하면:
            심각한 오류 로그를 남긴다.
            "활동 이벤트 저장소"의 상태를 "예기치 않은 오류로 실패(FAILED_UNEXPECTED)"로 변경하고 재시도 횟수를 증가시킨다.
            만약 락을 잡은 상태였다면, 반드시 락을 해제한다.

    끝 기능

 


마찬가지로 분산 락 역시 은총알은 아닙니다!


 

 

Q. 와, 분산 락을 쓰니 중복 지급 문제가 해결될 것 같아요! 또 어떤 좋은 점이 있나요? 👍

 

A. 네, 맞습니다! 가장 큰 장점은 역시 데이터 정합성과 일관성을 지킬 수 있다는 점입니다. 마치 인기 있는 한정판 상품을 사려고 여러 사람이 동시에 몰려들 때, "한 명씩 들어오세요!" 하고 줄을 세우는 안전요원처럼, 분산 락은 동일한 혜택에 대한 동시 요청들을 질서 있게 처리하도록 만들어줍니다.

 

덕분에 중복 지급 같은 골치 아픈 데이터 불일치 문제를 거의 완벽하게 막을 수 있죠. 또 다른 장점은, 혜택 지급 조건을 확인하고 실제 지급하는 과정을 하나의 논리적인 작업 단위처럼 보호해준다는 거예요. 중간에 다른 요청이 끼어들어 데이터를 이상하게 만드는 것을 막아주니, 마치 중요한 계약서에 서명하는 동안 아무도 방해하지 못하게 문을 잠그는 것과 비슷하다고 할 수 있습니다.

 

 

Q. 그럼 단점은 전혀 없나요? 너무 좋아 보이는데요! 🤔

 

A. 아쉽게도 완벽한 해결책은 세상에 없답니다. 분산 락도 몇 가지 신중하게 고려해야 할 단점들이 있어요.

 

첫째, 시스템 전체가 약간 느려질 수 있습니다. 특히 특정 사용자에게 짧은 시간에 여러 이벤트가 몰리거나, 아주 인기 있는 혜택 캠페인에 요청이 집중되면, 락을 얻기 위한 경쟁이 치열해지겠죠? 이렇게 락을 기다리는 시간이 길어지면 전체 시스템의 처리 속도가 떨어질 수 있습니다. 놀이공원에서 인기 있는 놀이기구를 타려고 길게 줄을 서는 것과 비슷해요.

 

둘째, 분산 락을 관리해 주는 외부 시스템(예: Redis)에 대한 의존성이 생깁니다. 만약 이 Redis 서버에 문제가 생기면 락을 잡거나 풀 수 없게 되고, 결국 전체 혜택 지급 시스템이 멈춰버릴 수도 있습니다. 그래서 Redis 자체도 안정적으로 운영(예: 클러스터 구성)하는 것이 매우 중요해집니다.

 

셋째, 구현 자체가 좀 더 복잡해집니다. 락을 언제까지 잡고 있을지(락 유효 시간, Lease Time), 만약 락을 잡은 녀석이 갑자기 죽어버리면 락은 어떻게 자동으로 풀리게 할지, 여러 요청이 동시에 락을 요청할 때 어떤 순서로 처리할지(데드락 방지) 등등… 신경 써야 할 부분이 한두 가지가 아닙니다.

 

넷째, "Thundering Herd"라고 불리는 문제도 생길 수 있어요. 이건 뭐냐면, 특정 락이 풀리기를 여러 요청이 기다리고 있다가, 락이 풀리는 순간 다 같이 "와!" 하고 몰려들어서 시스템에 순간적으로 큰 부담을 줄 수 있다는 거예요.

 

 

마지막으로, "잠금 키(Lock Key)"를 어떻게 설계하느냐가 정말 중요합니다. 너무 광범위하게 락을 잡으면(예: 모든 혜택 지급에 대해 단 하나의 락), 아무 상관없는 요청들까지 줄줄이 기다리게 되어 시스템 전체가 거북이처럼 느려질 수 있어요. 반대로 너무 세밀하게 락을 잡으면 정작 필요한 동시성 제어가 안 될 수도 있고요. 저희는 "사용자 ID + 혜택 캠페인 ID" 조합으로 락 키를 만들어, 너무 넓지도 너무 좁지도 않은 적절한 수준으로 제어하려고 노력했습니다.

 

 


 

구현 못지않게 중요한 게 테스트를 통한 검증일 텐데요.

 

이러한 분산 락 메커니즘의 효과를 검증하기 위해, 테스트 프레임워크인 Cucumber를 사용하여 "동일 사용자에게 짧은 시간 내에 여러 개의 동일 캠페인 관련 '미션 완료 이벤트'가 발생하는 시나리오"를 정의했습니다. 그리고 각 이벤트 처리 스레드가 혜택 지급 로직의 특정 지점(락 획득 직전)에 도달하면 CountDownLatch를 이용해 모든 스레드가 동시에 해당 지점을 통과하도록 제어했습니다.

 

이를 통해 실제로 여러 요청이 동시에 락을 경쟁하는 상황을 시뮬레이션하고, 분산 락이 정상적으로 동작하여 단 하나의 요청만이 혜택 지급 로직을 수행하고 나머지는 대기하거나 실패(재시도 대상)하는지, 그리고 최종적으로 중복 지급이 발생하지 않는지를 철저히 검증할 수 있었습니다. 이 과정은 분산 시스템에서 동시성 이슈를 디버깅하고 해결책을 확신하는 데 매우 중요한 역할을 했습니다.

 

분산 락을 도입함으로써 "혜택콕" 시스템은 동시성으로 인한 데이터 부정합 문제로부터 상당 부분 안전해졌습니다. 하지만 아직 해결해야 할 숙제가 남아있었습니다. 바로 일시적인 오류로 인해 실패한 이벤트들을 어떻게 안정적으로, 그리고 시스템에 부담을 주지 않으면서 재처리할 것인가 하는 문제와, 오랫동안 처리되지 못하고 방치되는 이벤트들을 어떻게 관리할 것인가 하는 문제였습니다.

3.3 Step 3 ― 안정적인 재처리 및 최종 상태 관리: "실패는 기록되고, 결국엔 처리된다!" 🛡️ Retry & Finalize!

앞선 두 단계로 메시지 유실을 막고 동시성 문제도 해결했지만, 여전히 남은 숙제는 '실패한 이벤트를 어떻게 똑똑하게 재시도하고, 모든 이벤트의 운명을 끝까지 책임질 것인가'였습니다.

 

"혜택콕"은 ActivityEventStore를 기반으로, 이벤트의 생명주기를 관리하는 체계를 구축했습니다.

 

핵심 아이디어:

  • 이벤트는 ActivityEventStore 내에서 명확한 상태(PENDING, PROCESSING, RETRY_NEEDED, SUCCESS, FAILED_PERMANENT 등)와 재시도 횟수를 갖는다. 일시적 오류는 지능적 재시도(예: 지수 백오프)로 극복하고, 반복 실패 시엔 명확히 "실패" 마킹 후 격리한다. 모든 이벤트는 최종적으로 SUCCESS 또는 FAILED_PERMANENT 상태에 도달하며, 장기 미처리 이벤트는 스케줄러가 관리한다.


주요 목표:

  1. 최종 일관성 달성: 외부 시스템 일시 장애 시 자동 복구 시도.
  2. 시스템 과부하 방지: 무분별한 재시도 제한.
  3. 추적 가능한 생명주기: 모든 이벤트의 성공/실패 원인 및 과정 추적.
  4. 운영 가시성 확보: 최종 실패 이벤트에 대한 분석 및 조치 기반 마련.

 

구현 하이라이트:

 


1.  ActivityEventStore 상태 머신 기반 처리:

  • 모든 이벤트는 ActivityEventStore에 저장될 때 status와 retry_count를 가집니다.
  • 실패 유형(일시적/영구적), 재시도 횟수에 따라 status가 전이됩니다. (예: RETRY_BUSINESS_LOGIC -> PROCESSING -> SUCCESS 또는 FAILED_PERMANENT)


이를 시각화하면 아래와 같은 이벤트 상태 전이 흐름을 생각해 볼 수 있습니다.

 

stateDiagram-v2
        direction LR
        [*] --> PENDING: 이벤트 수신
        PENDING --> PROCESSING: 처리 시작 (락 획득 후)
        PROCESSING --> SUCCESS: 혜택 지급 성공
        PROCESSING --> RETRY_BUSINESS_LOGIC: 일시적 로직 실패
        PROCESSING --> FAILED_PERMANENT: 영구적 로직 실패 / 최대 재시도 초과
        RETRY_BUSINESS_LOGIC --> PROCESSING: 재시도 (워커/스케줄러)
        RETRY_LOCK_FAILED: 락 획득 실패 시 대기
        RETRY_LOCK_FAILED --> PROCESSING: 락 획득 후 처리 시작
        SUCCESS --> [*]
        FAILED_PERMANENT --> [*]

        note left of PENDING : ActivityEventStore에 저장
        note right of PROCESSING : 분산 락 점유 구간
        note right of RETRY_BUSINESS_LOGIC : 재시도 횟수 증가, 지수 백오프 적용

 

 

 

2. 지능형 재시도 워커/스케줄러:

 

별도의 스케줄러가 주기적으로 ActivityEventStore에서 RETRY_LOCK_FAILED 또는 RETRY_BUSINESS_LOGIC 상태이고, 최대 재시도 횟수를 넘지 않았으며, 다음 재시도 시간이 도래한 이벤트를 조회합니다.

  • 조회된 이벤트에 대해 다시 혜택 지급 로직(분산 락 획득부터)을 시도합니다.
  • 지수 백오프(Exponential Backoff): 재시도 간격을 실패 횟수에 따라 점진적으로 늘려 (예: 1초, 2초, 4초, 8초...), 외부 시스템에 가해지는 부담을 줄이고 자체 회복 시간을 줍니다.
  • 최대 재시도 횟수(Max Retries) 초과 시: FAILED_PERMANENT 상태로 변경하고, 운영팀 알림 또는 Dead Letter Queue(DLQ)로 보내 사후 분석 및 조치를 가능하게 합니다.

 

3. "좀비" 이벤트 방지를 위한 PendingActivityCleanupScheduler 🧹:

  • 예기치 못한 이유로 PENDING이나 PROCESSING 상태로 오랫동안 멈춰있는 "Stuck" 이벤트를 감지합니다.
  • 주기적으로(예: 1시간마다) 이러한 이벤트를 찾아 운영팀에 알리거나, 특정 시간 초과 시 자동으로 FAILED_TIMEOUT 같은 상태로 변경하여 더 이상 리소스를 낭비하지 않도록 합니다.

 


 

 

그래서, 좋아졌나요? 🤔 장점과 현실적인 고민들!

 

Q. 이렇게까지 하면 시스템이 정말 튼튼해지겠네요! 👍

A. 네, 확실히 시스템 복원력과 내결함성이 크게 향상됩니다! 외부 시스템이 잠시 아파도 스스로 회복해서 결국엔 혜택을 지급해 줄 가능성이 높아지죠. 또, 똑똑한 재시도 덕분에 시스템 전체가 느려지거나 멈추는 일도 막을 수 있고요. 모든 이벤트의 처리 과정을 투명하게 볼 수 있으니, 문제 해결이나 운영에도 큰 도움이 됩니다.

 



Q. 흠... 여전히 틈이 있는 부분은 없을까요? 😥

A. 물론 완벽하다고 단언할 수는 없습니다. 예를 들어, PendingActivityCleanupScheduler가 "Stuck" 이벤트를 너무 늦게 발견하거나, 재시도 정책이 모든 예외 상황을 완벽하게 커버하지 못할 수도 있습니다. 또한, Dead Letter Queue(DLQ)로 보내진 FAILED_PERMANENT 이벤트들도 결국 누군가는 들여다보고 원인을 분석하거나 수동으로 재처리해야 하는 운영 부담이 여전히 남아있죠. 엣지 케이스들의 발생 가능성을 열어두고 모니터링이 필요합니다.



Q. 그런데 복잡성은 너무 커 보이네요. 이렇게 복잡할 거면 애초에 설계부터 잘못된 것은 아닌가 하는 의구심이 들어요. 😥

A. 좋은 지적입니다! 사실 "단순함이 최고다(Simplicity is the ultimate sophistication)"라는 말처럼, 가능하다면 시스템은 단순하게 유지하는 것이 가장 좋습니다. 하지만 "혜택콕"과 같이 수많은 사용자의 활동 이벤트를 실시간으로 처리하고, 외부 시스템과 연동하며, 데이터의 정합성과 시스템의 안정성을 동시에 확보해야 하는 요구사항이 복합적으로 얽히게 되면, 어느 정도의 복잡성 증가는 불가피한 측면이 있는 것 같아요. 중요한 것은 "관리 가능한 복잡성(Managed Complexity)"을 유지하는 것이 아닐까요?

즉, 각 컴포넌트의 역할과 책임을 명확히 하고, 표준화된 방식으로 상호작용하도록 설계하며, 이번에 논의된 것처럼 견고한 오류 처리 및 재시도 메커니즘을 갖춤으로써, 복잡하더라도 예측 가능하고 통제 가능한 시스템을 만드는 것이 중요한 것 같습니다. 서비스가 성장하고 새로운 요구사항과 문제점들이 드러나면서 점진적으로 시스템을 발전시켜 나가는 과정으로 이해해 주시면 좋을 것 같아요.




이처럼 체계적인 상태 관리와 재시도 메커니즘, 그리고 최종 처리를 보장하는 스케줄러의 도입은 "혜택콕" 시스템의 안정성과 신뢰성을 한 차원 높이는 결정적인 역할을 했습니다. 비록 시스템의 복잡성은 다소 증가했지만, 그로 인해 얻게 된 시스템의 견고함과 예측 가능성은 충분히 그만한 가치가 있었습니다.

 

하지만 이런 내부적인 노력만으로는 충분하지 않습니다. 아무리 내부 시스템이 견고해도, 우리가 어떤 일이 벌어지고 있는지 제대로 알지 못한다면 문제 발생 시 제대로 대응할 수 없겠죠? 그래서 다음 단계에서는 시스템 내부를 투명하게 들여다볼 수 있는 창과 계기판, 즉 로그와 모니터링의 중요성을 간략히 짚어보려 합니다.

3.4 Step 4 ― 투명한 시스템 운영: 로그와 모니터링으로 단서를 남겨 놓기

앞서 설명드린 개선 과정들은 시스템을 더욱 견고하게 만들었지만, 동시에 내부 구조는 더욱 복잡해졌습니다. 이렇게 복잡해진 시스템의 속을 투명하게 들여다보고, 문제가 생겼을 때 재빨리 알아챌 수 있도록 관찰 가능성(Observability)을 확보하는 것은 선택이 아닌 필수입니다. 이 부분은 어떻게 챙길 수 있는지 간단히 짚어보겠습니다.

 

핵심 아이디어: "모든 것은 기록되고, 모든 것은 측정되며, 이상 징후는 즉시 알려진다!"

 

주요 실천 사항:

  1. 상황별 로그 레벨 전략: 에피소드 1에서처럼, DEBUG (상세 흐름), INFO (주요 처리 성공), WARN (잠재적 문제/재시도), ERROR (명백한 실패/오류)로 구분하여 "로그 폭탄"은 피하면서도 문제 추적에 필요한 정보는 충분히 남겼습니다. (예: activityId, status, retry_count 포함)
  1. 핵심 지표(Key Metrics) 시각화: Prometheus, Grafana 등을 활용해 ActivityEventStore의 상태별 이벤트 개수, 평균 재시도 횟수, 분산 락 성공/실패율, 최종 처리까지의 시간(End-to-End Latency), Kafka 컨슈머 Lag 등을 대시보드에서 한눈에 볼 수 있게 했습니다.
  2. 중요 상황 자동 알림(Alerting): FAILED_PERMANENT 이벤트 급증, 특정 상태에 머무는 이벤트 과다 발생, Kafka Lag 임계치 초과 등 운영에 치명적인 상황 발생 시 즉시 담당팀에 알림이 가도록 설정했습니다.

복잡한 시스템일수록 그 안을 들여다볼 수 있는 "창문"과 "계기판"을 잘 만드는 것이 중요합니다. "혜택콕"은 이러한 노력으로 문제 발생 시 원인 분석 시간을 단축하고, 더 나아가 잠재적 위험을 미리 감지하여 선제적으로 대응할 수 있는 기반을 마련할 수 있었습니다.



4. 결론

지금까지 〈이벤트 드리븐 트러블슈팅〉 두 번째 에피소드를 통해, 가상의 실시간 사용자 활동 기반 혜택 지급 플랫폼이 Kafka 메시지 처리 과정에서 겪었던 다양한 내부 신뢰성 문제들과 이를 해결하기 위한 여정을 상세히 살펴보았습니다.

 

서비스 초기, 비교적 단순했던 아키텍처는 사용자가 증가하고 이벤트 트래픽이 늘어남에 따라 한계를 드러내기 시작했습니다. Kafka 컨슈머의 auto commit 설정은 편리함 이면에 메시지 유실이라는 치명적인 위험을 안고 있었고, 외부 시스템("혜택 규칙 엔진")의 일시적인 장애는 불안정한 재처리로 인해 시스템 전체의 마비로 이어지기도 했습니다. 또한, 분산 환경에서의 동시성 제어 부재는 동일 사용자에게 혜택이 중복 지급되는 데이터 부정합 문제를 야기했습니다. 이러한 문제들은 "혜택콕" 플랫폼의 핵심 가치인 '실시간 적시 혜택 지급'을 심각하게 훼손하고 있었습니다.

 

본 글에서는 이러한 문제점들을 해결하기 위해 "혜택콕" 플랫폼이 도입하고 발전시켜 온 다음과 같은 주요 해결 전략들을 단계별로 소개했습니다.

  1. 메시지 유실 방지를 위한 Kafka 수동 Ack 처리 및 ActivityEventStore(Inbox 패턴) 도입: 모든 수신 이벤트를 안전하게 선(先)저장 후 처리함으로써, 어떤 상황에서도 메시지가 유실되지 않도록 보장하는 강력한 첫 번째 방어선을 구축했습니다.
  2. 데이터 정합성 확보를 위한 분산 락(Redis 활용) 도입: 동일 리소스에 대한 동시 접근을 효과적으로 제어하여, 중복 혜택 지급과 같은 치명적인 오류를 방지하고 데이터의 일관성을 유지했습니다.
  3. 안정적인 재처리 및 최종 상태 관리를 위한 ActivityEventStore 상태 기반 로직 및 스케줄러 활용: 실패한 이벤트를 체계적으로 관리하고(지수 백오프 등), 정해진 정책에 따라 재시도하며, 모든 이벤트가 SUCCESS 또는 FAILED_PERMANENT와 같은 명확한 최종 상태에 도달하도록 보장했습니다. 또한, PendingActivityCleanupScheduler를 통해 장기간 미처리된 이벤트까지 관리했습니다.
  4. 투명한 시스템 운영을 위한 로그 및 모니터링 체계 강화: 시스템 내부 동작에 대한 가시성을 확보하여, 문제 발생 시 신속하게 원인을 파악하고 선제적으로 대응할 수 있는 기반을 마련했습니다.

물론, 이러한 개선 과정이 항상 순탄했던 것만은 아닙니다. 각 해결책을 도입할 때마다 시스템의 복잡도는 필연적으로 증가했고, 이는 개발 및 운영 비용의 상승으로 이어졌습니다. 예를 들어, ActivityEventStore 도입은 RDB에 대한 부하와 관리 포인트를 늘렸고, 분산 락은 Redis라는 또 다른 외부 시스템 의존성을 추가했습니다. 정교한 재시도 로직과 스케줄러는 그 자체로 또 하나의 작은 시스템을 만드는 것과 같았습니다.

 

하지만 이러한 트레이드오프에도 불구하고, "혜택콕" 플랫폼이 얻은 가장 큰 성과는 바로 시스템의 예측 가능성과 신뢰성 향상입니다.

 

더 이상 원인 모를 메시지 유실로 밤새 로그를 뒤지거나, 간헐적인 중복 지급 이슈로 긴급 패치를 반복하지 않아도 되었습니다. 장애가 발생하더라도 시스템이 스스로 일정 부분 회복하거나, 적어도 문제의 원인과 범위를 명확히 알려주어 운영팀이 효과적으로 대응할 수 있게 되었습니다.

 

결국 이벤트 드리븐 아키텍처에서 "이벤트가 성공적으로 발행되었다"는 것은 전체 여정의 시작점에 불과합니다. 그 이벤트가 수많은 단계를 거쳐 최종 목적지까지 안전하게(No Loss), 정확하게(No Duplicates, Correctness), 그리고 단 한 번만 효과적으로(Effectively-Once Processing via Idempotency) 처리되도록 보장하는 것, 이것이야말로 우리가 끊임없이 고민하고 개선해 나가야 할 엔지니어링의 본질일 것입니다.

 

이번 사례가 독자 여러분이 각자의 시스템에서 마주하고 있는 유사한 문제들을 해결하는 데 조금이나마 영감을 주고, 더 나아가 견고하고 신뢰할 수 있는 이벤트 기반 시스템을 구축하는 데 도움이 되었기를 바랍니다.

 

 

 

 

 


 

 


[1]: https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html "Kafka Consumer configuration reference for Confluent Platform"

[2]: https://stackoverflow.com/questions/51782515/if-i-set-value-of-commit-interval-ms-in-kafka-stream-whether-it-will-be-able "if i set value of commit.interval.ms = in kafka stream, Whether it will ..."

[3]: https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html?#auto-commit-interval-ms

 

 

 

 

 

반응형