프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
이번 게시물에서는 기본적인 패키지 구조를 설명하고, Controller
, Service
, User
등으로 회원가입 기능을 만들어 보려고 한다.
이 프로젝트는 유저 관련 기능을 정리하는 미니 프로젝트이기 때문에 계층형 구조를 선택할 것이다. 일단 도메인이 User
밖에 없기도 하고, 협업이 아니기 때문에 도메인형 구조보다는 계층형 구조가 적합할 것이라고 생각했다.
따라서 이번 프로젝트의 대략적인 패키지 구조는 아래와 같다.
com.project.securelogin
├── config
│ └── SecurityConfig.java
├── controller
│ └── UserController.java
├── domain
│ └── User.java
│ └── CustomUserDetails.java
├── dto
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── repository
│ └── UserRepository.java
└── service
└── UserService.java
패키지 구조에 대한 자세한 내용은 [Spring Boot] 패키지 구조: 계층형 vs 도메인형에서 확인하자.
먼저 필요한 의존성을 pom.xml
의 dependencies
에 추가해준다.
<dependencies>
··· 생략 ···
<!-- 스프링 시큐리티 (Spring Security) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 유효성 검사 (Validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
··· 생략 ···
</dependencies>
회원가입 기능을 만드는 데 필요한 의존성은 두 가지이다.
Spring Security
: 비밀번호 암호화와 HttpSecurity
설정Validation
: 사용자가 양식에 맞는 값을 입력했는지 확인특히 이 중에서 스프링 시큐리티는 꽤 복잡한 설정을 담고 있는 SecurityConfig
를 생성해야 하는데, 이에 대한 자세한 내용은 추후 업로드하겠다.
Domain
domain
패키지 안에는 User
와 CustomUserDetails
가 있다. User
는 데이터베이스와 직접적으로 연결되는 엔티티 클래스이고, CustomUserDetails
는 UserDetails
를 상속받는 클래스로써 계정 잠김 여부나 활성화 여부 등을 확인한다.
User
클래스User
클래스는 주로 데이터베이스와 직접적으로 연결된 엔티티 클래스이다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class User {
@Id // 데이터베이스에서 자동으로 값을 생성해준다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id; // 사용자 IDX
@Column(nullable = false) // null값 허용X
private String username; // 사용자 이름
@Column(nullable = false)
private String password; // 비밀번호
@Column(nullable = false)
private String email; // 이메일
@CreationTimestamp // INSERT 쿼리가 발생할 때, 현재 시간을 자동으로 저장
private LocalDateTime created_at; // 회원가입한 시간
@UpdateTimestamp // UPDATE 쿼리가 발생할 때, 현재 시간을 자동으로 저장
private LocalDateTime updated_at; // 마지막으로 수정한 시간
private boolean accountNonExpired; // 계정 만료 여부
private boolean accountNonLocked; // 계정 잠김 여부
private boolean credentialsNonExpired; // 자격 증명 만료 여부
private boolean enabled; // 계정 활성화 여부
}
User
의 컬럼에 대해필드 정의: 사용자의 기본 정보인 사용자 이름(username
), 비밀번호(password
), 이메일(email
)으로 구성되어 있다.
시간 관리: created_at
과 updated_at
필드를 사용하여 사용자의 생성 시간과 마지막 업데이트 시간을 저장한다. JPA
에서 제공하는 어노테이션을 사용한다.
계정 관리 상태: accountNonExpired
, accountNonLocked
, credentialsNonExpired
, enabled
필드를 포함하여 사용자 계정의 상태를 관리합니다.
CustomUserDetails
클래스CustomUserDetails
클래스는 UserDetails
인터페이스를 구현하여 스프링 시큐리티에서 사용자의 인증 및 권한 관리를 위한 정보를 제공한다. 추후 잠금 횟수 등을 추가할 예정이라 Custom
클래스로 만들어주었다.
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final String username; // 사용자 이름
private final String password; // 비밀번호
private final String email; // 이메일
private final boolean accountNonExpired; // 계정 만료 여부
private final boolean accountNonLocked; // 계정 잠김 여부
private final boolean credentialsNonExpired; // 자격 증명 만료 여부
private final boolean enabled; // 계정 활성화 여부
private final Collection<? extends GrantedAuthority> authorities; // 사용자 권한 목록
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
사용자 인증 정보 제공: UserDetails
인터페이스의 메서드들을 구현하여 사용자의 인증 및 권한 정보를 스프링 시큐리티에 제공한다. 사용자가 제공한 인증 정보와 시스템에서 관리하는 사용자 정보를 연결하여 인증을 수행하는 데 필요한 정보를 제공한다.
계정 상태 관리: accountNonExpired
, accountNonLocked
, credentialsNonExpired
, enabled
등의 필드를 사용하여 사용자 계정의 상태를 나타낸다. 이 정보는 스프링 시큐리티가 사용자의 계정 상태를 확인하고 인증 및 권한 부여를 결정하는 데 사용된다.
권한 관리: authorities
필드를 통해 사용자가 가진 권한 목록을 제공한다. 스프링 시큐리티는 이 정보를 기반으로 사용자의 접근 권한을 관리하고 제어한다.
DTO
UserRequestDTO
@Getter
@AllArgsConstructor
public class UserRequestDTO {
@NotBlank(message = "사용자 이름을 입력해주세요")
private final String username;
@NotBlank(message = "비밀번호를 입력해주세요")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,16}$",
message = "비밀번호는 영문 대소문자, 숫자, 특수문자를 포함하여 8~16자여야 합니다")
private final String password;
@NotBlank(message = "이메일을 입력해주세요")
@Email(message = "올바른 이메일 형식이 아닙니다")
private final String email;
@Override
public String toString() {
return "SignUpRequest{" +
"username='" + username + '\'' +
", password='[PROTECTED]'" +
", email='" + email + '\'' +
'}';
}
}
Validation
)의 위치유효성 검사는 사용자가 입력한 값이 형식에 맞는지 확인하는 기능이다. 따라서 사용자가 입력한 값을 담는 곳, 즉 UserRequestDTO
안에 위치해야 한다.
Validation
어노테이션@NotBlank
: 빈 값을 허용하지 않는 어노테이션@Pattern
: 정규식 등을 통해 패턴을 정의한다. 어떤 문자를 포함할지, 길이를 몇까지 할지 등 다양하게 형식을 만들 수 있다.@Email
: 이메일 형식에 맞지 않으면 message
를 리턴한다.유효성 검사에 대한 더 자세한 내용은 유효성 검사 (Validation) 글을 참고하자.
UserResponseDTO
@Getter
@AllArgsConstructor
public class UserResponseDTO {
private String username;
private String email;
}
회원 가입/수정 등을 정상적으로 마쳤을 때 사용자에게 정보를 보여주기 위한 DTO
객체이다. 비밀번호는 보안을 위해 노출시키지 않는다.
Repository
UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
username
은 동명이인의 가능성이 있기 때문에 나는 Email
로 유저를 구분할 것이다. 따라서 UserRepsitory
에서도 findByEmail
과 existsByEmail
을 추가해 이메일 중복 체크, 유저 조회 등을 할 수 있게 해준다.
Config
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean // 비밀번호 암호화를 위한 PasswordEncoder 빈 생성
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean // AuthenticationManager 빈을 생성 (인증 요청 처리)
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean // SecurityFilterChain 설정 (스프링 부트 3부터는 FilterChain 방식 사용)
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// REST API에서는 불필요한 CSRF 보안 비활성화
.csrf(AbstractHttpConfigurer::disable)
// JWT 토큰 인증 시스템을 사용할 것이기에 서버가 세션을 생성하지 않도록 한다.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// HTTP 요청에 대한 인가 규칙 설정
.authorizeHttpRequests(auth -> auth
// 로그인과 회원가입 페이지는 누구나 접근 가능
.requestMatchers("/login", "/signup").permitAll()
.anyRequest().authenticated() // 그 외 요청은 인증 필요
);
return httpSecurity.build();
}
}
@Bean // 비밀번호 암호화를 위한 PasswordEncoder 빈 생성
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
회원가입에서 SecurityConfig
의 가장 큰 역할은 비밀번호 암호화이다. 위 코드처럼 PasswordEncoder
빈을 생성한다.
이렇게 생성한 빈은 UserService
에서 회원가입 메서드를 만들 때 사용된다.
Service
UserService
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserResponseDTO signUp(UserRequestDTO userRequestDTO) {
// 이메일 중복 체크
if (isEmailAlreadyExists(userRequestDTO.getEmail())) {
throw new IllegalStateException("이미 등록된 이메일입니다.");
}
// 비밀번호 암호화
String encodedPassword = encodePassword(userRequestDTO.getPassword());
// 회원 정보 생성
User user = User.builder()
.username(userRequestDTO.getUsername())
.password(encodedPassword)
.email(userRequestDTO.getEmail())
.accountNonExpired(true)
.accountNonLocked(true)
.credentialsNonExpired(true)
.enabled(true)
.build();
// 회원 저장
userRepository.save(user);
// UserResponseDTO 생성
return new UserResponseDTO(user.getUsername(), user.getEmail());
}
// 이메일 중복 체크 메서드
public boolean isEmailAlreadyExists(String email) {
return userRepository.existsByEmail(email);
}
// 비밀번호 암호화 메서드
private String encodePassword(String password) {
return passwordEncoder.encode(password);
}
}
회원가입 메서드에서는 본격적으로 User
를 빌드하기 전 이메일 중복 여부를 확인한다. 이때, 이메일 중복 체크와 비밀번호는 다른 메서드에서도 사용될 수 있기에 별개로 빼준다.
UserService
에서는 PasswordEncoder
를 불러와 사용자가 입력한 비밀번호 값을 변환한 후 암호화된 비밀번호를 다른 값들과 User
에 빌드하고 데이터베이스에 저장한다.
회원가입 할 때는 계정 만료나 잠김 여부를 모두 true
(만료되지 않음, 잠기지 않음)으로 설정해준 후 빌드한다.
Controller
UserController
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/signup")
// @Valid 어노테이션을 사용해 `SignUpRequest`의 유효성 검사를 활성화, 통과한 경우 서비스 코드 호출
public ResponseEntity<UserResponseDTO> signUp(@Valid @RequestBody UserRequestDTO userRequestDTO) {
UserResponseDTO userResponseDTO = userService.signUp(userRequestDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(userResponseDTO);
}
}
여기서 포인트는 리턴값을 ResponseEntity<UserResponseDTO>
로 설정하는 것이다. 리턴값에 HTTP 상태 코드와 사용자가 입력한 값을 보여주는 UserResponseDTO
를 함께 보내준다. 원래는 조금 더 복잡한 예외 처리가 들어가야 하지만, 지금 단계에서는 간단히 작성하였다.
Test
지금까지의 코드를 전부 작성했다면 정상적으로 회원가입이 될 것이다. 참고로 아직 SecurityConfig
작성이 완성되지 않았기에 난 POSTMAN
으로 테스트를 해주었다.
위 스크린샷처럼 정상적으로 데이터베이스에 User
정보가 들어간 것을 볼 수 있다. 비밀번호 암호화 또한 정상적으로 처리되었다.
이번 게시물에서는 회원가입 기능을 만들어보았다. 다음 게시물에서는 본격적인 로그인 기능을 만들어보며 JWT
를 도입해 보도록 하겠다.