합동회사 코무라소프트
6장

스트림과 프레이밍 — 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 의 경계와 무관계한 것입니다.

이 성질을 바이트 스트림(메시지 경계를 보존하지 않는다)이라고 부릅니다. 소켓은 편지 묶음을 나르는 우편 배달부가 아니라, 경계가 없는 수도관입니다. 수도관에 「첫 잔의 물」과 「둘째 잔의 물」의 경계가 남지 않는 것과 같습니다.

송신 측의 2 회의 send 의 경계와, 수신 측의 recv 가 돌려주는 덩어리의 경계가 일치하지 않아, 메시지가 도중에 잘리거나 연결되거나 하여 보이는 모습의 그림

send 의 경계(위 단)는 recv 의 경계(아래 단)에 이어지지 않습니다. 바이트의 내용과 순서만이 약속됩니다. 「1 send = 1 recv」가 성립하는 것은 우연입니다.

경계가 바뀌는 2 개의 현장

경계는 어디서 바뀌는 것일까요. 대표적인 현장이 2 개 있습니다.

결합 — 작은 send 가 합쳐진다(Nagle 알고리즘)
1 바이트의 데이터에도 약 40 바이트의 헤더가 붙는다. 작은 송신을 잘게 흘리는 낭비를 막기 위해, ACK 대기 동안은 다음의 작은 데이터를 모아 기다렸다가 1 세그먼트로 보낸다. 대화형 통신에서 지연이 문제가 될 때는 TCP_NODELAY 로 무효화할 수 있다
분할 — 큰 send 가 잘려 나뉜다(MSS)
1 세그먼트로 운반할 수 있는 데이터에는 상한(MSS: 많은 환경에서 1460 바이트 전후)이 있다. 10 KB 의 send 는 반드시 여러 세그먼트로 쪼개지고, 수신 측에는 여러 번의 recv 로 나뉘어 보이는 일이 있다

어느 쪽도 TCP 에게는 올바른 최적화입니다. 개발 환경(로컬, 저지연, 작은 데이터)에서는 우연히 「1 send = 1 recv」가 계속 성립하기 때문에, 이 전제의 버그는 프로덕션에서만, 가끔 발화합니다. 「재현되지 않는 문제」의 명산지입니다.

소문제 6-1 — 바이트 스트림이라는 성질

「가끔 수신 데이터가 빠진다·붙는다」 — 실무 단골 버그의 정체를 확인합니다.

Q1. TCP 의 커넥션에서 송신 측이 send() 를 2 회(300 바이트와 200 바이트) 호출했습니다. 수신 측의 recv() 의 결과로 있을 수 있는 것은 어느 것입니까.

Q2. 「recv() 가 돌려준 데이터를 1 개의 JSON 메시지로 parse 한다」는 코드가, 개발 환경에서는 동작하는데 프로덕션에서 가끔 실패합니다. 가장 가능성이 높은 원인은 어느 것입니까.

Q3. 작은 send 를 연속해서 호출하면, TCP 가 그것들을 1 개의 세그먼트로 합쳐 보내는 일이 있습니다(Nagle 알고리즘). 이 동작의 목적과 부작용의 설명으로 올바른 것은 어느 것입니까.

프레이밍 — 경계는 앱이 스스로 만든다

메시지의 경계가 필요하다면, 경계의 정보를 바이트열 그 자체 안에 심어 넣을 수밖에 없습니다. 이것을 프레이밍이라고 부릅니다. 정석은 2 가지입니다.

길이 프리픽스 방식
각 메시지의 선두에 「본문은 몇 바이트인가」를 고정 길이(예: 4 바이트)로 붙인다. 수신 측은 길이만큼 갖춰질 때까지 버퍼에 쌓았다가 잘라낸다. 바이너리에 강하며, HTTP/2 나 gRPC 의 하부도 이 계통
구분 문자 방식
메시지의 끝에 줄바꿈 등의 표식을 둔다(1 행 = 1 메시지). 사람이 읽기 쉽고 디버그하기 쉽다. 다만 본문에 구분 문자가 나타나지 않는다는 보증(이스케이프나 인코딩)이 필수. HTTP/1.1 의 헤더나 Redis 의 프로토콜이 이 계통

어느 방식에서도, 수신 측의 코드는 같은 형태가 됩니다 — recv 로 도착한 분량을 버퍼에 추가하고, 완성된 메시지만을 잘라내며, 나머지는 다음으로 넘긴다. recv 의 횟수나 경계에는 일절 의존하지 않는 루프입니다. 거꾸로 말하면, 이 루프만 올바르면, TCP 가 어떤 경계로 전달하든 앱은 깨지지 않습니다.

기억하는 법: TCP 에 「1 통」이라는 개념은 없습니다. 「1 통」을 발명하는 것은 언제나 앱 측의 일입니다. 기성 프로토콜이나 라이브러리를 사용할 때도, 그 아래에서 이 프레이밍이 움직이고 있습니다.

소문제 6-2 — 경계는 스스로 만든다(프레이밍)

경계가 보존되지 않는다면, 경계의 정보를 데이터 안에 넣을 수밖에 없습니다.

Q4. 길이 프리픽스 방식(각 메시지의 선두에 본문의 길이를 붙여 보낸다)의 수신 측의 올바른 구현은 어느 것입니까.

Q5. 줄바꿈 문자 등의 구분 문자로 메시지 경계를 만드는 방식(예: 1 행 = 1 메시지)을 사용할 때, 반드시 설계해 두어야 할 것은 어느 것입니까.

Q6. 길이 프리픽스 4 바이트 + 본문 300 바이트(계 304 바이트)의 메시지를 2 통 연속으로 보냈습니다(합계 608 바이트). 수신 측의 최초 recv() 는 500 바이트를 돌려주었습니다. 2 통째의 메시지를 완성시키려면, 앞으로 몇 바이트를 수신할 필요가 있습니까.

바이트

이 장에서 가져갈 것

  • TCP 는 바이트 스트림 — 약속은 「같은 바이트열을 같은 순서로」. send 의 경계는 보존되지 않는다
  • 경계는 결합(Nagle)과 분할(MSS)로 일상적으로 바뀐다. 「1 send = 1 recv」는 개발 환경의 우연
  • 경계가 필요하면 프레이밍 — 길이 프리픽스 또는 구분 문자. 구분 문자에는 이스케이프의 설계가 필수
  • 수신 측의 정석은 「쌓고, 갖춰진 분량만 잘라내고, 나머지는 넘긴다」. recv 의 경계에 의존하지 않는다

이것으로 부품은 모두 갖춰졌습니다. 마지막 장에서는 실무풍의 8 가지 장애 케이스를 「어느 장의 이야기인가」로 번역하면서 구분해 나갑니다.