본문 바로가기

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

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

웹 서버, WAS, 서블릿 컨테이너는 서로 딱 나뉘는 제품 분류가 아닙니다. Tomcat도 HTTP 연결을 받고 정적 자원을 줄 수 있으며, Nginx 같은 리버스 프록시 없이 배포할 수도 있습니다. WAS(Web Application Server)는 애플리케이션 코드를 실행하는 서버를 넓게 부르는 운영과 교육의 용어일 뿐, Jakarta Servlet이 정한 구성 요소의 이름은 아닙니다. 그래서 제품 이름보다, 요청이 어느 구간(hop)까지 갔고 어떤 역할이 응답을 만들었는지를 먼저 봅니다.

가장자리(edge)의 리버스 프록시는 TLS나 공개 리스너, 라우팅, 제한, 캐시 적중에서 요청을 끝낼 수 있습니다. 서블릿 컨테이너는 HTTP 요청을 HttpServletRequest와 HttpServletResponse로 바꾸고 필터 체인과 서블릿 호출을 관리합니다. Spring MVC의 DispatcherServlet은 컨테이너에 등록된 서블릿 하나이며, 고른 MVC 핸들러에 요청을 넘깁니다. Spring Boot의 실행 가능한 JAR가 이것들을 한 프로세스에 조립하더라도, 역할과 관찰해야 할 지점이 하나로 합쳐지지는 않습니다.

같은 HTTP 요청도 edge와 embedded Servlet process에서 서로 다른 경로로 끝난다

edge 종료 여부 뒤 전달 요청만 connector·filter·MVC·handler에 도달하는 실제 경계를 따라간다.

브라우저 요청은 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에서 종료하거나 origin handler로 분기하는 요청edge에서 끝난 요청은 origin에 들어가지 않습니다. 전달된 요청만 connector, REQUEST filter, DispatcherServlet의 MVC 선택을 지나 정적 resource handler 또는 controller와 service로 갑니다.BrowserHTTP 요청Edge proxy · CDN라우팅 · 캐시TLS / 제한 / HITedge에서 끝남ConnectorHTTP 연결·해석Filter chainREQUEST dispatchMVChandler 선택ResourceHttpRequestHandlerBoot 기본 정적 자원Controller → Service동적 APIorigin으로 전달된 요청만 진입origin: embedded Servlet process · 한 OS process
edge 종료와 origin handler 분기를 모바일에서도 따라간다브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원 desktop 8노드7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.origin · 한 프로세스origin 전달정적 자원동적 APIBrowserHTTP 요청Edge proxy · CDN라우팅 · 캐시TLS / 제한 / HITedge에서 끝남ConnectorHTTP 연결·해석Filter chainREQUEST dispatchDispatcherServletMVC handler 선택정적 자원ResourceHttpRequestHandlerController → Service동적 API

01 · EDGE STOP

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

TLS handshake 실패에는 HTTP 응답이 없을 수 있고, edge의 routing·limit 거절은 HTTP 응답을 만들 수 있습니다. origin까지 전달되지 않았다면 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을 확정할 수는 없습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • edge에서 끝난 요청은 origin에 들어가지 않습니다. 전달된 요청만 connector, REQUEST filter, DispatcherServlet의 MVC 선택을 지나 정적 resource handler 또는 controller와 service로 갑니다.
  • 브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원래 가로 도표의 8노드와 7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1같은 HTTP 요청도 edge와 embedded Servlet process에서 서로 다른 경로로 끝난다
30브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원래 가로 도표의 8노드와 7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
edge 종료 판정

edge에서 끝나면 origin에 들어가지 않습니다.

edge 종료: origin 0 · 전달: origin 진입origin 도달 여부가 정해집니다.
Servlet 진입

전달 요청만 connector·REQUEST filter·DispatcherServlet을 지납니다.

요청: 전달된 요청 · MVC: handler 선택origin 내부 처리 경계에 도달합니다.
선택 handler 실행

정적 resource 또는 controller·service를 실행합니다.

선택: resource 또는 controller · edge 종료: 이 단계 없음선택된 경로만 실제 응답을 만듭니다.

같은 HTTP 요청도 edge와 embedded Servlet process에서 서로 다른 경로로 끝난다

edge 종료 여부 뒤 전달 요청만 connector·filter·MVC·handler에 도달하는 실제 경계를 따라간다.

