본문 바로가기
Web/Spring

[Spring Security] 2편. 아키텍처

by 조엘 2022. 8. 20.

안녕하세요! 조엘입니다!

 

1편에서 Spring Security가 Spring 진영에서 인증/인가를 위해 필요하다고 살펴보았는데요. 

그렇다면, 2편에서는 Spring Security가 어떻게 Spring의 기술을 사용하여 인증/인가를 하게 되는지 살펴봅시다!

 

 

Servlet Filter

Spring Security는 서블릿 필터 기반으로 동작합니다. 그러면 서블릿 필터가 뭔지 알아봅시다!

만약 서블릿이란 단어가 낯설다면, 아래 포스팅을 먼저 보고 오시는 것을 추천드립니다 💪

https://papimon.tistory.com/84

 

저는 서블릿이 처음이라니까요?

안녕하세요! 조엘입니다! "처음이라니까요" 시리즈 여덟 번째 토픽은 서블릿입니다. 🎯🎯 자바로 웹 개발을 하다 보면 서블릿이라는 말을 많이 듣게 되는데요! 스프링에서는 "디스패처 서블

joel-dev.site

 

서블릿 필터란 클라이언트의 요청을 서블릿에 닿기 전에 전처리하고,

서버에서의 응답을 클라이언트에게 보내기 전에 후처리할 때 사용되는 객체를 뜻해요. 

 

Spring MVC를 사용한다면, dispatcher servlet에 도달하기 전에 서블릿 필터를 통해 필요한 처리를 하게 되겠죠!

 

그렇다면 왜 클라이언트의 요청/응답에 전처리/후처리가 필요하며, 이를 필터에서 따로 처리해주는 걸까요? 

 

 

이는 비즈니스 로직과 기타 관심사의 분리를 가능하게 해 줘서 그래요. 

백엔드 서버에서 작성하는 로직은 사용자의 요청에 따라 적절한 응답을 만들어줄 수 있는 비즈니스 로직이에요. 

따라서 백엔드에서는 비즈니스 로직의 구현에만 집중하는 것이 좋아요. 

필터를 통해 비즈니스 로직과 관련이 덜한 로직은 위임시킬 수 있어요. 

 

가령 인증/인가에 대한 처리를 예로 들어본다면, 필터와 백엔드 로직을 각각 다음과 같이 분리할 수 있을 거예요. 

- 필터: http 요청이 적절한 사용자로부터 왔는지 인증/인가 로직을 처리한다. 

- 백엔드 로직: 적절한 사용자가 요청한 비즈니스 로직을 처리한다. 

 

이와 같이 필터를 사용하면, 백엔드 로직에서 처리할 인증/인가 로직을 따로 분류할 수 있어요. 

 

또 어떤 처리를 필터에서 할 수 있을까요?

암호화, 로깅, 데이터 압축 등 비즈니스 로직과는 다소 무관한 처리를 필터에서 따로 해줄 수 있어요.

웹 어플리케이션에서 전역적으로 처리해야 할 로직들은 서블릿 필터단으로 뺄 수 있겠네요! 

 

그렇다면 필터는 어떤 방식으로 구현될까요? 

자바에서는 필터 인터페이스에서 구현해야 할 함수를 다음과 같이 지정해요. 링크

 

init(FilterConfig filterConfig)

WebContainer(ex. tomcat)에서 필터를 서비스에 포함시킴

 

doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

WebContainer(ex. tomcat)에서 각 요청/응답 쌍에 대해 필터 체인을 통해 전달될 때마다 호출됨

- 필터 체인은 요청이 통과해야 할 필터를 순서대로 정의한 것을 뜻함

chain.doFilter()를 통해 다음 체인으로 넘기거나, 넘기지 않는다면 요청이 중단됨. 

 

destroy()

WebContainer(ex. tomcat)에서 필터를 서비스에서 이탈시킴

 

 

Spring Security 아키텍처

Spring Security는 인증/인가에 대한 처리를 필터 기반으로 진행한다고 얘기하는데요. 

독특하게도, Spring 기술의 혜택(ApplicationContext, IoC, AOP 등)을 받기 위해 조금 독특한 방식으로 동작합니다. 

 

그림부터 한 번 볼까요? 

위에서 살펴본 Servlet Container의 FilterChain에 DelegatingFilterProxy가 추가된 것을 볼 수 있는데요. 

DelegatingFilterProxy 필터는 조금 독특한 방식으로 동작해요. 

 

그림에서 보다시피, DelegatingFilterProxy는 요청을 ApplicationContext에 있는 FilterChainProxy에게 위임하게 되는데요. 

FilterChainProxy에 등록된 여러 개의 SpringSecurityFilter들에 대해 필터 처리가 이루어져요.

이때, SpringSecurityFilter들은 ApplicationContext의 관리를 받게 되는 Spring Bean으로 등록이 된답니다. 

 

SpringSecurityFilterChain에 등록된 SpringSecurityFilter들에 대한 처리를 모두 거치게 된다면, 요청이 비로소 SpringMVC에 닿을 거예요. 

 

 

Spring Security 아키텍처 코드로 보기

그러면 한 번 코드레벨에서 살펴봅시다! 그림의 아키텍처를 어떻게 코드 레벨에서 구현했는지 말이죠!

 

우선 DelegatingFilterProxy입니다. 이게 Servlet Container의 FilterChain으로 등록된다고 했어요. 

코드가 꽤나 긴데, 중요한 부분만 잘라서 보여드릴게요.

package org.springframework.web.filter;

/**
 * Proxy for a standard Servlet Filter, delegating to a Spring-managed bean that
 * implements the Filter interface. Supports a "targetBeanName" filter init-param
 * in {@code web.xml}, specifying the name of the target bean in the Spring
 * application context.
 */
public class DelegatingFilterProxy extends GenericFilterBean {

    @Nullable
    private String contextAttribute;

    @Nullable
    private WebApplicationContext webApplicationContext;

    @Nullable
    private String targetBeanName;

    private boolean targetFilterLifecycle = false;

    @Nullable
    private volatile Filter delegate;

    private final Object delegateMonitor = new Object();

    public DelegatingFilterProxy() {
    }

    public DelegatingFilterProxy(Filter delegate) {
        Assert.notNull(delegate, "Delegate Filter must not be null");
        this.delegate = delegate;
    }

    public DelegatingFilterProxy(String targetBeanName) {
        this(targetBeanName, null);
    }

    public DelegatingFilterProxy(String targetBeanName, @Nullable WebApplicationContext wac) {
        Assert.hasText(targetBeanName, "Target Filter bean name must not be null or empty");
        this.setTargetBeanName(targetBeanName);
        this.webApplicationContext = wac;
        if (wac != null) {
            this.setEnvironment(wac.getEnvironment());
        }
    }

    // Getters & Setters

    @Override
    protected void initFilterBean() throws ServletException {
        synchronized (this.delegateMonitor) {
            if (this.delegate == null) {
                if (this.targetBeanName == null) {
                    this.targetBeanName = getFilterName();
                }
                WebApplicationContext wac = findWebApplicationContext();
                if (wac != null) {
                    this.delegate = initDelegate(wac);
                }
            }
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Filter delegateToUse = this.delegate;
        // Lazily initialize the delegate if necessary.
        if (delegateToUse == null) {
            synchronized (this.delegateMonitor) {
                delegateToUse = this.delegate;
                if (delegateToUse == null) {
                    WebApplicationContext wac = findWebApplicationContext();
                    if (wac == null) {
                        throw new IllegalStateException("No WebApplicationContext found: " +
                            "no ContextLoaderListener or DispatcherServlet registered?");
                    }
                    delegateToUse = initDelegate(wac);
                }
                this.delegate = delegateToUse;
            }
        }
        invokeDelegate(delegateToUse, request, response, filterChain);
    }

    @Override
    public void destroy() {
        Filter delegateToUse = this.delegate;
        if (delegateToUse != null) {
            destroyDelegate(delegateToUse);
        }
    }

    protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
        String targetBeanName = getTargetBeanName();
        Assert.state(targetBeanName != null, "No target bean name set");
        Filter delegate = wac.getBean(targetBeanName, Filter.class);
        if (isTargetFilterLifecycle()) {
            delegate.init(getFilterConfig());
        }
        return delegate;
    }

