상세 컨텐츠

본문 제목

[Architecture] 5부 아키텍처 : 22장 클린 아키텍처

프로그래밍/Architecture

by jisooo 2024. 1. 17. 21:39

본문

 

 

  • 육각형 아키텍처
  • Data Contect and Interaction
  • Boundary-Control-Entity

지난 수십 년간 시스템 아키텍처와 관련된 여러 아이디어들이 제시되었는데

세부적인 면에서는 다소 차이가 있지만, 말하고자 하는 근본은 대부분 비슷하다.

이들이 공통적으로 말하고자 하는 것은 바로 "관심사의 분리"다.

이들은 모두 소프트웨어를 계층으로 분리함으로써

관심사의 분리라는 목표를 달성할 수 있었다.

각 아키텍처는 최소한 업무 규칙을 위한 계층 하나와,

사용자와 시스템 인터페이스를 위한 또 다른 계층 하나를 반드시 포함한다.

이들 아키텍처는 아래와 같은 특징을 지니고 있다.

  • 프레임워크 독립성
    • 아키텍처는 다양한 기능의 라이브러리를 제공하는 프레임워크의 존재 여부에 의존하지 않는다.
    • 프레임워크를 도구로 사용할 수 있으며, 프레임워크가 지닌 제약사항 안으로 시스템을 욱여 넣도록 강제하지 않는다.

  • 테스트 용이성
    • 업무 규칙은 UI, 데이터베이스, 웹 서버, 또는 여타 외부 요소가 없어도 테스트할 수 있다.

  • UI 독립성
    • 시스템의 나머지 부분을 변경하지 않고도 UI만 쉽게 변경할 수 있다. (느슨한 결합, 유연함)

  • 데이터베이스 독립성
    • 업무 규칙은 데이터베이스에 결합되지 않는다.
    • 소프트웨어에서 사용할 데이터베이스가 바뀌더라도 업무 규칙에 대한 코드는 변경에 대한 영향을 받지 않는다.

  • 모든 외부 에이전시에 대한 독립성
    • 업무 규칙은 외부 세계와의 인터페이스에 대해 전혀 알지 못한다.

22.1 다이어그램은 위에서 설명한 이상적인 클린 아키텍처 전부를 실행 가능한 하나의 아이디어로 통합한 모습이다.

다만 위 다이어그램은 하나의 예시일뿐 네 개보다 더 많은 원이 필요할 수도 있다.

하지만 클린 아키텍처를 지향하려면 어떤 경우에도 "의존성 규칙"이 반드시 적용되어야 한다.

소스 코드 의존성은 항상 안쪽을 향하고,

안쪽으로 이동할수록 추상화와 정책의 수준은 높아진다.

반면 가장 바깥쪽 원은 저수준의 구체적인 세부사항으로 구성된다.

그리고 안쪽으로 이동할수록 소프트웨어는 점점 추상화되고 더 높은 수준의 정책들을 캡슐화한다.

따라서 가장 안쪽 원의 계층은 가장 범용적이며 높은 수준을 가진다.

의존성 규칙

위 그림에서 각 동심원은 소프트웨어에서 서로 다른 영역을 표현한다.

안으로 들어갈수록 고수준의 소프트웨어가 된다.

바깥쪽 원은 메커니즘이고 안쪽 원은 정책이다.

이러한 아키텍처가 동작하도록 하는 가장 중요한 규칙은 의존성 규칙이다.

소스 코드 의존성은 반드시 바깥쪽에서 안쪽으로,

저수준에서 고수준의 정책을 향해야 한다.

내부 원에 속한 요소는 외부 원에 속한 어떤 것도 알지 못한다.

내부 원에 속한 코드는 외부 원에 선언된 함수나 클래스, 변수, 소프트웨어 엔티티 등등

어떤 것에 대해서도 그 이름을 언급해서는 절대로 안된다.

같은 이유로,

외부 원에 선언된 데이터 형식도 내부의 원에서 절대로 사용해서는 안된다.

특히 데이터 형식이 외부 원에 있는 프레임워크가 생성한 것이라면 더욱 그렇다.

