콘텐츠로 이동

TCP/UDP Internals

TCP/UDP 심화: 동작 원리와 실무 적용

섹션 제목: “TCP/UDP 심화: 동작 원리와 실무 적용”

TCP는 “연결 지향 + 신뢰성 보장” 프로토콜이고, UDP는 “비연결 + 속도 우선” 프로토콜이다. 둘 다 IP 위에서 동작하는 전송 계층(Transport Layer) 프로토콜이며, 어떤 것을 선택하느냐에 따라 시스템의 성능과 안정성이 근본적으로 달라진다.


  • 모든 네트워크 통신의 기반: HTTP, gRPC, WebSocket, DNS, 스트리밍 등 실무에서 쓰는 모든 프로토콜은 TCP 또는 UDP 위에서 동작한다.
  • 장애 원인 분석 필수: Connection reset by peer, TIME_WAIT 폭증, 패킷 손실과 같은 장애를 분석하려면 TCP 동작 원리를 알아야 한다.
  • AWS 인프라 설계 직결: ALB(L7)와 NLB(L4)를 구분하고, 어떤 상황에서 어떤 로드밸런서를 써야 하는지 판단하려면 TCP/UDP 이해가 필수다.
  • BackOps 실무: Nest.js API 서버가 느릴 때, DB 연결이 끊길 때, 소켓 에러가 날 때 — 모두 TCP 내부 동작을 알아야 디버깅이 가능하다.

3-1. TCP/IP 4계층 모델과 OSI 7계층 매핑

섹션 제목: “3-1. TCP/IP 4계층 모델과 OSI 7계층 매핑”

우편 시스템에 비유하면:

  • Application Layer (응용 계층): 편지 내용을 쓰는 사람 (HTTP, DNS, FTP)
  • Transport Layer (전송 계층): 우체국 — 편지를 봉투에 넣고, 분실 시 재발송 여부를 결정 (TCP, UDP)
  • Internet Layer (인터넷 계층): 배송 경로를 결정하는 물류 센터 (IP)
  • Network Access Layer (네트워크 접근 계층): 실제 도로와 트럭 — 물리적 전송 (Ethernet, Wi-Fi)

원리: OSI 7계층 ↔ TCP/IP 4계층 매핑

섹션 제목: “원리: OSI 7계층 ↔ TCP/IP 4계층 매핑”
OSI 7계층 TCP/IP 4계층
───────────────── ─────────────────────
7. Application ┐
6. Presentation ├──→ Application Layer
5. Session ┘ (HTTP, HTTPS, FTP, DNS, SSH)
4. Transport ────→ Transport Layer
(TCP, UDP)
3. Network ────→ Internet Layer
(IP, ICMP, ARP)
2. Data Link ┐
1. Physical ┴──→ Network Access Layer
(Ethernet, Wi-Fi, MAC)

캡슐화 (송신 측): 데이터를 전송할 때 각 계층이 자신의 헤더를 붙여가며 감싸는 과정

Application Data → [HTTP Header][Data]
→ [TCP Header][HTTP Header][Data] (Segment)
→ [IP Header][TCP Header][HTTP Header][Data] (Packet)
→ [Ethernet Header][IP][TCP][HTTP][Data][FCS] (Frame)

디캡슐화 (수신 측): 수신 측에서 각 계층이 자신의 헤더를 벗겨내며 데이터를 위 계층으로 전달

Terminal window
# Wireshark나 tcpdump로 실제 캡슐화 확인
sudo tcpdump -i eth0 -XX port 80

예상 출력:

14:30:22.123456 IP 192.168.1.10.54321 > 93.184.216.34.80: Flags [S], seq 12345678, win 65535, length 0
0x0000: 4500 003c 1c46 4000 4006 b2f3 c0a8 010a E..<.F@.@.......
0x0010: 5db8 d822 d431 0050 00bc 614e 0000 0000 ]..".1.P..aN....

3-1b. 프론트엔드 → 플랫폼 브릿지: fetch() API 아래에서 일어나는 TCP 연결

섹션 제목: “3-1b. 프론트엔드 → 플랫폼 브릿지: fetch() API 아래에서 일어나는 TCP 연결”

프론트엔드 개발자가 fetch('https://api.example.com/users')를 호출할 때, JavaScript 레벨에서는 Promise 하나만 보이지만 브라우저 엔진 내부에서는 TCP 연결 전 과정이 일어난다.

비유: fetch()는 식당 웨이터에게 주문하는 것이고, TCP 연결은 주방과 식당을 잇는 서빙 통로를 여는 것이다. 웨이터(fetch API)는 통로가 열려있는지 신경 쓰지 않는다. 브라우저 엔진이 자동으로 열어준다.

원리: fetch() 한 줄 뒤에서 일어나는 일

JavaScript 코드:
const res = await fetch('https://api.example.com/users', {
headers: { Authorization: 'Bearer token' }
});
↓ 브라우저가 처리하는 과정
1단계: DNS 조회 (평균 20~50ms, 캐시 있으면 0ms)
api.example.com → 52.68.1.100
2단계: TCP 연결 수립 (이미 연결 풀에 있으면 건너뜀!)
[SYN] 클라이언트 → 서버 (약 RTT의 절반 시간)
[SYN-ACK] 클라이언트 ← 서버
[ACK] 클라이언트 → 서버
소요 시간: 서울↔AWS ap-northeast-2 기준 약 1~5ms
3단계: TLS 핸드셰이크 (TLS 1.3: 1-RTT, TLS 1.2: 2-RTT)
클라이언트: ClientHello (지원 암호화 방식 목록)
서버: ServerHello + Certificate + Finished
클라이언트: Finished + HTTP 요청 동시 전송 (TLS 1.3)
소요 시간: 약 5~15ms
4단계: HTTP 요청/응답 (실제 데이터 전송)
GET /users HTTP/2 → 서버 처리 → 응답
소요 시간: 서버 처리 시간에 따라 다름
총 소요: DNS(캐시 없음 50ms) + TCP(3ms) + TLS(10ms) + 서버(20ms) ≈ 83ms
DNS(캐시 있음 0ms) + TCP(기존 재사용 0ms) + 서버(20ms) ≈ 20ms
↑ HTTP Keep-Alive의 핵심 이점!

실제 확인: Chrome DevTools로 TCP 단계별 시간 측정

Chrome DevTools → Network 탭 → 요청 클릭 → Timing 섹션
DNS Lookup: 23ms ← 3-1에서 배운 DNS 질의 과정
Initial connection: 12ms ← 3-2의 3-way handshake
SSL: 18ms ← TLS handshake
Waiting (TTFB): 45ms ← 서버 처리 + 응답 첫 바이트 도달
Content Download: 8ms ← 실제 데이터 전송
Total: 106ms

Node.js/Nest.js 서버에서 TCP 연결 재사용 최적화

// 외부 API 호출 시 TCP 연결 재사용 (Keep-Alive)
import * as http from "http";
import * as https from "https";
// Bad: 매 요청마다 새 TCP 연결 (기본값은 keepAlive: false)
const response = await fetch("https://external-api.com/data");
// → 요청마다 TCP 3-way handshake + TLS handshake = 30~50ms 추가 오버헤드
// Good: HTTP Agent로 연결 풀 사용
const agent = new https.Agent({ keepAlive: true, maxSockets: 10 });
const response = await fetch("https://external-api.com/data", {
// @ts-ignore (Node.js 전용 옵션)
agent,
});
// → 두 번째 요청부터 기존 TCP 연결 재사용 → 핸드셰이크 생략
Terminal window
# Node.js 서버에서 현재 ESTABLISHED 연결 수 확인
ss -tn state established | grep :443
# 예상 출력 (외부 API 연결 풀이 살아있을 때):
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# ESTAB 0 0 10.0.1.5:52345 external-api.com:443
# ESTAB 0 0 10.0.1.5:52346 external-api.com:443
# → 2개의 연결이 재사용 대기 중 (3-way handshake 비용 절약)

📖 더 보기: Using the Fetch API — MDN Web Docs — fetch()의 내부 동작과 Connection, Keep-Alive 헤더 관계 설명 (입문)


