Expo 와 Next.js 로 모노레포 환경을 구축하면서, 패키지 매니저로 Yarn Berry 를 선택했다. PnP, Zero-Install 로 의존성 관리 방식, CI 등을 개선해보고 싶었기 때문이었다. 무엇보다 PnP 모드가 타 패키지 매니저들에 비해 엄격한 의존성을 관리한다는 점도 마음에 들었다. 이전에 의존성 버전이 꼬여서 고생한 기억 때문이었다.
내가 기대했던 ‘엄격한 의존성 관리’ 란 다음과 같다.
- 유령 의존성 방지
- 로컬과 CI, 빌드 환경을 일치시키는 재현성
- 워크스페이스 간 버전 불일치나 의존성 규칙 위반 제약
실제로도 개발하면서 PnP 의 엄격함을 체감할 수 있었다. ESLint 공통 패키지를 만들던 중, 실수로 루트에만 설치하고 정작 Next.js 에선 설치하지 않았다. 이후 Next.js 에서 yarn run lint를 실행하는 순간 ESLint 를 찾을 수 없다는 에러를 뱉었다.
Usage Error: Couldn't find a script named "eslint".
만약 npm 이었다면 어땠을까? Node.js 의 기본 모듈 탐색 방식에 따라 상위 디렉토리(../node_modules)를 타고 올라가 루트에 있는 ESLint 를 찾아내 문제없이 실행되었을 것이다. Yarn Berry 의 엄격한 의존성 관리가 효과를 보여준 예시다.
Expo 와의 충돌
문제는 Expo 에서 발생했다. Next.js 에선 잘 동작하던 PnP 가 Expo 에선 동작하지 않았다. yarn run lint 를 실행하자 Metro 는 모듈을 찾지 못해 에러를 뱉었다.
error: Error: Unable to resolve module react-native from /Users/taewoongheo/monorepo/apps/expo/index.js: react-native could not be found within the project or in these directories:
node_modules
../../node_modules
> 1 | import { AppRegistry } from 'react-native';
| ^
2 | import App from './App';
3 | import { name as appName } from './app.json';원인을 찾아보니, Expo 에선 PnP 를 사용할 수 없었던 것이다. Metro Bundler 와 네이티브 모듈들은 구조적으로 물리적인 node_modules 경로를 전제로 동작하기 때문이었다.
물론 Metro 설정을 커스텀하여 PnP 를 지원하게 할 수도 있었지만, 이는 유지보수 복잡도를 지나치게 높인다고 판단했다. 중요한 건 제품을 만드는 것인데, 도구를 위해 일하는 주객전도가 될 거라 생각했다.
결국 PnP 모드 대신 node-modules 모드를 사용했다.
node-linker: node-modulesnode-modules 모드는 flat 구조의 node_modules 가 생성한다. 즉, npm v3, Yarn v1 과 동일한 구조를 갖게 되고 이는 유령 의존성 문제를 일으킬 수 있다. 처음에 기대했던 엄격한 의존성 관리에서 점점 멀어지고 있었다.
그렇다면 pnpm 은 어떨까?
pnpm 은 node_modules 를 평탄화하지 않고, 패키지들을 심볼릭 링크로 관리한다. 이를 통해 물리적으로 유령 의존성을 차단하고 디스크 공간을 크게 절약할 수 있다.
PnP 를 사용하지 못해 유령 의존성 문제가 발생한다면 차라리 물리적으로 유령 의존성을 차단하는 pnpm 을 사용하는게 낫지 않을까? 하지만 난 결국 Yarn 을 선택했다.
왜 pnpm 으로 가지 않았는가
pnpm 대신 여전히 Yarn 을 유지했다. 가장 큰 이유는 pnpm 의 물리적 구조를 Expo 에선 마찬가지로 사용할 수 없었기 때문이다.
Expo 환경에서 pnpm 을 사용하려면 shamefully-hoist=true 옵션을 사용해야 하는데, 이 옵션은 flat 구조의 node_modules 를 생성하는 것과 동일하다. 결국 다시 유령의존성 문제가 발생할 수 있다.
예를 들어 패키지 A, 패키지 B 가 하위 의존성으로 패키지 C 의 다른 버전을 요구한다고 해보자. flat 구조에선 호이스팅이 일어나 한 가지 버전만 top-level 로 올린다. 이렇게 되면 직접 설치하지 않은 패키지 C 를 직접 사용할 수 있게 된다.
즉, Expo 를 사용하는 한 어떤 도구를 써도 유령 의존성을 완벽하게 차단하기는 힘들었다.
어떤 패키지 매니저를 사용할 것인가
Expo로 인해 pnpm의 ‘물리적 구조 장점’이 무너진 상태에서, 파일 시스템에 의존하지 않고 프로젝트를 제어할 수 있는 논리적 제약이 필요했다. 이 지점에서 Yarn Berry가 빛을 발했다.
비록 flat 한 node-modules 구조로 회귀하더라도, Yarn 은 Yarn Constraints 와 Zero-Install 이라는 기능들을 제공한다. 나는 유령의존성을 완벽히 막지 못하는 상황을, Yarn 의 논리적 제약으로 최대한 보완하려 시도했다.
Yarn Constraints
모든 패키지의 의존성 버전 통일이나 package.json 필드 규칙 등을 코드로 강제할 수 있는 기능이다. 물론 pnpm도 pnpmfile.cjs 훅이나 syncpack 같은 서드파티 도구를 통해 비슷하게 구현할 수 있지만, Yarn Berry 는 이를 강력한 타입 시스템이 적용된 내장 API 로 제공한다는 점에서 차별화된다.
싱글톤 패키지, 개발 도구 등에 규칙을 적용해보았다. 아래 코드는 내 프로젝트 설정의 일부이다.
// @ts-check
const MUST_BE_PEER_IN_PACKAGES = [ // React ecosystem "react", "react-dom", // ...];
const SINGLETON_PACKAGES = { // Utility libraries "tailwind-merge": "^3.4.0", zod: "^4.1.13", // ...};
const DEV_TOOL_PACKAGES = { // TypeScript typescript: "~5.9.2",
// Prettier (eslint는 앱마다 다른 config 사용 가능) prettier: "^3.7.4", // ...};
module.exports = { async constraints({ Yarn }) { // 공유 패키지(packages/*)에서는 특정 패키지를 peerDependencies로만 사용 for (const packageName of MUST_BE_PEER_IN_PACKAGES) { for (const dependency of Yarn.dependencies({ ident: packageName })) { const workspacePath = dependency.workspace.cwd;
// packages/ 하위의 공유 패키지인 경우 if (workspacePath && workspacePath.startsWith("packages/")) { // dependencies로 선언된 경우 에러 (devDependencies는 개발용으로 허용) if (dependency.type === "dependencies") { dependency.error( `Shared package "${dependency.workspace.ident}" should use "${packageName}" as peerDependencies, not dependencies` ); } } } }
// 싱글톤 패키지 버전 일관성 검사 for (const [packageName, expectedVersion] of Object.entries( SINGLETON_PACKAGES )) { for (const dependency of Yarn.dependencies({ ident: packageName })) { if (dependency.type === "peerDependencies") { continue; }
dependency.update(expectedVersion); } }
// 개발 도구 버전 일관성 검사 for (const [packageName, expectedVersion] of Object.entries( DEV_TOOL_PACKAGES )) { for (const dependency of Yarn.dependencies({ ident: packageName })) { if (dependency.type === "peerDependencies") { continue; }
dependency.update(expectedVersion); } }
// ... },};설정 후 루트에서 아래 명령을 입력하면 각 워크스페이스의 package.json 의 버전이 자동으로 수정된다.
$ yarn constraints --fix아키텍처 규칙을 파편화된 스크립트가 아닌, 타입 안전성이 보장된 시스템으로 관리한다는 점에서 유지보수와 협업 측면에서 훨씬 유리하다고 판단했다.
Zero-Install
비록 node-linker: node-modules 설정 때문에 설치 과정(yarn install)이 필요해졌지만, Yarn Berry 의 캐시 시스템은 여전히 유효하다.
.yarn/cache 엔 패키지들이 zip 파일 형태로 저장되어 있다.
- PnP 모드라면, zip 에서 패키지를 직접 찾는다. 이때 node 파일 시스템을 Yarn 이 인터셉트하여 zip 파일을 직접 읽음
- node-modules 모드라면,
yarn install을 통해 zip 파일의 압축만 풀어서 node_modules 로 복사
나의 경우 node-modules 모드이기 때문에 엄밀한 의미의 Zero-Install 은 아니다. 하지만 네트워크 독립적이라는 장점이 있다. 이는 npm 레지스트리 장애나 네트워크 속도에 영향을 받지 않는 안정적인 CI/CD 파이프라인을 보장할 수 있다.
ESLint 룰
유령 의존성을 막을 방법을 찾아보다 import/no-extraneous-dependencies 룰을 발견했다. 이 룰은 package.json 에 직접 포함되지 않은 패키지를 불러올 시 에러를 발생시킨다. 이를 통해 유령 의존성을 한 층 단단하게 방어할 수 있게 되었다.
$ yarn add -D eslint-plugin-import{ "rules": { "import/no-extraneous-dependencies": "error" }}결론
지금까지 Expo + Next.js 모노레포 구조에서 Yarn 을 선택한 이유와 과정에 대해 정리해보았다. 엄격한 의존성 관리를 위해 여러 방법들을 시도했다. 이때 Expo 를 사용중이라면 완벽한 유령 의존성 방어는 힘들다는 결론이 나왔고, Constraints, Zero-Install, ESLint 룰 등을 통해 논리적 레벨에서 패키지 관리를 시도하려 노력했다.
이번 경험을 통해 여러 패키지 매니저들이 의존성을 관리하는 방법에 대해 배웠고, 나의 상황에 가장 적합한 패키지 매니저는 무엇인가 하는 깊은 고민을 할 수 있는 좋은 기회였다.