Skip to content
Published on

터미널 UI 개발 가이드 — Ink·OpenTUI·Bubble Tea·ratatui·Textual, 무엇을 언제 쓰는가

공유하기
Authors

들어가며 — shadcn 방식이 터미널에 도착했다

termcn이 GeekNews에 올라왔습니다. 설명은 한 줄로 끝납니다 — 터미널 앱을 위한 shadcn/ui. 복사해서 내 코드베이스에 넣고 마음대로 고치는 리액트 컴포넌트 모음인데, 렌더링 백엔드가 브라우저가 아니라 InkOpenTUI입니다.

이 소식 자체는 작은 뉴스입니다. 하지만 지금 TUI 생태계에서 벌어지는 일을 잘 요약합니다. 웹 UI 패러다임 — 컴포넌트, 플렉스박스 레이아웃, 테마 토큰, 카피-페이스트 배포 — 이 그대로 터미널로 넘어오고 있습니다. OpenTUI는 Zig로 짠 네이티브 코어에 TypeScript 바인딩을 얹고, Yoga 플렉스박스 레이아웃 엔진과 tree-sitter 문법 강조를 내장한 채 React와 Solid 바인딩을 제공합니다. 터미널 앱을 만드는 일이 웹 앱을 만드는 일과 비슷해지고 있다는 뜻입니다.

그런데 여기에는 함정이 있습니다. 브라우저는 자기가 무엇을 지원하는지 알려 주지만, 터미널은 알려 주지 않습니다. 그리고 렌더링 모델이 프레임워크마다 근본적으로 다릅니다. 이 글은 그 두 가지를 중심으로 TUI 개발을 정리합니다. 도구별 소개와 문화적 배경은 TUI 르네상스 2026 편에서 다뤘으니, 여기서는 "무엇을 고르고 무엇을 조심할 것인가"에 집중합니다.

세 가지 렌더링 모델

프레임워크를 언어로 분류하면 선택에 도움이 안 됩니다. 화면을 갱신하는 방식으로 나누면 셋입니다.

Elm 아키텍처 — Bubble Tea가 대표입니다. 앱은 Model(상태), Update(메시지를 받아 새 상태를 반환), View(상태를 문자열로 렌더링) 세 조각입니다. 상태 변경은 오직 메시지를 통해서만 일어나고, Update는 순수 함수입니다. 부수 효과는 Cmd로 표현해 런타임에 넘깁니다.

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" {
            return m, tea.Quit
        }
    case tea.WindowSizeMsg:
        m.width, m.height = msg.Width, msg.Height
    }
    return m, nil
}

장점은 상태 흐름이 한 곳에 모인다는 것입니다. 어떤 키가 무엇을 바꾸는지 Update 함수 하나만 읽으면 됩니다. 단점은 앱이 커지면 Update가 거대한 switch가 되고, 컴포넌트를 중첩할 때 메시지를 자식으로 위임하는 배관 코드가 늘어난다는 것입니다. 참고로 Bubble Tea는 2026년 2월에 v2가 나오면서 첫 파괴적 변경을 겪었고, 렌더러가 교체되며 성능이 크게 개선됐다고 보고됐습니다.

즉시 모드 — ratatui가 대표입니다. 위젯 트리를 보관하지 않습니다. 매 루프마다 Frame에 위젯을 그리고, 라이브러리는 이전 버퍼와 비교해 바뀐 셀만 터미널에 씁니다.

terminal.draw(|frame| {
    let area = frame.area();
    let block = Block::default().title("logs").borders(Borders::ALL);
    frame.render_widget(List::new(items).block(block), area);
})?;

장점은 개념이 극도로 적다는 것입니다. 위젯은 상태를 갖지 않고, "지금 상태를 보고 지금 화면을 그린다"가 전부입니다. 렌더링과 상태 사이의 동기화 버그가 구조적으로 생기지 않습니다. 단점은 이벤트 루프·포커스 관리·스크롤 위치를 전부 직접 만들어야 한다는 것입니다. 자유롭고, 그만큼 손이 많이 갑니다.

