Proven C BookEnglish GitHub

9 문자와 텍스트 — 표준 속의 흉터

먼저 알아야 할 것

8장 수의 표현 · 표현은 약속이고 그 약속에는 흉터가 남는다

돌아보기

8장에서 소수점은 비트에 없고 약속에 있다고 했다. 그러면 글자는 — 비트에 “가”라는 모양이 들어 있는 것인가?

답. 아니다, 같은 이치다. 비트에는 모양도 소리도 없다. 있는 것은 수뿐이고, “어떤 수가 어떤 글자인가”라는 번호표의 약속이 따로 있다. 글자를 담는다는 것은 번호를 담는 것이고, 텍스트란 번호의 열이다. 이 장은 그 번호표들의 이야기다.

이 장의 필요성과 맥락

사다리의 셋째 단이자, 2부에서 한국어 독자에게 가장 실감 나는 장이다. 글자를 여기 두는 이유는 그 역사가 C 문법에 흉터를 남겼기 때문이다 — 삼중자, 로케일, 와이드 문자는 전부 이 장의 이야기가 언어에 눌러붙은 자국이다. 그 자국을 만나기 전에 원인을 보아야, 나중에 그것들이 「이상한 규칙」이 아니라 「남은 흔적」으로 읽힌다.

이 장이 끝나면

사물함에 글자를 담는 법을 배운다 — 문자는 결국 번호라는 것, 그 번호표 (문자 코드)가 어떤 역사를 거쳐 유니코드와 UTF-8에 이르렀는지, 그리고 그 역사가 C 문법에 남긴 흉터(삼중자)까지. 한국어를 쓰는 독자에게는 유난히 실감 나는 장이다.

이 장에서 답할 질문

  1. EBCDIC 은 박물관의 물건 아닌가? 지금도 이 걱정을 해야 하는가?
  2. 삼중자는 지금도 쓰이는가?
  3. 이 함정들에서 프로그래머가 챙길 원칙을 하나로 줄이면 무엇인가?
  4. 이 어지러운 역사를 입문자가 왜 지금 알아야 하는가? UTF-8만 쓰면 되는 것 아닌가?

9.1 문자는 번호다

기계에 “A”를 저장하는 방법은 하나뿐이다 — “A는 65번으로 한다”는 약속을 만들고, 65라는 수를 저장하는 것이다. 이런 약속의 표, 즉 글자와 번호의 대응표를 문자 집합(character set) 또는 문자 코드라 부른다.

문제는 이 표가 여러 개였다는 것이다. 회사마다, 나라마다 자기 표를 만들었다. 같은 번호가 표마다 다른 글자를 가리켰고, 표가 다른 두 기계가 텍스트를 주고받으면 글자가 엉켰다. 이 장의 나머지는 그 엉킴의 역사이고, 역사의 각 굽이가 오늘의 C와 컴퓨팅에 흔적을 남겼다.

9.2 ASCII와 EBCDIC — 두 개의 표

1960년대에 두 갈래의 표가 자리 잡았다. IBM의 대형 컴퓨터 세계는 EBCDIC이라는 표를 썼다 — 천공카드 시절의 코드에서 자라난 표라서 알파벳이 연속 번호가 아닌 등 지금 눈에는 기묘한 배치였지만, IBM의 세계에서는 그것이 법이었다.

다른 한쪽에서 통신 업계를 중심으로 만들어진 표가 ASCII다. 7비트, 즉 128칸짜리 작은 표에 영어 대소문자·숫자·문장부호, 그리고 화면에 안 보이는 제어 문자들을 담았다. 제어 문자는 “종이를 한 줄 올려라”, “경고음을 울려라”처럼 글자가 아니라 장치에 내리는 지시다 — 이들의 사연은 다음 장(스트림)에서 만난다. 그리고 이 표의 0번 자리에 앉은 것이 6장에서 얼굴을 익힌 NUL 문자다. “아무 글자도 아님”을 뜻하는 문자로, 나중에 C가 문자열(string)의 끝 표시로 채용한다(42장).

9.2.1 「알파벳이 연속이 아니다」가 코드에 하는 일

방금 「기묘한 배치」라고 지나간 대목을 그냥 넘기면 안 된다. 그것이 C 코드 한 줄의 운명을 가르기 때문이다. 누구나 한 번은 이렇게 적는다.

