콘텐츠로 이동

TCP 흐름/혼잡 제어 심화

L2 선수 지식: 3-way handshake, rwnd 기반 Sliding Window 기초, MSS/MTU 개념은 L2(tcp-udp-internals.md)에서 다뤘다. 이 문서는 그 위에서 “왜 네트워크가 느려지는가”와 “어떻게 튜닝하는가”를 다룬다.


흐름 제어(Flow Control) 는 수신자 버퍼 overflow를 막는 메커니즘이고, 혼잡 제어(Congestion Control) 는 네트워크 중간 경로(라우터·스위치)의 overflow를 막는 메커니즘이다. 둘 다 “얼마나 빨리 보낼 수 있는가”를 결정하지만, 바라보는 대상이 다르다.


  • TTFB 직결: SSR 서버가 클라이언트에게 첫 바이트를 보내는 속도는 TCP의 초기 cwnd(혼잡 윈도우)에 따라 좌우된다. 서버 코드가 빠르더라도 TCP가 느리게 시작하면 사용자는 빈 화면을 본다.
  • 클라우드 환경 필수: AWS EC2 간 통신, EKS Pod 간 통신, RDS 연결 모두 TCP 위에서 동작한다. 혼잡 제어 알고리즘과 소켓 버퍼 설정은 throughput과 latency에 직접적 영향을 미친다.
  • 장애 원인 분석: ss -ti 출력에서 retrans, rtt, cwnd 값을 읽지 못하면 네트워크 병목을 구분할 수 없다.

3. Sliding Window 심화: rwnd와 cwnd의 관계

섹션 제목: “3. Sliding Window 심화: rwnd와 cwnd의 관계”

3-1. 두 개의 윈도우가 동시에 작동한다

섹션 제목: “3-1. 두 개의 윈도우가 동시에 작동한다”

L2에서 rwnd(receive window)만 배웠지만, 실제 송신 속도는 두 윈도우 중 작은 값으로 결정된다.

실제 전송 가능량 = min(rwnd, cwnd)
rwnd: 수신자가 "내 버퍼 여유 공간이 이만큼이야"라고 알려주는 값 (수신자 제어)
cwnd: 송신자가 "네트워크가 이 정도는 견딜 수 있어"라고 스스로 추정하는 값 (송신자 제어)
  • rwnd 는 TCP 헤더의 Window Size 필드(16bit, 최대 65535 bytes, Window Scaling 옵션으로 최대 1GB까지 확장)로 전달된다.
  • cwnd 는 운영체제 커널이 내부적으로 관리하는 값이며, 패킷 손실과 ACK 수신 패턴에 따라 동적으로 변한다.
Terminal window
# 현재 연결의 cwnd, rwnd, RTT 확인
ss -ti dst 10.0.0.1
# 출력 예시:
# State Recv-Q Send-Q Local Peer
# ESTAB 0 0 10.0.0.2:8080 10.0.0.1:54321
# cubic wscale:7,7 rto:200 rtt:1.2/0.6 ato:40
# mss:1448 pmtu:1500 rcvmss:1448 advmss:1448
# cwnd:10 ssthresh:2147483647 bytes_sent:140800
# bytes_acked:140800 bytes_received:2048 segs_out:98 segs_in:3
# send 96.5Mbps lastsnd:48 lastrcv:48 lastack:48
# pacing_rate 115.8Mbps delivery_rate 96.5Mbps
# rcv_rtt:1.167 rcv_space:87380 rcv_ssthresh:87380

핵심 필드:

  • cwnd:10 → 현재 혼잡 윈도우가 10 MSS (약 14.4KB)
  • ssthresh → Slow Start Threshold. 이 값 이상이면 Congestion Avoidance 단계
  • rtt:1.2/0.6 → 평균 RTT 1.2ms, 분산 0.6ms
  • retrans:0/0 → 재전송 없음 (첫 번째 숫자: 현재 미확인, 두 번째: 전체 재전송 횟수)

4. 혼잡 제어 알고리즘: 상태 머신

섹션 제목: “4. 혼잡 제어 알고리즘: 상태 머신”

TCP 혼잡 제어는 다음 네 가지 상태로 구성된다.

연결 시작
[Slow Start]
↓ (cwnd >= ssthresh)
[Congestion Avoidance]
↓ (3-duplicate ACK 발생)
[Fast Retransmit → Fast Recovery]
↓ (복구 완료)
[Congestion Avoidance]
↓ (타임아웃 발생)
[Slow Start] (ssthresh = cwnd/2, cwnd = 1 MSS로 리셋)

이름은 “느린 시작”이지만 실제로는 지수 증가로 가장 빠른 구간이다.

초기: cwnd = 1 MSS (초기 cwnd는 RFC 6928 이후 10 MSS가 기본값)
매 RTT마다: cwnd += (새로 ACK된 세그먼트 수) → 사실상 2배씩 증가
RTT 0: cwnd = 10 MSS (현대 기본값)
RTT 1: cwnd = 20 MSS (ACK 10개 → 10 MSS 추가)
RTT 2: cwnd = 40 MSS
...
ssthresh에 도달하거나 손실 발생 시까지 계속
cwnd
^
40│ *
20│ *
10│ *
1│ *
└──────────────→ RTT
0 1 2 3

실무 의미: 새 TCP 연결은 처음 몇 RTT 동안 대역폭을 다 쓰지 못한다. HTTP/1.1에서 이미지 수백 개를 각각 새 연결로 받으면 매번 Slow Start를 거쳐야 한다. HTTP/2의 멀티플렉싱과 연결 재사용이 중요한 이유다.

