Spring Security - Servlet이란?

2024. 7. 6. 13:57스프링 (Spring)/스프링 보안 (Spring Security)


Outsider

 

사실 서버 보안은 문단속을 잘 하면 됩니다.

적은 언제나 외부에 있기 때문이죠.

 

물론 내부의 적을 경계할 수도 있지만, 경계 강도가 강해질 수록 개발이 피곤해지고 생산성이 줄어듭니다.

필자도 실무에 있어서 내부 보안 정책이 하나 둘 추가될 때마다 - 그 의미는 나름 이해하지만,

이에 맞출 동안 다른 업무를 못한다는 것에 많은 피로감이 생길 때가 많습니다.

 

그렇기에 문단속만 잘 해두면,

이렇게 피곤해할 일도 없고 외부의 적으로부터 안전하게 우리 서버를 지킬 수 있습니다.

 

서론이 길었는데, 스프링의 문단속은 서블릿(servlet)으로부터 시작합니다.

 

 

 

 

 

 

 

 


Servlet

 

서블릿을 이해하기 위해서는 개념을 외우기보다는 역할을 아는 게 더 낫습니다.

이를 위해 스프링에서 클라이언트의 요청과 응답을 어떻게 처리하는 지 알아봅니다.

 

1. Client >> Front Controller

spring-boot-starter-web을 사용하면 어플리케이션을 웹서버(web server)로 사용할 수 있습니다.

사용자의 요청을 받고, 응답을 보낼 수 있는 서버가 되는 것이죠.


해당 라이브러리에 있는 웹서버 어플리케이션은 서블릿 컨텍스트(servlet context)를 가지고 있습니다.

이는 아래 createWebServer() 함수에서 힌트를 많이 찾을 수 있죠.

(컨텍스트라는 용어가 어렵다면, 그냥 현재 서블릿이 모여있는 장소라고 생각하면 됩니다.)

package org.springframework.boot.web.servlet.context;

...

public class ServletWebServerApplicationContext extends GenericWebApplicationContext implements ConfigurableWebServerApplicationContext {
    private static final Log logger = LogFactory.getLog(ServletWebServerApplicationContext.class);
    public static final String DISPATCHER_SERVLET_NAME = "dispatcherServlet";
    private volatile WebServer webServer;
    private ServletConfig servletConfig;
    private String serverNamespace;
    
    ...
    
    private void createWebServer() {
        WebServer webServer = this.webServer;
        ServletContext servletContext = this.getServletContext();
        if (webServer == null && servletContext == null) {
            StartupStep createWebServer = this.getApplicationStartup().start("spring.boot.webserver.create");
            ServletWebServerFactory factory = this.getWebServerFactory();
            createWebServer.tag("factory", factory.getClass().toString());
            this.webServer = factory.getWebServer(new ServletContextInitializer[]{this.getSelfInitializer()});
            createWebServer.end();
            this.getBeanFactory().registerSingleton("webServerGracefulShutdown", new WebServerGracefulShutdownLifecycle(this.webServer));
            this.getBeanFactory().registerSingleton("webServerStartStop", new WebServerStartStopLifecycle(this, this.webServer));
        } else if (servletContext != null) {
            try {
                this.getSelfInitializer().onStartup(servletContext);
            } catch (ServletException var5) {
                ServletException ex = var5;
                throw new ApplicationContextException("Cannot initialize servlet context", ex);
            }
        }

        this.initPropertySources();
    }
    
    ...

 

아래처럼 웹서버가 클라이언트로부터 요청을 받는다면, 서블릿 컨텍스트에 전달합니다.

이때 해당 요청을 건네받는 역할을 Front Controller라고 부릅니다.

공교롭게도 스프링은 기본적으로 1개의 서블릿만 가집니다. 바로 DispatcherServlet이죠.

(더 많은 서블릿을 가질수도 있습니다. 자세한 내용은 stackoverflow 글을 참고하면 좋습니다.)

 

 

 

 

2.  Front Controller >> Controller

Front Controller는 요청 정보를 확인해서 이를 처리해줄 적절한 핸들러(handler)를 모색합니다.

그리고 핸들러를 찾으면, 요청을 건네주죠.

사실 핸들러의 정체는 우리가 작성한 Controller 입니다.

package ...

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping

@Controller
class MainController {

    @GetMapping("/hello")
    fun hello(): String {
        return "index"
    }

    @GetMapping("/world")
    fun world(): String {
        return "world"
    }

}

 

 

즉, 정리하면 Front Controller는 요청의 method, path 정보 등을 보고 올바른 컨트롤러의 함수를 호출해줍니다.

구현 내용은 DispatcherServlet의 doDispatch() 함수를 보면 알 수 있습니다.

package org.springframework.web.servlet;

...

public class DispatcherServlet extends FrameworkServlet {
    ...
    
    protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
        boolean multipartRequestParsed = false;
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

        try {
            try {
                ModelAndView mv = null;
                Exception dispatchException = null;

                try {
                    processedRequest = this.checkMultipart(request);
                    multipartRequestParsed = processedRequest != request;
                    mappedHandler = this.getHandler(processedRequest);
                    if (mappedHandler == null) {
                        this.noHandlerFound(processedRequest, response);
                        return;
                    }

                    HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
                    String method = request.getMethod();
                    boolean isGet = HttpMethod.GET.matches(method);
                    if (isGet || HttpMethod.HEAD.matches(method)) {
                        long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                        if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
                            return;
                        }
                    }

                    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                        return;
                    }

                    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
                    
                    ...
                }
            ...

 

재밌는 점은 mappedHandler가 없으면 noHandlerFound가 실행되는데, 이는 우리가 익숙히 보았던 404 에러죠.

    ...

    protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
        ...