우리는 외부 원에 위치한 어떤 것도 (외부에서 일어난 변경에 대해서)

내부의 원에 영향을 주지 않도록 코드를 작성하여야 한다.

엔티티

엔티티는 전사적인 핵심 업무 규칙을 캡슐화한다.

엔티티는 메서드를 가진 객체이거나 일련의 데이터 구조와 함수의 집합일 수 있다.

단일 애플리케이션에서 엔티티는 해당 애플리케이션의 업무 객체가 된다.

이 경우 엔티티는 가장 일반적이며 고수준인 규칙을 캡슐화한다.

외부의 무언가가 변경되더라도 엔티티가 변경될 가능성은 지극히 낮다.

예를 들어 보안과 인증, 또는 요청/응답 데이터 형식, UI의 변경, 애플리케이션의 벨리데이션 규칙 같은 것들이 바뀌더라도

해당 애플리케이션의 업무 객체가 영향을 받지는 않을 것이다.

운영 관점에서 특정 애플리케이션에 무언가 변경이 필요하더라도

엔티티 계층에는 절대로 영향을 주어서는 안된다.

유스케이스

유스케이스 계층은 애플리케이션에 특화된 업무 규칙을 포함한다.

또한 유스케이스 게층은 시스템의 모든 유스케이스를 캡슐화하고 구현한다.

유스케이스는 엔티티로 들어오고 나가는 데이터 흐름을 조정하며,

엔티티가 자신의 핵심 업무 규칙을 사용해서 유스케이스의 목적을 달성하도록 이끈다.

유스케이스에서 발생한 변경 역시 엔티티에 영향을 줘서는 안된다.

또한 데이터베이스, UI, 프레임워크와 같은 외부 요소에서 발생한 변경 역시

유스케이스에 영향을 줘서도 안된다.

유스케이스 계층은 이러한 관심사로부터 분리되어야 한다.

하지만 운영 관점에서 보면,

애플리케이션이 변경된다면 유스케이스가 영향을 받으며,

따라서 이 계층의 소프트웨어도 영향을 줄 것이다.

유스케이스의 세부사항이 변하면 이 계층의 코드 일부도 당연히 영향을 받을 것이다.

인터페이스 어댑터

인터페이스 어댑터 계층은 일련의 어댑터들로 구성된다.

어댑터는 데이터를 유스케이스와 엔티티에게 가장 편리한 형식에서

데이터베이스나 웹 같은 외부 에이전시에 가장 편리한 형식으로 변환하는 작업들을 한다.

이 계층은 GUI의 MVC 아키텍처 (프레젠터, 뷰, 컨트롤러) 요소들이

모두 이 "인터페이스 어댑터" 계층에 속한다고 볼 수 있다.

모델은 그저 데이터 구조 정도에 지나지 않으며,

컨트롤러에서 유스케이스로 전달되고, 다시 유스케이스에서 프레젠터와 뷰로 되돌아간다.

마찬가지로 인터페이스 어댑터는

엔티티와 유스케이스에 가장 편리한 데이터 형식에서

영속성용으로 사용 중인 데이터베이스 프레임워크가 이용하기에 가장 편리한 형식으로 변환한다.

이 계층에 속한 어떠한 코드도 데이터베이스에 대해 조금도 알아서는 안된다.

또한 이 계층에는 데이터를 외부 서비스와 같은 외부적인 형식에서

유스케이스, 엔티티에서 사용되는 내부적인 형식으로 변환하는

또 다른 어댑터가 필요하다.

프레임워크와 드라이버

그림 22.1 다이어그램에서 가장 바깥쪽 계층은 일반적으로

데이터베이스나 웹 프레임워크 같은 프레임워크나 도구들로 구성된다.

일반적으로 이 계층에서는 안쪽 원과 통신하기 위한 코드 외에는

특별히 더 작성해야 할 코드가 그다지 많지 않다.

프레임워크나 드라이버, 웹 계층은 모든 세부사항이 위치하는 곳이다.

이러한 세부사항을 원의 외부에 위치 시켜 변경에 대한 피해, 영향도를 최소화한다.

경계 횡단하기

