본문 바로가기

TCP 심화와 실무

Nagle 알고리즘과 지연 ACK가 함께 만드는 지연, 끊긴 연결을 알아채는 Keep-Alive와 재전송 제한, 첫 요청의 왕복을 줄이는 TCP Fast Open을 정리합니다. CLOSE_WAIT와 연결 풀 고갈, 타임아웃의 종류, SYN Flood와 두 대기열 같은 실무 문제를 확인하는 방법도 함께 봅니다.

앞의 세 글에서 TCP가 연결을 맺고 닫는 방법과 보낼 양을 정하는 방법을 보았습니다. 실제 서비스를 운영하다 보면 이 기본 동작 위에 얹힌 여러 최적화와 설정이 문제를 일으키는 경우가 많습니다. 요청 하나가 이유 없이 40ms씩 늦어지거나, 끊긴 연결을 몇 시간 동안 모르고 있거나, 연결이 맺어지지 않아 서비스가 멈추는 일이 그렇습니다. 이 글에서는 그런 문제를 만드는 TCP의 동작과, 원인을 확인하는 방법을 정리합니다.

작은 쓰기를 모으는 Nagle 알고리즘

IPv4에서 TCP 헤더와 IP 헤더는 각각 최소 20바이트이므로, 1바이트를 보내도 40바이트의 헤더가 붙습니다. 키를 누를 때마다 한 글자씩 보내는 원격 터미널처럼 작은 쓰기가 쏟아지면 네트워크는 대부분 헤더를 실어 나르게 됩니다. Nagle 알고리즘은 이를 줄이려고, 아직 확인받지 못한 데이터가 있는 동안에는 작은 데이터를 바로 보내지 않고 모아 둡니다(RFC 9293 §3.7.4).

새로 보낼 데이터가 생기면
    모은 데이터가 MSS 이상이면 바로 보낸다
    확인받지 못한 데이터가 없으면 바로 보낸다
    그 밖에는 앞의 데이터에 대한 ACK가 올 때까지 모아 둔다

이 규칙 덕분에 RTT 하나 동안 보내는 작은 세그먼트는 하나로 줄어듭니다. 이 규칙이 결정하는 것은 Nagle 때문에 기다릴지뿐이며, 바로 보낼 수 있는 경우에도 수신 윈도우와 혼잡 윈도우의 한도는 그대로 적용됩니다.

받는 쪽에도 비슷한 최적화가 있습니다. 지연 ACK(delayed ACK)는 세그먼트를 받을 때마다 ACK만 담은 세그먼트를 바로 보내지 않고, 잠시 기다렸다가 응답 데이터에 ACK를 함께 싣거나 여러 ACK를 하나로 합칩니다. 기다리는 시간은 구현마다 다르지만, RFC 9293 §3.8.6.3은 0.5초를 넘지 않게 하고, 꽉 찬 세그먼트 두 개마다 적어도 한 번은 ACK하라고 정합니다.

Nagle과 지연 ACK가 만나면

두 최적화는 각각은 합리적이지만, 작은 요청을 두 번에 나누어 쓰는 프로그램에서 만나면 서로를 기다립니다. 클라이언트가 요청의 머리글 A와 본문 B를 따로 썼다고 합시다. A는 확인받지 못한 데이터가 없으니 바로 나가지만, B는 A의 ACK가 올 때까지 Nagle에 붙잡힙니다. 서버는 요청이 다 오지 않았으니 응답할 것이 없고, 작은 세그먼트 하나만 받았으니 ACK를 지연 ACK 타이머가 끝날 때까지 미룹니다.

작은 쓰기가 지연 ACK를 기다리는 한 가지 Nagle 상황

작은 데이터 A를 보내 미확인 상태로 둡니다. 다른 송신 제한도 허용할 때 B를 보냅니다. 각 단계의 값과 전환 이유를 비교합니다.

TCP · 작은 쓰기 지연

