PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책
안전한 인증의 출발점은 비밀번호 저장 방식입니다. Spring Security 6에서는 PasswordEncoder를 통해 비밀번호를 단방향 해시로 저장합니다. 기본으로 BCrypt가 권장되며, 서비스 공통 비밀을 더하는 Pepper 전략, 그리고 비밀번호 정책(길이/문자 조합/유출 여부)까지 갖추면 대부분의 웹 서비스에 필요한 최소 보안 요건을 충족할 수 있습니다. 이 글은 실전에 바로 쓰는 설정과 회원가입 흐름, 마이그레이션 포인트를 정리했습니다.
1) PasswordEncoder 올바르게 구성하기(DelegatingPasswordEncoder)
스프링은 DelegatingPasswordEncoder를 통해 “인코더 식별자”를 해시 앞에 붙여 저장합니다(예: {bcrypt}$2a$...). 이렇게 하면 나중에 알고리즘을 바꿔도 기존 해시를 그대로 검증할 수 있습니다.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
String idForEncode = "bcrypt"; // 기본 알고리즘
Map<String, PasswordEncoder> encoders = new HashMap<>();
// BCrypt 강도(라운드) 10~12 권장. 서버 스펙/부하에 맞게 측정 필요
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
// 레거시 마이그레이션 대비(검증만 허용)
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder(idForEncode, encoders);
}
}
저장 결과는 {id}hash 형태입니다. 로그인 시 스프링이 {id}를 읽어 해당 알고리즘으로 검증합니다. 신규 가입/비번 변경은 기본 알고리즘(bcrypt)로 저장되므로 장기적으로도 유연합니다.
2) Pepper 전략: 해시 전에 서비스 공통 비밀 더하기
Pepper는 사용자별 Salt와 달리 서비스 전역 비밀입니다. 환경변수/시크릿 매니저에 보관하고, 해시 전 단계에서 비밀번호와 함께 결합합니다. 데이터베이스가 유출되더라도 Pepper를 모르면 역산이 어려워집니다.
@Component
@RequiredArgsConstructor
public class PasswordHasher {
private final PasswordEncoder encoder;
// 환경 변수나 시크릿 매니저에서 로드 (예: Spring @Value)
@Value("${security.pepper}")
private String pepper;
public String encode(String rawPassword) {
String mixed = rawPassword + pepper; // 간단 결합 (필요 시 HMAC 등 사용 가능)
return encoder.encode(mixed);
}
public boolean matches(String rawPassword, String encoded) {
String mixed = rawPassword + pepper;
return encoder.matches(mixed, encoded);
}
}
Pepper 교체 시에는 비밀번호 변경 타이밍 또는 로그인 성공 타이밍의 무중단 재해시(lazy rehash) 전략을 고려하세요.
3) 회원가입: 정책 검증 → 인코딩 → 저장
정책은 길이, 문자 조합, 금지 패턴, 과거 비밀번호 재사용 금지, 유출 비밀번호 차단 등으로 구성합니다. 아래는 간단한 구현 예시입니다.
@Data
class SignupRequest {
@NotBlank @Email
private String email;
@NotBlank
private String password;
@NotBlank
private String passwordConfirm;
}
@Service
@RequiredArgsConstructor
class SignupService {
private final UserRepository users;
private final PasswordPolicy policy;
private final PasswordHasher hasher;
@Transactional
public void signup(SignupRequest req) {
if (!req.getPassword().equals(req.getPasswordConfirm())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호 확인이 일치하지 않습니다.");
}
policy.validate(req.getPassword(), req.getEmail());
String encoded = hasher.encode(req.getPassword());
User u = new User(req.getEmail(), encoded);
users.save(u);
}
}
4) 비밀번호 정책 구현(길이/문자/금지 목록/유출 여부)
비밀번호 정책은 사용자 경험과 보안의 균형이 중요합니다. 무작정 특수문자 나열보다 최소 길이와 유출 여부 체크가 효과적입니다. 다음은 실무에서 쓸 수 있는 밸런스 예시입니다.
@Component
public class PasswordPolicy {
private static final int MIN = 10; // 최소 10자 이상 권장
private static final Pattern UPPER = Pattern.compile("[A-Z]");
private static final Pattern LOWER = Pattern.compile("[a-z]");
private static final Pattern DIGIT = Pattern.compile("[0-9]");
private static final Pattern SPECIAL = Pattern.compile("[^A-Za-z0-9]");
// 흔한 금지 패턴/리스트 (서비스 특성에 맞게 확장)
private static final List<String> COMMON = List.of(
"password","qwerty","abc123","111111","iloveyou","admin"
);
public void validate(String raw, String emailOrId) {
if (raw == null || raw.length() < MIN) throw bad("비밀번호는 최소 " + MIN + "자 이상이어야 합니다.");
if (COMMON.stream().anyMatch(c -> raw.toLowerCase().contains(c))) throw bad("너무 흔한 비밀번호입니다.");
if (emailOrId != null && raw.toLowerCase().contains(emailOrId.split("@")[0].toLowerCase())) {
throw bad("아이디/이메일을 포함할 수 없습니다.");
}
// 문자 조합: 대문자/소문자/숫자/특수 중 3가지 이상 포함
int kinds = 0;
if (UPPER.matcher(raw).find()) kinds++;
if (LOWER.matcher(raw).find()) kinds++;
if (DIGIT.matcher(raw).find()) kinds++;
if (SPECIAL.matcher(raw).find()) kinds++;
if (kinds < 3) throw bad("대문자/소문자/숫자/특수문자 중 3가지 이상 조합하세요.");
}
private ResponseStatusException bad(String msg) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, msg);
}
}
유출 비밀번호(breached passwords) 검사는 외부 목록(k-익명 API 또는 내부 블룸필터)로 보완할 수 있습니다. 트래픽/프라이버시를 고려해 서버측 배치·캐시 전략으로 운영하세요.
5) 로그인/변경 시 자동 재해시(업그레이드)
라운드를 올리거나 알고리즘을 바꿀 때 기존 해시를 일괄 재계산하기 어렵습니다. 로그인 성공 시 lazy rehash를 적용하면 자연스럽게 최신 포맷으로 전환됩니다.
@Service
@RequiredArgsConstructor
class LoginService {
private final UserRepository users;
private final PasswordHasher hasher;
private final PasswordUpgrader upgrader;
public User login(String email, String rawPassword) {
User u = users.findByEmail(email).orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
if (!hasher.matches(rawPassword, u.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
// 해쉬 포맷/강도 체크 후 필요 시 재해시
if (upgrader.needRehash(u.getPassword())) {
u.setPassword(hasher.encode(rawPassword));
users.save(u);
}
return u;
}
}
@Component
class PasswordUpgrader {
boolean needRehash(String encoded) {
// 예: {bcrypt} 이지만 라운드가 10 미만이면 재해시
if (!encoded.startsWith("{bcrypt}")) return true;
// BCrypt 해시에서 라운드(cost) 파싱 (형식: $2a$12$...)
try {
int cost = Integer.parseInt(encoded.split("\\$")[2]);
return cost < 12;
} catch (Exception e) {
return true;
}
}
}
6) 비밀번호 변경/재설정(Reset) 설계
- 이메일/문자 기반 일회용 토큰으로 재설정(유효시간 10~30분, 1회 사용 후 폐기)
- 성공 시 모든 세션 무효화(서버 세션/리프레시 토큰 폐기)
- 최근 N개 비밀번호 재사용 금지(해시만 저장하여 비교)
@Service
@RequiredArgsConstructor
class PasswordResetService {
private final ResetTokenRepo tokens;
private final PasswordHasher hasher;
private final UserRepository users;
private final PasswordHistoryRepo history;
@Transactional
public void reset(String token, String newPassword) {
ResetToken t = tokens.consume(token); // 유효성/만료/1회성 확인
User u = users.findByEmail(t.getEmail()).orElseThrow();
// 최근 5개 내 재사용 금지
if (history.recent(u.getId(), 5).stream().anyMatch(h -> hasher.matches(newPassword, h.getHash()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "최근에 사용한 비밀번호입니다.");
}
String enc = hasher.encode(newPassword);
history.save(new PasswordHistory(u.getId(), enc));
u.setPassword(enc);
users.save(u);
// TODO: 세션/리프레시 토큰 무효화
}
}
7) 프런트 UX: 강도측정/타이포 방지
- 실시간 강도 게이지(길이 + 문자 다양성)로 좋은 비밀번호 유도
- CapsLock 경고, 보이는/숨기는 토글, 붙여넣기 허용(관리자 정책에 따라)
- “문자 요구사항”을 제출 전 즉시 검증하여 재시도를 줄임
8) 운영 체크리스트
- BCrypt 라운드(비용) 수치가 서버 CPU/로그인 TPS 기준에 적정한가? (부하 테스트로 결정)
- Pepper는 환경변수/시크릿으로 관리되고 로테이션 전략이 있는가?
- 비밀번호 정책이 과도하지 않으면서도 유출 리스트/금지 패턴을 반영하는가?
- 재설정/변경 시 세션/토큰이 무효화되는가?
- 로그인 성공 시 lazy rehash로 최신 포맷으로 전환되는가?
⏩ 다음 글 보기: AuthenticationProvider & UserDetailsService 커스터마이징
👉 1편: SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기
👉 2편: PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책
👉 3편: AuthenticationProvider & UserDetailsService 커스터마이징
👉 4편: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리
👉 5편: JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략
👉 6편: OAuth2 로그인(Google/GitHub) + JWT 브릿지
👉 7편: 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한
👉 8편: CORS·XSS·헤더 보안: SPA/REST 현실 설정
'Java & Spring' 카테고리의 다른 글
| 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리 (0) | 2025.10.12 |
|---|---|
| AuthenticationProvider & UserDetailsService 커스터마이징 (0) | 2025.10.12 |
| SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기 (0) | 2025.10.11 |
| Spring Security 6: JWT 로그인/리프레시 토큰 (0) | 2025.10.08 |
| springdoc-openapi로 API 문서 1분 셋업 (0) | 2025.10.05 |