본문으로 건너뛰기
Previous
Next
JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증

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

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

이 글의 핵심

JWT & OAuth 2.0 인증 실전 가이드에 대해 정리한 개발 블로그 글입니다. JWT로 상태 없이 검증하는 흐름과, OAuth로 외부 로그인을 붙인 뒤 우리 서비스 토큰을 발급하는 흐름을 예제로 정리한다. 이론 체크리스트보다는 배포에서 자주 묻는 지점 위주다. --- 인증… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: JWT,…

이 글의 핵심

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

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

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에서 사용자 정보 추출

JWT 내부 동작 심화 (JWS·검증·클레임·갱신)

실무에서 말하는 JWT 문자열은 RFC 7519 클레임을 RFC 7515 JWS로 만든 컴팩트 서명이다. 서명은 base64url(header) + "." + base64url(payload)에 대해 alg(예: HS256, RS256)로 계산된다. decode만으로는 위조 여부를 알 수 없다 — 반드시 같은 alg와 키로 서명을 재검증한 뒤 exp/nbf/iat를 확인해야 한다.

HS256은 대칭 키를 발급·검증에 공유하고, RS256은 발급자만 개인키를 쓰고 검증자는 공개키(JWKS 등)만 받는다. OAuth/OIDC 연동 시 iss·aud를 검증하지 않으면 다른 리소스 서버용으로 발급된 토큰을 잘못 수용할 수 있다. jti는 토큰별 고유 ID로, Refresh 회전·재사용 탐지·denylist와 맞물리면 로그아웃·탈취 대응에 유리하다. 아래 절의 Node 예제는 이 전제 위에서 jwt.sign / jwt.verify를 사용한다.


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());

OAuth 구현하면서 겪은 것들

OAuth 붙인다고 문서대로만 하면 되는 줄 알았는데, 첫 프로덕션에서는 거의 항상 리다이렉트 URI 한 글자state 빼먹은 것 때문에 밤을 샌다. 나는 로컬에선 됐는데 스테이징에서만 redirect_uri_mismatch 나는 날, 콘솔에 등록한 URL과 실제 콜백이 미세하게 다른 걸 찾느라 로그를 뒤졌다. 그리고 결론을 하나 박아 둔다. PKCE는 선택이 아니라 필수다. SPA·모바일·공개 클라이언트면 더더욱, 인가 코드만 믿고 가면 가로채기 시나리오에 너무 쉽게 열린다. 서버 쪽 기밀 클라이언트라고 해서 “우린 시크릿 있으니까” 하고 PKCE를 건너뛰는 팀도 봤는데, 난 이제 새 코드 플로우는 무조건 PKCE부터 깔고 본다. OAuth 2.1 쪽 흐름이 그쪽으로 기울어 있고, 구현해놓고 나면 디버깅할 때도 마음이 편하다. 아래는 그때 정리해둔 플로우·용어 쪽이다. 표로 암기하려다 말고, 한 번 브라우저 네트워크 탭으로 authorize → 콜백 → token 순서를 직접 밟아보면 훨씬 빨리 몸에 붙는다.


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 (액세스 토큰)
  • 리소스 접근 권한

인가 코드(Authorization Code) 플로우 — 내부 동작

위 다이어그램은 사실상 인가 코드 부여(Authorization Code Grant) 흐름이다. OAuth 2.0에서 “로그인 버튼 → 구글 → 다시 우리 앱”으로 돌아오는 구간을 프론트 채널(브라우저 리다이렉트) 이라 부르고, 인증 코드를 액세스 토큰으로 바꾸는 HTTP POST백 채널(서버 간) 에서 이루어지는 경우가 많다. 이 분리가 핵심이다.

왜 리다이렉트 URL에 바로 액세스 토큰을 실어 보내지 않는가?
히스토리·호환 이슈도 있지만, 실무적으로는 토큰이 URL·브라우저 기록·Referer 에 노출될 위험을 줄이기 위해 짧게 살아 있는 일회성 코드만 프론트에 넘기고, 클라이언트 비밀(client secret) 이 있을 수 있는 백엔드에서 토큰 엔드포인트로 교환하는 패턴이 널리 쓰인다. 공개 클라이언트(모바일·순수 SPA)는 시크릿을 안전하게 숨길 수 없으므로 PKCE로 같은 위협을 완화한다(아래 참고).

