본문 바로가기
프로젝트/Recipository

[Dev] 22.11.17. Signin with Validation

by 규글 2022. 11. 17.

 지난 게시글에서 Spring Security를 통해서 page의 이동을 구성하려고 했고, 우선 기존에 작성해둔 회원 가입과 로그인 기능이 정상적으로 동작하도록 했다. 하지만 이렇게 해도 Spring Security와의 연동이 되어 html에서 sec tag로 화면을 동적으로 구성할 수는 없으며, 따라서 원하는 구현 방식은 그것이 아니기때문에 하나씩 수정할 생각이다.

 

 기존에는 javascript로 validation을 했다면, server 측에서 validation 하는 것도 구현하려고 한다. 굳이 하려는 이유는? 없다. 그냥 해보고 싶다.

 

회원 가입

 현재 회원 가입 절차는 다음과 같은 순서로 이루어진다.

  • 이메일을 입력하면 validation을 통해 이메일 형식을 확인 후, DB와의 중복 확인
  • 닉네임을 입력하면 validation을 통해 이메일 형식을 확인 후, DB와의 중복 확인
  • 비밀번호를 입력하면 비밀번호 체크와 일치 여부를 확인
  • 위의 세 단계가 마무리되면 회원 가입 form이 제출되어 DB에 저장하는 절차를 밟음

 

 이 과정에서 Spring 에서 제공하는 Validation 기능을 사용해볼 생각이다. 꼭 이렇게 만들 필요는 없겠지만, 그냥 배운 내용을 사용해보는 과정이다. 현재 email과 nickname, password에 대한 validation은 입력할 때마다 그 여부를 화면에 출력하도록 되어 있다. 하지만 입력할 때마다 server 측으로 request를 보내서 validation에 대한 message를 return 받아 front 화면에 출력하는 것은 좋지 않다고 생각한다.

 

 그래서 다음과 같은 상황을 가정하기로 했다. 굳이 가정할 필요는 없지만 일종의 명분이라고 생각하면 좋을 것 같다. 뭔가 javascript의 validation에 대한 내용을 조작해서 무사히 server로 회원 가입 정보를 넘기는 상황이 일어날 수 있다고 생각해본다면, 이는 중복된다든지 원하지 않는 내용으로의 가입이 이루어지게 되는 것이다. 이에 대해 Spring Validation을 적용해보겠다. 이미 javascript로 적용되어 있는 validation이 있으므로 테스트는 해당 javascript를 주석처리해서 해볼 것이다.

 

사전 준비

build.gradle

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.7.4'

-------------------------------------------------------------------------

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

 mvn repository에서 spring boot validation을 검색해서 build.gradle에 추가해도 되고, 기존에 입력해둔 다른 내용이 있다면 슬쩍 validation으로 바꿔서 추가해줘도 된다. 추가 후에 새로고침을 해준다.

 

 

회원 가입 시 Validation

SpUser.java

package com.example.recipository.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern;
import java.util.Set;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "sp_user")
public class SpUser implements UserDetails {
    @Id
    private Long userId;
    @Email(message = "이메일 양식에 맞게 작성해주세요.")
    private String email;
    @Pattern(regexp = "^[a-zA-Z0-9가-힁]{4,12}$", message = "4~12자로 입력해주세요.")
    private String name;
    @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{8,20}$",
            message = "영문과 숫자를 합쳐 8자 이상 20자 이하로 입력해주세요.")
    private String password;
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "user_id"))
    private Set<SpAuthority> authorities;

    private boolean enabled;

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return enabled;
    }

    @Override
    public boolean isAccountNonLocked() {
        return enabled;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return enabled;
    }
}

 우선 UserDetails를 implements 한 SpUser class를 만들었다. 이 친구는 후에 작업할 로그인 과정에 사용될 친구로, Spring Security 에서는 이 UserDetails 객체를 principal로 삼아 Authenticate 과정을 거치게 된다. 후에 상술하겠다.

 아무튼 이 친구에도 회원 가입에 필요한 field를 가지고 있으므로, 이를 그대로 사용해보려고 했다.

 

 이어서 사용한 validation annotation에 대해 잠시 설명하고 넘어가겠다.

 우선 @Email 은 받아온 data가 email 형식인지에 대한 validation 과정을 거치도록 한다. 기존에 java script와 regular expression을 사용해서 작업한 내용에 비해서는 아주아주 간소화 된 모습이라 허탈할 수도 있다.

 그리고 @Pattern 은 받아온 data에 대해 regular expression 에 맞는 내용인지에 대한 validation 과정을 거치도록 한다. 표현 방식은 동일하다.

 각 annotation의 message 속성에는 각 validation을 통과하지 못해 오류를 발생시켰을 때 출력하게 될 내용을 작성할 수 있다. 만약 작성하지 않는다면 default로 설정된 에러 메시지가 출력된다.

 

