코딩관계론

Spring Interceptor를 이용한 JWT 개발 - 2탄 본문

개발/Hot-Stock

Spring Interceptor를 이용한 JWT 개발 - 2탄

개발자_티모 2024. 8. 5. 13:27
반응형

서론

앞서 JWT가 어떤 방식을 통해서 동작하는지 알아봤습니다. 이제는 어떤 방식으로 개발했는지에 대해서 설명하겠습니다.

1. 왜 Interceptor를 선택했나?

먼저 spring에는 filter를 통해 Interceptor와 똑같은 기능을 구현할 수 있습니다. 이때의 차이점이라고 한다면 Filter는 스프링 앞단에서 동작하고, Intercepto는 스프링 영역에서 동작하게 됩니다.

 
만약 Filter를 통해 구현하게 되면, 에러 처리가 추가적인 시간을 소모하게 됩니다. 그 이유는 Filter에서 발생한 에러가 WAS까지 전달된 후, WAS에서 다시 패킷을 생성해 /error 리다이렉트 시키는 방식이기 때문입니다. 반면, Interceptor를 사용할 경우, 서블릿 내에서 직접 Exception Handler를 호출하기 때문에 에러 처리가 더 쉬워지며, 추가적인 네트워크 비용도 발생하지 않습니다.
 
추가적으로 Spring Security애서 인증 기능을 제공하지만, 아직 이해도도 높지 않고, JWT를 구현하기에는 Interceptor의 기능만으로도 충분했습니다.

2. 어떤 구조를 선택했나?

먼저 제가 만든 서비스는 MSA 서버 구조를 선택하고 있기 때문에 아래 그림과 같이 API Gateway에서 인증 및 인가를 담당하게 됩니다.

 
이 때 Load balancer에서 사용자의 요청을 분배하게 되는데 API Gateway 서버의 메모리에 Token들의 정보를 저장하게 된다면 사용자가 요청을 보낼 때마다 새롭게 로그인을 해야 할 수도 있습니다. 이를 방지하기 위해  Load Balacer에 Sticky Session의 기능을 사용해도 되지 않냐고 반문하실 수도 있는데 해당 기능은 LB 자체에 부하가 생길 수 있기 때문에 사용하지 않는 것이 좋다고 판단했습니다.
 
따라서 저는 Token의 정보들을 캐시서버에 저장하여 LB의 부하없이, 사용자의 인증을 처리할 수 있게 됐습니다.

3. 캐시 서버의 생명주기와 저장 형식

캐시 서버는 무한한 자원을 가지지 않기 때문에, 적절한 캐시 무효화 전략이 중요합니다. 저는 Refresh Token의 생명주기에 맞춰 캐시 무효화 시간을 설정하고, LRU(Least Recently Used) 정책을 도입했습니다.
 
Refresh Token의 생명주기와 캐시 무효화 시간을 일치시킨 이유는, 사용자가 로그아웃 버튼을 클릭하지 않는 한 서버는 사용자의 로그아웃 상태를 직접 감지할 수 없기 때문입니다. 따라서 토큰의 생명주기 동안 해당 정보를 DB나 Redis에 저장해야 합니다.
 
만약 데이터를 DB와 Redis에 분산 저장하게 되면, 요청 시 캐시 서버(예: Redis)를 먼저 확인하고, 캐시에 없을 경우 DB를 확인해야 합니다. 이는 네트워크 요청을 세 번 발생시키므로, 사용자 경험에 부정적인 영향을 줄 수 있습니다.

 
또한, 많은 사용자가 접속하고 로그아웃하지 않은 채로 나가면 캐시 서버에 데이터가 과도하게 남아 있어 메모리 부족 현상이 발생할 수 있습니다. 이를 방지하기 위해 LRU 정책을 도입했습니다.
 
LRU 정책은 캐시에서 가장 오랫동안 사용되지 않은 데이터를 제거하는 방식으로, 메모리를 효율적으로 관리합니다. Redis에서 LRU 정책을 적용하면, 제한된 메모리 내에서 오래 사용되지 않은 데이터를 자동으로 제거하여 새로운 데이터를 저장할 수 있습니다.

4. Redis 키 설계

저의 서비스는 DAU가 10만 명 이상인 대규모 서비스로, 키 값을 통해 토큰 값을 빠르게 검색하는 것이 매우 중요합니다.
 
Redis는 key-value 구조의 데이터를 저장하는 인메모리 데이터베이스로, 해시 테이블 구조를 사용합니다. 키가 입력되면 이를 해시 함수로 처리해 해시 값이 생성되며, 이 해시 값으로 데이터를 저장하거나 검색합니다. 따라서 Redis의 성능은 O(1)이 되지만 키 설계가 비효율적일 경우, 해시 테이블의 성능이 저하될 수 있으며, 특히 체이닝이나 리해시(rehashing)와 같은 충돌 해결 과정에서 성능이 떨어질 수 있습니다.
 
따라서 레디스의 키는 논리적으로 그룹화 되어야 합니다. Redis에서 권장하는 키 설계 방식은 키를 논리적으로 그룹화하기 위해 콜론(:)을 사용해 네임스페이스를 구분하는 것입니다. 예를 들어, "tenantId:objectType:objectId"와 같은 형식을 사용하면 데이터 구조를 명확하게 표현할 수 있습니다.
 
앞서 Refresh Token은 AccessToken과 1대 1로 매칭된다고 했고, "USER:LOGIN:REFRESH_TOKEN"로 키를 설계하는 방식은 어떨까 생각해 봤습니다. 하지만 이 키는 적절하지 못하다고 생각했는데 Refresh Token이 128비트 길이를 가지므로 충돌 문제를 줄일 수는 있지만, Redis 키에 직접 사용할 경우 키의 길이가 길어질 수 있어 저장 효율성 측면에서 불리할 수 있습니다.
 
따라서 저는 sessionId를 발급해 Redis의 키로 사용했고 최종적으로는 "USER:LOGIN:sessionId"방식을 사용했습니다. 이 방법은 키의 길이를 줄이고, UUID를 사용하여 고유성을 확보함으로써 해시 충돌 가능성을 줄이고 O(1) 성능을 유지할 수 있습니다.
 
추가적인 장점으로,  앞선 글에서 확인했던 문제 중 하나인 Access Token이 탈취되었을 때 서버가 이를 즉시 검증할 수 없는 상황의 문제를 해결할 수 있습니다. Session ID를 사용하여 Access Token과 1대1로 매칭하면, 서버가 해당 Session ID를 통해 토큰의 유효성을 검증하고, 필요시 즉시 무효화할 수 있습니다.
 
또한 기존에 봤던 문제 refresh token이 탈취당해서 access token이 만료전인데도 갱신을 요청하는 문제 역시 해결할 수 있습니다.

5. 추가적인 보안사항 

우테코의 책을 보면 항상 보안 메커니즘으로 사용자의 지속 시도를 방지하는 정책이 있습니다. 따라서 이를 구현하는 것도 처리해 주는 게 좋습니다.

반응형