Recent Posts
Recent Comments
Link
07-23 21:59
Today
Total
관리 메뉴

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

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

DEV&OPS/Java

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

ALEPH.GEM 2026. 7. 23. 19:52
728x90

https://aacii.tistory.com/503

 

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

1편에서는 이메일과 비밀번호를 검증한 뒤 JWT 액세스 토큰을 발급했습니다.https://aacii.tistory.com/502 스프링 부트 JWT 인증 구현 가이드 1편: 로그인과 액세스 토큰 발급스프링 시큐리티스프링 부트

blog.aacii.net

 

JWT 인증은 정상 토큰으로 API 호출에 성공했다고 끝나지 않습니다.

실무에서는 인증정보가 없는 요청과 권한이 부족한 요청을 구분하고, 만료되거나 변조된 토큰에 일관된 오류를 반환해야 합니다.

액세스 토큰 만료 시간을 짧게 운영한다면 리프레시 토큰 정책도 필요합니다.

다음 내용을 구현하고 점검해보겠습니다.

1. 401과 403 오류 구분
2. 일관된 JSON 오류 응답
3. USER와 ADMIN 권한 설정
4. 메서드 단위 권한 검사
5. 리프레시 토큰 기본 구조
6. 로그아웃과 토큰 폐기 전략
7. 운영 환경 JWT 보안 점검

 

1. 401과 403은 무엇이 다른가?

401 Unauthorized

현재 요청을 인증할 수 없을 때 사용합니다.

  • 액세스 토큰이 없음
  • 토큰이 만료됨
  • 토큰 서명이 잘못됨
  • 토큰 형식이 잘못됨
  • 토큰에 해당하는 사용자를 찾을 수 없음

403 Forbidden

사용자가 인증됐지만 해당 작업에 필요한 권한이 없을 때 사용합니다.

  • USER 권한으로 ADMIN API 호출
  • 다른 사용자의 관리자 전용 데이터를 수정
  • 필요한 역할이나 권한이 없음

 

401 → 로그인 화면 이동 또는 토큰 재발급 검토
403 → 현재 계정에는 해당 작업 권한이 없다고 안내

단, 401을 받았다고 무조건 토큰을 자동 재발급하면 안 됩니다.

변조된 토큰, 폐기된 토큰, 잘못된 토큰도 401이 될 수 있기 때문입니다.

 

2. 공통 오류 응답 만들기

API 오류 응답 형식을 먼저 정의합니다.

package com.example.jwt.error;

import java.time.Instant;

public record ErrorResponse(String code, String message, String path, Instant timestamp) {
    public static ErrorResponse of(String code, String message, String path) {
        return new ErrorResponse(code, message, path, Instant.now());
    }
}

예상 응답은 다음과 같습니다.

{
  "code": "AUTHENTICATION_REQUIRED",
  "message": "인증이 필요합니다.",
  "path": "/api/members/me",
  "timestamp": "2026-07-17T10:00:00Z"
}

오류 코드와 메시지를 분리하면 클라이언트는 code를 기준으로 분기하고, 화면에는 사용자 친화적인 메시지를 표시할 수 있습니다.

 

3. 토큰이 없는 요청에 401 반환

Spring Security에서 인증되지 않은 사용자가 보호된 자원에 접근하면 AuthenticationEntryPoint가 호출됩니다.

package com.example.jwt.security;

import com.example.jwt.error.ErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
public class JwtAuthenticationEntryPoint
    implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException authException
    ) throws IOException {

        ErrorResponse body = ErrorResponse.of(
            "AUTHENTICATION_REQUIRED",
            "인증이 필요합니다.",
            request.getRequestURI()
        );

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        objectMapper.writeValue(response.getWriter(), body);
    }
}

이 클래스는 주로 토큰 없이 보호 API에 접근했을 때 사용됩니다.

 

4. 권한 부족 요청에 403 반환

인증은 됐지만 권한이 부족하면 AccessDeniedHandler가 호출됩니다.

package com.example.jwt.security;

import com.example.jwt.error.ErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public JwtAccessDeniedHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(
        HttpServletRequest request,
        HttpServletResponse response,
        AccessDeniedException accessDeniedException
    ) throws IOException {

        ErrorResponse body = ErrorResponse.of(
            "ACCESS_DENIED",
            "해당 요청을 처리할 권한이 없습니다.",
            request.getRequestURI()
        );

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        objectMapper.writeValue(response.getWriter(), body);
    }
}

 

