웹훅 전달 시스템 구축: 2026년 안정적인 포스트백 구현

큐, 지터링 재시도, 회로 차단기, 멱등성, 서명 및 엔드포인트별 지연 시간 원격 측정 기능을 활용한 안정적인 웹훅 및 제휴 포스트백 전송을 위한 실용적인 2026년 가이드.
웹훅 전달 시스템 - 웹훅 전달 시스템 구축: 2026년의 안정적인 포스트백

마지막 업데이트 : 24 년 2026 월 XNUMX 일 시저 픽슨

직접 답변: 안정적인 웹훅 또는 제휴사 포스트백 전달 서비스를 위해서는 내구성이 뛰어난 큐, 지터가 있는 제한된 재시도 횟수, 엔드포인트별 회로 차단, 중복 방지, 서명 검증, 그리고 전달 실패 전에 속도 저하 여부를 보여주는 원격 측정 데이터가 필요합니다.

이 구현은 실행 모델을 의도적으로 간소화했습니다. 파이썬 워커와 PostgreSQL을 사용하여 큐, 전송 내역 및 엔드포인트 상태를 관리합니다. 이는 B2B SaaS, iGaming 제휴 운영, 그리고 HTTP 타임아웃을 전환 손실로 처리하지 않고 아웃바운드 이벤트를 전달해야 하는 모든 제품에 적합한 실용적인 시작점입니다.

납품 계약이 보장해야 하는 사항

Control: 중요한 이유 작동 점검
내구성이 뛰어난 이벤트 기록 이벤트는 워커 재시작 후에도 유지됩니다. 승인된 모든 배송에는 고정된 ID와 상태가 부여됩니다.
서명된 요청 수신자는 발신자를 확인할 수 있고, 신체 훼손 여부를 감지할 수 있습니다. 타임스탬프가 포함된 서명을 사용하고 오래된 요청은 거부하십시오.
중복 방지 재시도 과정에서 중복 변환이나 업데이트가 발생할 수 있습니다. 이벤트 ID를 전송하고 수신자가 멱등성을 갖도록 합니다.
제한된 재시도 횟수 일시적인 오류는 성능 저하된 엔드포인트에 과부하를 주지 않고 복구됩니다. 진동을 줄이세요. 정해진 시도 횟수 이후에는 중단하세요.
엔드포인트 원격 측정 대기열 깊이만으로는 속도가 느리거나 오류가 발생하는 파트너를 숨길 수 있습니다. 엔드포인트별 성공률, 가장 오래된 대기 이벤트 및 지연 시간 백분위수를 추적합니다.

보안 및 중복 제어는 재시도 튜닝보다 우선적으로 고려됩니다.

송신 본문을 민감한 운영 데이터로 취급하십시오. 요청 본문에 정확한 서명을 하고, 전달 타임스탬프와 변경 불가능한 이벤트 ID를 포함하며, 서명 비밀 키를 주기적으로 변경하고, 수신 애플리케이션이 동일한 이벤트의 재실행을 안전하게 무시하도록 보장하십시오. HTTP 응답이 성공했다고 해서 비즈니스 이벤트가 정확히 한 번만 적용되었다는 것을 보장하는 것은 아닙니다. 수신 측에서 이를 판단해야 합니다.

제휴 마케팅 또는 iGaming 포스트백 워크플로의 경우, 접근 제어 및 보존 규칙이 명시적으로 설정되어 있지 않으면 클릭 ID, 전환 ID 및 지급 관련 필드는 로그에 기록하지 않도록 하십시오. 아래의 기존 Scaleo 참조는 제휴 포스트백 지연 시간 예시로만 활용해야 하며, 자체 서비스 간의 전달 계약을 문서화하는 것을 대체할 수 없습니다.

유용한 구현 참고 자료: Stripe 웹훅 서명 확인, PostgreSQL SELECT 및 SKIP LOCKED예산 및 AWS의 지수 백오프 및 지터 관련 지침.

데이터 모델

모든 것은 두 개의 테이블에서 시작됩니다. 하나는 배송 대기열용이고 다른 하나는 지연 시간 측정용입니다.

SQL