그림 22.1의 원의 각 경계에서 서로 다른 계층을 횡단하는 방법을 살펴보자.

먼저 컨트롤러와 프레젠터 (인터페이스 어댑터) 에서

그 다음 계층에 속하는 유스케이스 계층과 통신하는 모습을 확인할 수 있다.

제어 흐름에 주목해보면,

컨트롤러에서부터 시작해서, 유스케이스를 지난 후,

프레젠터에서 실행되면서 마무리된다.

제어 흐름 : 컨트롤러 -> 유스케이스 -> 프레젠터

반면 "의존성 방향"에 주목해보면

각 프레젠터, 컨트롤러의 의존성은 유스케이스를 향해 안쪽을 가리킨다.

의존성 방향 : 프레젠터 -> (유스케이스 출력포트 ) -> 유스케이스 <- (유스케이스 입력 포트) <- 컨트롤러

이처럼 제어흐름과 의존성의 방향이 명백히 반대여야 하는 경우,

대체로 의존성 역전 원칙을 사용하여 해결한다.

자바의 경우, 인터페이스와 상속 관계를 의존관계에 적절히 배치함으로써,

제어흐름이 경계를 가로지르는 지점에서 인터페이스를 추가하여

소스 코드 의존성을 제어 흐름과는 반대가 되게 만들 수 있다.

다이어그램에서 계속 강조했듯이,

다이어그램의 원의 갯수에 상관없이

클린 아키텍처를 지향하는 경우 반드시 "의존성 규칙"을 지켜야한다고 하였다.

즉, 내부의 원에서는 외부 원에 있는 어떤 이름도 언급해서는 안된다.)

이 의존성 규칙을 지키기 위해,

만약 제어 흐름이 의존성 규칙에서 지켜야 하는 방향을 위반하는 경우,

인터페이스와 상속을 사용하여 의존성을 역전시켜

의존성 방향이 외부에서 내부로 향하도록 만들 수 있다.

만약에 유스케이스(내부)에서 프레젠터(외부)를 호출해야한다고 할 때,

제어흐름과 같이 의존성을 똑같이 설정하면 "의존성 규칙"에 어긋난다.

따라서 우리는 유스케이스가 내부 원의 인터페이스(유스케이스 출력 포트)를 호출하도록 하고,

외부 원의 프레젠터가 해당 인터페이스를 구현하도록 만들 수 있다.

=> 이렇게 하면 의존성 역전 원칙이 적용되어

내부 계층(유스케이스)이 구체 계층(프레젠터)에 집적적으로 의존하지 않도록 제어할 수 있고,

구체 컴포넌트가 아닌 추상 컴포넌트에 의존하는 모양을 만들 수 있다.

 

 

package coucle.domain

@Getter
@Setter
@Entity
@Table(name = "delivery_invoice")
public class DeliveryInvoice {
// "송장 등록"을 구현한 애플리케이션 업무 규칙(유스케이스)에서 사용되는 엔티티
// 엔티티는 상대적으로 저수준인 RegisterInvoiceUseCase 유스케이스의 존재를 알지 못한다.

 @Column(name = "delivery_invoice_id")
 private Long deliveryInvoiceId;

 @Column(name = "order_id")
 private Long orderId;

 @Column(name = "delivery_company_code")
 @Enumerated(EnumType.STRING)
 private DeliveryCompanyCode deliveryCompanyCode;

 @Column(name = "invoice_number")
 private String invoiceNumber;
 ...
 ...

 void register(DeliveryCompanyCode code, String invoiceNumber) {
  // ...
  // ...
 }

 void remove() { 
  this.setDeliveryCompanyCode(EMPTY);
  this.setInvoiceNumber(EMPTY);
 }

...
...

}

 

 

클린 아키텍처에서 여지껏 유스케이스에 대한 내용에 의하면

유스케이스는 애플리케이션의 특화된 업무 규칙을 설명하는 요소라고 하였다.

그리고 유스케이스는 엔티티 내부의 핵심 업무 규칙을 이용하여

사용자와 엔티티 사이의 상호작용을 규정한다.