브라우저 요청은 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에서 종료하거나 origin handler로 분기하는 요청edge에서 끝난 요청은 origin에 들어가지 않습니다. 전달된 요청만 connector, REQUEST filter, DispatcherServlet의 MVC 선택을 지나 정적 resource handler 또는 controller와 service로 갑니다.BrowserHTTP 요청Edge proxy · CDN라우팅 · 캐시TLS / 제한 / HITedge에서 끝남ConnectorHTTP 연결·해석Filter chainREQUEST dispatchMVChandler 선택ResourceHttpRequestHandlerBoot 기본 정적 자원Controller → Service동적 APIorigin으로 전달된 요청만 진입origin: embedded Servlet process · 한 OS process
edge 종료와 origin handler 분기를 모바일에서도 따라간다브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원 desktop 8노드7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.origin · 한 프로세스origin 전달정적 자원동적 APIBrowserHTTP 요청Edge proxy · CDN라우팅 · 캐시TLS / 제한 / HITedge에서 끝남ConnectorHTTP 연결·해석Filter chainREQUEST dispatchDispatcherServletMVC handler 선택정적 자원ResourceHttpRequestHandlerController → Service동적 API

01 · EDGE STOP

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

TLS handshake 실패에는 HTTP 응답이 없을 수 있고, edge의 routing·limit 거절은 HTTP 응답을 만들 수 있습니다. origin까지 전달되지 않았다면 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을 확정할 수는 없습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • edge에서 끝난 요청은 origin에 들어가지 않습니다. 전달된 요청만 connector, REQUEST filter, DispatcherServlet의 MVC 선택을 지나 정적 resource handler 또는 controller와 service로 갑니다.
  • 브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원래 가로 도표의 8노드와 7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1같은 HTTP 요청도 edge와 embedded Servlet process에서 서로 다른 경로로 끝난다
30브라우저에서 edge로 요청한다. edge가 종료한 요청은 origin에 들어가지 않는다. 전달된 요청만 같은 origin 프로세스의 connector, REQUEST filter, DispatcherServlet을 거쳐 정적 ResourceHttpRequestHandler 또는 controller와 service로 나뉜다. 원래 가로 도표의 8노드와 7관계와 같은 뜻이며 TLS 실패에는 HTTP 응답이 없을 수도 있다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
edge 종료 판정

edge에서 끝나면 origin에 들어가지 않습니다.

edge 종료: origin 0 · 전달: origin 진입origin 도달 여부가 정해집니다.
Servlet 진입

전달 요청만 connector·REQUEST filter·DispatcherServlet을 지납니다.

요청: 전달된 요청 · MVC: handler 선택origin 내부 처리 경계에 도달합니다.
선택 handler 실행

정적 resource 또는 controller·service를 실행합니다.

선택: resource 또는 controller · edge 종료: 이 단계 없음선택된 경로만 실제 응답을 만듭니다.

그림은 브라우저의 요청이 가장자리에서 끝나거나, 원 서버(origin)로 넘어가 그 안에서 다시 갈라지는 경로를 보여 줍니다. TLS 실패나 접근 제한, CDN 캐시 적중은 가장자리에서 끝나므로 원 서버는 요청을 보지 못합니다. 원 서버로 넘어간 요청만 내장 Tomcat의 커넥터(connector)와 서블릿 컨테이너의 필터 체인을 거쳐 DispatcherServlet에 닿고, 기본 정적 자원은 ResourceHttpRequestHandler로, 동적 API는 컨트롤러와 서비스로 나뉩니다. 같은 프로세스 안의 구성 요소도 로그와 계측을 따로 두어야 하는 서로 다른 관찰 지점입니다.


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

가장자리에서 TLS 핸드셰이크가 실패하거나 요청 크기 제한으로 413을 돌려주면, 원 서버의 커넥터는 요청을 보지 못합니다. CDN의 캐시 적중도 가장자리가 저장한 표현으로 끝나므로 원 서버의 정적 자원 처리기가 실행되지 않습니다. 반대로 가장자리가 캐시 미스나 동적 API 요청을 업스트림으로 넘기면, 원 서버에서는 Tomcat 커넥터가 연결을 받아 HTTP를 해석하고, 서블릿 컨테이너가 DispatcherType.REQUEST에 해당하는 필터 체인을 실행하며, DispatcherServlet이 MVC 핸들러를 고르고, 기본 정적 자원은 ResourceHttpRequestHandler로, 동적 API는 컨트롤러와 그 뒤의 애플리케이션 서비스로 가는 순서를 차례로 관찰할 수 있습니다.

