Recent Posts
Recent Comments
Link
07-23 22:14
Today
Total
관리 메뉴

삶 가운데 남긴 기록 AACII.TISTORY.COM

스프링 부트 JWT 인증 구현 가이드 2편: 인증 필터와 보호 API 접근 본문

DEV&OPS/Java

스프링 부트 JWT 인증 구현 가이드 2편: 인증 필터와 보호 API 접근

ALEPH.GEM 2026. 7. 23. 17:36
728x90

1편에서는 이메일과 비밀번호를 검증한 뒤 JWT 액세스 토큰을 발급했습니다.

https://aacii.tistory.com/502

 

스프링 부트 JWT 인증 구현 가이드 1편: 로그인과 액세스 토큰 발급

스프링 시큐리티스프링 부트 REST API에서 사용할 JWT 인증을 구현해보겠습니다.먼저 로그인 요청을 검증하고, 인증된 사용자를 식별할 수 있는 액세스 토큰을 발급해야 합니다.Spring Security가 아이

blog.aacii.net

그러나 토큰을 발급하는 것만으로는 인증이 완성되지 않습니다.

클라이언트가 API를 호출할 때 전달한 JWT를 서버가 읽고 검증한 다음, Spring Security와 호환이 되는 인증정보로 변환해야 합니다.

1. 클라이언트가 Authorization 헤더에 JWT 전달
2. JWT 인증 필터가 Bearer Token 추출
3. 서명과 만료 시각 검증
4. 토큰에서 사용자 식별값 확인
5. 사용자 정보와 권한 조회
6. Authentication 객체 생성
7. SecurityContext에 인증정보 등록
8. 보호된 API 접근 허용

Spring Security의 인증 구조에서는 인증된 Authentication 객체가 SecurityContextHolder에 저장됩니다.

이후 인가 과정에서 현재 사용자의 인증 여부와 권한을 판단합니다.

 

1. 클라이언트(브라우저)가 JWT를 전달하는 내용

일반적인 Bearer Token 요청에서는 다음과 같이 Authorization 헤더를 사용합니다.

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Bearer 뒤에는 공백 한 칸과 액세스 토큰이 들어갑니다.

Bearer + 공백 + JWT

서버는 다음 예외적인 상황을 각각 처리해야 합니다.

  • Authorization 헤더가 없음
  • Bearer 접두어가 없음
  • 토큰 값이 비어 있음
  • 서명이 잘못됨
  • 토큰이 만료됨
  • 토큰 형식이 잘못됨
  • 정상적인 토큰임

이번 예제에서는 토큰이 없는 요청은 익명 요청으로 그대로 통과시켜서 Spring Security가 판단하도록 넘깁니다.

최종 접근 허용 여부는 Spring Security의 인가 설정이 판단해서 필터링합니다.

반면 헤더에 토큰이 들어왔지만 토큰이 변조되거나 만료됐다면 인증 실패로 처리해야 합니다.

 

2. JWT 검증 기능 추가하기

1편에서 만든 JwtTokenProvider에 검증과 사용자 식별값 조회 기능을 추가합니다.

