메인 콘텐츠로 건너뛰기
세션과 JWT 토큰의 생명주기를 효과적으로 관리하는 방법을 알아봅니다. 세션 관리는 사용자 경험과 보안에 직접적인 영향을 미치는 중요한 주제입니다.

세션 관리 개요

세션 스토어

Redis, PostgreSQL확장 가능한 저장소

세션 만료

자동 만료슬라이딩 만료

토큰 갱신

Refresh TokenAccess Token 재발급

로그아웃

세션 무효화토큰 블랙리스트

세션 기반 인증

세션의 동작 원리

세션 기반 인증은 서버가 사용자의 로그인 상태를 서버 측에 저장하는 방식입니다. 동작 방식을 이해하면 보안과 확장성 문제를 더 잘 다룰 수 있습니다. 세션 인증 흐름:
  1. 사용자가 로그인하면 서버가 세션 ID를 생성
  2. 세션 데이터(사용자 ID 등)를 세션 스토어에 저장
  3. 세션 ID를 쿠키로 클라이언트에 전송
  4. 이후 요청마다 쿠키의 세션 ID로 사용자 인증
장점:
  • 서버가 세션을 완전히 제어 (언제든 무효화 가능)
  • 민감한 정보가 클라이언트에 노출되지 않음
  • 구현이 간단하고 검증된 방식
단점:
  • 서버에 상태를 저장하므로 확장성 고려 필요
  • 세션 스토어가 단일 실패 지점(SPOF)이 될 수 있음

세션 스토어 설정

메모리 스토어 (개발용): 메모리 스토어는 개발 환경에서만 사용해야 합니다. 서버를 재시작하면 모든 세션이 사라지고, 여러 서버 인스턴스 간에 세션을 공유할 수 없습니다.
// server.ts (개발 환경)
import fastify from "fastify";
import fastifySession from "@fastify/session";
import fastifyCookie from "@fastify/cookie";

const app = fastify();

app.register(fastifyCookie);

app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    secure: false, // 개발 환경에서는 HTTP 허용
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24, // 1일
  },
  saveUninitialized: false,
  // store를 지정하지 않으면 메모리 스토어 사용
});
메모리 스토어의 문제점:
  • 서버 재시작 시 모든 세션 손실
  • 여러 서버 인스턴스 간 세션 공유 불가
  • 메모리 사용량 증가로 성능 저하 가능
  • 프로덕션에서는 절대 사용 금지
Redis 스토어 (프로덕션): 프로덕션 환경에서는 Redis를 세션 스토어로 사용하는 것이 업계 표준입니다. Redis는 빠르고, 확장 가능하며, 여러 서버 인스턴스 간 세션 공유를 지원합니다.
npm install @fastify/redis connect-redis
# or
pnpm add @fastify/redis connect-redis
// server.ts (프로덕션)
import fastify from "fastify";
import fastifySession from "@fastify/session";
import fastifyCookie from "@fastify/cookie";
import fastifyRedis from "@fastify/redis";
import RedisStore from "connect-redis";

const app = fastify();

// Redis 연결
app.register(fastifyRedis, {
  host: process.env.REDIS_HOST || "localhost",
  port: parseInt(process.env.REDIS_PORT || "6379"),
  password: process.env.REDIS_PASSWORD,
  // 연결 풀 설정
  lazyConnect: false,
  enableReadyCheck: true,
});

app.register(fastifyCookie);

// Redis를 세션 스토어로 사용
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  store: new RedisStore({
    client: app.redis, // Fastify Redis 클라이언트 사용
    prefix: "sess:", // 세션 키 접두사
    ttl: 60 * 60 * 24 * 7, // 7일 (초 단위)
  }),
  cookie: {
    secure: true, // HTTPS 필수
    httpOnly: true, // XSS 방지
    sameSite: "strict", // CSRF 방지
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
  },
  saveUninitialized: false,
  resave: false,
});
Redis 설정 설명:
  • prefix: 세션 키에 접두사를 붙여 다른 데이터와 구분
  • ttl: Time To Live, 세션 자동 만료 시간 (초 단위)
  • client: Redis 클라이언트 인스턴스
