들어가며
현재 봄봄 프로젝트에서는 앱 패키지와 웹 패키지가 따로 관리되고 있다. 앱은 Expo 프레임워크를, 웹은 React.js 라이브러리를 사용하는데, 둘 다 TypeScript를 쓰고 있다는 공통점이 있었다. 그래서 자연스럽게 "둘이 공통으로 쓰는 코드들을 따로 빼내면 관리가 편하지 않을까?"라는 생각이 들었다.
기존에는 theme.ts(Emotion의 디자인 시스템)와 webview.ts(앱과 웹이 서로 주고받는 메시지 타입 정의) 같은 파일들이 각 패키지마다 중복으로 존재했다. 내용은 완전히 동일한데 두 곳에서 따로 관리하다 보니, 한 쪽을 수정하면 다른 쪽도 똑같이 수정해야 하는 상황이 반복됐다. 이런 관리 포인트가 점점 늘어나면서 "이제 모노레포로 가야겠다"는 결론을 내렸다.
마이그레이션 과정에서 생각보다 많은 오류를 만났는데, 그 중에서도 누구나 한 번쯤은 마주할 법한 의존성 문제 하나를 골라 정리했다.
마이그레이션에 대한 대략적인 소개
기존 폴더 구조는 심플했다.
- frontend(web)
- app
여기에 공통 패키지를 추가해서 이렇게 바뀌었다.
- frontend
- web
- app
- shared
그리고 frontend 폴더의 package.json에 workspace 설정을 추가했다.
// frontend/package.json
{
// ..
"workspaces": [
"web",
"app",
"shared"
],
}
발생한 오류 : Cannot find module ‘ajv/dist/compile/codegen’
workspace 설정을 추가하고 npm install을 실행했다. 문제없이 설치가 끝나서 이제 npm run start로 webpack 개발 서버를 띄우려는데... 위와 같은 에러가 터졌다.
처음에는 갑자기 왜 터지는 건지 감도 안 왔다. 하지만 마침 최근에 읽었던 npm deep dive 책 내용이 떠올랐고, 하나씩 곱씹으며 디버깅을 시작했다.
왜 문제가 발생한 걸까?
먼저 npm ls ajv 명령어로 ajv 패키지를 어디서 사용하고 있는지 확인해봤다. npm ls는 특정 패키지가 어떤 패키지들의 의존성으로 설치되어 있는지 보여주는 명령어다.
결과를 보니 ajv는 app 패키지의 eslint와 expo-build-properties, web 패키지의 @storybook/react-webpack, copy-webpack-plugin, eslint 등 정말 많은 곳에서 사용하고 있었다. 그것도 각기 다른 버전으로 말이다.
특히 눈에 띄는 건 빨간 글씨로 표시된 @storybook/react-webpack이었다. 이 패키지가 의존하는 ajv-keywords는 ajv 8.8.2 버전을 요구하는데, 실제로 설치된 버전은 6.12.6이라는 거였다.
그럼 실제로 node_modules에는 어떤 버전이 설치되어 있을까?
확인해보니 최상위 node_modules에 ajv 6.12.6 버전 딱 하나만 설치되어 있었다.
그런데 ajv-keywords의 package.json을 보면 8.8.2 버전을 요구하고 있다.
여기서 의문이 들었다. "왜 6.12.6 버전이 설치된 거지?"
이건 npm이 의존성을 설치하는 방식과 관련이 있다. npm은 중복 설치를 최소화하기 위해 가능한 한 평평한(flat) node_modules 구조를 만든다. 같은 패키지의 여러 버전이 필요한 경우, 가장 먼저 설치되는 버전을 최상위 node_modules에 두고, 나중에 설치되는 다른 버전들은 각 패키지의 하위 node_modules에 중첩해서 설치한다.
이런 설치 순서는 package-lock.json 파일에 기록되는데, 위 스크린샷을 보면 ajv를 검색했을 때 첫 번째로 나오는 게 6.12.4 버전이다. 그래서 6.12.x 버전이 최상위에 설치된 것이다.
근데 왜 6.12.4가 아니라 6.12.6이 설치됐냐면, ^ 기호는 주 버전(major version)만 고정하기 때문이다. 부 버전(minor)과 패치 버전(patch)은 최신 버전으로 올라갈 수 있다.

