GNU Emacs의 문자열·인코딩·정규식·케이스 테이블, CCL, 그리고 버퍼·텍스트 속성·오버레이·마커 같은 개념과 갭 버퍼·로프·피스 트리 등 자료구조가 어떻게 얽혀 있어 재작성 난도가 높아지는지, 구현 관점에서 사례와 코드로 설명한다.
C 말고 다른 언어로 Emacs의 (일부) 기능을 다시 구현하려는 시도는 꽤 많았다. 예를 들어 JEmacs, remacs, lem 등이 있고, EmacsConf 2024에서도 새로운 시도가 보였다: rune1, schemacs2, 그리고 부활한 Guilemacs3. (엄밀히 말해 Guilemacs는 전면 재작성이라기보다 포크에 가깝지만, 여하튼.)
하지만 (GNU) Emacs4을 완전히 다시 쓰는 일은 언제나 넘기 힘든 과제처럼 보였다. 단지 에디터를 만드는 일이 본질적으로 어렵기 때문만은 아니다. 이 글은 그런 어려움의 일부와 (Emacs Lisp API로도 드러난) Emacs의 설계가 그것을 어떻게 야기하는지 살펴본다.
이 글을 쓰는 이유는, 나 역시 또 하나의 재작성에 대해 몽상해 본 적이 있기 때문이다. :-/
당신이 좋아하는 프로그래밍 언어가 지원하는 가장 높은 코드 포인트는 무엇인가? 현재 유니코드 표준처럼 #x10FFFF라면, Emacs와의 호환성을 유지하려면 직접 문자열 구현을 만들어야 할 것이다. Emacs는 #x3FFFFF까지의 문자를 지원하기 때문이다.
이 설계 결정에는 약간의 역사적 배경이 있으며, 그 결과 GNU Emacs는 다음 두 가지 멋진 기능을 갖게 되었다.
유니코드가 지배적인 시대에 여러 문자 집합을 다룰 일은 점점 드물어지고 있다. 하지만 다른 인코딩을 다뤄야 했던(하거나 해야 하는) 경우가 있다면, 파일을 열었는데 에디터가 인코딩을 잘못 추정한 경험이 있을 것이다. 일부 에디터는 그 상태에서 실수로 저장하면, 푹— 파일이 망가진다.
그런 재앙의 주된 이유는 문자열 타입이 잘못된 바이트 시퀀스를 다루는 방식에 있다. 예를 들어 Java에서 바이트를 내장 라이브러리로 String으로 디코딩할 때, 디코더에게 다음 중 하나를 시킬 수 있다:
#xFFFD로 치환한다.즉, 디코딩을 포기하거나, 손실을 감수하고 유니코드로 변환해야 한다. 유니코드를 인지하는 대부분의 언어/라이브러리의 문자열 타입에도 마찬가지가 적용된다. 문자열 타입에는 잘못된 유니코드 문자가 들어갈 자리가 없다. 그럴 만도 하다. 유니코드만 염두에 두고 설계(또는 재설계)되었기 때문이다.
그러나 텍스트 에디터에 특화된 언어인 Emacs Lisp은 잘못된 바이트를 위한 자리까지 마련해 두었다. Emacs에서 #x3FFFFF 하나를 포함하는 문자열을 만들어 보라. 그러면 그것이 단일 원시 바이트 \377(또는 #xFF)로 취급됨을 알 수 있다. 코드 포인트 범위 #x3FFF80 - #x3FFFFF는 원시 바이트를 표현하기 위해 예약되어 있다:
(string #x3FFF80 #x3FFFFF)
"\200\377"
이것이 Emacs가 손실 없는 파일 편집을 보장하는 방식이다. 인코딩이 무엇이든 간에, Emacs는 잘못된 바이트를 ELisp 런타임과 인코더가 모두 인식할 수 있는 "원시 바이트"로 변환한다. 저장할 때 이 원시 바이트는 그대로 보존된다.
사실 C 계열로 작성된 많은 에디터도 비슷한 성질을 가질 수 있다. 문자열이 그냥 바이트열이라, 수정되지 않은 바이트는 그대로 남기 때문이다. 다만 스크립팅 언어(만약 있다면)가 이 점을 잘 처리하도록 대비되어 있지는 않을 수 있다.
0 - #x3FFFFF 문자 영역을 택한 또 다른 이유는, Emacs가 유니코드 이전에 태어났고, 운 좋게도 유니코드가 실제로 보편적이라고 성급히 가정하지 않은 드문 앱 중 하나였기 때문이다.
유니코드 이전에는 지역과 언어마다 다양한 인코딩이 많았다. 그들의 상당수는 이제 유니코드에 편입되어 상호 맵핑이 가능하지만, 아직 완전히 맵핑되지 않은 것도 있다. 예로 티베트어 ISO-2022 인코딩이 있다. Emacs가 손실 없이 티베트어 코딩 시스템을 정의한 tibetan.el을 보면, 얼마나 많은 문자가 대체 문자로밖에 표현되지 않는지 알 수 있다.5
Emacs의 해결책은 잘못된 바이트에 대한 방식과 유사하다. Emacs는 유니코드에 아직 통합되지 않은(즉 유니코드로 맵핑되지 않은) 문자를 위해 #x110000 - #x3FFF7F의 코드 포인트 공간을 예약한다. 그러면 어떤 ELisp 프로그램도 그들을 정상적인 문자처럼 취급할 수 있고, 모든 텍스트 연산이 그대로 적용된다.
개인적으로는, 이렇게 "미통합" 문자들을 어떻게 표시해야 하는지 여전히 궁금하다. 내 Emacs 설정에서는 그저 16진수가 덧붙은 네모난 'tofu' 블록으로 보일 뿐이고, 그것들을 위한 폰트가 존재하는지조차 모르겠다. 요즘의 많은 폰트 포맷의 룩업 테이블도 유니코드 코드 포인트를 전제로 하는 듯하다(적어도 영어로 검색해보면 그렇게 보인다). 아마 이를 위해 특별한 폰트 포맷/터미널이 필요할지도?
이 문자들은 Emacs의 확장 UTF 유사 인코딩으로 인코딩된다. 또한 tibetan.el이 오래되어 현재는 유니코드가 더 나아졌을 가능성도 있다.
직접 문자열 타입을 만든다면, 문자열 라이브러리도 전부 재구현해야 한다. 심지어 그런 것을 무시하고 모든 것을 언어 내장 유니코드 문자열로 처리하기로 한다 해도, 대부분의 문자열 프리미티브를 위해 마음의 준비가 필요하다. 아니, 좋아하는 정규식/문자열 라이브러리를 그대로 감싸 쓰면 안 된다. 이유는 다음과 같다.
대부분의 언어는 문자열을 대문자/소문자로 변환하는 기능을 제공하며, 유니코드의 방대한 변환 수와 불규칙성 때문에 내부적으로 룩업 테이블을 쓴다. Emacs도 마찬가지지만, 그 룩업 테이블을 ELisp 코드에서 변경 가능하도록 완전히 노출한다(일부는 unicode-property-table-internal을 통해서만 접근 가능하긴 하다). 그 결과 upcase/downcase를 유니코드 대소문자 변환 외에도 임의의 문자열 변환용으로 활용할 수 있다. 그리고 실제로 그렇게 하는 ELisp 코드가 있다:
(defconst erc--casemapping-rfc1459-strict
(let ((tbl (copy-sequence ascii-case-table))
(cup (copy-sequence (char-table-extra-slot ascii-case-table 0))))
(set-char-table-extra-slot tbl 0 cup)
(set-char-table-extra-slot tbl 1 nil)
(set-char-table-extra-slot tbl 2 nil)
(pcase-dolist (`(,uc . ,lc) '((?\[ . ?\{) (?\] . ?\}) (?\\ . ?\|)))
(aset tbl uc lc)
(aset tbl lc lc)
(aset cup uc uc))
tbl))
이 코드는 Emacs에 내장된 IRC 클라이언트 중 하나인 erc.el에서 가져온 것이다. 여기서 하는 일은 RFC 1459에 명시된 대로, 스칸디나비아식 대소문자 변환을 위해 []\와 {}| 간의 매핑을 만드는 것이다:
IRC의 스칸디나비아적 기원 때문에, 문자 {}|는 각각 []\의 소문자에 해당하는 것으로 간주된다. 이는 두 닉네임의 동등성을 판단할 때 매우 중요한 문제다.
Emacs는 이를 위해 with-case-table 매크로도 제공한다. 흔히 ascii-case-table과 함께 써서, 로케일 의존형 대소문자 변환이 어떤 ASCII 프로토콜을 망치지 않도록 한다.
에디터가 정규식 없이 어찌하랴? 안타깝게도 Emacs의 정규식은 너무 특화되어 있어, 백트래킹이든 아니든 다른 어떤 정규식 구현과도 호환되지 않는다. 아래는 cc-fonts.el(CC Mode(C/C++ 모드)의 폰트 락 지원)에서 가져온 정규식 예시다:
"\\(\\=\\|\\(\\=\\|[^\\\\]\\)[\n\r]\\)" ; noncontinued-line-end
호환을 깨는 건 과도한 백슬래시 때문이 아니다. "보통"의 정규식 문법을 선호하는 사람을 위해 다시 쓰면 다음과 같다: (\=|(\=|[^\\])[\n\r]). 여기서 \=는 기본적으로 "사용자 커서가 있는 곳"을 뜻하고, 이 정규식은 "<역슬래시> 다음 <줄끝> 또는 <사용자_커서> 다음 <줄끝>을 연속되지 않은 줄의 끝으로 취급하자"는 의미다. 간단하지 않은가? 보편적인 정규식 라이브러리가 <사용자_커서> 같은 어설션을 지원한다면 말이다.
그리고 이게 전부가 아니다. Emacs 정규식은 [:lower:], [:upper:]나 case-fold-search를 통한 대소문자 무시 매칭에 케이스 테이블을 사용한다. 놀랍지 않게, 단어 어설션 \w, 단어 경계 어설션 \b 등에도 구문 테이블(syntax table)과 경우에 따라 문자 범주 테이블이 관여한다. 이 모든 것 때문에 속도가 다소 느린 백트래킹 정규식 엔진이 사실상 필수가 된다(다만 단순한 정규식은 비백트래킹 엔진으로 오프로드할 수 있다).
Emacs는 ELisp가 버퍼나 문자열의 특정 텍스트에
syntax-table속성을 붙여, "이 문자에 대해 구문 테이블이 말하는 것"을 덮어쓸 수 있게 한다. 주로parse-partial-sexp같은 파싱 서브루틴과 함께 쓰이는 듯하지만, 다행히 정규식 매칭에는 영향을 주지 않는다. (꽤흔한용례이긴하다.)
말했나 모르겠지만, Emacs는 인코딩 변환을 위해 또 다른 언어(CCL, Code Conversion Language)를 사용하며, ELisp에서 CCL로 사실상 어떤 인코딩 변환기도 구현할 수 있다. 그리 어렵지 않다. 필요한 것은 구현에 바이트코드 인터프리터를 하나 더 넣는 것뿐이다.
다른 선택지는 모든 것이 유니코드인 척하는 것이다.
모든 에디터는 편집 가능한 텍스트를 표현하는 무언가를 가져야 한다. 갭 버퍼, 로프, 피스 테이블/트리, 또는 그냥 문자열일 수도 있다. 그러나 Emacs의 버퍼는 단순한 텍스트 그 이상이며, 여러 개념이 버퍼와 촘촘히 결합되어 있다:
텍스트 속성: 특정 텍스트에 폰트, 색상, 임의의 객체를 부착한다. 텍스트 하이라이트나 숨김에도 사용할 수 있다.
오버레이: 텍스트 속성과 유사하지만, 기존 속성을 대체하지 않고 그 위에 "겹쳐서" 적용한다. 나중에 복구를 걱정할 필요가 없도록.
마커: 이전 커서 위치로 돌아가고 싶은가? C-u C-SPC는 스택의 마지막 마커를 꺼내 그곳으로 이동시킨다. 이는 버퍼에서 당신이 있던 곳을 ‘표시(mark)’해둔 값일 것이다.
간접 버퍼: 간접 버퍼는 기본 버퍼의 텍스트와 텍스트 속성을 공유하지만, 마커, 오버레이, 내로잉(버퍼가 텍스트의 특정 부분에만 동작하도록 범위를 제한하는 것)은 공유하지 않는다.
이 모든 것은 텍스트를 어디서 어떻게 삽입/삭제하든 텍스트와 동기화되어 있다. 그리고 Emacs용 버퍼를 설계할 때 반드시 고려해야 한다. 특히 애초에 동시 리디스플레이를 바란다면 더욱 그렇다.
또한, 적어도 Emacs에서는 사용자가 문자열과 버퍼를 대개 코드 포인트 기준으로 인덱싱한다. 따라서 문자열을 UTF-8 유사 바이트로 저장한다면, 바이트 오프셋과 문자 오프셋을 상호 매핑할 방법을 고안해야 한다. 또는 자동 압축형 UTF-32 인코딩을 사용한다면, Emacs의
position-bytes/string-bytes서브루틴이 요구하듯 문자 오프셋을 바이트 오프셋으로 되돌리는 매핑이 필요하다.
에디터에게는 괜찮은 텍스트 편집 성능이 핵심적이다. 몇몇 에디터의 버퍼 구현을 먼저 보자:
GNU Emacs: 갭 버퍼, 트리에 저장된 인터벌, 줄 번호 캐시, 지속적으로 조정이 필요한 정수 래퍼 형태의 마커.
VS Code: "피스 트리(piece trees)", 실상은 로프처럼 보인다. 거대한 추가 전용 문자열 버퍼의 부분 문자열 참조로 조각 문자열을 다루는 로프라고 할 수 있다.
Neovim: 라인 기반 로프, 별도 트리에 저장된 마크.
"엄마, 우리 로프 사줄 수 있어요?" "집에 로프 있단다."
IntelliJ: 로프 유사 문자열, RangeMarkerTree에 저장된 범위 마커, LineSet에 저장된 줄 번호 매핑.
Zed: 로프, SumTree로 구현.
이 구현들이 무엇인지 하나하나 설명하진 않겠다. 이미 갭 버퍼, 피스 트리, 로프 등에 관한 글이 많다. 서로 달라 보이지만, 로프를 "통계가 덧붙은 문자열"로 생각하면(단일 스레드 맥락에서) 구분이 흐려진다:
| 로프 | 갭 버퍼 + 트리 | 피스 트리 | |
|---|---|---|---|
| 메타데이터 유지 | 트리 내부 | 트리 내부 | 트리 내부 |
| 새 문자열 | 할당 | 갭에 저장 | 문자열 버퍼 |
| 문자열 삭제 | 할당 해제 | 갭 갱신 | 노옵 |
"트리 내부 메타데이터 유지"를 보았는가? 버퍼는 단지 텍스트가 아니다. 텍스트에 부착된 모든 메타데이터, 예컨대 텍스트 속성, 코드 포인트 오프셋, 줄 번호 등을 포함한다. 그리고 이들이 텍스트와 동기화되길 원한다면, 보통 트리가 해법이다. (그리고 Zed가 이를 Rust의 제네릭과 트레잇으로 모두 SumTree로 다루는 방식이 정말 마음에 든다.)
텍스트 속성은 Emacs 버퍼만의 것이 아니다. 예상했겠지만, ELisp 문자열도 속성을 지닌다. 그리고 문자열과 버퍼 사이에 속성을 주고받는 것은 자연스럽다:
(with-temp-buffer
;; `insert'는 문자열 속성도 버퍼에 함께 삽입한다.
(insert #("hello world" 0 11 (str-prop 1)))
(put-text-property (point-min) (point) 'buf-prop 2)
;; 버퍼의 텍스트 속성은 부분 문자열로 추출된다.
(buffer-substring 1 6))
#("hello" 0 5 (buf-prop 2 str-prop 1))
낯선
#("hello world" 0 11 (str-prop 1))표기는 텍스트 속성이 딸린 문자열의 읽기 구문(read syntax)이다.
문자열을 버퍼에 삽입할 때는 몇 가지 코너 케이스가 있다. 0/1 기반 오프셋 변환, 인터벌 병합(coalescing), 점착성(stickiness) 같은 것이다. 이런 것들은 비교적 단순하니, 좀 더 흥미로운 경우를 보자:
(with-temp-buffer
(insert #("🤗" 0 1 (a 1)))
(set-buffer-multibyte nil)
(buffer-string))
#("\360\237\244\227" 0 4 (a 1))
요지는, 멀티바이트 버퍼를 단일 바이트 버퍼로 변환할 때, 1 - 2에 걸친 속성이 자동으로 1 - 5로 확장된다는 점이다(아마 내부적으로 인터벌이 바이트 위치를 추적하기 때문일 것이다):
1 2 (chars)
|----> 속성: (a 1) <----|
+------+------+------+------+
| 🤗 (UTF-8에선 네 바이트) |
+------+------+------+------+
| (set-buffer-multibyte nil)
\|/
+------+------+------+------+
| \360 | \237 | \244 | \227 |
+------+------+------+------+
|----> 속성: (a 1) <----|
1 2 3 4 5 (chars)
그렇다면 멀티바이트 문자열을 단일 바이트 문자열로 바꾸면 어떻게 될까? 보통은 문자열 속성을 보존한 채 그렇게 할 수 없다. 하지만 Emacs 문자열은 가변적이므로 clear-string으로 우회해 볼 수는 있다:
(let ((s #("🤗" 0 1 (a 1))))
(clear-string s)
(prin1 s))
Fatal error 11: Segmentation fault Backtrace: emacs(emacs_backtrace+0x51) [0x57ed0155d1] emacs(terminate_due_to_signal+0xa1) [0x57ed014408f4] emacs(+0x80260) [0x57ed01441260] emacs(+0x80267) [0x57ed01441267] emacs(+0x19969e) [0x57ed0155a69e] /usr/lib/libc.so.6(+0x3d1d0) [0x755cd06dc1d0] emacs(copy_intervals+0x188) [0x57ed0164fa58] emacs(Fcopy_sequence+0x84) [0x57ed015dafc4] emacs(+0x23a86d) [0x57ed015fb86d] emacs(Fprin1+0x6b) [0x57ed015fcfdb] emacs(eval_sub+0x933) [0x57ed015d2c43] emacs(Flet+0x250) [0x57ed015d6080] emacs(eval_sub+0x7fa) [0x57ed015d2b0a] ... (as of Emacs 30.0.93)
음… 예상 밖이다. 크흠, 결론은 Emacs 텍스트(버퍼와 문자열 모두)가 단일 바이트(원시 바이트) 변형과 멀티바이트 변형으로 나뉘어 있다는 점에서, 코너 케이스를 특히 조심해야 한다는 것이다. 🤗
참고로 이제 수정되었다.
clear-string이 문자열 속성도 함께 지우도록.
가끔 갭 버퍼와 로프를 논할 때 인용되는 글(로프 기반 Rune의 제작자 Troy Hinckley 작성)이 있다: Text showdown: Gap Buffers vs Ropes. 하지만 그 벤치마크의 일부는 공정하지 않다고 본다. ropey와 crop은 줄 번호를 추적하는 반면, 갭 버퍼 구현은 그렇지 않기 때문이다. (혹시 내가 놓친 게 있을지도. 글에는 "문자와 줄 위치 같은 메트릭을 포함"한다고 되어 있지만, 실제로는 아직 그렇지 않다.) 새 줄 스캔과 CRLF에 대한 특수 처리는(예: 먼저 CR을 넣고 나중에 LF를 넣으면?) 분명 시간이 든다… 어쩌면 그렇지 않을 수도.6 어쨌든, 내가 좋아하는 벤치마크 라이브러리 중 하나(JMH)의 말을 인용하자면:
기억하라: 아래 숫자는 그저 데이터일 뿐이다. 재사용 가능한 통찰을 얻으려면, 왜 그 숫자가 그렇게 나왔는지 파고들어야 한다. 프로파일러를 사용하고(-prof, -lprof 참고), 요인 설계를 하고, 기준선 및 음성 테스트로 실험적 통제를 확보하고, JVM/OS/HW 레벨에서 벤치마크 환경을 안전하게 만들고, 도메인 전문가의 리뷰를 받아라. 숫자가 네가 바라는 이야기를 해준다고 가정하지 말라.
Rune의 저자는 Reddit 댓글에서 이 이슈에 답했다. 그러니 줄 번호 추적이 문제는 아닐 공산이 크다. 다만 제시된 갭 버퍼 구현과 로프 크레이트 사이의 차이는 여전히 의문을 낳는다. 성능 차이가 정말 갭 버퍼 대 로프에서 오는 것일까, 아니면 B-트리 대 RB-트리, B-트리 파라미터의 차이, 가변(제자리 수정) 대 불변(복사 전용)의 차이일까?
또한 앞서 논의했듯, 다음과 같은 상황에서는 로프와 갭 버퍼의 성능 차이가 그리 크지 않다고 생각한다:
하지만 이들은 다른 측면에서는 차이가 있으며, 그 차이가 Emacs의 다른 구성 요소 설계를 좌우할 수도 있다. 여기 Eli의 Reddit 댓글을 보자:
… 버퍼 텍스트 저장과 관련된 몇 가지 측면은 … Emacs에서 매우 중요하다: 디스플레이와 파일 I/O. 특히 전자가 중요하다. Emacs 리디스플레이가 느린 경우(아주 긴 줄 등)를 면밀히 분석하면, 근본 문제는 버퍼 텍스트의 완전히 비구조적이고 일차원적인 표현이라는 결론에 도달하게 된다. 그러므로 버퍼 텍스트의 대체 자료구조를 연구하면서 디스플레이에 미치는 효과를 고려하지 않거나, 최소한 현재 리디스플레이 문제의 일부라도 해결할 수 있는 대안을 검토하지 않는다면, 내 관점에서는 심각하게 불완전하다.
그러니 버퍼가 Emacs 리디스플레이와 Emacs 내부의 수많은 것에 관여하기 때문에, 버퍼의 난제를 이해하려면 먼저 리디스플레이를 이야기해야 한다. 하지만… 글이 너무 길어지므로 그건 다음 글에서. 이 글은 그 점에서 "심각하게 불완전"할 것이다.
잡학: Emacs의
xdisp.c파일은 약 1.2MB, 3.8만 줄에 이르며 대부분이 "디스플레이 생성"에 전념한다. 내가 그것을 이해하고 2편을 마칠 수 있을지 의문이지만, 두고 보자.
여기서 "근거 필요"란, "아무 근거도 없는 순전히 개인적 추측 :(이지만, 내 magit 세션/doom-modeline이 느린 데는 (버퍼 외의) 다른 이유가 분명 있을 것이다"라는 의미다.
이 글에서는 주로 Emacs의 텍스트 처리 기능과 그것이 구현에 던지는 도전 과제를 살펴봤다. 개인적으로, 내장 문자열에 원시 바이트를 포함하도록 설계한 또 다른 언어를 아직 보지 못했다. 이는 확실히 있으면 좋은 기능이다. Emacs의 유지보수자와 기여자들에게 다시 한 번 박수를! 다음 글(들이라면 좋겠다)에서는 Emacs의 리디스플레이를 논하고, Emacs를 병렬화하기가 왜 어려운지 살펴볼 것이다.
읽어 주셔서 감사합니다! 이 글에 관한 토론이 Lobsters, Reddit, HN에 있다. 통찰력 있는 댓글이 있으니 참고하길!
특히 Rune의 저자이자 "Text showdown: Gap Buffers vs Ropes"의 저자인 Troy Hinckley가 이 글의 여러 이슈를 지적하고, 여기 제시된 문제를 다루는 자신의 비전을 이야기했다. (rune 저장소는 https://github.com/CeleritasCelery/rune.)
또한 Schemacs(원래 이름 Gypsum)를 만든 Ramin Honary가 Fedi에 자신의 견해를 공유했다. (Schemacs 저장소는 https://codeberg.org/ramin_hal9001/schemacs.)