본문 바로가기

서블릿 생명 주기

실제 embedded Tomcat에서 Servlet 등록·초기화·HTTP 메서드 디스패치·동시 요청·소멸을 계측하고 요청별 상태의 소유권을 검증합니다.

Spring MVC 컨트롤러에 닿기 전에, 서블릿 컨테이너는 URL 매핑으로 서블릿을 고르고 요청과 응답 객체를 넘겨줍니다. 이번 글에서는 /raw/post-count에 직접 등록한 HttpServlet 하나를 실제 내장 Tomcat에서 실행해 봅니다. 핵심은 "서블릿 클래스는 JVM 전체에 영원히 하나"가 아니라, 이 서블릿 등록에 연결된 인스턴스 하나를 컨테이너가 초기화한 뒤 여러 요청에 다시 쓸 수 있다는 점입니다.


등록된 인스턴스의 생명 주기

이 예제에서는 구성 코드가 new PostCountServlet(probe, query)로 인스턴스를 만들어 ServletRegistrationBean에 넘깁니다. probe는 아래 테스트가 호출 횟수와 동시 진입을 관찰하는 계측 객체이고, query는 게시글 수를 구하는 협력 객체입니다. 그 뒤의 init, service, destroy 호출 순서는 서블릿 컨테이너가 관리합니다.

Servlet.init은 인스턴스가 만들어진 뒤 정확히 한 번 호출되며, 성공적으로 끝나야 요청을 받을 수 있습니다. 초기화에 실패하면 그 인스턴스에는 service도 destroy도 호출하지 않습니다. service는 한 번도 호출되지 않을 수도 있고, 같은 인스턴스에 여러 요청 스레드가 동시에 들어올 수도 있습니다. 컨테이너가 인스턴스를 서비스에서 뺄 때는 실행 중인 service가 끝나거나 서버가 정한 제한 시간이 지난 뒤 destroy를 호출하며, destroy 뒤에는 그 인스턴스로 새 요청을 보내지 않습니다.

등록된 Servlet 인스턴스는 init 뒤 여러 요청을 함께 처리하고 destroy로 끝난다

init 성공·0회 이상 동시 service·제거 대기/timeout·destroy의 수명 변화가 있다.

구성 코드가 만든 PostCountServlet 인스턴스를 ServletRegistrationBean이 등록하면 컨테이너가 init, 0회 이상의 동시 service, drain, destroy 순서를 관리합니다. 초기화 실패 인스턴스에는 service와 destroy를 호출하지 않습니다. 같은 등록 인스턴스에 요청 A와 B가 동시에 들어와도 request, response, memberId는 각 호출 범위에 두고, 공유 협력 객체만 명시적인 동시성 계약 아래 필드로 유지합니다.

REGISTRATION · INIT · CONCURRENT SERVICE · DRAIN · DESTROY

한 등록 인스턴스가 여러 요청을 함께 처리하므로, 요청 상태는 호출 안에 둔다

구성 코드가 인스턴스를 만들고 컨테이너가 생명주기를 호출합니다. 성공한 init 뒤 같은 등록 인스턴스에 service가 0회 이상, 때로는 동시에 들어오며, 제거 시에는 실행 중 호출을 정리한 뒤 destroy로 끝납니다.

한 Servlet 등록 인스턴스의 생명주기와 초기화 실패 분기구성 코드가 등록한 인스턴스는 init 성공 뒤 0회 이상의 동시 service 호출을 받을 수 있습니다. 제거 시 실행 중 호출 완료 또는 timeout 후 destroy하며 이후 service는 없습니다. 초기화 실패에는 service와 destroy가 없습니다.구성 코드 생성등록init · 정확히 1회성공해야 serviceservice · 0..N동시 호출 가능init 실패service / destroy 없음제거 · drain완료 또는 timeoutdestroy이후 service 없음초기화 실패서비스에서 제거
등록 인스턴스의 성공·실패 생명주기구성코드가 만든 등록 인스턴스에서 컨테이너가 init을 한번 호출한다. 성공 후 service호출은0회이상이며 동시에 들어올 수도 있다. 제거시 실행중호출의 완료또는 제한시간 뒤 destroy를 호출하며 뒤에는service가없다. init실패에는service와destroy가없다. 원desktop6노드5관계와같은범위이다.초기화실패성공제거정리 후구성 코드 생성Servlet 등록init · 정확히 1회성공 후 요청 가능init 실패service 없음destroy 없음service · 0..N동시 호출 가능제거 · drain완료 또는 timeoutdestroy이후 service 없음

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 규칙을 별도로 따라야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 구성 코드가 등록한 인스턴스는 init 성공 뒤 0회 이상의 동시 service 호출을 받을 수 있습니다. 제거 시 실행 중 호출 완료 또는 timeout 후 destroy하며 이후 service는 없습니다. 초기화 실패에는 service와 destroy가 없습니다.
  • 구성 코드가 만든 등록 인스턴스에서 컨테이너가 init을 한 번 호출한다. 성공 후 service 호출은 0회 이상이며 동시에 들어올 수도 있다. 제거 시 실행 중 호출의 완료 또는 제한 시간 뒤 destroy를 호출하며 뒤에는 service가 없다. init 실패에는 service와 destroy가 없다. 원래 가로 도표의 6노드와 5관계와 같은 범위다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1등록된 Servlet 인스턴스는 init 뒤 여러 요청을 함께 처리하고 destroy로 끝난다
