콘텐츠로 이동

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 값을 읽지 못하면 네트워크 병목을 구분할 수 없다.

2.5 Reno에서 BBR까지: 손실 신호만으로는 부족해진 이유

섹션 제목: “2.5 Reno에서 BBR까지: 손실 신호만으로는 부족해진 이유”

L2의 Sliding Window는 수신자 버퍼를 넘치지 않게 하는 데 충분했지만, 중간 경로의 라우터·스위치가 언제 포화되는지는 알려주지 못한다. 그래서 Reno 계열은 패킷 손실과 중복 ACK를 혼잡 신호로 삼았다. 이 방식은 저속 링크와 작은 버퍼가 일반적이던 환경에서는 실용적이었다. 버퍼가 먼저 차고, 그 결과로 손실이 나면 송신자가 속도를 줄이면 됐기 때문이다.

문제는 클라우드·모바일·대륙 간 링크에서 이 순서가 자주 뒤집힌다는 점이다. 얕은 버퍼에서는 실제 혼잡 전에 일시 손실이 먼저 나고, 깊은 버퍼에서는 손실이 나기 전에 큐가 길어져 RTT가 먼저 망가진다. Google의 BBR 설명은 10Gbps 서버 링크, RTT 100ms, 손실률 1% 조건에서 CUBIC이 약 3.3Mbps까지 떨어진 반면 BBR은 9,100Mbps 이상을 유지한 사례를 제시한다. 같은 글에서 마지막 마일 10Mbps, RTT 40ms, 1,000패킷 버퍼 조건의 큐잉 지연도 CUBIC 1090ms 대 BBR 43ms로 비교한다. 출처: https://cloud.google.com/blog/products/networking/tcp-bbr-congestion-control-comes-to-gcp-your-internet-just-got-faster

따라서 이 토픽의 진화 방향은 “윈도우를 키운다”가 아니라 무엇을 혼잡 신호로 볼 것인가의 변화다. RFC 6928의 IW10은 첫 RTT에 보낼 수 있는 상한을 min(10*MSS, max(2*MSS, 14600))로 넓혀 웹 응답 시작 지연을 줄였고, CUBIC은 RFC 8312 기준 손실 이후 회복 곡선을 시간의 3차 함수로 바꿔 고BDP 경로를 더 빨리 다시 채운다. BBR은 한 걸음 더 나아가 최근 delivery rate와 min RTT로 병목 대역폭과 BDP를 추정하고, pacing으로 큐를 짧게 유지한다. 즉 Reno → CUBIC → BBR의 계보는 “손실 후 후퇴”에서 “경로 모델을 추정해 미리 조절”로 이동한 것이다.


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 진입

4.99 새 백프레셔/혼잡 제어 시스템을 읽는 법

섹션 제목: “4.99 새 백프레셔/혼잡 제어 시스템을 읽는 법”

TCP의 핵심 4축(BDP, 혼잡 신호, sliding window, 회복 전략)은 다른 시스템에서도 동일하게 반복된다. 새 시스템을 만났을 때 이 표를 떠올리면 구조를 빠르게 파악할 수 있다.

원리TCPAPI Rate LimiterCircuit BreakerAsync Backpressure (RxJS, Reactor)
혼잡 신호 (피드백)패킷 loss, RTT 증가, ECN bit429 Too Many Requests, 헤더 X-RateLimit-Remaining에러율, latency p99, timeout 횟수demand 신호 (request(N))
회복 전략Slow Start → CA → Fast Recoveryexponential backoff + jitterOPEN → HALF_OPEN → CLOSED 전환onBackpressureBuffer/Drop/Latest
window/creditcwnd × MSS = 진행 중 미확인 데이터Token Bucket(burst+refill)error rate window (rolling 1분)backpressure buffer size
BDP 산정 = 처리량 한계BDP = bandwidth × RTT허용 RPS = bucket size / refill intervalwindow size × allowed rate = 시스템 capacityproducer rate × processing time = buffer 필요량

예를 들어 결제 API 클라이언트가 429 Too Many Requests를 받는 상황은 TCP 손실 감지와 구조가 같다. X-RateLimit-Remaining: 0은 명시적 혼잡 신호이고, token bucket의 burst 크기는 TCP의 in-flight window에 해당한다. 이때 backoff 없이 즉시 재시도하면 Reno가 손실 후에도 cwnd를 줄이지 않는 것과 같아서 큐와 실패율만 키운다. 반대로 Retry-After: 2를 존중하고 exponential backoff + jitter를 적용하면, 회복 전략이 Slow Start처럼 보수적으로 재진입한다.

Circuit Breaker도 같은 공식으로 읽을 수 있다. error rate window = 최근 1분, threshold = 50%, minimum calls = 20이라면 20개 미만의 표본에서는 OPEN 전환이 일어나지 않는다. 이 조건을 모르면 장애 초반 10개 요청이 모두 실패해도 breaker가 조용히 닫혀 있는 silent failure가 생긴다. TCP에서 retrans만 보고 rtt 증가를 놓치면 bufferbloat를 늦게 발견하는 것과 같은 유형이다.

