icon

안동민 개발노트

2장 : 네트워크 모델과 계층 구조

캡슐화와 역캡슐화


계층 모델을 배웠으니, 이제 핵심 질문에 답할 수 있어야 합니다. 계층들 사이에서 데이터는 정확히 어떤 방식으로 전달될까요?

웹 브라우저에서 Hello라는 텍스트를 서버에 보낸다고 합시다. 이 텍스트가 상대방에게 도달하려면 응용 계층에서 시작하여 아래 계층으로 내려가며 각 계층의 정보가 추가되고, 수신 측에서는 아래 계층부터 올라가며 그 정보를 하나씩 벗깁니다. 이 과정이 바로 캡슐화(Encapsulation)역캡슐화(Decapsulation)입니다.


보내는 쪽: 캡슐화

캡슐화는 데이터가 상위 계층에서 하위 계층으로 내려가면서, 각 계층이 자기만의 헤더(Header)를 앞에 덧붙이는 과정입니다.

송신 측 캡슐화 과정
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

응용 계층:   │      DATA       │                 → 메시지(Message)
             └─────────────────┘

전송 계층:   │TCP H│      DATA       │           → 세그먼트(Segment)
             └─────┴─────────────────┘

인터넷 계층: │IP H│TCP H│      DATA       │      → 패킷(Packet)
             └────┴─────┴─────────────────┘

네트워크     │ETH H│IP H│TCP H│    DATA    │FCS│ → 프레임(Frame)
인터페이스:  └─────┴────┴─────┴────────────┴───┘

물리:         01011010110101001110101...         → 비트(Bit)

응용 계층에서 시작합니다. 브라우저가 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바이트)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
├─────────────────────────────────────────────────────────────────┤
│                    목적지 MAC 주소 (6바이트)                    │
├─────────────────────────────────────────────────────────────────┤
│                    출발지 MAC 주소 (6바이트)                    │
├─────────────────────────────────────────────────────────────────┤
│              이더타입 (2바이트)                                 │
│              0x0800 = IPv4, 0x86DD = IPv6                       │
└─────────────────────────────────────────────────────────────────┘

IPv4 헤더 (20~60바이트)

필드크기설명
Version4비트IP 버전 (4)
IHL4비트헤더 길이 (보통 5 = 20바이트)
Total Length2바이트IP 패킷 전체 길이
TTL1바이트최대 홉 수 (보통 64 또는 128)
Protocol1바이트상위 프로토콜 (6=TCP, 17=UDP)
Source IP4바이트출발지 IP
Destination IP4바이트목적지 IP

TCP 헤더 (20~60바이트)

필드크기설명
Source Port2바이트출발지 포트 (0~65535)
Destination Port2바이트목적지 포트
Sequence Number4바이트바이트 순서 번호
Acknowledgment4바이트확인 응답 번호
Flags6비트SYN, ACK, FIN, RST 등
Window Size2바이트수신 버퍼 여유 공간
Checksum2바이트무결성 검증

받는 쪽: 역캡슐화

수신 측에서는 정확히 반대 과정이 일어납니다.

수신 측 역캡슐화 과정
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

물리:         01011010110101001110101...

네트워크     │ETH H│IP H│TCP H│    DATA    │FCS│
인터페이스:  └──┬──┴────┴─────┴────────────┴─┬─┘
               ✓ MAC 확인, FCS 검증           ✗ 제거

인터넷 계층: │IP H│TCP H│      DATA       │
             └──┬─┴─────┴─────────────────┘
                ✓ IP 주소 확인, TTL 검사

전송 계층:   │TCP H│      DATA       │
             └──┬──┴─────────────────┘
                ✓ 포트 확인, 순서 재조립

응용 계층:   │      DATA       │  → HTTP 메시지 복원
             └─────────────────┘

물리적 신호가 도착하면 네트워크 인터페이스 계층이 비트를 프레임으로 복원합니다. 이더넷 헤더를 읽어 목적지 MAC 주소가 자신의 것인지 확인하고, 트레일러의 FCS로 오류를 검사합니다. 문제가 없으면 이더넷 헤더와 트레일러를 벗겨내고, 안에 있는 패킷을 인터넷 계층으로 올려보냅니다.

인터넷 계층이 IP 헤더를 읽어 목적지 IP 주소를 확인하고, IP 헤더를 벗겨냅니다. 안에 있는 세그먼트를 전송 계층으로 올려보냅니다.

전송 계층이 TCP 헤더를 읽어 목적지 포트 번호를 확인하고, 해당 포트를 사용하는 프로세스에게 데이터를 전달합니다. TCP 헤더를 벗겨내면 원래의 HTTP 메시지가 나타납니다.

응용 계층에서 애플리케이션(예: 웹 서버)이 HTTP 메시지를 파싱하여 요청을 처리합니다.


PDU 이름 정리

각 계층에서의 데이터 단위를 PDU(Protocol Data Unit)라고 합니다.

