학습 로그/Python3

D+52[Python] 다중 접속과 브로드캐스팅메시지 & 프로토콜

goodjob^^ 2026. 6. 22. 19:33

ㅇ 일 자 : 2026년 6월 22일(월) 

ㅇ 주 제 : 다중 접속과 브로드캐스팅 메세지 실습하고 이해하기

 14, 15강 JSON 기반 메세지 타입 설계

 16강 패킷 경계란 무엇인가?

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

 18강 시스템 메세지와 오류 메세지 처리

 

  이 단원은 좀 어렵다. 좀더 이해가 필요하다.  실습간에는 가상의 공간 주소를 활용하였음. 이후 수정됨

 

1.  JSON 기반 메세지 타입 설계  

"type" : "메세지 종류, 서버는 이 값을 보고 처리방식을 결정함"
"sender" : "보낸사람"
"targer" : "받는 사람"
"content" : "실제 내용"

1.1. 문자열 방식 : 분할된 리스트에서 수신자 아이디를 추출하고, 문자열을 출력한다.

문자열 방식 화면

# 분석할 귓속말 메시지 정의 (형식: /w 유저명 메시지)
message = "/w user2 안녕하세요"
 
# 메시지가 귓속말 명령어("/w")로 시작하는지 확인
if message.startswith("/w"):
    # 공백(" ")을 기준으로 메시지를 최대 2번만 분할합니다.
    # 결과: ['/w', 'user2', '안녕하세요']
    parts = message.split(" ", 2)
    
    # 분할된 리스트에서 1번 인덱스(두 번째 요소)인 수신자 아이디를 추출
    target = parts[1]
    
    # 분할된 리스트에서 2번 인덱스(세 번째 요소)인 대화 내용을 추출
    content = parts[2]
    
    # 추출한 정보를 바탕으로 귓속말 형태의 문자열을 출력
    print(f"{target}에게 귓속말: {content}")

 

1.2. json 방식 : 조건문을 줘서 수신자와 내용을 변수에 저장하고 대상과 내용을 화면에 출력한다. 

# 1. 메시지 데이터를 담은 딕셔너리 정의
message = {
    "type": "whisper",   # 메시지 종류 (예: whisper = 귓속말)
    "sender": "user1",   # 메시지를 보내는 사람
    "target": "user2",   # 메시지를 받는 사람 (수신자)
    "content": "안녕하세요" # 메시지 내용
}

# 2. 메시지 타입이 '귓속말(whisper)'인지 확인하는 조건문
if message["type"] == "whisper":
    # 딕셔너리에서 수신자(target)와 내용(content)을 추출하여 변수에 저장
    target = message["target"]
    content = message["content"]
    
    # 귓속말 대상과 내용을 화면에 출력 (f-string 포맷팅 사용)
    print(f"{target}에게 귓속말: {content}")

 

※ 메시지 구조 공통 기본 구조

# 공통 기본 구조
{
    "type": "",      # 항상 포함 — 서버가 처리 방식 결정
    "sender": "",    # 보낸 사람
    "target": "",    # 받는 사람 (whisper 등 필요한 경우만)
    "content": ""    # 실제 내용
}
 
# 타입별 설계 정리
{"type": "chat",    "sender": "user1",                            "content": "안녕하세요"}
{"type": "join",    "sender": "user1"}
{"type": "leave",   "sender": "user1"}
{"type": "whisper", "sender": "user1", "target": "user2",         "content": "비밀 메시지"}
{"type": "error",                                                  "content": "존재하지 않는 사용자입니다."}

 

 

2.  패킷 경계란 무엇인가? 애플리케이션 책임 구간과 소켓에 흐름 이해 

    패킷 경계(Packet Boundary)란 네트워크 상에서 전송되는 데이터 패킷과 패킷 사이의 구분선을 의미한다.
    송신 측이 보낸 데이터의 시작과 끝을 수신 측이 정확하게 파악할 수 있느냐에 대한 개념으로, 우리가 사용하는 전송 계층

    프로토콜(TCP vs UDP)과 애플리케이션의 구현 방식에 따라 이 경계의 의미가 완전히 달라진다.

