카테고리 없음

JVM/Spring 서버에서 JSON 직렬화를 profile-guided fast path로 최적화해보기

ohksj77 2026. 5. 17. 13:26

Spring 서버에서 JSON 처리 속도를 높일 수 있을까?

Spring으로 API 서버를 만들다 보면 대부분의 요청과 응답은 JSON이다.

보통 서버 성능을 이야기하면 DB 쿼리, Redis, 외부 API 호출, 네트워크를 먼저 본다. 맞다. 실제로 많은 서비스에서 병목은 JSON이 아니라 DB 쪽이다.

 

그런데 항상 그런 것은 아니다.

예를 들어 이런 서버를 생각해보자.

  • DB는 이미 빠르다.
  • 캐시 hit 비율이 높다.
  • BFF 서버처럼 여러 응답을 조합해서 JSON으로 내려준다.
  • 목록 API 응답이 크다.
  • gateway처럼 요청/응답 JSON을 많이 만진다.
  • QPS가 높다.

이런 경우에는 JSON을 읽고 쓰는 비용도 꽤 커질 수 있다.

이 글은 “Spring 서버에서 JSON 처리를 더 빠르게 할 수 있을까?”라는 질문에서 시작한 실험 기록이다.

보통 Spring은 어떻게 JSON을 처리할까?

Spring Boot에서 보통 REST API를 만들면 이런 코드를 쓴다.

@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody OrderRequest request) {
    return orderService.create(request);
}

개발자는 @RequestBody와 return DTO만 신경 쓰면 된다.

하지만 내부에서는 대략 이런 일이 일어난다.

요청 JSON bytes
-> Jackson이 읽음
-> OrderRequest 객체 생성

OrderResponse 객체
-> Jackson이 JSON으로 씀
-> HTTP 응답으로 전송

 

Spring Boot는 기본적으로 Jackson을 잘 통합해준다. Jackson은 매우 강력하고 안정적인 JSON 라이브러리다. 대부분의 서비스에서는 이 기본값으로 충분하다.

 

하지만 Jackson은 범용 라이브러리다.

즉, 어떤 JSON이 들어올지 모르는 상황을 항상 처리할 준비를 한다.

  • field 순서가 달라도 처리해야 한다.
  • 모르는 field가 있어도 처리해야 한다.
  • 다양한 타입을 처리해야 한다.
  • reflection, annotation, 설정 등을 고려해야 한다.
  • 복잡한 객체 구조도 처리해야 한다.

이 범용성이 Jackson의 장점이다. 하지만 hot path에서는 비용이 될 수도 있다.

 

실제 API JSON은 생각보다 단순하다

실서비스의 API payload를 보면, 많은 경우 JSON 구조가 꽤 안정적이다.

예를 들어 /checkout 요청이 항상 이런 모양이라고 해보자.

{
  "userId": 492001,
  "items": [
    {
      "sku": "SKU-COFFEE-1KG",
      "quantity": 2,
      "unitPriceCents": 18900
    }
  ],
  "shippingAddress": {
    "country": "KR",
    "city": "Seoul",
    "line1": "Teheran-ro 427",
    "line2": "15F",
    "postalCode": "06159"
  },
  "couponCode": "SPRING-ORDER-10",
  "gift": false,
  "clientTraceId": "ios-..."
}

이런 API는 대부분 비슷한 field를 비슷한 순서로 받는다.

그렇다면 매번 범용 JSON 처리 비용을 모두 지불할 필요가 있을까?

이 실험의 아이디어는 여기서 시작한다.

자주 들어오는 JSON 모양을 관찰한다.
그 모양이 충분히 안정적이면 빠른 전용 처리 코드를 쓴다.
예상과 다른 JSON이면 기존 Jackson으로 fallback한다.

 

프로젝트 이름: json-fastlane

이 실험 프로젝트의 이름은 json-fastlane이다.

처음부터 Jackson을 대체하려는 목적은 아니다.

목표는 이쪽에 가깝다.

일반적인 경우: 빠른 전용 경로 사용
예외적인 경우: Jackson으로 안전하게 fallback

즉, Jackson을 버리는 게 아니라 Jackson 옆에 “빠른 길”을 하나 더 만드는 것이다.