요청 경로마지막으로 처리한 곳원 서버에서 관찰됨사용자 컨트롤러
TLS 실패, 가장자리의 413가장자리아니오아니오
CDN이나 가장자리의 정적 적중가장자리아니오아니오
원 서버의 정적 자원ResourceHttpRequestHandler예아니오
동적 API컨트롤러와 애플리케이션 서비스예예

상태 코드만으로 이 표의 어느 행인지 고를 수는 없습니다. RFC 9110의 404는 지금의 표현을 찾지 못했거나 존재를 밝히지 않는다는 뜻일 뿐, 어느 내부 구성 요소가 만들었는지 알려 주지 않습니다. 504도 게이트웨이나 프록시가 제때 업스트림 응답을 받지 못했다는 뜻일 뿐, 여러 프록시 중 어느 구간인지는 밝히지 않습니다. 가장자리의 최종 상태와 업스트림 상태, 원 서버의 접근 로그를 같은 요청 ID나 트레이스 ID로 이어야 응답을 만든 곳을 좁힐 수 있습니다.


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

이 교재의 빌드 기준은 Java 25와 Spring Boot에서 만든 Spring Boot 4.1.2의 spring-boot-starter-webmvc입니다. Boot의 스타터 설명처럼 이 스타터는 기본으로 Spring MVC와 Tomcat을 가져오지만, Tomcat은 바꿀 수 있는 기본 선택입니다. 실제로 실행에 쓰일 의존성 그래프와 버전은 빌드의 의존성 해석이 고르고, 실행 가능한 JAR는 그 결과와 애플리케이션 클래스를 함께 묶을 뿐 버전을 새로 정하지 않습니다.

Boot는 서블릿 웹 애플리케이션에서 ServletWebServerFactory로 서버를 만들고, DispatcherServlet을 기본 경로 /에 등록합니다. Boot 4의 정확한 컨텍스트 타입은 org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext이며, 예전의 org.springframework.boot.web.servlet.context 패키지를 임포트하면 Boot 4에서 컴파일되지 않습니다. 테스트의 RANDOM_PORT는 테스트 웹 컨텍스트에 server.port=0을 적용합니다. 이 애노테이션만 보고 리스너가 정말 열렸다고 단정하지 않고, 고른 포트와 웹 서버, 커넥터의 포트를 비교한 뒤 127.0.0.1로 HTTP를 실제로 주고받아 확인합니다.


실제 loopback에서 실행 구간을 계측합니다

다음 테스트 픽스처는 게시판의 메인 애플리케이션이나 /api/system을 다시 만들지 않습니다. 테스트 전용 /boundary/dynamic과 임시 정적 파일 /boundary.txt, REQUEST 필터, MVC 인터셉터를 한 파일 안에 두고, 이 글이 다루는 실행 구간만 확인합니다. 테스트 파일은 게시판 프로젝트의 src/test/java에 둡니다.

먼저 무엇을 세는지 구별해 두겠습니다. REQUEST는 GET이나 POST 같은 HTTP 메서드가 아니라 서블릿이 처음 호출될 때의 디스패치 종류입니다. 같은 HTTP 요청도 오류 처리나 비동기 재진입에서는 다른 디스패치 종류를 가질 수 있습니다. 이 픽스처는 필터를 REQUEST에만 등록하므로, 필터 횟수 1을 "모든 디스패치에서 필터는 한 번만 실행된다"는 일반 규칙으로 넓히면 안 됩니다. 또 client.send는 실제로 HTTP를 주고받지만 주소가 127.0.0.1이므로 바깥의 가장자리나 TLS, CDN은 지나지 않습니다.

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;
        }
    }
}

첫 테스트는 일반적인 웹 컨텍스트에 그치지 않고, TomcatWebServer와 그 아래 Tomcat 커넥터의 로컬 포트, dispatcherServlet 빈과 / 매핑, 서블릿 6.1 런타임을 함께 확인합니다. 둘째와 셋째 테스트는 같은 루프백 리스너와 REQUEST 필터를 지난 뒤, MVC 인터셉터가 각각 HandlerMethod와 ResourceHttpRequestHandler를 보았다는 것을 응답 헤더와 횟수로 함께 확인합니다. 넷째 테스트가 확인하는 것은 "이 픽스처의 ProbeController는 호출되지 않았고 최종 응답은 404였다"까지입니다. 없는 정적 자원의 대체 처리나 오류 처리도 끼어들 수 있으므로, 인터셉터 횟수나 오류 본문, Content-Type, 404를 만든 내부 구성 요소는 확인하지 않습니다. 이 테스트는 실제 루프백 소켓과 내장 Tomcat, 서블릿, MVC의 구간을 확인하지만, 바깥의 TLS나 리버스 프록시, CDN, 운영 커넥터 설정과 용량은 지나지 않습니다.


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

