본문으로 건너뛰기

안동민 개발노트

본문 시작

웹 서버·WAS·서블릿 컨테이너

edge에서 끝나는 요청과 내장 Tomcat의 connector·filter·DispatcherServlet 경로를 실제 loopback 계측으로 구분하고, 연결·스레드·DB lease 용량 경계를 진단합니다.

웹 서버, WAS, 서블릿 컨테이너는 서로 완전히 배타적인 제품 분류가 아닙니다.

Tomcat도 HTTP 연결을 받고 정적 자원을 제공할 수 있고, Nginx 같은 리버스 프록시가 없는 배포도 가능합니다.

WAS는 애플리케이션 코드를 실행하는 서버를 넓게 부르는 운영·교육 용어이지 Jakarta Servlet의 정밀한 구성 요소 이름은 아닙니다.

따라서 제품 이름보다 요청이 어느 hop까지 도달했고 어떤 역할이 응답을 만들었는지를 먼저 봅니다.

  • edge·리버스 프록시는 TLS, 공개 listener, routing, 제한, cache hit에서 요청을 끝낼 수 있습니다.
  • Servlet container는 HTTP 요청을 HttpServletRequestHttpServletResponse로 연결하고 filter chain과 Servlet dispatch를 관리합니다.
  • Spring MVC의 DispatcherServlet은 container에 등록된 Servlet이며 선택된 MVC handler로 요청을 넘깁니다.
  • Spring Boot의 executable JAR가 이들을 한 프로세스에 조립해도 역할과 관찰 경계가 하나로 합쳐지지는 않습니다.
브라우저 요청은 edge proxy에서 TLS나 접근 제한으로 끝나거나 CDN 정적 응답으로 반환될 수 있습니다. origin으로 전달된 요청은 embedded Tomcat connector와 Servlet container의 filter chain을 거쳐 DispatcherServlet에 도달합니다. 기본 Boot MVC 정적 자원은 ResourceHttpRequestHandler로, 동적 API는 controller와 service로 분기합니다. 같은 프로세스 안의 구성 요소도 로그와 계측이 필요한 서로 다른 관찰 경계입니다.

EDGE TERMINATION · ORIGIN CONNECTOR · SERVLET CHAIN · MVC DISPATCH

응답이 끝난 hop부터 찾고, origin에 왔을 때만 Servlet 경로를 추적한다

모든 요청이 controller까지 가는 것은 아닙니다. edge에서 끝나는 경로와 embedded Servlet process에 들어오는 경로를 먼저 나눈 뒤, origin에서는 connector → filter chain → DispatcherServlet → 선택된 handler 순서로 관찰합니다.

edge와 embedded Servlet process의 HTTP 요청 경계 브라우저 요청이 edge proxy에서 종료되거나 정적 응답으로 반환되는 경로와, origin의 connector, filter chain, DispatcherServlet을 거쳐 정적 resource handler 또는 동적 controller와 service로 분기되는 경로를 보여 줍니다. PUBLIC CLIENT EDGE · REVERSE PROXY / CDN EMBEDDED SERVLET PROCESS · ONE OS PROCESS HTTPS STOP AT EDGE EDGE HIT MISS / API STATIC ORIGIN DYNAMIC Browser GET /assets/app.js GET /api/system Edge proxy · CDN TLS · host/path route · cache request_id · upstream_status TLS · access stop origin 미도달 CDN static response HIT이면 origin 미도달 Connector accept · parse Tomcat HTTP Filter chain Servlet container REQUEST dispatch DispatcherServlet MVC handler 선택의 중심 default mapping / Resource handler ResourceHttpRequestHandler Boot MVC 기본 정적 자원 Controller · Service 동적 API business path ATTRIBUTION status alone ≠ 생성 hop edge access log + upstream status origin access log + filter/interceptor trace SAME REQUEST_ID FOCAL REQUEST PATH TERMINATED

01 · EDGE STOP

TLS나 접근 제한에서 끝나면 origin은 요청을 보지 못한다

edge의 handshake·routing·limit 단계가 응답을 만들면 connector, filter, controller 로그가 없는 것이 정상입니다. edge access log와 request ID에서 조사를 시작합니다.

