| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
| 31 |
- build
- add
- spring
- 11650
- 자바
- 우선순위
- Lock
- 투두메이트
- static
- 계수정렬
- Spring Security
- github
- 큐
- Java
- 클론코딩
- 데이터베이스
- Push
- 11651
- 알고리즘
- 인프런
- 백엔드
- 스프링
- 로그인
- gradle
- assertj
- todomate
- 2869
- 정렬
- 백준
- 람다식
- Today
- Total
여러가지 이야기
[Spring Security] 401 에러, PasswordEncoder를 써야하는 진짜 이유 / SecurityContext 내 UsernamePasswordAuthenticationToken의 원리 본문
[Spring Security] 401 에러, PasswordEncoder를 써야하는 진짜 이유 / SecurityContext 내 UsernamePasswordAuthenticationToken의 원리
jimddong 2025. 3. 25. 19:46Spring Security를 토대로 로그인을 구현하기 앞서, 우선 로그인용 필터를 사용한 로직을 구현하려 했다.
SecurityContext 내의 Authentication 객체를 만드는 과정을 구현한 필터를 만드는 것인데, 나는 form 로그인 대신 추후 json으로 로그인하여 JWT를 활용하는 것이 목표였기에 기본적인 로그인 로직만 UsernamePasswordAuthenticationFilter에서 가져와 변형하여 코드를 짰다.
username과 password를 입력받을 LoginRequestDTO, Authentication 객체를 생성하는 LoginFilter, Authentication-UsernamePasswordAuthenticationToken 내의 Principal 객체에서 쓰일 유저의 정보(username, password)를 담을 UserDetails와 UserDetailsService의 구현체 등을 구현했다.

참고로 Authentication 인터페이스에 속하는 UsernamePasswordAuthenticationToken 객체를 만드는 과정을 구현했다.
위 사진처럼 Authentication 객체는 SpringContextHolder 안의 SpringContext에 보관된다.
- SpringContextHolder: 인증된 사용자들의 세부사항을 저장하는 곳, 현재 실행 중인 스레드(Thread)에서 SecurityContext를 관리하는 역할
- SpringContext: Spring Security에서 로그인 후 인증이 완료되면 Authentication 객체가 저장 되는 보관소
- Authentication (인터페이스) 구성 요소 3가지
- Principal: 유저 정보를 담은 객체 = UserDetails 인스턴스 (username, password)
- Credentials: 증명 (비밀번호, 토큰)
- Principal이 올바른지 입증하는 객체, 보통 비밀번호를 저장한다.
- 증명 후에는 유출 방지를 위해 비워짐
- Authorities: 유저의 권한(ROLE) 목록을 저장한다.
- authorities 요소들 = GrantedAuthority(혹은 구현체의) 객체들
(Authentication.getAuthorities()로 구할 수 있음.)
- authorities 요소들 = GrantedAuthority(혹은 구현체의) 객체들
하지만 이후 코드를 실행시켜 Postman으로 로그인을 시도하니 처음에는 다음과 같은 에러가 떴다.

401 Unauthorized 에러가 뜬 것이다.
401 에러란 즉슨... 인증이 되지 않았다는 것인데, 코드를 디버깅해보니 Authentication 객체가 아예 생기지도 않았었다.
이유가 뭘까?