5. JWT 필터 오류를 JSON으로 반환하기

이전 2편에서는 JWT 검증 실패 시 sendError()를 사용했습니다.

이번에는 별도 핸들러를 만들어 오류 응답 형식을 JSON으로 통일합니다.

package com.example.jwt.security;

import com.example.jwt.error.ErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;

@Component
public class JwtFailureHandler {

    private final ObjectMapper objectMapper;

    public JwtFailureHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public void writeInvalidTokenResponse(
        HttpServletRequest request,
        HttpServletResponse response
    ) throws IOException {

        ErrorResponse body = ErrorResponse.of(
            "INVALID_ACCESS_TOKEN",
            "액세스 토큰이 유효하지 않습니다.",
            request.getRequestURI()
        );

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        objectMapper.writeValue(response.getWriter(), body);
    }
}

필터도 수정합니다.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

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

    @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 | llegalArgumentException | UsernameNotFoundException exception) {
            SecurityContextHolder.clearContext();
            jwtFailureHandler.writeInvalidTokenResponse(
                request,
                response
            );
        }
    }

    private void authenticate(String token, HttpServletRequest request) {
        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);
    }
}

getSubject() 내부에서 토큰 파싱과 검증이 이미 수행되므로, 별도의 isValid() 호출은 제거했습니다.

같은 토큰을 두 번 파싱하지 않도록 한 것입니다.

 

6. 예외 처리기를 Security 설정에 연결하기

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.method.configuration.EnableMethodSecurity;
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
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint authenticationEntryPoint;
    private final JwtAccessDeniedHandler accessDeniedHandler;

    public SecurityConfig(
        JwtAuthenticationFilter jwtAuthenticationFilter,
        JwtAuthenticationEntryPoint authenticationEntryPoint,
        JwtAccessDeniedHandler accessDeniedHandler
    ) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
    }

    @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/auth/refresh",
                    "/api/public/**"
                ).permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
            )
            .addFilterBefore(
                jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }

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

요청 경로 기반 인가는 authorizeHttpRequests에서 구성할 수 있습니다.

Spring Security는 요청 수준의 접근 정책과 메서드 수준의 접근 정책을 모두 지원합니다.

 

7. 관리자 전용 API 만들기

package com.example.jwt.api;

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

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/dashboard")
    public Map<String, String> dashboard() {
        return Map.of(
            "message",
            "관리자만 접근할 수 있습니다."
        );
    }
}

USER 권한 토큰으로 호출하면 403 Forbidden이 반환되어야 합니다.

GET 방식으로 호출: /api/admin/dashboard

Authorization: Bearer {USER 권한 사용자의 토큰}

ADMIN 권한 사용자로 로그인해 발급한 토큰을 사용하면 접근할 수 있어야 합니다.

8. 메서드 단위 권한 적용하기

URL 경로가 아니라 서비스 메서드 단위로 권한을 검사할 수도 있습니다.

@EnableMethodSecurity를 적용한 뒤 @PreAuthorize를 사용합니다.

package com.example.jwt.member;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class MemberManagementService {

    @PreAuthorize("hasRole('ADMIN')")
    public void suspendMember(Long memberId) {
        // 회원 정지 처리
    }
}

또는 컨트롤러 메서드에 적용할 수 있습니다.

@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
@GetMapping("/profile")
public Map<String, String> profile() {
    return Map.of("message", "회원 프로필");
}

메서드 보안은 URL 패턴만으로 표현하기 어려운 업무 규칙에 유용합니다.

예를 들어 “관리자이거나 해당 게시물의 작성자만 수정 가능” 같은 조건은 서비스 계층에서 검사하는 편이 자연스러울 수 있습니다.

 

9. 역할과 세부 권한을 구분하기

규모가 작은 서비스에서는 USER, ADMIN 같은 역할만으로 시작할 수 있습니다.

하지만 기능이 늘어나면 역할과 실제 작업 권한을 구분해야 할 수 있습니다.

역할
- USER
- MANAGER
- ADMIN

세부 권한
- POST_READ
- POST_WRITE
- MEMBER_SUSPEND
- REPORT_DOWNLOAD

모든 관리 기능을 ADMIN 하나로 묶으면 필요 이상의 권한을 부여하기 쉽습니다.

