JWT 인증 구현: Access·Refresh Token 패턴, Next.js·Express 구현, 흔한 보안 실수

이 글의 핵심

JWT가 어떻게 만들어지고 검증되는지, Access·Refresh Token을 나누는 이유와 저장 위치, Next.js 구현 예제, 토큰 탈취·만료 처리에서 자주 하는 실수와 세션과의 선택 기준을 정리합니다.

이 글의 핵심

한 줄로 말하면: JWT는 편의용 포맷이지, “세션 대체”가 아닙니다. 아래에서 원리·코드·실수까지 다루지만, 그 전에 제 의견부터 분명히 하겠습니다. JWT를 세션으로 쓰지 마세요. 긴 만료, localStorage에 심는 패턴, “그냥 stateless라서” 같은 논리로 설계를 풀면 나중에 모두 대가를 치릅니다.

보안 사고 사례(가상 시나리오이지만, 패턴은 실제 사례와 겹칩니다) 예전 팀에서 모바일 웹뷰에 7일짜리 액세스 토큰을 localStorage에 넣어 두었는데, XSS가 한 번 터지자 공격자가 토큰을 그대로 복사해 API를 호출했습니다. 서버는 “서명이 맞네?” 하고 믿어 줄 뿐이었고, 즉시 끊기도 어려웠으며, 누가 썼는지도 애매해졌습니다. 그날 밤 토큰 만료·저장소·CSRF·리프레시 로테이션을 전부 다시 짰습니다. “JWT가 나빴다”가 아니라, 세션 대용으로 쓴 것이 문제였다고 봤습니다.

제가 얻은 교훈(개인적인 생각)

  • 세션/리프레시/블랙리스트 중 무엇을 쓰든, “탈취되면 끊을 수 있는가”를 먼저 정합니다.
  • JWT를 쓰면 짧은 액세스 토큰 + 서버 쪽 리프레시(또는 denylist) 중 하나는 받아들여야 합니다. 완전한 무상태는 보통 미신에 가깝습니다.
  • “마이크로서비스라서 JWT”라는 말은 맞지만, 인증이 아니라 위임(Delegation) 관점에서 설명하는 편이 사고가 덜 납니다.

들어가며: “로그인 유지”가 왜 이렇게 꼬일까요

세션만 쓰면 메모리·스토어 부담이 생기고, JWT만 쓰면 무효화가 지옥이 됩니다. 그래서 아래에는 Access/Refresh 패턴과 최소한의 서버 측 상태(리프레시 jti, 블랙리스트) 이야기가 함께 붙어 있습니다.

이 글에서 굳이 시나리오를 쓰는 이유는 “토큰만 주고 끝”이 아니라, 웹/모바일/MSA마다 뚫리는 구멍이 다르기 때문입니다. 쿠키가 통하지 않는 앱, 게이트웨이 뒤의 여러 서비스… 상황은 달라도 원칙은 같습니다. 토큰을 어디에 두는지, 얼마나 유지하는지, 탈취되면 어떻게 할지입니다.

sequenceDiagram
    participant Client as 클라이언트
    participant Server as 서버
    participant DB as 데이터베이스
    Client->>Server: 로그인 (ID/PW)
    Server->>DB: 사용자 확인
    DB-->>Server: 사용자 정보
    Server->>Server: JWT 생성
    Server-->>Client: Access Token + Refresh Token
    Client->>Server: API 요청 (+ Access Token)
    Server->>Server: 토큰 검증
    Server-->>Client: 응답

JWT란?

아래에는 교과서 톤이 조금 섞여 있습니다(검색·정확도용). 핵심만 보면 JWT는 서명이 붙은 JSON 조각이고, 그것을 세션 대신 길게 들고 다니는 설계만 조심하면 됩니다.

JWT 구조

JWT (JSON Web Token)는 Header.Payload.Signature 형식의 문자열입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

1. Header (헤더)

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg: 서명 알고리즘 (HS256, RS256 등)
  • typ: 토큰 타입 (JWT)

2. Payload (페이로드)

{
  "userId": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}
  • iat (Issued At): 발급 시간
  • exp (Expiration): 만료 시간
  • 커스텀 클레임: userId, name 등

3. Signature (서명)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT 내부 동작 심화 — JWS, 서명, 클레임, 운영

