34강 구현 중심 ⏱ 약 30분

 

0. 학습 목표

→ 서버가 file_info · file_chunk · file_end 메시지를 처리해 received_files/ 폴더에 파일을 저장하는 수신 쪽을 완성합니다.

더보기

0.1 이번 글에서 다룰 내용

이번 글은 구현 중심 강의입니다.

 

33강에서 파일을 보내는 쪽(send_file())을 완성했습니다.

서버에는 아직 파일 메시지를 처리하는 코드가 없었기 때문에 파일 조각들이 수신되고 사라졌습니다.

 

이번 강의에서는 받는 쪽을 완성합니다. 세 가지 핸들러 함수를 추가해 메시지 흐름을 저장 흐름으로 연결합니다.

file_info  수신 → 저장 파일 준비 (파일 열기)
file_chunk 수신 → Base64 디코딩 → 파일에 이어 쓰기    ← 반복
file_end   수신 → 파일 닫기 → 저장 완료
구분 내용
이해할 것 파일 조각이 도착할 때마다 Base64를 디코딩해 파일에 이어 써야 한다는 것, receiving_files 딕셔너리로 상태를 관리하는 이유
만들 것 server.py에 파일 수신 상태 딕셔너리와 세 가지 핸들러 함수 추가
확인할 것 전송한 파일이 chat_server/received_files/ 폴더에 같은 이름으로 저장되는지

 

0.2 이번 강의에서 직접 다루는 구조

received_files/ 폴더는 server.py를 처음 실행할 때 os.makedirs()가 자동으로 만듭니다. 이번 강의에서 수정하는 파일은 server.py 하나입니다.

chat_server/
├── protocol.py
├── server.py            ← ✏️ 이번 강의에서 수정
└── received_files/      ← ➕ 서버 실행 시 자동 생성

chat_client/
├── protocol.py
├── client.py
├── main.py
└── downloads/
    └── received_files/

(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)

 

이번 강의에서 server.py에 추가할 구성입니다.

server.py (추가 사항)
├── import base64, os 추가
├── RECEIVE_DIR            ← 저장 폴더 경로 상수
├── receiving_files        ← file_id별 수신 상태 관리 딕셔너리
├── handle_file_info()     ← 저장 파일 열기, 수신 상태 등록
├── handle_file_chunk()    ← Base64 디코딩, 파일에 이어 쓰기
└── handle_file_end()      ← 파일 닫기, 수신 상태 제거

 

1. 실습 준비하기

→ 저장 폴더 경로와 수신 상태 딕셔너리를 정의하고, receiving_files가 왜 필요한지 이해합니다.

더보기

server.py 상단에 두 가지를 추가합니다.

RECEIVE_DIR는 파일을 저장할 폴더 경로이고,

receiving_files는 진행 중인 파일 전송 상태를 기억하는 딕셔너리입니다.

import base64   # ➕ Base64 디코딩
import os       # ➕ 파일·폴더 처리

RECEIVE_DIR = os.path.join(                                  # ➕ 저장 폴더 경로
    os.path.dirname(os.path.abspath(__file__)), "received_files"
)
receiving_files = {}                                         # ➕ 수신 상태 관리 딕셔너리

os.makedirs(RECEIVE_DIR, exist_ok=True)                      # ➕ 폴더가 없으면 자동 생성

os.path.dirname(os.path.abspath(__file__))server.py 파일이 있는 폴더를 가리킵니다.

상대 경로 대신 이 방식을 쓰면 어디서 서버를 실행하든 항상 chat_server/received_files/에 파일이 저장됩니다.

 

receiving_files가 필요한 이유가 있습니다.

파일 전송은 file_infofile_chunkfile_end 세 종류의 메시지가 따로따로 도착합니다.

 

file_info에서 파일을 열고, 이후 도착하는 file_chunk마다 같은 파일 객체에 계속 써야 합니다.

이 파일 객체를 함수 호출 사이에 기억해 두는 수단이 receiving_files입니다.

