서블릿 생명주기
HttpServlet의 초기화·서비스·소멸을 실행하고 공유 인스턴스의 동시성 규칙을 익힙니다.
Spring MVC 컨트롤러를 호출하기 전에 Servlet 컨테이너는 URL 매핑으로 Servlet을 찾고 요청·응답 객체를 전달합니다.
HttpServlet을 한 번 직접 구현하면 DispatcherServlet이 어디에 놓이는지, 왜 요청 값을 Servlet 필드에 저장하면 안 되는지 선명해집니다.
HttpServlet 메서드 콜백
HttpServlet은 일반적으로 service에서 요청 메서드를 확인해 doGet, doPost, doPut, doDelete 등에 디스패치합니다.
애플리케이션 Servlet은 필요한 콜백을 재정의합니다.
package board.servlet;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public final class PostCountServlet extends HttpServlet {
private final PostQuery query;
public PostCountServlet(PostQuery query) {
this.query = query;
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
var memberId = request.getParameter("memberId");
if (memberId == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"memberId is required");
return;
}
long count = query.countByMember(
Long.parseLong(memberId));
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding(
StandardCharsets.UTF_8.name());
response.setContentType("text/plain");
response.getWriter().write(Long.toString(count));
}
}쿼리 파라미터 파싱, 오류 상태, 콘텐츠 타입, 본문 쓰기가 한 클래스에 보입니다.
작은 엔드포인트는 가능하지만 입력 변환과 오류 표현이 늘면 반복 코드가 빠르게 커집니다.
Spring MVC는 이 공통 처리를 인자 리졸버와 메시지 컨버터로 옮깁니다.
ServletRegistrationBean 매핑
Servlet 애노테이션 스캔을 켤 수도 있지만 Java 구성에서 등록하면 인스턴스 생성과 매핑이 한곳에 보입니다.
package board.config;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.application.PostQuery;
import board.servlet.PostCountServlet;
@Configuration(proxyBeanMethods = false)
class ServletConfig {
@Bean
ServletRegistrationBean<PostCountServlet>
postCountServlet(PostQuery query) {
var servlet = new PostCountServlet(query);
var registration =
new ServletRegistrationBean<>(
servlet, "/raw/post-count");
registration.setName("postCountServlet");
registration.setLoadOnStartup(1);
return registration;
}
}loadOnStartup이 양수면 컨테이너 시작 때 초기화해 설정 오류를 첫 요청까지 늦추지 않습니다.
지연 초기화가 필요한 무거운 기능은 시작 시간과 첫 요청 지연 시간 사이의 장단점을 명시합니다.
DispatcherServlet은 보통 /에 매핑되고 이 원시 Servlet은 더 구체적인 /raw/post-count에 매핑됩니다.
컨테이너가 먼저 어떤 Servlet을 고를지 결정한 뒤 선택된 Servlet 안의 로직이 실행됩니다.
서블릿 생명주기 검증
모의 Servlet 객체로 생명주기와 GET 디스패치를 컨테이너 규칙에 가깝게 확인합니다.
package board.servlet;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletConfig;
import org.springframework.mock.web.MockServletContext;
class ServletLifecycleTest {
static final class RecordingServlet extends HttpServlet {
private final List<String> events;
RecordingServlet(List<String> events) {
this.events = events;
}
@Override
public void init(ServletConfig config)
throws ServletException {
super.init(config);
events.add("init");
}
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
events.add("get:" + request.getRequestURI());
response.setStatus(200);
response.getWriter().write("ok");
}
@Override
public void destroy() {
events.add("destroy");
}
}
@Test
void 하나의_instance가_초기화되고_요청_후_종료된다()
throws Exception {
var events = new ArrayList<String>();
var servlet = new RecordingServlet(events);
var context = new MockServletContext();
servlet.init(new MockServletConfig(
context, "recordingServlet"));
var request = new MockHttpServletRequest(
context, "GET", "/raw/ping");
var response = new MockHttpServletResponse();
servlet.service(request, response);
servlet.destroy();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString())
.isEqualTo("ok");
assertThat(events).containsExactly(
"init", "get:/raw/ping", "destroy");
}
}실제 컨테이너가 종료될 때 소멸을 호출하지만 프로세스 강제 종료에서 반드시 완료된다고 기대하지 않습니다.
중요한 자원 정합성은 트랜잭션과 외부 저장소가 보장하고 소멸은 풀·스레드 같은 프로세스 자원 정리에 사용합니다.
ServletLifecycleTest
> 하나의_instance가_초기화되고_요청_후_종료된다() PASSED
events = [init, get:/raw/ping, destroy]
BUILD SUCCESSFUL단일 서블릿과 동시 요청
컨테이너는 요청마다 Servlet을 새로 만들지 않습니다.
같은 인스턴스의 service와 doGet을 여러 워커 스레드가 호출할 수 있습니다.
따라서 다음 필드는 위험합니다.
public final class UnsafePostServlet
extends HttpServlet {
private Long currentMemberId;
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
currentMemberId = Long.valueOf(
request.getParameter("memberId"));
response.getWriter().write(
query.countByMember(currentMemberId)
.toString());
}
}필드에는 스레드-안전하거나 불변인 협력 객체만 두고 요청별 값은 메서드 파라미터와 지역 변수에 둡니다.
HttpServletRequest 자체도 현재 요청 수명에 속하므로 필드나 싱글톤 대기열에 저장하지 않습니다.
동시 접근을 막기 위해 doGet 전체에 synchronized를 붙이면 하나의 Servlet이 모든 요청을 직렬화합니다.
변경 상태를 제거하는 것이 먼저입니다.
공유 캐시가 필요하면 별도 스레드-안전 컴포넌트와 제한 정책을 둡니다.
요청·응답 소유권
애플리케이션은 요청 본문 스트림을 필요 이상 여러 번 읽을 수 없습니다.
응답이 커밋된 뒤 상태와 헤더를 바꾸려 해도 반영되지 않습니다.
필터나 래퍼가 본문을 캐싱할 수 있지만 메모리 상한과 민감 정보 마스킹이 필요합니다.
| 객체 | 생성·종료 주체 | 애플리케이션 규칙 |
|---|---|---|
| Servlet | 컨테이너 | 싱글톤처럼 무상태로 사용 |
| Request | 컨테이너, 요청마다 | 스레드 밖에 보관하지 않음 |
| Response | 컨테이너, 요청마다 | 커밋 전 상태·헤더 결정 |
| AsyncContext | 컨테이너와 애플리케이션 협력 | 타임아웃·완료를 명시 |
요청 처리 중 예외가 밖으로 나오면 컨테이너 오류 디스패치나 프레임워크 예외 리졸버가 처리합니다.
응답 일부를 이미 쓴 뒤 예외가 나면 정형 오류 본문으로 바꾸기 어려우므로 검증과 실패 가능한 작업을 본문 쓰기 전에 끝냅니다.
연습 문제
PostCountServlet에 POST를 보내면 405가 반환되는지 확인하고, GET 두 개를 가상 스레드에서 동시에 호출해 회원별 결과가 섞이지 않게 하세요.
실패 구현은 회원 ID를 필드에 두고 래치로 섞임을 재현한 뒤 지역 변수로 수정합니다.
해설 보기
HttpServlet은 재정의하지 않은 메서드에 기본 오류 처리를 제공합니다.
모의 요청 메서드를 POST로 만들고 service를 호출해 상태를 확인합니다.
var post = new MockHttpServletRequest(
context, "POST", "/raw/post-count");
var response = new MockHttpServletResponse();
servlet.service(post, response);
assertThat(response.getStatus()).isEqualTo(405);동시 GET은 각 작업이 자기 MockHttpServletRequest와 MockHttpServletResponse를 가져야 합니다.
Servlet 인스턴스만 공유하고 요청별 입력과 출력은 공유하지 않습니다.
필드를 제거한 뒤 회원 7 응답과 9 응답이 반복 실행에서도 정확한지 확인합니다.
다음 문서에서는 컨테이너가 만든 HttpServletRequest에서 쿼리 파라미터, 폼 필드, JSON 본문이 서로 다른 API와 프레이밍을 거치는 이유를 직접 읽습니다.