-- Pending and in-flight webhook deliveries
CREATE TABLE webhook_queue (
    id              SERIAL PRIMARY KEY,
    endpoint_id     INTEGER NOT NULL,
    endpoint_url    TEXT NOT NULL,
    payload         JSONB NOT NULL,
    
    -- Delivery state
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
        -- pending, in_flight, delivered, failed, dead_letter
    attempt_count   INTEGER NOT NULL DEFAULT 0,
    max_attempts    INTEGER NOT NULL DEFAULT 5,
    
    -- Timing
    created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
    delivered_at    TIMESTAMP,
    last_error      TEXT,
    
    -- Response tracking
    last_status_code INTEGER,
    last_response_ms INTEGER,  -- response time in milliseconds
    
    INDEX idx_queue_next (status, next_attempt_at)
        WHERE status IN ('pending', 'failed')
);

-- Per-endpoint latency measurements (ring buffer)
CREATE TABLE endpoint_latency (
    id              SERIAL PRIMARY KEY,
    endpoint_id     INTEGER NOT NULL,
    response_ms     INTEGER NOT NULL,
    status_code     INTEGER,
    measured_at     TIMESTAMP NOT NULL DEFAULT NOW(),
    
    INDEX idx_latency_endpoint (endpoint_id, measured_at)
);

-- Endpoint health state (circuit breaker)
CREATE TABLE endpoint_health (
    endpoint_id         INTEGER PRIMARY KEY,
    endpoint_url        TEXT NOT NULL,
    state               VARCHAR(20) NOT NULL DEFAULT 'closed',
        -- closed (healthy), open (broken), half_open (testing)
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
    failure_threshold   INTEGER NOT NULL DEFAULT 10,
    last_failure_at     TIMESTAMP,
    last_success_at     TIMESTAMP,
    opened_at           TIMESTAMP,  -- when circuit opened
    cooldown_seconds    INTEGER NOT NULL DEFAULT 300,  -- 5 min before half_open
    
    -- Latency stats (updated periodically)
    p50_ms              INTEGER,
    p95_ms              INTEGER,
    p99_ms              INTEGER,
    sample_count        INTEGER NOT NULL DEFAULT 0
);

이 스키마에 대해 주목해야 할 세 가지 사항이 있습니다.

첫째, webhook_queue 테이블은 다음을 사용합니다. next_attempt_at 별도의 스케줄링 메커니즘 대신 열을 사용합니다. 작업자는 특정 조건을 만족하는 행을 폴링합니다. status IN ('pending', 'failed') AND next_attempt_at <= NOW()이것은 저렴한 지연 큐 방식이며 분당 약 10,000건의 전송까지는 제대로 작동합니다. 그 이상을 처리하려면 제대로 된 메시지 브로커로 교체해야 합니다.

둘째, endpoint_latency 이 테이블은 링 버퍼 역할을 합니다. 저는 주기적으로 24시간보다 오래된 행을 삭제합니다. 지연 시간 백분위수는 다음과 같습니다. endpoint_health 이러한 값은 이동 평균값을 기준으로 계산되므로 최근의 움직임을 나타내는 것이지 과거 평균값을 나타내는 것은 아닙니다.

셋째, endpoint_health 이 표는 회로 차단기 상태 기계를 구현합니다. 자세한 내용은 아래에서 설명합니다.

배달원

핵심 작업자 루프는 의도적으로 단순하게 설계되었습니다. 복잡성은 재시도 로직과 회로 차단기에 있어야 하며, 전달 경로 자체에 있어서는 안 됩니다.

파이썬

import requests
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from datetime import datetime, timedelta

DB_DSN = "postgresql://user:pass@localhost/webhooks"

def get_connection():
    return psycopg2.connect(DB_DSN)

