JDK 28 Simple JSON API — 마케팅 이벤트 JSON 에서 ObjectMapper 를 걷어낼 수 있을까
마케팅 이벤트를 Amplitude 와 Braze 로 쏘는 코드를 들여다보다가, 그 경로가 하는 일이 결국 "필드 대여섯 개짜리 Map 을 JSON 문자열로 만들어 HTTP 로 던지는 것" 뿐이라는 걸 알게 됐습니다. 그런데 그 한 줄을 위해 Jackson 이 통째로 딸려 옵니다. 마침 JDK 28 에 JEP 540 Simple JSON API 가 들어온다는 소식을 봤습니다. 이번 글은 "그럼 이걸로
ObjectMapper를 덜어낼 수 있나?" 를 실제로 따져 본 기록입니다.
1. JEP 540 — 표준 JSON API, 두 번째 시도
Java 에 표준 JSON API 를 넣으려는 시도는 처음이 아닙니다.
| JEP 198 | JEP 540 | |
|---|---|---|
| 제안 시점 | 2014년 | 2026년 |
| 이름 | Light-Weight JSON API | Simple JSON API |
| 범위 | 넓음 (바인딩·스트리밍까지 시야에) | 좁음 (불변 값 계층만) |
| 상태 | withdrawn (구현되지 않고 닫힘) | JDK 28 타겟, Incubator |
12년 전 시도가 접힌 이유는 대략 "야심이 컸다" 로 요약됩니다. 이번 JEP 540 은 정반대 전략입니다. 불변 인메모리 값 계층 하나만 표준으로 들고 객체 매핑·스트리밍·스키마 검증·커스터마이징은 기존 생태계(Jackson, Gson)에 그대로 맡깁니다.
기본 정보는 이렇습니다.
| 항목 | 내용 |
|---|---|
| 모듈 | jdk.incubator.json |
| 표준 | RFC 8259 |
| 대상 JDK | JDK 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();
}줄 수는 오히려 조금 늘었습니다. 대신 얻는 게 두 가지 있습니다.
- 의존성이 사라집니다. 이 클래스가 더 이상
ObjectMapper빈을 주입받지 않습니다. - 페이로드 스키마가 코드에 그대로 드러납니다.
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.jackson→tools.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. 마무리
정리하면 세 줄입니다.
- JEP 540 은 Jackson 대체재가 아닙니다. 데이터 바인딩도 스트리밍도 없습니다. 설정 파일 읽기, 응답 들여다보기, 작은 페이로드 만들기 — 딱 그 자리입니다.
- 마케팅 이벤트 전송은 그 자리에 정확히 들어맞습니다. 필드가 적고, 평평하고, 스펙이 외부 문서로 고정되어 있습니다. 줄어드는 건 jar 가 아니라
ObjectMapper와의 결합입니다.- 하지만 지금은 아닙니다. 인큐베이터 + 비 LTS 조합이고 API 가 바뀔 수 있습니다.
개인적으로 이 JEP 에서 제일 마음에 든 건 기능이 아니라 범위를 좁힌 결정 이었습니다. JEP 198 이 12년간 못 나온 자리에서, 같은 목표를 반쯤 접고 다시 시작해 targeted 까지 온 셈입니다. "표준이 모든 걸 다 해 줄 필요는 없다" 는 판단이 API 설계에도, 우리 코드에도 똑같이 적용되는 것 같습니다.
ObjectMapper 를 걷어낼 자리 목록은, JDK 28 EA 빌드가 나오면 하나씩 실제로 옮겨 보고 다시 적어 두려고 합니다.