화면과 코드 사이: twain-ui를 만들면서 - React Inspector는 어떻게 DOM에서 컴포넌트를 역추적할까?

twain-ui를 만들게 된 배경
AI가 코드를 쓰는 시대가 되면서 “누가 코드를 만지는가”의 경계가 흐려지고 있다. 기획자가 프롬프트 한 줄로 화면을 뽑아내고, 디자이너가 컴포넌트를 브라우저에서 직접 조정하며, 개발자는 IDE가 아니라 실제 렌더된 UI 위에서 AI에게 자연어로 수정 지점을 지목하는 방식이 점점 자연스러워지고 있다. 바이브 코딩이 일상이 된 지금, 코드와 화면 사이를 오가는 왕복 편집 워크플로우는 오히려 휴먼옵스(human-ops)라는 병목으로 취급되기도 한다.
이 흐름에서 가장 자주 부딪히는 벽은 의외로 단순하다. “지금 이 화면에 보이는 이 버튼, 이건 어느 파일의 몇 번째 줄에서 온 거야?” 사람에게든 AI에게든 이 질문에 정확히 답할 수 있어야 UI에서 코드로, 코드에서 다시 UI로 이어지는 사이클이 매끄러워진다. 답이 부정확하면 AI는 엉뚱한 파일을 수정하고, 사람은 grep으로 컴포넌트 이름을 찾아 헤매게 된다.
그래서 twain-ui라는 유틸리티 패키지를 구상하기 시작했다. 이름 그대로 브라우저에서 렌더된 UI와 원본 소스 코드라는 두 세계를 잇는(twain) 브릿지 역할을 하는 라이브러리다. Locatorjs와 같은 오픈소스처럼 화면 위 어떤 요소든 클릭하면 그 뒤에 있는 컴포넌트 정체, 현재 props, 소스 파일의 위치를 바로 알려주고, 이 정보를 사람이 보든 AI 에이전트가 소비하든 동일한 형태로 제공하는 것이 목표다. 이 글은 이 패키지의 핵심 로직인 /fiber-walker.ts를 구현하며 정리한 공부 노트다. React, DOM, Fiber가 어떻게 맞물려 있고, 그 틈새를 어떻게 파고들어야 이 브릿지가 성립하는지 순서대로 짚어본다.
React가 화면을 그리는 방식
React는 UI를 “상태의 함수”로 표현한다. 개발자는 JSX로 컴포넌트를 선언하지만, 이 JSX는 실제 DOM 요소가 아니라 엘리먼트 객체라는 가벼운 표현으로 변환된다. <Button label="hi" />를 작성하면 브라우저에는 아무것도 생기지 않고, 대신 { type: Button, props: { label: "hi" } } 같은 자바스크립트 객체가 생성될 뿐이다.
이 엘리먼트 트리를 실제 DOM으로 변환하고, 상태가 바뀔 때마다 최소한의 변경만 반영하는 것이 React의 역할이다. 그 과정에서 React는 자신만의 내부 자료구조를 유지하는데, 그게 바로 Fiber다.
DOM: 브라우저의 세계
DOM(Document Object Model)은 브라우저가 HTML 문서를 트리 구조의 객체로 표현한 것이다. document.querySelector('div')로 얻는 노드가 바로 DOM 노드다. 화면에 실제로 그려지는 것은 오직 DOM이며, React가 아무리 정교한 내부 트리를 유지해도 최종적으로는 DOM에 반영되어야 유저의 눈에 보인다.
DOM은 React 입장에서 “출력 대상”이다. 하지만 흥미로운 사실은, React가 만들어낸 각 DOM 노드에는 자신을 만들어낸 Fiber에 대한 참조가 몰래 심어져 있다는 점이다. 이 참조는 개발자가 접근하도록 문서화된 API는 아니지만, 바로 이 지점 덕분에 DOM에서 React 세계로 거슬러 올라갈 수 있는 통로가 열린다. twain-ui가 성립할 수 있는 근본적인 이유도 여기에 있다.
Fiber: React의 내부 트리
Fiber는 React 16에서 도입된 재조정(reconciliation) 엔진이자, 그 엔진이 다루는 노드 자료구조의 이름이다. 각 컴포넌트 인스턴스 하나가 하나의 Fiber 노드가 되고, 이들이 서로 연결되어 트리를 이룬다.
Fiber 노드는 몇 가지 중요한 필드를 가진다. type은 이 노드가 어떤 컴포넌트 혹은 host 엘리먼트인지를 나타내며 함수나 문자열, 혹은 forwardRef/memo 같은 특수 객체가 담긴다. return은 부모 Fiber를 가리키는 포인터인데, 이름이 parent가 아니라 return인 이유는 Fiber가 재귀적 순회를 명시적 반복으로 변환하면서 “이 노드 작업이 끝나면 어디로 돌아갈지”를 표현하기 때문이다. memoizedProps는 마지막으로 커밋된 시점의 props 스냅샷을 담고 있다.
감이 잘 안 오니 실제 예시를 하나 살펴보자. 다음과 같은 JSX가 있다고 하자.
// App.tsx
function App() {
return (
<div>
<Button label="Click" />
</div>
);
}
// Button.tsx
function Button({ label }) {
return <button>{label}</button>;
}
이 코드가 렌더되면 React는 내부적으로 다음과 같은 Fiber 트리를 만든다. 화살표는 return 포인터, 즉 자식이 부모를 가리키는 방향이다.
┌──────────────┐
│ App Fiber │
│ type: App │
└──────▲───────┘
│ return
┌──────┴───────┐
│ div Fiber │
│ type: "div" │
└──────▲───────┘
│ return
┌──────┴───────┐
│ Button Fiber │
│ type: Button │
└──────▲───────┘
│ return
┌──────┴───────┐
│ button Fiber │
│type: "button"│
└──────▲───────┘
│ return
┌──────┴───────┐
│ "Click" Text │
└──────────────┘
button Fiber 노드 하나를 실제 객체 형태로 풀어보면 대략 이런 모습이다.
{
type: "button",
return: /* Button Fiber 참조 */,
stateNode: <button>Click</button>, // 실제 DOM 노드
memoizedProps: { children: "Click" },
_debugSource: {
fileName: "/src/Button.tsx",
lineNumber: 3,
columnNumber: 10,
},
_debugOwner: /* Button Fiber 참조 */,
// 실제로는 sibling, child, alternate 등 필드가 더 있다
}
사용자가 화면에서 <button> 요소를 클릭하면 우리는 이 Fiber에서 출발한다. _debugSource는 Button.tsx의 3번째 줄을 가리키고, _debugOwner는 이 <button> JSX를 실제로 작성한 Button 컴포넌트 Fiber를 가리킨다. 이 두 필드를 따라 위로 올라가는 것이 fiber-walker.ts의 핵심 동작이다.
개발 빌드에서는 여기에 두 개의 필드가 더 붙는다. _debugSource는 컴파일 시점에 Babel이나 SWC가 주입한 파일명과 줄 번호이고, _debugOwner는 이 요소의 JSX를 실제로 작성한 컴포넌트를 가리키는 포인터다. 이 둘의 존재가 Inspector 도구를, 그리고 twain-ui의 핵심 시나리오를 가능하게 만들어준다.
DOM에서 Fiber로 넘어가기
fiber-walker.ts의 첫 함수는 DOM 노드를 받아 그 노드에 연결된 Fiber를 꺼낸다.
export function getFiberFromDom(node: Element | null): Fiber | null {
if (!node) return null;
const key = Object.keys(node).find(
(k) =>
k.startsWith("__reactFiber$") ||
k.startsWith("__reactInternalInstance$"),
);
if (!key) return null;
return (node as unknown as Record<string, Fiber>)[key] ?? null;
}
React는 자신이 만든 DOM 노드에 __reactFiber$<랜덤해시> 형태의 속성을 붙여 Fiber 참조를 저장한다. 랜덤 해시가 붙는 이유는 여러 React 인스턴스가 한 페이지에 공존할 수 있기 때문이며, 그래서 정확한 키 이름을 미리 알 수 없다. Object.keys로 노드의 속성을 훑어 접두사가 맞는 것을 찾아내는 방식으로 이 문제를 우회한다. __reactInternalInstance$도 함께 확인하는 것은 React 16 이하 호환을 위한 것이다.
소스 위치를 거슬러 올라가기
Fiber 하나를 얻었다면, 그 다음 과제는 “이 요소를 만들어낸 소스 코드는 어디인가”를 찾는 것이다.
export function getDebugSource(fiber: Fiber | null): DebugSource | null {
let current: Fiber | null | undefined = fiber;
while (current) {
if (current._debugSource && current._debugSource.fileName) {
return current._debugSource;
}
current = current._debugOwner ?? current.return;
}
return null;
}
이 순회 로직에서 가장 중요한 부분은 _debugOwner를 return보다 우선한다는 점이다. 두 포인터의 차이를 이해하는 것이 관건이다. return은 렌더링 트리상의 부모, 즉 “이 노드가 자식으로 배치된 위치의 부모”를 가리킨다. 반면 _debugOwner는 “이 요소의 JSX를 실제로 작성한 컴포넌트”를 가리킨다.
예를 들어 Button 컴포넌트가 내부에서 <div>를 렌더링하고, 이 Button을 App이 사용한다면, <div>의 return은 Button Fiber이지만 _debugOwner 역시 Button이다. 하지만 Button이 children을 받아 그대로 렌더링하는 경우엔 이야기가 달라진다. <Button><Icon/></Button>에서 Icon의 return은 렌더링 위치상 Button 내부의 어떤 지점이지만, _debugOwner는 이 JSX를 작성한 App이다. UI에서 클릭한 지점을 근거로 실제 편집해야 할 소스 파일을 찾고 싶다면 후자를 따라가야 정확한 결과를 얻는다.
두 포인터가 어떻게 갈라지는지 그림으로 보자.
// App.tsx
function App() {
return (
<Button>
<Icon />
</Button>
);
}
// Button.tsx
function Button({ children }) {
return <div className="wrap">{children}</div>;
}
┌──────────────┐
│ App Fiber │◀──────────┐
│ (App.tsx) │ │
└──────▲───────┘ │
│ return │
┌──────┴───────┐ │
│ Button Fiber │ │ _debugOwner
│ (App.tsx에서 │ │ (이 JSX를 App이 작성)
│ 작성됨) │ │
└──────▲───────┘ │
│ return │
┌──────┴───────┐ │
│ div Fiber │ │
│ (Button.tsx) │ │
└──────▲───────┘ │
│ return │
┌──────┴───────┐ │
│ Icon Fiber │───────────┘
│ │
└──────────────┘
return: div Fiber (Button.tsx 내부)
_debugOwner: App Fiber (App.tsx)
Icon을 클릭했을 때 return만 따라 올라가면 div → Button → App 순서로 순회하게 되는데, 이 경로상 첫 번째로 만나는 _debugSource는 Button.tsx의 <div> 위치일 가능성이 크다. 하지만 정작 <Icon/>이라는 코드는 Button.tsx가 아니라 App.tsx에 있다. _debugOwner를 우선 따라가면 곧바로 App.tsx로 점프하기 때문에 사용자가 실제로 편집하고 싶어 하는 파일을 정확히 열어줄 수 있다.
컴포넌트 이름 추출의 복잡성
type 필드에서 이름을 얻는 것은 겉보기보다 까다롭다. React 컴포넌트는 함수일 수도, 클래스일 수도, React.forwardRef로 감싸진 객체일 수도, React.memo로 한 번 더 감싸진 객체일 수도 있기 때문이다.
function extractName(type: unknown): string | null {
if (!type) return null;
if (typeof type === "string") return type;
if (typeof type === "function") {
const fn = type as { displayName?: string; name?: string };
return fn.displayName || fn.name || null;
}
if (typeof type === "object") {
const obj = type as {
displayName?: string;
render?: { displayName?: string; name?: string };
type?: unknown;
$$typeof?: symbol;
};
if (obj.displayName) return obj.displayName;
if (obj.render) return obj.render.displayName || obj.render.name || null;
if (obj.type) return extractName(obj.type);
}
return null;
}
type이 문자열이면 host 엘리먼트다. 함수면 함수형/클래스 컴포넌트이므로 displayName을 우선하고 없으면 name을 쓴다. displayName은 개발자가 명시적으로 설정한 이름이며, 프로덕션 minify 이후에도 남기고 싶은 경우 흔히 사용된다.
객체 타입에서 render 필드가 있으면 forwardRef의 결과다. React.forwardRef((props, ref) => ...)는 render 함수를 포함한 객체를 반환하므로, 그 안쪽 함수의 이름을 봐야 한다. type 필드가 있으면 memo의 결과이며, React.memo(Component)는 원본 컴포넌트를 type 안에 담고 있다. 그래서 재귀적으로 extractName을 다시 호출한다. 이 재귀 덕분에 memo(forwardRef(Component)) 같은 중첩된 래핑도 정확히 풀린다.
Host 엘리먼트 스킵
사용자가 <button> 내부의 <span>을 클릭했다면, 우리가 알고 싶은 것은 "span"이 아니라 그 요소를 담고 있는 커스텀 컴포넌트, 예컨대 Button이다.
export function getComponentName(fiber: Fiber | null): string | null {
let current: Fiber | null | undefined = fiber;
while (current) {
const name = extractName(current.type);
if (name && !isHostElementName(name)) return name;
current = current.return;
}
return null;
}
function isHostElementName(name: string): boolean {
return name.length > 0 && name === name.toLowerCase();
}
isHostElementName은 이름이 완전히 소문자인지로 host 여부를 판별한다. HTML 태그명은 관례적으로 소문자이고, 사용자 정의 컴포넌트는 PascalCase로 시작한다는 React의 기본 규칙에 기대는 판별이다. host 엘리먼트를 만나면 스킵하고 계속 위로 올라간다.
최종 조립
export function getFiberInfo(node: Element | null): FiberInfo {
const fiber = getFiberFromDom(node);
return {
fiber,
componentName: getComponentName(fiber),
debugSource: getDebugSource(fiber),
props: (fiber?.memoizedProps as Record<string, unknown> | null) ?? {},
};
}
getFiberInfo는 위의 세 함수를 조합해 하나의 정보 묶음을 만든다. twain-ui의 상위 레이어는 이 결과를 받아 오버레이에 컴포넌트 이름과 파일 경로를 표시하거나, AI 에이전트에게 “지금 사용자가 지목한 요소는 이 파일의 이 컴포넌트다”라는 컨텍스트를 그대로 넘길 수 있다. 사람이 눈으로 보는 정보와 AI가 소비하는 컨텍스트가 같은 데이터에서 파생된다는 점이 중요하다. 그래야 UI 위에서의 지목과 코드 편집이 정확히 같은 대상을 가리키게 된다.
이 접근의 한계와 남은 과제
이 로직은 몇 가지 전제 위에 서 있다. 첫째, _debugSource와 _debugOwner는 React 내부 API이므로 언제든 바뀔 수 있다. 실제로 React 19에서는 _debugSource가 제거되고 _debugStack 기반의 새로운 표현으로 대체되는 흐름이 있어, React 메이저 업그레이드 시점에 이 유틸리티가 조용하게 깨질 수 있다.
둘째, _debugSource는 개발 빌드 전용이다. Babel의 @babel/plugin-transform-react-jsx-source나 SWC의 동등한 옵션이 켜져 있어야 주입되며, 프로덕션 빌드에서는 필드 자체가 존재하지 않으므로 소스 추적이 불가능하다. 그래서 twain-ui는 개발 환경에서만 유효한데, 내 처음 목표가 개발환경에서의 사용이라, 이건 추후 배포된 프로덕션 환경 지원을 고려해야할 때 다시 탭핑해볼 예정이다.
셋째, 컴포넌트 이름 추출의 휴리스틱이 완벽하지는 않다. 익명 함수 컴포넌트에 displayName을 설정하지 않았다면 minify 이후 무의미한 이름이 나올 수 있고, 커스텀 element 이름이 소문자로 시작하는 특수한 경우엔 host로 오판할 수도 있다.
정리
브라우저의 DOM, React가 내부적으로 유지하는 Fiber 트리, 그리고 개발자가 작성한 원본 소스 코드. React가 이 사이에 숨은 참조링크를 남겨둔 것이고, fiber-walker.ts가 이 링크들을 따라가면서 UI-to-Code, Code-to-UI 라는 브릿지 역할을 해주길 기대한다.
바이브 코딩이 일상이 된, AI없이 개발하는 개발자가 없어진 지금, 이런 시도가 유용하다고 생각하는 이유는, 결국 사람과 AI가 같은 화면을 보고 같은 지점을 가리킬 수 있어야 하기 때문이다. 사실 이런 니즈는 AI 이전부터 계속 있어왔다고 본다. 화면과 코드가 어긋나지 않도록 맞추는 일, 사람의 기억이나 설명에 기대지 않고도 소스코드만으로 UI를 정확하게 되짚을 수 있는 시스템을 만드는 일, 그리고 그 간극을 메우려던 수많은 문서화와 스토리북 같은 시도들 모두 결국 같은 문제를 다른 각도에서 풀려는 노력이었다. AI라는 새로운 협업자가 등장하면서 전면에 드러나고 있고, twain-ui 이라는 네이밍처럼 이 간극과 레이어를 더 밀착 압축시켜 추상화하는 시도를 이어가보고 싶다.