콘텐츠로 이동

TCP 흐름/혼잡 제어 심화

L2 선수 지식: 3-way handshake, 4-way close, Sliding Window, rwnd, MSS/MTU, 기본 Slow Start는 L2 tcp-udp-internals.mdx에서 다뤘다. 이 문서는 그 위에서 “TCP가 얼마나 보내도 되는지 어떻게 판단하는가”와 “느린 네트워크를 어떤 신호로 분해할 것인가”를 다룬다.

첫 회독 기준: 처음에는 모든 커널 파라미터를 외우지 않는다. rwnd, cwnd, RTT, MSS, BDP, retrans, TIME_WAIT가 각각 어떤 병목을 가리키는지만 잡으면 된다. 튜닝 명령은 마지막 선택 부록으로 미뤄도 본문 이해가 끊기지 않는다.


흐름 제어(Flow Control) 는 수신자가 감당할 수 있는 만큼만 보내게 하는 장치다. 수신자의 버퍼가 작거나 애플리케이션이 read()를 늦게 호출하면 송신자는 네트워크가 멀쩡해도 더 보낼 수 없다.

혼잡 제어(Congestion Control) 는 중간 경로가 감당할 수 있는 만큼만 보내게 하는 장치다. 라우터, 스위치, 가상 NIC 큐, 무선 구간, ISP 경로 중 어딘가가 포화되면 송신자는 수신자 버퍼가 넉넉해도 속도를 줄여야 한다.

두 제어는 모두 “지금 ACK 없이 떠 있어도 되는 데이터 양”을 제한한다. 차이는 누가 제한하는가다.

실제 전송 가능량 = min(rwnd, cwnd)
rwnd(receive window)
수신자가 ACK에 실어 보내는 수신 가능 공간.
목적: 수신자 버퍼와 애플리케이션을 보호한다.
cwnd(congestion window)
송신자 커널이 내부적으로 계산하는 혼잡 윈도우.
목적: 중간 네트워크 경로를 보호한다.

ACK(Acknowledgement)는 수신자가 “여기까지 받았다”고 알려주는 확인 신호다. MSS(Maximum Segment Size)는 TCP 세그먼트 하나에 담을 수 있는 최대 데이터 크기다. Ethernet MTU(Maximum Transmission Unit) 1500 bytes에서 IPv4 헤더 20 bytes와 TCP 헤더 20 bytes를 빼면 일반적인 MSS는 1460 bytes가 된다.

RTT(Round Trip Time)는 송신자가 데이터를 보낸 뒤 그 데이터에 대한 ACK를 받기까지 왕복 시간이다. TCP의 전송률은 대역폭만으로 결정되지 않고, RTT 동안 얼마나 많은 데이터를 in-flight로 유지할 수 있는지에 크게 좌우된다.


2. 등장한 문제: 수신자만 보아서는 네트워크가 보이지 않는다

섹션 제목: “2. 등장한 문제: 수신자만 보아서는 네트워크가 보이지 않는다”

L2의 Sliding Window는 수신자 버퍼를 넘치지 않게 하는 데 충분하다. 하지만 송신자와 수신자 사이에는 여러 큐가 있다.

송신 애플리케이션
-> 송신 커널 버퍼
-> 송신 NIC 큐
-> 스위치/라우터 큐
-> 인터넷/클라우드 백본
-> 수신 NIC 큐
-> 수신 커널 버퍼
-> 수신 애플리케이션

수신자의 rwnd가 10MB라고 해서 중간 경로가 10MB의 in-flight 데이터를 안전하게 받아줄 수 있다는 뜻은 아니다. 반대로 중간 경로가 10Gbps를 처리할 수 있어도 수신 애플리케이션이 느리게 읽으면 rwnd가 작아져 송신자는 멈춘다.

이 차이를 모르면 느린 전송을 모두 “네트워크 문제”로 부르게 된다. 실제로는 다음 셋이 서로 다르다.

관찰된 현상더 가까운 병목먼저 볼 개념
cwnd가 작고 손실 뒤 회복이 느리다중간 경로 혼잡 또는 손실 기반 회복 한계혼잡 제어
rcv_spacerwnd가 작다수신 커널 버퍼 또는 애플리케이션 read 지연흐름 제어
send는 높지만 rtt가 계속 부풀어 오른다큐가 길어지는 bufferbloat혼잡 신호 해석
새 연결마다 첫 응답이 늦다handshake, TLS, Slow Start 반복연결 재사용
작은 요청만 40ms 단위로 튄다Nagle + Delayed ACK 상호작용작은 패킷 지연

여기서 TLS handshake는 HTTPS 보안 채널을 만들기 전에 인증서 검증과 키 합의를 수행하는 왕복 절차다. 이 문서에서는 “새 연결마다 반복되는 왕복 비용”으로만 다루고, 암호화와 인증의 세부 의미는 다음 tls-https.mdx에서 다룬다.

BDP가 보여주는 “파이프를 채운다”는 뜻

섹션 제목: “BDP가 보여주는 “파이프를 채운다”는 뜻”

BDP(Bandwidth-Delay Product)는 대역폭과 RTT를 곱한 값이다. 경로 위에 동시에 떠 있어야 파이프를 꽉 채울 수 있는 데이터 양을 뜻한다.

BDP = bandwidth x RTT
예: 1Gbps 링크, RTT 100ms
1,000,000,000 bits/s x 0.100s = 100,000,000 bits
100,000,000 bits / 8 = 12.5MB

이 경로에서 송신자가 ACK 없이 12.5MB 정도를 계속 띄워 놓을 수 있어야 1Gbps를 채운다. cwnd나 송신/수신 버퍼가 1MB 수준이면 링크가 1Gbps여도 실제 처리량은 훨씬 낮다.

이 예제는 TCP 튜닝의 핵심 오해를 깨준다. 대역폭을 샀다고 처리량이 자동으로 나오지 않는다. RTT가 크면, 같은 처리량을 내기 위해 더 큰 in-flight 데이터와 더 큰 버퍼가 필요하다.

작은 웹 응답도 같은 원리를 따른다

섹션 제목: “작은 웹 응답도 같은 원리를 따른다”

초기 혼잡 윈도우가 IW10(Initial Window 10)이면 첫 RTT에 보낼 수 있는 데이터는 대략 10 x MSS다. MSS를 1460 bytes로 보면 약 14.6KB다.

초기 cwnd = 10 MSS ~= 14.6KB
HTML 응답 12KB:
첫 RTT 안에 대부분 전달 가능
HTML 응답 30KB:
첫 RTT: 약 14.6KB
ACK 이후 두 번째 RTT: 나머지 데이터

TTFB(Time to First Byte)나 SSR(Server-Side Rendering) 응답을 볼 때도 이 감각이 필요하다. 서버 렌더링 자체가 빠르더라도 새 TCP 연결, TLS handshake, Slow Start가 반복되면 사용자는 첫 바이트를 늦게 받는다. 이 문서의 초점은 프론트엔드 최적화가 아니라, 그 지연을 TCP 개념으로 분해하는 것이다.


2.5 선행 기술의 한계 — Reno에서 BBR까지 등장한 이유

섹션 제목: “2.5 선행 기술의 한계 — Reno에서 BBR까지 등장한 이유”

Reno 계열 TCP는 패킷 손실과 중복 ACK를 혼잡 신호로 삼았다. 이 방식은 작은 버퍼와 낮은 대역폭이 일반적이던 환경에서는 충분히 실용적이었다. 큐가 차면 패킷이 떨어지고, 손실이 보이면 송신자가 속도를 줄이면 됐기 때문이다.

문제는 현대 네트워크에서 손실이 항상 혼잡을 뜻하지 않는다는 점이다.

  • 무선이나 모바일 구간에서는 혼잡이 없어도 노이즈 때문에 손실이 날 수 있다.
  • 깊은 버퍼가 있는 경로에서는 손실이 나기 전에 큐가 길어져 RTT가 먼저 나빠진다.
  • 대륙 간 링크처럼 BDP가 큰 경로에서는 손실 후 선형 회복만으로 파이프를 다시 채우는 데 너무 오래 걸린다.
  • 클라우드 내부처럼 얕은 큐가 많은 경로에서는 순간 손실이 실제 용량 부족보다 먼저 보일 수 있다.