Boot의 정적 콘텐츠 기본값에서는 클래스 경로의 /static, /public, /resources, /META-INF/resources를 MVC의 ResourceHttpRequestHandler가 제공합니다. 혼자 실행되는 애플리케이션에서는 컨테이너의 기본 서블릿이 켜져 있지 않으므로, 원 서버의 정적 요청도 보통 DispatcherServlet까지 갑니다. 다만 업무 컨트롤러와 애플리케이션 서비스는 호출하지 않습니다. @EnableWebMvc나 사용자 정의 자원 매핑, spring.web.resources.add-mappings=false, 기본 서블릿 활성화 같은 설정을 바꾸면 이 경로도 달라지므로, CDN 적중과 원 서버의 정적 자원을 모두 "정적 처리"라고 묶지 않습니다.


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

Tomcat 커넥터와 애플리케이션의 의존 대상에는 단위가 서로 다른 여러 한도가 있습니다. maxConnections는 커넥터가 받아 처리하는 연결의 수입니다. acceptCount는 maxConnections에 이른 뒤 운영체제가 대기열에 쌓아 두는 들어오는 연결의 수이며, 애플리케이션의 요청 대기열이 아닙니다. maxThreads는 커넥터나 연결된 실행기(executor)가 요청을 처리하는 스레드의 수이고, DB 풀의 크기는 동시에 빌려 쓸 수 있는 DB 연결(lease)의 수입니다. Tomcat 11의 HTTP 커넥터 문서는 이 한도를 각각 정의하며, 실행기를 연결하면 커넥터의 maxThreads가 무시될 수 있고 운영체제가 대기열 설정을 다르게 다룰 수 있다고도 밝힙니다.

그러니 500 threads - 20 DB connections = 480 waiting 같은 계산은 일반적인 공식이 아닙니다. 동시에 실행 중인 동기 요청 60개가 모두 DB 연결 하나씩을 요구하고, 풀이 20이며, 그 순간 20개가 모두 빌려 쓰이는 중이라는 조건을 먼저 정했을 때만 "20개 사용 중, 40개 대기"라고 말할 수 있습니다. 설정값 max=20만 읽고 그 순간 사용 중인 것도 20이라고 단정할 수는 없습니다. 또 20은 동시에 들고 있는 수이지 초당 처리 수가 아닙니다. 빌려 쓰는 시간이 50ms이고 모든 연결이 쉬지 않는다는 이상적인 조건에서만 20 ÷ 0.05 = 초당 400회라는 상한을 계산할 수 있으며, 이것도 실제 요청 처리율이나 권장 풀 크기를 뜻하지는 않습니다.

동시 요청이 한정된 DB lease로 모이면 대기 경계가 드러난다

60개 요청·20 lease·40개 대기에서 반환 후 진입과 획득 기한 초과에 따른 실패로 같은 대기 상태가 변한다.

같은 관측 시점에 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 경계는 별도 지표로 관찰합니다.

60개의 동기 DB 수요가 20개 lease와 40개 대기로 나뉘는 snapshot이 예제는 같은 시점의 조건부 수요 60개와 DB pool 20개를 비교합니다. 20개가 DB 작업을 하고 40개는 기다립니다. lease 반환 시 진입할 수 있고 acquisition 기한을 넘으면 실패합니다.동기 요청 24 + 20 + 16 = 60모두 DB connection 1개 필요DB pool · 20 leases같은 순간의 대여 가능 수20 leasedDB 작업 중40 waiting동기 worker도 대기 중lease 반환 시 waiter 진입acquisition 기한 초과 → timeout
60개 동시 수요와20개 DB lease의 분기같은 순간의 동기 요청24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool20개 lease에서20개는 작업하고40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원 desktop4노드4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.대여 가능초과 수요lease 반환 시동기 요청 24+20+16합계 60DB connection 1개씩DB pool20 leases20 leasedDB 작업 중40 waiting동기 worker 대기기한 초과 → timeout

