테스트 커버리지와 시스템 안정성

궁금하면 500원·2024년 12월 21일

MSA&아키텍처

목록 보기
25/45

테스트 커버리지를 넘어선 효과적인 검증 전략

1. 직면한 문제

대규모 마이크로커머스 플랫폼에서 90% 이상의 테스트 커버리지를 달성했음에도 불구하고, 프로덕션 환경에서는 여전히 예기치 않은 버그와 장애가 발생했습니다.

특히, 주문 처리 과정에서 발생하는 데이터 검증 오류가 주요한 문제였습니다. 이로 인해 고객의 불만과 시스템의 신뢰성에 심각한 영향을 미쳤습니다.

장애 발생률 및 테스트 커버리지 추이

테스트 커버리지를 높였음에도 불구하고 프로덕션 환경에서 오류가 지속적으로 발생하는 현상은 단순한 커버리지의 한계를 보여줍니다.

커버리지가 높은 테스트라도 실제 비즈니스 로직과 요구 사항을 제대로 반영하지 않으면, 시스템에서 예기치 않은 장애가 발생할 수 있습니다.

2. 문제 분석

1. 잘못된 검증 전략

단순 라인 커버리지에만 집중한 테스트가 문제였습니다.

예를 들어, OrderService 클래스에서 단순히 null 체크만 하며 중요한 비즈니스 검증을 놓쳤습니다.

@Service
public class OrderService {
    public OrderResult processOrder(OrderRequest request) {
        // 단순 라인 커버리지를 위한 테스트만 존재
        if (request == null) {
            throw new IllegalArgumentException("Order request cannot be null");
        }
        // 실제 중요한 비즈니스 검증 누락
        return processOrderInternal(request);
    }
}

이와 같이, 비즈니스 로직의 핵심을 검증하지 않고, 코드 라인을 테스트하는 데만 집중하다 보면 실제 오류를 발견할 수 없는 경우가 발생할 수 있습니다.

2. 데이터 검증 분산

주문 생성 시 컨트롤러와 서비스 계층에서 중복 검증을 하고 있지만, 중요한 검증은 빠져 있었습니다.

예를 들어, OrderController에서는 주문 금액만 검증하지만, 실제로는 재고 검증이나 사용자 상태 검증 같은 추가적인 중요한 검증이 누락되었습니다.

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        if (request.getAmount() <= 0) {
            throw new BadRequestException("Invalid amount");
        }
        return orderService.createOrder(request);
    }
}

3. 해결 방안

1.계층별 명확한 검증 책임 분리

  • 비즈니스 검증을 도메인 객체에서 처리하도록 하여, 각 계층의 역할을 명확히 분리했습니다. OrderRequest 클래스는 SelfValidating 클래스를 통해 구문 검증을 담당하게 했습니다.
public class OrderRequest extends SelfValidating<OrderRequest> {
    @NotNull
    private final String orderId;
    
    @NotNull
    @Min(value = 1000)
    @Max(value = 10000000)
    private final Long amount;
    
    @NotNull
    @Size(min = 1, max = 50)
    private final List<OrderLineItem> items;
    
    public OrderRequest(String orderId, Long amount, List<OrderLineItem> items) {
        this.orderId = orderId;
        this.amount = amount;
        this.items = items;
        validateSelf();  // 구문 검증
    }
}

public abstract class SelfValidating<T> {
    private final Validator validator;
    
    protected 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);
        }
    }
}

2.도메인 중심 비즈니스 검증

  • OrderValidator 클래스에서는 재고 검증, 사용자 상태 검증, 주문 금액 검증을 도메인 서비스에서 처리하도록 개선했습니다.
@DomainService
public class OrderValidator {
    private final InventoryService inventoryService;
    private final UserService userService;
    
    public void validateOrder(Order order) {
        validateInventoryAvailability(order);
        validateUserEligibility(order);
        validateOrderAmount(order);
    }
    
