문제: 실패한 메시지는 어떻게 되나요?

SQS, SNS, Kafka 및 EventBridge와 같은 비동기 메시징 시스템은 패턴을 사용합니다. 최소 1회 배송: 메시지가 적어도 한 번 전달됩니다. 하지만 여러 번 전달될 수 있습니다(재시도 시 중복). 이로 인해 두 가지 중요한 시나리오가 생성됩니다.

  1. 일시적인 오류: 다운스트림 서비스를 일시적으로 사용할 수 없습니다. 자동 재시도를 통해 문제가 해결됩니다. 몇 번 시도한 후에 메시지가 올바르게 처리됩니다.
  2. 영구 오류(독약): 메시지 형식이 잘못되었거나 데이터가 포함되어 있습니다. 비즈니스 불변성을 위반하거나 소비자의 코드에 버그가 있습니다. 재시도는 도움이 되지 않습니다. 메시지는 무기한으로 계속 실패하여 잠재적으로 리소스를 소모하게 됩니다. 후속 메시지 처리를 차단합니다.

DLQ는 두 번째 시나리오를 해결합니다. 구성 가능한 시도 실패 횟수 이후(maxReceiveCount SQS에서는 MAX_RETRY_ATTEMPTS Kafka에서는 메시지가 배달 못한 편지 대기열로 이동됩니다. 통제된 방식으로 분석되고 재처리될 수 있습니다.

DLQ: 탄력성 계약

  • 데이터 손실 없음: 메시지가 자동으로 삭제되지 않습니다.
  • 문제 격리: 독약은 좋은 메시지를 차단하지 않습니다
  • 시계: DLQ의 메시지는 디버깅을 위해 검사 가능합니다.
  • 통제된 재처리: 문제 해결 후 메시지를 재처리합니다.

Amazon SQS의 DLQ

SQS에서 DLQ는 단순히 메시지 대상으로 구성된 또 다른 SQS 대기열입니다. 초과하는 maxReceiveCount. 메커니즘은 다음을 기반으로합니다. 공개 시간 초과: 소비자가 메시지를 받으면 해당 메시지는 해당 기간 동안 다른 소비자에게 "보이지 않게" 됩니다. 공개 시간 초과. 해당 시간 내에 삭제되지 않으면(소비자가 실패하거나 충돌한 경우) SQS는 또 다른 시도를 위해 다시 표시됩니다.

카운터 ApproximateReceiveCount 수신할 때마다 증가합니다. 도달하면 maxReceiveCount, SQS는 메시지를 구성된 DLQ로 이동합니다.

# Configurazione DLQ per SQS con Terraform

# 1. Crea la DLQ (stessa tipologia della coda principale)
resource "aws_sqs_queue" "ordini_dlq" {
  name                       = "ordini-queue-dlq"
  message_retention_seconds  = 1209600  # 14 giorni (massimo SQS)
  visibility_timeout_seconds = 300      # 5 minuti per elaborare dalla DLQ

  # CloudWatch alarm sulla DLQ
  tags = {
    Environment = "production"
    Alert       = "critical"
  }
}

# 2. Crea la coda principale con redrive policy che punta alla DLQ
resource "aws_sqs_queue" "ordini" {
  name                       = "ordini-queue"
  visibility_timeout_seconds = 60       # 60s per elaborare ogni messaggio
  receive_wait_time_seconds  = 20       # long polling
  message_retention_seconds  = 345600   # 4 giorni

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.ordini_dlq.arn
    maxReceiveCount     = 3  # 3 tentativi falliti -> DLQ
  })
}

# 3. CloudWatch Alarm: alert quando la DLQ ha messaggi
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "ordini-dlq-not-empty"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = "60"
  statistic           = "Sum"
  threshold           = "0"
  alarm_description   = "CRITICO: messaggi in DLQ ordini"

  dimensions = {
    QueueName = aws_sqs_queue.ordini_dlq.name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
}

DLQ SQS를 통한 검사 및 재처리

// DlqReprocessor.java - Riprocessa messaggi dalla DLQ SQS
import software.amazon.awssdk.services.sqs.*;
import software.amazon.awssdk.services.sqs.model.*;

public class SqsDlqReprocessor {

