만들면서 배우는 클린 아키텍처 - 9. 애플리케이션 조립하기

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

유스케이스, 웹 어댑터, 영속성 어댑터를 구현해봤으니, 이제 이것들을 동작하는 애플리케이션으로 조립할 차례다. 3장에서 이야기했듯이 애플리케이션이 시작될 때 클래스를 인스턴스화하고 묶기 위해서 의존성 주입 메커니즘을 이용한다. 이번 장에서는 평범한 자바로 이를 어떻게 하는지 그리고 스프링, 스프링 부트 프레임워크에서는 이를 각각 어떻게 하는지 살펴본다.

왜 조립까지 신경 써야 할까?

왜 유스케이스와 어댑터를 그냥 필요할 때 인스턴스화하면 안되는 걸까? 그것은 코드 의존성이 올바른 방향을 가리키게 하기 위해서다. 모든 의존성은 안쪽으로, 애플리케이션의 도메인 코드 방향으로 향해야 도메인 코드가 바깥 계층의 변경으로부터 안전하다는 점을 기억하자.

유스케이스가 영속성 어댑터를 호출하고 인스턴스화한다면 코드 의존성이 잘못된 방향으로 만들어진 것이다. 이것이 바로 아웃고잉 포트 인터페이스를 생성한 이유다. 유스케이스는 인터페이스만 알아야 하고, 런타임에 이 인터페이스의 구현을 제공받아야 한다.

이 프로그래밍 스타일의 유익한 점은 코드를 훨씬 더 테스트하기 쉽다는 것이다. 한 클래스가 필요로 하는 모든 객체를 생성자로 전달할 수 있다면 실제 객체 대신 목으로 전달할 수 있고, 이렇게 되면 격리된 단위 테스트를 생성하기가 쉬워진다.

그럼 우리의 객체 인스턴스를 생성할 책임은 누구에게 있을까? 그리고 어떻게 의존성 규칙을 어기지 않으면서 그렇게 할 수 있을까?

해답은 그림 9.1처럼 아키텍처에 대해 중립적이고 인스턴스 생성을 위해 모든 클래스에 대한 의존성을 가진 설정 컴포넌트(configuration component)가 있어야 한다는 것이다.

2장에서 소개한 클린 아키텍처에서 이 설정 컴포넌트는 의존성 규칙에 정의된 대로 모든 내부 계층에 접근할 수 있는 원의 가장 바깥쪽에 위치한다.

설정 컴포넌트는 우리가 제공한 조각들로 애플리케이션을 조립하는 것을 책임진다. 이 컴포넌트는 다음과 같은 역할을 수행해야 한다.

  • 웹 어댑터 인스턴스 생성
  • HTTP 요청이 실제로 웹 어댑터로 전달되도록 보장
  • 유스케이스 인스턴스 생성
  • 웹 어댑터에 유스케이스 인스턴스 제공
  • 영속성 어댑터 인스턴스 생성
  • 유스케이스에 영속성 어댑터 인스턴스 생성
  • 영속성 어댑터가 실제로 데이터베이스에 접근할 수 있도록 보장

더불어 설정 컴포넌트는 설정 파일이나 커맨드라인 파라미터 등과 같은 설정 파라미터의 소스에도 접근할 수 있어야 한다. 애플리케이션이 조립되는 동안 설정 컴포넌트는 이러한 파라미터를 애플리케이션 컴포넌트에 제공해서 어떤 데이터베이스에 접근하고 어떤 서버를 메일 전송에 사용할지 등의 행동 양식을 제어한다.

