[SpringBoot] 회원가입 유효성 검증 및 중복 조회 처리

2022. 9. 7. 12:34문제해결

개요

SpringBoot 기반으로 회원가입 서비스를 구현하던 중 사용자로부터 입력받은 회원가입 입력정보들이 올바른지 유효성을 검증하고 만약 이미 가입된 아이디, 이메일, 연락처 정보가 있다면 이미 가입된 정보가 존재한다고 메시지를 사용자에게 출력하고자 합니다.

 

기술스택

Spring Boot, Thymeleaf, Spring Validation, Spring Web, Lombok, H2

 

회원 엔티티

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
    private String name;		// 회원이름
    private LocalDate birthday;	// 회원생년월일
    private String phone;		// 회원핸드폰번호
    @Embedded
    private Address address;	// 회원주소
    private String email;		// 회원이메일
    private String userId;	    // 회원아이디
    private String password;	// 회원비밀번호
    private String gender;		// 회원성별
 }

위 엔티티에서 userId, phone, email은 다른 회원들과 중복되면 안되는 컬럼들입니다.

 

회원 폼 클래스

회원 폼 클래스는 사용자로부터 회원가입 입력정보를 받아 저장하는 클래스입니다.

@Getter
@Setter
public class MemberForm {
    @NotEmpty(message = "회원 이름을 입력해주세요")
    private String name;
    @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE)
    private LocalDate birthday;
    @NotEmpty(message = "핸드폰 번호를 입력해주세요")
    private String phone;
    @NotEmpty(message = "주소를 입력해주세요")
    private String zipcode;
    @NotEmpty(message = "주소를 입력해주세요")
    private String street;
    private String detail;
    @NotEmpty(message = "이메일을 입력해주세요")
    @Email(message = "이메일 형식이 올바르지 않습니다")
    private String email;
    @NotEmpty(message = "아이디를 입력해주세요")
    private String userId;
    @NotEmpty(message = "비밀번호를 입력해주세요")
    @Pattern(regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}",
            message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자의 비밀번호여야 합니다.")
    private String password;
    @NotEmpty(message = "비밀번호를 입력해주세요")
    private String password_confirm;
    @NotEmpty(message = "성별을 선택해주세요")
    private String gender;
}

 

회원 컨트롤러

@Controller
@RequiredArgsConstructor
class MemberController {

    private final MemberService memberService;
    private final CheckUserIdValidator checkUserIdValidator;
    private final CheckPhoneValidator checkPhoneValidator;
    private final CheckEmailValidator checkEmailValidator;

    // 커스텀 유효성 검증을 위해 추가
    @InitBinder
    public void validatorBinder(WebDataBinder binder){
        binder.addValidators(checkUserIdValidator);
        binder.addValidators(checkPhoneValidator);
        binder.addValidators(checkEmailValidator);
    }

    @GetMapping("/members/new")
    public String createForm(Model model){
        model.addAttribute("memberForm", new MemberForm());
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    public String create(@Valid MemberForm memberForm, Errors errors, Model model){
        if(errors.hasErrors()){
            // 회원가입 실패시 입력 데이터 값을 유지
            model.addAttribute("memberForm", memberForm);

            // 유효성 통과 못한 필드와 메시지를 핸들링
            Map<String, String> validatorResult = memberService.validateHandling(errors);
            for(String key : validatorResult.keySet()){
                model.addAttribute(key, validatorResult.get(key));
            }
            // 회원가입 페이지로 다시 리턴
            return "members/createMemberForm";
        }
        
        Member member = Member.createMember(memberForm);
        memberService.signUp(member);
        return "redirect:/";
    }
}

 

Validator 클래스

validator 클래스는 검사하고 싶은 정보를 매개변수로 받아 검증하는 역할의 클래스입니다.

@Slf4j
public abstract class AbstractValidator<T> implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }

    @SuppressWarnings("unchecked")
    @Override
    public void validate(Object target, Errors errors) {
        try{
            doValidate((T) target, errors);
        } catch (RuntimeException e) {
            log.error("중복 검증 에러", e);
            throw e;
        }
    }

    protected abstract void doValidate(final T dto, final Errors errors);
}

 

CheckUserIdValidator 클래스

@RequiredArgsConstructor
@Component
public class CheckEmailValidator extends AbstractValidator<MemberForm>{
    private final MemberRepository memberRepository;
    @Override
    protected void doValidate(MemberForm dto, Errors errors) {
        if(memberRepository.existByEmail(dto.getEmail())){
            errors.rejectValue("email", "이메일 중복 오류", "이미 사용중인 이메일 입니다.");
        }
    }
}

 

CheckPhoneValidator 클래스

@RequiredArgsConstructor
@Component
public class CheckPhoneValidator extends AbstractValidator<MemberForm>{
    private final MemberRepository memberRepository;
    @Override
    protected void doValidate(MemberForm dto, Errors errors) {
        if(memberRepository.existByPhone(dto.getPhone())){
            errors.rejectValue("phone", "연락처 중복 오류", "이미 사용중인 연락처 입니다.");
        }
    }
}

 

CheckEmailValidator 클래스

