인생을 코딩하다.

Jackson 2 vs Jackson 3 성능 비교 실험기 본문

Java

Jackson 2 vs Jackson 3 성능 비교 실험기

Hyung1 2025. 12. 21. 01:46
728x90
반응형

목차

1. 실험을 하게 된 배경

2. 왜 JMH를 사용했는가

3. 테스트 환경

4. 결과 및 요약 해석

5. 정리

6. 왜 Jackson3이 더 빠를까?

1. 실험을 하게 된 배경

최근 차세대 마이그레이션을 진행하는 업무를 맡아 수행하게 되었습니다. 이번 마이그레이션에서는 지금까지의 JDK 버전 흐름과 지원 현황을 종합적으로 검토한 결과, 여러 기술적·운영상의 이유로 JDK 25를 도입하는 것이 가장 적합하다고 판단하여 이를 채택하였습니다.

 

JDK 25의 주요 변화 중 하나는 Jackson 메이저 버전이 Jackson 2에서 Jackson 3으로 전환되었다는 점입니다. Jackson 3로의 전환 배경과 상세한 변경 사항에 대해서는 아래의 공식 문서와 커뮤니티 자료에 잘 정리되어 있어, 본 글에서는 해당 내용을 깊게 다루지는 않았습니다. 관련 내용은 아래 문서들을 참고하시면 됩니다.

 

본 글에서는 Jackson 2와 -> Jackson 3로 전환하면서 비교했던 성능 비교 실험 결과를 중심으로 내용을 구성하였습니다. 잘못된 내용이 있으면 댓글 남겨주세요.


2. 왜 JMH를 사용했는가

일반적인 System.currentTimeMillis()나 단순 반복 루프 기반 측정은 JVM 환경에서는 신뢰하기 어렵습니다. 이유는 다음과 같습니다.

  • JVM은 실행 중에 JIT 컴파일을 수행합니다.
  • 코드가 실행될수록 최적화 수준이 달라집니다.
  • GC, OS 스케줄링, 캐시 상태에 따라 결과가 크게 흔들릴 수 있습니다.

이런 문제를 피하기 위해 OpenJDK에서 공식적으로 제공하는 JMH(Java Microbenchmark Harness) 를 사용했습니다.

JMH는 다음을 자동으로 처리해줍니다.

  • 워밍업과 실제 측정 구간 분리
  • JVM Fork(새 JVM 실행)
  • 통계적 평균, 표준편차, 신뢰 구간 계산
  • 벤치마크 코드의 잘못된 최적화 방지

3.  테스트 환경

3-1. 실행 환경

  • OS: macOS (Apple Silicon)
  • 실행 위치: 로컬 머신
  • 원격 서버, 컨테이너, CI 사용 없음

로컬 환경을 선택한 이유는 다음과 같습니다.

  • 두 Jackson 버전을 완전히 동일한 머신 조건에서 비교하기 위함
  • 외부 네트워크, 서버 부하 등 변수를 제거하기 위함
  • 테스트 편의성(단순히 직렬화, 역직렬화시의 성능을 벤치마킹하기 위함)

3-2. JVM 선택

  • Jackson 3 테스트: JDK 25
  • Jackson 2 테스트: JDK 17

Jackson 3는 최신 JDK 환경에서의 사용을 염두에 두고 설계된 반면, Jackson 2는 여전히 JDK 17 환경에서 가장 많이 사용되고 있기 때문에 각각 현실적인 사용 환경을 기준으로 테스트했습니다. 중요한 점은, 각 비교는 같은 JVM 내에서 Jackson 버전만 바꿔서 수행되었다는 점입니다. (Jackson 2 vs 3 비교 시 JVM 자체는 동일하게 유지)

3-3. 소스코드

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput, Mode.AverageTime) // ops/s + 평균 시간 둘 다 측정
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = ["-Xms2g", "-Xmx2g"]) // 힙 크기 고정으로 GC 영향 최소화
open class JacksonBench {