01 · SNAPSHOT CONDITION

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

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

02 · CONNECTION BOUNDARY

maxConnections와 acceptCount는 연결 단위다

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를 함께 관찰합니다.

500 threads와 20 leases는 설명용 수치입니다. snapshot은 처리율이 아니며, 운영에서는 같은 시간창의 도착률·service time·완료율과 busy·pending·acquisition latency를 함께 측정합니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 이 예제는 같은 시점의 조건부 수요 60개와 DB pool 20개를 비교합니다. 20개가 DB 작업을 하고 40개는 기다립니다. lease 반환 시 진입할 수 있고 acquisition 기한을 넘으면 실패합니다.
  • 같은 순간의 동기 요청 24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool의 20개 lease에서 20개는 작업하고 40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원래 가로 도표의 4노드와 4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1동시 요청이 한정된 DB lease로 모이면 대기 경계가 드러난다
2160개 동시 수요와 20개 DB lease의 분기
22같은 순간의 동기 요청 24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool의 20개 lease에서 20개는 작업하고 40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원래 가로 도표의 4노드와 4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
동시 수요 발생

예제의 같은 시점에 60개의 조건부 DB 수요가 있습니다.

수요: 60 · pool: 20제한된 연결 풀에 동시 수요가 모입니다.
20개 실행과 40개 대기

연결 20개를 가진 작업이 DB를 사용하고 나머지 40개는 기다립니다.

DB 작업: 20 · 대기: 40 · pool: 20수요가 실행과 획득 대기 상태로 나뉩니다.
반환 또는 획득 실패

lease가 돌아오면 대기 작업이 들어갈 수 있고 획득 기한을 넘으면 실패합니다.

반환 후: 대기 작업 진입 가능 · 기한 초과: 획득 실패연결 반환과 획득 기한에 따라 대기 상태가 해소됩니다.

동시 요청이 한정된 DB lease로 모이면 대기 경계가 드러난다

60개 요청·20 lease·40개 대기에서 반환 후 진입과 획득 기한 초과에 따른 실패로 같은 대기 상태가 변한다.

같은 관측 시점에 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 경계는 별도 지표로 관찰합니다.

60개의 동기 DB 수요가 20개 lease와 40개 대기로 나뉘는 snapshot이 예제는 같은 시점의 조건부 수요 60개와 DB pool 20개를 비교합니다. 20개가 DB 작업을 하고 40개는 기다립니다. lease 반환 시 진입할 수 있고 acquisition 기한을 넘으면 실패합니다.동기 요청 24 + 20 + 16 = 60모두 DB connection 1개 필요DB pool · 20 leases같은 순간의 대여 가능 수20 leasedDB 작업 중40 waiting동기 worker도 대기 중lease 반환 시 waiter 진입acquisition 기한 초과 → timeout
60개 동시 수요와20개 DB lease의 분기같은 순간의 동기 요청24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool20개 lease에서20개는 작업하고40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원 desktop4노드4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.대여 가능초과 수요lease 반환 시동기 요청 24+20+16합계 60DB connection 1개씩DB pool20 leases20 leasedDB 작업 중40 waiting동기 worker 대기기한 초과 → timeout

01 · SNAPSHOT CONDITION

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

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

02 · CONNECTION BOUNDARY

maxConnections와 acceptCount는 연결 단위다

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를 함께 관찰합니다.

500 threads와 20 leases는 설명용 수치입니다. snapshot은 처리율이 아니며, 운영에서는 같은 시간창의 도착률·service time·완료율과 busy·pending·acquisition latency를 함께 측정합니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 이 예제는 같은 시점의 조건부 수요 60개와 DB pool 20개를 비교합니다. 20개가 DB 작업을 하고 40개는 기다립니다. lease 반환 시 진입할 수 있고 acquisition 기한을 넘으면 실패합니다.
  • 같은 순간의 동기 요청 24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool의 20개 lease에서 20개는 작업하고 40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원래 가로 도표의 4노드와 4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1동시 요청이 한정된 DB lease로 모이면 대기 경계가 드러난다