02 · EDGE STATIC HIT

CDN HIT은 origin 정적 handler도 실행하지 않는다

cache HIT은 edge가 저장한 표현으로 끝납니다. MISS나 FETCH일 때만 proxy가 origin으로 요청을 전달합니다.

03 · ORIGIN STATIC

Boot MVC 기본 정적 자원도 DispatcherServlet 경로를 지난다

origin에 도달한 기본 정적 요청은 connector와 filter chain 뒤에서 DispatcherServlet이 MVC의 ResourceHttpRequestHandler를 선택합니다. business controller만 건너뜁니다.

04 · DYNAMIC API

동적 요청은 connector부터 선택된 handler까지 hop별로 관찰한다

embedded server가 한 OS process 안에 있어도 connector, Servlet filter, MVC interceptor, controller는 서로 다른 역할과 실패 경계입니다. 같은 request ID로 로그를 연결해야 404나 timeout의 생성 지점을 좁힐 수 있습니다.

이 경로는 Boot MVC 자동 구성의 기본값을 전제로 합니다. 사용자 resource mapping, @EnableWebMvc, default servlet 활성화에 따라 origin 정적 경로는 달라질 수 있으며, 상태 코드 하나만으로 생성 hop을 확정할 수는 없습니다.


요청은 끝난 hop까지만 통과합니다

edge가 TLS handshake에 실패하거나 요청 크기 제한으로 413을 반환하면 origin connector는 요청을 보지 못합니다.

CDN cache hit도 edge에서 저장한 표현으로 끝나므로 origin의 정적 resource handler가 실행되지 않습니다.

반대로 edge가 cache miss나 동적 API 요청을 upstream으로 전달하면 origin에서 다음 경계를 차례로 관찰할 수 있습니다.

  1. Tomcat connector가 연결을 받고 HTTP를 해석합니다.
  2. Servlet container가 DispatcherType.REQUEST에 해당하는 filter chain을 실행합니다.
  3. DispatcherServlet이 MVC handler를 선택합니다.
  4. 기본 정적 자원은 ResourceHttpRequestHandler, 동적 API는 controller와 그 뒤의 application service로 갑니다.
요청 경로최종 처리 지점origin 관측사용자 controller
TLS 실패·edge 413edge없음없음
CDN·edge static hitedge없음없음
origin staticResourceHttpRequestHandler있음없음
dynamic APIcontroller·application service있음있음

상태 코드만으로 이 표의 행을 선택할 수는 없습니다.

RFC 9110의 404는 현재 표현을 찾지 못했거나 존재를 공개하지 않는다는 의미이지 내부 생성 component를 알려 주지 않습니다.

504도 gateway나 proxy가 제때 upstream 응답을 받지 못했다는 뜻일 뿐 여러 proxy 중 어느 hop인지는 밝히지 않습니다.

edge final status, upstream status, origin access log, 같은 request·trace ID를 연결해야 생성 지점을 좁힐 수 있습니다.


Boot 4는 embedded Servlet server를 조립합니다

이 교재의 빌드 기준은 ch1-1에서 만든 Spring Boot 4.1.1의 spring-boot-starter-webmvc입니다.

Boot 4.1.1의 starter 설명처럼 이 starter의 기본 runtime에는 Spring MVC와 Tomcat이 들어오지만 Tomcat은 교체 가능한 기본 선택입니다.

빌드의 dependency resolution이 실제 runtime graph와 버전을 선택하고, executable JAR는 그 결과와 애플리케이션 class를 함께 패키징합니다.

JAR 파일이 의존성 버전을 새로 결정하는 것은 아닙니다.

Boot는 Servlet web application에서 ServletWebServerFactory로 server를 만들고 DispatcherServlet을 기본 / mapping에 등록합니다.

Boot 4의 정확한 context type은 org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext입니다.

예전 org.springframework.boot.web.servlet.context package를 가져오면 Boot 4에서 컴파일되지 않습니다.

RANDOM_PORT는 test web context에 server.port=0을 적용합니다.

이 annotation만 보고 listener가 실제로 열렸다고 단정하지 않고, 선택 포트와 web server·connector 포트를 비교한 뒤 127.0.0.1로 HTTP를 왕복합니다.


