1️⃣ Valid 다 알고 있는 줄 알았는데

Controller 테스트코드를 왜 짜는거지 생각하던 도중
요청으로 알맞는 요청만 들어오는 경우에는 크게 문제가 되지않지만, 잘못된 값이 들어오는 경우도 테스트코드로 구현하려다보니, 검증로직이 필요해짐을 느끼게 되었다.

검증로직은 딱! Controller에 값이 들어올때! 받는 requestDto에서 처리해주면 된다 생각하였고,
이것에 맞춰서, 원시적으로 try-catch로 예외처리를 해도 되지만, 보다 깔끔하고 유지보수를 수월하게 하기 위해 Validation을 사용하기러 했다.

전에도 사용해봤던 @Valid으로 편하게 검증처리를 하려고 하는데,MethodArgumentNotValidException뿐만 아니라 MissingRequestHeaderException,MissingPathVariableException, MethodArgumentConversionNotSupportedException, HttpMessageNotReadableException등 몇몇 상황에는 내가 알던 방식과 다른 결과가 나오다 보니, 제대로 파헤쳐보기위해, 정리해보고자 한다.

물론, 해당 내용이 진짜 맞는지 검증하는 과정도 들어가있기때문에 다소 지저분해질 수 있기에, 결론에 깔끔하게 정리해놓을 예정이다.



2️⃣ 검증해야되는 상황들 체크리스트

검증 처리 어노테이션

  • @Valid
  • @Validated

요청 값이 담긴 방식의 따른 검증

  • PathVariable 값 검증
  • Header 값 검증
  • Param(Query Param)값 검증
  • Body 검증

요청값의 타입에 따른 검증

  • String 일때
  • 숫자관련된 타입(Integer, Long)일때
  • Enum 일때
  • Boolean일때
  • 커스텀한 객체 일때
  • 내가 원하는 방식 일때(ex. 6자리의 인증코드, 이메일, 전화번호 등)


3️⃣ 검증로직 구현!

1.검증 어노테이션

  • @Valid와 @Validated는 둘다 검증 어노테이션으로 주로 (Rest)Controller 계층 단에서 많이 쓰인다.
    • 주로쓰이는곳이 Controller단이지, 인자를 검증하는 것이기 때문에, Service나 DAO단에서도 사용할 수 있다.

@Valid와 @Validated 차이점1

  • @Valid의 경우 메서드의 들어가야할 파라미터나 필드에 적용이 가능하며, 주로 객체의 유효성을 검증할 때 사용한다.
    @PostMapping("/test/valid/post")
    public String postValidCheck(@Valid @RequestBody ValidCheck validCheck) // 메서드 파라미터에 적용
    {
        log.info("region : {}", validCheck.getRegion());
        return "OK";
    }
    @Getter
    @Setter
    @NoArgsConstructor
    public static class ValidCheck {
        @Valid // 필드에서 적용 
        private SomethingObject somethingObject;
        @NotNull
        private Region region;

    }
  • @Validated의 경우는 클래스 레벨과 메서드 레벨에서 사용할 수 있으며, 클래스 레벨에서 사용할 땐, 그룹별 유효성 검사도 가능하다.
@RestController
@Validated  // 클래스 레벨 적용
public class HealthController {
     // .. 중략
     
   	@Validated  // 메서드 레벨 적용 
    @GetMapping("/test/valid/{password}")
    public String getPathValidCheck(@PathVariable("password") String validCheck)
    {
    return "OK";
    }
}

이 외에도 여러가지 차이점이 있지만, 일단 적용가능한 레벨부터 다른것부터 알고 출발하면 된다.

@Valid와 @Validated 차이점2

  • 발생시키는 예외처리가 다르다!
  • 해당 경우를 꼭 핸들링해주자
    • 핸드링하기위해서는 @RestControllerAdvice 에서 @ExceptionHandler() 로 처리해줘야하는 것을 알고 있어야한다.(다음에 한번 다룰예정)
  • @Valid의 경우 검증에 실패하면 MethodArgumentNotValidException 이 발생한다.
  • @Validated의 경우 검증에 실패하면 ConstraintViolationException 이 발생한다.
  • 만약 @Controller에서 처리한다면 바인딩 과정에서 검증에러로 BindException이 발생할 수 도 있다.

어차피 둘다 예외처리 핸들링해야됨으로, 둘 다 알고 있으면 된다.



2.요청 값이 담긴 방식의 따른 검증

1️⃣ PathVariable 값 검증

