슬기로운 개발생활

Spring Boot 게시판 Validation을 커스텀하여 회원가입 중복검사 구현

by coco3o
반응형

저번 글에 이어서 구현한 기능들을 포스팅하겠다.
( 링크 )

Spring Boot JPA 게시판 회원가입 Validation 유효성 검사 및 중복 검사

만약, 사용자가 회원가입 페이지에서 회원가입을 진행한다면 입력한 데이터 값이 서버로 전송되기 전에 특정 규칙에 맞게 입력되었는지, 아이디가 이미 존재하는 지 등을 확인하는 검증 단계가

dev-coco.tistory.com


Validation 어노테이션으로는 단일 필드에 대한 유효성 검증만 처리가 가능하기 때문에,

중복체크 같은 경우는 validation 어노테이션으로 해결이 불가능했다.

그래서 필자는 Service 에서 중복 여부를 확인 후 Exception을 일으키는 식으로 구현했었다.

하지만, 다음과 같은 Whitelabel Error Page는 사용자 입장에서 좋은 UX는 아니다.

중복 여부를 회원가입 창에서 보여줄 방법이 없을까 방황하고 있다 정말 기적같이 글 하나를 발견했고, ( 링크 )

Validation을 커스터마이징 하는 방법으로 해결할 수 있었다.


1. UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    boolean existsByUsername(String username);
    boolean existsByNickname(String nickname);
    boolean existsByEmail(String email);
}

이전 게시글에서 작성해두었던 Named Query이다.

Spring Data Jpa에선 해당 데이터가 DB에 존재하는지 확인하기 위해 exists를 사용한다.

해당 데이터가 존재할 경우 true, 존재하지 않을 경우 false가 리턴된다.


2. Validator를 구현한 AbstractValidator 생성

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로 따로 빼주었다.

  • @SuppressWarnings("unchecked") : 컴파일러에서 경고하지 않도록 하기 위해 사용

3. CheckUsernameValidator, CheckNicknameValidator, CheckEmailValidator 클래스 생성

CheckUsernameValidator

@RequiredArgsConstructor
@Component
public class CheckUsernameValidator extends AbstractValidator<UserRequestDto> {

    private final UserRepository userRepository;
    @Override
    protected void doValidate(UserRequestDto dto, Errors errors) {
        if (userRepository.existsByUsername(dto.toEntity().getUsername())) {
            errors.rejectValue("username", "아이디 중복 오류", "이미 사용중인 아이디 입니다.");
        }
    }
}

CheckNicknameValidator

@RequiredArgsConstructor
@Component
public class CheckNicknameValidator extends AbstractValidator<UserRequestDto> {

    private final UserRepository userRepository;

    @Override
    protected void doValidate(UserRequestDto dto, Errors errors) {
        if (userRepository.existsByNickname(dto.toEntity().getNickname())) {
            errors.rejectValue("nickname", "닉네임 중복 오류", "이미 사용중인 닉네임 입니다.");
        }
    }
}

CheckEmailValidator

@RequiredArgsConstructor
@Component
public class CheckEmailValidator extends AbstractValidator<UserRequestDto> {
    private final UserRepository userRepository;

    @Override
    protected void doValidate(UserRequestDto dto, Errors errors) {
        if (userRepository.existsByEmail(dto.toEntity().getEmail())) {
            errors.rejectValue("email", "이메일 중복 오류", "이미 사용중인 이메일 입니다.");
        }
    }
}

doValidate를 구현해 검증로직을 작성하고 bean으로 등록될 수 있도록 @Component 어노테이션을 사용했다.


4. Controller

@RequiredArgsConstructor
@Controller
@Log4j2
public class UserController {

    private final UserService userService;
    private final CheckUsernameValidator checkUsernameValidator;
    private final CheckNicknameValidator checkNicknameValidator;
    private final CheckEmailValidator checkEmailValidator;

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

    @GetMapping("/auth/join")
    public String join() {
        return "/user/user-join";
    }

