웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법
이 글의 핵심
OWASP Top 10 웹 보안 취약점과 방어 기법 완벽 가이드. SQL Injection, XSS, CSRF, 인증/인가, 암호화, 보안 헤더까지 실전 예제와 방어 코드로 완벽 이해.
들어가며
몇 해 전, 스테이징에서만 쓰기로 해놓고 .env를 한 줄만 고치는 걸 깜빡한 채로 운영 배포를 태운 프로젝트를 봤다. (이름·회사는 가렸지만 실제로 돌아가던 이야기다.) API 키가 터진 줄도 모르다가, 외부 결제 뷰에 이상한 트래픽이 찍힌 뒤에야 “어, 이 값이 prod에서 왜 열려 있지?” 하고 뒤늦게 알았다. 공격이 얼마나 갔는지, 로그가 연속적으로 남아 있지도 않아서 감사팀이랑 밤을 샜던 기억이 난다. 그때 느낀 건 단순했다. “문서에 체크박스 다 찍었는데 왜 이러지?”가 아니라, 맥락 하나가 빠지면 체크리스트는 사진 찍힌 고무줄이라는 거.
그래서 먼저 박을 말부터 할게. 보안은 체크리스트가 아냐. OWASP Top 10을 외우는 건 시험 범위 잡는 것과 비슷하고, 시험지는 늘 “그 외” 한두 문제를 더 낸다. 체크리스트는 누락을 줄이는 도구일 뿐이고, “지금 이 요청, 이 유저, 이 경로에서 정말 맞냐?”를 의심하는 쪽이 훨씬 비싸게 팔린다.
나 혼자 정리해둔 개인 레슨 (다른 팀엔 맞을 수도, 안 맞을 수도 있음):
- “나중에 고칠게”는 거의 늪이다. 민감 설정·권한 경로는 티켓 끊는 날이 곧 취약점이 아니라 사고다.
- 로그·알람은 멋이 아니라 사후 입증이랑 탐지용이었다. 터지고 나서 “왜 이전에…”를 줄이는 게 목표면, 배포마다 같은 질문이 대시보드에 뜨게 만들 것.
- 코드 리뷰에서 “이게 보안이냐?”라고 말이 나오기 전에, PR 설명에 “누가·무엇을·왜”가 한 줄이라도 있으면 IDOR·접근 통제 쪽 실수가 확 줄었음.
이 글에서 할 일 (형식은 가이드지만, 읽는 입장에선 “내 서비스에 뭐가 걸릴지”로만 골라 읽어도 됨):
- OWASP Top 10 (2021)을 표가 아니라 한 번에 짚는 지도처럼
- 취약·안전한 코드 쌍
- 인증/인가, 암호화, 보안 헤더
- 맨 뒤 부록에 운영·트러블슈팅용으로 짧게 정리
1. OWASP Top 10 (2021)
아래는 표로 안 묶고, 2021판 기준으로 A01~A10이 각각 뭐가 그렇게 위험한지만 빠르게 훑는 용도다.
- A01 · Broken Access Control — “로그인만 했지, 이 데이터까지 볼 권한이 있나?”를 안 물어본 상태로 리소스가 열릴 때.
- A02 · Cryptographic Failures — 암호를 잘못 쓰거나(혹은 안 써서) 민감한 게 그냥 노출될 때.
- A03 · Injection — SQL·NoSQL·OS 명령·템플릿에 사용자 입력이 그대로 끼어들 때.
- A04 · Insecure Design — 코드 품질 문제가 아니라, “이 플로우가 원래 약하다”는 설계 단계부터.
- A05 · Security Misconfiguration — 디버그 켜둔 채로 prod, 기본 비번, CORS
*같은 “설정만”의 실수. - A06 · Vulnerable and Outdated Components —
npm audit이 울릴 때 안 듣는 구간. - A07 · Identification and Authentication Failures — 세션, 비번 정책, MFA 빼먹기 등 인증/세션 쪽.
- A08 · Software and Data Integrity Failures — 파이프라인·업데이트·서명 없이 “신뢰”를 건너뛸 때.
- A09 · Security Logging and Monitoring Failures — 뭔가 터졌는지 모르는 상태로 지나갈 때.
- A10 · Server-Side Request Forgery (SSRF) — 서버가 공격자가 시키는 URL로 요청을 보낼 때(내부망 스캔·메타데이터 털기 루트).
2. A01: Broken Access Control
취약한 코드
# ❌ 권한 확인 없음
@app.route('/user/<user_id>')
def get_user(user_id):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
return jsonify(user)
# 공격: /user/1, /user/2, ....모든 사용자 정보 조회 가능
안전한 코드
# ✅ 권한 확인
@app.route('/user/<user_id>')
@login_required
def get_user(user_id):
current_user = get_current_user()
# 본인 또는 관리자만 접근 가능
if current_user.id != user_id and not current_user.is_admin:
abort(403)
user = db.query("SELECT * FROM users WHERE id = ?", [user_id])
return jsonify(user)
IDOR (Insecure Direct Object Reference)
// ❌ 취약한 코드
app.get('/api/orders/:orderId', async (req, res) => {
const order = await Order.findById(req.params.orderId);
res.json(order);
});
// ✅ 안전한 코드
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findById(req.params.orderId);
// 본인의 주문인지 확인
if (order.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(order);
});
3. A02: Cryptographic Failures
비밀번호 해싱
# ❌ 평문 저장
password = "password123"
db.execute("INSERT INTO users (password) VALUES (?)", [password])
# ❌ MD5/SHA1 (취약)
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
# ✅ bcrypt
import bcrypt
# 해싱
password = "password123"
salt = bcrypt.gensalt()
password_hash = bcrypt.hashpw(password.encode(), salt)
# 저장
db.execute("INSERT INTO users (password_hash) VALUES (?)", [password_hash])
# 검증
def verify_password(password, password_hash):
return bcrypt.checkpw(password.encode(), password_hash)
// Node.js - bcrypt
const bcrypt = require('bcrypt');
// 해싱
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// 검증
const isValid = await bcrypt.compare(password, passwordHash);
데이터 암호화
# AES 암호화
from cryptography.fernet import Fernet
# 키 생성 (한 번만, 안전하게 저장)
key = Fernet.generate_key()
cipher = Fernet(key)
# 암호화
plaintext = "sensitive data"
encrypted = cipher.encrypt(plaintext.encode())
# 복호화
decrypted = cipher.decrypt(encrypted).decode()
HTTPS 강제
// Express.js
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});
4. A03: Injection
SQL Injection
# ❌ 취약한 코드
username = request.form['username']
password = request.form['password']
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
user = db.execute(query)
# 공격: username = "admin' OR '1'='1"
# 결과: SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = '...'
# ✅ Prepared Statement
username = request.form['username']
password = request.form['password']
query = "SELECT * FROM users WHERE username = ? AND password = ?"
user = db.execute(query, [username, password])
# ✅ ORM 사용
from sqlalchemy import select
user = session.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
NoSQL Injection
// ❌ 취약한 코드
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({
username: username,
password: password
});
// 공격: { "username": {"$ne": null}, "password": {"$ne": null} }
});
// ✅ 안전한 코드
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 타입 검증
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ username: username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 로그인 성공
});
Command Injection
# ❌ 취약한 코드
import os
filename = request.args.get('file')
os.system(f"cat {filename}")
# 공격: ?file=test.txt; rm -rf /
# ✅ 안전한 코드
import subprocess
filename = request.args.get('file')
# 화이트리스트 검증
if not re.match(r'^[a-zA-Z0-9_-]+\.txt$', filename):
abort(400)
# 안전한 실행
result = subprocess.run(
['cat', filename],
capture_output=True,
text=True,
timeout=5
)
8. A07: Authentication Failures
JWT 인증
const jwt = require('jsonwebtoken');
// 토큰 생성
function generateToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
}
// 토큰 검증
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 사용
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.userId });
});
세션 관리
from flask import Flask, session
from flask_session import Session
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PERMANENT'] = False
app.config['SESSION_USE_SIGNER'] = True
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True # JS 접근 불가
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF 방어
Session(app)
@app.route('/login', methods=['POST'])
def login():
username = request.json['username']
password = request.json['password']
user = User.query.filter_by(username=username).first()
if user and bcrypt.checkpw(password.encode(), user.password_hash):
session['user_id'] = user.id
session.permanent = True
return jsonify({'success': True})
return jsonify({'error': 'Invalid credentials'}), 401
Rate Limiting
const rateLimit = require('express-rate-limit');
// 로그인 시도 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5번
message: 'Too many login attempts, please try again later'
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
// API 전역 제한
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 100 // 최대 100 요청
});
app.use('/api/', apiLimiter);
XSS (Cross-Site Scripting)
Reflected XSS
# ❌ 취약한 코드
@app.route('/search')
def search():
query = request.args.get('q')
return f"<h1>Search results for: {query}</h1>"
# 공격: /search?q=<script>alert('XSS')</script>
# ✅ 안전한 코드 (이스케이핑)
from flask import escape
@app.route('/search')
def search():
query = request.args.get('q')
return f"<h1>Search results for: {escape(query)}</h1>"
Stored XSS
// ❌ 취약한 코드
app.post('/comment', async (req, res) => {
const { text } = req.body;
await Comment.create({ text });
res.json({ success: true });
});
// 공격: text = "<script>steal_cookies()</script>"
// ✅ 안전한 코드 (Sanitization)
const sanitizeHtml = require('sanitize-html');
app.post('/comment', async (req, res) => {
const { text } = req.body;
const cleanText = sanitizeHtml(text, {
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: {
'a': ['href']
}
});
await Comment.create({ text: cleanText });
res.json({ success: true });
});
React에서 XSS 방어
// ✅ React는 기본적으로 XSS 방어
function Comment({ text }) {
return <div>{text}</div>; // 자동 이스케이핑
}
// ❌ dangerouslySetInnerHTML 사용 시 위험
function Comment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ DOMPurify 사용
import DOMPurify from 'dompurify';
function Comment({ html }) {
const cleanHtml = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
CSRF (Cross-Site Request Forgery)
CSRF 공격 예시
<!-- 공격자의 사이트 -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit();
</script>
CSRF Token 방어
# Flask-WTF
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
csrf = CSRFProtect(app)
@app.route('/transfer', methods=['POST'])
def transfer():
# CSRF 토큰 자동 검증
amount = request.form['amount']
to = request.form['to']
# 송금 로직
// Express.js - csurf
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// CSRF 토큰 자동 검증
const { amount, to } = req.body;
// 송금 로직
});
SameSite Cookie
// ✅ SameSite 속성 설정
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict' // 또는 'lax'
}
}));
보안 헤더
Helmet.js (Node.js)
const helmet = require('helmet');
app.use(helmet());
// 또는 개별 설정
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ['self'],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ['self'],
imgSrc: ["'self'", "data:", "https:"]
}
}));
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: true
}));
주요 보안 헤더
# Nginx 설정
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
입력 검증
화이트리스트 검증
# ✅ 화이트리스트
def validate_username(username):
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("Invalid username")
return username
# ✅ 타입 검증
from pydantic import BaseModel, validator
class UserCreate(BaseModel):
username: str
email: str
age: int
@validator('username')
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', v):
raise ValueError('Invalid username')
return v
@validator('email')
def validate_email(cls, v):
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
raise ValueError('Invalid email')
return v
// Node.js - Joi
const Joi = require('joi');
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(20).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(120)
});
app.post('/register', (req, res) => {
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
// 검증된 데이터 사용
createUser(value);
});
인증/인가 구현
JWT 기반 인증
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// 회원가입
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// 입력 검증
if (!username || !password) {
return res.status(400).json({ error: 'Missing fields' });
}
// 비밀번호 해싱
const passwordHash = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({ username, passwordHash });
res.json({ userId: user.id });
});
// 로그인
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ where: { username } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT 생성
const token = jwt.sign(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json(user);
});
OAuth 2.0
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "http://localhost:3000/auth/google/callback"
},
async (accessToken, refreshToken, profile, done) => {
// 사용자 찾기 또는 생성
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName
});
}
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');
}
);
파일 업로드 보안
안전한 파일 업로드
from werkzeug.utils import secure_filename
import os
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file'}), 400
file = request.files['file']
# 파일명 검증
if not allowed_file(file.filename):
return jsonify({'error': 'Invalid file type'}), 400
# 파일 크기 검증
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({'error': 'File too large'}), 400
# 안전한 파일명
filename = secure_filename(file.filename)
# 랜덤 파일명 생성 (더 안전)
import uuid
ext = filename.rsplit('.', 1)[1].lower()
new_filename = f"{uuid.uuid4()}.{ext}"
# 저장
file.save(os.path.join(app.config['UPLOAD_FOLDER'], new_filename))
return jsonify({'filename': new_filename})
API 보안
API Key 인증
function apiKeyAuth(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// API 키 검증 (DB 또는 캐시)
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return res.status(403).json({ error: 'Invalid API key' });
}
next();
}
app.use('/api', apiKeyAuth);
CORS 설정
const cors = require('cors');
// ✅ 특정 origin만 허용
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// ❌ 모든 origin 허용 (위험)
app.use(cors({ origin: '*' }));
실전 보안 체크리스트
인증/인가
- 비밀번호는 bcrypt/Argon2로 해싱
- JWT는 짧은 만료 시간 (1시간 이하)
- Refresh Token 구현
- 로그인 시도 제한 (Rate Limiting)
- 2FA (Two-Factor Authentication) 구현
- 세션 타임아웃 설정
입력 검증
- 모든 입력값 검증 (화이트리스트)
- SQL Injection 방어 (Prepared Statement)
- XSS 방어 (이스케이핑, Sanitization)
- 파일 업로드 검증 (타입, 크기, 확장자)
- Command Injection 방어
암호화
- HTTPS 강제 (HSTS)
- 민감 데이터 암호화
- 환경 변수로 비밀 관리
- TLS 1.2 이상 사용
보안 헤더
- Content-Security-Policy
- X-Frame-Options
- X-Content-Type-Options
- Strict-Transport-Security
- Referrer-Policy
에러 처리
- 에러 메시지에 민감 정보 노출 금지
- 프로덕션에서 스택 트레이스 숨김
- 일관된 에러 응답
로깅/모니터링
- 보안 이벤트 로깅 (로그인 실패, 권한 오류)
- 로그에 비밀번호/토큰 기록 금지
- 이상 탐지 시스템
실전 예제: 안전한 REST API
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const Joi = require('joi');
const app = express();
// 보안 헤더
app.use(helmet());
// Rate Limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api/', limiter);
// Body Parser
app.use(express.json({ limit: '10kb' }));
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS.split(','),
credentials: true
}));
// 입력 검증 스키마
const registerSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(20).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required()
});
// 회원가입
app.post('/api/register', async (req, res) => {
try {
// 입력 검증
const { error, value } = registerSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
const { username, email, password } = value;
// 중복 확인
const existing = await User.findOne({ where: { email } });
if (existing) {
return res.status(409).json({ error: 'Email already exists' });
}
// 비밀번호 해싱
const passwordHash = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({
username,
email,
passwordHash
});
res.status(201).json({ userId: user.id });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 로그인
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5
});
app.post('/api/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ where: { email } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT 생성
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
try {
const user = await User.findById(req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// 민감 정보 제외
const { passwordHash, ...safeUser } = user.toJSON();
res.json(safeUser);
} catch (err) {
console.error('Profile error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 에러 핸들러
app.use((err, req, res, next) => {
console.error(err.stack);
// 프로덕션에서는 상세 에러 숨김
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({ error: err.message });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
보안 테스트
OWASP ZAP
# Docker로 실행
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://myapp.com
# 전체 스캔
docker run -t owasp/zap2docker-stable zap-full-scan.py \
-t https://myapp.com
Snyk (의존성 스캔)
# 설치
npm install -g snyk
# 로그인
snyk auth
# 스캔
snyk test
# 자동 수정
snyk fix
npm audit
# 취약점 확인
npm audit
# 자동 수정
npm audit fix
# 강제 수정
npm audit fix --force
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. OWASP Top 10 웹 보안 취약점 완벽 가이드. SQL Injection, XSS, CSRF, 인증/인가, 암호화, 보안 헤더까지 실전 예제와 방어 코드로 완벽 이해. Security·OWASP·Web Secu… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
참고 자료
- OWASP Top 10
- OWASP Cheat Sheet Series
- MDN Web Security
- Security Headers 한 줄 요약 (또 말하지만, 보안은 체크리스트가 아냐): 입력·권한·세션·비밀·로그·헤더를 “한 흐름”으로 봤을 때 구멍이 어디로 새는지가 보이고, OWASP Top 10은 그 지도에 이름 붙이는 쪽에 가깝다.
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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, 외부 호출 타임아웃·재시도가 대시보드에 같이 붙어 있는가.
- 안전성 — 입력 검증, 권한, 비밀, 감사 로그가 “어? 이 API만 예외” 없이 흐름이 비슷한가.
- 신뢰성 — 재시도는 멱등한 데에만 쓰고, 서킷·백오프·DLQ가 있는가.
- 성능 — N+1, 풀 크기, 인덱스, 백프레셔가 지금 데이터 양에 맞는가 (지난해 튜닝이 오늘도 맞다고 가정하지 말 것).
- 배포 — 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 “나 말고도” 읽힌다.
- 용량 — 피크 때 디스크·FD·스레드 상한이 숫자로라도 가끔 점검되는가.
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법」)를 배포·운영 흐름에 맞춰 옮긴 뼈대용 단계다. 맨 위에 썼듯 보안은 체크리스트가 아냐 — 이건 빠뜨린 걸 잡는 용이고, “이 단계가 우리 팀에선 어디에 해당하지?”를 머리로 겹쳐 보는 쪽이 더 중요하다. 도메인에 맞게 단계 이름만 바꿔 쓰면 된다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 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)
표 말고, 증상별로 “의심 → 다음 손”만 적었다.
- 간헐적만 터짐 — 레이스, 타임아웃, 외부 API, DNS. 최소 재현 스크립트 + 트레이스/로그 상관 + 재시도·서킷 값 재확인.
- 느려짐 — N+1, 동기 I/O, 락 싸움, 직렬화 과다, 캐시 미스. APM/프로파일러로 한 군데씩만 걷어내기.
- 메모리만 계속 늠 — 캐시에 상한 없음, 이벤트 구독 누수, 덩치 큰 버퍼, 커넥션 안 닫기. 상한·TTL, 힙/FD 스냅샷 전후 비교.
- 빌드/배포만 깨짐 — env, 권한, OS 차이, lockfile. CI 로그랑 로컬 diff, 런타임/이미지 버전을 핀으로 고정.
- 설정이 prod랑 말이 안 맞음 — 프로필, 시크릿, 기본값, 리전. 스키마로 검증되는 설정 한 소스로 몰기.
- 데이터가 어긋남 — 멱등 아닌 데 재시도, 쓰기 절반만 됨, 캐시 무효화 빠짐. 멱등 키·아웃박스·트랜잭션 경계 다시 읽기.
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 최신 기능 | ‘C++17/20/23’ 핵심 정리
- React 18 심화 가이드 | Concurrent Features· Suspense
- Socket.IO 완벽 가이드 | 실시간 통신·WebSocket·Room·Broadcasting·실전 활용
이 글에서 다루는 키워드 (관련 검색어)
Security, OWASP, Web Security, SQL Injection, XSS, CSRF, Authentication, Encryption 등으로 검색하시면 이 글이 도움이 됩니다.