스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초

스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 1편

개발학생 2026. 4. 10. 19:39
반응형

*책 내용과 다르게, 다음과 같은 환경에서 프로젝트 생성  

 

[Java] 차근차근 Java 설치하기 (JDK17, Window 11)

자바 개발 도구 설치 방법에 대해서 알아보겠습니다. Java17은 LTS(Long Term Support : 장기 지원) 릴리즈로 1년 후까지 기술 지원 및 버그를 개선한 서비스를 제공받을 수 있습니다. 업데이트 버전을 꾸

yungenie.tistory.com

 

[Windows] Spring Tool Suite 4(STS 4) 다운로드 및 설치

STS란?Spring Tool Suite(STS)는 스프링 프로젝트를 생성하고, 개발할 수 있게 해주는 도구입니다. STS 설치 과정에 대해 설명드리겠습니다. 설치 파일 다운로드STS 공식 사이트에서 설치 파일을 다운로

priming.tistory.com

 

MySQL :: Download MySQL Community Server

Select Version: 9.3.0 Innovation 8.4.5 LTS 8.0.42 Select Operating System: Select Operating System… Microsoft Windows Ubuntu Linux Debian Linux SUSE Linux Enterprise Server Red Hat Enterprise Linux / Oracle Linux Fedora Linux - Generic Oracle Solaris mac

dev.mysql.com

  • Gradle

**STS에서 Gradle 프로젝트 생성한 과정 

*** 함께 보면 좋은 글

2025.07.10 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링 부트 핵심 가이드(장정우 지음) - 스프링 부트 개요

 

스프링 부트 핵심 가이드(장정우 지음) - 스프링 부트 개요

1. 스프링 프레임워크자바(Java) 기반 애플리케이션 프레임워크로, 엔터프라이즈급(기업 환경 대상 개발) 애플리케이션을 위한 다양한 기능 제공-> 오픈소스 경량급 애플리케이션 프레임워크로

keep-programming-study.tistory.com

2025.07.11 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링 부트 핵심 가이드(장정우 지음) - 개발에 앞서 알면 좋은 기초 지식

 

스프링 부트 핵심 가이드(장정우 지음) - 개발에 앞서 알면 좋은 기초 지식

1. 서버 간 통신마이크로서비스 아키텍처에서 한 서버가 다른 서버에 통신을 요청하는 것을 의미-> 한 대는 서버/다른 한 대는 클라이언트가 됨 가장 많이 사용되는 방식은 HTTP/HTTPS 방식(TCP/IP, SOA

keep-programming-study.tistory.com

2026.03.26 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security) 개요

 

스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security) 개요

*책 내용과 다르게, 다음과 같은 환경에서 프로젝트 생성 Windows11(윈도우 11) 환경자바 JDK 17 버전 설치 https://yungenie.tistory.com/11 [Java] 차근차근 Java 설치하기 (JDK17, Window 11)자바 개발 도구 설치 방법

keep-programming-study.tistory.com

 

1. JWT(Json Web Token) 개요

  • 당사자 간 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
  • URL로 이용 가능한 문자열로만 구성되어 있어, HTTP 구성요소 어디든 위치 가능
  • 디지털 서명이 적용되어 있어 신뢰성 확보
  • 주로 서버와의 통신에서 권한 인가를 위해 사용

1) JWT의 구조

  • 점(.)으로 구분된 세 부분으로 구성
  • 형식: 헤더(Header).내용(Payload).서명(Signature)

(1) 헤더(Header)

  • 검증과 관련 내용을 담고 있음
  • alg, typ 속성
    1. alg: 해싱 알고리즘(SHA256 또는 RSA 사용)을 지정하여 토큰 검증시 사용되는 서명 부분에서 사용
      -> 어떤 알고리즘을 사용해서 서명을 만들고 검증할지 지정하는 값 
    2. typ: 토큰 타입 지정  
해싱 알고리즘    방식 키 구조 JWT 사용 예
SHA-256 (HMAC)  해시 기반   대칭키 (서명·검증에 같은 키 사용) HS256
RSA 공개키 암호화 비대칭키 (서명은 비밀키, 검증은 공개키) RS256
{
  "alg": "HS256",
  "typ": "JWT"
}
  • 완성된 헤더는 Base64Url 형식으로 인코딩되어 사용
    -> JWT 헤더를 JSON으로 만든 뒤, Base64Url 방식으로 변환해서 토큰에 포함

(2) 내용(Payload)

  • 토큰에 담는 정보를 포함
  • 클레임(Claim) 속성을 세 가지로 분류
    1. 등록된 클레임(Registered Claims)
      • iss: JWT 발급자(Issuer) 주체로, 값은 문자열이나 URI를 포함하는 대소문자 구분 문자열
      • sub: JWT의 제목(Subject)
      • aud: JWT의 수신인(Audience)로, 각 주체(iss)가 이 값으로 자신을 식별하지 않으면 JWT가 거부됨
      • exp: JWT의 만료시간(Expiration)으로, 시간은 NumericDate 형식[1970년 1월 1일 00:00:00 UTC(유닉스 에포크, Unix Epoch)부터 경과한 초를 정수로 나타낸 값] 으로 지정 
      • nbf: 'Not Before'를 의미
      • iat: JWT가 발급된 시간(Issued at)
      • jti: JWT의 식별자(JWT ID)로, 중복 처리 방지를 위해 사용
    2. 공개 클레임(Public Claims)
      • 키 값을 마음대로 정의할 수 있는데, 충돌이 발생하지 않도록 주의 필요
    3. 비공개 클레임(Private Claims)
      • 통신 간 상호 합의된, 등록된/공개된 클레임이 아닌 클레임
{
  "sub": "wikibooks payload",
  "exp": "1602076408",
  "userId": "wikibooks",
  "username": "flature"
}
  • 완성된 헤더는 Base64Url 형식으로 인코딩되어 사용
    -> JWT 헤더를 JSON으로 만든 뒤, Base64Url 방식으로 변환해서 토큰에 포함

(3) 서명(Signature)

  • 인코딩된 헤더, 인코딩된 내용, 비밀 키, 헤더의 알고리즘 속성값을 가져와 생성
  • 서명은 토큰 값들을 포함하여 암호화하므로, 메시지가 도중에 변경되지 않았는지 확인할 때 사용 
  • 예시(HMAC SHA256 알고리즘 사용)
HMACSHA256(
  base64UrlEncode(Header) + "." +
  base64UrlEncode(Payload),
  secret
  )

2) JWT 디버거 사용하기

 

JSON Web Token Introduction - jwt.io

Learn about JSON Web Tokens, what are they, how they work, when and why you should use them.

www.jwt.io

 

JWT 공식 사이트 - Decoded의 내용 변경 시 Encoded 콘텐츠가 자동으로 수정됨

2. 스프링 시큐리티와 JWT 적용

1) 기존 프로젝트(study)에 의존성 추가

  • build.gradle의 dependencies에 다음과 같이 추가한 후, 저장 후 우클릭하여 Gradle -> Refresh Gradle Project 클릭
dependencies {
    ... 생략 ...
    
    // Spring Boot 기본 스타터
    implementation 'org.springframework.boot:spring-boot-starter'

    // MariaDB JDBC Driver
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // JJWT (Java JWT)
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly('io.jsonwebtoken:jjwt-jackson:0.11.5') // JSON 처리용
}

 

2) UserDetails와 UserDetailsService 구현

  • UserDetails는 스프링 시큐리티에서 제공하는 개념
  • UserDetails에서의 username은 각 사용자를 구분할 수 있는 ID를 의미
  • UserDetails의 구현체로 User 엔티티를 생성하게 하여 User 객체를 리턴하게끔 구현 
    -> Spring Security가 요구하는 사용자 정보 구조와 DB 엔티티를 일치시켜서 변환 과정을 줄이고,
        권한/계정 상태를 통합 관리

