스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa

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

@Valid를 이용해서 유효성 검사와 아이디 중복검사를 확인하는 검증 단계를 구현해보려 한다.


1. build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

이전에는 spring-boot-starter-web 의존성 내부에 validation이 있었지만,

spring boot 2.3 version 이상부터는 아예 모듈로 빠져 validation 의존성을 따로 추가해줘야 사용할 수 있다.


2. UserDto

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserRequestDto {

    @NotBlank(message = "아이디는 필수 입력 값입니다.")
    private String username;

    @NotBlank(message = "비밀번호는 필수 입력 값입니다.")
    @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
    private String password;

    @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
    private String nickname;

    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
    @NotBlank(message = "이메일은 필수 입력 값입니다.")
    private String email;
    
    ...
}

유효성 검사에 필요한 Request 객체에 Validation 어노테이션을 사용했다.

Validation 어노테이션의 자세한 설명은 ( 링크 )를 참고하자.


3. Controller

    @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";
    }

컨트롤러에서 Request 객체 앞에 @Valid 어노테이션을 사용하고, Errors를 통해 유효성 검사 적합 여부를 확인한다.

※ 이때 Errors는 반드시 Request 객체 바로 뒤에 위치해야 한다.

(두 개의 객체에 validation 검사를 한다면, 각각 객체 뒤에 Errors를 받도록 한다.)

errors.hasErrors() 메서드로 유효성 검사에 실패한 필드가 있는지 확인하고,

userDto를 모델에 담아줘서 회원가입 실패 시, 회원가입 페이지에서 입력했던 정보들을 그대로 유지하기 위해 입력받았던 데이터를 그대로 할당해준다.

유효성 검사에 실패한 필드가 있다면, Service 계층으로 Errors 객체를 전달해 비즈니스 로직을 구현하고 모델에 담는다.


4. Service

    /* 회원가입 시, 유효성 체크 */
    @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에 넣어준다.


5. Mustache

{{>layout/header}}
<div id="posts_list">
    <div class="container col-md-4">
        <form action="/auth/joinProc" method="post" modelAttribute="userDto">
            <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}}

Service 계층에서 에러 객체의 Key 이름을 valid_%s로 설정해놨기 때문에 {{valid_필드명}}으로 작성한다.

스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa
스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa

회원가입시 아이디 중복체크

Validation 어노테이션으로 대부분의 유효성 검사 처리는 가능하지만,

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

그래서 필자는 다음과 같이 만들었다.


1. Repository

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

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

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


2. Service

    /* 아이디, 닉네임, 이메일 중복 여부 확인 */
    @Transactional(readOnly = true)
    public void checkUsernameDuplication(UserRequestDto dto) {
        boolean usernameDuplicate = userRepository.existsByUsername(dto.toEntity().getUsername());
        if (usernameDuplicate) {
            throw new IllegalStateException("이미 존재하는 아이디입니다.");
        }
    }

    @Transactional(readOnly = true)
    public void checkNicknameDuplication(UserRequestDto dto) {
        boolean nicknameDuplicate = userRepository.existsByNickname(dto.toEntity().getNickname());
        if (nicknameDuplicate) {
            throw new IllegalStateException("이미 존재하는 닉네임입니다.");
        }
    }

    @Transactional(readOnly = true)
    public void checkEmailDuplication(UserRequestDto dto) {
        boolean emailDuplicate = userRepository.existsByEmail(dto.toEntity().getEmail());
        if (emailDuplicate) {
            throw new IllegalStateException("이미 존재하는 이메일입니다.");
        }
    }

만약 데이터가 존재한다면 IllegalStateException이 일어나도록 했다.


3. Controller

    /* 회원가입 */
    @PostMapping("/auth/joinProc")
    public String joinProc(@Valid UserRequestDto userDto, Errors errors, Model model) {
       ...
       
        /* 중복검사 */
        userService.checkUsernameDuplication(userDto);
        userService.checkNicknameDuplication(userDto);
        userService.checkEmailDuplication(userDto);

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

스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa
임시 데이터

동일한 아이디(username)로 회원가입 진행시 다음과 같이 500 에러와 함께 메시지가 전달된다.

스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa
스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa

이 외에 닉네임과 이메일 또한 같은 결과를 가진다. 이렇게 Service 레이어에서 중복 검사를 구현했지만,

사용자 입장에서 이런 상황을 맞이한다면 좋지못한 UX가 될 것이다.

스프링 회원가입 유효성 검사 - seupeuling hoewongaib yuhyoseong geomsa

중복 여부를 회원가입창에서 보여줄 방법은 없을까 고민해봤지만, 이렇다 할 방법을 아직 찾지 못했다.

방법을 찾는다면 수정하도록 할 것이다.

해결 >> ( 포스트 보러가기 )