2.1. TCP와 UDP에서의 패킷 경계

패킷 구분자 실습 (A, B 구분)
예제 실습 화면
소과제1. 실습 화면

import socket

server, client = socket.socketpair()  # 네트워크 없이 연결된 두 소켓을 만든다

client.send(b"hello world")  # 메시지 하나를 한 번에 보낸다 (send는 1번뿐)

part1 = server.recv(4)  # 앞에서 최대 4바이트만 읽는다
part2 = server.recv(4)  # 이어서 최대 4바이트만 더 읽는다
print(part1)  # 첫 번째 조각 확인
print(part2)  # 두 번째 조각 확인

server.close()  # 소켓 닫기
client.close()

소과제 2. 실습 화면

import socket

# 소켓 쌍(서버와 클라이언트 역할을 동시에 할 수 있는 연결된 소켓 2개)을 생성합니다.
server, client = socket.socketpair()

# 같은 짧은 메시지를 10번 연속으로 보냅니다.
for i in range(10):
    # 메시지의 끝을 식별할 수 있도록 구분자(개행 문자 \n)를 붙여 바이트 형태로 전송합니다.
    client.send(b"hello\n")

# 수신 버퍼에서 데이터를 최대 1024바이트만큼 읽어옵니다. (단 한 번만 호출)
# TCP 특성상 보낸 메시지 10개가 하나의 덩어리로 뭉쳐서 한 번에 읽힐 수 있습니다.
data = server.recv(1024)

# 10개의 메시지가 어떤 모습으로 (예: b'hello\nhello\n...') 뭉쳐서 도착했는지 콘솔에 출력해 확인합니다.
print(data)

# 받은 바이트(bytes) 데이터를 디코딩하여 일반 문자열(string)로 변환합니다.
text = data.decode("utf-8")

# 약속된 구분자인 개행 문자('\n')를 기준으로 전체 문자열을 잘라 리스트로 만듭니다.
# 이 과정을 통해 붙어왔던 메시지들이 개별 메시지로 분리됩니다.
messages = text.split("\n")

# 제대로 분리되었는지 확인하기 위한 카운트 변수를 초기화합니다.
count = 0

# 분리된 메시지 리스트를 하나씩 순회합니다.
for message in messages:
    # 문자열의 맨 마지막 '\n' 때문에 split 결과로 생기는 마지막 빈 문자열("")은 제외합니다.
    if message:
        count += 1
        # 몇 번째 메시지인지 번호와 분리된 메시지 내용을 함께 출력합니다.
        print(count, message)

# 통신이 끝났으므로 사용한 서버 소켓과 클라이언트 소켓을 모두 닫아 자원을 반납합니다.
server.close()
client.close()

 

 

3. 패킷 경계와 메세지 구분 처리

3.1. 문제의 배경: TCP의 스트림 지향 특성
  • TCP는 경계가 없다 : TCP는 전송 데이터를 끊이지 않는 하나의 연속된 바이트 스트림(Stream)으로 취급한다.
  • 패킷 단편화와 결합 : 송신측이 보낸 하나의 큰 메시지가 여러 패킷으로 쪼개지거나(TCP Segmentation), 반대로 여러 작은 메시지가 하나의 패킷에 합쳐져서 수신될 수 있다.
  • 메시지 왜곡: 수신측이 정확한 기준 없이 읽으면 데이터가 잘리거나 다음 메시지까지 침범하는 현상이 발생한다.
3.2. 패킷 경계 문제의 발생 유형
  • Splicing (정렬 오류): 패킷의 끝과 메시지의 끝이 일치하지 않아 데이터가 밀리는 현상.
  • Fragmentation (단편화): 하나의 메시지가 여러 패킷에 분할되어 도달하여, 메시지의 일부분만 먼저 읽히는 현상.
  • Coalescing (결합): 송신 속도가 빠르거나 네트워크 버퍼링에 의해 여러 메시지가 한 번에 뭉쳐서 수신 버퍼에 쌓이는 현상.
