왜 WebSocket 워커 관리가 어려운가
HTTP 요청은 처리 후 응답을 반환하면 서버가 상태를 들고 있지 않아요. 반면 WebSocket은 지속적인 양방향 채널이라 워커가 TCP 연결을 계속 열어두고, 업스트림에서 오는 메시지를 처리하고, keep-alive 핑에도 응답해야 합니다.
문제는 이 상태성(statefulness)이 운영에서 여러 골칫거리를 만든다는 점이에요.
- 워커 장애: 컨테이너가 죽으면 연결이 뚝 끊기고, 업스트림이 잠깐 버퍼링은 해도 재할당 메커니즘이 없으면 데이터가 유실됩니다.
- 롤링 배포: ECS가 구 태스크를 종료하고 신 태스크를 띄우는 사이 연결의 주인이 공백이 되는 윈도우가 생겨요.
- 수평 확장(축소): 늘리는 건 쉽지만 줄일 때는 연결을 드레인하고 다른 워커가 인수했는지 확인해야 합니다.
- 이중 클레임: 두 워커가 같은 연결을 소유한다고 착각하면 업스트림에 중복 접속해 프로토콜 에러나 중복 처리가 발생해요.
워커는 업스트림으로 아웃바운드 연결을 여는 클라이언트라서, 네트워크 계층이 아니라 애플리케이션 계층에서의 조정이 필요합니다. 이 글에서는 Amazon DynamoDB의 조건부 쓰기를 분산 락처럼 활용해 이 문제를 푸는 패턴을 공유합니다.

