16강 개념 중심 ⏱ 약 35분

 

0. 학습 목표

→ 이번 글에서 무엇을 이해하고, 무엇을 정리하고, 무엇을 확인할지 먼저 정리합니다.

더보기

0.1 이번 글에서 다룰 내용

이번 글은 개념 중심 강의입니다.

 

15강까지 메시지 안에 type, sender, content 같은 규칙을 설계했습니다. 그런데 메시지 구조를 아무리 잘 정해도, 네트워크에서 받은 데이터가 항상 우리가 보낸 단위로 딱딱 나뉘어 도착하지는 않습니다.

 

초급자가 가장 많이 오해하는 지점이 바로 여기입니다.

보낸 사람:    send(메시지A)       send(메시지B)
기대하는 수신: recv() → A         recv() → B

실제 수신:     recv() → AB       
    또는:     recv() → A의 60% + recv() → A의 40% + B

 

send() 한 번과 recv() 한 번이 1:1로 대응한다고 생각하기 쉽지만, TCP는 데이터를 메시지 단위가 아니라 바이트 흐름으로 다룹니다. 그래서 메시지 두 개가 붙어서 오기도 하고, 한 메시지가 여러 조각으로 나뉘어 오기도 합니다.

 

이번 강의에서는 파일을 수정하지 않습니다. "왜 이런 일이 생기는가", "누가 이 문제를 책임지는가", "어떤 방식으로 해결하는가"를 이해하는 것이 목표이고, 해결 코드는 17강에서 구현합니다.

구분 내용
이해할 것 TCP는 메시지 단위가 아니라 바이트 흐름(스트림)으로 데이터를 전달한다는 것
정리할 것 TCP가 책임지는 구간과 애플리케이션이 책임지는 구간, 경계 문제가 생기는 원인
확인할 것 경계를 처리하는 세 가지 방법(고정 크기·구분자·길이 헤더)의 차이

 

0.2 이번 강의에서 살펴볼 데이터 흐름

이번 강의는 개념 중심이므로 기존 파일을 직접 수정하지 않습니다.

chat_server/
└── server.py      ← 이번 강의에서 수정하지 않음

chat_client/
└── client.py      ← 이번 강의에서 수정하지 않음

대신 클라이언트가 보낸 메시지가 서버에 어떤 형태로 도착할 수 있는지를 살펴봅니다.

클라이언트가 보낸 메시지:
├── {"type": "chat", "content": "안녕하세요"}
└── {"type": "chat", "content": "반갑습니다"}

서버가 실제로 받을 수 있는 형태:
├── 두 메시지가 한 번에 붙어서 도착
├── 한 메시지가 여러 조각으로 나뉘어 도착
└── 일부만 먼저 도착하고 나머지가 나중에 도착

 

1. Stream 이해하기

→ 스트림이 무엇인지, TCP가 왜 바이트 흐름인지, send()와 recv()가 실제로 무슨 일을 하는지 이해합니다.

더보기

1.1 Byte 이해

(참고자료) 전기 신호로, 숫자를 저장하는 방법

 

1.2 Stream 이란 무엇인가

(참고자료) 표준입출력(1) - Stream

 

스트림(stream)은 1바이트씩 순서대로 흘러가는 흐름을 뜻합니다. 사실 우리는 이미 스트림을 여러 번 다뤄 왔습니다. 파일에서 글자를 한 글자씩 읽는 것, 키보드 입력이 한 글자씩 들어오는 것 모두 같은 개념입니다.

파일 읽기   : 바이트가 앞에서부터 순서대로 흐른다
키보드 입력 : 누른 글자가 순서대로 흐른다
네트워크    : 보낸 바이트가 순서대로 흐른다   ← TCP도 똑같다

공통점은 "덩어리"가 아니라 "흐름"이라는 점입니다. 흐름에는 "여기까지가 한 덩어리"라는 표시가 들어 있지 않습니다.

 

1.3 수도관으로 비유하기

