런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 11. JWT 로그인

sson-coding 2026. 1. 2. 15:16

이번 프로젝트를 시작하면서 가장 고민했던 것 중 하나는 사용자 인증 방식이다.
세션 기반 인증과 JWT 토큰 인증 방식 중 어떤 것을 선택해야 할지 고민이었다.

이번 글에서는 JWT 를 선택한 이유와 코드로 구현한 과정을 공유하고자 한다.

프로젝트 구조와 인증 방식

Run-ing 프로젝트의 경우 프론트엔드와 백엔드가 완전히 분리된 구조이며 API 를 통해 통신한다.

  • 프론트엔드 : React
  • 백엔드 : Spring Boot + Spring Security
  • 인증 방식 : REST API 기반

인증 방식 비교 : 세션 vs JWT

먼저 세션과 JWT 에 대해서 간단하게 알아보자.

세션

  • 동작 방식
    1. 사용자가 로그인하면 서버가 세션을 생성
    2. 세션 ID 를 쿠키에 담아 클라이언트에 전달
    3. 이후 요청마다 세션 ID 로 사용자 식별
  • 장점
    • 서버에 세션을 직접 관리하므로 즉시 무효화 가능
    • 민감한 정보를 서버에만 보관
  • 한계점
    • 서버가 상태를 관리해야 함 (Stateful)
    • 서버가 여러 대로 확장될 경우 세션 공유 문제 발생
    • Redis 같은 외부 세션 저장소 필요
    • 확장성과 운영 복잡도 증가

JWT

  • 동작 방식
    1. 사용자가 로그인하면 서버가 JWT 발급
    2. 클라이언트가 토큰을 저장
    3. 요청 시 HTTP Header 에 토큰 포함
    4. 서버는 토큰의 서명 검증
  • 장점
    • Stateless : 서버가 상태를 저장하지 않음
    • 수평 확장 용이 : 서버를 여러 대로 늘려도 인증 로직 변경 불필요
    • 서버 부담 감소 : 클라이언트 측에서 인증 정보 유지
    • REST API 친화적 : 무상태성이 RESTful 설계 원칙과 일치
    • 세션 저장소 불필요
  • 단점
    • 토큰 탈취 시 만료 전까지 무효화 여려움
    • Refresh Token 관리 필요

JWT 선택 이유

위에서 세션과 JWT 의 장단점을 알아봤다.
이 프로젝트에서 JWT 를 선택한 이유는 다음과 같다.

  1. 프론트엔드-백엔드 분리 구조에 적합
    • JWT 의 무상태 특성 덕분에 REST API 의 설계 원칙과 자연스럽게 맞아 떨어진다.
  2. 확장 가능성
    • 사용자가 늘어나면 서버를 여러 대로 확장해야 할 수 있다.
    • JWT 를 사용하면 로드 밸런서만 추가하면 되고, 세션 공유를 위한 저장소가 필요없다.
  3. 운영 복잡도 감소
    • 세션 저장소를 별도로 관리할 필요가 없어 초기 개발과 운영이 단순해진다.

물론 JWT 가 완벽하다고 할 수 없다.
몇 가지 단점이 있어 어떻게 대응할지는 다음과 같다.

  1. 토큰 탈취 문제
    • Access Token : 만료 시간을 짧게 설정
    • Refresh Token 도입 : 장기간 유효한 토큰으로 Access Token 재발급
  2. 토큰 크기 문제
    • 불필요한 정보를 Payload 에 담지 않고, 최소한의 정보만 포함

Run-ing 프로젝트에서 JWT 인증을 선택한 것은 프로젝트의 구조적 특징과 향후 확장 가능성을 고려해 JWT 인증을 선택했다.

아래부터 어떻게 JWT 를 Run-ing 프로젝트에 구현했는지 설명하겠다.


1. 라이브러리 설정 및 application.yml 설정

JWT 를 사용하기 위해 라이브러리들을 추가한다.

  • JWT 0.12.3 버전
dependencies{
        ...
    //jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
    ...
}

또한 application.yml 에 jwt 와 관련된 설정 정보를 저장해줘야 한다.

spring:
  jwt:
    secret: ${JWT_SECRET}
    access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000}  # 기본값 1시간
    refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000}  # 기본값 7일

secret key 또는 database관련 정보는 보안을 위해 .env 파일에 작성하고, .gitignore 을 이용하여
로컬 저장소에서만 관리하는 것이 좋다.


2. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JWTUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
        config.setAllowedMethods(Collections.singletonList("*"));
        config.setAllowCredentials(true);
        config.setAllowedHeaders(Collections.singletonList("*"));
        config.setMaxAge(3600L);
        config.setExposedHeaders(Collections.singletonList("Authorization"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager,
        CustomUserDetailsService customUserDetailsService) throws Exception {
        // cors
        http
            .cors(cors -> cors
                .configurationSource(corsConfigurationSource())
            );

        // csrf
        http.csrf(auth->auth.disable());

        // form 로그인 방식
        http
            .formLogin(auth->auth.disable());

        // http basic 인증 방식
        http
            .httpBasic(auth->auth.disable());

        // 경로별 인가 방식
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/users/signup").permitAll()
                .requestMatchers(HttpMethod.POST, "/login").permitAll()
                .anyRequest().authenticated()
            );

        // 필터 추가
        http
            .addFilterBefore(new JWTAuthenticationFilter(jwtUtil,customUserDetailsService),CustomUsernamePasswordAuthenticationFilter.class)
            .addFilterAt(new CustomUsernamePasswordAuthenticationFilter(authenticationManager,jwtUtil),
                UsernamePasswordAuthenticationFilter.class);

        // 세션 설정
        http
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        return http.build();
    }

}

Security Config 는 Spring Security 의 설정을 담당하고,
애플리케이션의 보안 정책, 필터 체인, 예외 처리 등을 구성한다.

어노테이션

  1. @Configuration
    • Spring 설정 클래스임을 의미
  2. @EnableWebSecurity
    • Spring Security 기능 활성화

메서드

  1. authenticationManager()
  2. passwordEncoder()
  3. corsConfigurationSource()
  4. securityFilterChain()
    • CORS 설정을 기본값으로 설정
    • CSRF 보호 비활성화
      • CSRF 는 세션 + 쿠키 기반 인증에서만 의미 있음
      • JWT 는 요청마다 토큰을 헤더에 실어 보내므로 CSRF 공격 대상이 아님
    • form 로그인 방식 비활성화
      • Spring Security 기본 로그인 방식
      • 서버는 토큰 발급만 담당 + 프론트에서 로그인 처리
    • http basic 인증 방식 비활성
      • 매 요청마다 아이디/비밀번호 전송
      • 보안에 취약하고 JWT 인증 방식과 충돌
    • 경로별 인가 방식
      • authorizeHttpRequests : URL 별 접근 권한을 정의
      • permitAll : requestMatchers 에 있는 URL 은 로그인 없이 접근 가능
      • hasRole : 특정 권한을 가진 사용자만 가능
      • anyRequest().authenticated() : 매칭되지 않은 모든 요청은 인증 되어 있으면 접근 가능
    • 필터 추가
      • addFilterBefore : 특정 필터 전에 필터 삽입
      • addFilterAt : 특정 필터 자리에 삽입
    • 세션 설정
      • 세션 정책 : STATELESS
      • 서버가 세션을 생성하지도, 사용하지도 않음
      • 인증 정보는 오직 요청 헤더의 토큰으로만 판단

3. 로그인 필터

UsernamePasswordAuthenticationFilter

FormLogin 의 한계

Spring Security 의 기본 로그인 방식은 formLogin() 이다.
formLogin() 은 로그인 성공 시 세션을 생성하고, HTML 폼 기반 처리를 제공한다.
즉, 세션 기반 인증을 전제로 하기 때문에 JWT 인증과는 맞지 않는다.

커스텀 로그인 필터

Form 로그인 방식에서는 클라이언트단이 username 과 password 를 전송한 뒤 Security 필터를 통과한다.
통과한 username 과 password 는 UsernamePasswordAuthenticationFilter 에서 회원 검증을 진행한다.

회원 검증은 DB 에서 조회한 데이터를 UserDetailsService 를 통해 받아 필터에서 호출한 AuthenticationManager 를 통해 진행한다.

앞서 SecurityConfig 에서 formLogin 방식을 비활성화 했기 때문에 로그인을 진행하기 위해서 필터를 커스텀 해서 등록해야 한다.

