17강. 패킷 경계와 메시지 구분 처리

0. 학습 목표
→ 이번 글에서 무엇을 이해하고, 무엇을 만들고, 무엇을 확인할지 먼저 정리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
16강에서 TCP 바이트 흐름과 패킷 경계 문제를 이해했습니다. 메시지가 붙거나 나뉘어 도착할 수 있고, recv() 한 번이 항상 메시지 하나와 일치하지 않는다는 것을 확인했습니다. 이번 강의에서는 그 문제를 실제 코드로 해결합니다. 해결 방법은 다음 다섯 단계입니다.
① 메시지를 JSON 문자열로 만든다
② 메시지 끝에 구분자 \n을 붙이고 전송한다.
③ 받은 데이터는 buffer에 계속 모은다
④ buffer 안에 \n이 있으면 메시지 하나를 잘라낸다
⑤ 잘라낸 JSON 문자열을 Python 딕셔너리로 바꾼다
16강 과제2에서 받은 문자열을 split("\n")으로 한 번에 나눠 본 것이 이 아이디어의 출발점입니다. 다만 그 과제는 "모든 메시지가 한 번의 recv()에 다 들어온" 경우만 다뤘습니다.
이번 강의에서는 메시지가 여러 recv()에 걸쳐 오는 경우까지 안정적으로 처리합니다.
이 규칙을 서버와 클라이언트가 공통으로 사용할 protocol.py 파일에 담습니다.
서버와 클라이언트가 서로 다른 방식으로 메시지를 나누면 통신이 깨지기 때문에, 송수신 함수를 한 파일에 모아 양쪽이 같은 규칙을 쓰도록 합니다.

| 구분 | 내용 |
| 이해할 것 | 받은 데이터를 바로 해석하지 않고 buffer에 모은 뒤 구분자로 나누어야 한다는 것 |
| 만들 것 | protocol.py(서버·클라이언트 양쪽), 구분자 기반 server.py, client.py |
| 확인할 것 | 메시지를 여러 번 보내도 JSON 메시지가 안정적으로 분리되는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 protocol.py를 새로 만들고, 기존 두 파일을 수정합니다. 1강 프로젝트 구조에서 봤듯이 서버와 클라이언트는 원래 다른 컴퓨터에서 실행되므로, protocol.py는 양쪽 폴더에 같은 내용으로 들어갑니다.
chat_server/
├── protocol.py ← 이번 강의에서 생성 (송수신 공통 규칙)
└── server.py ← 이번 강의에서 수정 (protocol.py 함수 사용)
chat_client/
├── protocol.py ← chat_server/protocol.py와 동일한 내용 (복사)
└── client.py ← 이번 강의에서 수정 (protocol.py 함수 사용)
protocol.py가 담당하는 두 함수입니다.
protocol.py
├── send_message(sock, message)
│ └── 딕셔너리 → JSON 문자열 → \n 추가 → sendall()
└── receive_messages(sock, buffer)
└── recv() → buffer 누적 → \n 기준 분리 → json.loads()
1. 실습 준비하기
→ 이번 강의에서 사용할 메시지 형식과 sendall()의 역할을 확인합니다.
1.1 이번 강의에서 사용할 메시지 형식
이번 강의에서 메시지는 다음처럼 딕셔너리로 표현합니다.
{"type": "chat", "content": "안녕하세요"}
이 딕셔너리를 네트워크로 보낼 때와 받을 때의 변환 흐름입니다.

보낼 때:
딕셔너리 → json.dumps() → JSON 문자열 → + "\n" → encode("utf-8") → sendall()
받을 때:
recv() → decode("utf-8") → buffer에 추가 → "\n" 기준으로 분리 → json.loads() → 딕셔너리
16강에서 경계 처리 방법으로 텍스트에는 구분자(\n), 파일·이미지 같은 바이너리에는 길이 헤더 방식(recv_exact())이 적합하다고 정리했습니다. 이번 강의는 텍스트 채팅이므로 구분자 방식만 구현합니다. recv_exact()는 나중에 파일 전송 강의에서 만들 예정이니, 지금은 "텍스트는 구분자로 충분하다"만 기억하면 됩니다.
💡 강사 팁 — 실행 위치를 반드시 확인할 것server.py가 protocol.py를 import하려면 두 파일이 같은 폴더에 있어야 하고, 그 폴더 안에서 실행해야 합니다. 프로젝트 루트에서 경로를 포함해 실행하면 Python이 import 기준 폴더를 다르게 잡아 오류가 납니다.
# ✅ 올바른 실행 방법 — chat_server/ 폴더 안에서 실행
cd chat_server
python server.py
# ❌ 잘못된 실행 방법 — 프로젝트 루트에서 경로 포함 실행
python chat_server/server.py # ModuleNotFoundError: No module named 'protocol'
클라이언트도 마찬가지입니다. chat_client/ 폴더 안에서 python client.py로 실행합니다. 이 오류는 18.5강 체크포인트에서도 다시 점검하지만, 지금 실습 시작 전에 미리 알아두면 막히는 상황 없이 진행할 수 있습니다.
1.2 sendall()을 사용하는 이유