package com.example.jwt.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.time.Instant;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long accessTokenExpiration;

    public JwtTokenProvider(JwtProperties properties) {
        byte[] keyBytes = Decoders.BASE64.decode(properties.secret());
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenExpiration = properties.accessTokenExpiration();
    }

    public String createAccessToken(String email) {
        Instant now = Instant.now();
        Instant expiration = now.plusMillis(accessTokenExpiration);

        return Jwts.builder()
            .subject(email)
            .issuedAt(Date.from(now))
            .expiration(Date.from(expiration))
            .signWith(secretKey)
            .compact();
    }

    public String getSubject(String token) {
        return parseClaims(token).getSubject();
    }
    
    public boolean isValid(String token) {
        parseClaims(token);
        return true;
    }

    //JWT토큰을 각 부분별로 파싱(분해)해서 검증 한뒤 Claims 객체를 만들어 리턴합니다.
    //JWT가 잘못되었다면 여기서 예외가 발생합니다.
    private Claims parseClaims(String token) {
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

parseSignedClaims()는 단순히 Payload 문자열만 읽는 메서드가 아닙니다.

설정한 키를 이용해 토큰 서명을 검증하고, 유효한 서명 JWT의 Claims를 반환합니다.

토큰이 만료되거나 형식이 잘못됐거나 서명이 일치하지 않으면 예외가 발생합니다.

다음과 같이 Base64 문자열을 직접 분리해서 Payload만 읽고 인증에 사용하면 안 됩니다.

// 인증 검증 용도로 사용하면 안 되는 예시
String[] parts = token.split("\\.");
String payload = new String(
    java.util.Base64.getUrlDecoder().decode(parts[1])
);

Payload 디코딩만으로는 토큰이 신뢰할 수 있는 발급자에게서 왔는지, 중간에 변조되지 않았는지 확인할 수 없기 때문입니다.

 

3. JWT 예외 종류 이해하기

JJWT는 토큰 상태에 따라 여러 예외를 발생시킬 수 있습니다.

대표적인 예는 다음과 같습니다.

import io.jsonwebtoken.ExpiredJwtException; //토큰 만료
import io.jsonwebtoken.JwtException; //JWT 관련 예외의 부모 예외
import io.jsonwebtoken.MalformedJwtException; //토큰 구조가 잘못됨
import io.jsonwebtoken.security.SecurityException; //서명 검증 실패, 보안 오류

모든 JWT 관련 예외를 필터에서 인증 실패로 처리하되, 내부 로그에는 원인을 구분해 남겨도 되지만, 외부 응답에는 라이브러리 예외 메시지를 그대로 노출하지 않는 편이 좋습니다.

 

4. 요청에서 Bearer Token 추출하기

토큰 추출 책임을 별도 메서드로 분리하면 필터 코드가 읽기 쉬워집니다.

package com.example.jwt.security;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
public class BearerTokenResolver {

    //여기서는 Bearer 토큰만 사용합니다.
    private static final String BEARER_PREFIX = "Bearer ";

    public String resolve(HttpServletRequest request) {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (!StringUtils.hasText(authorization)) {
            return null;
        }

        if (!authorization.startsWith(BEARER_PREFIX)) {
            return null;
        }

        String token = authorization.substring(BEARER_PREFIX.length());

        return StringUtils.hasText(token) ? token : null;
    }
}

헤더가 없는 것과 잘못된 형식의 헤더를 엄격히 구분할지는 API 정책에 따라 달라질 수 있습니다.

여기서는 정상적인 Bearer Token이 아니면 토큰이 없는 것으로 처리합니다.

운영 환경에서는 Basic, 오타 난 접두어, 여러 Authorization 헤더에 대한 정책을 명확히 정하고 처리해야 합니다.

 

5. JWT 인증 필터 구현하기

Spring Security 필터 체인에서 요청당 한 번 실행할 필터는 OncePerRequestFilter를 상속해 구현할 수 있습니다.

package com.example.jwt.security;

import com.example.jwt.member.CustomUserDetailsService;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final BearerTokenResolver bearerTokenResolver;
    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(
        BearerTokenResolver bearerTokenResolver,
        JwtTokenProvider jwtTokenProvider,
        CustomUserDetailsService userDetailsService
    ) {
        this.bearerTokenResolver = bearerTokenResolver;
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        //토큰을 추출합니다.
        String token = bearerTokenResolver.resolve(request);

        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }
        //토큰을 검증합니다.
        try {
            authenticate(token, request);
            filterChain.doFilter(request, response);
        } catch (JwtException | IllegalArgumentException exception) {
            SecurityContextHolder.clearContext();
            response.sendError(
                HttpServletResponse.SC_UNAUTHORIZED,
                "유효하지 않은 액세스 토큰입니다."
            );
        }
    }
    //사용자를 조회합니다. (Email 아이디 기준)
    private void authenticate(
        String token,
        HttpServletRequest request
    ) {
        jwtTokenProvider.isValid(token);

        String email = jwtTokenProvider.getSubject(token);
        UserDetails userDetails =
            userDetailsService.loadUserByUsername(email);

        var authentication =
            UsernamePasswordAuthenticationToken.authenticated(
                userDetails,
                null,
                userDetails.getAuthorities()
            );

        authentication.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request)
        );
        //스프링 시큐리티의 컨텍스트에 인증 정보를 저장합니다.
        SecurityContextHolder.getContext()
            .setAuthentication(authentication);
    }
}

