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)
import socket
import threading
clients = [] # 접속자 명단 (모든 스레드가 공유)
def handle(conn, addr):
name = addr[0] + ":" + str(addr[1])
print("[입장]", name, flush=True)
while True:
try:
data = conn.recv(1024)
if not data:
break
msg = data.decode()
print(f"[{name}] {msg.strip()}", flush=True)
broadcast(f"[{name}] {msg}", conn)
except Exception:
break
clients.remove(conn)
conn.close()
print("[퇴장]", name, flush=True)
def broadcast(message, sender):
for c in clients:
if c is not sender:
try:
c.sendall(message.encode())
except Exception:
pass
s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen()
print("채팅 서버 열림 (포트 9999)", flush=True)
while True:
conn, addr = s.accept()
clients.append(conn)
threading.Thread(target=handle, args=(conn, addr)).start()
print("현재 스레드 수:", threading.active_count(), flush=True)
읽는 법: 메인 흐름(아래쪽 while)은 접속을 받아 명단에 올리고 스레드를 띄우기만 합니다. 대화는 전부 handle 스레드의 몫입니다. broadcast는 "본낸 사람 빼고 전원에게" 돌립니다. try/except가 곳곳에 있는 것은, 손님이 언제 갑자기 나가도 서버가 죽지 않게 하기 위한 안전장치입니다.
왜: 이 구조 — 문지기 + 전담 직원 + 명단 — 은 게임 서버, 메신저 서버의 축소판입니다. 크기만 다를 뿐 뼈대는 같습니다.
3-2. 손님 만들기와 첫 채팅
이번에는 클라이언트도 "듣기와 말하기"를 동시에 해야 합니다. chat_client.py입니다.
입력 (chat_client.py)
import socket
import threading
s = socket.socket()
s.connect(("127.0.0.1", 9999))
def listen():
while True:
try:
data = s.recv(1024)
if not data:
break
print(data.decode().strip())
except Exception:
break
threading.Thread(target=listen, daemon=True).start()
print("채팅방 입장! 종료하려면 quit 입력")
while True:
msg = input()
if msg == "quit":
break
s.sendall(msg.encode())
s.close()
입력: 터미널 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
읽는 법: 10개의 스레드가 각각 10만 번 더하니 정답 백만이 계속 나옵니다. 어? 레이스 컨디션이라며? — 그렇게 느끼셨다면 정상입니다. 파이썬은 스레드를 아주 짧은 간격으로만 교체하는데, counter += 1 한 번은 그 간격보다 훨씬 빨리 끝나서 충돌이 잘 안 일어납니다. 타이밍에 따라 결과가 달라지는 버그는 "잘 안 재현되는 것"이 특징입니다. 이것이 레이스 컨디션이 무서운 이유입니다 — 테스트에서는 멀쩡하다가 실전에서 한 번씩 터지니까요.
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
읽는 법: 정답 20만의 10% 남짓만 남았습니다. counter += 1은 사실 "읽기 → 더하기 → 쓰기" 세 동작인데, 한 스레드가 읽고 쓰는 사이에 다른 스레드가 끼어들면 덧셈이 증발합니다. sleep(0)이 그 틈을 넓혀 줬을 뿐, 버그의 구조는 3-5와 같습니다. 그리고 실행할 때마다 결과가 다른 것(20575, 20489, 20607)을 보세요 — 이 비결정성 자체가 진단의 단서입니다.
왜: 채팅 서버의 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이 들어간 서버의 뼈대:
import socket
import threading
clients = {} # 소켓 -> 닉네임
lock = threading.Lock()
def broadcast(message, sender=None):
with lock:
targets = [(c, n) for c, n in clients.items() if c is not sender]
for c, _ in targets:
try:
c.sendall(message.encode())
except Exception:
pass
def handle(conn, addr):
try:
nickname = conn.recv(1024).decode().strip() or str(addr[1])
with lock:
if len(clients) >= 5:
conn.sendall("방이 찼습니다".encode())
conn.close()
return
clients[conn] = nickname
broadcast(f"{nickname}님이 입장했습니다", conn)
while True:
data = conn.recv(1024)
if not data:
break
broadcast(f"[{nickname}] {data.decode().strip()}", conn)
except Exception:
pass
finally:
with lock:
nickname = clients.pop(conn, None)
conn.close()
if nickname:
broadcast(f"{nickname}님이 퇴장했습니다")
s = socket.socket()
s.bind(("0.0.0.0", 9999))
s.listen()
print("채팅 서버 열림 (포트 9999)", flush=True)
while True:
conn, addr = s.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
핵심 포인트 세 가지: ① 명단을 사전으로 바꿔 닉네임을 함께 기억합니다. ② 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는 "편지 묶음"이 아니라 "흐름"입니다. 본낸 횟수와 받는 횟수가 일치한다는 보장이 없습니다.
해결: 오늘 수준에서는 메시지 끝에 줄바꿈을 붙이고 받는 쪽에서 줄 단위로 끊어 읽는 것으로 충분합니다. 나중에 "길이를 먼저 보내고 내용을 보내는" 규약(프레이밍)을 배우면 정확해집니다.
벽 4. 서버를 끄려는데 안 꺼진다
증상: Ctrl+C를 눌러도 반응이 늦거나, 다시 켰더니 "주소가 이미 사용 중"(Step 77의 WinError 10048)입니다.
원인: accept/recv에서 멈춰 있는 동안 인터럽트 처리가 늦어질 수 있고, 살아 있는 스레드가 남아 있기 때문입니다.
해결: 스레드에 daemon=True를 주고, 서버는 확인 후 확실히 종료하세요. 포트가 남아 있으면 netstat으로 PID를 찾아 정리합니다.
벽 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.