[SpringBoot] Spring Secutiry + JWT 기반 로그인 구현

2026. 1. 22. 19:35·SpringBoot

Spring Security와 JWT를 활용한 로그인 기능 구현

 

주요 기능

1. 회원가입

  • 아이디(사용자명), 이메일, 비밀번호 검증
  • 중복 체크
  • 비밀번호 암호화 (BCrypt)
  • JWT 토큰 자동 발급

2. 로그인

  • 사용자 인증
  • JWT 토큰 발급

3. JWT 인증

  • Authorization 헤더의 Bearer 토큰 검증
  • 자동 인증 처리

 

라이브러리 추가

jjwt의 경우 보통 0.11.x 버전을 쓰는 것 같았는데, 공부 목적이니까 제일 최근 버전인 0.12.3을 쓰기로 함

변경된 부분이 있으므로 코드 작성 시 주의 필요

dependencies {
    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.boot:spring-boot-starter-security-test'

    // JWT Token
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

JWT 발급 및 검증

인증 과정

  1. 로그인 요청
  2. LoginFilter에서 사용자 인증 진행
  3. 인증 성공 시 JWT 발급
  4. 클라이언트는 JWT를 Authorization 헤더에 포함하여 요청
  5. 서버에서 JWT 검증 후 사용자 인증
  6. JWT Util을 사용하여 토큰 검증

 

JWT Util

JWT Util 생성, 검증 및 유효성 검사 기능

 

1. 비밀키, 토큰 유효 시간 설정값 주입

 

2. 서명 키 생성

 

3. 토큰에서 username, 만료시간, 공통 클레임 추출

 

4. 모든 클레임 파싱

private Claims extractAllClaims(String token) {
    return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
}
  • 토큰 서명 검증 --> 서명이 틀리면 예외 발생 (1차 검증)
  • 위조 여부 체크
  • Payload(Claims) 추출

 

5. 토큰 만료 여부 확인

 

6. 토큰 생성 (기본)

public String generateToken(UserDetails userDetails) {
    Map<String, Object> claims = new HashMap<>();
    return createToken(claims, userDetails.getUsername());
}
  • 로그인 성공 시 호출
  • 현재는 username만 subject로 저장
  • 필요하면 roles, userId 등을 claims에 추가 가능

 

7. 실제 JWT 생성

private String createToken(Map<String, Object> claims, String subject) {
    return Jwts.builder()
            .claims(claims)    // 사용자 정의 정보
            .subject(subject)    // 사용자 식별자 (username)
            .issuedAt(new Date(System.currentTimeMillis()))    // 토큰 발급 시간
            .expiration(new Date(System.currentTimeMillis() + expiration))    // 토큰 만료 시간
            .signWith(getSigningKey())    // 서명
            .compact();
}

 

8. 토큰 유효성 검증

 

전체 코드

💡 JWT 암호화 키 관리
JWT를 서명하고 검증하기 위해서는 비밀키를 안전하게 관리해야 함
비밀키를 코드 내부에 하드코딩하지 않고 설정 파일(application.properties)에서 관리하는 것이 안전

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    // Secret Key 생성
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // 토큰에서 username 추출
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 토큰에서 만료 시간 추출 - 토큰 만료 여부 판단에 사용
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 토큰에서 특정 클레임 추출
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // 모든 클레임 추출
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // 토큰 만료 여부 확인
    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // 토큰 생성
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }

    // 토큰 생성 (클레임 포함)
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .claims(claims)
                .subject(subject)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }

    // 토큰 유효성 검증
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

}

 

JWT Filter

매 요청마다 JWT를 검사해서 이 요청이 누구의 요청인지를 SecurityContext에 세팅함

 

전체 흐름

  1. Client Request
  2. JwtAuthenticationFilter
  3. Authorization 헤더 검사
  4. JWT 파싱 & 검증
  5. SecurityContext 인증 등록
  6. Controller 접근 가능

 

1. OncePerRequestFilter 상속

  • 요청당 1번만 실행됨
  • 동일 요청에서 여러 번 인증되는 문제 방지
  • Spring Security에서 JWT 필터는 OncePerRequestFilter를 상속하는 게 정석

 

2. 의존성 필드

  • JwtUtil
    • 토큰 파싱
    • username 추출
    • 만료 여부 검증
  • UserDetailsService
    • username --> UserDetails 조회
    • DB 기반 사용자 검증
  • JWT + DB 정보 모두 확인하는 구조

 

3. 모든 HTTP 요청마다 실행

@Override
protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
)

 

4. Authorization 헤더 추출

  • 클라이언트 요청 예) Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

 

5. Bearer 토큰 여부 확인

if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
    jwt = authorizationHeader.substring(7);
    try {
        username = jwtUtil.extractUsername(jwt);
    } catch (Exception e) {
        logger.error("JWT 토큰 파싱 오류: " + e.getMessage());
    }
}
  • Authorization 헤더 존재 여부 확인
  • "Bearer "로 시작하는지 확인
  • "Bearer " 제거 --> 실제 JWT
  • 토큰에서 username 추출

 

6. SecurityContext 중복 인증 방지