if (c >= 'A' && c <= 'Z') {}        /* 대문자인가? */

ASCII 에서는 잘 돈다. A 가 0x41 이고 Z 가 0x5A 라 그 사이 26칸이 정확히 알파벳이기 때문이다. 그런데 이 코드가 참인 것은 언어의 규칙이 아니라 표의 사정이다.

EBCDIC 은 다르다. 이 책이 실제 표(코드 페이지 037)를 재 보았다.

무엇ASCIIEBCDIC
A ~ Z0x41~0x5A — 끊김 없음0xC1~0xC9, 0xD1~0xD9, 0xE2~0xE9 — ★세 토막
a ~ z0x61~0x7A — 끊김 없음0x81~0x89, 0x91~0x99, 0xA2~0xA9 — 세 토막
0 ~ 90x30~0x390xF0~0xF9
대소문자 순서'a' > 'A''a' < 'A' — 뒤집혀 있다
숫자와 글자'0' < 'A''0' > 'Z' — 뒤집혀 있다

표 9.1

알파벳이 세 토막인 것은 천공카드 코드에서 자라난 흔적이다. 그래서 'A' 에서 'Z' 까지의 범위에는 41칸이 들어 있는데 그중 글자는 26개뿐이다. 나머지 15칸에 무엇이 앉아 있는지 세어 보았다.

끊긴 자리거기 앉은 문자
0xCA~0xCFô ö ò ó õ 등 악센트 글자
0xD0}
0xDA~0xDFû ü ù ú ÿ
0xE0\
0xE1÷

표 9.2

★ 즉 저 한 줄은 EBCDIC 에서 닫는 중괄호와 백슬래시를 「대문자」라고 대답한다. 컴파일 오류도, 경고도, 이상한 값도 없다. 그냥 조용히 틀린다.

흔한 오해. “그래도 '0' ~ '9' 범위 검사는 어디서나 안전하겠지”

이것만은 맞다 — 그리고 그것이 표준이 보장하는 유일한 연속이다.

“ 소스와 실행 기본 문자 집합 모두에서, 앞의 십진 숫자 목록에서 0 다음 각 문자의 값은 앞 문자의 값보다 1 커야 한다.(§5.2.1) ”

숫자 열 개에 대해서만 이렇게 적어 두었고, 알파벳에 대해서는 같은 문장이 없다. 표준을 만든 사람들이 EBCDIC 을 알고 있었다는 증거다 — 지킬 수 없는 약속은 하지 않은 것이다.

그래서 c - '0' 으로 숫자 문자를 값으로 바꾸는 관용구는 어디서나 옳고, c - 'A' 로 알파벳의 순번을 구하는 관용구는 옳지 않다.

9.2.2 그래서 라이브러리가 있다 — 추상이 하는 일

여기서 이 책이 되풀이할 논지 하나가 처음 모습을 드러낸다. 표의 사정을 코드에 적어 넣지 말고, 물어보라.

if (isupper((unsigned char)c)) {}   /* 표가 무엇이든 옳다 */

<ctype.h> 의 판정 함수들이 하는 일이 정확히 그것이다. 이 함수들은 「0x41 이상 0x5A 이하」를 검사하지 않는다 — 그 구현이 도는 문자 집합에서 무엇이 대문자인지를 안다. EBCDIC 기계의 C 구현은 세 토막을 아는 판정을 담고 오고, ASCII 기계의 구현은 간단한 범위 검사로 만들어도 된다. 어느 쪽이든 부르는 쪽의 코드는 한 글자도 바뀌지 않는다.

이것이 추상이 파는 물건이다 — 기능이 아니라 변하는 것을 한 자리에 가두는 일.

손으로 적으면무엇에 기대는가라이브러리에 물으면
c >= 'A' && c <= 'Z'알파벳이 연속이고 순서가 이렇다는 표의 사정isupper(c)
c >= '0' && c <= '9'★ 표준이 보장 — 이것만은 안전isdigit(c)
c + ('a' - 'A')대소문자 간격이 일정하다는 사정(EBCDIC 도 0x40 으로 일정하나 보장은 아니다)tolower(c)
c - 'A'알파벳의 순번을 뺄셈으로 얻을 수 있다는 사정표를 따로 만든다

