프롤로그 — 검은 화면 위의 르네상스
2010년대 중반, 누군가에게 "터미널 UI를 만든다"고 하면 두 가지 반응이 돌아왔다. 첫째, "왜?" 둘째, "ncurses?" 그리고 대화는 거기서 끝났다.
2026년의 풍경은 완전히 다르다.
- `lazygit`을 처음 본 사람이 "이거 Electron 앱이에요?"라고 묻는다. 아니다. Go로 짠 TUI다.
- `k9s`는 쿠버네티스 운영자의 표준 도구가 되었다. 웹 대시보드보다 빠르다.
- `atuin`은 셸 히스토리를 SQLite로 저장하고, 동기화하고, TUI로 검색한다. 한 번 쓰면 `Ctrl+R`로 못 돌아간다.
- `gum`은 셸 스크립트에 둥근 모서리 입력 폼을 입힌다. `gum choose`, `gum confirm`만으로 install.sh가 예뻐진다.
- `posting`은 Postman을 터미널로 옮겨놓았다. JSON 트리, 마크다운 응답, 변수 환경까지.
- `slumber`는 HTTP 클라이언트를 TOML 설정 파일 + 키바인딩으로 다시 짰다.
이게 가능했던 건 네 개의 toolkit이 거의 같은 시기에 성숙했기 때문이다.
- **Ratatui** (Rust, immediate-mode) — `tui-rs`에서 포크되어 2023년 정식 출범, 2026년 v0.31. lazygit-ish 도구의 Rust 진영을 사실상 점령.
- **Bubble Tea** (Go, Elm Architecture) — Charm사가 만든 The Elm Architecture(TEA)의 Go 포팅. lazygit, gh, gum, soft-serve의 기반.
- **Textual** (Python, declarative + CSS-like) — Will McGugan(Rich 저자)이 만든 Python 전용 toolkit. 2026년 5.x, posting의 기반.
- **Ink** (Node.js, JSX/React) — Vadim Demedes가 만든 React for CLIs. Claude Code, GitHub Copilot CLI, Gatsby CLI의 기반.
여기에 C++의 FTXUI, Rust의 cursive, C의 notcurses, Node의 blessed/blessed-contrib까지 더하면 "터미널은 죽은 platform이 아니라 부활한 platform"이라는 게 분명해진다.
이 글은 그 르네상스를 정리한다. 왜 다시 터미널인지(1장), 어떤 demo 앱들이 흐름을 바꿨는지(2장), 네 toolkit의 철학 차이(3~6장), 같은 카운터 앱을 네 언어로 짜는 비교(7장), 30분 첫 TUI 경로(8장), 정직한 trade-off(9장).
1장 · 왜 TUI가 다시 떴는가 — 6가지 이유
먼저 정직하게. TUI는 "감성"이나 "레트로"가 아니다. 그 환상으로는 lazygit이 GitHub Desktop을 대체하지 못한다. 진짜 이유는 따로 있다.
**1. SSH·컨테이너·CI 환경이 일상이 되었다.** 클라우드·쿠버네티스·원격 개발이 보편화되면서, 우리는 자주 "GUI가 없는 곳"에서 일한다. SSH 한 줄로 k9s를 띄울 수 있다는 건 X11 forwarding으로 GUI 앱 띄우는 것과 비교가 안 된다.
**2. 키보드 우선 워크플로의 재발견.** Vim·Neovim·Emacs 사용자에게 마우스 왕복은 인지 비용이다. lazygit이 `Ctrl+e`로 fetch, `s`로 stage, `c`로 commit으로 끝나는 흐름은 SourceTree보다 빠르다. 측정 가능하게.
**3. 터미널 에뮬레이터가 강해졌다.** Alacritty·WezTerm·Kitty·Ghostty가 GPU 가속·트루컬러·이미지 프로토콜·undercurl·합자(ligature)·마우스 이벤트까지 지원한다. 1990년대 ncurses 시절의 256색 한계는 옛말이다.
**4. Unicode와 폰트가 따라왔다.** Nerd Fonts(아이콘), Powerline 글리프, 박스 그리기 문자, 둥근 모서리(`╭─╮`)가 보편화되면서 "터미널은 텍스트만 그릴 수 있다"는 한계가 사실상 사라졌다.
**5. AI 코딩 도구의 기본 UI가 TUI로 갔다.** Claude Code, Codex CLI, GitHub Copilot CLI, Aider — 코딩 에이전트의 대표주자들이 모두 터미널 안에 산다. 채팅 UI가 웹이 아닌 곳에 있다는 건 이상한 일이 아니라 정상이 되었다.
**6. Charm·Ratatui라는 "마케팅 잘 하는 라이브러리"가 생겼다.** Charm사는 lipgloss·glow·gum·soft-serve·VHS 같은 도구로 "TUI도 예쁠 수 있다"는 걸 데모로 보여줬다. Ratatui는 책(2024년 Manning)·튜토리얼·전시(`ratatui.rs`)로 Rust TUI 신을 견인했다. 도구는 항상 있었다. 마케팅이 없었을 뿐.
기억할 한 줄: **"TUI는 GUI보다 못한 게 아니라, 다른 제약 위에서 다른 최적해를 찾는 형식이다."**
2장 · TUI를 다시 멋지게 만든 demo 앱들
도구의 성공은 도구 자신이 아니라 그 도구로 만들어진 결과물이 결정한다. 2020년대 TUI 부흥을 끌고 온 대표주자들을 본다.
lazygit (Go · Bubble Tea 이전 세대의 jesseduffield/gocui)
Git의 모든 일상 작업을 키 한두 번에 끝낸다. 패널 전환은 1·2·3·4, stage는 `s`, commit은 `c`, push는 `P`. 깃 GUI의 사실상 대안. 2026년 5월 기준 GitHub star 약 53k.
gh (Go · Bubble Tea 부분 사용)
GitHub의 공식 CLI. `gh pr create`, `gh pr view`, `gh repo clone` 같은 명령어가 익숙하지만, `gh pr checkout`이나 인터랙티브 모드에서는 Bubble Tea 패턴이 보인다.
gum (Go · Bubble Tea + Lipgloss)
Charm사의 "셸 스크립트용 위젯 라이브러리." `gum input`, `gum confirm`, `gum choose`, `gum spin` 같은 명령어를 bash에서 직접 호출한다. install 스크립트·dotfiles bootstrap에 사실상 표준.
k9s (Go · tcell + tview)
쿠버네티스 클러스터를 TUI로 운영. 네임스페이스 전환, pod logs, port-forward, edit deployment까지 키보드만으로. 운영팀의 표준 도구.
atuin (Rust · Ratatui)
Magical shell history. SQLite에 명령어 기록을 저장하고, 디바이스 간 동기화하고, `Ctrl+R`로 fuzzy 검색한다. 한 번 쓰면 기본 `Ctrl+R`로 못 돌아간다.
posting (Python · Textual)
"터미널의 Postman." HTTP 요청·환경 변수·컬렉션·JSON 트리 보기·마크다운 응답 렌더링까지. Darren Burns가 Textual의 limit를 보여주려고 만든 demo 앱이 정식 도구로 성장.
slumber (Rust · Ratatui)
YAML 설정 + 키바인딩으로 짠 HTTP 클라이언트. Postman 컬렉션을 텍스트로 다루고 싶은 사람을 위한 도구.
gitui (Rust · Ratatui)
lazygit의 Rust 버전. 더 빠르고 메모리 덜 먹는다는 게 셀링 포인트. 2026년 v0.27.
btop (C++ · 자체 렌더링)
`top`/`htop`의 후계자. 둥근 모서리 박스, 그래프, 마우스 지원. C++로 짰지만 UI 디자인이 TUI 르네상스의 시각적 기준을 만들었다.
glow (Go · Bubble Tea)
마크다운 뷰어. `glow README.md`로 터미널에서 마크다운을 예쁘게 본다. Charm 생태계의 출발점 중 하나.
vhs (Go · Bubble Tea + Headless 터미널)
`.tape` 스크립트로 터미널 GIF/MP4를 자동 생성. README의 데모 GIF가 일관되게 보이는 비결.
nushell + tabiew + others
CSV·Parquet 뷰어 `tabiew` (Rust/Ratatui), 파일 매니저 `yazi` (Rust/Ratatui), 검색 도구 `television` (Rust/Ratatui) 등 Ratatui 기반 도구가 폭증.
여기서 한 가지 패턴이 보인다. **Go = Bubble Tea, Rust = Ratatui, Python = Textual, Node = Ink.** 각 언어의 사실상 표준 toolkit이 정해졌다.
3장 · Ratatui — Rust의 immediate-mode TUI
출발점
Florian Dehau가 2016년에 시작한 `tui-rs`가 2023년 메인테이너 부재로 멈췄고, 커뮤니티가 `ratatui`로 포크하면서 정식 출범했다. 2026년 5월 기준 v0.31.x, GitHub star 약 14k, 의존하는 crate 수가 1,000개를 넘어섰다. Rust 생태계에서 atuin·gitui·yazi·bottom·television 같은 사실상 모든 메이저 TUI가 Ratatui 위에 산다.
핵심 철학 — Immediate-mode
대부분 GUI 프레임워크는 **retained-mode**다. 위젯 트리를 만들고, 상태가 바뀌면 트리를 업데이트하고, 프레임워크가 다시 그린다. Ratatui는 **immediate-mode**다. 매 프레임 사용자가 "이번 프레임의 UI는 이거다"를 직접 그린다.
// 매 프레임 호출되는 draw 클로저
terminal.draw(|frame| {
let area = frame.area();
let block = Block::default().borders(Borders::ALL).title("Counter");
let text = Paragraph::new(format!("count: {}", count)).block(block);
frame.render_widget(text, area);
})?;
이게 왜 좋은가? 상태 동기화 버그가 없다. 트리에 stale node가 남을 일이 없다. 게임 엔진(`bevy_egui`, `egui`)이 즐겨 쓰는 패턴이 그대로 터미널에 왔다.
대신 비용도 있다. 매 프레임 다 그리는 책임은 개발자에게 있다. 60fps로 돌리면 60번 draw 클로저가 호출된다. 비싼 계산은 직접 캐시해야 한다. (실제로는 input event가 있을 때만 다시 그리는 패턴이 일반적.)
위젯과 layout
use ratatui::{
layout::{Constraint, Direction, Layout},
widgets::{Block, Borders, List, ListItem, Paragraph},
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(frame.area());
frame.render_widget(sidebar, chunks[0]);
frame.render_widget(main_area, chunks[1]);
기본 위젯: `Block`, `Paragraph`, `List`, `Table`, `Tabs`, `Gauge`, `Sparkline`, `BarChart`, `Chart`, `Canvas`. `Canvas`로는 점·선·심지어 세계지도까지 그릴 수 있다.
이벤트 — crossterm
Ratatui 자체는 그리기만 하고, 이벤트는 `crossterm`(또는 `termion`)이 담당한다.
use crossterm::event::{self, Event, KeyCode};
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Up => count += 1,
KeyCode::Down => count -= 1,
_ => {}
}
}
}
누가 쓰는가
- **atuin** — 셸 히스토리
- **gitui** — Git TUI
- **yazi** — 파일 매니저
- **bottom** — 시스템 모니터(htop 대안)
- **television** — fuzzy finder, fzf의 후계자 후보
- **tabiew** — CSV/Parquet 뷰어
- **slumber** — HTTP 클라이언트
강점·약점
- 강점 — 성능(컴파일된 Rust), 메모리 효율, 안정성, 풍부한 위젯, 활발한 커뮤니티, 책(Manning 2024)·`ratatui.rs` 공식 사이트.
- 약점 — immediate-mode 학습 곡선, 보일러플레이트(crossterm 초기화·alternate screen·raw mode), 디버그 어렵다(터미널 그 자체가 stdout이라 println 디버그 불가).
4장 · Bubble Tea — Go의 The Elm Architecture
출발점
Charm사(Toby Padilla·Christian Rocha)가 2020년에 발표. The Elm Architecture(TEA)를 Go에 직접 옮겼다. 2026년 5월 기준 v1.3, GitHub star 약 32k. lazygit이 기반을 옮기는 중, gh CLI의 일부, gum 전체, glow 전체, soft-serve 전체가 Bubble Tea 위에 있다.
핵심 철학 — Elm Architecture(MVU)
`Model` → `Update` → `View`. 상태는 `Model`에 있다. 메시지(이벤트)가 들어오면 `Update(msg, model) -> (newModel, cmd)`. `View(model) -> string`이 화면을 그린다. 부수효과는 `Cmd`(고차 함수)로 격리한다.
type Model struct {
count int
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up":
m.count++
case "down":
m.count--
}
}
return m, nil
}
func (m Model) View() string {
return fmt.Sprintf("count: %d\n\nq to quit", m.count)
}
이 함수형 패턴이 주는 이점은 immediate-mode와 다르다. 상태 변화의 **흐름이 한 곳**에 있다. `Update`만 보면 무엇이 가능한 변화인지 안다. 테스트하기 쉽다(순수 함수).
Lipgloss — 스타일링
Bubble Tea 단독으로는 색·박스·정렬만 그린다. 그 위에 **Lipgloss**가 CSS-like 스타일 API를 얹는다.
var titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2)
fmt.Println(titleStyle.Render("Hello, TUI"))
색·여백·박스·정렬·정렬·줄임·줄 바꿈 모두 메서드 체인으로. 둥근 모서리 박스도 한 줄.
Bubbles — 위젯 라이브러리
Bubble Tea 코어는 의도적으로 작다. 위젯은 별도 라이브러리 `bubbles`에서. textinput, textarea, list, table, viewport, spinner, progress, paginator, key 등.
Wish & Soft Serve — TUI를 SSH로
Charm의 또 다른 작품. `wish`는 SSH 서버 라이브러리. Bubble Tea 앱을 SSH로 노출시키면, 사용자는 `ssh tui.example.com`만으로 접속한다. install 필요 없음. `soft-serve`(Charm의 셀프호스트 Git 서버)가 이 패턴의 대표 예.
누가 쓰는가
- **gum** — 셸 스크립트 위젯
- **glow** — 마크다운 뷰어
- **vhs** — 터미널 녹화
- **soft-serve** — Git 서버 TUI
- **bubbletea-app-template** 기반 신생 앱 다수
- **lazygit** 일부 — 점진 마이그레이션
강점·약점
- 강점 — 깔끔한 아키텍처, 함수형이라 테스트 쉬움, Lipgloss로 시각적 폴리시 높음, Charm 생태계의 거대한 도구 모음.
- 약점 — Go 인터페이스의 매번 캐스팅 보일러플레이트(`tea.Msg` 타입 스위치), 매우 큰 모델에서는 immutable update의 비용, ad-hoc 비동기는 `tea.Cmd`로 감싸야 함.
5장 · Textual — Python의 선언적 TUI
출발점
Will McGugan(Rich 라이브러리 저자)이 2021년에 시작. Rich가 "예쁜 print"라면 Textual은 "예쁜 앱". 2026년 5월 기준 v5.x, GitHub star 약 28k. posting의 성공이 결정타였다.
핵심 철학 — 웹스러운 선언
CSS-like 스타일링(`.tcss`), 위젯 트리, 이벤트 핸들러, async-first. 한마디로 "터미널에서 도는 React 비슷한 것" — 다만 명시적 가상 DOM은 없다.
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class CounterApp(App):
BINDINGS = [
("up", "increment", "Increment"),
("down", "decrement", "Decrement"),
("q", "quit", "Quit"),
]
def __init__(self):
super().__init__()
self.count = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static(f"count: {self.count}", id="counter")
yield Footer()
def action_increment(self):
self.count += 1
self.query_one("#counter", Static).update(f"count: {self.count}")
def action_decrement(self):
self.count -= 1
self.query_one("#counter", Static).update(f"count: {self.count}")
if __name__ == "__main__":
CounterApp().run()
여기서 보이는 것 — `compose`로 트리 선언, `BINDINGS`로 단축키 매핑, `action_*` 메서드가 자동으로 호출됨, `query_one`으로 CSS-like 셀렉터로 위젯 조회.
CSS-like 스타일 — TCSS
별도 `.tcss` 파일에 스타일을 분리.
Static {
background: $boost;
padding: 1 2;
border: round $primary;
text-align: center;
}
#counter {
width: 30;
height: 5;
margin: 1;
}
색은 디자인 시스템 변수(`$primary`, `$boost`, `$success` 등)를 그대로 쓸 수 있다.
위젯과 풍부함
Static, Label, Button, Input, TextArea, Select, Switch, Checkbox, RadioSet, ListView, DataTable, Tree, Tabs, ContentSwitcher, ProgressBar, Sparkline, LoadingIndicator, Pretty, Markdown, RichLog, Log, Header, Footer, Placeholder. 정말 많다.
Async-first
async def on_button_pressed(self, event: Button.Pressed) -> None:
async with httpx.AsyncClient() as client:
result = await client.get("https://api.example.com")
self.query_one("#result", Static).update(result.text)
Python의 `asyncio`를 일급으로 받아들여서 HTTP·DB·파일 IO가 자연스럽다. posting이 이걸 활용한 대표 예.
Textual Web — 같은 코드, 웹으로
`textual serve`로 같은 앱을 브라우저에서 띄울 수 있다. SSH·로컬·웹 세 가지 배포 경로. 2026년 현재 베타지만 작동한다.
누가 쓰는가
- **posting** — 터미널 HTTP 클라이언트(Postman 대안)
- **harlequin** — DuckDB·Postgres SQL IDE
- **textual-keymaps** — 시각적 키맵
- **frogmouth** — 마크다운 뷰어
- **memray** — 메모리 프로파일러(부분)
- 사내 데이터 도구 다수
강점·약점
- 강점 — Python 친화적, async 자연스러움, CSS 분리로 디자이너·개발자 분업 쉬움, Textual Web으로 웹 배포까지, 풍부한 위젯, Rich와의 시너지(마크다운·코드 하이라이트).
- 약점 — Python 시작 시간(인터프리터 + import), 메모리 사용량(Rust/Go 대비 큼), GIL의 한계, async 학습 곡선.
6장 · Ink — Node.js + JSX/React for CLIs
출발점
Vadim Demedes가 2017년에 시작. "React 컴포넌트로 CLI를 짜고 싶다"는 단순한 아이디어에서 출발. 2026년 5월 기준 v5.x, GitHub star 약 28k. Claude Code, GitHub Copilot CLI, Gatsby CLI, Prisma CLI, AWS Amplify CLI 모두 Ink로 짰다.
핵심 철학 — React 컴포넌트가 텍스트를 렌더
브라우저에서 React가 DOM을 렌더하듯, Ink는 React를 터미널 텍스트로 렌더한다. `<div>`/`<span>` 대신 `<Box>`/`<Text>`.
const Counter = () => {
const [count, setCount] = useState(0);
useInput((input, key) => {
if (key.upArrow) setCount(c => c + 1);
if (key.downArrow) setCount(c => c - 1);
if (input === 'q') process.exit(0);
});
return (
);
};
render(<Counter />);
React Hooks가 그대로 작동한다. `useState`, `useEffect`, `useReducer`, 커스텀 훅까지. 라우팅이 필요하면 `ink-router`, 폼이 필요하면 `ink-form`, 입력은 `ink-text-input`, 멀티-셀렉트는 `ink-multi-select`.
Flexbox layout
브라우저처럼 Flexbox로 배치한다.
`flexDirection`, `flexGrow`, `justifyContent`, `alignItems`, `padding`, `margin`, `width`, `height` 모두 친숙한 CSS 이름 그대로.
Suspense·streaming·async
React 18+ 패턴(Suspense, transitions)이 Ink 5에서 작동한다. AI 코딩 도구가 토큰 스트리밍 응답을 보여주는 데 적합한 이유.
누가 쓰는가
- **Claude Code** — Anthropic의 코딩 에이전트
- **GitHub Copilot CLI** — `gh copilot`
- **Gatsby CLI**
- **Prisma CLI**
- **AWS Amplify CLI**
- **Twilio CLI**
- **terkelg/prompts**의 React 버전들
강점·약점
- 강점 — React 지식 그대로 전이, npm 생태계, JSX의 표현력, AI/스트리밍 UI 자연스러움, 디자이너 친화적.
- 약점 — Node.js 시작 비용(node + V8 + 의존성 로드), 메모리(가장 무거움), `Text` 내부에 `Box` 못 넣는 등 텍스트-노드 트리의 제약, 큰 트리에서 리렌더 비용.
7장 · 같은 카운터 앱, 네 언어로 — 직접 비교
가장 간단한 앱: "↑/↓로 카운트 증감, q로 종료." 네 toolkit이 어떻게 다른지 한 화면에 보자.
Ratatui (Rust)
use crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{
backend::CrosstermBackend,
widgets::{Block, Borders, Paragraph},
Terminal,
};
use std::io;
fn main() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut count: i32 = 0;
loop {
terminal.draw(|f| {
let block = Block::default().borders(Borders::ALL).title("Counter");
let para = Paragraph::new(format!("count: {}", count)).block(block);
f.render_widget(para, f.area());
})?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Up => count += 1,
KeyCode::Down => count -= 1,
_ => {}
}
}
}
}
disable_raw_mode()?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
Ok(())
}
Bubble Tea (Go)
package main
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct{ count int }
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up":
m.count++
case "down":
m.count--
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf("count: %d\n\n(q to quit)\n", m.count)
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Textual (Python)
from textual.app import App, ComposeResult
from textual.widgets import Static
class CounterApp(App):
BINDINGS = [
("up", "inc", "Up"),
("down", "dec", "Down"),
("q", "quit", "Quit"),
]
count = 0
def compose(self) -> ComposeResult:
yield Static(f"count: {self.count}", id="c")
def render_count(self):
self.query_one("#c", Static).update(f"count: {self.count}")
def action_inc(self):
self.count += 1
self.render_count()
def action_dec(self):
self.count -= 1
self.render_count()
if __name__ == "__main__":
CounterApp().run()
Ink (TSX)
const Counter = () => {
const [count, setCount] = useState(0);
useInput((input, key) => {
if (key.upArrow) setCount(c => c + 1);
if (key.downArrow) setCount(c => c - 1);
if (input === 'q') process.exit(0);
});
return (
);
};
render(<Counter />);
코드 라인 수 비교
- Ratatui: 약 35줄(터미널 raw 모드 셋업 포함). 가장 길지만 가장 명시적.
- Bubble Tea: 약 30줄. MVU 패턴이 깔끔하지만 타입 스위치가 보일러플레이트.
- Textual: 약 20줄. 가장 짧고 가장 읽기 쉽다.
- Ink: 약 15줄. JSX 친화적이라면 가장 짧다.
라인 수가 곧 우열은 아니다. 가장 짧은 코드(Ink)는 가장 무거운 런타임(Node + V8)을 깔고 있고, 가장 긴 코드(Ratatui)는 가장 가벼운 단일 정적 바이너리를 만든다.
8장 · 첫 TUI 30분 — 실전 경로
Path A: Ratatui로 시작 (성능·배포 우선)
1. 새 프로젝트
cargo new my_tui && cd my_tui
2. 의존성 추가
cargo add ratatui crossterm
3. src/main.rs에 위 카운터 예제 붙여넣기
cargo run
배포는 `cargo build --release` 한 줄로 단일 정적 바이너리. 의존성 zero.
Path B: Bubble Tea로 시작 (Go 생태계 친화)
mkdir my_tui && cd my_tui
go mod init my_tui
go get github.com/charmbracelet/bubbletea
main.go에 위 예제 붙여넣기
go run main.go
크로스 플랫폼 빌드는 `GOOS=linux GOARCH=amd64 go build` 식으로 간단.
Path C: Textual로 시작 (Python 친화)
pip install textual
python counter.py
`textual run --dev counter.py`로 hot reload + dev console까지 켜진다.
Path D: Ink로 시작 (React 친화)
npx create-ink-app my-cli --typescript
cd my-cli
npm run dev
스캐폴드가 텍스트 입력·테스트·빌드까지 다 해준다.
그다음에 할 일 — 공통 체크리스트
- **alt screen 모드** 진입/이탈을 정확히 처리(Ratatui는 명시적, 나머지는 자동).
- **raw mode** 진입 시 Ctrl+C 처리도 직접(또는 toolkit이 처리).
- **resize 이벤트** 받으면 다시 그리기.
- **mouse 이벤트** 옵트인.
- **truecolor** 지원되는지 `$COLORTERM` 확인.
- **Unicode 너비** 처리(이모지·CJK는 2칸 차지). `unicode-width` 크레이트나 `wcwidth` 사용.
- **로그 분리** — println으로 디버그 못 함. 별도 로그 파일에 쓰거나 dev console로.
9장 · 정직한 trade-off — TUI가 안 어울리는 경우
TUI가 모든 걸 해결하지는 않는다. 다음 경우엔 다른 도구가 낫다.
1. 차트·그래프 무거운 대시보드
박스 그리기 문자로 sparkline은 그릴 수 있지만, 산점도·히트맵·3D 차트는 한계가 명확하다. 데이터 시각화가 중심이면 웹/Streamlit·Grafana가 낫다.
2. 이미지·동영상
iTerm2·Kitty·WezTerm의 inline image 프로토콜이 있지만 사용자 환경 의존성이 크다. tmux 안에서 깨지는 경우가 많다.
3. 마우스 중심 워크플로
복잡한 드래그 앤 드롭, 시각적 편집(다이어그램·이미지·캔버스). 가능은 하지만 사람들이 좋아하지 않는다.
4. 비개발자 대상
TUI는 사용자에게 키 단축키와 모드 개념을 요구한다. 일반 사용자에게는 학습 곡선이 너무 가파르다.
5. 접근성이 critical한 경우
스크린리더는 GUI/웹에 최적화되어 있고, TUI에 대한 지원은 부분적이다. 시각장애 사용자 비율이 큰 도구는 GUI/웹이 낫다.
6. 매우 큰 데이터를 한 화면에
터미널의 한 줄당 문자 수, 화면당 줄 수의 제한이 있다. 가상화 스크롤·페이지네이션이 필요한데, 웹에서 더 자연스럽다.
기억할 한 줄: **"TUI는 키보드 우선 워크플로, 원격 접근, 가벼움이 가치인 곳에서 빛난다. 시각화 중심·일반 사용자·접근성 critical인 곳에서는 GUI/웹이 낫다."**
10장 · 도구 선택 매트릭스
상황별로 어떤 toolkit을 고를지 한 표로.
| 상황 | 추천 | 이유 |
|---|---|---|
| 시스템 도구(파일/Git/모니터링) | Ratatui | 성능·배포·단일 바이너리 |
| 셸 스크립트 위젯 | Bubble Tea(gum) | 즉시 사용 가능, 표준 도구 |
| 사내 데이터 도구(SQL·HTTP·분석) | Textual | async·Python 라이브러리·풍부한 위젯 |
| AI 코딩 도구·CLI 마법사 | Ink | 스트리밍 UI·React 친화 |
| 새 Go 서비스의 관리 콘솔 | Bubble Tea | 같은 Go 코드베이스 안에서 |
| Rust 서버의 관리자 TUI | Ratatui | 같은 바이너리 안에서 |
| 빠른 프로토타입 | Textual | 가장 적은 보일러플레이트 |
| SSH로 노출할 멀티유저 TUI | Bubble Tea + wish | Charm의 SSH 라이브러리 |
| 클러스터 운영(k8s 등) | Bubble Tea or Ratatui | 키 단축키·즉시 반응 |
에필로그 — 터미널은 죽지 않았다, 부활했다
긴 글이었다. 정리한다.
한 줄 요약
**TUI 르네상스의 본질은 '레트로 감성'이 아니라 '키보드 우선·원격 친화·가벼운 배포'라는 세 가지 가치의 재발견이고, Ratatui·Bubble Tea·Textual·Ink가 각자 다른 언어 생태계에서 그 가치를 구현한다.**
30초 체크리스트
1. 새 TUI를 시작하기 전에, 정말 TUI가 맞는 형식인지 9장의 anti-case와 대조했는가?
2. 대상 사용자가 키보드 중심 워크플로에 익숙한가?
3. 언어 생태계·팀 역량에 맞는 toolkit을 골랐는가(Rust→Ratatui, Go→Bubble Tea, Python→Textual, Node→Ink)?
4. 단일 바이너리 배포가 중요한가? Rust/Go가 맞다.
5. async가 중요한가? Textual·Ink가 자연스럽다.
6. raw mode·alt screen·resize·Unicode 너비를 처리할 준비가 됐나?
7. 터미널 디버그 어려움(println 못 씀)을 위한 별도 로그 파일/dev console을 준비했나?
8. 마우스 옵션을 켜야 한다면, 어떤 사용자가 거부할지 생각했나?
9. truecolor를 가정해도 되는가? `$COLORTERM`을 확인하는가?
10. GIF/스크린샷용 vhs 같은 도구로 데모를 만들어 마케팅까지 챙겼나?
안티패턴 10가지
1. 매 키 입력마다 무거운 계산을 동기로 돌려서 UI가 멈춤.
2. immediate-mode(Ratatui)에서 매 프레임 비싼 IO를 다시 함.
3. Ink의 `Text` 안에 `Box`를 넣어서 런타임 에러.
4. Bubble Tea의 `Update`에서 부수효과를 `Cmd`로 격리하지 않고 직접 실행.
5. Textual의 `compose`에서 동기 IO를 길게 막아 시작 시간 폭주.
6. raw mode를 활성화한 채 panic으로 종료해 터미널 상태가 깨짐.
7. 터미널 resize 이벤트를 무시해서 레이아웃 깨짐.
8. CJK·이모지 너비 처리를 안 해서 위젯 경계가 어긋남.
9. `println`/`fmt.Println`/`print()`을 디버그로 써서 화면을 망가뜨림.
10. 사용자가 다양한 터미널을 쓴다는 사실을 잊고 `$TERM`/`$COLORTERM` 미체크.
다음 글 예고
다음 글 후보: **Charm 생태계 정복기 — Bubble Tea·Lipgloss·Bubbles·Wish·Glow·VHS 한 호흡으로**, **Ratatui로 Git TUI 만들기 — lazygit의 Rust 클론을 한 주 만에**, **Textual + SQLite로 사내 데이터 도구 — Python의 강점을 TUI로**.
> "GUI는 마우스의 시대를 정의했고, TUI는 키보드의 시대를 다시 가져왔다. 둘은 적이 아니라, 같은 작업에 대한 다른 답이다."
— TUI 르네상스 2026, 끝.
참고 / References
- [Ratatui 공식 사이트](https://ratatui.rs/)
- [Ratatui GitHub](https://github.com/ratatui/ratatui)
- [Ratatui Book (Manning)](https://www.manning.com/books/ratatui-cookbook)
- [Bubble Tea GitHub](https://github.com/charmbracelet/bubbletea)
- [Charm 공식 사이트](https://charm.sh/)
- [Lipgloss GitHub](https://github.com/charmbracelet/lipgloss)
- [Bubbles 위젯 라이브러리](https://github.com/charmbracelet/bubbles)
- [Wish — SSH 서버 라이브러리](https://github.com/charmbracelet/wish)
- [Textual 공식 문서](https://textual.textualize.io/)
- [Textual GitHub](https://github.com/Textualize/textual)
- [Rich 라이브러리](https://github.com/Textualize/rich)
- [Ink 공식 GitHub](https://github.com/vadimdemedes/ink)
- [Ink UI 컴포넌트](https://github.com/vadimdemedes/ink-ui)
- [lazygit GitHub](https://github.com/jesseduffield/lazygit)
- [gh CLI](https://github.com/cli/cli)
- [gum — 셸 스크립트 위젯](https://github.com/charmbracelet/gum)
- [k9s — 쿠버네티스 TUI](https://github.com/derailed/k9s)
- [atuin — 셸 히스토리](https://github.com/atuinsh/atuin)
- [posting — HTTP 클라이언트(Textual)](https://github.com/darrenburns/posting)
- [slumber — HTTP 클라이언트(Ratatui)](https://github.com/LucasPickering/slumber)
- [gitui — Git TUI(Ratatui)](https://github.com/extrawurst/gitui)
- [yazi — 파일 매니저(Ratatui)](https://github.com/sxyazi/yazi)
- [bottom — 시스템 모니터(Ratatui)](https://github.com/ClementTsang/bottom)
- [btop — 시스템 모니터(C++)](https://github.com/aristocratos/btop)
- [glow — 마크다운 뷰어](https://github.com/charmbracelet/glow)
- [vhs — 터미널 녹화](https://github.com/charmbracelet/vhs)
- [crossterm — Rust 터미널 IO](https://github.com/crossterm-rs/crossterm)
- [FTXUI — C++ TUI](https://github.com/ArthurSonzogni/FTXUI)
- [Notcurses — C TUI](https://github.com/dankamongmen/notcurses)
- [blessed-contrib — Node](https://github.com/yaronn/blessed-contrib)
- [harlequin — SQL IDE(Textual)](https://github.com/tconbeer/harlequin)
- [television — fuzzy finder(Ratatui)](https://github.com/alexpasmantier/television)
- [tabiew — CSV/Parquet 뷰어(Ratatui)](https://github.com/shshemi/tabiew)
- [Alacritty](https://github.com/alacritty/alacritty)
- [WezTerm](https://wezfurlong.org/wezterm/)
- [Kitty terminal](https://sw.kovidgoyal.net/kitty/)
- [Ghostty](https://ghostty.org/)
- [The Elm Architecture](https://guide.elm-lang.org/architecture/)
현재 단락 (1/415)
2010년대 중반, 누군가에게 "터미널 UI를 만든다"고 하면 두 가지 반응이 돌아왔다. 첫째, "왜?" 둘째, "ncurses?" 그리고 대화는 거기서 끝났다.