위 글은 yarn berry PnP모드로 모노 레포를 구축하다가 정말 어쩌다 보니 storybook에 오픈소스를 기여하게 된 과정을 정리하는 글입니다.
모노레포는 '하나의 저장소'를 의미하는 'Monolithic Repository'의 줄임말로, 많은 프로젝트를 단일 저장소에서 관리하는 방식을 말한다. 전통적으로 각 프로젝트나 라이브러리마다 별도의 저장소를 가지는 멀티 레포(Multi-repo) 방식과 대조된다.
무엇보다도 멀티 레포의 문제점을 개선, 코드 재사용성, 버전 관리, 종속성 관리 등의 장점이 있다. 모노레포에 대해서 더 자세하게 알고 싶다면 아래 링크들을 살펴보자.
모노레포에 대해 자세히 알고 싶다면
모노레포에 대해 - Naver D2
주니어 개발자가 알아야 할 모노레포 - Fast Campus
비효율적인 의존성 검색과 설치
NPM은 파일 시스템을 이용하여 의존성을 관리한다. 익숙한 node_modules 폴더를 이용하는 것이 특징이다. NPM은 패키지를 찾기 위해서 계속 상위 디렉토리의 node_modules 폴더를 탐색한다. node.js의 readdir, stat과 같은 느린 I/O 호출이 많기 때문에 탐색과 설치가 엄청 느리다.
유령 의존성
NPM 및 Yarn v1에서는 중복해서 설치되는 node_modules를 아끼기 위해 Hoisting기법을 사용합니다.
위의 그림처럼 기존에 A, C, D만 의존하고 있던 레포에서 Hoisting기법 때문에 B라이브러리도 귀신같이 사용할 수 있게 되었습니다.
yarn berry는 npm(yarn1)의 문제점을 보완, PnP(Plug'n'Play)전략을 사용하여 해결합니다.
Yarn Berry는 node_modules
를 생성하지 않습니다. 대신 .yarn/cache
폴더에 의존성의 정보가 저장되고, .pnp.cjs
파일에 의존성을 찾을 수 있는 정보가 기록됩니다. .pnp.cjs를 이용하면 디스크 I/O 없이 어떤 패키지가 어떤 라이브러리에 의존하는지, 각 라이브러리는 어디에 위치하는지를 바로 알 수 있습니다.
yarn/cache
.pnp.cjs
Yarn berry(PnP)에 대해 자세히 알고 싶다면
node_modules로부터 우리를 구원해 줄 Yarn Berry
따라서 효율적인 모노레포를 구성하기 위해서는 yarn berry(PnP모드)로 구성하는 것이 옳다고 판단했다.
위의 내용을 학습한 저는 효율적인 모노레포를 구성하기 위해서는 node_modules기반이 아닌 yarn berry(PnP)로 레포를 구성하는 것이 좋다고 판단하고 새로운 레포를 구성했다.
그런데 yarn berry(PnP)로 storybook
설치 시 node_modules가 생성되는 것이었다. 알고보니 storybook 설치 후 storybook UI가 실행되는 순간에 node_modules가 생성되는 것이었다.
설치를 잘 못한건지 yarn berry 버전이 따로 있는 건지 너무 혼란스러웠다. 심지어 공식문서를 아무리 뒤져봐도 아무런 설명이 존재하지 않았다.
그래서 문제의 이유를 다음과 같이 추론했다.
- Yarn Berry 모드에서 앱 실행 시 node_modules는 무조건 생성된다.
( 앱 실행 시yarn berry는 호환하지 못하는 의존성 문제 때문에 node_modules가 생성되는 것이라고 판단하였다 )
- Webpack이나 babel과 같은 번들러, 트랜스파일러 도구로 인해 생성되는 것이다.
사실 구글링과 yarn berry로 이루어진 많은 레포들을 찾아보면서 반례를 찾으려고 애를 많이 썼다. 그 결과CRA나 Vite와 같은 라이브러리에서는 앱 실행 시 node_modules가 생성되는 반면 해당 레포에서는 앱을 실행해도 node_modules가 생성되지 않았다.
CRA와 해당 레포의 가장 큰 차이점은 Webpack 커스텀 여부였다. 따라서 yarn eject를 한 CRA의 Webpack 파일과 해당 레포의 Webpack 파일을 비교하며 문제점을 찾아나갔다.
CRA에서는 앱 실행을 빠르게 하기 위해서 Webpack의 cache 설정을 사용하고 있었는데 cache의 저장소를 node_modules/.cache
라고 선언을 해버렸다. 따라서 해당 코드를 고치니 더 이상 node_modules가 생성되지 않는 모습을 볼 수 있었다. 자세한 건 cra-yarn-berry레포에서 확인하자.
CRA의 Webpack 코드 (path.js)
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp(buildPath),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
appJsConfig: resolveApp('jsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
// 여기 (그 외에도 바꿔야할 코드들이 더 있음...)
appNodeModules: resolveApp('node_modules'),
appWebpackCache: resolveApp('node_modules/.cache'),
appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'), /
swSrc: resolveModule(resolveApp, 'src/service-worker'),
publicUrlOrPath,
};
아래 코드는 storybook 라이브러리의 실제 코드 중 일부분이다.
import path from 'path';
import findCacheDirectory from 'find-cache-dir';
/**
* Get the path of the file or directory with input name inside the Storybook cache directory:
* - `node_modules/.cache/storybook/{directoryName}` in a Node.js project or npm package
* - `.cache/storybook/{directoryName}` otherwise
*
* @param fileOrDirectoryName {string} Name of the file or directory
* @return {string} Absolute path to the file or directory
*/
export function resolvePathInStorybookCache(fileOrDirectoryName: string, sub = 'default'): string {
let cacheDirectory = findCacheDirectory({ name: 'storybook' });
cacheDirectory ||= path.join(process.cwd(), '.cache', 'storybook');
return path.join(cacheDirectory, sub, fileOrDirectoryName);
}
주석에도 나와있듯이 node.js의 프로젝트일 경우 node_modules/.cache를 무조건 만든다는 코드가 존재한다.
결론부터 이야기하자면 할 수 있지만 매우 힘들지 않을까라고 생각하고 있다. 그 이유를 알기 위해서는 먼저 yarn berry(PnP)의 동작과정에 대해 조금 이해해보자.
yarn berry(PnP) 동작 과정
1. 작성된 코드들은 개발 서버를 통해 자동으로 번들링됩니다. 번들링 시 필요한 패키지들을 .pnp.cjs에서 확인 후 .pnp.loader.cjs의 resolve함수를 통해 압축된 파일을 풀고__virtual__
이라는 가상 메모리에 저장한다.
- 앱 실행 시
__virtual__
가상 메모리에 저장된 패키지들을 .pnp.loader.cjs의 load함수를 통해 로드합니다.
수정된 storybook 파일을 내 레포에 적용하기 위해서는 __virtual__
이라는 가상 메모리를 직접 수정해야한다. 하지만 가상 메모리를 우리가 직접적으로 접근할 수 있는 방법은 없다.
다른 방법으로는 zip파일을 바꾸는 방법이 있다. 하지만 zip파일을 압축해서 풀어본 결과, storybook코드가 압축된 채로 되어있어 코드 수정이 어려웠다.
따라서 라이브러리의 코드를 직접 수정하는 방법이 좋다고 판단하였다.
나와 똑같은 고민을 하고 기능을 개발 중인 사람이 있을 것 같아 Issue와 Pull Request를 꼼꼼히 찾아보았다. 3년 전에 저와 똑같은 고민을 한 분이 계셨으며 코드 수정을 요구한 Issue가 존재했다.
답변은 아래와 같았다.
요약하자면 pnp모드를 효과적으로 처리할 방법을 생각해내지 못했다는 내용이다. 대신 storybook팀에서
node_modules에 cache파일을 넣어도 충분하다는 결론을 내렸다는 내용이다.
3년전에 나눈 대화이기에 뒷 이야기가 궁금해서 github discussion을 활용해 뒷이야기를 직접 물어보았다.
대화 내용을 다시 요약하자면 node_modules로도 충분한 기능이 동작하고 별다른 문제가 없다고 판단한다.
대신 yarn pnp를 했을 경우 node_modules가 생성될 수 있다는 문구를 추가하면 어떨까라는 제안을 했더니 개발자 분들께서 너무 좋은 아이디어라고 바로 반영하겠다고 말씀하셨다. (node_modules가 생성된다는 이유로 2주 넘게 골머리를 앓았어서...)
그래서 이참에 나도 오픈소스 기여나 해보자하는 마음에 바로 Docs를 수정하는 Issue와 PR을 만들고 PR merge까지 받아냈다.
내가 직접 기여한 Storybook 공식문서 부분
node_modules
가 생성되는 것을 대수롭지 않게 여길 수도 있었지만 해당 문제를 끝까지 파고 들면서 CRA의 Webpack파일과 .pnp.loader.cjs
파일과 내부 구조를 꼼꼼히 파헤치면서 yarn berry의 세세한 동작과정과 CRA webpack 구조도 세세하게 알게 되어서 많이 배운 것 같다. 다음에는 오픈소스에 다양한 기능도 추가하는 그런 경험도 꼭 한번 해보고 싶다.
단순히 문제 인식에서 멈추지 않고 다른 분들 위해 기여하시는 모습이 멋지네요!