따라서 TCP 혼잡 제어의 진화는 “윈도우를 더 크게 만든다”가 아니다. 핵심 질문은 무엇을 혼잡 신호로 볼 것인가다.

Reno:
손실과 3 duplicate ACK를 주 신호로 본다.
손실 이후 cwnd를 줄이고 천천히 회복한다.
CUBIC:
여전히 손실 기반이지만, 시간의 3차 함수로 손실 전 윈도우 근처까지 빠르게 회복한다.
고BDP 경로에서 Reno보다 훨씬 빠르게 파이프를 다시 채운다.
BBR(Bottleneck Bandwidth and Round-trip propagation time):
손실 자체보다 병목 대역폭과 최소 RTT를 추정한다.
pacing으로 송신 속도를 조절해 큐를 짧게 유지하려 한다.

Google Cloud의 BBR 소개 글은 10Gbps 서버 링크, RTT 100ms, 손실률 1% 조건에서 CUBIC이 약 3.3Mbps까지 떨어진 반면 BBR은 9,100Mbps 이상을 유지한 사례를 제시한다. 마지막 마일 10Mbps, RTT 40ms, 1,000패킷 버퍼 조건의 큐잉 지연도 CUBIC 1090ms 대 BBR 43ms로 비교한다. 이 숫자는 일반적인 데이터센터 내부 경로의 보편값이 아니라, 손실 기반 제어가 잘못된 신호를 믿을 때 얼마나 크게 무너질 수 있는지 보여주는 학습용 극단 사례다.


3. 핵심 철학: TCP는 피드백 루프다

섹션 제목: “3. 핵심 철학: TCP는 피드백 루프다”

TCP 송신자는 미래의 네트워크 상태를 알 수 없다. 그래서 작은 시도를 보내고, 돌아오는 피드백으로 다음 전송량을 조정한다.

보낸다 -> ACK를 받는다 -> RTT와 손실을 본다 -> cwnd를 조정한다 -> 다시 보낸다

이 루프에서 중요한 것은 “한 번의 값”이 아니라 변화 방향이다.

피드백의미과하게 단순화하면 생기는 오해
ACK가 제때 돌아온다경로가 현재 전송량을 처리하고 있다ACK가 온다고 무한히 늘려도 되는 것은 아니다
3 duplicate ACK가 온다일부 세그먼트가 빠졌지만 뒤 세그먼트는 도착했다완전 단절은 아니므로 RTO만큼 가혹하게 줄일 필요는 없다
RTO가 발생한다일정 시간 ACK가 없어 더 심각한 손실로 본다타임아웃은 가장 비싼 신호라 회복 비용이 크다
RTT가 기준보다 오른다큐가 쌓이고 있을 가능성이 있다RTT 증가는 혼잡뿐 아니라 경로 변경, CPU 지연도 포함할 수 있다
delivery rate가 떨어진다병목 대역폭 추정이 낮아질 수 있다애플리케이션이 적게 보내서 낮아진 것과 구분해야 한다

RTO(Retransmission Timeout)는 재전송 타이머다. ACK가 일정 시간 안에 오지 않으면 송신자는 세그먼트가 사라졌다고 보고 재전송한다. 3 duplicate ACK는 같은 ACK 번호가 반복해서 오는 현상이다. 이는 “빠진 세그먼트 뒤의 데이터는 도착했다”는 의미라서, 타임아웃을 기다리지 않고 빠르게 복구할 수 있다.

이 철학은 TCP 밖에서도 반복된다. API Rate Limit, Circuit Breaker, 스트림 백프레셔도 모두 “신호를 보고 보내는 양을 조절한다”는 점에서 같은 구조다.

혼잡 제어를 다른 시스템으로 읽는 법

혼잡 신호

TCP: 패킷 손실, duplicate ACK, RTT 증가, ECN(Explicit Congestion Notification)

API: 429, Retry-After, queue depth, p95 latency

window 또는 credit

TCP: cwnd x MSS = ACK 없이 떠 있을 수 있는 데이터

Stream: request(N), queue capacity, worker concurrency

회복 전략

TCP: Slow Start, Congestion Avoidance, Fast Recovery

API: exponential backoff, jitter, circuit half-open

용량 추정

TCP: BDP = bandwidth x RTT

서비스: Little's Law의 N = lambda x W

이 비교는 비유가 아니라 읽기 프레임이다. 새 시스템을 만났을 때 “신호”, “허용량”, “회복”, “용량 추정”을 분리하면 튜닝값을 외우기 전에 구조를 먼저 볼 수 있다.


4. 해결 메커니즘 1: rwnd와 cwnd를 함께 읽는다

섹션 제목: “4. 해결 메커니즘 1: rwnd와 cwnd를 함께 읽는다”

rwnd(receive window)는 수신자가 알려주는 값이다. TCP 헤더의 Window Size 필드와 Window Scaling 옵션으로 표현된다. 기본 Window Size 필드는 16bit라 최대 65,535 bytes만 직접 표현할 수 있고, 고BDP 경로에서는 이 값이 너무 작다. Window Scaling은 “창 크기 값을 몇 배로 해석할지”를 합의해 긴 RTT·큰 대역폭 경로에서도 충분히 큰 rwnd를 표현하게 해준다.

cwnd(congestion window)는 송신자가 계산하는 값이다. 수신자가 직접 알려주지 않는다. 송신 커널은 ACK, 손실, RTT, 알고리즘 상태를 보고 “중간 경로가 이 정도는 견딜 것 같다”고 추정한다.

예시 1: 수신자 병목
rwnd = 64KB
cwnd = 4MB
실제 전송 가능량 = 64KB
해석:
네트워크가 더 받을 수 있어도 수신자 버퍼나 애플리케이션이 병목이다.
혼잡 제어 알고리즘을 바꿔도 효과가 작다.
예시 2: 네트워크 병목
rwnd = 8MB
cwnd = 128KB
실제 전송 가능량 = 128KB
해석:
수신자는 넉넉하지만 송신자가 경로를 신뢰하지 못한다.
손실, RTT 증가, BDP, 혼잡 제어 상태를 봐야 한다.

이 작은 공식 때문에 TCP 병목 분석의 첫 질문은 항상 같다.

  1. 수신자가 못 받는가?
  2. 중간 네트워크가 못 버티는가?
  3. 송신 애플리케이션이 충분히 보내고 있는가?

세 번째 질문도 중요하다. 애플리케이션이 10KB만 보내고 있다면 cwnd가 작아 보이지 않을 수 있고, delivery rate도 의미 있는 병목 신호가 아니다.

4-2. 관찰값은 “정답”이 아니라 가설이다

섹션 제목: “4-2. 관찰값은 “정답”이 아니라 가설이다”

ss -ti 같은 도구는 cwnd, rtt, retrans, delivery_rate, rcv_space를 보여준다. 하지만 숫자 하나만으로 결론을 내리면 위험하다.

필드풀어 쓴 뜻공부할 때의 해석
cwnd:10혼잡 윈도우가 10 MSS초기 연결 또는 손실 후 회복 초입일 수 있다
ssthreshSlow Start Threshold이 값 이상에서는 더 보수적으로 늘어난다
rtt:1.2/0.6평균 RTT와 편차편차가 커지면 큐잉 또는 경로 불안정을 의심한다
retrans:0/0현재/누적 재전송손실 기반 병목의 직접 신호 중 하나다
rcv_space수신 버퍼 여유 추정작으면 수신 측 흐름 제어 병목을 의심한다
delivery_rate최근 실제 전달률BBR류 알고리즘의 병목 대역폭 추정과 연결된다

좋은 진단은 필드 하나를 외우는 것이 아니라 조합을 본다.

cwnd 작음 + retrans 증가 + rtt 편차 증가:
중간 경로 혼잡 또는 손실 기반 회복 문제 가능성
rcv_space 작음 + 애플리케이션 처리 지연:
수신자 버퍼 또는 read 루프 병목 가능성
delivery_rate 낮음 + 애플리케이션이 조금만 보냄:
TCP 문제가 아니라 송신 workload가 부족한 상태일 수 있음

4-3. Slow Start는 느린 구간이 아니라 탐색 구간이다

