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
728x90

플러그인 스토리지 엔진


스토리지 엔진은 사용자의 데이터를 디스크와 메모리에 저장하고 읽어오는 역할을 담당한다. MMAPv1WiredTiger 등을 스토리지 엔진이라고 하며, 이 엔진들은 사용자의 데이터를 디스크에 영구적으로 기록하거나 다시 읽어와서 메모리에 적재하는 역할을 담당한다.

 

https://osoriandomori.github.io/posts/Real-Mongo-DB/

 

 

몽고디비 서버도 MySQL 서버와 동일하게 다양한 스토리지 엔진을 사용할 수 있도록 스토리지 엔진이 플로그인 형태로 구현되어 있으나 MySQL 서버와 달리 하나의 인스턴스에서 동시에 여러 개의 스토리지 엔진을 사용할 수가 없다.

 

즉 하나의 몽고디비 서버에서 MMAPv1와 WiredTiger 스토리지 엔진을 동시에 사용할 수는 없다. 몽고디비 서버는 쿼리를 분석해서 옵티마이저라고 부르는 컴포넌트가 최적화된 실행 계획을 수립하면 실제 그 실행 계획에 맞게 디스크에서 데이터를 읽어는 역할스토리지 엔진이 하는 것이다.

 

스토리지 엔진 종류

  • MMAPv1: 처음 출시됐을 때 사용되던 스토리지
  • WiredTiger: 몽고디비 3.0부터 도입된 스토리지 엔진
  • In-Memory: WiredTiger 변형, 디스크에 기록하지 않고 메모리에만 보관
  • RocksDB
  • TokuDB

MMAPv1 스토리지 엔진을 제외한 모든 스토리지 엔진은 도큐먼트 수준의 잠금을 지원하기 때문에 대부분 스토리지 엔진의 동시성 처리는 우수하다고 볼 수 있다. 또한 대부분 데이터 파일이나 인덱스의 압축도 지원한다.

 

WiredTiger 스토리지 엔진


WiredTiger 스토리지 엔진은 내부적인 Lock 경합 최소화를 위해서 Hazard-PointerSkip-List와 같은 많은 신기술을 채택하고 있으며, 최신 RDBMS들이 가지고 있는 MVCC 데이터 파일 압축, 암호화 기능 등을 갖추고 있다.

 

내부 동작 방식

 

 

다른 DBMS와 동일하게 B-Tree 구조의 데이터 파일과 서버 크래시로부터 데이터를 복구하기 위한 저널 로그(WAL)를 가지고 있다. 다른 RDBMS의 WA처럼 로테이션되면서 로그 파일의 로그 슬롯이 재활용되는 방식이 아니라 새로운 로그 파일이 계속 생성된다.

그리고 체크포인트 시점 이전의 저널 로그는 더 이상 필요하지 않으므로 체크포인트 이후 시점의 저널 로그만 남기저 이전 저널 로그는 자동으로 삭제한다.

 

https://www.slideshare.net/slideshow/mongodb-wiredtiger-internals/55965180

 

공유 캐시

 

WiredTiger 스토리지 엔진에서 사용자의 쿼리는 공유 캐시를 반드시 거쳐야 한다. 그래서 공유 캐시의 최적화는 몽고디비의 처리 성능에 있어서 매우 중요한 역할을 담당한다. 짧은 시간에 수많은 쿼리를 처리해야 하는 OLTP(On-Line Transaction Processing) 시스템에서는 많은 쿼리들이 공유 캐시에 있는 하나의 데이터 페이지를 동시에 참조하기 위해 경합하는 경우도 많기 때문에 공유 캐시 객체에 대한 잠금 경합이 성능에 많은 영향을 미친다.

 

WiredTiger 엔진은 공유 캐시의 잠금 경합(Mutex Contention)을 최소화하기 위해서 Lock-Free 알고리즘을 사용한다. 해당 알고리즘은 잠금을 사용하지 않는 것을 의미하는 것이 아니라 경합을 최소화하는 알고리즘을 의미한다. 대표적으로 하자드 포인터스킵 리스트 자료 구조를 활용하여 Lock-Free를 구현하고 있다.

 

MVCC(Multi Version Concurrency Control)

 

