본문 바로가기
공부/Spring

[Spring] 회원관리 연습 웹 프로젝트 - 2 / 스프링 폼, 스프링 유효성 @Valid

by thegreatjy 2023. 12. 30.
728x90

사용자 정의 애너테이션 : 이메일 중복 확인

  1. 오류 메세지 추가
  • 사용자정의애너테이션이름.커맨드객체이름.필드이름 = 오류메세지
MemberValid.member.email = \uC774\uBBF8 \uC874\uC7AC\uD558\uB294 \uC774\uBA54\uC77C \uC8FC\uC18C\uC785\uB2C8\uB2E4.
  1. 사용자 정의 애너테이션 생성
  • @interface를 만듦
    • message, groups(), payload() 속성이 필수적이다.
      • message : 오류 발생 시, 반환되는 기본 메세지
      • groups : 특정 유효성 검사를 그룹으로 설정한다.
      • payload : 사용자가 추가한 정보를 전달한다.
    • @Retention(RetentionPolicy.속성값)
      • Runtime : 런타임 할 때도 .class 파일에 유지. 주로 사용됨.
    • @Target : 필드, 메서드, 클래스 등 타겟 애너테이션을 작성
@Constraint(validatedBy = MemberDTOEmailValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MemberValid {
    String message() default "";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}
  • 객체에 제약 사항 애너테이션 선언
import kr.co.chunjae.validator.MemberValid;
import javax.validation.constraints.*;
import org.hibernate.validator.constraints.NotEmpty;

public class MemberDTO {
    @MemberValid // 사용자 정의 애너테이션
    @Pattern(regexp = "^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "{Pattern.member.email}")
    @NotEmpty
    private String email;
}
  • 사용자 정의 애너테이션은 JSR-380과 같이 사용 가능하다. 둘다 자바에서 지원하는 유효성 검사이기 때문이다.
  • 이때, String 변수의 @NotEmpty는 hibernate 것을 사용한다. > 오류가 났음…
  1. ConstraintValidator 인터페이스 구현체 생성
  • javax.validation.ConstraintValidator 인터페이스 구현
  • initialize(), isValid() 메서드를 구현해야 한다.
  • initialize() : 사용자 정의 애너테이션 정보를 읽음
@Component
@Log4j
public class MemberDTOEmailValidator implements ConstraintValidator<MemberValid, String> {
    @Autowired
    private MemberService memberService;

    public void initialize(MemberValid constraintAnnotation){ //@MemberValid 정보 초기화 메서드
    }

    // 유효성 검사 메서드
    public boolean isValid(String value, ConstraintValidatorContext context){
        boolean result = true;

        try{
            result = memberService.searchEmail(value);
        }catch (MemberDTOEmailException e){
            return true;
        }

        if(result){ // value값을 가진 이메일이 이미 존재한다
            return false;
        }else{
            return true;
        }
    }
}
  • ConstraintValidator<사용자 정의 애너테이션 이름, 도메인 클래스의 멤버 변수 타입>
  • isValid(도메인 클래스의 멤버 변수 타입, ConstraintValidatorContext)
  1. 컨트롤러의 요청 처리 메서드의 파라미터에 @Valid
  2. 뷰 폼 페이지에서 form:errors로 오류 메세지 출력

Validator 인터페이스 유효성 검사

  1. Validator 인터페이스의 구현체 생성
  2. @InitBinder를 선언한 메서드를 컨트롤러에 추가
  3. @Valid로 유효성 검사
  4. form:errors 태그로 오류 메세지 출력
  • 도메인 객체에 JSR-380 애너테이션이 존재하면 안 된다.
package kr.co.chunjae.domain;

import kr.co.chunjae.validator.MemberValid;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.validation.constraints.*;
import org.hibernate.validator.constraints.NotEmpty;

@Getter
@Setter
@ToString
public class MemberDTO {
    /*@MemberValid // 사용자 정의 애너테이션
    @Pattern(regexp = "^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "{Pattern.member.email}")
    @NotEmpty
    private String email;
    @Size(min=1, max=50, message = "1자 이상 50자 이하를 입력해 주세요.")
    private String password;
    @Size(min=1, max=30)
    private String name;
    @Min(value=1)
    private int age;
    @Size(max=30)
    private String mobile;*/
    private String email;
    private String password;
    private String name;
    private int age;
    private String mobile;
}
  1. Validator 인터페이스 구현
    • supports(Class<?> clazz) : 해당 객체에 유효성 검사를 시행할 수 있는지 확인
    • validate(Object, Errors) : 해당 객체에 유효성 검사를 실행하고 오류 발생 시, 오류 관련 정보를 Errors 객체에 저장.
      • errors.rejectValue(”커맨드 객체의 필드명”, “메세지”)
package kr.co.chunjae.validator;

@Component
@RequiredArgsConstructor
public class MemberDTOEmailValidator implements Validator {
    private final MemberService memberService;

    @Override
    public boolean supports(Class<?> clazz) {
        return MemberDTO.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        MemberDTO memberDTO = (MemberDTO) target;

        // 이메일 존재 여부 확인
        if (memberService.searchEmail(memberDTO.getEmail())) { // 이메일이 이미 존재한다
            errors.rejectValue("email", "email.not.available");
        }

    }
}
  • message 리소스 파일
email.not.available = \uC774\uBBF8 \uC874\uC7AC\uD558\uB294 \uC774\uBA54\uC77C \uC8FC\uC18C\uC785\uB2C8\uB2E4.
  1. 컨트롤러에 @InitBinder로 validator를 추가한다.
@AutoWired
private MemberDTOValidator memberDTOValidator;

@InitBinder
    public void initBinder(WebDataBinder binder){
        binder.setValidator(memberDTOValidator);
    }
  1. 컨트롤러 요청 처리 메서드의 파라미터 앞에 @Valid
@PostMapping("/save")
    public String save(@Valid @ModelAttribute("member") MemberDTO memberDTO, Errors errors, Model model){
        // 유효성 검사 오류 결과 확인
        if(errors.hasErrors()){
            return "save";
        }
}
  • 모델 맵핑 전에 @Valid로 유효성 검사 실행됨.
  • Errors 객체에 오류 관련 정보가 저장됨.
  1. 폼 페이지에 form:errors로 오류 메세지 출력
<p>아이디 : <form:input path="email" name="email" /> <form:errors path="email"/></p>

=> 이렇게하면 Validator 인터페이스 구현 객체의 유효성만 검사되고 JSR-380 애너테이션 유효성 검사는 무시된다.

JSR-380과 Validator 인터페이스 동시에 사용하기

  1. 도메인 객체 JSR-380 애너테이션 선언
import kr.co.chunjae.validator.MemberValid;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.validation.constraints.*;
import org.hibernate.validator.constraints.NotEmpty;

@Getter
@Setter
@ToString
public class MemberDTO {
    @MemberValid // 사용자 정의 애너테이션 - 이메일 중복 검사
    @Pattern(regexp = "^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "{Pattern.member.email}")
    @NotEmpty
    private String email;
    @Size(min=1, max=50, message = "1자 이상 50자 이하를 입력해 주세요.")
    private String password;
    @Size(min=1, max=30)
    private String name;
    @Min(value=1)
    private int age;
    @Size(max=30)
    private String mobile;
}
  1. 사용자 정의 애너테이션 생성 : 이메일 중복 검사
    • @interface 생성
    • ConstraintsValidator 구현
      • initialize()
      • isValid()
  • @interface
import javax.validation.Constraint;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// 이메일 중복 검사 사용자 애너테이션
@Constraint(validatedBy = MemberDTOEmailValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MemberValid {
    String message() default "{MemberValid.member.email}";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}
  • ConstraintsValidator 인터페이스 구현
    • @interface의 @Constraint(validatedBy = MemberDTOEmailValidator.class)
import kr.co.chunjae.exception.MemberDTOEmailException;
import kr.co.chunjae.service.MemberService;
import lombok.extern.log4j.Log4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

@Component
@Log4j
public class MemberDTOEmailValidator implements ConstraintValidator<MemberValid, String> {
    @Autowired
    private MemberService memberService;

    public void initialize(MemberValid constraintAnnotation){ //@MemberValid 정보 초기화 메서드
    }

    // 유효성 검사 메서드
    public boolean isValid(String value, ConstraintValidatorContext context){
        boolean result = true;

        try{
            result = memberService.searchEmail(value);
        }catch (MemberDTOEmailException e){
            return true;
        }

        if(result){ // value값을 가진 이메일이 이미 존재한다
            return false;
        }else{
            return true;
        }
    }
}
  1. @Validator 인터페이스 구현 객체 생성 : 전화번호 패턴 확인
  • supports(), validate() 메서드 구현
@Component
@Log4j
public class MemberDTOMobileValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return MemberDTO.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        MemberDTO memberDTO = (MemberDTO) target;
        String mobile = memberDTO.getMobile();

        // 전화번호 형식 확인
        String pattern = "^\\d{2,3}-\\d{3,4}-\\d{4}$";

        if(mobile == null || !Pattern.matches(pattern, mobile)){
            errors.rejectValue("mobile", "mobile.not.available");
        }

    }
}
  • 원래 이 구현 객체를 컨트롤러의 @InitBinder에서 WebDataBinder.setValidator()로 추가해주어야 하지만, jsr-380과 validator 동시 사용하게 만드는 validator 구현 객체를 생성하고 해당 validator 구현 객체를 넣어줄 것이다.
  1. @Validator 구현 객체 생성 : JSR-380, @Validator 둘 다 사용하게 만드는 객체
  • beanValidator, springValidator를 인스턴스(멤버변수)로 가진다.
  • beanValidator Set에서 하나씩 꺼내서 유효성 검사를 한다. 오류 발생 시, errors 객체에 errors.rejectValue()로 저장한다.
  • springValidator Set에서도 하나씩 꺼내서 validate()로 유효성 검사를 한다.
import kr.co.chunjae.domain.MemberDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

import javax.validation.ConstraintViolation;
import java.util.HashSet;
import java.util.Set;

// jsr-380 + spring validator
@Component
public class MemberDTOValidator implements Validator {
    // jsr-380 연동
    @Autowired
    private javax.validation.Validator beanValidator;
    // spring validator
    private Set<Validator> springValidators;

    // 생성자
    public MemberDTOValidator(){
        springValidators = new HashSet<>();
    }

    public void setSpringValidators(Set<Validator> springValidators){
        this.springValidators = springValidators;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return MemberDTO.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        MemberDTO memberDTO = (MemberDTO) target;

        // jsr-380 beanValidator
        Set<ConstraintViolation<Object>> violations = beanValidator.validate(target);
        for(ConstraintViolation<Object> violation : violations){
            // 오류 발생 필드 저장
            String propertyPath = violation.getPropertyPath().toString();
            String message = violation.getMessage(); // 오류 발생 메세지 저장
            // 오류 발생된 필드와 메세지를 Errors 객체에 저장
            errors.rejectValue(propertyPath, "", message);
        }

                // spring validator
        for(Validator validator: springValidators){
            validator.validate(target, errors); // 발생된 오류 정보를 전달
        }
    }
}

정리

  • jsr-380 : errors.rejectValue()로 오류 메세지 추가
  • validator : validator.validate()로 오류 메세지 추가
  1. 컨트롤러의 @InitBinder 선언한 메서드에서 WebDataBinder.addValidator()로 방금 구현한 객체를 validator로 넣어준다.
@Autowired
    private MemberDTOValidator memberDTOValidator;

// jsr-380 + spring validator 
    @InitBinder
    public void initBinder(WebDataBinder binder){
        binder.setValidator(memberDTOValidator);
    }
  1. 컨트롤러의 요청 처리 메서드의 파라미터 앞에 @Valid 확인, Errors 객체 확인.
  2. servlet-context.xml에 빈 추가
  • annotation-driven에 validator 추가
<annotation-driven enable-matrix-variables="true" validator="validator" />
  1. 뷰 페이지의 <form:errors path=”필드명” />로 오류 메세지 출력
  • validator 구현 객체를 빈으로 등록
<!-- JSR-380 유효성 검사를 위해 LocalValidatorFactoryBean을 등록 -->
    <beans:bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
        <beans:property name="validationMessageSource" ref="messageSource"/>
    </beans:bean>

    <!-- validator 구현 객체 -->
    <beans:bean id="memberDTOMobileValidator" class="kr.co.chunjae.validator.MemberDTOMobileValidator"/>
    <beans:bean id="memberDTOValidator" class="kr.co.chunjae.validator.MemberDTOValidator">
        <beans:property name="springValidators">
            <beans:set>
                <beans:ref bean="memberDTOMobileValidator"/>
            </beans:set>
        </beans:property>
    </beans:bean>

+선택적 유효성 검사

  • 회원정보 수정 시, 이메일은 수정하지 못하게 readonly로 되어 있는데 바인딩 될 때, 유효성 검사가 수행되어 이미 존재하는 이메일이라 수정이 되지 않았다.
  • 따라서, 수정 가능한 mobile, age만 유효성 검사 에러가 뜨면 페이지에 에러 메세지가 출력되고, 다른 필드의 유효성 검사는 무시하는 것을 구현하고 싶었다.

errors.hasFieldErrors("age") 를 사용하자!

  1. update.jsp
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
  <title>update</title>
</head>
<body>
<%--@elvariable id="member" type="kr.co.chunjae.domain.MemberDTO"--%>
<form:form modelAttribute="member" action="/member/update" method="post" name="updateForm">
  <p>id: <form:input path="id" name="id" value="${member.id}" readonly="true" /> </p>
  <p>email: <form:input path="email" name="email" value="${member.email}" readonly="true"/> </p>
  <p>password: <form:password path="password" name="password"/> <form:errors path="password"/> </p>
  <p>name: <form:input path="name" name="name" value="${member.name}" readonly="true"/> </p>
  <p>age: <form:input path="age" name="age" value="${member.age}"/> <form:errors path="age"/> </p>
  <p>mobile: <form:input path="mobile" name="mobile" value="${member.mobile}"/> <form:errors path="mobile"/> </p>

  <input type="button" value="수정" onclick="update()">
</form:form>
</body>

<script>
  // 기존 비밀번호와 일치했을 경우에만 회원정보 수정 가능
  const update = () => {
    const passwordDB = '${member.password}';
    const password = document.getElementById("password").value;

    if (passwordDB == password) {
      document.updateForm.submit();
    } else {
      alert("비밀번호가 일치하지 않습니다!");
    }
  }
</script>
</html>
  1. controller
@Controller
@Log4j
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
        @PostMapping("/update")
    public String update(@Valid @ModelAttribute("member") MemberDTO memberDTO, BindingResult errors){
        log.info(errors); // 다른 필드의 오류가 잡힌다.

        if(errors.hasFieldErrors("age") || errors.hasFieldErrors("mobile")){
            return "update";
        }

        boolean result = memberService.update(memberDTO);
        if(result) {
            return "redirect:/member?id="+memberDTO.getId();
        }else{
            return "update";
        }
    }
}
  • log.info(errors); 에서 이메일 중복 유효성 검사를 통과하지 못한 것을 알 수 있다.

  • 하지만 errors.hasFieldErrors("age")를 통해 age 필드 에러만 있을 때, 다시 수정 페이지로 이동하게 하였다.

정리 !

유효성 검사를 하는 방법에는 두 가지가 있다.

  1. JSR-380을 사용하기

    1-1. 정해진 애너테이션

    1-2. 사용자 지정 애너테이션 생성

  2. spring 유효성 검사인 Validator 인터페이스를 구현하기

  3. JSR-380 + Validator 동시에 사용하기

1-1. JSR-380 기본 애너테이션 사용

  • 도메인 객체의 필드 위에 알맞은 애너테이션을 선언한다.
  • 메세지 리소스 파일 혹은 애너테이션 안에 message 속성값에 오류 메세지를 설정한다.
  • 컨트롤러의 요청 처리 메서드의 파라미터 앞에 @Valid 선언하여 유효성 검사 시행
  • 뷰 페이지의 <form:errors path=”필드”/>로 에러 메세지 출력

1-2. 사용자 지정 애너테이션 사용

  • 도메인 객체의 필드 위에 사용자 지정 애너테이션을 선언한다.
  • @interface 객체로 사용자 지정 애너테이션을 생성한다.
    • message, groups(), payload()
  • ConstraintValidator 인터페이스 구현 객체를 생성한다.
    • initialize(), isValid() 메서드 구현
  • 컨트롤러의 요청 처리 메서드의 파라미터 앞에 @Valid
  • 뷰 페이지의 <form:errors />로 오류 메세지 출력
  1. Validator 인터페이스 구현
  • implements Validator 로 구현 객체를 만든다.
    • supports() : 해당 객체의 유효성 검사를 진행할 수 있는지 판단
    • validate() : 유효성 검사
  • 컨트롤러에 @InitBinder 선언 메서드를 생성한다.
    • WebDataBinder.addValidator(구현객체)로 validator 추가한다.
  1. JSR-380 + Validator 동시 사용
  • 기본 애너테이션, 사용자 지정 애너테이션, Validator 인터페이스 구현까지 동일하게 수행
  • Validator 구현 객체를 하나 더 생성한다.
  • jsr-380 유효성 검사인 javax.validation.Validator 변수 인스턴스 추가
  • Set<org.springframework.validation.Validator> 변수 인스턴스 추가
  • for문으로 validate() 실행하며 유효성 검사 수행
  • servlet-context.xml에 validator 구현 객체, LocalValidatorFactoryBean 빈 등록

= 깃허브에서 전체 코드 확인하기 =
https://github.com/thegreatjy/ChunjaeFullStack/tree/main/Spring_Study/memberFrameWorkPractice

728x90