29구성 코드가 만든 등록 인스턴스에서 컨테이너가 init을 한 번 호출한다. 성공 후 service 호출은 0회 이상이며 동시에 들어올 수도 있다. 제거 시 실행 중 호출의 완료 또는 제한 시간 뒤 destroy를 호출하며 뒤에는 service가 없다. init 실패에는 service와 destroy가 없다. 원래 가로 도표의 6노드와 5관계와 같은 범위다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
init 결과

등록 인스턴스의 init 성공만 service를 허용합니다.

실패: service·destroy 없음 · 성공: 요청 가능인스턴스 공개 가능 상태가 정해집니다.
동시 service

성공 인스턴스는 0회 이상의 동시 service 호출을 받습니다.

호출: 0회 이상·동시 가능 · 상태: 사용 중같은 인스턴스가 요청을 처리합니다.
제거와 destroy

제거 때 진행 호출 완료 또는 timeout 뒤 destroy합니다.

종료: destroy · 이후 service: 없음등록 인스턴스의 요청 수명이 끝납니다.

등록된 Servlet 인스턴스는 init 뒤 여러 요청을 함께 처리하고 destroy로 끝난다

init 성공·0회 이상 동시 service·제거 대기/timeout·destroy의 수명 변화가 있다.

구성 코드가 만든 PostCountServlet 인스턴스를 ServletRegistrationBean이 등록하면 컨테이너가 init, 0회 이상의 동시 service, drain, destroy 순서를 관리합니다. 초기화 실패 인스턴스에는 service와 destroy를 호출하지 않습니다. 같은 등록 인스턴스에 요청 A와 B가 동시에 들어와도 request, response, memberId는 각 호출 범위에 두고, 공유 협력 객체만 명시적인 동시성 계약 아래 필드로 유지합니다.

REGISTRATION · INIT · CONCURRENT SERVICE · DRAIN · DESTROY

한 등록 인스턴스가 여러 요청을 함께 처리하므로, 요청 상태는 호출 안에 둔다

구성 코드가 인스턴스를 만들고 컨테이너가 생명주기를 호출합니다. 성공한 init 뒤 같은 등록 인스턴스에 service가 0회 이상, 때로는 동시에 들어오며, 제거 시에는 실행 중 호출을 정리한 뒤 destroy로 끝납니다.

한 Servlet 등록 인스턴스의 생명주기와 초기화 실패 분기구성 코드가 등록한 인스턴스는 init 성공 뒤 0회 이상의 동시 service 호출을 받을 수 있습니다. 제거 시 실행 중 호출 완료 또는 timeout 후 destroy하며 이후 service는 없습니다. 초기화 실패에는 service와 destroy가 없습니다.구성 코드 생성등록init · 정확히 1회성공해야 serviceservice · 0..N동시 호출 가능init 실패service / destroy 없음제거 · drain완료 또는 timeoutdestroy이후 service 없음초기화 실패서비스에서 제거
등록 인스턴스의 성공·실패 생명주기구성코드가 만든 등록 인스턴스에서 컨테이너가 init을 한번 호출한다. 성공 후 service호출은0회이상이며 동시에 들어올 수도 있다. 제거시 실행중호출의 완료또는 제한시간 뒤 destroy를 호출하며 뒤에는service가없다. init실패에는service와destroy가없다. 원desktop6노드5관계와같은범위이다.초기화실패성공제거정리 후구성 코드 생성Servlet 등록init · 정확히 1회성공 후 요청 가능init 실패service 없음destroy 없음service · 0..N동시 호출 가능제거 · drain완료 또는 timeoutdestroy이후 service 없음

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 규칙을 별도로 따라야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 구성 코드가 등록한 인스턴스는 init 성공 뒤 0회 이상의 동시 service 호출을 받을 수 있습니다. 제거 시 실행 중 호출 완료 또는 timeout 후 destroy하며 이후 service는 없습니다. 초기화 실패에는 service와 destroy가 없습니다.
  • 구성 코드가 만든 등록 인스턴스에서 컨테이너가 init을 한 번 호출한다. 성공 후 service 호출은 0회 이상이며 동시에 들어올 수도 있다. 제거 시 실행 중 호출의 완료 또는 제한 시간 뒤 destroy를 호출하며 뒤에는 service가 없다. init 실패에는 service와 destroy가 없다. 원래 가로 도표의 6노드와 5관계와 같은 범위다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1등록된 Servlet 인스턴스는 init 뒤 여러 요청을 함께 처리하고 destroy로 끝난다
