~/khan

JDK 28 Simple JSON API — 마케팅 이벤트 JSON 에서 ObjectMapper 를 걷어낼 수 있을까

· 16 min read · by Khan
#Java#JDK 28#JSON#Jackson#의존성

마케팅 이벤트를 Amplitude 와 Braze 로 쏘는 코드를 들여다보다가, 그 경로가 하는 일이 결국 "필드 대여섯 개짜리 Map 을 JSON 문자열로 만들어 HTTP 로 던지는 것" 뿐이라는 걸 알게 됐습니다. 그런데 그 한 줄을 위해 Jackson 이 통째로 딸려 옵니다. 마침 JDK 28 에 JEP 540 Simple JSON API 가 들어온다는 소식을 봤습니다. 이번 글은 "그럼 이걸로 ObjectMapper 를 덜어낼 수 있나?" 를 실제로 따져 본 기록입니다.

1. JEP 540 — 표준 JSON API, 두 번째 시도

Java 에 표준 JSON API 를 넣으려는 시도는 처음이 아닙니다.

JEP 198JEP 540
제안 시점2014년2026년
이름Light-Weight JSON APISimple JSON API
범위넓음 (바인딩·스트리밍까지 시야에)좁음 (불변 값 계층만)
상태withdrawn (구현되지 않고 닫힘)JDK 28 타겟, Incubator

12년 전 시도가 접힌 이유는 대략 "야심이 컸다" 로 요약됩니다. 이번 JEP 540 은 정반대 전략입니다. 불변 인메모리 값 계층 하나만 표준으로 들고 객체 매핑·스트리밍·스키마 검증·커스터마이징은 기존 생태계(Jackson, Gson)에 그대로 맡깁니다.

기본 정보는 이렇습니다.

항목내용
모듈jdk.incubator.json
표준RFC 8259
대상 JDKJDK 28 (GA 2027년 3월, 비 LTS)
상태Incubator — 비호환 변경·제거 가능

여기서 이미 절반은 답이 나옵니다. 인큐베이터 모듈이고 JDK 28 은 LTS 가 아닙니다. 이 두 줄이 뒤에서 계속 발목을 잡게 됩니다.

2. API 모양 — Json 하나와 sealed JsonValue

중심에는 Json 클래스와 밀봉된(sealed) JsonValue 인터페이스가 있습니다.

JsonValue (sealed)
   ├─ JsonObject   { ... }
   ├─ JsonArray    [ ... ]
   ├─ JsonString   "..."
   ├─ JsonNumber   123
   ├─ JsonBoolean  true / false
   └─ JsonNull     null

모든 인스턴스는 불변이고 스레드 안전 합니다. ObjectMapper 를 싱글턴으로 둘지, 매번 만들지, 설정을 바꾸면 안전한지 고민하던 지점이 애초에 없습니다.

2.1 읽기

int temperature = Json.parse(body)
    .get("properties")
    .get("periods")
    .get(0)
    .get("temperature")
    .asInt();
  • Json.parse(String) / Json.parse(char[]) — 문서 전체를 한 번에 파싱합니다.
  • get(String) — 객체 필드, get(int) — 배열 인덱스.
  • asInt() / asLong() / asDouble() / asBoolean() / asMap() / asList() — 값 꺼내기.
  • tryGet(String)Optional<JsonValue> 로 안전하게.

Map<String, Object> 로 받아서 캐스팅 지옥을 만들던 코드와 비교하면 최소한 꺼내는 자리에서 타입이 드러납니다.

2.2 쓰기

JsonObject document = JsonObject.of(Map.of(
    "service", JsonString.of("web_server"),
    "id", JsonNumber.of(3),
    "active", JsonBoolean.of(true)
));

JsonString.of / JsonNumber.of 로 값을 감싸는 게 처음엔 번거로워 보입니다. 그런데 이 번거로움이 의도된 것 입니다. Object 를 받아서 런타임에 타입을 추론하지 않겠다는 선언이고 그래서 "왜 Long 이 문자열로 나갔지" 같은 사고가 원천적으로 안 생깁니다.

2.3 sealed 라서 얻는 것 — 패턴 매칭

JsonValue 가 sealed 인 덕분에 switch 가 전부 다뤘는지 컴파일러가 검사해 줍니다.

String describe(JsonValue value) {
    return switch (value) {
        case JsonObject o  -> "object(" + o.asMap().size() + ")";
        case JsonArray a   -> "array(" + a.asList().size() + ")";
        case JsonString s  -> "string";
        case JsonNumber n  -> "number";
        case JsonBoolean b -> "boolean";
        case JsonNull ignored -> "null";
    };
}

default 절이 필요 없습니다. 새 타입이 생기면 컴파일이 깨지므로 빠뜨린 분기가 런타임까지 살아 나가지 않습니다. 표준 JSON API 가 지금 시점에 들어오는 게 의미 있는 이유 중 하나가 이것입니다. sealed interface 와 패턴 매칭이 이미 언어에 자리 잡은 뒤에 설계됐습니다.

