본문 바로가기
JAVA/요점정리

스프링 부트 유효성 검증 예외처리하는 방법(jakarta validation)

by 진짠 2024. 6. 12.
728x90

시작

SI 회사에 재직할때부터 유효성 검증하는 방법은 꽤 자주 바뀌었다. 자바 jsp를 주로 사용해서 그냥 jsp에서 모두 처리를 하고 백엔드에서 처리해야할 로직이 있을 경우에만 service단에서 처리하기도 했다. 나중에는 컨트롤러와 jsp에서 더블체킹하기도 했고 dto의 어노테이션을 사용하여 컨트롤러에서 BindResult 객체를 사용하여 처리를 하기도 했다.

사이드 프로젝트를 진행하며 이에 대해 고민하다 나름대로 길을 정하여 그 과정을 포스팅해보고자 한다.

Service 처리

재직 초반에 사용했던 방법이다. 로그인 API를 예로 들자면, 아이디나 패스워드가 공백은 아닌지, 패스워드 정규식을 만족하는지 등의 체크사항들이 있을 것이다. 그중 프론트엔드에서 처리 가능한 로직은 jsp단에서 전부 체크한다.

if(xxx.util.isEmpty(__userId)) {
		alert("아이디를 입력해 주세요.");
		$("#admin-id").focus();
		return false;
}
	
if(xxx.util.isEmpty(__password)) {
	alert("패스워드를 입력해 주세요.");
	$("#admin-password").focus();
	return false;
}

다음과 같은 방법이다.('xxx.util.isEmpty'는 회사 내에서 사용하는 유틸 함수의 이름을 조금 바꾼 것이다.)