전형적인 단계 (RFC 6749 기준 개념):

  1. 인가 요청: 클라이언트가 사용자 브라우저를 인가 엔드포인트로 보낸다. response_type=code코드를 요청한다.
  2. 사용자 인증·동의: 인증 서버가 로그인·동의 화면을 보여 준다.
  3. 리다이렉트: 승인되면 redirect_uri 로 돌아오며 쿼리에 code 가 붙는다. 동시에 CSRF 방지용 state 를 원래 보냈다면 그대로 돌려받아 검증한다.
  4. 토큰 요청: 클라이언트(보통 백엔드)가 토큰 엔드포인트grant_type=authorization_codecode, redirect_uri, 클라이언트 식별 정보 등을 보내 액세스 토큰(및 경우에 따라 리프레시 토큰)을 받는다.
  5. 리소스 접근: 액세스 토큰으로 리소스 서버(또는 사용자 정보 엔드포인트)를 호출한다.

클라이언트 유형도 같이 이해해야 한다. 기밀(confidential) 클라이언트는 서버에서 동작하며 client secret 을 숨길 수 있고, 공개(public) 클라이언트는 시크릿 없이 동작하므로 PKCE·리다이렉트 URI 엄격 일치 같은 보완이 필수에 가깝다.

PKCE(Proof Key for Code Exchange)

PKCE는 인가 코드가 가로채였을 때 공격자가 그 코드로 토큰을 받아가는 상황을 어렵게 만든다. 코드 검증기(code_verifier) 를 클라이언트가 만들고, 그로부터 코드 챌린지(code_challenge) 만 인가 요청에 넣는다. 토큰 교환 시에는 원본 code_verifier 를 토큰 엔드포인트에 보내 검증한다.

흐름 요약:

  • code_verifier: 길고 예측하기 어려운 난수 문자열(일반적으로 43–128자 범위의 URL-safe 문자).
  • code_challenge: BASE64URL(SHA256(code_verifier)) (S256 방법이 사실상 표준).
  • 인가 요청 시 code_challengecode_challenge_method=S256 를 전달한다.
  • 토큰 요청 시 code_verifier 를 함께 보내면, 인증 서버가 동일한 해시인지 확인한다.

브라우저 기반 SPA·모바일 앱처럼 client secret 을 둘 수 없는 환경에서 인가 코드 플로우 + PKCE 는 “권장” 수준이 아니라, 나는 그냥 필수라고 본다. 백엔드만 있고 시크릿이 있다고 해도, 새로 짜는 인가 코드 플로우에는 PKCE를 기본으로 넣는 쪽이 이후 감사·보안 질문에도 답이 된다. OAuth 2.1 은 새 구현에 PKCE를 사실상 기대하는 쪽으로 정리되어 있다.

토큰 엔드포인트(Token Endpoint)

토큰 엔드포인트는 액세스 토큰(및 선택적으로 리프레시 토큰)을 발급·갱신하는 HTTPS 전용 API 이다. 인가 코드를 받은 뒤의 첫 교환뿐 아니라, grant_type=refresh_token 으로 액세스 토큰 갱신도 여기서 처리한다.

인가 코드 교환 시(개념적 파라미터):

  • grant_type: authorization_code
  • code: 리다이렉트로 받은 일회성 코드
  • redirect_uri: 인가 요청 때 사용한 것과 동일해야 하는 경우가 많다
  • client_id: 등록된 클라이언트 ID
  • 기밀 클라이언트: client_secret 또는 HTTP Basic 으로 클라이언트 인증
  • PKCE 사용 시: code_verifier

응답은 보통 JSON 이며 access_token, token_type(보통 Bearer), expires_in 을 포함한다. OpenID Connect 를 쓰면 id_token 이 추가되기도 한다. 에러error, error_description 으로 돌아오므로, 프로덕션에서는 이를 로깅·알림에 포함하되 시크릿·코드 값은 마스킹한다.