실제 loopback에서 실행 경계를 계측합니다

다음 test fixture는 게시판의 main application이나 /api/system 계약을 다시 만들지 않습니다.

test 전용 /boundary/dynamic, 임시 정적 /boundary.txt, REQUEST filter, MVC interceptor를 한 compilation unit 안에 두고 이 단원이 소유하는 실행 hop만 검증합니다.

src/test/java/board/server/EmbeddedServletBoundaryTest.java
package board.server;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.servlet.context
        .ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.ResourceHttpRequestHandler;
@SpringBootTest(
        classes = EmbeddedServletBoundaryTest.TestApplication.class,
        webEnvironment = RANDOM_PORT,
        properties = {
            "server.address=127.0.0.1",
            "spring.main.banner-mode=off",
            "logging.level.root=OFF"
        })
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Execution(ExecutionMode.SAME_THREAD)
class EmbeddedServletBoundaryTest {
    private static final Path STATIC_ROOT = createStaticRoot();
    private final HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    @LocalServerPort
    int port;
    @Autowired
    ApplicationContext applicationContext;
    @Autowired
    ProbeState state;
    @DynamicPropertySource
    static void staticLocation(DynamicPropertyRegistry registry) {
        registry.add(
                "spring.web.resources.static-locations",
                () -> STATIC_ROOT.toUri().toString());
    }
    @BeforeEach
    void resetCounters() {
        state.reset();
    }
    @AfterAll
    static void removeStaticFixture() throws IOException {
        Files.deleteIfExists(STATIC_ROOT.resolve("boundary.txt"));
        Files.deleteIfExists(STATIC_ROOT);
    }
    @Test
    void Boot가_Tomcat과_DispatcherServlet을_random_port에_조립한다() {
        var context = assertInstanceOf(
                ServletWebServerApplicationContext.class,
                applicationContext);
        var server = assertInstanceOf(
                TomcatWebServer.class,
                context.getWebServer());
        var registration = context.getServletContext()
                .getServletRegistration("dispatcherServlet");
        assertNotNull(registration);
        assertAll(
                () -> assertTrue(port > 0),
                () -> assertEquals(port, server.getPort()),
                () -> assertEquals(
                        port,
                        server.getTomcat()
                                .getConnector()
                                .getLocalPort()),
                () -> assertInstanceOf(
                        DispatcherServlet.class,
                        applicationContext.getBean(
                                "dispatcherServlet")),
                () -> assertEquals(
                        Set.of("/"), registration.getMappings()),
                () -> assertEquals(
                        6,
                        context.getServletContext()
                                .getMajorVersion()),
                () -> assertEquals(
                        1,
                        context.getServletContext()
                                .getMinorVersion()));
    }
    @Test
    void 동적_probe는_loopback_Filter_DispatcherServlet_handler를_통과한다()
            throws Exception {
        var response = send("/boundary/dynamic");
        assertAll(
                () -> assertEquals(200, response.statusCode()),
                () -> assertEquals("dynamic-ok", response.body()),
                () -> assertTrue(contentType(response)
                        .startsWith(MediaType.TEXT_PLAIN_VALUE)),
                () -> assertEquals(
                        "REQUEST",
                        header(response, "X-Boundary-Filter")),
                () -> assertEquals(
                        Integer.toString(port),
                        header(response, "X-Boundary-Local-Port")),
                () -> assertEquals(
                        "handler-method",
                        header(response, "X-Boundary-Mvc-Handler")),
                () -> assertEquals(1, state.filterCalls.get()),
                () -> assertEquals(1, state.interceptorCalls.get()),
                () -> assertEquals(1, state.controllerCalls.get()));
    }
    @Test
    void 정적_probe는_loopback_Filter_DispatcherServlet_resource_handler를_통과한다()
            throws Exception {
        var response = send("/boundary.txt");
        assertAll(
                () -> assertEquals(200, response.statusCode()),
                () -> assertEquals("static-ok", response.body()),
                () -> assertTrue(contentType(response)
                        .startsWith(MediaType.TEXT_PLAIN_VALUE)),
                () -> assertEquals(
                        "REQUEST",
                        header(response, "X-Boundary-Filter")),
                () -> assertEquals(
                        Integer.toString(port),
                        header(response, "X-Boundary-Local-Port")),
                () -> assertEquals(
                        "resource",
                        header(response, "X-Boundary-Mvc-Handler")),
                () -> assertEquals(1, state.filterCalls.get()),
                () -> assertEquals(1, state.interceptorCalls.get()),
                () -> assertEquals(0, state.controllerCalls.get()));
    }
    @Test
    void 없는_경로는_404이고_ProbeController를_호출하지_않는다()
            throws Exception {
        var response = send("/boundary/missing");
        assertAll(
                () -> assertEquals(404, response.statusCode()),
                () -> assertEquals(1, state.filterCalls.get()),
                () -> assertEquals(0, state.controllerCalls.get()));
    }
    private HttpResponse<String> send(String path)
            throws IOException, InterruptedException {
        var request = HttpRequest.newBuilder()
                .uri(URI.create(
                        "http://127.0.0.1:" + port + path))
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();
        return client.send(
                request,
                HttpResponse.BodyHandlers.ofString(UTF_8));
    }
    private static String header(
            HttpResponse<?> response, String name) {
        return response.headers()
                .firstValue(name)
                .orElse("");
    }
    private static String contentType(HttpResponse<?> response) {
        return header(response, "Content-Type");
    }
    private static Path createStaticRoot() {
        Path root = null;
        try {
            root = Files.createTempDirectory(
                    "embedded-servlet-boundary-");
            Files.writeString(
                    root.resolve("boundary.txt"),
                    "static-ok",
                    UTF_8);
            return root;
        } catch (IOException exception) {
            if (root != null) {
                try {
                    Files.deleteIfExists(
                            root.resolve("boundary.txt"));
                    Files.deleteIfExists(root);
                } catch (IOException cleanupException) {
                    exception.addSuppressed(cleanupException);
                }
            }
            throw new UncheckedIOException(exception);
        }
    }
    @SpringBootConfiguration
    @EnableAutoConfiguration
    static class TestApplication {
        @Bean
        ProbeState probeState() {
            return new ProbeState();
        }
        @Bean
        ProbeController probeController(ProbeState state) {
            return new ProbeController(state);
        }
        @Bean
        FilterRegistrationBean<BoundaryFilter> boundaryFilter(
                ProbeState state) {
            var registration = new FilterRegistrationBean<>(
                    new BoundaryFilter(state));
            registration.setName("boundaryFilter");
            registration.setDispatcherTypes(DispatcherType.REQUEST);
            registration.addUrlPatterns("/*");
            registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
            return registration;
        }
        @Bean
        WebMvcConfigurer boundaryMvcConfigurer(ProbeState state) {
            return new WebMvcConfigurer() {
                @Override
                public void addInterceptors(
                        InterceptorRegistry registry) {
                    registry.addInterceptor(
                            new BoundaryInterceptor(state));
                }
            };
        }
    }
    static final class ProbeState {
        private final AtomicInteger filterCalls =
                new AtomicInteger();
        private final AtomicInteger interceptorCalls =
                new AtomicInteger();
        private final AtomicInteger controllerCalls =
                new AtomicInteger();
        void reset() {
            filterCalls.set(0);
            interceptorCalls.set(0);
            controllerCalls.set(0);
        }
    }
    @RestController
    static final class ProbeController {
        private final ProbeState state;
        ProbeController(ProbeState state) {
            this.state = state;
        }
        @GetMapping(
                value = "/boundary/dynamic",
                produces = MediaType.TEXT_PLAIN_VALUE)
        String dynamic() {
            state.controllerCalls.incrementAndGet();
            return "dynamic-ok";
        }
    }
    static final class BoundaryFilter
            extends OncePerRequestFilter {
        private final ProbeState state;
        BoundaryFilter(ProbeState state) {
            this.state = state;
        }
        @Override
        protected void doFilterInternal(
                HttpServletRequest request,
                HttpServletResponse response,
                FilterChain chain)
                throws ServletException, IOException {
            state.filterCalls.incrementAndGet();
            response.setHeader(
                    "X-Boundary-Filter",
                    request.getDispatcherType().name());
            response.setHeader(
                    "X-Boundary-Local-Port",
                    Integer.toString(request.getLocalPort()));
            chain.doFilter(request, response);
        }
    }
    static final class BoundaryInterceptor
            implements HandlerInterceptor {
        private final ProbeState state;
        BoundaryInterceptor(ProbeState state) {
            this.state = state;
        }
        @Override
        public boolean preHandle(
                HttpServletRequest request,
                HttpServletResponse response,
                Object handler) {
            state.interceptorCalls.incrementAndGet();
            String kind;
            if (handler instanceof HandlerMethod) {
                kind = "handler-method";
            } else if (handler
                    instanceof ResourceHttpRequestHandler) {
                kind = "resource";
            } else {
                kind = handler.getClass().getSimpleName();
            }
            response.setHeader("X-Boundary-Mvc-Handler", kind);
            return true;
        }
    }
}

