개발 기술/개발 이야기

컴포넌트 프리뷰는 어떻게 실행될까? iframe으로 만들어본 프리뷰 런타임

by GicoMomg 2026. 5. 5.

0. 들어가며

디자인 시스템이나 공통 UI를 작업하다 보면 자연스럽게 컴포넌트 문서를 만들게 된다.

처음에는 컴포넌트 이름, 사용 예시, props 목록 정도만 정리해도 충분해 보인다.

<Button variant="primary" disabled={false}>
  CTA
</Button>

하지만 실제로 컴포넌트를 사용하는 입장에서 궁금한 것은 단순히 “어떤 props가 있는가”가 아니다.

오히려 더 자주 확인하고 싶은 것은 그 값을 바꿨을 때 실제 화면에서 어떻게 동작하는가이다.

  • variant를 바꾸면 실제로 어떻게 보이는지
  • disabled 상태에서는 어떤 인터랙션이 막히는지
  • children이 길어졌을 때 레이아웃이 어떻게 변하는지
  • 모바일 너비에서는 어떻게 보이는지
  • 클릭했을 때 이벤트가 제대로 발생하는지

즉, 컴포넌트 문서는 단순히 읽는 정보에 머물면 부족하다.
컴포넌트는 결국 화면에서 동작하는 UI이기에, 문서 역시 실제 동작을 확인할 수 있는 형태에 가까워야 한다.

이번에 만들었던 도구의 출발점도 여기에 있었다.
망 분리 환경에서도 Storybook처럼 컴포넌트를 문서화하고, 그 자리에서 바로 동작까지 확인할 수 있는 화면이 필요했다.

다만 목표는 Storybook 자체를 그대로 다시 만드는 것이 아니었다.
필요했던 것은 현재 코드베이스 안에서 실행할 수 있고, 컴포넌트 문서를 프리뷰와 연결할 수 있는 환경이었다.

이번 시간에는 iframe을 이용해 컴포넌트 프리뷰를 만들면서, 문서 UI와 프리뷰 실행 환경을 어떻게 분리했는지 정리해보려 한다. (작업 코드)







1. 프리뷰 런타임을 어떻게 만들까?

1) 필요한 것은 문서 사이트가 아니라 실행되는 프리뷰였다

이 도구에서 필요했던 것은 단순한 문서 사이트가 아니었다.

컴포넌트 이름, 설명, props 목록을 보여주는 것만으로는 부족했다.
문서에 적힌 값을 직접 바꿔보고, 그 결과가 실제 컴포넌트 화면에 바로 반영되는 구조가 필요했다.


예를 들어 버튼 컴포넌트를 문서화한다고 해보자.

문서에는 버튼의 종류, 비활성화 여부, 버튼 안에 들어갈 문구, 클릭 이벤트 같은 정보가 들어간다.
그리고 사용자는 이 값을 화면에서 바꿔보면서 다음 결과를 확인할 수 있어야 한다.

  • 버튼의 variant를 바꾸면 실제 모양이 바뀐다.
  • 버튼 안의 문구를 바꾸면 프리뷰에도 반영된다.
  • 버튼을 클릭하면 이벤트가 발생했는지 로그로 확인할 수 있다.
  • 모바일 / 태블릿 / 데스크톱 너비에서 어떻게 보이는지도 확인할 수 있다.

구조를 단순화하면 아래와 같다.

  1. 사용자가 컴포넌트 문서를 정의한다.
  2. 문서에 정의된 props, children, event 정보는 프리뷰 런타임의 입력값이 된다.
  3. 프리뷰 런타임은 이 입력값을 바탕으로 실제 컴포넌트를 실행한다.
  4. 사용자는 화면에서 렌더링 결과를 확인하고, 클릭 같은 이벤트가 발생했을 때 이벤트 로그까지 함께 확인할 수 있다.



여기서 중요한 점은 컴포넌트 문서가 단순한 설명서로 끝나지 않는다는 것이다.
문서에 적힌 값이 실제 프리뷰를 실행하기 위한 입력값이 된다.

