글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.
애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요하다.
이것을 유효성 검사 또는 데이터 검증이라고 부른다.
유효성 검사(validation)은 프로그래밍에서 매우 중요한 부분이며,
자바에서 가장 신경 써야 하는 것 중 하나로 NullPointException 예외가 있다.
일반적인 애플리케이션 유효성 검사의 문제점
- 계층별로 진행하는 유효성 검사는 검증 로직이 각 클래스별로 분산돼 있어 관리가 어렵다.
- 검증 로직에 의외로 중복이 많아 여러 곳에 유사한 기능의 코드가 존재할 수 있다.
- 검증해야 할 값이 많다면 검증하는 코드가 길어진다.
- 이러한 문제로 코드가 복잡해지고 가독성이 떨어진다.
위의 문제를 해결하기 위해 자바 진영에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공
Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공
Bean Validation을 사용한다는 것은 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미
어노테이션을 사용한 검증 방식이기 때문에 코드의 간결함 유지 가능
Hibernate Validator
- Bean Validation 명세의 구현체
- 스프링 부트에서는 Hibernate Validator를 유효성 검사 표준으로 채택해서 사용
- 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와준다.
스프링 부트용 유효성 검사 관련 의존성 추가
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
스프링 부트의 유효성 검사
- 유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시
- 부트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 유효성 검사를 DTO 객체를 대상으로 수행하는 것이 일반적
// ValidationRequestDto 클래스
import com.springboot.valid_exception.config.annotation.Telephone;
import com.springboot.valid_exception.data.group.ValidationGroup1;
import com.springboot.valid_exception.data.group.ValidationGroup2;
import lombok.*;
import javax.validation.constraints.*;
@Data
@NoArgsConstructor
@AllArgsConstructor
@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})$")
//@Telephone(groups = ValidationGroup2.class)
//@Telephone
private String phoneNumber;
@Min(value = 20, groups = ValidationGroup1.class)
@Max(value = 40, groups = ValidationGroup1.class)
//@Min(value = 20)
//@Max(value = 40)
private int age;
@Size(min = 0, max = 40)
private String description;
@Positive(groups = ValidationGroup2.class)
//@Positive
private int count;
@AssertTrue
private boolean booleanCheck;
}
// ValidationController 클래스
import com.springboot.valid_exception.data.dto.ValidRequestDto;
import javax.validation.Valid;
import com.springboot.valid_exception.data.dto.ValidatedRequestDto;
import com.springboot.valid_exception.data.group.ValidationGroup1;
import com.springboot.valid_exception.data.group.ValidationGroup2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/validation")
public class ValidationController {
private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);
@PostMapping("/valid")
public ResponseEntity<String> checkValidationByValid(
@Valid @RequestBody ValidRequestDto validRequestDto) {
LOGGER.info(validRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
}
@PostMapping("/validated")
public ResponseEntity<String> checkValidation(
@Validated @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group1")
public ResponseEntity<String> checkValidation1(
@Validated(ValidationGroup1.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group2")
public ResponseEntity<String> checkValidation2(
@Validated(ValidationGroup2.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/all-group")
public ResponseEntity<String> checkValidation3(
@Validated({ValidationGroup1.class,
ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
}
대표적인 유효성 검사를 위한 어노테이션
- 문자열 검증
- @Null : null값만 허용
- @NotNull : null 허용x
- @NotEmpty : null, “” 허용 x
- @NotBlank : null, “”, “ “ 허용x
- 최댓값/최솟값 검증
- BigDecimal, BigInteger, int, long 등의 타입을 지원
- @DecimalMax(value = “$numberString”) : $numberString보다 작은 값을 허용
- @DecimalMin(value = “$numberString”) : $numberString보다 큰 값 허용
- @Min(value = $number) $number 이상의 값을 허용
- @Max(vlaue = $number) $number 이하의 값을 허
- 값의 범위 검증
- BigDecimal, BigInteger, int, long 등의 타입을 지원
- @Positive : 양수를 허용
- @PositiveOrZero : 0을 포함한 양수 허용
- @Negative : 음수 허용
- @NegativeOrZero : 0을 포함한 음수 허용
- 시간에 대한 검증
- Date, LocalDate, LocalDateTime 등의 타입 지원
- @Future : 현재보다 미래의 날짜 허용
- @FutureOrPresent : 현재를 포함한 미래의 날짜 허용
- @Post : 현재보다 과거의 날짜 허용
- @PostOrPresent : 현재 포함한 과거의 날짜 허용
- 이메일 검증
- @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.Pattern 패키지의 컨벤션을 따름
정규식(Regular Expression)?
특정한 규칙을 가진 문자열 집합을 표현하기 위해 쓰이는 형식
전화번호, 주민번호, 이메일과 같이 특정 형식의 값을 검증해야 할 때가 있다.
이러한 값은 정규식을 통해 쉽게 검증 가능
@Validate활용
- @Valid 어노테이션은 자바에서 지원하는 어노테이션
- 스프링도 @Validated라는 별도의 어노테이션으로 유효성 검사를 지원
- @Validated는 @Valid 어노테이션의 기능을 포함하고 있기 때문에 @Validated로 변경 가능
- 또한 @Validated는 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.
- @Validated : 어노테이션에 특정 그룹을 설정하지 않은 경우 gruops가 설정되지 않은 필드에 대해 유효성 검사를 수행
- @Validated 어노테이션에 특정 그룹을 설정하는 경우 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행
커스텀 Validation 추가
- 실무에서는 유효성 검사를 할 때 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 써야 할때도 있다.
- 이 경우 ConstraintValidator와 커스텀 어노테이션을 조합해 별도의 유효성 검사 어노테이션 생성 가능
- 전화번호 형식이 일치하는지 확인하는 간단한 유효성 검사 어노테이션
package com.springboot.valid_exception.config.annotation;
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})$");
}
}
package com.springboot.valid_exception.config.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
String message() default "전화번호 형식이 일치하지 않습니다.";
Class[] groups() default {};
Class[] payload() default {};
}
@Target 어노테이션
- 이 어노테이션을 어디서 선언할 수 있는지 정의하는데 사용
ElementType.PACKAGE |
ElementType.TYPE |
ElementType.CONSTRUCTOR |
ElementType.FILED |
ElementType.METHOD |
ElementType.ANNOTATION_TYPE |
ElementType.LOCAL_VARIABLE |
ElementType.PARAMETER |
ElementType.TYPE_PARAMETER |
ElementType.TYPE_USE |
@Retention
- 이 이노테이션이 실제로 적용되고 유지되는 범위를 의미
RetentionPolicy.RUNTIME | 컴파일 이후에도 JVM에 의해 계속 참조. 리플렉션이나 로깅에 많이 사용되는 정책 |
RetentionPolicy.CLASS | 컴파일러가 클래스를 참조할 때까지 유지 |
RetentionPolicy.SOURCE | 컴파일 전까지만 유지. 컴파일 이후 사라짐 |
예외 처리
애플리케이션을 개발할 때는 불파기하게 많은 오류가 발생하게 된다.
자바에서는 이러한 오류를 try-catch, throw 구문을 활용해 처리
스프링 부트에서는 더욱 편리하게 예외를 처리할 수 있는 기능을 제공
스프링 부트에서 적용할 수 있는 예외 처리 방식에 대해 학습
예외와 에러
- 예외(exception) : 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 의미,
예외는 개발자가 직접처리할 수 있는 것이므로 미리 코드 설계를 통해 처리 가능 - 에러(error) : 에러는 주로 자바의 가상머신에서 발생시키는 것으로 예외와 달리 애플리케이션에서 코드로 처리할 수 있는 것이 거의 없다.
대표적인 예로 메모리부족(OutOfMemory), 스택 오버플로(StackOverFlow)등이 있다.
에러는 발생 시점에 처리하는 것이 아닌 미리 애플리케이션의 코드를 살펴보면서
문제가 발생하지 않도록 예방해서 원천적으로 차단해야 한다.
예외 클래스
Checked Exception과 UnChecked Exception
- Checked Exception : 컴파일 단계에서 확인 가능한 예외 상황, 이러한 예외는 IDE에서 캐치해서 반드시 예외 처리를 할 수 있게 표시 해준다.
- Uncheck Exception : 런타임 단계에서 확인되는 예외 상황, 문법상 문제는 없지만 프로그램 동작 중 예기치 않은 상황이 생겨 발생하는 예
- 간단히 분류하자면 RuntimeException을 상속받은 Exception 클래스는 Unchecked Exception이고 그렇지 않은 Exception 클래스는 Checked Exception
예외 처리 방법
예외가 발생 시 처리하는 방법은 크게 3가지가 있다.
- 예외 복구
- 예외 상황을 파악해서 문제를 해결하는 방식 대표적인 방법이 try-catch
- 예외 처리 회피
- 예외가 발생한 시점에서 바로 처리하는 것이 아닌 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식, 이때 throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있다.
- 예외 전환
- 앞의 두 방식을 적절하게 섞은 방식, 예외가 발생 시 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달할 필요가 있다. 또는 애플리케이션에서 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping)해야 하는 경우도 있다. 이런 경우 try-catch방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 된다.
스프링 부트의 예외 처리 식
- @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
- @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
@ControllerAdvice 대신 @RestControllerAdvice를 사용하면 결괏값을 JSON 형태로 반환할 수 있다.
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice //(basePackages = "com.springboot.valid_exception")
public class CustomExceptionHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", e.getMessage());
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleException(MethodArgumentNotValidException 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());
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<Map<String, String>> handleException(CustomException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
e.getMessage());
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());
}
}
import com.springboot.valid_exception.common.Constants.ExceptionClass;
import com.springboot.valid_exception.common.exception.CustomException;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/exception")
public class ExceptionController {
private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);
@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;
LOGGER.error("클래스 내 handleException 호출, {}, {}", request.getRequestURI(),
e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", e.getMessage());
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
@GetMapping("/custom")
public void getCustomException() throws CustomException {
throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출");
}
}
커스텀 예외
- 커스텀 예외 클래스 생성에 필요한 내용
- 에러 타입(error type) : HttpStatus의 reasonPharse
- 에러 코드(error code) : HttpStatus의 value
- 메세지 (message) : 상황별 상세 메세지
public class Exception Throwable {
static final long serialVersionUID = -3387516993124229948L;
public Exception() {
super();
}
public Exception(String message) {
super(message);
}
public Exception(String message, Throwable cause) {
super(message, cause);
}
public Exception(Throwable) {
super(cause);
}
protected Exception(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
package com.springboot.valid_exception.common.exception;
import com.springboot.valid_exception.common.Constants;
import org.springframework.http.HttpStatus;
public class CustomException extends Exception{
private static final long serialVersionUID = 4300333310379239987L;
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 httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
package com.springboot.valid_exception.common.exception;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice //(basePackages = "com.springboot.valid_exception")
public class CustomExceptionHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", e.getMessage());
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleException(MethodArgumentNotValidException 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());
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<Map<String, String>> handleException(CustomException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
e.getMessage());
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());
}
}
'북스터디 > 스프링 부트 핵심 가이드' 카테고리의 다른 글
Chapter 09. 연관관계 매핑 (0) | 2023.06.21 |
---|---|
Chapter 08. Spring Data JPA 활용 (0) | 2023.06.11 |
Chapter 06. 데이터베이스 연동 (3) | 2023.06.11 |
Chapter 05. API를 작성하는 다양한 방법 (0) | 2023.05.27 |
Chapter 04. 스프링 부트 애플리케이션 개발하기 (0) | 2023.05.27 |