이전 강의에서는 send()를 사용했습니다. 이번 강의부터 sendall()을 사용합니다.
# 이전 방식
client_socket.send(message.encode("utf-8"))
# 이번 방식
sock.sendall(data.encode(ENCODING))
16강에서 본 송신 버퍼를 떠올리면 이해가 쉽습니다. send()는 데이터를 커널 송신 버퍼에 복사하는데, 버퍼에 자리가 부족하면 일부만 복사하고 끝날 수 있습니다. 그러면 메시지 뒷부분이 잘려 나가 JSON이 깨집니다. sendall()은 보내려는 데이터를 전부 복사할 때까지 반복 시도하므로, 정해진 JSON 문자열을 안전하게 보낼 수 있습니다. 초급 단계에서는 "메시지가 잘려 나가지 않도록 보장해 주는 함수"로 이해하면 충분합니다.
✔ 확인 기준: 메시지 송수신 규칙을 protocol.py에 모을 준비가 되었고, sendall()이 데이터를 끝까지 보내 준다는 점을 16강 송신 버퍼와 연결해 설명할 수 있으면 완료. protocol.py를 서버와 클라이언트 양쪽에 둬야 한다는 것을 기억하는지 확인하세요.
2. protocol.py - 파일 만들기
→ 서버와 클라이언트가 공통으로 사용할 protocol.py 파일을 만들고 상수를 정의합니다.
먼저 chat_server/ 폴더 안에 protocol.py 파일을 만듭니다. 클라이언트 폴더로의 복사는 함수를 완성한 뒤 3번 끝에서 합니다.
touch chat_server/protocol.py
protocol.py를 열고 공통 상수를 먼저 작성합니다.
import json
ENCODING = "utf-8"
DELIMITER = "\n" # 🆕 16강에서 개념으로 본 구분자를 상수로 정의
| 상수 | 의미 |
ENCODING |
문자열을 바이트로 변환할 때 사용하는 인코딩 방식 |
DELIMITER |
메시지 끝을 표시하는 구분자 — \n 한 글자 |
서버와 클라이언트가 이 상수를 protocol.py에서 가져다 쓰기 때문에, 구분자를 바꾸고 싶다면 이 파일만 수정하면 됩니다(단, 양쪽 폴더의 파일을 함께 갱신해야 합니다).
✔ 확인 기준: chat_server/protocol.py 파일이 에디터에서 열리고 import json과 두 상수가 작성되면 완료. 이 파일은 잠시 뒤 chat_client/로도 복사한다는 점을 기억하세요.
3. protocol.py - send_message()와 receive_messages() 작성하기
→ 메시지를 보내는 함수와 버퍼 기반으로 받는 함수를 단계별로 추가하고, 완성본을 클라이언트 폴더로 복사합니다.
3.1 send_message() 추가하기
상수 아래에 send_message() 함수를 추가합니다.
import json
ENCODING = "utf-8"
DELIMITER = "\n"
def send_message(sock, message): # ➕ 송신을 함수로 분리
json_text = json.dumps(message, ensure_ascii=False) # 딕셔너리 → JSON 문자열
data = json_text + DELIMITER # ➕ 끝에 구분자 \n 추가
sock.sendall(data.encode(ENCODING)) # ✏️ send() → sendall()로 변경

