만들면서 배우는 클린 아키텍처 - 10. 아키텍처 경계 강제하기

청포도봉봉이·2025년 2월 16일
1
post-thumbnail

지금까지 아키텍처에 대해서 많은 이야기를 나눴다.

하지만 일정 규모 이상의 모든 프로젝트에서는 시간이 지나면서 아키텍처가 서서히 무너지게 된다. 계층 간의 경계가 약화되고, 코드는 점점 테스트하기 어려워지고, 새로운 기능을 구현하는 데 점점 더 많은 시간이 든다.

이번 장에서는 아키텍처 내의 경계를 강제하는 방법과 함께 아키텍처 붕괴에 맞서 싸우기 위해 취할 수 있는 몇 가지 조치를 살펴보겠다.

경계와 의존성

아키텍처 경계를 강제하는 여러 가지 방법에 대해 이야기하기에 앞서 아키텍처의 어디에 경계가 있고, '경계를 강제한다'는 것이 어떤 의미인지 먼저 살펴보자.

그림 10.1은 육각형 아키텍처의 요소들이, 2장에서 소개한 포괄적인 클린 아키텍처 방식과 유사한 4개의 계층에 어떻게 흩어져 있는지 보여준다.

가장 안쪽의 계층에는 도메인 엔티티가 있다. 애플리케이션 계층은 애플리케이션 서비스 안에 유스케이스를 구현하기 위해 도메인 엔티티에 접근한다. 어댑터는 인커밍 포트를 통해 서비스에 접근하고, 반대로 서비스는 아웃고잉 포트를 통해 어댑터에 접근한다. 마지막으로 설정 계층은 어댑터와 서비스 객체를 생성할 팩터리를 포함하고 있고, 의존성 주입 메커니즘을 제공한다.

의존성 규칙에 따르면 계층 경계를 넘는 의존성은 항상 안쪽 방향으로 향해야 한다.

이번 장에서는 이러한 의존성 규칙을 강제하는 방법들을 알아보고, (점선 화살표처럼) 잘못된 방향을 기리키는 의존성을 없게 만들고자 한다.

접근 제한자

경계를 강제하기 위해 자바에서 제공하는 가장 기본저인 도구인 접근 제한자(visibility modifier)부터 시작해보자.

접근 제한자는 내가 지난 몇 년간 진행한 거의 대부분의 신입 개발자 면접에서 단골로 출제했던 주제다. 면접자들에게 자바에 어떤 접근 제한자가 있고, 차이점이 무엇인지 물어봤다.

대부분의 면접자는 public, protected, private 제한자만 알고 있었다. 거의 대부분이 package-private(혹은 'default') 제한자를 몰랐다. 이것이 나에게는 왜 접근 제한자가 이런 식으로 구성돼 있는지 질문을 해나가면서 면접자가 직접 답을 추론해나갈 수 있는지 확인해보는 좋은 기회가 됐다.

그럼 package-private 제한자는 왜 그렇게 중요할까? 자바 패키지를 통해 클래스들을 응집적으로 '모듈'로 만들어 주기 때문이다. 이러한 모듈 내에 있는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다. 그럼 모듈의 진입점으로 활용될 클래스들만 골라서 public으로 만들면 된다. 이렇게 하면 의존성이 잘못된 방향을 가리켜서 의존성 규칙을 위반할 위험이 줄어든다.

접근 제한자를 염두에 두고 3장에서 본 패키지 구조를 다시 한번 살펴보자.

persistence 패키지에 있는 클래스들은 외부에서 접근할 필요가 없기 때문에 package-private(위의 트리에서 'o'로 표시)으로 만들 수 있다. 영속성 어댑터는 자신이 구현하는 출력 포트를 통해 접근된다. 같은 이유로 SendMoneyService를 package-private으로 만들 수 있다. 의존성 주입 메커니즘은 일반적으로 리플렉션을 이용해 클래스를 인스턴스로 만들기 때문에 package-private 이더라도 여전히 인스턴스를 만들 수 있다.

이 방법을 스프링에서 사용하려면 9장에서 설명한 클래스패스 스캐닝을 이용해야만 한다. 다른 방법에서는 객체의 인스턴스들을 우리가 직접 생성해야 하기 때문에 public 제한자를 이용해야 한다.

