카테고리 없음

[Spring Security] 인증 아키텍처, 한 번은 끝까지 따라가보기

imgdevel 2025. 11. 23. 08:32

 

“인증이 실제로 어떻게 흘러가는지”를 구조 관점에서 정리해 본다.

들어가며

Spring Security를 처음 접하면 http.formLogin() 정도의 설정만으로도 로그인과 세션 관리가 동작하기 때문에, 내부 인증 구조를 깊게 볼 필요가 없다고 느끼기 쉽다. 하지만 조금만 복잡한 요구사항이 추가되면(일부 엔드포인트는 JSON 로그인, 일부는 JWT, 나머지는 세션 유지 등) “어디에서 어떤 인증 로직이 실행되는지”, “어떤 필터와 컴포넌트가 관여하는지”를 모르면 디버깅과 설계가 어렵다.

 

이 글은 공식 문서의 인증 아키텍처 다이어그램을 바탕으로, AuthenticationFilter → AuthenticationManager → AuthenticationProvider → UserDetailsService → SecurityContextHolder로 이어지는 인증 흐름을 한 번 끝까지 따라가 보는 것을 목표로 한다.

 

 


 

1.  전체 인증 아키텍처 흐름

 

먼저 큰 그림을 말로 정리해 보자. 아래는 인증 다이어그램을 텍스트로 옮긴 것이다.

  1. 클라이언트가 로그인 요청을 보낸다. (Http Request)
  2. AuthenticationFilter가 요청에서 아이디/비밀번호를 꺼내 UsernamePasswordAuthenticationToken을 만든다.
  3. 이 토큰을 AuthenticationManager에게 넘겨 “인증 좀 해줘”라고 부탁한다.
  4. AuthenticationManager(대표 구현체: ProviderManager)는 등록된 여러 AuthenticationProvider에게 순서대로 물어본다.
  5. 예를 들어 DaoAuthenticationProvider는 내부에서 UserDetailsService를 호출해 사용자 정보를 읽어온다.
  6. UserDetailsService는 DB에서 사용자를 찾고, UserDetails 객체를 만들어 반환한다.
  7. AuthenticationProvider는 비밀번호를 비교하고, 권한을 채워 넣어 인증된 Authentication을 만든다.
  8. 이 인증된 객체를 다시 AuthenticationManager에게 돌려준다.
  9. AuthenticationManager는 성공/실패를 AuthenticationFilter에게 알려준다.
  10. 인증이 성공했다면 AuthenticationFilter는 이 인증 객체를 SecurityContextHolder에 저장한다.

한 줄로 줄이면 이렇다.

요청 → 필터 → 매니저 → 프로바이더 → 유저 조회 → 인증 결정 → SecurityContextHolder 저장

 

이제 이 박스들을 하나씩, 조금 더 자세하게 뜯어보자.

 

 


 

2. 인증 흐름 뜯어보며 이해하기

  1) AuthenticationFilter – 문 앞에서 신분증을 받는 경비원

AuthenticationFilter“요청에서 자격 증명을 꺼내는 역할”만 한다. 폼 로그인이라면 이렇게 생겼을 것이다.

String username = request.getParameter("username");
String password = request.getParameter("password");

Authentication authRequest =
    new UsernamePasswordAuthenticationToken(username, password);

 

이 시점의 Authentication은 아직 미인증 상태다. 경비원 입장에서는 “신분증을 받아 들고 경비실로 전달하는 정도”의 역할이다.

여기서 중요한 포인트는 두 가지였다.

  • 필터는 비밀번호 검증을 하지 않는다.
  • 필터는 오직 Authentication을 만들어 AuthenticationManager에게 던지는 역할만 한다.

 

 2) AuthenticationManager & ProviderManager – 일을 분배하는 보안팀장

AuthenticationManager는 인터페이스고, 실제 구현체는 대부분 ProviderManager다.
나는 이 둘을 “보안팀장”이라고 생각한다.

  • 문 앞 경비원(필터)이 “이 사람 좀 확인해 달라”고 신분증을 건네면
  • 팀장은 여러 명의 “전문 심사관(AuthenticationProvider)”에게 순서대로 일을 맡긴다.

ProviderManager의 핵심 로직은 정말 단순하다.

  1. 등록된 AuthenticationProvider 목록을 순회하면서
  2. supports()로 “이 토큰 타입 처리할 수 있어?”를 물어보고
  3. 할 수 있으면 authenticate()를 호출해본다.

