본문으로 건너뛰기
Previous
Next
웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법

웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법

웹 보안 완벽 가이드 | 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 Componentsnpm 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 속성 설정
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과 실전 방어 기법」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(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과 실전 방어 기법」)를 배포·운영 흐름에 맞춰 옮긴 뼈대용 단계다. 맨 위에 썼듯 보안은 체크리스트가 아냐 — 이건 빠뜨린 걸 잡는 용이고, “이 단계가 우리 팀에선 어디에 해당하지?”를 머리로 겹쳐 보는 쪽이 더 중요하다. 도메인에 맞게 단계 이름만 바꿔 쓰면 된다.

  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)

표 말고, 증상별로 “의심 → 다음 손”만 적었다.

  • 간헐적만 터짐 — 레이스, 타임아웃, 외부 API, DNS. 최소 재현 스크립트 + 트레이스/로그 상관 + 재시도·서킷 값 재확인.
  • 느려짐 — N+1, 동기 I/O, 락 싸움, 직렬화 과다, 캐시 미스. APM/프로파일러로 한 군데씩만 걷어내기.
  • 메모리만 계속 늠 — 캐시에 상한 없음, 이벤트 구독 누수, 덩치 큰 버퍼, 커넥션 안 닫기. 상한·TTL, 힙/FD 스냅샷 전후 비교.
  • 빌드/배포만 깨짐 — env, 권한, OS 차이, lockfile. CI 로그랑 로컬 diff, 런타임/이미지 버전을 핀으로 고정.
  • 설정이 prod랑 말이 안 맞음 — 프로필, 시크릿, 기본값, 리전. 스키마로 검증되는 설정 한 소스로 몰기.
  • 데이터가 어긋남 — 멱등 아닌 데 재시도, 쓰기 절반만 됨, 캐시 무효화 빠짐. 멱등 키·아웃박스·트랜잭션 경계 다시 읽기.

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

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


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

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


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

Security, OWASP, Web Security, SQL Injection, XSS, CSRF, Authentication, Encryption 등으로 검색하시면 이 글이 도움이 됩니다.