흔히 JWT라고 부르는 문자열은 RFC 7519의 JSON Web Token 클레임 집합을 RFC 7515 JWS(JSON Web Signature) 규칙으로 직렬화한 컴팩트 표현입니다. 세 구간은 각각 Base64URL(패딩 제거, +// 대체)로 인코딩된 JSON이며, 서명은 헤더·페이로드 바이트열에 대해 헤더의 alg에 맞는 암호 연산을 적용한 값입니다.

Base64URL과 신뢰 경로
서명 입력은 base64url(header) + "." + base64url(payload) 문자열(바이트)에 대해 계산됩니다. jwt.decode는 이 두 부분만 읽을 뿐 서명을 검증하지 않습니다. 공격자는 페이로드를 바꾼 뒤 임의의 문자열을 붙일 수 있으므로, 운영에서는 반드시 jwt.verify(또는 동등한 검증)로 서명·만료·필수 클레임을 통과시킨 뒤에만 신뢰해야 합니다.

HS256 vs RS256
HS256은 발급자와 검증자가 동일한 대칭 키를 공유합니다. 단일 API 서버에는 단순하지만, 키가 한 번이라도 유출되면 위조와 검증이 동시에 깨집니다. RS256은 발급자만 개인키를 갖고, 각 리소스 서버·게이트웨이는 공개키만으로 검증합니다. 마이크로서비스나 API 게이트웨이가 많을 때 비밀을 모든 노드에 복제하지 않아도 되며, 공개키는 JWKS(JSON Web Key Set) URL로 배포하는 패턴이 널리 쓰입니다.

검증 파이프라인(검증자 관점)
(1) . 기준으로 세 부분을 나누고 형식 오류를 거절합니다. (2) 헤더의 alg가 허용 목록에 있는지 확인하고, none 알고리즘 등은 거절합니다(알고리즘 혼동 공격 방지). (3) 비밀 키 또는 IdP 공개키로 서명을 재계산해 타이밍 안전 비교로 일치 여부를 봅니다. (4) 서명이 맞은 뒤에만 exp, nbf, iat 등을 현재 시각과 비교합니다. (5) 필요하면 iss(발급자)·aud(대상)를 정책과 대조해 “우리 API용 토큰인지”를 확인합니다.

클레임: 등록·공개·비공개
iss, sub, aud, exp, nbf, iat, jti 등은 등록 클레임입니다. 특히 jti는 토큰마다 고유 ID를 부여해 Redis 등에 거부 목록(denylist)을 두거나, Refresh 회전(rotation)과 맞물려 재사용 탐지에 쓸 수 있습니다. 커스텀 클레임(role, tenant_id 등)은 페이로드가 단순 Base64라 누구나 읽을 수 있다는 점을 전제로 설계합니다. 비밀번호·카드번호·내부 식별자를 과하게 노출하는 것은 피합니다.

토큰 갱신(Refresh) 전략
Access는 짧게(예: 15분), Refresh는 길게(예: 7~30일) 두어 탈취 창을 줄입니다. Refresh 회전은 한 번 사용한 Refresh를 폐기하고 새 쌍을 내줍니다. 같은 Refresh가 두 번 제출되면 탈취 또는 복제로 보고 세션 전체를 무효화하는 재사용 탐지(reuse detection)를 함께 두는 것이 안전합니다. Refresh의 jti(또는 해시)를 Redis/DB에 두고 로그아웃 시 삭제하면 서버 측에서도 세션을 끊을 수 있습니다.

프로덕션에서 자주 쓰는 패턴
aud로 API 식별, iss+JWKS로 IdP 키 로테이션 대응, jti+denylist로 로그아웃, Access/Refresh용 서명 키 분리, BFF 뒤에서는 쿠키+CSRF와 짧은 Access 조합 등이 반복해서 쓰입니다.


JWT 생성 및 검증

Node.js (jsonwebtoken)

npm install jsonwebtoken
// JWT 생성
import jwt from 'jsonwebtoken';
const SECRET_KEY = process.env.JWT_SECRET;
function generateAccessToken(userId) {
  return jwt.sign(
    { userId },
    SECRET_KEY,
    { expiresIn: '15m' }  // 15분
  );
}
function generateRefreshToken(userId) {
  return jwt.sign(
    { userId },
    SECRET_KEY,
    { expiresIn: '7d' }  // 7일
  );
}
// 사용 예시
const accessToken = generateAccessToken('user123');
const refreshToken = generateRefreshToken('user123');
// JWT 검증
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET_KEY);
    return { valid: true, decoded };
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return { valid: false, error: 'Token expired' };
    }
    if (error.name === 'JsonWebTokenError') {
      return { valid: false, error: 'Invalid token' };
    }
    return { valid: false, error: 'Token verification failed' };
  }
}
// 사용 예시
const result = verifyToken(accessToken);
if (result.valid) {
  console.log('User ID:', result.decoded.userId);
} else {
  console.error('Error:', result.error);
}