ssthresh에 도달하면 더 조심스럽게 증가한다.

매 RTT마다: cwnd += 1 MSS (선형 증가)
이유: 이미 한 번 혼잡이 발생했던 지점 근처에서 조심스럽게 탐색
cwnd
^
│ ......
40│ ........
│ ....
20│ **** ← Congestion Avoidance (선형)
│ **** ← Slow Start (지수)
1│*
└──────────────────────────→ RTT

4-3. Fast Retransmit: 타임아웃 없이 즉시 재전송

섹션 제목: “4-3. Fast Retransmit: 타임아웃 없이 즉시 재전송”

패킷 손실을 감지하는 방법은 두 가지다:

  1. RTO(Retransmission Timeout): 타이머가 만료될 때까지 ACK 없음 → cwnd = 1 MSS로 완전 리셋 (가장 가혹한 처벌)
  2. 3 Duplicate ACK: 같은 ACK 번호가 3번 연속 수신 → 해당 패킷만 즉시 재전송, cwnd는 절반만 줄임
클라이언트가 보낸 패킷: [1][2][3][4][5]
3번 패킷이 손실:
서버 응답: ACK(2), ACK(2), ACK(2), ACK(2) ← 3번째 중복 ACK
↑ 1번 2번이 도착함을 알림 ↑ 3개 중복 → Fast Retransmit 발동
송신자: 3번 패킷 즉시 재전송 (타임아웃 기다리지 않음)

3 Duplicate ACK는 손실이지만 “이후 패킷은 도달하고 있다”는 신호다. 완전한 네트워크 단절이 아니므로 가혹한 처벌(cwnd=1)을 피한다.

Fast Retransmit 이후 Slow Start로 돌아가지 않고 Congestion Avoidance로 바로 진입한다.

TCP Reno 기준:
1. 3-dup ACK 감지
2. ssthresh = cwnd / 2
3. cwnd = ssthresh + 3 MSS (3개의 중복 ACK가 "버퍼에 있음"을 의미)
4. 누락 패킷 재전송
5. 이후 ACK마다 cwnd += 1 (임시로 빠르게 증가)
6. 새 ACK(손실 패킷에 대한 ACK) 수신 시: cwnd = ssthresh → Congestion Avoidance 진입

문제: 손실을 혼잡의 유일한 신호로 간주한다. 고대역폭-고지연(High BDP: Bandwidth-Delay Product) 경로에서 비효율적이다.

BDP = 대역폭 × RTT
예: 1Gbps × 100ms RTT = 100Mb = 12.5MB
Reno로 이 파이프를 채우려면 cwnd가 12.5MB에 도달해야 한다.
손실 후 cwnd/2로 줄었다가 다시 선형 증가로 회복하는 데 수백 RTT가 걸린다.
→ 위성 통신, 대륙 간 링크에서 처참한 성능

5-2. TCP Cubic (Linux 기본값, 2.6.19 이후)

섹션 제목: “5-2. TCP Cubic (Linux 기본값, 2.6.19 이후)”

핵심 아이디어: 시간의 3차 함수(cubic function)로 cwnd를 증가시킨다. 손실 후 회복 속도가 훨씬 빠르다.

W(t) = C × (t - K)³ + W_max
W_max: 손실 직전의 cwnd 값
K: W_max * β / C의 세제곱근 (β = 0.7, C = 0.4)
t: 마지막 손실 이후 경과 시간
cwnd
^
│ * ← W_max
│ * ← 급격히 복구
│ *
│ * ← 손실 직전에 천천히 탐색 (평탄한 구간)
│ *
│ * ← 손실 후 빠르게 회복
└──────────────────────→ 시간

장점: 고BDP 경로에서 Reno보다 훨씬 효율적. 동일 경로의 여러 Cubic 흐름 간 공정성(fairness) 좋음. 단점: 여전히 손실 기반. 버퍼가 가득 찬 후에야 반응한다(bufferbloat 문제).

5-3. TCP BBR (Bottleneck Bandwidth and Round-trip propagation time)

섹션 제목: “5-3. TCP BBR (Bottleneck Bandwidth and Round-trip propagation time)”

Google이 2016년 개발, Linux 4.9에 도입. 패러다임 전환: 손실이 아니라 실제 병목 대역폭과 RTT를 직접 측정해서 제어한다.

전통 알고리즘의 문제:
버퍼가 꽉 참 → 손실 발생 → 뒤늦게 속도 줄임 (버퍼링 레이턴시 높음)
BBR의 접근:
병목 링크의 실제 BW와 RTT를 주기적으로 측정
→ 버퍼를 채우지 않고도 최적 전송 속도 유지
→ 낮은 레이턴시 + 높은 throughput 동시 달성
1. STARTUP (Slow Start와 유사)
- 측정된 BW가 더 이상 증가하지 않을 때까지 지수 증가
- 병목 BW 추정치 확보
2. DRAIN
- STARTUP 중 쌓인 큐(버퍼)를 비우는 단계
- pacing_gain < 1 (의도적으로 느리게 전송)
3. PROBE_BW (정상 운전)
- 8 RTT 주기로 반복:
* 1 RTT: gain=1.25 (대역폭 탐색, 약간 빠르게)
* 1 RTT: gain=0.75 (큐 비우기)
* 6 RTT: gain=1.0 (안정 유지)
4. PROBE_RTT
- 10초마다 cwnd를 4 MSS로 줄여 실제 min RTT 재측정
- 버퍼 점유로 인한 RTT 부풀림 제거