또한 유스케이스는 엔티티에 비하면 저수준의 개념이고,

상대적으로 저수준인 유스케이스에서 엔티티에 대해 알고 있고 엔티티에 의존한다.

작업하는 실무에서는 유스케이스라는 이름을 붙인 컴포넌트나 클래스를 별도로 다루지는 않지만,

위 역할을 하는 클래스는 애플리케이션 서비스를 담당하는 클래스에서 주로 수행하고 있다.

각 유스케이스 별로 인터페이스를 정의하고,

해당 유스케이스에 상응하는 서비스를 만들어 유스케이스를 구현하도록 할 수 있다.

예시 코드에서는 "송장 정보 등록"이라는 애플리케이션의 업무 규칙을 설명하기 위한

유스케이스를 추가하였다.

 

 

 

package coucle.application.usecase;

interface RegisterInvoiceUseCase {
// 이 유스케이스에서느 "송장 등록" 애플리케이션에 대한 업무 규칙을 정의한다.
// 각 서비스에서 그에 상응하는 유스케이스 인터페이스를 구현한다.

 boolean isValidateInvoice(RegisterInvoiceRequest request);
 void register(RegisterInvoiceRequest request);
 Optional<DeliveryInvoice> getDeliveryInvoiceById(Long deliveryInvoiceId);
 Optional<DeliveryInvoice> getDeliveryInvoiceByOrderId(Long orderId);

 ...
 ...
}

 

 

package coucle.application.service

@Service
@RequiredArgsConstructor
public class RegisterInvoiceService implements RegisterInvoiceUseCase {
// RegisterInvoiceService는 송장 등록이라는 애플리케이션의 유스케이스 업무 규칙을 구현한다.
// 엔티티에 비해 저수준인 유스케이스는 RegisterInvoice 엔티티를 알고 있고 해당 도메인에 의존한다.

// 각 유스케이스에서는 아래와 같은 과정들이 반복된다.

// 비즈니스 규칙 검증
// 모델 상태 변경
// 출력 값 반환

// 입력 값에 대한 비즈니스 규칙 검증
// 입력 값을 기반으로 모델 생성
// 출력 값 반환  

 private final DeliveryInvoiceRepository deliveryInvoiceRepository;

 @Override
 boolean isValidateInvoice(RegisterInvoiceRequest request) {
    // ...

 }

 @Override
 void register(RegisterInvoiceRequest request) {
    // ...

 }

 @Override
 Optional<DeliveryInvoice> getDeliveryInvoiceById(Long deliveryInvoiceId) {
   deliveryInvoiceRepository.findById(deliveryInvoiceId);
 }

 @Override
 Optional<DeliveryInvoice> getDeliveryInvoiceByOrderId(Long orderId) {
   // ...
 }
  
}

경계 횡단 데이터의 모습

경계를 가로지르는 데이터는 흔히 간단한 데이터 구조로 이루어져 있다.

우리는 경계를 넘나들 때 전용 데이터 전송 객체(Data transfet Object)를 만들어 쓰거나,

함수를 호출할 때 간단한 인자를 사용해서 데이터를 전달 할 때도 있다.

중요한 점은 격리되어 있는 간단한 데이터 구조가 경계를 가로질러 전달된다는 사실이다.

엔티티 객체나 데이터베이스 행(record)을 전달하는 일은 원치 않는다.

데이터 구조가 어떤 의존성을 가져 의존성 규칙을 위배해버리면 안되기 때문이다.

예를 들어 많은 데이터베이스 프레임워크는

쿼리에 대한 응답으로 사용하기 편리한 데이터 포맷을 사용한다.

이러한 포맷은 보통 데이터베이스의 "행 구조"인 경우가 많다. (엔티티)

이러한 행 구조가 의존성 규칙을 어기고 경계를 넘어 내부로 그대로 전달되는 것을 원치 않는다.

행 구조 데이터를 이용하여 경계를 넘나들면 내부의 원에서 외부의 원의 무언가를 알아야만 하기 때문이다.

(즉, 내부의 원에서 외부 원에 대한 의존성이 생긴다. => 의존성 규칙의 위반)

