
해시 함수: 갈아버린 고기는 다시 소가 될 수 없다
비밀번호를 안전하게 저장하는 유일한 방법. 단방향 암호화(One-way Encryption)와 눈사태 효과(Avalanche Effect) 이해하기.

비밀번호를 안전하게 저장하는 유일한 방법. 단방향 암호화(One-way Encryption)와 눈사태 효과(Avalanche Effect) 이해하기.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

개발 초기, 레거시 코드를 보다가 충격을 받았습니다.
SELECT * FROM users WHERE password = 'mypassword123';
저: "비밀번호를 평문으로 저장하면 안 되는 거 아닌가요?"
시니어: "그래서 해시 함수를 써야지."
저: "해시가 뭔데요?"
시니어: "갈아버린 고기는 다시 소가 될 수 없잖아?"
"????"그 순간 이해가 안 갔습니다. 고기? 소? 이게 보안이랑 무슨 상관인가 싶었습니다. 시니어는 웃으며 "나중에 알게 될 거야"라고만 했습니다.
그 다음 주, 회사 DB가 해킹당했습니다.
해커가 훔친 것:
CEO: "어떻게 이런 일이..."
시니어: "해시 함수 안 써서 그래요. 비밀번호가 그대로 노출됐습니다."
더 끔찍한 건, 사용자들이 그 비밀번호를 다른 사이트에서도 쓰고 있었다는 겁니다. 은행, 이메일, 쇼핑몰... 한 번의 해킹으로 모든 게 뚫렸습니다.
법무팀 미팅이 소집됐고, GDPR 위반으로 엄청난 벌금이 부과될 거라는 얘기가 나왔습니다. CTO는 사표를 냈습니다.
그날부터 보안을 공부했습니다. 정확히는, 공부할 수밖에 없었습니다.
보안 공부를 시작하면서 부딪힌 벽들:
무엇보다 "왜 복호화를 못 하게 만들어?"라는 의문이 가장 컸습니다. 비밀번호를 암호화해서 저장했으면, 필요할 때 복호화하면 되는 거 아닌가요?
시니어에게 물었습니다: "복호화 키를 안전하게 보관하면 되는 거 아닌가요?"
시니어: "그 키를 누가 관리해? 그 키도 털리면?"
저: "그럼... 그 키를 암호화하는 키를... 만들면..."
시니어: "그럼 그 키는? 무한 회귀야. 그래서 아예 되돌릴 수 없게 만드는 거야."
그때 머리를 한 대 맞은 기분이었습니다.시니어가 실제로 삼겹살을 사 와서 믹서기를 돌렸습니다.
"소고기를 믹서기에 넣고 갈았어.
다짐육 ← 소고기 (갈기)
이제 이 다짐육을 다시 살아있는 소로 만들 수 있어? 수학적으로 불가능해.
해시 함수 = 믹서기 입력(소고기) → 출력(다짐육) 출력에서 입력 복원 = 불가능"
갈린 고기를 보면서 이해했습니다.
"아, 정보가 손실되는구나!"복호화가 불가능한 게 아니라, 애초에 원본 정보가 파괴되는 겁니다. 고기를 갈 때, 어떤 부위였는지, 어떤 모양이었는지, 모든 구조 정보가 사라집니다. 남는 건 "간 고기"라는 결과물뿐입니다.
그때 "갈아버린 고기는 다시 소가 될 수 없다"는 말이 제 학습 노트 첫 페이지에 적혔습니다. 결국 이거였습니다. 해시의 본질은 정보 파괴였던 겁니다.
const crypto = require('crypto');
const hash1 = crypto.createHash('sha256').update('Hello').digest('hex');
const hash2 = crypto.createHash('sha256').update('안녕하세요 이것은 매우 긴 문자열입니다 123456789').digest('hex');
console.log(hash1.length); // 64
console.log(hash2.length); // 64
// 항상 같은 길이!
이게 신기했습니다. 입력이 5글자든, 1만 글자든, 출력은 항상 64자(SHA-256 기준)입니다.
특징:
처음엔 1번만 알았는데, 3번과 4번이 진짜 중요하다는 걸 나중에 이해했습니다.
-- users 테이블
| id | email | password |
|----|-------------------|---------------|
| 1 | user@example.com | mypassword123 |
| 2 | admin@example.com | admin1234 |
문제:
우리 회사가 바로 이랬습니다. DB 백업 파일도 암호화 안 돼 있어서, 백업을 훔친 순간 게임 끝이었습니다.
const bcrypt = require('bcrypt');
// 회원가입 시
async function registerUser(email, password) {
// 1. 비밀번호를 해시로 변환
const saltRounds = 10; // 2^10 = 1024번 반복
const hashedPassword = await bcrypt.hash(password, saltRounds);
// 2. DB에 해시만 저장
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, hashedPassword]
);
console.log('원본 비밀번호:', password);
console.log('저장된 해시:', hashedPassword);
// $2b$10$N9qo8uLvGBiOGNWJF/T2FeHB7KzY5ZJZ7Nw8fO.5BkJN...
}
// 로그인 시
async function loginUser(email, inputPassword) {
// 1. DB에서 해시 가져오기
const user = await db.query(
'SELECT password_hash FROM users WHERE email = $1',
[email]
);
// 2. 입력한 비밀번호를 해시로 변환해서 비교
const isMatch = await bcrypt.compare(inputPassword, user.password_hash);
if (isMatch) {
console.log('로그인 성공!');
return true;
} else {
console.log('비밀번호 틀림!');
return false;
}
}
-- users 테이블 (해시 사용)
| id | email | password_hash |
|----|-------------------|----------------------------------------------|
| 1 | user@example.com | $2b$10$N9qo8uLvGBiOGNWJF/T2FeHB7KzY... |
| 2 | admin@example.com | $2b$10$KpOzL9mN3qR7sT8uV9wX0eYzA... |
해커가 DB 훔쳐도:
$2b$10$N9qo8uL... 만 보임mypassword123) 알 수 없음이렇게 바꾸고 나서 숨통이 트였습니다. 이제 DB가 털려도 비밀번호는 안전합니다.
const crypto = require('crypto');
const hash1 = crypto.createHash('sha256').update('Hello').digest('hex');
const hash2 = crypto.createHash('sha256').update('Hello.').digest('hex');
const hash3 = crypto.createHash('sha256').update('hello').digest('hex'); // 대소문자만 변경
console.log('Hello:');
console.log(hash1);
// 185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969
console.log('\nHello.:');
console.log(hash2);
// f96b697d7cb7938d525a2f31aaf161d0478869e5c5bb94f4b3c2d3f3f3e2d2e1
console.log('\nhello:');
console.log(hash3);
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
// 완전 다름!
. 하나 추가했을 뿐인데, 해시가 완전히 달라집니다. 이게 눈사태 효과입니다.
처음 봤을 때 "이게 왜 중요해?"라고 생각했는데, 나중에 파일 무결성 검사를 하면서 이해했습니다.
활용:
제가 AWS S3에 파일 업로드할 때 이걸 씁니다. 업로드 전후 해시를 비교해서 전송 중 손상 여부를 확인합니다.
제가 만든 서비스에서 유저 프로필 이미지를 저장할 때, 파일명을 이렇게 만들었습니다:
// 나쁜 예시 (실제로 제가 썼던 코드)
function generateImageFilename(userId, timestamp) {
// userId를 해시로 변환해서 파일명으로 사용
const hash = crypto.createHash('md5')
.update(userId.toString())
.digest('hex')
.substring(0, 8); // 앞 8자리만 사용
return `profile_${hash}.jpg`;
}
// 결과: profile_5d41402a.jpg
짧고 깔끔해 보여서 좋아했습니다. 8자리면 충분하다고 생각했습니다.
유저가 5만 명쯤 됐을 때, 이상한 버그 리포트가 들어왔습니다:
"제 프로필 사진이 다른 사람 사진으로 바뀌어 있어요."
처음엔 믿지 않았습니다. 그럴 리가 없다고. 로그를 뒤져보니...
User A (ID: 12345) → profile_a3d5c8e1.jpg
User B (ID: 67890) → profile_a3d5c8e1.jpg
// 충돌!
해시 충돌(Hash Collision)이 일어난 겁니다.
MD5 해시의 앞 8자리만 쓰면, 가능한 경우의 수는 16^8 = 4,294,967,296(약 43억)입니다. 생일 문제(Birthday Paradox)에 의하면, 5만 명만 되어도 충돌 확률이 급격히 올라갑니다.
// 개선된 코드
const { v4: uuidv4 } = require('uuid');
function generateImageFilename(userId) {
// UUID v4: 완전 랜덤, 충돌 확률 극히 낮음
const uuid = uuidv4();
return `profile_${userId}_${uuid}.jpg`;
}
// 결과: profile_12345_f47ac10b-58cc-4372-a567-0e02b2c3d479.jpg
이후로 충돌 문제는 완전히 사라졌습니다. 그때 이해했습니다: "해시 충돌은 이론이 아니라 현실이다."
제가 프로젝트를 바꾸고, 그 예전 프로젝트 이야기입니다.
import hashlib
def hash_password(password):
# MD5로 비밀번호 해시
return hashlib.md5(password.encode()).hexdigest()
# 회원가입
password = 'password123'
hashed = hash_password(password)
print(hashed)
# 482c811da5d5b4bc6d497ffa98491e38
# DB 저장
db.execute(
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
('user@example.com', hashed)
)
당시 팀장: "MD5도 해시잖아. 되돌릴 수 없으니까 안전하지."
저도 그렇게 믿었습니다. 큰 착각이었습니다.
1. DB 털어서 해시 획득: 482c811da5d5b4bc6d497ffa98491e38
2. Google 검색: "482c811da5d5b4bc6d497ffa98491e38 md5"
3. 결과: password123 (0.3초 만에 크랙!)
왜?
import bcrypt
def hash_password(password):
# Bcrypt로 비밀번호 해시
salt = bcrypt.gensalt(rounds=10) # 2^10 = 1024번 반복
return bcrypt.hashpw(password.encode(), salt)
# 회원가입
password = 'password123'
hashed = hash_password(password)
print(hashed)
# b'$2b$10$N9qo8uLvGBiOGNWJF/T2FeHB7KzY5ZJZ7Nw8fO.5BkJN...'
# 로그인 검증
def check_password(input_password, stored_hash):
return bcrypt.checkpw(input_password.encode(), stored_hash)
# 테스트
result = check_password('password123', hashed)
print(result) # True
개선점:
Bcrypt로 바꾸고 나서, 평균 로그인 시간이 100ms 늘어났습니다. 사용자는 못 느끼지만, 해커는 1초에 100억 번 → 10번만 시도 가능합니다. 이게 결정적인 차이였습니다.
// Salt 없이 해시
function hashPasswordNoSalt(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
// 회원가입
const user1 = hashPasswordNoSalt('password123'); // a1b2c3d4...
const user2 = hashPasswordNoSalt('password123'); // a1b2c3d4...
const user3 = hashPasswordNoSalt('password123'); // a1b2c3d4...
// 같은 비밀번호 = 같은 해시!
문제:
실제로 우리 회사 DB 덤프를 보니, a1b2c3d4... 해시를 가진 유저가 3,000명이었습니다. 다들 "password123"을 쓰고 있었던 겁니다.
const bcrypt = require('bcrypt');
async function hashPasswordWithSalt(password) {
const saltRounds = 10;
return await bcrypt.hash(password, saltRounds);
}
// 회원가입
const user1Hash = await hashPasswordWithSalt('password123');
const user2Hash = await hashPasswordWithSalt('password123');
const user3Hash = await hashPasswordWithSalt('password123');
console.log(user1Hash);
// $2b$10$a$f7b2N9qo8uLvGBiOGNWJF...
console.log(user2Hash);
// $2b$10$x3k9m1LvP2qR5sT8uV9wX...
console.log(user3Hash);
// $2b$10$c7d5e2MnQ4rS6tU7vW8xY...
// 다 다름!
Salt의 원리:
Salt는 비밀번호에 붙이는 랜덤 문자열입니다.
User 1: hash("password123" + "a$f7b2") → $2b$10$a$f7b2...
User 2: hash("password123" + "x3k9m1") → $2b$10$x3k9m1...
User 3: hash("password123" + "c7d5e2") → $2b$10$c7d5e2...
같은 비밀번호지만, Salt가 다르니까 해시가 완전히 다릅니다.
Bcrypt의 Salt 저장 방식:
$2b$10$N9qo8uLvGBiOGNWJF/T2FeHB7KzY5ZJZ7Nw8fO.5BkJN...
[알고리즘][Cost][ Salt ][ 실제 해시 ]
Salt를 해시 안에 저장합니다! 따로 관리할 필요 없습니다. 검증할 때 Bcrypt가 알아서 추출해서 비교합니다.
이걸 이해하고 나서 "결국 이거였다"라고 정리해본다면: Salt는 Rainbow Table을 무용지물로 만드는 마법의 가루입니다.
| 알고리즘 | 출력 길이 | 속도 | 보안 | 용도 | 권장 |
|---|---|---|---|---|---|
| MD5 | 128비트 | 매우 빠름 (1초에 100억 개) | ❌ 깨짐 | 파일 체크섬 (보안 X) | 절대 NO |
| SHA-1 | 160비트 | 빠름 (1초에 10억 개) | ❌ 깨짐 | Git commit (보안 X) | NO |
| SHA-256 | 256비트 | 빠름 (1초에 1억 개) | ✅ 안전 | 파일 체크섬, 블록체인 | 파일 체크섬 OK |
| Bcrypt | 448비트 | 느림 (1초에 10~100개) | ✅ 안전 | 비밀번호 저장 | ✅ 비밀번호 저장 |
| Argon2 | 가변 | 느림 (조절 가능) | ✅ 최신, 최강 | 비밀번호 저장 | ✅ 비밀번호 저장 (최신) |
속도가 중요하면 SHA-256, 보안이 중요하면 Bcrypt입니다. 이해했습니다.
제가 서버에 배포할 때 실제로 쓰는 스크립트입니다.
# SHA-256으로 파일 해시 계산
$ shasum -a 256 app.js
d3b07384d113edec49eaa6238ad5ff00 app.js
# 해시를 파일로 저장
$ shasum -a 256 app.js > app.js.sha256
$ cat app.js.sha256
d3b07384d113edec49eaa6238ad5ff00 app.js
# 서버에 파일 업로드 후
$ scp app.js user@server:/var/www/
$ scp app.js.sha256 user@server:/var/www/
# 서버에서 검증
$ ssh user@server
$ cd /var/www
$ shasum -a 256 -c app.js.sha256
app.js: OK
# 해시 같음 → 전송 중 손상 없음!
const crypto = require('crypto');
const fs = require('fs');
function calculateFileHash(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
async function verifyFileIntegrity(filePath, expectedHash) {
const actualHash = await calculateFileHash(filePath);
if (actualHash === expectedHash) {
console.log('✅ 파일 무결성 확인됨');
return true;
} else {
console.log('❌ 파일 손상됨!');
console.log('예상 해시:', expectedHash);
console.log('실제 해시:', actualHash);
return false;
}
}
// 사용 예시
(async () => {
// 배포 전 해시 저장
const originalHash = await calculateFileHash('./dist/app.js');
console.log('배포 전 해시:', originalHash);
// 배포 후 검증
await verifyFileIntegrity('./dist/app.js', originalHash);
})();
배포 파이프라인에 이걸 넣어두니까, 전송 중 파일 손상이나 변조를 바로 잡아낼 수 있었습니다.
해시는 자료구조에도 쓰입니다. 해시 테이블(Hash Table)은 O(1) 시간에 데이터를 찾는 마법 같은 구조입니다.
class HashTable {
constructor(size = 53) {
this.keyMap = new Array(size);
}
// 해시 함수 (간단한 버전)
_hash(key) {
let total = 0;
const WEIRD_PRIME = 31; // 소수를 쓰면 충돌 줄어듦
for (let i = 0; i < Math.min(key.length, 100); i++) {
const char = key[i];
const value = char.charCodeAt(0) - 96;
total = (total * WEIRD_PRIME + value) % this.keyMap.length;
}
return total;
}
// 값 저장
set(key, value) {
const index = this._hash(key);
// Separate Chaining으로 충돌 처리
if (!this.keyMap[index]) {
this.keyMap[index] = [];
}
// 같은 키가 있으면 업데이트
for (let i = 0; i < this.keyMap[index].length; i++) {
if (this.keyMap[index][i][0] === key) {
this.keyMap[index][i][1] = value;
return;
}
}
// 없으면 추가
this.keyMap[index].push([key, value]);
}
// 값 가져오기
get(key) {
const index = this._hash(key);
const bucket = this.keyMap[index];
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
return bucket[i][1];
}
}
}
return undefined;
}
// 모든 키 가져오기
keys() {
const keysArr = [];
for (let i = 0; i < this.keyMap.length; i++) {
if (this.keyMap[i]) {
for (let j = 0; j < this.keyMap[i].length; j++) {
keysArr.push(this.keyMap[i][j][0]);
}
}
}
return keysArr;
}
}
// 사용 예시
const ht = new HashTable();
ht.set('hello', 'world');
ht.set('goodbye', 'moon');
ht.set('pink', '#ff69b4');
ht.set('cyan', '#00ffff');
console.log(ht.get('hello')); // 'world'
console.log(ht.get('pink')); // '#ff69b4'
console.log(ht.keys());
// ['hello', 'goodbye', 'pink', 'cyan']
충돌 처리 방법:
제 코드는 Separate Chaining을 썼습니다. 간단하고 이해하기 쉬워서입니다.
캐시 서버를 3대로 늘리면서 겪은 문제입니다.
// 나쁜 예시
function getServerIndex(key, serverCount) {
const hash = simpleHash(key);
return hash % serverCount;
}
// 서버 3대
getServerIndex('user123', 3); // 서버 1
getServerIndex('user456', 3); // 서버 2
getServerIndex('user789', 3); // 서버 0
괜찮아 보였습니다. 그런데 서버를 1대 더 추가(3 → 4)하는 순간...
// 서버 4대로 변경
getServerIndex('user123', 4); // 서버 3 (변경됨!)
getServerIndex('user456', 4); // 서버 0 (변경됨!)
getServerIndex('user789', 4); // 서버 1 (변경됨!)
모든 키의 서버 위치가 바뀌었습니다! 캐시 히트율이 0%로 떨어지고, DB에 엄청난 부하가 걸렸습니다. 서비스가 다운됐습니다.
class ConsistentHash {
constructor(replicas = 150) {
this.replicas = replicas; // 가상 노드 개수
this.ring = new Map(); // 해시 링
this.sortedKeys = [];
this.nodes = [];
}
// 서버 추가
addNode(node) {
this.nodes.push(node);
// 가상 노드 여러 개 생성
for (let i = 0; i < this.replicas; i++) {
const hash = this._hash(`${node}:${i}`);
this.ring.set(hash, node);
this.sortedKeys.push(hash);
}
this.sortedKeys.sort((a, b) => a - b);
}
// 서버 제거
removeNode(node) {
this.nodes = this.nodes.filter(n => n !== node);
for (let i = 0; i < this.replicas; i++) {
const hash = this._hash(`${node}:${i}`);
this.ring.delete(hash);
this.sortedKeys = this.sortedKeys.filter(k => k !== hash);
}
}
// 키가 저장될 서버 찾기
getNode(key) {
if (this.ring.size === 0) return null;
const hash = this._hash(key);
// 시계방향으로 가장 가까운 서버 찾기
for (let i = 0; i < this.sortedKeys.length; i++) {
if (hash <= this.sortedKeys[i]) {
return this.ring.get(this.sortedKeys[i]);
}
}
// 링의 끝이면 처음으로
return this.ring.get(this.sortedKeys[0]);
}
_hash(key) {
// 간단한 해시 함수 (실제론 CRC32 등 사용)
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash) + key.charCodeAt(i);
hash = hash & hash; // 32비트 정수로 변환
}
return Math.abs(hash);
}
}
// 사용 예시
const ch = new ConsistentHash();
// 서버 3대 추가
ch.addNode('server1');
ch.addNode('server2');
ch.addNode('server3');
console.log('user123 → ', ch.getNode('user123')); // server2
console.log('user456 → ', ch.getNode('user456')); // server1
console.log('user789 → ', ch.getNode('user789')); // server3
// 서버 1대 추가
ch.addNode('server4');
console.log('user123 → ', ch.getNode('user123')); // server2 (변경 없음!)
console.log('user456 → ', ch.getNode('user456')); // server1 (변경 없음!)
console.log('user789 → ', ch.getNode('user789')); // server4 (일부만 변경)
Consistent Hashing을 쓰니까, 서버 추가/제거 시 전체의 1/N만 재배치됩니다. 나머지는 그대로입니다.
AWS ElastiCache, Redis Cluster, Cassandra 등이 다 이걸 씁니다. 와닿았습니다.
미리 계산된 해시-비밀번호 쌍입니다:
// rainbowtable.txt (수백 GB 크기)
482c811da5d5b4bc6d497ffa98491e38 → password123
5f4dcc3b5aa765d61d8327deb882cf99 → password
e10adc3949ba59abbe56e057f20f883e → 123456
...
해커는 해시를 얻으면 이 테이블에서 찾기만 하면 됩니다. 조회는 1초도 안 걸립니다.
실제로 온라인에서 "MD5 decrypt" 검색하면 무료 서비스들이 수두룩합니다.
// Bcrypt는 Salt를 자동으로 생성하고 해시에 포함
const bcrypt = require('bcrypt');
async function testRainbowTableDefense() {
const password = 'password123'; // 흔한 비밀번호
// 같은 비밀번호도 매번 다른 해시
const hash1 = await bcrypt.hash(password, 10);
const hash2 = await bcrypt.hash(password, 10);
const hash3 = await bcrypt.hash(password, 10);
console.log(hash1);
// $2b$10$aF7b2N9qo8uLvGBiOGNWJF/T2FeHB7KzY5ZJZ7Nw8fO.5BkJN...
console.log(hash2);
// $2b$10$xK9m1LvP2qR5sT8uV9wX0eYzAbCdEfGhIjKlMnOpQrStUvWxYz...
console.log(hash3);
// $2b$10$c7D5e2MnQ4rS6tU7vW8xYzAbCdEfGhIjKlMnOpQrStUvWxYz...
// 다 다름! Rainbow Table 무용지물
}
왜 안전한가:
이제 이해했습니다. Salt는 단순히 "소금"이 아니라, Rainbow Table을 무력화하는 핵심 방어 기제였던 겁니다.
예전 프로젝트 시스템을 MD5에서 Bcrypt로 바꿀 때 겪은 일입니다.
// 기존 DB
| id | email | password_hash (MD5) |
|----|-------|---------------------|
| 1 | user@example.com | 5f4dcc3b5aa... |
MD5 해시에서 원본 비밀번호를 알 수 없으니까, Bcrypt로 재해시할 수가 없습니다.
async function loginAndMigrate(email, password) {
const user = await db.query(
'SELECT id, password_hash, hash_type FROM users WHERE email = $1',
[email]
);
// 1. 기존 해시 방식으로 검증
let isValid = false;
if (user.hash_type === 'md5') {
// MD5 검증
const md5Hash = crypto.createHash('md5').update(password).digest('hex');
isValid = (md5Hash === user.password_hash);
if (isValid) {
// 2. 로그인 성공하면 Bcrypt로 재해시!
const bcryptHash = await bcrypt.hash(password, 10);
await db.query(
'UPDATE users SET password_hash = $1, hash_type = $2 WHERE id = $3',
[bcryptHash, 'bcrypt', user.id]
);
console.log(`User ${user.id} migrated to Bcrypt`);
}
} else if (user.hash_type === 'bcrypt') {
// Bcrypt 검증
isValid = await bcrypt.compare(password, user.password_hash);
}
return isValid;
}
작동 원리:
6개월 후 확인해보니, 활성 유저의 95%가 Bcrypt로 전환돼 있었습니다. 나머지 5%는 휴면 계정이었고, 이들은 다음 로그인 시 자동 전환됐습니다.
정리해본다면: 해시는 단방향이지만, 로그인 시점에 원본 비밀번호에 접근할 수 있으니 그때 마이그레이션한다. 이 아이디어가 와닿았습니다.
| 구분 | 해시 | 암호화 |
|---|---|---|
| 방향 | 단방향 (되돌릴 수 없음) | 양방향 (복호화 가능) |
| 키 | 필요 없음 | 필요 (대칭/비대칭 키) |
| 목적 | 무결성, 비밀번호 저장 | 데이터 숨기기 |
| 예시 | SHA-256, Bcrypt | AES, RSA |
| 사용 | 파일 체크섬, 비밀번호, 블록체인 | 메시지 암호화, HTTPS |
| 속도 | 빠름 (SHA) 또는 느림 (Bcrypt) | 비교적 빠름 |
| 충돌 | 가능 (확률 낮음) | 해당 없음 |
처음엔 해시를 "단순한 암호화"라고 생각했습니다. 완전히 다른 개념이었습니다.
암호화: 비밀 편지. 열쇠(키)로 열 수 있음.
해시: 고기 갈기. 되돌릴 수 없음.
처음엔 "왜 비밀번호를 복호화 못 하게 만들어?"라고 의문이었습니다.
지금은 이해했습니다:
복호화 가능 = 해커도 복호화 가능그래서 아예 되돌릴 수 없게 만든 겁니다. 정보를 파괴해서, 원본을 복원할 수 없게 만든 겁니다.
제가 프로젝트에서 배운 교훈:
이 한 문장이 해시 함수의 본질입니다. 시니어가 믹서기를 돌리면서 보여줬던 그 순간, 지금도 생생합니다.
DB 해킹 사고 이후, 5년이 지났습니다. 이제 저도 많이 성장했고, 블로그에 보안 지식을 정리합니다. 그때마다 믹서기 비유를 씁니다.
후배: "해시가 뭔가요?"
저: "갈아버린 고기는 다시 소가 될 수 없잖아?"
후배: "????"
그 표정을 보면, 5년 전 제 모습이 떠오릅니다. 결국, 이렇게 지식이 전달되는구나 싶습니다.