    private final SqsClient sqsClient;
    private final String dlqUrl;
    private final String mainQueueUrl;

    // Riprocessa tutti i messaggi dalla DLQ verso la coda principale
    public void reprocessAll() {
        int reprocessed = 0;
        List<Message> messages;

        do {
            messages = receiveMessages(dlqUrl, 10);

            for (Message message : messages) {
                try {
                    // Analizza il messaggio per capire il tipo di errore
                    System.out.printf("Riprocesso messaggio: id=%s, receiveCount=%s%n",
                        message.messageId(),
                        message.attributes().get(MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT)
                    );

                    // Rimanda alla coda principale (con delay opzionale)
                    sqsClient.sendMessage(
                        SendMessageRequest.builder()
                            .queueUrl(mainQueueUrl)
                            .messageBody(message.body())
                            .messageAttributes(message.messageAttributes())
                            .delaySeconds(0)
                            .build()
                    );

                    // Elimina dalla DLQ
                    sqsClient.deleteMessage(
                        DeleteMessageRequest.builder()
                            .queueUrl(dlqUrl)
                            .receiptHandle(message.receiptHandle())
                            .build()
                    );

                    reprocessed++;

                } catch (Exception e) {
                    System.err.println("Errore reprocessing: " + e.getMessage());
                    // Non eliminare: rimane in DLQ
                }
            }

        } while (!messages.isEmpty());

        System.out.printf("Reprocessing completato: %d messaggi rimandati%n", reprocessed);
    }

    private List<Message> receiveMessages(String queueUrl, int maxMessages) {
        return sqsClient.receiveMessage(
            ReceiveMessageRequest.builder()
                .queueUrl(queueUrl)
                .maxNumberOfMessages(maxMessages)
                .waitTimeSeconds(5)
                .attributeNames(QueueAttributeName.ALL)
                .build()
        ).messages();
    }
}

AWS Lambda의 DLQ: 함수 수준과 대기열 수준

AWS Lambda에서 DLQ는 서로 다른 의미를 지닌 두 가지 수준으로 구성될 수 있습니다.

  • SQS 대기열 DLQ: 소스 SQS 대기열에 구성됩니다. 메시지가 이동되었습니다. DLQ에서 SQS가 maxReceiveCount. 이런 일이 일어난다 전에 해당 Lambda가 호출됩니다. 이는 Lambda + SQS에 권장되는 구성입니다.
  • 람다 함수 DLQ: Lambda 자체에 구성됨(비동기 호출에만 해당, SQS를 사용한 이벤트 소스 매핑에는 해당되지 않습니다. 대기열이 아닌 Lambda 호출의 실패를 포착합니다.
# Terraform: Lambda con SQS event source e DLQ configurata sulla coda

resource "aws_lambda_function" "ordini_consumer" {
  function_name = "ordini-consumer"
  handler       = "handler.lambda_handler"
  runtime       = "python3.12"
  role          = aws_iam_role.lambda_role.arn
  timeout       = 30  # 30 secondi per messaggio

  # DLQ a livello di Lambda (solo per invocazioni async dirette)
  dead_letter_config {
    target_arn = aws_sqs_queue.lambda_dlq.arn
  }
}

# SQS come event source per Lambda
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
  event_source_arn = aws_sqs_queue.ordini.arn
  function_name    = aws_lambda_function.ordini_consumer.arn
  batch_size       = 10
  enabled          = true

  # Bisection: in caso di errore batch, prova prima con metà messaggi
  # Aiuta a isolare il poison pill senza mandare tutti in DLQ
  bisect_batch_on_function_error = true

  # Report batch item failures: Lambda può indicare quali specifici
  # messaggi nel batch hanno fallito (solo quelli vanno in DLQ)
  function_response_types = ["ReportBatchItemFailures"]
}

ReportBatchItemFailures: 일괄 처리에 대한 세분화된 DLQ

// Handler Lambda Python con batch item failures
// Solo i messaggi falliti vanno in DLQ, non l'intero batch