첫 번째: JSON 모양을 관찰하기

먼저 API별 JSON 모양을 기록하는 작은 profiler를 만들었다.

예를 들면 이런 정보를 기록한다.

  • endpoint
  • JSON 크기
  • 최상위 field 목록
  • field 순서
  • field 타입
  • sample 수

 

예를 들어 /checkout 요청을 많이 받으면 이런 식으로 볼 수 있다.

/checkout samples=42000 avgBytes=479

commonOrder:
userId,items,shippingAddress,couponCode,gift,clientTraceId

field=userId kinds={NUMBER=42000}
field=items kinds={ARRAY=42000}
field=shippingAddress kinds={OBJECT=42000}
field=couponCode kinds={STRING=42000}
field=gift kinds={BOOLEAN=42000}
field=clientTraceId kinds={STRING=42000}

여기서 중요한 질문은 하나다.

이 API의 JSON 모양이 충분히 안정적인가?


충분히 안정적이라면 전용 빠른 코드를 만들 수 있다.

 

두 번째: 응답 JSON을 더 빠르게 쓰기

보통 응답은 이렇게 처리된다.

OrderResponse 객체
-> Jackson
-> JSON byte[]
-> HTTP 응답

우리는 실험용으로 이런 전용 writer를 만들었다.

OrderResponse 객체
-> 직접 UTF-8 JSON으로 쓰기
-> HTTP 응답

예를 들어 field 이름은 매번 문자열로 처리하지 않고 미리 byte로 들고 있는다.

private static final byte[] ORDER_ID = "{\"orderId\":".getBytes(US_ASCII);
private static final byte[] STATUS = ",\"status\":".getBytes(US_ASCII);

 

그리고 응답을 쓸 때는 이런 식으로 바로 쓴다.

out.writeRaw(ORDER_ID).writeLong(value.orderId());
out.writeRaw(STATUS).writeString(value.status());

Jackson처럼 annotation, reflection, 설정을 매번 확인하지 않는다.

이미 이 DTO의 구조를 알고 있다고 가정하고 바로 JSON을 쓰는 것이다.

 

세 번째: 매번 byte[]를 만들지 않기

처음에는 writer가 byte[]를 반환했다.

byte[] json = writer.write(response);

그런데 서버에서는 꼭 byte[]를 새로 만들 필요가 없다.

더 좋은 방향은 reusable buffer를 쓰는 것이다.

DTO
-> 재사용 가능한 buffer에 JSON 쓰기
-> response OutputStream으로 전송

그래서 이런 인터페이스를 만들었다.

public interface FastJsonBufferWriter<T> {
    void write(T value, Utf8JsonBuffer out);
}

이렇게 하면 매 요청마다 큰 byte array를 새로 만드는 비용을 줄일 수 있다.

 

네 번째: Spring에 붙이기

Spring MVC에서는 JSON 변환을 HttpMessageConverter가 담당한다.

그래서 generated writer를 Spring converter에 연결했다.

구조는 이렇다.

Spring Controller return DTO
-> FastJsonHttpMessageConverter
-> 등록된 writer가 있는지 확인
-> 있으면 빠른 writer 사용
-> 없으면 기존 Jackson 사용

예를 들면 이런 식으로 writer를 등록한다.

FastJsonWriterRegistry registry = new FastJsonWriterRegistry();

registry.register(
    OrderSummaryResponse.class,
    new OrderSummaryResponseWriter()
);

핵심은 fallback이다.

등록된 writer가 있는 타입만 빠른 경로를 타고, 나머지는 기존 Jackson을 그대로 쓴다.

이러면 위험하게 전체 JSON 처리를 한 번에 바꾸지 않아도 된다.

 

다섯 번째: 요청 JSON도 빠르게 읽기

응답뿐 아니라 요청도 실험했다.

보통 요청 JSON은 Jackson이 읽어서 DTO를 만든다.

JSON bytes -> Jackson -> Request DTO

우리는 자주 들어오는 모양에 대해서는 전용 reader를 쓸 수 있다.

JSON bytes -> 전용 reader -> Request DTO

전용 reader는 field 이름을 직접 비교한다.