(1) User 엔티티 생성

package com.example.demo.jpa.data.entity;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;

import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;

import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;


@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false, unique = true)
    private String uid;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    /** 사용자 권한 목록 반환 (roles → SimpleGrantedAuthority 변환) */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    /** 로그인 아이디로 사용할 uid 반환 */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    /** 비밀번호 반환 (JSON 응답에는 노출되지 않음) */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getPassword() {
        return this.password;
    }

    /** 계정 만료 여부 (true → 만료 없음) */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /** 계정 잠금 여부 (true → 잠금 없음) */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /** 자격 증명 만료 여부 (true → 만료 없음) */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /** 계정 활성화 여부 (true → 항상 활성화) */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

(2) UserRepository 구현

  • JpaRepository 상속
package com.example.demo.jpa.data.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.example.demo.jpa.data.entity.User;

/**
 * User 엔티티용 JPA Repository
 *
 * - JpaRepository<User, Long> 상속: 기본적인 CRUD 메서드 제공
 * - 제네릭 타입:
 *   User → 엔티티 클래스
 *   Long → 기본 키 타입
 *
 * 커스텀 메서드:
 * - getByUid(String uid): uid 컬럼을 기준으로 User 엔티티 조회
 *   (Spring Data JPA의 메서드 이름 규칙에 따라 자동으로 쿼리 생성)
 */
public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}

(3) UserDetailsService, UserDetailsServiceImpl 구현

package com.example.demo.service;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

/**
 * 사용자 인증을 위한 서비스 인터페이스
 *
 * - Spring Security에서 사용자 정보를 불러오기 위해 사용
 * - UserDetailsService 인터페이스는 반드시 구현체가 필요하며,
 *   loadUserByUsername() 메서드를 통해 DB 등에서 사용자 정보를 조회
 *
 * 주요 메서드:
 * - loadUserByUsername(String username)
 *   → username(로그인 아이디)으로 UserDetails 객체를 반환
 *   → 사용자가 존재하지 않을 경우 UsernameNotFoundException 발생
 */
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
package com.example.demo.service.impl;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import com.example.demo.jpa.data.repository.UserRepository;
import com.example.demo.service.UserDetailsService;


import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    
    /** 로깅을 위한 Logger 인스턴스 */
    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
    
    /** 사용자 정보를 조회하기 위한 JPA Repository */
    private final UserRepository userRepository;
    
    /**
     * username(uid)으로 사용자 정보를 조회하여 UserDetails 반환
     * - 로그 기록: 메서드 수행 시 username 출력
     * - userRepository.getByUid(username) 호출로 DB에서 사용자 조회
     * - 조회된 User 엔티티는 UserDetails를 구현하므로 그대로 반환 가능
     */
    @Override
    public UserDetails loadUserByUsername(String username) {
        LOGGER.info("[loadUserByUsername] 수행, username: {}", username);
        return userRepository.getByUid(username);
    }
}

3) JwtTokenProvider 구현

(1) UsernamePasswordAuthenticationToken의 상속 구조

(2) JwtTokenProvider 구현

  • JWT 토큰 생성에 필요한 정보를 UserDetails에서 가져와서, JwtTokenProvider에서 JWT 토큰을 생성
  • 보안을 위해서는 secretKey 값을 application.properties 파일에서 값을 정의하나,
    값을 가져올 수 없을 경우 기본값(아래 코드에서는 secretKey)을 가져옴 
package com.example.demo.config.security;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.List;

import javax.crypto.spec.SecretKeySpec;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import com.example.demo.service.UserDetailsService;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

