[Spring Boot] 2-7. Validation 회원가입 중복 체크
validation 어노테이션으로는 단일 필드에 대한 유효성 검증만 처리가 가능하기 때문에, 중복체크 같은 경우는 validation 어노테이션으로 해결이 불가능하다. 이전 포스팅에서는 js로 중복 검사를 하였는데 이번엔 validation을 커스터마이징하는 방법을 사용하려고 한다.
📑 1. Mapper
회원가입 시 사용자가 입력한 이메일 또는 닉네임에 해당하는 회원 정보가 db에 있을 경우 boolean 타입으로 true를 반환한다. 즉) false를 반환되어야 회원 가입이 가능한 것이다.
<UserMapper.xml>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wish.library.security.mapper.UserMapper">
<select id="getOneByEmail" resultType="boolean">
select count(*)
from members
where email = #{email}
</select>
<select id="getOneByNickname" resultType="boolean">
select count(*)
from members
where nickname = #{nickname}
</select>
</mapper>
<UserMapper.java>
package com.wish.library.security.mapper;
import com.wish.library.security.domain.MemberSaveForm;
import com.wish.library.security.domain.UserDTO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper {
/*중복 검사, 이메일 조회*/
boolean getOneByEmail(String email);
/*중복 검사, 닉네임 조회*/
boolean getOneByNickname(String nickname);
}
📑 2. Validator 구현한 AbstractValidator 생성
<AbstractValidator.java>
package com.wish.library.security.controller.validation;
import jdk.jshell.ErroneousSnippet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.Errors;
import org.springframework.validation.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);
}
validate를 구현하고 검증로직이 들어갈 부분을 doValidate로 따로 빼주었다.
@SupperssWarnings("unchecked")는 컴파일러에서 경고하지 않도록 하기 위해 사용했다.
validate를 구현한 AbstractValidator를 구현하는 이메일 체크, 닉네임 체크 클래스를 생성한다.
<CheckEmailValidator.java>
package com.wish.library.security.controller.validation;
import com.wish.library.security.domain.MemberSaveForm;
import com.wish.library.security.service.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.parameters.P;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
@Component
@Slf4j
@RequiredArgsConstructor
public class CheckEmailValidator extends AbstractValidator<MemberSaveForm> {
private final MemberService memberService;
@Override
protected void doValidate(MemberSaveForm dto, Errors errors) {
log.info("이메일 중복체크 중");
if(memberService.checkEmailDuplication(dto.getEmail())) {
errors.rejectValue("email", "이메일 중복 오류", "이미 사용중인 이메일입니다.");
}
}
}
errors에 rejectValue 메서드로 에러 메세지를 전달한다.
각각 파라미터는 field, errorCode, defaultMessegae를 나타낸다.
<CheckNicknameValidator.java>
package com.wish.library.security.controller.validation;
import com.wish.library.security.domain.MemberSaveForm;
import com.wish.library.security.service.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
@Component
@Slf4j
@RequiredArgsConstructor
public class CheckNicknameValidator extends AbstractValidator<MemberSaveForm> {
private final MemberService memberService;
@Override
protected void doValidate(MemberSaveForm dto, Errors errors) {
log.info("닉네임 중복체크 중");
if(memberService.checkNicknameDuplication(dto.getNickname())) {
errors.rejectValue("nickname", "닉네임 중복 오류", "이미 사용중인 닉네임입니다.");
}
}
}
📑 Controller
package com.wish.library.security.controller;
import com.wish.library.security.controller.validation.CheckEmailValidator;
import com.wish.library.security.controller.validation.CheckNicknameValidator;
import com.wish.library.security.domain.MemberSaveForm;
import com.wish.library.security.domain.UserDTO;
import com.wish.library.security.service.CustomUserService;
import com.wish.library.security.service.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.Map;
/**
* GET 방식, 화면 조회시
*/
@Controller
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final CustomUserService customUserService;
private final MemberService memberService;
private final CheckEmailValidator checkEmailValidator;
private final CheckNicknameValidator checkNicknameValidator;
/*커스텀 유효성 검증을 위해 추가*/
@InitBinder
public void validatorBinder(WebDataBinder binder){
binder.addValidators(checkEmailValidator);
binder.addValidators(checkNicknameValidator);
}
@GetMapping("/join")
public String join(){
return "join";
}
@PostMapping("/join")
public String join(@Validated @ModelAttribute("memberSaveForm") MemberSaveForm memberSaveForm
, BindingResult bindingResult
, RedirectAttributes rttr
,Model model) {
log.info("=================join Controller");
if(bindingResult.hasErrors()){
model.addAttribute("memberDto", memberSaveForm);
log.info("errors={}", bindingResult);
/*중복 검사 통과 못한 메시지를 핸들링*/
/* Map<String, String> validatorResult = memberService.validateHandling(bindingResult);
//keySet() : Map의 key값을 가져올때 사용.
for(String key:validatorResult.keySet()){
model.addAttribute(key, validatorResult.get(key));
}*/
return "join";
}
boolean saveResult = memberService.save(memberSaveForm);
if(saveResult){
rttr.addFlashAttribute("result", "회원 가입 완료");
return "redirect:/login";
}
return "join";
}
}
Validator 사용을 위해 @InitBinder이 붙은 WebDataBinder를 인자로 받는 메서드를 작성해주었다.
📌 @InitBinder는 특정 컨트롤러에서 바인딩 또는 검증 설정을 변경하고 싶을때 사용한다. 즉) @Valid 어노테이션으로 검증이 필요한 객체를 가져오기 전에 수행할 메서드를 지정해주는 어노테이션이다.
📌 WebDataBinder는 HTTP 요청 정보를 컨트롤러 메서드의 파라미터나 모델에 바인딩할 때 사용되는 바인딩 객체이다. addValidators를 통해 새로운 유효성을 정의한 객체를 binder에 추가해주는 작업이다.
화면단에서 에러 메세지를 출력하기 위해 따로 validateHandling 메서드를 서비스 단에서 만들었는데, 사용할 필요가 없는 듯 하다. 이미 <form:errors path="">로 에러 값들을 출력하고 있기에 메시지가 중복된다.
📑 에러 출력 화면 (jsp)
<%@ page language="java" contentType="text/html; charset=UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
<meta name="generator" content="Hugo 0.111.3">
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<title>회원가입</title>
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/sign-in/">
<link href="assets/dist/css/sign-in.css" rel="stylesheet">
<jsp:include page="./includes/common_includes.jsp"></jsp:include>
<%-- <script type="text/javascript" src="assets/dist/js/commonAjax.js"></script>
<script type="text/javascript" src="assets/dist/js/member.js"></script>--%>
<style>
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
.nav-scroller .nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
</style>
</head>
<script>
</script>
<body class="text-center">
<main class="form-signin w-100 m-auto">
<form:form name="join_form" action="/join" method="post" modelAttribute="memberSaveForm">
<%--<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />--%>
<img class="mb-4" src="assets/brand/bootstrap-logo.svg" alt="" width="72" height="57"
onclick="location.href='/'">
<h1 class="h3 mb-3 fw-normal">Please join in</h1>
<div class="form-floating">
<input type="email" class="form-control" name="email" id="email" value="${memberDto.email}"
placeholder="name@example.com">
<label for="email">Email address</label><%--<button class="btn btn-light rounded-pill px-3" type="button" name="btn" id="btnEmailCheck">Duplicate check</button>--%>
<%--<span id="id-check"></span>--%>
<span class="text-danger"><%--${valid_email}--%><form:errors path="email" /></span>
</div>
<div class="form-floating">
<input type="password" class="form-control" name="password" id="password" placeholder="Password" autoComplete="off">
<label for="password">Password</label>
<span class="text-danger"><form:errors path="password" /></span>
</div>
<%-- <div class="form-floating">
<input type="password" class="form-control" name="passwordConfirm" id="passwordConfirm" placeholder="passwordConfirm" autoComplete="off">
<label for="passwordConfirm">passwordConfirm</label>
<form:errors path="passwordConfirm" /><br>
</div>--%>
<div class="form-floating">
<input type="text" class="form-control" name="name" id="name" value="${memberDto.name}"
placeholder="name">
<label for="name">name</label>
<span class="text-danger"><form:errors path="name" /></span>
</div>
<div class="form-floating">
<input type="text" class="form-control" name="nickname" id="nickname" value="${memberDto.nickname}"
placeholder="nickname">
<label for="nickname">nickname</label>
<%--<button class="btn btn-light rounded-pill px-3" type="button" name="btn" id="btnNicknameCheck">Duplicate check</button>--%>
<%--<span id="nickname-check"></span>--%>
<span class="text-danger"><%--${valid_nickname}--%> <form:errors path="nickname" /></span>
</div>
<div class="form-floating">
<div class="form-check">
<input id="men" name="mfCode" value="MEN" type="radio" class="form-check-input" checked>
<label class="form-check-label" for="men">Men</label>
</div>
<div class="form-check">
<input id="female" name="mfCode" value="FEMALE" type="radio" class="form-check-input">
<label class="form-check-label" for="female">Female</label>
</div>
</div>
<div class="form-floating">
<input type="text" class="form-control" name="cellNo" id="cellNo" value="${memberDto.cellNo}"
placeholder="cellNo">
<label for="cellNo">cellNo</label>
<span class="text-danger"><form:errors path="cellNo" /></span>
</div>
<button class="w-50 btn btn-lg btn-dark" type="button" onclick="history.back();" >이전페이지로가기</button>
<%--<button class="w-50 btn btn-lg btn-primary" type="button" name="btn" id="btnSingUp" >Sign up</button>--%>
<button type="submit" class="w-50 btn btn-lg btn-primary">Sign up</button>
</form:form>
</main>
</body>
</html>
이미 db에 저장되어 있는 이메일과 닉네임 값을 넣어봤다. CheckEmailValidator와 CheckNicknameValidator에서 지정해둔 defaultMessage 값이 출력되는 걸 확인할 수 있다.
[참고] https://dev-coco.tistory.com/125