필터에서 하는 일은 네 단계로 정리할 수 있습니다.

토큰 추출
→ 토큰 검증
→ 사용자 조회
→ SecurityContext에 Authentication 저장

인증된 Authentication 객체에는 다음 정보가 들어갑니다.

  • Principal: 현재 사용자 정보
  • Credentials: 인증 후에는 일반적으로 null
  • Authorities: 사용자의 권한
  • Details: IP, 세션 ID 같은 요청 관련 부가정보

 

왜 비밀번호를 다시 확인하지 않을까?

JWT 인증 요청에서는 로그인 비밀번호를 다시 받지 않습니다.

대신 서버는 JWT의 서명을 검증해 자신이 발급한 토큰인지 확인합니다.

그 후 토큰의 사용자 식별값으로 회원을 조회하고 현재 권한을 가져옵니다.

토큰을 검증한 뒤 매 요청마다 데이터베이스를 조회할지는 설계 선택입니다.

이 예제는 입문자가 회원 상태와 권한 변경을 이해하기 쉽도록 매 요청마다 사용자를 조회합니다.

트래픽이 큰 시스템에서는 토큰에 권한을 포함하거나 캐시를 사용하거나 인증 서버와 자원 서버를 분리하는 방식을 검토할 수 있습니다.

 

6. 필터를 SecurityFilterChain에 등록하기

JWT 필터를 UsernamePasswordAuthenticationFilter보다 앞에 추가합니다.

package com.example.jwt.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/auth/login",
                    "/api/public/**"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }

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

Spring Security에서는 SecurityFilterChain을 통해 요청별 보안 필터와 접근 정책을 구성합니다.

요청 권한은 authorizeHttpRequests에서 경로별로 설정할 수 있습니다.

설정 순서도 중요합니다.

.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated()

구체적인 경로를 먼저 선언해 필터링하고 마지막에 나머지 요청 정책을 설정하는 편이 명확합니다.

 

7. 공개 API와 보호 API 만들기

공개용 샘플 API를 작성합니다.

package com.example.jwt.api;

import java.util.Map;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/public")
public class PublicController {

    @GetMapping("/hello")
    public Map<String, String> hello() {
        return Map.of("message", "누구나 접근할 수 있습니다.");
    }
}

보호된 샘플 API도 작성합니다.

package com.example.jwt.api;

import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/members")
public class MemberController {

    @GetMapping("/me")
    public Map<String, String> me(@AuthenticationPrincipal UserDetails userDetails) {
        return Map.of(
            "email", userDetails.getUsername(),
            "message", "인증된 사용자입니다."
        );
    }

    @GetMapping("/authentication")
    public Map<String, Object> authentication(Authentication authentication) {
        return Map.of(
            "name", authentication.getName(),
            "authorities", authentication.getAuthorities()
        );
    }
}

필터를 통과한 뒤 현재 사용자는 컨트롤러에서 여러 방식으로 받을 수 있습니다.

  • Authentication
  • Principal
  • @AuthenticationPrincipal
  • SecurityContextHolder

컨트롤러에서는 @AuthenticationPrincipal이나 Authentication 매개변수를 사용하는 편이 테스트와 가독성 측면에서 편리합니다.

 

8. 공개 API 테스트하기

GET방식으로 토큰 없이 공개 API를 호출합니다.

/api/public/hello

그러면 응답은 다음과 같이 오게 됩니다.

{
  "message": "누구나 접근할 수 있습니다."
}

permitAll()로 설정했기 때문에 JWT가 없어도 접근할 수 있습니다.

 

