부록: 성능 최적화 기법

이 부록은 경로 조회부터 JSON 본문의 바이트까지, Spider Silk가 요청의 비용을 낮게 유지하려고 쓰는 기법을 나열한다. 각 항목은 기법이 하는 일을 말하고, 측정을 거친 경우에는 그 근거가 된 측정을 함께 적는다. 수치는 부록: 벤치마크와 `notes/decisions.md`가 기록한 측정에서 왔으며, 벤치마크가 기술한 머신에만 해당한다.

원칙

아래의 모든 기법은 세 규칙을 따른다.

  • 실행되는 것은 코드에 적힌 그대로다. core와 silk-json은 리플렉션, 프록시, 클래스패스 스캔, `ServiceLoader`를 쓰지 않으므로, 요청은 인터페이스와 람다를 거치는 직접 호출로 실행된다. "리플렉션 없음"의 범위가 그 경계를 긋는다.

  • 일은 요청에서 시작 시점이나 빌드로 옮겨진다. 경로 표, 필터 목록, JSON 코덱, 템플릿은 각각 한 번 준비되고, 요청은 그것을 읽기만 한다.

  • 변경은 측정으로 이득이 확인될 때만 남는다. 각 후보는 직전 빌드와 연달아 측정했다. 이득이 없던 후보는 `notes/decisions.md`에 Rejected 줄로 기록되며, 측정 후 기각한 것가 그중 참고할 만한 것을 나열한다.

요청 경로

한 번 만드는 라우팅 스냅숏

등록은 AppServlet`이 초기화될 때 닫히고, 애플리케이션은 그때 불변 스냅숏 하나로 굳는다. 스냅숏은 라우터의 복사본, 필터 목록, 핸들러 맵을 담으며, 각각은 수정할 수 없는 컬렉션이다. 요청은 `volatile 필드 하나를 통해 스냅숏을 읽고 잠금을 잡지 않는다. 각 서버는 서블릿을 첫 요청이 아니라 시작 시점에 초기화한다: Jetty는 init order 0으로, Tomcat과 Undertow는 load-on-startup 0으로 한다. 따라서 스냅숏은 첫 요청이 도착하기 전에 준비된다.

메서드와 첫 세그먼트로 색인한 경로

라우터는 HTTP 메서드마다 표를 하나 두고, 각 표는 경로를 첫 리터럴 세그먼트별 버킷으로 나눈다. 첫 세그먼트가 변수인 경로는 모든 버킷에 들어가므로, 병합은 요청마다가 아니라 등록할 때 일어난다. 조회는 맵 조회 두 번과, 이미 등록 순서로 놓인 버킷 하나를 훑는 일이다. 따라서 전체를 훑을 때의 우선순위 규칙이 그대로 유지된다: 라우팅이 말하듯 `/study/{mode}`보다 먼저 등록한 `/study/today`가 여전히 이긴다.

버킷 안에서 패턴은 경로와 세그먼트 단위로 equals`로 비교되며, 정규 표현식은 쓰이지 않는다. 변수가 없는 패턴은 공유된 빈 맵으로 답하므로, 정적 경로로 오는 요청은 파라미터 맵을 할당하지 않는다. 경로는 요청마다 한 번 나뉘며, 라우터, 필터, CORS가 같은 배열을 읽는다. 405나 `OPTIONS 응답의 Allow 집합은 일치하는 경로가 없을 때만 계산되며, 일치했을 때는 계산되지 않는다.

핸들러까지 프레임 두 개

컨테이너의 `HttpServlet.service`와 핸들러 사이에는 프레임워크 프레임이 두 개 있다: `AppServlet.service`와 `AppServlet.dispatch`다. 필터는 중첩된 체인이 아니라 불변 목록을 도는 평평한 루프로 실행된다. 따라서 필터, CORS, gzip, 보안 헤더, 요청 로거를 등록해도 프레임이 늘지 않는다. `CallStackDepthTest`가 빌드에서 두 가지를 모두 확인하며, Tomcat과 Undertow 모듈은 각자의 컨테이너에서 같은 것을 확인한다.

