매핑 전략에 찬성하는 의견

두 계층 간에 매핑을 하지 않으면 양 계층에서 같은 모델을 사용해야 하는데 이렇게 하면 두 계층이 강하게 결합된다.

 

매핑 전략에 반대하는 의견

두 계층 간에 매핑을 하게 되면 코드 중복이 너무 많이 일어난다.
많은 유스케이스들이 오직 CRUD만 수행하고 계층에 걸쳐 같은 모델을 사용하기 때문에 계층 사이의 매핑은 과하다.

두 의견 모두 일정 부분 맞다. 몇가지 매핑 전략을 장단점과 함께 살펴보며 언제 어떤 전략을 사용하면 좋을지 생각해보자.

1. 매핑하지 않기 전략

지금부터 살펴볼 그림들은 앞에서 보았던 '송금하기' 유즈케이스 구조를 도식화 한 것이다.

이 그림 에서는 웹 계층(컨트롤러)과 어플리케이션 계층(서비스), 영속성 계층(레포지토리)이 모두 Account 도메인 모델을 전용 모델로 사용하고 있다.

즉 계층간 매핑이 전혀 이루어지지 않았으며, 세 계층이 하나의 Account 도메인 모델을 공유하여 사용하고 있고, 이런 형태를 '매핑하지 않기 전략' 이라 부른다.

 

매핑하지 않기 전략의 장점

모든 계층이 정확히 같은 구조의 같은 정보를 필요로 한다면 매핑하지 않기 전략은 완벽한 선택지이다.

예를 들어 간단한 CRUD 유즈케이스의 경우, 컨트롤러 계층의 모델과 레포지토리 계층의 모델의 코드가 완전히 동일하면 계층마다 별도의 모델을 둘 필요가 없다.

 

매핑하지 않기 전략의 단점

컨트롤러 계층의 필요로 Account 모델에 어노테이션을 추가하거나, 어플리케이션 계층의 필요로 Account 모델에 특정 커스텀 필드를 둬야하는 등 모든 계층이 같은 구조의 같은 정보를 필요로 하지 않을 수 있다.

이 때 매핑하지 않기 전략을 사용하면 단일 책임 원칙(클래스를 변경하는 이유는 오로지 하나여야 한다)을 위배하게 된다.

Account 모델 클래스를 변경하는 이유가 컨트롤러, 서비스, 레포지토리 계층 총 세가지나 되기 때문이다.


2. 양방향 매핑 전략

각 계층이 전용 모델을 가지는 매핑 전략을 '양방향 매핑 전략' 이라고 한다.

위 그림처럼 양방향 매핑 전략에서는 각 계층이 도메인 모델과 완전히 다른 구조의 전용 모델을 갖는다.

각 계층에서는 입력 받은 전용 모델을 도메인 모델로 매핑되기도 하고, 도메인 모델이 다시 전용 모델로 매핑되기도 하기 때문에 양방향 매핑이라고 부른다.

많은 개발자들이 이 매핑 전략을 반드시 지켜야 하는 철칙처럼 생각하는데, 이는 개발을 쓸데없이 더디게 만들기도 한다.

어떠한 매핑 전략도 철칙처럼 여겨져서는 안되며, 각 유즈케이스마다 적절한 전략을 택할 수 있어야 한다.

 

양방향 매핑 전략의 장점

각 계층이 전용 모델을 가지고 있기 때문에 각 계층에서 모델을 변경하더라도 다른 계층에 영향이 없다.

즉 단일 책임 원칙을 만족하는 구조가 된다.

또한 개념적으로는 '매핑하지 않기' 전략 다음으로 간단하다. 매핑 책임이 명확하기 때문이다.

바깥쪽 계층/어댑터에서 해당 계층 모델을 안쪽 계층 모델로 매핑하고, 다시 안쪽 계층 모델을 해당 계층 모델로 매핑하기 때문에

안쪽 계층은 안쪽 계층의 모델만 알면 되어 매핑 대신 해당 계층의 로직에 집중할 수 있다.

 

