본문 바로가기

Spring

유효성 검사와 예외 처리

1 스프링 부트의 유효성 검사

애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요하다. 이것을 유효성 검사라 한다. 스프링 부트의 유효성 검사는 spring-boot-starter-web에 포함되어 있었지만, 2.3 버전 이후로는 별도의 라이브러리로 제공하고 있다.

 

유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시한다. 스프링 부트 프로젝트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 아래 그림과 같이 유효성 검사를 DTO 객체를 대상으로 수행하는 것이 일반적이다.

도메인 모델에 유효성 검사 적용

 

1.1 유효성 검사를 위한 어노테이션

문자열 검증

  • @Null: null 값만 허용
  • @NotNull: null을 허용하지 않는다.
  • @NotEmpty: null과 빈 값을 허용하지 않는다.
  • @NotBlank: null과 빈 값, 공백을 허용하지 않는다.

최댓값/최솟값 검증

  • BigDecimal, BigInteger, int, long 등의 지원
  • @DecimalMax(value = "$numberString"): $numberString보다 작은 값을 허용
  • @DecimalMin(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인지 확인
  • @AssertFalse: 값이 false인지 확인

문자열 길이 검증

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

정규식 검증

  • @Pattern(regexp = "$expression"): 정규식을 검사 (정구식은 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따름)

 

1.2 @Validated 활용

 

자바에서는 @Valid 어노테이션을 지원하며, 스프링에서는 @Validated라는 별도의 어노테이션으로 유효성 검사를 지원한다. @Validated는 @Valid 어노테이션의 기능을 포함한다.

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.*;

@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {

    @PostMapping("/register")
    public String registerUser(@Validated @RequestBody UserDTO userDTO) {
        return "User registered successfully: " + userDTO;
    }

    public static class UserDTO {

        @NotBlank(message = "Name must not be blank")
        private String name;

        @Email(message = "Email should be valid")
        private String email;

        @NotNull(message = "Age must not be null")
        @Min(value = 18, message = "Age must be at least 18")
        private Integer age;
    }
}

 

위 예제는 스프링 부트에서 @Validated를 사용하여 UserController에서 UserDTO 개체의 유효성을 검사하는 예제이다.

@Validated를 사용하여 request의 유효성을 검사한다.

UserDTO 클래스에는 각 필드에 제약을 적용하기 위한 어노테이션이 사용되었다.

 

1.3 커스텀 Validation

 

자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 사용해야 할 때가 있다. 이 경우 ConstraintValidator와 커스텀 어노테이션을 조합하여 벌도의 유효성 검사 어노테이션을 생성할 수 있다.

예를 들어, 전화번호 형식이 일치하는지 확인하는 유효성 검사가 필요하다고 해보자.

먼저, ConstraintValidator 인터페이스를 구현하는 클래스를 생성한다.

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class TelephoneValidator implements ConstraintValidator<Telephone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return false;
        }
        return value.matches("01(?:0|1|[6-9])[-.]?\d{3}[-.]?\d{4}[-.]?\d{4}$");
    }
}

 

인터페이스를 선언할 때는 어떤 어노테이션 인터페이스인지 타입을 지정해야 한다.

ConstraintValidator 인터페이스는 invalid() 메서드를 정의하고 있다. 이 메서드를 구현하려면 위 예제처럼 직접 유효성 검사 로직을 작성해야 한다.

해당 로직에서 false가 리턴되면 MethodArgumentNotValidException 예외가 발생한다.

아래는 ConstraintValidator 인터페이스에서 정의한 TelePhone 인터페이스이다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다.";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

@Target 어노테이션은 어노테이션을 어디서 선언할 수 있는지 정의하는 데 사용된다.

@Retention 어노테이션은 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다. 적용 범위는 RetentionPolicy를 통해 지정한다.

  • RetentionPolicy.RUNTIME: 컴파일 이후에도 JVM에 의해 참조된다. 리플렉션이나 로깅에 주로 사용하는 정책
  • RetentionPolicy.CLASS: 컴파일러가 클래스를 참조할 때까지 유지
  • RetentionPolicy.SOURCE: 컴파일 전까지만 유지