    protected void invokeDelegate(
        Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        delegate.doFilter(request, response, filterChain);
    }

    protected void destroyDelegate(Filter delegate) {
        if (isTargetFilterLifecycle()) {
            delegate.destroy();
        }
    }
}

주석에서 설명하듯, DelegatingFilterProxy는 Servlet Filter이자, 프록시로 동작해요.

 

우선 해당 클래스가 GenericFilterBean을 상속받아 구현한 것을 볼 수 있는데요! 

GenericFilterBean은 다음과 같은 인터페이스를 상속받은 추상 클래스예요. Filter를 구현했다는 것이 확인되네요!

public abstract class GenericFilterBean implements Filter, BeanNameAware, EnvironmentAware,
		EnvironmentCapable, ServletContextAware, InitializingBean, DisposableBean {
	// ...
}

또한 여러 가지 생성자를 통해 DelegatingFilterProxy를 생성할 수 있는 것으로 보이네요. 

 

doFilter() 함수를 볼까요? 요청을 위임할 Filter (아마 FilterChainProxy겠죠?)를 가져오고, invokeDelegate()를 호출해요. 

이때 FilterChainProxy는 Lazy Initialization으로 가져와서 DelegatingFilterProxy의 delegate 변수에 저장되는데요. 

디버거를 통해 확인해보니, 최초 요청이 들어올 때 FilterChainProxy를 delegate 변수에 넣어주더라고요. 

 

invokeDelegate() 함수를 호출하면 위임할 필터의 doFilter() 함수를 호출하게 되어요. 

 

 

이제 FilterChainProxy를 까보죠!

package org.springframework.security.web;

/**
 * Delegates {@code Filter} requests to a list of Spring-managed filter beans. As of
 * version 2.0, you shouldn't need to explicitly configure a {@code FilterChainProxy} bean
 * in your application context unless you need very fine control over the filter chain
 * contents. Most cases should be adequately covered by the default
 * {@code <security:http />} namespace configuration options.
 */
public class FilterChainProxy extends GenericFilterBean {

    private static final Log logger = LogFactory.getLog(FilterChainProxy.class);

    private static final String FILTER_APPLIED = FilterChainProxy.class.getName().concat(".APPLIED");

    private List<SecurityFilterChain> filterChains;

    private FilterChainValidator filterChainValidator = new NullFilterChainValidator();

    private HttpFirewall firewall = new StrictHttpFirewall();

    private RequestRejectedHandler requestRejectedHandler = new DefaultRequestRejectedHandler();

    public FilterChainProxy() {
    }

    public FilterChainProxy(SecurityFilterChain chain) {
        this(Arrays.asList(chain));
    }

    public FilterChainProxy(List<SecurityFilterChain> filterChains) {
        this.filterChains = filterChains;
    }

    @Override
    public void afterPropertiesSet() {
        this.filterChainValidator.validate(this);
    }
    