전화 통화를 시작하는 과정과 동일하다.

  1. 클라이언트: “여보세요? (SYN)” — 연결 요청
  2. 서버: “네, 들립니다. 저도 들리시나요? (SYN-ACK)” — 연결 수락 + 역방향 연결 요청
  3. 클라이언트: “네, 들립니다 (ACK)” — 역방향 연결 수락
클라이언트 서버
| |
|──── SYN (seq=100) ──────────→ | LISTEN 상태
| |
| ←── SYN-ACK (seq=200, ack=101)─| SYN_RECEIVED
| |
|──── ACK (ack=201) ───────────→ | ESTABLISHED
| |
| 데이터 전송 시작 |
Terminal window
# 3-way handshake 확인
sudo tcpdump -i lo port 8080 -S

예상 출력:

# S = SYN, . = ACK, P = PUSH
14:30:22 IP client.54321 > server.8080: Flags [S], seq 0, win 65535
14:30:22 IP server.8080 > client.54321: Flags [S.], seq 0, ack 1, win 65535
14:30:22 IP client.54321 > server.8080: Flags [.], ack 1, win 65535

3-3. TCP Flow Control (흐름 제어): Sliding Window

섹션 제목: “3-3. TCP Flow Control (흐름 제어): Sliding Window”

물탱크와 호스에 비유하자. 수신자의 물탱크(버퍼)가 가득 차면 송신자에게 “천천히 보내줘”라고 알려야 한다. 이 신호가 바로 수신 윈도우(rwnd) 값이다. 탱크 여유 공간이 100리터면 “100바이트까지만 보내도 돼”라고 ACK에 포함해서 알려주는 방식이다.

Sliding Window 동작 방식:

  • 수신자는 자신의 버퍼 여유 공간을 rwnd(receive window) 값으로 TCP 헤더에 담아 ACK와 함께 송신자에게 전달한다.
  • 송신자는 rwnd를 초과하는 데이터를 보낼 수 없다.
  • 각 세그먼트에 대한 ACK를 받으면, 윈도우가 슬라이딩되어 다음 데이터를 보낼 수 있다.
  • 수신자 버퍼가 꽉 차면 rwnd = 0이 되고, 송신자는 전송을 멈추고 주기적으로 Zero Window Probe를 보내 버퍼가 비었는지 확인한다.
송신 버퍼: [보낸 것(ACK됨)] [보내는 중(미확인)] [보낼 수 있음] [보낼 수 없음]
↑ ↑ ↑
Send Base Send Next Send Base + rwnd

MSS와 MTU의 관계:

  • MTU (Maximum Transmission Unit): 네트워크 계층에서 한 번에 전송 가능한 최대 프레임 크기. Ethernet 기준 1500 bytes
  • MSS (Maximum Segment Size): TCP 헤더(20B) + IP 헤더(20B)를 뺀 데이터 부분 최대 크기 = 1460 bytes
Terminal window
# 현재 네트워크 인터페이스의 MTU 확인
ip link show eth0
# MSS 확인 (tcpdump에서 options 항목)
sudo tcpdump -i eth0 -v port 80 | grep -i mss

예상 출력:

eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP
# tcpdump MSS 출력
14:30:22 Flags [S], seq 0, win 65535, options [mss 1460, sackOK, TS val ...], length 0

3-4. TCP Congestion Control (혼잡 제어)

섹션 제목: “3-4. TCP Congestion Control (혼잡 제어)”

고속도로 진입 방식에 비유하자. 도로(네트워크)가 얼마나 막혔는지 모르는 상태에서 차를 한꺼번에 쏟아내면 교통 체증이 발생한다. 그래서 처음에는 차 1대씩 보내고(Slow Start), 막히지 않으면 2대, 4대, 8대로 늘린다. 교통 체증이 생기면(패킷 손실) 다시 속도를 줄인다.

핵심 변수:

  • cwnd (Congestion Window): 송신자가 ACK 없이 보낼 수 있는 최대 데이터량 (혼잡 제어용)
  • ssthresh (Slow Start Threshold): Slow Start에서 Congestion Avoidance로 전환되는 임계값
  • 실제 전송 윈도우 = min(cwnd, rwnd)
CWND
│ ╭──────── Congestion Avoidance
│ ssthresh ───╯ (선형 증가: +1 MSS/RTT)
│ ╱
│ Slow Start ╱ (지수 증가: 2배/RTT)
│ 1 MSS ─────────/
└──────────────────────────────────────────── 시간(RTT)

1단계: Slow Start (느린 시작)

  • 초기 cwnd = 1 MSS (또는 최근 Linux는 10 MSS)
  • ACK 1개 수신 시 cwnd += 1 MSS → RTT마다 2배로 지수 증가
  • cwnd >= ssthresh가 되면 Congestion Avoidance로 전환

2단계: Congestion Avoidance (혼잡 회피)

  • cwnd += MSS * (MSS / cwnd) — RTT당 약 1 MSS씩 선형 증가
  • 패킷 손실 발생 시 혼잡으로 판단하고 cwnd를 대폭 줄임

3단계: Fast Retransmit (빠른 재전송)

  • 타임아웃을 기다리지 않고, 3번의 중복 ACK(Duplicate ACK) 수신 시 즉시 손실된 세그먼트를 재전송
  • 이는 일부 패킷이 도착했다는 의미이므로 심각한 혼잡은 아닌 상황

4단계: Fast Recovery (빠른 회복)

  • Fast Retransmit 후: ssthresh = cwnd / 2, cwnd = ssthresh + 3 MSS
  • Congestion Avoidance 단계로 진입 (Slow Start로 되돌아가지 않음)
  • 타임아웃 시: ssthresh = cwnd / 2, cwnd = 1 MSS (Slow Start로 복귀)
Slow Start → CA 전환점
CWND(MSS) ↓
64 ─ ╭─── ssthresh=64
32 ─ ╭──╯ CA: 선형 증가
16 ─ ╭─╯
8 ─ ╭─╯
4 ─╭─╯
2 ╭╯
1 ╯
└────────────────────────── RTT
0 1 2 3 4 5 6
Terminal window
# Linux에서 TCP 혼잡 제어 알고리즘 확인
sysctl net.ipv4.tcp_congestion_control
# 사용 가능한 알고리즘 목록
sysctl net.ipv4.tcp_available_congestion_control

예상 출력:

net.ipv4.tcp_congestion_control = cubic
net.ipv4.tcp_available_congestion_control = reno cubic bbr

참고: Linux의 기본 혼잡 제어 알고리즘은 CUBIC이며, Google이 개발한 **BBR(Bottleneck Bandwidth and RTT)**은 더 효율적인 대역폭 활용으로 주목받고 있다.


전화 통화의 라이프사이클과 같다: 대기 → 연결 중 → 통화 중 → 끊는 중 → 완전히 끊김

CLOSED
│ (서버: socket() + bind() + listen())
LISTEN ←───────────────────────────────────┐
│ │
│ (SYN 수신) 클라이언트: connect() │
▼ ┌────────────────────┘
SYN_RECEIVED ▼
│ SYN_SENT
│ (ACK 수신) │ (SYN+ACK 수신)
└───────────────────┘
ESTABLISHED ←── 데이터 전송 가능
│ (close() 호출: 먼저 끊는 쪽)
FIN_WAIT_1 ── FIN 전송 ──→ 상대방: CLOSE_WAIT
│ │
│ (ACK 수신) │ (close() 호출)
▼ ▼
FIN_WAIT_2 LAST_ACK ── FIN 전송
│ │
│ (FIN 수신) │ (ACK 수신)
▼ ▼
TIME_WAIT CLOSED
│ (2 * MSL 대기)
CLOSED

이사를 나온 후에도 혹시 모를 우편물을 위해 잠깐 기다리는 것과 같다. 연결이 완전히 종료됐음을 확신하기 전까지 잠시 대기하는 상태다.

이유 1: 마지막 ACK가 유실됐을 경우 대비