예제의 나머지 클래스들은 아키텍처의 정의에 의해 public('+'로 표시)이어야 한다. domain 패키지는 다른 계층에서 접근할 수 있어야 하고, application 계층은 web 어댑터와 persistence 어댑터에서 접근 가능해야 한다.

package-private 제한자는 몇 개 정도의 클래스만 이뤄진 작원 모듈에서 가장 효과적이다. 그러나 패키지 내의 클래스가 특정 개수를 넘어가기 시작하면 하나의 패키지에 너무 많은 클래스를 포함하는 것이 혼란스럽다. 이렇게 되면 코드를 쉽게 찾을 수 있도록 하위 패키지를 만드는 방법을 선호한다. 하지만 자바는 하위 패키지를 다른 패키지로 취급하기 때문에 package-private 멤버에 접근할 수 없다. 그래서 하위 패키지의 멤버는 public으로 만들어서 바깥 세계에서 노출시켜야 하기 때문에 우리의 아키텍처에서 의존성 규칙이 깨질 수 있는 환경이 만들어진다.

컴파일 후 체크

클래스에 public 제한자를 쓰면 아키텍처 상의 의존성 방향이 잘못되더라도 컴파일러는 다른 클래스들이 이 클래스를 사용하도록 허용한다. 이런 경우에는 컴파일러가 전혀 도움이 되지 않기 때문에 의존성 규칙을 위반했는지 확인할 다른 수단을 찾아야 한다.

한 가지 방법은 컴파일 후 체크(post-compile check)를 도입하는 것이다. 다시 말해, 코드가 컴파일된 후에 런타임에 체크한다는 뜻이다. 이러한 런타임 체크는 지속적인 통합 빌드 환경에서 자동화된 테스트 과정에서 가장 잘 동작한다.

이러한 체크를 도와주는 자바용 도구로 ArchUnit이 있다. 다른 무엇보다 ArchUnit은 의존성 방향이 기대한 대로 잘 설정돼 있는지 체크할 수 있는 API를 제공한다. 의존성 규칙 위반을 발견하면 예외를 던진다. 이 도구는 JUnit과 같은 단위 테스트 프레임워크 기반에서 가장 잘 동작하며 의존성 규칙을 위반할 경우 테스트를 실패시킨다.

이전 절에서 정의한 패키지 구조대로 각 계층이 전용 패키지를 가지고 있다고 가정하면 ArchUnit으로 계층 간의 의존성을 체크할 수 있다. 예를 들어, 도메인 계층에서 바깥쪽의 애플리케이션 계층으로 향하는 의존성이 없다는 것을 체크할 수 있다.

public class DependencyRuleTests {

    private final JavaClasses classes = new ClassFileImporter()
            .importPackages("io.refactoring.bank.account");

    @Test
    void domain_should_not_depend_on_application() {
        ArchRule rule = classes()
                .that().resideInAPackage("io.refactoring.bank.account.domain..") // domain 패키지의 모든 클래스들은
                .should().onlyDependOnClassesThat()                              // 의존해야 한다
                .resideOutsideOfPackages("io.refactoring.bank.account.application.."); // application 패키지 외의 다른 패키지에만

        rule.check(classes);
    }
}

ArchUnit API를 이용하면 적은 작업만으로도 육각형 아키텍처 내에서 관련된 모든 패키지를 명시할 수 있는 일종의 도메인 특화 언어(DSL)를 만들 수 있고, 패키지 사이의 의존성 방향이 올바른지 자동으로 체크할 수 있다.

@Test
void validate_registration_context_architecture() {
    HexagonalArchitecture.boundedContext("io.refactoring.bank.buckpal")  // 전체 패키지 경로 지정
            .withDomainLayer("domain")
                .withAdaptersLayer("adapter")
                .incoming("web")
                .outgoing("persistence")
                .and()
            .withApplicationLayer("application")
                .services("service")
                .incomingPorts("port.in")
                .outgoingPorts("port.out")
                .and()
            .withConfiguration("configuration")
            .check(new ClassFileImporter()
                    .importPackages("io.refactoring.buckpal..")); // 전체 패키지를 스캔
}