3. 하지 않는 것 — 여기가 진짜 중요합니다

JEP 540 의 비목표(non-goals)는 명시적입니다.

  • 데이터 바인딩 없음 — POJO ↔ JSON 자동 변환을 하지 않습니다.
  • 스트리밍 없음 — 문서를 통째로 메모리에 올립니다.
  • 관대한 파싱 모드 없음 — 트레일링 콤마, 주석 같은 걸 봐주지 않습니다.
  • 문법 확장 없음

노리는 자리는 이렇게 못 박혀 있습니다. 설정 파일 읽기, REST 응답 들여다보기, 작은 JSON 페이로드 만들기.

Jackson 대체재가 아닙니다. Jackson 을 안 써도 되는 자리를 골라내는 도구입니다.

4. 그래서 우리 마케팅 이벤트 코드는?

여기서부터가 제가 실제로 궁금했던 부분입니다. Amplitude 와 Braze 로 이벤트를 쏘는 경로는 대략 이런 모양입니다.

// 지금 — Jackson 으로 직렬화해서 던집니다
private final ObjectMapper objectMapper;
 
public void track(String userId, String eventType, Instant occurredAt) {
    Map<String, Object> event = Map.of(
        "user_id", userId,
        "event_type", eventType,
        "time", occurredAt.toEpochMilli()
    );
 
    String body = objectMapper.writeValueAsString(Map.of("events", List.of(event)));
    restClient.post().uri("/2/httpapi").body(body).retrieve().toBodilessEntity();
}

이 경로가 Jackson 에게 요구하는 기능을 세어 보면 writeValueAsString 하나 입니다. 애노테이션도, 커스텀 시리얼라이저도, @JsonInclude 도 쓰지 않습니다. 필드는 대여섯 개고 구조는 평평합니다.

4.1 Simple JSON API 로 옮기면

public void track(String userId, String eventType, Instant occurredAt) {
    JsonObject event = JsonObject.of(Map.of(
        "user_id", JsonString.of(userId),
        "event_type", JsonString.of(eventType),
        "time", JsonNumber.of(occurredAt.toEpochMilli())
    ));
 
    JsonObject payload = JsonObject.of(Map.of(
        "events", JsonArray.of(List.of(event))
    ));
 
    restClient.post().uri("/2/httpapi").body(payload.toString()).retrieve().toBodilessEntity();
}

줄 수는 오히려 조금 늘었습니다. 대신 얻는 게 두 가지 있습니다.

  1. 의존성이 사라집니다. 이 클래스가 더 이상 ObjectMapper 빈을 주입받지 않습니다.
  2. 페이로드 스키마가 코드에 그대로 드러납니다. user_id 가 문자열이고 time 이 숫자라는 게 타입으로 적혀 있습니다. Map<String, Object> 를 넘기던 코드에서는 이게 런타임에만 존재하던 사실입니다.

Amplitude 나 Braze 같은 외부 SaaS 로 쏘는 이벤트는 스펙이 문서로 고정되어 있고, 필드가 적고, 구조가 평평합니다. JEP 540 이 노린 자리와 거의 정확히 겹칩니다.

4.2 무엇이 줄고, 무엇은 안 줄어드나

코드대체 가능?이유
이벤트 페이로드 조립 (평평한 Map)JsonObject.of 로 충분
SaaS 응답에서 필드 한두 개 꺼내기get().asInt() 체이닝
설정/스펙 JSON 파일 읽기Json.parse
도메인 객체 → JSON 자동 직렬화바인딩이 없습니다. 필드마다 손으로 감싸야 합니다
@JsonProperty 로 필드명 매핑애노테이션 기반 매핑이 아예 없습니다
대용량 응답 스트리밍 파싱전체를 메모리에 올립니다
Spring MVC 요청/응답 바디 변환HttpMessageConverter 는 Jackson 몫

마지막 네 줄이 핵심입니다. 필드 30개짜리 도메인 객체를 통째로 직렬화하는 자리에 이걸 넣으면 손으로 감싸는 코드만 30줄이 됩니다. 그건 개선이 아닙니다.

그리고 현실적으로, Spring Boot 를 쓰는 이상 Jackson 은 어차피 클래스패스에 있습니다. spring-boot-starter-web 이 끌고 옵니다. 그러니 이 이야기는 "의존성 트리에서 Jackson 을 지운다" 가 아니라 "이 클래스가 ObjectMapper 를 주입받지 않아도 되게 만든다" 쪽에 가깝습니다. 사라지는 건 jar 가 아니라 결합 입니다.