클라이언트 (TIME_WAIT) 서버
│ │
│ ←── FIN ───────────────── │
│ │
│ ──── ACK ────────────────→ │ ← 이 ACK가 유실되면?
│ (TIME_WAIT 진입) │
│ │ ← 서버는 FIN을 재전송
│ ←── FIN (재전송) ────────── │
│ │
│ ──── ACK ────────────────→ │ ← 재전송에 응답
│ │
│ (2MSL 후 CLOSED) │

이유 2: 옛 세그먼트(Duplicate Segment) 방지

  • 같은 포트 번호로 새 연결이 생겼을 때, 이전 연결의 오래된 패킷이 뒤늦게 도착하는 것을 막는다.
  • MSL (Maximum Segment Lifetime): 네트워크에서 세그먼트가 살아있을 수 있는 최대 시간 = 60초
  • 2MSL = 120초: 이 시간이 지나면 이전 연결의 모든 패킷이 소멸됐다고 보장
Terminal window
# TIME_WAIT 상태 소켓 수 확인
netstat -an | grep TIME_WAIT | wc -l
# 상세 목록
ss -tan state time-wait

예상 출력:

# TIME_WAIT 개수
1247
# ss 상세
State Recv-Q Send-Q Local Address:Port Peer Address:Port
TIME-WAIT 0 0 10.0.1.5:8080 10.0.0.1:54321
TIME-WAIT 0 0 10.0.1.5:8080 10.0.0.2:54322

3-6b. CLOSE_WAIT vs TIME_WAIT: 정상과 비정상의 차이

섹션 제목: “3-6b. CLOSE_WAIT vs TIME_WAIT: 정상과 비정상의 차이”

TIME_WAIT은 “이사 나온 뒤 혹시 모를 우편물을 기다리는 것” — 정상적 마무리다. CLOSE_WAIT은 “손님이 먼저 일어났는데 주인이 아직 문을 안 닫고 있는 것” — 응용 프로그램 버그다.

원리: 4-Way Handshake와 두 상태의 발생 위치

섹션 제목: “원리: 4-Way Handshake와 두 상태의 발생 위치”
Active Close (먼저 끊는 쪽) Passive Close (나중에 끊는 쪽)
───────────────────────── ─────────────────────────────
FIN_WAIT_1 CLOSE_WAIT ← 여기서 멈추면 버그!
│── FIN ─────────────────────→ │
│ │ (app이 close()를 부를 때까지 대기)
│ ←──── ACK ────────────────── │
FIN_WAIT_2 │
│ │ (app이 close() 호출)
│ ←──── FIN ────────────────── LAST_ACK
│── ACK ─────────────────────→ │
TIME_WAIT CLOSED
│ (2MSL = 120초 대기)
CLOSED

CLOSE_WAIT이 쌓이는 상황 (BackOps에서 자주 발생):

Terminal window
# 1. TypeORM 연결 풀에서 DB 연결이 반환되지 않음
# DB 서버가 연결을 끊었는데(FIN), 앱이 연결을 풀로 반환 안 함 → CLOSE_WAIT
# 2. 외부 API 호출 후 응답 처리 중 예외가 발생해 연결을 닫지 않음
# axios/fetch의 연결이 누수됨
# 3. WebSocket 클라이언트가 비정상 종료 → 서버는 FIN을 받았지만
# 이벤트 핸들러에서 socket.close()를 호출하지 않음
# 확인: 어느 프로세스가 CLOSE_WAIT를 많이 가지는지
ss -tnp state close-wait | awk '{print $NF}' | sort | uniq -c | sort -rn | head -5

예상 출력:

# 정상 서버
$ ss -s | grep -i close
TCP: 45 (estab 12, closed 28, orphaned 0, timewait 28)
# 비정상 서버 (CLOSE_WAIT 누수)
$ ss -s | grep -i close
TCP: 4823 (estab 12, closed 4811, orphaned 4800, timewait 23)
# ↑ close_wait + fin_wait 합산

Nest.js에서 빠른 진단:

// main.ts에 주기적 소켓 상태 로깅 추가
setInterval(() => {
const handles = (process as any)._getActiveHandles?.() || [];
const sockets = handles.filter((h: any) => h.constructor?.name === "Socket");
if (sockets.length > 100) {
console.warn(`[SOCKET_LEAK] Active socket handles: ${sockets.length}`);
}
}, 10000);

등기우편(TCP)과 일반 전단지 배포(UDP)의 차이다. 등기우편은 수신 확인, 서명, 분실 시 재발송까지 해주지만 느리다. 전단지는 그냥 던지고 끝이지만 빠르다.

1. 핸드셰이크 없음 (Connection-less)

  • TCP는 데이터 전송 전 3-way handshake가 필요 (1.5 RTT 소요)
  • UDP는 바로 데이터 전송 → 첫 패킷부터 0 RTT 가능

2. 헤더 크기 차이

TCP 헤더 (20~60 bytes):
┌───────┬───────┬───────────────────────────────────┐
│ Source│ Dest │ Sequence Number (4B) │
│ Port │ Port │ Acknowledgment Number (4B) │
│ (2B) │ (2B) │ Flags, Window Size, Checksum, 등 │
└───────┴───────┴───────────────────────────────────┘
UDP 헤더 (8 bytes 고정):
┌───────────┬───────────┬───────────┬───────────┐
│ Src Port │ Dst Port │ Length │ Checksum │
│ (2B) │ (2B) │ (2B) │ (2B) │
└───────────┴───────────┴───────────┴───────────┘

3. 재전송 없음, ACK 없음

  • 패킷 손실 시 그냥 버린다
  • 재전송 대기 시간(RTT)이 없으므로 지연(latency)이 극단적으로 낮다
  • 순서 보장 없음 → 수신 순서 버퍼링 불필요

UDP 사용 사례와 이유:

서비스프로토콜이유
DNS 조회UDP단일 요청/응답, 재전송 앱에서 처리
실시간 영상통화UDP오래된 프레임은 버리는 것이 나음
온라인 게임UDP1ms 지연 차이가 중요
DHCPUDP브로드캐스트 기반
HTTP/3 (QUIC)UDP직접 신뢰성 구현으로 Head-of-Line Blocking 해결
# Python UDP 소켓 예시 (서버)
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(('0.0.0.0', 9999))
while True:
data, addr = server.recvfrom(1024) # 블로킹 수신
print(f"Received: {data.decode()} from {addr}")
server.sendto(b"OK", addr) # 응답 (연결 없이 바로 전송)

예상 출력:

Received: hello from ('192.168.1.10', 54321)
Received: world from ('192.168.1.11', 54322)

QUIC은 UDP를 기반으로 Google이 만든 전송 프로토콜로, 2022년 HTTP/3의 표준 전송 프로토콜이 됐다.

TCP의 문제점: Head-of-Line Blocking

HTTP/2 (TCP):
스트림1: [패킷1][ 손실 ][패킷3] ← 손실된 패킷2를 기다리는 동안
스트림2: [패킷A][패킷B][패킷C] ← 스트림2도 함께 지연됨
HTTP/3 (QUIC):
스트림1: [패킷1][손실][패킷3] ← 스트림1만 대기
스트림2: [패킷A][패킷B][패킷C] ← 스트림2는 계속 진행!

QUIC의 주요 특징:

  • UDP 위에서 직접 패킷 재전송/순서 보장 구현
  • TLS 1.3이 기본 내장 → 1-RTT로 암호화 + 연결 수립 동시 처리
  • Connection Migration: IP가 바뀌어도(LTE → Wi-Fi) 연결 유지
Terminal window
# 사이트가 HTTP/3를 지원하는지 확인
curl -I --http3 https://www.google.com 2>/dev/null | head -5
# 또는 Chrome DevTools Network 탭에서 Protocol 열 확인

예상 출력:

HTTP/3 200
content-type: text/html; charset=UTF-8
alt-svc: h3=":443"; ma=2592000

실무 주의: 기업 네트워크와 방화벽의 UDP 차단 문제

섹션 제목: “실무 주의: 기업 네트워크와 방화벽의 UDP 차단 문제”

