33강. 파일을 조각으로 나누어 보내기

0. 학습 목표
→ 32강에서 설계한 메시지 구조를 코드로 옮겨, 파일을 읽어 조각으로 나누어 보내는 전체 흐름을 완성합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
32강에서 파일 전송 흐름을 file_info → file_chunk → file_end 세 단계 메시지로 설계했습니다.
이번 강의에서는 그 설계를 실제 코드로 옮깁니다.
protocol.py에 send_file() 함수를 추가하고,
client.py의 ChatClient 클래스에 파일 전송 메서드를 연결합니다.

| 구분 | 내용 |
| 이해할 것 | 파일을 조각으로 나누어 Base64로 변환한 뒤 전송하는 흐름, 파일 ID 생성 방식 |
| 만들 것 | protocol.py에 send_file() 함수 추가, client.py의 ChatClient에 send_file() 메서드 추가 |
| 확인할 것 | 파일을 전송했을 때 클라이언트에서 "[파일 전송 완료]" 메시지가 출력되고 서버 연결이 유지되는지 |
0.2 이번 강의에서 직접 다루는 구조
send_file()은 서버와 클라이언트 양쪽에서 사용할 수 있어 두 protocol.py를 모두 수정합니다. main.py에 파일 선택 버튼을 연결하는 것은 다음 강의에서 진행합니다.
chat_server/
├── protocol.py ← ✏️ 이번 강의에서 수정 (send_file 추가)
└── server.py
chat_client/
├── protocol.py ← ✏️ 이번 강의에서 수정 (send_file 추가)
├── client.py ← ✏️ 이번 강의에서 수정 (ChatClient.send_file 메서드 추가)
├── main.py
└── downloads/
└── received_files/
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
1. 실습 준비하기
→ 이번 강의에서 새로 사용하는 모듈을 확인하고, 전송 테스트에 쓸 파일을 준비합니다.
이번 강의에서 새로 추가하는 모듈은 base64, os, time입니다. 세 모듈 모두 Python 기본 라이브러리로 별도 설치가 필요 없습니다.
| 모듈 | 용도 |
base64 |
바이너리 조각을 JSON에 담을 수 있는 문자열로 변환 |
os |
파일 이름(basename)과 파일 크기(getsize) 확인 |
time |
고유한 파일 ID를 만들기 위한 타임스탬프 생성 |
전송 테스트에 사용할 파일을 프로젝트 루트에 준비합니다. 처음에는 작은 파일(수십 KB 이하)로 시작하는 것이 좋습니다.
# 테스트용 파일 예시 (프로젝트 루트에 있으면 됩니다)
# sample.png, test.txt, document.pdf 등 아무 파일이나 사용 가능
✔ 확인 기준: python -c "import base64, os, time; print('OK')"를 실행했을 때 OK가 출력되고, 전송할 테스트 파일이 프로젝트 폴더 안에 있으면 완료.
2. protocol.py — 파일 전송 함수 추가하기
→ CHUNK_SIZE 상수와 새 import를 정의하고, file_info → file_chunk 반복 → file_end 순서로 전송하는 send_file()을 완성합니다.
2.1 import와 CHUNK_SIZE 추가하기
기존 protocol.py 맨 위에 세 줄을 추가합니다. CHUNK_SIZE는 파일을 한 번에 읽어 보낼 바이트 수입니다. 64KB(65536바이트)로 설정하면 대부분의 파일을 안정적으로 처리할 수 있습니다.
import json
import base64 # ➕ 바이너리 ↔ Base64 문자열 변환
import os # ➕ 파일 이름·크기 확인
import time # ➕ 파일 ID 고유성 확보용 타임스탬프
CHUNK_SIZE = 65536 # ➕ 파일 조각 크기 (64KB)
기존 send_message()와 recv_message()는 그대로 둡니다. 이번 강의에서는 그 아래에 send_file()만 추가합니다.
💡 강사 팁 — protocol.py를 바꾸면 서버·클라이언트 양쪽을 반드시 함께 교체할 것
이번 강의에서 protocol.py는 메시지 경계 처리 방식이 바뀝니다.
| 구분 | Part 3~6 방식 | Part 7 방식 (이번 강의) |
| 경계 처리 | \n 구분자 + buffer 누적 |
4바이트 길이 헤더 선독 |
| 수신 함수 | receive_messages(sock, buffer) |
recv_message(sock) |
한쪽만 바꾸면 한 쪽은 \n을 기다리다 영구 블로킹 상태가 되거나, 앞 4바이트를 메시지 본문으로 오해합니다. 실습 중 연결은 됐는데 메시지가 전혀 화면에 표시되지 않는다면 서버와 클라이언트 양쪽 protocol.py가 같은 버전인지 가장 먼저 확인하세요.
2.2 send_file() 함수 작성하기
파일 정보를 먼저 수집하고 file_info를 전송하는 부분부터 시작합니다.
file_id는 파일 이름만으로 만들면 두 사람이 같은 이름의 파일을 동시에 보낼 때 서버가 구분하지 못합니다.
파일 이름에 밀리초 타임스탬프를 붙여 고유성을 확보합니다.
os.path.basename()은 "/home/user/photo.png"에서 "photo.png"만 꺼냅니다.
수신 측이 이 이름으로 파일을 저장하기 때문에 경로가 아닌 파일 이름만 보내야 합니다.