Nagle의 작은 쓰기가 지연 ACK를 기다릴 수 있다

미확인 데이터 A가 남고 다음 쓰기 B가 MSS보다 작으면 Nagle이 B를 보류할 수 있습니다. 수신 측도 ACK를 지연하는 경우에는 ACK가 도착한 뒤 B의 송신이 이어집니다.

첫 작은 데이터 뒤 ACK를 기다리는 두 번째 쓰기클라이언트가 작은 데이터A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.작은 데이터 A지연된 ACK작은 데이터 B클라이언트서버B는 송신 버퍼에서 대기
첫 작은 데이터 뒤 ACK를 기다리는 두 번째 쓰기클라이언트가 작은 데이터A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.작은 데이터 A지연된 ACK작은 데이터 B클라이언트서버B 대기Nagle 조건ACK 지연타이머

중간 간격은 실제 시간에 비례하지 않습니다. 지연 ACK에는 구현별 타이머가 있고 영구 대기를 뜻하지 않습니다. TCP_NODELAY는 Nagle을 끄지만 rwnd·cwnd 등 다른 송신 제한까지 없애지는 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 A 송신부터 B 송신까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목작은 쓰기가 지연 ACK를 기다리는 한 가지 Nagle 상황
관계 설명클라이언트가 작은 데이터 A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기 B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 송신

작은 데이터 A를 보내 미확인 상태로 둡니다.

  • 미확인·버퍼 1: A outstanding
  • A의 첫 전송; ACK 대기
B 보류

MSS보다 작은 다음 쓰기 B를 버퍼에 둡니다.

  • 미확인·버퍼 1: B<MSS
  • 미확인·버퍼 2: A 미확인
  • 미확인·버퍼 3: Nagle 대기
  • Nagle 조건으로 B 보류; 서버는 지연 ACK 타이머를 사용할 수 있음
ACK 도착

서버의 지연 확인이 송신자에게 도착합니다.

  • 미확인·버퍼 1: A 확인
  • A가 확인돼 Nagle 대기 조건 해제
B 송신

다른 송신 제한도 허용할 때 B를 보냅니다.

  • 미확인·버퍼 1: B 전송 가능
  • rwnd·cwnd·pacing 조건은 별도; 타이머 값의 실측 예는 아님

작은 쓰기가 지연 ACK를 기다리는 한 가지 Nagle 상황

작은 데이터 A를 보내 미확인 상태로 둡니다. 다른 송신 제한도 허용할 때 B를 보냅니다. 각 단계의 값과 전환 이유를 비교합니다.

TCP · 작은 쓰기 지연

Nagle의 작은 쓰기가 지연 ACK를 기다릴 수 있다

미확인 데이터 A가 남고 다음 쓰기 B가 MSS보다 작으면 Nagle이 B를 보류할 수 있습니다. 수신 측도 ACK를 지연하는 경우에는 ACK가 도착한 뒤 B의 송신이 이어집니다.

첫 작은 데이터 뒤 ACK를 기다리는 두 번째 쓰기클라이언트가 작은 데이터A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.작은 데이터 A지연된 ACK작은 데이터 B클라이언트서버B는 송신 버퍼에서 대기
첫 작은 데이터 뒤 ACK를 기다리는 두 번째 쓰기클라이언트가 작은 데이터A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.작은 데이터 A지연된 ACK작은 데이터 B클라이언트서버B 대기Nagle 조건ACK 지연타이머

중간 간격은 실제 시간에 비례하지 않습니다. 지연 ACK에는 구현별 타이머가 있고 영구 대기를 뜻하지 않습니다. TCP_NODELAY는 Nagle을 끄지만 rwnd·cwnd 등 다른 송신 제한까지 없애지는 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 A 송신부터 B 송신까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목작은 쓰기가 지연 ACK를 기다리는 한 가지 Nagle 상황
관계 설명클라이언트가 작은 데이터 A를 보내고, 미확인 데이터가 남아 다음 작은 쓰기 B를 Nagle 조건으로 보류합니다. 서버의 지연 ACK 타이머 뒤 ACK가 오면 B를 보냅니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 송신