앞의 예제에서는 먼저 바운디드 컨텍스트의 부모 패키지를 지정한다(단일 바운디드 컨텍스트라면 애플리케이션 전체에 해당한다). 그런 다음 도메인, 어댑터, 어플리케이션, 설정 계층에 해당하는 하위 패키지들을 지정한다. 마지막에 호출하는 ㅊheck()는 몇 가지 체크를 실행하고 패키지 의존성이 의존성 규칙을 따라 유효하게 설정됐는지 검증한다. 육각형 아키텍처 DSL에 대한 코드는 예제 프로젝트의 HexagonalArchitecture 클래스에서 확인할 수 있다.

잘못된 의존성을 바로잡는 데 컴파일 후 체크가 큰 도움이 되긴 하지만, 실패에 안전(fail-safe)하지는 않다. 패키지 이름인 buckpal에 오타를 내면 테스트가 어떤 클래스도 찾지 못하기 때문에 의존성 규칙 위반 사례를 발견하지 못할 것이다. 오타가 하나라도 나거나 패키지명을 하나만 리팩터링해도 테스트 전체가 무의미해질 수 있다. 이런 상황을 방지하려면 클래스를 하나도 찾지 못했을 때 실패하는 테스트를 추가해야 한다. 그럼에도 불구하고 여전히 리팩터링에 취약한 것은 사실이다. 컴파일 후 체크는 언제나 코드와 함께 유지보수해야 한다.

빌드 아티팩트

지금까지 코드 상에서 아키텍처 경계를 구분하는 유일한 도구는 패키지였다. 모든 코드가 같은 모놀리식 빌드 아티팩트(monolithic build artifact)의 일부였던 셈이다.

빌드 아티팩트는 (아마 자동화된) 빌드 프로세스의 결과물이다. 자바 세계에서 요즘 가장 인기 있는 빌드 도구는 메이븐(Maven)과 그레이들(Gradle)이다. 그러므로 지금까지 단일 메이븐 혹은 그레이들 빌드 스크립트가 있고, 메이븐이나 그레이들을 호출해서 코드를 컴파일하고, 테스트하고, 하나의 JAR 파일로 패키징할 수 있었다고 상상하자.

빌드 도구의 주요한 기능 중 하나는 의존성 해결(dependency resolution)이다. 어떤 코드베이스를 빌드 아티팩트로 변환하기 위해 빌드 도구가 가장 먼저 할 일은 코드베이스가 의존하고 있는 모든 아티팩트가 사용 가능한지 확인하는 것이다. 만약 사용 불가한 것이 있다면 아티팩트 레포지토리로부터 가져오려고 시도한다. 이마저도 실패한다면 코드를 컴파일도 하기 전에 에러와 함께 빌드가 실패한다.

이를 활용해서 모듈과 아키텍처의 계층 간의 의존성을 강제할 수 있다(따라서 경계를 강제하는 효과가 생긴다). 각 모듈 혹은 계층에 대해 전용 코드베이스와 빌드 아티팩트로 분리된 빌드 모듈(JAR 파일)을 만들 수 있다. 각 모듈의 빌드 스크립트에서는 아키텍처에서 허용하는 의존성만 지정한다. 클래스들이 클래스패스에 존재하지도 않아 컴파일 에러가 발생하기 때문에 개발자들은 더이상 실수로 잘못된 의존성을 만들 수 없다.

그림 10.3은 아키텍처를 여러 개의 분리된 빌드 아티팩트로 나누는 몇 가지 선택지를 보여준다.

맨 왼쪽에는 설정, 어댑터, 애플리케이션 계층의 빌드 아티팩트로 이뤄진 기본적인 3개의 모듈 빌드 방식이 있다. 설정 모듈은 어댑터 모듈에 접근할 수 있고, 어댑터 모듈은 애플리케이션 모듈에 접근할 수 있다. 설정 모듈은 암시적이고 전이적인 의존성 때문에 애플리케이션 모듈에도 접근할 수 있다.