json.dumps(message, ensure_ascii=False)에서 ensure_ascii=False의 역할을 짚어 둡니다. 기본값인 True로 두면 한글이 \uc548\ub155\ud558\uc138\uc694 같은 유니코드 이스케이프 형태로 변환됩니다. False로 설정하면 한글이 그대로 JSON 문자열에 유지됩니다.
# ensure_ascii=True (기본값) — 한글이 이스케이프됨
json.dumps({"content": "안녕하세요"})
# '{"content": "\\uc548\\ub155\\ud558\\uc138\\uc694"}'
# ensure_ascii=False — 한글이 그대로 유지됨
json.dumps({"content": "안녕하세요"}, ensure_ascii=False)
# '{"content": "안녕하세요"}'
ensure_ascii=False를 빠뜨려도 기능 자체는 동작합니다. 이스케이프된 형태로 보내도 json.loads()로 받으면 다시 한글로 복원되기 때문입니다. 다만 서버 터미널에서 메시지 내용이 \uc548\ub155처럼 나오면 디버깅할 때 읽기 어렵습니다. 개발 중에 메시지를 눈으로 확인하기 위해 넣어 두는 것이 좋습니다.
3.2 receive_messages() 추가하기
16강 과제2에서는 받은 문자열을 split("\n")으로 한 번에 나눴습니다. 그건 메시지가 한 번의 recv()에 전부 들어온 경우라 가능했습니다. 하지만 16강에서 봤듯이 메시지 하나가 잘려서 여러 recv()에 걸쳐 올 수 있습니다.
recv() 1회: '{"type":"chat","content":"안' ← \n 없음 → 아직 미완성
recv() 2회: '녕"}\n' ← 이제 \n 도착 → 비로소 완성
이때 받은 문자열을 곧바로 split("\n")으로 전부 나누면, 아직 \n이 안 온 마지막 미완성 조각까지 메시지로 처리하려다 깨집니다. 그래서 17강에서는 세 가지를 추가합니다. (1) 받은 조각을 buffer에 계속 모으고, (2) \n이 있을 때만 앞에서부터 하나씩 잘라내고(split(DELIMITER, 1)), (3) 아직 \n이 없는 나머지는 buffer에 남겨 둡니다. 이 buffer 잔류가 16강 과제2에는 없던 부분입니다.
send_message() 아래에 receive_messages() 함수를 추가합니다. 이 함수가 buffer 누적과 메시지 분리의 핵심입니다.
import json
ENCODING = "utf-8"
DELIMITER = "\n"
def send_message(sock, message):
json_text = json.dumps(message, ensure_ascii=False)
data = json_text + DELIMITER
sock.sendall(data.encode(ENCODING))
def receive_messages(sock, buffer): # ➕ buffer를 넘겨받는 수신 함수
data = sock.recv(1024)
if not data:
return buffer, [], False # recv()가 빈 바이트 → 연결 끊김 신호
buffer += data.decode(ENCODING) # ➕ 받은 조각을 buffer에 누적
messages = []
while DELIMITER in buffer: # ➕ \n이 있는 동안 반복해서 꺼낸다
raw_message, buffer = buffer.split(DELIMITER, 1) # ➕ \n 하나만 기준으로 자르고 나머지는 buffer에 남김
if raw_message.strip():
message = json.loads(raw_message) # JSON 문자열 → 딕셔너리
messages.append(message)
return buffer, messages, True # (남은 buffer, 완성된 메시지 목록, 연결 유지)
if not data 부분을 짚어 둡니다. 16강에서 수신 버퍼에 바이트가 쌓인다고 했는데, 상대가 연결을 닫으면 recv()는 빈 바이트(b'')를 돌려줍니다. 이는 "더 들어올 데이터가 없다"는 연결 종료 신호이므로, 세 번째 반환값을 False로 돌려 호출한 쪽이 반복을 멈추게 합니다.
buffer.split(DELIMITER, 1)에서 두 번째 인자 1이 중요합니다. split("\n")처럼 인자 없이 쓰면 모든 \n을 한 번에 잘라버려 미완성 조각까지 분리됩니다. split(DELIMITER, 1)은 가장 앞의 \n 하나만 기준으로 자르고 나머지는 buffer로 돌려주므로, while 반복과 함께 쓰면 완성된 메시지만 하나씩 안전하게 꺼낼 수 있습니다.
buffer = '{"type":"chat","content":"A"}\n{"type":"chat","content":"B"}\n'
1회 while:
raw_message = '{"type":"chat","content":"A"}'
buffer = '{"type":"chat","content":"B"}\n'
2회 while:
raw_message = '{"type":"chat","content":"B"}'
buffer = ''
함수가 반환하는 세 값의 의미입니다.
| 반환값 | 의미 |
buffer |
아직 \n이 오지 않아 처리되지 않은 남은 데이터 |
messages |
이번 recv()에서 완성된 메시지 딕셔너리 목록 |
True / False |
True면 연결 유지, False면 연결 끊김 |
한 가지 한계도 알아 둡니다. 이 방식은 content 안에 구분자와 같은 \n(줄바꿈)이 들어가면 메시지가 중간에서 잘립니다. 16강 4.2에서 짚은 구분자 방식의 약점입니다. 지금은 input()이 한 줄만 받으므로 content에 \n이 들어갈 일이 없어 안전합니다. 나중에 여러 줄 입력이나 파일을 다루면 이 한계가 드러나고, 그때 길이 헤더 방식으로 넘어갑니다.
3.3 완성한 protocol.py를 클라이언트 폴더로 복사하기
1강에서 봤듯이 서버 프로젝트와 클라이언트 프로젝트는 원래 서로 다른 컴퓨터(폴더)에서 실행됩니다. 그래서 client.py가 from protocol import ...로 함수를 가져오려면 chat_client/ 폴더 안에도 같은 protocol.py가 있어야 합니다. 완성한 파일을 복사합니다.
cp chat_server/protocol.py chat_client/protocol.py
윈도우 명령 프롬프트에서는 copy chat_server\protocol.py chat_client\protocol.py를 사용합니다. 앞으로 protocol.py를 수정하면 양쪽 폴더를 함께 갱신해야 한다는 점을 기억하세요. 한쪽만 바꾸면 서버와 클라이언트의 규칙이 어긋나 통신이 깨집니다.
✔ 확인 기준: receive_messages()가 받은 데이터를 바로 json.loads()에 넣지 않고 buffer에 누적한 뒤 \n이 있을 때만 잘라낸다고 설명할 수 있으면 완료. split(DELIMITER, 1)의 두 번째 인자 1과 while 반복이 있는지, chat_server/protocol.py와 chat_client/protocol.py가 모두 존재하는지 확인하세요.
4. server.py - protocol.py 함수 적용하기
→ server.py에서 protocol.py 함수를 사용하도록 import와 broadcast(), handle_client()를 수정합니다.
4.1 import와 broadcast() 수정하기
server.py 상단에 protocol.py 함수 import를 추가하고, broadcast() 안의 send() 호출을 send_message()로 교체합니다.
import socket
import threading
from protocol import receive_messages, send_message # ➕ protocol.py에서 가져오기
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def broadcast(message, sender_socket):
for client in clients[:]: # 순회 중 목록이 변경될 수 있어 복사본 사용
if client != sender_socket:
try:
send_message(client, message) # ✏️ 기존 send() → send_message()로 변경
except Exception: # ✏️ bare except → except Exception
if client in clients:
clients.remove(client)
clients[:]는 clients 리스트의 복사본을 순회합니다. broadcast() 실행 중에 except 블록에서 clients.remove(client)가 호출되면 원본 리스트를 순회하면서 동시에 수정하는 상황이 생깁니다. 복사본을 순회하면 이 문제를 피할 수 있습니다.
4.2 handle_client()와 메인 루프 수정하기
handle_client() 안에서 기존 recv() + decode() 방식을 receive_messages() 기반으로 교체합니다. buffer = ""는 반드시 함수 안에 있어야 합니다. 함수 밖 전역 변수로 두면 모든 클라이언트가 같은 버퍼를 공유해버립니다. 스레드마다 별도 버퍼가 필요하기 때문에 함수 안에서 선언합니다.
import socket
import threading
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def broadcast(message, sender_socket):
for client in clients[:]:
if client != sender_socket:
try:
send_message(client, message)
except Exception:
if client in clients:
clients.remove(client)
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
buffer = "" # ➕ 이 클라이언트 전용 버퍼 — 반드시 함수 안에 선언
with client_socket:
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer) # ➕ recv()+decode() → 수신 함수에 위임
if not connected:
print(f"클라이언트 연결 끊김: {client_address}")
break
for message in messages: # ➕ 한 번에 여러 메시지가 올 수 있어 반복 처리
print(f"{client_address} 메시지: {message}")
if message.get("type") == "exit":
print(f"클라이언트 종료 요청: {client_address}")
return
if message.get("type") == "chat":
broadcast_message = {
"type": "chat",
"sender": str(client_address),
"content": message.get("content", "")
}
broadcast(broadcast_message, client_socket)
except Exception as error:
print(f"클라이언트 처리 오류: {client_address}, {error}")
break
if client_socket in clients:
clients.remove(client_socket)
print(f"접속자 목록에서 제거: {client_address}")
print_clients()
print(f"클라이언트 처리 종료: {client_address}")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.bind((HOST, PORT))
server_socket.listen()
print("메시지 구분 처리 서버를 시작합니다.")
print("클라이언트 접속 대기 중...")
while True:
client_socket, client_address = server_socket.accept()
print(f"클라이언트 접속: {client_address}")
clients.append(client_socket)
print(f"접속자 목록에 추가: {client_address}")
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.start()</code></pre>
while 안을 try/except Exception으로 감싼 이유를 짚어 둡니다. 한 클라이언트의 메시지 처리에서 오류가 나도 그 스레드만 반복을 멈추고 정리되면 됩니다. 이렇게 해야 한 명에게서 생긴 오류가 서버 전체나 다른 접속자에게 번지지 않습니다. except Exception은 KeyboardInterrupt(Ctrl+C 종료) 같은 신호까지 삼키지 않으므로, except:보다 안전합니다.
이전 강의와 비교해 달라진 핵심 부분입니다.
| 위치 | 이전 방식 | 이번 방식 |
handle_client() 상단 |
없음 | buffer = "" 초기화 |
| 메시지 수신 | recv() + decode() + if not data |
receive_messages() 한 줄 |
| 브로드캐스팅 | client.send(msg.encode()) |
send_message(client, msg_dict) |
✔ 확인 기준: python chat_server/server.py 실행 후 "메시지 구분 처리 서버를 시작합니다." 출력과 함께 대기 상태가 되면 완료. from protocol import receive_messages, send_message가 있는지, protocol.py가 server.py와 같은 폴더에 있는지 확인하세요.
5. client.py - protocol.py 함수 적용하기
→ client.py의 수신 함수와 송신 코드를 protocol.py 기반으로 수정합니다.
5.1 receive_loop() 수정하기
client.py 상단에 import를 추가하고, 기존 수신 함수를 receive_loop()로 이름을 바꿔 protocol.py의 receive_messages()를 사용하도록 수정합니다. 이 import가 동작하려면 3.3에서 chat_client/에 복사해 둔 protocol.py가 반드시 있어야 합니다.
import socket
import threading
from protocol import receive_messages, send_message # ➕ protocol.py에서 가져오기
HOST = "127.0.0.1"
PORT = 5000
def receive_loop(client_socket):
buffer = "" # ➕ 수신 전용 버퍼
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer) # ➕ 수신 함수에 위임
if not connected:
print("서버와의 연결이 끊어졌습니다.")
break
for message in messages:
if message.get("type") == "chat":
sender = message.get("sender", "unknown")
content = message.get("content", "")
print(f"\n{sender}: {content}")
else:
print(f"\n수신 메시지: {message}")
except Exception: # ✏️ bare except → except Exception
break
수신 루프를 try/except Exception으로 감싼 이유는 서버가 갑자기 끊기거나 데이터 처리 중 오류가 났을 때 클라이언트가 깔끔하게 반복을 멈추기 위해서입니다. 기존의 except: 대신 except Exception으로 적으면 Ctrl+C 같은 종료 신호까지 잡아먹지 않아 더 안전합니다.
5.2 메인 흐름 수정하기 — send_message() 적용
input() 반복문에서 기존 send() 호출을 send_message()로 교체합니다. exit도 이제 문자열 비교가 아닌 딕셔너리 형태로 전송합니다.
import socket
import threading
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
def receive_loop(client_socket):
buffer = ""
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer)
if not connected:
print("서버와의 연결이 끊어졌습니다.")
break
for message in messages:
if message.get("type") == "chat":
sender = message.get("sender", "unknown")
content = message.get("content", "")
print(f"\n{sender}: {content}")
else:
print(f"\n수신 메시지: {message}")
except Exception:
break
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((HOST, PORT))
print("서버에 연결되었습니다.")
print("전체 채팅을 시작합니다. 종료하려면 exit을 입력하세요.")
receive_thread = threading.Thread(
target=receive_loop,
args=(client_socket,)
)
receive_thread.daemon = True
receive_thread.start()
while True:
text = input("")
if text == "exit":
send_message(client_socket, {"type": "exit"}) # ✏️ send() → send_message(), exit을 딕셔너리로 전송
print("서버와의 연결을 종료합니다.")
break
send_message(client_socket, { # ✏️ text.encode() 직접 전송 → 딕셔너리로 send_message()
"type": "chat",
"content": text
})
print("클라이언트 소켓을 닫았습니다.")
이전 강의와 비교해 달라진 핵심 부분입니다.
| 위치 | 이전 방식 | 이번 방식 |
| 수신 함수 | recv() + decode() 직접 처리 |
receive_messages() 위임 |
| 일반 메시지 송신 | client_socket.send(text.encode()) |
send_message(sock, {"type": "chat", "content": text}) |
| exit 처리 | 문자열 "exit" 직접 비교 후 send() |
{"type": "exit"} 딕셔너리로 전송 |
✔ 확인 기준: 서버가 실행 중일 때 python chat_client/client.py를 실행하면 "서버에 연결되었습니다." 출력과 함께 입력 대기 상태가 되면 완료. chat_client/ 폴더 안에 protocol.py 복사본과 from protocol import ...가 모두 있는지 확인하세요.
6. 실행 결과 확인하기
→ 서버와 클라이언트 2개를 실행해 JSON 메시지가 안정적으로 분리되는지 확인합니다.
6.1 정상 실행 확인
터미널 1에서 서버를 실행합니다.
python chat_server/server.py
메시지 구분 처리 서버를 시작합니다.
클라이언트 접속 대기 중...
터미널 2와 3에서 각각 클라이언트를 실행합니다.
python chat_client/client.py
서버에 연결되었습니다.
전체 채팅을 시작합니다. 종료하려면 exit을 입력하세요.
클라이언트 A(터미널 2)에서 메시지를 입력합니다.
안녕하세요 A입니다
클라이언트 B(터미널 3)에 다음처럼 출력되면 성공입니다.
('127.0.0.1', 52344): 안녕하세요 A입니다
서버 터미널에는 딕셔너리 형태로 메시지가 기록됩니다.
('127.0.0.1', 52344) 메시지: {'type': 'chat', 'content': '안녕하세요 A입니다'}
이번 강의의 성공 판단 기준: 클라이언트 A가 빠르게 여러 메시지를 연속으로 보내도 클라이언트 B에서 메시지가 정상적으로 분리되어 출력되면 완료입니다. 16강에서 "사람이 천천히 칠 때는 문제가 안 보인다"고 했으니, 일부러 빠르게 여러 줄을 연달아 보내 확인해 보세요.
6.2 흔한 오류와 해결 방법
오류가 나오면 어느 단계에서 실패했는지 먼저 생각합니다.
| 오류 상황 | 원인 및 해결 방법 |
ModuleNotFoundError: No module named 'protocol' (서버) |
chat_server/에 protocol.py가 없음. 2번에서 같은 폴더에 만들었는지 확인한다 |
ModuleNotFoundError: No module named 'protocol' (클라이언트) |
chat_client/에 protocol.py 복사본이 없음. 3.3의 cp 명령으로 chat_client/protocol.py를 만든다 |
json.decoder.JSONDecodeError |
완성되지 않은 JSON을 json.loads()에 전달했음. receive_messages() 안에서 반드시 \n으로 분리된 raw_message만 json.loads()에 넣는지 확인한다 |
| 메시지가 다른 클라이언트에 전달되지 않음 | send_message() 대신 기존 send()를 섞어 사용했음. 서버와 클라이언트 모두 send_message()로 통일한다 |
| 한 메시지가 가끔 둘로 쪼개져 보임 | 서버·클라이언트의 protocol.py 내용이 다름(특히 DELIMITER). 3.3대로 한쪽을 복사해 양쪽을 똑같이 맞춘다 |
한글이 \uc548\ub155 형태로 출력됨 |
ensure_ascii=False가 없음. json.dumps() 호출 시 추가한다 |
ConnectionRefusedError |
서버가 실행되지 않았음. python chat_server/server.py를 먼저 실행한다 |
✔ 확인 기준: 서로의 메시지가 정상 전달되고, 서버에 딕셔너리 형태로 출력되면 완료. send_message()가 메시지 끝에 \n을 붙이는지, receive_messages()가 while 반복으로 메시지를 하나씩 꺼내는지 확인하세요.
7. 최종 코드 정리하기
→ 이번 강의에서 완성한 세 파일의 전체 코드를 한곳에 정리합니다.
7.1 chat_server/protocol.py (chat_client/protocol.py 와 동일)
아래 파일은 chat_server/와 chat_client/에 똑같이 들어갑니다.
import json
ENCODING = "utf-8"
DELIMITER = "\n"
def send_message(sock, message):
json_text = json.dumps(message, ensure_ascii=False)
data = json_text + DELIMITER
sock.sendall(data.encode(ENCODING))
def receive_messages(sock, buffer):
data = sock.recv(1024)
if not data:
return buffer, [], False
buffer += data.decode(ENCODING)
messages = []
while DELIMITER in buffer:
raw_message, buffer = buffer.split(DELIMITER, 1)
if raw_message.strip():
message = json.loads(raw_message)
messages.append(message)
return buffer, messages, True
7.2 chat_server/server.py
import socket
import threading
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def broadcast(message, sender_socket):
for client in clients[:]:
if client != sender_socket:
try:
send_message(client, message)
except Exception:
if client in clients:
clients.remove(client)
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
buffer = ""
with client_socket:
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer)
if not connected:
print(f"클라이언트 연결 끊김: {client_address}")
break
for message in messages:
print(f"{client_address} 메시지: {message}")
if message.get("type") == "exit":
print(f"클라이언트 종료 요청: {client_address}")
return
if message.get("type") == "chat":
broadcast_message = {
"type": "chat",
"sender": str(client_address),
"content": message.get("content", "")
}
broadcast(broadcast_message, client_socket)
except Exception as error:
print(f"클라이언트 처리 오류: {client_address}, {error}")
break
if client_socket in clients:
clients.remove(client_socket)
print(f"접속자 목록에서 제거: {client_address}")
print_clients()
print(f"클라이언트 처리 종료: {client_address}")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.bind((HOST, PORT))
server_socket.listen()
print("메시지 구분 처리 서버를 시작합니다.")
print("클라이언트 접속 대기 중...")
while True:
client_socket, client_address = server_socket.accept()
print(f"클라이언트 접속: {client_address}")
clients.append(client_socket)
print(f"접속자 목록에 추가: {client_address}")
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.start()
7.3 chat_client/client.py
import socket
import threading
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
def receive_loop(client_socket):
buffer = ""
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer)
if not connected:
print("서버와의 연결이 끊어졌습니다.")
break
for message in messages:
if message.get("type") == "chat":
sender = message.get("sender", "unknown")
content = message.get("content", "")
print(f"\n{sender}: {content}")
else:
print(f"\n수신 메시지: {message}")
except Exception:
break
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((HOST, PORT))
print("서버에 연결되었습니다.")
print("전체 채팅을 시작합니다. 종료하려면 exit을 입력하세요.")
receive_thread = threading.Thread(
target=receive_loop,
args=(client_socket,)
)
receive_thread.daemon = True
receive_thread.start()
while True:
text = input("")
if text == "exit":
send_message(client_socket, {"type": "exit"})
print("서버와의 연결을 종료합니다.")
break
send_message(client_socket, {
"type": "chat",
"content": text
})
print("클라이언트 소켓을 닫았습니다.")
7.4 최종 확인 표
| 확인할 코드 | 위치 | 의미 |
DELIMITER = "\n" |
protocol.py |
메시지 경계 구분자 |
json.dumps(message, ensure_ascii=False) |
protocol.py |
딕셔너리 → JSON 문자열, 한글 유지 |
sock.sendall(data.encode(ENCODING)) |
protocol.py |
데이터 전체 전송 보장 |
if not data: return ..., False |
protocol.py |
recv() 빈값 → 연결 종료 신호 |
buffer += data.decode(ENCODING) |
protocol.py |
받은 조각 누적 |
buffer.split(DELIMITER, 1) |
protocol.py |
\n 하나만 기준으로 분리(나머지는 buffer 잔류) |
from protocol import receive_messages, send_message |
server.py, client.py |
공통 송수신 함수 가져오기(양쪽 폴더에 protocol.py 필요) |
buffer = "" |
handle_client(), receive_loop() |
클라이언트·스레드별 독립 버퍼 |
→ 다음 강의 (18강): 이번 강의로 분리된 메시지를 type 값에 따라 나누어 처리하는 분기 구조를 구현합니다. type이 "chat"이면 전체 브로드캐스팅, "system"이면 시스템 메시지, "error"이면 오류 메시지, "exit"이면 연결 종료로 처리하는 서버 로직을 완성합니다.
8. 과제로 직접 확인하기
→ 만든 코드를 직접 돌려 경계 문제가 해결되는지 확인하고, buffer에 미완성 조각이 남는 과정을 눈으로 관찰합니다.
아래 과제는 이번 강의에서 완성한 protocol.py, server.py, client.py를 그대로 사용합니다. 16강에서 머리로 이해한 "메시지 붙음"과 "buffer 잔류"가 실제 코드에서 어떻게 처리되는지 직접 확인하는 것이 목적입니다.
8.1 과제 1 — 빠르게 연속으로 보내도 분리되는지 확인하기
16강에서 메시지를 빠르게 연속으로 보내면 여러 개가 한 번의 recv()에 붙어 도착한다고 배웠습니다. 이번 강의의 receive_messages()가 그 붙은 메시지를 제대로 다시 나누는지 확인합니다. 사람이 직접 타이핑하면 충분히 빠르지 않으니, 메시지 20개를 지연 없이 보내는 작은 테스트 파일을 만듭니다.
touch chat_client/test_burst.py
test_burst.py에 다음 코드를 작성합니다.
import socket
import time
from protocol import send_message # 이번 강의에서 만든 송신 함수 재사용
HOST = "127.0.0.1"
PORT = 5000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((HOST, PORT)) # 서버에 접속한다
for i in range(1, 21): # 메시지 20개를
send_message(sock, {"type": "chat", "content": f"메시지 {i}"}) # 지연 없이 빠르게 연속 전송
time.sleep(1) # 서버가 처리할 시간을 잠깐 준다
이제 터미널 3개를 사용합니다. 터미널 1에서 서버를, 터미널 2에서 일반 클라이언트(메시지를 받아 볼 B)를 실행해 둡니다.
python chat_server/server.py # 터미널 1
python chat_client/client.py # 터미널 2 (수신 확인용 B)
그다음 터미널 3에서 방금 만든 테스트 파일을 실행합니다.
python chat_client/test_burst.py
터미널 2(클라이언트 B)에 20개의 메시지가 섞이거나 깨지지 않고 한 줄씩 정상 출력되면 성공입니다.
('127.0.0.1', 52361): 메시지 1
('127.0.0.1', 52361): 메시지 2
...
('127.0.0.1', 52361): 메시지 20
확인할 점: 20개가 거의 동시에 붙어서 서버에 도착했는데도 깨지지 않은 이유는, receive_messages()가 받은 데이터를 \n 기준으로 하나씩 잘라 처리했기 때문입니다. 16강에서 봤던 "메시지 붙음"이 코드로 해결된 것입니다.
8.2 과제 2 — buffer에 미완성 조각이 남는 과정 관찰하기
이번 강의의 핵심은 "아직 \n이 안 온 조각은 buffer에 남겨 둔다"는 것이었습니다. 그런데 같은 컴퓨터(localhost)에서는 짧은 메시지가 대부분 한 번에 다 도착해서 buffer 잔류가 잘 안 보입니다. 그래서 recv()를 일부러 작게 만들어 메시지가 조각나도록 한 뒤, buffer가 어떻게 쌓이는지 출력으로 관찰합니다.
protocol.py의 receive_messages()를 잠깐만 아래처럼 고칩니다. (관찰이 끝나면 반드시 되돌립니다.)
def receive_messages(sock, buffer):
data = sock.recv(10) # 1024 → 10으로 잠깐 줄인다 (조각나게 만들기)
if not data:
return buffer, [], False
buffer += data.decode(ENCODING)
print(f" [recv 조각] buffer = {buffer!r}") # buffer가 쌓이는 과정을 출력
messages = []
while DELIMITER in buffer:
raw_message, buffer = buffer.split(DELIMITER, 1)
if raw_message.strip():
message = json.loads(raw_message)
messages.append(message)
return buffer, messages, True
서버를 다시 실행하고, 클라이언트에서 조금 긴 메시지 하나를 보냅니다.
안녕하세요 반갑습니다
서버 터미널에 buffer가 10바이트씩 조금씩 쌓이다가, \n이 도착하는 순간 메시지로 완성되는 과정이 출력됩니다.
[recv 조각] buffer = '{"type": "'
[recv 조각] buffer = '{"type": "chat", "'
[recv 조각] buffer = '{"type": "chat", "content":'
... (중간 생략) ...
[recv 조각] buffer = '{"type": "chat", "content": "안녕하세요 반갑습니다"}\n'
('127.0.0.1', 52377) 메시지: {'type': 'chat', 'content': '안녕하세요 반갑습니다'}
확인할 점: \n이 오기 전까지는 buffer에 미완성 JSON 조각이 계속 남아 있고, 그동안에는 json.loads()를 한 번도 호출하지 않습니다. 이것이 16강에서 배운 "쪼개짐"을 buffer 잔류로 견디는 모습입니다. 관찰이 끝나면 recv(10)을 recv(1024)로 되돌리고 print 줄을 지웁니다.
8.3 생각해 볼 문제
코드를 작성하지 말고 개념으로 답해 보세요. 구분자를 \n 대신 다른 문자(예: 특수 기호)로 바꾸려면 몇 개의 파일을 수정해야 할까요? 그리고 그 이유는 무엇일까요?
✔ 정답 방향: 과제 1은 메시지 20개가 깨지지 않고 한 줄씩 출력되면 성공입니다. 과제 2는 \n이 오기 전까지 buffer에 미완성 조각이 남아 있다가, \n이 도착하는 순간 메시지로 완성되는 흐름을 관찰하면 됩니다. 8.3의 답은 2개 파일입니다. chat_server/protocol.py와 chat_client/protocol.py의 DELIMITER를 함께 바꿔야 합니다. server.py와 client.py는 DELIMITER를 직접 쓰지 않고 protocol.py에서 가져다 쓰므로 수정할 필요가 없습니다. 단, 양쪽 protocol.py 중 한쪽만 바꾸면 서버와 클라이언트의 구분자가 달라져 메시지가 분리되지 않으니 반드시 같이 수정해야 합니다.