서버

JettyServer에는 애플리케이션이 흔히 조정하는 설정마다 메서드가 있다. 그 밖의 설정은 커스터마이저로 바꾸며, 커스터마이저는 시작 직전에 실제 Jetty 객체를 대상으로 실행된다:

new JettyServer(app)
        .port(8443)
        .host("127.0.0.1")
        .contextPath("/app")
        .sessions(false)
        .threadPool(new QueuedThreadPool(200, 8))
        .multipart(new MultipartConfigElement(tmp, 10_485_760L, 10_485_760L, 1_048_576))
        .stopTimeout(Duration.ofSeconds(20)) // longer drain for slow requests
        .shutdownHook(false) // something else owns the lifecycle
        .customizeHttpConfiguration(http -> http.setSendServerVersion(false))
        .customizeContext(context -> context.addFilter(MyFilter.class, "/*", null))
        .customizeServer(server -> server.setDumpBeforeStop(true))
        .start();

가상 스레드

핸들러는 가상 스레드에서 실행될 수 있다. 핸들러를 가상 스레드에서 실행하는 것은 프레임워크 기능이 아니라 스레드 풀 설정이므로, Jetty 자체 API 두 줄로 남는다. 플랫폼 스레드는 계속 셀렉터를 실행하고, 핸들러는 가상 스레드를 얻는다:

QueuedThreadPool pool = new QueuedThreadPool();
pool.setVirtualThreadsExecutor(VirtualThreads.getDefaultVirtualThreadsExecutor());

app.server((a, port) -> new JettyServer(a).port(port).threadPool(pool))
   .start(8080);

가상 스레드는 핸들러가 I/O나 데이터베이스에서 블로킹할 때만 이점이 있다. 그 블로킹 호출을 감싼 synchronized 블록은 JDK 24(JEP 491)부터 캐리어 스레드를 고정(pin)시키지 않는다. 네이티브 메서드 안의 블로킹 호출은 여전히 캐리어 스레드를 고정시켜 이점을 도로 가져간다.

종료

종료는 기본으로 정상 종료(graceful shutdown)다. JVM 셧다운 훅이 Ctrl-C나 SIGTERM에서 서버를 멈추고, stop()은 진행 중인 요청에 5초를 주어 마치게 한 뒤 끊는다. 애플리케이션 자체의 훅이 같은 때에 열린 SSE 스트림을 닫으므로, 스트림이 드레인을 타임아웃 내내 붙잡지 않는다. 유휴 keep-alive 연결은 중지를 지연시키지 않으므로, 진행 중인 요청이 없으면 중지는 즉시 반환된다. Tomcat과 Undertow에서는 드레인 중에 도착한 요청이 503으로 답한다. stopTimeout(Duration.ZERO)는 드레인을 완전히 끈다.

서버 교체하기

app.start(port)를 진입점으로 유지하면서 서버를 설정하거나, 완전히 다른 서버를 실행하려면 팩토리를 교체한다:

app.server((a, port) -> new JettyServer(a).port(port).sessions(false))
   .start(9000);

app.server((a, port) -> new TomcatServer(a).port(port)) // spider-silk-tomcat
   .start(9000);

app.server((a, port) -> new UndertowServer(a).port(port)) // spider-silk-undertow
   .start(9000);

app.server((a, port) -> new MyOwnServer(a, port)) // implements WebServer
   .start(9000);

WebServer는 메서드 네 개(start, stop, join, port)로 이루어지므로, 다른 구현체를 만드는 것은 작은 일이다. 구현체 두 개가 이미 있다: Tomcat과 Undertow이며, 각각 묶인 서버의 이름을 딴 모듈에 있다.

서버 고르기

core가 제공하는 모든 것은 셋 모두에서 같게 동작한다: 라우팅, 필터, 상태 페이지, JSON, 템플릿, 정적 파일, SSE, 멀티파트 업로드, 세션. core가 필요로 한 것은 서블릿 컨테이너뿐이었다. WebTest는 app.start(0)으로 앱을 시작하고 이는 앱이 지정한 서버를 만들므로, 테스트도 바뀌지 않고 앱이 실행되는 서버에서 실행된다. 서버마다 다른 것은 임베딩이다: 각 서버가 주변 환경에서 무엇을 필요로 하는지, 그리고 이 프로젝트가 그 라이프사이클 중 얼마나 많은 부분을 손으로 조립해야 했는지다.

Jetty (기본) Tomcat Undertow

모듈

spider-silk-core에 포함

spider-silk-tomcat

spider-silk-undertow

세션 처리

sessions(false)로 끈다

항상 켜짐

항상 켜짐

정상 종료(graceful shutdown)

Jetty 자체의 stopTimeout

직접 구현: 503으로 답하고 요청을 세는 밸브

요청을 세는 Undertow의 GracefulShutdownHandler

드레인 대 가상 스레드

영향 없음

영향 없음

영향 없음

셧다운 훅

Jetty의 setStopAtShutdown

자체 구현: 시작 시 등록, 중지 시 제거

자체 구현: 시작 시 등록, 중지 시 제거

JVM 유지

Jetty의 스레드가 non-daemon

Server.await()에 대기하는 non-daemon 스레드

래치에 대기하는 non-daemon 스레드

작업 디렉터리

없음

디스크 위의 catalina.base, baseDir(…​)가 없으면 임시

없음

가상 스레드

QueuedThreadPool을 넘기는 threadPool(…​)

executor(…​)

executor(…​)

로깅

slf4j 직접

JULI, 따라서 jul-to-slf4j와 브리지 핸들러

JBoss Logging, 클래스패스의 slf4j를 찾는다

커스터마이저

server, context, HTTP configuration

Tomcat, context, connector

deployment, builder

어댑터 크기

286줄

430줄

381줄

셋 중 어느 것도 더 나은 서버는 아니다. 각자 치르는 비용이 다를 뿐이다.

Jetty가 기본으로 남는 이유는 유지하는 데 비용이 들지 않기 때문이다. 디스크가 필요 없고, 종료와 라이프사이클은 여기서 조립한 것이 아니라 Jetty 자체의 것이며, 애플리케이션의 나머지처럼 slf4j로 로깅한다. jar로 배포되어 운영자가 손대지 않는 서비스라면 그 밖에 따질 것이 없다.

Tomcat은 주변 환경이 이미 Tomcat을 전제하는 곳에서 자리를 얻는다. 대부분의 조직에서 운영 지식은 Tomcat 지식이다: 커넥터 속성, 액세스 로그 밸브, 스레드 풀 숫자, 대시보드가 이미 긁어 가고 있는 JMX 빈. 또한 Spring Boot가 기본으로 택하는 서버이므로, Spring Boot에서 옮겨 가는 서비스는 웹 계층만 바꾸면서 같은 런타임 동작과 같은 튜닝을 유지한다. 보안 권고도 대부분의 엔터프라이즈 프로세스가 이미 추적하는 파이프라인으로 나온다.

Undertow는 기술적 장점으로 택할 서버다. 셋 중 임베딩이 가장 가볍고, 작업 디렉터리도 로깅 브리지도 없다. 정상 종료(graceful shutdown)가 일급 핸들러이며, Tomcat의 정상 종료는 같은 설계를 여기서 밸브로 조립한 것이다. 단점은 저변이다: 독립 제품이 아니라 WildFly 컴포넌트이므로, 운영상의 친숙함과 주변 도구가 Tomcat보다 부족하다.

이 선택은 쉽게 되돌릴 수 있다. 어느 방향으로 서버를 바꾸든 팩토리의 같은 한 줄만 고치면 된다.