https://github.com/JH-Keem/Spring-Web-Utils/tree/main/Spring_Validation

 

사용자가 입력한 데이터의 검증을 위해, 스프링 프레임워크가 제공하는 Validation 기능을 이용해 봅니다.

큰 흐름은 다음과 같습니다.

 

1. 기본설정 : 유저 입력 데이터를 커맨드객체로 받는다.

2. Validation 적용하기 : 커맨드객체에 Validation 어노테이션을 사용해 각 필드에 대해 유효성 검사를 실시한다.

3. 예외 처리하기 : 검사를 통과하지못해 예외발생시 해당 예외를 처리할 로직을 구현한다.

4. 추가기능 : 사용자편의를 위해 몇가지 로직을 추가 구현한다(예: 검증 그룹 등)


1. 기본 설정

아주 간단하게 JSON 응답을 하는 컨트롤러를 먼저 만들어 보겠습니다.

@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(CommandVO commandVO){
        Map<String, Object> response = new HashMap<>();

	//	사용자가 입력한 데이터를 출력해봅니다.
        System.out.println(commandVO.toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

우선, 회원가입이라고 가정하고 Post HTTP 메서드를 이용해 요청을 받기로 하겠습니다.

사용자가 입력하는 데이터를 CommandVO 객체로 매핑해 파라미터 주입받는 모습입니다.

CommandVO 객체는 아래와 같이 만들었습니다.

 

@Data
public class CommandVO {

    private String username; 	//	사용자 아이디
    private String password; 	//	사용자 비밀번호
    private String email;	//	사용자 이메일
    
}

사용자는 파라미터로 username, password, email을 입력한다고 가정해보겠습니다.

 

사용자가 각 필드를 입력해 POST 요청을 보내면 아래와 같이 콘솔창에 사용자 입력 데이터가 잘 출력되는 모습을 볼 수 있습니다.


1. Validation 적용하기

Spring Validation을 객체에 적용하기 위해서는 파라미터 주입받는 객체 앞에 @Valid 혹은 @Validated 어노테이션을 사용하면 됩니다. 두 어노테이션의 차이는 다음과 같습니다.

특징 @Valid @Validated
제공 패키지 javax.validation org.springframework.validation
검증 대상 객체의 필드 수준 검증 클래스/메서드 수준 검증
검증 그룹 지원 지원 X 지원(Validation Groups)
중첩 객체 검증 지원(@Valid로 지원된 필드) 중첩 객체 검증 불가
사용 위치 주로 컨트롤러 계층 서비스 계층 및 메서드 매개변수

 

이 게시글에서는, 더 다양한 검증 그룹의 사용을 위해 @Validated 어노테이션을 사용합니다.

검증 그룹 지원이란 개발자가 커스터마이징해 유효성 검증할 그룹들을 나누어서 필요한 때에 필요한 그룹에 대한 검증만 가능토록 하는것을 지원한다는 이야기입니다.

더보기

예) 게시글 객체가 '글 제목, 작성자, 글 내용' 세 필드로 작성되었다고 가정해봅니다.

글 작성시에는 글 제목, 작성자, 글 내용이 필요하므로 세 필드에 대해 'Write.class'라고 임의로 이름지어놓고,

글 수정시에는 글 제목과 내용에만 추가로 'Edit.class'라고 임의로 이름 짓습니다.

 

나중에 글 작성 컨트롤러의 파라미터 에서는 게시글 객체에 'Write.class'그룹에 대한 검증만 실행되도록 하고, 글 수정 컨트롤러의 파라미터에서는 'Edit.class'그룹에 대한 검증만 실행하도록 할 수 있습니다.

우선 컨트롤러 메서드에 @Validated 어노테이션을 추가하고, CommandVO 객체에 간단한 유효성검증 어노테이션을 추가하겠습니다.

//	Controller 메서드에 @Validated 어노테이션 추가
@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

//	CommandVO 각 필드에 @NotNull 어노테이션 추가
@Data
public class CommandVO {

    @NotBlank(message = "아이디를 입력해주세요.")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

}

 

이후 같은 방식으로 Postman을 이용해 아무것도 입력하지 않은 데이터로 요청을 보내면

콘솔창에 MethodArgumentNotValidException 예외가 발생했다고 출력됩니다.

 

이제 이 예외를 처리해서 사용자에게 메시지를 응답해보도록 하겠습니다.

 

유효성 검사를 실시할 필드에 사용할 수 있는 어노테이션은 다음과 같은 것들이 있습니다.

@NotNull 필드값이 null이 아니어야 함.
@Null 필드값이 null 이어야 함.
@NotEmpty 문자열, 컬렉션, 맵, 배열 등이 null이 아니고 빈 상태가 아니어야 함.
@NotBlank 문자열이 null이 아니고, 공백이 아닌 문자 하나 이상을 포함해야 함.
(””, “ “) 불가능
@Size(min=, max=) 문자열, 컬렉션, 맵, 배열 등이 주어진 범위 내에 있어야 함.
@Min(value) 숫자가 지정한 최소값 이상 이어야함.
@Max(value) 숫자가 지정한 최대값 이하 이어야 함.
@Positive 숫자가 양수여야 함.
@PositiveOrZero 숫자가 양수거나 0이어야 함.
@Negative 숫자가 음수여야 함.
@NegativeOrZero 숫자가 음수거나 0이어야 함.
@Email 문자열이 이메일 형식이어야 함.
@Digits(Integer=, fraction=) 숫자가 지정된 자릿수를 넘지 않아야 함.
(Integer = 정수부분 최대 자릿수, fraction = 소수부분 최대 자릿수)
@Pattern(regexp=) 문자열이 정규식에 매치되어야 함.

2. 예외 처리하기

발생한 유효성검증 예외를 처리하는 방법은 여러가지가 있지만, 대표적으로 2가지가 있습니다.

전역 예외 처리 핸들러를 사용하거나, Error 객체를 사용해 처리하는 방식입니다.

 

먼저 전역 예외 처리 핸들러를 사용하는 방법을 알아보겠습니다.

간단하게 ExceptionHandler.java 클래스를 다음과 같이 구현합니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<?> handleValidationException(Exception ex){
    	// 사용자에게 응답할 객체
        Map<String, Object> response = new HashMap<>();
        
        // 예외를 처리할 BindingResult객체(아래 설명 참조)
        BindingResult bindingResult = null;
        if(ex instanceof MethodArgumentNotValidException){
             bindingResult = ((MethodArgumentNotValidException)ex).getBindingResult();
        }

        response.put("message", bindingResult.getFieldError());
        return ResponseEntity.ok().body(response);
    }
}

