socket 연결

seongha_h·2026년 2월 19일

이 글은 클로드 agent로 학습하며 시험삼아 작성한 글 입니다.

소켓 서버 2대, 로드밸런서 뒤에서 어떻게 같은 인스턴스를 찾아갈까?

TL;DR

  • TCP/WebSocket 연결은 본질적으로 Stateful합니다. 서버 메모리에 연결 상태가 유지되므로 클라이언트는 반드시 같은 인스턴스에 연결되어야 합니다.
  • 로드밸런서는 Sticky Session(세션 어피니티)을 통해 동일 클라이언트를 같은 인스턴스로 라우팅하며, WebSocket 연결 자체는 하나의 TCP 연결이므로 업그레이드 이후 자동으로 고정됩니다.
  • 다중 인스턴스 환경에서는 Sticky Session만으로 부족하고, Redis Adapter 같은 크로스 인스턴스 통신 계층이 필수입니다.

배경

"소켓 서버 인스턴스를 2대로 늘렸는데, 채팅 메시지가 상대방에게 안 갑니다."

이 문제를 한 번이라도 겪어보셨다면, 소켓 서버가 HTTP 서버와 근본적으로 다르다는 것을 체감하셨을 겁니다. HTTP는 요청-응답이 끝나면 연결이 종료되기 때문에 어떤 서버가 처리하든 상관없지만, 소켓은 연결이 살아 있는 동안 서버 메모리에 상태가 유지됩니다.

이 글에서는 TCP부터 시작해서, 패킷이 클라이언트에서 서버까지 어떤 경로로 도착하는지, 로드밸런서가 어떻게 동작하는지, 그리고 다중 인스턴스 환경에서 소켓 서버를 안정적으로 운영하는 방법까지 전체 흐름을 정리합니다.


핵심 개념

1. TCP/Socket 연결의 구성요소

Socket이란?

Socket = IP + Port + Protocol의 조합으로 만들어진 통신 끝점(endpoint)입니다.

소켓은 "네트워크 통신을 위한 문" 이라고 생각하면 됩니다.

프로세스(Node.js 서버)
  │
  └── Socket (10.0.1.15:3000, TCP)
        │
        │  이 문을 통해 외부와 데이터를 주고받음
        ▼
      네트워크

프로그램이 네트워크 통신을 하려면 반드시 소켓을 열어야 합니다. app.listen(3000)을 호출하면 내부적으로 소켓이 생성됩니다.

TCP 연결 하나의 구성요소

┌──────────────────────────────────────────────────────────────┐
│                    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가 이 소켓을 관리하기 위해 부여하는 번호       │
│              │                                               │
└──────────────┴───────────────────────────────────────────────┘

IP 주소 - "어떤 컴퓨터인지"

인터넷에서 각 컴퓨터를 식별하는 주소

  52.1.2.3       → 클라이언트 (브라우저가 실행되는 컴퓨터)
  10.0.1.15      → 서버 (소켓 서버가 실행되는 EC2 인스턴스)
  52.78.10.20    → ALB (로드밸런서)

Port - "그 컴퓨터의 어떤 프로그램인지"

하나의 컴퓨터에서 여러 프로그램이 동시에 네트워크를 사용하므로,
포트 번호로 "어떤 프로그램에 전달할지" 구분합니다.