29구성 코드가 만든 등록 인스턴스에서 컨테이너가 init을 한 번 호출한다. 성공 후 service 호출은 0회 이상이며 동시에 들어올 수도 있다. 제거 시 실행 중 호출의 완료 또는 제한 시간 뒤 destroy를 호출하며 뒤에는 service가 없다. init 실패에는 service와 destroy가 없다. 원래 가로 도표의 6노드와 5관계와 같은 범위다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
init 결과

등록 인스턴스의 init 성공만 service를 허용합니다.

실패: service·destroy 없음 · 성공: 요청 가능인스턴스 공개 가능 상태가 정해집니다.
동시 service

성공 인스턴스는 0회 이상의 동시 service 호출을 받습니다.

호출: 0회 이상·동시 가능 · 상태: 사용 중같은 인스턴스가 요청을 처리합니다.
제거와 destroy

제거 때 진행 호출 완료 또는 timeout 뒤 destroy합니다.

종료: destroy · 이후 service: 없음등록 인스턴스의 요청 수명이 끝납니다.

그림은 이 순서와 실패 분기를 보여 줍니다. 등록된 인스턴스는 init이 성공한 뒤 0번 이상의 service를 받고 때로는 동시에 받으며, 제거될 때 실행 중인 호출을 마무리한 뒤 destroy로 끝납니다. 아래쪽 상자들은 같은 인스턴스에 요청 A와 B가 함께 들어와도 request, response, memberId는 각 호출 안에 두고, 공유 협력 객체만 동시 접근 규칙을 지키는 필드로 둔다는 점을 짚습니다. 같은 클래스를 여러 이름으로 등록하거나 분산 배포하면 인스턴스의 수가 달라질 수 있습니다.


URL 매핑과 HTTP 메서드는 서로 다른 선택입니다

먼저 서블릿 컨테이너가 URL 패턴으로 서블릿 등록을 고릅니다. /raw/post-count는 요청 경로와 정확히 일치하는 매핑이고, DispatcherServlet이 매핑된 /는 다른 매핑이 하나도 맞지 않을 때만 쓰는 기본 매핑이므로 이 원시 서블릿이 선택됩니다. 그다음 HttpServlet.service가 HTTP 메서드에 맞는 doGet, doPost, doPut, doDelete, doPatch 같은 콜백으로 나누어 보내고, 애플리케이션은 필요한 콜백만 재정의합니다.

아래 예제의 PostCountServlet은 doGet만 재정의하므로, GET은 게시글 수를 돌려주고 HTTP/1.1 POST는 물려받은 기본 doPost에서 405 Method Not Allowed가 됩니다. 서블릿 6.1의 기본 구현은 HTTP/1.0 요청에는 400 Bad Request를 쓰므로, "재정의하지 않은 메서드는 언제나 405"라고 일반화하면 안 됩니다.

컨테이너 매핑이 Servlet을 고른 뒤 HttpServlet.service가 메서드 콜백을 고른다

raw path 매핑으로 Servlet을 고른 뒤 service의 method 선택과 GET 200/POST 405가 이어진다.

Servlet 컨테이너는 먼저 URL 패턴으로 등록을 선택합니다. 정확한 /raw/post-count 매핑이면 postCountServlet으로 보내고, 기본 / 매핑이면 DispatcherServlet 경계로 보냅니다. 선택된 PostCountServlet 안에서는 HttpServlet.service가 HTTP 메서드를 판별합니다. GET은 재정의한 doGet과 PostQuery를 거쳐 200 text/plain이 되고, 재정의하지 않은 HTTP/1.1 POST는 상속한 doPost에서 405가 됩니다.

URL MAPPING FIRST · HTTP METHOD DISPATCH SECOND

컨테이너가 Servlet을 고르고, 선택된 HttpServlet이 doXxx를 고른다

두 선택을 한 단계로 섞지 않습니다. URL의 가장 구체적인 등록으로 실행 인스턴스를 정한 뒤, 그 인스턴스의 HttpServlet.service가 GET·POST 같은 메서드 콜백을 선택합니다.