왜 클라우드 환경에서 중요한가

섹션 제목: “왜 클라우드 환경에서 중요한가”
Terminal window
# Google Cloud, AWS의 많은 인스턴스는 BBR을 기본으로 사용
# 직접 확인:
sysctl net.ipv4.tcp_congestion_control
# 출력: net.ipv4.tcp_congestion_control = bbr
# 사용 가능한 알고리즘 목록:
sysctl net.ipv4.tcp_available_congestion_control
# 출력: net.ipv4.tcp_available_congestion_control = reno cubic bbr

클라우드 환경 특성과 BBR의 궁합:

상황CubicBBR
긴 RTT (리전 간 통신)BDP가 크면 회복 느림RTT 직접 측정으로 최적화
얕은 버퍼 (NIC 큐 작음)손실 잦아짐버퍼 최소 사용으로 손실 감소
무선/셀룰러 노이즈 손실혼잡으로 오해해 속도 줄임BW 측정으로 노이즈 손실 구분
다중 테넌트 공유 링크공격적인 흐름에 bandwidth 뺏김안정적 BW 확보

실무 적용: BBR 활성화

Terminal window
# /etc/sysctl.conf 또는 /etc/sysctl.d/99-bbr.conf
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq # BBR은 fq(Fair Queuing) qdisc와 함께 써야 효과적
# 즉시 적용
sysctl -p
# 검증
sysctl net.ipv4.tcp_congestion_control
tc qdisc show dev eth0 # fq 확인

주의: BBR v1은 multiple flows 간 공정성 문제(Cubic 흐름을 starve)가 보고된다. 프로덕션 도입 전 벤치마크 필수. BBR v2(실험적)는 이를 개선했다.

BBR이 1% 패킷 손실에서도 고속 전송을 유지하는 이유:

기존 알고리즘(Reno, Cubic)은 패킷 손실을 “네트워크 혼잡”으로 간주한다. 그러나 무선 네트워크나 장거리 링크에서는 혼잡이 없어도 패킷이 손실된다(noise loss). BBR은 이를 구분한다.

Cubic의 패킷 손실 반응:
패킷 손실 감지 → ssthresh = cwnd/2 → 속도 절반 감소 → 선형 증가로 복구
→ 손실률 1%: 지속적으로 cwnd가 줄어 throughput 저하
BBR의 패킷 손실 반응:
패킷 손실 감지 → 그러나 최근 측정 BW가 충분히 높으면
→ 단순 노이즈 손실로 판단, cwnd를 줄이지 않음
→ BW 측정값이 실제로 감소할 때만 속도 조정
Google 실측:
1% 손실률에서:
Cubic: ~3 Mbps
BBR: ~9,100 Mbps (2700배 차이)

📖 더 보기: BBR: Congestion-Based Congestion Control — ACM Queue — Google 엔지니어가 직접 작성한 BBR 설계 원리와 실측 데이터. 이 섹션의 BBR 4단계 상태머신의 공식 레퍼런스


6. Nagle 알고리즘과 Delayed ACK의 상호작용

섹션 제목: “6. Nagle 알고리즘과 Delayed ACK의 상호작용”

소규모 패킷을 버퍼링해서 더 큰 세그먼트로 합쳐 보내는 알고리즘. 1980년대 초 네트워크 정체(Silly Window Syndrome) 해결을 위해 도입됐다.

Nagle 규칙:
"미확인(in-flight) 데이터가 있으면, 새 데이터는 MSS 크기가 될 때까지 버퍼링"
즉:
- 보낼 데이터가 MSS 이상: 즉시 전송
- in-flight 데이터 없음: 즉시 전송
- in-flight 데이터 있음 + 데이터 < MSS: ACK 올 때까지 대기

TCP 수신자는 ACK를 즉시 보내지 않고, 최대 40ms(Linux 기본값)까지 기다렸다가 데이터와 함께 piggyback하거나 여러 ACK를 합쳐 보낸다.

목적: ACK 패킷 수 감소 → 네트워크 부하 감소

6-3. 두 알고리즘의 치명적 상호작용

섹션 제목: “6-3. 두 알고리즘의 치명적 상호작용”
클라이언트 (Nagle ON) 서버 (Delayed ACK)
| |
|── Data(1) ───────────────→| (작은 첫 패킷)
|── Data(2) [버퍼링됨] | ← Nagle: in-flight 있음, MSS 미만이므로 대기
| | ← Delayed ACK: ACK를 40ms 기다리는 중
| |
| (40ms 대기) |
| |
|←─────────── ACK(1) ───────| ← 40ms 후 ACK 전송
|── Data(2) ───────────────→| ← 이제 Nagle이 전송 허용
| |
총 지연: 40ms 추가

실제 사례: HTTP/1.1 request-response 패턴에서 클라이언트가 작은 POST body를 두 패킷으로 나눠 보낼 때 발생. MySQL 프로토콜에서도 자주 발생.

// C 소켓 코드
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
Node.js
const net = require("net");
const socket = new net.Socket();
socket.setNoDelay(true); // TCP_NODELAY 활성화
// Go
conn, _ := net.Dial("tcp", "10.0.0.1:8080")
conn.(*net.TCPConn).SetNoDelay(true)

TCP_NODELAY 설정 권장 상황:

상황권장
인터랙티브 프로토콜 (SSH, telnet, MySQL)TCP_NODELAY ON
게임 서버, 실시간 채팅TCP_NODELAY ON
대용량 파일 전송TCP_NODELAY OFF (Nagle이 효율적)
HTTP/2 (이미 자체 멀티플렉싱)TCP_NODELAY ON 권장
gRPCTCP_NODELAY ON (기본값)
Terminal window
# 운영 중인 프로세스의 소켓 옵션 확인
ss -tnop | grep :3306 # MySQL 포트
# /proc/net/tcp 에서 확인 (더 낮은 레벨)
cat /proc/net/tcp

7-1. TIME_WAIT가 필요한 두 가지 이유

섹션 제목: “7-1. TIME_WAIT가 필요한 두 가지 이유”

L2에서 4-way handshake와 TIME_WAIT 존재는 배웠다. 여기서는 왜 2MSL(Maximum Segment Lifetime, 보통 60초)이나 기다려야 하는지 이해한다.

이유 1: 지연된 패킷이 새 연결에 섞이지 않게
────────────────────────────────────
이전 연결의 패킷이 라우터에서 지연됐다가
같은 4-tuple(src IP:port, dst IP:port)로 새 연결이 생기면
오래된 패킷이 새 연결에 전달될 수 있음
→ 2MSL 대기로 모든 지연 패킷이 소멸되길 보장
이유 2: 마지막 ACK가 안전하게 전달되게
────────────────────────────────────
FIN을 받고 ACK를 보냈는데 그 ACK가 손실되면
서버는 FIN을 재전송함
→ TIME_WAIT 상태에서 재전송된 FIN에 ACK를 다시 보낼 수 있음
→ CLOSED 상태라면 RST를 보내 서버 쪽에서 에러 발생

단기 연결이 많은 서버(API 게이트웨이, 로드밸런서)에서 TIME_WAIT가 수만 개 쌓일 수 있다.

Terminal window
# TIME_WAIT 개수 확인
ss -tan state time-wait | wc -l
# 상태별 연결 수 확인
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

문제: 로컬 포트는 65535개(보통 1024~65535, 약 64511개)가 한계다. 같은 대상 IP:port로의 연결에서 TIME_WAIT가 포트를 점유하면 새 연결을 못 만들 수 있다.

/etc/sysctl.conf
# tw_reuse: TIME_WAIT 소켓을 새 연결에 재사용 (안전함)
net.ipv4.tcp_tw_reuse = 1
# 조건: TCP timestamp가 활성화돼 있어야 함 (net.ipv4.tcp_timestamps = 1)
# 동작: 새 연결의 timestamp가 이전 연결보다 크면 재사용 허용
# tw_recycle: 절대 사용 금지 (Linux 4.12에서 제거됨)
# net.ipv4.tcp_tw_recycle = 1 ← NAT 환경에서 패킷 드롭 유발
# 이유: NAT 뒤의 여러 클라이언트가 같은 IP를 공유할 때
# timestamp 단조 증가 검사를 클라이언트별로 못 함 → 패킷 드롭

실무 권장 설정:

Terminal window
# 안전한 TIME_WAIT 관리
net.ipv4.tcp_tw_reuse = 1 # 클라이언트 측(아웃바운드 연결)에서만 동작
net.ipv4.tcp_timestamps = 1 # tw_reuse의 전제 조건
net.ipv4.tcp_fin_timeout = 30 # FIN_WAIT2 → CLOSED 타임아웃 (기본 60초 → 30초)
# 로컬 포트 범위 확장
net.ipv4.ip_local_port_range = 1024 65535

중요: tw_reuse는 서버(LISTEN 소켓)가 아닌 클라이언트(아웃바운드) 측에서 동작한다. API 서버가 DB나 외부 서비스에 연결할 때의 TIME_WAIT를 줄여준다.


유휴 연결이 살아있는지 확인하는 메커니즘이다. 미들박스(NAT, 방화벽)가 유휴 연결을 조용히 끊어버리는 것을 방지한다.

Terminal window
# 커널 수준 TCP Keep-Alive 설정 확인
sysctl net.ipv4.tcp_keepalive_time # 마지막 데이터 후 probe 시작까지 대기 (기본 7200초)
sysctl net.ipv4.tcp_keepalive_intvl # probe 간격 (기본 75초)
sysctl net.ipv4.tcp_keepalive_probes # 최대 probe 횟수 (기본 9회)
# 총 감지 시간 = tcp_keepalive_time + tcp_keepalive_intvl × tcp_keepalive_probes
# 기본: 7200 + 75×9 = 7875초 ≈ 2.2시간 (너무 길다!)

AWS/클라우드 환경 권장 설정:

/etc/sysctl.conf
net.ipv4.tcp_keepalive_time = 60 # 60초 후 probe 시작
net.ipv4.tcp_keepalive_intvl = 10 # 10초 간격
net.ipv4.tcp_keepalive_probes = 3 # 3번 실패 시 연결 종료
# 총 감지 시간: 60 + 10×3 = 90초 (AWS ELB 유휴 타임아웃 60초에 맞춤)

AWS ELB/ALB 주의사항:

  • ALB 기본 유휴 타임아웃: 60초
  • 백엔드 서버의 Keep-Alive 타임아웃이 60초보다 짧으면 ALB가 아직 살아있다고 생각하는 연결을 서버가 먼저 끊음 → 502 에러
// Node.js HTTP 서버 설정
const server = http.createServer(app);
server.keepAliveTimeout = 65000; // 65초 (ALB 60초보다 길게)
server.headersTimeout = 70000; // keepAliveTimeout보다 반드시 길게