Redis는 클러스터 모드를 지원하여 고가용성(HA)을 구현할 수 있습니다. AWS ElastiCache, Azure Cache for Redis 등의 관리형 서비스를 사용하면 운영이 더 편리합니다.
PostgreSQL 스토어 (대안): 이미 PostgreSQL을 사용 중이라면 별도의 Redis 없이 PostgreSQL을 세션 스토어로 사용할 수 있습니다. 단, 성능은 Redis보다 낮습니다.
npm install connect-pg-simple
# or
pnpm add connect-pg-simple
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import pg from "pg";

const PgStore = connectPgSimple(session);
const pgPool = new pg.Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || "5432"),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  store: new PgStore({
    pool: pgPool,
    tableName: "sessions", // 세션 테이블명
  }),
  cookie: {
    secure: true,
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 7,
  },
});

세션 만료 전략

고정 만료 (Fixed Expiration): 세션이 생성된 시점부터 정해진 시간이 지나면 무조건 만료됩니다. 보안성은 높지만 사용자 경험이 좋지 않을 수 있습니다.
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 2, // 2시간 후 무조건 만료
  },
  rolling: false, // 슬라이딩 만료 비활성화
});
슬라이딩 만료 (Sliding Expiration): 사용자가 활동할 때마다 세션 만료 시간이 갱신됩니다. “Remember Me” 기능에 적합하며, 사용자 경험이 좋습니다.
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
  },
  rolling: true, // 매 요청마다 만료 시간 갱신
  resave: false, // 세션이 변경되지 않아도 저장
});
하이브리드 전략: 초기에는 짧은 만료 시간을 설정하고, “Remember Me”를 선택하면 긴 만료 시간을 적용합니다.
class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
    rememberMe?: boolean;
  }): Promise<{ user: User }> {
    const { email, password, rememberMe } = params;
    const context = Sonamu.getContext();
    
    // 인증 로직...
    const user = await this.authenticateUser(email, password);
    
    // Remember Me에 따라 세션 만료 시간 조정
    if (rememberMe) {
      context.request.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 30; // 30일
    } else {
      context.request.session.cookie.maxAge = 1000 * 60 * 60 * 2; // 2시간
    }
    
    // 세션에 로그인 정보 저장
    await this.loginToSession(user);
    
    return { user };
  }
}

세션 데이터 관리

세션에는 최소한의 정보만 저장해야 합니다. 세션 크기가 커지면 성능이 저하되고, 스토리지 비용이 증가합니다.
// ✅ 좋은 예: 최소 정보만 저장
context.request.session.userId = user.id;
context.request.session.role = user.role;

// ❌ 나쁜 예: 너무 많은 정보 저장
context.request.session.user = {
  id: user.id,
  email: user.email,
  username: user.username,
  bio: user.bio,
  profileImage: user.profileImage,
  preferences: user.preferences,
  // ... 수많은 필드
};
세션 데이터 접근:
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getProfile(): Promise<{ user: User }> {
    const context = Sonamu.getContext();
    
    // 세션에서 사용자 ID 가져오기
    const userId = context.request.session.userId;
    
    if (!userId) {
      throw new Error("Not authenticated");
    }
    
    // 데이터베이스에서 최신 정보 조회
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
}

JWT 토큰 관리

JWT의 동작 원리

JWT(JSON Web Token)는 상태를 저장하지 않는(stateless) 인증 방식입니다. 서버가 토큰을 생성한 후에는 토큰 자체에 모든 정보가 포함되어 있어, 서버가 별도로 저장할 필요가 없습니다. JWT 구조:
header.payload.signature
  • Header: 토큰 타입과 알고리즘 (예: {"alg": "HS256", "typ": "JWT"})
  • Payload: 사용자 정보와 클레임 (예: {"userId": 123, "role": "user"})
  • Signature: 토큰의 무결성을 검증하는 서명
JWT의 장점:
  • 서버가 상태를 저장하지 않아 확장성이 좋음
  • 마이크로서비스 환경에 적합
  • 토큰 자체에 정보가 포함되어 있어 별도 조회 불필요
JWT의 단점:
  • 토큰을 서버에서 강제로 무효화하기 어려움
  • 토큰 크기가 쿠키보다 큼
  • 토큰이 탈취되면 만료 전까지 사용 가능

Access Token과 Refresh Token