섹션 제목: “4-3. Slow Start는 느린 구간이 아니라 탐색 구간이다”

Slow Start라는 이름은 오해를 부른다. 실제로는 RTT마다 cwnd가 거의 두 배로 증가하는 가장 빠른 증가 구간이다. 느린 것은 증가 속도가 아니라, 처음에 경로 용량을 모르는 상태에서 작은 값으로 시작한다는 점이다.

초기 cwnd = 10 MSS라고 가정
RTT 0: cwnd = 10 MSS
RTT 1: ACK 10개 수신 -> cwnd ~= 20 MSS
RTT 2: ACK 20개 수신 -> cwnd ~= 40 MSS
RTT 3: ACK 40개 수신 -> cwnd ~= 80 MSS

이 증가가 계속되면 언젠가는 경로의 실제 용량을 넘는다. 그래서 ssthresh(Slow Start Threshold)라는 경계가 있다. cwndssthresh에 도달하거나 손실이 발생하면 TCP는 Congestion Avoidance로 넘어가 더 조심스럽게 증가한다.

cwnd
^
80| *
40| *
20| *
10| *
1| *
+------------------------> RTT
0 1 2 3

실무에서 새 연결이 느린 이유는 이 탐색을 매번 다시 하기 때문이다. HTTP Keep-Alive, HTTP/2 multiplexing, DB connection pool은 모두 “새 연결과 Slow Start를 반복하지 말자”는 의미가 있다.

4-4. Congestion Avoidance는 조심스러운 재탐색이다

섹션 제목: “4-4. Congestion Avoidance는 조심스러운 재탐색이다”

Congestion Avoidance는 이미 한 번 위험 지점에 가까워졌다고 보고, RTT마다 대략 1 MSS씩 선형으로 늘린다. Reno의 기본 모형에서는 손실이 보이면 cwnd를 줄이고 다시 천천히 올라간다.

Slow Start:
10 -> 20 -> 40 -> 80 MSS
Congestion Avoidance:
80 -> 81 -> 82 -> 83 MSS

이 차이는 고BDP 경로에서 크게 보인다. 12.5MB BDP를 채워야 하는 경로에서 손실 후 cwnd가 절반으로 줄면, 1 MSS씩 회복하는 데 수천 RTT가 필요할 수 있다. CUBIC이 등장한 이유가 여기에 있다.

패킷 손실 감지는 크게 두 가지다.

  1. RTO(Retransmission Timeout): 일정 시간 ACK가 오지 않는다. 경로가 심각하게 막혔다고 보고 cwnd를 크게 줄인다.
  2. 3 duplicate ACK: 같은 ACK 번호가 반복된다. 빠진 세그먼트 뒤 데이터는 도착했으므로, 누락 세그먼트만 빠르게 재전송한다.
송신자 전송: [1][2][3][4][5]
3번 세그먼트 손실
수신자 응답:
ACK(3) -> 다음으로 3번을 기대함
ACK(3) -> 4번이 왔지만 3번이 없어 여전히 3번을 기대
ACK(3) -> 5번이 왔지만 3번이 없어 여전히 3번을 기대
ACK(3) -> 같은 ACK가 반복되며 duplicate ACK가 쌓임
송신자:
3번을 즉시 재전송

Fast Recovery는 여기서 Slow Start로 완전히 돌아가지 않게 해준다. 뒤 세그먼트가 도착했다는 사실은 경로가 완전히 죽지 않았다는 신호다. 그래서 Reno는 대략 ssthresh = cwnd / 2로 낮추고, 복구 후 Congestion Avoidance로 돌아간다.

이 메커니즘의 경계도 중요하다. 중복 ACK가 아니라 RTO가 발생했다면 송신자는 더 심각한 문제로 보고 훨씬 보수적으로 돌아간다. 같은 “재전송”이어도 Fast Retransmit와 RTO는 실패 신호의 강도가 다르다.

퀴즈

`rwnd`는 큰데 `cwnd`가 작고 `retrans`가 증가한다면 먼저 의심할 쪽은?

힌트: 두 윈도우 중 작은 값이 실제 상한이다.

정답 보기

수신자 버퍼보다 중간 경로 혼잡이나 손실 기반 회복 문제를 먼저 의심한다. 실제 전송 가능량은 min(rwnd, cwnd)이므로 작은 cwnd가 전체 전송량을 제한한다.


5. 해결 메커니즘 2: Reno, CUBIC, BBR은 신호가 다르다

섹션 제목: “5. 해결 메커니즘 2: Reno, CUBIC, BBR은 신호가 다르다”

TCP Reno는 전통적인 손실 기반 알고리즘이다. 패킷 손실을 혼잡의 강한 신호로 보고, 손실 이후 cwnd를 줄인 뒤 선형으로 회복한다.

Reno의 직관:
ACK가 잘 온다 -> 조금 더 보낸다
손실이 난다 -> 너무 많이 보냈다고 보고 줄인다
3 duplicate ACK -> 절반쯤 줄이고 빠르게 복구한다
RTO -> 더 심각하므로 크게 후퇴한다

Reno는 간단하고 안정적이지만, 고BDP 경로에서 회복이 느리다.

BDP = 1Gbps x 100ms = 12.5MB
MSS ~= 1460 bytes
12.5MB / 1460 ~= 8,562 MSS
손실 후 cwnd가 4,281 MSS로 줄고,
Congestion Avoidance에서 RTT마다 1 MSS씩 늘어난다면,
손실 전 수준으로 돌아가는 데 약 4,281 RTT가 필요하다.
RTT가 100ms이면:
4,281 x 0.1s = 428.1s

이 계산은 단순화된 학습 모델이지만, 왜 “긴 RTT + 큰 대역폭”에서 Reno가 답답해지는지 보여준다.

5-2. CUBIC: 손실 전 지점까지 빠르게 돌아간다

섹션 제목: “5-2. CUBIC: 손실 전 지점까지 빠르게 돌아간다”

CUBIC은 Linux에서 널리 쓰이는 기본 혼잡 제어 알고리즘이다. 이름처럼 시간의 3차 함수(cubic function)를 사용해 cwnd를 증가시킨다.

W(t) = C x (t - K)^3 + W_max
W_max:
직전 손실 직전의 cwnd
t:
마지막 손실 이후 경과 시간
C, K:
곡선 형태를 정하는 파라미터

수식을 외울 필요는 없다. 학습 핵심은 곡선의 모양이다.

cwnd
^
| * W_max 근처
| *
| *
| *
| *
| *
+--------------------------------> 시간
손실 직후 빠르게 회복
W_max 근처에서는 조심스럽게 탐색

CUBIC은 Reno보다 고BDP 경로를 빨리 다시 채운다. 하지만 여전히 손실 기반이다. 큐가 충분히 깊으면 손실이 나기 전에 RTT가 크게 부풀 수 있고, CUBIC은 그 지연을 늦게 알아차릴 수 있다. 이 현상을 bufferbloat라고 부른다. 버퍼가 “좋은 완충재”가 아니라 “지연을 숨기는 큐”가 되는 상황이다.

5-3. BBR: 병목 대역폭과 최소 RTT를 추정한다

섹션 제목: “5-3. BBR: 병목 대역폭과 최소 RTT를 추정한다”

BBR(Bottleneck Bandwidth and Round-trip propagation time)은 손실 자체보다 병목 대역폭과 최소 RTT를 추정한다. 손실이 나야만 속도를 줄이는 모델이 아니라, 경로가 실제로 전달할 수 있는 속도와 지연을 측정해 pacing한다.

pacing은 데이터를 한 번에 몰아 보내지 않고 일정한 속도로 펴서 보내는 것이다. BBR은 “파이프를 채우되 큐는 길게 만들지 않는다”는 목표를 가진다.

BBR의 간단한 모델:
1. 최근 delivery rate로 병목 대역폭을 추정한다.
2. 최근 min RTT로 큐가 비었을 때의 전파 지연을 추정한다.
3. BDP = 병목 대역폭 x min RTT를 계산한다.
4. 그 정도의 in-flight 데이터만 유지하도록 pacing rate와 cwnd를 조절한다.

BBR의 대표 상태는 다음과 같이 읽으면 된다.

