Weekly Paper

Weekly Paper #10

승주우에요 2026. 4. 1. 09:38

1. 계층별 입력값 검증 전략

핵심 개념

애플리케이션은 일반적으로 세 계층으로 나뉜다.

Client → Presentation Layer (Controller) → Service Layer → Domain/DB Layer

각 계층이 다른 이유로, 다른 관심사를 검증한다는 점이 핵심이다.
이를 혼동하면 중복 검증이 생기거나, 반대로 특정 계층에서 검증이 누락되는 문제가 생긴다.


계층별 책임 분리

Presentation Layer (Controller/DTO)

관심사: "이 데이터가 우리 시스템에 들어올 수 있는 형태인가?" (형식적 유효성)

검증 대상:

  • null / 빈 값
  • 이메일, 날짜 등 형식(Format) 오류
  • 기본적인 범위 위반 (수량 최솟값 등)
public class CreateOrderRequest {
    @NotBlank
    @Pattern(regexp = "^[A-Z]{3}\\d{6}$")
    private String productId;

    @NotNull
    @Min(1) @Max(999)
    private Integer quantity;

    @NotBlank @Email
    private String email;
}

이 계층에서 "재고가 있는가?", "구매 권한이 있는가?" 같은 비즈니스 규칙은 검증하지 않는다.


Service Layer

관심사: "이 요청이 비즈니스 맥락에서 의미가 있는가?" (비즈니스 유효성)

검증 대상:

  • DB 조회가 필요한 존재 여부 확인 (상품, 사용자 등)
  • 재고 확인, 권한 검사 같은 비즈니스 규칙
@Service
public class OrderService {
    public Order createOrder(String productId, int quantity, String userId) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        if (product.getStock() < quantity) {
            throw new InsufficientStockException(product, quantity);
        }

        // Controller가 보장한 형식 검증은 반복하지 않음
        return orderRepository.save(new Order(...));
    }
}

Controller가 이미 검증한 null 체크, 형식 검사를 Service에서 반복하면 중복 검증이다.


Domain Layer (Entity / Value Object)

관심사: "이 객체가 항상 유효한 상태를 유지하는가?" (불변식, Invariant)

도메인 객체는 누가 생성하든 항상 유효해야 한다.
테스트 코드가 Service를 우회해 직접 생성하거나, 다른 서비스에서 재사용될 수 있기 때문이다.

public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new InvalidMoneyException("금액은 0 이상이어야 합니다");
        }
        if (currency == null) {
            throw new InvalidMoneyException("통화는 필수입니다");
        }
        this.amount = amount;
        this.currency = currency;
    }
}

중복 검증을 피하면서 안정성 확보하기

핵심 원칙: 각 계층은 자신의 경계에서만 검증하고, 상위 계층을 신뢰한다.

Controller → 형식 검증 → Service를 신뢰
Service    → 비즈니스 규칙 → Domain을 신뢰
Domain     → 불변식 → 절대 타협하지 않음

나쁜 예 (중복 검증):

public Order createOrder(String productId, int quantity) {
    if (productId == null || productId.isBlank()) { // Controller에서 이미 했음
        throw new IllegalArgumentException("...");
    }
    if (quantity < 1) { // Controller에서 이미 했음
        throw new IllegalArgumentException("...");
    }
    // 비즈니스 로직...
}

좋은 예 (책임 분리):

public Order createOrder(String productId, int quantity) {
    // Service는 자신의 관심사인 비즈니스 규칙만 검증
    Product product = productRepository.findById(productId)
        .orElseThrow(() -> new ProductNotFoundException(productId));
    // 비즈니스 로직...
}

트레이드오프

신뢰 vs. 방어적 프로그래밍

계층 간 신뢰 (중복 제거) 방어적 프로그래밍 (중복 허용)
장점 코드 간결, 유지보수 용이 진입점이 여러 개일 때 안전
단점 내부 직접 호출 시 위험 검증 로직 변경 시 여러 곳 수정

Service가 오직 하나의 Controller에서만 호출된다면 신뢰가 합리적이다.
반면 스케줄러, 이벤트 핸들러, 다른 서비스 등 여러 진입점에서 호출된다면 Service에도 자체 검증이 필요하다.

// 여러 진입점 → Service 자체 방어 필요
public Order createOrderFromExternalAPI(OrderCommand command) {
    validateCommand(command); // 직접 검증
}

// 내부 도메인 이벤트만 → 신뢰 가능
public void processConfirmedOrder(ConfirmedOrderEvent event) {
    // event는 이미 검증된 도메인 이벤트
}

성능 vs. 안전성

DB를 조회하는 검증(재고 확인, 이메일 중복)은 비용이 크다.
Controller에서 미리 조회하면 DB 왕복이 2번 발생하고, Service에서만 하면 불필요한 요청이 Controller까지 도달한다.
트래픽과 비즈니스 중요도에 따라 결정해야 한다. 정답은 없다.


2. Mockito: Mock, Stub, Spy

개념 정리

세 가지 모두 테스트 대역(Test Double)이지만 목적이 다르다.