@Slf4j
@RequiredArgsConstructor
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;

    // 로그인 요청 시 사용자 인증 처리
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
        AuthenticationException {
        // 로그인 시도
        String username = obtainUsername(request);
        log.info("로그인 시도 - username: {}", username);
        String password = obtainPassword(request);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password);

        return authenticationManager.authenticate(authToken);
    }

    // 로그인 성공 시 JWT 토큰 발급
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
        try {
            // 사용자 정보 추출
            CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal();

            UUID userUuid = customUserDetails.getUesrUuid();
            String email = customUserDetails.getEmail();
            String name = customUserDetails.getUsername();
            Role role = customUserDetails.getRole();
            //jwt 토큰 생성
            JWTUserDto user = new JWTUserDto(userUuid,email,name,role);
            String token = jwtUtil.createAccessToken(user);

            //응답 설정
            response.addHeader("Authorization", "Bearer " + token);

            log.info("로그인 성공 - name: {}, role: {}", name, role);
        } catch (Exception e) {
            log.error("JWT 토큰 생성 중 오류 발생", e);
        }
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException failed) throws IOException, ServletException {
        log.warn("로그인 실패 - IP: {}, 이유: {}", request.getRemoteAddr(), failed.getMessage());
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

CustomUsernamePasswordAuthenticationFilter

  • Spring Security 의 기본 로그인 필터를 상속
  • 폼 로그인 대신 직접 인증 로직을 제어하기 위해 사용
  • 커스텀 필터를 만들면 JWT 발급, JSON 응답 등 자유롭게 처리 가능

AuthenticationManager

  • 인증을 실제로 수행하는 핵심 객체
  • UserDetailsService 를 통해 DB 에서 사용자 조회
  • 비밀번호 일치 여부 확인
  • 인증 성공 시 - Authentication 객체 반환
  • 인증 실패 시 - 예외 발생

attemptAuthentication

  • FilterChain 에서 로그인 요청(POST /login)이 들어오면 실행됨
  • 사용자 입력 추출
  • JSON 로그인이라면 직접 파싱해야 함
  1. obtainUsername() / obtainPassword()
    • HTTP 요청에서 아이디/비밀번호 값을 꺼내는 메서드
    • UsernamePasswordAuthenticationFilter protected 메서드
    • protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.usernameParameter); }
  2. 인증 토큰 생성
    • UsernamePasswordAuthenticationToken 생성
    • 아직 인증되지 않은 상태의 토큰
    • authorities = null 인 이유 : 인증 성공 후 주입 됨
  3. 인증 위임
    • authenticationManager.authenticate(authToken)
      • 아이디/비밀번호가 진짜인지 검증해서 맞으면 인증된 Authentication 을 돌려줘
    • 인증 과정
      1. AuthenticationManager.authentication(authToken)
        1. 실제 구현체 : ProviderManager
        2. 여러 개의 AuthenticationProvider 관리
      2. ProviderManager.authenticate()
        1. 등록된 Provider 목록을 순회
        2. 처리 가능한 Provider 찾기
      3. DaoAuthenticationProvider 선택
        1. UsernamePasswordAuthenticationToken 처리 가능
        2. supports() 메서드로 확인
      4. DaoAuthenticationProvider.authenticate()
        1. retrieveUser() → UserDetailsService 호출
      5. UserDetailsService.loadUserByUsername(username)
        1. 실제 구현 : CustomUserDetailsService
        2. DB 에서 사용자 정보 조회
        3. User 객체 반환
      6. 비밀번호 검증
        1. PasswordEncoder.matches(rawPassword, encodedPassword)
      7. 성공 - 인증된 Authentication 생성, 실패 - 예외 발생

successfulAuthentication - 인증 성공

  • 인증 성공 시 JWT 토큰 생성 및 응답
  • 과정
    1. 인증된 사용자 정보 꺼냄
    2. JWT 생성
    3. HTTP 응답 헤더에 토큰 담기
  • 헤더에 담는 이유
    • REST API 에서는 응답 본문에 데이터를 담음
    • 하지만 인증 토큰은 HTTP 표준인 Authorization 헤더에 담음
    • 클라이언트는 이후 요청 시 이 토큰을 다시 헤더에 담아 보냄

unsuccesfulAuthentication - 인증 실패

  • 인증 실패 시 에러 응답

인증 흐름

전체 인증 과정을 단계별로 정리해보자.

1. 사용자 로그인 요청 (POST /login)
   └─> CustomUsernamePasswordAuthenticationFilter.attemptAuthentication()

2. AuthenticationManager에게 인증 위임
   └─> UsernamePasswordAuthenticationToken 생성

