> ## Documentation Index
> Fetch the complete documentation index at: https://sonamu.cartanova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 세션 관리

> 세션과 토큰의 생명주기 관리

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

## 세션 관리 개요

<CardGroup cols={2}>
  <Card title="세션 스토어" icon="database">
    Redis, PostgreSQL 확장 가능한 저장소
  </Card>

  <Card title="세션 만료" icon="clock">
    자동 만료 슬라이딩 만료
  </Card>

  <Card title="토큰 갱신" icon="arrows-rotate">
    Refresh Token Access Token 재발급
  </Card>

  <Card title="로그아웃" icon="right-from-bracket">
    세션 무효화 토큰 블랙리스트
  </Card>
</CardGroup>

## 세션 기반 인증

### 세션의 동작 원리

세션 기반 인증은 서버가 사용자의 로그인 상태를 **서버 측에 저장**하는 방식입니다. 동작 방식을 이해하면 보안과 확장성 문제를 더 잘 다룰 수 있습니다.

**세션 인증 흐름**:

1. 사용자가 로그인하면 서버가 세션 ID를 생성
2. 세션 데이터(사용자 ID 등)를 세션 스토어에 저장
3. 세션 ID를 쿠키로 클라이언트에 전송
4. 이후 요청마다 쿠키의 세션 ID로 사용자 인증

**장점**:

* 서버가 세션을 완전히 제어 (언제든 무효화 가능)
* 민감한 정보가 클라이언트에 노출되지 않음
* 구현이 간단하고 검증된 방식

**단점**:

* 서버에 상태를 저장하므로 확장성 고려 필요
* 세션 스토어가 단일 실패 지점(SPOF)이 될 수 있음

### 세션 스토어 설정

**메모리 스토어 (개발용)**:

메모리 스토어는 개발 환경에서만 사용해야 합니다. 서버를 재시작하면 모든 세션이 사라지고, 여러 서버 인스턴스 간에 세션을 공유할 수 없습니다.

```typescript theme={null}
// 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를 지정하지 않으면 메모리 스토어 사용
});
```

<Warning>
  **메모리 스토어의 문제점**: - 서버 재시작 시 모든 세션 손실 - 여러 서버 인스턴스 간 세션 공유 불가

  * 메모리 사용량 증가로 성능 저하 가능 - **프로덕션에서는 절대 사용 금지**
</Warning>

**Redis 스토어 (프로덕션)**:

프로덕션 환경에서는 Redis를 세션 스토어로 사용하는 것이 업계 표준입니다. Redis는 빠르고, 확장 가능하며, 여러 서버 인스턴스 간 세션 공유를 지원합니다.

```bash theme={null}
npm install @fastify/redis connect-redis
# or
pnpm add @fastify/redis connect-redis
```

```typescript theme={null}
// 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 클라이언트 인스턴스

<Info>
  Redis는 클러스터 모드를 지원하여 고가용성(HA)을 구현할 수 있습니다. AWS ElastiCache, Azure Cache
  for Redis 등의 관리형 서비스를 사용하면 운영이 더 편리합니다.
</Info>

**PostgreSQL 스토어 (대안)**:

이미 PostgreSQL을 사용 중이라면 별도의 Redis 없이 PostgreSQL을 세션 스토어로 사용할 수 있습니다. 단, 성능은 Redis보다 낮습니다.

```bash theme={null}
npm install connect-pg-simple
# or
pnpm add connect-pg-simple
```

```typescript theme={null}
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)**:

세션이 생성된 시점부터 정해진 시간이 지나면 무조건 만료됩니다. 보안성은 높지만 사용자 경험이 좋지 않을 수 있습니다.

```typescript theme={null}
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 2, // 2시간 후 무조건 만료
  },
  rolling: false, // 슬라이딩 만료 비활성화
});
```

**슬라이딩 만료 (Sliding Expiration)**:

사용자가 활동할 때마다 세션 만료 시간이 갱신됩니다. "Remember Me" 기능에 적합하며, 사용자 경험이 좋습니다.

```typescript theme={null}
app.register(fastifySession, {
  secret: process.env.SESSION_SECRET!,
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
  },
  rolling: true, // 매 요청마다 만료 시간 갱신
  resave: false, // 세션이 변경되지 않아도 저장
});
```

**하이브리드 전략**:

초기에는 짧은 만료 시간을 설정하고, "Remember Me"를 선택하면 긴 만료 시간을 적용합니다.

```typescript theme={null}
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 };
  }
}
```

### 세션 데이터 관리

세션에는 최소한의 정보만 저장해야 합니다. 세션 크기가 커지면 성능이 저하되고, 스토리지 비용이 증가합니다.

```typescript theme={null}
// ✅ 좋은 예: 최소 정보만 저장
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,
  // ... 수많은 필드
};
```

**세션 데이터 접근**:

```typescript theme={null}
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은 서버에 저장하여 무효화 가능

```typescript theme={null}
// 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;
  }
}
```

<Warning>
  **보안 주의사항**: - Access Token과 Refresh Token의 Secret을 **반드시 다르게** 설정 - Refresh
  Token은 서버에 저장하여 무효화 가능하게 만들기 - XSS 공격을 막기 위해 토큰을 localStorage 대신
  httpOnly 쿠키에 저장 권장
</Warning>

### Refresh Token 데이터베이스 스키마

```sql theme={null}
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)
);
```

### 토큰 갱신 구현

```typescript theme={null}
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는 기본적으로 만료 전까지 유효하지만, 블랙리스트를 사용하여 강제로 무효화할 수 있습니다.

```typescript theme={null}
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 };
  }
}
```

### 토큰 블랙리스트 미들웨어

```typescript theme={null}
// 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",
    });
  }
}
```

## 실전 패턴

### 동시 접속 제한

한 계정으로 여러 기기에서 동시 접속을 제한하는 방법입니다.

```typescript theme={null}
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을 삭제하여 데이터베이스 크기를 관리합니다.

```typescript theme={null}
// 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`);
}
```

## 주의사항

<Warning>
  **세션/토큰 관리 주의사항**: 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 기능 활용
</Warning>

## 다음 단계

<CardGroup cols={2}>
  <Card title="인증 설정" icon="gear" href="/ko/api-development/authentication/setup">
    인증 시스템 구축
  </Card>

  <Card title="보호된 라우트" icon="shield-halved" href="/ko/api-development/authentication/protected-routes">
    Guards로 권한 제어
  </Card>

  <Card title="Context" icon="diagram-project" href="/ko/api-development/context/sonamu-context">
    Context 자세히 알아보기
  </Card>

  <Card title="Workflows" icon="diagram-next" href="/ko/advanced-features/workflows/overview">
    작업 스케줄링
  </Card>
</CardGroup>
