커스텀 Ink 렌더링 엔진

React 트리, Yoga 레이아웃, 패킹된 화면 버퍼, diff 출력이 어떻게 한 프레임으로 이어지는지 추적합니다.

01

개요

Claude Code의 터미널 UI는 브라우저 DOM 대신 터미널 셀 그리드에 그려집니다. Ink 렌더러는 React Reconciler가 만든 호스트 트리를 받아 Yoga로 레이아웃을 계산하고, 그 결과를 화면 버퍼에 적재한 뒤, 이전 프레임과 비교해 필요한 ANSI 출력만 내보냅니다.

다루는 소스 파일: ink/renderer.tsink/output.tsink/screen.tsink/dom.tsink/styles.ts
02

renderer.ts는 조정자 계층이다

renderer.ts는 단순한 "마지막 출력기"가 아닙니다. Reconciler에서 받은 변경 신호를 수집하고, 언제 레이아웃을 다시 계산할지, 언제 전체 프레임을 다시 그릴지, 언제 이전 버퍼를 무효화할지를 결정하는 프레임 조정자 역할을 합니다.

주요 책임

// renderer.ts의 개념적 흐름
commit from reconciler
  -> mark dirty subtrees
  -> schedule frame
  -> compute layout if needed
  -> build output commands
  -> write into packed screen buffer
  -> diff with previous frame
  -> flush terminal escapes
딥 다이브 - 왜 renderer.ts가 중심인가?

터미널 렌더러는 "React가 커밋했다"와 "지금 안전하게 다시 그릴 수 있다"가 같은 뜻이 아닙니다. resize, 외부 출력, alt-screen 전환 같은 요인이 섞이기 때문에, 한 곳에서 프레임 경계를 잡는 조정자가 꼭 필요합니다.

03

React Reconciler와 호스트 트리

React Reconciler는 DOM 대신 Ink의 호스트 노드를 만듭니다. 텍스트 노드, 박스 노드, 스타일 노드가 여기에 속하며, 커밋 단계에서 변경된 노드를 dirty로 표시해 후속 렌더를 예약합니다.

커밋의 핵심

// 커밋 후 dirty 전파 예시
function commitUpdate(node, nextProps) {
  node.props = nextProps
  markDirty(node)
}

function markDirty(node) {
  node.isDirty = true
  let current = node.parentNode
  while (current) {
    current.hasDirtyDescendant = true
    current = current.parentNode
  }
}
04

Yoga 레이아웃 계층

호스트 트리가 준비되면 Yoga가 각 노드의 사각형을 계산합니다. 터미널 렌더러는 CSS 전체를 구현하지 않지만, Flexbox 축과 패딩, 정렬, 폭 계산 같은 핵심 규칙은 Yoga에 맡겨 일관성을 얻습니다.

왜 별도 레이아웃 단계가 필요한가

// Yoga 계산 결과는 숫자 좌표로 내려옵니다
layout = {
  x: 0,
  y: 3,
  width: 64,
  height: 5,
}
05

Output 단계와 blit 경로

renderNodeToOutput 단계는 레이아웃이 끝난 노드를 실제 쓰기 명령으로 평탄화합니다. 여기서 중요한 점은 blit 최적화가 "터미널로 바로 쓰는 지름길"이 아니라, 같은 화면 버퍼 모델 안에서 일부 영역만 빠르게 갱신하는 경로라는 점입니다.

일반 경로

  1. 노드 트리를 순회합니다
  2. 텍스트와 스타일을 출력 조각으로 바꿉니다
  3. 조각을 화면 버퍼에 기록합니다
  4. 최종 diff를 계산해 터미널에 flush합니다

blit 경로의 실제 의미

blit은 이미 계산된 출력 조각이나 버퍼 조각을 대상 영역에 복사하는 빠른 경로입니다. 하지만 alt-screen 관리, 이전 프레임 비교, 오염 감지는 여전히 같은 파이프라인이 담당합니다. 즉, blit이 diff와 버퍼 불변식을 건너뛰는 것은 아닙니다.

// blit은 버퍼 일부를 같은 프레임 모델 안에서 복사합니다
function blitRegion(screen, fragment, x, y) {
  // fragment -> packed screen buffer의 일부 영역으로 복사
  // 이후 flush 단계는 여전히 prev frame과 비교합니다
}
주의사항

blit을 "전체 렌더링 파이프라인을 우회한다"고 이해하면 틀립니다. 빠르게 복사하는 지점은 있어도, 최종 출력은 같은 화면 버퍼와 diff 규칙을 따라야 화면 일관성이 유지됩니다.

06

패킹된 Screen Buffer

화면 버퍼는 문자열 배열이 아니라 셀 상태를 정수 중심으로 패킹한 구조입니다. 문자, 스타일, 가시성 플래그, 와이드 문자 연속 상태 같은 값이 버퍼에 담겨 있으므로, diff 단계는 먼저 값 비교를 빠르게 수행하고 필요할 때만 ANSI 시퀀스를 만듭니다.

왜 패킹하는가

// 화면 버퍼는 문자 자체보다 셀 상태 비교에 최적화됩니다
cell = {
  codePoint,
  styleBits,
  visibleOnSpace,
  continuation,
}
딥 다이브 - visible-on-space 비트

공백도 항상 "비어 있음"을 뜻하지는 않습니다. 배경색이 있는 공백은 실제로 그려야 하므로 가시성 비트가 남아 있어야 하고, 투명 공백은 flush 단계에서 건너뛸 수 있습니다.

07

와이드 문자와 결합 문자 처리