Access Token은 API 요청에 사용되는 짧은 수명의 토큰입니다. 보통 15분~1시간 정도의 짧은 유효기간을 가집니다. 탈취되더라도 피해를 최소화할 수 있습니다. Refresh Token은 Access Token을 재발급받기 위한 긴 수명의 토큰입니다. 보통 7일~30일 정도의 유효기간을 가지며, 서버에 저장하여 관리합니다. 왜 두 개의 토큰을 사용하나요? 만약 Access Token의 유효기간을 30일로 설정하면:
  • 토큰이 탈취되면 30일 동안 악용될 수 있음
  • 사용자 권한 변경이 즉시 반영되지 않음
Access Token을 짧게 설정하고 Refresh Token으로 갱신하면:
  • 탈취된 Access Token은 짧은 시간만 유효
  • 권한 변경을 빠르게 반영 가능
  • Refresh Token은 서버에 저장하여 무효화 가능
// auth/jwt.ts
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;

/**
 * Access Token 생성 (짧은 수명)
 */
export function generateAccessToken(user: User): string {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role,
    },
    ACCESS_TOKEN_SECRET,
    {
      expiresIn: "15m", // 15분
    }
  );
}

/**
 * Refresh Token 생성 (긴 수명)
 */
export function generateRefreshToken(user: User): string {
  return jwt.sign(
    {
      userId: user.id,
      // Refresh Token에는 최소 정보만
    },
    REFRESH_TOKEN_SECRET,
    {
      expiresIn: "7d", // 7일
    }
  );
}

/**
 * Access Token 검증
 */
export function verifyAccessToken(token: string) {
  try {
    return jwt.verify(token, ACCESS_TOKEN_SECRET);
  } catch (error) {
    return null;
  }
}

/**
 * Refresh Token 검증
 */
export function verifyRefreshToken(token: string) {
  try {
    return jwt.verify(token, REFRESH_TOKEN_SECRET);
  } catch (error) {
    return null;
  }
}
보안 주의사항:
  • Access Token과 Refresh Token의 Secret을 반드시 다르게 설정
  • Refresh Token은 서버에 저장하여 무효화 가능하게 만들기
  • XSS 공격을 막기 위해 토큰을 localStorage 대신 httpOnly 쿠키에 저장 권장

Refresh Token 데이터베이스 스키마

CREATE TABLE refresh_tokens (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  
  INDEX idx_user_id (user_id),
  INDEX idx_token (token),
  INDEX idx_expires_at (expires_at)
);

토큰 갱신 구현

class AuthModel extends BaseModelClass {
  /**
   * Access Token 갱신
   * 
   * Refresh Token을 받아 새로운 Access Token을 발급합니다.
   * Refresh Token의 유효성과 데이터베이스 저장 여부를 모두 확인합니다.
   */
  @api({ httpMethod: "POST" })
  async refreshToken(params: {
    refreshToken: string;
  }): Promise<{
    accessToken: string;
    refreshToken?: string; // Refresh Token Rotation 시
  }> {
    const { refreshToken } = params;
    
    // 1. Refresh Token 검증
    const payload = verifyRefreshToken(refreshToken);
    
    if (!payload || typeof payload === "string") {
      throw new Error("Invalid refresh token");
    }
    
    // 2. 데이터베이스에서 Refresh Token 확인
    const rdb = this.getPuri("r");
    const storedToken = await rdb
      .table("refresh_tokens")
      .where("token", refreshToken)
      .where("user_id", payload.userId)
      .where("expires_at", ">", new Date())
      .first();
    
    if (!storedToken) {
      throw new Error("Refresh token not found or expired");
    }
    
    // 3. 사용자 정보 조회
    const user = await rdb
      .table("users")
      .where("id", payload.userId)
      .first();
    
    if (!user) {
      throw new Error("User not found");
    }
    
    // 4. 새 Access Token 생성
    const accessToken = generateAccessToken(user);
    
    // 5. Refresh Token Rotation (선택사항)
    // 보안 강화를 위해 Refresh Token도 함께 갱신
    const newRefreshToken = generateRefreshToken(user);
    
    const wdb = this.getPuri("w");
    
    // 기존 Refresh Token 삭제
    await wdb
      .table("refresh_tokens")
      .where("token", refreshToken)
      .delete();
    
    // 새 Refresh Token 저장
    await wdb.table("refresh_tokens").insert({
      user_id: user.id,
      token: newRefreshToken,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7일
    });
    
    return {
      accessToken,
      refreshToken: newRefreshToken,
    };
  }
}
Refresh Token Rotation은 Refresh Token을 한 번 사용하면 새로운 Refresh Token을 발급하여 보안을 강화하는 기법입니다. 토큰이 탈취되더라도 탈취자와 정상 사용자 중 한 쪽만 새 토큰을 받을 수 있으므로, 탈취를 빠르게 감지할 수 있습니다.

