iBetter Books
수정

첫 통신 — 1대 1 메시지 주고받기

지금까지 모은 조각들을 하나로 합칠 시간입니다. 소켓의 일생을 배웠고, 주소와 포트를 알았고, 바이트 오더의 함정도 짚었습니다. 이제 두 프로그램을 실제로 연결해, 한쪽이 보낸 한 마디를 다른 쪽이 받아 그대로 돌려주는 가장 작은 통신 프로그램을 만들어 봅니다. 같은 프로그램을 Python으로 한 번, C로 한 번 만들면서 두 언어가 같은 일을 어떻게 다르게 표현하는지 비교하겠습니다.

무엇을 만들 것인가

만들 프로그램은 에코 서버입니다. 에코, 즉 메아리라는 이름처럼 클라이언트가 보낸 말을 그대로 되돌려주는 서버입니다. 기능은 더없이 단순하지만, 소켓의 일생 전체를 담고 있습니다. 생성하고, 묶고, 듣고, 수락하고, 받고, 보내고, 닫는 일곱 단계가 모두 들어갑니다. 이 흐름을 한 번 손에 익히면 앞으로 만날 어떤 네트워크 프로그램도 같은 뼈대 위에서 이해할 수 있습니다.

이번 장에서는 의도적으로 가장 단순하게 만듭니다. 한 클라이언트와 한 번만 주고받고 끝냅니다. 데이터가 중간에 잘릴 가능성이나 여러 클라이언트를 동시에 받는 문제는 잠시 접어 둡니다. 그 주제들은 다음 PART에서 본격적으로 다룹니다. 지금은 통신이 성립하는 그 짜릿한 순간에만 집중하겠습니다.

Python으로 만드는 에코 서버

먼저 서버입니다. 코드를 보고 한 줄씩 뜯어보겠습니다.

# 새 파일: echo_server.pyimport socketHOST = "127.0.0.1"PORT = 9000server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.bind((HOST, PORT))server.listen()print(f"에코 서버가 {HOST}:{PORT}에서 기다립니다")conn, addr = server.accept()print(f"{addr} 가 연결했습니다")data = conn.recv(1024)print(f"받은 데이터: {data.decode()}")conn.sendall(data)conn.close()server.close()

한 줄씩 따라가 보겠습니다.

socket.socket(socket.AF_INET, socket.SOCK_STREAM)은 소켓을 만드는 첫 단계입니다. 첫 인자 AF_INET은 IPv4 주소 체계를 쓰겠다는 뜻이고, 둘째 인자 SOCK_STREAM은 TCP 스트림 소켓을 만들겠다는 뜻입니다. 앞 장에서 다룬 소켓의 일생 중 전화기를 마련하는 단계에 해당합니다.

server.bind((HOST, PORT))는 이 소켓을 주소와 포트에 묶습니다. Python에서는 주소와 포트를 괄호로 묶은 튜플 하나로 간단히 넘깁니다. 잠시 뒤에 보겠지만 C에서는 이 한 줄이 훨씬 수다스러워집니다.

server.listen()은 운영체제에게 연결 요청을 받을 준비가 됐다고 알립니다. 이 순간부터 들어오는 연결이 대기열에 쌓입니다.

server.accept()는 대기열에서 연결 하나를 꺼내 수락합니다. 돌려주는 값이 두 개인데, conn은 이 클라이언트와 대화할 전용 소켓이고 addr은 클라이언트의 주소입니다. 중요한 점은 이 줄에서 프로그램이 멈춘다는 것입니다. 클라이언트가 연결해 올 때까지 여기서 가만히 기다립니다. 이런 동작을 블로킹이라 부르며, 앞으로 자주 만나게 됩니다.

conn.recv(1024)는 클라이언트가 보낸 데이터를 최대 1024바이트까지 받습니다. 받은 것은 바이트열이므로, 사람이 읽을 수 있도록 decode()로 문자열로 바꿔 출력합니다.

conn.sendall(data)는 받은 데이터를 그대로 돌려보냅니다. 메아리가 울리는 순간입니다. send가 아니라 sendall을 쓴 데에는 이유가 있는데, 그 이야기는 다음 PART에서 자세히 하겠습니다.