9. 보호 API를 토큰 없이 호출하기

역시 GET방식으로 보호된 API를 호출해 봅니다.

/api/members/me

인증정보가 없으므로 접근이 거부되어야 합니다.

환경과 기본 설정에 따라 응답 본문 형식은 달라질 수 있지만 HTTP 상태는 일반적으로 401 Unauthorized가 되어야 합니다.

다음 3편에서는 이 응답을 아래처럼 JSON 구조로 변경하겠습니다.

{
  "code": "AUTHENTICATION_REQUIRED",
  "message": "인증이 필요합니다."
}

 

10. 유효한 토큰으로 보호 API 호출하기

먼저 /api/auth/login 경로를 POST 방식으로 호출해서 로그인합니다.

Content-Type: application/json으로 호출합니다.

{
  "email": "user@example.com",
  "password": "test1234!"
}

응답에서 받은 액세스 토큰을 GET 방식으로 /api/members/me 요청 헤더에 넣습니다.

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

예상 응답입니다.

{
  "email": "user@example.com",
  "message": "인증된 사용자입니다."
}

이 요청에서는 다음 과정이 실행됩니다.

JwtAuthenticationFilter
→ Authorization 헤더 확인
→ 서명과 만료 시각 검증
→ subject에서 이메일 조회
→ UserDetailsService로 사용자 조회
→ Authentication 생성
→ SecurityContext 등록
→ MemberController 실행

 

11. 변조된 토큰으로 테스트하기

JWT 문자열의 마지막 문자 하나를 변경해 GET 방식으로  /api/members/me 요청합니다.

Authorization: Bearer {마지막 문자를 변경한 토큰}

서명과 토큰 내용이 일치하지 않으므로 인증에 실패해야 합니다.

JWT의 Payload를 수정한 다음 다시 Base64 URL 인코딩하더라도 올바른 서명을 만들 수 없다면 인증에 사용할 수 없습니다.

이것이 JWT 인증에서 서명 검증을 생략하면 안 되는 이유입니다.

 

12. 만료된 토큰 테스트하기

테스트를 위해 YAML 설정 파일에서 만료 시간을 짧게 설정할 수 있습니다.

jwt:
  access-token-expiration: 5000

로그인 후 5초 이상 지난 다음 보호 API를 GET 방식으로 /api/members/me으로  호출합니다.

Authorization: Bearer {만료된 토큰}

ExpiredJwtException이 발생하고 401 Unauthorized 응답을 받아야 합니다.

테스트가 끝난 뒤 실제 정책에 맞는 만료 시간으로 되돌려야 합니다.

 

13. 필터에서 자주 발생하는 실수

토큰이 없을 때 무조건 오류를 반환한다

공개 API에도 같은 JWT 필터가 적용될 수 있습니다.

토큰이 없는 요청은 필터 체인을 계속 진행시키고, 해당 경로가 인증을 요구하는지는 인가 설정에서 판단하도록 구성하는 편이 역할 분리에 적합합니다.

예외가 발생했는데 SecurityContext를 정리하지 않는다

잘못된 토큰을 처리할 때는 다음 코드를 통해 기존 인증정보가 남지 않도록 정리해야 합니다.

SecurityContextHolder.clearContext();

토큰 서명은 확인하지 않고 subject만 읽는다

JWT Payload 디코딩과 JWT 검증은 다른 작업입니다.

인증에 사용하려면 서명과 만료 시각을 반드시 검증해야 합니다.

필터에서 비밀번호까지 다시 검증한다

액세스 토큰 요청에는 비밀번호가 포함되지 않습니다.

로그인 시 비밀번호를 검증하고, 이후 요청에서는 토큰을 검증합니다.

JWT 전체를 운영 로그에 남긴다

로그 파일 접근 권한이 탈취되면 기록된 액세스 토큰이 재사용될 수 있습니다.

디버깅 목적으로도 토큰 전체를 출력하지 않는 편이 안전합니다.

모든 경로를 permitAll로 설정한다

필터가 인증정보를 만드는 것과 URL 접근을 제한하는 것은 다른 역할입니다.

