JWT Authentication System 구현 가이드: Node.js API 서버 구축
Node.js 백엔드 API 서버에서 JWT(JSON Web Token)를 활용한 안전하고 확장 가능한 인증 시스템을 구축하는 방법을 심층적으로 다룹니다. 아키텍처 설계부터 실제 코드 구현까지 자세히 설명합니다.
JWT Authentication System 구현 가이드: Node.js API 서버 구축
최신 웹 애플리케이션 개발에서 사용자 인증은 필수적인 요소입니다. 특히 RESTful API 기반의 백엔드 서버를 구축할 때는 상태를 저장하지 않는(Stateless) 방식으로 사용자 세션을 관리하는 것이 중요하며, JWT(JSON Web Token)는 이러한 요구사항을 충족시키는 강력한 도구입니다. 이 글에서는 Node.js 환경에서 JWT를 활용한 인증 시스템을 설계하고 구현하는 과정을 상세히 안내해 드립니다.
1. JWT란 무엇이며, 왜 사용해야 할까요?
JWT는 클라이언트와 서버 간에 정보를 안전하게 주고받기 위해 정의된 작고 자체 포함된(self-contained) JSON 객체입니다. 사용자 인증 정보나 기타 데이터를 담아 서명함으로써, 정보가 변조되지 않았음을 확인할 수 있도록 합니다.
JWT의 구조:
JWT는 .으로 구분된 세 부분으로 구성됩니다. Header.Payload.Signature
- Header (헤더): 토큰의 종류(typ)와 서명에 사용된 알고리즘(alg) 정보를 포함합니다.
- Payload (페이로드): 실제 전달하려는 정보를 담는 부분입니다. 사용자 ID, 권한 등 필요한 데이터를
Claim형태로 저장합니다.iss(발행자),exp(만료 시간),sub(주제) 등 미리 정의된 클레임(Registered Claims)과 사용자 정의 클레임(Private Claims)을 사용할 수 있습니다. - Signature (서명): 인코딩된 헤더, 인코딩된 페이로드, 그리고 서버에만 알려진 비밀 키(Secret Key)를 사용하여 생성됩니다. 이 서명을 통해 토큰의 무결성(Integrity)을 검증할 수 있습니다.
JWT의 장점:
- Stateless(무상태성): 서버가 클라이언트의 세션 정보를 저장할 필요가 없어 서버 부하를 줄이고 수평 확장에 유리합니다.
- Scalability(확장성): 여러 서버가 동일한 비밀 키를 공유하여 토큰을 검증할 수 있으므로, 분산 시스템 환경에 적합합니다.
- Self-contained: 토큰 자체에 필요한 모든 정보가 포함되어 있어, 데이터베이스 조회 없이 빠르게 인증 및 권한 부여를 처리할 수 있습니다.
- 보안: 서명 덕분에 토큰이 위변조되지 않았음을 확인할 수 있습니다.
JWT의 단점 및 고려사항:
- 만료 관리: 토큰이 탈취될 경우, 만료되기 전까지는 유효하므로 탈취된 토큰을 즉시 무효화하기 어렵습니다. 이를 위해 Refresh Token 전략을 주로 사용합니다.
- 토큰 크기: Payload에 많은 정보를 담을수록 토큰의 크기가 커져 네트워크 부하가 증가할 수 있습니다. 필요한 최소한의 정보만 담는 것이 좋습니다.
- 저장 위치: 클라이언트 측에서 JWT를 안전하게 저장하는 방법에 대한 고려가 필요합니다. (Local Storage, Session Storage, HttpOnly Cookie 등)
2. JWT 인증 시스템 아키텍처 설계
JWT 인증 시스템의 핵심은 Access Token과 Refresh Token을 함께 사용하는 전략입니다. Access Token은 짧은 만료 시간을 가지고 실제 API 요청에 사용되며, Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받는 용도로 사용됩니다. Refresh Token은 Access Token보다 긴 만료 시간을 가지며, 한 번만 사용되거나 데이터베이스에 저장되어 관리됩니다.
기본 인증 흐름:
- 사용자 로그인: 클라이언트가 사용자 ID와 비밀번호를 서버에 전송합니다.
- 인증 및 토큰 발급: 서버는 사용자 정보를 검증하고, 유효한 사용자라면 Access Token과 Refresh Token을 생성하여 클라이언트에 응답합니다.
- 토큰 저장: 클라이언트는 발급받은 두 토큰을 안전한 곳에 저장합니다. (예: Access Token은 메모리 또는 Local Storage, Refresh Token은 HttpOnly Cookie 또는 데이터베이스)
- API 요청: 클라이언트는 Access Token을 HTTP 헤더(Authorization: Bearer <Access Token>)에 포함하여 보호된 API에 요청합니다.
- Access Token 검증: 서버는 요청된 Access Token의 유효성을 검증합니다.
- 유효한 경우: 요청된 API 작업을 수행하고 응답합니다.
- 만료된 경우: 401 Unauthorized 에러를 반환합니다.
- Access Token 갱신 (Refresh Token 사용): Access Token이 만료되면, 클라이언트는 Refresh Token을 사용하여 새로운 Access Token 발급을 요청합니다.
- Refresh Token 검증 및 재발급: 서버는 Refresh Token의 유효성을 검증하고, 유효하다면 새로운 Access Token과 Refresh Token을 발급하여 클라이언트에 응답합니다. (선택적으로 Refresh Token도 함께 갱신하여 재사용 방지)
- 로그아웃: 클라이언트가 로그아웃을 요청하면, 서버는 Refresh Token을 무효화(데이터베이스에서 삭제)하고 클라이언트는 저장된 토큰을 모두 삭제합니다.
아키텍처 다이어그램 (텍스트 기반):
+------------------+ +-----------------------+ +-----------------+
| Client | | Node.js Backend | | Database |
| (Web/Mobile App) | | (API Server) | | (User/Token DB) |
+------------------+ +-----------------------+ +-----------------+
| | |
| 1. POST /api/register (회원가입) | |
+-------------------------------------->| 사용자 정보 저장 (비밀번호 해싱) |
| +------------------------------------------->|
| 201 Created |<-------------------------------------------+
|<--------------------------------------+ |
| | |
| 2. POST /api/login (로그인) | |
+-------------------------------------->| 사용자 인증, Access Token, Refresh Token 생성 |
| | Refresh Token 저장 (DB 또는 HttpOnly Cookie) |
| +------------------------------------------->|
| 200 OK (Access Token, Refresh Token) |<-------------------------------------------+
|<--------------------------------------+ |
| | |
| 3. GET /api/protected (보호된 리소스 요청) |
| (Header: Authorization: Bearer <Access Token>) |
+-------------------------------------->| Access Token 검증 (미들웨어) |
| | 인증 성공 시 리소스 반환 |
| 200 OK (리소스 데이터) |<-------------------------------------------+
|<--------------------------------------+ |
| | |
| 4. Access Token 만료 시 | |
| POST /api/token (Access Token 갱신) | |
| (Body: { refreshToken: <Refresh Token> }) |
+-------------------------------------->| Refresh Token 검증 (DB 조회) |
| | 새 Access Token, Refresh Token 생성 및 저장|
| 200 OK (New Access Token, New Refresh Token) | |
|<--------------------------------------+<-------------------------------------------+
| | |
| 5. POST /api/logout (로그아웃) | |
+-------------------------------------->| Refresh Token 무효화 (DB 삭제) |
| +------------------------------------------->|
| 200 OK |<-------------------------------------------+
|<--------------------------------------+ |
3. Node.js 환경 설정 및 JWT 라이브러리
Node.js 백엔드 서버 구축을 위해 Express 프레임워크와 JWT 관련 라이브러리들을 설치합니다.
프로젝트 초기화 및 의존성 설치:
mkdir jwt-auth-server
cd jwt-auth-server
npm init -y
npm install express jsonwebtoken bcrypt dotenv
npm install --save-dev nodemon
-
express: 웹 애플리케이션 프레임워크 -
jsonwebtoken: JWT 생성 및 검증 라이브러리 -
bcrypt: 비밀번호 해싱 라이브러리 -
dotenv: 환경 변수 관리를 위한 라이브러리 -
nodemon: 개발 중 서버 자동 재시작을 위한 도구
.env 파일을 생성하여 JWT 비밀 키와 토큰 만료 시간을 설정합니다. 절대 이 비밀 키를 외부에 노출해서는 안 됩니다.
ACCESS_TOKEN_SECRET=your_access_token_secret_key
REFRESH_TOKEN_SECRET=your_refresh_token_secret_key
ACCESS_TOKEN_EXPIRATION=1h
REFRESH_TOKEN_EXPIRATION=7d
PORT=3000
package.json의 scripts에 start와 dev 스크립트를 추가합니다.
{
"name": "jwt-auth-server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
간단한 server.js 파일을 작성하여 서버를 구동할 준비를 합니다.
// server.js
require('dotenv').config(); // .env 파일 로드
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json()); // JSON 요청 본문 파싱
// 임시 사용자 데이터베이스 (실제 환경에서는 MongoDB, PostgreSQL 등 사용)
const users = [];
// 임시 Refresh Token 저장소 (실제 환경에서는 Redis, 데이터베이스 사용)
const refreshTokens = [];
app.get('/', (req, res) => {
res.send('JWT 인증 서버가 실행 중입니다!');
});
// 여기에 인증 관련 API 라우터를 추가할 예정입니다.
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
4. 사용자 인증 API 구현
이제 사용자 회원가입(register)과 로그인(login) API를 구현하여 Access Token과 Refresh Token을 발급하는 로직을 추가합니다.
4.1. 회원가입 (/api/register)
사용자 비밀번호는 반드시 해싱하여 저장해야 합니다. bcrypt 라이브러리를 사용합니다.
// server.js (추가)
const bcrypt = require('bcrypt');
// ... (기존 코드)
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: '사용자 이름과 비밀번호를 입력해주세요.' });
}
// 사용자 이름 중복 확인 (실제 DB에서는 고유성 제약조건 사용)
if (users.find(user => user.username === username)) {
return res.status(409).json({ message: '이미 존재하는 사용자 이름입니다.' });
}
try {
const hashedPassword = await bcrypt.hash(password, 10); // 솔트 라운드 10
const newUser = { id: users.length + 1, username, password: hashedPassword };
users.push(newUser);
console.log('Registered users:', users);
res.status(201).json({ message: '회원가입 성공', user: { id: newUser.id, username: newUser.username } });
} catch (error) {
console.error('회원가입 오류:', error);
res.status(500).json({ message: '서버 오류 발생' });
}
});
4.2. 로그인 (/api/login)
로그인 시 사용자 인증 후 Access Token과 Refresh Token을 생성하여 응답합니다. Refresh Token은 서버에 저장해두고 관리합니다.
// server.js (추가)
const jwt = require('jsonwebtoken');
// ... (기존 코드)
// JWT 토큰 생성 함수
function generateTokens(user) {
const accessToken = jwt.sign({ id: user.id, username: user.username }, process.env.ACCESS_TOKEN_SECRET, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRATION
});
const refreshToken = jwt.sign({ id: user.id, username: user.username }, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: process.env.REFRESH_TOKEN_EXPIRATION
});
return { accessToken, refreshToken };
}
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = users.find(u => u.username === username);
if (!user) {
return res.status(400).json({ message: '사용자를 찾을 수 없습니다.' });
}
try {
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });
}
const { accessToken, refreshToken } = generateTokens(user);
// Refresh Token을 서버에 저장 (실제로는 DB에 사용자 ID와 함께 저장)
refreshTokens.push(refreshToken);
console.log('Current Refresh Tokens:', refreshTokens);
res.json({
message: '로그인 성공',
accessToken,
refreshToken
});
} catch (error) {
console.error('로그인 오류:', error);
res.status(500).json({ message: '서버 오류 발생' });
}
});
5. JWT 미들웨어 구현 및 보호된 API
Access Token을 검증하여 보호된 API에 대한 접근을 제어하는 미들웨어를 구현합니다.
5.1. 인증 미들웨어 (authenticateToken)
// server.js (추가)
// ... (기존 코드)
// JWT Access Token 검증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN 에서 TOKEN 추출
if (token == null) {
return res.status(401).json({ message: 'Access Token이 필요합니다.' }); // 토큰 없음
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
console.error('Access Token 검증 실패:', err.message);
return res.status(403).json({ message: 'Access Token이 유효하지 않거나 만료되었습니다.' }); // 토큰 유효하지 않음 또는 만료
}
req.user = user; // 요청 객체에 사용자 정보 추가
next(); // 다음 미들웨어 또는 라우터로 이동
});
}
5.2. 보호된 API (/api/protected)
미들웨어를 적용하여 특정 API가 인증된 사용자만 접근할 수 있도록 설정합니다.
// server.js (추가)
// ... (기존 코드)
app.get('/api/protected', authenticateToken, (req, res) => {
// authenticateToken 미들웨어를 통과했으므로 req.user에 사용자 정보가 있습니다.
res.json({
message: `${req.user.username}님, 보호된 리소스에 접근하셨습니다!`,
data: {
userId: req.user.id,
username: req.user.username,
secretInfo: '이것은 중요한 비밀 정보입니다.'
}
});
});
6. Refresh Token 전략 구현
Access Token이 만료되었을 때 Refresh Token을 사용하여 새로운 Access Token을 발급받는 API를 구현합니다.
// server.js (추가)
// ... (기존 코드)
app.post('/api/token', (req, res) => {
const { refreshToken } = req.body;
if (refreshToken == null) {
return res.status(401).json({ message: 'Refresh Token이 필요합니다.' });
}
// 서버에 저장된 Refresh Token인지 확인
if (!refreshTokens.includes(refreshToken)) {
return res.status(403).json({ message: '유효하지 않은 Refresh Token입니다.' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
if (err) {
console.error('Refresh Token 검증 실패:', err.message);
return res.status(403).json({ message: 'Refresh Token이 유효하지 않거나 만료되었습니다.' });
}
// 기존 Refresh Token을 제거하고 새로운 Refresh Token 발급 (선택적)
// 이 전략은 Refresh Token 재사용 공격을 방지하는 데 도움이 됩니다.
refreshTokens = refreshTokens.filter(token => token !== refreshToken);
// 새로운 Access Token과 Refresh Token 생성
const newTokens = generateTokens({ id: user.id, username: user.username });
refreshTokens.push(newTokens.refreshToken); // 새로운 Refresh Token 저장
res.json({
message: '새로운 Access Token이 발급되었습니다.',
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken // 새로운 Refresh Token도 함께 반환
});
});
});
6.1. 로그아웃 (/api/logout)
로그아웃 시에는 서버에 저장된 Refresh Token을 무효화해야 합니다.
// server.js (추가)
// ... (기존 코드)
app.delete('/api/logout', (req, res) => {
const { refreshToken } = req.body;
if (refreshToken == null) {
return res.status(400).json({ message: 'Refresh Token이 필요합니다.' });
}
// 서버에 저장된 Refresh Token을 배열에서 제거하여 무효화
const initialLength = refreshTokens.length;
refreshTokens = refreshTokens.filter(token => token !== refreshToken);
if (refreshTokens.length === initialLength) {
// Refresh Token이 서버에 없었을 경우
return res.status(404).json({ message: '로그아웃할 Refresh Token을 찾을 수 없습니다.' });
}
console.log('Remaining Refresh Tokens:', refreshTokens);
res.status(204).send(); // No Content
});
7. 보안 고려 사항
JWT 인증 시스템을 구현할 때 다음과 같은 보안 사항들을 반드시 고려해야 합니다.
- HTTPS 사용: 모든 통신은 반드시 HTTPS를 통해 암호화되어야 합니다. HTTP를 사용하면 토큰이 네트워크를 통해 평문으로 노출될 수 있습니다.
- 비밀 키 관리:
ACCESS_TOKEN_SECRET과REFRESH_TOKEN_SECRET은 절대 외부에 노출되어서는 안 됩니다. 환경 변수나 보안 금고(Vault) 서비스를 통해 안전하게 관리해야 합니다. - Access Token 만료 시간: 짧게 설정하여 토큰 탈취 시 피해를 최소화합니다.
- Refresh Token 관리:
- 저장 위치: 클라이언트 측에서는 HttpOnly 속성을 가진 쿠키에 저장하는 것이 Local Storage보다 XSS 공격에 안전합니다. 서버 측에서는 데이터베이스에 사용자 ID와 함께 저장하고, 사용자가 로그아웃하거나 의심스러운 활동이 감지될 때 무효화할 수 있도록 관리해야 합니다.
- 재사용 방지: Refresh Token이 한 번 사용되면 즉시 폐기하고 새로운 Refresh Token을 발급하는 "One-time use Refresh Token" 전략을 사용하는 것이 좋습니다.
- 비밀번호 해싱:
bcrypt와 같이 강력한 해싱 알고리즘을 사용하여 비밀번호를 안전하게 저장합니다. - 입력값 검증: 사용자 입력(로그인 정보 등)에 대한 철저한 검증을 통해 SQL Injection, XSS 등의 공격을 방지합니다.
- Rate Limiting: 로그인 시도, 토큰 갱신 등 특정 API에 대한 요청 횟수를 제한하여 무차별 대입 공격(Brute-force attack)을 방지합니다.
마무리
지금까지 Node.js 환경에서 JWT를 활용한 인증 시스템을 구축하는 과정을 단계별로 살펴보았습니다. JWT는 Stateless API 서버에 매우 적합하며, Access Token과 Refresh Token 전략을 통해 보안성과 확장성을 동시에 확보할 수 있습니다. 제시된 코드는 기본적인 구현 예시이며, 실제 프로덕션 환경에서는 데이터베이스 연동, 에러 핸들링, 로깅 등 더 많은 요소를 고려하여 견고한 시스템을 구축해야 합니다. 이 가이드가 여러분의 백엔드 API 개발에 유용한 기반이 되기를 바랍니다.
관련 게시글
데이터베이스 Indexing 최적화 전략: Node.js API 성능 향상 가이드
Node.js API 백엔드 서버의 성능을 극대화하기 위한 데이터베이스 Indexing 최적화 전략을 심층적으로 다룹니다. B-tree, 복합 인덱스, Covering Index 등 다양한 기법과 실제 활용 예시를 통해 쿼리 속도를 향상시키는 방법을 알아보세요.
gRPC vs REST: Modern API Architecture Deep Dive
백엔드 API 아키텍처의 핵심인 gRPC와 REST를 비교 분석합니다. 성능, 개발 편의성, 사용 사례를 통해 각 기술의 장단점을 깊이 있게 탐구하고, Node.js 기반의 구현 예시를 제공하여 최적의 API 선택 가이드를 제시합니다.
Microservices Architecture Design: Node.js Backend 개발 전략
Node.js를 활용한 Microservices Architecture 설계 및 구현 전략을 심층적으로 다룹니다. 모놀리식의 한계를 넘어 확장 가능하고 유연한 백엔드 시스템을 구축하는 핵심 원칙, 통신 방식, 데이터 관리, 그리고 Node.js 기반 구현 예시를 제공합니다.