마지막 두 줄 conn.close()server.close()는 통화를 끝내고 전화기를 정리합니다. 대화용 소켓과 창구 소켓을 차례로 닫습니다.

Python 에코 클라이언트

이번엔 거는 쪽입니다.

# 새 파일: echo_client.pyimport socketHOST = "127.0.0.1"PORT = 9000client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client.connect((HOST, PORT))client.sendall("안녕, 소켓!".encode())data = client.recv(1024)print(f"서버가 돌려준 말: {data.decode()}")client.close()

클라이언트는 더 짧습니다. socket()으로 소켓을 만드는 것은 같지만, bindlisten이 없습니다. 거는 쪽은 번호를 미리 정해 둘 필요가 없기 때문입니다.

client.connect((HOST, PORT))가 핵심입니다. 서버의 주소와 포트로 연결을 요청합니다. 이 한 줄 안에서 TCP의 3-way 핸드셰이크가 일어나 두 소켓 사이에 길이 뚫립니다.

연결이 맺어지면 client.sendall(...)로 인사말을 보냅니다. 문자열은 네트워크로 보내기 전에 encode()로 바이트열로 바꿔야 합니다. 네트워크는 글자가 아니라 바이트만 알기 때문입니다. 그다음 client.recv(1024)로 서버가 돌려준 메아리를 받아 출력하고, close()로 끝냅니다.

실행해 보기

이제 둘을 연결해 봅니다. 터미널 창이 두 개 필요합니다. 한쪽에서 서버를 먼저 띄웁니다.

# 파일: 터미널 1 (서버)python3 echo_server.py
에코 서버가 127.0.0.1:9000에서 기다립니다

서버가 accept()에서 멈춰 클라이언트를 기다리고 있습니다. 다른 터미널에서 클라이언트를 실행합니다.

# 파일: 터미널 2 (클라이언트)python3 echo_client.py
서버가 돌려준 말: 안녕, 소켓!

클라이언트 쪽에 서버가 돌려준 메아리가 찍혔습니다. 서버 터미널을 보면 다음과 같은 기록이 남아 있습니다.

에코 서버가 127.0.0.1:9000에서 기다립니다
('127.0.0.1', 54890) 가 연결했습니다
받은 데이터: 안녕, 소켓!

두 프로그램이 처음으로 대화를 나눴습니다. 포트 번호 54890은 운영체제가 클라이언트에게 임시로 붙여 준 번호라, 실행할 때마다 달라집니다.

같은 프로그램을 C로

이제 똑같은 에코 서버를 C로 만들어 봅니다. 하는 일은 한 글자도 다르지 않습니다. 다만 Python이 뒤에서 대신 해 주던 일들을 우리가 직접 해야 합니다. 어떤 일들이 드러나는지 보면서 따라오세요.

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

#define PORT 9000

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); exit(1); }

    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(PORT);

    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
    if (listen(server_fd, 1) < 0) { perror("listen"); exit(1); }
    printf("에코 서버가 127.0.0.1:%d에서 기다립니다\n", PORT);

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
    if (client_fd < 0) { perror("accept"); exit(1); }
    printf("%s 가 연결했습니다\n", inet_ntoa(client_addr.sin_addr));

    char buffer[1024];
    ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        printf("받은 데이터: %s\n", buffer);
        send(client_fd, buffer, n, 0);
    }
    close(client_fd);
    close(server_fd);
    return 0;
}

Python 코드의 열 몇 줄이 C에서는 두 배 가까이 늘었습니다. 늘어난 부분이 바로 Python이 감춰 주던 일들입니다.

socket(AF_INET, SOCK_STREAM, 0)은 Python과 거의 같습니다. 다만 돌려주는 값이 정수 하나입니다. 이 정수가 앞 장에서 이야기한 파일 디스크립터, 곧 소켓의 번호표입니다. 그리고 그 값이 0보다 작으면 실패한 것이므로, 바로 다음 줄에서 확인합니다. C에서는 이렇게 거의 모든 함수의 반환값을 일일이 점검해야 합니다. Python이 예외로 알아서 알려 주던 일을 직접 챙기는 셈입니다.

