서블릿 생명 주기
실제 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의 수명 변화가 있다.
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 규칙을 별도로 따라야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 구성 코드가 등록한 인스턴스는 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의 수명 변화가 있다.
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 규칙을 별도로 따라야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 구성 코드가 등록한 인스턴스는 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가 이어진다.
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 내부는 뒤
단원으로 넘깁니다.
시작 노드에 적힌 /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가 메서드 콜백을 고른다 |
| 28 | 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 분기를 선택하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 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: 405 | method별 종료 응답이 결정됩니다. |
컨테이너 매핑이 Servlet을 고른 뒤 HttpServlet.service가 메서드 콜백을 고른다
raw path 매핑으로 Servlet을 고른 뒤 service의 method 선택과 GET 200/POST 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 내부는 뒤
단원으로 넘깁니다.
시작 노드에 적힌 /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가 메서드 콜백을 고른다 |
| 28 | 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 분기를 선택하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 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: 405 | method별 종료 응답이 결정됩니다. |
그림은 두 선택을 차례로 보여 줍니다. 정확한 /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 요청을 보냅니다.
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와 프레이밍을 거치는 이유를 살펴봅니다.