즉, ProviderManager“하나의 애플리케이션에 여러 인증 방식을 동시에 붙일 수 있게 해주는 허브”다.

  • 아이디/비밀번호 로그인 → DaoAuthenticationProvider
  • JWT 토큰 → JwtAuthenticationProvider
  • 소셜 로그인 → OAuth2LoginAuthenticationProvider

 

3) AuthenticationProvider – 실제로 신분을 확인하는 심사관

AuthenticationProvider“인증 로직이 실제로 들어가는 핵심”이다.

  • supports()로 내가 처리할 수 있는 토큰인지 확인하고
  • authenticate()에서 비밀번호를 비교하거나, JWT를 검증하거나, 외부 서버를 호출한다.

예를 들어 아이디/비밀번호 로그인을 담당하는 DaoAuthenticationProvider는 대략 이런 흐름이다.

  1. UserDetailsService로 사용자를 조회한다.
  2. PasswordEncoder로 비밀번호를 비교한다.
  3. 성공하면 권한이 포함된 인증된 Authentication을 만들어 반환한다.

JWT용 Provider라면 이렇게 바뀔 뿐이다.

  1. 토큰 서명을 검증한다.
  2. 토큰 안에서 사용자 ID와 권한을 꺼낸다.
  3. 세션 없이 Authentication을 만들어 반환한다.

“인증 규칙이 복잡해지면 어떻게 하지?”라는 질문에 대한 Spring Security의 답이 바로 이 부분이다.
규칙이 바뀌어도 Provider 하나만 갈아 끼우면 된다.

 

 

4) UserDetailsService & UserDetails – 사용자 정보를 찾아오는 사원 DB 담당자

UserDetailsService는 “아이디 하나를 받아 사용자 정보를 찾아오는 역할”이다.

  • 우리 회사 DB에서는 User 엔티티로 저장되어 있고
  • Spring Security는 UserDetails라는 인터페이스를 원하니
  • 그 둘 사이를 어댑터처럼 이어주는 계층이다.

이 부분을 직접 구현해 보면 다음과 같은 점을 확인할 수 있다.

  1. 도메인 모델과 보안 모델을 분리할 수 있다.
    (User 엔티티는 비즈니스 용도, UserDetails는 보안 용도)
  2. 사용자 저장소가 변경되더라도 (RDB → LDAP → 외부 API)
    UserDetailsService만 교체하면 나머지 인증 흐름은 그대로 재사용할 수 있다.

 

5) SecurityContextHolder – 현재 인증 정보를 보관하는 저장소

마지막으로, 모든 인증 결과가 모이는 곳이 SecurityContextHolder다.

  • 성공한 Authentication은 여기 저장된다.
  • 서비스/컨트롤러 어디에서든 SecurityContextHolder.getContext().getAuthentication()으로 조회할 수 있다.
  • 기본적으로 ThreadLocal에 저장된다.

요청 처리 스레드의 생명 주기와 함께 SecurityContext가 생성되고 정리된다는 점이 중요하다.

  • 요청 스레드가 끝나면 SecurityContext는 정리되어야 한다.
  • 새로운 스레드에서 인증 정보를 사용하려면 의도적으로 복사해야 한다.
  • 테스트 코드에서는 SecurityContextHolder를 직접 세팅해 시나리오를 구성할 수 있다.

 


 

3. 로그인 요청 흐름 끝까지 따라가 보기

이제 실제로 로그인 요청 하나가 그림을 어떻게 타고 흐르는지, 조금 더 구체적으로 따라가 보자.

① 클라이언트가 로그인 요청을 보낸다

POST /login
Content-Type: application/x-www-form-urlencoded

username=devon&password=1234

폼 로그인이라면, 이 요청은 UsernamePasswordAuthenticationFilter에 걸린다.

 

② 필터가 Authentication을 만든다

필터는 요청 파라미터에서 아이디/비밀번호를 꺼내
이렇게 UsernamePasswordAuthenticationToken을 만든다.

Authentication authRequest =
    new UsernamePasswordAuthenticationToken(username, password);

아직은 미인증 상태다. isAuthenticated() == false.

 

③ AuthenticationManager에게 “이 사람 좀 확인해줘”라고 맡긴다

Authentication authResult =
    this.authenticationManager.authenticate(authRequest);

