[개발지식] Web Application의 GateKeeper #8 - ThreadLocal에 임시로 저장하는 SecurityContext를 통해 인증정보의 반영구적 지속이 가능하게 하는 과정 분석(*SecurityContextRepository/SecurityContextHolderFilter/MVC login)

Hyo Kyun Lee·2025년 11월 26일

개발지식

목록 보기
109/131

1. 개요

일전에 Thread 요청 별로, 해당 요청에 대한 인증을 처리하여 Authentication 인증정보를 Security Context에 저장하고, Thread Local에 저장하기에 요청이 사라지면 Security Context에 저장된 인증정보는 사라진다고 하였다.

이 순간에는 사라지지만, 최초 인증처리 시점에서 Session 등에 해당 내용을 저장하여, 인증정보를 그대로 재활용하여 사용하기에 인증정보를 유지할 수 있다고 하였다.

ThreadLocal에 저장하는 SecurityContext를 보면 인증상태를 유지하는 것이 맞나라는 의문점이 들 수 있는데, 이전의 인증처리 시 생성한 Session을 통해 정보를 가져오고 이를 SecurityContext에 저장하여 사용하기에 반영구적인 인증정보 지속이 가능하다는 점을 기억해야 한다.

이러한 점에 유의하면서 SecurityContext를 중심으로, 인증정보가 어떻게 유지가 되는지 그 과정에 대해 분석해보았다.

2. SecurityContext는 영구적이지 않지만 인증정보를 지속가능한 이유

앞서 최초 인증 요청, 인증이 성공한 이후에도 인증정보를 SecurityContext에 반드시 저장한다.

다만, 최초 인증 시도 시 SecurityContext에 저장하며 이를 SecurityContextRepository를 통해 HttpSession에 해당 SecurityContext를 또 저장한다.

이 과정이 존재하기에 인증 성공 이후에 요청이 또 올때마다, HttpSession에 저장된 SecurityContext를 확인하여 이를 가져오기 때문에 재활용이 가능한 것이다.

최종적으로 HttpSession을 통해 가져온 인증컨텍스트를 다시 SecurityContext에 저장하여 반영구적인 활용, 인증상태의 지속이 가능한 것이다.

참고로 HttpSession에 저장하기 위해선 개발자가 반드시 명시적으로, SecurityContextRepository를 통해 HttpSession에 SecurityContext를 저장하는 구현을 해주어야 한다.

이에 대한 흐름을 정리하면 아래와 같다.

[1] 최초 요청 (로그인 시도)
      ↓
[2] AuthenticationManager 인증 처리
      ↓ (성공)
[3] SecurityContext 생성 → 인증객체(Authentication) 저장
      ↓
[4] SecurityContextRepository.saveContext()
      → HttpSession에 SecurityContext 저장
      ↓
-------------------------------------------
   이후 요청부터
-------------------------------------------
[5] 새로운 요청 도착
      ↓
[6] SecurityContextRepository.loadContext()
      → HttpSession에서 기존 SecurityContext 조회
      ↓
[7] 조회된 SecurityContext를 SecurityContextHolder에 다시 저장
      ↓
[8] 인증 상태 유지된 채로 요청 처리 가능

2-1. SecuirtyContextRepository

이러한 인증유지를 가능하게 해주는 인터페이스가 바로 SecurityContextRepository이다.

이 인터페이스를 들여다보면 아래와 같다.

public interface SecurityContextRepository {
	default DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
		Supplier<SecurityContext> supplier = () -> loadContext(new HttpRequestResponseHolder(request, null));
		return new SupplierDeferredSecurityContext(SingletonSupplier.of(supplier),
				SecurityContextHolder.getContextHolderStrategy());
	}
    
    void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response);
    
    boolean containsContext(HttpServletRequest request);
    
}

말그대로, containsContext는 보안컨텍스트의 조회, saveContext는 인증요청완료 후 보안컨텍스트를 저장, loadDeferredContext는 SecurityContext를 추출해온다.

하지만 이것보다 중요한 것은 이 인터페이스의 구현체들이다.

                     [ Interface ]
              ┌────────────────────────┐
              │  SecurityContextRepository  │
              └────────────────────────┘
                         ▲
                         │ implements
