실습 — TLS 적용 보안 채팅 서버
이 책의 여정을 마무리하는 실습입니다. 지금까지 배운 것을 한데 모아, 암호화된 보안 채팅 서버를 완성합니다. PART 05의 채팅 프로토콜, PART 04의 동시성, 그리고 이 PART의 TLS를 결합합니다. 앞 장에서 평문으로 훤히 보이던 그 채팅을, 도청해도 읽을 수 없는 안전한 채팅으로 다시 태어나게 하는 것입니다.
설계 선택, 스레드 기반으로
먼저 설계를 정합니다. PART 05의 채팅 서버는 select를 썼습니다. 그런데 TLS는 select 같은 논블로킹 방식과 결합하기가 까다롭습니다. TLS 핸드셰이크 자체가 여러 번의 주고받음을 거치는데, 논블로킹 상태에서 이 과정을 직접 다루려면 코드가 복잡해집니다.
그래서 이 실습에서는 PART 04에서 배운 또 다른 동시성, 스레드를 씁니다. 클라이언트마다 스레드를 하나씩 붙이고, 각 스레드는 평범한 블로킹 TLS 소켓으로 그 클라이언트를 상대합니다. TLS가 블로킹 소켓 위에서 가장 단순하게 동작하므로, 코드가 깔끔해집니다. 동시 접속이 아주 많은 극한 상황이라면 PART 06의 방법을 고려하겠지만, 보통의 채팅에는 스레드 방식이 충분하고 명료합니다. 상황에 맞는 도구를 고르는 것, 이것도 우리가 이 책에서 배운 능력입니다.
보안 채팅 서버
앞서 만든 인증서 cert.pem과 key.pem을 그대로 씁니다.
# 새 파일: secure_chat_server.pyimport socketimport sslimport threadingclients = []lock = threading.Lock()def broadcast(message, sender): with lock: for c in list(clients): if c is not sender: try: c.sendall(message) except OSError: passdef handle(conn, addr): with lock: clients.append(conn) print(f"{addr} 입장") buffer = "" try: while True: data = conn.recv(1024) if not data: break buffer += data.decode() while "\n" in buffer: line, buffer = buffer.split("\n", 1) broadcast((line + "\n").encode(), conn) finally: with lock: if conn in clients: clients.remove(conn) conn.close() print(f"{addr} 종료")context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)context.load_cert_chain(certfile="cert.pem", keyfile="key.pem")server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9444))server.listen(5)tls_server = context.wrap_socket(server, server_side=True)print("보안 채팅 서버 대기 중")while True: conn, addr = tls_server.accept() threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
이 한 파일에 우리가 배운 것들이 모여 있습니다. 하나씩 짚어 보세요.
TLS는 PART 07에서 배운 그대로입니다. SSLContext를 만들어 인증서를 싣고, wrap_socket으로 듣기 소켓을 감쌌습니다. 그 위에서 받은 모든 연결은 자동으로 암호화됩니다.
동시성은 PART 04의 스레드 방식입니다. accept로 연결을 받으면 새 스레드에게 handle을 맡기고 곧바로 다음 연결을 받습니다. 여러 사람이 동시에 대화할 수 있는 이유입니다.
줄 단위 프레이밍은 PART 05의 채팅 프로토콜 그대로입니다. buffer에 받은 데이터를 모았다가 줄바꿈마다 한 줄씩 잘라 처리합니다. 스트림이 쪼개져 와도 메시지를 정확히 가릅니다.
브로드캐스트와 견고성은 PART 05와 07에서 배운 것입니다. 보낸 사람을 뺀 모두에게 전하되, 각 전송을 try로 감싸 한 명의 문제가 전체를 막지 않게 합니다. lock은 여러 스레드가 동시에 클라이언트 명단을 건드릴 때 생길 혼란을 막는 안전장치입니다. 그리고 finally로 클라이언트가 어떻게 떠나든 반드시 명단에서 빼고 소켓을 닫아, PART 07에서 강조한 자원 누수를 막습니다.
보안 채팅 클라이언트
클라이언트도 TLS로 감쌉니다. PART 05의 채팅 클라이언트에 TLS만 입힌 모습입니다.
# 새 파일: secure_chat_client.pyimport socketimport sslimport threadingimport syscontext = ssl.create_default_context()context.check_hostname = Falsecontext.verify_mode = ssl.CERT_NONEraw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client = context.wrap_socket(raw, server_hostname="localhost")client.connect(("127.0.0.1", 9444))def receive(): while True: try: data = client.recv(1024) except OSError: break if not data: break print(data.decode(), end="")threading.Thread(target=receive, daemon=True).start()for line in sys.stdin: client.sendall(line.encode())client.close()
구조는 PART 05의 채팅 클라이언트와 똑같습니다. 받는 일을 스레드에 맡기고, 주 흐름은 입력을 읽어 보냅니다. 달라진 것은 소켓을 TLS로 감싼 것뿐입니다. 앞 장에서처럼 연습용 자체 서명 인증서를 받아들이려고 검증을 꺼 두었는데, 실제 서비스에서는 켜야 한다는 것을 다시 한번 기억하세요.
안전하게 대화해 보기
서버를 띄우고 두 클라이언트로 대화해 봅니다.
# 파일: 터미널 1 (서버)python3 secure_chat_server.py
# 파일: 터미널 2, 3 (각각 다른 사람)python3 secure_chat_client.py
한 사람이 메시지를 입력하면 다른 사람의 화면에 나타납니다. 겉보기에는 PART 05의 채팅과 똑같습니다.
Alice: 암호화된 인사
하지만 결정적 차이가 있습니다. 이번에는 tcpdump로 엿봐도 이 대화가 보이지 않습니다. 앞 장의 평문 채팅에서는 글자가 그대로 드러났지만, 이 보안 채팅에서는 암호화된 바이트만 흐릅니다. 같은 채팅이지만, 한쪽은 엽서이고 다른 한쪽은 봉인된 편지입니다.
우리가 도달한 곳
이 작은 프로그램을 돌아보세요. TCP 소켓 위에, 줄 단위 프로토콜을 얹고, 스레드로 동시성을 더하고, TLS로 보안을 입히고, 견고성 패턴으로 단단히 했습니다. PART 01의 첫 에코 한 마디에서 출발해, 여기까지 왔습니다. 우리는 이제 안전하고, 동시적이고, 견고한 실전 서버를 처음부터 끝까지 직접 만들 수 있습니다.
이것으로 두 심화 PART를 모두 마칩니다. 고성능과 보안, 견고함이라는 실전의 세 봉우리를 넘었습니다. 다음 마지막 PART에서 지금까지의 여정을 돌아보고, 앞으로 더 나아갈 길을 안내하겠습니다.