담비의 개발블로그

[Spring Boot]PasswordEncoder 본문

언어&프레임워크/Spring&Spring Boot

[Spring Boot]PasswordEncoder

담비12 2025. 11. 3. 21:40
PasswordEncoder

 

PasswordEncoder는 단방향 해싱과 솔팅을 사용한다.

 

◆ 단방향 해싱 (One-Way Hashing)
해싱은 입력된 원본 값을 기반으로 복잡한 계산을 거쳐 고정된 길이의 문자열(해시)을 만든다. 

 

만약 사용자가 "1234"라는 비밀번호로 회원가입을 할 때, DB에 "1234"라고 그대로 저장했다고 가정해보자.

이때 DB가 해킹당하게 된다면 해커가 모든 회원의 아이디와 비밀번호 "1234"를 알아내게 된다. 이 문제를 해결하기 위해, "1234"를 아무도 알아볼 수 없는 복잡한 문자열(예: $2a$10$N9qo8uLO...)로 변환해서 저장한다. 이 '변환'을 해싱(Hashing)이라고 부르며, 이 역할을 하는 도구가 바로 PasswordEncoder이다.

솔팅 (Salting)

솔팅은 원본 비밀번호에 '소금(Salt)'처럼 임의의 랜덤 값을 추가하여 해싱하는 기법이다.
해싱은 동일한 입력값에 대해 항상 동일한 출력값을 반환한다. (예: 1234 -> abcd) 만약 해커가 미리 계산된 해시 값들의 목록(레인보우 테이블)을 가지고 있다면, 저장된 해시 값을 비교하여 원본 비밀번호를 유추할 수 있다. 

예시:
(Bad) 1234 -> abcd
(Good) 1234 + random_salt_1 -> zxyw
(Good) 1234 + random_salt_2 -> qwer (다른 사용자가 같은 비번을 써도 다르게 저장됨)
BCrypt와 같은 최신 인코더는 이 Salt 값을 해시 결과물에 자동으로 포함시켜 관리하므로 개발자가 솔트를 별도로 저장할 필요가 없다.

 

 

PasswordEncoder의 핵심 메서드
1. String encode(CharSequence rawPassword)
회원가입시 사용된다. 사용자가 입력한 원본 비밀번호(rawPassword)를 받아, 솔팅(Salting) 및 해싱(Hashing)을 수행한 후, 암호화된 문자열을 반환한다. 이 값을 DB에 저장한다.

2. boolean matches(CharSequence rawPassword, String encodedPassword)
로그인 시 사용된다. 사용자가 로그인 시 입력한 원본 비밀번호(rawPassword)와 DB에 저장된 해시된 비밀번호(encodedPassword)를 비교한다. (이 메서드는 encodedPassword를 복호화하는 것이 아니다.)
encodedPassword에서 Salt 값을 추출한다. rawPassword에 그 Salt 값을 더해 다시 해싱한다. 그 결과가 encodedPassword와 일치하는지 비교하여 true / false를 반환한다.

 

스프링 부트에서 사용하기
스프링 시큐리티에서는 BCryptPasswordEncoder를 가장 널리 권장한다. BCrypt는 비밀번호 해싱을 위해 설계된 검증된 알고리즘이며, 자동으로 솔팅을 처리해 준다.

 

 

1. html 코드

<form th:action="@{/login-process}" method="post">
    <div>
        <label>아이디:</label>
        <input type="text" name="username"> </div>
    <div>
        <label>비밀번호:</label>
        <input type="password" name="password"> </div>
    <button type="submit">로그인</button>
</form>

 

 

 

2. Controller 코드

@Controller
public class PageController {

    // 1. 로그인 '페이지' 보여주기 (GET)
    @GetMapping("/login")
    public String showLoginPage() {
        return "login"; // login.html
    }

    // 2. 로그인 '처리' (POST) 
    // ... 가 없음!!!!
    // @PostMapping("/login-process") 같은 코드를 만들지 않음!!
}

 

 

 

3. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    // UserDetailsServiceImpl을 주입받는다.
    private final UserDetailsService userDetailsService; 

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/login", "/register").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                // 1. 로그인 페이지 주소 (PageController의 @GetMapping)
                .loginPage("/login") 
                // 2. [!! 여기가 핵심 !!]
                // HTML의 <form action="/login-process">가
                // 'POST'로 요청을 보내면,
                // 스프링 시큐리티가 이 요청을 "가로채서" 알아서 처리한다.
                .loginProcessingUrl("/login-process") 
                // 3. 로그인 성공 시 이동할 곳
                .defaultSuccessUrl("/", true) 
                .permitAll()
            );
        return http.build();
    }
}

 

 

4. LoginService 대신 UserDetailsServiceImpl

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;
    
    // (PasswordEncoder가 여기엔 없ek!)

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 4. 스프링 시큐리티가 ID만 넘겨주면서 "사용자 정보 줘"라고 호출
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("..."));

        // 5. DB의 '암호화된 비번'을 그대로 전달
        return new User(user.getUsername(), user.getPassword(), ...);
    }
}