@ControllerAdvice 어노테이션은 모든 @Controller 어노테이션이 할당된 클래스에서 발생하는 예외를 처리해주는 어노테이션 입니다. 비슷한 어노테이션으로는 @RestControllerAdvice 등이 있습니다.

 

메서드에 작성된 @ExceptionHandler({예외클래스}) 메서드는 파라미터에 작성된 예외를 처리하겠음을 명시합니다.

앞서 유효성검사 과정에서 발생된 예외가 MethodArgumentNotValidException 예외였으므로 이 예외를 작성해줍니다.

 

메서드의 파라미터에서는 해당 예외를 처리할 수 있도록 Exception 객체를 주입받습니다.

 

우리가 받는 사용자 데이터는 Spring의 데이터 바인딩 과정을 통해 사용자 데이터 -> CommandVO 객체로 변환됩니다.

예외는 이 과정에서 발생하므로 데이터 바인딩중 발생한 에러를 다루기 위해 BindingResult 객체가 필요한데, BidningResult 객체는 Exception객체 하위의 상세 예외객체에서 가져올 수 있으므로, 이 과정을 위해 예외가 정확히 어떤 예외인지 형변환을 실시해야 합니다.

현재 이 예시에서는 MethodArgumentNotValidException을 다루므로, 해당 예외로 형변환 합니다.

 

발생한 필드에러를 곧장 응답하게되면 아래와 같은 응답을 받아볼 수 있습니다.

{
    "message": {
        "objectName": "commandVO",
        "field": "email",
        "rejectedValue": null,
        "codes": [
            "NotBlank.commandVO.email",
            "NotBlank.email",
            "NotBlank"
        ],
        "arguments": [
            {
                "codes": [
                    "commandVO.email",
                    "email"
                ],
                "arguments": null,
                "defaultMessage": "email",
                "code": "email"
            }
        ],
        "defaultMessage": "이메일을 입력해주세요.",
        "bindingFailure": false,
        "code": "NotNull"
    }
}

보통, 유효성검사를 진행하고 이로인한 안내메시지를 필요로 하는 경우에, 사용자경험을 위해 아이디 → 비밀번호 → 이메일 순으로 메시지를 보내고 싶은데 현재의 예시처럼 진행하면 세 에러메시지가 랜덤으로 응답되게 됩니다.

 

이를 해결하는 방법으로는 CommandVO 내부에 원하는 순서대로 정렬한 필드 이름의 배열을 반환하는 메서드를 만들고, 해당 배열의 필드 이름 순서와 error객체의 getField() 메서드를 통해 반환받은 순서를 비교해 정렬하면 됩니다.