따라서 경계를 가로질러 데이터를 전달할 때,

데이터는 항상 내부의 원에서 사용하기에 가장 편리한 형태를 가져야 한다.

전형적인 시나리오

아래의 그림 22.2의 다이어그램은 데이터베이스를 사용하는

웹 기반 자바 시스템의 전형적인 시나리오를 보여준다.

위 그림을 잘 살펴보면,

2줄로 된 선을 통해 각 계층 간에 경계를 표현하였다.

엔티티와 유스케이스 사이의 경계,

그리고 원에서 가장 외부에 위치해있는 세부사항인 데이터베이스와 유스케이스 사이의 경계

그리고 유스케이스와 프레젠터 / 컨트롤러 사이의 경계

22.1 그림에 비유하자면,

엔티티 경계가 가장 원 내부에 위치하고,

그 다음 UseCaseInterator,

그 다음이 Presenter, Controller, View 계층이 가장 원의 바깥에 위치한다.

데이터베이스 역시 업무 정책에서는 영향을 받지 않아야하는 세부사항이므로

원의 바깥 쪽에 위치한다.

웹 서버는 사용자로부터 입력 데이터를 모아서

좌측 상단의 "Controller"로 전달한다.

Controller는 데이터를 평범한 자바 POJO 객체로 묶은 후,

InputBoundary 인터페이스를 통해 UseCaseInteractor로 전달한다.

데이터의 흐름, 즉 제어 흐름은

Controller -> InputBoundary -> UseCaseInteractor -> Entities 와 같이 흐르지만,

이 그림에서도 책에서 계속 강조한 바와 같이, 의존 관계는 제어흐름과 반대 방향으로 흘러간다. (의존성 역전의 원칙)

Controller -> InputBoundary <- UseCaseInteractor

(그림에서 OutBoundDary 인터페이스의 역할도 마찬가지다.

구체에는 의존하지 않고 추상에 의존하는 유연성이 극대화된 시스템)

UseCaseInteractor는 이 데이터를 해석하여

Entity가 어떻게 활용하고 제어하는데 사용한다.

POJO 객체를 엔티티 형식으로 알맞게 변환하거나 엔티티를 로딩하여

애플리케이션의 유스케이스 요구 사항에 따라 수정하고,

애플리케이션의 업무 규칙을 적용시킨다.

또한 UseCaseInteractor는 DataAccessInterface를 사용하여

Entity가 사용할 데이터를 데이터베이스에서 불러와서 메모리로 로드한다.

Entity가 완성되면, UsecaseInteractor는 Entity로부터 데이터를 모아서

또 다른 평범한 자바 객체인 OutputData를 구성한다.

그러고 나서 OutputData는 OutPutBoundary 인터페이스를 통해 Presenter로 전달된다.

Entity -> UsecaseInteractor -> OutputData

여기서도 의존관계를 살펴보면,

Presenter(원의 외부)는 OutputBoundary 인터페이스에 의존한다.

Presenter의 역할은 OutputData를 ViewModel과 같이

화면에 출력할 수 있는 형식으로 재구성하는 일이다.

다만 OutputData보다 Presenter에서 좀 더

사용자와 가까이서, 사용자 관점에서 보기에 적절한 데이터 형식들을 담는다고 생각할 수 있다.

22.2 그림에서 주목해야할 점은

모든 의존성의 방향이 경계선을 안쪽으로 가로지르고 있다.

원의 외부에 위치한 계층이 내부에 위치한 계층에 의존하고 있다.

반대로 내부 계층은 외부 계층의 존재를 알지 않고 의존하지 않고 있으므로

의존성 규칙을 철저히 준수하고 있다.

결론

이러한 간단한 규칙들을 준수하는 일은 어렵지 않으며,

소프트웨어를 변경하고 유지보수 하면서 향후 겪을 수많을 고통거리를 줄어줄 것이다.

소프트웨어를 계층으로 분리하고

의존성 규칙을 준수한다면,

본질적으로 테스트하기 쉬운 시스템을 만들게 될 것이며,

그에 따른 이점을 누릴 수 있다.

관련글 더보기

댓글 영역