PasswordEncoder
PasswordEncoder는 비밀번호를 암호화하는 기능을 하는 인터페이스로, 해시함수를 사용해 encode()를 할 수 있다. 또한 encoded 된 비밀번호와 요청으로부터 전달받은 비밀번호가 일치하는지 확인하는 matches()나 추가적인 암호화 메서드도 사용할 수 있다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
이와 같은 내용으로 SecurityConfig 클래스 안에 빈 등록하여 PasswordEncoder로 쓸 수 있다. (bcrypt 사용)
하지만 앞선 코드에서 나는 회원 가입 로직에서 다음과 같이 PasswordEncoder를 일절 사용하지 않았었다.
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
@Override
public Member createMember(MemberRequestDTO.JoinDTO joinDTO) {
Member member = MemberConverter.toMember(joinDTO);
return memberRepository.save(member);
}
}
일단 회원가입 된 유저가 로그인을 시도하면 username과 password를 받아 Authentication 객체를 생성시켜 로그인이 되는 과정을 만들어보고 싶은 마음이 앞섰다. 하지만 이내 이 과정에서 반드시 PasswordEncoder가 필요함을 깨달았다. 왜지? 단순히 보안을 위해서?
사실 핵심은 바로 내가 Authentication 객체로 사용한 UsernamePasswordAuthenticationToken을 발급하기 위한 과정에 있었다.
LoginFilter 코드
@Getter
public class LoginRequestDTO {
private String email;
private String password;
}
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
LoginRequestDTO loginRequestDTO = readBody(request);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(loginRequestDTO.getEmail(),
loginRequestDTO.getPassword());
return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
}
public LoginRequestDTO readBody (HttpServletRequest request) {
ObjectMapper om = new ObjectMapper();
try {
return om.readValue(request.getInputStream(), LoginRequestDTO.class);
} catch (IOException e) {
throw new AuthHandler(ErrorStatus._BAD_REQUEST);
}
}
}
내가 짠 코드는 위와 같았다. (username과 password를 json으로 받아 이를 토대로 인증 객체를 만드는 과정, JWT 구현 제외)
내가 로그인 필터를 구현할 때 참고한 메서드는 UsernamePasswrdAuthenticationFilter의 attemptAuthentication()이다.
LoginFilter를 보면 클라이언트 요청 request를 토대로 username(email), password를 필드로 하는 LoginRequestDTO 클래스의 객체로 변환했다. 이 내용을 토대로 UsernamePasswordAuthentiationToken을 만든다.

이때 코드와 그림에서도 볼 수 있듯이, AuthenticationManager가 관여한다. 이 안의 DaoAuthenticationProvider라는 인터페이스는 인증 객체인 UsernamePasswordAuthenticationToken을 처리하는데, 여기서 PasswordEncoder가 필요했던 이유를 파악할 수 있다.
자세히 알아보자!
AuthenticationManager의 원리
AuthenticationManager
SecurityContextHolder에 있는 Authentication이 나오게끔 혹은 만들어지게끔 인증을 도와주는 인터페이스다.
❗️ 반드시 SecurityConfig에 AuthenticationManager 등록해 주기!
→ 그렇지 않으면 Spring Security가 SecurityContext 내에 있는 Authentication 객체를 인증 처리해 줄 AuthenticationManager가 선언되지도 않았기에, 작동할 수도 없고 Authentication(- 안의 Principal - 안의 UserDetails까지 모두)이 생기지 않는다.
* 만약 AuthenticationManager를 등록해주지 않으면, 직접 구현한 UserDetailsService(PrinicipalDetailsService)를 호출할 수 없기에 결국 인증 과정을 처리할 수도 없음 → SecurityContext에 Authentication 객체가 만들어지지 않아 에러
AuthenticationManager의 가장 흔한 구현체는 ProviderManager이다.

ProviderManager는 여러 AuthenticationProviders를 List로 선언한다.
AuthenticationManager가 모든 AuthenticationProvider를 반복문으로 돌려 인증을 확인한다.
DaoAuthenticationProvider
위 AuthenticationProvider의 구현체 중 하나가 DaoAuthenticationProvider다. DaoAuthenticationProvider는 UsernamePasswordAuthenticationToken을 처리한다.

