TCP/UDP Internals
TCP/UDP 심화: 동작 원리와 실무 적용
섹션 제목: “TCP/UDP 심화: 동작 원리와 실무 적용”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”TCP는 “연결 지향 + 신뢰성 보장” 프로토콜이고, UDP는 “비연결 + 속도 우선” 프로토콜이다. 둘 다 IP 위에서 동작하는 전송 계층(Transport Layer) 프로토콜이며, 어떤 것을 선택하느냐에 따라 시스템의 성능과 안정성이 근본적으로 달라진다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”- 모든 네트워크 통신의 기반: 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. 핵심 개념
섹션 제목: “3. 핵심 개념”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 Layer5. 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)디캡슐화 (수신 측): 수신 측에서 각 계층이 자신의 헤더를 벗겨내며 데이터를 위 계층으로 전달
# 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 handshakeSSL: 18ms ← TLS handshakeWaiting (TTFB): 45ms ← 서버 처리 + 응답 첫 바이트 도달Content Download: 8ms ← 실제 데이터 전송Total: 106msNode.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 연결 재사용 → 핸드셰이크 생략# 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 헤더 관계 설명 (입문)
3-2. TCP 3-Way Handshake
섹션 제목: “3-2. TCP 3-Way Handshake”전화 통화를 시작하는 과정과 동일하다.
- 클라이언트: “여보세요? (SYN)” — 연결 요청
- 서버: “네, 들립니다. 저도 들리시나요? (SYN-ACK)” — 연결 수락 + 역방향 연결 요청
- 클라이언트: “네, 들립니다 (ACK)” — 역방향 연결 수락
클라이언트 서버 | | |──── SYN (seq=100) ──────────→ | LISTEN 상태 | | | ←── SYN-ACK (seq=200, ack=101)─| SYN_RECEIVED | | |──── ACK (ack=201) ───────────→ | ESTABLISHED | | | 데이터 전송 시작 |# 3-way handshake 확인sudo tcpdump -i lo port 8080 -S예상 출력:
# S = SYN, . = ACK, P = PUSH14:30:22 IP client.54321 > server.8080: Flags [S], seq 0, win 6553514:30:22 IP server.8080 > client.54321: Flags [S.], seq 0, ack 1, win 6553514:30:22 IP client.54321 > server.8080: Flags [.], ack 1, win 655353-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 + rwndMSS와 MTU의 관계:
- MTU (Maximum Transmission Unit): 네트워크 계층에서 한 번에 전송 가능한 최대 프레임 크기. Ethernet 기준 1500 bytes
- MSS (Maximum Segment Size): TCP 헤더(20B) + IP 헤더(20B)를 뺀 데이터 부분 최대 크기 = 1460 bytes
# 현재 네트워크 인터페이스의 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 03-4. TCP Congestion Control (혼잡 제어)
섹션 제목: “3-4. TCP Congestion Control (혼잡 제어)”고속도로 진입 방식에 비유하자. 도로(네트워크)가 얼마나 막혔는지 모르는 상태에서 차를 한꺼번에 쏟아내면 교통 체증이 발생한다. 그래서 처음에는 차 1대씩 보내고(Slow Start), 막히지 않으면 2대, 4대, 8대로 늘린다. 교통 체증이 생기면(패킷 손실) 다시 속도를 줄인다.
원리: 4단계 알고리즘
섹션 제목: “원리: 4단계 알고리즘”핵심 변수:
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# Linux에서 TCP 혼잡 제어 알고리즘 확인sysctl net.ipv4.tcp_congestion_control
# 사용 가능한 알고리즘 목록sysctl net.ipv4.tcp_available_congestion_control예상 출력:
net.ipv4.tcp_congestion_control = cubicnet.ipv4.tcp_available_congestion_control = reno cubic bbr참고: Linux의 기본 혼잡 제어 알고리즘은 CUBIC이며, Google이 개발한 **BBR(Bottleneck Bandwidth and RTT)**은 더 효율적인 대역폭 활용으로 주목받고 있다.
3-5. TCP 상태 다이어그램
섹션 제목: “3-5. TCP 상태 다이어그램”전화 통화의 라이프사이클과 같다: 대기 → 연결 중 → 통화 중 → 끊는 중 → 완전히 끊김
원리: TCP 상태 전환
섹션 제목: “원리: TCP 상태 전환”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 대기) ▼ CLOSED3-6. TIME_WAIT가 왜 필요한가 (2MSL)
섹션 제목: “3-6. TIME_WAIT가 왜 필요한가 (2MSL)”이사를 나온 후에도 혹시 모를 우편물을 위해 잠깐 기다리는 것과 같다. 연결이 완전히 종료됐음을 확신하기 전까지 잠시 대기하는 상태다.
원리: 2가지 이유
섹션 제목: “원리: 2가지 이유”이유 1: 마지막 ACK가 유실됐을 경우 대비
클라이언트 (TIME_WAIT) 서버 │ │ │ ←── FIN ───────────────── │ │ │ │ ──── ACK ────────────────→ │ ← 이 ACK가 유실되면? │ (TIME_WAIT 진입) │ │ │ ← 서버는 FIN을 재전송 │ ←── FIN (재전송) ────────── │ │ │ │ ──── ACK ────────────────→ │ ← 재전송에 응답 │ │ │ (2MSL 후 CLOSED) │이유 2: 옛 세그먼트(Duplicate Segment) 방지
- 같은 포트 번호로 새 연결이 생겼을 때, 이전 연결의 오래된 패킷이 뒤늦게 도착하는 것을 막는다.
- MSL (Maximum Segment Lifetime): 네트워크에서 세그먼트가 살아있을 수 있는 최대 시간 = 60초
- 2MSL = 120초: 이 시간이 지나면 이전 연결의 모든 패킷이 소멸됐다고 보장
# 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:PortTIME-WAIT 0 0 10.0.1.5:8080 10.0.0.1:54321TIME-WAIT 0 0 10.0.1.5:8080 10.0.0.2:543223-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초 대기)CLOSEDCLOSE_WAIT이 쌓이는 상황 (BackOps에서 자주 발생):
# 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 closeTCP: 45 (estab 12, closed 28, orphaned 0, timewait 28)
# 비정상 서버 (CLOSE_WAIT 누수)$ ss -s | grep -i closeTCP: 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);3-7. UDP: 왜 빠른가
섹션 제목: “3-7. UDP: 왜 빠른가”등기우편(TCP)과 일반 전단지 배포(UDP)의 차이다. 등기우편은 수신 확인, 서명, 분실 시 재발송까지 해주지만 느리다. 전단지는 그냥 던지고 끝이지만 빠르다.
원리: UDP가 TCP보다 빠른 이유
섹션 제목: “원리: UDP가 TCP보다 빠른 이유”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 | 오래된 프레임은 버리는 것이 나음 |
| 온라인 게임 | UDP | 1ms 지연 차이가 중요 |
| DHCP | UDP | 브로드캐스트 기반 |
| 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)3-8. QUIC (HTTP/3): UDP 위의 신뢰성
섹션 제목: “3-8. QUIC (HTTP/3): UDP 위의 신뢰성”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) 연결 유지
# 사이트가 HTTP/3를 지원하는지 확인curl -I --http3 https://www.google.com 2>/dev/null | head -5
# 또는 Chrome DevTools Network 탭에서 Protocol 열 확인예상 출력:
HTTP/3 200content-type: text/html; charset=UTF-8alt-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 허용 설정:
# 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에서의 대응: SYN Cookie
섹션 제목: “AWS에서의 대응: SYN Cookie”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 대응 |
|---|---|
| ALB | AWS Shield Standard 자동 적용. SYN Proxy 내장 |
| NLB | TCP 리스너는 방어 취약 (NLB가 TCP를 그대로 전달) → Global Accelerator 추가 권장 |
| NLB (TLS 리스너) | TLS 종단에서 SYN 흡수 가능 |
| CloudFront | 엣지에서 흡수. Origin까지 도달하지 않음 |
# 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:00ZBackOps 실무 팁: NLB + TCP 리스너 조합은 SYN Flood에 취약하다. 외부 트래픽을 NLB로 직접 받는다면 AWS Shield Advanced나 Global Accelerator를 앞단에 배치해야 한다. ALB는 Shield Standard만으로도 대부분 방어된다.
3-9. BBR: 왜 CUBIC보다 빠른가
섹션 제목: “3-9. BBR: 왜 CUBIC보다 빠른가”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가지 페이즈:
- Startup: 대역폭이 2배씩 증가 (CUBIC Slow Start와 유사)
- Drain: Startup에서 채운 큐를 비우는 단계
- ProbeBW: 주기적으로 대역폭 탐색. 대부분의 시간을 여기서 보냄
- ProbeRTT: 최소 RTT를 재측정하기 위해 잠시 전송률을 낮춤
AWS/GCP에서의 BBR 적용:
# EC2 인스턴스에서 BBR 활성화sudo modprobe tcp_bbrsudo sysctl -w net.ipv4.tcp_congestion_control=bbrsudo sysctl -w net.core.default_qdisc=fq # BBR 필수 설정
# 영구 적용 (/etc/sysctl.conf)net.core.default_qdisc=fqnet.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 확인:
# 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 200content-type: text/html; charset=UTF-8
# Alt-Svc 헤더 (브라우저에 HTTP/3 지원을 알림)alt-svc: h3=":443"; ma=86400AWS에서 HTTP/3 활성화:
# 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의 불필요한 지연이 추가된다.
해결: TCP_NODELAY
섹션 제목: “해결: TCP_NODELAY”// 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();# 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 Keepalive | HTTP Keep-Alive |
|---|---|---|
| 계층 | Transport Layer (L4) | Application Layer (L7) |
| 목적 | 연결이 살아있는지 확인 (프로브 패킷 전송) | TCP 연결을 재사용하여 핸드셰이크 절약 |
| 동작 | 일정 시간 유휴 후 빈 ACK 패킷을 주기적 전송 | 하나의 TCP 연결로 여러 HTTP 요청/응답 |
| 기본값 (Linux) | 7200초 후 시작, 75초 간격, 9회 시도 | HTTP/1.1에서 기본 활성화 |
| 설정 | OS 커널 파라미터 (sysctl) | HTTP 헤더 + 서버 타임아웃 설정 |
ALB 502 에러의 근본 원인
섹션 제목: “ALB 502 에러의 근본 원인”시간 흐름 →
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초 유휴 후 프로브 시작});# 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. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”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, gRPC | TCP, UDP, TLS |
| 라우팅 기준 | URL, 헤더, 쿠키 | IP + Port |
| 지연시간 | 상대적으로 높음 | 초저지연 (us 단위) |
| 정적 IP | 미지원 | 지원 (Elastic IP) |
| WebSocket | 지원 | 지원 |
| UDP | 미지원 | 지원 |
| 클라이언트 IP 보존 | X-Forwarded-For 헤더 | 직접 보존 |
언제 NLB를 선택해야 하는가:
- UDP 프로토콜이 필요한 경우 (DNS, syslog, 게임 서버)
- 정적 IP가 필요한 경우 (화이트리스트 IP 등록 등)
- 극도로 낮은 지연시간이 필요한 경우
- TLS Passthrough (백엔드에서 직접 TLS 처리)
4-2. Nest.js에서의 TCP 소켓 설정
섹션 제목: “4-2. Nest.js에서의 TCP 소켓 설정”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();5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”BackOps + Nest.js + AWS 스택 기준:
-
ALB keepAliveTimeout 설정: ALB의 기본 idle timeout은 60초인데, Nest.js의
keepAliveTimeout이 60초 이하면 ALB가 살아있는 연결을 닫기 전에 서버가 먼저 닫아서502 Bad Gateway가 발생한다. →keepAliveTimeout = 65000으로 설정 필수 -
DB Connection Pool과 TCP: TypeORM/Prisma의 connection pool은 TCP 연결을 재사용한다. pool이 부족하면 새 TCP 연결(3-way handshake)이 추가되고, 너무 많으면 DB 서버의 소켓 제한에 걸린다.
-
Lambda + RDS: Lambda는 새 인스턴스마다 새 TCP 연결을 만들어 RDS에 연결하므로, 동시 실행 수가 높아지면 RDS의
max_connections를 초과할 수 있다. → RDS Proxy(TCP 연결 풀링)가 해결책 -
TIME_WAIT 모니터링: 트래픽이 많은 API 서버에서
netstat -an | grep TIME_WAIT로 수천 개가 보이면 비정상은 아니지만,net.ipv4.tcp_tw_reuse = 1설정을 검토해야 한다.
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”TCP vs UDP 전체 비교
섹션 제목: “TCP vs UDP 전체 비교”| 항목 | TCP | UDP |
|---|---|---|
| 연결 방식 | 연결 지향 (3-way handshake) | 비연결 |
| 신뢰성 | 보장 (재전송, ACK) | 미보장 |
| 순서 보장 | 보장 | 미보장 |
| 흐름 제어 | 있음 (Sliding Window) | 없음 |
| 혼잡 제어 | 있음 (Slow Start 등) | 없음 |
| 헤더 크기 | 20~60 bytes | 8 bytes |
| 지연시간 | 높음 | 낮음 |
| 처리량 | 낮음 (제어 오버헤드) | 높음 |
| 사용 사례 | HTTP, FTP, SSH, DB | DNS, 스트리밍, 게임, QUIC |
Flow Control vs Congestion Control
섹션 제목: “Flow Control vs Congestion Control”| 항목 | Flow Control (흐름 제어) | Congestion Control (혼잡 제어) |
|---|---|---|
| 목적 | 수신자 버퍼 보호 | 네트워크 혼잡 방지 |
| 제어 주체 | 수신자가 rwnd로 제어 | 송신자가 cwnd로 자체 제어 |
| 정보 출처 | 수신자의 버퍼 상태 | 패킷 손실/RTT 변화 |
| 알고리즘 | Sliding Window | Slow Start, CUBIC, BBR |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”문제 1: “Connection reset by peer”
섹션 제목: “문제 1: “Connection reset by peer””증상:
Error: read ECONNRESET at TCP.onStreamRead (internal/stream_base_commons.js:209:20)# 또는 curl에서:curl: (56) Recv failure: Connection reset by peer원인:
- 상대방이
RST패킷을 보내 강제로 연결을 끊은 상태 - 주로 발생하는 상황:
- 서버가 재시작되거나 프로세스가 죽었는데 클라이언트는 연결을 유지하려 할 때
- 방화벽/로드밸런서가 idle timeout으로 연결을 끊었는데 (ALB 기본 60초) 서버는 해당 연결로 요청을 보낼 때
- 서버가
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,}# 서버 측 RST 발생 확인sudo tcpdump -i eth0 'tcp[tcpflags] & tcp-rst != 0'문제 2: TIME_WAIT 폭증
섹션 제목: “문제 2: TIME_WAIT 폭증”증상:
$ netstat -an | grep TIME_WAIT | wc -l18432 # 수만 개의 TIME_WAIT 소켓
$ ss -sTCP: 18945 (estab 1023, closed 17891, orphaned 0, timewait 17891)원인:
- API 서버가 HTTP keep-alive 없이 매 요청마다 새 TCP 연결을 생성하고 종료하는 경우
- 단시간에 많은 short-lived connection이 생성될 때
- HTTP 클라이언트에
Connection: close헤더가 붙어있는 경우
해결 방법:
# 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 = 1net.ipv4.tcp_fin_timeout = 30net.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 폭증 — 애플리케이션 소켓 누수”증상:
# CLOSE_WAIT 소켓이 계속 쌓이고 줄어들지 않음$ ss -tn state close-wait | wc -l4823 # 비정상적으로 높은 수치
# 시간이 지나도 줄어들지 않음 (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% 애플리케이션 코드 버그다. 소켓을 받아 쓰고 닫지 않은 것이다. 대표적인 원인:
- HTTP 클라이언트가 응답을 받은 뒤 연결을 닫지 않음 (예: axios keep-alive pool)
- TypeORM / Prisma 쿼리 타임아웃 후 연결이 풀로 반환되지 않음
- WebSocket 연결이 클라이언트 측에서 끊어졌는데 서버가 이를 감지하지 못함
해결 방법:
# 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 연결 차단”증상:
# 서버 내부에서는 정상 동작하는데 외부에서 접근 불가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: 허용) → EC2EC2 → (Outbound 응답: Ephemeral Port 50000 → NACL REJECT) → 차단!해결 방법:
# 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 불일치로 인한 패킷 손실”증상:
# 소규모 요청은 성공하지만 대용량 응답(파일 다운로드 등)에서만 실패# 또는 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 블록)
해결 방법:
# 현재 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]));# 현재 서버의 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”증상:
# 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가 발생한다.
해결 방법:
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);}# 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순서로 설정해야 한다.headersTimeout이keepAliveTimeout보다 짧으면 Node.js 내부에서 헤더 파싱 타임아웃이 먼저 발생한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”TCP 기초 이해
섹션 제목: “TCP 기초 이해”- 3-way handshake의 각 단계 (SYN, SYN-ACK, ACK)를 설명할 수 있다
- 4-way handshake(연결 종료)와 TIME_WAIT가 왜 필요한지 설명할 수 있다
- Sliding Window(흐름 제어)와 Congestion Control의 차이를 구분한다
Congestion Control
섹션 제목: “Congestion Control”- Slow Start → Congestion Avoidance → Fast Retransmit → Fast Recovery 흐름을 그릴 수 있다
-
cwnd와ssthresh의 역할을 설명할 수 있다 - 패킷 손실 시 cwnd가 어떻게 변하는지 설명할 수 있다
UDP/QUIC
섹션 제목: “UDP/QUIC”- UDP가 TCP보다 빠른 3가지 이유를 말할 수 있다
- QUIC이 TCP의 어떤 문제를 해결하는지 설명할 수 있다
- Head-of-Line Blocking이 무엇인지 설명할 수 있다
실무 적용
섹션 제목: “실무 적용”-
netstat -an | grep TIME_WAIT으로 소켓 상태를 확인할 수 있다 - ALB와 NLB의 차이를 설명하고 언제 어느 것을 써야 할지 판단할 수 있다
- Nest.js에서 keepAliveTimeout을 올바르게 설정할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 설명 |
|---|---|
| 3-Way Handshake | TCP 연결 수립 과정 (SYN → SYN-ACK → ACK) |
| Sliding Window | 수신자 버퍼 기반 흐름 제어 메커니즘 |
| cwnd | Congestion Window — 혼잡 제어용 송신 윈도우 |
| ssthresh | Slow Start Threshold — Slow Start 종료 임계값 |
| Slow Start | 초기 cwnd를 1 MSS부터 지수적으로 증가 |
| CUBIC | Linux 기본 혼잡 제어 알고리즘 |
| BBR | Google이 개발한 대역폭 기반 혼잡 제어 알고리즘 |
| TIME_WAIT | 연결 종료 후 2MSL 대기 상태 |
| MSL | Maximum Segment Lifetime (60초) |
| MSS | Maximum Segment Size = MTU - IP헤더 - TCP헤더 = 1460 bytes |
| MTU | Maximum Transmission Unit = 1500 bytes (Ethernet) |
| QUIC | UDP 기반 신뢰성 전송 프로토콜 (HTTP/3의 기반) |
| Head-of-Line Blocking | 하나의 패킷 손실이 전체 스트림을 블록하는 현상 |
| Fast Retransmit | 3회 중복 ACK 수신 시 즉시 재전송 |
| NLB | AWS Network Load Balancer — L4에서 TCP/UDP 처리 |
| ALB | AWS Application Load Balancer — L7에서 HTTP 처리 |
| Nagle Algorithm | 작은 패킷을 모아 한꺼번에 전송하는 송신 최적화 |
| TCP_NODELAY | Nagle 알고리즘을 비활성화하는 소켓 옵션 |
| Delayed ACK | ACK 전송을 최대 40~500ms 지연하는 수신 최적화 |
| TCP Keepalive | L4에서 유휴 연결의 생존 여부를 확인하는 프로브 메커니즘 |
| HTTP Keep-Alive | L7에서 TCP 연결을 재사용하여 핸드셰이크를 절약하는 기능 |
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 MDN: TCP Slow Start — 짧고 명확한 Congestion Control 개념 정리. TCP 처음 이해할 때 가장 먼저 볼 것. (입문)
- 🎬 TCP Course — Hussein Nasser (YouTube) — TCP vs UDP 비교, 3-Way Handshake, Wireshark 분석까지 3.5시간 분량의 실전 강의. BackOps 엔지니어가 TCP 내부를 한 번에 정리하기에 가장 적합. (입문~중급)
- 📖 It’s always TCP_NODELAY. Every damn time. — Marc Brooker’s Blog — AWS 수석 엔지니어가 정리한 Nagle 알고리즘과 TCP_NODELAY의 실무적 중요성. BackOps 필수. (중급)
- 📖 Troubleshoot Application Load Balancer HTTP 502 Errors — AWS re:Post — ALB 502 에러의 모든 원인과 해결책. keepAliveTimeout 설정이 핵심임을 공식 문서로 확인. (중급)
- 📖 NestJS: Keep-Alive Connections 공식 문서 — Nest.js에서 HTTP Keep-Alive, keepAliveTimeout 설정 방법 공식 가이드. ALB 502 방지 핵심 설정 포함. (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: TCP 3-way handshake 캡처
섹션 제목: “실습 1: TCP 3-way handshake 캡처”# 터미널 1: tcpdump로 로컬 트래픽 캡처sudo tcpdump -i lo port 8080 -S -nn
# 터미널 2: curl로 HTTP 요청curl -v http://localhost:8080/health예상 출력 (tcpdump):
# SYN14:30:22.100 127.0.0.1.54321 > 127.0.0.1.8080: Flags [S], seq 1000000000, win 65495
# SYN-ACK14: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 상태 확인 및 모니터링”# 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-waitNetid State Recv-Q Send-Q Local Address:Port Peer Address:Porttcp 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 혼잡 제어 알고리즘 확인 및 변경”# 현재 혼잡 제어 알고리즘 확인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 = cubicnet.ipv4.tcp_available_congestion_control = reno cubic bbr
# BBR 변경 후net.ipv4.tcp_congestion_control = bbr실습 4: netstat으로 포트/연결 상태 전체 확인
섹션 제목: “실습 4: netstat으로 포트/연결 상태 전체 확인”# 모든 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 -sTotal: 1247TCP: 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 테스트”# TCP Keepalive 커널 파라미터 확인sysctl net.ipv4.tcp_keepalive_timesysctl net.ipv4.tcp_keepalive_intvlsysctl 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 점검 스크립트”# 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))10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”TCP는 “느리더라도 반드시 도착”을 보장하는 신뢰성 우선 프로토콜이고, UDP는 “빠르게 쏘고 신뢰성은 앱에서 처리”하는 속도 우선 프로토콜이다. Slow Start는 네트워크를 조심스럽게 탐색하는 예의 바른 시작이며, TIME_WAIT은 마지막 ACK를 놓쳤을 때를 대비한 안전망이다.