서버 오류 및 복구 설계 #재시도, DLQ, Transactional Outbox, 멱등 소비자

분산 시스템에서 오류는 예외적인 상황이 아니다. 네트워크 단절, 데이터베이스 타임아웃, 서버 재시작, 메시지 브로커 장애 등 다양한 원인으로 작업이 실패할 수 있다.

중요한 것은 오류를 완전히 없애는 것이 아니라, 실패한 작업을 안전하게 복구하고 중복 실행과 데이터 불일치를 방지하는 것이다.

예를 들어 게임 서버에서 플레이어가 보스를 처치하면 전투 결과 저장, 보상 지급, 업적 갱신, 랭킹 업데이트, 알림 전송 등의 작업이 발생할 수 있다.

이 과정에서 보상 지급은 성공했지만 랭킹 업데이트가 실패할 수 있다. 전체 작업을 무작정 재시도하면 보상이 중복 지급될 수 있고, 재시도하지 않으면 랭킹 데이터가 누락될 수 있다.

이러한 문제를 해결하려면 재시도와 지수 백오프, Dead Letter Queue, Transactional Outbox, 실패 작업 재처리, 멱등 소비자 설계가 필요하다.

재시도와 지수 백오프

재시도의 목적

재시도(Retry)는 일시적인 오류가 발생했을 때 동일한 작업을 다시 실행하는 방식이다.

대표적인 재시도 대상은 다음과 같다.

  • 일시적인 네트워크 연결 실패
  • 데이터베이스 연결 타임아웃
  • HTTP 서버의 일시적인 오류
  • 메시지 브로커의 일시적인 장애
  • 외부 API의 요청 제한이나 과부하

반면 잘못된 입력값, 권한 부족, 데이터 무결성 위반처럼 요청 자체에 문제가 있다면 무조건 재시도해서는 안 된다.

오류 유형일반적인 처리
네트워크 일시 장애제한된 재시도
HTTP 429서버가 제시한 대기 시간 등을 고려해 재시도
HTTP 502, 503, 504정책에 따라 재시도
HTTP 400입력값 수정 없이는 재시도하지 않음
HTTP 401, 403인증 또는 권한 문제 해결
데이터 무결성 위반원인 분석 후 처리
타임아웃작업 반영 여부를 확인한 뒤 재시도

특히 타임아웃은 주의해야 한다. 클라이언트가 응답을 받지 못했더라도 서버에서는 작업이 이미 완료됐을 수 있기 때문이다.

지수 백오프

일정한 간격으로 재시도하면 여러 클라이언트의 요청이 동시에 몰릴 수 있다. 서버가 과부하 상태라면 재시도 요청이 부하를 더욱 증가시키는 악순환이 발생한다.

지수 백오프(Exponential Backoff)는 재시도할 때마다 대기 시간을 점진적으로 늘리는 방식이다.

delay = min(baseDelay * 2^attempt, maxDelay)

초기 대기 시간이 짧더라도 재시도가 반복되면 대기 시간이 증가한다. 최대 대기 시간을 설정하면 무한정 긴 대기 시간이 발생하는 것도 방지할 수 있다.

지수 백오프에 무작위 지연을 추가하는 Jitter 기법도 자주 사용한다.

function calculateBackoff(attempt) {
  const baseDelay = 500;
  const maxDelay = 8000;

  const exponentialDelay = Math.min(
    baseDelay * 2 ** attempt,
    maxDelay
  );

  return Math.random() * exponentialDelay;
}

이 방식은 Full Jitter에 해당한다. 계산된 최대 대기 시간 범위에서 실제 대기 시간을 무작위로 선택한다.

이를 통해 여러 클라이언트가 같은 시점에 재시도하는 현상을 완화할 수 있다.

실무 적용 시 주의점

재시도 로직에는 최대 재시도 횟수, 전체 실행 시간 제한, 재시도 가능한 오류 분류, 요청 취소, 타임아웃, 재시도 이력 관리가 필요하다.

또한 여러 계층에서 각각 재시도하면 실제 요청 횟수가 예상보다 크게 증가할 수 있다. HTTP 클라이언트, 서비스 계층, 메시지 소비자가 각각 재시도하는 구조라면 전체 재시도 예산을 고려해야 한다.

핵심은 일시적인 오류에는 재시도하되, 재시도가 시스템 장애를 증폭시키지 않도록 제한하는 것이다.

Dead Letter Queue

DLQ의 역할

Dead Letter Queue(DLQ)는 정상적인 처리에 반복적으로 실패한 메시지를 별도의 큐나 저장소로 이동시켜 격리하는 방식이다.