벤치마크는 이것이 아끼는 양을 잰다. 같은 컨테이너인 Tomcat 위에서 Spider Silk는 Spring Boot보다 `text`에서 초당 31%, `json`에서 24% 많은 요청을 처리한다. 결과 해석이 기술하듯, Spring Web MVC는 요청마다 핸들러 매핑, 인자 리졸버, 반환값 핸들러, 메시지 컨버터를 거친다.

필요할 때 읽는 요청

요청은 핸들러가 묻는 것만 파싱한다.

  • 쿼리 문자열은 처음 읽을 때 파싱되고 보관된다. 퍼센트 디코더는 값에 `%`도 `+`도 없으면 같은 `String`을 돌려준다.

  • param`은 요청이 폼 콘텐츠 타입을 가진 `POST, PUT, `PATCH`가 아니면 쿼리 문자열만 읽는다. 따라서 그 밖의 요청에서는 컨테이너의 폼 파싱이 실행되지 않는다.

  • 본문은 한 번 읽혀 바이트로 보관되며, 그 텍스트는 한 번 디코드되어 바이트 옆에 보관된다. `bodyJson`은 도착한 바이트를 그대로 파싱하며, 본문의 문자열을 만들지 않는다.

  • 본문의 버퍼는 `Content-Length`에 맞춰, 1 MB까지 크기가 정해진다. 본문 크기 한도가 기술하듯, 한도보다 큰 본문은 한 바이트도 읽기 전에 `Content-Length`로 거부된다.

  • NDJSON 본문은 메모리에 최대 한 줄과 8 KB 청크 하나만 둔다.

  • 세션을 읽어도 세션이 생기지 않으며, 요청마다 하는 플래시 확인은 컨테이너에 이미 있는 세션만 묻는다.

  • 요청 로거가 받는 `RequestCompletion`은 로거가 등록되어 있을 때만 만들어진다.

응답

값으로 답하고, 한 번 쓴다

핸들러는 본문이 봉인된 종류 가운데 하나인 WebResponse 값으로 답하며, 서블릿은 모든 필터가 답한 뒤에 그것을 쓴다. 템플릿은 헤더가 나가기 전에 렌더링되고 JSON 문서도 그 전에 쓰인다. 따라서 서블릿은 메모리에 있는 모든 본문의 길이를 알고, `Content-Length`를 놓으며, 청크 인코딩을 보내지 않는다.

  • Bytes 본문은 방어적 복사 없이 호출자의 배열을 그대로 쥔다.

  • 메모리에 있는 본문에 대한 HEAD 요청은 길이만 놓고 아무것도 쓰지 않는다. 길이를 아는 스트림 본문에서는 writer가 실행되지 않으므로, 파일이 열리지 않는다.

  • 템플릿은 StringBuilder 위의 `Writer`로 렌더링되며, 이로써 `StringWriter`의 동기화된 append를 피한다.

  • 헤더 맵은 쓸 때 복사하며, 소문자로 바꾼 이름을 키로 쓴다. CORS와 보안 헤더는 자기 필드를 복사 한 번으로 더하며, 이미 있는 필드에는 복사가 들지 않는다.

보내는 자리에서 쓰는 JSON 본문

WebResponse.json 본문은 문서가 아니라 값과 그 writer를 쥔다. 서블릿은 응답이 나갈 때 문서를 스레드의 버퍼에 쓰고, 그 길이로 `Content-Length`를 놓고, 그 버퍼에서 바로 바이트를 보낸다. 문서 크기의 배열은 복사되어 나오지 않는다.

  • 플랫폼 스레드는 64 KB까지의 버퍼를 ThreadLocal 안의 평범한 `byte[]`로 간직한다. 다음 문서는 그 버퍼를 키우지도 0으로 채우지도 않는다.

  • 가상 스레드는 작업 하나를 위해 살므로 버퍼를 간직하지 않는다.

  • 서블릿은 GET, HEAD, gzip, 실패한 쓰기 뒤에 똑같이 finally 블록에서 버퍼를 반납한다.

  • gzip은 같은 버퍼에서 압축하므로, 압축되는 문서도 한 번만 쓰인다.