Servlet URL 등록 선택 다음에 HTTP 메서드 콜백 선택이 그림은 정확한 raw post-count URL이 PostCountServlet으로 가고 그 인스턴스의 service가 GET과 POST를 각각 선택하는 순서를 보여줍니다. 다른 경로의 기본 슬래시 등록은 DispatcherServlet 경계입니다. GET은 200, 미재정의 HTTP/1.1 POST는 405입니다.HTTP/1.1 · /raw/post-count이 경로의 GET / POST 요청01 · Servlet container URL mappingexact /raw/post-count가 선택됨PostCountServlet이 경로의 정확한 등록DispatcherServlet다른 경로의 기본 /02 · HttpServlet.service선택된 인스턴스에서 method 확인GET → doGetPostQuery → 200text/plain · 7:70POST → doPost미재정의 기본 처리HTTP/1.1 · 405
URL 등록과 HTTP 메서드의 두 선택HTTP1.1요청의URL로등록을먼저고른다. raw/post-count exact매핑이면PostCountServlet이다. 다른경로의기본슬래시매핑은DispatcherServlet경계이다. 선택된PostCountServlet에서service가메서드로다시나누며GET은doGet/PostQuery/200textplain,미재정의POST는doPost/405이다. 원desktop7노드6관계의조건을보존하고정확한예시URL에서는default분기를선택하지않는다.다른 경로exact 선택GETPOSTHTTP/1.1 요청URL · GET / POST01 · URL mapping컨테이너 등록 선택DispatcherServlet다른 경로의 기본 /PostCountServlet/raw/post-count02 · 메서드 선택HttpServlet.service선택된 인스턴스GET → doGetPostQuery → 200text/plain · 7:70POST → doPost미재정의 기본 처리HTTP/1.1 · 405

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 내부는 뒤 단원으로 넘깁니다.

시작 노드에 적힌 /raw/post-count 요청은 exact 등록만 선택합니다. 기본 / 쪽은 다른 URL을 보낸 경우를 비교하는 분기이며, 같은 exact 요청을 두 Servlet 모두로 보내는 뜻이 아닙니다.

HttpServlet.service의 전체 분기표가 아니라 이 예제의 GET과 POST 경로입니다. HEAD는 기본적으로 doHead를 거쳐 doGet과 연결될 수 있고, OPTIONS·TRACE·PUT·DELETE·PATCH도 각각의 Servlet 6.1 규칙을 따릅니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 이 그림은 정확한 raw post-count URL이 PostCountServlet으로 가고 그 인스턴스의 service가 GET과 POST를 각각 선택하는 순서를 보여줍니다. 다른 경로의 기본 슬래시 등록은 DispatcherServlet 경계입니다. GET은 200, 미재정의 HTTP/1.1 POST는 405입니다.
  • HTTP/1.1 요청의 URL로 등록을 먼저 고른다. /raw/post-count exact 매핑이면 PostCountServlet이다. 다른 경로의 기본 슬래시 매핑은 DispatcherServlet 경계다. 선택된 PostCountServlet에서 service가 메서드로 다시 나누며 GET은 doGet/PostQuery/200 text/plain, 미재정의 POST는 doPost/405다. 원래 가로 도표의 7노드와 6관계의 조건을 보존하고 정확한 예시 URL에서는 default 분기를 선택하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1컨테이너 매핑이 Servlet을 고른 뒤 HttpServlet.service가 메서드 콜백을 고른다
28HTTP/1.1 요청의 URL로 등록을 먼저 고른다. /raw/post-count exact 매핑이면 PostCountServlet이다. 다른 경로의 기본 슬래시 매핑은 DispatcherServlet 경계다. 선택된 PostCountServlet에서 service가 메서드로 다시 나누며 GET은 doGet/PostQuery/200 text/plain, 미재정의 POST는 doPost/405다. 원래 가로 도표의 7노드와 6관계의 조건을 보존하고 정확한 예시 URL에서는 default 분기를 선택하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
raw URL 매핑

정확한 post-count URL이 PostCountServlet을 선택합니다.

경로: raw post-count · 다른 기본 경로: DispatcherServlet컨테이너가 사용할 Servlet을 정합니다.
service method 선택

그 Servlet의 service가 GET과 POST를 분기합니다.

GET: doGet · POST: 미재정의 콜백선택 인스턴스 안에서 method가 해석됩니다.
독립 method 응답

GET은 200이고 미재정의 HTTP/1.1 POST는 405입니다.

GET: 200 · POST: 405method별 종료 응답이 결정됩니다.

컨테이너 매핑이 Servlet을 고른 뒤 HttpServlet.service가 메서드 콜백을 고른다

raw path 매핑으로 Servlet을 고른 뒤 service의 method 선택과 GET 200/POST 405가 이어진다.

