LabHub
배우기 러닝패스 코스

TCP 택배가 잘려 도착했다 · 느린 택배에 시간을 무한정 주지 않는다 · 이론

느린 택배에 시간을 무한정 주지 않는다

LabHub 에서 이어서 보기

한 줄 요약

길이와 시간에는 서로 다른 상한이 필요합니다. 매번 조금씩 진행한다고 무한히 기다려도 되는 것은 아닙니다.

왜 이게 필요했나

택배 봉투에는 4 GiB 가까운 본문이 온다고 쓰여 있습니다. 값 자체는 32비트 정수에 들어가지만 우리 서비스의 수신 예산은 4096바이트입니다. 이 숫자를 믿고 메모리를 먼저 할당하면 작은 헤더 하나로 큰 자원을 소비합니다. 상대의 약속은 자원 할당 허가가 아닙니다. 이 실습은 큰 본문이 실제로 오기를 기다리지 않고 헤더를 완성한 순간 거절하는 연습입니다.

메모리를 제한한 다음에는 시간이 남습니다. 한 바이트가 올 때마다 1초를 새로 기다린다면 상대가 0.9초마다 한 바이트를 보낼 때 연결은 계속 살아 있습니다. 프레임 전체를 1초 안에 받아야 한다는 요구와 각 recv가 1초 안에 돌아온다는 요구는 다릅니다. 전자는 요청의 끝까지 남은 예산을 전달해야 합니다.

어떻게 동작하나

처음에 deadline=clock()+timeout을 한 번 계산합니다. 매번 recv를 부르기 직전에 remaining=deadline-clock()을 계산합니다. remaining이 0 이하이면 더 읽기 전에 TimeoutError를 냅니다. 그렇지 않으면 sock.settimeout(remaining)으로 블로킹 읽기도 남은 시간을 넘기지 않게 합니다. 헤더와 본문이 같은 deadline을 공유해야 합니다. 헤더가 늦게 왔다고 본문용 시간을 새로 지급하지 않습니다.

기본 시계는 time.monotonic입니다. 시계 보정으로 벽시계가 뒤로 움직여도 경과 시간 계산이 역행하지 않게 합니다. 시험에서는 clock 인자로 함수 하나를 주입합니다. 그러면 실제로 수 초씩 자면서 경계 상황을 기다리는 대신, recv가 진행할 때 모의 시각을 조금씩 전진시킬 수 있습니다. 모의 시험은 시간 예산 계산을 결정적으로 검사하고, 실제 소켓 시험은 운영체제 인터페이스와의 결합을 검사합니다. 둘 중 하나만으로 다른 하나를 증명했다고 말하지 않습니다.

read_exact는 n바이트를 얻을 때까지 남은 크기만큼 recv를 반복합니다. recv(n)이 n보다 적게 주는 것은 정상입니다. b""는 EOF입니다. 새 헤더를 전혀 받지 않은 채 EOF면 메시지가 더 없으므로 None을 반환합니다. 헤더 한 바이트라도 받았거나 본문 일부가 부족하면 EOFError입니다. 길이 0인 본문은 읽을 바이트가 없으므로 b""를 바로 반환합니다. 이때 recv를 또 불러 버리면 다음 프레임을 먹거나 쓸데없이 기다릴 수 있습니다.

성공한 경우뿐 아니라 예외가 난 경우에도 호출 전 소켓 timeout을 복원해야 합니다. 같은 소켓을 다음 작업에 넘길 때 지난 읽기의 짧은 remaining 값이 남아 있으면 다음 작업이 알 수 없는 이유로 빨리 실패합니다. try/finally는 이 자원 계약을 표현합니다. timeout이 0·음수·무한대·NaN이면 양의 유한 예산이라는 API 계약을 위반하므로 ValueError입니다.

길이 오류 후에는 그 연결을 폐기합니다. 임의로 한 바이트씩 버리며 다음 헤더를 찾아보는 복구를 이 프로토콜은 정의하지 않았습니다. 따라서 다음 숫자가 그럴듯해 보인다고 재동기화하면 본문 중간을 새 메시지로 오해합니다. 실제 프로토콜이 복구 지점이나 체크섬을 정의하는지는 별도 문제입니다. 없는 규칙을 파서가 상상해서 추가하지 않습니다.

현장에서 만나는 모습

전체 요청 기한과 하위 작업별 기한의 차이는 DB 호출과 외부 API에도 나타납니다. 각각 2초인 작업 네 개가 순서대로 실행되면 사용자 요청 전체는 2초를 훨씬 넘을 수 있습니다. 남은 예산을 아래로 전달하고, 취소 시 무엇을 닫을지 정해야 합니다. 이 코스는 동기식 TCP 프레임 한 개의 예산까지만 구현합니다. 대규모 동시 접속·이벤트 루프·전역 송신 큐의 역압력은 별도 실시간 과정의 범위입니다.

예를 들어 시작 시각이 10.0초이고 예산이 1초라면 deadline은 11.0입니다. 첫 바이트를 10.4에 받았으면 다음 recv의 예산은 0.6초, 그다음을 10.8에 받았으면 0.2초입니다. 그 뒤 바이트가 0.4초 걸려 도착한다면 남은 0.2초 안에 읽지 못한 것입니다. 매번 1초를 다시 주는 구현은 모든 호출이 성공한 것처럼 보이지만 전체 요청 계약은 이미 어겼습니다.

반대로 호출자의 시계가 주입된 시험에서 실제 time.sleep을 반복하면 모의 시간과 현실 시간이 섞입니다. clock 함수는 현재 시각을 반환하는 역할만 하고, 모의 소켓은 바이트 진행에 맞춰 모의 시각을 전진시킵니다. 실제 소켓을 쓰는 경로에서는 기본 monotonic을 유지합니다. 이 구분을 API에 적으면 다른 구현도 같은 계약으로 시험할 수 있으며 특정 import 문장에 시험을 억지로 맞출 필요가 없습니다.

시간 초과 후에는 남은 프레임을 다시 읽을지 연결을 닫을지 정해야 합니다. 이 코스는 읽기 실패 후 해당 연결을 폐기하므로 중간 상태를 다른 호출로 이어 붙이지 않습니다. 예외를 잡고 빈 bytes로 바꿔 반환하면 불완전한 데이터가 유효한 빈 메시지처럼 보입니다. 오류 종류를 호출자에게 전달하는 것이 복구 정책을 분리하는 출발점입니다.

다음 확인에서 할 것

바로 뒤 퀴즈에서 상한과 전체 기한을 구분하고 마지막 모듈에서 정상 분할 입력, 중간 EOF, 초과 길이 헤더, 느린 입력을 각각 실행합니다. 매번 timeout을 새로 지급하는 그럴듯한 오답도 시험에 넣습니다. 오류가 발생했다는 사실뿐 아니라 원래 소켓 설정이 돌아왔는지도 확인합니다. 1초는 교육용 계약이며 모든 업무에 적용할 권장값은 아닙니다.