프롤로그 - "DB 털렸는데 패스워드는 안전합니다"
회사 DB가 해킹당했다는 뉴스를 봤습니다.
기사: "다행히 비밀번호는 암호화되어 있어 안전합니다."
저: "진짜 안전한 건가?"
시니어: "해시만 했으면 1초 만에 뚫려. Salt 쳤으면 좀 버티고."
그 순간 등골이 오싹했습니다. 제가 만든 첫 프로젝트 DB에는 비밀번호가 어떻게 저장되어 있을까요? 정확히는 기억이 안 나는데, SHA-256으로만 Hash 했던 것 같습니다. Salt는 안 쳤던 것 같고요.
왜 공부하게 되었나
첫 프로젝트에서 회원가입 기능을 만들 때:
저: "비밀번호를 그냥 DB에 저장하면 되죠?"
시니어: "절대 안 돼! 평문 저장은 1급 보안 사고야."
저: "그럼 어떻게요?"
시니어: "Hash + Salt + Pepper. 3단계 요리법이야."
그때부터 Salting을 공부했습니다. 사실 창업 초기라 매출이 고민이었는데, 보안 공부까지 해야 한다는 게 막막했습니다. 하지만 결국 이거였다는 걸 깨달았습니다. 서비스가 아무리 좋아도 유저 비밀번호를 지키지 못하면 한순간에 무너질 수 있다는 것을요.
처음엔 뭐가 이해가 안 갔나
- Hash만 하면 안 되나?
- Salt가 왜 필요해?
- Pepper는 또 뭐야?
- 레인보우 테이블이 뭐야?
무엇보다 "왜 이렇게 복잡하게 해야 해?"
저는 단순히 "비밀번호는 암호화하면 된다"라고 이해했다가, 실제로는 암호화(Encryption)가 아니라 해시(Hash)라는 걸 알았고, 심지어 해시만으로는 부족하다는 걸 알게 되면서 머리가 복잡해졌습니다.
깨달음의 순간 - "요리 레시피"
시니어의 비유:
생고기 보관 (평문): "비밀번호를 그대로 DB에 저장. 누가 봐도 '1234'라고 보임. DB 관리자도 다 봄. 최악."
갈아서 보관 (Hash): "비밀번호를 Hash 함수로 갈아서 저장. '1234' → 'a3f9c12...' 역으로 복원 불가능.
문제: 해커가 '1234'의 Hash를 미리 계산해 둠. DB에서 'a3f9c12...' 발견 → 사전 찾아봄 → '아 1234네' → 뚫림."
소금 치기 (Salt): "'1234' + 랜덤문자열 'zXy9' → Hash 이제 해커 사전에 없는 값. 한 명 뚫으려면 처음부터 다시 계산해야 함."
후추 뿌리기 (Pepper): "Salt + 서버 비밀키(Pepper)까지 추가. DB 털려도 Pepper는 별도 보관 → 해독 불가능."
"아, 3단 보안이구나!"
이 비유가 머릿속에 확 들어왔습니다. 요리할 때 생고기를 그대로 내놓지 않고, 갈아서, 소금 치고, 후추 뿌리듯이, 비밀번호도 여러 단계를 거쳐야 한다는 걸 정리해본다면 이렇게 받아들였습니다: "DB가 해킹당할 수 있다는 가정 하에 설계해야 한다"는 것입니다.
1. 평문 저장 - 절대 금지
최악의 경우
-- ❌ 절대 하지 마세요!
CREATE TABLE users (
id INT,
username VARCHAR(50),
password VARCHAR(50) -- 평문 저장!
);
INSERT INTO users VALUES (1, 'ratia', '1234');
문제:
- DB 관리자가 비밀번호 다 봄
- SQL Injection 공격 시 즉시 노출
- 법적 책임 (개인정보보호법 위반)
실제 사건
2013년 Adobe 해킹:
- 1억 5천만 계정 유출
- 비밀번호 평문 저장 (일부)
- 집단 소송 → $1.1M 배상
처음 이 사건을 알았을 때는 "Adobe 같은 큰 회사가 평문 저장을 했다고?"라며 놀랐는데, 나중에 보니 초기 시스템 레거시 때문이었다고 하더군요. 그래서 더 와닿았습니다. 처음부터 제대로 하지 않으면 나중에 고치기 힘들다는 것을요.
2. Hash: 1단계 보안
Hash 함수
const crypto = require('crypto');
const password = '1234';
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
console.log(hash);
// a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
특징:
- 일방향: Hash → 원본 복원 불가능
- 고정 길이: 어떤 입력이든 64자
- 결정적: 같은 입력 → 항상 같은 출력
문제 - 레인보우 테이블
해커가 미리 계산:
1234 → a665a45920...
password → 5e884898da...
qwerty → 65e84be33...
12345678 → ef797c81...
...
(수백만 개)
DB 털림 → Hash 발견 → 사전 검색 → 1초 만에 뚫림
처음 레인보우 테이블을 알았을 때는 "와, 이렇게 단순한 공격이 통한다고?"라며 허탈했습니다. Hash가 안전하다고 생각했는데, 결국 사람들이 자주 쓰는 비밀번호는 정해져 있고, 그걸 미리 계산해 두면 끝이라는 게 충격이었습니다.
3. Salt: 2단계 보안
원리
const crypto = require('crypto');
// 1. 유저별 랜덤 Salt 생성
const salt = crypto.randomBytes(16).toString('hex');
// salt: "3a7f2c9e1d8b4a5f..."
// 2. 비밀번호 + Salt 결합 후 Hash
const password = '1234';
const hash = crypto.createHash('sha256')
.update(password + salt)
.digest('hex');
// 3. DB 저장
// users: [username, hash, salt]
효과
유저 A: '1234' + Salt_A → Hash_A
유저 B: '1234' + Salt_B → Hash_B
Hash_A ≠ Hash_B // 같은 비밀번호인데 다른 Hash!
결과: 레인보우 테이블 무용지물
이 부분을 이해했다는 순간이 정말 짜릿했습니다. 같은 비밀번호라도 유저별로 다른 Hash 값이 나온다는 게 핵심이더군요. 해커 입장에서는 유저 한 명 한 명 전부 새로 계산해야 하니 사실상 불가능해지는 겁니다.
4. Bcrypt: 실제 Salt
사용법
const bcrypt = require('bcrypt');
// 회원가입
async function signup(username, password) {
const saltRounds = 10; // 비용 계수
const hash = await bcrypt.hash(password, saltRounds);
// DB 저장: hash에 salt 포함됨!
await db.insert({ username, hash });
}
// 로그인
async function login(username, password) {
const user = await db.findByUsername(username);
const match = await bcrypt.compare(password, user.hash);
if (match) {
console.log('로그인 성공');
}
}
Bcrypt의 장점
$2b$10$N9qo8uLOickgx2ZMRZoMye...
│ │ │ │
│ │ │ └─ Hash (31자)
│ │ └─ Salt (22자)
│ └─ Cost Factor (10 = 2^10 = 1024 rounds)
└─ Algorithm version (2b)
특징:
- Salt 자동 생성 및 포함
- 의도적으로 느림 (Brute Force 방지)
- Cost Factor 조정 가능
Bcrypt를 처음 써봤을 때 가장 놀랐던 건 Salt를 별도로 관리할 필요가 없다는 점이었습니다. Hash 값 안에 이미 Salt가 포함되어 있더군요. 그래서 DB에 Hash만 저장하면 되고, 검증할 때도 bcrypt.compare()만 쓰면 알아서 Salt를 추출해서 비교해줍니다.
5. Pepper: 3단계 보안
Salt의 약점
DB 테이블:
username | hash | salt
ratia | a3f9c12... | zXy9...
문제: DB 털리면 Salt도 같이 털림!
Pepper 추가
const PEPPER = process.env.SECRET_PEPPER; // 환경 변수
async function signup(username, password) {
const saltRounds = 10;
// Pepper를 먼저 섞음
const pepperedPassword = password + PEPPER;
const hash = await bcrypt.hash(pepperedPassword, saltRounds);
await db.insert({ username, hash });
// Pepper는 DB에 저장 안 함!
}
async function login(username, password) {
const user = await db.findByUsername(username);
const pepperedPassword = password + PEPPER;
const match = await bcrypt.compare(pepperedPassword, user.hash);
return match;
}
보관 위치:
- 환경 변수 (
.env) - AWS Secrets Manager
- HashiCorp Vault
- HSM (Hardware Security Module)
Pepper는 솔직히 처음엔 "이것까지 필요한가?" 싶었습니다. 하지만 실제로 DB 유출 사고를 여러 번 보고 나니까, Pepper의 중요성을 받아들였습니다. DB가 털려도 Pepper만 안전하면 해커는 어떻게 할 수가 없으니까요.
6. 왜 느린 Hash를 써야 하나?
SHA-256 (빠름 - 위험)
const crypto = require('crypto');
console.time('SHA-256');
for (let i = 0; i < 100000; i++) {
crypto.createHash('sha256').update('1234').digest('hex');
}
console.timeEnd('SHA-256');
// SHA-256: 50ms
문제: 해커가 1초에 수백만 개 시도 가능
Bcrypt (느림 - 안전)
const bcrypt = require('bcrypt');
console.time('Bcrypt');
for (let i = 0; i < 100; i++) { // 100개만
await bcrypt.hash('1234', 10);
}
console.timeEnd('Bcrypt');
// Bcrypt: 1000ms
효과: 해커가 1초에 10개만 시도 가능
이 대조가 정말 충격적이었습니다. SHA-256은 10만 번을 50ms에 처리하는데, Bcrypt는 100번에 1000ms가 걸립니다. 1000배 차이입니다. 처음엔 "왜 일부러 느리게 만들어?" 싶었는데, 결국 이거였다는 걸 이해했습니다. 정상 유저는 로그인 1번에 0.1초 느려지는 거고, 해커는 수백만 번 시도가 불가능해지는 겁니다.
7. 실제 - Cost Factor 선택
Bcrypt Cost
Cost 10: ~100ms (권장)
Cost 12: ~400ms
Cost 14: ~1600ms
선택 기준:
- 로그인 체감 속도 vs 보안
- 일반적으로 Cost 10~12 사용
코드
const bcrypt = require('bcrypt');
// 개발 환경
const devCost = 10;
// 프로덕션 (더 높은 보안)
const prodCost = 12;
const cost = process.env.NODE_ENV === 'production'
? prodCost
: devCost;
const hash = await bcrypt.hash(password, cost);
제 서비스에서는 Cost 10을 씁니다. 로그인 속도가 체감상 차이가 없고, 보안도 충분하다고 판단했습니다. 나중에 하드웨어 성능이 더 좋아지면 Cost를 올릴 수도 있겠죠.
8. Argon2: 최신 표준
Password Hashing Competition 우승
const argon2 = require('argon2');
// 회원가입
const hash = await argon2.hash(password);
// 로그인
const match = await argon2.verify(hash, password);
장점:
- 메모리 하드 (GPU 공격 방어)
- Bcrypt보다 강력
- 2015년 PHC 우승
Argon2는 Bcrypt의 약점을 보완한 알고리즘입니다. Bcrypt는 CPU로만 계산하는데, Argon2는 메모리를 많이 쓰게 만들어서 GPU나 ASIC으로 병렬 공격하는 걸 막습니다. 다만 제 서비스에서는 아직 Bcrypt를 쓰고 있습니다. 이미 구현되어 있고, 충분히 안전하다고 봤기 때문입니다.
9. 실수 모음
실수 1 - 같은 Salt 재사용
// ❌ 잘못된 방법
const GLOBAL_SALT = "myapp_salt";
users.forEach(user => {
const hash = hash(user.password + GLOBAL_SALT);
});
문제: 레인보우 테이블 다시 유효
저도 이 실수를 했습니다. "Salt를 유저별로 만들면 관리가 복잡하지 않나?" 싶어서 전역 Salt를 썼다가, 시니어한테 "그럼 Salt 의미가 없다"는 말을 들었습니다. 전역 Salt는 결국 해커가 한 번만 계산하면 끝이니까요.
실수 2 - Salt를 비밀로 취급
// ❌ Salt를 숨길 필요 없음
const salt = crypto.randomBytes(16);
// Salt는 공개되어도 OK!
이유: Salt는 예측 불가능성만 중요
처음엔 Salt도 비밀번호처럼 숨겨야 한다고 생각했는데, 아니더군요. Salt는 공개되어도 상관없습니다. 중요한 건 "유저별로 다르다"는 것뿐입니다. 해커가 Salt를 알아도, 여전히 모든 유저에 대해 따로 계산해야 하니까 공격이 비효율적입니다.
실수 3 - SHA-256만 사용
// ❌ 너무 빠름
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
해결: Bcrypt/Argon2 사용
이건 제가 처음 프로젝트에서 한 실수입니다. "SHA-256이 안전하다"라고만 알고 있었는데, 비밀번호 해싱에는 부적합하더군요. 너무 빠르니까요.
10. 정리 - Salting 체크리스트
| 항목 | 설명 |
|---|---|
| 평문 저장 | 절대 금지! 법적 리스크 |
| Hash | 일방향, 레인보우 테이블 취약 |
| Salt | 유저별 랜덤, DB 저장 OK |
| Pepper | 전역 비밀키, 별도 보관 |
| 알고리즘 | Bcrypt/Argon2 (느린 Hash) |
| Cost Factor | 10~12 권장 |
마치며 - "요리사의 마음"
처음엔 "Hash만 하면 되지 왜 이렇게 복잡해?"라고 생각했습니다.
지금은 이해합니다:
"DB는 언제든 털릴 수 있다"
제가 배운 교훈:
- 평문 저장: 1급 보안 사고
- Hash만: 1초 만에 뚫림 (레인보우 테이블)
- Hash + Salt: 유저별 다른 Hash
- Hash + Salt + Pepper: DB 털려도 안전
"소금과 후추로 맛없는 데이터를 만들자"
해커한테는 쓸모없는 데이터를요.
비밀번호 보안은 결국 "해커가 포기하게 만드는 것"이라고 정리해본다면, Salting은 그 첫 번째 단계입니다. 완벽한 보안은 없지만, 최소한 해커가 "이 서비스는 뚫기 힘들겠네"라고 생각하게 만들 수는 있습니다.