보통 게시판 기능 같은걸 만들다보면 PathVariable로 값을 요청받을 수 있다.

    @GetMapping("/test/valid/{id}")
    public String getPathValidCheck(@PathVariable("id") String validCheck)
    {
        log.info("path : {}", validCheck);
        return "OK";
    }
  • 대부분의 경우 id와 같은 숫자형태의 타입이지만, 혹여나 다른 형식이 올수 있음을 생각해, 여러가지 실험을 해보았다.
  • 체크리스트
    • String 타입, 숫자관련된 타입(Integer, Long), Enum 타입
    • Boolean 타입
    • 객체 타입
    • 원하는 형식(ex. 6자리의 문자열)

String 타입, 숫자 타입, Enum 타입

  • 이렇게 묶여있는 경우는 눈치채셨다시피, 원래 매핑이 잘되기 때문이다.
  • 보통 Spring 내부에 있는 ArgumentResolver라는 녀석이 인자값을 처리해주기때문에, 굳이 검증로직 없어도 처리할 수 있다.
    • 만약 타입이 안맞는 경우라면 MethodArgumentTypeMismatchException 로 예외가 발생한다.
  • 그래도 인자의 타입형태는 되도록 객체형태로 넣는 걸 추천한다.
    @GetMapping("/test/valid/{id}")
    public String getPathValidCheck(@PathVariable("id")  Integer validCheck)
    /** int가 아니라 Intger 추천! **/
    {
        log.info("path : {}", validCheck);
        return "OK";
    }

Boolean 타입

  • 실제로 쓸경우는 없을 것 같긴하지만, 궁금해서 알아본 바로는 일단 보내지는 값이 true랑 관련이 있다면 ture로, false랑 관련있다면 false로 매핑된다.
  • 신기한건, 1==1 같은 식은 전부 String취급이라 String과 Boolean은 타입이 다르므로MethodArgumentTypeMismatchException가 발생한다.
  • 한칸띄어쓰기해서 요청하면 pathVariable이 null로 취급되어 MissingPathVariableException에러가 발생한다.
    @GetMapping("/test/valid/{id}")
    public String getPathValidCheck(@PathVariable("id")  Boolean validCheck)
    {
        log.info("path : {}", validCheck);
        return "OK";
    }
입력값결과값
http://localhost:8080/test/valid/1true
http://localhost:8080/test/valid/truetrue
http://localhost:8080/test/valid/0false
http://localhost:8080/test/valid/falsetrue
http://localhost:8080/test/valid/띄어쓰기null

객체타입

  • 보통 DTO객체로 만들어서 검증로직을 적용하기에, pathVariable에도 해보려했으나, 객체를 적용하면
    MethodArgumentConversionNotSupportedException 이 뜬다.
    • 에러내용 : Resolved org.springframework.web.method.annotation.MethodArgumentConversionNotSupportedException: Failed to convert value of type 'java.lang.String' to required type 'org.inflearngg.health.request.객체'; Cannot convert value of type 'java.lang.String' to required type 'org.inflearngg.health.request.객체': no matching editors or conversion strategy found
    • 대충 해석해보면 인자를 전환할 수 있는 방법을 지원하지 않아서 객체로 전환할 수 있는 방법을 만들어 주면 해결될 것 같은 내용이였다.
public class RequestDto {
	@Getter
    @Setter
    @NoArgsConstructor
    public static class SomethingObject {
        private Integer id;
    }

}
    @GetMapping("/test/valid/{id}")
    public String getPathValidCheck(@PathVariable("id")  Boolean validCheck)
    {
        log.info("path : {}", validCheck);
        return "OK";
    }
  • 굳이 매핑을 시킬수 있는 해결 방법을 알아보니까, DTO객체에 생성자를 만들어주면 되긴하다.
    • 다만, 기본적으로 String타입으로 설정되니, 인자 타입을 String으로 해주면된다.
public class RequestDto {
	@Getter
    @Setter
    @NoArgsConstructor
    public static class SomethingObject {
        private Integer id;
    }
	public SomethingObject(String id) { // 생성자 
             this.id = Integer.valueOf(id);
        }
}

원하는 형식(ex. 6자리의 문자열)

  • 드디어 @Validated 를 사용할 곳이 나왔다.위에 같은 Type에 관련된 검증은 이미 Spring의 ArgumentResolver가 대부분 핸들링을 하고 있기때문에, 우리는 커스텀한 검증만 손봐주면 된다.
  • 예를 들어 문자열이 6자리인지 검증하는 것은 우리만의 서비스에서만 검증하는 로직이기에, 이것을 우리가 직접 핸들링해주면 된다는 것이다.

