Webpack → Vite: 스토리북의 번들러 마이그레이션

dev-redo
22 min readMar 21, 2024

--

현재 다니고 있는 회사에서 작성했던 테크 블로그 글을 가져왔습니다.

안녕하세요, 코르카 Frontend Engineer 홍승연입니다.

기존 코르카에서는 디자인 시스템을 스토리북으로 구축하기 위해 Storybook v6.5.16, Node v18, Yarn 1을 사용했으며, builder-webpack5 프레임워크를 통해 Webpack5로 스토리북을 빌드했습니다.

하지만 자사 테크팀은 Vite의 장점을 활용하고자 스토리북의 번들러를 Webpack에서 Vite로 마이그레이션하기로 결정했습니다. 이 과정에서 여러 이슈가 연쇄적으로 발생하여, 결과적으로 Storybook을 v6에서 v7로 버전업하고, 패키지 매니저를 Yarn 1에서 Pnpm으로 변경해야만 했습니다.

이 글에서는 Webpack에서 Vite로의 마이그레이션 회고 및 그 과정에서 이루어진 기술 스택 변경에 대해 공유하고자 합니다.

왜 마이그레이션 했나요?

개발 서버 구동 속도가 느린 Webpack

코르카에서 기존에 사용하던 Webpack은 번들러이기 때문에 개발 서버를 실행하는데 번들링하는 시간이 소요됩니다. 평균 Cold Start time이 14초 걸렸고, HMR(Hot Module Replacement)은 2~3초가 소요되었습니다. HMR로 변경된 파일만 업데이트하더라도 다시 번들링 하는 시간이 수 초가 걸렸기 때문에 개발 속도가 더뎠습니다.

이에 반해 Vite는 개발 서버에서 Webpack, Rollup 등 전통적인 번들러와 다른 방식으로 동작하기 때문에 구동 속도가 빠릅니다. 이어서 Vite가 개발 서버에서 어떻게 동작 하는지 자세히 설명하겠습니다.

Vite가 개발 서버 구동 속도가 빠른 이유

Vite는 Webpack과 Rollup 같은 전통적인 번들러보다 개발 서버에서 콜드 스타트와 HMR 속도가 빠릅니다.

Bundle based dev server vs Native ESM based dev server

먼저, 콜드 스타트 속도가 빠른 이유는 Vite가 디펜던시와 소스 코드를 별도로 처리하기 때문입니다. 디펜던시는 개발 중에 내용이 자주 바뀌지 않지만, 소스 코드는 빈번하게 수정됩니다. Vite는 이 점을 활용하여 Esbuild를 이용해 디펜던시를 사전에 번들링하고 캐싱합니다.

다음으로, HMR 속도가 빠른 이유는 Vite가 수정된 모듈만 Native ESM 방식을 이용해 브라우저에 전달하기 때문입니다. Native ESM은 브라우저에서 ES 모듈을 직접 불러올 수 있게 해주는 기능입니다. Vite는 개발 모드에서 Native ESM 기반으로 동작하므로, 어떤 모듈이 수정되면 브라우저는 Vite로부터 HTTP 요청을 보내 수정된 모듈만을 받아 교체합니다. 이때 의존성이 있는 패키지는 캐시에서 가져오므로 HMR 속도가 빨라집니다. 기존 번들러에서도 HMR을 지원하지만, Vite는 전 과정에서 ESM을 활용하므로 추가적인 번들링 프로세스 없이 소스 코드 갱신 속도가 빠릅니다.

이러한 점에서 Vite는 개발 서버를 구동할 때 모든 모듈을 분석하고 번들링하는 기존 번들러보다 콜드 스타트와 HMR 속도가 빨라지게 됩니다.

결과

Mackbook Pro 16, MacOS Ventura 기준

위 그래프는 로컬 환경에서 터미널로 Vite와 Webpack의 개발 서버 콜드 스타트(스토리북 preview 기준)와 HMR 시간을 측정한 결과입니다.