@Data
public class CommandVO {

    @NotBlank(message = "아이디를 입력해주세요.")
    private String username;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

    public List<String> getFieldOrder() {
        return List.of(
                "username",
                "password",
                "email"
        );
    }
}

CommandVO 내부에 필드 이름을 순서대로 가지고있는 배열을 반환하는 메서드를 만들었습니다.

 

 

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<?> handleValidationException(Exception ex){
        Map<String, Object> response = new HashMap<>();

        BindingResult bindingResult = null;
        if(ex instanceof MethodArgumentNotValidException){
             bindingResult = ((MethodArgumentNotValidException)ex).getBindingResult();
        }

        Object target = bindingResult.getTarget();
        List<String> fieldOrder = List.of();

        List<FieldError> fieldErrors = new ArrayList<>(bindingResult.getFieldErrors());

        final List<String> finalFieldOrder = fieldOrder;
        fieldErrors.sort(Comparator.comparingInt(error -> {
            int index = finalFieldOrder.indexOf(error.getField());
            return index != -1 ? index : Integer.MAX_VALUE;
        }));

        FieldError firstError = fieldErrors.stream().findFirst().orElse(null);

        if (firstError != null) {
            return ResponseEntity.ok().body(Map.of("message", firstError.getDefaultMessage()));
        }

        return ResponseEntity.ok().body(Map.of("message", "요청이 유효하지 않습니다."));
    }
}

CommandVO의 필드순서 리스트를 가져와 람다식을 이용해 순서를 정렬합니다.

1. fieldError 리스트에 있는 원소 error 객체에서 getField() 메서드를 통해 필드명을 반환받습니다.

2. 각 필드명이 CommandVO에서 작성한 필드순서의 몇 번째 위치에 있는지 확인합니다.

3. 해당 필드명이 CommandVO의 필드순서 리스트에 있다면 해당 위치의 인덱스값을 반환합니다.

4. 없다면 Integer.MAX_VALUE를 반환해 최하위로 정렬합니다.

 

정렬된 fieldErrors 리스트에서 첫번째 error를 가져와 해당 메시지를 응답합니다.

 

이렇게 만들면 아이디 → 비밀번호 → 이메일 순으로 사용자에게 안내메시지를 전달할 수 있습니다.

 

순서대로 처리해야 하는 상황이 아니라 에러메시지를 모아 한번에 전달하려면 어떻게 할까요 ?

전역 예외 처리 핸들러가 아니라 Error 객체를 이용해 처리해보도록 하겠습니다.

 

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO, Errors errors){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());
        System.out.println(errors.getFieldErrors().toString());

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

컨트롤러의 파라미터로 Errors 객체를 주입받는 것으로 '이 메서드에서 발생한 예외를 직접 처리하겠음'을 명시하게 됩니다.

 

errors.getFieldErrors().toString() 메서드로 예외를 출력해보면, 전역 예외 처리 핸들러를 사용한것과 같은 에러문구가 출력됩니다.

 

Errors 객체에는 어떤 필드에서 오류가 났는지 어떤 메시지를 예외로 출력하는지 모두 가지고 있으므로 다음과 같이 처리할 수 있습니다.

@RestController
@RequestMapping("/")
public class DefaultController {

    @PostMapping("")
    public ResponseEntity<Map<String, Object>> signUpController(@Validated CommandVO commandVO, Errors errors){
        Map<String, Object> response = new HashMap<>();

        System.out.println(commandVO.toString());
        System.out.println(errors.getFieldErrors().toString());

	// 검증결과를 담을 validatorResult 해쉬맵 객체 생성
        Map<String, String> validatorResult = new HashMap<>();
        
        // 에러가 난 필드를 Key로, 에러 메시지를 Value로 삽입
        for(FieldError error : errors.getFieldErrors()) {
            String validKeyName = String.format("%sErrorMsg", error.getField());
            validatorResult.put(validKeyName, error.getDefaultMessage());
        }
        
        // 이후 개발자의 의도에 맞게 처리

        response.put("message", "Hello World!");
        return ResponseEntity.ok().body(response);
    }

}

validatorResult를 응답에 포함시켜 프론트에서 적절히 처리하도록 합시다.

 

유효성 검사, Validation (2) 편에서는 CommandVO에 NotNull 외에 다른 어노테이션을 여러개 동시에 붙이면서, 해당 어노테이션들의 검증 순서를 관리하는 방법에 대해 작성하겠습니다.

'Java > Spring Framework' 카테고리의 다른 글

설정파일을 이용한 환경 변수 설정  (3) 2025.01.13
유효성 검사, Validation (2)  (7) 2025.01.03

+ Recent posts