스코프는 “이 토큰으로 무엇을 할 수 있는지”를 문자열로 표현한 것이다. 공백으로 구분된 목록으로 전달되는 경우가 많고, 제공자마다 정의한 권한 집합(예: 프로필 읽기, 이메일, 캘린더)에 대응한다.

동의 화면은 사용자가 해당 스코프에 실제로 동의했는지를 확보하는 단계다. 최초 로그인 때 많은 권한을 한꺼번에 요구하면 이탈이 늘 수 있어, 필요한 최소 스코프만 요청하고 점진적 인가(incremental authorization) 로 나중에 추가 권한을 요청하는 전략이 권장된다. 이미 부여된 스코프는 세션에 따라 동의 화면을 생략할 수도 있다.

애플리케이션 설계 시 “우리 서비스의 RBAC”“IdP 가 부여한 OAuth 스코프” 를 혼동하지 않는 것이 중요하다. 소셜 로그인 후에는 보통 우리 쪽 사용자 레코드우리 API용 JWT/세션을 별도로 발급해 권한을 관리한다.

프로덕션 OAuth 패턴

실서비스에서 반복되는 패턴을 정리하면 다음과 같다.

  1. 리다이렉트 URI 엄격 일치: 스킴·호스트·포트·경로·슬래시까지 등록값과 완전히 동일하게 맞춘다. redirect_uri_mismatch 는 대부분 여기서 발생한다.
  2. state + PKCE: state 로 CSRF 를 막고, 공개 클라이언트는 PKCE 로 코드 탈취 완화.
  3. 토큰 저장소: 브라우저에서는 가능하면 HttpOnly·Secure·SameSite 쿠키 등으로 XSS 노출면을 줄이고, SPA 는 BFF(백엔드 포 프론트) 패턴으로 토큰을 서버 측에 두는 선택도 흔하다.
  4. 짧은 액세스 토큰 + 리프레시·로테이션: 탈취 윈도우를 줄이고, 리프레시 토큰 재사용 탐지 등으로 계정 탈취에 대응한다.
  5. OIDC 사용 시: id_tokenJWT 이므로 서명 검증에 JWKS 엔드포인트를 사용하고, nonce 로 리플레이를 완화한다.
  6. 관측 가능성: 인가 실패·토큰 엔드포인트 오류율·지연을 대시보드에 올려 고객 지원 문의와 상관분석한다.

이 절의 내용은 Passport 같은 라이브러리가 내부에서 대신해 주는 부분이 많지만, 장애 분석·보안 감사·새 플랫폼(모바일·데스크톱 딥링크) 연동 시에는 위 개념을 직접 알아야 원인을 빠르게 좁힐 수 있다.


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. 세션·JWT·OAuth, 표 말고 감으로 고르기

표로 갈지 말지 비교해보면 막막하다. 내 기준은 이렇다. 전통 웹(서버 렌더) 이고 서버가 세션을 쥐고 있어도 되면 세션이 직관적이고, 강제 로그아웃도 잘 풀린다. API·모바일·수평 확장이 핵심이면 JWT 쪽이 덜 끈적이고, 대신 토큰 보관·만료·탈취 대응이 숙제다. 구글/카카오로 로그인 붙이거나 다른 서비스 리소스를 사용자 대리로 쓰면 그게 곧 OAuth 영역이고, 여기서는 “우리 서비스 권한”이랑 IdP 스코프를 섞지 말고 로그인 후 우리 쪽 토큰/세션 따로 두는 쪽이 정신 건강에 이롭다. 복잡도는 OAuth가 제일 올라가는데, 그만큼 운영에서 묻는 건 리다이렉트·state·PKCE 같은 실전 질문이다. 암기용 표는 없앴으니, 위에 적은 OAuth 구현하면서 단락이랑 같이 보면 된다.

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, 소셜 로그인

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

이 부록은 앞선 본문에서 다룬 주제(「JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

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

앞선 본문 주제(「JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  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최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

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


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

JWT, OAuth, 인증, Authentication, Token, Security, 보안, Access Token, Refresh Token, OAuth 2.0 등으로 검색하시면 이 글이 도움이 됩니다.