시큐리티 삽질하기
이전 글과 이어집니다.
📌 TokenQuota로 API 사용량 제한하기
대화 생성이라는 GPT API 특성상, 요청마다 사용되는 토큰 수가 다르기 때문에, 단순히 호출 횟수만 제한하는 것으로는 실제 사용량과 차이가 생길 수밖에 없다. 예를 들어, 호출 횟수만 기준으로 삼을 경우 적은 토큰만 사용하는 유저도 과도하게 제한받을 수 있고, 반대로 많은 토큰을 사용하는 유저가 상대적으로 더 많은 리소스를 소모하게 된다.
이러한 문제를 해결하기 위해, 유저별로 실제 사용한 토큰량을 기준으로 제한을 두는 TokenQuota 시스템을 도입했다.
문답 생성 API 흐름
API에서는 아래와 같은 흐름으로 동작한다:

외부 API를 호출하기 전에 사용 가능한 토큰이 충분한지 먼저 검사하고, 문답 생성이 완료되면 실제 사용된 토큰 수만큼 사용량을 차감한다. 이렇게 하면 불필요한 호출 낭비를 방지하고, 과도한 사용을 사전에 차단할 수 있다.
TokenQuota 엔티티 설계
TokenQuota는 유저당 하나씩 생성되며, 다음과 같은 구조로 구성되어 있다:

- remaingQuota는 현재 남아 있는 토큰 수를 나타낸다
- 유저가 새로 가입하면 createMaxQuota()를 통해 초기 최대치를 할당받는다
이 엔티티를 통해 유저별 사용량 집계 및 통계 수집도 가능하고, 나중에는 MAX_REMAINING_QUOTA 값을 조정하여 유저 등급별 차등 할당도 구현할 수 있다.
📌 Spring Security Filter 에러 핸들링이 있었는데


분명 인증 관련 부분은 예외 처리까지 깔끔하게 해놓고, 예외 케이스에 대한 테스트 코드도 모두 작성했는데... 갑자기 날것의 에러 객체가 반환됐다.

프로젝트 에러 핸들링 구조
커스텀 예외인 NexterviewException을 포함해서, DispatcherServlet 이후에 발생하는 예외는 @ControllerAdvice에서 전역적으로 처리해 주고 있었다.

이때, Spring Security FilterChain에서 발생하는 예외는 ControllerAdvice에서 처리할 수 없으니, 인증 인가 관련 예외는 ExceptionTranslationFilter를 통해 처리하로도록 Security를 설정했다.

그래서 모든 인증 관련 오류가 잘 catch되어, 프로젝트에 정의한 에러 객체 형식대로 반환되고 있는 줄 알았다.
JwtFilter 예외 처리 누락
로그인 유저의 문답 생성 API를 모두 구현한 후, 인증도 함께 검증하기 위해 로그인을 하지 않은 상태로 Postman으로 테스트 요청을 보내 보았다. 그랬더니 인증 실패로 인한 400번대가 아닌 500 Internal Server Error가 발생했다.
로그를 확인해 보니 JwtFilter 내부에서 발생한 예외가 처리되지 못한 상태였다.

authenticateUser(jwt) 메서드에서는 토큰 유효성을 검사하고, 만료됐거나 유효하지 않은 토큰이 입력된 경우 예외를 던진다.
왜 ExceptionTranslationFilter가 인증 필터에서 발생한 예외를 잡아주지 않았을까?
If an AuthenticationException is detected, the filter will launch the authenticationEntryPoint. This allows common handling of authentication failures originating from Web or Method Security.
If an AccessDeniedException is detected, the filter will determine whether or not the user is an anonymous user. If they are an anonymous user, the authenticationEntryPoint will be launched. If they are not an anonymous user, the filter will delegate to the AccessDeniedHandler. By default the filter will use AccessDeniedHandlerImpl.
ExceptionTranslationFilter에 대한 설명을 보면 AuthenticationException과 AccessDeniedException을 처리해 준다고 명시되어 있다.

그리고 내 TokenProvider는 아주 당당하게 NexterviewException을 던지고 있다.
이 부분을 수정하여 AuthenticationException을 상속하는 InvalidJwtException을 넘겨 주도록 했지만 여전히 예외 처리가 되지 않았다.
Security FilterOrderRegistration
시큐리티에 필터를 등록하는 코드를 보고 힌트를 얻을 수 있었다. Spring Security의 FilterChain은 정해진 순서대로 실행된다. 나는 기본적인 인증 필터인 UsernamePasswordAuthenticationFilter의 앞에 커스텀 인증 필터인 JwtFilter를 배치했다.

