안녕하세요, 코딩은 못 하지만 IT가 너무 궁금한 비개발자 루카(Luka)입니다.

thumbnail

어느 날 갑자기 서버에서 'Too many open files'라는 에러가 떴다는 이야기를 들었습니다. 찾아보니 네트워크 연결이 너무 많아서 그렇다는데, 아니 연결을 끊었는데도 왜 남아있다는 걸까요? 연결이 어떻게 시작되고 끝나는지에 대한 근본적인 궁금증이 생겼고, 그 중심에 'TCP 3-Way & 4-Way 핸드셰이크'라는 녀석이 있더라고요. 이름부터 어렵지만, 현직 개발자분들이 이걸로 어떤 고민을 하는지, 또 어떻게 문제를 해결하고 최적화하는지 비개발자의 눈높이에서 파헤쳐 봤습니다.

그게 뭔데? 일단 그림으로 이해해보자! (TCP 3-Way Handshake)

TCP(Transmission Control Protocol)는 인터넷에서 데이터를 주고받을 때 '신뢰성'을 보장하는 핵심 프로토콜입니다. 쉽게 말해, 우편물(데이터)을 보냈을 때 "잘 받았니?" 하고 확인하고 "응, 잘 받았어!"라고 답을 받아야 안심하는 것과 비슷해요.

이 신뢰성 있는 통신을 시작하기 위해 꼭 거쳐야 하는 절차가 바로 TCP 3-Way Handshake(3단계 핸드셰이크)입니다. 마치 전화 통화를 시작하기 전에 "여보세요?" (상대방 확인), "네, 여보세요" (내 존재 알림), "네, 잘 들립니다" (통화 시작 확인) 하는 것과 같아요.

  1. SYN (Synchronize Sequence Numbers): 클라이언트가 서버에게 "나랑 통신할래?" 하고 연결 요청을 보냅니다. 이때 자기의 초기 시퀀스 번호(데이터 순서를 위한 고유 번호)를 함께 보냅니다.
  2. SYN-ACK (Synchronize-Acknowledgement): 서버는 클라이언트의 요청을 받고 "그래, 나도 통신할 준비가 됐어!"라고 응답하면서, 클라이언트의 요청을 잘 받았다는 ACK(확인 응답)와 함께 자기의 초기 시퀀스 번호를 보냅니다.
  3. ACK (Acknowledgement): 클라이언트는 서버의 응답을 받고 "알겠어, 그럼 이제 통신 시작하자!" 하며 ACK를 보내 통신을 최종적으로 확립합니다.

이렇게 3번의 과정을 거쳐야 연결이 안전하게 '수립(Established)'되었다고 볼 수 있어요. 제가 개발자는 아니지만, 이 3단계는 통신이 오가는 양쪽 모두가 서로의 존재와 통신 준비 상태를 확인하고, 데이터 송수신에 필요한 초기 정보를 교환하는 매우 중요한 과정이라는 것을 알 수 있었습니다.

연결 종료도 복잡해? TCP 4-Way Handshake 파헤치기

연결을 시작하는 것만큼, 깔끔하게 연결을 '종료(Closed)'하는 것도 중요합니다. 데이터가 중간에 유실되거나, 불필요한 자원이 계속 점유되지 않도록 하기 위함이죠. 이게 바로 TCP 4-Way Handshake(4단계 핸드셰이크)입니다.

통화를 마치고 "나 이제 끊을게" (한쪽의 종료 요청), "알겠어, 너 끊어도 돼" (종료 확인), "나도 이제 할 말 다 했어, 나도 끊을게" (다른 쪽의 종료 요청), "그래, 나도 확인했어" (최종 종료 확인)와 비슷해요.

  1. FIN (Finish): 통신을 종료하고 싶은 한쪽(예: 클라이언트)이 "나 이제 데이터 보낼 거 없어, 연결 끊고 싶어"라는 FIN 플래그를 보냅니다.
  2. ACK (Acknowledgement): 상대방(예: 서버)은 FIN 요청을 받고 "알겠어, 네 종료 요청 잘 받았어"라고 ACK를 보냅니다. 이때 서버는 아직 보낼 데이터가 있다면 계속 보낼 수 있습니다. 이 상태를 '하프-클로즈드(Half-Closed)' 상태라고 부릅니다.
  3. FIN (Finish): 서버도 보낼 데이터가 더 이상 없으면, "나도 이제 데이터 보낼 거 없어, 나도 종료할게"라고 FIN 플래그를 보냅니다.
  4. ACK (Acknowledgement): 클라이언트는 서버의 FIN 요청을 받고 "응, 알겠어. 그럼 이제 완전히 종료하자"라고 ACK를 보냅니다.

