Spider Silk

Spider Silk

호출 스택은 얇게, 시그니처는 탄탄하게.

Spider Silk는 Jakarta Servlet API 위에 만들어진 웹 프레임워크다.

추구하는 가치

빠른 부팅

애플리케이션의 부팅 시간은 최소화한다. 오토스케일링되는 컨테이너, 서버리스 환경, 테스트 실행, 개발 서버의 재시작 등 개발과 운영의 주요 시점마다 부팅 시간은 반복해서 치르는 비용이기 때문이다.

소스로 추적할 수 있는 코드

요청이 응답이 되기까지 거치는 코드는 소스를 읽는 것만으로 따라갈 수 있어야 한다. 추적성은 사람 개발자와 AI 코딩 에이전트 모두에게 필요하다. 사람에게는 추적하기 쉬운 코드가 이해하고 디버깅하기 쉬운 코드다. AI 에이전트는 실행해 보기 전에 코드만 읽고 동작을 추론하므로, 코드에 드러나지 않는 동작은 추측으로 메운다. 예를 들어 애너테이션이 실행 시점에 무엇을 하는지 잘못 추측한 에이전트는 컴파일은 되지만 동작하지 않는 코드를 쓴다.

오픈소스 기술 생태계와의 조화

바퀴를 재발명하지 않고 검증된 오픈소스 기술을 활용한다. 같은 역할을 하는 기술 중에서는 사용자가 선택할 수 있는 확장 지점을 열어 둔다.

설계와 기술 선택

이 절은 앞의 세 가치를 실현하기 위해 프레임워크가 택한 설계와 기술을 설명한다.

API의 단순성, 직관성 추구

Spider Silk는 코드 추적성을 위해 핵심 API를 직관적이고 단순하게 유지한다. 핵심 인터페이스인 핸들러는 HTTP 요청을 WebRequest 타입의 메서드 파라미터로 전달받아 응답을 WebResponse로 반환한다.

Handler 인터페이스
@FunctionalInterface
public interface Handler {
    WebResponse handle(WebRequest request) throws Exception;
}

핸들러는 람다 표현식이나 메서드 참조로 URL과 HTTP 메서드와 매핑된다.

라우트 등록
var deckRepository = new DeckRepository(dataSource);
var deckService = new DeckService(deckRepository);
var app = new App();

app.get("/decks/{deckId}", req -> {
    long deckId = req.pathParamLong("deckId");
    Deck deck = deckService.getDeck(deckId);
    return WebResponse.template("deck", Model.of("deck", deck));
});

app.get("/api/decks", req -> {
    JsonArray resBody = Json.array();
    for (DeckSummary deck : deckService.deckSummaries()) {
        resBody.add(Json.object()
            .put("id", deck.id())
            .put("name", deck.name()));
    }
    return WebResponse.json(resBody);
});

app.post("/api/decks", req -> {
    String name = req.bodyJson(json -> json.asObject().getString("name"));
    Deck deck = deckService.createDeck(name);
    JsonObject resBody = Json.object()
        .put("id", deck.id())
        .put("name", deck.name());
    return WebResponse.json(resBody).status(HttpStatus.CREATED);
});

app.start(8080);

리플렉션 없음

Spider Silk 프레임워크 내부 코드는 리플렉션을 쓰지 않는다. 애플리케이션 부팅 시점의 애너테이션 스캔, 프록시 생성이 없으므로 부팅 시간이 빨라진다. 프레임워크의 콜 스택을 따라가기가 쉽기 때문에 코드 추적성도 높아진다. 애플리케이션을 GraalVM 네이티브 이미지로 컴파일하기도 쉽다.

최소한의 콜 스택 추가

이 프레임워크는 주된 호출 경로인 핸들러에서 HttpServlet.service 위에 스택 프레임을 두 개만 더한다. 필터, 예외 핸들러, 스트리밍 본문처럼 프레임워크가 애플리케이션 코드를 부르는 다른 곳도 네 프레임을 넘지 않는다. 스택 트레이스의 프레임이 적을수록 문제가 생긴 코드를 찾아가기가 쉬워서 코드 추적성이 높다. 다음은 핸들러 안에서 찍은 스택 트레이스다.

java.lang.Throwable: where a handler stands
    at com.example.decks.DeckRoutes.one(DeckRoutes.java:15) // the handler
    at net.benelog.spidersilk.AppServlet.dispatch(AppServlet.java:299) // Spider Silk
    at net.benelog.spidersilk.AppServlet.service(AppServlet.java:149) // Spider Silk
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java) // the Servlet API
    at org.eclipse.jetty.ee10.servlet.ServletHolder.handle(ServletHolder.java) // Jetty, from here down

풋프린트를 최소화하는 기본 기술 스택

Spider Silk는 빠른 부팅과 코드 추적성을 기준으로 기본 기술 스택을 골랐다. spider-silk-core는 웹 서버로 Jetty를, 템플릿 엔진으로 jte를 기본으로 포함한다. 의존성 하나만 추가하면 서버와 템플릿 엔진을 갖춘 애플리케이션을 바로 시작할 수 있다.

Jetty