@Constraint 어노테이션은 TelephoneValidator와 매핑하는 작업을 수행한다. @Telephone 인터페이스 내부에는 message(), groups(), payload() 요소를 정의해야 한다. 각 항목은 다음과 같다.

  • message(): 유효성 검사가 실패할 경우 반환되는 메시지
  • groups(): 유효성 검사를 사용하는 그룹으로 설정
  • payload(): 사용자가 추가 정보를 위해 전달하는 값

 

 

2 예외 처리

 

애플리케이션을 개발할 때는 많은 오류가 발생하게 된다. 자바에서는 이러한 오류를 try/catch, throw 구문을 활용해 처리한다. 스프링 부트에서는 더욱 편리하게 예외 처리할 수 있는 기능을 제공한다.

 

2.1 예외와 에러

 

프로그래밍에서 예외(exception)란 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정삭적으로 동작하지 못하는 상황을 의미한다. 예외는 개발자가 직접 처리할 수 있는 것이므로 미리 코드 설계를 통해 처리할 수 있다.

 

다음으로 에러(error)는 주로 자바의 가상머신에서 발생시키는 것으로서, 예외와 달리 애플리케이션 코드에서 처리할 수 있는 것이 거의 없다. 대표적인 예로 메모리 부족(OutOfMemory), 스택 오버플로(StackOverFlow) 등이 있다. 이러한 에러는 발생 시점에서 처리하는 것이 아니라, 미리 애플리케이션의 코드를 살펴보면서 문제가 발생하지 않도록 예방해서 원척적으로 차단해야 한다.

 

많은 사람들이 예외와 에러를 비슷한 의미로 사용하고 있지만 소프트웨어 공학에서는 엄연히 다르게 사용되는 용어이다.

 

2.2 예외 클래스

 

자바의 예외 클래스는 아래 그림과 같은 상속 구조를 갖추고 있다.

예외 클래스의 상속 구조

 

모든 예외 클래스는 Throwable 클래스를 상속받는다. 그리고 Exception 클래스는 다양한 자식 클래스를 갖고 있다. 이 클래스는 크게 Checked Exception과 UncheckedException으로 구분할 수 있다.

  Checked Exception Unchecked Exception
처리 여부 반드시 예외 처리 필요 명시적 처리를 강제하지 않음
확인 시점 컴파일 단계 런타임 단계
대표적인 예외 클래스 IOException

SQLException
RuntimeException

NullPointerException

IllegalArgumentException

IndexOutOfBoudException

SystemException

 

Check Exception은 컴파일 단계에서 확인 가능한 예외이다. 이러한 예외는 IDE에서 캐치하여 반드시 예외 처리 하도록 표시해 준다. 반면, Unchecked Exception은 런타임 단계에서 확인되는 예외이다. 즉, 문법상 문제는 없지만 프로그램이 동작하는 도중 예기치 않은 상황이 생겨 발생하는 예외를 의미한다.

간단히 분류하면 RuntimeException을 상속받는 Exception 클래스는 Unchecked Exception이고, 그렇지 않은 Exception 클래스는 Checked Excpetion이다.

 

2.3 예외 처리 방법

 

예외가 발생했을 때 처리하는 방법은 크게 세 가지가 있다.

  • 예외 복구
    • 예외 상황을 파악해서 문제를 해결하는 방식. 대표적으로 try/catch 구문을 사용
  • 예외 처리 회피
    • 예외가 발생한 시점에서 바로 처리하지 않고, 예외가 발생한 메서드를 호출한 곳에서 처리하도록 전가. throw 키워드를 사용하여 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있음
  • 예외 전환
    • 위 두 방식을 적절히 섞은 방식. 예외가 발생했을 때 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달할 필요가 있다. 또는 애플리케이션에서 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping) 해야 하는 경우도 있다. 이런 경우에는 try/catch 방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 된다.

 

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

 

