프론트엔드에서 세션 타임아웃을 구현할 때 흔히 떠올리는 방법은 setInterval로 주기적으로 "만료됐나?"를 확인하는 방식이다. 동작은 하지만, 조금 더 들여다보면 개선할 여지가 꽤 있다.
이 글은 실제로 운영 중인 서비스에서 세션 타임아웃 로직을 리팩토링하면서 고민한 내용을 정리한 것이다.
기존 코드의 문제점
1. 폴링 방식의 부정확성
setInterval(isExceedTimeout, 60000); // 60초마다 체크
60초마다 만료 여부를 확인하는 방식은 최대 60초의 오차가 생긴다. 사용자가 59초에 로그아웃됐어야 하는 상황인데, 다음 체크 시점까지 1분을 더 기다리는 셈이다. 반대로 활성 세션인데도 매 60초마다 불필요하게 실행된다.
2. mousemove 이벤트의 과도한 처리
document.addEventListener('mousemove', updateLastOperationTime);
mousemove는 마우스를 1px 움직일 때마다 발생한다. 쓰로틀링 없이 Date.now()를 갱신하는 리스너가 초당 수십~수백 번 실행되는 구조다.
3. 단일 책임 원칙 위반
async function isExceedTimeout() {
if (timeout < Date.now() - lastOperationTime) {
localStorage.removeItem('accessToken');
await Message.alertMessage(...);
location.href = Path.login;
}
}
타임아웃 확인 함수가 localStorage 삭제, 알림 표시, 페이지 이동까지 직접 처리하고 있다. 역할이 하나여야 할 함수가 너무 많은 일을 한다.
4. 불필요한 상태 관리
let lastOperationTime = null;
마지막 활동 시간을 별도 변수로 관리하면서, 폴링 시마다 현재 시간과 비교하는 구조다. 타이머 핸들 하나로 충분한 정보를 굳이 시간 값으로 환산해서 비교하고 있다.
개선 방향
핵심 아이디어: setTimeout 재설정 패턴
폴링 대신, 사용자 활동이 감지될 때마다 타이머를 재설정하는 방식으로 전환한다.
// 기존: "지금 만료됐나?" 를 주기적으로 확인
setInterval(isExceedTimeout, 60000);
// 개선: "활동이 있으면 만료 시계를 다시 돌린다"
function resetTimer() {
clearTimeout(timeoutHandle);
timeoutHandle = setTimeout(onSessionExpired, SESSION_TIMEOUT);
}
활동이 없으면 타이머가 그대로 흘러 정확히 1시간 후에 만료된다. 오차가 없고, 불필요한 폴링도 사라진다.
이벤트 쓰로틀링
타임아웃이 1시간(3,600,000ms)인 환경에서 mousemove를 1초 단위로 처리하는 것조차 과하다. 30초 단위로 넉넉하게 잡아도 세션 관리에 전혀 문제가 없다.
function throttle(fn, delay) {
let lastCall = 0;
return (...args) => {
if (Date.now() - lastCall >= delay) {
lastCall = Date.now();
fn(...args);
}
};
}
function registerActivityListeners() {
const throttledReset = throttle(resetTimer, 30000); // 30초에 1번
document.addEventListener('click', resetTimer);
document.addEventListener('mousemove', throttledReset);
document.addEventListener('keydown', throttledReset);
}
click은 상대적으로 드물게 발생하므로 쓰로틀 없이 즉시 반응하도록 두고, mousemove와 keydown은 30초 간격으로 제한한다.
만료 시 처리: 모달 vs 바로 이동
처음에는 기존 코드처럼 "세션이 만료되었습니다" 모달을 띄우는 방식을 고려했다. 그런데 생각해보면, 모달을 확인한 이후에도 결국 로그인 페이지로 이동한다. 사용자가 확인 버튼을 눌러야 한다는 불필요한 액션이 하나 추가되는 셈이다.
더 깔끔한 방법은 로그인 페이지에서 이유를 보여주는 것이다.
function onSessionExpired() {
location.href = `${Path.login}?reason=session_expired`;
}
// 로그인 페이지에서
const reason = new URLSearchParams(location.search).get('reason');
if (reason === 'session_expired') {
// 페이지 상단에 토스트나 인라인 메시지로 표시
}
사용자 흐름이 모달 확인 → 로그인 페이지에서 로그인 페이지 (메시지 포함)로 한 단계 줄어든다. 전달하고 싶은 정보는 그대로 전달하면서.
파일 분리: time.js → session.js
기존에는 세션 타임아웃 로직이 time.js 안에 있었다. time.js의 다른 함수들을 보면 성격이 다르다.
toLocalTime,convertUtcToLocalDate,formatToLocalDateTime— 순수한 시간 변환 유틸리티startTimeoutTimer,registerActivityListeners— 인증/세션 관리 로직
"시간을 다룬다"는 공통점은 있지만, 역할이 다르다. 세션 정책이 바뀌거나 타임아웃 처리 방식을 수정할 때 time.js를 건드릴 이유가 없어야 한다. session.js로 분리한다.
최종 코드
// session.js
import { Path } from './path.js';
const SESSION_TIMEOUT = 3600000; // 1시간
let timeoutHandle = null;
function onSessionExpired() {
location.href = `${Path.login}?reason=session_expired`;
}
function resetTimer() {
clearTimeout(timeoutHandle);
timeoutHandle = setTimeout(onSessionExpired, SESSION_TIMEOUT);
}
function throttle(fn, delay) {
let lastCall = 0;
return (...args) => {
if (Date.now() - lastCall >= delay) {
lastCall = Date.now();
fn(...args);
}
};
}
function registerActivityListeners() {
const throttledReset = throttle(resetTimer, 30000);
document.addEventListener('click', resetTimer);
document.addEventListener('mousemove', throttledReset);
document.addEventListener('keydown', throttledReset);
}
function initSession() {
resetTimer();
registerActivityListeners();
}
export { initSession };
// 사용하는 쪽
import { initSession } from './session.js';
function initializePage() {
initSession();
// ...
}
startTimeoutTimer와 registerActivityListeners를 각각 알 필요 없이, 호출하는 쪽에서는 "세션을 초기화한다"는 의도만 표현하면 된다. 두 함수는 항상 같이 호출되는 것이기 때문에, 외부에 노출할 이유가 없다.
정리
| 기존 | 개선 | |
|---|---|---|
| 만료 감지 방식 | 60초마다 폴링 | 활동 없으면 정확히 1시간 후 |
| 오차 | 최대 60초 | 없음 |
| mousemove 처리 | 무제한 실행 | 30초에 1번으로 제한 |
| 만료 시 처리 | 모달 → 이동 | 바로 이동 (로그인 페이지에서 안내) |
| 상태 관리 | lastOperationTime 변수 |
타이머 핸들 하나 |
| 외부 인터페이스 | 함수 2개 노출 | initSession 하나로 통합 |
작은 유틸리티 함수 하나에서 시작했지만, 들여다보니 패턴, 성능, UX, 파일 구조까지 꽤 많은 것을 다시 생각하게 된 작업이었다.