운영자 역할을 세분화해야 하는 서비스라면 최소 권한 원칙에 따라 필요한 작업만 허용하는 구조를 검토해야 합니다.

 

10. 리프레시 토큰이 필요한 이유

액세스 토큰의 만료 시간을 짧게 설정하면 유출 피해 시간을 줄일 수 있습니다.

하지만 사용자는 액세스 토큰이 만료될 때마다 아이디와 비밀번호를 다시 입력해야 할 수 있습니다.

이를 보완하기 위해 리프레시 토큰을 사용할 수 있습니다.

리프레시 토큰의 기본 흐름은 다음과 같습니다.

로그인 성공
    ↓
액세스 토큰 + 리프레시 토큰 발급
    ↓
액세스 토큰 만료
    ↓
클라이언트가 리프레시 토큰 전송
    ↓
서버가 리프레시 토큰 검증
    ↓
새 액세스 토큰 발급

리프레시 토큰은 단순히 만료 시간이 길기 때문에 고려해야할 것이 많습니다.

  • 어디에 저장할 것인가?
  • 서버에서도 상태를 관리할 것인가?
  • 한 번 사용한 토큰을 교체할 것인가?
  • 로그아웃 시 어떻게 폐기할 것인가?
  • 탈취가 의심될 때 토큰 묶음을 어떻게 무효화할 것인가?
  • 여러 기기의 로그인을 어떻게 구분할 것인가?

 

11. 리프레시 토큰 저장 구조 예시

서버에서 리프레시 토큰 상태를 관리한다면 다음과 같은 엔티티를 사용할 수 있습니다.

@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long memberId;

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

    @Column(nullable = false)
    private Instant expiresAt;

    @Column(nullable = false)
    private boolean revoked;

    protected RefreshToken() {
    }
}

데이터베이스에는 원본 리프레시 토큰 대신 해시를 저장하는 방식을 고려할 수 있습니다.

데이터베이스 내용이 노출됐을 때 저장된 원본 토큰이 바로 사용되는 위험을 줄이기 위해서입니다.

다만 어떤 해시 방식과 조회 키를 사용할지, 토큰 교체와 재사용 탐지를 어떻게 구현할지는 별도 설계가 필요합니다.

 

12. 토큰 재발급 시 확인할 조건

재발급 API는 최소한 다음 조건을 확인해야 합니다.

1. 리프레시 토큰 형식이 올바른가?
2. 서명이 유효한가?
3. 만료되지 않았는가?
4. 서버 저장소에 존재하는가?
5. 폐기된 토큰이 아닌가?
6. 대상 회원이 활성 상태인가?
7. 토큰이 해당 회원과 연결되는가?

검증에 성공하면 새 액세스 토큰을 발급합니다.

보안을 강화하려면 새 리프레시 토큰도 함께 발급하고 기존 토큰을 폐기하는 Refresh Token Rotation을 검토할 수 있습니다.

그러나 회전 방식을 적용하면 동시 요청, 모바일 네트워크 재시도, 여러 기기 관리, 토큰 재사용 탐지 정책까지 함께 설계해야 합니다.

 

13. JWT 로그 아웃은 어떻게 구현해야 하는가?

JWT는 서버 세션처럼 서버에서 객체 하나를 삭제한다고 모든 액세스 토큰이 즉시 사라지지 않습니다.

이미 발급된 액세스 토큰은 서명과 만료 시간이 유효하면 계속 사용할 수 있기 때문입니다.

로그아웃 전략은 서비스 요구사항에 따라 선택합니다.

방법 1. 클라이언트에서 토큰 삭제

가장 단순한 방식입니다.

서버는 별도 상태를 관리하지 않고, 클라이언트가 저장한 액세스 토큰과 리프레시 토큰을 제거합니다.

단점은 탈취된 액세스 토큰을 만료 전까지 즉시 차단하기 어렵다는 점입니다.

방법 2. 리프레시 토큰 폐기

로그아웃 시 서버 저장소의 리프레시 토큰을 폐기합니다.

새 액세스 토큰은 발급받지 못하지만, 기존 액세스 토큰은 만료될 때까지 유효할 수 있습니다.

따라서 액세스 토큰 만료 시간을 비교적 짧게 설정하는 방식과 함께 사용합니다.

방법 3. 액세스 토큰 차단 목록

로그아웃한 액세스 토큰의 식별값이나 해시를 만료 시점까지 차단 목록에 저장합니다.