/**
 * JWT 토큰을 생성하고 관리하는 Provider 클래스
 *
 * 주요 역할:
 * - secretKey 초기화 (Base64 인코딩)
 * - 사용자 정보(uid, roles)를 Claims에 담아 JWT 토큰 생성
 * - 토큰에서 subject(uid) 추출 (getUsername)
 * - 토큰 기반으로 UserDetails 조회 후 Authentication 객체 생성 (getAuthentication)
 * - HTTP 요청 헤더에서 토큰 추출 (resolveToken)
 * - 토큰 유효성 검증 (validateToken: 서명 검증 및 만료 시간 확인)
 *
 * 즉, JWT 인증 흐름의 핵심 기능을 모두 제공하는 클래스이며,
 * Spring Security의 필터나 인증 로직에서 직접 사용되어
 * 사용자 인증 및 권한 부여 과정을 지원한다.
 *
 * 사용 라이브러리:
 * - io.jsonwebtoken.Jwts: JWT 빌더 및 파서
 * - io.jsonwebtoken.Claims: JWT payload에 담을 데이터
 * - SignatureAlgorithm.HS256: HMAC-SHA256 알고리즘으로 서명
 */

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    
    /** 로깅을 위한 Logger 인스턴스 */
    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);

    /** 사용자 정보를 조회하기 위한 UserDetailsService (인증 과정에서 활용 가능) */
    private final UserDetailsService userDetailsService;
    
    /** application.yml 또는 properties에서 주입받는 secretKey (기본값: "secretKey") */
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";

    /** 토큰 유효 시간 (1시간 = 1000ms * 60 * 60) */
    private final long tokenValidMillisecond = 1000L * 60 * 60;
    
    /** 변환된 Key 객체를 저장할 필드 */
    private Key key;
    
    /**
     * secretKey 초기화 메서드
     * - @PostConstruct: Bean 생성 후 자동 실행
     * - secretKey를 Base64 인코딩하여 보안 강화
     * - secretKey → Key 객체 변환을 한 번만 수행(보안 강화를 위해 String 대신 Key 객체 사용)
     */
    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8),
                                SignatureAlgorithm.HS256.getJcaName());
        
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }
    
    /**
     * JWT 토큰 생성 메서드
     *
     * @param userUid 사용자 고유 ID (subject)
     * @param roles   사용자 권한 목록
     * @return        생성된 JWT 토큰 문자열
     *
     * 동작 흐름:
     * 1. Claims 객체 생성 → subject(userUid)와 roles 추가
     * 2. 현재 시간(now) 기준으로 발급일(issuedAt)과 만료일(expiration) 설정
     * 3. secretKey를 Key 객체로 변환 (SecretKeySpec)
     * 4. Jwts.builder()로 토큰 생성 및 HS256 알고리즘으로 서명
     * 5. compact() 호출로 최종 JWT 문자열 반환
     */
    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        
        // JWT payload에 담을 Claims 생성
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();
        
        // JWT 토큰 빌드
        String token = Jwts.builder()
                .setClaims(claims) // 사용자 정보 포함
                .setIssuedAt(now) // 발급 시간
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond)) // 만료 시간
                .signWith(key, SignatureAlgorithm.HS256) // 서명 (Key 객체 기반)
                .compact(); // 최종 문자열 반환
        
        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }
    
    /**
     * JWT 토큰에서 사용자 이름(subject)을 추출하는 메서드
     *
     * 동작 흐름:
     * 1. secretKey를 Key 객체로 변환 (SecretKeySpec 사용)
     *    - 문자열 기반 키 대신 Key 객체를 사용해야 보안 강화 및 최신 JJWT API 대응 가능
     * 2. Jwts.parserBuilder()로 파서 생성
     *    - setSigningKey(key): 토큰 서명 검증을 위한 키 설정
     *    - build(): 파서 빌드
     * 3. parseClaimsJws(token): 토큰을 파싱하여 Claims(페이로드) 추출
     * 4. getBody().getSubject(): Claims에서 subject 값(사용자 UID) 반환
     * 5. 로그 기록: 토큰 기반 회원 구별 정보 추출 시작/완료 로그 출력
     *
     * @param token JWT 토큰 문자열
     * @return      토큰에 저장된 subject (userUid)
     */
    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");

        // JWT 파싱 및 subject 추출
        String info = Jwts.parserBuilder()
                .setSigningKey(key)   // ✅ Key 객체 사용
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();

        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info: {}", info);

        return info;
    }

    /**
     * JWT 토큰을 기반으로 Authentication 객체를 생성하는 메서드
     *
     * 동작 흐름:
     * 1. 로그 기록: 토큰 인증 정보 조회 시작
     * 2. getUsername(token) 호출 → 토큰에서 subject(uid) 추출
     * 3. userDetailsService.loadUserByUsername(uid) 호출 → DB에서 사용자 정보(UserDetails) 조회
     * 4. 로그 기록: 조회 완료 및 사용자 이름 출력
     * 5. UsernamePasswordAuthenticationToken 생성
     *    - principal: 조회된 UserDetails 객체
     *    - credentials: 빈 문자열("") → 비밀번호는 필요하지 않음
     *    - authorities: UserDetails에 포함된 권한 목록
     * 6. 최종적으로 Authentication 객체 반환 → SecurityContext에 저장되어 인증 완료 상태로 사용됨
     *
     * @param token JWT 토큰 문자열
     * @return      Spring Security Authentication 객체
     */
    public Authentication getAuthentication(String token) {
        LOGGER.info("토큰 인증 정보 조회 시작");

        // 토큰에서 사용자 uid(subject) 추출 후 DB에서 UserDetails 조회
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));

        LOGGER.info("토큰 인증 정보 조회 완료, UserDetails UserName: {}", userDetails.getUsername());

        // UserDetails 기반으로 Authentication 객체 생성
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    
    /**
     * HTTP 요청 헤더에서 JWT 토큰을 추출하는 메서드
     *
     * 동작 흐름:
     * 1. 로그 기록: 토큰 추출 시작
     * 2. HttpServletRequest의 "X-AUTH-TOKEN" 헤더 값 반환
     *    - 클라이언트가 요청 시 Authorization 헤더 대신 커스텀 헤더("X-AUTH-TOKEN")에 토큰을 담아 전달
     * 3. 반환된 문자열이 JWT 토큰으로 사용됨
     *
     * @param request HttpServletRequest 객체
     * @return        요청 헤더에 담긴 JWT 토큰 문자열
     */
    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    /**
     * JWT 토큰의 유효성을 검증하는 메서드
     *
     * 동작 흐름:
     * 1. 로그 기록: 토큰 유효성 체크 시작
     * 2. secretKey → Key 객체 변환 (SecretKeySpec 사용)
     *    - 문자열 기반 키 대신 Key 객체를 사용하여 보안 강화
     * 3. Jwts.parserBuilder()로 파서 생성 후 build()
     *    - setSigningKey(key): 토큰 서명 검증용 키 설정
     *    - parseClaimsJws(token): 토큰 파싱 및 서명 검증
     * 4. Claims에서 만료 시간(expiration) 추출
     *    - 현재 시간과 비교하여 만료 여부 확인
     *    - 만료되지 않았다면 true 반환
     * 5. 예외 발생 시 (서명 불일치, 토큰 변조, 만료 등) false 반환
     *
     * @param token JWT 토큰 문자열
     * @return      토큰이 유효하면 true, 그렇지 않으면 false
     */
    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작");

        try {
            // 토큰 파싱 및 서명 검증
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);

            // 만료 시간 확인
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }

}

