백엔드DB / SQL
6

BCrypt는 왜 같은 비밀번호도 매번 다른 해시값을 만들까?

BCrypt에 대해서 알아봅시다..!

BCrypt는 왜 같은 비밀번호도 매번 다른 해시값을 만들까?

1. 들어가며

로그인 기능을 구현하거나 관리자 비밀번호를 직접 초기화하다 보면 BCrypt 해시값을 다루게 됩니다. 이때 거의 모두가 같은 지점에서 멈칫하게 됩니다.

같은 비밀번호를 넣었는데 왜 생성되는 해시값이 매번 다를까?

1234라는 비밀번호를 BCrypt로 변환하면 실행할 때마다 다른 값이 나옵니다.

$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
$2a$10$yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

자연스럽게 의문이 생깁니다. 매번 결과가 다르면 DB 값과 비교 자체가 안 되는 거 아닌가요?

결론부터 말씀드리면, BCrypt는 검증에 필요한 정보를 해시값 안에 함께 저장합니다. 그래서 매번 결과가 달라도 인증은 정확히 동작합니다.


2. BCrypt는 암호화가 아니라 해시입니다

BCrypt는 정확히는 "암호화"가 아니라 단방향 해시입니다.

구분방향복원
암호화평문 ↔ 암호문가능 (복호화)
해시평문 → 해시값불가능

BCrypt로 저장한 비밀번호는 다시 원래 비밀번호로 되돌릴 수 없습니다. 그래서 로그인할 때는 비밀번호를 복호화해서 비교하는 게 아니라, 입력한 비밀번호가 저장된 해시값과 맞는지 검증하는 방식으로 동작합니다.


3. BCrypt 해시값의 구조

BCrypt 해시값은 다음과 같은 형태입니다.

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └────────────────┬────────────────┘└──────┬──────┘
 │   │                  salt(22자)            hash(31자)
 │   │
 │   └─ cost factor (반복 강도)
 │
 └─ BCrypt 버전
구간의미
$2a$BCrypt 버전 식별자
10cost factor (반복 강도)
다음 22자salt
마지막 31자실제 해시 결과
전체 길이60자 (고정)

핵심은 salt와 cost가 해시값 안에 함께 들어 있다는 점입니다. 검증할 때 이 정보를 다시 꺼내 쓸 수 있기 때문에 매번 다른 해시여도 비교가 가능합니다.


4. Salt: 매번 다른 해시가 나오는 이유

Salt는 해시를 만들 때 비밀번호에 추가되는 랜덤 값입니다. 같은 비밀번호여도 salt가 다르면 결과 해시는 완전히 달라집니다.

1234 + saltA → 해시값 A
1234 + saltB → 해시값 B
1234 + saltC → 해시값 C

같은 비밀번호인데 매번 다른 결과가 나오는 건 오류가 아니라 정상 동작입니다.

Salt가 없다면?

사용자 3명이 모두 1234라는 비밀번호를 사용한다고 가정해보겠습니다.

[salt 없음]
user1 → 1234 → abc123
user2 → 1234 → abc123
user3 → 1234 → abc123

DB가 유출되면 같은 비밀번호를 쓰는 사용자가 한눈에 드러납니다. 게다가 미리 만들어 둔 해시 테이블(rainbow table)로 비밀번호를 빠르게 역추적할 수 있습니다.

salt가 있으면 그림이 달라집니다.

[salt 있음]
user1 → 1234 + saltA → hashA
user2 → 1234 + saltB → hashB
user3 → 1234 + saltC → hashC

같은 비밀번호도 서로 다른 해시값으로 저장되고, rainbow table 공격 비용이 사실상 사용자 수만큼 곱해집니다.


5. 로그인 검증은 어떻게 동작할까요?

가장 헷갈리는 지점입니다. 매번 다른 해시가 나오는데 어떻게 일치 여부를 판단할까요? 핵심은 한 줄로 정리됩니다.

로그인할 때는 새로운 salt를 만들지 않고, DB에 저장된 해시값 안의 salt를 꺼내서 사용합니다.

회원가입 흐름

비밀번호 입력 → 랜덤 salt 생성 → 비밀번호 + salt로 해시 계산 → DB 저장

로그인 흐름

비밀번호 입력
   ↓
DB의 BCrypt 해시값 조회
   ↓
해시값에서 salt와 cost 추출
   ↓
입력값에 같은 salt·cost 적용해 해시 재계산
   ↓
결과가 DB 해시와 일치하는지 비교

6. 비교 방식: ❌ equals() vs ✅ matches()