리액트식 재조정 — Ink, OpenTUI, 그리고 성격은 다르지만 Textual이 여기 속합니다. 컴포넌트 트리를 만들면 재조정기가 이전 트리와 비교해 바뀐 부분만 다시 그립니다. Ink는 Yoga 플렉스박스로 레이아웃을 계산하고, Textual은 CSS 비슷한 문법으로 스타일을 분리합니다.

장점은 웹 개발자의 지식이 그대로 전이된다는 것입니다. useState, useEffect, 컴포넌트 합성, 리스트 키가 모두 똑같이 동작합니다. Claude Code와 Gemini CLI가 Ink 위에서 돌아간다는 사실이 이 모델의 실전 검증이기도 합니다. 단점은 추상화 층이 두꺼워 성능 문제를 추적하기 어렵고, 리렌더가 실제 터미널 쓰기로 얼마나 이어지는지 직관적으로 알기 힘들다는 것입니다.

프레임워크 지도

프레임워크언어 / 런타임모델레이아웃어디에 맞는가
Bubble TeaGoElm 아키텍처Lip Gloss 스타일 문자열 조합단일 바이너리 배포가 중요한 개발자 도구
ratatuiRust즉시 모드제약 기반 레이아웃 분할성능과 제어가 필요한 전체 화면 앱
TextualPython재조정 + CSS 스타일시트CSS 유사 문법파이썬 생태계, 데이터 도구, 웹 동시 배포
InkNode.js / React재조정Yoga 플렉스박스이미 Node로 만든 CLI에 UI를 얹을 때
OpenTUIZig 코어 + TS 바인딩재조정(React·Solid)Yoga 플렉스박스성능이 필요한 TypeScript TUI
termcnInk·OpenTUI 위의 컴포넌트 모음위 둘을 따름위 둘을 따름화면을 빨리 만들어야 할 때
blessed 계열Node(위젯 라이브러리) / Python(능력 래퍼)보유형 위젯 트리절대·비율 좌표레거시 유지보수, 저수준 능력 조회

blessed에 대해서는 오해가 잦아 짚어 둡니다. Node.js의 blessed는 오래된 위젯 라이브러리로 원본은 사실상 유지보수가 멈췄고 neo-blessed 같은 포크가 이어받았습니다. 반면 파이썬의 blessed는 완전히 다른 물건으로, 위젯이 아니라 터미널 능력 조회와 입력 처리를 담당하는 저수준 라이브러리이고 지금도 활발히 관리됩니다. 최신 문서에는 kitty 키보드 프로토콜 지원까지 들어 있습니다. 이름이 같다고 같은 계보로 묶으면 안 됩니다.

버전에 대해서는 솔직하게 적습니다. ratatui는 0.30 계열이 확인되고 v2 작업이 진행 중이라는 보고가 있으나 시점에 따라 다르게 보이므로 릴리스 페이지에서 직접 확인하는 편이 정확합니다. Textual은 릴리스 주기가 매우 빨라 특정 버전 번호를 인용하는 것이 무의미할 정도입니다. Bubble Tea v2와 Ink 6은 각각 2026년에 메이저 전환을 마쳤습니다.

터미널은 자기 능력을 알려 주지 않는다

브라우저에는 기능 탐지 API가 있습니다. 터미널에는 없습니다. 있는 것은 관습적인 환경 변수 몇 개와, 답을 줄 수도 있고 안 줄 수도 있는 이스케이프 시퀀스 질의뿐입니다. TUI에서 "왜 내 화면에 이상한 문자가 찍히지"의 원인은 거의 항상 여기입니다.

실무에서 필요한 탐지는 넷입니다.

. TERM은 트루컬러 여부를 잘 담지 못하고, terminfo의 RGB 능력도 균일하게 채워져 있지 않습니다. 현실적으로 가장 신뢰할 만한 신호는 COLORTERM 환경 변수이며, 값이 truecolor 또는 24bit이면 24비트 SGR 시퀀스를 안전하게 내보낼 수 있습니다. kitty, WezTerm, Ghostty, foot, iTerm2, Alacritty, Windows Terminal이 자동으로 설정합니다. 반대 방향으로는 NO_COLOR가 설정돼 있으면 색을 완전히 끄는 것이 관례입니다.

