‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
9장 - 애플리케이션 조립하기
구현된 유스케이스, 웹 어댑터, 영속성 어댑터를 애플리케이션단에서 조립해야지만 하나의 애플리케이션으로 동작하게 된다.
왜 조립까지 신경 써야 할까?
- 코드 의존성이 안쪽으로(올바로)가게 하기 위해서다. 그래야 도메인 코드를 바깥 계층의 변경으로부터 지킬수 있다.
- 유스케이스가 포트가 아닌 영속성 어댑터를 직접 참조하게 되면 안된다. 유스케이스는 포트만 알아야하고, 런타임에 구현체가 주입되도록 해야한다.
- 이렇게함으로써 코드를 더 테스트하기 쉽게 만드는 부수효과를 얻을 수 있다.
- 한 클래스가 필요로 하는 모든 객체를 생성자로 전달할 수 있다면 실제 객체 대신 목으로 전달할 수 있고, 이렇게 되면 격리된 단위 테스트를 생성하기 쉬워진다.
- 객체 인스턴스를 생성하고 의존성을 주입시켜주는 책임은 ‘설정 컴포넌트’에 있다.
- 설정 컴포넌트는 아래 이미지처럼 아키텍처에 대해 중립적이고 인스턴스 생성을 위해 모든 클래스에 대한 의존성을 가진다.
- 설정 컴포넌트는 모든 내부 계층에 접근할 수 있는 가장 바깥쪽에 위치한다.
출처: https://jandari91.tistory.com/59
- 설정 컴포넌트의 역할은 다음과 같다.
- 웹 어댑터 인스턴스 생성
- HTTP 요청이 실제로 웹 어댑터로 전달되도록 보장
- 유스케이스 인스턴스 생성
- 웹 어댑터에 유스케이스 인스턴스 제공
- 영속성 어댑터 인스턴스 생성
- 유스케이스 영속성 어댑터 인스턴스 제공
- 영속성 어댑터가 실제로 데이터베이스에 접근할 수 있도록 보장
- 설정 파일이나 커맨드라인 파라미터 등과 같은 설정 파라미터의 소스에도 접근할 수 있어야 한다.
- 이러한 파라미터를 애플리케이션 컴포넌트에 제공해서 어떤 DB에 접근하고 어떤 서버를 메일 전송에 사용할지 등의 행동 양식을 제어한다.
- 근데 위 내용처럼 책임(변경할 이유)이 굉장히 많다. SRP를 위반한다. 그러나 애플리케이션 나머지 부분을 깔끔하게 유지하고 싶다면 이처럼 구성요소들을 연결하는 바깥쪽 컴포넌트가 필요하다.
평범한 코드로 조립하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package copyeditor.configuration;
class Application {
public static void main(String[] args) {
AccountRepository accountRepository = new AccountRepository();
ActivityRepository activityRepository = new ActivityRepository();
AccountPersistenceAdapter accountPersistenceAdapter = new AccountPersistenceAdapter(accountRepository, activityRepository);
SendMoneyUseCase sendMoneyUseCase = new SendMoneyUseService(
accountPersistenceAdapter,
accountPersistenceAdapter
);
SendMoneyController sendMoneyController = new SendMoneyController(sendMoneyUseCase);
startProcessingWebRequests(sendMoneyController);
}
}
- 메인 함수에서 필요한 모든 클래스의 인스턴스를 생성후 함께 연결한다. 그리고 startProcessingWebRequests 를 호출하여 웹 컨트롤러를 HTTP로 노출시킨다. (해당 함수는 웹 어댑터를 HTTP로 노출시키는데 필요한 애플리케이션 부트스트랩핑 로직이 들어간다)
평범한 코드로 조립의 단점
1) 조립하는데 필요한 코드양이 많아진다.
- 위 예제는 단순한 예제지만 엔터프라이즈 애플리케이션라면 엄청 생성 및 조립 코드가 많아질것이다.
2) 모든 조립되는 클래스는 public 접근제한자를 가져야한다.
- 각 클래스가 속한 패키지 외부에서 인스턴스를 생성하기 때문이다.
- 이렇게 되면 가령 유스케이스에서 영속성 어댑터에 직접 접근하는것을 막지 못한다.
- package-private 접근 제한자로 원치 않는 의존성을 피할 수 있었다면 더 좋았을것이다.
다행히도 package-private 의존성을 유지하면서 이처럼 지저분한 작업을 대신해줄 수 있는 의존성 주입 프레임워크들이 있다.
스프링의 클래스패스 스캐닝으로 조립하기
- 스프링 프레임워크를 이용해서 애플리케이션을 조립한 결과물을 애플리케이션 컨텍스트(application context)라 한다.
- 애플리케이션 컨텍스트는 애플리케이션 모든 객체(자바 용어로는 빈)를 포함한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
LoadAccountPort,
UpdateAccountStatePort {
private final AccountRepository accountRepository;
private final ActivityRepository activityRepository;
private final AccountMapper accountMapper;
@Override
public Account loadAccount(Account.AccountId accountId,
LocalDateTime baselineDate,
LocalDateTime baselineDate) {
// ...
}
@Override
public void updateActivities(Account account) {
// ...
}
}
- 스프링은 클래스패스 스캐닝으로 @Component 애너테이션이 붙은 클래스들을 객체로 생성하여 애플리케이션 컨텍스트에 등록한다. 의존성 주입도 잘 알아서 해준다.
1
2
3
4
5
6
7
8
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface PersistenceAdapter {
@AliasFor(annotation = Component.class)
String value() default "";
}
- 위와 같은 메타-애너테이션으로 영속성 어댑터 클래스들이 애플리케이션의 일부임을 표시할수 있다. 이는 코드를 읽는 사람들이 아키텍처를 더 쉽게 파악하도록 한다.
스프링의 클래스패스 스캐닝 방식의 단점
1) 클래스에 프레임워크에 특화된 애너테이션이 붙는다.
- 강경한 클린 아키텍처파는 이런 방식이 특정 프레임워크와 결합되어 사용하지 말아야 한다고 한다..
- 근데 뭐 일반적인 애플리케이션 개발에선 필요하다면 한 클래스에 애너테이션 하나 정도는 용인할 수 있고, 리팩터링도 그리 어렵지 않게 할 수 있다.
- 하지만 다른 개발자들이 사용할 라이브러리나 프레임워크를 만드는 입장에선 사용하지 말아야할 방법이다.
- 라이브러리나 프레임워크 사용자들이 스프링 프레임워크의 의존성에 엮이게 되기 때문이다.
2) 원인 찾기 어려운 숨겨진 부수효과를 야기할 수도 있다.
- 클래스패스 스캐닝이 애플리케이션에 조립에 사용하기엔 너무 둔한 도구이기 때문에 그런다.
스프링의 자바 컨피그로 조립하기
- 스캐닝보다 덜 지저분하고 프레임워크와 함꼐 제공되므로 모든것을 직접 코딩할 필요가 없는 방식이다.
- 이 방식은 애플리케이션 컨텍스트에 올릴 빈을 생성하는 설정 클래스를 만드는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableJpaRepositories
class PersistenceAdapterConfiguration {
@Bean
AccountPersistenceAdapter accountPersistenceAdapter(
AccountRepository accountRepository,
ActivityRepository activityRepository,
AccountMapper accountMapper
) {
return new AccountPersistenceAdapter(
accountRepository,
activityRepository,
accountMapper
);
}
@Bean
AccountMapper accountMapper() {
return new AccountMapper();
}
}
@EnabledJpaRepositories
애너테이션을 메인 애플리케이션에도 붙일수는 있지만 별도 설정 묘듈로 옮겨야 한다.- 메인 애플리케이션(@SpringBootApplication 애너테이션이 붙은 시작 클래스)에 붙이면 기동할때마다 JPA를 활성화해서 영속성이 필요 없는 테스트에서 애플리케이션을 실행할때도 JPA레포지토리들을 활성화할것이다.
- 그러므로 이러한 ‘기능 애너테이션’을 별도 설정 모듈로 옮기는게 애플리케이션을 더 유연하게 만들고, 항상 모든 것을 한꺼번에 시작할 필요 없게 해준다.
장점
1. 모든 빈을 가져오는 대신 설정 클래스만 선택하기 때문에 원인 모를 부수효과가 일어날 확률이 줄어든다.
2. 애플리케이션 컨텍스트에 등록되는 빈들을 제어하기 쉽게 만들어준다.
- 웹 어댑터, 애플리케이션 계층의 특정 모듈을 위한 설정 클래스를 만들수도 있다. 그러면 특정 모듈만 포함하고, 그외 다른 모듈의 빈은 모킹해서 애플리케이션 컨텍스트를 만들수 있다. 이렇게 하면 테스트에 큰 유연성이 생기고 리팩터링을 많이 하지 않고 각 모듈의 코드를 자체 코드베이스, 자체 패키지, 자체 JAR 파일로 밀어넣을수 있다.
- @Component 애너테이션을 코드 여기 저기에 붙이도록 강제하지 않아서 그래서 애플리케이션 계층을 스프링과의 의존성없이 깔끔하게 유지 가능하다.
단점
1. 설정 클래스가 생성하는 빈이 설정 클래스와 같은 패키지에 존재해야만 한다.
- 같은 패키지가 아니라면 public 접근제한자로 만들어야 한다.
- 가시성을 제한하기 위해 패키지를 모듈 경계로 사용하고 각 패키지 안에 전용 설정 클래스를 만들수는 있다. 하지만 이렇게하면 하위 패키지를 사용 못하게 된다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 스프링 스캐닝 방식은 코드 규모가 커지면 금방 투명성이 낮아진다. 어떤 빈이 애플리케이션 컨택스트에 올라가는지 파악하기 어려워진다. 또 테스트에서 애플리케이션 컨텍스트의 일부만 독립적으로 띄우기가 어려워진다.
- 반면 전용 설정 컴포넌트 방식은 애플리케이션이 이러한 책임(변경할 이유)으로부터 자유로워지고 서로 다른 모듈로부터 독립되어 코드 상에서 손쉽게 옮겨 다닐수 있는 응집도가 매우 높은 모듈을 만들수 있다. 하지만 늘 그렇듯이 설정 컴포넌트를 유지보수하는데 약간의 시간을 추가로 들여야 한다.