본문 바로가기

React

React 앱에서 사용자 세션 타이머 및 자동 로그아웃 구현하기

사용자 세션이 만료되었는데 계속 접근이 가능한 상태는 보안 리스크로 이어집니다. 반대로 너무 공격적인 타임아웃은 사용자 경험을 해칩니다. 이 글에서는 React 앱에서 사용자 활동 기반 세션 타이머와 자동 로그아웃을 실무적으로 구현하는 방법을 설명합니다.

1. 목표와 설계 전략

목표는 두 가지입니다. 첫째, 사용자가 일정 시간 동안 아무 활동이 없으면 자동 로그아웃합니다. 둘째, 토큰의 만료 시간을 기준으로 절대 만료를 강제합니다. 이를 위해 로컬 상태와 localStorage로 탭 간 동기화, 사용자 활동 이벤트, setTimeout 기반 타이머, JWT exp 파싱, 토큰 갱신 훅과 Axios 인터셉터를 결합합니다.

2. 핵심 개념 요약

활동 기반 타임아웃은 사용자의 키보드, 마우스, 터치, 스크롤 등의 이벤트를 감지해 마지막 활동 시각을 갱신합니다. 절대 만료는 서버가 발급한 토큰의 exp를 사용해 강제 로그아웃합니다. cross-tab 동기화를 위해 localStorage와 storage 이벤트를 사용합니다. 백그라운드 탭 정확도를 위해 절대 만료를 신뢰하고, 활동 타이머는 UI가 활성일 때만 민감하게 작동합니다.

3. JWT 만료 시간 파싱 헬퍼

JWT 토큰의 exp 클레임을 파싱해 만료 시점을 밀리초로 얻습니다. base64url 디코딩을 안전하게 처리합니다.

// jwt.js
export function getJwtExpMs(token) {
  try {
    const [, payload] = token.split('.');
    const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
    const json = JSON.parse(decodeURIComponent(escape(atob(base64))));
    if (typeof json.exp === 'number') {
      return json.exp * 1000; // ms
    }
    return null;
  } catch (e) {
    return null;
  }
}

4. 세션 타이머 훅 구현

사용자 활동을 감지하고 idle 타임아웃과 절대 만료를 관리하는 훅입니다. 탭 간 동기화와 토큰 갱신 시점 제어를 포함합니다.

// useSessionTimer.js
import { useEffect, useRef, useState, useCallback } from 'react';

const LS_LAST_ACTIVITY = 'app:lastActivityAt';
const LS_ABSOLUTE_EXP = 'app:absoluteExp';

const DEFAULT_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];