사진에서 볼 수 있듯이 username과 password를 인증화하기 위해 DaoAuthenticationProvider는 UserDetailsService와 PasswordEncoder(필수!!)를 사용한다.
그런데 만약 회원 가입 코드에서 PasswordEncoder를 사용하지 않았다면, 회원 가입 시 입력된 비밀번호가 그대로 데이터 베이스에 저장될 것이다. DaoAuthenticationProvider는 PasswordEncoder.matches()를 사용해 비밀번호 검증을 수행하는데, 데이터베이스에 저장된 값이 암호화되지 않은 상태라면 비교에 실패하게 된다. 따라서 비밀번호 검증이 실패하여 인증 객체(Authentication)가 생성되지 않고 로그인도 실패한다.
DaoAuthenticationProvider 입장에서는 인증 객체(토큰)를 발급하려는데, 요청으로 받은 password를 PasswordEncoder로 암호화하여 matches()로 비교해보려 해도, 애초에 회원 가입 코드에서 (MemberServiceImpl의 createMember() 참고) 멤버의 비밀번호가 PasswordEncoder.encode()를 통해 암호화가 되지도 않았으니 요청으로 입력 받은 비밀번호와 이가 일치하는 비밀번호인지 비교할 수가 없을 것이다.
결론은 인증 객체(Authentication, UsernamePasswordAuthenticationToken)를 만들어 SecurityContext 내에 저장하고 싶다면 반드시 유저 생성 시 PasswordEncoder로 비밀번호를 반드시 암호화시켜줘야 한다!
회원 가입 로직에 PasswordEncoder로 비밀번호 암호화 과정 추가 (MemberServiceImpl, MemberConverter)
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public Member createMember(MemberRequestDTO.JoinDTO joinDTO) {
Member member = MemberConverter.toMember(joinDTO, passwordEncoder);
return memberRepository.save(member);
}
}
public class MemberConverter {
public static Member toMember (MemberRequestDTO.JoinDTO joinDTO, PasswordEncoder passwordEncoder) {
return Member.builder()
.email(joinDTO.getEmail())
.password(passwordEncoder.encode(joinDTO.getPassword()))
.signUpType(joinDTO.getSignUpType())
.role(joinDTO.getRole())
.build();
}
}
UserDetails, UserDetailsService
참고로 DaoAuthenticationProvider에 의해 사용되는 UserDetailsServices가 username과 password 등을 가진다. UserDetailsService가 반환하는 것이 UserDetails이며, UserDetailsService와 UserDetails 인터페이스를 구현해 주는 PrincipalDetailsService와 PrincipalDetails를 다음과 같이 작성했다.
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
private final Member member;
// 역할들
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<String> roleList = new ArrayList<>();
roleList.add("ROLE_" + member.getRole().name());
Collection<? extends GrantedAuthority> authorities = roleList.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
}
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(() -> {
throw new MemberHandler(ErrorStatus._NOT_FOUND_MEMBER);
});
return new PrincipalDetails(member);
}
}
정리
다시금 정리해 보자면 UsernamePasswordAuthenticationToken이 SecurityContext 내에 보관되는 구체적인 과정은 다음과 같다.
- UsernamePasswordAuthenticaitonFilter 같은 필터(나는 LoginFilter로 상속 받아 구현했다.)는 AuthenticationManager에게 토큰을 건네준다.
- AuthenticationManager의 구현체, ProviderManager, 이것에 의해 선언 된 AuthenticationProvider들의 구현체 DaoAuthenticationProvider
- DaoAuthenticationProvider에서는 UserDetailsService에서 UserDetails(username, password)들을 파악
- PasswordEncoder가 이 UserDetails의 비밀번호를 검증 ( ⇒ UserDetailsService로 사용자 조회, PasswordEncoder로 비밀번호 검증 - 기존의 비밀번호와 비교 시 PasswordEncoder.matches()로)
- 최종적으로 토큰이 필터에 의해 SecurityContextHolder에 설정됨
코드를 짤 때 역시 중요한 건 작동 원리를 알아야 한다는 것을 재차 깨달았다. 그냥 '아 PasswordEncoder 없으면 인증 안 되는구나' 하고 넘어갈 수도 있었겠지만, 왜?라는 질문을 던지면서 Spring Security의 전반적인 구조와 필터의 원리까지 세세하게 살펴볼 수 있었다.
특히 도움 됐던 점은 스프링 공식 문서를 살펴보며 개념을 정리해 가며 공부했다는 것! 교환 학생 다녀오길 잘했다(?)
앞으로도 의문이 생기면 끊임없이 질문하고 탐구하는 자세를 잃지 말자!

참고 자료
'study > Spring' 카테고리의 다른 글
| [Spring] Lock 동시성 해결 중 트랜잭션의 관리, Transaction 원리 (0) | 2026.02.11 |
|---|---|
| [Spring] 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입 (0) | 2026.01.23 |
| [Spring] 예상치 못한 JSON 필드 추가? - Lombok과 Jackson 직렬화 문제 해결 (0) | 2025.02.16 |
| [Spring/클론 코딩] todo mate를 따라 만들어보자(1) - IA와 데이터베이스 ERD 설계 (0) | 2025.01.24 |
| [Spring/Spring Boot] assertj import가 안 될 때 - build.gradle 의존성 충돌 (0) | 2024.10.31 |