즉시 차단할 수 있지만 모든 요청에서 차단 목록을 확인해야 하므로 상태와 운영 비용이 늘어납니다.

방법 4. 사용자 토큰 버전 관리

회원 정보에 토큰 버전이나 마지막 강제 로그아웃 시각을 저장하고 토큰의 값과 비교합니다.

전체 기기 로그아웃이나 비밀번호 변경 후 기존 토큰 무효화에 활용할 수 있지만, 요청마다 사용자 상태 조회가 필요할 수 있습니다.

 

14. 토큰을 어디에 저장해야 할까?

브라우저 환경에서는 토큰 저장 위치도 중요한 보안 결정입니다.

대표적인 선택지는 다음과 같습니다.

  • JavaScript 메모리
  • 브라우저 저장소
  • HttpOnly 쿠키
  • 모바일 보안 저장소

어느 한 방식이 모든 서비스에 무조건 적합하지는 않습니다.

브라우저 저장소

구현은 비교적 단순하지만 XSS가 발생하면 JavaScript를 통해 토큰이 노출될 가능성을 고려해야 합니다.

HttpOnly 쿠키

JavaScript에서 직접 읽기 어렵게 만들 수 있지만 브라우저가 요청에 쿠키를 자동으로 전송하므로 CSRF 방어와 SameSite, Secure, 도메인 범위 설정을 함께 검토해야 합니다.

메모리 저장

페이지 새로고침과 여러 탭 처리, 재인증 경험을 별도로 설계해야 합니다.

저장 위치만 선택해서 끝나는 문제가 아닙니다.

XSS, CSRF, 서비스 구조, 프론트엔드 배포 도메인, 모바일 앱 여부를 함께 고려해야 합니다.

 

15. 운영 환경 보안 점검

서명 키를 소스 저장소에서 제거한다

운영 키는 환경변수나 시크릿 관리 시스템을 이용합니다.

키를 실수로 Git에 올렸다면 커밋만 삭제하지 말고 노출된 키를 폐기하고 새 키로 교체해야 합니다.

환경별 키를 분리한다

로컬, 테스트, 스테이징, 운영 환경에서 같은 서명 키를 공유하지 않는 편이 안전합니다.

액세스 토큰 만료 시간을 제한한다

긴 만료 시간은 사용자 편의성을 높일 수 있지만 유출 시 피해 시간도 늘어납니다.

서비스 위험도와 재발급 정책을 기준으로 결정해야 합니다.

Payload에 최소한의 정보만 넣는다

비밀번호, 인증번호, 개인식별정보, 내부 보안값 등을 넣지 않습니다.

권한이나 프로필이 자주 변경된다면 토큰 정보가 오래된 상태로 남는 문제도 고려해야 합니다.

HTTPS를 사용한다

JWT 서명은 토큰 전송 구간을 암호화하지 않습니다.

네트워크 전송 보호를 위해 HTTPS가 필요합니다.

JWT 전체를 로그에 출력하지 않는다

Authorization 헤더와 액세스 토큰은 로그 마스킹 대상에 포함합니다.

사용자 상태 변경을 고려한다

탈퇴, 정지, 비밀번호 변경, 역할 변경 시 기존 토큰을 어떻게 처리할지 정해야 합니다.

키 교체 정책을 준비한다

장기간 운영되는 서비스는 서명 키 교체가 필요할 수 있습니다.

여러 키를 구분하려면 JWT Header의 kid와 키 저장·조회 정책을 검토할 수 있습니다.

대칭 키와 비대칭 키를 구분한다

단일 서버 또는 동일한 신뢰 영역에서는 HMAC 대칭 키가 단순할 수 있습니다.

여러 API 서버가 토큰을 검증하지만 토큰 발급 권한은 인증 서버에만 주고 싶다면 RSA나 EC 기반 공개키 검증 방식을 검토할 수 있습니다.

 

16. 자동화 테스트에서 확인할 시나리오

정상 요청만 테스트해서는 부족합니다.

최소한 다음 시나리오를 자동화 테스트에 포함하는 것이 좋습니다.

로그인
- 올바른 계정으로 로그인 성공
- 잘못된 비밀번호로 로그인 실패
- 존재하지 않는 계정으로 로그인 실패
- 필수 입력값 누락

JWT 인증
- 토큰 없이 보호 API 요청
- 정상 토큰으로 요청
- 만료된 토큰으로 요청
- 변조된 토큰으로 요청
- 잘못된 Bearer 헤더로 요청
- 삭제 또는 정지된 사용자 토큰으로 요청