Access Token + Refresh Token 패턴

왜 두 개의 토큰?

flowchart TB
    subgraph Problem[Access Token만 사용]
        A1[짧은 만료: 자주 로그인]
        A2[긴 만료: 보안 위험]
    end
    subgraph Solution[Access + Refresh Token]
        B1[Access: 짧은 만료 15분]
        B2[Refresh: 긴 만료 7일]
        B3[Access 만료 시 Refresh로 갱신]
    end
    Problem --> Solution

구현

// server.js
import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const app = express();
app.use(express.json());
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// Refresh Token 저장소 (실제로는 Redis 사용 권장)
const refreshTokens = new Set();
// 로그인
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  // 사용자 확인 (실제로는 DB 조회)
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  // 비밀번호 확인
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  // 토큰 생성
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
  // Refresh Token 저장
  refreshTokens.add(refreshToken);
  res.json({ accessToken, refreshToken });
});
// Access Token 갱신
app.post('/api/auth/refresh', (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  if (!refreshTokens.has(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
    // 새 Access Token 생성
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken });
  } catch (error) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});
// 로그아웃
app.post('/api/auth/logout', (req, res) => {
  const { refreshToken } = req.body;
  refreshTokens.delete(refreshToken);
  res.json({ message: 'Logged out' });
});
// 인증 미들웨어
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }
  try {
    const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
}
// 보호된 라우트
app.get('/api/user/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.userId, email: req.user.email });
});

Next.js 구현

API Routes

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
  const { email, password } = await request.json();
  // 사용자 확인
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }
  // 비밀번호 확인
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return NextResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }
  // 토큰 생성
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET!,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: '7d' }
  );
  // Refresh Token을 HttpOnly 쿠키에 저장
  const response = NextResponse.json({ accessToken });
  response.cookies.set('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60, // 7일
  });
  return response;
}
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function POST(request: NextRequest) {
  const refreshToken = request.cookies.get('refreshToken')?.value;
  if (!refreshToken) {
    return NextResponse.json(
      { error: 'Refresh token required' },
      { status: 401 }
    );
  }
  try {
    const decoded = jwt.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET!
    ) as { userId: string };
    // 새 Access Token 생성
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.ACCESS_TOKEN_SECRET!,
      { expiresIn: '15m' }
    );
    return NextResponse.json({ accessToken });
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid refresh token' },
      { status: 403 }
    );
  }
}

클라이언트 (React)

// lib/auth.ts
let accessToken: string | null = null;
export async function login(email: string, password: string) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!response.ok) {
    throw new Error('Login failed');
  }
  const data = await response.json();
  accessToken = data.accessToken;
  return data;
}
export async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
  });
  if (!response.ok) {
    throw new Error('Refresh failed');
  }
  const data = await response.json();
  accessToken = data.accessToken;
  return data.accessToken;
}
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  if (!accessToken) {
    throw new Error('Not authenticated');
  }
  let response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });
  // Access Token 만료 시 갱신
  if (response.status === 403) {
    try {
      await refreshAccessToken();
      response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    } catch (error) {
      // Refresh 실패 시 로그인 페이지로
      window.location.href = '/login';
      throw error;
    }
  }
  return response;
}
// components/LoginForm.tsx
'use client';
import { useState } from 'react';
import { login } from '@/lib/auth';
export default function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(email, password);
      window.location.href = '/dashboard';
    } catch (error) {
      alert('로그인 실패');
    }
  };
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호"
        required
      />
      <button type="submit">로그인</button>
    </form>
  );
}

보안 모범 사례

비밀 키 관리

# .env
ACCESS_TOKEN_SECRET=your-very-long-random-secret-key-at-least-256-bits
REFRESH_TOKEN_SECRET=another-different-long-random-secret-key
// 비밀 키 생성
import crypto from 'crypto';
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret);

HttpOnly 쿠키

// ✅ 좋은 예: Refresh Token을 HttpOnly 쿠키에
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,  // JavaScript로 접근 불가
  secure: true,    // HTTPS만
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000,
});
// ❌ 나쁜 예: localStorage에 저장
localStorage.setItem('refreshToken', refreshToken);  // XSS 취약

짧은 만료 시간

// Access Token: 15분
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh Token: 7일
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });

토큰 무효화 (Blacklist)

// Redis를 사용한 블랙리스트
import { createClient } from 'redis';
const redis = createClient();
async function blacklistToken(token) {
  const decoded = jwt.decode(token);
  const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
  await redis.setEx(`blacklist:${token}`, expiresIn, 'true');
}
async function isBlacklisted(token) {
  const result = await redis.get(`blacklist:${token}`);
  return result !== null;
}
// 미들웨어에서 확인
async function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (await isBlacklisted(token)) {
    return res.status(403).json({ error: 'Token revoked' });
  }
  
  // 토큰 검증...
}

