새소식

Welcome to the tech blog of Junior Backend Developer Myoungji Kim!

Development/PHP

[OAuth] RTR 방식에서 Refresh Token 동시성 이슈 해결하기

  • -

📌 Intro

고객 계층 관리를 위해 통합 로그인 기능 개발이라는 새로운 프로젝트를 담당하게 되었다.

 

이번 프로젝트에서는 보안 강화를 위해 Oauth 2.0 프로토콜에서 RTR(Refresh Token Rotation) 방식을 도입하고, 인증 방식으로는 JWT(Json Web Token)을 사용하기로 했다. 그러나 개발 과정에서 Refresh Token 동시성 이슈가 발생하여 간헐적으로 토큰 갱신이 실패하고 사용자가 로그아웃 되는 이슈가 있었다.

다행히 이 이슈는 QA 과정에서 발견되었어서, 리얼 배포 전에 조치하였다🫠

 

이번 포스팅에서는 RTR 방식에서 발생한 Refresh Token 동시성 이슈와 이를 해결한 과정을 기록하고자 한다.

🔖 용어 정리

  • RTR: Refresh Token Rotation
  • JWT: Json Web Token
  • AT: Access Token
  • RT: Refresh Token

🚨 발생했던 이슈

Oauth 를 사용할 때, 로그인 세션이 연장되는 일반적인 순서는 다음과 같다.

1. Access Token(AT)이 만료되고, Refresh Token(RT)의 유효 기간이 끝나기 전에
2. 사용자가 액션을 취하면, Refresh Token으로 토큰 갱신을 요청한다.
3. 토큰 갱신에 성공하면, 로그인 세션 내 토큰을 교체하고
4. 토큰 갱신에 실패하면, 로그인 세션을 삭제한 후 로그아웃 처리한다.

 

위 과정에서 1번 조건을 충족한다면 조작된 토큰이 아닌 이상, 사용자 액션에 인해 4번의 로그아웃 상황이 발생하는 경우는 없어야 한다. 하지만 QA 과정에서, 간헐적으로 1번 조건을 충족한 상태에서 짧은 시간 내에 여러 요청을 동시에 보낼 경우, Refresh Token 갱신이 실패하고 로그인 세션이 삭제되는 현상 발생했다. (팀 내에서 이를 '따닥 이슈'라고 불렀다.)

 

🔍 RTR 방식에서 Refresh Token 동시성 이슈 발생 원인

RTR 방식은 Refresh Token의 보안성을 강화하기 위해 유효한 RT로 AT를 재발급할 때 RT도 함께 갱신하며 기존 RT를 즉시 무효화하는 방식이다. 이 방식에서 사용자가 여러 요청을 동시에 보낼 경우, 다음과 같은 상황이 발생할 수 있다.

이슈를 도식화한 Flow

  1. 첫 번째 요청으로 새로운 RT와 AT를 정상적으로 발급한다.
  2. 새로운 RT와 AT가 로그인 세션에 저장되기 전에 두 번째 요청이 들어온다. 🚨
  3. 두 번째 요청은 이미 무효화된 기존 RT를 사용하여 재발급을 시도한다.
  4. 인증 서버는 두 번째 요청을 유효하지 않은 요청으로 판단하고, 갱신 실패 응답을 반환한다.
  5. 클라이언트는 갱신 실패가 되었기에, 사용자를 로그아웃 처리한다. 😥

결과적으로, Refresh Token의 즉각적인 무효화와 동시 요청이 맞물리면서 사용자가 로그아웃되는 문제가 발생했다.

 

😯 어디에서 동시성 요청을 제어할 것인가?

클라이언트, 중개 서버, 인증 서버 중에서 동시성 요청을 관리할 지점에 대해 고민했다.

 

참고로 이번에 구현하는 Oauth에서는 issue, refresh, revoke 모두 중개 서버를 거치도록 설계되었다.

이유는 아래와 같다.

인증 서버는 refresh 요청에 포함된 redirect_uri가 인증 서버 DB에 등록된 값인지 검증한다. 하지만 redirect_uri를 각 상점의 도메인으로 설정하면, 인증 서버에 2만 개가 넘는 상점 도메인을 모두 등록해야 하는 부담이 발생한다. 부담을 줄이기 위해 redirect_uri를 하나로 통합하여 처리할 별도의 중개 API를 구축했다.

 

1. 인증 서버에서의 중복 요청 감지 👎

처음에는 인증 서버에서 중복 요청을 감지하고, 직전에 발급된 토큰으로 동일하게 응답하는 방식을 고민했다. 하지만 이는 인증 서버의 refresh API 역할에 모순이 생길 수 있다는 결론에 도달했다. 일부 요청에서는 새로운 토큰을 발급하고, 다른 요청에서는 기존 토큰을 반환한다면, refresh API의 목적이 흐려지고 일관성이 훼손되기 때문이다. 인증 서버가 맡아야 하는 역할을 흐리게 만드는 방향은 옳지 않았다.

 

뿐만 아니라 여러 클라이언트를 상대하는 인증 서버가 모든 클라이언트의 중복 요청을 제어한다?
... 아마 인증 서버의 등이 터져나갈 것이다

 

2. 중개 서버에서의 중복 요청 감지 👎