def deliver_webhooks(batch_size=50):
    """
    Fetch pending webhooks and attempt delivery.
    Uses SELECT FOR UPDATE SKIP LOCKED for safe concurrent workers.
    """
    conn = get_connection()
    cur = conn.cursor(cursor_factory=RealDictCursor)
    
    try:
        cur.execute("""
            SELECT id, endpoint_id, endpoint_url, payload, 
                   attempt_count, max_attempts
            FROM webhook_queue
            WHERE status IN ('pending', 'failed')
              AND next_attempt_at <= NOW()
            ORDER BY next_attempt_at ASC
            LIMIT %s
            FOR UPDATE SKIP LOCKED
        """, (batch_size,))
        
        rows = cur.fetchall()
        
        for row in rows:
            # Check circuit breaker before attempting
            if is_circuit_open(cur, row['endpoint_id']):
                # Don't attempt delivery — reschedule for after cooldown
                reschedule_for_cooldown(cur, row['id'], row['endpoint_id'])
                continue
            
            # Attempt delivery and measure latency
            result = attempt_delivery(
                row['endpoint_url'], 
                row['payload']
            )
            
            # Record latency measurement regardless of success/failure
            record_latency(
                cur, 
                row['endpoint_id'], 
                result['response_ms'], 
                result['status_code']
            )
            
            if result['success']:
                mark_delivered(cur, row['id'], result)
                record_success(cur, row['endpoint_id'])
            else:
                handle_failure(
                    cur, row['id'], row['endpoint_id'],
                    row['attempt_count'], row['max_attempts'],
                    result
                )
        
        conn.commit()
    
    except Exception as e:
        conn.rollback()
        raise
    finally:
        cur.close()
        conn.close()


def attempt_delivery(url, payload):
    """
    Fire the webhook and measure response time.
    Returns dict with success, status_code, response_ms, error.
    """
    start = time.monotonic()
    
    try:
        response = requests.post(
            url,
            json=payload,
            timeout=15,          # 15 second hard timeout
            headers={
                'Content-Type': 'application/json',
                'User-Agent': 'WebhookDelivery/1.0',
                'X-Delivery-Timestamp': str(int(time.time()))
            }
        )
        
        elapsed_ms = int((time.monotonic() - start) * 1000)
        
        return {
            'success': 200 <= response.status_code < 300,
            'status_code': response.status_code,
            'response_ms': elapsed_ms,
            'error': None if response.ok else f"HTTP {response.status_code}"
        }
    
    except requests.Timeout:
        elapsed_ms = int((time.monotonic() - start) * 1000)
        return {
            'success': False,
            'status_code': None,
            'response_ms': elapsed_ms,
            'error': 'timeout_15s'
        }
    
    except requests.ConnectionError as e:
        elapsed_ms = int((time.monotonic() - start) * 1000)
        return {
            'success': False,
            'status_code': None,
            'response_ms': elapsed_ms,
            'error': f'connection_error: {str(e)[:200]}'
        }

The SELECT FOR UPDATE SKIP LOCKED 해당 절은 여러 워커 인스턴스를 실행하는 데 매우 중요합니다. 이 절이 없으면 SKIP LOCKED두 작업자가 같은 행에서 블록되는 문제가 발생했지만, 이 방식을 사용하면 각 작업자가 대기 중인 웹훅의 서로 다른 배치를 가져옵니다. 따라서 작업자 프로세스를 추가하는 것만으로 수평 확장이 가능합니다.

The time.monotonic() 대신 전화하세요 time.time() 고의적이다. time.time() NTP 조정 중에 뒤로 이동할 수 있습니다. time.monotonic() 절대 뒤로 돌아가지 않는데, 이는 1초 미만의 지연 시간을 측정할 때 중요한 요소입니다.

지수 백오프 및 지터를 사용한 재시도 로직

배송이 실패했을 때 재시도 타이밍은 시스템이 원활하게 복구될지, 아니면 과부하 상태인 엔드포인트에 엄청난 부하를 일으키는지 여부를 결정합니다.

파이썬

import random

def calculate_next_attempt(attempt_count, base_delay=30, max_delay=3600):
    """
    Exponential backoff with full jitter.
    
    Attempt 1: 0-30s
    Attempt 2: 0-60s  
    Attempt 3: 0-120s
    Attempt 4: 0-240s
    Attempt 5: 0-480s (capped at max_delay)
    
    Full jitter prevents thundering herd when an endpoint
    recovers and hundreds of retries fire simultaneously.
    """
    exponential_delay = base_delay * (2 ** attempt_count)
    capped_delay = min(exponential_delay, max_delay)
    jittered_delay = random.uniform(0, capped_delay)
    
    return datetime.utcnow() + timedelta(seconds=jittered_delay)


