캡슐화와 역캡슐화
계층 모델을 배웠으니, 이제 핵심 질문에 답할 수 있어야 합니다. 계층들 사이에서 데이터는 정확히 어떤 방식으로 전달될까요?
웹 브라우저에서 Hello라는 텍스트를 서버에 보낸다고 합시다. 이 텍스트가 상대방에게 도달하려면 응용 계층에서 시작하여 아래 계층으로 내려가며 각 계층의 정보가 추가되고, 수신 측에서는 아래 계층부터 올라가며 그 정보를 하나씩 벗깁니다. 이 과정이 바로 캡슐화(Encapsulation)와 역캡슐화(Decapsulation)입니다.
보내는 쪽: 캡슐화
캡슐화는 데이터가 상위 계층에서 하위 계층으로 내려가면서, 각 계층이 자기 계층에서 필요한 제어 정보를 덧붙이는 과정입니다. 대부분은 앞쪽의 헤더(Header)이지만, 이더넷처럼 뒤쪽에 오류 검사용 트레일러(Trailer)를 붙이는 경우도 있습니다.
응용 계층에서 시작합니다. 브라우저가 HTTP 요청 메시지를 생성합니다. 여기에는 요청 메서드, URL, 헤더, 바디 등이 포함됩니다. 이 시점의 데이터를 메시지(Message)라고 부릅니다.
이 메시지가 전송 계층으로 내려갑니다. TCP는 메시지 앞에 TCP 헤더를 붙입니다. TCP 헤더에는 출발지/목적지 포트 번호, 순서 번호(Sequence Number), 확인 번호(Acknowledgment Number), 윈도우 크기 등이 담깁니다. 이 결과물을 세그먼트(Segment)라고 부릅니다.
세그먼트가 인터넷 계층으로 내려갑니다. IP는 세그먼트 앞에 IP 헤더를 붙입니다. IP 헤더에는 출발지/목적지 IP 주소, TTL(Time To Live), 프로토콜 번호 등이 담깁니다. 이 결과물을 패킷(Packet)이라고 부릅니다.
패킷이 네트워크 인터페이스 계층으로 내려갑니다. 이더넷은 패킷 앞에 이더넷 헤더(출발지/목적지 MAC 주소)를, 뒤에는 트레일러(FCS: 오류 검출 코드)를 붙입니다. 이 최종 결과물을 프레임(Frame)이라고 부릅니다.
프레임은 최종적으로 비트(Bit)로 변환되어 물리적 매체(케이블, 전파)를 통해 전송됩니다.
각 계층 헤더의 구조
이더넷 헤더 (14바이트, FCS 제외)
이더넷 프레임의 기본 헤더는 목적지 MAC 6바이트, 출발지 MAC 6바이트, EtherType 2바이트로 구성됩니다. 프레임 끝에는 FCS 4바이트가 붙지만, 일반적인 패킷 캡처 도구에서는 NIC나 드라이버가 FCS를 제거한 뒤 전달하는 경우가 많습니다.
IPv4 헤더 (20~60바이트)
| 필드 | 크기 | 설명 |
|---|---|---|
| Version | 4비트 | IP 버전 (4) |
| IHL | 4비트 | 헤더 길이 (보통 5 = 20바이트) |
| Total Length | 2바이트 | IP 패킷 전체 길이 |
| TTL | 1바이트 | 최대 홉 수 (보통 64 또는 128) |
| Protocol | 1바이트 | 상위 프로토콜 (6=TCP, 17=UDP) |
| Source IP | 4바이트 | 출발지 IP |
| Destination IP | 4바이트 | 목적지 IP |
TCP 헤더 (20~60바이트)
| 필드 | 크기 | 설명 |
|---|---|---|
| Source Port | 2바이트 | 출발지 포트 (0~65535) |
| Destination Port | 2바이트 | 목적지 포트 |
| Sequence Number | 4바이트 | 바이트 순서 번호 |
| Acknowledgment | 4바이트 | 확인 응답 번호 |
| Flags | 가변 표현 | SYN, ACK, FIN, RST 등 제어 비트 |
| Window Size | 2바이트 | 수신 버퍼 여유 공간 |
| Checksum | 2바이트 | 무결성 검증 |
받는 쪽: 역캡슐화
수신 측에서는 큰 흐름상 반대 방향의 처리가 일어납니다. 다만 실제 구현에서는 오류가 있는 프레임을 버리거나, IP 조각을 재조립하거나, TCP 바이트 스트림을 다시 맞추거나, TLS를 복호화하는 단계가 추가될 수 있습니다.
물리적 신호가 도착하면 네트워크 인터페이스 계층이 비트를 프레임으로 복원합니다. 이더넷 헤더를 읽어 목적지 MAC 주소가 자신의 것인지 확인하고, 트레일러의 FCS로 오류를 검사합니다. 문제가 없으면 이더넷 헤더와 트레일러를 벗겨내고, 안에 있는 패킷을 인터넷 계층으로 올려보냅니다.
인터넷 계층이 IP 헤더를 읽어 목적지 IP 주소를 확인하고, IP 헤더를 벗겨냅니다. 안에 있는 세그먼트를 전송 계층으로 올려보냅니다.
전송 계층이 TCP 헤더를 읽어 목적지 포트 번호를 확인하고, 해당 포트를 사용하는 프로세스에게 바이트 스트림을 전달합니다. TCP 세그먼트 경계와 HTTP 메시지 경계가 항상 일치하는 것은 아니며, HTTPS라면 TLS 복호화 뒤에 HTTP 메시지를 볼 수 있습니다.
응용 계층에서 애플리케이션(예: 웹 서버)이 HTTP 메시지를 파싱하여 요청을 처리합니다.
PDU 이름 정리
각 계층에서의 데이터 단위를 PDU(Protocol Data Unit)라고 합니다.
| 계층 | PDU 이름 | 구성 |
|---|---|---|
| 응용 | 메시지 (Message) | 순수 애플리케이션 데이터 |
| 전송 | 세그먼트 (Segment) / 데이터그램 | TCP 헤더 + 데이터 / UDP 헤더 + 데이터 |
| 인터넷 | 패킷 (Packet) | IP 헤더 + 세그먼트 |
| 네트워크 인터페이스 | 프레임 (Frame) | L2 헤더 + 패킷 + 트레일러 |
| 물리 | 비트 (Bit) | 0과 1의 전기/광 신호 |
일상적으로 패킷이라는 단어를 계층 구분 없이 사용하는 경우가 많지만, 정확히 말하면 패킷은 인터넷 계층의 PDU입니다.
크기 오버헤드 계산
"""캡슐화로 인한 오버헤드를 계산"""
ethernet_header = 14 # 목적지MAC(6) + 출발지MAC(6) + 이더타입(2)
ip_header = 20 # 기본 IPv4 헤더
tcp_header = 20 # 기본 TCP 헤더 (옵션 제외)
fcs = 4 # Frame Check Sequence
ethernet_min_frame = 64 # 목적지 MAC부터 FCS까지의 최소 프레임 크기
total_overhead = ethernet_header + ip_header + tcp_header + fcs
# 일반적인 이더넷 MTU = 1500 바이트 (IP 패킷 최대 크기)
mtu = 1500
max_tcp_payload = mtu - ip_header - tcp_header # MSS
print(f"=== 캡슐화 오버헤드 ===")
print(f"이더넷 헤더: {ethernet_header} bytes")
print(f"IP 헤더: {ip_header} bytes")
print(f"TCP 헤더: {tcp_header} bytes")
print(f"FCS: {fcs} bytes")
print(f"총 오버헤드: {total_overhead} bytes")
print()
print(f"이더넷 MTU: {mtu} bytes")
print(f"TCP MSS: {max_tcp_payload} bytes")
print(f"최대 프레임: {mtu + ethernet_header + fcs} bytes")
print(f"데이터 효율: {max_tcp_payload/(mtu+ethernet_header+fcs)*100:.1f}%")
print()
# 작은 패킷의 비효율
small_data = 1 # 1바이트 데이터 (예: Telnet 키 입력)
small_frame_without_padding = small_data + ip_header + tcp_header + ethernet_header + fcs
small_frame = max(ethernet_min_frame, small_frame_without_padding)
padding = small_frame - small_frame_without_padding
print(f"=== 작은 패킷의 비효율 ===")
print(f"1바이트 전송 시 프레임 크기: {small_frame} bytes")
print(f"이더넷 padding: {padding} bytes")
print(f"데이터 효율: {small_data/small_frame*100:.1f}%")
print(f"→ 작은 TCP 세그먼트를 줄이려는 최적화가 필요한 이유")1바이트 데이터를 보내더라도 이더넷 최소 프레임 크기 때문에 목적지 MAC부터 FCS까지 최소 64바이트가 전송됩니다. 데이터 효율은 약 1.6%에 불과합니다. TCP의 Nagle 알고리즘은 이런 작은 세그먼트가 과도하게 많이 생기는 것을 줄이려는 최적화 중 하나입니다.
중간 라우터에서의 처리
캡슐화와 역캡슐화는 송수신 양쪽에서만 일어나는 것이 아닙니다. 중간에 있는 라우터에서도 부분적인 역캡슐화와 재캡슐화가 일어납니다.
라우터는 프레임을 수신하면 링크 계층 헤더를 벗기고 IP 헤더에서 목적지 IP를 확인합니다. 라우팅 테이블을 조회해 다음 홉을 결정한 뒤, 다음 링크에 맞는 새로운 L2 헤더를 붙여 프레임을 재생성합니다. 다음 링크가 이더넷이라면 이때 다음 홉의 MAC 주소가 들어갑니다.
핵심은 일반적인 라우터가 L3(IP)까지 확인해 다음 홉을 결정하고, TCP 헤더나 HTTP 데이터는 그대로 통과시킨다는 것입니다. 일반 라우팅에서는 IP 주소가 종단 간 유지되고 MAC 주소는 홉마다 바뀝니다. 다만 NAT, 방화벽, 프록시, 터널링 장비는 이보다 더 깊은 계층의 정보를 보거나 주소를 바꿀 수 있습니다.
Wireshark로 실제 확인하기
Wireshark는 네트워크 인터페이스를 통해 오가는 패킷을 캡처하여 각 계층의 헤더를 시각적으로 보여주는 프로그램입니다.
# Wireshark 대신 CLI로 패킷 캡처 (서버 환경)
# HTTP 트래픽 캡처 (80번 포트)
sudo tcpdump -i eth0 port 80 -c 5 -nn
# 헥스 덤프로 헤더 구조 확인
sudo tcpdump -i eth0 port 80 -c 1 -XX
# 특정 호스트와의 통신만 캡처
sudo tcpdump -i eth0 host 93.184.216.34 -c 10
# pcap 파일로 저장 (나중에 Wireshark에서 열기)
sudo tcpdump -i eth0 -w capture.pcap -c 100tcpdump의 -XX 옵션으로 캡처한 패킷의 헥스 덤프를 보면, 캡슐화 구조를 바이트 단위로 확인할 수 있습니다. 단, 앞에서 언급한 것처럼 이더넷 FCS는 보통 캡처 결과에 나타나지 않습니다.
| 오프셋 | 내용 | 계층 |
|---|---|---|
| 0x0000~0x000d | MAC 주소 + 이더타입 | 네트워크 인터페이스 |
| 0x000e~0x0021 | IP 버전, TTL, 주소 등 | 인터넷 |
| 0x0022~0x0035 | 포트, 순서번호, 플래그 | 전송 |
| 0x0036~ | HTTP 요청 데이터 | 응용 |
Wireshark에서는 각 계층을 클릭하면 해당 바이트가 하이라이트됩니다. 캡처된 프레임 안에서는 계층별 헤더와 페이로드가 어떤 바이트 범위를 차지하는지 확인할 수 있습니다.
Python으로 직접 패킷 파싱
"""이더넷 프레임을 바이트 단위로 파싱하는 예시"""
import struct
def parse_ethernet(raw_bytes):
"""이더넷 헤더 파싱 (14바이트)"""
dst_mac = raw_bytes[0:6]
src_mac = raw_bytes[6:12]
ether_type = struct.unpack("!H", raw_bytes[12:14])[0]
dst = ":".join(f"{b:02x}" for b in dst_mac)
src = ":".join(f"{b:02x}" for b in src_mac)
protocols = {0x0800: "IPv4", 0x86DD: "IPv6", 0x0806: "ARP"}
proto = protocols.get(ether_type, f"0x{ether_type:04x}")
print(f"[이더넷] {src} → {dst} ({proto})")
return raw_bytes[14:] # 페이로드 반환
def parse_ipv4(raw_bytes):
"""IPv4 헤더 파싱 (20바이트)"""
version_ihl = raw_bytes[0]
ihl = (version_ihl & 0x0F) * 4 # 헤더 길이
total_length = struct.unpack("!H", raw_bytes[2:4])[0]
ttl = raw_bytes[8]
protocol = raw_bytes[9]
src_ip = ".".join(str(b) for b in raw_bytes[12:16])
dst_ip = ".".join(str(b) for b in raw_bytes[16:20])
proto_names = {6: "TCP", 17: "UDP", 1: "ICMP"}
proto = proto_names.get(protocol, str(protocol))
print(f"[IPv4] {src_ip} → {dst_ip} (TTL={ttl}, {proto})")
print(f" 헤더: {ihl}B, 전체: {total_length}B")
return raw_bytes[ihl:] # 페이로드 반환
def parse_tcp(raw_bytes):
"""TCP 헤더 파싱 (20바이트~)"""
src_port = struct.unpack("!H", raw_bytes[0:2])[0]
dst_port = struct.unpack("!H", raw_bytes[2:4])[0]
seq_num = struct.unpack("!I", raw_bytes[4:8])[0]
ack_num = struct.unpack("!I", raw_bytes[8:12])[0]
data_offset = (raw_bytes[12] >> 4) * 4
flags = raw_bytes[13]
flag_str = []
if flags & 0x02: flag_str.append("SYN")
if flags & 0x10: flag_str.append("ACK")
if flags & 0x01: flag_str.append("FIN")
if flags & 0x04: flag_str.append("RST")
if flags & 0x08: flag_str.append("PSH")
print(f"[TCP] :{src_port} → :{dst_port}")
print(f" SEQ={seq_num}, ACK={ack_num}")
print(f" 플래그: {', '.join(flag_str)}")
return raw_bytes[data_offset:] # 페이로드 반환
# 예시: 가상 패킷 파싱
sample_frame = bytes([
# 이더넷 헤더 (14B)
0xff,0xff,0xff,0xff,0xff,0xff, # 목적지 MAC
0x00,0x1a,0x2b,0x3c,0x4d,0x5e, # 출발지 MAC
0x08,0x00, # IPv4
# IPv4 헤더 (20B)
0x45,0x00,0x00,0x34,0x00,0x00,0x40,0x00,
0x40,0x06,0x00,0x00, # TTL=64, TCP
0xc0,0xa8,0x01,0x64, # 192.168.1.100
0x5d,0xb8,0xd8,0x22, # 93.184.216.34
# TCP 헤더 (20B)
0xcc,0x95,0x00,0x50, # 포트 52373 → 80
0x00,0x00,0x00,0x01, # SEQ=1
0x00,0x00,0x00,0x00, # ACK=0
0x50,0x02,0xff,0xff,0x00,0x00,0x00,0x00, # SYN
])
ip_data = parse_ethernet(sample_frame)
tcp_data = parse_ipv4(ip_data)
payload = parse_tcp(tcp_data)
print(f"\n페이로드: {len(payload)} bytes")실무 트러블슈팅에서의 계층별 접근
패킷 캡처에서 문제를 진단할 때, 각 계층을 순서대로 확인합니다.
| 단계 | 확인 사항 | 도구 | 비정상 징후 |
|---|---|---|---|
| L2 | MAC 주소, 이더타입 | tcpdump -e | 브로드캐스트 폭풍, ARP 문제 |
| L3 | IP 주소, TTL | ping, traceroute | TTL=0 (라우팅 루프), ICMP 도달불가 |
| L4 | 포트, TCP 플래그 | ss, netstat | SYN만 보이고 SYN-ACK 없음 (방화벽) |
| L7 | HTTP 상태, DNS 응답 | curl, dig | 503 에러, NXDOMAIN |
# L2: ARP 테이블에서 게이트웨이 MAC 확인
arp -n | grep "$(ip route | grep default | awk '{print $3}')"
# L3: 목적지까지 경로 확인
traceroute -n 93.184.216.34
# L4: TCP 연결 상태 확인
ss -tn state established | head -20
# L7: HTTP 응답 확인
curl -I https://example.com 2>/dev/null | head -5다음 장에서는 이 계층 구조의 가장 아래, 물리적 세계에서 데이터가 어떻게 전달되는지를 살펴봅니다.