Grapheme Clusters and Terminal Emulators

Mitchell Hashimoto

그래핌 클러스터와 터미널 에뮬레이터

터미널 에뮬레이터에 "🧑‍🌾"를 복사해 붙여 넣어 보세요. 커서가 몇 칸 앞으로 이동했나요? 터미널 에뮬레이터에 따라 커서는 2칸, 4칸, 5칸 혹은 6칸 이동했을 수도 있습니다1. 어휴. 이 글에서는 왜 이런 일이 일어나는지, 그리고 터미널 에뮬레이터와 프로그램 작성자가 어떻게 하면 모든 문자에 대해 일관된 간격을 구현할 수 있는지 설명합니다.


문자 그리드의 역사

터미널은 고정된 크기의 셀들로 이루어진 그리드 위에서 동작합니다. 이는 터미널 에뮬레이터 동작 방식의 근간이 됩니다. 프로그램이 터미널에 보낼 수 있는 많은 제어 시퀀스 단위로 동작하기 때문입니다.

예를 들어 커서를 왼쪽 CSI n D, 오른쪽 CSI n C, 위 CSI n A, 아래 CSI n B로 이동시키는 제어 시퀀스가 있습니다. 이때 각각의 "n"은 이동할 의 개수입니다. 커서 위치를 요청하는 시퀀스 CSI 6 n도 있습니다. 그러면 터미널은 프로그램에 CSI y ; x R 형태로 응답하며, 이때 "y"와 "x"는 모두 좌표입니다.

전통적으로 터미널은 입력 바이트 스트림을 단순히 읽어 각 바이트를 그리드의 셀 하나에 매핑했습니다. 예를 들어 "1234"라는 스트림은 4바이트이며, 프로그래머는 바이트를 하나씩 읽어 다음 셀에 넣고 커서를 한 칸 오른쪽으로 이동시키는 과정을 아주 쉽게 반복할 수 있었습니다.

이후 "와이드 문자"가 등장했습니다. 대표적인 와이드 문자는 橋 같은 아시아 문자나 😃 같은 이모지입니다. libc에는 와이드 문자의 너비를 단위로 반환하는 wcwidth 함수가 추가되었습니다. 와이드 문자에는 보통 너비 "2"가 부여되었습니다. 따라서 터미널 에뮬레이터에 橋를 입력하면 이 문자는 그리드에서 두 셀을 차지하고 커서는 두 칸 앞으로 이동해야 합니다.

그리고 이것이 오늘날 대부분의 터미널 에뮬레이터와 터미널 프로그램(셸, TUI 등)이 구현된 방식입니다. 입력 문자를 wcwidth로 처리하고 그에 맞춰 커서를 이동시킵니다. 한동안은 이 방식이 아무 문제 없이 잘 동작했습니다. 하지만 오늘날에는 더 이상 충분하지 않으며 많은 오류를 낳고 있습니다.


그래핌 클러스터링

알고 보니 32비트 값 하나로는 세상의 모든 사용자가 인식하는 문자를 표현하기에 충분하지 않습니다. "사용자가 인식하는 문자"는 유니코드 표준에서 그래핌이라고 정의하는 개념입니다.

이모지 "🧑‍🌾"를 예로 들어 보겠습니다. 컴퓨터에서 이 이모지가 제대로 표시되지 않을 경우를 대비해 이렇게 생겼습니다. 아마 누구라도 이것이 하나의 "사용자가 인식하는 문자", 즉 그래핌이라는 데 동의할 것입니다. 유니코드 표준 자체도 이를 단일 그래핌으로 정의하므로, 개인적인 의견과 상관없이 국제 표준상 이는 하나의 그래핌입니다.

컴퓨터 입장에서는 그리 명확하지 않습니다. "🧑‍🌾"는 세 개의 코드포인트(U+1F9D1 🧑, U+200D, 그리고 U+1F33E 🌾)로 이루어져 있으며, UTF-32로 인코딩하면 세 개의 32비트 값이 되고, UTF-8로 인코딩하면 11바이트가 됩니다2.

