JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증

JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증

이 글의 핵심

JWT로 상태 없이 검증하는 흐름과, OAuth로 외부 로그인을 붙인 뒤 우리 서비스 토큰을 발급하는 흐름을 예제로 정리한다. 이론 체크리스트보다는 배포에서 자주 묻는 지점 위주다.


목차

  1. 인증이란?
  2. 세션 vs 토큰 인증
  3. JWT 기초
  4. JWT 구현 (Node.js)
  5. Refresh Token 전략
  6. OAuth 2.0 기초
  7. OAuth 2.0 구현
  8. 보안 모범 사례

사전 지식 (초보자를 위한 기초)

1. 인증(Authentication) vs 인가(Authorization)

인증 (Authentication): “당신은 누구인가?”

로그인 흐름 예:
- 사용자가 아이디·비밀번호를 보낸다
- 서버가 검증해 본인이면 이후 요청에 쓸 수단(세션·토큰 등)을 준다

인가 (Authorization): “무엇을 할 수 있는가?”

권한 확인 예:
- 삭제 요청이 오면 역할(관리자·일반)을 본다
- 정책에 없으면 거절한다

인증은 “누구인지”, 인가는 “무엇을 할 수 있는지”다. 둘을 섞어 말하면 설계가 흐려지기 쉽다.

2. HTTP는 Stateless

Stateless는 서버가 이전 요청을 기억하지 않는다는 뜻이다.

문제 상황:

요청 1: POST /login (로그인 성공!)
요청 2: GET /profile (누구인지 모름)
요청 3: GET /posts (누구인지 모름)

HTTP는 각 요청을 독립적으로 처리하므로
서버는 "이 사용자가 로그인했다"는 것을 기억 못함!

해결 방법:

  1. 세션: 서버에 로그인 정보 저장
  2. 토큰: 클라이언트가 신분증(토큰) 들고 다님

3. 쿠키(Cookie)란?

쿠키는 브라우저가 저장했다가 같은 사이트 요청에 실어 보내는 작은 데이터다.

// 서버가 쿠키 설정
res.cookie('sessionId', 'abc123', {
  httpOnly: true,  // JavaScript로 접근 불가 (보안)
  secure: true,    // HTTPS에서만 전송
  maxAge: 3600000  // 1시간
});

// 이후 모든 요청에 자동으로 쿠키 포함
// Cookie: sessionId=abc123

1. 인증이란?

인증의 필요성

인증이 없다면:
- 누구나 다른 사람의 정보 조회 가능
- 누구나 게시글 삭제 가능
- 보안 사고 발생!

인증이 있다면:
- 로그인한 사용자만 접근
- 본인 데이터만 조회/수정
- 안전한 서비스 운영

인증 방식의 진화

1세대: 기본 인증 (Basic Auth)
  - ID/PW를 매 요청마다 전송
  - 보안 취약 (Base64 인코딩만)

2세대: 세션 기반 인증
  - 서버에 로그인 정보 저장
  - 세션 ID를 쿠키로 전송
  - 서버 메모리 사용

3세대: 토큰 기반 인증 (JWT)
  - 서버가 상태 저장 안함
  - 토큰에 모든 정보 포함
  - 확장성 좋음

4세대: OAuth 2.0
  - 소셜 로그인 (구글, 카카오)
  - 제3자 인증 위임

2. 세션 vs 토큰 인증

세션 기반 인증

┌─────────┐                    ┌─────────┐
│ Client  │                    │ Server  │
└────┬────┘                    └────┬────┘
     │                              │
     │ 1. POST /login               │
     │ (ID: alice, PW: 1234)        │
     ├─────────────────────────────>│
     │                              │ 2. 비밀번호 확인
     │                              │ 3. 세션 생성 (메모리 저장)
     │                              │    sessions['abc123'] = { userId: 1 }
     │ 4. Set-Cookie: sessionId=abc123
     │<─────────────────────────────┤
     │                              │
     │ 5. GET /profile              │
     │ Cookie: sessionId=abc123     │
     ├─────────────────────────────>│
     │                              │ 6. 세션 확인
     │                              │    sessions['abc123'] → userId: 1
     │ 7. { name: "Alice", ... }    │
     │<─────────────────────────────┤