    // Getters & Setters

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
        boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
        if (!clearContext) {
            doFilterInternal(request, response, chain);
            return;
        }
        try {
            request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
            doFilterInternal(request, response, chain);
        }
        catch (RequestRejectedException ex) {
            this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response, ex);
        }
        finally {
            SecurityContextHolder.clearContext();
            request.removeAttribute(FILTER_APPLIED);
        }
    }

    private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
        FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
        HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
        List<Filter> filters = getFilters(firewallRequest);
        if (filters == null || filters.size() == 0) {
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
            }
            firewallRequest.reset();
            chain.doFilter(firewallRequest, firewallResponse);
            return;
        }
        if (logger.isDebugEnabled()) {
            logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
        }
        VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
        virtualFilterChain.doFilter(firewallRequest, firewallResponse);
    }

    private List<Filter> getFilters(HttpServletRequest request) {
        int count = 0;
        for (SecurityFilterChain chain : this.filterChains) {
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, ++count,
                    this.filterChains.size()));
            }
            if (chain.matches(request)) {
                return chain.getFilters();
            }
        }
        return null;
    }
    
    /**
     * Internal {@code FilterChain} implementation that is used to pass a request through
     * the additional internal list of filters which match the request.
     */
    private static final class VirtualFilterChain implements FilterChain {

        private final FilterChain originalChain;

        private final List<Filter> additionalFilters;

        private final FirewalledRequest firewalledRequest;

        private final int size;

        private int currentPosition = 0;

        private VirtualFilterChain(FirewalledRequest firewalledRequest, FilterChain chain,
                                   List<Filter> additionalFilters) {
            this.originalChain = chain;
            this.additionalFilters = additionalFilters;
            this.size = additionalFilters.size();
            this.firewalledRequest = firewalledRequest;
        }

        @Override
        public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
            if (this.currentPosition == this.size) {
                if (logger.isDebugEnabled()) {
                    logger.debug(LogMessage.of(() -> "Secured " + requestLine(this.firewalledRequest)));
                }
                // Deactivate path stripping as we exit the security filter chain
                this.firewalledRequest.reset();
                this.originalChain.doFilter(request, response);
                return;
            }
            this.currentPosition++;
            Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(),
                    this.currentPosition, this.size));
            }
            nextFilter.doFilter(request, response, this);
        }
    }
}

이번에도 doFilter()를 봐보죠. doFilterInternal() 함수에 필터 처리를 넘겨주는데요.

이 과정에서  List<Filter>를 가져오네요. => 이게 위에 그림을 보며 언급한 SpringSecurityFilter 목록이에요!

여기서 가져오는 필터들은 SecurityFilterChain에 저장된 List<Filter>로,

Spring Security에서 처리하도록 정의한 Filter들을 담고 있어요. 

 

이후에 VirtualFilterChain이라는 내부 정적 클래스에 처리를 위임해주는 것을 볼 수 있어요. 

여기에서 List<Filter> 들을 전달받아 Security에 등록된 필터를 하나씩 doFilter()를 해주게 됩니다. 

로그를 친절하게 찍어주는데요! 한번 같이 확인해보면 다음과 같이 하나씩 찍히는 것을 발견할 수 있어요!

2022-08-20 14:04:00.575[TRACE] : Invoking DisableEncodeUrlFilter (1/12)
2022-08-20 14:04:04.549[TRACE] : Invoking WebAsyncManagerIntegrationFilter (2/12)
2022-08-20 14:04:09.326[TRACE] : Invoking SecurityContextPersistenceFilter (3/12)
2022-08-20 14:04:11.117[TRACE] : Invoking HeaderWriterFilter (4/12)
2022-08-20 14:04:13.364[TRACE] : Invoking LogoutFilter (5/12)
2022-08-20 14:04:15.376[TRACE] : Invoking JwtAuthenticationFilter (6/12)
2022-08-20 14:04:17.370[TRACE] : Invoking RequestCacheAwareFilter (7/12)
2022-08-20 14:04:19.338[TRACE] : Invoking SecurityContextHolderAwareRequestFilter (8/12)

이런 식으로 Spring Security에 등록해준 Filter들이 하나씩 동작하게 됩니다!

 

 

Spring Security에서는 기본적으로 인증/인가와 관련된 여러 개의 Filter를 제공해주는데요.

주로 세션 기반의 로그인 방식에 대해서 정말 많은 지원을 해줘요. 

 

하지만 저희 프로젝트 상에서는 JWT 토큰을 통해 인증/인가를 진행하고 있는데요. 

이를 Spring Security로 구현하면서 발생한 문제점과 Spring Security에 대해 제가 느낀 장단점들을 정리해볼게요!

 

긴 글 읽어주셔서 감사합니다! 질문 환영이에요! 🙌🏻

 

 

참고

- https://docs.oracle.com/javaee/6/api/javax/servlet/Filter.html

- https://www.digitalocean.com/community/tutorials/java-servlet-filter-example-tutorial#servlet-filter

- https://github.com/joelonsw/TIL/blob/master/concepts/Spring%20Security.md

 

반응형

댓글