우리가 지금까지 해왔던 것처럼 32비트 값만을 고려해 각 코드포인트를 개별적으로 wcwidth에 넘기면, 각 코드포인트에 대해 순서대로 "2", "0", "2"라는 결과를 얻게 됩니다. 따라서 커서는 4칸 앞으로 이동합니다. 이 글을 쓰는 시점의 대부분 터미널에서 커서가 4칸 앞으로 이동하는 이유가 바로 이것입니다.

너비가 0인 문자는 왜 있는 걸까요? 코드포인트 U+200D제로 폭 연결자(Zero-Width Joiner)(ZWJ)로 알려져 있으며 표준상 너비가 0으로 정의되어 있습니다. ZWJ는 텍스트 처리 시스템에 그 주변의 코드포인트들을 하나의 문자로 결합해서 처리하라고 알려줍니다. 그래서 "🧑‍🌾"와 "🧑🌾"를 둘 다 입력할 수 있는 것입니다. 따옴표 안의 두 값의 유일한 차이는 왼쪽 농부 이모지에 두 이모지 사이에 제로 폭 연결자가 들어 있다는 점입니다.

그래핌 클러스터링이 등장합니다. 그래핌 클러스터링은 코드포인트 스트림에서 단일 그래핌을 판별하는 과정입니다. 그래핌 클러스터링을 통해 프로그램은 세 개의 32비트 값을 하나의 사용자가 인식하는 문자로 볼 수 있게 됩니다. 그래핌 클러스터링 알고리즘은 UAX #29, "Unicode Text Segmentation"에 정의되어 있습니다. 알고리즘이 어떻게 동작하는지 자세히 설명하지는 않겠습니다. 사용하는 프로그래밍 언어의 최신 유니코드 라이브러리 하나만 가져오면 그래핌 클러스터링을 수행할 수 있을 것입니다.

그래핌 클러스터링의 특히 어려운 점은 상태를 유지해야(stateful) 한다는 것입니다. 그래핌의 경계를 안정적으로 판단하려면 이전 코드포인트, 현재 코드포인트, 그리고 정수형 상태 값에 접근할 수 있어야 합니다. 따라서 기존 프로그램에 바로 적용하기가 아주 쉽지는 않습니다(그렇다고 어렵지도 않습니다).

그래핌 클러스터링을 적용하면 터미널은 "🧑‍🌾"를 하나의 와이드 문자 그래핌으로 인식하고 커서를 네 칸이 아니라 두 칸만 앞으로 이동시킵니다.

이 문제는 이모지만의 이야기가 아닙니다. 저는 예시로 이모지를 들었지만, 그래핌 클러스터링은 세계의 언어를 올바르게 처리하는 데에도 매우 중요합니다. 예를 들어 아랍 문자는 종종 여러 코드포인트로 구성됩니다.

여담: 폰트 셰이핑. 그래핌 클러스터링은 코드포인트 스트림에서 그래핌 경계를 판단하는 문제만 해결합니다. 코드포인트 스트림을 렌더링하는 문제는 해결하지 못합니다. 이를 위해서는 Harfbuzz 같은 폰트 셰이퍼가 필요합니다. Harfbuzz는 코드포인트 스트림을 보고 그래핌을 감지한 뒤, 그 그래핌들을 폰트 내의 개별 글리프에 매핑할 수 있습니다.

이 글에서는 폰트 셰이핑을 다루지 않겠습니다. 터미널에 "🧑‍🌾"를 붙여 넣었을 때 하나의 이모지 대신 두 개의 이모지가 보인다면, 터미널이 제로 폭 연결자를 제거했기 때문이거나 더 가능성이 높은 이유는 터미널이 폰트 셰이핑을 지원하지 않기 때문입니다.


터미널에서의 그래핌 클러스터링