출력 대상이 TTY인가. 이게 가장 자주 빠뜨리는 검사입니다. 표준 출력이 파이프나 파일이면 색·스피너·커서 이동을 전부 꺼야 합니다. 그렇지 않으면 로그 파일에 제어 문자가 그대로 들어가고, grep으로 필터링할 수 없게 됩니다.

터미널 크기. 환경 변수가 아니라 ioctl(TIOCGWINSZ)로 얻고, 변경은 SIGWINCH 시그널로 통지받습니다. Node에서는 process.stdout.columns와 stdout의 resize 이벤트가 그 래퍼입니다.

고급 기능. 동기화 출력, kitty 키보드 프로토콜, 마우스 리포팅은 DECRQM 질의로 물어볼 수 있습니다. 다만 응답하지 않는 터미널이 있으므로 타임아웃을 반드시 두어야 합니다. 응답을 기다리며 영원히 멈춰 있는 TUI는 흔한 버그입니다.

# 지금 터미널이 무엇을 자칭하는지 확인
printf 'TERM=%s COLORTERM=%s TERM_PROGRAM=%s\n' \
  "$TERM" "$COLORTERM" "$TERM_PROGRAM"

# 동기화 출력(DEC 프라이빗 모드 2026) 지원 여부 질의 — DECRQM
# 응답 형식: CSI ? 2026 ; <상태> $ y   (상태 0이면 미지원)
printf '\033[?2026$p'; sleep 0.2; echo

# 1차 장치 속성 질의 — 응답이 오면 최소한 살아 있는 터미널이다
printf '\033[c'; sleep 0.2; echo

# 트루컬러 육안 확인
printf '\033[38;2;255;100;0mtruecolor\033[0m\n'

여기에 하나 더. 문자 폭 계산입니다. 한글·한자·이모지는 셀 하나가 아니라 둘을 차지하고, 이모지 시퀀스는 코드포인트 여러 개가 한 글자입니다. 폭 계산을 문자열 길이로 하면 한글이 섞이는 순간 테두리가 어긋납니다. 프레임워크가 대개 처리해 주지만, 직접 폭을 재는 코드를 쓸 때는 반드시 wcwidth 계열 함수나 그래핌 클러스터 단위 계산을 써야 합니다. 한국어 TUI를 만든다면 이건 선택이 아닙니다.

리사이즈와 깜빡임

TUI에서 사용자가 가장 먼저 알아채는 결함 둘입니다.

리사이즈는 이벤트로 다뤄야 합니다. 렌더링할 때마다 크기를 다시 읽는 방식은 프레임 중간에 크기가 바뀌면 반쪽짜리 화면을 만듭니다. 올바른 순서는 리사이즈 시그널을 받아 상태를 갱신하고, 그 갱신이 렌더링을 유발하게 하는 것입니다. Bubble Tea는 tea.WindowSizeMsg로, Ink는 stdout의 resize 이벤트로, Textual은 on_resize로 이걸 표현합니다.

리사이즈를 처리할 때 자주 놓치는 것이 스크롤 위치 보정입니다. 창이 줄어들면 현재 보이는 범위가 콘텐츠 끝을 넘어갈 수 있으므로, 매번 위치를 유효 범위로 다시 묶어 줘야 합니다.

깜빡임과 찢어짐은 원인이 하나입니다. 한 프레임을 여러 번의 write로 나눠 내보내는 동안 터미널이 중간 상태를 화면에 그리는 것. 대응은 셋입니다.

  1. 한 프레임을 한 번의 write로. 문자열을 전부 조립한 뒤 한 번에 씁니다.
  2. 바뀐 셀만 다시 그리기. 전체를 지우고 다시 그리면(clear 후 redraw) 반드시 깜빡입니다. 이전 프레임과 diff를 내는 것이 표준입니다.
  3. 동기화 출력 사용. DEC 프라이빗 모드 2026을 켜고 끄면 터미널이 그 구간을 원자적으로 처리합니다. Ink 6.7 이상과 Bubble Tea v2가 이 프로토콜을 채택했다고 보고됩니다.
# 동기화 출력의 원형 — 프레임 시작과 끝을 알린다
printf '\033[?2026h'   # begin synchronized update
#   ... 프레임 전체를 여기서 출력 ...
printf '\033[?2026l'   # end synchronized update