3.3. 메시지 구분 처리 해결 방안 (애플리케이션 프로토콜 설계)
TCP 자체는 경계를 유지해주지 않으므로, 상위 애플리케이션 계층에서 직접 메시지의 시작과 끝을 구별하는 규칙을 정의해야 한다.
① 고정 길이 방식 (Fixed Length)
  • 개념: 모든 메시지의 크기를 미리 동일하게 고정(예: 무조건 1024바이트)하여 전송한다.
  • 처리 방법: 수신측은 버퍼에서 정확히 지정된 바이트 수만큼만 끊어서 읽는다.
  • 장단점: 구현이 가장 직관적이고 간단하나, 작은 데이터를 보낼 때도 남은 공간을 패딩(공백)으로 채워야 하므로 대역폭 낭비가 심하다.
② 구분자 기반 방식 (Delimiter-based)
  • 개념: 메시지의 끝에 특정 문자나 바이트 패턴(예: \n, \r\n, \0)을 붙여 경계를 표시한다.
  • 처리 방법: 수신측은 스트림을 읽다가 해당 구분자가 등장할 때까지를 하나의 메시지로 인식한다. (HTTP 헤더, SMTP 등에서 사용)
  • 장단점: 가변 길이 메시지를 다루기 편하나, 실제 데이터 내부에 구분자와 동일한 문자가 포함될 경우 이스케이프(Escape) 처리가 필수적이며 문자열 파싱 오버헤드가 존재한다.
③ 길이 필드 명시 방식 (Length-Prefix / Type-Length-Value)
  • 개념: 메시지를 보낼 때, 실제 데이터 앞에 해당 데이터의 길이를 나타내는 고정 크기의 헤더(예: 4바이트 정수)를 먼저 붙여 전송한다.
  • 처리 방법:
    1. 수신측은 먼저 헤더 크기(예: 4바이트)만큼 읽어 데이터의 전체 길이 \(N\)을 파악한다.
    2. 그 다음 정확히 \(N\)바이트만큼 수신 버퍼에서 읽어 전송된 메시지를 완성한다. 
  • 장단점: 바이너리 데이터 전송에 가장 효율적이며 이스케이프 처리가 필요 없다. 다만 구현 시 수신 버퍼에 데이터가 충분히 쌓였는지 체크하는 상태 관리 뼈대 코드가 필요하다.
3.4. 수신측 가용 데이터 체크 핵심 로직
어떤 방식을 쓰든 네트워크 라이브러리 레벨에서는 다음과 같은 예외 처리가 필수적이다.
  • Loop 구조 필수: 수신 버퍼(recv_buf)에 쌓인 바이트 수가 처리해야 할 메시지 요구량보다 많다면, 하나의 패킷 안에서 여러 개의 메시지를 복원하기 위해 반복문으로 잘라내야 한다.
  • 미완성 패킷 보존: 메시지의 일부만 도착한 경우, 가용 바이트가 채워질 때까지 처리를 미루고 다음 데이터 수신 이벤트를 기다려야 한다.

실습 파일
가상의 주소에 가상의 접속자간의 대화를 위한 서버 연결
가상의 주소에 가상의 접속자간의 대화를 위한 서버 연결
가상의 사용자 (2)에서 채팅이 정상 시행된 화면

 

가상의 대화창을 여러개 열어서 정상 실행 여부 확인 화면 (메세지는 20개까지 구분해서 출력됨)

 

4.  시스템 메세지와 오류 메세지 처리

4.1. 오류로 수정 보완 검색 확인

① 빈 데이터(공백) 수신 시 json.loads() 예외 처리