# receiving_files의 구조 예시
{
    "photo.png_1718000000000": {    # file_id (파일명+타임스탬프)
        "filename": "photo.png",
        "path": "/path/to/received_files/photo.png",
        "file": <파일 객체>,            # open()으로 열어 둔 상태
        "received_size": 0,
        "size": 204800
    }
}

✔ 확인 기준: RECEIVE_DIRreceiving_files = {}server.py 상단에 추가되고, import base64import os가 있으면 완료. 서버 실행 후 chat_server/received_files/ 폴더가 생성되는지 확인하세요.

 

2. server.py — handle_file_info() 만들기

→ file_info 메시지를 받으면 저장 파일을 열고 수신 상태를 등록합니다.

더보기

file_info 메시지가 도착하면 세 가지 일을 합니다. 필

수 정보를 확인하고, 저장 경로를 결정하고, 파일을 "wb" 모드로 열어 receiving_files에 등록합니다.

 

os.path.basename(filename)으로 파일 이름을 정제하는 것은 보안을 위해서입니다.

만약 보내는 쪽이 "../../etc/passwd"처럼 경로가 섞인 값을 보내도, basename()이 파일 이름 부분만 남겨 의도치 않은 위치에 파일이 저장되는 것을 방지합니다.

def handle_file_info(message):                          # ➕
    file_id = message.get("file_id")
    filename = message.get("filename")
    size = message.get("size", 0)

    if not file_id or not filename:                     # 필수 정보 누락 시 처리
        print("파일 정보가 올바르지 않습니다.")
        return

    safe_filename = os.path.basename(filename)          # 경로 제거 — 파일 이름만 안전하게 추출
    save_path = os.path.join(RECEIVE_DIR, safe_filename)

    file = open(save_path, "wb")                        # 바이너리 쓰기 모드로 파일 열기

    receiving_files[file_id] = {                        # file_id 기준으로 수신 상태 등록
        "filename": safe_filename,
        "path": save_path,
        "file": file,
        "received_size": 0,
        "size": size
    }

    print(f"파일 수신 시작: {safe_filename}, 크기: {size} bytes")

파일을 "wb" 모드로 여는 시점이 중요합니다. 이 파일 객체를 receiving_files에 저장해 두면, 이후 file_chunk가 올 때마다 같은 파일에 이어 쓸 수 있습니다. 파일을 매번 열고 닫지 않아도 됩니다.

✔ 확인 기준: handle_file_info()가 호출된 뒤 receiving_files에 해당 file_id가 등록되고, received_files/ 폴더 안에 빈 파일이 생성되면 완료.

 

3. server.py — handle_file_chunk()와 handle_file_end() 만들기

→ 파일 조각을 복원해 파일에 쓰고, 전송 완료 시 파일을 닫는 함수를 작성한 뒤 메시지 분기에 연결합니다.

더보기

3.1 handle_file_chunk() 만들기

file_chunk가 올 때마다 data 필드의 Base64 문자열을 바이너리로 복원해 파일에 씁니다. JSON 파싱 결과로 꺼낸 data는 이미 str이므로 base64.b64decode()에 그대로 전달할 수 있습니다.

def handle_file_chunk(message):                          # ➕
    file_id = message.get("file_id")
    data = message.get("data")

    info = receiving_files.get(file_id)                  # file_id로 수신 중인 파일 찾기

    if info is None:
        print(f"알 수 없는 파일 조각입니다: {file_id}")
        return

    if data is None:
        print(f"파일 조각 데이터가 없습니다: {file_id}")
        return

    chunk = base64.b64decode(data)                       # Base64 문자열 → 바이너리 복원

    info["file"].write(chunk)                            # 복원한 바이너리를 파일에 이어 쓰기
    info["received_size"] += len(chunk)                  # 받은 크기 누적

    print(
        f"파일 조각 저장: {info['filename']} "
        f"({info['received_size']}/{info['size']} bytes)"
    )

조각이 도착할 때마다 info["file"].write(chunk)가 호출됩니다. 파일 객체가 이미 열려 있으므로 자동으로 파일 끝에 이어 붙여집니다.

 

3.2 handle_file_end() 만들기

