HyperFrames 탐구 5 — 코드를 직접 고치는 에디터, Studio의 닫힌 루프
시리즈 마지막 편입니다. HyperFrames에는 브라우저에서 영상을 보며 편집하는 Studio가 있는데, 이 에디터에는 다른 에디터와 근본적으로 다른 점이 하나 있습니다. 편집 결과가 메모리의 객체가 아니라, 사람이 손으로 쓴 HTML 파일에 직접 반영됩니다. 이 선택 하나가 에디터의 모든 설계를 결정하는 과정을 따라가 봅니다.
진실의 원본은 어디에 있는가
모든 에디터에는 "진짜 데이터가 사는 곳"이 딱 하나 있습니다. 화면은 그걸 보기 좋게 그린 것일 뿐이죠.
| 구분 | Figma 같은 디자인 툴 | HyperFrames Studio |
|---|---|---|
| 진실의 원본 | 내부의 객체 트리 (메모리) | 디스크의 HTML 파일 |
| 동작 방식 | 편집 → 트리 값 수정 → 리렌더 | 편집 → .html 파일 수정 → 리로드 |
| 특징 | 독자 포맷(.fig), 외부 도구 불가 | 코드 직접 제어, Git 버전 관리, 범용 포맷 |
피그마는 "데이터가 먼저, 코드는 없음"이고, HyperFrames는 **"코드가 곧 데이터"**입니다. 2편에서 본 "HTML이 SoT"라는 원칙이 에디터에도 그대로 적용된 겁니다. 에이전트도, 사람의 텍스트 에디터도, Studio의 마우스 클릭도 전부 같은 파일을 고칩니다.
그런데 이 선택은 두 가지 까다로운 숙제를 만듭니다.
- 화면에서 한 편집을 어떻게 텍스트에 되돌려 쓰지? — 피그마는 트리 노드 하나 지우면 끝이지만, 여기선 텍스트 파일에서 해당 부분만 수술하듯 도려내되 나머지 들여쓰기·주석·포맷은 그대로 보존해야 합니다.
- 화면의 픽셀 덩어리와 텍스트의 한 줄을 어떻게 연결하지? — 클릭한 파란 상자와 7번째 줄의
<h1>이 같은 존재라는 걸 알려줄 공통 이름표가 필요합니다.
무대, 리모컨, 연출가 — 3겹 구조
Studio는 세 영역이 협력합니다.
브라우저 탭
├── 에디터 UI (사이드바·인스펙터) ← 관객석의 리모컨
│ ↕ postMessage
└── 프리뷰 <iframe>: 사용자 HTML + 피커 ← 배우들이 연기하는 무대
↕ HTTP / SSE
Studio 서버 (Node.js): API + 파일 감시자 ← 대본을 고치는 무대 뒤 연출가
↕ 읽기/쓰기
HTML 소스 파일 ← 진실의 원본
프리뷰가 <div>가 아니라 <iframe>인 이유는 샌드박스입니다. 사용자가 작성한 CSS/JS가 에디터 UI를 망가뜨리지 못하게 격리하는 거죠. 브라우저는 보안상 로컬 파일에 직접 접근할 수 없으니 파일 읽기/쓰기는 Node.js 서버가 대행하고, iframe과 에디터 UI는 postMessage로, 서버와 브라우저는 HTTP/SSE로 대화합니다.
그리고 두 숙제의 답이 되는 공통 이름표가 selector + selectorIndex 쌍입니다. 실무 HTML에는 id가 없는 요소가 많아서 selector: "p"만으로는 세 문단을 구분할 수 없습니다. 그래서 "어떤 종류인가(p:nth-of-type(2))"와 "그중 몇 번째인가(selectorIndex: 0)"를 한 쌍으로 묶어 요소를 특정합니다. 이 주소를 만드는 쪽(피커)과 해석하는 쪽(서버)이 완전히 같은 우선순위 규칙을 써야 한다는 것이 이 시스템의 대전제입니다.
조각 1: 소스 역매핑 — 편지에서 한 문단만 오려내기
서버 쪽 sourceMutation.ts의 비유는 이겁니다. "손으로 쓴 편지에서 한 문단만 깔끔하게 오려내기." 가위(정규식)로 자르면 옆 글자까지 잘립니다. 그래서 편지를 한 번 디지털로 스캔(DOM 파싱)해서, 정확히 그 문단만 지우고, 다시 같은 종이에 인쇄합니다.
[HTML 문자열] → [DOM 변환 (linkedom)] → [요소 찾아 .remove()] → [다시 문자열로]
디테일이 알찬 함수 네 개가 이 흐름을 나눠 맡습니다.
parseSourceDocument — 입력이 완전한 문서(<!doctype> 포함)인지 조각(<div>...</div>만)인지 판별합니다. 조각은 임시 봉투(<body>)에 넣어 파싱하되 wrappedFragment: true라는 포스트잇을 붙여 둡니다. 들어올 때 손댔으면 나갈 때 되돌려야 하니까요.
querySelectorAllWithTemplates — <template> 안쪽은 일반 querySelectorAll에 잡히지 않는 숨겨진 방입니다. 일반 검색이 실패하면 template들을 직접 열어봅니다. 여기엔 버그로 배운 함정도 있습니다 — 숨겨진 방의 .content를 직접 수정하면 직렬화에 반영되지 않아서, <template> 태그 자체에 질의해야 합니다.
findTargetElement — 식별 우선순위: ① id가 있으면 id로 ② 없으면 selector + selectorIndex로 ③ 그래도 실패하면 에러 대신 null.
removeElementFromHtml — 실제 출구입니다.
const { document, wrappedFragment } = parseSourceDocument(source);
const element = findTargetElement(document, target);
if (!element) return source; // 🌟 에디터의 황금률: 못 찾으면 원본 그대로!
element.remove();
return wrappedFragment
? document.body.innerHTML // 봉투에 넣어 왔으면 알맹이만
: document.toString(); // 전체 문서였으면 통째로
if (!element) return source 한 줄이 이 파일의 정신입니다. 식별 실패 = 아무것도 안 함. 잘못 지우는 것보다 안 지우는 게 낫습니다.
조각 2: 요소 피커 — HTML을 찍는 스포이드
iframe 안에서 도는 피커의 비유는 *"포토샵 스포이드인데 색상이 아니라 HTML 요소를 찍는 도구"*입니다. 클릭하면 "당신이 클릭한 건 p:nth-of-type(2)입니다"라고 주소를 만들어 부모에게 알려주는 게 일의 전부인데, 그 안의 디테일이 훌륭합니다.
- 겹친 요소를 전부 수집 —
elementFromPoint(맨 위 1개) 대신elementsFromPoint(z축으로 겹친 전부)를 써서 후보 최대 8개를 부모에 넘깁니다. 버튼을 클릭했지만 사실 카드 전체를 선택하고 싶었던 사용자를 위한 배려입니다. - 검문소(
isPickableElement) —html/body,script/style, 그리고 피커 자신이 그린 하이라이트 박스를 걸러냅니다. 마지막 항목을 안 거르면 자기가 그린 박스를 자기가 다시 클릭하는 꼬리 물기 버그가 납니다.closest('[data-no-pick]')로 "이 영역 전체는 건드리지 마" 지정도 지원합니다. - 가시성 검사(
isEffectivelyHidden) — 요소 자신만 보면 안 되고 조상까지getComputedStyle로 훑습니다. 투명망토를 쓴 상자 안의 인형은 인형이 멀쩡해도 안 보이니까요. - 기본 동작 진압(
enablePickMode) — 픽 모드 중에는 링크가 이동하면 안 됩니다. 이벤트를 버블 단계보다 앞선 캡처 단계(addEventListener의 세 번째 인자true)에서 가로채고,preventDefault+stopPropagation+stopImmediatePropagation3연타로 원래 하려던 행동을 차단합니다. - 흔적 없는 하이라이트 — 요소의 인라인 스타일을 더럽히지 않고
<style>태그를 임시로 꽂은 뒤 클래스만 토글합니다. 픽 모드를 끌 때 style 태그 하나만 지우면 원상복구입니다.
그리고 핵심인 buildElementSelector — 주소 공장입니다. ① id가 있으면 #title ② 고유 데이터 속성이 있으면 [data-composition-id="..."] ③ 아니면 부모의 직계 형제 중 순번(p:nth-of-type(2)). 이 우선순위가 서버의 findTargetElement와 한 세트라는 것, 그게 이 시스템 전체의 계약입니다. 어긋나면 화면에서 지운 것과 파일에서 지워진 것이 달라지는 대참사가 납니다.
조각 3: 파일 감시와 SSE — 자동 벨 시스템
파일이 바뀌었으니 이제 화면을 갱신해야 합니다. watcher.ts의 비유는 *"파일이 바뀌면 브라우저가 알아서 새로고침되는 자동 벨"*입니다.
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
if (!shouldWatchProjectFile(relativePath)) return; // ① 노이즈 필터
if (debounceTimer) clearTimeout(debounceTimer); // ② 디바운스
debounceTimer = setTimeout(() => {
for (const fn of listeners) fn(relativePath); // ③ Fan-Out
}, 300);
});
- 노이즈 필터 —
node_modules,dist,.git은 무시합니다. 빌드 산출물 변경을 감지해 리로드하면 다시 빌드가 돌고 또 리로드되는 무한 새로고침 지옥에 빠지니까요. - 디바운스 300ms — 저장 한 번에 파일 쓰기 이벤트가 여러 번 터질 수 있어서, 변경이 완전히 멈추고 0.3초 잠잠해진 뒤에만 벨을 한 번 울립니다. (OpenCut의 SaveManager가 저장에 800ms 디바운스를 쓴 것과 같은 원리를, 방향만 반대로 쓴 셈입니다.)
- Fan-Out (옵저버 패턴) — 벨은 하나인데 듣는 소비자가 둘입니다. SSE 핸들러는 브라우저에
file-change이벤트를 밀어 프리뷰를 새로고침시키고, 캐시 무효화 리스너는 서버의 프로젝트 시그니처 캐시를 폭파합니다(??=로 다음 요청 때만 게으르게 재계산). 변경 신호의 근원이 하나라서 화면과 서버 캐시가 어긋나는 싱크 버그가 원천 차단됩니다.
서버→브라우저 통지에 WebSocket 대신 SSE를 쓴 것도 합리적입니다. 방향이 서버→클라이언트 단방향이면 충분하고, 표준 HTTP라 단순하고, 끊기면 브라우저가 공짜로 재연결해 주니까요. 연결 유지를 위해 30초마다 하트비트를 보내 프록시의 idle timeout을 막고, 탭이 닫힌 연결에 쓰다 나는 에러는 .catch(() => {})로 조용히 무시합니다.
방어적 설계도 눈에 띕니다. fs.watch의 recursive는 일부 OS에서 실패할 수 있는데, try/catch로 감싸 실패 시 자동 새로고침(편의 기능)만 포기하고 F5 수동 새로고침(핵심 기능)은 살려 둡니다 — 우아한 강등(graceful degradation). close()에서는 타이머·파일 핸들·리스너를 전부 정리해 며칠씩 켜두는 개발 서버의 메모리 누수를 막고요.
닫힌 루프 — 전부 이어 붙이면
① 프리뷰(iframe)에서 h1 클릭
→ 피커가 { selector: "h1", selectorIndex: 0 } 생성 → postMessage
② 에디터 UI에서 [삭제] → 서버 API 호출
③ 서버: removeElementFromHtml — 파싱 → 제거 → 디스크 저장 (진실의 원본 갱신)
④ watcher 감지 → 300ms 디바운스 → SSE "file-change"
→ 프리뷰 새로고침 + 캐시 무효화
⑤ h1이 사라진 새 화면에서 다음 클릭 → ①로 순환
같은 selector 규칙으로 클릭 지점을 약속하고, 단일 watcher가 모든 후속 갱신을 트리거하는 — 편집이 끊김 없이 한 바퀴 도는 **닫힌 루프(Closed Loop)**입니다.
더 깊이: 텍스트를 SoT로 두는 선택의 대차대조표
"디스크의 HTML이 진실"이라는 설계는 트레이드오프가 분명합니다. 시리즈를 닫으며 정리해 봅니다.
얻는 것 — Git으로 diff·리뷰·롤백이 되는 디자인 데이터, 어떤 텍스트 에디터로도 열리는 범용성, 그리고 결정적으로 LLM 에이전트와의 궁합입니다. 에이전트는 바이너리 객체 트리를 못 고치지만 텍스트는 원어민처럼 다룹니다. Studio의 클릭 편집과 에이전트의 Edit 도구가 같은 파일에서 충돌 없이 공존하는 건 SoT가 텍스트이기 때문입니다.
치르는 비용 — 첫째, 포맷 보존 문제. DOM으로 파싱했다 되돌리는 왕복에서 원본의 미세한 서식이 변할 수 있어, "못 찾으면 원본 반환" 같은 보수적 규칙이 필요해집니다. 둘째, 주소의 취약성. p:nth-of-type(2) 같은 구조적 주소는 요소가 하나 추가되는 순간 다른 요소를 가리킵니다. 피커가 id와 고유 데이터 속성을 우선하는 건 이 취약성을 줄이려는 노력이지만, 근본적으로는 "화면과 파일 사이에 안정적인 identity가 없다"는 문제가 남습니다. 피그마류 에디터가 모든 노드에 영구 ID를 박는 이유가 이거고요.
그래서 이 구조가 잘 맞는 조건도 명확합니다 — 편집 단위가 굵고(요소 삭제·속성 변경), 동시 편집자가 적고(로컬 1인 + 에이전트), 파일이 자주 리로드되어 주소가 금방 재계산되는 환경. Studio는 정확히 그 조건 위에 서 있습니다.
한 줄 요약 — Studio는 "디스크의 HTML = 진실"이라는 원칙 아래, 피커(주소 생성)와 소스 뮤테이션(주소 해석)이 같은 selector 규칙을 공유하고 단일 watcher가 SSE와 캐시를 함께 갱신하는, 클릭 → 파일 → 화면이 한 바퀴 도는 닫힌 루프 에디터다.
스스로 점검할 질문
if (!element) return source라는 황금률이 없다면 어떤 시나리오에서 무엇이 망가질까요?- 하나의 watcher에 SSE와 캐시 무효화를 함께 매단 설계가, 각자 따로 감시할 때보다 나은 이유는 무엇인가요?
- 여러분의 에디터형 제품에서 진실의 원본을 텍스트로 둘지 객체 트리로 둘지, 어떤 기준으로 정하겠습니까?
참고 링크