if (username != null 
    && SecurityContextHolder.getContext().getAuthentication() == null) {
  • username != null
    • 토큰 파싱 성공
  • getAuthentication() == null
    • 이미 인증된 사용자가 아님
  • 이미 인증된 요청에 대해 다시 인증하지 않음
  • 필터 체인 중복 처리 방지

 

7. UserDetails 조회

  • DB에서 사용자 정보 조회
  • 계정 존재 여부 + 권한 정보 확보

 

8. 토큰 유효성 검증

  • 토큰 subject == DB의 username
  • 토큰 만료 여부
  • 토큰과 DB 정보 교차 검증

 

9. Authentication 객체 생성

  • principal : 인증된 사용자
  • credentials : null (JWT는 비밀번호 없음)
  • authorities : 권한 목록
  • 이 객체가 Spring Security가 인식하는 로그인 상태

 

10. 요청 정보 세팅

  • IP, Session ID 등 요청 메타 정보
  • 감사 로그, 보안 로그에 활용 가능

 

11. SecurityContext에 인증 정보 저장

  • 해당 요청이 인증된 사용자의 요청임을 나타냄

 

전체

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.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

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

        // Authorization 헤더 추출
        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        // Bearer 토큰 확인
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            try {
                username = jwtUtil.extractUsername(jwt);
            } catch (Exception e) {
                logger.error("JWT 토큰 파싱 오류: " + e.getMessage());
            }
        }

        // 토큰이 유효하고 SecurityContext에 인증 정보가 없는 경우
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            // 토큰 유효성 검증
            if (jwtUtil.validateToken(jwt, userDetails)) {
            	// Authentication 객체 생성
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                // 요청 정보 세팅
                authenticationToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                // SecurityContext에 인증 정보 저장
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }

}

 

Spring Security 설정 (SecurityConfig)

JWT 기반의 인증 및 인가를 적용하는 설정 파일

 

SecurityFilterChain

Spring Security의 전체 동작 규칙을 정의하는 핵심 메서드

 

1. CSRF 비활성화

  • JWT + REST API 구조에서는 세션을 안 쓰기 때문에 CSRF 불필요

 

2. CORS 설정

  • 프론트엔드와의 통신 허용
  • 아래에서 정의할 CorsConfigurationSource Bean 사용
  • 프론트엔드와 백엔드 도메인 분리 시 필수

 

3. 요청별 접근 제어 (인가)

  • 누구나 접근 가능한 경로와 아닌 경로 구분

 

4. 세션 관리 정책

  • 서버 세션 생성 안 함
  • 요청마다 JWT로 인증
  • JWT + Session 혼용 시 버그 발생하므로 Security가 세션을 생성하지 않도록 막음

 

5. AuthenticationProvider 등록

  • 로그인 요청 시 사용할 인증 전략
  • JWT 검증 X
  • ID + password 검증용

 

6. JWT 필터 등록

  • 기본 로그인 필터보다 JWT 필터를 먼저 실행
  • JWT 인증 실패 시 일반 로그인 로직으로 넘어감

 

AuthenticationProvider (로그인 담당)

아이디/비밀번호 기반 로그인 처리

@Bean
public AuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider =
            new DaoAuthenticationProvider(userDetailsService);
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
}
  1. UserDetailsService.loadUserByUsername
  2. DB 비밀번호 조회
  3. PasswordEncoder로 비교

 

AuthenticationManager

로그인 컨트롤러에서 사용

이를 통해 AuthenticationProvider가 호출됨

 

CORS 설정

허용할 프론트엔드 주소 설정

 

전체

import 프로젝트경로.security.JwtAuthenticationFilter;
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.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, CorsConfigurationSource corsConfigurationSource) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                // 요청별 접근 제어
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/test/**").permitAll()
                        .requestMatchers("/admin").hasAuthority("ADMIN")
                        .anyRequest().authenticated()
                )
                // 세션 관리 정책
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // 로그인 인증 담당
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

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

	// CORS 설정
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

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

}

 

전체 인증 흐름

로그인 (POST /로그인url)

  1. AuthenticationManager
  2. DaoAuthenticationProvider
  3. UserDetailsService
  4. 비밀번호 검증
  5. JWT 발급

 

일반요청 (Request)

  1. JwtAuthenticationFilter
  2. JWT 검증
  3. SecurityContext 인증
  4. Controller
저작자표시 (새창열림)

'SpringBoot' 카테고리의 다른 글

[SpringBoot] Flyway로 DB 마이그레이션 및 DDL 작성  (1) 2026.06.08
[SpringBoot] Swagger를 이용한 API 문서 자동화  (0) 2026.05.31
[AWS] SpringBoot + AWS EC2 Ubuntu 서버 생성 및 배포  (0) 2026.04.07
[AWS] RDS DB 인스턴스 생성(PostgreSQL) 및 SpringBoot 연동  (0) 2026.04.07
[스프링 부트] 어노테이션  (0) 2022.07.14
'SpringBoot' 카테고리의 다른 글
  • [SpringBoot] Swagger를 이용한 API 문서 자동화
  • [AWS] SpringBoot + AWS EC2 Ubuntu 서버 생성 및 배포
  • [AWS] RDS DB 인스턴스 생성(PostgreSQL) 및 SpringBoot 연동
  • [스프링 부트] 어노테이션
naahy
naahy
  • naahy
    종합장
    naahy
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • SpringBoot
      • Git
      • 트러블슈팅
      • Vue.js
      • Kotlin
      • Node.js
      • 코딩테스트
        • 백준
        • 프로그래머스
      • 스터디
        • CS
        • 알고리즘
      • -
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 인기 글

  • 태그

    SpringBoot
    mongodb
    Kotlin
    github
    투포인터
    이분탐색
    자바
    API
    브루트포스
    프로그래머스
    React
    node.js
    bfs
    코틀린
    백트래킹
    cs
    백준
    트러블슈팅
    Java
    Nexacro
  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
naahy
[SpringBoot] Spring Secutiry + JWT 기반 로그인 구현
상단으로

티스토리툴바