여기서 중요한 점은, 클라이언트가 마지막 ACK를 보내고 나서 바로 연결을 끊는 것이 아니라, 일정 시간(보통 120초) 동안 TIME_WAIT 상태로 대기한다는 것입니다. 왜 이렇게 복잡하고 오래 기다려야 하는지 처음엔 의아했는데, 아래에서 설명할 '실전 문제'와 깊은 연관이 있더라고요.

현직 개발자들은 이걸로 뭘 고민할까? 실전 문제와 최적화

제가 개발자는 아니지만, 이 과정을 파고들면서 현직 개발자분들이 어떤 점에서 골머리를 앓고, 또 어떻게 최적화하는지 엿볼 수 있었습니다. 단순히 연결을 맺고 끊는 것 이상의 복잡한 상황들이 많더라고요.

문제 1: 서버 포트 고갈과 '좀비' TIME_WAIT 상태

서버 개발자 친구가 가장 스트레스받는다고 한 게 바로 이 'TIME_WAIT' 상태였습니다. 고성능 웹 서버나 API 서버처럼 수많은 클라이언트 연결을 처리해야 하는 경우, 연결 종료 후 클라이언트 쪽 소켓(정확히는 포트와 IP 조합)이 TIME_WAIT 상태로 묶여버리는 시간이 너무 길다는 거죠. 리눅스에서는 기본적으로 120초(2 * MSL) 동안 이 상태를 유지합니다.

만약 초당 100개의 새로운 연결이 생기고 끊어진다면, 1분 후에는 100 connections/sec * 60 sec = 6000개의 소켓이 TIME_WAIT 상태가 되고, 2분 후에는 100 * 120 = 12000개의 소켓이 이 상태에 갇히게 됩니다. 서버가 재사용할 수 있는 포트 번호는 약 6만 개인데, 이게 계속 쌓이면 결국 새로운 연결을 맺을 포트가 부족해져 'Cannot assign requested address' 같은 에러가 발생하게 됩니다. 마치 수많은 사람들이 전화 통화를 끝내고 수화기를 내려놓았는데, 전화선이 2분 동안 먹통이라 아무도 전화를 걸거나 받을 수 없는 상황과 비슷하죠.

해결책: TIME_WAIT 소켓 재사용 및 대기 시간 단축

이 문제를 해결하기 위해 개발자들은 리눅스 커널 파라미터를 조정합니다. 제가 직접 터미널에 쳐봤더니, 이런 명령어로 설정하더라고요.

# TIME_WAIT 소켓을 빠르게 재사용하도록 허용
sudo sysctl -w net.ipv4.tcp_tw_reuse = 1
# 설정 영구 적용을 위해 /etc/sysctl.conf 파일 수정 (예시)
# net.ipv4.tcp_tw_reuse = 1

이 설정은 서버가 새로운 연결 요청을 받았을 때, 기존에 TIME_WAIT 상태에 있던 소켓을 재빨리 회수하여 재사용하도록 허용합니다. 개발자 친구 말로는, 이렇게 설정하면 초당 수백 개의 연결도 안정적으로 처리할 수 있으며, 특히 웹 크롤러나 짧은 연결이 많은 환경에서 서버 자원 효율성을 30% 이상 향상시킨다고 합니다.

또 다른 설정은 tcp_fin_timeout입니다. 이 설정은 서버가 클라이언트로부터 FIN을 받고 ACK를 보낸 후(FIN_WAIT_2 상태) 얼마나 기다릴지를 정하는 시간입니다. 기본값은 60초입니다.