    @Param("1000", "10000")
    var bigSize: Int = 0
	
    // A 프로젝트에서는 JsonMapper, B 프로젝트에서는 ObjectMapper로 로직 변경하며 테스트
    // tools.jackson.databind.json.JsonMapper
    private lateinit var mapper: JsonMapper

    // import com.fasterxml.jackson.databind.ObjectMapper
    // private lateinit var mapper: ObjectMapper

    private lateinit var small: SmallDto
    private lateinit var big: BigDto

    private lateinit var smallBytes: ByteArray
    private lateinit var bigBytes: ByteArray

    @Setup(Level.Trial)
    fun setup() {
        mapper = GLOBAL_OBJECT_MAPPER

        small = SmallDto.sample()
        big = BigDto.sample(size = bigSize)

        // deserialize 벤치에서 매번 serialize하지 않도록 미리 만들어둠
        smallBytes = mapper.writeValueAsBytes(small)
        bigBytes = mapper.writeValueAsBytes(big)
    }

    // ---------- Small: Serialize / Deserialize ----------

    @Benchmark
    fun serializeSmall(bh: Blackhole) {
        val bytes = mapper.writeValueAsBytes(small)
        bh.consume(bytes)
    }

    @Benchmark
    fun deserializeSmall(bh: Blackhole) {
        val obj = mapper.readValue(smallBytes, SmallDto::class.java)
        bh.consume(obj)
    }

    @Benchmark
    fun serializeBig(bh: Blackhole) {
        val bytes = mapper.writeValueAsBytes(big)
        bh.consume(bytes)
    }

    @Benchmark
    fun deserializeBig(bh: Blackhole) {
        val obj = mapper.readValue(bigBytes, BigDto::class.java)
        bh.consume(obj)
    }
}
data class SmallDto(
    val id: Long,
    val name: String,
    val email: String?,
    val age: Int,
    val score: Double,
    val active: Boolean,
    val status: Status,
    val createdAt: Instant,
    val updatedAt: Instant?,
    val requestId: String,
) {
    companion object {
        fun sample(): SmallDto = SmallDto(
            id = 123456789L,
            name = "jeonghyeongil",
            email = "jeonghyeongil@gmail.com",
            age = 29,
            score = 98.76,
            active = true,
            status = Status.ACTIVE,
            createdAt = Instant.parse("2025-12-20T00:00:00Z"),
            updatedAt = Instant.parse("2025-12-20T01:02:03Z"),
            requestId = UUID.randomUUID().toString()
        )
    }
}

data class Item(
    val id: Long,
    val sku: String,
    val title: String,
    val price: Int,
    val tags: List<String>,
    val attrs: Map<String, String>?,
    val createdAt: Instant
)

data class BigDto(
    val id: Long,
    val items: List<Item>
) {
    companion object {
        fun sample(size: Int): BigDto {
            val rnd = Random(1)
            val tagsPool = listOf("a", "b", "c", "d", "e", "f")

            val items = (0 until size).map { i ->
                Item(
                    id = i.toLong(),
                    sku = "SKU-$i",
                    title = "Item Title $i",
                    price = rnd.nextInt(1000, 100_000),
                    tags = List(3) { t -> tagsPool[(i + t) % tagsPool.size] },
                    attrs = if (i % 10 == 0) null else mapOf(
                        "color" to "black",
                        "size" to "M",
                        "idx" to i.toString()
                    ),
                    createdAt = Instant.ofEpochMilli(1_700_000_000_000L + i)
                )
            }

            return BigDto(
                id = 1L,
                items = items
            )
        }
    }
}

3-4. 힙 메모리를 고정한 이유

@Fork(
    value = 2,
    jvmArgs = ["-Xms2g", "-Xmx2g"]
)

힙 메모리를 2GB로 초기 크기와 최대 크기를 동일하게 설정했습니다. 이렇게 설정한 이유는 다음과 같습니다.

