[JWT] refresh token으로 로그아웃 구현

minjonyyy·2025년 8월 6일

Node.js 백엔드에서 Refresh Token 기반 로그아웃 처리 및 세션 정리 전략

JWT 기반 인증 시스템에서는 Access Token이 만료되었을 때 Refresh Token으로 재발급을 진행하게 됩니다.
이 구조에서 "로그아웃" 기능을 구현하기 위해서는 Refresh Token을 서버에서 제거하거나 무효화해야 합니다.
이 포스트에서는 아래와 같은 기능을 중심으로 안전한 로그아웃 및 세션 정리 방식을 정리합니다.

  • Refresh Token 기반 백엔드 로그아웃 처리
  • Rate Limiting으로 악성 요청 방어
  • 만료된 세션 정리를 위한 스케줄러
  • 프론트엔드 연동 처리

✅ 1. 백엔드: Refresh Token 기반 로그아웃 구현

Access Token이 유효한 경우는 req.user에서 userId를 추출하고, 만료된 경우에는 Body에 담긴 refreshToken을 이용해 디코딩 후 userId를 얻습니다.

// user.controller.js
export const logout = async (req, res) => {
  try {
    let userId;

    if (req.user?.userId) {
      userId = req.user.userId;
    } else if (req.body.refreshToken) {
      try {
        const jwt = await import('jsonwebtoken');
        const decoded = jwt.verify(
          req.body.refreshToken,
          process.env.REFRESH_TOKEN_SECRET_KEY
        );
        userId = decoded.userId;
      } catch (error) {
        return errorResponse(res, new Error('유효하지 않은 Refresh Token입니다.'));
      }
    } else {
      return errorResponse(res, new Error('인증 정보가 필요합니다.'));
    }

    const result = await userService.logout(userId);
    return successResponse(res, result, '로그아웃이 완료되었습니다.');
  } catch (error) {
    return errorResponse(res, error);
  }
};

추가 정보

  • 보안 상 Refresh Token은 서버에서 관리되거나 Redis 등 저장소에서 만료 시간 기반으로 관리되는 것이 안전합니다.
  • 디코딩만으로는 위조된 토큰일 수 있으므로 verify 과정을 반드시 거쳐야 합니다.

🚫 2. 로그아웃 API에 Rate Limiting 적용

비정상적인 로그아웃 요청을 방지하기 위해 express-rate-limit 미들웨어를 사용해 15분 동안 최대 5번만 시도 가능하도록 설정합니다.

// user.router.js
import rateLimit from 'express-rate-limit';

const logoutLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 5,
  message: '너무 많은 로그아웃 시도가 있었습니다. 잠시 후 다시 시도해주세요.',
  standardHeaders: true,
  legacyHeaders: false,
});

router.post('/logout', logoutLimiter, userController.logout);

💡 추가 정보

  • login, logout, password reset API에는 반드시 rate limiting을 적용해 DoS를 방지해야 합니다.

🧹 3. 만료된 Refresh Token 정리 스케줄러

서버 DB에 저장된 Refresh Token이 유효기간이 지나도 남아있다면, 악의적인 재사용의 가능성이 생깁니다.
따라서 만료된 토큰을 주기적으로 삭제하는 스케줄러가 필요합니다.