CSRF 방어

// CSRF 토큰 생성
import crypto from 'crypto';
function generateCsrfToken() {
  return crypto.randomBytes(32).toString('hex');
}
// 로그인 시 CSRF 토큰 발급
app.post('/api/auth/login', async (req, res) => {
  // ... 로그인 로직
  const csrfToken = generateCsrfToken();
  
  res.cookie('csrfToken', csrfToken, {
    httpOnly: false,  // JavaScript로 읽을 수 있어야 함
    secure: true,
    sameSite: 'strict',
  });
  res.json({ accessToken, csrfToken });
});
// API 요청 시 CSRF 토큰 확인
function verifyCsrf(req, res, next) {
  const csrfToken = req.headers['x-csrf-token'];
  const cookieCsrf = req.cookies.csrfToken;
  if (!csrfToken || csrfToken !== cookieCsrf) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  next();
}

자주 하는 실수와 해결법

문제 1: 민감한 정보를 Payload에 저장

// ❌ 잘못된 코드
const token = jwt.sign(
  {
    userId: user.id,
    password: user.password,  // 절대 안 됨!
    creditCard: user.creditCard,  // 절대 안 됨!
  },
  secret
);
// ✅ 올바른 코드
const token = jwt.sign(
  {
    userId: user.id,
    email: user.email,
    role: user.role,
  },
  secret
);

문제 2: 토큰을 localStorage에 저장

// ❌ 잘못된 코드 - XSS 취약
localStorage.setItem('accessToken', token);
// ✅ 올바른 코드 - 메모리에 저장
let accessToken = null;
// 또는 HttpOnly 쿠키 (Refresh Token)
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: true,
});

문제 3: 비밀 키를 코드에 하드코딩

// ❌ 잘못된 코드
const token = jwt.sign(payload, 'my-secret-key');
// ✅ 올바른 코드
const token = jwt.sign(payload, process.env.JWT_SECRET);

문제 4: 만료 시간 확인 안 함

// ❌ 잘못된 코드
const decoded = jwt.decode(token);  // 검증 안 함!
// ✅ 올바른 코드
try {
  const decoded = jwt.verify(token, secret);  // 만료 시간 자동 확인
} catch (error) {
  if (error.name === 'TokenExpiredError') {
    // 만료 처리
  }
}

JWT vs Session — 표 대신 실제 판단 기준

표로 어느 쪽이 이기는지 비교하는 글이 많습니다. 저는 그렇게 쓰지 않습니다. 이유는 단순합니다. JWT는 “세션을 대체하는 승자”가 아니라, 조건부로 쓰는 도구이기 때문입니다.

다시 한번: JWT를 세션으로 쓰지 마세요.
”로그인 상태 = 만료가 긴 JWT 하나”로 가면 설계는 쉬워 보이지만 탈취·로그아웃·권한 회수에서 모두 문제가 터집니다. 세션이 주는 것은 서버가 ID 하나로 “지금 이 연결을 믿는다”는 판단을 끊을 수 있다는 점이고, JWT만으로 그것을 흉내 내려면 결국 블랙리스트/짧은 TTL/리프레시 저장소를 다시 들여야 하며, 그러면 stateless라는 장점은 반쯤 무너집니다.

그렇다면 JWT는 언제 쓸까요? MSA에서 게이트웨이가 공개키로 검증하는 경우, 모바일에서 헤더로 실어 나르는 경우, 서드파티에 위임된 토큰을 검증하는 경우에는 JWT가 잘 맞습니다. 반대로 브라우저 중심이고, 즉시 무효화가 중요하며, 운영자가 “지금 당장 끊어”를 자주 해야 한다면 세션(또는 서버 측 리프레시) 쪽이 훨씬 마음이 편합니다.

제 개인적인 결론: 둘 중 하나만 고르라는 질문 자체가 함정이며, 현실에서는 Access(짧은 JWT) + Refresh(서버/쿠키) + 필요 시 denylist 조합이 많습니다. 그것을 “세션이냐 아니냐”로 줄 세우기보다, 탈취됐을 때의 복구 비용을 기준으로 정하세요.


정리 및 체크리스트