실제로 ajv의 GitHub 저장소를 확인해보면, major 버전 6 중에서는 6.12.6이 가장 최신이다.
그럼에도, ajv-keywords가 ajv@8.8.2 버전을 설치해서 해결되어야 하는 게 아닐까?
이론적으로는 맞는 말이다. npm은 평평한 구조를 유지하지만, 같은 패키지의 다른 버전이 필요하면 중첩된 node_modules 구조를 사용한다.
예를 들어보자. 패키지 A의 1버전과 2버전에 각각 의존하는 B와 C가 있다고 해보자. B를 먼저 설치하면 A의 1버전이 최상위 node_modules에 설치된다. 그 다음 C를 설치하면, A의 다른 버전을 요구하니까 C 패키지 안의 node_modules에 A의 2버전이 따로 설치되는 식이다.
그런데 ajv는 왜 이런 중첩 구조를 가지지 못했을까? 그 이유는 ajv-keywords의 package.json 설정 때문이다.
ajv-keywords는 ajv를 devDependencies에 가지고 있다. devDependencies는 개발 환경에서만 필요한 의존성이라, 우리가 ajv-keywords를 설치할 때 ajv를 함께 설치하지 않는다.
대신 ajv-keywords는 peerDependencies에 ajv 8.8.2 버전을 명시해서, "이 패키지를 쓰려면 ajv 8.8.2를 직접 설치해주세요"라고 알려주고 있는 것이다.
모노레포로 마이그레이션하기 이전까지는 왜 문제가 없었던 걸까?
이게 제일 궁금했다. 분명 전에는 잘 됐는데 왜 갑자기 안 되는 걸까?
답은 package-lock.json을 재생성하는 과정에 있었다. 모노레포로 마이그레이션하면서 package-lock.json을 새로 만들었는데, 이때 ajv의 설치 순서가 달라졌고, 6 버전이 먼저 설치되면서 버전 불일치가 발생한 것이다.
조금 더 파보면 이렇다. npm은 package-lock.json을 만들 때 의존성 트리를 훑으면서 패키지를 하나씩 최상위에 올릴지 결정하는데, 이 순서가 곳 "누가 먼저 자리를 차지하느냐"를 정한다. 그리고 이 순회 순서는 워크스페이스 목록이나 트리를 도는 방식(알파벳 순, 트리 깊이 등)에 영향을 받는다. 모노레포로 옮기면서 workspace 구성이 바뀌었고, lock 파일을 새로 생성하니 이 순회 순서 자체가 달라진 것이다.
기존에는 어쳤다 ajv 8 버전을 요구하는 쪽이 먼저 자리를 잡아 최상위에 8이 올라가 있었는데, 재생성하면서 6 버전을 쓰는 쪽이 먼저 순회되어 6이 최상위를 차지해버렸다. 코드는 한 줄도 안 건들였는데 lock 파일 재생성만으로 최상위 버전이 뒤바뀜 것이다. 이래서 lock 파일을 반드시 커밋하고 함부로 지우지 말라는 거구나 싶었다.
이를 해결하기 위해서는 어떻게 해야 할까?
첫 번째 방법은 package.json의 overrides를 쓰는 거다. 이 기능은 특정 패키지의 버전을 강제로 고정할 수 있다.
{
"overrides": {
"ajv": "^8.12.0"
}
}
이렇게 하면 모든 ajv를 8 버전으로 강제할 수 있다. 하지만 문제가 있다. ajv 6 버전을 사용하던 패키지들이 8 버전과 호환되지 않을 수 있다는 점이다.
overrides는 말 그대로 모든 곳의 ajv를 8로 덮어써버린다. 근데 원래 ajv 6에 맞춰 만들어진 패키지 입장에서는, 자기가 기대하던 6이 아니라 갑자기 8이 꽂히는 셀이다. 6과 8은 major가 다르니 API가 바뀜 부분이 있고, 그런 패키지는 설치는 되더라도 런타임에서 조용히 깨질 수 있다. 빌드는 통과했는데 실행하면 터지는, 제일 찾기 싫은 종류의 버그다. 그래서 overrides로 전부 밀어붙이는 건 눈앞의 에러만 덮는 임시방편처럼 느껴졌다.
두 번째 방법은 ajv를 직접 dependencies에 추가하는 것이다. 필요한 버전을 명시적으로 설치하면 peerDependencies 요구사항을 충족할 수 있다.
{
"dependencies": {
"ajv": "^8.12.0"
}
}
이렇게 하면 최상위에는 8 버전의 ajv가 설치되고, 6 버전이 필요한 web 패키지에는 별도로 6 버전이 중첩 설치된다.
그래서 아래처럼 의존성 문제를 깔끔하게 해결할 수 있다.
하지만 이 방법도 좀 찝찝하다. ajv는 우리가 직접 사용하는 패키지가 아닌데, dependencies에 명시적으로 추가해야 한다는 게 석연치 않았다.
결국 이 문제의 근본 원인을 생각해보니, npm의 평평한 node_modules 구조에서 비롯된 것 같았다. 게다가 ajv-keywords가 ajv를 dependencies가 아닌 devDependencies와 peerDependencies에만 명시한 것도 혼란을 가중시켰다.
pnpm으로의 전환
이 문제를 명쾌하게 해결하려면 어떻게 해야 할까? 우리가 내린 결론은 pnpm을 사용하는 것이었다.
pnpm은 버전이 다르면 각각 별도로 설치한다. 대신 중복 설치로 인한 용량 문제는 소프트 링크(symbolic link) 방식으로 해결한다. 유령 의존성 문제도 없고, 병렬 설치에 캐싱까지 돼서 설치도 빨랐다.
pnpm으로 패키지를 설치하고 node_modules를 살펴보면 확연히 다른 구조를 볼 수 있다.
node_modules 폴더에 .pnpm이라는 폴더가 생겼고, 여기에 ajv 6.12.6 버전과 8.17.1 버전이 모두 설치되어 있다. npm처럼 하나의 버전만 설치하는 게 아니라, 필요한 버전이 두 가지면 둘 다 설치하는 것이다.
물론 pnpm으로 갈아탄다고 마냥 공짜는 아니었다. pnpm은 소프트 링크로 node_modules를 구성하는데, Expo(React Native)는 이 심링크 구조를 잘 못 읽는 경우가 있다. 메트로 번들러가 링크를 따라가지 못해 모듈을 못 찾는 식이다. 그래서 앱 패키지 쪽은 node_modules를 npm처럼 평평하게 풀어주는 설정(pnpm의 node-linker=hoisted 같은 옵션)으로 맞추면 이 문제를 피할 수 있다. pnpm의 이점은 살리되, Expo가 기대하는 구조는 따로 챙기는 셀이다.
그럼 npm이 평평한 구조로 해결하려 했던 용량 문제는 어떻게 해결했을까?
node_modules에서 .pnpm 폴더를 제외하면 prettier, stylelint 같은 루트에서 직접 설치한 패키지들만 보인다. 그리고 각 패키지 폴더 옆에 화살표가 보이는데, 이건 실제로 여기 설치된 게 아니라 다른 곳을 참조하는 소프트 링크라는 의미다.
실제 설치 위치를 확인해볼까?
readlink로 해당 패키지가 실제로 설치된 위치를 확인할 수 있다.
해당 위치에 가보면 실제로 패키지가 설치되어 있다. 이런 식으로 pnpm은 실제 파일은 하나만 저장하고, 필요한 곳에서는 링크로 참조하는 방식으로 용량 문제를 해결한 것이다.
정리하면
처음 webpack 서버를 실행했을 때 Cannot find module 'ajv/dist/compile/codegen' 에러가 발생한 건, 말 그대로 ajv/dist/compile/codegen 파일이 없었기 때문이다. 이 파일은 ajv 8 버전에만 있는데, 우리 프로젝트에는 6 버전이 설치되어 있었다. npm ls ajv 명령어로 이 문제를 확인할 수 있었다.
8 버전이 설치되지 않은 이유는 npm이 peerDependencies에 명시된 패키지를 자동으로 설치하지 않기 때문이었다.
해결 방법으로는 ajv 8 버전을 명시적으로 설치하거나, overrides로 6 버전을 8 버전으로 덮어쓰는 방법이 있었다. 하지만 우리는 이런 임시방편 대신 pnpm을 선택했다. pnpm은 peerDependencies에 명시된 패키지도 제대로 설치하고, npm이 평평한 구조로 해결하려던 용량 문제를 소프트 링크로 깔끔하게 해결했다.
맺으며
책으로 공부했던 내용이 실제 문제 상황에서 딱 들어맞는 걸 보니 신기하면서도 재미있었다. 시간 가는 줄 모르고 문제를 파고들었는데, 그동안 배웠던 지식들이 마치 퍼즐 조각처럼 맞춰지는 기분이었다. 이론과 실무가 연결되는 순간이 바로 이런 거구나 싶었다.
모노레포를 마이그레이션한 추가적인 내용은 아래 PR에서도 확인해볼 수 있습니다.