[의존성의 방향을 따라 2/5] 의존 그래프를 읽는 Planner

기술 블로그
페이스북링크드인트위터

레포 사이에도 의존성이 있다

하나의 레포 안에서 모듈 간 의존성은 build.gradle.ktsdependencies 블록으로 명확하게 드러납니다. 하지만 레포 간 의존성은 어떨까요?

레포들은 Nexus를 통해 아티팩트를 주고받습니다. 각 서비스 레포의 build.gradle.kts에는 어떤 공유 라이브러리의 어떤 버전에 의존하는지가 선언되어 있습니다. 예를 들어 service-acommons-corebackend-commons최신-안정 버전에 의존한다고 선언합니다. 그룹 ID 접두사(예: com.example.commons)로 한 묶음의 아티팩트 버전을 한꺼번에 지정하는 방식입니다.

핵심은 이 선언 방식을 버전 관리 플러그인이 모든 레포에 강제한다는 점입니다. 개별 모듈이 제멋대로 다른 버전을 선언할 수 없고, 레포마다 같은 형태의 선언이 반복됩니다. 이게 왜 중요한지는 잠시 뒤에 분명해집니다 — 일관된 선언이 없으면 그래프를 읽는 일 자체가 불가능하기 때문입니다.

Planner는 이 선언들을 파싱해서 레포 간 의존 관계 그래프를 구축합니다. 한 레포 안의 모듈 의존성이 컴파일러에게 익숙한 영역이라면, 레포 사이의 의존성은 빌드 시스템 입장에서 보이지 않는 영역입니다. Nexus에 올라간 아티팩트의 버전 숫자로만 연결되어 있을 뿐, 어느 레포가 어느 레포를 끌어다 쓰는지는 어디에도 한눈에 정리되어 있지 않습니다. Planner가 가장 먼저 하는 일은 이 흩어진 선언들을 모아 전체 지형도를 복원하는 것입니다.


실제 의존 그래프

백엔드 생태계의 핵심 의존 체인은 4단계 깊이를 가집니다.

핵심 레포의 역할을 정리하면 이렇습니다.

레포 역할 하위 의존 레포 수
gradle-plugins 빌드 플러그인 (version-management, name-policy 등) 전체 (50+)
commons-core 순수 Kotlin/Spring 유틸리티 라이브러리 40+
backend-commons Spring Boot 스타터, Outbox, 인가, 예외 처리 40+
서비스 레포들 도메인별 비즈니스 로직 0 (리프 노드)

이 그래프에서 중요한 성질이 있습니다. DAG(Directed Acyclic Graph)라는 것. 순환 의존이 없습니다. commons-coreservice-a에 의존하는 일은 없습니다. 라이브러리는 서비스를 알지 못하고, 서비스만 라이브러리를 알 뿐입니다.

이게 단순한 미관상의 깔끔함이 아니라는 점이 핵심입니다. 만약 순환이 하나라도 있다면 — A가 B를 쓰고 B가 다시 A를 쓴다면 — "무엇부터 올려야 하는가"라는 질문에 답이 없습니다. A를 먼저 올리려면 B가 필요하고, B를 올리려면 A가 필요한 교착 상태에 빠집니다. 순환이 없다는 보장이 있어야만 위상 정렬(topological sort)이 가능하고, "이것부터, 그다음 저것" 하는 전파 순서를 기계가 자동으로 계산할 수 있습니다. 뒤에서 보겠지만 이 DAG 성질 역시 공짜로 주어진 게 아니라, 빌드 컨벤션이 의존 방향을 강제한 결과입니다.


전파 방향은 하나가 아니다

1화에서 "Spring Boot 버전업"을 예로 들었습니다. Spring Boot 버전은 upstream부터 올려야 합니다. backend-commons가 먼저 Spring Boot 3.5.10으로 올라가고, 그 위에서 서비스 레포들이 새 버전의 commons를 받아야 합니다. 순서가 뒤집히면, 서비스 레포가 아직 구버전인 commons의 API를 호출하면서 컴파일 에러가 발생합니다.

하지만 Kotlin 버전업은 다릅니다. 그리고 이 "다름"이 Planner가 단순한 위상 정렬기를 넘어서야 하는 이유입니다. 같은 의존 그래프 위에서도, 변경의 성격에 따라 안전한 전파 방향이 정반대로 뒤집힙니다.

Kotlin: downstream-first

Kotlin 컴파일러 버전을 올리면, 생성되는 바이트코드의 메타데이터 버전이 올라갑니다. 상위 Kotlin으로 컴파일된 라이브러리를 하위 Kotlin으로 컴파일하는 클라이언트가 읽으면, 메타데이터 호환성 문제가 발생할 수 있습니다.

Kotlin
[안전한 순서 — downstream-first]

서비스 레포들 (Kotlin 2.2.20) ← 먼저 올림
    ↑ 사용
backend-commons (Kotlin 2.1.0) ← 나중에 올림
    ↑ 사용
commons-core (Kotlin 2.1.0) ← 가장 나중에 올림

서비스 레포(downstream)가 먼저 새 Kotlin을 사용해도, 라이브러리(upstream)가 구 Kotlin으로 컴파일되어 있으면 문제없이 읽을 수 있습니다. 반대로 라이브러리가 먼저 올라가면, 아직 구 Kotlin을 쓰는 서비스 레포에서 메타데이터를 읽지 못할 수 있습니다.

Spring: upstream-first

Spring Boot 버전업은 반대입니다. backend-commons가 Spring Boot 3.5.10 기반으로 API를 제공하면, 서비스 레포들은 그 API를 호출할 때 새 버전의 Spring이 필요합니다.

Kotlin
[안전한 순서 — upstream-first]

commons-core (Spring Boot 3.5.10) ← 먼저 올림
    ↓ 제공
backend-commons (Spring Boot 3.5.10) ← 다음
    ↓ 제공
서비스 레포들 (Spring Boot 3.5.10) ← 가장 나중에 올림

라이브러리가 먼저 새 Spring Boot로 올라가고, 새 아티팩트를 퍼블리시한 뒤, 서비스 레포들이 그 아티팩트를 받아서 빌드합니다.

전파 방향 정리

변경 유형 전파 방향 이유
Kotlin 버전 downstream → upstream 바이트코드 메타데이터 하위 호환
Spring Boot 버전 upstream → downstream API/BOM 의존성 방향
라이브러리 API 변경 upstream → downstream 제공자가 먼저 변경, 소비자가 적응
빌드 플러그인 변경 upstream → downstream 플러그인이 먼저, 적용 레포가 나중
보안 패치 (긴급) upstream → downstream, 병렬 최대화 속도 우선

Planner의 알고리즘

Planner가 전파 순서를 결정하는 과정을 단계별로 설명합니다.

Step 1: 의존 그래프 구축

모든 레포의 build.gradle.kts에서 의존성 선언 블록을 파싱합니다. 버전 관리 플러그인이 모든 레포에서 동일한 선언 형태를 강제하므로, 파싱 로직이 하나면 됩니다.

Kotlin
// 파싱 결과 예시 (의사 코드)
의존_그래프 = {
    "service-a"        → { "commons-core"(최신-안정), "backend-commons"(최신-안정) },
    "service-b"        → { "commons-core"(3.71.0),    "backend-commons"(최신-안정) },
    "backend-commons"  → { "commons-core"(이전-안정-SNAPSHOT) },
    "commons-core"     → { "gradle-plugins"(1.8.0) },
    // ... 50+ 레포
}

Step 2: 변경 성격 판별

변경 요청(예: "Spring Boot 3.5.5 → 3.5.10")의 성격을 판별합니다. 이 판별이 전파 방향을 결정합니다.

Kotlin
enum class PropagationDirection {
    UPSTREAM_FIRST,    // Spring, 라이브러리 API
    DOWNSTREAM_FIRST,  // Kotlin, JVM
    PARALLEL,          // 독립적인 변경 (보안 패치 긴급 모드)
}

fun determinePropagationDirection(change: Change): PropagationDirection {
    return when {
        change.affectsCompilerMetadata() -> DOWNSTREAM_FIRST
        change.affectsRuntimeApi() -> UPSTREAM_FIRST
        change.isSecurityPatch && change.isCritical -> PARALLEL
        else -> UPSTREAM_FIRST  // 기본값
    }
}

Step 3: 위상 정렬로 Wave 생성

여기서 앞서 본 "방향이 뒤집힌다"는 성질이 알고리즘으로 구현됩니다. 핵심 아이디어는 의외로 단순합니다 — 그래프 구조 자체는 그대로 두고, 전파 방향이 downstream-first일 때만 간선을 통째로 뒤집은 뒤, 어느 경우든 동일한 위상 정렬을 돌리는 것입니다. 방향이라는 변수를 알고리즘 앞단의 "그래프 뒤집기" 한 번으로 흡수해버리면, 정렬 로직 자체는 한 벌만 유지하면 됩니다.

의존 그래프를 위상 정렬하되, 전파 방향에 따라 간선의 방향을 뒤집습니다.

Kotlin
fun planWaves(
    graph: DependencyGraph,
    direction: PropagationDirection,
): List<Wave> {
    val orderedGraph = when (direction) {
        UPSTREAM_FIRST -> graph                  // 원래 방향
        DOWNSTREAM_FIRST -> graph.reversed()     // 간선 뒤집기
        PARALLEL -> graph.flattenToSingleWave()  // 모두 Wave 0
    }

    // Kahn's algorithm으로 위상 정렬
    val waves = mutableListOf<Wave>()
    val inDegree = orderedGraph.computeInDegrees()
    var currentWave = inDegree.filter { it.value == 0 }.keys.toSet()

    while (currentWave.isNotEmpty()) {
        waves.add(Wave(currentWave))
        val nextInDegree = orderedGraph.removeNodes(currentWave)
        currentWave = nextInDegree.filter { it.value == 0 }.keys.toSet()
    }

    return waves
}