2160개 동시 수요와 20개 DB lease의 분기
22같은 순간의 동기 요청 24+20+16=60개가 DB connection을 하나씩 요구한다는 조건이다. DB pool의 20개 lease에서 20개는 작업하고 40개는 기다린다. lease 반환 때 waiter가 진입하며 기한 초과는 timeout이다. 원래 가로 도표의 4노드와 4관계와 같은 조건부 snapshot이고 처리율이나 FIFO 순서를 보장하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
동시 수요 발생

예제의 같은 시점에 60개의 조건부 DB 수요가 있습니다.

수요: 60 · pool: 20제한된 연결 풀에 동시 수요가 모입니다.
20개 실행과 40개 대기

연결 20개를 가진 작업이 DB를 사용하고 나머지 40개는 기다립니다.

DB 작업: 20 · 대기: 40 · pool: 20수요가 실행과 획득 대기 상태로 나뉩니다.
반환 또는 획득 실패

lease가 돌아오면 대기 작업이 들어갈 수 있고 획득 기한을 넘으면 실패합니다.

반환 후: 대기 작업 진입 가능 · 기한 초과: 획득 실패연결 반환과 획득 기한에 따라 대기 상태가 해소됩니다.

그림은 같은 순간에 24, 20, 16개, 합해서 60개의 동기 요청이 모두 DB 연결 하나씩을 필요로 한다는 조건에서, 20개는 DB 작업을 하고 40개는 기다리는 한 시점의 모습입니다. 연결이 반환되면 기다리던 요청이 들어갈 수 있고, 얻는 기한을 넘기면 시간 초과로 실패합니다. 아래쪽 상자들은 maxConnections와 acceptCount가 연결 단위이고, 스레드가 따로 있으며, DB 대기는 사용 중과 최대, 대기 수, 얻는 데 걸린 시간을 함께 보아야 한다는 점을 정리합니다. 이것은 처리율이 아니라 조건을 정한 한 순간의 모습입니다.

요청률을 용량과 비교하려면 같은 시간 범위의 도착률과 처리 시간, 완료율이 더 필요합니다. keep-alive와 HTTP/2 때문에 연결 하나가 요청 하나라고 볼 수도 없습니다. 그래서 용량을 조정할 때는 숫자 하나가 아니라 다음 신호를 같은 시간축에서 봅니다.

구간단위함께 볼 신호
가장자리와 프록시요청과 시간최종 상태, 업스트림 상태, 전체 시간과 업스트림 시간
Tomcat 커넥터연결현재 연결 수, 수락과 거부, 대기열
요청 작업자스레드바쁜 수, 최대, 실행기 대기열, 요청 처리 시간
DB 풀연결 대여와 시간사용 중, 최대, 대기 수, 얻는 시간과 시간 초과, 빌려 쓴 시간과 쿼리 시간

가장자리 로그만 있고 원 서버의 신호가 없다면, 가장자리에서 멈췄거나 캐시에 적중했거나 업스트림 연결이 실패했을 가능성을 봅니다. 다만 로그가 없다는 것과 요청이 없었다는 것은 같지 않으므로, 원 서버의 로그가 켜져 있었는지, 표본 추출이나 수집 지연, 요청 ID 누락은 없었는지부터 확인합니다. 필터에 들어왔다는 것은 그 지점을 지났다는 뜻일 뿐, 최종 응답이 클라이언트에게 전달되었다는 뜻은 아닙니다. 원 서버의 필터 신호는 있는데 컨트롤러 호출이 없다면, 정적 처리기인지, 매핑에 실패했는지, 컨트롤러 전에 실패했는지를 구분합니다. Tomcat의 바쁜 스레드와 DB 대기가 함께 늘면 아래쪽 병목을 의심할 수 있지만, 지표가 함께 움직인다는 것만으로 원인을 확정하지는 않습니다.

더 깊이: Boot가 Tomcat에 넣는 기본 한도와 가상 스레드

Tomcat 커넥터의 한도는 따로 설정하지 않으면 Boot의 속성 기본값을 따릅니다. Spring Boot 4.1.2의 속성 목록에 적힌 값은 다음과 같습니다.

속성기본값뜻
server.tomcat.threads.max200요청을 처리하는 작업 스레드의 최대 수
server.tomcat.threads.min-spare10작업 스레드의 최소 수
server.tomcat.max-connections8192한 시점에 받아 처리하는 연결의 최대 수
server.tomcat.accept-count100연결 수가 max-connections에 이른 뒤 운영체제가 쌓아 두는 연결 요청 대기열의 길이
server.tomcat.threads.max-queue-capacity2147483647스레드 풀이 쓰는 작업 대기열의 최대 용량

