클라이언트로부터 온 데이터의 유효성을 어디에서 검증할 것인가
DTO와 도메인 객체 양쪽 모두에서 유효성 검사를 해줘야 하지만 성격이 다르다.
✅ 예를 들어 '삼품 이름이 1글자 이상 ~ 100글자 이하의 문자열로 이루어져야 한다'는 것은 상품 이름이라는 필드의 문자열의 길이가 1 이상 ~ 100 이하여야 한다는 것이다. 바로 이게 도메인 지식이다. → 도메인 지식은 도메인 객체 밖으로 빠져나가지 않는 것이 좋다.
이러한 유효성 검사는 당연하게도 도메인 객체 내부에 존재하는 것이 가장 적절하다.
👉🏻 Product 클래스 내부에서 Product 클래스의 필드에 대한 유효성을 검사하도록 한다.
✅ 혹은 상품 이름이라는 필드 자체가 JSON에 포함되지 않았거나, null
데이터를 보내고 있는 경우는 어떨까? → 이런 경우 DTO에서 유효성 검사를 하는 것이 좋다.
👉🏻 도메인 지식과 무관하게 데이터 그 자체가 유효한지 아닌지를 검사할 때는 DTO에서 하는 것이 적절하다.
- 도메인 지식이 도메인 객체 외부로 새어나가지 않게 한다
→ Product 클래스의 응집도를 높임
이 때문에 가급적이면 getter를 사용하지 않는 것이 좋다. (만들었다고 하더라도 불필요한 상황에서 사용하지 않는 것이 좋다.)
getter를 사용하지 않고 도메인 객체 내부에서 유효성을 검사하는 방법
1. 생성자에서 검사하는 방법
→ 도메인 객체를 비롯한 자바의 모든 인스턴스는 생성자를 통해 생성된다. 때문에 생성하는 시점에 불완전한 인스턴스가 애초에 생성되지 않도록 유효성 검사를 통해 막는 것이 안전하다.
2. setter에서 검사하는 방법
3. Bean Validation을 사용하여 검사하는 방법
우리는 ModdelMapper를 사용하고 있기 때문에 생성자에서 검사하는 방법은 사용할 수 없다. 때문에 Bean Validation을 사용하여 유효성을 검사할 것이다.
자바의 스펙('명세' 혹은 '사양') 문서 > JSR(Java Specification Requests)
Bean Validation → JSR-303 문서에 기록된 스펙 (설명서)
👉🏻 우리는 JSR-303 스펙을 구현한 구현체를 실제로 사용한다!
👉🏻 실제 주로 사용되는 Bean Validation 스펙은 JSR-380이라는 확장된 스펙이다.
먼저 의존성을 추가해준다. 그레들 저장소에서 검색한 후 가장 최신 버전 선택!
build.gradle
(생략)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.3.0'
implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.1.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
(생략)
dependency를 추가한 다음에 Product.java 필드에 애너테이션을 달아준다.
Product.java
package kr.co.hanbit.product.management.domain;
(생략)
public class Product {
private Long id;
@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 내부에 머물게 만들었다.
하지만 아직 유효성 검사가 실행된 것은 아니다. 우리는 서비스에서 유효성 검사를 하도록 만들 것이다.
👉🏻 검사 진행: 어플리케이션 서비스에서 내림 / 도메인 지식은 여전히 Product에 머무르도록!
application 패키지 내부에 유효성 검사를 할 서비스 클래스를 추가한다.
ValidationService.java
package kr.co.hanbit.product.management.application;
import jakarta.validation.Valid;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@Service
@Validated
public class ValidationService {
public <T> void checkValid(@Valid T validationTarget) {
// do nothing
}
}
@Service
애너테이션이 붙어있기 때문에 스프링 프레임워크가 해당 클래스를 인스턴스화하여 빈으로 등록한다. @Validated
애너테이션은 해당 클래스에 있는 메서드들 중 @Valid
가 붙은 메서드 매개변수를 유효성 검사하겠다는 의미이다. checkValid()
<T>
: 해당 메서드 내에서 T라는 이름의 제네릭을 사용하겠다는 선언이며, T
는 어떤 타입이든 올 수 있다는 의미이다. 👉🏻 제네릭을 통해서 코드의 중복을 피할 수 있다. Product만의 유효성 검사가 아닌 모든 도메인 객체에 대한 유효성 검사를 할 수 있다. T
를 매개변수의 타입으로 지정했다. = 해당 메서드의 매개변수로 어떤 타입이든 올 수 있다 do nothing
: checkValid 메서드는 아무 것도 하는 일이 없다. 인자를 담아 호출하는 것만으로 유효성 검사가 이뤄지기 때문이다! 그 후 애플리케이션 서비스인 SimpleProductService 위에 ValidationService를 사용하여 유효성을 검사하도록 코드를 수정한다.
SimpleProductService.java
package kr.co.hanbit.product.management.application;
(생략)
@Service
public class SimpleProductService {
private ListProductRepository listProductRepository;
private ModelMapper modelMapper;
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;
}
(생략)
}
컨트롤러 유효성 검사는 엄밀히 말해 클라이언트 요청에 대한 유효성 검사를 말한다. 별도의 클래스 없이 진행이 가능하다.
데이터가 없거나 null인 경우 유효성 검사를 통과하지 못하도록 코드를 수정할 것이다.
ProductDto.java
package kr.co.hanbit.product.management.presentation;
import jakarta.validation.constraints.NotNull;
public class ProductDto {
private Long id;
@NotNull
private String name;
@NotNull
private Integer price;
@NotNull
private Integer amount;
(생략)
}
@NotNull
애너테이션을 달아준다. ProductController.java
package kr.co.hanbit.product.management.presentation;
(생략)
@RestController
public class ProductController {
private SimpleProductService simpleProductService;
@Autowired
ProductController(SimpleProductService simpleProductService) {
this.simpleProductService = simpleProductService;
}
@RequestMapping(value = "/products", method = RequestMethod.POST)
public ProductDto createProduct(@Valid @RequestBody ProductDto productDto) {
return simpleProductService.add(productDto);
}
(생략)
}
@Valid
애너테이션을 달아준다.
@NotNull
,@NotEmpty
,@NotBlank
의 차이점
@NotNull
- 오직 null 만 허용하지 않음 ("", " " 가능)@NotEmpty
- null과 ""처럼 빈 문자열 허용하지 않음 (" " 가능)@NotBlank
- null, "", " " 전부 허용하지 않음
예외의 종류는 예외를 처리하는 것에 있어 중요한 기준이 된다!
@RestControllerAdvice
로 유효성 검사 예외 처리예외처리를 할 때는 예외 패키지 이름보다는 예외 이름이 더 중요하다.
GlobalExceptionHandler.java
package kr.co.hanbit.product.management.presentation;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@RestControllerAdvice
public class GlobalExceptionHandler {
// 도메인 객체 유효성 검사 실패
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorMessage> handleConstraintViolatedException(
ConstraintViolationException ex
) {
// 예외에 대한 처리
Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
List<String> errors = constraintViolations.stream()
.map(
constraintViolation ->
extractField(constraintViolation.getPropertyPath()) + ", " +
constraintViolation.getMessage()
)
.toList();
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
}
@RestControllerAdvice
라는 애너테이션을 달았다@ExceptionHandler
라는 애너테이션과 처리하려는 예외의 종류를 적어준다 ErrorMessage.java
package kr.co.hanbit.product.management.presentation;
import java.util.List;
public class ErrorMessage {
private List<String> errors;
public ErrorMessage(List<String> errors) {
this.errors = errors;
}
public List<String> getErrors() {
return errors;
}
}
에러 메시지를 좀 더명확하게 나타내기 위해 표현 계층에 ErrorMessage 클래스를 추가한다.
이번에는 컨트롤러에서 유효성 검사에 실패하는 경우 발생하는 예외인 MethodArgumentNotValidException를 처리하는 예외 핸들러를 작성할 것이다.
GlobalExceptionHandler.java
package kr.co.hanbit.product.management.presentation;
(생략)
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
(생략)
@RestControllerAdvice
public class GlobalExceptionHandler {
(생략)
// 컨트롤러 유효성 검사 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorMessage> handleMethodArgumentNotValidException(
MethodArgumentNotValidException ex
) {
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
List<String> errors = fieldErrors.stream()
.map(
fieldError ->
fieldError.getField() + ", " + fieldError.getDefaultMessage()
)
.toList();
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
}
만약 도메인 객체에 대한 유효성을 검증하는 예외 처리 핸들러에서도 응답 코드 포맷을 맞추고 싶다면
package kr.co.hanbit.product.management.presentation;
(생략)
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
(생략)
@RestControllerAdvice
public class GlobalExceptionHandler {
// 도메인 객체 유효성 검사 실패
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorMessage> handleConstraintViolatedException(
ConstraintViolationException ex
) {
// 예외에 대한 처리
Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
List<String> errors = constraintViolations.stream()
.map(
constraintViolation ->
extractField(constraintViolation.getPropertyPath()) + ", " +
constraintViolation.getMessage()
)
.toList();
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST);
}
(생략)
// 코드 포맷 맞추기
private String extractField(Path path) {
String[] splittedArray = path.toString().split("[.]");
int lastIndex = splittedArray.length - 1;
return splittedArray[lastIndex];
}
}
이렇게 수정해서 일관성있게 응답을 맞출 수 있다.
전역 예외 핸들러와 웹 애플리케이션이 예외 처리 전략은 큰 관련이 있다. 애플리케이션에서 발생한 예외를 클라이언트에게 적절한 상태 코드와 메시지로 전달해 주어야하기 때문이다.
Checked Exception 👉🏻
try-catch
문이 강제되는 예외 (Exception 클래스를 상속받는 예외들)
Unchecked Exception 👉🏻try-catch
문이 강제되지 않는 예외 (RuntimeException 클래스를 상속받는 예외들)
예외는 어플리케이션에서 자연스럽게 발생하는 것이다. 또한 개발자가 적절히 처리하기 위한 로직을 작성할 수 있다. (개발자가 제어)
하지만 Error는 OutOfMemoryError, StackOverflowError 등 메모리와 관련해 발생한 문제이기 때문에, 개발자가 작성하기 어려우며 생기면 안됨... (개발자가 제어 x, 프로그램에서 제어할 수 있는 오류)
왜인지... 나에겐 말이 어려워서 다른데서 다시 찾아봤는데!
Checked Exception 👉🏻 RuntimeException의 하위 클래스가 아니면서! Exception의 하위 클래스들을 말한다.
Unchecked Exception 👉🏻 RuntimeException의 하위 클래스들을 말한다.
RuntimException은 Exception의 하위 클래스가 아니라는건가...? 말을 이상하게 읽어서 괜한 혼란이 생겨서 찾아봤는데 그냥 Exception의 하위 클래스들에서 RuntimeException을 기준으로 나눠주면 될 것 같다.
👉🏻 애플리케이션에서 예외처리를 할 때 어떤 예외로 던져줘야 할 것인가에 대한 논쟁이 많았는데 Unchecked Exception을 사용하는 것이 적절하다는 결론!
그 외 등등 이러한 이유들로 Uncehcked Exception을 통해서 예외를 처리하는 것이 좋다는 결론!
실제 코드에 적용을 해보도록 합시다!
ListProductRepository의 findById 메서드에서 id에 해당하는 Product가 없을 경우에 현재는 NoSuchElementException을 던진다. 이는...! 적절치 않다!
👉🏻 NoSuchElementException가 Optional에서 요소를 찾지 못해서 발생하는 예외이기 때문이다.
하지만 나중에 List가 아닌 데이터베이스 레포지토리를 사용하는 상황에서는 서로 다른 예외가 던져진다.
→ 예외를 핸들링하는 코드가 두 개 필요하기 때문에 코드의 중복이 발생한다.
→ 레포지토리를 어떤 것을 사용하고 있는지(List인지 데이터베이스인지) 클라이언트에게 알려지게 되므로 Repository 코드의 캡슐화를 깨뜨린다.
이런건 공부해두지 않으면 절대 모를 것 같다. 알아갑시다!
때문에 우리는 예외를 새로 정의할 것이다. Repository에서 특정 id에 해당하는 Product를 찾지 못해서 발생한 예외이다. Product처럼 id(식별자)를 가지는 도메인 객체를 엔티티라고 하므로 EntityNotFoundException이라는 이름의 예외를 정의할 것이다.
EntityNotFoundException.java
package kr.co.hanbit.product.management.domain;
public class EntityNotFoundException extends RuntimeException {
public EntityNotFoundException(String message) {
super(message);
}
}
도메인 계층에 EntityNotFoundException 예외 클래스를 추가한다.
👉🏻 엔티티를 찾지 못했을 때 발생하는 예외이기 때문이기도 하지만
👉🏻 EntityNotFoundException 클래스는 모든 계층에 의해 사용된다.
레이어드 아키텍처에서 모든 계층은 도메인 계층을 의존할 수 있다. 때문에 모든 계층에서 사용되어야 할 EntityNotFoundException을 도메인 계층에 위치시키는 것이 적절하다.
ListProductRepository.java
package kr.co.hanbit.product.management.infrastructure;
import kr.co.hanbit.product.management.domain.EntityNotFoundException;
(생략)
@Repository
public class ListProductRepository {
private List<Product> products = new CopyOnWriteArrayList<>();
private AtomicLong sequence = new AtomicLong(1L);
public Product add(Product product) {
product.setId(sequence.getAndAdd(1L));
products.add(product);
return product;
}
public Product findById(Long id) {
return products.stream()
.filter(product -> product.sameId(id))
.findFirst()
.orElseThrow(() -> new EntityNotFoundException("Product를 찾지 못했습니다."));
}
(생략)
}
이렇게 Product를 찾지 못한 상황을 예외 메시지로 지정해 준다.
그 후 GlobalExceptionHandler에 대한 예외 처리 핸들러를 추가해준다.
GlobalExceptionHandler.java
package kr.co.hanbit.product.management.presentation;
(생략)
@RestControllerAdvice
public class GlobalExceptionHandler {
(생략)
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorMessage> handleEntityNotFoundExceptionException(
EntityNotFoundException ex
) {
List<String> errors = new ArrayList<>();
errors.add(ex.getMessage());
ErrorMessage errorMessage = new ErrorMessage(errors);
return new ResponseEntity<>(errorMessage, HttpStatus.NOT_FOUND);
}
}
어플리케이션의 각 레이어에서 발생하는 예외는 가급적 특정 레이어 내에서 특정 기술에 종속적인 예외를 사용하기보다는 애플리케이션 내에서 의미있는 예외를 정의해서 사용하는 것이 좋다. → RuntimeException을 상속받아서 Unchecked Exception으로 만든다.