작은 데이터 A를 보내 미확인 상태로 둡니다.

  • 미확인·버퍼 1: A outstanding
  • A의 첫 전송; ACK 대기
B 보류

MSS보다 작은 다음 쓰기 B를 버퍼에 둡니다.

  • 미확인·버퍼 1: B<MSS
  • 미확인·버퍼 2: A 미확인
  • 미확인·버퍼 3: Nagle 대기
  • Nagle 조건으로 B 보류; 서버는 지연 ACK 타이머를 사용할 수 있음
ACK 도착

서버의 지연 확인이 송신자에게 도착합니다.

  • 미확인·버퍼 1: A 확인
  • A가 확인돼 Nagle 대기 조건 해제
B 송신

다른 송신 제한도 허용할 때 B를 보냅니다.

  • 미확인·버퍼 1: B 전송 가능
  • rwnd·cwnd·pacing 조건은 별도; 타이머 값의 실측 예는 아님

영원히 멈추지는 않습니다. 지연 ACK 타이머가 끝나면 ACK가 오고, 그제야 B가 나가 서버가 응답합니다. 하지만 RTT가 1ms인 데이터센터 안에서도 요청마다 지연 ACK 타이머만큼씩 늦어지므로, 응답 시간 그래프에 일정한 크기의 지연이 규칙적으로 나타납니다.

쓰는 방법세그먼트가 나가는 모양응답이 오기까지
두 번에 나누어 쓰기, Nagle 켬A가 먼저 가고 B는 ACK를 기다린다대략 RTT에 지연 ACK 타이머를 더한 시간
두 번에 나누어 쓰기, TCP_NODELAYA와 B가 바로 나간다대략 RTT
한 번에 모아 쓰기세그먼트 하나로 나간다대략 RTT