def handle_failure(cur, webhook_id, endpoint_id, 
                   attempt_count, max_attempts, result):
    """
    Handle a failed delivery attempt.
    Either retry with backoff or move to dead letter queue.
    """
    new_attempt_count = attempt_count + 1
    
    if new_attempt_count >= max_attempts:
        # Exhausted retries — dead letter
        cur.execute("""
            UPDATE webhook_queue 
            SET status = 'dead_letter',
                attempt_count = %s,
                last_error = %s,
                last_status_code = %s,
                last_response_ms = %s
            WHERE id = %s
        """, (
            new_attempt_count, result['error'],
            result['status_code'], result['response_ms'],
            webhook_id
        ))
    else:
        # Schedule retry with backoff
        next_attempt = calculate_next_attempt(new_attempt_count)
        cur.execute("""
            UPDATE webhook_queue
            SET status = 'failed',
                attempt_count = %s,
                next_attempt_at = %s,
                last_error = %s,
                last_status_code = %s,
                last_response_ms = %s
            WHERE id = %s
        """, (
            new_attempt_count, next_attempt,
            result['error'], result['status_code'],
            result['response_ms'], webhook_id
        ))
    
    # Update circuit breaker
    record_failure(cur, endpoint_id)

왜 디코레리티브 지터나 균등 지터 대신 전체 지터를 사용해야 할까요? AWS는 이에 대한 명확한 분석 자료를 발표했습니다. 전체 지터(0과 지수적 상한값 사이에서 무작위로 생성)는 모든 클라이언트에서 가장 짧은 총 완료 시간을 제공합니다. 균등 지터(상한값의 절반과 전체 사이에서 무작위로 생성)는 더 보수적이지만 재시도 백로그를 처리하는 속도가 더 느립니다. 여러 개의 독립적인 엔드포인트가 있는 웹훅 전송의 경우, 각 엔드포인트의 재시도가 독립적이므로 전체 지터가 적합한 선택입니다. 즉, 엔드포인트 간의 조정이 필요하지 않습니다.

회로 차단기: 고장난 단자에 무리한 충격을 가하지 마세요

회로 차단기 패턴은 시스템이 지속적으로 실패하는 엔드포인트에 리소스를 낭비하는 것을 방지합니다. 이 패턴이 없으면 작동하지 않는 엔드포인트는 수백 건의 재시도 요청을 누적시키고, 이 요청들은 모두 15초 타임아웃되어 결국 성공할 수 없는 전송 작업에 작업자 용량을 낭비하게 됩니다.

파이썬

def is_circuit_open(cur, endpoint_id):
    """
    Check if the circuit breaker is open (endpoint is broken).
    If open and cooldown has passed, transition to half_open.
    """
    cur.execute("""
        SELECT state, opened_at, cooldown_seconds
        FROM endpoint_health
        WHERE endpoint_id = %s
    """, (endpoint_id,))
    
    row = cur.fetchone()
    if not row:
        return False  # no health record = assume healthy
    
    if row['state'] == 'closed':
        return False
    
    if row['state'] == 'open':
        # Check if cooldown period has elapsed
        if row['opened_at'] and row['cooldown_seconds']:
            elapsed = (datetime.utcnow() - row['opened_at']).total_seconds()
            if elapsed >= row['cooldown_seconds']:
                # Transition to half_open — allow one probe
                cur.execute("""
                    UPDATE endpoint_health
                    SET state = 'half_open'
                    WHERE endpoint_id = %s
                """, (endpoint_id,))
                return False  # allow the probe delivery
        return True  # still in cooldown
    
    if row['state'] == 'half_open':
        return False  # allow probe delivery
    
    return False


def record_failure(cur, endpoint_id):
    """
    Record a delivery failure. Open circuit if threshold reached.
    """
    cur.execute("""
        UPDATE endpoint_health
        SET consecutive_failures = consecutive_failures + 1,
            last_failure_at = NOW()
        WHERE endpoint_id = %s
        RETURNING consecutive_failures, failure_threshold, state
    """, (endpoint_id,))
    
    row = cur.fetchone()
    if not row:
        # Create health record on first failure
        cur.execute("""
            INSERT INTO endpoint_health (endpoint_id, endpoint_url, 
                consecutive_failures, last_failure_at)
            VALUES (%s, '', 1, NOW())
            ON CONFLICT (endpoint_id) DO UPDATE
            SET consecutive_failures = endpoint_health.consecutive_failures + 1,
                last_failure_at = NOW()
        """, (endpoint_id,))
        return
    
    if row['state'] == 'half_open':
        # Probe failed — re-open circuit with longer cooldown
        cur.execute("""
            UPDATE endpoint_health
            SET state = 'open',
                opened_at = NOW(),
                cooldown_seconds = LEAST(cooldown_seconds * 2, 3600)
            WHERE endpoint_id = %s
        """, (endpoint_id,))
    
    elif (row['state'] == 'closed' and 
          row['consecutive_failures'] >= row['failure_threshold']):
        # Threshold reached — open circuit
        cur.execute("""
            UPDATE endpoint_health
            SET state = 'open',
                opened_at = NOW(),
                cooldown_seconds = 300  -- reset to 5 minutes
            WHERE endpoint_id = %s
        """, (endpoint_id,))


