JEP 540 Simple JSON API 파헤치기 — 자바 표준 JSON, Jackson·Gson과 뭐가 다른가

20년 넘게 자바에는 표준 JSON 파서가 없었다. { } 하나 읽으려고 Jackson이나 Gson을 pom.xml에 넣어야 했고, 설정 파일 한 줄 파싱하는 데도 외부 라이브러리가 따라붙었다. JEP 540 “Simple JSON API”는 이 오래된 공백을 메운다. JDK 28을 겨냥해 jdk.incubator.json 모듈로 들어오는 이 API는 파싱·탐색·생성을 표준 라이브러리만으로 처리한다. 다만 데이터 바인딩과 스트리밍은 일부러 뺐다. 이 글에서는 실제 코드로 Json.parse부터 gettryGet의 차이, sealed 타입 패턴 매칭, 숫자 정밀도까지 짚고, 언제 이걸 쓰고 언제 여전히 Jackson·Gson을 써야 하는지 비교 테이블로 정리한다.

JEP 540이 대체 뭐고 왜 하필 지금인가

JEP 540은 JDK가 처음으로 제공하는 표준 JSON API다. jdk.incubator.json 모듈에 담겨 JDK 28을 목표(Proposed to Target)로 올라와 있고, RFC 8259를 엄격하게 따른다. 목표는 딱 세 가지 — JSON을 파싱하고, 탐색하고, 생성하는 것. 설정 파일을 읽거나 REST 응답을 확인하거나 작은 페이로드를 만드는, 그 흔한 작업을 외부 의존성 없이 끝내자는 것이다.

왜 이제야 나왔냐면, 그동안 JDK 팀은 JSON을 표준에 넣는 걸 미뤄왔기 때문이다. Jackson·Gson 생태계가 워낙 성숙해서 굳이 표준이 필요하냐는 반론이 컸다. 그런데 JSON이 사실상 모든 API의 공용어가 되면서 “언어가 기본으로 JSON을 몰라도 되나”라는 쪽으로 무게가 기울었다. Hacker News의 한 반응이 이 정서를 잘 요약한다. “JSON을 표준 라이브러리에 넣는다는 건 JSON이 얼마나 필수 시민이 됐는지를 말해준다”는 것이다.

한 가지 오해를 먼저 걷어내자. JEP 540은 Jackson을 대체하려고 만든 게 아니다. JEP 문서가 데이터 바인딩·스트리밍·스키마 검증을 명시적으로 비목표(non-goal)로 선언한다. 무거운 라이브러리를 끌어오기 아까운 가벼운 작업을 겨냥한, 의도적으로 좁은 API다.

5분이면 파싱된다 — Json.parse와 값 꺼내기

파싱의 진입점은 Json.parse 하나다. 문자열이나 char[]를 넘기면 최상위 값을 JsonValue로 돌려주고, 그 아래를 get으로 파고든다. JsonValue는 sealed 인터페이스라 실제 타입은 JsonObject·JsonArray·JsonString·JsonNumber·JsonBoolean·JsonNull 여섯 중 하나다. 아래는 REST 응답 한 조각을 읽는 코드다.

import jdk.incubator.json.*;

String body = """
    { "service": "web_server", "id": 3, "active": true }
    """;

JsonValue root = Json.parse(body);          // 최상위 JsonValue
JsonObject obj = (JsonObject) root;          // 객체로 좁히기

String service = ((JsonString) obj.get("service")).value();  // "web_server"
Number id      = ((JsonNumber) obj.get("id")).toNumber();    // 3
boolean active = ((JsonBoolean) obj.get("active")).value();  // true
Java

핵심은 모든 접근이 JsonValue를 돌려주고, 원하는 자바 타입은 캐스팅 뒤 접근자로 꺼낸다는 점이다. JsonStringvalue()String을, JsonNumbertoNumber()Number를, JsonBooleanvalue()boolean을 준다. JsonObjectmembers()Map<String, JsonValue> 전체를, JsonArrayvalues()List<JsonValue>를 통째로 넘겨받을 수도 있다. 파싱된 모든 인스턴스는 불변이고 스레드 세이프라, 한 번 읽은 트리를 여러 스레드가 공유해도 안전하다.

