네트워크 계층과 전송 프로토콜
OSI 7계층 모델에서 전송 계층(Transport Layer)은 끝단 시스템 간의 논리적 통신을 담당합니다. 이 계층에서는 주로 TCP와 UDP 프로토콜이 사용되며, 각 프로토콜은 데이터 전송의 신뢰성과 방식에 따라 명확히 구분됩니다.
TCP (Transmission Control Protocol)
TCP는 신뢰할 수 있는 연결형 프로토콜로, 데이터의 손실 없이 정확한 순서로 전달하는 것을 보장합니다. 이러한 신뢰성은 피드백 메커니즘과 양방향 채널을 통해 구현됩니다.
- 3-way Handshake (3방향 핸드셰이크): 클라이언트가 연결 요청(SYN)을 보내면, 서버가 이를 확인하고 자신의 연결 요청(SYN-ACK)을 보냅니다. 클라이언트가 이를 다시 확인(ACK)하면 양방향 데이터 전송 채널이 수립됩니다.
- 데이터 재전송: 송신측은 데이터 사본을 유지하다가 수신측으로부터 확인 응답(ACK)을 받아야만 사본을 폐기합니다. 응답이 없으면 일정 시간 후 재전송합니다.
- SYN Flood 공격: 악의적인 클라이언트가 대량의 SYN 패킷만 보내고 ACK를 보내지 않아 서버의 반연결 큐를 고갈시키는 서비스 거부(DoS) 공격입니다.
- 4-way Handshake (4방향 핸드셰이크): 연결 종료 시 사용됩니다. 한쪽에서 FIN 패킷을 보내 종료를 알리고, 상대가 ACK를 보냅니다. 이후 상대도 FIN을 보내고, 최초 송신측이 ACK를 보내며 연결이 완전히 해제됩니다.
UDP (User Datagram Protocol)
UDP는 비연결형 데이터그램 프로토콜입니다. 전송 전 연결 수립 과정이 없으며, 데이터 도달 여부나 순서에 대한 피드백 메커니즘을 제공하지 않습니다. 헤더 오버헤드가 적어 실시간 스트리밍이나 빠른 전송이 필요한 환경에 적합합니다.
소켓(Socket) 모듈의 개념과 역할
소켓은 애플리케이션 계층과 TCP/IP 프로토콜 스택 간의 통신을 중재하는 소프트웨어 추상화 계층입니다. 디자인 패턴 관점에서 소켓은 파사드(Facade) 패턴을 적용하여, 복잡한 네트워크 프로토콜의 세부 사항을 숨기고 개발자에게 단순화된 인터페이스(API)만을 제공합니다.
TCP 소켓 프로그래밍 구현
Python의 socket 모듈을 사용하여 TCP 기반의 서버와 클라이언트를 구현할 수 있습니다.
TCP 서버 구현
import socket
def run_tcp_server():
# IPv4(AF_INET)와 TCP(SOCK_STREAM) 소켓 생성
srv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 포트 재사용 옵션 설정 (Address already in use 오류 방지)
srv_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 로컬호스트의 9090 포트에 바인딩
srv_socket.bind(('127.0.0.1', 9090))
# 최대 5개의 연결 대기 큐(Backlog) 설정
srv_socket.listen(5)
print("TCP 서버가 클라이언트의 연결을 대기 중입니다...")
# 클라이언트 연결 수락 (3-way Handshake 완료)
cli_socket, client_addr = srv_socket.accept()
print(f"클라이언트 연결 성공: {client_addr}")
try:
# 최대 1024 바이트의 데이터 수신
incoming_data = cli_socket.recv(1024)
print(f"수신된 메시지: {incoming_data.decode('utf-8')}")
# 클라이언트로 응답 전송
response_msg = "서버에서 응답을 보냅니다.".encode('utf-8')
cli_socket.sendall(response_msg)
finally:
# 연결 및 소켓 해제 (4-way Handshake)
cli_socket.close()
srv_socket.close()
if __name__ == "__main__":
run_tcp_server()
TCP 클라이언트 구현
import socket
def run_tcp_client():
cli_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 서버의 IP와 포트로 연결 요청
cli_socket.connect(('127.0.0.1', 9090))
try:
# 서버로 데이터 전송
msg_to_send = "클라이언트에서 보낸 테스트 메시지입니다.".encode('utf-8')
cli_socket.sendall(msg_to_send)
# 서버의 응답 수신
server_response = cli_socket.recv(1024)
print(f"서버 응답: {server_response.decode('utf-8')}")
finally:
cli_socket.close()
if __name__ == "__main__":
run_tcp_client()
주요 소켓 메서드 요약
socket(family, type): 지정된 주소 체계와 프로토콜 타입으로 소켓 객체를 생성합니다.bind(address): 서버 소켓을 특정 IP와 포트에 할당합니다.listen(backlog): 연결 요청을 대기할 반연결 풀(Semi-connection Pool)의 크기를 설정합니다.connect(address): 클라이언트가 서버에 연결을 요청합니다.accept(): 서버가 클라이언트의 연결을 수락하며, 통신용 새 소켓과 클라이언트 주소를 반환합니다.send(bytes)/sendall(bytes): 연결된 소켓을 통해 바이트 데이터를 전송합니다.recv(bufsize): 소켓으로부터 지정된 크기만큼의 바이트 데이터를 수신합니다.
실무 네트워크 통신 최적화 기법
- 반복 통신:
while루프를 활용하여 단일 연결 내에서 여러 번의 메시지를 주고받을 수 있도록 구현합니다. - 빈 메시지 처리: 클라이언트가 비정상적으로 종료되면 서버의
recv()가 빈 바이트(b'')를 반환합니다. 이를 감지하여 연결을 안전하게 종료해야 합니다. - 포트 점유 문제: 서버 재시작 시
Address already in use오류가 발생할 수 있으므로,SO_REUSEADDR옵션을 적용하여 포트를 즉시 재사용할 수 있게 합니다.
TCP 스티키 패킷(Sticky Packet) 현상과 해결책
발생 원인
TCP는 스트림 프로토콜이므로 데이터의 경계를 구분하지 않습니다. 송신측에서 여러 번에 나누어 보낸 작은 데이터들이 네트워크 단에서 하나의 패킷으로 합쳐지거나(뭉침), 수신측에서 한 번에 읽지 못하고 쪼개져서 읽히는 현상이 발생합니다. 수신측은 전체 데이터의 크기를 알 수 없기 때문에 recv()를 언제 멈춰야 할지 판단할 수 없습니다.
1차 해결책: 고정 길이 헤더 (struct 모듈)
데이터 본문을 전송하기 전에, 본문 데이터의 크기를 고정된 길이(예: 4바이트)의 헤더로 먼저 전송합니다. Python의 struct 모듈을 사용하면 숫자를 고정 길이의 바이트로 패킹할 수 있습니다.
| 송신측 프로세스 | 수신측 프로세스 |
|---|---|
| 1. 데이터 크기를 4바이트로 패킹하여 전송 | 1. 4바이트를 수신하여 언패킹 (크기 확인) |
| 2. 실제 데이터 본문 전송 | 2. 확인한 크기만큼만 반복하여 데이터 수신 |
# 클라이언트 (송신)
import socket
import struct
import os
def send_file_with_header():
cli_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli_socket.connect(('127.0.0.1', 9090))
file_path = 'sample_data.txt'
file_size = os.path.getsize(file_path)
# 4바이트 고정 길이 헤더 전송
header = struct.pack('i', file_size)
cli_socket.sendall(header)
# 실제 파일 데이터 전송
with open(file_path, 'rb') as f:
while chunk := f.read(1024):
cli_socket.sendall(chunk)
cli_socket.close()
# 서버 (수신)
import socket
import struct
def receive_file_with_header():
srv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv_socket.bind(('127.0.0.1', 9090))
srv_socket.listen(1)
cli_socket, _ = srv_socket.accept()
# 4바이트 헤더 수신 및 패킹 해제
header_data = cli_socket.recv(4)
file_size = struct.unpack('i', header_data)[0]
# 파일 데이터 수신
received_bytes = 0
with open('received_data.txt', 'wb') as f:
while received_bytes < file_size:
remaining = file_size - received_bytes
chunk_size = 1024 if remaining > 1024 else remaining
data = cli_socket.recv(chunk_size)
if not data:
break
f.write(data)
received_bytes += len(data)
cli_socket.close()
srv_socket.close()
2차 해결책: 동적 메타데이터 헤더 (헤더의 헤더)
struct 모듈의 'i' 포맷은 4바이트 정수이므로 최대 약 2GB까지만 표현할 수 있습니다. 대용량 파일 전송 시 크기 제한을 우회하고 파일명, 해시값 등 추가 메타데이터를 전달하려면 JSON 형식의 동적 헤더를 사용해야 합니다. 이때 JSON 문자열 자체의 길이를 4바이트 고정 헤더로 먼저 보내는 '헤더의 헤더' 방식을 채택합니다.
# 클라이언트 (대용량 파일 및 메타데이터 송신)
import socket
import struct
import json
import os
def send_large_file_metadata():
cli_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli_socket.connect(('127.0.0.1', 9090))
file_path = 'large_dataset.bin'
file_size = os.path.getsize(file_path)
metadata = {
'filename': 'large_dataset.bin',
'filesize': file_size,
'checksum': 'md5_placeholder'
}
json_bytes = json.dumps(metadata).encode('utf-8')
json_length = len(json_bytes)
# 1. 메타데이터의 길이를 4바이트로 전송 (헤더의 헤더)
cli_socket.sendall(struct.pack('i', json_length))
# 2. 메타데이터(JSON) 전송
cli_socket.sendall(json_bytes)
# 3. 실제 파일 데이터 전송
with open(file_path, 'rb') as f:
while chunk := f.read(4096):
cli_socket.sendall(chunk)
cli_socket.close()
# 서버 (대용량 파일 및 메타데이터 수신)
import socket
import struct
import json
def receive_large_file_metadata():
srv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv_socket.bind(('127.0.0.1', 9090))
srv_socket.listen(1)
cli_socket, _ = srv_socket.accept()
# 1. 메타데이터 길이 수신
meta_length = struct.unpack('i', cli_socket.recv(4))[0]
# 2. 메타데이터 수신 및 파싱
meta_bytes = cli_socket.recv(meta_length)
metadata = json.loads(meta_bytes.decode('utf-8'))
total_size = metadata['filesize']
saved_filename = metadata['filename']
# 3. 파일 데이터 수신
received_bytes = 0
with open(f"saved_{saved_filename}", 'wb') as f:
while received_bytes < total_size:
chunk = cli_socket.recv(4096)
if not chunk:
break
f.write(chunk)
received_bytes += len(chunk)
cli_socket.close()
srv_socket.close()
UDP 소켓 프로그래밍 구현
UDP는 연결 수립 과정이 없으므로 listen()이나 accept()를 사용하지 않습니다. 대신 sendto()와 recvfrom()을 사용하여 데이터그램과 주소 정보를 동시에 처리합니다.
UDP 서버 구현
import socket
def run_udp_server():
# SOCK_DGRAM을 사용하여 UDP 소켓 생성
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_socket.bind(('127.0.0.1', 9091))
print("UDP 서버가 데이터그램을 대기 중입니다...")
while True:
# 데이터와 송신자 주소를 함께 수신
msg, client_addr = udp_socket.recvfrom(1024)
print(f"[{client_addr}]로부터 수신: {msg.decode('utf-8')}")
# 송신자 주소로 응답 데이터그램 전송
ack_msg = "메시지 수신 확인".encode('utf-8')
udp_socket.sendto(ack_msg, client_addr)
UDP 클라이언트 구현
import socket
def run_udp_client():
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
target_addr = ('127.0.0.1', 9091)
# 대상 주소로 데이터그램 전송
payload = "UDP 테스트 패킷 전송".encode('utf-8')
udp_socket.sendto(payload, target_addr)
# 서버의 응답 데이터그램 수신
response, server_addr = udp_socket.recvfrom(1024)
print(f"서버 응답: {response.decode('utf-8')}")
udp_socket.close()