상태목적첫 독서 기준
STARTUP병목 대역폭을 빠르게 찾는다Slow Start처럼 빠르게 올리지만 목표는 bandwidth 추정이다
DRAINSTARTUP에서 쌓인 큐를 비운다일부러 덜 보내 큐잉 지연을 줄인다
PROBE_BW평상시 대역폭을 탐색한다빠르게 보내는 RTT와 느리게 보내는 RTT를 섞는다
PROBE_RTT최소 RTT를 다시 측정한다잠시 in-flight를 낮춰 큐가 빈 지연을 본다

BBR의 장점은 노이즈 손실과 혼잡 손실을 덜 혼동한다는 점이다. 무선 손실 1%가 있어도 delivery rate가 유지된다면, BBR은 “경로 용량이 무너진 것은 아니다”라고 볼 수 있다.

하지만 BBR도 만능이 아니다.

  • token bucket policer는 정해진 속도까지는 토큰을 써서 통과시키고, 허용량을 넘는 burst는 drop 또는 mark하는 rate limiter다. 이런 경로에서는 BBR의 대역폭 탐색 구간이 중간 손실을 만들 수 있다.
  • BBR v1은 일부 환경에서 CUBIC 흐름과의 공정성 문제가 보고됐다.
  • 아주 낮은 RTT의 데이터센터 내부 경로에서는 CUBIC 기본값이 충분할 수 있다.
  • 애플리케이션이 충분히 보내지 않으면 BBR도 병목 대역폭을 제대로 추정할 수 없다.

Reno, CUBIC, BBR 비교

Reno

손실과 duplicate ACK를 중심 신호로 삼는다. 이해하기 쉽지만 고BDP 경로에서 손실 후 회복이 느리다.

학습 기준선, 단순한 손실 기반 제어를 이해할 때

CUBIC

손실 기반이지만 3차 함수로 손실 전 cwnd 근처까지 빠르게 회복한다. Linux 기본값으로 흔하다.

일반 서버, 데이터센터 내부, 기본값을 존중할 때

BBR

병목 대역폭과 최소 RTT를 추정하고 pacing으로 큐를 짧게 유지하려 한다.

장거리, 무선 손실, 고BDP 경로에서 CUBIC 회복이 느릴 때

선택 기준은 “최신 알고리즘을 켠다”가 아니다. 먼저 병목을 분류해야 한다.

상황더 자연스러운 선택이유
데이터센터 내부, RTT가 매우 낮고 손실이 적다CUBIC 기본값 유지튜닝 이득보다 회귀 위험이 클 수 있다
리전 간 파일 전송, RTT 30~100ms, 대역폭이 큰데 처리량이 낮다BDP 계산 후 버퍼와 알고리즘 검토cwnd/버퍼가 파이프를 못 채울 수 있다
모바일/무선 손실이 잦다BBR 검토손실을 혼잡으로만 보지 않는 모델이 유리할 수 있다
p99 RTT가 늘고 큐잉 지연이 의심된다BBR 또는 pacing 검토bufferbloat를 줄이는 방향이 중요하다
상대 서비스나 네트워크가 policer를 쓴다신중한 canary처리량만 보면 BBR 회귀를 놓칠 수 있다

6. 작은 패킷의 경계: Nagle 알고리즘과 Delayed ACK

섹션 제목: “6. 작은 패킷의 경계: Nagle 알고리즘과 Delayed ACK”

Nagle 알고리즘은 작은 TCP 세그먼트가 너무 많이 생기는 문제를 줄이기 위해 송신 측에서 데이터를 잠깐 모아 보내는 규칙이다. 작은 패킷을 흔히 tinygram이라고 부른다.

Nagle의 단순 규칙:
1. 보낼 데이터가 MSS 이상이면 즉시 보낸다.
2. 아직 ACK를 받지 못한 in-flight 데이터가 없으면 즉시 보낸다.
3. in-flight 데이터가 있고 새 데이터가 MSS보다 작으면 ACK가 올 때까지 모은다.

Delayed ACK는 수신자가 ACK만 담긴 작은 패킷을 줄이기 위해 ACK 전송을 잠깐 미루는 방식이다. Linux 환경에서 흔히 40ms 수준의 지연으로 관찰된다. 목적은 ACK를 다른 데이터와 함께 보내거나 여러 ACK를 합치는 것이다.

각각은 효율을 위한 장치다. 문제는 둘이 만나면 서로를 기다릴 수 있다는 점이다.

클라이언트(Nagle ON) 서버(Delayed ACK ON)
| |
|-- write(header, 100B) -------------> | 수신, ACK 지연 타이머 시작
| |
|-- write(body, 50B) |
| Nagle: 이전 데이터 ACK가 없고 |
| body가 MSS보다 작으므로 대기 |
| |
| 서버: 더 올 데이터가 있나 기다림
| |
| ~ 40ms 대기 ~ |
| |
| <------------------------------ ACK |
| |
|-- body 전송 -----------------------> |

이 현상은 평균 지연보다 tail latency에서 먼저 보인다. p50은 괜찮은데 p95나 p99가 40ms 단위로 튀면, 작은 write 패턴과 Nagle/Delayed ACK 조합을 의심할 수 있다.

TCP_NODELAY는 Nagle 알고리즘을 비활성화하는 소켓 옵션이다. 이름은 “TCP 지연 없음”처럼 보이지만, 모든 지연을 없애는 마법이 아니다. 작은 write를 즉시 세그먼트로 내보내도록 허용할 뿐이다.

상황판단이유
실시간 채팅, 게임, SSH, 요청/응답이 작고 지연 민감켜는 쪽이 자연스럽다40ms 대기가 사용자 경험에 직접 보인다
DB/MySQL/Redis 클라이언트의 작은 왕복 요청켜거나 드라이버 기본값 확인작은 패킷 왕복이 많다
HTTP/2, gRPC보통 켜는 쪽이 자연스럽다스트림 단위 지연이 중요하고 구현체가 이미 조절한다
대용량 파일 전송끄거나 기본값 존중작은 패킷보다 throughput과 batching 효율이 중요하다
애플리케이션이 이미 한 번에 큰 버퍼를 write영향이 작다MSS 이상 데이터는 Nagle과 무관하게 바로 나간다

반례를 기억하자. TCP_NODELAY를 켠다고 대역폭이 늘어나는 것은 아니다. 작은 요청의 대기 시간을 줄이는 선택이지, 고BDP 링크의 파이프를 채우는 선택은 아니다. 고BDP 처리량 문제는 cwnd, rwnd, 소켓 버퍼, 알고리즘, BDP를 봐야 한다.

6-4. 코드보다 먼저 write 패턴을 본다

섹션 제목: “6-4. 코드보다 먼저 write 패턴을 본다”

Nagle 문제는 소켓 옵션만의 문제가 아니다. 애플리케이션이 아주 작은 조각을 여러 번 write()하면 문제를 만들 확률이 높다.

Bad pattern:
write(header 100B)
write(body 50B)
flush()
Better pattern:
write(header + body 150B)
flush()

즉, 선택 순서는 보통 다음이다.

  1. 작은 write가 여러 번 나뉘는지 본다.
  2. 프로토콜이나 드라이버가 TCP_NODELAY를 기본으로 켜는지 본다.
  3. p95/p99 지연이 40ms 단위로 튀는지 본다.
  4. 필요하면 TCP_NODELAY를 적용하고 요청 수, 패킷 수, tail latency를 같이 본다.

7. 연결 생애주기: TIME_WAIT, Keep-Alive, 커넥션 풀

섹션 제목: “7. 연결 생애주기: TIME_WAIT, Keep-Alive, 커넥션 풀”

7-1. TIME_WAIT는 낭비가 아니라 안전장치다

섹션 제목: “7-1. TIME_WAIT는 낭비가 아니라 안전장치다”

TIME_WAIT는 연결을 먼저 닫은 쪽이 일정 시간 기다리는 TCP 상태다. MSL(Maximum Segment Lifetime)은 세그먼트가 네트워크에 살아 있을 수 있는 최대 시간이고, 2MSL은 그 두 배다.

TIME_WAIT가 필요한 이유는 두 가지다.