get vs tryGet — 여기서 대부분 발이 걸린다

gettryGet을 구분하지 못하면 운영에서 JsonValueException에 시달린다. get("key")는 키가 없거나 타입이 어긋나면 예외를 던지고, tryGet("key")는 값을 Optional<JsonValue>로 감싸 없으면 빈 Optional을 준다. 필수 필드는 get, 있을 수도 없을 수도 있는 필드는 tryGet을 쓰는 게 기본 원칙이다.

JsonObject obj = (JsonObject) Json.parse(body);

// 필수 필드 — 없으면 즉시 실패시키고 싶다
String service = ((JsonString) obj.get("service")).value();

// 선택 필드 — 없으면 기본값으로
String region = obj.tryGet("region")
                   .map(v -> ((JsonString) v).value())
                   .orElse("ap-northeast-2");
Java

주의할 함정이 하나 있다. get이 던지는 JsonValueException“키가 없다”와 “타입이 다르다”를 구분해주지 않는다. Hacker News에서도 이 지점을 “footgun”이라 꼬집었다. 예외 하나만 보고는 필드가 빠진 건지, 문자열인 줄 알았는데 숫자였는지 알 수가 없다. 그래서 선택 필드는 tryGet으로 존재 여부를 먼저 걸러내고, 타입은 아래에서 다룰 패턴 매칭으로 확인하는 편이 안전하다. 이 두 갈래를 처음부터 나눠 쓰면 디버깅 시간이 확 줄어든다.

JSON 만들기 — JsonObject.of와 ‘ceremony’ 논란

생성은 팩토리 메서드 of로 한다. JsonObject.of(Map), JsonString.of(...), JsonNumber.of(...), JsonBoolean.of(...)를 조합해 트리를 쌓고, toString()으로 압축 문자열을, Json.toDisplayString(...)으로 사람이 읽기 좋은 들여쓰기 출력을 얻는다.

JsonObject doc = JsonObject.of(Map.of(
    "service", JsonString.of("web_server"),
    "id",      JsonNumber.of(3),
    "active",  JsonBoolean.of(true),
    "tags",    JsonArray.of(List.of(
                   JsonString.of("prod"), JsonString.of("kr")))
));

String compact = doc.toString();
// {"service":"web_server","id":3,"active":true,"tags":["prod","kr"]}

String pretty = Json.toDisplayString(doc);   // 줄바꿈·들여쓰기 적용
Java

여기서 API가 가장 많이 욕먹는 지점이 드러난다. JsonArray.of(List.of(JsonString.of("prod")))처럼 네이티브 값 하나를 넣는 데도 래퍼로 두 번 감싸야 한다. JEP가 내세운 “low ceremony(적은 격식)”라는 목표와 달리, 커뮤니티는 “이게 low ceremony냐”고 되묻는다. JsonArray.of("prod", "kr") 같은 가변 인자 편의 메서드가 있었으면 훨씬 간결했을 거라는 지적이다. 생성 쪽 코드가 장황해 보인다면 당신 잘못이 아니다. 인큐베이터 단계에서 이 부분이 다듬어질 여지가 남아 있는 이유이기도 하다.

sealed + 패턴 매칭 = JEP 540의 진짜 무기

JEP 540을 다른 트리 모델과 가르는 결정적 차이는 JsonValuesealed 인터페이스라는 점이다. 하위 타입이 여섯 개로 봉인돼 있어서, switch 패턴 매칭으로 모든 경우를 컴파일러가 강제로 검사하게 만들 수 있다. Jackson의 JsonNode나 Gson의 JsonElement에서 isTextual()·isInt() 같은 메서드를 줄줄이 확인하던 코드가, 여기서는 하나의 exhaustive switch로 접힌다.

static String describe(JsonValue v) {
    return switch (v) {
        case JsonObject o  -> "객체(멤버 " + o.members().size() + "개)";
        case JsonArray a   -> "배열(요소 " + a.values().size() + "개)";
        case JsonString s  -> "문자열: " + s.value();
        case JsonNumber n  -> "숫자: " + n.toNumber();
        case JsonBoolean b -> "불리언: " + b.value();
        case JsonNull ignored -> "널";
    };                                   // default 불필요 — sealed라 컴파일러가 보장
}
Java