표 9.3

★ 그리고 이 이야기는 문자 판정에서 끝나지 않는다. 같은 논리가 이 책 곳곳에서 되풀이된다 — 문자열의 길이를 세는 일(42장), 정렬 순서를 정하는 일(69장의 strcoll), 글자 수를 세는 일(72장). 전부 「내가 아는 표가 세상의 전부」라는 가정을 코드에서 걷어 내는 일이다.

문. EBCDIC 은 박물관의 물건 아닌가? 지금도 이 걱정을 해야 하는가?

답. 솔직히 말하면 — 당신의 코드가 IBM 대형기에서 돌 일은 아마 없다. EBCDIC 은 z/OS 계열에 남아 은행·보험의 기간계에서 여전히 돌지만, 이 책을 읽는 대부분에게는 만날 일 없는 세계다.

그런데도 이 자리에서 짚는 이유가 둘 있다.

첫째, 이것이 「가정을 코드에 적어 넣었다」의 가장 깨끗한 표본이다. 실제로 깨지는 것을 한 번 보아 두면, 지금 내 코드에 어떤 가정이 적혀 있는가를 묻는 습관이 생긴다. 그 습관은 EBCDIC 을 만나지 않아도 값을 한다 — 뒤에서 만날 사고들 (char 의 부호, UTF-8 의 바이트 수, 로케일이 바꾸는 판정)이 전부 같은 모양이다.

둘째, 표준이 왜 그렇게 인색하게 적혀 있는지 알려 준다. 「숫자만 연속」이라는 문장을 처음 보면 지나치게 조심스러워 보이지만, 그 조심스러움에는 이름과 얼굴이 있다. 표준의 낯선 조항들 상당수가 이런 식으로 한때 실재한 기계를 가리킨다.

ASCII가 이긴 표가 되었다 — 오늘날 거의 모든 문자 코드가 ASCII를 안쪽에 품고 있다. 그러나 128칸은 영어만으로도 빠듯했고, 세계의 글자를 담기에는 어림도 없었다. 여기서 흉터의 역사가 시작된다.

9.3 ISO 646 — 국가 변형과 C의 삼중자

첫 번째 미봉책은 ASCII의 국제판인 ISO 646이었다. 아이디어는 이랬다: “표는 하나로 쓰되, 나라마다 덜 중요한 몇 칸을 자기 글자로 바꿔 쓰게 허용한다.” 그렇게 바꿔 쓰도록 지정된 칸에 하필 [ ] { } \ | # 같은 기호들이 있었다.

결과는 프로그래머에게 재앙이었다. 덴마크의 단말기에서 C 코드를 열면 중괄호 자리에 덴마크 글자가 보였다. 같은 번호인데 표가 달라 다른 글자가 찍힌 것이다.

실제 사례. ₩ 표시 — 한국에 남은 ISO 646의 흉터

한국도 이 역사의 당사자다. 한국판 표(KS X 1003)는 백슬래시 \ 자리를 원화 기호 로 바꿨다. 그 시절 한국 컴퓨터에서 파일 경로는 C:₩Program Files₩...로 보였고 — 놀랍게도 이 흉터는 지금도 만질 수 있다. 오늘날의 한국어 윈도우에서도 일부 글꼴은 백슬래시 번호를 ₩로 그린다. 같은 번호, 같은 비트인데 표(글꼴)가 ₩를 그리는 것이다. “기억된 것과 그것을 읽는 방법은 별개”라는 이 책의 후렴구를, 한국 독자는 화면에서 매일 목격해 온 셈이다.

