(번역) 모노레포 인사이트: Nx, Turborepo 그리고 PNPM (4/4 - pnpm 중점적인)

TapK·5일 전
1
post-thumbnail

원문: Monorepo Insights: Nx, Turborepo, and PNPM (4/4)

오늘날 최고의 모노레포 솔루션의 장단점을 살펴봅시다.

아래 링크를 통해 전체 시리즈를 살펴보세요.

소개

이 글은 Nx, PNPM, Turborepo의 기능, 성능, 프로젝트 적합성을 비교하는 시리즈의 일부입니다.

NxTurborepo의 내부 작동 방식과 주요 기능을 살펴본 후, 이제 PNPM에 주목해 보겠습니다. PNPM의 워크스페이스만으로도 우리의 요구 사항을 충족하고 개발 워크플로를 혁신할 수 있을 만큼 강력할까요?

다시 한 번 말씀드리지만, 저희의 궁극적인 목표는 개발을 간소화하고 코드베이스 관리를 강화할 수 있는 도구인 모노레포 챔피언을 선정하는 것입니다.

최고의 모노레포가 빛나길 바랍니다! 우리가 함께 정복할 도전은 다음과 같습니다.

전투는 계속됩니다! 우리가 함께 정복할 도전 과제는 다음과 같습니다.

다음 단계가 궁금하신가요? 함께 알아보시죠! 🚀 🌟

현미경으로 보는 PNPM

PNPM

pnpm(Performant NPM)은 속도, 효율성 및 디스크 공간 사용량에 대한 새로운 접근 방식을 취함으로써 npmYarn과 같은 기존 패키지 관리자와 차별화됩니다.

🔳 pnpm은 패키지를 글로벌 CAS(콘텐츠 주소 지정 가능 스토리지)에 저장합니다. CAS 디렉터리를 찾으려면 pnpm store path.


PNPM 로컬 스토어(작성자 이미지)

저장소(CAS)를 방문하면 모든 콘텐츠가 해시 이름으로 레이블이 지정되어 있는 것을 확인할 수 있습니다.

그리고 해시 파일 중 하나를 열면 종속성의 실제 내용이 표시됩니다.


PNPM 해시 파일 콘텐츠(작성자 이미지)

pnpm에서 파일은 파일 이름이 아닌 콘텐츠를 기준으로 저장 및 검색됩니다. 각 파일에는 식별자 역할을 하는 고유한 해시(Git 커밋 해시와 유사)가 할당됩니다. 이 해시는 파일의 콘텐츠를 기반으로 생성되므로 중복된 파일은 동일한 해시를 갖게 됩니다.

그런 다음 패키지를 설치할 때 pnpm은 먼저 글로벌 스토어(CAS)에 동일한 해시를 가진 파일이 이미 존재하는지 확인합니다.

  • 파일이 존재하는 경우: pnpm은 프로젝트의 node_modules/.pnpm 폴더에서 저장소(CAS)의 기존 파일에 대한 하드 링크를 생성합니다.
  • 파일이 존재하지 않는 경우: pnpm이 파일을 다운로드하여 CAS에 저장한 다음 하드 링크를 생성합니다.


https://x.com/HemSays/status/1434921646083563525/photo/1

🔵 종속성의 각 버전은 물리적으로 저장 폴더(CAS)에 한 번만 저장되므로 단일 소스를 제공하고 상당한 양의 디스크 공간을 절약할 수 있습니다.


https://blog.logrocket.com/javascript-package-managers-compared/

🔳 하드 링크는 원본 파일과 동일한 inode(파일의 고유 식별자)를 공유하므로 디스크의 동일한 데이터 블록을 직접 가리킵니다.

예를 들어 document.txt라는 파일과 report.txt라는 하드 링크가 있는 경우 두 이름 모두 동일하고 정확한 파일 콘텐츠를 가리킵니다. document.txt 또는 report.txt 중 하나를 수정하면 두 데이터 모두 변경됩니다.


https://www.scaler.com/topics/hard-link-and-soft-link-in-linux/

이 하드 링크 전략은 디스크 공간을 절약할 뿐만 아니라 파일을 복사하는 패키지 관리자에 비해 설치 및 업데이트 속도를 획기적으로 높여줍니다.

🔳 그런 다음 모든 패키지를 node_modules/.pnpm에 하드 링크한 후(CAS -> node_modules/.pnpm) 심볼릭 링크를 생성하여 중첩 종속성 그래프 구조를 구축합니다.

node_modules
└── .pnpm
    ├── pretty-format@27.5.1
    │   └── node_modules
    │       └── react-is -> ../../react-is@17.0.2  // symlink
    ├── pretty-format@28.1.3
    │   └── node_modules
    │       └── react-is -> ../../react-is@18.3.1 // symlink
    ├── prop-types@15.8.1
    │   └── node_modules
    │       └── react-is -> ../../react-is@16.13.1 // symlink
    ├── rc-util@5.43.0_react-dom@18.2.0_react@18.2.0
    │   └── node_modules
    │       ├── react-is -> ../../react-is@18.3.1  // symlink
    │       ├── react-dom -> ../../react-dom@18.2.0 // symlink
    │       └── react -> ../../react@18.2.0 // symlink
    ├── react-is@16.13.1
    ├── react-is@17.0.2
    ├── react-is@18.3.1
    ├── react-dom@18.2.0
    └── react@18.2.0

심볼릭 링크를 사용하는 이유는 무엇인가요? 세 가지 주요 이유 때문에 필수적입니다.

✔️ 호환성: Node.js와 많은 도구는 종속성이 node_modules 내에 중첩되기를 기대합니다. 심볼릭 링크는 이러한 중첩 구조의 착각을 불러일으키는 동시에 효율성을 위해 하드 링크를 활용합니다.