장점:

  • ✅ 서버에서 세션 제어 가능 (강제 로그아웃)
  • ✅ 세션 데이터 서버에서 관리

단점:

  • ❌ 서버 메모리 사용 (사용자 많으면 부담)
  • ❌ 수평 확장 어려움 (세션 공유 필요)
  • ❌ CORS 문제 (쿠키 전송 제한)

토큰 기반 인증 (JWT)

┌─────────┐                    ┌─────────┐
│ Client  │                    │ Server  │
└────┬────┘                    └────┬────┘
     │                              │
     │ 1. POST /login               │
     │ (ID: alice, PW: 1234)        │
     ├─────────────────────────────>│
     │                              │ 2. 비밀번호 확인
     │                              │ 3. JWT 생성 (서명)
     │                              │    token = sign({ userId: 1 })
     │ 4. { token: "eyJhbG..." }    │
     │<─────────────────────────────┤
     │ 5. localStorage에 저장        │
     │                              │
     │ 6. GET /profile              │
     │ Authorization: Bearer eyJhbG...
     ├─────────────────────────────>│
     │                              │ 7. JWT 검증 (서명 확인)
     │                              │    verify(token) → userId: 1
     │ 8. { name: "Alice", ... }    │
     │<─────────────────────────────┤

장점:

  • ✅ 서버가 상태 저장 안함 (Stateless)
  • ✅ 수평 확장 쉬움
  • ✅ 모바일 앱 친화적

단점:

  • ❌ 토큰 크기 큼 (매 요청마다 전송)
  • ❌ 강제 로그아웃 어려움
  • ❌ 토큰 탈취 시 위험

3. JWT 기초

JWT 구조

JWT (JSON Web Token)는 3개 부분으로 구성됩니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTYxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└────────────── Header ──────────────┘ └─────────── Payload ───────────┘ └──────────── Signature ────────────┘

1) Header (헤더)

{
  "alg": "HS256",  // 알고리즘
  "typ": "JWT"     // 타입
}

2) Payload (페이로드)

{
  "userId": 1,
  "email": "[email protected]",
  "role": "admin",
  "iat": 1616239022,  // 발급 시간
  "exp": 1616242622   // 만료 시간
}

3) Signature (서명)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret  // 비밀 키
)

JWT 동작 원리

1. 로그인 성공 시:
   서버가 JWT 생성 → 클라이언트에게 전달

2. 이후 요청 시:
   클라이언트가 JWT를 헤더에 포함해서 전송
   Authorization: Bearer <token>

3. 서버 검증:
   - 서명 확인 (위조 여부)
   - 만료 시간 확인
   - Payload에서 사용자 정보 추출

4. JWT 구현 (Node.js)

패키지 설치

npm install express jsonwebtoken bcrypt

기본 구현

server.js

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

const SECRET_KEY = 'your-secret-key-change-this';
const users = []; // 실제로는 DB 사용

// 회원가입
app.post('/register', async (req, res) => {
  const { email, password } = req.body;
  
  // 비밀번호 해싱
  const hashedPassword = await bcrypt.hash(password, 10);
  
  const user = {
    id: users.length + 1,
    email,
    password: hashedPassword
  };
  
  users.push(user);
  res.status(201).json({ message: '회원가입 성공' });
});

// 로그인
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  // 사용자 찾기
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: '사용자를 찾을 수 없습니다' });
  }
  
  // 비밀번호 확인
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    return res.status(401).json({ error: '비밀번호가 틀렸습니다' });
  }
  
  // JWT 생성
  const token = jwt.sign(
    { userId: user.id, email: user.email },  // Payload
    SECRET_KEY,                              // Secret
    { expiresIn: '1h' }                      // 옵션
  );
  
  res.json({ token });
});

// 인증 미들웨어
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: '토큰이 없습니다' });
  }
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      return res.status(403).json({ error: '유효하지 않은 토큰입니다' });
    }
    req.user = user;
    next();
  });
}

