ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Tomcat 에서 발생하는 간헐적 404 해결기
    Spring Framework 2026. 1. 25. 00:55
    반응형

    개요

    Spring Boot embedded-tomcat 환경의 특정 서버에서 404 응답이 발생하였습니다.

    해당 원인을 분석해서 해결한 내용을 공유해보고자 합니다.

     

    현상

    A -> B 서버로 동일한 http 요청에도 불구하고 6건 중 5건은 성공하고 1건이 실패하였습니다.

    실패했을 때의 응답은 아래와 같습니다.

    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>HTTP Status 404 – Not Found</title>
    
        <style type="text/css">
          ....(축약)
        </style>
      </head>
    
      <body>
        <h1>HTTP Status 404 – Not Found</h1>
      </body>
    </html>

     

    이를 404.html 파일로 만들어서 웹에서 확인하면 아래와 같은 화면을 만날 수 있습니다.

    404 Not Found

     

    유효하지 않은 url으로 접근했을 때 나타나는 Whitelabel 페이지과 차이가 보입니다.

    분석

    위의 404 페이지를 생성하는 클래스는 org.apache.catalina.valves.ErrorReportValve 입니다.

    이는 Tomcat의 클래스이며 호출흐름을 간단히 요약해 보면 Tomcat 레벨에서 에러 페이지가 반환되었음을 추측할 수 있습니다.

    요청
     ↓
    DispatcherServlet 도달 ❌ (아예 못함)
     ↓
    Tomcat 엔진 레벨
     ↓
    ErrorReportValve
     ↓
    기본 HTML 에러 페이지 생성

     

    하지만 해당 케이스의 경우 DispatcherServlet이나 Spring Filter 까지도 도달하지 못해서 적절한 로그가 남지 않고 있었습니다.

     

    따라서 ErrorReportValve 클래스를 동일한 패키지명으로 재정의하고 에러 로그를 추가하였습니다.

     

    이후에 확인해 보면 decodeUrl이 null 인 모습이 확인됩니다.

     

     

    decodeUrl은 org.apache.catalina.connector.CoyoteAdapter 클래스의 postParseRequest 메서드에서 duplicate 메서드가 호출되면서 세팅됩니다.

     

     

    그리고 이때 if 구문에 만족하지 않는다면 else 구문으로 분기되며 in-memory 프로토콜 핸들러 로직으로 처리하게 됩니다.

     

    소켓을 직접 타는 HTTP 요청이 아니라, 내부 컴포넌트 간에 메모리 상에서 전달된 요청으로 가정하여 decodeUrl에 값이 세팅되지 않습니다.

     

    그리고 url에 null이 세팅된다면 org.apache.catalina.mapper.Mapper 클래스에서 Context를 초기화하지 않게 되고 

    org.apache.catalina.core.StandardHostValve 클래스에 의해 404 상태코드가 세팅됩니다.

     

    즉, 아래 분기문이 핵심인데 어떤 상황에서 getType을 호출했을 때 2가 나올 때도 있고 아닐 때도 있을까요?

    } else if (undecodedURI.getType() == 2) {

     

    톰캣은 내부적으로 org.apache.tomcat.util.buf.MessageBytes 클래스를 활용하는데 내부적으로 string인지 byte인지 chars 인지에 따라 타입을 반환합니다.

     

    그렇다면 이 Type이 언제 바뀔 수 있을까요?

    toString 메서드가 호출될 때 내부적으로 type을 T_SRT로 변경합니다.

     

    이런 이유로 Request undecodedURI 필드에 디버깅 포인트를 걸고 getRequestURI() 메서드를 호출하게 되면 내부적으로 MessageBytes의 타입이 변경되게 됩니다.

     

    이제 404가 발생할 수 있는 원인은 추측했는데 어디서 getRequestURI()가 호출되는지를 찾아야 합니다.

     

    디버깅 시 헬스체크가 실패한다는 글을 참고해 보면 open-telemetry-javaagent 를 적용했을 때 requestURI

    의 toString을 호출하는 과정에서 MessageBytes.toString()

    이 호출된 것이 원인이라는 레퍼런스가 존재합니다.

     

    이후에 톰캣에서는 toString이 호출될 때 내부적으로 type을 변경하지 않도록 개선하였지만, 추후에 매번 toString이 호출되어 gc에 부담이 된다는 제보를 받고 내부적으로 type을 변경하여 toString을 호출하지 않도록 캐시하는 toStringType 메서드를 호출하도록 대체되면서 톰캣 버전에 따라 getRequestURI()를 호출한다면 내부적으로 MessageBytes의 type이 변경될 수 있습니다.

     

    유사하게 모니터링 시스템으로 pinpoint를 활용하고 있었기 때문에 pinpoint일 가능성도 열어두었지만 pinpoint가 에러를 유발한다면 간헐적으로 발생하는 것이 아니라 항상 발생해야 합니다.

     

    따라서 원인을 정확하게 파악하기 위해서 org.apache.catalina.connector.Request 클래스를 재정의하여 getRequestURI 메서드가 호출될 때 stack trace 로그를 남겨두었습니다.

     

    원인

    이후 stack trace 로그를 확인했을 때 원인은 애플리케이션의 버그였고, 코루틴 환경에서 다른 쓰레드에 의해 Request 객체의 getRequestURI() 메서드가 호출되었고 수많은 요청 중 간헐적으로 타이밍에 맞춰서 type이 변경되면 404 상태코드가 반환되고 있었습니다.

     

    Spring 에서는 RequestContextHolder를 통하여 ServletRequestAttributes 객체를 가져올 수 있고 이를 통하여 Request 객체에 접근할 수 있습니다.

     

    해당 정보는 ThreadLocal 객체를 통해 접근되므로 코루틴에게 넘겨줄 때는 아래와 같이 넘겨줄 수 있습니다.

    fun CoroutineContext.withRequestAttributesContext() : CoroutineContext{
        return this + RequestContextElement()
    }
    
    class RequestContextElement(
        private val requestAttributes: RequestAttributes? = RequestContextHolder.getRequestAttributes()
    ) : ThreadContextElement<RequestAttributes?> {
    
        companion object Key : CoroutineContext.Key<RequestContextElement>
    
        override val key: CoroutineContext.Key<RequestContextElement>
            get() = Key
    
        /**
         * 코루틴이 특정 스레드에서 실행되기 직전 호출
         */
        override fun updateThreadContext(context: CoroutineContext): RequestAttributes? {
            val previous = RequestContextHolder.getRequestAttributes()
            if (requestAttributes != null) {
                RequestContextHolder.setRequestAttributes(requestAttributes)
            } else {
                RequestContextHolder.resetRequestAttributes()
            }
            return previous
        }
    
        /**
         * 코루틴이 해당 스레드를 떠날 때 호출
         */
        override fun restoreThreadContext(
            context: CoroutineContext,
            oldState: RequestAttributes?
        ) {
            if (oldState != null) {
                RequestContextHolder.setRequestAttributes(oldState)
            } else {
                RequestContextHolder.resetRequestAttributes()
            }
        }
    }

     

    아래와 같이 컨트롤러를 구성하고 성능테스트처럼 1초에 200번 정도 요청을 지속적으로 보내면 200 OK 대신 간헐적으로 404 응답을 받을 수 있습니다.

        @GetMapping("/404-test")
        fun hello(): String{
            CoroutineScope(Dispatchers.IO.withRequestAttributesContext()).launch {
                sleep(Random.nextLong(5, 500)) // 5 ~ 500 ms
                val servletRequestAttributes = RequestContextHolder.getRequestAttributes() as ServletRequestAttributes
                println(servletRequestAttributes.request.requestURI)
            }
            return "hello"
        }

     

    이제 간헐적으로 404 응답코드가 발생될 수 있는 원인을 분석했고, 실제 테스트로 재현도 가능합니다.

    다만 서로 다른 요청의 Request 객체가 어떻게 공유되는거지? 라는 의문이 들었습니다.

     

    org.apache.coyote.AbstractProtocol 클래스를 살펴보면 processorCache 라는 개념이 존재합니다.

    Processor를 매번 생성하지 않고 요청마다 cache에 push 하고 pop 하여 재사용합니다.

    Processor 내부에는 Request, Response 필드도 존재하며 매 요청이 끝나게 되면 recycle 됩니다.

     

    이 과정에서 t1과 t2의 요청이 Reqeust 객체를 동시에 접근할 수 있게 됩니다.

     

     

    수정 방법

    백그라운드 쓰레드에서 접근하는 Request 객체는 요청이 반환되는 순간 recycle 되어 내부적으로 null 값이 세팅되거나 다음 요청의 값으로 세팅될 수 있습니다.

     

    따라서 백그라운드 쓰레드에서 ServletRequestAttributes 객체를 넘기고 사용하는 것은 개념상 적절하지 않습니다.

    RequestContextHolder 대신 Spring Filter에서 RequestContextSnapshotHolder를 정의하여 requestURI 필드 등의 필요한 값들을 data class로 가지고 있으면 됩니다.

     

    재발 방지

    백그라운드 쓰레드에 ServletRequestAttributes  객체를 넘겨서 활용하게 되면 recycle 되어 내부적으로 null 값이 세팅될 수 있습니다.

     

    이때 requestURI() 메서드로 접근할 때 org.apache.catalina.connector.RequestFacade 객체가 request 객체가 null 인 경우 예외를 발생시킵니다.

     

    해당 예외를 우회하기 위해서는 org.apache.catalina.connector.Connector 클래스의 discardFacades 옵션을 비활성화해야 합니다.

     

    해당 옵션은 기본적으로 true인데 false로 비활성화하면 recycle이 발생할 때 Request 객체를 null으로 변경하지 않습니다.

     

    따라서 해당 옵션을 false로 켜주어야 백그라운드 쓰레드에서 예외가 발생하지 않는데, Spring Application이 로드될 때 discardFacades 옵션이 false 라면 예외를 발생시켜 실행되지 않도록 하여 재발방지까지 챙겨볼 수 있습니다.

     

     

    참고자료

    https://medium.com/@wirelesser/debugging-spring-boot-application-health-check-fail-return-404-unhealthy-from-tomcat-b320863ea9ff

     

    댓글

Designed by Tistory.