뒤로가기

모노레포 의존성 관리: ESLint Boundaries 플러그인으로 패키지 경계 강제하기

eslint

모노레포에서 여러 패키지를 관리할 때, 패키지 간 의존성 규칙을 정의하고 강제하는 것은 아키텍처 일관성을 유지하는 핵심 요소입니다. 그러나 개발자의 실수나 부주의로 의도하지 않은 의존성이 추가될 수 있습니다.

이 글에서는 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';

규칙 설정의 이점

  • 순환 의존성 방지: Feature 간 참조를 막아 순환 참조 근본적으로 차단
  • 관심사 분리: Library는 도메인에 독립적, Feature는 비즈니스 로직에 집중
  • 재사용성 향상: 공통 기능이 Library로 명확히 분리됨
  • 코드 스플리팅 최적화: 의존성 트리가 단순해져 번들 분리 용이

ESLint 플러그인 동작 원리

ESLint 플러그인 로드 메커니즘

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 플러그인 구현

플러그인 구조

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-plugin-check-import

IDE 통합 문제 해결

문제: TypeScript Path Alias 미지원

ESLint CLI에서는 정상 동작하지만, IntelliJ IDEA에서 path alias 기반 import가 에러로 표시되지 않는 문제가 발생했습니다.

원인:

IntelliJ IDEA는 가장 가까운 .eslintrc 파일을 기준으로 ESLint를 실행합니다. 모노레포에서 각 패키지마다 ESLint 설정이 있는 경우, root의 설정을 인식하지 못합니다.

해결책: Working Directory 명시

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 설정을 올바르게 인식합니다.

프로덕션 솔루션 비교

직접 구현 vs 기존 라이브러리

직접 구현한 플러그인을 사용할지, 검증된 라이브러리를 사용할지 비교했습니다.

솔루션 장점 단점
직접 구현 완전한 커스터마이징 가능, 학습 기회 유지보수 부담, 엣지 케이스 처리 어려움
@nx/eslint-plugin NX 생태계 통합, 강력한 기능 NX 의존성 (회사 정책상 사용 불가)
eslint-plugin-boundaries 독립적, 디버깅 모드 제공, 커뮤니티 검증됨 초기 설정 복잡

@nx/eslint-plugin

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 라이브러리 사용 불가 → 배제

eslint-plugin-boundaries

독립적으로 사용 가능한 검증된 솔루션입니다.

// .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 환경변수로 상세 로그
  • 📸 Capture 기능: 의존성 그래프 스냅샷 생성
  • [지원] 커뮤니티 검증: 수천 개 프로젝트에서 사용
  • 🔧 유연한 설정: glob 패턴, custom matcher 지원

최종 선택: eslint-plugin-boundaries

다음 이유로 eslint-plugin-boundaries를 선택했습니다:

  1. 독립적 사용 가능 (NX 같은 추가 의존성 불필요)
  2. 디버깅 도구 제공 (개발 생산성 향상)
  3. minimatch 기반 (직접 구현한 버전과 동일한 패턴 매칭)
  4. 활발한 유지보수 (정기 업데이트, 이슈 대응)

직접 구현의 가치

결과적으로 직접 구현한 플러그인은 사용하지 않았지만, 다음을 얻었습니다:

학습 효과

ESLint 내부 동작 이해:

  • AST 순회 메커니즘
  • ImportDeclaration node 구조
  • Context API 활용법

TypeScript 설정 통합:

  • tsconfig.json path alias 파싱
  • 상대 경로와 절대 경로 정규화
  • 모노레포 환경에서의 모듈 해석

문제 해결 능력

실제 겪은 이슈:

  • IDE와 CLI 간 동작 불일치
  • Working directory 설정의 중요성
  • Path alias 해석 복잡성

이러한 경험은 eslint-plugin-boundaries 설정 시 트러블슈팅에 큰 도움이 되었습니다.

실전 적용 예시

package.json 표시 규칙

{
  "name": "@my-app/feature-dashboard",
  "meta": {
    "type": "feature"
  }
}
{
  "name": "@my-app/library-ui",
  "meta": {
    "type": "library"
  }
}

ESLint 에러 예시

// 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 플러그인을 활용하면 개발자의 실수를 사전에 방지할 수 있습니다.

핵심 권장사항:

  1. 명확한 의존성 규칙 정의

    • Library ↛ Feature
    • Feature ↛ Feature (자기 자신 제외)
    • Feature → Library [권장]
  2. 프로덕션 환경에는 검증된 솔루션 사용

    • eslint-plugin-boundaries (독립적 사용 시)
    • @nx/eslint-plugin (NX 사용 시)
  3. 직접 구현은 학습 목적으로 권장

    • ESLint 동작 원리 이해
    • 트러블슈팅 능력 향상
    • 실제 사용은 검증된 라이브러리
  4. IDE 통합 확인

    • Working directory 설정
    • Path alias 인식 검증

참고 자료: