기존 편집기들에 만족하지 못해 직접 TUI 텍스트 에디터를 만들고 실제로 데일리 드라이브하면서 얻은 개발 경험과 설계·최적화에 대한 단상들.
프로그래머의 텍스트 에디터는 그들의 성이다
장난감 소프트웨어가 별들이 맞아떨어지고 바이트들이 숨을 죽일 때 작업을 수행하는 것과, 현실 세계가 내놓는 기괴한 데이터가 무엇이든 받아들이고 이를 우아하게 처리하는 것은 별개의 일이다.
한동안 나는 내 텍스트 에디터에 만족하지 못했다. 약 10년 전 Howl에 정착했는데, 가볍고 효율적으로 쓰기 좋지만 여러 면에서 부족하다:
개발이 몇 년째 사실상 멈췄다. 한동안 내 포크를 유지해 왔지만, 에디터는 MoonScript로 작성되어 있고 나는 그 언어를 배워서 코드베이스를 깊게 이해하고 싶지 않다. 작은 손질 이상의 일을 하기에는 부담이 크다.
Howl은 프로젝트 전체 파일 검색에서 숨이 막힌다. 끔찍하진 않지만, 흐름 상태에서 나를 끌어낼 정도로 충분히 나쁘고 결국 사용을 피하게 만든다. 나는 LSP를 쓰는 습관이 없어서, 큰 코드베이스를 이해할 때 텍스트를 grep로 뒤지는 일에 꽤 많이 의존한다. 그래서 이런 부분이 싫다.
Howl은 GUI 에디터다. 대체로 키보드 중심이긴 하지만 SSH 연결 위에서 쉽게 돌릴 수가 없다. 점점 더 많은 시간을 네트워크 케이블 반대편의 머신에 로그인한 채로 보내고 있고, SFTP는 한계가 있다.
통합 터미널이 없다. 외부 명령을 실행하고 출력은 볼 수 있지만, 실시간 상호작용을 위한 장치가 없고 ANSI 이스케이프 코드의 대부분이 지원되지 않아 색상도 기대하기 어렵다.
지난 몇 년 동안 대안을 찾아다녔다. 아래는 내가 써 본 에디터의 불완전한 목록이다:
모두 저마다 강점이 있지만, 내가 원하는 fingerspitzengefühl은 그 어디에도 없었다. 가장 오래 붙잡고 있던 건 Helix였지만, 한 달쯤 쓰고 나니 정이 떨어졌다. 특정한 비판이 있는 건 아니다. 아주 좋은 에디터다. 다만 내가 중요하게 여기는 그 형언하기 어려운 무언가가 없었다.
그래서 지난 2년 동안 나는 내 것을 만들고 있다. 개발의 여러 측면에 대한 단상을 몇 가지 적어 보겠다. 개인적으로 관심 있는 섹션으로 건너뛰어도 좋다.
처음에는 범위를 작게 잡았다. 기능 목록에서 빼 둔 것들은 이런 것들이다:
나 말고 다른 사람을 위한 기능: 토글 스위치 같은 건 없고, 모든 선호 설정은 에디터에 하드코딩했다.
성능: 당장은 String 기반 버퍼면 충분하다. 성능이 문제가 되면 그때 고치겠다.
유니코드 grapheme에 대한 제대로 된 지원. 나는 단일 언어 사용자이고 이모지도 많이 쓰지 않는다. £가 한 칸을 차지하기만 하면, 사실 별로 상관 없다.
문법 하이라이팅 다양성: 내가 하는 일은 꽤 작은 언어 풀 안에서 벌어진다. 그 언어들만 지원하고, 더 이국적인 것들은 일반적인 구분자 기반 하이라이팅으로 폴백하겠다.
처음에는 진척이 느렸다. 기본적인 터미널 렌더링을 대충 엮는 동안 빈 화면을 바라보는 건 정말로 의욕이 꺾이는 일이어서, 몇 주씩 전혀 손대지 않는 때도 많았다.
공교롭게도 이것은 내가 텍스트 에디터를 써 보는 두 번째 시도였고, 이벤트와 상태 로직을 단순화해 주는, 단순하지만 꽤 조합 가능한 TUI 프레임워크를 만들기로 했다. 지금 돌이켜보면 초기 노력의 상당 부분은 과했고, 시간이 지나면서 더 직관적이고 세밀한 접근을 선호하게 되어 점진적으로 뜯어내게 됐다.
어느 시점에서 마침내 임계치에 도달했다. 내 에디터가 텍스트 파일 하나를 열고, 간단히 작업하고, 변경 사항을 저장할 수 있을 만큼은 딱 기능을 갖춘 것이다. 나는 돌이켜보면 프로젝트가 ~/projects 디렉터리의 또 다른 사망한 항목이 되지 않도록 해 준 핵심적인 세 가지를 실천하기로 했다:
내 에디터로 nano를 대체했다. 시스템 파일을 편집하거나 빠르게 메모할 때마다, 아무리 고통스러워도 무조건 내 에디터를 쓰도록 스스로를 강제했다.
기능 누락, 버그, 이상한 동작, 제한에 부딪힐 때마다, 얼마나 사소하든 프로젝트 README.md에 기록했다. 남는 시간에 무엇을 해킹해야 진척이 나는지 아는 것이 필수였다.
어떤 문제가 나를 짜증 나게 할 정도라면, 그 자리에서 바로 고쳐야 했다.
이 조합 덕분에 프로젝트에 쓰는 시간이 한 달에 한 시간 수준에서, 일주일에 몇 시간 수준으로 늘었다. 전체 약 1만 줄의 코드 중 거의 전부가 지난 6개월 동안에 생겼다.

