BlueNyang
[멀티캠퍼스] 풀스택 개발자 아카데미 (28) - Spring Boot & JWT(1)
Java 풀스택 아카데미
· 멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차 (28편)

[멀티캠퍼스] 풀스택 개발자 아카데미 (28) - Spring Boot & JWT(1)

BlueNyangBlueNyang
·
·
약 4분
·
# 부트캠프후기# 멀티캠퍼스it부트캠프# [현대이지웰] JAVA 풀스택 개발자 아카데미 6회차# jwt# stateless-authentication# filter-chain# spring-security
시리즈·멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차(30개의 글)
  • ···
  • 27.[멀티캠퍼스] 풀스택 개발자 아카데미 (27) - Spring Boot(3)
  • 28.[멀티캠퍼스] 풀스택 개발자 아카데미 (28) - Spring Boot & JWT(1)현재
  • 29.[멀티캠퍼스] 풀스택 개발자 아카데미 (29) - Spring Boot & JWT (2)
  • ···

지금까지 과정에서는 ReactJS와 SpringBoot의 기초적인 내용을 배웠다.

이번 글에서는 모바일 앱이나 SPA(Single Page Application) 프론트엔드와 통신할 때 필수적인 JWT(JSON Web Token) 인증을 Spring Boot 프로젝트에 구현하는 방법을 알아본다. 기존의 세션 기반 인증과 달리 서버의 확장성을 높여주는 JWT와 실제 코드 구현까지 자세히 다뤄본다.

1. Session의 한계

과거에는 서버가 클라이언트의 상태(State)를 세션 저장소에 저장하고, 세션 ID를 통해 사용자를 식별했다. 하지만 이 방식은 서버가 여러 대로 늘어나는 Scale-out 환경에서 세션 동기화 문제를 야기한다.

반면, JWTStateless(무상태) 방식을 지향한다. 서버는 더 이상 로그인 상태를 저장하지 않고, 클라이언트가 보낸 토큰(Token)의 유효성만 검증하면 된다.

세션 기반 인증 vs 토큰 기반 인증 아키텍처 비교 다이어그램
세션 기반 인증 vs 토큰 기반 인증 아키텍처 비교 다이어그램

2. JWT (JSON Web Token)

구현에 앞서 JWT의 구조를 살펴보자. JWT는 .(점)을 구분자로 하여 세 부분으로 나뉜다.

  • Header (헤더): 토큰의 타입(JWT)과 해싱 알고리즘(HS256, HS512 등) 정보
  • Payload (페이로드): 실제로 담길 데이터(Claim). 유저 ID, 유효기간, 권한 등이 포함
  • Signature (서명): 헤더와 페이로드를 합친 뒤 비밀키(Secret Key)로 서명한 값. 위변조를 방지
JWT 구조 분해 이미지
JWT 구조 분해 이미지

3. Spring Security와 JWT 동작 흐름

Spring Boot에서 이를 구현하기 위해 Spring Security Filter Chain에 커스텀 필터를 끼워 넣어야 한다. 전체적인 인증 흐름은 다음과 같다.

  1. 클라이언트가 로그인 요청 (ID/PW)
  2. 서버가 검증 후 JWT Access Token (및 Refresh Token) 발급
  3. 클라이언트는 이후 요청의 헤더(Authorization)에 토큰을 담아 보냄
  4. 서버의 JwtAuthenticationFilter가 요청을 가로채 토큰을 검증
  5. 검증 성공 시, Authentication 객체를 생성해 SecurityContext에 저장 (인증 완료 처리)
전체 인증 프로세스 플로우 차트
전체 인증 프로세스 플로우 차트

4. 본격 구현하기 (Spring Boot 3.x 기준)

4.1. 의존성 추가 (build.gradle)

먼저 build.gradle에 Spring Security와 JWT 관련 라이브러리(jjwt)를 추가한다.

이전에 사용했던 Spring Security가 함께 의존성에 있어야 한다.
gradle
dependencies {
    /* ... */
    implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
    implementation 'io.jsonwebtoken:jjwt-impl:0.13.0'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.13.0'
}

4.2. application.yml 설정

토큰 서명에 사용할 비밀키(Secret Key)를 설정한다. 이 키는 절대 외부에 유출되어서는 안 되며, Base64로 인코딩된 긴 문자열을 사용하는 것이 좋다.

