Storybook에서 iframe으로 breakpoint별 화면 확인하기
예제 코드는 React + TypeScript 환경을 기준으로 작성했다.
Storybook에서 반응형 레이아웃을 확인할 때는 보통 viewport 기능을 사용하거나 브라우저 개발자 도구를 열어 화면 사이즈를 줄여가며 확인하고 있었다.
그런데 화면 사이즈만 줄여서는 확인하기 어려운 케이스가 있었다.
모바일, 태블릿, PC 레이아웃을 각각 독립된 Story로 만들어 확인할 방법이 없을까 찾아봤고, iframe을 이용하면 가능하다는 걸 알게 됐다.
정확히는 AI에게 질문해서 받은 답변이었다.
답변을 받고 구글링해봤지만 내가 원하는 것과 비슷한 사례는 거의 찾지 못했다.(내 검색 방법이 잘못된건지 모르겠지만 일단 나는 찾을 수 없었다..) 결국 AI에게 코드를 작성하게 한 뒤, 그 코드를 역으로 분석하기로 했고 그 결과 어느정도 이해 하게 되어 개인 공부 자료의 기록으로써 이 글을 작성하게 되었다.
왜 iframe으로 이 문제가 해결되는지, 코드에 들어간 srcDoc, createPortal, contentDocument, matchMedia는 각각 어떤 역할을 하는지 하나씩 찾아본 내용을 아래에 정리해보려고 한다.
iframe으로 viewport를 만들 수 있다고?
AI의 답변에서 가장 먼저 눈에 들어온 건 iframe이었다.
iframe? 이거 유튜브 동영상 복붙해서 넣을 때 사용하는 거 아닌가?