버퍼는 서블릿 밖으로 나가지 않는다. `WebResponse`는 상수나 필터가 쥘 수 있는 공개 불변 값이며, 버퍼를 담은 본문은 버퍼가 스레드로 돌아간 뒤 다른 요청의 문서를 읽게 된다.

이로써 없앤 복사는 쓰기 프로파일의 16%였다. 벤치마크의 json-list 케이스에서 요청당 할당은 Jetty에서 14.4 KB에서 3.8 KB로, Tomcat에서 13.2 KB에서 2.6 KB로 줄었다.

제값을 하는 압축

CORS, 압축, 보안이 기술하듯, gzip은 `App#gzip`으로 지정할 때까지 꺼져 있다. 켜지면 이득이 있는 것만 압축한다.

  • 텍스트 본문은 인코딩되지 않은 채로 문턱값, 기본 1024바이트와 비교된다. 문자는 UTF-8에서 1~3바이트를 차지하므로 대부분의 본문은 문자열의 길이로 판정되고, 바이트는 상하한이 문턱값을 사이에 둘 때만 센다.

  • 압축한 형태가 더 작지 않은 본문은 원래대로 나간다.

  • 스트림 본문은 쓰이는 대로 sync flush와 함께 압축된다. `FilterOutputStream`은 그대로 두면 한 바이트씩 쓰므로, 배열 쓰기는 배열 그대로 넘긴다.

  • 압축되는 스트림에 대한 HEAD 요청은 파일을 읽지 않는다.

정적 파일

정적 파일이 기술하듯, 정적 파일은 바이트가 필요해질 때까지 메타데이터로 답한다.

  • `ETag`와 `Last-Modified`는 파일의 속성에서 오므로, `304`는 파일을 열지 않고 판정된다.

  • 파일은 본문이 쓰일 때만 열린다. HEAD, 304, 필터가 바꾼 응답은 아무것도 열지 않는다.

  • 본문은 통째로 읽히지 않고 `transferTo`로 스트림 복사된다.

  • 빌드가 수정 시각을 찍은 파일은 내용의 CRC-32 태그를 받으며, 이 태그는 경로, 시각, 길이마다 한 번 계산되어 보관된다. jar 항목의 CRC는 jar의 중앙 디렉터리에서 오므로, 그것을 위해 항목을 읽지 않는다.

  • precompressed()`는 `.br`이나 `.gz 형제 파일을 보내므로, 자산이 요청마다 압축되지 않는다. 이 기능은 대개 없는 형제 파일을 찾느라 모든 정적 요청에 최대 두 번의 조회를 들이므로 기본으로 꺼져 있다.

  • Cache-Control 값은 정적 파일을 설정할 때 만들어지며, 콘텐츠 타입 표는 정적 불변 맵이다.

JSON 엔진

silk-json은 `sun.misc.Unsafe`도 리플렉션도 없이 fastjson2의 속도로 JSON을 쓰고 읽는다. 직렬화기와 파서만 따로는 생성된 코덱이 레코드 100개의 목록을 Jackson 시간의 40%에 쓰고 24%에 읽는다고 잰다. 아래 기법이 그 수치를 만든다.

바이트로 들어오고, 바이트로 나간다

모든 문서는 바이트 단위의 writer JsonOutput 하나와 바이트 단위의 풀 파서 JsonInput 하나를 지난다. 매핑은 그 위의 람다이거나 빌드가 생성한 코덱이며, 값과 바이트 사이에 트리가 끼지 않는다.

첫 엔진은 StringBuilder`를 한 문자씩 채워 `String`을 만들고, 서블릿이 그것을 다시 인코딩하게 했다. 그 방식으로는 10 KB 본문이 114 KB를 할당했다. `JsonObject`와 `JsonArray 값의 트리를 거치면 레코드 100개의 목록이 쓰기에 12.6 µs와 29.8 KB, 읽기에 25.5 µs와 82 KB가 들었다. 출력에 바로 쓰고 입력에서 바로 읽으면 쓰기에 5.2 µs와 11 KB, 읽기에 6.0 µs와 18 KB가 든다.

컴파일 시점에 생성하는 코덱