3. AuthenticationManager가 적절한 Provider 찾기
   └─> DaoAuthenticationProvider 선택

4. UserDetailsService를 통해 DB에서 사용자 조회
   └─> CustomUserDetailsService.loadUserByUsername()

5. 비밀번호 검증
   └─> PasswordEncoder.matches()

6-A. 인증 성공
   └─> successfulAuthentication() 호출
   └─> JWT 토큰 생성 및 응답

6-B. 인증 실패
   └─> unsuccessfulAuthentication() 호출
   └─> 401 에러 응답

4. JWT 발급 및 검증

앞서 로그인 검증 필터를 만들었다.
하지만 실제로 JWT 를 생성하고 검증하는 핵심 로직은 JWTUtil 클래스에서 진행한다.

이번에는 JWTUtil 클래스를 구현하면서 내부 동작을 살펴보자.

JWT 란?

JWT (Json Web Token) 는 세 부분으로 구성된 문자열이다.
점(.) 으로 구분하고, Header, Payload, Signature 로 구성된다.

  • Header : 토큰 타입과 암호화 알고리즘
  • Payload : 실제 데이터
  • Signature : 위변조 방지 서명

Header

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg : 서명 알고리즘
  • typ : 토큰 타입

Payload

{
  "username": "user123",
  "role": "ROLE_USER",
  "iat": 1516239022,
  "exp": 1516242622
}
  • username, role : 커스텀 클레임 ( 넣은 정보)
  • iat : 발급 시간
  • exp : 만료 시간

Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
  • 헤더와 페이로드를 합쳐서 비밀키로 서명한다.
  • 서명으로 인해
    • 토큰이 우리 서버에서 발급된 것인지 확인 가능
    • 중간에 누군가 내용을 변조했는지 검증 가능

Base64 인코딩

Header 과 Payload 는 Base64 로 인코딩한다.
누구나 디코딩할 수 있으므로 비밀번호나 민감한 정보는 절대 넣으면 안된다.

비밀키 관리

JWT 서명에 사용할 비밀키는 코드에 직접 넣으면 안된다.
설정 파일에 저장하고 환경 변수로 관리하는 것이 좋다.

spring:
  jwt:
    secret: ${JWT_SECRET}
  • 주의사항
    • 최소 32자 이상
    • Git 에 올리지 말고 환경 변수로 관리

JWTUtil

JWT 를 생성하고 검증하는 유틸 클래스를 만들어보자.

@Slf4j
@Component
public class JWTUtil {

    private final SecretKey secretKey;
    private final Long accessTokenExpiration; // Access Token 만료 시간

