
솔팅(Salting) & 페퍼(Pepper): 비밀번호를 요리하는 법
단순히 해시(Hash)만 하면 1초 만에 뚫립니다. 레인보우 테이블 공격을 막기 위해 소금(Salt)과 후추(Pepper)를 치는 원리.

단순히 해시(Hash)만 하면 1초 만에 뚫립니다. 레인보우 테이블 공격을 막기 위해 소금(Salt)과 후추(Pepper)를 치는 원리.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

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

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

회사 DB가 해킹당했다는 뉴스를 봤습니다.
기사: "다행히 비밀번호는 암호화되어 있어 안전합니다."
저: "진짜 안전한 건가?"
시니어: "해시만 했으면 1초 만에 뚫려. Salt 쳤으면 좀 버티고."
그 순간 등골이 오싹했습니다. 제가 만든 첫 프로젝트 DB에는 비밀번호가 어떻게 저장되어 있을까요? 정확히는 기억이 안 나는데, SHA-256으로만 Hash 했던 것 같습니다. Salt는 안 쳤던 것 같고요.
첫 프로젝트에서 회원가입 기능을 만들 때:
저: "비밀번호를 그냥 DB에 저장하면 되죠?"
시니어: "절대 안 돼! 평문 저장은 1급 보안 사고야."
저: "그럼 어떻게요?"
시니어: "Hash + Salt + Pepper. 3단계 요리법이야."
그때부터 Salting을 공부했습니다. 사실 창업 초기라 매출이 고민이었는데, 보안 공부까지 해야 한다는 게 막막했습니다. 하지만 결국 이거였다는 걸 깨달았습니다. 서비스가 아무리 좋아도 유저 비밀번호를 지키지 못하면 한순간에 무너질 수 있다는 것을요.
무엇보다 "왜 이렇게 복잡하게 해야 해?"
저는 단순히 "비밀번호는 암호화하면 된다"라고 이해했다가, 실제로는 암호화(Encryption)가 아니라 해시(Hash)라는 걸 알았고, 심지어 해시만으로는 부족하다는 걸 알게 되면서 머리가 복잡해졌습니다.
시니어의 비유:
"아, 3단 보안이구나!"생고기 보관 (평문): "비밀번호를 그대로 DB에 저장. 누가 봐도 '1234'라고 보임. DB 관리자도 다 봄. 최악."
갈아서 보관 (Hash): "비밀번호를 Hash 함수로 갈아서 저장. '1234' → 'a3f9c12...' 역으로 복원 불가능.
문제: 해커가 '1234'의 Hash를 미리 계산해 둠. DB에서 'a3f9c12...' 발견 → 사전 찾아봄 → '아 1234네' → 뚫림."
소금 치기 (Salt): "'1234' + 랜덤문자열 'zXy9' → Hash 이제 해커 사전에 없는 값. 한 명 뚫으려면 처음부터 다시 계산해야 함."
후추 뿌리기 (Pepper): "Salt + 서버 비밀키(Pepper)까지 추가. DB 털려도 Pepper는 별도 보관 → 해독 불가능."
이 비유가 머릿속에 확 들어왔습니다. 요리할 때 생고기를 그대로 내놓지 않고, 갈아서, 소금 치고, 후추 뿌리듯이, 비밀번호도 여러 단계를 거쳐야 한다는 걸 정리해본다면 이렇게 받아들였습니다: "DB가 해킹당할 수 있다는 가정 하에 설계해야 한다"는 것입니다.
-- ❌ 절대 하지 마세요!
CREATE TABLE users (
id INT,
username VARCHAR(50),
password VARCHAR(50) -- 평문 저장!
);
INSERT INTO users VALUES (1, 'ratia', '1234');
문제:
2013년 Adobe 해킹:
- 1억 5천만 계정 유출
- 비밀번호 평문 저장 (일부)
- 집단 소송 → $1.1M 배상
처음 이 사건을 알았을 때는 "Adobe 같은 큰 회사가 평문 저장을 했다고?"라며 놀랐는데, 나중에 보니 초기 시스템 레거시 때문이었다고 하더군요. 그래서 더 와닿았습니다. 처음부터 제대로 하지 않으면 나중에 고치기 힘들다는 것을요.
const crypto = require('crypto');
const password = '1234';
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
console.log(hash);
// a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
특징:
해커가 미리 계산:
1234 → a665a45920...
password → 5e884898da...
qwerty → 65e84be33...
12345678 → ef797c81...
...
(수백만 개)
DB 털림 → Hash 발견 → 사전 검색 → 1초 만에 뚫림
처음 레인보우 테이블을 알았을 때는 "와, 이렇게 단순한 공격이 통한다고?"라며 허탈했습니다. Hash가 안전하다고 생각했는데, 결국 사람들이 자주 쓰는 비밀번호는 정해져 있고, 그걸 미리 계산해 두면 끝이라는 게 충격이었습니다.
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 값이 나온다는 게 핵심이더군요. 해커 입장에서는 유저 한 명 한 명 전부 새로 계산해야 하니 사실상 불가능해지는 겁니다.
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('로그인 성공');
}
}
$2b$10$N9qo8uLOickgx2ZMRZoMye...
│ │ │ │
│ │ │ └─ Hash (31자)
│ │ └─ Salt (22자)
│ └─ Cost Factor (10 = 2^10 = 1024 rounds)
└─ Algorithm version (2b)
특징:
Bcrypt를 처음 써봤을 때 가장 놀랐던 건 Salt를 별도로 관리할 필요가 없다는 점이었습니다. Hash 값 안에 이미 Salt가 포함되어 있더군요. 그래서 DB에 Hash만 저장하면 되고, 검증할 때도 bcrypt.compare()만 쓰면 알아서 Salt를 추출해서 비교해줍니다.
DB 테이블:
username | hash | salt
ratia | a3f9c12... | zXy9...
문제: DB 털리면 Salt도 같이 털림!
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)Pepper는 솔직히 처음엔 "이것까지 필요한가?" 싶었습니다. 하지만 실제로 DB 유출 사고를 여러 번 보고 나니까, Pepper의 중요성을 받아들였습니다. DB가 털려도 Pepper만 안전하면 해커는 어떻게 할 수가 없으니까요.
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초에 수백만 개 시도 가능
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초 느려지는 거고, 해커는 수백만 번 시도가 불가능해지는 겁니다.
Cost 10: ~100ms (권장)
Cost 12: ~400ms
Cost 14: ~1600ms
선택 기준:
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를 올릴 수도 있겠죠.
const argon2 = require('argon2');
// 회원가입
const hash = await argon2.hash(password);
// 로그인
const match = await argon2.verify(hash, password);
장점:
Argon2는 Bcrypt의 약점을 보완한 알고리즘입니다. Bcrypt는 CPU로만 계산하는데, Argon2는 메모리를 많이 쓰게 만들어서 GPU나 ASIC으로 병렬 공격하는 걸 막습니다. 다만 제 서비스에서는 아직 Bcrypt를 쓰고 있습니다. 이미 구현되어 있고, 충분히 안전하다고 봤기 때문입니다.
// ❌ 잘못된 방법
const GLOBAL_SALT = "myapp_salt";
users.forEach(user => {
const hash = hash(user.password + GLOBAL_SALT);
});
문제: 레인보우 테이블 다시 유효
저도 이 실수를 했습니다. "Salt를 유저별로 만들면 관리가 복잡하지 않나?" 싶어서 전역 Salt를 썼다가, 시니어한테 "그럼 Salt 의미가 없다"는 말을 들었습니다. 전역 Salt는 결국 해커가 한 번만 계산하면 끝이니까요.
// ❌ Salt를 숨길 필요 없음
const salt = crypto.randomBytes(16);
// Salt는 공개되어도 OK!
이유: Salt는 예측 불가능성만 중요
처음엔 Salt도 비밀번호처럼 숨겨야 한다고 생각했는데, 아니더군요. Salt는 공개되어도 상관없습니다. 중요한 건 "유저별로 다르다"는 것뿐입니다. 해커가 Salt를 알아도, 여전히 모든 유저에 대해 따로 계산해야 하니까 공격이 비효율적입니다.
// ❌ 너무 빠름
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
해결: Bcrypt/Argon2 사용
이건 제가 처음 프로젝트에서 한 실수입니다. "SHA-256이 안전하다"라고만 알고 있었는데, 비밀번호 해싱에는 부적합하더군요. 너무 빠르니까요.
| 항목 | 설명 |
|---|---|
| 평문 저장 | 절대 금지! 법적 리스크 |
| Hash | 일방향, 레인보우 테이블 취약 |
| Salt | 유저별 랜덤, DB 저장 OK |
| Pepper | 전역 비밀키, 별도 보관 |
| 알고리즘 | Bcrypt/Argon2 (느린 Hash) |
| Cost Factor | 10~12 권장 |
처음엔 "Hash만 하면 되지 왜 이렇게 복잡해?"라고 생각했습니다.
지금은 이해합니다:
"DB는 언제든 털릴 수 있다"제가 배운 교훈:
해커한테는 쓸모없는 데이터를요.
비밀번호 보안은 결국 "해커가 포기하게 만드는 것"이라고 정리해본다면, Salting은 그 첫 번째 단계입니다. 완벽한 보안은 없지만, 최소한 해커가 "이 서비스는 뚫기 힘들겠네"라고 생각하게 만들 수는 있습니다.