회원가입 페이지를 구현하려면 아래 사항들이 필요하다
✅ 엔티티
id, 비밀번호, 이메일주소
✅ 리포지터리
✅ 서비스
회원가입을 진행하는 create 메소드를 만들어야 함
✅ 컨트롤러
템플릿과 매핑해주는 작업이 필요함
✅ 템플릿
회원가입 페이지를 사용자에게 보여줄 view가 필요함
엔티티 부터 만들어보자!
새로운 패키지에서 SiteUser엔티티를 작성한다
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;
}
스프링 시큐리티에 이미 User 클래스가 있기 때문이다. 물론 패키지명이 달라 User라는 이름을 사용할수 있지만 패키지 오용으로 인한 오류가 발생할수 있으므로 User 대신 SiteUser라는 이름으로 명명하였다.
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long>{
}
❓ 리포지터리란?
리포지터리는 엔티티에 의해 생성된 데이터베이스 테이블에 접근하는 메서드들(예: findAll, save 등)을 사용하기 위한 인터페이스이다.
데이터 처리를 위해서는 테이블에 어떤 값을 넣거나 값을 조회하는 등의 CRUD(Create, Read, Update, Delete)가 필요하다. 이 때 이러한 CRUD를 어떻게 처리할지 정의하는 계층이 바로 리포지터리이다.
package com.mysite.sbb.user;
//import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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; // 객체를 직접 생성하여 사용하지 않고 빈으로 등록
// user 데이터를 생성하는 create 메서드 추가
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
// 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장
// BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
✅ BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 암호화한다.
✅ 주석처리한 부분
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장하기 위해 새로운 객체를 생성하였다. 그러나, 이렇게 새로운 객체를 생성하는 것 보다 PasswordEncoder 빈(bean)으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
위와 같은 코드를 추가해 주고, 서비스에서 위 처럼 주석처리 하거나 해당부분 삭제한다!
💡 Bean이란?
빈(Bean)은 스프링 컨테이너에 의해 관리되는 재사용 가능한 소프트웨어 컴포넌트이다.
즉, 스프링 컨테이너가 관리하는 자바 객체를 뜻하며, 하나 이상의 빈(Bean)을 관리한다.✅ 스프링의 특징에는 제어의 역전(IoC)이 있다.
제어의 역전이란, 간단히 말해서 객체의 생성 및 제어권을 사용자가 아닌 스프링에게 맡기는 것이다. 지금까지는 사용자가 new연산을 통해 객체를 생성하고 메소드를 호출했다. IoC가 적용된 경우에는 이러한 객체의 생성과 사용자의 제어권을 스프링에게 넘긴다. 사용자는 직접 new를 이용해 생성한 객체를 사용하지 않고, 스프링에 의하여 관리당하는 자바 객체를 사용한다. 이 객체를 '빈(bean)'이라 한다.
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) // 3-25글자 사이
@NotEmpty(message = "사용자ID는 필수항목입니다.")
private String username;
// 검증 실패시 출력할 메세지
@NotEmpty(message = "비밀번호는 필수항목입니다.")
private String password1;
@NotEmpty(message = "비밀번호 확인은 필수항목입니다.")
private String password2;
@NotEmpty(message = "이메일은 필수항목입니다.")
@Email // 이메일 형식인지 확인해주는 애너테이션
private String email;
}
💡 검증을 위한 DTO가 필요하다는 것을 생각하지 못하였다! 질문 작성 페이지를 만들 때 질문에 대한 제목과 내용을 검증하는 DTO를 만들었다는 것을 생각해보자!
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") // URL이 GET형식으로 요청될때
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup") // URL이 POST형식으로 요청될 때
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
// 에러가 있었는 지 체크
if (bindingResult.hasErrors()) {
return "signup_form";
}
// 패스워드와 패스워드 확인이 일치하는 지 여부
// 값이 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 오류를 발생시킴
// bindingResult.rejectValue(필드명, 오류코드, 에러메시지)
if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
bindingResult.rejectValue("password2", "passwordInCorrect",
"2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
return "redirect:/";
}
}
✅ bindingResult.rejectValue(필드명, 오류코드, 에러메시지)
여기서 오류코드는 일단 "passwordInCorrect"로 정의했다.
💡 BindingResult 란?
BindingResult는 스프링이 제공하는 검증 오류 처리 방법의 핵심
BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체
검증 오류가 발생하면 BindingResult 객체에 보관
<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">
1 <div th:replace="~{form_error :: 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>
여기서 처음에 똑같이 따라썼는데 자꾸 500 에러가 떴다. 오류 내용이 계속
an error happened during template parsing (template: "class path resource [templates/signup_form.html]")
이런식으로 뜨는 걸 보니 템플릿에 오류가 있다는 것을 알게 되었고, 하나씩 지우면서 테스트해본 결과 1번줄에서 에러가 뜨는 걸 확인할 수 있었다.
✅ <div th:replace="~{form_error :: formErrorsFragment}"></div>
해당 부분은 타임리프의 th:replace 속성을 사용하여 div 엘리먼트를 form_error.html 파일의 th:fragment 속성명이 formErrorsFragment인 엘리먼트로 교체하라는 의미인데,
여기서 나는 파일명을 form_error.html
로 하였고, 책에서는 form_errors
로 하였기 때문에 자꾸 에러가 뜬 것 이었다.
an error happened during template parsing (template: "class path resource [templates/signup_form.html]")
👉 템플릿 내에 코드를 지워가면서 어느 부분에 에러가 있는지 확인해보자!
위와 같이 에러메세지가 출력되고, 이메일의 경우 @을 포함해달라는 경고 박스가 뜬다!
<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img src="\home.png" alt="사진없음" width="50" height="50"></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>
1 <li class="nav-item">
2 <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
3 </li>
</ul>
</div>
</div>
</nav>
💡 네비게이션 바에 회원가입 링크를 추가해줘야 된다는 것을 생각하지 못하였다.
좌측을 보면 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들이 보인다.
동일한 사용자ID 또는 동일한 이메일 주소로 사용자 데이터를 저장하는 것은 unique=true 설정으로 인해 허용되지 않기 때문에 오류가 발생한다.
package com.mysite.sbb.user;
import jakarta.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 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) {
// 사용자ID 또는 이메일 주소가 동일할 경우에는 DataIntegrityViolationException이 발생
e.printStackTrace();
// DataIntegrityViolationException 예외가 발생할 경우 "이미 등록된 사용자입니다."라는 오류를 화면에 표시
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "signup_form";
}catch(Exception e) {
e.printStackTrace();
// 다른 오류의 경우에는 해당 오류의 메세지 출력
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
✅ 수정 전
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
✅ 수정 후
import org.springframework.dao.DataIntegrityViolationException;
try {
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
} catch(DataIntegrityViolationException e) {
// 사용자ID 또는 이메일 주소가 동일할 경우에는 DataIntegrityViolationException이 발생
e.printStackTrace();
// DataIntegrityViolationException 예외가 발생할 경우 "이미 등록된 사용자입니다."라는 오류를 화면에 표시
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "signup_form";
}catch(Exception e) {
e.printStackTrace();
// 다른 오류의 경우에는 해당 오류의 메세지 출력
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
userService.create
부분을 try -catch문으로 감쌌다.
✅ bindingResult.reject(오류코드, 오류메시지)
: 특정 필드의 오류가 아닌 일반적인 오류를 등록할때 사용
중복(사용자id or 이메일)시 에러메세지가 출력된다.
✅ Bean을 사용하는 방법
✅ 회원가입 시 내용을 검증해 줄 DTO가 필요하다
✅ bindingResult, Validation에 대한 내용
✅ 사용자는 내가 원하는 대로 움직여주지 않는다. 중복가입에 대한 부분
✅ 템플릿 오류일 때 한 줄씩 확인해보기, 파일명이나 태그가 잘 되었는 지 등등
✅ bean에 대한 추가적인 공부 필요
✅ bindingResult, Validation에 대한 공부 필요