✔️ 유연성: 심볼릭 링크를 사용하면 여러 패키지에 동일한 종속성의 다른 버전이 필요할 수 있는 복잡한 종속성 시나리오를 pnpm에서 처리할 수 있습니다.

✔️ 효율성: 심링크는 파일을 복제하지 않고, 필요한 중첩 구조를 생성하여 디스크 사용량을 낮게 유지합니다.

🔳 패키지에 피어 종속성가 있는 경우 pnpm종속성 그래프에서 더 위에 설치된 패키지에서 이러한 종속성이 해결되도록 합니다. 다음 예시를 살펴보겠습니다.

node_modules
└── .pnpm
    ├── pretty-format@27.5.1
    │   └── node_modules
    │       └── react-is -> ../../react-is@17.0.2
    ├── pretty-format@28.1.3
    │   └── node_modules
    │       └── react-is -> ../../react-is@18.3.1
    ├── prop-types@15.8.1
    │   └── node_modules
    │       └── react-is -> ../../react-is@16.13.1
    ├── rc-util@5.43.0_react-dom@18.2.0_react@18.2.0
    │   └── node_modules
    │       ├── react-is -> ../../react-is@18.3.1
    │       ├── react-dom -> ../../react-dom@18.2.0
    │       └── react -> ../../react@18.2.0
    ├── react-is@16.13.1
    ├── react-is@17.0.2
    ├── react-is@18.3.1
    ├── react-dom@18.2.0
    └── react@18.2.0

pnpm은 설치를 복제하는 대신 올바른 버전의 종속성을 가리키는 심볼릭 링크를 생성하므로 시간, 대역폭 및 디스크 공간을 절약할 수 있습니다.

기존 node_modules 설정(예: npm 또는 Yarn classic)에서 여러 패키지가 동일한 종속성의 다른 버전에 의존하는 경우 해당 종속성은 node_modules에 중복됩니다.

node_modules
├── pretty-format@27.5.1
│   └── node_modules
│       └── react-is@17.0.2
├── pretty-format@28.1.3
│   └── node_modules
│       └── react-is@18.3.1 (first one)
├── prop-types@15.8.1
│   └── node_modules
│       └── react-is@16.13.1
└── rc-util@5.43.0
    └── node_modules
        ├── react-is@18.3.1 (second one)
├── react-dom@18.2.0
        └── react@18.2.0

보시다시피 react-is@18.3.1 은 여러 번 중복되어 불필요한 디스크 공간을 차지합니다.

🔳 node_modules/.pnpm 폴더 내 각 종속성 폴더 이름에는 다음과 같은 세부 정보가 인코딩됩니다.

  • 종속성 이름: 예: rc-picker
  • 종속성 버전: 예: 4.6.9
  • 피어 종속성: 패키지가 의존하는 피어 종속성의 이름 및 버전(예: rc-picker@4.6.9_date-fns@2.30.0_dayjs@1.11.11_luxon@3.4.4_moment@2.30.1_react-dom@18.2.0_react@18.2.0__react@18.2.0).


PNPM 종속성 메타데이터(작성자 이미지)

...
// https://github.com/react-component/picker/blob/master/package.json#L85

"peerDependencies": {
  "date-fns": ">= 2.x",
      "dayjs": ">= 1.x",
      "luxon": ">= 3.x",
      "moment": ">= 2.x",
      "react": ">=16.9.0",
      "react-dom": ">=16.9.0"
},
"peerDependenciesMeta": {
  "date-fns": {
    "optional": true
  },
  "dayjs": {
    "optional": true
  },
  "luxon": {
    "optional": true
  },
  "moment": {
    "optional": true
  }
}
...

이 세심한 구성은 pnpm이 종속성 호환성을 보장하고 잠재적인 충돌을 해결하는 데 도움이 됩니다.

🔳 이 단순화된 스키마는 pnpm이 종속성을 관리하는 방법을 요약한 것입니다.

PNPM Dependency Management - Schema

Central Store (CAS)
│
├── package1@version1
│   ├── package.json
│   ├── index.js
│   └── ... (other files)
├── package2@version2
│   ├── package.json
│   └── ...
└── ... (other packages)

Project Structure (node_modules/.pnpm)
│
├── package1@version1 (Hard Link)
│   └── node_modules
│       ├── dependency1 -> ../../../dependency1@versionX (Symlink)
│       ├── dependency2 -> ../../../dependency2@versionY (Symlink)
│       └── ...
├── package2@version2 (Hard Link)
│   └── node_modules
│       └── ...
└── ...
  • .pnpm 디렉터리 내의 각 패키지의 메인 폴더는 글로벌 스토어(CAS)에서 하드 링크됩니다.
  • 각 패키지의 node_modules 폴더 내에서 심볼릭 링크는 피어 종속성의 올바른 버전을 가리키는 데 사용됩니다.

🔳 다음 표는 pnpm과 경쟁사의 기능을 비교한 것입니다.


https://pnpm.io/feature-comparison

🔳 아래 벤치마크는 시간 성능과 다양한 상황에서 경쟁사 대비 pnpm의 순위를 보여줍니다.


https://pnpm.io/benchmarks#lots-of-files


https://pnpm.io/benchmarks#lots-of-files

단일 프로젝트를 관리할 때 pnpm의 효율성은 잘 알려져 있습니다. 하지만 서로 연결된 여러 프로젝트를 처리할 때 어떻게 확장할 수 있을까요? pnpm 워크스페이스을 자세히 살펴보고 여러 프로젝트 종속성을 동일한 효율로 관리할 수 있는지 알아봅시다.

