본문 바로가기
개발지식

[Security] 비밀번호를 안전하게 보관하는 법: Hash, Salt, 그리고 Key Stretching 이해

by hunovator 2026. 7. 22.
반응형

웹 서비스를 개발할 때 사용자들의 민감 정보 중 최우선으로 관리해야 하는 것은 비밀번호입니다. 현대 개인정보보호법 및 정보통신망법상 비밀번호는 DB에 그대로 저장하는 평문(Plaintext) 보관이 원천적으로 불법입니다. 그렇다면 해킹으로 데이터베이스 전체가 털리더라도 사용자의 실제 비밀번호를 알아낼 수 없도록 방어하는 암호화 매커니즘은 어떻게 구성될까요? 안전한 패스워드 저장을 위한 **Hash(단방향 해시), Salt(솔트), 그리고 Key Stretching(키 스트레칭)**의 개념과 작동 원리, 실제 코드 구현까지 상세하게 파헤쳐 보겠습니다.


1. 단방향 해시 함수 (One-way Hash Function)

단방향 해시 함수는 임의의 길이를 가진 메시지를 입력받아 고정된 길이의 해시값(다이제스트, Digest)으로 변환하는 함수입니다.

1.1. 단방향 해시의 3대 핵심 성질

  • 일방향성(One-wayness): 평문 $P$를 해시 함수 $H$에 대입하여 해시값 $H(P)$를 구하는 것은 매우 쉽지만, 반대로 $H(P)$로부터 원래의 $P$를 역산하는 것은 불가능해야 합니다.
  • 충돌 저항성(Collision Resistance): 서로 다른 두 평문 $P_1, P_2$가 동일한 해시값 $H(P_1) = H(P_2)$를 가질 확률이 극도로 낮아야 합니다.
  • 눈사태 효과(Avalanche Effect): 입력값 평문에서 단 1글자 또는 1비트만 바뀌어도 결과 해시값은 규칙성 없이 완전히 다른 형태로 대격변을 일으켜야 합니다.

1.2. 단순 해시의 치명적 약점과 레인보우 테이블 공격

일반적인 암호학적 해시 함수(SHA-256 등)는 데이터의 무결성 검증과 빠른 전송을 목적으로 설계되어 연산 속도가 극도로 빠릅니다. 이는 비밀번호 저장 장치로서 엄청난 약점이 됩니다.

  • 무차별 대입 공격(Brute-force): 해커가 고성능 GPU와 클라우드 인프라를 동원하면 SHA-256 연산을 초당 수십억 번 이상 수행할 수 있으므로, 짧고 단순한 비밀번호는 순식간에 복원됩니다.
  • 레인보우 테이블(Rainbow Table) 공격: 해커가 자주 사용되는 비밀번호(예: 123456, password, welcome)들의 해시값과 평문 매핑 관계를 미리 수 기가바이트에서 수 테라바이트 크기의 데이터베이스(표)로 구축해 둔 뒤, 해킹한 DB의 해시값과 단순 대조하여 패스워드를 1초 만에 유추하는 기법입니다.

2. 소금 치기: 솔트 (Salt)

레인보우 테이블 공격과 동일 비밀번호가 항상 동일한 해시값으로 변환되는 1:1 매핑 문제를 해결하기 위한 기술이 바로 **Salt(솔트)**입니다.

[평문 패스워드] ──┐
                  ├─► [합산 및 해싱] ─► [독특한 최종 해시값]
[무작위 솔트값] ──┘

2.1. 솔트의 작동 방식

사용자가 회원 가입 시 비밀번호 love123을 입력하면, 시스템은 암호학적으로 안전한 난수 생성기(CSPRNG)를 활용하여 사용자당 고유한 32바이트 이상의 문자열(Salt)을 임의 생성합니다.

  • 예: Salt = x8F9aB2...
  • 이를 패스워드 뒤에 덧붙여 love123x8F9aB2...의 형태로 합산한 뒤 해시 연산을 수행합니다.
  • 최종 데이터베이스에는 사용자의 고유 Salt값최종 해시값이 함께 저장됩니다.

2.2. 솔트의 방어 효과