Servlet 컨테이너는 먼저 URL 패턴으로 등록을 선택합니다. 정확한 /raw/post-count 매핑이면 postCountServlet으로 보내고, 기본 / 매핑이면 DispatcherServlet 경계로 보냅니다. 선택된 PostCountServlet 안에서는 HttpServlet.service가 HTTP 메서드를 판별합니다. GET은 재정의한 doGet과 PostQuery를 거쳐 200 text/plain이 되고, 재정의하지 않은 HTTP/1.1 POST는 상속한 doPost에서 405가 됩니다.

URL MAPPING FIRST · HTTP METHOD DISPATCH SECOND

컨테이너가 Servlet을 고르고, 선택된 HttpServlet이 doXxx를 고른다

두 선택을 한 단계로 섞지 않습니다. URL의 가장 구체적인 등록으로 실행 인스턴스를 정한 뒤, 그 인스턴스의 HttpServlet.service가 GET·POST 같은 메서드 콜백을 선택합니다.

Servlet URL 등록 선택 다음에 HTTP 메서드 콜백 선택이 그림은 정확한 raw post-count URL이 PostCountServlet으로 가고 그 인스턴스의 service가 GET과 POST를 각각 선택하는 순서를 보여줍니다. 다른 경로의 기본 슬래시 등록은 DispatcherServlet 경계입니다. GET은 200, 미재정의 HTTP/1.1 POST는 405입니다.HTTP/1.1 · /raw/post-count이 경로의 GET / POST 요청01 · Servlet container URL mappingexact /raw/post-count가 선택됨PostCountServlet이 경로의 정확한 등록DispatcherServlet다른 경로의 기본 /02 · HttpServlet.service선택된 인스턴스에서 method 확인GET → doGetPostQuery → 200text/plain · 7:70POST → doPost미재정의 기본 처리HTTP/1.1 · 405
URL 등록과 HTTP 메서드의 두 선택HTTP1.1요청의URL로등록을먼저고른다. raw/post-count exact매핑이면PostCountServlet이다. 다른경로의기본슬래시매핑은DispatcherServlet경계이다. 선택된PostCountServlet에서service가메서드로다시나누며GET은doGet/PostQuery/200textplain,미재정의POST는doPost/405이다. 원desktop7노드6관계의조건을보존하고정확한예시URL에서는default분기를선택하지않는다.다른 경로exact 선택GETPOSTHTTP/1.1 요청URL · GET / POST01 · URL mapping컨테이너 등록 선택DispatcherServlet다른 경로의 기본 /PostCountServlet/raw/post-count02 · 메서드 선택HttpServlet.service선택된 인스턴스GET → doGetPostQuery → 200text/plain · 7:70POST → doPost미재정의 기본 처리HTTP/1.1 · 405

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 내부는 뒤 단원으로 넘깁니다.

시작 노드에 적힌 /raw/post-count 요청은 exact 등록만 선택합니다. 기본 / 쪽은 다른 URL을 보낸 경우를 비교하는 분기이며, 같은 exact 요청을 두 Servlet 모두로 보내는 뜻이 아닙니다.

HttpServlet.service의 전체 분기표가 아니라 이 예제의 GET과 POST 경로입니다. HEAD는 기본적으로 doHead를 거쳐 doGet과 연결될 수 있고, OPTIONS·TRACE·PUT·DELETE·PATCH도 각각의 Servlet 6.1 규칙을 따릅니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 이 그림은 정확한 raw post-count URL이 PostCountServlet으로 가고 그 인스턴스의 service가 GET과 POST를 각각 선택하는 순서를 보여줍니다. 다른 경로의 기본 슬래시 등록은 DispatcherServlet 경계입니다. GET은 200, 미재정의 HTTP/1.1 POST는 405입니다.
  • HTTP/1.1 요청의 URL로 등록을 먼저 고른다. /raw/post-count exact 매핑이면 PostCountServlet이다. 다른 경로의 기본 슬래시 매핑은 DispatcherServlet 경계다. 선택된 PostCountServlet에서 service가 메서드로 다시 나누며 GET은 doGet/PostQuery/200 text/plain, 미재정의 POST는 doPost/405다. 원래 가로 도표의 7노드와 6관계의 조건을 보존하고 정확한 예시 URL에서는 default 분기를 선택하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1컨테이너 매핑이 Servlet을 고른 뒤 HttpServlet.service가 메서드 콜백을 고른다
28HTTP/1.1 요청의 URL로 등록을 먼저 고른다. /raw/post-count exact 매핑이면 PostCountServlet이다. 다른 경로의 기본 슬래시 매핑은 DispatcherServlet 경계다. 선택된 PostCountServlet에서 service가 메서드로 다시 나누며 GET은 doGet/PostQuery/200 text/plain, 미재정의 POST는 doPost/405다. 원래 가로 도표의 7노드와 6관계의 조건을 보존하고 정확한 예시 URL에서는 default 분기를 선택하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
raw URL 매핑

