12강. 접속자 목록 관리

0. 학습 목표
→ 이번 글에서 무엇을 이해하고, 무엇을 만들고, 무엇을 확인할지 먼저 정리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
11강에서 여러 클라이언트가 동시에 접속하고 각자 메시지를 주고받는 서버를 만들었습니다. 하지만 그 서버에는 중요한 빈자리가 하나 있었습니다. 서버가 현재 접속 중인 클라이언트들을 따로 기억하지 못한다는 것입니다. 이 상태로는 다음 질문에 답하기 어렵습니다.
지금 몇 명이 접속 중인가?
어떤 클라이언트 소켓들이 연결되어 있는가?
A가 보낸 메시지를 B와 C에게 전달하려면 누구에게 보내야 하는가?
이번 강의에서는 서버 안에 clients 리스트를 만들고, 클라이언트가 접속하면 추가하고 종료하면 제거하는 구조를 구현합니다. 아직 메시지를 전체에게 전달하지는 않습니다. 이번 강의의 목표는 "목록 추가와 제거가 정확히 되는지" 확인하는 것입니다.
클라이언트 A 접속 → clients에 A 소켓 추가
클라이언트 B 접속 → clients에 B 소켓 추가
클라이언트 A 종료 → clients에서 A 소켓 제거
이번 강의에서 완성하는 clients 리스트는 단순한 접속자 수 확인용이 아닙니다. 13강의 브로드캐스팅 기능에서 "누구에게 메시지를 보낼지"를 결정하는 핵심 기반이 됩니다.