이미지 출처 : https://www.youtube.com/
딱 그 정도로만 알고 있었고, 실제로 어떤 역할을 하는지는 신경쓴적도 없었다.(애초에 유튜브에서 복붙할 일도 거의 없었고..)
찾아보니 iframe은 현재 페이지 안에 또 다른 HTML 문서를 넣는 요소였다. iframe 안에는 바깥 페이지와 별도의 window, document, viewport가 만들어진다.(일 하면서 회사에서 담당했던 프로젝트에서 우연히 유튜브 동영상을 첨부해야 했던 적이 있는데 사실 복붙해서 넣기만했지 딱히 iframe에 대해서 생각한적은 없다.)
여기서 이번 작업과 연결되는 부분은 별도의 viewport가 만들어진다는 점이었다.
<iframe style={{ width: 375, height: 500 }} />
iframe의 너비를 375px로 만들면 iframe 안의 문서는 375px viewport를 기준으로 렌더링된다. iframe 내부에 적용된 CSS media query도 이 너비를 기준으로 동작한다.
그렇다..! 즉 이걸 이용하면 스토리북에서도 원하는 브레이크 포인트별 테스트 케이스를 작성할 수 있다.
Mobile → 375px
Tablet → 768px
Desktop → 1200px
이렇게 하면 각각의 화면을 독립된 환경에서 확인할 수 있다. 이게 내가 원했던거다.
그런데 iframe 안에 Story는 어떻게 넣지?
iframe으로 viewport를 만들 수 있다는 건 이해했다.
그런데 iframe만 만든다고 React로 작성한 Story가 그 안에 들어가는 건 아니었다.
AI가 작성한 코드에는 srcDoc이 속성이 들어 있었다.
<iframe
srcDoc="<!doctype html><html><body></body></html>"
/>
srcDoc은 iframe 안에서 사용할 HTML을 직접 작성할 수 있는 속성이다.
보통 iframe의 src에는 불러올 페이지의 URL을 넣는다.
<iframe src="https://example.com"></iframe>
이번에는 외부 페이지를 보여주려는 게 아니라 Story를 넣을 빈 문서가 필요했고 그래서 src 대신 srcDoc으로 body만 있는 HTML 문서를 만들었다.(참고로 HTML에서는 srcdoc이지만 React JSX에서는 srcDoc으로 작성한다.)
createPortal은 왜 필요했을까?
iframe 안에 빈 body를 만들었으니 이제 그 안에 Story를 렌더링해야 한다.
여기서 AI가 작성한 코드에는 createPortal이 사용되고 있었다.
createPortal(children, frameBody);
createPortal은 이번에 처음 알게 된 기능이었다.
첫 번째 인자로 렌더링할 React 요소를 받고, 두 번째 인자로 실제 DOM이 들어갈 위치를 받는다.
createPortal(
렌더링할_React_요소,
렌더링할_DOM_위치,
);
이번 코드에서는 children으로 받은 Story를 iframe의 body에 렌더링하기 위해 사용한 것이었다.
createPortal을 이해할 때는 React 공식 문서와 React createPortal 이해하기 글을 같이 참고했다.
Portal은 DOM이 만들어지는 위치만 바꾼다 React 트리 자체가 iframe 쪽으로 옮겨지는 것은 아니라서 상위 Context와 React 이벤트는 기존 React 트리를 따라 동작한다.
iframe의 body는 어떻게 가져올까?
createPortal을 사용하려면 iframe의 body가 필요하다.
AI가 작성한 코드에서는 iframe에 ref를 연결한 뒤 contentDocument를 사용하고 있었다.
(contentDocument는 iframe 안에 들어 있는 HTML 문서의 document다.)
const frameBody =
iframeRef.current?.contentDocument?.body;
위의 코드를 iframe 안에 Story를 넣기 위해 필요한 부분만 합치면 다음과 같다.
import { type PropsWithChildren, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type Props = PropsWithChildren<{ width: number }>;
export const PreviewFrame = ({ width, children }: Props) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [frameBody, setFrameBody] = useState<HTMLElement | null>(null);
return (
<>
<iframe
ref={iframeRef}
title={`preview-${width}`}
srcDoc="<!doctype html><html><body></body></html>"
onLoad={() =>
setFrameBody(iframeRef.current?.contentDocument?.body ?? null)
}
style={{ width, height: 500, border: 0 }}
/>
{frameBody && createPortal(children, frameBody)}
</>
);
};
iframe이 로드되면 contentDocument에서 body를 가져와 상태에 저장한다. frameBody가 준비된 뒤에는 createPortal로 Story를 렌더링한다.
이 코드에서 contentDocument에 접근할 수 있는 건 srcDoc으로 만든 문서가 부모와 같은 origin으로 취급되기 때문이다.
다른 origin의 페이지를 src로 불러왔다면 same-origin policy 때문에 같은 방식으로 내부 문서에 접근할 수 없다.
Storybook에는 어떻게 연결할까?
iframe 안에 Story를 넣는 것까지 확인했으니 이제 Storybook에 연결할 차례다.
먼저 확인할 viewport 너비를 정했다.
const viewportWidth = {
mobile: 375,
tablet: 768,
desktop: 1024,
} as const;
type Viewport = keyof typeof viewportWidth;
이 값은 실제 프로젝트에서 사용하는 breakpoint에 맞게 바꾸면 된다.
Storybook에서는 Decorator를 이용해 Story를 PreviewFrame으로 감쌌다.
import type { Decorator } from '@storybook/react';
export const withViewportFrame: Decorator = (Story, context) => {
const viewport =
(context.parameters.previewViewport as Viewport) ?? 'desktop';
return (
<PreviewFrame width={viewportWidth[viewport]}>
<Story />
</PreviewFrame>
);
};
각 Story에서는 parameters로 사용할 viewport를 지정했다.
export const Mobile = {
parameters: { previewViewport: 'mobile' },
};
export const Tablet = {
parameters: { previewViewport: 'tablet' },
};
export const Desktop = {
parameters: { previewViewport: 'desktop' },
};
이제 각 Story를 열면 지정한 너비의 iframe 안에서 컴포넌트를 확인할 수 있다.
matchMedia는 왜 따로 처리했을까?
여기까지 보면 CSS media query는 iframe의 viewport를 기준으로 동작한다.
그런데 AI가 작성한 코드에는 이런 부분도 있었다.
const frameWindow = iframeRef.current?.contentWindow;
const frameMatchMedia =
frameWindow.matchMedia.bind(frameWindow);
처음에는 이 코드가 가장 이해되지 않았다.
iframe 너비도 지정했는데 왜 matchMedia를 따로 가져오지? bind(frameWindow)는 또 왜 붙는 거지?
JavaScript에서 window.matchMedia()를 사용하면 해당 window의 viewport를 기준으로 media query를 확인한다.
문제는 Portal로 컴포넌트를 iframe 안에 렌더링해도 전역 window가 자동으로 iframe의 window로 바뀌지는 않는다는 점이다.
iframe의 viewport를 기준으로 확인하려면 iframe 안의 window가 필요하다. 이 window는 contentWindow로 가져올 수 있다.
const frameWindow = iframeRef.current?.contentWindow;
const result =
frameWindow?.matchMedia('(max-width: 768px)');
contentDocument가 iframe 안의 document라면 contentWindow는 iframe 안의 window인 셈이다.
그렇다면 bind(frameWindow)는 왜 필요했을까?
const frameMatchMedia =
frameWindow.matchMedia.bind(frameWindow);
frameWindow.matchMedia()처럼 직접 호출할 때는 bind()가 필요하지 않다.
하지만 사용 중인 UI 라이브러리에 matchMedia 함수 자체를 전달하려면 frameWindow.matchMedia만 따로 꺼내야 한다. 이때 원래 호출 대상이었던 frameWindow가 빠지게 된다.
bind()를 사용하면 따로 전달한 함수가 계속 iframe의 window를 기준으로 실행되도록 연결할 수 있다.
// 직접 호출할 때
frameWindow.matchMedia(query);
// 함수만 다른 곳에 전달할 때
const frameMatchMedia =
frameWindow.matchMedia.bind(frameWindow);
라이브러리마다 matchMedia를 설정하는 방법은 다르기 때문에 실제 적용할 때는 사용 중인 라이브러리의 문서를 따로 확인해야 한다.
마무리
처음에는 Storybook에서 breakpoint별 화면을 독립적으로 확인하고 싶어서 AI에게 질문했다.
답변으로 받은 코드에는 iframe부터 srcDoc, createPortal, contentDocument, contentWindow, bind()까지 잘 모르던 내용이 한꺼번에 들어 있었다.
처음에는 코드가 동작한다는 것만 확인하고 넘어갈 수도 있었지만 단순히 복붙해서 하는거랑 하나씩 찾아보고 이해 하는건 전혀 다른 영역이고 이해하는게 중요하기에 이번 글을 작성하며 나중에라도 storybook에 또 다시 iframe을 써야하는 날이 오면 참고용으로 기록 하고 싶었다.
AI에게 받은 답변에서 시작했지만, 코드를 역으로 분석해보면서 왜 이 방법으로 breakpoint별 화면을 확인할 수 있는지까지 이해할 수 있었다(정말..?) 지금은 이해 했지만 아마 조만간에 까먹고 다시 이 글을 보게 될거 같은 느낌도 든다...ㅋㅋ
요즘 시간이 있을때 바이브 코딩을 많이 하는데 다음에는 바이브 코딩과 바이브 코딩을 하며 사용했던 AI에 대한 소감과 어떤 AI가 좋았는지에 대한 소감을 써보려 한다.



