본문으로 건너뛰기

Monorepo란? npm workspaces를 활용한 크로스 프로젝트 코드 공유 완벽 가이드

· 약 7분
AI MDX 편집

현대의 프론트엔드 및 풀스택 개발에서 제품군이 확장됨에 따라 우리는 종종 "여러 프로젝트에서 동일한 코드를 공유해야 하는" 상황에 직면합니다. 예를 들어, 일반 사용자를 위한 메인 웹사이트(Client App)와 내부 직원을 위한 관리자 패널(Admin Panel)이 있다고 가정해 봅시다. 이 둘은 독립적으로 실행되지만, 동일한 UI 컴포넌트 라이브러리, API 호출 로직 또는 타입(Type) 정의를 공유하는 경우가 많습니다.

두 프로젝트에 같은 코드를 복사하여 붙여넣는다면, 나중에 로직을 수정해야 할 때 개발자는 여러 프로젝트를 오가며 반복해서 수정해야 합니다. 이 과정에서 작업 누락이나 버전 불일치 같은 오류가 발생하기 쉽습니다. 이러한 문제를 해결하기 위해 Monorepo(모노레포) 아키텍처가 등장했으며, 현재 Node.js 생태계에서 npm workspaces는 가장 진입 장벽이 낮은 기본 도구 중 하나입니다.

Monorepo 아키텍처란?

Monorepo 개념 설명 이미지

Monorepo(Monolithic Repository)는 여러 다른 프로젝트(Projects)나 패키지(Packages)를 모두 하나의 거대한 Git 저장소(Repository) 내에서 통합 관리하는 것을 말합니다. 이와 반대되는 개념은 기존의 Polyrepo(Multi-repo)로, 각 프로젝트가 자신만의 독립적인 저장소를 가지는 형태입니다.

Monorepo의 핵심 장점

  1. 단일 진실 공급원 (Single Source of Truth): 모든 코드가 동일한 트리 구조 아래에 있으므로, 팀원들이 항상 일관된 코드베이스를 참조할 수 있도록 보장합니다.
  2. 손쉬운 코드 공유: 다른 프로젝트에 있는 공유 모듈을 참조할 때 패키지를 일일이 npm registry에 게시할 필요 없이 로컬에서 심볼릭 링크(Symlink)만으로 가능합니다.
  3. 일관된 의존성 관리: 서드파티 의존 패키지(예: React, Lodash)를 루트 디렉토리로 끌어올림(Hoist)으로써, 모든 하위 프로젝트가 완전히 동일한 버전의 패키지를 사용하도록 보장하여 버전 충돌을 방지하고 스토리지 공간을 절약할 수 있습니다.
  4. 대규모 리팩터링의 용이성: 공유 모듈의 API가 변경되었을 때, 해당 모듈에 의존하는 모든 프로젝트가 같은 저장소에 있기 때문에 개발자는 한 번에 변경 사항을 적용하고 TypeScript 컴파일러를 통해 잠재적인 오류를 모두 잡아낼 수 있습니다.

npm workspaces 이해하기

npm v7 버전부터 npm은 공식적으로 Workspaces에 대한 지원을 내장했습니다. 이는 같은 로컬 파일 시스템 내에 있는 여러 패키지의 의존성을 관리하기 위한 CLI 네이티브 기능을 제공합니다. 루트 디렉토리의 package.json만 적절히 설정해 주면, npm이 자동으로 하위 프로젝트의 의존성을 정리하고 서로 참조할 수 있도록 만들어 줍니다.

실전 튜토리얼: npm workspaces를 활용해 코드 공유하기

지금부터 구체적인 예시를 통해 project-a, project-b 및 공유 모듈인 shared-utils를 포함하는 Monorepo를 구축하는 방법을 시연하겠습니다.

단계 1: 루트 디렉토리 생성 및 초기화

먼저, 우리 Monorepo의 루트 디렉토리가 될 새로운 폴더를 생성하고 프로젝트를 초기화합니다.

