728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

서론


오늘날의 애플리케이션은 대부분 웹 인터페이스 같은 것을 제공한다. 웹 브라우저를 통해 상호작용할 수 있는 UI나 다른 시스템에서 우리 애플리케이션으로 호출하는 방식으로 상호작용하는 HTTP API가 여기에 해당한다.

헥사고날 아키텍처에서 외부 세계와의 모든 커뮤니케이션은 어댑터를 통해 이뤄진다.

웹 인터페이스를 제공하는 어댑터의 구현 방법을 살펴보자

 

의존성 역전


웹 어댑터는 주도하는 혹은 인커밍 어댑터다. 외부로부터 요청을 받아 애플리케이션 코어를 호출하고 무슨 일을 해야 할지 알려준다. 이때 제어 흐름은 웹 어댑터에 있는 컨트롤러에서 애플리케이션 계층에 있는 서비스로 흐른다.

애플리케이션 계층은 웹 어댑터가 통신할 수 있는 특정 포트를 제공한다. 서비스는 이 포트를 구현하고, 웹 어댑터는 이 포트를 호출할 수 있다.

 

 

  • 웹 어댑터와 관련된 아키텍처 요소에 초점을 맞추었다.
  • 인커밍 어댑터는 애플리케이션 서비스에 의해 구현된 인터페이스인 전용포트를 통해 애플리케이션 계층과 통신한다.

 

자세히 살펴보면 의존성 역전 원칙이 적용된 것을 발견할 수 있다. 그런데 위의 그림을 보면 제어 흐름이 왼쪽에서 오른쪽으로 흐르기 때문에 웹 어댑터가 유스케이스를 직접 호출할 수 있는데 왜 사이에 간접 계층을 넣어야 할까?

 

 

그 이유는 애플리케이션 코어가 외부 세계와 통신할 수 있는 곳에 대한 명세가 포트이기 때문이다. 포트를 적절한 곳에 위치시키면 외부와 어떤 통신이 일어나고 있는지 정확힐 알 수 있고, 이는 유지보수 측면에서 장점이 있다.

 

 

하지만 만약 애플리케이션이 웹 어댑터에 능동적으로 알림을 줘야 한다면 올바른 방향으로 유지하기 위해 아웃고잉 포트를 통과해야 한다.

  • ex) 웹 소켓을 통해 실시간 데이터를 사용자의 브라우저로 보낸다고 가정
  • 포트가 반드시 필요
  • 해당 포트는 웹 어댑터에서 구현하고 애플리케이션 코어에서 호출해야 한다.
  • 엄밀히 말하면 해당 포트는 아웃고잉 포트이기 때문에 이제 웹 어댑터는 인커밍 어댑터인 동시에 아웃고잉 어댑터가 된다.

 

웹 어댑터의 책임


애플리케이션이 REST API를 제공한다고 하면 웹 어댑터의 책임은 보통 다음과 같다.

  1. HTTP 요청을 자바 객체로 매핑
  2. 권한 검사
  3. 입력 유효성 검증
  4. 입력을 유스케이스의 입력 모델로 매핑
  5. 유스케이스 호출
  6. 유스케이스의 출력을 HTTP로 매핑
  7. HTTP 응답을 반환

 

웹 어댑터는 특정 기준을 만족하는 HTTP 요청을 수신해야 하며 요청의 파라미터와 컨텐츠를 객체로 역직렬화 해야한다.

또한 웹 어댑터가 인증과 권한 부여를 수행하고 실패할 경우 에러를 반환한다.

 

그러고 나면 객체의 상태 유효성 검증을 할 수 있다. 여기서의 입력 유효성 검증은 유스케이스의 입력 모델이 책임지는 검증을 말하는 것이 아니라 웹 어댑터의 입력 모델에 대해 검증을 말하는 것이다.

  • 유스케이스의 입력 모델과는 구조과 의미가 다를 수 있으므로 또 다른 유효성 검증을 수행해야 한다.
  • 유스케이스 입력 모델에서 했던 검증을 똑같이 구현해야하는 것은 아니다.
  • 웹 어댑터의 입력 모델을 유스케이스의 입력 모델로 변환할 수 있다는 것을 검증해야한다.
  • 해당 변환을 방해는 모든 것이 유효성 검증 에러다.

 

이는 자연스럽게 웹 어댑터의 다음 책임인 변환된 입력 모델로 특정 유스케이스를 호출하는 것으로 연결된다. 어댑터는 유스케이스의 출력을 반환하고, HTTP 응답으로 직렬화해서 호출자에게 전달한다.

 

해당 과정에서 한 군데서라도 문제가 생기면 예외를 던지고, 웹 어댑터는 에러를 호출자에게 보여줄 메시지로 변환해야 한다. HTTP와 관련된 것은 애플리케이션 계층으로 침투해서는 안 된다.

 

웹 어댑터와 애플리케이션 계층 간의 이 같은 경계는 웹 계층에서부터가 아닌 도메인과 애플리케이션 계층부터 개발하기 시작하면 자연스럽게 생긴다. 특정 인커밍 어댑터를 생각할 필요 없이 유스케이스를 먼저 구현하면 경계를 흐리게 만들 유혹에 빠지지 않을 수 있다.

 

컨트롤러 나누기


웹 어댑터는 한 개 이상의 클래스로 구성해도 되지만 클래스들이 같은 소속이라는 것을 표현하기 위해 같은 패키지 수준에 놓아야 한다.

컨트롤러 갯수는 너무 적은 것보다는 너무 많은 게 낫다. 가능한 좁고 다른 컨트롤러와 가능한 적게 공유하는 웹 어댑터 조각을 구현해야 한다.

 

저자는 각 연산에 대해 가급적이면 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식을 선호한다. 또한 가급적 메서드와 클래스명은 유스케이스를 최대한 반영해서 지어야 한다고 말한다.

package buckpal.adapter.in.web;

@RestController
@RequiredArgsConstructor
class SendMoneyController {

  private final SendMoneyUseCase sendMoneyUseCase;

  @PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
  void sendMoney(
      @PathVariable("sourceAccountId") Long sourceAccountId,
      @PathVariable("targetAccountId") Long targetAccountId,
      @PathVariable("amount") Long amount) {

    SendMoneyCommand command = new SendMoneyCommand(
        new AccountId(sourceAccountId),
        new AccountId(targetAccountId),
        Money.of(amount));

    sendMoneyUseCase.sendMoney(command);
  }
}

 

요약


애플리케이션의 웹 어댑터를 구현할 때는 HTTP 요청을 애플리케이션의 유스케이스에 대한 메서드 호출로 변환하고 결과를 다시 HTTP로 변환하고 어떤 도메인 로직도 수행하지 않는 어댑터를 만들고 있다는 점을 염두에 둬야 한다.

반면 애플리케이션 계층은 HTTP에 대한 상세 정보를 노출시키지 않도록 웹 계층과 관련된 작업을 해서는 안된다.

 

