웹 백엔드 개발/Spring Boot

[Spring Boot/JWT] JWT로 로그인/로그아웃 구현하기

iinana 2025. 4. 12. 22:22

 토큰 기반 인증 

1. 토큰 기반 인증이란

 토큰을 사용하여 인증하는 방식이다. 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 이 토큰을 가지고 있다가 여러 요청을 토큰과 함께 신청한다. 서버는 토큰을 보고 유효한 사용자인지 검증하여 요청을 수행해준다. 

 

2. 세션 기반 인증과 토큰 기반 인증

 세션 기반 인증은 사용자마다 사용자의 정보를 담은 세션을 생성하고 저장해서 인증하는 방식이다. 세션 기반 인증과 토큰 기반 인증의 가장 큰 차이는 요청과 함께 전달하는 정보의 양 차이이다. 토큰 기반 인증의 경우, 검증에 필요한 정보를 모두 담은 토큰을 요청과 함께 전달한다. 즉, 토큰에 회원정보와 유효기간 같은 검증에 필요한 정보도 담겨있어, 토큰만 확인하면 회원의 유효성을 입증할 수 있다. 하지만 세션 기반 인증의 경우, 토큰과 달리 최소한의 정보만을 담아서 요청과 함께 서버에 보낸다. 그러면 서버에서는 메모리나 DB에 저장된 정보에서 전달받은 정보를 기반으로 유효한 사용자인지 검증을 하는 방식이다. 하지만 토큰 기반 인증의 경우, 토큰만 확인하면 되기 때문에 따로 정보를 저장해둘 필요도, 이를 검색하여 유효성을 검증할 필요도 없다.

3. 토큰 기반 인증의 특징

 위에서 세션 기반 인증과의 차이점으로 인해 발현되는 토큰 기반 인증의 특징들이 있다. 특징이자 jwt의 장점이기도 하다. 그 특징들은 아래와 같다.

  • 무상태성: 서버에 별도로 인증 정보를 저장할 필요 없음 (클라이언트가 토큰을 가지고 있음)
  • 확장성: 서버 확장 시 상태 관리에 신경 쓸 필요가 없음 (하나의 토큰으로 다양한 서버에 요청을 보낼 수 있음)
  • 무결성: 토큰을 발급한 이후 누군가 토큰 정보를 변경하는 행위를 할 수 없음

 

 JWT  (Json Web Token) 

1. JWT의 토큰 구성

 발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중 Authorization 키 값에 "Bearer " + JWT 토큰값을 넣어 보내야 한다. JWT 토큰값은 헤더(header), 내용(payload), 서명(signature)로 구성된다. 이 구성 정보들을 BASE64로 인코딩한 값이 JWT 토큰값이 된다. 

 

(1) 헤더(header): 토큰의 타입과 해싱 알고리즘을 지정하는 정보

{
    "typ": "JWT",
    "alg": "HS256"
}

 

(2) 내용(payload): 토큰과 관련된 정보를 담음. 내용 한 덩어리를 클레임(claim)이라고 하며, 공개되어도 상관없는 클레임인 공개 클레임, 공개되면 안되는 클레임인 비공개 클레임, 마지막으로 JWT 자체에서 등록된 등록된 클레임이 있다. 등록된 클레임은 아래와 같다.

  • iss : issuer (토큰 발급자)
  • sub : subject (토큰 제목)
  • aud: audience (토큰 대상자)
  • exp: experation (토큰 만료 시간, NumericDate 형식)
  • nbf: not before (토큰의 활성 날짜, NumericDate 형식)
  • iat: issued at (토큰 발급 시간)
  • jti: JWT의 고유 식별자 (일회용 토큰에서 주로 사용)

(3) 서명(signiture): 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용된다. header와 payload의 인코딩 값을 합친 후 secret key를 넣는다.

 

2. refresh token

 토큰의 유효기간이 길면 토큰 탈취의 위험이 있고, 유효기간이 짧으면 재발급의 불편이 있으므로, 이를 해결하기 위해 refresh token이 사용된다. 액세스 토큰의 유효기간을 짧게 설정하고, refresh token의 유효기간은 길게 설정하면, 엑세스 토큰이 탈취당해도 짧은 유효기간 이후에 사용할 수 없으므로 보다 안전해진다. 

 작동하는 방식은 액세스 토큰으로 요청을 전달했는데, 만료된 액세스 토큰인 경우 refresh token과 함께 액세스 토큰 발급 요청을 해서 새로운 액세스 토큰을 받는 방식이다.

 