작지만 실제로 도움이 되는 지점들이 있습니다.

  • 이벤트 전송 유틸이 ObjectMapper 빈에 안 묶이니 테스트에서 Spring 컨텍스트 없이 단위 테스트가 됩니다.
  • 전역 ObjectMapper 설정(예: NON_NULL 정책, 네이밍 전략)이 바뀌어도 외부로 나가는 페이로드 모양이 흔들리지 않습니다. 이건 마케팅 데이터처럼 한 번 잘못 쏘면 되돌릴 수 없는 경로에서 은근히 큰 안전장치입니다.
  • Jackson 3 마이그레이션(com.fasterxml.jacksontools.jackson) 같은 이사 때 손댈 파일이 줄어듭니다.

5. 그런데 지금 당장은 못 씁니다 — 조건 두 가지

기대를 적었으니 조건도 정확히 적어 둡니다.

5.1 인큐베이터입니다

jdk.incubator.json 은 인큐베이터 모듈입니다. 컴파일·실행 모두 명시적으로 켜 줘야 합니다.

javac --add-modules jdk.incubator.json ...
java  --add-modules jdk.incubator.json ...

그리고 인큐베이터의 의미는 개발자 피드백에 따라 비호환하게 바뀌거나 통째로 빠질 수 있다 는 것입니다. 지금 평가 중이라고 언급된 항목만 해도 네비게이션 모델, 예외 의미론, 숫자 변환 방식, 생성 API 사용성입니다. 즉 이 글에 적은 메서드 이름들도 정식 표준화 시점에는 다를 수 있습니다.

운영 코드에 넣을 물건이 아닙니다.

5.2 JDK 28 은 LTS 가 아닙니다

JDK 25 (LTS)  ──  JDK 26  ──  JDK 27  ──  JDK 28  ──  ...  ──  JDK 29 (LTS)
   2025-09        2026-03     2026-09     2027-03              2028-09 (예정)

                                   JEP 540 인큐베이터 1차

지금 대부분의 서비스가 앉아 있는 자리는 JDK 25 (LTS) 입니다. JDK 28 은 GA 가 2027년 3월, EOL 이 2027년 9월인 6개월짜리 비 LTS 릴리즈입니다. 인큐베이터 API 를 비 LTS 위에서 운영에 태운다 는 건 위험을 두 번 겹치는 선택입니다.

현실적인 타임라인은 이렇게 잡힙니다.

시점할 일
지금우리 코드에서 Jackson 을 이 정도로만 쓰는 자리를 목록으로 뽑아 둡니다
JDK 28 (2027-03)EA 빌드나 샌드박스에서 이벤트 전송 경로 하나만 옮겨 보고 피드백
정식 표준화 이후목록에 뽑아 둔 자리부터 순차 이전

5.3 대신 지금 할 수 있는 것

인큐베이터가 풀릴 때까지 손 놓고 기다릴 필요는 없습니다. 이전을 쉽게 만드는 준비 는 지금 해 둘 수 있습니다.

// 직렬화 방식을 인터페이스 뒤로 숨겨 둡니다
interface EventPayloadWriter {
    String write(MarketingEvent event);
}

지금은 Jackson 구현 하나만 두고 나중에 Simple JSON API 구현을 하나 더 만들어 갈아 끼우는 식입니다. 다만 이건 이전 계획이 실제로 있을 때만 정당한 추상화 입니다. 구현이 하나뿐인 인터페이스를 "언젠가 바꿀지도 모르니까" 로 깔아 두는 건 그냥 층 하나 늘리는 일입니다. 이벤트 전송 경로처럼 외부 스펙에 묶여 있고 바뀔 이유가 분명한 자리 에만 두는 게 맞습니다.

6. 마무리

정리하면 세 줄입니다.

  1. JEP 540 은 Jackson 대체재가 아닙니다. 데이터 바인딩도 스트리밍도 없습니다. 설정 파일 읽기, 응답 들여다보기, 작은 페이로드 만들기 — 딱 그 자리입니다.
  2. 마케팅 이벤트 전송은 그 자리에 정확히 들어맞습니다. 필드가 적고, 평평하고, 스펙이 외부 문서로 고정되어 있습니다. 줄어드는 건 jar 가 아니라 ObjectMapper 와의 결합입니다.
  3. 하지만 지금은 아닙니다. 인큐베이터 + 비 LTS 조합이고 API 가 바뀔 수 있습니다.

개인적으로 이 JEP 에서 제일 마음에 든 건 기능이 아니라 범위를 좁힌 결정 이었습니다. JEP 198 이 12년간 못 나온 자리에서, 같은 목표를 반쯤 접고 다시 시작해 targeted 까지 온 셈입니다. "표준이 모든 걸 다 해 줄 필요는 없다" 는 판단이 API 설계에도, 우리 코드에도 똑같이 적용되는 것 같습니다.

ObjectMapper 를 걷어낼 자리 목록은, JDK 28 EA 빌드가 나오면 하나씩 실제로 옮겨 보고 다시 적어 두려고 합니다.

참고