# FIN_WAIT_2 상태의 대기 시간을 30초로 단축 (기본 60초)
sudo sysctl -w net.ipv4.tcp_fin_timeout = 30
# 설정 영구 적용을 위해 /etc/sysctl.conf 파일 수정 (예시)
# net.ipv4.tcp_fin_timeout = 30

tcp_fin_timeout을 30초로 단축하면, 클라이언트가 FIN-ACK를 보내지 않고 끊어져 버렸을 때 서버가 불필요하게 오래 대기하는 것을 줄여, 최대 50%의 자원 낭비를 막을 수 있습니다.

문제 2: 느린 초기 연결 설정과 성능 저하

3-Way Handshake 과정에서 SYN, SYN-ACK, ACK 세 번의 메시지가 네트워크를 오가야만 비로소 데이터 전송이 시작됩니다. 이 과정은 짧은 시간(수십 ~ 수백 밀리초)이지만, 웹 페이지 로딩처럼 수십, 수백 개의 TCP 연결이 동시에 필요한 상황에서는 이 지연 시간(Latency)이 사용자 경험을 크게 저해할 수 있습니다. 특히 물리적으로 거리가 먼 서버와 통신할수록 RTT(Round Trip Time, 왕복 시간)가 길어져 더 느려지죠.

해결책: TCP Fast Open (TFO)

이 문제를 해결하기 위해 등장한 것이 TCP Fast Open(TFO)입니다. TFO는 첫 연결 설정 시 SYN 패킷에 실제 데이터를 함께 보내서 최대 1 RTT(왕복 시간)의 지연 시간을 줄이는 기술입니다.

TFO가 작동하는 방식은 이렇습니다: 1. 클라이언트가 서버와 처음 연결할 때 TFO 요청과 함께 SYN 패킷에 데이터를 실어 보냅니다. 2. 서버는 이 데이터를 받고 TFO Cookie를 생성하여 SYN-ACK와 함께 클라이언트에 보냅니다. 3. 클라이언트는 이 Cookie를 저장해 둡니다. 4. 이후 클라이언트가 서버에 재연결할 때는 저장된 TFO Cookie와 함께 SYN 패킷에 데이터를 실어 보냅니다. 서버는 Cookie를 검증하고 유효하면 3-Way Handshake 완료 전에 데이터를 처리하기 시작합니다.

# TCP Fast Open 활성화 (클라이언트 및 서버 모두 필요)
# 클라이언트: sudo sysctl -w net.ipv4.tcp_fastopen = 1
# 서버: sudo sysctl -w net.ipv4.tcp_fastopen = 3 (0=비활성화, 1=클라이언트 활성화, 2=서버 활성화, 3=클라이언트/서버 모두)
sudo sysctl -w net.ipv4.tcp_fastopen = 3
# 설정 영구 적용을 위해 /etc/sysctl.conf 파일 수정 (예시)
# net.ipv4.tcp_fastopen = 3

이 설정을 통해 웹 페이지 로딩 시 첫 연결 설정 시간이 평균 50ms 단축될 수 있으며, 많은 연결을 동시에 처리하는 API 서버에서는 전체 처리량을 10% 이상 증가시키는 효과를 가져올 수 있다고 합니다.

문제 3: 갑작스러운 트래픽 폭증과 SYN Flood 공격

서버에 대한 연결 요청(SYN)이 너무 많이 들어오면 어떻게 될까요? 정상적인 요청이라면 문제가 없지만, 악의적인 목적으로 대량의 SYN 요청을 보내 서버의 자원을 고갈시키는 공격을 SYN Flood 공격이라고 합니다. 서버는 각 SYN 요청에 대해 SYN-ACK를 보내고 응답을 기다리는데, 공격자는 마지막 ACK를 보내지 않아 서버의 'half-open' 연결 큐를 가득 채워버립니다. 결국 정상적인 사용자의 연결 요청은 처리되지 못하고 서비스가 마비됩니다.

해결책: SYN Cookies와 SYN Backlog 증설

리눅스 커널은 이런 공격을 방어하기 위한 강력한 메커니즘을 제공합니다.