3. JWT로 인증 구현 시 주의할 점 (https://www.youtube.com/watch?v=XXseiON9CV0)

(1) 헤더 부분의 알고리즘을 none으로 채우지 말 것

 알고리즘을 none으로 만들면, 이를 이용해서 공격하는 것으로부터 안전할 수 없다. 따라서, none으로 만든 JWT 입장권들을 거절하는 기능이 있는지 확인해야 한다. 최신 라이브러리(HS256 등)를 잘 사용하면 안전하다.

{
    "alg": "none", //위험 -> "HS256" 사용
    "typ": "JWT"
}

 

(2) 최소한의 정보만 넣기

 JWT는 변환이 쉽기 때문에, 민감한 정보들은 빼고, 인증을 위한 최소한의 정보만 넣는 것이 좋다.

 

(3) 시크릿키를 제대로 작성

 너무 짧거나 추측하기 쉬운 secret key로 설정을 하게 되면, 예측이 쉬워져서 bruteforce attack을 받을 수 있다. secret key가 유출되면 token이 외부에 의해 마음대로 발행되는 일이 발생할 수 있다. 따라서 키를 매우 길게 설정하고, 공유하지 않는 것이 중요하다. 조금 더 엄격하게 관리하고 싶다면, 생성용키와 검증용키를 따로 생성하여 사용하는 방법도 있다. 


 

 JWT로 로그인/로그아웃 구현하기 

├── src
│ ├── main
│ │ ├── java
│ │ │     └── com.study.blog_project
│ │ │             ├── config
│ │ │                      ├── jwt
│ │ │                             ├── JwtProperties.java                                // 토큰제공자&secretKey 변수로 가져오기
│ │ │                             ├── TokenAuthenticationFilter.java      // 토큰 필터  

│ │ │                             └── TokenProvider.java                              // 토큰생성&유효성검증&인증정보가져오기
│ │ │                      ├── RedisConfig.java                                          // 인증코드 저장을 위한 Redis 저장소 설정

│ │ │                      └── WebSecurityConfig.java                           // 접근권한, password encoder 설정
│ │ │             ├── controller
│ │ │                      └── UserController.java
│ │ │             ├── dto   // 편의상 알파벳 순이 아닌 서비스 로직 순으로 작성
│ │ │                      ├──  SignupRequestDTO.java                      // 회원가입
│ │ │                      ├──  SignupResponseDTO.java
│ │ │                      ├──  IdentConfirmDTO.java                         // 아이디 중복확인 (회원가입 시)
│ │ │                      ├──  LoginRequestDTO.java                        // 로그인
│ │ │                      ├──  LoginResponseDTO.java
│ │ │                      ├──  FindIdentRequestDTO.java               // 아이디, 비밀번호 찾기
│ │ │                      ├──  FindIdentResponseDTO.java

│ │ │                      ├──  FindPasswordRequestDTO.java
│ │ │                      ├── PasswordUpdateDTO.java                 // 비밀번호 변경 (비밀번호 찾기 시)
│ │ │                      ├── VerificationEmailSendDTO.java      // 인증메일 전송

│ │ │                      └── CodeVeriyDTO.java                              // 인증번호 확인
│ │ │             ├── model
│ │ │                      └── User.java
│ │ │             ├── repository
│ │ │                      ├── UserRepository.jva
│ │ │                      └── VerificationRepository.java      // 인증번호 저장용 Redis Repository

│ │ │             └── service
│ │ │                      ├── MailService.java
│ │ │                      └── UserService.java
│ │ └── resources
│ │ │     ├── application.yml

│ │ │     └── application-secret.yml 
└── build.gradle

0. 기본 준비

(1) 의존성 추가하기

/** build.gradle **/
dependencies {
    // spring web
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // validation
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // java mail sender
    implementation 'org.springframework.boot:spring-boot-starter-mail'

    // jwt
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    implementation 'javax.xml.bind:jaxb-api:2.3.1'

    // lombok
    implementation 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // spring security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'

    // DB & caching
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'  // jpa
    implementation 'org.mariadb.jdbc:mariadb-java-client:3.1.2' // maria db
    implementation 'org.springframework.boot:spring-boot-starter-data-redis' // redis

    // gson for redis config
    implementation 'com.google.code.gson:gson:2.8.6'

    // test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

(2) application.yml 설정

 민감한 정보는 application-secret.yml에 따로 빼서 넣었다. 어떤 정보가 있는지는 주석으로 표기되어 있다. 

(관련 글: https://programming-diary-ina.tistory.com/116 )

## application.yml ##
spring:
  application:
    name: survey-server

  # DB 연결
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://127.0.0.1:3306/survey?serverTimezone=Asia/Seoul

  # 인증 메일 전송
  mail:
    host: smtp.gmail.com
    port: 587
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true

  # jwt token
  jwt:
    expiration: 3600000 #1시간(단위:ms)

  config:
    import: application-secret.yml
    #datasource:
    #  username: username
    #  password: password
    #jwt:
    #  issuer: issuer_email
    #  secret_key: secret_key
    #  expiration: 3600000
    #mail:
    #  username: email_address
    #  password: email_password

  # db 연결용 jpa
  jpa:
    properties:
      hibernate:
        show_sql: true
        format_sql: true
    hibernate:
      ddl-auto: create
    database-platform: org.hibernate.dialect.MySQLDialect

# 로깅
logging:
  level:
    root: DEBUG
    com.survey: DEBUG

 

1. JWT Token 관련 로직

(1) application.yml에서 설정한 jwt properties 불러오기

 application.yml 파일에 설정해 둔 token issuer, secretKey, 그리고 토큰 만료 기간에 해당하는 expiration을 주입 받아 사용할 수 있도록 JwtProperties 파일을 만들어서 불러온다.

/** JwtProperties.java **/
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties("spring.jwt")
@Getter
@Setter
@Component
public class JwtProperties {
    private String issuer;
    private String secretKey;
    private Long expiration;
}

 

(2) TokenProvider 서비스 작성

/** TokenProvider.java **/
import java.util.Collections;
import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;

import com.survey.server.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.security.core.Authentication;

@RequiredArgsConstructor
@Service
public class TokenProvider {
    private final JwtProperties jwtProperties;
    private final Logger log = LoggerFactory.getLogger(this.getClass());

    /**
     * 주어진 사용자 정보로 새로운 JWT 토큰 생성
     *
     * @param userEmail 사용자 이메일 (사용자 구분할 unique한 값)
     * @param roles 사용자 권한 (ROLE_USER이 담긴 set)
     * @return 생성된 JWT 토큰 문자열
     */
    public String makeToken(String userEmail, Set<String> roles) {
        log.info("토큰 발행 프로세스 시작: email={}, roles={}", userEmail, roles);
        Date now = new Date();

        log.debug("토큰 정보: expiration={}", jwtProperties.getExpiration());
        Date validity = new Date(now.getTime() + jwtProperties.getExpiration());

        log.debug("토큰 리턴: issuer={}, secret_key={}", jwtProperties.getIssuer(), jwtProperties.getSecretKey());
        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)  //header type : JWT
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)
                .setSubject(userEmail)
                .claim("roles", roles)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                // signiture (secret key와 함께 해시값을 HS256 방식으로 암호화)
                .compact();
    }

    /**
     * 토큰 유효성 검증
     *
     * @param token 검증할 JWT 토큰
     * @return 토큰이 유효하면 true
     */
    public boolean isValidToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 유효한 토큰 기반으로 인증 정보 가져오기
     *
     * @param token 유효한 JWT 토큰
     * @return 사용자 정보와 기본 권한을 포함한 Authentication 객체
     */
    public Authentication getAuthentication(String token) {
        // 토큰에서 클레임 추출
        Claims claims = Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();

        // 기본 권한 설정 (ROLE_USER)
        Set<SimpleGrantedAuthority> authorities = Collections
                .singleton(new SimpleGrantedAuthority(("ROLE_USER")));

        // Authentication 객체 리턴
        return new UsernamePasswordAuthenticationToken(
                new org.springframework.security.core.userdetails.User( // 사용자 정보
                        claims.getSubject(),"", authorities),  // - email, password(JWT 기반 인증에서 불필요), 권한정보(ROLE_USER)
                token, authorities); // 인증정보(token), 권한 리스트(authorities)
    }
}

 

[ method 01: makeToken() ] 토큰 생성: 아래와 같은 내용을 인코딩하여 토큰을 만든다

#header
{
    "typ": "JWT",
    "alg": "HS256"
}

#payload
{
    "iss": {jwtProperties에서 가져온 issuer},
    "iat": {현재 시간: new Date() 값},
    "exp": {method argument로 받은 expiry},
    "roles": {사용자에게 부여할 접근 권한 set},
    "sub": {subject로 user email 사용}
}

#signiture: jwProperties에서 가져온 secretKey

 

[ method 02: validToken() ] 토큰 유효성 검증

 여기서 Jwts.parser()는 입력받은 토큰을 비밀값으로 복호화해서 토큰이 유효한지 확인하는 역할을 한다. setSigningKey로 입력받은 토큰을 복호화 할 secret key를 설정하고, parseClaimsJws(token)으로 인자인 'token'이 유효한 토큰인지를 검증한다. 

 

[ method 03: getAuthentication() ] 토큰 기반으로 인증 정보 가져오기

  • token으로부터 claim 추출
  • 사용자 권한 설정
    • 부여되는 권한 : ROLE_USER (일반 사용자 권한, 관리자 권한은 ROLE_ADMIN으로 부여)
    • Set Collection Framework 사용 : 사용자가 여러개의 권한을 가질 수 있음을 의미. Set은 중복이 불가능하고 contains() 메서드가 있어 이용자 권한을 쉽게 저장하고 확인할 수 있음
    • Collections.singleton() : authorization set에 변경이 불가능한 단일 요소만 추가. 따라서 authorization set에는 "ROLE_USER"만 포함 가능 => 이용자는 ROLE_USER 권한만 가질 수 있음 (참고자료: https://ittrue.tistory.com/563)
  • 인증 정보 생성 (UsernamePasswordAuthenticationToken이라는 spring security 인증 객체 생성)
    • 첫번째 인자: User 객체 (사용자 정보)
    • 두번째 인자: token
    • 세번째 인자: authorities (사용자 권한

 

(3) Token Filter 생성

 각 요청마다 토큰이 유효한지 검증하고, 유효한 토큰만 context holder에 저장할 수 있도록 한다. 이때 context holder에 저장한다는 것은 추후 사용자가 인증이 필요한 페이지에 접근할 경우 인증된 사용자라는 것을 알 수 있도록 spring security에 알리는 것을 말한다. 추후 로그인이 필요한 서비스에서 사용될 예정이다.

/** TokenAuthenticationFilter.java **/
import com.survey.server.config.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";


    /**
     * 모든 요청에 대해 JWT 토큰을 검증하고 인증 처리를 수행
     *
     * @param request HTTP 요청
     * @param response HTTP 응답
     * @param filterChain 필터체인
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        // 요청 헤더에서 토큰 추출
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        String token = (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX))
                ? authorizationHeader.substring(TOKEN_PREFIX.length()) : null;

        // 유효한 토큰일 경우 context holder에 인증 정보 저장
        // - 현재 요청을 보낸 사용자가 인증되었음을 Spring Security에 알림
        if (tokenProvider.isValidToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

 

(4) Web Security Configuration 생성

 다음으로, 비밀번호 암호화와 페이지 접근 권한 설정을 위해 Web Security에 대한 Configuration을 작성해준다.

 먼저 페이지 접근 권한 설정 부분을 보면, 앞서 만든 Token Filter를 등록하여 인증이 필요한 모든 요청에 대해 JWT 토큰 기반 인증을 수행하도록 설정한다. 단, 로그인, 회원가입, 이메일 인증 등과 같이 인증 없이 접근 가능한 일부 페이지는 permitAll()을 통해 예외 처리되어 있다.

 또한, PasswordEncoder는 비밀번호를 DB에 평문으로 저장하는 것이 아닌 해시 값으로 안전하게 저장하기 위해 사용된다. 사용자가 로그인 시 입력한 비밀번호는 저장된 해시 값과 비교되어 인증되며, 이를 위해 passwordEncoder.matches() 메서드가 사용된다.

/** WebSecurityConfig.java **/
import com.survey.server.config.jwt.TokenAuthenticationFilter;
import com.survey.server.config.jwt.TokenProvider;
import com.survey.server.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final TokenProvider tokenProvider;

    /**
     * 페이지 접근 권한 설정
     *  - 특정 HTTP 요청에 대해 웹기반 보안 구성
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                // CSRF 비활성화 (JWT에서는 불필요)
                .csrf(AbstractHttpConfigurer::disable)

                // JWT 기반 인증을 위한 Token Filter 추가
                .addFilterBefore(new TokenAuthenticationFilter(tokenProvider),
                        org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class)

                // 로그인 + 회원가입 관련 페이지 회원이 아니더라도 접근 허용
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/user/signup",
                                "/user/login",
                                "/user/verification/**"
                        ).permitAll()
                        .anyRequest().authenticated())
                .build();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder passwordEncoder, UserService userService) throws Exception {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService);
        authProvider.setPasswordEncoder(passwordEncoder);
        return new ProviderManager(authProvider);
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

 


2. Entity 및 DTO 생성

(1) User Entity 생성

User Entity의 구조는 다음과 같다. (실제로 지금 개발 중인 서비스의 ERD는 이와 조금 달랐으나, 편의상 지금 구현 중인 로그인/회원가입에 필요한 요소만으로 구성했다.)

컬럼명 자료형 nullable 설명 특이사항
user_id int unsigned false 일련번호, 기본키 자동 생성
email varchar(255) false 이용자 이메일 중복 불가
user_id varchar(50) false 사용자 입력 아이디 중복 불가
password_hash varchar(50) fasle encoding된 사용자 비밀번호 bcrytypt encoder 사용
created_at DATE false 회원가입일 (이용자 생성일) 자동 생성
privacy_agree boolean fasle 개인정보보호 동의 여부 false 값일 수 없음 (가입 불가)
marketing_agree boolean false 마케팅수신 동의 여부 선택 약관

 

 자동생성이라고 표기된 id는 @GenerateValue 어노테이션으로 일련번호가 자동 생성되고, created_at의 경우에도 @PrePersist 어노테이션과 함께 사용된 onCreate method로 자동으로 회원가입 시의 날짜가 부여된다.

 그리고 User Entity는 UserDetails를 implement 한다. UserDetails를 Spring Security에서 사용자의 인증 정보를 담아 두는 interface이다. 해당 객체를 토앻 인증 정보를 가져오려면 필수로 몇가지 method들을 override해야 한다. 

/** User.java **/
import com.survey.server.constant.SEX;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Date;
import java.util.List;

@Entity
@Table(name="users")
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="user_id", updatable=false)
    private Long userId;

    @Column(name="user_name", nullable = false)
    private String userName;

    @Column(name="email", nullable=false, unique=true)
    private String email;

    @Column(name="user_ident", nullable=false, unique=true)
    private String userIdent;

    @Column(name="password_hash", nullable=false)
    private String passwordHash;

    @Temporal(TemporalType.TIMESTAMP)
    @Column(name="created_at", nullable=false, updatable=false)
    private Date createdAt;

    @Column(name="privacy_agree", nullable=false)
    private Boolean privacyAgree;

    @Column(name="marketing_agree", nullable=false)
    private Boolean marketingAgree;

    // 비밀번호 변경
    public void updatePassword(String newPasswordHash) {
        this.passwordHash = newPasswordHash;
    }

    // createdAt 자동 생성
    @PrePersist
    protected void onCreate() {
        this.createdAt = new Date();
    }

    // 사용자가 가지고 있는 권한 목록 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_USER"));
    }

    // 사용자 이름 반환
    @Override
    public String getUsername() {
        return userName;
    }

    // encoding 된 비밀번호 반환
    @Override
    public String getPassword() {
        return passwordHash;
    }

    // 계정 만료 여부 반환
    @Override
    public boolean isAccountNonExpired() {
        return true; //만료되지 않았음
    }

    // 계정 잠금여부 반환
    @Override
    public boolean isAccountNonLocked() {
        return true; //잠금되지 않았음
    }

    // 패스워드 만료여부 반환
    @Override
    public boolean isCredentialsNonExpired() {
        return true; //만료되지 않았음
    }

    // 계정 사용 가능 여부 반환
    @Override
    public boolean isEnabled() {
        return true; //계정 사용 가능
    }
}

 