──────────────────────────────────────────────────────────
                         │
        ┌────────────────┼───────────────────────┬──┐
        │                │                       │
        │                │                       │
        ▼                ▼                       ▼
┌────────────────┐  ┌──────────────────────┐  ┌──────────────────────────┐
│ HttpSession    │  │ RequestAttribute     │  │ NullSecurityContext       │
│ SecurityContext│  │ SecurityContext      │  │ Repository                │
│ Repository     │  │ Repository           │  │ (실제로 아무것도 저장 X) │
└────────────────┘  └──────────────────────┘  └──────────────────────────┘
        │                │       
        │                │       
        │                │       
        └────────┬───────┘
                 │ wraps (위 둘을 위임 방식으로 결합)
                 ▼
        ┌────────────────────────────────────────┐
        │ DelegatingSecurityContextRepository     │
        │  - 내부적으로 두 Repository를 보유       │
        │    (예: HttpSession + RequestAttribute) │
        │  - load/save 시 두 저장소에 위임        │
        └────────────────────────────────────────┘

일단 기본적인 구현체 3가지는 말그대로 SecurityContext, 보안컨텍스트를 어디에 저장할지에 대한 방법을 제공해주는 구현체들이다.

  • HttpSessionSecurityContextRepository

SecurityContext를 HttpSession에 저장하는 구현체이며, 가장 흔히 사용되는 방식(세션 기반 인증 유지)이다.

  • RequestAttributeSecurityContextRepository

SecurityContext를 HTTP 요청(request attribute)에만 저장하며( ServletRequest), 요청 단위에서만 보관되므로 요청 간 인증 유지를 할 수 없다.

  • NullSecurityContextRepository

아무것도 저장하지 않는다(컨텍스트 처리가 없다). 세션을 저장하지 않는 Stateless 환경(JWT, OAuth2)에서 사용한다.

그리고 특이한 구현체로 DelegatingSecurityContextRepository가 존재한다.

Spring Security 6.x ver이상에서 기본 설정으로 포함된 구현체로, 위임 기반 저장 방식(DelegatingSecurityContextRepository)을 사용하여 위의 저장소들을 조합하여 위임 방식으로 관리하는 구현체이다.

내부적으로 두 개 이상의 SecurityContextRepository를 갖는데,

  • load() 시 우선순위 있는 저장소에서 로드하거나
  • save() 시 둘 다에 저장하는 방식으로 동작한다.

위 구현체를 조합하여 두가지 이상의 저장소를 가질 수 있으며, 클래스의 이름대로 본인이 직접 저장하지 않고 저장의 책임을 두 구현체에게 위임하는 방식으로 동작한다.

3. SecurityContextHolderFilter

SecurityContextHolderFilter는 이러한 SecurityContext의 세션 저장, 나아가 인증 컨텍스트 추출 후 SecurityContextHolder에 저장해주는 필터 클래스이다.

Spring Security Filter 목록 중 가장 최상위에 위치하여, 최초 인증요청 시 인증컨텍스트를 생성하여 저장소에 저장하고, 이후 인증요청 시 인증컨텍스트를 추출해오는 역할을 하는 매우 중요한 필터이다.

그리고 중요한 점은, SecurityContextHolderFilter를 통해 인증컨텍스트를 저장소에 저장하는 과정은 반드시 개발자가 직접 명시해주어야 하며, Spring Security가 강제로 발생시키지 않는다.

이는 Spring Security 6.x이후의 개선된 점 중 하나이며, 기존의 SecurityContextPersistenceFilter와 다르게 자동 저장이 아닌 명시적 저장이다(PersistenceFilter는 아직까지는 공존하지만 권장사항인 HolderFilter를 사용할 것).

인증컨텍스트의 저장을 개발자가 직접 명시해주어야 하기에, 인증지속여부와 인증 메커니즘의 독립적 선택(앞서 말한 구현체들)을 가능하게 하여 구현에 유연성을 더해주었고, 필요한 시점에만 HttpSession에 인증컨텍스트를 저장하게 하여 성능적 유리함을 더하였다.

참고로, 인증컨텍스트는 SecurityContextHolder에 저장이 되며, 요청마다 인증컨텍스트는 사라진다. 그러나 SecurityContextHolder는 유지된다. 따라서 매 요청마다 새로운 인증컨텍스트가 생성되어 Holder에 저장되며, 단지 내용만 지속이 되느냐 안되느냐의 차이이다.