// 보호된 라우트
app.get('/profile', authenticateToken, (req, res) => {
  // req.user에 JWT payload 정보 있음
  res.json({
    message: '프로필 조회 성공',
    user: req.user
  });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

사용 예제

# 1. 회원가입
curl -X POST http://localhost:3000/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"1234"}'

# 2. 로그인
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"1234"}'
# 응답: {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

# 3. 프로필 조회 (토큰 필요)
curl http://localhost:3000/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

5. Refresh Token 전략

문제: Access Token 만료

Access Token 유효 기간: 15분

15분 후:
- 사용자가 페이지 사용 중
- 토큰 만료 → 로그인 화면으로 이동 😱
- 사용자 불편!

해결: Refresh Token

Access Token (짧은 수명):
- 유효 기간: 15분
- API 요청에 사용
- 탈취 시 피해 최소화

Refresh Token (긴 수명):
- 유효 기간: 7일
- Access Token 재발급에만 사용
- DB에 저장 (강제 로그아웃 가능)

Refresh Token 구현

const jwt = require('jsonwebtoken');

const ACCESS_TOKEN_SECRET = 'access-secret';
const REFRESH_TOKEN_SECRET = 'refresh-secret';

let refreshTokens = []; // 실제로는 DB 사용

// 로그인
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  // 사용자 인증 (생략)
  const user = { id: 1, email };
  
  // Access Token 생성 (15분)
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
  
  // Refresh Token 생성 (7일)
  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
  
  // Refresh Token 저장 (DB)
  refreshTokens.push(refreshToken);
  
  res.json({ accessToken, refreshToken });
});

// Access Token 재발급
app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh Token이 없습니다' });
  }
  
  // Refresh Token이 DB에 있는지 확인
  if (!refreshTokens.includes(refreshToken)) {
    return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
  }
  
  // Refresh Token 검증
  jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: '만료된 Refresh Token' });
    }
    
    // 새로운 Access Token 발급
    const accessToken = jwt.sign(
      { userId: user.userId },
      ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    
    res.json({ accessToken });
  });
});

// 로그아웃
app.post('/logout', (req, res) => {
  const { refreshToken } = req.body;
  
  // Refresh Token 삭제 (DB에서 제거)
  refreshTokens = refreshTokens.filter(token => token !== refreshToken);
  
  res.json({ message: '로그아웃 성공' });
});

클라이언트 구현 (React)

// API 요청 함수
async function apiRequest(url, options = {}) {
  let accessToken = localStorage.getItem('accessToken');
  
  // Access Token 포함
  options.headers = {
    ...options.headers,
    'Authorization': `Bearer ${accessToken}`
  };
  
  let response = await fetch(url, options);
  
  // Access Token 만료 시
  if (response.status === 401) {
    // Refresh Token으로 재발급
    const refreshToken = localStorage.getItem('refreshToken');
    const refreshResponse = await fetch('/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken })
    });
    
    if (refreshResponse.ok) {
      const { accessToken: newAccessToken } = await refreshResponse.json();
      localStorage.setItem('accessToken', newAccessToken);
      
      // 원래 요청 재시도
      options.headers['Authorization'] = `Bearer ${newAccessToken}`;
      response = await fetch(url, options);
    } else {
      // Refresh Token도 만료 → 로그인 페이지로
      window.location.href = '/login';
    }
  }
  
  return response;
}

// 사용 예시
const data = await apiRequest('/api/profile').then(r => r.json());

6. OAuth 2.0 기초

OAuth 2.0이란?

OAuth 2.0은 제3자 앱이 사용자 동의 하에 리소스에 접근하도록 권한을 위임하는 프로토콜이다.

비유:

호텔 발렛 파킹:
- 당신: 리소스 소유자 (차 주인)
- 발렛 직원: 제3자 애플리케이션
- 차: 보호된 리소스
- 발렛 키: Access Token (트렁크 못 열게 제한)

OAuth도 마찬가지:
- 구글 계정 (리소스)
- 우리 앱 (제3자)
- 제한된 권한 (이메일만 조회)

OAuth 2.0 플로우