오늘날 대부분의 터미널은 그래핌 클러스터링을 지원하지 않습니다. 큰 이유 중 하나는 셸이나 텍스트 에디터 같은 고급 터미널 애플리케이션이 커서의 정확한 위치를 항상 파악하고 터미널 그리드 상태와 동기화를 유지해야 하기 때문입니다.

역사적으로 터미널이 wcwidth를 사용해 왔기 때문에 셸, 에디터 및 기타 TUI 앱 역시 wcwidth를 사용하며 오늘날까지도 계속 그렇게 하고 있습니다. 비록 이 방식이 다중 코드포인트 그래핌에 대해서는 잘못된 값을 내놓지만, 적어도 그 잘못된 값이 터미널 에뮬레이터 전반에 걸쳐 일관되게 틀리다는 장점이 있습니다.

제가 제 터미널을 위해 처음 텍스트 처리를 구현했을 때, 좋은 일이라고 생각해 완전한 그래핌 클러스터 지원을 구현했습니다. 하지만 제대로 된 그래핌 클러스터링을 적용하자 커서를 이동할 때 fish 셸이 프롬프트를 잘못된 위치에 다시 그리는 것을 보고 바로 실망했습니다. 😞 셸은 제 터미널이 wcwidth를 사용할 거라고 가정했고, 제 터미널은 프로그램이 신경 쓰지 않을 거라고 가정했습니다. 둘 다 틀렸고, 그래서 저는 그래핌 클러스터링을 비활성화했습니다...

모드 2027이 등장했습니다. 모드 2027은 터미널의 그래핌 지원을 위한 제안입니다. 이 제안은 Contour 터미널의 작성자가 내놓은 것입니다. 아이디어는 터미널에서 실행 중인 프로그램이 그래핌 클러스터링을 완전히 지원하며 동작하고 싶다는 것을 터미널에 알릴 수 있고, 이 기능을 켜고 끌 수 있다는 것입니다. 실행 중인 프로그램은 터미널이 이 기능을 지원하는지 조회할 수도 있습니다.

최근 저는 제 터미널에 모드 2027 지원을 구현했으며, 아래에서 그 동작을 확인할 수 있습니다. 모드 2027이 꺼져 있을 때는 농부 이모지 뒤에 커서가 5열로 이동하고(너비 4), 모드 2027이 켜져 있을 때는 커서가 3열로 이동합니다(너비 2).


터미널 비교

아래 표는 다양한 터미널이 보고한 "🧑‍🌾"의 너비를 보여줍니다. 각 터미널마다 2023년 10월 2일 기준으로 제가 찾을 수 있었던 최신 버전을 사용했습니다. 제 터미널을 목록 맨 위에 두었고, 나머지는 알파벳 순으로 정렬했습니다.

터미널너비모드 2027비고
Ghostty2모드 2027이 비활성화된 경우 wcwidth로 폴백합니다
Alacritty4셰이핑을 지원하지 않아 두 개의 분리된 이모지로 표시됩니다
Contour2모드 2027을 제안했으며 항상 그래핌 클러스터링을 수행합니다
Foot2모드 2027이 비활성화된 경우 wcwidth로 폴백합니다
Gnome4셰이핑을 지원하지 않아 두 개의 분리된 이모지로 표시됩니다
iTerm2항상 그래핌 클러스터링을 수행합니다
Kitty4
Tmux4특히 터미널 에뮬레이터와 일치하지 않을 때 재미있어집니다
Terminal.app6🤡 저주받은 자신만의 세상에서 살고 있습니다
Warp4셰이핑을 지원하지 않아 두 개의 분리된 이모지로 표시됩니다
Wezterm2항상 그래핌 클러스터링을 수행합니다
Windows Terminal5🧐 ZWJ를 한 셀 취급하며 두 개의 분리된 이모지로 표시됩니다
Xfce4셰이핑을 지원하지 않아 두 개의 분리된 이모지로 표시됩니다
xterm4셰이핑을 지원하지 않아 두 개의 분리된 이모지로 표시됩니다