    public JWTUtil(@Value("${spring.jwt.secret}") String secret,
        @Value("${spring.jwt.access-token-expiration}") Long accessTokenExpiration
    ) {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); // 문자열 바이트 변환
        this.secretKey = Keys.hmacShaKeyFor(keyBytes); // 키 길이 검증(HS256), 알고리즘(HmacSHA256) 자동 매핑
        this.accessTokenExpiration = accessTokenExpiration;
    }

    // AccessToken 토큰 생성
    public String createAccessToken(JWTUserDto user){
        return createJWT(user,accessTokenExpiration);
    }

    // JWT 생성
    private String createJWT(JWTUserDto user, Long expiration) {
        Date now = new Date();
        Date expirationDate = new Date(now.getTime() + expiration);
        String token = Jwts.builder()
            .subject(String.valueOf(user.userUuid()))
            .claim("email", user.email())
            .claim("name", user.name())
            .claim("role", user.role().name())
            .issuedAt(now)
            .expiration(expirationDate)
            .signWith(secretKey)
            .compact();

        log.info("JWT 토큰 생성 완료 - email: {} , name: {}, role: {}, expirationDate: {}",
            user.email(), user.name(), user.role(), expirationDate);

        return token;
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("잘못된 JWT 서명입니다: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            log.warn("만료된 JWT 토큰입니다.: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.error("지원되지 않는 JWT 토큰입니다.: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.error("JWT 토큰이 잘못되었습니다.: {}", e.getMessage());
        }
        return false;
    }

    // Claims 추출
    public Claims getClaims(String token) {
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    // 토큰에서 정보 추출
    public UUID getUserUuid(Claims claims) {
        return UUID.fromString(claims.getSubject());
    }

    public String getEmail(Claims claims) {
        return claims.get("email", String.class);
    }

    public String getName(Claims claims) {
        return claims.get("name", String.class);
    }

    public Role getRole(Claims claims) {
        return Role.valueOf(claims.get("role", String.class));
    }

    public Date getExpired(Claims claims) {
        return claims.getExpiration();
    }

    public Date getIssuedAt(Claims claims) {
        return claims.getIssuedAt();
    }
}

생성자 : Secret Key 초기화

  1. 비밀키 문자열을 바이트 배열로 변환
    1. 암호화 알고리즘은 문자열이 아닌 배열을 입력으로 받음
    2. UTF-8 인코딩 사용 (한글도 안전하게 처리)
  2. 안전한 키 생성 : Keys.hmacShaKeyFor()
    1. 키 길이 검증
      1. HS256 알고리즘은 최소 32바이트 필요
      2. 자동으로 검증해서 약한 키 사용 방지
    2. SecretKey 객체 생성
      1. 알고리즘 이름 자동 매핑(HmacSHA256)

JWT 생성 : createJWT()

  1. 발급 시간과 만료 시간 계산
  2. 토큰의 주체 설정 : .subject()
    1. Subject
      1. JWT 표준 클레임 중 하나(sub)
      2. 이 토큰이 누구에 대한 것인가? 를 나타냄
    2. UUID 를 사용하는 이유
      1. 절대 바뀌지 않는 고유값
  3. 커스텀 정보 추가 : .claims()
    1. Claim
      1. JWT 의 Payload 에 저장되는 key-value 쌍
  4. 서명 생성 : .signWith()
    1. 내부 동작
      1. 헤더와 페이로드를 합침
      2. HMAC-SHA256 해시 생성
      3. Base64 인코딩
    2. 필요한 이유
      1. 공격자가 Payload 를 변조하려고 시도한다면
      2. 변조된 Payload 로는 올바른 서명을 만들 수 없음
  5. 최종 토큰 생성 : .compact()

JWT 검증 : validateToken()

토큰의 유효성을 검증하는 메서드이다.

  • 예외 종류별 의미
    • SecurityException | MalformedJwtException
      • 서명이 일치하지 않음
      • 토큰 형식이 잘못됨
    • ExpiredJwtException
      • 현재 시간이 토큰의 exp 를 넘어섬
    • UnsupportedJwtException
      • 암호화 알고리즘이 다름
    • IllegalArgumentException
      • 토큰이 null 이거나 빈 문자열
      • Base64 디코딩 실패
  • Claims 추출 : getClaims()
    1. 파서 빌더 생성 : Jwts.parser()
      1. JWT 를 파싱하기 위한 설정 시작
    2. 검증 키 설정 : .verifyWith(secretKey)
      1. 토큰을 . 으로 분리
      2. 서명 재생성
      3. 서명 비교
    3. 파서 생성 : .build
      1. 설정이 완료된 실제 파서를 생성
    4. 파싱 및 검증 : .parseSignedClaims(token)
      1. 토큰 분리 (Header,Payload,Signature)
      2. 서명 검증 ( 설정한 secretKey 로)
      3. Base64 디코딩
      4. JSON 파싱
      5. Claims 객체 생성
      6. 반환값 : Jws (서명된 클레임)
    5. Claims 추출 : .getPayload()Claims 객체를 파라미터로 받아 각 정보를 추출한다.
      Claims 를 파라미터로 받는 이유는 무엇일까?따라서 파라미터로 Claims 를 받는다.
    6. 파싱에는 Base64 디코딩, JSON 파싱, 서명 검증 등이 포함되어있다.
      토큰을 받아 매번 파싱하게 되면 이렇게 불필요한 연산이 반복된다.
    7. 정보 추출 메서드들 : getXxx()

5. JWT 검증 필터

이제 클라이언트가 보낸 토큰을 검증하여 로그인된 사용자임을 확인하는 필터를 만들어야 한다.

JWT 검증 필터란?

로그인에 성공하면 클라이언트는 JWT 토큰을 받는다.
이후 모든 API 요청 시 이 토큰을 헤더에 담아 보낸다.

서버는 매 요청마다

  1. 토큰이 유효한지 검증
  2. 토큰에서 사용자 정보 추출
  3. 데이터베이스에서 사용자 존재 여부 확인
  4. Spring Security 가 인증된 사용자로 인식하도록 설정

이 과정을 자동화하는 것이 JWT 검증 필터이다.

JWT 검증 필터 구현

Spring Security 는 필터가 요청당 딱 한 번만 실행되도록 보장하는 OncePerRequestFilter 를 제공한다.

이를 상속 받아서 구현하면 된다.

@RequiredArgsConstructor
@Slf4j
public class JWTAuthenticationFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        // Authorization 헤더에서 JWT 토큰 추출
        String authorization = request.getHeader("Authorization");

        //Authorization 헤더 검증 : JWT 헤더가 없을 경우 다음 필터로 넘김
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.debug("JWT Token 을 request headres 에서 찾을 수 없음");
            filterChain.doFilter(request, response);
            return;
        }

        // Bearer 접두사 제거해 토큰 값 추출
        String token = authorization.substring(7);

        try{
            // JWT 유효성 검증
            if (!jwtUtil.validateToken(token)) {
                log.warn("JWT token 유효성 검증 실패");
                sendErrorResponse(response, "JWT token 유효성 실패");
                return;
            }

            // JWT 유효성 검증 성공 후
            Claims claims = jwtUtil.getClaims(token);
            String email = jwtUtil.getEmail(claims);

            log.debug("JWT token 인증 유저 : {}",email);

            // 데이터베이스에서 사용자 정보 조회
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);

            // 사용자가 존재 하지 않는 경우
            if (userDetails == null) {
                log.warn("사용자가 존재하지 않음 : {}", email);
                sendErrorResponse(response, "사용자가 존재하지 않음");
            }

            // security 인증 토큰 생성
            Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());

            // 세션에 사용자 등록
            SecurityContextHolder.getContext().setAuthentication(authToken);

        }catch (Exception e) {
            log.error("JWT Authentication 실패 : {}",e.getMessage());
            sendErrorResponse(response, "Authentication 실패");
        }
        filterChain.doFilter(request, response);
    }

    private static void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write("{\"error\": \"" + message + "\"}");
    }
}