HTTP/1.1은 기본으로 Keep-Alive 커넥션을 사용한다. 같은 TCP 연결로 여러 HTTP 요청을 처리해 Slow Start 반복을 피한다.

Keep-Alive 없음 (HTTP/1.0 기본):
TCP 연결 → HTTP 요청 → HTTP 응답 → TCP 종료 (매번 3-way handshake + Slow Start)
Keep-Alive 있음 (HTTP/1.1 기본):
TCP 연결 → HTTP 요청1 → HTTP 응답1 → HTTP 요청2 → HTTP 응답2 → ... → TCP 종료
(한 번의 3-way handshake + 한 번의 Slow Start)

DB, Redis, 외부 API 등 연결 수립 비용이 높은 서비스에 필수적이다.

// PostgreSQL 커넥션 풀 (pg 라이브러리)
const { Pool } = require("pg");
const pool = new Pool({
host: "db.internal",
port: 5432,
max: 20, // 최대 연결 수 (DB의 max_connections 고려)
min: 5, // 최소 유지 연결 수
idleTimeoutMillis: 30000, // 유휴 연결 제거 시간
connectionTimeoutMillis: 2000, // 연결 획득 타임아웃
});
// Keep-Alive를 통해 연결 유지 확인
pool.on("connect", (client) => {
client.query("SET statement_timeout = 5000");
});
# SQLAlchemy 커넥션 풀
engine = create_engine(
DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True, # 연결 사용 전 alive 확인 (keep-alive 대안)
pool_recycle=3600, # 1시간마다 연결 재생성 (NAT 타임아웃 대비)
)

애플리케이션 커널 네트워크
│ │
│ write() │
│──────────────────────→│ send buffer (SO_SNDBUF)
│ │──────────────────────→ 네트워크
│ │
│ read() │
│←──────────────────────│ recv buffer (SO_RCVBUF)
│ │←────────────────────── 네트워크
rwnd = recv buffer의 남은 공간
(수신 버퍼가 크면 rwnd가 크고, 더 많은 in-flight 데이터를 허용)
Terminal window
# 현재 설정 확인
sysctl net.core.rmem_max # recv buffer 최대값 (bytes)
sysctl net.core.wmem_max # send buffer 최대값
sysctl net.core.rmem_default # recv buffer 기본값
sysctl net.core.wmem_default # send buffer 기본값
sysctl net.ipv4.tcp_rmem # TCP recv buffer: min default max
sysctl net.ipv4.tcp_wmem # TCP send buffer: min default max
sysctl net.ipv4.tcp_mem # TCP 전체 메모리 사용량 제한: low pressure high
# 예시 출력:
# net.ipv4.tcp_rmem = 4096 87380 6291456
# ↑min ↑def ↑max (6MB)
/etc/sysctl.d/99-network-tuning.conf
# 소켓 버퍼 최대값 확대
net.core.rmem_max = 134217728 # 128MB
net.core.wmem_max = 134217728 # 128MB
# TCP 버퍼 자동 조정 범위 (min, default, max)
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
# 자동 버퍼 튜닝 활성화 (커널이 BDP에 맞게 자동 조정)
net.ipv4.tcp_moderate_rcvbuf = 1
# 백로그 큐 크기 (LISTEN 소켓의 대기열)
net.core.somaxconn = 65535 # accept() 대기 최대 연결 수
net.ipv4.tcp_max_syn_backlog = 65535
# TIME_WAIT 버킷 (많은 TIME_WAIT를 처리할 메모리)
net.ipv4.tcp_max_tw_buckets = 262144
BDP(Bandwidth-Delay Product) = 대역폭 × RTT
예: 서울 ↔ 도쿄 = 10Gbps × 30ms RTT
BDP = 10,000,000,000 bps × 0.030 s = 300,000,000 bits = 37.5MB
→ 파이프를 꽉 채우려면 tcp_rmem max가 최소 37.5MB 이상이어야 함
→ 기본값 6MB로는 10Gbps의 16%만 사용 가능
Terminal window
# RTT 측정
ping -c 100 tokyo-server.example.com | tail -1
# rtt min/avg/max/mdev = 28.5/31.2/35.1/1.8 ms
# 현재 연결의 실제 throughput 한계 계산
# ss -ti 출력의 send Xbps 값과 이론값 비교

9-5. 소켓 버퍼 애플리케이션 수준 설정

섹션 제목: “9-5. 소켓 버퍼 애플리케이션 수준 설정”
// C: 특정 소켓의 버퍼 크기 설정
int rcvbuf_size = 4 * 1024 * 1024; // 4MB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf_size, sizeof(rcvbuf_size));
// 주의: 커널은 요청값의 2배를 실제로 할당 (메타데이터 포함)
// 실제 할당값: getsockopt()로 확인
// Go: net.Dialer로 소켓 옵션 설정
dialer := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET,
syscall.SO_RCVBUF, 4*1024*1024)
})
},
}
conn, err := dialer.Dial("tcp", "10.0.0.1:8080")

10. 프론트엔드 → 플랫폼 브릿지: React SSR과 TCP 튜닝

섹션 제목: “10. 프론트엔드 → 플랫폼 브릿지: React SSR과 TCP 튜닝”

10-1. TTFB(Time to First Byte)와 TCP의 관계

섹션 제목: “10-1. TTFB(Time to First Byte)와 TCP의 관계”

프론트엔드 개발자 시절에는 TTFB를 “서버가 느린 것”으로만 이해했다. 하지만 TCP 관점에서 TTFB에는 여러 레이어가 있다.