그리고 전체 화면 앱이라면 대체 화면 버퍼를 써야 합니다. 진입 시 CSI ? 1049 h, 종료 시 CSI ? 1049 l을 보내면 앱이 끝난 뒤 사용자의 셸 화면과 스크롤백이 그대로 돌아옵니다. 이걸 안 하면 사용자의 터미널 히스토리가 앱 화면으로 덮여 버립니다 — TUI 예의에서 가장 기본입니다. 종료 처리도 중요합니다. 패닉이나 시그널로 죽을 때도 대체 화면에서 나오고, 커서를 다시 보이게 하고, 원시 모드를 해제해야 합니다. 그러지 않으면 사용자는 입력이 보이지 않는 셸에 남겨집니다.

작은 예제 — Ink로 만드는 스크롤 로그 뷰어

리사이즈, 키 입력, 범위 보정, 종료 처리를 모두 포함한 최소 예제입니다. 그대로 실행됩니다.

mkdir tui-demo && cd tui-demo
npm init -y && npm pkg set type=module
npm i ink react
npm i -D tsx typescript @types/react
// viewer.tsx — 실행: npx tsx viewer.tsx
import React, { useEffect, useState } from 'react'
import { render, Box, Text, useApp, useInput, useStdout } from 'ink'

const LINES = Array.from(
  { length: 500 },
  (_, i) => `[${String(i).padStart(4, '0')}] worker-${i % 4} processed batch ${i}`
)

function Viewer({ lines }: { lines: string[] }) {
  const { stdout } = useStdout()
  const { exit } = useApp()
  const [size, setSize] = useState({
    cols: stdout.columns ?? 80,
    rows: stdout.rows ?? 24,
  })
  const [top, setTop] = useState(0)

  // 크기는 렌더링 중에 읽지 않고 이벤트로 받아 상태에 넣는다
  useEffect(() => {
    const onResize = () =>
      setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 })
    stdout.on('resize', onResize)
    return () => {
      stdout.off('resize', onResize)
    }
  }, [stdout])

  const body = Math.max(size.rows - 2, 1)
  const maxTop = Math.max(lines.length - body, 0)

  // 창이 줄어들면 현재 위치가 범위를 넘어갈 수 있다 — 매번 다시 묶어 준다
  useEffect(() => {
    setTop((t) => Math.min(t, maxTop))
  }, [maxTop])

  useInput((input, key) => {
    if (input === 'q') exit()
    if (input === 'j' || key.downArrow) setTop((t) => Math.min(t + 1, maxTop))
    if (input === 'k' || key.upArrow) setTop((t) => Math.max(t - 1, 0))
    if (key.pageDown) setTop((t) => Math.min(t + body, maxTop))
    if (key.pageUp) setTop((t) => Math.max(t - body, 0))
  })

  const view = lines.slice(top, top + body)

  return (
    <Box flexDirection="column" width={size.cols}>
      <Box borderStyle="round" borderColor="cyan" paddingX={1}>
        <Text color="cyan">
          {`${top + 1}-${top + view.length} / ${lines.length}`}
        </Text>
        <Text dimColor>{'   j·k 이동   PgUp·PgDn 페이지   q 종료'}</Text>
      </Box>
      {view.map((line, i) => (
        <Text key={top + i} wrap="truncate-end">
          {line}
        </Text>
      ))}
    </Box>
  )
}

// 출력이 TTY가 아니면 UI를 띄우지 않고 그냥 텍스트로 흘려보낸다
if (!process.stdout.isTTY) {
  for (const line of LINES) console.log(line)
} else {
  render(<Viewer lines={LINES} />)
}

이 40여 줄에 앞에서 말한 규칙들이 들어 있습니다. 크기를 이벤트로 받고, 스크롤 위치를 범위로 묶고, wrap="truncate-end"로 긴 줄이 레이아웃을 깨뜨리지 않게 하고, TTY가 아니면 UI 자체를 포기합니다. 마지막 조건이 특히 중요합니다 — 이 한 줄이 node viewer.tsx | grep worker-2를 동작하게 만듭니다.

Ink는 기본적으로 일반 화면 버퍼에 그립니다. 전체 화면 앱으로 만들려면 대체 화면 진입·복귀를 직접 넣어야 합니다.