생성되는 JSON 코덱이 기술하듯, silk-json-processor`는 javac 안에서 실행되어 `@JsonBound 타입마다 `JsonCodec`을 생성한다. 생성된 코드는 타입의 접근자와 생성자를 호출하므로, 실행 시점의 조회가 없는 직선 코드다.

  • `JsonKey.of(name)`는 멤버의 이름을 쓰인 그대로, 앞의 쉼표가 있는 형태와 없는 형태로 쥔다. 코덱은 키를 8바이트 저장 두 번으로 쓰며, 레코드마다 이스케이프하지 않는다.

  • `put(JsonKey, value)`는 writer의 상태를 한 번 확인하고 키와 값을 한 호출로 쓴다.

  • 생성된 읽기는 다음에 선언된 키를 `nextKeyIs`로 시도하며, 이 메서드는 쉼표, 따옴표, 이름, 콜론을 제자리에서 비교한다.

  • 문서가 선언 순서를 벗어나면, 각 키는 다음 멤버가 놓인 자리의 4바이트인 `JsonInput.keyHead()`에 대한 `switch`로 간다. 선언 순서의 읽기와 함께, 이것은 키가 역순인 목록의 읽기를 9.5 µs에서 7.2 µs로 줄였다.

  • `nextKeyIs`는 키 앞의 공백을 건너뛰며, 쉼표 하나와 공백 하나를 먼저 확인한다. 이것은 Python의 `json.dumps`가 띄어 쓴 목록의 읽기를 10.0 µs에서 6.6 µs로, 들여쓴 목록의 읽기를 11.6 µs에서 8.6 µs로 줄였다.

  • enum의 상수 이름은 한 번 JsonKey 값으로 인코딩되어, 나갈 때는 키처럼 복사되고 들어올 때는 제자리에서 비교된다. 이것은 주문 100건을 쓰기에서 2.9 µs에서 2.4 µs로, 읽기에서 4.6 µs에서 3.0 µs로 줄였다.

avaje-jsonb처럼 jackson-core 위에 생성하는 방식은 측정 후 기각했다: 그 생성기는 같은 목록을 13.7 µs에 썼다.

8바이트씩

`LittleEndian`은 `MethodHandles.byteArrayViewVarHandle`을 통해 8바이트씩 읽고 쓴다. 컴파일러는 각 호출을 배열 접근과 같은 인덱스 검사가 붙은 로드 하나나 저장 하나로 만든다. 이 방식은 클래스도 멤버도 조회하지 않고 메모리 오프셋도 쥐지 않으므로, 이것으로 빌드한 네이티브 이미지는 설정이 필요 없다.

  • 문자열은 따옴표, 역슬래시, 제어 문자, ASCII 밖의 바이트를 찾아 한 워드씩 훑어진다.

  • 일곱 자리까지의 정수는 한 워드로 자릿수를 세고 곱셈 세 번으로 변환된다.

  • 8~16자리 정수는 두 워드씩 읽히며, 이것은 18자리 id 100개의 읽기를 6.5 µs에서 5.9 µs로 줄였다.

이 방식으로 목록 읽기는 13.0 µs에서 6.0 µs로, 쓰기는 7.0 µs에서 5.2 µs로 줄었다.

문자열 쓰기

문자열은 `getChars`로 한 번 복사되어 그 배열에서 검사되며, `String.charAt`으로는 읽히지 않는다. `charAt`의 프로파일은 프로세스 전체에 하나여서, Latin-1 밖의 문자열이 한 번 지나가면 그것을 부르는 모든 루프가 더 느리게 컴파일된다.

  • 따옴표나 역슬래시는 평범한 ASCII를 쓰는 루프 안에서 이스케이프되며, 그 루프는 각 문자를 표에서 찾지 않고 비교한다. 이것은 목록 쓰기를 5.3 µs에서 4.7 µs로 줄였다.

  • 1000 미만의 수는 표에서 꺼낸 저장 한 번으로, 한글 음절 같은 3바이트 문자는 4바이트 저장 한 번으로 나간다.

  • 64자 이상의 문자열은 다른 경로를 탄다. JDK의 US_ASCII 인코더가 JIT 컴파일러의 인트린식으로 그것을 버퍼에 좁혀 넣고, 또 다른 인트린식인 `String.indexOf`가 따옴표와 역슬래시를 찾는다. 좁힌 바이트는 제어 문자만 8바이트씩 검사된다. 이스케이프는 그 뒤의 좁힌 바이트를 다시 좁히지 않고 뒤로 민다. 이것들은 200자 설명이 붙은 레코드 100개의 쓰기를 15.0 µs에서 6.9 µs로 줄였다.

