| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 오라클 캐릭터셋 확인
- Oracle 18c 설치
- ORA-12899
- Oracle 초기 사용자
- 무료 오라클 데이터베이스
- Oracle 사용자명 입력
- Oracle 윈도우 설치
- ora-01722
- Oracle 테이블 띄어쓰기
- 서평단
- 오라클 캐릭터셋 조회
- 비전공자를 위한 데이터베이스 입문
- oracle
- oracle 18c
- Orace 18c
- 오라클 캐릭터셋 변경
- Oracle 사용자명
- ORA-00922
- Oracle Express Edition
- 윈도우 Oracle
- Oracle 테이블 대소문자
- Oracle 18c HR
- Oracle 18c HR schema
- 무료 오라클 설치
- Today
- Total
The Nirsa Way
[Spring Security] BCrypt Salt의 이해: 로그인 검증이 가능한 이유와 Pepper를 고려하는 상황 본문
[Spring Security] BCrypt Salt의 이해: 로그인 검증이 가능한 이유와 Pepper를 고려하는 상황
KoreaNirsa 2026. 6. 4. 11:52[Spring Security] BCrypt Salt의 이해: 로그인 검증이 가능한 이유와 Pepper를 고려하는 상황
비밀번호 저장을 처음 구현할 때 흔히 "BCryp로 암호화해서 저장한다"라고 표현하지만 정확히는 아래와 같은 단방향 해싱 흐름입니다.
[회원가입] 사용자 비밀번호 → BCrypt 해싱 → DB 저장
[로그인] 사용자 입력 비밀번호 → DB의 BCrypt 해시와 비교
그런데 BCrypt를 조금 더 들여다보면 BCrypt는 매번 랜덤 Salt를 생성하므로 같은 비밀번호도 매번 다른 Hash가 나오게 됩니다. 그렇다면 로그인이 성공 가능한 이유는 무엇일지 의문이 생깁니다.
이번 글은 위의 질문에서 출발하며 먼저 요약한 결론은 다음과 같습니다.
1. BCrypt는 암호화가 아니라 단방향 해시다.
2. BCrypt는 매번 랜덤 Salt를 생성한다.
3. Salt는 최종 Hash 문자열 안에 함께 저장된다.
4. 로그인 시에는 새 Salt를 만들지 않고, 저장된 Hash 안의 Salt를 재사용한다.
5. DB가 유출되면 Salt와 Hash가 함께 노출된다.
6. 따라서 오프라인 딕셔너리 공격은 가능하다.
7. 이를 완화하기 위해 서버 측 비밀 값인 Pepper를 추가로 사용할 수 있다.
Spring Security 공식 문서에서도 BCrypt는 Salt를 내장 생성하고, 생성된 Salt를 출력 Hash에 포함한다고 설명합니다. 또한 Spring Security의 BCryptPasswordEncoder는 기본적으로 strength 값 10을 사용하며, version, strength, SecureRandom을 설정할 수 있습니다.

