TroubleShooting & Study/Architecture & Design Pattern

[Domain Model Pattern] 도메인 모델 패턴에서 테스트코드 작성에 대한 고찰

DH_0518 2025. 7. 10. 23:11

최근 'DDD'와 '도메인 모델 패턴'에 관심을 보이신 스터디원께 이런 질문을 받았다

"DDD를 적용하면 비즈니스 로직이 도메인 모델에 위치하는 거라 이해했는데, 그렇다면 테스트코드는 어떤 방식으로 작성하나요? 그냥 하면 되는 건가요?"

 

최근에는 테스트코드를 작성한 적도 잘 없었고, 있더라도 그냥 기계적으로 작성하다 보니 한 번쯤은 정리해 보면 좋을 것 같아서 '도메인 모델 패턴에서의 테스트코드'에 대해 생각해 보게 됐다
 

작성하고보니 'DDD'나 '도메인 모델 패턴'에 국한된 게 아니라, 어떤 형태로 프로젝트를 구성하든 테스트코드를 작성할 때 고민해 보면 좋은 것이라 생각된다

어쨌든 오늘은 이 질문을 바탕으로, 테스트코드에 대해 내가 어떤 방식으로 접근하고 있는지를 이야기해 보겠다

 

 

 

 

 

 

 

첫 테스트코드 작성

 

 

 

 처음으로 테스트코드를 작성하던 시절, 내 프로젝트에서 model이라 할 수 있는 건 Entity만을 사용하고 있었다. 즉, Domain Model과 Persistence Model이 분리되어있지 않았다는 의미이다. 그러다 보니 모든 테스트가 DB에 의존하게 됐고, 통합테스트를 할 수밖에 없었다. 이건 곧 다음과 같은 문제들로 이어졌다

  1. 모든 컨텍스트를 로드해야 하니까 실행시간이 오래 걸린다 (@SpringBootTest)
  2. 기존 코드에 @Transactional이 걸려있는지 확인하고 다 적용시켜줘야 한다
  3. 트랜잭션을 쓸 수 없는 경우에는 테스트 데이터 정리를 직접 해줘야 한다
  4. 정작 테스트하고 싶은 비즈니스 로직보다, 테스트 환경 설정에 더 많은 시간을 쏟게 된다

결국 이런 문제들로 인해 테스트코드가 복잡하고, 느리고, 번거로워진다
(그 시절에 관한 더 자세한 글은 다음을 참고해 주길 바란다. https://kdh0518.tistory.com/61)

 

 

 

도메인 모델 패턴 도입

이 문제들을 해결하기 위해 나는 DB와의 의존성을 분리하고, 작고 가벼운 단위테스트를 작성하기 위해 도메인 모델 패턴을 도입했다. 핵심은 다음과 같다

 

"도메인에 위치한 비즈니스 로직만을 테스트하는, '순수한' 단위테스트를 작성한다."

 

 

간단한 예시를 보자

// 도메인 모델 패턴 이전
@Entity
@Getter
public class Order {
    @Id
    private Long id;
    private OrderStatus status;
    private LocalDateTime createdAt;
}

@Service
public class OrderService {

    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        // 서비스의 비즈니스 로직
        if (order.getStatus() == OrderStatus.SHIPPED) {
            throw new IllegalStateException("배송된 주문은 취소할 수 없습니다");
        }
        if (order.getCreatedAt().isBefore(LocalDateTime.now().minusHours(1))) {
            throw new IllegalStateException("1시간 이후에는 취소할 수 없습니다");
        }
        order.setStatus(OrderStatus.CANCELLED);
    }
    
}

 

이렇게 되면 OrderService를 테스트하기 위해 DB가 필요하다. 위에서 언급한 모든 문제가 발생한다는 것이다.

 

하지만 도메인 모델 패턴을 적용하면, 비대한 서비스를 줄이고 풍부한 도메인 모델을 만들 수 있다.

