PromleeBlog
sitemap
aboutMe

posting thumbnail
예외 처리와 검증 로직 테스트 - Spring Boot로 시작하는 TDD 10편
Testing Exception Handling and Validation Logic - TDD with Spring Boot Part 10

📅

🚀

들어가기 전에 🔗

지난 9편까지 우리는 Mockito를 사용하여 의존성을 분리하고 객체 간의 협력을 테스트했습니다.
이제 우리 애플리케이션은 정상적인 요청을 아주 잘 처리합니다.

하지만 사용자가 항상 올바른 데이터만 보낼까요?
상품 가격을 -1000원으로 보내거나, 상품 이름을 비워서 보낸다면 어떻게 해야 할까요?
당연히 서버는 이런 요청을 거절하고 친절하게 에러 이유를 알려줘야 합니다.
오늘은
유효성 검사(Validation)
를 적용하고, 이를 테스트하는 방법을 알아보겠습니다.

🚀

Validation 의존성 추가 🔗

스프링 부트 2.3 버전부터는 유효성 검사 라이브러리가 별도로 분리되었습니다.
그래서 build.gradle에 의존성을 직접 추가해야 합니다.
아래 전체 코드를 참고하여 수정해 주세요.
build.gradle
// ... 기존 내용 생략 ...
 
dependencies {
    // ... 기존 의존성 생략 ...
    
    // [추가] 유효성 검사 라이브러리
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}
👨‍💻
반드시 우측 상단의 코끼리 아이콘을 눌러 Gradle을 새로고침 해주세요.

🚀

유효성 검사 규칙 적용 🔗

이제 입력 데이터를 담는 DTO 객체에 규칙을 정해줍니다.
우리가 사용할 규칙은 다음과 같습니다.

1. DTO 수정 🔗

AddProductRequest 클래스에 어노테이션을 붙입니다.
@NotBlank는 문자열이 비어있지 않은지 검사하고,
@Min(1000)은 숫자가 최소값 이상인지 검사합니다.
src/main/java/com/example/tdd_study/product/dto/AddProductRequest.java
package com.example.tdd_study.product.dto;
 
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
 
@Getter
@NoArgsConstructor
public class AddProductRequest {
 
    @NotBlank(message = "상품명은 필수입니다.")
    private String name;
 
    @Min(value = 1000, message = "상품 가격은 최소 1000원 이상이어야 합니다.")
    private int price;
 
    public AddProductRequest(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

2. Controller 수정 🔗

DTO에 규칙을 정했더라도, Controller에서 검사를 수행하라고 지시하지 않으면 무용지물입니다.
@Valid 어노테이션을 파라미터 앞에 붙여야 합니다.
src/main/java/com/example/tdd_study/product/ProductController.java
package com.example.tdd_study.product;
 
import com.example.tdd_study.product.dto.AddProductRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class ProductController {
 
    private final ProductService productService;
 
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
 
    @PostMapping("/products")
    public void register(@RequestBody @Valid AddProductRequest request) {
        productService.register(request.getName(), request.getPrice());
    }
}

🚀

검증 로직 테스트하기 🔗

이제 잘못된 값을 보냈을 때 400 Bad Request 에러가 발생하는지 테스트해 보겠습니다.
이것은 우리가 만든 안전장치가 잘 작동하는지 확인하는 중요한 테스트입니다.

테스트 코드 작성 🔗

기존 ProductControllerTest에 실패 케이스를 추가합니다.
src/test/java/com/example/tdd_study/product/ProductControllerTest.java
package com.example.tdd_study.product;
 
import com.example.tdd_study.product.dto.AddProductRequest;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
 
@WebMvcTest(ProductController.class)
class ProductControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @MockBean
    private ProductService productService;
 
    @Test
    void 상품_등록_성공() throws Exception {
        // given
        AddProductRequest request = new AddProductRequest("아메리카노", 4000);
        Gson gson = new Gson();
        String jsonContent = gson.toJson(request);
 
        // when & then
        mockMvc.perform(MockMvcRequestBuilders.post("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonContent))
                .andExpect(MockMvcResultMatchers.status().isOk());
    }
 
    @Test
    void 상품명은_필수다() throws Exception {
        // given: 이름이 비어있는 잘못된 요청
        AddProductRequest request = new AddProductRequest("", 4000);
        Gson gson = new Gson();
        String jsonContent = gson.toJson(request);
 
        // when & then
        mockMvc.perform(MockMvcRequestBuilders.post("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonContent))
                .andExpect(MockMvcResultMatchers.status().isBadRequest()); // 400 에러 기대
    }
 
    @Test
    void 가격은_1000원_이상이어야_한다() throws Exception {
        // given: 가격이 너무 낮은 요청
        AddProductRequest request = new AddProductRequest("아메리카노", 500);
        Gson gson = new Gson();
        String jsonContent = gson.toJson(request);
 
        // when & then
        mockMvc.perform(MockMvcRequestBuilders.post("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonContent))
                .andExpect(MockMvcResultMatchers.status().isBadRequest()); // 400 에러 기대
    }
}

테스트 실행 🔗

이제 테스트를 실행해 봅시다.
세 개의 테스트가 모두 통과해야 합니다.
테스트 성공
테스트 성공
이것이 의미하는 것은 다음과 같습니다.
이는 우리가 설정한 유효성 검사 규칙이 제대로 작동하고 있음을 의미합니다.

🚀

전역 예외 처리 (Global Exception Handling) 🔗

위 테스트를 통과하더라도, 실제 클라이언트(프론트엔드) 입장에서 보면 응답 메시지가 매우 불친절합니다.
스프링이 기본으로 만들어주는 에러 응답은 너무 길고 복잡하기 때문입니다.

우리는 에러가 발생했을 때, 정확히 어떤 필드가 왜 틀렸는지
깔끔한 JSON
으로 내려주길 원합니다.
이때 사용하는 것이 @RestControllerAdvice입니다.

GlobalExceptionHandler 생성 🔗

이 클래스는 애플리케이션 전역에서 발생하는 예외를 가로채서 처리하는 역할을 합니다.
MethodArgumentNotValidException이 바로 유효성 검사 실패 시 발생하는 예외입니다.
src/main/java/com/example/tdd_study/common/GlobalExceptionHandler.java
package com.example.tdd_study.common;
 
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
 
import java.util.HashMap;
import java.util.Map;
 
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        
        // 에러가 발생한 필드(field)와 에러 메시지(message)를 맵에 담습니다.
        ex.getBindingResult().getFieldErrors().forEach(error -> 
            errors.put(error.getField(), error.getDefaultMessage()));
 
        return ResponseEntity.badRequest().body(errors);
    }
}
이제 서버를 실행하고 잘못된 요청을 보내면 아래와 같이 예쁜 JSON 응답이 나옵니다.
{
    "name": "상품명은 필수입니다.",
    "price": "상품 가격은 최소 1000원 이상이어야 합니다."
}

🚀

결론 🔗

오늘은 사용자의 잘못된 입력을 방어하는 방법과 그것을 테스트하는 방법을 알아보았습니다.


이로써 우리는 정상적인 기능뿐만 아니라 예외 상황까지 꼼꼼하게 챙기는 애플리케이션을 만들게 되었습니다.
다음 시간에는 지금까지 작성한 코드를 다시 한번 되돌아보며, 테스트하기 쉬운 코드와 어려운 코드의 특징을 분석하고 설계를 개선하는 시간을 갖겠습니다.

참고 🔗