IT Knowledge/Security/images/api-보안-diagram.svg
📌 핵심 개념
쉬운 비유: API는 식당의 주문 창구. 안전한 API는 “신분증 확인 → 메뉴만 주문 가능 → 주문 횟수 제한” 같은 규칙이 있는 창구입니다.
🎯 필수 보안 조치
1. API 키 인증
쉬운 비유: 식당 멤버십 카드. 카드가 있어야 주문할 수 있고, 카드마다 사용 내역이 기록됩니다.
// ❌ 나쁜 예: API 키 없음
app.get('/api/users', (req, res) => {
// 누구나 호출 가능 → 악용 위험
const users = db.users.findAll();
res.json(users);
});
// ✅ 좋은 예: API 키 필수
const API_KEYS = new Map([
['key_abc123', { client: 'Mobile App', limit: 1000 }],
['key_xyz789', { client: 'Web App', limit: 5000 }]
]);
function validateApiKey(req, res, next) {
const apiKey = req.header('X-API-Key');
if (!apiKey) {
return res.status(401).json({ error: 'API 키가 필요합니다' });
}
const keyInfo = API_KEYS.get(apiKey);
if (!keyInfo) {
return res.status(403).json({ error: '유효하지 않은 API 키' });
}
req.apiKeyInfo = keyInfo;
next();
}
app.get('/api/users', validateApiKey, (req, res) => {
// API 키가 유효한 경우에만 실행
const users = db.users.findAll();
res.json(users);
});2. Rate Limiting (속도 제한)
쉬운 비유: 뷔페 식당의 “1인당 3회 방문 제한”. 한 사람이 무한정 음식을 가져가면 다른 손님이 피해를 봅니다.
const rateLimit = require('express-rate-limit');
// IP 기반 속도 제한
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // 최대 100 요청
message: '요청이 너무 많습니다. 15분 후 다시 시도하세요.',
standardHeaders: true, // RateLimit-* 헤더 반환
legacyHeaders: false
});
// 로그인 시도 제한 (더 엄격)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 최대 5회
skipSuccessfulRequests: true // 성공하면 카운트 안 함
});
// 사용
app.use('/api/', generalLimiter);
app.post('/api/login', loginLimiter, loginHandler);
// API 키별 속도 제한
const keyUsage = new Map();
function rateLimitByApiKey(req, res, next) {
const apiKey = req.header('X-API-Key');
const now = Date.now();
if (!keyUsage.has(apiKey)) {
keyUsage.set(apiKey, { count: 0, resetTime: now + 60000 });
}
const usage = keyUsage.get(apiKey);
// 1분 경과 시 리셋
if (now > usage.resetTime) {
usage.count = 0;
usage.resetTime = now + 60000;
}
// 제한 확인
const limit = req.apiKeyInfo.limit;
if (usage.count >= limit) {
return res.status(429).json({
error: '요청 한도 초과',
limit,
resetIn: Math.ceil((usage.resetTime - now) / 1000)
});
}
usage.count++;
next();
}3. CORS (Cross-Origin Resource Sharing)
쉬운 비유: 식당이 “우리 가게 단골 고객만 테이크아웃 가능”이라고 정한 것. 다른 가게 손님은 주문 못 함.
const cors = require('cors');
// ❌ 나쁜 예: 모든 출처 허용
app.use(cors({
origin: '*' // 어떤 웹사이트에서든 API 호출 가능 → 위험
}));
// ✅ 좋은 예: 허용된 출처만
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
'https://mobile.myapp.com'
];
app.use(cors({
origin: (origin, callback) => {
// origin이 없으면 (모바일 앱 등) 허용
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS 정책 위반'));
}
},
credentials: true, // 쿠키 허용
maxAge: 86400 // Preflight 캐시 (24시간)
}));
// 특정 라우트만 CORS 허용
app.get('/api/public', cors(), (req, res) => {
// 이 엔드포인트는 모두에게 공개
res.json({ message: 'Public data' });
});4. 입력 검증
쉬운 비유: 식당에서 “라면 10개”는 정상 주문이지만 “라면 100만 개”는 비정상. 주문을 검증해야 합니다.
const { body, param, validationResult } = require('express-validator');
// 검증 규칙 정의
app.post('/api/users',
[
body('email')
.isEmail()
.withMessage('올바른 이메일 형식이 아닙니다')
.normalizeEmail(),
body('age')
.isInt({ min: 1, max: 150 })
.withMessage('나이는 1-150 사이여야 합니다'),
body('username')
.isLength({ min: 3, max: 20 })
.withMessage('사용자명은 3-20자여야 합니다')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('사용자명은 영문, 숫자, _만 가능합니다')
],
(req, res) => {
// 검증 결과 확인
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 검증 통과 후 처리
const user = db.users.insert(req.body);
res.json(user);
}
);
// 숫자형 파라미터 검증
app.get('/api/posts/:id',
param('id').isInt().withMessage('ID는 숫자여야 합니다'),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const post = db.posts.findById(req.params.id);
res.json(post);
}
);5. HTTPS 강제
쉬운 비유: 편지를 투명 봉투가 아닌 불투명 봉투에 넣어 보내기. 중간에 누가 내용을 못 봅니다.
// HTTP → HTTPS 리다이렉트
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
// HSTS 헤더 (브라우저에게 HTTPS만 사용하도록 강제)
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});6. 민감 정보 숨기기
쉬운 비유: 식당 주방을 손님에게 공개하지 않기. 레시피나 조리 과정은 비밀입니다.
// ❌ 나쁜 예: 에러 메시지로 시스템 정보 노출
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack, // 스택 트레이스 노출!
sql: err.sql // SQL 쿼리 노출!
});
});
// ✅ 좋은 예: 일반적인 에러 메시지
app.use((err, req, res, next) => {
// 서버 로그에만 상세 정보 기록
console.error('Error:', err);
// 클라이언트에는 간단한 메시지만
res.status(500).json({
error: '서버 오류가 발생했습니다'
});
});
// X-Powered-By 헤더 제거
app.disable('x-powered-by');
// 민감한 필드 제거
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
// 비밀번호, 내부 ID 등 제거
const { password, internalId, ssn, ...publicUser } = user;
res.json(publicUser);
});📊 보안 수준별 API 설계
Level 1: 공개 API
// 누구나 사용 가능, 속도 제한만
app.get('/api/public/products', generalLimiter, (req, res) => {
const products = db.products.findAll();
res.json(products);
});Level 2: API 키 필요
// API 키 + 속도 제한
app.get('/api/products', validateApiKey, rateLimitByApiKey, (req, res) => {
const products = db.products.findAll();
res.json(products);
});Level 3: 사용자 인증 필요
// JWT 토큰 + 권한 확인
app.post('/api/products', authenticateToken, authorize('admin'), (req, res) => {
const product = db.products.insert(req.body);
res.json(product);
});💡 보안 체크리스트
필수 (모든 API)
- HTTPS 사용
- Rate Limiting
- 입력 검증
- CORS 설정
- 에러 메시지 통제
권장 (중요 API)
- API 키 또는 JWT 인증
- 로깅 및 모니터링
- API 버전 관리
- 문서화 (Swagger/OpenAPI)
고급 (민감 API)
- IP 화이트리스트
- 암호화된 요청/응답
- 감사 로그
- 침입 탐지 시스템