@RequiredArgsConstructor
@Component
public class CheckEmailValidator extends AbstractValidator<MemberForm>{
    private final MemberRepository memberRepository;
    @Override
    protected void doValidate(MemberForm dto, Errors errors) {
        if(memberRepository.existByEmail(dto.getEmail())){
            errors.rejectValue("email", "이메일 중복 오류", "이미 사용중인 이메일 입니다.");
        }
    }
}

 

MemberRepository 클래스

@Repository
@RequiredArgsConstructor
public class MemberRepository {
    private final EntityManager em;
 
 	...
    
    public boolean existByPhone(String phone){
        List<Member> members = em.createQuery("select m from Member m where m.phone = :phone", Member.class)
                .setParameter("phone",phone)
                .getResultList();
        return members.stream().findAny().isPresent();
    }

    public boolean existByEmail(String email){
        List<Member> members = em.createQuery("select m from Member m where m.email = :email", Member.class)
                .setParameter("email",email)
                .getResultList();
        return members.stream().findAny().isPresent();
    }

    public boolean existByUserId(String userId){
        List<Member> members = em.createQuery("select m from Member m where m.userId = :userId", Member.class)
                .setParameter("userId",userId)
                .getResultList();
        return members.stream().findAny().isPresent();
    }
}

 

MemberService 클래스

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    // 회원가입
    @Transactional
    public void signUp(Member member){
        memberRepository.save(member);
    }

    // 회원가입시 유효성 체크 및 중복 조회 처리
    public Map<String, String> validateHandling(Errors errors) {
        Map<String, String> validatorResult = new HashMap<>();
        for(FieldError error : errors.getFieldErrors()){
            String validKeyName = String.format("valid_%s", error.getField());
            validatorResult.put(validKeyName, error.getDefaultMessage());
        }
        return validatorResult;
    }
}

 

createMemberForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />

<style>
    .fieldError {
        border-color: #bd2130;
    }
</style>
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <form role="form" action="/members/new" th:object="${memberForm}" method="post">
        <div class="form-group">
            <label th:for="name">이름</label>
            <input type="text" th:field="*{name}" placeholder="이름을 입력하세요"
                   th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
            <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
        </div>
        <div class="form-group">
            <label th:for="birthday">생년월일</label>
            <input type="date" th:field="*{birthday}" class="form-control">
        </div>
        <div class="form-group">
            <label th:for="phone">휴대폰번호</label>
            <input type="text" th:field="*{phone}" placeholder="010-0000-0000"
                   th:class="${#fields.hasErrors('phone')}? 'form-control fieldError' : 'form-control'"/>
            <p th:if="${#fields.hasErrors('phone')}" th:errors="*{phone}"></p>
        </div>
        <div class="form-group">
            <label th:for="address">주소</label>
            <div class="form-control" style="height: auto">
                <label th:for="zipcode" style="width : 100px;">우편번호</label>
                <input type="text" th:field="*{zipcode}"/>
                <button type="button" class="btn btn-secondary" onclick="openZipSearch()">검색</button><br>

                <label th:for="street" style="width : 100px;">주소</label>
                <input type="text" th:field="*{street}" readonly /><br>

                <label th:for="detail" style="width : 100px;">상세주소</label>
                <input type="text" th:field="*{detail}"/>
            </div>
        </div>
        <div class="form-group">
            <label th:for="email">이메일</label>
            <input type="email" th:field="*{email}" placeholder="user@gmail.com"
                   th:class="${#fields.hasErrors('email')}? 'form-control fieldError' : 'form-control'"/>
            <p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
        </div>
        <div class="form-group">
            <label th:for="userId">아이디</label>
            <input type="text" th:field="*{userId}"
                   th:class="${#fields.hasErrors('userId')}? 'form-control fieldError' : 'form-control'"/>
            <p th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}"></p>
        </div>
        <div class="form-group">
            <label th:for="password">비밀번호</label>
            <input type="password" th:field="*{password}"
                   th:class="${#fields.hasErrors('userId')}? 'form-control fieldError' : 'form-control'"/>
            <p th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
        </div>
        <div class="form-group">
            <label th:for="password_confirm">비밀번호 확인</label>
            <input type="password" th:field="*{password_confirm}"
                   th:class="${#fields.hasErrors('password_confirm')}? 'form-control fieldError' : 'form-control'"/>
            <p th:if="${#fields.hasErrors('password_confirm')}" th:errors="*{password_confirm}"></p>
        </div>
        <div class="form-group">
            <label th:for="gender">성별</label>
            <div class="form-control">
                <input type="radio" name="gender" value="male" checked="checked"/>남자
                <input type="radio" name="gender" value="female" />여자
            </div>
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <br/>
    <div th:replace="fragments/footer :: footer" />

    <script src="/js/members/address.js"></script>
</div> <!-- /container -->
</body>
</html>

 

실행결과

다음 그림은 첫번째로 어떤 한 회원의 정보를 입력하여 회원가입을 성공하고 두번째 회원가입 시도에 아이디, 이메일, 연락처를 중복된 정보로 넣고 회원가입을 요청한 결과입니다.

 

References

source code : https://github.com/yonghwankim-dev/spring_movie/tree/d7857826ea3b709c698c6b7b86bdb9c84f9a01c9/src/main/java/kr/yh/movie
Spring Boot 회원가입 Validation 유효성 검사하기
Spring Boot 게시판 Validation을 커스텀하여 회원가입 중복검사 구현