HTTP/3(QUIC)가 UDP 기반이라는 점은 실무에서 중요한 문제를 일으킨다. 기업 네트워크나 방화벽이 UDP 트래픽을 차단하는 경우가 흔하기 때문이다.

HTTP/3 요청 흐름:
1. 브라우저가 HTTPS(HTTP/2) 로 첫 요청
2. 서버가 Alt-Svc: h3=":443" 헤더로 HTTP/3 지원 알림
3. 브라우저가 UDP 443 포트로 QUIC 연결 시도
↓ (기업 방화벽이 UDP 443 차단하면)
4. QUIC 연결 실패 → 자동으로 HTTP/2(TCP)로 폴백
5. 사용자는 느린 속도로 계속 서비스 이용

AWS CloudFront에서 HTTP/3 활성화 시, 클라이언트가 UDP를 차단한 경우 자동 폴백이 일어나므로 서비스 단절은 없다. 하지만 HTTP/3의 성능 이점을 얻지 못하는 사용자가 생긴다.

EC2 보안 그룹에서 QUIC 허용 설정:

Terminal window
# CloudFront → EC2 Origin 간: TCP만 필요 (CloudFront가 QUIC 종단점)
# 단, ALB를 거치지 않고 직접 NLB를 쓰는 경우 UDP 허용 필요
# AWS CLI로 보안 그룹에 UDP 443 허용 추가
aws ec2 authorize-security-group-ingress \
--group-id sg-xxxxxxxx \
--protocol udp \
--port 443 \
--cidr 0.0.0.0/0

📖 더 보기: HTTP/3 and QUIC in Production: A Practical Deployment Guide for 2026 — 방화벽 UDP 차단 대처법, 폴백 설정, 프로덕션 배포 체크리스트 (중급)


3-8b. SYN Flood 공격과 AWS Shield: TCP 보안 관점

섹션 제목: “3-8b. SYN Flood 공격과 AWS Shield: TCP 보안 관점”

SYN Flood는 “전화를 수백만 통 걸어놓고 모두 끊지 않는” 공격이다. 서버의 half-open 연결 테이블이 가득 차서 정상 연결을 받을 수 없게 된다.

정상 3-way Handshake:
클라이언트: SYN → 서버 (half-open 연결 생성)
서버: SYN-ACK → 클라이언트
클라이언트: ACK → 서버 (연결 완성, half-open 해제)
SYN Flood 공격:
공격자: SYN (위조 IP) → 서버 (half-open 연결 생성)
서버: SYN-ACK → 위조 IP (응답 없음!)
서버: half-open 상태로 메모리 점유 ← 이게 수백만 건 쌓임
서버: backlog queue 가득 참 → 정상 클라이언트 연결 거부!

AWS Shield Standard는 SYN Cookie 기법으로 SYN Flood를 방어한다. SYN Cookie는 half-open 연결을 서버 메모리에 저장하지 않고, 암호화된 시퀀스 번호(cookie)를 SYN-ACK에 담아 보낸다. 진짜 클라이언트만 올바른 ACK를 돌려보낼 수 있다.

SYN Cookie 방어:
클라이언트: SYN → AWS Shield
Shield: SYN-ACK (cookie = hash(IP, 포트, 시간)) → 클라이언트
Shield: 메모리에 아무것도 저장 안 함! (stateless)
클라이언트: ACK (ack=cookie+1) → Shield
Shield: cookie 검증 성공 → 진짜 서버로 연결 전달

AWS 서비스별 SYN Flood 방어 방식:

서비스SYN Flood 대응
ALBAWS Shield Standard 자동 적용. SYN Proxy 내장
NLBTCP 리스너는 방어 취약 (NLB가 TCP를 그대로 전달) → Global Accelerator 추가 권장
NLB (TLS 리스너)TLS 종단에서 SYN 흡수 가능
CloudFront엣지에서 흡수. Origin까지 도달하지 않음
Terminal window
# AWS Shield 보호 상태 확인
aws shield describe-protection \
--resource-arn arn:aws:elasticloadbalancing:ap-northeast-2:YOUR_ACCOUNT:loadbalancer/app/my-alb/xxx
# Shield Advanced 활성화 시 DDoS 이벤트 확인
aws shield list-attacks \
--start-time 2026-01-01T00:00:00Z \
--end-time 2026-04-08T00:00:00Z

BackOps 실무 팁: NLB + TCP 리스너 조합은 SYN Flood에 취약하다. 외부 트래픽을 NLB로 직접 받는다면 AWS Shield Advanced나 Global Accelerator를 앞단에 배치해야 한다. ALB는 Shield Standard만으로도 대부분 방어된다.


CUBIC은 “패킷을 잃어봐야 도로가 막혔는지 아는” 방식이다. 교통사고가 나야 막힌 걸 아는 것과 같다. 반면 BBR은 GPS 내비게이션처럼 실시간 도로 상황(RTT 변화 + 대역폭 측정) 을 보고 미리 속도를 조절한다.

원리: BBR (Bottleneck Bandwidth and RTT)

섹션 제목: “원리: BBR (Bottleneck Bandwidth and RTT)”

BBR은 Google이 개발해 2016년 오픈소스로 공개한 혼잡 제어 알고리즘이다. 핵심 차이는 패킷 손실 이 아닌 RTT 변화와 대역폭 을 기준으로 혼잡을 판단한다는 점이다.

CUBIC vs BBR 동작 비교:

CUBIC (손실 기반):
cwnd ↑───────────────────────X ← 패킷 손실 감지
↓ (절반으로 줄이고 다시 증가)
↑─────────────────────X
BBR (대역폭 기반):
전송률 ───────────────────────── ← 대역폭과 RTT를 주기적으로 측정
네트워크 포화 전에 속도 조절 → 손실 없이 안정적 유지

BBR의 4가지 페이즈:

  1. Startup: 대역폭이 2배씩 증가 (CUBIC Slow Start와 유사)
  2. Drain: Startup에서 채운 큐를 비우는 단계
  3. ProbeBW: 주기적으로 대역폭 탐색. 대부분의 시간을 여기서 보냄
  4. ProbeRTT: 최소 RTT를 재측정하기 위해 잠시 전송률을 낮춤

AWS/GCP에서의 BBR 적용:

Terminal window
# EC2 인스턴스에서 BBR 활성화
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
sudo sysctl -w net.core.default_qdisc=fq # BBR 필수 설정
# 영구 적용 (/etc/sysctl.conf)
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
# 적용 확인
sysctl net.ipv4.tcp_congestion_control

예상 출력:

net.ipv4.tcp_congestion_control = bbr

실무 팁: 고지연(High Latency) 환경, 즉 미국/유럽 리전 간 통신이나 CDN Origin으로 트래픽이 많은 경우 BBR 전환 시 처리량이 크게 개선될 수 있다. Google Cloud는 2017년부터 전체 인프라에 BBR을 기본 적용했다.

📖 더 보기: TCP BBR Congestion Control comes to GCP — Google이 BBR을 전사 적용한 배경과 성능 수치 분석


3-10. HTTP/3 + QUIC 프로덕션 현황 (2025~2026)

섹션 제목: “3-10. HTTP/3 + QUIC 프로덕션 현황 (2025~2026)”

2025년 10월 기준 HTTP/3 글로벌 채택률은 35% (Cloudflare 데이터). Chrome, Firefox, Safari, Edge 모두 지원하며, 서버와 클라이언트는 Alt-Svc 헤더로 프로토콜을 협상한다. 미지원 클라이언트는 자동으로 HTTP/2로 폴백된다.

실제 사이트에서 HTTP/3 확인:

Terminal window
# curl로 HTTP/3 지원 여부 확인
curl -I --http3 https://www.cloudflare.com 2>/dev/null | head -3
# DNS 레코드로 HTTP/3 확인 (HTTPS 레코드 타입)
dig HTTPS cloudflare.com
# 응답 헤더에서 Alt-Svc 확인
curl -sI https://www.google.com | grep -i alt-svc

예상 출력:

HTTP/3 200
content-type: text/html; charset=UTF-8
# Alt-Svc 헤더 (브라우저에 HTTP/3 지원을 알림)
alt-svc: h3=":443"; ma=86400

