어플리케이션의 비즈니스 로직이 올바르게 동작하려면 사전 검증하는 작업이 필요하다. 이것을 유효성 검사(validation) 또는 데이터 검증이라고 부른다. 유효성검사는 매우 프로그래밍에서 매우 중요한 부분이기 때문에 이번에는 유효성검사에 대해 공부해 본다.
코드가 복잡해지고 가독성이 떨어지던 기존의 유효성 검사의 문제점을 해결하기 위해서 자바 진영에서는 Bean Validation이라는 데이터 유효성 검사 프레임 워크를 제공해왔다.
Hibernate Validator는 Bean Validation 명세의 구현체입니다. 스프링 부트에서는 유효성 검사 표준으로 채택해서 사용하고 있다. 명세의 구현체로서 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와준다.
유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시한다. 계층간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 유효성 검사를 DTO객체를 대상으로 수행해 보도록 할 것이다.

ValidRequestDto 작성
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Builder
public class ValidRequestDto {
@NotBlank
String name;
@Email
String email;
@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
String number;
@Min(value = 20)
@Max(value = 40)
int age;
@Size(min = 0, max = 40)
String description;
@Positive
int count;
@AssertTrue
boolean booleanCheck;
}
@RestController
@RequestMapping("/validation")
public class ValidationController {
@PostMapping("/valid")
public ResponseEntity<String> checkValidationByValid(
@Valid @RequestBody ValidRequestDto validRequestDto){
return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
}
}
위에서는 유효성 검사를 하기 위해 @Valid 어노테이션으 사용했다. @Valid 어노테이션은 자바에서 지원하는 어노테이션이다. 이와 비슷하게 스프링에서도 @Validated라는 별도의 어노테이션을 제공한다. @Validated 어노테이션은 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능을 제공한다.
ValidationGroup1, ValidationGroup2라는 인터페이스를 만든다. 모두 내부 코드는 없고 인터페이스만 생성해서 그룹화하는 용도로 사용한다.
public interface ValidationGroup1 {
}
public interface ValidationGroup2{
}
검증 그룹 설정은 DTO 객체에서 한다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {
@NotBlank
private String name;
@Email
private String email;
@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
private String phoneNumber;
@Min(value = 20, groups = ValidationGroup1.class)
@Max(value = 40, groups = ValidationGroup2.class)
private int age;
@Size(min = 0, max = 40)
private String description;
@Positive(groups = ValidationGroup2.class)
private int count;
@AssertTrue
private boolean booleanCheck;
}
groups 속성을 사용해서 ValidationGroups1 그룹을 설정하거나 ValidationGroups2를 설정한다. 이 설정을 통해 어느 그룹에 맞춰 유효성 검사를 실시할 것인지 지정해 주는 것이다.
@RestController
@RequestMapping("/validation")
public class ValidationController {
@PostMapping("/validated")
public ResponseEntity<String> checkValidation(
@Validated @RequestBody ValidatedRequestDto validatedRequestDto){
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group1")
public ResponseEntity<String> checkValidation1(
@Validated @RequestBody ValidatedRequestDto validatedRequestDto){
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group2")
public ResponseEntity<String> checkValidation2(
@Validated @RequestBody ValidatedRequestDto validatedRequestDto){
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/all-group")
public ResponseEntity<String> checkValidation3(
@Validated({ValidationGroup1.class, ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto){
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
}
자바에서 발생하는 오류를 try/catch, throw 구문을 활용해 처리한다. 스프링 부트에서는 더욱 편리하게 예외처리를 할 수 있는 기능을 제공한다.
예외 클래스는 Throwable 클래스를 상속받는다.
Exception 클래스는 당양한 자식 클래스를 가지는데 다음 두가지로 구분한다.
예외 상황을 파악해서 문제를 해결하는 방식
try.catch 구문
int a = 1;
String b = "a";
try{
System.out.println(a + Integer.parseInt(b));
}catch (NumberFormatException e){
b = "2";
System.out.println(a + Integer.parseInt(b));
}
예외가 발생한 시점에서 바로 처리하는 것이 아니라 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식
throw 키워드 사용하여 어떤 예외가 발생헀는지 내용 전달
int a = 1;
String b = "a";
try{
System.out.println(a + Integer.parseInt(b));
}catch (NumberFormatException e){
b = "2";
throw new NumberFormatException("숫자가 아닙니다.");
}
앞의 두 방식을 적절하게 섞은 방식
예외처리를 좀 더 단숳나게 하기 위해 래핑(wrapping)해야 하는경우 try/catch 방식을 사용하면서 catch블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달
예외가 발생했을 때 클라이언트에 오류 메세지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야한다.
처리 방식
CustomExceptionHandler 클래스 만들기
@RestControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handleException(RuntimeException e, HttpServletRequest request){
HttpHeaders responseHeaders = new HttpHeaders();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", e.getMessage());
System.out.println("다른 CustomExceptionHandler 클래스의 ExceptionHandler 실행");
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
}
테스트를 해볼 ExceptionController 만들기
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping
public void getRuntimeException(){
throw new RuntimeException("getRuntimeException 메서드 호출");
}
}
작성후 swagger를 사용하여 메서드를 호출해보았다.

Map 객체에 응답할 메시지를 구성하고 ResponseEntity에 HttpHeader, HttpStauts, Body를 담아 전달한다. 그러면 위와 같이 클라이언트는 값을 받게 된다.
그렇다면 컨트롤러 클래스 내부에 작성한다면 누가 실행되게 될까?
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping
public void getRuntimeException(){
throw new RuntimeException("getRuntimeException 메서드 호출");
}
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handleException(RuntimeException e, HttpServletRequest request){
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", e.getMessage());
System.out.println("클래스 내부 ExceptionHandler 실행");
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
}
위와 같이 작성하고 실행시켜보면 응답 값은 동일하다. 하지만 누가 실행되었는지 혹인하기 위해서 System.out.println을 실행해본 결과
이렇게 클래스 내부에 작성한 ExceptionHanlder가 작동했다.
이를 통해 클래스 내부에 인쓴 Handler가 글로벌하게 작동하는 Handler보다 우선순위가 높다는 것을 알 수 있었다.
왜 커스텀 예외를 사용하려 할까?
도메인 레벨 표현을 위해 열거형으로 ExceptionClass를 작성한다. 이후의 확장성을 고려해 Constants라는 상수들을 통합 관리하는 클래스를 생성하고 내부에 ExceptionClass를 선언한다. 어떤 도메인에서 문제가 발생했는지 보여주는 데 사용된다.
public class Constants {
public enum ExceptionClass{
PRODUCT("Product");
private String exceptionClass;
ExceptionClass(String exceptionClass){
this.exceptionClass = exceptionClass;
}
public String getExceptionClass(){
return this.exceptionClass;
}
@Override
public String toString(){
return getExceptionClass() + "Exception. ";
}
}
}
ExceptionClass, HttpStauts 두가지를 필드로 가지는 클래스를 생성한다.
public class CustomException extends Exception{
private Constants.ExceptionClass exceptionClass;
private HttpStatus httpStatus;
public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus, String message){
super(exceptionClass.toString() + message);
this.exceptionClass = exceptionClass;
this.httpStatus = httpStatus;
}
public Constants.ExceptionClass getExceptionClass(){
return exceptionClass;
}
public int getHttpStatusCode(){
return this.httpStatus.value();
}
public String getHttpStatusType(){
return this.httpStatus.getReasonPhrase();
}
public HttpStatus getHttpStatus(){
return this.httpStatus;
}
}
다음과 같이 작성하면 예외 발생 시점에 HttpStatus를 정의해서 전달하기 떄문에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다.
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<Map<String, String>> handleException(CustomException e, HttpServletRequest request){
HttpHeaders responseHeaders = new HttpHeaders();
Map<String, String> map = new HashMap<>();
map.put("error type", e.getHttpStatusType());
map.put("code", Integer.toString(e.getHttpStatusCode()));
map.put("message", e.getMessage());
return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
}
마지막으로 Swagger로 테스트해보기 위해 컨트롤러를 작성해준다.
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping("/custom")
public void getCustomException() throws CustomException{
throw new CustomException(Constants.ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메서드 호출");
}
}

위와같이 CustomException이 잘 실행된것을 알 수 있다.
유효성을 처리하는 방법과 예외를 처리하는 방법에 대해 알아보았다. 개발공부를 하다보면 정말 많은 오류와 예외상황을 마주하게 된다. 그럴때마다 나는 어떻게 예외를 처리해줘야할지를 생각하기 보다 그냥 어디에나 예외가 있으니 이런 상황을 만들지 않는 환경을 만들려고 해왔다. 책을 읽고 생각해보니 너무나도 잘못된 방법이었다. 다양한 예외상황도 이제는 잘 처리하도록 하고 커스텀으로도 만들어 예외처리에 수월하게 구현을 해보는것이 좋을것 같다. 또한 항상 Null값을 받아 처리하라다 nullpointException을 만나는 경우가 많았는데 Validation을 통해 어느정도 잡을 수 있을것이라고 생각했다.
공부하면서 어려웠던 점은 아무래도 Exception이 Throwable을 상속받았다는 사실과 내부 구조를 파악하는 거이었다. 객체지향 언어의 상속개념은 매번들어도 와닿지가 않는다. 잠깐의 생각할 시간이 있으면 이해를 하기 시작하지만 생각보다 시간이 좀 걸린다. 그래도 구조를 파악하고 이해해나가는 행위자체는 재미있어서 다행이다. 이번에도 시간은 오래걸렸지만 어떻게 Exception을 커스텀하고 왜 그렇게 해야하는지를 이해할 수 있어서 즐거웠다. 앞으로 구조를 보는 연습을 더한다면 좋지않을까?