정확한 post-count URL이 PostCountServlet을 선택합니다.

경로: raw post-count · 다른 기본 경로: DispatcherServlet컨테이너가 사용할 Servlet을 정합니다.
service method 선택

그 Servlet의 service가 GET과 POST를 분기합니다.

GET: doGet · POST: 미재정의 콜백선택 인스턴스 안에서 method가 해석됩니다.
독립 method 응답

GET은 200이고 미재정의 HTTP/1.1 POST는 405입니다.

GET: 200 · POST: 405method별 종료 응답이 결정됩니다.

그림은 두 선택을 차례로 보여 줍니다. 정확한 /raw/post-count URL은 PostCountServlet을 고르고, 다른 경로는 기본 / 등록인 DispatcherServlet으로 갑니다. 고른 인스턴스 안에서는 service가 메서드를 다시 나누어, GET은 doGet과 PostQuery를 거쳐 200 text/plain이, 재정의하지 않은 HTTP/1.1 POST는 405가 됩니다. HEAD나 OPTIONS, PUT, PATCH 같은 다른 메서드도 각각 서블릿 6.1의 규칙을 따릅니다.

더 깊이: 컨테이너의 네 가지 매핑 규칙과 DispatcherServlet의 /

서블릿 6.1 명세의 12장은 요청 경로로 서블릿을 고르는 순서를 정합니다. 컨테이너는 먼저 경로와 정확히 같은 매핑을 찾고, 없으면 가장 긴 경로 접두사 매핑을, 그다음 마지막 경로 조각에 .jsp 같은 확장자가 있으면 확장자 매핑을 찾습니다. 셋 다 없을 때만 기본(default) 서블릿을 씁니다. 매핑 문법도 이 순서와 맞물려 있어서, /로 시작해 /*로 끝나는 문자열은 경로 접두사, *.으로 시작하는 문자열은 확장자, / 한 글자는 기본 서블릿을 뜻하고 나머지는 정확한 일치에만 쓰입니다.

/raw/post-count가 DispatcherServlet보다 앞서는 것은 구체성을 따졌기 때문이 아니라, 정확한 일치가 첫 규칙이고 /가 마지막 규칙이기 때문입니다. 만약 DispatcherServlet을 /*에 매핑하면 경로 접두사 규칙이 되어 확장자 규칙보다 먼저 적용됩니다. 명세는 내부에 JSP 엔진이 있는 컨테이너가 *.jsp를 그 엔진에 암묵적으로 매핑한다고 정하므로, /*는 이런 확장자 매핑까지 가로채게 됩니다. 반대로 /는 컨테이너의 기본 서블릿 자리만 대신하며, Spring MVC 문서도 DispatcherServlet을 /에 매핑하면 컨테이너의 기본 서블릿 매핑을 덮어쓴다고 설명합니다. 그래서 정적 파일도 컨테이너가 아니라 Spring의 자원 처리기가 맡게 되는데, 이것이 앞 글에서 본 ResourceHttpRequestHandler 경로입니다.


실제 embedded Tomcat에서 확인합니다

다음 파일은 예제의 서블릿과 등록 구성, 실제 루프백 확인을 한 파일에 둔 테스트 픽스처이며, 게시판 프로젝트의 src/test/java에 둡니다. Spring의 테스트 대역에서 init, service, destroy를 직접 호출하지 않고, Boot 4.1.2가 임의의 포트로 Tomcat을 열면 JDK HttpClient가 127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다.

src/test/java/board/servlet/EmbeddedServletLifecycleTest.java
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 작업 스레드가 같은 서블릿 인스턴스에 동시에 들어와도 memberId와 요청, 응답이 요청마다 따로인지, 넷째는 애플리케이션 컨텍스트를 정상적으로 닫으면 마지막으로 끝난 요청 뒤에 destroy가 한 번 호출되는지 확인합니다.

setLoadOnStartup(1)의 1은 참과 거짓의 값이 아니라 시작 순서의 값입니다. ServletRegistrationBean에 음수를 주면 지연 초기화를 허용하고, 0 이상이면 시작할 때 초기화합니다. 테스트는 정상 종료의 콜백을 확인할 뿐, 프로세스의 강제 종료나 전원 장애에서도 destroy가 실행된다는 것을 확인하지는 않습니다.

클라이언트의 동시 실행과 서버의 처리 스레드도 구별합니다. newVirtualThreadPerTaskExecutor()는 두 HTTP 요청을 보내는 테스트 쪽의 실행기이며, 이 한 줄이 Tomcat의 요청 처리 스레드를 가상 스레드로 바꾸지는 않습니다. maxActiveCalls=2와 진입 래치는 두 doGet 호출이 겹쳤는지를 관찰하고, 5초 대기는 테스트가 끝없이 멈추지 않게 하는 장치입니다. 이 숫자로 운영 서버의 요청 제한 시간이나 처리 용량을 정하지 않습니다.


공유 인스턴스에는 요청 상태를 저장하지 않습니다

동시에 들어온 두 요청은 PostCountServlet 인스턴스와 query 필드를 함께 씁니다. 반면 request, response, rawMemberId, memberId, count는 각 doGet 호출의 파라미터와 지역 변수입니다. 그래서 요청 A가 memberId=7을 읽은 뒤 멈추고 요청 B가 memberId=9를 읽어도 서로 덮어쓸 필드가 없습니다. currentMemberId 같은 바뀌는 인스턴스 필드를 추가하면 호출 사이에서 값이 섞일 수 있고, doGet 전체를 synchronized로 막으면 이 등록의 요청이 한 줄로 서게 되므로, 요청 상태를 필드에서 없애는 것이 먼저입니다. final PostQuery query의 final은 참조를 다시 대입하지 못하게 할 뿐이므로, 실제 협력 객체도 상태가 없거나 불변이거나 명시적으로 스레드 안전해야 합니다.

초기화나 소멸과, 요청마다의 정리는 수명이 다릅니다. destroy는 요청 하나의 finally가 아니므로 요청이 끝날 때마다 서블릿을 소멸시키지 않습니다. 그 호출이 빌린 자원은 그 호출의 try/finally나 try-with-resources에서 정리하고, 인스턴스가 사는 동안 가진 공유 자원의 정리는 따로 설계합니다. 소멸 전의 대기 제한이 지나면 아직 실행 중인 호출이 있을 수 있으므로, destroy를 모든 작업이 끝났다는 보장으로 쓰지 않습니다.

대상기본 소유 범위안전한 규칙
서블릿 인스턴스이 등록이 서비스하는 동안요청마다 바뀌는 상태를 필드에 두지 않음
공유 협력 객체여러 service 호출불변이거나 동시 접근 규칙이 있는지 확인
요청과 응답지금의 동기 디스패치현재 호출 안에서만 쓰고 다른 스레드에 넘기지 않음
요청 파라미터와 계산값지금의 service 호출파라미터와 지역 변수에 둠

요청과 응답의 구현은 스레드 안전이 보장되지 않으며, 컨테이너가 다시 쓸 수도 있습니다. 비동기 처리는 예외입니다. request.startAsync()가 성공하면 객체의 수명이 service가 돌아온 뒤 AsyncContext.complete()까지 늘어나지만, 그것만으로 여러 스레드가 마음대로 접근해도 안전해지지는 않으므로, 시간 제한과 complete를 포함한 별도의 규칙이 필요합니다. 응답의 상태와 헤더는 응답이 커밋되기 전에 정해야 하며, 본문을 일부 보낸 뒤에는 정해진 형식의 오류 응답으로 바꾸기 어렵습니다. 입력 스트림과 응답 커밋의 자세한 규칙은 이어지는 요청과 응답의 글에서 다룹니다.


정리

  • 서블릿 등록 하나에 연결된 인스턴스는 init이 성공한 뒤 여러 요청을, 때로는 동시에 처리하고, 제거될 때 destroy로 끝납니다.
  • 초기화에 실패한 인스턴스에는 service와 destroy가 호출되지 않으며, destroy는 정상 종료에서만 보장됩니다.
  • 컨테이너는 먼저 URL로 서블릿을 고르고, 고른 HttpServlet의 service가 HTTP 메서드에 맞는 doXxx를 고릅니다.
  • 재정의하지 않은 메서드는 HTTP/1.1에서 보통 405가 되지만, HTTP/1.0에서는 400이 될 수 있습니다.
  • 공유되는 서블릿 인스턴스에는 요청마다 바뀌는 상태를 두지 않고, 요청과 응답 객체는 현재 호출 안에서만 씁니다.

연습 문제

1. 공유 필드로 섞이는 응답

픽스처의 PostCountServlet에 private long currentMemberId 필드를 추가하고, memberId를 그 필드에 쓴 뒤 동시성 관문(gate)을 지나도록 바꾸어 두 응답이 섞이는 실패를 재현하세요. 이때 필드에 쓰기만 하지 말고, 관문 뒤의 query.countByMember(...) 인자와 응답의 회원 식별자도 공유 필드에서 읽도록 바꾸어야 합니다. 그다음 필드를 없애 셋째 테스트를 다시 통과시키세요. 또 공유 필드에는 마지막에 9가 남았지만 계산과 출력은 원래의 지역 변수로 둔 경우, 두 응답은 무엇일까요?

해설 보기

A의 지역 변수는 7, B의 지역 변수는 9입니다. 원래 코드에서는 두 요청이 관문에서 기다린 뒤에도 각각 7:70과 9:90을 만듭니다. 실패하는 변형에서 두 요청이 모두 관문 뒤에 공유 필드의 값 9를 읽으면, 서로 다른 입력을 보낸 두 요청이 모두 9:90을 만들 수 있습니다. 중요한 것은 어느 요청이 먼저 끝나는지가 아니라, 응답을 계산할 때 어느 호출의 값을 읽느냐입니다. 이렇게 중복된 응답은 원래 테스트가 기대하는 두 값의 집합을 만족하지 못하고, 같은 문자열 두 개를 Set.of에 넣는 단계에서 중복 요소 오류가 날 수도 있습니다.

필드에 쓰기만 하고 계산과 출력은 지역 변수로 둔 경우의 두 응답은 그대로 7:70과 9:90입니다. 공유 필드에 9를 기록했다고 각 호출의 지역 변수가 바뀌지는 않기 때문입니다. 이 변경은 공유 상태의 문제를 관찰하려는 임시 실험이므로, 끝나면 원래의 지역 변수 경로로 되돌립니다.

2. 잘못된 회원 식별자

원래 픽스처에 memberId=abc와 memberId가 없는 요청을 보내는 실제 루프백 테스트를 작성하고, 400이 나오는지와 검증이 본문을 쓰기 전에 끝나는지 확인하세요. 이어서 이미 디코딩된 파라미터 문자열로 +7, -3, 앞에 공백이 있는 7, 값 누락, 9223372036854775808을 넣었을 때, 숫자 변환이 성공하는지와 조회까지 가는지, 성공하면 응답이 무엇인지 구하세요.

해설 보기

abc와 값 누락은 Long.parseLong에서 NumberFormatException이 나서 sendError(400, ...) 뒤 돌아갑니다. 누락은 getParameter가 null을 돌려주는 경우입니다. 그래서 조회와 동시성 관문, 정상 본문 쓰기를 실행하지 않습니다. 실제 오류 본문과 헤더는 컨테이너의 오류 처리에 맡기고, 새 테스트에서는 상태 400과 정상 응답 경로가 실행되지 않았는지를 먼저 확인합니다.

Long.parseLong은 부호 있는 10진 정수를 바꿉니다. +7은 7로 바뀌어 조회까지 가고 응답은 7:70입니다. -3도 변환에 성공해 원래 코드에서는 -3:-30이 되지만, 이것이 양수 회원이라는 조건을 통과했다는 뜻은 아닙니다. 이 픽스처의 memberId * 10은 실제 DB 조회가 아닌 테스트용 계산이기 때문입니다. 7과 null, long 범위를 넘는 9223372036854775808은 NumberFormatException으로 400이 되어 조회까지 가지 않습니다. 양수 식별자와 회원의 존재, 조회 권한을 요구한다면 변환 뒤 조회 전에 그 조건을 따로 검사해야 하며, 아주 큰 값의 곱셈도 7과 9, 11의 사례로 보장되지 않습니다. URL 쿼리에서 글자 그대로의 +를 보내려면 인코딩 조건이 따로 필요하지만, 이 문제는 이미 얻은 문자열을 비교합니다.

3. 요청이 없는 정상 제거

인스턴스가 정상적으로 초기화된 뒤 요청을 한 건도 받지 않고 정상적으로 제거됩니다. init, service, destroy의 호출 횟수는 각각 몇 번일까요? 초기화가 ServletException으로 실패한 경우와 비교하고, 클라이언트가 가상 스레드 두 개로 요청을 보냈다는 사실이 서버 스레드의 종류를 알려 주는지도 설명하세요.

해설 보기

요청 없이 정상 제거되면 init 1회, service 0회, destroy 1회입니다. 초기화에 실패한 인스턴스에는 service와 destroy가 호출되지 않습니다. 정상 제거 대신 프로세스가 강제로 종료되면 이 소멸 횟수는 보장되지 않습니다. 클라이언트 실행기의 가상 스레드는 요청을 보내는 방식일 뿐 서버의 Tomcat 스레드 설정을 알려 주지 않으며, 이 실험이 확인하는 것은 두 호출이 겹쳤는지와 각 호출이 어느 값을 읽었는지입니다.

다음 글 서블릿 요청 입력에서는 같은 HttpServletRequest에서 쿼리 파라미터와 폼 필드, JSON 본문이 서로 다른 API와 프레이밍을 거치는 이유를 살펴봅니다.