서버 (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: 다른 앱의 연결
  • 서버 포트 (Well-known): 서버가 명시적으로 지정 (예: 3000, 80, 443)
  • 클라이언트 포트 (Ephemeral): OS가 자동으로 할당 (49152~65535 범위)

서버 포트가 하나(3000)라도, 클라이언트마다 Source IP와 Source Port가 다르기 때문에 4-Tuple이 달라져서 수천 개의 동시 연결이 가능합니다.

File Descriptor (fd) - "OS가 소켓을 관리하는 번호표"

리눅스에서는 "모든 것이 파일"입니다. 소켓도 파일처럼 취급됩니다.

프로세스 (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 수를 확인/설정 가능

Listen Socket vs Connection Socket

서버에는 두 종류의 소켓이 존재합니다.

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                               │
   └────────────────────────────────────────────────┘
  • Listen Socket: 포트 3000에서 새 연결 요청을 기다립니다. 서버당 1개입니다.
  • Connection Socket: 각 클라이언트와의 실제 통신용입니다. 연결마다 1개씩 생성됩니다.

app.listen(3000)을 하면 Listen Socket이 생기고, 클라이언트가 접속할 때마다 Connection Socket이 하나씩 추가됩니다. 소켓 서버에 유저 1000명이 접속하면 1(Listen) + 1000(Connection) = 1001개의 소켓(fd)이 열려 있는 것입니다.


2. TCP - 모든 네트워크 통신의 기반

소켓이든 HTTP든 WebSocket이든, 결국 TCP 위에서 동작합니다.

WebSocket / HTTP  (애플리케이션 프로토콜)
       │
      TCP          (전송 프로토콜 - 신뢰성 있는 바이트 스트림)
       │
       IP          (네트워크 프로토콜 - 패킷 라우팅)

TCP의 핵심 특성을 이해해야 왜 소켓이 Stateful한지 알 수 있습니다.

TCP 3-Way Handshake

TCP 연결은 데이터를 보내기 전에 양측이 "준비됐는지" 확인하는 과정을 거칩니다.

Client                              Server
  │                                    │
  │  ──── SYN (seq=100) ────────────>  │   Client: SYN_SENT
  │                                    │   Server: SYN_RECEIVED
  │  <─── SYN-ACK (seq=300, ack=101)   
                                      
    ──── ACK (ack=301) ────────────>  │   양쪽: ESTABLISHED
  │                                    │
  │        ✅ 데이터 전송 시작           │
  1. SYN: 클라이언트가 "연결하고 싶어요" (초기 시퀀스 번호 전달)
  2. SYN-ACK: 서버가 "좋아요, 나도 준비됐어요" (서버의 시퀀스 번호 + 클라이언트 확인)
  3. ACK: 클라이언트가 "확인했어요, 시작합시다"

TCP가 Stateful인 이유

TCP는 연결 유지를 위해 양쪽 모두 아래 상태를 메모리에 보관합니다.

┌─────────────────────────────────────────┐
│     TCB (Transmission Control Block)     │
├─────────────────────────────────────────┤
│  연결 상태: ESTABLISHED                  │
│  시퀀스 번호: 다음에 보낼 바이트 위치      │
│  ACK 번호: 상대방이 다음에 보낼 바이트 위치 │
│  수신 윈도우: 받을 수 있는 버퍼 크기       │
│  송신 버퍼: 아직 ACK 안 된 데이터         │
│  수신 버퍼: 아직 애플리케이션이 안 읽은 데이터│
│  타이머: 재전송, keep-alive 등            │
└─────────────────────────────────────────┘

이 상태 정보가 없으면 "어디까지 보냈는지", "상대방이 어디까지 받았는지"를 알 수 없기 때문에, TCP는 본질적으로 Stateful입니다.


3. HTTP (Stateless) vs WebSocket (Stateful)

HTTP - 요청하면 끝

Client                         Server
  │                               │
  │ ── GET /api/users ──────────> │   요청
  │ <── 200 OK [{...}] ───────── │   응답
  │                               │
  │     (연결 종료 또는 재사용)      │
  │                               │
  │ ── GET /api/posts ──────────> │   새로운 요청 (이전 요청과 무관)
  │ <── 200 OK [{...}] ───────── │   응답
  │                               │
  • 각 요청은 독립적입니다. 서버는 이전 요청을 기억하지 않습니다.
  • 그래서 어떤 서버가 처리하든 결과가 같습니다 → 스케일 아웃이 쉽습니다.

WebSocket - 연결이 살아 있다

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 연결을 그대로 재사용하되, 프로토콜만 바뀌는 것입니다.

특성HTTPWebSocket
연결 모델요청-응답 후 종료지속 연결 (Persistent)
상태StatelessStateful
통신 방향단방향 (Client → Server → Client)양방향 Full-Duplex
오버헤드매 요청마다 헤더 (수백 바이트~수 KB)프레임 헤더 2~14바이트
스케일 아웃쉬움 (아무 서버나 가능)어려움 (같은 서버 유지 필요)
적합한 사례REST API, 웹 페이지실시간 채팅, 게임, 협업 도구

4. 클라이언트의 요청은 어떻게 서버에 도달하는가?

기초 개념을 이해했으니, 이제 EB 환경에서 클라이언트의 요청이 인스턴스까지 도착하는 전체 경로를 살펴봅니다.

단계별 흐름: DNS → ALB → Instance → WebSocket

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를 거치지 않습니다.

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                      │
└──────────────────────┴──────────────────────────────────────────┘
  • 인스턴스가 외부 API를 호출하면, NAT Gateway가 출발지 IP를 자신의 공인 IP로 변환(SNAT) 합니다.
  • 외부 서버의 응답이 돌아오면, Translation Table을 역참조하여 원래 인스턴스로 전달합니다.
  • 이 테이블은 NAT Gateway 내부 메모리에 유지되며, AWS가 관리하므로 사용자가 직접 볼 수는 없습니다.

정리: 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 내부 메모리  │
└──────────────┴───────────────────────────┴──────────────────────────┘
  • Target Group: "어떤 인스턴스들이 트래픽을 받을 수 있는가"의 후보 목록입니다.
  • conntrack: 현재 활성화된 TCP 연결의 실시간 매핑입니다. 연결이 끊어지면 사라집니다.
  • AWSALB 쿠키: Sticky Session 매핑 정보를 ALB 서버가 아닌 클라이언트가 들고 다닙니다.

Target Group과 Auto Scaling

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 인스턴스로 라우팅합니다.


5. 로드밸런서와 소켓 연결 - 핵심 문제

문제: 인스턴스가 2대일 때 무슨 일이 벌어지는가?

                    ┌────────────────┐
                    │   클라이언트 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 연결 중YesTCP 연결이 유지됨 (ALB conntrack)
재연결 + Sticky Session ON거의 YesAWSALB 쿠키 (인스턴스가 healthy한 경우)
재연결 + Sticky Session OFFNoRound Robin → 아무 인스턴스
인스턴스가 종료된 경우No쿠키가 있어도 unhealthy면 다른 인스턴스로

Socket.IO에 Sticky Session이 필요한 이유

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을 지원하지 않는 환경(일부 기업 프록시)에서는 연결 자체가 실패합니다.

Sticky Session 동작 원리

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가 쿠키를 읽고 같은 인스턴스로 라우팅합니다.
  • 해당 인스턴스가 unhealthy가 되면 ALB가 다른 인스턴스를 선택하고 새 쿠키를 발급합니다.

실전 적용

EB 환경에서 소켓 서버 2대 운영하기

아키텍처

                         ┌─────────────┐
                         │  클라이언트   │
                         └──────┬──────┘
                                │
                         ┌──────▼──────┐
                         │ ALB (L7)    │
                         │ Sticky: ON  │
                         └──┬───────┬──┘
                            │       │
                  ┌─────────▼──┐ ┌──▼─────────┐
                  │ Instance 1 │ │ Instance 2  │
                  │ Socket.IO  │ │ Socket.IO   │
                  │ Server     │ │ Server      │
                  └─────┬──────┘ └──────┬──────┘
                        │               │
                        └───────┬───────┘
                         ┌──────▼──────┐
                         │ ElastiCache │
                         │ (Redis)     │
                         └─────────────┘

필수 구성 요소 3가지:

  1. ALB + Sticky Session → HTTP Polling 단계에서 같은 인스턴스로 라우팅
  2. Redis Adapter → 인스턴스 간 이벤트 브로드캐스트
  3. 재연결 처리 로직 → 연결 끊김 시 상태 복원

1단계: EB Sticky Session 설정

.ebextensions/01-sticky-session.config:

option_settings:
  aws:elasticbeanstalk:environment:process:default:
    StickinessEnabled: true
    StickinessLBCookieDuration: 86400 # 24시간 (초 단위)

2단계: Redis Adapter 적용

// 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가 해결하는 문제

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 ✅ 수신

내부 동작:

  1. Instance 1에서 io.to("room1").emit("message", data) 호출
  2. Instance 1의 room1 클라이언트에 직접 전달
  3. 동시에 Redis 채널에 메시지를 publish
  4. Instance 2가 해당 채널을 subscribe하고 있다가 메시지 수신
  5. Instance 2의 room1 클라이언트에 전달

참고: Redis Adapter는 Pub/Sub만 사용합니다. Redis에 키(key)를 저장하지 않으므로 Redis에 영구적인 상태가 남지 않습니다. Room 정보, 소켓 매핑 등은 각 서버의 메모리에 유지됩니다.

3단계: 재연결 처리

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);
    }
  });
});