가장 좋은 해결은 마지막 행처럼 애플리케이션이 보낼 것을 모아 한 번에 쓰는 것입니다. 세그먼트 수도 줄고 Nagle과 부딪히지도 않습니다. 작은 메시지를 바로바로 보내야 하는 게임 서버, 대화형 프로토콜, 요청과 응답이 작은 RPC나 캐시 서버라면 소켓에 TCP_NODELAY를 켜서 Nagle을 끕니다. 많은 HTTP 클라이언트와 데이터베이스 드라이버가 이 옵션을 기본으로 켜 둡니다. 다만 TCP_NODELAY가 없애는 것은 Nagle의 대기뿐이어서, 받는 쪽이 읽지 않아 수신 윈도우가 0이 된 경우처럼 다른 이유로 막힌 전송은 풀어 주지 못합니다.

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    print("기본값에서 Nagle을 끄는가:", sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
    print("TCP_NODELAY를 켠 뒤:", sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0)
기본값에서 Nagle을 끄는가: False
TCP_NODELAY를 켠 뒤: True

새 소켓은 Nagle이 켜진 상태로 시작하므로, 끄고 싶다면 연결마다 직접 옵션을 켜야 합니다.

더 깊이: Linux의 지연 ACK와 TCP_QUICKACK, TCP_CORK

Linux 커널 소스의 include/net/tcp.h는 ACK를 미루는 시간의 하한 TCP_DELACK_MIN을 HZ/25, 곧 40ms로, 상한 TCP_DELACK_MAX를 HZ/5, 곧 200ms로 정해 둡니다. Linux에서 Nagle과 지연 ACK가 부딪힐 때 40ms 안팎의 지연이 자주 보이는 것은 이 하한 때문입니다. tcp(7)에 따르면 커널은 상황에 따라 ACK를 바로 보내는 퀵 ACK(quickack) 모드와 ACK를 미루는 보통의 모드를 스스로 오갑니다.

애플리케이션이 이 동작을 조절하는 옵션도 있습니다. TCP_QUICKACK을 켜면 퀵 ACK 모드로 바뀌지만, 문서가 밝히듯 영구적인 설정이 아니어서 커널이 다시 보통의 모드로 돌아갈 수 있으므로 필요할 때마다 다시 켜야 합니다. 반대로 TCP_CORK는 Nagle보다 더 적극적으로 모아서, 켜 두는 동안은 꽉 차지 않은 세그먼트를 보내지 않다가 옵션을 끄면 모아 둔 것을 한꺼번에 내보냅니다. sendfile()로 파일을 보내기 전에 헤더를 앞에 붙일 때처럼 여러 번의 쓰기를 묶을 때 쓰며, 지금 구현에서는 200ms가 지나면 끄지 않아도 내보내고 TCP_NODELAY보다 우선합니다.

끊긴 연결을 알아채는 Keep-Alive

TCP 연결은 양쪽이 아무것도 보내지 않아도 유지됩니다. 그래서 상대 컴퓨터의 전원이 나가거나, 중간의 NAT 장비가 오래 쓰이지 않은 연결의 정보를 지워 버리면, 이쪽은 다음에 데이터를 보내 볼 때까지 연결이 죽었다는 것을 모릅니다. 한쪽만 연결이 있다고 믿는 이런 상태를 반쯤 열린(half-open) 연결이라고 합니다.

TCP Keep-Alive는 일정 시간 데이터가 오가지 않은 연결에 작은 probe를 보내 상대가 아직 있는지 확인합니다. 표준에서는 선택 기능이고, Linux에서도 소켓에 SO_KEEPALIVE를 켜야 동작합니다. 기본값은 커널 문서에 따르면 마지막 데이터 뒤 첫 probe까지 7200초(tcp_keepalive_time), probe 사이 75초(tcp_keepalive_intvl), 응답 없는 probe 9번(tcp_keepalive_probes)이어서, 기본값만으로는 끊긴 것을 알아채는 데 두 시간이 넘게 걸립니다. 그래서 필요한 소켓에서는 값을 줄여 씁니다. 다음 코드는 Linux의 소켓 옵션(tcp(7))으로 값을 정하고 다시 읽어 봅니다.

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:          # Linux 기준
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 300)         # 마지막 데이터 뒤 첫 probe까지(초)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 30)         # probe 사이 간격(초)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)            # 응답 없는 probe를 몇 번까지 보낼지

    idle = sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE)
    interval = sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL)
    count = sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT)
    print(f"끊긴 것을 알아채기까지 약 {idle + interval * count}초")
    print(f"기본값이라면 약 {7200 + 75 * 9}초")
끊긴 것을 알아채기까지 약 450초
기본값이라면 약 7875초

값을 정할 때는 경로 위의 NAT 장비와 로드 밸런서를 함께 봐야 합니다. 이런 장비는 일정 시간 조용한 연결의 정보를 지우므로, keep-alive의 첫 probe가 그 시간보다 먼저 나가야 연결이 지워지지 않습니다.

Keep-Alive가 확인하는 것은 데이터가 오가지 않는 연결뿐입니다. 데이터를 보냈는데 ACK가 오지 않는 연결에는 재전송 규칙이 적용되며, Linux의 tcp_retries2 기본값 15는 커널 문서의 계산으로 924.6초, 15분이 넘게 지나야 연결을 끊습니다. 이 시간을 줄이려면 TCP_USER_TIMEOUT 옵션으로 확인받지 못한 데이터를 기다릴 최대 시간을 정합니다. 또 probe에 응답이 온다는 것은 상대의 운영체제와 경로가 살아 있다는 뜻일 뿐, 상대 애플리케이션이 요청을 처리할 수 있다는 뜻은 아닙니다. 서비스가 정상인지는 애플리케이션 수준의 상태 확인으로 따로 봐야 합니다.