TCP를 수도관에 비유하면 이해하기 쉽습니다. 클라이언트가 메시지를 보내는 것은 수도관에 물을 붓는 것과 같습니다. 메시지 A를 붓고 메시지 B를 이어서 부으면, 반대편에서 물을 받는 서버는 어디까지가 A였고 어디서부터가 B인지 알 수 없습니다. 물은 그냥 하나의 흐름으로 이어져 있기 때문입니다.

수도관 비유 TCP 소켓
수도관에 물 붓기 send()로 데이터 흘려보내기
반대편에서 물 받기 recv()로 흐르는 데이터 떠가기
물이 이어져 흐름 바이트가 연속된 흐름으로 도착
A물과 B물의 경계 구분 어려움 메시지 경계를 알 수 없음
중간에 구분선 넣기 \n 같은 구분자 삽입

 

 

2. Buffer 이해하기

→ 송신 버퍼와 수신 버퍼 구조를 보고, TCP가 보장하는 것과 보장하지 않는 것을 명확히 나눕니다.

더보기

2.1 send()와 recv()의 실제 의미

우리는 보통 다음처럼 코드를 작성합니다.

message = '{"type": "chat", "content": "안녕하세요"}'
client_socket.send(message.encode("utf-8"))

작성하는 사람 입장에서는 이 문자열이 하나의 덩어리처럼 느껴집니다.

하지만 TCP는 "이 문자열이 메시지 하나다"라는 정보를 기억하지 않습니다. 

 

send()는 버퍼에 전달한 Byte(바이트)를 stream에 흘려보내는 것이고, 

recv()는 stream에서 버퍼로 전달 받은 Byte(바이트)일부를 떠가는 것일 뿐입니다.

 

특히 recv(1024)의 숫자를 메시지 개수로 오해하기 쉽습니다. 

개발자가 지정한 1024는 메시지 수가 아니라 한 번에 읽을 수 있는 최대 바이트 크기입니다.

코드 실제 의미
recv(1024) 최대 1024바이트까지 읽는다 (메시지 1개도, 1024개도 아니다)
recv() 반환값 지금 수신 버퍼에 있는 만큼 반환한다
결과의 단위 메시지 하나일 수도, 여러 개일 수도, 메시지 일부일 수도 있다

✔ 확인 기준: "TCP는 바이트 흐름이며, send() 한 번과 recv() 한 번이 항상 1:1로 대응하지 않는다"라고 설명할 수 있으면 완료. recv(1024)를 메시지 1024개를 받는 코드라고 생각하고 있지 않은지 확인하세요.

 

2.2 송신 버퍼와 수신 버퍼

send()를 호출한다고 데이터가 곧바로 상대에게 날아가는 것은 아닙니다.

데이터는 먼저 운영체제(커널)의 송신 버퍼에 복사되고, 커널이 백그라운드에서 적절한 크기로 잘라 전송합니다.

 

반대로 도착한 바이트는 수신 버퍼에 순서대로 쌓이고, recv()가 그 버퍼에서 읽어 갑니다.

송신 쪽:
send() → 커널 송신 버퍼에 복사 → 커널이 잘라서 전송 → ACK 받으면 버퍼에서 제거

수신 쪽:
도착한 바이트 → 커널 수신 버퍼에 순서대로 쌓임 → recv()가 읽어 감

여기서 핵심은 "얼마씩 잘라 보내는지", "얼마씩 읽는지"를 우리가 정하지 않는다는 점입니다. 그래서 send() 횟수와 recv() 횟수가 어긋날 수밖에 없습니다.

 


표준입출력(2) - Buffer

 

표준입출력(2) - Buffer

1. Buffer(버퍼)를 사용하는 이유더보기버퍼는 여러 입출력 장치간에 데이터를 읽고 쓰는 동안 발생하는 속도 차이를 해결한다.1,000 바이트를 읽는다고 가정할 때, 1바이트를 여러 번 읽을 경우 CPU

basiclike.tistory.com

 

3. TCP와 Application 책임 이해하기

→ 송신 버퍼와 수신 버퍼 구조를 보고, TCP가 보장하는 것과 보장하지 않는 것을 명확히 나눕니다.