MVCC는 하나의 도큐먼트에 대해서 여러 개의 버전을 동시에 관리하면서 필요에 따라 적절한 버전을 사용할 수 있게 해주는 기술이다.

WiredTiger 스토리지 엔진은 READ_UNCOMMITTED와 READ_COMMITTED 그리고 SNAPSHOT 격리 수준을 기본 격리 수준을 제공하며, 몽고 디비 서버에 내장된 WiredTiger 스토리지 엔진은 SNAPSHOT 격리 수준을 기본으로 하고 있다.

 

SNAPSHOT 격리 수준은 REPEATABLE_READ와 동일한 격리 수준이다. 그러므로 RDBMS와 비슷하게 검색을 실행하는 커넥션은 자신의 트랜잭션 번호보다 낮은 트랜잭션이 변경한 마지막 데이터만 볼 수 있다.

 

참고

  • 도서 : Real MongoDB
728x90

'mongo' 카테고리의 다른 글

[Real MongoDB] Mongo DB  (0) 2024.10.20
728x90

테스트 더블이란?


제라드 메스자로스(Gerard Meszaros)가 사람들이 테스트를 위해 시스템의 일부를 떼어낼 때 사용하는 Stub, Mock, Fake, Dummy 등 다양한 이름들로 불리고 있던 것을 해결하기 위해 만든 용어가 바로 테스트 더블이다.

즉, 테스트 더블이란 테스트 목적으로 프로덕션 객체를 대체하는 모든 경우를 통칭하는 용어이다.

테스트 더블이란 용어는 영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 비롯되었다고 한다.

 

테스트 더블 종류


일반적으로 크게 5가지의 종류로 Dummy, Fake, Stub, Spy, Mock으로 분류된다.

Dummy

  • 가장 기본적인 테스트 더블이다.
  • 객체가 전달되지만 사용되지 않는 객체다.
    • 보통 매개변수 목록을 채우는 데만 사용된다.
    • 동작하지 않아도 테스트에는 영향을 미치지 않은 테스트 객체.
  • 정말 말 그대로 모조 객체. 객체인 척만 한다.
    • 테스트 핵심 로직과는 상관없는 객체이다.

 

Fake

  • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
  • 실제 동작의 구현을 가지고 있지만, 실제 프로덕션에는 적합하지 않는 객체를 의미한다
예시로는 DAO 혹은 repository의 inmemory 구현이 있다.

 

Stub

  • 스텁은 미리 정의된 데이터를 보관하고 테스트 중에 호출에 응답하는 객체이다.
    • 테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공한다.
  • 쉽게 말해 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태다.

 

Spy

  • 스파이는 일부 기록 기능이 있는 테스트 스텁이다.
    • Stub의 역할을 하면서 호출된 내용에 대한 정보를 기록한다.
    • ex. 이메일 서비스를 목킹할 때 전송된 메시지 수를 기록할 때 사용할 수 있다.
  • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub으로 만들어서 동작을 지정할 수 있다.
    • 실제 객체로도 사용할 수 있고, Stub 객체로도 활용할 수 있으며 필요한 경우 특정 메서드가 제대로 호출되었는지 여부를 확인할 수 있다.
  • 상태를 가질 수 있어 대표적인 상태 검증 테스트 더블이다.
Mockito 프레임워크의 verify() 메서드가 같은 역할을 한다.

 

 

Mock

  • 호출에 대한 기대를 명세하고 내용에 따라 동작하도록 프로그래밍된 객체를 의미한다.
  • 프로덕션 코드를 호출하고 싶지 않거나 의도한 코드가 실행되었는지 쉽게 확인할 방법이 없을 때 사용한다,
  • 따라서 다른 테스트 더블과는 다르게 행위 검증 사용을 추구한다.
Mockito 프레임워크가 대표적인 Mock 프레임워크라 볼 수 있다.

 

 

단위테스트


공통적인 속성

마틴 파울러가 정리한 '유닛테스트'에 대한 다양한 의견 중 공통점은 다음과 같다.

  • there is a notion that unit tests are low-level, focusing on a small part of the software system.
    • 단위 테스트는 소프트웨어 시스템의 작은 부분들에 집중하는 로우 레벨이다.
  • unit tests are usually written these days by the programmers themselves using their regular tools
    • 일반적인 도구를 사용해서 스스로 단위 테스트를 작성한다
  • Thirdly unit tests are expected to be significantly faster than other kinds of tests.
    • 단위 테스트는 다른 테스트 종류들의 테스트에 비해 확실하게 빠르기를 기대한다.

 