가운데의 struct sockaddr_in 부분이 Python에서는 튜플 하나였던 그 주소입니다. C에서는 구조체를 손으로 채웁니다. sin_family에 IPv4를 뜻하는 AF_INET을 넣고, sin_addr에는 문자열 주소를 inet_addr로 변환해 넣고, sin_port에는 포트 번호를 넣습니다. 이때 포트에 htons를 씌운 것을 눈여겨보세요. 앞 장에서 예고한 바이트 오더 변환이 바로 여기서 등장합니다. 이 변환을 빼먹으면 엉뚱한 포트에 묶입니다.

bind, listen, accept는 Python과 같은 순서로 흘러갑니다. 다만 bindaccept에 주소 구조체의 포인터와 크기를 일일이 넘겨야 합니다. Python이 튜플 하나로 끝내던 일을 C에서는 포인터와 길이로 풀어서 전달합니다.

recv 부분도 비슷합니다. 받은 바이트를 담을 버퍼를 직접 마련하고, 받은 길이만큼만 유효하다는 점도 직접 챙겨야 합니다. 받은 바이트 끝에 '\0'을 넣어 문자열로 만드는 것은 C에서 글자를 다룰 때의 약속입니다. 버퍼 크기를 1024가 아니라 1023까지만 받도록 한 것도 이 마지막 한 칸을 비워 두기 위해서입니다.

C 클라이언트

클라이언트도 마찬가지로 옮겨 봅니다.

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

#define PORT 9000

int main(void) {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) { perror("socket"); exit(1); }

    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(PORT);

    if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("connect"); exit(1);
    }

    const char *msg = "안녕, 소켓!";
    send(sock, msg, strlen(msg), 0);

    char buffer[1024];
    ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
        buffer[n] = '\0';
        printf("서버가 돌려준 말: %s\n", buffer);
    }
    close(sock);
    return 0;
}

흐름은 Python 클라이언트와 똑같습니다. 소켓을 만들고, 주소 구조체를 채우고, connect로 연결하고, send로 보내고, recv로 받고, close로 끝냅니다. 역시 주소 구조체를 손으로 채우고 htons로 포트를 변환하는 점이 다릅니다.

C로 실행하기

C는 컴파일을 거쳐야 실행됩니다. 두 파일을 각각 컴파일합니다.

# 파일: 터미널cc -Wall -o echo_server echo_server.ccc -Wall -o echo_client echo_client.c

-Wall은 컴파일러에게 의심스러운 부분을 모두 경고해 달라는 옵션입니다. 소켓 코드에서는 작은 실수가 큰 버그로 이어지기 쉬우니 켜 두는 습관을 들이면 좋습니다.

Python 때와 똑같이 터미널 두 개로 실행합니다.

# 파일: 터미널 1 (서버)./echo_server
# 파일: 터미널 2 (클라이언트)./echo_client
서버가 돌려준 말: 안녕, 소켓!

C로도 같은 메아리가 돌아왔습니다. 같은 일을 두 언어로 해낸 것입니다.

Python과 C, 무엇이 달랐나

같은 프로그램을 두 번 만들어 보니 차이가 또렷합니다.

Python은 주소를 튜플 하나로 넘기고, 바이트 오더 변환을 알아서 처리하고, 실패하면 예외로 알려 주고, 소켓을 닫는 것도 비교적 너그럽습니다. 덕분에 우리는 통신의 흐름 자체에만 집중할 수 있었습니다.

C는 주소 구조체를 손으로 채우고, htons로 바이트 오더를 직접 변환하고, 모든 함수의 반환값을 점검하고, 버퍼의 크기와 끝을 직접 관리합니다. 번거롭지만, 그 번거로움이 곧 운영체제가 실제로 하는 일입니다. Python이 편한 이유는 이 일들을 대신 해 주기 때문이라는 것을 C를 통해 비로소 알게 됩니다.

앞으로도 이 리듬은 계속됩니다. 개념은 Python에서 잡고, 그 아래의 진실은 C에서 확인합니다.

이번 장에서는 한 번 주고받고 끝나는 가장 단순한 형태를 만들었습니다. 그런데 여기에는 우리가 일부러 덮어 둔 문제가 숨어 있습니다. 데이터가 정말 한 번에, 보낸 그대로 도착할까요. 클라이언트가 둘 이상이면 어떻게 될까요. 다음 PART에서 TCP 소켓을 본격적으로 파고들며 이 질문들에 답해 가겠습니다.