개발 slecs

토큰 버전 관리로 세션 만료 정책과 강제 로그아웃 도입

목차

세션 만료 정책과 강제 로그아웃 인프라를 한 번에 묶어서 배포했다.

작업 자체는 단순해 보이지만, 이게 건드리는 게 auth 전반이라 꽤 신경 썼던 작업이다. package.json, prisma/schema.prisma, migration.sql, auth-actions.ts, 그리고 어드민 UI까지 — 파일 스펙트럼이 DB 레이어부터 UI까지 수직으로 관통한다.

왜 지금 이 작업이었나

세션 정책이 느슨한 상태로 방치되고 있다는 건 알고 있었다. 로그인 후 세션이 사실상 무기한 유지되는 구조였고, 관리자가 특정 계정을 강제로 로그아웃시킬 방법도 없었다. 보안 감사 항목에 계속 올라오던 이슈였는데, 다른 피처 작업에 밀려서 미뤄두고 있었다.

결정적으로 움직이게 된 건 팀 내부 코드리뷰에서 "지금 세션 구조면 탈취된 토큰을 서버에서 무효화할 방법이 없다"는 코멘트가 나왔을 때다. 맞는 말이었다. 그냥 더 미루기엔 리스크가 너무 컸다.

작업 내용

크게 두 가지를 묶었다.

① 세션 maxAge 8시간 고정

기존에는 세션 만료 시간이 명시적으로 설정되어 있지 않아 라이브러리 기본값에 의존하고 있었다. 이걸 8h로 명시적으로 박았다.

// src/lib/auth-actions.ts (개념 예시)
session: {
  strategy: "jwt",
  maxAge: 8 * 60 * 60, // 8h
},

사내 서비스 특성상 업무 시간 기준으로 하루 세션이면 충분하다는 판단이었다. 너무 짧으면 UX 불편이고, 너무 길면 보안 리스크. 8h는 그 트레이드오프에서 팀이 합의한 값이다.

② token versioning으로 강제 로그아웃 구현

이게 이번 작업의 핵심이다. 강제 로그아웃을 구현하는 방법은 여러 가지인데, 비교하면 이렇다.

방식 장점 단점
블랙리스트 (Redis 등) 즉각적 무효화 인프라 추가, 매 요청마다 조회 비용
DB token 버전 관리 인프라 추가 없음, 구조 단순 DB 조회 필요 (캐시로 완화 가능)
단순 세션 삭제 구현 쉬움 JWT stateless면 서버에서 제어 불가

JWT 기반 구조에서 "서버가 클라이언트 토큰을 무효화"하려면 결국 서버 사이드에 뭔가를 남겨야 한다. 블랙리스트는 Redis 같은 별도 인프라가 필요하고, 지금 스택에서 그걸 추가하는 건 오버엔지니어링이라고 판단했다.

그래서 선택한 게 token version 컬럼을 User 테이블에 추가하는 방식이다.

-- prisma/migrations/20260523120000_add_2fa_and_token_version/migration.sql
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
// prisma/schema.prisma
model User {
  // ...
  tokenVersion Int @default(0)
}

JWT payload에 발급 시점의 tokenVersion을 심어두고, 요청마다 DB의 현재 버전과 비교한다. 관리자가 강제 로그아웃을 누르면 DB의 tokenVersion을 increment — 기존에 발급된 토큰은 전부 버전 불일치로 거부된다.

// auth-actions.ts 개념
async function validateTokenVersion(userId: string, version: number) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return user?.tokenVersion === version;
}

async function forceSignout(userId: string) {
  await prisma.user.update({
    where: { id: userId },
    data: { tokenVersion: { increment: 1 } },
  });
}

③ 어드민 UI 연동

UnifiedUsersClient.tsx에 강제 로그아웃 액션을 붙였다. 운영자가 특정 유저를 선택해서 즉시 세션 만료를 트리거할 수 있는 흐름이다. 이게 없으면 버전 관리 로직을 깔아도 운영에서 쓸 수가 없으니, UI까지 한 PR에 묶는 게 맞다고 봤다.

migration 파일명에 2fa가 같이 들어가 있는 건, 이번 마이그레이션이 2FA 관련 스키마 변경도 함께 포함하고 있기 때문이다. 두 피처를 같은 마이그레이션으로 묶은 건 배포 타이밍을 맞추려는 의도였다.

회고

이런 류의 작업은 기능 자체보다 "왜 지금, 왜 이 방식"을 팀에 설명하는 게 더 중요하다. 토큰 버전 방식은 매 요청마다 DB 조회가 추가된다는 단점이 있고, 트래픽이 커지면 부담이 될 수 있다. 이 트레이드오프를 팀에 명확히 공유하고 합의한 뒤 머지했다.

또 세션 maxAge 변경은 실사용자에게 바로 영향이 가는 변경이라, 배포 전에 운영팀에 공지했다. 코드만 바꾸고 조용히 배포했다가 "갑자기 로그인이 풀린다"는 문의가 쏟아지면 신뢰 비용이 더 크다.

auth 관련 변경은 항상 이 순서로 접근하게 된다 — 보안 요구사항 → 트레이드오프 분석 → 팀 합의 → 운영 공지 → 배포. 이번도 그 순서를 지켰고, 결과적으로 무난하게 롤아웃됐다.

다음은 이 버전 검증 로직에 캐시 레이어를 얹을지 검토할 예정이다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.