첫 번째 test는 generic web context에 그치지 않고 TomcatWebServer, underlying Tomcat connector의 local port, dispatcherServlet bean과 / mapping, Servlet 6.1 runtime을 함께 확인합니다.

두 번째와 세 번째 test는 같은 loopback listener와 REQUEST filter를 지난 뒤 MVC interceptor가 각각 HandlerMethodResourceHttpRequestHandler를 관찰했음을 응답 header와 counter로 교차 검증합니다.

네 번째 test가 증명하는 범위는 “이 fixture의 ProbeController는 호출되지 않았고 최종 응답은 404였다”까지입니다.

없는 정적 자원의 fallback이나 error 처리도 관여할 수 있으므로 interceptor 횟수, 오류 본문, Content-Type, 내부 404 생성 component는 고정하지 않습니다.

이 test는 실제 loopback socket과 embedded Tomcat·Servlet·MVC 경계를 검증하지만 외부 TLS, reverse proxy, CDN, production connector 설정, 운영 용량은 통과하지 않습니다.


origin 정적 자원도 기본 MVC 경로를 지납니다

Boot 4.1.1의 정적 콘텐츠 기본값에서는 classpath의 /static, /public, /resources, /META-INF/resources를 MVC ResourceHttpRequestHandler가 제공합니다.

