logo
Published on

yarn classic은 workspace에서 어떻게 디펜던시를 관리할까

우리가 알고 있는 npm, yarn, pnpm과 같은 패키지 매니저에는 워크스페이스라는 기능이 있다. 워크스페이스를 설정하면, 모노레포를 구성할 수 있다. 모노레포는 여러 프로젝트를 하나의 레포지토리에서 관리하는 방식이다. 우리가 잘 아는 리액트 프로젝트도 모노레포로 관리된다.

이번 글에서는 yarn의 1.x 버전에 해당하는 yarn classic으로 워크스페이스를 만들고, 이 패키지 매니저가 디펜던시를 관리하는 방법을 알아본다.

워크스페이스 설정하기

예시로 들 모노레포의 폴더 구조는 최상단에 root-repo가 있고 apps 폴더 하위에 세 개의 패키지가 있는 구조이다. 앞으로 최상단에 있는 root-repo를 워크스페이스 루트라고 부를 것이다. 이 구조에서 yarn으로 어떻게 모노레포를 설정하는지 알아보자.

| root-root/
| ---- package.json
| ---- apps/
| -------- admin/
| ------------ package.json
| -------- api-server/
| ------------ package.json
| -------- extension/
| ------------ package.json

우선 워크스페이스 루트에 package.json 파일을 만든다. 그 파일에 workspacesprivate 필드를 추가해야 한다. workspaces에는 워크스페이스인 폴더를 명시하는데, 일일이 명시할 수도 있고 glob 패턴을 사용하여 명시할 수도 있다. 여기서는 apps/*로 명시해두었는데, 이는 apps 폴더 바로 하위에 있는 폴더가 워크스페이스라는 의미이다. private은 true 로 설정한다. 워크스페이스 루트는 패키지로서의 역할을 하지 않기에, 저장소에 퍼블리싱되는 것을 막아야 한다. 그렇게 하려면, private은 true로 설정해야 한다.

  • package.json
{
  "name": "yarn-repo",
  "private": true,
  "workspaces": ["apps/*"]
}

이제 워크스페이스 루트에서 yarn install을 하면, 워크스페이스 루트와 각 워크스페이스 폴더 안에 node_modules 폴더가 생긴다. 그리고 워크스페이스 루트에만 yarn.lock 파일이 생긴다. 패키지 매니저가 자동으로 관리해주는 lock 파일은 패키지들의 버전이나, 패키지가 의존하고 있는 디펜던시 등을 명시한 메타데이터가 적혀있다. lock 파일이 있기에 설치할 때 마다 패키지들의 버전은 동일하게 유지될 수 있다.

  • 모노레포 폴더 구조
| root-repo/
| ---- package.json
| ---- node_modules
| ---- yarn.lock
| ---- apps/
| -------- admin/
| ------------ package.json
| ------------ node_modules
| -------- api-server/
| ------------ package.json
| ------------ node_modules
| -------- extension/
| ------------ package.json
| ------------ node_modules

yarn classic의 패키지 관리 방식

npm, yarn, pnpm과 같은 패키지 매니저들은 디펜던시가 효율적으로 설치될 수 있도록 알아서 관리해준다. 셋 다 호이스팅 기법을 사용하여, 디펜던시들이 중복으로 설치되지 않게 해준다. 각 패키지 매니저마다 그 방식엔 차이가 있다. npm v3와 yarn classic은 node_modules 안에 모든 패키지를 같은 레벨로 설치한다. 흔히 평평(flat)한 node_modules 구조라고 한다.

평평한 node_modules 구조

아래 보이는 그림은 하나의 프로젝트 안에서의 호이스팅 전후에 따른 디펜던시 트리를 보여준다. 호이스팅 이전의 디펜던시 트리에서 A@1.0, B@1.0, B@2.0은 서로 다른 레벨에 있다. 호이스팅 이후에는 A@1.0B@1.0이 node_modules의 바로 하위에 같은 레벨로 위치하게 된다. 이렇게 호이스팅되어 같은 버전의 패키지는 중복 없이 설치된다.

위에서 yarn classic은 모든 패키지를 같은 레벨로 설치한다고 하였으나, 예외는 있다. 바로 호환될 수 없는 패키지의 경우이다. 예시에서 B@2.0은 같은 최상위에 있는 B@1.0과 같은 패키지이나 버전은 호환될 수 없다. 따라서 B@2.0D@1.0 패키지의 node_modules 폴더 안에 위치하게 된다.

이렇게 호환될 수 없는 디펜던시를 제외하고 모든 디펜던시를 최상위로 끌어올리는 방식은 모노레포 구조에서도 비슷하다. 각 패키지에서 의존하고 있는 디펜던시들이 워크스페이스 루트의 node_modules로 끌어올려진다. B@2.0은 최상위에 있는 B@1.0과는 버전이 달라서 호환될 수 없기에, package-1의 node_modules에 위치하게 된다. 그림에서 package-1package-2 또한 워크스페이스 루트에서 심볼릭 링크로 연결된 것을 알 수 있다.

yarn classic 모노레포에서 디펜던시 관리 방식

지금까지는 yarn classic으로 모노레포를 생성하였을 때의 디펜던시 관리 방식에 대해서 알아보았다. 이제는 admin, api-server, extension이라는 패키지를 갖고 있는 모노레포 구조에서 아래 3가지 케이스에 해당하는 디펜던시를 어떻게 관리하는지 알아보자.

  1. 한 패키지에서만 사용되는 디펜던시
  2. 여러 패키지에서 사용되고, 호환될 수 있는 디펜던시
  3. 여러 패키지에서 사용되지만, 호환될 수 없는 디펜던시

1. 한 패키지에서만 사용되는 디펜던시의 경우

admin, api-server, extension 패키지 중에서 api-server에서만 jest를 디펜던시로 갖고 있다고 하자. 그러면, jest는 최상단의 워크스페이스 루트의 node_modules로 끌어올려져서 설치된다.

| root-repo/
| ---- package.json
| ---- node_modules
| ------- jest # 최상단에 설치됨
| ---- yarn.lock
| ---- apps/
| -------- admin/
| ------------ package.json
| ------------ node_modules
| -------- api-server/
| ------------ package.json
| ------------ node_modules
| -------- extension/
| ------------ package.json
| ------------ node_modules

2. 여러 패키지에서 사용되는 디펜던시의 경우

이번에는 복수의 패키지에서 호환될 수 있는 디펜던시가 설치된 경우에, 어떻게 디펜던시가 설치되는지 알아보자.

아래 packge.json을 보면, adminextension 모두 react를 사용하고 있고, 리액트의 ^18.2.0^18.0.0을 사용함을 알 수 있다.

  • admin의 package.json
{
  "name": "admin",
  "dependencies": {
    "react": "^18.2.0"
  }
}
  • extension의 package.json
{
  "name": "extension",
  "dependencies": {
    "react": "^18.0.0"
  }
}

시맨틱 버저닝에서 버전은 MAJOR.MINOR.PATCH로 불린다. ^18.0.0처럼 버전 앞에 붙은 캐럿(^)은 해당 메이저 버전의 마이너와 패치 버전까지 하위호환성이 보장된다는 의미이다. 설치 시점에서 리액트 18의 가장 최신 버전이 18.2.0일 경우, ^18.2.0^18.0.018.2.0 버전과 호환될 수 있다. 만약 리액트 18의 가장 최신 버전이 18.3.1이라면, 그 버전이 설치 될 것이다.

시맨틱 버저닝에 따라 최상단의 node_modules에 설치되는 리액트는 react@18.2.0이 된다. 그리고 이 정보는 yarn.lock에서도 확인할 수 있다.

  • yarn.lock
react@^18.0.0, react@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
  dependencies:
    loose-envify "^1.1.0"
  • 폴더 구조
| root-repo/
| ---- package.json
| ---- node_modules
| ------- react # 최상단에 v18.2.0이 설치됨
| ---- yarn.lock
| ---- apps/
| -------- admin/
| ------------ package.json
| ------------ node_modules
| -------- api-server/
| ------------ package.json
| ------------ node_modules
| -------- extension/
| ------------ package.json
| ------------ node_modules

3. 여러 패키지에서 사용되지만, 호환될 수 없는 디펜던시

마지막으로 2개 이상의 패키지에서 공통으로 사용하고 있으나, 호환될 수 없는 디펜던시가 있는 경우엔 어떻게 설치되는지 확인해보자.

typescript는 세 패키지에서 모두 사용되고 있으나, 명시하고 있는 버전이 다르다.

- admin: ^5.0.2
- api-server: ^4.2.3
- extension: ^4.6.3

api-server와 extension은 타입스크립트 4 버전 중 가장 최신 버전인 4.9.5 버전으로 호환될 수 있다. 그래서 typescript@4.9.5은 워크스페이스 루트에 있는 node_modules에 설치된다. 반면 admin은 ^5.0.2 버전을 사용하는데, 4.9.5과는 메이저 버전이 다르기 때문에 호환될 수 없다. 이 경우엔 admin 패키지 하위에 있는 node_modules에 typescript 5 버전 중에서 설치 시점에서 가장 최신 버전인 5.1.6 버전이 설치된다.

  • yarn.lock
typescript@^4.2.3, typescript@^4.6.3:
  version "4.9.5"
  ...

typescript@^5.0.2:
  version "5.1.6"
  ...
  • 폴더 구조
| root-repo/
| ---- package.json
| ---- node_modules
| ------- typescript # 최상단에 v4.9.5가 설치됨
| ---- yarn.lock
| ---- apps/
| -------- admin/
| ------------ package.json
| ------------ node_modules
| --------------- typescript # v5.1.6이 설치됨
| -------- api-server/
| ------------ package.json
| ------------ node_modules
| -------- extension/
| ------------ package.json
| ------------ node_modules

유령 의존성

yarn은 의존성 중복 설치를 막기 위해 호이스팅 기법을 사용하지만, 평평한 node_modules 구조로 인해 한 가지 문제점이 생긴다. 바로 유령 의존성 문제이다. 유령 의존성이란 package.json에 명시하지 않은 패키지를 불러올 수 있는 것을 말한다.

예를 들어, api-server 프로젝트는 rxjs를 package.json에 명시하였으나, admin 프로젝트에서는 rxjs를 명시하지 않았다.

  • api-server/package.json
{
  "name": "api-server",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "rxjs": "^7.8.1",
    "typescript": "^4.2.3"
  }
}

하지만 admin 프로젝트 내에서 rxjs 패키지를 불러오더라도, 어떠한 에러 없이 정상적으로 실행된다.

반면 pnpm과 같이 유령 의존성 문제를 해결한 패키지 매니저를 사용하면, package.json에 명시되지 않은 rxjs를 불러올 때 에러가 발생한다.

만약 api-server 프로젝트에서 rxjs 패키지를 삭제한다면, rxjs를 유령 의존성으로 사용한 admin 프로젝트에서 갑자기 에러가 발생하게 된다. 이 문제를 해결하기 위해, pnpm은 평평하지 않은 node_modules 구조로 관리하고, yarn berry는 node_modules 폴더 자체를 만들지 않고 .pnp.cjs 파일에 의존성 정보를 관리한다.

참고 자료