웹 애플리케이션에서는 외부에서 들어오는 요청에 담긴 데이터를 처리하는 경우가 많은데, 이 과정에서 예외가 발생하면 예외를 복구해서 정상적으로 처리하기보다는 요청을 보낸 클라이언트에 어떤 문제가 발생했는지 상황을 전달하는 경우가 많다. 이 때문에 예외 상황을 복구하는 방법보다는 스프링 부트에서 사용하는 예외 처리 방법을 중심으로 알아보자.

 

예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 한다. 이렇게 전달받은 예외를 스프링 부트에서 처리하는 방식으로 크게 두 가지가 있다.

  • @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤의 예외를 처리
    • @ControllerAdvice 대신, @RestControllerAdvice를 사용하면 결괏값을 JSON 형태로 반환할 수 있다.
  • @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// 모든 컨트롤러에 대한 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return new ResponseEntity<>("Invalid input: " + ex.getMessage(), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(value = Exception.class)
    public ResponseEntity<String> handleGeneralException(Exception ex) {
        return new ResponseEntity<>("An error occurred: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SampleController {

    @GetMapping("/example")
    public String exampleEndpoint(@RequestParam String input) {
        if (input.isEmpty()) {
            throw new IllegalArgumentException("Input cannot be empty");
        }
        return "Input is valid: " + input;
    }
}

 

 

GlobalExceptionHandler 클래스는 IllegalArgumentException 및 SampleController와 같은 컨트롤러에서 발생한 예외를 처리한다.

어떤 예외 클래스를 처리할지는 value 속성으로 등록할 수 있다. value 속성은 배열의 형식으로도 전달받을 수 있어, 여러 예외 클래스를 등록할 수도 있다.

@RestControllerAdvice(basePackages = "com.springboot.valid_exception")처럼 예외를 관제하는 범위를 지정할 수도 있다.

만약 컨트롤러 또는 @RestControllerAdvice 클래스 내에 동일한 핸들러 메서드가 선언된 상태라면 좀 더 구체적인 클래스로 지정된 쪽이 우선순위를 갖는다.

예외 타입에 따른 우선순위
핸들러 위치에 따른 우선순위

 

https://docs.oracle.com/en/java/javase/11/docs/api/index.html

 

Overview (Java SE 11 & JDK 11 )

This document is divided into two sections: Java SE The Java Platform, Standard Edition (Java SE) APIs define the core Java platform for general-purpose computing. These APIs are in modules whose names start with java. JDK The Java Development Kit (JDK) AP

docs.oracle.com

다음 URL을 통해 Checked Exception과 Unchecked Excpetion을 구분해서 확인하면 적절한 예외 처리에 도움이 될 것이다.

 

2.5 커스텀 예외

 

애플리케이션의 예외 처리에는 표준 예외만 사용해도 모든 상황을 처리할 수 있다. 그러나 커스텀 예외를 만들어서 사용하면 네이밍에 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다.

또한 커스텀 예외를 사용하면 애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기가 수월해진다. 표준 예외를 상속받은 커스텀 예외들을 개발자가 직접 코드로 관리하기 때문에 책임 소재를 애플리케이션 내부로 가져올 수 있게 된다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 애플리케이션의 모든 컨트롤러에 대한 전역 예외 처리
 * @RestControllerAdvice를 사용하여 REST 컨트롤러에서 발생한 예외를 처리
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * IllegalArgumentException을 처리하고 BAD_REQUEST 상태와 오류 메시지를 반환
     * @param ex IllegalArgumentException 인스턴스
     * @return 오류 메시지와 BAD_REQUEST 상태를 포함하는 ResponseEntity
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return new ResponseEntity<>("유효하지 않은 입력: " + ex.getMessage(), HttpStatus.BAD_REQUEST);
    }

    /**
     * CustomException을 처리하고 BAD_REQUEST 상태와 사용자 정의 오류 메시지를 반환
     * @param ex CustomException 인스턴스
     * @return 오류 메시지와 BAD_REQUEST 상태를 포함하는 ResponseEntity
     */
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<String> handleCustomException(CustomException ex) {
        return new ResponseEntity<>("사용자 정의 오류: " + ex.getMessage(), HttpStatus.BAD_REQUEST);
    }

    /**
     * 일반 예외를 처리하고 INTERNAL_SERVER_ERROR 상태와 오류 메시지를 반환
     * @param ex Exception 인스턴스
     * @return 오류 메시지와 INTERNAL_SERVER_ERROR 상태를 포함하는 ResponseEntity
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleGeneralException(Exception ex) {
        return new ResponseEntity<>("오류가 발생했습니다: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

// 예외 처리를 시연하기 위한 샘플 컨트롤러
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SampleController {

    /**
     * 입력 값을 검증하고 예외를 발생시킬 수 있는 예제 엔드포인트
     * @param input 검증할 입력 파라미터
     * @return 입력이 유효한 경우 성공 메시지
     * @throws IllegalArgumentException 입력이 비어있는 경우
     * @throws CustomException 입력이 "custom"인 경우
     */
    @GetMapping("/example")
    public String exampleEndpoint(@RequestParam String input) {
        if (input.isEmpty()) {
            throw new IllegalArgumentException("입력은 비어 있을 수 없습니다");
        }
        if (input.equals("custom")) {
            throw new CustomException("이것은 사용자 정의 예외입니다");
        }
        return "입력이 유효합니다: " + input;
    }
}