Step 4: 결과 — 실행 계획

Spring Boot 3.5.5 → 3.5.10 업그레이드의 경우, 생성되는 실행 계획은 이렇습니다.

Wave 0의 레포들은 다른 내부 레포에 의존하지 않으므로 먼저 처리됩니다. Wave 1은 Wave 0이 완료된 후에야 시작됩니다 (새 버전의 commons를 받아야 하므로). Wave 2의 40개 이상의 서비스 레포는 서로 의존하지 않으므로, 병렬로 처리됩니다.

이 실행 계획에서 눈여겨볼 것은, Wave의 개수가 곧 "직렬로 기다려야 하는 횟수"라는 점입니다. 4단계 깊이의 그래프라도 변경의 성격에 따라 Wave는 3개로 압축되고, 가장 무거운 Wave 2는 한 덩어리로 병렬 처리됩니다. 50개 레포를 일렬로 세우면 50번을 기다려야 하지만, 의존 구조를 읽으면 "꼭 기다려야 하는 순간"만 골라낼 수 있습니다. Planner의 출력이 단순한 순서 목록이 아니라 어디까지 동시에 가도 되는지를 알려주는 계획인 이유입니다.


일관된 DSL이 파싱을 가능하게 한다

Planner가 의존 그래프를 자동으로 구축할 수 있는 전제 조건이 있습니다. 모든 레포가 같은 방식으로 의존성을 선언한다는 것입니다.

버전 관리 플러그인은 의존성 버전을 선언하는 3단계 우선순위 시스템을 제공합니다. 한 묶음의 라이브러리 패밀리 버전을 그룹 ID 접두사로 한꺼번에 지정하는 단계, 특정 그룹의 버전을 지정하는 단계, 개별 아티팩트의 버전을 지정하는 단계. 이 세 단계의 선언 방식이 모든 레포에서 동일하게 강제됩니다.

만약 레포마다 의존성 선언 방식이 달랐다면 어떨까요?

Kotlin
// 레포 A: version catalog
[versions]
spring-boot = "3.5.10"

// 레포 B: 직접 선언
dependencies {
    implementation("org.springframework.boot:spring-boot-starter:3.5.10")
}

// 레포 C: 변수
val springBootVersion = "3.5.10"

// 레포 D: BOM만 사용
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.10"))

4가지 다른 형식을 파싱하는 것은 가능하지만, 유지보수 비용이 레포 수에 비례해서 증가합니다. 새로운 형식이 추가될 때마다 파서를 수정해야 하고, 엣지 케이스가 쌓입니다.

버전 관리 플러그인이 모든 레포에 동일한 선언 방식을 강제하기 때문에, Planner의 파싱 로직은 하나면 충분합니다. 이것이 본편 1화에서 이야기한 "구조적 일관성"이 Evergreen의 전제인 이유입니다.


그래프가 말해주는 것

의존 그래프는 단순히 "어떤 순서로 작업할까"를 넘어서, 변경의 폭발 반경(blast radius)을 보여줍니다.

  • gradle-plugins를 변경하면 → 50개 이상의 레포에 영향
  • backend-commons를 변경하면 → 40개 이상의 서비스 레포에 영향
  • service-a를 변경하면 → 해당 레포만 영향 (리프 노드)

이 정보는 리뷰 정책에도 반영됩니다. 폭발 반경이 큰 레포의 변경은 더 신중한 리뷰가 필요하고, Distributer가 PR을 생성할 때도 이 정보를 활용합니다. 같은 한 줄의 버전 변경이라도, gradle-plugins에서의 한 줄과 리프 노드에서의 한 줄은 위험의 무게가 전혀 다릅니다. 그래프는 그 무게의 차이를 정량적으로 알려주는 유일한 근거입니다.

결국 Planner가 하는 일은 "코드를 바꾸는 것"이 아니라 "무엇을, 어떤 순서로, 얼마나 조심해서 바꿔야 하는지"를 결정하는 것입니다. 한 줄의 변경도 그 자체로는 의미가 없고, 의존성의 방향이라는 맥락 위에 놓여야 비로소 안전한 작업이 됩니다. Planner가 그래프를 읽어 그 맥락을 세웠으니, 다음은 그 계획에 따라 실제 코드를 변환하는 Updater의 차례입니다.


다음 화 — OpenRewrite와 Claude가 코드를 변환한다. OpenRewrite recipe의 구조와 작성법, 그리고 AI가 빌드 에러를 분석하고 수정하는 루프를 살펴봅니다.

🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기
글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기
  • [의존성의 방향을 따라 1/5] 버전업이 고통인 이유
    50개 레포지토리의 Spring Boot 버전업, 왜 패치 하나가 조직 전체의 문제가 되는가
  • 아티클
    2020. 5. 25
    근태관리, 유연근무제, 그리고 코로나 시대
    코로나, 뉴노멀, 유연근무제, 그리고 근태관리