디버깅 실전 · 밖이 문제인가 우리가 문제인가 · 이론
저쪽이 죽었대요, 라는 말의 여섯 가지 뜻
한 줄 요약
"연동이 안 됩니다" 는 최소한 여섯 가지 서로 다른 사건이고, 그 여섯을 가르는 순간 책임의 위치와 다음 행동이 함께 정해진다.
왜 이게 필요했나
현장에서 "저쪽이 죽었어요" 라는 말을 듣고 상대에게 전화를 걸면, 상대는 자기 대시보드가 초록색이라고 답한다. 둘 다 거짓말을 하는 것이 아니다. 우리 쪽에서 본 것과 상대가 보는 것이 다른 사건일 뿐이다.
이름을 못 찾은 것은 상대가 멀쩡해도 일어난다. 연결이 거부된 것은 상대 호스트는 살아 있는데 그 포트에 아무도 없다는 뜻이다. 연결이 걸린 것은 상대가 우리 손을 잡지 못하고 있다는 뜻이고, 이 경우 상대 애플리케이션 로그에는 아무 기록도 남지 않는다. 읽기가 걸린 것은 상대가 손은 잡았는데 답을 못 주고 있다는 뜻이고, 이때는 상대 쪽에 요청이 이미 도착해 처리 중일 수 있다. 마지막 문장이 결정적이다 — 재시도를 해도 되는지가 여기서 갈린다.
어떻게 동작하나
여섯 가지 얼굴을 표로 정리하면 이렇다.
얼굴 무슨 일이 일어났나 책임의 위치 재시도dns_error 이름을 주소로 못 바꿨다 경로 안전connection_refused 호스트는 답하는데 포트가 닫혔다 상대 안전connect_timeout 손잡기(TCP)가 끝나지 않았다 경로 안전read_timeout 손은 잡았고 답이 안 온다 상대 위험partial_response 본문이 약속한 길이보다 짧다 상대 위험slow_response 답은 왔는데 늦었다 상대 안전재시도 칸이 이 표의 핵심이다. 연결이 이루어지기 전에 실패한 것은 상대가 요청을 받지 못했다는 뜻이므로 다시 보내도 같은 일이 두 번 일어나지 않는다. 그러나 읽기 타임아웃과 부분 응답은 요청이 이미 처리됐을 수 있다. 그 요청이 결제나 이체라면 재시도는 두 번 청구가 된다. 이 경우 멱등 키 같은 보호 없이 재시도해서는 안 된다.
구분하는 방법은 다행히 어렵지 않다. 파이썬 requests 는 [연결과 읽기에 각각 다른 타임아웃](https://requests.readthedocs.io/en/latest/user/advanced/)을 줄 수 있고, 둘을 튜플로 주면 실패도 ConnectTimeout 과 ReadTimeout 으로 갈려서 온다. 이 둘을 한 값으로 주면 두 사건이 한 얼굴로 뭉개진다.
타임아웃을 아예 주지 않으면 더 나쁜 일이 벌어진다. 기본값이 없으므로 요청은 상대가 답할 때까지, 또는 커널이 연결을 포기할 때까지 기다린다. 그동안 그 작업 스레드는 돌아오지 않고, 요청이 쌓이면 우리 쪽이 먼저 죽는다. 상대의 느림이 우리의 장애로 번지는 가장 흔한 경로가 이것이다.
여기에 재시도가 얹히면 상황이 배로 나빠진다. 끝나지 않는 시도를 세 번 하면 세 배로 오래 기다린다. 그래서 재시도에는 전체 마감이 함께 있어야 한다. 시도 횟수만 정한 재시도는 최악의 경우 얼마나 기다릴지 아무도 모른다.
현장에서 만나는 모습
첫째, 인터넷이 없는 곳에서 재현하지 못한다고 믿는다. 실은 로컬 소켓만으로 여섯 얼굴이 모두 재현된다. 연결 거부는 아무도 듣지 않는 포트면 되고, 연결 타임아웃은 대기열을 채운 뒤 accept 하지 않는 소켓으로 만들 수 있다([listen(2)](https://man7.org/linux/man-pages/man2/listen.2.html)의 backlog 가 그 자리다). 이름 해석 실패는 [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606.html)이 이 목적을 위해 예약해 둔 .invalid 이름을 쓰면 된다.
둘째, 상대 탓과 우리 탓을 섞는다. 연결 타임아웃을 "상대 장애" 로 보고하면 상대는 자기 로그에서 아무것도 못 찾는다. 손을 못 잡은 연결은 상대 애플리케이션까지 가지도 않았기 때문이다. 이 구분이 보고서에 있으면 조사는 방화벽과 경로 쪽에서 시작한다.
셋째, 느린 응답을 성공으로만 기록한다. 200 이 왔으니 성공이라고 적으면, 상대가 점점 느려지는 것을 아무도 못 본다. 성공에도 임계값을 두고 넘으면 다른 얼굴로 기록해야 추세가 보인다.
넷째, 부분 응답을 파싱 오류로 기록한다. JSON 파서가 터졌으니 응답 형식이 바뀐 줄 안다. 실제로는 본문이 중간에 끊긴 것이고, 이것은 상대나 중간 장비가 연결을 끊었다는 뜻이다. Content-Length 와 실제로 받은 바이트 수를 비교하면 바로 갈린다.
실무에서 진짜 중요한 것
- 연결과 읽기의 타임아웃을 따로 준다. 한 값으로 주면 두 사건이 뭉개진다.
- 재시도 가능 여부는 얼굴이 정한다. 읽기 타임아웃과 부분 응답은 이미 처리됐을 수 있다.
- 재시도에는 전체 마감을 함께 둔다. 횟수만 정한 재시도는 최악을 약속하지 못한다.
- 우리 탓과 경로 탓과 상대 탓을 나눠 적는다. 그 줄이 조사의 출발점을 정한다.
다음 실습에서 할 것
연동 상대의 여섯 얼굴을 로컬 소켓으로 세워 놓고, 요청 하나를 던져 그 얼굴을 가르는 판정기를 만든다. 연결과 읽기의 타임아웃을 따로 주어 두 사건을 갈라 보고, 타임아웃이 없을 때 시도가 끝나지 않는 것을 직접 재고, 마감을 지키는 재시도를 만든다. 마지막으로 여섯 대상을 한꺼번에 점검해 책임의 위치를 나눠 적은 표를 한 장으로 보고한다.