4. 인증컨텍스트의 변화과정

애플케이션이 실행되면 익명인증필터가 동작하면서 Spring Security가 동작이 되는데, 이 시점부터 Security Context의 동작이 어떻게 이루어지는지 그 변화과정을 잠깐 정리해보았다.

  • 익명인증사용자

SecurityContextRepository를 사용하여 새로운 인증컨텍스트 객체를 생성, 이를 SecurityContextHolder에 저장 및 다음 필터로 전달한다.

익명인증사용자필터가 동작하면서 AnonymousAuthenticationToken 및 익명인증 사용자 전용 Authentication 객체를 생성하게 되며, 이 인증정보가 SecurityContext에 저장이 된다.

이 처리로 인해 어떠한 경우든 Authentication은 null이 될 수 없다는 점을 기억한다.

  • 인증요청

최초 인증요청 시점에는 SecurityContextHolderFilter가 동작하여, 내부적으로 SecurityContextRepositry를 통해 Authentication 객체를 생성하여 SecurityContext 및 SecurityContextHolder에 저장한다.

이후 다음 필터로 전달하는데, 로그인의 경우 UsernamePasswordAuthenticationFilter를 통해 생성된 Authentication객체를 SecurityContextHolderFilter가 전달받아 이를 SecurityContext와 SecurityContextHolder에 저장한다.

중요한 것은 Filter가 동작하여 SecurityContextRepository를 통해 Authentication 객체를 생성하고 이를 인증컨텍스트 및 인증컨텍스트홀더에 저장한다는 것이다.

나아가, 필요시 Session에 해당 인증컨텍스트를 저장하기도 한다.

  • 인증 후

인증이 일어난 이후에는 Repository로부터 해당 인증컨텍스트를 꺼내고, 이를 홀더에 저장하여 다음 필터로 전달한다.

다만 저장소에 인증컨텍스트 정보가 있을 경우에만 하며, 없다면 예외처리를 발생시킨다.

  • 공통

SecurityContextHolder는 clearContext()하여 내부 인증컨텍스트를 삭제한다.

5. AuthenticationFilter와 SecurityContextHolderFilter의 관계

SecurityContextHolderFilter는 최상위 필터로써, SecurityContextRepositry를 통해 HttpSession 및 이에 준하는 저장소로부터 인증객체를 먼저 확인한다.

  • 인증객체가 없다면

이때 SecurityContextHolder에 인증컨텍스트 및 Authentication 객체가 없는 것이고,

-> 이 Authenticaton 객체를 먼저 AuthenticationFilter가 만들어서 인증컨텍스트에 저장한다.
-> 이 인증컨텍스트를 SecurityContextRepository를 통해 저장소에 저장한다.

  • 인증객체가 있다면

SecurityContextRepositry는 저장소로부터 인증컨텍스트를 다시 추출해오고, SecurityContextHolder에 그 인증컨텍스트를 다시 저장하여 재활용한다.

그 후 다음 필터로 전달하며, 최종적으로 인증컨텍스트를 삭제한다.

이에 대한 관계를 중심으로 정리해보면 다음과 같다.

[HTTP 요청 시작]
      │
      ▼
┌──────────────────────────────┐
│ SecurityContextHolderFilter   │
│  - SecurityContextRepository  │
└──────────────────────────────┘
      │
      ▼
[1] 저장소에서 SecurityContext 조회
      │
      ├─ 인증객체 없음 → SecurityContextHolder.getContext()는 비어 있음
      │      │
      │      ▼
      │   ┌──────────────────────────────┐
      │   │ AuthenticationFilter          │
      │   │  - 요청 정보로 Authentication 생성 │
      │   │  - SecurityContext에 저장      │
      │   │  - SecurityContextRepository에 저장 │
      │   └──────────────────────────────┘
      │
      └─ 인증객체 존재 → SecurityContextHolder에 저장
               │
               ▼
[2] SecurityContextHolder.getContext()에 SecurityContext 세팅
      │
      ▼
[3] 다음 필터 / 컨트롤러로 요청 위임
      │
      ▼
[4] 요청 처리 종료 시
      │
      ▼
[5] SecurityContextRepository.saveContext(SecurityContext)
      │
      ▼
[6] SecurityContextHolder.clearContext() → ThreadLocal 초기화
      │
      ▼
