프로덕션을 위한 견고성 패턴
연습용 서버와 실제로 쓰이는 서버 사이에는 큰 강이 있습니다. 연습용은 잘 돌아가기만 하면 되지만, 실제 서버는 며칠, 몇 달을 멈추지 않고 버텨야 합니다. 자원이 새지 않아야 하고, 종료할 때 깔끔해야 하며, 악의적 접속에도 무너지지 않아야 합니다. 이 장에서 그 강을 건너는 견고성 패턴들을 배웁니다. 화려하지 않지만, 진짜 서버를 만드는 사람이라면 반드시 알아야 하는 것들입니다.
우아하게 종료하기
서버를 끄는 가장 거친 방법은 그냥 강제 종료하는 것입니다. 하지만 그러면 처리 중이던 요청이 끊기고, 자원이 제대로 정리되지 않을 수 있습니다. 좋은 서버는 종료 신호를 받으면, 새 연결은 그만 받되 처리 중인 일은 마무리하고, 자원을 정리한 뒤 깨끗이 물러납니다. 이를 우아한 종료라 부릅니다.
핵심은 종료 신호를 가로채는 것입니다. 사용자가 Ctrl과 C를 누르거나 시스템이 종료를 요청하면 신호가 날아오는데, 이것을 직접 처리해 정리할 틈을 마련합니다.
# 새 파일: graceful_server.pyimport socketimport signalrunning = Truedef shutdown(signum, frame): global running running = False print("종료 신호 수신, 정리를 시작합니다")signal.signal(signal.SIGINT, shutdown)signal.signal(signal.SIGTERM, shutdown)server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(("127.0.0.1", 9098))server.listen(5)server.settimeout(0.3)print("서버 시작")while running: try: conn, addr = server.accept() conn.close() except socket.timeout: continueserver.close()print("서버가 자원을 정리하고 깨끗이 종료되었습니다")
signal.signal로 두 가지 종료 신호를 우리 함수에 연결합니다. 사용자가 Ctrl과 C를 누를 때 오는 신호와 시스템이 종료를 청할 때 오는 신호입니다. 이 신호가 오면 shutdown 함수가 running을 거짓으로 바꿉니다. 그러면 주 반복문이 다음 바퀴에서 자연스럽게 빠져나오고, server.close()로 자원을 정리한 뒤 종료됩니다.
여기서 server.settimeout(0.3)이 중요한 역할을 합니다. accept가 영원히 멈춰 있으면 종료 신호를 받아도 반복문으로 돌아오지 못합니다. 그래서 짧은 타임아웃을 걸어, 0.3초마다 한 번씩 반복문 조건을 다시 확인하게 합니다. 연결이 없으면 타임아웃이 나고, 그때 running이 거짓이면 반복을 빠져나옵니다. 강제로 끊어 죽는 대신, 스스로 마무리하고 물러나는 것입니다.
자원 누수를 막기
서버가 며칠씩 돌면 작은 누수도 치명적이 됩니다. 가장 흔한 것이 소켓을 닫지 않는 실수입니다. PART 01에서 배웠듯 소켓은 파일 디스크립터를 차지합니다. 그런데 한 프로그램이 열 수 있는 파일 디스크립터 수에는 한계가 있습니다. 연결을 받고 닫지 않으면, 이 한계에 부딪혀 어느 순간 새 연결을 못 받게 됩니다. 멀쩡하던 서버가 시간이 지나면 먹통이 되는 흔한 원인입니다.
해법은 단순합니다. 모든 소켓은 다 쓰면 반드시 닫는 것입니다. Python에서는 with 블록을 쓰면 블록을 벗어날 때 자동으로 닫히니 누수를 막기 좋습니다. 에러가 나도 닫히도록, 소켓을 다루는 코드는 try와 finally로 감싸거나 with를 쓰는 습관을 들여야 합니다. C에서는 모든 경로에서 close가 빠짐없이 불리는지 직접 확인해야 합니다. PART 04의 fork 서버에서 부모와 자식이 각자 필요 없는 소켓을 닫던 것을 떠올려 보세요. 그것도 누수를 막는 일이었습니다.
악의적 접속을 견디기
실제 서버는 선의의 사용자만 만나지 않습니다. 일부러 서버를 괴롭히려는 접속도 옵니다. 몇 가지 흔한 위협과 대비책을 짚겠습니다.
하나는 느린 공격입니다. 연결만 잔뜩 맺어 두고 데이터를 아주 천천히 보내거나 아예 안 보내는 방식입니다. 슬로로리스라 불리는 이런 공격은, 서버의 연결 자리를 야금야금 차지해 정상 사용자가 들어올 자리를 없앱니다. 대비책은 타임아웃입니다. PART 02에서 배운 그 타임아웃을 걸어, 일정 시간 안에 제대로 된 데이터를 보내지 않는 연결은 끊어 버립니다.
또 하나는 자원 고갈입니다. 한꺼번에 수많은 연결을 쏟아부어 서버의 자원을 바닥내려는 시도입니다. 대비책은 동시 연결 수에 상한을 두는 것입니다. 현재 연결 수를 세어 두고, 한계를 넘으면 새 연결을 거절합니다. 모두를 받으려다 모두를 잃는 것보다, 받을 수 있는 만큼만 받아 안정을 지키는 편이 낫습니다.
세 번째는 잘못된 입력입니다. 공격자는 우리 프로토콜이 예상하지 못한 데이터를 보내 서버를 혼란에 빠뜨리려 합니다. 예를 들어 PART 02의 파일 전송에서, 파일 크기를 터무니없이 크게 속여 보내면 어떻게 될까요. 서버가 그 크기만큼 메모리를 잡으려다 죽을 수 있습니다. 그래서 받은 길이나 크기 값이 상식적인 범위 안에 있는지 항상 검사해야 합니다. 들어오는 모든 데이터를 의심하는 것, 이것이 보안의 기본자세입니다.
견고함은 디테일에 있다
이 장에서 다룬 것들은 화려한 기능이 아닙니다. 우아한 종료, 자원 정리, 타임아웃, 연결 제한, 입력 검증. 모두 눈에 잘 띄지 않는 디테일입니다. 하지만 바로 이 디테일들이 하루 만에 죽는 서버와 몇 달을 버티는 서버를 가릅니다.
견고한 서버를 만드는 사람은 늘 최악을 상상합니다. 연결이 끊기면, 데이터가 이상하면, 악의적 접속이 오면, 자원이 부족해지면 어떻게 될까를 미리 생각하고 대비합니다. 네트워크에서는 무엇이든 잘못될 수 있다는 PART 02의 교훈이, 여기서 견고함이라는 형태로 완성됩니다.
이제 보안과 견고성을 모두 배웠습니다. 다음 장에서 이 모든 것을 한데 모아, 암호화되고 견고한 보안 채팅 서버를 완성합니다.