더보기

3.1 애플리케이션 책임 구간과 소켓

여기서 한 가지 더 짚어 둘 점이 있습니다. 송신 버퍼에서 데이터를 적절한 크기로 잘라 보내고, 빠진 바이트를 다시 채우고, 도착한 바이트를 순서대로 수신 버퍼에 쌓는 일은 모두 운영체제(커널)가 알아서 합니다.

우리는 그 버퍼를 직접 만들거나 관리하지 않습니다.

TCP가 책임지는 이런 일(순서 보장, 재전송, 속도 조절)은 너무 복잡하고 까다로워서, 운영체제가 한 번 잘 만들어 두고 모든 프로그램이 똑같이 빌려 쓰도록 되어 있기 때문입니다. 덕분에 우리는 send()recv()만 호출하면 되고, 그 아래에서 벌어지는 일은 신경 쓰지 않아도 됩니다. 이것이 소켓이 제공하는 편리함입니다.

 

그렇다면 더 아래 단계, 즉 TCP가 해 주는 순서 보장이나 재전송까지 직접 만드는 프로그래밍도 있을까요? 있습니다. 다만 그 단계로 내려가면 흐름 제어와 오류 처리를 전부 손으로 구현해야 해서 훨씬 어렵습니다. 우리가 소켓(send()/recv())을 쓰는 이유는, 그 까다로운 부분을 운영체제에 맡기고 메시지를 주고받는 일 자체에 집중하기 위해서입니다.

 

3.2 TCP가 보장하는 것과 보장하지 않는 것

TCP가 무엇까지 책임지는지를 정확히 알아야 "왜 경계는 내가 처리해야 하는가"가 이해됩니다.

보장하는 것:
- 같은 소켓에서 보낸 바이트의 순서   → aaaa 다음엔 반드시 bbbb 순서로 도착
- 수신 버퍼에 바이트가 쌓이는 순서
- 손실된 바이트는 다시 보내 채워 줌

보장하지 않는 것:
- recv() 한 번이 읽는 단위          → "aaaabbbb"일 수도, "aa"일 수도
- 메시지가 어디서 끝나는지(경계)

→ "bbbbaaaa"처럼 순서가 뒤바뀌는 일은 절대 없다   ← 순서는 TCP가 책임진다

순서는 TCP가 지켜 주지만, 메시지 경계는 지켜 주지 않습니다. 그래서 데이터 구간을 두 영역으로 나눠 보면 책임이 분명해집니다.

[ TCP가 책임지는 구간 ]
send() → 송신 버퍼 → 네트워크 → 수신 버퍼      (순서 보장, 손실 시 재전송)

[ 애플리케이션이 책임지는 구간 ]
수신 버퍼 → recv() → 의미 있는 단위로 자르기    ← 경계 처리는 여기서 (우리 몫)

✔ 확인 기준: TCP가 "바이트 순서"는 보장하지만 "메시지 경계"는 보장하지 않는다는 점, 경계 처리는 애플리케이션(내 코드)의 책임이라는 점을 설명할 수 있으면 완료. "bbbbaaaa처럼 순서가 뒤집힐 수도 있다"고 오해하고 있지 않은지 확인하세요.

 

4.3 TCP 는 어떻게 신뢰성 있는 통신을 할까? 

 

4. 메시지 경계 문제를 직접 확인하기

→ 메시지 붙음과 쪼개짐을 확인하고, socketpair()로 네트워크 없이 직접 재현해 봅니다.

더보기

4.1 메시지 붙음과 쪼개짐

경계 문제는 두 가지 모습으로 나타납니다. 먼저 빠르게 두 메시지를 보내면 한 번의 recv()에 붙어서 도착할 수 있습니다.

클라이언트: send("A") → send("B")
서버 recv() 1회: "AB"

 

JSON 메시지라면 문제가 더 분명합니다. 아래처럼 객체 두 개가 붙어 오면 json.loads()로 한 번에 해석할 수 없습니다.

{"type":"chat","content":"A"}{"type":"chat","content":"B"}

 