AWS에서 HTTP/3 활성화:

Terminal window
# CloudFront는 HTTP/3(QUIC)를 기본 지원
# AWS 콘솔: CloudFront → Distribution → Edit → Supported HTTP Versions
# 또는 AWS CLI:
aws cloudfront update-distribution \
--id E1234ABCD \
--distribution-config '{"HttpVersion": "http3", ...}'

📖 더 보기: HTTP/3 and QUIC in Production: A Practical Deployment Guide for 2026 — 프로덕션 QUIC/HTTP3 배포 실전 가이드 (입문/중급)


3-11. Nagle 알고리즘과 Delayed ACK: 왜 같이 쓰면 느린가

섹션 제목: “3-11. Nagle 알고리즘과 Delayed ACK: 왜 같이 쓰면 느린가”

Nagle 알고리즘은 “편지가 모이면 한꺼번에 보내자”는 우체부이고, Delayed ACK는 “수신확인을 바로 보내지 말고 잠깐 모아서 보내자”는 수신자다. 둘 다 효율성을 위한 것이지만, 같이 동작하면 우체부는 확인을 기다리고, 수신자는 확인을 미루는 교착 상태가 발생한다.

Nagle 알고리즘 (송신 측):

  • 작은 패킷(< MSS)이 이미 전송된 상태에서 ACK를 받지 못했으면, 추가 데이터를 버퍼링하여 하나의 큰 패킷으로 합쳐 보낸다.
  • 목적: 네트워크에 작은 패킷(tinygram)이 넘치는 것을 방지

Delayed ACK (수신 측):

  • ACK를 즉시 보내지 않고 최대 40~500ms 대기하며, 그 사이 다른 데이터와 함께 보내려고 한다.
  • 목적: ACK만 보내는 빈 패킷의 수를 줄임

교착 시나리오:

클라이언트 (Nagle ON) 서버 (Delayed ACK ON)
│ │
│── write(헤더, 100B) ──────────────────→ │ 수신, ACK 지연 시작 (40ms 타이머)
│ │
│── write(본문, 50B) ← Nagle이 보류! │ "아직 ACK 안 보냄, 데이터 올 때까지 대기"
│ (이전 ACK를 못 받았으므로 버퍼링) │
│ │
│ ~~~ 40ms 교착 상태 ~~~ │
│ │
│ ←──────────────────── ACK ───────────── │ 40ms 타이머 만료, ACK 전송
│ │
│── write(본문, 50B) 이제 전송 ──────────→ │ 드디어 본문 수신

결과: 매 요청마다 40~500ms의 불필요한 지연이 추가된다.

// Nest.js에서 TCP_NODELAY 설정 (Nagle 비활성화)
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as net from "net";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const server = app.getHttpServer();
// 새 연결마다 TCP_NODELAY 적용
server.on("connection", (socket: net.Socket) => {
socket.setNoDelay(true); // Nagle 알고리즘 비활성화
});
await app.listen(3000);
}
bootstrap();
Terminal window
# Linux에서 Delayed ACK 비활성화 (TCP_QUICKACK)
# 소켓 옵션으로만 설정 가능 (sysctl 전역 설정 없음)
# Node.js에서는 사실상 TCP_NODELAY만 설정하면 충분
# HTTP/1.1 keep-alive + TCP_NODELAY 조합이 표준 실무 설정

실무 팁: Node.js의 net.Socket은 기본적으로 Nagle이 활성화되어 있다. HTTP 서버에서는 대부분 setNoDelay(true)를 권장한다. 특히 WebSocket이나 실시간 API에서는 필수다.

📖 더 보기: It’s always TCP_NODELAY. Every damn time. — Marc Brooker’s Blog — AWS 엔지니어가 정리한 TCP_NODELAY의 실무적 중요성 (중급)


3-12. TCP Keepalive vs HTTP Keep-Alive: 이름은 같지만 다른 것

섹션 제목: “3-12. TCP Keepalive vs HTTP Keep-Alive: 이름은 같지만 다른 것”

둘 다 “연결을 유지한다”는 개념이지만, 동작 계층과 목적이 완전히 다르다. 이 차이를 이해하지 못하면 AWS ALB에서 502 에러가 발생하는 원인을 찾을 수 없다.

항목TCP KeepaliveHTTP Keep-Alive
계층Transport Layer (L4)Application Layer (L7)
목적연결이 살아있는지 확인 (프로브 패킷 전송)TCP 연결을 재사용하여 핸드셰이크 절약
동작일정 시간 유휴 후 빈 ACK 패킷을 주기적 전송하나의 TCP 연결로 여러 HTTP 요청/응답
기본값 (Linux)7200초 후 시작, 75초 간격, 9회 시도HTTP/1.1에서 기본 활성화
설정OS 커널 파라미터 (sysctl)HTTP 헤더 + 서버 타임아웃 설정
시간 흐름 →
ALB idle timeout: 60초
├────────────────────────────────────────────┤
Nest.js keepAliveTimeout: 5초 (Node.js 기본값!)
├────┤
55초 후: ALB가 연결을 재사용하려고 요청을 보냄
→ 하지만 Nest.js는 이미 55초 전에 연결을 닫음
→ ALB가 닫힌 소켓에 쓰기 시도 → 502 Bad Gateway!
// 해결: Nest.js keepAliveTimeout을 ALB보다 길게 설정
const server = app.getHttpServer();
server.keepAliveTimeout = 65000; // 65초 (ALB 60초 + 5초 버퍼)
server.headersTimeout = 66000; // keepAliveTimeout + 1초
// TCP Keepalive도 함께 설정 (장시간 유휴 연결 감지)
server.on("connection", (socket: net.Socket) => {
socket.setKeepAlive(true, 30000); // 30초 유휴 후 프로브 시작
});
Terminal window
# Linux TCP Keepalive 전역 설정 확인
sysctl net.ipv4.tcp_keepalive_time # 유휴 시간 (기본 7200초)
sysctl net.ipv4.tcp_keepalive_intvl # 프로브 간격 (기본 75초)
sysctl net.ipv4.tcp_keepalive_probes # 프로브 횟수 (기본 9회)
# 예상 출력:
# net.ipv4.tcp_keepalive_time = 7200
# net.ipv4.tcp_keepalive_intvl = 75
# net.ipv4.tcp_keepalive_probes = 9
# → 7200 + 75*9 = 7875초(약 2시간 11분) 후 연결 종료

📖 더 보기: Troubleshoot Application Load Balancer HTTP 502 Errors — AWS re:Post — ALB 502 에러의 모든 원인과 해결 방법 (중급)


4-1. AWS ALB vs NLB: TCP/UDP 처리 방식 차이

섹션 제목: “4-1. AWS ALB vs NLB: TCP/UDP 처리 방식 차이”
인터넷
┌────────────┴────────────┐
│ │
ALB (L7) NLB (L4)
Application LB Network LB
│ │
HTTP 요청을 파싱: TCP 연결을 그대로 전달:
- URL 기반 라우팅 - 패킷 수준 포워딩
- 헤더 분석 - 정적 IP 제공
- SSL Termination - Ultra-low latency
- gRPC 지원 - UDP 지원
│ │
EC2 인스턴스 EC2 인스턴스

차이점 요약:

항목ALB (L7)NLB (L4)
OSI 계층Layer 7 (Application)Layer 4 (Transport)
지원 프로토콜HTTP, HTTPS, gRPCTCP, UDP, TLS
라우팅 기준URL, 헤더, 쿠키IP + Port
지연시간상대적으로 높음초저지연 (us 단위)
정적 IP미지원지원 (Elastic IP)
WebSocket지원지원
UDP미지원지원
클라이언트 IP 보존X-Forwarded-For 헤더직접 보존

언제 NLB를 선택해야 하는가:

  • UDP 프로토콜이 필요한 경우 (DNS, syslog, 게임 서버)
  • 정적 IP가 필요한 경우 (화이트리스트 IP 등록 등)
  • 극도로 낮은 지연시간이 필요한 경우
  • TLS Passthrough (백엔드에서 직접 TLS 처리)