사용자 A와 B가 동일하게 123456이라는 대중적인 비밀번호를 사용하더라도, 가입할 때 각각 독립적으로 부여된 솔트($Salt_A \neq Salt_B$)가 다르면 최종 저장되는 해시값은 완전히 달라집니다. 이로 인해 해커가 미리 준비해 둔 레인보우 테이블은 무용지물이 되며, 해커는 각 행(Row)마다 솔트를 적용해 해싱을 전부 처음부터 수행해야 하므로 공격 비용이 기하급수적으로 폭증합니다.


3. 시간 지연: 키 스트레칭 (Key Stretching)

솔트를 적용해 레인보우 테이블은 막았으나, 고성능 병렬 연산 GPU 시스템을 활용해 타겟 유저 한 명을 대상으로 하는 무차별 대입 공격(Brute-force)은 여전히 위협적입니다. 이를 해결하고자 연산 횟수를 의도적으로 늘려 연산 시간을 지연시키는 키 스트레칭 기법이 도입되었습니다.

[비밀번호 + 솔트] ──► [해시] ──► [해시] ──► [해시] ... (N만 번 반복) ──► [최종 저장]

3.1. 시간 지연의 마법

일반적인 사용자가 로그인을 시도할 때 검증에 소요되는 시간 0.1초~0.2초는 인간에게 거의 느껴지지 않는 찰나의 순간입니다. 그러나 매 연산마다 0.1초가 걸리는 키 스트레칭 필터를 거치게 되면, 초당 수억 번 대입하여 해시를 맞추려던 해커의 브루트 포스 장비는 1초당 10번밖에 연산하지 못하는 무력한 상태가 됩니다. 1초당 10번 연산으로는 간단한 문자열 조합을 푸는 데만 수백 년이 걸리게 되므로 크래킹이 불가능해집니다.


4. 패스워드 해시 알고리즘 비교

알고리즘 개발 연도 제어 파라미터 메모리 하드화 (Memory-hard) 특징 및 적용 권장
PBKDF2 2000년 Salt, Iteration Count X (CPU 연산 위주) NIST 표준 규격, 구형 환경 호환성 우수
bcrypt 1999년 Work Factor (Cost) X (느린 연산 위주) 업계 de-facto 표준, 단순 비밀번호 보안에 가장 널리 쓰임
scrypt 2009년 CPU Cost, Memory Cost, Parallelism ⚠️ 지원 (메모리 제어) 대규모 리소스 장악에 강하나 파라미터 튜닝이 다소 복잡
Argon2 2015년 Time Cost, Memory Cost, Parallelism ✅ 강력 지원 (ASIC 방어) 암호학적 검증을 거친 최신 대안, 대규모 고성능 프라이빗 서버 권장

5. 실무 실전 코드 구현

예제 1: Java (Spring Security BCryptPasswordEncoder)

Spring Boot 환경에서 가장 쉽고 안전하게 비밀번호를 검증하는 표준 방식입니다.

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class SecurityPasswordExample {
    public static void main(String[] args) {
        // BCrypt 인코더 선언 (Cost factor 기본값: 10)
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

        String rawPassword = "mySecurePassword123!";

        // 1. 비밀번호 단방향 암호화 (Salt 자동 랜덤 생성 및 Key Stretching 포함)
        String hashedPassword = encoder.encode(rawPassword);
        System.out.println("생성된 해시값 (DB 저장용): " + hashedPassword);

        // 2. 사용자가 로그인 시 입력한 평문과 DB 해시 검증
        boolean isMatch = encoder.matches(rawPassword, hashedPassword);
        System.out.println("비밀번호 일치 여부: " + isMatch);

        // 잘못된 입력 테스트
        boolean isWrongMatch = encoder.matches("wrongPassword", hashedPassword);
        System.out.println("잘못된 비밀번호 검증 결과: " + isWrongMatch);
    }
}

예제 2: Node.js (bcrypt 라이브러리 사용)

비동기 이벤트 기반의 Node.js 환경에서 CPU 블로킹 없이 비밀번호 해싱을 처리하는 예제입니다.

const bcrypt = require('bcrypt');

