소켓과 전송 계층 (3) - 멀티플렉싱과 패킷 흐름 - soo:bak
작성일 :
멀티플렉싱이란
Part 2에서 TCP 연결의 상태 전이를 살펴보았습니다. 하나의 서버가 수천 개의 클라이언트와 동시에 연결을 유지할 수 있다고 했는데, 물리적 네트워크 연결은 하나뿐입니다. 어떻게 가능할까요?
1870년대 전화가 발명되었을 때, 통화를 하려면 교환원에게 요청해야 했습니다. 교환원이 물리적으로 전선을 연결했고, 한 통화에 하나의 전용 회선이 필요했습니다.
이 방식에는 문제가 있습니다. 100명이 동시에 통화하려면 100개의 회선이 필요합니다. 회선 대부분은 대화 중 침묵(데이터 없음) 상태이므로 낭비가 심합니다.
시분할 다중화(TDM, Time Division Multiplexing)는 이 문제를 해결했습니다. 여러 통화가 하나의 회선을 시간으로 나눠 씁니다. 각 통화는 짧은 시간 슬롯을 할당받고, 그 시간에만 회선을 사용합니다. 인간은 밀리초 단위의 끊김을 인지하지 못하므로 연속된 대화처럼 느껴집니다.
패킷 교환(Packet Switching)은 더 나아갔습니다. 데이터를 작은 패킷으로 나누고, 각 패킷이 독립적으로 네트워크를 지나갑니다. 전용 회선을 예약하지 않고, 보낼 데이터가 있을 때만 네트워크를 사용합니다.
인터넷은 패킷 교환 네트워크입니다. 하나의 물리적 연결(이더넷 케이블, Wi-Fi)로 수백 개의 동시 연결이 가능합니다. 이것을 멀티플렉싱(Multiplexing)이라고 합니다.
포트 번호: 프로세스 식별자
멀티플렉싱의 핵심 요소는 포트 번호(Port Number)입니다. IP 주소가 컴퓨터를 식별한다면, 포트 번호는 그 컴퓨터의 프로세스를 식별합니다.
왜 16비트인가?
포트 번호는 16비트 정수로, 0부터 65535까지 총 65536개의 값을 가질 수 있습니다.
| 비트 수 | 포트 개수 | 문제점 |
|---|---|---|
| 8비트 | 256개 | 동시 연결 수 부족 |
| 16비트 | 65536개 | 절충안 (채택) |
| 32비트 | 40억 개 | TCP/UDP 헤더가 커짐 |
16비트는 현실적인 동시 연결 수를 감당하면서 헤더 크기를 작게 유지하는 절충안입니다.
포트 범위의 의미
| 범위 | 이름 | 용도 | 예시 |
|---|---|---|---|
| 0 ~ 1023 | Well-known Ports | 표준 서비스 (IANA 관리) | 22(SSH), 80(HTTP), 443(HTTPS) |
| 1024 ~ 49151 | Registered Ports | 특정 애플리케이션 (관례) | 3306(MySQL), 5432(PostgreSQL) |
| 49152 ~ 65535 | Ephemeral Ports | 클라이언트 임시 포트 | OS가 자동 할당 |
Unix/Linux에서 Well-known Ports(0~1023)에 바인딩하려면 기본적으로 root 권한이 필요합니다. 일반 사용자가 시스템 서비스를 사칭하는 것을 방지하는 보안 조치입니다.
Part 1에서 서버는 bind()로 포트를 지정하고, 클라이언트는 OS가 Ephemeral 포트를 자동 할당한다고 했습니다. 이 포트 번호가 5-tuple의 일부가 되어 연결을 식별합니다.
운영체제의 패킷 라우팅
패킷이 네트워크 인터페이스에 도착하면 운영체제가 어느 소켓으로 전달할지 결정해야 합니다. 이 과정을 역다중화(Demultiplexing)라고 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
네트워크 인터페이스
│
↓ 패킷 수신
┌───────────────────────────────────────────────┐
│ 커널 │
│ │
│ 1. IP 헤더 확인 → 목적지 IP가 내 것인가? │
│ ↓ Yes │
│ 2. 프로토콜 확인 → TCP? UDP? │
│ ↓ TCP │
│ 3. 5-tuple로 소켓 테이블 검색 │
│ ↓ │
│ 4. 매칭된 소켓의 수신 버퍼에 데이터 추가 │
│ │
└───────────────────────────────────────────────┘
│
↓ read() 호출 시
┌───────────────────────────────────────────────┐
│ 애플리케이션 │
└───────────────────────────────────────────────┘
소켓 테이블
커널은 모든 소켓을 추적하는 테이블을 유지합니다. Part 1에서 설명한 5-tuple이 여기서 사용됩니다.
1
2
3
4
5
6
7
8
9
┌────────────────────────────────────────────────────────────┐
│ 프로토콜 │ 로컬IP │ 로컬포트 │ 원격IP │ 원격포트 │ 상태 │
├────────────────────────────────────────────────────────────┤
│ TCP │ 0.0.0.0 │ 80 │ * │ * │ LISTEN │
│ TCP │ 192.168.1.1 │ 80 │ 10.0.0.5 │ 52001 │ ESTABLISHED │
│ TCP │ 192.168.1.1 │ 80 │ 10.0.0.5 │ 52002 │ ESTABLISHED │
│ TCP │ 192.168.1.1 │ 80 │ 203.0.113.10 │ 49500 │ ESTABLISHED │
│ UDP │ 0.0.0.0 │ 53 │ * │ * │ - │
└────────────────────────────────────────────────────────────┘
세 개의 TCP 연결이 모두 같은 로컬 포트(80)를 사용하지만, 원격 IP와 원격 포트가 다르므로 5-tuple이 고유합니다.
LISTEN 소켓의 특수성
LISTEN 상태의 소켓은 원격 주소가 *(와일드카드)입니다. 어떤 클라이언트의 SYN도 받을 수 있습니다.
1
2
3
4
5
LISTEN 소켓: (TCP, 0.0.0.0, 80, *, *)
│
SYN from 10.0.0.5:52001
↓
새 소켓 생성: (TCP, 192.168.1.1, 80, 10.0.0.5, 52001) [ESTABLISHED]
Part 1에서 accept()가 새 소켓을 반환한다고 했습니다. 이때 반환되는 소켓이 바로 구체적인 5-tuple을 가진 연결 소켓입니다.
하나의 서버 포트, 수천 개의 연결
웹 서버가 80번 포트에서 LISTEN하고 있다고 가정합니다.
1
2
3
4
5
6
7
8
9
서버: 192.168.1.100:80 [LISTEN]
┌──────────────────────────────────────────────────────────────────────────┐
│ 연결 │ 프로토콜 │ 로컬IP │ 로컬포트 │ 원격IP │ 원격포트 │
├──────────────────────────────────────────────────────────────────────────┤
│ 연결 1 │ TCP │ 192.168.1.100 │ 80 │ 10.0.0.5 │ 52001 │
│ 연결 2 │ TCP │ 192.168.1.100 │ 80 │ 10.0.0.5 │ 52002 │ ← 같은 클라이언트
│ 연결 3 │ TCP │ 192.168.1.100 │ 80 │ 203.0.113.10 │ 49500 │
└──────────────────────────────────────────────────────────────────────────┘
세 연결 모두 서버의 같은 포트(80)를 사용하지만 5-tuple이 모두 다르므로 별개의 연결입니다. 연결 1과 2는 같은 클라이언트(10.0.0.5)에서 왔지만, 원격 포트가 다릅니다.
최대 동시 연결 수
이론적으로 하나의 서버 포트에서 가능한 최대 연결 수는 얼마일까요?
1
2
3
4
5
6
7
5-tuple: (프로토콜, 로컬IP, 로컬포트, 원격IP, 원격포트)
(고정) (고정) (변수) (변수)
원격 IP: 2^32 (IPv4)
원격 포트: 2^16
최대 = 2^32 × 2^16 = 2^48 ≈ 281조 개
실제로는 다른 요소들이 제한합니다.
파일 디스크립터 제한
Part 1에서 소켓이 파일 디스크립터라고 했습니다. 프로세스당 열 수 있는 파일 디스크립터 수에는 제한이 있습니다.
1
2
3
4
5
$ ulimit -n
1024 # 프로세스당 기본값
$ cat /proc/sys/fs/file-max
9223372036854775807 # 시스템 전체 최대값
연결 1만 개를 처리하려면 파일 디스크립터도 1만 개 이상 필요합니다. 한도를 초과하면 accept()가 EMFILE 에러를 반환하고 새 연결을 받을 수 없습니다.
백로그 큐 제한
백로그(Backlog)는 “밀린 일” 또는 “대기열”이라는 뜻입니다. 클라이언트가 연결을 요청하는 속도와 서버가 accept()로 연결을 처리하는 속도가 다를 수 있습니다. 3-Way Handshake가 완료되었지만 accept()로 아직 꺼내지 않은 연결이 백로그 큐에서 대기합니다.
1
2
3
4
5
6
7
클라이언트 A ──→ ┌─────────────┐
클라이언트 B ──→ │ 백로그 큐 │ ──→ accept() ──→ 처리
클라이언트 C ──→ │ (대기열) │
└─────────────┘
↑
Handshake 완료했지만
아직 accept() 안 된 연결들
listen(sockfd, backlog)의 backlog가 이 대기열의 크기를 지정합니다.
1
2
3
클라이언트 → SYN → [SYN 큐] → SYN+ACK/ACK → [Accept 큐] → accept()
↑
backlog가 제한하는 부분
| 설정 | 의미 | 기본값 |
|---|---|---|
| listen() backlog | 애플리케이션이 요청하는 값 | 애플리케이션 지정 |
| net.core.somaxconn | 시스템이 허용하는 최대값 | 4096 |
실제 백로그 크기는 min(backlog, somaxconn)입니다. 대기열이 가득 차면 새 연결 요청은 거부됩니다.
메모리 제한
각 연결은 Part 2에서 설명한 송신/수신 버퍼를 가집니다.
1
2
3
연결당 메모리 ≈ 송신 버퍼 + 수신 버퍼 + 소켓 구조체
≈ 16KB + 16KB + 수 KB (기본 설정 기준)
≈ 약 40KB
1만 연결이면 약 400MB, 10만 연결이면 약 4GB의 메모리가 필요합니다. 메모리가 부족하면 커널이 새 소켓 생성을 거부합니다.
대규모 서버 튜닝
C10K는 “Concurrent 10,000”의 약자로, 동시에 1만 개의 연결을 처리하는 문제를 말합니다. 1999년에 Dan Kegel이 제기한 개념으로, 당시 서버들이 1만 연결을 넘기 어려웠습니다. C10K 이상을 처리하려면 여러 설정을 조정해야 합니다.
| 설정 | 역할 | 기본값 |
|---|---|---|
ulimit -n |
프로세스당 열 수 있는 파일 디스크립터 수 | 1024 |
somaxconn |
Accept 큐의 최대 크기 | 4096 |
tcp_max_syn_backlog |
SYN 큐의 최대 크기 | 1024 |
두 개의 큐가 있는 이유는 3-Way Handshake 단계가 다르기 때문입니다.
1
2
3
4
5
6
클라이언트 서버
│ │
│─── SYN ──────────────→ │ ← [SYN 큐] Handshake 진행 중
│←── SYN+ACK ────────────│
│─── ACK ──────────────→ │ ← [Accept 큐] Handshake 완료, accept() 대기
│ │
| 큐 | 저장하는 연결 | 제한 설정 |
|---|---|---|
| SYN 큐 | SYN을 받고 SYN+ACK를 보낸 상태 (반쯤 열린 연결) | tcp_max_syn_backlog |
| Accept 큐 | 3-Way Handshake 완료, accept() 대기 중 | somaxconn |
1
2
3
4
5
6
# 파일 디스크립터 제한 늘리기
ulimit -n 65535
# 시스템 설정 (sysctl)
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
추가로 자주 사용하는 튜닝 옵션입니다.
| 설정 | 역할 |
|---|---|
net.ipv4.tcp_syncookies = 1 |
SYN Flood 공격 방어. SYN 큐가 가득 차도 연결 수락 가능 |
net.ipv4.tcp_tw_reuse = 1 |
TIME_WAIT 상태의 소켓 재사용 허용 |
net.ipv4.ip_local_port_range |
임시 포트 범위 확장 (기본 32768~60999) |
net.core.netdev_max_backlog |
네트워크 인터페이스의 수신 큐 크기 |
패킷의 생성: 캡슐화
Part 2에서 write()가 데이터를 송신 버퍼에 복사한다고 했습니다. 그 후 데이터가 네트워크로 나가기까지의 과정을 살펴봅시다.
1
2
3
4
5
6
7
8
9
애플리케이션: write("GET / HTTP/1.1\r\n...")
↓
TCP: 세그먼트 생성 (TCP 헤더 + 데이터)
↓
IP: 패킷 생성 (IP 헤더 + TCP 세그먼트)
↓
이더넷: 프레임 생성 (이더넷 헤더 + IP 패킷 + FCS)
↓
물리 계층: 비트 → 전기 신호
각 계층이 자신의 헤더를 붙이는 이 과정을 캡슐화(Encapsulation)라고 합니다.
페이로드(Payload)는 “실제로 운반하는 화물”이라는 뜻입니다. 택배 상자에서 송장이 헤더라면, 안에 든 물건이 페이로드입니다. 각 계층에서 상위 계층 전체가 하위 계층의 페이로드가 됩니다.
1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────────────────────────┐
│ 이더넷 헤더 │ 이더넷의 페이로드 │
│ │ ┌─────────────────────────────────────────────┐ │
│ │ │ IP 헤더 │ IP의 페이로드 │ │
│ │ │ │ ┌─────────────────────────────────┐ │ │
│ │ │ │ │ TCP 헤더 │ TCP의 페이로드 │ │ │
│ │ │ │ │ │ (애플리케이션 데이터) │ │ │
│ │ │ │ └─────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| 계층 | 헤더 | 페이로드 |
|---|---|---|
| TCP | TCP 헤더 | 애플리케이션 데이터 |
| IP | IP 헤더 | TCP 세그먼트 (TCP 헤더 + 데이터) |
| 이더넷 | 이더넷 헤더 | IP 패킷 (IP 헤더 + TCP 세그먼트) |
TCP 세그먼트
TCP는 애플리케이션 데이터에 TCP 헤더를 붙여 세그먼트(Segment)를 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌────────────────────────────────────────────────────────────┐
│ TCP 헤더 (20~60 바이트) │
├────────────────────────────────────────────────────────────┤
│ 출발지 포트 (16비트) │ 목적지 포트 (16비트) │
├────────────────────────────────────────────────────────────┤
│ 시퀀스 번호 (32비트) │
├────────────────────────────────────────────────────────────┤
│ 확인 번호 (32비트) │
├────────────────────────────────────────────────────────────┤
│헤더길이│예약│ 플래그 │ 윈도우 크기 │
│ (4) │(3) │ (9) │ (16비트) │
├────────────────────────────────────────────────────────────┤
│ 체크섬 (16비트) │ 긴급 포인터 (16비트) │
├────────────────────────────────────────────────────────────┤
│ 옵션 (가변) │
├────────────────────────────────────────────────────────────┤
│ 데이터 (페이로드) │
└────────────────────────────────────────────────────────────┘
플래그 9비트: NS, CWR, ECE, URG, ACK, PSH, RST, SYN, FIN
플래그는 세그먼트의 목적이나 상태를 나타냅니다.
| 플래그 | 이름 | 용도 |
|---|---|---|
| SYN | Synchronize | 연결 수립 요청 |
| ACK | Acknowledgment | 확인 번호 필드가 유효함 |
| FIN | Finish | 연결 종료 요청 |
| RST | Reset | 연결 강제 종료/거부 |
| PSH | Push | 버퍼링 없이 즉시 애플리케이션에 전달 |
| URG | Urgent | 긴급 데이터 (긴급 포인터 필드 유효) |
| ECE | ECN-Echo | 네트워크 혼잡 알림 |
| CWR | Congestion Window Reduced | 혼잡 윈도우 축소 알림 |
| NS | Nonce Sum | ECN 보호용 |
Part 2에서 다룬 3-Way/4-Way Handshake가 SYN, ACK, FIN 플래그를 사용합니다.
IP 패킷
IP 계층은 TCP 세그먼트를 페이로드로 받아 IP 헤더를 붙입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌────────────────────────────────────────────────────────────┐
│ IP 헤더 (20~60 바이트) │
├────────────────────────────────────────────────────────────┤
│버전│헤더길이│ 서비스 타입 │ 전체 길이 │
│(4) │ (4) │ (8) │ (16비트) │
├────────────────────────────────────────────────────────────┤
│ 식별자 (16비트) │플래그│ 단편 오프셋 │
│ │ (3) │ (13비트) │
├────────────────────────────────────────────────────────────┤
│ TTL (8) │ 프로토콜 (8) │ 헤더 체크섬 (16비트) │
├────────────────────────────────────────────────────────────┤
│ 출발지 IP 주소 (32비트) │
├────────────────────────────────────────────────────────────┤
│ 목적지 IP 주소 (32비트) │
├────────────────────────────────────────────────────────────┤
│ 옵션 (가변) │
├────────────────────────────────────────────────────────────┤
│ 페이로드 (TCP 세그먼트) │
└────────────────────────────────────────────────────────────┘
MTU와 세그먼테이션
MTU(Maximum Transmission Unit)는 네트워크 인터페이스가 한 번에 전송할 수 있는 최대 페이로드 크기입니다.
이더넷의 표준 MTU는 1500바이트입니다. 이 값은 1980년대에 당시 메모리 가격과 처리 능력을 고려해 정해진 절충안입니다.
1
2
3
4
5
6
7
8
이더넷 프레임 구조
┌───────────┬───────────┬──────────┬─────────────────────┬─────┐
│ 목적지 MAC │ 출발지 MAC │ EtherType│ 페이로드 (MTU) │ FCS │
│ 6바이트 │ 6바이트 │ 2바이트 │ 46~1500바이트 │ 4B │
└───────────┴───────────┴──────────┴─────────────────────┴─────┘
├────── 이더넷 헤더 14바이트 ──────┤ │트레일러│
최소 64바이트 ─────────────────────────────────────── 최대 1518바이트
| 구성 요소 | 크기 | 역할 |
|---|---|---|
| 헤더 | 14바이트 | 목적지/출발지 MAC 주소, EtherType (상위 프로토콜 식별) |
| 페이로드 | 46~1500바이트 | IP 패킷 (MTU) |
| FCS | 4바이트 | Frame Check Sequence, 오류 검출용 체크섬 |
페이로드가 46바이트 미만이면 최소 프레임 크기(64바이트)를 맞추기 위해 패딩을 추가합니다.
MSS(Maximum Segment Size)
TCP가 한 세그먼트에 담을 수 있는 최대 애플리케이션 데이터 크기입니다.
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────────────────┐
│ MTU (1500바이트) │
├─────────────┬─────────────┬─────────────────────────────────────┤
│ IP 헤더 │ TCP 헤더 │ MSS (데이터) │
│ 20~60바이트 │ 20~60바이트 │ 최대 1460바이트 │
└─────────────┴─────────────┴─────────────────────────────────────┘
MSS = MTU - IP 헤더 - TCP 헤더 = 1500 - 20 - 20 = 1460바이트 (옵션 없을 때)
IP 헤더와 TCP 헤더는 옵션에 따라 20~60바이트로 가변입니다. 1460바이트는 양쪽 헤더에 옵션이 없을 때의 최대값입니다.
Part 2에서 설명한 3-Way Handshake에서 양측이 자신의 MSS를 교환합니다.
1
2
3
4
5
6
클라이언트 (MTU=1500) 서버 (MTU=1440)
│ │
│─── SYN (MSS=1460) ───────────────→ │ "나에게 1460B까지 보내도 됨"
│ │
│←── SYN+ACK (MSS=1400) ──────────── │ "나에게 1400B까지 보내도 됨"
│ │
MSS는 “나에게 이 크기까지 보내도 된다”는 의미입니다. 각 호스트는 자신의 MTU를 기준으로 MSS를 계산합니다.
| 방향 | 사용하는 MSS | 이유 |
|---|---|---|
| 클라이언트 → 서버 | 1400 | 서버가 광고한 값 |
| 서버 → 클라이언트 | 1460 | 클라이언트가 광고한 값 |
MSS를 교환하지 않으면 기본값 536바이트를 사용합니다. 이는 인터넷의 최소 MTU(576바이트)에서 IP/TCP 헤더(40바이트)를 뺀 값입니다.
경로 중간에 MTU가 더 작은 구간이 있으면 아래에서 설명할 Path MTU Discovery로 조정합니다.
세그먼테이션
애플리케이션이 10,000바이트를 write()하면 어떻게 될까요? TCP는 MSS 크기로 데이터를 분할합니다.
1
2
3
4
5
6
7
8
9
10
11
애플리케이션 데이터 (10,000바이트)
┌──────────────────────────────────────────────────────────────────────┐
│ 0 9999 │
└──────────────────────────────────────────────────────────────────────┘
│
↓ TCP가 MSS(1460바이트)로 분할
│
┌────────┬────────┬────────┬────────┬────────┬────────┬──────┐
│ seg 1 │ seg 2 │ seg 3 │ seg 4 │ seg 5 │ seg 6 │seg 7 │
│ 1460B │ 1460B │ 1460B │ 1460B │ 1460B │ 1460B │1240B │
└────────┴────────┴────────┴────────┴────────┴────────┴──────┘
10,000 ÷ 1460 = 6.85 → 7개 세그먼트가 필요합니다.
| 세그먼트 | 시퀀스 번호 | 바이트 범위 | 데이터 크기 |
|---|---|---|---|
| 1 | 0 | 0 ~ 1,459 | 1,460B |
| 2 | 1460 | 1,460 ~ 2,919 | 1,460B |
| 3 | 2920 | 2,920 ~ 4,379 | 1,460B |
| 4 | 4380 | 4,380 ~ 5,839 | 1,460B |
| 5 | 5840 | 5,840 ~ 7,299 | 1,460B |
| 6 | 7300 | 7,300 ~ 8,759 | 1,460B |
| 7 | 8760 | 8,760 ~ 9,999 | 1,240B |
시퀀스 번호는 바이트 단위입니다. 세그먼트 2의 시퀀스 번호가 1460인 이유는 세그먼트 1이 바이트 0~1459를 담고 있기 때문입니다.
각 세그먼트는 TCP 헤더(20바이트)와 IP 헤더(20바이트)가 붙어서 IP 패킷이 됩니다.
| 세그먼트 | 데이터 | + TCP 헤더 | + IP 헤더 | IP 패킷 크기 |
|---|---|---|---|---|
| 1~6 | 1,460B | 20B | 20B | 1,500B (MTU) |
| 7 | 1,240B | 20B | 20B | 1,280B |
수신측의 TCP 프로토콜 로직이 시퀀스 번호를 보고 순서대로 정렬합니다. 정렬된 데이터는 Part 2에서 설명한 수신 버퍼에 저장되고, 애플리케이션이 read()로 읽어갑니다.
IP 단편화
IP 패킷이 MTU가 더 작은 네트워크를 지나야 할 때 단편화(Fragmentation)가 발생합니다. 라우터가 패킷을 작은 조각으로 나누어 전송하고, 수신측에서 재조립합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
원본 패킷 (MTU 1500 네트워크)
┌─────────────────────┐
│ IP 헤더 │ 데이터 │
│ 20B │ 1480B │
└─────────────────────┘
1500B
│
↓ MTU 576 네트워크 진입
│
라우터가 단편화
│
┌─────┼─────┐
↓ ↓ ↓
┌──────┐ ┌──────┐ ┌──────┐
│단편 1│ │단편 2│ │단편 3│
│ 572B │ │ 572B │ │ 396B │
└──────┘ └──────┘ └──────┘
│ │ │
└─────┼─────┘
↓
수신측에서 재조립
│
↓
┌─────────────────────┐
│ 원본 데이터 │
│ 1480B │
└─────────────────────┘
단편화 계산 예시
원본 패킷 1500바이트가 MTU 576 네트워크를 지나야 할 때:
1
2
3
4
원본: IP 헤더(20B) + 데이터(1480B) = 1500B
중간 네트워크 MTU: 576B
각 단편의 최대 데이터: 576 - 20(IP 헤더) = 556B
556바이트를 그대로 사용할 수 없습니다. 오프셋이 8바이트 단위이므로 데이터 크기도 8의 배수여야 합니다.
1
2
556 ÷ 8 = 69.5 → 버림 → 69
69 × 8 = 552B (8바이트 정렬된 최대 데이터 크기)
| 단편 | IP 헤더 | 데이터 | 총 크기 | 오프셋 | MF |
|---|---|---|---|---|---|
| 1 | 20B | 552B | 572B | 0 | 1 |
| 2 | 20B | 552B | 572B | 69 | 1 |
| 3 | 20B | 376B | 396B | 138 | 0 |
- 오프셋: 원본 데이터에서 이 단편의 시작 위치 (8바이트 단위)
- MF(More Fragments): 뒤에 단편이 더 있으면 1, 마지막이면 0
각 단편에 IP 헤더가 새로 붙습니다.
오프셋과 바이트 위치
오프셋 값에 8을 곱하면 실제 바이트 위치가 됩니다.
| 단편 | 오프셋 | 오프셋 × 8 | 실제 바이트 범위 |
|---|---|---|---|
| 1 | 0 | 0 | 0 ~ 551 |
| 2 | 69 | 552 | 552 ~ 1,103 |
| 3 | 138 | 1,104 | 1,104 ~ 1,479 |
1
2
3
4
5
6
7
8
원본 데이터 (1480바이트)
┌────────────────┬────────────────┬──────────┐
│ 0 ~ 551 │ 552 ~ 1103 │1104~1479 │
│ (552B) │ (552B) │ (376B) │
└────────────────┴────────────────┴──────────┘
↑ ↑ ↑
단편 1 단편 2 단편 3
offset=0 offset=69 offset=138
수신측은 오프셋을 보고 단편들을 원래 순서대로 재조립합니다.
왜 8바이트 단위일까요?
IP 헤더의 전체 길이(Total Length) 필드가 16비트이므로 IP 패킷은 최대 65,535바이트(약 64KB)까지 가능합니다. 그런데 오프셋 필드는 13비트밖에 없습니다.
| 필드 | 비트 수 | 표현 범위 |
|---|---|---|
| Total Length | 16비트 | 0 ~ 65,535 |
| Fragment Offset | 13비트 | 0 ~ 8,191 |
13비트로 64KB 범위를 표현하려면 3비트가 부족합니다. 8을 곱하면(2³ = 8) 이 3비트를 보충할 수 있습니다.
1
2
오프셋 필드 (13비트) ×8 실제 위치 (16비트 효과)
8,191 → 65,528
| 저장 방식 | 계산 | 표현 가능한 범위 |
|---|---|---|
| 바이트 단위 | 13비트 그대로 | 0 ~ 8,191 (8KB) |
| 8바이트 단위 | 13비트 + 3비트(×8) | 0 ~ 65,528 (64KB) |
왜 하필 8인가?
| 배수 | 계산 | 최대 범위 | 64KB 커버 |
|---|---|---|---|
| ×4 | 8,191 × 4 | 32,764 | 부족 |
| ×8 | 8,191 × 8 | 65,528 | 충분 |
| ×16 | 8,191 × 16 | 131,056 | 과잉 (정밀도 낭비) |
8은 64KB를 커버하면서 정밀도를 최대한 유지하는 최소 배수입니다.
트레이드오프: 8바이트 단위이므로 단편 데이터 크기도 8의 배수여야 합니다. 마지막 단편을 제외한 모든 단편은 8바이트 경계에 맞춰야 합니다.
단편화의 문제점
단편화는 여러 문제를 일으킵니다.
| 문제 | 설명 |
|---|---|
| 성능 저하 | 라우터가 단편화 처리에 CPU를 사용하고, 수신측은 모든 단편이 도착할 때까지 메모리에 보관해야 함 |
| 손실 취약 | 단편 하나만 손실되어도 전체 패킷을 폐기하고 재전송해야 함 |
| 보안 문제 | 첫 번째 단편에만 TCP/UDP 헤더가 있어서, 일부 방화벽이 나머지 단편의 포트 정보를 확인하지 못함 |
Path MTU Discovery
단편화를 피하려면 경로 상의 최소 MTU를 미리 알아야 합니다. 이를 Path MTU Discovery라고 합니다.
동작 방식은 다음과 같습니다.
- 송신측이 IP 헤더의 DF(Don’t Fragment) 플래그를 설정하고 패킷을 보냄
- 경로 상의 라우터가 MTU보다 큰 패킷을 만나면 단편화하지 않고 패킷을 폐기
- 라우터가 ICMP “Fragmentation Needed” 메시지를 송신측에 반환 (해당 링크의 MTU 포함)
- 송신측이 패킷 크기를 줄여서 재전송
1
2
3
4
5
6
7
8
9
송신측 라우터 (MTU: 1280) 수신측
│ │ │
│── 패킷 1500B, DF=1 ──→ │ │
│ ✕ (MTU 초과로 폐기) │
│←─ ICMP: Frag Needed ───│ │
│ "MTU는 1280이야" │ │
│ │ │
│── 패킷 1280B, DF=1 ──→ │ ─────────────────────→ │
│ │ │
TCP는 Path MTU Discovery 결과를 MSS에 반영합니다. 경로 MTU가 1280바이트라면 MSS는 1280 - 20(IP) - 20(TCP) = 1240바이트가 됩니다.
PMTUD의 한계
일부 방화벽이 ICMP 메시지를 차단하면 Path MTU Discovery가 동작하지 않습니다. 송신측은 패킷이 폐기되었는지 모르고 계속 큰 패킷을 보내다가 연결이 멈춥니다. 이를 ICMP 블랙홀 문제라고 합니다.
대안으로 PLPMTUD(Packetization Layer Path MTU Discovery)가 있습니다. ICMP에 의존하지 않고 TCP/UDP 계층에서 패킷 크기를 조절하며 경로 MTU를 찾습니다.
TCP vs UDP의 단편화
| 프로토콜 | 단편화 발생 여부 | 이유 |
|---|---|---|
| TCP | 거의 없음 | MSS 협상으로 세그먼트 크기를 MTU 이하로 제한 |
| UDP | 발생 가능 | MSS 개념 없음, 애플리케이션이 패킷 크기를 직접 관리 |
TCP는 MSS 협상을 통해 경로 MTU를 고려한 세그먼트 크기를 사용합니다. 세그먼트가 MSS를 넘지 않으면 IP 패킷도 MTU를 넘지 않아 단편화가 발생하지 않습니다.
UDP는 단편화에 더 취약합니다.
| 문제 | 설명 |
|---|---|
| 재전송 없음 | UDP는 재전송 기능이 없어서, 단편 하나가 손실되면 전체 데이터그램이 유실됨 |
| 애플리케이션 책임 | 패킷 크기 관리를 애플리케이션이 직접 해야 함 |
UDP 단편화가 발생하는 대표적인 경우:
| 애플리케이션 | 상황 |
|---|---|
| DNS | 응답이 512바이트를 초과할 때 (DNSSEC, 대용량 레코드) |
| 온라인 게임 | 한 번에 많은 상태 정보를 전송할 때 |
| VoIP/영상 통화 | 큰 프레임을 전송할 때 |
대부분의 UDP 애플리케이션은 단편화를 피하기 위해 패킷 크기를 MTU 이하로 제한합니다. DNS는 UDP 대신 TCP를 사용하거나 EDNS0으로 MTU를 협상합니다.
재조립과 순서 보장
수신측에서 데이터를 원래 순서로 복원하는 과정을 살펴봅시다.
TCP 재조립
Part 2에서 시퀀스 번호가 데이터 순서를 추적한다고 했습니다. 패킷이 순서대로 도착하지 않아도 수신측이 재정렬할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
수신된 순서: 세그먼트 3 → 세그먼트 1 → 세그먼트 4 → 세그먼트 2
수신 버퍼 (시퀀스 번호 기준):
seq=0 seq=1460 seq=2920 seq=4380
───── ──────── ──────── ────────
세그먼트 3 도착:
┌─────┬─────┬─────┬─────┐
│ │ │ [3] │ │ ← 앞이 비어있어 전달 불가
└─────┴─────┴─────┴─────┘
세그먼트 1 도착:
┌─────┬─────┬─────┬─────┐
│ [1] │ │ [3] │ │ ← [2]가 없어 전달 불가
└─────┴─────┴─────┴─────┘
세그먼트 4 도착:
┌─────┬─────┬─────┬─────┐
│ [1] │ │ [3] │ [4] │ ← [2]가 없어 전달 불가
└─────┴─────┴─────┴─────┘
세그먼트 2 도착:
┌─────┬─────┬─────┬─────┐
│ [1] │ [2] │ [3] │ [4] │ → [1][2][3][4] 애플리케이션에 전달!
└─────┴─────┴─────┴─────┘
TCP는 순서대로 애플리케이션에 데이터를 전달합니다. 세그먼트 3, 4가 먼저 도착해도 세그먼트 1, 2가 올 때까지 버퍼에 보관합니다.
이 특성은 Head-of-Line Blocking 문제를 일으킵니다. 앞 세그먼트 하나가 지연되거나 손실되면, 이미 도착한 뒤 세그먼트들도 애플리케이션에 전달되지 못하고 대기합니다.
| 상황 | TCP 동작 |
|---|---|
| 세그먼트 지연 | 앞 세그먼트가 도착할 때까지 대기 |
| 세그먼트 손실 | Part 2에서 설명한 재전송으로 복구 |
UDP는 다릅니다
UDP는 순서 보장이 없습니다. 도착한 데이터그램을 그대로 애플리케이션에 전달합니다.
| 프로토콜 | 순서 보장 | 장점 | 단점 |
|---|---|---|---|
| TCP | 있음 | 데이터 무결성 | Head-of-Line Blocking |
| UDP | 없음 | 지연 최소화 | 애플리케이션이 순서 처리 |
실시간 애플리케이션(게임, 영상 통화)은 UDP를 선호합니다. 이전 프레임을 기다리느니 최신 프레임을 바로 처리하는 것이 사용자 경험에 유리하기 때문입니다.
IP 단편 재조립
IP 단편화된 패킷도 재조립이 필요합니다. IP 헤더의 식별자(ID), 플래그, 단편 오프셋 필드가 사용됩니다.
1
원본 패킷: IP 헤더 (ID=1234) + 데이터 (4500바이트)
MTU가 1500바이트인 네트워크를 지나면 4개 단편으로 나뉩니다. 각 단편은 최대 1480바이트의 데이터를 담습니다 (1500 - 20 = 1480).
| 단편 | ID | offset | offset × 8 | MF | 데이터 크기 | 바이트 범위 |
|---|---|---|---|---|---|---|
| 1 | 1234 | 0 | 0 | 1 | 1480B | 0 ~ 1,479 |
| 2 | 1234 | 185 | 1,480 | 1 | 1480B | 1,480 ~ 2,959 |
| 3 | 1234 | 370 | 2,960 | 1 | 1480B | 2,960 ~ 4,439 |
| 4 | 1234 | 555 | 4,440 | 0 | 60B | 4,440 ~ 4,499 |
- ID: 같은 패킷에서 나온 단편들은 동일한 ID를 가짐
- MF(More Fragments): 1이면 뒤에 더 있음, 0이면 마지막
- offset: 8바이트 단위 (185 × 8 = 1480)
수신측은 같은 ID를 가진 단편들을 모아 오프셋 순서대로 재조립합니다.
재조립 실패
단편 하나라도 손실되면 전체 패킷을 복원할 수 없습니다.
| 상황 | 동작 |
|---|---|
| 단편 손실 | 타임아웃(보통 30~60초) 후 모든 단편 폐기 |
| 타임아웃 | ICMP “Time Exceeded (Fragment Reassembly)” 메시지 반환 |
IP 계층은 재전송 기능이 없으므로, TCP라면 상위 계층에서 세그먼트 전체를 재전송합니다.
마무리
하나의 네트워크 인터페이스가 수천 개의 연결을 동시에 처리할 수 있는 이유는 다중화입니다.
Part 1에서 소켓이 5-tuple로 식별되는 통신 끝점이라고 했습니다. Part 2에서는 그 끝점들이 어떻게 상태를 동기화하는지 살펴보았습니다. 이번 글에서는 데이터가 어떻게 패킷이 되어 네트워크를 오가는지 살펴보았습니다.
| 주제 | 핵심 내용 |
|---|---|
| 멀티플렉싱 | 포트 번호로 여러 연결을 하나의 IP 주소에서 처리 |
| 역다중화 | 커널이 5-tuple로 패킷을 올바른 소켓에 전달 |
| 캡슐화 | 각 계층이 헤더를 붙여 하위 계층으로 전달 |
| MTU/MSS | TCP는 MSS 협상으로 단편화 방지 |
| IP 단편화 | MTU가 작은 네트워크를 지날 때 패킷 분할 |
| 재조립 | 시퀀스 번호/오프셋으로 순서대로 복원 |
이 모든 과정이 커널에서 자동으로 처리됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
애플리케이션: write(data) ───────────────────────────────→ read(data)
│ ↑
↓ │
TCP: 세그먼트 생성 (시퀀스 번호) 시퀀스 번호로 재조립
│ ↑
↓ │
IP: 패킷 생성 (출발지/목적지 IP) 오프셋으로 단편 재조립
│ ↑
↓ │
이더넷: 프레임 생성 (MAC 주소) MAC 주소로 수신
│ ↑
└──────────────── 네트워크 ───────────────────┘
애플리케이션은 write()로 데이터를 보내고 read()로 받기만 하면 됩니다. Part 1에서 살펴본 Berkeley Sockets 추상화가 이 복잡성을 모두 숨깁니다.
시리즈를 마치며
세 편의 글을 통해 소켓과 전송 계층을 살펴보았습니다.
| Part | 주제 | 핵심 질문 |
|---|---|---|
| 1 | 소켓의 탄생과 추상화 | 네트워크 통신을 어떻게 파일처럼 다룰 수 있을까? |
| 2 | TCP 연결의 상태 머신 | 두 호스트가 어떻게 상태를 동기화할까? |
| 3 | 멀티플렉싱과 패킷 흐름 | 데이터가 어떻게 패킷이 되어 전달될까? |
1983년에 만들어진 Berkeley Sockets API가 40년이 지난 지금도 모든 운영체제에서 표준으로 사용됩니다. 그 이유는 복잡한 네트워크 동작을 단순한 파일 I/O 인터페이스로 추상화했기 때문입니다.
관련 글
시리즈
- 소켓과 전송 계층 (1) - 소켓의 탄생과 추상화
- 소켓과 전송 계층 (2) - TCP 연결의 상태 머신
- 소켓과 전송 계층 (3) - 멀티플렉싱과 패킷 흐름 (현재 글)