예를 들어 보상 지급 메시지를 소비하는 서버가 잘못된 데이터 구조 때문에 메시지를 처리하지 못한다고 가정하자.

해당 메시지를 무한히 재시도하면 정상 메시지의 처리까지 지연될 수 있고, 리소스 낭비와 오류 로그 증가가 발생할 수 있다.

DLQ는 이러한 메시지를 일반 처리 경로에서 분리해 정상 작업이 계속 진행되도록 돕는다.

DLQ 처리 흐름

Producer
   |
   v
Main Queue
   |
   v
Consumer
   |
   +---- 성공 ----> ACK
   |
   +---- 실패 ----> 재시도
                    |
                    +---- 성공 ----> ACK
                    |
                    +---- 최대 재시도 초과
                               |
                               v
                              DLQ
                               |
                               v
                       원인 분석 및 복구
                               |
                               v
                         재처리 또는 폐기

ACK는 메시지 처리가 완료되었음을 브로커에 알리는 확인 응답이다. 구체적인 동작은 메시지 브로커의 전달 및 확인 방식에 따라 달라진다.

DLQ에 저장해야 하는 정보

실패 메시지를 나중에 복구하려면 원본 데이터뿐 아니라 실패 이력도 함께 관리해야 한다.

항목목적
messageId메시지 식별
payload원본 메시지
errorCode실패 유형 분류
errorMessage실패 원인 분석
retryCount재시도 횟수
firstFailedAt최초 실패 시각
lastFailedAt최근 실패 시각
consumerName실패한 소비자 식별
correlationId관련 요청 및 작업 추적
schemaVersion메시지 형식 버전 확인

민감한 개인정보나 인증 정보가 포함될 수 있으므로 저장 정책과 접근 권한도 고려해야 한다.

DLQ 운영과 재처리

DLQ는 실패를 자동으로 해결하는 장치가 아니다. 실패 메시지를 격리하고 원인을 분석해 복구할 수 있도록 만드는 장치다.

운영 환경에서는 다음 기능이 필요하다.

  • DLQ 메시지 수와 증가율 모니터링
  • 실패 유형별 집계
  • 반복 실패 알림
  • 메시지 조회 및 상세 분석
  • 재처리 권한 관리
  • 재처리 이력 기록
  • 메시지 보존 및 삭제 정책

DLQ에 메시지가 계속 쌓인다면 잘못된 데이터, 소비자 코드의 버그, 외부 시스템 장애 등을 의심해야 한다.

재처리하기 전에 실패 원인을 해결해야 한다. 메시지 형식이 잘못되었는데 그대로 재발행하면 같은 오류가 반복된다. 이미 반영된 데이터가 다시 반영되지 않도록 멱등성 설계도 함께 적용해야 한다.

Transactional Outbox

데이터베이스와 메시지 브로커 사이의 문제

Transactional Outbox는 데이터베이스 변경과 이벤트 발행 사이에서 발생하는 데이터 불일치 문제를 해결하기 위한 패턴이다.

예를 들어 플레이어가 아이템을 구매하면 포인트를 차감하고 아이템을 지급한 뒤, 구매 이벤트를 메시지 브로커에 발행해야 한다.

await database.transaction(async tx => {
  await tx.points.decrement(playerId, price);
  await tx.inventory.add(playerId, itemId);
});

await messageBroker.publish({
  type: "ItemPurchased",
  playerId,
  itemId
});

데이터베이스 트랜잭션은 성공했지만 이벤트 발행에 실패하면 다른 서비스는 구매 사실을 알 수 없다.

반대로 이벤트를 먼저 발행하고 데이터베이스 트랜잭션이 실패하면 실제로 구매되지 않은 아이템에 대한 이벤트가 전달될 수 있다.

데이터베이스와 메시지 브로커가 서로 다른 시스템이라면 일반적인 데이터베이스 트랜잭션만으로 두 시스템의 변경을 원자적으로 묶을 수 없다.

Outbox 패턴의 핵심

이벤트를 즉시 브로커에 발행하는 대신, 비즈니스 데이터와 이벤트 기록을 동일한 데이터베이스 트랜잭션으로 저장한다.

Outbox 테이블은 다음과 같이 구성할 수 있다.

CREATE TABLE outbox_events (
    id VARCHAR(36) PRIMARY KEY,
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSON NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NULL
);

이벤트 발행 여부, 재시도 횟수, 마지막 오류 등을 별도 컬럼으로 관리할 수도 있다.

구매 처리 예시는 다음과 같다.

