Rust로 다시 쓴 패키지 매니저, Yarn 6
TypeScript Berry와 Rust Yarn 6의 실제 코드를 비교하며 살펴본 성능 개선 원리와 아키텍처 변화
2026년 1월, Yarn의 리드 메인테이너 Maël Nison은 Yarn을 Rust로 포팅하겠다는 계획을 공식 발표했습니다.
약 1년의 개발 끝에 프리뷰 단계에 진입한 Yarn 6은 zpm(yarnpkg/zpm)이라는 새로운 Rust 코드베이스 위에서 개발되며, JavaScript 생태계에서 오랫동안 사용된 패키지 매니저의 구현 방식을 근본적으로 바꾸는 시도입니다.
zpm 저장소 README도 이곳이 Yarn 6 이상의 소스이고 Berry(>=2 <6)는 yarnpkg/berry에 남는다고 명시하고 있습니다.
처음 소식을 봤을 때는 “정말 체감될 만큼 달라질까?“가 가장 궁금했습니다. 이 글에서는 단순히 “빨라졌다”는 결론에 머무르지 않고, 실제 코드를 열어보며 왜 빨라졌는지, 무엇이 달라졌는지, 그리고 그 선택이 어떤 의미를 갖는지를 정리합니다.
1. Rust 선택 배경: Berry의 한계
Berry의 구조적 한계
Yarn Berry(v2~v4)는 패키지 매니저 역사에서 가장 야심찬 프로젝트 중 하나였습니다. Plug’n’Play, Zero Installs, 플러그인 아키텍처, constraints 등 혁신적인 개념을 도입했고, 그 설계 자체는 훌륭했습니다.
하지만 대규모 모노레포를 운영하는 팀들에서 같은 패턴의 불만이 반복되었습니다.
1. 순차적 설치 파이프라인
Berry의 Project.install()은 설치를 해석(resolve) → 페칭(fetch) → 링킹(link)이라는 전역 단계로 나눠 실행합니다.
각 단계 내부에는 상당한 동시성이 있지만, 단계의 경계는 넘지 않습니다.
그래서 패키지 A의 해석이 끝나도 B의 해석이 끝날 때까지 A의 페칭을 시작할 수 없고, 의존성이 수천 개인 프로젝트에서는 이 단계 간 대기가 무시할 수 없는 수준이 됩니다.
2. 플러그인 시스템의 런타임 오버헤드
Berry의 핵심 강점인 플러그인 아키텍처에는 런타임 비용이 따라붙습니다.
Berry 저장소에는 plugin-* 패키지만 25개가 있고, 이 중 다수가 Resolver, Fetcher, Linker 인터페이스를 구현한 뒤 매 패키지마다 “이 플러그인이 지원하는가?“를 런타임에 순회하며 확인합니다.
호출 대상이 다양한 다형성 호출은 V8이 인라이닝하기 어려운 형태(megamorphic call site)가 되기 쉽고, 정적으로 결정된 코드보다 디스패치 비용이 큽니다.
다만 Yarn 팀이 “이것이 Berry 성능 저하의 핵심 원인”이라고 밝힌 프로파일링 자료는 공개된 적이 없습니다. 여기서는 가능한 비용 요인 정도로 읽는 편이 맞습니다.
3. lockfile 파싱의 반복 비용
수만 줄에 달하는 lockfile을 매 설치마다 YAML로 파싱하고, JavaScript 객체로 변환하고, 가비지 컬렉션의 대상이 되는 수만 개의 객체를 힙에 할당합니다. 이 과정은 warm cache에서도 피할 수 없는 고정 비용입니다.
4. 단일 실행 스레드 중심의 구조
Berry의 핵심 로직은 대부분 하나의 JavaScript 실행 스레드 위에서 동작합니다.
Node.js에도 worker_threads가 있어 의존성 그래프 계산이나 체크섬 검증 같은 CPU 바운드 작업의 병렬화 자체는 가능하지만, 작업을 명시적으로 분리하고 스레드 간 통신 비용까지 직접 관리해야 합니다.
반면 Rust에서는 Rayon과 Tokio를 통해 CPU 병렬 처리와 비동기 I/O를 코드베이스 전반에 더 직접적으로 적용할 수 있습니다.
이 비용들이 Berry의 코드 품질 문제인 것은 아닙니다. 일부는 Berry가 선택한 아키텍처에서, 일부는 JavaScript 런타임의 GC와 실행 모델에서 비롯됩니다. 그리고 이것이 Yarn 팀이 “더 나은 TypeScript 코드”가 아닌 “다른 언어”를 선택한 배경입니다.
JavaScript 생태계의 네이티브 전환
Yarn만 이런 판단을 내린 것은 아닙니다. JavaScript 생태계 전반에서 성능 크리티컬한 툴링을 네이티브 언어로 재작성하는 흐름이 이어지고 있습니다.
| 도구 | 기존 (JS/TS) | 신규 (Rust/Zig) |
|---|---|---|
| 번들러 | webpack | Rspack (Rust), Turbopack (Rust), Rolldown (Rust) |
| 린터/포매터 | ESLint + Prettier | Biome (Rust), oxlint (Rust) |
| 트랜스파일러 | Babel, tsc | SWC (Rust), oxc (Rust) |
| 패키지 매니저 | Yarn Berry (TS), npm (JS) | Yarn 6 (Rust), Bun (Zig) |
이 흐름의 핵심은 단순한 “언어 교체”가 아닙니다. JavaScript 런타임에서 발생하는 가비지 컬렉션 오버헤드, CPU 병렬화에 드는 추가 비용, 직렬화/역직렬화 비용이 대규모 모노레포에서 체감 가능한 병목으로 작용하기 시작했다는 것입니다.
Yarn 6의 Cargo.toml을 살펴보면, 이 선택이 얼마나 의도적인지 드러납니다.
# Yarn 6 (zpm) 핵심 의존성
tokio = { version = "1.39.2", features = ["full"] } # 멀티스레드 async 런타임
rayon = "1.10.0" # 데이터 병렬 처리
rkyv = "0.8" # 바이너리 직렬화 (내부 상태 캐시)
dashmap = "6" # 샤딩된 동시 해시맵
tokio로 비동기 I/O를, rayon으로 CPU 바운드 병렬 처리를, rkyv로 내부 상태의 직렬화 비용을 줄이고, dashmap으로 락 경합이 낮은 동시 자료구조를 사용합니다.
dashmap은 흔히 락프리로 오해받지만 실제로는 여러 shard로 나눠 shard별 락을 잡는 구조로, 전역 RwLock<HashMap> 하나보다 경합을 줄이는 쪽에 가깝습니다.
Node.js에서도 Worker Threads나 네이티브 애드온으로 비슷한 조합을 만들 수는 있지만, Rust에서는 이 네 가지를 하나의 실행 환경 안에서 훨씬 자연스럽게 엮을 수 있습니다.
2. 성능 벤치마크
공식 벤치마크 결과는 다음과 같습니다.
Warm Cache 벤치마크
| 프로젝트 | Berry (v4) | Yarn 6 | 개선율 |
|---|---|---|---|
| Next.js | 577ms | 184ms | 68% 단축 |
| Gatsby | 1.7s | 0.3s | 82% 단축 |
Warm cache 벤치마크에 주목해야 하는 이유가 있습니다. 네트워크 비용의 영향을 크게 줄인 상태에서 패키지 매니저 자체의 처리 비용 차이를 비교하기 좋은 지표이기 때문입니다. 다만 이 숫자 안에는 런타임과 자료구조뿐 아니라 lockfile 처리, 설치 상태 캐시, 파일시스템 접근, 링커까지 모두 섞여 있습니다. 특정 구현 하나로 환원할 수 있는 수치는 아닙니다.
그렇다면 구체적으로 어디서 이 차이가 발생하는 걸까요? 이를 이해하려면 코드를 직접 들여다봐야 합니다.
3. 아키텍처 비교: Berry vs Yarn 6
이 섹션은 두 레포의 실제 소스 코드를 비교해, 아키텍처 수준의 차이를 확인하는 파트입니다.
- Yarn Berry: https://github.com/yarnpkg/berry
- Yarn 6 (zpm): https://github.com/yarnpkg/zpm
전체 구조: 47개 패키지 → 14개 crate
Yarn 6의 main.rs를 먼저 보겠습니다.
// packages/zpm/src/main.rs
extern crate zpm_allocator;
use std::process::ExitCode;
#[tokio::main]
async fn main() -> ExitCode {
env_logger::init();
zpm::commands::run_default(None).await
}
코드는 단 8줄이지만, 설계 방향이 명확하게 드러납니다.
zpm_allocator로 커스텀 메모리 할당자를 주입하고, #[tokio::main]으로 멀티스레드 비동기 런타임 위에서 실행하며, 모든 로직은 zpm::commands 모듈로 위임합니다.
이 단순한 진입점이 어떤 구조 위에서 작동하는지 살펴보겠습니다.
Berry는 47개의 npm 패키지로 구성된 모노레포입니다.
packages/
├── yarnpkg-core/ # 핵심 엔진
├── yarnpkg-cli/ # CLI 진입점
├── yarnpkg-fslib/ # 파일시스템 추상화
├── yarnpkg-parsers/ # 파서
├── yarnpkg-shell/ # 내장 셸
├── yarnpkg-pnp/ # PnP 런타임
├── yarnpkg-nm/ # node_modules 생성
├── plugin-essentials/ # 필수 명령어
├── plugin-npm/ # npm 레지스트리
├── plugin-git/ # Git 의존성
├── plugin-pnp/ # PnP 링커
├── plugin-nm/ # node_modules 링커
├── ... (plugin-* 25개)
└── ... (기타 유틸리티)
Yarn 6은 14개의 Rust crate로 재편됩니다.
packages/
├── zpm/ # 메인 바이너리 (commands, resolvers, fetchers, linker 포함)
├── zpm-config/ # 설정 관리
├── zpm-primitives/ # 기본 타입 (Ident, Descriptor, Locator 등)
├── zpm-semver/ # SemVer 파서/비교기
├── zpm-parsers/ # lockfile 등 파서
├── zpm-formats/ # 형식 변환
├── zpm-git/ # Git 유틸리티
├── zpm-sync/ # 동기화
├── zpm-utils/ # 공통 유틸리티
├── zpm-switch/ # Yarn Switch 바이너리
├── zpm-constraints/ # 제약 조건 엔진
├── zpm-allocator/ # 커스텀 메모리 할당자
├── zpm-macro-enum/ # 매크로 유틸리티
└── zpm-macro-helpers/ # 매크로 헬퍼
가장 눈에 띄는 변화는 플러그인 시스템의 축소/통합입니다.
Berry에서는 Resolver, Fetcher, Linker가 각각 독립된 플러그인 패키지로 분리되어 있었습니다.
plugin-npm은 npm resolver와 fetcher를, plugin-git은 git resolver와 fetcher를 각각 제공하는 식이었죠.
Yarn 6에서는 이 역할이 zpm crate 내부의 서브모듈로 통합됩니다.
확장성보다 컴파일 타임 최적화와 낮은 런타임 오버헤드를 우선한 트레이드오프로 볼 수 있습니다.
Resolution 파이프라인: interface 다형성 → enum 디스패치
의존성 해석(Resolution)은 패키지 매니저의 심장입니다. "^4.0.0"이라는 범위를 "4.28.0"이라는 구체적 버전으로 변환하는 과정이죠.
Berry의 접근: TypeScript interface 기반 플러그인
// packages/yarnpkg-core/sources/Resolver.ts
export interface Resolver {
supportsDescriptor(
descriptor: Descriptor,
opts: MinimalResolveOptions,
): boolean;
supportsLocator(locator: Locator, opts: MinimalResolveOptions): boolean;
shouldPersistResolution(
locator: Locator,
opts: MinimalResolveOptions,
): boolean;
bindDescriptor(
descriptor: Descriptor,
locator: Locator,
opts: MinimalResolveOptions,
): Descriptor;
getResolutionDependencies(
descriptor: Descriptor,
opts: MinimalResolveOptions,
): Record<string, Descriptor>;
getCandidates(
descriptor: Descriptor,
dependencies: Record<string, Package>,
opts: ResolveOptions,
): Promise<Locator[]>;
getSatisfying(
descriptor: Descriptor,
dependencies: Record<string, Package>,
locators: Locator[],
opts: ResolveOptions,
): Promise<{ locators: Locator[]; sorted: boolean }>;
resolve(locator: Locator, opts: ResolveOptions): Promise<Package>;
}
Berry에서는 각 플러그인이 이 Resolver 인터페이스를 구현합니다. plugin-npm의 NpmSemverResolver, plugin-git의 GitResolver 등이 각각 독립된 패키지에서 이 인터페이스를 구현하고, 런타임에 등록됩니다.
이 설계는 확장성이 높지만, 런타임 다형성에 의존해 V8 인라이닝이 어려워지는 “megamorphic call site” 문제가 생길 수 있습니다.
Yarn 6의 접근: enum 기반 정적 디스패치
// packages/zpm/src/resolvers/mod.rs (일부 생략)
pub async fn resolve_descriptor(context: InstallContext<'_>, descriptor: Descriptor, dependencies: Vec<InstallOpResult>) -> Result<ResolutionResult, Error> {
// ...워크스페이스 우선 처리 생략...
match &descriptor.range {
Range::Builtin(params)
=> builtin::resolve_builtin_descriptor(&context, &descriptor, params).await,
Range::AnonymousSemver(params)
=> semver::resolve_descriptor(&context, &descriptor, params).await,
Range::AnonymousTag(params)
=> tag::resolve_descriptor(&context, &descriptor, params).await,
Range::Git(params)
=> git::resolve_descriptor(&context, &descriptor, params).await,
Range::Link(params)
=> link::resolve_descriptor(&context, &descriptor, params),
Range::Url(params)
=> url::resolve_descriptor(&context, &descriptor, params).await,
Range::Patch(params)
=> patch::resolve_descriptor(&context, &descriptor, params, dependencies).await,
Range::Tarball(params)
=> tarball::resolve_descriptor(&context, &descriptor, params, dependencies).await,
Range::Folder(params)
=> folder::resolve_descriptor(&context, &descriptor, params, dependencies).await,
Range::Portal(params)
=> portal::resolve_descriptor(&context, &descriptor, params, dependencies),
Range::RegistrySemver(params) => match params.ident.is_some() {
true => npm::resolve_aliased(&descriptor, dependencies),
false => npm::resolve_semver_descriptor(&context, &descriptor, params).await,
},
// ...WorkspacePath, 그리고 resolver로 오면 안 되는 range들의 panic 분기...
}
}
Yarn 6에서는 trait 다형성 대신 enum match를 사용합니다.
호출 대상이 컴파일 타임에 모두 드러나므로, 컴파일러가 호출 경로를 분석하기 쉬워지고 경우에 따라 인라이닝이나 효율적인 분기 코드로 최적화될 여지가 커집니다.
물론 항상 점프 테이블이 생성되거나 모든 분기가 인라이닝된다는 보장은 없습니다. 실제 결과는 어셈블리나 LLVM IR을 확인해야 알 수 있습니다.
그래도 이는 단순한 코딩 스타일 차이가 아닙니다. 수만 개 패키지를 해석하는 모노레포에서는, 패키지마다 반복되는 디스패치 비용의 누적이 실제 성능 차이로 이어질 수 있습니다.
설치 파이프라인: 순차 → 그래프 기반 병렬 실행
Berry의 설치 흐름
Berry의 Project.ts에서 설치는 전역 3단계로 진행됩니다.
실제 install()은 검증·텔레메트리 같은 코드가 섞여 200줄이 넘지만, 골격만 남기면 다음과 같습니다.
// packages/yarnpkg-core/sources/Project.ts (골격만 발췌)
async install(opts: InstallOptions) {
// Resolution step
await this.resolveEverything(opts);
// Fetch step
await this.fetchEverything(opts);
// Link step
await this.linkEverything(opts);
}
resolveEverything()이 끝나야 fetchEverything()이 시작되고, 그것이 끝나야 linkEverything()이 시작됩니다.
각 단계 내부에서는 비동기 처리가 활발하지만, 단계 간 파이프라이닝은 없습니다.
Yarn 6의 설치 흐름: 그래프 기반 태스크 스케줄러
// packages/zpm/src/install.rs
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum InstallOp<'a> {
#[allow(dead_code)]
Phantom(PhantomData<&'a ()>),
Refresh { locator: Locator },
Validate { descriptor: Descriptor, locator: Locator },
Resolve { descriptor: Descriptor },
Fetch { locator: Locator, is_mock_request: bool },
}
Yarn 6에서는 해석, 페칭, 검증을 별도의 “단계”로 나누지 않습니다.
대신 각 작업을 InstallOp enum으로 표현하고, 의존성 그래프를 기반으로 스케줄링합니다.
// packages/zpm/src/graph.rs (일부 생략)
pub struct GraphTasks<'a, TCtx, TIn, TOut, TErr, TCache> {
context: TCtx,
cache: TCache,
ready: Vec<TIn>, // 실행 준비된 태스크
running: FuturesUnordered<BoxFuture<'a, (TIn, Result<TOut, TErr>)>>, // 실행 중인 비동기 태스크
results: GraphTaskResults<TIn, TOut, TErr>, // 완료된 결과
tasks: HashMap<TIn, (usize, Vec<TIn>)>, // 태스크별 의존성
dependents: HashMap<TIn, Vec<TIn>>, // 역방향 의존성
}
이 스케줄러는 while self.running.len() < 100 조건으로 동시에 최대 100개의 태스크를 굴리며, 한 패키지의 해석이 끝나면 즉시 페칭을 시작할 수 있습니다.
100이라는 숫자 자체는 구현 세부사항이라 언제든 바뀔 수 있는 값입니다.
Berry: [--- resolve all ---][--- fetch all ---][--- link all ---]
Yarn 6: [resolve A][fetch A]
[resolve B][fetch B]
[resolve C][fetch C][link C]
...동시 진행...
이 차이는 의존성이 많은 대규모 프로젝트에서 더 크게 나타납니다. A 패키지 해석이 끝나는 즉시 B를 기다리지 않고 A 페칭을 시작할 수 있기 때문입니다.
Lockfile 처리와 install state 캐시
대규모 모노레포의 lockfile은 수만 줄에 달합니다. 이 파일을 읽고 파싱하는 비용은 매 설치마다 발생하는 고정 비용입니다.
Berry와 Yarn 6 모두 lockfile은 텍스트로 파싱합니다
Berry는 자체 YAML 파서(yarnpkg-parsers)로 lockfile을 파싱합니다.
그리고 Yarn 6도 lockfile만큼은 똑같이 텍스트로 읽어 파싱합니다.
// packages/zpm/src/project.rs
let src = lockfile_path
.fs_read_text()?;
if src.starts_with('#') {
return from_legacy_berry_lockfile(&src);
}
let lockfile: Lockfile
= JsonDocument::hydrate_from_str(&src)
.map_err(|e| Error::LockfileParseError(e))?;
#으로 시작하면 Berry 형식으로 보고 변환하고, 아니면 zpm의 JSON 계열 문서로 파싱합니다.
어느 쪽이든 사람이 읽고 리뷰할 수 있는 lockfile을 유지한다는 원칙은 두 구현이 같습니다.
rkyv가 실제로 쓰이는 곳: 내부 install state
그러면 rkyv는 어디에 쓰일까요. lockfile이 아니라, 매 실행마다 다시 만들기 비싼 내부 설치 상태를 저장하고 복원하는 데 쓰입니다.
// packages/zpm/src/project.rs
let install_state
= rkyv::from_bytes::<InstallState, rkyv::rancor::BoxedError>(&src)
.map_err(|_| Error::InvalidInstallState)?;
// ...
let contents
= rkyv::to_bytes::<rkyv::rancor::BoxedError>(install_state)
.map_err(|_| Error::InvalidInstallState)?
.to_vec();
npm 레지스트리 메타데이터 캐시(http_npm.rs)와 변경 감지(diff_finder.rs)도 같은 방식으로 rkyv 바이너리를 씁니다.
한 가지 덧붙이면, rkyv는 zero-copy 접근(rkyv::access)을 제공하는 라이브러리가 맞지만 zpm이 호출하는 것은 from_bytes입니다.
이것은 아카이브에서 소유권 있는 값으로 역직렬화하는 경로라, “메모리에 매핑해서 그대로 쓴다”는 설명과는 다릅니다.
정확히는 텍스트 파싱과 스키마 검증을 건너뛰는 바이너리 상태 캐시 정도로 보는 게 맞습니다.
따라서 warm cache 벤치마크의 개선을 lockfile 파싱 방식 하나로 설명할 수는 없습니다. 네이티브 코드, 설치 파이프라인 구조, 캐시와 자료구조, 파일시스템 처리, 동시성이 함께 만든 결과로 보는 편이 정확합니다.
Linker: 플러그인 분리 → 단일 모듈 통합
Berry의 Linker
// packages/yarnpkg-core/sources/Linker.ts
export interface Linker {
supportsPackage(pkg: Package, opts: MinimalLinkOptions): boolean;
findPackageLocation(
locator: Locator,
opts: LinkOptions,
): Promise<PortablePath>;
findPackageLocator(
location: PortablePath,
opts: LinkOptions,
): Promise<Locator | null>;
makeInstaller(opts: LinkOptions): Installer;
}
Berry에서 PnP와 node_modules는 완전히 독립된 플러그인입니다.
plugin-pnp와 plugin-nm은 각자의 패키지에서 Linker 인터페이스를 구현합니다.
Yarn 6의 Linker
packages/zpm/src/linker/
├── mod.rs # 링커 진입점
├── helpers.rs # 공통 헬퍼
├── pnp.rs # PnP 링커
├── pnpm.rs # pnpm 호환 링커
├── nm/ # node_modules 링커 (하위 디렉토리)
├── pnp-cjs.brotli.dat # PnP 런타임 (brotli 압축)
└── pnp-mjs.brotli.dat # PnP 런타임 (ESM, brotli 압축)
주목할 점은 pnp-cjs.brotli.dat와 pnp-mjs.brotli.dat입니다.
이 파일들의 정체는 저장소의 scripts/import-artifacts.mjs를 보면 드러납니다. Berry의 yarnpkg-pnp/sources/hook.js와 ESM 로더를 그대로 가져와 brotli로 압축한 것입니다.
즉 런타임을 Rust로 다시 컴파일한 것이 아니라, Berry가 쓰던 PnP 런타임 JavaScript를 압축해 바이너리에 내장한 형태입니다.
// packages/zpm/src/linker/pnp.rs
const PNP_CJS_TEMPLATE: &[u8] = std::include_bytes!("pnp-cjs.brotli.dat");
const PNP_MJS_TEMPLATE: &[u8] = std::include_bytes!("pnp-mjs.brotli.dat");
설치 시점에 런타임 소스를 매번 구성하는 대신, 사전에 준비된 템플릿을 풀어서 .pnp.cjs를 만드는 방식으로 바뀐 셈입니다.
또한 pnpm.rs의 존재는 Yarn 6가 pnpm 스타일 링킹도 다룬다는 신호입니다.
실제로 lockfile.rs의 from_pnpm_node_modules()는 pnpm의 node_modules/.pnpm에서 lockfile을 재구성하고, 이 함수는 Project::lockfile_from()에서 직접 호출됩니다. 계획 단계가 아니라 이미 들어와 있는 코드입니다.
특별히 주목할 구현: clipanion-rs와 pnp-rs
Yarn 생태계에서 흥미로운 점은, Maël Nison이 자신이 만든 TypeScript 라이브러리를 직접 Rust로 포팅했다는 것입니다.
- clipanion → clipanion-rs: Yarn의 CLI 프레임워크. 타입 안전한 명령어 파싱과 서브커맨드 라우팅을 제공합니다.
- @yarnpkg/pnp → pnp-rs: PnP 해석 로직의 Rust 구현.
# Cargo.toml - Git 의존성으로 관리
clipanion = { git = "https://github.com/arcanis/clipanion-rs.git", features = ["serde", "tokens"] }
pnp = { git = "https://github.com/yarnpkg/pnp-rs.git", branch = "mael/pub-vpath" }
TypeScript에서 Rust로의 1:1 포팅이 아니라, Rust의 타입 시스템과 소유권 모델에 맞게 재설계된 것이 이 프로젝트들의 특징입니다.
4. 핵심 신기능
Yarn Switch: 버전 관리 도구
이 기능의 배경을 이해하려면, Corepack이라는 도구의 역할과 운명을 먼저 알아야 합니다.
Corepack은 Node.js에 실험적으로 포함된 패키지 매니저 버전 관리 도구였습니다.
package.json의 packageManager 필드를 읽어, 프로젝트가 요구하는 정확한 버전의 yarn이나 pnpm을 자동으로 설치·실행해주는 역할을 했습니다.
팀원 간 패키지 매니저 버전 불일치를 방지하는 핵심 인프라였죠.
다만 2024년 이후 Node.js 측에서 Corepack의 장기 포함 여부를 재검토하는 논의가 진행됐고, 생태계 차원에서 대체 수단 필요성이 커졌습니다. “실험적” 딱지를 떼지 못한 채 유지보수 부담만 커졌고, Node.js 코어에 패키지 매니저 관리 로직이 포함되는 것 자체에 대한 근본적인 의문이 제기된 것입니다.
Yarn 팀 입장에서 이것은 무시하기 어려운 문제였습니다.
yarnPath나 Volta, CI에서의 버전 고정 같은 다른 방법이 남아 있긴 하지만, Corepack이 빠지면 packageManager 필드만으로 Yarn 버전이 자동으로 맞춰지는 기본 경로가 사라지니까요.
Yarn Switch는 이런 공백을 메우기 위해 등장한 도구입니다.
rustup이나 nvm처럼 package.json의 packageManager 필드를 읽어 적절한 Yarn 버전을 자동으로 다운로드하고 실행합니다.
Corepack과 아이디어는 유사하지만, Yarn 팀이 직접 유지보수하며 Yarn에 최적화되어 있다는 점이 다릅니다.
구현 측면에서는 독립된 Rust 바이너리(zpm-switch crate)로, Yarn 본체와 별도로 설치·관리할 수 있습니다.
경량 바이너리가 ~/.yarn/switch/bin/yarn에 위치하면서 모든 yarn 호출을 가로채 적절한 버전으로 라우팅하는 구조입니다.
Lazy Installs: 자동 설치 감지
Yarn Berry의 Zero Installs는 혁신적인 아이디어였습니다.
.yarn/cache/에 모든 의존성을 zip 형태로 저장하고 Git에 커밋하면, yarn install 없이 바로 실행할 수 있습니다.
하지만 실무에서는 저장소 크기 증가, Git 성능 저하 등의 문제가 있었습니다.
Lazy Installs는 같은 불편을 다른 방향에서 덜어냅니다.
# 기존: install을 먼저 실행해야 함
$ yarn install
$ yarn run build
# Lazy Installs: run이 자동으로 install 여부를 감지
$ yarn run build # 필요하면 자동으로 install 실행
yarn run 같은 명령 실행 시, Yarn이 자동으로 설치 상태를 감지하고 필요한 경우에만 설치를 수행합니다.
다만 Zero Installs를 대체하는 기능이라고 보기는 어렵습니다.
Zero Installs의 목적은 clone 직후 바로 실행, 오프라인·재현 가능한 설치, 의존성 아티팩트까지의 VCS 관리에 가깝고, Lazy Installs는 의존성을 저장소에 넣지 않는 프로젝트에서 명시적인 yarn install 단계를 줄여주는 쪽에 가깝습니다.
project.rs의 lazy_install() 메서드가 이 기능의 진입점입니다.
run, bin, exec, node, why, dedupe, constraints 등 상당수 명령이 이 메서드를 거칩니다.
5. Correctness: 정확성 우선 철학
발표 글은 Yarn이 늘 세 가지 축을 우선해왔다고 설명합니다.
“Yarn has always prioritized three pillars: correctness, developer experience, and performance.”
성능이 마지막에 놓여 있다는 점이 이 프로젝트의 성격을 잘 보여줍니다.
실사용 사례도 같은 글에 언급됩니다. Yarn 6.x의 실험적 릴리스가 Datadog에서 프로덕션에 배포됐고, breaking change가 거의 없었다는 내용입니다. 대규모 모노레포에서 굴려봤다는 뜻이지, 공식적으로 “Berry와 동일한 결과를 낸다”는 검증 결과가 공개된 것은 아닙니다.
코드에서도 이 철학이 드러납니다.
lockfile.rs의 from_legacy_berry_lockfile() 함수는 Berry 형식의 lockfile을 Yarn 6 형식으로 변환합니다.
심지어 from_pnpm_node_modules() 함수는 pnpm의 node_modules에서 lockfile을 재구성하는 기능까지 제공합니다.
// packages/zpm/src/lockfile.rs
pub fn from_legacy_berry_lockfile(data: &str) -> Result<Lockfile, Error> { /* ... */ }
pub fn from_pnpm_node_modules(project_cwd: &Path) -> Result<Lockfile, Error> { /* ... */ }
기존 프로젝트의 마이그레이션을 코드 레벨에서 지원하고 있는 것입니다.
6. 마이그레이션 가이드
버전 로드맵
| 버전 | 시기 | 상태 |
|---|---|---|
| Yarn 4.x | 현재 | stable (JavaScript) |
| Yarn 5.x | 2-3개월 내 | 추가 deprecated 기능 포함 |
| Yarn 6.x | 2026 Q3 이후 | Rust 기반 stable |
발표에 따르면 Yarn 5.x는 출시 후 약 30개월간 LTS 지원을 받을 예정이라, 급하게 마이그레이션할 필요는 없습니다. 다만 이건 어디까지나 발표 시점의 계획이고, 실제 일정은 밀릴 수 있는 종류의 숫자입니다.
현재 남은 과제
프리뷰 단계라 아직 미완성이라고 안내된 부분들이 있습니다.
- Windows 지원: 아직 완전하지 않음
- 대화형 명령어: 일부 interactive 명령이 미구현
- 잠금 파일 파싱 도구 호환성: 서드파티 도구와의 호환성
- 일부 테스트 및 명령어: 구현 진행 중
Windows는 릴리스 워크플로에서도 드러납니다. releases.yml의 빌드 매트릭스는 x86_64/aarch64/i686-unknown-linux-musl과 aarch64-apple-darwin뿐이라, 아직 Windows 바이너리 자체가 나오지 않습니다.
프리뷰 테스트 가이드
프리뷰 단계이므로, 우선 비프로덕션 환경에서 테스트하는 것을 권장합니다. 특히 CI 파이프라인에서의 설치 시간이 병목인 팀이라면, 벤치마크를 직접 돌려보는 것을 권장합니다.
7. 결론
정리하면 Yarn 6는 단순히 “더 빠른 yarn”으로만 보기는 어렵습니다.
SWC, Biome, Rspack에 이어 패키지 매니저 영역까지 네이티브 전환이 확장되는 흐름에서, Yarn 6는 기존 Yarn 생태계의 도메인 지식을 유지한 채 재구현을 진행한다는 점에서 의미가 큽니다.
Berry의 Resolver 인터페이스와 zpm의 Range enum을 나란히 놓고 보면,
같은 문제를 풀되 완전히 다른 도구와 사고방식으로 접근하는 과정이 보입니다.
Berry가 resolve, fetch, link를 전역 단계로 끊는 반면, zpm은 더 작은 작업 단위와 동시 실행을 전제로 설치 파이프라인을 다시 설계했습니다.
그리고 두 구현 모두 사람이 읽을 수 있는 lockfile을 파싱하되, zpm은 반복 실행에서 쓰는 내부 설치 상태를 별도의 rkyv 바이너리로 캐싱합니다.
그 결과가 공식 벤치마크에서 68~82% 수준의 단축으로 보고됩니다.
Yarn 6의 stable 버전은 아직 몇 개월 남았지만, 지금 코드 구조를 읽어두면 전환 시점에 “왜, 어떻게 빨라졌는지”를 훨씬 빠르게 판단할 수 있을 것입니다.