여기서부터는 필터의 영역이 아니라,
보안팀장(ProviderManager)의 영역이다.

 

④ ProviderManager가 적절한 Provider를 찾는다

authRequest의 타입이 UsernamePasswordAuthenticationToken이므로,
supports(UsernamePasswordAuthenticationToken.class)true를 반환하는 Provider를 찾는다.

대부분의 경우 DaoAuthenticationProvider가 선택된다.

 

⑤ UserDetailsService로 사용자 정보를 읽어온다

DaoAuthenticationProvider는 내부에서 다음과 같은 일을 한다.

  1. userDetailsService.loadUserByUsername(username) 호출
  2. DB에서 해당 사용자를 찾는다.
  3. 찾았다면 UserDetails로 변환해 반환한다.

이 단계에서 “사용자를 못 찾았다면” UsernameNotFoundException이 터진다.

 

⑥ 비밀번호를 비교하고 인증을 완료한다

UserDetails에 담겨 있는 비밀번호(보통 해시된 값)와 사용자가 보낸 비밀번호를 PasswordEncoder로 비교한다.

일치하면: 권한을 채워 넣은 새로운 Authentication을 만들어 반환

불일치하면: BadCredentialsException 발생

중요한 점은, 인증에 성공한 이후에는 비밀번호를 보통 null로 만든다는 것이다. (ProviderManager가 자격 증명을 지워준다.)

 

⑦ 인증이 성공하면 SecurityContextHolder에 저장한다

필터는 최종적으로 다음과 같은 일을 한다.

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);

 

이제 이 요청을 처리하는 동안, 서비스/컨트롤러 어디에서든 SecurityContextHolder를 통해 현재 사용자의 정보를 확인할 수 있다.

 


 

4. 이해한 흐름으로 실제 프로젝트에 적용하기

이제부터는 앞에서 정리한 구조를 실제 프로젝트에 어떻게 적용했는지 관점에서 살펴본다.

우선 우리가 구현해야할 부분을 살펴보기 위해 인증 흐름을 다시 한번 봐보자.

인증 흐름에서 우리가 구현할 부분은 다음과 같이 간단하다.

1. Authertication Filter

2. UserDetailsService

3. CustomUserDetails (=UserDetatils 구현체)

 

5-1. AutherticationFilter

@RequiredArgsConstructor
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Authentication attemptAuthentication(
    			HttpServletRequest request, HttpServletResponse response
           			) throws AuthenticationException {
        try {
            // request 내용을 prase 
            String requestBody = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
            LoginRequest loginRequest = objectMapper.readValue(requestBody, LoginRequest.class);

            String username = loginRequest.email();
            String password = loginRequest.password();
			
            // UsernamePasswordAuthenticationToken 생성
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    username, password);
                    
            // authenticationManager에게 전달한 후 결과 수신
            return authenticationManager.authenticate(authenticationToken);

        } catch (Exception e) {
        	// 예외 처리 ...
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        // 로그인 과정이 성공했을 때 처리 로직
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        // 로그인 과정이 실패했을 때 처리 로직
    }


}

 

일반적으로 AutherticationFilter 중 가장 많이 사용되는 UsernamePasswordAuthenticationFilter를 상속 받아 사용한다.

해당 커스텀 필터에서는 다음과 같은 역활을 해야한다.


(1)AutherticationFilter
에서는 Json 객체를 풀어서 (2)UsernamePasswordAuththicationToken으로 묶어서 (3)AuthenticationManager에게 전달 또한 (9)AuthenticationManager으로 부터 인증 결과를 받아 처리한다. 

  • 자격 증명을 가져오는 방식: form-data → JSON
  • 나머지 인증 처리: 기존과 동일하게 AuthenticationManager, AuthenticationProvider, UserDetailsService가 담당

 

5-2. UserDetailsService

@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));

        if (!member.isActive()) {
            throw new DisabledException("비활성화된 계정입니다");
        }

        return new CustomUserDetails(member);
    }
}

UserDetailsService를 도메인에 맞게 정의한다.

이 단계에서는 우리가 들고 있는 Member와 Security가 이해할 수 있는 UserDetails 객체로 변환해주는 작업을 거친다.

이 작업만으로도 다음과 같은 효과를 얻을 수 있었다.

  • 서비스에서 사용하는 권한의 의미를 팀 차원에서 다시 정의할 수 있었고
  • 도메인 엔티티와 보안 모델을 분리해 관리할 수 있었다.

 