| 구분 | 내용 |
| 이해할 것 | 서버가 접속한 클라이언트 소켓을 목록으로 관리해야 하는 이유 |
| 만들 것 | 접속 시 추가되고 종료 시 제거되는 clients 리스트, print_clients() 함수 |
| 확인할 것 | 클라이언트가 접속하거나 종료할 때 서버 출력의 접속자 수가 정확히 바뀌는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 11강에서 만든 chat_server/server.py를 수정합니다. chat_client/client.py는 11강의 테스트용 클라이언트를 그대로 사용합니다.
chat_server/
└── server.py ← 이번 강의에서 수정 (clients 리스트·print_clients() 추가)
chat_client/
└── client.py ← 11강에서 만든 파일 그대로 사용
이번 강의에서 server.py에 추가되는 구조입니다.
chat_server/server.py
├── clients = [] ← 이번 강의에서 추가 (접속자 소켓 목록)
├── print_clients() ← 이번 강의에서 추가 (접속자 수 출력)
├── handle_client()
│ ├── 기존: 메시지 처리 반복문
│ └── 추가: 종료 시 clients에서 제거 + print_clients()
└── 메인 서버 루프
├── 기존: accept() 반복 + 스레드 생성
└── 추가: 접속 시 clients에 추가 + print_clients()
1. 실습 준비하기
→ 11강 서버의 한계를 확인하고, 이번 강의에서 추가할 내용을 정리합니다.
11강에서 만든 서버가 정상 실행되는지 먼저 확인합니다.
python chat_server/server.py
스레드 기반 서버를 시작합니다.
클라이언트 접속 대기 중...
정상 실행을 확인했으면 Ctrl + C로 서버를 종료합니다. 이번 강의에서는 server.py 파일을 수정하면서 진행합니다.
11강 서버에 없는 것을 정리하면 다음과 같습니다.
| 없는 것 | 이번 강의에서 추가 |
| 현재 접속자 목록 | clients = [] |
| 접속 시 목록 추가 | clients.append(client_socket) |
| 종료 시 목록 제거 | clients.remove(client_socket) |
| 접속자 수 확인 | print_clients() |
✔ 확인 기준: 11강에서 만든 서버가 정상 실행되면 완료. 이번 강의 수정을 시작할 준비가 된 것입니다. 포트 5000이 다른 프로그램에 점유되어 있지 않은지 확인하세요.
2. 서버 - clients 리스트와 print_clients() 만들기
→ 공유 접속자 목록과 접속자 수 출력 함수를 추가합니다.
2.1 clients 리스트 추가하기
chat_server/server.py를 열고 HOST, PORT 아래에 clients 리스트를 추가합니다.
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = [] # 현재 접속 중인 클라이언트 소켓 목록
서버가 처음 실행될 때는 아무도 접속하지 않았으므로 빈 리스트입니다. 클라이언트가 접속하거나 종료할 때마다 이 리스트가 바뀝니다.
서버 시작: clients = []
A 접속 후: clients = [A 소켓]
B 접속 후: clients = [A 소켓, B 소켓]
A 종료 후: clients = [B 소켓]
clients를 함수 안에 정의하면 스레드마다 별도 리스트가 생겨 공유가 되지 않습니다. 반드시 함수 바깥, 파일 상단에 정의해야 합니다.
2.2 print_clients() 함수 추가하기
clients 리스트 아래에 접속자 수를 출력하는 함수를 추가합니다.
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = [] # 현재 접속 중인 클라이언트 소켓 목록
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
| 코드 | 의미 |
clients |
현재 접속 중인 클라이언트 소켓 목록 |
len(clients) |
현재 접속자 수 |
print_clients() |
접속자 수를 화면에 출력하는 함수. 접속·종료가 일어나는 곳마다 호출해 목록이 의도대로 바뀌는지 확인하는 역할 |
✔ 확인 기준: clients = []와 print_clients()가 handle_client() 함수 위에, 파일 상단에 있으면 완료. clients 리스트가 함수 안에 정의되어 있지 않은지 확인하세요.
3. 서버 - 접속 시 추가, 종료 시 제거 연결하기
→ accept() 직후에 append(), handle_client() 종료 직후에 remove()를 연결합니다.
3.1 handle_client()에 종료 시 제거 코드 추가하기
handle_client() 함수의 with client_socket: 블록이 끝난 뒤, 즉 반복문이 완전히 종료된 직후에 제거 코드를 추가합니다. 이번에 추가된 줄은 with 블록 바깥에 있다는 점이 중요합니다.
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
with client_socket:
while True:
data = client_socket.recv(1024)
if not data:
print(f"클라이언트 연결 끊김: {client_address}")
break
message = data.decode("utf-8")
print(f"{client_address} 메시지: {message}")
if message == "exit":
print(f"클라이언트 종료 요청: {client_address}")
break
response = f"서버가 받은 메시지: {message}"
client_socket.send(response.encode("utf-8"))
# with 블록이 끝난 뒤 — 소켓은 이미 닫혔고, 목록에서 제거
if client_socket in clients: # 이미 제거된 소켓을 다시 제거하려 할 때 ValueError 방지
clients.remove(client_socket) # 종료된 클라이언트를 목록에서 제거
print(f"접속자 목록에서 제거: {client_address}")
print_clients()
print(f"클라이언트 처리 종료: {client_address}")
제거 코드 위치에 주의합니다. with client_socket: 블록 안이 아니라 바깥에 있어야 합니다.
블록 안에서 제거하면 소켓이 아직 열려 있는 상태에서 목록이 줄어들어 나중에 브로드캐스팅할 때 문제가 생길 수 있습니다.
if client_socket in clients: 조건을 먼저 확인하는 이유가 있습니다. exit 입력과 연결 끊김이 거의 동시에 일어나는 드문 상황에서 같은 소켓을 두 번 제거하려 하면 ValueError가 발생합니다. 조건을 먼저 확인하면 이 문제를 예방할 수 있습니다.
clients.remove(client_socket)은 리스트에서 해당 객체를 찾아 제거합니다. 같은 주소에서 접속한 클라이언트가 여럿 있더라도, 소켓 객체 자체가 다르기 때문에 정확히 해당 클라이언트만 제거됩니다. 나중에 브로드캐스팅할 때 이 구조가 맞게 작동하려면 append()와 remove()에서 같은 client_socket 객체를 사용해야 합니다.
3.2 메인 루프에 접속 시 추가 코드 연결하기
while True 반복문 안에서 accept() 직후, 스레드를 시작하기 전에 append() 코드를 추가합니다.
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
with client_socket:
while True:
data = client_socket.recv(1024)
if not data:
print(f"클라이언트 연결 끊김: {client_address}")
break
message = data.decode("utf-8")
print(f"{client_address} 메시지: {message}")
if message == "exit":
print(f"클라이언트 종료 요청: {client_address}")
break
response = f"서버가 받은 메시지: {message}"
client_socket.send(response.encode("utf-8"))
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) # 접속 시 목록에 추가 — start() 전에 실행
print(f"접속자 목록에 추가: {client_address}")
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.start()
clients.append(client_socket)은 스레드를 시작하기 전에 해야 합니다. 스레드가 먼저 시작되면 아주 드물게 handle_client()가 종료 처리를 하려는 순간 아직 append()가 실행되지 않은 상태가 될 수 있습니다.
accept() ← 새 접속 받음
clients.append() ← 목록에 추가 (start() 전에)
client_thread.start() ← 스레드 시작
✔ 확인 기준: python chat_server/server.py 실행 후 "접속자 목록 관리 서버를 시작합니다." 출력과 함께 대기 상태가 되면 완료. clients.append()가 client_thread.start() 앞에 있는지, clients 리스트가 함수 바깥에 정의되어 있는지 확인하세요.
4. 실행 결과 확인하기
→ 서버 1개와 클라이언트 2개를 실행해 접속자 수 변화를 확인합니다.
4.1 서버 실행 및 클라이언트 2개 접속하기
터미널 1에서 서버를 실행합니다.
python chat_server/server.py
접속자 목록 관리 서버를 시작합니다.
클라이언트 접속 대기 중...
터미널 2에서 첫 번째 클라이언트를 실행합니다.
python chat_client/client.py
서버 터미널에 다음 출력이 추가됩니다.
클라이언트 접속: ('127.0.0.1', 52344)
접속자 목록에 추가: ('127.0.0.1', 52344)
현재 접속자 수: 1
클라이언트 처리 시작: ('127.0.0.1', 52344)
터미널 3에서 두 번째 클라이언트를 실행합니다.
python chat_client/client.py
서버 터미널에 다음 출력이 추가됩니다.
클라이언트 접속: ('127.0.0.1', 52345)
접속자 목록에 추가: ('127.0.0.1', 52345)
현재 접속자 수: 2
클라이언트 처리 시작: ('127.0.0.1', 52345)
4.2 클라이언트 종료 확인하기
클라이언트 A 터미널(터미널 2)에서 exit을 입력합니다.
보낼 메시지: exit
서버와의 연결을 종료합니다.
클라이언트 소켓을 닫았습니다.
서버 터미널에서 접속자 수가 1로 줄어야 합니다.
('127.0.0.1', 52344) 메시지: exit
클라이언트 종료 요청: ('127.0.0.1', 52344)
접속자 목록에서 제거: ('127.0.0.1', 52344)
현재 접속자 수: 1
클라이언트 처리 종료: ('127.0.0.1', 52344)
포트 번호 52344, 52345는 실행할 때마다 달라집니다. 여러 스레드가 동시에 출력하는 경우 출력 순서가 섞일 수 있습니다. 접속자 수 숫자 자체가 맞는지를 기준으로 성공을 판단합니다.
4.3 흔한 오류와 해결 방법
오류가 나오면 어느 단계에서 실패했는지 먼저 생각합니다.
| 오류 상황 | 원인 및 해결 방법 |
ValueError: list.remove(x): x not in list |
이미 제거된 소켓을 다시 제거하려 했음. if client_socket in clients: 조건을 먼저 확인한다 |
| 접속자 수가 줄어들지 않음 | clients.remove()가 실행되지 않았음. handle_client() 반복문이 끝난 뒤(with 블록 밖)에 제거 코드가 있는지 확인한다 |
| 접속자 수가 계속 늘기만 함 | exit 종료와 연결 끊김 상황 중 하나에서 제거가 누락됨. if not data: break와 if message == "exit": break 두 경로 모두 with 블록을 빠져나오는지 확인한다 |
NameError: name 'clients' is not defined |
clients 리스트가 함수 안에 정의되어 있음. clients = []를 함수 바깥, 파일 상단에 정의한다 |
ConnectionRefusedError |
서버가 실행되지 않은 상태에서 클라이언트를 실행했음. python chat_server/server.py를 먼저 실행한다 |
| 여러 클라이언트 출력 순서가 섞임 | 여러 스레드가 동시에 print()를 호출하는 것. 정상 동작이다. 접속자 수 숫자 변화가 맞는지를 기준으로 판단한다 |
✔ 확인 기준: 클라이언트 2개 접속 시 "현재 접속자 수: 2", 한 개 종료 후 "현재 접속자 수: 1"이 출력되면 완료. 접속자 수가 줄지 않으면 handle_client() 안의 remove() 코드가 with 블록 밖에 있는지 확인하세요. 접속자 수가 늘지 않으면 append()가 accept() 직후에 있는지 확인하세요.
5. 최종 코드 정리하기
→ 이번 강의에서 완성한 chat_server/server.py 전체 코드를 한곳에 정리합니다.
5.1 chat_server/server.py
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
with client_socket:
while True:
data = client_socket.recv(1024)
if not data:
print(f"클라이언트 연결 끊김: {client_address}")
break
message = data.decode("utf-8")
print(f"{client_address} 메시지: {message}")
if message == "exit":
print(f"클라이언트 종료 요청: {client_address}")
break
response = f"서버가 받은 메시지: {message}"
client_socket.send(response.encode("utf-8"))
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()
5.2 최종 확인 표
| 확인할 코드 | 위치 | 의미 |
clients = [] |
server.py 상단 |
공유 접속자 목록 — 함수 바깥에 정의 |
def print_clients(): |
server.py |
접속자 수를 출력하는 함수 |
clients.append(client_socket) |
server.py 메인 루프 |
접속 시 목록에 추가 — start() 전에 실행 |
if client_socket in clients: |
server.py handle_client() |
제거 전 존재 여부 확인 — ValueError 방지 |
clients.remove(client_socket) |
server.py handle_client() |
종료 시 목록에서 제거 — with 블록 밖에서 실행 |
이제 서버는 clients 리스트를 순회하는 것만으로 현재 접속 중인 모든 클라이언트에게 무언가를 할 수 있는 기반을 갖췄습니다.
for client in clients:
client.send(메시지) # 13강에서 구현할 브로드캐스팅의 핵심
→ 다음 강의 (13강): 한 클라이언트가 보낸 메시지를 clients 목록에 있는 나머지 클라이언트 모두에게 전달하는 브로드캐스팅을 구현합니다. 이번 강의에서 완성한 clients 리스트가 그 핵심 기반이 됩니다.
6. [과제] 접속자에게 자동 번호 부여하기
→ 12강의 clients 리스트를 {소켓: 이름} 딕셔너리로 바꾸고, 접속 순서대로 "손님1", "손님2" 이름을 부여합니다.
6.1 과제 설명과 목표
난이도 ★★ · 출발 코드는 12강 최종 chat_server/server.py입니다. 12강 시점에는 접속자를 clients = [] 리스트로 관리하고, 아직 broadcast()는 없습니다. 13강 브로드캐스팅 바로 직전 단계입니다.
지금까지 서버는 접속자를 ('127.0.0.1', 51001) 같은 주소로만 구분했습니다. 사람이 읽기 불편하고, 나중에 채팅 메시지에 "누가 보냈는지"를 표시하기도 어렵습니다. 그래서 접속 순서대로 "손님1", "손님2" 같은 이름을 붙여 줍니다.
이 과제는 14강에서 다룰 "메시지에 sender가 필요하다"는 동기를 미리 만들어 줍니다.
13강 브로드캐스팅 직전에 식별자 개념을 손으로 도입해 보는 단계입니다.