// 도메인 모델 패턴 적용 후
public class Order {
    private Long id;
    private OrderStatus status;
    private LocalDateTime createdAt;
    
    public void cancel() {
        validateCancellation();
        this.status = OrderStatus.CANCELLED;
    }
    
    private void validateCancellation() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("배송된 주문은 취소할 수 없습니다");
        }
        if (this.createdAt.isBefore(LocalDateTime.now().minusHours(1))) {
            throw new IllegalStateException("1시간 이후에는 취소할 수 없습니다");
        }
    }
}


@Service
public class OrderService {

    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.cancel(); // 서비스에서는 단순히 호출만
        orderRepository.save(order);
    }
    
}

 

 

이제 비즈니스 로직은 Order 도메인 모델에 위치하고, 이 단순한 도메인 로직의 테스트는 DB 의존성을 생각할 필요가 없으므로 정말 간단하게 작성할 수 있다.

@Test
void 주문_취소_성공() {
    // given
    Order order = Order.create(...);
    // when
    order.cancel();
    // then
    assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
}

 

 

 

그래서 테스트 코드를 어떻게 작성하라고?

 서론이 길었는데,, 그렇다면 도대체 테스트 코드를 어떻게 작성하라는 말일까?

 

지금까지 도메인 모델 패턴을 적용하면서 알게 된 사실은, 서비스는 도메인 로직을 호출하는 역할만을 하고 있다는 것이다.

 

즉, 도메인에 위치한 '비즈니스 로직'만을 테스트하는 아주 작고 가벼운 단위테스트만을 작성하고 검증한다면, 굳이 '호출'하는 역할만을 하는 서비스 로직은 테스트할 필요가 없다는 것이다.

 

도메인 로직은 외부 의존성도 없고 순수 자바 코드이기 때문에 빠르고 간단하게 테스트할 수 있어서, 앞에서 언급했던 문제점들은 자연스럽게 해결된다

  • 실행시간? → 수십 ms 안에 끝남
  • 트랜잭션 관리? → 필요 없음
  • 롤백? → 아예 DB를 안 쓰니 신경 꺼도 됨
  • 부가적인 설정? → 안 해도 됨

테스트는 결국 핵심적인 비즈니스 로직만에 집중할 수 있게 된다.

 

 

 

 

 

 

 

 

 

 

그럼 진짜 서비스 테스트는 안 해도 되는 걸까?

 

 

 

지금까지의 글을 잘 따라왔다면 한 가지 오해가 생길 수도 있다.

 

"그렇다면 서비스 로직은 테스트할 필요 없겠네?"

 

그건 절대절대절대 아니다!!!!

 

 

이전에 서비스 로직을 테스트할 필요가 없다는 건 도메인 로직을 강조하기 위한 조금 과장된 표현이었고, 테스트코드 작성에서 가장 중요하고 본질적인 건 다음 질문이다.

 

 

"정말 이 코드가 테스트가 필요한가?"

 

 

도메인 모델 패턴을 도입했음에도 서비스 테스트가 필요한 경우는 분명 존재한다. 예외 상황을 정리해 보자.

 

 

서비스 테스트가 필요한 경우

1. 도메인 객체들 간의 조합이 필요한 경우

 예를 들어 post와 comment 도메인 모델이 존재하는데, post의 설정에 따라 comment에 recomment를 달 수 있는지 여부를 판단해야 하는 경우를 생각해 보자

@Service
public class CommentService {
    public void createRecomment(Long postId, Long commentId, String content) {
        Post post = postRepository.findById(postId);
        Comment comment = commentRepository.findById(commentId);
        
        // 두 도메인의 협력이 필요한 비즈니스 로직
        if (!post.allowsRecomments()) {
            throw new IllegalStateException("대댓글을 허용하지 않는 게시글입니다");
        }
        if (post.getAuthorId().equals(comment.getAuthorId())) {
            throw new IllegalStateException("자신의 댓글에는 대댓글을 달 수 없습니다");
        }
        
        comment.addRecomment(content);
    }
}

 