┌─────────┐                  ┌─────────┐                  ┌─────────┐
│  User   │                  │  Our    │                  │ Google  │
│         │                  │  App    │                  │  (OAuth)│
└────┬────┘                  └────┬────┘                  └────┬────┘
     │                            │                            │
     │ 1. "구글로 로그인" 클릭     │                            │
     ├───────────────────────────>│                            │
     │                            │ 2. 구글 로그인 페이지로 리다이렉트
     │                            ├───────────────────────────>│
     │ 3. 구글 로그인 화면         │                            │
     │<────────────────────────────────────────────────────────┤
     │                            │                            │
     │ 4. ID/PW 입력, 권한 승인    │                            │
     ├────────────────────────────────────────────────────────>│
     │                            │                            │ 5. 인증 코드 생성
     │ 6. 인증 코드와 함께 리다이렉트                            │
     │<────────────────────────────────────────────────────────┤
     │                            │                            │
     │ 7. 인증 코드 전달           │                            │
     ├───────────────────────────>│                            │
     │                            │ 8. 인증 코드로 Access Token 요청
     │                            ├───────────────────────────>│
     │                            │ 9. Access Token 발급        │
     │                            │<───────────────────────────┤
     │                            │ 10. 사용자 정보 요청        │
     │                            ├───────────────────────────>│
     │                            │ 11. 사용자 정보 (이메일 등) │
     │                            │<───────────────────────────┤
     │ 12. 로그인 완료             │                            │
     │<───────────────────────────┤                            │

OAuth 2.0 주요 용어

1) Resource Owner (리소스 소유자)

  • 사용자 (당신)

2) Client (클라이언트)

  • 우리 애플리케이션

3) Authorization Server (인증 서버)

  • 구글, 카카오 등의 OAuth 서버

4) Resource Server (리소스 서버)

  • 사용자 정보를 제공하는 서버

5) Authorization Code (인증 코드)

  • 임시 코드, Access Token으로 교환

6) Access Token (액세스 토큰)

  • 리소스 접근 권한

7. OAuth 2.0 구현

Google OAuth 설정

1) Google Cloud Console 설정

1. https://console.cloud.google.com 접속
2. 프로젝트 생성
3. "API 및 서비스" → "사용자 인증 정보"
4. "OAuth 2.0 클라이언트 ID" 생성
5. 승인된 리디렉션 URI 추가:
   http://localhost:3000/auth/google/callback
6. Client ID와 Client Secret 복사

Passport.js를 사용한 구현

npm install passport passport-google-oauth20 express-session

server.js

const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');

const app = express();

// 세션 설정
app.use(session({
  secret: 'session-secret',
  resave: false,
  saveUninitialized: false
}));

app.use(passport.initialize());
app.use(passport.session());

// Google OAuth 전략 설정
passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: 'http://localhost:3000/auth/google/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    // 사용자 정보 저장 (DB)
    const user = {
      id: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
      picture: profile.photos[0].value
    };
    
    return done(null, user);
  }
));

// 세션 직렬화
passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser((id, done) => {
  // DB에서 사용자 조회
  const user = { id, email: '[email protected]' };
  done(null, user);
});

// 구글 로그인 시작
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

// 구글 로그인 콜백
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => {
    // 로그인 성공
    res.redirect('/dashboard');
  }
);

// 로그아웃
app.get('/logout', (req, res) => {
  req.logout((err) => {
    if (err) return next(err);
    res.redirect('/');
  });
});

// 보호된 라우트
app.get('/dashboard', (req, res) => {
  if (!req.isAuthenticated()) {
    return res.redirect('/login');
  }
  res.json({ user: req.user });
});

app.listen(3000);

프론트엔드 구현

<!DOCTYPE html>
<html>
<head>
  <title>OAuth Login</title>
</head>
<body>
  <h1>로그인</h1>
  
  <!-- 구글 로그인 버튼 -->
  <a href="/auth/google">
    <button>구글로 로그인</button>
  </a>
  
  <!-- 카카오 로그인 버튼 -->
  <a href="/auth/kakao">
    <button>카카오로 로그인</button>
  </a>
</body>
</html>

8. 카카오 OAuth 구현

카카오 개발자 설정

1. https://developers.kakao.com 접속
2. 애플리케이션 추가
3. "플랫폼" → "Web" 추가
4. Redirect URI 설정:
   http://localhost:3000/auth/kakao/callback
5. "동의 항목" 설정 (이메일, 프로필)
6. REST API 키 복사

Passport-Kakao 구현

npm install passport-kakao
const KakaoStrategy = require('passport-kakao').Strategy;