클라이언트가 연결을 끊은 것이 아니라, 어떤 이유로든 빈 줄("\n")이나 공백만 포함된 데이터를 보냈을 때 raw_message.strip()이 거짓(False)이 되어 무시되기도 하지만 만약 공백이 아닌 잘못된 JSON 형식(예: {"action": 처럼 잘린 데이터)이 들어오면 json.loads()에서 json.JSONDecodeError가 발생해 서버가 위험할 수도 있어서 안전하게 try-except로 감싸는 것이 좋다.

② 성능 최적화 (문자열 더하기 + 연산 배제)

Python에서 문자열은 불변(Immutable) 객체,  buffer += data.decode(...)를 반복하면 매번 새로운 메모리 공간을 할당하고 복사하므로 성능이 저하되니, 데이터가 바이트 단위로 들어오므로, 버퍼를 bytes 타입으로 유지하다가 구분자 단위로 쪼갠 뒤에 decode하는 것이 훨씬 효율적이다.

 

4. 2. 올바르게 수정된 protocol.py 코드 :  버퍼를 bytes 기반으로 처리하도록 변경

※ chat_client 패키지 파일 설치 

4.1. protocol.py

import json

ENCODING = "utf-8"
# 네트워크 전송 시 혼선을 줄이기 위해 바이트 단위로 구분자를 정의합니다.
DELIMITER = b"\n"  


def send_message(sock, message):
    """메시지를 JSON으로 직렬화하고 구분자를 붙여 전송합니다."""
    json_text = json.dumps(message, ensure_ascii=False)
    # 문자열 뒤에 \n을 붙이고 전체를 encode해서 전송
    data = (json_text + "\n").encode(ENCODING)
    sock.sendall(data)


def receive_messages(sock, buffer: bytes):
    """
    소켓에서 데이터를 읽어와 버퍼에 추가한 뒤, 
    완전한 JSON 메시지들을 파싱하여 반환합니다.
    """
    try:
        data = sock.recv(1024)
    except ConnectionResetError:
        # 상대방이 강제 종료했을 경우 안전하게 연결 종료 처리
        return b"", [], False

    if not data:
        # 상대방이 정상적으로 연결을 종료한 경우 (EOF)
        return buffer, [], False

    buffer += data
    messages = []

    # 버퍼에 구분자(\n)가 포함되어 있는 동안 반복
    while DELIMITER in buffer:
        raw_message, buffer = buffer.split(DELIMITER, 1)

        # 빈 바이트가 아니라면 JSON 파싱 시도
        if raw_message.strip():
            try:
                # 파싱 직전에 문자열로 디코딩
                decoded_msg = raw_message.decode(ENCODING)
                message = json.loads(decoded_msg)
                messages.append(message)
            except (json.JSONDecodeError, UnicodeDecodeError) as e:
                # 잘못된 데이터가 들어와도 서버가 죽지 않도록 예외 처리
                print(f"[경고] 잘못된 패킷 수신 및 무시: {e}")
                continue

    return buffer, messages, True

 

4.2. chat_server/server.py

import socket
import threading

# 사용자 정의 프로토콜 모듈에서 메시지 송수신 함수를 가져옵니다.
from protocol import receive_messages, send_message

# 서버가 구동될 호스트 IP와 포트 번호 설정
HOST = "127.0.0.1"
PORT = 5000

# 현재 서버에 연결된 클라이언트 소켓들을 저장하는 리스트
clients = []


def print_clients():
    """현재 접속 중인 클라이언트 수를 출력하는 함수"""
    print(f"현재 접속자 수: {len(clients)}")


def broadcast(message, sender_socket=None):
    """
    연결된 모든 클라이언트에게 메시지를 전송(브로드캐스트)하는 함수
    sender_socket이 지정된 경우, 메시지를 보낸 본인을 제외하고 나머지 사람들에게만 전송합니다.
    """
    # 순회 중 리스트가 변경되어 오류가 발생하는 것을 방지하기 위해 복사본(clients[:])을 사용합니다.
    for client in clients[:]:
        if client != sender_socket:
            try:
                send_message(client, message)
            except Exception:
                # 전송 실패 시, 해당 클라이언트와의 연결이 끊어진 것으로 간주하고 목록에서 제거합니다.
                if client in clients:
                    clients.remove(client)


def send_error(client_socket, content):
    """특정 클라이언트에게 에러 메시지를 형식을 맞춰 전송하는 함수"""
    error_message = {"type": "error", "content": content}
    send_message(client_socket, error_message)


def handle_client(client_socket, client_address):
    """
    각 클라이언트의 연결을 독립적으로 처리하는 스레드 함수
    메시지 수신 대기 및 타입별(채팅, 종료 등) 처리를 담당합니다.
    """
    print(f"클라이언트 처리 시작: {client_address}")

    # [수정 포인트] protocol.py가 bytes 버퍼를 사용하므로 b"" 로 초기화합니다.
    buffer = b""
    address_text = str(client_address)

    # 새로운 클라이언트 입장 알림 메시 생성 및 브로드캐스트
    join_message = {
        "type": "system",
        "content": f"{address_text} 님이 입장했습니다.",
    }
    broadcast(join_message, client_socket)

    # context manager(with)를 사용하여 함수가 끝나거나 오류 발생 시 소켓이 자동으로 닫히도록 합니다.
    with client_socket:
        while True:
            try:
                # 클라이언트로부터 메시지를 수신합니다.
                # receive_messages는 바이트 버퍼, 파싱된 메시지 리스트, 연결 유지 여부를 반환합니다.
                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}")

                    message_type = message.get("type")

                    # 1. 종료 요청("exit") 메시지 처리
                    if message_type == "exit":
                        print(f"클라이언트 종료 요청: {client_address}")
                        break

                    # 2. 일반 채팅("chat") 메시지 처리
                    if message_type == "chat":
                        content = message.get("content", "").strip()

                        # 빈 메시지 전송 시 에러 처리
                        if not content:
                            send_error(
                                client_socket, "빈 메시지는 보낼 수 없습니다."
                            )
                            continue

                        # 다른 클라이언트들에게 채팅 내용 전달
                        broadcast_message = {
                            "type": "chat",
                            "sender": address_text,
                            "content": content,
                        }
                        broadcast(broadcast_message, client_socket)
                        continue

                    # 3. 정의되지 않은 메시지 타입이 올 경우 에러 처리
                    send_error(
                        client_socket,
                        f"지원하지 않는 메시지 타입입니다: {message_type}",
                    )

                # 메시지 리스트 중에 "exit" 요청이 포함되어 있었다면 메인 수신 루프도 종료합니다.
                if any(message.get("type") == "exit" for message in messages):
                    break

            except Exception as error:
                print(f"클라이언트 처리 오류: {client_address}, {error}")
                break

    # [연결 종료 프로세스]
    # 접속자 목록에서 해당 클라이언트 제거
    if client_socket in clients:
        clients.remove(client_socket)

    # 다른 사용자들에게 퇴장 알림 전송
    leave_message = {
        "type": "system",
        "content": f"{address_text} 님이 퇴장했습니다.",
    }
    broadcast(leave_message)

    print(f"접속자 목록에서 제거: {client_address}")
    print_clients()
    print(f"클라이언트 처리 종료: {client_address}")