standalone application의 container default Servlet은 기본적으로 활성화되지 않으므로 origin 정적 요청도 보통 DispatcherServlet까지 갑니다.

다만 business controller와 application service는 호출하지 않습니다.

@EnableWebMvc, 사용자 resource mapping, spring.web.resources.add-mappings=false, default Servlet 활성화 같은 설정을 바꾸면 이 경로도 달라집니다.

CDN hit와 origin static을 모두 “정적 처리”라고 묶지 않는 이유입니다.


연결·thread·lease는 서로 다른 용량입니다

Tomcat connector와 application dependency에는 단위가 다른 여러 한도가 있습니다.

  • maxConnections는 connector가 accepted·processed하는 connection 수입니다.
  • acceptCountmaxConnections에 도달한 뒤 운영체제가 보관하는 incoming connection backlog입니다. 애플리케이션 request queue가 아닙니다.
  • maxThreads는 connector 또는 연결된 executor의 request-processing thread 수입니다.
  • DB pool size는 동시에 빌릴 수 있는 DB connection lease 수입니다.

Tomcat 11 HTTP connector 문서는 이 제한을 각각 정의하고, executor를 연결하면 connector의 maxThreads가 무시될 수 있으며 운영체제가 backlog 설정을 다르게 다룰 수도 있음을 명시합니다.

따라서 500 threads - 20 DB connections = 480 waiting은 일반 공식이 아닙니다.

동시에 실행 중인 동기 요청 60개가 모두 DB lease 하나를 요구하고 pool이 20이라는 조건을 먼저 잠갔을 때만 그 순간 20 leased / 40 waiting이라고 말할 수 있습니다.

