Sangcho.log
dev

HyperFrames 탐구 5 — 코드를 직접 고치는 에디터, Studio의 닫힌 루프

12 min read|

시리즈 마지막 편입니다. HyperFrames에는 브라우저에서 영상을 보며 편집하는 Studio가 있는데, 이 에디터에는 다른 에디터와 근본적으로 다른 점이 하나 있습니다. 편집 결과가 메모리의 객체가 아니라, 사람이 손으로 쓴 HTML 파일에 직접 반영됩니다. 이 선택 하나가 에디터의 모든 설계를 결정하는 과정을 따라가 봅니다.

진실의 원본은 어디에 있는가

모든 에디터에는 "진짜 데이터가 사는 곳"이 딱 하나 있습니다. 화면은 그걸 보기 좋게 그린 것일 뿐이죠.

구분 Figma 같은 디자인 툴 HyperFrames Studio
진실의 원본 내부의 객체 트리 (메모리) 디스크의 HTML 파일
동작 방식 편집 → 트리 값 수정 → 리렌더 편집 → .html 파일 수정 → 리로드
특징 독자 포맷(.fig), 외부 도구 불가 코드 직접 제어, Git 버전 관리, 범용 포맷

피그마는 "데이터가 먼저, 코드는 없음"이고, HyperFrames는 **"코드가 곧 데이터"**입니다. 2편에서 본 "HTML이 SoT"라는 원칙이 에디터에도 그대로 적용된 겁니다. 에이전트도, 사람의 텍스트 에디터도, Studio의 마우스 클릭도 전부 같은 파일을 고칩니다.

그런데 이 선택은 두 가지 까다로운 숙제를 만듭니다.

  1. 화면에서 한 편집을 어떻게 텍스트에 되돌려 쓰지? — 피그마는 트리 노드 하나 지우면 끝이지만, 여기선 텍스트 파일에서 해당 부분만 수술하듯 도려내되 나머지 들여쓰기·주석·포맷은 그대로 보존해야 합니다.
  2. 화면의 픽셀 덩어리와 텍스트의 한 줄을 어떻게 연결하지? — 클릭한 파란 상자와 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 + stopImmediatePropagation 3연타로 원래 하려던 행동을 차단합니다.
  • 흔적 없는 하이라이트 — 요소의 인라인 스타일을 더럽히지 않고 <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.watchrecursive는 일부 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와 캐시를 함께 갱신하는, 클릭 → 파일 → 화면이 한 바퀴 도는 닫힌 루프 에디터다.

스스로 점검할 질문

  1. if (!element) return source라는 황금률이 없다면 어떤 시나리오에서 무엇이 망가질까요?
  2. 하나의 watcher에 SSE와 캐시 무효화를 함께 매단 설계가, 각자 따로 감시할 때보다 나은 이유는 무엇인가요?
  3. 여러분의 에디터형 제품에서 진실의 원본을 텍스트로 둘지 객체 트리로 둘지, 어떤 기준으로 정하겠습니까?

참고 링크