모노레포에서 여러 패키지를 관리할 때, 패키지 간 의존성 규칙을 정의하고 강제하는 것은 아키텍처 일관성을 유지하는 핵심 요소입니다. 그러나 개발자의 실수나 부주의로 의도하지 않은 의존성이 추가될 수 있습니다.
이 글에서는 ESLint 플러그인을 직접 구현하여 모노레포의 패키지 경계를 자동으로 검증하는 과정을 살펴봅니다. 그리고 실제 프로덕션 환경에서 사용할 수 있는 기존 솔루션과의 비교를 통해, 최적의 선택을 위한 가이드를 제공합니다.
모노레포 구조에서 다음과 같은 패키지 구조를 가정합니다:
packages/
├── features/
│ ├── auth/ # 인증 기능
│ ├── dashboard/ # 대시보드 기능
│ └── settings/ # 설정 기능
└── libraries/
├── ui-components/ # 공통 UI 컴포넌트
├── utils/ # 유틸리티
└── api-client/ # API 클라이언트
아키텍처 일관성을 위해 다음 규칙을 정의했습니다:
1. Library 패키지는 Feature 패키지를 참조 불가
// [주의] packages/libraries/ui-components/Button.tsx
import { useAuth } from '@features/auth'; // 불가능!
// [권장] 올바른 사용
import { theme } from '@libraries/utils';2. Feature 패키지는 다른 Feature 패키지를 참조 불가
// [주의] packages/features/dashboard/Dashboard.tsx
import { LoginForm } from '@features/auth'; // 불가능!
// [권장] 올바른 사용: 공통 기능은 library로 추출
import { AuthGuard } from '@libraries/auth-utils';3. Feature 패키지는 Library 패키지를 참조 가능
// [권장] packages/features/auth/Login.tsx
import { Button } from '@libraries/ui-components';
import { api } from '@libraries/api-client';ESLint는 다음 순서로 플러그인을 찾습니다:
1. node_modules/eslint-plugin-<name>/
2. @scope/eslint-plugin-<name>/
3. eslint-plugin-local (로컬 플러그인)
사용 방법:
// .eslintrc.js
module.exports = {
plugins: ['boundaries'], // eslint-plugin-boundaries를 로드
rules: {
'boundaries/element-types': 'error',
},
};프로덕션 배포 없이 개발하려면:
방법 1: npm link
cd eslint-plugin-my-boundaries
npm link
cd ~/my-project
npm link eslint-plugin-my-boundaries방법 2: eslint-plugin-local
npm install --save-dev eslint-plugin-local// .eslintrc.js
module.exports = {
plugins: ['local'],
rules: {
'local/check-boundaries': 'error',
},
};ESLint 플러그인은 규칙(Rule) 의 집합입니다. 각 규칙은 AST(Abstract Syntax Tree)를 순회하며 패턴을 검사합니다.
// eslint-plugin-check-import/index.ts
export = {
rules: {
'no-invalid-import': {
meta: {
type: 'problem',
docs: {
description: 'Enforce package boundaries in monorepo',
category: 'Best Practices',
},
messages: {
invalidImport: 'Invalid import: {{from}} cannot import from {{to}}',
},
},
create(context) {
// 규칙 구현
},
},
},
};플러그인이 수행해야 할 작업:
1. Package.json 파싱
interface PackageInfo {
name: string;
type: 'library' | 'feature';
dependencies: string[];
}
function parsePackageInfo(packageJsonPath: string): PackageInfo {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
// 패키지 타입 판별
const isLibrary = pkg.name.includes('libraries');
const isFeature = pkg.name.includes('features');
return {
name: pkg.name,
type: isLibrary ? 'library' : isFeature ? 'feature' : 'unknown',
dependencies: Object.keys(pkg.dependencies || {}),
};
}2. Import 경로 정규화
function resolveImportPath(
importPath: string,
currentFile: string,
tsConfig: TsConfig
): string {
// 상대 경로 처리
if (importPath.startsWith('.')) {
return path.resolve(path.dirname(currentFile), importPath);
}
// TypeScript path alias 처리
const { paths, baseUrl } = tsConfig.compilerOptions;
for (const [alias, targets] of Object.entries(paths)) {
const pattern = alias.replace('/*', '');
if (importPath.startsWith(pattern)) {
const target = targets[0].replace('/*', '');
const relativePath = importPath.slice(pattern.length);
return path.join(baseUrl, target, relativePath);
}
}
// node_modules
return importPath;
}3. ImportDeclaration 핸들러
create(context) {
const currentFile = context.getFilename();
const currentPkg = findPackageInfo(currentFile);
return {
ImportDeclaration(node) {
const importPath = node.source.value;
const resolvedPath = resolveImportPath(
importPath,
currentFile,
tsConfig
);
const targetPkg = findPackageInfo(resolvedPath);
// 의존성 규칙 검증
if (isInvalidDependency(currentPkg, targetPkg)) {
context.report({
node,
messageId: 'invalidImport',
data: {
from: currentPkg.name,
to: targetPkg.name,
},
});
}
},
};
}4. 의존성 규칙 검증
function isInvalidDependency(
from: PackageInfo,
to: PackageInfo
): boolean {
// 규칙 1: Library는 Feature 참조 불가
if (from.type === 'library' && to.type === 'feature') {
return true;
}
// 규칙 2: Feature는 다른 Feature 참조 불가
if (
from.type === 'feature' &&
to.type === 'feature' &&
from.name !== to.name
) {
return true;
}
return false;
}완성된 코드는 GitHub에서 확인할 수 있습니다:
ESLint CLI에서는 정상 동작하지만, IntelliJ IDEA에서 path alias 기반 import가 에러로 표시되지 않는 문제가 발생했습니다.
원인:
IntelliJ IDEA는 가장 가까운 .eslintrc 파일을 기준으로 ESLint를 실행합니다. 모노레포에서 각 패키지마다 ESLint 설정이 있는 경우, root의 설정을 인식하지 못합니다.
IntelliJ IDEA 설정:
Settings → Languages & Frameworks → JavaScript → Code Quality Tools → ESLint
→ Manual ESLint Configuration
→ Working Directory: ./
VS Code 설정:
// .vscode/settings.json
{
"eslint.workingDirectories": ["./"]
}이렇게 설정하면 root의 tsconfig.json path alias 설정을 올바르게 인식합니다.
직접 구현한 플러그인을 사용할지, 검증된 라이브러리를 사용할지 비교했습니다.
| 솔루션 | 장점 | 단점 |
|---|---|---|
| 직접 구현 | 완전한 커스터마이징 가능, 학습 기회 | 유지보수 부담, 엣지 케이스 처리 어려움 |
| @nx/eslint-plugin | NX 생태계 통합, 강력한 기능 | NX 의존성 (회사 정책상 사용 불가) |
| eslint-plugin-boundaries | 독립적, 디버깅 모드 제공, 커뮤니티 검증됨 | 초기 설정 복잡 |
NX 모노레포 도구와 통합된 플러그인입니다.
// .eslintrc.json
{
"plugins": ["@nx"],
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:library"]
},
{
"sourceTag": "type:library",
"bannedExternalImports": ["@features/*"]
}
]
}
]
}
}문제: 회사 정책상 NX 라이브러리 사용 불가 → 배제
독립적으로 사용 가능한 검증된 솔루션입니다.
// .eslintrc.js
module.exports = {
plugins: ['boundaries'],
settings: {
'boundaries/elements': [
{
type: 'library',
pattern: 'packages/libraries/*',
mode: 'folder',
},
{
type: 'feature',
pattern: 'packages/features/*',
mode: 'folder',
},
],
'boundaries/ignore': ['**/*.test.ts'],
},
rules: {
'boundaries/element-types': [
'error',
{
default: 'disallow',
rules: [
{
from: 'feature',
allow: ['library'],
},
{
from: 'library',
disallow: ['feature'],
},
],
},
],
},
};장점:
BOUNDARIES_DEBUG=1 환경변수로 상세 로그다음 이유로 eslint-plugin-boundaries를 선택했습니다:
결과적으로 직접 구현한 플러그인은 사용하지 않았지만, 다음을 얻었습니다:
ESLint 내부 동작 이해:
TypeScript 설정 통합:
tsconfig.json path alias 파싱실제 겪은 이슈:
이러한 경험은 eslint-plugin-boundaries 설정 시 트러블슈팅에 큰 도움이 되었습니다.
{
"name": "@my-app/feature-dashboard",
"meta": {
"type": "feature"
}
}{
"name": "@my-app/library-ui",
"meta": {
"type": "library"
}
}// packages/libraries/ui-components/AuthButton.tsx
import { useAuth } from '@features/auth'; // [주의] ESLint 에러
// Error: boundary-types
// Library packages cannot import from feature packages// packages/features/dashboard/Dashboard.tsx
import { LoginForm } from '@features/auth'; // [주의] ESLint 에러
// Error: boundary-types
// Feature packages cannot import from other feature packages모노레포에서 패키지 간 의존성 규칙을 정의하고 자동화하는 것은 아키텍처 일관성 유지에 필수적입니다. ESLint 플러그인을 활용하면 개발자의 실수를 사전에 방지할 수 있습니다.
핵심 권장사항:
명확한 의존성 규칙 정의
프로덕션 환경에는 검증된 솔루션 사용
직접 구현은 학습 목적으로 권장
IDE 통합 확인
참고 자료:
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션