// 전체 화면으로 쓰려면 진입과 복귀를 짝으로 관리한다
const enter = () => process.stdout.write('[?1049h')
const leave = () => process.stdout.write('[?1049l[?25h')

enter()
process.on('exit', leave)
process.on('SIGINT', () => {
  leave()
  process.exit(130)
})

process.on('exit', leave)가 있어야 예외로 죽을 때도 사용자의 화면이 복구됩니다. 이 처리를 빼먹은 TUI가 정말 많습니다.

CLI인가 전체 화면 앱인가

마지막 판단이 사실 첫 번째로 해야 할 판단입니다. 둘은 다른 물건입니다.

스트리밍 CLI는 표준 출력에 줄 단위로 쓰고, 끝나면 결과가 스크롤백에 남고, 파이프로 넘길 수 있습니다. 진행 표시줄이나 스피너가 있어도 본질은 같습니다. 이쪽을 골라야 할 신호는 이렇습니다.

  • 결과를 다른 명령으로 넘기거나 파일로 저장할 가능성이 있다
  • CI에서 실행된다
  • 사용자가 명령을 실행하고 결과를 읽고 나가는 흐름이다
  • 한 번의 실행이 몇 초 안에 끝난다

전체 화면 앱은 대체 화면을 점유하고 입력을 독점하며, 종료하면 아무것도 남기지 않습니다. 이쪽 신호는 다음과 같습니다.

  • 사용자가 같은 화면에서 여러 작업을 오간다(탐색, 필터, 선택, 실행)
  • 상태가 계속 갱신된다(로그 추적, 리소스 모니터)
  • 키보드 단축키가 여러 개 필요하다
  • 세션이 분 단위 이상 지속된다

애매하면 스트리밍 CLI로 시작하세요. 전체 화면 앱은 되돌리기 어렵고, 접근성·자동화·파이프 호환을 전부 포기하는 결정입니다. 실제로 잘 만들어진 도구 다수가 두 모드를 같이 제공합니다 — 인자를 주면 한 번에 출력하고, 인자 없이 실행하면 인터랙티브 화면을 띄우는 방식입니다.

프레임워크 선택은 그다음입니다. 배포 형태가 단일 바이너리여야 하면 Go나 Rust(Bubble Tea, ratatui), 이미 Node CLI가 있으면 Ink, 파이썬 데이터 도구라면 Textual, TypeScript로 쓰되 성능이 필요하면 OpenTUI. termcn은 프레임워크가 아니라 그 위의 컴포넌트 모음이므로, Ink나 OpenTUI를 이미 골랐을 때 화면 만드는 시간을 줄이는 용도입니다.

마치며 — 터미널은 화면이 아니라 프로토콜이다

TUI 프레임워크는 편해졌습니다. 플렉스박스로 배치하고, 테마 토큰으로 색을 바꾸고, 컴포넌트를 복사해 붙입니다. 그래서 오히려 아래층을 잊기 쉬워졌습니다.

  • 렌더링 모델을 먼저 고르세요. Elm은 상태 흐름을 한곳에 모으고, 즉시 모드는 개념을 줄이고, 재조정은 웹 지식을 재사용합니다. 언어보다 이 선택이 코드 구조를 결정합니다.
  • 능력은 추정하지 말고 탐지하세요. COLORTERM, NO_COLOR, TTY 여부는 최소 셋이고, DECRQM 질의에는 반드시 타임아웃을 두세요.
  • 리사이즈는 이벤트로 받고, 스크롤 위치는 매번 범위로 묶으세요.
  • 한 프레임은 한 번의 write로, 가능하면 동기화 출력 구간 안에서 내보내세요.
  • 대체 화면에 들어갔으면 어떤 경로로 죽더라도 나오세요. 커서 복원과 원시 모드 해제까지가 한 세트입니다.
  • 한글을 표시한다면 문자 폭 계산을 반드시 확인하세요. 문자열 길이는 폭이 아닙니다.

터미널은 그냥 검은 화면이 아니라 수십 년치 이스케이프 시퀀스 규약의 퇴적층입니다. 프레임워크가 그 위에 예쁜 층을 하나 더 얹어 줄 뿐, 규약은 그대로 살아 있습니다.

참고 자료