JVM 힙 확장/축소로 인한 노이즈 제거

JVM은 기본적으로 필요에 따라 힙 크기를 늘리거나 줄입니다. 이 과정에서 GC 패턴이 바뀌고, 측정 결과가 흔들릴 수 있습니다.

serialize / deserialize 자체 비용만 보고 싶었기 때문

이번 실험의 목적은

  • “메모리가 부족할 때 어떻게 되는가”가 아니라
  • “같은 메모리 조건에서 순수 처리 성능이 어떤가”

이기 때문에, 힙 크기를 고정하여 환경 변수를 최대한 제거했습니다

3-5. 왜 Fork를 사용했는가 (Fork = 2)

@Fork(2)

Fork는 JVM을 완전히 새로 띄워서 벤치마크를 실행하는 옵션입니다. Fork를 사용한 이유는 다음과 같습니다.

  • JVM은 실행 초기에 JIT 컴파일을 진행합니다.
  • 한 JVM에서만 실행하면, 이전 실행의 최적화 상태가 다음 측정에 영향을 줄 수 있습니다.

Fork를 사용하면:

  • JVM을 새로 띄운 상태에서
  • 동일한 벤치마크를 다시 실행하고
  • 두 결과를 평균냅니다.

즉, JVM을 껐다 켠 상태에서 같은 실험을 여러 번 반복하는 효과를 얻을 수 있습니다. 이번 실험에서는 신뢰도를 확보하면서 실행 시간이 과도하게 늘어나지 않도록 Fork = 2로 설정했습니다.

3-6. Warmup을 왜 이렇게 설정했는가

@Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS)

Warmup의 의미

Warmup은 측정하지 않는 구간입니다. 이 구간에서는 JIT 컴파일, 클래스 로딩, 내부 캐시 준비 등이 이루어집니다.

왜 3초 × 3회인가

  • 1회만으로는 JVM 최적화가 충분히 끝나지 않는 경우가 많습니다.
  • 너무 길게 잡으면 전체 실행 시간이 과도하게 증가합니다.

그래서 3초 동안 최대한 많이 실행하고 이를 3번 반복 하는 설정을 선택했습니다. 이는 JVM이 충분히 “몸을 푼 상태” 이지만 실행 시간이 과하지 않은 타협점이라고 판단했습니다.

3-7. Measurement 설정 의도

@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS)

 

Measurement는 실제 결과로 사용되는 구간입니다.

“3초 동안 실행”의 의미

이것은 “3초 동안 한 번 실행”이 아니라, 3초 동안 해당 메서드를 최대한 많이 반복 실행한다는 의미입니다. 예를 들어 메서드 한 번이 2ms 걸린다면 3초 동안 약 150만 번 실행됩니다. 이렇게 많은 샘플을 모아야 OS 스케줄링, GC 타이밍, 순간적인 노이즈 를 평균으로 눌러서 안정적인 수치를 얻을 수 있습니다.

iterations = 5

이 3초짜리 측정을 5번 반복하여 평균, 표준편차, 신뢰 구간 을 계산합니다.

3-8. Throughput + AverageTime를 동시에 측정한 이유

@BenchmarkMode(Mode.Throughput, Mode.AverageTime)

 

두 모드는 같은 현상을 다른 관점에서 보여줍니다.

  • Throughput (ops/ms)
    → 서버 처리량 관점
    → “1ms에 몇 번 처리 가능한가”
  • AverageTime (ms/op)
    → 지연 시간 관점
    → “1번 처리하는 데 평균 얼마나 걸리는가”

3-9. 코드 설계 의도

State와 Setup

@State(Scope.Benchmark)
@Setup(Level.Trial)
  • 벤치마크 실행 동안 동일한 객체를 공유
  • 테스트 시작 시 한 번만 데이터 준비