doFilterInternal

  1. Authorization 헤더에서 토큰 추출 및 검증
    1. 클라이언트는 다음과 같은 형식으로 토큰을 보낸다.
    2. Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    3. 에러를 내지 않고 doFilter() 를 호출하는 이유
      1. 모든 엔드포인트가 인증을 요구하는 건 아님
  2. Bearer 접두사 제거
    1. 7번 인덱스부터 하는 이유
      1. “Bearer “ - 7글자 (공백 포함)
      2. substring(7) : 7번 인덱스부터 끝까지 추출
  3. 유효성 검증
    1. validateToken() 을 호출해 JWT 유효성 검증을 함
    2. 검증 실패 시 에러 응답을 보내는 이유
      1. 클라이언트가 토큰 문제임을 인지하고 재로그인 유도 가능
  4. 토큰에서 사용자 정보 추출
  5. 데이터베이스에서 사용자 정보 조회
    1. JWT 에서 이미 사용자 정보가 있는데 DB 를 조회하는 이유
      1. 사용자 계정 상태 확인 (JWT 만 검증하면 계정이 비활성화 되었는데도 접근이 가능함)
      2. 권한 변경 반영 (JWT 만 검증하면 오래된 권한으로 접근)
      3. 추가 정보 로드 (JWT 에는 최소한의 정보만 담고, 나머지는 DB 에서)
  6. 사용자 존재 여부 확인
    1. 토큰 발급 후 사용자가 계정 탈퇴
    2. 사용자 삭제
  7. SecurityContext 에 인증 정보 저장
    1. Spring Security 는 SecurityContext 에 인증 정보가 있으면 로그인된 사용자로 인식
    2. UsernamePasswordAuthenticationToken 파라미터
      1. principal(주체) : 사용자 정보
      2. credentials(자격 증명) : 비밀번호 → null(이미 검증 완료)
      3. authorities(권한) : 사용자의 권한 목록
  8. 다음 필터로 넘김

sendErrorResponse : JSON 에러 응답

REST API 는 모든 응답이 일관된 형식이어야 하기 때문에 JSON 으로 에러를 보낸다.

  1. 상태 코드 설정
    1. SC_UNAUTHORIZED = 401 = 인증 실패(토큰 없음, 만료, 유효하지 않음)
  2. Content-Type 설정
    1. 클라이언트에게 “ 이 응답은 JSON 이야” 라고 알려줌
  3. 문자 인코딩 설정
    1. 한글 에러 메시지가 깨지지 않도록
  4. JSON 응답 작성