await database.transaction(async tx => {
  await tx.points.decrement(playerId, price);
  await tx.inventory.add(playerId, itemId);

  await tx.outbox.insert({
    id: crypto.randomUUID(),
    aggregateId: playerId,
    eventType: "ItemPurchased",
    payload: JSON.stringify({
      playerId,
      itemId,
      price
    })
  });
});

포인트 차감, 아이템 지급, 이벤트 기록은 동일한 데이터베이스 트랜잭션에 포함된다. 트랜잭션이 커밋되면 함께 반영되고, 롤백되면 함께 취소된다.

실제 구현에서는 포인트 잔액 검증과 동시 구매에 대한 동시성 제어도 필요하다.

Outbox Relay

Outbox 테이블에 저장된 이벤트를 메시지 브로커로 전달하는 별도 프로세스를 Outbox Relay라고 한다.

                Database Transaction
                         |
             +-----------+-----------+
             |                       |
             v                       v
       Business Data            Outbox Table
                                     |
                                     v
                                Outbox Relay
                                     |
                                     v
                               Message Broker
                                     |
                                     v
                                  Consumer

Relay는 아직 발행되지 않은 이벤트를 조회하고 브로커에 전송한 다음, 발행 상태를 업데이트한다.

여기서 중요한 실패 상황이 있다.

  • Relay가 이벤트를 브로커에 발행한다.
  • 브로커는 메시지를 정상적으로 저장한다.
  • Relay가 발행 완료 상태를 기록하기 전에 종료된다.
  • Relay가 재시작한 뒤 같은 이벤트를 다시 발행한다.

따라서 Outbox는 이벤트 유실 위험을 줄이지만 중복 발행을 자동으로 제거하지는 않는다.

Outbox는 데이터베이스 변경과 이벤트 기록을 원자적으로 보장하는 패턴이지, 브로커 발행까지 정확히 한 번만 수행하도록 보장하는 패턴은 아니다.

Outbox 구현 방식

Polling 방식

  • 주기적으로 미발행 이벤트를 조회한다.
  • 구현이 비교적 단순하다.
  • 폴링 주기만큼 발행 지연이 발생할 수 있다.
  • 조회 인덱스와 여러 Relay 간 작업 분배를 설계해야 한다.

CDC(Change Data Capture) 방식

  • 데이터베이스의 변경 로그를 읽어 Outbox 이벤트를 전달한다.
  • 대규모 이벤트 전달에 적합할 수 있다.
  • CDC 도구와 커넥터, 로그 보존 등 추가 인프라가 필요하다.

작은 시스템에서는 Polling으로 시작하고, 처리량이나 지연 요구 사항이 커지면 CDC를 검토할 수 있다.

실패 작업 재처리

재시도와 재처리의 차이

재시도(Retry)는 작업 실행 중 일시적인 오류가 발생했을 때 자동으로 다시 시도하는 방식이다.

재처리(Reprocessing)는 실패 이력이나 저장된 이벤트를 이용해 나중에 작업을 다시 수행하는 방식이다.

데이터베이스 연결이 일시적으로 끊겼을 때는 재시도를 사용할 수 있다. 반면 소비자 코드의 버그를 수정한 뒤 DLQ에 쌓인 메시지를 다시 처리하거나, 누락된 데이터를 복구하는 경우에는 재처리가 적합하다.

재처리 설계 원칙

실패 원인 확인

재처리하기 전에 실패 원인이 해결되었는지 확인한다.

재처리 대상 선별

전체 메시지를 무조건 재처리하지 않고 실패 유형, 발생 시각, 사용자, 이벤트 ID 등을 기준으로 대상을 선택한다.

처리 속도 제한

대량의 메시지를 한 번에 재처리하면 데이터베이스나 외부 서비스에 부하가 집중될 수 있다. 처리량 제한과 배치 크기 조절이 필요하다.

재처리 이력 관리

누가 언제 어떤 메시지를 재처리했는지, 결과가 성공했는지 기록한다.

중복 실행 방지

이미 반영된 작업이 다시 실행되더라도 데이터가 중복 변경되지 않도록 멱등성을 확보한다.

재처리 상태 모델

PENDING
   |
   v
PROCESSING
   |
   +---- 성공 ----> COMPLETED
   |
   +---- 일시 오류 ----> RETRY_WAIT
   |                       |
   |                       v
   |                   PROCESSING
   |
   +---- 재시도 초과 ----> DEAD

여러 작업자가 동시에 같은 작업을 가져가지 않도록 원자적인 작업 선점이 필요하다.

데이터베이스에서는 조건부 UPDATE나 적절한 행 잠금을 활용할 수 있다. 여러 서버가 함께 처리한다면 작업 임대(Lease), 잠금 만료, 작업 소유자 정보도 고려해야 한다.