adminInfo = loginDao.selectAdminInfo(subParam);
 if(adminInfo == null) {
      throw new BaseException("로그인에 실패하였습니다. 관리자에게 문의하세요.");
      

서비스 단에서는 백엔드 로직이 필요한 경우의 유효성 검증을 한다. 다음과 같이 아이디와 비밀번호로 DB에서 사용자 정보를 조회했는데 값이 없을 경우 사용자 정의 Exception을 통해 메시지를 던지는 식이다.

다른 로직 처리를 할 필요도 없고 해당 페이지에서 체크해야 하는 사항들만 만들면 되니 처음에 개발할때는 간단하다.

하지만 위 방법은 문제점이 있다. 먼저, 프론트엔드에서만 유효성을 검증하는 부분이 있다. 이렇게 모든 API의 유효성 검증을 진행하면 악의적인 사용자가 자바스크립 소스를 조작하여 API를 호출하면 백엔드에서는 유효성을 검증하지 않기 때문에 보안 취약점이 드러날 수 있다. 그 외에도 서버에 잘못된 데이터를 보내니 서버 부하 증가, 데이터 무결성등 여러 위험이 존재한다.

그래서 일단 프론트와 백엔드에서 기본적인 유효성 검증은 모두 체크한다는 전제를 세웠다.

service 처리2

그래서 기본적인 유효성 처리를 전부 서비스 단에다 몰아버렸다.

@Override
    public Response login(LoginDto loginDto) throws Exception{
        loginEmptyCheck(loginDto);
        validatePasswordCheck(loginDto);
        UserVo userInfo = findUserInfoByUserIdAndUserPw(loginDto);
        TokenVo tokenInfo = generateTokenInfo(loginDto);
        LoginVo loginInfo = new LoginVo(userInfo, tokenInfo);
        return new Response("success", MessageUtil.LOGIN_SUCCESS, loginInfo);
    }

당시의 코드를 복기하여 구현해보았다.

유효성 검증 결과 에러가 나타나면 

public class LoginException extends RuntimeException {
    public LoginException(String message) {
        super(message);
    }
}

다음과 같이 사용자 정의 예외처리를 만들었다. 해당 메시지는 글로벌 예외 처리 클래스인 GlobalExceptionHandler에서

@ExceptionHandler(LoginException.class)
    public ResponseEntity<ErrorResponse> handleLoginExceptions(LoginException exception) {
        ErrorResponse response = new ErrorResponse(FAIL_VALUE, HttpStatus.BAD_REQUEST.value(), exception.getMessage());
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

이렇게 ResponseEntity객체로 넘겨 프론트엔드에서 확인이 가능하도록 던졌다.

일단 전역처리를 했기 때문에 유효성 검증 로직을 구현하고 예외 객체에다가 메시지만 넣어서 던지면 되기 때문에 간단해졌다.

그리고 모든 유효성 검증하는 부분이 서비스단에 들어가 있으니 일단 유효성 검증 부분은 이곳만 뒤지면 돼서 유지보수도 편리할 것 같다.

하지만 조금만 더 생각해봐도 착각이라는 사실을 알 수 있다. 로그인 API니까 이정도이지 회원가입 API만 해도 유효성 검증 로직이 쌓이기 시작할 거고 다른 비즈니스 로직까지 처리해야하는 service단을 보기가 힘들어질 것 같다는 생각이 들었다.

그리고 애초에 유효성을 검증하는 부분은 컨트롤러에서 진행한다는 말을 듣고 컨트롤러로 옮겼다.

controller, service 처리

이렇게 나누었지만 뭔가 소스를 보기가 좀 그렇다. 서비스 단에서 유효성을 검증하는 부분이 전부 컨트롤러로 옮겨진 것 뿐이었다. 컨트롤러의 크기가 늘어날텐데 뭔가 더 보기쉽고 간단하게 볼 수 없을까라는 생각이 들었다.

dto annotaition처리

dto의 어노테이션을 통해 유효성 검증이 가능하다. jakarta.validation에서 정의된 @NotBlank, @Size 어노테이션도 있지만 직접 정의하여 어노테이션을 만들 수 있었다.

@PasswordConfirmValidate(passwordField = "userPw", confirmPasswordField = "userPwConfirm")
public record SignupDto(
        @NotBlank(message = MessageUtil.BLANK_ID)
        @Size(min = 4, max = 15, message = MessageUtil.INVALID_LENGTH_ID)
        String userId,

        @NotBlank(message = MessageUtil.BLANK_PASSWORD)
        @Size(min = 8, max = 15, message = MessageUtil.INVALID_LENGTH_PASSWORD)
        @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$",
                message = MessageUtil.INVALID_PASSWORD)
        String userPw,

        @NotBlank(message = MessageUtil.BLANK_PASSWORD_CONFIRM)
        String userPwConfirm,

        @NotBlank(message = MessageUtil.BLANK_PHONENUMBER)
        @Pattern(regexp = "^01(?:0|1|[6-9])-(?:\\d{4}|\\d{3})-\\d{4}$",
                message = MessageUtil.INVALID_PHONENUMBER)
        String phoneNumber
) {}

간단한 회원가입 API의 데이터 전달 객체인 DTO이다. 자바 레코드로 생성하고 어노테이션을 생성했다. @NotBlank는 빈 값 체크, size는 최대, 최소값이고 @Pattern은 비밀번호와 휴대폰번호가 올바른 형식인지를 체크하는 부분이다.(지금 보니 정규식부분도 static 변수로 바꿔야 할 것 같다.)

메시지는 MessageUtil 클래스를 만들어 정리해두었다. 이곳에서 어노테이션으로 유효성을 검증한 부분은 컨트롤러에서 bindResult 객체로 받아 예외 처리를 할 수도 있고 따로 글로벌 예외 처리 부분에서 처리할 수도 있다.

컨트롤러에 일일히 bindResult객체로 처리해주는 것보단 예외 객체를 만드는게 좋을 것 같다는 생각이 들었다.

@ControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    private static final String FAIL_VALUE = "failed";

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException exception) {
        String errorMessage = exception.getBindingResult().getAllErrors().get(0).getDefaultMessage();
        ErrorResponse response = new ErrorResponse(FAIL_VALUE, HttpStatus.BAD_REQUEST.value(), errorMessage);
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
    
    ...

@ControllerAdvice는 어플리케이션을 실행하는 최상위 객체가 (@SpringBootApplication 어노테이션이 있는 부분) 인식할 수 있게 하위 패키지 안에 들어있어야 한다.

@ExceptionHandler(MethodArgumentNotValidException.class)

해당 어노테이션으로 유효성 검증 에러가 발생할 시 이곳에서 처리한다는 사실을 명시할 수 있다. 어노테이션으로 검증한 부분은 MethodArgumentNotValidException 이 객체로 받는다.

MethodArgumentNotValidException: org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.treeze.response.Response com.example.treeze.task.login.LoginController.postSignup(com.example.treeze.dto.login.SignupDto) throws java.lang.Exception with 2 errors: [Field error in object 'signupDto' on field 'userPw': rejected value [123]; codes [Size.signupDto.userPw,Size.userPw,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [signupDto.userPw,userPw]; arguments []; default message [userPw],15,8]; default message [비밀번호는 최소 8자 이상, 최대 20자 이하입니다.]] [Field error in object 'signupDto' on field 'userPw': rejected value [123]; codes [Pattern.signupDto.userPw,Pattern.userPw,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [signupDto.userPw,userPw]; arguments []; default message [userPw],[Ljakarta.validation.constraints.Pattern!%?&])[A-Za-z\d@$!%?&]{8,}$]; default message [비밀번호는 대,소문자, 숫자, 특수문자를 포함한 8자 이상이어야 합니다.]]

 