def record_success(cur, endpoint_id):
    """
    Record a delivery success. Close circuit if half_open.
    """
    cur.execute("""
        UPDATE endpoint_health
        SET consecutive_failures = 0,
            last_success_at = NOW(),
            state = 'closed'
        WHERE endpoint_id = %s
    """, (endpoint_id,))

half_open 상태에서 open 상태로 전환될 때 쿨다운 시간을 두 배로 늘리는 방식은 대부분의 구현에서 간과되는 중요한 부분입니다. 엔드포인트가 프로브 도중 실패(half_open 상태)하면 5분 후에 재시도하는 것은 바람직하지 않습니다. 해당 엔드포인트는 여전히 고장난 상태이기 때문입니다. 쿨다운 시간을 10분, 20분으로 두 배로 늘리고 최대 1시간까지 설정하여, 회로 차단기가 주기적으로 과부하를 일으키는 메커니즘으로 악용되는 것을 방지합니다.

지연 시간 백분위 추적

평균은 거짓말을 합니다. 평균 응답 시간이 200ms인 엔드포인트가 95%의 경우 50ms 안에 응답하고 나머지 5%의 경우 3,000ms 안에 응답할 수도 있습니다. 평균은 괜찮아 보입니다. 하지만 P95는 20건의 전송 중 1건에 영향을 미치는 문제를 드러냅니다.

파이썬

def record_latency(cur, endpoint_id, response_ms, status_code):
    """
    Record a latency measurement and update percentile stats.
    """
    cur.execute("""
        INSERT INTO endpoint_latency 
            (endpoint_id, response_ms, status_code)
        VALUES (%s, %s, %s)
    """, (endpoint_id, response_ms, status_code))


def update_latency_percentiles(cur, endpoint_id, window_hours=24):
    """
    Calculate P50, P95, P99 from the rolling window.
    Uses PostgreSQL's percentile_cont for exact percentiles.
    """
    cur.execute("""
        SELECT 
            COUNT(*) as sample_count,
            percentile_cont(0.50) WITHIN GROUP 
                (ORDER BY response_ms) AS p50,
            percentile_cont(0.95) WITHIN GROUP 
                (ORDER BY response_ms) AS p95,
            percentile_cont(0.99) WITHIN GROUP 
                (ORDER BY response_ms) AS p99
        FROM endpoint_latency
        WHERE endpoint_id = %s
          AND measured_at >= NOW() - INTERVAL '%s hours'
    """, (endpoint_id, window_hours))
    
    row = cur.fetchone()
    
    if row and row['sample_count'] > 0:
        cur.execute("""
            UPDATE endpoint_health
            SET p50_ms = %s,
                p95_ms = %s,
                p99_ms = %s,
                sample_count = %s
            WHERE endpoint_id = %s
        """, (
            int(row['p50']), int(row['p95']), 
            int(row['p99']), row['sample_count'],
            endpoint_id
        ))
    
    return row


def get_slow_endpoints(cur, p95_threshold_ms=2000):
    """
    Find endpoints whose P95 latency exceeds the threshold.
    These are candidates for investigation or circuit opening.
    """
    cur.execute("""
        SELECT endpoint_id, endpoint_url, 
               p50_ms, p95_ms, p99_ms, sample_count,
               state, consecutive_failures
        FROM endpoint_health
        WHERE p95_ms > %s
          AND sample_count >= 20  -- need sufficient samples
        ORDER BY p95_ms DESC
    """, (p95_threshold_ms,))
    
    return cur.fetchall()