# --- 메인 서버 시작 부 ---
# IPv4(AF_INET), TCP(SOCK_STREAM) 소켓을 생성합니다.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
    # 재사용 가능하도록 설정 (서버 재시작 시 포트 바인딩 에러 방지)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind((HOST, PORT))  # 지정한 IP와 포트에 소켓 바인딩
    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()

        # 멀티스레딩 적용: 클라이언트별로 독립된 스레드를 생성하여 handle_client 함수를 실행합니다.
        client_thread = threading.Thread(
            target=handle_client, args=(client_socket, client_address)
        )
        client_thread.start()  # 스레드 시작

 

4.3. chat_client/client.py

import socket
import threading

# 사용자 정의 프로토콜 모듈에서 메시지 송수신 함수를 가져옵니다.
from protocol import receive_messages, send_message

# 접속할 서버의 IP 주소와 포트 번호 설정
HOST = "127.0.0.1"
PORT = 5000


def receive_loop(client_socket):
    """
    서버로부터 데이터를 실시간으로 수신하는 무한 루프 함수입니다.
    메인 스레드와 별개로 백그라운드(데몬 스레드)에서 작동합니다.
    """
    # [수정 포인트] protocol.py가 bytes 버퍼를 사용하므로 b"" 로 초기화합니다.
    buffer = b""

    while True:
        try:
            # 서버로부터 메시지를 수신합니다.
            # receive_messages는 바이트 버퍼, 파싱된 메시지 리스트, 연결 유지 여부를 반환합니다.
            buffer, messages, connected = receive_messages(
                client_socket, buffer
            )

            # 서버가 연결을 끊었거나 연결이 유실된 경우
            if not connected:
                print("\n서버와의 연결이 끊어졌습니다.")
                break

            # 수신된 메시지들을 하나씩 꺼내어 타입별로 처리합니다.
            for message in messages:
                message_type = message.get("type")

                # 1. 일반 채팅 메시지 처리
                if message_type == "chat":
                    sender = message.get("sender", "unknown")
                    content = message.get("content", "")
                    print(f"\n{sender}: {content}")

                # 2. 시스템 알림 메시지 처리 (예: 누구누구 님이 입장했습니다.)
                elif message_type == "system":
                    content = message.get("content", "")
                    print(f"\n[시스템] {content}")

                # 3. 에러 메시지 처리 (예: 빈 메시지 전송 제한 오류 등)
                elif message_type == "error":
                    content = message.get("content", "")
                    print(f"\n[오류] {content}")

                # 4. 약속되지 않은 다른 타입의 메시지가 왔을 경우 처리
                else:
                    print(f"\n[알 수 없는 메시지] {message}")

        except Exception:
            # 수신 중 에러가 발생하면 수신 루프를 종료합니다.
            break


