왜 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)

핵심 아이디어: DynamoDB 조건부 쓰기 = 분산 락
별도의 코디네이션 서비스(ZooKeeper, etcd) 없이 DynamoDB의 조건부 쓰기(Conditional Write) 만으로 분산 락을 구현합니다. 각 연결마다 Lease(임차권) 를 부여하고, 워커는 주기적으로 갱신(heartbeat)해야 하며, 갱신이 끊기면 lease가 만료되어 다른 워커가 인수할 수 있어요.
테이블 스키마
| 속성 | 타입 | 설명 |
|---|---|---|
Pk | String (PK) | 연결 ID (예: CONN#meeting-123) |
desired_state | String | STARTED 또는 STOPPED |
ws_url | String | 업스트림 WebSocket URL |
lease_owner | String | 현재 소유 워커 ID |
lease_expires_at_ms | Number | 만료 시각 (epoch ms) |
last_seq | Number | 마지막 처리 시퀀스 (재개용) |
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을 보내면 워커는 세 단계로 정리합니다.
- 시그널 전파: 공유
shutdown_event를 set → 모든 루프가while not shutdown_event.is_set()에서 즉시 탈출 - 병렬 정리:
asyncio.gather로 모든 WebSocket을 동시에 닫고 lease를 동시에 반납 (연결 수와 무관하게 종료 시간 일정) - 즉시 반납:
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초.

실무에서 반드시 알아야 할 함정
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/초 | 온디맨드 월 비용 | 프로비저닝 월 비용 |
|---|---|---|---|
| 100 | 20 | ~$65 | ~$10 |
| 500 | 100 | ~$325 | ~$47 |
| 2,000 | 400 | ~$1,300 | ~$190 |
지속적으로 높은 연결 수를 유지하는 프로덕션이라면 온디맨드보다 프로비저닝 + Auto Scaling이 압도적으로 저렴합니다. Heartbeat 쓰기는 예측 가능하고 일정해서 프로비저닝 용량과 궁합이 좋아요.
3. 스케일 시 주의
check_desired_state()를 연결마다 개별 GetItem으로 돌리면 N번/10초의 읽기가 발생합니다. 대규모 플릿에서는 BatchGetItem으로 중앙 루프 하나로 통합해서 N회 → 1회 배치로 줄이세요.
4. 튜닝 가이드
| 설정 | 기본값 | 근거 |
|---|---|---|
| Lease duration | 20초 | 짧은 네트워크 hiccup은 버티고, failover는 빠르게 |
| Heartbeat interval | 5초 | 만료 전 4회 갱신 시도 (4:1 안전 마진) |
| Reconciliation interval | 60초 | 복구 속도 vs DynamoDB 읽기 비용 균형 |
| Max connections/task | 700 | 연결당 2~5MB 메모리 프로파일링 기반 |
| Scale-out cool down | 2분 | 트래픽 스파이크 시 thrashing 방지 |
| Scale-in cool down | 15분 | 연결 안정화 후 용량 축소 |
5. 이 패턴이 안 맞는 경우
- 짧은 연결(수 초 이내): lease 오버헤드가 이득보다 큽니다
- 강한 일관성이 필수인 금융 트랜잭션: GSI 쿼리는 기본적으로 eventually consistent → RCU 절반을 아끼는 대신 약간의 지연 감수
- 단일 워커로 충분한 소규모: 그냥 SQS + 단일 컨슈머로 끝내세요

정리: 이 패턴이 진짜 주는 것
이 아키텍처의 본질은 "별도 코디네이션 서비스 없이 DynamoDB 하나로 분산 락 + 도메인 상태 + 페일오버를 동시에 해결" 한다는 점입니다. ZooKeeper나 etcd를 새로 띄우는 대신, 이미 쓰고 있을지도 모르는 DynamoDB 테이블 하나로 끝납니다.
핵심 테이크어웨이 3줄 요약:
- 조건부 쓰기 = 원자적 분산 락.
attribute_not_exists OR lease_expires_at_ms < :now하나로 정확히 한 워커만 연결을 소유합니다. - Heartbeat + Reconciliation이 전체 장애 스펙트럼을 커버. 예기치 못한 종료는 lease 만료로, 롤링 배포는 graceful shutdown으로 처리. 신규 워커는 획득하고 떠나는 워커는 반납하니 스케일링이 안전합니다.
- 적용 범위가 넓다. 실시간 전사, IoT 데이터 수집, 금융 피드 처리, 라이브 이벤트 스트리밍 등 장수명 WebSocket 연결을 다루는 모든 시스템에 그대로 이식 가능.
한국 개발 생태계에서의 적용 맥락
국내 SI/스타트업 환경에서 이 패턴이 특히 유용한 지점은 "운영 인력이 얇은 팀" 입니다. 워커 하나 죽을 때마다 새벽에 깨서 수동 재시작하는 구조에서 벗어나려면, 이 정도 자동 복구 레이어는 필수예요. 다만 국내에서 흔한 온프레미스 IDC + 하이브리드 클라우드 구성이라면 시계 동기화(NTP)를 반드시 사전 점검하세요. 폐쇄망에서 chrony 설정이 안 되어 있으면 lease가 오탐으로 만료되어 연결이 계속 재할당되는 지옥을 볼 수 있습니다.
다음 단계 학습 방향
- 분산 추적: AWS X-Ray로 워커 간 end-to-end 가시성 확보
- 재연결 로직: 업스트림 replay 또는 offset 기반 재개로 워커 장애~복구 사이 데이터 갭 메우기
- IaC: Terraform으로 DynamoDB 테이블 + SQS + ECS 클러스터까지 한 번에 프로비저닝 (원문 GitHub 저장소에 템플릿 포함)
함께 보면 좋은 글
- "Gallup이 Amazon Bedrock으로 수천 명 리더를 실시간 코칭한 방법 (아키텍처 해부)" — AWS 위에서 대규모 실시간 워크로드를 어떻게 설계하는지 실제 사례
- "공장 알람 수백 건, AI 에이전트가 5초 만에 처리하는 구조 (NVIDIA Nemotron 실전 분석)" — 대량 이벤트를 실시간으로 처리하는 또 다른 접근