본문으로 건너뛰기
글 목록으로 돌아가기
이 글 목차
~/posts/backend

AWS ECS/ALB에서의 WebSocket 아키텍처

ALB, ECS, Redis Pub/Sub를 활용한 실시간 알림용 WebSocket 연결 구조

사용자들이 캘린더 동기화가 끝났는지 보려고 계속 페이지를 새로고침했어요. 동기화가 끝나는 순간 서버가 “동기화 완료” 알림을 바로 밀어줘야 했는데, NestJS 백엔드는 ALB 뒤에서 여러 ECS 컨테이너로 돌아가고 있었죠. 작업이 Container 2에서 끝나면, Container 1에 붙어 있는 사용자 A는 그걸 어떻게 알까요?

HTTP 폴링으로도 되긴 했지만 리소스를 낭비하고 눈에 띄는 지연이 생겼어요. WebSocket이 답이었는데, 컨테이너 여러 대와 로드 밸런서가 끼어들면서 질문이 줄줄이 따라왔어요. ALB는 HTTP-to-WebSocket 업그레이드를 어떻게 처리할까? 서로 다른 인스턴스에 연결된 클라이언트에게 여러 컨테이너가 어떻게 브로드캐스트할까? 연결이 끊기면 어떻게 될까?

답을 찾다 보니 경로 전체를 뜯어보게 됐어요. ALB가 업그레이드를 넘겨주는 방식, Redis Pub/Sub가 컨테이너 사이를 잇는 방식, 연결이 끊길 때 실제로 벌어지는 일까지요.

어려웠던 점들

여러 오해가 진행을 늦췄어요.

처음에 ALB가 WebSocket 상태를 적극적으로 관리한다고 생각했어요. 아니에요. HTTP 업그레이드 핸드셰이크가 끝나고 나면 ALB는 그냥 TCP 터널이에요. 이 오해 때문에 아무 소용도 없는 ALB 설정을 몇 번 붙잡고 있었어요.

컨테이너 간 브로드캐스팅이 더 어려운 문제였어요. 사용자 A가 Container 1에 연결되어 있는데 동기화 작업이 Container 2에서 완료되면, Container 2가 사용자 A에게 직접 알릴 수 없어요. Redis Pub/Sub가 HTTP 콜백이 아니라 영구 TCP 구독으로 이 문제를 푼다는 걸 이해하는 데 시간이 좀 걸렸어요.

연결 수명 주기의 엣지 케이스는 Socket.io 문서만으로는 명확하지 않았어요. 브라우저 탭 닫기는 TCP FIN을 보내고 (WebSocket 닫기 프레임이 아님), 네트워크 끊김은 아무것도 보내지 않고 (ping/pong 타임아웃에 의존), ALB는 자체 유휴 타임아웃이 있어요. 각 시나리오마다 다른 처리가 필요해요.

Socket.io에 sticky session이 필수라고 생각했는데, 아니었어요. Sticky session은 HTTP 폴링 폴백에만 필요해요. WebSocket 전송만 사용하면, 초기 업그레이드 후 어떤 컨테이너든 연결을 처리할 수 있어요.

아키텍처 개요

Loading diagram...

연결 흐름은 이렇게 동작해요:

  1. ClientUpgrade: websocket 헤더와 함께 HTTP 요청을 보냄
  2. ALB가 요청을 받아 컨테이너로 라우팅
  3. Container가 업그레이드를 수락하고 WebSocket 설정
  4. ALB가 TCP 터널을 열린 상태로 유지 (데이터를 통과시킴)
  5. Socket.io가 여기서부터 연결을 관리

컴포넌트 역할

컴포넌트역할
ALB초기 HTTP 업그레이드 라우팅, 이후 TCP 통과 (터널)
Socket.io ServerWebSocket 연결 관리, 클라이언트 추적, 하트비트 처리
NestJS Gateway애플리케이션 로직: 인증, 메시지 처리, 룸 관리
Redis Adapter여러 컨테이너 간 메시지 브로드캐스트

ALB는 그냥 터널이에요. Socket.io가 실제 연결을 관리해요.

ALB가 필요한 이유

Socket.io가 연결을 관리한다면 ALB는 왜 필요할까요? 프로토콜 처리가 아니라 네트워크 구조 때문이에요. 처음 연결을 어디로 보낼지 정하는 역할이죠:

Without ALB:
  Client: "I want to connect to wss://api.example.com"
  Containers have private IPs:
  - 10.0.1.5:3000
  - 10.0.1.6:3000
  Client can't access private IPs directly!

With ALB:
  Client → api.example.com → ALB (public) → picks container → WebSocket established

