해시·암호화·인코딩의 차이, 직접 실행하고 재 보기
보안 기초 강좌 1. Base64 를 한 줄로 되돌리고, '비밀번호'의 SHA-256 을 계산하고, 1비트만 바꿔 출력 절반이 바뀌는 것을 확인합니다. MD5·SHA-1 이 비밀번호에 맞지 않는 이유를 실측.
시리즈: 보안 기초 강좌 1/6 (초급). "해시해 두세요", "암호화돼 있어요", "Base64 로 감춰 놨어요"가 같은 뜻처럼 쓰이는 현장에서 헷갈리는 신입 개발자를 위한 글입니다. 다 읽고 나면 네 가지 도구를 구별하고 각각 1분 안에 직접 돌려 볼 수 있으며, 빠른 해시가 비밀번호 저장에 맞지 않는 이유를 숫자로 설명할 수 있습니다.
한국에서 서비스를 만들면 개인정보보호법과 그 하위 기준 때문에 "비밀번호는 일방향 암호화해서 저장하고, 주민등록번호 같은 고유식별정보는 암호화해야 한다"는 말을 일찍부터 듣게 됩니다. 그런데 여기서 말하는 '일방향 암호화'는 이 글의 용어로는 해시이고, '암호화'는 되돌릴 수 있는 암호화입니다. 단어가 비슷해서 비밀번호를 양방향 암호화로 저장하거나, Base64 로 바꿔 놓고 암호화했다고 착각하는 일이 실제로 생깁니다. 정보보안기사 공부를 하다 보면 "해시와 암호화의 차이", "Base64 는 암호화인가" 같은 질문을 자주 만나는데, 이 글은 그 답을 손으로 확인하는 실습입니다. (법령의 세부 요건은 전문가에게 확인하세요. 여기서는 널리 알려진 일반론만 다룹니다.)
아래 예제는 모두 Apple Silicon Mac(M1 Pro)에서 Python 3.9.6, PHP 8.5.6, OpenSSL 4.1.0, macOS 기본 shasum 과 base64 로 실행했습니다. 라이브러리는 아무것도 설치하지 않았고, 경로는 ~/demo/ 로 바꿨습니다.
비슷해 보이지만 다른 네 가지
먼저 전체 그림입니다. 이어서 하나씩 직접 돌려 봅니다.
| 도구 | 예 | 키가 필요한가 | 되돌릴 수 있나 | 무엇을 막나 |
|---|---|---|---|---|
| 인코딩 | Base64, 16진수, UTF-8 | 아니오 | 누구나 되돌림 | 아무것도 막지 않음. 형식만 바꿈 |
| 해시 (일방향) | SHA-256, BLAKE2 | 아니오 | 불가 | 우연한 손상, 출력에서 입력을 알아내기 |
| MAC | HMAC-SHA256 | 공유 비밀 키 | 불가 | 키 없는 사람의 위조·변조 |
| 암호화 (양방향) | AES-GCM | 필요 | 키가 있으면 가능 | 키 없는 사람이 내용을 읽는 것 |
용어 두 개만 먼저 풀어 둡니다. 해시 함수는 어떤 입력이든 고정 길이 '지문'(SHA-256 은 32바이트)으로 바꾸는 함수이고, 거꾸로 계산할 수 없습니다. MAC(메시지 인증 코드)은 비밀 키도 입력으로 받는 해시라서, 키를 아는 사람만 검증을 통과하는 지문을 만들 수 있습니다.
Base64 는 암호화가 아니다: 한 줄이면 되돌아온다
Base64 는 3바이트마다 A–Z a–z 0–9 + / 중 4글자로 바꾸는 방식입니다. 메일, JSON, URL, HTTP 헤더처럼 '글자만 지나가는 곳'에 바이너리를 통과시키려고 있습니다. 신입이 "알아볼 수 없으니 안전하겠지" 하고 설정 파일에 넣기 쉬운 문자열을 만들어 바로 되돌려 봅니다.
import base64
token = base64.b64encode(b"kim:Gaeul2026!").decode()
print("encoded :", token)
print("decoded :", base64.b64decode(token))
encoded : a2ltOkdhZXVsMjAyNiE=
decoded : b'kim:Gaeul2026!'
Python 이 없어도 이 문자열을 본 사람은 누구나 셸에서 되돌립니다.
$ echo 'a2ltOkdhZXVsMjAyNiE=' | base64 -d
kim:Gaeul2026!
이 문자열이 바로 HTTP Basic 인증이 보내는 값입니다. 받은 헤더를 출력하기만 하는 3줄짜리 스크립트를 php -S 127.0.0.1:18157 로 띄우고 curl 로 호출했습니다.
$ curl -sv -u 'kim:Gaeul2026!' http://127.0.0.1:18157/ 2>&1 | grep -E '^> Auth|^decoded'
> Authorization: Basic a2ltOkdhZXVsMjAyNiE=
decoded: kim:Gaeul2026!
평문 HTTP 에서 Basic 인증을 쓰면 비밀번호가 읽을 수 있는 형태로 흘러갑니다. 지켜 주는 것은 TLS 뿐입니다(HTTPS, TLS, 인증서). 규칙은 하나, 키가 없으면 비밀도 없다.
5분 만에 첫 해시: SHA-256
해시는 '글자'가 아니라 바이트를 다룹니다. 한글은 인코딩에 따라 바이트가 달라지므로 먼저 UTF-8 로 바꿉니다(바이트와 글자의 차이가 흐릿하다면 문자 인코딩, 바이트, UTF-8, 코드 포인트를 먼저 보세요).
import hashlib
for s in ["비밀번호", "비밀번호!"]:
b = s.encode("utf-8")
print(f"{s!r:14} {len(b):2d} bytes {hashlib.sha256(b).hexdigest()}")
'비밀번호' 12 bytes 4fa210a06f9faa90aadca1a977d784d5d4e95ed4c993a60b2d6c5f0bf5956633
'비밀번호!' 13 bytes e7852e6f4c0ee022358f12e772ca614ac731bf2a5556fb4ed1f3f375e11b3ef3
한글 4글자가 12바이트이고, 느낌표 하나로 출력이 통째로 바뀌었습니다. SHA-256 은 표준이라 다른 도구로 계산해도 값이 같습니다.
$ printf '비밀번호' | shasum -a 256
4fa210a06f9faa90aadca1a977d784d5d4e95ed4c993a60b2d6c5f0bf5956633 -
$ printf 'Hello, OWASP' | openssl dgst -sha256
SHA2-256(stdin)= 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
$ php -r 'echo hash("sha256", "Hello, OWASP"), "\n";'
7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
여기서 첫 번째 함정입니다. printf 대신 echo 를 쓰면 값이 완전히 달라집니다.
$ echo '비밀번호' | shasum -a 256
7d8698cc02d9da71e4b6b097d414e9cecc11c179546f136103b0fce3bd9894b2 -
echo 가 끝에 줄바꿈을 붙여서 13바이트를 해시했기 때문입니다. 한글에는 함정이 하나 더 있습니다. 눈에는 같은 '비밀번호'라도 완성형으로 합친 NFC 는 12바이트, 자모로 풀어 쓴 NFD 는 30바이트이고 해시는 4fa210a0… 와 1d6f48c0… 로 갈립니다. Mac 에서 만든 파일 이름이 NFD 로 오는 경우가 있으니, "같은 문자열인데 해시가 다르다"면 바이트부터 의심하세요.
애벌런치 효과: 1비트만 바꿔 보기
좋은 해시에는 애벌런치 효과(눈사태 효과)가 있습니다. 입력 1비트만 바꿔도 출력 비트의 절반쯤이 규칙 없이 바뀌는 성질입니다. H(0x48)를 I(0x49)로 바꾸면 정확히 1비트 차이입니다.
b'Hello, OWASP' 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
b'Iello, OWASP' ab4d7b3d2213c9bc8a3286cb80924bcfc49d4c3a6eff5441f3a742727fae1c66
input bits changed : 1
output bits changed: 124 of 256
한 번은 우연일 수 있으니, 시드를 고정한 무작위 32바이트 입력으로 10,000번 반복했습니다.
import hashlib, random, statistics
rng = random.Random(2026)
bits = lambda b: int.from_bytes(b, "big")
counts = []
for _ in range(10_000):
m = bytearray(rng.getrandbits(8) for _ in range(32))
a = hashlib.sha256(m).digest()
i = rng.randrange(256)
m[i // 8] ^= 1 << (i % 8) # 무작위 1비트 뒤집기
counts.append(bin(bits(a) ^ bits(hashlib.sha256(m).digest())).count("1"))
print(f"N={len(counts)} mean={statistics.mean(counts):.2f} "
f"stdev={statistics.pstdev(counts):.2f} min={min(counts)} max={max(counts)}")
N=10000 mean=127.96 stdev=8.00 min=92 max=160
공정한 동전 256개를 던졌을 때와 같은 모양(평균 128, 표준편차 8)입니다. 그래서 해시를 원하는 값 쪽으로 조금씩 '맞춰 갈' 수 없고, 해시 두 개를 비교해서 입력이 비슷했는지 알아낼 수도 없습니다.
해시·MAC·암호화를 나란히 돌려 보기
그냥 해시에는 비밀이 없습니다. 서버가 user_id=42&role=member 와 그 SHA-256 을 클라이언트에 넘기면, 클라이언트는 role 을 바꾸고 SHA-256 을 다시 계산하면 그만입니다. MAC 은 태그를 계산하는 데 서버의 키가 필요하므로 이를 막습니다.
import hmac, hashlib
key = b"server-side-secret-key-32-bytes!"
msg = b"user_id=42&role=member"
tag = hmac.new(key, msg, hashlib.sha256).hexdigest()
forged = b"user_id=42&role=admin"
print("tag :", tag)
print("forged message accepted?",
hmac.compare_digest(tag, hmac.new(key, forged, hashlib.sha256).hexdigest()))
tag : 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6
forged message accepted? False
OpenSSL 로도 같은 태그가 나옵니다. 두 구현을 맞춰 볼 때 쓰는 방법입니다.
$ printf 'user_id=42&role=member' | openssl dgst -sha256 -hmac 'server-side-secret-key-32-bytes!'
SHA2-256(stdin)= 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6
다만 MAC 은 아무것도 숨기지 않습니다. role=member 는 그대로 읽힙니다. 숨기려면 암호화가 필요하고, 암호화는 키가 있으면 되돌아오고 키가 틀리면 실패합니다.
$ printf 'card ending 4242' > plain.txt
$ openssl enc -aes-256-cbc -pbkdf2 -salt -in plain.txt -out c.bin -pass pass:correct-horse
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:correct-horse
card ending 4242
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:wrong
bad decrypt
80E182F501000000:error:1C800064:Provider routines:ossl_cipher_unpadblock:bad decrypt:...
$ echo $?
1
c.bin 의 첫 8바이트는 글자 그대로 Salted__ 이고 그 뒤에 무작위 솔트가 이어집니다. -pbkdf2 를 빼먹으면 OpenSSL 4.1 에서도 *** WARNING : deprecated key derivation used. 가 나옵니다. 이 함정과, CBC 대신 AES-GCM 을 써야 하는 이유는 강좌 5에서 다룹니다.
흔한 실수: 무슨 일이 생기고 어떻게 고치나
1. "앱 안의 API 키를 Base64 로 바꿔 놨으니 안 보인다"
생기는 일: 앱을 풀어 본 사람이 base64 -d 한 번이면 몇 초 만에 키를 얻습니다. 고치는 법: 클라이언트 앱에 넣어 배포한 비밀은 비밀이 아닙니다. 키는 서버에 두고, 앱에는 수명이 짧은 토큰을 줍니다(강좌 3).
2. 직접 만든 서명 sha256(비밀 + 메시지)
생기는 일: SHA-256 구조상 sha256(비밀 + 메시지) 값만 알면 비밀 + 메시지 + 패딩 + 추가 데이터 의 올바른 해시를 계산할 수 있습니다(길이 확장 공격. 여기서는 설명만 하고 실행하지 않았습니다). 고치는 법: Python hmac.new(key, msg, sha256) 나 PHP hash_hmac() 를 씁니다.
3. 태그를 == 로 비교하기
생기는 일: PHP 에서 "0e462097431906509019562988736854" == "0e830400451993494058024219903391" 은 true 입니다. 둘 다 숫자 0 처럼 보이기 때문입니다(강좌 2에서 실행합니다). 어느 언어든 == 는 처음 다른 바이트에서 멈추므로 시간 차이가 샙니다. 고치는 법: Python 은 hmac.compare_digest(), PHP 는 hash_equals()(강좌 6).
4. 해시를 '복호화'하기 생기는 일: "MD5 복호화"를 해 준다는 사이트는 알려진 문자열의 해시를 모아 둔 거대한 표를 찾아볼 뿐입니다. 고치는 법: 추측하기 쉬운 입력의 해시는 추측할 수 있다는 것을 이해하는 것입니다. 비밀번호에 솔트와 느린 해시가 필요한 이유가 바로 이것입니다.
5. 배포 파일 확인에 MD5 쓰기 생기는 일: MD5 와 SHA-1 에는 실용적인 충돌 공격이 있어서, 해시가 같은 서로 다른 파일을 만들 수 있습니다. 공격자가 파일을 고를 수 있는 상황에서는 서명이나 다운로드 확인이 무너집니다. 고치는 법: 무결성 확인에는 SHA-256 이나 BLAKE2, 공개한 해시 자체가 바뀔 수 있다면 MAC 이나 전자서명을 씁니다.
실측: MD5·SHA-1·SHA-256 이 비밀번호에 맞지 않는 이유
"MD5 는 깨졌으니 비밀번호에 쓰면 안 된다"는 말을 자주 듣습니다. 하지만 진짜 이유는 충돌이 아니라 속도입니다. 비밀번호용 해시는 추측 한 번을 비싸게 만들어야 하는데, 이 함수들은 싸게 계산되도록 설계됐습니다. OpenSSL 자체 벤치마크로 CPU 코어 하나를 쟀습니다(입력 16바이트, 각 2초, 3회).
$ openssl speed -seconds 2 -bytes 16 -evp sha256
Doing sha256 ops for 2s on 16 size blocks: 15614250 sha256 ops in 1.98s
SHA-256 은 코어 하나로 초당 약 789만 번, MD5 는 초당 약 575만 번이었습니다. Apple Silicon 에는 SHA-256 전용 명령어가 있어서, 이 환경에서는 SHA-256 이 MD5 보다 느리지도 않습니다. MD5 를 SHA-256 으로 바꿔도 비밀번호 보호는 전혀 강해지지 않는다는 뜻입니다.
시간으로 바꿔 봅시다. 영문 소문자 8자리 비밀번호는 26⁸ ≈ 2.09 × 10¹¹ 개입니다. 2.09 × 10¹¹ ÷ 7.89 × 10⁶ ≈ 26,500초, 노트북 코어 하나로 약 7.4시간. 자주 쓰이는 비밀번호 100만 개 목록이라면 약 0.13초입니다. 공격자는 코어 여러 개와 GPU 를 쓰므로 실제로는 훨씬 더 빠릅니다(여기서는 재지 않았습니다).
Python 에서 호출하면 느려집니다(8바이트 입력 기준 초당 325만 번, N = 200,000, 5회 중앙값). 인터프리터 루프가 해시 자체보다 무겁기 때문입니다. 공격자의 속도를 어림할 때는 내 코드가 아니라 가장 빠른 구현을 기준으로 삼으세요.
용도별 판단표
| 하고 싶은 일 | 쓸 것 | 쓰지 말 것 |
|---|---|---|
| 바이트를 JSON·URL·메일에 싣기 | Base64 / Base64url | 숨겼다고 생각하기 |
| 다운로드 확인, 우연한 손상 감지 | SHA-256, BLAKE2 | MD5, SHA-1 |
| 쿠키나 웹훅이 내가 보낸 것임을 증명 | 비밀 키를 쓰는 HMAC-SHA256 | sha256(비밀 + msg) |
| 데이터를 비밀로 두고 나중에 꺼내기 (고유식별정보 등) | AES-GCM 이나 libsodium (강좌 5) | Base64, XOR, ECB 모드 |
| 비밀번호 저장 | bcrypt, Argon2id, scrypt, PBKDF2 (강좌 2) | MD5, SHA-1, SHA-256, 양방향 암호화 |
| 토큰과 키 만들기 | secrets / random_bytes (강좌 3) |
random, 시각, 사용자 ID 의 해시 |
주민등록번호처럼 나중에 원문이 다시 필요한 값은 양방향 암호화, 대조만 하면 되는 비밀번호는 일방향(해시)이라는 구분이 이 표의 4번째와 5번째 줄입니다.
정직한 한계와 버전 메모
- Python 3.9.6(이 Mac 에서는
hashlib이 LibreSSL 2.8.3 에 링크됨), PHP 8.5.6, OpenSSL 4.1.0 으로 실행했습니다. 해시 값은 표준이라 어디서나 같지만, 속도는 이 M1 Pro 코어 하나의 숫자입니다. openssl speed는 하드웨어 SHA 명령어를 쓰는 C 코드 속도입니다. CPU 코어 하나 기준의 공격자 어림값이지 GPU 수치가 아닙니다.- 애벌런치 통계는 시드 2026, N = 10,000 한 번의 결과입니다. 시드를 바꾸면 건수는 조금 달라지지만 모양은 같습니다.
- 길이 확장 공격과 MD5 충돌은 설명만 하고 실행하지 않았습니다. SHA-256 은 충돌에는 강하지만 길이 확장의 영향을 받고, HMAC 과 SHA-3 은 받지 않습니다.
- OWASP Top 10 의 번호는 판마다 바뀝니다. 여기서는 2021년판 분류 이름(Cryptographic Failures)만 언급했습니다.
연습 문제
1. 실행하지 말고 답하세요. echo -n '비밀번호' | shasum -a 256 의 결과는 printf 판과 echo 판 중 어느 쪽과 같을까요? 이유도 함께.
2. 동료가 로그인 유지용으로 쿠키에 base64(user_id + ":" + 만료시각) 을 넣었습니다. 문제점과, 판단표의 어떤 도구로 고칠지 답하세요.
3. 초당 789만 번일 때, 솔트 없는 SHA-256 으로 저장한 6자리 숫자 PIN(000000~999999)을 코어 하나로 전부 시도하면 몇 초 걸릴까요?
정답
1. printf 판과 같은 4fa210a0…6633 입니다. -n 이 줄바꿈을 붙이지 않기 때문입니다. (zsh 내장 echo 는 -n 을 받아 주지만 sh 종류에 따라 받지 않기도 하므로 printf 가 더 확실합니다.)
2. Base64 는 키 없이 되돌릴 수 있어서 누구나 디코딩해 user_id 를 바꾸고 다시 인코딩할 수 있습니다. 서버 쪽 키로 HMAC-SHA256 태그를 붙이고 상수 시간 비교로 검증합니다(또는 무작위 세션 ID 를 서버에서 관리합니다).
3. 10⁶ ÷ 7.89 × 10⁶ ≈ 0.13초입니다. 오프라인 공격자 앞에서는 어떤 해시를 써도 6자리 PIN 은 지킬 수 없습니다. 다음 강좌에서 시도 횟수 제한과 비밀번호 길이 이야기를 함께 하는 이유입니다.
다음 글
- 다음 강좌: 보안 기초 강좌 2/6: bcrypt 와 Argon2id 로 비밀번호 해시하기
- 이 시리즈의 뒷부분: 안전한 난수와 토큰, JWT HS256 서명의 함정, AES-GCM 제대로 쓰기, 타이밍 공격과 상수 시간 비교
- 관련 글: PHP 보안: XSS, CSRF, 세션, HTTP 헤더, 쿠키, 세션
- 모든 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
Base64 는 암호화의 한 종류인가요?
아닙니다. Base64 는 바이트를 출력 가능한 64개 문자로 바꿔 쓰는 것뿐이고 키가 없습니다. base64 -d 나 Python 한 줄이면 a2ltOkdhZXVsMjAyNiE= 가 kim:Gaeul2026! 로 바로 돌아옵니다. HTTP Basic 인증 헤더도 Base64 라서 HTTPS 로만 보내야 합니다.
해시, MAC, 암호화는 무엇이 다른가요?
SHA-256 같은 해시는 키 없는 일방향 지문이라 누구나 계산할 수 있습니다. HMAC-SHA256 같은 MAC 은 비밀 키를 섞기 때문에 키를 가진 쪽만 올바른 태그를 만들 수 있고, 위조된 메시지는 거부됩니다. 암호화는 양방향이라 키가 있으면 원문을 되찾습니다. 내용을 숨기는 것은 암호화뿐이고, 누가 썼는지 증명하는 것은 MAC 뿐입니다.
비밀번호를 MD5, SHA-1, SHA-256 으로 저장하면 안 되는 이유는?
너무 빠르기 때문입니다. openssl speed 실측에서 Apple M1 Pro 코어 하나가 SHA-256 을 초당 약 789만 번, MD5 를 초당 약 575만 번 계산했습니다. 영문 소문자 8자리 비밀번호를 전부 시도해도 약 7.4시간입니다. 비밀번호에는 bcrypt 나 Argon2id 처럼 솔트가 붙고 일부러 느린 해시를 씁니다.