서블릿 생명 주기
실제 embedded Tomcat에서 Servlet 등록·초기화·HTTP 메서드 디스패치·동시 요청·소멸을 계측하고 요청별 상태의 소유권을 검증합니다.
Spring MVC 컨트롤러에 도달하기 전에 Servlet 컨테이너는 URL 매핑으로 Servlet을 고르고 요청·응답 객체를 전달합니다.
이 단원에서는 /raw/post-count에 직접 등록한 HttpServlet 하나를 실제 embedded Tomcat에서 실행합니다.
핵심은 "Servlet 클래스가 JVM 전체에서 영원히 하나"가 아니라, 이 Servlet 등록에 연결된 한 인스턴스를 컨테이너가 초기화한 뒤 여러 요청에 재사용할 수 있다는 점입니다.
등록된 인스턴스의 생명 주기
이 예제에서는 구성 코드가 new PostCountServlet(probe, query)로 인스턴스를 만들고 ServletRegistrationBean에 넘깁니다. probe는 아래 테스트가 호출 횟수와 동시 진입을 관찰하는 계측 객체이고, query는 게시글 수를 구하는 협력 객체입니다.
그 뒤의 init, service, destroy 호출 순서는 Servlet 컨테이너가 관리합니다.
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: 없음 | 등록 인스턴스의 요청 수명이 끝납니다. |
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"라고 일반화하면 안 됩니다.
컨테이너 매핑이 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별 종료 응답이 결정됩니다. |
실제 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가 실행된다고 증명하지 않습니다.
클라이언트의 동시 실행과 서버의 처리 스레드를 구별합니다. newVirtualThreadPerTaskExecutor()는 두 HTTP 요청을 보내는 테스트 쪽 executor입니다. 이 한 줄이 Tomcat의 요청 처리 스레드를 가상 스레드로 바꾸지는 않습니다. maxActiveCalls=2와 진입 latch는 두 doGet 호출이 겹쳤음을 관찰하고, 5초 대기는 테스트가 무한히 멈추지 않도록 둔 가드입니다. 이 숫자로 운영 서버의 요청 제한 시간이나 처리 용량을 정하지 않습니다.
공유 인스턴스에는 요청 상태를 저장하지 않습니다
동시에 들어온 두 요청은 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여야 합니다.
초기화·소멸과 요청별 정리는 수명이 다릅니다. destroy는 한 요청의 finally가 아니므로 요청이 끝날 때마다 Servlet을 소멸시키지 않습니다. 해당 호출이 빌린 자원은 그 호출의 try/finally나 try-with-resources에서 정리하고, 인스턴스 수명 동안 소유한 공유 자원의 정리는 별도로 설계합니다. 소멸 전 대기 제한이 지나면 아직 실행 중인 호출이 있을 수 있으므로 destroy를 모든 작업의 무조건적인 완료 보장으로 사용하지 않습니다.
| 대상 | 기본 소유 범위 | 안전한 규칙 |
|---|---|---|
| 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를 통과하도록 바꿔 보세요.
두 응답이 섞이는 실패를 재현한 다음 필드를 제거해 세 번째 테스트를 다시 통과시키세요.
실패 실험에서는 필드에 저장하는 것뿐 아니라, gate 뒤의 query.countByMember(...) 인수와 응답의 회원 ID도 공유 필드에서 읽도록 바꾸어야 합니다. 필드에 쓰기만 하고 이후 계산·출력은 지역 변수 memberId를 계속 사용하면 원 응답은 섞이지 않습니다. 이 변경은 공유 상태의 문제를 관찰하기 위한 임시 실험이며, 수정 후에는 원 지역 변수 경로로 되돌립니다.
예시
A의 지역 변수는 7, B의 지역 변수는 9입니다. 원 코드에서는 두 요청이 gate에서 기다린 뒤에도 각각 7:70, 9:90을 만듭니다. 실패 변형에서는 두 요청 모두 gate 뒤에 공유 필드의 값 9를 읽었다고 정해 보세요. 그러면 서로 다른 입력을 보낸 두 요청이 모두 9:90을 만들 수 있습니다. 중요한 것은 특정 요청이 먼저 끝나는 순서가 아니라 응답 계산이 어느 호출의 값을 읽는가입니다.
독립 확인
공유 필드에는 마지막에 9가 남았지만 계산·출력은 원 지역 변수로 유지한 경우, 두 응답은 무엇인가요? 별도로 원 fixture에서 memberId=abc와 값이 없는 요청은 gate와 query 호출까지 도달하나요?
지역 값과 공유 값의 풀이 확인
지역 변수를 사용하는 두 응답은 7:70, 9:90입니다. 공유 필드에 9를 기록했다는 사실만으로 각 호출의 지역 변수가 바뀌지는 않습니다. 실패 변형의 중복 응답은 원 테스트의 두 값 집합을 충족하지 못하고, 같은 문자열 두 개를 Set.of에 넣는 단계에서도 중복 요소 오류가 날 수 있습니다.
abc와 누락된 값은 Long.parseLong에서 NumberFormatException이 발생해 sendError(400, ...) 뒤 반환합니다. 누락은 getParameter가 null을 반환하는 경우입니다. 따라서 query·concurrency gate·정상 본문 쓰기를 실행하지 않습니다. 실제 오류 본문과 header 모양은 container의 오류 처리 경로에 맡기고, 새 loopback 테스트에서는 우선 상태 400과 정상 응답 경로가 실행되지 않았는지를 확인합니다.
이 풀이는 코드의 조건을 추적한 예상입니다. 테스트를 실행하면 두 요청의 겹침, 공유 필드 실패, 복구 후 통과와 입력 오류를 각각 기록하세요.
추가로 memberId=abc와 memberId 누락 요청이 400인지 실제 loopback 테스트를 작성하고, 검증을 본문 쓰기 전에 끝내세요.
숫자로 바뀌었다고 회원 조건이 검증된 것은 아닙니다. Long.parseLong은 부호 있는 10진 정수를 변환합니다. +7과 -3은 숫자로 읽지만 7처럼 앞에 공백이 있는 값, 누락된 값, long 범위를 벗어난 값은 변환 오류입니다. 이 fixture의 memberId -> memberId * 10은 실제 DB 조회가 아닌 테스트용 계산이므로 -3도 -3:-30을 만들 수 있습니다. 양수 ID, 회원 존재와 조회 권한을 요구한다면 변환 뒤 query 호출 전에 그 조건을 따로 검사해야 합니다. 매우 큰 값의 곱셈도 여기서 검증한 7·9·11의 사례로 보장되지 않습니다.
다음 문서에서는 같은 HttpServletRequest에서 쿼리 파라미터, 폼 필드, JSON 본문이 서로 다른 API와 프레이밍을 거치는 이유를 읽습니다.
추가 연습 — 수명·입력·호출 소유권을 따로 판단합니다. 다음 세 경우를 각각 판단하세요. (1) 인스턴스가 정상 초기화된 뒤 요청은 한 건도 없고 정상적으로 제거됩니다. init, service, destroy 호출 횟수는 무엇인가요? 초기화 자체가 ServletException으로 실패한 경우도 비교하세요. (2) 원 fixture에 memberId=+7, memberId=-3, 앞에 공백이 있는 memberId= 7, 값 누락, memberId=9223372036854775808을 전달합니다. 여기서는 이미 디코딩된 파라미터 문자열을 조건으로 삼으며 URL에서 +를 어떻게 인코딩하는지는 제외합니다. 숫자 변환 성공 여부와 query 도달 여부, 성공 시 toy 응답을 구하세요. (3) A와 B가 지역 ID 7·9를 얻어 동시에 gate에서 기다립니다. 공유 필드의 최종 값은 9라고 정합니다. 원 지역 변수 경로와 계산·출력을 모두 공유 필드로 바꾼 경로의 결과를 비교하고, 클라이언트가 가상 스레드 두 개를 사용했다는 사실이 서버 스레드 종류를 증명하는지도 설명하세요.
추가 연습의 풀이 확인
- 정상 초기화 후 요청 없는 정상 제거는
init=1,service=0,destroy=1입니다. 초기화가 실패한 해당 인스턴스에는service와destroy가 호출되지 않습니다. 정상 제거 조건을 강제 프로세스 종료로 바꾸면 이 소멸 횟수는 보장할 수 없습니다. +7은 7로 변환되어 query에 도달하고 toy 응답은7:70입니다.-3도 변환에 성공하여 원 코드에서는-3:-30이 됩니다. 이는 양수 회원 조건을 통과했다는 뜻이 아닙니다.7, null,9223372036854775808은NumberFormatException을 일으켜 상태 400 뒤 반환하므로 query에 도달하지 않습니다. URL 쿼리에서 리터럴+를 전달하려면 별도의 인코딩 조건이 필요하지만, 이 문제는 이미 얻은 문자열을 비교합니다.- 원 지역 변수 경로는 A=
7:70, B=9:90입니다. 두 호출이 gate 뒤 공유 값 9로 계산·출력을 하면 둘 다9:90이 될 수 있습니다. 지역 값은 공유 필드의 마지막 쓰기에 의해 바뀌지 않습니다. 클라이언트 executor의 가상 스레드는 요청 송신 방식이며 서버의 Tomcat 스레드 설정을 증명하지 않습니다. 두 호출의 겹침과 어느 값을 읽었는지가 이 실험의 검증 대상입니다.