PNPM 워크스페이스

🔳 워크스페이스의 루트에는 pnpm-workspace.yaml 파일이 있어야 합니다.

다음은 pnpm-workspace.yaml 파일의 내용 예시입니다.

packages:
  - 'packages/*'

catalog:
  '@babel/parser': ^7.24.7
  '@babel/types': ^7.24.7
  'estree-walker': ^2.0.2
  'magic-string': ^0.30.10
  'source-map-js': ^1.2.0
  'vite': ^5.3.3

여기를 살펴보시면 워크스페이스 사용의 실제 예시 코드를 확인할 수 있습니다.

🔳 워크스페이스의 루트에 .npmrc가 있을 수도 있습니다. .npmrc 파일에 워크스페이스에 대한 많은 구성 옵션을 추가할 수 있습니다.


pnpm 워크스페이스 구성(작성자 이미지)

다음은 .npmrc 파일의 내용 예시입니다.

# https://github.com/withastro/astro/blob/main/.npmrc
# 중요! 새 버전이 레지스트리에 있어도 `astro`를 설치하지 마십시오.
prefer-workspace-packages=true
link-workspace-packages=true
save-workspace-protocol=false # 이렇게 하면 예제에 `workspace:` 접두사가 붙지 않습니다.

🔳 pnpm 워크스페이스 내에서 패키지를 참조하려면 두 가지 주요 옵션이 있습니다.

✔️ 개별 package.json 파일 내에 워크스페이스 패키지의 별칭을 생성한 다음 다른 패키지의 package.json 파일에서 이 별칭을 종속성으로 참조할 수 있습니다.

다음은 MUI의 실제 예시입니다. 두 개의 횡단 패키지인 mui/systemmui/types를 정의합니다.