새 백프레셔/혼잡 시스템을 만났을 때 4가지 진단 질문

  1. 혼잡 신호는 무엇인가? — 명시적(429, error code)? 암묵적(latency, queue depth)?
  2. 회복 전략은? (수동/자동/하이브리드) — backoff 파라미터(initial, factor, jitter)는 정량으로 설정 가능한가?
  3. BDP-equivalent 한계는? — 시스템의 “동시 in-flight” 상한은 어떻게 계산하는가? (Little’s Law: N = λ × W)
  4. 회귀 시 rollback 절차는? — 알고리즘 변경 후 회귀가 발생하면 어떻게 되돌리는가?

TCP 알고리즘 변경 후 Rollback 절차

섹션 제목: “TCP 알고리즘 변경 후 Rollback 절차”

BBR/Cubic 같은 혼잡 제어 알고리즘 변경은 운영 회귀를 유발할 수 있다. 명시적 rollback 단계:

Terminal window
# (0) 변경 전 baseline 측정
sysctl net.ipv4.tcp_congestion_control # 현재 알고리즘 기록
ss -i | head -50 # 현재 RTT, cwnd, retrans 통계 저장
# 권장: iperf3 또는 실제 트래픽 p50/p99 latency 30분 측정
# (1) 변경 적용
echo "bbr" | sudo tee /proc/sys/net/ipv4/tcp_congestion_control
# (2) 5~15분 모니터링 (검증 단계)
ss -ti | grep -E "bbr|retrans|rtt"
# 검증 항목: retrans rate < 0.1%, RTT 변동 < 20%, 처리량 변동 < 10%
# (3) 회귀 발생 시 즉시 rollback
echo "cubic" | sudo tee /proc/sys/net/ipv4/tcp_congestion_control
# 영구 적용된 경우 /etc/sysctl.conf 수정 후 sudo sysctl -p
sudo sed -i '/net.ipv4.tcp_congestion_control/d' /etc/sysctl.conf
# (4) rollback 후 검증
ss -i | grep cubic # 새 연결이 cubic으로 동작하는지 확인
# 기존 연결은 알고리즘 변경 전 상태 유지 — 명시적 재시작 필요할 수 있음

일반화된 rollback 체크리스트:

  1. 변경 전 baseline 메트릭 기록 (정량)
  2. 단계적 적용 (canary → 일부 노드 → 전체)
  3. 명시적 검증 윈도우 (5~30분)
  4. 회귀 발견 시 즉시 되돌리는 명령 사전 준비
  5. rollback 후 별도 검증 (이전 상태로 완전 복귀했는가)

(참고: Linux Kernel BBR commit history, Google BBR v2 paper)

문제: 손실을 혼잡의 유일한 신호로 간주한다. 고대역폭-고지연(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배 차이)

📋 수치 출처 및 테스트 조건 (Google Cloud Blog, 2017):

  • 테스트 환경: Chicago → Berlin, RTT 100ms, 10Gbps 서버 링크, 패킷 손실률 1%
  • 결과: Cubic ~3.3Mbps vs BBR >9,100Mbps (2,700배 차이)
  • YouTube 실제 배포 결과: 전 세계 평균 throughput +4%, RTT -33%, 재버퍼링 빈도 -11%
  • 주의: 1% 손실 수치는 “합성 마이크로벤치마크”이며 고대역폭-고RTT-고손실 경로의 최악 시나리오를 나타냄. 일반적인 데이터센터 내부 경로에서는 격차가 훨씬 작음.

📖 설계 원리 원문: 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

알고리즘 선택은 “BBR이 더 최신이니 켠다”가 아니라, 현재 병목이 손실 기반 회복 지연인지, 큐잉 지연인지, 수신자/애플리케이션 병목인지를 먼저 분리한 뒤 결정한다. ss -ti에서 cwnd가 낮고 rtt가 안정적인데 장거리 전송만 느리면 고BDP 회복 문제가 의심되고, send는 충분한데 rcv_space가 작거나 애플리케이션 read가 늦으면 혼잡 제어를 바꿔도 효과가 없다.

환경권장 알고리즘이유
데이터센터 내부 (RTT < 1ms)Cubic (기본값)BBR의 PROBE_RTT가 오히려 비효율
리전 간 (RTT 10~100ms)BBR고BDP에서 throughput 향상
모바일/무선 환경BBR노이즈 손실을 혼잡으로 오해 방지
위성 통신 (RTT > 500ms)BBRCubic의 선형 회복이 너무 느림

실패 시나리오도 같이 둬야 한다. ACM Queue의 BBR 설명은 token-bucket policer가 있는 경로에서 ProbeBW의 고 gain 구간이 지속적인 중간 손실을 만들 수 있다고 설명한다. 따라서 BBR canary는 처리량만 보면 안 되고, 같은 시간대의 retrans, p95/p99 RTT, 상대 Cubic 플로우의 처리량을 함께 봐야 한다. 운영 가드레일(추정)로는 전환 후 처리량이 10% 올라도 p99 RTT가 2배가 되거나 같은 링크의 Cubic 트래픽이 굶으면 rollback 대상으로 둔다.

Terminal window
# 변경 전/후 같은 대상에 대해 동일한 검증 윈도우로 비교
ss -ti dst api.partner.example.com | grep -E "bbr|cubic|rtt|retrans|send"
# 기대: send/delivery_rate 증가, retrans 급증 없음, rtt 평균과 분산이 baseline 범위
# 의심: delivery_rate는 증가했지만 retrans가 계속 늘거나 rtt 분산이 2배 이상 증가
  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