[HTTP 요청 종료]

6. vs SecurityContextPersistenceFilter

SecurityContextPersistenceFilter는 최종적으로 SecurityContextHolder에 인증컨텍스트를 저장한다.

이 과정으로 인해 강제적으로 인증컨텍스트를 저장해버리는 동작이 발생하는 것이고, 이에 대한 유연성을 제고하기 위해 지금의 동작처럼 개발자가 직접 명시를 해줌으로써 인증컨텍스트를 Session에 저장하고 여기서 추출해오는 방식으로 개선이 된 것이다.

그렇기에 securityFilterChain을 구성해줄때

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
	http.securityContext(securityContext -> securityContext.requrieExplicitSave(true));
    
    return http.build();
}

인증컨텍스트의 저장옵션을 true로 선택해야, Spring Security가 권장하는 SecurityContextHolderFilter 동작을 진행할 수 있게 된다. false로 선택한다면 SecurityContextPersistenceFilter가 동작하며 인증컨텍스트를 강제로 저장하는데, 이는 웬만하면 사용하지 않는 것이 좋다.

또한 나아가서, CustomAuthenticationFilter를 만들었을 경우 SecurityConteextRepository를 통해 인증컨텍스트를 인증컨텍스트 홀더와 HttpSession 등에 저장하기 위한 로직을 직접 구현해주어야 한다.

SecurityContextRepository는 인증방식에 맞게 알맞은 구현체를 사용하면 되며, 보통은 HttpSessionSecurityContextRepository 혹은 DelegatingSecurityContextRepsitory를 사용한다.

7. 실무적용

만약 CustomizedAuthenticationFilter를 새로 만들고 이를 폼 로그인 인증방식에 적용한다고 가정하자.

먼저 필터를 구성한다.

말그대로 Cusotmized Filter를 먼저 구성한다. 이 경우에는 AbstractAuthenticationProcessingFilter를 상속받는 구현체이다.

public class CustomizedAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public CustomizedAuthenticationFilter(HttpSecurity http){
        super(new AntPathRequestMatcher("/api/login", "GET"));
        this.setSecurityContextRepository(this.getSecurityContextRepository(http));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

        //from super
        return this.getAuthenticationManager().authenticate(token);
    }

    private SecurityContextRepository getSecurityContextRepository(HttpSecurity http) {
        SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
        if(securityContextRepository == null) {
            //securityContextRepository를 구현하여 session에 인증정보를 저장하도록 한다.
            securityContextRepository = new DelegatingSecurityContextRepository(new HttpSessionSecurityContextRepository(), new RequestAttributeSecurityContextRepository());
        }

        return securityContextRepository;
    }
}

여기서 생성자를 만들면서

public CustomizedAuthenticationFilter(HttpSecurity http){
        super(new AntPathRequestMatcher("/api/login", "GET"));
        this.setSecurityContextRepository(this.getSecurityContextRepository(http));
    }

부모클래스에 요청매핑("/api/login")을 구성해주고, httpSecurity를 전달받아 securityContextRepository를 구성하여 준다.

따라서, 이후 filter 빈을 등록할때 반드시 httpSecurity 객체를 이 customizedFilter에게 전달해야한다.

@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

        //from super
        return this.getAuthenticationManager().authenticate(token);
    }

이 customizedAuthenticationFilter는 username과 password를 받아 usernameAuthenticationToken을 생성하여 authenticationManger에게 해당 인증토큰을 전달하여 인증처리를 진행하도록 위임한다.

즉, customized Filter 내부에서 authenticationManager를 호출하여 본인이 만든 token객체를 전달, 이를 통해 인증을 하도록 유도할 수 있다.

그리고 securityContextRepository를 구성할때 httpSecurity 객체를 전달받아, securityContextRepository 구현체를 생성하여 전달하도록 한다.

private SecurityContextRepository getSecurityContextRepository(HttpSecurity http) {
        SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
        if(securityContextRepository == null) {
            //securityContextRepository를 구현하여 session에 인증정보를 저장하도록 한다.
            securityContextRepository = new DelegatingSecurityContextRepository(new HttpSessionSecurityContextRepository(), new RequestAttributeSecurityContextRepository());
        }

        return securityContextRepository;
    }