양방향 매핑 전략의 단점

먼저 코드의 중복이 많이 생길 수 있다.

코드의 양을 줄이기 위해 매핑 프레임워크를 사용하면 두 모델 간 매핑을 구현하는데 꽤 시간이 들 수 있다.

특히 매핑 프레임워크가 내부 동작 방식을 제네릭 코드와 리플렉션 뒤로 숨기는 경우 매핑 로직을 디버깅하는 것은 꽤나 힘들 수 있다.

그리고 이 매핑 전략에서는 도메인 모델이 함수의 파라미터와 반환값으로 사용되는데, 이 경우 도메인 모델이 바깥쪽 계층의 변화에 취약해질 수 있다.


3. 완전 매핑 전략

이 매핑 전략에서는 각 연산마다 별도의 입출력 모델을 사용한다.

계층 간 통신에서 도메인 모델을 사용하는 대신 그림의 SendMoneyCommand(SendMoneyUseCase의 입력 모델로 사용된다) 처럼

각 작업에 특화된 모델을 사용한다.

이런 모델을 command 혹은 request 라 표현하기도 하며, 각 모델은 관련된 유즈케이스에 특화된 전용 필드와 로직을 가진다.

이 전략은 컨트롤러 계층과 서비스 계층 사이에서 사용하는게 가장 좋고,

서비스 계층과 레포지토리 계층 사이에서는 매핑 오버헤드 떄문에 사용하지 않는 것이 좋다.

어떤 경우에는 입력 모델에서만 이 매핑 전략을 사용하고, 도메인 객체를 그대로 출력 모델로 사용해도 된다.

이처럼 매핑 전략은 여러가지를 섞어 쓸 수 있고, 섞어 쓸 때 더 좋은 효과가 나타나는 경우가 많다.

어떠한 매핑 전략도 모든 계층에 동일하게 적용되어야 하는 규칙일 필요는 없다.

 

완전 매핑 전략의 장점

여러 유즈케이스의 요구사항을 하나의 전용 모델에서 다루는 것 보다 구현하고 유지보수하기 훨씬 쉽다.

 

완전 매핑 전략의 단점

한 계층의 전용 모델을 다른 여러개의 커맨드로 매핑하기 위해 더 많은 코드와 매핑 작업이 필요하다.


4. 단방향 매핑 전략

이 전략에서는 모든 계층이 전용 모델을 가지되, 각 모델들이 동일한 인터페이스를 구현한다.

이 인터페이스는 도메인 모델에서 필요한 필드에 대한 getter 메서드를 제공하여 도메인 모델의 상태를 캡슐화한다.

인터페이스를 통해 도메인 모델은 풍부한 행동을 구현할 수 있고, 다른 계층에서 이 행동에 접근할 수 있다.

또한 도메인 모델의 정보를 매핑 없이 다른 계층으로 전달할 수 있다.

이 전략에서 바깥 계층은 인터페이스를 이용할지, 전용 모델을 도메인 모델로 매핑할지 결정할 수 있다.

단 도메인 모델로 매핑하는 것은 factory 라는 DDD 개념과 잘 어울린다.

이 전략은 계층간 모델이 동일하지는 않지만, 비슷할 때 가장 효과적이다.

 

단방향 매핑 전략의 장점

이 전략에서 매핑 책임은 아주 명확하다.

한 계층이 다른 계층으로부터 객체를 받으면 해당 계층에서 이용할 수 있는 모델로 매핑하면 된다.

따라서 각 계층은 한 방향으로만 매핑을 수행하게 된다.

 

단방향 매핑 전략의 단점

매핑 개념이 모델 뿐만 아니라 인터페이스까지 퍼져 있기 때문에 이 전략은 다른 전략에 비해 개념적으로 어렵다.


5. 매핑 전략 선택 방법

언제 어떤 매핑 전략을 사용하는게 좋은지는 '그때 그때 다르다'.

