타임아웃은 실패가 아니라 모름이다
한 줄 요약
동기 중계는 전문을 받아 대상 시스템을 부르고 그 결과를 표준 응답코드로 바꿔 돌려주는 일이다. 가장 중요한 구분은 "실패했다" 와 "모른다" 다. 연결조차 못 했으면 보내지 않은 것이 확실하지만(E902), 보낸 뒤 답을 못 받았으면 대상이 처리했는지 아무도 모른다(E901). 이 둘을 같은 코드로 뭉개는 순간 이중 이체가 시작된다.
왜 이게 필요했나
채널(인터넷뱅킹)이 계정계를 직접 부르면, 채널은 계정계의 주소·HTTP 규약·오류 형식을 전부 알아야 한다. 계정계가 오류를 422 {"result":"INSUFFICIENT_FUNDS"} 로 주든 200 {"code":"E-17"} 로 주든 채널마다 해석 코드가 따로 생기고, 대상 시스템이 바뀔 때 채널 열 개를 고친다. 중계 계층이 이 해석을 한 곳에서 맡고 채널에는 표준 응답코드 하나만 돌려준다 — 잔액 부족은 어느 대상에서 왔든 B201 이다.
그런데 중계가 끼면 실패 지점이 하나 는다. 채널 → 허브 → 계정계 구간 어디서든 멈출 수 있고, 멈춘 자리에 따라 뜻이 완전히 다르다. 이 모듈의 계정계 픽스처는 현실과 똑같이 만들었다. 느려도 처리는 끝까지 한다. 허브가 2초 만에 포기해도 계정계는 4초째에 잔액을 뺀다. 이때 허브가 채널에 "실패" 라고 답하면 채널은 다시 보내고, 돈은 두 번 빠진다.
동기 중계는 전문을 받아 대상을 부르고 결과를 표준 응답코드로 바꿔 돌려주는 일이다. 본문이 번호를 붙여 적은 여섯 걸음을 세 단계로 묶어 옮겼다.
- 전문을 읽고 거른다TCP 에서 길이 4바이트를 정확히 읽고 그만큼 다시 읽는다. 헤더를 파싱해 형식이 틀리면 E102, 거래코드가 중계 대상이 아니면 E101 로 돌려준다.
- 본문을 바꿔 계정계를 부른다본문을 레이아웃대로 JSON 으로 바꾸고 POST /v1/transfers 를 부른다. GUID 를 본문과 X-GUID 헤더에 싣는다.
- 결과를 표준 코드로 바꿔 응답한다상태코드와 업무코드를 함께 보고 변환표대로 표준 코드를 정한다. 정상일 때만 응답 본문 45바이트를 싣는다.
여기서 구분할 것 RFC 9110 이 뒷받침하는 것은 2xx 는 요청이 받아들여졌다는 뜻, 4xx 는 요청 쪽의 문제, 5xx 는 서버 쪽의 문제이고 422 는 요청 내용을 이해했지만 처리할 수 없다는 뜻이라는 상태 코드의 일반 의미이다. 여섯 걸음의 순서, 코드 이름(E101, E102, B201 따위), 45바이트는 이 코스의 가상 표준과 실습 픽스처이다.
잠깐, 예측해 보세요 계정계가 업무 거절을 422 로 주고 본문의 result 값으로 이유를 가른다. 변환표가 422 라는 상태코드만 보면 어떤 정보가 사라질까?
설명 확인 · 채점 없는 자가 점검
업무 거절의 이유가 사라진다. 본문은 이유가 result 에 있고 표준 코드도 B201 에서 B203 까지로 갈린다고 한다. 그래서 변환표는 상태코드와 업무코드를 함께 봐야 한다.
어떻게 동작하나
요청 한 건의 길. ① TCP 로 길이 4바이트를 정확히 읽고 그만큼 다시 읽는다(1모듈) ② 헤더를 파싱해 형식이 틀리면 E102 ③ 거래코드가 중계 대상이 아니면 E101(라우팅, 2모듈) ④ 본문을 레이아웃대로 JSON 으로 바꾼다(3모듈의 규칙, 이제 공통 라이브러리 lhconv) ⑤ 계정계 POST /v1/transfers 를 부른다 — GUID 를 본문과 X-GUID 헤더에 싣는다 ⑥ 결과를 표준 코드로 바꿔 응답 전문을 만든다(정상일 때만 응답 본문 45바이트).
결과를 네 갈래로 나눈다. HTTP 의미론(RFC 9110)에서 2xx 는 요청이 성공적으로 처리됐다는 뜻이고, 4xx 는 요청 쪽의 문제, 5xx 는 서버가 처리하지 못했다는 뜻이다. 계정계는 업무 거절을 422(RFC 9110 15.5.21, 요청 형식은 이해했지만 처리할 수 없는 내용)로 주고 본문의 result 로 이유를 가른다. 그래서 변환표는 상태코드와 업무코드를 함께 본다.
| 상황 | 알 수 있는 것 | 표준 코드 |
|---|---|---|
| 200 | 처리됨 | 0000 |
| 422 + INSUFFICIENT_FUNDS 등 | 거절됨(돈은 안 움직임) | B201~B203 |
| 500 | 대상이 처리하지 못했다고 말했다 | E500 |
| 응답을 기다리다 시간 초과 | 보냈다. 처리 여부는 모른다 | E901 |
| 연결 거부·연결 시간 초과 | 보내지 못했다(미전송 확정) | E902 |
타임아웃은 두 종류다. 연결을 맺는 동안의 시간 초과와, 요청을 보낸 뒤 응답을 기다리는 동안의 시간 초과는 뜻이 정반대다. 앞의 것은 요청 바이트가 한 바이트도 나가지 않았으니 다시 보내도 안전하다. 뒤의 것은 요청이 이미 대상에 도착했다. 파이썬 3.12 의 urllib.request.urlopen 은 이 둘을 다른 예외로 낸다(이 과정에서 실측) — 연결 단계의 문제는 URLError 로 감싸고(reason 이 ConnectionRefusedError 나 TimeoutError), 응답을 기다리다 난 시간 초과는 감싸지 않은 TimeoutError 로 올라온다. 예외 하나를 통째로 잡아 같은 코드로 바꾸면 이 구분이 사라진다.
타임아웃 예산은 안쪽으로 갈수록 짧아야 한다. 채널이 10초를 기다리는데 허브가 계정계를 15초 기다리면, 채널은 이미 포기하고 재전송했는데 허브는 아직 첫 요청을 붙들고 있다. 허브의 결과는 아무도 받지 않는다. 그래서 채널 > 허브 > 대상 순으로 예산을 줄이고, 허브는 자기 예산 안에서 반드시 무언가를 답한다 — 모르면 모른다(E901)고.
연결마다 따로 처리한다. 요청 하나를 처리하는 동안 다음 연결을 받지 않는 서버는, 느린 이체 하나 때문에 뒤의 모든 이체를 줄 세운다. 파이썬 표준 라이브러리의 socketserver.ThreadingTCPServer 는 연결마다 스레드를 하나씩 띄운다. 스레드는 한도 없이 늘어날 수 있으므로 11모듈에서 대상별 동시 처리 한도(벌크헤드)를 붙인다.
현장에서 만나는 모습
가장 비싼 사고는 E901 을 실패로 처리한 채널이다. "응답 시간 초과 — 다시 시도하세요" 라는 화면 문구 하나로 고객이 버튼을 한 번 더 누르고, 계정계에는 이체가 두 건 남는다. 그래서 결과 미확정 응답에는 재전송 금지와 결과 조회가 짝으로 붙는다(8모듈). 두 번째는 대상 시스템의 오류 메시지를 그대로 채널에 흘려보내는 허브다. 계정계 내부 오류 문구(스택 트레이스·테이블 이름)가 고객 화면에 뜨고, 채널은 대상마다 다른 문구를 해석하는 코드를 쌓는다. 세 번째는 로컬에서 늘 되던 중계가 운영에서 전문을 반씩 잃는 것 — 길이만큼 다 읽지 않고 recv 한 번으로 끝낸 코드다.
타임아웃은 두 종류이고 뜻이 정반대이다. 본문은 파이썬 3.12 의 urllib.request.urlopen 이 두 경우를 다른 예외로 낸다고 이 과정에서 실측했다고 적는다.
- 연결을 맺는 동안: 보내지 않은 것이 확실하다 (E902)연결 거부나 연결 시간 초과는 요청 바이트가 한 바이트도 나가지 않았다는 뜻이라 다시 보내도 안전하다. urlopen 에서는 URLError 로 감싸이고 reason 이 ConnectionRefusedError 나 TimeoutError 이다.
- 요청을 보낸 뒤 응답을 기다리는 동안: 모른다 (E901)요청이 이미 대상에 도착했으므로 처리했는지 아무도 모른다. urlopen 에서는 감싸이지 않은 TimeoutError 로 올라온다.
여기서 구분할 것 RFC 9110 9.2.2 가 뒷받침하는 것은 멱등하지 않은 요청은 원래 요청이 적용되지 않았음을 알 방법이 없으면 자동으로 다시 보내지 말아야 한다는 일반 규칙이다. 두 예외의 모양은 이 문서에서 확인한 것이 아니라 본문이 파이썬 3.12 에서 실측한 결과이므로 버전을 바꾸면 다시 확인해야 한다.
잠깐, 예측해 보세요 중계 코드가 except URLError 한 줄로만 오류를 잡는다. 계정계가 요청을 받은 뒤 응답을 늦게 주면 이 코드는 어떻게 될까?
설명 확인 · 채점 없는 자가 점검
본문 기준으로 응답을 기다리다 난 시간 초과는 URLError 로 감싸이지 않고 TimeoutError 로 올라오므로 이 except 에 걸리지 않는다. 연결 단계의 문제와 응답 대기의 문제를 같은 코드로 뭉개지 말고 예외를 나눠 E902 와 E901 로 옮겨야 한다.
다음 실습에서 할 것
계정계 픽스처를 띄워 직접 불러 보고, 정의서를 보고 응답코드 변환표를 쓴다. 그다음 중계 서버 relay.py 를 단계마다 키운다 — 조각난 전문을 끝까지 읽는 뼈대, 정상 이체 중계, 업무 오류 변환, 읽기 타임아웃(E901), 연결 실패(E902), 동시 처리. 채점기는 당신의 relay.py 를 직접 띄우고 계정계 픽스처의 호출 통계로 몇 번 불렀는지까지 본다.