이 코드에 default 절이 없다는 데 주목하자. JsonValue가 sealed라 여섯 경우가 전부임을 컴파일러가 알고 있어서, 하나라도 빠뜨리면 컴파일이 안 된다. 새로운 JSON 타입이 생길 일도 없으니 이 exhaustiveness는 영원히 유효하다. 재귀적으로 타면 임의 깊이의 문서도 타입 안전하게 순회한다. 최신 자바(record·sealed·패턴 매칭)의 문법과 맞물려 돌아가도록 설계된 API라, 이 조합을 쓸 때 가장 빛난다.

숫자가 double로 뭉개지지 않는다

JEP 540의 JsonNumber는 숫자를 임의 정밀도로 보존한다. 파싱할 때 곧바로 double로 변환하지 않기 때문에, 큰 정수나 고정밀 소수가 조용히 손상되는 사고가 없다. 금액·좌표·타임스탬프처럼 정밀도가 곧 정확성인 데이터에서 이 차이는 결정적이다.

JsonValue v = Json.parse("{ \"amount\": 12345678901234567890.12345 }");
JsonNumber amount = (JsonNumber) ((JsonObject) v).get("amount");
amount.toBigDecimal();                 // 12345678901234567890.12345 — 원본 그대로
amount.toBigDecimal().doubleValue();   // 1.2345678901234567E19 — double은 손실
Java

toBigDecimal()은 파싱된 숫자를 BigDecimal로 원본 정밀도까지 돌려주고, 여기에 doubleValue()를 걸면 그제야 double로 좁혀지며 값이 뭉개진다. Gson은 기본적으로 숫자를 double로 읽어 이 단계를 강제하고, Jackson은 정밀도를 지키려면 별도 설정이 필요하다. JEP 540은 정밀 보존이 기본값이다. Hacker News에서 이 설계를 가장 반긴 것도 이 지점이었는데, silent double 변환으로 값이 조용히 깨지는 걸 막아준다는 이유였다. 넓게 읽고 필요할 때 doubleValue()·intValue()로 좁히는 방향이라, 데이터를 먼저 잃고 나중에 후회할 일이 없다.

Jackson·Gson과 언제 뭘 써야 하나

JEP 540은 Jackson·Gson을 대체하지 않는다. 세 도구는 겨냥하는 지점이 다르다. JEP 540은 의존성 없이 가볍게 읽고 쓰는 자리를, Jackson은 POJO·record 바인딩과 스트리밍이 필요한 프로덕션 전반을, Gson은 그 중간의 경량 범용을 맡는다. 아래 표가 선택 기준을 압축한다.

항목JEP 540 (jdk.incubator.json)JacksonGson
의존성JDK 내장(인큐베이터)외부외부
데이터 바인딩(POJO/record)✗ (비목표)
스트리밍 파서
트리 모델✓ (불변)✓ (JsonNode, 가변)✓ (JsonElement)
숫자 정밀도임의 정밀(기본)설정 시 보존double 기본
sealed 패턴 매칭
주석·느슨한 파싱✗ (엄격)설정 가능일부
대표 용도설정 읽기·REST 응답 확인·작은 페이로드대규모 서비스 전반경량 범용

판단은 단순하다. POJO나 record로 매핑해야 하거나, 수십 MB짜리 응답을 스트리밍으로 훑어야 하면 Jackson이 정답이다. Spring Boot 4에서 Jackson 3로 넘어가며 달라진 부분이나 Jackson이 boolean 필드의 is를 떼어먹는 이유 같은 함정은 여전히 Jackson 생태계의 몫이다. 반대로 설정 파일 한 조각을 읽거나 REST 응답에서 필드 두어 개만 확인하는 거라면, 이제 라이브러리를 추가하지 않고 JEP 540으로 끝낼 수 있다. 좁은 도구를 좁은 자리에 쓰는 게 요점이다.

직접 돌려보려면 — JDK 28 EA와 인큐베이터 모듈