즉 하나의 전략을 전체 코드에 대한 전역 규칙으로 정의해서는 안된다.

소프트웨어는 시간이 지나며 변화를 거듭하기 때문에, 고정된 매핑 전략으로 계속 유지하기 보다는

빠르게 코드를 짤 수 있는 간단한 전략으로 시작해서 계층 간 결합을 떼어내는 데 도움이 되는 복잡한 전략으로 갈아타는 것도 괜찮은 방법이다.

어떤 상황에서 어떤 매핑 전략을 가장 먼저 택해야 하는가에 대한 가이드라인을 팀 내에서 하나 정해두는 것도 좋다.


6. 정리

상황에 따라 다른 매핑 전략을 선택하는 것은 모든 상황에 같은 매핑 전략을 사용하는 것보다 분명 더 어렵고 더 많은 커뮤니케이션을 필요로 하겠지만 매핑 가이드라인이 있는 한, 코드가 정확히 해야 하는 일만 수행하면서도 더 유지보수하기 쉬운 코드를 작성할 수 있을 것이다.

 

이번 장에서는 육각형 아키텍처 스타일에서 유즈케이스를 구현하는 방법을 알아보자.

이를 위해 한 계좌에서 다른 계좌로 돈을 송금하는 유즈케이스를 구현해 볼 것이다.

먼저 도메인 엔티티를 만드는 방법을 살펴본 다음, 해당 도메인 엔티티를 중심으로 유즈케이스를 구현해보자.

이 때 도메인은 대상, 유즈케이스는 대상에 가해지는 행위 정도로 생각하면 이해하기 쉽다.

1. 도메인 구현하기

먼저 입출금이 가능한 Account 엔티티를 만들자.

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;
    }

    @Value
    public static class AccountId {
        private Long value;
    }
}

Account 엔티티에 대한 모든 입금과 출금은 Activity 엔티티를 사용하고 있는데,

Account 엔티티가 도메인 이라면 Activity 엔티티는 유즈케이스 라고 할 수 있다.

ActivityWindow 란 무엇인가? 한 계좌에 요청된 모든 행위를 메모리에 한꺼번에 올려두는 것은 성능 저하의 원인이 될 수 있기 때문에,

Account 엔티티는 ActivityWindow 객체를 둠으로써 특정 기간동안의 행위만을 보유하고 있을 수 있다.

그리고 외부에서 Account 엔티티에 요청이 들어오면 withdraw(삭제), deposit(추가) 메소드를 이용해

ActivityWindow 에 새로운 행위를 더하거나 과거의 행위를 제거할 수 있다.

Account 엔티티는 도메인 모델이기 때문에 행위를 구현하지 않았다.

단지 관련 속성(계좌번호, 잔액 등)만 포함하고, 행위는 모두 Activity 엔티티로 구현해 해당 엔티티를 가져다가 사용하기만 한다.

✅  풍부한 도메인 모델 vs 빈약한 도메인 모델

도메인 모델을 어떻게 작성해야 하는지에 대한 지침은 정해져 있지 않다.

그래서 책에서는 자주 사용되는 풍부한 도메인 모델과 빈약한 도메인 모델 두가지 유형을 소개한다.

풍부한 도메인 모델은 도메인 엔티티에 가능한 한 많은 도메인 로직을 구현하는 형태이다.

이 경우 유즈케이스는 도메인의 모델의 진입점으로 동작하며 많은 비즈니스 규칙이 유즈케이스 구현체 대신 엔티티에 위치하게 된다.

빈약한 도메인 모델은 이와 반대로 엔티티에는 상태 필드와 getter, setter 메서드만 포함하고 다른 로직은 가지지 않는다.

그 대신 로직은 유즈케이스 클래스에서 구현된다.


2. 유즈케이스 구현하기