(2) SignupRequestDTO 생성

 DTO의 경우 너무 많은 파일이 존재하기 때문에, 기본적으로 DTO를 쓸 때 신경 썼던 부분이 모두 담겨있는 SignupRequestDTO만 기록하고 넘어가려 한다. DTO들에서 값의 패턴이나 조건을 확인하는 어노테이션을 많이 사용했다. 형식에 맞는 값이 아니라면 DTO 생성에서부터 exception을 발생시켜 다음 단계를 진행할 수 없도록 코드를 작성했다.

 우선 전반적으로 빈 값이면 안되는 값들에 대해 @NotBlank 어노테이션을 사용했다. 무조건 입력되어야 하는 값들에 사용할 수 있는 어노테이션이 세가지 있는데, 그 내용은 아래와 같다. 결론부터 말하자면 나는 가장 엄격하게 입력값을 확인하는 어노테이션을 사용했다.

  Null 값 허용 빈칸 허용 공백만 있는 값 허용 설명
@NotNull X O O Null값인지 확인
@NotEmpty X X O 입력값의 길이를 확인
@NotBlank X X X 공백을 제거한 입력값의 길이를 확인

 

 비밀번호 형식의 경우 @Pattern 어노테이션을 사용했다. regexp가 지켜저야 하는 비밀번호 형식에 해당하는데, 나는 8자리 이상의 숫자, 알파벳, 특수문자 중 모두를 조합한 비밀번호인지 확인했다. 

 그리고 privacyArgee, 즉 개인정보이용동의 항목 같은 경우, 동의하지 않으면 회원가입을 진행할 수 없다. 따라서 이 경우 @AssertTrue 어노테이션을 활용하여 true 값이 아니면 exception을 발생시켰다. 비슷한 로직으로 userPassword와 passwordConfirm, 즉 비밀번호 입력란과 비밀번호 확인란의 값이 같은지, DTO내 method인  isPasswordMatching()에 @AssertTrue 어노테이션을 달아 확인한다.

