작은 write 두 번에 왜 40ms가 규칙적으로 찍히는가 — Nagle과 delayed ACK의 교착

seonwoo_jung·약 6시간 전

1. 도입 — 대역폭은 남는데 요청마다 40ms

tcpdump를 걸어놓고 클라이언트-서버 왕복을 들여다보면 가끔 이런 그림이 나온다. 데이터는 몇십 바이트뿐이고 네트워크 대역폭은 텅텅 비어 있는데, 요청과 요청 사이에 약 40ms가 자로 잰 듯 규칙적으로 찍힌다. CPU도 놀고 있고, 서버 처리도 순식간인데 왜 latency만 죽는가.

"small write가 느리면 Nagle을 꺼라(TCP_NODELAY)"는 조언은 유명하다. 하지만 나는 느려지는지를 끝까지 따라가 본 적이 없었다. 결론부터 말하면 이건 Nagle 단독의 문제가 아니다. Nagle 알고리즘과 delayed ACK라는, 각자는 지극히 합리적인 두 최적화가 맞물릴 때만 생기는 상호작용 버그다. 이 글은 두 알고리즘의 판정 조건을 각각 짚고, 둘이 어떻게 서로를 기다리는 교착을 만드는지를 정리한다.

2. Nagle 알고리즘 — tinygram을 막는 self-clocking

RFC 896(John Nagle, 1984)이 풀려던 문제는 텔넷 같은 대화형 세션이었다. 키 한 글자를 누를 때마다 payload 1바이트에 헤더 40바이트가 붙은 패킷이 나간다. 헤더 오버헤드가 40배고, 느린 WAN에서는 이 작은 패킷(tinygram)들이 큐에 쌓여 혼잡을 악화시켰다.

Nagle의 규칙은 한 문장이다.

아직 ACK가 안 온(un-ACKed) 데이터가 네트워크에 떠 있고, 지금 보내려는 데이터가 full-sized segment(MSS)보다 작으면 — 보내지 말고 모아라.

모아둔 데이터는 다음 중 하나가 되면 flush된다.

  1. 모인 데이터가 MSS를 채워 full segment가 되거나,
  2. 앞서 보낸(in-flight) 데이터에 대한 ACK가 도착하거나,
  3. 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에 묶인다는 뜻이기도 하다. 이 성질이 뒤에서 문제의 씨앗이 된다.

3. delayed ACK — ACK를 잠깐 쥐고 있기

수신 측 최적화도 비슷한 동기에서 나왔다. 데이터를 받을 때마다 순수 ACK 패킷(헤더만 40바이트)을 즉시 쏘는 건 낭비다. 그래서 RFC 1122 §4.2.3.2는 ACK를 잠깐 미루도록 허용한다.

  • ACK를 지연하되 500ms를 넘기지 않는다(RFC 상한). 실제 Linux 구현은 기본 약 40ms(TCP_DELACK_MIN)~200ms.
  • full-sized segment 2개를 받으면 지연 없이 즉시 ACK한다("ack every second segment").
  • 회신할 응답 데이터가 있으면 그 데이터에 ACK를 piggyback한다.

즉 수신자는 "곧 두 번째 세그먼트나 응답 데이터가 오겠지" 하고 ACK를 잠깐 쥐고 기다린다. 이것도 그 자체로는 합리적이다.

4. 두 최적화가 만드는 교착

문제는 애플리케이션이 하나의 논리적 요청을 여러 번의 작은 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 ───────────▶ 받고 응답 처리

여기서 두 알고리즘이 정확히 반대 방향으로 서로를 기다린다.

  • A(송신자)는 in-flight인 write#1의 ACK가 와야 write#2를 flush한다(Nagle).
  • B(수신자)는 두 번째 세그먼트(=write#2)가 와야 즉시 ACK할 텐데, 그게 안 오니 타이머 만료까지 ACK를 안 준다(delayed ACK).

서로가 서로의 다음 행동을 기다리는 이 데드락이 매 요청마다 delayed ACK 타이머(~40ms)를 통째로 물게 만든다. throughput이 아니라 latency가 죽는 게 특징이다 — 대역폭은 남는데 요청당 40ms가 규칙적으로 찍히는 도입부의 바로 그 증상이다.

상황별로 정리하면 이렇다.

송신 write 패턴Nagledelayed ACK결과
큰 write 1개(≥MSS)즉시 전송응답에 piggyback정상
작은 write 1개 후 응답 대기in-flight 없음 → 즉시타이머 후 ACK, 하지만 응답이 곧 옴대부분 OK
작은 write 2개 이상 연속#2 HOLD#1 ACK 지연40ms 스파이크

5. 해법 — 세 갈래, 그리고 가장 근본적인 것

증상을 없애는 방법은 양쪽 끝 어디서든 가능하다.

// 해법 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가 불필요하게 막히지 않게 한다.

6. 정리

내가 오래 잘못 알고 있던 것들을 바로잡으며 마무리한다.

  • "Nagle이 느림의 원인" → 반만 맞다. Nagle 단독으로는 ACK가 돌아오는 순간 바로 flush하므로 심각한 지연이 없다. delayed ACK와 겹칠 때만 생기는 상호작용 버그다.
  • "작은 write 한 번이면 무조건 40ms" → 아니다. 교착은 미완 세그먼트가 있는데 또 작은 세그먼트를 보내려 할 때(연속 조각 write)만 성립한다.
  • "delayed ACK는 항상 200ms" → 구현마다 다르다. Linux 기본은 약 40ms이고, RFC 1122 상한이 500ms일 뿐 실제 값은 훨씬 짧고 동적으로 조정된다.

각자 합리적인 두 최적화가, 하필 request/response의 조각 write에서 서로를 기다리며 40ms를 만든다. 프로토콜 최적화는 국소적으로 옳아도 조합에서 어긋날 수 있다는 걸 이 사례가 잘 보여준다.

더 파고들 만한 주제로는 Nagle과 동전의 양면인 수신 측 Silly Window Syndrome 회피(RFC 9293 §3.7.4), 그리고 Linux의 delayed ACK 동적화(quick ACK 모드 진입/이탈, ACK compression)가 있다.

참고 자료

  • RFC 896 — John Nagle, "Congestion Control in IP/TCP Internetworks" (tinygram 문제 정의)
  • RFC 1122 §4.2.3.2 — delayed ACK 규칙과 500ms 상한
  • RFC 9293 §3.7.4 — Nagle / SWS avoidance의 현대 통합 기술
  • Linux net/ipv4/tcp_output.ctcp_nagle_check, tcp_minshall_check
  • TCP_NODELAY / TCP_QUICKACK — tcp(7), setsockopt(2) man page

0개의 댓글