# SYN Cookies 활성화 (SYN Flood 공격 방어)
sudo sysctl -w net.ipv4.tcp_syncookies = 1
# SYN 대기열 (backlog) 크기 증가 (기본 1024)
sudo sysctl -w net.ipv4.tcp_max_syn_backlog = 4096
# 설정 영구 적용을 위해 /etc/sysctl.conf 파일 수정 (예시)
# net.ipv4.tcp_syncookies = 1
# net.ipv4.tcp_max_syn_backlog = 4096

tcp_syncookies는 SYN Flood 공격 시 서버가 SYN-ACK를 보낼 때 특정 쿠키를 함께 보내고, 클라이언트가 ACK로 응답할 때 이 쿠키를 다시 검증하는 방식으로 작동합니다. 이렇게 하면 서버가 모든 half-open 연결 정보를 메모리에 저장하지 않아도 되므로, 공격 시 초당 10,000개 이상의 SYN 요청도 효율적으로 방어할 수 있습니다. tcp_max_syn_backlog는 SYN_RECV 상태의 최대 연결 수를 늘려주어, 일시적인 트래픽 폭증에도 서버가 더 많은 연결을 수용할 수 있게 해줍니다. 기본값 1024에서 4096으로 증설하면 약 4배 더 많은 동시 대기 연결을 처리할 수 있습니다.

문제 4: 유휴 연결 관리와 리소스 효율 (Keep-Alive)

클라이언트와 서버 간에 연결이 맺어졌지만, 한동안 데이터를 주고받지 않는 '유휴(Idle)' 상태인 경우도 많습니다. 예를 들어, 채팅 애플리케이션에서 사용자가 잠시 다른 작업을 하는 동안 연결은 유지되지만 실제 데이터 교환은 없는 경우죠. 이런 유휴 연결을 무작정 오래 유지하면 서버 자원 낭비가 심해지고, 반대로 너무 빨리 끊으면 매번 3-Way Handshake를 새로 해야 해서 비효율적입니다.

해결책: TCP Keep-Alive

TCP Keep-Alive는 유휴 상태의 연결이 활성 상태인지 주기적으로 확인하여, 불필요한 연결은 자동으로 종료하고 필요한 연결은 유지시켜주는 기능입니다.

# Keep-Alive 패킷을 보내는 주기 설정 (기본 7200초 = 2시간)
sudo sysctl -w net.ipv4.tcp_keepalive_time = 300
# Keep-Alive 패킷 재전송 횟수 설정 (기본 9회)
sudo sysctl -w net.ipv4.tcp_keepalive_probes = 5
# Keep-Alive 패킷 재전송 간격 설정 (기본 75초)
sudo sysctl -w net.ipv4.tcp_keepalive_intvl = 30
# 설정 영구 적용을 위해 /etc/sysctl.conf 파일 수정 (예시)
# net.ipv4.tcp_keepalive_time = 300
# net.ipv4.tcp_keepalive_probes = 5
# net.ipv4.tcp_keepalive_intvl = 30

tcp_keepalive_time을 기본 7200초(2시간)에서 300초(5분)로 조정하면, 유휴 연결이 5분 이상 데이터를 주고받지 않을 경우 Keep-Alive 패킷을 보내 연결 상태를 확인합니다. 만약 지정된 횟수(tcp_keepalive_probes, 기본 9회)만큼 재전송 간격(tcp_keepalive_intvl, 기본 75초) 내에 응답이 없으면 연결을 끊습니다. 이 설정으로 유휴 상태의 리소스 낭비를 줄이고, 서버의 동시 연결 처리 능력을 최대 20%까지 향상시킬 수 있습니다.

비개발자가 헷갈리기 쉬운 부분 2~3가지

제가 직접 파헤치면서 가장 헷갈렸던 부분들이 있었어요. 개발자분들은 당연하게 생각할지 모르지만, 비개발자 시선에서는 "어? 이게 왜 이렇지?" 하는 지점들입니다.

1. CLOSE_WAIT vs. TIME_WAIT: 대체 뭐가 다른데?

