// blog post
터미널 화면이 깨지는 이유 — VibeShell이 Unicode width를 다루는 방법
⏸ 버튼 하나가 vim을 망가뜨렸습니다
VibeShell 개발 초기, 터미널 UI 테스트를 하던 중 이상한 걸 발견했습니다.
재생/일시정지 컨트롤에 ⏸ ⏹ ⏺ 같은 심볼을 쓰고 싶었거든요. 근데 이걸 화면에 넣는 순간, vim에서 커서가 이상한 위치로 가고 줄 정렬이 완전히 무너지는 겁니다. tmux에서도 마찬가지였고요.
처음엔 폰트 문제인 줄 알았습니다. 그다음엔 locale 설정 문제인가 싶었고요. 한참 삽질한 끝에 알게 된 진짜 이유는 훨씬 근본적인 거였습니다.
바로 Unicode width 불일치 문제입니다.
터미널은 셀 기반입니다
터미널 에뮬레이터는 문자를 그리드 셀(grid cell) 단위로 배치합니다. 문자 하나가 차지하는 셀 수가 곧 그 문자의 width입니다.
| Width | 의미 | 예시 |
|---|---|---|
| 0 | Combining character | 결합 문자 |
| 1 | Narrow (일반) | A, 가, 1 |
| 2 | Wide (전각) | 한, 日, A |
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 정책 예시:
| Sequence | Width | 설명 |
|---|---|---|
⏸ | 1 | 서버 wcwidth 기준 |
⏹ | 1 | 서버 wcwidth 기준 |
⏺ | 1 | 서버 wcwidth 기준 |
⏸️ | 2 | VS16 — emoji presentation |
⏹️ | 2 | VS16 — emoji presentation |
⏺️ | 2 | VS16 — 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 character →
width = 1(서버 호환) - Character + VS16 →
width = 2(emoji presentation) - 서버 호환성 → 최우선
SSH 환경에서 bash, zsh, vim, tmux가 모두 안정적으로 동작하는 게 목표입니다. emoji를 wide로 쓰고 싶으면 VS16을 붙이면 되고, 그렇지 않으면 서버와 동일한 width로 안전하게 동작합니다.
깨진 커서로 고통받던 경험이 결국 이 정책을 만들었습니다. 직접 불편을 겪어야 제대로 된 해결책이 나오는 것 같아요.
더 자세한 내용은 VibeShell 공식 문서에서 확인할 수 있습니다.