해싱 vs 암호화: 언제 무엇을 쓸까
단방향 해시 함수와 양방향 암호화의 근본적 차이, 비밀번호 저장에 암호화 대신 해싱을 써야 하는 이유, MD5/SHA-1/SHA-256/bcrypt/Argon2 알고리즘 선택 기준을 다룹니다.
지난 글에서 대칭·비대칭 암호화의 차이와 TLS의 하이브리드 구조를 살펴봤다. 이번 글에서는 암호학에서 자주 혼동되는 해싱(Hashing)과 암호화(Encryption)의 근본적 차이를 명확히 하고, 각 상황에서 올바른 선택을 한다.
핵심 차이: 단방향 vs 양방향
해싱은 임의 크기의 입력을 고정 크기의 다이제스트(digest)로 변환하는 단방향 함수다. 수학적으로 역방향 계산이 불가능하다 — 다이제스트에서 원본을 복구할 수 없다.
암호화는 키를 사용해 데이터를 변환하는 양방향 함수다. 같은 키(대칭) 또는 쌍(비대칭)으로 원본을 복구할 수 있다.
비밀번호는 반드시 해싱
가장 흔한 보안 실수 중 하나는 비밀번호를 암호화해서 저장하는 것이다.
# ❌ 잘못된 방식 — 비밀번호를 암호화로 저장
def wrong_store_password(password: str) -> bytes:
# AES로 암호화 — 키가 있으면 복구 가능!
return encrypt(SECRET_KEY, password.encode())
# ❌ 더 나쁜 방식 — 평문 저장
def terrible_store_password(password: str) -> str:
return password # 데이터베이스 침해 시 즉시 전체 유출
# ✅ 올바른 방식 — 비밀번호를 단방향 해싱
def correct_store_password(password: str) -> str:
import bcrypt
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt(rounds=12)
).decode('utf-8')
왜 암호화가 아닌 해싱인가?
비밀번호는 복호화할 필요가 없다. 로그인 시 사용자가 입력한 비밀번호를 다시 해싱해서 저장된 해시와 비교하면 된다. 암호화로 저장하면 키가 유출되는 순간 전체 비밀번호 데이터베이스가 위험해진다.
알고리즘 선택 가이드
MD5, SHA-1: 절대 금지
# ❌ MD5 — 충돌 알려짐, 레인보우 테이블로 역추적 가능
import hashlib
h = hashlib.md5(b"password123").hexdigest()
# 1초도 안 걸려 역추적 가능
# ❌ SHA-1 — SHAttered 공격(2017)으로 충돌 데모됨
h = hashlib.sha1(b"password123").hexdigest()
MD5와 SHA-1은 속도가 매우 빠르다 — 이것이 비밀번호 해싱에서는 치명적인 단점이다. GPU로 초당 수백억 개의 해시를 계산할 수 있어 무차별 대입 공격이 현실적으로 가능하다.
SHA-256/384/512: 파일 무결성에 사용
import hashlib
# 파일 무결성 검증 — SHA-256 적합
def verify_file_integrity(filepath: str, expected_hash: str) -> bool:
h = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
return h.hexdigest() == expected_hash
# HMAC으로 메시지 인증 코드 생성 (API 요청 서명 등)
import hmac
mac = hmac.new(SECRET_KEY, message, hashlib.sha256).hexdigest()
SHA-256은 파일 무결성 검증, SRI 해시, 디지털 서명의 해시 단계에 적합하다. 그러나 비밀번호 해싱에는 사용하면 안 된다 — 너무 빠르기 때문이다.
bcrypt: 비밀번호 해싱 표준
bcrypt는 의도적으로 느리게 설계되어 있다. cost factor(작업량)를 조절해 하드웨어 발전에 맞춰 속도를 조절할 수 있다.
import bcrypt
def hash_password(password: str) -> str:
# cost=12: 약 0.2~0.5초 (권장 최솟값)
# cost=14: 약 1초 (보안 민감 환경)
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(
password.encode('utf-8'),
hashed.encode('utf-8')
)
# 사용
hashed = hash_password("myP@ssword123!")
assert verify_password("myP@ssword123!", hashed) # True
assert not verify_password("wrongpassword", hashed) # False
bcrypt는 salt를 자동으로 생성해 포함하므로 같은 비밀번호도 매번 다른 해시가 생성된다. 레인보우 테이블 공격이 원천 차단된다.
한계: 최대 72바이트 입력 제한이 있다. 긴 비밀번호는 앞 72바이트만 처리된다.
Argon2id: 현재 최고 표준
2015년 Password Hashing Competition(PHC) 우승 알고리즘이다. bcrypt보다 강력한 이유는 메모리 집약적이라 GPU 병렬 처리에 저항성이 있다.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=2, # 반복 횟수
memory_cost=65536, # 메모리 사용량 (KB) = 64MB
parallelism=2 # 병렬 스레드
)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(stored_hash: str, password: str) -> bool:
try:
return ph.verify(stored_hash, password)
except VerifyMismatchError:
return False
# 재해싱 필요 여부 확인 (설정 변경 후)
def login_and_update(stored_hash: str, password: str) -> str | None:
if not verify_password(stored_hash, password):
return None
if ph.check_needs_rehash(stored_hash):
return hash_password(password) # 새 해시 반환
return stored_hash
scrypt: Argon2의 대안
Node.js 내장 crypto 모듈이 scrypt를 지원한다.
const { scrypt, randomBytes } = require('node:crypto');
const { promisify } = require('util');
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(32);
const hash = await scryptAsync(password, salt, 64);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
언제 무엇을 쓸 것인가
| 용도 | 권장 알고리즘 |
|---|---|
| 비밀번호 저장 | Argon2id (우선) / bcrypt |
| API 요청 서명 (HMAC) | HMAC-SHA256 |
| 파일 무결성 검증 | SHA-256 / SHA-384 |
| 디지털 서명 내 해시 | SHA-256 이상 |
| TLS 인증서 서명 | SHA-256 이상 |
| ❌ 절대 금지 | MD5, SHA-1 (모든 보안 용도) |
지난 글: 대칭 암호화 vs 비대칭 암호화
다음 글: AES 운용 모드: ECB·CBC·GCM의 보안 차이
읽어주셔서 감사합니다. 😊