왜 순환 의존성이 문제인가

모노레포는 패키지 간 코드 공유가 쉽다는 장점이 있지만, 그만큼 A → B → A 형태의 순환 참조가 조용히 스며들기 쉽다. 순환이 생기면 빌드 순서를 위상 정렬로 결정할 수 없고, 증분 빌드 캐시가 무효화되며, 한 패키지의 사소한 변경이 무관해 보이는 패키지의 재빌드·재테스트를 유발한다. 런타임에서는 모듈 초기화 순서에 따라 undefined가 참조되는 비결정적 버그가 나타난다. 더 나쁜 것은 이런 순환이 리뷰에서 잘 안 보인다는 점이다. 한 PR에서 import 한 줄이 추가됐을 뿐인데 전체 그래프에 사이클이 닫힌다.

핵심 원칙은 명확하다. 순환은 리뷰가 아니라 CI가 막아야 한다. 사람이 매번 그래프를 머릿속으로 추적할 수 없기 때문이다.

어디서 감지할 것인가: 파일 vs 패키지

순환은 두 층위에서 발생한다. 파일(모듈) 단위 import 사이클과, 패키지(워크스페이스) 단위 의존 사이클이다. 둘은 도구도 다르고 위험도도 다르다.

층위대표 도구주요 위험
파일/모듈madge, dpdm, eslint(import/no-cycle)런타임 초기화 순서 버그
패키지/워크스페이스nx graph, turbo, pnpm list빌드 순서·캐시 무효화

실무에서는 두 층위를 모두 검사하되, 패키지 단위 사이클을 더 엄격하게 차단하는 것이 보통이다. 패키지 사이클은 아키텍처 경계가 무너졌다는 신호이기 때문이다.

파일 단위 감지: madge

madge는 TypeScript/JavaScript import 그래프를 만들어 사이클을 뽑아낸다. CI에서는 사이클이 하나라도 있으면 종료 코드를 0이 아닌 값으로 반환하게 해 파이프라인을 실패시킨다.

#!/usr/bin/env bash
set -euo pipefail

# tsconfig의 path alias까지 해석해서 순환을 검사
npx madge --circular \
  --extensions ts,tsx \
  --ts-config tsconfig.json \
  packages/*/src

# --circular 는 사이클이 있으면 exit code 1 을 반환한다.
# CI 는 이 종료 코드만으로 자동 실패한다.

이미 다수의 순환이 존재하는 레거시 저장소라면 한 번에 0으로 만들기 어렵다. 이때는 현재 사이클 개수를 baseline으로 고정하고, 그 수를 넘기면 실패시키는 방식으로 점진적으로 조인다.

패키지 단위 감지: 그래프에서 직접

워크스페이스 간 사이클은 각 패키지의 package.json 의존 관계를 그래프로 만들어 검사한다. 도구에 의존하지 않고 직접 짜두면 어떤 모노레포에도 붙일 수 있다.

import json, glob, sys

# 워크스페이스 의존 그래프 구성
graph = {}
for path in glob.glob("packages/*/package.json"):
    pkg = json.load(open(path))
    deps = {**pkg.get("dependencies", {}),
            **pkg.get("devDependencies", {})}
    # 내부 패키지(@myorg/*)만 그래프에 남긴다
    graph[pkg["name"]] = [d for d in deps if d.startswith("@myorg/")]

WHITE, GRAY, BLACK = 0, 1, 2
color = {n: WHITE for n in graph}

def dfs(node, stack):
    color[node] = GRAY
    for nxt in graph.get(node, []):
        if color.get(nxt) == GRAY:          # 뒤로 가는 간선 = 사이클
            cycle = stack[stack.index(nxt):] + [nxt]
            print("CYCLE:", " -> ".join(cycle))
            return True
        if color.get(nxt) == WHITE and dfs(nxt, stack + [nxt]):
            return True
    color[node] = BLACK
    return False

failed = any(dfs(n, [n]) for n in graph if color[n] == WHITE)
sys.exit(1 if failed else 0)

DFS 중 회색(GRAY, 방문 중) 노드로 향하는 간선이 곧 순환이다. 발견 즉시 경로를 출력하면 개발자가 어떤 import를 끊어야 할지 바로 안다.

CI에 차단 단계로 심기

감지 스크립트는 테스트·빌드보다 앞에 배치해 빠르게 실패시킨다. 순환이 있으면 어차피 빌드가 불안정하므로 뒤 단계를 돌릴 이유가 없다.

name: ci
on: [pull_request]

jobs:
  graph-guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      # 두 층위를 모두 검사, 하나라도 실패하면 잡 실패
      - name: file-level cycles
        run: bash scripts/check-madge.sh
      - name: package-level cycles
        run: python scripts/check-pkg-cycle.py

순환을 끊는 실전 패턴

감지 후 실제로 끊을 때 자주 쓰는 세 가지가 있다. 첫째, 공통 타입·인터페이스를 @myorg/contracts 같은 의존성 없는 리프 패키지로 추출한다. 양쪽이 서로가 아니라 공통 리프를 보게 만들어 사이클을 편다. 둘째, 의존 방향 역전(DIP)이다. 상위 모듈이 인터페이스를 정의하고 하위가 그것을 구현하도록 화살표를 뒤집는다. 셋째, 정말 양방향 통신이 필요하면 직접 import 대신 이벤트 버스나 콜백 주입으로 컴파일타임 의존을 런타임 의존으로 낮춘다.

주의점

몇 가지 함정이 있다. madge는 path alias나 동적 import를 놓칠 수 있으니 --ts-config를 반드시 넘기고 결과를 실제 그래프와 대조한다. import type만으로 이어진 타입 전용 순환은 런타임에 해가 없지만, 도구가 이를 구분하지 못하면 오탐이 난다. eslint의 import/no-cycle은 allowUnsafeDynamicCyclicDependency 같은 옵션으로 완화하되 남발하지 않는다. 마지막으로, baseline 방식은 반드시 숫자를 줄이는 방향으로만 갱신되도록 잠가야 한다. 그러지 않으면 baseline이 순환을 정당화하는 창구가 되어 감지 장치 자체가 무력화된다.