보안은 대부분의 개발자가 중요하다고 인정하면서도 자신이 완전히 이해한다고 느끼는 사람은 드문 주제입니다. 한편으로는 용어가 위협적이기 때문이고 — 암호학, 솔트, HMAC, TOTP, 비대칭 키 — 다른 한편으로는 잘못한 결과가 사고가 터질 때까지 보이지 않기 때문입니다.
이 가이드는 용어의 벽을 걷어내고 웹 개발자가 매일 내리는 실제 결정에 집중합니다. 비밀번호를 어떻게 저장할지, 데이터에 어떻게 서명할지, 사용자를 어떻게 인증할지, 토큰을 어떻게 생성하고 검증할지. 각 개념마다 브라우저에서 동작하는 검증 도구가 함께 제공되어 이해를 테스트하고 구현을 확인할 수 있습니다.
이 글로 암호학자가 되지는 않습니다. 하지만 일상 웹 개발에서 가장 중요한 보안 프리미티브의 실용적인 멘탈 모델은 갖게 됩니다.
해싱: 모든 것의 기초
해시 함수는 임의 길이의 입력을 고정 길이의 출력으로 변환합니다. 같은 입력은 항상 같은 출력을 만들고, 다른 입력은 (거의 항상) 다른 출력을 만듭니다. 그리고 결정적으로 — 해시에서 원본 입력을 역산할 수 없습니다.
이 단방향성이 해시를 보안에 유용하게 만드는 핵심입니다.
해싱의 용도:
- 비밀번호 저장 (평문 비밀번호는 절대 저장하지 말고 — 해시를 저장)
- 파일 무결성 검증 (다운로드한 파일의 해시를 알려진 정상 해시와 비교)
- 결정론적 식별자 생성 (구조화된 입력을 해시해 일관된 ID 획득)
- 데이터 변조 탐지 (수신 데이터의 해시가 기대 해시와 일치하면 변경되지 않음)
주요 해시 알고리즘:
| 알고리즘 | 출력 길이 | 지금도 사용 가능? |
|---|---|---|
| MD5 | 128비트 | 불가 — 충돌 발견됨, 보안 용도 금지 |
| SHA-1 | 160비트 | 불가 — 암호 용도 폐기됨 |
| SHA-256 | 256비트 | 가능 — 대부분의 용도에 견고함 |
| SHA-512 | 512비트 | 가능 — 출력이 더 길고, 무차별 공격이 약간 더 어려움 |
| bcrypt | 가변 | 가능 — 비밀번호 전용 설계 |
해시 생성기로 해시를 직접 계산해보세요. 같은 입력을 MD5와 SHA-256에 넣어 출력을 비교하고, 입력을 한 글자만 바꿔서 해시가 얼마나 완전히 달라지는지 확인하세요 (이것을 눈사태 효과(avalanche effect)라고 하며, 의도된 설계입니다).
비밀번호 해싱의 중요한 주의: 비밀번호를 일반 SHA-256이나 MD5로 해시해서는 절대 안 됩니다. 이 알고리즘들은 빠르고 — 빠르다는 것이 비밀번호 용도에서 가장 원하지 않는 속성입니다. 빠른 해싱은 공격자가 일반 하드웨어로 초당 수십억 개의 비밀번호를 시도할 수 있다는 뜻이기 때문입니다. 비밀번호에는 bcrypt, scrypt, Argon2를 사용하세요. 의도적으로 느리고 계산 비용이 높게 설계되어 있습니다.
솔팅: 비밀번호 해싱만으로는 부족한 이유
같은 비밀번호를 같은 알고리즘으로 해싱하면 결과는 항상 동일합니다. 즉 공격자가 해싱된 비밀번호 DB를 탈취하면 사전 계산 테이블(레인보우 테이블)로 흔한 비밀번호를 역조회할 수 있습니다. password123은 항상 같은 SHA-256 값으로 해싱되므로, 그 값이 DB에 나타나면 사용자별 계산 없이 비밀번호가 노출됩니다.
솔트는 해싱 전 비밀번호에 더해지는 무작위 값입니다. 사용자마다 고유한 솔트를 가지고 해시와 함께 저장됩니다. 이렇게 하면 같은 비밀번호를 쓰는 두 사용자도 서로 다른 해시를 갖게 되고, 레인보우 테이블은 무력화됩니다 — 가능한 모든 솔트 값마다 별도의 테이블이 필요해지기 때문입니다.
구체적으로:
# 솔트 없음 — 같은 비밀번호, 같은 해시
sha256("password123") → a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
# 솔트 적용 — 같은 비밀번호, 사용자별로 다른 해시
sha256("password123" + "user_alice_salt_xyz") → 다른 해시
sha256("password123" + "user_bob_salt_abc") → 또 다른 해시
현대의 비밀번호 해싱 라이브러리(bcrypt, Argon2)는 솔트 생성을 자동으로 처리합니다. 수동으로 관리할 필요는 없지만, 왜 필요한지 이해해두면 라이브러리가 올바르게 동작하는지 판단할 수 있습니다.
HMAC: 데이터가 변경되지 않았음을 서명으로 증명하기
해시 단독으로는 어떤 데이터가 특정 출력에 매핑된다는 사실만 알려줄 뿐, 누가 그 데이터를 만들었는지, 신뢰할 수 있는 사람에게서 왔는지는 알려주지 않습니다.
**HMAC(Hash-based Message Authentication Code)**는 해시와 비밀 키를 결합합니다. 출력되는 서명은 두 가지를 동시에 증명합니다: 데이터의 내용(일반 해시처럼)과 작성자가 비밀 키를 가지고 있었다는 사실.
웹 API에서 광범위하게 사용됩니다:
- Webhook 서명 (Stripe, GitHub 등은 HMAC으로 webhook 페이로드에 서명해 발송자 검증을 가능하게 함)
- API 요청 서명 (AWS Signature Version 4는 HMAC으로 API 요청에 서명)
- 쿠키 변조 방지 (HMAC으로 쿠키 내용에 서명하고 요청마다 검증)
HMAC 생성기로 시도해보세요. 비밀 키로 메시지의 HMAC을 생성한 뒤 메시지 한 글자를 바꿔 다시 생성하면 HMAC이 완전히 달라집니다. 비밀 키 없이 원래 HMAC을 재현하려고 해도 불가능합니다.
Webhook 검증 실전 코드:
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
# 타이밍 공격 방지를 위해 hmac.compare_digest 사용
return hmac.compare_digest(expected, signature)
==이 아닌 hmac.compare_digest를 쓴 점에 주목하세요. 타이밍 공격은 실재하는 위협입니다: 단순 문자열 비교는 불일치를 발견하는 즉시 조기 반환하면서 몇 글자가 일치했는지 정보를 흘립니다. compare_digest는 불일치 위치와 무관하게 상수 시간으로 동작합니다.
JWT: 컴팩트한 클레임 토큰
JSON Web Token은 현대 웹 애플리케이션에서 인증 클레임을 운반하는 주류 형식입니다. JWT는 점으로 구분된 세 개의 base64url 인코딩된 JSON 객체로 구성됩니다: 헤더, 페이로드, 서명.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
헤더는 알고리즘을, 페이로드는 클레임을 담고, 서명(비밀 또는 개인 키로 계산)이 변조를 막습니다.
JWT 디코더로 임의의 JWT 내부를 확인할 수 있습니다. 디코더는 비밀 키 없이도 헤더와 페이로드를 보여줍니다.
JWT의 핵심 보안 규칙:
1. 항상 서명을 검증하세요. 디코드 ≠ 검증. 어떤 라이브러리든 비밀 키 없이 JWT를 디코드할 수 있습니다 — 검증되지 않은 JWT의 클레임은 진위에 대해 아무것도 말해주지 않습니다. 백엔드는 모든 요청마다 기대하는 비밀 키 또는 공개 키로 서명을 검증해야 합니다.
2. exp(만료) 클레임을 확인하세요. 만료된 JWT는 서명이 유효해도 거부해야 합니다. “로그아웃 후에도 사용자가 인증된 상태로 남는 이유는?” 같은 버그 다수가 검증 경로에서 exp를 실제로 강제하지 않아 발생합니다.
3. alg: none을 경계하세요. 일부 JWT 라이브러리는 과거에 헤더가 "alg": "none"인 토큰을 받아들여 서명 검증을 완전히 우회시켰습니다. 사용 중인 라이브러리가 none을 명시적으로 거부하는지 확인하세요.
4. 민감한 애플리케이션에서는 JWT를 localStorage에 저장하지 마세요. localStorage는 JavaScript에서 접근 가능하므로 XSS 취약점 하나만으로 모든 토큰이 탈취됩니다. 가치 높은 인증 토큰에는 HttpOnly 쿠키가 적합합니다 — JavaScript가 읽을 수 없습니다.
5. 페이로드는 최소한으로 유지하세요. JWT 페이로드는 base64로 인코딩되어 있을 뿐 암호화되지 않습니다. 토큰을 가진 누구나 클레임을 읽을 수 있습니다. 비밀, 비밀번호, 민감한 PII를 페이로드에 넣지 마세요.
TOTP: 시간 기반 일회용 비밀번호 (인증 앱의 동작 원리)
이중 인증은 사용자 계정이 있는 모든 애플리케이션의 기본 보안 요구사항입니다. 내부 동작을 이해하면 올바르게 구현하고 문제 발생 시 디버깅에 도움이 됩니다.
**TOTP(Time-Based One-Time Password, RFC 6238)**의 동작 방식:
- 설정 시 서버가 비밀(보통 20바이트 무작위 값, base32 인코딩)을 생성
- 그 비밀이 QR 코드를 통해 사용자의 인증 앱과 공유됨
- 이제 서버와 인증 앱이 같은 비밀을 공유
- 코드 생성 시 양쪽이 모두
HMAC-SHA1(secret, floor(current_time / 30))을 계산 - 결과를 6자리 숫자로 절단
- 같은 비밀과 같은 현재 30초 윈도우를 사용하므로 양쪽이 같은 6자리 코드를 얻음
QR 코드는 비밀과 메타데이터를 URI 인코딩한 것일 뿐입니다: otpauth://totp/Issuer:[email protected]?secret=BASE32SECRET&issuer=Issuer&algorithm=SHA1&digits=6&period=30
TOTP 생성기로 TOTP 생성을 테스트하세요. 비밀을 생성하고 코드가 30초마다 바뀌는 모습을 관찰합니다 — Google Authenticator나 Authy 내부에서 일어나는 일 그대로입니다.
TOTP 구현에서 흔한 실수:
- 클라이언트와 서버 사이 시계 오차를 허용하는 작은 윈도우(±1 주기)를 두지 않음
- 같은 30초 윈도우 안에서 코드 재사용을 막지 않음
- TOTP 비밀을 평문으로 저장 (암호화하거나 비밀 관리 서비스에 보관)
- 매우 긴 주기(30초 대신 300초) 사용 — 편의성은 높지만 공격 윈도우가 크게 넓어짐
비대칭 암호학: 서명과 암호화를 위한 RSA 키
위의 모든 프리미티브는 대칭 암호 — 같은 비밀로 생성과 검증을 모두 수행 — 를 사용합니다. 비대칭 암호학은 수학적으로 연결된 두 키를 사용합니다: 공개 키와 개인 키. 한쪽이 암호화한 것은 다른 쪽만 복호화할 수 있고, 한쪽이 서명한 것은 다른 쪽이 검증할 수 있습니다.
RSA는 가장 널리 사용되는 비대칭 알고리즘입니다. 주요 용도:
- TLS 인증서 (서버가 제시하는 인증서에는 공개 키가 들어 있어, 브라우저가 개인 키 없이도 암호화 연결을 수립)
- RS256 JWT (서버가 개인 키로 JWT에 서명하고 클라이언트가 공개 키로 검증 — 서명 비밀을 공유할 필요가 없음)
- SSH 키 (
~/.ssh/id_rsa.pub은 공개 키; 개인 키는 본인 머신에 남아 있음; 서버는 공개 키만 저장하고 개인 키는 로컬을 떠나지 않음) - 코드 서명 (소프트웨어 배포자가 개인 키로 릴리스에 서명하고 사용자가 공개 키로 검증)
RSA 키 쌍 생성기로 RSA 키 쌍을 생성하세요. 공개 키가 개인 키에서 파생된다는 점에 주목하세요 — 독립적이지 않고 같은 수학적 객체의 양면입니다.
개인 키 취급 — 타협 불가 규칙:
- 개인 키를 버전 관리에 절대 커밋하지 마세요. 임시로도 안 되고, 비공개 저장소에서도 안 됩니다.
- 개인 키를 평문 채널(이메일, Slack 등)로 보내지 마세요
- 개인 키는 비밀 관리 서비스(AWS Secrets Manager, HashiCorp Vault, Google Secret Manager)에 보관하세요
- 접근 권한이 있던 사람이 조직을 떠나면 즉시 키를 회전시키세요
공개 키 암호학을 강력하게 만드는 비대칭성은 동시에 가차 없는 성격의 원천이기도 합니다. 공개 키는 자유롭게 공유해도 됩니다 — 웹사이트에 게시, API 응답에 포함, 클라이언트 앱에 하드코딩 모두 괜찮습니다. 그러나 개인 키가 노출되는 순간 그에 의존했던 모든 보안 보장이 무효화됩니다.
비밀번호 생성: “강력한”이 실제로 의미하는 것
대부분의 보안 시스템에서 가장 약한 고리는 사용자가 직접 선택한 비밀번호입니다. 사용자에게 직접 선택하게 하면 상당수가 password123, 반려동물 이름, 태어난 해를 고릅니다. 비밀번호를 생성해주거나 복잡도 요구사항을 강제하면 결과는 보통 더 나아지지만 항상 원하는 모습은 아닙니다.
무엇이 비밀번호를 강력하게 만들까요?
길이가 가장 큰 요인입니다. 95자 알파벳에서 뽑은 8자 비밀번호는 약 53비트의 엔트로피, 같은 알파벳의 16자 비밀번호는 약 105비트입니다. 공격자의 비용은 엔트로피 1비트마다 두 배가 됩니다. 길이는 가장 효과적인 레버입니다.
무작위성이 복잡도보다 중요합니다. 무작위 소문자 12자 비밀번호는 예측 가능한 “복잡한” 비밀번호 P@ssw0rd!1보다 강력합니다. 복잡도 요구사항은 비밀번호를 기억하기 어렵게 만들 뿐 추측하기 어렵게 만들지 못하는 경우가 많은데, 인간이 만드는 복잡한 패턴이 예측 가능하기 때문입니다.
자격증명을 생성해야 할 때(임시 비밀번호, API 키, 서비스 계정 자격증명, 복구 코드)는 비밀번호 생성기를 사용하세요. 브라우저 기반 도구의 암호학적 무작위 생성은 신뢰할 수 있고 생성된 값을 제3자 서버에 노출하지 않습니다.
사용자가 직접 만든 비밀번호에는 강력한 비밀번호 해싱 함수(비용 계수 12 이상의 bcrypt 또는 Argon2id)를 사용하고, 유출된 비밀번호 DB와 대조하세요(Have I Been Pwned API는 프라이버시를 보존하는 범위 쿼리를 지원합니다).
종합: 안전한 인증 체크리스트
사용자 인증이 있는 모든 웹 애플리케이션에서 다음을 확인하세요:
- 비밀번호는 사용자별 솔트와 함께 bcrypt/Argon2id 해시로 저장 (평문이나 일반 SHA-256 절대 금지)
- JWT는 모든 요청마다 검증 (서명 + 만료 + issuer + audience)
- Webhook 페이로드는 처리 전 HMAC으로 검증
- TOTP 또는 다른 2FA 제공 (관리자 계정에는 필수)
- RSA 또는 ECDSA 개인 키는 비밀 관리 서비스에 보관, 소스 코드에는 절대 두지 않음
- HTTPS 전면 강제 (혼합 콘텐츠 없음, HSTS 헤더 설정)
- 민감한 쿠키는 HttpOnly와 Secure 플래그 설정
- 비밀번호 재설정 토큰은 일회용이며 단기간 유효 (≤15분)
- 인증 엔드포인트에 속도 제한 적용
보안은 마지막에 추가하는 기능이 아닙니다. 설계와 구현의 매 단계에서 내려지는 결정의 집합입니다. 프리미티브 — 해싱, HMAC, JWT, TOTP, RSA — 는 잘 이해되어 있고 잘 문서화되어 있으며 모든 주요 언어의 표준 라이브러리에서 제공됩니다.
웹 애플리케이션 보안 취약점의 주된 원인은 암호학 지식의 부족이 아닙니다. 올바른 프리미티브의 잘못된 적용입니다: 느린 해시가 필요한 곳에 빠른 해시 사용, JWT 서명 검증 누락, 너무 접근하기 쉬운 환경에 비밀 보관.
프리미티브를 정확히 적용할 만큼 깊이 이해하는 것이 실용적인 목표입니다. 위 도구들이 그 이해를 구축하고 검증하는 데 도움을 줍니다. 사용해보세요 — 무언가 이상해 보이면 어떤 것도 가정하기 전에 포맷하고, 디코드하고, 실제 값을 읽어보세요.
보안은 항상 디테일에 있습니다.