이번에는 SBB에 회원가입 기능을 구현해 보자.
회원가입 기능을 만들어 보았다면 웹 프로그래밍은 거의 마스터했다고 할 수 있다. 그만큼 회원가입 기능은 웹 사이트에서 핵심 중의 핵심이라 할 수 있다.
지금까지는 질문, 답변 엔티티만 사용했다면 이제 회원 정보를 위한 엔티티가 필요하다. 회원 정보 엔티티에는 최소한 다음과 같은 속성이 필요하다.

그리고 회원은 질문, 답변 도메인이 아니므로 user라는 도메인을 사용할 것이다. 다음과 같이 com.mysite.sbb.user 패키지를 생성하자.
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에 동일한 값이 저장되지 않는다.
다음과 같이 UserRepository를 작성하자.
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository {
}
SiteUser의 PK의 타입은 Long이다. 따라서 JpaRepository<SiteUser, Long>처럼 사용했다.
그리고 다음과 같이 UserService를 작성하자.
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 빈으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.
PasswordEncoder는 BCryptPasswordEncoder의 인터페이스이다.
PasswordEncoder 빈을 만드는 가장 쉬운 방법은 @Configuration이 적용된 SecurityConfig에 @Bean 메서드를 생성하는 것이다. 다음과 같이 SecurityConfig를 수정하자.
(... 생략 ...)
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();
}
}
BCryptPassowrdEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용할 수 있도록 수정했다.
그리고 회원 가입을 위한 폼 클래스를 작성하자. => 이게 DTO임
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는 폼 유효성 검증시 문자열의 길이가 최소와 최대길이 사이에 해당하는지를 검증한다. password1과 password2는 "비밀번호"에 대한 속성이다. 로그인 할때는 비밀번호가 한번만 필요하지만 회원가입시에는 입력한 비밀번호가 정확한지 확인하기 위해 2개의 필드가 필요하다. 그리고 email 속성에는 @Email 어노테이션이 적용되었다. @Email은 해당 속성의 값이 이메일형식과 일치하는지를 검증한다.
이제 사용자 엔티티와 서비스 그리고 폼이 준비되었으니 회원가입을 위한 User 컨트롤러를 만들어보자
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"로 정의했다.
대형 프로젝트에서는 번역과 관리를 위해 오류코드를 잘 정의하여 사용해야 한다.
이어서 회원가입 템플릿을 작성하자. 다음처럼 signup_form.html 파일을 작성하자

회원가입을 위한 "사용자 ID", "비밀번호", "비밀번호 확인", "이메일"에 해당되는 input 엘리먼트를 추가했다. <회원가입> 버튼을 누르면 폼 데이터가 POST 방식으로 /user/signup/ URL로 전송된다.
이제 회원가입 화면으로 이동할 수 있는 링크를 내비게이션 바에 추가하자.

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

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

이처럼 우리가 만든 회원가입 기능에는 필숫값 검증, 이메일 규칙 검증 등이 적용되어 있다. 올바른 입력값으로 회원가입을 완료하면 메인 페이지로 리다이렉트될 것이다.
H2 콘솔에서 다음의 SQL을 실행하여 바로 앞 단계를 거쳐 만든 회원 정보를 확인해 보자.

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

동일한 사용자ID 또는 동일한 이메일 주소로 사용자 데이터를 저장하는 것은 unique=true 설정으로 인해 허용되지 않기 때문에 오류가 발생하는 것은 당연하다. 하지만 화면에 이렇게 500 오류 메시지를 그대로 보여주는 것은 좋지 않다. 따라서 회원가입시 발생하는 오류를 다음과 같이 처리해 주자.
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 예외가 발생할 경우 "이미 등록된 사용자입니다."라는 오류를 화면에 표시하도록 했다. 그리고 다른 오류의 경우에는 해당 오류의 메시지를 출력하도록 했다.
bindingResult.reject(오류코드, 오류메시지)는 특정 필드의 오류가 아닌 일반적인 오류를 등록할때 사용한다.
이렇게 수정하고 다시 동일한 사용자로 로그인을 하면 다음과 같이 정상적인 오류를 표시하는 화면을 볼수 있다.