로그로 찍어보니 다음과 같이 나오고 있다. signupDto의 userPw에서 거부당했다, 그 오류에 대한 메시지와 조건들은 이것이다, 이 정도를 알 수 있는 듯 하다.

에러메시지는 다른 웹페이지처럼 한개만 출력되면 되리라 생각해서 get(0).getDefaultMessage(); 다음과 같이 첫번째 값만 가져왔다.

이렇게 하면 컨트롤러에서 별도의 처리를 하지 않아도 이곳에서 모든 유효성 검증에 대한 예외처리를 할 수 있었다.

하지만 한가지 해결해야할 부분이 남았다.

비밀번호, 비밀번호 확인 텍스트를 비교해서 두 값이 다르면 예외 메시지를 던져야 하는데 이것은 정의된 어노테이션으로 할 수가 없었다. 하지만 어노테이션을 만들어서 사용하는 방법이 있었다.

사용자 정의 어노테이션

@Constraint(validatedBy = PasswordConfrimValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordConfirmValidate {
    String message() default "비밀번호 확인값이 서로 다릅니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String passwordField();
    String confirmPasswordField();
}

@Constaint를 통해 유효성 검증 로직을 수행할 파일을 지정한다.
@Target을 통해 이 어노테이션은 클래스나 인터페이스에 적용한다는 것을 명시하고
@Retention은 해당 어노테이션이 런타임 단계에서 적용되어야 한다는 것을 명시한다.

그리고 오류 메시지, 적용되는 필드명을 지정해준다.(비밀번호, 비밀번호 확인 필드).

메시지 값 MessageUtil에다 안넣었네

public class PasswordConfrimValidator implements ConstraintValidator<PasswordConfirmValidate, Object> {
    private String passwordField;
    private String confirmPasswordField;

    @Override
    public void initialize(PasswordConfirmValidate constraintAnnotation) {
        this.passwordField = constraintAnnotation.passwordField();
        this.confirmPasswordField = constraintAnnotation.confirmPasswordField();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            Object userPassword = new BeanWrapperImpl(value).getPropertyValue(passwordField);
            Object userPasswordConfirm = new BeanWrapperImpl(value).getPropertyValue(confirmPasswordField);
            return userPassword.equals(userPasswordConfirm);
        } catch (Exception e) {
            return false;
        }
    }
}

인터페이스의 구현 부분이다. 

public interface ConstraintValidator<A extends Annotation, T> {
    default void initialize(A constraintAnnotation) {
    }

    boolean isValid(T var1, ConstraintValidatorContext var2);
}

ConstraintValidator는 jakarta.validation에서 제공하는 인터페이스이다. 

이름으로 유추할 수 있듯이 초기화하는 부분과 어노테이션 구현부분으로 이루어져 있다.

public class PasswordConfrimValidator implements ConstraintValidator<PasswordConfirmValidate, Object> {
    private String passwordField;
    private String confirmPasswordField;

    @Override
    public void initialize(PasswordConfirmValidate constraintAnnotation) {
        this.passwordField = constraintAnnotation.passwordField();
        this.confirmPasswordField = constraintAnnotation.confirmPasswordField();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            Object userPassword = new BeanWrapperImpl(value).getPropertyValue(passwordField);
            Object userPasswordConfirm = new BeanWrapperImpl(value).getPropertyValue(confirmPasswordField);
            return userPassword.equals(userPasswordConfirm);
        } catch (Exception e) {
            return false;
        }
    }
}

나는 다음과 같이 구현하였다. 로직 자체는 equals  체크만 하면 되니 참으로 간단하다. 내가 좀 애를 먹은 부분은 userPassword와 userPasswordConfirm에 대한 값을 어떻게 가져오느냐였다. Object로 가져오면

'SignUpDto[data="data1", data2="data2"...]' 이런식으로 값이 출력된다. 객체를 애초에 Object로 안받고 signupDto로 받으면 쉽게 뺄 수 있긴 한데 이러면 캡슐화가 깨지고 결합도가 높아져 다른 곳에서 사용에 제약이 있을듯 했다. 로그인, 회원가입API는 잘만 만들어놓으면 다른 프로젝트에서도 얼마든지 그대로 쓸 수 있기 때문에 꼭 Object객체로 받고 싶었다.

다행히 BeanWrapperImpl로 자바 빈 객체의 프로퍼티 값을 가져올 수 있었다.

해당 어노테이션은

'@PasswordConfirmValidate(passwordField = "userPw", confirmPasswordField = "userPwConfirm")' 다음과 같이 비밀번호와 비밀번호 확인 필드만 잡아주면 유효성 검증을 수행할 수 있을 것이다.

여기까지가 현재 예외처리에 대한 진행사항이다. 개발을 하다가 변경사항이 생기면 추가로 포스팅할 것 같다.

728x90

댓글