이렇게 한 이유는 serialize / deserialize 자체 비용만 측정하기 위함입니다. 특히 deserialize 테스트에서

smallBytes = mapper.writeValueAsBytes(small)
bigBytes = mapper.writeValueAsBytes(big)

 

를 미리 수행한 이유는 deserialize 성능을 측정하는데 serialize 비용이 섞이지 않도록 하기 위함입니다.

Small DTO

  • 일반적인 API 응답 DTO 크기

Big DTO

  • 내부에 List<Item> 포함
  • 대량 리스트 응답 DTO 크기

Big DTO는:

  • 1,000개
  • 10,000개

두 케이스로 나누어 크기에 따른 선형 증가 여부도 함께 확인했습니다.


4. 결과 요약 및 해석 

Jackson3

Benchmark                      (bigSize)   Mode  Cnt     Score     Error   Units
JacksonBench.deserializeBig         1000  thrpt   10     0.837 ±   0.043  ops/ms
JacksonBench.deserializeBig        10000  thrpt   10     0.086 ±   0.001  ops/ms
JacksonBench.deserializeSmall       1000  thrpt   10   541.207 ±  22.379  ops/ms
JacksonBench.deserializeSmall      10000  thrpt   10   550.785 ±   6.788  ops/ms
JacksonBench.serializeBig           1000  thrpt   10     2.837 ±   0.046  ops/ms
JacksonBench.serializeBig          10000  thrpt   10     0.274 ±   0.004  ops/ms
JacksonBench.serializeSmall         1000  thrpt   10  1708.548 ±  36.480  ops/ms
JacksonBench.serializeSmall        10000  thrpt   10  1640.783 ± 114.743  ops/ms
JacksonBench.deserializeBig         1000   avgt   10     1.194 ±   0.054   ms/op
JacksonBench.deserializeBig        10000   avgt   10    11.696 ±   0.363   ms/op
JacksonBench.deserializeSmall       1000   avgt   10     0.002 ±   0.001   ms/op
JacksonBench.deserializeSmall      10000   avgt   10     0.002 ±   0.001   ms/op
JacksonBench.serializeBig           1000   avgt   10     0.349 ±   0.006   ms/op
JacksonBench.serializeBig          10000   avgt   10     3.588 ±   0.070   ms/op
JacksonBench.serializeSmall         1000   avgt   10     0.001 ±   0.001   ms/op
JacksonBench.serializeSmall        10000   avgt   10     0.001 ±   0.001   ms/o

 

Jackson2

Benchmark                      (bigSize)   Mode  Cnt     Score    Error   Units
JacksonBench.deserializeBig         1000  thrpt   10     0.677 ±  0.046  ops/ms
JacksonBench.deserializeBig        10000  thrpt   10     0.075 ±  0.002  ops/ms
JacksonBench.deserializeSmall       1000  thrpt   10   502.430 ±  4.089  ops/ms
JacksonBench.deserializeSmall      10000  thrpt   10   487.280 ± 14.380  ops/ms
JacksonBench.serializeBig           1000  thrpt   10     2.016 ±  0.025  ops/ms
JacksonBench.serializeBig          10000  thrpt   10     0.190 ±  0.004  ops/ms
JacksonBench.serializeSmall         1000  thrpt   10  1425.201 ± 36.081  ops/ms
JacksonBench.serializeSmall        10000  thrpt   10  1458.080 ± 51.928  ops/ms
JacksonBench.deserializeBig         1000   avgt   10     1.420 ±  0.029   ms/op
JacksonBench.deserializeBig        10000   avgt   10    13.221 ±  0.096   ms/op
JacksonBench.deserializeSmall       1000   avgt   10     0.002 ±  0.001   ms/op
JacksonBench.deserializeSmall      10000   avgt   10     0.002 ±  0.001   ms/op
JacksonBench.serializeBig           1000   avgt   10     0.500 ±  0.017   ms/op
JacksonBench.serializeBig          10000   avgt   10     5.068 ±  0.092   ms/op
JacksonBench.serializeSmall         1000   avgt   10     0.001 ±  0.001   ms/op
JacksonBench.serializeSmall        10000   avgt   10     0.001 ±  0.001   ms/op

 