반대로 메시지 하나가 여러 조각으로 나뉘어 오기도 합니다.

클라이언트가 보낸 메시지:
{"type":"chat","content":"안녕하세요"}

서버가 받은 조각:
recv() 1회 → {"type":"chat",
recv() 2회 → "content":"안녕하세요"}

첫 번째 조각만 보고 해석하려 하면 메시지가 아직 완성되지 않았기 때문에 실패합니다.

두 상황 모두 같은 원인에서 나옵니다. 경계를 알려 주는 표시가 없기 때문입니다.

 

4.2 socketpair()로 직접 재현하기

말로만 들으면 잘 와닿지 않으니 직접 재현해 봅니다. socket.socketpair()는 네트워크 설정 없이 서로 연결된 두 소켓을 한 번에 만들어 주는 함수라, 경계 문제를 손쉽게 눈으로 확인할 수 있습니다.

import socket

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

client.send(b"aaaa")                    # 첫 번째 메시지를 흘려보낸다
client.send(b"bbbb")                    # 두 번째 메시지를 이어서 흘려보낸다

data = server.recv(1024)                # 최대 1024바이트까지, recv()는 단 한 번만 호출
print(data)                             # 출력 확인

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

send()를 두 번 호출했지만 출력은 다음과 같이 한 덩어리로 붙어 나옵니다.

b'aaaabbbb'

두 메시지가 recv() 한 번에 붙어 도착한 것입니다. 

✔ 확인 기준: 메시지가 붙어 오는 경우와 나뉘어 오는 경우를 각각 설명할 수 있고, socketpair() 예제에서 send() 2회가 recv() 1회에 붙어 나오는 이유를 말할 수 있으면 완료. 짧은 메시지는 항상 안전하다고 생각하고 있지 않은지 확인하세요.

 

5. 경계 처리 방법 세 가지 비교하기

→ 고정 크기, 구분자, 길이 헤더 세 방식의 원리와 장단점을 비교합니다.

더보기

5.1 고정 크기 방식

모든 메시지를 항상 같은 크기(예: 64바이트)로 맞춰 보내는 방식입니다. 받는 쪽은 정해진 크기만큼만 읽으면 되므로 경계 고민이 사라집니다.

항상 64바이트씩 전송
"안녕하세요" + 빈 공간으로 64바이트 채움 → 받는 쪽은 64바이트씩 읽기

구현이 가장 단순하지만, 짧은 메시지에도 빈 공간을 채워 보내야 해서 낭비가 생기고, 정해진 크기를 넘는 데이터는 보낼 수 없습니다. 주로 센서·아두이노처럼 데이터 크기가 일정한 임베디드 환경에서 사용합니다.

 

5.2 구분자 방식

메시지 끝에 특별한 문자(예: \n)를 붙여 경계를 표시하는 방식입니다. 받는 쪽은 그 문자를 기준으로 잘라 메시지를 나눕니다.

{"type":"chat","content":"A"}\n{"type":"chat","content":"B"}\n
→ \n 기준으로 자르면 메시지 2개

구현이 단순해 텍스트 채팅에 잘 맞습니다. 다만 메시지 내용(content) 안에 구분자와 같은 문자가 들어가면 경계가 깨질 수 있어, 보통 텍스트 전용으로 사용합니다. 이번 프로젝트가 채택할 방식이며 17강에서 구현합니다.

 

5.3 길이 헤더 방식

메시지 본문 앞에 "이 메시지는 몇 바이트다"라는 길이 정보를 먼저 붙이는 방식입니다. 받는 쪽은 길이를 먼저 읽고, 그 길이만큼 정확히 읽어 메시지를 완성합니다.

[type][length][data]
예: length=32 → 본문 32바이트를 정확히 읽어서 한 메시지로 완성

구분자에 의존하지 않으므로 이미지·파일 같은 바이너리 데이터도 안전하게 다룰 수 있습니다. 대신 구현이 가장 복잡합니다. 실무 프로토콜과 파일 전송에서 널리 쓰이며, 이 프로젝트에서도 추후 파일 전송 단계에서 사용합니다.

 

