웹 서버·WAS·서블릿 컨테이너
리버스 프록시, 내장 Tomcat, Servlet 컨테이너, Spring 애플리케이션의 경계를 게시판 실제 포트 요청으로 확인하고 장애 위치를 구분합니다.
브라우저 요청을 가장 먼저 받는 프로그램을 넓게 웹 서버라고 부릅니다.
Java 웹 애플리케이션에서는 Tomcat 같은 서블릿 컨테이너가 요청을 Java 객체로 만들고 알맞은 서블릿을 호출합니다.
서블릿은 요청을 읽고 응답을 만드는 Java 객체입니다.
WAS는 이처럼 애플리케이션 코드를 실행하는 서버를 가리키는 넓은 용어입니다.
운영 환경에서는 Nginx 같은 앞단 웹 서버가 TLS와 정적 파일·프록시를 맡고 Tomcat으로 요청을 전달할 수 있습니다.
리버스 프록시는 클라이언트 대신 내부 애플리케이션 서버를 선택해 요청을 넘기는 중간 서버입니다.
Spring Boot 실행 파일에는 내장 Tomcat과 애플리케이션이 함께 들어가지만 역할까지 하나가 되는 것은 아닙니다.
웹 서버의 역할
Nginx나 클라우드 로드 밸런서 같은 앞단 웹 서버는 보통 다음 일을 수행합니다.
- 공개 443 포트에서 TLS 핸드셰이크와 인증서 관리
- 호스트와 경로 기준으로 애플리케이션 인스턴스에 리버스 프록시
- 정적 자산과 압축 표현 제공
- 연결 제한, 요청 크기, 타임아웃의 첫 방어
- 접근 로그, WAF, 요청률 제한, 상태 확인
모든 배포에 별도 웹 서버가 필수인 것은 아닙니다.
내장 Tomcat도 HTTP 서버 기능을 포함하므로 로컬이나 작은 내부 서비스는 직접 요청을 받을 수 있습니다.
운영 요구가 TLS 자동화, 여러 서비스 라우팅, CDN과 연결될 때 앞단 계층을 둡니다.
앞단에서 413을 반환하면 애플리케이션 컨트롤러는 호출되지 않습니다.
프록시 읽기 타임아웃이 30초인데 애플리케이션 타임아웃이 60초라면 애플리케이션이 완료하기 전에 클라이언트 연결이 먼저 끊깁니다.
타임아웃은 바깥에서 안쪽으로 무작정 증가시키기보다 전체 기한과 취소 전파를 설계합니다.
서블릿 컨테이너의 HTTP 변환
Tomcat은 소켓에서 HTTP 메시지를 해석해 HttpServletRequest와 HttpServletResponse를 만들고 URL 매핑에 맞는 필터 체인과 Servlet을 호출합니다.
또한 다음 생명주기를 관리합니다.
- Servlet 등록·초기화·서비스 호출·소멸
- 요청별 스레드 할당과 커넥터 대기열
- 필터, 리스너, 세션, 요청 디스패처
- 멀티파트 파싱과 연결·응답 커밋
- 애플리케이션 컨텍스트 경로와 Servlet 매핑
애플리케이션 개발자는 원시 TCP 스트림을 매번 파싱하지 않고 Servlet API를 통해 메서드, URI, 헤더, 파라미터, 본문을 읽습니다.
Spring MVC의 DispatcherServlet도 컨테이너에 등록되는 하나의 Servlet이며 그 안에서 다시 컨트롤러 매핑과 인자 바인딩을 수행합니다.
Spring Boot 내장 서버 조립
spring-boot-starter-webmvc가 있고 Servlet 웹 애플리케이션으로 판정되면 Boot가 내장 Servlet 컨테이너와 DispatcherServlet 등록을 자동 구성합니다.
메인 메서드는 같은 방식으로 실행됩니다.
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(
BoardApplication.class, args);
}
}배포 서버에 WAR를 올리는 전통적인 방식과 달리 실행 가능한 JAR이 서버 버전과 애플리케이션 의존성을 함께 고정합니다.
환경마다 외부 WAS 설정이 달라지는 폭을 줄이지만 커넥터, TLS, 스레드, 접근 로그 운영 설정이 사라지는 것은 아닙니다.
server.port=0은 운영체제가 빈 포트를 선택하게 해 통합 테스트 충돌을 피합니다.
실제 선택 포트는 컨텍스트가 시작된 뒤 확인합니다.
package board.server;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.web.servlet.context
.ServletWebServerApplicationContext;
@SpringBootTest(
webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
class EmbeddedServerBoundaryTest {
@LocalServerPort
int port;
@Autowired
ServletWebServerApplicationContext context;
@Test
void 실제_Tomcat_port로_HTTP를_왕복한다()
throws Exception {
var request = HttpRequest.newBuilder()
.uri(URI.create(
"http://127.0.0.1:" + port
+ "/api/system"))
.GET()
.build();
var response = HttpClient.newHttpClient().send(
request,
HttpResponse.BodyHandlers.ofString());
assertThat(port).isPositive();
assertThat(context.getWebServer().getPort())
.isEqualTo(port);
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.headers()
.firstValue("Content-Type"))
.hasValueSatisfying(value ->
assertThat(value)
.startsWith(
"application/json"));
}
}이 테스트는 컨트롤러 직접 호출이 아니라 실제 루프백 소켓, HTTP 파서, 필터, DispatcherServlet, 컨버터를 통과합니다.
전체 경계가 필요한 계약에만 사용하고 단순 서비스 규칙까지 매번 서버를 띄우지 않습니다.
Tomcat initialized with port 0 (http)
Tomcat started on port 53142 (http)
EmbeddedServerBoundaryTest
> 실제_Tomcat_port로_HTTP를_왕복한다() PASSED
BUILD SUCCESSFUL동적·정적 처리 분리
Spring Boot는 클래스 경로 /static의 자산을 리소스 핸들러로 제공할 수 있습니다.
이것도 내장 서버와 Servlet 인프라를 통하지만 애플리케이션 서비스를 호출하지 않습니다.
규모가 커지면 해시가 붙은 자산을 CDN으로 보내고 API만 애플리케이션에 남길 수 있습니다.
| 요청 | 주 처리 주체 | 변경되는 영역 |
|---|---|---|
/assets/app.abc123.js | CDN·정적 핸들러 | 배포 자산 |
/api/posts/42 | DispatcherServlet·컨트롤러 | 업무 리소스 |
/actuator/health | 관리 엔드포인트 | 인스턴스 상태 |
| TLS 핸드셰이크 | 프록시·커넥터 | 인증서·암호 설정 |
정적 자산 404를 서비스 리포지토리에서 찾지 않고, API 500을 CDN 캐시 문제로만 보지 않습니다.
접근 로그의 업스트림 상태와 애플리케이션 트레이스 ID를 연결하면 어느 계층이 최종 상태를 만들었는지 알 수 있습니다.
스레드와 연결 자원
Servlet 방식에서는 일반적으로 요청 처리 스레드가 컨트롤러 호출 동안 사용됩니다.
지속 연결 수, 커넥터 수락 대기열, 최대 작업자 스레드, DB 연결 풀 수는 서로 다른 자원입니다.
HTTP/2 한 연결에 여러 스트림이 있어도 블로킹 애플리케이션 작업은 실행 스레드가 필요합니다.
DB 풀이 20인데 워커 스레드를 500으로 늘리면 480개 요청이 연결을 기다릴 수 있습니다.
외부 API 타임아웃과 DB 쿼리 기한 없이 스레드만 늘리면 메모리와 컨텍스트 전환이 증가합니다.
가상 스레드를 쓰더라도 하위 시스템 동시성 한도와 요청 기한은 필요합니다.
연습 문제
임의 포트로 게시판 서버를 띄워 정적 /health.txt와 동적 /api/system을 실제 HttpClient로 요청하세요.
각각의 상태와 Content-Type을 확인하고, 존재하지 않는 경로가 404일 때 컨트롤러 호출 카운터가 증가하지 않는지 검증합니다.
해설 보기
src/test/resources/static/health.txt를 두고 임의 포트 테스트를 사용합니다.
정적 리소스와 API에 같은 응답 본문을 기대하지 말고 미디어 타입과 처리 경계를 각각 검증합니다.
var staticResponse = send("/health.txt");
assertThat(staticResponse.statusCode()).isEqualTo(200);
assertThat(staticResponse.body()).isEqualTo("ready");
var apiResponse = send("/api/system");
assertThat(apiResponse.statusCode()).isEqualTo(200);
assertThat(apiResponse.headers()
.firstValue("Content-Type").orElseThrow())
.startsWith("application/json");404 요청은 커넥터와 DispatcherServlet까지 도달할 수 있지만 특정 컨트롤러 메서드는 호출되지 않습니다.
프록시 앞단 404와 애플리케이션 404를 구분하려면 서버 접근 로그와 애플리케이션 응답 헤더를 함께 봅니다.
다음 문서에서는 컨테이너가 Servlet 객체를 언제 하나 만들고 여러 요청 스레드가 같은 인스턴스의 service를 어떻게 호출하는지 직접 등록한 Servlet로 확인합니다.