핵심 요약 (제 기준)

  • JWT — 구조는 Header·Payload·Signature이고, 페이로드는 단순히 base64이므로 비밀을 넣지 마세요.
  • Access — 짧게 둡니다. 이것을 “세션”처럼 길게 잡는 순간 JWT를 세션으로 쓰는 것이 됩니다.
  • Refresh — 길게 둘 수 있는 대신, HttpOnly/서버 측 저장/회전 중 무엇인가가 책임을 져야 합니다.
  • 보안 — 키는 env에 두고, HTTPS, CSRF 방어(쿠키를 쓸 때), XSS 노출 줄이기(스토리지)를 챙깁니다.
  • 무효화 — “JWT는 취소할 수 없다”는 말이 맞는 만큼 denylist/짧은 TTL/로테이션을 설계에 넣습니다.

다시 사고가 나지 않도록 제가 체크하는 것

  • JWT_SECRET이 코드에 박혀 있지 않은지 확인합니다
  • Refresh는 가능하면 HttpOnly 쿠키에 둡니다 (모바일은 위험과 대안을 별도로 검토)
  • Access는 15분 전후를 기본으로 두고, “편하다”는 이유로 늘리지 않습니다
  • Payload에 비번·카드·과한 PII 금지
  • HTTPS
  • 쿠키 쓰면 CSRF
  • 로그아웃·탈취 대응을 위해 블랙리스트 또는 jti+저장소 중 하나는 받아들입니다

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. JWT를 localStorage에 넣어도 되나요?

A. 가능하면 피하는 쪽에 한 표입니다. XSS가 발생하면 토큰이 그대로 유출됩니다. Access는 메모리에, Refresh는 HttpOnly 쿠키(가능한 경우)에 두는 쪽이 낫습니다.

Q. JWT “취소”는 되나요?

A. 표준만으로 즉시 취소하기는 애매합니다. 만료를 짧게 두거나, Redis 같은 저장소에 블랙리스트/jti를 두는 방식으로 감수합니다. 그래서 “JWT를 길게 세션처럼 쓰지 말자”는 결론이 다시 나옵니다.

Q. Access Token 만료 시간은 얼마가 적당한가요?

A. 대략 15분 전후를 많이 씁니다. Refresh로 갱신을 붙이면 UX도 괜찮아지는 편입니다.

Q. JWT와 OAuth는 다른 것인가요?

A. OAuth는 프로토콜/플로우, JWT는 토큰 모양입니다. OAuth가 JWT를 쓸 수는 있습니다.

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 JWT 인증(원리·구현·보안·Refresh Token)을 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴 (표 대신 질문 리스트)

운영하다 보면 “JWT 버그”라기보다 주변에서 문제가 터지는 경우가 많습니다. 아래는 제가 배포 전에 스스로에게 묻는 질문 수준입니다.

  • 관측성 — 상관 ID가 붙어 있고, p95/p99, 타임아웃·재시도가 대시보드에 있는가.
  • 안전성 — 검증·권한·감사 로그가 경로마다 일관된가.
  • 신뢰성 — 재시도는 멱등한 곳에만 걸려 있고, 서킷/백오프/DLQ가 있는가.
  • 성능 — N+1, 풀 크기, 캐시, 백프레셔.
  • 배포 — 롤백, 카나리, 마이그레이션 문서.
  • 용량 — FD·스레드·디스크 상한을 피크 기준으로 다시 확인했는가.

스테이징의 데이터 양·RTT·동시성을 프로덕션에 가깝게 맞출수록 “JWT는 됐는데 DB가…” 같은 말이 줄어듭니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문의 JWT 인증 흐름을 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둡니다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 봅니다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현합니다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인합니다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검합니다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting) — 겪어본 패턴

표 대신 증상 → 제가 먼저 의심하는 것 순서로 적어 두겠습니다.

  • 간헐 실패 — 타임아웃, DNS, 레이스, 외부 5xx. 트레이스로 상관관계부터 봅니다.
  • 느려짐 — N+1, 락, 동기 I/O, 직렬화 과다. 한 가지씩 뜯어보는 것이 빠릅니다.
  • 메모리 — 무한히 커지는 캐시, FD/커넥션, 리스너 누수. 상한·TTL부터 봅니다.
  • 빌드만 깨짐 — env, 권한, lockfile, 이미지 태그. CI와 로컬의 차이를 비교합니다.
  • 환경 괴리 — 시크릿, 리전, 기본값. 단일 스키마로 고정합니다.
  • 데이터 꼬임 — 멱등성 없는 재시도, 캐시 무효화 누락. 멱등 키·트랜잭션을 재확인합니다.

순서는: 최소 재현 → 최근 변경만 좁히기 → 환경 차이 → 메트릭/로그로 가설 → 고치고 부하/회귀. JWT만 의심하지 말고 주변부터 봤을 때가 많았습니다.