예를 들어 버튼 문서는 다음처럼 정의할 수 있다.

export default defineComponentDoc({
  title: 'Button',
  component: Button,
  props: [
    {
      name: 'variant',
      type: "'primary' | 'secondary' | 'danger' | 'ghost'",
      default: 'primary',
      control: 'select',
      options: ['primary', 'secondary', 'danger', 'ghost'],
    },
    {
      name: 'disabled',
      type: 'boolean',
      default: false,
    },
  ],
  events: [{ name: 'onClick', payload: 'void' }],
  composition: {
    entries: [{ name: 'default' }],
  },
  compositionExamples: { default: 'CTA' },
})

위 정의에는 프리뷰를 실행하기 위한 정보가 함께 들어 있다.

필드 역할
component 실제 프리뷰에 렌더링할 컴포넌트
props 사용자가 조작할 수 있는 속성 정의
events 프리뷰에서 감지할 이벤트 정의
composition children처럼 조합 가능한 영역 정의
compositionExamples 기본으로 보여줄 조합 예시

문서 UI는 이 정의를 읽고, 사용자가 조작할 수 있는 상태를 준비한다.

예를 들어 variant는 선택 박스가 되고, disabled는 체크박스가 될 수 있다.
compositionExamples.default에 적힌 CTA는 버튼 안에 들어갈 기본 문구가 된다.

즉, 문서에 적힌 값이 사용자가 바꿔볼 수 있는 프리뷰 상태로 바뀐다.


이제 이 화면을 역할 기준으로 나눠보면 두 영역으로 정리할 수 있다.

영역 책임
Host UI 문서를 보여주고, 사용자가 값을 바꿀 수 있는 조작 영역
Preview Runtime Host UI에서 만든 값을 받아 실제 컴포넌트를 보여주는 실행 영역


다시 말해 Host UI는 “값을 만드는 곳”이고, Preview Runtime은 “그 값으로 컴포넌트를 실행하는 곳”이다.

이렇게 나누고 나면 다음 질문이 생긴다.

그럼 만들어진 값을 Preview Runtime에서 어떻게 실행해야 할까?



2) iframe을 사용하는 프리뷰 런타임 만들기

(1) 어떻게 프리뷰를 실행해야할까?

가장 단순한 방법은 현재 페이지의 DOM 안에 프리뷰 컴포넌트를 그대로 렌더하는 것이다.

export function PreviewPanel({
  Component,
  props,
}: {
  Component: React.ComponentType<any>
  props: Record<string, unknown>
}) {
  return (
    <div className="preview-panel">
      <Component {...props} />
    </div>
  )
}

이 방법은 문서 화면 안에 프리뷰 영역을 만들고, 선택된 컴포넌트를 바로 렌더하면 된다.

하지만 여기서부터 문제가 생긴다.

문서 UI와 프리뷰 컴포넌트가 같은 DOM과 CSS 문맥을 공유하면, 전역 스타일이 프리뷰에 영향을 줄 수 있기 때문이다.



첫 번째 문제는 css 충돌이다.

/* 문서 UI 전역 스타일 */
button {
  font-size: 12px;
  border-radius: 0;
}

/* 프리뷰 대상 컴포넌트 스타일 */
button {
  font-size: 14px;
  border-radius: 999px;
}

예를 들어 문서 UI와 프리뷰 컴포넌트가 같은 DOM 문맥에 있다면, 위 두 스타일은 같은 button 요소를 대상으로 경쟁하게 된다.

이 경우 지금 보이는 버튼이 컴포넌트가 의도한 모습인지, 문서 UI의 전역 스타일이 덮어쓴 결과인지, 혹은 reset CSS나 utility class의 영향인지 알기 어렵다.

문서가 컴포넌트의 실제 모습을 설명해야 하는데, 오히려 문서 화면 자체가 프리뷰 결과를 왜곡할 수 있는 것이다.



두 번째 문제는 레이아웃 간섭이다.

.preview-panel {
  display: flex;
  align-items: center;
  overflow: hidden;
  font-size: 13px;
  line-height: 1;
}

부모 컨테이너(.preview-panel) 안에서 프리뷰를 렌더하면, 더 이상 “컴포넌트가 독립적으로 실행된 모습”이라고 볼 수 없다.

이미 부모의 display, overflow, font-size, line-height에 영향을 받았기 때문이다.


결국 문제는 단순히 “프리뷰를 어디에 렌더할까?”가 아니었다.

더 정확히는 문서 UI와 프리뷰 컴포넌트가 같은 DOM/CSS 문맥을 공유해도 되는가의 문제이다.


(2) iframe은 DOM과 CSS 문맥을 분리해준다

그래서 선택한 방법이 iframe이었다. iframe은 부모 문서와 독립된 document를 가진다.

즉, 프리뷰를 부모 페이지의 <div> 안에 렌더하는 대신, iframe.contentDocument 안의 별도 문서에 렌더할 수 있다.

const targetDocument = iframe.contentDocument

이렇게 되면 프리뷰는 부모 문서의 DOM tree에 직접 섞이지 않는다.


구조를 단순화하면 아래와 같다.

문서 UI와 프리뷰 UI는 한 화면처럼 보이지만, 같은 DOM 문맥을 공유하지 않는다.

Host UI가 props, children, viewport 상태를 관리하고, Preview Runtime은 그 상태를 iframe 내부 문서에서 실행한다.

Host UI
= 문서를 보여주고 상태를 조작한다.

iframe Preview Runtime
= 전달받은 상태를 별도 document 안에서 실행한다.

다만 iframe을 사용한다고 해서 자동으로 컴포넌트를 렌더할 공간이 준비되는 것은 아니다.

프리뷰 런타임이 동작하려면 iframe 내부 문서 안에 실제 컴포넌트가 마운트될 지점이 필요하다.


(3) iframe 내부에 컴포넌트가 렌더링될 공간을 준비한다

iframe을 실행 경계로 쓰려면 먼저 iframe 내부 문서에 프리뷰용 HTML 구조를 준비해야 한다.

export function bootstrapPreviewDocument(targetDocument: Document) {
  // iframe 내부 document를 연다.
  targetDocument.open() 

  // React가 마운트될 #app을 만든다.
  targetDocument.write(`
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>
        <div id="app"></div> 
      </body>
    </html>
  `)

  // document를 닫는다.
  targetDocument.close()
}

여기서 핵심은 #app이다.

React는 결국 특정 DOM 노드에 컴포넌트를 렌더링한다.
따라서 iframe 내부 문서에도 React가 마운트될 DOM 노드가 필요하다.

const targetDocument = iframe.contentDocument

bootstrapPreviewDocument(targetDocument)

const mountTarget = targetDocument.getElementById('app')

이렇게 하면 프리뷰 컴포넌트는 부모 문서의 특정 <div>가 아니라, iframe 내부 문서에 만들어진 #app에 마운트된다.

즉, 프리뷰는 부모 DOM 안에 섞이는 것이 아니라, iframe 내부에 준비된 별도의 실행 공간에서 렌더링된다.


(4) 필요한 스타일만 의도적으로 주입한다

다만 iframe을 사용하면 한 가지 문제가 생긴다.

문서가 분리되기에 부모 페이지의 스타일이 자동으로 적용되지 않는다. 이건 장점이자 단점이다.

- 장점: 문서 UI의 전역 스타일이 프리뷰에 섞이지 않는다.

- 단점: 실제 앱에서 필요한 스타일도 자동으로 적용되지 않는다.

그래서 필요한 스타일은 의도적으로 iframe 내부에 복제해야 한다.

export function cloneAndInjectParentStyles(
  targetDocument: Document,
  sourceDocument: Document = document
) {
  const parentStyles = Array.from(
    sourceDocument.querySelectorAll(
      'link[rel="stylesheet"], style:not([scoped])'
    )
  )

  parentStyles.forEach(node => {
    targetDocument.head.appendChild(node.cloneNode(true))
  })
}