단위의 기준 

 

그렇다면 가장 헷갈리는 부분은 바로 단위이다. 무엇이 단위가 되어야 할까? 단위의 범위는 어디까지일까?

 

마틴 파울러는 단위는 상황적이라고 언급한다. 무엇이 단위가 되는 것인지는 팀이나 개인이 (그때그때) 정하는 것이라고 한다.

또한, 이런 것을 정의하는 것은 전혀 중요하지 않는다고 한다. 클래스를 하나의 단위로 취급할수도 있고 클래스 메서드들의 부분 집합을 하나의 단위로 삼을 수도 있다.

 

Sociable & Solitary


따라서 단위 테스트를 말할 때 더 중요한 차이는 단위가 단독(Solitary)으로 진행할지 혹은 협동적(Sociable)으로 진행할지 정의하는 데 있다고 한다. 

 

Mock 객체를 이용하여 고립되어 단독으로 실행하는 것을 지향하는 'Mockist`파와 Mock객체를 사용하지 않고 실제 의존하고 있는 객체를 이용하여 협동적인 테스트를 지향하는 'Classist'파로 나뉜다고 한다.

즉 SUT의 협력 객체를 실제 객체로 사용하는지 Mock 객체로 사용하는지에 따라 구분되어지는 것이다.

 

 

Classist vs Mockist


 

Classist

  • 협력 객체를 실제 객체를 사용한다.
    • 실제 객체를 사용하기 때문에 협력 객체의 상세 구현에 대해서 알 필요가 없다.
  • 행위가 끝난 후 직접적으로 상태를 검증을 한다.
    • 테스트의 안정성은 높아질 수 있다.
  • 비결정적인 외부 요인은 테스트 더블 사용이 가능하다.
  • 테스트와 테스트간의 격리

 

Mockist

  • 협력 객체를 Mock 객체를 사용한다.
    • 하지만 테스트가 협력 객체의 상세 구현을 알아야 한다.
  • 객체 내부의 상태가 아닌 행위를 검증한다.
    • 비교적 테스트의 안정성은 낮아질 수 있다.
  • 테스트가 상세 구현에 의존하는 경향이 생긴다.
  • SUT와 협력 객체간의 격리

 

Inside-Out vs Outside-In


이렇게 격리의 단위에 따라 테스트의 구현 방법이 달라지므로 자연스럽게 TDD의 방향도 달라지게 된다. 

주로 Classist는 Inside out 방법을 사용하고 Mockist는 outside in 방법을 사용한다.

Inside-Out

일반적으로 도메인부터 시작하여 개발을 진행한다. 도메인에 필요한 것은 무엇인지 생각하여 도메인 개체가 필요한 작업을 수행한다.

이후 작업이 완료되면 그 위에 사용자와 맞닿는 영역을 구현한다. 따라서

  • 리팩토링 단계에서 디자인이 도출된다.
  • TDD에서 빠른 피드백이 가능하다.
  • 오버 엔지니어링을 피하기 쉽다.
  • 객체 간의 협력이 이상하거나 public api가 잘못 설계될 수 있다.

 

Outside-In

일반적으로 시스템 외부에 대한 첫 테스트를 작성한다. 요구사항을 만족하기 위해 협력 객체들에 대해 생각한다.

이후 작업이 완료되면 시스템 내부적으로 들어가며 구현한다. 따라서

  • Test Red 단계에서 디자인이 도출된다.
  • 오버 엔지니어링으로 이어질 수 있다.
  • 협력 객체의 public api가 자연스럽게 도출된다.
  • 객체들 간의 구현보다는 행위에 집중할 수 있기 때문에 객체지향적으로 바라보기 쉽다.

 

그래서?


둘 중의 한 가지 방식을 선택하는 개념의 문제가 아니다. 켄트백의 TDD에서는 방향성을 가질 필요가 있다면 아는 것에서 모르는 것으로(known-to-unknown)의 방향이 유용할 수 있다고 말한다.

 

개인적으로는 보통 요구사항을 단위로 전달받아 주로 개발이 진행되므로 인수테스트를 작성하여 전반적인 요구사항의 전반적인 이해를 먼저 진행한 뒤에 도메인부터 구현하는 방법도 유용할 수 있을 것이다.

 