PostgreSQL의 percentile_cont 이는 정확한 백분위수를 계산하는 순서 집합 집계 함수입니다. 대규모 데이터 세트의 경우 다른 함수로 전환해야 합니다. percentile_disc (보간 대신 실제 관측값을 반환하는) 또는 t-digest 근사치를 사용합니다. 24시간 간격의 웹훅 전송의 경우, 원시 데이터에 대한 정확한 백분위수는 엔드포인트당 약 100,000만 개의 측정값까지 충분히 빠르게 처리할 수 있습니다.

The get_slow_endpoints 이 함수는 15분마다 예약된 점검으로 실행됩니다. P95 값이 2초를 초과하는 엔드포인트는 조사 대상으로 표시됩니다. P95 값이 5초를 초과하는 엔드포인트는 회로 차단기 임계값이 낮아집니다. 즉, 각 전송 실패 시 작업자 스레드가 타임아웃 시간 동안 점유되므로 회로가 열리기 전에 허용되는 연속 실패 횟수가 줄어듭니다.

웹훅 전달 상태 모니터링

다음은 제가 5분마다 실행하는 모니터링 쿼리입니다. 이 쿼리는 전체 배송 파이프라인의 상태 요약 정보를 한 행으로 출력합니다.

SQL

SELECT
    -- Queue depth
    COUNT(*) FILTER (WHERE status = 'pending') AS pending,
    COUNT(*) FILTER (WHERE status = 'failed') AS awaiting_retry,
    COUNT(*) FILTER (WHERE status = 'in_flight') AS in_flight,
    COUNT(*) FILTER (WHERE status = 'dead_letter') AS dead_letter,
    
    -- Delivery rate (last hour)
    COUNT(*) FILTER (
        WHERE status = 'delivered' 
        AND delivered_at >= NOW() - INTERVAL '1 hour'
    ) AS delivered_last_hour,
    
    -- Failure rate (last hour)
    COUNT(*) FILTER (
        WHERE status IN ('failed', 'dead_letter')
        AND created_at >= NOW() - INTERVAL '1 hour'
    ) AS failed_last_hour,
    
    -- Oldest undelivered
    MIN(created_at) FILTER (
        WHERE status IN ('pending', 'failed')
    ) AS oldest_pending,
    
    -- Average delivery latency (last hour, successful only)
    AVG(last_response_ms) FILTER (
        WHERE status = 'delivered'
        AND delivered_at >= NOW() - INTERVAL '1 hour'
    ) AS avg_delivery_ms_last_hour

FROM webhook_queue;

The oldest_pending 이 쿼리에서 가장 중요한 지표는 값입니다. 값이 최대 재시도 시간(모든 백오프 지연 시간의 합)보다 오래되었다면 구조적인 문제가 있는 것입니다. 워커가 멈췄거나, 엔드포인트가 블랙홀에 걸렸거나, 큐가 비우는 속도보다 빠르게 커지고 있는 것일 수 있습니다.

저는 다음 세 가지 조건에 대해 알림을 보냅니다. 반송 우편물 수가 증가하는 경우(엔드포인트가 영구적으로 고장났는데 아무도 조사하지 않는 경우), 대기열 시간이 30분을 초과하는 경우(배송이 지연되는 경우), 엔드포인트별 P95 지연 시간이 전달 신뢰성 저하를 나타내는 임계값을 초과했습니다. 세 번째는 조기 경고 신호입니다. 장애가 발생하기 전에 지연 시간이 증가합니다. 200ms 만에 응답하던 엔드포인트가 3초 만에 응답하기 시작하면 타임아웃이 발생하기 시작할 가능성이 높습니다.

미처리 우편물 대기열은 단순한 저장 공간이 아닙니다.

대부분의 팀은 실패한 웹훅을 저장하는 테이블 형태의 데드 레터 큐를 구현합니다. 그리고 장애 대응 중에 가끔씩 이를 확인합니다. 하지만 이는 시간 낭비입니다.

미배송 우편물 목록은 가장 가치 있는 디버깅 데이터 세트입니다. 각 행은 시스템이 여러 번 시도했지만 결국 포기한 배송을 나타냅니다. 미배송 우편물의 패턴을 분석하면 성공 지표로는 알 수 없는 정보를 얻을 수 있습니다.

