10. 유효성 검사와 예외 처리[Spring Boot]

JuJaeng2·2024년 1월 1일

어플리케이션의 비즈니스 로직이 올바르게 동작하려면 사전 검증하는 작업이 필요하다. 이것을 유효성 검사(validation) 또는 데이터 검증이라고 부른다. 유효성검사는 매우 프로그래밍에서 매우 중요한 부분이기 때문에 이번에는 유효성검사에 대해 공부해 본다.

✅ Bean Validation

코드가 복잡해지고 가독성이 떨어지던 기존의 유효성 검사의 문제점을 해결하기 위해서 자바 진영에서는 Bean Validation이라는 데이터 유효성 검사 프레임 워크를 제공해왔다.

Bean Validation이란?

  • 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공
  • 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다. => 조금 더 쉽게 말하면 엔티티 내부에 규픽을 정해서 그 규칙을 따르도록 함으로 외부에서 들어오는 데이터가 항상 일관성 있고 유효한 상태를 유지할 수 있도록 해주는 것이다.
  • 주로 자바 빈(Java Bean)에 대한 유효성 검증을 다룬다.

✅ Hibernate Validator

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;
}

문자열 검증

  • @Null : null 값만 허용
  • @NotNull : null을 허용하지 않음( "". " "는 허용)
  • @NotEmpty : null, ""을 허용하지 않음(" "는 허용)
  • @NotBlank : null, "", " "을 허용하지 않음

최댓값/최솟값 검증

  • BigDecimal, BigInteger, int, long 등의 타입을 지원
  • @DemicalMax(value = "$numberString") : $numberString보다 작은 값을 허용
  • @DemicalMin(value = "$numberString") : $numberString 보다 큰 값을 허용
  • @Min(value = $number) : $number 이상의 값을 허용
  • @Max(value = $number) : $number 이하의 값을 허용

값의 범위 검증

  • BigDecimal, BigInteger, int, long 등의 타입을 지원
  • @Positive : 양수를 허용
  • @PositiveOrZero : 0을 포함한 양수 허용
  • @Negative : 음수를 허용
  • @NegativeOrZero : 0을 포함한 음수 허용

시간에 대한 검증

  • Date, LocalDate, LocalDateTime 등의 탑입 지원
  • @Future : 현재보다 미래의 날짜를 허용
  • @FutureOrPresent : 현재를 포함한 미래의 날짜를 허용
  • @Past : 현재보다 과거의 날짜를 허용
  • @PastOrPresent : 현재를 포함한 과거의 날짜를 허용

이메일 검증

  • @Email : 이메일 형식을 검사("" 는 허용)

자릿수 범위 검증

  • BigDecimal, BigInteger, int, long 등의 타입을 지원
  • @Digits(integer = $number1, fraction = @number2) : @number1 의 정수 자릿수와 @number2의 소수 자릿수를 허용

Boolean 검증

  • @AssertTrue :true인지 체크(Null값은 체크하지 않음)
  • @AssertFalse : false 인지 체크(null 값은 체크하지 않음)

문자열 길이 검증

  • @Size(min = $number1, max = $number2) : $number1 이상 @number2 이하의 범위를 허용

정규식 검증

  • @Pattern(regexp = "$expression") : 정규식을 검사. 정규식은 자바의 java.util.regex.Patter 패키지의 컨벤션을 따른다.
@RestController
@RequestMapping("/validation")
public class ValidationController {


    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDto validRequestDto){
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }
}

@Validated 활용

위에서는 유효성 검사를 하기 위해 @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());
    }
}
  • @Validated 어노테이션에 특정 그룹을 설정하지 않은 경우 : groups가 설정도지 않은 필드에 대해 유효성 검사
  • @Validated 어노테이션에 특정 그룹을 설정하는 경우 : 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사

✅ 예외 처리

자바에서 발생하는 오류를 try/catch, throw 구문을 활용해 처리한다. 스프링 부트에서는 더욱 편리하게 예외처리를 할 수 있는 기능을 제공한다.

예외(Exception)

  • 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 어플리케이션이 정상적으로 동작하지 못하는 상황을 의미
  • 개발자가 직접 처리할 수 있는것이므로 코드 설계를 통해 처리 가능

에러(Error)

  • 주로 자바 가상머신에서 발생시키는 것
  • 예외와 달리 어플리케이션 코드에서 퍼리할 수 있는 것이 거의 없다.
  • 대표 에러
    • OutOfMemory(메모리 부족)
    • StackOverFlow(스택 오버플로)

예외 클래스

예외 클래스는 Throwable 클래스를 상속받는다.

Exception 클래스는 당양한 자식 클래스를 가지는데 다음 두가지로 구분한다.

  • Checked Exception
    • 반드시 예외 처리 필요
    • 컴파일 단계에서 확인
    • Ex) IOException, SQLException
  • Unchecked Exception
    • 명시적으로 처리를 강제하지 않음
    • 실행 중에 확인
    • Ex) RuntimeException, NullPointerException, IllegalArgumentException 등 ...

예외 처리 방법

  1. 예외 복구
  2. 예외 처리 회피
  3. 예외 전환

예외 복구

예외 상황을 파악해서 문제를 해결하는 방식
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 키워드를 사용해 다른 예외 타입으로 전달

스프링 부트의 예외 처리 방식

예외가 발생했을 때 클라이언트에 오류 메세지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야한다.

처리 방식

  • @(Rest)ControllerAdivce, @ExceptionHandler 를 통해 모든 컨트롤러의 예외 처리
  • @ExceptionHandler를 통해 특정 컨트롤러의 예외 처리

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을 커스텀하고 왜 그렇게 해야하는지를 이해할 수 있어서 즐거웠다. 앞으로 구조를 보는 연습을 더한다면 좋지않을까?

profile
다 잘하고 싶은 개발자

0개의 댓글