작업자가 PROCESSING 상태에서 비정상 종료되는 상황도 처리해야 한다. 작업 임대가 만료되면 다른 작업자가 다시 가져갈 수 있도록 설계할 수 있다.

단, 작업이 실제로 완료됐는지 불확실한 상황에서 다시 실행될 수 있으므로 작업 선점만으로 중복 실행 문제가 해결되지는 않는다.

이벤트 재생과 상태 복구

이벤트를 저장하고 있다면 특정 시점부터 이벤트를 다시 소비하여 파생 데이터를 복구할 수 있다.

예를 들어 랭킹 데이터가 손상되었을 때 전투 결과 이벤트를 재생하여 랭킹을 재구성할 수 있다.

단, 외부 API 호출이나 보상 지급처럼 부수 효과가 발생하는 이벤트는 그대로 재생하면 위험하다.

따라서 다음을 구분해야 한다.

  • 파생 데이터 재구성
  • 실제 비즈니스 작업 재실행
  • 외부 부수 효과 발생
  • 이미 처리된 이벤트의 중복 여부

이벤트 재생은 데이터 복구뿐 아니라 데이터 검증과 장애 대응에도 활용할 수 있다.

멱등 소비자 설계

멱등성의 의미

멱등성(Idempotency)은 동일한 작업을 여러 번 수행해도 최종 결과가 한 번 수행한 것과 동일하도록 만드는 성질이다.

메시지 시스템에서는 재시도, 재연결, ACK 손실 등의 이유로 같은 메시지가 여러 번 전달될 수 있다. 따라서 소비자는 중복 메시지 전달을 정상적인 상황으로 가정해야 한다.

예를 들어 다음 코드를 살펴보자.

async function grantReward(playerId, amount) {
  await database.query(
    `UPDATE players
     SET points = points + ?
     WHERE id = ?`,
    [amount, playerId]
  );
}

같은 보상 메시지가 중복 전달되면 포인트가 중복으로 증가한다.

메시지가 한 번만 전달될 것이라고 기대하는 대신, 소비자 측에서 중복 처리를 방지해야 한다.

메시지 ID 기반 중복 방지

일반적인 방법은 메시지별 고유 ID를 사용하고, 처리한 메시지 ID를 저장하는 것이다.

CREATE TABLE processed_messages (
    consumer_name VARCHAR(100) NOT NULL,
    message_id VARCHAR(100) NOT NULL,
    processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (consumer_name, message_id)
);

복합 기본키를 이용하면 동일한 소비자가 같은 메시지를 중복 등록하는 것을 데이터베이스가 차단한다.

단순히 메시지 ID를 확인한 뒤 실제 작업을 실행하고 나중에 처리 완료를 기록하면 경쟁 조건이 발생할 수 있다.

따라서 중복 확인, 비즈니스 변경, 처리 완료 기록을 동일한 데이터베이스 트랜잭션에 포함하는 방식이 안전하다.

트랜잭션을 이용한 멱등 소비자

async function consumeReward(message) {
  await database.transaction(async tx => {
    const inserted = await tx.processedMessages.insertIfAbsent({
      consumerName: "reward-consumer",
      messageId: message.id
    });

    if (!inserted) {
      return;
    }

    await tx.players.incrementPoints(
      message.playerId,
      message.amount
    );
  });
}

여기서 insertIfAbsent는 사용하는 데이터베이스와 ORM에 맞게 구현해야 한다. 핵심은 유니크 제약 조건을 이용해 최초 처리 여부를 원자적으로 결정하는 것이다.

처리 기록 삽입과 포인트 지급이 같은 트랜잭션에 포함되면 다음과 같이 동작한다.

  • 최초 메시지: 처리 기록과 보상 지급이 함께 커밋된다.
  • 중복 메시지: 이미 처리한 ID이므로 보상 지급을 생략한다.
  • 처리 도중 오류: 트랜잭션이 롤백되면 처리 기록과 보상 지급 모두 취소된다.

실제 구현에서는 중복 키 충돌을 일반 오류와 구분하고 트랜잭션을 안전하게 종료해야 한다.

또한 소비자는 비즈니스 처리가 커밋된 후 메시지를 ACK하는 것이 일반적이다. 커밋 전에 ACK하면 이후 장애가 발생했을 때 메시지가 유실될 수 있다.

외부 시스템 호출과 멱등성

데이터베이스 트랜잭션으로 외부 API 호출까지 원자적으로 묶을 수는 없다.

예를 들어 결제 요청이 외부 시스템에서 성공했지만 응답을 받지 못할 수 있다. 이때 단순 재시도하면 중복 결제가 발생할 수 있다.