def send_file(sock, file_path, sender): # ➕
filename = os.path.basename(file_path) # 경로 제외 파일 이름만 추출
size = os.path.getsize(file_path) # 파일 전체 크기 (바이트)
file_id = f"{filename}_{int(time.time() * 1000)}" # 파일명+타임스탬프 = 고유 ID
send_message(sock, { # file_info 먼저 전송
"type": "file_info",
"file_id": file_id,
"filename": filename,
"size": size,
"sender": sender
})
다음으로 파일을 열고 청크 단위로 읽어 전송하는 반복 루프를 추가합니다.
file.read(CHUNK_SIZE)가 빈 바이트(b"")를 반환하면 파일을 다 읽은 것입니다.

def send_file(sock, file_path, sender):
filename = os.path.basename(file_path)
size = os.path.getsize(file_path)
file_id = f"{filename}_{int(time.time() * 1000)}"
send_message(sock, {
"type": "file_info",
"file_id": file_id,
"filename": filename,
"size": size,
"sender": sender
})
with open(file_path, "rb") as file: # ➕ 바이너리 읽기 모드로 열기
while True:
chunk = file.read(CHUNK_SIZE) # CHUNK_SIZE씩 읽기
if not chunk: # 빈 바이트 = 파일 끝
break
encoded = base64.b64encode(chunk).decode("utf-8") # Base64 문자열로 변환
send_message(sock, { # file_chunk 전송
"type": "file_chunk",
"file_id": file_id,
"data": encoded
})
반복문 안의 흐름을 한 줄로 요약하면 다음과 같습니다.
64KB 읽기 → 빈 바이트면 종료
→ 아니면 Base64 변환 → file_chunk 전송 → 다음 조각
마지막으로 file_end를 보내 전송 완료를 알립니다. 파일 읽기 도중 오류가 발생하면 file_end가 전송되지 않아 받는 쪽이 영원히 대기 상태에 빠집니다. try/except로 이 상황을 방어합니다.
file_end는 try 블록 밖에 두었습니다. 파일을 모두 읽어 try를 정상 종료한 뒤에만 완료 신호가 전송됩니다. 오류가 나면 except로 빠져 return False로 끝납니다.