위 함수는 부모 문서의 stylesheet와 style 태그를 찾아 iframe 내부 문서의 <head>에 복제한다.

여기서 중요한 점은 “스타일을 그냥 공유한다”가 아니라 필요한 스타일을 의도적으로 다시 주입한다는 것이다.


(5) viewport 시뮬레이션이 가능하게 구성한다

iframe을 선택하면서 얻은 또 다른 장점은 viewport 시뮬레이션이었다.

프리뷰 영역의 너비를 바꿔 모바일, 태블릿, 데스크톱 상태를 확인하려면 여러 방법이 있다.
단순히 부모 컨테이너의 width를 줄일 수도 있다.

하지만 iframe을 사용하면 iframe 자체의 width를 바꿔 독립된 화면처럼 다룰 수 있다.

<iframe
  ref={iframeRef}
  style={{
    width: `${previewWidth}px`,
    height: '500px',
  }}
/>

이 방식은 반응형 컴포넌트를 확인할 때 직관적이다.

물론 이것이 실제 브라우저 viewport와 완전히 같다는 뜻은 아니다.
하지만 내부 컴포넌트 프리뷰 도구에서 레이아웃 반응을 확인하기에는 충분히 예측 가능한 방식이었다.


(6) 변경 시에는 다시 그리고, 이벤트는 위로 올린다

propschildren 변경시에는 iframe 내부 프리뷰를 다시 그리도록 구성했다.

그리고 iframe 안에서 발생한 이벤트는 주입한 핸들러를 통해 다시 바깥으로 올린다.


즉, 데이터 흐름은 다음과 같다.

props / children 변경
→ Host UI 상태 갱신
→ iframe 내부 프리뷰 재실행

event 발생
→ 주입된 핸들러 실행
→ Host UI로 이벤트 로그 전달

(7) 정리하면

핵심은 Host UI와 Preview Runtime의 실행 문맥을 분리하는 것이었다.

- Host UI
  : 문서를 보여주고 상태를 조작한다.

- Preview Runtime
  : 전달받은 상태로 컴포넌트를 실행한다.
  : iframe은 두 영역 사이의 DOM/CSS 경계를 만든다.
  : 필요한 스타일은 iframe 내부로 명시적으로 주입한다.
  : 컴포넌트는 iframe 내부의 mount target에 렌더링한다.

정리하면 iframe은 단순히 프리뷰를 보여주는 태그가 아니었다.

문서 UI와 프리뷰 실행 환경을 분리하고,

필요한 스타일만 다시 주입하고,

전달받은 상태를 기준으로 컴포넌트를 실행하는 런타임 경계였다.






2. 마치며…

이번 도구를 만들면서 가장 크게 배운 것은 iframe 사용법 자체가 아니었다.

처음에는 단순히 “망 분리 환경에서도 Storybook처럼 컴포넌트를 확인할 수 있는 화면을 만들자”에 가까웠다.

하지만 직접 구현해보니, 컴포넌트 프리뷰 도구는 생각보다 많은 일을 하고 있었다.

- 문서에서 정의한 props를 현재 상태로 바꾸고
- children을 편집 가능한 값으로 다루고
- 이벤트 핸들러를 주입하고
- 별도의 실행 공간에 컴포넌트를 mount하고
- 변경이 생기면 다시 실행하고
- 실행 결과와 이벤트를 다시 문서 UI로 올린다

이번 작업은 거창한 도구를 만든 경험이라기보다,

이미 익숙하게 쓰던 도구들의 내부 구조를 작게 재현해본 경험에 가까웠다.

그래도 이 구조를 작게 구현해보면서, Storybook이나 코드 편집기 같은 도구가

왜 단순한 문서나 화면이 아니라 실행 환경으로 설계되는지 조금은 이해하게 됐다.


반응형

댓글