JDK 28 Early-Access 빌드로 시험해볼 수 있다. jdk.java.net/28에서 빌드를 받은 뒤, 먼저 java --list-modules | grep incubator.json으로 내 빌드에 모듈이 들어와 있는지 확인한다. JEP 540은 이제 막 JDK 28을 목표로 올라온 단계라, 인큐베이터 모듈은 특정 EA 빌드부터 포함된다. 여기서 한 가지 자주 헷갈린다. 인큐베이터 모듈은 프리뷰 기능이 아니라서 --enable-preview가 아니라 --add-modules로 켠다.

# 컴파일과 실행 모두 모듈을 명시적으로 추가한다
javac --add-modules jdk.incubator.json JsonDemo.java
java  --add-modules jdk.incubator.json JsonDemo
ShellScript

인큐베이터 모듈은 정식 표준이 되기 전 피드백을 받는 단계라, --add-modules를 빠뜨리면 jdk.incubator.json을 못 찾는다는 컴파일 에러가 난다. 또한 인큐베이터는 이름 그대로 API가 확정 전이라, 다음 빌드에서 메서드 시그니처가 바뀔 수 있다. 프로덕션에 바로 넣기보다는 사이드 프로젝트나 설정 로더 같은 저위험 지점에서 먼저 만져보는 걸 권한다. 정식 편입 시점에 패키지명이 jdk.incubator.json에서 바뀔 가능성도 열려 있으니, import 한 줄만 갈아끼우면 되게 얇게 감싸 두면 이전 비용이 거의 없다.

직접 돌려본 결과 — 참조 구현으로 실행

앞의 코드가 정말 도는지 확인하려고, OpenJDK의 jdk.incubator.json 참조 구현을 내려받아 JDK 28 EA로 컴파일해 실제로 돌려봤다. 파싱·값 접근·tryGet·생성·패턴 매칭·숫자 정밀도까지 이 글의 예제를 그대로 실행한 결과다.

실행에 쓴 전체 코드는 다음과 같다. --add-modules jdk.incubator.json으로 컴파일·실행하면 아래 캡처와 같은 출력이 나온다.

import jdk.incubator.json.*;
import java.util.List;
import java.util.Map;

public class JsonDemo {
    public static void main(String[] args) {
        // [1] 파싱 후 값 꺼내기
        JsonObject obj = (JsonObject) Json.parse(
            "{ \"service\": \"web_server\", \"id\": 3, \"active\": true }");
        System.out.println(((JsonString) obj.get("service")).value());   // web_server
        System.out.println(((JsonNumber) obj.get("id")).toNumber());      // 3

        // [2] tryGet — 없는 필드는 Optional
        String region = obj.tryGet("region")
                           .map(v -> ((JsonString) v).value())
                           .orElse("ap-northeast-2");
        System.out.println(region);                                       // ap-northeast-2

        // [3] 만들고 출력
        JsonObject doc = JsonObject.of(Map.of(
            "service", JsonString.of("web_server"),
            "tags", JsonArray.of(List.of(JsonString.of("prod"), JsonString.of("kr")))));
        System.out.println(doc.toString());                               // 압축 JSON
        System.out.println(Json.toDisplayString(doc));                    // 들여쓰기 출력

        // [4] sealed 패턴 매칭
        for (JsonValue v : List.of(Json.parse("[1,2,3]"),
                                   Json.parse("\"hi\""), Json.parse("true")))
            System.out.println(describe(v));

        // [5] 숫자 정밀도
        JsonNumber amount = (JsonNumber) ((JsonObject) Json.parse(
            "{ \"amount\": 12345678901234567890.12345 }")).get("amount");
        System.out.println(amount.toBigDecimal());                        // 원본 그대로
        System.out.println(amount.toBigDecimal().doubleValue());          // double 손실
    }

    static String describe(JsonValue v) {
        return switch (v) {
            case JsonObject o  -> "객체 " + o.members().size();
            case JsonArray a   -> "배열 " + a.values().size();
            case JsonString s  -> "문자열 " + s.value();
            case JsonNumber n  -> "숫자 " + n.toNumber();
            case JsonBoolean b -> "불리언 " + b.value();
            case JsonNull ig   -> "널";
        };
    }
}
Java

