Table of contents
- IP만으로는 부족하다
- 포트와 소켓 — 프로세스를 가리키는 번호
- TCP는 왜 “믿을 수 있는” 프로토콜인가
- 3-way handshake — 연결을 여는 세 번의 악수
- 연결을 닫는 4-way handshake
- 흐름 제어 vs 혼잡 제어
- TCP 상태 기계
- UDP — 신뢰성을 버리는 선택
- TCP와 UDP 직접 비교
- 눈으로 보는 TCP — tcpdump로 핸드셰이크 찍기
- 실무에서 자주 만나는 이슈
IP만으로는 부족하다
앞선 2편에서 IP 주소는 “어느 호스트로 보낼지”를 정한다고 했다. 그런데 한 호스트 안에는 수많은 프로그램이 떠 있다. 웹 서버도 있고, 메일 서버도 있고, SSH 데몬도 있다. IP 주소만으로는 “이 데이터가 어느 프로그램에 가야 하는지”를 알 수 없다. 그래서 필요한 것이 포트(port)다. 그리고 IP + 포트의 조합으로 양 끝의 프로세스를 연결해주는 장치가 소켓(socket)이다.
그 위에 깔리는 두 개의 프로토콜이 TCP와 UDP다. 둘 다 OSI 4계층(Transport)에 속하지만 성격은 정반대다. TCP는 “믿을 수 있게”에 모든 것을 걸었고, UDP는 “빠르고 가볍게”에 모든 것을 걸었다. 이번 편은 이 두 프로토콜이 왜 그렇게 설계됐고, 어떤 상황에서 무엇을 고르는지를 따라간다.
포트와 소켓 — 프로세스를 가리키는 번호
포트는 16비트 숫자(0~65535)다. 같은 IP를 쓰는 여러 프로세스를 구분하는 번호표다. 웹 서버는 80번이나 443번을 듣고, SSH는 22번을 듣고, PostgreSQL은 5432번을 듣는다. 숫자에 구속력은 없지만 관례로 정해진 번호들이 있다.
- 0 ~ 1023 (Well-Known Ports): 관리자 권한이 있어야 열 수 있는 번호. HTTP 80, HTTPS 443, SSH 22, DNS 53 등
- 1024 ~ 49151 (Registered Ports): IANA에 등록된 서비스 포트. PostgreSQL 5432, Redis 6379, Prometheus 9090 등
- 49152 ~ 65535 (Ephemeral Ports): 클라이언트가 연결할 때 OS가 임시로 할당하는 번호. 브라우저가 서버에 접속할 때마다 이 대역에서 하나 뽑아 쓴다
소켓은 IP 주소와 포트를 하나로 묶은 통신 종단점이다. 두 소켓이 연결되면 양 끝의 프로세스는 파일에 쓰듯 데이터를 주고받을 수 있다. 리눅스 관점에서 소켓은 파일 디스크립터 하나에 불과하다. read, write로 다룬다.
한 연결은 네 개의 값으로 유일하게 식별된다 — 출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트. 이 네 값을 4-tuple이라고 부른다. 같은 서버가 같은 포트(예: 443)로 수만 개의 동시 연결을 받을 수 있는 건, 클라이언트 쪽 IP나 포트가 달라서 tuple이 모두 다르기 때문이다.
flowchart LR
subgraph C["클라이언트 192.168.1.10"]
CA["브라우저<br/>sport: 54321"]
end
subgraph S["서버 93.184.216.34"]
SA["웹 서버<br/>dport: 443"]
end
CA -- "4-tuple<br/>(192.168.1.10, 54321, 93.184.216.34, 443)" --> SA
현재 리눅스에서 열린 소켓은 ss 명령으로 볼 수 있다.
ss -tunap
# TCP/UDP, 숫자로, 모든 상태, 프로세스 이름까지
# Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
# tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",...))
# tcp ESTAB 0 0 192.168.1.10:54321 93.184.216.34:443 users:(("chrome",...))
LISTEN 상태는 “이 포트로 누군가 들어오기를 기다린다”는 뜻이고, ESTAB은 “연결이 열려 있다”는 뜻이다. 실무에서 “이 포트가 뭐가 쓰고 있지?”를 확인할 때 가장 먼저 꺼내는 명령이다.
TCP는 왜 “믿을 수 있는” 프로토콜인가
TCP(Transmission Control Protocol)가 신뢰성을 보장한다고 할 때, 이는 다음을 의미한다.
- 순서 보장: 보낸 순서대로 받는 쪽에 전달된다
- 손실 복구: 중간에 패킷이 사라지면 재전송해서 결국 도착시킨다
- 중복 제거: 같은 패킷이 두 번 와도 한 번만 쓴다
- 흐름 제어: 받는 쪽이 벅차면 보내는 속도를 줄인다
- 혼잡 제어: 네트워크 자체가 막히면 전체 전송 속도를 낮춘다
이 모든 걸 IP 위에서 해낸다. IP는 그냥 “던져보고 도착하면 좋고 아니면 말고”인 프로토콜이다. TCP는 그 불안정한 기반 위에 신뢰성 레이어를 씌운다. 덕분에 애플리케이션은 “데이터를 쓰면 순서대로 상대에게 간다”는 착각을 유지할 수 있다. 이 착각이 TCP의 본질이다.
3-way handshake — 연결을 여는 세 번의 악수
TCP는 연결 지향(connection-oriented) 프로토콜이다. 데이터를 주고받기 전에 먼저 “연결을 엽시다”라는 합의를 맺는다. 이 합의 과정이 세 번의 패킷으로 이뤄지기 때문에 3-way handshake라고 부른다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
Note over C,S: 연결 수립
C->>S: SYN (seq=x)
Note right of S: 들어오는 연결 수락 준비
S->>C: SYN-ACK (seq=y, ack=x+1)
C->>S: ACK (ack=y+1)
Note over C,S: 이제 데이터 주고받을 수 있음
C->>S: HTTP GET /
S->>C: HTTP 200 OK + body
세 단계의 의미를 풀어본다.
- SYN (Synchronize): 클라이언트가 “연결 좀 엽시다. 내 초기 순서번호는 x요” 하고 첫 패킷을 보낸다
- SYN-ACK: 서버가 “그래요. 내 초기 순서번호는 y고, 당신의 x 다음인 x+1을 기다립니다” 하고 응답한다
- ACK: 클라이언트가 “당신의 y 다음인 y+1을 기다립니다” 하고 마무리한다
왜 두 번이 아니라 세 번인가? 양쪽 모두 자기 초기 순서번호를 상대에게 알리고, 상대가 그걸 받았는지 확인해야 하기 때문이다. 순서번호가 엇갈리면 재전송 로직이 꼬인다. 또 오래된 SYN이 뒤늦게 도착해서 생기는 “유령 연결”을 막기 위해서도 세 번이 필요하다.
이 핸드셰이크는 지연 시간을 만드는 주범이기도 하다. RTT(Round-Trip Time)가 50ms인 네트워크에서는 첫 데이터가 오가기 전에 최소 50ms의 지연이 생긴다. HTTPS는 여기에 TLS 핸드셰이크까지 겹쳐서 수백 ms가 쉽게 쌓인다. 이런 비용 때문에 HTTP/2는 연결 하나를 오래 유지하며 여러 요청을 나르고, HTTP/3은 아예 TCP를 버리고 QUIC(Quick UDP Internet Connections) 위에서 동작한다.
연결을 닫는 4-way handshake
연결을 여는 게 셋이었으니 닫는 건 넷이다. 양쪽이 독립적으로 “나는 더 보낼 게 없다”를 선언하고 확인받는 구조다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
C->>S: FIN
S->>C: ACK
Note over S: 서버는 아직 보낼 게 있을 수 있다
S->>C: FIN
C->>S: ACK
Note over C: TIME_WAIT 상태로 잠시 대기
FIN을 보낸 쪽은 반대 방향으로 들어오는 데이터는 계속 받을 수 있다. 이걸 half-close라고 한다. 파이프라인 처리가 필요한 프로토콜(SSH, 대용량 파일 전송 등)에서 중요하다.
마지막 ACK를 보낸 클라이언트는 잠시 TIME_WAIT 상태로 남는다. 이건 “혹시 마지막 ACK가 유실돼서 서버가 FIN을 재전송하면 받아주기 위한” 안전장치다. 기본값은 2 * MSL(약 60초)이다. 이 특성 때문에 짧은 연결을 수만 건 처리하는 서버는 TIME_WAIT 소켓이 쌓여 포트 고갈이 생기기도 한다. 이럴 때 net.ipv4.tcp_tw_reuse 같은 커널 파라미터를 건드리는 일이 있다.
흐름 제어 vs 혼잡 제어
TCP가 “믿을 수 있다”고 할 때, 그 믿음의 핵심은 속도 조절이다. 두 종류가 있다.
흐름 제어(flow control)는 받는 쪽을 보호한다. 수신자가 버퍼를 다 쓰면 더 보내지 말라고 신호를 보낸다. TCP 헤더의 Window Size 필드로 “지금 내가 받을 수 있는 바이트 수”를 매번 알려준다. 이 값이 0이면 송신자는 잠시 멈춘다.
혼잡 제어(congestion control)는 네트워크 자체를 보호한다. 경로 중간의 라우터가 막혀서 패킷이 버려지기 시작하면, 송신자는 속도를 확 줄였다가 천천히 올린다. 대표적인 알고리즘이 TCP Reno의 Slow Start, Congestion Avoidance, Fast Retransmit, Fast Recovery 네 단계다.
flowchart LR
SS["Slow Start<br/>지수적으로 증가"] --> CA["Congestion Avoidance<br/>선형적으로 증가"]
CA -- 패킷 손실 --> FR["Fast Retransmit<br/>즉시 재전송"]
FR --> FCR["Fast Recovery<br/>창 크기 절반으로"]
FCR --> CA
- Slow Start: 처음엔 작은 창(cwnd=1)으로 시작해서 ACK 받을 때마다 두 배씩 늘린다. 초기에 조심스럽게 탐색
- Congestion Avoidance: 어느 임계치(ssthresh)를 넘으면 1씩만 늘린다. 한계 근처에서는 천천히 접근
- Fast Retransmit: 중복 ACK 세 번을 받으면 타이머를 기다리지 않고 바로 재전송
- Fast Recovery: 손실 감지 시 창을 절반으로 줄이고 Congestion Avoidance로 복귀
더 최근에 나온 TCP BBR(Bottleneck Bandwidth and RTT)은 손실이 아니라 실제 대역폭과 RTT를 측정해서 속도를 조절한다. 구글이 밀고 있고, 장거리 연결이나 모바일 환경에서 Reno보다 빠른 경우가 많다. 자세한 배경은 Wikipedia: TCP congestion control에 잘 정리돼 있다.
흐름 제어와 혼잡 제어를 헷갈리지 않는 감각 — “수신자 보호냐, 네트워크 보호냐” — 이 있으면 TCP 튜닝이 필요한 순간에 어느 쪽을 건드려야 할지 판단이 선다.
TCP 상태 기계
TCP 연결은 생명주기 동안 여러 상태를 거친다. 자주 마주치는 상태들을 정리한다.
- LISTEN: 서버가 수신 대기 중
- SYN-SENT: 클라이언트가 SYN을 보내고 응답을 기다림
- SYN-RECEIVED: 서버가 SYN을 받고 SYN-ACK를 보낸 상태
- ESTABLISHED: 연결 수립 완료, 데이터 송수신 가능
- FIN-WAIT-1 / FIN-WAIT-2: 능동적으로 연결을 닫는 쪽
- CLOSE-WAIT: 상대가 닫자고 했고 내가 응답할 차례
- TIME-WAIT: 마지막 ACK를 보낸 뒤 안전한 대기
CLOSE_WAIT이 쌓이는 건 애플리케이션 버그의 신호다. 상대는 “끝” 신호를 보냈는데 우리 쪽 프로세스가 close()를 안 부른 상태. ss로 확인해서 CLOSE_WAIT이 많으면 코드의 연결 해제 로직을 점검해야 한다.
UDP — 신뢰성을 버리는 선택
UDP(User Datagram Protocol)는 TCP와 정반대의 철학이다. “보내고 잊어라(fire and forget).” 순서 보장도 없고, 재전송도 없고, 흐름 제어도 없다. 헤더는 고작 8바이트다.
UDP 헤더(8바이트):
출발지 포트 (16bit)
목적지 포트 (16bit)
길이 (16bit)
체크섬 (16bit)
왜 이런 프로토콜이 필요한가? 어떤 종류의 데이터는 늦게 오는 것보다 차라리 빠지는 게 낫기 때문이다.
- DNS 질의: 작은 요청/응답 한 번이 끝이다. 핸드셰이크 비용을 감당할 이유가 없다
- 실시간 비디오/음성: 1초 전 프레임을 재전송받아봤자 쓸모없다. 놓친 프레임은 버리고 최신 프레임을 받는 게 낫다
- 게임 상태 동기화: 플레이어 위치는 매 프레임 새로 갱신된다. 옛 위치를 재전송받을 필요가 없다
- 대규모 브로드캐스트/멀티캐스트: 수많은 수신자 각각과 연결을 맺는 TCP는 맞지 않다
UDP 위에서 신뢰성이 필요하면? 애플리케이션이 직접 구현하면 된다. QUIC이 대표적인 사례다. UDP 위에 TCP의 신뢰성 + TLS의 암호화를 합쳐서 올린 프로토콜이고, HTTP/3가 이 위에서 돈다. TCP보다 빠른 핸드셰이크(0-RTT 가능), 연결 마이그레이션, 헤드 오브 라인 블로킹 해결 같은 이점을 준다.
TCP와 UDP 직접 비교
| 항목 | TCP | UDP |
|---|---|---|
| 연결 | 연결 지향 (handshake) | 비연결 |
| 신뢰성 | 보장 (재전송, 순서) | 없음 |
| 흐름/혼잡 제어 | 있음 | 없음 |
| 헤더 크기 | 20바이트 이상 | 8바이트 |
| 속도 | 상대적으로 느림 | 빠름 |
| 용도 | HTTP, SSH, DB, 파일 전송 | DNS, VoIP, 게임, QUIC |
어느 쪽을 쓸지 고르는 기준은 하나다. “데이터가 늦게라도 꼭 도착해야 하는가, 아니면 늦으면 쓸모없는가?” 답이 전자면 TCP, 후자면 UDP다.
눈으로 보는 TCP — tcpdump로 핸드셰이크 찍기
말로만 하면 추상적이니 tcpdump로 실제 패킷을 떠본다. 이 명령은 루트 권한이 필요하다.
# 대상 호스트와 주고받는 TCP 패킷을 캡처
sudo tcpdump -i any -n 'host example.com and tcp port 443' -c 6
# 출력 예시 (일부 단순화)
# IP 192.168.1.10.54321 > 93.184.216.34.443: Flags [S], seq 1000
# IP 93.184.216.34.443 > 192.168.1.10.54321: Flags [S.], seq 2000, ack 1001
# IP 192.168.1.10.54321 > 93.184.216.34.443: Flags [.], ack 2001
# IP 192.168.1.10.54321 > 93.184.216.34.443: Flags [P.], len 517 ← ClientHello
# ...
[S]가 SYN, [S.]가 SYN-ACK, [.]만 있으면 ACK, [P.]는 Push(데이터). 이 Flags 문자열이 TCP 헤더의 플래그 비트를 한 글자씩 축약한 표기다. 한 번 눈으로 핸드셰이크를 보고 나면 이 프로토콜이 훨씬 구체적으로 와닿는다.
UDP도 똑같이 볼 수 있다. sudo tcpdump -i any -n 'udp port 53'으로 DNS 질의를 떠보면 핸드셰이크 없이 요청 하나, 응답 하나로 끝나는 간결함이 확인된다.
실무에서 자주 만나는 이슈
TCP/UDP와 관련된 현장의 대표적인 곤란들을 짚어둔다.
- Keep-Alive와 유휴 연결: 클라우드 로드밸런서나 방화벽은 일정 시간 유휴인 연결을 끊는다. 애플리케이션은 연결이 죽은 걸 모르고 쓰다가 에러를 만난다.
SO_KEEPALIVE옵션이나 애플리케이션 레벨 heartbeat가 필요 - Nagle 알고리즘과 지연: TCP는 작은 패킷을 묶어서 보내는 최적화(Nagle)가 기본이다. 채팅이나 커맨드 라인 툴처럼 실시간성이 필요하면
TCP_NODELAY로 끈다 - 포트 고갈: 짧은 연결을 초당 수만 건 처리하면 ephemeral 포트가 고갈될 수 있다. TIME_WAIT 문제와 얽혀서 나타나곤 한다
- MTU와 단편화: TCP 세그먼트가 링크의 MTU(보통 1500바이트)를 넘으면 쪼개진다. VPN이나 터널링 환경에서 MTU가 줄어들면 성능이 훅 떨어지는 원인이 된다
이 주제들은 한 편 한 편 별도의 글이 될 만한 양이다. 여기서는 “이런 게 있다”는 레이블만 달아둔다.
다음 편에서는 이 TCP/UDP 위에서 도는 가장 많이 쓰이는 프로토콜 중 하나 — DNS를 해부한다. example.com이라는 사람의 언어가 어떻게 93.184.216.34라는 숫자로 바뀌는지, 그 번역 과정에 참여하는 여러 서버의 역할을 따라간다.
→ 4편: DNS

Loading comments...