mkdir my-monorepo
cd my-monorepo
npm init -y

그런 다음, 생성된 루트 디렉토리의 package.json을 열어 수동으로 workspaces 필드를 추가합니다. 이는 이 Monorepo가 어느 디렉토리에 하위 프로젝트들을 포함하고 있는지를 선언하는 역할을 합니다. 일반적으로 프로젝트는 apps(애플리케이션)와 packages(공유 모듈)로 분류합니다.

package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}

주의: 루트 디렉토리의 package.json에는 반드시 "private": true를 설정하여, 실수로 전체 워크스페이스가 공개된 npm 레지스트리에 배포되는 것을 방지해야 합니다.

단계 2: 공유 모듈(Shared Package) 생성

이제 공유할 로직을 담을 패키지를 만들어 보겠습니다. 루트 디렉토리 아래에 packages/shared-utils 폴더를 생성합니다.

mkdir -p packages/shared-utils
cd packages/shared-utils
npm init -y

packages/shared-utils/package.json을 편집합니다. 특히 "name" 필드에 주의하세요. 이 이름은 다른 프로젝트에서 이 모듈을 참조할 때 사용될 이름입니다.

packages/shared-utils/package.json
{
"name": "@my-org/shared-utils",
"version": "1.0.0",
"main": "index.js"
}

이어서 해당 폴더 안에 index.js를 만들고, 공유하고자 하는 함수 코드를 작성합니다.

packages/shared-utils/index.js
// 공유할 날짜 포맷 함수
function formatDate(date) {
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date);
}

// 공유할 덧셈 함수
function add(a, b) {
return a + b;
}

module.exports = {
formatDate,
add
};

단계 3: 두 개의 애플리케이션 프로젝트 생성

루트 디렉토리로 돌아와서, apps/ 디렉토리 아래에 두 개의 독립된 프로젝트를 생성합니다 (간단한 시연을 위해 기본적인 Node.js 프로젝트를 초기화하지만, 실무에서는 Next.js, React 또는 Express 프로젝트가 될 수 있습니다).

mkdir -p apps/project-a
mkdir -p apps/project-b

# project-a 초기화
cd apps/project-a
npm init -y

# project-b 초기화
cd ../project-b
npm init -y

각각 apps/project-a/package.jsonapps/project-b/package.json"name""project-a""project-b"로 변경해 줍니다.

단계 4: 공유 모듈을 프로젝트에 설치하기

가장 중요한 단계입니다! 방금 작성한 @my-org/shared-utilsproject-aproject-b에서 사용할 수 있도록 설정하겠습니다.

npm workspaces 환경에서는 상대 경로(../../packages/shared-utils)를 직접 설정할 필요 없이, npm 설치 명령어와 함께 -w (workspace) 파라미터를 사용하기만 하면 됩니다.

# Monorepo 루트 디렉토리에서 실행
npm install @my-org/shared-utils -w project-a
npm install @my-org/shared-utils -w project-b

실행하고 나면, project-apackage.json 종속성 항목에 다음 사항이 추가된 것을 볼 수 있습니다.

apps/project-a/package.json
"dependencies": {
"@my-org/shared-utils": "^1.0.0"
}

이때 npm은 인터넷에서 @my-org/shared-utils를 다운로드하는 것이 아니라, 심볼릭 링크(Symlink) 기술을 영리하게 활용하여 apps/project-a/node_modules/@my-org/shared-utils가 로컬에 있는 packages/shared-utils 폴더를 가리키게 합니다! 즉, 공유 모듈에서 무언가를 수정하면 두 개의 애플리케이션 프로젝트에 즉각 반영되며, 재컴파일이나 재설치가 전혀 필요 없습니다.

단계 5: 프로젝트에서 공유 코드 호출하기

마지막으로 project-a에서 공유 코드를 성공적으로 가져오는지 테스트해 보겠습니다. apps/project-a/index.js 파일을 만듭니다.

