Coding/사이드 플젝

[팔레트] 스프링 시큐리티와 Refresh Token 제대로 알고 사용하기

kangplay 2025. 5. 24. 20:46

이번에 새로운 프로젝트에 참여하게 되면서 사용자 도메인을 담당하게 되었다. 맡은 주요 역할은 바로 Spring Security 기반의 인증(Authentication)과 인가(Authorization) 구현이었다.

 

Spring Security는 워낙 방대한 프레임워크라서 처음부터 쉽지만은 않았다. 적용하면서 많은 오류가 발생했고, 굳이 내가 Spring Security를 써야하나? 하고 고민되는 부분도 많았다. 결론적으로 인증과 인가에 대한 흐름은 Spring Security가 전담하고,
각종 사용자 액션(회원가입/로그인 등)은 Spring MVC 기반의 컨트롤러에서 처리하도록 분리했다.

 

인증과 인가를 담당하면서 여러 기술에 대해 트레이드오프를 고민했고, 더 나은 선택을 하기 위해 수많은 고민을 거쳤다. 그 과정에서의 고민과 선택의 이유를 기록으로 남기고자 한다. 

1. 로그인는 어디서 담당해야할까?

로그인을 구현하기 위해 여러 레퍼런스를 참고했지만, 구현 방식은 제각각이었습니다. 크게 두 가지 방식이 있었다.

  • 로그인 로직을 필터에서 처리하는 방식
  • 스프링 MVC 기반의 컨트롤러에서 처리하는 방식

두 방법의 장단점을 비교해보았다.

 

필터에서 구현하면  Spring Security의 구조 안에서 비교적 쉽게 인증 과정을 처리할 수 있다. 단, 이는 주로 로그인 폼이나 세션 기반일 때 적합하다.  우리 프로젝트는 JWT 기반 인증을 사용하기 때문에 기본 필터를 그대로 사용할 수 없고, UsernamePasswordAuthenticationFilter, AuthenticationProvider, UserDetailsService, UserDetails 등을 모두 직접 구현하거나 커스터마이징해야 했다. 게다가 필터 방식은 /login과 같은 엔드포인트가 명확하게 드러나지 않는다는 단점도 존재한다.

 

컨트롤러에서 처리할 경우, 엔드포인트가 명확하고, JWT 발급 과정을 자유롭게 커스터마이징 가능하고, 불필요한 Spring Security의 의존성을 줄일 수 있다. 

 

우리 프로젝트에서는 컨트롤러 방식이 더 적합하다고 판단했고, 실제로도 그렇게 구현했다.

1번과 같은 이유라면 그렇다면 JWT 기반 인증/인가에서 Spring Security는 과한 게 아닐까?”라는 의문이 들 수 있다. 하지만 오히려 Spring Seuciry는 인가에서 그 힘을 발휘한다고 생각한다. 커스텀 JwtFilter를 Spring Security 필터 체인에 추가하면, 인증이 완료된 User 정보를 SecurityContextHolder에 저장해 스프링 MVC에서 활용할 수 있다. 또한, 우리 프로젝트는 어드민 전용 API가 있는데, 이런 경우도 간단한 메소드 호출로 해결할 수 있다.

2.  JwtFilter에서의 예외 처리

Spring Security의 필터에서 발생한 인증/인가 예외는 CustomAuthenticationEntryPoint와 AcceptedDeniedHandler에서 각각 처다. 따라서 나는 JWT 필터에서 예외가 발생했을 때, 기존에는 AuthenticationException을 던지면 CustomAuthenticationEntryPoint에서 이를 받아 처리해줄 것으로 기대했다. 하지만 실제 응답은 다음과 같았다. :

{
    "message": "Full authentication is required to access this resource",
    "error": "Unauthorized",
    "status": 401,
    "path": "/error"
}

이유를 찾기 위해 JwtFilter와 CustomAuthenticationEntryPoint에 breakPoint를 찍어 디버깅을 진행하였다.

요청에 대한 마지막까지 내가 의도한 예외 메시지를 가진 예외가 던져지는 것을 볼 수 있다.
하지만 CustomAuthenticationEntryPoint에 들어온 예외는 WAS가 만든 예외였다.

 

예외는 분명 내가 정의한 예외였지만, 실제 EntryPoint에서는 WAS가 만들어낸 예외 객체가 도착해 있었다. 그 이유는 필터에서 예외 발생 시 흐름에 있었다. 나는 필터에서 예외가 발생하면 앞 단의 필터들을 되돌아가면서 CustomAuthenticationEntryPoint가 예외를 잡아주는 줄 알았다. 하지만 그게 아니라, 예외가 발생하면 필터들을 그냥 거치고, /error 요청을 다시 보낸다고 한다. 즉, 필터에서는 내가 발생시킨 예외를 잡을 수 없다. 하지만 토큰에 대한 예외도 여러가지임에 따라 커스텀 예외가 필수적이라고 생각한 나는 JwtFilter에서 직접 예외를 내려주었다. (직접 예외를 내려주면 WAS는 정상 응답이라고 생각하고 다시 /error 요청을 보내지 않는다.)

하지만 인가 예외는 상황마다 다른 메시지가 아닌 단일 메시지를 내려주어도 되겠다는 판단하에 CustomAccessDeniedHandler를 활용했다.

3.  Refresh 토큰을 쓰는 이유

JWT을 사용할 때, 일반적으로 로그인 성공 시 AccessToken과 RefreshToken을 응답해준다. AccessToken은 사용자의 민감하지 않은 정보를 담아 서버와 Stateless한 통신을 하기 위해서이고, RefreshToken은 탈취 위험을 방지하기 위해 헤더에 설정한 후, Redis에 저장하여 AccessToken 만료 시 재발급해준다. 서버에서 관리하는 이유(Redis에 저장하는 이유)는 토큰이 탈취되었을 경우 서버에서 삭제할 수 있어야하기 때문에, Stateful하게 서버에서 관리하고 있어야 한다. 

 

그런데 AccessToken과 RefreshToken을 모두 쓰는 것에 대한 의문이 생겼다. 보안을 위해 RefreshToken을 쓰는 것이라면, 굳이 AccessToken 응답을 내려줄 필요가 있나? 단일 토큰으로 헤더에서 인증에 대한 과정을 주고받는다면, RefreshToken의 기능도 수행하고, AccessToken의 기능인 인증도 가능하다고 느꼈기 때문이다. 어차피 RefreshToken으로 인해 Stateful한 특성을 지닌 과정이기 때문에, 단일 토큰으로 수행하는 게 더 간단한 과정이라고 생각했다.

 

하지만 RefreshToken은 보안뿐만 아니라, 사용자 경험 향상에 대한 장점도 제공한다. 즉, 단일 토큰의 만료 기간을 짧게 잡으면, 토큰이 만료될 때마다 사용자는 로그인 하여 토큰을 갱신해야 한다. 이는 사용자 경험을 악화시킨다.

즉, Refresh Token은 단순한 보안 장치가 아니라 로그인 유지 경험을 부드럽게 만들어주는 장치이기도 하다.

4.  마무리하며

인증과 인가를 담당하면서 많은 고민을 했고, 기술 간의 트레이드오프에 대해 많이 배울 수 있는 경험이었다. 틀린 내용이 있을 수 있으니,
읽으시면서 잘못된 점이나 더 나은 방법이 있다면 알려주시면 감사하겠습니다. 🙏