[Spring Security] 회원가입 전체 로직

HJ·2023년 8월 12일
0

Spring Security

목록 보기
2/9
post-thumbnail

이전에 비밀번호 암호화를 공부하면서 인터넷을 찾아보며 회원가입을 구현해보았는데 회원가입 뿐만 아니라 로그인까지 구체적으로 구현해보고 싶어 다시 처음부터 구현하게 되었습니다.

또한 지금까지는 model 을 이용해서 화면과 데이터를 주고 받았는데 이번에는 연습삼아 JSON 형식으로 주고 받는 방식을 사용해보았습니다. 이와 관련한 내용을 잘 모르시는 분들은 이전 게시글 에 주고 받는 방법에 대해 나와있으니 참고하시면 되겠습니다.

이번 게시글에서는 코드를 중심으로 제가 했던 내용을 간단하게 정리해보려 합니다. 구현할 때 이전 게시글에 있는 passwordEncoder 만 사용했기 때문에 추가적으로 다루는 내용은 없습니다. 전체 코드는 github 에서 확인하실 수 있습니다.


WebSecurityConfig


@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/", "/signUp", "/login", "/js/**").permitAll()    // js 실행을 위해 필요
                .anyRequest().authenticated();
    }
    
}

이전 게시글에서 말씀드린 것처럼 Spring Security 를 사용할 때 설정하는 설정파일입니다. 회원가입 시 DB 에 데이터를 저장할 때 암호화 하기 위해 사용합니다.

configure() 에는 홈, 회원가입 페이지, 로그인 페이지, js 하위의 모든 파일를 인증없이 접근 가능하도록 설정하고, 나머지 모든 요청은 인증이 필요하도록 설정하였습니다.

JS 와 같은 정적 리소스는 white list 를 만들어서 이 리스트에 있는 것들의 인증을 무시한다고 작성하는 방법도 존재합니다.

뒤에서 나오겠지만 저는 Response 를 직접 구현하여 내려주었습니다. 포스트맨을 사용할 때는 직접 만든 Response 객체를 JSON 형식으로 잘 파싱해서 보여주지만 thymeleaf 에서 이 정보를 이용하려면 JS 를 거쳐서 사용해야했기에 JS 도 작성하게 되었습니다.




User


@Data
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long no;
    @NotBlank
    private String id;
    @NotBlank
    private String name;
    @NotBlank
    private String password;
    @NotBlank
    private String email;
    @Enumerated(EnumType.STRING)
    private UserRole role;

    @Builder
    public User(String id, String name, String password, String email, UserRole role) {
        this.id = id;
        this.name = name;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    public User() {

    }

    // User 가 생성되기 이전 DTO 로 User 를 생성할 때 사용
    // 비밀번호 암호화까지 동시에 수행
    public static User createUser(UserDTO dto, PasswordEncoder passwordEncoder) {
        User user = User.builder()
                .id(dto.getId())
                .name(dto.getName())
                .email(dto.getEmail())
                .password(passwordEncoder.encode(dto.getPassword()))    // 암호화해서 User 생성
                .role(UserRole.USER)    // 역할 지정
                .build();
        return user;
    }
}

유저의 역할을 Enum 으로 정의해서 사용했습니다. 로그인에서 User 가 디폴트로 사용되기 때문에 사용자 관련 객체는 Member 라고 하는 것이 좋습니다.

UserDTO 를 전달받아서 UserEntity 를 만들 수 있도록 createUser() 메서드를 구현하였습니다. User Entity 를 만드는 친구이기 때문에 User 생성과 관계 없이 사용하기 위해 static 으로 선언하였습니다.

또한 비밀번호를 암호화하여 저장하기 위해 파라미터로 passwordEncoder 를 전달받아 DTO 의 비밀번호를 암호화해서 Entity 를 만들도록 하였습니다.




UserDTO


@Data
public class UserDTO {

    @NotBlank(message="id는 필수 입력값 입니다")
    private String id;
    @NotBlank(message="이름은 필수 입력값 입니다")
    private String name;
    @NotBlank(message="비밀번호는 필수 입력값 입니다")
    private String password;
    @NotBlank(message="이메일은 필수 입력값 입니다")
    @Email
    private String email;
}

화면에서 전달받을 User 데이터를 담는 클래스입니다. 회원 번호나 역할 같은 것은 화면에서 받는 것이 아니기 때문에 회원가입 시 사용자에게 입력받을 데이터만 추린 클래스입니다.

또한 Entity 를 만들기 전에 validation 체크를 하기 위해 DTO 에서 @NotBlank@Email 같은 검증 어노테이션을 활용하였습니다. 검증에서 에러가 발생한다면 어노테이션에 지정한 message 가 출력되게 되는데 이는 조금 더 뒤에서 확인해보도록 하겠습니다.




UserRepository


@Repository
@Transactional
public class JpaUserRepository implements UserRepository{

    private final EntityManager em;

    public JpaUserRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public User save(User user) {
        em.persist(user);
        return user;
    }

    @Override
    public Optional<User> findById(String id) {
        String jpql = "select u from User u where u.id=:id";
        TypedQuery<User> query = em.createQuery(jpql, User.class).setParameter("id", id);
        List<User> userList = query.getResultList();
        if (userList.size() == 0) {
            return Optional.empty();
        }
        return Optional.ofNullable(userList.get(0));
    }
}

JPA 를 사용하기 때문에 EntityManager 를 주입받습니다. Repopsitory 에서는 우선 간단하게 저장하고 id 중복 체크를 위한 findById 메서드만 구현했습니다.

쿼리를 실행하고 결과를 받을 때 getResultList() 를 사용한 이유는 getResultList() 의 경우 조회되는 것이 없으면 빈 리스트가 반환되지만 getSingleResult() 를 사용하면 예외가 발생하기 때문에 이를 방지하고자 사용했습니다.

또한 값이 있는지 없는지 모르기 때문에 Optioinal 로 반환하도록 구현하였습니다. 기본적으로 Spring Data JPA 에서 findById 는 Optional 을 반환합니다.




UserService


@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User save(UserDTO dto) {
        User user = User.createUser(dto, passwordEncoder);
        return userRepository.save(user);
    }

    public Optional<User> findById(String id) {
        return userRepository.findById(id);
    }

    public boolean isExistId(String id) {
        return this.findById(id).isPresent();
    }

}

다음은 서비스입니다. Repository 와 passwordEncoder 를 주입받아 사용합니다. 회원을 저장할 때 화면, 정확히는 Controller 에서 DTO 를 전달받고, dto 와 passwordEncoder 를 함께 파라미터로 넘겨주면서 UserEntity 를 만들게 됩니다.

id 로 조회하는 경우는 repository 에서 구현한 메서드를 이용합니다. id 중복을 확인하기 위해 isExistId() 메서드를 만들었는데 같은 Service 내부의 findById() 메서드를 사용했습니다.




UserController


@Controller
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/signUp")
    public String loadSignUpPage() {
        return "home/signUpHome";
    }

    @PostMapping("/signUp")
    @ResponseBody   // 화면도 같이 사용하기 때문에 @RestController 대신 @ResponseBody 사용
    public ResponseDTO<?> signUp(@Valid @RequestBody UserDTO userDTO, BindingResult bindingResult) throws JsonProcessingException {
        if (bindingResult.hasErrors()) {
            Map<String, String> errorMap = new HashMap<>();
            for (FieldError fieldError : bindingResult.getFieldErrors()) {
                errorMap.put("error-" + fieldError.getField(), fieldError.getDefaultMessage());
            }
            return new ResponseDTO<>(errorMap, HttpStatus.BAD_REQUEST);
        }

        boolean isExist = userService.isExistId(userDTO.getId());
        if (isExist) {
            return new ResponseDTO<>("동일한 id 가 존재합니다", HttpStatus.CONFLICT); // 409 상태코드
        }
        User savedUser = userService.save(userDTO);
        return new ResponseDTO<>(savedUser, HttpStatus.OK);
    }

    @GetMapping("/login")
    public String loadLoginHome() {
        return "home/loginHome";
    }
}

[ ResponseDTO ]

회원가입 로직을 보면 ResponseDTO 라는 것을 반환하는데 이는 제가 응답을 위해 직접 만들었습니다.

ResponseEntity 를 사용해도 되지만, UserDTO 를 검증하고 오류가 있다면 사용자에게 알려주어야 하는데 Model 을 사용하지 않다보니 에러 메세지를 전달할 방법이 마땅치 않아 만들게 되었습니다.

이렇게 전달된 ResponseDTO 는 JS 가 받아 처리한 후 thymeleaf 로 전달하여 사용자에게 보여주게 됩니다. JS 는 뒤에서 다루도록 하겠습니다.



[ RestController ]

view 의 이름을 반환하는 메서드와 회원가입 후 응답 메세지를 반환하는 Controller 가 동일하기 때문에 @RestController 가 아닌 @Controller 를 선언하였습니다.

응답 메세지를 반환하는 메서드에는 @ResponseBody 를 붙여주는 방식을 사용하였습니다. 이렇게 하면 반환하는 내용이 message body 에 들어가게 됩니다.

추가로 @RestController@Controller + @ResponseBody 라고 생각하시면 됩니다.

만약 @RestController 로 선언하게 되면 회원가입 페이지 GET 요청이 왔을 때 페이지가 보여지는 것이 아니라 반환되는 내용이 String 으로 message body 에 들어가 home/signUpHome 라는 문자열이 그대로 보여지게 됩니다.



[ Controller 전체 흐름 ]

  1. DTO 를 검증했을 떄 오류가 있다면 오류가 발생한 필드와 에러 메세지를 Map 에 넣고 상태코드와 함께 반환합니다. 여기서 에러 메세지는 위에서 어노테이션에 지정한 message 가 들어가게 됩니다.

  2. 첫 번째 검증이 끝났다면 아이디 중복 확인을 수행합니다. 이는 서비스에서 구현한 isExistId() 를 활용하며, 마찬가지로 ResponseDTO 를 반환하는데 이번에는 에러 메세지를 직접 작성하여 반환합니다.

  3. 모든 검증이 끝난다면 save 로직을 수행하고, 저장된 User 를 반환합니다.




signUp.js


const signUpUser = document.querySelector("#sign-up-user");

// 버튼 클릭 이벤트 감지
signUpUser.addEventListener("click", () => {

    // 태그의 id 를 이용해 입력된 값들을 불러와 객체 생성
    const user = {
        id: document.querySelector("#id").value,
        password: document.querySelector("#password").value,
        name: document.querySelector("#name").value,
        email: document.querySelector("#email").value
    }

    // RestAPI 호출
    fetch("/signUp", {
        method: "post",
        headers: {"Content-Type": "application/json"},  // body 에 담긴 데이터 타입을 명시
        body: JSON.stringify(user)  // 생성한 객체를 JSON 형식으로 변경
    }).then(response => {
        response.text().then(textData => {
            ["error-id", "error-password", "error-name", "error-email"].forEach(tagId => {
                document.getElementById(tagId).innerText = '' })    // 기존에 출력된 오류 메세지 제거
            const res = JSON.parse(textData);   // ResponseDTO 를 JSON 형식으로 변환
            if (res.status === "OK") {
                alert("회원가입이 완료되었습니다");
                location.href = "/login"
            } else if (res.status === "CONFLICT") {
                alert(res.data);    // 동일한 id 가 존재합니다
            }  else if (res.status === "BAD_REQUEST") {
                Object.entries(res.data).forEach(   // res.data 에는 오류 필드와 메세지가 담겨있음
                    ([key, value]) =>
                        document.getElementById(`${key}`).innerText = `${value}`    // 에러 메세지 출력
                );
            } else {
                alert("회원가입에 실패하였습니다");
            }
        });

    })
});

[ 참고 ]

document.querySelector 를 통해 태그들을 가져올 수 있습니다. 이 때 앞에 # 을 붙이면 태그의 id 로 조회한다는 의미입니다. document.getElementById 를 사용해도 괜찮습니다.

response.text() 는 흔히 말해서 응답 메세지에 담긴 내용을 String 으로 반환합니다. 정확히는 JSON String 이기 때문에 이를 JSON.parse() 를 통해 온전한 JSON 형식으로 만들어줄 수 있습니다.

response.text() 를 통해 나온 String을 JSON 으로 파싱하면 아래처럼 data 에 오류가 발생한 필드와 DTO 에서 지정한 오류 메세지가 JSON 형식으로 담겨있습니다. 또한 status 에는 문자열로 응답 상태 코드가 들어있습니다.

{
    "data" : {
                "error-name" : "이름은 필수 입력값 입니다"
                "error-name" : "이메일은 필수 입력값 입니다"
            },
    "stataus" : "BAD_REQUEST"
}


[ JS 흐름 ]

  1. document.querySelector 로 버튼을 가져와 이벤트를 등록합니다. 이벤트를 등록하면서 클릭 시 로직을 함께 작성하는데 input 태그의 id 로 태그의 데이터를 가져와 object 를 만들고, 회원가입 API 를 호출하면서 이를 JSON 데이터로 변환합니다.

  2. 그 후 응답이 오면 이에 대한 처리를 해야하는데 여기서 정말 중요한 것은 response.text() 를 해야 ResponseDTO 에 담긴 상태코드와 데이터를 온전히 볼 수 있습니다. ( 이걸로 2시간 날렸습니다...ㅎ... )

  1. 오류가 발생했다면 JSON 데이터에 오류가 발생한 필드와 메세지가 존재하기 때문에 이 오류 필드는 태그의 id 와 동일하게 맞추었기 때문에 오류 필드를 id 로 해서 태그를 가져와 내부 텍스트를 오류 메세지로 지정합니다.

  2. 다시 회원가입을 호출했을 때 검증 후 오류 메세지를 다시 바꾸어야 하기 때문에 위에서 오류 필드를 배열로 만들고 태그의 내부 텍스트를 초기화시킵니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 12일

정리가 잘 된 글이네요. 도움이 됐습니다.

답글 달기