5.4 세 방식 비교표

방식 장점 단점 주로 쓰는 곳
고정 크기 구현이 단순하고 경계 문제가 없다 빈 공간 낭비, 크기 초과 불가 임베디드, 센서, 아두이노
구분자 구현이 단순하다 텍스트 전용, 내용에 구분자 포함 불가 텍스트 채팅 (이번 프로젝트)
길이 헤더 바이너리 포함 모든 타입 처리 구현이 가장 복잡하다 파일·이미지 전송, 실무 프로토콜

✔ 확인 기준: 세 방식이 각각 어떤 원리로 경계를 구분하는지, 어떤 상황에 적합한지 설명할 수 있으면 완료. 어느 하나가 무조건 더 좋다고 생각하지 않는지 확인하세요. 텍스트 채팅에는 구분자, 파일 전송에는 길이 헤더가 적합합니다.

 

6. 경계 처리는 누구의 몫인지 정리하기

→ 경계 처리가 애플리케이션 책임인 이유와, 이번 프로젝트가 구분자 방식을 고른 이유를 정리합니다.

더보기

6.1 왜 애플리케이션이 처리해야 하는가

2번에서 봤듯이 TCP는 바이트 순서까지만 책임집니다. "여기까지가 한 메시지"라는 의미는 TCP가 알 수 없고, 그건 우리가 정한 약속입니다. 그래서 경계 처리는 TCP가 아니라 애플리케이션(내 코드)이 직접 구현해야 합니다. 보통은 받은 바이트를 버퍼에 모아 두었다가, 약속한 기준(구분자나 길이)에 따라 잘라 냅니다.

recv()로 받은 조각 → 버퍼에 누적
        ↓
약속한 기준으로 자르기   ← 여기가 애플리케이션의 몫
        ↓
완성된 메시지 1개씩 처리

 

6.2 이번 프로젝트가 구분자 방식을 고른 이유

현재 프로젝트는 텍스트 메시지(JSON 문자열)를 주고받습니다. 가장 단순하면서 텍스트에 잘 맞는 \n 구분자 방식이 적합합니다. 핵심 아이디어는 메시지 끝에 \n을 붙이고, 받은 문자열을 \n 기준으로 나누는 것입니다.

received = '{"type":"chat","content":"A"}\n{"type":"chat","content":"B"}\n'   # 메시지 끝마다 \n 구분자
messages = received.split("\n")         # 구분자를 기준으로 메시지를 나눈다

for message in messages:
    if message:                         # split 끝에 생기는 빈 문자열은 건너뛴다
        print(message)
{"type":"chat","content":"A"}
{"type":"chat","content":"B"}

나중에 파일·이미지 전송을 추가할 때는 구분자만으로 부족하므로 길이 헤더 방식으로 넘어갑니다. 그때는 길이를 먼저 읽고 그만큼 정확히 읽는 함수(예: recv_exact())를 만들게 됩니다. 지금은 "텍스트는 구분자, 파일은 길이 헤더"라는 큰 그림만 기억하면 충분합니다.

✔ 확인 기준: 경계 처리가 TCP가 아닌 애플리케이션의 책임이라는 점, 이번 프로젝트가 텍스트에 맞는 구분자 방식을 고른 이유를 설명할 수 있으면 완료. 구분자만 붙이면 끝이 아니라, 받은 쪽에서 버퍼에 모아 두었다가 잘라 내는 처리가 필요하다는 점도 함께 기억하세요.

 

7. 자주 하는 오해 바로잡기

→ 흔한 오해를 정리하고, 이번 강의 핵심과 17강에서 구현할 내용을 예고합니다.

더보기

패킷 경계에 대해 초급자가 자주 하는 오해를 정리합니다.