Mock Stub Spy
관심사 호출 여부 / 횟수 반환값 실제 동작 + 부분 제어
구현 완전한 가짜 완전한 가짜 실제 객체 래핑
주요 검증 verify() 결과값 assert 둘 다 가능
사용처 이메일, 알림 등 부수 효과 DB, 외부 API 시나리오 레거시 코드, 부분 격리

Mock

"이 메서드가 호출되었는가?"를 검증하고 싶을 때 사용한다.
반환값보다 행동(Behavior) 자체가 관심사인 경우다.

@Test
void 주문_생성시_이메일_발송을_호출해야_한다() {
    // given
    EmailService emailService = mock(EmailService.class);
    OrderService orderService = new OrderService(emailService);

    // when
    orderService.createOrder(new OrderRequest("product-1", 2, "user@test.com"));

    // then - 행동 검증
    verify(emailService).sendOrderConfirmation("user@test.com");
    verify(emailService, times(1)).sendOrderConfirmation(anyString());
    verify(emailService, never()).sendCancellationEmail(anyString());
}

사용 상황: 이메일 발송, 알림 전송, 외부 API 호출처럼 "실행됐는지"가 중요하고 반환값은 없거나 중요하지 않을 때.


Stub

"특정 조건에서 이 값을 반환해라"를 설정하고 싶을 때 사용한다.
반환값이 관심사이고, 호출 여부 자체는 관심이 없다.

@Test
void 재고_부족시_주문이_실패해야_한다() {
    // given
    ProductRepository productRepository = mock(ProductRepository.class);

    // Stub 설정 - 특정 입력에 대한 반환값 지정
    Product outOfStockProduct = new Product("product-1", "노트북", 0); // 재고 0
    when(productRepository.findById("product-1"))
        .thenReturn(Optional.of(outOfStockProduct));

    OrderService orderService = new OrderService(productRepository);

    // when & then
    assertThatThrownBy(() ->
        orderService.createOrder("product-1", 1, "user-1")
    ).isInstanceOf(InsufficientStockException.class);
}

@Test
void 상품_없을시_예외가_발생해야_한다() {
    ProductRepository productRepository = mock(ProductRepository.class);
    when(productRepository.findById(anyString()))
        .thenReturn(Optional.empty());

    OrderService orderService = new OrderService(productRepository);

    assertThatThrownBy(() ->
        orderService.createOrder("invalid-product", 1, "user-1")
    ).isInstanceOf(ProductNotFoundException.class);
}

📌 Mockito에서 Mock과 Stub은 같은 mock() 객체로 만든다. when().thenReturn()을 쓰면 Stub, verify()를 쓰면 Mock 역할이다. 개념적 구분이다.

사용 상황: 재고 없음, 사용자 없음, 외부 API 실패처럼 다양한 시나리오를 시뮬레이션할 때.


Spy

실제 객체를 감싸서, 일부 메서드만 가짜로 대체한다.
나머지는 실제 구현이 호출된다.
전체 로직은 실제 코드 테스트하고 싶은데, 그 중 외부와 연결된 부분만 통제하고 싶을 때 Spy를 사용한다!

@Test
void 할인_적용_후_총액이_올바르게_계산되어야_한다() {
    // given - 실제 객체를 Spy로 감쌈
    PricingService realPricingService = new PricingService();
    PricingService spyPricingService = spy(realPricingService);

    // 외부 API 호출 부분만 Stub으로 대체
    doReturn(0.1).when(spyPricingService).fetchDiscountRateFromExternalAPI("VIP");

    // when
    BigDecimal totalPrice = spyPricingService.calculateTotal("VIP", new BigDecimal("100000"));

    // then
    // 실제 calculateTotal 로직은 실행되고,
    // 내부의 fetchDiscountRateFromExternalAPI만 Stub 처리됨
    assertThat(totalPrice).isEqualByComparingTo(new BigDecimal("90000"));
    verify(spyPricingService).fetchDiscountRateFromExternalAPI("VIP");
}

레거시 코드에서 특히 유용하다:

@Test
void 레거시_서비스의_특정_메서드만_격리하여_테스트() {
    LegacyOrderService legacyService = spy(new LegacyOrderService());

    // 바꿀 수 없는 레거시 코드에서 외부 의존성만 제어
    doReturn("PROCESSED").when(legacyService).callLegacyPaymentGateway(any());

    // 나머지 레거시 로직은 그대로 실행
    OrderResult result = legacyService.processOrder(testOrder);
    assertThat(result.getStatus()).isEqualTo("COMPLETED");
}

사용 상황: 실제 로직의 대부분은 검증하되, 외부 API·파일 I/O 등 일부만 격리하고 싶을 때. 특히 리팩토링이 어려운 레거시 코드 테스트에 유용하다.


선택 기준 요약

"이 메서드가 실행됐는지 알고 싶다" → Mock

"이 메서드가 특정 값을 반환하게 하고 싶다" → Stub

"실제 로직은 살리되 일부만 제어하고 싶다" → Spy


'Weekly Paper' 카테고리의 다른 글

Weekly Paper #13  (0) 2026.06.09
Weekly Paper #12  (0) 2026.05.11
Weekly Paper #9  (0) 2026.03.23
Weekly Paper #7  (0) 2026.02.27
Weekly Paper #6  (0) 2026.02.09