동시 접속 수만 건을 처리하는 서버를 운영하다 보면, 애플리케이션 코드는 최적화가 끝났는데도 처리량이 어느 지점에서 벽에 부딪히는 경험을 하게 됩니다. CPU도 메모리도 여유가 있는데 새 연결이 거부되거나, 응답 지연이 튀거나, TIME_WAIT 소켓이 수만 개씩 쌓이는 상황입니다. 이런 증상의 상당수는 커널의 기본 TCP 파라미터가 고동시성 워크로드를 가정하지 않기 때문에 발생합니다.

리눅스의 TCP 스택 기본값은 저사양 환경을 기준으로 잡힌 것이 많아 현대의 고성능 서버에서는 지나치게 보수적입니다. 이 글에서는 연결 수립 단계의 백로그 큐부터 소켓 버퍼, 포트 고갈, TIME_WAIT 관리, 측정 방법까지 고동시성 서버 관점에서 정리합니다. 모든 값은 워크로드에 따라 다르므로, 무작정 복사하지 말고 각 파라미터가 무엇을 조절하는지 이해하는 데 초점을 둡니다.

튜닝 전에: 병목이 정말 커널인지 확인하기

파라미터를 만지기 전에 병목이 어디인지 측정해야 합니다. 커널이 연결을 떨어뜨릴 때 남기는 카운터가 있으며, nstat는 이를 델타로 보여줘 “무엇이 늘어나는지”를 파악하기에 좋습니다.

# -a: 0인 카운터도 표시, -z: 조회 후 리셋(델타 측정)
nstat -az | grep -E 'ListenDrops|ListenOverflows|TCPReqQFullDrop'
# ListenOverflows : accept 큐(백로그) 오버플로
# ListenDrops     : SYN 처리 중 드롭
# 위 값이 계속 증가하면 백로그/somaxconn 튜닝이 필요하다는 신호

# 소켓 상태 분포를 빠르게 집계 (TIME_WAIT 등)
ss -tan state all | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

이 카운터가 증가하지 않는데 지연이 있다면 문제는 소켓 버퍼나 애플리케이션 쪽일 가능성이 큽니다. 반대로 ListenOverflows가 초당 수백씩 늘어난다면 백로그 큐가 넘치는 것이므로 다음 섹션이 처방이 됩니다.

연결 수립 경로: SYN 백로그와 accept 큐

TCP 서버로 연결이 들어올 때 커널은 두 개의 큐를 사용하며, 이 둘을 각각 다른 파라미터가 통제합니다.

  • SYN 큐: 아직 3-way 핸드셰이크가 끝나지 않은 half-open 연결을 담는 버퍼. net.ipv4.tcp_max_syn_backlog로 통제.
  • accept 큐: 핸드셰이크가 완료되어 accept() 대기 중인 연결을 담음. listen()의 backlog와 net.core.somaxconn작은 값으로 결정됨.
# somaxconn 기본값은 커널 5.4 이후 4096, 그 이전은 128로 매우 작음
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
# SYN 큐가 넘칠 때 SYN 쿠키로 방어(SYN flood 완화). 폭주 시 활성화
sysctl -w net.ipv4.tcp_syncookies=1

여기서 흔한 함정이 있습니다. somaxconn을 65535로 올려도 애플리케이션이 listen(fd, backlog)에서 작은 backlog를 넘기면 그 값이 실제 상한이 됩니다. Nginx는 listen ... backlog=65535;를 명시해야 하고, 파이썬 socket.listen(128)은 128로 고정됩니다. 커널만 튜닝하고 애플리케이션 설정을 빼먹으면 효과가 없으며, ss -ltn의 큐 열로 실제 상한을 확인할 수 있습니다.

소켓 버퍼: 대역폭-지연 곱에 맞추기

TCP 처리량의 이론적 상한은 대역폭-지연 곱(BDP)이 결정합니다. 수신 윈도우가 BDP보다 작으면 링크가 아무리 빨라도 왕복 지연만큼 대기하느라 파이프를 가득 채우지 못합니다. 리눅스는 자동 튜닝을 기본 지원하므로, 우리가 할 일은 자동 튜닝이 확장할 상한을 충분히 열어주는 것입니다.

# BDP 예: 10Gbps, RTT 30ms → 대역폭*RTT/8 = 10e9*0.03/8 ≈ 36 MB
# 이 값보다 버퍼 상한이 작으면 장거리 고속 링크에서 처리량이 안 나온다