def send_file(sock, file_path, sender):
filename = os.path.basename(file_path)
size = os.path.getsize(file_path)
file_id = f"{filename}_{int(time.time() * 1000)}"
send_message(sock, {
"type": "file_info",
"file_id": file_id,
"filename": filename,
"size": size,
"sender": sender
})
try:
with open(file_path, "rb") as file:
while True:
chunk = file.read(CHUNK_SIZE)
if not chunk:
break
encoded = base64.b64encode(chunk).decode("utf-8")
send_message(sock, {
"type": "file_chunk",
"file_id": file_id,
"data": encoded
})
except Exception as error: # ➕ 파일 읽기 오류 처리
send_message(sock, { # 받는 쪽에 실패를 알린다
"type": "error",
"content": f"파일 전송 실패: {error}"
})
return False
send_message(sock, { # ➕ 전송 완료 신호
"type": "file_end",
"file_id": file_id
})
return True
✔ 확인 기준: send_file() 함수가 file_info → file_chunk(반복) → file_end 순서로 실행되고, 오류가 발생하면 error 메시지를 보내고 False를 반환하도록 구조가 잡혀 있는지 확인하세요.
3. client.py — ChatClient에 send_file 메서드 추가하기
→ ChatClient 클래스에 send_file() 메서드를 추가해, main.py가 파일 전송을 요청할 수 있도록 연결합니다.
기존 from protocol import 구문에 send_file을 추가하고, ChatClient 클래스 안에 메서드를 하나 더 넣습니다.
from protocol import send_message, recv_message, send_file # ✏️ send_file 추가
기존 send_chat(), send_whisper() 메서드와 나란히 send_file() 메서드를 추가합니다.
class ChatClient:
# ... 기존 메서드 유지 ...
def send_file(self, file_path): # ➕ 파일 전송 메서드
"""파일을 서버로 전송한다. 실제 전송은 protocol.send_file이 담당한다."""
sender = str(self.socket.getsockname()) # 자신의 소켓 주소를 발신자로 사용
success = send_file(self.socket, file_path, sender)
return success
ChatClient의 역할은 protocol.send_file()에 소켓과 발신자 정보를 넘기는 것입니다. 나중에 main.py에서 파일 선택 버튼을 눌렀을 때 이 메서드를 호출합니다.
main.py (파일 선택 버튼 클릭)
↓
ChatClient.send_file(file_path)
↓
protocol.send_file(socket, file_path, sender)
↓
file_info → file_chunk × N → file_end 전송
이번 강의에서는 main.py에 파일 선택 버튼을 아직 추가하지 않습니다. GUI 연결은 다음 강의에서 진행합니다.
✔ 확인 기준: ChatClient에 send_file(self, file_path) 메서드가 추가되고, from protocol import 구문에 send_file이 포함되어 있으면 완료.
4. 실행 결과 확인하기
→ 서버를 실행하고 간단한 테스트로 파일 전송 함수가 오류 없이 동작하는지 확인합니다.
4.1 테스트 스크립트로 전송 확인하기
이번 강의에서는 main.py에 파일 버튼을 아직 연결하지 않았으므로,
프로젝트 루트에 임시 테스트 파일을 만들어 직접 확인합니다. 테스트가 끝나면 삭제해도 됩니다.
# test_send_file.py (임시 테스트 파일 — 확인 후 삭제 가능)
import socket
import sys
sys.path.insert(0, "chat_client")
from protocol import send_file
HOST = "127.0.0.1"
PORT = 5000
test_path = sys.argv[1] if len(sys.argv) > 1 else "sample.png"
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((HOST, PORT))
success = send_file(sock, test_path, "tester")
if success:
print(f"[파일 전송 완료] {test_path}")
else:
print(f"[파일 전송 실패] {test_path}")
서버를 먼저 실행한 뒤 테스트 스크립트를 실행합니다.
# 터미널 1 — 서버 실행
python chat_server/server.py
# 터미널 2 — 파일 전송 테스트
python test_send_file.py sample.png
터미널 2에서 다음 메시지가 출력되면 성공입니다.
[파일 전송 완료] sample.png
현재 서버에는 file_info, file_chunk, file_end를 처리하는 코드가 없습니다. 서버는 메시지를 수신하지만 처리하지 못해 연결이 끊기거나 오류가 발생할 수 있습니다. 이는 정상적인 상태입니다. 34강에서 서버와 클라이언트의 수신 처리 코드를 추가합니다.
✔ 확인 기준: 테스트 스크립트를 실행했을 때 FileNotFoundError나 ImportError 없이 "[파일 전송 완료]" 메시지가 출력되면 완료. 서버 쪽에서 알 수 없는 타입 오류가 발생하더라도 클라이언트가 정상 종료되면 성공입니다.
4.2 흔한 오류와 해결 방법
| 오류 상황 | 원인 및 해결 방법 |
FileNotFoundError |
파일 경로가 잘못됐습니다. sys.argv[1]로 넘긴 경로가 실제로 존재하는지 확인하세요 |
ImportError: cannot import name 'send_file' |
protocol.py에 send_file 함수가 없거나 이름이 다릅니다. 함수 정의를 확인하세요 |
ModuleNotFoundError: protocol |
sys.path.insert(0, "chat_client")가 없거나 경로가 다릅니다. 테스트 파일 위치를 확인하세요 |
ConnectionRefusedError |
서버가 실행되지 않았습니다. 터미널 1에서 서버를 먼저 실행하세요 |
NameError: name 'time' is not defined |
protocol.py에 import time이 없습니다. 2.1 단계의 import를 다시 확인하세요 |
5. 최종 코드 정리하기
→ 이번 강의에서 추가한 코드를 한곳에 정리합니다.
5.1 protocol.py — 추가·수정된 전체 코드
import json
import base64
import os
import time
CHUNK_SIZE = 65536
def send_message(sock, message):
data = json.dumps(message).encode("utf-8")
length = len(data)
sock.sendall(length.to_bytes(4, "big") + data)
def recv_message(sock):
raw_length = sock.recv(4)
if not raw_length:
return None
length = int.from_bytes(raw_length, "big")
data = b""
while len(data) < length:
packet = sock.recv(length - len(data))
if not packet:
return None
data += packet
return json.loads(data.decode("utf-8"))
def send_file(sock, file_path, sender):
filename = os.path.basename(file_path)
size = os.path.getsize(file_path)
file_id = f"{filename}_{int(time.time() * 1000)}"
send_message(sock, {
"type": "file_info",
"file_id": file_id,
"filename": filename,
"size": size,
"sender": sender
})
try:
with open(file_path, "rb") as file:
while True:
chunk = file.read(CHUNK_SIZE)
if not chunk:
break
encoded = base64.b64encode(chunk).decode("utf-8")
send_message(sock, {
"type": "file_chunk",
"file_id": file_id,
"data": encoded
})
except Exception as error:
send_message(sock, {
"type": "error",
"content": f"파일 전송 실패: {error}"
})
return False
send_message(sock, {
"type": "file_end",
"file_id": file_id
})
return True
5.2 client.py — 추가된 부분 발췌
from protocol import send_message, recv_message, send_file # send_file 추가
class ChatClient:
# ... 기존 메서드 유지 ...
def send_file(self, file_path):
sender = str(self.socket.getsockname())
return send_file(self.socket, file_path, sender)
5.3 최종 확인 표
| 추가된 코드 | 역할 |
import base64 / os / time |
Base64 변환, 파일 정보 확인, 고유 ID 생성에 필요한 모듈 |
CHUNK_SIZE = 65536 |
파일을 나눌 조각 크기 (64KB) |
f"{filename}_{int(time.time() * 1000)}" |
같은 이름의 파일이 동시에 전송될 때 충돌하지 않도록 고유 ID 생성 |
os.path.basename() / getsize() |
파일 이름(경로 제외)과 파일 크기 확인 |
base64.b64encode(chunk).decode("utf-8") |
바이너리 조각을 JSON에 담을 수 있는 문자열로 변환 |
try / except / return False |
파일 읽기 오류 시 받는 쪽이 무한 대기에 빠지지 않도록 방어 |
ChatClient.send_file(self, file_path) |
main.py에서 파일 전송을 요청하는 진입점 |
→ 다음 강의 (34강): 이번에 완성한 전송 코드의 반대쪽을 구현합니다. 서버와 클라이언트에서 file_info를 받으면 저장 경로를 결정하고, file_chunk가 올 때마다 base64.b64decode()로 복원해 파일에 이어 쓰고, file_end를 받으면 파일을 닫아 저장을 완료합니다.