기준

  • thrpt 개선율 = 처리량 기준 (높을수록 좋음)
  • avgt 개선율 = 1건 처리 시간 기준 (낮을수록 좋음)
  • 절감 시간 = 객체 1건 처리 시 줄어든 실제 시간

Big DTO

항목 Size Jackson 2 Jackson 3 thrpt 개선율 avgt 개선율 1건당 절감 시간
serializeBig 1000 2.016 ops/ms
0.500 ms/op
2.837 ops/ms
0.349 ms/op
+40.7% -30.2% 0.151 ms
serializeBig 10000 0.190 ops/ms
5.068 ms/op
0.274 ops/ms
3.588 ms/op
+44.2% -29.2% 1.480 ms
deserializeBig 1000 0.677 ops/ms
1.420 ms/op
0.837 ops/ms
1.194 ms/op
+23.6% -15.9% 0.226 ms
deserializeBig 10000 0.075 ops/ms
13.221 ms/op
0.086 ops/ms
11.696 ms/op
+14.7% -11.5% 1.525 ms

 

Small DTO (절대 시간이 1~2ms 수준이라 개선율은 존재하지만 단일 요청 체감은 거의 없습니다.)

항목 Jackson 2 Jackson 3  thrpt 개선율 avgt 개선율 
serializeSmall 1425 ~ 1458 ops/ms 1641 ~ 1709 ops/ms +12.5% ~ +19.9% ~0.001 ms/op
deserializeSmall 487 ~ 502 ops/ms 541 ~ 551 ops/ms +7.7% ~ +13.0% ~0.002 ms/op

 

정리하면 다음과 같습니다.

  • Small DTO
    → Jackson 3가 더 빠르지만, 절대 시간이 매우 작아 단일 요청 체감은 제한적입니다.
  • Big DTO
    → Jackson 3는
    • 직렬화 기준 최대 약 40~45% 처리량 개선,
    • 역직렬화 기준 약 15~25% 처리량 개선,
    • 객체 1건당 약 0.15~1.48ms, 역직렬화는 객체 1건당 약 0.23~1.53ms의 밀리초 단위의 실질적인 시간 절감 

대용량 JSON 처리 구간에서 실제로 의미 있는 개선을 확인할 수 있었습니다.


5. 정리

이번 실험은 동일 코드, 동일 데이터 구조, 동일 JMH 설정 동일 머신 환경 에서 Jackson 버전만 변경하여 수행되었습니다.  그 결과 Jackson 3는 Jackson 2 대비 특히 대용량 JSON 처리에서 명확하고 일관된 성능 개선을 보여주었습니다. Small DTO 위주의 서비스에서는 체감이 크지 않을 수 있지만, Big DTO일때는 체감이 꽤나 컸습니다. 


6. 왜 Jackson3이 더 빠를까? 

Jackson3이 왜 더 빠른지 실제 라이브러리 내부 코드를 확인해보았습니다. Jackson 3은 직렬화 과정에서 반복과 런타임 판단을 최대한 제거하는 방향으로 성능을 개선한 것을 확인하였습니다. 대표적으로 아래와 같은 최적화들이 적용되었습니다.

6-1. UnrolledBeanSerializer (반복문 + 배열 접근 제거)

기존 구조 (Jackson 2)

// 배열을 순회하면서 프로퍼티 직렬화
protected void _serializePropertiesNoView(
        Object bean,
        JsonGenerator gen,
        SerializationContext provider,
        BeanPropertyWriter[] props
) {
    int i = 0;
    int left = props.length;

    do {
        BeanPropertyWriter prop = props[i];
        prop.serializeAsProperty(bean, gen, provider);

        prop = props[i + 1];
        prop.serializeAsProperty(bean, gen, provider);

        i += 2;
        left -= 2;
    } while (left > 1);
}