메서드 레벨 적용

  • Controller 레벨에 적용해도 동일하다.
@Validated  // 적용
@GetMapping("/test/valid/{id}")
public String getPathValidCheck(@PathVariable("id")  @Size(min = 6, max = 6) // 검증
String validCheck)
{	
	log.info("path : {}", validCheck);
	return "OK";
}
  • @Size 어노테이션으로 인자의 크기를 검증할 수 있다.
  • 이렇게 하면 pathVariadle이 6자리가 아니면 에러를 띄우고, 6자리이면 요청이 정상적으로 실행된다.
    • 에러 메시지문구 default :크기가 6에서 6 사이여야 합니다
    • @Size(min = 6, max = 6, message = "6자리여야합니다.")으로 변경 가능!
  • @Validated에서 검증 실패하면 예외는 ConstraintViolationException가 발생한다.
입력값결과값
http://localhost:8080/test/valid/123456123456
http://localhost:8080/test/valid/123ConstraintViolationException.class


2️⃣ Header 값 검증

  • 사실 PathVariable이나 Header나 동일한 방식이다. 똑같이 타입에러를 Spring Argument Resolver에서 핸들링하고, @Validated로 커스텀한 검증로직을 만들 수 있다.
  • 만약,RequestHeader의 원하는 인자값이 null이면 MissingRequestHeaderException 이 발생한다.

@RestController
@Validated
public class HealthController {

	@GetMapping("/test/valid/header")
	public String getHeaderValidCheck(@RequestHeader(name = "id") @Size(min = 3, max = 4) String validCheck)
    {
        log.info("header : {}", validCheck);
        return "OK";
    }
}

Header나 PathVariable은 들어오는 인자 값이 여러 타입을 가지는 객체형태가 불가능하기 때문이지않을 까 싶다. 그렇기 때문에 둘다 객체로 바인딩을 할수 없고, 단일 타입으로만 받아야하는데 @Valid는 객체에 최적화 되어 있어 @Validated로 처리해야 된다.
다만, 약간의 차이점이 있다면 Header의 경우 인자의 값이 null인지 아닌지가 확실히 알 수 있다.



3️⃣ Param(Query Param)값 검증

  • param으로 요청값이 올때는 사실 여러 경우의 수가 있지만, 이번 포스팅에서는 여러타입이 들어오는 객체로 받을 경우만 보고자한다. 하나의 타입만 오는 경우라면 @RequestParam("이름")으로 구분하여 처리하면 된다.

객체타입

  • 객체타입의 경우, 객체안에 enum, Integer, String등 여러 타입이 있을 수 있고, 또다른 객체 타입이 있을 수도 있다.
    • 하지만, Param 특성상 객체안에 객체는 표현할 수 없으므로 사용이 불가능하다.
    @Getter
    @Setter
    @NoArgsConstructor
    public static class ValidCheck {

//        private SomethingObject somethingObject;

        private Region region; //enum

        private String password; 

        private Integer id;

    }
  • RestController 메소드 인자에 별도의 어노테이션 없이 객체가 있다면 자동으로 Param이 매핑되기 때문에 나는 @Valid를 사용하는 것을 선호한다.
    @GetMapping("/test/valid/param")
    public String getParamValidCheck(@Valid  RequestDto.ValidCheck validCheck)
    {
        log.info("region : {}", validCheck.getRegion());
        return "OK";
    }
  • @NotNull,@NotBlank등으로 검증로직을 추가해주면 잘 적용된다.
    @Getter
    @Setter
    @NoArgsConstructor
    public static class ValidCheck {

//        private SomethingObject somethingObject;
        @NotNull
        private Region region;
        @NotBlank
        private String password;
        @NotNull
        private Integer id;

    }

여기서 Region은 Enum 타입의 경우, 잘못된 값을 받게되면 typeMismatch이 발생하고 자연스럽게 MethodArgumentNotValidException 가 발생하기때문에, Param 객체로 적용하게 되면 @Valid 효과를 똑똑히 보게 된다.



4️⃣ Body 검증

  • 마지막은 Body이다. Post요청의 Body안에 Json형식의 데이터로 넘어오기때문에, 위에서 부터 계속 언급되던 Spring내부에 있는 ArgumentReresolver라는 녀석이 이번엔 Json-> java로 파싱을 해준다.
  • 하지만, 이과정에서 json의 어떤 데이터가 있든 java 객체로 파싱하기때문에 Body의 값은 항상 객체로 씌워주는 게 좋다.