이건 각각의 도메인 모델에서 처리할 수 없기 때문에(post의 authorId와 comment의 authorId를 비교해야 함), 두 도메인의 협력이 필요하다. 따라서 어쩔 수 없이 서비스에 로직에 비즈니스 로직이 들어가게 되고, 이런 경우는 서비스 로직도 테스트를 해주어야만 한다.

 

(** 여담으로, 이런 케이스에서는 Application Service와 Domain Service를 구분해 주면 베스트다. Application Service는 호출 & 조합만을 담당하고, Domain Service에서 복잡한 도메인 로직을 처리한다면, 흐름을 담당하는 orchestration(오케스트레이션)과 우리가 집중해야 하는 비즈니스 로직을 분리해서 바라볼 수 있기 때문이다)

 

 

2. 외부 라이브러리 or API를 사용하는 경우

 예를 들어 서비스에서 Redis를 사용하거나, 외부 API를 호출하는 로직이 있다면?

도메인은 외부 라이브러리나 api에 의존할 수 없으므로, 결국 서비스에서 처리를 해야만 한다. 이런 경우에는 전체적인 비즈니스 플로우가 제대로 동작하되 단위테스트로 확인하려면 Mocking을 통해서 테스트를 해야만 한다.

 

 

물론 다음처럼 의존성 역전을 통해 도메인의 순수성을 유지하면서도 외부 라이브러리를 사용할 수도 있지만, 보통 이렇게 작성하기 힘든 경우가 많으므로 Mokcing을 적절하게 활용해서 서비스 로직을 테스트해 주자.

// 인터페이스 정의
public interface NotificationSender {
    void send(String message);
}

// 도메인에서 사용
public class User {
    public void updateProfile(String newName, NotificationSender sender) {
        this.name = newName;
        sender.send("프로필이 업데이트되었습니다"); // 여전히 순수함
    }
}

 

 

 

3. 복잡한 플로우를 제어하는 경우

 단순히 도메인 로직을 호출하는 것 같더라도, 호출 순서에 따라 플로우가 크게 달라지는 경우를 생각해 보자. 예를 들어 하나의 서비스 메서드에서 10개가 넘는 도메인 로직을 순서대로 호출한다고 해보자.

@Service
public class OrderProcessingService {
    public void processComplexOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        
        // 복잡한 비즈니스 플로우
        order.validate();
        order.calculatePrice();
        order.applyDiscount();
        order.checkInventory();
        order.reserveItems();
        order.processPayment();
        order.updateDeliveryInfo();
        order.sendNotification();
        order.updateOrderHistory();
        order.generateInvoice();
        // ... 더 많은 로직들
    }
}

 

이건 단순 호출 이상의 의미가 있다. 개발자의 의도가 깃든 순서와 조건들이 있을 것이고, 이 플로우 자체를 테스트해야만 한다. 즉, 비즈니스 로직이 없더라도 오케스트레이션 자체가 중요해지기 때문에 테스트가 필요하다는 것이다.

 

 

 

 

 

 

 

 

결론

 

 

 

처음 질문으로 돌아가자.

"그냥 하면 되는 건가?"에 대한 답변은, '그냥 하면 된다'가 맞다. 단지, '어떤 것을' 테스트할지 판단해서 테스트하라는 것이다.

 

우리 모두 테스트 커버리지 100%를 목표로 억지로 테스트를 작성하지 말고, 다음을 꼭 생각하면서 테스트 코드를 작성해 보자

 

 

"정말로 이 코드가 테스트가 필요할까?"

 

 

 

 

 

 

 

 

 

 

 

** 블로그에 작성된 글에는 잘못된 정보가 있을 수 있습니다. 피드백은 언제나 환영합니다.