/** SignupRequestDTO.java **/
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import jakarta.validation.constraints.NotBlank;

@AllArgsConstructor
@Getter
public class SignupRequestDTO {
    @NotBlank(message = "user_name cannot be empty")
    String userName;
    @NotBlank(message = "user_email cannot be empty")
    String userEmail;
    @NotBlank(message = "user_ident cannot be empty")
    String userIdent;

    @NotBlank(message = "user_password cannot be empty")
    @Pattern(
            regexp = "^(?=.*[A-Za-z].*)(?=.*\\d.*|.*[^A-Za-z0-9].*).{8,}$",
            message = "Password must be at least 8 characters long and contain at least two of the following: letters, numbers, or special characters"
    )
    String userPassword;
    String passwordConfirm;
    @AssertTrue(message = "Passwords do not match")
    public boolean isPasswordMatching() {
        return userPassword.equals(passwordConfirm);
    }

    @AssertTrue(message = "user should agree with privacy agreement")
    Boolean privacyAgree;
    Boolean marketingAgree;

    @AssertTrue(message = "user should check ident duplication first")
    Boolean identConfirmed;
    @AssertTrue(message = "user should verify email first")
    Boolean emailVerified;
}

 

3. User 관련 주요 로직 작성 (controller, service, repository)

(1) User Controller 작성