4) JwtAuthenticationFilter 구현

  • JWT 토큰으로 인증한 후 SecurityContextHolder에 추가하는 필터를 설정하는 클래스
  • GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링 설정 정보를 가져올 수 있게 확장된 추상 클래스
    -> 서블릿은 사용자의 요청을 받으면 서블릿 생성 후 메모리에 저장해둔 다음 동일한 클라이언트의 요청을 받으면 재활용하는 구조이므로, 이를 상속받으면 RequestDispatcher에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행
    • 이를 해결하기 위해 등장한 것이 OncePerRequestFilter인데,
      이는 GenericFilterBean을 상속받지만 매 요청받아 한 번만 실행됨

(1) OncePerRequestFilter 상속 ver - 이 버전을 사용할 것

package com.example.demo.config.security;

import java.io.IOException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * JWT 인증을 처리하는 커스텀 필터 클래스
 * 
 * OncePerRequestFilter를 상속하여 모든 요청마다 한 번만 실행됨.
 *
 * 주요 역할:
 * 1. HTTP 요청에서 JWT 토큰 추출 (resolveToken)
 * 2. 토큰 유효성 검증 (validateToken)
 * 3. 토큰이 유효하다면 Authentication 객체 생성 후 SecurityContext에 저장
 *    - SecurityContextHolder는 현재 요청의 인증 상태를 관리하는 Spring Security의 핵심 컨텍스트
 * 4. 필터 체인을 계속 진행하여 이후 요청 처리 로직으로 전달
 *
 * 즉, 이 필터는 클라이언트 요청이 들어올 때마다 JWT 기반 인증을 수행하고,
 * 인증된 사용자 정보를 Spring Security 컨텍스트에 주입하는 역할을 담당
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    // JWT 검증을 건너뛸 경로들
    private static final List<String> EXCLUDE_URLS = List.of(
    	"/swagger-ui",
    	"/swagger-ui/",
    	"/swagger-ui.html",
    	"/swagger-ui/**",
    	"/v3/api-docs",
    	"/v3/api-docs/",
    	"/v3/api-docs/**",
    	"/v3/api-docs.yaml",
    	"/api/public",
    	"/sign-api",
    	"/exception"
    );
    
    /**
     * 실제 필터 로직 구현 메서드
     *
     * 동작 흐름:
     * 1. 요청(servletRequest)에서 JWT 토큰 추출
     * 2. 토큰 값 로그 출력
     * 3. 토큰 유효성 검증 시작
     * 4. 토큰이 존재하고 유효하다면:
     *    - JwtTokenProvider.getAuthentication(token) 호출 → Authentication 객체 생성
     *    - SecurityContextHolder.getContext().setAuthentication(authentication) → 인증 정보 저장
     *    - 로그 출력: 유효성 체크 완료
     * 5. 필터 체인 계속 진행 (filterChain.doFilter)
     *
     * @param servletRequest  클라이언트 요청 객체
     * @param servletResponse 클라이언트 응답 객체
     * @param filterChain     필터 체인 객체
     */
    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
                                    HttpServletResponse servletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
        
        // Swagger, 공개 API는 JWT 검증 건너뛰기
    	String path = servletRequest.getRequestURI();
    	
        if (EXCLUDE_URLS.stream().anyMatch(path::startsWith)) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        
        // 요청에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token: {}", token);
        
        // 토큰 유효성 검증
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 Authentication 객체 생성 후 SecurityContext에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        
        // 필터 체인 계속 진행
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

 