어댑터 모듈은 영속성 어댑터뿐만 아니라 웹 어댑터도 포함하고 있다. 즉, 빌드 도구가 어댑터 간의 의존성을 막지 않을 것이라는 뜻이다. 두 어댑터 간의 의존성이 의존성 규칙에서 엄격하게 금지된 것은 아니지만 (두 어댑터 모두 같은 바깥 계층에 있으므로) 대부분의 경우 어댑터를 서로 격리시켜 유지하는 것이 좋다.

어쨌든 영속성 계층의 변경이 웹 계층에 영향을 미치거나 웹 계층의 변경이 영속성 계층에 영향을 미치는 것을 바라지 않을 것이다.(단일 책임 원칙을 기억하자)

애플리케이션을 다른 서드파티 API에 연결하는 다른 종류의 어댑터에서도 마찬가지다. 실수로 어댑터 간에 의존성이 추가되는 바람에 API와 관련된 세부사항이 다른 어댑터로 새어나가는 것을 바라지 않을 것이다.

그렇기 때문에 하나의 어댑터 모듈을 여러 개의 빌드 모듈로 쪼개서 어댑터당 하나의 모듈이 되게 할 수도 있다. 그림 10.3의 두 번째 열이 여기에 해당한다.

다음으로 애플리케이션 모듈도 쪼갤 수 있다. 두 번째 열에서는 애플리케이션 모듈이 애플리케이션에 대한 인커밍/아웃고잉 포트, 그리고 이러한 포트를 구현하거나 사용하는 서비스, 도메인 로직을 담은 도메인 엔티티를 모두 포함하고 있다.

도메인 엔티티가 포트에서 전송 객체(transfer object)로 사용되지 않는 경우라면(8장에서 이야기한 '매핑하지 않기' 전략을 허용하지 않는 경우) 의존성 역전 원칙을 적용해서 포트 인터페이스만 포함하는 API 모듈을 분리해서 빼낼 수 이싿. 이는 그림 10.3의 세 번째 열에 해당한다.

어댑터 모듈과 애플리케이션 모듈은 API 모듈에 접근할 수 있지만, 그 반대는 불가능하다. API 모듈은 도메인 엔티티에 접근할 수도 없고 포트 인터페이스 안에서 도메인 엔티티를 사용할 수도 없다. 또한 어댑터는 더이상 엔티티와 서비스에 직접 접근할 수 없고 포트를 통해서 접근해야 한다.

한걸음 더 나아가 API 모듈을 인커밍 포트와 아웃고잉 포트 각각만 가지고 있는 두 개의 모듈로 쪼갤 수 있다.(그림 10.3의 4번째 열). 이런 식으로 인커밍 포트나 아웃고잉 포트에 대해서만 의존성을 선언함으로써 특정 어댑터가 인커밍 어댑터인지 아웃고잉 어댑터인지를 매우 명확하게 정의할 수 있다.

또, 애플리케이션 모듈을 더 쪼갤 수 있다. 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 쪼개는 것이다. 서비스만 가지고 있는 모듈과 도메인 엔티티만 가지고 있는 모듈로 쪼개는 것이다. 이렇게 하면 엔티티가 서비스에 접근할 수 없어지고, 도메인 빌드 아티팩트에 대한 의존성을 간단하게 선언하는 것만으로도 (다른 유스케이스, 다른 서비스를 가진) 다른 애플리케이션이 같은 도메인 엔티티를 사용할 수 있게 된다.

그림 10.3은 애플리케이션을 빌드 모듈로 쪼개는 다양한 방법을 묘사하고 있다. 그림에는 4개만 표현했지만 실제로 더 다양한 방법이 있다. 핵심은 모듈을 더 세분화할수록, 모듈 간 의존성을 더 잘 제어할 수 있게 된다는 것이다. 하지만 더 작게 분리할수록 모듈 간의 매핑을 더 많이 수행해야 한다. 이를 위해 8장에서 소개한 매핑 전략들 중 하나를 적용해야 한다.

이 밖에도 빌드 모듈로 아키텍처 경계를 구분하는 것은 패키지로 구분하는 방식과 비교했을 때 몇 가지 장점이 있다.

