본문 바로가기
tech epilogue

Rolling Session

by rami_ 2026. 6. 23.

최근 인증과 로그인,로그아웃 기능을 구현할 일이 있었는데 조사한 내용들을 기억하기 위해 글을 작성한다.

 

제품이 MVP 과정을 넘어 실제로 사용자들이 많아지게 되면서 인증을 다시 들여다 보게 됐고, 사용하는 동안 로그인이 유지되어야 사용자 UX에 도움이 될 것 같아 롤링 세션을 검토하게 됐다.

 

1. 롤링 세션 도입 결정과 정책

구분 고정 세션 롤링 세션(sliding session)
만료 기준 로그인 시점 고정 마지막 활동 시점 갱신
문제점 작업 중에 갑자기 튕김 탈취시 영구 유지 가능

 

롤링 세션을 도입하기로 결정했다면 정책을 먼저 정해야한다.

 

- idle timeout : 몇 분/시간 무활동 시 만료 할지?

- absolute cap : 절대 상한을 둘 것인가? 없으면 공격자가 주기적 요청으로 세션 무한 유지 가능, 있으면 쿠키 별도 저장 필요.

 

 

그다음에는 토큰 형식도 함께 봐야 했는데, JWT토큰으로 되어있어 토큰안에 exp가 고정되어 있다. 쿠키의 Max-Age만 리셋하면 의미가 없었다. Rolling 방법 3가지를 검토했다. 매 요청마다 재서명을 하거나 Refresh Token으로 분리를 하거나 exp를 길게하고 쿠키로만 제어한다. 하지만 매 요청마다 재서명을 하는건 서버에 부하가 갈 수 있고, exp를 길게하고 쿠키로만 제어하는건 보안에 취약하다.

 

결국 현실적인 선택은 Refresh Token 분리다. Access Token은 짧게 유지하고(15분), Refresh Token을 Rolling 대상으로 삼는다. 그런데 여기서 Refresh Token을 JWT로 만들면 같은 문제가 반복된다. 토큰 안의 exp가 고정되기 때문이다. 그래서 Refresh Token은 Opaque Token(랜덤 문자열)으로 만드는 게 일반적이다. 값 자체에 만료가 없으니 쿠키 Max-Age가 유일한 만료 수단이 되고, Rolling = 쿠키 덮어쓰기로 깔끔하게 끝난다.

Access Token  → JWT, 짧은 수명 (15분), 실제 인증에 사용
Refresh Token → Opaque, 긴 수명 (7일), Rolling 대상

 

2. 저장소 결정

토큰 형식을 정했으면 어디에 저장할지도 고민이 됐다.

 

쿠키 vs 서버 스토어(Redis)

인프라 추가 없음 필요
강제 로그아웃 불가 가능
비밀번호 변경 시 세션 무효화 불가 가능

 

판단 기준은 하나였다. 특정 사용자의 세션을 서버에서 강제로 끊어야 하는 상황이 있는가? 내부 도구나 MVP 수준이라면 쿠키만으로도 충분하고 계정 탈취 대응이나 보안 정책이 필요한 서비스라면 Redis를 붙여야 한다.

 

3. 전송 방식 결정

 

쿠키로 전송할지, Bearer 토큰으로 전송할지도 골라야 했다. 조사해보니 둘의 가장 큰 차이는 Rolling의 주체가 달라진다는 점이다.

쿠키 전송    → 서버(middleware)가 Rolling 담당
Bearer 전송  → 클라이언트(interceptor)가 Rolling 담당

 

Bearer를 쓰면 CSRF 걱정이 없어지는 대신, Access Token을 어디에 저장하느냐 문제가 생긴다. localStorage는 XSS에 취약하고, 메모리에 두면 새로고침 시 사라진다. 주로 둘을 혼용하여 사용한다.

Access Token  → 클라이언트 메모리 (Bearer로 전송)
Refresh Token → httpOnly 쿠키 (자동 전송)

 

Bearer의 CSRF 취약점을 피할 수 있고 httpOnly 쿠키의 XSS 방어를 동시에 챙긴다.

 

4. Rolling 주체와 활동 범위 결정

 

Next.js라면 middleware가 자연스러운 선택이다. 모든 요청이 반드시 거쳐가는 단일 길목이기 때문에, Rolling 로직이 여러 곳에 흩어지지 않는다.

 

Vue의 router.beforeEach와 비교하면 트리거 범위가 달라진다.

Vue router.beforeEach  → 라우터 이동만 트리거
Next.js middleware     → 페이지 이동 + API 호출 + Server Action 전부 트리거

 