대응 방법은 다음과 같다.

  • 외부 API가 지원한다면 동일한 Idempotency-Key 사용
  • 요청 ID와 결과 기록을 영속적으로 저장
  • 외부 시스템의 처리 상태 조회
  • Transactional Outbox로 외부 요청 전달
  • 보상 트랜잭션이나 수동 복구 절차 준비

멱등성은 메시지 ID를 기록하는 것만으로 완성되지 않는다. 실제 비즈니스 효과가 중복 발생하지 않는지까지 확인해야 한다.

멱등성 키의 보존 기간

처리한 메시지 ID를 영구 보관하면 저장 공간이 계속 증가한다. 반대로 너무 빨리 삭제하면 오래된 메시지가 재전달되었을 때 중복 처리가 발생할 수 있다.

보존 기간은 메시지 브로커의 보존 기간, 최대 재시도 기간, DLQ 보관 및 재처리 기간, 이벤트 재생 가능 기간, 비즈니스 데이터의 중복 처리 위험을 기준으로 결정해야 한다.

포인트 지급이나 결제처럼 중복 실행의 영향이 큰 작업은 짧은 TTL에만 의존하기보다 비즈니스 고유 ID와 영구적인 유니크 제약 조건을 활용하는 것이 적절할 수 있다.

전체 복구 아키텍처

앞에서 살펴본 기법들은 서로 대체 관계가 아니다. 각자 다른 실패 지점을 해결하며 함께 사용할 때 복구 안정성이 높아진다.

[Business Request]
        |
        v
[Database Transaction]
  - 비즈니스 데이터 변경
  - Outbox 이벤트 기록
        |
        v
[Outbox Relay]
  - 재시도
  - 발행 상태 관리
        |
        v
[Message Broker]
        |
        v
[Consumer]
  - 메시지 검증
  - 멱등성 확인
  - 비즈니스 트랜잭션
        |
        +---- 성공 ----> ACK
        |
        +---- 일시 오류 ----> 지수 백오프 재시도
        |
        +---- 반복 실패 ----> DLQ
                                   |
                                   v
                           원인 분석 및 수정
                                   |
                                   v
                                 재처리

각 기법의 역할은 다음과 같다.

기법해결하려는 문제
재시도일시적인 오류로 실패한 작업 복구
지수 백오프와 Jitter재시도로 인한 과부하 완화
DLQ반복 실패 메시지 격리
Transactional OutboxDB 변경과 이벤트 발행 의도 사이의 불일치 방지
실패 작업 재처리복구 가능한 작업을 다시 실행
멱등 소비자중복 전달로 인한 중복 비즈니스 효과 방지

여기에 관측 가능성(Observability)을 추가해야 한다. 재시도 횟수, 큐 적체량, DLQ 유입률, Outbox 대기 시간, 소비자 처리 지연, 중복 메시지 비율을 측정해야 실제 장애를 빠르게 파악할 수 있다.

실무 적용 체크리스트

재시도

  • 재시도 가능한 오류와 불가능한 오류를 구분한다.
  • 최대 재시도 횟수와 전체 실행 시간 제한을 설정한다.
  • 지수 백오프와 Jitter를 적용한다.
  • 중첩된 재시도로 요청 수가 폭증하지 않는지 확인한다.

DLQ

  • 메시지 ID와 실패 원인을 저장한다.
  • 최대 재시도 횟수 초과 시 메시지를 격리한다.
  • DLQ 적체량과 증가율에 대한 알림을 설정한다.
  • 원인 해결 후 중복 처리 위험을 확인하고 재처리한다.

Transactional Outbox

  • 비즈니스 데이터 변경과 Outbox 기록을 동일한 트랜잭션으로 처리한다.
  • Outbox Relay의 재시작과 중복 발행을 고려한다.
  • 발행 상태와 재시도 이력을 관리한다.
  • Outbox 테이블의 인덱스, 보존 기간, 정리 정책을 설계한다.

실패 작업 재처리

  • 실패 원인과 재처리 대상을 식별할 수 있다.
  • 재처리 이력을 기록한다.
  • 작업 선점과 작업자 장애 복구를 설계한다.
  • 대량 재처리 시 처리량을 제한한다.

멱등 소비자

  • 모든 메시지에 안정적인 고유 ID가 존재한다.
  • 유니크 제약 조건으로 중복 처리를 방지한다.
  • 처리 기록과 비즈니스 변경을 동일한 트랜잭션으로 처리한다.
  • ACK 시점과 트랜잭션 커밋 순서를 검증한다.
  • 외부 시스템 호출의 중복 실행 가능성을 확인한다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다