웹 컨트롤러를 나눌 때는 모델을 공유하지 않는 것을 두려워 해서는 안된다. 작은 클래스들이 더 파악하기 쉽고, 더 테스트하기 쉬우며, 동시 작업을 지원한다. 처음에는 조금 더 공수가 들겠지만 유지보수 측면에서는 분명 더 좋은 효과를 낼 수 있을 것이다.

 

 

 

728x90
728x90

만들면서 배우는 클린 아키텍처 책을 읽고 정리하며 소감을 적는 포스트입니다.

 

도메인 모델 구현하기


애플리케이션, 웹, 영속성 계층이 현재 아키텍처에서 느슨하게 결합대 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다. DDD 개념을 적용하거나, rich or anemic 도메인 모델을 구현할 수도 있고, 새로운 방식을 만들어 낼 수도 있다.

 

이제 헥사고날 스타일에서 유스케이스를 구현하기 위한 방법을 살펴보자. 도메인 중심 아키텍처에 적합하므로 도메인 엔티티를 만드는 것을 시작으로 유스케이스를 구현해 보자.

 

계좌 송금 유스케이스

계좌에서 다른 계좌로 송금하는 유스케이스를 구현해 보자. 객체지향적인 방식으로 모델링하는 방법 중 하나는 입금과 출금을 할 수 있는 Account 엔티티를 만들고 출금 계좌에서 출금해서 입금 계좌로 돈을 입금하는 것이다.

package buckpal.domain;

@AllArgsConstructor
@Getter
public class Account {
    private AccountId id;
    private Money baselineBalance;
    private ActivityWindow activityWindow;
    
    // 생성자와 게터 생략

    public Money calculateBalance() {
        return Money.add(
            this.baselineBalance,
            this.activityWindow.calculateBalance(this.id)
        );
    }

    public boolean withDraw(Money money, AccountId targetAccountId) {
        if (!mayWithDraw(money)) {
            return false;
        }

        Activity withDrawal = new Activity(
            this.id,
            this.id,
            targetAccountId,
            LocalDateTime.now(),
            money
        );
        this.activityWindow.addActivity(withDrawal);
        return true;
    }

    private boolean mayWithDraw(Money money) {
        return Money.add(
            this.calculateBalance(),
            money.negate()
        ).isPositive();
    }

    public boolean deposit(Money money, AccountId sourceAccountId) {
        Activity deposit = new Activity(
            this.id,
            sourceAccountId,
            this.id,
            LocalDateTime.now(),
            money
        );
        this.activityWindow.addActivity(deposit);
        return true;
    }
}

 

Account 엔티티는 실제 계좌의 현재 스냅샷을 제공한다. 계좌에 대한 모든 입금과 출근은 Activity 엔티티에 포착된다. 한 계좌에 대한 모든 activity 들은 항상 메모리에 한꺼번에 올리는 것은 좋지 않기 때문에 Account 엔티티는 ActivityWindow(value object)에서 포착한 특정 지난 기간의 해당하는 활동만 보유한다. 계좌의 현재 잔고를 계산하기 위해서 Account 엔티티는 활동창(activity window)의 첫 번째 활동 바로 전의 잔고를 표현하는 기준 잔고(baselineBalance) 속성을 가지고 있다.

총 잔고 = 기준 잔고 + 모든 활동창의 잔고

 

해당 모델 덕분에 계좌에서 일어나는 입금과 출금은 각각 withdraw(), deposit() 메서드에서처럼 새로운 활동을 활동창에 추가하는 것에 불과하다. 출금하기 전에는 잔고를 초과하는 금액은 출금할 수 없도록 하는 비즈니스 규칙을 검사한다. 이제 입출금이 가능한 Account 엔티티가 있으므로 이를 중심으로 유스케이스를 구현하기 위해 바깥 방향으로 나아가 보자.

 

유스케이스 둘러보기


먼저 유스케이스가 무슨 일을 하는지 살펴보자. 일반적으로는 다음과 같은 단계를 따른다.

  1. 입력을 받는다.
  2. 비즈니스 규칙을 검증한다.
  3. 모델 상태를 조작한다.
  4. 출력을 반환하다.

유스케이스는 인커밍 어댑터로부터 입력을 받는다. 이 단계를 왜 입력 유효성 검증으로 부르지 않을까? 저자는 유스케이스 코드가 도메인 로직에만 신경 써야 하고 입력 유효성 검증으로 오염되면 안 된다고 생각하였다. 따라서 입력 유효성 검증은 곧 살펴볼 다른곳에서 처리하는 것을 확인해 보자.

 

그러나 유스케이스는 비즈니스 규칙을 검증할 책임이 있다. 그리고 도메인 엔티티와 이 책임을 공유한다. 비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델의 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수도 있다. 마지막 단계는 아웃고잉 어댑터에서 온 출력값을, 유스케이스를 호출한 어댑터로 반환할 출력 객체로 변환하는 것이다.

package buckpal.application.service

@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

    private final LoadAccountPort loadAccountPort;
    private final AccountLock accountLock;
    private final UpdateAccountStatePort updateAccountStatePort;

    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        // TODO: 비즈니스 규칙 검증
        // TODO: 모듈상태 조작
        // TODO: 출력 값 반환
    }
}

 

서비스는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 계좌를 불러오기 위한 아웃 고잉 포트 인터페이스인 LoadAccountPort를 호출한다. 그리고 데이터에비스의 계좌 상태를 업데이트하기 위해 UpdateAccountStatePort를 호출한다.

하나의 서비스가 하나의 유스케이스를 구현하고, 도메인 모델을 변경하고, 변경된 상태를 저장하기 위해 아웃고잉 포트를 호출한다.

 

입력 유효성 검증


입력 유효성 검증은 유스케이스 클래스의 책임이 아니라고 이야기하긴 했지만, 여전히 이 작업은 애플리케이션 계층의 책임에 해당한다.

 

호출하는 어댑터에서 입력 유효성 검증?

 

호출하는 어댑터가 유스케이스에 입력을 전달하기 전에 입력 유효성을 검증하면 어떨까? 유스케이스에서 필요로 하는 것을 호출자가 모두 검증했다고 믿을 수 있을까? 또, 유스케이스는 하나 이상의 어댑터에서 호출될 텐데, 그러면 유효성 검증을 각 어댑터에서 전부 구현해야 한다.

 

애플리케이션 계층에서 입력 유효성을 검증해야 하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있기 때문이다. 유스케이스 클래스가 아니라면 어디에서 입력 유효성을 검증해야 할까? 입력 모델(input model)이 이 문제를 다루도록 해보자.

 

입력 모델에서 입력 유효성 검증

 

송금하기 유스케이스에서 입력 모델은 SendMoneyCommand 클래스다. 생성자 내에서 입력 유효성을 검증할 것이다.

package buckpal.application.port.in

@Getter
public class SendMoneyCommand {

    private final AccountId sourceAccountId;
    private final AccountId targetAccountId;
    private final Money money;