콜드 스타트 시간을 보면, 기존 번들러는 14초가 소요됐지만, Vite는 2.5초로 측정되어 약 82% 성능 향상되었습니다. 또한, HMR 속도에서도 기존 번들러는 3초가 걸리지만, Vite는 50ms로 측정되어 무려 98%나 성능 향상되었습니다.

마이그레이션 중에 만난 이슈들

1. OpenSSL version 에러

[문제 원인] Webpack 4와 OpenSSL 3 버전 호환 이슈

Webpack을 Vite로 마이그레이션 하는 과정에서 [Error: error:0308010C:digital envelope routines::unsupported]라는 에러가 발생했습니다. 해당 에러는 OpenSSL 오픈소스 라이브러리와 관련된 에러입니다.

OpenSSL은 현존하는 대부분의 대칭/비대칭 암호화 프로토콜을 구현한 오픈소스 라이브러리로, TLS/SSL 프로토콜 등의 암호화 기능을 제공합니다. Node.js는 기본적으로 OpenSSL 라이브러리를 내장하고 있지만, 버전 17 미만에서는 OpenSSL 1.x.x 버전을, 그 이후 버전부터는 OpenSSL 3 버전을 기본으로 사용합니다. 따라서 구버전 OpenSSL API를 사용하는 내부 패키지가 있다면, OpenSSL 버전 호환성 문제로 인해 에러가 발생할 수 있습니다.

OpenSSL API 버전 이슈를 해결하는 과정에서 변경된 기술 스택

Storybook v6에서는 Webpack 4를 디폴트 빌더로 이용하고, Webpack 5를 옵셔널하게 제공합니다.

Webpack4를 디폴트 빌더로 사용하는 Storybook v6

이전에 스토리북을 설치할 당시 Webpack 5를 선택했었고, Webpack ^5.9.0 를 사용하는 builder-webpack5를 이용했습니다. Webpack은 5.61.0 미만 버전까지는 구버전 OpenSSL을 이용하다가 그 이후부터는 OpenSSL 3을 사용하므로 에러가 발생하지 않았습니다.

Webpack 5.61.0 미만 버전에서 OpenSSL API 에러가 발생하는 이유

storybook은 6.4, 6.5 버전을 위해 Vite를 지원하는 서드파티 라이브러리 builder-vite를 제공합니다. builder-webpack5에서 builder-vite로 교체하는 과정에서 OpenSSL 3과의 호환성 문제로 인해 에러가 발생했습니다. 디폴트 빌더로 Webpack 4를 이용하고 있기 때문에 OpenSSL 3 버전과 호환되지 않아 에러가 발생한 것입니다.

[해결 방법] Storybook v6를 v7로 버전업

공식 레포지토리의 이슈에서는 해당 이슈를 해결하기 위해서는 아래 2가지 방법이 해답이 될 수 있다고 언급하고 있습니다.

첫 번째로 NODE_OPTIONS에 --openssl-legacy-provider 옵션을 추가하는 방법입니다. 해당 옵션은 레거시로 간주되는 API 사용 시 에러를 발생시키지 않고, 레거시에 대응하는 구현을 사용하도록 지시하는 옵션입니다. 해당 옵션을 주면 OpenSSL 버전을 1.x.x 버전으로 낮춘 것과 동일하게 동작하게 됩니다.

두 번째 방법은 Storybook v7을 이용하는 방법입니다. Storybook v7에서 Vite 프레임워크를 이용할 때 스토리북 내부적으로 Webpack4를 이용하지 않기 때문에 OpenSSL 에러가 발생하지 않게 됩니다.

첫 번째 방법인 NODE_OPTIONS로 OpenSSL 버전을 낮추는 것은 보안 취약점 문제가 있습니다. 다행히도 자사 디자인 시스템의 Storybook에서 사용한 문법들이 v7에서 단순한 인터페이스 변경만 있어 버전업 비용이 높지 않았습니다. 따라서 두 번째 방법인 Storybook v7로 버전업 하면서 OpenSSL 에러를 안전하게 해결했습니다. 또한 Storybook v7에서는 Vite 번들러를 사용하여 스토리북을 빌드하기 위해 react-vite 프레임워크를 제공하므로, 해당 프레임워크로 마이그레이션했습니다.