UserController.java

    @PostMapping(value = "/signin")
    public ResponseEntity<Map<String, Object>> signin(@Valid @RequestBody SpUser user,
                                                      BindingResult bindingResult){
        Map<String, Object> map = new HashMap<>();
        if(bindingResult.hasErrors()){
            map.put("beSuccess", false);

            Map<String, Object> errorList = new HashMap<>();

            StringBuffer sb = new StringBuffer();
            bindingResult.getAllErrors().forEach(error -> {
                FieldError fieldError = (FieldError)error;
                String field = fieldError.getField();
                String errorMessage = error.getDefaultMessage();

                errorList.put(field, errorMessage);
                map.put("errorMessage", errorList);
            });

            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(map);
        }

        map.put("beSuccess", userService.signin(user));

        return ResponseEntity.status(HttpStatus.OK).body(map);
    }

 UserController의 /signin 요청을 다루는 method이다. 우선 client로부터 받아오는 JSON data에 대한 validation을 하기 위해서는 받아오는 대상 앞에 @Valid 를 작성해주어야 한다. 이 annotation을 작성해야만 data를 받아오는 객체의 field에 작성한 validation annotation이 동작한다.

 이어서 BindingResult를 method의 인자로 받는다. 이 친구가 없다면 method로 들어왔을 때 에러를 발생시키면서 method 내부로 들어오지 못한다. 따라서 에러에 대한 내용을 받아보기 위해 BindingResult 로 에러를 받을 수 있도록 했다. 만약 validation 때문에 에러가 발생한 경우, 해당 에러가 발생한 field와 그 메시지를 client 쪽으로 전달하기 위해서 Map 객체에 담아 return 하도록 했다.

 

 

 

회원 가입 절차

auto_increment 문제

 우선 시작하자마자 아주 당황스러운 상황에 봉착했다. 단순히 JPA repository의 save를 통해 client의 signin form으로부터 넘겨받은 data를 저장하도록 하는 단순한 logic이었음에도 불구하고 user_id 가 default value를 가지고 있지 않다는 에러 메시지를 보게 된 것이다.

 

Sp_User.java

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

 게다가 data를 넘겨주는 Sp_User(UserDetails) 의 user_id field에는 @GeneratedValue annotation과 그 strategy 속성으로 GenerationType.IDENTITY 로 주었다. 해당 속성은 기본 key의 생성을 DB에 위임하여 해당 field의 값이 null인 경우 DB가 알아서 auto_increment 해주며, mySQL이나 PostgreSQL 등과 잘 어울리는 것 같다.[각주:1]

 

 해당 오류는 단순하게 strategy를 IDENTITY에서 AUTO 로 바꾸면 해결된다. 하지만 왜 IDENTITY의 경우에는 오류가 발생하는 것인지 알아야겠어서 검색을 조금 해보았고, 다음 페이지에서 이유를 확인할 수 있었다.[각주:2]

 

 해당 페이지에서는 실제로 DB의 column 에 대해서는 auto_increment가 적용된 것이 아니라고 언급한다. 이는 다른 여러 게시글에서도 확인할 수 있었다. 이를 해결하려고 방안을 찾으려고 했으나, 바로 또 막히게 되었다.

 

foreign key 추가 제거

alter table sp_user
modify user_id bigint not null auto_increment;

 곧바로 막힌 문제는 foreign key 때문에 user_id column에 auto_increment 를 새롭게 추가할 수 없었다. 에러 메시지는 해당 column이 다른 table 에서 foreign key로 사용되고 있어서 불가하다는 말이었다. 이를 해결하기 위해 foreign key constraint를 없애고 auto_increment를 추가한 뒤 다시 foreign key constraint를 걸어주기로 했다.

 

 방법은 다음의 내용을 참고했다.[각주:3]

 

alter table table_name
drop foreign key column_name

------------------------------

alter table sp_user_authority
drop foreign key user_id

 우선 foreign key를 가지고 있는 table에서 해당 내용을 삭제하고, 다시 위에서 시도했던 auto_increment 를 추가해주는 query 문을 실행하였다. 기존의 에러 메시지 없이 아주 정상적으로 수행되었다.

 

alter table table_name
add constraint constraint_name foreign key(fk로 지정할 column_name)
references ref_table_name(ref_table_column_name)
on delete cascade

------------------------------