    public SendMoneyCommand(
            AccountId sourceAccountId,
            AccountId targetAccountId,
            Money money) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;
        requireNonNull(sourceAccountId);
        requireNonNull(targetAccountId);
        requireNonNull(money);
        requireGraterThan(money, 0);
    }
}

 

송금을 위한 조건 중 하나라도 위배되면 객체를 생성할 때 예외를 던져서 객체 생성을 막으면 된다. SendMoneyCommand의 필드에 final을 지정해 불변 필드로 만들었다. 따라서 일단 생성에 성고하고 나면 상태는 유효하고 이후에 잘못된 상태로 변경할 수 없다는 사실을 보장할 수 있다.

 

SendMoneyCommand는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치한다. 그러므로 유효성 검증이 애플리케이션의 코어에 남아 있지만 유스케이스 코드를 오염시키지는 않는다.

 

입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주위에 사실상 오류 방지 계층(anti corruption layer)을 만들었다. 여기서 말하는 계층은 하위 계층을 호출하는 계층형 아키텍처의 계층이 아니라 잘못된 입력을 호출자에게 돌려주는 유스케이스 보호막을 의미한다.

자바에 Bean Validation API가 이러한 작업을 위한 사실상의 표준 라이브러리다. 해당 api를 이용하면 애너테이션으로 표현할 수 있다.

 

생성자의 힘

 

SendMoneyCommand는 생성자에 많은 책임을 지우고 있다. 클래스가 불변이기 때문에 생성자의 인자 리스트에는 클래스의 각 속성에 해당하는 파라미터들이 포함돼 있다. 그뿐만 아니라 생성자가 파라미터의 유효성 검증까지 하고 있기 때문에 유효하지 않은 상태의 객체를 만드는 것은 불가능하다.

 

그런데 만약 생성자의 파라미터가 더 많다면 어떻게 해야 할까? 빌더 패턴을 활용하면 더 좋지 않을까?

new SendMoneyCommandBuilder()
        .sourceAccountId(new AccountId(41L))
        .targetAccountId(new AccountId(42L))
        // ... 다른 여러 필드를 초기화
        .build();

 

하지만 빌더 패턴을 사용하면 SendMoneyCommand에 필드를 새로 추가해야 하는 상황에서 빌더를 호출하는 코드에 새로운 필드를 추가하는 것을 잊을 수 있다. 물론 런타임에 유효성 검증 로직이 동작해서 누락된 파라미터에 대해 에러를 던지긴 하지만 컴파일 단에서 이끌도록 하는 것이 어떨까?

 

유스케이스마다 다른 입력 모델

각기 다른 유스케이스에서 동일한 입력 모델을 사용하고 싶을 수 있다. 예를 들어 ‘계좌 등록하기’‘계좌 정보 업데이트’라는 두 가지 유스케이스를 생각해 보자. 두 가지 모두 거의 동일한 상세 정보가 필요하지만, 두 유스케이스에서 공유하지 않는 정보가 존재하는데 입력 모델을 공유할 경우 특정 속성들의 null 값을 허용해야 한다.

 

불변 커맨드 객체의 필드에 대해서 null을 유효한 상태로 받아들이는 것은 code smell이다. 하지만 더 문제가 되는 부분은 서로 다른 유효성 검증 로직이 필요해지는 것이다. 유스케이스에 커스텀 유효성 검증 로직을 넣어야 할 것이고, 이것은 비즈니스 코드의 관심사를 오염시킨다. 따라서 각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 side effect를 발생하지 않게 한다.

 

비즈니스 규칙 검증하기


입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 유스케이스 로직의 일부이다. 그런데 언제 입력 유효성을 검증하고 언제 비즈니스 규칙을 검증해야 할까?

 

비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그럴 필요가 없다.

입력 유효성을 검증하는 것은 구문상의(syntactical) 유효성을 검증하는 것이라고도 할 수 있다. 반면 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인(semantical) 유효성을 검증하는 일이라고 할 수 있다.

  • ex) ‘출금 계좌는 초과 출금되어서는 안 된다. → 모델의 현재 상태에 접근해야 하기 때문에 비즈니스 규칙이다.
  • ex) ‘송금되는 금액은 0보다 커야 한다. → 모델에 접근하지 않고도 검증될 수 있기 때문에 입력 유효성 검증으로 구현할 수 있다.

이러한 구분은 논재의 여지가 있지만 이러한 구분법의 장점은 특정 유효성 검증 로직을 코드 상의 어느 위치에 존재할지와 찾는 데 도움이 된다.

 

비즈니스 규칙 검증을 구현하는 좋은 방법은 도메인 엔티티 안에 넣는 것이다.

public class Account {
    // ...

    public boolean withdraw(Money money, AccountId targetAccountId) {
            if (!mayWithdraw(money)){
                    return false;
            }
            // ...
    }
}
  • 규칙을 지켜야 하는 비즈니스 로직 바로 옆에 규칙이 위치하기 때문에 위치를 정하는 것도 쉽고 추론하기도 쉽다.

 

만약 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.

package buckpal.application.service;

@RequiredArgConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    // ...

    @Override
    public boolean sendMoney(SendMoneyCommand command){
			    requireAccountExists(command.getSourceAccountId());
          requireAccountExists(command.getTargetAccountId());
    }
}
  • 유효성을 검증하는 코드를 호출하고, 실패할 경우 유효성 검증 전용 예외를 던진다.
  • 사용자와 통신하는 어댑터는 이 예외를 에러 메시지로 사용자에게 보여주거나 적절한 다른 방법으로 처리한다.

더 복잡한 비즈니스 규칙의 경우에는 먼저 데이터베이스에서 도메인 모델을 로드해서 상태를 검증해야 할 수도 있다. 결국 도메인 모델을 로드해야 한다면 도메인 엔티티 내에 비즈니스 규칙을 구현해야 한다.

 

rich(풍부한) vs anemic(빈약한) 도메인 모델


도메인 모델을 구현하는 방법에서는 열려 있다. DDD 철학을 따르는 rich domain model을 구현할 것인지, anemic domain model을 구현할 것인가는 자주 논의되는 사항이다.

 

풍부한 도메인 모델

  • 애플리케이션 코어에 있는 엔티티에서 가능한 많은 도메인 로직 구현
  • 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만 허용
  • 유스케이스는 도메인 모델의 진입점으로 동작

 

빈약한 도메인 모델

  • 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 포함하고 도메인 로직을 가지지 않는다.
  • 도메인 로직이 유스케이스 클래스에 구현돼 있다는 것

 

유스케이스마다 다른 출력 모델


입력과 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다. 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다.

‘송금하기’ 유스케이스 코드에서는 boolean 값 하나를 반환했다. 이는 송금하기 맥락에서 반환할 수 있는 가장 구체적인 최소한의 값이다.

업데이트된 Account를 반환하고 싶을 수도 있다. 하지만 ‘송금하기’ 유스케이스에서 정말로 이 데이터를 반환해야 할까? 이 부분에 정답은 없다.

 