브라우저 CDN/엣지 SSR 서버
│ │ │
│── TCP SYN ───────────────→│ │
│←─ SYN-ACK ────────────────│ │
│── ACK ───────────────────→│ │
│ ↑ 3-way handshake (RTT1)│ │
│ │── TCP SYN ───────→│
│ │←─ SYN-ACK ────────│
│ │── ACK ────────────→│
│ │ ↑ RTT2 (CDN→서버)│
│ │── HTTP Request ───→│
│ │ [React rendering]
│ │←─ HTTP 200 ───────│
│←─ HTTP 200 ───────────────│ ↑ Slow Start 영향
↑ 사용자가 느끼는 TTFB

TTFB = RTT(클라이언트→CDN) + RTT(CDN→서버) + 서버 처리 시간 + TCP Slow Start 지연

10-2. 초기 cwnd가 TTFB에 미치는 영향

섹션 제목: “10-2. 초기 cwnd가 TTFB에 미치는 영향”
HTML 응답이 15KB라고 가정:
초기 cwnd = 10 MSS = 10 × 1460 bytes = 14.6KB
→ 첫 번째 RTT에서 14.6KB 전송 → HTML의 97%가 첫 RTT에 도착
→ 두 번째 RTT에서 나머지 0.4KB 전송
만약 HTML이 30KB라면:
→ 첫 RTT: 14.6KB (cwnd=10)
→ ACK 수신 후 cwnd=20: 두 번째 RTT에서 나머지 15.4KB 전송
→ 총 2 RTT 소요
→ 미들웨어를 최대한 줄이고, 초기 HTML을 14.6KB(10 MSS) 이하로 만들면
첫 RTT에서 전체 페이지 구조를 전달 가능
/etc/sysctl.d/99-ssr-tuning.conf
# SSR 서버 (Next.js, Remix 등) 최적화 설정
# 초기 cwnd 확인 (Linux 3.0+ 기본값 10, 구형은 4)
# ip route show | grep initcwnd
# 만약 낮다면:
ip route change default via 10.0.0.1 initcwnd 10
# 빠른 연결 수립
net.ipv4.tcp_fastopen = 3 # TFO 활성화 (클라이언트+서버 모두)
# TFO: 3-way handshake 중 SYN에 데이터 포함 → 1 RTT 절약
# Keep-Alive (ALB와 연동)
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 3

Next.js 서버 설정 예시:

next.config.js
const nextConfig = {
// HTTP 압축으로 응답 크기 줄여 Slow Start 영향 최소화
compress: true,
// 헤더 최적화
async headers() {
return [
{
source: "/(.*)",
headers: [
// 연결 재사용 명시
{ key: "Connection", value: "keep-alive" },
{ key: "Keep-Alive", value: "timeout=65" },
],
},
];
},
};
// server.js (Custom Server)
const http = require("http");
const next = require("next");
const app = next({ dev: false });
app.prepare().then(() => {
const server = http.createServer(app.getRequestHandler());
// ALB 유휴 타임아웃(60초)보다 길게 설정
server.keepAliveTimeout = 65000;
server.headersTimeout = 70000;
server.listen(3000, () => {
console.log("Ready on port 3000");
});
});

10-4. Streaming SSR과 TCP의 상호작용

섹션 제목: “10-4. Streaming SSR과 TCP의 상호작용”

React 18의 Streaming SSR(renderToPipeableStream)은 HTML을 청크(chunk)로 나눠 보낸다. 여기서 TCP Nagle 알고리즘이 문제가 될 수 있다.

Streaming SSR 흐름:
서버: [<html><head>...</head><body>] → flush
서버: [<div id="root">Loading...</div>] → flush
서버: [<script>] ... React hydration ... [</script>] → flush
문제: 작은 청크들이 Nagle에 의해 버퍼링될 수 있음
→ 첫 청크를 브라우저가 늦게 받아 LCP(Largest Contentful Paint) 지연
// Node.js HTTP 서버에서 TCP_NODELAY 설정
const server = http.createServer((req, res) => {
// 소켓에 TCP_NODELAY 설정
req.socket.setNoDelay(true);
// React Streaming
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
// Transfer-Encoding: chunked가 자동 설정됨
pipe(res);
},
});
});
Terminal window
# 1단계: TTFB 측정
curl -o /dev/null -s -w "
DNS: %{time_namelookup}s
TCP: %{time_connect}s
TLS: %{time_appconnect}s
TTFB: %{time_starttransfer}s
Total: %{time_total}s
" https://myapp.example.com/
# 2단계: 연결 상태 확인
ss -ti 'sport = :3000' # SSR 서버의 연결 상태
# 3단계: 패킷 수준 분석
sudo tcpdump -i eth0 -w /tmp/capture.pcap port 3000
# Wireshark로 열어 TCP Stream Graph → Time/Sequence 그래프로 Slow Start 확인
# 4단계: 재전송 통계
netstat -s | grep -i retransmit
# 또는
cat /proc/net/snmp | grep Tcp