// session-cleanup.util.js
export const cleanupExpiredTokens = async () => {
  const connection = await mysql.createConnection(dbConfig);
  const [result] = await connection.query(\`
    UPDATE users 
    SET refresh_token = NULL 
    WHERE refresh_token IS NOT NULL 
    AND JSON_EXTRACT(JWT_DECODE(refresh_token), '$.exp') < UNIX_TIMESTAMP()
  \`);
  console.log(\`✅ 만료된 토큰 정리 완료: \${result.affectedRows}개 토큰 제거\`);
};

이 cleanup 함수는 앱 시작 시 24시간마다 한 번씩 실행되도록 설정합니다.

export const startSessionCleanupScheduler = () => {
  const cleanupInterval = 24 * 60 * 60 * 1000;
  const runCleanup = async () => {
    try {
      await cleanupExpiredTokens();
    } catch (error) {
      console.error('세션 정리 스케줄러 오류:', error);
    }
  };
  runCleanup();
  setInterval(runCleanup, cleanupInterval);
};

🚀 4. 서버 시작 시 세션 정리 스케줄러 자동 실행

서버 실행 코드에서 startSessionCleanupScheduler()를 호출해 스케줄러가 자동으로 동작하도록 설정합니다.

// server.js or main.ts
app.listen(port, '0.0.0.0', () => {
  console.log(\`✅ Server is running on http://localhost:\${port}\`);
  startSessionCleanupScheduler();
});

💻 5. 프론트엔드 로그아웃 처리

프론트엔드는 Access Token이 만료되었을 경우에도 Refresh Token만 있으면 로그아웃이 가능해야 합니다.

// auth.ts
export const logout = async (refreshToken?: string): Promise<ApiResponse<any>> => {
  const data = refreshToken ? { refreshToken } : {};
  const response = await api.post<ApiResponse<any>>('/users/logout', data);
  return response.data;
};

프론트엔드에서 로그아웃 시도 후에는 상태를 무조건 초기화합니다.

// Header.tsx
const handleLogout = async () => {
  try {
    const refreshToken = localStorage.getItem('refreshToken') || undefined;
    await logout(refreshToken);
  } catch (error) {
    console.error('로그아웃 API 호출 실패:', error);
  } finally {
    logoutStore();
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    window.location.href = '/';
  }
};

🧠 마무리 정리

기능구현 방식
로그아웃 처리Access Token / Refresh Token 기반
악성 요청 방어express-rate-limit
세션 정리만료 토큰 삭제 SQL + 스케줄러
프론트 상태 초기화로그아웃 성공 여부와 관계없이 초기화

🔒 보안 팁

  • Refresh Token은 탈취될 경우 큰 보안 위협이 될 수 있으므로 HttpOnly 쿠키 또는 암호화된 저장소에 보관하는 것이 이상적입니다.
  • 서버 측에서 Refresh Token을 명시적으로 관리하는 경우, 토큰 사용 이력을 기록하여 이상 징후를 감지할 수도 있습니다.

💭 개선 방안

✅ 1. Refresh Token 저장 방식 개선

🔍 현재 구조

DB에 refresh_token을 그대로 저장하고 있음
로그아웃 시 null로 업데이트하고, 만료 시 정리하는 방식

⚠️ 문제점

평문 JWT 저장은 보안적으로 안전하지 않음 (탈취 시 위험)
DB I/O 비용 발생

✅ 개선 방안

토큰을 해시(SHA256 등)해서 저장 → 비교 시에도 해시 비교로 대응
Redis 등 In-Memory 저장소에 토큰을 저장하고, 만료 시간 설정하여 자동 정리

🧠 Refresh Token은 쿠키에 저장할 땐 HttpOnly + Secure 속성을 사용하고, 서버엔 평문 저장보다는 해싱이 기본입니다.


✅ 2. Token 재사용 방지 전략 (Replay Attack 방어)

🔍 현재 구조

로그아웃 요청 시 토큰을 삭제하지만, 이미 유출된 토큰은 재사용 가능

✅ 개선 방안

Refresh Token에 JWT ID(jti)를 부여하고, 사용 시마다 무효화
사용된 토큰을 Redis에 넣고 일정 시간 동안 블랙리스트화

// 예: refreshToken jti를 Redis에서 invalidate 목록으로 관리
SET used_token:{jti} true EX 600

✅ 3. JWT 토큰 만료 이후 상태 처리 고도화

🔍 현재 구조

Access Token 만료 시에도 Refresh Token만 있다면 로그아웃 가능

✅ 개선 방안

토큰 만료 상태를 명확히 구분하여 사용자에게 안내 메시지를 전달
프론트엔드에서 401 Unauthorized 발생 시 → 토큰 만료 여부 판단 후 자동 갱신 or 강제 로그아웃 흐름으로 분기


✅ 4. 세션 정리 스케줄러 → DB 종속 구조 개선

🔍 현재 구조

DB에 직접 SQL로 만료된 Refresh Token 정리

✅ 개선 방안

DB가 JWT 구조를 파싱해야 하는 구조는 권장되지 않음
차라리 토큰 발급 시 만료 시간을 별도 컬럼 (refresh_token_exp) 으로 저장
이후 WHERE refresh_token_exp < NOW()로 정리

🔎 MySQL은 JWT_DECODE()를 지원하지 않으므로 위 쿼리는 실제 환경에서는 별도 함수나 로직이 필요합니다.

✅ 5. 스케줄러 실행 방식 개선

🔍 현재 구조

setInterval로 24시간마다 동작하는 방식

✅ 개선 방안

node-cron 등 정식 스케줄러 라이브러리 사용 권장
로그 출력, 에러 로깅, Slack 알림 등과 연동 가능

import cron from 'node-cron';

cron.schedule('0 0 * * *', async () => {
  await cleanupExpiredTokens();
});

✅ 6. Rate Limiting → 사용자 기반 분리

🔍 현재 구조

IP 기반 rate limit (기본 설정)

⚠️ 문제점

여러 사용자가 같은 NAT망(IP)에서 요청 시 전체가 막힐 수 있음

✅ 개선 방안

userId 기반 rate limit 또는 API Key 기반 Rate Limiting 적용 고려


✅ 7. 보안 이벤트 로깅 및 알림 시스템

누가 언제 로그아웃을 시도했는지에 대한 로깅 필요
Refresh Token이 사용된 이력, 실패 로그, 로그인 시도 횟수 등

예: 로그아웃 실패가 5회 이상일 경우 관리자에게 슬랙 알림


✅ 8. OAuth / 소셜 로그인 대응 여부

현재 구조는 자체 로그인 기준이지만, Google, Kakao 등 OAuth 기반 로그인 시스템이 있다면 Refresh Token 관리 구조가 완전히 달라집니다.

외부 Provider에서는 토큰 무효화를 직접 할 수 없음
-> 자체 서버에서 세션 관리하는 방식 고려해야 함


Velog by @minjonyyy

0개의 댓글