이름이 비슷한 HTTP Keep-Alive는 다른 개념입니다. HTTP Keep-Alive는 같은 TCP 연결로 여러 HTTP 요청을 주고받겠다는 것이고, TCP Keep-Alive는 조용한 연결이 아직 살아 있는지 확인하는 것입니다.

첫 요청의 왕복을 줄이는 TCP Fast Open

보통의 연결에서 클라이언트는 SYN을 보내고 SYN+ACK를 받은 뒤에야 첫 데이터를 보낼 수 있으므로, 요청을 보내기까지 RTT 하나를 씁니다. TCP Fast Open(RFC 7413)은 처음 연결할 때 서버가 쿠키를 발급하고, 다음에 같은 서버로 연결할 때 클라이언트가 그 쿠키와 첫 요청을 SYN에 함께 실어 보내게 합니다. 서버가 쿠키를 확인하면 핸드셰이크가 끝나기 전에 요청을 애플리케이션에 넘길 수 있으므로, 다시 연결할 때의 첫 응답이 RTT 하나만큼 빨라집니다.

다만 SYN에 실린 데이터는 네트워크에서 중복되어 서버에 두 번 도착할 수 있으므로, 같은 요청이 두 번 처리되어도 괜찮은 요청에만 써야 합니다. SYN에 데이터가 실린 것을 이상하게 보고 버리는 중간 장비도 있어서 인터넷 전체에 널리 쓰이지는 못했습니다. Linux에서는 net.ipv4.tcp_fastopen 설정의 비트로 켜며, 커널 문서에 따르면 클라이언트 기능(0x1)은 기본으로 켜져 있고 서버 기능(0x2)은 꺼져 있습니다. 같은 목적은 지금은 TLS 1.3의 0-RTT나 QUIC으로 이루는 경우가 많습니다.

소켓을 닫지 않아 쌓이는 CLOSE_WAIT

2편에서 본 것처럼 CLOSE-WAIT는 상대가 이미 닫았는데 내 애플리케이션이 아직 소켓을 닫지 않은 상태이고, 이 상태를 끝낼 수 있는 것은 애플리케이션의 close()뿐입니다. CLOSE-WAIT가 쌓이는 가장 흔한 원인은 예외가 난 경로에서 close()에 닿지 못하는 코드입니다. Python에서는 with 문으로 소켓을 감싸면 블록을 어떻게 벗어나든 소켓이 닫힙니다.

import socket

listener = socket.create_server(("127.0.0.1", 0))
client = socket.create_connection(listener.getsockname())
conn, _ = listener.accept()

def handle(conn):
    with conn:                                      # 블록을 어떻게 벗어나든 close()가 불린다
        conn.recv(1024)
        raise ValueError("요청 형식 오류")

client.sendall(b"bad request")
client.shutdown(socket.SHUT_WR)                     # 클라이언트가 먼저 닫는다(FIN)
try:
    handle(conn)
except ValueError as e:
    print("처리 실패:", e)
print("서버 쪽 소켓이 닫혔는가:", conn.fileno() == -1)
print("클라이언트가 받은 것:", client.recv(1024))   # 서버의 FIN이 왔으므로 빈 바이트열
client.close()
listener.close()
처리 실패: 요청 형식 오류
서버 쪽 소켓이 닫혔는가: True
클라이언트가 받은 것: b''

처리 도중 예외가 났지만 with 문이 소켓을 닫았으므로 서버의 FIN이 클라이언트에 도착했고, 서버 쪽 연결은 CLOSE-WAIT에 남지 않습니다. with 문 없이 conn.close()를 함수의 마지막 줄에 두었다면 예외 때문에 그 줄에 닿지 못하고, 연결은 프로세스가 끝날 때까지 CLOSE-WAIT로 남았을 것입니다. 연결 풀을 쓰는 코드라면 연결을 닫는 대신 풀에 돌려주는 일을 같은 방식으로 보장해야 합니다.