# --- 메인 클라이언트 실행 부 ---
# IPv4(AF_INET), TCP(SOCK_STREAM) 소켓을 생성합니다.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
    try:
        # 설정된 IP와 포트를 통해 서버에 연결을 시도합니다.
        client_socket.connect((HOST, PORT))
    except ConnectionRefusedError:
        print("서버가 켜져 있지 않거나 연결할 수 없습니다.")
        exit()

    print("서버에 연결되었습니다.")
    print("전체 채팅을 시작합니다. 종료하려면 exit을 입력하세요.")

    # [멀티스레딩] 서버의 메시지를 상시 대기할 서브 스레드를 생성합니다.
    receive_thread = threading.Thread(
        target=receive_loop, args=(client_socket,)
    )
    # 데몬 스레드로 설정: 메인 스레드(사용자 입력 루프)가 종료되면 이 스레드도 자동으로 함께 종료됩니다.
    receive_thread.daemon = True
    receive_thread.start()  # 수신 스레드 시작

    # [메인 루프] 사용자의 키보드 입력을 대기하고 서버로 전송하는 블로킹 구간입니다.
    while True:
        try:
            text = input("")  # 사용자 입력 대기

            # 사용자가 'exit'을 입력하면 종료 프로토콜을 수행합니다.
            if text == "exit":
                send_message(
                    client_socket, {"type": "exit"}
                )  # 서버에 종료 알림 전송
                print("서버와의 연결을 종료합니다.")
                break  # 키보드 입력 루프 탈출

            # 일반 입력값은 채팅 메시지 형태로 포맷팅하여 서버로 전송합니다.
            send_message(client_socket, {"type": "chat", "content": text})
        except (ConnectionResetError, BrokenPipeError):
            print("\n서버가 비정상 종료되어 메시지를 보낼 수 없습니다.")
            break

# with 블록을 빠져나가면서 client_socket이 자동으로 close(닫힘) 됩니다.
print("클라이언트 소켓을 닫았습니다.")

-끝-