export function useSessionTimer({
  idleTimeoutMs = 15 * 60 * 1000, // 15분
  absoluteExpMs = null, // JWT exp(ms) 또는 null
  refreshBeforeMs = 60 * 1000, // 만료 1분 전 토큰 갱신 시도
  onLogout, // (reason) => void
  onRefreshToken, // () => Promise<{ accessToken, expMs }>
  activityEvents = DEFAULT_EVENTS,
} = {}) {
  const [lastActivityAt, setLastActivityAt] = useState(() => {
    const ls = Number(localStorage.getItem(LS_LAST_ACTIVITY));
    return Number.isFinite(ls) ? ls : Date.now();
  });
  const [absoluteExp, setAbsoluteExp] = useState(() => {
    const ls = Number(localStorage.getItem(LS_ABSOLUTE_EXP));
    return Number.isFinite(ls) ? ls : absoluteExpMs;
  });

  const idleTimerRef = useRef(null);
  const absoluteTimerRef = useRef(null);
  const refreshTimerRef = useRef(null);

  const clearTimers = useCallback(() => {
    [idleTimerRef, absoluteTimerRef, refreshTimerRef].forEach(ref => {
      if (ref.current) {
        clearTimeout(ref.current);
        ref.current = null;
      }
    });
  }, []);

  const broadcastActivity = useCallback((ts) => {
    localStorage.setItem(LS_LAST_ACTIVITY, String(ts));
  }, []);

  const broadcastAbsoluteExp = useCallback((exp) => {
    if (exp) localStorage.setItem(LS_ABSOLUTE_EXP, String(exp));
  }, []);

  const markActivity = useCallback(() => {
    const now = Date.now();
    setLastActivityAt(now);
    broadcastActivity(now);
  }, [broadcastActivity]);

  const scheduleTimers = useCallback(() => {
    clearTimers();

    const now = Date.now();
    const idleRemaining = idleTimeoutMs - (now - lastActivityAt);

    if (idleRemaining > 0) {
      idleTimerRef.current = setTimeout(() => {
        onLogout?.('idle');
      }, idleRemaining);
    } else {
      onLogout?.('idle');
    }

    if (absoluteExp && Number.isFinite(absoluteExp)) {
      const absoluteRemaining = absoluteExp - now;
      if (absoluteRemaining > 0) {
        absoluteTimerRef.current = setTimeout(() => {
          onLogout?.('absolute');
        }, absoluteRemaining);
        if (onRefreshToken && refreshBeforeMs > 0) {
          const refreshIn = Math.max(absoluteRemaining - refreshBeforeMs, 0);
          refreshTimerRef.current = setTimeout(async () => {
            try {
              const res = await onRefreshToken();
              if (res?.expMs) {
                setAbsoluteExp(res.expMs);
                broadcastAbsoluteExp(res.expMs);
                scheduleTimers();
              }
            } catch (e) {
              onLogout?.('refresh-failed');
            }
          }, refreshIn);
        }
      } else {
        onLogout?.('absolute');
      }
    }
  }, [idleTimeoutMs, lastActivityAt, absoluteExp, refreshBeforeMs, onRefreshToken, onLogout, clearTimers, broadcastAbsoluteExp]);

  useEffect(() => {
    const onActivity = () => {
      // 숨김 탭에서도 활동으로 간주하지 않으려면 조건을 추가할 수 있습니다.
      // if (document.visibilityState === 'hidden') return;
      markActivity();
      scheduleTimers();
    };

    activityEvents.forEach(evt => window.addEventListener(evt, onActivity, { passive: true }));
    const onVisibility = () => {
      // 다시 활성화되면 타이머 재계산
      scheduleTimers();
    };
    document.addEventListener('visibilitychange', onVisibility);

    const onStorage = (e) => {
      if (e.key === LS_LAST_ACTIVITY && e.newValue) {
        const ts = Number(e.newValue);
        if (Number.isFinite(ts)) {
          setLastActivityAt(ts);
          scheduleTimers();
        }
      }
      if (e.key === LS_ABSOLUTE_EXP && e.newValue) {
        const exp = Number(e.newValue);
        if (Number.isFinite(exp)) {
          setAbsoluteExp(exp);
          scheduleTimers();
        }
      }
    };
    window.addEventListener('storage', onStorage);

    scheduleTimers();

    return () => {
      activityEvents.forEach(evt => window.removeEventListener(evt, onActivity));
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('storage', onStorage);
      clearTimers();
    };
  }, [activityEvents, markActivity, scheduleTimers, clearTimers]);

  const remainingIdleMs = Math.max(idleTimeoutMs - (Date.now() - lastActivityAt), 0);
  const remainingAbsoluteMs = absoluteExp ? Math.max(absoluteExp - Date.now(), 0) : null;

  return {
    lastActivityAt,
    absoluteExp,
    remainingIdleMs,
    remainingAbsoluteMs,
    markActivity,
    setAbsoluteExp: (exp) => {
      setAbsoluteExp(exp);
      broadcastAbsoluteExp(exp);
      scheduleTimers();
    },
  };
}

5. Axios 인터셉터로 토큰 갱신 연결

401 응답 시 토큰을 한 번 갱신하고 세션 타이머의 절대 만료를 업데이트합니다. 동시 요청은 큐잉해 한 번만 갱신합니다.

// api.js
import axios from 'axios';

let refreshing = null;
let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

export const api = axios.create({ baseURL: '/api' });

api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers['Authorization'] = `Bearer ${accessToken}`;
  }
  return config;
});

export function attachRefreshInterceptor({ refreshFn, onRefreshed, onLogout }) {
  api.interceptors.response.use(
    (res) => res,
    async (error) => {
      const { config, response } = error || {};
      if (response?.status !== 401 || config.__retry) {
        throw error;
      }
      if (!refreshing) {
        refreshing = refreshFn()
          .then((data) => {
            refreshing = null;
            setAccessToken(data.accessToken);
            onRefreshed?.(data);
            return data;
          })
          .catch((e) => {
            refreshing = null;
            onLogout?.('refresh-failed');
            throw e;
          });
      }
      const data = await refreshing;
      config.__retry = true;
      config.headers['Authorization'] = `Bearer ${data.accessToken}`;
      return api(config);
    }
  );
}

6. App 적용 예시

로그인 후 토큰의 exp를 세션 타이머에 설정하고, 남은 시간 UI를 표시합니다. 만료 시 로그아웃 처리와 리다이렉트를 수행합니다.

// App.jsx
import React, { useMemo, useState, useEffect } from 'react';
import { useSessionTimer } from './useSessionTimer';
import { getJwtExpMs } from './jwt';
import { api, setAccessToken, attachRefreshInterceptor } from './api';