연결 풀과 타임아웃

데이터베이스나 외부 API를 부를 때는 연결을 매번 맺지 않고 연결 풀(connection pool)에 모아 두고 빌려 씁니다. 풀의 연결이 모두 빌려 나가 있으면 새 요청은 연결이 돌아올 때까지 기다리는데, 아래쪽 시스템이 느려져 연결이 늦게 돌아오면 기다리는 요청이 쌓이며 서비스 전체가 멈춘 것처럼 보입니다. 풀 크기를 키우면 이미 느려진 아래쪽 시스템을 더 압박할 수 있으므로, 풀에서 기다리는 최대 시간과 요청 전체의 타임아웃을 먼저 정해야 합니다.

타임아웃재는 구간넘었을 때 의심할 것
연결 타임아웃connect()를 시작해 핸드셰이크가 끝날 때까지경로, 방화벽, 서버가 듣고 있는지, 이름 해석까지 포함하는 라이브러리인지
읽기 타임아웃데이터를 기다리기 시작해 다음 데이터가 올 때까지서버의 처리 지연, 경로의 손실
쓰기 타임아웃송신 버퍼에 자리가 날 때까지상대가 읽지 않아 윈도우가 닫혔는지
유휴 타임아웃마지막 데이터 뒤 연결을 정리할 때까지풀과 로드 밸런서의 유휴 시간이 서로 맞는지

라이브러리마다 같은 이름의 타임아웃이 재는 구간이 다릅니다. 읽기 타임아웃이 "응답 전체를 받기까지"가 아니라 "다음 바이트가 오기까지"를 재는 라이브러리라면, 서버가 조금씩 끝없이 보내는 응답은 이 타임아웃에 걸리지 않습니다. 그래서 요청 하나에 걸리는 전체 시간의 상한을 따로 두는 것이 안전합니다.

SYN Flood와 두 개의 대기열

Linux의 리스닝 소켓에는 대기열이 두 개 있습니다. SYN을 받고 SYN+ACK를 보낸 뒤 마지막 ACK를 기다리는 연결은 SYN 대기열에, 핸드셰이크를 마치고 애플리케이션의 accept()를 기다리는 연결은 accept 대기열에 들어갑니다. SYN 대기열에 기억해 둘 수 있는 연결 수의 한도는 tcp_max_syn_backlog이고, accept 대기열의 크기는 listen()에 넘긴 값과 net.core.somaxconn 가운데 작은 값으로 정해집니다. SYN 대기열에 있는 연결도 흔히 반쯤 열린(half-open) 연결이라고 부르는데, 이것은 핸드셰이크가 아직 끝나지 않았다는 뜻으로, 앞에서 본 한쪽이 상대를 잃은 연결과는 다른 상태입니다.

SYN Flood는 마지막 ACK를 보내지 않을 SYN을, 대개 출발지 주소를 속여 대량으로 보내 SYN 대기열을 채우는 공격입니다. 대기열이 차면 정상 사용자의 SYN도 받아들일 자리가 없습니다. SYN cookie는 이에 대한 대표적인 방어로, 대기열이 넘치면 연결 정보를 저장하지 않고 그 정보를 SYN+ACK의 ISN에 암호학적으로 담아 보냅니다. 정상 클라이언트가 마지막 ACK를 보내면 서버는 ACK 번호에서 정보를 되살려 연결을 만듭니다. 커널 문서에 따르면 Linux의 tcp_syncookies 기본값은 대기열이 넘칠 때만 SYN cookie를 쓰는 1입니다.

sysctl net.ipv4.tcp_syncookies net.ipv4.tcp_max_syn_backlog net.core.somaxconn
ss -Htan state syn-recv | wc -l             # 마지막 ACK를 기다리는 연결 수
ss -lnt                                     # LISTEN 소켓의 Recv-Q와 Send-Q
nstat -az TcpExtListenOverflows TcpExtListenDrops

