15 min to read
[스프링부트 시리즈25] 회원 가입기능 구현하기
회원 가입 기능 구현하기
먼저 SBB에 사용자가 회원 가입할 수 있는 화면을 만들고 회원 가입 기능을 완성해 보자. 여러분이 회원 가입 기능을 완성한다면 웹 프로그래밍은 거의 마스터했다고 할 수 있다. 그만큼 회원 가입 기능은 웹 사이트의 중요 기능이고 이를 구현하는 것은 웹 프로그래밍의 핵심이라 할 수 있다.
회원 가입 기능 구성하기
회원 가입 기능을 구현하려면 회원 정보와 관련된 데이터를 저장하고 이를 관리하는 엔티티와 리포지터리 등을 만들어야 하고, 폼과 컨트롤러와 같은 요소를 생성해 사용자로부터 입력받은 데이터를 웹 프로그램에서 사용할 수 있도록 만들어야 한다.
회원 엔티티 생성하기
지금까지는 질문, 답변 엔티티만 사용했다면 이제 회원 정보와 관련된 데이터를 저장하는 엔티티가 필요하다. 즉, 회원 엔티티를 구상해야 한다.
1) 회원 엔티티에는 최소한 다음 속성이 필요하다.
속성이름 | 설명 |
---|---|
username | 사용자 이름(또는 ID) |
password | 비밀번호 |
이메일 |
1) 회원 관련 자바 파일은 질문, 답변 도메인에 포함하기는 어색하므로 다음과 같이 com.mysite.sbb.user
패키지를 생성해 사용자(User) 도메인을 새로 만들어 보자.
1) 새로 생성한 com.mysite.sbb.user
패키지에 SiteUser.java
파일을 만들어 회원 정보 데이터를 저장할 회원 엔티티를 다음과 같이 작성해 보자.
[파일명:/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
는 유일한 값만 저장할 수 있음을 의미한다. 즉, 값을 중복되게 저장할 수 없음을 말한다. 이렇게 해야 username과 email에 동일한 값이 저장되는 것을 막을 수 있다.
4) SiteUser 엔티티를 생성하였으므로 로컬 서버를 재시작한 후, H2 콘솔에 접속하여 테이블이 잘 만들어졌는지 확인해 보자.
SITE_USER 테이블과 데이터 열들 그리고 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들을 확인할 수 있다.
- unique=true로 지정한 속성들은 DB에 유니크 인덱스로 생성된다.
- 여기서 쓰인 UK는 unique key의 줄임말이다.
User 리포지터리와 서비스 생성하기
SiteUser 엔티티가 준비되었으니 이제 User 리포지터리와 User 서비스를 만들어 보자.
1) 다음과 같이 UserRepository
를 만들어 보자. 리포지터리는 인터페이스임을 다시 한번 기억하자.
[파일명:/user/UserRepository.java]
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
}
SiteUser의 기본키 타입은 Long이므로 JpaRepository<SiteUser, Long>
으로 사용했다.
2) 이번에는 UserService.java
파일을 생성하여 서비스를 활용하기 위해 다음과 같은 내용을 작성하자.
[파일명:/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 리포지터리(UserRepository)를 사용하여 회원(User) 데이터를 생성하는 create
메서드를 추가했다. 이때 User의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다. 그러므로 스프링 시큐리티의 BCryptPasswordEncoder
클래스를 사용하여 암호화하여 비밀번호를 저장했다.
BCryptPasswordEncoder
클래스는 비크립트(BCrypt) 해시 함수를 사용하는데, 비크립트는 해시 함수의 하나로 주로 비밀번호와 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술이다.
3) 하지만 이렇게 BCryptPasswordEncoder
객체를 직접 new
로 생성하는 방식보다는 PasswordEncoder
객체를 빈으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면
BCryptPasswordEncoder`를 사용한 모든 프로그램을 일일이 찾아다니며 수정해야 하기 때문이다.
PasswordEncoder
는BCryptPasswordEncoder
의 인터페이스이다.
PasswordEncoder
빈을 만드는 가장 쉬운 방법은 @Configuration
이 적용된 SecurityConfig.java
파일에 @Bean
메서드를 새로 추가하는 것이다. 다음과 같이 SecurityConfig.java
파일을 수정하자.
[파일명: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((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
;
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
4) PasswordEncoder를 @Bean으로 등록하면 UserService.java도 다음과 같이 수정할 수 있다.
[파일명:/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 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 Password Encoder 객체를 주입받아 사용할 수 있도록 수정했다.
회원 가입 폼 생성하기
이번에는 com.mysite.sbb.user
패키지에 회원 가입을 위한 폼 클래스인 UserCreateForm
을 다음과 같이 만들어 보자.
[파일명:/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
은 해당 속성의 값이 이메일 형식과 일치하는지를 검증한다.
회원가입 컨트롤러 생성하기
회원 가입을 위한 엔티티와 서비스 그리고 폼이 준비되었으니 URL 매핑을 하기 위한 User 컨트롤러를 만들어 보자.
[파일명:/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로 요청되면 회원 가입을 진행하도록 했다.
회원 가입을 위한 템플릿은 signup_form.html 파일로 아직 생성하지 않았지만 곧 뒤이어 생성할 예정이다.
그리고 회원 가입 시 password1
과 password2
가 동일한지를 검증하는 조건문을 추가했다. 만약 2개의 값이 서로 일치하지 않을 경우에는 bindingResult.rejectValue
를 사용하여 입력 받은 2개의 비밀번호가 일치하지 않는다는 오류가 발생하게 했다. bindingResult.rejectValue
의 매개변수는 순서대로 각각 bindingResult.rejectValue
(필드명, 오류 코드, 오류 메시지)를 의미한다.
여기서 오류 코드는 임의로
passwordInCorrect
로 정의했다. 하지만 대형 프로젝트에서는 번역과 관리를 위해 오류 코드를 잘 정의하여 사용해야 한다.
그리고 userService.create
메서드를 사용하여 사용자로부터 전달받은 데이터를 저장한다.
회원 가입 화면 구성하기
이제 회원 가입 화면을 만들어 보자. 템플릿을 생성하여 사용자가 화면을 통해 회원 가입을 할 수 있도록 해보자.
회원 가입 템플릿 생성하기
다음과 같이 signup_form.html
파일을 작성해 회원 가입 화면을 구성하는 템플릿을 만들어 보자. 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 요소들을 추가하여 회원 가입 화면에 각각의 필드가 나타나도록 했다. 그리고 [회원 가입] 버튼을 누르면 form 데이터가 POST 방식으로 /user/signup/
URL에 전송된다.
내비게이션 바에 회원 가입 링크 추가하기
사용자가 회원 가입을 쉽게 할 수 있도록 회원 가입 화면으로 이동할 수 있는 링크를 내비게이션 바에 추가해 보자. 그러기 위해 navbar.html
에서 다음과 같은 내용을 추가해 보자.
[파일명: /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>
이와 같이 회원 가입을 할 수 있는 링크를 추가했다.
회원 가입 기능 확인하기
1) 회원 가입 기능을 확인하기 위해 로컬 서버를 재시작한 후, 브라우저에서 http://localhost:8080 URL을 요청해 보자. 이어서 내비게이션 바에 있는 ‘회원 가입’ 링크를 누르면 다음과 같은 회원 가입 화면이 등장한다.
2) 비밀번호, 비밀번호 확인 항목을 다르게 입력하고 [회원 가입] 버튼을 누르면 검증 오류가 발생하여 화면에 다음과 같은 오류 메시지가 표시될 것이다.
이처럼 우리가 만든 회원 가입 기능에는 각 항목에 값이 제대로 입력되었는지를 확인하는 필수 값 검증, 양식에 따라 이메일이 제대로 입력되었는지를 확인하는 이메일 규칙 검증 등이 적용되어 있다. 올바른 입력 값으로 회원 가입을 완료하면 메인 페이지로 리다이렉트될 것이다.
3) 이번에는 브라우저에서 http://localhost:8080/h2-console
URL을 요청해 보자. H2 콘솔에서 다음과 같이 SQL을 실행하여 앞에서 만든 회원 정보를 확인해 보자.
ELECT * FROM SITE_USER
BCryptPasswordEncoder
클래스에 의해 암호화되었다.
SBB에서 회원 가입을 완료하면 DB에 회원 정보가 저장되는 것을 확인할 수 있다. 즉, 이제 우리가 만든 SBB 서비스에 회원 가입 기능이 추가되었다!
중복 회원 가입 방지하기
1) 회원 가입할 때, 이미 등록된 사용자 ID 또는 이메일 주소로 회원 가입을 시도해 보자. 아마도 다음과 같은 오류가 발생할 것이다.
이미 등록된 사용자 ID 또는 이메일 주소를 DB에 저장하는 것은 회원 엔티티의 unique=true
설정으로 허용되지 않으므로 이와 같은 오류가 발생하는 것이다.
2) 화면에 500 오류 메시지를 그대로 보여 주는 것은 좋지 않다. 회원 가입 화면에서 비밀번호가 일치하지 않는다는 오류 메시지를 등장시킨 것처럼, 회원 가입 시 이미 동일한 ID와 이메일 주소가 있다는 것을 알리는 메시지가 나타나도록 다음과 같이 UserController
를 수정해 보자.
[파일명:/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
이라는 예외가 발생하므로 ‘이미 등록된 사용자입니다.’라는 오류 메시지가 화면에 표시하도록 했다. 그리고 그 밖에 다른 예외들은 해당 예외에 관한 구체적인 오류 메시지를 출력하도록 e.getMessage()
를 사용했다. 여기서 bindingResult.reject(오류 코드, 오류 메시지)
는 UserCreateForm
의 검증에 의한 오류 외에 일반적인 오류를 발생시킬 때 사용한다.
3) 이렇게 코드를 수정하고 다시 이미 등록된 사용자 ID로 회원 가입을 시도하면 다음과 같은 오류 메시지를 표시하는 화면을 볼 수 있다.