# tcp_rmem/wmem = "최소  기본  최대"(bytes), 최대값을 BDP 이상으로
sysctl -w net.ipv4.tcp_rmem="4096 131072 67108864"
sysctl -w net.ipv4.tcp_wmem="4096 65536 67108864"
# SO_RCVBUF/SO_SNDBUF 명시 설정 시의 전체 상한도 함께 올림
sysctl -w net.core.rmem_max=67108864
sysctl -w net.core.wmem_max=67108864
# 윈도우 스케일링(64KB 초과 윈도우에 필수)은 기본 활성이므로 확인만
sysctl net.ipv4.tcp_window_scaling  # = 1

주의할 점은 버퍼 상한이 연결 수만큼 곱해진다는 것입니다. 동시 연결 10만 개에 소켓당 64MB 상한을 열면 최악의 경우 수 TB의 메모리가 필요합니다. 실제로는 자동 튜닝이 필요한 만큼만 할당하지만, 상한은 “장거리 고속 대량 전송”이 존재할 때만 크게 잡는 것이 안전하고, 짧은 요청-응답 위주의 API 서버라면 기본값이 적절합니다. 또한 setsockopt(SO_RCVBUF)를 직접 호출하면 자동 튜닝이 꺼진다는 점도 함정이니, 특별한 이유가 없으면 상한만 조절하고 버퍼 크기는 커널에 맡기세요.

TIME_WAIT과 포트 고갈

연결을 능동적으로 닫는 쪽(active close)은 TIME_WAIT 상태를 2*MSL(약 60초) 동안 유지합니다. 문제는 초당 수천 건의 짧은 연결을 맺고 끊는 서버, 특히 업스트림으로 요청을 보내는 리버스 프록시나 API 게이트웨이에서 발생합니다. 클라이언트 역할로 능동 종료를 하므로 로컬 포트가 TIME_WAIT으로 소진되어 “Cannot assign requested address” 에러를 냅니다.

# 아웃바운드 로컬 포트 범위 확대 (기본 32768~60999, 약 28,000개)
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# TIME_WAIT 포트 재사용: 타임스탬프로 구분되는 아웃바운드 연결에만 영향
sysctl -w net.ipv4.tcp_tw_reuse=1
# TIME_WAIT 소켓 총량 상한. 초과하면 즉시 파괴
sysctl -w net.ipv4.tcp_max_tw_buckets=1048576

여기서 반드시 짚어야 할 것이 있습니다. 과거 널리 퍼진 net.ipv4.tcp_tw_recycle절대 켜서는 안 됩니다. NAT 뒤 여러 클라이언트를 같은 출발지로 오인해 연결을 무작위로 거부하는 버그로 커널 4.12에서 제거되었습니다. tcp_tw_reuse=1아웃바운드 연결에만 도움이 되며, 근본 해결책은 업스트림 keepalive 연결 풀TIME_WAIT 발생 자체를 줄이는 것입니다.

파일 디스크립터와 연결 한도

모든 소켓은 파일 디스크립터를 하나씩 소비하므로, 동시 연결 10만 개를 목표로 한다면 fd 한도가 그보다 커야 합니다. 이 한도는 커널 sysctl과 프로세스 rlimit 두 계층이며 둘 다 올려야 합니다.

# 시스템 전체 fd 상한 (전 프로세스 합산)
sysctl -w fs.file-max=2097152

# nf_conntrack(iptables/NAT/도커) 사용 시 추적 테이블 상한도 함께 조정
# 작으면 "nf_conntrack: table full" 으로 연결이 조용히 버려짐
sysctl -w net.netfilter.nf_conntrack_max=1048576

프로세스별 한도는 systemd 유닛의 LimitNOFILElimits.conf보다 확실합니다. 컨테이너라면 호스트의 nf_conntrack_max가 병목이 되기 쉽습니다.

# /etc/systemd/system/myapp.service
[Service]
LimitNOFILE=1048576   # 소프트/하드 fd 한도
LimitNPROC=65535      # 스레드/프로세스 한도

혼잡 제어: cubic과 bbr

혼잡 제어 알고리즘은 손실이 있는 장거리 링크에서 처리량에 큰 차이를 만듭니다. 리눅스 기본값 cubic은 패킷 손실을 혼잡 신호로 삼아 손실률이 조금만 있어도 처리량이 급감합니다. bbr은 손실 대신 대역폭과 RTT를 추정하므로 손실이 존재하는 환경에서 훨씬 안정적입니다.

# BBR 활성화 (fq qdisc와 함께 쓰는 것이 권장 조합)
modprobe tcp_bbr
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr

다만 bbr이 항상 정답은 아닙니다. 손실이 거의 없는 데이터센터 내부 링크에서는 cubic과의 차이가 미미하므로, 변경 전후를 실측해 판단해야 합니다.

keepalive와 죽은 연결 정리