이스케이프가 필요 없는 짧은 문자열의 레코드는 여전히 fastjson2가 1.17배 빠르게 쓴다. fastjson2는 JDK가 퇴역시키는 중인 sun.misc.Unsafe`로 `String 자신의 Latin-1 바이트를 읽는다. 검사 없이 문자열의 바이트를 복사하기만 하면 평범한 목록을 fastjson2의 3.7 µs보다 빠른 2.7 µs에 쓰므로, 격차는 전부 문자열의 표현에서 온다.

문자열 읽기

  • ASCII 밖의 바이트가 있는 문자열은 한 번에 검사되고 문자로 디코드되며, 그 앞의 ASCII는 검사 없이 넓혀진다. 이것은 한글 목록의 읽기를 11.0 µs에서 5.6 µs로 줄였으며, fastjson2는 7.9 µs다.

  • 빈 문자열이나 한 글자 문자열은 할당 없이 표에서 나온다. 이것은 그런 문자열을 가진 레코드의 읽기를 3.4 µs와 13.9 KB에서 2.8 µs와 5.5 KB로 줄였다.

  • 한 문서의 레코드들에 되풀이되는 키는 들어올 때 문자열 하나다.

소수부가 있는 수

소수부가 있는 수는 문자열을 만들지 않고 자릿수에서, 그리고 자릿수로 변환된다.

  • `readDouble`은 유효 숫자가 253 이하이고 10의 지수가 -22에서 22 사이이면 곱셈이나 나눗셈 한 번을 쓴다(Clinger의 빠른 경로). 19자리까지의 나머지는 Eisel-Lemire의 128비트 곱을 쓰며, 그보다 긴 수나 반올림이 정해지지 않는 경우만 `Double.parseDouble`로 간다. 이것은 그런 수를 셋씩 가진 장소 100개의 읽기를 12.6 µs와 43.9 KB에서 5.3 µs와 11.1 KB로 줄였다.

  • `readFloat`는 자릿수에서 한 번만 반올림하는데, `(float) readDouble()`은 두 번 반올림해 다른 float에 떨어질 수 있기 때문이다.

  • double은 JDK 19부터 `Double.toString`이 쓰는 알고리즘인 Schubfach로 나가며, 그 자릿수는 한 워드에 여덟 개씩 버퍼에 놓인다. 이것은 장소 쓰기를 16.5 µs와 35.6 KB에서 9.3 µs와 8.5 KB로 줄였다.

템플릿

기본 엔진인 jte는 템플릿을 Java로 컴파일하므로, `${deck.title}`은 생성된 클래스 안의 메서드 호출이 된다. 개발과 프로덕션이 기술하듯, 프로덕션에서는 빌드가 템플릿을 미리 컴파일한다. 벤치마크의 페이지는 템플릿을 해석하고 표현식 언어로 모델을 읽는 Thymeleaf보다 jte에서 약 2.8배 빠르게 렌더링된다.

  • 기본 JteTemplates`는 첫 렌더링 때 만들어지므로, 템플릿을 렌더링하지 않는 애플리케이션은 jte를 준비하지 않는다. 그 필드는 모든 요청이 잡아야 하는 잠금 대신, `volatile 읽기를 사이에 둔 두 번의 확인으로 검사된다.

  • `Model`은 이미 쥔 수정 불가 맵 그대로, 복사 없이 엔진에 닿는다.

  • FreeMarker, Handlebars, Thymeleaf 모듈은 파싱한 템플릿을 각 엔진의 캐시에 둔다.

시작과 네이티브 이미지