BCrypt는 암호화가 아니라 Hash 이다.
실무에서는 아직도 "비밀번호를 암호화해서 저장한다"는 표현을 사용하는 경우가 많지만 정확히는 해시로 저장한다고 표현하는 것이 맞습니다.
비밀번호 저장의 목적은 원문을 복원하는 것이 아니라 사용자가 입력한 값이 기존에 저장된 값과 동일한지를 검증하는 것이기 때문입니다.
따라서 비밀번호 저장에는 AES와 같은 양방향 암호화보다 BCrypt, Argon2, PBKDF2와 같은 단방향 Password Hashing 알고리즘이 사용됩니다. 용어는 아래와 같습니다.
| 구분 | 암호화 | 일반 해시 | Password Hashing |
| 대표 예시 | AES, RSA | SHA-256, SHA-512 | BCrypt, Argon2, PBKDF2 |
| 방향 | 양방향 | 단방향 | 단방향 |
| 복호화 | 가능 | 불가능 | 불가능 |
| 비밀번호 저장 | 부적합 | 단독 사용 부적합 | 적합 |
| 로그인 검증 | 복호화 후 비교 | 재계산 후 비교 가능 | 재계산 후 비교 |
단방향 해시라고 해서 모두 비밀번호 저장에 적합한 것은 아닙니다. SHA-256, SHA-512 같은 일반 해시 함수는 계산이 너무 빠르기 때문에 공격자가 대량의 후보 비밀번호를 빠르게 대입할 수 있습니다.
따라서 비밀번호 저장에는 BCrypt, Argon2id, PBKDF2, scrypt처럼 의도적으로 계산 비용을 높인 Password Hashing 알고리즘을 사용해야 합니다.
Bcrypt 구조 파악하기
BCryptPasswordEncoder의 코드를 직접 확인해보면 Salt 생성 후 Hash를 생성하는 모습과 저장된 Hash와 비교하여 확인하는 것을 볼 수 있습니다.

BCrypt를 보면 Salt 길이가 16바이트로 정의되어 있으며 getsalt(...) 내부에서는 아래와 같은 코드가 실행됩니다.
byte rnd[] = new byte[BCRYPT_SALT_LEN];
random.nextBytes(rnd);
이 부분이 실제 랜덤 Salt가 생성되는 지점입니다. SecureRandom을 통해 16바이트의 랜덤 데이터를 생성한 뒤, BCrypt 포맷($2a$10$...)에 맞게 버전(version), 비용 계수(cost factor), 그리고 Salt 값을 조합하여 문자열을 생성합니다.
작성일 기준 BCrypt 소스코드 686~709라인을 살펴보면 SecureRandom#nextBytes()로 랜덤 바이트를 생성한 후, 이를 BCrypt 전용 Base64 형식으로 인코딩하여 Salt 문자열에 포함시키는 과정을 확인할 수 있습니다.
BCrypt의 저장되는 문자열을 보통 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy와 같이 작성되는데, 구조는 아래와 같습니다.
- Version : $2a
- Cost : 10
- Salt : N9qo8uLOickgx2ZMRZoMye
- Hash : IjZAgcfl7p92ldGxad68LJZdL17lhWy
Spring Security 내부 BCrypt에서는 저장된 문자열에서 cost와 salt를 파싱합니다. 실제로 BCrypt의 650~653 라인을 확인해보면 22자의 Salt 문자열을 추출하고 이를 다시 디코딩하는 것을 확인해볼 수 있습니다.

