서블릿 생명주기
실제 embedded Tomcat에서 Servlet 등록·초기화·HTTP 메서드 디스패치·동시 요청·소멸을 계측하고 요청별 상태의 소유권을 검증합니다.
Spring MVC 컨트롤러에 도달하기 전에 Servlet 컨테이너는 URL 매핑으로 Servlet을 고르고 요청·응답 객체를 전달합니다.
이 단원에서는 /raw/post-count에 직접 등록한 HttpServlet 하나를 실제 embedded Tomcat에서 실행합니다.
핵심은 "Servlet 클래스가 JVM 전체에서 영원히 하나"가 아니라, 이 Servlet 등록에 연결된 한 인스턴스를 컨테이너가 초기화한 뒤 여러 요청에 재사용할 수 있다는 점입니다.
등록된 인스턴스의 생명주기
이 예제에서는 구성 코드가 new PostCountServlet(query)로 인스턴스를 만들고 ServletRegistrationBean에 넘깁니다.
그 뒤의 init, service, destroy 호출 순서는 Servlet 컨테이너가 관리합니다.
Servlet.init은 인스턴스 생성 뒤 정확히 한 번 호출되며 성공적으로 끝나야 요청을 받을 수 있습니다.
초기화가 실패하면 그 인스턴스에는 service와 destroy를 호출하지 않습니다.
service는 한 번도 호출되지 않을 수도 있고, 같은 인스턴스에 여러 요청 스레드가 동시에 들어올 수도 있습니다.
컨테이너가 인스턴스를 서비스에서 제거할 때는 실행 중인 service가 끝나거나 서버가 정한 제한 시간이 지난 뒤 destroy를 호출합니다. destroy 뒤에는 그 인스턴스로 새 요청을 보내지 않습니다.
REGISTRATION · INIT · CONCURRENT SERVICE · DRAIN · DESTROY
한 등록 인스턴스가 여러 요청을 함께 처리하므로, 요청 상태는 호출 안에 둔다
구성 코드가 인스턴스를 만들고 컨테이너가 생명주기를
호출합니다.
성공한 init 뒤 같은 등록 인스턴스에
service가 0회 이상, 때로는 동시에 들어오며, 제거 시에는
실행 중 호출을 정리한 뒤 destroy로 끝납니다.
01 · CREATE / REGISTER
구성 코드가 인스턴스를 만들고 컨테이너에 등록한다
ServletRegistrationBean이 인스턴스와
/raw/post-count를 연결합니다. 이후 init,
service, destroy 호출은 컨테이너가
관리합니다.
02 · LIFECYCLE
init 성공 뒤 service 0..N, 제거 때 drain 뒤 destroy
초기화 실패 인스턴스에는 service와
destroy가 없습니다. 정상 제거에서는 실행 중 호출의
완료 또는 서버 제한 시간을 기다리고, destroy 뒤에는
요청을 보내지 않습니다.
03 · CONCURRENT SERVICE
같은 등록 인스턴스에도 A와 B의 요청 값은 각각 지역 소유다
Servlet과 공유 협력 객체는 함께 쓰지만 request,
response, memberId, 계산 결과는 각 호출
안에 둡니다. async는 별도의 수명·동시성 계약이 필요합니다.
04 · REMOVE STATE FIRST
final 참조와 전체 동기화는 공유 객체를 자동으로 안전하게 만들지 않는다
협력 객체 자체의 동시 접근 계약을 확인하고 요청별 mutable field를
제거합니다. 전체 doGet 동기화는 이 인스턴스의 요청을
직렬화하며, destroy는 강제 종료 내구성 보장이
아닙니다.
범위는 이 비분산 Servlet 등록의 한 인스턴스입니다. 같은 클래스를 여러
이름으로 등록하거나 분산 배포하면 인스턴스 수가 달라질 수 있습니다.
Request/Response의 async 수명은 startAsync와
AsyncContext.complete 규칙을 별도로 따라야 합니다.
URL 매핑과 HTTP 메서드는 서로 다른 선택입니다
먼저 Servlet 컨테이너가 URL 패턴으로 Servlet 등록을 선택합니다.
/raw/post-count는 기본 / 매핑의 DispatcherServlet보다 구체적이므로 원시 Servlet이 선택됩니다.
그 다음 HttpServlet.service가 HTTP 메서드에 맞는 doGet, doPost, doPut, doDelete, doPatch 등의 콜백으로 디스패치합니다.
애플리케이션은 필요한 콜백만 재정의합니다.
아래 예제의 PostCountServlet은 doGet만 재정의하므로 GET은 게시글 수를 반환하고, HTTP/1.1 POST는 상속한 기본 doPost에서 405 Method Not Allowed가 됩니다. Servlet 6.1 기본 구현은 HTTP/1.0 요청에는 400 Bad Request를 사용하므로 "미재정의 메서드는 언제나 405"라고 일반화하면 안 됩니다.
URL MAPPING FIRST · HTTP METHOD DISPATCH SECOND
컨테이너가 Servlet을 고르고, 선택된 HttpServlet이 doXxx를 고른다
두 선택을 한 단계로 섞지 않습니다. URL의 가장
구체적인 등록으로 실행 인스턴스를 정한 뒤, 그 인스턴스의
HttpServlet.service가 GET·POST 같은 메서드 콜백을
선택합니다.
01 · URL MAPPING
Servlet 컨테이너가 URL로 실행할 등록을 먼저 고른다
정확한 /raw/post-count 등록이 기본 /보다
구체적입니다. 따라서 이 요청은 postCountServlet으로
들어갑니다.
02 · METHOD DISPATCH
선택된 HttpServlet 안에서 service가 doXxx를 고른다
service(req, res)가 요청 메서드를 확인합니다. GET과
POST만의 규칙이 아니며 Servlet 6.1에는 doPatch를
포함한 다른 콜백도 있습니다.
03 · GET OVERRIDE
GET은 doGet의 지역 값과 PostQuery를 거쳐 200이 된다
memberId=7을 현재 호출의 지역 값으로 변환하고, 공유
협력 객체의 동시 접근 계약 아래 게시글 수를 조회해
text/plain 응답을 씁니다.
04 · INHERITED DEFAULT
미재정의 HTTP/1.1 POST는 상속한 doPost에서 405가 된다
HTTP/1.0에서는 기본 응답이 400이므로 프로토콜 조건을 함께
읽습니다. 기본 / 분기는
DispatcherServlet 경계까지만 표시하고 MVC 내부는 뒤
단원으로 넘깁니다.
HttpServlet.service의 전체 분기표가 아니라 이 예제의
GET과 POST 경로입니다. HEAD는 기본적으로 doHead를 거쳐
doGet과 연결될 수 있고, OPTIONS·TRACE·PUT·DELETE·PATCH도
각각의 Servlet 6.1 규칙을 따릅니다.
실제 embedded Tomcat에서 계약을 검증합니다
다음 파일은 production 예제의 Servlet과 등록 구성, 실제 loopback 검증을 한 compilation unit에 둔 test fixture입니다.
Spring mock 객체에서 init → service → destroy를 직접 호출하지 않습니다. Boot 4.1.1이 random port로 Tomcat을 열고, JDK HttpClient가 127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다.
package board.servlet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.server.servlet.context
.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
class EmbeddedServletLifecycleTest {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.version(HttpClient.Version.HTTP_1_1)
.build();
@Test
void loadOnStartup은_첫_요청_전에_한_인스턴스를_초기화한다() {
var probe = new LifecycleProbe();
try (var server = RunningServer.start(probe)) {
var registration = server.context()
.getServletContext()
.getServletRegistration("postCountServlet");
assertNotNull(registration);
assertAll(
() -> assertEquals(
Set.of("/raw/post-count"),
Set.copyOf(registration.getMappings())),
() -> assertEquals(1, probe.initCalls.get()),
() -> assertEquals(0, probe.getCalls.get()),
() -> assertEquals(1, probe.servletIdentities.size()),
() -> assertEquals(0, probe.destroyCalls.get()));
}
assertAll(
() -> assertEquals(1, probe.destroyCalls.get()),
() -> assertEquals(
List.of("init", "destroy"),
List.copyOf(probe.events)));
}
@Test
void service는_GET을_doGet으로_보내고_HTTP1_1_POST는_405로_응답한다()
throws Exception {
var probe = new LifecycleProbe();
try (var server = RunningServer.start(probe)) {
var get = send(
server,
"GET",
"/raw/post-count?memberId=7");
var post = send(server, "POST", "/raw/post-count");
assertAll(
() -> assertEquals(200, get.statusCode()),
() -> assertEquals("7:70", get.body()),
() -> assertTrue(get.headers()
.firstValue("content-type")
.orElseThrow()
.startsWith("text/plain")),
() -> assertEquals(405, post.statusCode()),
() -> assertEquals(1, probe.getCalls.get()),
() -> assertEquals(1, probe.servletIdentities.size()));
}
}
@Test
void 같은_인스턴스가_두_GET을_동시에_처리해도_지역_값은_섞이지_않는다()
throws Exception {
var probe = new LifecycleProbe();
try (var server = RunningServer.start(probe);
var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first = executor.submit(() -> send(
server,
"GET",
"/raw/post-count?memberId=7&hold=true"));
var second = executor.submit(() -> send(
server,
"GET",
"/raw/post-count?memberId=9&hold=true"));
try {
assertTrue(probe.concurrentEntries.await(5, SECONDS));
assertAll(
() -> assertEquals(2, probe.activeCalls.get()),
() -> assertEquals(2, probe.maxActiveCalls.get()));
} finally {
probe.releaseConcurrentCalls.countDown();
}
var firstResponse = first.get(5, SECONDS);
var secondResponse = second.get(5, SECONDS);
assertAll(
() -> assertEquals(
Set.of("7:70", "9:90"),
Set.of(
firstResponse.body(),
secondResponse.body())),
() -> assertEquals(2, probe.requestIdentities.size()),
() -> assertEquals(2, probe.responseIdentities.size()),
() -> assertEquals(1, probe.servletIdentities.size()),
() -> assertEquals(0, probe.activeCalls.get()));
}
}
@Test
void context를_닫으면_마지막_service_뒤_destroy를_한_번_호출한다()
throws Exception {
var probe = new LifecycleProbe();
HttpResponse<String> response;
try (var server = RunningServer.start(probe)) {
response = send(
server,
"GET",
"/raw/post-count?memberId=11");
}
assertAll(
() -> assertEquals(200, response.statusCode()),
() -> assertEquals(1, probe.destroyCalls.get()),
() -> assertEquals(
List.of("init", "get:11", "destroy"),
List.copyOf(probe.events)));
}
private static HttpResponse<String> send(
RunningServer server,
String method,
String path)
throws IOException, InterruptedException {
var request = HttpRequest.newBuilder(
URI.create(server.origin() + path))
.timeout(Duration.ofSeconds(5))
.method(method, HttpRequest.BodyPublishers.noBody())
.build();
return CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString(UTF_8));
}
private record RunningServer(
ServletWebServerApplicationContext context,
String origin)
implements AutoCloseable {
static RunningServer start(LifecycleProbe probe) {
var application = new SpringApplication(TestApplication.class);
application.setWebApplicationType(WebApplicationType.SERVLET);
application.setRegisterShutdownHook(false);
application.setDefaultProperties(Map.of(
"server.address", "127.0.0.1",
"server.port", "0",
"spring.main.banner-mode", "off",
"logging.level.root", "OFF"));
application.addInitializers(context -> context
.getBeanFactory()
.registerSingleton("lifecycleProbe", probe));
var context = (ServletWebServerApplicationContext)
application.run();
var origin = "http://127.0.0.1:"
+ context.getWebServer().getPort();
return new RunningServer(context, origin);
}
@Override
public void close() {
context.close();
}
}
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
static class TestApplication {
@Bean
ServletRegistrationBean<PostCountServlet> postCountServlet(
LifecycleProbe probe) {
var servlet = new PostCountServlet(
probe,
memberId -> memberId * 10);
var registration = new ServletRegistrationBean<>(
servlet,
"/raw/post-count");
registration.setName("postCountServlet");
registration.setLoadOnStartup(1);
return registration;
}
}
@FunctionalInterface
interface PostQuery {
long countByMember(long memberId);
}
static final class PostCountServlet extends HttpServlet {
private final LifecycleProbe probe;
private final PostQuery query;
PostCountServlet(
LifecycleProbe probe,
PostQuery query) {
this.probe = probe;
this.query = query;
}
@Override
public void init() {
probe.initCalls.incrementAndGet();
probe.servletIdentities.add(
System.identityHashCode(this));
probe.events.add("init");
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException {
probe.getCalls.incrementAndGet();
probe.servletIdentities.add(
System.identityHashCode(this));
probe.requestIdentities.add(
System.identityHashCode(request));
probe.responseIdentities.add(
System.identityHashCode(response));
var rawMemberId = request.getParameter("memberId");
final long memberId;
try {
memberId = Long.parseLong(rawMemberId);
} catch (NumberFormatException exception) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"memberId must be an integer");
return;
}
probe.events.add("get:" + memberId);
var active = probe.activeCalls.incrementAndGet();
probe.maxActiveCalls.accumulateAndGet(active, Math::max);
try {
if ("true".equals(request.getParameter("hold"))) {
probe.concurrentEntries.countDown();
if (!probe.releaseConcurrentCalls.await(5, SECONDS)) {
response.sendError(
HttpServletResponse.SC_SERVICE_UNAVAILABLE,
"concurrency gate timed out");
return;
}
}
var count = query.countByMember(memberId);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("text/plain");
response.setCharacterEncoding(UTF_8);
response.getWriter().write(memberId + ":" + count);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ServletException(
"request was interrupted",
exception);
} finally {
probe.activeCalls.decrementAndGet();
}
}
@Override
public void destroy() {
probe.destroyCalls.incrementAndGet();
probe.events.add("destroy");
}
}
static final class LifecycleProbe {
final AtomicInteger initCalls = new AtomicInteger();
final AtomicInteger getCalls = new AtomicInteger();
final AtomicInteger destroyCalls = new AtomicInteger();
final AtomicInteger activeCalls = new AtomicInteger();
final AtomicInteger maxActiveCalls = new AtomicInteger();
final Set<Integer> servletIdentities =
ConcurrentHashMap.newKeySet();
final Set<Integer> requestIdentities =
ConcurrentHashMap.newKeySet();
final Set<Integer> responseIdentities =
ConcurrentHashMap.newKeySet();
final ConcurrentLinkedQueue<String> events =
new ConcurrentLinkedQueue<>();
final CountDownLatch concurrentEntries =
new CountDownLatch(2);
final CountDownLatch releaseConcurrentCalls =
new CountDownLatch(1);
}
}네 테스트가 각각 증명하는 경계는 다음과 같습니다.
loadOnStartup=1인 등록은 첫 요청 전init을 한 번 호출하고 정확한 URL 매핑을 갖습니다.- 실제 HTTP/1.1 GET은
doGet으로, 미재정의 POST는 기본doPost의 405로 갑니다. - 두 Tomcat worker가 같은 Servlet 인스턴스에 동시에 들어와도
memberId와 request/response는 요청별로 분리됩니다. - 정상적인 application context 종료는 마지막 완료된 요청 뒤
destroy를 한 번 호출합니다.
setLoadOnStartup(1)의 1은 단순 boolean이 아니라 시작 순서 값입니다. ServletRegistrationBean에 음수를 주면 지연 초기화를 허용하고, 0 이상이면 시작 시 초기화합니다.
테스트는 정상 종료의 콜백을 검증할 뿐, 프로세스 강제 종료나 전원 장애에서도 destroy가 실행된다고 증명하지 않습니다.
공유 인스턴스에는 요청 상태를 저장하지 않습니다
동시에 들어온 두 요청은 PostCountServlet 인스턴스와 query 필드를 공유합니다.
반면 request, response, rawMemberId, memberId, count는 각 doGet 호출의 파라미터와 지역 변수입니다.
요청 A가 memberId=7을 읽은 뒤 멈추고 요청 B가 memberId=9를 읽어도 서로 덮어쓸 필드가 없습니다.
currentMemberId 같은 mutable instance field를 추가하면 호출 사이에 값이 섞일 수 있습니다. doGet 전체를 synchronized로 막으면 이 등록의 요청을 직렬화하므로, 요청 상태를 없애는 것이 우선입니다.
final PostQuery query의 final은 참조 재할당만 막습니다. 실제 협력 객체도 stateless, immutable 또는 명시적으로 thread-safe여야 합니다.
| 대상 | 기본 소유 범위 | 안전한 규칙 |
|---|---|---|
| Servlet 인스턴스 | 이 등록의 service 수명 | 요청별 mutable 상태를 필드에 두지 않음 |
| 공유 협력 객체 | 여러 service 호출 | 불변 또는 동시 접근 계약을 확인 |
| Request·Response | 현재 동기 dispatch | 현재 호출 범위에서만 사용하고 다른 스레드에 넘기지 않음 |
| 요청 파라미터·계산값 | 현재 service 호출 | 파라미터와 지역 변수에 유지 |
Request와 Response 구현은 thread-safe가 보장되지 않으며 컨테이너가 재사용할 수 있습니다.
비동기 처리는 예외입니다. request.startAsync()가 성공하면 객체 수명이 service 반환 뒤 AsyncContext.complete()까지 연장되지만, 그것만으로 여러 스레드의 임의 접근이 안전해지는 것은 아닙니다. timeout과 complete를 포함한 별도 소유권 규칙이 필요합니다.
응답 상태·헤더는 응답이 커밋되기 전에 결정합니다. 본문을 일부 전송한 뒤에는 정형 오류 응답으로 바꾸기 어렵습니다. 입력 스트림과 응답 커밋의 상세 규칙은 이어지는 요청·응답 단원에서 다룹니다.
연습 문제
fixture의 PostCountServlet에 private long currentMemberId 필드를 추가하고, memberId를 그 필드에 쓴 뒤 concurrency gate를 통과하도록 바꿔 보세요.
두 응답이 섞이는 실패를 재현한 다음 필드를 제거해 세 번째 테스트를 다시 통과시키세요.
추가로 memberId=abc와 memberId 누락 요청이 400인지 실제 loopback 테스트를 작성하고, 검증을 본문 쓰기 전에 끝내세요.
다음 문서에서는 같은 HttpServletRequest에서 쿼리 파라미터, 폼 필드, JSON 본문이 서로 다른 API와 프레이밍을 거치는 이유를 읽습니다.