오해 올바른 정리
send() 한 번이면 recv() 한 번으로 정확히 받는다 TCP는 메시지 단위가 아니라 바이트 흐름이다. 메시지가 붙거나 나뉘어 도착할 수 있다
recv(1024)가 메시지 1024개를 받는다 1024는 개수가 아니라 바이트 수다. 최대 1024바이트까지 읽는다는 뜻이다
JSON으로 바꾸면 경계 문제가 자동으로 해결된다 JSON은 데이터 구조일 뿐, 네트워크 경계를 알려 주지 않는다. 경계 규칙은 따로 필요하다
메시지가 짧으면 항상 안전하다 짧은 메시지도 연속으로 보내면 붙을 수 있다. 크기와 무관하게 경계 규칙이 필요하다
순서가 뒤바뀌어 도착할 수도 있다 같은 소켓에서는 순서가 보장된다. 뒤바뀌지는 않고, 단지 경계가 어긋날 뿐이다

지금까지 실습에서 이 문제가 잘 안 보였던 이유도 짚어 둘 만합니다. 사람이 직접 타이핑하는 속도에서는 메시지 사이 간격이 충분해 경계 문제가 거의 드러나지 않습니다. 하지만 프로그램이 빠르게 여러 메시지를 연속으로 보내거나, 메시지가 커지거나, 파일 전송을 시작하면 문제가 드러납니다. 로컬에서 잘 됐던 것은 구조가 안전해서가 아니라 상황이 단순했기 때문입니다.

이번 강의에서 이해한 핵심을 정리합니다.

- TCP는 메시지 단위가 아니라 바이트 흐름(스트림)이다
- TCP는 순서는 보장하지만 메시지 경계는 보장하지 않는다
- 경계 처리는 TCP가 아니라 애플리케이션(내 코드)의 책임이다
- 경계 처리 방법: 고정 크기 / 구분자 / 길이 헤더
- 텍스트 채팅은 구분자(\n), 파일 전송은 길이 헤더가 적합하다

한 문장으로 정리하면, 메시지의 의미를 구분하려면 프로토콜(type·sender·content)이 필요하고, 메시지의 을 구분하려면 경계 처리 규칙(\n 구분자 또는 길이 헤더)이 필요합니다.

→ 다음 강의 (17강): 메시지 끝에 \n 구분자를 붙이는 방식으로 chat_server/server.pychat_client/client.py를 수정합니다. 받은 바이트를 버퍼에 누적했다가 \n 기준으로 잘라 내는 코드를 직접 구현해, 이번 강의에서 이해한 경계 문제를 실제로 해결합니다.

 

[과제] 직접 확인하기

→ socketpair()로 경계 문제를 직접 재현하고, 구분자로 메시지를 다시 나누는 처리를 손으로 확인합니다.

더보기

아래 과제는 모두 네트워크 설정 없이 파이썬 인터프리터(또는 짧은 .py 파일)에서 바로 실행해 볼 수 있습니다. 채팅 서버·클라이언트 파일은 수정하지 않습니다. 눈으로 결과를 확인하는 것이 목적이니, 한 줄씩 천천히 따라 하면 됩니다.

과제 1 — 한 메시지가 조각나서 읽히는 것 확인하기

3번에서는 두 메시지가 붙어서 도착하는 경우를 봤습니다. 이번에는 반대로, 메시지를 send()로 한 번에 보냈는데 받는 쪽에서는 여러 조각으로 나뉘어 읽히는 상황을 직접 만들어 봅니다. 방법은 간단합니다. recv()에 아주 작은 숫자를 넣으면 됩니다.

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

 

실행하면 다음처럼 한 메시지가 4바이트씩 잘려서 나옵니다.

b'hell'
b'o wo'

send()는 분명히 한 번만 호출했는데, 받는 쪽에서는 메시지 하나가 두 조각으로 나뉘어 들어왔습니다. 그리고 "rld" 부분은 아직 수신 버퍼에 남아 있어서, recv()를 한 번 더 부르면 그때 읽힙니다.

 

여기서 한 가지 짚을 점이 있습니다. 이 과제에서 메시지가 쪼개진 것은 네트워크가 알아서 쪼갠 것이 아니라, 우리가 recv(4)로 작게 잘라 읽었기 때문입니다. 실제 네트워크에서는 TCP가 알아서 조각내는 일이 일어나는데, 그 현상을 작은 recv()로 똑같이 흉내 낸 것입니다. 핵심은 같습니다. 한 번에 읽히는 단위는 보내는 쪽이 아니라 받는 쪽 사정에 따라 정해진다는 점입니다.

 