6. UserDetails & UserDetailsService

앞서 JWT 토큰을 생성하고 검증하는 필터들을 만들었다.
하지만 Spring Security 는 어떻게 우리 데이터베이스의 User 엔티티를 이해하는 것일까?

Spring Security 와 우리 도메인 모델을 연결하는 다리 역할을 하는 법을 알아보자.

UserDetails와 UserDetailsService 란?

Spring Security 는 사용자 인증을 위해 두 가지 핵심 인터페이스를 제공한다.

  • UserDetails
    • Spring Security 가 이해할 수 있는 형태의 사용자 정보
  • UserDetailsService
    • 사용자 정보를 어디서 , 어떻게 가져올지 정의

User (우리 도메인) →CustomUserDetailsService : CustomUserDetails (변환) → Spring Security

CustomUserDetails

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
        authorities.add(new GrantedAuthority() {
            @Override
            public @Nullable String getAuthority() {
                return user.getRole().toString();
            }
        });
        return authorities;
    }

    @Override
    public @Nullable String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getEmail(); // email 을 username 으로 사용
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    public @Nullable UUID getUesrUuid(){
        return user.getUuid();
    }
    public @Nullable String getEmail(){
        return user.getEmail();
    }
    public @Nullable String getName(){
        return user.getName();
    }
    public @Nullable Role getRole(){
        return user.getRole();
    }
}

먼저 UserDetails 인터페이스를 구현하여 User 엔티티를 감싸는 래퍼 클래스를 만든다.

  1. 권한 반환 : getAuthorities()
    1. 사용자의 권한 목록을 Spring Security가 이해할 수 있는 형태로 반환
    2. 동작 순서
      1. 빈 권한 컬렉션 생성
      2. GrantedAuthority 익명 클래스 생성
  2. 사용자 식별자 : getUsername()
    1. 일반적으로 username 은 로그인 ID 를 의미한다.
    2. Run-ing 프로젝트는 이메일을 username 으로 사용한다.
  3. 비밀번호 : getPassword()
  4. 계정 상태 메서드 : isXxx()
    1. isAccountNonExpired() : 계정 만료
    2. isAccountNonLocked() : 계정 잠금
    3. isCredentialsNonExpired() : 비밀번호 만료
    4. isEnabled() : 계정 활성
  5. 추가 getter 메서드

CustomUserDetailsService

이제 사용자 정보를 DB 에서 조회하여 CustomUserDetails 로 변환하는 서비스를 만들어보자.

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("사용자 조회 시도 : {}",username);

        // 이메일로 사용자 조회
        User user = userRepository.findByEmail(username)
            .orElseThrow(() -> {
                log.warn("사용자를 찾을 수 없습니다 - email: {}", username);
                return new UsernameNotFoundException("사용자를 찾을 수 없습니다. - email : " +username);
            });
        log.debug("사용자 로드 성공 - email: {}", username);

        return new CustomUserDetails(user);
    }
}
  1. loadUserByUsername()
    1. 파라미터
      1. username : 사용자 식별자 (프로젝트에서는 email 사용)
    2. 반환값
      1. UserDetails : Spring Security 가 이해할 수 있는 형태의 사용자 정보
    3. 예외
      1. UsernameNotFoundException : 사용자를 찾을 수 없을 때
  2. 동작
    1. 데이터베이스에서 사용자 조회
    2. CustomUserDetails 로 변환 후 반환

이렇게 JWT 인증 방식을 프로젝트에 어떻게 적용했는지 알아보았다.

아직 완벽하게 구현되지는 않았고 몇 가지 보완할 점이 있다.

  1. Refresh 토큰
  2. 매번 DB 를 조회하면 느려지기 때문에, Redis 캐싱 적용
  3. 예외 및 로그 구체적으로 구현

기능이 추가적으로 구현이 되면 이 글에 추가하도록 하겠다.


참고자료

https://prao.tistory.com/entry/Spring-Security-Spring-Security%EC%99%80-JWT-%EC%A0%81%EC%9A%A9-%EA%B3%BC%EC%A0%95#article-12--securityconfig

https://closed-on-sunday.tistory.com/10

https://suddiyo.tistory.com/entry/Spring-Spring-Security-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-2

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/dashboard

https://www.youtube.com/@xxxjjhhh