저는 얼마 전까지만 해도 비밀번호 암호화를 "그냥 bcrypt 쓰면 되는 것"이라고 생각했습니다. 강의에서 그렇게 배웠고, 다들 그렇게 쓰는 것 같았으니까요. 그런데 최근 프로젝트에서 보안 관련 기능을 맡으면서 암호화를 처음부터 제대로 공부하게 됐고, 제가 알고 있던 게 전부가 아니었다는 걸 알았습니다.
이 글은 그 과정에서 배운 것들을 정리한 글입니다. "왜?"라는 질문을 계속 던지면서 공부했기 때문에, 개념 하나하나를 최대한 쉽게 풀어보려고 했습니다. 저처럼 암호화가 막막하게 느껴지는 분들께 도움이 됐으면 합니다.
"암호화하면 되는 거 아니야?" — 시작은 이 질문이었습니다
처음에 저는 비밀번호를 암호화한다는 개념 자체를 단순하게 생각했습니다. 그냥 원문을 알아볼 수 없게 만들면 되는 거 아닌가? 그런데 공부하다 보니 암호화에는 목적이 전혀 다른 두 종류가 있다는 걸 알게 됐습니다.
단방향 암호화 는 원문을 한 방향으로만 변환하고, 결과값으로 원문을 되돌릴 수 없는 방식입니다. 이걸 해시(Hash) 라고 부릅니다. 비밀번호에 쓰는 방식이 바로 이겁니다. 서버 입장에서 사용자의 실제 비밀번호를 알 필요가 없습니다. 로그인할 때 "입력값을 해시한 결과"와 "DB에 저장된 해시값"이 같은지만 확인하면 되니까요.
양방향 암호화 는 반대로 원문으로 복원이 가능한 방식입니다. 이메일이나 전화번호처럼 나중에 꺼내서 사용해야 하는 정보에 씁니다. 암호화된 상태로 보관하다가 필요할 때 복호화해서 꺼내는 방식입니다.
이 차이를 모르면 잘못된 설계를 하게 됩니다. 비밀번호를 양방향으로 암호화하면 서버가 언제든 원문 비밀번호를 복원할 수 있다는 뜻인데, 이건 보안상 있어서는 안 되는 구조입니다. DB가 털리는 순간 모든 사용자의 실제 비밀번호가 노출됩니다.
bcrypt, 너는 왜 여기서 안 돼?
비밀번호 해시 알고리즘을 공부하면서 bcrypt 외에 Argon2 라는 알고리즘도 알게 됐습니다. Argon2는 2015년에 열린 국제 비밀번호 해싱 대회(PHC)에서 1위를 차지한 알고리즘인데, 연산할 때 메모리를 많이 잡아먹는 구조 덕분에 GPU로 수백만 개의 비밀번호를 병렬로 뚫으려는 공격에 특히 강합니다. 요즘 글로벌 기준에서는 bcrypt의 자리를 점점 대체하고 있는 추세입니다.
이렇게 보면 bcrypt도, Argon2도 충분히 안전한 선택입니다. 실제로 해외 서비스에서는 두 알고리즘 모두 광범위하게 쓰입니다.
그런데 제가 작업하던 프로젝트는 국내 공공 기관 납품용이었고, GS 인증과 ISMS 인증이 필요했습니다. 이 순간부터 "안전한가"의 문제가 아니라 "규격에 맞는가"의 문제가 됩니다.
국내에서 공공 기관에 납품하는 소프트웨어는 KCMVP(한국 암호 모듈 검증 제도) 라는 기준을 충족해야 합니다. KCMVP는 간단히 말하면 "이 제품이 암호화할 때 사용하는 엔진이 국가가 정한 표준대로 만들어졌는지"를 국가가 직접 검사하는 제도입니다. 그리고 이 제도에는 KISA(한국인터넷진흥원)가 승인한 알고리즘 목록이 있는데, bcrypt도 Argon2도 그 목록에 없습니다.
왜 없을까요? 이유가 꽤 흥미롭습니다.
bcrypt가 목록에 없는 이유 ① — 블록 크기와 생일 공격
bcrypt는 내부적으로 Blowfish 라는 알고리즘을 기반으로 동작합니다. 암호화 알고리즘은 데이터를 잘게 쪼개 블록 단위로 처리하는데, Blowfish는 이 블록 하나의 크기가 64비트입니다.
반면 현재 국제 표준인 AES와 국내 표준인 ARIA는 블록 크기가 128비트입니다. 두 배 차이인데, 이게 왜 중요한지 이해하려면 생일 공격(Birthday Attack) 이라는 개념을 알아야 합니다.
생일 공격은 수학의 "생일의 역설" 을 암호 해킹에 응용한 기법입니다. 생일의 역설을 먼저 설명하면 이렇습니다. 1년은 365일이니까 생일이 겹치는 두 사람을 찾으려면 366명이 있어야 할 것 같습니다. 그런데 실제로 계산해보면 겨우 23명만 있어도 그 중 두 명의 생일이 같을 확률이 50%가 넘습니다. 왜냐하면 "나와 생일이 같은 사람"을 찾는 게 아니라, "아무나 서로 생일이 같은 쌍"을 찾는 것이기 때문에 비교의 경우의 수가 사람 수의 제곱에 가깝게 불어나기 때문입니다.
암호학에서의 생일 공격은 이 원리를 이용해 서로 같은 결과(충돌)를 만들어내는 두 입력값을 찾아내는 공격입니다. 블록 크기가 64비트면 가능한 경우의 수가 2⁶⁴개인데, 생일의 역설에 의해 약 2³²번, 즉 43억 번의 연산만으로 충돌이 발생할 확률이 절반을 넘습니다. 현대 서버 환경에서 대용량 데이터를 처리하다 보면 43억 번의 연산은 충분히 도달 가능한 수준이라 현실적인 위협이 됩니다.
128비트(AES)라면 충돌 확률이 50%가 되는 지점이 2⁶⁴번 연산인데, 이건 현재 기술로는 현실적으로 불가능한 수준입니다. 블록 크기가 두 배 늘어나는 것이 안전성 면에서는 훨씬 큰 차이를 만드는 이유입니다.
bcrypt가 목록에 없는 이유 ② — 암호 표준 경쟁의 역사
bcrypt가 KCMVP 목록에 없는 데는 기술적 이유 외에도 역사적인 배경이 있습니다.
2000년대 초반, 미국 NIST가 당시 표준이던 DES를 대체할 새로운 암호 표준을 공개 경쟁 방식으로 선정했습니다. 전 세계의 암호학자들이 알고리즘을 제출했고, Blowfish의 후속 버전인 Twofish도 최종 후보 5개 안에 들었습니다. 그런데 치열한 심사 끝에 Rijndael이 최종 우승하며 AES(Advanced Encryption Standard) 로 채택됩니다.
이 결정 이후 세계 각국은 AES를 기준으로 자국의 암호화 표준을 재정립하기 시작했습니다. 한국도 이 흐름에 맞춰 AES와 함께, 국내에서 자체 개발한 ARIA 와 SEED 를 국가 표준 알고리즘으로 공식화했습니다. 표준 경쟁에서 밀려난 Blowfish 계열의 알고리즘인 bcrypt가 KISA 권고 목록에서 빠지게 된 건 이 역사적인 흐름의 결과입니다.
bcrypt가 목록에 없는 이유 ③ — 심사에서의 현실적인 문제
마지막으로 행정적인 이유도 있습니다.
KISA 가이드라인에는 "SHA-256 이상의 해시 알고리즘을 사용할 것"이라고 명시되어 있습니다. SHA-256은 국내 TTA(한국정보통신기술협회)와 미국 NIST 모두에서 공식 승인된 알고리즘입니다.
실제 보안 심사 자리에서 SHA-256을 썼다면 "KISA 가이드라인 기준을 따랐습니다"한 줄로 끝납니다. 반면 bcrypt를 썼다면 승인 목록에 없는 알고리즘을 선택한 이유와 근거를 처음부터 직접 제출하고 심사위원을 설득해야 합니다. 기술적 안전성과 별개로, 인증 통과 가능성과 소명 비용 면에서 SHA-256이 압도적으로 유리합니다.
SHA-256 하나면 충분할까? — 레인보우 테이블의 존재
"그럼 비밀번호를 그냥 SHA-256으로 해시하면 되겠네?"라고 생각했는데, 이게 또 단순하지 않습니다.
SHA-256만 단독으로 쓰면 레인보우 테이블 공격(Rainbow Table Attack) 에 무너집니다.
레인보우 테이블이란, 공격자가 수천만 개의 흔한 비밀번호와 그 비밀번호를 SHA-256으로 해시한 결과값을 미리 전부 계산해서 만들어 놓은 거대한 대조표입니다. 예를 들어 DB가 해킹돼서 해시값 "ef92..." 가 유출됐다고 해봅시다. 공격자는 이 값을 레인보우 테이블에서 검색하면 됩니다. 표에 "1234" → "ef92..."가 등록되어 있다면 단 1초 만에 원문이 "1234"임을 알아냅니다.
아무리 복잡한 해시 알고리즘을 써도, 미리 계산된 표에 있기만 하면 원문이 바로 노출됩니다. "강력한 자물쇠를 달았지만, 그 자물쇠의 모든 패턴이 이미 열쇠 사전에 있는" 상황과 같습니다.
레인보우 테이블을 막는 두 가지 무기 — Salt와 Key Stretching
KISA는 이 문제를 해결하기 위해 SHA-256에 반드시 두 가지를 추가하도록 권고합니다.
Salt — "같은 비밀번호도 다르게 보이게"
Salt는 해시하기 전에 원문 비밀번호에 덧붙이는 무작위 문자열입니다. 예를 들면 이런 식입니다.
사용자 A의 비밀번호: "1234" + Salt "x9Qm..." → 해시 → "a3f9..."
사용자 B의 비밀번호: "1234" + Salt "p2Wk..." → 해시 → "71bc..."
두 사람이 똑같은 비밀번호 "1234"를 쓰더라도 Salt가 다르기 때문에 해시 결과가 완전히 달라집니다. 공격자가 "1234"의 해시값을 레인보우 테이블에 미리 계산해 놓았더라도, Salt가 포함된 이 결과값은 표에 없기 때문에 공격이 무력화됩니다.
여기서 중요한 실무 포인트가 하나 있습니다. Salt는 반드시 해시값과 함께 DB에 저장해야 합니다. 나중에 사용자가 로그인할 때 같은 Salt를 꺼내와서 입력값에 다시 붙여 해시해야 비교가 가능하기 때문입니다. Salt는 감추어야 할 비밀 값이 아니라, 해시 연산을 사용자마다 유일하게 만드는 재료입니다. KISA는 Salt를 최소 16바이트 이상, 사용자마다 반드시 다른 값 으로 생성하도록 권고합니다.
Key Stretching — "해커의 시도를 의도적으로 느리게"
Key Stretching은 해시 연산을 수만 번 반복해서 실행하는 기법입니다. "왜 일부러 느리게 만들지?" 싶겠지만, 그게 핵심입니다.
정상적인 사용자 입장에서는 로그인할 때 해시 연산이 한 번 일어나는 것과 10만 번 반복되는 것의 차이는 체감하기 어렵습니다. 수십 밀리초 차이입니다. 하지만 공격자 입장에서는 차원이 다른 이야기입니다. 비밀번호 10억 개를 대입해서 시도하려면 기존에 1시간 걸리던 것이 10만 배 느려지면 수천 년이 걸리는 계산이 됩니다. 현실적으로 불가능해지는 것입니다.
SHA-256 + Salt + Key Stretching = PBKDF2
이 세 가지를 하나로 합친 것이 PBKDF2-HMAC-SHA256 입니다. PBKDF2는 "Password-Based Key Derivation Function 2"의 약자로, 특정 해시 알고리즘과 조합해서 쓸 수 있는 유연한 구조를 가집니다. 국내 인증 환경에서는 SHA-256과 결합한 PBKDF2-HMAC-SHA256이 비밀번호 저장의 사실상 표준입니다.
전체 흐름을 한눈에 보면 이렇습니다.
[사용자가 입력한 비밀번호]
↓
무작위 Salt 생성 (16바이트 이상, 사용자마다 고유한 값)
↓
(비밀번호 + Salt)를 SHA-256 기반으로 100,000회 반복 해시
↓
[최종 해시값] + [Salt] → DB에 함께 저장
코드로 직접 보기 ① 비밀번호 단방향 암호화
Node.js 기준 구현 예시입니다.
const crypto = require('crypto');
// 비밀번호 해싱 함수
function hashPassword(password) {
// 1. 사용자마다 고유한 Salt를 무작위로 생성 (16바이트)
const salt = crypto.randomBytes(16).toString('hex');
// 2. SHA-256 기반으로 100,000회 반복하여 64바이트 해시값 생성
const hash = crypto
.pbkdf2Sync(password, salt, 100000, 64, 'sha256')
.toString('hex');
// 3. 해시값과 Salt를 함께 반환 — DB에 같이 저장해야 로그인 시 검증 가능
return { hash, salt };
}
// 로그인 시 검증 함수
function verifyPassword(inputPassword, storedHash, storedSalt) {
// 저장된 Salt를 꺼내와서 동일한 조건으로 해시 후 비교
const hash = crypto
.pbkdf2Sync(inputPassword, storedSalt, 100000, 64, 'sha256')
.toString('hex');
return hash === storedHash;
}
반복 횟수를 10만 번으로 설정한 것은 임의로 정한 숫자가 아닙니다. NIST SP 800-132 문서에서 PBKDF2의 최소 반복 횟수 권고 기준을 제시하고 있으며, 현재 일반적인 서버 하드웨어 성능을 기준으로 10만 번이 보안과 성능 사이의 합리적인 출발점으로 통용됩니다. 보안을 더 강화하려면 반복 횟수를 늘릴 수 있지만, 그만큼 로그인마다 서버 부하도 커지기 때문에 서비스 상황에 맞게 조율이 필요합니다.
코드로 직접 보기 ② 개인정보 양방향 암호화
이메일, 전화번호처럼 나중에 복호화해서 꺼내야 하는 개인정보에는 AES-256-GCM 을 사용합니다. KISA는 AES-256 또는 ARIA-256을 권고합니다.
여기서 운영 모드를 꼭 짚고 넘어가야 합니다. AES는 데이터를 블록 단위로 처리하는데, 이 블록들을 어떻게 연결해서 암호화하느냐에 따라 운영 모드가 나뉩니다.
ECB 모드(Electronic Codebook) 는 각 블록을 독립적으로 암호화하는 방식인데, 같은 평문 블록은 항상 같은 암호문 블록이 나옵니다. 예를 들어 이미지를 ECB로 암호화하면 암호화됐음에도 원본 이미지의 윤곽이 그대로 드러나는 유명한 사례가 있습니다. 패턴이 노출되는 치명적인 약점 때문에 ECB 모드는 절대 사용해서는 안 됩니다.
GCM 모드(Galois/Counter Mode) 는 암호화와 동시에 데이터가 중간에 변조되지 않았음을 검증하는 인증 태그(Auth Tag) 를 함께 생성합니다. 복호화할 때 이 태그가 일치하지 않으면 즉시 오류를 발생시켜 변조된 데이터를 차단합니다. 현재 가장 안전하고 권장되는 운영 모드입니다.
const crypto = require('crypto');
// 32바이트(256비트) 대칭키 — 절대 코드에 하드코딩하지 말 것
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
// 암호화 함수
function encrypt(plainText) {
// IV(Initialization Vector): 같은 평문 + 같은 키로 암호화해도
// 매번 다른 암호문이 나오게 만드는 무작위 값
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(plainText, 'utf8'),
cipher.final(),
]);
// 인증 태그: 복호화 시 데이터 무결성 검증에 필요
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
encrypted: encrypted.toString('hex'),
authTag: authTag.toString('hex'),
};
}
// 복호화 함수
function decrypt({ iv, encrypted, authTag }) {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
ENCRYPTION_KEY,
Buffer.from(iv, 'hex')
);
// 저장해둔 인증 태그를 설정 — 값이 다르면 복호화 자체를 거부
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'hex')),
decipher.final(),
]).toString('utf8');
}
코드에서 눈여겨볼 포인트 세 가지입니다.
① ENCRYPTION_KEY: AES-256에서 256은 키의 길이를 뜻합니다. 즉 32바이트(256비트) 키를 씁니다. 이 키 하나가 유출되면 암호화된 모든 데이터가 복호화될 수 있습니다. 그래서 소스 코드나 DB에 직접 넣는 건 위험합니다.
② IV(Initialization Vector): 같은 전화번호를 두 번 암호화하더라도 IV가 매번 새로 생성되기 때문에 암호문도 매번 달라집니다. 이렇게 해야 암호문만 봐도 같은 값임을 알아챌 수 있는 패턴 노출을 막을 수 있습니다.
③ 인증 태그(Auth Tag): GCM 모드가 생성하는 서명과 같은 역할입니다. 암호문이 전송 또는 저장 중에 단 1비트라도 바뀌면 복호화 시 태그 검증에서 오류가 발생하고, 시스템은 변조된 데이터라고 판단해 복호화를 거부합니다.
암호화 키는 어디에 저장해야 할까?
ENCRYPTION_KEY를 코드에 직접 쓰면 절대 안 된다고 했는데, 그럼 어디에 써야 할까요?
개발 환경에서는 .env 파일에 저장하고 dotenv 같은 라이브러리로 불러오는 방식을 씁니다. 이때 .env 파일은 반드시 .gitignore에 추가해서 깃허브 같은 버전 관리 시스템에 올라가지 않도록 해야 합니다. 실수로 올라가는 순간 키가 공개되는 것과 다름없습니다.
프로덕션 환경에서는 AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault 같은 전용 시크릿 관리 서비스를 쓰는 것이 안전합니다. 키를 코드와 완전히 분리하고, 어떤 서버가 어떤 키에 접근할 수 있는지 권한도 세밀하게 제어할 수 있습니다.
정리 — 어느 상황에 어떤 알고리즘을?
비밀번호 저장 (단방향)
├── 국내 공공 인증 필요 → PBKDF2-HMAC-SHA256 (KISA 권고)
└── 공공 인증 불필요 → bcrypt / Argon2 (글로벌 표준, 둘 다 안전)
개인정보 저장 (양방향, 복호화 필요)
├── 국내 공공 인증 필요 → AES-256-GCM 또는 ARIA-256 (KISA 권고)
└── 운영 모드 선택 → GCM 사용, ECB는 절대 금지
암호화 키 관리
├── 개발 환경 → .env 파일 (gitignore 필수)
└── 운영 환경 → AWS Secrets Manager 등 시크릿 관리 서비스
마치며
이 글을 쓰기 전까지 저는 bcrypt만 쓸 줄 알았고, 암호화가 이렇게 깊은 주제인지 몰랐습니다. 공부하면서 가장 많이 느낀 건 "왜?"라는 질문을 멈추지 않는 것의 중요성입니다. 강의에서 "이거 쓰세요"라고 하면 그냥 쓰는 게 아니라, 왜 이걸 쓰는지, 어떤 상황에서는 다른 선택이 필요한지를 한 번씩 생각해보는 습관이 결국 더 나은 개발자로 만들어 준다고 생각했습니다.
여전히 많이 부족한 주니어이지만, 이 글이 비슷한 고민을 하는 분들께 조금이라도 도움이 됐으면 합니다. 틀린 내용이나 보완할 점은 댓글로 편하게 알려주세요 :)
참고 자료
- KISA 암호 이용 안내서 (한국인터넷진흥원)
- NIST SP 800-132: Recommendation for Password-Based Key Derivation
- KCMVP 검증 알고리즘 목록 (KISA)
- PHC (Password Hashing Competition) — Argon2 공식 발표 자료
- 왜 bcrypt는 왜 한국 공공 인증을 통과하지 못할까?
'공부 > Spring' 카테고리의 다른 글
| 회원가입·로그인 직접 설계하며 배운 것들 (GS인증 / KISA 기준) (0) | 2026.07.02 |
|---|---|
| AOP (0) | 2025.05.09 |
| 구글 로그인 구현 (1) | 2025.01.16 |
| 서버사이드 렌더링, 클라이언트 사이드 렌더링 (0) | 2024.11.25 |
| 웹 어플리케이션의 의해 (1) | 2024.11.25 |