1. 입력을 받는다.
2. 비즈니스 규칙을 검증한다. (이는 도메인 엔티티와 공유하는 책임이다)
3. 모델 상태를 조작한다. (조작 후 영속성 계층에 전달해 저장한다)
4. 출력을 반환한다. (조작 및 저장 결과 반환한다)

위는 유즈케이스의 역할이다. 이를 바탕으로 송금 유즈케이스를 구현해보자.

넓은 서비스 문제를 피하기 위해 모든 유즈케이스를 한 서비스 클래스에 넣지 말고, 각 유즈케이스별를 각각의 분리된 서비스 클래스로 만들자.

@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를 호출한다.

 

✅  입력 유효성 검증

입력 유효성 검증은 유즈케이스의 책임이 아니다. 이는 입력 모델(DTO)에서 다루는 것이 바람직하다.

현 유즈케이스에서 입력 모델은 SendMoneyCommand 이다.

이 입력 모델의 생성자에 입력 유효성을 검증하는 로직을 직접 작성할 수도 있지만,

자바의 Bean Validation API 표준 라이브러리를 사용하면 훨씬 간편하게 검증 작업을 처리할 수 있다.

@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {

    @NotNull
    private final AccountId sourceAccountId;

    @NotNull
    private final AccountId targetAccountId;

    @NotNull
    private final Money money;

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

기본적으로 제공되는 유효성 검증 도구가 부족하다면 직접 구현하는 것도 가능하다.

package shared;

public abstract class SelfValidating<T> {

  private Validator validator;

  public SelfValidating() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
  }

  protected void validateSelf() {
    Set<ConstraintViolation<T>> violations = validator.validate((T) this);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }
}

 

✅  생성자 활용하기

SendMoneyCommand 클래스는 생성자에서 많은 작업을 처리하고 있다.

혹시 Builder 패턴을 활용하면 더 깨끗한 코드를 작성할 수 있지 않을까?

생성자를 private 으로 만들고 빌더의 build() 메서드 내부에 생성자 호출을 숨길 수 있다.

그러면 파라미터가 여러개인 생성자를 호출하는 대신 다음과 같이 객체를 만들 수 있다.

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

하지만 빌더를 사용하는 경우, 이 빌더를 호출하는 코드에서 필드가 누락되었을 경우 컴파일 타임에 이를 캐치할 수 없다.

따라서 웬만하면 생성자 패턴을 이용하는 것을 권장한다. (요즘은 IDE가 좋아져서 생성자 패턴도 나름 깔끔하다)

 

✅  유즈케이스의 입력 모델

서로 다른 유즈케이스 간에 동일한 입력 모델(DTO)을 사용해도 될까?

얼핏 생각해보면 게시글을 업데이트 하는 DTO와 게시글을 생성하는 DTO가 동일해도 큰 문제는 없을 것 같다.

하지만 이렇게 구현하는 경우, 나중에 게시글 업데이트 부분에서만 기능이 추가되는 경우, 공통으로 사용하던 DTO를 수정하게 되면

게시글 생성 부분에서는 문제가 발생할 수 있다.

또한 서로 다른 유즈케이스는 서로 다른 유효성 검증 로직을 필요로 할 수 있으므로,

유즈케이스의 입력 모델은 각각 분리하여 작성하도록 하자.

물론 각 유즈케이스마다 해당하는 입력 모델을 만들어줘야 하기 때문에 비용이 조금 더 많이 들 수 있다.

✅  비즈니스 규칙 검증

입력 유효성 검증은 유즈케이스의 책임이 아니지만, 비즈니스 규칙 검증은 분명히 유즈케이스의 책임이다.

그런데 언제 입력 유효성을 검증하고 언제 비즈니스 규칙을 검증해야 할까? 이 두 상황은 아래와 같이 구분하면 좋다.

도메인 모델의 현재 상태에 접근해야 하는가? : 비즈니스 규칙 검증
도메인 모델의 현재 상태에 접근할 필요가 없는가? : 입력 유효성 검증

송금 유즈케이스로 예시를 들면 "출금 계좌는 초과 출금되어서는 안된다" 라는 규칙은 비즈니스 규칙 검증에 해당하고,