ss -lnt에서 LISTEN 소켓의 Recv-Q는 지금 accept 대기열에서 기다리는 연결 수이고, Send-Q는 그 대기열의 한도입니다. TcpExtListenOverflows가 늘고 있다면 SYN Flood가 아니라 애플리케이션이 accept()를 충분히 빨리 하지 못해 accept 대기열이 넘치는 것일 수 있습니다. 이때 somaxconn만 키우면 기다리는 줄이 길어질 뿐이므로, 애플리케이션이 연결을 받아 가는 속도를 먼저 봐야 합니다. SYN cookie도 넘칠 때를 버티는 수단일 뿐 처리 용량을 늘려 주지는 않으며, 대규모 공격은 앞단의 방화벽이나 DDoS 방어 서비스에서 걸러야 합니다.

점검표

증상확인할 명령먼저 볼 것
TIME-WAIT가 많음ss -s, ss -Htan state time-wait | wc -l연결 재사용, 연결 풀, 임시 포트 범위
CLOSE-WAIT가 계속 늘어남ss -tanp state close-wait예외 경로에서 소켓을 닫지 않는 코드
연결이 맺어지지 않음ss -lnt, nstat -az TcpExtListenOverflowsaccept 대기열이 넘치는지, 애플리케이션의 accept() 속도
SYN-RECV가 급증ss -Htan state syn-recv | wc -lSYN cookie, 앞단의 방화벽과 속도 제한
처리량이 낮음ss -ti, sysctl net.ipv4.tcp_rmemcwnd와 RTT, 대역폭과 RTT의 곱에 맞는 버퍼
재전송이 늘어남nstat -az TcpRetransSegs경로의 손실과 혼잡, 무선이나 터널 구간
요청마다 일정한 짧은 지연패킷 캡처, 애플리케이션의 쓰기 횟수작은 쓰기의 분할, TCP_NODELAY
끊긴 연결을 늦게 알아챔ss -tano의 타이머 정보keep-alive 설정, TCP_USER_TIMEOUT

정리

  • Nagle 알고리즘은 확인받지 못한 데이터가 있는 동안 작은 쓰기를 모으고, 지연 ACK는 ACK를 잠시 미룹니다. 작은 쓰기를 두 번 나누어 하는 프로그램에서는 둘이 만나 요청마다 지연이 생깁니다.
  • 가장 좋은 해결은 보낼 것을 모아 한 번에 쓰는 것이고, 작은 메시지를 바로 보내야 한다면 TCP_NODELAY로 Nagle을 끕니다.
  • TCP Keep-Alive는 조용한 연결이 살아 있는지 확인하며, Linux 기본값으로는 두 시간이 넘게 걸리므로 NAT와 로드 밸런서의 유휴 시간보다 짧게 조정합니다. 데이터가 확인되지 않는 연결은 재전송 규칙과 TCP_USER_TIMEOUT이 다룹니다.
  • TCP Fast Open은 다시 연결할 때 SYN에 첫 요청을 실어 RTT 하나를 줄이지만, 중복 처리되어도 되는 요청에만 써야 합니다.
  • CLOSE-WAIT를 막으려면 예외 경로에서도 소켓이 닫히도록 with 문 같은 구조를 쓰고, 연결 풀과 타임아웃은 재는 구간을 확인하고 정합니다.
  • 리스닝 소켓에는 SYN 대기열과 accept 대기열이 따로 있으며, SYN Flood에는 SYN cookie로, accept 대기열이 넘치는 문제에는 애플리케이션의 처리 속도로 대응합니다.

연습 문제

1. 같은 지연, 다른 원인