        if (this.throwExceptionIfNoHandlerFound) {
            throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request), (new ServletServerHttpRequest(request)).getHeaders());
        } else {
            response.sendError(404);
        }
    }

 

 

 

 

3. Controller >> Front Controller

컨트롤러(핸들러)가 본인의 로직을 충분히 수행하고 결과를 리턴하면,

이를 Response에 넣어서 Front Controller에 다시 전달합니다.

 

 

 

4. Front Controller (>> View Template) >> Client

그리고 Front Controller는 이 응답을 클라이언트에게 전달하는 것이죠.

물론 Spring MVC 패턴에 의하면 중간에 View Template를 거쳐야 하지만 생략했습니다.

필자는 다른 곳에서 다루기도 하고, View를 스프링에서 다루는 곳은 흔치 않기 때문이죠.

 

 

 

 

 


FrameworkServlet

 

DispatcherServlet을 근원적으로 파고들다보면 다음과 같은 상속 구조를 확인할 수 있습니다.

 

먼저 HttpServlet의 service 함수 내용을 보면

package jakarta.servlet.http;

...

public abstract class HttpServlet extends GenericServlet {
    ...
    
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String method = req.getMethod();
        long lastModified;
        if (method.equals("GET")) {
            lastModified = this.getLastModified(req);
            if (lastModified == -1L) {
                this.doGet(req, resp);
            } else {
                long ifModifiedSince = req.getDateHeader("If-Modified-Since");
                if (ifModifiedSince < lastModified) {
                    this.maybeSetLastModified(resp, lastModified);
                    this.doGet(req, resp);
                } else {
                    resp.setStatus(304);
                }
            }
        } else if (method.equals("HEAD")) {
            lastModified = this.getLastModified(req);
            this.maybeSetLastModified(resp, lastModified);
            this.doHead(req, resp);
        } else if (method.equals("POST")) {
            this.doPost(req, resp);
        } else if (method.equals("PUT")) {
            this.doPut(req, resp);
        } else if (method.equals("DELETE")) {
            this.doDelete(req, resp);
        } else if (method.equals("OPTIONS")) {
            this.doOptions(req, resp);
        } else if (method.equals("TRACE")) {
            this.doTrace(req, resp);
        } else {
            String errMsg = lStrings.getString("http.method_not_implemented");
            Object[] errArgs = new Object[]{method};
            errMsg = MessageFormat.format(errMsg, errArgs);
            resp.sendError(501, errMsg);
        }

    }

 

각 메소드에 맞게 do 함수를 호출하고 있으며, 이는 FrameworkServlet에 구현되어 있습니다.

package org.springframework.web.servlet;

...

public abstract class FrameworkServlet extends HttpServletBean implements ApplicationContextAware {
    ...
    
    protected final void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.processRequest(request, response);
    }

    protected final void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.processRequest(request, response);
    }

    protected final void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.processRequest(request, response);
    }

    protected final void doDelete(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.processRequest(request, response);
    }
    
    ...

    protected final void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        long startTime = System.currentTimeMillis();
        Throwable failureCause = null;
        LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
        LocaleContext localeContext = this.buildLocaleContext(request);
        RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes requestAttributes = this.buildRequestAttributes(request, response, previousAttributes);
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
        asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor());
        this.initContextHolders(request, localeContext, requestAttributes);

        try {
            this.doService(request, response);
        } catch (IOException | ServletException var16) {
            Exception ex = var16;
            failureCause = ex;
            throw ex;
        } catch (Throwable var17) {
            Throwable ex = var17;
            failureCause = ex;
            throw new ServletException("Request processing failed: " + ex, ex);
        } finally {
            this.resetContextHolders(request, previousLocaleContext, previousAttributes);
            if (requestAttributes != null) {
                requestAttributes.requestCompleted();
            }

            this.logResult(request, response, (Throwable)failureCause, asyncManager);
            this.publishRequestHandledEvent(request, response, startTime, (Throwable)failureCause);
        }

    }

 

여기서 핵심으로 호출하는 함수가 doService() 입니다.
그리고 여기서 일전에 소개한 doDispatcher() 함수를 호출하죠.

package org.springframework.web.servlet;

...

public class DispatcherServlet extends FrameworkServlet {
    ...
    
    protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
        ...

        try {
            this.doDispatch(request, response);
        } finally {
            if (!WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted() && attributesSnapshot != null) {
                this.restoreAttributesAfterInclude(request, attributesSnapshot);
            }

            if (this.parseRequestPath) {
                ServletRequestPathUtils.setParsedRequestPath(previousRequestPath, request);
            }

        }

    }

 

 

 

 

 

 

 

 


Servlet Filters

 

서두에 스프링의 문단속은 서블릿으로부터 시작한다고 했는데,

Spring Security는 클라이언트와 서블릿 간 필터(filter)를 통해 여러 보안 과정을 통과하도록 합니다.

 

Spring Security / Servlet Applications / Architecture - Figure 1. FilterChain

 

 

Spring Security에서 제공한 예시를 살펴보면,

package org.springframework.security.samples.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain

/**
 * @author Eleftheria Stein
 */
@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeRequests {
                authorize("/css/**", permitAll)
                authorize("/user/**", hasAuthority("ROLE_USER"))
                authorize("/index/**", authenticated)
            }
            formLogin {
                loginPage = "/log-in"
            }
        }
        return http.build()
    }

    ...

}

 

filterChain을 통해 인증, 인가 등 다양한 보안 규칙을 만들 수 있는 걸 알 수 있습니다.

 

즉, 이제 서블릿이 무엇이고 스프링 보안에서 서블릿이 어떤 역할을 하고 있는 지 알고 있으므로

문단속을 어떻게 해야 할 지 느낌이 오겠죠?