Nest.js는 기본적으로 HTTP(TCP) 위에서 동작하지만, keep-alive와 타임아웃 설정이 성능에 크게 영향을 미친다.

// main.ts: TCP Keep-Alive와 타임아웃 설정
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as http from "http";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// HTTP Keep-Alive 설정 (TCP 연결 재사용)
const server = app.getHttpServer() as http.Server;
server.keepAliveTimeout = 65000; // ALB timeout(60s)보다 5초 더 크게
server.headersTimeout = 66000; // keepAliveTimeout보다 1초 더 크게
await app.listen(3000);
}
bootstrap();

BackOps + Nest.js + AWS 스택 기준:

  1. ALB keepAliveTimeout 설정: ALB의 기본 idle timeout은 60초인데, Nest.js의 keepAliveTimeout이 60초 이하면 ALB가 살아있는 연결을 닫기 전에 서버가 먼저 닫아서 502 Bad Gateway가 발생한다. → keepAliveTimeout = 65000으로 설정 필수

  2. DB Connection Pool과 TCP: TypeORM/Prisma의 connection pool은 TCP 연결을 재사용한다. pool이 부족하면 새 TCP 연결(3-way handshake)이 추가되고, 너무 많으면 DB 서버의 소켓 제한에 걸린다.

  3. Lambda + RDS: Lambda는 새 인스턴스마다 새 TCP 연결을 만들어 RDS에 연결하므로, 동시 실행 수가 높아지면 RDS의 max_connections를 초과할 수 있다. → RDS Proxy(TCP 연결 풀링)가 해결책

  4. TIME_WAIT 모니터링: 트래픽이 많은 API 서버에서 netstat -an | grep TIME_WAIT로 수천 개가 보이면 비정상은 아니지만, net.ipv4.tcp_tw_reuse = 1 설정을 검토해야 한다.


항목TCPUDP
연결 방식연결 지향 (3-way handshake)비연결
신뢰성보장 (재전송, ACK)미보장
순서 보장보장미보장
흐름 제어있음 (Sliding Window)없음
혼잡 제어있음 (Slow Start 등)없음
헤더 크기20~60 bytes8 bytes
지연시간높음낮음
처리량낮음 (제어 오버헤드)높음
사용 사례HTTP, FTP, SSH, DBDNS, 스트리밍, 게임, QUIC
항목Flow Control (흐름 제어)Congestion Control (혼잡 제어)
목적수신자 버퍼 보호네트워크 혼잡 방지
제어 주체수신자가 rwnd로 제어송신자가 cwnd로 자체 제어
정보 출처수신자의 버퍼 상태패킷 손실/RTT 변화
알고리즘Sliding WindowSlow Start, CUBIC, BBR

증상:

Error: read ECONNRESET
at TCP.onStreamRead (internal/stream_base_commons.js:209:20)
# 또는 curl에서:
curl: (56) Recv failure: Connection reset by peer

원인:

  • 상대방이 RST 패킷을 보내 강제로 연결을 끊은 상태
  • 주로 발생하는 상황:
    1. 서버가 재시작되거나 프로세스가 죽었는데 클라이언트는 연결을 유지하려 할 때
    2. 방화벽/로드밸런서가 idle timeout으로 연결을 끊었는데 (ALB 기본 60초) 서버는 해당 연결로 요청을 보낼 때
    3. 서버가 keepAliveTimeout이 ALB idle timeout보다 짧을 때

해결 방법:

// Nest.js에서 keepAliveTimeout 올바르게 설정
server.keepAliveTimeout = 65000; // ALB timeout(60s) + 5s 버퍼
server.headersTimeout = 66000;
// TypeORM connection pool에서 연결 유효성 검사 추가
// ormconfig:
{
keepConnectionAlive: true,
connectTimeout: 60000,
acquireTimeout: 60000,
}
Terminal window
# 서버 측 RST 발생 확인
sudo tcpdump -i eth0 'tcp[tcpflags] & tcp-rst != 0'

증상:

Terminal window
$ netstat -an | grep TIME_WAIT | wc -l
18432 # 수만 개의 TIME_WAIT 소켓
$ ss -s
TCP: 18945 (estab 1023, closed 17891, orphaned 0, timewait 17891)

원인:

  • API 서버가 HTTP keep-alive 없이 매 요청마다 새 TCP 연결을 생성하고 종료하는 경우
  • 단시간에 많은 short-lived connection이 생성될 때
  • HTTP 클라이언트에 Connection: close 헤더가 붙어있는 경우

해결 방법:

Terminal window
# 1. 즉시 완화: SO_REUSEADDR 활성화 (안전한 방법)
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
# 2. TIME_WAIT 대기 시간 단축 (주의: 프로덕션에서 신중히)
sudo sysctl -w net.ipv4.tcp_fin_timeout=30
# 3. 포트 범위 확장 (새 연결에 사용할 포트 확보)
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# 영구 적용: /etc/sysctl.conf에 추가
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30
net.ipv4.ip_local_port_range = 1024 65535

근본 해결책: HTTP keep-alive를 활성화하여 연결을 재사용하고, Connection Pool을 사용해 short-lived connection을 최소화한다.

// axios에서 HTTP keep-alive 활성화 (Node.js)
const http = require("http");
const axios = require("axios");
const agent = new http.Agent({ keepAlive: true });
const client = axios.create({ httpAgent: agent });

문제 3.5: CLOSE_WAIT 폭증 — 애플리케이션 소켓 누수

섹션 제목: “문제 3.5: CLOSE_WAIT 폭증 — 애플리케이션 소켓 누수”

증상:

Terminal window
# CLOSE_WAIT 소켓이 계속 쌓이고 줄어들지 않음
$ ss -tn state close-wait | wc -l
4823 # 비정상적으로 높은 수치
# 시간이 지나도 줄어들지 않음 (TIME_WAIT와 다른 점!)
watch -n 5 "ss -tn state close-wait | wc -l"
# 4823 → 4956 → 5102 (계속 증가)
# 결국 "too many open files" 에러 발생
Error: EMFILE: too many open files

원인:

CLOSE_WAIT은 TIME_WAIT와 전혀 다른 상태다.

TIME_WAIT: 내가 먼저 FIN을 보낸 후 → 정상적인 마무리 대기 (자동 소멸)
CLOSE_WAIT: 상대방이 FIN을 보냈는데 → 내 코드가 아직 close()를 안 부른 것!

거의 100% 애플리케이션 코드 버그다. 소켓을 받아 쓰고 닫지 않은 것이다. 대표적인 원인:

  1. HTTP 클라이언트가 응답을 받은 뒤 연결을 닫지 않음 (예: axios keep-alive pool)
  2. TypeORM / Prisma 쿼리 타임아웃 후 연결이 풀로 반환되지 않음
  3. WebSocket 연결이 클라이언트 측에서 끊어졌는데 서버가 이를 감지하지 못함

해결 방법:

Terminal window
# 1. CLOSE_WAIT 소켓을 보유한 프로세스 확인
ss -tap state close-wait
# Netid State Recv-Q Send-Q Local:Port Peer:Port Process
# tcp CLOSE-WAIT 0 0 :3000 :54321 users:(("node",pid=1234))
# 2. 해당 PID의 열린 파일 수 확인
lsof -p 1234 | grep -c CLOSE_WAIT
# 3. 실제 누수 지점 추적 (Node.js)
# 방법: 주기적으로 현재 연결 상태 로그
setInterval(() => {
const handle = process._getActiveHandles();
console.log('Active handles:', handle.length);
}, 30000);
// 흔한 원인: axios keep-alive 설정 후 연결 미반환
// Bad: keep-alive agent를 쓰면서 응답을 다 읽지 않음
const response = await axios.get(url);
// response.data만 쓰고 스트림을 닫지 않은 경우
// Good: TypeORM에서 쿼리 타임아웃 설정으로 연결 반환 보장
TypeOrmModule.forRoot({
// ...
connectTimeout: 10000, // 연결 타임아웃 10초
acquireTimeout: 10000, // 풀에서 연결 획득 타임아웃
extra: {
connectionLimit: 10, // 풀 최대 연결 수 명시
},
});

