인증 시스템 설계에 관한 연구
현대 웹 애플리케이션에서 인증(Authentication)은 단순히 사용자를 확인하는 것을 넘어, 보안과 사용자 경험, 그리고 시스템 성능 사이의 최적의 균형점을 찾는 엔지니어링 과정입니다.
본 문서에서는 프로덕션 환경에서 검증된 JWT 기반 인증 시스템의 설계와 구현을 다룹니다. Access Token과 Refresh Token의 이중화 전략부터 시작하여, Redis를 활용한 효율적인 세션 관리, 그리고 토큰 탈취 위협을 최소화하는 Refresh Token Rotation(RTR) 기법까지 실무에 바로 적용 가능한 수준으로 상세히 다룹니다.
다루는 주요 내용:
- JWT 기반 토큰 이중화 구조 설계 원리
- Redis를 활용한 고성능 세션 관리
- Refresh Token Rotation(RTR)을 통한 보안 강화
- 전체 인증 플로우 구현 (로그인, 토큰 갱신, 로그아웃)
- 프로덕션 환경 보안 고려사항 및 공격 시나리오 대응
1. 인증 시스템의 이중 구조: Access Token과 Refresh Token
인증 시스템에서 토큰의 수명은 보안과 사용자 경험이라는 양날의 검과 같습니다. 이를 해결하기 위해 두 종류의 토큰을 혼용합니다.
1.1. Access Token
- 역할: 모든 API 요청의 인증 헤더에 포함되어 사용자의 신원을 증명하는 단기 인증 토큰입니다.
- 특징:
- 매 요청마다 네트워크를 통해 전송되므로 탈취 위험이 상대적으로 높습니다.
- 이를 보완하기 위해 유효기간을 짧게(권장: 15분~30분) 설정합니다.
- 탈취되더라도 짧은 시간 내에 만료되어 피해를 최소화할 수 있습니다.
- 저장 위치: 클라이언트 메모리(변수) 또는 짧은 TTL의 쿠키에 저장합니다.
1.2. Refresh Token
- 역할: Access Token이 만료되었을 때 새로운 토큰 쌍을 발급받기 위한 장기 인증 토큰입니다.
- 특징:
- Access Token보다 긴 유효기간(권장: 7일~14일)을 가집니다.
- 평소에는 통신에 노출되지 않고, 토큰 갱신 요청 시에만 서버로 전송됩니다.
- 반드시 서버 측(Redis)에도 저장하여 무효화 가능한 상태를 유지해야 합니다.
- 저장 위치: HttpOnly, Secure 플래그가 설정된 쿠키에 저장하여 JavaScript 접근을 차단합니다.
1.3. 토큰 이중화의 장점
이러한 이중 구조는 다음과 같은 이점을 제공합니다:
- 보안성: 자주 사용되는 Access Token은 짧은 수명으로 위험을 최소화
- 사용자 경험: Refresh Token으로 자동 갱신하여 재로그인 없이 연속적인 사용 가능
- 제어 가능성: Refresh Token을 서버에서 관리하여 필요시 즉시 세션 무효화 가능
2. Redis를 활용한 토큰 관리의 필연성
JWT(JSON Web Token)는 기본적으로 무상태(Stateless)를 지향하지만, 실제 프로덕션 환경에서는 세션 제어 능력이 필수적입니다. Redis는 이러한 요구사항을 충족시키는 최적의 솔루션입니다.
2.1. Redis가 필요한 이유
-
즉시 세션 무효화 (Kill Switch)
- JWT는 한 번 발급되면 만료 전까지 서버가 제어하기 어렵습니다.
- Redis에 Refresh Token을 저장하면 로그아웃, 보안 위협 감지, 비밀번호 변경 등의 상황에서 해당 키를 삭제하여 즉시 접근을 차단할 수 있습니다.
- 이는 JWT의 무상태 장점을 유지하면서도 필요시 상태 제어를 가능하게 합니다.
-
고성능 I/O
- 인증 검증은 모든 API 요청의 관문으로, 시스템에서 가장 빈번하게 발생하는 작업입니다.
- 인메모리(In-Memory) 기반인 Redis는 일반적으로 1ms 미만의 응답 속도를 제공합니다.
- 디스크 기반 DB 대비 10~100배 빠른 성능으로 대규모 트래픽에도 안정적입니다.
-
자동 만료 관리 (TTL)
- Redis의 Time-To-Live 기능으로 토큰 만료 시점에 데이터가 자동 삭제됩니다.
- 별도의 배치 작업이나 스케줄러 없이 메모리 관리가 자동화됩니다.
- 시스템 리소스를 효율적으로 활용할 수 있습니다.
-
멀티 디바이스 지원
- 사용자별로 여러 디바이스의 Refresh Token을 관리할 수 있습니다.
- Key 네이밍 전략 (예:
user:{userId}:device:{deviceId})으로 디바이스별 세션 제어가 가능합니다.
2.2. Redis 데이터 구조 설계
Refresh Token 저장을 위한 Redis 키 구조는 다음과 같이 설계할 수 있습니다:
Key: user:{userId}:refresh_token
Value: {refreshToken}
TTL: 604800 (7일, 초 단위)멀티 디바이스를 지원하는 경우:
Key: user:{userId}:device:{deviceId}:refresh_token
Value: {refreshToken}
TTL: 6048003. JWT의 핵심 기술: 서명 알고리즘과 보안
3.1. HMACSHA256을 통한 무결성 보장
JWT의 Signature(서명) 영역에 사용되는 HMACSHA256 알고리즘은 데이터의 **무결성(Integrity)**과 **인증(Authentication)**을 동시에 보장합니다.
동작 원리:
Signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)- 서명 생성: 서버는 JWT의 Header와 Payload를 인코딩한 후, 비밀 키(Secret Key)와 함께 HMAC-SHA256 해시 함수에 입력하여 서명을 생성합니다.
- 서명 검증: 클라이언트로부터 받은 토큰의 Header와 Payload를 동일한 방식으로 해싱한 후, 토큰에 포함된 서명과 비교합니다.
- 변조 감지: Payload나 Header가 1바이트라도 변경되면 해시값이 완전히 달라져 즉시 변조를 탐지할 수 있습니다.
3.2. Secret Key 관리 모범 사례
Secret Key는 인증 시스템의 핵심 보안 요소입니다. 다음 원칙을 반드시 준수해야 합니다:
-
충분한 엔트로피: 최소 256비트(32바이트) 이상의 무작위 문자열 사용
# 안전한 Secret Key 생성 예시 openssl rand -base64 32 -
환경 분리: 개발/스테이징/프로덕션 환경마다 다른 Secret Key 사용
-
안전한 저장:
- 코드에 하드코딩 금지
- 환경 변수 또는 AWS Secrets Manager, HashiCorp Vault 등의 비밀 관리 서비스 활용
- Git 저장소에 커밋되지 않도록
.gitignore설정
-
주기적 로테이션: 보안 정책에 따라 정기적으로 키를 교체 (예: 90일마다)
-
Access Token과 Refresh Token의 분리: 두 토큰은 서로 다른 Secret Key를 사용하여 한쪽이 노출되어도 다른 쪽의 보안을 유지합니다.
3.3. RS256 vs HS256
마이크로서비스 환경에서는 비대칭 키 알고리즘(RS256)도 고려할 수 있습니다:
- HS256 (대칭 키): 단일 서버 또는 신뢰할 수 있는 내부 서비스 간 통신에 적합
- RS256 (비대칭 키): 여러 서비스가 토큰을 검증해야 하는 경우, 공개 키만 배포하여 보안성 향상
4. 실전 구현: Node.js와 Redis 연동
4.1. 환경 설정 및 Redis 연결
환경별 권장사항:
- 로컬 개발: Docker Compose를 통한 일관된 개발 환경 구축
- 프로덕션: AWS ElastiCache, Redis Cloud 등의 관리형 서비스 사용
- 고가용성: Redis Sentinel 또는 Cluster 모드로 장애 대응
Redis 클라이언트 설정
프로덕션 환경에 적합한 연결 설정은 다음과 같습니다:
// config/redisClient.js
const redis = require('redis');
// Redis 클라이언트 생성 및 설정
const client = redis.createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
password: process.env.REDIS_PASSWORD,
socket: {
connectTimeout: 5000,
// V4부터 reconnectStrategy는 더 이상 사용되지 않으며,
// 대신 기본적으로 지수 백오프(exponential backoff) 전략이 내장되어 있습니다.
// 커스텀 로직이 필요한 경우 'error' 이벤트와 조합하여 구현할 수 있습니다.
}
});
// 에러 핸들링
client.on('error', (err) => {
console.error('Redis Client Error:', err);
// 프로덕션에서는 모니터링 시스템에 알림 전송 (예: Sentry, DataDog)
});
client.on('connect', () => {
console.log('Redis에 성공적으로 연결되었습니다.');
});
client.on('reconnecting', () => {
console.log('Redis 재연결 시도 중...');
});
// 연결 초기화
(async () => {
try {
await client.connect();
} catch (err) {
console.error('Redis 초기 연결 실패:', err);
process.exit(1); // 애플리케이션 시작 실패
}
})();
// Graceful Shutdown
process.on('SIGINT', async () => {
await client.quit();
console.log('Redis 연결이 정상적으로 종료되었습니다.');
process.exit(0);
});
module.exports = client;Docker Compose 로컬 환경 설정
# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:8-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
redis_data:환경 변수 설정
# .env
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_secure_password
ACCESS_TOKEN_SECRET=your_access_token_secret_key_256bit
REFRESH_TOKEN_SECRET=your_refresh_token_secret_key_256bit
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=7d4.2. 유틸리티 함수 정의
먼저 토큰 생성 및 검증에 사용할 헬퍼 함수를 정의합니다:
// utils/tokenUtils.js
const jwt = require('jsonwebtoken');
/**
* Access Token 생성
*/
function generateAccessToken(userId) {
return jwt.sign(
{ id: userId, type: 'access' },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN || '15m' }
);
}
/**
* Refresh Token 생성
*/
function generateRefreshToken(userId) {
return jwt.sign(
{ id: userId, type: 'refresh' },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || '7d' }
);
}
/**
* Access Token 검증
*/
function verifyAccessToken(token) {
try {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
} catch (err) {
throw new Error('Invalid or expired access token');
}
}
/**
* Refresh Token 검증
*/
function verifyRefreshToken(token) {
try {
return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
} catch (err) {
throw new Error('Invalid or expired refresh token');
}
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken
};4.3. 로그인 엔드포인트 구현
사용자 인증의 시작점인 로그인 API를 구현합니다. 비밀번호 검증 시 bcrypt를 사용하며, cost factor를 적절히 설정하는 것이 중요합니다.
Bcrypt Cost Factor (Salt Rounds):
bcrypt.hash(password, saltRounds)의saltRounds인자는 해싱의 복잡도를 결정합니다.- 값이 높을수록 더 많은 계산이 필요하여 브루트 포스 공격에 더 오래 저항할 수 있지만, 서버의 응답 시간도 길어집니다.
- 권장 값: 10~12. (12 이상은 대부분의 웹 서비스에서 과도할 수 있습니다.)
- 하드웨어 성능이 발전함에 따라 이 값은 주기적으로 재평가해야 합니다.
// routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const redisClient = require('../config/redisClient');
const { generateAccessToken, generateRefreshToken } = require('../utils/tokenUtils');
const router = express.Router();
const BCRYPT_SALT_ROUNDS = 12;
/**
* 로그인 엔드포인트
*/
router.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// 입력 검증
if (!email || !password) {
return res.status(400).json({ message: '이메일과 비밀번호를 입력해주세요.' });
}
// 사용자 조회 (실제로는 데이터베이스에서 조회)
const user = await getUserByEmail(email);
if (!user) {
// 보안을 위해 구체적인 실패 이유를 노출하지 않음
return res.status(401).json({ message: '인증에 실패했습니다.' });
}
// 비밀번호 검증
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return res.status(401).json({ message: '인증에 실패했습니다.' });
}
// 토큰 생성
const accessToken = generateAccessToken(user.id);
const refreshToken = generateRefreshToken(user.id);
// Redis에 Refresh Token 저장 (7일 TTL)
const ttl = 7 * 24 * 60 * 60; // 7일을 초 단위로
await redisClient.setEx(
`user:${user.id}:refresh_token`,
ttl,
refreshToken
);
// Refresh Token은 HttpOnly 쿠키로 전송
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict',
maxAge: ttl * 1000 // 밀리초 단위
});
// Access Token은 응답 body로 전송
res.json({
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (err) {
console.error('로그인 에러:', err);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
});
/**
* 사용자 조회 함수 (예시)
* 실제로는 데이터베이스 쿼리로 구현
*/
async function getUserByEmail(email) {
// 예시: PostgreSQL, MongoDB 등에서 사용자 조회
// const user = await db.users.findOne({ email });
// return user;
return null; // placeholder
}
module.exports = router;4.2. Refresh Token Rotation (RTR) 로직
보안을 한 단계 더 높이기 위해 토큰 재발급 시 Refresh Token도 함께 갱신하는 RTR 기법을 적용합니다.
/**
* 토큰 갱신 엔드포인트 (Refresh Token Rotation 적용)
*/
router.post('/auth/refresh', async (req, res) => {
try {
// 1. 쿠키에서 Refresh Token 추출
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: '인증이 필요합니다.' });
}
// 2. Refresh Token 검증 (서명 및 만료 확인)
let decoded;
try {
decoded = verifyRefreshToken(refreshToken);
} catch (err) {
return res.status(403).json({ message: '유효하지 않은 토큰입니다.' });
}
const userId = decoded.id;
// 3. Redis에 저장된 토큰과 대조
const savedToken = await redisClient.get(`user:${userId}:refresh_token`);
// 4. 토큰 불일치 시 Reuse Detection (재사용 탐지)
if (!savedToken || savedToken !== refreshToken) {
// 토큰이 이미 사용되었거나 탈취된 것으로 의심
// 해당 사용자의 모든 세션 무효화 (보안 조치)
await redisClient.del(`user:${userId}:refresh_token`);
console.warn(`[보안 경고] Refresh Token 재사용 탐지 - User ID: ${userId}`);
// 실제 프로덕션에서는 사용자에게 알림을 보내거나 추가 인증 요구
return res.status(403).json({
message: '보안 위협이 감지되었습니다. 다시 로그인해주세요.'
});
}
// 5. 새로운 토큰 쌍 생성 (RTR: Rotation)
const newAccessToken = generateAccessToken(userId);
const newRefreshToken = generateRefreshToken(userId);
// 6. Redis에 새로운 Refresh Token 저장 (기존 토큰 덮어쓰기)
const ttl = 7 * 24 * 60 * 60;
await redisClient.setEx(
`user:${userId}:refresh_token`,
ttl,
newRefreshToken
);
// 7. 새로운 Refresh Token을 쿠키로 전송
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: ttl * 1000
});
// 8. 새로운 Access Token 응답
res.json({
accessToken: newAccessToken
});
} catch (err) {
console.error('토큰 갱신 에러:', err);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
});RTR의 핵심 보안 메커니즘
-
토큰 재사용 탐지 (Reuse Detection)
- 이미 사용된 Refresh Token으로 재요청 시 탈취로 간주
- 즉시 모든 세션 무효화로 추가 피해 방지
-
토큰 패밀리 (Token Family)
- 각 갱신마다 새로운 토큰 쌍 발급
- 탈취자가 오래된 토큰을 사용하면 즉시 감지 가능
-
자동 세션 정리
- Redis TTL로 오래된 토큰 자동 삭제
- 메모리 효율성과 보안 동시 달성
4.5. 로그아웃 구현
로그아웃은 단순히 클라이언트에서 토큰을 삭제하는 것이 아니라, 서버 측 세션도 함께 무효화해야 합니다:
/**
* 로그아웃 엔드포인트
*/
router.post('/auth/logout', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Refresh Token에서 사용자 ID 추출
try {
const decoded = verifyRefreshToken(refreshToken);
const userId = decoded.id;
// Redis에서 Refresh Token 삭제
await redisClient.del(`user:${userId}:refresh_token`);
} catch (err) {
// 토큰이 이미 만료되었거나 유효하지 않은 경우에도 계속 진행
console.log('로그아웃 중 토큰 검증 실패 (무시):', err.message);
}
}
// 쿠키 삭제
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
res.json({ message: '로그아웃되었습니다.' });
} catch (err) {
console.error('로그아웃 에러:', err);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
});4.6. 인증 미들웨어 구현
보호된 API 라우트에 적용할 인증 미들웨어입니다:
// middleware/authMiddleware.js
const { verifyAccessToken } = require('../utils/tokenUtils');
/**
* Access Token 검증 미들웨어
*/
function authenticateToken(req, res, next) {
// Authorization 헤더에서 토큰 추출
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ message: '인증이 필요합니다.' });
}
try {
// 토큰 검증
const decoded = verifyAccessToken(token);
req.user = decoded; // 요청 객체에 사용자 정보 추가
next();
} catch (err) {
return res.status(403).json({ message: '유효하지 않은 토큰입니다.' });
}
}
module.exports = { authenticateToken };사용 예시:
const { authenticateToken } = require('../middleware/authMiddleware');
// 보호된 라우트
router.get('/api/profile', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const userProfile = await getUserProfile(userId);
res.json(userProfile);
} catch (err) {
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
});5. 브라우저 저장소와 쿠키 보안 전략
토큰을 어디에 저장하느냐는 보안 아키텍처의 핵심 결정사항입니다. 각 저장 방식은 고유한 취약점과 장점을 가지고 있습니다.
5.1. 저장소별 보안 특성 비교
| 저장소 | XSS 취약성 | CSRF 취약성 | 권장 용도 |
|---|---|---|---|
| LocalStorage | 높음 (JS 접근 가능) | 없음 | 사용 비권장 |
| SessionStorage | 높음 (JS 접근 가능) | 없음 | 사용 비권장 |
| 메모리 (변수) | 낮음 (페이지 새로고침 시 소실) | 없음 | Access Token |
| HttpOnly Cookie | 없음 (JS 접근 차단) | 있음 (대응 필요) | Refresh Token (권장) |
5.2. 권장 저장 전략
Access Token
- 저장 위치: 클라이언트 애플리케이션의 메모리 (JavaScript 변수)
- 장점: XSS 공격에 상대적으로 안전 (DOM에 노출되지 않음)
- 단점: 페이지 새로고침 시 소실 → Refresh Token으로 재발급
- 대안: 짧은 TTL의 쿠키 사용 가능 (자동 전송 편의성)
Refresh Token
- 저장 위치: HttpOnly + Secure 쿠키 (필수)
- 이유:
- JavaScript에서 접근 불가하여 XSS 공격 방어
- 긴 유효기간을 가지므로 LocalStorage 사용 시 위험이 큼
- 서버가 쿠키 유효성을 완전히 제어 가능
5.3. 쿠키 보안 속성 상세 설명
res.cookie('refreshToken', token, {
httpOnly: true, // (1) XSS 방어
secure: true, // (2) HTTPS 전용
sameSite: 'strict', // (3) CSRF 방어
maxAge: 604800000, // (4) 7일 (밀리초)
path: '/auth', // (5) 경로 제한 (선택사항)
domain: '.example.com' // (6) 도메인 제한 (선택사항)
});(1) HttpOnly
- 기능: JavaScript (
document.cookie)를 통한 접근을 완전히 차단 - 방어: XSS (Cross-Site Scripting) 공격으로부터 토큰 탈취 방지
- 중요성: ★★★★★ (필수)
(2) Secure
- 기능: HTTPS 연결에서만 쿠키 전송
- 방어: HTTP 연결에서의 중간자 공격 (MITM) 차단
- 주의: 로컬 개발 환경에서는
false로 설정 (HTTP 사용) - 중요성: ★★★★★ (프로덕션 필수)
(3) SameSite
옵션별 특징:
-
Strict (가장 안전):
- 동일 사이트 요청에서만 쿠키 전송
- CSRF 공격을 거의 완벽하게 차단
- 외부 링크를 통한 접근 시에도 쿠키가 전송되지 않음
- 사용자 경험에 영향을 줄 수 있음 (예: 이메일 링크 클릭 시 로그인 상태 유지 안 됨)
-
Lax (균형):
- GET 요청과 최상위 네비게이션에서는 쿠키 전송
- POST, PUT, DELETE 등 상태 변경 요청에서는 쿠키 미전송
- 대부분의 CSRF 공격 차단
- 합리적인 사용자 경험 제공
-
None (비권장):
- 모든 크로스 사이트 요청에서 쿠키 전송
- Secure 속성과 함께 사용해야 함
- 특수한 경우 (iframe, 크로스 도메인 인증)에만 사용
권장 설정: strict (보안 우선) 또는 lax (사용성 고려)
5.4. 추가 보안 레이어: CSRF 토큰
SameSite 속성만으로 부족한 경우, 추가로 CSRF 토큰 패턴을 적용할 수 있습니다:
// CSRF 토큰 생성 (로그인 시)
const crypto = require('crypto');
const csrfToken = crypto.randomBytes(32).toString('hex');
// 세션에 저장 (Redis 또는 서버 메모리)
await redisClient.setEx(`csrf:${userId}`, 3600, csrfToken);
// 클라이언트에 전달 (일반 쿠키 또는 응답 body)
res.cookie('csrfToken', csrfToken, {
httpOnly: false, // JavaScript에서 읽을 수 있어야 함
secure: true,
sameSite: 'strict'
});
// 클라이언트는 상태 변경 요청 시 헤더에 포함
// X-CSRF-Token: {csrfToken}
// 서버에서 검증
const receivedToken = req.headers['x-csrf-token'];
const storedToken = await redisClient.get(`csrf:${userId}`);
if (receivedToken !== storedToken) {
return res.status(403).json({ message: 'CSRF 토큰 불일치' });
}6. 표준 프로토콜: RFC 준수의 중요성
우리가 사용하는 쿠키와 JWT는 임의의 구현이 아닌, 국제 표준화 기구(IETF)에서 정의한 RFC(Request for Comments) 문서를 따릅니다.
6.1. 주요 RFC 표준
RFC 6265 - HTTP State Management Mechanism (쿠키)
- 정의: 쿠키의 구조, 속성, 보안 요구사항
- 핵심 내용:
- 쿠키의 Set-Cookie 헤더 포맷
- Domain, Path, Secure, HttpOnly 속성 규격
- 쿠키의 생명주기 및 만료 처리
- 참고: RFC 6265
RFC 7519 - JSON Web Token (JWT)
- 정의: JWT의 구조, 클레임, 서명 방식
- 핵심 내용:
- Header, Payload, Signature의 3-part 구조
- 표준 클레임 (iss, sub, aud, exp, nbf, iat, jti)
- 서명 알고리즘 (HS256, RS256 등)
- 참고: RFC 7519
RFC 6749 - OAuth 2.0 Authorization Framework
- 정의: Access Token과 Refresh Token의 개념적 기초
- 핵심 내용:
- Access Token의 역할과 수명
- Refresh Token을 통한 토큰 갱신 플로우
- 인증 서버와 리소스 서버의 분리
- 참고: RFC 6749
6.2. 표준 준수의 이점
- 상호 운용성: 모든 브라우저와 서버가 동일한 방식으로 데이터를 처리
- 보안 모범 사례: 오랜 기간 검증된 보안 메커니즘 활용
- 유지보수성: 표준을 따르면 라이브러리와 도구의 지원을 받을 수 있음
- 확장성: 새로운 플랫폼이나 서비스와의 통합이 용이
7. 프로덕션 환경 고려사항
7.1. 성능 최적화
Redis 연결 풀 관리
// 연결 풀 설정 예시 (ioredis 사용)
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
connectTimeout: 10000,
// 연결 풀 크기 조정
lazyConnect: true
});Rate Limiting (속도 제한)
토큰 엔드포인트는 브루트 포스 공격의 주요 타겟입니다:
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const redisClient = require('../config/redisClient');
// 로그인 엔드포인트에 Rate Limit 적용
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // IP당 최대 5회 시도
message: '너무 많은 로그인 시도가 있었습니다. 나중에 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false,
// Redis 기반 스토어 사용 (분산 환경 대응)
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
})
});
router.post('/auth/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
// 토큰 갱신 엔드포인트
const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: '토큰 갱신 요청이 너무 많습니다.'
});
router.post('/auth/refresh', refreshLimiter, async (req, res) => {
// 토큰 갱신 로직
});7.2. 모니터링 및 로깅
보안 이벤트 로깅
const winston = require('winston');
const securityLogger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'security.log' })
]
});
// RTR Reuse Detection 발생 시
securityLogger.warn({
event: 'REFRESH_TOKEN_REUSE_DETECTED',
userId: userId,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString()
});
// 로그인 실패 시
securityLogger.warn({
event: 'LOGIN_FAILED',
email: email,
ip: req.ip,
timestamp: new Date().toISOString()
});7.3. 고가용성 (High Availability)
Redis Sentinel 구성
프로덕션 환경에서는 Redis의 단일 장애점을 제거해야 합니다:
const Redis = require('ioredis');
const redis = new Redis({
sentinels: [
{ host: 'sentinel1.example.com', port: 26379 },
{ host: 'sentinel2.example.com', port: 26379 },
{ host: 'sentinel3.example.com', port: 26379 }
],
name: 'mymaster',
password: process.env.REDIS_PASSWORD
});7.4. 보안 강화 체크리스트
- Secret Key: 256비트 이상의 강력한 무작위 키 사용
- 환경 변수: 모든 민감한 정보를 환경 변수로 관리
- HTTPS: 프로덕션에서 반드시 HTTPS 사용
- Rate Limiting: 모든 인증 엔드포인트에 적용
- 쿠키 속성: HttpOnly, Secure, SameSite 모두 설정
- 토큰 수명: Access Token 15-30분, Refresh Token 7-14일
- Redis 보안: 비밀번호 설정 및 네트워크 격리
- 로깅: 보안 이벤트 상세 로깅 및 모니터링
- 입력 검증: 모든 사용자 입력에 대한 검증 및 sanitization
- 에러 메시지: 구체적인 실패 이유를 노출하지 않음
7.5. 공격 시나리오별 대응 전략
시나리오 1: Access Token 탈취
- 영향: 제한적 (짧은 수명으로 자동 만료)
- 대응: 탈취 감지 시 해당 세션 무효화
시나리오 2: Refresh Token 탈취
- 영향: 심각 (장기간 접근 가능)
- 대응:
- RTR로 재사용 즉시 탐지
- 모든 세션 무효화
- 사용자에게 알림 및 비밀번호 변경 권고
시나리오 3: XSS 공격
- 영향: LocalStorage 사용 시 치명적
- 대응:
- HttpOnly 쿠키 사용으로 근본적 차단
- CSP (Content Security Policy) 헤더 설정
- 입력 sanitization 철저히
시나리오 4: CSRF 공격
- 영향: 상태 변경 요청 위조
- 대응:
- SameSite 쿠키 속성 사용
- CSRF 토큰 패턴 적용
- Origin/Referer 헤더 검증
8. 전체 인증 플로우 요약
8.1. 로그인 플로우
1. 클라이언트: 이메일/비밀번호 전송
2. 서버: 사용자 인증 (bcrypt)
3. 서버: Access Token + Refresh Token 생성
4. 서버: Refresh Token을 Redis에 저장 (TTL 7일)
5. 서버: Refresh Token을 HttpOnly 쿠키로 전송
6. 서버: Access Token을 응답 body로 전송
7. 클라이언트: Access Token을 메모리에 저장8.2. API 요청 플로우
1. 클라이언트: Authorization 헤더에 Access Token 포함
2. 서버: Access Token 검증 (서명, 만료)
3. 서버: 유효하면 요청 처리
4. 서버: 만료되었으면 401 응답
5. 클라이언트: 401 수신 시 토큰 갱신 요청8.3. 토큰 갱신 플로우 (RTR)
1. 클라이언트: 쿠키의 Refresh Token 자동 전송
2. 서버: Refresh Token 검증 (서명, 만료)
3. 서버: Redis의 저장된 토큰과 비교
4. 서버: 불일치 시 → 재사용 탐지 → 모든 세션 무효화
5. 서버: 일치 시 → 새로운 토큰 쌍 생성
6. 서버: 새 Refresh Token을 Redis에 저장 (기존 토큰 덮어쓰기)
7. 서버: 새 Refresh Token을 HttpOnly 쿠키로 전송
8. 서버: 새 Access Token을 응답 body로 전송
9. 클라이언트: 새 Access Token으로 원래 요청 재시도8.4. 로그아웃 플로우
1. 클라이언트: 로그아웃 요청
2. 서버: Refresh Token 추출 및 검증
3. 서버: Redis에서 Refresh Token 삭제
4. 서버: Refresh Token 쿠키 삭제
5. 클라이언트: 메모리의 Access Token 삭제9. 최신 동향 및 미래 전망
9.1. OAuth 2.1: 더 간결하고 안전하게
OAuth 2.0은 10년 이상 인증 및 인가의 표준으로 자리 잡았지만, 그동안 여러 보안 취약점이 발견되고 복잡한 명세로 인해 구현 실수가 발생하기 쉬웠습니다. 이를 개선하기 위해 OAuth 2.1 이 등장했습니다.
OAuth 2.1은 기존 2.0의 모범 사례를 집대성하고, 취약한 부분을 제거한 차세대 프로토콜입니다.
주요 변경 사항:
-
PKCE (Proof Key for Code Exchange) 의무화:
- 기존에는 모바일 앱 등 Public 클라이언트에 권장되었으나, 이제 모든 클라이언트 타입(Confidential 포함)에 의무화됩니다.
- Authorization Code 탈취 공격을 원천적으로 차단하여 보안성을 크게 향상시킵니다.
-
암시적 허용 타입(Implicit Grant) 폐지:
response_type=token사용이 금지됩니다. 이 방식은 Access Token을 프론트엔드 채널(URL Fragment)로 직접 전달하여 토큰 탈취에 매우 취약했습니다.- 모든 플로우는 Authorization Code 방식을 통해 백엔드 채널로 토큰을 교환해야 합니다.
-
Redirect URI 정확성 강화:
- 와일드카드나 서브도메인을 허용하지 않고, 완전한 URI를 사전에 등록해야 합니다.
-
Refresh Token Rotation 권장:
- 본 문서에서 핵심적으로 다룬 RTR 기법이 OAuth 2.1에서 강력히 권장됩니다.
본 문서의 설계는 OAuth 2.1과 어떻게 부합하는가? 본 문서에서 제안하는 아키텍처는 Authorization Server를 직접 구현하는 관점에서 OAuth 2.1의 철학을 대부분 따르고 있습니다. 특히 Refresh Token Rotation(RTR)을 적용한 것은 최신 보안 표준에 부합하는 중요한 설계 결정입니다.
9.2. 비밀번호 없는 미래: Passkey (FIDO)
사용자 비밀번호는 피싱, 데이터 유출 등 끊임없는 보안 위협의 원인이 됩니다. Passkey는 이러한 문제를 해결하기 위한 차세대 인증 기술로, W3C와 FIDO Alliance가 표준화했습니다.
동작 원리:
- 등록: 사용자가 서비스에 Passkey를 등록하면, 사용자의 디바이스(스마트폰, 노트북 등)에 암호화된 개인 키(Private Key)가 생성되고, 서비스 서버에는 공개 키(Public Key)만 저장됩니다.
- 인증:
- 사용자가 로그인을 시도하면, 서버는 챌린지(무작위 문자열)를 보냅니다.
- 디바이스는 생체 인증(지문, 얼굴 인식)이나 PIN으로 사용자를 확인한 후, 개인 키로 챌린지에 서명하여 서버로 보냅니다.
- 서버는 저장된 공개 키로 서명을 검증하여 인증을 완료합니다.
장점:
- 강력한 보안: 서버에 비밀번호가 저장되지 않아 데이터 유출 시에도 안전하며, 피싱 공격이 불가능합니다.
- 편리한 경험: 비밀번호를 외울 필요 없이 생체 인증만으로 빠르고 간편하게 로그인할 수 있습니다.
- 플랫폼 간 동기화: Apple, Google, Microsoft 계정을 통해 여러 디바이스에서 Passkey를 동기화하여 사용할 수 있습니다.
기존 시스템에 통합하기: Passkey는 기존 인증 시스템을 대체하는 것이 아니라, 추가적인 인증 옵션으로 제공될 수 있습니다. 사용자는 이메일/비밀번호 로그인 또는 Passkey 로그인을 선택할 수 있습니다.
// Passkey 라이브러리 예시 (simplewebauthn 사용)
// routes/passkey.js
// 1. 등록 옵션 생성
router.get('/passkey/register-options', (req, res) => {
// ... simplewebauthn 라이브러리를 사용하여 옵션 생성
});
// 2. 등록 검증
router.post('/passkey/verify-registration', (req, res) => {
// ... 디바이스에서 받은 응답 검증 후 공개 키 저장
});
// 3. 인증 옵션 생성
router.get('/passkey/authenticate-options', (req, res) => {
// ... 챌린지를 포함한 인증 옵션 생성
});
// 4. 인증 검증
router.post('/passkey/verify-authentication', (req, res) => {
// ... 서명 검증 후 로그인 처리 (JWT 토큰 발급)
});Passkey 도입은 인증 시스템의 미래 방향을 제시하며, 사용자에게 최상의 보안과 편의성을 제공하는 중요한 단계가 될 것입니다.
결론
엔터프라이즈급 인증 시스템 구축은 단순한 기능 구현을 넘어, 다층적 보안 메커니즘의 유기적 결합입니다. 본 문서에서 다룬 핵심 요소들을 정리하면:
- 토큰 이중화: Access Token의 편의성과 Refresh Token의 보안성을 동시에 확보
- Redis 활용: 고성능 세션 관리와 즉시 무효화 능력 제공
- RTR 기법: 토큰 재사용 탐지로 탈취 공격 조기 차단
- 쿠키 보안: HttpOnly, Secure, SameSite 속성으로 다층 방어
- 표준 준수: RFC 및 OAuth 2.1과 같은 최신 표준을 따라 상호 운용성과 안정성 확보
- 미래 준비: Passkey와 같은 비밀번호 없는 기술을 도입하여 사용자 경험과 보안을 한 단계 더 발전
이러한 원칙들을 바탕으로 견고하고 확장 가능하며 안전한 인증 시스템을 설계하고 구현할 수 있습니다.