이 기본값으로는 연결은 8천 개 넘게 붙잡을 수 있어도, 플랫폼 스레드로 동시에 처리하는 요청은 200개까지입니다. 그 이상은 사실상 상한이 없는 작업 대기열에서 차례를 기다리므로, 부하가 몰리면 거절보다 대기 시간이 먼저 늘어납니다. 그래서 위의 신호 표에 있는 실행기 대기열처럼, 바쁜 스레드 수와 함께 요청이 대기열에서 보낸 시간도 봐야 합니다.

spring.threads.virtual.enabled를 true로 켜면(기본값 false) 이 계산이 달라집니다. 속성 목록은 이때 threads.max와 threads.min-spare가 효과가 없다고 적고, SpringApplication 문서는 가상 스레드가 전용 풀이 아니라 JVM 전체의 플랫폼 스레드 위에서 스케줄되기 때문이라고 설명합니다. 같은 문서는 가상 스레드에 Java 21 이상이 필요하고 Java 24 이상을 강하게 권하며, 고정된 가상 스레드(pinned virtual thread) 때문에 처리량이 떨어질 수 있다는 점도 경고합니다. 작업 스레드 수의 상한이 사라져도 DB 풀처럼 아래쪽의 한도는 그대로 남습니다.


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

보통의 비동기가 아닌 서블릿 요청은 애플리케이션 처리가 끝날 때까지 요청 처리 스레드를 씁니다. 막히는(blocking) DB나 외부 API를 기다리는 시간도 여기에 들어갑니다. Jakarta Servlet 6.1 명세의 비동기 처리에서는 startAsync() 뒤에 처음의 컨테이너 스레드를 돌려줄 수 있고, 이후 콜백이 응답을 끝내거나 AsyncContext.dispatch()가 컨테이너가 관리하는 스레드로 다시 들어옵니다. 그러니 비동기는 "스레드가 필요 없다"는 뜻이 아니며, 필터와 서블릿의 비동기 지원, 시간 제한, complete(), 요청과 응답에 접근하는 규칙을 함께 설계해야 합니다. 가상 스레드도 막히는 비용의 형태를 바꿀 뿐, DB 풀이나 외부 서비스가 동시에 받아 줄 수 있는 수를 늘리지는 않습니다.


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

예를 들어 Nginx의 proxy_read_timeout은 전체 응답의 절대 기한이 아니라, 업스트림에서 연속된 두 번의 읽기 사이에 아무 데이터도 오지 않은 시간입니다. 시간이 초과된 뒤 다른 업스트림을 시도할지, 프록시가 오류 응답을 바꿀지, 클라이언트 연결 종료가 원 서버 작업의 취소로 이어질지는 별도의 설정과 애플리케이션의 규칙입니다. 그래서 바깥의 제한 시간을 안쪽보다 작게 또는 크게 두는 규칙 하나로는 부족하며, 전체 기한과 재시도 예산, 멱등성, 취소 전파, 구간마다 관찰한 값을 함께 맞춥니다.


정리

  • 웹 서버와 WAS, 서블릿 컨테이너는 딱 나뉘는 제품 분류가 아니므로, 요청이 어느 구간까지 갔고 어떤 역할이 응답을 만들었는지부터 봅니다.
  • 가장자리에서 끝난 요청은 원 서버에 닿지 않으며, 원 서버로 넘어온 요청만 커넥터, 필터 체인, DispatcherServlet, 핸들러의 순서로 관찰할 수 있습니다.
  • Boot의 기본 설정에서는 원 서버의 정적 자원도 DispatcherServlet과 ResourceHttpRequestHandler를 거치지만 업무 컨트롤러는 호출하지 않습니다.
  • maxConnections와 acceptCount는 연결, maxThreads는 스레드, DB 풀은 연결 대여의 단위이므로 서로 빼거나 더해 대기 수를 계산하지 않습니다.
  • 비동기나 가상 스레드도 DB 연결 같은 아래쪽의 동시 처리 수를 늘리지 않으며, 제한 시간은 제품마다 뜻과 취소 전파를 확인합니다.

연습 문제

1. 정적 매핑을 끄면