컨테이너는 프라이빗 서브넷에 있어요. ALB가 트래픽을 올바른 곳으로 라우팅하는 퍼블릭 진입점이에요.

멀티 컨테이너 브로드캐스팅을 위한 Redis

여러 ECS 컨테이너가 있을 때, 사용자 A가 Container 1에 연결되어 있지만 동기화 작업은 Container 2에서 실행될 수 있어요. Container 2는 사용자 A에게 직접 알릴 수 없어요.

Loading diagram...

Pub/Sub의 실제 동작 방식

메시지가 발행되면 Redis가 앱으로 HTTP 요청을 쏜다고 생각하기 쉬운데, 실제로는 방향이 반대예요. 앱이 Redis로 영구 TCP 연결을 열어두면, Redis가 이미 열려 있는 그 소켓에 데이터를 그대로 써 넣어요:

Step 1: STARTUP
  Container opens TCP connection to Redis (stays open!)
  → "SUBSCRIBE app:socket.io"

Step 2: PUBLISH
  Container 2 sends message to Redis
  → "PUBLISH app:socket.io {user:123, data:...}"

Step 3: PUSH
  Redis writes to the ALREADY OPEN TCP connection

Step 4: RECEIVE
  Node.js event loop picks up data from socket
  Socket.io adapter handles it → delivers to user's WebSocket

Socket.io Redis Adapter 설정에서는 Redis 클라이언트를 두 개 써요. 하나는 발행용, 하나는 구독용이에요:

// pubClient: for PUBLISHING messages
// subClient: for SUBSCRIBING (maintains open TCP connection)
const pubClient = createClient({ url: redisUrl });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

this.adapterConstructor = createAdapter(pubClient, subClient, {
  key: `${redisConfig.prefix}:socket.io`,
});

구독용 연결은 채널에 메시지가 올라올 때마다 Redis가 데이터를 써 넣는 통로예요. 그래서 계속 열린 상태로 유지돼요.

연결 수명 주기

세 가지 연결 끊김 시나리오가 있고, 각각 다르게 감지돼요:

정상 종료 (사용자가 탭 닫기)

Client closes browser

Browser's TCP stack sends FIN packet (not Socket.io!)

TCP FIN packet ──► ALB ──► Container

Socket.io detects TCP connection closed

handleDisconnect() called (auto room cleanup)

브라우저는 WebSocket 닫기 프레임이 아닌 TCP FIN을 보내요. 브라우저가 종료 중이라 우아한 WebSocket 닫기를 할 시간이 없거든요. TCP FIN은 더 빠르고, OS가 처리하며, JavaScript가 멈춰도 동작해요.

네트워크 중단

Network drops (no FIN packet)

Socket.io ping/pong timeout (~25s)

Server marks client as disconnected

handleDisconnect() called

ALB 유휴 타임아웃

No activity for 60 seconds (ALB default)

ALB closes the TCP connection

Both client and server detect disconnect

이건 드물게 발생해요. Socket.io가 25초마다 하트비트를 보내서 60초 임계값 전에 ALB 유휴 타이머를 리셋하거든요.

시작자메커니즘감지
클라이언트 (정상 종료)TCP FIN즉시
클라이언트 (크래시/네트워크)FIN 없음Ping/pong 타임아웃 (~25s)
ALB유휴 타임아웃 (60s)TCP RST
서버client.disconnect()즉시

ALB 유휴 타임아웃과 Socket.io 하트비트

설정기본값역할
ALB 유휴 타임아웃60초60초 동안 데이터 없으면 연결 종료
Socket.io pingInterval25초25초마다 ping 전송
Socket.io pingTimeout20초pong 응답을 20초 대기
Timeline:
0s   ─── Connection established
25s  ─── Socket.io sends PING → ALB resets idle timer
50s  ─── Socket.io sends PING → ALB resets idle timer
75s  ─── Socket.io sends PING → ALB resets idle timer
...

ALB idle timeout (60s) is NEVER reached because Socket.io
sends heartbeat every 25s. No config change needed!

검토한 옵션들

옵션장점단점
Socket.io + Redis Adapter자동 재연결, 룸 관리, 폴링 폴백, 컨테이너 간 브로드캐스트추가 의존성 (Redis); Socket.io 프로토콜 오버헤드
Raw WebSocket (ws 라이브러리)최소 오버헤드; 추상화 레이어 없음자동 재연결 없음; 수동 룸 관리; 커스텀 pub/sub 없이 컨테이너 간 브로드캐스트 불가
Server-Sent Events (SSE)간단; 대부분의 프록시 통과; 업그레이드 불필요단방향 (서버에서 클라이언트만); 바이너리 지원 없음
Long Polling어디서든 동작; 특별한 인프라 불필요높은 지연; 서버 리소스 낭비; 복잡한 클라이언트 로직
AWS API Gateway WebSockets서버리스; 관리형 스케일링벤더 종속; 다른 프로그래밍 모델; Socket.io 호환 불가

