LabHub
배우기 러닝패스 코스

연결 하나가 느려지자 나머지가 전부 멈췄다 · 먼저 입을 다물었을 뿐인데 답장까지 사라졌다 · 이론

수신 종료와 응답 완료는 다른 사건이다

LabHub 에서 이어서 보기

한 줄 요약

상대가 보내기를 끝냈다고 해서 내가 답장을 보낼 수 없는 것은 아닙니다. 수신 EOF와 송신 큐 소진을 서로 다른 상태로 관리해야 합니다.

왜 이게 필요했나

앞 실습의 줄 단위 서버는 EOF를 만나면 closed를 표시하고 소켓을 회수했습니다. 클라이언트가 요청과 응답을 주고받은 뒤 연결을 닫는 범위에서는 이해하기 쉬운 출발점입니다. 그러나 요청을 다 보낸 뒤 자기 송신 방향만 닫고 결과를 기다리는 클라이언트까지 지원하려면 이 정책을 확장해야 합니다. 다중화 자체가 고장 난 것이 아니라, 종료를 하나의 불리언으로만 표현한 모델의 한계입니다.

이번에는 장난감 '대문자 인쇄소'를 만듭니다. 손님은 원고 바이트를 보내고 송신 방향을 닫습니다. 인쇄소는 EOF를 원고의 끝으로 삼아 ASCII 소문자를 대문자로 바꾼 답장을 보냅니다. 원고를 건넨 손님이 입을 다물었다고 귀까지 닫았다고 판단하면 답장을 버리게 됩니다. 실습용 프로토콜은 연결당 요청 한 번이며 HTTP, TLS 또는 지속 연결 프로토콜을 대신하지 않습니다.

어떻게 동작하나

[shutdown(2)](https://man7.org/linux/man-pages/man2/shutdown.2.html)는 읽기와 쓰기 중 종료할 방향을 구분합니다. 클라이언트의 shutdown(socket.SHUT_WR)는 더 이상 보내지 않겠다는 뜻이며, 같은 소켓으로 응답을 읽는 것은 계속 가능합니다. 로컬 파일 서술자를 회수하는 close와 역할이 다릅니다. close한 소켓으로 다시 recv하려는 예제는 반쪽 종료를 시험하지 않습니다.

[recv(2)](https://man7.org/linux/man-pages/man2/recv.2.html)의 스트림 EOF 반환값은 0입니다. 파이썬에서는 양수 크기로 요청한 recv가 b""를 돌려주는 것으로 관찰합니다. UDP의 길이 0 데이터그램이나 recv(0)까지 같은 규칙으로 일반화하면 안 됩니다. 특히 입력 버퍼가 한도를 채웠다고 recv(0)를 호출하면, 원고가 끝났다는 증거 없이 끝났다고 판정할 수 있습니다. 실습은 최대 허용량보다 한 바이트 더 관찰할 공간을 두고 EOF인지 초과인지 구분합니다.

| 상태 | 관심 이벤트 | 다음 행동 |
| --- | --- | --- |
| 원고를 받는 중 | READ | 최대 4096바이트를 한 번 읽는다 |
| EOF, 답장이 남음 | WRITE | 한 번 보내고 실제 나간 길이만 지운다 |
| 답장이 모두 큐에서 나감 | 없음 | 등록 해제 후 소켓을 닫는다 |
| 크기 초과·오류·마감 | 없음 | 사유를 보존하고 버퍼와 소켓을 회수한다 |

EOF는 계속 읽어 볼 이벤트가 아닙니다. 답장이 남았다는 이유로 READ를 계속 등록해 두면 EOF를 반복해서 관찰하며 루프가 헛돌 수 있습니다. 반대로 EOF에서 WRITE까지 지우면 보내야 할 답장이 영영 진행하지 못합니다. 관심 마스크는 '연결이 살아 있나' 하나가 아니라 수신 상태와 남은 출력으로 계산합니다.

[send(2)](https://man7.org/linux/man-pages/man2/send.2.html)의 성공은 상대 애플리케이션이 답장을 읽고 처리했다는 확인이 아닙니다. 실습의 complete는 로컬 출력 큐를 비웠다는 뜻으로 한정합니다. 거래 완료처럼 상대 처리까지 확인해야 하는 시스템에는 별도 응답이나 애플리케이션 확인 메시지가 필요합니다. 정답지가 complete를 남긴 사실만 보지 않고 실제 클라이언트가 받은 바이트까지 대조하는 이유입니다.

메모리 상한과 마감은 서로 다른 방어다

원고 한도를 4096바이트로 두어도 한 바이트씩 천천히 보내는 손님은 연결을 오래 점유할 수 있습니다. 활동할 때마다 연장하는 idle timeout만 있으면 계속 살아남을 수도 있습니다. 여기서는 시작할 때 time.monotonic() + 수명으로 절대 마감을 정하고, 바이트를 받거나 EOF를 보더라도 연장하지 않습니다. EOF 뒤 상대가 답장을 읽지 않아 송신이 막힐 때도 같은 마감을 적용합니다. 운영에서는 요청 수신·처리·응답 전송 마감을 따로 정할 수 있지만 이번 모델은 하나의 총수명으로 책임을 끝냅니다.

상한 초과는 거대한 오류 응답으로 돌려주지 않고 닫습니다. 작은 변환 서버에 대한 학습용 정책이며, 운영 프로토콜에서는 오류 프레임과 미수신 입력 처리까지 별도로 설계해야 합니다. 한 연결의 입력과 출력이 각각 유한해도 연결 수가 무제한이면 총메모리는 유한하지 않습니다. 따라서 이 실습만으로 서비스 전체의 자원 보호가 끝났다고 말할 수 없습니다.

소유권은 한 곳에서 끝낸다

Peer.finish는 종료 사유와 버퍼 상태만 바꾸고 소켓을 직접 닫지 않습니다. [Python selectors](https://docs.python.org/3/library/selectors.html)가 안내하는 순서대로 반복문 쪽에서 unregister 후 close합니다. 먼저 닫으면 등록 목록에 이미 무효가 된 서술자가 남아 예외나 누수 분석을 어렵게 만듭니다. 마지막 finally도 정상 완료뿐 아니라 중간 예외에서 소켓을 회수하기 위해 필요합니다.

현장에서 만나는 모습

2026-09-13 확인한 [Cloudflare Spectrum 채용 공고](https://job-boards.greenhouse.io/cloudflare/jobs/8094826?gh_jid=8094826-1)는 Linux 소켓, TCP 연결 상태, 연결 수명주기 문제의 디버깅과 운영 장애 분석을 요구합니다. 이 모듈은 그중 종료 방향·자원 회수·경계 조건 검증을 연습합니다. 기업의 공식 교육이나 채용 대비를 보증하는 과정은 아니며, Go·Rust 및 대규모 분산 시스템 요구까지 이 실습 하나로 충족하지는 않습니다.

다음 실습에서 할 것

8단계로 Peer 상태와 다중화 반복문을 만듭니다. 마지막 검사는 실제 TCP 연결 네 개를 엽니다. 첫 손님은 침묵하고, 다음 손님은 원고를 나눠 보낸 뒤 SHUT_WR만 호출하며, 나머지는 빈 요청과 한도 초과를 만듭니다. 정상 손님이 침묵한 손님의 종료를 기다리지 않고 전체 답장을 받는지, 모든 소켓이 회수되는지 확인합니다. 부분 send와 EAGAIN은 별도의 결정적 시험으로 강제합니다. 작은 응답이 우연히 잘 전달됐다는 관찰만으로 부분 송신 분기가 검증됐다고 하지 않습니다.