위 픽스처에 spring.web.resources.add-mappings=false를 적용했을 때, /boundary.txt의 상태와 필터 횟수, 컨트롤러 횟수 가운데 무엇이 그대로이고 무엇이 달라질지 먼저 적어 보세요. 이어서 DB 연결을 얻는 기한을 넘긴 요청과 가장자리의 읽기 시간 초과를 어떤 지표와 로그의 조합으로 구분할지 표로 정리하세요.

해설 보기

REQUEST 필터는 원 서버에 들어온 요청을 계속 관찰하고, ProbeController는 여전히 호출되지 않습니다. 하지만 기본 MVC 자원 매핑을 껐으므로 정적 파일의 200은 더 이상 기대할 수 없습니다. 최종 404를 어느 내부 구성 요소가 만들었는지 상태 코드만으로 단정하지 말고, 새 설정에서 핸들러의 종류와 오류 디스패치를 다시 관찰해야 합니다.

증상필요한 원 서버 신호필요한 가장자리 신호
DB 연결 얻기 시간 초과풀의 대기 수, 얻는 시간과 시간 초과, 추적같은 요청 ID의 업스트림 상태와 시간
가장자리의 업스트림 읽기 초과원 서버에 닿았는지, 완료와 취소 여부, 추적최종 상태, 업스트림 읽기 사이의 간격과 재시도

원 서버의 작업이 끝났다고 클라이언트가 응답을 받았다고 볼 수 없고, 가장자리의 시간 초과가 보인다고 DB가 원인이라고 볼 수도 없습니다.

2. 실행 구간과 용량 수치 읽기

다음은 서로 독립된 상황이며, 숫자는 밝힌 조건에서만 씁니다. ① 이 픽스처의 /boundary.txt 응답이 200 static-ok이고, 필터 1회, 인터셉터 1회, 컨트롤러 0회이며 핸들러 헤더는 resource입니다. 원 서버에 들어왔나요? 사용자 컨트롤러를 지났나요? 상태 코드 200만 주어졌어도 같은 경로를 확정할 수 있나요? ② 지금 동기 요청 60개 중 20개가 DB 연결을 들고 있고 40개가 기다리며, 다른 사용자는 없습니다. 연결 8개와 maxThreads=500도 관찰했습니다. DB 대기 수를 500 − 20이나 60 − 8로 계산해도 되나요? 20개의 연결이 각각 50ms씩 쉬지 않고 한 번의 DB 작업을 처리한다는 이상적인 조건에서 처리 상한은 초당 몇 회이며, 풀을 늘리면 응답이 반드시 빨라지나요? ③ proxy_read_timeout=2s이고 연결은 유지됩니다. 업스트림에서 데이터를 받은 시각이 0, 1.5, 3.0, 4.5초이고, 그 뒤 마지막 수신으로부터 2초가 넘는 무응답 구간이 생겼습니다. 처음 2초가 지난 순간 전체 응답 시간 초과로 끝나나요?

해설 보기

① 실제 루프백 응답과 REQUEST 필터, 자원 처리기의 관찰이 있으므로 원 서버의 기본 정적 MVC 경로를 지났고, 사용자 컨트롤러는 호출되지 않았습니다. 상태 코드만으로는 가장자리가 만든 200과 원 서버 처리기의 200을 구별할 수 없습니다.

② 관찰된 DB 대기는 40개입니다. 500은 스레드의 한도이고 8은 연결 수이므로 DB 연결 수와 빼지 않으며, HTTP/2나 keep-alive에서는 연결 수와 요청 수도 일대일이 아닙니다. 이상적인 대여 처리 상한은 20 ÷ 0.05 = 초당 400회입니다. 대기와 DB 자체의 처리 능력, 네트워크, 다른 단계가 달라질 수 있으므로, 실제 처리율과 응답 시간이 나아지는지는 따로 측정해야 합니다.

③ 아닙니다. 앞의 간격은 모두 1.5초로 2초보다 짧습니다. 이 설정은 시작부터의 절대 기한이 아니라 읽기 사이의 무응답 시간에 적용되므로, 마지막 수신 뒤 무응답이 2초를 넘기면 그때 적용 대상이 됩니다. 정확히 언제 실행되고 어떤 오류 응답을 내는지, 재시도하는지는 별도의 동작입니다.

다음 글 서블릿 생명 주기에서는 서블릿 객체가 등록되고 초기화되어 요청을 처리하다가 소멸되는 과정과, 여러 요청 스레드가 공유 인스턴스를 호출할 때의 스레드 안전성을 다룹니다.