2. Storybook v7, Vite, Yarn1의 호환 이슈

[문제 원인] Yarn 1의 호이스팅으로 인해 버전이 다른 디펜던시 사용

기존 디자인시스템에서는 Yarn 1 패키지매니저를 사용하고 있었습니다. 그런데 Storybook v7과 react-vite를 이용해 스토리북을 실행하는 과정에서 다음과 같은 에러가 발생했습니다.

➜  cds git:(main) yarn storybook
yarn run v1.22.21
warning package.json: No license field
$ storybook dev -p 6006
🔴 Error: It looks like you are having a known issue with package hoisting.
Please check the following issue for details and solutions: <https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092>

/Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js:1
const stringWidth = require('string-width');
^

Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/seungyeonhong/project/corca/cds/node_modules/string-width/index.js from /Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js not supported.
Instead change the require of index.js in /Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js to a dynamic import() which is available in all CommonJS modules.

에러 메시지의 "It looks like you are having a known issue with package hoisting""require() of ES Module ... not supported."문구를 통해, 해당 에러가 Yarn 1의 패키지 호이스팅으로 인해 모듈 포맷 호환성 이슈가 발생하였음을 알 수 있습니다.

cli-table3가 string-width 모듈을 resolve하는 과정에서 모듈 포맷 호환성 이슈가 발생하는 이유

문제의 원인은 @isaacs/cliui 패키지에서 ESM 포맷의 string-width@5와 CommonJS(CJS) 포맷의 string-width@4를 string-width-cjs라는 alias로 사용하고 있기 때문입니다. Yarn 1은 중복 의존성 패키지를 루트 노드 모듈로 호이스팅합니다. string-width@4와 @5는 여러 패키지에서 중복해서 사용되는 sub-dependency이기 때문에 루트 노드 모듈로 호이스팅되었습니다.

호이스팅된 string-width 패키지 중에서 CJS 포맷의 cli-table3가 string-width@4에 접근하려 했지만, alias로 인해 동일 버전이 존재하지 않아 ESM 형식의 string-width@5를 resolve하게 되었습니다. 이렇게 cli-table3에서 의존성으로 명시되지 않은 string-width@5에 접근할 수 있는 이유는 Yarn 1의 패키지 호이스팅 때문에 팬텀 디펜던시로 사용될 수 있게 되었기 때문입니다.

패키지 호이스팅으로 인한 팬텀 디펜던시 B 1.0

팬텀 디펜던시란, 특정 패키지가 명시적으로 요구하지 않음에도 불구하고, 다른 의존성을 통해 간접적으로 사용되는 패키지를 의미합니다. 이 경우, cli-table3는 string-width@4를 필요로 하나, 해당 버전 대신 호이스팅된 string-width@5가 사용되어 모듈 호환성 문제가 발생하게 된 것입니다.

자세한 추론 과정은 아래 “부록 — Yarn 1의 패키지 호이스팅으로 인한 모듈 포맷 호환 이슈”를 참고해 주세요.

[해결 방법] Yarn 1을 Pnpm으로 마이그레이션

Storybook 공식 레포 이슈에서는 해당 팬텀 디펜던시 이슈를 해결하기 위해서는 아래 2가지 방법이 해답이 될 수 있다고 언급하고 있습니다.

Storybook 7과 Vite 호환 이슈 원인과 해결 방법

첫 번째로 jackspeak 패키지를 resolution을 이용해 2.1.1로 고정하는 방법입니다. 이슈에 따르면 jackspeak v2.1.2부터 해당 이슈가 발생하기 때문에, jackspeak를 2.1.1 버전으로 고정하면 에러가 해결된다고 언급하고 있습니다.

