본문 바로가기

Projects/Monkey Penthouse

에러 표준화 과정 1 - Spring Validator

기능 구현에 급급하다보니 계속 신경이 쓰였던 부분 중에 하나였던 예외 처리를 리팩토링 해보려고 한다.

현재 상황

  • 각각의 예외 처리가 일관적이지 못하고 일부 케이스는 그냥 서버 내부 에러 500으로 처리되고 있음
  • 클라이언트의 잘못된 input 값에 대한 에러 처리에 일관성이 없음
  • 컨트롤러와 서비스에 try/catch 문이 너무 많아 코드의 가독성을 떨어트림
  • Runtime Exception과 Exception 구별 없이 마구 사용되고 있음
  • 컨트롤러 뿐만 아니라 dispatcher sevlet 전에 위치하는 security 관련 filter에서 발생하는 에러들도 일관적으로 처리되지 못하고 있음

목표

  • 클라이언트 요청 처리에서 발생할 수 있는 예상가능한 예외들을 모두 표준화하여 일관적으로 처리하고, 예상할 수 없는 상황에 대해서만 500으로 처리
  • Spring validator를 이용하여 input 값에 대해 400 에러를 보내주며 어떤 값이 잘못되었는 지에 대한 정보를 보내줌
  • 서비스에서는 Exception을 던지고 이를 Exception Handler에서 일괄적으로 catch하여 처리함으로써 기존의 controller에서는 try/catch 문을 모두 삭제하고 성공 케이스에 대해서만 처리
  • 예상 가능한 상황에 대해서 사용하고 있는 Runtime Exception을 모두 삭제하고, Custom Exception 혹은 표준화된 Exception을 던져 효율적으로 에러 처리를 할 수 있도록 하고 코드의 가독성도 높임
  • 인증/인가 관련 에러들도 가능한 케이스들에 대하여 401 에러를 보내고 모두 일관적으로 처리

위의 목표를 달성하기 위해

1. Spring validator를 사용하여 모든 요청에 클라이언트가 보내줄 데이터의 형식 조건을 추가 및 검증

2. Controller 전역에서 동작할 Exception Handler를 작성

3. 필요할 경우 Custom Exception을 작성하여 여러 요청에서 공통적으로 보이는 예외를 일관적으로 처리


Spring validator

가장 먼저 spring-boot-starter-web에서 기본적으로 제공되는 @valid를 사용하여 클라이언트에서 보내주는 input 값들을 검증하고, 부적절한 값이 왔을 시에는 곧바로 400 에러를 보내줄 수 있도록 했다.

 

클라이언트와의 데이터 교환 객체가 되는 DTO의 필드들에 대하여 validator에서 제공해주는 다양한 애노테이션을 활용하여 각 데이터들에 해당하는 조건들을 붙였다.

	public static class SignupReqDTO extends UserDTO {

        @NotBlank(message = "이름은 필수 입력값입니다.")
        @Pattern(regexp = "^[가-힣|A-Za-z|1-9]{1,10}$")
        private String name;

        @JsonFormat(pattern = "yyyy.MM.dd")
        @NotNull(message = "생일은 필수 입력값입니다.")
        private LocalDate birth;

        // 0: 여성, 1: 남성
        @Max(value = 1)
        @Min(value = 0)
        @NotNull(message = "성별은 필수 입력값입니다.")
        private int gender;

        @NotEmpty(message = "이메일은 필수 입력값입니다.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식에 맞지 않습니다.")
        private String email;
		...

이렇게 해주고 Controller의 파라미터에서 @Valid를 붙이면 자동적으로 DTO 필드에 붙인 조건들에 의해 데이터가 검증이 되고 조건에 부합하지 않는 데이터인 경우에는 MethodArgumentNotValidException을 던진다.

 

@Valid 뿐만 아니라 @Validated을 사용하면 제약 조건을 그룹화하여 사용할 수 있지만 그 정도는 필요할 것 같지 않아서 @valid를 사용했다.

@Valid와 @Validated의 차이
 

[Spring] @Valid, @Validated를 이용한 데이터 유효성 검증

들어가기에 앞서 request 후에 서버측에서 데이터를 바인딩할때, 데이터가 유효한지(ex. 누락, 최대 크기 초과 등) 검사해야 하는 경우가 있을 수 있다. 그럴 때는 @Valid 또는 @Validated 어노테이션을

velog.io

ExceptionHandler에서 MethodArgumentNotValidException을 처리할 때에는 getBindingResult().getFieldErrors()를 호출하여 검증 실패한 필드들을 꺼내어 400 에러 응답과 함께 보냈다. 

{
    "statusCode": 400,
    "responseMessage": "정보가 유효하지 않습니다. (형식을 벗어난 값)",
    "data": [
        "email"
    ]
}

고민이었던 부분 - Enum, LocalDateTime 타입 필드에 대한 valid 검증

내가 사용하는 DTO에는 Enum 타입과 LocalDateTime 타입의 필드들이 포함되어 있었다.

해당 필드에 대해서 특별히 Custom Validator를 정의하여 일괄적으로 처리해주는 방법도 존재했다. 

DateTime 타입에 Validation 적용하기 
Enum 타입에 validation 적용하기

그런데 이것도 일단은 이미 DateTime이나 Enum 타입에 맞게 들어온 데이터에 한해서 프로그래머가 원하는 시간대나 Enum 클래스를 검증하는 용도로만 사용할 수 있었다.

만약 클라이언트가 아예 DateTime이나 Enum 형식에 맞지 않는 데이터를 보내주었을 때에는 valid 검증을 하기도 전에 Json 파싱 에러가 발생하여 검증을 할 수가 없었다. 

나는 알맞은 형식으로 들어온 데이터에 대해서 추가적인 검증을 하기보다는 애초에 맞지 않는 형식의 데이터에 대하여 처리를 하고자 했기 때문에 이것을 사용할 수가 없었다.

 

그래서 Custom Validator를 정의하는 대신 여기서 발생할 만한 Json 파싱 관련 Exception을 처리해주기로 하였다.

 

그러나 여전히 남은 문제가 있었는데 Json 파싱 에러와 invalid 에러 발생 시점이 달라서 Json 파싱 에러가 발생하였을 때에는 다른 필드들의 valid 여부를 확인할 수 없었다는 점이었다.

 

이 문제를 해결하기 위해서는 DTO에서 Json 파싱 에러가 나지 않도록 필드를 String 타입으로 바꾸고 validation 처리를 하는 방법이 있었다. 그러나 이때에는 DAO <-> DTO 변환 시에 String <-> Enum/LocalDateTime 변환해야 하는 수고로움이 발생했다.

 

일단 client에서 반드시 invalid한 데이터를 보내줘야한다는 요구사항이 없었고, 순전히 내 욕심으로 구현하고 싶었던 기능이기에 이 방법을 사용하지는 않고 Json 파싱 에러와 Invalid 에러를 분리하여 응답을 보내는 것으로 만족했다.