/** UserController.java **/
import com.survey.server.dto.*;
import com.survey.server.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.NoSuchElementException;

@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    /**
     * 회원 가입 endpoint
     *
     * @param request 회원가입 요청 DTO
     * @return 회원가입 응답 DTO를 포함한 ResponseEntity
     */
    @PostMapping("/signup")
    public ResponseEntity<SignupResponseDTO> signup(@RequestBody SignupRequestDTO request) {
        try {
            SignupResponseDTO response = userService.signup(request);
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 아이디 중복확인 endpoint
     *
     * @param request 아이디 중복확인 요청 DTO
     * @return 중복 확인 결과 Boolean 값 (중복되지 않을 시, 즉 아이디를 사용 가능한 경우 true)
     */
    @PostMapping("/signup/id")
    public ResponseEntity<Boolean> confirmIdent(@RequestBody IdentConfirmDTO request) {
        try {
            Boolean result = userService.confirmIdent(request);
            return ResponseEntity.ok(result);
        } catch (IllegalStateException e) {
            return ResponseEntity.status(HttpStatus.CONFLICT).body(false);
        }
    }

    /**
     * 로그인 endpoint
     *
     * @param request 로그인 요청 DTO
     * @return 로그인 응답 DTO를 포함한 ResponseEntity
     */
    @PostMapping("/login")
    public ResponseEntity<LoginResponseDTO> login(@RequestBody @Validated LoginRequestDTO request) {
        try {
            LoginResponseDTO response = userService.login(request);
            return ResponseEntity.ok(response);
        } catch (IllegalStateException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 아이디 찾기 endpoint
     *
     * @param request 아이디 찾기 요청 DTO
     * @return 아이디 찾기 응답 DTO를 포함한 ResponseEntity
     */
    @PostMapping("/ident")
    public ResponseEntity<FindIdentResponseDTO> findIdent(@RequestBody FindIdentRequestDTO request) {
        try {
            FindIdentResponseDTO response = userService.findIdent(request);
            return ResponseEntity.ok(response);
        } catch (IllegalStateException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 비밀번호 찾기 endpoint
     *
     * @param request 비밀번호 찾기 요청 DTO
     * @return 회원정보와 일치하는 회원의 존재여부 (회원 정보 + 인증 번호 모두 맞아야 true)
     */
    @PostMapping("/password")
    public ResponseEntity<Boolean> findPassword(@RequestBody FindPasswordRequestDTO request) {
        try {
            Boolean result = userService.findPassword(request);
            return ResponseEntity.ok(result);
        } catch (IllegalStateException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 인증 메일 전송 endpoint
     *
     * @param sendDTO 이메일 전송 요청 DTO
     * @return 이메일 전송 결과 String
     */
    @PostMapping("/verification/email")
    public ResponseEntity<String> sendVerificationEmail(@RequestBody VerificationEmailSendDTO sendDTO) {
        try {
            userService.sendVerificationEmail(sendDTO);
            return ResponseEntity.ok("Verification email sent");
        } catch (IllegalStateException e) { // 회원가입 시 이메일 중복
            return ResponseEntity.status(HttpStatus.CONFLICT).body("signup: existing email");
        } catch (Exception e) {
            return ResponseEntity.internalServerError().build();
        }
    }

    /**
     * 인증번호 확인 endpoint
     *
     * @param verifyDTO 인증번호 확인 요청 DTO
     * @return 인증번호 확인 결과 Boolean (번호 일치할 경우 true)
     * 이메일 존재하지 않을 경우 UNAUTHORIZED(401)
     */
    @PostMapping("/verification")
    public ResponseEntity<Boolean> verifyCode(@RequestBody CodeVerifyDTO verifyDTO) {
        try {
            Boolean result = userService.verifycode(verifyDTO);
            return ResponseEntity.ok(result);
        } catch (NoSuchElementException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 비밀번호 변경 endpoint
     *
     * @param passwordUpdateDTO 비밀번호 변경 요청 DTO
     * @return 비밀번호 변경 결과 String
     */
    @PostMapping("/password/new")
    public ResponseEntity<String> updatePassword(@RequestBody PasswordUpdateDTO passwordUpdateDTO) {
        try {
            userService.updatePassword(passwordUpdateDTO);
            return ResponseEntity.ok("Password updated");
        } catch (IllegalStateException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.internalServerError().build();
        }
    }
}

 

(2) User Service 작성

/** UserService.java **/
import com.survey.server.config.jwt.TokenProvider;
import com.survey.server.dto.*;
import com.survey.server.repository.UserRepository;
import com.survey.server.model.User;
import lombok.RequiredArgsConstructor;
import org.apache.logging.log4j.util.InternalException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final UserRepository userRepository;
    private final TokenProvider tokenProvider;
    private final MailService mailService;

    private final Logger log = LoggerFactory.getLogger(this.getClass());

    /**
     * 회원가입 요청을 처리하여 사용자 정보를 저장하고 반환
     *
     * @param request 회원가입 요청 DTO
     * @return 회원가입 응답 DTO
     */
    public SignupResponseDTO signup(SignupRequestDTO request) {
        log.info("회원가입 프로세스 시작: ident={}, email={}", request.getUserIdent(), request.getUserEmail());

        String passwordHash = bCryptPasswordEncoder.encode(request.getUserPassword());
        log.debug("회원가입: 비밀번호 encoding 완료");
        User user = User.builder()
                .userName(request.getUserName())
                .userIdent(request.getUserIdent())
                .email(request.getUserEmail())
                .passwordHash(passwordHash)
                .point(0L)
                .privacyAgree(request.getPrivacyAgree())
                .marketingAgree(request.getMarketingAgree())
                .build();
        log.debug("회원가입: user entity 생성 완료");

        try {
            userRepository.save(user);
            log.info("사용자 정보 저장 완료: ident={}, name={}", request.getUserIdent(), request.getUserName());
        } catch (Exception e) {
            log.error("회원가입 실패: ident={}, error={}", request.getUserIdent(), e.getMessage());
            throw new RuntimeException("회원 정보 저장 실패");
        }

        return new SignupResponseDTO(
                user.getUserIdent(),
                user.getUsername()
        );
    }

    /**
     * 아이디 중복확인하고 결과 반환
     *
     * @param request 아이디 중복확인 요청 DTO
     * @return 중복되지 않을 시 true, 중복되는 경우 exception 발생
     */
    public Boolean confirmIdent(IdentConfirmDTO request) {
        log.info("아이디 중복확인 시작: ident={}", request.getUserIdent());

        if (!userRepository.existsByUserIdent(request.getUserIdent())) {
            log.info("사용 가능한 아이디: ident={}", request.getUserIdent());
            return true;
        } else {
            log.info("중복된 아이디: ident={}", request.getUserIdent());
            throw new IllegalStateException("이미 사용하고 있는 아이디입니다.");
        }
    }

    /**
     * 로그인 처리하고 결과 반환
     * @param request 로그인 요청 DTO
     * @return 로그인 응답 DTO
     */
    public LoginResponseDTO login(LoginRequestDTO request) {
        log.info("로그인 시도: ident={}", request.getUserIdent());

        Optional<User> optionalUser = userRepository.findByUserIdent(request.getUserIdent());
        if (optionalUser.isEmpty() ||
                !bCryptPasswordEncoder.matches(request.getUserPassword(), optionalUser.get().getPasswordHash())) {
            log.warn("로그인 실패: ident={}, reason=invalid_credentials", request.getUserIdent());
            throw new IllegalStateException("로그인 실패: invalid_credentials");
        }
        log.debug("로그인 조건 충족 (valid id and password)");

        // 토큰 생성
        User user = optionalUser.get();
        Set<String> roles = user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toSet());

        log.debug("토큰 생성: ident={}, roles={}", request.getUserIdent(), roles);
        String token = tokenProvider.makeToken(user.getEmail(), roles);

        log.info("로그인 성공: ident={}", request.getUserIdent());
        return new LoginResponseDTO(
                token,
                user.getUserIdent(),
                roles
        );
    }

    /**
     * 아이디 찾기 (인증 코드 확인하고 아이디 반환)
     * @param request 아이디 찾기 요청 DTO
     * @return 아이디 찾기 응답 DTO
     * @throws Exception (verification code 확인하며 발생한 exception throw)
     */
    public FindIdentResponseDTO findIdent(FindIdentRequestDTO request) throws Exception {
        log.info("아이디 찾기 시작: email={}", request.getUserEmail());

        User user = userRepository.findByEmail(request.getUserEmail())
                .orElseThrow(() -> new IllegalStateException("Invalid user credentials"));
        log.debug("아이디 찾기 대상 user 찾기 성공 (valid email)");
        if (!user.getUsername().equals(request.getUserName())) {
            log.warn("아이디 찾기 실패: email={}, reason=invalid_credentials", request.getUserEmail());
            throw new IllegalStateException("Invalid user credentials");
        }

        // 인증 번호 확인
        if(!mailService.verifycode(request.getUserEmail(), request.getVerificationCode())) {
            log.warn("아이디 찾기 실패: email={}, reason=invalid_verification_code", request.getUserEmail());
            throw new IllegalStateException("Invalid verification code");
        }

        log.debug("아이디 찾기 성공: email={}, ident={}", user.getEmail(), user.getUserIdent());
        return new FindIdentResponseDTO(
                user.getUserIdent()
        );
    }

    /**
     * 비밀번호 찾기 (비밀번호 변경으로 넘어가기 전 확인)
     * @param request 비밀번호 찾기 요청 DTO
     * @return true면 비밀번호 변경으로 이동 가능 (조건 충족)
     * @throws Exception verifyCode에서 받은 exception throw
     */
    public Boolean findPassword(FindPasswordRequestDTO request) throws Exception {
        log.info("비밀번호 찾기 시작: email={}", request.getUserEmail());
        User user = userRepository.findByEmail(request.getUserEmail())
                .orElseThrow(() -> new IllegalStateException("Invalid user credentials"));
        log.debug("비밀번호 찾기 대상 user 찾기 성공 (valid email)");
        if (!user.getUsername().equals(request.getUserName())) {
            log.warn("비밀번호 찾기 실패: email={}, reason=invalid_credentials", request.getUserEmail());
            throw new IllegalStateException("Invalid user credentials");
        }

        // 인증 번호 확인
        Boolean isValidCode = mailService.verifycode(request.getUserEmail(), request.getVerificationCode());
        if (isValidCode) {
            log.info("비밀번호 찾기 성공: email={}", request.getUserEmail());
        } else {
            log.info("비밀번호 찾기 실패: email={}, reason=invalid_verification_code", request.getUserEmail());
            throw new IllegalStateException("Invalid user credentials");
        }
        return isValidCode;
    }

    /**
     * 인증 코드 확인
     * @param verifyDTO 인증 코드 확인 요청 DTO
     * @return 인증 코드가 일치하면 true, 아니면 false
     */
    public Boolean verifycode(CodeVerifyDTO verifyDTO) {
        log.debug("인증코드 확인: email={}", verifyDTO.getUserEmail()); //mailService에서 로깅
        return mailService.verifycode(verifyDTO.getUserEmail(), verifyDTO.getVerificationCode());
    }

    /**
     * 인증 메일 전송
     * @param sendDTO 인증 메일 전송 요청 DTO
     * @throws Exception 메일 전송 시 발생한 exception throw
     */
    public void sendVerificationEmail(VerificationEmailSendDTO sendDTO) throws Exception {
        log.info("인증 메일 전송 시작: email={}", sendDTO.getUserEmail());
        if (sendDTO.getRequestType() == VerificationEmailSendDTO.RequestType.SIGNUP
                && userRepository.existsByEmail(sendDTO.getUserEmail())) {
            log.warn("인증 메일 전송 실패: email={}, reason=invalid_credentials", sendDTO.getUserEmail());
            throw new IllegalStateException("existing email");
        }

        if (!mailService.sendVerificationEmail(sendDTO.getUserEmail())) {
            log.error("인증 메일 전송 실패: email={]", sendDTO.getUserEmail());
            throw new RuntimeException("failed to send verification email");
        }
        log.info("인증 메일 전송 성공: email={}", sendDTO.getUserEmail());
    }

    /**
     * 비밀번호 변경
     * @param request 비밀번호 변경 요청 DTO
     */
    public void updatePassword(PasswordUpdateDTO request) {
        log.info("비밀번호 변경 시작: email={}", request.getUserEmail());

        User user = userRepository.findByEmail(request.getUserEmail())
                .orElseThrow(() -> new IllegalStateException("Invalid user credentials"));
        log.debug("비밀번호 변경 대상 user 찾기 성공(valid email)");

        String passwordHash = bCryptPasswordEncoder.encode(request.getUserPassword());
        user.updatePassword(passwordHash);
        userRepository.save(user);
        log.info("비밀번호 변경 성공");
    }
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // Fetch user by username from the database
        User user = userRepository.findByUserName(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Return a UserDetails object based on your User class
        return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                user.getAuthorities()
        );
    }
}

 

(3) User Repository 작성

/** UserRepository.java **/
import com.survey.server.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String userEmail);
    Optional<User> findByUserIdent(String userIdent);

    Boolean existsByEmail(String userEmail);
    Boolean existsByUserIdent(String userIdent);
}

 

4. 인증번호 관련 주요 로직 작성(service, repository)

 인증번호 관련 서비스는 아래 글에 보다 자세히 기록했다. https://programming-diary-ina.tistory.com/120

 

[SpringBoot/Java] JavaMailSender로 인증 메일 전송하기

회원가입 및 로그인 기능을 구현하면서, 인증코드를 담은 메일을 보내는 기능을 구현해야 했다. JavaMailSender를 이용하면 어렵지 않게 구현할 수 있다. 크게 아래 두 가지 기능을 구현해 보았다.1.

programming-diary-ina.tistory.com

728x90
반응형