Go 언어는 1.0 출시 이전부터 표준 라이브러리에 encoding/json을 포함해 왔다. 임의의 Go 타입을 기본 규칙에 따라 JSON으로 직렬화하고, 필요하면 커스터마이징할 수 있는 이 설계는 15년 넘게 큰 틀에서 잘 버텨 왔다. 그러나 JSON이 인터넷의 사실상 표준 데이터 포맷으로 자리 잡는 동안 명세는 더 엄격해졌고, 사용자들은 기존 패키지의 한계를 꾸준히 지적해 왔다. Go 1.27은 그 누적된 요구에 대한 응답으로 encoding/json/v2와 encoding/json/jsontext라는 두 패키지를 정식으로 추가했다. 지난해 실험적 API로 공개된 뒤 피드백과 다듬기를 거쳐 안정 버전으로 일반 제공되기 시작한 것이다.
기존 encoding/json이 안고 있던 문제들
Go 팀이 정리한 기존 패키지의 결함은 크게 동작 방식, API 구조, 성능 세 갈래로 나뉜다. 동작 측면에서 가장 두드러지는 것은 JSON 문법 처리의 느슨함이다. 현재의 encoding/json은 유효하지 않은 UTF-8을 그대로 받아들이지만, 최신 인터넷 표준인 RFC 8259는 유효한 UTF-8을 요구한다. 조용히 데이터가 손상되는 대신 오류를 보고하는 편이 안전하다. 객체에 같은 이름의 멤버가 중복되는 경우도 마찬가지다. 중복 이름이 있는 JSON 값은 보편적으로 합의된 의미가 없어 구현마다 해석이 갈릴 수 있고, 실제로 CVE-2017-12635처럼 보안 공격에 악용된 전례가 있다.
이 밖에도 nil 슬라이스나 맵을 JSON null로 마셜링하는 동작은 null을 배열이나 객체로 받지 못하는 다른 구현과 통신할 때 오류를 유발한다. 언마셜링 시 JSON 멤버 이름과 Go 구조체 필드를 대소문자 구분 없이 매칭하는 기본 동작도 예상 밖이며 보안과 성능 양면에서 약점으로 꼽힌다. 또 포인터 리시버에 선언된 MarshalJSON 메서드가 일관되지 않게 호출되는 문제는 버그로 인정되지만, 이미 너무 많은 애플리케이션이 현재 동작에 의존하고 있어 고칠 수 없는 상태가 되어 버렸다.
API 자체의 제약도 적지 않다. io.Reader에서 올바르게 언마셜링하기가 까다로워 json.NewDecoder(r).Decode(v) 같은 코드는 입력 끝에 붙은 잉여 데이터를 걸러내지 못한다. Encoder와 Decoder에 설정한 옵션을 Marshal·Unmarshal 함수에서는 쓸 수 없고, 커스텀 메서드로 옵션을 전달할 방법도 없다. 성능 면에서는 MarshalJSON이 반환 []byte를 반드시 할당하게 만들고, UnmarshalJSON은 값의 경계를 찾느라 JSON을 두 번 파싱하게 만든다. 스트리밍이 사실상 불가능해 전체 값을 메모리에 버퍼링하며, 재귀 호출 시 성능이 제곱으로 나빠지는 문제까지 있다.
왜 수정이 아니라 v2인가
새로운 비호환 메이저 버전을 내놓는 일은 가볍게 결정할 사안이 아니다. Go 팀도 가능하면 기존 패키지를 고치는 쪽을 먼저 고려했다. 그러나 위 문제들은 기존 API의 구조에서 비롯된 것이라 Go 1 호환성 약속 안에서는 사실상 손댈 수 없다. MarshalV2 같은 이름을 같은 패키지 안에 병렬로 두는 방식도 결국 네임스페이스를 둘로 나누는 셈이어서, 별도의 encoding/json/v2 네임스페이스로 분리하는 결론에 이르렀다. 기존 패키지(v1)는 그대로 두고, 바뀐 동작은 v2에 담는 구조다.
문법과 의미를 나눈 jsontext와 v2
v2를 이해하는 열쇠는 Go 팀이 직렬화를 두 층으로 나눴다는 점이다. 문법만 다루는 일은 '인코딩/디코딩', 의미를 부여하는 일은 '마셜/언마셜'로 용어를 구분했다. 하위층인 jsontext는 RFC 8259가 문법을 JSON-text라 부르는 데서 이름을 따왔으며, 순수하게 문법 수준에서만 JSON을 다루기 때문에 리플렉션에 의존하지 않는다. 이 패키지의 Encoder와 Decoder는 v1과 달리 진정한 스트리밍 방식으로 동작한다. v1의 RawMessage는 이제 jsontext.Value의 타입 별칭이 되었고, 토큰은 할당 없이 임의의 JSON 토큰을 표현하도록 설계된 Token 타입으로 다룬다.
상위층인 encoding/json/v2는 이 토대 위에서 v1의 문제를 고치되 사용 경험은 최대한 비슷하게 유지한다. Marshal·Unmarshal은 v1과 유사한 시그니처에 옵션 인자를 받고, io.Writer나 io.Reader에 직접 동작하는 MarshalWrite·UnmarshalRead, jsontext 타입에 동작하는 MarshalEncode·UnmarshalDecode가 추가됐다. 핵심은 옵션이 일급 인자가 되어 호출 스택을 따라 전달된다는 점이다. 성능 문제의 뿌리였던 인터페이스 메서드를 대신해 Encoder·Decoder 위에서 스트리밍으로 동작하는 MarshalJSONTo와 UnmarshalJSONFrom이 도입됐고, MarshalerTo·UnmarshalerFrom 인터페이스와 WithMarshalers를 통한 타입별 커스텀 직렬화도 가능해졌다. 문서의 ProtoJSON 예시는 proto.Message 직렬화를 protojson에 위임하는 방식을 보여 준다.
마이그레이션 전에 알아둘 동작 변화
실무자가 반드시 유념할 부분은 v2의 기본 동작이 몇 가지 바뀌었다는 점이다. 유효하지 않은 UTF-8과 객체의 중복 이름은 이제 오류로 보고된다. nil 슬라이스와 맵은 각각 빈 JSON 배열과 객체로 마셜링되며, 구조체 필드 매칭은 대소문자를 구분한다. omitempty 태그는 필드가 null, "", [], {} 같은 '빈' JSON 값으로 인코딩될 때 생략하도록 재정의됐다. time.Duration은 기본 표현이 없어 직렬화 시 오류를 내고 대신 호출자가 표현을 고르도록 옵션을 제공한다. Go 팀은 v1 사용 코드를 v2로 직접 옮겨도 대체로 동일하게 동작하는 것을 목표로 삼았지만, 더 엄격한 검증과 바뀐 기본값은 기존 시스템과 주고받는 데이터의 호환성에 영향을 줄 수 있다. 따라서 외부 구현과 연동하는 서비스라면 UTF-8·중복 키 처리와 nil 직렬화 변화가 실제 트래픽에서 어떻게 드러나는지 점검한 뒤 전환하는 편이 안전하다.