file_end를 받으면 열어 두었던 파일을 닫고 수신 상태에서 제거합니다. close()를 빠뜨리면 OS 버퍼에 남아 있는 데이터가 디스크에 기록되지 않아, 저장된 파일이 손상되거나 열리지 않을 수 있습니다.

def handle_file_end(message):                            # ➕
    file_id = message.get("file_id")
    info = receiving_files.get(file_id)

    if info is None:
        print(f"완료할 파일을 찾을 수 없습니다: {file_id}")
        return

    info["file"].close()                                 # 파일 닫기 — 반드시 호출해야 저장 완료

    print(f"파일 저장 완료: {info['path']}")

    del receiving_files[file_id]                         # 수신 상태에서 제거

 

3.3 메시지 분기에 연결하기

handle_client()의 메시지 처리 분기 안에 세 줄을 추가합니다. 기존에 파일 메시지를 처리하지 못하던 자리입니다.

if message_type == "file_info":   # ➕
    handle_file_info(message)
    continue

if message_type == "file_chunk":  # ➕
    handle_file_chunk(message)
    continue

if message_type == "file_end":    # ➕
    handle_file_end(message)
    continue

세 분기가 추가되면 서버의 파일 수신 흐름이 완성됩니다.

file_info  → handle_file_info()  → 파일 열기, receiving_files 등록
file_chunk → handle_file_chunk() → Base64 디코딩, 파일에 쓰기    ← 반복
file_end   → handle_file_end()   → 파일 닫기, receiving_files 제거

✔ 확인 기준: 세 함수가 모두 작성되고 handle_client()의 분기에 연결되었으면 완료. handle_file_end()에서 file.close()가 반드시 호출되는지 확인하세요.

 

4. 실행 결과 확인하기

→ 서버를 실행하고 33강에서 만든 테스트 스크립트로 파일을 전송해, received_files/ 폴더에 저장되는지 확인합니다.

더보기

4.1 정상 실행 확인

서버를 먼저 실행한 뒤 33강에서 만든 test_send_file.py로 파일을 보냅니다.

# 터미널 1 — 서버 실행
python chat_server/server.py
# 터미널 2 — 테스트 스크립트로 파일 전송
python test_send_file.py sample.png

서버 터미널에서 다음 순서로 출력되면 성공입니다. 파일 크기와 CHUNK_SIZE에 따라 조각 수는 달라집니다.

파일 수신 시작: sample.png, 크기: 204800 bytes
파일 조각 저장: sample.png (65536/204800 bytes)
파일 조각 저장: sample.png (131072/204800 bytes)
파일 조각 저장: sample.png (196608/204800 bytes)
파일 조각 저장: sample.png (204800/204800 bytes)
파일 저장 완료: .../chat_server/received_files/sample.png

저장이 끝나면 chat_server/received_files/ 폴더에 파일이 생깁니다.

chat_server/received_files/
└── sample.png

저장된 파일을 직접 열어 원본과 같은지 확인합니다. 이미지 파일은 그림이 정상적으로 보여야 하고, 텍스트 파일은 글자가 그대로여야 합니다.

✔ 확인 기준: 서버 터미널에 "파일 저장 완료" 메시지가 출력되고, chat_server/received_files/에 원본과 동일한 파일이 생성되면 완료. 저장된 파일을 열어 내용이 손상되지 않았는지 반드시 확인하세요.

 

4.2 흔한 오류와 해결 방법

오류 상황 원인 및 해결 방법
NameError: base64 import base64server.py 맨 위에 없습니다. 추가하세요
NameError: os import os가 없습니다. 추가하세요
FileNotFoundError os.makedirs(RECEIVE_DIR, exist_ok=True) 호출이 누락됐습니다. RECEIVE_DIR 정의 바로 아래에 있는지 확인하세요
저장된 파일이 열리지 않거나 내용이 깨짐 "wb" 모드가 아니거나 handle_file_end()에서 file.close()가 호출되지 않았습니다
파일 크기가 원본보다 작음 file_chunk 일부가 저장되지 않았거나 file_end 전에 연결이 끊겼습니다. 서버 로그에서 모든 조각이 수신됐는지 확인하세요
"완료할 파일을 찾을 수 없습니다" 출력 file_info가 오지 않은 채 file_end가 도착하거나, file_id가 맞지 않습니다. 33강의 send_file()에서 file_id가 일관되게 생성되는지 확인하세요
같은 이름의 파일을 보내면 덮어씀 저장 경로가 동일하기 때문입니다. 학습 단계에서는 허용하며, 이후 파일명 중복 처리를 추가할 수 있습니다

 