async function manageUserPassword() {
    const rawPassword = 'userSuperSecretPassword!';
    const saltRounds = 12; // 키 스트레칭 반복 횟수 설정 (2^12 = 4096회)

    try {
        // 1. Salt 생성 및 해싱 동시 진행 (비동기 논블로킹)
        const hashedPassword = await bcrypt.hash(rawPassword, saltRounds);
        console.log('최종 저장용 해시:', hashedPassword);

        // 2. 입력 패스워드와 검증
        const isMatched = await bcrypt.compare(rawPassword, hashedPassword);
        console.log('검증 결과:', isMatched); // true
        
        const isWrongMatched = await bcrypt.compare('wrong_pass', hashedPassword);
        console.log('틀린 패스워드 결과:', isWrongMatched); // false
    } catch (err) {
        console.error('해싱 오류 발생:', err);
    }
}

manageUserPassword();

6. 자주 묻는 질문 (FAQ)

Q1. 솔트(Salt)는 평문으로 저장되는데 해커가 솔트도 훔쳐 가면 무용지물 아닌가요?

아닙니다. 솔트의 목적은 솔트 자체를 비밀로 숨겨 암호화하는 것이 아닙니다. 솔트의 진짜 목적은 공격자가 하나의 레인보우 테이블을 제작해 수천만 명의 유저 계정을 일괄 탈취(Mass Cracking)하는 연산 효율을 완전히 붕괴시키는 것입니다. 솔트가 평문으로 노출되어 있더라도 해커는 털어낸 각 계정의 솔트를 조합해 한 명 한 명 개별적으로 Brute-force 연산을 매번 처음부터 다시 굴려야 하므로 현실적인 시간 내에 크래킹이 절대 불가능해집니다.

Q2. 솔트를 DB 내부가 아닌 소스 코드나 외부 별도 파일에 숨기는 페퍼(Pepper)는 무엇인가요?

**Pepper(페퍼)**는 솔트와 유사하지만 고유 솔트값과 다르게 모든 유저가 동일하게 공유하거나 애플리케이션 서버 코드 내부에 안전하게 하드코딩해 두는 전역적 키값입니다. 데이터베이스가 통째로 털리는 SQL Injection 사고가 발생하더라도, 해커는 웹 애플리케이션 서버 소스 코드를 탈취하지 못했으므로 페퍼값을 몰라 공격을 전혀 재현할 수 없게 만듭니다. 솔트와 페퍼를 혼용하면 2중 방벽 보안을 달성할 수 있습니다.

Q3. 이미 약한 해시(MD5, SHA-1)로 저장된 기존 유저들의 DB를 어떻게 bcrypt나 Argon2로 마이그레이션하나요?

사용자의 원본 평문 비밀번호를 강제로 다 수집할 수 없기 때문에 점진적(Lazy) 업데이트 기법을 씁니다.

  1. 임시 테이블에 해싱 알고리즘 버전을 기록하는 필드를 추가합니다.
  2. 사용자가 정상 로그인을 시도하여 평문 비밀번호를 서버 메모리에 올리는 로그인 성공 시점 직후에, 해당 평문을 새 알고리즘(예: bcrypt)으로 즉시 인코딩하여 DB를 리프레시하고 버전을 신규 알고리즘 번호로 업데이트합니다.
  3. 오랫동안 로그인하지 않은 장기 휴면 계정 등은 비밀번호 재설정 링크를 발송해 강제 리셋을 유도합니다.

7. 마무리 및 권장사항

안전한 비밀번호 관리는 모던 웹 애플리케이션 보안의 시작점입니다. 현대 자바 백엔드 및 Node.js 진영에서는 이미 검증되고 널리 통용되는 라이브러리(bcrypt, Argon2 등)를 기본 탑재하고 있으므로 절대로 개인적인 암호화 알고리즘을 급조해서 쓰지 마십시오. 최저 코스트 팩터 10~12 이상을 적용하여 0.1초 이상의 딜레이를 유도하고 유니크 솔팅을 강제하는 것만으로도 대부분의 웹 해킹 위협으로부터 유저 계정을 안전하게 방어할 수 있습니다.

반응형