참고

728x90

'test' 카테고리의 다른 글

[Cucumber] Scenario Outline vs Data Table  (1) 2024.11.27
728x90

서론


통합테스트나 인수테스트를 위해 @SpringBootTest를 이용하는 테스트 환경에서 테스트들을 격리하는 방법들 중 주로 널리 알려진 것들에 대해 정리하고 TestExecutionListener 사용하여 격리했던 방법을 공유해보고자 한다.

 

테스트 격리란


테스트 격리란 테스트 순서에 상관없이 독립적으로 실행되며, 결정적으로 동작되는 것을 의미한다.

비결정적인 테스트는 쉽게 말해서 테스트 실행 시 같은 입력값에 대해 항상 같은 출력하지 않는 테스트를 의미한다.

 

따라서 "결정적으로 동작"의 의미란 멱등성과 비슷하다. 같은 입력 값이면 항상 같은 결과를 반환하는 것을 의미한다.

 

@SpringBootTest 테스트 격리 방법


@Transactional

첫 번째로 트랜잭션 어노테이션이다. 많이 알려진 내용으로 해당 어노테이션을 테스트 코드에 사용하면 테스트가 종료되고 자동으로 롤백하는 기능을 이용하는 것이다.

test transactions will be automatically rolled back after completion of the test.

 

하지만 해당 방법은 API 접점에서 검증하는 E2E 테스트를 인수테스트의 의도로 인수테스트 환경을 구축했다면 @Transactional 어노테이션으로는 테스트 격리를 하기 힘들 수도 있다.

 

인수테스트는 보통 블랙박스 성격의 테스트이므로 WebTestClient 혹은 RestAssured을 이용하는 경우가 많은데 그러려면 @SpringBootTest 어노테이션을 RANDOM_PORT나 DEFINED_PORT를 이용하여 실제 웹 한경을 구성하기 때문이다

따라서 포트 번호를 따로 주어 WebEnvironment를 구성하는 경우 Http client와 테스트 서버는 별도의 스레드에서 수행되기 때문에 자동 롤백이 이루어지지 않는다.

 

@DirtiesContext

효과적인 테스트 수행을 위해 스프링에서는 context caching 기능을 지원한다. 해당 어노테이션은 테스트와 관련된 ApplicationContext가 Dirty하기 때문에 Context Cache에서 종료되고 삭제되어야 함을 나타내는 Test Annotation이다.

 

@DirtiesContext 어노테이션을 통해 테스트를 수행하기 전, 수행한 이후, 그리고 테스트의 각 테스트 케이스마다 수행하기 전, 수행한 이후에 context를 다시 생성하도록 지시하여 테스트를 격리할 수 있다.

 

하지만 매 테스트마다 Application Context를 매번 생성해야 하기 때문에 테스트 속도가 현저히 느려진다.

 

테스트 코드상으로 매번 삭제

테스트에 필요한 데이터를 JUnit 생명주기인 @BeforeEach, @AfterEach를 활용하여 테스트가 시작되기 전이나 후에 데이터들을 삭제하여 테스트 격리를 하는 방식이다. application context를 매번 띄우는 것보다 낮은 비용이므로 속도 측면에서 효율적이라고 볼 수 있다.

 

하지만 이 방식의 단점은 생성해야 할 데이터가 많거나, 연관관계를 모를 경우 제대로 데이터를 삭제하지 못하는 문제가 발생할 수 있다.

또한, 테스트 클래스도 길어지기에 가독성도 안 좋을 수 있다.

 

TRUNCATE를 통한 테이블 초기화


TRUNCATE (DDL)는 DELETE (DML)와 다르게 행마다 락을 걸지 않고, 트랜잭션 로그 공간을 적게 사용하므로 초기화하는 속도가 더 빠르다.

테스트 격리를 위해 TRUNCATE 하는 방법은 보통 크게 2가지이다.

  1. @Sql 사용
  2. 코드에서 truncate 쿼리 사용

 

@Sql

@Sql은 Spring Boot에서 제공하는 애노테이션이며, 클래스 테스트가 실행되기 전 @Sql이 가리키는 경로에 있는 SQL문이 먼저 실행되게 된다. 이 SQL 파일 안에 TRUNCATE관련 내용을 넣어두는 방식을 통해 DB 테스트 격리를 할 수 있다.

 

