유효성 검사

상품 관리 애플리케이션을 만들다 보면, 단순히 요청을 받고 저장하는 것만으로는 충분하지 않다는 걸 금방 느끼게 된다.
예를 들어 상품 이름이 비어 있거나, 가격이 음수이거나, 재고 수량이 비정상적으로 큰 값이라면
애플리케이션은 이런 데이터를 그대로 받아들여서는 안 된다.
이런 상황을 막기 위해 필요한 것이 바로 유효성 검사(validation) 다.
유효성 검사는 말 그대로 우리가 다루는 데이터가 요구사항에 맞는 적절한 값을 가지고 있는지 확인하는 과정이다.
처음 유효성 검사 로직을 작성할 때는,
Product에 getter를 추가한 뒤 서비스 코드에서 값을 하나씩 꺼내 검증하는 방식으로 구현하는 경우가 많다.
예를 들어 서비스 안에서 상품 이름 길이를 확인하고, 가격 범위를 검사하고,
조건에 맞지 않으면 처리를 중단하는 식이다. 이 방식은 당장은 동작한다.
하지만 이 방식은 구조적으로 보면 몇 가지 문제가 있다.
① 도메인 지식이 도메인 객체 바깥으로 새어 나간다
상품 이름은 몇 글자까지 가능한지, 가격은 어느 범위까지 허용되는지 같은 규칙은 사실 “상품”이라는 개념에 속한 지식이다.
그런데 이런 검사를 서비스 코드에서 하면, 도메인이 가져야 할 규칙이 외부 계층으로 흩어지게 된다.
게다가 같은 검사가 상품 생성 로직에도, 상품 수정 로직에도 반복해서 들어갈 가능성이 크다.
② 서비스 코드의 흐름이 복잡해진다
서비스 계층은 원래 “어떤 기능을 수행하는가”를 중심으로 읽혀야 한다.
예를 들어 상품을 추가한다면, DTO를 도메인 객체로 바꾸고, 저장하고,
다시 DTO로 반환하는 흐름이 눈에 들어와야 한다.
그런데 그 중간에 유효성 검사 코드가 길게 섞여 들어가면, 서비스의 핵심 흐름이 잘 보이지 않게 된다.
실제 서비스에서는 지금보다 훨씬 더 많은 로직이 얽히기 때문에,
검증 로직까지 서비스 안에 섞이면 가독성이 급격히 떨어진다.
③ 유효성 검사 실패 시 null을 반환하는 방식이 적절하지 않다
초보자들이 자주 하는 실수 중 하나가, 조건에 맞지 않으면 null을 반환해버리는 것이다.
하지만 유효성 검사는 “더 이상 정상적인 로직을 진행할 수 없는 상황”을 발견하는 과정이기 때문에,
이런 경우에는 null보다 예외를 던지는 것이 훨씬 더 적절하다.
null을 반환하면 이 메서드를 호출하는 쪽마다 null 체크 분기문을 추가해야 하고,
자칫하면 NullPointerException의 원인이 되기도 한다.
반면 예외를 사용하면 “이 입력은 잘못되었다”는 사실을 더 명확하게 전달할 수 있다.
Bean Validation을 통한 유효성 검사
도메인 가까이에 유효성 검사를 두는 것이 좋다고 해도, 지금 구조에서는 한 가지 제약이 있다.
바로 ModelMapper를 사용하고 있다는 점이다.
생성자를 통해 객체를 만들고 그 안에서 직접 검증하는 방식은 객체지향적으로 보기에는 꽤 자연스러울 수 있지만,
ModelMapper는 보통 파라미터가 없는 생성자로 객체를 만든 다음, 리플렉션을 사용해 필드 값을 채워 넣는다.
즉, 생성자 안에서 강하게 유효성 검사를 수행하는 방식과는 잘 맞지 않는다.
이런 상황에서 사용할 수 있는 대표적인 방법이 Bean Validation이다.
Bean Validation은 객체의 필드에 어노테이션을 붙여서
“이 필드는 어떤 조건을 만족해야 하는가”를 선언적으로 표현하는 방식이다.
이 방식은 꼭 ModelMapper를 쓸 때만 필요한 것이 아니라, 스프링 애플리케이션에서 유효성 검사를 할 때 아주 널리 사용된다.
먼저 의존성을 추가해야 한다. (pom.xml 기준)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
이후 Product 클래스에 요구사항에 맞는 제약 조건을 어노테이션으로 표현할 수 있다.
@Size(min = 1, max = 100)
private String name;
@Max(1_000_000)
@Min(0)
private Integer price;
@Max(9_999)
@Min(0)
private Integer amount;
어노테이션을 붙였다고 해서 자동으로 모든 곳에서 검사되는 것은 아니다.
실제로 이 객체가 생성된 뒤, “지금 이 객체가 유효한지 검사하라”는 동작이 한 번 더 필요하다.
지금 구조에서는 Product가 생성되는 지점을 보면 서비스 계층에서 modelMapper.map()을 통해 만들어진다.
그러므로 매핑 직후에 이 객체를 검사하는 단계가 필요하다.
이를 위해 별도의 ValidationService를 둘 수 있다.
@Service
@Validated
public class ValidationService {
public <T> void checkValid(@Valid T validationTarget){
}
}
여기서 제네릭 <T>를 사용한 이유는, 꼭 Product만이 아니라 어떤 타입이든 유효성 검사를 할 수 있도록 하기 위해서다.
@Valid가 붙은 파라미터는, 해당 객체에 선언된 Bean Validation 어노테이션들을 기준으로 검사를 수행하게 된다.
서비스 계층에서는 매핑 직후에 이 검사를 호출한다. (코드 추가)
private ValidationService validationService;
@Autowired
SimpleProductService(
ListProductRepository listProductRepository,
ModelMapper modelMapper,
ValidationService validationService
){
this.listProductRepository = listProductRepository;
this.modelMapper = modelMapper;
this.validationService = validationService;
}
public ProductDto add(ProductDto productDto){
Product product = modelMapper.map(productDto, Product.class);
validationService.checkValid(product);
Product savedProduct = listProductRepository.add(product);
ProductDto savedProductDto = modelMapper.map(savedProduct, ProductDto.class);
return savedProductDto;
}
이렇게 하면 외부에서 들어온 DTO를 도메인 객체로 바꾼 뒤, 실제 저장 전에 도메인 규칙을 검사할 수 있다.
즉, 저장되기 직전의 Product가 정말 유효한가를 서비스 흐름 안에서 확인하게 되는 것이다.
ProductDto와 Controller에 유효성 검사 추가
도메인 객체 Product에 유효성 규칙을 두는 것만으로도 꽤 많은 검사가 가능하다.
하지만 외부 요청을 처리하는 과정에서는 또 다른 문제가 있다.
예를 들어 아예 name, price, amount 값 자체가 빠진 요청이 들어올 수도 있다.
이런 경우는 도메인 규칙을 검사하기 전에, 요청 자체가 기본 형태를 갖추고 있는지부터 확인할 필요가 있다.
그래서 ProductDto에도 유효성 검사 로직을 추가할 수 있다.
@NotNull
private String name;
@NotNull
private Integer price;
@NotNull
private Integer amount;
이 설정은 “이 필드들은 최소한 null이면 안 된다”는 의미다.
즉, 외부 요청이 DTO로 바인딩되는 시점에서 필수 값이 비어 있는지를 먼저 검사할 수 있게 된다.
그리고 컨트롤러에서 @Valid를 붙이면,
스프링이 요청 바디를 DTO로 변환한 뒤 바로 유효성 검사를 수행하게 된다
@RequestMapping(value = "/products", method = RequestMethod.POST)
public ProductDto createProduct(@Valid @RequestBody ProductDto productDto){
return simpleProductService.add(productDto);
}
이렇게 하면 클라이언트가 필수 값을 아예 보내지 않은 경우,
서비스 계층까지 들어가기 전에 컨트롤러 단계에서 검증 실패가 발생한다.
즉, DTO의 @NotNull은 외부 입력 형식이 최소한의 조건을 만족하는지를 검사하는 역할을 한다.
현재 구조의 문제점
지금까지 설정한 구조를 실제로 실행해보면, 잘못된 값을 넣었을 때 유효성 검사 자체는 동작한다.
예를 들어 범위를 벗어난 가격이나 재고 수량을 넣으면 검증 실패가 발생하고,
필수 값이 비어 있으면 DTO 단계에서 요청이 거절된다.
그런데 이 상태에서 한 가지 아쉬운 점이 있다.
클라이언트는 무엇이 잘못되었는지 명확하게 알기 어렵다는 점이다.
예를 들어 잘못된 값이 들어왔을 때 어떤 경우에는 500 에러가 발생하고, 값이 아예 없으면 400 에러가 발생할 수 있다.
하지만 상태 코드만 보고는 “가격이 범위를 벗어난 건지”, “name이 비어 있는 건지”, “재고 수량이 음수인 건지”를
구체적으로 알기 어렵다.
지금 단계에서는 유효성 검사 자체를 추가하는 것이 목표였기 때문에,
우선 검사가 어디서 어떻게 이루어지는지 이해하는 데 집중하면 된다.
그리고 다음 단계에서는 전역 예외 처리 핸들러를 추가해서,
이런 유효성 검사 실패를 더 읽기 좋은 에러 응답으로 바꾸는 방식까지 확장해볼 수 있다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP20. 상품 관리 애플리케이션에 데이터베이스 연동하기 1편 (0) | 2026.04.22 |
|---|---|
| EP19. 상품 관리 애플리케이션에 유효성 검사 추가하기 2편 (0) | 2026.04.21 |
| EP17. List를 이용한 상품 관리 애플리케이션 만들기 5편 (0) | 2026.04.10 |
| EP16. List를 이용한 상품 관리 애플리케이션 만들기 4편 (0) | 2026.04.10 |
| EP15. List를 이용한 상품 관리 애플리케이션 만들기 3편 (0) | 2026.04.09 |