yaml
jwt:
  secret: v3rYh4rdT0Gu3ssS3cr3tK3yB4s364Enc0d3dStr1ng...
  expiration: 86400000 # 1일

4.2.1. KeyGenerator

토큰 서명에 사용할 비밀키를 생성하는 것도 Java 코드로 작성해볼 수 있다.

java
package com.example.bluenyang.common.jwt.generator;

import io.jsonwebtoken.Jwts;

import java.util.Base64;

public class KeyGenerator {
    public static void main(String[] args) {
        var key = Jwts.SIG.HS512.key().build();
        var base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
        System.out.println("Generated Base64-encoded key: " + base64Key);
    }
}

해당 코드에는 main() 구문이 있어 단독적으로 실행이 가능하며, 실행 후 생성된 Base64 문자열을 사용할 수 있다.

코드를-생성한-뒤-콘솔-창-스크린샷
코드를-생성한-뒤-콘솔-창-스크린샷

4.3. JwtProperties

Properties 클래스를 사용하여 application.properties(application.yaml)로부터 값을 입력받을 수 있다.

4.3.1. Properties 클래스

java
package com.example.bluenyang.common.properties;

import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;

@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    @Getter
    private String secret;

    @Setter
    @Getter
    private long accessTokenValidityMs;

    @Setter
    @Getter
    private long refreshTokenValidityMs;

    @Getter
    private SecretKey secretKey;

    // Setter를 직접 작성하여 인코딩된 비밀 키를 SecretKey로 디코딩
    public void setSecret(String secret) {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
    }
}

4.3.2. application.properties

다음 2가지 properties 방식 중 하나를 선택하여 작성하면 된다.

properties
# ...
# main/resources/application.properties
jwt.secret=0z4awKkBAxxnuoymWMO99ID0XeO5BOzgJE6TlLfqLgMFjH5l1Oy+S0+AbgZeRH9BXvOUZ6QkWWNtXG1MdTBsJA==
# 30분
jwt.access-token-validity-ms=1800000
# 7일
jwt.refresh-token-validity-ms=604800000
yaml
# main/resources/application.yaml
# ...
jwt:
  secret: 0z4awKkBAxxnuoymWMO99ID0XeO5BOzgJE6TlLfqLgMFjH5l1Oy+S0+AbgZeRH9BXvOUZ6QkWWNtXG1MdTBsJA==
  access-token-validity-ms: 1800000 # 30분
  refresh-token-validity-ms: 604800000 # 7일

4.4. JwtTokenProvider 구현

토큰을 생성하고, 유효성을 검증하며, 토큰에서 회원 정보를 추출하는 핵심 유틸리티 클래스를 작성한다.

java
package com.example.bluenyang.common.jwt.provider;

@Slf4j
@Component
public class JwtTokenProvider {
    private final SecretKey secretKey;
    private final long accessTokenValidityMs;

    // 생성자. Properties를 통해 비밀 키와 토큰 유효 기간을 주입받음
    public JwtTokenProvider(JwtProperties jwtProperties) {
        this.secretKey = jwtProperties.getSecretKey();
        this.accessTokenValidityMs = jwtProperties.getAccessTokenValidityMs();
    }

    // Authentication 객체에서 사용자 정보와 권한을 추출하여 액세스 토큰 생성
    public String createAccessToken(Authentication authentication) {
        // e.g., "ROLE_USER,ROLE_ADMIN"
        String authorities = authentication
                .getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return createToken(authentication.getName(), authorities);
    }

    // 토큰에서 인증 정보를 추출하여 Authentication 객체 생성
    public Authentication getAuthentication(String token) {
        // 토큰에서 클레임(Claims) 추출
        var claims = Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();

        // 권한 정보 파싱
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .toList();
        // 사용자 정보와 권한으로 UserDetails 객체 생성
        // principal(사용자 정보)에는 비밀번호를 넣지 않음
        var principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
    }