같은 관측 시점에 24개, 20개, 16개의 동기 요청이 모두 데이터베이스 연결 하나를 필요로 한다는 예시입니다. 합계 60개 요청은 500개 request-processing thread 예시 안에서 실행되지만 DB pool에는 20개 lease만 있어 20개가 진입하고 40개가 기다립니다. connector maxConnections와 acceptCount는 연결 단위이고 worker는 thread 단위이며 DB pool은 lease 단위이므로 서로 더하거나 가장 작은 숫자만으로 병목을 판정할 수 없습니다.

CONCURRENT SNAPSHOT · CONNECTION BOUNDARY · WORKER THREADS · DB LEASE WAIT

같은 단위의 동시 수요와 한도를 맞춘 뒤 대기 위치를 찾는다

이 그림은 처리율 계산이 아니라 한 시점의 조건부 snapshot입니다. 동기 요청 60개가 모두 DB lease를 요구한다는 조건에서 20 leased / 40 waiting을 설명하고, 연결·thread·lease 경계는 별도 지표로 관찰합니다.

동시 HTTP 요청의 fan-in과 DB lease 대기 경계 세 종류의 동기 요청 60개가 같은 시점에 connector와 worker를 지나 20개 DB lease로 모이는 조건부 예시입니다. 20개는 DB 작업에 진입하고 40개는 lease를 기다리며, connector backlog와 worker thread는 서로 다른 단위의 경계로 구분됩니다. IN-FLIGHT SNAPSHOT CONNECTOR REQUEST EXECUTION DB ACQUISITION OUTCOME Σ 60 60 NEED 20 LEASED DEADLINE GET /posts 24 concurrent requests GET /search 20 concurrent requests POST /posts 16 concurrent requests Tomcat connector maxConnections accepted connections request count와 별도 OS accept backlog acceptCount · connections maxConnections 도달 뒤 Worker capacity 60 active / max 500 request-processing threads 동기 처리 예시 DB pool 20 concurrent leases 20 requests admitted 40 requests must wait DB lease wait 10 10 10 10 40 waiting requests DB work 20 running lease 반환 뒤 waiter가 진입 Lease timeout deadline exceeded acquisition fails EXAMPLE CONDITION 같은 관측 시점 · 동기 요청 60개 모두 DB connection 1개 필요 500·20은 기본값/권장값 아님 snapshot ≠ throughput UNITS requests = in-flight snapshot connections = connector/backlog threads = request execution leases = DB concurrency 요청률을 비교하려면 별도로 도착률·service time·완료율을 같은 시간창에서 측정한다.

01 · SNAPSHOT CONDITION

같은 시점의 동기 요청 24 + 20 + 16 = 60개를 관찰한다

60개가 모두 DB connection 하나를 필요로 한다는 설명용 조건입니다. 이 수는 requests/s가 아니며 처리율이나 권장 용량을 뜻하지 않습니다.

02 · CONNECTION BOUNDARY

maxConnectionsacceptCount는 연결 단위다

maxConnections는 accepted·processed connection 수입니다. 그 한도에 도달한 뒤의 acceptCount는 OS가 보관하는 incoming connection backlog이며 애플리케이션 request queue가 아닙니다.

03 · WORKER CAPACITY

60 active / max 500 threads도 동기 처리 예시다

동기 요청은 blocking wait 동안 request-processing thread를 점유합니다. Servlet async는 최초 dispatch thread를 반환할 수 있지만, callback 실행이나 redispatch에는 다시 실행 자원이 필요합니다.

04 · DB LEASE WAIT

20 leases가 차면 나머지 40 requests는 기다린다

lease가 반환되면 waiter가 진입하고, acquisition deadline을 넘으면 timeout이 됩니다. connector reject나 proxy timeout과 합치지 말고 active·max·pending·acquisition latency를 함께 관찰합니다.

  • DB lease로 모이는 focal flow
  • lease 반환 뒤 waiter 진입
  • admission·wait·deadline 경계

운영값은 실제 Tomcat connector·executor·DB pool 설정과 resolved server version을 기준으로 확인합니다. async나 virtual thread도 DB lease 같은 downstream permit 수를 자동으로 늘리지 않습니다.

요청률을 용량과 비교하려면 같은 시간창의 arrival rate, service time, completion rate가 더 필요합니다.