❌ 잘못된 방식

String inputPassword = "1234";
String savedPassword = user.getPassword();

String newEncodedPassword = passwordEncoder.encode(inputPassword);

if (newEncodedPassword.equals(savedPassword)) {
    // 거의 항상 실패합니다
}

encode()를 호출할 때마다 새로운 salt가 생성되기 때문에 같은 1234를 넣어도 매번 다른 해시가 만들어집니다.

✅ 올바른 방식

String inputPassword = "1234";
String savedPassword = user.getPassword();

if (passwordEncoder.matches(inputPassword, savedPassword)) {
    // 로그인 성공
} else {
    // 로그인 실패
}

matches()는 DB 해시값에서 salt와 cost를 자동으로 추출해 입력값을 검증합니다. 개발자가 salt를 직접 다룰 필요가 없습니다.


7. Spring Boot 설정과 Cost Factor

Spring Security를 사용한다면 PasswordEncoder를 빈으로 등록합니다.

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(10);
}

여기서 10cost factor, 즉 BCrypt 연산의 반복 강도를 결정하는 값입니다. 값이 1 증가할 때마다 연산 시간은 2배가 됩니다.

Cost특징참고 처리 시간
8빠르지만 상대적으로 약함수 ms
10실무 표준약 100ms
12더 안전하지만 느림약 400ms
14 이상서버 부하 큼1초 이상

실무에서는 보통 10 ~ 12를 사용합니다. 로그인 트래픽이 많은 서비스에서 cost를 무작정 올리면 인증 서버가 병목이 될 수 있으니 주의해야 합니다.


8. 관리자 비밀번호를 직접 초기화할 때

관리자 비밀번호를 잊어버린 경우 DB에 저장된 비밀번호를 직접 BCrypt 해시값으로 바꿀 수 있습니다.

새 비밀번호 평문 → BCrypt 해시 생성 → DB password 컬럼에 저장 → 새 비밀번호로 로그인

주의: 온라인 BCrypt 생성 사이트에 운영 비밀번호를 넣는 것은 권장하지 않습니다. 평문이 외부 서버 로그에 남을 수 있고, 라이브러리 호환성 문제도 생길 수 있습니다. 가능하면 프로젝트 내부 코드나 로컬 Java 코드로 생성하는 것이 안전합니다.

로컬에서 BCrypt 해시 생성

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

public class PasswordHashGenerator {
    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);

        String rawPassword = "1234";
        String encodedPassword = encoder.encode(rawPassword);

        System.out.println(encodedPassword);
    }
}

DB 업데이트와 검증

UPDATE users
SET password = '$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
WHERE username = 'admin';

-- 저장된 값이 잘리거나 오염되지 않았는지 확인
SELECT username, password, LENGTH(password)
FROM users
WHERE username = 'admin';
확인 항목정상값
시작 문자열$2a$10$ 또는 $2b$10$
길이60자
공백/줄바꿈없어야 함

9. BCrypt 문제가 아닐 때 — 체크리스트와 정리

비밀번호를 정상적으로 바꿨는데도 로그인이 안 된다면, BCrypt 외 다른 지점도 함께 점검해야 합니다.

확인 항목설명
계정 ID실제 로그인 대상 row를 수정했는지
password 컬럼 길이60자가 모두 저장되는 길이인지 (VARCHAR(60) 이상)
계정 상태enabled, locked, use_yn, deleted가 로그인 가능 상태인지
권한admin role이 정상 부여되어 있는지
로그인 로직equals()가 아니라 matches()를 사용하는지
PasswordEncoder실제로 BCryptPasswordEncoder가 빈으로 등록되어 있는지

핵심 정리

  • BCrypt는 단방향 해시입니다 (복호화 불가능)
  • 같은 비밀번호도 매번 다른 해시값이 나오는 것이 정상입니다 — salt가 매번 새로 생성되기 때문입니다
  • DB에 저장된 BCrypt 문자열 안에는 salt와 cost 정보가 함께 들어 있습니다
  • 로그인할 때는 반드시 **matches()**를 사용해야 합니다
  • encode() 결과를 equals()로 비교하면 절대 안 됩니다
회원가입: 비밀번호 입력 → encode() → 새 salt 생성 → BCrypt 해시 DB 저장
로그인:   비밀번호 입력 → DB 해시 조회 → matches() → 저장된 salt로 검증

BCrypt는 결과가 매번 달라도 인증이 가능합니다. 로그인 검증 시 DB에 저장된 해시값 안의 salt를 다시 사용하기 때문입니다.

댓글

(0)