passport.use(new KakaoStrategy({
    clientID: process.env.KAKAO_CLIENT_ID,
    callbackURL: 'http://localhost:3000/auth/kakao/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    const user = {
      id: profile.id,
      email: profile._json.kakao_account.email,
      name: profile.displayName,
      picture: profile._json.properties.profile_image
    };
    
    return done(null, user);
  }
));

// 카카오 로그인 라우트
app.get('/auth/kakao',
  passport.authenticate('kakao')
);

app.get('/auth/kakao/callback',
  passport.authenticate('kakao', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

9. 보안 모범 사례

JWT 보안

1) Secret Key 관리

// ❌ 나쁜 예: 코드에 하드코딩
const SECRET = 'my-secret-key';

// ✅ 좋은 예: 환경 변수 사용
const SECRET = process.env.JWT_SECRET;

// .env 파일
JWT_SECRET=randomly-generated-long-secret-key-256-bits

2) HTTPS 사용 필수

// ❌ HTTP에서 토큰 전송 (탈취 위험)
http://example.com/api?token=eyJhbG...

// ✅ HTTPS에서만 토큰 전송
https://example.com/api
Authorization: Bearer eyJhbG...

3) 짧은 만료 시간

// ❌ 너무 긴 만료 시간
jwt.sign(payload, secret, { expiresIn: '30d' });

// ✅ 짧은 만료 시간 + Refresh Token
jwt.sign(payload, secret, { expiresIn: '15m' });

4) 민감한 정보 저장 금지

// ❌ 비밀번호 저장
const token = jwt.sign({ userId: 1, password: '1234' }, secret);

// ✅ 최소한의 정보만
const token = jwt.sign({ userId: 1, role: 'user' }, secret);

XSS 방어

XSS (Cross-Site Scripting): 악성 스크립트 삽입 공격

// ❌ localStorage에 토큰 저장 (XSS 취약)
localStorage.setItem('token', token);
// 악성 스크립트: localStorage.getItem('token')

// ✅ HttpOnly 쿠키에 저장
res.cookie('token', token, {
  httpOnly: true,  // JavaScript로 접근 불가
  secure: true,    // HTTPS에서만 전송
  sameSite: 'strict'  // CSRF 방어
});

CSRF 방어

CSRF (Cross-Site Request Forgery): 사용자 모르게 요청 전송

// CSRF 토큰 생성
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.use(csrfProtection);

app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/process', csrfProtection, (req, res) => {
  res.send('처리 완료');
});
<!-- 폼에 CSRF 토큰 포함 -->
<form method="POST" action="/process">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>">
  <button type="submit">제출</button>
</form>

10. 실전 패턴

역할 기반 접근 제어 (RBAC)

// 역할 확인 미들웨어
function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ error: '권한이 없습니다' });
    }
    next();
  };
}

// 관리자만 접근 가능
app.delete('/users/:id', 
  authenticateToken, 
  requireRole('admin'), 
  (req, res) => {
    // 사용자 삭제
    res.json({ message: '삭제 완료' });
  }
);

// 다중 역할 지원
function requireAnyRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: '권한이 없습니다' });
    }
    next();
  };
}

app.get('/admin/dashboard',
  authenticateToken,
  requireAnyRole('admin', 'moderator'),
  (req, res) => {
    res.json({ message: '관리자 대시보드' });
  }
);

토큰 블랙리스트

const redis = require('redis');
const client = redis.createClient();

// 로그아웃 시 토큰 블랙리스트에 추가
app.post('/logout', authenticateToken, async (req, res) => {
  const token = req.headers['authorization'].split(' ')[1];
  
  // JWT에서 만료 시간 추출
  const decoded = jwt.decode(token);
  const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
  
  // Redis에 블랙리스트 저장
  await client.setex(`blacklist:${token}`, expiresIn, 'true');
  
  res.json({ message: '로그아웃 성공' });
});

// 인증 미들웨어에서 블랙리스트 확인
async function authenticateToken(req, res, next) {
  const token = req.headers['authorization']?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: '토큰이 없습니다' });
  }
  
  // 블랙리스트 확인
  const isBlacklisted = await client.get(`blacklist:${token}`);
  if (isBlacklisted) {
    return res.status(401).json({ error: '로그아웃된 토큰입니다' });
  }
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) return res.status(403).json({ error: '유효하지 않은 토큰' });
    req.user = user;
    next();
  });
}

