Go JSON v2에서 달라진 기본값, API 계약을 다시 확인한다
Go JSON v2는 중복 이름을 거부하고 nil 목록과 필드 생략 규칙을 바꾼다. 기존 API의 데이터 계약에 미치는 영향과 스트리밍 변환의 이득, 호환성 옵션을 이용한 점진 전환 기준을 살펴본다.
Go의 JSON 패키지 import만 바꿨는데, 같은 구조체가 내보내던 null이 []로 바뀔 수 있다. 컴파일은 성공해도 응답을 받는 클라이언트의 분기는 달라진다. Go JSON v2를 검토할 때는 처리 속도와 함께, 어떤 입력을 받아들이고 어떤 응답을 약속했는지 다시 읽어야 한다.
Go 팀의 공식 설명 글은 2026년 11월 4일 공개됐고, GeekNews에는 5일 오전 7시 37분에 등록됐다. 피드 시각은 한국 기준이다. 발표는 encoding/json/v2와 encoding/json/jsontext가 Go 1.27에서 안정화됐다고 설명한다. 아래는 Go 1.27.3의 패키지 문서와 이전 안내를 읽은 분석이며, 코드를 실행하거나 성능을 재측정하지 않았다.
엄격한 기본값은 해석 차이를 줄이려는 선택이다
같은 JSON을 앞단의 검증 서버와 뒷단의 서비스가 다르게 이해하면 문제가 생긴다. 예를 들어 한 객체에 같은 이름이 두 번 나오면 어느 값을 사용할지 구현마다 다를 수 있다. Go 팀이 중복 이름을 기본적으로 거부하도록 바꾼 이유다. 잘못된 UTF-8도 기존 패키지는 대체 문자로 바꿨지만 v2는 오류로 보고한다. 변경 배경
기존 동작을 바로 고치지 못한 이유는 호환성이다. 오래된 프로그램이 이미 그 동작에 의존하므로, 오류를 고치는 변경도 정상 동작하던 프로그램을 깨뜨릴 수 있다. 별도 패키지 경로는 더 엄격한 기본값을 선택하면서 기존 사용자를 계속 지원하기 위한 경계다.
다만 엄격하다는 말이 모든 입력 검증을 대신하지는 않는다. v2는 JSON 이름과 구조체 필드 이름을 기본적으로 대소문자까지 맞춰 연결하지만, 알 수 없는 필드를 거부하는 것은 RejectUnknownMembers(true)로 따로 선택한다. 필수 필드나 금액의 허용 범위도 업무 규칙에 맞게 검증해야 한다. v2 패키지 문서
내가 주목한 부분은 실패를 명시적으로 드러내는 방향이다. 외부 요청이나 여러 언어 사이의 통신에서는 해석이 갈리는 입력을 일찍 거절할 근거가 된다. 반대로 오래된 생산자가 보낸 데이터를 계속 읽어야 한다면, 새 오류를 관찰하고 생산자를 고칠 순서까지 전환 계획에 들어가야 한다.
응답 모양은 컴파일 오류 없이 달라진다
이전의 어려움은 함수 이름보다 데이터의 의미에 있다. 공식 비교 문서에서 서비스 계약에 영향을 줄 만한 차이를 추리면 다음과 같다. 표는 기본 동작 기준이며 개별 옵션이나 사용자 정의 변환에 따라 달라질 수 있다.
| 대상 | 기존 v1 | v2 |
|---|---|---|
nil인 []string·map[string]string | null | 빈 배열·빈 객체 |
omitempty를 붙인 숫자 0·불리언 false | 필드 생략 | 값 포함 |
| 일반 맵의 출력 순서 | 결정적 순서 | 순서 비결정적 |
time.Duration | 나노초 단위 숫자 | 기본 표현이 없어 오류 |
omitempty는 v2에서 JSON으로 바꾼 결과가 null, 빈 문자열·객체·배열인지 판단한다. 숫자 0과 false는 여기에 해당하지 않는다. Go 값의 영값을 생략하려는 의도였다면 omitzero를 검토할 수 있다. 두 버전에서 같은 의미로 지원하지만, 기존 태그를 일괄 치환하기 전에 각 필드가 무엇을 표현하는지 확인해야 한다. 생략 규칙
가령 부분 수정 요청에서 필드 부재를 “유지”, false를 “해제”로 해석한다고 가정하자. 같은 구조체를 보내더라도 필드가 새로 포함되면 다른 변경 요청이 된다. 실제 장애 사례가 아니라 계약 검증에 넣을 반례다. nil 목록도 “아직 계산하지 않음”과 “계산했지만 결과 없음”을 구분해 왔다면 빈 배열로 바꿀 수 있는지 먼저 합의해야 한다.
맵의 키 순서는 JSON 객체의 의미를 바꾸지 않지만, 출력 바이트를 그대로 비교하는 테스트나 캐시 키에는 영향을 줄 수 있다. v2의 Deterministic 옵션으로 순서를 제어할 수 있다. 다만 결정적 출력과 서로 다른 구현 사이의 서명용 정규화는 같은 보장이 아니다. 비교 대상이 데이터인지 바이트인지부터 정하겠다. 출력 옵션과 주의점
스트리밍의 이득은 사용자 정의 변환까지 봐야 한다
새 구조는 JSON 문법을 읽고 쓰는 jsontext와, JSON을 Go 값에 대응시키는 v2를 나눈다. 문법 처리는 토큰이나 값 단위로 진행할 수 있다. 기존 MarshalJSON은 완성된 바이트 배열을 반환하고 UnmarshalJSON은 완성된 JSON 값을 받아야 했으므로, 임시 버퍼와 반복 파싱을 줄이는 데 API 자체의 한계가 있었다. 설계 설명
새 MarshalJSONTo와 UnmarshalJSONFrom은 인코더·디코더를 받아 처리한다. 복잡한 객체를 재귀적으로 변환하는 코드라면 import 변경에 그치지 않고 이 경로까지 살펴볼 이유가 있다. 그렇다고 결과를 큰 Go 구조체 하나에 담는 작업의 메모리까지 자동으로 사라지는 것은 아니다.
공식 발표는 역직렬화인 Unmarshal에서 벤치마크상 최대 10배 개선을 보고한다. 직렬화인 Marshal은 대체로 비슷하며 더 느린 경우도 있다고 밝힌다. 이를 API 전체의 예상 개선율로 가져오지는 않겠다. 대표 입력의 크기·중첩 깊이·사용자 정의 메서드를 고정하고 변환 시간과 할당량, 전체 요청 지연을 함께 비교해야 한다. 성능 주장의 범위
입력을 읽는 API의 경계도 확인할 만하다. v2의 UnmarshalRead는 공백을 제외한 JSON 값 하나를 요구하고 입력 끝인 EOF까지 소비한다. 기존 Decoder.Decode 한 번과 같은 전제로 바꾸면, 연속된 JSON 값을 읽던 경로나 끝나지 않는 스트림에서 동작이 달라질 수 있다. 입력 소비 계약 스트리밍 지원과 입력을 어디까지 읽을지는 별도 판단이다.
호출 지점 하나에서 호환성을 확인한다
기존 encoding/json은 계속 지원되므로 이전은 의무가 아니다. 또한 두 버전은 내부 구현을 공유하고 사용자 정의 메서드가 상호 동작하도록 설계됐다. 의존성 전체를 한 번에 바꾸지 않고 호출 지점별로 옮길 수 있다. 공식 이전 안내
나는 외부 경계 하나를 골라 다음 순서로 확인하겠다.
- 현재 응답과 요청에서 필드 부재·
null·빈 배열·0·false의 뜻을 적는다. 중복 이름, 잘못된 문자, 대소문자가 다른 이름도 검증 자료에 넣는다. - v2 API에
jsonv1.DefaultOptionsV1()을 전달해 기존 동작을 유지한 상태로 비교한다. 이후 호환성 옵션을 하나씩 바꿔 차이의 원인을 좁힌다. - 데이터 차이와 출력 순서만의 차이를 나누고, 실제 소비자의 처리 결과와 오류율을 확인한다. 그다음 변환 비용을 측정한다.
운영 입력에서 차이를 찾는 jsonsplit도 공식 가이드가 소개하는 선택지다. 두 버전을 실행해 비교하면서 기존 결과를 반환할 수 있다. 다만 직렬화를 두 번 하면 그 비용이 대략 두 배가 되고, 원인 옵션을 자동 탐색하면 추가 호출도 생긴다. 표본 비율과 관찰 기간을 정할 이유다. 병행 비교의 비용
새 API를 만드는 경우에는 엄격한 기본값에서 출발하고, 이미 공개된 API는 필요한 호환성을 명시하며 옮기는 편이 합리적이라고 본다. 전환의 완료 기준은 빌드 성공이 아니다. 새로 거절할 입력을 설명할 수 있고, 소비자가 해석하는 응답의 의미가 유지되며, 실제 병목에서 얻는 이득이 확인됐는가를 보겠다.
참고 자료
이 이야기가 도움이 되었나요?