그러니 유스케이스를 가능한 한 구체적으로 유지하기 위해서는 계속 질문해야 하며, 의심된다면 가능한 한 적게 반환하자. 유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는 데 도움이 된다. 따라서 도메인 엔티티를 출력 모델로 사용하지 않아야 한다.

 

읽기 전용 유스케이스?


UI에 계좌의 잔액을 표시해야 한다고 가정해 보자. 이를 위한 새로운 유스케이스를 구현해야 할까? 이 같은 읽기 전용 작업을 유스케이스라고 언급하는 것은 조금 이상하다.

 

‘계좌 잔고 보여주기’라고 부를 수 있는 특정 유스케이스를 구현하기 위해 요청한 데이터가 필요할 수도 있고 전체 프로젝트 맥락에서 이러한 작업이 유스케이스로 분류된다면 다른 유스케이스와 비슷한 방식으로 구현해야 한다.

 

하지만 애플리케이션 코어의 관점에서 이 작업은 간단한 데이터 쿼리다. 그렇기 때문에 프로젝트 맥락에서 유스케이스로 간주되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다. 이를 구현하는 한 가지 방법은 쿼리를 위한 인커밍 전용 포트를 만들고 이를 쿼리 서비스에 구현하는 것이다.

package buckpal.application.service;

@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {
    private final LoadAccountPort loadAccountPort;

    @Override
    public Money getAccountBalance(AccountId, accountId){
        return loadAccountPort.loadAccount(accoutId, LocalDateTime.now()).calculateBalance();
    }
}

 

쿼리 서비스는 유스케이스 서비스와 동일한 방식으로 동작한다. GetAccountBalanceQuery라는 인커밍 포트를 구현하고, 데이터베이스로부터 실제로 데이터를 로드하기 위해 LoadAccountPort라는 아웃고잉 포트를 호출한다. 이처럼 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(커맨드)와 코드 상에서 명확하게 구분된다. 이러한 방식은 CQS or CQRS 같은 개념과 아주 잘 맞는다.

 

요약


도메인 로직을 원하는 대로 구현할 수 있도록 열어두었지만, 입출력 모델을 독립적으로 모델링한다면 side effect를 피할 수 있다. 물론 유스페이스 간 모델을 공유하지 않으므로 더 많은 코드 작업이 필요하지만 장기적으로 유지보수 측면에서 유용하다.

728x90
728x90

DataBufferLimitException


Spring Webflux의 WebClient를 사용하던 중 특정 api를 호출하여 응답받는 경우 아래와 같은 예외가 발생했다.

org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144

 

원인은?

WebClient 설정에는 애플리케이션의 메모리 이슈를 방지할 수 있도록 코덱(codec)의 메모리 버퍼 사이즈 제한 값을 갖고 있다. 이 값은 기본적으로 256KB로 설정되어 있는데, 이 값을 넘어가는 경우 DataBufferLimitException 예외가 발생한다.

 

해결 방법


@Bean
fun webClient(): WebClient {
    val strategies = ExchangeStrategies.builder()
        .codecs { codecs: ClientCodecConfigurer ->
            codecs.defaultCodecs().maxInMemorySize(64 * 1024 * 1024)
        }
        .build()
    return WebClient.builder()
        .exchangeStrategies(strategies)
        .build()
}

DataBufferLimitException 예외가 발생하지 않도록 WebClient를 설정할 때 코덱(codec)의 메모리 버퍼 사이즈 제한 값을 maxInMemorySize 메서드의 파라미터로 넣어주면 된다.

만약 사이즈 제한을 두고싶지 않은 경우 메서드 파라미터로 -1을 전달하면 된다.

 

클라이언트 사이드

참고로 해당 설정의 코덱은 client side의 코덱이다.

public interface ClientCodecConfigurer extends CodecConfigurer {

	/**
	 * {@inheritDoc}
	 * <p>On the client side, built-in default also include customizations related
	 * to multipart readers and writers, as well as the decoder for SSE.
	 */
	@Override
	ClientDefaultCodecs defaultCodecs();
	
	}

 

 

서버 사이드

만약 서버사이드쪽 버퍼 사이즈를 변경하고 싶다면 WebFluxConfigurer 인터페이스를 사용하여 구성이 가능하다.

@Configuration
class WebConfig : WebFluxConfigurer {

  override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        configurer.defaultCodecs().maxInMemorySize(10*1024*1024)
  }
}

 

 

WebClient Timeout 관련


추가적으로 WebClient의 타임 아웃 관련 설정도 알아보자. WebClient의 타임아웃을 설정하는 쉬운 방법은 기본 http 클라이언트를 사용하여 전역적으로 설정하는 것이다. 기본적으로 Reactor Netty가 사용된다.

 

Response Timeout

요청을 보낸 후 응답을 받기까지 기다리는 시간 제한이다. responseTimeout() 메서드를 통해 설정 가능하다.

val httpClient = HttpClient.create()
	.responseTimeout(Duration.ofMillis(5000))
    
return WebClient.builder()
	.clientConnector(ReactorClientHttpConnector(httpClient))
	.build()

 

Connection Timeout

커넥션 타임아웃은 클라이언트와 서버간의 연결이 이루어져야하는 시간 제한이다. option() 메서드를 통해 다양한 옵션 키값들을 설정할 수 있다. 커넥션이 이루어지지 않거나 끊어지면 ConnectTimeoutException  예외가 발생한다.

val client = HttpClient.create()
  .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
  
  
val client = HttpClient.create()
  .option(ChannelOption.SO_KEEPALIVE, true)
  .option(EpollChannelOption.TCP_KEEPIDLE, 300)
  .option(EpollChannelOption.TCP_KEEPINTVL, 60)
  .option(EpollChannelOption.TCP_KEEPCNT, 8);

 

Read and Write Timeout

read timeout은 특정 시간 내에 데이터를 읽지 못햇을 때, write timeout은 특정 시간에 쓰기 작업을 완료할 수 없을 때 발생한다.

val httpClient = HttpClient.create()
    .doOnConnected { conn: Connection ->
        conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
            .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
    
}

 

그 외

이 외에도 SSL/TLS timeout, Proxy timeout, Request level의 timeout 설정 또한 가능하다.

 

참고

728x90

'spring' 카테고리의 다른 글

[spring data mongo] Query를 Type-safe 하게 작성하기 (작성 중)  (0) 2024.11.19
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
728x90

코드를 보는 것만으로도 어떤 아키텍처인지 파악할 수 있다면 좋지 않을까?

지금부터 코드를 구조화하기 위해 여러 가지 방법을 살펴보자

 

계층으로 구성하기


buckapl
|--- domain
|    |----- Account
|    |----- Activity
|    |----- AccountRepository
|    |----- AccountService
|--- persistence
|    |----- AccountRepositoryImpl
|--- web
|    |----- AccountController

