네트워크
Step 78. 소켓 통신 2 — 멀티 클라이언트 채팅 서버
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 77을 마쳤다. socket/bind/listen/accept/connect 흐름과 encode/decode를 안다. 파이썬 함수와 리스트를 능숙하게 다룬다.
- 준비물: 파이썬, 터미널 세 개 이상(서버 1 + 손님 2). Step 77의 echo 서버 코드를 옆에 두고 오세요.
- 주의: 오늘 실험도 전부 127.0.0.1, 내 컴퓨터 안에서만 일어나는 안전한 실습입니다.
지난 시간의 echo 서버에는 치명적인 제약이 있었습니다. 한 손님과 대화하는 동안 다른 손님은 문 밖에서 기다린다는 것. 진짜 서비스는 “직원을 접속마다 한 명씩 배치한다”는 발상으로 이를 풉니다. 프로그램 안의 그 직원이 스레드(thread, 실행 흐름)입니다. 오늘 우리는 스레드로 여러 클라이언트가 동시에 떠드는 채팅 서버를 만들고, 그 과정에서 “공유”가 낳는 함정 — 레이스 컨디션 — 도 직접 밟아 봅니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 스레드가 “같은 프로세스 안에서 메모리를 공유하는 실행 흐름”임을 설명한다
- 접속마다 스레드를 띄우는 서버 구조(문지기 + 전담 직원 + 명단)를 만든다
- broadcast(본낸 사람 빼고 전원에게)와 퇴장 처리를 구현한다
- 레이스 컨디션을 실험으로 재현하고, 왜 취약점의 씨앗인지 말한다
- “손님은 예고 없이 사라진다”를 전제로 예외 처리를 설계한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 표준 라이브러리 socket + threading (설치 불필요), 터미널 세 개 |
| 오늘의 함수 | threading.Thread(target=..., args=...), .start(), threading.active_count(), .join() |
| 필요한 개념 | 스레드, 공유 메모리, broadcast, 레이스 컨디션, daemon 스레드 |
| 오늘의 산출물 | chat_server.py + chat_client.py — 여럿이 동시에 떠드는 첫 다중 사용자 시스템 |
2-1. 스레드 — 한 프로그램 안의 여러 손
지금까지 우리의 프로그램은 “한 줄씩 위에서 아래로”, 실행 흐름이 하나였습니다. 스레드는 이 흐름을 여러 개로 복제하는 기술입니다. Step 68의 fork가 프로세스 통째를 복제했다면, 스레드는 같은 프로세스 안에서 실행 흐름만 나눕니다. 그래서 스레드들은 변수와 리스트를 서로 공유합니다 — 요리사 여러 명이 한 주방을 쓰는 그림입니다.
import threading
t = threading.Thread(target=함수, args=(재료,))
t.start()
“이 함수를 별도의 실행 흐름으로 시작해 줘”라는 명령입니다. start가 호출되면 그 함수는 본래 흐름과 동시에 돌아갑니다.
2-2. 채팅 서버의 설계도
오늘 만들 서버의 구조입니다.
- 메인 흐름은 accept만 반복합니다 — 손님이 올 때마다
- 접속한 손님의 소켓(conn)을 접속자 명단(리스트)에 올립니다
- 그 손님 전담 스레드를 하나 띄워 “이 손님의 말 듣기”를 맡깁니다
- 스레드는 손님이 말할 때마다 그 내용을 명단의 전원에게 전달(브로드캐스트, broadcast)합니다
- 손님이 나가면 명단에서 지웁니다
메인 흐름은 “문지기”, 스레드들은 “전담 직원”입니다. 문지기는 접객만 하고, 대화는 전부 직원들에게 넘어갑니다.
2-3. 레이스 컨디션 맛보기
스레드들이 같은 데이터를 동시에 만지면 어떻게 될까요? A 스레드가 “명단을 읽는 중”인데 B 스레드가 그 순간 명단에서 누군가를 지우면, A는 이미 없는 사람에게 말을 걸려다 실패합니다. 이렇게 실행 순서의 타이밍에 따라 결과가 달라지는 문제를 레이스 컨디션(race condition)이라 부릅니다. 3-6에서 직접 재현해 봅니다.
3. 따라 하기
3-1. 채팅 서버의 뼈대
chat_server.py를 만듭니다.
입력 (chat_server.py)
"""Five-client local classroom chat. No TLS, authentication, or persistence."""
import socket
import threading
"""Newline-framed UTF-8 for the owned loopback labs; not a production protocol."""
MAX_BYTES = 1024
def read_line(reader, limit=MAX_BYTES):
raw = reader.readline(limit + 2)
if not raw:
return None
if not raw.endswith(b"\n") or len(raw) - 1 > limit:
raise ValueError("missing delimiter or oversized message")
return raw[:-1].decode("utf-8", errors="strict")
def encode_line(text, limit=MAX_BYTES):
if "\n" in text or "\r" in text:
raise ValueError("one logical message per line")
data = text.encode("utf-8")
if len(data) > limit:
raise ValueError("message too long in UTF-8 bytes")
return data + b"\n"
clients = {} # socket -> {nickname, send_lock}
roster_lock = threading.Lock()
def send(conn, peer, message):
with peer["send_lock"]:
conn.sendall(encode_line(message, 1200))
def broadcast(message, sender=None):
with roster_lock:
targets = [(c, p) for c, p in clients.items() if c is not sender]
for conn, peer in targets:
try:
send(conn, peer, message)
except OSError:
# Wake the owner thread so its finally block removes the peer.
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def handle(conn, addr):
nickname = None
try:
conn.settimeout(30)
with conn.makefile("rb") as reader:
name = read_line(reader, 64)
if name is None:
return
nickname = name.strip() or f"guest-{addr[1]}"
with roster_lock:
clients[conn]["nickname"] = nickname
broadcast(f"{nickname} has joined", conn)
while (message := read_line(reader)) is not None:
broadcast(f"[{nickname}] {message}", conn)
except (OSError, ValueError, UnicodeError) as exc:
print("Peer ended:", type(exc).__name__, flush=True)
finally:
with roster_lock:
clients.pop(conn, None)
conn.close()
if nickname is not None:
broadcast(f"{nickname} has left")
def serve(host="127.0.0.1", port=9999, stop=None):
stop = stop or threading.Event()
workers = []
with socket.socket() as listener:
listener.bind((host, port))
listener.listen(5)
listener.settimeout(0.2)
print("Chat listening:", listener.getsockname(), flush=True)
try:
while not stop.is_set():
try:
conn, addr = listener.accept()
except socket.timeout:
continue
with roster_lock:
full = len(clients) >= 5
if not full:
clients[conn] = {"nickname": None,
"send_lock": threading.Lock()}
if full:
with conn:
conn.settimeout(1)
try:
conn.sendall(encode_line("The room is full"))
except OSError:
pass
continue
worker = threading.Thread(target=handle, args=(conn, addr))
worker.start()
workers = [w for w in workers if w.is_alive()] + [worker]
finally:
with roster_lock:
targets = list(clients)
for conn in targets:
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
for worker in workers:
worker.join(timeout=2)
if __name__ == "__main__":
try:
serve()
except KeyboardInterrupt:
print("Chat stopped")
아래 완성 로컬 예제는 프레이밍, 별명, 5개 연결 제한, 명단 잠금, 수신자별 송신 잠금을 포함합니다. 연결 수락과 완성된 메시지 처리를 구분해 살펴보세요. 블로킹 송신은 명단 잠금 밖에서 실행합니다.
왜: 이 구조 — 문지기 + 전담 직원 + 명단 — 은 게임 서버, 메신저 서버의 축소판입니다. 크기만 다를 뿐 뼈대는 같습니다.
3-2. 손님 만들기와 첫 채팅
이번에는 클라이언트도 “듣기와 말하기”를 동시에 해야 합니다. chat_client.py입니다.
입력 (chat_client.py)
import socket
import threading
"""Newline-framed UTF-8 for the owned loopback labs; not a production protocol."""
MAX_BYTES = 1024
def read_line(reader, limit=MAX_BYTES):
raw = reader.readline(limit + 2)
if not raw:
return None
if not raw.endswith(b"\n") or len(raw) - 1 > limit:
raise ValueError("missing delimiter or oversized message")
return raw[:-1].decode("utf-8", errors="strict")
def encode_line(text, limit=MAX_BYTES):
if "\n" in text or "\r" in text:
raise ValueError("one logical message per line")
data = text.encode("utf-8")
if len(data) > limit:
raise ValueError("message too long in UTF-8 bytes")
return data + b"\n"
def receive(conn):
try:
with conn.makefile("rb") as reader:
while (message := read_line(reader, 1200)) is not None:
print(message, flush=True)
except (OSError, ValueError, UnicodeError) as exc:
print("Receiver ended:", type(exc).__name__)
def main():
with socket.create_connection(("127.0.0.1", 9999), timeout=30) as conn:
conn.sendall(encode_line(input("Nickname: "), 64))
worker = threading.Thread(target=receive, args=(conn,), daemon=True)
worker.start()
try:
while worker.is_alive():
message = input("> ")
if message == "quit":
break
conn.sendall(encode_line(message))
finally:
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
worker.join(timeout=2)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, UnicodeError, KeyboardInterrupt, EOFError) as exc:
print("Client ended:", type(exc).__name__)
입력: 터미널 1에서 서버 실행 → 터미널 2, 3에서 각각 클라이언트 실행 → 양쪽에서 번갈아 입력.
출력 (2026-09-09 실측 — 손님 A가 먼저 입장해 “안녕하세요, A입니다”를 말하고, 이후 손님 B가 입장해 “반갑습니다, B입니다”, “잘 가세요”라고 말한 상황):
손님 A의 화면:
[127.0.0.1:52429] 반갑습니다, B입니다
[127.0.0.1:52429] 잘 가세요
서버 화면:
채팅 서버 열림 (포트 9999)
[입장] 127.0.0.1:52428
현재 스레드 수: 2
[127.0.0.1:52428] 안녕하세요, A입니다
[입장] 127.0.0.1:52429
현재 스레드 수: 3
[127.0.0.1:52429] 반갑습니다, B입니다
[127.0.0.1:52429] 잘 가세요
[퇴장] 127.0.0.1:52428
[퇴장] 127.0.0.1:52429
읽는 법: 세 가지를 눈여겨보세요. ① B가 말한 내용이 A의 화면에 도착했습니다 — broadcast가 일했습니다. ② 반대로 A가 먼저 말한 “안녕하세요”는 B의 화면에 없습니다 — B가 입장하기 전의 말이라 B에게 갈 이유가 없었습니다. ③ 접속 하나에 스레드가 하나씩 늘어 2(메인 + A), 3(메인 + A + B)이 됐습니다.
왜: 클라이언트도 스레드를 씁니다. listen 스레드가 “서버에서 오는 말 듣기”를 전담하는 동안, 메인 흐름은 키보드 입력을 받습니다. daemon=True는 “메인이 끝나면 같이 죽는 보조 스레드”라는 표시입니다. 여러분은 방금 “보내기와 받기가 동시에 일어나는” 프로그램을 만들었습니다.
3-3. 예측해 보기 — 본낸 사람에게도 메아리가 갈까
예측입니다. broadcast에서 if c is not sender: 조건을 지우면(본인에게도 보내게 되면), 말한 사람의 화면은 어떻게 될까요? 예측 후 지워서 확인해 보세요.
직접 확인 (2026-09-09 실측): 조건을 끄고 A가 “안녕하세요”를 말하자, A의 화면에도 이렇게 찍혔습니다.
A 화면: [127.0.0.1:60022] 안녕하세요
읽는 법: 입력한 것과 전달받은 것이 이중으로 보여 어색합니다. 그래서 broadcast는 보통 본낸 사람을 제외합니다.
왜: “누구에게 보낼 것인가”의 설계가 사용자 경험을 가른다는 작은 실험입니다. 서버 개발은 통신 기술이면서 동시에 대화의 디자인입니다.
3-4. 예측해 보기 — 한 명이 강제 종료하면
예측입니다. 채팅 중인 클라이언트 하나를 Ctrl+C로 강제 종료하면, 서버는 어떻게 될까요? ① 서버도 같이 죽는다 ② 그 손님만 명단에서 사라진다 ③ 다른 손님들도 튕긴다.
직접 확인: 실험해 보면 서버 로그에 “[퇴장]”이 찍히고 나머지 손님들은 멀쩡히 대화를 계속합니다. 3-2의 실측 로그에서도 두 손님의 퇴장이 각각 기록됐습니다. handle 스레드의 try/except와 “연결 끊김 = 빈 데이터” 규칙이 퇴장 처리를 해 준 덕분입니다.
왜: “손님은 예고 없이 사라진다”를 전제로 설계하는 것이 서버의 기본 자세입니다. 네트워크의 상대는 정전, 강제 종료, 통신 두절 등 온갖 방식으로 사라집니다. 예외 처리가 곧 서버의 생존 본능입니다.
3-5. 레이스 컨디션 체험 1 — 생각보다 안 터진다
함정을 직접 밟아 봅니다. race.py를 만드세요.
입력 (race.py)
import threading
counter = 0
def work():
global counter
for _ in range(100000):
counter += 1
threads = [threading.Thread(target=work) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("결과:", counter)
입력: 여러 번 실행해 보세요.
python race.py; python race.py; python race.py
출력 (2026-09-09 실측, 다섯 번 실행 — 전부 같은 결과라 세 줄만 표시):
결과: 1000000
결과: 1000000
결과: 1000000
이 과거 실행에서는 예상 합계가 나왔지만 스레드 안전성이 입증된 것은 아닙니다. 스케줄링, Python 구현·버전, GIL 유무가 결과에 영향을 줍니다. race2.py처럼 읽기·수정·쓰기를 명시적으로 나누면 갱신 손실을 관찰할 수 있습니다. 공유 상태의 전체 불변 조건을 잠금으로 보호하세요.
3-6. 레이스 컨디션 체험 2 — 틈을 벌려 주면 터진다
같은 코드인데 “읽기 → 더하기 → 쓰기” 사이의 틈을 일부러 벌려 봅니다. race2.py입니다.
입력 (race2.py)
import threading, time
counter = 0
def work():
global counter
for _ in range(20000):
v = counter # 읽기
time.sleep(0) # 다른 스레드에게 기회를 양보
counter = v + 1 # 더한 값 쓰기
threads = [threading.Thread(target=work) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("정답은 200000, 결과:", counter)
출력 (2026-09-09 실측, 세 번 실행):
정답은 200000, 결과: 20575
정답은 200000, 결과: 20489
정답은 200000, 결과: 20607
기록된 race2.py 실행에서는 먼저 증가한 값을 쓰기 전에 다른 스레드가 이전 값을 읽어 갱신이 사라졌습니다. 이 예제는 동작을 명시적으로 나누고 중간에 실행 기회를 양보합니다. 기록된 숫자는 예시이며 정답 출력값이 아닙니다. 스케줄에 따라 합계가 달라질 수 있습니다.
왜: 채팅 서버의 clients 리스트에서도 같은 일이 일어날 수 있습니다. 그래서 실전에서는 lock(잠금)으로 “한 번에 한 스레드만”을 강제합니다. 그리고 이 원리가 운영체제나 서버 프로그램에서 터지면 권한 상승으로 이어지는 취약점이 됩니다. 오늘은 장난 같은 숫자 오류이지만, 같은 구조가 진지한 공격 소재라는 것을 기억하세요.
4. 미션과 연습문제
미션 — 채팅 서버 완성도 올리기
- 닉네임: 입장 후 첫 메시지를 닉네임으로 삼아, 이후 “[닉네임] 메시지” 형태로 방송되게 바꿉니다
- 입퇴장 알림: 누군가 들어오거나 나갈 때 전원에게 “○○님이 입장했습니다”를 방송합니다
- 인원 제한: 동시 접속을 5명으로 제한하고, 초과 시 “방이 찼습니다”를 보내고 끊습니다
- lock 적용: clients 리스트를 만지는 곳(추가/삭제/방송)에 threading.Lock()을 걸고, 걸기 전후의 차이를 주석으로 정리합니다
- 회고: race2.py 실험 결과를 바탕으로 “왜 채팅 서버에 lock이 필요한가”를 세 문장으로 씁니다
연습문제
문제 1. 스레드와 프로세스의 차이를 “공유하는 것”의 관점에서 설명해 보세요.
문제 2. 채팅 서버의 메인 흐름이 accept만 하고 대화는 스레드에 맡기는 이유를, 메인이 직접 대화할 때 생기는 문제와 함께 설명해 보세요.
문제 3. 3-2 실측에서 A가 먼저 말한 내용이 B의 화면에 없었던 이유는 무엇인가요? 이것은 버그인가요, 설계인가요?
문제 4. race.py(백만 정답)와 race2.py(결과 붕괴)의 차이를 볼 때, 레이스 컨디션 버그가 테스트에서 발견되기 어려운 이유를 말해 보세요.
해설과 완료 기준 · 펼치기/접기
5. 모범 답안과 완료 기준
미션 모범 답안
닉네임과 입퇴장 알림, lock이 들어간 서버의 뼈대:
"""Five-client local classroom chat. No TLS, authentication, or persistence."""
import socket
import threading
"""Newline-framed UTF-8 for the owned loopback labs; not a production protocol."""
MAX_BYTES = 1024
def read_line(reader, limit=MAX_BYTES):
raw = reader.readline(limit + 2)
if not raw:
return None
if not raw.endswith(b"\n") or len(raw) - 1 > limit:
raise ValueError("missing delimiter or oversized message")
return raw[:-1].decode("utf-8", errors="strict")
def encode_line(text, limit=MAX_BYTES):
if "\n" in text or "\r" in text:
raise ValueError("one logical message per line")
data = text.encode("utf-8")
if len(data) > limit:
raise ValueError("message too long in UTF-8 bytes")
return data + b"\n"
clients = {} # socket -> {nickname, send_lock}
roster_lock = threading.Lock()
def send(conn, peer, message):
with peer["send_lock"]:
conn.sendall(encode_line(message, 1200))
def broadcast(message, sender=None):
with roster_lock:
targets = [(c, p) for c, p in clients.items() if c is not sender]
for conn, peer in targets:
try:
send(conn, peer, message)
except OSError:
# Wake the owner thread so its finally block removes the peer.
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def handle(conn, addr):
nickname = None
try:
conn.settimeout(30)
with conn.makefile("rb") as reader:
name = read_line(reader, 64)
if name is None:
return
nickname = name.strip() or f"guest-{addr[1]}"
with roster_lock:
clients[conn]["nickname"] = nickname
broadcast(f"{nickname} has joined", conn)
while (message := read_line(reader)) is not None:
broadcast(f"[{nickname}] {message}", conn)
except (OSError, ValueError, UnicodeError) as exc:
print("Peer ended:", type(exc).__name__, flush=True)
finally:
with roster_lock:
clients.pop(conn, None)
conn.close()
if nickname is not None:
broadcast(f"{nickname} has left")
def serve(host="127.0.0.1", port=9999, stop=None):
stop = stop or threading.Event()
workers = []
with socket.socket() as listener:
listener.bind((host, port))
listener.listen(5)
listener.settimeout(0.2)
print("Chat listening:", listener.getsockname(), flush=True)
try:
while not stop.is_set():
try:
conn, addr = listener.accept()
except socket.timeout:
continue
with roster_lock:
full = len(clients) >= 5
if not full:
clients[conn] = {"nickname": None,
"send_lock": threading.Lock()}
if full:
with conn:
conn.settimeout(1)
try:
conn.sendall(encode_line("The room is full"))
except OSError:
pass
continue
worker = threading.Thread(target=handle, args=(conn, addr))
worker.start()
workers = [w for w in workers if w.is_alive()] + [worker]
finally:
with roster_lock:
targets = list(clients)
for conn in targets:
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
for worker in workers:
worker.join(timeout=2)
if __name__ == "__main__":
try:
serve()
except KeyboardInterrupt:
print("Chat stopped")
핵심 포인트 세 가지: ① 명단을 사전으로 바꿔 닉네임을 함께 기억합니다. ② with lock: 안에서 명단을 만지고, 방송 대상 목록을 먼저 복사해 둔 뒤 잠금 밖에서 sendall합니다 — 잠금을 오래 쥐고 있으면 다른 스레드가 멈추니까요. ③ finally로 퇴장 처리를 해서 어떤 경로로 끝나도 명단이 정리되게 합니다.
5번 회고의 예: “race2.py는 읽기와 쓰기 사이에 다른 스레드가 끼어들어 덧셈이 증발했다. 채팅 서버의 clients도 방송 도중 다른 스레드가 수정하면 같은 구조다. lock은 ‘읽고 쓰는 동안 아무도 끼어들지 못한다’를 보장하는 장치다.”
검증하는 법: ① 닉네임으로 방송되는가. ② 6번째 접속이 “방이 찼습니다”를 받고 끊기는가. ③ 강제 종료해도 퇴장 알림이 가고 서버가 사는가. ④ 회고 세 문장이 race2 실험 수치를 언급하는가. 넷이 전부 ‘예’이면 완성입니다.
연습문제 해답
문제 1 해답. 프로세스는 각자 독립된 메모리(변수, 리스트)를 갖지만, 스레드는 같은 프로세스 안의 메모리를 공유합니다. 그래서 스레드는 서로의 변수를 그대로 보고 바꿀 수 있습니다 — 편리하지만 동시에 만지면 꼬이는 이유이기도 합니다.
문제 2 해답. 메인 흐름이 직접 대화(recv 반복)에 들어가면 그 손님과의 대화가 끝날 때까지 accept로 돌아오지 못해, 다음 손님의 접속을 받지 못합니다. accept에만 전념하고 대화는 접속마다 새 스레드에 맡기면, 새 손님은 언제든 입장할 수 있습니다.
문제 3 해답. broadcast는 “지금 명단에 있는 사람들”에게만 보냅니다. A가 말한 시점에 B는 아직 입장 전이라 명단에 없었으므로 못 받았습니다. 버그가 아니라 설계입니다 — 대부분의 채팅 서비스도 입장 전 대화는 보여 주지 않거나(이 설계), 따로 저장했다가 보여 줍니다(다른 설계).
문제 4 해답. 레이스 컨디션은 “타이밍이 맞을 때만” 터집니다. race.py처럼 충돌 창이 좁으면 수만 번 실행해도 정답이 나와 테스트를 통과하고, race2처럼 창이 넓어져야 드러납니다. 실전의 부하(느린 디스크, 많은 사용자)가 그 창을 넓히기 때문에, “테스트에서는 멀쩡했는데”라는 사고가 생깁니다.
완료 기준 체크리스트
- [ ] 스레드가 무엇인지(공유 메모리를 갖는 실행 흐름) 설명할 수 있다
- [ ] 접속마다 스레드를 띄우는 서버 구조를 만들 수 있다
- [ ] broadcast와 퇴장 처리를 구현할 수 있다
- [ ] 레이스 컨디션을 race2.py로 재현하고 비결정성을 관찰했다
- [ ] 2명 이상 동시 접속 채팅을 성공시켰다
- [ ] 미션: 닉네임·입퇴장 알림·lock까지 완성했다
6. 흔한 실수와 해결
벽 1. 한 명이 말하면 서버가 멈춘다
증상: 첫 손님과는 대화가 되는데 둘째 손님이 접속을 못 합니다.
원인: handle을 스레드로 띄우지 않고 메인 흐름에서 직접 호출했습니다. 메인이 대화에 묶여 accept로 돌아가지 못합니다.
해결: 메인 while 안에서는 반드시 threading.Thread(...).start()만 하세요. start는 “띄우고 즉시 다음으로”이고, 직접 호출은 “끝날 때까지 기다림”입니다.
벽 2. 퇴장한 손님에게 보내려다 오류가 난다
증상: 누가 나간 뒤 sendall에서 예외가 터집니다.
원인: 끊긴 연결에 말을 걸었습니다. 명단 정리가 방송보다 늦었기 때문입니다.
해결: 모범 답안처럼 잠금 안에서 방송 대상 목록을 먼저 복사하고, 실패한 연결은 퇴장 처리에서 제거하세요. broadcast 안의 try/except는 최소한의 안전핀입니다.
벽 3. 메시지가 뭉개져서 도착한다
TCP는 한 번의 전송을 나누거나 여러 전송을 합칠 수 있습니다. 양쪽이 같은 프레이밍 규칙을 구현해야 합니다. 교정 예제는 UTF-8 메시지를 줄바꿈으로 구분하고 인코딩된 줄 길이를 제한하며 완성된 줄만 디코딩합니다. 길이 접두사는 다른 선택지이며 올바른 프레이밍의 필수 조건은 아닙니다.
벽 4. 서버를 끄려는데 안 꺼진다
Ctrl+C는 수신 대기를 중단합니다. finally에서 활성 소켓을 종료해 읽기 스레드가 빠져나와 합류할 수 있게 합니다. 유휴 연결에도 timeout이 있습니다. 직접 실행한 실습 프로세스만 멈추고, 포트를 차지했다는 이유로 정체를 모르는 프로세스를 종료하지 마세요.
벽 5. 레이스 컨디션이 재현이 안 된다
증상: race.py가 계속 정답(1000000)만 냅니다.
원인: 버그가 없는 것이 아니라 충돌 창이 좁은 것입니다 (2026-09-09 실측에서도 다섯 번 모두 정답이었습니다).
해결: race2.py처럼 읽기와 쓰기 사이에 time.sleep(0)를 넣어 틈을 벌리면 붕괴가 보입니다 (2026-09-09 실측: 20575, 20489, 20607). “재현이 안 된다”와 “안전하다”는 다르다는 것 — 이것이 이 벽의 교훈입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 스레드(thread) | 한 프로세스 안의 여러 실행 흐름 — 메모리를 공유 |
| daemon 스레드 | 메인이 끝나면 같이 죽는 보조 스레드 |
| broadcast | 본낸 사람을 빼고 접속자 전원에게 전달 |
| 레이스 컨디션 | 실행 순서의 타이밍에 따라 결과가 달라지는 버그 — 취약점의 씨앗 |
| lock(잠금) | “한 번에 한 스레드만” 공유 데이터를 만지게 하는 장치 |
| 비결정성 | 실행할 때마다 결과가 다른 것 — 동시성 버그의 진단 단서 |
오늘의 함수
| 함수 | 하는 일 |
|---|---|
threading.Thread(target=함수, args=(재료,)) |
새 실행 흐름 준비 |
.start() / .join() |
흐름 시작 / 끝날 때까지 기다리기 |
threading.active_count() |
살아 있는 스레드 수 세기 |
threading.Lock() / with lock: |
공유 데이터의 동시 접근 잠그기 |
time.sleep(0) |
다른 스레드에게 실행 기회 양보(실험용) |
명령어보다 중요한 감각
오늘의 clients 리스트처럼 여러 실행 흐름이 공유하는 데이터는, 편리함의 대가로 “동시성 버그”라는 빚을 짊어집니다. 이 버그가 메신저에서 일어나면 메시지가 엉키지만, 인증 서버에서 일어나면 다른 사람으로 로그인되는 사고가 됩니다. 레이스 컨디션 취약점은 CTF의 단골 소재이기도 합니다. 모든 공격 실습은 내 랩·합법 플랫폼에서만 — 오늘의 채팅도 전부 127.0.0.1에서만 일어났습니다.
그리고 서버 화면의 출력을 다시 보세요. 누가 들어오고, 무엇을 말하고, 나갔는지가 시간 순으로 전부 기록되어 있습니다. 여러분은 오늘 로그의 주인이 됐습니다 — 메신저의 대화가 서비스 회사에 남는 이유, 그리고 로그가 침해사고 조사의 보물창고인 이유를 운영자 자리에서 체감한 것입니다. “서버는 다 본다”는 사실, 그리고 “공유하는 자는 잠글 책임이 있다”는 원칙. 이 둘이 오늘의 진짜 수확입니다.
전부 체크되면 Step 78 완료입니다.
한 단계 더 가까이
수업을 마쳤나요?
본문의 완료 기준을 확인한 뒤 직접 표시하세요.