본문으로 건너뛰기
Previous
Next
JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제

JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제

JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제

이 글의 핵심

JWT 인증을 실무에 적용하는 완벽 가이드. JWT 원리부터 Access Token, Refresh Token, 보안 모범 사례, Next.js/Express 구현까지. JWT·인증·보안 중심으로 설명합니다. Start now.

이 글의 핵심

한 줄로 말하면: JWT는 편의용 포맷이지, “세션 대체”가 아니다.
아래는 원리·코드·실수까지 있는데, 그 전에 제 의견부터 박을게요. JWT를 세션으로 쓰지 마세요. 긴 만료, 로컬스토에 심는 패턴, “그냥 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: 응답

1. 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 조합 등이 반복된다.


2. 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);
}

3. 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 });
});

4. 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>
  );
}

5. 보안 모범 사례

1. 비밀 키 관리

# .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);

2. 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 취약

3. 짧은 만료 시간

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

4. 토큰 무효화 (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' });
  }
  
  // 토큰 검증...
}

5. 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();
}

6. 자주 하는 실수와 해결법

문제 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') {
    // 만료 처리
  }
}

7. 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+저장소 둘 중 하나는 받아들인다

같이 보면 좋은 글

  • 웹 보안 완벽 가이드 | OWASP Top 10·XSS·CSRF
  • Next.js 15 완벽 가이드 | App Router·Server Actions
  • Docker Compose 실전 가이드 | 멀티 컨테이너·배포

이 글에서 다루는 키워드

JWT, 인증, Authentication, Access Token, Refresh Token, 보안, Security, OAuth

자주 묻는 질문 (FAQ)

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

A. 웬만하면 싫다 쪽에 한 표. XSS 나오면 토큰 그대로 나가요. Access는 메모리, Refresh는 HttpOnly(가능한 경우) 쪽이 낫습니다.

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

A. 표준만으로 바로 취소는 애매해요. 짧게 쓰거나, Redis 같은 데 블랙리스트/jti를 쓰는 식으로 감수합니다. “그래서 JWT를 길게 세션처럼 쓰지 말자”가 다시 나오죠.

Q. Access는 얼마가 적당?

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 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  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 vs 로컬 diff.
  • 환경 괴리 — 시크릿, 리전, 기본값. 단일 스키마로 고정.
  • 데이터 꼬임 — 멱등 없이 재시도, 캐시 무효 누락. 멱등 키·트랜잭션 재확인.

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

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.