가장 헷갈렸던 부분입니다. 둘 다 연결이 종료되는 과정에서 발생하는 상태인데, 어떤 차이가 있는지 궁금했어요.

  • CLOSE_WAIT: 서버에서 이 상태가 많이 보인다면, 클라이언트로부터 FIN 요청(연결 종료 요청)은 받았지만, 서버 애플리케이션이 아직 자신의 소켓을 닫지 않은 상태를 의미합니다. 쉽게 말해, 클라이언트는 "나 이제 할 말 없어"라고 했는데, 서버는 "응, 알겠어. 근데 나도 할 말이 있어서 아직은 못 끊어" 하는 상황인 거죠. 이 상태가 오래 지속된다면, 대부분 서버 애플리케이션 코드에 문제가 있어서 소켓을 제대로 닫지 못하고 있다는 뜻입니다. 흔히 애플리케이션 버그로 인한 '리소스 누수(Resource Leak)'를 의미할 때가 많다고 해요.

  • TIME_WAIT: 이 상태는 클라이언트(혹은 먼저 연결을 종료한 쪽)가 마지막 ACK를 보낸 후, 일정 시간 동안 기다리는 상태입니다. 아까 위에서 120초 정도 기다린다고 했죠? 이 대기 시간은 네트워크에 남아있을 수 있는 '지연 패킷'이 완전히 사라지도록 하고, 같은 포트 번호로 새 연결이 시작될 때 이전 연결의 패킷이 뒤섞이는 문제를 방지하기 위함입니다. 이건 정상적인 종료 과정의 일부이며, 위에서 설명한 tcp_tw_reuse 같은 설정으로 이 대기 시간을 최적화할 수 있습니다.

한마디로, CLOSE_WAIT는 애플리케이션 문제일 가능성이 높고, TIME_WAIT는 정상적인 TCP 프로토콜 동작의 일부지만 과도할 경우 자원 문제가 될 수 있다는 점이 큰 차이였습니다.

2. 하프-오픈(Half-Open) vs. 하프-클로즈드(Half-Closed): 반쪽짜리 연결은 뭐야?

이것도 처음에 용어가 너무 비슷해서 헷갈렸어요.

  • 하프-오픈 (Half-Open) 연결: 3-Way Handshake 과정에서 SYN 요청은 왔는데, 서버가 SYN-ACK를 보내고 클라이언트로부터 마지막 ACK를 받지 못한 상태를 말합니다. 보통 클라이언트가 연결을 요청하고 응답을 받지 못했거나, SYN Flood 공격 등으로 인해 발생합니다. 서버 입장에서는 연결이 '열려있는 것처럼' 보이지만, 실제로는 아직 온전한 통신 준비가 되지 않은 불안정한 상태입니다.

  • 하프-클로즈드 (Half-Closed) 연결: 4-Way Handshake 과정에서 한쪽이 FIN을 보내고 상대방이 ACK로 응답했지만, 아직 데이터를 마저 주고받을 수 있는 상태를 말합니다. 즉, 한쪽 방향의 데이터 흐름은 종료되었지만 다른 쪽 방향은 여전히 데이터를 보낼 수 있는 상태입니다. 이는 TCP의 특징 중 하나로, 한쪽에서 종료를 요청해도 나머지 한쪽이 보낼 데이터가 있다면 그것을 마저 보내도록 허용하는 유연성을 제공합니다.

3. TCP 포트 개수는 무한정일까?

저는 서버에 연결할 수 있는 포트가 무한정 많을 줄 알았는데, 아니더라고요. TCP/IP 통신에서 사용할 수 있는 포트 번호는 0부터 65535까지 총 65536개입니다. 이 중에서 0-1023번 포트는 잘 알려진 서비스(HTTP 80, HTTPS 443 등)에 예약되어 있고, 1024-49151번은 등록된 포트, 그리고 49152-65535번은 동적/사설 포트(Ephemeral Ports)로 클라이언트가 서버에 연결할 때 임시로 할당되는 포트입니다.

즉, 한 서버에서 동시에 맺을 수 있는 외부 연결 수는 이 동적 포트의 개수와 TIME_WAIT 상태의 소켓 수에 의해 크게 제한받을 수 있습니다. 만약 동적 포트가 모두 TIME_WAIT 상태로 묶여 버린다면, 서버는 새로운 연결을 시작할 포트가 없어 Cannot assign requested address 에러를 뿜어내게 되는 거죠. 그래서 위에서 언급한 tcp_tw_reuse 같은 옵션이 고성능 서버 환경에서는 필수적인 최적화 설정이 됩니다.

