How to 목록

// blog post

터미널 화면이 깨지는 이유 — VibeShell이 Unicode width를 다루는 방법

|
unicodeterminaldeep-diverendering

⏸ 버튼 하나가 vim을 망가뜨렸습니다

VibeShell 개발 초기, 터미널 UI 테스트를 하던 중 이상한 걸 발견했습니다.

재생/일시정지 컨트롤에 같은 심볼을 쓰고 싶었거든요. 근데 이걸 화면에 넣는 순간, vim에서 커서가 이상한 위치로 가고 줄 정렬이 완전히 무너지는 겁니다. tmux에서도 마찬가지였고요.

처음엔 폰트 문제인 줄 알았습니다. 그다음엔 locale 설정 문제인가 싶었고요. 한참 삽질한 끝에 알게 된 진짜 이유는 훨씬 근본적인 거였습니다.

바로 Unicode width 불일치 문제입니다.

터미널은 셀 기반입니다

터미널 에뮬레이터는 문자를 그리드 셀(grid cell) 단위로 배치합니다. 문자 하나가 차지하는 셀 수가 곧 그 문자의 width입니다.

Width의미예시
0Combining character결합 문자
1Narrow (일반)A, , 1
2Wide (전각), ,

bash, zsh, vim, tmux 같은 CLI 프로그램들은 다음 함수로 커서 위치를 계산합니다.

wcwidth()   // 문자 하나의 width
wcswidth()  // 문자열 전체의 width

이 함수는 서버의 glibc가 제공합니다. 즉, 서버 기준으로 커서 위치를 계산한다는 뜻이에요.

문제의 정체

(U+23F8)를 예로 들어볼게요.

이 문자의 Unicode East Asian Width 속성은 Neutral입니다. glibc의 wcwidth()가 반환하는 값은 1 — 즉 한 칸짜리 문자로 취급됩니다.

근데 실제 터미널 화면에서는 이 문자가 emoji처럼 두 칸으로 그려지는 경우가 많습니다.

서버 계산:  ⏸ = width 1  →  커서 위치 +1
터미널 렌더: ⏸ = width 2  →  실제 표시 +2

이 1칸 차이가 쌓이면:

  • 커서가 엉뚱한 위치로 이동
  • 줄 정렬이 무너짐
  • vim 화면 전체가 깨짐
  • tmux 패널 레이아웃 붕괴
  • progress bar가 줄넘김

단 하나의 문자 때문에 전체 레이아웃이 무너집니다.

왜 서버에서 고칠 수 없나

처음엔 서버 설정을 바꾸면 되지 않을까 생각했습니다. 근데 현실은 다릅니다.

방법결과
glibc 업데이트불가 — Unicode EAW가 Neutral이라 변경 없음
locale 변경무관 — width 계산과 별개
TERM 환경변수 변경무관 — terminfo는 문자 width를 정의하지 않음
LD_PRELOAD로 wcwidth 오버라이드비현실적 — 원격 서버마다 적용 불가

결론: 서버의 wcwidth() 결과는 변경 불가능한 상수입니다.

iTerm2, Kitty, WezTerm, Alacritty 같은 메이저 터미널들도 이 문제를 완전히 해결하지 못했습니다. 대부분은 "emoji를 wide로 렌더링하고, 약간의 깨짐은 감수한다"는 전략을 씁니다.

VibeShell은 다른 길을 택했습니다.

Unicode의 해답: Variation Selector

사실 Unicode 자체에 이미 해결책이 있습니다.

Variation Selector-16 (VS16, U+FE0F)

이 코드포인트를 문자 뒤에 붙이면 emoji presentation을 명시적으로 요청합니다.

⏸       →  text presentation  (width = 1)
⏸️  →  emoji presentation (width = 2)
(⏸ + U+FE0F)

이걸 이용하면 width를 사용자가 명시적으로 선택할 수 있습니다.

VibeShell의 Width 정책

VibeShell의 렌더링 정책은 단순합니다.

Base character     →  width = 1  (서버 wcwidth 결과와 동일)
Character + VS16   →  width = 2  (emoji presentation)

렌더링 알고리즘:

if (character + U+FE0F):
    treat as emoji → width = 2
else:
    use wcwidth(base_char) → width = 1

이렇게 하면 서버의 커서 계산과 터미널 렌더링이 항상 일치합니다.

실제 width 정책 예시:

SequenceWidth설명
1서버 wcwidth 기준
1서버 wcwidth 기준
1서버 wcwidth 기준
⏸️2VS16 — emoji presentation
⏹️2VS16 — emoji presentation
⏺️2VS16 — emoji presentation

내부 구조

VibeShell 터미널 코어는 이렇게 구성됩니다.

terminal-core
 ├─ unicode
 │   ├─ wcwidth-table       ← 서버 wcwidth() 결과 매핑
 │   ├─ emoji-sequence      ← emoji 시퀀스 감지
 │   └─ variation-selector  ← VS16 처리
 └─ renderer

입력 스트림이 들어오면:

input stream
    ↓
grapheme parser         ← grapheme cluster 단위로 분리
    ↓
variation selector 감지  ← U+FE0F 포함 여부 확인
    ↓
width 계산              ← VS16 있으면 2, 없으면 wcwidth()
    ↓
grid placement          ← 계산된 width로 셀에 배치

정리

이 문제를 파고들면서 터미널 개발이 왜 어려운지 실감했습니다. 문자 하나의 width가 1이냐 2이냐 하는 문제가, vim 전체 화면이 깨지느냐 아니냐를 결정합니다.

VibeShell의 Unicode width 정책을 정리하면:

  • Base characterwidth = 1 (서버 호환)
  • Character + VS16width = 2 (emoji presentation)
  • 서버 호환성 → 최우선

SSH 환경에서 bash, zsh, vim, tmux가 모두 안정적으로 동작하는 게 목표입니다. emoji를 wide로 쓰고 싶으면 VS16을 붙이면 되고, 그렇지 않으면 서버와 동일한 width로 안전하게 동작합니다.

깨진 커서로 고통받던 경험이 결국 이 정책을 만들었습니다. 직접 불편을 겪어야 제대로 된 해결책이 나오는 것 같아요.


더 자세한 내용은 VibeShell 공식 문서에서 확인할 수 있습니다.