Skip to content
Published on

TUI 르네상스 2026 — Ratatui·Bubble Tea·Textual·Ink로 다시 아름다워진 터미널 (심층 가이드)

Authors

프롤로그 — 검은 화면 위의 르네상스

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)

ModelUpdateView. 상태는 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를 얹는다.

import "github.com/charmbracelet/lipgloss"

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>.

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

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 (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

render(<Counter />);

React Hooks가 그대로 작동한다. useState, useEffect, useReducer, 커스텀 훅까지. 라우팅이 필요하면 ink-router, 폼이 필요하면 ink-form, 입력은 ink-text-input, 멀티-셀렉트는 ink-multi-select.

Flexbox layout

브라우저처럼 Flexbox로 배치한다.

<Box flexDirection="column" width="100%">
  <Box height={3} borderStyle="single">
    <Text>Header</Text>
  </Box>
  <Box flexGrow={1}>
    <Text>Body</Text>
  </Box>
</Box>

flexDirection, flexGrow, justifyContent, alignItems, padding, margin, width, height 모두 친숙한 CSS 이름 그대로.

Suspense·streaming·async

React 18+ 패턴(Suspense, transitions)이 Ink 5에서 작동한다. AI 코딩 도구가 토큰 스트리밍 응답을 보여주는 데 적합한 이유.

누가 쓰는가

  • Claude Code — Anthropic의 코딩 에이전트
  • GitHub Copilot CLIgh 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

import (
    "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)

import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';

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 (
    <Box borderStyle="round" padding={1}>
      <Text>count: {count}</Text>
    </Box>
  );
};

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·분석)Textualasync·Python 라이브러리·풍부한 위젯
AI 코딩 도구·CLI 마법사Ink스트리밍 UI·React 친화
새 Go 서비스의 관리 콘솔Bubble Tea같은 Go 코드베이스 안에서
Rust 서버의 관리자 TUIRatatui같은 바이너리 안에서
빠른 프로토타입Textual가장 적은 보일러플레이트
SSH로 노출할 멀티유저 TUIBubble Tea + wishCharm의 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