왜 WebSocket 워커는 늘 문제를 일으키는가

실시간 전사(transcription) 서비스가 동시에 500개 회의를 처리한다고 가정해 봅시다. 각 회의마다 전용 WebSocket 연결이 필요하고, 워커 하나가 100개 이상의 연결을 들고 있어요. 워커 하나만 죽어도 100개 연결이 그대로 끊기고, 운영자가 수동으로 재시작하기 전까지 2~3분씩 데이터가 유실됩니다.

HTTP 요청과 WebSocket은 근본적으로 달라요. HTTP는 요청-응답 후 서버가 상태를 들고 있지 않지만, WebSocket은 지속적인 양방향 채널이라 워커가 살아있는 동안 TCP 연결을 유지하고 keep-alive 핑에 응답해야 합니다. 이 상태성(statefulness)이 다음과 같은 운영 지옥을 만듭니다.

  • 워커 장애: 프로세스가 죽으면 연결이 그대로 방치되고, 업스트림이 잠깐 버퍼링해줘도 재할당 메커니즘이 없으면 데이터 유실
  • 롤링 배포: ECS가 구버전 태스크를 내리면 연결이 끊기는데, 소유권 조정이 없으면 잠깐 '주인 없는 연결'이 발생
  • 수평 확장: 늘리는 건 쉬운데 줄이는 게 지옥. 떠나는 워커의 연결을 드레인하고 다른 워커가 인수했는지 확인해야 함
  • 이중 점유(Double-claiming): 두 워커가 같은 연결을 소유한다고 착각하면 업스트림에 중복 접속 → 데이터 중복 처리나 프로토콜 에러

워커가 클라이언트로서 외부로 나가는(outbound) 연결을 맺기 때문에, 네트워크 계층이 아닌 애플리케이션 계층에서의 조정 메커니즘이 필요합니다. 이 글에서는 AWS Architecture Blog에서 공유한 DynamoDB Lease 패턴을 실무 관점에서 풀어볼게요. (근거자료: AWS Architecture Blog)

AWS ECS Fargate workers managing WebSocket connections with DynamoDB lease table architecture diagram Coding Session Visual

핵심 아이디어: DynamoDB 조건부 쓰기 = 분산 락

별도의 코디네이션 서비스(ZooKeeper, etcd) 없이 DynamoDB의 조건부 쓰기(Conditional Write) 만으로 분산 락을 구현합니다. 각 연결마다 Lease(임차권) 를 부여하고, 워커는 주기적으로 갱신(heartbeat)해야 하며, 갱신이 끊기면 lease가 만료되어 다른 워커가 인수할 수 있어요.

테이블 스키마