"{\"userId\":" 맞는지 확인
숫자 읽기
",\"items\":[" 맞는지 확인
배열 읽기
...

이건 범용 JSON parser라기보다, 특정 API 요청에 맞춘 작은 reader다.

 

그런데 JSON 순서가 바뀌면?

여기서 중요한 문제가 있다.

실제로 JSON field 순서는 바뀔 수 있다.

예를 들어 원래는 이런 순서였는데:

{
  "userId": 1,
  "items": [],
  "shippingAddress": {}
}

어떤 클라이언트가 이렇게 보낼 수도 있다.

{
  "shippingAddress": {},
  "items": [],
  "userId": 1
}

이 JSON은 잘못된 JSON이 아니다. 단지 우리가 예상한 빠른 경로와 순서가 다를 뿐이다.

 

처음에는 이런 경우 fast reader가 실패하고 exception을 던진 뒤 Jackson으로 fallback했다.

fast reader 시도
실패
exception 발생
Jackson fallback

하지만 이건 좋지 않다. 정상적인 JSON인데 exception을 쓰는 것은 비싸다.

 

그래서 구조를 바꿨다.

public interface TryFastJsonReader<T> {
    T tryRead(byte[] json);
}

이 reader는 처리할 수 있으면 DTO를 반환하고, 처리할 수 없으면 null을 반환한다.

tryRead(json) -> 성공하면 DTO
tryRead(json) -> 처리할 수 없으면 null
null이면 Jackson fallback

이렇게 하면 field 순서가 다른 JSON도 exception 없이 자연스럽게 fallback할 수 있다.

 

결과 차이는 꽤 컸다.

exception fallback 방식        904,318 ops/s
tryRead fallback 방식        2,039,685 ops/s

둘 다 fallback은 100% 발생했지만, exception을 없앤 것만으로 2배 이상 빨라졌다.

 

성능은 얼마나 나왔나?

간단한 부하 시뮬레이션을 만들었다.

비교 대상은 다음과 같다.

  • Spring 기본 Jackson converter
  • 직접 만든 generated reader
  • 직접 만든 generated writer
  • Spring에 붙인 fast converter
  • reusable buffer writer

긴 실행에서 나온 결과는 대략 이렇다.

요청 읽기

Spring 기본 read       1,382,506 ops/s
Fast generated read    2,114,481 ops/s

1.53배 빨랐다.

 

응답 쓰기

Spring 기본 writer와 실제 Spring converter 경로를 비교하면:

Spring 기본 write              1,659,146 ops/s
Fast dedicated converter       2,022,223 ops/s

1.22배 빨랐다.

 

그런데 Spring converter 경로를 제외하고, writer 자체만 보면 차이는 더 크다.

Jackson writeValueAsBytes      1,609,938 ops/s
Fast generated writer          3,815,398 ops/s
Reusable buffer writer         4,097,266 ops/s

즉 writer 자체는 2배 이상 빠르지만, 실제 Spring 경로에 붙이면 Spring abstraction 비용도 같이 들어가서 이득이 줄어든다.

이게 중요하다.

라이브러리 내부 benchmark와 실제 서버 integration benchmark는 다르다.

둘 다 봐야 한다.

 

JMH도 추가했다

부하 시뮬레이션은 현실적인 비교에는 좋지만, JVM 최적화나 스케줄링에 따라 숫자가 흔들릴 수 있다.

그래서 JMH benchmark도 추가했다.

짧은 JMH 결과는 다음과 같았다.

Jackson byte[] writer         5,907,167 ops/s
Fast byte[] writer            9,549,247 ops/s
Fast reusable buffer writer  17,131,683 ops/s
Netty ByteBuf writer         15,717,725 ops/s

여기서도 reusable buffer 방향이 꽤 좋게 나왔다.

 

Netty ByteBuf도 실험했다

Spring MVC의 OutputStream보다 더 zero-copy에 가까운 방향은 Netty 쪽이다.

그래서 Netty ByteBuf에 직접 JSON을 쓰는 실험도 추가했다.

public interface FastJsonByteBufWriter<T> {
    void write(T value, ByteBuf out);
}