코드를 구조화하는 첫 번째 접근법은 계층을 이용하는 것으로 위처럼 코드를 구성할 수 있다. 간단한 구조의 계층은 가장 적합한 구조가 아닐 수 있으므로 의존성 역전 원칙을 적용해서 의존성이 domain 패키지에 있는 도메인 코드만을 향하도록 했다.

하지만 적어도 세 가지 이유로 이 패키지 구조는 최적의 구조가 아니다.

 

문제점

애플리케이션의 기능 조각(functional slice)이나 특징(feature)을 구분 짓는 패키지 경계가 없다.

  • 이 구조에서 사용자를 관리하는 기능을 추가해야 한다면?
    • web 패키지에 UserController를 추가
    • domain 패키지에 UserService, UserRepository, User를 추가
    • persistence 패키지에 UserRepositoryImpl을 추가
  • 추가적인 구조가 없다면, 아주 빠르게 서로 연관되지 않은 기능들끼리 예상하지 못한 side effect를 일으킬 수 있는 클래스들의 묶음으로 변모할 수 있다.

애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다.

  • 특정 기능을 찾기 위해서는 어떤 서비스가 이를 구현했는지 추측해야 하고, 해당 서비스 내의 어떤 메서드가 그에 대한 책임을 수행하는지 찾아야 한다.

패키지 구조를 통해서는 우리의 목표로 하는 아키텍처를 파악할 수 없다.

  • 어떤 기능이 웹 어댑터에서 호출되는지, 영속성 어댑터가 도메인 계층에 어떤 기능을 제공하는지 한눈에 알아볼 수 없다.
  • 인커밍 포트와 아웃고잉 포트가 코드 속에 숨겨져 있다.

 

기능으로 구성하기


buckpal
|-- account
    |-- Account
    |-- AccountController
    |-- AccountRepository
    |-- AccountRepositoryImpl
    |-- SendMoneyService

계층 패키지들을 모두 없애고 계좌와 관련된 모든 코드를 최상우의 account 패키지에 넣었다.

 

“계층으로 구성하기” 방법의 몇 가지 문제를 해결해보자

  • 기능을 묶은 그룹은 account와 같은 레벨의 패키지로 들어가고, 패키지 외부에서 접근하면 안 되는 클래스들에 대해 package-private 접근 수준을 이용해 패키지 간의 경계를 강화할 수 있다.
  • 패키지 경계를 package-private 접근 수준과 결합하면 각 기능사이의 불필요한 의존성을 방지할 수 있다.
  • 책임을 좁히기 위해 SendMoneyService와 같이 송금하기 기능을 구현한 클래스를 클래스명으로 바로 찾을 수 있다.
    • 소리치는 아키텍처: 애플리케이션의 기능을 코드를 통해 볼 수 있게 만드는 것
    • 물론 계층형 패키지 구조 방식에서도 가능하다.

 

문제점

  • 계층의 의한 패키징 방식보다 아키텍처의 가시성을 훨씬 더 떨어뜨린다.
    • 어댑터를 나타내는 패키지명도 없다
    • 인커밍 포트, 아웃 고잉 포트도 없다.
  • SendMoneyService가 AccountRepository인터페이스만 알고 구현체를 알 수 없게 했지만 패키지 내부 package-private 접근 수준을 이용해 영속성 코드에 의존하는 것을 막을 수 없다.

 

아키텍처적으로 표현력 있는 패키지 구조


buckpal
|-- account
    |-- adapter
    |   |-- in
    |   |   |-- web
    |   |       |-- AccountController
    |   |-- out
    |   |   |-- persistence
    |   |       |-- AccountPersistenceAdapter
    |   |       |-- SpringDataAccountRepository
    |-- domain
    |   |-- Account
    |   |-- Activity
    |-- application
        |-- SendMoneyService
        |-- port
            |-- in
            |   |-- SendMoneyUseCase
            |-- out
            |   |-- LoadAccountPort
            |   |-- UpdateAccountStatePort

 

헥사고날 아키텍처에서 구조적 핵심요소는 엔티티, 유스케이스, 인커밍/아웃고잉 포트, 인커밍/아웃고잉 어댑터다.

  • account 패키지: 최상위에 Account 관련 유스케이스를 구현한 모듈임을 나타냄
  • domain 패키지: 유스케이스 다음 레벨로 도메인 모델이 속한 패키지
  • application 패키지: 도메인 모델을 둘러싼 서비스 계층을 포함하는 패키지
    • SendMoneyService
      • 인커밍 포트 케이스 SendMoneyUseCase를 구현
      • 아웃고잉 포트 인터페이스이자 영속성 어댑터에 의해 구현된 LoadAccountPort와 UpdateAccountStatePort를 사용
  • adapter 패키지: 애플리케이션 계층의 인커밍 포트를 호출하는 인커밍 어댑터와 애플리케이션 계층의 아웃고잉 포트에 대한 구현을 제공하는 아웃고잉 어댑터 포함하는 패키지

 

패키지 접근 수준

  • 모두 package-private 가능
    • adapter 패키지
    • adapter 패키지 내의 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥으로 호출되지 않기 때문
  • 일부 public 지정 필요
    • application 패키지의 포트 클래스
      • 어댑터에서 접근 가능해야 하는 포트들은 public 이어야 하기 때문
    • domain 패키지의 도메인 클래스
      • 도메인 클래스들은 서비스, 또는 어댑터에서도 접근 가능하도록 public 이어야 한다.
  • package-private 가능
    • application 패키지의 서비스 클래스
      • 서비스는 인커밍 포트 인터페이스 뒤에 숨겨질 수 있기 때문에 public일 필요가 없다.

 

장점

  • 이러한 패키지 구조는 모델-코드 갭(아키텍처-코드 갭)을 효과적으로 다룰 수 있다.
    • 패키지 구조가 아키텍처를 반영할 수 없다면 결국 관심사의 분리가 불가능해질 것이다.
  • 어댑터 코드를 자체 패키지로 이동시키면 필요할 경우 하나의 어댑터를 다른 구현으로 쉽게 교체 가능하다.
    • SQL 데이터베이스에서 NoSQL 데이터베이스로 교체해야 하는 경우 아웃고잉 포트들만 새로운 어댑터 패키지에 구현하고 기존 패키지를 지우면 된다.
  • DDD 개념을 직접적으로 대응시킬 수 있다.
    • account 같은 상위 레벨 패키지는 다른 바운디드 컨텍스트와 통신할 전용 진입점과 출구(포트)를 포함하는 바운디드 컨텍스트에 해당한다.
모델-코드 갭(model-code gap) 아키텍처 모델에는 항상 코드에 매핑할 수 없는 추상적인 개념, 기술 선택 및 설계 결정이 혼합되어 있다. 최종 결과는 모델이 정한 구성 요소의 배열과 반드시 일치하지 않는 소스 코드가 될 수 있다.

 

의존성 주입의 역할


클린 아키텍처의 가장 본질적인 요건은 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것이다.

 

