본문 바로가기
Follow Work/SpringbootBoard

[StringBoot] 회원가입 (19)

by ReCode.B 2022. 8. 16.
728x90

참고 링크 : https://wikidocs.net/book/7601

오라클DB와 intelliJ로 작업하였습니다


회원 정보를 위한 엔티티

지금까지는 질문, 답변 엔티티만 사용했다면 이제 회원 정보를 위한 엔티티가 필요하다. 회원 정보 엔티티에는 최소한 다음과 같은 속성이 필요하다.

속성설명

username 사용자 이름 (사용자 ID)
password 비밀번호
email 이메일

User 도메인

그리고 회원은 질문, 답변 도메인이 아니므로 user라는 도메인을 사용할 것이다. user패키지를 생성하자

 

SiteUser 엔티티

그리고 사용자를 관리할 SiteUser 엔티티를 다음처럼 작성하자.

package com.gosari.repick_project.user;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;

    @Column(unique = true)
    private String email;
}

Question, Answer 엔티티와 동일한 방법으로 SiteUser 엔티티를 만들었다.

엔티티명을 User 대신 SiteUser로 한 이유는 스프링 시큐리티에 이미 User 클래스가 있기 때문이다.

물론 패키지명이 달라 User라는 이름을 사용할수 있지만 패키지 오용으로 인한 오류가 발생할수 있으므로

이 책에서는 User 대신 SiteUser라는 이름으로 명명하였다.

그리고 username, email 속성에는 @Column(unique = true) 처럼 unique = true를 지정했다. 

unique = true는 유일한 값만 저장할 수 있음을 의미한다.

즉, 값을 중복되게 저장할 수 없음을 뜻한다. 이렇게 해야 username과 email에 동일한 값이 저장되지 않는다.

SiteUser 엔티티를 작성하고  DB tool을 통해 테이블이 잘 만들어졌는지 확인해 보자.

SITE_USER 테이블과 컬럼들 그리고 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들이 보일 것이다.

 

User 리포지터리

다음과 같이 UserRepository를 작성하자.

package com.gosari.repick_project.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
}

SiteUser의 PK의 타입은 Long이다. 따라서 JpaRepository<SiteUser, Long>처럼 사용했다.

 

User 서비스

그리고 다음과 같이 UserService를 작성하자.

package com.gosari.repick_project.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;

    public SiteUser create(String username, String email, String password){
        SiteUser user = new SiteUser();
        user.setUsername(username);
        user.setEmail(email);

        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        user.setPassword(passwordEncoder.encode(password));

        this.userRepository.save(user);
        return user;
    }
}

@RequiredArgsConstructor는 롬복이 제공하는 애너테이션으로 final이 붙은 속성을 포함하는 생성자를 자동으로 생성하는 역할을 한다.

 

User 서비스에는 User 리포지터리를 사용하여 User 데이터를 생성하는 create 메서드를 추가했다.

이 때 사용자의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다.

암호화를 위해 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.

BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 암호화한다.

하지만 이렇게 BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다는

PasswordEncoder 빈(bean)으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.

PasswordEncoder는 BCryptPasswordEncoder의 인터페이스이다.

 

 

PasswordEncoder 빈(bean)을 만드는 가장 쉬운 방법은

@Configuration이 적용된 SecurityConfig에 @Bean 메서드를 생성하는 것이다.

다음과 같이 SecurityConfig를 수정하자.

package com.gosari.repick_project.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;


@Configuration //스프링환경설정파일의미-스프링시큐리티의설정
@EnableWebSecurity //모든요청URL이 스프링시큐리티의 제어받음
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /*스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정 가능*/

        http.authorizeRequests().antMatchers("/**").permitAll();
        /* 모든 인증되지 않은 요청을 허락한다는 의미 - 로그인하지않더라도 모든페이지에 접근가능*/

        return http.build();
    }

    //PasswordEncoder 빈(bean)
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
package com.gosari.repick_project.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public SiteUser create(String username, String email, String password){
        SiteUser user = new SiteUser();
        user.setUsername(username);
        user.setEmail(email);

        /*BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        user.setPassword(passwordEncoder.encode(password));*/
        user.setPassword(passwordEncoder.encode(password));

        this.userRepository.save(user);
        return user;
    }
}

BCryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고

빈으로 등록한 PasswordEncoder 객체를 주입받아 사용하도록 수정했다.

 

회원가입 폼

그리고 회원 가입을 위한 폼 클래스를 작성하자. 다음처럼 UserCreateForm을 만들자.

package com.gosari.repick_project.user;

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

@Getter
@Setter
public class UserCreateForm {
    @Size(min = 3, max = 25)
    @NotEmpty(message = "사용자 ID는 필수항목입니다.")
    private String username;

    @NotEmpty(message = "비밀번호는 필수항목입니다.")
    private String password1;

    @NotEmpty(message = "비밀번호 확인은 필수항목입니다.")
    private String password2;

    @NotEmpty(message = "이메일은 필수항목입니다")
    @Email
    private String email;
}

username은 필수항목이고 길이가 3-25 사이여야 한다는 검증조건을 설정했다.

@Size는 폼 유효성 검증시 문자열의 길이가 최소길이(min)와 최대길이(max) 사이에 해당하는지를 검증한다.

password1과 password2는 "비밀번호"와 "비밀번호확인"에 대한 속성이다.

로그인 할때는 비밀번호가 한번만 필요하지만 회원가입시에는 입력한 비밀번호가 정확한지

확인하기 위해 2개의 필드가 필요하다.

그리고 email 속성에는 @Email 애너테이션이 적용되었다.

@Email은 해당 속성의 값이 이메일형식과 일치하는지를 검증한다.

 

회원가입 컨트롤러

이제 사용자 엔티티와 서비스 그리고 폼이 준비되었으니 회원가입을 위한 User 컨트롤러를 만들어보자.

package com.gosari.repick_project.user;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.validation.Valid;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm){
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm,
                         BindingResult bindingResult){
        if(bindingResult.hasErrors()){
            return "signup_form";
        }
        if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())){
            bindingResult.rejectValue("password2", "passwordInCorrect",
                    "2개의 패스워드가 일치하지 않습니다");
            return "signup_form";
        }

        userService.create(userCreateForm.getUsername(),
                            userCreateForm.getEmail(), userCreateForm.getPassword1());

        return "redirect:/";
    }
}

/user/signup URL이 GET으로 요청되면 회원 가입을 위한 템플릿을 렌더링하고

POST로 요청되면 회원가입을 진행하도록 했다.

그리고 회원 가입시 비밀번호1과 비밀번호2가 동일한지를 검증하는 로직을 추가했다.

만약 2개의 값이 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 오류가 발생하게 했다.

 bindingResult.rejectValue의 각 파라미터는 bindingResult.rejectValue(필드명, 오류코드, 에러메시지)를 의미하며 여기서 오류코드는 일단 "passwordInCorrect"로 정의했다.

대형 프로젝트에서는 번역과 관리를 위해 오류코드를 잘 정의하여 사용해야 한다.

 

회원가입 템플릿

이어서 회원가입 템플릿을 작성하자. 다음처럼 signup_form.html 파일을 작성하자.

<html  xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">

<div layout:fragment="content" class="container my-3">
    <div class="my-3 border-bottom">
        <div>
            <h4>회원가입</h4>
        </div>
    </div>

    <form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
        <div th:replace="form_errors :: formErrorsFragment"></div>

        <div class="mb-3">
            <label for="username" class="form-label">사용자ID</label>
            <input type="text" th:field="*{username}" class="form-control">
        </div>

        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" th:field="*{password1}" class="form-control">
        </div>

        <div class="mb-3">
            <label for="password" class="form-label">비밀번호 확인</label>
            <input type="password" th:field="*{password2}" class="form-control">
        </div>

        <div class="mb-3">
            <label for="email" class="form-label">이메일</label>
            <input type="email" th:field="*{email}" class="form-control">
        </div>

        <button type="submit" class="btn btn-primary">회원가입</button>

    </form>