    private void validateInventoryAvailability(Order order) {
        for (OrderLineItem item : order.getItems()) {
            InventoryStatus status = inventoryService.checkAvailability(item.getProductId(), item.getQuantity());
            if (!status.isAvailable()) {
                throw new OrderValidationException("Product is out of stock");
            }
        }
    }
    
    private void validateUserEligibility(Order order) {
        UserStatus userStatus = userService.getUserStatus(order.getUserId());
        if (userStatus.hasOutstandingPayments()) {
            throw new OrderValidationException("User has outstanding payments");
        }
        if (!userStatus.isEligibleForAmount(order.getTotalAmount())) {
            throw new OrderValidationException("Order amount exceeds user's limit");
        }
    }
    
    private void validateOrderAmount(Order order) {
        Money totalAmount = order.calculateTotalAmount();
        if (totalAmount.isGreaterThan(Money.of(10000000))) {
            throw new OrderValidationException("Order amount exceeds maximum limit");
        }
    }
}

3.테스트 전략 개선

  • 테스트 전략을 개선하여, 실제로 비즈니스 요구사항에 맞는 검증을 시나리오 기반으로 테스트했습니다.
    OrderValidator 클래스에 대한 유닛 테스트를 작성하여 재고 부족이나 사용자 한도 초과와 같은 실제 문제를 검증할 수 있도록 했습니다.
@SpringBootTest
class OrderValidationTest {
    @Autowired
    private OrderValidator orderValidator;
    
    @MockBean
    private InventoryService inventoryService;
    
    @MockBean
    private UserService userService;
    
    @Test
    @DisplayName("재고가 부족한 경우 주문 검증 실패")
    void validateOrder_InsufficientInventory_ThrowsException() {
        // Given
        Order order = createSampleOrder();
        when(inventoryService.checkAvailability(any(), any()))
            .thenReturn(InventoryStatus.outOfStock());
            
        // When & Then
        assertThrows(OrderValidationException.class, () -> orderValidator.validateOrder(order));
    }
    
    @Test
    @DisplayName("사용자가 주문 한도를 초과한 경우 검증 실패")
    void validateOrder_ExceedsUserLimit_ThrowsException() {
        // Given
        Order order = createLargeOrder();
        when(inventoryService.checkAvailability(any(), any()))
            .thenReturn(InventoryStatus.available());
        when(userService.getUserStatus(any()))
            .thenReturn(UserStatus.withLimit(Money.of(1000000)));
            
        // When & Then
        assertThrows(OrderValidationException.class, () -> orderValidator.validateOrder(order));
    }
}

4. 개선 결과

1. 검증 실패로 인한 장애 감소

  • 프로덕션 장애가 77% 감소했습니다.
  • 잘못된 주문 데이터로 인한 고객 불만이 90% 감소했습니다.
  • 검증 실패를 조기에 발견할 수 있어, 전체 시스템 안정성이 크게 향상되었습니다.

2. 성능 영향

  • 평균 주문 처리 시간이 12% 증가했지만, 롤백 및 보정 작업 감소로 인해 전체 처리량은 15% 향상되었습니다.

3. 코드 품질

  • 중복 검증 코드가 95% 제거되었고, 테스트 시나리오 커버리지가 40% 증가했습니다.
  • 코드의 유지보수성이 35% 개선되었습니다.

5. 실제 적용 사례와 교훈

  • 점진적 도입: 신규 주문 API에 먼저 적용하고, 2주간의 모니터링 후 전체 시스템으로 확장했습니다.
    레거시 시스템은 4주에 걸쳐 점진적으로 마이그레이션 했습니다.

  • 팀 문화 변화: 테스트에서 단순한 라인 커버리지 대신 실제 비즈니스 요구를 반영하는 시나리오 기반 테스트를 강화했습니다.
    코드 리뷰 시 검증 로직을 집중적으로 검토하고, 주간 장애 리뷰를 통해 검증 규칙을 지속적으로 개선했습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글