apps/project-a/index.js
const { formatDate, add } = require('@my-org/shared-utils');

const today = new Date();
console.log(`【Project A】오늘은: ${formatDate(today)}`);
console.log(`【Project A】덧셈 계산 10 + 20 = ${add(10, 20)}`);

루트 디렉토리로 돌아가 실행해 봅니다.

node apps/project-a/index.js

이 포맷팅된 날짜 문자열과 덧셈 결과가 기분 좋게 출력된다면, 크로스 프로젝트용 코드 공유 실습을 완벽하게 해낸 것입니다!

고급 기술 및 모범 사례 (Best Practices)

단순한 코드 공유를 넘어서, 대규모 Monorepo를 성공적으로 유지 관리하려면 다음과 같은 모범 사례 메커니즘을 숙지하는 것도 좋습니다.

1. 통합 종속성 호이스팅 (Dependency Hoisting)

전통적인 Polyrepo 체제에서는 각 프로젝트가 비대해진 node_modules를 따로 갖고 있었습니다. 그러나 npm workspaces는 기본적으로 모든 하위 프로젝트의 '공통' 서드파티 라이브러리들을 '루트 디렉토리'의 node_modules로 끌어올립니다. 이렇게 하면 종속성 버전 지뢰(예컨대 React 이중 등록 등)가 해결될 뿐만 아니라, 설치 시간과 디스크 용량도 획기적으로 절약됩니다. 단지 최상위 루트에서 npm install을 한 번만 실행하는 것으로 준비가 끝납니다.

2. 아키텍처 빌드 도구 탑재 (Turborepo)

npm workspaces가 종속성 설치 문제는 해결해 주었지만, '테스트'나 '빌드' 같은 스크립트를 여러 개 실행할 때 일일이 폴더마다 들어가서 구동하는 것은 정말 비효율적입니다. 실무 환경에서는 TurborepoLerna 같은 고성능 빌드 도구와의 연계를 강력히 권장합니다. 특히 Turborepo는 '클라우드 캐시' 및 '동시 실행(Concurrency)' 능력을 갖추었습니다. 서브 프로젝트 간의 의존성 위상 그래프를 자동으로 분석하여, shared-utils의 빌드가 끝나는 즉시, 이것을 기다렸던 project-a의 빌드가 시작되도록 척척 맞물려 전체 빌드 시간을 극한까지 단축해 줍니다.

3. TypeScript의 경로 매핑 (Path Mapping)

프로젝트에서 TypeScript를 사용하고 있다면, 루트 레벨에 tsconfig.base.json을 설계해 두고 references (Project References) 기능을 활용해 코드 편집기가 시스템 전체에서 공유 패키지의 타입 정의를 재사용하도록 해볼 수 있습니다. 이 방식은 개발자 경험(IDE 코드 탐색 원활화)을 높일 뿐 아니라, 메모리가 과부하 되는 일도 효과적으로 예방해 줍니다.

마무리

Monorepo와 npm workspaces의 도입은, 밀접하게 연관된 복수의 프로젝트들을 거느린 팀이 현대 프론트엔드 엔지니어링 과정에서 거의 피할 수 없는 필수 코스가 되었습니다. 초기에 ESLint나 TypeScript, CI/CD 파이프라인의 구성 조율에 시간이 꽤 들 수는 있으나, 이를 투자하여 얻게 되는 "강력해진 리팩터링 확신성", "엄격한 버전 일치" 그리고 "극도로 높아진 코드 재사용률"은 분명 그에 상응하는 든든한 보상으로 돌아올 것입니다.

만일 여러분의 현재 프로젝트가, "핵심 로직 하나 바꾸려고 여러 레포지토리를 옮겨가며 PR을 날려야 하는" 고질적인 고통을 겪고 있다면, 지금 바로 부담 없는 작은 규모의 workspace 환경부터 가볍게 시작해 보는 것은 어떨까요?