.anyRequest().permitAll()

로 설정하면 JWT가 없어도 모든 요청이 허용될 수 있습니다.

 

14. Spring Security의 Resource Server 방식도 알아두기

Spring Security는 OAuth 2.0 Resource Server 기능을 통해 JWT Bearer Token으로 API를 보호하는 기능을 공식 지원합니다. 외부 인증 서버가 발급한 JWT뿐 아니라 커스텀 JWT를 사용하는 구성에도 Resource Server 지원을 적용할 수 있습니다.

여기서는 필터와 SecurityContext의 관계를 이해할 수 있도록 커스텀 필터를 직접 구현했습니다.

다만 실무 프로젝트에서는 다음 조건을 검토해야 합니다.

  • 별도 인증 서버가 있는가?
  • 공개키와 개인키 방식으로 서명을 검증하는가?
  • 여러 API 서버가 동일한 토큰을 검증하는가?
  • 표준화된 Bearer Token 오류 처리가 필요한가?
  • Spring Security의 JwtDecoder를 활용할 수 있는가?

이 조건에 해당하면 직접 만든 필터보다 Resource Server 구성을 우선 검토할 가치가 있습니다.

 

자주 묻는 질문

JWT 필터는 모든 요청에서 실행되나요?

등록한 SecurityFilterChain에 포함되는 요청에서 실행됩니다.

공개 경로에서도 실행될 수 있으므로 토큰이 없는 요청을 적절히 통과시켜야 합니다.

인증 필터에서 매번 데이터베이스를 조회해야 하나요?

필수는 아니고 토큰에 권한과 상태 판단 정보를 포함할 수도 있습니다.

다만 회원 탈퇴, 정지, 권한 변경을 즉시 반영하려면 데이터베이스나 캐시 조회가 필요할 수 있습니다.

Authorization 헤더의 Bearer는 대소문자를 구분하나요?

일관된 API 계약을 위해 표준적인 Bearer 형식을 사용해야 합니다.

서버에서 어떤 변형까지 허용할지는 명확하게 정해야 합니다.

만료된 JWT에서 사용자 정보를 읽어도 되나요?

재발급 과정에서 제한적으로 만료 토큰 정보를 참고하는 설계도 있지만, 만료된 액세스 토큰을 정상 인증정보로 등록해서는 안 됩니다.

필터에서 발생한 예외는 ControllerAdvice가 처리하나요?

서블릿 필터에서 컨트롤러에 도달하기 전에 발생한 예외는 일반적인 @RestControllerAdvice만으로 처리되지 않을 수 있습니다. 필터 내부 또는 Spring Security 전용 실패 처리 지점에서 응답을 작성해야 합니다.

 

내용 정리

 

Authorization 헤더
→ Bearer Token 추출
→ JWT 서명·만료 검증
→ 사용자 조회
→ Authentication 생성
→ SecurityContext 등록
→ 보호 API 접근

주요 클래스의 역할은 다음과 같습니다.

  • BearerTokenResolver: 요청 헤더에서 토큰 추출
  • JwtTokenProvider: 토큰 검증과 subject 조회
  • JwtAuthenticationFilter: JWT를 Spring Security 인증정보로 변환
  • SecurityConfig: 필터 등록과 경로별 접근 제어
  • MemberController: 현재 인증 사용자 확인

다음 3편에서는 401과 403을 구분한 JSON 오류 응답, USER와 ADMIN 권한 제어, 리프레시 토큰, 로그아웃과 운영 환경 보안 점검을 다룹니다.

https://aacii.tistory.com/504

 

스프링 부트 JWT 인증 구현 가이드 3편: 예외 처리, 권한 설정과 운영 보안

JWT 인증은 정상 토큰으로 API 호출에 성공했다고 끝나지 않습니다.실무에서는 인증정보가 없는 요청과 권한이 부족한 요청을 구분하고, 만료되거나 변조된 토큰에 일관된 오류를 반환해야 합니

blog.aacii.net

 

 

 

 

 

아 망했어요.

728x90