스트림과 프레이밍 — TCP 의 send 와 recv 는 어긋난다
TCP 가 전하는 것은 메시지의 나열이 아니라 바이트의 흐름. send 의 경계는 recv 에 남지 않는다 — 경계가 필요하면 앱이 스스로 만든다. 단골 버그를 축으로 배운다.
TCP 는 올바르게 동작하고 있는데, 데이터가 「깨진다」
여기까지의 5 개 장에서, TCP 는 3 가지 약속을 훌륭하게 지킨다는 것을 봐 왔습니다. 그런데 실무에서는 TCP 위에서 「가끔 수신 데이터가 빠진다」「2 개의 메시지가 붙어서 도착한다」는 문제가 빈발합니다. 패킷 캡처를 봐도 재전송도 로스도 없다. TCP 는 완전히 정상. 그런데도 앱의 데이터는 깨져 보인다 — 왜일까요.
답은 TCP 가 전하는 것의 정체에 있습니다. TCP 가 약속한 것은 「같은 바이트열을, 같은 순서로」이지, 「같은 경계로」가 아닙니다. 제 3 장에서 본 대로, TCP 의 주역은 세그먼트가 아니라 바이트입니다. send() 를 300 바이트·200 바이트의 2 회로 나누어 호출해도, 네트워크를 여행하는 동안 1 개의 세그먼트로 합쳐질지도 모르고, 120 + 380 으로 다시 잘릴지도 모릅니다. 수신 측의 recv() 가 돌려주는 덩어리의 경계는, 송신 측의 send 의 경계와 무관계한 것입니다.
이 성질을 바이트 스트림(메시지 경계를 보존하지 않는다)이라고 부릅니다. 소켓은 편지 묶음을 나르는 우편 배달부가 아니라, 경계가 없는 수도관입니다. 수도관에 「첫 잔의 물」과 「둘째 잔의 물」의 경계가 남지 않는 것과 같습니다.
send 의 경계(위 단)는 recv 의 경계(아래 단)에 이어지지 않습니다. 바이트의 내용과 순서만이 약속됩니다. 「1 send = 1 recv」가 성립하는 것은 우연입니다.
경계가 바뀌는 2 개의 현장
경계는 어디서 바뀌는 것일까요. 대표적인 현장이 2 개 있습니다.
어느 쪽도 TCP 에게는 올바른 최적화입니다. 개발 환경(로컬, 저지연, 작은 데이터)에서는 우연히 「1 send = 1 recv」가 계속 성립하기 때문에, 이 전제의 버그는 프로덕션에서만, 가끔 발화합니다. 「재현되지 않는 문제」의 명산지입니다.
소문제 6-1 — 바이트 스트림이라는 성질
「가끔 수신 데이터가 빠진다·붙는다」 — 실무 단골 버그의 정체를 확인합니다.
Q1. TCP 의 커넥션에서 송신 측이 send() 를 2 회(300 바이트와 200 바이트) 호출했습니다. 수신 측의 recv() 의 결과로 있을 수 있는 것은 어느 것입니까.
TCP 가 약속하는 것은 「같은 바이트열이 같은 순서로 도착한다」는 것뿐이며, send 의 경계는 보존되지 않습니다. 경로의 사정으로 2 회분이 1 세그먼트로 합쳐질 수도, 1 회분이 여러 개로 쪼개질 수도 있습니다. 500 바이트의 순서는 반드시 유지되므로, 나중에 보낸 분량이 먼저 도착하는 일은 없습니다. 경계만이 신용할 수 없는 것입니다.
Q2. 「recv() 가 돌려준 데이터를 1 개의 JSON 메시지로 parse 한다」는 코드가, 개발 환경에서는 동작하는데 프로덕션에서 가끔 실패합니다. 가장 가능성이 높은 원인은 어느 것입니까.
단골 중의 단골 버그입니다. 개발 환경은 로컬이라 저지연·메시지도 작아, 우연히 1 send = 1 recv 가 성립해 버립니다. 프로덕션에서는 지연·분할·결합의 조건이 바뀌어, 어중간한 JSON(parse 에러)이나 2 통 연결(후반이 사라진 것처럼 보임)이 발생합니다. 데이터의 파손이라면 TCP 의 체크섬과 재전송이 막아 주고, 순서도 항상 보증됩니다. 깨져 있는 것은 데이터가 아니라 경계의 전제입니다.
Q3. 작은 send 를 연속해서 호출하면, TCP 가 그것들을 1 개의 세그먼트로 합쳐 보내는 일이 있습니다(Nagle 알고리즘). 이 동작의 목적과 부작용의 설명으로 올바른 것은 어느 것입니까.
1 바이트를 보내는 데도 TCP/IP 헤더로 약 40 바이트가 붙습니다. Nagle 알고리즘은 「ACK 대기의 작은 데이터가 있는 동안은, 다음의 작은 송신을 모아 기다린다」는 것으로 이 낭비를 억제합니다. 대가는 대화형의 작은 쓰기의 지연으로, 실시간성이 필요한 장면에서는 TCP_NODELAY 로 무효화합니다. 여기서도 중요한 것은, TCP 가 보낸 단위를 아무렇지 않게 재조합한다는 사실입니다.
프레이밍 — 경계는 앱이 스스로 만든다
메시지의 경계가 필요하다면, 경계의 정보를 바이트열 그 자체 안에 심어 넣을 수밖에 없습니다. 이것을 프레이밍이라고 부릅니다. 정석은 2 가지입니다.
어느 방식에서도, 수신 측의 코드는 같은 형태가 됩니다 — recv 로 도착한 분량을 버퍼에 추가하고, 완성된 메시지만을 잘라내며, 나머지는 다음으로 넘긴다. recv 의 횟수나 경계에는 일절 의존하지 않는 루프입니다. 거꾸로 말하면, 이 루프만 올바르면, TCP 가 어떤 경계로 전달하든 앱은 깨지지 않습니다.
기억하는 법: TCP 에 「1 통」이라는 개념은 없습니다. 「1 통」을 발명하는 것은 언제나 앱 측의 일입니다. 기성 프로토콜이나 라이브러리를 사용할 때도, 그 아래에서 이 프레이밍이 움직이고 있습니다.
소문제 6-2 — 경계는 스스로 만든다(프레이밍)
경계가 보존되지 않는다면, 경계의 정보를 데이터 안에 넣을 수밖에 없습니다.
Q4. 길이 프리픽스 방식(각 메시지의 선두에 본문의 길이를 붙여 보낸다)의 수신 측의 올바른 구현은 어느 것입니까.
수신 측은 「바이트의 흐름에서 스스로 잘라내는」 루프를 작성합니다 — (1) 길이 필드가 갖춰질 때까지 쌓는다, (2) 길이를 읽는다, (3) 본문이 그 길이만큼 갖춰질 때까지 쌓는다, (4) 잘라내어 처리하고, 나머지는 다음으로 넘긴다. recv 의 횟수나 경계에는 일절 의존하지 않습니다. 시간으로 구분하는 방식은 지연에 따라 깨지고, 앱 계층으로부터의 재전송 요구라는 구조는 TCP 에는 없습니다.
Q5. 줄바꿈 문자 등의 구분 문자로 메시지 경계를 만드는 방식(예: 1 행 = 1 메시지)을 사용할 때, 반드시 설계해 두어야 할 것은 어느 것입니까.
본문에 구분 문자 그 자체가 나타나면, 수신 측은 거기를 가짜 경계로 잘라 버립니다. 그래서 구분 문자 방식에는 「본문에 구분 문자를 포함하지 않는다」는 보증 — 이스케이프(예: 줄바꿈을 \n 으로 쓴다)나 Base64 같은 인코딩 — 이 필수입니다. JSON Lines 가 「JSON 안의 줄바꿈은 반드시 \n 으로 이스케이프된다」는 성질에 올라타 있는 것이 좋은 예입니다. 고정 길이화나 랜덤화는 방식 자체를 다른 것으로 만드는 이야기로, 대책으로서 맞물리지 않습니다.
Q6. 길이 프리픽스 4 바이트 + 본문 300 바이트(계 304 바이트)의 메시지를 2 통 연속으로 보냈습니다(합계 608 바이트). 수신 측의 최초 recv() 는 500 바이트를 돌려주었습니다. 2 통째의 메시지를 완성시키려면, 앞으로 몇 바이트를 수신할 필요가 있습니까.
1 통째는 바이트 1〜304 로, 500 바이트 안에 통째로 들어 있습니다. 남는 500 − 304 = 196 바이트는 2 통째의 선두 부분(프리픽스 4 바이트 + 본문 192 바이트). 2 통째는 전체 304 바이트이므로, 앞으로 304 − 196 = 108 바이트입니다. 수신 버퍼에 196 바이트를 보관한 채 다음 recv 를 기다린다 — 소문제 6-2 제 1 문의 루프의 움직임 그 자체입니다.
이 장에서 가져갈 것
- TCP 는 바이트 스트림 — 약속은 「같은 바이트열을 같은 순서로」. send 의 경계는 보존되지 않는다
- 경계는 결합(Nagle)과 분할(MSS)로 일상적으로 바뀐다. 「1 send = 1 recv」는 개발 환경의 우연
- 경계가 필요하면 프레이밍 — 길이 프리픽스 또는 구분 문자. 구분 문자에는 이스케이프의 설계가 필수
- 수신 측의 정석은 「쌓고, 갖춰진 분량만 잘라내고, 나머지는 넘긴다」. recv 의 경계에 의존하지 않는다
이것으로 부품은 모두 갖춰졌습니다. 마지막 장에서는 실무풍의 8 가지 장애 케이스를 「어느 장의 이야기인가」로 번역하면서 구분해 나갑니다.