인커밍 어댑터(ex 웹 어댑터)

  • 제어의 방향이 어댑터와 도메인 코드 간의 의존성 방향과 같아서 쉽다.
  • 어댑터는 단순히 애플리케이션 계층에 위치한 서비스를 호출할 뿐이다.
  • 그럼에도 불구하고 계층 진입점(경계 구분) 구분 짓기 위해 실제 서비스를 포트 인터페이스를 구현하도록 한다.

 

아웃고잉 어댑터(ex 영속성 어댑터)

  • 제어 흐름이 반대 방향으로 의존성을 돌리기 위해 의존성 역전 원칙을 이용
  • 애플리케이션 계층에 포트 인터페이스를 만들고, 어댑터에 해당 포트 인터페이스를 구현한 클래스(아웃 고잉 어댑터)를 둔다.
  • 포트 인터페이스를 구현한 실제 아웃 고잉 어댑터 객체를 누가 애플리케이션 계층에 제공해야 할까?
    • 애플리케이션 계층에서 수동 초기화는 어댑터에 대한 의존성 추가
    • 의존성 주입 활용
    • spring ioc
728x90
728x90

만들면서 배우는 클린 아키텍처를 읽고 공부한 내용을 정리해 보자.

단일 책임 원칙


하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.

보통 단일 책임 원칙은 위의 내용처럼 해석하기 쉽지만 실제 의도는 아래에 가깝다

컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다.

 

책임은 사실 한 가지 일만 하는 것보다는 변경할 이유로 해석해야 한다. 컴포넌트가 변경할 이유가 오로지 한 가지라면 컴포넌트는 자연스럽게 한 가지 일만 하게 된다. 변경할 이유가 오직 한 가지라는 것은 아키텍처에서 어떤 의미일까?  컴포넌트를 변경할 이유가 한 가지라면 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경되더라도 기대한 대로 동작할 것이기 때문이다.

 

하지만 변경할 이유라는 것은 컴포넌트 간의 의존성을 통해 쉽게 전파된다. 컴포넌트의 의존성 각각은 해당 컴포넌트를 변경하는 이유 하나씩에 해당한다. 컴포넌트 E를 변경할 유일한 이유는 E의 기능을 바꿔야 할 때뿐이다. 반면 컴포넌트 A는 모든 컴포넌트에 의존하고 있기 때문에 다른 어떤 컴포넌트가 바뀌든지 같이 바뀌어야 한다.

 

 

의존성 역전 원칙


 

계층형 아키텍처에서 계층 간 의존성은 다음 계층인 아래 방향을 가리킨다. 단일 책임 원칙을 고수준에서 적용할 때 상위 계층들이 하위 계층들에 비해 변경할 이유가 더 많다는 것을 알 수 있다. 그러므로 영속성 계층에 대한 도메인 계층의 의존성 때문에 영속성 계층을 변경할 때마다 잠재적으로 도메인 계층도 변경해야 한다.

 

 

그러나 도메인 코드는 애플리케이션에서 가장 중요한 코드다. 어떻게 이 의존성을 제거할 수 있을까? 바로 의존성 역전 원칙이다.

코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.

 

사실 의존성의 양쪽 코드를 모두 제어할 수 있을 때만 역전시킬 수 있다. 만약 서드파티 라이브러리에 의존성이 있다면 제어할 수 없기 때문에 역전시킬 수 없다. 도메인 코드와 영속성 코드 간의 의존성을 역전시켜서 영속성 코드가 도메인 코드에 의존하고, 도메인 코드를 “변경할 이유”의 개수를 줄여보자.

 

 

엔티티는 도메인 객체를 표현하고 도메인 코드는 이 엔티티들의 상태를 변경하는 일을 중심으로 하기 때문에 먼저 엔티티를 도메인 계층으로 올린다. 그러나 이제는 영속성 계층의 리포지토리가 도메인 계층에 있는 엔티티에 의존하기 때문에 두 계층 사이에 순한 의존성이 생긴다. 따라서 DIP를 적용하여 도메인 계층에 리포지토리 대한 인터페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현하게 하는 것이다.

 

클린 아키텍처


로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다. 이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다.

 

 

대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있다. 클린 아키텍처에서 중요한 규칙은 의존성 규칙으로, 계층 간의 모든 의존성이 안쪽으로 향해야 한다는 것이다.  아키텍처의 코어에는 주변 유스케이스에서 접근하는 도메인 엔티티들이 있다. 유스케이스는 앞에서 서비스라고 불렀던 것들인데, 단일 책임을 갖기 위해 조금 더 세분화돼 있다. 이를 통해 넓은 서비스 문제를 피할 수 있다.

 

도메인 코드에서는 어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에 특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있다. 그래서 도메인 코드를 자유롭게 모델링할 수 있다. 예를 들어, DDD를 순수한 형태로 적용해 볼 수도 있다.

 

하지만 클린 아키텍처에는 외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 가령 영속성 계층에서 ORM 프레임워크를 사용한다고 해보자. 도메인 계층은 영속성 계층을 모르기 때문에 도메인 계층에서 사용한 엔티티 클래스를 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야 한다. 즉, 도메인 계층과 영속성 계층이 데이터를 주고받을 때, 두 엔티티를 서로 변환해야 한다는 뜻이다. 이는 도메인 계층과 다른 계층들 사이에서도 마찬가지다. 하지만 이것은 바람직한 일이다. 이것이 바로 도메인 코드를 프레임워크에 강결합이 제거된 상태이다.

예를 들어 JPA에서 인자가 없는 기본 생성자를 강제하는 것

 

헥사고날 아키텍처


 

헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.

 

육각형 바깥에는 애플리케이션과 상호작용하는 다양한 어댑터들이 있다. 애플리케이션 코어와 어댑터들 간의 통신이 가능해지려면 코어가 각각의 포트를 제공해야 한다.

  • 코어를 주도하는 어댑터(driving adapter)에게는 포트가 코어에 있는 유스케이스 클래스 중 하나에 의해 구현되고 어댑터에 의해 호출되는 인터페이스가 될 것이다.
  • 코어에 의해 주도되는 어댑터(driven adapter)에게는 포트가 어댑터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것이다.

 

헥사고날 아키텍처도 클린 아키텍처처럼 계층으로 구성할 수 있다. 가장 바깥쪽에 있는 계층을 애플리케이션과 다른 시스템 간의 번역을 담당하는 어댑터로 구성되어 있다. 다음으로 포트와 유스케이스 구현체를 결합해서 애플리케이션 계층을 구성할 수 있다. 마지막 계층에는 도메인 엔티티가 위치한다.

 

결국 핵심은? 의존성


결국 어떤 아키텍처라고 불리든 의존성을 역전시켜 도메인 코드가 다른 바깥쪽 코드에 의존하지 않게 함으로써 영속성과 UI에 특화된 모든 문제로부터 도메인 로직의 결합을 제거하고 코드를 변경할 이유의 수를 줄일 수 있다. 도메인 코드는 비즈니스 문제에 딱 맞도록 자유롭게 모델링 될 수 있고, 영속성 코드와 UI 코드도 영속성 문제와 UI 문제에 맞게 자유롭게 모델링 될 수 있다.

 