Vue 방식은 한 페이지에 오래 머물며 API만 호출하면 Rolling이 안 된다는 한계가 있다. middleware는 이 문제가 자연스럽게 해소된다.

무엇을 "활동"으로 볼지는 matcher로 결정한다. 정적 에셋(이미지, CSS)과 자동 폴링은 사람의 활동이 아니므로 보통 제외한다.

// 페이지 이동만 활동으로 볼 때
matcher: ['/((?!login|api|_next/static|_next/image).*)']

// API 호출도 활동으로 볼 때
matcher: ['/((?!login|_next/static|_next/image).*)']

 

 

5. 예외 상황 처리

1. 동시에 여러 API가 401을 받는 경우

한 페이지에서 API가 3개가 동시에 날아가다가 토큰이 만료되면 셋 다 401을 받는다. refresh 요청도 3번 중복으로 나간다. 이럴때는 refresh를 한번만 실행해야 한다.

API A ──> 401 ──> refresh 호출
API B ──> 401 ──> refresh 호출  ← 중복
API C ──> 401 ──> refresh 호출  ← 중복

 

let isRefreshing = false
let queue: Array<(token: string) => void> = []

axios.interceptors.response.use(
  res => res,
  async error => {
    if (error.response?.status !== 401) return Promise.reject(error)

    if (isRefreshing) {
      return new Promise(resolve => {
        queue.push((token) => {
          error.config.headers.Authorization = `Bearer ${token}`
          resolve(axios(error.config))
        })
      })
    }

    isRefreshing = true
    const newToken = await refresh()
    isRefreshing = false

    queue.forEach(cb => cb(newToken))
    queue = []

    error.config.headers.Authorization = `Bearer ${newToken}`
    return axios(error.config)
  }
)
API A ──> 401 ──> refresh 실행 (isRefreshing = true)
API B ──> 401 ──> 줄 서서 대기
API C ──> 401 ──> 줄 서서 대기
          ↓
      refresh 완료 → 새 토큰 발급
          ↓
A, B, C 전부 새 토큰으로 재시도

 

2. Refresh Token도 만료된 경우

Access Token 만료 → refresh 시도 → Refresh Token도 만료 → 실패

 

이때는 queue에 쌓인 요청들을 전부 버리고 로그인으로 보내야 한다. 

const newToken = await refresh()

if (!newToken) {
  queue = []
  isRefreshing = false
  redirectToLogin()
  return Promise.reject(error)
}

 

 

3. 서버에서 강제로 토큰을 무효화한 경우

비밀번호 변경, 계정 정지, 관리자 강제 로그아웃 등의 상황이다. 이때는 retry 횟수에 제한을 둬야한다.

let retryCount = 0
const MAX_RETRY = 1

if (retryCount >= MAX_RETRY) {
  redirectToLogin()
  return
}
retryCount++

 

4. 탭 여러개가 동시에 살아 있는 경우

탭 A에서 refresh를 성공해서 새 토큰을 받는 순간, 탭 B도 만료를 감지하고 구버전 refresh token으로 refresh를 시도한다.

탭 A: refresh 성공 → 새 Refresh Token 저장
탭 B: 구버전 Refresh Token으로 refresh 시도 → 서버 거부 → 로그아웃

 

BroadcastChannel로 탭 간 토큰을 공유하면 이 문제를 막을 수 있다.

const channel = new BroadcastChannel('auth')

// 탭 A: refresh 성공 시 다른 탭에 알림
channel.postMessage({ type: 'TOKEN_REFRESHED', token: newToken })

// 탭 B: 새 토큰 받아서 메모리 업데이트
channel.onmessage = (e) => {
  if (e.data.type === 'TOKEN_REFRESHED') {
    accessToken = e.data.token
  }
}

 

 

5. 네트워크 단절 후 복구

오프라인 상태에서 토큰이 만료되고, 네트워크가 복구되는 순간 밀려있던 요청들이 일제히 전송되면 전부 401을 받는다. 1번 케이스와 흐름이 같다. 별도 처리보다 refresh 실패시 로그인으로 유도하는 흐름에 자연스럽게 흡수되며 사용자 입장에서 갑자기 로그인 화면이 뜨면 당황할 수 있으니 알려주는 것이 중요하다.

 

 

결국 모든 예외의 최후 수단은 로그인 페이지로 보내는 것이었고, 그 전에 얼마나 사용자에게 자연스럽게 페이지가 보여지면서 복구를 시도하느냐가 구현의 품질을 결정한다는걸 느꼈다.