alter table sp_user_authority
add constraint user_id foreign key(user_id)
references sp_user(user_id)
on delete cascade

 이어서 원래 있었던 foreign key를 추가해주었다. 이 과정에서 기존에 sp_user_authority table의 constraint 정보도 동일하게 복원하기 위해 constraint 항목도 query문에 추가했다.

 

 그리고 작성은 해두었으나 'on delete cascade' 의 경우는 포함하지 않은 채로 query 문을 commit 했는데, 이는 JPA 에서 다시 볼 수 있을 것 같은 항목이라서 우선 그냥 두었다. 그럼에도 굳이 기록해둔 이유는 테스트 중에 foreign key로 연관된 table에 data가 있을 경우 data를 지울 때 foreign key를 가진 table의 data를 지우지 않으면 다른 쪽에서 data를 지울 수 없었던 경험 때문이다. 위의 'on delete cascade' 항목을 추가하면 reference table에서 data를 삭제할 경우 함께 foreign key 가 포함된 table에서도 해당 column을 함께 삭제하도록 하는 항목이라고 참고한 블로그에 작성되어 있었고, 때문에 일단 기록해두기로 한 것이다.

 

 이렇게 작업하고 IDENTITY 옵션으로도 정상적인 회원가입이 진행되는 것을 확인했다.

 

 

BcryptPasswordEncoding 후 @Pattern 의 regex와 충돌하는 문제 (javax.validation.ConstraintViolationException)

 이것이 회원 가입 구현 과정에서의 마지막 문제라고 생각한다.

 이것은 어떤 문제인가 하면, 전달 받은 password를 encoding 한 후에 그것을 다시 객체에 넣어 JPA 의 save( ) method로 DB에 insert 하는 과정에서 password field에 대한 @Pattern validation에 걸려 method를 정상적으로 수행할 수 없는 것이다.

 

 사실 UserDetails 를 implements 한 Sp_User는 로그인을 위한 친구이다. 하지만 회원 가입 때 client의 form data를 받는 DTO 객체를 따로 만드는 것이 번거로워서 Sp_User 객체에 한 번에 Validation 을 위한 annotation을 붙여서 문제가 발생한 것이라고 생각한다. 이번 문제에 대한 해결책은 각각의 목적에 맞게 객체의 역할을 구분하는 것이다.

 다음과 같이 UserDto class를 따로 만들어서 validation annotation을 Sp_User class로부터 분리했다. 그리고 이렇게 동작했을 때, 정상적인 회원 가입이 진행될 수 있었다.

 

UserDto.java

package com.example.recipository.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
    @Email(message = "이메일 양식에 맞게 작성해주세요.")
    private String email;
    @Pattern(regexp = "^[a-zA-Z0-9가-힁]{4,12}$", message = "4~12자로 입력해주세요.")
    private String name;
    @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{8,20}$",
            message = "영문과 숫자를 합쳐 8자 이상 20자 이하로 입력해주세요.")
    private String password;
}

 

Sp_User.java

(...)

public class SpUser implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;
    private String email;
    private String name;
    private String password;
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "user_id"))
    private Set<SpAuthority> authorities;

    private boolean enabled;
    
(...)

 

UserServiceImpl.java

    @Override
    public boolean signin(UserDto userDto) {
        // 비밀번호 encoding
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        String encodedPwd = encoder.encode(userDto.getPassword());

        SpUser user = new SpUser();
        user.setEmail(userDto.getEmail());
        user.setName(userDto.getName());
        user.setPassword(encodedPwd);
        user.setEnabled(true);

        if(userRepository.save(user) != null){
            addAuthority(user.getUserId(), "ROLE_USER");
            return true;
        }
        return false;
    }

    @Transactional
    // 권한을 추가하는 method
    public void addAuthority(Long userId, String authority){
        userRepository.findById(userId).ifPresent(user -> {
            SpAuthority spAuthority = new SpAuthority(user.getUserId(), authority);
            if(user.getAuthorities() == null){
                HashSet<SpAuthority> authorities = new HashSet<>();
                authorities.add(spAuthority);
                user.setAuthorities(authorities);
                userRepository.save(user);
            } else if(!user.getAuthorities().contains(spAuthority)){
                HashSet<SpAuthority> authorities = new HashSet<>();
                authorities.addAll(user.getAuthorities());
                authorities.add(spAuthority);
                user.setAuthorities(authorities);
                userRepository.save(user);
            }
        });
    }

 그렇게 수행되는 signin method의 logic이다. 이는 Spring Security에서 사용했던 logic을 거의 그대로 들고왔다.

 

 Client 로부터 넘겨받은 UserDto의 data로 SpUser 의 field를 setting 하고 우선 SpUser를 save 했다. 그리고 이어서 해당 id에 대한 권한을 추가해주는 addAuthority method를 수행하도록 했다.

 

 addAuthority method의 경우 특정 id에 특정 authority를 담아 추가하는 방식인데, sp_user table로부터 id에 맞는 사용자의 data를 불러와서 권한에 대해 해당 항목이 있는 경우와 없는 경우로 분기하여 setting 하고 다시 save 하도록 되어 있다. 뭔가 조금 이상해보이긴 하지만 동작은 온전히 된다.

 

Reference

댓글