Socket.io와 Redis Adapter를 선택한 이유는 애플리케이션에 양방향 통신 (클라이언트가 액션을 보내고, 서버가 알림을 푸시), 자동 재연결 처리, 컨테이너 간 메시지 브로드캐스트가 필요했기 때문이에요. Raw WebSocket은 룸 관리, 하트비트, 재연결 로직을 다시 구현해야 해요. SSE는 단방향이에요. Redis Adapter는 직렬화, 네임스페이스, 룸 범위 브로드캐스팅을 기본 제공해요.

스케일링 고려사항

단일 컨테이너의 경우 설정이 간단해요:

컴포넌트필요?이유
ALBYes퍼블릭 트래픽을 프라이빗 컨테이너로 라우팅
Redis AdapterYes멀티 컨테이너를 위한 미래 대비
Sticky SessionsNo단일 컨테이너 = 모든 연결이 같은 곳으로
여러 컨테이너No스케일이 필요할 때까지 불필요

여러 컨테이너로 확장할 때, sticky session은 재연결이 같은 컨테이너로 가도록 보장하고 (더 빠른 재연결, Redis 오버헤드 감소), Socket.io의 HTTP 폴링 폴백에 필수예요.

실전 가이드

브라우저 클라이언트에 실시간 알림이 필요하거나, 로드 밸런서 뒤에서 컨테이너화된 배포를 하거나, 백그라운드 작업 완료 알림이 사용자에게 즉시 도달해야 할 때 이 아키텍처를 사용하세요.

단순한 요청-응답 API(REST나 GraphQL이 더 간단), server-sent events로 충분한 경우(단방향, 더 간단한 설정), 낮은 빈도의 업데이트(long-polling이나 주기적 fetch가 더 저렴), 서버리스/Lambda 환경(Socket.io 대신 API Gateway WebSocket API 사용)에는 WebSocket을 사용하지 마세요.

그림으로 보면 복잡한데, 각 컴포넌트가 맡은 일이 정리되고 나면 머릿속 모델은 오히려 단순해요. 정리하면 네 가지예요.

ALB는 처음 업그레이드 핸드셰이크가 끝나면 TCP 바이트를 흘려보내는 통로일 뿐이에요. ping/pong, 타임아웃, 룸, 정리까지 나머지 수명 주기는 Socket.io가 다 맡아요. 하트비트가 25초마다 나가서 60초 유휴 타임아웃에 걸릴 일이 없으니, ALB 설정을 따로 손댈 이유도 없어요.

멀티 컨테이너 브로드캐스팅은 Redis Pub/Sub가 풀어줘요. 컨테이너마다 영구 TCP 연결로 한 번 구독해두면, Container 2가 발행할 때 Redis가 Container 1의 열린 소켓에 메시지를 그대로 써 넣어요. 중간에 HTTP 콜백도 폴링도 없고요. 직렬화와 룸 단위 전달은 Socket.io Adapter가 알아서 처리해요.

컨테이너가 한 대뿐이어도 Redis Adapter는 미리 넣어두는 게 좋아요. 부담이 거의 없고, 나중에 확장할 때 애플리케이션을 고치는 대신 Terraform만 바꾸면 되거든요. Sticky session은 HTTP 폴링 폴백이 실제로 필요해질 때 붙여도 늦지 않아요.

연결이 끊기는 경우는 세 가지고, 감지 방식이 각각 달라요. 탭을 정상적으로 닫으면 TCP FIN이 날아와서 바로 잡히고, 네트워크가 끊기면 아무것도 안 오니까 25초쯤 뒤 ping/pong 타임아웃으로 걸려요. 60초 ALB 유휴 타임아웃은 하트비트 덕분에 거의 발동하지 않고요. 셋 다 Socket.io가 알아서 처리하지만, 지금 어느 경우인지 알고 있으면 운영 중 디버깅이 훨씬 빨라져요.

컨테이너 환경에 실시간 기능을 붙일 생각이라면 ALB 뒤에 Socket.io와 Redis Adapter를 두는 조합이 무난해요. 연결 관리도 재연결도 컨테이너 간 메시징도 직접 짤 필요가 없거든요.

댓글

글 목록으로 돌아가기
enko