기본 웹 서버는 임베디드 Jetty다. Jetty는 세 서버 가운데 서버 라이프사이클 관리를 자체 기능으로 가장 완성도 높게 제공한다. 그래서 Jetty를 쓰면 애플리케이션과 서버 사이에 놓이는 이 프레임워크의 코드가 Tomcat이나 Undertow를 쓸 때보다 적다. Jetty가 자체로 제공하는 기능은 다음과 같다.

  • 정상 종료는 Jetty 자체의 stopTimeout이, 셧다운 훅은 setStopAtShutdown이, JVM 유지는 Jetty의 non-daemon 스레드가 맡는다.

  • 디스크 위의 작업 디렉터리 없이 실행된다.

  • 세션을 sessions(false)로 끌 수 있다.

jte

기본 템플릿 엔진은 jte다. jte는 템플릿을 Java 소스로 컴파일한다. 그래서 ${deck.name()} 같은 표현식이 컴파일된 메서드 호출로 실행된다. jte는 모델 값을 리플렉션으로 읽지 않으므로 리플렉션 없음의 원칙과 맞는다. 미리 컴파일한 템플릿은 런타임에 컴파일러가 필요 없으므로 GraalVM 네이티브 이미지에서도 잘 동작한다. 개발 중에 DirectoryCodeResolver로 소스 디렉터리를 읽게 하면 템플릿을 고친 뒤 다시 빌드하지 않아도 바로 반영된다(개발과 프로덕션).

확장성 있는 모듈 구조

Spider Silk는 기본 기술 스택 외에도 많이 쓰이는 기술을 사용할 수 있도록 선택 모듈을 제공한다. 모든 모듈은 spider-silk-core에 의존하고, core는 그 위에 지어진 JSON 라이브러리인 silk-json 말고는 어느 모듈에도 의존하지 않는다.

분류 모듈 내용 의존성

기본

spider-silk-core

프레임워크 자체. 기본 웹 서버는 Jetty, 기본 템플릿 엔진은 jte다.

gg.jte:jte, 내장 Jetty (jetty-ee10-servlet), silk-json

JSON

silk-json

core가 그 위에 지어진 JSON 라이브러리: 라이터, 리더, 트리, 그리고 @JsonBound. 독립된 라이브러리이므로 다른 프레임워크 위의 애플리케이션도 쓸 수 있다.

실행 시점에는 JDK만

spider-silk-jackson

핸들러가 받는 JsonWriter와 JsonReader를 통해 Jackson으로 바인딩한다. ObjectMapper는 JacksonCodecs의 생성자에 한 번 건넨다.

core, jackson-databind 3.1.x

웹 서버

spider-silk-tomcat

같은 애플리케이션을 임베디드 Tomcat에서 실행한다. Tomcat을 이미 운영하는 조직이나 Spring Boot에서 옮겨 오는 애플리케이션에 맞는다.

core, tomcat-embed-core 11.0.x

spider-silk-undertow

같은 애플리케이션을 임베디드 Undertow에서 실행한다. Undertow의 정상 종료는 요청을 세므로, 가상 스레드 executor에서도 드레인이 동작한다.

core, undertow-servlet 2.3.x

템플릿 엔진

spider-silk-freemarker

FreeMarker로 렌더링한다.

core, freemarker

spider-silk-handlebars

Handlebars.java로 렌더링한다.

core, handlebars

spider-silk-thymeleaf

Thymeleaf로 렌더링한다.

core, thymeleaf

WebSocket

spider-silk-jetty-websocket

애플리케이션의 HTTP 라우트 곁에 WebSocket 엔드포인트를 매핑한다. 구조상 Jetty 전용이므로, App의 메서드가 아니라 Jetty의 이름을 단 모듈이다.

core, jetty-websocket-jetty-server

테스트

spider-silk-test

테스트 범위용 WebTest 하니스와 TestRequest. 별도 모듈이므로 프로덕션 jar에는 테스트 코드가 실리지 않는다.

core와 JDK만 (서블릿 API는 core처럼 컴파일 시점에만 필요)

예제

example-flashcard

플래시카드 학습 애플리케이션

core, spring-jdbc, H2

라우팅, 필터, 상태 페이지, JSON, 템플릿, 정적 파일, SSE, 멀티파트 업로드, 세션은 세 서버에서 같게 동작한다. TomcatServer와 UndertowServer의 API는 JettyServer를 그대로 따르므로, 서버를 바꾸려면 App.server(…​)에 넘기는 팩토리 한 줄만 고치면 된다.

템플릿 엔진은 App.templates(…​)로 바꾼다. 엔진을 바꿔도 핸들러의 WebResponse.template(name, model) 호출은 그대로다.

모듈 사이의 의존 관계는 다음 그림과 같다.

모든 모듈은 spider-silk-core에 의존하고 core는 silk-json에 의존한다; 예제 애플리케이션은 core와 openapi에 의존하고
Figure 1. 모듈과 각 모듈의 의존 대상

개발 도구 생태계 연동

Spider Silk는 개발 도구 생태계와 조화를 이루도록 테스트, API 문서, 관측, 빌드, AI 코딩 에이전트를 위한 도구를 별도 모듈과 스킬로 제공한다.