728x90
728x90

만들면서 배는 클린 아키텍처를 읽고 공부한 내용을 정리해 보자.

 

계층형 아키텍처


계층으로 구성된 웹 애플리케이션은 누구나 개발해 본 적 있을 것이다.

계층일 이용하는 사고방식은 컴퓨터 과학 수업이나 튜토리얼, 모범사례를 통해 주입되어 왔다.

 

전통적인 웹 애플리케이션 구조

 

사실 계층형 아키텍처는 견고한 아키텍처 패턴이다. 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다. 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다. 잘 만들어진 계층형 아키텍처는 선택의 폭을 넓히고 , 변화하는 요구사항과 외부 요인에 빠르게 적용할 수 있게 해 준다. 로버튼 마틴에 의하면 이것이 바로 아키텍처의 전부다(클린 아키텍처)

 

그렇다면 계층형의 문제점은 무엇일까? 계층형 아키텍처는 코드에 나쁜 습관들이 스며들기 쉽게 만들고 시간이 지날수록 소프트웨어를 점점 더 변경하기 어렵게 만드는 허점들을 노출한다.

 

데이터베이스 주도 설계를 유도


정의에 따르면 계층형 아키텍처의 토대는 데이터베이스다. 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 된다. 모든 것이 영속성 계층을 토대로 만들어진다. 이런 방식은 다양한 이유로 문제를 초래한다.

 

우리가 만드는 애플리케이션의 대부분의 목적은 무엇인가, 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 이러한 규칙과 정책을 더욱 편리하게 활용할 수 있게 한다. 이때 우리는 상태가 아니라 행동을 중심으로 모델링한다. 상태가 중요한 요소이긴 하지만 행동이 상태를 바꾸는 주체이기 때문에 행동이 비즈니스를 이끌어간다.

 

그렇다면 왜 도메인 로직이 아닌 데이터베이스를 토대로 아키텍처를 만드는 걸까? 그동안 만들어 본 애플리케이션의 유스케이스를 도메인 로직이 아니라 영속성 계층을 먼저 구현했을 것이다. 데이터베이스의 구조를 먼저 생각하고 이를 토대로 도메인 로직을 구현했을 것이다.

계층형 아키텍처에서는 합리적인 방법이다. 의존성에 방향에 따라 자연스럽게 구현한 것이기 때문이다. 하지만 비즈니스 관점에서는 전혀 맞지 않는 방법이다. 먼저 도메인 로직을 만들어야 한다. 그래야 우리가 로직을 제대로 이해했는지 확인할 수 있으며, 도메인 로직이 맞다는 것을 확인한 후에 이를 기반으로 영속성 계층과 웹 계층을 만들어야 한다.

 

데이터베이스 중심 아키텍처가 만들어지는 가장 큰 원인은 ORM 프레임워크를 사용하기 때문이다. ORM 프레이워크가 나쁘다는 것이 아니라 ORM 프레임워크를 계층형 아키텍처와 결합하면 비즈니스 규칙을 영속성 관점과 섞고 싶은 유혹을 쉽게 받는다.

 

 

도메인 계층에서 이러한 영속성 계층 속 엔티티에 접근할 수 있으며 사용되기 마련이다. 이렇게 되면 영속성 계층과 도메인 계층 사이에 강한 결합이 생긴다. 서비스는 영속성 모델을 비즈니스 모델처럼 사용하게 되고 이로 인해 도메인 로직뿐만 아니라 즉시로딩/지연로딩, 데이터베이스 트랜잭션, 캐시 플러시 등등 영속성 계층과 관련된 작업들을 해야만 한다. 영속성 코드가 사실상 도메인 코드에 녹아들어 가서 둘 중 하나만 바꾸는 것이 어려워진다.

 

지름길을 택하기 쉬워진다.


계층형 아키텍처에서 전체적으로 적용되는 유일한 규칙은, 특정한 계층에서는 같은 계층에 있는 컴포넌트나 아래에 있는 계층에만 접근 가능하다는 것이다. 만약 상위 계층에 컴포넌트에 접근해야 한다면 간단하게 해당 컴포넌트를 계층아래로 내려버리면 된다. 딱 한번 이렇게 하는 것은 괜찮을 수 있지만 보통 영속성 계층은 수년에 걸친 개발과 유지보수로 아래 그림처럼 될 가능성이 높다.

 

 

영속성 계층에서는 모든 것에 접근 가능하기 때문에 시간이 지나면 점점 비대해진다. 영속성 계층은 컴포넌트를 아래 계층으로 내릴수록 비대해진다. 어떤 계층에도 속하지 않는 것처럼 보이는 헬퍼 컴포넌트나 유틸리티 컴포넌트들이 이처럼 아래 계층을 내릴 가능성이 큰 후보다.

 

테스트하기 어려워진다.


계층형 아키텍처를 사용할 때 일반적으로 나타나는 변화의 형태는 계층을 건너뛰는 것이다. 엔티티의 필드를 단 하나만 조작하면 되는 경우에 웹 계층에서 바로 영속성 계층에 접근하면 도메인 계층을 건드릴 필요가 없지 않을까?

 

 

도메인 계층을 건너뛰는 것은 도메인 로직을 코드 여기저기에 흩어지게 만든다. 웹 계층 쪽 유스케이스가 확장되는 경우 아무리 간단한 것에 불과하더라도 도메인 로직을 웹 계층에 구현하게 된다. 따라서 애플리케이션 전반에 걸쳐 책임이 섞이고 핵심 도메인 로직들이 퍼져나갈 확률이 높다.

 

유스케이스를 숨긴다.


 

기능을 추가하거나 변경할 적절한 위치를 찾는 일이 빈번하기 때문에 아키텍처는 코드를 빠르게 탐색하는 데 도움이 돼야 한다.

하지만 계층형 아키텍처는 말했듯이 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽다. 이럴 경우 새로운 기능을 추가할 적당한 위치를 찾는 일이 어려워진 상태이다. 계층형 아키텍처는 도메인 서비스의 ‘너비’에 관한 규칙을 강제하지 않기 때문에 시간이 지나면 여러 개의 유스케이스를 담당하는 아주 넓은 서비스가 만들어지기도 한다.

 

 

넓은 서비스는 영속성 계층에 많은 의존성을 갖게 되고, 다시 웹 레이어의 많은 컴포넌트가 이 서비스에 의존하게 된다. 그럼 서비스를 테스트하기도 어려워지고 작업해야 할 유스케이스를 책임지는 서비스를 찾기도 어려워진다. 고도로 특화된 좁은 도메인 서비스가 유스케이스 하나씩만 담당하게 한다면 이런 작업들이 얼마나 수월해질까? UserService에서 사용자 등록 유스케이스를 찾는 대신 RegisterUserService를 바로 열어서 작업을 시작하는 것처럼 말이다.

 

동시 작업이 어려워진다.