문제점

  • props[i] 접근마다 bounds check 발생
  • i, left 같은 카운터 변수 관리 필요
  • 반복 조건을 매번 검사

개선 구조 (Jackson 3 – UnrolledBeanSerializer)

// 프로퍼티를 배열이 아닌 필드로 보관
protected BeanPropertyWriter _prop1;
protected BeanPropertyWriter _prop2;
protected BeanPropertyWriter _prop3;
protected BeanPropertyWriter _prop4;
protected int _propCount;

protected void serializeNonFiltered(
        Object bean,
        JsonGenerator gen,
        SerializationContext provider
) throws IOException {
    gen.writeStartObject(bean);

    switch (_propCount) {
        case 4:
            _prop1.serializeAsProperty(bean, gen, provider);
        case 3:
            _prop2.serializeAsProperty(bean, gen, provider);
        case 2:
            _prop3.serializeAsProperty(bean, gen, provider);
        case 1:
            _prop4.serializeAsProperty(bean, gen, provider);
    }

    gen.writeEndObject();
}

개선 효과

  • 배열 접근 제거
  • 반복문 제거
  • 조건 체크 제거
  • switch fall-through 구조로 CPU 분기 예측에 유리

6-2. 직렬화 사전 결정

기존 구조 - 매번 타입 확인(Jackson 2)

public void serializeAsProperty(
        Object bean,
        JsonGenerator g,
        SerializationContext ctxt
) throws Exception {
    Object value = get(bean);

    ValueSerializer<Object> ser = _serializer;
    if (ser == null) {
        Class<?> cls = value.getClass();
        PropertySerializerMap m = _dynamicSerializers;

        ser = m.serializerFor(cls);
        if (ser == null) {
            ser = _findAndAddDynamic(m, cls, ctxt);
        }
    }

    ser.serialize(value, g, ctxt);
}

매 호출마다 발생하는 작업

  • 직렬화기 존재 여부 체크
  • 런타임 타입 확인
  • 캐시 조회
  • 필요 시 신규 생성

객체 수 × 프로퍼티 수만큼 반복됩니다.

개선 구조 - 생성 시점에 한 번만 결정(Jackson 3)

@Override
public void resolve(SerializationContext provider) throws JsonMappingException {
    for (BeanPropertyWriter prop : _props) {
        if (prop.hasSerializer()) {
            continue;
        }

        JavaType type = prop.getType();

        // final 타입은 런타임에 바뀌지 않음
        if (type.isFinal()) {
            ValueSerializer<Object> ser =
                provider.findPrimaryPropertySerializer(type, prop);
            prop.assignSerializer(ser);
        }
    }
}

 

실제 직렬화시

public void serializeAsProperty(
        Object bean,
        JsonGenerator g,
        SerializationContext ctxt
) throws Exception {
    Object value = get(bean);

    // final 타입이면 이미 serializer가 할당되어 있음
    _serializer.serialize(value, g, ctxt);
}

6-3. 요약

항목 Jackeson2 Jackson3
배열 접근 O X
반복문 O X
런타임 타입 체크 매번 생성 시 1회
CPU 분기 예측 어려움 쉬움

이 외에도 Jackson 3에는 다음과 같은 개선이 함께 적용되었습니다.

  • PropertySerializerMap에서 단일 타입 사용 시 해시맵을 우회하는 빠른 조회 경로 추가
  • SerializerCache에 읽기 전용 캐시를 분리하여 락 없는 조회 가능
  • 리스트 직렬화 시 Iterator 대신 인덱스 기반 접근 사용
  • Java 17 환경에서 Record 타입의 구조적 특성을 활용하여 리플렉션 비용 감소
728x90
반응형
Comments