11. 비교표

인증 방식 비교

항목세션JWTOAuth 2.0
저장 위치서버 메모리/DB클라이언트제3자 서버
확장성낮음높음높음
강제 로그아웃쉬움어려움쉬움
CORS제한적자유로움자유로움
모바일 앱어려움쉬움쉬움
복잡도낮음중간높음
보안높음중간높음

선택 가이드

세션 사용:
- 전통적인 웹 애플리케이션
- 서버 1대 또는 세션 공유 가능
- 강력한 보안 필요

JWT 사용:
- RESTful API
- 마이크로서비스
- 모바일 앱
- 수평 확장 필요

OAuth 2.0 사용:
- 소셜 로그인 필요
- 제3자 API 접근
- 사용자 편의성 중요

12. 트러블슈팅

자주 발생하는 문제

1) “jwt malformed” 에러

// 원인: 잘못된 토큰 형식
// 해결: Bearer 접두사 제거
const token = authHeader.split(' ')[1]; // "Bearer TOKEN"

2) “jwt expired” 에러

// 원인: 토큰 만료
// 해결: Refresh Token으로 재발급
if (err.name === 'TokenExpiredError') {
  return res.status(401).json({ error: 'expired', message: '토큰이 만료되었습니다' });
}

3) CORS 에러

// 해결: CORS 설정
const cors = require('cors');
app.use(cors({
  origin: 'http://localhost:3001',
  credentials: true  // 쿠키 전송 허용
}));

4) OAuth 콜백 에러

원인: Redirect URI 불일치
해결:
1. OAuth 제공자 설정 확인
2. 정확한 URI 입력 (http vs https, 포트 번호)
3. 로컬 테스트: http://localhost:3000/auth/google/callback

13. 프로덕션 체크리스트

보안 체크리스트

✅ HTTPS 사용
✅ Secret Key 환경 변수로 관리
✅ 짧은 Access Token 만료 시간 (15분)
✅ Refresh Token DB 저장
✅ 비밀번호 해싱 (bcrypt, scrypt)
✅ Rate Limiting (무차별 대입 공격 방어)
✅ CORS 설정
✅ HttpOnly, Secure 쿠키
✅ XSS 방어 (입력 검증, sanitization)
✅ CSRF 토큰

Rate Limiting 구현

const rateLimit = require('express-rate-limit');

// 로그인 엔드포인트 제한
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15분
  max: 5, // 최대 5회 시도
  message: '너무 많은 로그인 시도입니다. 15분 후 다시 시도하세요.'
});

app.post('/login', loginLimiter, async (req, res) => {
  // 로그인 로직
});

14. 실전 예제: 완전한 인증 시스템

프로젝트 구조

auth-system/
├── src/
│   ├── config/
│   │   └── passport.js
│   ├── middleware/
│   │   ├── auth.js
│   │   └── rateLimiter.js
│   ├── models/
│   │   └── User.js
│   ├── routes/
│   │   ├── auth.js
│   │   └── users.js
│   └── server.js
├── .env
└── package.json

완전한 구현

src/middleware/auth.js

const jwt = require('jsonwebtoken');

exports.authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: '인증이 필요합니다' });
  }
  
  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'expired' });
      }
      return res.status(403).json({ error: '유효하지 않은 토큰' });
    }
    req.user = user;
    next();
  });
};

exports.requireRole = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: '권한이 없습니다' });
    }
    next();
  };
};

src/routes/auth.js

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');

const router = express.Router();

// 회원가입
router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = req.body;
    
    // 이메일 중복 확인
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
    }
    
    // 비밀번호 해싱
    const hashedPassword = await bcrypt.hash(password, 10);
    
    // 사용자 생성
    const user = await User.create({
      email,
      password: hashedPassword,
      name
    });
    
    res.status(201).json({ 
      message: '회원가입 성공',
      userId: user.id 
    });
  } catch (error) {
    res.status(500).json({ error: '서버 오류' });
  }
});