토큰 무효화 (Logout)

JWT는 기본적으로 만료 전까지 유효하지만, 블랙리스트를 사용하여 강제로 무효화할 수 있습니다.
class AuthModel extends BaseModelClass {
  /**
   * 로그아웃
   * 
   * Refresh Token을 데이터베이스에서 삭제하고,
   * Access Token을 블랙리스트에 추가합니다.
   */
  @api({ httpMethod: "POST" })
  async logout(): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Not authenticated");
    }
    
    const wdb = this.getPuri("w");
    
    // 1. 모든 Refresh Token 삭제
    await wdb
      .table("refresh_tokens")
      .where("user_id", context.user.id)
      .delete();
    
    // 2. 현재 Access Token을 블랙리스트에 추가
    const authHeader = context.request.headers.authorization;
    if (authHeader && authHeader.startsWith("Bearer ")) {
      const token = authHeader.substring(7);
      
      // Access Token의 남은 유효시간 계산
      const decoded = jwt.decode(token) as any;
      const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
      
      // Redis에 블랙리스트 추가 (TTL은 토큰 남은 시간)
      const redis = getRedisClient();
      await redis.setex(`blacklist:${token}`, expiresIn, "1");
    }
    
    return { success: true };
  }
  
  /**
   * 모든 세션 로그아웃 (다른 기기 포함)
   */
  @api({ httpMethod: "POST" })
  async logoutAll(): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Not authenticated");
    }
    
    const wdb = this.getPuri("w");
    
    // 모든 Refresh Token 삭제
    await wdb
      .table("refresh_tokens")
      .where("user_id", context.user.id)
      .delete();
    
    return { success: true };
  }
}

토큰 블랙리스트 미들웨어

// auth/jwt.middleware.ts
import type { FastifyRequest, FastifyReply } from "fastify";

export async function checkTokenBlacklist(
  request: FastifyRequest,
  reply: FastifyReply
) {
  const authHeader = request.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return;
  }
  
  const token = authHeader.substring(7);
  
  // Redis에서 블랙리스트 확인
  const redis = getRedisClient();
  const isBlacklisted = await redis.exists(`blacklist:${token}`);
  
  if (isBlacklisted) {
    return reply.status(401).send({
      error: "Token has been revoked",
    });
  }
}

실전 패턴

동시 접속 제한

한 계정으로 여러 기기에서 동시 접속을 제한하는 방법입니다.
class AuthModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: {
    email: string;
    password: string;
  }): Promise<{ accessToken: string }> {
    const { email, password } = params;
    
    // 인증...
    const user = await this.authenticateUser(email, password);
    
    const wdb = this.getPuri("w");
    
    // 기존 세션 모두 삭제 (강제 로그아웃)
    await wdb
      .table("refresh_tokens")
      .where("user_id", user.id)
      .delete();
    
    // 새 토큰 발급
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
    
    await wdb.table("refresh_tokens").insert({
      user_id: user.id,
      token: refreshToken,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    });
    
    return { accessToken };
  }
}

만료된 세션 정리

주기적으로 만료된 Refresh Token을 삭제하여 데이터베이스 크기를 관리합니다.
// tasks/cleanup-tokens.ts
import { task } from "sonamu";

/**
 * 매일 자정에 만료된 Refresh Token 삭제
 */
@task({ cron: "0 0 * * *" })
export async function cleanupExpiredTokens() {
  const db = getDatabase();
  
  const result = await db
    .table("refresh_tokens")
    .where("expires_at", "<", new Date())
    .delete();
  
  console.log(`Deleted ${result} expired refresh tokens`);
}

주의사항

세션/토큰 관리 주의사항:
  1. 세션 Secret과 JWT Secret은 환경변수로 관리
  2. 프로덕션에서는 Redis 등 외부 세션 스토어 필수
  3. Refresh Token은 데이터베이스에 저장하여 무효화 가능하게
  4. Access Token은 짧게, Refresh Token은 길게 설정
  5. HTTPS 사용 시에만 secure: true 설정
  6. XSS 방지를 위해 httpOnly: true 필수
  7. 블랙리스트는 Redis의 TTL 기능 활용

다음 단계