커서 조작은 어렵다! 텍스트 입력 위젯을 사용할 때, 당연한 기본 동작들 중 상당수는 의식조차 하지 않는다. ctrl + shift + left 같은 키바인딩을 길게 누를 때 정확히 무엇이 일어나는지는 아마도 근육 기억일 텐데, 그 모든 로직이 서로 잘 맞물리도록 만드는 일은 작성하기 즐겁지 않다.
내가 줄 수 있는 가장 좋은 조언은, 고수준 입력을 더 원시적인 입력들로 구현해 보라는 것이다. 단어 단위 백스페이스를 구현하고 싶나? 단어 단위 커서 이동, 시작/끝 위치 사이 범위 선택, 그리고 삭제라는 동작으로 구성해 구현하라. 나중에 undo/redo를 구현할 때는, 이 3가지 동작이 하나로 묶여야 한다는 점도 보장해야 한다. 그렇지 않으면 undo가 매우 직관적이지 않은 결과를 만들게 된다. 모달 에디터들이 중간 단계를 건너뛰고 이런 원시 연산들을 사용자에게 직접 노출해서 서로 체이닝하도록 하는 이유가 놀랍지 않다.
나를 Howl로 다시 돌아오게 만들던 기능이 하나 있었는데, Howl이 놀랄 만큼 잘하는 기능이다. 바로 파일 브라우저다.
Howl의 파일 브라우저는 보기엔 별 것 없지만, 쓰는 경험은 정말 즐겁다. 파일에 대한 즉시 업데이트되는 퍼지 필터가 있고, 그 필터가 좋다. 첫 한두 번의 키 입력만으로도 내가 찾는 파일을 맞히는 경우가 매우 흔하고, 네 번째나 다섯 번째 입력까지도 못 맞히는 경우는 극히 드물다. 파일이 존재하지 않고 새로 만들고 싶다면, 다른 메뉴로 이동할 필요 없이 그 자리에서 바로 만들 수 있다. ~/를 입력하면, 다른 경로에서 아무리 깊이 들어가 있더라도 자동으로 홈 디렉터리로 전환된다. 메인 편집 창은 열기 직전의 파일 미리보기를 보여 준다.
Howl의 파일 브라우저는 너무 많은 것을 올바르게 하고 있는데, 다른 에디터들이 파일 열기 문제에 이렇게까지 맥 빠지는 해법을 선택하는 게 정말 이해가 안 된다. 목록에 있는 많은 에디터들은 마우스를 집게 만들거나, GTK의 _기본 파일 열기 대화상자_를 띄워서 편집 경험을 끊어 놓거나, 어떤 옵션이 가능한지 보여 주는 대신 열고 싶은 파일 이름을 내가 추측하게 만든다.
이를 내 취향대로 다시 구현하는 건 특별히 복잡한 작업이 아니었다. 파일 필터를 위해 Levenshtein distance처럼 복잡한 것을 후보 항목과 필터 사이에 적용하는 방법을 잠깐 고민했지만, 중요한 교훈은 이런 노력은 실전에서 무의미한 과잉이라는 점이었다. 닫힌 집합의 아이템에 대해 훌륭한 예측 검색을 제공하려면 실제로 필요한 것은 세 가지뿐이다:
항목이 필터 구문으로 시작하는지
항목이 필터 구문을 포함하는지
항목의 가장 최근 수정/접근 시간
이 기준으로 아이템을 랭킹하라. 대소문자 무시 매칭을 허용하되, 대소문자가 정확히 일치하면 약간 더 높은 점수를 주면 된다.
끝이다! 수만 개의 파일이 있는 프로젝트에서 프로젝트 전체 파일 검색을 수행하더라도, 이 기준들은 대략 95%의 경우 단 2번의 키 입력 후에 내가 찾는 파일을 목록 상단에서 2칸 이내에 배치해 준다.
정규식 지원은 여러 방식으로 사용된다:
세 가지 모두 어느 정도 성능이 괜찮은 구현이 필요하지만, 앞의 둘에서는 그것이 결정적이다. regex-automata 같은 기존 솔루션을 통합하는 것도 잠깐 고려했지만, 내 요구사항 중 하나는 Rust 스타일 raw string 문법 같은 문맥 민감한 엣지 케이스를 하이라이터가 올바르게 처리하는 것이다. 이런 건 기본 정규식 문법만으로는 처리할 수 없다.
게다가 이 프로젝트 전체가 내 스택을 직접 만들고 이해하는 연습이기도 해서, 나는 문맥 민감성과 패턴의 임의 중첩을 편의상 지원하는 확장을 포함한 나만의 정규식 엔진을 구현하기로 했다.
첫 시도들은, 음, 빠르지 않았다. 내 파싱 크레이트 chumsky를 사용해서 정규식 문법에 대한 파서를 작성했고, 매치를 찾기 위해 입력의 모든 문자마다 결과 AST를 순회했다.
시간이 지나면서 위에 몇 가지 최적화를 쌓았다:
AST를 순회하며, 플레임그래프에서 반복해서 보이던 흔한 패턴들을 정리(커뮤트)하는 단일 패스 옵티마이저. 예를 들어, 문자 매치들의 그룹은 우회 없이 정확한 문자열을 찾는 단일 String 노드로 최적화된다.
모든 매치가 공유하는 공통 접두사를 찾기 위해 AST를 순회했다. 예를 들어 hel[(lo)p]는 hello와 help를 모두 매치하지만, 두 경우 모두 항상 hel로 시작한다. 그러니 그에 맞게 시작하는 위치에서만 매칭을 수행하면 된다. 이는 프로젝트 전체 검색에서 엄청난 이득이었다.
AST 워커를 Rust의 동적 호출로 구현한 간단한 threaded code VM으로 재구현했다. 이 기법에 대해 더 알고 싶다면 여기를 보라.
threaded code VM을 CPS 형태로 변환했다. 각 VM 명령이 후속 명령을 tail-call하도록 하여 컴파일러가 이를 tail call로 최적화할 수 있게 했다.
호출 시 vtable lookup이 필요 없도록, Rust의 (느린) 동적 함수 호출을 감싸는 방식을 구현했다. 이 기법에 대한 자세한 내용은 여기에서 읽을 수 있다. 이를 적용한 뒤에는 많은 정규식 명령의 코드 생성이 크게 개선되어, 종종 각 명령이 몇 개의 머신 인스트럭션 수준으로 떨어졌다.
가능한 많은 정규식 명령을 유니코드 코드포인트가 아니라 바이트 기준으로 구현했다. UTF-8 설계에서 가장 멋진 점 중 하나는, ASCII 문자열을 빠르게 다루게 해 주는 기법들의 상당수가 멀티바이트 코드포인트가 섞여 있어도 여전히 잘 작동한다는 것이다!
정규식을 점프 LUT 체인으로 컴파일하려는 시도도 했지만, 벤치마크를 해 보니 최선의 경우에도 threaded code 접근보다 20-30% 정도 빠른 수준에 그쳤고 구현의 유연성을 크게 해치는 데 비해 복잡성이 증가하는 걸 정당화하기 어려웠다.
이 작업의 결과, 내가 가진 가장 큰 Rust(내 에디터에서 가장 복잡한 하이라이팅 언어) 샘플 파일(자동 생성 바인딩 5만 줄)을 깨끗한 상태에서 10밀리초 미만으로 완전 하이라이팅할 수 있게 됐다. 화면이 스스로 리프레시하는 속도보다 빠르다. 조금 더 작업하면 더 밀어붙일 수 있을 것 같지만, 최적화 토끼굴이 얼마나 깊은지 너무 잘 알고 있고, 이걸 정규식 엔진 설계 연습으로 바꾸고 싶지 않다.
초기 접근은 변경이 있을 때마다 파일 전체를 다시 하이라이팅하는 것이었다. 기능적으로는 되지만, 큰 파일에서는 성능 저하가 눈에 띄기 시작한다.
이를 개선하기 위해, 대략 비슷한 크기의 청크 단위로 토큰을 필요할 때 하이라이팅하는 캐시를 작성했다(매우 큰 토큰 때문에 청크가 더 커지는 경우도 있다). 버퍼에 변경(‘damage’)이 발생하면, 변경 위치와 겹치거나 그 이후에 오는 모든 청크를 무효화한다.
이 접근은 실제로 매우 빠르다. 가장 비관적인 경우는 아주 큰 파일의 정확히 가운데에서 텍스트를 편집하는 상황이다. 이때는 손상 영역 이전의 하이라이팅 상태를 전부 유지할 수 있고, 에디터는 화면 아래쪽 훨씬 이후에 오는 부분은 굳이 하이라이팅하지 않는다. 왜냐하면 그에 대한 하이라이팅 정보가 요청되지 않기 때문이다! 이 접근은 _수요 기반_이라서, 여러 패널이 같은 버퍼의 서로 다른 부분에 포커스되어 있어도 잘 작동한다.
여기서 할 말은 많지 않다. 프로젝트를 검색할 때 나는:
현재 디렉터리에서 거슬러 올라가 .git/ 디렉터리를 찾고, 그것을 프로젝트 루트로 간주한다
프로젝트 루트의 모든 디렉터리를 재귀적으로 순회하며 파일 내용에 대해 검색 바늘 정규식 패턴을 매칭한다
매치가 난 결과마다 파일 스니펫을 추출하고, 결과 미리보기를 위해 문법 하이라이팅한다
현재 경로로부터의 순회 거리(traversal distance)에 따라 결과를 랭킹한다(가까운 파일일수록 관련성이 높을 가능성이 크다)
빌드 디렉터리 같은 것들을 순회하지 않도록 하는 기본적인 필터링 규칙도 있다.
약간 흥미로운 디테일 하나는, 이 과정이 멀티스레드라는 점이며, 기본적인 work-stealing 방식으로 스레드에 작업을 배분한다는 것이다. 내가 맞닥뜨린 흥미로운 문제는 모든 스레드가 더 이상 새로운 작업을 생성하지 않는 시점을 판단하는 것이었다(각 스레드가 소비자이면서 동시에 생산자이기도 한데, 이는 work-stealing에서 흔치 않다). 나는 작업을 기다리는 스레드가 크리티컬 섹션에 들어가 원자 카운터를 증가시키고, 짧은 시간 잠자기를 루프하는 설계를 선택했다. 원자 카운터가 워커 스레드 수와 같은 값에 도달하고 작업 큐가 비어 있다면, 이는 모든 워커가 작업 생성을 끝냈고 모두가 멈춰도 된다는 뜻이다.
실제로는 위에서 언급한 정규식 최적화와 현대 SSD의 속도 덕분에, 사소한 패턴에 대한 전체 프로젝트 검색은 Veloren 같은 꽤 큰 코드베이스에서도 거의 즉시 끝난다는 걸 알게 됐다. 플레임그래프는 대체로 IO 바운드임을 보여 주지만, 파일 접근을 배치하는 더 영리한 접근이 있다면 더 개선될 여지도 분명 있다.
이게 내 생산성에 얼마나 큰 승리인지 말로 다 하기 어렵다. 큰 코드베이스에서 질문에 대한 답을, 에디터 안에서, 생각의 속도로—질문이 머리에 떠오르는 즉시—검색할 수 있다는 건 정말 기분 좋은 일이고, 내가 실제로 즐겨 쓰는 어떤 에디터에서도 이전엔 경험하지 못했던 것이었다.
내 에디터는 패널 기반이고, 여러 버퍼를 나란히 열어 둘 수 있다. 터미널 에뮬레이터에 이 기능을 의존하는 대신(그리고 따라서 이를 관리하기 위한 다른 키바인딩 세트를 쓰는 대신), 패널 하나가 터미널 창이 되는 편의성이 엄청나다는 사실이 빠르게 분명해졌다.
ANSI 파서를 손으로 구현하는 것도 잠깐 들여다봤지만, OSC52, Kitty 키보드 확장 등 최신 터미널 렌더링 기능을 지원하려고 하면 금세 감당하기 어려운(그리고 무엇보다 그리 재미있지도 않은) 산이 되어 버린다. 나는 오를 의사가 없었기 때문에, Alacritty 터미널 에뮬레이터 프로젝트의 핵심 이스케이프 시퀀스 파서와 터미널 상태 관리 로직을 구현한 alacritty_terminal 크레이트 위에 구축하기로 했다. 그 결과 이 기능의 구현은 꽤 사소한 일이 되었지만, 서드파티 라이브러리를 사용했기 때문에 더 할 말은 많지 않다.
이제 내 텍스트 에디터는 screen/tmux의 핵심 기능을 대체할 만큼 충분히 쓸 만하고, 거기에 더 풍부한 이스케이프 시퀀스 지원까지 갖췄다. 좋다.
내 에디터는 TUI 기반이지만, 터미널을 쓴다고 해서 자동으로 빠른 건 아니다! 모바일 연결로 원격에서 에디터를 사용할 때는 대역폭도 여전히 중요하고, 큰 파일을 스크롤하는 것 역시 여전히 꽤 큰 문제가 될 수 있다.
이를 최소화하기 위해 내 에디터는 터미널 화면의 내부 복사본을 더블 버퍼링한다. 다시 그릴 때 새 프레임을 이전 프레임과 비교해 손상된 셀에 대해서만 ANSI 이스케이프 시퀀스를 내보내고, 커서 이동, 스타일 모드 변경 등도 실제로 필요할 때만 시퀀스를 방출한다.
그 결과 대부분의 터미널 에뮬레이터(아마 Ghostty는 제외)에서는, 내 에디터를 터미널 패널 안에 열고 큰 파일을 cat한 다음 에디터를 닫는 편이, 호스트 터미널에서 그냥 cat하는 것보다 실제로 더 빠르다. 내 에디터가 호스트 터미널 에뮬레이터가 추가 stdout 바이트들을 처리하는 비용으로부터 보호해 주기 때문이다(고마워요, alacritty_terminal!).
상식(그리고 어쩌면 상식적 판단)은, 자기 에디터/도구를 직접 만드는 일이 쓸데없는 고통의 연습이라고 말할 것이다. 하지만 직접 해 본 뒤로 나는 단호히 동의하지 않는다. 동기 있는 엔지니어에게는 많은 장점이 있다:
장갑처럼 딱 맞는다: 내 에디터는 내가 원하는 것을 정확히 한다. 더도 말고 덜도 말고.
많은 것을 배웠다: 에디터를 만들기 위해, 이전에는 부분적으로만 알고 있던 여러 일반적으로도 유용한 기술들—정규식, ANSI, 가상 터미널(pty), TUI 디자인, UTF-8의 자잘한 디테일 등—을 깊이 이해해야 했다.
장기적인 생산성 향상: 자기 도구를 구석구석까지 이해하고, 개인 워크플로에 맞춘 기능을 명시적으로 내장하면, (결국에는!) 도구와 싸우는 데 쓰는 시간이 줄고 프로그래밍 행위 자체를 즐기는 시간이 늘어난다. 소프트웨어 개발의 다이얼을 ‘사무적인 잡일’에서 ‘사고 작업’ 쪽으로 돌려 준다.
그냥 미친 듯이 재미있다: 깔끔하게 분리된 멋진 문제들을 잔뜩 풀고, 그 노동의 산물을 눈으로 보는 것—아니, 손가락으로 느끼는 것—만큼 좋은 건 없다. 최근 몇 달 동안 간신히 붙잡고 있던 프로그래밍에 대한 사랑이 다시 불타올랐고, 그 열정의 상당 부분이 오픈소스, 본업, 개인 생활로까지 번져 나갔다. 프로그래밍을 하면서 미친 듯이 씩 웃고 혼자 킥킥 웃는 나 자신을 발견했는데, 이런 일은 정말 오랜만이다. 좋은 신호이길 바랄 뿐이지만, 아직은 판단을 보류하겠다.
그러니: 자기 도구를 만들어 보라! 꼭 텍스트 에디터일 필요는 없다. 그리고 제발, 도전을 즐기고 어려운 부분을 통계 상자 속으로 밀어 넣고 싶은 유혹을 참아라. 고투 속에는 기쁨이 있다.