그리고 ExceptionTranslationFilter를 설정할 때 인증 실패 상황을 처리하는 AuthenticationEntryPoint를 넘겨주니, 해당 필터가 FilterChain에서 발생하는 모든 예외를 처리해 줄 것이라고 막연히 생각했다.

그러나 실제로 ExceptionTranslationFilter는 인증 필터보다 한참 뒤에 등록되고, UsernamePasswordAuthenticationFilter가 상속하고 있는 AbstractAuthenticationProcessingFilter에서는 unsuccessfulAuthentication이라는 메서드로 직접 예외를 처리하고 있었다.


🤔 그럼 대체 ExceptionTranslationFilter가 하는 일은 뭘까?
ExceptionTranslationFilter는 자신보다 뒤에 있는 필터나 애플리케이션 로직에서 발생하는 예외를 처리한다.


해당 필터는 AuthenticationException을 처리한다고 명시되어 있고, UsernamePasswordAuthenticationFilter와 같은 인증 필터 들은 인증 과정에서 예외를 발생시킬 수 있다. 필터 실행 순서를 모르는 상태에서는 자연스럽게 AuthenticationFilter가 ExceptionTranslationFilter의 처리 범위 내에 있다고 생각했기에, Security Filter들의 실제 순서가 납득되지 않았다. 열심히 문서를 찾아보고, AI 친구랑 열심히 토론을 해서 겨우겨우 이 구조를 받아들일 수 있게 되었다.
우선 Spring Security의 필터 체인은 인증과 인가 두 가지 큰 보안 단계를 분리해서 처리한다. 인증 필터는 사용자의 신원을 확인하는 역할을 하고, 인가 필터(대표적으로 AuthorizationFilter, FilterSecurityInterceptor)는 사용자가 특정 리소스에 접근할 권한이 있는지 확인한다. 그리고 인증 필터들은 ExceptionTranslationFilter의 앞에 위치하고, 인가 필터들은 ExceptionTranslationFilter의 뒤에 위치한다.
즉, ExceptionTranslationFilter는 인가 필터에서 발생한 예외를 처리하는 구조이다. 혼란은 해당 필터가 AuthenticationExceptino을 처리하는 것에서부터 비롯되었다. 인가는 반드시 인증이 선행된 상태에서 이루어진다는 이론적 지식을 기준으로 생각하고 있어서, 인가 과정에서도 인증 관련 예외가 발생할 수 있다는 가능성을 완전히 배제하고 있었다.
그러나 AuthorizationFilter에서는 인가를 수행하기 전에 내부적으로 이런 코드를 실행한다:

즉, 인가 전에 인증 상태를 확인하고, 인증 정보가 없거나 유효하지 않으면 AuthenticationException을 던진다.
따라서 ExceptionTranslationFilter는 다음과 같은 두 가지 역할을 수행한다:
1. 인가 예외 처리 (AccessDeniedException)
2. 인가 도중 인증이 되지 않았다는 사실이 드러난 경우의 예외 처리 (AuthenticationException)
🔨 이제 어떻게고 치나요
원인을 파악하고, 흐름을 이해하는 부분이 헷갈렸지만 해결하는 방법은 아주 간단하다:
- JwtFilter를 ExceptionTranslationFilter의 뒤에 배치
- JwtFilter 내부에서 직접 AuthenticationEntryPoint 호출
- JwtFilter 내부에서 직접 응답 객체 작성
1번 방식은 Spring Security가 의도한 필터 체인의 흐름을 망치는 느낌이 들었고, 다른 인증 필터들과 마찬가지로 통일성 있게 내부에서 예외를 처리하는 것이 좋을 것 같아 2번 방식을 적용하여 문제를 해결했다.


📌 오늘의 교훈

설거지는 미뤄도 학습은 미루지 맙시다.
'감자-갱생-프로젝트' 카테고리의 다른 글
| 🤖 대인공지능 시대에서 살아남기 (부제 - 게스트 API 요청 제한) (6) | 2025.05.11 |
|---|---|
| Spring Security는 사드세요... 제발 - Authentication 편 (2) | 2025.04.25 |
| Spring Security는 사드세요... 제발 - Architecture 편 (1) | 2025.04.14 |
| 내 이름은 지속적 통합, 실패했죠 (3) | 2025.04.10 |
| 동시성의 이름으로 날 용서하지 않겠다 - Distributed Lock 편 (2) | 2024.12.03 |