보다시피 책임('변경할 이유’라고 읽기 바란다)이 굉장히 많다. 이것은 단일 책임 원칙을 위반하는 게 아닐까? 위반하는 게 맞다. 그러나 애플리케이션의 나머지 부분을 깔끔하게 유지하고 싶다면 이처럼 구성요소들을 연결하는 바깥쪽 컴포넌트가 필요하다. 그리고 이 컴포넌트는, 작동하는 애플리케이션으로 조립하기 위해 애플리케이션을 구성하는 모든 움직이는 부품을 알아야 한다.

평범한 코드로 조립하기

애플리케이션을 조립할 책임이 있는 설정 컴포넌트를 구현하는 방법은 여러 가지다. 의존성 주입 프레임워크의 도움 없이 애플리케이션을 만들고 있다면 평범한 코드로 이러한 컴포넌트를 만들 수 있다.

class Application {
	public static void main(String args[]) {
		AccountRepository accountRepository = new AccountRepository();
		ActivityRepository activityRepository = new ActivityRepository();

		AccountPersistenceAdaptar accountPersistenceAdaptar = new AccountPersistenceAdapter(accountRepository, activityRepository);

		SendMoneyUseCase sendMoneyUseCase = new SendMoneyUseService(
			accountPersistenceApacter,  // LoadAccountPort
			accountPersistenceAdapter); // UpdateAccountStatePort

		SendMoneyController sendMoneyController = new SendMoneyController(sendMoneyUseCase);
		
		startProcessingWebRequests(sendMoneyController);
	}
}

이 코드는 설정 컴포넌트의 모습을 간략하게 보여주는 예다. 자바에서는 애플리케이션이 main 메서드로부터 시작된다. main 메서드 안에서 웹 컨트롤러부터 영속성 어댑터까지, 필요한 모든 클래스의 인스턴스를 생성한 후 함께 연결한다.

마지막으로 웹 컨트롤러를 HTTP로 노출하는 신비한 메서드인 startProcessingWebReqeust()를 호출한다. 이 메서드는 웹 어댑터를 HTTP로 노출시키는 데 필요한 애플리케이션 부트스트랩핑 로직이 들어갈 곳이다.

이제 애플리케이션은 요청을 처리할 준비가 끝난다.

이 평범한 코드 방식은 애플리케이션을 조립하는 가장 기본적인 방법이다. 하지만 몇 가지 단점이 있다.

첫 번째로, 앞의 코드는 웹 컨트롤러, 유스케이스, 영속성 어댑터가 단 하나씩만 있는 애플리케이션을 예로 든 것이다. 완전한 엔터프라이즈 애플리케이션을 실행하기 위해서는 이러한 코드를 얼마나 많이 만들어야 할지 상상해보라.

두 번째로, 각 클래스가 속한 패키지 외부에서 인스턴스를 생성하기 때문에 이 클래스들은 전부 public 이어야 한다. 이렇게 되면 가령 유스케이스가 영속성 어댑터에 직접 접근하는 것을 막지 못한다. package-private 접근 제한자를 이용해서 이러한 원치 않은 의존성을 피할 수 있었다면 더 좋았을 것이다.

다행히도 package-private 의존성을 유지하면서 이처럼 지저분한 작업을 대신해줄 수 있는 의존성 프레임워크들이 있다. 자바 세계에서는 그 중 스프링 프레임워크가 가장 인기 있다. 무엇보다도 스프링은 웹과 데이터베이스 환경을 지원하기 때문에 신비한 startProcessingWebRequests() 메서드 같은 것을 구현할 필요가 없다.

스프링의 클래스패스 스캐닝으로 조립하기

스프링 프레임워크를 이용해서 애플리케이션을 조립한 결과물을 애플리케이션 컨텍스트(application context)라고 한다. 애플리케이션 컨텍스트는 애플리케이션을 구성하는 모든 객체(자바 용어로 빈 '빈(bean)')를 포함한다.

스프링은 애플리케이션 컨텍스트를 조립하기 위한 몇 가지 방법을 제공하는데 각기 장단점이 있다. 일단 가장 인기 있는 (그리고 편리한) 방법인 클래스패스 스캐닝(classpath scanning)을 살펴보자.

스프링은 클래스패스 스캐닝으로 클래스패스에서 접근 가능한 모든 클래스를 확인해서 @Component 어노테이션이 붙은 클래스를 찾는다. 그러고 나서 이 애너테이션이 붙은 각 클래스의 객체를 생성한다. 이때 클래스는 6장의 AccountPersistenceAdapter 처럼 필요한 모든 필드를 인자로 받는 생성자를 가지고 있어야 한다.

@Component
@RequiredArgsConstructor
public class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {

    private final SpringDataAccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    @Override
    public Account loadAccount(
            AccountId accountId,
            LocalDateTime baselineDate) {
		// ...
    }


    @Override
    public void updateActivities(Account account) {
		// ...
	}
}

이 코드에서는 생성자를 직접 만들지 않고 Lombok 라이브러리의 @RequiredArgsConstructor 애너테이션을 이용해 모든 final 필드를 인자로 받는 생성자를 자동으로 생성했다.

그럼 스프링은 이 생성자를 찾아 생성자의 인자로 사용된 @Component가 붙은 클래스들을 찾고, 이 클래스들의 인스턴스를 만들어 애플리케이션 컨텍스트에 추가한다. 필요한 객체들이 모두 생성되면 AccountPersistenceAdapter의 생성자를 호출하고 생성된 객체도 마찬가지로 애플리케이션 컨텍스트에 추가한다.

클래스패스 스캐닝 방식을 이용하면 아주 편리하게 애플리케이션을 조립할 수 있다. 적절한 곳에 @Component 애너테이션을 붙이고 생성자만 잘 만들어두면 된다.

스프링이 인식할 수 있는 애너테이션을 직접 만들 수도 있다. 예를 들어, 다음 예제와 같은 @PersistenceAdapter라는 애너테이션을 만들 수 있다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface PersistenceAdapter {

    @AliasFor(annotation = Component.class)
    String value() default "";
}

이 애너테이션은 메타-애너테이션으로 @Component를 포함하고 있어서 스프링이 클래스패스 스캐닝을 할 때 인스턴스를 생성할 수 있도록 한다. 이제 @Component 대신 @PersistenceAdaptar를 이용해 영속성 어댑터 클래스들이 애플리케이션의 일부임을 표시할 수 있다. 이 애너테이션 덕분에 코드를 읽는 사람들은 아키텍처를 더 쉽게 파악할 수 있다.

하지만 클래스패스 스캐닝 방식에는 단점이 있다.

  • 클래스에 프레임워크에 특화된 애너테이션을 붙어야 한다는 점에서 침투적이다.

강경한 클린 아키텍처파는 이런 방식이 코드를 특정한 프레임워크와 결합시키기 때문에 사용하지 말아야 한다고 주장할 것이다.

일반적인 애플리케이션 개발에서는 필요하다면 한 클래스에 애너테이션 하나 정도는 용인할 수 있고, 리팩터링도 그리 어렵게 할 수 있다.

하지만 다른 개발자들이 사용할 라이브러리나 프레임워크를 만드는 입장에서는 사용하지 말아야 할 방법이다. 라이브러리 사용자가 스프링 프레임워크의 의존성에 엮이게 되기 때문이다.

  • 또 다른 단점은 마법 같은 일이 일어날 수 있다는 점이다. '마법 같은’이라 함은 흑마법 같은 것인데, 스프링 전문가가 아니라면 원인을 찾는데 수일이 걸릴 수 있는 숨겨진 부수 효과를 야기할 수도 있다는 뜻이다.

마법 같은 일이 발생하는 이유는 클래스패스 스캐닝이 애플리케이션 조립에 사용하기에 너무 둔한 도구이기 때문이다. 이 방법에서는 단순히 스프링에게 부모 패키지를 알려준 후 이 패키지 안에서 @Component가 붙은 클래스를 찾으라고 지시한다.

여러분은 애플리케이션에 존재하는 모든 클래스 하나하나에 대해 자세하게 아는가? 아마 그렇지 않을 것이다. 애플리케이션 컨텍스트에 실제로는 올라가지 않았으면 하는 클래스가 있을 수 있다. 아마도 이 클래스는 애플리케이션 컨텍스트를 악의적으로 조작해서 추적하기 어려운 에러를 일으킬 수도 있을 것이다.

스프링 자바 컨피그로 조립하기

클래스패스 스캐닝이 애플리케이션 조립하기의 곤봉이라면 스프링의 자바 컨피그(Java Config)는 수술용 메스다. 이번 장의 앞부분에서 소개한 평범한 코드를 이용하는 방식과 비슷한데, 덜 지저분하고 프레임워크와 함께 제공되므로 모든 것을 직접 코딩할 필요가 없는 방식이다.

이 방식에서는 애플리케이션 컨텍스트에 추가할 빈을 생성하는 설정 클래스를 만든다.

예를 들어, 모든 영속성 어댑터의 인스턴스 생성을 담당하는 설정 클래스를 하나 만들어 보자.

@Configuration
@EnableJpaRepositories
public class PersistenceAdapterConfiguration {

    @Bean
    AccountPersistenceAdapter accountPersistenceAdapter(
            AccountRepository accountRepository,
            ActivityRepository activityRepository,
            AccountMapper accountMapper) {

        return new AccountPersistenceAdapter(
                accountRepository,
                activityRepository,
                accountMapper
        );
    }

    @Bean
    AccountMapper accountMapper() {
        return new AccountMapper();
    }
}

@Configuration 애너테이션을 통해 이 클래스가 스프링의 클래스패스 스캐닝에서 발견해야 할 설정 클래스임을 표시해둔다. 그러므로 사실 여전히 클래스패스 스캐닝을 사용하고 있는 것이기는 하다. 하지만 모든 빈을 가져오는 대신 설정 클래스만 선택하기 때문에 해로운 마법이 일어날 확률이 줄어든다.

빈 자체는 설정 클래스 내에 @Bean 애너테이션이 붙은 팩터리 메서드를 통해 생성된다. 앞의 예제에서는 영속성 어댑터를 애플리케이션 컨텍스트에 추가했다. 영속성 어댑터는 2개의 레포지토리와 한 개의 매퍼를 생성자 입력으로 받는다. 스프링은 이 객체들을 자동으로 팩터리 메서드에 대한 입력으로 제공한다.

그럼 스프링은 이 레포지토리 객체를 어디서 가져올까? @EnableJpaRepositories를 설정 클래스뿐만 아니라 메인 애플리케이션에도 붙일 수 있다는 점을 알 것이다. 물론 이렇게 하는 것도 가능하지만 그럼 애플리케이션을 시작할 때마다 JPA를 활성화해서 영속성이 실직적으로 필요 없는 테스트에서 애플리케이션을 JPA 레포지토리들을 활성화할 것이다. 그러므로 이러한 '기능 애너테이션'을 별도의 설정 '모듈'로 옮기는 편이 애플리케이션을 더 유연하게 만들고, 항상 모든 것을 한꺼번에 시작할 필요 없게 해준다.

PersistenceAdapterconfiguration 클래스를 이용해서 영속성 계층에서 필요로 하는 모든 객체를 인스턴스화하는 매우 한정적인 범위의 영속성 모듈을 만들었다. 이 클래스는 스프링의 클래스패스 스캐닝을 통해 자동으로 선택될 것이고, 우리는 여전히 어떤 빈이 애플리케이션 컨텍스트에 등록될지 제어할 수 있게 된다.

비슷한 방법으로 웹 어댑터, 혹은 애플리케이션 계층의 특정 모듈을 위한 설정 클래스를 만들 수도 있다. 그러면 특정 모듈만 포함하고, 그 외의 다른 모듈의 빈은 모킹해서 애플리케이션 컨테스트를 만들 수 있다. 이렇게 하면 테스트에 큰 유연성이 생긴다. 심지어 리팩터링을 많이 하지 않고도 각 모듈의 코드를 자체 코드베이스, 자체 패키지, 자체 JAR 파일로 밀어넣을 수 있다.

또한 이 방식에서는 클래스패스 스캐닝 방식과 달리 @Component 애너테이션을 코드 여기 저기에 붙이도록 강제하지 않는다. 그래서 애플리케이션 계층을 스프링 프레임워크(혹은 그 외의 어떤 프레임워크)에 대한 의존성 없이 깔끔하게 유지할 수 있다.

하지만 이 방법에도 문제점은 있다. 설정 클래스가 생성하는 빈(이 경우에는 영속성 어댑터 클래스들)이 설정 클래스와 같은 패키지에 존재하지 않는다면 이 빈들을 public으로 만들어야 한다. 가시성을 제한하기 위해 패키지를 모듈 경계로 사용하고 각 패키지 안에 전용 설정 클래스를 만들 수는 있다.

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

스프링과 스프링 부트(그리고 비슷한 프레임워크)는 개발을 편하게 만들어주는 다양한 기능을 제공한다. 그 중 하나가 바로 애플리케이션 개발자로서 우리가 제공하는 부품(클래스)들을 이용해서 애플리케이션을 조립하는 것이다.

클래스패스 스캐닝은 아주 편리한 기능이다. 스프링에게 패키지만 알려주고 거기서 찾은 클래스로 애플리케이션을 조립한다. 이를 통해 애플리케이션 전체를 고민하지 않고도 빠르게 개발할 수 있게 된다.

하지만 코드의 규모가 커지만 금방 투명성이 낮아진다. 어떤 빈이 애플리케이션 켄텍스트에 올라오는지 정확히 알 수 없게 된다. 또, 테스트에서 애플리케이션 컨텍스트의 일부만 독립적으로 띄우기가 어려워진다.

반면, 애플리케이션 조립을 책임지는 전용 설정 컴포넌트를 만들면 애플리케이션이 이러한 책임(변경할 이유)으로부터 자유로워진다. 이 방식을 이용하면 서로 다른 모듈로부터 독립되어 코드 상에서 손쉽게 옮겨 다닐 수 있는 응집도가 매우 높은 모듈을 만들 수 있다. 하지만 늘 그렇듯이 설정 컴포넌트를 유지보수하는 데 약간의 시간을 추가로 들여야 한다.

profile
서버 백엔드 개발자

0개의 댓글