Spring Security의 PasswordEncoder를 사용해 회원가입 시 비밀번호를 안전하게 해시하여 저장하는 방법을 다룹니다.
1. 왜 비밀번호를 해시해야 할까?
비밀번호를 평문(plain text)으로 저장하면, DB가 탈취됐을 때 모든 사용자의 비밀번호가 그대로 노출됩니다.
해시(hash) 는 단방향 암호화입니다. 한 번 변환하면 원래 값으로 되돌릴 수 없습니다. 그래서 DB가 털려도 공격자는 원본 비밀번호를 알 수 없습니다.
- 암호화(Encryption): 양방향. 키가 있으면 원문 복원 가능
- 해시(Hash): 단방향. 원문 복원 불가
비밀번호는 반드시 해시로 저장해야 합니다. 암호화로 저장하면 키가 유출됐을 때 전체 비밀번호가 복원됩니다.
2. BCrypt란?
Spring Security가 기본으로 권장하는 해시 알고리즘입니다.
BCrypt의 특징:
- 같은 비밀번호를 해시해도 매번 다른 결과가 나옵니다. (내부적으로
salt를 자동 생성하기 때문) cost factor(강도)를 조절할 수 있어 서버 성능에 맞게 느리게 만들 수 있습니다. 느릴수록 brute force 공격에 강합니다.
BCrypt는 해시 결과 안에 salt 정보를 포함시킵니다. 그래서 matches() 메서드가 salt를 꺼내 동일한 방식으로 재해시하여 비교합니다.
3. 의존성 추가
Spring Security가 이미 추가되어 있다면 PasswordEncoder는 별도 의존성 없이 사용할 수 있습니다.
build.gradle에 다음이 있으면 됩니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
4. PasswordEncoder 빈 등록
PasswordEncoder는 인터페이스입니다. BCrypt 구현체를 빈으로 등록해줘야 합니다.
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Configuration: 이 클래스가 스프링 설정 클래스임을 선언@Bean: 반환값을 스프링 컨테이너에 빈으로 등록BCryptPasswordEncoder: BCrypt 알고리즘을 사용하는PasswordEncoder구현체
별도 클래스를 만들지 않고 기존 SecurityConfig 안에 @Bean 메서드로 추가해도 됩니다.
5. Service에서 비밀번호 해시 적용
BasicUserService에 PasswordEncoder를 주입하고, 저장 전에 해시합니다.
회원가입 (signIn)
@Service
@RequiredArgsConstructor
public class BasicUserService implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; // 추가
@Override
public UserDto signIn(UserCreateRequest dto, MultipartFile profile) {
String encodedPassword = passwordEncoder.encode(dto.password()); // 해시
User user = new User(dto.username(), encodedPassword, dto.email()); // 해시된 값 저장
// ...
}
}
passwordEncoder.encode(dto.password()): 평문 비밀번호를 BCrypt로 해시- 해시된 값을
User생성자에 전달하여 저장
비밀번호 변경 (update)
if (dto.newPassword() != null) {
user.setPassword(passwordEncoder.encode(dto.newPassword())); // 해시 후 저장
}
비밀번호 변경 시에도 반드시 encode()를 거쳐야 합니다. 빠뜨리면 변경된 비밀번호만 평문으로 저장됩니다.
6. 비밀번호 검증 (로그인 시)
로그인할 때는 사용자가 입력한 평문과 DB의 해시값을 비교해야 합니다.
boolean isMatch = passwordEncoder.matches(rawPassword, encodedPassword);
rawPassword: 사용자가 입력한 평문 비밀번호encodedPassword: DB에 저장된 해시값- 반환값이
true면 비밀번호 일치
```java
// 틀린 방법
user.getPassword().equals(inputPassword)
// 올바른 방법
passwordEncoder.matches(inputPassword, user.getPassword())
```
해시값은 항상 다르게 나오므로 equals()로 비교하면 절대 일치하지 않습니다.