/**
 * 특정 애플리케이션 오류를 나타내기 위한 사용자 정의 예외 클래스
 */
public class CustomException extends RuntimeException {
    /**
     * 지정된 상세 메시지로 새로운 CustomException을 생성
     * @param message 상세 메시지
     */
    public CustomException(String message) {
        super(message);
    }
}

 

위 예제를 보면 사용자 정의 클래스인 CustomException 클래스를 사용하고 있다.

또한, HttpStatus를 커스텀 예외 클래스에 포함시키면 핸들러 안에서 선언해서 사용하는 것이 아닌, 예외 클래스만 전달받으면 그 안에 내용이 포함되는 구조로 설계할 수 있다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return new ResponseEntity<>("유효하지 않은 입력: " + ex.getMessage(), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<String> handleCustomException(CustomException ex) {
        return new ResponseEntity<>("사용자 정의 오류: " + ex.getMessage(), ex.getStatus());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleGeneralException(Exception ex) {
        return new ResponseEntity<>("오류가 발생했습니다: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SampleController {

    @GetMapping("/example")
    public String exampleEndpoint(@RequestParam String input) {
        if (input.isEmpty()) {
            throw new IllegalArgumentException("입력은 비어 있을 수 없습니다");
        }
        if (input.equals("custom")) {
            throw new CustomException("이것은 사용자 정의 예외입니다", HttpStatus.BAD_REQUEST);
        }
        return "입력이 유효합니다: " + input;
    }
}

import org.springframework.http.HttpStatus;

/**
 * HttpStatus를 사용자 정의 예외 클래스에 포함하여 예외와 관련된 HTTP 상태 코드를 지정
 * 이를 통해 예외 처리기에서 상태 코드를 따로 선언하지 않고 예외 클래스 자체에서 상태 코드 정보를 관리
 */
public class CustomException extends RuntimeException {
    private final HttpStatus status;

    public CustomException(String message, HttpStatus status) {
        super(message);
        this.status = status;
    }

    public HttpStatus getStatus() {
        return status;
    }
}

 

예외 발생 시점에 HttpStatus를 정의해서 전달하기 때문에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다.

 

 

 

 

 

출처: 이 글의 출처는 책 '스프링 부트 핵심 가이드'를 참고하여 작성하였습니다.

https://www.yes24.com/Product/Goods/110142898

'Spring' 카테고리의 다른 글

서버 간 통신  (1) 2024.10.31
액추에이터 활용하기  (0) 2024.10.31
연관관계 매핑  (2) 2024.10.17
Spring Data JPA  (7) 2024.10.10
스프링 부트와 ORM  (5) 2024.10.03