이후 Hash 결과를 만들 때는 Salt와 Hash를 하나의 문자열에 이어붙여 만들어지게 됩니다. 즉, BCrypt는 단순히 Hash만 저장하는 것이 아니라 검증에 필요한 모든 정보를 하나의 문자열 안에 포함하고 있습니다.
검증할 때는 새로운 Salt를 만들지 않는다.
BCrypt의 방식에 의문이 든것은 아래와 같았습니다.
- BCrypt는 매번 랜덤 Salt를 생성함
- 로그인때도 새 Salt를 만들면 전체적인 Hash가 달라짐
- 그렇다면 같은 값을 입력하더라도 로그인은 매번 실패됨
하지만 실제로 코드를 살펴본 결과 검증을 할때는 새 Salt를 만들지 않았습니다. 스프링 시큐리티의 matchesNonNull()은 입력 비밀번호와 DB에 저장된 BCrypt 문자열을 BCrypt.checkpw()로 넘깁니다.
핵심 코드는 아래와 같은데, encodedPassword는 DB에 저장된 전체 BCrypt 문자열입니다.
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
BCrypt.checkpw()는 이 문자열에서 version, cost, salt를 다시 꺼내고 사용자가 입력한 평문 비밀번호(rawPassword.toString())를 같은 조건으로 다시 해쉬하여 비교합니다. 간단한 흐름은 아래와 같습니다.
- 로그인 요청 : rawPassword = "1234"
- DB 저장 값 : $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
- DB에 저장된 값에서 Salt 추출
- 입력된 비밀번호(rawPassword)를 추출한 Salt로 BCrypt 재계산
- 재계산 결과와 DB 저장값 비교
즉 BCrypt는 처음 저장 시 Salt를 랜덤 생성하여 Salt+입력값으로 해시를 만들어 저장하고, 추후 검증이 필요할 경우 Salt값을 가져와 입력값으로 해싱하여 기존 값과 비교하는 구조를 가집니다.
이렇게 되면 같은 값이더라도 매번 다른 결과가 나오므로 레인보우 테이블의 공격을 무효화할 수 있습니다. 예시는 아래와 같습니다.
- rawPassword = "1234"
- BCrypt가 랜덤 Salt 생성 후 해싱 -> A의 결과
- 이후 BCrypt를 재실행하면 또 새로운 랜덤 Salt가 생성되며 해싱 -> B의 결과
Salt가 없으면 공격자는 "1234" → Hash 값처럼 미리 계산해 둔 테이블을 여러 DB에 재사용할 수 있습니다.
하지만 사용자마다 Salt가 다르면 같은 "1234"라도 Salt마다 결과가 달라집니다.
따라서 공격자는 기존에 만들어 둔 범용 Rainbow Table을 그대로 재사용하기 어렵고 각 사용자 Salt마다 후보 비밀번호를 다시 계산해야 합니다. 즉 Salt는 공격 자체를 불가능하게 만드는 값이라기보다 사전 계산 공격의 경제성을 크게 떨어뜨리는 값입니다.
Salt는 왜 공개되어도 되고, Cost Factor는 어떤 역할을 수행하는가?
많은 사람들이 Salt도 숨겨야 하는 값이라고 생각하지만 Salt의 목적은 비밀 유지가 아닙니다. Salt는 동일한 비밀번호가 동일한 Hash로 저장되는 문제를 해결하기 위해 존재합니다.
즉 공격자가 Salt를 알고 있다는 전제 하에 설계된 것이 BCrypt 이기 때문에 Salt를 Hash 내부에 그대로 저장합니다.
"1234" → Salt A → Hash A
"1234" → Salt B → Hash B
BCrypt의 Cost Factor는 해시 계산에 필요한 연산량을 의미하며 Cost가 1 증가할 때마다 필요한 연산량은 2배씩 증가합니다.
| COst | 반복 시 발생하는 비용 |
| 10 | 2^10 = 1,024 |
| 11 | 2^11 = 2,048 |
| 12 | 2^12 = 4,096 |
| 13 | 2^13 = 8,192 |
| 14 | 2^14 = 16,384 |
즉 Salt는 레인보우 테이블 공격을 방어하고 Cost Factor는 무차별 대입 공격과 딕셔너리 공격의 비용을 증가시키는 역할을 수행합니다. 다만, Cost Factor는 높을수록 많은 연산이 필요하기에 서버의 부하가 발생할 수 있으므로 너무 높기만 해도 좋은 것은 아닙니다.
Cost는 높을수록 공격 비용을 증가시키지만, 로그인 서버의 CPU 비용도 함께 증가시킵니다.
따라서 무조건 높은 값을 선택하기보다는 실제 서버 환경에서 측정한 뒤 사용자 경험과 서버 부하를 감당할 수 있는 수준으로 조정해야 합니다.
BCrypt만 사용할 경우 DB 유출 시 어떤 일이 발생하는가
여기서 중요한 결론이 나옵니다. BCrypt가 레인보우 테이블 어택을 어렵게 만들지만, DB가 유출되면 오프라인 딕셔너리 공격 자체가 불가능해지는 것은 아닙니다.
DB가 유출되면 공격자는 아래와 같은 정보를 얻습니다.
$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
여기에는 Version, Cost, Salt, Hash의 정보가 들어있기에 공격자는 비밀번호 후보를 하나씩 대입할 수 있습니다. 간략한 공격 흐름은 아래와 같습니다. 딕셔너리 어택은 자주 사용되는 단어, 문구, 숫자등을 조합한 사전(Disctionary)을 미리 만들어두고 이를 순차적으로 대입하는 해킹 기법이며 아래에서 언급하는 후보 비밀번호는 사전에 있는 하나의 문자열을 의미합니다.
- DB Hash 획득
- Hash 문자열에서 Salt만 추출
- 후보 비밀번호 선택
- BCrypt(후보 비밀번호, 추출한 Salt)
- DB Hash와비교
- 일치하면 비밀번호 추측 성공
충분히 길고 예측 불가능한 비밀번호라면 BCrypt Hash를 깨는 것은 매우 어렵지만 실제 사용자의 비밀번호는 사전에 있는 단어, 생년월일, 전화번호, 서비스명 등을 조합하는 경우가 많습니다.
따라서 DB가 유출되면 공격자는 저장된 Salt와 Cost를 이용해 오프라인 딕셔너리 공격을 수행할 수 있고 약한 비밀번호는 여전히 추측될 수 있습니다.
다만 이 논의는 기본적으로 “비밀번호 저장”에 대한 이야기입니다. 주민등록번호, 전화번호, 사번, 학번처럼 후보 공간이 작거나 복원이 필요한 데이터는 비밀번호와 다른 방식으로 설계해야 합니다.
원문 복원이 필요 없다면 서버 측 비밀 키를 사용하는 HMAC 기반 저장을 검토할 수 있고, 원문 복원이 필요하다면 KMS/Secret Manager로 관리되는 키를 사용한 필드 레벨 암호화가 더 적합할 수 있습니다.
중요한 점은 Salt가 공개된 단순 Hash만으로는 후보 공간이 작은 데이터를 충분히 보호하기 어렵다는 것입니다.
Salt와 Pepper
| 구분 | Salt | Pepper |
| 목적 | Hash 중복 방지, 레인보우 테이블 방어 | DB 단독 유출 시 오프라인 크래킹 난이도 증가 |
| 저장 위치 | Hash 내부 또는 DB | DB와 분리된 Secret Manager, Vault, KMS, HSM 등 |
| 공개 여부 | 공개 가능 | 비공개 |
| 사용자별 | 사용자마다 다름 | 일반적으로 애플리케이션/시스템 단위로 공유 |
| DB 유출 시 노출 | O | X |
| 교체 난이도 | 낮음 | 높음. 기존 비밀번호 원문을 모르므로 로그인/비밀번호 재설정 필요 |
OWASP는 Pepper를 비밀번호 Salt처럼 공개하거나 Hash와 함께 저장하면 안 되고 DB와 분리된 Secret Vault나 HSM에 저장해야 한다고 설명합니다. 또한 Pepper가 노출되면 사용자의 원문 비밀번호를 알 수 없기 때문에 Pepper 교체에는 비밀번호 재설정이 필요하다고 설명합니다.
추가로 OWASP Cryptographic Storage Cheat Sheet는 키를 소스 코드에 하드코딩하거나 버전 관리 시스템에 올리지 말고, 가능하면 KMS, Key Vault, HSM, Vault 같은 별도 키 관리 시스템을 사용하라고 안내합니다.
Pepper를 추가하면 무엇이 달라질까
실제 서버에서 password + pepper 형태로 저장했다고 가정한다면 DB가 유출되더라도 공격자는 Pepper 값을 모르므로 Salt를 알고, 실제 사용된 값으로 해싱하더라도 동일한 해시를 재현할 수 없습니다.
만약 아래와 같은 코드가 존재한다면 DB가 유출되엇을 때 $2a$12$N9qo8uLOickgx2ZMRZoMye...와 같은 값을 가져가 Salt를 알 수 있습니다.
String encoded = passwordEncoder.encode(rawPassword + pepper);
하지만 pepper를 사용했을 경우 "password123" + "pepper값"이 붙기에 공격자는 pepper를 추측할 수 없어서 동일한 입력값을 넣더라도 같은 해시를 확인할 수 없습니다.
예를 들면 "password123"를 해싱하였다면 공격자는 DB 유출 된 데이터를 기반으로 Salt + "password123"으로 동일한 해시값을 얻어서 추측이 가능하지만, "password123secret-pepper"를 해싱했을 경우 공격자는 Salt + "password123"으로 동일한 해시값을 얻어낼 수 없습니다. 실제 사용자의 비밀번호 뒤에 pepper가 추가적으로 붙어서 해싱된 값이기 때문입니다.
다만 DB와 함께 서버의 코드 또는 환경 변수, Secret Manager가 노출되었을 경우 공격자는 pepper의 값도 알아낼 수 있기 때문에 만능은 아닙니다. pepper가 유효한 것은 DB 데이터만 유출되었을 때로 한정됩니다.
Pepper를 추가할 때 단순 문자열 연결만으로 충분할까?
에서는 이해를 돕기 위해 아래와 같은 형태로 Pepper를 설명했습니다.
String encoded = passwordEncoder.encode(rawPassword + pepper);
개념적으로는 맞습니다. 사용자가 입력한 비밀번호에 서버만 알고 있는 비밀값인 Pepper를 추가한 뒤 BCrypt로 해싱하는 구조입니다.
rawPassword + pepper → BCrypt(rawPassword + pepper, random salt, cost) → DB 저장
이렇게 하면 DB만 유출된 상황에서는 공격자가 Salt와 Hash를 모두 알고 있더라도 Pepper 값을 모르기 때문에 동일한 BCrypt 입력값을 재현하기 어려워집니다. OWASP도 Pepper를 Salt와 함께 사용할 수 있는 추가 방어층으로 설명하며, Pepper는 Hash와 함께 저장하지 말고 DB와 분리된 Secret Vault나 HSM 등에 보관해야 한다고 설명합니다.
하지만 실무에서는 단순히 rawPassword + pepper를 그대로 BCrypt에 넣는 방식에는 주의가 필요합니다.
가장 큰 이유는 BCrypt의 입력 길이 제한입니다. OWASP Password Storage Cheat Sheet는 bcrypt 사용 시 72바이트 입력 제한을 고려해야 한다고 설명합니다. Spring Security도 2025년에 BCryptPasswordEncoder의 72자 초과 비밀번호 처리와 관련된 보안 권고를 발표한 바 있습니다. (CVE-2025-2228)
예를 들어 아래와 같은 입력이 있다고 가정해보겠습니다.
rawPassword = "매우 긴 비밀번호..."
pepper = "server-secret-pepper"
BCrypt 구현체가 입력 앞부분 72바이트까지만 사용한다면, 비밀번호가 이미 72바이트를 초과하는 순간 뒤에 붙인 Pepper가 실제 해싱 입력에 반영되지 않을 수 있습니다.
rawPassword + pepper → BCrypt는 앞 72바이트까지만 사용 → pepper가 잘릴 수 있음
따라서 실무에서는 단순 문자열 연결보다는 HMAC 기반 pre-hash 후 BCrypt를 적용하는 방식을 고려할 수 있습니다. 흐름은 아래와 같습니다.
[Pre-hash Peppering]
사용자 입력 비밀번호 → HMAC-SHA384(rawPassword, pepperKey) → Base64 인코딩 → BCrypt(Base64(HMAC 결과), random salt, cost) → DB 저장
이 방식에서는 Pepper를 단순히 문자열로 이어붙이지 않고 HMAC의 Secret Key로 사용합니다. HMAC 결과를 Base64로 인코딩하면 고정 길이 문자열이 만들어지고 이 값을 BCrypt의 입력으로 사용합니다.
OWASP도 bcrypt와 pre-hashing을 함께 사용할 경우 bcrypt(base64(hmac-sha384(password, pepper)), salt, cost) 형태의 접근을 예시로 제시합니다.
만약, 스프링부트에서 해당 방식을 적용한다면 아래와 같은 코드와 같이 사용할 수 있습니다.
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.Objects;
public final class PepperingPasswordEncoder implements PasswordEncoder {
private static final String HMAC_ALGORITHM = "HmacSHA384";
private final PasswordEncoder delegate;
private final byte[] pepperKey;
public PepperingPasswordEncoder(PasswordEncoder delegate, byte[] pepperKey) {
this.delegate = Objects.requireNonNull(delegate);
this.pepperKey = Objects.requireNonNull(pepperKey).clone();
}
@Override
public String encode(CharSequence rawPassword) {
return delegate.encode(preHash(rawPassword));
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return delegate.matches(preHash(rawPassword), encodedPassword);
}
private String preHash(CharSequence rawPassword) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(pepperKey, HMAC_ALGORITHM);
mac.init(keySpec);
byte[] hmac = mac.doFinal(
rawPassword.toString().getBytes(StandardCharsets.UTF_8)
);
return Base64.getEncoder().encodeToString(hmac);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Failed to calculate password HMAC", e);
}
}
}
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Base64;
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder(
@Value("${security.password.pepper-key}") String base64PepperKey
) {
byte[] pepperKey = Base64.getDecoder().decode(base64PepperKey);
return new PepperingPasswordEncoder(
new BCryptPasswordEncoder(12),
pepperKey
);
}
}
위와 같이 구성하면 회원가입과 로그인 검증 흐름은 아래처럼 동작합니다.
[회원가입]
rawPassword → HMAC-SHA384(rawPassword, pepperKey) → Base64(HMAC 결과) → BCryptPasswordEncoder.encode() → DB 저장
[로그인]
rawPassword → HMAC-SHA384(rawPassword, pepperKey) → Base64(HMAC 결과) → BCryptPasswordEncoder.matches() → DB의 BCrypt Hash와 비교
이렇게 하면 서비스 코드에서는 기존과 동일하게 passwordEncoder.encode()와 passwordEncoder.matches()만 사용하면 됩니다.
String encodedPassword = passwordEncoder.encode(rawPassword);
boolean result = passwordEncoder.matches(rawPassword, storedPasswordHash);
즉, Pepper 적용 여부를 서비스 로직에서 매번 신경 쓰지 않아도 됩니다.
마무리
정리하면 BCrypt의 Salt는 숨기기 위한 값이 아니라 동일한 비밀번호가 동일한 Hash로 저장되는 것을 방지하고 Rainbow Table과 같은 사전 계산 공격의 효율을 떨어뜨리기 위한 값입니다.
BCrypt는 Salt와 Cost Factor를 통해 비밀번호 크래킹 비용을 증가시키지만 DB가 유출되면 공격자는 저장된 BCrypt 문자열에서 Version, Cost, Salt, Hash를 모두 얻을 수 있습니다. 따라서 약한 비밀번호에 대해서는 오프라인 딕셔너리 공격이 여전히 가능합니다.
Pepper는 이런 상황에서 추가 방어층이 될 수 있습니다.
Pepper를 DB와 분리된 Secret Manager, KMS, Vault, HSM 등에 안전하게 보관하면 공격자가 DB만 탈취했을 때 동일한 Hash를 재현하기 어려워집니다.
다만 Pepper는 만능이 아닙니다.
애플리케이션 서버, 환경 변수, Secret Manager, 배포 파이프라인까지 함께 노출되면 Pepper의 보호 효과는 크게 줄어들며 Pepper가 유출되면 기존 비밀번호 원문을 알 수 없기 때문에 교체와 마이그레이션이 어렵습니다.
결국 BCrypt는 강력한 Password Hashing 알고리즘이지만 Salt, Cost, Pepper의 역할과 한계를 이해하고 위협 모델에 맞게 조합해야 합니다.