이유 1: 마지막 ACK 유실 대비
클라이언트가 서버의 FIN에 ACK를 보냈다.
그 ACK가 네트워크에서 사라졌다.
서버는 FIN을 다시 보낸다.
클라이언트가 TIME_WAIT에 남아 있으면 다시 ACK할 수 있다.
CLOSED로 바로 사라졌다면:
서버는 예상치 못한 RST(reset)를 볼 수 있다.
이유 2: 오래된 세그먼트가 새 연결에 섞이는 것 방지
이전 연결:
src IP:port A -> dst IP:port B
같은 4-tuple로 새 연결이 너무 빨리 생김:
지연된 이전 세그먼트가 새 연결에 도착할 수 있다.
2MSL 대기:
이전 세그먼트가 네트워크에서 사라질 시간을 준다.

따라서 TIME_WAIT가 많다는 사실만으로 장애는 아니다. 연결을 많이 만들고 닫는 서비스에서는 정상적으로 많을 수 있다. 문제는 로컬 ephemeral port가 고갈될 정도로 단기 연결을 반복하는 경우다.

ephemeral port는 클라이언트가 아웃바운드 연결을 만들 때 임시로 쓰는 로컬 포트다. Linux에서는 설정에 따라 범위가 달라지지만, 넓혀 잡아도 대략 수만 개 수준의 유한한 자원이다. 같은 대상 IP:port로 짧은 연결을 매우 많이 만들면 TIME_WAIT가 이 포트를 점유해 새 연결을 만들지 못할 수 있다.

상황해석더 먼저 할 일
TIME_WAIT가 많지만 에러가 없다정상일 수 있다연결 생성률과 포트 사용률을 같이 본다
cannot assign requested address가 난다ephemeral port 고갈 의심커넥션 풀, keep-alive, 로컬 포트 범위 확인
서버 인바운드 TIME_WAIT가 많다서버가 active close를 많이 한다프로토콜 종료 방향과 idle timeout 확인
CLOSE_WAIT가 계속 늘어난다애플리케이션이 close를 안 한 버그 가능성코드의 socket close 누락을 먼저 본다
tw_recycle을 켜고 싶다위험한 선택NAT 환경 패킷 드롭 때문에 사용하지 않는다

tcp_tw_reusetcp_tw_recycle은 이름이 비슷하지만 경계가 다르다. tw_reuse는 조건이 맞는 아웃바운드 TIME_WAIT 소켓 재사용을 돕는 설정으로 설명되는 경우가 많다. 반면 tw_recycle은 NAT 뒤 여러 클라이언트의 timestamp가 단조 증가하지 않는 문제로 패킷 드롭을 일으킬 수 있어 Linux 4.12에서 제거됐다. 학습 기준은 간단하다. TIME_WAIT를 없애려 하지 말고, 단기 연결을 줄이는 구조를 먼저 본다.

7-3. Keep-Alive는 연결 비용을 아끼지만 오래된 연결을 만든다

섹션 제목: “7-3. Keep-Alive는 연결 비용을 아끼지만 오래된 연결을 만든다”

TCP Keep-Alive는 유휴 연결이 살아 있는지 확인하기 위해 probe를 보내는 커널 기능이다. HTTP Keep-Alive는 같은 TCP 연결로 여러 HTTP 요청을 보내는 애플리케이션/프로토콜 수준의 연결 재사용이다. 이름은 같지만 층이 다르다.

TCP Keep-Alive:
유휴 TCP 연결이 죽었는지 확인한다.
NAT, 방화벽, 미들박스가 조용히 끊은 연결을 감지하는 데 쓰인다.
HTTP Keep-Alive:
같은 TCP 연결로 여러 HTTP 요청/응답을 처리한다.
handshake, TLS, Slow Start 반복을 줄인다.

기본 TCP Keep-Alive 감지 시간은 매우 길 수 있다.

예시 기본값:
tcp_keepalive_time = 7200초
tcp_keepalive_intvl = 75초
tcp_keepalive_probes = 9
총 감지 시간 = 7200 + 75 x 9 = 7875초 ~= 2.2시간

클라우드 로드밸런서, NAT Gateway, 방화벽은 이보다 훨씬 짧은 idle timeout을 가질 수 있다. 그래서 “연결이 살아 있다고 믿고 썼는데 이미 중간 장비가 버렸다”는 실패가 생긴다.

7-4. ALB와 서버 timeout의 작은 반례

섹션 제목: “7-4. ALB와 서버 timeout의 작은 반례”

ALB(Application Load Balancer)의 기본 idle timeout을 60초라고 하자. Node.js HTTP 서버의 keepAliveTimeout이 5초라면 서버가 먼저 연결을 닫을 수 있다. ALB는 아직 재사용 가능한 연결이라고 생각하고 새 요청을 보냈는데, 서버 쪽에서는 이미 닫힌 연결이면 502가 산발적으로 발생할 수 있다.

ALB idle timeout = 60초
서버 keepAliveTimeout = 5초
0초:
요청 처리 완료, 연결 idle
5초:
서버가 연결 종료
30초:
ALB는 연결이 살아 있다고 보고 재사용 시도
결과:
서버는 닫힌 연결로 보거나 RST(reset)를 보냄
ALB 502가 산발적으로 발생

해결의 핵심은 “무조건 timeout을 길게”가 아니다. 연결의 양 끝과 중간 장비의 idle timeout 순서를 맞추는 것이다. 일반적으로 백엔드 서버 timeout은 ALB idle timeout보다 약간 길게 두어 ALB가 먼저 연결 생애주기를 관리하게 만든다.

7-5. 커넥션 풀링은 TIME_WAIT와 Slow Start를 동시에 줄인다

섹션 제목: “7-5. 커넥션 풀링은 TIME_WAIT와 Slow Start를 동시에 줄인다”

커넥션 풀은 DB, Redis, 외부 API 같은 대상과 TCP 연결을 재사용한다. 이득은 세 가지다.

  1. 3-way handshake와 TLS handshake를 반복하지 않는다.
  2. 매번 Slow Start를 다시 겪지 않는다.
  3. 단기 연결 종료가 줄어 TIME_WAIT와 ephemeral port 압박이 줄어든다.

하지만 풀이 크다고 항상 좋은 것은 아니다.

풀 설정너무 작을 때너무 클 때
DB pool max요청이 pool 대기열에서 기다린다DB max_connections와 메모리를 소모한다
HTTP agent max sockets외부 API 호출이 직렬화된다상대 서비스 rate limit과 로컬 포트를 압박한다
idle timeout연결 재사용 이득이 줄어든다죽은 연결을 오래 붙잡거나 중간 장비 timeout과 충돌한다
pre-ping/recycle죽은 연결 감지가 늦다너무 자주 검사하면 요청 전 오버헤드가 늘어난다

첫 기준은 “동시 요청 수와 대상 서비스의 처리량”이다. TCP 관점에서 풀은 빠른 마법이 아니라 연결 수, handshake 비용, TIME_WAIT, 대상 서버 부하를 한꺼번에 조절하는 backpressure 장치다.


8. 소켓 버퍼와 BDP: 튜닝은 계산 뒤에 한다

섹션 제목: “8. 소켓 버퍼와 BDP: 튜닝은 계산 뒤에 한다”

8-1. 버퍼는 rwnd와 throughput의 상한이 된다

섹션 제목: “8-1. 버퍼는 rwnd와 throughput의 상한이 된다”

송신 버퍼(SO_SNDBUF)는 애플리케이션이 write()한 데이터가 네트워크로 나가기 전 머무는 공간이다. 수신 버퍼(SO_RCVBUF)는 네트워크에서 도착한 데이터가 애플리케이션 read() 전까지 머무는 공간이다.

송신 애플리케이션
write()
-> send buffer
-> 네트워크
-> recv buffer
-> read()
수신 애플리케이션

수신 버퍼가 작으면 수신자는 작은 rwnd를 알린다. 그러면 송신자는 cwnd가 충분히 커도 rwnd 때문에 멈춘다.

rwnd = recv buffer 여유 공간
cwnd = 경로 혼잡 기준 전송 허용량
실제 전송 가능량 = min(rwnd, cwnd)

8-2. BDP 계산으로 필요한 버퍼 감각 잡기

