기록
[Spring Boot] 9. Validation, Exception 본문

엔코아 플레이데이터(Encore Playdata) Backend 2기 백엔드 개발 부트캠프 (playdata.io)
백엔드 개발 부트캠프
백엔드 기초부터 배포까지! 매력있는 백엔드 개발자 포트폴리오를 완성하여 취업하세요.
playdata.io
1. Bean Validation
계층별로 진행하는 유효성 검사는 검증 로직이 각 클래스 별로 분산되어있어 관리하기가 어렵다.
그리고 검증 로직에 의외로 중복이 많아 여러곳에 유사한 기능의 코드가 존재할 수 있다. 또한, 검증 해야할 값이 많다면 검증 코드가 길어진다.
이러한 문제 때문에 Bean Validation이라는 데이터 유효성 검사 프레임워크를 사용한다.
Bean Validation 을 사용한다는 것은, 유효성 검사를 위한 로직을 DTO와 같은 도메인 모델과 묶어서, 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미이다.
* 클라이언트 검증, 서버 검증
1. 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
2. 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
3. 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.
4. API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
2. @Valid
@Valid는 자바에서 지원하는 어노테이션으로, 기본적인 유효성 검사를 수행해준다.
1. Controller
/**
* @Valid : 자바에서 지원하는 어노테이션
* 유효성 검사를 수행한다.
*/
@PostMapping("/valid")
public ResponseEntity<String> checkValidationByValid(
@Valid
@RequestBody ValidRequestDto validRequestDto) {
log.info("validRequestDto : {}", validRequestDto);
return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
}
2. ValidRequestDto
@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class ValidRequestDto {
/* null, "", " " 을 허용하지 않는다. */
@NotBlank
String name;
/* 이메일 형식을 검사한다. "" 는 허용한다. */
@Email
String email;
/* 정규식을 검사한다. */
@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
String phoneNumber;
/* min 이상의 값을 허용한다. max 이하의 값을 허용한다. */
@Min(value = 20)
@Max(value = 40)
int age;
/* min 이상 max 이하의 범위를 허용한다. */
@Size(min = 0, max = 40)
String description;
/* 양수를 허용한다. */
@Positive
int count;
/* true 인지 체크한다. null 값은 체크하지 않는다. */
@AssertTrue
boolean booleanCheck;
}
3. @Validated
@Validated는 스프링에서 제공하는 어노테이션으로,
특정 그룹을 지정하지 않는 경우, groups 속성을 설정하지 않은 필드에 대해서만 유효성 검사를 수행한다.
반면 특정 그룹을 지정한 경우, 지정한 그룹으로 설정 된 필드에 대해서만 유효성 검사를 수행한다.
1. Controller Class
/**
* @Validated : 스프링에서 제공하는 어노테이션
* 특정 그룹을 지정하지 않는 경우, groups 속성을 설정하지 않은 필드에 대해서만 유효성 검사를 수행한다.
* 특정 그룹을 지정한 경우, 지정한 그룹으로 설정 된 필드에 대해서만 유효성 검사를 수행한다.
*/
@PostMapping("/validated")
public ResponseEntity<String> checkValidation(
@Validated
@RequestBody ValidatedRequestDto validatedRequestDto) {
log.info("validatedRequestDto : {}", validatedRequestDto);
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group1")
public ResponseEntity<String> checkValidation1(
@Validated(ValidationGroup1.class)
@RequestBody ValidatedRequestDto validatedRequestDto) {
log.info("validatedRequestDto : {}", validatedRequestDto);
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group2")
public ResponseEntity<String> checkValidation2(
@Validated(ValidationGroup2.class)
@RequestBody ValidatedRequestDto validatedRequestDto) {
log.info("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) {
log.info("validatedRequestDto : {}", validatedRequestDto);
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
2. ValidatedRequestDto Class
@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class ValidatedRequestDto {
/**
* @Null : null 만 허용
* @NotNull : null 을 허용하지 않는다. "", " " 는 허용한다.
* @NotEmpty : null, "" 를 허용하지 않는다. " " 는 허용한다.
* @NotBlank : null, "", " " 을 허용하지 않는다.
*/
@NotBlank
private String name;
/**
* @Email : 이메일 형식을 검사
* "" 는 허용한다.
*/
@Email
private String email;
/**
* @Pattern(regexp = "$expression") : 정규식을 검사한다.
* @Telephone : custom annotation
*/
// @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
@Telephone
private String phoneNumber;
/**
* 최대값, 최소값 검증
* @Min : min 이상의 값을 허용한다.
* @Max : max 이하의 값을 허용한다.
* 별다른 기능이 없는 마커 인터페이스 사용, 오직 그룹화 목적으로 사용
* Controller 에서 @Validated 사용 시, 해당 인터페이스를 함께 등록해야 유효성 검사를 수행한다.
*/
@Min(value = 20, groups = ValidationGroup1.class)
@Max(value = 40, groups = ValidationGroup1.class)
private int age;
/**
* @Size : 문자열 길이 검증
* min 이상 max 이하의 범위를 허용한다.
*/
@Size(min = 0, max = 40)
private String description;
/**
* @Positive : 양수를 허용
* @Negative : 음수를 허용
* Controller 에서 @Validated 사용 시, 해당 인터페이스를 함께 등록해야 유효성 검사를 수행한다.
*/
@Positive(groups = ValidationGroup2.class)
private int count;
/**
* @AssertTrue : true 인지 체크한다. null 값은 체크하지 않는다.
* @AssertFalse : false 인지 체크한다. null 값은 체크하지 않는다.
*/
@AssertTrue
private boolean booleanCheck;
/**
* 자릿 수 범위 검증
* @Digits(integer = $number1, fraction = $number2)
* $number1 의 정수 자릿수와 $number2 의 소수 자릿수를 허용
*/
}
3. ValidationGroup1, ValidationGroup2 Interface
public interface ValidationGroup1 {
}
public interface ValidationGroup2 {
}
4. Custom Validation
실무에서는 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 써야할 때도 있다.
이 경우 ConstraintValidator 와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있다.
대표적으로 동일한 정규식을 계속 쓰는 @Pattern 어노테이션의 경우가 가장 흔한 사례이다.
1. TelephoneValidator Class
public class TelephoneValidator implements ConstraintValidator<Telephone, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value != null ? value.matches("01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$") : false;
}
}
2. Telephone Annotation
/**
* @Target : 어노테이션을 어디서 선언할 수 있는지 정의
* PACKAGE, TYPE, CONSTRUCTOR, METHOD, PARAMETER ...
*/
/**
* @Retention : 어노테이션이 실제로 적용되고 유지되는 범위를 의미
* RUNTIME : 컴파일 이후에도 JVM 에 의해 계속 참조
* CLASS : 컴파일러가 클래스를 참조할 때 까지 유지
* SOURCE : 컴파일러 전까지만 유지. 컴파일 이후에는 사라진다.
*/
/**
* @Constraint : TelephoneValidator 와 매핑
* message() : 유효성 검사가 실패할 경우 반환되는 메세지
* groups() : 유효성 검사가 사용하는 그룹으로 설정
* payload() : 사용자가 추가 정보를 위해 전달하는 값
*
* 현재 groups() 와 payload() 는 정의하지 않았다.
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
String message() default "전화번호 형식이 일치하지 않습니다.";
Class[] groups() default {};
Class[] payload() default {};
}
3. 사용 예
@Telephone
private String phoneNumber;
5. 예외 처리
자바에서는 오류를 보통 try - catch - throw 구문을 활용해 처리한다. 이를 스프링부트에서는 더 편리하게 처리할 수 있는 기능을 제공한다.
1. 예외 처리 방법
1. 예외 복구
예외 상황을 파악해서 문제를 해결하는 방식으로, try - catch 구문이 있다.
2. 예외 처리 회피
예외가 발생한 시점에 처리하는게 아니라, 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게
전가하는 방식으로 throw 키워드를 사용한다.
3. 예외 전환
1, 2번을 적절히 섞은 방식으로 try - catch 방식을 사용하며 catch 단에서 throw 키워드를 사용해
다른 예외 타입으로 전달하면 된다. 해당 방식은 커스텀 예외를 만드는 과정에서 사용된다.
2. 스프링부트 예외 처리
웹 어플리케이션에서는 외부에서 들어오는 요청에 담긴 데이터를 처리하는 경우가 많다.
그 과정에서 예외가 발생하면, 예외를 복구해서 정상으로 처리하기 보다는 요청을 보낸 클라이언트에 어떤 문제가 발생했는지 상황을 전달하는 경우가 많다.
때문에 예외가 발생했을 때 클라이언트에 오류 메세지를 전달하려면,
각 레이어에서 발생한 예외를 엔드포인트 레벨인 Controller로 전달해야한다. 이렇게 전달받은 예외를 스프링부트에서 처리하는 방식으로 크게 두가지가 있다.
1. @ControllerAdvice와 @ExceptionHandler 를 통해 모든 컨트롤러의 예외 처리
2. @ExceptionHandler를 통해 특정 컨트롤러의 예외 처리
* 이때 @RestControllerAdvice를 사용하면 결과값을 JSON 형태로 반환할 수 있다.
6. @RestControllerAdvice
@RestControllerAdvice는 스프링에서 제공하는 어노테이션으로, @RestController에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능을 수행한다.
1. CustomExceptionHandler Class
/**
* @RestControllerAdvice
* @RestController 에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능
* basePackages 옵션을 통해 예외를 처리하는 범위를 지정할 수 있다.
*/
@Slf4j
@RestControllerAdvice(basePackages = "com.springboot.validexception")
public class CustomExceptionHandler {
/**
* @ExceptionHandler
* @Controller 나 @RestController 가 적용 된 빈에서 발생하는 예외를 처리하는 메서드를 만들 때 사용
* value : 어떤 에러가 발생했을 때 해당 메서드를 동작시킬지 설정
* 현재 예제에서는 RuntimeException 이 발생했을 경우 해당 메서드가 동작한다.
*/
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handlerException(RuntimeException e, HttpServletRequest request) {
/* 클라이언트에게 보낼 오류 응답메세지 설정 */
HttpHeaders httpHeaders = new HttpHeaders();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
log.error("Advice 내 handlerException 호출 : {}, {}", 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, httpHeaders, httpStatus);
}
}
2. ExceptionController Class
@Slf4j
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping
public void getRuntimeException() {
throw new RuntimeException("getRuntimeException Controller 호출");
}
/**
* 컨트롤러 클래스 내에 @ExceptionHandler 를 사용하면 해당 클래스에 국한해 예외 처리 가능
* 글로벌 설정보다 우선순위를 가진다.
*/
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handlerException(RuntimeException e, HttpServletRequest request) {
/* 클라이언트에게 보낼 오류 응답메세지 설정 */
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
log.error("클래스 내 handlerException 호출 : {}, {}", 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, httpHeaders, httpStatus);
}
}
7. Custom Exception
어플리케이션을 개발하다보면 점점 예외로 처리할 영역이 늘어나고, 예외 상황이 다양해지며 사용하는 예외 타입도 많아진다.
대부분 상황에서 자바에서 제공하는 표준 예외(Standard Exception) 을 사용하면 해결되지만, 예외를 커스텀하여 만들어서 사용할 수도 있다.
1. 커스텀 예외를 사용했을 때 장점
1. 이름을 지을 때 개발자의 의도를 담을 수 있기 때문에 이름만으로도 예외 상황을 짐작할 수 있다.
반면, 표준 예외는 해당 예외 타입의 이름만으로 이해하기 어려워 예외 메세지를 상세하게 작성해야하는 경우가 있다.
2. 어플리케이션에서 발생하는 예외를 개발자가 직접 관리하기 편해진다.
표준 예외를 상속받은 커스텀 예외들을 개발자가 직접 코드로 관리, 동일한 예외 상황이 발생할 경우
한 곳에서 처리하며 특정 상황에 맞는 예외 코드를 적용할 수 있게 된다.
3. 예외 상황에 대한 처리가 용이해진다.
표준 예외를 사용하면 의도하지 않은 상황에서도 정해진 예외 처리 코드가 동작하기 때문에
어디서 문제가 발생했는지 확인하기가 어렵다. 반면, 커스텀 예외로 관리하면 의도하지 않았던 부분에서 발생한 예외는
개발자가 관리하는 예외 처리 코드가 처리하지 않으므로 개발 과정에서 혼동할 여지가 줄어든다.
2. 커스텀 예외 클래스 생성에 필요한 것
1. 에러 타입(error type) : HttpStatus의 reasonPhase
2. 에러 코드(error code) : HttpStatus의 value
3. 메세지(message) : 상황별 상세 메세지
8. Custom Exception 예
1. Constants Class
/**
* 열거형(enum) 클래스를 별도로 만들어도 되지만, 확장성을 위해
* Constants 라는 상수들을 통합 관리하는 클래스 내부에 enum 클래스 생성
*/
public class Constants {
/**
* 커스텀 예외 클래스에서 어떤 도메인에서 문제가 발생했는지 보여주는데 사용
*/
public enum ExceptionClass {
PRODUCT("Product");
private String exceptionClass;
ExceptionClass(String exceptionClass) {
this.exceptionClass = exceptionClass;
}
public String getExceptionClass() {
return exceptionClass;
}
@Override
public String toString() {
return getExceptionClass() + " Exception. ";
}
}
}
2. CustomException Class
/**
* @ControllerAdvice 와 @ExceptionHandler 의 무분별한 예외 처리를 방지하기 위한 커스텀 예외 클래스
* 1. 에러 타입 : HttpStatus.reasonPhrase()
* 2. 에러 코드 : HttpStatus.value()
* 3. 메세지 : 상황별 상세 메세지
*
* 클래스 구조
* 1. 커스텀 예외는 예외가 발생하는 상황에 해당하는 상위 클래스를 상속받는다.
* 2. CustomException - Exception - Throwable
* 3. Exception 클래스는 Throwable 클래스의 생성자 호출, message 변수 값을 detailMessage 로 전달 받는다. (getter)
* 4. HttpStatus 를 커스텀 예외 클래스에 포함
* 5. getHttpStatusCode() : HttpStatus 의 value (1xx, 2xx, 3xx, 4xx, 5xx)
* 6. getHttpStatusType() : HttpStatus 의 reasonPhrase (OK, Found, Not Found ... )
* 7. getHttpStatus() : HttpStatus Getter
*/
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 httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
3. CusteomException 을 처리하는 handleException() 메서드
/**
* @RestControllerAdvice
* @RestController 에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능
* basePackages 옵션을 통해 예외를 처리하는 범위를 지정할 수 있다.
*/
@Slf4j
@RestControllerAdvice(basePackages = "com.springboot.validexception")
public class CustomExceptionHandler {
/**
* 커스텀 예외 처리 메서드
* 파라미터를 RuntimeException 에서 CustomException 으로 변경
*/
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<Map<String, String>> handlerException(CustomException e, HttpServletRequest request) {
/* 클라이언트에게 보낼 오류 응답메세지 설정 */
HttpHeaders httpHeaders = new HttpHeaders();
log.error("Advice 내 handlerException 호출 : {}, {}", 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, httpHeaders, e.getHttpStatus());
}
}
4. Exception Controller Class
@Slf4j
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping("/custom")
public void getCustomException() throws CustomException {
throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException Method 호출");
}
}
9. 44일차 후기
컨트롤러의 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다. 유효성 검사는 단순히 생각해도 회원가입 등 입력 양식에 많이 들어가기 때문에, 직접 유효성 검사 코드를 넣어 구현해볼 필요가 있다고 생각한다.
또한, 예외 처리는 예외가 발생했을 경우 클라이언트에게 알아보기 쉬운 오류 페이지를 제공하기 위해서 꼭 알고있어야 한다 생각한다. 해당 부분을 전부 직접 만든다면 어려움이 있을 것 같지만, 스프링부트에서 제공해주는 페이지를 활용해보면 어떤 식으로 예외를 처리를 할 수 있는지 습득할 수 있다는 생각이 들었다.
'Bootcamp > Spring Boot' 카테고리의 다른 글
| [Spring Boot] 11. 서버 간 통신 (0) | 2023.09.13 |
|---|---|
| [Spring Boot] 10. Actuator (0) | 2023.09.12 |
| [Spring Boot] 8. Thymeleaf (0) | 2023.09.11 |
| [Spring Boot] 6. Spring Data JPA (0) | 2023.09.11 |
| [Spring Boot] 5. 상속 관계 매핑 (0) | 2023.09.11 |