(2) GenericFilterBean 상속 ver

package com.example.demo.config.security;

import java.io.IOException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;

/**
 * JWT 인증을 처리하는 커스텀 필터 클래스 (GenericFilterBean 기반)
 *
 * 주요 역할:
 * 1. HTTP 요청에서 JWT 토큰 추출 (resolveToken)
 * 2. 토큰 유효성 검증 (validateToken)
 * 3. 토큰이 유효하다면 Authentication 객체 생성 후 SecurityContext에 저장
 *    - SecurityContextHolder는 현재 요청의 인증 상태를 관리하는 Spring Security의 핵심 컨텍스트
 * 4. 필터 체인을 계속 진행하여 이후 요청 처리 로직으로 전달
 *
 * 특징:
 * - GenericFilterBean을 상속하여 Spring Security 필터 체인에 등록 가능
 * - OncePerRequestFilter와 달리, 요청마다 한 번만 실행된다는 보장은 없지만
 *   보통 SecurityConfig에서 적절히 등록하면 동일하게 동작
 *
 * 즉, 이 필터는 클라이언트 요청이 들어올 때마다 JWT 기반 인증을 수행하고,
 * 인증된 사용자 정보를 Spring Security 컨텍스트에 주입하는 역할을 담당한다.
 */
public class JwtAuthenticationFilter2 extends GenericFilterBean {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter2.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter2(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    /**
     * 실제 필터 로직 구현 메서드
     *
     * 동작 흐름:
     * 1. 요청(servletRequest)에서 JWT 토큰 추출
     * 2. 토큰 값 로그 출력
     * 3. 토큰 유효성 검증 시작
     * 4. 토큰이 존재하고 유효하다면:
     *    - JwtTokenProvider.getAuthentication(token) 호출 → Authentication 객체 생성
     *    - SecurityContextHolder.getContext().setAuthentication(authentication) → 인증 정보 저장
     *    - 로그 출력: 유효성 체크 완료
     * 5. 필터 체인 계속 진행 (filterChain.doFilter)
     *
     * @param servletRequest  클라이언트 요청 객체
     * @param servletResponse 클라이언트 응답 객체
     * @param filterChain     필터 체인 객체
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        // 요청에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
        LOGGER.info("[doFilter] token 값 추출 완료. token: {}", token);
        
        // 토큰 유효성 검증
        LOGGER.info("[doFilter] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 Authentication 객체 생성 후 SecurityContext에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilter] token 값 유효성 체크 완료");
        }
        
        // 필터 체인 계속 진행
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

5) SecurityConfig 구현

  • WebSecurityConfigureAdapter를 상속받는 Configuration 구현하는 방식은 최신 버전에서 완전히 제거됨
  • Spring Security 팀은 구성 클래스 상속 방식 대신, 명시적인 Bean 등록 방식을 권장
    -> SecurityFilterChain Bean을 직접 등록
package com.example.demo.config.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	// JWT 필터를 SecurityConfig에 연결하기 위해 꼭 필요한 의존성 주입 코드
	private final JwtTokenProvider jwtTokenProvider;
	
	@Autowired
	public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
		this.jwtTokenProvider = jwtTokenProvider;
	}
	
	 /**
     * Spring Security의 핵심 보안 설정을 정의하는 Bean
     *
     * SecurityFilterChain을 Bean으로 등록해야 최신 버전(Spring Security 5.7+)에서
     * 보안 설정이 적용됨. (WebSecurityConfigurerAdapter는 제거됨)
     *
     * @param http HttpSecurity 객체 (Spring Security가 제공하는 보안 설정 DSL)
     * @return     SecurityFilterChain (최종 보안 필터 체인)
     */
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
	        // CSRF 비활성화 (REST API 서버에서는 보통 CSRF 토큰을 사용하지 않음)
	        .csrf(csrf -> csrf.disable())
	
	        // 요청별 권한 설정
	        .authorizeHttpRequests(auth -> auth
	            	            .requestMatchers(
	            		"/api/public/**",
	            		"/swagger-ui/**",     // Swagger UI 리소스 전체 허용
	            		"/swagger-ui.html",    // Swagger UI 진입 페이지
	            		"/v3/api-docs/**",     // OpenAPI 문서 엔드포인트 허용
                        // 뒤에 실습할 api 엔드포인트들 허용
                        "/sign-api/**",    
                        "**exception**"
	              ).permitAll() // 공개 API는 모두 허용
	            .requestMatchers(HttpMethod.GET, "/product", "/product/**").permitAll()  // GET 메서드 외에 나머지는 모두 인증 필요
                // 관리자 전용 페이지라면 .authenticated() 대신 .hasRole("ADMIN")
	            .anyRequest().authenticated()                 // 그 외 요청은 인증 필요
	        )
	
	        // JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
	        // → 기본 로그인 인증 필터보다 먼저 JWT 검증을 수행하도록 설정
	        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
	                         UsernamePasswordAuthenticationFilter.class)
	
	        // 기본 form 로그인 설정 (커스터마이징 가능)
	        .formLogin(Customizer.withDefaults());
	
	    // 최종적으로 SecurityFilterChain 반환
	    return http.build();
	}
}

 

 

***다음 편에서 계속됩니다***

반응형