섹션 제목: “8-2. BDP 계산으로 필요한 버퍼 감각 잡기”

서울과 도쿄 사이에 10Gbps 링크가 있고 RTT가 30ms라고 하자.

BDP = 10,000,000,000 bits/s x 0.030s
= 300,000,000 bits
= 37.5MB

이론적으로 파이프를 꽉 채우려면 수십 MB 수준의 in-flight 데이터가 필요하다. TCP 수신 버퍼 최대값이 6MB라면 단순 계산상 다음과 같은 상한이 생긴다.

throughput ~= window / RTT
6MB / 0.030s = 200MB/s ~= 1.6Gbps
10Gbps 링크를 샀어도,
윈도우가 6MB 수준이면 10Gbps를 채우기 어렵다.

이 계산은 “버퍼를 무조건 128MB로 올려라”가 아니다. 먼저 현재 경로의 목표 처리량과 RTT를 넣어 필요한 윈도우 크기를 계산하라는 뜻이다.

8-3. 버퍼를 키우면 해결되는 경우

섹션 제목: “8-3. 버퍼를 키우면 해결되는 경우”

버퍼 확대가 의미 있는 신호는 다음처럼 조합으로 나타난다.

  • 장거리 또는 고RTT 경로다.
  • 링크 대역폭은 큰데 단일 TCP 흐름 처리량이 낮다.
  • retrans가 크지 않고, RTT도 폭발적으로 증가하지 않는다.
  • rwnd 또는 수신 버퍼 한계가 BDP보다 작다.
  • 애플리케이션은 충분히 빠르게 읽고 쓴다.

이 경우에는 tcp_rmem, tcp_wmem, rmem_max, wmem_max, 자동 수신 버퍼 조정(tcp_moderate_rcvbuf)을 검토할 수 있다. 하지만 값 자체는 환경 의존적이다. 첫 독서에서는 “BDP보다 작은 버퍼가 throughput 상한이 될 수 있다”는 원리를 잡으면 충분하다.

8-4. 버퍼를 키우면 나빠지는 경우

섹션 제목: “8-4. 버퍼를 키우면 나빠지는 경우”

버퍼가 너무 크면 큐가 길어진다. 손실은 줄어들 수 있지만 지연이 커진다. 특히 CUBIC 같은 손실 기반 알고리즘은 손실이 나기 전까지 큐가 길어지는 것을 늦게 알아차릴 수 있다.

작은 버퍼:
손실은 빨리 보일 수 있다.
지연은 상대적으로 짧다.
큰 버퍼:
손실은 늦게 보일 수 있다.
큐잉 지연이 커져 p95/p99 RTT가 나빠질 수 있다.

따라서 버퍼 튜닝의 관측값은 throughput 하나가 아니다.

목표같이 봐야 할 값실패 신호
단일 흐름 처리량 증가delivery rate, cwnd, rwnd, BDP처리량은 올랐지만 p99 RTT가 2배 이상 증가
손실 감소retrans, duplicate ACK손실은 줄었지만 큐잉 지연이 늘어 사용자 지연 악화
수신자 병목 완화rcv_space, 애플리케이션 read 지연버퍼만 키우고 read 루프 병목은 그대로
리전 간 전송 개선동일 시간대 baseline 비교다른 트래픽의 latency가 악화

운영 절차를 외우기 전에 순서를 외우는 편이 낫다.

  1. 목표를 정한다. throughput인지, tail latency인지, 연결 안정성인지 분리한다.
  2. 경로 RTT와 목표 대역폭으로 BDP를 계산한다.
  3. 현재 rwnd, cwnd, delivery_rate, retrans, rtt를 관찰한다.
  4. 애플리케이션이 충분히 데이터를 공급하고 읽는지 확인한다.
  5. 버퍼, 알고리즘, pacing, pool 설정 중 가장 작은 병목을 하나만 바꾼다.
  6. 같은 시간대, 같은 대상, 같은 workload로 전후를 비교한다.
  7. p95/p99 RTT, 재전송, 상대 트래픽 영향이 나빠지면 되돌린다.

이 순서는 튜닝을 느리게 만들려는 것이 아니다. TCP는 공유 자원을 다루기 때문에 한 노드의 처리량 개선이 다른 흐름의 지연 악화로 바뀔 수 있다.


9-1. 증상에서 개념으로 연결하기

섹션 제목: “9-1. 증상에서 개념으로 연결하기”
증상의미먼저 의심할 개념
retrans가 계속 증가한다세그먼트 손실 또는 혼잡 신호혼잡 제어, MTU/링크 손실
rtt 평균보다 편차가 크게 증가한다큐잉 또는 경로 불안정bufferbloat, pacing
cwnd가 낮은 값에서 자주 리셋된다손실/RTO로 회복이 반복된다Reno/CUBIC 회복, 경로 손실
rcv_space가 작다수신자 버퍼 여유가 작다흐름 제어, 애플리케이션 read
TIME_WAIT가 포트 범위에 가깝다단기 아웃바운드 연결 과다keep-alive, connection pool
CLOSE_WAIT가 누적된다앱이 close를 호출하지 않았다소켓 생애주기 버그
p99만 40ms 단위로 튄다작은 패킷 지연 가능성Nagle + Delayed ACK
ALB 502가 산발적이고 재시도 성공idle timeout 불일치 가능성Keep-Alive, 연결 종료 방향

시나리오 A: 리전 간 전송이 링크 속도보다 훨씬 느리다

섹션 제목: “시나리오 A: 리전 간 전송이 링크 속도보다 훨씬 느리다”
관찰:
서울 -> 도쿄 전송이 10Gbps 링크인데 5MB/s 수준
RTT ~= 32ms
cwnd가 수십 MSS 수준
손계산:
BDP = 10Gbps x 32ms = 40MB
cwnd 45 MSS ~= 65KB
해석:
파이프를 채우려면 수십 MB in-flight가 필요한데,
현재 cwnd/버퍼가 그보다 훨씬 작다.
대응 방향:
애플리케이션 송신량, rwnd, 소켓 버퍼, 혼잡 제어 알고리즘을 순서대로 본다.
바로 BBR을 켜기 전에 CUBIC에서도 버퍼 상한이 병목인지 확인한다.

시나리오 B: DB 요청이 1ms가 아니라 41ms로 튄다

섹션 제목: “시나리오 B: DB 요청이 1ms가 아니라 41ms로 튄다”
관찰:
p50은 낮지만 일부 요청이 40ms 근처로 튄다.
요청 payload가 작고 write가 둘 이상으로 쪼개진다.
해석:
Nagle이 두 번째 작은 write를 잡고,
서버 Delayed ACK가 첫 write의 ACK를 미루는 교착 가능성.
대응 방향:
write를 합칠 수 있는지 본다.
드라이버의 TCP_NODELAY 기본값을 확인한다.
적용 전후 p95/p99와 패킷 수를 같이 본다.

시나리오 C: ALB 뒤 Node 서버에서 산발적 502가 난다

섹션 제목: “시나리오 C: ALB 뒤 Node 서버에서 산발적 502가 난다”
관찰:
ALB access log에는 502가 산발적으로 보인다.
재시도하면 성공한다.
애플리케이션 에러 로그는 빈약하다.
해석:
서버가 ALB보다 먼저 keep-alive 연결을 닫고,
ALB가 그 연결을 재사용하려 했을 가능성.
대응 방향:
ALB idle timeout과 서버 keepAliveTimeout/headersTimeout의 순서를 맞춘다.
연결을 오래 살리는 것만 보지 말고 pool 크기와 idle connection 수를 같이 본다.

최소 관계식은 headersTimeout > keepAliveTimeout이고, 외부 프록시나 ALB의 idle timeout과도 맞아야 한다. 서버가 프록시보다 먼저 연결을 닫으면 재사용 시점에 502가 생기고, 반대로 너무 오래 유지하면 idle connection과 메모리 사용량이 늘 수 있다.

TCP 튜닝의 성공 기준은 단일 메트릭이 아니다.

바꾼 것좋아졌다고 말하려면나빠졌다고 봐야 할 신호
CUBIC -> BBR처리량 증가와 RTT 안정이 같이 보인다처리량은 늘었지만 retrans나 p99 RTT가 크게 증가
버퍼 확대BDP 상한이 완화되고 tail latency가 유지된다큐잉 지연과 메모리 압박이 증가
TCP_NODELAY작은 요청 p95/p99가 줄고 패킷 폭증이 없다패킷 수만 늘고 latency 개선이 없다
Keep-Alive 증가handshake 반복과 TIME_WAIT가 줄어든다죽은 연결 재사용, idle connection 과다
Pool 확대pool 대기 시간이 줄고 대상 서버가 버틴다DB 연결 수, 메모리, lock 경합이 증가

10-1. “BBR이 최신이니 무조건 켠다”는 틀렸다

섹션 제목: “10-1. “BBR이 최신이니 무조건 켠다”는 틀렸다”

BBR은 좋은 도구지만, 손실 기반 알고리즘이 항상 나쁘다는 뜻은 아니다. 낮은 RTT, 낮은 손실, 충분한 버퍼, 안정적인 데이터센터 내부 경로에서는 CUBIC 기본값이 더 예측 가능할 수 있다.

BBR을 검토할 때는 최소한 다음을 같이 본다.

  • 기존 CUBIC baseline의 throughput, p95/p99 RTT, retrans
  • 같은 링크의 다른 트래픽이 굶지 않는지
  • policer나 rate limit이 있는 경로인지
  • fq(Fair Queuing) qdisc 같은 pacing 전제가 맞는지
  • canary에서 회귀 시 되돌릴 수 있는지

qdisc(queueing discipline)는 Linux 네트워크 스택에서 패킷 큐를 관리하는 규칙이다. BBR은 pacing과 함께 동작할 때 의도한 효과가 잘 나오므로, 알고리즘 이름만 보고 판단하면 안 된다.

10-2. “버퍼를 키우면 빠르다”는 절반만 맞다

섹션 제목: “10-2. “버퍼를 키우면 빠르다”는 절반만 맞다”

BDP보다 작은 버퍼는 throughput 상한이 될 수 있다. 그러나 버퍼를 키우면 큐잉 지연도 커질 수 있다. 처리량이 중요한 백업 전송과 지연이 중요한 API 트래픽은 같은 튜닝값을 공유하면 안 된다.

반례:

리전 간 백업 전송:
목표: 큰 파일을 빨리 보낸다.
버퍼 확대와 BBR이 도움이 될 수 있다.
사용자 API 트래픽:
목표: p99 latency를 낮게 유지한다.
큰 큐는 tail latency를 악화할 수 있다.

10-3. “TIME_WAIT가 많으면 줄여야 한다”는 틀렸다

섹션 제목: “10-3. “TIME_WAIT가 많으면 줄여야 한다”는 틀렸다”

TIME_WAIT는 정상 종료의 안전 대기다. 줄여야 할 것은 TIME_WAIT 자체가 아니라, 불필요한 단기 연결 생성일 때가 많다. 풀링과 keep-alive가 먼저고, 커널 knob는 그다음이다.

반대로 CLOSE_WAIT는 다르다. CLOSE_WAIT는 상대가 FIN을 보냈는데 로컬 애플리케이션이 소켓을 닫지 않은 상태다. 시간이 지나도 계속 쌓이면 코드 버그나 리소스 누수를 의심해야 한다.

10-4. “Keep-Alive를 길게 하면 안정적이다”도 틀릴 수 있다

섹션 제목: “10-4. “Keep-Alive를 길게 하면 안정적이다”도 틀릴 수 있다”

Keep-Alive는 handshake와 Slow Start를 줄인다. 하지만 너무 긴 idle connection은 죽은 연결을 오래 붙잡거나, 중간 장비 timeout과 충돌하거나, 대상 서버의 연결 슬롯을 낭비할 수 있다.

좋은 설정은 길이 자체가 아니라 timeout의 정렬이다.

클라이언트 pool idle timeout
< 중간 장비 idle timeout
< 서버가 기대하는 keep-alive 생애주기

실제 값은 환경마다 다르다. 중요한 것은 어느 쪽이 먼저 닫는지, 닫힌 연결을 누가 재사용하려 하는지다.

10-5. SSR/TTFB는 TCP를 설명하는 한 사례일 뿐이다

섹션 제목: “10-5. SSR/TTFB는 TCP를 설명하는 한 사례일 뿐이다”

SSR 응답의 첫 바이트가 늦을 때 TCP를 보면 도움이 된다. 하지만 이 문서는 React SSR 튜닝 문서가 아니다. TTFB를 DNS, TCP, TLS, 서버 처리, Slow Start, CDN 경로로 분해할 수 있으면 충분하다.

TTFB ~= DNS + TCP handshake + TLS handshake + 서버 처리 + 첫 응답 전송

초기 HTML이 14.6KB 근처인지, 연결 재사용이 되는지, CDN과 origin 사이에 새 연결이 반복되는지는 TCP 관점의 좋은 질문이다. 하지만 컴포넌트 렌더링, hydration, route splitting은 별도 프론트엔드 성능 주제다.


11. 판단 순서: 튜닝하기 전에 분류한다

섹션 제목: “11. 판단 순서: 튜닝하기 전에 분류한다”

TCP 문제를 만나면 다음 순서로 좁힌다.

1. 애플리케이션이 충분히 보내고/읽고 있는가?
아니면 TCP가 아니라 앱 처리량 문제일 수 있다.
2. rwnd가 작은가?
수신자 버퍼나 read 루프 병목을 먼저 본다.
3. cwnd가 작은가?
손실, RTT, 혼잡 제어 상태를 본다.
4. RTT가 기준보다 계속 높아지는가?
큐잉 지연과 bufferbloat를 본다.
5. retrans가 증가하는가?
손실, MTU, 경로 품질, policer를 본다.
6. 새 연결이 너무 많은가?
Keep-Alive, pooling, TIME_WAIT, ephemeral port를 본다.
질문CUBIC 유지 쪽 신호BBR 검토 쪽 신호
RTT매우 낮고 안정적리전 간, 모바일, 위성처럼 크거나 변동 큼
손실거의 없고 baseline 안정손실이 있는데 혼잡이 아닐 가능성 큼
처리량목표를 이미 만족BDP 대비 단일 흐름 처리량이 낮음
지연p99가 중요하고 안정적큐잉 지연을 줄여야 함
운영 위험기본값을 바꿀 이유 부족canary와 rollback 기준이 준비됨
조치먼저 만족해야 할 조건피해야 할 적용
소켓 버퍼 확대BDP가 현재 버퍼보다 크고 앱이 충분히 빠름p99 지연이 더 중요한 공유 API 경로에 무턱대고 적용
Pool 확대pool 대기 시간이 병목이고 대상 서버 여유 있음DB 연결 수 한계와 메모리 압박을 무시
Keep-Alive 유지 시간 조정중간 장비 timeout과 순서가 맞지 않음죽은 연결을 오래 붙잡는 방향
TCP_NODELAY작은 요청 tail latency가 문제대용량 전송 throughput 개선 목적으로 적용
BBR 전환고BDP/손실/큐잉 지연 문제가 관측됨baseline 없이 전체 노드에 즉시 적용

11-4. 변경 전후 비교의 최소 조건

섹션 제목: “11-4. 변경 전후 비교의 최소 조건”

TCP 튜닝은 시간대와 트래픽 패턴의 영향을 크게 받는다. 비교 조건을 맞추지 않으면 “좋아진 것처럼 보이는” 착시가 많다.

같은 대상:
같은 리전, 같은 endpoint, 같은 path
같은 workload:
같은 payload 크기, 같은 동시성, 같은 전송 시간
같은 관측 창:
p50/p95/p99, retrans, RTT, delivery rate, error rate
같은 rollback 기준:
처리량 +10%라도 p99 RTT 2배면 실패로 본다

  1. TCP 전송 가능량은 min(rwnd, cwnd)다. 수신자 병목과 네트워크 병목을 먼저 나눈다.
  2. BDP = bandwidth x RTT는 고대역폭 경로를 채우는 데 필요한 in-flight 데이터 감각을 준다.
  3. Reno는 손실을 보고 후퇴하고, CUBIC은 손실 전 지점까지 빠르게 회복하며, BBR은 병목 대역폭과 최소 RTT를 추정한다.
  4. Slow Start는 “느리게 증가”가 아니라 “모르는 경로를 빠르게 탐색”하는 구간이다.
  5. Nagle과 Delayed ACK는 각각 효율 최적화지만, 작은 write가 나뉘면 40ms tail latency를 만들 수 있다.
  6. TIME_WAIT는 안전장치다. TIME_WAIT 자체보다 단기 연결 생성률과 pooling을 먼저 본다.
  7. Keep-Alive와 커넥션 풀은 handshake, Slow Start, TIME_WAIT를 줄이지만 timeout 정렬과 대상 서버 한계를 같이 봐야 한다.
  8. 소켓 버퍼는 BDP보다 작으면 throughput 상한이 되지만, 너무 크면 bufferbloat와 tail latency를 만들 수 있다.
  9. 튜닝은 명령어가 아니라 가설 검증이다. 하나를 바꾸고, 같은 조건에서 전후를 비교한다.
  1. rwnd가 64KB이고 cwnd가 4MB라면 병목은 어느 쪽에 더 가까운가?
  2. 1Gbps, RTT 100ms 경로의 BDP는 얼마인가?
  3. Reno가 고BDP 경로에서 손실 후 회복이 느린 이유는 무엇인가?
  4. CUBIC이 Reno보다 빨리 회복하면서도 bufferbloat 문제를 완전히 없애지 못하는 이유는 무엇인가?
  5. BBR이 패킷 손실 1% 환경에서 CUBIC보다 나을 수 있는 이유는 무엇인가?
  6. Nagle과 Delayed ACK가 만났을 때 40ms 지연이 생기는 구체적인 순서는 무엇인가?
  7. TIME_WAIT가 많은 상황과 CLOSE_WAIT가 쌓이는 상황 중 코드 버그를 먼저 의심해야 하는 쪽은 무엇인가?
  8. ALB idle timeout 60초, 서버 keepAliveTimeout 5초 조합이 산발적 502를 만들 수 있는 이유는 무엇인가?
  9. TCP_NODELAY가 throughput 튜닝이 아니라 tail latency 튜닝에 가까운 이유는 무엇인가?
  10. 버퍼를 키운 뒤 처리량은 늘었지만 p99 RTT가 2배가 됐다면 성공인가?
  1. rwnd가 더 작으므로 수신자 버퍼 또는 애플리케이션 read 병목에 가깝다.
  2. 1,000,000,000 bits/s x 0.1s = 100,000,000 bits = 12.5MB다.
  3. 손실 후 cwnd를 줄이고 RTT마다 대략 1 MSS씩 선형 회복하기 때문이다.
  4. CUBIC도 여전히 손실 기반이라 큐가 길어져 RTT가 커진 뒤에야 반응할 수 있다.
  5. BBR은 손실 자체보다 delivery rate와 min RTT로 병목 모델을 추정하므로 노이즈 손실을 혼잡으로 덜 오해한다.
  6. 첫 작은 write는 전송되고, 두 번째 작은 write는 Nagle이 ACK를 기다리며 보류하고, 수신자는 Delayed ACK로 ACK를 미룬다.
  7. CLOSE_WAIT다. 상대 FIN 이후 로컬 애플리케이션이 close하지 않은 상태일 수 있다.
  8. 서버가 먼저 닫은 연결을 ALB가 살아 있다고 보고 재사용할 수 있기 때문이다.
  9. 작은 write를 즉시 보내 tail latency를 줄이는 옵션이지, BDP나 버퍼 상한을 늘리는 옵션이 아니기 때문이다.
  10. 보통 실패 또는 회귀다. 공유 경로에서는 처리량과 tail latency를 함께 봐야 한다.

본문의 개념을 이해하는 데 아래 명령이 필수는 아니다. 실제 환경을 볼 때 “어떤 신호를 어디서 보나”를 확인하는 짧은 관찰용이다.

1. 현재 연결에서 cwnd, RTT, retrans 보기
Terminal window
ss -ti dst 10.0.0.1

관찰 포인트:

  • cwnd: 혼잡 윈도우
  • rtt: 평균 RTT와 편차
  • retrans: 재전송
  • delivery_rate: 최근 전달률
  • rcv_space: 수신 공간 추정

해석은 단일 필드가 아니라 조합으로 한다. cwnd가 작아도 애플리케이션이 적게 보내는 상황이면 혼잡이라고 단정하지 않는다.

2. 혼잡 제어 알고리즘 확인
Terminal window
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control

관찰 포인트:

  • 현재 새 TCP 연결에 적용되는 알고리즘
  • 커널이 사용할 수 있는 알고리즘 목록

주의:

  • 기존 연결은 변경 전 상태를 유지할 수 있다.
  • 알고리즘 변경은 처리량, p95/p99 RTT, 재전송, 공정성을 같이 비교한다.
3. BBR canary를 볼 때 최소 비교 항목
Terminal window
# 변경 전후 같은 대상, 같은 시간 창에서 비교
ss -ti dst api.partner.example.com | grep -E "bbr|cubic|rtt|retrans|send|delivery_rate"

성공 신호:

  • delivery_rate 또는 실제 처리량 증가
  • retrans 급증 없음
  • p95/p99 RTT가 baseline 범위
  • 같은 링크의 다른 트래픽 악화 없음

회귀 신호:

  • 처리량은 늘었지만 p99 RTT가 크게 증가
  • retrans가 지속적으로 증가
  • CUBIC 흐름이 같은 경로에서 굶음
  • policer가 있는 경로에서 중간 손실 반복
4. TIME_WAIT와 CLOSE_WAIT 구분
Terminal window
ss -tan state time-wait | wc -l
ss -tan state close-wait | wc -l
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

관찰 포인트:

  • TIME_WAIT는 정상 종료에서도 많이 생긴다.
  • CLOSE_WAIT가 계속 쌓이면 애플리케이션 close 누락을 먼저 의심한다.
  • ephemeral port 고갈 에러가 있을 때는 TIME_WAIT 수와 연결 생성률을 같이 본다.
5. Keep-Alive timeout 정렬 확인
Terminal window
# Linux TCP keepalive 기본값
sysctl net.ipv4.tcp_keepalive_time
sysctl net.ipv4.tcp_keepalive_intvl
sysctl net.ipv4.tcp_keepalive_probes

관찰 포인트:

  • 커널 TCP Keep-Alive와 HTTP Keep-Alive는 층이 다르다.
  • ALB, NLB, NAT Gateway, 서버, 클라이언트 pool의 idle timeout 순서를 비교한다.
  • 산발적 502나 reset은 “누가 먼저 닫았는가”를 따라가야 한다.
6. 작은 패킷 지연 관찰
Terminal window
sudo tcpdump -i eth0 -nn port 3306 -w /tmp/mysql.pcap

관찰 포인트:

  • 작은 payload가 여러 TCP 세그먼트로 나뉘는지
  • ACK 전송 사이에 40ms 안팎의 공백이 반복되는지
  • TCP_NODELAY 적용 전후 p95/p99가 줄었는지

주의:

  • 패킷 캡처는 민감 정보가 섞일 수 있다.
  • 프로덕션에서는 캡처 범위, 보관 위치, 삭제 절차를 제한한다.
7. BDP 계산 템플릿
BDP(bytes) = bandwidth(bits/s) x RTT(seconds) / 8
예:
bandwidth = 10Gbps
RTT = 30ms = 0.030s
10,000,000,000 x 0.030 / 8
= 37,500,000 bytes
~= 37.5MB

비교:

  • 현재 cwnd x MSS
  • 현재 rwnd
  • tcp_rmem/tcp_wmem 최대값
  • 실제 애플리케이션 write/read 속도
8. TCP 버퍼 한도 확인
Terminal window
sysctl net.core.rmem_max net.core.wmem_max net.ipv4.tcp_rmem net.ipv4.tcp_wmem

관찰 포인트:

  • BDP 계산값보다 tcp_rmem/tcp_wmem 최대값이 지나치게 낮지 않은가?
  • 애플리케이션 처리 속도가 병목인데 커널 버퍼만 키우려는 것은 아닌가?
  • staging에서 p95/p99와 retransmission 변화를 같이 봤는가?