계층형 아키텍처는 동시 작업 측면에서는 그다지 도움이 되지 않는다. 계층형 아키텍처에서는 모든 것이 영속성 계층 위에 만들어지기 때문에 특정 기능은 동시에 한 명의 개발자만 작업할 수 있다. 인터페이스를 먼저 같이 정의 후 작업을 할 순 있지만 영속성 로직과 도메인 로직이 뒤섞여서 각 측면을 개별적으로 작업하기 힘들고, 또 코드에 넓은 서비스가 있다면 서로 다른 기능을 동시에 작업하기는 더욱 어렵다.

 

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


물론 계층형 아키텍처도 올바르게 구축하고 몇 가지 추가적인 규칙들을 적용하면 유지보수가 쉬워지며 코드를 쉽게 변경하거나 추가할 수 있다. 하지만 앞에서 살펴봤듯이 잘못된 방향으로 흘러가도록 쉽고 용인하는 구조이다.

 

따라서 계층형 아키텍처로 만들든 다른 아키텍처 스타일로 만들든, 계층형 아키텍처의 문제점들을 염두에 두면 지름길을 택하지 않고 유지보수하기에 더 쉬운 솔루션을 만드는 데 도움이 될 것이다.

 

 

728x90
728x90

서론


전통적인 레이어드 아키텍처로 프로젝트를 개발하며 점점 문제점들이 보이기 시작했다. 서비스가 조금씩 커지거나 요구사항의 확장이 진행되며 점점 더 복잡해지면서 유지보수에 점점 더 많은 시간이 쓰이고 있었다. 많은 서비스를 담당하는 엄청난 서비스, 영속성 계층인 데이터베이스에 의존성이 점점 커지는 것 등 이러한 문제점을 해결하기 위한 고민이 있던 와중 평소에 소프트웨어 개발에서 도메인 주도 설계, 클린 아키텍처, 헥사고날 아키텍처라는 용어를 많이 들어왔으나 "클린 아키텍처는 뭐고, 헥사고날 아키텍처는 뭐지?, 둘의 차이는 무엇이고 그래서 어떤 아키텍처를 사용하란 걸까? " 하며 항상 헷갈려했다.

이러한 아키텍처들이 왜 등장하게 되었으며, 전통적인 계층형 아키텍처의 문제점을 해결할 수 있을까 싶어 찾아보게 되었다.

조금 조사하던 와중 DDD는 조금 더 큰 영역의 내용인 것 같아 추후에 다시 작성하도록 하고 먼저 클린아키텍처에 대해서 가볍게 훑은 내용들을 정리하고자 한다.

 

 

왜 소프트웨어 아키텍처가 중요할까?


정말 소프트웨어 아키텍처가 중요한 걸까? 소프트웨어가 제공하는 가치는 두 가지가 있다고 한다. 바로 기능과 구조이다. 기능과 구조에서 조금 더 중요한 것은 무엇일까?? 평소에는 기능이라고 생각해 왔다. 우리가 만드는 애플리케이션의 대부분의 목적은 바로 비즈니스를 관장하는 규칙이나 정책을 반영한 모델을 만들어서 사용자가 기능들을 편리하게 사용할 수 있게 만드는 것이라고 생각했기 때문이다.

 

하지만 로버틴 C. 마틴은 구조의 중요성은 언급한다. 왜일까? 바로 우리가 원하는 것은 더 정확하게, 더 빠르게, 더 많이 기능을 추가하기 위해 코드를 읽고, 이해하고, 수정해야 하기 때문이다. 즉, 시스템을 만들고 유지보수하는데 투입되는 인력을 최소화하는 것이다. 잘 생각해 보면 레거시 프로젝트이든 최근에 만든 프로젝트이든 새로운 코드를 짜는 것보다 기존 코드를 바꾸는데 훨씬 더 많은 시간을 쓰는 것이 생각났다.

즉 구조가 좋다는 것은 수정의 비용이 적다는 것이다.

 

좋은 아키텍처 - 1. 계층형 아키텍처


그렇다면 좋은 아키텍처에는 무엇이 있을까?  첫 번째로 계층형 아키텍처다. 사실 계층형 아키텍처는 계층을 잘 이해하고 구성한다면 웹 계층이나 영속성 계층에 독립적으로 도메인 로직을 작성할 수 있다고 한다.  따라서 기존 기능에 영향을 주지 않고 새로운 기능을 추가할 수도 있다.

 

하지만 계층형 아키텍처의 가장 큰 단점은 도메인 계층이 영속성 계층을 의존하는 데이터베이스 주도 설계를 유도한다는 것이다. 도메인 로직을 여러 계층에 흩어지게 만들기 쉬운 아키텍처이다. 기능 기반으로 패키지를 구성하여도 계층적 구조는 동일하다.

 

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

 

좋은 아키텍처 - 2. 클린 아키텍처


로버트 마틴은 클린 아키텍처라는 용어를 정립했다. 클린 아키텍처에서는 설계나 비즈니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, ui 기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 이야기했다.

 

이 말은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미한다. 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하고 있는 아키텍처이다.

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

헥사고날 아키텍처?


헥사고날 아키텍처는 애플리케이션 코어가 각 어댑터와 상호작용하기 위해 특정 포트를 제공하기 때문에 포트 어댑터 아키텍처라고도 불린다. 육각형이란 단어에는 아무런 뜻도 없으며 중요한 것은 육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 있다. 육각형에서 외부로 향하는 의존성이 없기 때문에 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다는 점을 주목하자. 대신 모든 의존성은 코어를 향한다.

 

https://www.youtube.com/watch?v=g6Tg6_qpIVc

 

클린아키텍처의 애매함

클린 아키텍처는 핵심 규칙 외에는 케이스 바이 케이스라 애매한 지점이 많다. 애매할 때는 아래의 기준점을 참고하면 좋을 것 같다.

1. 필요한 시스템을 만들고 유지보수하는 데 투입되는 인력 최소화에 유리한가?
2. 소스 코드 의존성이 안쪽으로, 고수준의 정책을 향하고 있는가? 
3. 세부 사항이 변경되어도 도메인(핵심 규칙)에 변경이 없을 것인가?
4. 테스트하기 쉬운가?
5. 각각의 아키텍처 원칙들을 잘 지키고 있는가?

 

클린아키텍처는 항상 좋을까?

위에서 말했듯이 전통적인 계층적 아키텍처도 충분히 영속성 계층에 독립적으로 도메인 로직을 유지할 수 있을 것이다. 마찬가지로 클린 아키텍처도 항상 좋을 것은 아니다.

 

외부 계층과 철저하게 분리돼야 하므로 애플리케이션 엔티티에 대한 모델을 각 계층에서 유지보수해야 한다. 따라서 코드의 절대적인 양이 많아질 것이며, 프로젝트 개발자 모두가 클린 아키텍처를 이해하고 있지 않을 때 혹은 모두가 사용하기로 합의하지 않았을 때에는 사용하는 것이 오히려 좋지 않을 수 있다고 생각한다.

 

참고

728x90

+ Recent posts