어느 서비스에서 두 가지 지연이 보고되었습니다. 하나는 작은 요청을 두 번에 나누어 쓰는 클라이언트에서 요청마다 일정한 지연이 생기는 것이고, 다른 하나는 수신 측 애플리케이션이 데이터를 읽지 않아 수신 윈도우가 0이 된 것입니다. 두 경우 모두 TCP_NODELAY를 켜면 해결된다고 말할 수 있나요?

해설 보기

첫 번째 경우는 Nagle이 두 번째 쓰기를 붙잡고 서버가 ACK를 미루어 생기는 지연일 가능성이 크므로, TCP_NODELAY를 켜거나 쓰기를 하나로 모으면 사라집니다. 패킷 캡처에서 두 번째 세그먼트가 ACK가 온 직후에 나가는지 확인하면 원인을 확정할 수 있습니다. 두 번째 경우는 받는 쪽의 흐름 제어 때문에 막힌 것이어서, TCP_NODELAY를 켜도 윈도우가 열리지 않으므로 데이터를 보낼 수 없습니다. 수신 측 애플리케이션이 왜 읽지 않는지, 처리가 밀렸는지를 조사해야 합니다. 겉보기 증상이 같아도 막고 있는 장치가 다르면 해결책도 다릅니다.

2. NAT의 유휴 시간과 Keep-Alive

클라이언트와 서버 사이에 300초 동안 조용한 연결의 정보를 지우는 NAT 장비가 있습니다. 서버가 Linux의 기본 keep-alive 설정으로 SO_KEEPALIVE만 켰다면, 10분 동안 아무것도 주고받지 않은 연결은 어떻게 되나요? 이 연결을 살려 두려면 어떻게 설정해야 하나요?

해설 보기

기본값으로는 첫 probe가 마지막 데이터 뒤 7200초에야 나가므로, 300초가 지나 NAT가 연결 정보를 지울 때까지 아무것도 보내지 않습니다. 10분 뒤에 어느 쪽이 데이터를 보내면 NAT는 그 패킷이 어느 연결의 것인지 모르므로 버리거나 RST로 답하고, 애플리케이션은 그제야 연결이 끊겼다는 것을 압니다. 연결을 살려 두려면 TCP_KEEPIDLE을 300초보다 넉넉히 짧게, 예를 들어 240초 정도로 정해 NAT가 지우기 전에 probe가 오가게 합니다. 애플리케이션 프로토콜에 자체 ping 메시지가 있다면 그것으로 같은 효과를 낼 수도 있습니다.

3. SYN 대기열이 차는 시간

어느 서버의 SYN 대기열 한도가 1024개이고, SYN+ACK를 다시 보내다 포기하기까지 반쯤 열린 연결 하나가 대기열에 60초쯤 머문다고 합시다. 공격자가 마지막 ACK를 보내지 않는 SYN을 초당 1000개씩 보내면 대기열은 몇 초 만에 차나요? SYN cookie가 켜져 있다면 정상 사용자에게 어떤 차이가 있나요?

해설 보기

연결 하나가 60초 동안 자리를 차지하는데 초당 1000개씩 들어오므로, 1024 ÷ 1000 ≈ 1초 만에 대기열이 찹니다. 그 뒤로는 공격이 계속되는 한 대기열이 비지 않으므로, SYN cookie가 없다면 정상 사용자의 SYN도 버려져 연결이 맺어지지 않습니다. SYN cookie가 켜져 있으면 대기열이 넘친 뒤에도 서버는 정보를 저장하지 않고 SYN+ACK를 보내며, 마지막 ACK를 보내는 정상 사용자의 연결은 ACK 번호에서 정보를 되살려 맺어 줍니다. 다만 SYN+ACK를 보낼 대역폭과 CPU는 여전히 쓰이므로, 공격이 아주 크다면 앞단에서 걸러야 합니다.

다음 장 UDP의 특성에서는 TCP와 반대로 연결도 재전송도 없이 데이터그램을 보내는 UDP를 살펴보고, 언제 어떤 프로토콜을 고를지 판단하는 기준을 세웁니다.