3-Way vs. 4-Way Handshake 비교

특징 TCP 3-Way Handshake TCP 4-Way Handshake
목적 클라이언트와 서버 간의 연결 수립 클라이언트와 서버 간의 연결 종료
시작 주체 클라이언트 (SYN 요청) 연결 종료를 원하는 한쪽 (FIN 요청)
패킷 교환 SYN, SYN-ACK, ACK (3단계) FIN, ACK, FIN, ACK (4단계)
주요 상태 SYN_SENT, SYN_RECV, ESTABLISHED FIN_WAIT_1, FIN_WAIT_2, CLOSE_WAIT, LAST_ACK, TIME_WAIT
특징 양방향 통신 준비, 초기 시퀀스 번호 교환 각 방향별 데이터 전송 종료, 자원 해제
지연 1.5 RTT (데이터 전송 전까지) 최소 2 RTT (마지막 ACK까지) + TIME_WAIT 대기

핵심 요약 3줄

  • TCP 3-Way Handshake는 안전한 연결 시작을 위한 3단계 과정이며, SYN Flood 공격 및 초기 지연 문제와 관련이 깊습니다.
  • TCP 4-Way Handshake는 깔끔한 연결 종료를 위한 4단계 과정이며, TIME_WAIT 상태로 인한 서버 자원 고갈이 주요 문제로 떠오릅니다.
  • sysctl 명령어를 통해 tcp_tw_reuse, tcp_fastopen, tcp_syncookies, tcp_keepalive_time 등의 커널 파라미터를 조정하여 실전 문제를 해결하고 서버 성능을 최적화할 수 있습니다.

FAQ (자주 묻는 질문)

Q1: net.ipv4.tcp_tw_reuse를 활성화하면 보안상 문제가 생길 수도 있나요?

A1: tcp_tw_reuse는 같은 클라이언트 IP와 포트를 재사용할 때만 적용되므로, 이전 연결의 패킷이 새로운 연결에 유입될 위험이 적어 보안상 큰 문제는 없습니다. 하지만 tcp_tw_recycle은 더 공격적인 재사용 정책으로, NAT 환경 등에서 문제가 될 수 있어 최신 리눅스 커널에서는 제거되거나 권장되지 않습니다.

Q2: TCP Fast Open(TFO)은 모든 환경에서 사용할 수 있나요?

A2: TFO는 클라이언트와 서버 양쪽 모두에서 TFO를 지원해야 작동합니다. 리눅스 커널 3.7 이상, Windows 10 이상, macOS 10.11 이상에서 지원되며, 일부 네트워크 장비나 방화벽이 TFO 패킷을 차단할 수 있어 완벽하게 보편화되지는 않았습니다.

Q3: CLOSE_WAIT 상태가 너무 많은데 어떻게 해야 하나요?

A3: CLOSE_WAIT는 서버 애플리케이션이 클라이언트의 종료 요청을 받았지만, 아직 자신의 소켓을 닫지 않은 상태를 의미합니다. 이는 주로 서버 애플리케이션 코드에 소켓을 명시적으로 닫는 로직(socket.close())이 누락되었거나, 데이터 처리 지연으로 인해 소켓을 닫을 수 없는 버그가 있을 때 발생합니다. netstat -an | grep CLOSE_WAIT 명령어로 확인 후, 해당 프로세스를 찾아 코드 레벨에서 소켓 종료 로직을 검토하고 수정해야 합니다.


휴, 개발자분들이 왜 이런 사소해 보이는 연결 과정 하나하나에 그렇게 신경을 쓰는지 이제야 조금 알 것 같습니다. 단순히 기술을 아는 것을 넘어, 그 기술이 현실에서 어떤 문제를 일으키고 어떻게 해결될 수 있는지 이해하는 것이 정말 중요하더라고요. 저처럼 코딩은 못 하지만 IT가 너무 궁금한 비개발자분들도 이 글을 통해 TCP 핸드셰이크가 왜 중요한지, 그리고 어떻게 서버를 더 똑똑하게 만들 수 있는지 조금이나마 감을 잡으셨으면 좋겠습니다. 저처럼 비개발자도 충분히 이해할 수 있다는 점, 꼭 기억하세요!