이 글은 클로드 agent로 학습하며 시험삼아 작성한 글 입니다.
"소켓 서버 인스턴스를 2대로 늘렸는데, 채팅 메시지가 상대방에게 안 갑니다."
이 문제를 한 번이라도 겪어보셨다면, 소켓 서버가 HTTP 서버와 근본적으로 다르다는 것을 체감하셨을 겁니다. HTTP는 요청-응답이 끝나면 연결이 종료되기 때문에 어떤 서버가 처리하든 상관없지만, 소켓은 연결이 살아 있는 동안 서버 메모리에 상태가 유지됩니다.
이 글에서는 TCP부터 시작해서, 패킷이 클라이언트에서 서버까지 어떤 경로로 도착하는지, 로드밸런서가 어떻게 동작하는지, 그리고 다중 인스턴스 환경에서 소켓 서버를 안정적으로 운영하는 방법까지 전체 흐름을 정리합니다.
Socket = IP + Port + Protocol의 조합으로 만들어진 통신 끝점(endpoint)입니다.
소켓은 "네트워크 통신을 위한 문" 이라고 생각하면 됩니다.
프로세스(Node.js 서버)
│
└── Socket (10.0.1.15:3000, TCP)
│
│ 이 문을 통해 외부와 데이터를 주고받음
▼
네트워크
프로그램이 네트워크 통신을 하려면 반드시 소켓을 열어야 합니다. app.listen(3000)을 호출하면 내부적으로 소켓이 생성됩니다.
┌──────────────────────────────────────────────────────────────┐
│ TCP 연결 하나의 구성요소 │
├──────────────┬───────────────────────────────────────────────┤
│ │ │
│ 클라이언트 측 │ ┌─ IP: 52.1.2.3 (내 컴퓨터 주소) │
│ Socket │ ├─ Port: 49152 (OS가 자동 할당한 임시 포트) │
│ │ └─ Protocol: TCP │
│ │ │
├──────────────┼───────────────────────────────────────────────┤
│ │ │
│ 서버 측 │ ┌─ IP: 10.0.1.15 (서버 주소) │
│ Socket │ ├─ Port: 3000 (서버가 listen하는 포트) │
│ │ └─ Protocol: TCP │
│ │ │
├──────────────┼───────────────────────────────────────────────┤
│ │ │
│ 연결 식별 │ 4-Tuple = (52.1.2.3, 49152, 10.0.1.15, 3000) │
│ (4-Tuple) │ → OS가 이 조합으로 각 연결을 고유하게 구분 │
│ │ │
├──────────────┼───────────────────────────────────────────────┤
│ │ │
│ OS 내부 │ File Descriptor (fd) = 정수값 (예: fd=7) │
│ │ OS가 이 소켓을 관리하기 위해 부여하는 번호 │
│ │ │
└──────────────┴───────────────────────────────────────────────┘
인터넷에서 각 컴퓨터를 식별하는 주소
52.1.2.3 → 클라이언트 (브라우저가 실행되는 컴퓨터)
10.0.1.15 → 서버 (소켓 서버가 실행되는 EC2 인스턴스)
52.78.10.20 → ALB (로드밸런서)
하나의 컴퓨터에서 여러 프로그램이 동시에 네트워크를 사용하므로,
포트 번호로 "어떤 프로그램에 전달할지" 구분합니다.
서버 (10.0.1.15)
├── Port 3000: Socket.IO 서버 ← 여기로!
├── Port 22: SSH 데몬
└── Port 443: Nginx
클라이언트 (52.1.2.3)
├── Port 49152: 브라우저 탭 1의 소켓 연결 ← OS가 자동 할당
├── Port 49153: 브라우저 탭 2의 소켓 연결
└── Port 49154: 다른 앱의 연결
서버 포트가 하나(3000)라도, 클라이언트마다 Source IP와 Source Port가 다르기 때문에 4-Tuple이 달라져서 수천 개의 동시 연결이 가능합니다.
리눅스에서는 "모든 것이 파일"입니다. 소켓도 파일처럼 취급됩니다.
프로세스 (Node.js 서버, PID: 1234)
├── fd 0: stdin
├── fd 1: stdout
├── fd 2: stderr
├── fd 3: server socket (listen용, 0.0.0.0:3000)
├── fd 4: 클라이언트 A와의 연결 소켓
├── fd 5: 클라이언트 B와의 연결 소켓
├── fd 6: 클라이언트 C와의 연결 소켓
└── ...
동시 접속자가 1000명이면 fd가 1000개+ 필요합니다.
→ ulimit -n 으로 프로세스당 최대 fd 수를 확인/설정 가능
서버에는 두 종류의 소켓이 존재합니다.
1) Listen Socket (1개) - 새 연결을 기다리는 소켓
┌────────────────────────┐
│ fd 3: 0.0.0.0:3000 │ ← app.listen(3000)으로 생성
│ 상태: LISTEN │ ← "연결 요청 대기 중"
└────────────────────────┘
│
│ 클라이언트가 연결하면
│ accept() 호출
▼
2) Connection Socket (연결마다 1개씩) - 실제 통신하는 소켓
┌────────────────────────────────────────────────┐
│ fd 4: 52.1.2.3:49152 ↔ 10.0.1.15:3000 │ ← 클라이언트 A
│ 상태: ESTABLISHED │
├────────────────────────────────────────────────┤
│ fd 5: 52.1.2.3:49153 ↔ 10.0.1.15:3000 │ ← 클라이언트 B
│ 상태: ESTABLISHED │
└────────────────────────────────────────────────┘
즉
app.listen(3000)을 하면 Listen Socket이 생기고, 클라이언트가 접속할 때마다 Connection Socket이 하나씩 추가됩니다. 소켓 서버에 유저 1000명이 접속하면 1(Listen) + 1000(Connection) = 1001개의 소켓(fd)이 열려 있는 것입니다.
소켓이든 HTTP든 WebSocket이든, 결국 TCP 위에서 동작합니다.
WebSocket / HTTP (애플리케이션 프로토콜)
│
TCP (전송 프로토콜 - 신뢰성 있는 바이트 스트림)
│
IP (네트워크 프로토콜 - 패킷 라우팅)
TCP의 핵심 특성을 이해해야 왜 소켓이 Stateful한지 알 수 있습니다.
TCP 연결은 데이터를 보내기 전에 양측이 "준비됐는지" 확인하는 과정을 거칩니다.
Client Server
│ │
│ ──── SYN (seq=100) ────────────> │ Client: SYN_SENT
│ │ Server: SYN_RECEIVED
│ <─── SYN-ACK (seq=300, ack=101) │
│ │
│ ──── ACK (ack=301) ────────────> │ 양쪽: ESTABLISHED
│ │
│ ✅ 데이터 전송 시작 │
TCP는 연결 유지를 위해 양쪽 모두 아래 상태를 메모리에 보관합니다.
┌─────────────────────────────────────────┐
│ TCB (Transmission Control Block) │
├─────────────────────────────────────────┤
│ 연결 상태: ESTABLISHED │
│ 시퀀스 번호: 다음에 보낼 바이트 위치 │
│ ACK 번호: 상대방이 다음에 보낼 바이트 위치 │
│ 수신 윈도우: 받을 수 있는 버퍼 크기 │
│ 송신 버퍼: 아직 ACK 안 된 데이터 │
│ 수신 버퍼: 아직 애플리케이션이 안 읽은 데이터│
│ 타이머: 재전송, keep-alive 등 │
└─────────────────────────────────────────┘
이 상태 정보가 없으면 "어디까지 보냈는지", "상대방이 어디까지 받았는지"를 알 수 없기 때문에, TCP는 본질적으로 Stateful입니다.
Client Server
│ │
│ ── GET /api/users ──────────> │ 요청
│ <── 200 OK [{...}] ───────── │ 응답
│ │
│ (연결 종료 또는 재사용) │
│ │
│ ── GET /api/posts ──────────> │ 새로운 요청 (이전 요청과 무관)
│ <── 200 OK [{...}] ───────── │ 응답
│ │
Client Server
│ │
│ ── GET /chat (Upgrade: websocket) ─> │ HTTP Upgrade 요청
│ <── 101 Switching Protocols ──────── │ 프로토콜 전환
│ │
│ ═══════════ WebSocket 연결 수립 ══════════════
│ │
│ ←→ 양방향 실시간 데이터 교환 │
│ ── "안녕하세요" ─────────────────────> │
│ <── "반갑습니다" ──────────────────── │
│ <── "새 메시지가 도착했습니다" ──────── │ 서버가 먼저 push 가능
│ │
│ ... 연결 유지 (수 시간~수 일) ... │
│ │
│ ── Close Frame ──────────────────── > │ 명시적 종료
WebSocket Upgrade 과정을 좀 더 자세히 보면:
# 1. 클라이언트 → 서버: HTTP Upgrade 요청
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket ← "WebSocket으로 전환해주세요"
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 랜덤 키
Sec-WebSocket-Version: 13
# 2. 서버 → 클라이언트: 101 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← Key + GUID를 SHA-1 해시
101 응답 이후, 이 TCP 연결은 더 이상 HTTP가 아닌 WebSocket 프레임으로 통신합니다. 기존 TCP 연결을 그대로 재사용하되, 프로토콜만 바뀌는 것입니다.
| 특성 | HTTP | WebSocket |
|---|---|---|
| 연결 모델 | 요청-응답 후 종료 | 지속 연결 (Persistent) |
| 상태 | Stateless | Stateful |
| 통신 방향 | 단방향 (Client → Server → Client) | 양방향 Full-Duplex |
| 오버헤드 | 매 요청마다 헤더 (수백 바이트~수 KB) | 프레임 헤더 2~14바이트 |
| 스케일 아웃 | 쉬움 (아무 서버나 가능) | 어려움 (같은 서버 유지 필요) |
| 적합한 사례 | REST API, 웹 페이지 | 실시간 채팅, 게임, 협업 도구 |
기초 개념을 이해했으니, 이제 EB 환경에서 클라이언트의 요청이 인스턴스까지 도착하는 전체 경로를 살펴봅니다.
Step 1~2: 브라우저가 DNS에 IP를 물어본다
브라우저가 wss://api.example.com/socket에 접속하려면, 먼저 api.example.com이 어떤 IP인지 알아야 합니다.
브라우저 → DNS(Route 53): "api.example.com의 IP가 뭐야?"
DNS → 브라우저: "52.78.10.20이야" ← 이것은 ALB의 공인 IP
여기서 반환되는 IP는 인스턴스 IP가 아니라 ALB의 IP입니다. 클라이언트는 인스턴스의 존재를 전혀 모릅니다.
Step 3: 브라우저가 ALB와 TCP 연결 수립 (3-Way Handshake)
브라우저(52.1.2.3:49152) ──── SYN ────────> ALB(52.78.10.20:443)
브라우저(52.1.2.3:49152) <─── SYN-ACK ───── ALB(52.78.10.20:443)
브라우저(52.1.2.3:49152) ──── ACK ────────> ALB(52.78.10.20:443)
✅ TCP 연결 A 수립 (브라우저 ↔ ALB)
이 시점에서 브라우저와 ALB 사이에만 TCP 연결이 있습니다. 인스턴스는 아직 아무것도 모릅니다.
Step 4: ALB가 Target Group에서 인스턴스를 선택
ALB 내부 로직:
1. Target Group을 확인한다
├── Instance 1 (10.0.1.15:3000) ✅ healthy
└── Instance 2 (10.0.2.20:3000) ✅ healthy
2. 이 클라이언트에게 AWSALB 쿠키가 있는가?
├── 쿠키 있음 → 쿠키에 적힌 인스턴스로 보냄 (Sticky Session)
└── 쿠키 없음 → Round Robin으로 선택 (예: Instance 1 당첨)
여기서 매핑이 결정됩니다. ALB가 "이 클라이언트는 Instance 1로 보내자"고 결정하는 순간입니다.
Step 5: ALB가 선택된 인스턴스와 새로운 TCP 연결 수립
ALB(10.0.0.50:38210) ──── SYN ────────> Instance 1(10.0.1.15:3000)
ALB(10.0.0.50:38210) <─── SYN-ACK ───── Instance 1(10.0.1.15:3000)
ALB(10.0.0.50:38210) ──── ACK ────────> Instance 1(10.0.1.15:3000)
✅ TCP 연결 B 수립 (ALB ↔ Instance 1)
ALB는 자기가 가진 사설 서브넷의 ENI IP(10.0.0.50)로 인스턴스에 접속합니다. Instance 1 입장에서는 "10.0.0.50에서 연결이 왔네"라고만 보입니다. 클라이언트(52.1.2.3)의 IP는 보이지 않습니다.
Step 6: 두 개의 독립적인 TCP 연결이 ALB를 중심으로 존재
브라우저(52.1.2.3) ALB Instance 1(10.0.1.15)
│ │ │
│◄══ TCP 연결 A ══════►│◄══ TCP 연결 B ════════════►│
│ │ │
│ 브라우저는 │ conntrack 테이블: │ 인스턴스는
│ ALB IP만 안다 │ "연결A ↔ 연결B" 매핑 유지 │ ALB IP만 안다
ALB 커널의 conntrack(Connection Tracking) 테이블이 "TCP 연결 A와 TCP 연결 B는 쌍이다"라는 매핑을 메모리에 유지합니다. 브라우저가 데이터를 보내면 ALB가 연결 A에서 받아서 연결 B로 전달합니다.
ALB는 리버스 프록시이므로, "Client IP ↔ Instance IP"를 직접 매핑하는 단일 테이블은 존재하지 않습니다. 클라이언트와 인스턴스는 서로의 IP를 모르고, ALB가 두 TCP 연결을 이어주는 구조입니다.
(X-Forwarded-For헤더로 원본 클라이언트 IP를 전달받을 수는 있지만, TCP 레벨에서는 ALB의 사설 IP만 보입니다.)
Step 7: HTTP → WebSocket 업그레이드 (같은 TCP 연결 위에서)
브라우저 ── GET /socket (Upgrade: websocket) ──> ALB ──> Instance 1
브라우저 <── 101 Switching Protocols ──────────── ALB <── Instance 1
이 과정은 Step 6에서 만든 TCP 연결 A, B 위에서 일어납니다. 새로운 TCP 연결이 생기는 게 아닙니다. 같은 TCP 연결 위에서 프로토콜만 HTTP → WebSocket으로 바뀝니다.
Step 8: WebSocket 양방향 통신
브라우저 ══ "안녕하세요" ══> ALB ══> Instance 1 (conntrack: 연결A→연결B)
브라우저 <══ "반갑습니다" ══ ALB <══ Instance 1 (conntrack: 연결B→연결A)
TCP 연결이 살아 있는 한, conntrack이 자동으로 같은 인스턴스로 전달합니다. Sticky Session 쿠키와 무관합니다. TCP 연결 자체가 경로입니다.
Step 9: 연결이 끊어지고 재연결할 때
브라우저 ══✕══ ALB ══✕══ Instance 1
│
└─ conntrack 테이블에서 연결A↔연결B 매핑 삭제
브라우저: "다시 연결해야지"
브라우저 ── GET /socket.io/?transport=polling ──> ALB
+ Cookie: AWSALB=암호화(Instance 1 정보)
│
ALB: "쿠키를 보니 Instance 1이네"
▼
Instance 1 ✅
TCP 연결이 끊어지면 conntrack 매핑이 사라집니다. 이때 AWSALB 쿠키가 있으면 ALB가 같은 인스턴스로 보내고, 쿠키가 없으면 Round Robin으로 아무 인스턴스에 보냅니다.
전체 요약:
Step 1-2: DNS → ALB의 공인 IP를 알아냄 Step 3: 브라우저 → ALB와 TCP 연결 A 수립 Step 4: ALB → Target Group에서 인스턴스 선택 (쿠키 or Round Robin) Step 5: ALB → 선택된 인스턴스와 TCP 연결 B 수립 Step 6: ALB → conntrack 테이블에 "연결A ↔ 연결B" 매핑 저장 Step 7: HTTP → WebSocket 업그레이드 (같은 TCP 연결 위에서) Step 8: 통신 중 → conntrack이 자동으로 같은 인스턴스 유지 Step 9: 끊김 → conntrack 삭제 → 쿠키로 같은 인스턴스 찾아감
많이 혼동하는 부분입니다. 인바운드 트래픽은 NAT Gateway를 거치지 않습니다.
NAT Gateway는 아웃바운드 트래픽 전용입니다.
인터넷
▲
│ 3) NAT의 공인 IP(54.xx.xx.xx)로
│ 외부에 요청 전달
┌──────┴──────┐
│ NAT Gateway │ ← 공인 서브넷에 위치
│ 공인 IP 보유 │
└──────▲──────┘
│ 2) 라우팅 테이블:
│ 0.0.0.0/0 → NAT Gateway
┌──────┴──────┐
│ EC2 Instance │ ← 사설 서브넷
│ 10.0.1.15 │
└─────────────┘
│
1) npm install,
외부 API 호출,
apt-get update 등
사설 서브넷의 인스턴스가 인터넷에 접근해야 할 때(패키지 설치, 외부 API 호출 등) NAT Gateway를 통해 나갑니다.
NAT Gateway는 내부적으로 NAT Translation Table을 유지합니다.
┌─────────────────────────────────────────────────────────────────┐
│ NAT Translation Table │
├──────────────────────┬──────────────────────────────────────────┤
│ 원본 (사설) │ 변환 후 (공인) │
├──────────────────────┼──────────────────────────────────────────┤
│ 10.0.1.15:49301 │ 54.180.xx.xx:1024 │
│ 10.0.1.15:49302 │ 54.180.xx.xx:1025 │
│ 10.0.2.20:50100 │ 54.180.xx.xx:1026 │
└──────────────────────┴──────────────────────────────────────────┘
정리:
DNS → NAT Gateway → Instance가 아닙니다. 올바른 흐름은 아래와 같습니다.
- 인바운드(클라이언트 → 서버):
DNS → ALB(공인 IP) → Instance(사설 IP)- 아웃바운드(서버 → 외부):
Instance → NAT Gateway → 인터넷
┌──────────────┬───────────────────────────┬──────────────────────────┐
│ 인프라 │ 들고 있는 매핑 │ 저장 위치 │
├──────────────┼───────────────────────────┼──────────────────────────┤
│ DNS │ 도메인 → ALB IP │ Route 53 (DNS 레코드) │
├──────────────┼───────────────────────────┼──────────────────────────┤
│ Target Group │ Instance IP:Port 후보 목록 │ AWS 내부 │
│ │ + Health Check 상태 │ (콘솔/API로 조회 가능) │
├──────────────┼───────────────────────────┼──────────────────────────┤
│ ALB 노드 │ TCP 연결 A ↔ TCP 연결 B │ ALB 노드의 커널 메모리 │
│ (conntrack) │ (클라이언트↔백엔드 연결 쌍) │ (AWS 관리, 조회 불가) │
├──────────────┼───────────────────────────┼──────────────────────────┤
│ AWSALB 쿠키 │ Client ↔ 특정 Instance │ 클라이언트 브라우저 │
│ (Sticky) │ 고정 매핑 (암호화) │ (쿠키로 저장) │
├──────────────┼───────────────────────────┼──────────────────────────┤
│ NAT Gateway │ 사설 IP:Port ↔ 공인 IP:Port│ NAT Gateway 내부 메모리 │
└──────────────┴───────────────────────────┴──────────────────────────┘
ALB
└── Listener (포트 80/443에서 요청 수신)
└── Rule (경로별 라우팅 규칙, 예: /api/* → Target Group A)
└── Target Group ← 여기에 인스턴스 IP 목록이 있음
├── Instance 1 (10.0.1.15:3000) ✅ healthy
├── Instance 2 (10.0.2.20:3000) ✅ healthy
└── Instance 3 (10.0.3.30:3000) ❌ unhealthy → 트래픽 제외
EB 환경에서는 직접 등록하지 않고 Auto Scaling Group이 자동으로 관리합니다.
Auto Scaling Group (EB가 관리)
│
│ 인스턴스 생성/종료 시 자동으로
│ Target Group에 등록/해제
▼
Target Group
├── 새 인스턴스 launch → 자동 등록 → Health Check 통과 후 트래픽 수신
└── 인스턴스 terminate → 자동 해제 → 기존 연결 draining 후 제거
소켓 서버에서 이것이 중요한 이유는, 인스턴스가 제거될 때 해당 인스턴스의 WebSocket 연결이 모두 끊어지기 때문입니다. 이때 클라이언트는 재연결을 시도하게 되고, ALB는 남아 있는 healthy 인스턴스로 라우팅합니다.
┌────────────────┐
│ 클라이언트 A │
└───────┬────────┘
│
┌───────▼────────┐
│ ALB │
└──┬──────────┬──┘
│ │
┌────────▼───┐ ┌───▼────────┐
│ Instance 1 │ │ Instance 2 │
│ │ │ │
│ 유저A 연결 │ │ 유저B 연결 │
│ 유저C 연결 │ │ 유저D 연결 │
└────────────┘ └────────────┘
유저A가 Instance 1에 "안녕하세요"를 보냈습니다. 유저B는 Instance 2에 연결되어 있습니다.
Instance 1은 유저B의 존재를 모릅니다. 각 인스턴스는 자신에게 연결된 클라이언트만 알고 있기 때문입니다.
→ io.emit("message", "안녕하세요")를 호출해도 Instance 1의 클라이언트(유저A, 유저C)에게만 전달됩니다.
| 상황 | 같은 인스턴스 보장 | 메커니즘 |
|---|---|---|
| WebSocket 연결 중 | Yes | TCP 연결이 유지됨 (ALB conntrack) |
| 재연결 + Sticky Session ON | 거의 Yes | AWSALB 쿠키 (인스턴스가 healthy한 경우) |
| 재연결 + Sticky Session OFF | No | Round Robin → 아무 인스턴스 |
| 인스턴스가 종료된 경우 | No | 쿠키가 있어도 unhealthy면 다른 인스턴스로 |
WebSocket 자체는 sticky가 불필요합니다. 문제는 WebSocket이 아닌 HTTP 요청에서 발생합니다.
Socket.IO는 먼저 HTTP Long Polling으로 handshake를 한 뒤 WebSocket으로 업그레이드하는데, 이 polling 단계에서 여러 번의 독립적인 HTTP 요청이 발생합니다.
# Socket.IO의 연결 과정
1️⃣ HTTP Long Polling으로 시작 (여러 번의 HTTP 요청)
GET /socket.io/?transport=polling → Instance 1 (sid 발급: "abc123")
GET /socket.io/?transport=polling&sid=abc123 → Instance 2 ❌ sid를 모름!
2️⃣ Sticky Session이 있으면
GET /socket.io/?transport=polling → Instance 1 (sid 발급: "abc123")
GET /socket.io/?transport=polling&sid=abc123 → Instance 1 ✅ sid 알고 있음!
3️⃣ WebSocket Upgrade
GET /socket.io/?transport=websocket&sid=abc123 (Upgrade 헤더 포함)
→ Instance 1에서 101 응답
→ 이후 WebSocket으로 통신 (자동 sticky)
첫 요청에서 Instance 1이 sid를 발급하는데, 두 번째 요청이 Instance 2로 가면 sid를 모르므로 HTTP 400 에러가 발생합니다.
Tip: WebSocket만 사용한다면 Sticky Session 없이도 됩니다.
const socket = io('https://api.example.com', { transports: ['websocket'], // polling 비활성화 });다만, WebSocket을 지원하지 않는 환경(일부 기업 프록시)에서는 연결 자체가 실패합니다.
ALB의 Cookie 기반 Sticky Session:
첫 번째 요청:
Client ── GET /socket.io/ ──────────────────────> ALB
│ Instance 1 선택
ALB ── GET /socket.io/ ──────────────────────────> Instance 1
ALB <── 200 OK ──────────────────────────────────── Instance 1
Client <── 200 OK + Set-Cookie: AWSALB=암호화된값 ── ALB
두 번째 요청:
Client ── GET /socket.io/ + Cookie: AWSALB=암호화된값 ──> ALB
│ 쿠키 복호화
│ → Instance 1로 라우팅
ALB ── GET /socket.io/ ────────────────────────────────> Instance 1 ✅
AWSALB 쿠키에 타겟 인스턴스 정보가 암호화되어 있습니다. ┌─────────────┐
│ 클라이언트 │
└──────┬──────┘
│
┌──────▼──────┐
│ ALB (L7) │
│ Sticky: ON │
└──┬───────┬──┘
│ │
┌─────────▼──┐ ┌──▼─────────┐
│ Instance 1 │ │ Instance 2 │
│ Socket.IO │ │ Socket.IO │
│ Server │ │ Server │
└─────┬──────┘ └──────┬──────┘
│ │
└───────┬───────┘
┌──────▼──────┐
│ ElastiCache │
│ (Redis) │
└─────────────┘
필수 구성 요소 3가지:
.ebextensions/01-sticky-session.config:
option_settings:
aws:elasticbeanstalk:environment:process:default:
StickinessEnabled: true
StickinessLBCookieDuration: 86400 # 24시간 (초 단위)
// server.js
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
async function createSocketServer(httpServer) {
const io = new Server(httpServer, {
cors: { origin: '*' },
// ALB idle timeout(기본 60초)보다 짧게 설정
pingInterval: 25000,
pingTimeout: 20000,
});
// Redis 연결
const pubClient = createClient({
url: process.env.REDIS_URL, // redis://your-elasticache-endpoint:6379
});
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
// Redis Adapter 적용
io.adapter(createAdapter(pubClient, subClient));
return io;
}
Redis Adapter 없이:
──────────────────
유저A (Instance 1) ── "안녕" ──> Instance 1
│
└── io.to("room1").emit("message", "안녕")
│
├── 유저C (Instance 1) ✅ 수신
└── 유저B (Instance 2) ❌ 못 받음
Redis Adapter 사용:
──────────────────
유저A (Instance 1) ── "안녕" ──> Instance 1
│
├── 유저C (Instance 1) ✅ 직접 전달
│
└── Redis에 publish ──> Instance 2가 subscribe
│
└── 유저B ✅ 수신
내부 동작:
io.to("room1").emit("message", data) 호출참고: Redis Adapter는 Pub/Sub만 사용합니다. Redis에 키(key)를 저장하지 않으므로 Redis에 영구적인 상태가 남지 않습니다. Room 정보, 소켓 매핑 등은 각 서버의 메모리에 유지됩니다.
WebSocket 연결이 끊어지면 (네트워크 불안정, 배포 등), Socket.IO는 자동으로 재연결을 시도합니다.
재연결 타이밍 (지수 백오프):
1차 시도: ~1초 후
2차 시도: ~2초 후
3차 시도: ~4초 후
4차 이후: ~5초 후 (최대값)
※ 각 시도에 ±50% 랜덤 변동 추가
문제: 재연결 시 새로운 sid가 발급되고, 다른 인스턴스로 라우팅될 수 있습니다. 이전 서버에 저장된 room 참여 정보는 사라집니다.
해결: 클라이언트 측 재연결 처리
// client.js
const socket = io('https://api.example.com', {
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socket.on('connect', () => {
// 재연결 시 이전 상태 복원 요청
if (socket.recovered) {
// Socket.IO v4.6+: 서버가 이전 상태를 기억하고 있음
console.log('자동 복구됨');
} else {
// 새로운 연결 또는 다른 인스턴스에 연결됨
// 서버에 상태 복원 요청
socket.emit('rejoin', {
userId: myUserId,
rooms: ['room1', 'room2'],
});
}
});
// server.js
io.on('connection', (socket) => {
socket.on('rejoin', async ({ userId, rooms }) => {
// 인증 확인
const user = await verifyUser(userId);
if (!user) return;
// room 재참여
for (const room of rooms) {
socket.join(room);
}
// 놓친 메시지가 있다면 전송 (Redis 또는 DB에서 조회)
const missedMessages = await getMissedMessages(userId);
if (missedMessages.length > 0) {
socket.emit('missed_messages', missedMessages);
}
});
});
pingInterval: 25초
Client ◄════════════════════════════► ALB ◄════════════════► Instance
│
idle timeout: 60초 (기본값)
최대: 4000초
pingInterval(기본 25초)을 ALB idle timeout보다 짧게 유지해야 합니다.# .ebextensions/02-alb-timeout.config
option_settings:
aws:elbv2:loadbalancer:
IdleTimeout: 3600 # 1시간
| 기준 | ALB (L7) | NLB (L4) |
|---|---|---|
| WebSocket 인식 | O (Upgrade 헤더 인식) | X (TCP 패스스루) |
| Sticky Session | Cookie 기반 | Source IP 기반 |
| SSL 종료 | ALB에서 가능 | NLB에서 가능 |
| 지연시간 | 보통 | 더 낮음 |
| Socket.IO 호환 | O (Polling + WS 모두 지원) | 제한적 (WS only 모드 필요) |
| 권장 | Socket.IO 사용 시 | 순수 WebSocket만 사용 시 |
auth 옵션 사용)cors 설정을 *가 아닌 허용할 도메인만 지정1. 클라이언트가 api.example.com 접속
└─ DNS가 ALB의 공인 IP 반환 (NAT Gateway 아님!)
2. ALB가 Target Group에서 인스턴스 선택
└─ Sticky Session 쿠키가 있으면 해당 인스턴스로 라우팅
3. HTTP Polling으로 Socket.IO handshake
└─ sid 발급, Sticky Session 덕분에 같은 인스턴스 유지
4. WebSocket Upgrade
└─ 이후 TCP 연결이 유지되므로 자동으로 같은 인스턴스에 고정
5. 메시지 전송
└─ 같은 인스턴스의 클라이언트: 직접 전달
└─ 다른 인스턴스의 클라이언트: Redis Pub/Sub로 전달
6. 연결 끊김 → 재연결
└─ 다른 인스턴스로 갈 수 있음 → 클라이언트에서 rejoin 로직 필요
| 질문 | 답변 |
|---|---|
| 소켓은 Stateful한가? | Yes. TCP 연결 상태 + 서버 메모리의 세션 정보가 유지됩니다. |
| 로드밸런서 뒤에서 같은 인스턴스를 찾아가는가? | WebSocket 연결 중에는 Yes (TCP 연결이 유지). 재연결 시에는 No (Sticky Session 필요). |
| NAT Gateway를 거치는가? | 인바운드는 No (ALB → Instance 직접). 아웃바운드만 NAT Gateway 사용. |
| 인스턴스 2대면 충분한가? | Sticky Session + Redis Adapter + 재연결 처리가 모두 갖춰져야 안정적으로 운영 가능합니다. |