첫 번째로, 빌드 도구가 순환 의존성(circular dependency)을 극도로 싫어한다는 것이다. 순환 의존성은 하나의 모듈에서 일어나는 변경이 잠재적으로 순환 고리에 포함된 다른 모든 모듈을 변경하게 만들며, 단일 책임 원칙을 위배하기 때문에 좋지 않다. 무한 루프에 빠지기 때문이다. 그러므로 빌드 도구를 이용하면 빌드 모듈 간 순환 의존성이 없음을 확신할 수 있다.

반면 자바 컴파일러는 두 개 혹은 그 이상의 패키지에서 순환 의존성이 있든지 말든지 신경 쓰지 않는다.

두 번째로, 빌드 모듈 방식에서는 다른 모듈을 고려하지 않고 특정 모듈의 코드를 격리한 채로 변경할 수 있다. 일시적으로 특정 어댑터에서 컴파일 에러가 생기는 애플리케이션 계층을 리팩터링하고 있다고 상상해보자. 만약 어댑터와 애플리케이션 계층이 같은 빌드 모듈에 있다면 어댑터가 컴파일되지 않더라도 애플리케이션 계층의 테스트를 실행할 수 있는 경우에도 대부분의 IDE는 테스트를 실행하려면 어댑터의 컴파일 에러를 모두 고쳐야 한다고 고집할 것이다. 만약 애플리케이션 계층이 독립된 빌드 모듈이라면 IDE가 어댑터에 신경 쓰지 않을 것이기 때문에 애플리케이션 계층의 테스트를 마음대로 실행할 수 있다. 메이븐이나 그레이들로 빌드 프로세스를 실행하는 것 역시 마찬가지다. 만약 두 계층이 같은 빌드 모듈에 있다면 어느 한쪽 계층의 컴파일 에러 때문에 빌드가 실패할 것이다.

그러므로 여러 개의 빌드 모듈은 각 모듈을 격리한 채로 변경할 수 있게 해준다. 심지어 각 모듈을 자체 코드 레포지토리에 넣어 서로 다른 팀이 서로 다른 모듈을 유지보수하게 할 수도 있다.

마지막으로, 모듈 간 의존성이 빌드 스크립트에 분명하게 선언돼 있기 때문에 새로운 의존성을 추가하는 일은 우연이 아닌 의식적인 행동이 된다. 어떤 개발자가 당장은 접근할 수 없는 특정 클래스에 접근해야 할 일이 생기면 빌드 스크립트에 이 의존성을 추가하기에 앞서 정말 이 의존성이 필요한 것인지 생각할 여지가 생긴다.

하지만 이런 장점에는 빌드 스크립트를 유지보수하는 비용을 수반하기 때문에 아키텍처를 여러 개의 빌드 모듈로 나누기 전에 아키텍처가 어느 정도는 안정된 상태여야 한다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

기본적으로 소프트웨어 아키텍처는 아키텍처 요소 간의 의존성을 관리하는 게 전부다. 만약 의존성이 거대한 진흙 덩어리(big ball of mud)가 된다면 아키텍처 역시 거대한 진흙 덩어리가 된다.

그렇기 때문에 아키텍처를 잘 유지해나가고 싶으면 의존성이 올바른 방향을 가리키고 있는지 지속적으로 확인해야 한다.

새로운 코드를 추가하거나 리팩터링할 때 패키지 구조를 항상 염두에 둬야 하고, 가능하다면 package-private 가시성을 이용해 패키지 바깥에서 접근하면 안 되는 클래스에 대한 의존성을 피해야 한다.

하나의 빌드 모듈 안에서 아키텍처 경계를 강제해야 하고, 패키지 구조가 허용하지 않아 package-private 제한자를 사용할 수 없다면 ArchUnit 같은 컴파일 후 체크 도구를 이용해야 한다.

그리고 아키텍처가 충분히 안정적이라고 느껴지면 아키텍처 요소를 독립적인 빌드 모듈로 추출해야 한다. 그래야 의존성을 분명하게 제어할 수 있기 때문이다.

아키텍처 경계를 강제하고 시간이 지나도 유지보수하기 좋은 코드를 만들기 위해 세 가지 접근 방식 모두를 함께 조합해서 사용할 수 있다.

profile
서버 백엔드 개발자

0개의 댓글