// https://github.com/mui/material-ui/blob/next/packages/mui-system/package.json#L2
{
  "name": "@mui/system",
  "version": "6.0.0-beta.1",
  "private": false,
  "author": "MUI Team",
...

// https://github.com/mui/material-ui/blob/next/packages/mui-types/package.json#L2
{
  "name": "@mui/types",
  "version": "7.2.14",
  "private": false,
  "author": "MUI Team",

이 패키지는 다음과 같이 참조됩니다.

....
"dependencies": {
    "@mui/core-downloads-tracker": "workspace:^",
    "@mui/system": "workspace:*",
    "@mui/types": "workspace:^",
    "@mui/utils": "workspace:*",
...
  },
 "devDependencies": {
    "@mui/internal-babel-macros": "workspace:^",
    "@mui/internal-test-utils": "workspace:^",
...

별칭으로 작성된 종속성의 특정 버전 또는 범위를 지정할 수도 있습니다.

{
  "dependencies": {
    "foo": "workspace:*",
    "bar": "workspace:~",
    "qar": "workspace:^",
    "zoo": "workspace:^1.5.0" // 특정 버전 지정
  }
}

✔️ 상대 경로: 모노레포 내에서 상대 경로를 사용하여 워크스페이스 패키지를 참조할 수도 있습니다. 예를 들어, “foo”: “workspace:../foo"는 종속성을 선언하는 패키지와 상대적인 형제 디렉터리에 있는 foo 패키지를 참조합니다.

🔳 작업 영역 패키지를 게시할 준비가 되면 pnpm은 로컬 workspace: 의존성 참조를 표준 SemVer(Semantic Versioning) 범위로 자동 변환합니다. 이렇게 하면 게시된 패키지를 다른 프로젝트에서 원활하게 사용할 수 있으며, 심지어 pnpm을 사용하지 않는 프로젝트에서도 사용할 수 있습니다.

예를 들어, 다음과 종속성 같은 종속성이 있습니다.

{
  "dependencies": {
    "foo": "workspace:*", // 워크스페이스의 모든 ‘foo’ 버전
    "bar": "workspace:~", // ~1.5.0(1.5.x에 해당)
    "qar": "workspace:^", // ^1.5.0(1.x.x와 호환)
    "zoo": "workspace:^1.5.0" // 위와 같음
  }
}

위 종속성은 아래와 같이 변환됩니다.

{
  "dependencies": {
    "foo": "1.5.0", // 정확한 버전
    "bar": "~1.5.0",
    "qar": "^1.5.0",
    "zoo": "^1.5.0"
  }
}

이제 게시된 패키지를 설치하는 모든 사람은 워크스페이스가 설정되어 있지 않더라도 올바른 버전의 종속성을 얻게 됩니다.

🔴 pnpm 작업 영역에는 내장된 버전 관리 기능(예: lerna 또는 npm)이 포함되어 있지 않지만 ChangesetsRush와 같은 기존 도구와 쉽게 통합할 수 있습니다.

pnpm 작업 영역의 순환 종속성으로 인해 스크립트 실행 순서가 예측할 수 없게 될 수 있습니다. 설치 중에 이러한 순환이 감지되면 pnpm에서 경고를 표시합니다. 문제가 있는 패키지는 pnpm에 의해 식별될 수도 있습니다. 이 경고가 발생하면 관련 package.json 파일에서 dependencies, optionalDependenciesdevDependencies에 선언된 종속성을 검사해야 합니다.

다행히도 우리는 순환 종속성에 대해 이야기하고 있습니다. 다음 섹션에서는 종속성 그래프, 유향 비순환 정렬 등 pnpm에 대해 자세히 살펴보겠습니다.

PNPM 그래프

🔳 pnpm은 그래프 관리를 위해 다음과 같은 내부 라이브러리를 사용합니다.

  • graph-builder: 이 라이브러리는 pnpm-lock.yaml 파일에서 종속성 그래프를 구성하는 역할을 담당합니다.
  • graph-sequencer: 이 라이브러리는 그래프에서 패키지를 정렬하기 위해 유향 비순환 정렬 알고리즘을 구현합니다.

🔳 PNPM은 주로 DAG를 사용하여 패키지 간의 종속성 관계를 모델링합니다. 이 그래프는 pnpm-lock.yaml 파일의 dependencies, devDependenciesoptionalDependencies 필드를 기반으로 구성됩니다(const currentPackages = currentLockfile?.packages ?? {}).

// https://github.com/ahaoboy/pnpm/blob/main/deps/graph-builder/src/lockfileToDepGraph.ts#L91C1-L108C1
export async function lockfileToDepGraph (
    lockfile: Lockfile,
    currentLockfile: Lockfile | null,
    opts: LockfileToDepGraphOptions
): Promise<LockfileToDepGraphResult> {
  const currentPackages = currentLockfile?.packages ?? {}
  const graph: DependenciesGraph = {}
  const directDependenciesByImporterId: DirectDependenciesByImporterId = {}
  if (lockfile.packages != null) {
    const pkgSnapshotByLocation: Record<string, PackageSnapshot> = {}
    await Promise.all(
        Object.entries(lockfile.packages).map(async ([depPath, pkgSnapshot]) => {
          if (opts.skipped.has(depPath)) return
          // TODO: 최적화: 이 정보는 이미 pkgSnapshotToResolution()에서 반환할 수 있습니다.
          const { name: pkgName, version: pkgVersion } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
          const modules = path.join(opts.virtualStoreDir, dp.depPathToFilename(depPath), 'node_modules')
          const packageId = packageIdFromSnapshot(depPath, pkgSnapshot, opts.registries)

        ...
          const dir = path.join(modules, pkgName)
          const depIsPresent = !('directory' in pkgSnapshot.resolution && pkgSnapshot.resolution.directory != null) &&
              currentPackages[depPath] && equals(currentPackages[depPath].dependencies, lockfile.packages![depPath].dependencies)
          let dirExists: boolean | undefined
          if (
              depIsPresent && isEmpty(currentPackages[depPath].optionalDependencies ?? {}) &&
              isEmpty(lockfile.packages![depPath].optionalDependencies ?? {})
        ) {
            dirExists = await pathExists(dir)
            if (dirExists) {
              return
            }

            brokenModulesLogger.debug({
              missing: dir,
            })
          }
          let fetchResponse!: Partial<FetchResponse>
          if (depIsPresent && equals(currentPackages[depPath].optionalDependencies, lockfile.packages![depPath].optionalDependencies)) {
            if (dirExists ?? await pathExists(dir)) {
              fetchResponse = {}
            } else {
              brokenModulesLogger.debug({
                missing: dir,
              })
            }
          }
...

pnpm-lock.yaml의 예제를 살펴봅시다.

packages:
  "@adobe/css-tools@4.4.0":
    resolution:
      {
        integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==,
      }

  "@ampproject/remapping@2.3.0":
    resolution:
      {
        integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==,
      }
    engines: { node: ">=6.0.0" }

  "@ant-design/colors@7.1.0":
    resolution:
      {
        integrity: sha512-MMoDGWn1y9LdQJQSHiCC20x3uZ3CwQnv9QMz6pCmJOrqdgM9YxsoVVY0wtrdXbmfSgnV0KNk6zi09NAhMR2jvg==,
      }

  "@ant-design/cssinjs@1.21.0":
    resolution:
      {
        integrity: sha512-gIilraPl+9EoKdYxnupxjHB/Q6IHNRjEXszKbDxZdsgv4sAZ9pjkCq8yanDWNvyfjp4leir2OVAJm0vxwKK8YA==,
      }
    peerDependencies:
      react: ">=16.0.0"
      react-dom: ">=16.0.0"
---
"@jest/reporters@28.1.3":
  resolution:
    {
      integrity: sha512-JuAy7wkxQZVNU/V6g9xKzCGC5LVXx9FDcABKsSXp5MiKPEE2144a/vXTEDoyzjUpZKfVwp08Wqg5A4WfTMAzjg==,
    }
  engines: { node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0 }
  peerDependencies:
    node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
  peerDependenciesMeta:
    node-notifier:
      optional: true

"@jest/reporters@29.7.0":
  resolution:
    {
      integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==,
    }
  engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 }
  peerDependencies:
    node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
  peerDependenciesMeta:
    node-notifier:
      optional: true

---
devDependencies:
  "@babel/core":
    specifier: "=7.18.6"
    version: 7.18.6
  "@babel/eslint-parser":
    specifier: "=7.24.6"
    version: 7.24.6(@babel/core@7.18.6)(eslint@9.0.0)
  "@babel/preset-env":
    specifier: "=7.23.6"
    version: 7.23.6(@babel/core@7.18.6)
  babel-jest:
    specifier: "=29.7.0"
    version: 29.7.0(@babel/core@7.18.6)

🔳 findCycle 함수는 방향이 지정된 그래프에서 주기를 감지하는 고전적인 알고리즘입니다. 이 함수는 큐 기반 접근 방식(BFS, Breadth-First Search)을 사용하여 주어진 노드(startNode)에서 시작하여 그래프를 탐색합니다. 탐색 중에 startNode를 다시 만나면 사이클이 존재한다는 뜻입니다.

// https://github.com/pnpm/pnpm/blob/main/deps/graph-sequencer/src/index.ts#L99
function findCycle (startNode: T): T[] {
  const queue: Array<[T, T[]]> = [[startNode, [startNode]]]
  const cycleVisited = new Set<T>()
  const cycles: T[][] = []

  while (queue.length) {
    const [id, cycle] = queue.shift()!
    for (const to of graph.get(id)!) {
      if (to === startNode) {
        cycleVisited.add(to)
        cycles.push([...cycle])
        continue
      }
      if (visited.has(to) || cycleVisited.has(to)) {
        continue
      }
      cycleVisited.add(to)
      queue.push([to, [...cycle, to]])
    }
  }

  if (!cycles.length) {
    return []
  }

  cycles.sort((a, b) => b.length - a.length)
  return cycles[0]
}

...
// https://github.com/pnpm/pnpm/blob/main/deps/graph-sequencer/src/index.ts#L66
const cycleNodes: T[] = []
for (const node of nodes) {
  const cycle = findCycle(node)
  if (cycle.length) {
    cycles.push(cycle)
    cycle.forEach(removeNode)
    cycleNodes.push(...cycle)

    if (cycle.length > 1) {
      safe = false
    }
  }
}
  chunks.push(cycleNodes)
}

🔳 종속성 그래프가 구성되면 pnpm은 위상 정렬 알고리즘을 사용하여 패키지를 처리할 올바른 순서를 결정합니다. 이렇게 하면 패키지의 종속성이 항상 패키지 자체보다 먼저 처리되도록 보장합니다.

/**
 * 노드 제한을 지원하면서 그래프에서 유향 비순환 정렬을 수행합니다.
 *
 * @param {Graph<T>} graph - 키는 노드이고 값은 간선의 가장자리인 맵으로 표현된 그래프입니다.
 * @param {T[]} includedNodes - 정렬 프로세스에 포함해야 하는 노드의 배열입니다. 다른 노드는 무시됩니다.
 * @returns {Result<T>} safe, chunk, cycles를 포함한 정렬 결과가 포함된 객체입니다.
 */
export function graphSequencer<T> (graph: Graph<T>, includedNodes: T[] = [...graph.keys()]): Result<T> {
  // 모든 노드에 대해 빈 배열로 reverseGraph를 초기화합니다.
  const reverseGraph = new Map<T, T[]>()
  for (const key of graph.keys()) {
    reverseGraph.set(key, [])
  }

...

🔴 pnpm의 주요 초점은 효율적인 종속성 관리에 있으며, DAG와 유향 비순환 정렬을 사용하여 패키지 종속성을 올바르게 설치하고 해결할 수 있습니다. 그러나 작업 실행(package.json에 정의된 스크립트)과 관련하여 pnpm은 본질적으로 엄격한 유향 비순환 순서를 강요하거나 Nx 또는 Turborepo와 같은 도구와 같은 캐싱 메커니즘을 제공하지 않습니다.

pnpm에는 NxTurborepo와 같은 작업 오케스트레이션 시스템이 내장되어 있지는 않지만, 워크스페이스의 여러 패키지에서 작업을 쉽게 실행할 수 있는 여러 명령어와 옵션을 제공합니다.

예시: 병렬 빌드 및 미리보기 스크립트

"scripts": {
  "build": "pnpm  --parallel --filter \"./**\" build",
  "preview": "pnpm  --parallel --filter \"./**\" preview"
},

이 예에서는 buildpreview 스크립트가 워크스페이스 내의 모든 패키지에 걸쳐 병렬로 실행됩니다.

PNPM vs NX vs Turbo

다음 표는 pnpm, NxTurborepo를 구분하는 기능에 대한 간략한 개요를 제공합니다.


PNPM vs NX vs Turbo(작성자 이미지)

간단히 말해 다음과 같이 말할 수 있습니다.

  • Turborepo = PNPM 워크스페이스 + 빌드 최적화
  • NX = PNPM 워크스페이스 + 빌드 최적화 + 작업 오케스트레이션 + 추가 기능

이러한 인사이트를 바탕으로, 저처럼 PNPM 워크스페이스와 Vite, Vitest, ESLint와 같은 성능 좋은 도구를 결합하면 효율적인 모노레포 개발에 충분한지 궁금하실 수도 있습니다. NxTurborepo와 같은 복잡한 모노레포 전용 도구에 의존하지 않고도 원활한 개발자 경험(DX)을 구현할 수 있을까요? 다음 섹션에서 이 질문에 대해 자세히 알아보겠습니다.

PNPM의 워크스페이스로 충분할까요?

PNPM 워크스페이스 + Vite + Vitest + ESLint: 강력한 조합?

🔳 이 설정의 잠재력을 이해하려면 NxTurborepo와 같은 모노레포 도구가 제공하는 핵심 이점을 기억해 두는 것이 좋습니다.

  • 속도: 지능적인 작업 오케스트레이션 및 캐싱을 통해 빌드 및 테스트 실행을 최적화합니다.
  • 작업 캐싱: 빌드 아티팩트를 저장하여 후속 빌드에서 중복 작업을 방지합니다.
  • 증분 빌드: 변경 사항의 영향을 받는 코드베이스 부분만 다시 빌드합니다.
  • 사용 편의성: 일반적인 모노레포 작업을 위한 간소화된 설정 및 구성.

🔳 이제 pnpm Workspace + Vite + Vitest + ESLint 스택의 가치 제안을 분석해 보겠습니다.

✔️ pnpm Workspace

  • 효율적인 종속성 관리: pnpm의 핵심 강점은 콘텐츠 주소 지정이 가능한 파일 시스템을 사용하여 효율적인 종속성 해결 및 저장에 있습니다. 따라서 설치 속도가 빨라지고, 디스크 사용 공간이 줄어들며, 안정성이 향상됩니다.
  • 워크스페이스 기능: NxTurborepo만큼 포괄적이지는 않지만 pnpm 워크스페이스는 공유 종속성, 프로젝트 연결, 패키지 간 손쉬운 스크립트 실행과 같은 기본적인 모노레포 기능을 제공합니다.

✔️ Vite

  • 초고속 개발 서버: Vite의 개발 서버는 네이티브 ES 모듈을 활용하여 거의 즉각적인 핫 모듈 리로딩(HMR)을 지원하므로 개발자의 생산성이 향상됩니다.
  • 최적화된 프로덕션 빌드: Vite의 프로덕션 빌드는 뛰어난 성능으로 유명한 고효율 번들러인 Rollup으로 구동됩니다.
  • 다양한 기능: Vite는 CSS 전처리기 지원, 모듈 해상도, 리액트 및 Vue.js와 같은 인기 프레임워크와의 통합 등 다양한 기능을 제공합니다.

✔️ Vitest

  • 내장된 모노레포 지원: Vite용으로 설계된 테스트 프레임워크인 Vitest는 모노레포를 기본적으로 지원하여 패키지 전반에서 테스트 구성 및 실행을 간소화합니다.
  • 빠르고 효율적입니다: Vitest는 Vite의 캐싱 및 모듈 해상도 기능을 활용하여 테스트를 빠르게 실행하고 원활한 개발자 경험을 제공합니다.

✔️ 캐싱이 포함된 ESLint

  • 향상된 린팅 성능: 널리 사용되는 자바스크립트 린터인 ESLint를 캐싱으로 구성하여 변경되지 않은 파일을 다시 분석하지 않도록 하여 린팅 프로세스의 속도를 높일 수 있습니다.
  • 모노레포-와이드 린팅: pnpm 워크스페이스을 사용하면 모노레포 전체에서 ESLint 구성 및 규칙을 쉽게 공유하여 일관된 코드 품질을 보장할 수 있습니다.

🔳 다음은 pnpm Workspace + Vite + Vitest + ESLint(캐싱 포함) 조합, NxTurborepo 간의 주요 차이점을 요약한 비교표입니다.


마법의 조합 vs Nx vs Turborepo (작성자 이미지)

Key

  • ✅: 강력한 지원 또는 기능 제공.
  • 🟡: 부분적 또는 제한적 지원.
  • 🟢: 배우기 쉽고 사용하기 쉬움.
  • 🟡: 중간 정도의 학습 곡선.
  • ❌: 기능이 없거나 상당한 사용자 지정 구성이 필요합니다.

🔳 비교 표에 비추어 몇 가지 미리 생각해 보세요.

  • 오버헤드가 적은 익숙한 스택을 선호하고 개별 패키지의 효율성을 우선시한다면 PNPM + Vite가 좋은 선택이 될 수 있습니다.
  • 다양한 기본 제공 기능을 갖춘 포괄적인 모노레포 프레임워크가 필요하다면 Nx가 강력한 옵션입니다.
  • 빌드 성능이 절대적인 최우선 순위라면 Vite의 속도와 캐싱 가능성에도 불구하고 TurborepoPNPM + Vite에 비해 몇 가지 이점을 제공할 수 있습니다.

지금까지의 결과는 희망적이지만 아직 끝나지 않았습니다! 팀 개발과 지속적 통합을 위해 공유 캐시를 설정하여 PNPM + Vite의 잠재력을 최대한 발휘할 수 있는 우회 방법을 함께 살펴보세요.

캐싱 질문

실제로 로컬 캐싱은 Vite, Vitest, ESLint와 같은 도구에 내장된 캐싱 메커니즘으로 인해 PNPM + Vite에서는 문제가 되지 않는 경우가 많습니다. 따라서 Nx 또는 Turborepo에서 제공하는 추가 로컬 캐싱은 불필요할 수 있습니다. 모노레포 성능에 대한 진정한 과제는 PNPM의 고유한 구조로 인해 전략적인 캐시 관리가 필요한 CI 빌드를 최적화하는 데 있습니다.

CI 빌드 캐시에 대한 가능한 해결책은 Docker를 사용하는 것입니다.

🔳 핵심 아이디어는 기본 프로젝트 설정(Node.js, PNPM 등)이 포함된 공유 Docker 이미지를 사용하고 각 CI 빌드 중에 리포지토리의 최신 코드로 이 이미지를 업데이트하는 것입니다.

🔳 다음은 기본 프로젝트 설정이 포함된 Docker 이미지의 예입니다.

FROM node:18

# 전역적으로 pnpm 설치
RUN npm install -g pnpm

# 작업 디렉토리 설정
WORKDIR /app

# 프로젝트 파일 복사
COPY package.json pnpm-workspace.yaml ./

# 의존성 설치
RUN pnpm 설치

# 기타 설정(선택 사항)
# (예: Vitest, ESLint 등과 같은 추가 도구 설치)

🔳 Docker 이미지가 빌드되고 my-project-base와 같은 이름으로 레이블이 지정됩니다.

다음은 브랜치별 특정 빌드에 대한 GitHub 액션 워크플로우를 구성하는 간단한 예제입니다.

name: CI Build

on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set environment variables
        run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/})" >> $GITHUB_ENV

      - name: Pull Docker image
        run: docker pull my-project:${{ env.BRANCH_NAME }} || true

      - name: Start container and update project
        run: |
          docker run -v $(pwd):/app my-project:${{ env.BRANCH_NAME }} sh -c " \
            git fetch && \
            git checkout ${{ env.BRANCH_NAME }} && \
            pnpm install \ 
            pnpm build
          "

      - name: Build and push Docker image
        run: |
          docker build -t my-project:${{ env.BRANCH_NAME }} .
          # (Optional: push the updated image to a registry)
  • 특정 브랜치 이름(my-project:${{ env.BRANCH_NAME }})으로 태그된 Docker 이미지를 가져옵니다. 태그가 존재하지 않으면 예를 들어 가장 최근의 태그로 되돌아갑니다.
  • 새로 빌드된 이미지는 레지스트리에 푸시되기 전에 현재 브랜치 이름(my-project:${{ env.BRANCH_NAME }})으로 태그가 지정됩니다.
  • 이를 통해 여러 브랜치에서 변경 사항을 독립적으로 분석하고 테스트할 수 있습니다.

🔳 다음은 병렬 풀 리퀘스트 처리를 위한 GitHub Actions 워크플로우를 구성하는 간단한 예시입니다.

name: CI Build

on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set environment variables
        run: echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV

      - name: Pull Docker image
        run: docker pull my-project-base:latest || true

      - name: Start container and update project
        run: |
          docker run -v $(pwd):/app my-project-base sh -c " \
            git fetch && \
            git checkout ${{ github.head_ref }} && \
            pnpm install \ 
            pnpm build
          "

      - name: Build and push Docker image
        run: docker build -t my-project:pr-${{ env.PR_NUMBER }} .
        # (선택 사항: 업데이트된 이미지를 레지스트리에 푸시)
  • 각 풀 리퀘스트는 PR 번호(예: my-project:pr-123)로 태그된 Docker 이미지를 빌드하는 별도의 CI 작업을 트리거할 수 있습니다.
  • 각 PR의 빌드와 테스트는 자체 격리된 Docker 컨테이너 내에서 실행되므로 한 PR의 변경 사항이 다른 PR의 빌드에 영향을 미치지 않습니다.
  • 이러한 PR별 빌드는 병렬로 실행할 수 있으므로 여러 PR을 동시에 테스트하고 검토할 수 있어 개발 주기가 빨라집니다.

🔳 이 솔루션의 주요 한계는 다음과 같습니다.

🔻 여러 팀 또는 프로젝트에서 동일한 기본 이미지를 사용하는 경우 한 팀에서 변경한 내용이 다른 팀의 캐시를 의도치 않게 무효화할 수 있습니다.

  • 가장 간단한 해결책은 각 팀 또는 프로젝트에 대해 별도의 기본 이미지를 만드는 것입니다(예: my-project-teamA-base, my-project-teamB-base).

🔻 특히 종속성이 많은 모노레포의 경우 Docker 이미지가 상당히 커질 수 있습니다. 이러한 이미지를 밀고 당기는 데는 시간이 걸리고 상당한 대역폭을 소비할 수 있습니다.

  • 이에 대한 가능한 해결책은 다단계 빌드를 사용하고, pnpm prune으로 사용하지 않는 종속성을 검토하고, Docker파일 명령을 전략적으로 정렬하고, 명령을 결합하고, .dockerignore를 사용하는 것입니다. 또한 알파인 Linux 또는 배포되지 않는 이미지와 같은 경량 옵션을 사용하는 것도 고려해 보세요.

🔻 캐시 무효화 및 정리 전략을 구현하여 오래된 캐시가 누적되어 저장 공간을 차지하지 않도록 해야 합니다.

  • 이를 해결하기 위한 한 가지 방법은 변경 사항이 없더라도 기본 이미지를 자동으로 재구축하는 예약된 CI 작업(예: 매주 또는 매일)을 만드는 것입니다. 이렇게 하면 주기적으로 캐시를 새로 고치고 기본 이미지 또는 기본 시스템 패키지에 대한 모든 업데이트를 통합하는 데 도움이 됩니다.

✅ 일부 CI 플랫폼은 특정 도구(예: Yarn 또는 npm 또는 pnpm)에 대한 기본 제공 캐싱을 제공합니다. 추가 최적화를 위해 이러한 기능을 Docker 캐싱과 함께 활용할 수 있습니다.

PNPM + Vite 모노레포 내에서 수동 캐싱 전략(예: Docker 또는 사용자 정의 스크립트)에 의존하는 것의 중요한 장점 중 하나는 NxTurborepo에서 제공하는 클라우드 기반 캐싱 솔루션과 관련된 잠재적 비용을 피할 수 있다는 것입니다.

pnpm 워크스페이스 구성과 캐싱 전략의 미묘한 차이를 살펴본 다음, pnpm의 효과에 대한 기술적 평가를 살펴보겠습니다. 그런 다음 사례 연구와 실제 사례를 통해 실제 환경에서 어떻게 작동하는지 살펴보겠습니다. 시작하겠습니다!

기술적 평가

인사이트

🔳 장점: PNPM이 빛나는 곳

  • 매우 빠름: npm 또는 Yarn에 비해 설치 및 종속성 관리 속도가 눈에 띄게 빠릅니다.
  • 공간 효율적: 종속성을 한 번만 저장하고 하드 링크를 사용하여 디스크 공간을 크게 절약할 수 있습니다.
  • 신뢰성: 엄격한 종속성 해결로 선언된 종속성만 액세스할 수 있어 오류를 방지합니다.
  • 워크스페이스 지원: 기본 제공되는 워크스페이스 기능으로 단일 리포지토리 내에서 여러 패키지를 쉽게 관리할 수 있습니다.

🔳 좋지 않은 점: 개선이 필요한 부분

  • 학습 곡선: 고유한 접근 방식은 npm이나 Yarn에 익숙한 사용자에게는 약간의 적응이 필요할 수 있습니다.
  • 제한된 작업 오케스트레이션: 패키지 전반에서 복잡한 작업을 관리하기 위한 기본 제공 기능이 부족합니다.
  • 캐싱: 대규모 모노레포에서 빌드 시간을 최적화하려면 추가 설정(예: Docker 또는 CI 캐싱)이 필요합니다.
    첫인상을 더 검증하기 위해 광범위한 개발 커뮤니티에서 pnpm이 어떻게 활용되고 있는지 살펴봅시다.

실제 인사이트: 현장에서의 PNPM

🔳 pnpm은 지난 몇 년 동안 꾸준히 인기를 얻고 있습니다.


https://npmtrends.com/pnpm-vs-yarn


https://npmtrends.com/pnpm-vs-yarn


https://npm-stat.com/charts.html?package=pnpm&package=yarn&package=npm&from=2021-01-04&to=2024-07-21


https://npm-stat.com/charts.html?package=pnpm&package=yarn&package=npm&from=2021-01-04&to=2024-07-21

🔳 pnpm은 Microsoft와 같은 거대 기술 기업부터 Prisma와 같은 혁신적인 스타트업, 심지어 Rush 및 SvelteKit과 같은 영향력 있는 오픈 소스 프로젝트에 이르기까지 다양한 조직에서 널리 채택되고 있습니다.


https://pnpm.io/users

🔳 GitHub의 수많은 프로젝트에서 pnpm을 사용합니다.

🔳 GitHub의 수많은 프로젝트pnpm 워크스페이스을 사용합니다.

이러한 실제 현장에서의 모멘텀은 자바스크립트 생태계에서 pnpm의 중요성이 커지고 있음을 강조하며, 기존 패키지 관리자에 대한 실행 가능하고 매력적인 대안으로서 그 입지를 확인해줍니다.

이제 상황을 살펴봤으니 이제 우리의 길을 선택할 차례입니다. 하나의 도구가 최고로 군림할까요, 아니면 하이브리드 접근 방식이 모노레포에 최적화된 전략일까요? 한번 봅시다!

최종 평가: 모노레포 개발을 위한 우리의 길

다음은 의사 결정 과정에 도움이 되도록 PNPM workspace + Vite, NxTurborepo의 주요 차이점을 요약한 표입니다.


모노레포 도구 적합성(작성자 이미지)

분석 결과에서 알 수 있듯이 이상적인 모노레포 도구는 각 프로젝트의 특정 요구사항에 따라 크게 달라집니다. 그러나 특히 자체 코드 생성기인 비스트로를 개발하는 저희는 단순성, 유연성, 성능을 최우선 순위로 삼고 있으므로 Nx를 제외하여 선택의 폭을 좁힐 수 있습니다.

Nx는 분명 강력하지만 광범위한 기능으로 인해 현재 요구사항에 불필요한 복잡성을 유발하여 잠재적으로 성능에 영향을 미칠 수 있습니다. 저희는 툴을 완벽하게 제어하고, 비스트로를 워크플로에 쉽게 통합하며, 무엇보다도 높은 성능 표준을 유지할 수 있는 솔루션을 선호합니다.

따라서 유연성과 효율성이 뛰어난 PNPM Workspace + Vite(및 기타 필수 도구)로 기준선을 설정할 것입니다. 빌드 성능을 향상해야 하는 경우, 전문화된 최적화 기능을 제공하는 Turborepo를 쉽게 추가할 수 있습니다(기존 리포지토리에 추가하는 방법에 대한 Turborepo가이드 참조).

이상으로 모노레포 도구의 세계에 대해 자세히 알아봤습니다! 지금까지 모노레포의 환경을 살펴보고, 내부 작동 방식을 자세히 살펴보고, 장단점을 따져봤습니다. 이제 결론을 내릴 시간입니다! 🌟

결론

결론적으로 모노레포 도구를 살펴본 결과, 프로젝트의 요구 사항에 따라 PNPM, Nx, Turborepo가 각각 고유한 강점을 제공한다는 것을 알 수 있었습니다.

속도, 효율성, 단순성에 중점을 둔 PNPM은 다양한 시나리오에 적합한 다목적 솔루션으로 돋보입니다. 그러나 Nx는 복잡한 대규모 모노레포를 관리하는 데 탁월한 반면, Turborepo는 빌드 최적화에 우선순위를 둡니다.

유연성과 툴링에 대한 통제력 유지에 중점을 두었기 때문에 현재 요구 사항을 처리하고 사용자 지정 코드 생성기인 비스트로와 원활하게 통합할 수 있다는 확신을 가지고 PNPM Workspace + Vite를 기반으로 구축하기로 결정했습니다. Turborepo의 빌드 최적화 능력은 부인할 수 없지만, 프로젝트의 성장으로 인해 더 큰 성능 향상이 필요할 때 언제든지 배포할 수 있도록 계속 보유할 것입니다.

단순하고 직관적으로 유지하세요! 레오나르도 다빈치는 단순함이 궁극적인 정교함이라고 말했습니다. ❤️

이 시리즈를 통해 프로젝트에 가장 적합한 모노레포 도구를 결정하는 데 필요한 지식과 통찰력을 얻으셨기를 바랍니다.

만능 솔루션은 없으며, 다양한 조합을 실험해 보면 특정 요구사항에 맞는 완벽한 설정을 찾을 수 있다는 점을 기억하세요. 모노레포의 유연성을 활용하고 행복한 코딩을 즐겨보세요! 🌟

모노레포의 모험에 동참해 주셔서 감사합니다! 🚀

새로운 글과 새로운 모험으로 다시 만날 때까지! ❤️

제 글을 읽어주셔서 감사합니다.

저와 연락하고 싶으신가요?
GitHub에서 저를 찾을 수 있습니다: https://github.com/helabenkhalfallah
profile
누구나 읽기 편한 글을 위해

0개의 댓글

관련 채용 정보