iBetter Books
수정

실습 — 파일 전송 프로그램

지금까지 배운 것을 한데 모을 시간입니다. 이 장에서는 파일 하나를 통째로 네트워크 너머로 보내는 프로그램을 만듭니다. 짧은 인사말이 아니라 진짜 파일을 다루므로, 앞 장에서 배운 스트림의 본질과 부분 수신 문제를 정면으로 마주하게 됩니다. 그리고 그 문제를 풀기 위해 우리만의 첫 프로토콜을 직접 설계합니다.

문제는 경계다

파일을 보낸다고 해 봅시다. 보내는 쪽은 파일 내용을 sendall로 흘려보내면 됩니다. 그런데 받는 쪽이 곤란합니다. 앞 장에서 배웠듯 TCP는 스트림이라 메시지 경계가 없습니다. 받는 쪽은 파일이 어디서 끝나는지 알 수가 없습니다. 언제까지 recv를 반복해야 할까요.

연결을 닫는 것으로 끝을 알릴 수도 있지만, 그러면 파일 하나 보내고 매번 연결을 새로 맺어야 합니다. 더 나은 방법은 보내기 전에 미리 알려 주는 것입니다. "이름이 몇 글자인지, 파일이 몇 바이트인지"를 앞에 붙여 보내면, 받는 쪽은 정확히 그만큼만 모으면 됩니다. 이것이 앞 장에서 예고한 길이 접두어 방식입니다.

프로토콜 설계

우리가 주고받을 데이터의 형식을 다음과 같이 정합니다. 이것이 바로 프로토콜입니다.

[ 이름 길이 2바이트 ][ 파일 이름 ][ 파일 크기 4바이트 ][ 파일 내용 ]

맨 앞 2바이트에 파일 이름의 길이를 담습니다. 그다음 그 길이만큼 파일 이름이 옵니다. 이어서 4바이트에 파일 전체 크기를 담고, 마지막으로 그 크기만큼 파일 내용이 흐릅니다.

여기서 길이를 담는 숫자는 여러 바이트로 이루어지므로, PART 01에서 배운 바이트 오더 문제가 등장합니다. 그래서 네트워크 순서, 곧 빅 엔디안으로 변환해 보냅니다. 이름 길이는 2바이트라 한 글자 이름이라도 최대 65535자까지 표현할 수 있고, 파일 크기는 4바이트라 약 4기가바이트까지 다룰 수 있습니다. 연습용으로는 넉넉합니다.

Python 파일 서버

받는 쪽부터 만듭니다. 앞 장에서 만든 recv_exactly가 주인공으로 활약합니다.

# 새 파일: file_server.pyimport socketimport structdef recv_exactly(sock, n):    buf = b""    while len(buf) < n:        chunk = sock.recv(n - len(buf))        if not chunk:            raise ConnectionError("연결이 일찍 끊겼습니다")        buf += chunk    return bufserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9102))server.listen(1)print("파일 서버 대기 중")conn, addr = server.accept()with conn:    name_len = struct.unpack(">H", recv_exactly(conn, 2))[0]    filename = recv_exactly(conn, name_len).decode()    file_size = struct.unpack(">I", recv_exactly(conn, 4))[0]    data = recv_exactly(conn, file_size)    out = "received_" + filename    with open(out, "wb") as f:        f.write(data)    print(f"수신 완료: {filename} ({file_size}바이트)")server.close()

서버의 핵심은 우리가 정한 프로토콜을 그대로 거꾸로 읽어 들이는 부분입니다.

먼저 recv_exactly(conn, 2)로 정확히 2바이트를 받아 이름 길이를 알아냅니다. struct.unpack(">H", ...)이 그 2바이트를 숫자로 풀어 줍니다. >는 빅 엔디안, 곧 네트워크 순서를 뜻하고 H는 2바이트 정수를 뜻합니다. 바이트 오더 변환을 struct가 알아서 처리해 주는 셈입니다.

이름 길이를 알았으니 recv_exactly(conn, name_len)로 그만큼 받아 파일 이름을 얻습니다. 같은 방식으로 4바이트를 받아 파일 크기를 알아내는데, 이번엔 ">I"를 씁니다. I는 4바이트 정수입니다. 마지막으로 그 크기만큼 recv_exactly로 파일 내용을 통째로 모읍니다.

길이를 먼저 알고 그만큼 정확히 받는다는 약속 덕분에, 스트림이 아무리 잘게 쪼개져 도착해도 데이터를 한 조각도 놓치거나 더 받지 않습니다. 프로토콜이 경계를 만들어 준 것입니다.

Python 파일 클라이언트

보내는 쪽은 같은 프로토콜을 순서대로 쌓아 보냅니다.

# 새 파일: file_client.pyimport socketimport structimport syspath = sys.argv[1]with open(path, "rb") as f:    data = f.read()filename = path.split("/")[-1].encode()with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:    client.connect(("127.0.0.1", 9102))    client.sendall(struct.pack(">H", len(filename)))    client.sendall(filename)    client.sendall(struct.pack(">I", len(data)))    client.sendall(data)    print(f"전송 완료: {path} ({len(data)}바이트)")

파일을 통째로 읽어 들인 뒤, 프로토콜 순서대로 보냅니다. struct.pack(">H", len(filename))은 이름 길이를 2바이트 빅 엔디안으로 포장하고, struct.pack(">I", len(data))는 파일 크기를 4바이트로 포장합니다. 받을 때 unpack으로 풀었던 것을 보낼 때는 pack으로 싸는 것입니다. 보내는 순서가 받는 순서와 정확히 짝을 이루는 점을 눈여겨보세요. 프로토콜이란 결국 이 양쪽의 약속입니다.

실행해 보기

시험용 파일을 하나 만들고 전송해 봅니다.

# 파일: 터미널 1 (서버)python3 file_server.py
# 파일: 터미널 2 (클라이언트)python3 file_client.py sample.txt
전송 완료: sample.txt (65바이트)

서버 쪽에는 다음과 같이 찍힙니다.

파일 서버 대기 중
수신 완료: sample.txt (65바이트)

전송이 끝나면 서버를 실행한 폴더에 received_sample.txt가 생깁니다. 원본과 비교해 한 바이트도 다르지 않은지 확인해 봅니다.

# 파일: 터미널diff sample.txt received_sample.txt

아무것도 출력되지 않으면 두 파일이 완전히 같다는 뜻입니다. 우리가 설계한 프로토콜이 제대로 동작한 것입니다.

같은 일을 C로

이제 C로 옮깁니다. 앞 장에서 이야기한 대로, C에는 sendallrecv_exactly도 없으니 직접 만들어야 합니다. 먼저 서버입니다.

// 새 파일: file_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

static int recv_all(int fd, void *buf, size_t n) {
    size_t got = 0;
    char *p = buf;
    while (got < n) {
        ssize_t r = recv(fd, p + got, n - got, 0);
        if (r <= 0) return -1;
        got += (size_t)r;
    }
    return 0;
}

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int yes = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9103);
    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(server_fd, 1);
    printf("C 파일 서버 대기 중\n");

    int fd = accept(server_fd, NULL, NULL);
    if (fd < 0) { perror("accept"); exit(1); }

    uint16_t name_len_net;
    if (recv_all(fd, &name_len_net, 2) < 0) { fprintf(stderr, "이름 길이 수신 실패\n"); exit(1); }
    uint16_t name_len = ntohs(name_len_net);

    char filename[256];
    if (name_len >= sizeof(filename)) { fprintf(stderr, "이름이 너무 깁니다\n"); exit(1); }
    if (recv_all(fd, filename, name_len) < 0) { exit(1); }
    filename[name_len] = '\0';

    uint32_t size_net;
    if (recv_all(fd, &size_net, 4) < 0) { exit(1); }
    uint32_t size = ntohl(size_net);

    char *data = malloc(size);
    if (recv_all(fd, data, size) < 0) { fprintf(stderr, "본문 수신 실패\n"); exit(1); }

    char out[300];
    snprintf(out, sizeof(out), "creceived_%s", filename);
    FILE *f = fopen(out, "wb");
    fwrite(data, 1, size, f);
    fclose(f);
    free(data);

    printf("수신 완료: %s (%u바이트)\n", filename, size);
    close(fd);
    close(server_fd);
    return 0;
}

맨 위의 recv_all 함수가 Python의 recv_exactly와 똑같은 일을 합니다. 원하는 바이트 수를 다 채울 때까지 recv를 반복하고, 아직 부족한 만큼인 n - got만큼만 요청합니다. 상대가 일찍 끊으면 recv가 0 이하를 돌려주므로 실패로 처리합니다.

main의 흐름은 Python 서버와 판박이입니다. 2바이트를 받아 ntohs로 이름 길이를 풀고, 그만큼 이름을 받고, 4바이트를 받아 ntohl로 크기를 풀고, 그만큼 내용을 받습니다. Python에서 struct.unpack(">H", ...)이 하던 바이트 오더 변환을 C에서는 ntohsntohl이 직접 합니다. PART 01에서 배운 그 함수들이 실전에서 제 역할을 하는 모습입니다.

크기를 미리 알기에 그만큼 malloc으로 메모리를 잡아 받은 점, 받은 이름 끝에 '\0'을 넣어 문자열로 만든 점도 C다운 손길입니다.

C 파일 클라이언트

보내는 쪽도 send_all을 직접 만들어 씁니다.

// 새 파일: file_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

static int send_all(int fd, const void *buf, size_t n) {
    size_t sent = 0;
    const char *p = buf;
    while (sent < n) {
        ssize_t s = send(fd, p + sent, n - sent, 0);
        if (s <= 0) return -1;
        sent += (size_t)s;
    }
    return 0;
}

int main(int argc, char **argv) {
    if (argc < 2) { fprintf(stderr, "사용법: %s <파일경로>\n", argv[0]); exit(1); }
    const char *path = argv[1];

    FILE *f = fopen(path, "rb");
    if (!f) { perror("fopen"); exit(1); }
    fseek(f, 0, SEEK_END);
    long size = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *data = malloc(size);
    fread(data, 1, size, f);
    fclose(f);

    const char *base = strrchr(path, '/');
    base = base ? base + 1 : path;
    uint16_t name_len = (uint16_t)strlen(base);

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9103);
    if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); exit(1); }

    uint16_t name_len_net = htons(name_len);
    uint32_t size_net = htonl((uint32_t)size);
    send_all(sock, &name_len_net, 2);
    send_all(sock, base, name_len);
    send_all(sock, &size_net, 4);
    send_all(sock, data, size);
    printf("전송 완료: %s (%ld바이트)\n", path, size);

    free(data);
    close(sock);
    return 0;
}

send_allrecv_all의 거울입니다. 다 보낼 때까지 send를 반복하며, 아직 못 보낸 n - sent만큼만 다시 보냅니다. PART 02 내내 강조한 부분 송수신 처리를 C에서 손으로 구현한 것입니다. 보내는 순서는 Python 클라이언트와 똑같고, htonshtonl로 길이를 네트워크 순서로 변환하는 것만 다릅니다.

컴파일하고 실행합니다.

# 파일: 터미널cc -Wall -o file_server file_server.ccc -Wall -o file_client file_client.c
# 파일: 터미널 2 (클라이언트)./file_client sample.txt
전송 완료: sample.txt (65바이트)

C 서버는 creceived_sample.txt라는 이름으로 파일을 저장합니다. 역시 원본과 한 바이트도 다르지 않습니다.

언어를 넘나드는 대화

여기서 멋진 일이 가능해집니다. Python 클라이언트와 C 서버, 또는 C 클라이언트와 Python 서버가 서로 대화할 수 있습니다. 두 프로그램이 같은 언어로 짜였는지는 전혀 중요하지 않습니다. 오직 같은 프로토콜을 지키는지만 중요합니다.

같은 포트를 쓰도록 맞춘 뒤 Python 서버에 C 클라이언트를 붙여 보면, 파일이 멀쩡히 전송됩니다. 반대로 C 서버에 Python 클라이언트를 붙여도 마찬가지입니다. 바이트 오더를 빅 엔디안으로 통일하고, 필드의 순서와 크기를 똑같이 약속해 두었기 때문입니다.

이것이 프로토콜의 힘입니다. 프로토콜은 언어와 운영체제를 가리지 않는 공통의 약속입니다. 우리가 매일 쓰는 웹과 메신저가 서로 다른 회사, 서로 다른 언어로 만들어졌어도 통신할 수 있는 이유가 바로 이것입니다. 작은 파일 전송 프로그램 하나로 그 원리를 직접 확인한 셈입니다.

이로써 TCP를 제대로 다루는 법을 모두 익혔습니다. 다음 PART에서는 TCP와는 성격이 정반대인 UDP의 세계로 넘어가, 빠르지만 아무것도 보장하지 않는 통신을 만나 봅니다.