롱리브드 연결(웹소켓, gRPC 스트림, DB 커넥션 풀)을 다루는 서버는 상대가 조용히 사라진 좀비 연결이 fd와 메모리를 잡아먹는 문제를 겪습니다. TCP keepalive는 유휴 연결에 주기적으로 프로브를 보내 죽은 연결을 감지하는데, 기본값 2시간 후 첫 프로브는 너무 느립니다.

# 유휴 300초 후 프로브, 30초 간격 최대 5회 → 약 450초에 죽은 연결 정리
sysctl -w net.ipv4.tcp_keepalive_time=300
sysctl -w net.ipv4.tcp_keepalive_intvl=30
sysctl -w net.ipv4.tcp_keepalive_probes=5

다만 sysctl 값은 소켓 생성 시점의 기본값일 뿐이며, 애플리케이션이 setsockopt으로 SO_KEEPALIVE·TCP_KEEPIDLE을 직접 설정하면 그 값이 우선합니다. 프레임워크에 따라 keepalive를 명시적으로 켜야 sysctl 튜닝이 적용됩니다.

영구 적용과 검증

지금까지의 sysctl -w는 재부팅하면 사라집니다. 영구 적용은 /etc/sysctl.d/ 아래 파일로 관리하고, 배포 파이프라인에서 형상 관리하면 서버 간 설정 드리프트를 막을 수 있습니다.

# /etc/sysctl.d/99-highconc-tcp.conf 예시 (핵심 키만)
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_syncookies = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_rmem = 4096 131072 67108864
net.core.rmem_max = 67108864
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_congestion_control = bbr

# 적용: sudo sysctl --system

적용 후에는 반드시 부하 테스트로 검증합니다. wrk -t8 -c10000 -d60s --latency http://127.0.0.1:8080/ 같은 명령으로 부하를 인가하면서, 다른 터미널에서 watch -n1 "nstat -z | grep Listen"으로 카운터를 델타로 관찰합니다. accept 큐 오버플로 카운터가 0으로 유지되는지, TIME_WAIT이 안정적인지, p99 지연이 개선되었는지를 튜닝 전후로 비교하세요.

마무리

고동시성 서버의 TCP 튜닝은 “값을 크게 잡는” 작업이 아니라, 연결 수립·데이터 전송·연결 종료의 각 단계에서 병목을 이해하고 그 지점에만 정밀하게 개입하는 작업입니다. 백로그 큐가 넘치면 somaxconn과 애플리케이션 backlog를, 처리량이 안 나오면 BDP 기준 소켓 버퍼를, 포트가 고갈되면 포트 범위와 연결 재사용을, 좀비 연결이 쌓이면 keepalive를 조정합니다.

가장 중요한 원칙은 측정 먼저, 튜닝 나중입니다. nstatss로 실제 병목을 확인하고, 한 번에 하나씩 바꾸며 부하 테스트로 검증하는 사이클을 돌리면, 커널을 미신적으로 만지지 않고도 하드웨어가 낼 수 있는 처리량에 근접할 수 있습니다. tcp_tw_recycle 같은 폐기된 파라미터를 오래된 가이드에서 복사하지 않도록, 각 값이 지금 커널에서 어떤 의미인지 확인하는 습관도 중요합니다.

자주 묻는 질문

Q. somaxconn을 65535로 올렸는데도 여전히 연결이 거부됩니다. 무엇을 놓친 걸까요?

A. 십중팔구 애플리케이션의 listen() backlog가 작기 때문입니다. accept 큐 크기는 min(애플리케이션 backlog, somaxconn)이므로 커널만 올려서는 소용이 없습니다. Nginx라면 listen ... backlog=65535;, 파이썬이라면 socket.listen(65535)처럼 애플리케이션 쪽 backlog를 함께 올리고, ss -ltn의 큐 열로 실제 상한을 확인하세요.

Q. tcp_tw_reuse와 이미 제거된 tcp_tw_recycle의 차이가 무엇인가요?

A. tcp_tw_reuse는 타임스탬프로 안전하게 구분되는 아웃바운드 연결이 TIME_WAIT 소켓의 포트를 재사용하도록 허용하며, 지금도 안전하게 쓸 수 있습니다. 반면 tcp_tw_recycle은 인바운드 연결까지 공격적으로 재활용하면서 NAT 뒤의 서로 다른 클라이언트를 같은 출발지로 오인해 연결을 거부하는 버그로 커널 4.12에서 제거되었습니다. 오래된 가이드에 tcp_tw_recycle=1이 있다면 무시하고, 근본적으로는 업스트림 keepalive 연결 풀로 TIME_WAIT 발생 자체를 줄이는 것이 정석입니다.