글 작성자: beaniejoy
목표
- 하나의 request에서 Filter의 중복 호출되는 사례들을 알아보자
- OncePerRequestFilter를 사용함으로써 Filter 중복 호출 방지

Spring Boot를 사용해 web application을 개발하다보면 Filter를 구현해서 적용하는 일이 반드시 생기게 됩니다. Filter를 사용하다보면 Filter가 중복 호출되는 경우가 발생하게 되는데요. 어떤 경우에 이런 Filter 중복 호출 현상이 발생하는지에 대해 알아보고 OncePerRequestFilter를 통해서 이러한 현상을 방지하는 것까지 정리해보려합니다.
(참고로 예시 코드는 kotlin 입니다.)

 

📌 1. 기본적인 Filter 구성

class FirstFilter : Filter {
    companion object : KLogging()

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val req = request as HttpServletRequest
        logger.info { "#### [FirstFilter] [${req.dispatcherType}] request uri ${req.requestURI} ####" }

        chain.doFilter(request, response)
        logger.info { "#### [FirstFilter] after doFilter ####" }
    }
}

class SecondFilter : Filter {
    companion object : KLogging()

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val req = request as HttpServletRequest
        logger.info { "#### [SecondFilter] [${req.dispatcherType}] request uri ${req.requestURI} ####" }

        chain.doFilter(request, response)
        logger.info { "#### [SecondFilter] after doFilter ####" }
    }
}

기본적인 서블릿 Filter 인터페이스를 구현하였고 테스트를 위해 두 개의 Filter를 만들었습니다.

@Configuration
class FilterConfig {

    @Bean
    fun firstFilter(): FilterRegistrationBean<Filter> {
        return FilterRegistrationBean<Filter>().apply {
            this.filter = FirstFilter()
            this.order = 1
        }
    }

    @Bean
    fun secondFilter(): FilterRegistrationBean<Filter> {
        return FilterRegistrationBean<Filter>().apply {
            this.filter = SecondFilter()
            this.order = 2
        }
    }
}

따로 Config file 구성해서 따로 Spring Bean으로 두 개의 필터를 등록했습니다. "setOrder"를 통해 순서까지만 설정하였습니다.

@Controller
@RequestMapping("/api/filter")
class SpringFilterController {
    @ResponseBody
    @GetMapping("/test")
    fun filterTest(): String {
        return "ok"
    }
}

테스트를 위한 api 호출을 위해 test controller를 만들고 테스트용 handler도 하나 적용했습니다.(GET Method)

위와 같이 세팅하고 application을 실행하여 api를 호출해보면 다음과 같이 로그 결과가 나와야 합니다.

적용한 2개의 필터 로그 결과

test api 호출시 request 유입에서는 config file에 적용했던 순서대로 FirstFilter > SecondFilter 순으로 호출이 되고 response 내보내는 과정에서는 반대로 SecondFilter > FirstFilter 순으로 호출되는 것을 확인할 수 있습니다.

기본적인 Filter의 호출되는 과정을 봤습니다.
이렇게 일반적인 케이스에서는 Filter가 한 번씩만 호출되지만 하나의 request에서 하나의 Filter가 중복으로 여러 번 호출되는 경우가 존재합니다. 이러한 케이스에 대해서 한 번 알아보겠습니다.

 

📌 2. Filter 중복 호출 사례

 

🔖 2-1. forward 처리시

forward는 redirect와 비교내용으로 많이 언급되는 페이지 전환 기법입니다. 둘의 차이점에 대해서는 구글에 검색해보시면 자세한 내용 확인할 수 있을 것입니다.(요즘은 ChatGPT로...)

forward 방식은 쉽게 말해서 client 최초 요청 그대로 다른 url로 바로 전달하는 방식입니다. 새로운 url로 새로운 요청정보를 가지고 client에서 다시 request를 보내는 redirect 방식과 다르게 서버 내부에서 요청정보 그대로 새로운 url로 요청을 알아서 전달해준다는 차이점이 있습니다.

여기에서 하나의 request에서 Filter 중복 호출 이슈가 발생할 수 있습니다.

@Bean
fun firstFilter(): FilterRegistrationBean<Filter> {
    return FilterRegistrationBean<Filter>().apply {
        this.filter = FirstFilter()
        this.order = 1
        this.setDispatcherTypes(DispatcherType.REQUEST)
    }
}

@Bean
fun secondFilter(): FilterRegistrationBean<Filter> {
    return FilterRegistrationBean<Filter>().apply {
        this.filter = SecondFilter()
        this.order = 2
        this.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD)
    }
}

Filter를 등록했던 config file에서 SecondFilter에는 DispatcherType으로 FORWARD 타입을 추가해서 등록합니다.

@GetMapping("/forward")
fun forward(): String {
    return "forward:/api/filter/test"
}

그 다음에 위에서 만들었던 Controller에 Spring의 forward 기능을 적용한 api handler를 추가합니다.
이렇게 되면 하나의 요청에서 GET /api/filter/forward 요청시 > GET /api/filter/test로 forwarding 될 것입니다.
한 번 결과를 확인해볼까요.

forwarding 처리된 api 호출시 로그 결과

SecondFilter가 두 번 호출된 것을 확인할 수 있습니다.
forwarding시 filter들이 다음과 같이 처리됨을 알 수 있습니다.

1. GET /api/filter/forward 요청
2. FirstFilter (REQUEST)
3. SecondFilter (REQUEST)
4. api 내부에서 /api/filter/test api로 forwarding 처리
5. SecondFilter (FORWARD)
6. GET /api/filter/test handler 내부에 "ok" return
7. SecondFilter (FORWARD) - filterChain doFilter 이후 프로세스 진행
8. SecondFilter (REQUEST) - filterChain doFilter 이후 프로세스 진행
9. FirstFilter (REQUEST) - filterChain doFilter 이후 프로세스 진행
10. client에 response 반환

FORWARD dispatcherType을 등록한 SecondFilter에 대해서 하나의 요청에 대해 두 번 호출된 것을 확인할 수 있었습니다.

 

🔖 2-2. Spring MVC error 처리시

Spring MVC는 구현한 api 내부 로직에서 exception 발생시 try catch로 따로 잡아내지 않으면 tomcat까지 에러내용이 전달됩니다.
김영한님의 스프링 MVC 2편 강의에서 Spring MVC의 error handling에 대해서 자세하게 설명해주고 있는데요.

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error) -> View

컨트롤러 내부 로직에서 Exception 발생시 따로 handling하지 않으면 tomcat(WAS)까지 해당 예외가 전달이되고 default로 설정되어 있는 /error url을 다시 요청하게 됩니다. 이 과정에서 같은 필터가 중복 호출이 될 수 있습니다.
이부분에 대해서 한 번 실제로 확인해보겠습니다.

@Bean
fun secondFilter(): FilterRegistrationBean<Filter> {
    return FilterRegistrationBean<Filter>().apply {
        this.filter = SecondFilter()
        this.order = 2
        this.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.ERROR)
    }
}

SecondFilter에 dispatcherType으로 ERROR type을 추가 적용해보겠습니다.

@GetMapping("/error")
fun error() {
    throw RuntimeException("error test")
}

RuntimeException을 던지는 api handler를 하나 추가해보겠습니다. 해당 api를 요청하면 다음과 같은 결과가 나옵니다.

error 처리 과정에서의 Filter 중복 호출

SecondFilter가 REQUEST 때 한 번 호출되고 Exception 발생 이후에 ERROR type으로 한 번 더 호출되는 것을 확인할 수 있습니다.

위의 두 개의 사례로 하나의 요청에서 같은 Filter가 중복 호출될 수 있음을 알 수 있었습니다. 위의 사례는 테스트용으로 보여드리기 위해 간단한 상황을 코드로 적용해본 것인데요. 실제로는 아주 복잡한 상황에서 복잡한 프로세스에 의해 Filter가 중복 호출될 수 있기 때문에 개발시 이부분에 대해서 따로 체크를 하시는 것이 좋습니다.

불필요한 중복 호출을 방지하기 위해 OncePerRequestFilter을 제공하고 있는데요. 이것을 한 번 적용해보겠습니다.

 

📌 3. OncePerRequestFilter를 통한 중복 호출 방지 적용해보기

class OnceFilter : OncePerRequestFilter() {
    private val log = KotlinLogging.logger {}

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        log.info { "#### [*OnceFilter*] [${request.dispatcherType}] request uri ${request.requestURI} ####" }
        filterChain.doFilter(request, response)
        log.info { "#### [*OnceFilter*] after doFilter ####" }
    }
}

두 개의 Filter(FirstFilter, SecondFilter)와 다르게 OncePerRequestFilter를 구현하고 있습니다. 위와 같이 간단하게 새로운 필터를 만들고 다음과 같이 설정파일에 Bean으로 적용해줍니다.

@Bean
fun onceFilter(): FilterRegistrationBean<Filter> {
    return FilterRegistrationBean<Filter>().apply {
        this.filter = OnceFilter()
        this.order = 3
        this.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.ERROR)
    }
}

SecondFilter와 똑같이 dispatcherType으로 REQUEST, FORWARD, ERROR 모두 적용해줍니다.
이런 상태에서 forward, exception 발생 상황에 대해서 중복 호출이 발생하는지 확인해보겠습니다.

forward 상황에서 OnceFilter 중복호출 여부 확인
Exception 발생 상황에서 OnceFilter 중복호출 여부 확인

SecondFilter와 똑같은 dispatcherType을 적용했음에도 forward, exception 발생상황에서 OncePerRequestFilter를 구현한 Filter는 한 번만 호출된 것을 확인했습니다.

 

📌 정리

OncePerRequestFilter가 Filter 중복 호출을 방지하고 클래스 이름 그대로 하나의 Request에 한 번만 호출되도록 하는 필터인 것은 전에도 알고 있던 내용이었고 그대로 적용했었는데요.

중요한 것은 어떤 상황에서 하나의 요청에서 같은 Filter가 중복호출이 되는지에 대해서 막연하게만 알았는데 이번을 계기로 좀더 구체적으로 알게 되었습니다. 간단하게 구글링해서 나온 코드를 단순히 복붙으로 적용하는 것보다는 왜 이러한 것이 나오게 됐는지에 대한 배경과 이유를 알고 사용하는 것이 좋다고 생각해서 장황하게 정리를 해보았습니다.

필터 중복 호출은 불필요한 리소스 낭비차원에서 방지해야한다고 생각할 수 있지만,
그것보다 인증, 인가 과정에서 하나의 요청에 대해 불필요한 인증 작업을 두 번이상 진행할 수도 있는 점을 고려해보았을 때 요청 처리 과정에서 치명적인 결함이 발생할 수 있기 때문에 중복 호출 이슈는 가볍게 넘기고 가야할 이슈는 아니라고 생각합니다.

상황에 따라 필수적으로 하나의 요청에 단 한 번만 필터가 적용해야할 필요가 있다면 OncePerRequestFilter를 구현해서 적용하시면 될 것 같습니다.