export default function App() {
  const [user, setUser] = useState(null);

  const session = useSessionTimer({
    idleTimeoutMs: 10 * 60 * 1000, // 10분
    onLogout: (reason) => {
      alert(`로그아웃되었습니다: ${reason}`);
      setUser(null);
      setAccessToken(null);
      localStorage.removeItem('app:absoluteExp');
      localStorage.removeItem('app:lastActivityAt');
      window.location.assign('/login');
    },
    onRefreshToken: async () => {
      const res = await api.post('/auth/refresh');
      const { accessToken } = res.data;
      setAccessToken(accessToken);
      const expMs = getJwtExpMs(accessToken);
      return { accessToken, expMs };
    },
  });

  useEffect(() => {
    attachRefreshInterceptor({
      refreshFn: async () => {
        const res = await api.post('/auth/refresh');
        const { accessToken } = res.data;
        const expMs = getJwtExpMs(accessToken);
        return { accessToken, expMs };
      },
      onRefreshed: ({ accessToken, expMs }) => {
        setAccessToken(accessToken);
        session.setAbsoluteExp(expMs);
      },
      onLogout: session.onLogout, // 훅에서 받은 onLogout을 그대로 사용 가능하도록 설계했다면 대체하세요.
    });
  }, []);

  const onLogin = async (email, password) => {
    const res = await api.post('/auth/login', { email, password });
    const { accessToken, profile } = res.data;
    setAccessToken(accessToken);
    setUser(profile);
    const expMs = getJwtExpMs(accessToken);
    session.setAbsoluteExp(expMs);
  };

  const formatMs = (ms) => {
    const s = Math.floor(ms / 1000);
    const m = String(Math.floor(s / 60)).padStart(2, '0');
    const r = String(s % 60).padStart(2, '0');
    return `${m}:${r}`;
  };

  if (!user) {
    return (
      <div>
        <h1>로그인</h1>
        <button onClick={() => onLogin('test@example.com', 'pass')}>테스트 로그인</button>
      </div>
    );
  }

  return (
    <div>
      <h1>대시보드</h1>
      <p>안녕하세요, {user.name}님</p>
      {session.remainingAbsoluteMs != null && (
        <p>토큰 만료까지: {formatMs(session.remainingAbsoluteMs)}</p>
      )}
      <p>활동 없을 시 자동 로그아웃까지: {formatMs(session.remainingIdleMs)}</p>
      <button onClick={session.markActivity}>활동 표시</button>
      <button onClick={() => session.onLogout?.('manual')}>로그아웃</button>
    </div>
  );
}

7. 서버와 클라이언트 동기화 팁

서버 시간이 클라이언트와 다를 수 있습니다. 가능하면 서버가 exp를 클라이언트에 명시적으로 내려주고, 클라이언트는 그 값을 신뢰합니다. refreshBeforeMs는 트래픽과 UX를 고려해 30~120초 범위로 조정합니다.

8. 테스트 체크리스트

활동 이벤트가 발생할 때 idle 타이머가 정상적으로 연장되는지 확인합니다. JWT exp가 지난 경우 즉시 로그아웃되는지 검증합니다. 탭 두 개를 열고 한 탭에서 로그아웃할 때 다른 탭도 동기화되는지 확인합니다. 백그라운드 탭에서 idle 타이머가 과도하게 지연되지 않는지 점검합니다. 401 응답 후 한 번만 갱신되고 모든 대기 요청이 재시도되는지 확인합니다.

9. 접근성 및 보안 모범 사례

세션 만료 직전 사용자에게 남은 시간을 알려주고 연장할 수 있는 버튼을 제공합니다. 민감한 페이지에서는 짧은 idleTimeoutMs를 적용하고, 공용 컴퓨터 사용을 가정한다면 로그아웃 후 캐시를 비웁니다. localStorage에 토큰을 저장하지 말고 메모리와 httpOnly 쿠키 조합을 고려합니다.

10. 자주 하는 실수와 방지법

setInterval로 타이머를 돌리면 드리프트가 커질 수 있어 setTimeout 재계산 방식이 안전합니다. storage 이벤트를 누락하면 탭 간 로그아웃이 분리됩니다. visibilitychange 처리 없이 활동 이벤트만 의존하면 백그라운드 탭에서 타이머가 비정상적으로 지연될 수 있습니다. refresh 실패 시 무한 루프 대신 즉시 로그아웃으로 폴백합니다.

11. 마무리

위 구조를 적용하면 React 앱에서 사용자 활동 기반 자동 로그아웃과 토큰 만료 관리가 견고해집니다. 비즈니스와 보안 요구에 맞춰 idleTimeoutMs, refreshBeforeMs를 조정하고, 서버와의 계약을 명확히 해 최적의 UX와 보안을 달성하시기 바랍니다.