</div>
</html>

타임리프의 th:replace 속성을 사용하면 공통 템플릿을 템플릿 내에 삽입할수 있다. 

<div th:replace="form_errors :: formErrorsFragment"></div> 의 의미는

div 엘리먼트 form_errors.html 파일의 th:fragment 속성명이 formErrorsFragment

엘리먼트로 교체하라는 의미이다.

 

 th:field 속성

타임리프가 value 속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

 

회원가입을 위한 "사용자 ID", "비밀번호", "비밀번호 확인", "이메일"에 해당되는 input 엘리먼트를 추가했다.

<회원가입> 버튼을 누르면 폼 데이터가 POST 방식으로 /user/signup/ URL로 전송된다.

 

 

내비게이션 바에 회원가입 링크 추가하기

이제 회원가입 화면으로 이동할 수 있는 링크를 내비게이션 바에 추가하자.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
                aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" href="#">로그인</a>
                </li>
                <!--회원가입화면으로 이동할수있는 링크-->
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>
                
            </ul>
        </div>
    </div>
</nav>

application.properties 설정 create로 !!!!!

spring.jpa.hibernate.ddl-auto=create

 

회원가입 실행해 보기

이제 내비게이션 바의 "회원가입" 링크를 누르면 다음과 같은 회원가입 화면이 나온다.

입력값 중에서 비밀번호, 비밀번호 확인을 다르게 입력하고 <회원가입>을 누르면 검증 오류가 발생하여 화면에 다음과 같은 오류 메시지를 표시해 줄 것이다.

이처럼 우리가 만든 회원가입 기능에는 필숫값 검증, 이메일 규칙 검증 등이 적용되어 있다. 올바른 입력값으로 회원가입을 완료하면 메인 페이지로 리다이렉트될 것이다.

 

회원가입 데이터 확인해보기

DBTool로 정보확인해보자

축하한다. 이제 SBB 서비스에 회원가입 기능이 추가되었다.

중복 회원가입

이번에는 이미 가입한 동일한 사용자ID, 또는 동일한 이메일 주소로 회원가입을 진행해 보자. 아마도 다음과 같은 오류가 발생할 것이다.

동일한 사용자ID 또는 동일한 이메일 주소로 사용자 데이터를 저장하는 것은 unique=true 설정으로 인해 허용되지 않기 때문에 오류가 발생하는 것은 당연하다. 하지만 화면에 이렇게 500 오류 메시지를 그대로 보여주는 것은 좋지 않다. 따라서 회원가입시 발생하는 오류를 다음과 같이 처리해 주자.

 

package com.gosari.repick_project.user;

import javax.validation.Valid;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.dao.DataIntegrityViolationException;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm) {
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "signup_form";
        }

        if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect",
                    "2개의 패스워드가 일치하지 않습니다.");
            return "signup_form";
        }
        
        /*중복회원가입막기*/
        try {
            userService.create(userCreateForm.getUsername(),
                    userCreateForm.getEmail(), userCreateForm.getPassword1());
        }catch(DataIntegrityViolationException e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
            return "signup_form";
        }catch(Exception e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", e.getMessage());
            return "signup_form";
        }

        return "redirect:/";
    }
}

사용자ID 또는 이메일 주소가 동일할 경우에는 DataIntegrityViolationException이 발생하므로 DataIntegrityViolationException 예외가 발생할 경우 "이미 등록된 사용자입니다."라는 오류를 화면에 표시하도록 했다. 그리고 다른 오류의 경우에는 해당 오류의 메시지(e.getMessage())를 출력하도록 했다.

bindingResult.reject(오류코드, 오류메시지)는 특정 필드의 오류가 아닌 일반적인 오류를 등록할때 사용한다.

 

이렇게 수정하고 다시 동일한 사용자로 로그인을 하면 다음과 같이 정상적인 오류를 표시하는 화면을 볼수 있다.

728x90