3. CustomUserDetails

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final Member member;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }   
    // 그 외 구현체들...
}

이제 우리의 사용자 정보인 Member를 Spring Security가 이해할수 있는 객체 UserDetails 형태로 만들어줘야 한다.

UserDetails을 구현하여 CustomUserDetails를 만들어 매필 해주는 단계가 된다. 생략 되었지만 나머지 구현체도 만들어 주어야한다.

 

 

4. SecurityConfig에 Filter 등록

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthenticationConfiguration authenticationConfiguration;
    // 다른 코드들...
    
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
				
                // 다른 Security 설정 들...
                
                /* 커스텀 로그인 필터 추가 (Bean으로 등록하지 않고 직접 생성) */
                .addFilterAt(
                        new LoginAuthenticationFilter(authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class
                )
                
                // 다른 Security 설정 들...
        ;

        return http.build();
    }
    
    // ...
}

 

위와 같이 `addFilterAt` 메서드를 사용하여 우리가 정의한 커스텀 Filter를 UsernamePasswordAuthenticationFilter 자리에 대체한다. 

 

 


 

6. SecurityContextHolder를 직접 만지면서 배운 것들

실제 개발 과정에서는 SecurityContextHolder를 직접 다루어야 하는 상황도 자주 발생한다.

  • 테스트 코드에서 특정 사용자로 로그인된 상태를 만들 때
  • 스케줄러에서 “시스템 계정”으로 작업을 수행할 때
  • 다른 스레드로 인증 정보를 전달해야 할 때

예를 들어 다음과 같이 사용할 수 있다.

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

비동기 작업과 스케줄러를 사용하면서 다음과 같은 부분을 특히 신경 쓰게 되었다.

  1. ThreadLocal 기반이라는 점을 항상 고려해야 한다.
    요청이 끝난 뒤 clearContext()가 호출되지 않으면,
    쓰레드 풀에서 재사용된 스레드가 이전 요청의 인증 정보를 유지할 수 있다.
  2. 스케줄러나 배치 작업에서 인증을 모사할 때는
    “어떤 주체가 어떤 권한으로 작업하는지”를 명확히 표현하는 것이 중요하다.
    (예: SYSTEM이라는 principal과 ROLE_SYSTEM 권한)
  3. 테스트 코드에서 SecurityContext를 직접 세팅해 보면
    애플리케이션이 “현재 사용자” 정보에 의존하는 지점을 명확히 확인할 수 있다.

 


 

7.  결론

처음 Spring Security를 사용할 때는, “어려운 일을 대신해주지만, 내부를 수정하기는 부담스러운 프레임워크” 에 가깝게 느껴지는 경우가 많다.

하지만 인증 아키텍처 다이어그램을 기준으로 HTTP 요청 하나가 필터 → 매니저 → 프로바이더 → 서비스 → 컨텍스트로 흐르는 과정을 끝까지 따라가 보면, 관점이 조금 달라진다.

  • AuthenticationFilter자격 증명을 꺼내기만 한다.
  • AuthenticationManager/ProviderManager여러 인증 방식을 조율한다.
  • AuthenticationProvider실제 인증 규칙을 담고 있다.
  • UserDetailsService우리 도메인과 보안 모델을 연결한다.
  • SecurityContextHolder현재 요청이 누구의 이름으로 실행되는지 기록한다.

이 역할들을 기준으로 보면, JSON 로그인, 소셜 로그인, JWT, 세션 혼합과 같은 요구사항도 어느 계층에서 처리해야 하는지 비교적 선명하게 구분할 수 있고 로그를 확인할 때도 “어떤 필터에서 Authentication이 어떻게 변하고 있는지”를 따라가기가 훨씬 수월하다.

실제 프로젝트에서 사용하는 URL 하나를 골라서,

  1. 어떤 필터가 자격 증명을 추출하는지,
  2. 어떤 Provider가 인증을 처리하는지,
  3. 최종 Authentication이 어떤 형태로 SecurityContextHolder에 저장되는지,

로그와 코드를 통해 끝까지 추적해 보면 인증 아키텍처에 대한 이해가 한층 선명해진다. 그 이후에는 Spring Security를 “건드리면 위험한 검은 상자”라기보다, 이해한 설계 위에서 필요한 부분만 선택적으로 확장할 수 있는 도구로 바라보기 쉬워진다.

 


8. Reference