연결 하나가 느려지자 나머지가 전부 멈췄다 · 보낸 만큼 다 나가지 않는다 · 이론
보낸 만큼 다 나가지 않는다
한 줄 요약
recv 와 send 는 요청한 크기를 약속하지 않습니다. 남은 것을 들고 있는 일은 프로그램의 몫입니다.
왜 이게 필요했나
다중화로 바꾸고 나면 새로운 종류의 버그가 나타납니다. 블로킹 모드에서 sendall 은 다 보낼 때까지 알아서 돌아오지 않았습니다. 논블로킹으로 바꾸는 순간 그 편의가 사라집니다. 커널의 송신 버퍼에 자리가 20바이트뿐인데 200바이트를 보내려 하면, send 는 20을 돌려주고 나머지는 프로그램에게 남깁니다.
[send(2)](https://man7.org/linux/man-pages/man2/send.2.html) 의 반환값 설명은 짧습니다. 성공하면 보낸 바이트 수를 돌려준다는 것입니다. 보낸 바이트 수라고 적혀 있지 보내려던 바이트 수라고 적혀 있지 않습니다. 이 한 줄을 흘려 읽으면, 평소에는 잘 돌다가 응답이 커지거나 상대가 느려질 때만 뒷부분이 잘려 나가는 프로그램이 됩니다. 그리고 그 증상은 거의 항상 상대 쪽 파서의 오류로 먼저 신고됩니다.
어떻게 동작하나
받는 쪽과 보내는 쪽 모두 연결마다 남은 조각을 담을 자리가 필요합니다. 받는 쪽 버퍼는 앞 코스에서 다룬 프레이밍과 같은 이야기입니다. 이번 코스의 프로토콜은 줄 단위라 개행이 나올 때까지 모으면 되지만, 개행이 없다고 해서 잘못된 입력은 아닙니다. 아직 다 오지 않았을 뿐입니다.
보내는 쪽 버퍼는 새로 배우는 부분입니다. 연결마다 아직 나가지 못한 바이트를 outbox 같은 이름으로 들고, 쓰기 준비가 오면 한 번 send 하고, 돌아온 수만큼만 앞에서 잘라냅니다.
sent = sock.send(self.outbox)self.outbox = self.outbox[sent:] # 나머지는 남긴다여기서 흔한 실수는 self.outbox = b"" 입니다. 짧은 문자열로 시험하면 대개 한 번에 다 나가기 때문에 통과하고, 운영에서 응답이 커졌을 때만 깨집니다.
이제 관심 이벤트를 언제 바꾸는지가 중요해집니다. 읽기 준비는 상대가 보낸 것이 있을 때만 참이지만, 쓰기 준비는 송신 버퍼에 자리가 있을 때 참입니다. 대부분의 시간에 버퍼는 비어 있으므로 쓰기 준비는 거의 항상 참입니다. 보낼 것이 없는데 쓰기 준비를 지켜보면, 다중화 호출이 아무 일 없이 즉시 돌아오는 반복이 됩니다.
| 상태 | 지켜볼 이벤트 | 이유 |
| --- | --- | --- |
| 보낼 것이 없다 | 읽기만 | 쓰기 준비는 거의 항상 참이라 루프가 헛돈다 |
| 보낼 것이 남았다 | 읽기와 쓰기 | 자리가 생기는 순간을 알아야 이어 보낸다 |
그래서 규칙은 한 줄로 정리됩니다. 보낼 것이 남아 있는 동안에만 쓰기 준비를 지켜본다. 큐에 무언가를 넣은 직후와 보내기를 마친 직후, 두 시점에 관심 이벤트를 다시 계산하면 이 규칙이 지켜집니다.
받는 쪽에도 짝이 되는 규칙이 있습니다. 한 번의 읽기 준비에 recv 를 한 번만 부를지, EAGAIN 이 날 때까지 부를지입니다. [selectors](https://docs.python.org/3/library/selectors.html) 의 기본 동작인 레벨 트리거에서는 남은 데이터가 있으면 다음 번에 또 깨워 주므로 한 번만 불러도 안전합니다. 한 번만 부르는 쪽이 연결 사이의 공평함에도 유리합니다. 말이 많은 손님 하나가 한 번의 깨어남에서 루프를 독차지하지 못하기 때문입니다.
여기서 [epoll(7)](https://man7.org/linux/man-pages/man7/epoll.7.html) 이 설명하는 에지 트리거를 떠올릴 수 있습니다. 에지 트리거는 상태가 바뀌는 순간에만 통지하므로, 한 번 깨어났을 때 더 읽을 것이 없을 때까지 읽어 두지 않으면 남은 데이터가 다음 통지를 기다리며 잠들어 버립니다. 반대로 레벨 트리거는 조건이 유지되는 동안 계속 통지하므로 덜 읽어도 잊히지 않습니다. 두 방식은 성능의 우열이 아니라 책임의 위치가 다른 것입니다. 어느 쪽을 쓰는지 모르는 채로 코드를 옮겨 붙이면, 가끔 응답이 사라지는데 재현은 안 되는 종류의 버그가 됩니다.
버퍼에는 상한도 필요합니다. 상대가 개행 없는 바이트를 계속 보내면 받는 쪽 버퍼는 끝없이 자랍니다. 송신 쪽도 마찬가지여서, 상대가 읽지 않는데 응답을 계속 큐에 쌓으면 메모리가 그 연결 하나에 묶입니다. 앞 코스에서 배운 길이 상한과 배압이 여기서도 그대로 필요하다는 뜻입니다. 다중화는 기다림을 옮겨 줄 뿐 없애 주지 않으며, 옮겨진 기다림은 대개 메모리의 모습으로 나타납니다.
현장에서 만나는 모습
부분 송신은 평소에 잘 숨습니다. 응답이 커널 송신 버퍼보다 작으면 늘 한 번에 나가서, 시험도 통과하고 스테이징도 통과합니다. 드러나는 때는 정해져 있습니다. 응답에 목록이 붙어 커질 때, 상대가 읽기를 늦출 때, 그리고 한 연결에 응답을 연달아 밀어 넣을 때입니다. 이때 상대는 잘린 JSON 이나 절반짜리 줄을 받고, 신고는 파서 오류로 들어옵니다. 보낸 쪽 로그에는 성공만 남아 있습니다.
반대로 관심 이벤트를 고치지 않아 생기는 사고는 CPU 그래프로 먼저 옵니다. 연결 수와 무관하게 코어 하나가 100퍼센트에 붙어 있고, 프로파일러는 다중화 호출 자체를 가장 뜨거운 지점으로 보여 줍니다. 실제로는 그 호출이 느린 것이 아니라 아무 일 없이 너무 자주 불리는 것입니다.
다음 퀴즈에서 할 것
send 가 20바이트 중 3을 돌려준 상황, 쓰기 준비를 늘 지켜보는 서버의 CPU 그래프, 그리고 개행이 없는 조각을 받은 상황을 각각 어떻게 다뤄야 하는지 정리해 보세요. 세 가지 모두 남은 것을 어디에 두는가라는 같은 질문의 다른 얼굴입니다.