C는 이 혼란의 한복판에서 표준화됐다(12장). 중괄호가 안 보이는 나라에서도 C를 쓸 수 있어야 했으므로, C89는 기괴한 우회로를 마련했다 — 삼중자(trigraph)다. ??<라고 적으면 {로, ??/라고 적으면 \로 읽어 주는 문자 세 개짜리 암호였다. 언어 문법에 새겨진, 문자 코드 전쟁의 흉터다.

문. 삼중자는 지금도 쓰이는가?

답. 아니다 — 그리고 그 최후가 근래 C 역사의 상징적 장면이다. 유니코드 시대가 오며 삼중자는 존재 이유를 잃었고, 오히려 ??! 같은 문자열이 뜻밖에 변환되는 함정으로만 남았다. C23이 삼중자를 표준에서 제거했다. 1989년에 새겨진 흉터가 2023년에야 지워진 것이다 — 표준이 무언가를 들이는 일보다 빼는 일이 얼마나 느린지 보여 주는 사례이기도 하다.

9.4 8비트의 백가쟁명, 그리고 한글

바이트가 8비트로 자리 잡자(4장) 표를 256칸으로 늘린 ISO 8859 계열이 나왔다 — 앞 128칸은 ASCII 그대로, 뒤 128칸에 각 언어권 글자를 담는 방식이다(서유럽용 Latin-1 등). 그러나 뒤 128칸을 언어권마다 다르게 쓰는 구조라, 표를 잘못 짚으면 글자가 엉키는 문제는 그대로였다.

그리고 256칸으로는 어림도 없는 글자들이 있었다. 한글, 한자, 가나 — 동아시아의 글자는 수천, 수만이다. 해법은 한 글자에 바이트 여러 개쓰는 멀티바이트 인코딩이었다(한국의 EUC-KR 등). 글자마다 길이가 다른 데다 나라마다 방식이 달라, 표를 잘못 짚은 한글 문서가 뜻 모를 기호의 행렬로 깨지는 일 — 이른바 “글자 깨짐” — 은 이 시대 한국 컴퓨터 사용자의 일상이었다.

9.5 유니코드와 UTF-8 — 하나의 표, 영리한 담기

근본 해법은 결국 하나뿐이었다. 세상 모든 글자를 하나의 표에 넣자. 그것이 1990년대의 유니코드다. 글자마다 고유 번호(코드 포인트)를 주고 U+AC00(가)처럼 적는다. 표의 크기는 백만 칸이 넘는다.

남은 문제는 그 큰 번호를 바이트에 어떻게 담느냐였다. 모든 글자를 4바이트씩 쓰면 낭비가 크고, 무엇보다 산더미 같은 기존 ASCII 텍스트와 호환되지 않는다. 답이 된 것이 UTF-8이라는 가변 길이 인코딩이다 — ASCII 범위의 글자는 1바이트 그대로, 그 밖의 글자는 2–4바이트로 담는다. 기존 ASCII 파일은 그 자체로 올바른 UTF-8이라는 절묘한 설계 덕에, UTF-8은 오늘날 웹과 소스 코드의 사실상 유일한 표준이 됐다. 이 책의 예제와 C23의 u8"" 문자열이 쓰는 것도 UTF-8이다(42장).

수학. UTF-8의 바이트 구조

코드 포인트의 크기에 따라 길이가 달라진다. 첫 바이트의 앞 비트들이 “이 글자가 몇 바이트짜리인지”를 알리고, 이어지는 바이트는 전부 10으로 시작한다:

범위바이트 수비트 배치
U+0000   U+007F10xxxxxxx (= ASCII)
U+0080   U+07FF2110xxxxx 10xxxxxx
U+0800   U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000   U+10FFFF411110xxx 10xxxxxx ×3

표 9.4

이어지는 바이트가 항상 10으로 시작하므로, 텍스트 중간 아무 데서 깨어나도 글자의 경계를 되찾을 수 있다(자기 동기화). 한글 음절(U+AC00 부근)은 3바이트 구간에 산다.

흔한 오해. “한 글자는 1바이트다”

ASCII 시대의 직관이 남긴 오개념이고, C를 배울 때 특히 위험하다 — C의 char 타입은 이름과 달리 “글자”가 아니라 바이트이기 때문이다(42장). UTF-8에서 영문 A는 1바이트지만 한글 는 3바이트다. “안녕”이라는 두 글자는 여섯 바이트다. 글자 수와 바이트 수는 다른 물건이고, 이 구별이 무너진 코드는 한글 처리에서 반드시 사고를 낸다.

9.6 C가 가르는 두 문자 집합 — 소스와 실행

여기까지가 세상의 문자 집합 이야기였다. 이제 C가 그것을 어떻게 다루는가를 본다. 핵심은 한 문장이다 — C는 소스 코드의 문자 집합과 프로그램이 실행될 때의 문자 집합을 서로 다른 것으로 취급한다.

표준이 붙인 이름부터 정확히 적어 둔다(§5.2.1).

표준의 이름우리말무엇인가
source character set소스 문자 집합소스 파일이 쓰여 있는 문자의 집합
execution character set실행 문자 집합실행 환경에서 해석되는 문자의 집합
basic character set기본 문자 집합양쪽이 반드시 갖춰야 하는 공통분모 — 라틴 문자 52자, 숫자 10자, 그래픽 문자 32자, 공백과 몇 가지 제어 문자
extended characters확장 문자기본 집합에 없는, 로케일이 정하는 나머지 — 한글이 여기 있다
extended character set확장 문자 집합기본 집합과 확장 문자를 합친 것

표 9.5

표준은 이 둘이 같은 인코딩이어야 한다고 말하지 않는다. 오히려 §5.2.2 가 명시적으로 갈라 둔다 — 실행 문자 집합도 멀티바이트 문자를 담을 수 있고, 그 인코딩이 소스 문자 집합의 것과 같을 것을 요구하지 않는다고. 그리고 §5.2.1 은 못을 하나 더 박는다: 실행 문자 집합 구성원의 값은 구현 정의(implementation-defined)다.

9.6.1 언제 옮겨 실리는가 — 번역 단계 1과 5

두 집합이 만나는 자리는 번역 단계(translation phase, 57장)에 정확히 못박혀 있다.

단계표준이 정한 일(§5.1.1.2)
1물리적 소스 파일의 멀티바이트 문자를 구현 정의 방식으로 소스 문자 집합에 대응시킨다
5문자 상수와 문자열 리터럴 안의 소스 문자 집합 구성원과 확장열(escape sequence)을 실행 문자 집합의 대응 구성원으로 변환한다

표 9.6

5단계의 뒷문장이 특히 중요하다 — 대응하는 구성원이 없으면, 구현이 정한 방식으로 널이 아닌 어떤 구성원으로 바꾼다. 즉 조용히 다른 글자가 된다. 오류가 아니다.

흔한 오해. “소스에 적은 글자가 그대로 실행 파일에 들어간다”

기본 문자 집합(영문자·숫자·기호)에 대해서는 사실상 그렇다. 그러나 한글처럼 확장 문자는 5단계에서 한 번 변환된다. 소스를 UTF-8로 적어도 실행 문자 집합이 EUC-KR 이면 실행 파일에는 EUC-KR 바이트가 박힌다 — 17장에서 이것을 실측으로 본다.

그래서 「소스 파일 인코딩」과 「프로그램이 뱉는 바이트」는 다른 질문이다. 둘을 하나로 여기면 한글이 깨질 때 어느 고리를 고쳐야 할지 영영 좁히지 못한다.

9.6.2 C23 이 붙인 더 정확한 이름 — 리터럴 인코딩

C23 은 §6.2.9 「Encodings」를 새로 두어 이름을 하나 더 정리했다.

표준의 이름(§6.2.9)정의
literal encoding (리터럴 인코딩)실행 문자 집합의 문자를 문자 상수와 문자열 리터럴의 값으로 옮기는 구현 정의 대응
wide literal encoding (와이드 리터럴 인코딩)같은 것을 wchar_t 문자 상수·문자열 리터럴에 대해 정한 대응

표 9.7

「실행 문자 집합」이 어떤 글자들이 있는가라면, 「리터럴 인코딩」은 그 글자를 어떤 바이트 값으로 적는가다. 표준은 둘 다 기본 실행 문자 집합 전체를 대응시킬 것만 요구하고, 나머지는 구현에 맡긴다.

플랫폼 노트. C++ 는 이름을 다시 지었다

같은 개념을 C++20~23 이 갈아엎었다. C++ 는 「소스 문자 집합」 대신 translation character set(번역 문자 집합)을 쓰고, 그것을 유니코드로 못박았다. 실행 쪽은 C 와 같은 literal encoding·wide literal encoding 이라는 이름을 쓴다.

그래서 두 언어의 표준 문서를 함께 읽을 때는 낱말이 어긋난다는 것을 알아 두어야 한다 — C 에는 아직 “translation character set” 이라는 용어가 없다. C23 (N3220) §5.2.1 은 여전히 source character set / execution character set 으로 쓰고, §6.2.9 에서 literal encoding 만 새로 이름 붙였다.

9.6.3 인코딩을 못박는 접두어

실행 문자 집합이 무엇이든 상관없이 인코딩을 고정하는 방법이 있다 — 리터럴 접두어다(§6.4.5).

리터럴원소 타입인코딩누가 보장하는가
"가"char리터럴 인코딩 — 구현이 정한다아무도. 옵션에 따라 바뀐다
u8"가"char8_t (C23)언제나 UTF-8표준이 못박는다
u"가"char16_tUTF-16__STDC_UTF_16__ 이 1 이면
U"가"char32_tUTF-32__STDC_UTF_32__ 이 1 이면
L"가"wchar_t와이드 리터럴 인코딩__STDC_ISO_10646__ 이 정의되어 있으면 유니코드

표 9.8

바이트가 흔들리면 곤란한 자리 — 프로토콜, 파일 형식, 시험의 기대값 — 에는 u8"…"를 쓴다. 「구현이 정한다」를 「표준이 정한다」로 바꾸는 낱말 두 글자다. 와이드 문자(wide character) 쪽의 자세한 사정은 70장과 71장에서 다시 본다.

9.7 같은 글자, 여러 표현 — 텍스트에 잠복한 보안 지형

유니코드가 표를 통일했다고 문제가 끝난 것은 아니다. 오히려 표가 커지고 표현이 여러 겹이 되면서 새로운 함정이 생겼다. 텍스트를 다루는 프로그램 이라면 언젠가 만나게 되는 지형이라, 길잡이 삼아 훑어 둔다. 뿌리는 하나다 — 같은 것을 두 가지 이상으로 적을 수 있으면, 검사하는 쪽과 해석하는 쪽이 어긋날 수 있다.

① 정규화(normalization) — 한글의 NFC와 NFD. “각”이라는 글자는 유니코드에서 두 가지로 적을 수 있다. 완성된 음절 하나로 적는 방식(NFC, U+AC01 한 코드 포인트)과, 자모를 이어 적는 방식(NFD, ++ 세 코드 포인트)이다. 눈에는 똑같이 보이지만 바이트 열은 완전히 다르다 — 그래서 단순 바이트 비교는 “각 ≠ 각”이라 답한다. 한국 사용자에게 이것은 교과서 이야기가 아니다: macOS가 파일 이름을 NFD 계열로 저장해 온 탓에, 한글 파일 이름이 다른 OS로 건너가며 자모가 풀려 보이거나 압축 파일에서 이름이 어긋나는 일이 흔했다. 해법은 경계에서 한 형태로 정규화하는 것 — 그리고 보안 문맥에서는 이 정규화의 순서가 결정적이다(아래 ④).

② 오버롱 인코딩 — UTF-8이 한때 열어 둔 뒷문. 이 장 수학 박스의 UTF-8 규칙대로면 /(U+002F)는 1바이트 2F다. 그런데 초기 구현들은 같은 문자를 2바이트(C0 AF)나 3바이트로 “길게” 적은 것도 관대하게 받아 주었다 — 오버롱(overlong) 표현이다. 여기서 고전적 공격이 나왔다: 보안 검사는 2F만 찾아 경로 조작(../)을 걸러 내는데, 파일 시스템은 오버롱 C0 AF/로 해석한 것이다. 검사와 해석의 어긋남 — 2001년 IIS 서버를 휩쓴 디렉터리 탈출 취약점의 정체가 이것이었다. 그래서 오늘의 표준은 오버롱을 불법으로 못박고 디코더가 거부하도록 정했다.

③ 사라진 인코딩의 유령 — UTF-7. 7비트 통로(초기 전자우편)로 유니코드를 보내려고 만든 UTF-7은, 평범해 보이는 ASCII 글자들로 다른 문자를 표현 한다(+ADw-<가 되는 식이다). 브라우저들이 인코딩을 자동 추측하던 시절, 공격자는 이 성질로 필터를 통과시켰다 — 검사할 때는 무해한 글자 열이었다가, 브라우저가 UTF-7로 해석하는 순간 태그로 부활하는 것이다. UTF-7은 이 사고들 때문에 사실상 퇴출됐고, 오늘의 규범은 인코딩을 추측하지 말고 명시하라로 굳었다.

④ 유니코드 자체의 함정 — 동형 문자와 방향 제어. 표가 크다는 것은 똑같이 생긴 다른 글자가 있다는 뜻이기도 하다. 라틴 a와 키릴 а는 화면에서 구별되지 않는다 — 이를 이용해 진짜와 똑같아 보이는 가짜 도메인을 만드는 것이 호모그래프 공격이다(그래서 브라우저는 의심스러운 혼용 도메인을 원래 표기로 되돌려 보여 준다). 더 교묘한 것은 방향 제어 문자다: 유니코드에는 글자의 표시 순서를 뒤집는 보이지 않는 문자 들이 있어서, 소스 코드에서 사람이 읽는 순서와 컴파일러가 읽는 순서를 다르게 만들 수 있다 — 2021년 공개된 “Trojan Source”가 이 기법으로, 리뷰어에게는 멀쩡해 보이는 코드가 실제로는 다른 논리로 컴파일되게 만들 수 있음을 보였다. 이후 컴파일러들이 이런 문자에 경고를 내기 시작했다.

⑤ 층이 겹칠 때 — 이스케이프와 다중 해석. 웹에서 <를 글자로 보이게 하려면 &lt;로 적는다(HTML 이스케이프). 이 층이 여럿 겹치면 사고가 난다 — 한 번 이스케이프한 것을 다른 층이 다시 해독하면 원래 기호가 부활하고, 반대로 검사만 하고 이스케이프를 잊으면 입력이 코드가 된다. 22장에서 볼 서식 문자열 취약점, 데이터베이스의 SQL 주입, 웹의 XSS가 전부 같은 무늬다 — 데이터를 틀(코드)로 오해하게 만드는 것. C의 삼중자도 이 무늬의 조상 격 사례였다: 문자열 안에 우연히 ??/가 들어 있으면 전처리기가 그것을 백슬래시로 바꿔 버려, 프로그래머가 적은 적 없는 이스케이프가 생겨났다(C23의 삼중자 제거가 반가운 이유의 하나다).

문. 이 함정들에서 프로그래머가 챙길 원칙을 하나로 줄이면 무엇인가?

답. 검사와 해석 사이에 표현이 바뀌지 않게 하라는 것이다. 실무 수칙으로 펼치면 셋이다 — 첫째, 인코딩을 추측하지 말고 명시한다(입출력 경계 에서 UTF-8로 못박기). 둘째, 정규화·디코딩을 먼저 하고 검사는 그 뒤에 한다 — 순서가 뒤집히면 오버롱·UTF-7 사고가 재현된다. 셋째, 데이터를 틀에 섞지 않는다 — 값은 언제나 값의 자리에 둔다(22장의 printf("%s", 입력) 관용구가 이 원칙의 가장 작은 실천이다). 세 수칙 모두 “표현과 해석은 별개”라는 이 책의 후렴구에서 나온다.

9.8 글자들의 열 — 문자열을 담는 방식들

글자 하나를 담는 법을 알았으니, 마지막 질문이 남는다. 글자들의 문자열(string) — 을 사물함에 담을 때, “어디부터 어디까지가 이 문자열인가”를 어떻게 아는가. 5장의 후렴구가 다시 나온다: 메모리는 덩어리의 경계를 모르고, 아는 것은 읽는 쪽의 약속이다. 그 약속의 방식이 여럿이라, 주요 갈래를 구경해 둔다.

방식 1 — 길이를 앞에 적는다(길이-버퍼). 문자열 앞에 “이 뒤로 몇 글자”라는 수를 붙여 두는 방식이다. 파스칼 계열 언어가 써서 파스칼 문자열이라고도 부른다. 길이를 묻는 데 셈이 필요 없고(적힌 수를 읽으면 끝), 어떤 바이트든 — 0번 문자조차 — 내용물로 담을 수 있다. 대가는 길이 칸의 크기를 미리 정해야 한다는 것 — 옛 파스칼은 길이를 1바이트에 담아서 문자열이 255자를 넘을 수 없었다(4장의 그릇 이야기가 여기서도).

방식 2 — 끝에 표지를 세운다(NUL 종단). 길이를 적지 않는 대신, 열의 에 특별한 문자 — 6장에서 얼굴을 익힌 값 0의 NUL 문자 — 를 세워 두는 방식이다. C가 택한 방식이 이것이다. 미덕은 극단적 단순함이다 — 시작 지점만 알면 되고, 열의 중간 어디를 가리켜도 “거기부터 NUL까지”가 곧 문자열이다. 대가는 셋: 길이를 알려면 NUL까지 일일이 세어야 하고, 내용물에 NUL을 담을 수 없으며, 무엇보다 — 표지 세우기를 잊으면 읽는 쪽이 남의 땅까지 폭주한다. 이 마지막 대가가 C 역사상 가장 많은 사고를 낸 지형이고, 42장이 그 현장이다.

방식 3 — 길이와 용량을 함께 관리한다. 현대 언어들의 동적 문자열은 대개 [내용물의 위치, 지금 길이, 그릇의 용량] 세 값을 한 묶음으로 관리한다 — 길이 조회는 즉시, 덧붙이기는 용량 안에서 자유, 모자라면 더 큰 그릇으로 이사한다. C++의 std::string, Rust의 String이 이 구조이고, 뒤에서 만날 proven의 문자열 처리도 길이를 명시적으로 다루는 이 계열의 사고방식이다(제12부).

실제 사례. 15글자의 마법 — MS STL의 작은 문자열 최적화

방식 3의 실제 구현에는 영리한 겹장치가 있다. 이웃 언어 C++의 마이크로소프트 표준 라이브러리 구현(MS STL)의 std::string은, 짧은 문자열 — 구체적으로 15글자 이하(15바이트 + 끝 표지) — 이면 별도의 큰 창고(동적 메모리)를 빌리지 않고 문자열 객체 자신의 몸 안에 그냥 담아 버린다. “작은 문자열 최적화”(SSO, small string optimization)라 불리는 기법이다. 왜 15인가 — 객체 안에 어차피 있어야 할 위치·길이· 용량 칸들의 자리를 짧은 문자열일 때 내용물 버퍼로 재활용하면 딱 그 크기가 나오기 때문이다. 효과는 11장의 지식으로 설명된다: 실무 문자열의 대다수는 짧은데, 그것들이 창고 왕복(할당) 없이 객체와 한 몸으로 붙어 다니니 캐시 라인 위에서 함께 움직인다. 다른 구현들(GCC·Clang의 표준 라이브러리)도 수치만 다를 뿐 같은 기법을 쓴다 — “표현 방식의 선택이 곧 속도”라는 것을 보여 주는 현대의 교과서적 사례다.

이 밖에도 지형은 넓다 — 큰 문서를 조각의 나무로 관리하는 편집기용 표현(로프), 원본을 복사하지 않고 [시작, 길이]만 가리키는 뷰(view) 방식 등, 쓰임마다 알맞은 표현이 따로 있다. 기억할 것은 하나다: 문자열이 메모리에 놓이는 모양은 하나가 아니고, 언어와 라이브러리의 선택이며, 그 선택이 성능과 안전의 성격을 정한다. C의 선택(NUL 종단)이 어떤 성격이고 어떻게 다뤄야 하는지는 42장에서 정면으로 만난다.

문. 이 어지러운 역사를 입문자가 왜 지금 알아야 하는가? UTF-8만 쓰면 되는 것 아닌가?

답. 새로 만드는 것은 UTF-8만 쓰면 된다 — 그것이 오늘의 관행이고 이 책의 전제다. 그러나 두 가지 이유로 역사가 필요하다. 첫째, 세상의 파일과 시스템에는 옛 인코딩이 여전히 흐르고 있어서, 글자 깨짐을 만났을 때 “표를 잘못 짚었구나”를 진단할 수 있어야 한다. 둘째, C 자신이 이 역사의 산물이라 — char라는 이름, NUL 종단, 삼중자의 잔해 — 역사를 알면 C의 기묘한 구석들이 전부 사연 있는 상처로 읽힌다. 외울 것은 없다. “문자는 번호이고, 표가 여러 개였다가 유니코드로 통일됐고, 담는 방식은 UTF-8이 이겼다” — 이 세 문장이면 충분하다.

다음 장은 표현의 사다리 마지막 단이다. 글자를 담을 줄 알게 됐으니, 이제 글자가 흐르는 이야기다 — 컴퓨터의 입출력이 왜 “문자가 한 줄씩 흘러가는 것”으로 생겼는지, 그 답이 종이 카드와 타자기의 시대에 있다.