TCP는 메시지가 무엇인지 모른다
Source: Dev.to
번역할 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.
소개
HTTP를 다룰 때, 나는 마음속 깊은 곳에 조용한 가정을 가지고 있었다:
하나를 보내면, 반대편도 하나를 받는다.
그것은 당연하게 느껴졌다. 질문하기엔 너무 당연할 정도로.
상위 계층에서 작업하면 그런 식으로 생각하게 된다. 요청은 하나의 단위처럼 보인다. 응답은 완전하게 보인다. 사물 사이의 경계가 실제처럼 느껴진다. 모든 것이 깔끔하고 자체적으로 포함된 것처럼 보여서, 그 아래에 무엇이 있는지는 생각하지 않게 된다. 요청은 원자적이라고 느껴진다. 응답은 전체라고 느껴진다.
그래서 HTTP에서 TCP로 옮겨갈 때, 나는 그 가정을 그대로 가져갔다. 오래가지 못했다.
구문을 대충 살펴보고, 작은 TCP 서버를 작성한 뒤 내가 이미 알고 있던 것을 적용해 보았다. 거의 즉시, 예상치 못한 방식으로 동작하기 시작했다. 데이터를 한 번 쓰면 조각조각 나눠서 돌아오거나, 전체가 되어야 할 것이 조각처럼 도착했다. 또 어떤 경우엔 두 개의 별도 쓰기가 합쳐져서 도착했다. 때로는 전혀 도착하지 않다가 훨씬 나중에야 나타났다.
내 코드에 버그가 있는가 생각했다. 그때 깨달았다: 나는 아직도 TCP를 HTTP처럼 다루고 있었다.
HTTP에서는 TCP가 보이지 않는다. 프레임워크가 스트림을 숨긴다. 하나의 요청을 보내면 하나의 응답을 받는다. 오류는 명시적이다. 부분 데이터가 새어나오지 않으므로, 그것에 대해 생각할 필요가 전혀 없다.
Source: …
TCP가 이렇게 동작하는 이유
TCP는 하나의 핵심 책임만 가지고 있습니다: 한 쪽에서 보낸 바이트가 다른 쪽에 신뢰성 있게, 동일한 순서대로 도착하도록 보장하는 것.
그게 전부입니다.
- 메시지를 이해하지 못합니다.
- 경계(boundary)를 보존하지 않습니다.
- 데이터가 어떻게 해석되길 의도했는지는 전혀 신경 쓰지 않습니다.
바이트가 TCP 스트림에 들어가면, 그것은 단순히 바이트일 뿐이며, 운영 체제가 사용 가능하다고 판단할 때 전달됩니다.
그래서 하나의 write가 여러 개의 data 이벤트로 도착할 수 있고, 여러 write가 하나로 합쳐질 수도 있습니다. 여러분이 보는 청크(chunking)는 TCP 기능이 아니라 전달 방식의 세부 사항입니다.
그 바이트들을 어떻게 처리할지는 전적으로 여러분의 책임입니다.
핵심 정리
data이벤트가 발생했다고 해서 전체 메시지가 도착했다는 의미는 아닙니다.- 메시지 경계는 여러분이 정의하고 강제해야 하는 것입니다.
- 오프셋은 단순한 장부 기록이 아니라 정확성을 위한 것입니다.
- 대부분의 프로토콜 버그는 크게 울리지 않고 조용히 실패합니다.
오프셋을 단 한 바이트라도 잘못 이동시키면, 파서는 항상 크래시하지는 않습니다. 손상된 상태에서 계속 실행될 뿐입니다. 이런 식으로 버그가 눈에 띄지 않고 지나가게 됩니다.
대부분의 예제가 무시하는 결과
TCP가 메시지 경계를 보존하지 않으면, 그 위에 구축된 모든 프로토콜은 자체적으로 정의해야 합니다.
명시적인 프레이밍이 없으면, 하나의 메시지가 어디서 끝나고 다음 메시지가 어디서 시작되는지 알 수 없습니다. 전체 메시지를 받았는지 아니면 일부만 받았는지 판단할 수 없습니다. 그리고 추측이 틀렸을 경우, 오류가 항상 명확히 드러나는 것은 아니며 종종 조용히 발생합니다.
이것이 프로토콜 버그가 발생하는 방식입니다.
이 깨달음이 당신에게 강요하는 것
TCP가 순서가 보장된 바이트 스트림 외에는 아무 것도 제공하지 않는다는 것을 받아들였을 때, 몇 가지 규칙은 피할 수 없게 됩니다:
- 연결당 버퍼를 유지하세요. 데이터는 불완전하게 도착하거나 합쳐져서 도착할 수 있습니다.
- 명시적인 프레이밍을 구현하세요. 메시지 경계는 여러분이 정의하지 않는 한 존재하지 않습니다.
- 완전한 데이터만 파싱하세요. 불완전한 데이터를 파싱하는 것은 복구 가능한 실수가 아닙니다.
파싱 시도가 불완전한 데이터 때문에 실패하면, 롤백하고 기다려야 합니다. 오프셋은 파싱이 시작된 지점으로 되돌아가야 하며, 그 외의 처리는 프로토콜을 조용히 오염시킵니다.
이것이 상태 머신이 필요해지는 지점이기도 합니다. 서로 다른 바이트는 연결이 어떤 단계에 있느냐에 따라 다른 의미를 가집니다. 엄격한 상태 강제가 없으면, 올바르게 프레이밍된 데이터조차도 잘못 해석될 수 있습니다.
제가 TCP를 구조를 제공해 주는 것으로 대우하는 것을 멈추자, 나머지는 자연스럽게 따라왔습니다:
- 버퍼는 더 이상 선택 사항이 아니었습니다.
- 프레이밍은 더 이상 사소한 것이 아니었습니다.
- 파싱은 가정이 아니라 획득해야 할 것이 되었습니다.
모든 바이트는 추적되어야 하고, 모든 오프셋은 정당화되어야 하며, 모든 실패는 의도적으로 처리되어야 합니다.
다음 글에서는 한 단계 더 깊이 들어가, 바이트 스트림 위에 프로토콜을 구축한다는 것이 실제로 무엇을 의미하는지 살펴보겠습니다. 라이브러리나 추상이 아니라 메커니즘—버퍼, 프레이밍, 그리고 파싱이 재시작 가능해야 하는 이유에 대해 다룹니다. 여기서 TCP는 혼란스러움을 멈추고 솔직해집니다.
이 글은 제가 Node.js에서 원시 TCP부터 시작해 BitTorrent를 처음부터 구축하는 시리즈의 일부입니다. 초점은 정확성과 프로토콜 규율에 있으며, 지름길이나 추상이 아닙니다.
이미 이 수준에서 TCP를 다뤄본 적이 있다면, 어떤 가정이 먼저 깨졌는지 알려주시면 좋겠습니다.