가장 눈여겨볼 곳은 [5]번 정밀도다. 12345678901234567890.12345toBigDecimal()로 읽으면 원본이 그대로 살아 있는데, 같은 값에 doubleValue()를 거는 순간 1.2345678901234567E19로 뭉개진다. [3]번에서 toString()은 압축 JSON을, Json.toDisplayString()은 들여쓰기된 형태를 뽑았고, [4]번 sealed 패턴 매칭은 default 절 없이 여섯 타입을 모두 처리했다.

한 가지 밝혀둔다. 지금 내려받을 수 있는 참조 구현(webrev)은 정식 스펙보다 한 세대 앞선 판이라 접근 메서드 이름이 member()·memberOrAbsent()였다. 그래서 현재 JEP 540 스펙의 get()·tryGet()으로 이어주는 얇은 별칭만 얹어 실행했다. 파싱·값 변환·숫자 정밀도 같은 실제 동작은 전부 참조 구현 그대로다. 정식 EA 빌드에 jdk.incubator.json 모듈이 포함되면 별칭 없이 그대로 돌아간다.

자주 묻는 질문 (FAQ)

JEP 540이 Jackson을 대체하나요?

아니다. JEP 문서가 데이터 바인딩(POJO·record 매핑)과 스트리밍을 명시적으로 비목표로 선언했다. JEP 540은 의존성 없이 가볍게 읽고 쓰는 용도이고, 객체 매핑이나 대용량 처리는 여전히 Jackson·Gson의 몫이다. 둘은 경쟁이 아니라 역할 분담이다.

왜 데이터 바인딩(객체 매핑)을 안 넣었나요?

API를 좁고 단순하게 유지하기 위해서다. 바인딩을 넣는 순간 애노테이션·리플렉션·타입 변환 규칙이 딸려와 “simple”이라는 정체성이 무너진다. JDK 팀은 트리 모델과 파싱·생성이라는 핵심만 표준화하고, 그 위의 편의는 생태계에 맡기는 쪽을 택했다.

대용량 JSON을 스트리밍으로 처리할 수 있나요?

할 수 없다. JEP 540은 문서 전체를 메모리에 트리로 올리는 방식이라, 수십 MB 이상의 응답에는 부적합하다. 이런 경우 Jackson의 스트리밍 파서(JsonParser)를 써야 한다. JEP 540은 설정 파일·소형 페이로드처럼 통째로 올려도 부담 없는 크기를 겨냥한다.

JSON 주석이나 느슨한 문법을 허용하나요?

아니다. RFC 8259를 엄격히 따르므로 주석, 후행 콤마, 작은따옴표 문자열 같은 확장 문법을 거부한다. 주석이 든 설정 파일을 다뤄야 한다면 JSON5나 HOCON을 지원하는 별도 라이브러리가 필요하다.

왜 정식이 아니라 인큐베이터인가요?

API 설계가 아직 확정 전이기 때문이다. 생성 API의 장황함(ceremony)처럼 피드백으로 다듬을 지점이 남아 있어, 인큐베이터로 먼저 공개해 사용자 반응을 모은다. 그래서 다음 빌드에서 메서드 시그니처나 패키지명이 바뀔 수 있고, --add-modules로 명시적으로 켜야 한다.

함께 읽으면 좋은 글

마치며 — 표준이 된다는 것의 무게

솔직히 처음 JEP 540을 봤을 때는 시큰둥했다. Jackson 하나면 20년째 아무 문제 없었으니까. 그런데 설정 파일 한 줄 읽자고 spring-boot-starter에 딸려온 Jackson을 쓰거나, 작은 CLI 도구에 Gson을 통째로 넣던 순간들이 떠올랐다. 그 자리에는 사실 이렇게 얇은 도구가 맞았다.

기대하는 건 화려한 기능이 아니라 정밀도와 예측 가능성이다. 숫자가 조용히 double로 뭉개지지 않고, sealed 타입 덕에 순회 코드에서 경우를 빠뜨리면 컴파일러가 막아준다. gettryGet만 처음부터 구분해 쓰면 운영에서 만나는 JSON 예외의 대부분이 사라진다. 인큐베이터라 시그니처가 흔들릴 수 있다는 점만 감안하면, 지금부터 사이드 프로젝트에서 만져보며 정식 편입을 기다리기 좋은 API다. 표준 라이브러리에 들어온다는 건 결국, 모두가 같은 방식으로 JSON을 읽게 된다는 뜻이다.