두 번째로 팬텀 디펜던시 이슈가 발생하지 않는 pnpm 또는 yarn berry 패키지 매니저로 마이그레이션하는 것입니다. 두 패키지 매니저는 내부적으로 독특한 방식으로 작동하여 프로젝트에 직접 설치되지 않은 패키지의 접근을 차단함으로써 팬텀 디펜던시 문제를 방지합니다.

  • Pnpm은 의존성을 node_modules를 직접 설치하지 않고, 전역 저장소에 각 패키지의 고유한 버전을 content-addressable 방식으로 저장합니다. 프로젝트에 패키지를 설치할 때, pnpm은 전역 저장소에서 해당 패키지 버전을 프로젝트 내 node_modules 폴더로 Symbolic Link(symlinks)를 생성해 참조합니다.
의존성이 content-addressable 저장소에 저장되는 Pnpm
  • Yarn berry는 PnP(Plug’n’Play) 방식을 채택합니다. 이 방식에서는 .yarn 디렉토리 내에 패키지를 가상화하여 설치합니다.

자사 디자인 시스템은 단일 레포로 구성되어 있으며, 컴포넌트와 스토리북으로만 구성되어 있었습니다. 또한 설치된 의존성 패키지 수도 많지 않았기 때문에, yarn에서 pnpm으로 패키지 매니저를 마이그레이션하는 데 큰 부담이 없었습니다. 따라서 논의 끝에 두 번째 방법인 패키지 매니저 마이그레이션으로 결정했습니다.

저희는 빠르게 마이그레이션 하길 희망하였기에, pnpm과 yarn berry 중에서 기존 npm과 yarn 1의 명령어와 node_modules / lock 파일 구조가 유사한 pnpm을 선택했습니다.

마이그레이션 이후, 마치며

스토리북 번들러 마이그레이션 후, 회사 슬랙에 공유

스토리북 번들러를 Webpack에서 Vite로 마이그레이션 하면서 개발 서버 구동 속도가 약 90% 이상 성능 향상되었습니다. 이로써 자사 프론트엔드 팀의 DX(Developer Experience)를 개선할 수 있었습니다.

하지만 마이그레이션 하는 과정에서 예상치 못한 에러들이 연쇄적으로 발생해 시행착오가 많았다는 점에서 아쉬움이 있었습니다. 다행히 사용되는 디펜던시가 적고, 단일 레포이기 때문에 마이그레이션이 수월했으나, 규모가 큰 애플리케이션이었다면 엄청난 대규모 공사가 되었을 거로 생각하니 아찔했습니다.

이번 경험을 통해 새로운 기술을 도입할 때는 단순히 성능이나 기능적 측면만 고려할 것이 아니라, 기존 레거시 기술 스택과의 호환성 및 마이그레이션 비용도 면밀하게 검토해야 한다는 것을 깨달았습니다. 이번 마이그레이션처럼 예기치 못한 사이드 이팩트로 인해 추가 작업이 많이 발생할 수 있기 때문입니다. 따라서 앞으로는 새로운 기술을 도입하기에 앞서, 발생할 수 있는 사이드 이팩트가 있을지 자세하게 찾아보고 도입했을 때의 장점과 마이그레이션 비용과의 trade-off를 계산해서 의사 결정을 해야겠다고 반성했습니다.

이 글이 스토리북 번들러를 Webpack에서 Vite로 마이그레이션 하는 것을 고려하는 개발자들에게 도움이 되기를 바랍니다. 감사합니다.

부록

Yarn 1의 패키지 호이스팅으로 인한 모듈 포맷 호환 이슈

➜  cds git:(main) yarn storybook
yarn run v1.22.21
warning package.json: No license field
$ storybook dev -p 6006
🔴 Error: It looks like you are having a known issue with package hoisting.
Please check the following issue for details and solutions: <https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092>

/Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js:1
const stringWidth = require('string-width');
^
Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/seungyeonhong/project/corca/cds/node_modules/string-width/index.js from /Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js not supported.
Instead change the require of index.js in /Users/seungyeonhong/project/corca/cds/node_modules/cli-table3/src/utils.js to a dynamic import() which is available in all CommonJS modules.