하지만 테이블이 추가/변경/삭제될 때마다 수정이  필요하므로 관리가 필요하다.

 

코드에서 테이블 TRUNCATE 쿼리 사용

JPA 사용 시 EntityManager를 이용하여 혹은 jpa를 사용하지 않는 경우 DataSource를 이용하여 테이블 이름을 조회하여 각 테이블들을 truncate 시켜주는 쿼리를 수행하는 것이다. 해당 방법을 사용하면 테이블 상태에 의존하지 않는 초기화 환경 구축 가능하다.

해당 방법을 사용해서 테스트 격리를 적용해 보자.

 

 

코드에서 테이블 TRUNCATE 쿼리 사용 - TestExecutionListener 이용하기


TestExecutionListener

TestExecutionListener는 스프링에서 제공하는 테스트 실행 주기에서 콜백 메서드를 통해 사용자가 추가 작업을 수행하도록 지원하는 인터페이스이다.

 

사실 위에서 살펴본 transactional test의 기본 롤백, @DirtiesContext, @Sql의 기능들 모두 해당 TestExecutionListener 인터페이스를 상속받은 기본 리스너를 통해 스프링에서 제공하고 있는 것이다.

public interface TestExecutionListener {
	default void beforeTestClass(TestContext testContext) throws Exception {};
	default void prepareTestInstance(TestContext testContext) throws Exception {};
	default void beforeTestMethod(TestContext testContext) throws Exception {};
	default void afterTestMethod(TestContext testContext) throws Exception {};
	default void afterTestClass(TestContext testContext) throws Exception {};
}
  • beforeTestClass()
    • junit의 @BeforeAll와 같다.
    • 모든 테스트를 실행하기 전 단 한 번만 실행/호출
  • prepareTestInstance()
    • junit에서 X
    • 테스트 인스턴스가 준비되었을 때 호출
  • beforeTestMethod()
    • junit의 @BeforeEach
    • 각 테스트 메서드 실행 전에 실행/호출
  • afterTestExecution()
    • junit에서 X
    • 각 테스트 메서드 실행 직후 호출
  • afterTestMethod()
    • junit에서 @AfterEach
    • 각 테스트 메서드 실행 후에 실행/호출
  • afterTestClass()
    • junit의 @AfterAll
    • 모든 테스트를 실행한 후 단 한 번만 실행/호출

 

Custom TestExecutionListener를 구현


sql 파일이 아닌 코드에서 테이블 TRUNCATE를 위해 AbstractTestExecutionListener 이용해 보자.

afterTestMethod() 메서드 오버라이딩을 통해 테스트 메서드가 종료된 후 모든 테이블 truncate 쿼리를 실행한다.

 

 

@TestExecutionListeners 통해 리스너 등록

 

해당 커스텀 리스너를 등록하기 위해서는 @TestExecutionListeners 어노테이션을 통해 등록하면 되는데 기본 제공되는 테스트 리스너들이 필요하다면 MERGE_WITH_DEFAULTS 모드를 사용하도록 하자.

 

그리고 주의할 점은 TestExecutionListeners 들은 AnnotationAwareOrderComparator를 통해 정렬되어 순서대로 핸들링되기 때문에 순서에 주의하자.

 

따라서 AcceptanceTestExecutionListener 클래스에 마지막 순서로 동작하기 위해 Ordered 인터페이스를 구현하도록 추가하고 어노테이션을 붙여 테스트 격리를 진행하였다.

Ordered 구현하여 순서값 지정

 

사용 및 확인


 

이렇게 테스트 메서드 종료시마다 truncate 쿼리가 나가는 것을 확인할 수 있다. 물론 해당 방법이 가장 좋은 방법인 것은 아닐 것이다.

따라서 각각 자신의 상황에서 추구하는 테스트 격리의 정의와 격리를 위한 장치가 필요한지 살펴보며 적용하는 것이 가장 좋은 방법일 것이라고 생각한다.

 

참고


728x90

'spring' 카테고리의 다른 글

[spring data mongo] Query를 Type-safe 하게 작성하기 (작성 중)  (0) 2024.11.19
WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02

+ Recent posts