테스트
  • spider-silk-test의 WebTest 하니스는 빈 포트에서 애플리케이션을 시작하고, 쿠키를 유지하는 클라이언트를 건넨다.

API 문서
  • spider-silk-openapi는 app.routes()가 돌려주는 라우트 목록을 OpenAPI 3.1 문서로 바꾼다. 라우트가 평범한 데이터이므로, 이 모듈은 리플렉션 없이 동작한다.

관측
  • spider-silk-opentelemetry-agent는 서버 스팬마다 응답한 라우트로 GET /api/decks/{deckId}처럼 이름을 붙이는 OpenTelemetry Java 에이전트의 확장이다. 이 모듈이 없으면 에이전트는 /*에 매핑된 서블릿 하나만 보고 모든 스팬을 GET /*로 이름 붙인다.

빌드 도구
  • Gradle 플러그인 net.benelog.spidersilk는 jte 템플릿 사전 컴파일, Jib 컨테이너 이미지, GraalVM 네이티브 컴파일의 컨벤션을 담는다.

  • spider-silk-maven-parent는 같은 패키징 컨벤션을 Maven의 부모 POM으로 제공한다.

AI 코딩 에이전트
  • 에이전트 스킬은 skills/spider-silk/에 Agent Skills 형식으로 들어 있으며, 에이전트가 프레임워크를 추측하지 않고 의도대로 쓰도록 안내한다. Agent Skills는 개방 표준이고 이 스킬은 특정 도구에만 있는 기능을 쓰지 않으므로, 같은 디렉터리를 Claude Code, Codex, Cursor, GitHub Copilot 등 여러 AI 에이전트가 그대로 읽는다. 리포지토리가 플러그인 마켓플레이스를 겸하므로, Claude Code에서는 두 명령으로 설치한다:

/plugin marketplace add benelog/spider-silk
/plugin install spider-silk@spider-silk

리포지토리는 Codex 플러그인 마켓플레이스도 겸하므로, Codex에서도 두 명령으로 설치한다:

codex plugin marketplace add benelog/spider-silk
codex plugin add spider-silk@spider-silk

성능

부록의 벤치마크에서 Jetty 위의 Spider Silk는 단순 텍스트를 Spring Boot보다 약 1.42배, 레코드 100개의 JSON 목록을 약 1.7배 빠르게 처리하며, JVM 프로세스는 3분의 1의 시간에 부팅한다. 이 벤치마크는 두 프레임워크를 한 노트북에서 같은 JVM과 같은 부하로 실행했으며, 비교 대상은 Spring Web MVC, Jackson, Thymeleaf를 기본 설정으로 쓰는 Spring Boot 4.1이다.

측정 항목 Spider Silk (Jetty) Spring Boot

단순 텍스트, 초당 요청 수

256,545

180,984

작은 JSON 객체, 초당 요청 수

256,318

180,642

레코드 100개의 JSON 목록, 초당 요청 수

163,817

95,200

요청 본문으로 받은 레코드 100개의 JSON 목록, 초당 요청 수

114,480

55,860

HTML 페이지, 초당 요청 수 (jte 대 Thymeleaf)

197,418

63,927

JVM에서의 부팅 시간

295 ms

902 ms

GraalVM 네이티브 이미지의 부팅 시간

24 ms

37 ms

직렬화기가 대부분의 일을 하는 JSON 목록에서는 Spider Silk가 Jackson을 쓰는 Spring Boot보다 약 1.7배 많은 요청을 처리하고, 같은 목록을 요청 본문에서 읽을 때는 약 2배를 처리한다. 양쪽 모두 Thymeleaf를 쓰고 같은 컨테이너에서 실행하면 두 프레임워크의 페이지 렌더링 성능은 2% 이내로 차이가 나므로, HTML 페이지에서의 격차는 jte의 몫이다. GraalVM 네이티브 이미지로는 Spider Silk가 Spring Boot보다 JVM 처리량을 더 많이 유지하며, 이는 GraalVM CE 이미지와 프로파일 기반 최적화(PGO)로 빌드한 Oracle GraalVM 이미지 모두에서 그렇다. 아래 각 칸은 네이티브 이미지의 처리량을 같은 애플리케이션의 JVM 처리량에 대한 비율로 나타낸 값이다.

케이스 Spider Silk, GraalVM CE Spring Boot, GraalVM CE Spider Silk, PGO Spring Boot, PGO

단순 텍스트

75%

65%

93%

84%

작은 JSON 객체

80%

69%

94%

86%

레코드 100개의 JSON 목록

60%

14%

92%

27%

요청 본문으로 받은 레코드 100개의 JSON 목록

73%

14%

90%

27%

HTML 페이지 (jte 대 Thymeleaf)

71%

57%

92%

89%

양쪽 모두 Thymeleaf를 쓰면 Spider Silk의 이미지가 덜 유지한다: GraalVM CE에서 40%, PGO에서 79%로, Spring Boot의 57%와 89%보다 낮다. 측정 방법, 머신, 네이티브 이미지, 이 수치의 한계는 부록에서 다룬다.