이처럼, securityContextRepository의 구현체를 전달하되, DelegatingSecurityContextRepository를 전달하여 Spring Security의 기본 구현체를 생성하여 전달한다.

필터 정보를 filter chain에 주입한다.

다음 단계로 위에서 생성한 필터정보를 filter chain에 주입한다.

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
      //customized filtering
      AuthenticationManagerBuilder builder = http.getSharedObject(AuthenticationManagerBuilder.class);
      AuthenticationManager authenticationManager = builder.build();

      http
                .authorizeRequests(auth -> auth
                        .requestMatchers("/api/login").permitAll()
                        .anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .authenticationManager(authenticationManager)
                .addFilterBefore(customizedAuthenticationFilter(http, authenticationManager), UsernamePasswordAuthenticationFilter.class)
        ;

        return http.build();
    }

이를 위해 authenticationManager를 따로 생성하여 주입해주고, addFilter를 통해 추가해준다.

public CustomizedAuthenticationFilter customizedAuthenticationFilter(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        CustomizedAuthenticationFilter customizedAuthenticationFilter = new CustomizedAuthenticationFilter(http);
        customizedAuthenticationFilter.setAuthenticationManager(authenticationManager);

        return customizedAuthenticationFilter;
    }

위에서 filter를 매개변수로 전달해주기 위해, 별도로 위와 같이 filter를 생성하고 이를 반영한 authenticationManager를 setter해주면 최종적으로 customized filter 등록이 완료된다.

7-1. Expected

위 필터를 정상적으로 등록해주었다면,

if(securityContextRepository == null) {
            //securityContextRepository를 구현하여 session에 인증정보를 저장하도록 한다.
            securityContextRepository = new DelegatingSecurityContextRepository(new HttpSessionSecurityContextRepository(), new RequestAttributeSecurityContextRepository());
        }

위의 로직으로 인해 최초 인증처리 시 생성한 인증컨텍스트를 session에 저장하게 되어, 요청이후 인증컨텍스트가 제거되더라도 이를 session에서 재활용하여 인증정보를 유지할 수 있게 된다.

super(new AntPathRequestMatcher("/api/login", "GET"));

이 정보로 인해 /api/login?username=user&password=1111의 쿼리스트링을 전달하였을때 인증처리를 거치게 되는데, 위에서 구성한 정보가 적용되어 securityContextHolderFilter 구현체가 동작하게 된다.

따라서, 인증컨텍스트가 session에 저장되어 인증정보가 유지가 되기에, 최초 로그인 이후에 지속적인 인증정보 사용이 가능한 것이다.

세션에 인증컨텍스트 정보가 없다면, 로그인을 분명 진행했음에도 불구하고 다른 요청을 하는 내내 인증처리를 요구하고 이에 따라 로그인을 계속 진행해야 한다.

참고) post login으로 엔드포인트화하여 MVC Controller 기반의 인증처리도 가능하다(SpringSecurity filter 기반동작이 아닌 직접 인증과정 구현).

Controller(Service)

Servlet 기반 로그인 구현이기에 filter를 모두 거친 후, DispatherServlet으로 요청을 넘긴 이후의 과정에서 로그인을 구현하는 과정이다.

컨트롤러/서비스를 나누어 구현하는 것이 바람직하겠으나 지금은 편의상 컨트롤러에 몰아서 구현하였다.

@RestController
@RequiredArgsConstructor
public class LoginController {

    //인증수행
    private final AuthenticationManager authenticationManager;

    //Repository
    private final HttpSessionSecurityContextRepository httpSessionSecurityContextRepository = new HttpSessionSecurityContextRepository();

//    @PostMapping("/login")
//    public Authentication login(@RequestParam("username") String username, @RequestParam("password") String password) {
//        return new Authentication();
//    }

    @PostMapping("/login")
    public Authentication login(@RequestBody LoginRequest loginRequest, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse){
        //token -> authenticate -> authentication
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.getUsername(), loginRequest.getPassword());
        Authentication authentication = authenticationManager.authenticate(token); //token 인증시도 -> 인증객체 반환

        //authentication -> securityContext / securityContextHolder / threadLocal
        SecurityContext securityContext = SecurityContextHolder.getContextHolderStrategy().getContext();
        securityContext.setAuthentication(authentication);
        SecurityContextHolder.getContextHolderStrategy().setContext(securityContext);