터미널 텍스트는 단순히 "문자 1개 = 셀 1칸"이 아닙니다. 한글, CJK 문자는 2칸을 차지할 수 있고, 결합 문자나 variation selector는 새 셀을 차지하지 않을 수 있습니다. 렌더러는 이 폭 정보를 알고 버퍼를 채워야 셀 경계가 깨지지 않습니다.

와이드 문자

결합 문자와 0폭 문자

결합 문자는 독립 셀을 하나 더 차지하기보다 앞선 글리프에 붙습니다. 그래서 렌더러는 단순한 string.length가 아니라 실제 셀 폭 규칙을 기준으로 버퍼 오프셋을 계산해야 합니다.

// 폭 계산은 코드 유닛 수가 아니라 셀 폭 기준입니다
const width = getCellWidth(grapheme)

if (width === 2) {
  // lead cell + continuation cell
}

if (width === 0) {
  // 이전 셀 글리프에 결합
}
08

Alt-screen 불변식과 전체 지우기 규칙

전체 화면 지우기와 풀 프레임 재렌더는 아무 때나 해도 되는 동작이 아닙니다. alt-screen에 들어간 상태에서만 기존 셸 화면을 건드리지 않고 안전하게 전체 프레임을 다시 그릴 수 있습니다. 이게 렌더러의 중요한 불변식입니다.

핵심 불변식

// 전체 clear는 alt-screen과 버퍼 무효화가 맞물릴 때만 안전합니다
if (isInAltScreen && shouldClearScreen) {
  clearScreen()
  previousFrame = null
}
주의사항

"첫 렌더에서 무조건 CSI 2J"처럼 이해하면 위험합니다. alt-screen 밖에서 이 규칙을 적용하면 사용자가 보던 셸 내용을 파괴할 수 있습니다.

09

핵심 요약

핵심 포인트

  • renderer.ts는 단순 출력기가 아니라 프레임 조정자입니다
  • React Reconciler는 Ink 호스트 트리를 만들고 dirty 상태를 전파합니다
  • Yoga는 터미널 셀 기준의 레이아웃 숫자를 계산해 후속 단계를 단순화합니다
  • blit은 버퍼 일부를 빠르게 복사하는 경로이지, diff와 불변식을 버리는 지름길이 아닙니다
  • Screen Buffer는 문자, 스타일, 가시성, continuation 상태를 패킹한 비교 친화적 구조입니다
  • 와이드 문자와 결합 문자는 실제 셀 폭 기준으로 처리해야 깨진 출력이 생기지 않습니다
  • alt-screen 밖에서의 파괴적 clear는 금지에 가깝고, contamination 이후에는 전체 재그리기가 필요합니다
10

지식 확인

퀴즈 - 5문제

Q1. renderer.ts를 가장 정확하게 설명한 것은?

  • A) 최종 ANSI 문자열만 합치는 파일
  • B) 프레임 스케줄링, 버퍼 수명, 재그리기 조건을 조정하는 중심 계층
  • C) Yoga를 대체하는 레이아웃 엔진
  • D) React 훅만 담은 유틸리티 파일
renderer.ts는 dirty 상태 수집, 프레임 예약, 버퍼 무효화, 최종 flush까지 묶는 조정자 역할을 합니다.

Q2. blit 경로에 대한 설명으로 맞는 것은?

  • A) 터미널에 직접 써서 diff를 완전히 건너뛴다
  • B) Yoga 계산과 버퍼 기록을 항상 모두 생략한다
  • C) 같은 화면 버퍼 모델 안에서 일부 영역을 빠르게 복사한 뒤, 최종 flush는 기존 diff 규칙을 따른다
  • D) alt-screen 불변식과 무관하게 동작한다
blit은 부분 갱신 최적화이지만, 여전히 같은 버퍼와 diff 파이프라인 안에서 동작합니다.

Q3. 패킹된 Screen Buffer를 쓰는 이유는?

  • A) 문자열 렌더링 품질을 높이기 위해
  • B) 셀 상태를 정수 중심으로 빠르게 비교하고 스타일 및 가시성 정보를 함께 유지하기 위해
  • C) Yoga를 제거하기 위해
  • D) React 상태를 저장하기 위해
화면 버퍼는 셀 상태 비교에 최적화된 구조입니다. 문자뿐 아니라 스타일과 가시성도 함께 다룹니다.

Q4. 와이드 문자 처리에서 꼭 필요한 규칙은?

  • A) string.length만 믿고 셀 폭을 계산한다
  • B) 와이드 문자는 한 셀만 차지한다고 가정한다
  • C) 결합 문자는 항상 새 셀을 만든다
  • D) 리드 셀과 연속 셀을 함께 관리하고, 덮어쓸 때 continuation 상태도 정리한다
와이드 문자는 여러 셀을 차지하므로 첫 셀만 바꾸고 연속 셀을 남겨 두면 화면이 깨집니다.

Q5. 전체 화면 clear와 alt-screen의 관계로 맞는 것은?

  • A) alt-screen 안에서만 파괴적 clear가 안전하며, contamination 이후에는 이전 프레임을 무효화하고 다시 그려야 한다
  • B) 첫 프레임이면 언제나 clear해도 된다
  • C) alt-screen은 출력 성능과만 관련 있다
  • D) 일반 셸 화면에서도 동일하게 적용된다
alt-screen은 기존 셸 화면을 보호하는 안전 장치입니다. 버퍼 기준이 오염되면 이전 프레임을 버리고 다시 그려야 합니다.
0 / 5