속성타입설명
PkString (PK)연결 ID (예: CONN#meeting-123)
desired_stateStringSTARTED 또는 STOPPED
ws_urlString업스트림 WebSocket URL
lease_ownerString현재 소유 워커 ID
lease_expires_at_msNumber만료 시각 (epoch ms)
last_seqNumber마지막 처리 시퀀스 (재개용)

desired_state(PK) + lease_expires_at_ms(SK)로 GSI를 걸면 "활성이어야 하는데 주인이 없는 연결"을 한 방에 조회할 수 있습니다.

Lease 획득 — 조건부 쓰기의 정수

# worker.py — lease 획득 핵심 로직
async def try_acquire_lease(table, conn_id: str, worker_id: str, now_ms: int) -> bool:
    try:
        await table.update_item(
            Key={"Pk": f"CONN#{conn_id}"},
            UpdateExpression=(
                "SET lease_owner = :w, lease_expires_at_ms = :exp"
            ),
            # 핵심: lease가 없거나(attribute_not_exists) 이미 만료된 경우에만 성공
            ConditionExpression=(
                "attribute_not_exists(lease_owner) "
                "OR lease_expires_at_ms < :now"
            ),
            ExpressionAttributeValues={
                ":w": worker_id,
                ":exp": now_ms + 20_000,  # 20초 lease
                ":now": now_ms,
            },
        )
        return True
    except table.meta.client.exceptions.ConditionalCheckFailedException:
        # 다른 워커가 이미 점유 → 우아하게 물러남
        return False

두 워커가 동시에 같은 연결을 노려도 DynamoDB가 원자적으로 조건을 평가하기 때문에 정확히 한 워커만 성공합니다. 나머지는 ConditionalCheckFailedException을 받고 백오프하면 끝.

세 개의 동시 태스크로 연결을 지킨다

Lease를 획득한 워커는 asyncio.gather로 세 개의 태스크를 동시에 돌립니다. 하나라도 종료되면 전체 연결이 정리되는 구조예요.

async def manage_connection(conn_id, ws_url, table, worker_id):
    try:
        await asyncio.gather(
            heartbeat_loop(conn_id, table, worker_id),   # 5초마다 lease 갱신
            receive_loop(conn_id, ws_url, table, worker_id),  # 메시지 수신/저장
            check_desired_state(conn_id, table),         # 10초마다 STOP 확인
        )
    finally:
        # 어떤 이유로 끝났든 반드시 lease 반납
        await release_lease(table, conn_id, worker_id)
  • heartbeat_loop: 5초마다 renew_lease. 실패하면 소유권 상실 → 즉시 종료
  • receive_loop: 업스트림 메시지를 DynamoDB messages 테이블에 저장
  • check_desired_state: 외부에서 STOP 이벤트를 보냈는지 확인

Graceful Shutdown — 배포 중 무중단의 비밀

ECS가 SIGTERM을 보내면 워커는 세 단계로 정리합니다.

  1. 시그널 전파: 공유 shutdown_event를 set → 모든 루프가 while not shutdown_event.is_set()에서 즉시 탈출
  2. 병렬 정리: asyncio.gather로 모든 WebSocket을 동시에 닫고 lease를 동시에 반납 (연결 수와 무관하게 종료 시간 일정)
  3. 즉시 반납: lease_expires_at_ms = 0으로 설정 → 다른 워커의 reconciliation 쿼리에 즉시 만료된 것처럼 보임 → 20초 기다리지 않고 다음 사이클에 바로 인수

Orphan Reconciliation — 최후의 안전망

워커가 크래시로 SIGTERM 핸들러를 못 돌리면? 60초마다 도는 reconciliation 루프가 GSI를 조회해서 desired_state=STARTED AND lease_expires_at_ms < now인 고아 연결을 찾아 재획득합니다. 여러 워커가 동시에 돌아도 조건부 쓰기 덕분에 정확히 한 놈만 이깁니다. 최악의 복구 시간은 lease 20초 + reconciliation 60초 = 약 80초.

DynamoDB conditional write lease acquisition flow for distributed WebSocket connection ownership

실무에서 반드시 알아야 할 함정

1. 시계 정확도 — 이 패턴의 아킬레스건

Lease 만료 판정은 워커의 로컬 시계로 만든 epoch ms를 기준으로 합니다. DynamoDB 서버 시계가 아니라 호출한 워커가 넘긴 :now 값을 씁니다. 즉, 모든 워커의 시계가 동기화되어 있어야 합니다.

  • AWS Fargate는 Amazon Time Sync Service로 리전 내 skew를 수 ms 이내로 유지 → 20초 lease + 5초 heartbeat의 안전 마진에 충분
  • Fargate 밖(온프레미스, 다른 클라우드)에서 돌린다면 반드시 NTP 설정하고 clock drift 모니터링하세요. 시계 정확도를 보장할 수 없으면 최대 예상 skew만큼 lease duration을 늘려야 합니다.

2. 비용 — heartbeat 쓰기가 지배적

각 연결이 heartbeat마다 update_item 1회 = 1 WCU. 5초 주기 기준:

활성 연결WCU/초온디맨드 월 비용프로비저닝 월 비용
10020~$65~$10
500100~$325~$47
2,000400~$1,300~$190

지속적으로 높은 연결 수를 유지하는 프로덕션이라면 온디맨드보다 프로비저닝 + Auto Scaling이 압도적으로 저렴합니다. Heartbeat 쓰기는 예측 가능하고 일정해서 프로비저닝 용량과 궁합이 좋아요.

3. 스케일 시 주의

check_desired_state()를 연결마다 개별 GetItem으로 돌리면 N번/10초의 읽기가 발생합니다. 대규모 플릿에서는 BatchGetItem으로 중앙 루프 하나로 통합해서 N회 → 1회 배치로 줄이세요.

4. 튜닝 가이드

설정기본값근거
Lease duration20초짧은 네트워크 hiccup은 버티고, failover는 빠르게
Heartbeat interval5초만료 전 4회 갱신 시도 (4:1 안전 마진)
Reconciliation interval60초복구 속도 vs DynamoDB 읽기 비용 균형
Max connections/task700연결당 2~5MB 메모리 프로파일링 기반
Scale-out cool down2분트래픽 스파이크 시 thrashing 방지
Scale-in cool down15분연결 안정화 후 용량 축소

5. 이 패턴이 안 맞는 경우

  • 짧은 연결(수 초 이내): lease 오버헤드가 이득보다 큽니다
  • 강한 일관성이 필수인 금융 트랜잭션: GSI 쿼리는 기본적으로 eventually consistent → RCU 절반을 아끼는 대신 약간의 지연 감수
  • 단일 워커로 충분한 소규모: 그냥 SQS + 단일 컨슈머로 끝내세요

CloudWatch metrics dashboard showing ActiveConnections scaling ECS Fargate worker fleet Software Concept Art

정리: 이 패턴이 진짜 주는 것

이 아키텍처의 본질은 "별도 코디네이션 서비스 없이 DynamoDB 하나로 분산 락 + 도메인 상태 + 페일오버를 동시에 해결" 한다는 점입니다. ZooKeeper나 etcd를 새로 띄우는 대신, 이미 쓰고 있을지도 모르는 DynamoDB 테이블 하나로 끝납니다.

핵심 테이크어웨이 3줄 요약:

  1. 조건부 쓰기 = 원자적 분산 락. attribute_not_exists OR lease_expires_at_ms < :now 하나로 정확히 한 워커만 연결을 소유합니다.
  2. Heartbeat + Reconciliation이 전체 장애 스펙트럼을 커버. 예기치 못한 종료는 lease 만료로, 롤링 배포는 graceful shutdown으로 처리. 신규 워커는 획득하고 떠나는 워커는 반납하니 스케일링이 안전합니다.
  3. 적용 범위가 넓다. 실시간 전사, IoT 데이터 수집, 금융 피드 처리, 라이브 이벤트 스트리밍 등 장수명 WebSocket 연결을 다루는 모든 시스템에 그대로 이식 가능.

한국 개발 생태계에서의 적용 맥락

국내 SI/스타트업 환경에서 이 패턴이 특히 유용한 지점은 "운영 인력이 얇은 팀" 입니다. 워커 하나 죽을 때마다 새벽에 깨서 수동 재시작하는 구조에서 벗어나려면, 이 정도 자동 복구 레이어는 필수예요. 다만 국내에서 흔한 온프레미스 IDC + 하이브리드 클라우드 구성이라면 시계 동기화(NTP)를 반드시 사전 점검하세요. 폐쇄망에서 chrony 설정이 안 되어 있으면 lease가 오탐으로 만료되어 연결이 계속 재할당되는 지옥을 볼 수 있습니다.

다음 단계 학습 방향

  • 분산 추적: AWS X-Ray로 워커 간 end-to-end 가시성 확보
  • 재연결 로직: 업스트림 replay 또는 offset 기반 재개로 워커 장애~복구 사이 데이터 갭 메우기
  • IaC: Terraform으로 DynamoDB 테이블 + SQS + ECS 클러스터까지 한 번에 프로비저닝 (원문 GitHub 저장소에 템플릿 포함)

함께 보면 좋은 글

본 콘텐츠는 신뢰할 수 있는 출처를 바탕으로 AI 도구를 활용하여 초안이 작성되었으며, 편집자의 검토를 거쳐 발행되었습니다. 전문가의 조언을 대체하지 않습니다.