모드 2027 지원은 아직 드물고 상대적으로 널리 지원되지 않습니다. 하지만 모든 터미널에 걸쳐 보고된 너비의 편차를 보는 것은 흥미롭습니다. "2"와 "4"는 모두 이해할 수 있는 값입니다. "5"와 "6"을 보고하는 터미널들은 이상한 현실에 살고 있습니다.

특별한 난제 중 하나는 tmux나 zellij 같은 터미널 멀티플렉서입니다. 위에서 보셨듯 tmux는 wcwidth를 사용하므로 커서를 네 칸 앞으로 이동시킵니다. 하지만 tmux 자체도 출력된 값을 함께 보는 터미널 에뮬레이터 안에서 동작합니다. tmux와 터미널이 일치하지 않으면 커서가 동기화되지 않고 그로 인한 버그는 우스꽝스러울 수 있습니다3.


프로그램 작성자는 오늘 당장 무엇을 할 수 있을까?

터미널에서 실행되는 프로그램(텍스트 에디터, CLI, TUI 등)의 작성자라면, 오늘 당장 그래핌 클러스터를 더 적절하게 처리하기 위해 할 수 있는 일들이 있습니다.

첫째, 그냥 신경 쓰지 않아도 됩니다. 프로그램이 커서 위치를 추적할 필요가 없고, 수동으로 줄 바꿈을 할 필요가 없다면, 그냥 신경 쓰지 않고 터미널이 하는 대로 두면 됩니다.

둘째, 모드 2027 지원을 조회하고 가능하다면 모드 2027을 사용해 보아야 합니다. 표준 시퀀스인 DECRQM(모드 요청): CSI ? 2027 $ p를 사용해 모드 2027 지원을 조회할 수 있습니다.

셋째, CSI 6 n을 사용해 일련의 텍스트를 출력한 뒤 커서 위치를 조회할 수 있습니다. 그러면 터미널이 커서 위치를 보고하고, 이를 이용해 텍스트의 너비를 계산할 수 있습니다.

해서는 안 될 일wcwidth 동작 이나 그래핌 클러스터링 동작을 가정하는 것입니다. 앞선 섹션의 표에서 볼 수 있듯, 인기 있거나 주류인 터미널 에뮬레이터만 고려하더라도 두 가정 중 어느 쪽이든 여러 터미널 에뮬레이터에서는 틀리게 됩니다.


앞으로 더 많은 터미널과 터미널 프로그램이 모드 2027과 올바른 그래핌 클러스터링을 지원하게 되기를 바랍니다. 앞서 언급했듯이 이는 이모지 같은 "귀여운" 기능에만 영향을 미치는 것이 아니라 아랍어 같은 다양한 세계 언어에도 영향을 미칩니다.

터미널이나 터미널 프로그램 작성자가 아니더라도 이 글이 유익했기를 바랍니다. 터미널 에뮬레이터를 구현하면서 텍스트 처리의 복잡성과 레거시 동작이 주는 부담을 보는 것은 제게 정말 눈을 뜨게 하는 경험이었습니다.

모드 2027을 제안해 주신 Christian Parpart에게도 감사를 전하고 싶습니다. Christian은 제가 본 사람들 중 그래핌 클러스터와 터미널의 문제에 대해 가장 공개적으로 이야기하고, 이를 해결하기 위해 직접 행동에 나선 첫 번째 분입니다. 감사합니다!

각주

  1. 다행히도 3칸 이동하는 터미널 에뮬레이터는 찾지 못했습니다.

  2. 요즘에는 1바이트가 8비트라고 가정하는 것이 꽤 안전합니다.

  3. 다행히도 tmux가 커서 위치를 매우 적극적으로 수동으로 제어하기 때문에 이런 상황을 촉발하기는 매우 어렵습니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.