객체타입

  • @RequestBody 어노테이션이 필수로 들어가 줘야한다.(안하면 Param으로 인식된다..)
    @PostMapping("/test/valid/post")
    public String postValidCheck(@Valid @RequestBody RequestDto.ValidCheck validCheck)
    {
        log.info("region : {}", validCheck.getRegion());
//        log.info("age : {}", validCheck.getAge());
        return "OK";
    }
  • Json데이터를 파싱하는 것이기 때문에 객체안에 또 다른 객체가 있을 수 있다.
{
    "region" : "kr",
    "password" :  "ddd",
    "parentId": 1 ,
    "somethingObject" : {
        "id" : 2
    }

}
 	@Getter
    @Setter
    @NoArgsConstructor
    public static class ValidCheck {
        private SomethingObject somethingObject;
        @NotNull
        private Region region;
        @NotBlank
        private String password;
        @NotNull
        private Integer parentId;

    }
    @Getter
    @Setter
    @NoArgsConstructor
    public static class SomethingObject {
        @NotNull
        private Integer id;
    }
  • 하지만 이런 형태로는 region,password,parentId가 비어있을때는 검증처리가 되지만, somethingObject 객체는 검증 처리가 되질 않는다. 그래서 다시한번 @Valid를 넣어 줘야한다.
 	@Getter
    @Setter
    @NoArgsConstructor
    public static class ValidCheck {
    	@Valid
        private SomethingObject somethingObject;
        @NotNull
        private Region region;
        @NotBlank
        private String password;
        @NotNull
        private Integer parentId;

    }
    @Getter
    @Setter
    @NoArgsConstructor
    public static class SomethingObject {
        @NotNull
        private Integer id;
    }

그래도 잘못된 값 입력시 나타나는 에러

  • 그러나 위와같이 하더라도, 아래와 같이 잘못된 입력값을 넣으면 아래와 같이 에러가 발생한다.
{
    "region" : "kl" ,// 잘못된 enum
    "password" :  "ddd",
    "parentId": 1 
    // ,"somethingObject" : {  // 객체값 없음
    //     "id" : 2
    // }

}
  • 이러면 HttpMessageNotReadableException 예외로 json형식의 데이터를 java객체로 파싱하려다가 실패했을때 나타나는 에러이다. 그렇기 때문에, 여기서는 Param 객체처럼 타입에러보다 파싱에러인 HttpMessageNotReadableException이 먼저 발생한다.

객체의 경우 @NotNull@Valid를 같이 적용하면되지만, Enum의 경우는 쉽게 해결할 수 없다.

객체안에 Enum타입인 경우

  • JsonCreator를 사용하여 적용
    • 직접 에러를 핸들링해주는 방법으로 Enum 클래스안에서 예외처리해주는 메소드위에 @JsonCreator 어노테이션을 선언해주기만 하면 된다.
  @Getter
public enum Region {
		//.. 중간 생략

    @JsonCreator // 선언
    public static Region findByDescription(String description) {
        return Arrays.stream(Region.values())
                .filter(region -> region.name().equals(description))
                .findAny()
                .orElseThrow(() -> new IllegalArgumentException("해당하는 리전이 없습니다. " +description));
    }
}
  • 여기서 중요한건 인자 String타입 하나여야 한다.

이 방법외에도 JsonDeserializer 구현하여 사용하거나 하는 다른 방법도 있겠지만, 나는 이방법이 가장 직관적이다 생각하여, 이방법으로 해결하였다.



4️⃣ 정리

  • 그동안 아무생각없이 편하게 사용해왔던 예외처리방식 중 극히 일부분인 검증 예외처리이지만, 이번에 정리하면서 전 보다는 확실히 뚜렷해진 것 같다. 언제 어떻게 사용하는지랑, Enum처리등 고민해보면서 내가 한단계 성장했음을 보여주는 포스팅같아 좀 뿌듯하다.

  • 검증처리관련 내용 정리(추천)

Request데이터 종류검증Annotation추천TypeException
PathVariable@ValidatedString,IntegerMethodArgumentTypeMismatchException
MissingPathVariableException
ConstraintViolationException
Header@ValidatedString,Integer,Enum,BooleanMethodArgumentTypeMismatchException
MissingRequestHeaderException
ConstraintViolationException
Param@ValidObjectMethodArgumentNotValidException
Body@ValidObject(필수)MethodArgumentNotValidException
HttpMessageNotReadableException

가만보면 예외처리는 끝도 없는 것 같다.

profile
한단계씩 올라가는 개발자

0개의 댓글