아직 WebFlux codec까지 만든 것은 아니지만, pooled buffer로 가기 위한 첫 구조는 만들었다.

 

프로젝트 구조

프로젝트는 이제 멀티모듈로 나누었다.

json-fastlane-core
  JSON profiler, reader/writer contract, buffer

json-fastlane-spring
  Spring MVC converter, Jackson fallback

json-fastlane-netty
  Netty ByteBuf writer

json-fastlane-benchmarks
  부하 테스트, JFR, JMH

이렇게 나눈 이유는 간단하다.

core는 Spring, Jackson, Netty를 몰라야 한다.

그래야 나중에 Spring 없이도 쓸 수 있고, Netty 없이도 쓸 수 있고, 필요한 모듈만 가져다 쓸 수 있다.

 

지금까지 배운 것

이번 실험에서 얻은 가장 큰 교훈은 이렇다.

1. Jackson은 느린 라이브러리가 아니다

Jackson은 이미 충분히 빠르고 안정적이다.

다만 모든 상황을 처리하는 범용 라이브러리이기 때문에, 특정 API의 hot path에서는 전용 코드가 더 빠를 수 있다.

2. 전체를 바꾸면 위험하다

JSON 라이브러리를 통째로 교체하는 것은 위험하다.

대신 특정 DTO, 특정 endpoint에만 fast path를 적용하고 fallback을 두는 방식이 현실적이다.

3. 응답 쓰기는 최적화 여지가 크다

특히 response JSON은 서버가 구조를 알고 있다.

그래서 generated writer가 잘 맞는다.

4. 요청 읽기는 fallback 설계가 중요하다

field 순서가 바뀌어도 정상 JSON이다.

이런 경우 exception으로 fallback하지 말고, tryRead -> null -> fallback 구조가 좋다.

5. byte[]보다 reusable buffer가 좋다

매번 새 byte[]를 만드는 것보다, 재사용 가능한 buffer나 Netty ByteBuf에 직접 쓰는 방향이 더 좋다.

6. Spring에 붙이면 이득이 줄어든다

순수 writer는 2배 이상 빨라도, Spring converter 경로에 붙이면 Spring 자체의 비용이 섞인다.

그래서 “라이브러리 benchmark”와 “Spring integration benchmark”를 둘 다 봐야 한다.

 

앞으로 할 일

다음으로 제일 중요한 것은 code generation이다.

지금은 reader/writer를 손으로 만들었다.

하지만 실제 라이브러리라면 이런 흐름이어야 한다.

API payload shape 관찰
DTO 구조 확인
reader/writer 코드 생성
Spring registry에 자동 등록
fallback rate 측정
성능 리포트 출력

그다음은 Spring Boot starter다.

implementation("io.jsonfastlane:json-fastlane-spring-boot-starter")

이렇게 붙이면 자동으로 profiler와 converter가 등록되는 형태가 되어야 한다.

그 뒤에는 WebFlux codec을 만들 수 있다.

Netty ByteBuf writer가 이미 있으니, 다음에는 WebFlux에서 pooled buffer에 직접 JSON을 쓰는 쪽으로 갈 수 있다.

 

마무리

이 실험은 “Jackson을 버리자”는 이야기가 아니다.

오히려 반대에 가깝다.

Jackson은 계속 안전한 fallback으로 두고, 자주 호출되는 안정적인 API에 대해서만 빠른 길을 추가하는 것이다.

일반 요청 -> Jackson
자주 나오는 안정적인 요청/응답 -> generated fast path
예상과 다른 JSON -> Jackson fallback

이 방식은 도입하기 쉽고, 실패해도 안전하다.

 

Spring 서버에서 JSON 성능이 실제로 문제가 되는 상황이라면, 전체 JSON 라이브러리를 갈아엎기보다 이런 식의 부분적이고 관측 기반인 fast path가 더 현실적인 접근일 수 있다.

 

https://github.com/not-a-platform-bug/json-fastlane

 

GitHub - not-a-platform-bug/json-fastlane: json-fastlane is a JVM experiment for profile-guided JSON serialization

json-fastlane is a JVM experiment for profile-guided JSON serialization - not-a-platform-bug/json-fastlane

github.com