핵심 구분: TIME_WAIT이 많은 것은 정상(연결을 많이 맺고 끊는 정상 서버). CLOSE_WAIT이 쌓이는 것은 비정상(코드에서 소켓을 닫지 않는 버그). TIME_WAIT는 자동으로 줄어들지만, CLOSE_WAIT는 코드를 고쳐야만 줄어든다.


문제 4: AWS VPC Security Group / NACL로 인한 TCP 연결 차단

섹션 제목: “문제 4: AWS VPC Security Group / NACL로 인한 TCP 연결 차단”

증상:

Terminal window
# 서버 내부에서는 정상 동작하는데 외부에서 접근 불가
curl: (7) Failed to connect to api.example.com port 443: Connection refused
# VPC Flow Logs에서 REJECT 확인
# action=REJECT protocol=6 (TCP)

원인: AWS VPC의 NACL(Network ACL)은 Stateless다. Inbound는 허용했는데 Outbound Ephemeral Port(1024-65535)를 막으면 TCP 응답이 클라이언트로 돌아오지 못한다.

클라이언트 → (Inbound: 허용) → EC2
EC2 → (Outbound 응답: Ephemeral Port 50000 → NACL REJECT) → 차단!

해결 방법:

Terminal window
# AWS 콘솔: VPC → Network ACLs → Outbound Rules 확인
# Outbound Ephemeral Port 범위 허용이 필요:
# Rule #100: Allow All Outbound (권장)
# Protocol: All, Port Range: All, CIDR: 0.0.0.0/0, Action: Allow
# 또는 최소 권한으로: Ephemeral Port 허용
# Protocol: TCP, Port Range: 1024-65535, Action: Allow
# VPC Flow Logs로 REJECT 원인 확인
aws logs filter-log-events \
--log-group-name /aws/vpc/flow-logs \
--filter-pattern "REJECT"

핵심: Security Group은 Stateful(응답 자동 허용)이지만, NACL은 Stateless(인바운드/아웃바운드 각각 설정 필요)다. 연결이 안 될 때 NACL Outbound 룰을 먼저 확인한다.


문제 3: MTU/MSS 불일치로 인한 패킷 손실

섹션 제목: “문제 3: MTU/MSS 불일치로 인한 패킷 손실”

증상:

Terminal window
# 소규모 요청은 성공하지만 대용량 응답(파일 다운로드 등)에서만 실패
# 또는 VPN/터널링 환경에서 특정 크기 이상의 패킷이 손실
# ping으로 확인 가능
ping -s 1472 -M do 10.0.0.1 # MTU 1500 - IP헤더(20) - ICMP헤더(8) = 1472

원인:

  • VPN이나 터널링(IPSec, GRE) 사용 시 추가 헤더가 붙어 실제 MTU가 줄어듦
  • 예: VPN 오버헤드 50B → 실제 MTU = 1500 - 50 = 1450, MSS = 1410
  • Path MTU Discovery가 방화벽에 의해 차단된 경우 (ICMP Type 3 Code 4 블록)

해결 방법:

Terminal window
# 현재 MTU 확인
ip link show eth0
# MTU 조정 (VPN 환경에서 흔함)
sudo ip link set eth0 mtu 1400
# MSS Clamping (iptables로 강제 조정)
sudo iptables -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu
# Path MTU Discovery 테스트 (DF bit 설정)
ping -s 1472 -M do <목적지 IP>
# 실패 시 ICMP "Frag needed" 메시지 확인

예상 출력:

# MTU 문제가 있는 경우
From 10.0.0.1 icmp_seq=1 Frag needed and DF set (mtu = 1400)
# → MTU를 1400 이하로 줄여야 함

문제 5: Nagle + Delayed ACK로 인한 40~500ms 랜덤 지연

섹션 제목: “문제 5: Nagle + Delayed ACK로 인한 40~500ms 랜덤 지연”

증상:

# API 응답 시간이 불규칙적으로 40~500ms 추가됨
# 특히 작은 크기의 요청/응답에서 발생
# 대용량 전송은 정상, 소량 다건 전송에서 느림
# tcpdump로 확인 시 ACK 지연이 보임
sudo tcpdump -i eth0 port 8080 -ttt
# 00:00:00.000000 > Flags [P.], seq 1:101, length 100 ← 데이터 전송
# 00:00:00.040123 < Flags [.], ack 101 ← 40ms 후 ACK (지연!)

원인:

Nagle 알고리즘(송신 측)이 이전 데이터의 ACK를 기다리면서 추가 데이터를 버퍼링하고, Delayed ACK(수신 측)가 ACK 전송을 40ms까지 미루는 교착 상태. write(header)write(body) 패턴으로 두 번 나눠 보내는 코드에서 빈번하게 발생한다.

해결 방법:

// 방법 1: TCP_NODELAY로 Nagle 비활성화 (권장)
const server = app.getHttpServer();
server.on("connection", (socket: net.Socket) => {
socket.setNoDelay(true);
});
// 방법 2: 데이터를 한 번에 보내기 (코드 레벨)
// Bad: 두 번 write → Nagle 트리거
res.write(header);
res.write(body);
// Good: 한 번에 write → Nagle 영향 없음
res.write(Buffer.concat([header, body]));
Terminal window
# 현재 서버의 TCP_NODELAY 설정 확인 (strace)
strace -e setsockopt -p $(pgrep -f "node") 2>&1 | grep TCP_NODELAY
# setsockopt(12, SOL_TCP, TCP_NODELAY, [1], 4) = 0 ← 활성화됨

핵심: HTTP API 서버에서는 거의 항상 TCP_NODELAY를 켜야 한다. 1980년대 저대역폭 환경을 위한 Nagle 알고리즘은 현대 서버 환경에서는 오히려 해가 된다.


문제 6: ALB 뒤 Nest.js에서 간헐적 502 Bad Gateway

섹션 제목: “문제 6: ALB 뒤 Nest.js에서 간헐적 502 Bad Gateway”

증상:

Terminal window
# CloudWatch에서 ALB TargetResponseTime이 간헐적으로 급증
# ALB HTTP 5xx 에러 카운트 증가
# 서버 로그에는 아무 에러가 없음 (서버는 정상이라고 인식)
# ALB Access Log에서 확인:
# elb_status_code=502 target_status_code=-
# → "-"는 ALB가 타겟으로부터 응답을 받지 못했다는 의미

원인:

Node.js의 기본 keepAliveTimeout은 5초다. ALB의 기본 idle timeout은 60초다. ALB가 60초 이내에 기존 연결을 재사용하려 하지만, Node.js는 이미 5초 후에 연결을 닫아버린 상태다. ALB가 닫힌 소켓에 요청을 보내면 ECONNRESET → 502가 발생한다.

해결 방법:

main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const server = app.getHttpServer();
// 핵심: ALB idle timeout(60s)보다 반드시 길게 설정
server.keepAliveTimeout = 65000; // 65초
server.headersTimeout = 66000; // keepAliveTimeout + 1초 (필수)
await app.listen(3000);
}
Terminal window
# ALB idle timeout 확인
aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn YOUR_ALB_ARN \
--query 'Attributes[?Key==`idle_timeout.timeout_seconds`].Value'
# 예상 출력: ["60"]
# ALB 502 에러 모니터링
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_502_Count \
--dimensions Name=LoadBalancer,Value=YOUR_ALB_NAME \
--start-time $(date -u -v-1H +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 300 --statistics Sum

핵심: keepAliveTimeout > ALB idle timeout > headersTimeout 순서가 아니라, headersTimeout > keepAliveTimeout > ALB idle timeout 순서로 설정해야 한다. headersTimeoutkeepAliveTimeout보다 짧으면 Node.js 내부에서 헤더 파싱 타임아웃이 먼저 발생한다.


  • 3-way handshake의 각 단계 (SYN, SYN-ACK, ACK)를 설명할 수 있다
  • 4-way handshake(연결 종료)와 TIME_WAIT가 왜 필요한지 설명할 수 있다
  • Sliding Window(흐름 제어)와 Congestion Control의 차이를 구분한다
  • Slow Start → Congestion Avoidance → Fast Retransmit → Fast Recovery 흐름을 그릴 수 있다
  • cwndssthresh의 역할을 설명할 수 있다
  • 패킷 손실 시 cwnd가 어떻게 변하는지 설명할 수 있다
  • UDP가 TCP보다 빠른 3가지 이유를 말할 수 있다
  • QUIC이 TCP의 어떤 문제를 해결하는지 설명할 수 있다
  • Head-of-Line Blocking이 무엇인지 설명할 수 있다
  • netstat -an | grep TIME_WAIT으로 소켓 상태를 확인할 수 있다
  • ALB와 NLB의 차이를 설명하고 언제 어느 것을 써야 할지 판단할 수 있다
  • Nest.js에서 keepAliveTimeout을 올바르게 설정할 수 있다

키워드설명
3-Way HandshakeTCP 연결 수립 과정 (SYN → SYN-ACK → ACK)
Sliding Window수신자 버퍼 기반 흐름 제어 메커니즘
cwndCongestion Window — 혼잡 제어용 송신 윈도우
ssthreshSlow Start Threshold — Slow Start 종료 임계값
Slow Start초기 cwnd를 1 MSS부터 지수적으로 증가
CUBICLinux 기본 혼잡 제어 알고리즘
BBRGoogle이 개발한 대역폭 기반 혼잡 제어 알고리즘
TIME_WAIT연결 종료 후 2MSL 대기 상태
MSLMaximum Segment Lifetime (60초)
MSSMaximum Segment Size = MTU - IP헤더 - TCP헤더 = 1460 bytes
MTUMaximum Transmission Unit = 1500 bytes (Ethernet)
QUICUDP 기반 신뢰성 전송 프로토콜 (HTTP/3의 기반)
Head-of-Line Blocking하나의 패킷 손실이 전체 스트림을 블록하는 현상
Fast Retransmit3회 중복 ACK 수신 시 즉시 재전송
NLBAWS Network Load Balancer — L4에서 TCP/UDP 처리
ALBAWS Application Load Balancer — L7에서 HTTP 처리
Nagle Algorithm작은 패킷을 모아 한꺼번에 전송하는 송신 최적화
TCP_NODELAYNagle 알고리즘을 비활성화하는 소켓 옵션
Delayed ACKACK 전송을 최대 40~500ms 지연하는 수신 최적화
TCP KeepaliveL4에서 유휴 연결의 생존 여부를 확인하는 프로브 메커니즘
HTTP Keep-AliveL7에서 TCP 연결을 재사용하여 핸드셰이크를 절약하는 기능


Terminal window
# 터미널 1: tcpdump로 로컬 트래픽 캡처
sudo tcpdump -i lo port 8080 -S -nn
# 터미널 2: curl로 HTTP 요청
curl -v http://localhost:8080/health

예상 출력 (tcpdump):

# SYN
14:30:22.100 127.0.0.1.54321 > 127.0.0.1.8080: Flags [S], seq 1000000000, win 65495
# SYN-ACK
14:30:22.100 127.0.0.1.8080 > 127.0.0.1.54321: Flags [S.], seq 2000000000, ack 1000000001, win 65483
# ACK (연결 수립)
14:30:22.100 127.0.0.1.54321 > 127.0.0.1.8080: Flags [.], ack 2000000001, win 512

실습 2: TIME_WAIT 상태 확인 및 모니터링

섹션 제목: “실습 2: TIME_WAIT 상태 확인 및 모니터링”
Terminal window
# TIME_WAIT 소켓 수 확인
ss -tan | grep TIME-WAIT | wc -l
# TIME_WAIT 소켓 상세 목록 (처음 10개)
ss -tan state time-wait | head -10
# 연속 모니터링 (1초마다)
watch -n 1 'ss -s | grep -i time'

예상 출력:

# ss -tan state time-wait
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
tcp TIME-WAIT 0 0 0.0.0.0:8080 192.168.1.100:54321
# watch -n 1 'ss -s'
TCP: 856 (estab 12, closed 823, orphaned 0, timewait 823)

실습 3: TCP 혼잡 제어 알고리즘 확인 및 변경

섹션 제목: “실습 3: TCP 혼잡 제어 알고리즘 확인 및 변경”
Terminal window
# 현재 혼잡 제어 알고리즘 확인
sysctl net.ipv4.tcp_congestion_control
# 사용 가능한 알고리즘 목록
sysctl net.ipv4.tcp_available_congestion_control
# BBR로 변경 (성능 향상을 위해)
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# BBR 사용 확인
sysctl net.ipv4.tcp_congestion_control

예상 출력:

net.ipv4.tcp_congestion_control = cubic
net.ipv4.tcp_available_congestion_control = reno cubic bbr
# BBR 변경 후
net.ipv4.tcp_congestion_control = bbr

실습 4: netstat으로 포트/연결 상태 전체 확인

섹션 제목: “실습 4: netstat으로 포트/연결 상태 전체 확인”
Terminal window
# 모든 TCP 연결 상태 요약
ss -s
# 특정 포트(8080)의 연결 상태별 수
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# ESTABLISHED 연결만 확인
ss -tan state established
# 연결 많은 IP 상위 10개
netstat -an | grep ESTABLISHED | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -10

예상 출력:

# ss -s
Total: 1247
TCP: 987 (estab 45, closed 897, orphaned 0, timewait 897)
# 상태별 수
897 TIME-WAIT
45 ESTABLISHED
12 LISTEN
8 FIN-WAIT-1

실습 5: TCP Keepalive 설정 확인 및 Nagle 테스트

섹션 제목: “실습 5: TCP Keepalive 설정 확인 및 Nagle 테스트”
Terminal window
# TCP Keepalive 커널 파라미터 확인
sysctl net.ipv4.tcp_keepalive_time
sysctl net.ipv4.tcp_keepalive_intvl
sysctl net.ipv4.tcp_keepalive_probes
# 예상 출력:
# net.ipv4.tcp_keepalive_time = 7200 ← 2시간 유휴 후 프로브 시작
# net.ipv4.tcp_keepalive_intvl = 75 ← 75초 간격으로 프로브
# net.ipv4.tcp_keepalive_probes = 9 ← 9회 실패 시 연결 종료
# Nagle 알고리즘 영향 확인 (strace로 소켓 옵션 추적)
strace -e setsockopt -p $(pgrep -f "node") 2>&1 | grep -E "NODELAY|KEEPALIVE"
# 예상 출력 (TCP_NODELAY가 설정된 경우):
# setsockopt(12, SOL_TCP, TCP_NODELAY, [1], 4) = 0

실습 6: ALB keepAliveTimeout 점검 스크립트

섹션 제목: “실습 6: ALB keepAliveTimeout 점검 스크립트”
Terminal window
# Nest.js 서버의 keepAliveTimeout이 ALB보다 긴지 확인
# 1. ALB idle timeout 확인
ALB_TIMEOUT=$(aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn YOUR_ALB_ARN \
--query 'Attributes[?Key==`idle_timeout.timeout_seconds`].Value' \
--output text)
echo "ALB idle timeout: ${ALB_TIMEOUT}s"
# 2. Node.js keepAliveTimeout 확인 (프로세스에 접속)
curl -s http://localhost:3000/health -w "\n" -o /dev/null
# 그 후 ss로 연결 상태 확인
ss -tnp | grep 3000
# 예상 출력:
# ALB idle timeout: 60s
# ESTAB 0 0 10.0.1.5:3000 10.0.0.1:54321 users:(("node",pid=1234,fd=12))

TCP는 “느리더라도 반드시 도착”을 보장하는 신뢰성 우선 프로토콜이고, UDP는 “빠르게 쏘고 신뢰성은 앱에서 처리”하는 속도 우선 프로토콜이다. Slow Start는 네트워크를 조심스럽게 탐색하는 예의 바른 시작이며, TIME_WAIT은 마지막 ACK를 놓쳤을 때를 대비한 안전망이다.