yoowonyoungabout

30분 세션 타임아웃 구현 — 순수 타이머 모듈과 Zustand 구독의 조합

React 밖에서 동작하는 타이머와 Zustand subscribe

들어가며

백오피스 시스템에서 세션 타임아웃은 단순한 편의 기능이 아니라 보안 요구사항이다. 자리를 비운 사이 누군가 화면을 조작하는 것을 방지해야 한다. 하지만 구현해보면 의외로 고려할 것이 많다. 타이머는 어디에 둘 것인가, 활동 감지는 어떻게 할 것인가, 경고 다이얼로그는 누가 트리거하는가, 인증 상태와 어떻게 연동하는가.

이 글에서는 React 컴포넌트에 의존하지 않는 순수 타이머 모듈을 설계하고, Zustand 구독으로 인증 상태와 연동한 과정을 정리한다.


1. 요구사항

항목 내용
세션 만료 30분 동안 활동이 없으면 자동 로그아웃
만료 경고 만료 5분 전 경고 다이얼로그 표시
활동 감지 마우스, 키보드, 클릭, 스크롤, 터치 이벤트
경고 응답 “계속 사용” → 타이머 리셋, “로그아웃” → 즉시 로그아웃
강제 만료 경고를 무시해도 30분 도달 시 강제 로그아웃
인증 연동 로그인 시 타이머 시작, 로그아웃 시 타이머 중지

2. 설계: React 바깥의 순수 타이머

세션 타임아웃의 핵심 로직은 “마지막 활동 시점으로부터 N분이 지났는가”를 판단하는 것이다. 이것은 React의 렌더링 사이클과 무관한 관심사다.

왜 React 훅으로 만들지 않았는가

// [X] Bad: 훅으로 구현하면 생기는 문제
const useSessionTimeout = () => {
  useEffect(() => {
    const timer = setTimeout(handleTimeout, 30 * 60 * 1000)
    // 이벤트 리스너 등록...
    return () => clearTimeout(timer)
  }, [])
}
  • 훅은 컴포넌트가 마운트되어 있을 때만 동작한다
  • 라우트 전환 시 컴포넌트가 언마운트되면 타이머가 초기화된다
  • 전역 상태(인증)와의 연동이 복잡해진다

세션 타임아웃은 앱의 전체 생명주기에 걸쳐 동작해야 하므로, React 컴포넌트 트리 바깥에 두는 것이 자연스럽다.

순수 타이머 모듈

// src/shared/lib/session-timeout.ts

const SESSION_TIMEOUT_MS = 30 * 60 * 1000 // 30분
const WARNING_BEFORE_MS = 5 * 60 * 1000   // 만료 5분 전 경고

const ACTIVITY_EVENTS: (keyof WindowEventMap)[] = [
  'mousemove', 'keydown', 'click', 'scroll', 'touchstart',
]

let timeoutTimerId: ReturnType<typeof setTimeout> | null = null
let warningTimerId: ReturnType<typeof setTimeout> | null = null
let isActive = false
let onWarningCallback: (() => void) | null = null
let onTimeoutCallback: (() => void) | null = null

상태는 모듈 스코프 변수로 관리한다. React state도, Zustand store도 아니다. 이 타이머는 어떤 UI 프레임워크에도 의존하지 않는다.

타이머 리셋 로직

const resetTimer = () => {
  if (!isActive) return

  clearTimers()
  warningTimerId = setTimeout(handleWarning, SESSION_TIMEOUT_MS - WARNING_BEFORE_MS)
  timeoutTimerId = setTimeout(handleTimeout, SESSION_TIMEOUT_MS)
}

활동이 감지될 때마다 두 타이머를 모두 리셋한다.

  • 경고 타이머: 25분 후 발동 (30분 - 5분)
  • 만료 타이머: 30분 후 발동

공개 API

export const sessionTimeout = {
  start() {
    if (isActive) return
    isActive = true

    for (const event of ACTIVITY_EVENTS) {
      window.addEventListener(event, resetTimer, { passive: true })
    }
    resetTimer()
  },

  stop() {
    if (!isActive) return
    isActive = false

    for (const event of ACTIVITY_EVENTS) {
      window.removeEventListener(event, resetTimer)
    }
    clearTimers()
  },

  reset() {
    resetTimer()
  },

  onWarning(callback: () => void) {
    onWarningCallback = callback
  },

  onTimeout(callback: () => void) {
    onTimeoutCallback = callback
  },
}

콜백 등록 패턴에 주목하자. onWarningonTimeout은 콜백을 받아 저장만 한다. 타이머 모듈은 "만료 시 무엇을 할 것인가"를 모른다. 로그아웃, 다이얼로그 표시 등은 호출하는 쪽이 결정한다.

{ passive: true }를 쓰는 이유

활동 감지 이벤트(mousemove, scroll, touchstart)는 초당 수십~수백 회 발생할 수 있다. passive: true는 브라우저에게 “이 리스너는 preventDefault()를 호출하지 않는다”고 알려주어, 스크롤 성능 저하를 방지한다.


3. 인증 상태와의 연동

타이머 모듈은 순수하게 유지하되, 인증 상태 변화에 따라 시작/중지되어야 한다. 이 연동은 별도의 setup 파일에서 처리한다.

// src/app/providers/session-timeout-setup.ts

