왜 송신 큐가 지연을 만드는가

애플리케이션이 소켓에 데이터를 write()하면, 그 데이터는 곧바로 네트워크로 나가지 않고 커널의 송신 버퍼에 쌓인다. TCP는 이 버퍼를 채울 수 있는 만큼 채워 두려 하기 때문에, 혼잡 윈도우가 작거나 상대가 아직 ACK를 주지 않은 상황에서도 버퍼에는 수백 KB가 대기할 수 있다.

문제는 응답성이 중요한 워크로드다. 실시간 영상, 게임, 인터랙티브 스트림처럼 "가장 최신 데이터"가 중요한 경우, 이미 버퍼에 쌓인 오래된 데이터가 먼저 나가면서 새 프레임이 지연된다. 또한 write()가 블로킹되거나 epoll의 EPOLLOUT이 늦게 발생해, 애플리케이션이 우선순위를 조정할 타이밍을 놓친다. 이것이 전형적인 버퍼블로트(bufferbloat)의 송신 측 형태다.

tcp_notsent_lowat의 동작 원리

tcp_notsent_lowat는 "아직 네트워크로 전송되지 않은(not sent) 바이트"가 이 임계값 미만일 때만 소켓을 쓰기 가능(writable)으로 표시한다. 즉 이미 전송된(in-flight) 데이터는 계산에서 빠지고, 오직 커널 큐에서 대기 중인 미전송 바이트만 본다.

임계값을 예를 들어 128KB로 잡으면, 미전송 데이터가 128KB 미만일 때만 EPOLLOUT이 뜬다. 애플리케이션은 딱 그만큼만 채우고, 다음 프레임을 만들 때 최신 데이터로 다시 write할 수 있다. 커널 버퍼 자체는 여전히 크게 유지되므로 처리량(throughput)은 손해 보지 않는다.

시스템 전역 설정

전역 기본값은 sysctl로 설정한다. 값의 단위는 바이트다.

bash
# 현재 값 확인 (0 = 비활성, 기본값)
sysctl net.ipv4.tcp_notsent_lowat

# 128KB로 임시 적용
sudo sysctl -w net.ipv4.tcp_notsent_lowat=131072

# 영구 적용
echo 'net.ipv4.tcp_notsent_lowat = 131072' | sudo tee /etc/sysctl.d/90-notsent.conf
sudo sysctl --system

전역값은 IPv4·IPv6 소켓 모두에 적용된다. 다만 전체 서버에 일괄 적용하면 처리량 위주 트래픽(대용량 다운로드 등)에도 영향을 주므로, 가능하면 소켓 단위 설정을 권장한다.

소켓 단위 설정

개별 연결에만 적용하려면 setsockopt의 TCP_NOTSENT_LOWAT 옵션을 쓴다. 전역값을 덮어쓰므로, 지연에 민감한 커넥션만 골라서 튜닝할 수 있다.

python
import socket

# 리눅스 헤더에 정의된 상수 (일부 파이썬 버전엔 없음)
TCP_NOTSENT_LOWAT = getattr(socket, "TCP_NOTSENT_LOWAT", 25)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.0.2.10", 8443))

# 이 소켓의 미전송 임계값을 128KB로
s.setsockopt(socket.IPPROTO_TCP, TCP_NOTSENT_LOWAT, 131072)

# 이후 EPOLLOUT은 미전송 바이트 < 128KB 일 때만 발생
s.sendall(b"...frame data...")

핵심은 이 설정을 논블로킹 I/O + epoll(EPOLLOUT) 구조와 함께 써야 효과가 난다는 점이다. 블로킹 write만 쓴다면 임계값이 write 반환 타이밍에만 영향을 준다.

값을 얼마로 잡을 것인가

임계값은 "한 번의 쓰기 기회에 채울 데이터 양"과 "지연 허용치"의 균형이다. 너무 작으면 write/epoll 호출이 잦아져 CPU가 늘고, 너무 크면 지연 감소 효과가 사라진다.

값특성적합한 워크로드
0 (기본)버퍼를 최대한 채움대용량 전송, 처리량 우선
~16KB지연 최소, 호출 빈번초저지연 인터랙티브
128KB균형점, 리눅스 권장 예시영상/스트리밍 서버

일반적으로 128KB가 처리량을 거의 유지하면서 큐 대기 시간을 크게 줄이는 출발점으로 알려져 있다. 실제 값은 대역폭·프레임 크기에 맞춰 부하 테스트로 확정한다.

주의점과 검증

첫째, 이 옵션은 미전송 바이트만 제한할 뿐 in-flight 데이터나 RTT 자체를 줄이지 않는다. 네트워크 경로 혼잡 문제는 별개이며, 혼잡 제어(BBR 등)와 함께 봐야 한다.

둘째, 애플리케이션이 EPOLLOUT을 제대로 대기하지 않고 write 실패 시 스핀 루프를 돌면 CPU만 태운다. 셋째, 효과 검증은 실제 큐 상태로 확인해야 한다.

bash
# 특정 소켓의 미전송/전송 대기 큐 관찰
ss -tim dst 192.0.2.10

# 출력의 notsent, unacked 값과 send-Q 크기를 튜닝 전후로 비교
watch -n1 'ss -ti dst 192.0.2.10 | grep -E "notsent|unacked"'

설정 전후로 notsent 값이 임계값 아래로 안정화되고, 애플리케이션 측 프레임 지연 지표(p99)가 줄었는지 함께 확인하면 실제 효과를 판단할 수 있다.