성능 / 보안 고려사항

ALB Idle Timeout 설정

                 pingInterval: 25초
Client ◄════════════════════════════► ALB ◄════════════════► Instance
                                       │
                                  idle timeout: 60초 (기본값)
                                  최대: 4000초
  • ALB의 idle timeout(기본 60초) 안에 트래픽이 없으면 ALB가 연결을 끊습니다.
  • Socket.IO의 pingInterval(기본 25초)을 ALB idle timeout보다 짧게 유지해야 합니다.
  • EB 설정으로 idle timeout을 늘릴 수 있습니다:
# .ebextensions/02-alb-timeout.config
option_settings:
  aws:elbv2:loadbalancer:
    IdleTimeout: 3600 # 1시간

ALB vs NLB 선택 기준

기준ALB (L7)NLB (L4)
WebSocket 인식O (Upgrade 헤더 인식)X (TCP 패스스루)
Sticky SessionCookie 기반Source IP 기반
SSL 종료ALB에서 가능NLB에서 가능
지연시간보통더 낮음
Socket.IO 호환O (Polling + WS 모두 지원)제한적 (WS only 모드 필요)
권장Socket.IO 사용 시순수 WebSocket만 사용 시

보안 체크리스트

  • WebSocket 연결 시에도 인증 토큰 검증 (handshake 단계에서 auth 옵션 사용)
  • ElastiCache Redis는 사설 서브넷에 배치, 공인 접근 차단
  • ALB에 WAF(Web Application Firewall) 적용
  • Socket.IO의 cors 설정을 *가 아닌 허용할 도메인만 지정
  • Rate limiting으로 단일 클라이언트의 과도한 연결/메시지 방지

정리

전체 흐름 요약

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 + 재연결 처리가 모두 갖춰져야 안정적으로 운영 가능합니다.

추가 학습 키워드

  • Socket.IO Connection State Recovery (v4.6+): 서버가 일시적으로 이전 세션 상태를 보관하여 자동 복구
  • @socket.io/redis-streams-adapter: Redis Streams 기반 어댑터, Pub/Sub 대비 메시지 유실 방지
  • AWS Global Accelerator + NLB: 글로벌 소켓 서버 구성 시 고려
  • TCP TIME_WAIT: 소켓 서버에서 연결이 많을 때 포트 고갈 문제
  • WebSocket Compression (permessage-deflate): 대용량 메시지 최적화

참고 자료

profile
https://github.com/Fixtar

0개의 댓글