import { useSessionStore } from '@/entities/session'
import { sessionTimeout } from '@/shared/lib/session-timeout'
import { useToastStore } from '@/shared/store/useToastStore'

경고 다이얼로그

let isWarningOpen = false

sessionTimeout.onWarning(async () => {
  if (isWarningOpen) return
  isWarningOpen = true

  const confirmed = await useToastStore.getState().showConfirm({
    title: '세션 만료 안내',
    description: '5분간 활동이 없으면 자동으로 로그아웃됩니다.\n계속 사용하시겠습니까?',
    variant: 'warning',
    confirmText: '계속 사용',
    cancelText: '로그아웃',
  })

  isWarningOpen = false

  if (confirmed) {
    sessionTimeout.reset()
  } else {
    useSessionStore.getState().logout()
    window.location.href = '/login'
  }
})

강제 만료

sessionTimeout.onTimeout(() => {
  // 경고 다이얼로그가 열려있든 아닌든 강제 로그아웃
  useToastStore.getState().hideToast()
  isWarningOpen = false
  useSessionStore.getState().logout()
  window.location.href = '/login'
})

경고 무시 시나리오를 처리한다. 25분에 경고 다이얼로그가 뜨고, 사용자가 아무 버튼도 누르지 않으면 30분에 onTimeout이 발동한다. 이때 경고 다이얼로그를 강제로 닫고(hideToast), isWarningOpen을 초기화한 뒤 로그아웃한다.

Zustand 구독으로 자동 시작/중지

useSessionStore.subscribe((state) => {
  if (state.isAuthenticated) {
    sessionTimeout.start()
  } else {
    sessionTimeout.stop()
  }
})

// 현재 인증 상태에 따라 초기화
if (useSessionStore.getState().isAuthenticated) {
  sessionTimeout.start()
}

이 코드가 이 설계의 핵심이다. Zustand의 subscribe는 React 컴포넌트 바깥에서도 동작한다. 모듈이 import되는 시점에 구독이 설정되고, 이후 인증 상태가 바뀔 때마다 타이머가 자동으로 시작/중지된다.


4. 타임라인으로 보는 전체 흐름

sequenceDiagram participant U as 사용자 participant S as SessionStore participant T as sessionTimeout participant D as 경고 다이얼로그 U->>S: 로그인 S->>T: subscribe → start() T->>T: 이벤트 리스너 등록 + 타이머 시작 Note over U,T: 사용자 활동 중 → resetTimer() 반복 Note over T: 25분 경과 (활동 없음) T->>D: onWarning() → showConfirm() D->>U: "계속 사용하시겠습니까?" alt 계속 사용 U->>D: "계속 사용" 클릭 D->>T: reset() Note over T: 타이머 30분 재시작 else 로그아웃 U->>D: "로그아웃" 클릭 D->>S: logout() S->>T: subscribe → stop() else 무응답 Note over T: 30분 경과 T->>S: onTimeout() → logout() S->>T: subscribe → stop() end

5. 왜 이 구조가 좋은가

관심사 분리

모듈 관심사 아는 것 모르는 것
session-timeout.ts 타이머 + 활동 감지 시간, DOM 이벤트 인증, UI, 로그아웃
session-timeout-setup.ts 연동 + UI 처리 세션 스토어, 토스트 타이머 내부 구현
useSessionStore 인증 상태 토큰, 사용자 정보 타임아웃 존재 자체

타이머 모듈은 React를 모르고, 세션 스토어는 타임아웃을 모른다. 둘을 연결하는 setup 파일만이 양쪽을 알고 있다.

테스트 용이성

타이머 모듈은 DOM 이벤트와 setTimeout만 사용하므로, React 없이 단위 테스트가 가능하다.

// 테스트 예시
vi.useFakeTimers()
sessionTimeout.onTimeout(mockCallback)
sessionTimeout.start()

vi.advanceTimersByTime(30 * 60 * 1000)
expect(mockCallback).toHaveBeenCalledOnce()

라우트 전환에 영향받지 않음

훅 기반이었다면 페이지 전환 시 컴포넌트가 언마운트되면서 타이머가 초기화될 수 있다. 모듈 스코프에 두었기 때문에 앱이 살아있는 한 타이머도 유지된다.


6. 정리

세션 타임아웃을 구현할 때 핵심은 “타이머 로직을 React 렌더링 사이클에서 분리하는 것”이다.

결정 이유
순수 모듈 (훅 아님) 라우트 전환에 영향받지 않고, 앱 전체 생명주기에 걸쳐 동작
콜백 패턴 타이머가 UI/인증을 몰라도 되므로, 관심사 분리 + 테스트 용이
Zustand subscribe React 컴포넌트 바깥에서 인증 상태 변화를 감지하여 타이머 자동 연동
passive 이벤트 리스너 활동 감지가 스크롤 성능을 저하시키지 않도록 보장
경고 + 강제 만료 이중 처리 경고를 무시해도 반드시 로그아웃되는 안전장치

이 패턴은 세션 타임아웃뿐 아니라, 앱 전체 생명주기에 걸쳐 동작해야 하는 모든 기능(웹소켓 연결 관리, 글로벌 이벤트 버스 등)에 동일하게 적용할 수 있다. 핵심은 순수 모듈로 로직을 분리하고, 프레임워크와의 연동은 별도의 setup 파일에서 처리하는 것이다.