React 트리, Yoga 레이아웃, 패킹된 화면 버퍼, diff 출력이 어떻게 한 프레임으로 이어지는지 추적합니다.
Claude Code의 터미널 UI는 브라우저 DOM 대신 터미널 셀 그리드에 그려집니다. Ink 렌더러는 React Reconciler가 만든 호스트 트리를 받아 Yoga로 레이아웃을 계산하고, 그 결과를 화면 버퍼에 적재한 뒤, 이전 프레임과 비교해 필요한 ANSI 출력만 내보냅니다.
ink/renderer.ts → ink/output.ts → ink/screen.ts → ink/dom.ts → ink/styles.ts
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
터미널 렌더러는 "React가 커밋했다"와 "지금 안전하게 다시 그릴 수 있다"가 같은 뜻이 아닙니다. resize, 외부 출력, alt-screen 전환 같은 요인이 섞이기 때문에, 한 곳에서 프레임 경계를 잡는 조정자가 꼭 필요합니다.
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
}
}
호스트 트리가 준비되면 Yoga가 각 노드의 사각형을 계산합니다. 터미널 렌더러는 CSS 전체를 구현하지 않지만, Flexbox 축과 패딩, 정렬, 폭 계산 같은 핵심 규칙은 Yoga에 맡겨 일관성을 얻습니다.
// Yoga 계산 결과는 숫자 좌표로 내려옵니다
layout = {
x: 0,
y: 3,
width: 64,
height: 5,
}
renderNodeToOutput 단계는 레이아웃이 끝난 노드를 실제 쓰기 명령으로 평탄화합니다. 여기서 중요한 점은 blit 최적화가 "터미널로 바로 쓰는 지름길"이 아니라, 같은 화면 버퍼 모델 안에서 일부 영역만 빠르게 갱신하는 경로라는 점입니다.
blit은 이미 계산된 출력 조각이나 버퍼 조각을 대상 영역에 복사하는 빠른 경로입니다. 하지만 alt-screen 관리, 이전 프레임 비교, 오염 감지는 여전히 같은 파이프라인이 담당합니다. 즉, blit이 diff와 버퍼 불변식을 건너뛰는 것은 아닙니다.
// blit은 버퍼 일부를 같은 프레임 모델 안에서 복사합니다
function blitRegion(screen, fragment, x, y) {
// fragment -> packed screen buffer의 일부 영역으로 복사
// 이후 flush 단계는 여전히 prev frame과 비교합니다
}
blit을 "전체 렌더링 파이프라인을 우회한다"고 이해하면 틀립니다. 빠르게 복사하는 지점은 있어도, 최종 출력은 같은 화면 버퍼와 diff 규칙을 따라야 화면 일관성이 유지됩니다.
화면 버퍼는 문자열 배열이 아니라 셀 상태를 정수 중심으로 패킹한 구조입니다. 문자, 스타일, 가시성 플래그, 와이드 문자 연속 상태 같은 값이 버퍼에 담겨 있으므로, diff 단계는 먼저 값 비교를 빠르게 수행하고 필요할 때만 ANSI 시퀀스를 만듭니다.
// 화면 버퍼는 문자 자체보다 셀 상태 비교에 최적화됩니다
cell = {
codePoint,
styleBits,
visibleOnSpace,
continuation,
}
공백도 항상 "비어 있음"을 뜻하지는 않습니다. 배경색이 있는 공백은 실제로 그려야 하므로 가시성 비트가 남아 있어야 하고, 투명 공백은 flush 단계에서 건너뛸 수 있습니다.
터미널 텍스트는 단순히 "문자 1개 = 셀 1칸"이 아닙니다. 한글, CJK 문자는 2칸을 차지할 수 있고, 결합 문자나 variation selector는 새 셀을 차지하지 않을 수 있습니다. 렌더러는 이 폭 정보를 알고 버퍼를 채워야 셀 경계가 깨지지 않습니다.
결합 문자는 독립 셀을 하나 더 차지하기보다 앞선 글리프에 붙습니다. 그래서 렌더러는 단순한 string.length가 아니라 실제 셀 폭 규칙을 기준으로 버퍼 오프셋을 계산해야 합니다.
// 폭 계산은 코드 유닛 수가 아니라 셀 폭 기준입니다
const width = getCellWidth(grapheme)
if (width === 2) {
// lead cell + continuation cell
}
if (width === 0) {
// 이전 셀 글리프에 결합
}
전체 화면 지우기와 풀 프레임 재렌더는 아무 때나 해도 되는 동작이 아닙니다. alt-screen에 들어간 상태에서만 기존 셸 화면을 건드리지 않고 안전하게 전체 프레임을 다시 그릴 수 있습니다. 이게 렌더러의 중요한 불변식입니다.
// 전체 clear는 alt-screen과 버퍼 무효화가 맞물릴 때만 안전합니다
if (isInAltScreen && shouldClearScreen) {
clearScreen()
previousFrame = null
}
"첫 렌더에서 무조건 CSI 2J"처럼 이해하면 위험합니다. alt-screen 밖에서 이 규칙을 적용하면 사용자가 보던 셸 내용을 파괴할 수 있습니다.
renderer.ts는 단순 출력기가 아니라 프레임 조정자입니다Q1. renderer.ts를 가장 정확하게 설명한 것은?
renderer.ts는 dirty 상태 수집, 프레임 예약, 버퍼 무효화, 최종 flush까지 묶는 조정자 역할을 합니다.Q2. blit 경로에 대한 설명으로 맞는 것은?
Q3. 패킹된 Screen Buffer를 쓰는 이유는?
Q4. 와이드 문자 처리에서 꼭 필요한 규칙은?
Q5. 전체 화면 clear와 alt-screen의 관계로 맞는 것은?