Vol. 16

개발일지

출퇴근 웹앱 개발일지 (2) — 인증과 세션

bcrypt + JWT만으로는 '관리자가 직원을 즉시 강제 로그아웃'이 안 됩니다. 왜 안 되는지, sessions 테이블을 추가해 어떻게 풀었는지 정리합니다.

2026.05.29 · 발신 우체부J


title: "출퇴근 웹앱 개발일지 (2) — 인증과 세션" date: "2026-05-29" description: "bcrypt + JWT만으로는 '관리자가 직원을 즉시 강제 로그아웃'이 안 됩니다. 왜 안 되는지, sessions 테이블을 추가해 어떻게 풀었는지 정리합니다." tags: ["dev", "attendance-web", "auth"] category: "dev"

1편에서 살짝 예고했던 인증 편입니다.

이 앱은 매장 PC를 직원 여러 명이 돌려쓰는 구조라, 로그인이 "비밀번호 맞히기" 만으로는 부족했습니다. 누가 어디서 들어와 있는지 관리자가 보고, 필요하면 즉시 끊어낼 수 있어야 했습니다. 그래서 들어간 게 sessions 테이블입니다.

1. 일단 보통의 JWT부터

비밀번호는 bcrypt(cost 10)로 해싱해서 users.password_hash에 저장합니다. 로그인하면 그걸 검증한 뒤 JWT를 발급해 쿠키에 심습니다. 여기까지는 표준입니다.

// lib/auth.js — 단순화
import { SignJWT } from "jose";
import bcrypt from "bcryptjs";

export async function hashPassword(plain) {
  return bcrypt.hash(plain, 10);
}
export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

JWT는 HS256, 만료 7일, SESSION_SECRET(32자 이상)로 서명합니다. 쿠키는 HttpOnly, SameSite=Lax, production에서는 Secure.

2. JWT만으로는 강제 로그아웃이 안 된다

여기서 막혔습니다. JWT는 stateless입니다. 한번 서명해서 발급하면 서버는 그 토큰에 대해 아무것도 기억하지 않습니다. 만료 시각까지 그 토큰은 유효하고, 서버 입장에서는 "이 토큰을 무효화" 할 방법이 없습니다.

이 앱에서 필요한 건 두 가지였습니다.

  1. 관리자가 특정 직원 계정을 즉시 끊어내기 — 누군가 자기 계정을 공유했다가 다시 회수하고 싶을 때, 또는 퇴사자가 로그인 상태로 남아있을 때.
  2. 현재 어디서 로그인 중인지 보기 — IP, UA, 마지막 활동 시각.

JWT만 쓰면 둘 다 불가능합니다.

3. 해결: jti + sessions 테이블

JWT 페이로드에 jti(JWT ID, 랜덤 16바이트)를 박고, 같은 jti로 DB에 행을 하나 만듭니다. 이후 모든 요청은 쿠키 → JWT 검증 → DB에 jti 존재 확인 3단계를 거칩니다. 행을 지우면 그 순간부터 그 토큰은 죽습니다.

CREATE TABLE sessions (
  jti          TEXT PRIMARY KEY,
  user_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  ip           TEXT,
  user_agent   TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

로그인 시:

const jti = crypto.randomBytes(16).toString("hex");
const token = await new SignJWT({ id: user.id, name, role, jti })
  .setProtectedHeader({ alg: "HS256" })
  .setExpirationTime("7d")
  .sign(getSecret());

await sql`INSERT INTO sessions (jti, user_id, ip, user_agent)
          VALUES (${jti}, ${user.id}, ${ip}, ${ua})`;

요청 검증 시:

const verified = await jwtVerify(token, getSecret());
const rows = await sql`SELECT user_id FROM sessions WHERE jti = ${verified.payload.jti}`;
if (rows.length === 0) return null;   // 강제 로그아웃당한 토큰

DELETE FROM sessions WHERE jti = ? 한 줄이면 그 세션이 끊깁니다. DELETE ... WHERE user_id = ?로 한 사람의 전체 세션을 끊을 수도 있습니다.

4. 활성 세션 목록과 끊기 API

/api/sessions가 두 개 있습니다 (index.js + [id].js — 함수 한도 때문에 어쩔 수 없이 2개).

| 엔드포인트 | 권한 | 동작 | |---|---|---| | GET /api/sessions | 본인(자기 것만) / 관리자(전체) | 최근 7일 활성 세션 목록 | | DELETE /api/sessions/:jti | 본인 또는 관리자 | 그 세션 끊기 |

관리자가 다른 사람 세션을 끊으면 audit_logSESSION_REVOKE로 자동 기록됩니다. 본인이 본인 걸 끊으면 안 남깁니다(자기 로그아웃에 가까운 행위라).

5. 부가로 들어간 세 가지

이 구조 위에 운영용 가드 세 개를 더 얹었습니다.

(a) last_seen_at 업데이트

검증할 때마다 UPDATE sessions SET last_seen_at = NOW() WHERE jti = ?를 fire-and-forget으로 던집니다(.catch(() => {})). 응답 지연에 영향을 주면 안 되니 await하지 않습니다. 관리자 화면의 "마지막 활동 5분 전"이 여기서 나옵니다.

(b) 5분 무활동 자동 로그아웃 — 프론트엔드 쪽

매장 PC를 직원이 돌려쓰니, 화면 켜놓고 자리 비우면 다음 사람이 그 계정으로 출근을 찍어버리는 사고가 있었습니다. 그래서 클라이언트에서 5분 동안 마우스· 키보드 입력이 없으면 자동으로 POST /api/auth/logout을 호출합니다. 서버는 그 요청을 받아 DELETE FROM sessions WHERE jti = ?로 세션 행을 지우고 쿠키를 비웁니다.

이건 서버가 강제하는 게 아니라 클라이언트가 "예의상" 끊는 구조라 완벽한 보안 대책은 아닙니다. 다만 매장에서 일어나는 실수의 95%는 이걸로 잡힙니다.

(c) 모바일 직원 로그인 차단

UA 정규식으로 iPhone|iPad|Android|Mobile 등이 매치되면 직원 계정 로그인은 서버가 403을 반환합니다. 관리자만 모바일 허용. 출퇴근 찍기도 마찬가지로 모바일 직원은 차단됩니다. 이건 1편에 잠깐 나왔던 정책입니다.

6. 7일 넘은 세션 자동 청소

따로 cron을 두지 않고, 로그인할 때 그 사람의 7일 넘은 세션을 같이 삭제합니다.

await sql`DELETE FROM sessions
          WHERE user_id = ${user.id}
            AND created_at < NOW() - INTERVAL '7 days'`;

테이블이 작은 앱이라 이 정도로 충분하고, 함수 한도(12/12) 안에서 cron을 하나라도 아끼고 싶었습니다.

정리 — 무엇을 얻었나

  • "강제 로그아웃 가능한 JWT" 라는, 보기엔 모순적인 동작이 가능해졌습니다.
  • IP/UA/마지막 활동 시각이 관리자 화면에 그대로 보입니다. 의심스러운 접속을 즉시 끊을 수 있고, 그 행위가 감사 로그에 자동으로 남습니다.
  • 비용은 sessions 테이블 PK(jti) 조회 한 번이 매 요청마다 추가되는 정도입니다. Neon에서 인덱스로 ms 단위 안에 끝납니다.

다음 편 예고

Vol. 3 — 급여명세서 모듈. 시급제·월급제·주휴수당이 섞여 있고, 한 사람이 호점 여러 개에서 일할 때 명세서를 한 장으로 통합하는 구조가 어떻게 짜여 있는지 보는 편입니다.