네트워크 바이트 순서 변환 함수인 ntoh*/hton* 계열 API가 왜 잘못된 추상화인지, 정수와 엔디언, 직렬화 형식을 분리해서 이해해야 하는 이유를 설명한다.
2025년 11월 18일 · Lobsters
다른 마이크로서비스로 JSON을 통해 네트워크 상에서 소비자 레코드(consumer records) 목록을 보내고 싶다고 하자. 이 과정에는 세 가지 개념이 작용한다.
논리적 값(logical value) — 우리가 인간으로서 데이터를 다루는 방식이다. 이 예시에서는 “소비자 레코드의 목록”이 된다. 이 설명은 그 값이 컴퓨터에서 어떻게 표현되는지, 혹은 컴퓨터를 쓰고 있는지조차 전혀 규정하지 않는다.
데이터 타입(data type) — 예를 들어 std::vector<ConsumerRecord>. 데이터 타입의 목적은 두 가지다. (a) 논리적 값의 특정한 런타임 표현을 컴퓨터 메모리 안에서 나타내고, (b) 이 구현 세부사항을 신경 쓰지 않고도 논리적 값을 다룰 수 있게 하는 추상화를 제공한다.
직렬화 포맷(serialization format) — 여기서는 JSON. 이는 논리적 값을 바이트 시퀀스 중심의 대체 표현으로 나타내지만, JSON 문자열 안에 인코딩된 레코드를 직접 다룰 수는 없으므로 추상화 계층을 제공하지는 않는다.
이 세 개념은 대부분 서로 직교한다. vector를 연결 리스트로 바꾸거나 아예 프로그래밍 언어를 바꾸더라도, 바뀌는 것은 데이터 타입뿐이다. 또한 애플리케이션 내부에서 데이터를 다루는 방식은 그대로 두고, 다른 마이크로서비스와는 XML로 통신할 수도 있다.
보통 라이브러리는 데이터 타입과 직렬화된 데이터를 서로 변환하는 (역)직렬화 함수를, 대략 다음과 같은 모양의 API로 제공한다.
cchar* serialize(struct object obj); // malloc으로 할당한 버퍼를 반환 struct object deserialize(const char* bytes);
이제 같은 아이디어를 이진 프로토콜(binary protocol)을 통해 전송되는 정수에 적용해 보자. 여기에도 다음 세 가지가 있다.
논리적 값 — “정수(integer)”. 이는 13 같은 숫자 개념에 대응한다. 컴퓨팅이나 10진법과 굳이 연결될 필요는 없다. 열두 개 손가락을 가진 외계인도 13이라는 개념은 알고 있겠지만, 이를 전혀 다른 방식으로 소통할 수 있다. 예를 들어 특정한 기호(여기서는 a라고 하자)로 표현할 수도 있다.
조작에 사용되는 데이터 타입 — 예를 들어 int나 uint32_t. 우리는 CPU가 이를 내부적으로 어떻게 다루는지 신경 쓰지 않고 정수에 대해 산술 연산을 할 수 있다. 아예 정수가 AES로 암호화돼 있다고 해도 상관없을 것이다. 추상화가 2 + 2 == 4라고만 보장해 준다면, 구체적인 구현은 무엇이든 괜찮다.
직렬화 포맷 — 정수가 바이트 시퀀스로 어떻게 인코딩되는지를 기술한다. 가장 단순한 접근은 수를 8비트 단위로 쪼개어 특정 순서대로 나열하는 것이다. 가장 흔한 두 가지 순서를 “리틀 엔디언(little-endian)”과 “빅 엔디언(big-endian)”이라고 부른다.
정수에 대한 (역)직렬화 API는 대략 이렇게 생겼을 수 있다.
cchar[4] serialize(uint32_t num); uint32_t deserialize(char bytes[4]);
하지만 C 표준 라이브러리는 이런 API를 제공하지 않는다. 대신 훨씬 더 사악한 무언가를 노출한다.
cuint32_t htonl(uint32_t hostlong); uint32_t ntohl(uint32_t netlong);
htonl은 serialize 흉내를, ntohl은 deserialize 흉내를 낸다. 어디까지나 “흉내”일 뿐이다.
uint32_t는 추상화여야 한다. 즉 “정수”라는 개념의 컴퓨터 구현체다. 그런데 직렬화된 데이터, 논리적으로는 바이트의 시퀀스인 그것이 왜 또 하나의 정수로 취급되는가? 이는 우리가 데이터의 복잡성을 줄이려 한다는 점을 고려하면 전혀 말이 되지 않는다. 게다가 이 값은 데이터 타입의 의미에서 말하는 “정수”조차 아니다. htonl이 돌려준 값들에 연산(예를 들어, 서로 더하기)을 하면 아무 의미 없는 값이 나온다.
소켓이 바이트 스트림만 처리할 수 있고, std::vector<ConsumerRecord>를 바이트 시퀀스로 변환하기 전에는 보낼 수 없다면, htonl이 원하는 것처럼 uint32_t를 직접 보내는 것이 그다지 말이 되지 않는다. 이는 범주 오류(category error)다. 이게 그럭저럭 동작하는 유일한 이유는 정수의 런타임 표현 — 즉 uint32_t 데이터 타입의 바이트 레이아웃 — 이 의도된 직렬화 포맷과 어느 정도 비슷하기 때문이다. 여기서 “어느 정도 비슷하다”는 말은 “길이가 같고 비트 패턴이 유효하다”는 뜻 정도일 뿐이다. htonl은 흉측한 핵(hack)이다. 이 함수는 데이터 타입이 표현하는 새 값의 의미 같은 건 아랑곳하지 않고, 그저 런타임 표현을 뒤틀어서 그 바이트 시퀀스가 의도된 출력과 일치하도록 조작할 뿐이다.
이런 짓을 다른 타입에 대해서도 한다고 상상해 보라. std::vector의 바이트 순서를 재배열하는 건 광기이며, 정의되지 않은 동작(UB)이 난무하게 만들 뿐이다. 가령 가상의 함수 bool htonb(bool hostbool)이 있다고 해보자. 여기서 “host bool”은 0 혹은 1 바이트로 표현되고, “network bool”은 0 혹은 0xFF로 표현된다고 하자. 이런 함수는 어떤 ABI에서는 애초에 구현조차 불가능할 수 있으며, 설령 가능하더라도 정의되지 않은 동작 없이 구현하기 어렵다. 그리고 많은 경우에 런타임 표현과 직렬화 표현은 길이조차 같다는 보장이 없다.
사실 htonl이 char[4]를 반환하지 않고, ntohl이 char[4]를 인자로 받지 않는 진짜 이유는, C가 그 위치에 배열 타입을 쓸 수 있게 지원하지 않기 때문이다. 이는 오로지 언어의 결함 때문이다. 더 나은 언어라면 이런 시그니처의 함수를 절대 노출하지 않을 것이다. 실제로 Go, Python, Java는 이 점을 제대로 처리하고 있다.
하지만 내가 하고 싶은 말은 C를 두들겨 패자는 것이 아니다.
내 요지는 이 API의 기묘함이 사람들의 엔디언(endianness)에 대한 사고방식을 근본적으로 바꿔서, 원래라면 저지르지 않았을 실수들을 저지르게 만든다는 것이다. 많은 사람은 정수에 고유한 엔디언이 있다고 생각하거나, 엔디언을 다루는 일을 피하고 싶어서 텍스트 포맷을 선호한다. 나 역시 한때는 이런 생각들을 모두 갖고 있었다.
그렇다고 사람들이 멍청해서 이런 오해를 하는 것은 아니다. 엔디언을 직렬화 포맷의 하나의 매개변수(parameter)로 다루기만 해도, 같은 실수를 반복하기가 거의 불가능해진다. 하지만 C는 당신이 그렇게 생각하길 원치 않는다. 그래서 깊게 생각해 볼 계기가 생기기 전까지는 자신이 속고 있었다는 사실을 깨닫지 못한다. man 페이지와 인터넷 튜토리얼까지 나서서 ntohl이 “숫자를 네트워크 바이트 순서에서 호스트 바이트 순서로 변환한다”고 입을 모아 말하니, 상황은 더 악화될 뿐이다.
이게 실제로 그렇다는 걸 나는 어떻게 아는가? 개인적 경험을 넘어서, Rust가 u32::to_le 같은, 마찬가지로 깨진 시그니처의 메서드를 제공한다는 사실이 있다. 물론 Rust에는 정상적인 버전인 u32::to_le_bytes도 있지만, 훨씬 나중에 추가되었다. to_le은 한 번도 deprecated된 적이 없고, 지금도 문서에 아무런 경고 없이 자연스럽게 서술돼 있다. 나는 이것을, 개발자들의 뇌리에 너무 깊이 각인되어 버린 역사적 실수라고밖에 해석할 수 없다. 그래서 뭐가 문제인지 즉각적으로 드러나지 않는다.
내 당부는 교육자들에게 향한다. 엔디언스를 다른 방식으로 가르쳐 달라. 특정 직렬화 포맷의 매개변수로서 소개하라. 네이티브 엔디언(native endianness)은 int 같은 추상화만 사용하는 한 신경 쓸 필요가 없는 구현 세부사항임을 강조하라. 숫자 자체에는 엔디언이 없다는 점을 설명하라. 마치 JSON이 아키텍처에 독립적인 알고리즘으로 파싱된 뒤에는 더 이상 의미가 없는 것처럼, 리틀 엔디언과 빅 엔디언 (역)직렬화 모두 네이티브 엔디언을 알 필요 없이, 혹은 원본 데이터의 엔디언을 노출하지 않고 수행될 수 있음을 자세히 설명하라. 가능하다면 타입 안전(type-safe)한 API를 권장하라. ntoh*/hton*은 잘못 설계된, 타입 안전하지 않은 API라는 점을 강조하고, 온라인에서 잘못된 정보에 맞닥뜨리게 될 것이라는 사실도 미리 알려 주어라.