겉바속촉

[점프투스프링부트] 3-06. 회원가입 본문

IT 일기 (상반기)/SPRING 기초

[점프투스프링부트] 3-06. 회원가입

겉바속촉 2023. 3. 3. 14:42
728x90
반응형

 

점프투스프링부트 3-06

 

 

 

목표 : 회원가입 기능 구현하기

 

 

 

이번에는 SBB에 회원가입 기능을 구현해 보자.

 

 

1.  회원 정보를 위한 엔티티

 

지금까지는 질문, 답변 엔티티만 사용했다면 이제 회원 정보를 위한 엔티티가 필요하다.

회원 정보 엔티티에는 최소한 다음과 같은 속성이 필요하다.

 

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

 

1-1. User 도메인

 

회원은 질문, 답변 도메인이 아니므로 user라는 도메인을 사용할 것이다.

다음과 같이 com.mysite.sbb.user 패키지를 생성하자.

 

 

 

1-2. SiteUser 엔티티

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/user/SiteUser.java]

package com.mysite.sbb.user;

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

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    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에 동일한 값이 저장되지 않는다.

 

 

1-3.  SiteUser 테이블

 

SiteUser 엔티티를 작성하고 H2 콘솔에 접속하여 테이블이 잘 만들어졌는지 확인해 보자.

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

 

 

2. User 리포지터리와 서비스

 

사용자 엔티티가 준비되었으니 이제 User 리포지터리와 User 서비스를 만들어 보자.

 

2-1. User 리포지터리

 

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

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserRepository.java]

package com.mysite.sbb.user;

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

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

 

SiteUser의 PK의 타입은 Long이다.

따라서 JpaRepository<SiteUser, Long>처럼 사용했다.

 

 

2-2. User 서비스

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

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserService.java]

package com.mysite.sbb.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

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

 

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

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

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

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

 

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

PasswordEncoder 빈(bean)으로 등록해서 사용하는 것이 좋다.

 

왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.

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

 

 

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

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

 

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

[파일명:/sbb/src/main/java/com/mysite/sbb/SecurityConfig.java]

(... 생략 ...)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll()
            .and()
                .csrf().ignoringRequestMatchers(
                        new AntPathRequestMatcher("/h2-console/**"))
            .and()
                .headers()
                .addHeaderWriter(new XFrameOptionsHeaderWriter(
                        XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
            ;

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

이렇게 PasswordEncoder를 @Bean으로 등록하면

UserService도 다음과 같이 수정할수 있다.

 

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserService.java]

package com.mysite.sbb.user;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@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);
        user.setPassword(passwordEncoder.encode(password));
        this.userRepository.save(user);
        return user;
    }
}

 

BCryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용하도록 수정했다.

 

 

 

 

 

3. 회원가입 폼

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

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserCreateForm.java]

package com.mysite.sbb.user;

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

import lombok.Getter;
import lombok.Setter;

@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은 해당 속성의 값이 이메일형식과 일치하는지를 검증한다.

 

 

4. 회원가입 컨트롤러

 

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

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserController.java]

package com.mysite.sbb.user;

import jakarta.validation.Valid;

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

        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"로 정의했다.

 

 

 

 

 

5. 회원가입 템플릿

 

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

 

[파일명: /sbb/src/main/resources/templates/signup_form.html]

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

 

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

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

 

 

 

 

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

 

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

[파일명: /sbb/src/main/resources/templates/navbar.html]

<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>
        (... 생략 ...)
        <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>

 

 

7.  회원가입 실행해 보기

 

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

 

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

 

 

이처럼 우리가 만든 회원가입 기능에는 필숫값 검증, 이메일 규칙 검증 등이 적용되어 있다.

올바른 입력값으로 회원가입을 완료하면 메인 페이지로 리다이렉트될 것이다.

 

 

 

 

 

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

 

H2 콘솔에서 다음의 SQL을 실행하여 바로 앞 단계를 거쳐 만든 회원 정보를 확인해 보자.

SELECT * FROM SITE_USER 

 

 

 

 

 

9. 중복 회원가입

 

이번에는 이미 가입한 동일한 사용자ID, 또는 동일한 이메일 주소로 회원가입을 진행해 보자.

아마도 다음과 같은 오류가 발생할 것이다.

 

 

동일한 사용자ID 또는 동일한 이메일 주소로 사용자 데이터를 저장하는 것은 unique=true 설정으로 인해 허용되지 않기 때문에 오류가 발생하는 것은 당연하다.

하지만 화면에 이렇게 500 오류 메시지를 그대로 보여주는 것은 좋지 않다.

따라서 회원가입시 발생하는 오류를 다음과 같이 처리해 주자.

 

 

[파일명:/sbb/src/main/java/com/mysite/sbb/user/UserController.java]

package com.mysite.sbb.user;

(... 생략 ...)
import org.springframework.dao.DataIntegrityViolationException;
(... 생략 ...)
public class UserController {

    (... 생략 ...)

    @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
반응형