[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (4) - 회원가입

김찬미·2024년 7월 3일
0
post-thumbnail

프로젝트 전체 코드: 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.xmldependencies에 추가해준다.

<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 패키지 안에는 UserCustomUserDetails가 있다. User는 데이터베이스와 직접적으로 연결되는 엔티티 클래스이고, CustomUserDetailsUserDetails를 상속받는 클래스로써 계정 잠김 여부나 활성화 여부 등을 확인한다.

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_atupdated_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에서도 findByEmailexistsByEmail을 추가해 이메일 중복 체크, 유저 조회 등을 할 수 있게 해준다.


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를 도입해 보도록 하겠다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보