keep-alive와 HTTP/2 때문에 connection 하나가 request 하나라고 볼 수도 없습니다.

용량을 조정할 때는 숫자 하나가 아니라 다음 신호를 같은 시간축에서 봅니다.

경계핵심 단위함께 볼 신호
edge·proxyrequest·timefinal status, upstream status, total/upstream time
Tomcat connectorconnectioncurrent connections, accept·reject, backlog
request workerthreadbusy, max, executor queue, request duration
DB poollease·timeactive, max, pending, acquisition latency·timeout, lease hold·query duration

edge 로그만 있고 origin 신호가 없으면 edge stop·cache hit·upstream 연결 실패가 후보입니다.

origin filter 신호는 있는데 controller 호출이 없으면 정적 handler, mapping failure, controller 이전 실패를 구분합니다.

Tomcat busy thread와 DB pending이 함께 늘면 downstream 병목을 의심할 수 있지만 metric 상관관계만으로 인과를 확정하지는 않습니다.


동기와 async의 thread 점유를 구분합니다

일반적인 non-async Servlet 요청은 애플리케이션 처리가 끝날 때까지 request-processing thread를 사용합니다.

blocking DB나 외부 API를 기다리는 시간도 포함됩니다.

Jakarta Servlet 6.1 specification의 async 계약에서는 startAsync() 뒤 최초 container thread를 반환할 수 있습니다.

이후 callback이 응답을 완료하거나 AsyncContext.dispatch()가 container-managed thread로 다시 진입합니다.

따라서 async는 “thread가 필요 없다”는 뜻이 아니며 filter·Servlet의 async support, timeout, complete(), request·response 접근 규칙을 함께 설계해야 합니다.

virtual thread도 blocking 비용의 형태를 바꿀 뿐 DB pool이나 외부 서비스의 동시성 permit을 늘리지는 않습니다.


timeout은 제품별 의미와 취소 전파를 확인합니다

예를 들어 Nginx의 proxy_read_timeout은 전체 응답의 절대 기한이 아니라 연속된 두 upstream read 사이에 아무 데이터도 오지 않은 시간입니다.

timeout 뒤 다른 upstream을 시도할지, proxy가 오류 응답을 바꿀지, client 연결 종료가 origin 작업 취소로 전파될지는 별도 설정과 application 계약입니다.

바깥 timeout을 안쪽 timeout보다 단순히 작게 또는 크게 두는 규칙만으로는 충분하지 않습니다.

전체 deadline, 재시도 budget, idempotency, 취소 전파, 각 hop의 관측값을 함께 맞춥니다.


연습 문제

위 fixture에 다음 두 실험을 설계하세요.

  1. spring.web.resources.add-mappings=false를 적용했을 때 /boundary.txt의 상태, filter counter, controller counter 가운데 무엇이 그대로이고 무엇이 달라질지 먼저 기록합니다.
  2. DB lease acquisition deadline을 넘긴 요청과 edge read timeout을 각각 어떤 metric·log 조합으로 구분할지 표로 작성합니다.
해설 보기

첫 실험에서 REQUEST filter는 origin에 들어온 요청을 계속 관찰하고 ProbeController는 호출되지 않습니다.

하지만 기본 MVC resource mapping을 껐으므로 기존 static 200 계약은 더 이상 성립하지 않습니다.

최종 404를 어느 내부 component가 만들었다고 status만으로 단정하지 말고 handler 분류와 error dispatch를 새 설정에서 다시 관찰해야 합니다.

두 번째 실험은 최소한 다음 열을 분리합니다.

증상필요한 origin 신호필요한 edge 신호
DB lease acquisition 초과pool pending·acquisition time·timeout, trace같은 request ID의 upstream status·time
edge upstream read 초과origin 도달·완료·취소 여부, tracefinal status, upstream read gap·retry

origin 작업이 완료됐다는 사실만으로 client가 응답을 받았다고 볼 수 없고, edge timeout이 보인다는 사실만으로 DB가 원인이라고 볼 수도 없습니다.

다음 문서에서는 Servlet 객체의 등록·초기화·service·소멸과 여러 요청 thread가 공유 인스턴스를 호출할 때의 thread safety를 다룹니다.