에러와 신호 다루기
지금까지 우리는 모든 일이 순조롭게 풀린다고 가정했습니다. 서버는 늘 거기 있고, 클라이언트는 예의 바르게 떠나고, 네트워크는 한 번도 끊기지 않았습니다. 현실은 다릅니다. 서버가 죽어 있을 수도, 클라이언트가 갑자기 사라질 수도, 네트워크가 먹통이 될 수도 있습니다. 견고한 프로그램은 이런 상황에서도 무너지지 않습니다. 이 장에서는 가장 흔한 사고들과 그 대처법을 배웁니다.
아무도 없는 곳에 연결하면
서버가 없는 포트로 connect를 시도하면 어떻게 될까요. 직접 해 봅니다.
# 새 파일: error_demo.pyimport socketsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)try: sock.connect(("127.0.0.1", 9999))except ConnectionRefusedError as e: print(f"{type(e).__name__}: {e}")
아무도 듣고 있지 않은 9999번 포트로 연결을 시도하면 이렇게 나옵니다.
ConnectionRefusedError: [Errno 61] Connection refused
연결이 거부되었습니다. 상대 컴퓨터는 살아 있지만 그 포트에서 아무도 듣고 있지 않을 때 나는 에러입니다. 에러 번호 61은 macOS 값이고 리눅스에서는 111이지만, 의미는 같습니다. Python은 이를 ConnectionRefusedError라는 또렷한 예외로 알려 주므로, try와 except로 감싸 우아하게 대처할 수 있습니다. 서버가 아직 안 떴을 때 잠시 기다렸다 다시 시도하는 식의 재연결 로직이 여기서 출발합니다.
응답 없는 상대를 무한정 기다리지 않기
연결이 거부되는 것보다 더 곤란한 상황이 있습니다. 상대가 거부도 수락도 하지 않고 그냥 침묵하는 경우입니다. 방화벽에 막혔거나 상대 컴퓨터가 응답 불능일 때 그렇습니다. 이때 connect나 recv는 기본적으로 하염없이 기다립니다. 프로그램이 멈춰 버린 것처럼 보입니다.
이를 막는 것이 타임아웃입니다. 소켓에 제한 시간을 걸어 두면, 그 시간이 지나도록 응답이 없을 때 예외를 던지고 빠져나옵니다.
# 새 파일: timeout_demo.pyimport socketsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)sock.settimeout(2.0)try: sock.connect(("10.255.255.1", 80))except TimeoutError: print("TimeoutError: 연결 시도가 시간 초과되었습니다")
settimeout(2.0)은 이 소켓의 모든 작업에 2초의 제한을 겁니다. 응답이 거의 없는 주소로 연결을 시도하면 2초 뒤 이렇게 나옵니다.
TimeoutError: 연결 시도가 시간 초과되었습니다
타임아웃은 견고한 클라이언트의 기본입니다. 사용자를 무한정 기다리게 두는 대신, 적절한 시간이 지나면 포기하고 재시도하거나 오류를 알려야 합니다.
사라진 상대에게 계속 말 걸기
이번엔 보내는 쪽의 사고입니다. 상대가 이미 연결을 닫았는데 그것도 모르고 계속 데이터를 보내면 어떻게 될까요.
처음 한두 번은 그냥 넘어갈 수 있습니다. 하지만 상대가 확실히 떠난 뒤 계속 보내면, 운영체제가 더는 보낼 곳이 없다며 에러를 냅니다.
BrokenPipeError: [Errno 32] Broken pipe
깨진 파이프라는 이름이 인상적입니다. 데이터가 흘러갈 관이 끊겨 버렸다는 뜻입니다. 그런데 여기에는 Python이 가려 주는 함정이 하나 있습니다.
원래 유닉스에서 닫힌 연결에 데이터를 쓰면 SIGPIPE라는 신호가 프로그램에 전달됩니다. 이 신호는 기본적으로 프로그램을 그 자리에서 강제 종료시킵니다. 에러를 처리할 기회조차 주지 않고 프로그램이 죽어 버리는 것입니다. 다행히 Python은 이 신호를 가로채 BrokenPipeError라는 예외로 바꿔 줍니다. 덕분에 우리는 try와 except로 잡아낼 수 있습니다.
하지만 C에서는 이야기가 다릅니다. C에서는 아무 조치도 하지 않으면 SIGPIPE 신호에 프로그램이 정말로 죽습니다. 그래서 C로 서버를 짤 때는 이 신호를 무시하도록 미리 설정해 두는 것이 거의 필수입니다. 프로그램 시작 부분에 다음 한 줄을 넣습니다.
// 새 파일: ignore_sigpipe.c (핵심 부분)
#include <signal.h>
signal(SIGPIPE, SIG_IGN);
이렇게 SIGPIPE를 무시하도록 해 두면, 닫힌 소켓에 쓰더라도 프로그램이 죽는 대신 send가 에러를 돌려줍니다. 그러면 우리가 그 에러를 보고 차분히 대처할 수 있습니다. 신호에 죽는 대신 에러로 다루는 것, 이것이 견고한 C 서버의 첫걸음입니다.
연결 유지를 확인하는 keep-alive
마지막으로 조용한 사고 하나를 짚겠습니다. 연결은 맺어 두었는데 한참 동안 아무 데이터도 오가지 않을 때, 상대가 아직 살아 있는지 어떻게 알 수 있을까요. 네트워크가 중간에 끊겼다면, 우리 쪽은 그 사실조차 모른 채 죽은 연결을 붙들고 있을 수 있습니다.
이를 위한 장치가 keep-alive입니다. 소켓에 이 옵션을 켜 두면, 일정 시간 조용하면 운영체제가 상대에게 살짝 신호를 보내 생사를 확인합니다. 응답이 없으면 연결이 끊겼다고 판단합니다.
# 새 파일: keepalive_demo.pyimport socketsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)print("keep-alive를 켰습니다")
앞 장에서 본 setsockopt가 또 등장합니다. SO_KEEPALIVE 옵션을 켜는 것입니다. 세부 시간 설정은 운영체제마다 다르지만, 오래 유지되는 연결을 다룰 때 이런 장치가 있다는 것을 알아 두면 좋습니다.
에러를 friend로 삼기
이 장에서 배운 에러들을 정리해 보겠습니다. 연결이 거부되는 ConnectionRefusedError, 응답이 없을 때의 타임아웃, 끊긴 연결에 쓸 때의 BrokenPipeError와 그 뒤의 SIGPIPE 신호, 그리고 죽은 연결을 감지하는 keep-alive까지.
초보자는 에러를 두려워하지만, 견고한 프로그램을 짜는 사람은 에러를 예상하고 미리 대비합니다. 네트워크에서는 무엇이든 잘못될 수 있다는 것을 전제로 코드를 짜는 것, 그것이 이 장의 진짜 교훈입니다.
이제 TCP를 다루는 데 필요한 도구가 모두 모였습니다. 다음 장에서는 이 모든 것을 한데 모아, 첫 프로토콜을 직접 설계하고 파일을 통째로 전송하는 실전 프로그램을 만들어 봅니다.