    /* 회원가입 */
    @PostMapping("/auth/joinProc")
    public String joinProc(@Valid UserRequestDto userDto, Errors errors, Model model) {
        if (errors.hasErrors()) {
             /* 회원가입 실패시 입력 데이터 값을 유지 */
            model.addAttribute("userDto", userDto);

            /* 유효성 통과 못한 필드와 메시지를 핸들링 */
            Map<String, String> validatorResult = userService.validateHandling(errors);
            for (String key : validatorResult.keySet()) {
                model.addAttribute(key, validatorResult.get(key));
            }
            /* 회원가입 페이지로 다시 리턴 */
            return "/user/user-join";
        }

        userService.userJoin(userDto);
        return "redirect:/auth/login";
    }
    ...
}


Validator 사용을 위해 @InitBinder이 붙은 WebDataBinder를 인자로 받는 메소드를 작성해 검증 Validator를 추가했다.

  • @InitBinder : 특정 컨트롤러에서 바인딩 또는 검증 설정을 변경하고 싶을 때 사용한다.
    • WebDataBinder binder : HTTP 요청 정보를 컨트롤러 메소드의 파라미터나 모델에 바인딩할 때 사용되는 바인딩 객체

5. UserService

    /* 회원가입 시, 유효성 및 중복 검사 */
    @Transactional(readOnly = true)
    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;
    }

유효성 검사에 실패한 필드들은 Map 자료구조를 통해 키값과 에러 메시지를 응답한다.

Key : valid_{dto 필드명}
Message : dto에서 작성한 message 값

유효성 검사에 실패한 필드 목록을 받아 미리 정의된 메시지를 가져와 Map에 넣어준다.


6. Mustache

{{>layout/header}}
<div id="posts_list">
    <div class="container col-md-4">
        <form action="/auth/joinProc" method="post">
            <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
            <div class="form-group">
                <label>아이디</label>
                <input type="text" name="username" value="{{#userDto}}{{userDto.username}}{{/userDto}}" class="form-control" placeholder="아이디를 입력해주세요"/>
                {{#valid_username}} <span id="valid">{{valid_username}}</span> {{/valid_username}}

            </div>

            <div class="form-group">
                <label>비밀번호</label>
                <input type="password" name="password" value="{{#userDto}}{{userDto.password}}{{/userDto}}" class="form-control" placeholder="비밀번호를 입력해주세요"/>
                {{#valid_password}} <span id="valid">{{valid_password}}</span> {{/valid_password}}
            </div>

            <div class="form-group">
                <label>닉네임</label>
                <input type="text" name="nickname" value="{{#userDto}}{{userDto.nickname}}{{/userDto}}" class="form-control" placeholder="닉네임을 입력해주세요"/>
                {{#valid_nickname}} <span id="valid">{{valid_nickname}}</span> {{/valid_nickname}}
            </div>

            <div class="form-group">
                <label>이메일</label>
                <input type="email" name="email" value="{{#userDto}}{{userDto.email}}{{/userDto}}" class="form-control" placeholder="이메일을 입력해주세요"/>
                {{#valid_email}} <span id="valid">{{valid_email}}</span> {{/valid_email}}
            </div>

            <button class="btn btn-primary bi bi-person"> 가입</button>
            <a href="/" role="button" class="btn btn-info bi bi-arrow-return-left"> 목록</a>
        </form>
    </div>
</div>
{{>layout/footer}}

임시 데이터

이렇게 Validation을 커스텀하기 위해 Validator를 구현하고 검증 로직을 작성하면 위와 같이 중복체크 또한 유효성 검사에 포함시킬 수 있다.

※ 해당 Validator를 사용하기 위해서는 Controller에 @InitBinder가 붙은 WebDataBinder를 파라미터로 받는 메서드를 반드시 추가해주어야 한다.

반응형

블로그의 정보

슬기로운 개발생활

coco3o

활동하기