환경권장 알고리즘이유
데이터센터 내부 (RTT < 1ms)Cubic (기본값)BBR의 PROBE_RTT가 오히려 비효율
리전 간 (RTT 10~100ms)BBR고BDP에서 throughput 향상
모바일/무선 환경BBR노이즈 손실을 혼잡으로 오해 방지
위성 통신 (RTT > 500ms)BBRCubic의 선형 회복이 너무 느림
  1. rwndcwnd 중 어느 것이 더 작으면 네트워크 병목이고, 어느 것이 더 작으면 수신 측 병목인가?
  2. Fast Retransmit가 일반 타임아웃 재전송보다 빠른 이유는 무엇인가?
  3. AWS ALB 뒤에 Node.js SSR 서버를 둘 때, keepAliveTimeout을 ALB 타임아웃보다 길게 설정해야 하는 이유를 TCP 관점에서 설명하라.
  4. Nagle 알고리즘과 Delayed ACK가 동시에 활성화됐을 때 40ms 지연이 발생하는 구체적인 시나리오를 설명하라.
  5. tw_recycle이 위험한 이유를 NAT와 TCP timestamp의 관점에서 설명하라.
  1. cwnd < rwnd이면 네트워크(중간 경로) 병목, rwnd < cwnd이면 수신 측 애플리케이션/버퍼 병목
  2. 3-dup ACK는 “뒤의 패킷이 도달하고 있다”는 신호 → 타임아웃 만료 없이 즉시 재전송 가능
  3. 서버가 먼저 연결을 끊으면 ALB는 살아있다고 보고 새 요청을 보냄 → 502 에러
  4. 서버가 Delayed ACK로 40ms 대기, 클라이언트가 Nagle로 ACK 오기를 기다림 → 교착
  5. NAT 뒤 다른 클라이언트들의 timestamp가 단조 증가하지 않을 수 있어 패킷 드롭 발생

🔧 Node.js SSR 서버에서 ALB 502 — keepAliveTimeout 미설정

섹션 제목: “🔧 Node.js SSR 서버에서 ALB 502 — keepAliveTimeout 미설정”

증상:

ALB 액세스 로그:
"elb_status_code": 502, "target_status_code": "-"
# 특이한 점: 일부 요청만 산발적으로 502 발생, 재시도하면 성공
# 서버 로그에는 아무것도 없음 (서버가 연결을 먼저 끊었기 때문)

원인: Node.js HTTP 서버의 기본 keepAliveTimeout이 5000ms(5초)이다. ALB의 기본 유휴 타임아웃(60초) 동안 ALB는 연결이 살아있다고 판단하고 새 요청을 보내는데, 서버는 이미 해당 TCP 연결을 종료한 상태다. 서버가 RST를 보내면 ALB는 502를 반환한다.

해결:

// server.js — ALB 타임아웃(60초)보다 길게 설정
const server = http.createServer(app);
server.keepAliveTimeout = 65000; // 65초 (ALB 60초 + 여유 5초)
server.headersTimeout = 70000; // keepAliveTimeout보다 반드시 크게
server.listen(3000);
console.log("Server keepAliveTimeout:", server.keepAliveTimeout);
// Server keepAliveTimeout: 65000
Terminal window
# ALB 유휴 타임아웃 확인
aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:... \
--query 'Attributes[?Key==`idle_timeout.timeout_seconds`]'
# [{"Key": "idle_timeout.timeout_seconds", "Value": "60"}]
# 연결 상태 확인 (CLOSE_WAIT 폭증이 증거)
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# CLOSE_WAIT가 수백~수천이면 서버가 먼저 연결을 끊고 있는 것

🔧 TIME_WAIT 포트 고갈 — “connection refused: no ephemeral port available”

섹션 제목: “🔧 TIME_WAIT 포트 고갈 — “connection refused: no ephemeral port available””

증상:

Terminal window
# API 서버 → DB 연결 시 에러:
dial tcp: connect: cannot assign requested address
# 또는:
FATAL: role "myapp" is already connected to the database
# 확인:
ss -tan state time-wait | wc -l
# 62000 ← 포트 고갈 임박 (최대 약 64511개)
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 62000 TIME-WAIT
# 150 ESTABLISHED
# 5 LISTEN

원인: 단기 연결을 많이 생성하는 서비스(커넥션 풀 없이 DB 직접 연결, 외부 API 호출)에서 TIME_WAIT가 쌓여 로컬 포트가 고갈된다. TIME_WAIT는 2MSL(약 60초)간 포트를 점유한다.

해결:

Terminal window
# 1. 즉시 완화 (런타임 적용)
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# 2. 영구 설정 (/etc/sysctl.d/99-tcp-tune.conf)
net.ipv4.tcp_tw_reuse = 1 # TIME_WAIT 소켓 재사용 (클라이언트 아웃바운드에만 동작)
net.ipv4.tcp_timestamps = 1 # tw_reuse의 전제 조건
net.ipv4.ip_local_port_range = 1024 65535 # 로컬 포트 범위 확장
net.ipv4.tcp_fin_timeout = 30 # FIN_WAIT2 타임아웃 단축 (기본 60초)
# 3. 근본 해결: 커넥션 풀 적용
# pg Pool({ max: 20 }) → 고정 연결로 TIME_WAIT 자체를 줄임
# 적용 확인
sysctl -p && ss -tan state time-wait | wc -l
# 적용 후 수 분 내에 TIME_WAIT 수 감소

🔧 리전 간 파일 전송 throughput 저조 — cwnd 병목

섹션 제목: “🔧 리전 간 파일 전송 throughput 저조 — cwnd 병목”

증상:

Terminal window
# 서울 → 도쿄 파일 전송 시 속도 저조:
scp large-file.tar user@tokyo-server:/data/
# 속도: 5MB/s (10Gbps 링크인데도)
# 진단:
ss -ti dst tokyo-server.example.com
# ESTAB ...
# cubic wscale:7,7 rto:250 rtt:32.0/1.0
# cwnd:45 ssthresh:80 bytes_sent:160000000
# send 16.5Mbps delivery_rate 16.5Mbps