        //securityContext -> session (*repository)
        httpSessionSecurityContextRepository.saveContext(securityContext, httpServletRequest, httpServletResponse);

        return authentication;
    }

}

여기서 중요한 점은,

  • AuthenticationManager는 빈으로 주입을 받는데, 빈으로 주입받기 위해서는 반드시 생성자를 통한 주입이 이루어져야 한다. 따라서, final 필드의 생성자를 구성해준다(RequiredArgsConstructor).
  • HttpSessionSecurityContextRepository는 POJO로 생성한다.

참고로, 필드생성자에 대한 가이드(RequiredArgsConstructor)가 없으면 NPE오류가 발생한다(기본 생성자로 주입을 못받으므로 NPE 발생).

이제 인증처리를 진행하여 authentication 객체를 생성하고 이를 인증컨텍스트에 등록, 최종적으로 인증컨텍스트 홀더 및 threadLocal에 저장하는 과정까지 분석해보자.

인증객체를 만들기 위한 토큰 생성

인증객체를 만들기 위해선 먼저 인증정보가 들어있는 토큰을 만들어주어야 하는데, 이를 위해 특정 authenticationToken를 빌드하여 유저정보를 주입해주면 된다.

//token -> authenticate -> authentication
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.getUsername(), loginRequest.getPassword());
        Authentication authentication = authenticationManager.authenticate(token); //token 인증시도 -> 인증객체 반환

최종적으로 인증처리를 완료했다면 authentication 객체(인증객체)를 확보할 수 있다.

인증객체를 인증컨텍스트와 컨텍스트 홀더, ThreadLocal에 저장

        //authentication -> securityContext / securityContextHolder / threadLocal
        SecurityContext securityContext = SecurityContextHolder.getContextHolderStrategy().getContext();
        securityContext.setAuthentication(authentication);
        SecurityContextHolder.getContextHolderStrategy().setContext(securityContext);

다음 단계로 위에서 생성한 인증객체를 인증컨텍스트, 인증컨텍스트홀더, ThreadLocal에 저장하면 된다.

참고로 인증컨텍스트홀더에 저장하면, 전략에 해당 인증컨텍스트를 저장하면서 자동옵션에 의해 컨텍스트홀더에 까지 저장을 진행한다.

최종단계 : securityContextRepository를 통한 인증정보 유지

        //securityContext -> session (*repository)
        httpSessionSecurityContextRepository.saveContext(securityContext, httpServletRequest, httpServletResponse);

        return authentication;

인증정보를 유지하기 위해 securityContextRepository에 해당 인증컨텍스트를 저장한다.

참고로, authenticationManager 정보를 주입받기 위해

//authentication manager
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

위와 같이 config에 빈객체를 별도로 등록해준다.

최초 로그인 url에 대하여 접근 허가

http
                .authorizeRequests(auth -> auth
                        .requestMatchers("/login").permitAll()
                        .anyRequest().authenticated())
                //.formLogin(Customizer.withDefaults())
                        .csrf(csrf -> csrf.disable())//test를 위한 임시조치
        ;

        return http.build();
    }

"/login" 요청에 대해 permitAll()하여, 로그인 시도에 대한 접근을 허가해준다.
또한, 이 부분은 formLogin 대신에 Servlet과 MVC 기반의 로그인이므로, formLogin이 이를 가로채지 않도록 비활성화해주어야 한다.

csrf는 현재 테스트 환경을 고려하여 일단은 비활성화 해주었다.

이 상태에서 POST login을 요청해보면 아래와 같이 정상적으로 인증객체가 생성되었음을 알 수 있다.

참고로, 우리는 Controller의 반환값을 @RestController를 통해 객체를 JSON화하였으므로, 위와 같은 결과를 추출할 수 있는 것이다.

유의) RestController

이 하나의 어노테이션을 잘못 표기하여 forbidden 오류가 발생하였는데, 그 해결과정을 기억에 남기기 위해 기록한다.

RestController 어노테이션은 결과반환을 JSON화 하여 반환하기에, 이 어노테이션을 잘못 사용할 경우 내부적으로 결과값을 파싱을 하지 못하거나 전달이 이루어지지 않아 404/403 오류가 발생할 수 있다.

이에 대해 간략히 아래와 같이 정리하겠다.

  • @RestController