Storybook v7과 react-vite를 이용해 스토리북을 실행하면 위의 에러가 발생합니다. cli-table3가 참조하는 string-width가 cli-table3와 모듈 포맷이 달라서 발생하는 이슈처럼 보입니다. 에러가 발생한 원인을 하나씩 파헤쳐 보겠습니다.

cli-table3@^0.6.1:
version "0.6.3"
resolved "<https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2>"
integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==
dependencies:
string-width "^4.2.0"
optionalDependencies:
"@colors/colors" "1.5.0"

yarn.lock에서는 cli-table3 패키지는 string-width@4를 의존성으로 지정하고 있습니다. 공식 레포지토리를 살펴보면 cli-table3는 CJS 포맷으로 작성되었고, string-width는 5.0 미만 버전은 CJS, 5.0 이상 버전은 ESM 포맷으로 작성되었습니다. 따라서 cli-table3가 의존하는 string-width 4.2.0은 CJS 포맷이므로 모듈 호환성 문제가 발생하지 않을 것으로 예상됩니다.

하지만 콘솔에서 확인한 결과, 런타임 때 cli-table3에서 ESM 포맷의 string-width@5를 resolve 해 모듈 호환성 에러가 발생하는 것을 확인했습니다.

이렇게 yarn.lock에 지정된 버전과 실제 사용 버전이 다른 이유는 @isaacs/cliui 모듈 때문입니다. @isaacs/cliui는 storybook/react-vite의 하위 의존성으로, string-width의 ESM과 CJS 버전을 모두 설치하기 위해 string-width@4를 string-width-cjs라는 alias로 사용하고 있습니다.

"@isaacs/cliui@8.0.2":
version "8.0.2"
resolved "<https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550>"
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
dependencies:
string-width "^5.1.2"
string-width-cjs "npm:string-width@^4.2.0"
strip-ansi "^7.0.1"
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
wrap-ansi "^8.1.0"
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"

yarn.lock 파일을 확인해 보면 string-width와 string-width-cjs가 의존성으로 지정되어 있습니다. cli-table3에서 사용하는 string-width@4는 어디에 설치될까요? 생각한 가설은 아래 3가지였습니다.

  1. string-width@5를 사용한다.
  2. @cli-table3 내부에서 node_modules 디렉터리가 만들어지고 string-width@4이 새로 설치된다.
  3. @cli-table3에서 사용되는 string-width@4가 루트에 새로 설치되고, 해당 모듈을 이용한다.

먼저 결과를 알려드리자면, 정답은 위의 에러에서 유추할 수 있듯이 첫 번째였습니다.

가설을 검증하기 위해 @isaacs/cliui와 cli-table3만 설치한 결과, string-width@4는 string-width-cjs라는 이름으로 루트 디렉토리에 설치되며, string-width@5도 함께 설치됨을 확인했습니다.

"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0:
name string-width-cjs
version "4.2.3"
resolved "<https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010>"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "<https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794>"
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
dependencies:
eastasianwidth "^0.2.0"
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"

@isaacs/cliui에서 사용된 string-width-cjs(v4)와 string-width(v5)가 루트로 호이스팅된 것입니다. 이를 그림으로 표현하면 아래와 같습니다.

cli-table3는 CJS 모듈로, string-width@4를 디펜던시로 지정했지만, 호이스팅된 루트 node_modules에는 string-width@4 버전이 존재하지 않았습니다. 따라서 cli-table3에서는 의도치 않게 ESM 형식의 string-width@5를 팬텀 디펜던시로 참조하게 됩니다.

결과적으로 cli-table3는 디펜던시로 명시한 string-width@4 대신 팬텀 디펜던시인 string-width@5를 사용하게 되면서, CJS와 ESM 간 호환성 문제로 인해 에러가 발생하게 된 것입니다.

References

--

--