    // 토큰의 유효성을 검사하는 메서드
    public boolean validateToken(String token) {
        try {
            // 디버깅 시에만 로그 출력
            log.debug("JwtTokenProvider - validateToken");
            // 토큰 파싱 시 예외가 발생하지 않으면 유효한 토큰
            Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (MalformedJwtException e) {
            // JWT 토큰 형식이 잘못된 경우
            log.debug("JwtTokenProvider.getAuthentication - Invalid JWT token format: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            // JWT 토큰이 만료된 경우
            log.debug("JwtTokenProvider.getAuthentication - Expired JWT token: {}", e.getMessage());
        } catch (SecurityException e) {
            // JWT 서명이 잘못된 경우
            log.debug("JwtTokenProvider.getAuthentication - Invalid JWT signature: {}", e.getMessage());
        } catch (Exception e) {
            // 그 외 기타 예외
            log.debug("JwtTokenProvider.getAuthentication - JWT token validation error: {}", e.getMessage());
        }
        return false;
    }

    // 실제 토큰 생성 로직
    // 향후 OAuth2 인증 등 다양한 인증 방식에 대응하기 위해 헬퍼 메서드로 분리
    private String createToken(String subject, String role) {
        var now = new Date();
        var expiryDate = new Date(now.getTime() + accessTokenValidityMs);

        return Jwts.builder()
                .subject(subject)
                .claim("auth", role)
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(secretKey)
                .compact();
    }
}
IntelliJ IDEA에서 작성된 JwtTokenProvider 클래스의 핵심 메서드 동작
IntelliJ IDEA에서 작성된 JwtTokenProvider 클래스의 핵심 메서드 동작

4.5. JwtAuthenticationFilter 작성

이제 모든 요청의 앞단에서 헤더를 검사할 필터를 만든다. OncePerRequestFilter를 상속받아 구현하며, 유효한 토큰이 발견되면 SecurityContextHolder에 인증 정보를 심어준다.

java
package com.example.bluenyang.common.jwt.filter;

import com.example.bluenyang.common.jwt.provider.JwtTokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    // JwtTokenProvider 주입
    private final JwtTokenProvider jwtTokenProvider;

    // 필터 로직 구현
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 요청 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰 유효성 검사 및 SecurityContext에 인증 정보 저장
        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 인증 정보를 SecurityContext에 저장
            var authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            // 디버깅 로그
            log.debug("Saved Authentication to Security Context: {}", authentication);
        }

        filterChain.doFilter(request, response);
    }

    // 요청 헤더에서 'Bearer '로 시작하는 토큰 문자열을 추출하는 헬퍼 메서드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        // 토큰이 없거나 형식이 잘못된 경우 null 반환
        return null;
    }
}
JWT 토큰 문자열에 Bearer 접두어가 붙는 이유는 OAuth 2.0 표준에서 권장하는 방식이기 때문이다. 이는 토큰의 유형을 명확히 구분하기 위함이며, 이는 W3C와 IETF에서 정의한 HTTP 표준에 따른 것이다. Bearer는 지참인이라는 의미로, 이 토큰을 소지한 자가 해당 리소스에 접근할 수 있음을 나타낸다.
이는 보안 수준이 높거나 낮은 다른 방식과 구분할 수 있는데, 예를 들어 Basic 인증 방식은 사용자 이름과 비밀번호를 인코딩하여 전송하고, PoP(Proof of Possession) 토큰 방식은 토큰 소유권을 증명하는 추가적인 메커니즘을 포함한다. 반면, Bearer 토큰은 단순히 토큰 자체만으로 접근 권한을 부여하므로, HTTPS와 같은 보안 채널을 통해 전송되어야 한다.
HttpServletRequest 헤더에서 'Bearer '로 시작하는 토큰 문자열을 파싱하고 검증에 성공하면 SecurityContext에 저장하는 필터 로직의 흐름을 도식화한 그림
HttpServletRequest 헤더에서 'Bearer '로 시작하는 토큰 문자열을 파싱하고 검증에 성공하면 SecurityContext에 저장하는 필터 로직의 흐름을 도식화한 그림

4.6. SecurityConfig 설정

Spring Security 6.0(Spring Boot 3.0)부터는 설정 방식이 SecurityFilterChain Bean을 등록하는 방식으로 변경되었다. 여기서 중요한 점은 세션 정책을 Stateless로 설정하고, 우리가 만든 필터를 UsernamePasswordAuthenticationFilter 앞에 배치하는 것이다.

java
package com.example.bluenyang.common.config;

