Code

문자열의 끝을 컴퓨터는 어떻게 찾을까

종료 문자부터 길이 명시와 HTTP 청크까지 데이터의 경계를 표현해 온 방법

Encode and Decode Strings 문제를 풀면서 문자열 앞에 길이를 붙이는 방식이 어디서 시작됐는지 궁금해졌다 ㅎㅎ 문자열 목록을 하나로 합쳤다가 복원하려면 각 문자열의 경계를 전달해야 하는데, 이 문제는 프로그래밍 언어의 문자열 표현부터 네트워크 프로토콜의 메시지 프레이밍까지 걸쳐져 있다.

자료를 확인해 보니 종료 문자와 길이 명시, HTTP 청크는 모두 데이터의 끝을 전달하지만 해결하려는 범위가 달랐다.


데이터의 끝을 어떻게 표현할까?

연속된 데이터에서 경계를 표현하는 기본 방법은 크게 세 가지로 나눌 수 있다.

첫 번째는 고정 길이다. 모든 데이터가 10바이트라고 약속하면 수신자는 10바이트마다 하나의 값을 끊어 읽을 수 있다. 구현은 단순하지만 실제 데이터가 짧으면 공간이 낭비되고, 더 긴 값은 담을 수 없다.

두 번째는 종료 문자나 구분자를 두는 방식이다.

hello\0world\0

본문을 읽다가 약속한 문자를 만나면 하나의 문자열이 끝났다고 판단한다. 구조가 단순하고 길이 정보를 따로 저장하지 않아도 되지만, 본문에 같은 문자를 넣으려면 이를 금지하거나 escape 규칙을 추가해야 한다.

세 번째는 길이를 먼저 기록하는 방식이다.

5:hello5:world

디코더는 구분자를 찾은 뒤 앞의 숫자를 길이로 읽고, 그 길이만큼 본문을 가져온다. 콜론은 본문의 끝이 아니라 길이 정보의 끝을 표시하므로, 본문에 콜론이나 null 문자가 들어 있어도 길이만큼 읽으면 경계가 깨지지 않는다.

이 세 방식은 서로를 완전히 대체하지 않는다. 데이터 크기를 미리 고정할 수 있는지, 본문에 어떤 값이 들어올 수 있는지, 전체 길이를 전송 전에 알 수 있는지에 따라 선택이 달라진다.


C 문자열은 종료 문자를 찾는다

C에서는 문자열을 별도의 내장 자료형으로 두지 않고, null 문자로 끝나는 문자 배열로 다룬다. Dennis Ritchie도 C의 발전 과정을 정리한 글에서 C 문자열을 문자 배열과 종료 표식의 조합으로 설명했다.

['h', 'e', 'l', 'l', 'o', '\0']

이 표현은 배열이라는 기존 구조를 그대로 사용할 수 있어서 언어와 컴파일러의 규칙이 단순해진다. 반면 길이를 별도로 보관하지 않으면 문자열 길이를 구할 때 null 문자가 나올 때까지 순서대로 읽어야 하고, 본문 중간에 null 문자가 들어가면 C 문자열 함수는 그 위치를 문자열의 끝으로 처리한다.

Ritchie는 이 선택의 비용도 함께 언급했다. 일부 문자열 연산은 끝을 찾기 위해 데이터를 훑어야 하고 저장 공간을 관리할 책임도 프로그램에 더 많이 남지만, 종료 문자 방식은 단순한 배열 규칙을 유지하기 위해 선택한 트레이드오프다.


TCP에서는 메시지 경계를 다시 만들어야 한다

문자열 하나를 메모리에 저장하는 문제와 네트워크로 여러 메시지를 보내는 문제는 범위가 다르지만, 수신자가 어디까지 읽어야 하는지 알아야 한다는 점은 같다.

TCP는 애플리케이션 메시지 단위를 보존하지 않는 바이트 스트림을 제공한다. 송신자가 두 번 나누어 보낸 데이터가 수신자에게도 같은 두 덩어리로 도착한다고 가정할 수 없어서, 애플리케이션 프로토콜이 메시지 경계를 직접 정의해야 한다.

syslog의 TCP 전송을 정리한 RFC 6587은 두 방식을 나란히 보여 준다. octet counting은 메시지 길이를 먼저 보내고, non-transparent framing은 LF나 null 같은 종료 문자를 붙인다.

23 <23>message payload...

길이를 먼저 읽으면 본문 안의 모든 문자를 그대로 허용할 수 있다. 종료 문자를 사용하는 방식은 본문에 같은 문자가 들어왔을 때 수신자가 이를 여러 메시지로 잘못 나눌 수 있다. LeetCode 문제에서 구분자만 사용했을 때 발생하는 문제가 실제 프로토콜에서도 그대로 반복된다.


HTTP는 전체 길이와 청크 길이를 구분한다

HTTP/1.0을 정리한 RFC 1945는 본문의 전체 크기를 알고 있을 때 Content-Length로 바이트 수를 전달하고, 그렇지 않은 응답은 서버가 연결을 닫는 것으로 끝을 판단할 수 있다고 설명한다.

Content-Length: 3495

연결을 닫아서 끝을 알리는 방식은 응답 하나를 보낸 뒤 연결을 계속 재사용하기 어렵다. 동적으로 생성하는 응답은 전송을 시작할 때 전체 길이를 아직 모를 수도 있다.

HTTP/1.1은 이 조건을 위해 chunked transfer coding을 도입했다. 최초 HTTP/1.1 규격인 RFC 2068은 1997년 1월에 발행됐고, 각 청크의 크기를 앞에 붙여 동적으로 생성되는 콘텐츠를 전송하는 구조를 담고 있다. 1999년의 RFC 2616은 이를 처음 발명한 문서가 아니라 HTTP/1.1 규격을 개정한 문서다.

현재 HTTP/1.1 메시지 형식을 정의하는 RFC 9112의 구조를 단순화하면 다음과 같다.

[16진수 청크 크기]\r\n
[청크 데이터]\r\n
...
0\r\n
\r\n

각 청크는 자기 크기를 가지고, 크기가 0인 청크가 전송의 끝을 알린다. 전체 본문 크기를 미리 몰라도 완성된 조각부터 보낼 수 있고, 연결도 바로 닫지 않아도 된다.

LeetCode 문제에서 길이는 문자열 하나가 어디서 끝나는지 알려 준다. 반면 HTTP의 청크 크기는 하나의 응답 본문을 전송하기 좋게 나눈 각 조각의 크기인데, 예를 들어 1,000바이트짜리 HTML을 400바이트와 600바이트 청크로 보내더라도, 수신 측은 두 청크를 이어 붙여 원래의 1,000바이트 HTML로 사용한다. 청크를 어디서 나눴는지는 HTML 내용의 구분과 관계가 없다.

이번 글은 단순히 호기심에서 찾아본 내용이지만 나름 재미있는 내용이었고 또 도움이 되는 내용이었다. 앞으로도 자주 궁금할때마다 찾아서 정리해보려고 한다.