파이썬

def analyze_dead_letters(cur, hours=24):
    """
    Analyze recent dead letter entries for patterns.
    Returns per-endpoint failure analysis.
    """
    cur.execute("""
        SELECT 
            endpoint_id,
            endpoint_url,
            COUNT(*) AS dead_count,
            
            -- Most common error
            MODE() WITHIN GROUP (ORDER BY last_error) AS primary_error,
            
            -- Most common status code
            MODE() WITHIN GROUP (ORDER BY last_status_code) 
                AS primary_status_code,
            
            -- Timing
            MIN(created_at) AS first_dead,
            MAX(created_at) AS last_dead,
            
            -- Average attempts before giving up
            AVG(attempt_count)::INTEGER AS avg_attempts
            
        FROM webhook_queue
        WHERE status = 'dead_letter'
          AND created_at >= NOW() - INTERVAL '%s hours'
        GROUP BY endpoint_id, endpoint_url
        ORDER BY dead_count DESC
        LIMIT 20
    """, (hours,))
    
    return cur.fetchall()

폐기된 편지를 검토할 때 저는 세 가지 패턴을 찾습니다.

클러스터 오류: 같은 엔드포인트에서 같은 시간 내에 50개의 데드 레터가 발생했다는 것은 해당 엔드포인트가 다운되었고 재시도 시간 내에 복구되지 않았음을 의미합니다. 조치: 재시도 시간을 연장하거나 수동 재시도 대기열을 구현하십시오.

상태 코드 패턴: 401/403 데드 레터 오류가 급증하는 것은 엔드포인트에서 자격 증명을 변경했지만 웹훅 구성이 업데이트되지 않았음을 의미합니다. 429(요청 과다) 오류가 급증하는 것은 요청 제한을 초과하여 트래픽을 제한해야 함을 의미합니다.

점진적 축적: 단일 엔드포인트에서 하루에 2~3개의 데드 레터가 고르게 발생합니다. 이는 가장 교묘한 패턴으로, 엔드포인트는 대부분 정상적으로 작동하지만 간헐적인 오류가 발생하여 시간이 지남에 따라 재시도 횟수가 소진되는 현상입니다. 해결 방법은 일반적으로 오류 발생 횟수를 늘리는 것입니다. max_attempts 특정 엔드포인트에 대해 또는 타임아웃 시간을 줄이는 것입니다.

작업자 실행

모든 것을 하나로 묶는 핵심 고리:

파이썬

import signal
import sys

running = True

def shutdown_handler(signum, frame):
    global running
    running = False
    print(f"Received signal {signum}, shutting down gracefully...")

signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)

def main():
    print("Webhook delivery worker starting...")
    
    while running:
        try:
            deliver_webhooks(batch_size=50)
        except Exception as e:
            print(f"Worker error: {e}")
            time.sleep(5)  # back off on errors
            continue
        
        # Update latency stats every 100 iterations
        # (cheap operation, doesn't need to run every loop)
        if int(time.time()) % 100 == 0:
            conn = get_connection()
            cur = conn.cursor(cursor_factory=RealDictCursor)
            try:
                cur.execute(
                    "SELECT DISTINCT endpoint_id FROM endpoint_health"
                )
                for row in cur.fetchall():
                    update_latency_percentiles(cur, row['endpoint_id'])
                conn.commit()
            finally:
                cur.close()
                conn.close()
        
        # Poll interval — 500ms keeps latency low without
        # hammering the database
        time.sleep(0.5)

    print("Worker shut down cleanly.")

if __name__ == '__main__':
    main()

The SIGTERM 핸들러는 컨테이너 환경에서 깔끔한 종료를 위해 필수적입니다. Kubernetes가 SIGTERM 신호를 보내면 워커는 현재 배치 작업을 완료하고 트랜잭션을 커밋한 후 종료됩니다. 이 핸들러가 없으면 처리되지 않은 행이 남아 있게 됩니다. in_flight 처리 중인 작업자가 없는 상태입니다.

이 시스템이 제공하지 않는 기능 (그리고 더 많은 기능이 필요한 경우)

이 구현은 2~3개의 워커 프로세스를 사용하는 단일 PostgreSQL 인스턴스에서 분당 최대 약 10,000건의 배송을 처리합니다. 그 이상을 처리하려면 세 가지 변경 사항이 필요합니다.

