ghlee.dev
스토리북 캡처 도구를 만들며 알게된 것들
2025. 2. 2.이번에 블로그의 CSS 라이브러리를 변경하게 됐는데, 마이그레이션 할 일이 생기면 시각적 회귀 테스트를 진행 해보고 싶었다. 평소 예기치 못한 사이드 이펙트 없이 안전하게 마이그레이션하기에는 적합한 테스트 방법이라 생각했었다.
이전에 작성한 피드에서도 관련한 이야기를 했었다.
그리고 페이지 단위보다 컴포넌트 단위로 테스트를 진행하고 싶었다. 그래서 컴포넌트 단위로 문서화와 테스트가 용이한 스토리북을 도입하고 각 스토리를 캡쳐하여 Before와 After를 비교하는 시각적 회귀 테스트를 도입하게 되었다.
각 스토리를 캡쳐하기 위해 Storycap
이라고 하는 스토리북 애드온(Addon)이면서 CLI 도구인 라이브러리를 사용했는데, 현재 블로그에 적용하는 수준에서는 충분히 좋은 라이브러리였다.
다만 라이브러리 내부가 구 버전의 Puppeteer
를 기준으로 작성되어 있었는데, 이 점이 마음에 걸렸다. 상대적으로 범용성 있고 안정적인 Playwright
로 변경하고 싶었고, 그래서 Storycap
과 Storybook
을 분석하여 새롭게 도구를 제작하기로 결정했다.
이 글에서는 도구를 제작하면서 알게된 것들과 겪은 시행착오를 작성해보려고 한다.
개발 목표
Storycap
에서 아쉬웠던 것은 크게 두 가지다.
- 목적에 비해 잡다한 기능이 많다.
- 상대적으로 구형 라이브러리인
Puppeteer
를 사용한다.
개발 목표는 간단한데, 아래와 같다.
- 잡다한 기능을 쳐내고, 기본에 충실하게 만든다.
Playwright
로 마이그레이션한다.
목표로 하는 기능은 한가지로 간소화했다. 오로지 모든 스토리를 캡쳐하여 저장하는 기능 하나 뿐이다. 간소화한 기능을 우선 사용해보면서 추후 다른 기능이 필요해지면 하나씩 추가해 볼 생각이다.
도구를 개발하기 위해 필요한 사항
우선 Storycap
과 Storybook
의 코드를 분석했고, 이를 토대로 도구를 개발하기 위해 구현해야할 사항에 대해 정리했다. 정리한 것을 필수 사항과 선택 사항으로 나누어봤는데, 이는 다음과 같다.
- 커맨드라인 인터페이스(CLI)를 통해 실행한다.
- (선택) 스토리북 서버를 실행한다.
Playwright
기반으로 헤드리스 브라우저를 생성한다.- 헤드리스 브라우저로 스토리북 페이지에 접근하여 모든 스토리 정보를 추출한다.
- (선택) 스토리 캡쳐를 위한 병렬 실행 환경을 생성한다.
- 추출한 스토리 정보를 참고하여 각 스토리를 캡쳐한다.
- 캡쳐한 스토리를 파일 시스템을 이용하여 저장한다.
- 모든 캡쳐를 완료한 후 커맨드라인 인터페이스를 종료한다.
선택 사항은 두 가지인데, 그 중 스토리북 서버를 실행하는 기능은 추후에 넣기로 결정했다. 대신 CLI에서 URL을 인자로 받도록 하였다. 병렬 실행 환경은 없어도 Playwright
에서는 충분히 빠르지만, 스토리 개수가 매우 많고 실행 환경이 열악할 때를 감안하여 적용하기로 했다.
그 외에는 꼭 필요한 사항 뿐이라 모두 구현해야 했다. 이 중에서 구현하면서 알게된 내용 중 공유할만한 것을 적어보겠다.
스토리 정보 추출
각 스토리를 캡쳐하려면, 우선 모든 스토리 정보를 가져와야한다. 스토리북에서는 서드 파티 라이브러리를 개발하는 외부 개발자를 위해 숨겨진 API를 제공한다. 이는 문서에 있지 않아서 코드를 찾아보아야 했다.
우선 각 스토리에서 뷰어 페이지에 접근해야 하는데, 이는 iframe
으로 제공하고 있다.
개발자 도구를 통해 스토리 뷰어 페이지의
iframe
을 확인할 수 있다.
iframe
으로 이동하여 window
객체에서 __STORYBOOK_PREVIEW__
혹은 __STORYBOOK_STORY_STORE__
속성에 접근하면, 스토리북에서 제공하는 StoryStore
클래스를 가져올 수 있다.
구 버전 스토리북에서는
__STORYBOOK_CLIENT_API__
속성에 접근해야한다. 최신 버전에서는 Deprecated 되어있다.
StoryStore
클래스는 실제로 storyStoreValue
라는 변수에 담겨 사용되는데, storyStoreValue.cacheAllCSFFiles()
함수를 실행시키면, 모든 스토리 정보가 캐시된다. 그리고 storyStoreValue.extract()
함수를 사용하여 캐시된 스토리 정보를 추출할 수 있다.
이를 Playwright
와 함께 사용하려면 다음과 유사한 코드가 필요할 것이다.
import { chromium } from "playwright";
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const baseUrl = 'http://localhost:6006';
await page.goto(`${baseUrl}/iframe.html`, {
waitUntil: 'networkidle',
});
await page.evaluate(() => {
const api = window.__STORYBOOK_PREVIEW__;
api.storyStoreValue.cacheAllCSFFiles();
});
const stories = await page.evaluate(() => {
const api = window.__STORYBOOK_PREVIEW__;
return api.storyStoreValue.extract();
});
console.log(stories); // 모든 스토리 추출 결과
의사코드(PseudoCode)라서 이대로 실행되지 않을 수 있다.
각 스토리 뷰어 페이지로 이동하는 방법
추출한 스토리 정보를 이용해서 뷰어 페이지로 이동하는 방법은 두 가지가 있다.
iframe
내에서window.postMessage
를 이용해서setCurrentStory
함수를 한다.- 쿼리스트링을 이용하여
storyId
값을 전달한다.
두 방법은 장단점이 있지만, window.postMessage
를 이용하면 페이지 전체가 로드 되지 않기 때문에 성능에서 유리하다. 그에 반해 쿼리스트링 방법은 페이지가 새롭게 로드된다. 이는 스토리북에서 iframe
페이지의 내부 구현이 어떻게 되어있느냐에 따라 달라질 수 있는데, 현재는 window.postMessage
를 이용하면 추가적인 로드 없이 렌더링한다.
window.postMessage
를 통해 보내야할 메시지 구조는 다음과 같다.
const data = {
key: 'storybook-channel',
event: {
type: 'setCurrentStory',
args: [
{
storyId: 'components-ui-header--default', // storyId 예시
},
],
},
};
await page.evaluate((_data: typeof data) => {
window.postMessage(JSON.stringify(_data), '*');
}, data);
page
가 꼭iframe
내부로 이동한 상태에서 호출하여야 한다.
불필요한 유틸리티 제거
Storycap
을 분석하면서 느꼈지만, Puppeteer
를 사용하면서 페이지를 이동한 후 각 페이지가 안정적으로 캡쳐할 수 있는 상태인지 직접 확인하는 커스텀 로직이 많았다.
라이브러리의 기존 히스토리를 전부 파악할 수는 없다보니 이러한 로직을 만든 이유는 정확히 알 수 없지만, 일반적인 상식으로는 구 버전 Puppeteer
의 안정성이 상대적으로 부족하여 만들었나 싶기도 하다. 어디까지나 추측이다.
Playwright
를 사용하게되면 waitForLoadState
함수로 렌더링 후 다양한 상태를 확인할 수 있다.
// Playwright
await page.waitForLoadState('networkidle');
최신 버전의 Puppeteer
에서도 유사하게 가능은 하다.
// Puppeteer
await page.waitForNetworkIdle();
아래는 기존의 Storycap
에서 Puppeteer
의 metrics()
함수를 호출하여, 성능 측정 항목을 받아 브라우저의 렌더링이 완료 되었는지 확인하는 코드이다. 단순한 코드이니 참고삼아 보면 재미있다.
// https://github.com/reg-viz/storycap/blob/master/packages/storycrawler/src/browser/metrics-watcher.ts
class MetricsWatcher {
// ...
async waitForStable() {
for (let i = 0; i < this.count; ++i) {
if (await this.check()) return i;
await sleep(16);
}
return this.count;
}
private async check() {
const current = await this.page.metrics();
if (this.previous.length < this.length) return this.next(current);
if (this.diff('Nodes')) return this.next(current);
if (this.diff('RecalcStyleCount')) return this.next(current);
if (this.diff('LayoutCount')) return this.next(current);
return true;
}
private diff(k: keyof Metrics) {
for (let i = 1; i < this.previous.length; ++i) {
if (this.previous[i][k] !== this.previous[0][k]) return true;
}
return false;
}
private next(m: Metrics) {
this.previous.push(m);
this.previous = this.previous.slice(-this.length);
return false;
}
}
새로 만든 캡처 도구에서는 이러한 커스텀 방식의 안정화 방법을 사용하지 않는다.
또한 Puppeteer
에서는 스크린샷을 촬영할 때 CSS 애니메이션을 제거하기 위해 전체 노드의 애니메이션을 제거하는 별도의 CSS 파일을 만들고, 페이지를 생성할 때 캡쳐하기 앞서 CSS 파일을 별도로 넣어주었다. 이는 CSS 파일을 파싱하는 과정이 캡처 전체 과정 중 하나로 들어가므로 총 캡처 시간에 영향을 준다.
그와 다르게 Playwright
에서는 screenshot()
함수에서 애니메이션을 비활성화 할 수 있는 옵션을 제공하므로 위와 같은 과정이 필요없다.
// Playwright
await page.screenshot({
animations: "disabled",
});
병렬 스크린샷 구현
기존에 사용하던 Storycap
에서는 캡처 시간을 줄이기 위해 워커와 큐, 비동기 제네레이터를 이용하여 병렬 작업을 도입하고 있었다. 근데 새로 도구를 만들면서 Playwright
의 기본 기능만을 이용하여 캡쳐해보니 기존보다 캡처 시간이 절반이 줄어서, 병렬 작업을 굳이 넣어야하나 고민했다.
여기서 워커는 실제 워커가 아닌 코드 레벨에서의 가상의 개념이다.
다만 테스트 했던 환경은 성능이 괜찮은 노트북 환경이었고, 추후 클라우드 환경에서 자동화를 하려면 순차적인 동기 방식으로는 느릴 수 있겠다는 생각에 기존 Storycap
을 참고하여 병렬 작업을 새롭게 구성하였다.
기존 Storycap
과 다른 점으로는 기존에는 Puppeteer
의 브라우저 인스턴스를 여러개 생성하여 촬영하는 방식이었다. 새롭게 구현하면서는 브라우저 인스턴스를 하나로 가져가면서 페이지(탭)를 여러개 생성하여 메모리도 덜 잡아먹고, 촬영 시간도 단축되었다.
시각적으로 보이기 위해 임시로 헤드리스 옵션을 비활성화한 후 페이지(탭)가 4개로 구성된 병렬 작업을 촬영했다. 각 페이지는 하나의 큐에서 스토리 정보를 가져다가 캡쳐에 사용한다.
마치며
기본적인 기능 구현은 했지만, 앞으로 개선할 점이 매우 많다.
- Feature 별로 사용할 수 있으려면 특정 스토리를 필터링 하는 기능이 필요하다.
- 자동화를 위해 스토리북 서버를 실행하는 기능도 자체적으로 있으면 좋겠다.
Puppeteer
에서는 할 수 없었던Webkit
지원을 하고싶다.- 스토리 정보를 추출할 때 스토리북 버전 별로 안정적인 지원이 필요하다.
Github
에는 조만간 초기 코드를 공개 해놓은 후 차근차근 개발할 예정이고, 개선을 마친 후에 NPM
과 JSR
에 차례로 배포할 생각이다.