6.2 자료구조를 비유로 이해하기
리스트는 사람들을 줄 세워 놓기만 한 것이고, 딕셔너리는 각 사람에게 이름표를 붙여 놓은 것입니다. 카페로 비유하면 리스트는 "지금 매장에 손님 세 명"이라고만 아는 상태이고, 딕셔너리는 "1번 테이블 김씨, 2번 테이블 이씨"처럼 누가 누구인지 아는 상태입니다.
| 구분 | 리스트 (12강) | 딕셔너리 (이번 과제) |
| 저장 형태 | [sock1, sock2] |
{sock1: "손님1", sock2: "손님2"} |
| 이름 조회 | 불가능 | clients[sock]으로 바로 조회 |
| 제거 방법 | clients.remove(sock) |
del clients[sock] |
6.3 단계별로 코드 바꾸기
먼저 전역 부분에서 clients를 리스트 대신 딕셔너리로 바꾸고, 다음에 부여할 번호를 세는 변수를 추가합니다.
clients = {} # {소켓: 이름} 형태로 저장 (리스트 → 딕셔너리)
guest_number = 0 # 접속 순서대로 올려 가며 이름을 만들 카운터
접속자 수를 출력하는 함수는 딕셔너리에서도 len()이 그대로 동작하므로 거의 그대로 둡니다. 이름까지 함께 보여주도록 한 줄만 보강합니다.
def print_clients():
print(f"현재 접속자 수: {len(clients)}") # 딕셔너리도 len()으로 개수를 센다
print(f"접속자 목록: {list(clients.values())}") # 이름만 모아서 출력
이제 메인 루프에서 접속을 받을 때 번호를 올려 이름을 만들고, 딕셔너리에 저장합니다. global 선언이 필요한데, 함수 밖 카운터를 함수 안에서 바꾸기 때문입니다. 메인 루프는 함수가 아니므로 여기서는 모듈 수준에서 바로 증가시킵니다.
while True:
client_socket, client_address = server_socket.accept()
guest_number += 1 # 접속할 때마다 번호를 하나 올린다
client_name = f"손님{guest_number}" # "손님1", "손님2" 형태의 이름 생성
clients[client_socket] = client_name # 소켓을 키로, 이름을 값으로 저장
print(f"{client_name} 접속: {client_address}") # 주소 대신 이름으로 안내
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.start()
마지막으로 handle_client()에서 이름을 활용하고, 종료 시 딕셔너리에서 제거합니다. remove()가 아니라 del을 쓰는 점에 주의하세요.
def handle_client(client_socket, client_address):
client_name = clients[client_socket] # 이 소켓에 부여된 이름을 꺼낸다
print(f"{client_name} 처리 시작: {client_address}")
with client_socket:
while True:
data = client_socket.recv(1024)
if not data:
break
message = data.decode("utf-8")
print(f"{client_name}: {message}") # 주소 대신 이름으로 메시지 출력
if message == "exit":
break
response = f"서버가 받은 메시지: {message}"
client_socket.send(response.encode("utf-8"))
if client_socket in clients: # 종료 시 딕셔너리에서 제거
del clients[client_socket] # 리스트의 remove() 대신 del 사용
print(f"{client_name} 처리 종료")
print_clients()
✓ 확인 기준: 두 클라이언트를 차례로 접속시키면 손님1 접속, 손님2 접속이 찍히고, 한 명이 나가면 접속자 목록: ['손님2']처럼 남은 사람만 보이면 완료입니다.
6.4 최종 코드 (chat_server/server.py)
import socket
import threading
HOST = "127.0.0.1"
PORT = 5000
clients = {}
guest_number = 0
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
print(f"접속자 목록: {list(clients.values())}")
def handle_client(client_socket, client_address):
client_name = clients[client_socket]
print(f"{client_name} 처리 시작: {client_address}")
with client_socket:
while True:
data = client_socket.recv(1024)
if not data:
break
message = data.decode("utf-8")
print(f"{client_name}: {message}")
if message == "exit":
break
response = f"서버가 받은 메시지: {message}"
client_socket.send(response.encode("utf-8"))
if client_socket in clients:
del clients[client_socket]
print(f"{client_name} 처리 종료")
print_clients()
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()
guest_number += 1
client_name = f"손님{guest_number}"
clients[client_socket] = client_name
print(f"{client_name} 접속: {client_address}")
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.start()
| 확인할 코드 | 의미 |
clients = {} |
소켓을 키, 이름을 값으로 저장하는 딕셔너리 |
clients[client_socket] = client_name |
접속한 소켓에 이름을 짝지어 저장 |
del clients[client_socket] |
종료 시 딕셔너리에서 해당 항목 제거 |
list(clients.values()) |
저장된 이름만 모아 목록으로 확인 |
6.5 강사 팁 · 막히는 지점
중요한 점 1. 딕셔너리에서 항목을 지울 때 리스트 습관대로 clients.remove(client_socket)를 쓰면 AttributeError가 납니다. 딕셔너리는 del clients[client_socket] 또는 clients.pop(client_socket, None)을 씁니다.
중요한 점 2. guest_number는 접속이 끊겨도 줄어들지 않습니다. 손님1이 나가도 다음 접속자는 손님3이 됩니다. 번호를 "현재 접속자 순번"이 아니라 "지금까지 들어온 누적 순번"으로 설계했기 때문입니다. 이 점을 학생이 헷갈려 하면, 실제 서비스의 회원 번호도 탈퇴자가 있어도 재사용하지 않는다는 예를 들어 설명하면 좋습니다.
강사 팁. 이 과제를 마치면 "이제 메시지에도 이 이름을 붙이고 싶다"는 생각이 자연스럽게 듭니다. 그 욕구가 바로 14강 sender 필드의 출발점입니다. 과제를 13강 진도 직전에 배치하면 동기 부여가 가장 큽니다.