import com.example.bluenyang.common.jwt.filter.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter authenticationFilter;

    /* ... */

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(FormLoginConfigurer::disable)
                .httpBasic(HttpBasicConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/api/*/auth/login",
                                "/api/*/auth/login-refresh",
                                "/api/*/auth/join",
                                "/api/*/auth/refresh",
                                "/api/*/main/**",
                        ) // 로그인, 회원가입, 메인 등 인증이 필요 없는 경로는 모두 허용. 없으면 로그인도 못함
                        .permitAll()
                        .anyRequest() // 모든 요청에 대해
                        .authenticated()) // 인증 필요
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint((request,
                                                   response,
                                                   authException) -> {

                            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                            response.setContentType("application/json;charset=UTF-8");
                            response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"" + authException.getMessage() + "\"}");
                        })
                )
                .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

4.7. Auth 기능 구현

마지막으로 로그인 API를 구현하여 JWT 토큰을 발급하는 로직을 작성한다.

  • DTO 클래스: 로그인 요청과 응답을 위한 DTO
java
package com.example.bluenyang.api.auth.dto;

public record LoginRequest(String email, String password) {
}
  • LoginResponse DTO: 로그인 응답에 토큰과 사용자 정보를 담는 DTO
java
package com.example.bluenyang.api.auth.dto;

import com.example.bluenyang.api.user.entity.Users;

public record LoginResponse(String accessToken) {
}
  • Controller Layer: 로그인 요청을 받아 서비스에 위임
java
package com.example.bluenyang.api.auth.controller;

import com.example.bluenyang.api.auth.dto.LoginRequest;
import com.example.bluenyang.api.auth.dto.LoginResponse;
import com.example.bluenyang.api.auth.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class AuthController {
    private final AuthService authService;

    @PostMapping("/v1/auth/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest dto) {

        return ResponseEntity.ok(authService.login(dto));
    }
}
  • Service Layer: 실제 인증 로직과 토큰 발급을 담당
java
package com.example.bluenyang.api.auth.service;

import com.example.bluenyang.common.jwt.provider.JwtTokenProvider;
import com.example.bluenyang.api.auth.dto.LoginRequest;
import com.example.bluenyang.api.auth.dto.LoginResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;

    public LoginResponse login(LoginRequest dto) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(dto.email(), dto.password())
        );

        return new LoginResponse(
                jwtTokenProvider.createAccessToken(authentication)
        );
    }
}

5. 테스트 및 결과

모든 설정이 끝났으니 Postman으로 테스트를 해본다.

  1. 로그인 요청: /api/auth/login으로 ID/PW를 보낸다.
  2. 토큰 발급: 응답으로 accessToken이 정상적으로 반환된다.
Postman을 통해 로그인 API에 POST 요청을 보내고, 하단 응답 Body에 JSON 형태로 Access Token이 반환된 성공 화면
Postman을 통해 로그인 API에 POST 요청을 보내고, 하단 응답 Body에 JSON 형태로 Access Token이 반환된 성공 화면
  1. 인증이 필요한 요청: 이제 발급받은 토큰을 헤더(Authorization: Bearer {Token})에 넣고, 회원 전용 API(/api/members/me)를 호출해 본다. 정상적으로 내 정보를 불러온다면 성공이다.
Postman에서 Authorization 탭을 선택하고 Type을 Bearer Token으로 설정한 뒤, 토큰을 입력하여 보호된 리소스를 요청해 200 OK 응답을 받은 화면
Postman에서 Authorization 탭을 선택하고 Type을 Bearer Token으로 설정한 뒤, 토큰을 입력하여 보호된 리소스를 요청해 200 OK 응답을 받은 화면

6. 마무리

이렇게 Spring Boot와 Spring Security를 활용해 JWT 인증 시스템을 구축해 보았다. 처음에는 설정 코드가 복잡해 보일 수 있지만, 한 번 구축해 두면 프론트엔드와의 결합도를 낮추고 서버 확장에 유연한 강력한 백엔드를 가질 수 있다.

다음 글에서는 JWT 인증을 더욱 견고하게 만들기 위한 Refresh Token 관리와, 이 JWT 인증을 ReactJS 프론트엔드와 연동하는 방법에 대해 다뤄볼 예정이다.

BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus