tcpdump를 걸어놓고 클라이언트-서버 왕복을 들여다보면 가끔 이런 그림이 나온다. 데이터는 몇십 바이트뿐이고 네트워크 대역폭은 텅텅 비어 있는데, 요청과 요청 사이에 약 40ms가 자로 잰 듯 규칙적으로 찍힌다. CPU도 놀고 있고, 서버 처리도 순식간인데 왜 latency만 죽는가.
"small write가 느리면 Nagle을 꺼라(TCP_NODELAY)"는 조언은 유명하다. 하지만 나는 왜 느려지는지를 끝까지 따라가 본 적이 없었다. 결론부터 말하면 이건 Nagle 단독의 문제가 아니다. Nagle 알고리즘과 delayed ACK라는, 각자는 지극히 합리적인 두 최적화가 맞물릴 때만 생기는 상호작용 버그다. 이 글은 두 알고리즘의 판정 조건을 각각 짚고, 둘이 어떻게 서로를 기다리는 교착을 만드는지를 정리한다.
RFC 896(John Nagle, 1984)이 풀려던 문제는 텔넷 같은 대화형 세션이었다. 키 한 글자를 누를 때마다 payload 1바이트에 헤더 40바이트가 붙은 패킷이 나간다. 헤더 오버헤드가 40배고, 느린 WAN에서는 이 작은 패킷(tinygram)들이 큐에 쌓여 혼잡을 악화시켰다.
Nagle의 규칙은 한 문장이다.
아직 ACK가 안 온(un-ACKed) 데이터가 네트워크에 떠 있고, 지금 보내려는 데이터가 full-sized segment(MSS)보다 작으면 — 보내지 말고 모아라.
모아둔 데이터는 다음 중 하나가 되면 flush된다.
TCP_NODELAY가 켜져 있는 등의 예외.Linux 커널 net/ipv4/tcp_output.c의 판정 취지를 의사코드로 옮기면 이렇다.
// tcp_nagle_check 취지 — 작은 세그먼트를 보낼지 말지
bool can_send_now(seg) {
if (seg.len >= MSS) return true; // full segment는 항상 즉시
if (nodelay) return true; // TCP_NODELAY
if (no_unacked_small_inflight) return true; // in-flight 작은 조각 없음
return false; // 그 외 → 모은다(hold)
}
핵심은 "네트워크에 떠 있는 작은 세그먼트가 항상 최대 한 개로 제한된다"는 것. ACK가 돌아와야 다음 작은 조각이 나가므로, ACK가 사실상 clock 역할을 한다(self-clocking). RTT가 길어도 tinygram이 폭주하지 않는다. 다만 이 말은 곧, 전송 속도가 RTT에 묶인다는 뜻이기도 하다. 이 성질이 뒤에서 문제의 씨앗이 된다.
수신 측 최적화도 비슷한 동기에서 나왔다. 데이터를 받을 때마다 순수 ACK 패킷(헤더만 40바이트)을 즉시 쏘는 건 낭비다. 그래서 RFC 1122 §4.2.3.2는 ACK를 잠깐 미루도록 허용한다.
TCP_DELACK_MIN)~200ms.즉 수신자는 "곧 두 번째 세그먼트나 응답 데이터가 오겠지" 하고 ACK를 잠깐 쥐고 기다린다. 이것도 그 자체로는 합리적이다.
문제는 애플리케이션이 하나의 논리적 요청을 여러 번의 작은 write로 쪼갤 때 터진다. 헤더를 한 번 write하고 body를 또 write한 뒤 응답을 기다리는, 흔한 request/response 패턴이 대표적이다.
Sender(A, Nagle ON) Receiver(B, delayed ACK ON)
write#1(작은 seg) ──────────────▶ 받음. full seg 2개 아님 → ACK 보류(타이머 시작)
write#2(작은 seg)
└ Nagle: in-flight 미완 seg(#1) 있음 & <MSS → HOLD (전송 안 함)
... ...ACK 아직 안 옴 → A는 계속 대기...
▼ (아무 일도 안 일어남) ▼ delayed ACK 타이머 만료(~40ms)
◀────────── ACK(#1) 뒤늦게 전송
ACK 수신 → in-flight 비었으니
write#2 이제서야 flush ───────────▶ 받고 응답 처리
여기서 두 알고리즘이 정확히 반대 방향으로 서로를 기다린다.
서로가 서로의 다음 행동을 기다리는 이 데드락이 매 요청마다 delayed ACK 타이머(~40ms)를 통째로 물게 만든다. throughput이 아니라 latency가 죽는 게 특징이다 — 대역폭은 남는데 요청당 40ms가 규칙적으로 찍히는 도입부의 바로 그 증상이다.
상황별로 정리하면 이렇다.
| 송신 write 패턴 | Nagle | delayed ACK | 결과 |
|---|---|---|---|
| 큰 write 1개(≥MSS) | 즉시 전송 | 응답에 piggyback | 정상 |
| 작은 write 1개 후 응답 대기 | in-flight 없음 → 즉시 | 타이머 후 ACK, 하지만 응답이 곧 옴 | 대부분 OK |
| 작은 write 2개 이상 연속 | #2 HOLD | #1 ACK 지연 | 40ms 스파이크 |
증상을 없애는 방법은 양쪽 끝 어디서든 가능하다.
// 해법 1: 송신자 — 작은 세그먼트를 모으지 말고 즉시 flush
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
// 해법 2(Linux): 수신자 — 다음 ACK를 지연 없이 (1회성이라 재설정 필요)
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &one, sizeof(one));
// 해법 3(가장 근본): write를 한 번에 (writev/버퍼링) — 애초에 조각내지 않기
한쪽만 꺼도(TCP_NODELAY 또는 TCP_QUICKACK) 교착의 한 변이 사라지므로 대부분 해소된다. 하지만 가장 견고한 해법은 애플리케이션이 논리적 메시지를 한 번의 write(또는 writev)로 보내는 것이다. Nagle을 끄는 건 증상 치료에 가깝고, 조각 write 자체를 없애면 Nagle이 켜져 있어도 교착이 성립하지 않는다.
한 가지 덧붙이면, Linux에는 원래 Nagle을 조금 완화한 Minshall 변형(tcp_minshall_check)이 있다고 알려져 있다. "un-ACKed 데이터가 있으면"이 아니라 "마지막으로 보낸 것이 작은 세그먼트였는지"로 판정을 좁혀, full-sized 전송이 진행 중일 때 뒤따르는 작은 write가 불필요하게 막히지 않게 한다.
내가 오래 잘못 알고 있던 것들을 바로잡으며 마무리한다.
각자 합리적인 두 최적화가, 하필 request/response의 조각 write에서 서로를 기다리며 40ms를 만든다. 프로토콜 최적화는 국소적으로 옳아도 조합에서 어긋날 수 있다는 걸 이 사례가 잘 보여준다.
더 파고들 만한 주제로는 Nagle과 동전의 양면인 수신 측 Silly Window Syndrome 회피(RFC 9293 §3.7.4), 그리고 Linux의 delayed ACK 동적화(quick ACK 모드 진입/이탈, ACK compression)가 있다.
net/ipv4/tcp_output.c — tcp_nagle_check, tcp_minshall_checkTCP_NODELAY / TCP_QUICKACK — tcp(7), setsockopt(2) man page