원인: BDP = 10Gbps × 32ms = 40MB인데, cwnd가 45 MSS ≈ 65KB에 불과하다. 소켓 버퍼 크기가 BDP보다 훨씬 작아 파이프를 채우지 못하고 있다.

해결:

Terminal window
# 1. 커널 소켓 버퍼 확대 (/etc/sysctl.d/99-network-tuning.conf)
net.core.rmem_max = 134217728 # 128MB
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_moderate_rcvbuf = 1 # 자동 버퍼 조정
sysctl -p
# 2. BBR 전환 (고지연 링크 최적화)
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
sysctl -p
# 3. 재전송 확인
ss -ti dst tokyo-server.example.com
# 적용 후: cwnd:2000+ send 800Mbps+ 가 되어야 정상
# 이론 최대 throughput 검증:
# BDP / RTT = 40MB / 0.032s = 1.25GB/s (링크 한계)
# cwnd 제한 없을 때: 10Gbps 링크의 80%+ 활용 가능

🔧 Nagle + Delayed ACK 40ms 레이턴시 — MySQL/Redis 지연

섹션 제목: “🔧 Nagle + Delayed ACK 40ms 레이턴시 — MySQL/Redis 지연”

증상:

Terminal window
# MySQL 쿼리 레이턴시가 40ms 단위로 증가:
# 예상: 1-2ms, 실제: 41ms, 42ms (40ms 가산)
# 진단: tcpdump로 패킷 타이밍 확인
sudo tcpdump -i eth0 -nn port 3306 -w /tmp/mysql.pcap
# Wireshark에서 Time/Sequence 그래프 확인 → 40ms 공백 구간 발견
# ss로 TCP_NODELAY 확인 (nodelay가 없으면 Nagle 활성화)
ss -tione dst db.internal | grep -i nodelay
# (출력 없음) ← TCP_NODELAY 미설정

원인: 클라이언트의 Nagle 알고리즘과 서버의 Delayed ACK(40ms)가 결합되어 교착 상태 발생. MySQL 프로토콜처럼 요청이 여러 TCP 세그먼트로 분할될 때 항상 발생 가능하다.

해결:

// Node.js MySQL2 드라이버
const mysql = require("mysql2");
const conn = mysql.createConnection({
host: "db.internal",
// ...
});
// 연결 후 TCP_NODELAY 설정
conn.on("connect", () => {
conn.stream.setNoDelay(true); // TCP_NODELAY 활성화
});
// 또는 커넥션 풀 생성 시
const pool = mysql.createPool({
// ...
enableKeepAlive: true,
keepAliveInitialDelay: 10000,
});
Terminal window
# MySQL 서버 측에서도 확인
mysql -h db.internal -e "SHOW STATUS LIKE 'Bytes_received'"
# 적용 전후 레이턴시 비교 (mysqlslap 또는 sysbench)
mysqlslap --concurrency=50 --iterations=100 --auto-generate-sql \
--host=db.internal --auto-generate-sql-load-type=mixed

참고: 자주 쓰는 진단 명령어 모음

섹션 제목: “참고: 자주 쓰는 진단 명령어 모음”
Terminal window
# TCP 상태 및 성능 통계
ss -ti # 모든 TCP 연결 상세 정보
ss -tan state time-wait | wc -l # TIME_WAIT 개수
ss -tan state established | wc -l # ESTABLISHED 개수
netstat -s | grep -E 'retransmit|segment' # 재전송 통계
# 커널 파라미터 확인
sysctl -a | grep tcp_ # 모든 TCP 파라미터
sysctl net.ipv4.tcp_congestion_control # 현재 혼잡 제어 알고리즘
# 패킷 캡처
sudo tcpdump -i eth0 -nn port 80 -c 1000 -w /tmp/http.pcap
sudo tcpdump -i eth0 'tcp[tcpflags] & (tcp-rst) != 0' # RST 패킷만
# 실시간 소켓 모니터링
watch -n 1 'ss -s' # 소켓 요약 통계
# 라우팅 및 MTU
ip route show # 라우팅 테이블 + initcwnd 확인
ip link show eth0 # MTU 확인
tracepath google.com # 경로상 MTU 탐색 (PMTUD)

주요 명령 예상 출력:

Terminal window
# 현재 혼잡 제어 알고리즘 확인
sysctl net.ipv4.tcp_congestion_control

예상 출력:

net.ipv4.tcp_congestion_control = bbr
Terminal window
# TCP 연결 상태 요약
ss -s

예상 출력:

Total: 1245
TCP: 385 (estab 152, closed 198, orphaned 0, timewait 194)
Transport Total IP IPv6
* 1245 - -
RAW 0 0 0
UDP 8 5 3
TCP 187 143 44
Terminal window
# 특정 연결의 상세 TCP 정보
ss -ti dst 10.0.1.100

예상 출력:

State Recv-Q Send-Q Local Peer
ESTAB 0 0 10.0.0.10:8080 10.0.1.100:54321
cubic wscale:7,7 rto:208 rtt:8.2/2.1 ato:40 mss:1448 pmtu:1500
cwnd:30 ssthresh:120 bytes_sent:8294400 bytes_acked:8294400
bytes_received:2048 segs_out:5730 segs_in:32 send 42.6Mbps
pacing_rate 51.1Mbps delivery_rate 42.6Mbps
Terminal window
# 재전송 통계
netstat -s | grep -E "retransmit|Retransmit"

예상 출력:

1234 segments retransmitted
0 fast retransmits
2 retransmits in slow start
0 SACK retransmits failed