계층PDU 이름구성
응용메시지 (Message)순수 애플리케이션 데이터
전송세그먼트 (Segment) / 데이터그램TCP 헤더 + 데이터 / UDP 헤더 + 데이터
인터넷패킷 (Packet)IP 헤더 + 세그먼트
네트워크 인터페이스프레임 (Frame)ETH 헤더 + 패킷 + FCS
물리비트 (Bit)0과 1의 전기/광 신호

일상적으로 패킷이라는 단어를 계층 구분 없이 사용하는 경우가 많지만, 정확히 말하면 패킷은 인터넷 계층의 PDU입니다.


크기 오버헤드 계산

overhead_calculator.py
"""캡슐화로 인한 오버헤드를 계산"""

ethernet_header = 14   # 목적지MAC(6) + 출발지MAC(6) + 이더타입(2)
ip_header = 20         # 기본 IPv4 헤더
tcp_header = 20        # 기본 TCP 헤더 (옵션 제외)
fcs = 4                # Frame Check Sequence

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 = small_data + ip_header + tcp_header + ethernet_header + fcs
print(f"=== 작은 패킷의 비효율 ===")
print(f"1바이트 전송 시 프레임 크기: {small_frame} bytes")
print(f"데이터 효율: {small_data/small_frame*100:.1f}%")
print(f"→ Nagle 알고리즘이 작은 패킷을 모아 보내는 이유")

1바이트 데이터를 보내더라도 최소 59바이트의 프레임이 필요합니다. 데이터 효율이 1.7%에 불과합니다. 이것이 TCP의 Nagle 알고리즘이 작은 패킷들을 모아서 보내는 이유입니다.


중간 라우터에서의 처리

캡슐화와 역캡슐화는 송수신 양쪽에서만 일어나는 것이 아닙니다. 중간에 있는 라우터에서도 부분적인 역캡슐화와 재캡슐화가 일어납니다.

PC ──→ [라우터 A] ──→ [라우터 B] ──→ 서버
        │                  │
        │ L2 헤더 제거     │ L2 헤더 제거
        │ L3(IP) 확인      │ L3(IP) 확인
        │ 새 L2 헤더 생성  │ 새 L2 헤더 생성
        │ (다음 홉 MAC)    │ (서버 MAC)
        ↓                  ↓

라우터는 프레임을 수신하면 이더넷 헤더를 벗기고 IP 헤더에서 목적지 IP를 확인합니다. 라우팅 테이블을 조회해 다음 홉을 결정한 뒤, 새로운 이더넷 헤더(다음 홉의 MAC 주소)를 붙여 프레임을 재생성합니다.

핵심은 라우터가 L3(IP)까지만 확인하고, TCP 헤더나 HTTP 데이터에는 손대지 않는다는 것입니다. IP 주소는 종단 간 변하지 않지만, MAC 주소는 홉마다 바뀝니다.


Wireshark로 실제 확인하기

Wireshark는 네트워크 인터페이스를 통해 오가는 패킷을 캡처하여 각 계층의 헤더를 시각적으로 보여주는 프로그램입니다.

tcpdump_capture.sh
# 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 100

tcpdump의 -XX 옵션으로 캡처한 패킷의 헥스 덤프를 보면, 캡슐화 구조를 바이트 단위로 확인할 수 있습니다.

# 헥스 덤프 예시 (HTTP GET 요청)
0x0000: aa bb cc dd ee ff 11 22  33 44 55 66 08 00  ← 이더넷 헤더(14B)
0x000e: 45 00 00 3c 1c 46 40 00  40 06 ...          ← IP 헤더 시작
0x0022: c0 a8 01 64 5d b8 d8 22  ...                ← IP 주소들
0x0026: ...                                         ← TCP 헤더 시작
0x003e: 47 45 54 20 2f 20 48 54  54 50 ...          ← "GET / HTTP" 시작
오프셋내용계층
0x0000~0x000dMAC 주소 + 이더타입네트워크 인터페이스
0x000e~0x0021IP 버전, TTL, 주소 등인터넷
0x0022~0x0035포트, 순서번호, 플래그전송
0x0036~HTTP 요청 데이터응용

Wireshark에서는 각 계층을 클릭하면 해당 바이트가 하이라이트됩니다. 네트워크의 모든 통신은 계층별 헤더가 겹겹이 쌓인 하나의 바이트 스트림입니다.


Python으로 직접 패킷 파싱

parse_ethernet_frame.py
"""이더넷 프레임을 바이트 단위로 파싱하는 예시"""
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")

실무 트러블슈팅에서의 계층별 접근

패킷 캡처에서 문제를 진단할 때, 각 계층을 순서대로 확인합니다.

단계확인 사항도구비정상 징후
L2MAC 주소, 이더타입tcpdump -e브로드캐스트 폭풍, ARP 문제
L3IP 주소, TTLping, tracerouteTTL=0 (라우팅 루프), ICMP 도달불가
L4포트, TCP 플래그ss, netstatSYN만 보이고 SYN-ACK 없음 (방화벽)
L7HTTP 상태, DNS 응답curl, dig503 에러, NXDOMAIN
layer_debug.sh
# 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

다음 장에서는 이 계층 구조의 가장 아래, 물리적 세계에서 데이터가 어떻게 전달되는지를 살펴봅니다.

목차