들어가며: 폴리레포 환경의 아키텍처 거버넌스 문제

Netflix는 수만 개의 Java 리포지토리를 운영하는 폴리레포(Polyrepo) 전략을 사용합니다. 각 팀이 독립적으로 개발하지만, 공통 빌드 로직과 라이브러리 사용 규칙을 일관되게 유지하는 것은 매우 어려운 과제입니다. 특히, 하위 호환성을 깨는 변경(breaking change)이 발생하면 전체 서비스에 영향을 줄 수 있습니다.

이 글에서는 Netflix JVM Platform 팀이 이러한 문제를 해결하기 위해 만든 Nebula ArchRules 플러그인을 소개합니다. 단순한 툴 소개를 넘어, 왜 기존 정적 분석 도구(PMD, SpotBugs) 대신 ArchUnit을 선택했고, 어떻게 이를 5,000개 리포지토리 규모로 확장했는지 그 설계 원리를 살펴봅니다.

참고: 이 글은 Netflix Tech Blog의 'Scaling ArchUnit with Nebula ArchRules'를 기반으로 합니다. 더 자세한 내용은 원문을 참고하세요.

Netflix microservices architecture diagram with thousands of Java repositories connected by Gradle plugins System Abstract Visual

ArchUnit이 정적 분석 도구(PMD, SpotBugs)보다 나은 이유

1. AST가 아닌 바이트코드(Bytecode) 분석

PMD와 같은 도구는 소스 코드의 AST(Abstract Syntax Tree)를 분석합니다. 이는 언어에 종속적입니다. Kotlin, Scala 등 JVM 언어마다 AST 구조가 다르기 때문에 규칙을 각 언어에 맞게 다시 작성해야 합니다. 반면, ArchUnit은 ASM을 사용해 컴파일된 바이트코드를 직접 분석합니다. 따라서 어떤 언어로 작성되었든, 실제 실행되는 코드를 기준으로 규칙을 적용할 수 있습니다.

2. 타입 안전(Type-Safe)하고 IDE 친화적인 규칙 작성

PMD의 XPath 기반 규칙은 다음과 같이 생겼습니다:

<!-- PMD XPath 규칙 예시: DateTime이 명시적 Zone 없이 생성되지 않도록 함 -->
<rule name="AvoidDateTimeWithoutZone" language="java"
      message="DateTime must be instantiated with an explicit time zone">
    <properties>
        <property name="xpath">
            <value>
                //AllocationExpression/ClassOrInterfaceType[
                @Image='DateTime' and (
                (count(..//Name[@Image='DateTimeZone.UTC'])&lt;=0)
                and
                (count(..//Name[@Image='DateTimeZone.forID'])&lt;=0)
                ) or (
                (
                (count(..//Name[@Image='DateTimeZone.UTC'])&gt;0)
                or
                (count(..//Name[@Image='DateTimeZone.forID'])&gt;0)
                ) and (../Arguments/ArgumentList and count(../Arguments/ArgumentList/Expression) = 1)
                )
                ]
            </value>
        </property>
    </properties>
</rule>

이와 동일한 규칙을 ArchUnit으로 작성하면 다음과 같습니다:

// ArchUnit 규칙: DateTime 생성자에 Zone 인자가 없으면 경고
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public class DateTimeRules {
    public static final ArchRule AVOID_DATETIME_WITHOUT_ZONE = 
        priority(Priority.MEDIUM)
            .noClasses()
            .should()
            .callConstructorWhere(
                // 생성자에 DateTimeZone 인자가 없는 경우
                target(doesNotHave(rawParameterTypes(DateTimeZone.class)))
                // DateTime 클래스의 생성자인 경우
                .and(targetOwner(assignableTo(DateTime.class)))
            );
}

차이가 확연히 드러납니다. ArchUnit은 순수 Java 코드로 규칙을 작성하며, IDE의 자동 완성, 리팩토링, 타입 검사 기능을 그대로 활용할 수 있습니다. 단위 테스트도 간단합니다.

3. 클래스 관계 그래프 활용

ArchUnit은 ASM으로 전체 클래스패스를 로드하여 클래스 간의 의존성 그래프를 구성합니다. 이를 통해 "A 클래스가 B 클래스의 deprecated 메서드를 호출하는지"와 같은 맥락 있는 규칙을 쉽게 작성할 수 있습니다.

Developer analyzing ArchUnit rule violations report on a terminal with code snippets Technical Structure Concept

Nebula ArchRules: 규칙을 공유하고 자동 실행하는 플랫폼

ArchUnit 자체는 단일 리포지토리에서 JUnit 테스트로 실행되도록 설계되었습니다. Netflix의 요구사항은 조직 전체의 모든 리포지토리에서 동일한 규칙을 실행하는 것이었습니다. 이를 위해 Nebula ArchRules는 두 가지 핵심 플러그인을 제공합니다.

1. ArchRules Library Plugin: 규칙을 라이브러리로 패키징

이 플러그인은 Gradle 프로젝트에 archRules라는 추가 소스 세트(source set)를 생성합니다. 여기에 ArchRulesService 인터페이스를 구현한 클래스를 작성하면 됩니다.

// build.gradle (규칙 라이브러리 프로젝트)
plugins {
    id 'nebula.archrules.library'
}

dependencies {
    // ArchUnit API 의존성
    implementation 'com.tngtech.archunit:archunit:1.2.0'
}
// src/archRules/java/com/example/rules/GuavaRules.java
package com.example.rules;

import com.netflix.nebula.archrules.service.ArchRulesService;
import com.tngtech.archunit.lang.ArchRule;

import java.util.HashMap;
import java.util.Map;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public class GuavaRules implements ArchRulesService {

    // Guava Optional 대신 Java Optional을 사용하도록 강제
    static final ArchRule OPTIONAL = 
        priority(Priority.MEDIUM)
            .noClasses()
            .should()
            .dependOnClassesThat()
            .haveFullyQualifiedName("com.google.common.base.Optional")
            .because("Java Optional is preferred over Guava Optional");

    @Override
    public Map<String, ArchRule> getRules() {
        Map<String, ArchRule> rules = new HashMap<>();
        rules.put("guava-optional", OPTIONAL);
        return rules;
    }
}

이렇게 작성된 규칙은 arch-rules classifier로 별도 Jar에 패키징되어 배포됩니다.

2. ArchRules Runner Plugin: 규칙을 자동으로 실행

적용 대상 리포지토리에서는 다음과 같이 설정합니다:

// build.gradle (규칙을 적용받을 프로젝트)
plugins {
    id 'nebula.archrules.runner'
}

dependencies {
    // 독립형 규칙 라이브러리: 모든 소스 세트에 적용
    archRules 'com.example:my-standalone-rules:1.0.0'
    
    // 번들 규칙 라이브러리: 자동 감지됨 (해당 라이브러리를 의존성으로 추가하면 자동 적용)
    testImplementation 'com.example:my-test-helper:2.0.0'  // 이 라이브러리에 번들된 규칙이 자동 실행됨
}

// 규칙 커스터마이징
archRules {
    ruleClass("com.netflix.nebula.archrules.deprecation") {
        priority = "HIGH"
    }
}

**번들 규칙(Bundled Rule Libraries)**이 특히 강력합니다. 라이브러리 개발자가 자신의 라이브러리 사용법에 특화된 규칙을 함께 배포하면, 사용자는 별도 설정 없이도 해당 규칙이 자동으로 적용됩니다.

3. 리포팅: 사람이 읽기 쉬운 실패 정보

규칙 위반 시 다음과 같은 상세한 메시지가 제공됩니다:

규칙 위반: 'Deprecated API 사용 금지'
우선순위: HIGH
클래스: com.example.MyService
위치: MyService.java:42
설명: Deprecated API 'com.oldlib.LegacyClass.oldMethod()'가 사용되었습니다.
       이 API는 제거 대상이므로 'com.newlib.NewClass.newMethod()'로 대체하세요.

이 정보는 JSON 파일로도 출력되어, Netflix는 이를 내부 개발자 포털에 연동하여 모든 팀이 자신의 코드에 어떤 위반 사항이 있는지 대시보드에서 확인할 수 있도록 했습니다.

Cloud infrastructure diagram showing polyrepo strategy and shared build logic across teams

실무 적용 사례: API 생명주기 관리

Netflix JVM Platform 팀은 이 플러그인을 사용하여 라이브러리 API 생명주기 관리 문제를 해결했습니다. 라이브러리 개발자는 다음과 같은 어노테이션을 API에 붙입니다:

  • @Deprecated (Java 표준): 더 이상 사용하지 않음
  • @Public (커스텀): 외부에서 사용해도 되는 API
  • @Experimental (커스텀): 아직 안정적이지 않음
  • 기본값: 내부 전용(internal)

그리고 ArchRules로 다음과 같은 규칙을 작성합니다:

// 라이브러리 패키지 외부에서 Deprecated API 사용 금지
ArchRuleDefinition.priority(Priority.MEDIUM)
    .noClasses()
    .that(resideOutsideOfPackage(packageName + ".."))
    .should()
    .dependOnClassesThat(
        resideInAPackage(packageName + "..")
            .and(are(deprecated()))
    )
    .orShould()
    .accessTargetWhere(
        targetOwner(resideInAPackage(packageName + ".."))
            .and(target(is(deprecated()))
                .or(targetOwner(is(deprecated()))))
    )
    .allowEmptyShould(true)
    .because("Deprecated APIs are subject to removal");

이제 라이브러리 개발자는 메인 브랜치 CI 빌드마다 모든 다운스트림 프로젝트에서 자신의 API가 어떻게 사용되고 있는지 보고서를 확인할 수 있습니다. "이 deprecated API를 실제로 사용하는 프로젝트가 3개뿐이니, 먼저 마이그레이션을 요청하고 제거하자"와 같은 의사 결정이 가능해집니다.

결과 및 성과

  • 358개의 규칙을 5,000개 이상의 리포지토리에서 실행 중
  • 약 100만 개의 이슈 탐지
  • 약 1,000개의 High Priority 이슈 식별

이를 통해 Netflix는 방대한 마이크로서비스 fleet의 기술 부채(technical debt)를 가시화하고, 우선순위를 정해 효율적으로 해결할 수 있게 되었습니다.

한국 개발 생태계에서의 적용 맥락

한국 SI/스타트업 환경에서도 이 접근법은 매우 유용합니다. 특히:

  • 멀티 모듈 프로젝트: 하나의 리포지토리에 여러 모듈이 있을 때, 모듈 간 의존성 규칙을 ArchRules로 정의할 수 있습니다.
  • 공통 라이브러리 관리: 회사 내 공통 라이브러리(예: 로깅, 보안, HTTP 클라이언트)의 사용법을 강제하고 싶을 때 번들 규칙이 효과적입니다.
  • 레거시 마이그레이션: Spring Boot 버전 업그레이드나 Jakarta EE 전환 시, 사용이 금지된 API를 실시간으로 감지할 수 있습니다.

주의사항: ArchUnit은 바이트코드를 분석하므로, 리플렉션(reflection)이나 동적 프록시(dynamic proxy)로 호출되는 코드는 감지하지 못할 수 있습니다. 또한 규칙이 너무 많아지면 빌드 시간이 증가할 수 있으므로, 우선순위가 낮은 규칙은 경고(warning) 수준으로 설정하는 것이 좋습니다.

다음 단계 학습 방향

  1. ArchUnit 공식 문서를 읽고 더 다양한 규칙 유형(클래스, 메서드, 필드, 어노테이션)을 익혀보세요.
  2. Nebula ArchRules 오픈소스 라이브러리를 참고하여 자신만의 규칙 라이브러리를 만들어보세요.
  3. OpenRewrite와 연계하여 자동 리팩토링 파이프라인을 구축하는 방법을 연구해보세요.

함께 보면 좋은 글

본 콘텐츠는 신뢰할 수 있는 출처를 바탕으로 AI 도구를 활용하여 초안이 작성되었으며, 편집자의 검토를 거쳐 발행되었습니다. 전문가의 조언을 대체하지 않습니다.