경로는 람다이고 코덱은 생성된 코드이므로, 시작할 때 스캔할 것이 없다.

  • core는 `jetty-ee10-servlet`에만 의존하며, 애너테이션을 스캔하고 `web.xml`을 읽는 Jetty 모듈에는 의존하지 않는다.

  • Tomcat 모듈은 `tomcat-embed-core`만으로 컨텍스트를 더하고, Undertow 모듈은 배포에 빈 리소스 매니저를 준다.

  • JVM 프로세스는 Jetty 위의 Spider Silk가 295 ms, Spring Boot가 902 ms에 부팅하며, 그 시점에 117 MB 대 210 MB를 쥔다.

core와 silk-json은 클래스나 멤버를 이름으로 조회하지 않으므로, 네이티브 이미지에서 리플렉션 설정이 필요 없다. GraalVM CE 이미지는 24 ms에 부팅한다. 네이티브 이미지와 JVM이 보고하듯, Oracle GraalVM의 프로파일 기반 최적화로 빌드한 Spider Silk의 이미지는 JVM 처리량의 79%~94%를 유지한다. GraalVM 네이티브 이미지가 빌드를 기술한다.

스레드

가상 스레드가 기술하듯, 가상 스레드는 감싸지 않고 문서로 안내하는 서버 설정이다. 가상 스레드는 핸들러가 블로킹할 때만 이득이 있다. 가상 스레드에서 JSON writer는 문서 사이에 버퍼를 간직하지 않으므로, 보내는 자리에서 쓰는 JSON 본문는 복사는 아끼지만 할당은 아끼지 못한다. SSE 스트림은 스레드 하나를 쥐며, 이벤트의 줄을 나누는 패턴은 호출마다가 아니라 한 번 컴파일된다.

측정 후 기각한 것

아래 후보는 측정했고 이득이 없었다. 나머지는 `notes/decisions.md`가 기록한다.

  • 생성된 코덱의 바이트를 검사 없이 쓰는 출력은 목록을 5% 빠르게 썼지만, 코덱이 JSON이 아닌 문서를 쓸 수 있게 만든다.

  • 이스케이프가 필요한 것만 표시하며 문자열 전체를 한 번 훑는 방식은 목록 쓰기를 5.3 µs에서 7.4~9.2 µs로 늘렸다. JDK 25의 JIT 컴파일러는 `char`를 `byte`로 좁히는 일도, 그 표시도 벡터화하지 않는다.

  • 넓히지 않고 문자열의 Latin-1 바이트를 복사하는 유일한 공개 호출인 `MemorySegment.setString`은 목록을 4.91 µs 대신 5.43 µs에 썼다. 호출 자체의 비용이 아끼는 복사보다 크다.

  • 키 머리의 해시에 대한 `switch`는 역순 레코드를 같은 7.35 µs에 읽었고, 들여쓴 것과 `json.dumps`의 것은 3%~4% 느리게 읽었다.

  • 들여쓰기의 공백을 8바이트씩 건너뛰는 방식은 들여쓴 레코드를 7%, `json.dumps`의 것을 17% 느리게 읽었는데, 문서의 공백은 줄바꿈과 공백 몇 개이기 때문이다.

  • double의 자릿수를 두 자리 표에서 쓰는 방식은 장소를 9.3 µs 대신 10.0 µs에 썼다.

  • 수의 자릿수를 8개씩 세는 방식은 장소를 5.3 µs 대신 5.7~6.1 µs에 읽었는데, 두~여섯 자리는 워드가 본전을 찾기 전에 끝나기 때문이다.

  • JSON 본문을 서블릿의 스트림에 바로 쓰면 `Content-Length`를 놓을 길이가 없고, 중간에 실패하면 문서의 절반이 이미 나간 채로 남는다.

프레임워크가 하지 않는 것

흔한 기법 가운데 몇은 빠져 있으며, 그것이 필요한 애플리케이션은 앞에 프록시나 CDN을 둔다.

  • 정적 파일은 Range 요청에 `206`으로 답하지 않는다.

  • 파일은 `transferTo`로 복사되며, `sendfile`이나 다른 제로 카피 경로는 없다.

  • 압축하는 응답마다 자기 `GZIPOutputStream`을 만들며, 응답 사이에 `Deflater`를 간직하지 않는다.

  • Date 헤더는 컨테이너의 몫이며, 정적 파일의 `ETag`와 `Last-Modified`는 응답마다 만들어진다.