"송금되는 금액은 0보다 커야 한다" 라는 규칙은 입력 유효성 검증에 해당한다.

이렇게 구분법을 명확히 해 두어야 나중에 특정 유효성 검증 로직을 어느 위치에 생성할지 결정하거나 어디에 구현해 두었는지 찾기 쉽다.

그럼 비즈니스 규칙 검증은 어떻게 구현할까? 가장 좋은 방법은 도메인 엔티티 안에 구현하는 것이다.

이렇게 하면 규칙이 대상과 함께 위치하기 때문에 위치를 정하는 것도 쉽고 추론하기도 쉽다.

package buckpal.domain;

public class Account {

  // ...
  
  public boolean withdraw(Money money, AccountId targetAccountId) {
    if (!mayWithdraw(money)) {
      return false;
    }
    // ...
  }
}

만약 이 방법이 여의치 않다면 유즈케이스 코드에서 도메인 엔티티를 사용하기 전 부분에 구현해도 된다.

package buckpal.application.service;

@RequireArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

  // ...
  
  @Override
  public boolean sendMoney(SendMoneyCommand command) {
    requireAccountExists(command.getSourceAccountId());
    requireAccountExists(command.getTargetAccountId());
    // ...
  }
}

하지만 이 코드는 단순히 출금 계좌와 입금 계좌가 데이터베이스에 존재하는지의 여부만 확인하는 코드이다.

즉 도메인 모델을 로드해서 상태를 검증할 필요는 없는 코드이다.

만약 도메인 모델의 로드가 필요하다면 유즈케이스 코드가 아닌 도메인 엔티티 내에 비즈니스 규칙을 구현하도록 하자.

어차피 도메인 모델을 가져와야 한다면, 굳이 규칙의 대상과 거리가 먼 유즈케이스 코드에 규칙을 작성할 필요는 없으니까!

 

✅  유즈케이스의 출력 모델

앞에서는 유즈케이스의 입력 모델을 살펴보았다. 그럼 출력 모델은 어떨까?

입력과 비슷하게 출력도 가능하면 각 유즈케이스에 맞게 구체적일수록 좋고, 최소한의 정보만 반환하는 것이 좋다.

또한 유즈케이스 간에 동일한 출력 모델을 공유하는 것은 유즈케이스간의 결합도를 높이는 행위이므로 지양하자.

단일 책임 원칙에 따라 모델을 분리해서 유지/관리하자. 같은 이유로 도메인 엔티티를 출력 모델로 사용하는것도 지양하자.

✅  읽기 전용 유즈케이스

아무런 연산이 필요 없는 읽기 전용 유즈케이스는 어떨까? 구현할 필요가 있을까?

놀랍게도 이 책에서는 쿼리를 위한 전용 포트를 만들고, 이를 쿼리 서비스로 구현하라고 제안하고 있다.

package buckpal.application.service;

@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {

    private final LoadAccountPort loadAccountPort;

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

GetAccountBalanceQuery 라는 인커밍 포트를 구현했고,

데이터베이스로부터 데이터를 로드하기 위해 LoadAccount라는 아웃고잉 포트를 호출하는 코드이다.

이런 식으로 읽기 전용 쿼리를 구현하면 다른 유즈케이스와 코드 상에서 명확하게 구분된다는 장점이 있다.


3. 정리

이번 장에서는 클린 아키텍쳐를 만들기 위해 유즈케이스를 어떻게 구현하면 좋은지 알아보았다.

핵심은, 유즈케이스와 각 유즈케이스 관련 모델은 공유하는 것 보다 독립적으로 구현하는게 좋다는 것이다.

그래야 각 유즈케이스의 역할을 명확히 이해할 수 있고, 장기적을 유지보수 하기도 쉬우며, 여러명의 개발자가 동시에 작업할 수 있다.

단점은, 작성해야 하는 코드의 양이 많아진다.