5. 최종 코드 정리하기

→ server.py에 추가된 전체 코드를 한곳에 정리합니다.

더보기

5.1 server.py — 추가된 코드 전체

아래 코드에서 상단의 import와 상수는 server.py 맨 위에, 세 함수는 기존 함수들과 나란히, 메시지 분기 세 줄은 handle_client() 안에 추가합니다.

import base64
import os

RECEIVE_DIR = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "received_files"
)
receiving_files = {}

os.makedirs(RECEIVE_DIR, exist_ok=True)


def handle_file_info(message):
    file_id = message.get("file_id")
    filename = message.get("filename")
    size = message.get("size", 0)

    if not file_id or not filename:
        print("파일 정보가 올바르지 않습니다.")
        return

    safe_filename = os.path.basename(filename)
    save_path = os.path.join(RECEIVE_DIR, safe_filename)

    file = open(save_path, "wb")

    receiving_files[file_id] = {
        "filename": safe_filename,
        "path": save_path,
        "file": file,
        "received_size": 0,
        "size": size
    }

    print(f"파일 수신 시작: {safe_filename}, 크기: {size} bytes")


def handle_file_chunk(message):
    file_id = message.get("file_id")
    data = message.get("data")

    info = receiving_files.get(file_id)

    if info is None:
        print(f"알 수 없는 파일 조각입니다: {file_id}")
        return

    if data is None:
        print(f"파일 조각 데이터가 없습니다: {file_id}")
        return

    chunk = base64.b64decode(data)

    info["file"].write(chunk)
    info["received_size"] += len(chunk)

    print(
        f"파일 조각 저장: {info['filename']} "
        f"({info['received_size']}/{info['size']} bytes)"
    )


def handle_file_end(message):
    file_id = message.get("file_id")
    info = receiving_files.get(file_id)

    if info is None:
        print(f"완료할 파일을 찾을 수 없습니다: {file_id}")
        return

    info["file"].close()

    print(f"파일 저장 완료: {info['path']}")

    del receiving_files[file_id]


# handle_client() 메시지 분기에 추가
if message_type == "file_info":
    handle_file_info(message)
    continue

if message_type == "file_chunk":
    handle_file_chunk(message)
    continue

if message_type == "file_end":
    handle_file_end(message)
    continue

 

5.2 최종 확인 표

추가된 코드 역할
import base64, import os Base64 복원과 파일·폴더 처리에 필요한 모듈
os.path.dirname(os.path.abspath(__file__)) 실행 위치에 상관없이 server.py 기준으로 received_files/ 경로를 결정
receiving_files = {} file_id별 수신 상태(파일 객체, 크기 등)를 함수 호출 사이에 기억
handle_file_info() 저장 파일을 "wb"로 열고 receiving_files에 등록
handle_file_chunk() Base64 문자열(str)을 b64decode()로 직접 복원해 파일에 이어 쓰기
handle_file_end() 파일 close(), receiving_files에서 제거
os.path.basename(filename) 경로가 섞인 파일명에서 파일 이름만 안전하게 추출

→ 다음 강의 (35강): 파일 전송·수신이 동작하게 됐습니다. 35강에서는 GUI 쪽을 연결합니다. 파일 선택 다이얼로그 버튼을 main.py에 추가하고, ChatClient.send_file()을 호출해 사용자가 UI에서 직접 파일을 보낼 수 있도록 합니다.

 

파일 수신 구현, handle_file_info, handle_file_chunk, handle_file_end, receiving_files, base64.b64decode, os.makedirs, received_files, 파일 조각 저장, RECEIVE_DIR