다음으로 중개 서버에서 중복 요청을 감지한 뒤, 인증 서버에 refresh 요청을 보내지 않고 이미 발급된 토큰을 반환하는 방안도 검토했다. 하지만 이는 중개 서버의 설계 의도와 어긋난다고 판단했다. 중개 서버는 redirect_uri 통합이라는 명확한 목적 아래 도입된 만큼, 요청을 단순히 전달(bypass)하는 역할에 집중하도록 했다. 핵심적인 비즈니스 로직은 클라이언트에서 처리하고 있었기 때문에, 중개 서버가 클라이언트 요청을 제어하는 추가 로직을 갖는 것은 유지보수의 복잡성을 가중시킬 위험이 있다고 생각했다. (중개 역할을 바꿀 정도로 메리트가 있지도 않았다.)

 

1, 2번을 고려했을 때, 클라이언트 단에서 중복 요청을 방지하는 방법을 고민하는 것이 가장 적합하다고 결론을 내렸다.

 

3. 클라이언트에서의 중복 요청 감지 ✅

클라이언트 단에서 중복 요청을 제어하기 위해 여러 방안을 고민했다. 처음에는 Redis를 이용한 분산 락 방식을 검토했지만, 락이 걸렸을 때 발생할 예외 상황을 처리하는 로직 설계가 까다로워 고민이 깊어졌다.(땜빵 느낌이 강했달까..) 이 상황을 팀장님께 설명드리고 1차 개선안을 공유하며 논의하는 중, 점심 시간이 끝나갈 무렵 팀장님께서 관련된 사례를 다룬 블로그 포스팅 하나를 보내주셨다. (감사합니다!!)

 

🔗 [SwiftUI] RTR 기법을 적용하여 refresh token 처리 시 발생하는 예외와 해결 방법 (feat. Backend)

나랑 똑같은 상황에 처한 개발자가 여기 또 있었네..? 라는 생각에 너무 반가웠다🥹

 

 

샘플을 받고, 1시간 뒤에 새로운 전략으로 무사히 컨펌 받았던 :)

 

포스팅을 꼼꼼히 읽으며 우리 서비스에 어떻게 응용할 수 있을지 검토했고, 이를 바탕으로 새로운 개선안을 구상했다. 이후, 약 1시간 동안 다듬은 개선안을 팀장님께 다시 공유하며 최종적인 적용 전략을 확정했다.

 

⚒️ 클라이언트에서 Refreshing State 관리하기

초기 문제는 클라이언트가 현재 보낸 refresh 요청이 최초 요청인지, 이미 진행 중인 요청(n번째 요청)인지를 알 수 없었다는 점이었다. 만약 현재 사용 중인 refresh 토큰이 이미 요청 상태이라는 점만 클라이언트가 알 수 있다면, 문제를 해결할 수 있지 않을까?를 고민하며 해결책을 모색했다.

이를 해결하기 위해 Redis의 싱글 스레드 특성을 활용해 state를 관리하는 키를 추가했고, 아래 Flow로 개선을 진행했다.

Refresh 동시성 이슈 해소를 위한 방안

 

1. 클라이언트는 Redis에서 refresh_token_state_{기존 refresh_token} 키를 조회한다. 결과값이 없으면 is_refreshing 상태로 set 하고 인증 서버에 refresh token을 요청해 Redis에 갱신한다.

2. 상태가 is_refreshing이면 중복 요청으로 간주하여 최대 10번까지 0.1초씩 대기하며 재조회한다. 결과값이 is_refreshing이 아닌 경우, 최초 요청으로 간주하고 새로 발급 받은 refresh token 값을 refresh_token_{기존 refresh_token} 키에 저장합니다.

3. 재시도 중 refresh_token_{기존 refresh_token} 결과값이 새로운 refresh token으로 set 되면 해당 값을 사용해 로그인 세션을 업데이트한다. 재시도 횟수를 초과할 경우, 로그인 세션 갱신 실패로 처리하고 로그아웃 시킨다.

 

결과적으로 Redis를 활용해 is_refreshing 상태와 최종 갱신된 refresh token 값을 관리함으로써, 중복 요청을 방지할 수 있었다.

 

📝 마무리하며

평소에는 솔루션에서 발생하는 버그를 개선하거나 레거시 로직을 파악하고 분석하는 일이 대부분이었어서 시야를 넓히기엔 조금 힘든 환경이었는데, 이번 OAuth 프로젝트는 솔루션-중개-인증 서버라는 세 가지 요소를 모두 고려해야 해서 더 넓은 관점에서 고민할 수 있어서 재밌었다. 복잡한 구조 속에서 각 기능에 대한 역할과 책임을 세심하게 검토하는 것도, 이번 동시성 이슈를 해결하기 위해 전략을 고민하고 도입하는 과정도 모두 즐거웠다.

 

이커머스 솔루션에서는 동시성 이슈가 정말 많이 생기는데, 나는 개인적으로 동시성 문제를 분석하고 효율적인 방안을 찾아낼 때 다른 이슈들보다 훨씬 더 큰 쾌감을 느끼는 것 같다. 이번에 고민하고 적용한 개선 방안은 단순히 문제를 해결하는 데서 끝나지 않고, 앞으로 비슷한 상황에서도 잘 활용할 수 있을 것 같아서 더 기분이 좋았다 :)

 

고생했다!

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.