권한
- USER가 일반 API 접근
- USER가 관리자 API 접근
- ADMIN이 관리자 API 접근

재발급
- 정상 리프레시 토큰
- 만료된 리프레시 토큰
- 폐기된 리프레시 토큰
- 이미 교체된 리프레시 토큰 재사용

Spring Security는 Resource Server JWT 테스트에서 실제 JWT 전체를 생성하지 않고도 인증 요청을 구성할 수 있는 테스트 지원 기능을 제공합니다.

Resource Server 방식을 채택한다면 공식 테스트 지원도 함께 검토할 수 있습니다.

 

17. 직접 만든 JWT 필터와 Resource Server 중 무엇을 선택할까?

직접 만든 필터는 인증 흐름을 학습하고 작은 애플리케이션의 요구사항을 이해하는 데 도움이 됩니다.

하지만 다음 조건에서는 Spring Security Resource Server 기능을 우선 검토할 수 있습니다.

  • 인증 서버와 API 서버가 분리됨
  • 표준 Bearer Token 처리 필요
  • RSA 또는 EC 공개키 검증 사용
  • JWK Set URI를 통해 공개키 관리
  • issuer와 audience 검증 필요
  • 여러 자원 서버가 같은 JWT 검증
  • OAuth 2.0 또는 OpenID Connect와 통합

Spring Security Resource Server는 JWT와 불투명 토큰을 이용한 보호 API 구성을 공식 지원하며, 커스텀 JWT에도 사용할 수 있습니다.

직접 구현하는 코드가 많다고 더 실무적인 것은 아닙니다.

표준 기능으로 대체할 수 있는 부분은 공식 기능을 활용하고, 서비스 고유 정책에 개발 시간을 쓰는 편이 유지보수에 유리할 수 있습니다.

 

자주 묻는 질문

JWT 로그아웃은 토큰 문자열만 삭제하면 끝나나요?

클라이언트 저장 토큰은 삭제할 수 있지만 이미 탈취된 토큰까지 서버에서 즉시 무효화되는 것은 아닙니다.

리프레시 토큰 폐기, 짧은 액세스 토큰 만료, 차단 목록 등의 정책을 검토해야 합니다.

401을 받으면 항상 리프레시 토큰으로 재발급해야 하나요?

아닙니다. 토큰 변조, 잘못된 서명, 폐기된 계정도 401이 될 수 있습니다.

만료 오류처럼 재발급이 허용되는 상황을 구분해야 합니다.

권한은 JWT에 넣는 것이 좋은가요?

요청 성능에는 유리할 수 있지만 권한 변경이 토큰 만료 전까지 반영되지 않을 수 있습니다.

즉시 반영 요구와 데이터베이스 조회 비용을 비교해야 합니다.

리프레시 토큰도 JWT여야 하나요?

반드시 그렇지는 않습니다.

무작위 문자열 형태의 불투명 토큰을 사용하고 서버 저장소에서 상태를 조회하는 방식도 가능합니다.

JWT를 사용하면 완전한 Stateless 시스템인가요?

액세스 토큰 자체만 검증하면 서버 인증 세션을 줄일 수 있습니다.

그러나 리프레시 토큰 저장, 차단 목록, 사용자 상태 확인을 도입하면 일부 상태를 서버에서 관리하게 됩니다.

 

내용 정리

JWT 인증을 운영 가능한 수준으로 확장하려면 다음 요소가 필요합니다.

인증 실패 → 401
권한 부족 → 403
역할별 접근 제어
리프레시 토큰 정책
로그아웃과 토큰 폐기
키 관리와 로그 마스킹
자동화된 실패 시나리오 테스트

전체 흐름은 다음과 같습니다.

1편
아이디·비밀번호 인증
→ 액세스 토큰 발급

2편
Bearer Token 검증
→ SecurityContext 등록
→ 보호 API 접근

3편
401·403 처리
→ 권한 제어
→ 재발급과 로그아웃
→ 운영 보안 점검

예제 코드를 그대로 운영 환경에 적용하기보다 서비스의 회원 상태, 권한 변경, 다중 기기 로그인, 토큰 저장 위치, 키 교체, 사고 대응 요구사항에 맞게 수정해야 합니다.

 

 

 

 

 

망했어요. 어려워요.

728x90