iBetter Books
수정

TCP 서버와 클라이언트의 뼈대

PART 01의 에코 서버는 한 번 받고 한 번 돌려주면 곧장 끝나 버렸습니다. 진짜 서버라면 클라이언트가 떠날 때까지 계속 대화를 이어가야 합니다. 이 장에서는 그 뼈대를 제대로 세웁니다. 클라이언트가 보내는 동안 계속 받아 돌려주도록 고치고, 연습할 때마다 우리를 괴롭히는 한 가지 문제를 해결합니다.

계속 받아 돌려주는 서버

먼저 서버를 고칩니다. PART 01에서 만든 echo_server.py를 다음과 같이 확장합니다.

# 수정: echo_server.pyimport socketHOST = "127.0.0.1"PORT = 9000server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind((HOST, PORT))server.listen(5)print(f"에코 서버가 {HOST}:{PORT}에서 기다립니다")conn, addr = server.accept()print(f"{addr} 가 연결했습니다")with conn:    while True:        data = conn.recv(1024)        if not data:            break        conn.sendall(data)print("연결이 종료되었습니다")server.close()

바뀐 곳을 따라가 보겠습니다.

가장 눈에 띄는 변화는 가운데의 while True 반복문입니다. 이제 서버는 recv로 데이터를 받아 곧바로 sendall로 돌려주는 일을 계속 반복합니다. 그렇다면 이 반복은 언제 끝날까요. 클라이언트가 연결을 닫으면 recv가 빈 바이트열을 돌려줍니다. 데이터가 없는 것이 아니라, 상대가 떠났다는 신호입니다. 그래서 if not data: break로 그 순간을 감지해 반복을 빠져나옵니다. 이 빈 바이트열의 의미는 소켓 프로그래밍에서 두고두고 쓰이니 꼭 기억해 두세요.

with conn: 블록은 PART 01에서 잠깐 본 그 방식입니다. 블록을 벗어나면 대화용 소켓이 자동으로 닫힙니다. 닫는 일을 깜빡할 걱정이 없어집니다.

server.listen(5)에서 괄호 안의 숫자 5는 백로그라 부릅니다. 서버가 미처 accept하지 못한 연결 요청을 운영체제가 몇 개까지 대기열에 쌓아 둘지를 정합니다. 손님이 몰릴 때 잠시 줄을 세워 두는 대기 의자의 수라고 생각하면 됩니다. 작은 연습 프로그램에서는 큰 의미가 없지만, 명시해 두는 편이 좋습니다.

Address already in use라는 골칫거리

코드 맨 위에 새로 추가된 줄이 하나 있습니다. server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)입니다. 이 한 줄이 왜 필요한지 이해하려면, 먼저 그것이 없을 때 겪는 고통을 알아야 합니다.

서버를 실행했다가 종료하고, 곧바로 다시 실행해 보면 이런 에러를 만나곤 합니다.

OSError: [Errno 48] Address already in use

에러 번호 48은 macOS에서 본 값이고, 리눅스에서는 98로 나옵니다. 번호는 운영체제마다 다르지만 메시지는 같습니다. 분명히 방금 서버를 껐는데 포트가 이미 사용 중이라니 황당합니다. 범인은 다음 장에서 자세히 다룰 TIME_WAIT이라는 상태입니다. 서버를 닫아도 운영체제는 그 포트를 잠시 동안, 보통 수십 초간 붙들어 둡니다. 혹시 늦게 도착하는 데이터가 있을까 봐 안전을 위해 기다리는 것입니다. 그동안에는 같은 포트에 다시 묶을 수 없어 이 에러가 납니다.

SO_REUSEADDR 옵션은 운영체제에게 "이 주소가 잠깐 붙들려 있어도 다시 묶게 해 달라"고 요청합니다. 덕분에 서버를 껐다 켜는 일을 막힘없이 반복할 수 있습니다. 개발 중에는 사실상 필수에 가까운 옵션이라, 앞으로 만들 모든 서버에 이 줄을 넣겠습니다.

setsockopt는 소켓의 동작을 세밀하게 조정하는 함수입니다. 첫 인자 SOL_SOCKET은 소켓 일반 수준의 옵션이라는 뜻이고, 둘째 인자가 켜려는 옵션, 셋째 인자 1은 그 옵션을 켜겠다는 뜻입니다.

클라이언트도 손보기

클라이언트도 여러 줄을 주고받도록 고칩니다.

# 수정: echo_client.pyimport socketHOST = "127.0.0.1"PORT = 9000with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:    client.connect((HOST, PORT))    for message in ["첫째 줄", "둘째 줄", "셋째 줄"]:        client.sendall(message.encode())        echoed = client.recv(1024)        print(f"받음: {echoed.decode()}")

클라이언트는 세 줄을 차례로 보내고, 보낼 때마다 메아리를 받아 출력합니다. 반복이 끝나면 with 블록을 벗어나면서 소켓이 닫히고, 그 순간 서버의 recv가 빈 바이트열을 받아 반복을 끝냅니다. 두 프로그램이 손발을 맞춰 깔끔하게 마무리되는 셈입니다.

실행해 보기

서버를 띄우고 클라이언트를 실행합니다.

# 파일: 터미널 2 (클라이언트)python3 echo_client.py
받음: 첫째 줄
받음: 둘째 줄
받음: 셋째 줄

서버 쪽에는 다음 기록이 남습니다.

에코 서버가 127.0.0.1:9000에서 기다립니다
('127.0.0.1', 53691) 가 연결했습니다
연결이 종료되었습니다

세 줄을 차례로 주고받고, 클라이언트가 떠나자 서버가 연결 종료를 감지했습니다.

C에서의 SO_REUSEADDR

C에서도 같은 옵션을 켤 수 있습니다. Python의 setsockopt 한 줄이 C에서는 다음과 같습니다.

// 수정: echo_server.c (옵션 설정 부분)
int yes = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

하는 일은 똑같습니다. 다만 옵션 값을 직접 변수에 담아 그 포인터와 크기를 넘기는 점이 C답습니다. 이 줄은 socket()으로 소켓을 만든 직후, bind()를 호출하기 전에 넣습니다. 다음 장의 파일 전송 실습에서 완성된 C 서버에 이 옵션이 들어간 모습을 보게 됩니다.

뼈대를 단단히 세웠습니다. 그런데 이 코드에는 아직 위험한 가정이 하나 숨어 있습니다. recv(1024)가 클라이언트가 보낸 데이터를 언제나 통째로, 한 번에 받아 준다고 믿고 있는 점입니다. 다음 장에서 이 믿음이 왜 위험한지 파헤칩니다.