def lambda_handler(event, context):
    """
    ReportBatchItemFailures: ritorna solo i message ID falliti.
    SQS mandrà in DLQ solo quelli, non il batch intero.
    """
    failed_items = []

    for record in event['Records']:
        message_id = record['messageId']
        try:
            # Elabora il messaggio
            payload = json.loads(record['body'])
            process_ordine(payload)
            print(f"Successo: {message_id}")

        except PermanentError as e:
            # Errore permanente: vai in DLQ subito
            print(f"PERMANENTE: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

        except TransientError as e:
            # Errore transitorio: riprova (non aggiungere a failed)
            # SQS ritenterà l'intero batch se almeno uno fallisce
            # Con ReportBatchItemFailures, solo i falliti vengono ritentati
            print(f"TRANSITORIO: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

    return {'batchItemFailures': failed_items}

EventBridge의 DLQ

EventBridge에는 자체 DLQ 수준이 있습니다. 목표: 이벤트를 전달하는 경우 구성된 모든 재시도 후 대상(Lambda, SQS)에 실패하면 이벤트가 전송됩니다. 다음에 지정된 SQS DLQ에서 dead_letter_config 규칙의.

# EventBridge DLQ per target Lambda
resource "aws_cloudwatch_event_target" "ordini_lambda" {
  rule           = aws_cloudwatch_event_rule.ordini.name
  event_bus_name = aws_cloudwatch_event_bus.mioapp.name
  arn            = aws_lambda_function.ordini_consumer.arn

  # Retry policy di EventBridge: quanti tentativi prima di DLQ
  retry_policy {
    maximum_event_age_in_seconds = 86400  # Riprova per max 24h
    maximum_retry_attempts       = 185    # ~exponential backoff su 24h
  }

  # DLQ per eventi non consegnati
  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn
  }
}

# L'evento in DLQ EventBridge include metadata di debug
# {
#   "version": "1.0",
#   "timestamp": "...",
#   "requestId": "...",
#   "condition": "...",
#   "approximateInvokeCount": 185,
#   "requestParameters": {
#     "FunctionName": "ordini-consumer"
#   },
#   "responseParameters": {
#     "statusCode": 500,
#     "errorCode": "Lambda.ServiceException"
#   },
#   "originalEvent": { ... l'evento originale ... }
# }

고급 패턴: SQS 지연을 사용하는 점진적 백오프로 재시도

SQS를 사용하면 다음을 구성할 수 있습니다. 단일 메시지 지연 (최대 15분) FIFO 대기열과 결합되어 메시지 그룹, 구현이 가능하다 다른 메시지를 차단하지 않고 기하급수적인 재시도 패턴:

// RetryWithSqsDelay.java - Retry progressivo con SQS message delay
public class SqsExponentialRetry {

    private static final int MAX_ATTEMPTS = 5;
    private static final int MAX_DELAY_SECONDS = 900;  // 15 minuti (max SQS)

    public void handleWithRetry(String queueUrl, Message sqsMessage) {
        // Leggi il numero di tentativi corrente dal message attribute
        int currentAttempt = Integer.parseInt(
            sqsMessage.messageAttributes()
                .getOrDefault("retryAttempt",
                    MessageAttributeValue.builder().stringValue("0").build())
                .stringValue()
        );

        try {
            processMessage(sqsMessage.body());
            // Successo: elimina dalla coda
            sqsClient.deleteMessage(...);

        } catch (TransientException e) {
            if (currentAttempt >= MAX_ATTEMPTS) {
                // Troppi tentativi: manda in DLQ manuale
                sendToManualDLQ(sqsMessage, e);
                sqsClient.deleteMessage(...);
                return;
            }

            // Calcola delay esponenziale (1s, 2s, 4s, 8s, 16s...)
            int delaySeconds = (int) Math.min(
                Math.pow(2, currentAttempt),
                MAX_DELAY_SECONDS
            );

            // Rimanda il messaggio con delay e contatore incrementato
            sqsClient.sendMessage(
                SendMessageRequest.builder()
                    .queueUrl(queueUrl)
                    .messageBody(sqsMessage.body())
                    .delaySeconds(delaySeconds)
                    .messageAttributes(Map.of(
                        "retryAttempt", MessageAttributeValue.builder()
                            .stringValue(String.valueOf(currentAttempt + 1))
                            .dataType("Number")
                            .build()
                    ))
                    .build()
            );

            // Elimina il messaggio originale (non usare la DLQ automatica)
            sqsClient.deleteMessage(...);
        }
    }
}

DLQ 모니터링: 필수 경고

DLQ를 적극적으로 모니터링해야 합니다. DLQ의 메시지는 실제 문제를 나타냅니다. 주의가 필요한 일입니다. CloudWatch에서 모니터링할 SQS 지표는 다음과 같습니다.

# CloudWatch Metric Alarms per DLQ - AWS CLI

# Alert: qualsiasi messaggio in DLQ (soglia 0)
aws cloudwatch put-metric-alarm \
  --alarm-name "ordini-dlq-not-empty" \
  --alarm-description "CRITICO: messaggi in DLQ ordini" \
  --metric-name "ApproximateNumberOfMessagesVisible" \
  --namespace "AWS/SQS" \
  --dimensions Name=QueueName,Value=ordini-queue-dlq \
  --period 60 \
  --evaluation-periods 1 \
  --statistic Sum \
  --comparison-operator GreaterThanThreshold \
  --threshold 0 \
  --alarm-actions "arn:aws:sns:eu-west-1:123456:alerts-topic"

# Metriche importanti da monitorare su DLQ:
# - ApproximateNumberOfMessagesVisible: messaggi pronti per essere letti
# - ApproximateNumberOfMessagesNotVisible: messaggi in processing
# - NumberOfMessagesSent: rate di arrivo in DLQ (crescita = problema)
# - NumberOfMessagesDeleted: rate di reprocessing

비동기식 시스템의 DLQ 모범 사례

  • 모든 소비자에게 필수인 DLQ: 프로덕션에는 비동기 시스템이 없습니다. DLQ 없이. 누락된 경우 실패한 메시지가 손실되거나 흐름이 차단됩니다.
  • 임계값 0 경고로 DLQ 모니터링: DLQ의 모든 메시지는 문제의 징후. 반응하기 전에 수백 개가 쌓일 때까지 기다리지 마십시오.
  • 메타데이터로 DLQ 메시지 강화: 헤더 또는 속성 추가 오류 유형, 스택 추적, 재시도 횟수 및 실패 타임스탬프가 포함됩니다. 이 데이터가 없으면 디버깅이 거의 불가능합니다.
  • DLQ에 대한 장기 보존: 14일 보존 구성(최대 SQS) 또는 Kafka에서는 최소 30일입니다. DLQ의 메시지는 지연 분석에 사용할 수 있어야 합니다.
  • 정기적으로 재처리 테스트: 모르면 DLQ는 쓸모가 없다 메시지를 재처리하는 방법 재처리 프로세스를 문서화하고 테스트합니다.
  • 별도의 오류 유형: 영구 오류에 대해서는 별도의 DLQ를 고려합니다. (손상된 페이로드) 및 일시적인 오류(서비스 중단). 재처리 전략이 다릅니다.

안티패턴: DLQ 무시

가장 위험한 패턴은 DLQ를 구성한 다음 모니터링하지 않는 것입니다. 메시지는 몇 주 동안 조용히 쌓이다가 누군가 알아차립니다. 중요한 데이터가 누락되었습니다. 항상 DLQ에 대한 경고를 설정하십시오. 이것이 안전망입니다. 절대 무시하면 안 된다는 것.

시리즈의 다음 단계

  • 제9조 – 소비자의 멱등성: DLQ에서 재시도 및 재처리 중복 메시지가 발생할 수 있습니다. 멱등성 키 패턴이 주요 방어 수단입니다.
  • 기사 10 – 보낼 편지함 패턴: 이벤트가 게시되었는지 확인 생산자 충돌이 발생하는 경우에도 데이터베이스의 보낸 편지함 테이블을 사용하면 항상 발생합니다.

다른 시리즈와의 연계

  • SQS vs SNS vs EventBridge(7조): 각 AWS 서비스에는 고유한 DLQ 및 재시도의 의미. 이 문서에서는 서비스 간의 구성 차이점을 다룹니다.
  • Kafka 데드 레터 큐(Kafka 시리즈 10조): DLQ 패턴 소비자 그룹이 있는 Kafka에서 주제를 재시도하고 재처리하는 것은 동일한 기반을 갖습니다. 개념적이지만 SQS와는 다른 구현입니다.