먼저 PostgreSQL 큐를 Redis Streams 또는 RabbitMQ로 교체하십시오. SELECT FOR UPDATE SKIP LOCKED 이 패턴은 높은 처리량에서 큐 테이블에 쓰기 경합을 발생시킵니다. 전용 메시지 브로커를 사용하면 이 문제를 해결할 수 있습니다.

둘째, 엔드포인트별 요청 제한을 추가하세요. 일부 수신 엔드포인트는 요청 수 제한(분당 100건, 시간당 1,000건)이 있습니다. 클라이언트 측에서 요청 수 제한을 설정하지 않으면 할당량을 초과하여 429 오류가 발생할 수 있습니다. 엔드포인트별로 토큰 버킷을 구현하세요.

세 번째로, 요청 서명을 추가하세요. 페이로드에 HMAC-SHA256 서명을 추가하면 수신 엔드포인트에서 웹훅이 사용자 시스템에서 전송되었으며 전송 중에 변조되지 않았음을 확인할 수 있습니다. 이는 금융 데이터를 전송하는 모든 웹훅 시스템에 필수적인 요소입니다.

이 글에서 소개하는 시스템은 기본 토대입니다. 이 토대는 규모와 관계없이 모든 웹훅 전송 시스템에 필요한 핵심적인 문제들, 즉 재시도 로직, 회로 차단, 지연 시간 측정, 데드 레터 분석 등을 처리합니다. 메시지 브로커, 속도 제한 장치, 요청 서명과 같은 추가적인 구성 요소는 처리량 및 보안 요구 사항에 따라 달라집니다.

가장 중요한 부분은 대부분의 팀이 간과하는 부분입니다. 바로 전달 시스템 자체를 측정하는 것입니다. "지난 24시간 동안 엔드포인트 X까지의 P95 전달 지연 시간은 얼마인가?"라는 질문에 답할 수 없다면, 제대로 된 정보를 확보하지 못한 채 작업하는 것과 마찬가지입니다. 먼저 측정 도구를 구축하세요. 그러면 나머지는 자연스럽게 따라올 것입니다.

웹훅 전송 관련 FAQ

웹훅 발신자는 정확히 한 번만 전달됨을 약속해야 할까요?

일반적으로는 그렇지 않습니다. 발신자는 재시도 여부를 명확히 표시하고 안정적인 이벤트 ID를 제공해야 하며, 수신자는 이벤트 처리가 멱등성을 갖도록 하여 비즈니스 결과가 중복되지 않도록 해야 합니다.

어떤 오류를 재시도해야 할까요?

계약에서 일시적인 오류로 분류한 네트워크 오류, 시간 초과, 특정 서버 응답과 같은 오류에 대해서만 재시도하십시오. 명확한 해결 경로가 없는 한, 형식이 잘못된 요청, 인증 실패 또는 기타 영구적인 오류에 대해서는 반복적으로 재시도하지 마십시오.

팀은 언제 데이터베이스 기반 큐 시스템을 넘어서야 할까요?

측정된 경쟁, 백로그 기간, 처리량 또는 운영 복구 요구 사항이 데이터베이스 큐가 더 이상 제공 계약을 충족하지 못하는 것으로 나타날 때 용량을 조정하십시오. 용량 결정은 일반적인 요청률 주장이 아닌 관찰된 워크로드를 기반으로 해야 합니다.

이전 기사

2026년 최고의 iGaming 제휴 마케팅 추적 소프트웨어

다음 글

iGaming 분야의 AI: 카지노를 위한 ChatGPT 활용 사례

시저 픽슨
저자:

시저 픽슨

저는 온라인 게임 플랫폼, 도박 활동 및 시장 동향과 관련된 데이터를 분석하고 해석하는 데 특화된 iGaming 데이터 분석가입니다. 플레이어 행동, 게임 성과, 매출 동향을 분석하여 게임 경험과 비즈니스 전략을 최적화합니다.

데모 요청
STEP 1 3의
감사합니다. 대기열에 등록되었습니다.
NowG 솔루션 엔지니어가 영업일 기준 하루 이내에 연락드려 현장 방문 일정을 잡아드리겠습니다.
색인