// 로그인
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // 사용자 찾기
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: '이메일 또는 비밀번호가 틀렸습니다' });
    }
    
    // 비밀번호 확인
    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) {
      return res.status(401).json({ error: '이메일 또는 비밀번호가 틀렸습니다' });
    }
    
    // Access Token 생성
    const accessToken = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    
    // Refresh Token 생성
    const refreshToken = jwt.sign(
      { userId: user.id },
      process.env.REFRESH_TOKEN_SECRET,
      { expiresIn: '7d' }
    );
    
    // Refresh Token DB에 저장
    await user.update({ refreshToken });
    
    res.json({ accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ error: '서버 오류' });
  }
});

// 토큰 재발급
router.post('/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    if (!refreshToken) {
      return res.status(401).json({ error: 'Refresh Token이 없습니다' });
    }
    
    // JWT 검증
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    
    // DB에서 Refresh Token 확인
    const user = await User.findOne({ 
      id: decoded.userId, 
      refreshToken 
    });
    
    if (!user) {
      return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
    }
    
    // 새로운 Access Token 발급
    const accessToken = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );
    
    res.json({ accessToken });
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Refresh Token이 만료되었습니다' });
    }
    res.status(403).json({ error: '유효하지 않은 Refresh Token' });
  }
});

// 로그아웃
router.post('/logout', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    // DB에서 Refresh Token 삭제
    await User.updateOne(
      { refreshToken },
      { $unset: { refreshToken: 1 } }
    );
    
    res.json({ message: '로그아웃 성공' });
  } catch (error) {
    res.status(500).json({ error: '서버 오류' });
  }
});

module.exports = router;

15. 테스트

JWT 테스트

const request = require('supertest');
const app = require('./server');

describe('Authentication', () => {
  let accessToken;
  
  it('회원가입 성공', async () => {
    const res = await request(app)
      .post('/register')
      .send({
        email: '[email protected]',
        password: '1234',
        name: 'Test User'
      });
    
    expect(res.status).toBe(201);
  });
  
  it('로그인 성공', async () => {
    const res = await request(app)
      .post('/login')
      .send({
        email: '[email protected]',
        password: '1234'
      });
    
    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('accessToken');
    accessToken = res.body.accessToken;
  });
  
  it('보호된 라우트 접근', async () => {
    const res = await request(app)
      .get('/profile')
      .set('Authorization', `Bearer ${accessToken}`);
    
    expect(res.status).toBe(200);
  });
  
  it('토큰 없이 접근 시 401', async () => {
    const res = await request(app)
      .get('/profile');
    
    expect(res.status).toBe(401);
  });
});

FAQ

Q1. JWT를 어디에 저장해야 하나요?

  • localStorage: 편리하지만 XSS 취약
  • HttpOnly Cookie: 더 안전하지만 CSRF 방어 필요
  • 추천: HttpOnly Cookie + CSRF 토큰

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

  • 일반적: 15분~1시간
  • 민감한 서비스: 5~15분
  • 덜 민감한 서비스: 1~24시간

Q3. Refresh Token은 어디에 저장하나요?

  • DB에 저장 (강제 로그아웃 가능)
  • Redis에 저장 (빠른 조회)

Q4. OAuth 2.0과 JWT를 함께 쓰나?

흔한 패턴이다. IdP에서 인가 코드로 사용자 정보를 받은 뒤, 우리 서비스 세션·JWT를 발급해 쓴다.

app.get('/auth/google/callback',
  passport.authenticate('google'),
  (req, res) => {
    // OAuth 로그인 성공 후 JWT 발급
    const token = jwt.sign({ userId: req.user.id }, SECRET);
    res.json({ token });
  }
);

요약

핵심 정리

JWT:

  • 토큰 기반 인증
  • Stateless, 확장성 좋음
  • Access Token + Refresh Token 조합

OAuth 2.0:

  • 소셜 로그인
  • 제3자 인증 위임
  • 사용자 편의성

보안:

  • HTTPS 필수
  • HttpOnly 쿠키
  • 짧은 만료 시간
  • Rate Limiting

다음 글 추천

  • Node.js 보안 가이드
  • API 보안 체크리스트
  • HTTPS·SSL/TLS 정리

키워드: JWT, OAuth, 인증, Authentication, Token, Access Token, Refresh Token, OAuth 2.0, 보안, Security, 소셜 로그인

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3