핵심: Lease(임차) 패턴과 조건부 쓰기
Lease는 "누가 이 연결을 소유하고, 언제 소유권이 만료되는가"를 담은 DynamoDB 아이템이에요. 스키마는 다음과 같습니다.
| 속성 | 타입 | 설명 |
|---|---|---|
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를 걸어두면 "활성이어야 하는데 주인이 없는 연결"을 효율적으로 조회할 수 있습니다.
획득(Acquire): 원자적 소유권 확보
워커는 ConditionExpression으로 "lease가 없거나(attribute_not_exists), 이미 만료됐다(lease_expires_at_ms < :now)" 일 때만 쓰기가 성공하도록 겁니다. 두 워커가 동시에 시도해도 DynamoDB가 원자적으로 평가하므로 정확히 한 명만 성공하고, 나머지는 ConditionalCheckFailedException을 받고 물러납니다.
import time
from botocore.exceptions import ClientError
async def try_acquire_lease(table, conn_id: str, worker_id: str, lease_seconds: int = 20):
now_ms = int(time.time() * 1000)
try:
table.update_item(
Key={"Pk": f"CONN#{conn_id}"},
UpdateExpression=(
"SET lease_owner = :w, lease_expires_at_ms = :exp"
),
ConditionExpression=(
"attribute_not_exists(lease_owner) "
"OR lease_expires_at_ms < :now"
),
ExpressionAttributeValues={
":w": worker_id,
":exp": now_ms + lease_seconds * 1000,
":now": now_ms,
},
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False # 다른 워커가 이미 소유 중
raise
갱신(Renew): 하트비트
소유 워커는 몇 초마다 lease를 갱신합니다. 갱신이 실패하면 소유권을 잃었다는 뜻이므로 루프를 종료해요.
async def renew_lease(table, conn_id: str, worker_id: str, lease_seconds: int = 20) -> bool:
now_ms = int(time.time() * 1000)
try:
table.update_item(
Key={"Pk": f"CONN#{conn_id}"},
UpdateExpression="SET lease_expires_at_ms = :exp",
ConditionExpression="lease_owner = :w",
ExpressionAttributeValues={
":w": worker_id,
":exp": now_ms + lease_seconds * 1000,
},
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise
해제(Release)와 만료(Expired)
정상 종료 시에는 lease_expires_at_ms = 0으로 밀어버립니다. 0이면 조정 루프에서 즉시 만료로 보여서, 다른 워커가 대기 없이 바로 인수할 수 있어요. 반대로 워커가 크래시하면 갱신이 끊기고 lease가 자연 만료되는데, 이때 desired_state = STARTED인 채로 주인 없는 연결 = 오펀(orphan) 이 됩니다.
⚠️ 주의: 시계 동기화 만료 판정은 워커가 보낸
now값을 기준으로 하므로, 모든 워커의 시계가 어느 정도 맞아야 합니다. Fargate는 Amazon Time Sync로 수 ms 이내 오차를 유지하지만, 다른 인프라에 배포한다면 NTP를 반드시 설정하고, 시계 오차가 보장되지 않는 환경에서는 lease 길이를 최대 예상 skew만큼 늘려 오탐 만료를 막으세요.

조정 루프와 그레이스풀 셧다운
오펀 조정(Reconciliation)
모든 워커가 주기적으로(기본 60초) GSI를 조회해 desired_state = STARTED 이면서 lease_expires_at_ms < now인 레코드를 찾습니다. 각 오펀에 대해 try_acquire_lease를 호출하는데, 조건부 쓰기라서 여러 워커가 동시에 조정을 돌려도 정확히 하나만 성공합니다.
최악의 복구 시간은 대략 lease 만료(20초) + 조정 주기(60초) = 약 80초가 됩니다. 그레이스풀 셧다운이면 lease_expires_at_ms = 0으로 즉시 만료 처리되므로 다음 조정 사이클에서 바로 인수돼요.
그레이스풀 셧다운 3단계
ECS가 롤링 배포나 스케일인에서 SIGTERM을 보내면:
- 시그널 전파:
signal_handler가 공유shutdown_event를 세팅하면, 모든 연결의 하트비트·상태 체커·조정 루프가while not shutdown_event.is_set()을 빠져나옵니다. 개별 종료 로직을 따로 짤 필요가 없어요. - 병렬 정리: 연결을 순차로 닫으면 연결 수가 많을수록 느려지니,
asyncio.gather로 동시에 WebSocket을 닫고 lease를 해제합니다. - 즉시 해제:
lease_expires_at_ms = 0으로 밀어 다른 워커가 다음 조정 사이클에서 바로 인수하게 합니다.
import asyncio, signal
shutdown_event = asyncio.Event()
def signal_handler():
shutdown_event.set()
async def graceful_shutdown(active_connections: dict):
# 모든 연결을 병렬로 닫고 lease 해제
await asyncio.gather(*[
conn.close_and_release(lease_expires_at_ms=0)
for conn in active_connections.values()
], return_exceptions=True)
async def main():
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, signal_handler)
# ... 워커 실행 ...
await shutdown_event.wait()
await graceful_shutdown(active_connections)
실무 적용 시 주의사항과 비용
한국 SI/스타트업 환경에서의 적용 맥락 국내에서 실시간 스트리밍(예: 실시간 회의록, 라이브 커머스 채팅)을 운영할 때, 초기엔 단일 워커로 버티다가 연결이 수백 개를 넘어가면 이 패턴이 필요해집니다. 다만 DynamoDB는 국내 리전(서울)에서도 온디맨드/프로비저닝 선택이 가능하고, 하트비트 쓰기가 비용의 대부분을 차지하므로 프로비저닝 + Auto Scaling을 초기에 잡아두는 게 안전해요. 온디맨드로 방치하면 연결 수가 늘 때마다 WCU 비용이 선형으로 올라갑니다.
비판적 시각: 이 패턴의 한계
- 시계 의존성: 결국 워커 시계에 의존합니다. 시계가 크게 어긋나는 환경(예: 온프레미스 혼합)에서는 안전 마진을 크게 늘려야 하고, 그만큼 장애 조치가 느려져요.
- DynamoDB 종속: 조건부 쓰기의 원자성에 기대는 구조라 다른 저장소로 옮기기 어렵습니다. 멀티클라우드가 필요하면 별도 조정 서비스(etcd, Consul)를 검토해야 해요.
- 핫 파티션 위험: 연결 하나가 하트비트마다 쓰기를 하므로, 연결 수가 많아지면 파티션 분산이 중요합니다. 연결 ID를 키로 쓰는 건 좋지만, 조정 GSI 조회가 몰리지 않도록 페이지 크기와 주기를 조정하세요.
- per-connection
get_item: 소규모에선 괜찮지만 수백 개 넘어가면BatchGetItem으로 묶어 RCU를 줄이는 게 좋습니다.
다음 단계 학습 방향
- AWS X-Ray로 워커 간 분산 트레이싱을 붙여 장애 조치 경로를 가시화해 보세요.
- 업스트림 재생(replay)이나 오프셋 기반 재개(
last_seq활용)를 구현해 워커 장애~복구 사이의 데이터 갭을 메우는 로직을 추가하면 완성도가 올라갑니다. - 유사한 분산 락 패턴을 공부하려면 DynamoDB 조건 표현식 문서와 GSI 설계 가이드를 함께 보는 걸 추천합니다.

마무리: 소유권을 명시적으로 관리하라
장시간 유지되는 WebSocket 연결을 대규모로 운영하려면 명시적 소유권 추적, 자동 장애 조치, 함대 차원의 조정이 반드시 필요합니다. 이 글에서 소개한 패턴의 핵심은 세 가지예요.
- DynamoDB 조건부 쓰기 = 외부 락 서비스 없는 분산 조정.
update_item의ConditionExpression으로 한 시점에 한 워커만 연결을 소유하도록 보장합니다. - 하트비트 + 조정 루프 = 전체 장애 스펙트럼 커버. 예기치 못한 종료는 lease 만료로, 롤링 배포는 그레이스풀 셧다운으로 처리됩니다.
- 확장/축소가 안전해짐. 신규 워커는 lease를 획득하고, 떠나는 워커는 해제하므로 스케일링이 자연스럽게 안전해집니다.
실시간 전사, IoT 데이터 수집, 금융 피드, 라이브 이벤트 스트리밍 등 장시간 연결을 다루는 시스템이라면 이 패턴이 바로 적용 가능합니다. 전체 구현(워커 앱, Lambda 이벤트 라우터, Terraform 템플릿)은 원문 저장소에서 확인할 수 있어요. 자세한 아키텍처 설명은 근거자료를 참고하세요.