메서드의 return 값이 그대로 HTTP Response Body 로 나간다.
즉, View Resolver가 실행되지 않기에 반환값이 JSON화하여 전달이 가능하고, 그렇기에 REST API 서버에서 보통 사용한다.

일반 Controller 서버에서는 반환값을 파싱해주기 위해서는 반드시 @ResponseBody를 메서드마다 붙여야 하는데, RESTController는 그럴 필요가 없다.

  • @RestController (@Controller 포함한 오기)

메서드의 return 값은 뷰 이름(view name), 즉 jsp나 thymleaf의 파일명을 탐색하는 것으로 해석한다.

따라서, 기본적으로 뷰기반 이름을 해석하므로, API 응답을 하려면 메서드에 반드시 @ResponseBody를 붙여주어야 한다.

그렇지 않으면 403 Forbidden 또는 404 view not found 같은 오류가 발생할 수 있다.

추가) Bean에 대하여

위 과정을 유심히 살펴보았다면, 한가지 의문이 들 수 있다.

컴포넌트 스캔 대상의 Component/Controller 등을 탐색한 후에 빈객체 주입이 동작 가능한가? 시점 상으로 오류가 있는것 아닌가? 컴포넌트 스캔이 먼저 일어나는건가?

빈에 대해서는 너무나도 많이 기록을 하였는데, 다시 한번 상기할 겸 기록해놓는다.

컴포넌트 스캔 단계에서는 “빈을 만들지 않는다”, "빈을 정의할 뿐이다".

먼저 스프링이 컴포넌트 스캔을 할 때 하는 일은 단지 후보를 등록하는 것이다.

컴포넌트 스캔 대상을 탐색하고, 클래스패스에서 @Component / @Controller / @Service 등을 가진 클래스들을 찾는다.

그 후 BeanDefinition(빈 메타데이터) 를 컨테이너에 등록한다.

이때 어노테이션의 명세도 그대로 빈 메타데이터로 등록, 이후 refresh하여 빈이 리플렉션되는 시점에 그대로 동작한다.

  • 클래스명
  • 생성자 정보
  • scope
  • lazy 여부
  • 기타 메타정보

중요한건, 이 단계에서 실제 객체(빈)는 생성되지 않는다. 따라서 우리가 빈을 등록할때는 등록/주입 시점에 대한 걱정을 할 필요가 없다.

실제 빈 생성은 “스프링 컨테이너가 refresh() 과정에서 한꺼번에 수행”한다.

빈 생성(Instantiation)은 다른 단계이다.

스프링은 ApplicationContext 초기화 과정에서 다음 순서를 따른다:

  • 컴포넌트 스캔으로 BeanDefinition 등록
  • BeanFactoryPostProcessor 처리
  • BeanPostProcessor 등록

사실 이 과정까진 알 필요는 없고, Bean Definition을 통해 메타데이터를 등록한다는 정도는 숙지하고 있는 것이 좋겠다.

이후 본격적으로 빈 생성을 시작하는데,

  • 생성자 호출
  • 의존성 주입
  • 초기화 메서드 호출

즉, 빈 생성 시점에 DI를 수행하여, 빈이 등록 -> 생성 -> 주입 -> 초기화의 일련의 단계가 수행된다.

즉, 의존성 주입 시점에는 모든 빈이 이미 등록, 생성되어 있는 상태이기에 빈주입이 가능한 것이고, 지연주입과 같은 명세가 있다면 이후 실제 클래스 호출 시점에 빈 주입이 일어나는 등의 과정이 발생한다.

8. 결론

Spring Security는 필터의 연속이다.

이 화면이 나타나기 위해서는 1) 로그인을 요하는 페이지의 permitAll, 2) 인증정보를 유지하도록 설정하여 로그인 후 리다이렉트 페이지로 이동할때 인증정보를 사용하도록 한다.

리다이렉트 하는 페이지도 스레드가 새로 생성되어 요청되는 것이므로, 세션정보 혹은 이에 준하는 인증컨텍스트 정보가 유지되지 못할 경우 로그인 이후 리다이렉트 하는 요청에도 인증요청을 요구할 수 있다.

spring security의 내부 동작을 충분히 이해해야, filter를 customized하는 작업을 무리없이 수행할 수 있을 것이다.

0개의 댓글