과제 2 — 붙어 온 메시지를 구분자로 다시 나누기

이번에는 짧은 메시지를 여러 번 보낸 뒤, 받는 쪽에서 그것을 다시 원래 개수대로 복원해 봅니다. 5.2에서 본 "메시지 끝에 \n을 붙이고, 받은 문자열을 \n으로 나눈다"는 아이디어를 그대로 실습하는 과제입니다. 같은 메시지 hello를 10번 보내고, recv()는 일부러 한 번만 호출합니다.

import socket

server, client = socket.socketpair()

for i in range(10):                     # 같은 짧은 메시지를 10번 보낸다
    client.send(b"hello\n")             # 메시지 끝마다 구분자 \n 을 붙인다

data = server.recv(1024)                # recv()는 단 한 번만 호출한다
print(data)                             # 10개가 어떤 모습으로 도착했는지 확인

text = data.decode("utf-8")             # 받은 바이트를 문자열로 바꾼다
messages = text.split("\n")             # 구분자 \n 을 기준으로 나눈다

count = 0
for message in messages:
    if message:                         # split 끝에 생기는 빈 문자열은 건너뛴다
        count += 1
        print(count, message)           # 몇 번째 메시지인지 번호와 함께 출력

server.close()
client.close()

먼저 print(data)에서 10개의 메시지가 한 덩어리로 붙어서 도착한 것을 볼 수 있습니다.

b'hello\nhello\nhello\nhello\nhello\nhello\nhello\nhello\nhello\nhello\n'

send()를 10번 호출했지만 recv() 한 번에 전부 붙어서 들어왔습니다. 이것이 바로 "메시지 붙음"입니다. 그다음 \n으로 나누면 다시 10개로 복원됩니다.

1 hello
2 hello
3 hello
4 hello
5 hello
6 hello
7 hello
8 hello
9 hello
10 hello

여기서 초급자가 자주 헷갈리는 부분을 짚어 둡니다. 마지막 hello 뒤에도 \n이 붙어 있기 때문에, split("\n")을 하면 맨 끝에 빈 문자열('') 하나가 더 생깁니다. 그래서 결과 목록의 길이는 10이 아니라 11입니다. 이 빈 문자열을 그대로 처리하면 빈 메시지가 하나 끼게 되므로, 코드에서 if message:로 걸러 낸 것입니다. 이 한 줄이 빈 메시지를 건너뛰는 역할을 합니다.

아직 "버퍼에 모아 두기"나 "미완성 메시지 처리" 같은 내용은 다루지 않았습니다. 그 부분은 17강에서 직접 구현하니 지금은 붙어 온 메시지를 구분자로 다시 나눌 수 있다는 것만 확인하면 충분합니다.

 

생각해 볼 문제

이번 문제는 코드를 작성하지 않고 개념으로 답해 봅니다. 채팅에 이어 이미지 파일 전송 기능을 추가한다면, 4번에서 본 세 가지 경계 처리 방법(고정 크기 · 구분자 · 길이 헤더) 중 무엇을 골라야 할까요? 그 이유를 한두 문장으로 설명해 보세요.

✔ 정답 방향: 과제 1은 send() 한 번이 recv() 여러 번으로 나뉘어 읽히는 것을 확인하면 성공입니다. 과제 2는 10번 보낸 메시지가 recv() 한 번에 붙어 도착하고, split("\n")으로 다시 10개로 복원되며, 끝의 빈 문자열을 if message:로 걸러야 한다는 점을 이해하면 완료입니다. 6.3은 길이 헤더 방식이 답입니다. 구분자(\n)는 텍스트 전용이라, 이미지 파일 안에 우연히 \n과 같은 바이트가 들어 있으면 그 지점을 메시지 끝으로 잘못 인식해 경계가 깨지기 때문입니다.