Go 1.27 encoding/json/v2 및 jsontext 정식 출시 해부: v1과 달라진 6가지 보안·직렬화 기본값과 스트리밍 전환하기
Go 1.27에서 정식(GA) 표준 라이브러리로 채택된 encoding/json/v2와 encoding/json/jsontext의 구문·의미 분리 아키텍처를 정리한다. 중복 키·UTF-8·대소문자·nil 슬라이스 등 v1과의 6가지 기본 동작 차이와 점진적 마이그레이션 방법을 살펴본다.
구문 계층(encoding/json/jsontext)의 무할당 토큰 스트리밍과 의미 계층(encoding/json/v2)의 엄격한 중복 키·UTF-8·대소문자 검증이 분리되어 동작하는 Go 1.27 JSON 아키텍처 개념 일러스트다. 실제 도구 화면이 아니다.
6년간의 설계와 Go 1.25 실험 단계를 거친 encoding/json/v2와 encoding/json/jsontext 패키지가 Go 1.27에서 정식(GA) 표준 라이브러리로 채택되었다. 기존 encoding/json(v1)은 Go 1 호환성 보장 원칙 때문에 깨진 UTF-8 허용, 중복 키 허용, 대소문자 무시 매칭, UnmarshalJSON([]byte)의 2차(Quadratic) 재파싱 병목을 직접 고칠 수 없었는데, 별도의 v2 네임스페이스를 도입해 이를 근본적으로 해결했다. Go 공식 블로그 발표(A new Go API for JSON), encoding/json/v2 패키지 문서, encoding/json/jsontext 패키지 문서
결론부터 말하면 encoding/json/v2로의 전환은 단순한 속도 업그레이드가 아니라, 파서 간 해석 차이(Parser Differential)를 악용한 보안 허점을 닫고 구문(Syntax)과 의미(Semantics) 계층을 분리하는 아키텍처 전환이다. 다만 v2는 기본값으로 ① 중복 키와 잘못된 UTF-8을 즉시 에러로 거부하고, ② 구조체 필드명을 대소문자 구분(Case-sensitive)으로만 매칭하며, ③ nil 슬라이스·맵을 null 대신 []·{}로 직렬화하므로, 외부 클라이언트나 레거시 페이로드와 통신하는 코드에서 일괄 치환하면 런타임 동작이 달라질 수 있다. 다행히 Go 1.27에서는 기존 v1이 내부적으로 v2 옵션 조합 위에서 돌아가도록 재구현되어 호출 지점별로 점진적 마이그레이션이 가능하다.
이 글은 Go 공식 블로그 발표문과 encoding/json/v2·encoding/json/jsontext 공식 문서를 교차 검증해 정리한 엔지니어링 가이드다. 대규모 마이크로서비스의 프로덕션 처리량 벤치마크는 직접 측정하지 않았으며, 로컬 검증 코드는 v1과 v2의 6가지 핵심 동작 경계와 옵션 오버라이드 규칙을 확인한다.
1. 구문(jsontext)과 의미(json/v2)의 2계층 분리와 최대 10배 언마샬링 가속 원리
기존 encoding/json(v1)의 가장 큰 구조적 한계는 토큰 파싱(구문)과 Go 리플렉션 기반 타입 변환(의미)이 한 덩어리로 묶여 있었다는 점이다. 특히 커스텀 UnmarshalJSON([]byte) 메서드는 완전한 JSON 바이트 슬라이스를 요구하기 때문에, 라이브러리가 먼저 끝 위치를 찾으려고 전체 JSON을 한 번 스캔하고 커스텀 메서드 안에서 또 파싱을 수행해 중첩 구조체(예: Kubernetes OpenAPI 스키마 파싱 이슈 #315)에서 성능 저하가 발생했다.
Go 1.27은 이를 두 패키지로 명확히 나눴다.
| 계층 구분 | 하위 구문 계층: encoding/json/jsontext | 상위 의미 계층: encoding/json/v2 |
| 핵심 역할 | RFC 8259 JSON 문법에 따른 순수 토큰·값 인코딩 및 디코딩 (Encode / Decode) | JSON 데이터와 Go 타입(구조체, 슬라이스, 맵 등) 사이의 변환 및 매핑 (Marshal / Unmarshal) |
| Go 리플렉션 의존성 | 리플렉션 전혀 없음. 바이트 스트림과 불투명(Opaque) Token·Value 타입만 다루며 힙 할당 없이 토큰 스트리밍 수행 | 타입 매핑과 구조체 태그 해석을 담당하며, 내부 입출력은 모두 jsontext.Encoder·jsontext.Decoder 위에서 수행 |
| 스트리밍 인터페이스 | NewEncoder(io.Writer, ...Options), NewDecoder(io.Reader, ...Options)로 전체 버퍼링 없는 순수 스트리밍 지원 | MarshalerTo(MarshalJSONTo(*jsontext.Encoder) error) 및 UnmarshalerFrom(UnmarshalJSONFrom(*jsontext.Decoder) error) 도입 |
io.Reader 후행 쓰레기 데이터 처리 | Decoder.ReadValue()·ReadToken()으로 스트림 내 개별 JSON 값을 순차 처리 | UnmarshalRead(io.Reader, out, ...Options)가 io.EOF까지 읽으며 후행 쓰레기 토큰(Trailing junk) 발견 시 즉시 에러 반환 (기존 v1 NewDecoder(r).Decode(v)의 허점 해결) |
이 분리 덕분에 UnmarshalJSONFrom을 구현한 타입은 중간 []byte 할당이나 이중 스캔 없이 스트리밍 디코더에서 직접 토큰을 읽어 들일 수 있으며, 호출 스택 아래로 옵션(Encoder.Options())을 그대로 전파할 수 있어 언마샬링 성능이 v1 대비 최대 10배까지 향상된다.
2. 그냥 바꿨다간 장애 나는 v1 vs v2 6대 기본 동작 차이
encoding/json/v2는 보안 취약점과 데이터 손상을 막기 위해 v1의 느슨했던 기본 동작 6가지를 엄격하게 바꿨다.
| 비교 항목 | 기존 encoding/json (v1 기본값) | 신규 encoding/json/v2 (v2 기본값) | 보안·실무 영향 및 개별 제어 옵션 |
| 1. 객체 내 중복 키 (Duplicate Names) | 허용함 (예: {"role":"user","role":"admin"}에서 뒤쪽 값이 덮어씀) | 즉시 에러 반환 | 프록시와 백엔드 간 해석 차이(CVE-2017-12635류 권한 우회)를 원천 차단함. 불가피할 때만 jsontext.AllowDuplicateNames(true) 사용 |
| 2. 잘못된 UTF-8 바이트 | 허용함 (유니코드 대체 문자 U+FFFD로 조용히 치환해 데이터 변형 발생) | 무결성 깨짐 방지. 레거시 호환이 필요하면 jsontext.AllowInvalidUTF8(true) 지정 | |
| 3. 구조체 필드명 대소문자 매칭 | 대소문자 무시(Case-insensitive) 매칭이 기본값 (성능 저하 및 의도치 않은 필드 바인딩 유발) | 엄격한 대소문자 구분(Case-sensitive) 매칭이 기본값 | 언마샬링 속도 향상 및 예측 가능성 확보. 필드별 `json:",case:ignore"` 태그 또는 json.MatchCaseInsensitiveNames(true)로 예외 허용 가능 |
| 4. nil 슬라이스와 nil 맵 직렬화 | 둘 다 JSON null로 직렬화 | nil 슬라이스는 [], nil 맵은 {}로 직렬화 | 프론트엔드나 타 언어 클라이언트에서 null 참조 에러가 나던 고질적 문제 해결. 이전 동작은 FormatNilSliceAsNull(true), FormatNilMapAsNull(true)로 복원 |
| 5. omitempty의 기준과 omitzero | Go 타입의 제로값(Zero value) 기준 (단, 빈 구조체 struct{}{}는 생략되지 않는 비일관성 존재) | omitempty는 인코딩된 JSON 값이 비어 있는지(null, "", [], {}) 기준. Go 제로값 및 IsZero() bool 기준은 omitzero 태그로 분리 | time.Time{}이나 빈 구조체를 생략하려면 `json:",omitzero"` 또는 `json:",omitempty"`를 목적에 맞게 선택해야 함 |
| 6. time.Duration 기본 직렬화 | 정수 나노초(Nanoseconds) 숫자로 암묵적 직렬화 | 기본 표현 없음(에러 반환). 호출자나 구조체 태그에서 포맷을 명시해야 함 | 단위 혼동 사고 방지. 마이그레이션 시 time.Duration 필드가 있는 구조체는 반드시 포맷 옵션을 지정해야 함 |
3. 호환성 설계와 호출 지점별 점진적 마이그레이션 패턴
Go 1.27에서 가장 실용적인 설계는 기존 encoding/json(v1) 자체가 내부적으로 encoding/json/v2 엔진에 jsonv1.DefaultOptionsV1()을 넘겨 동작하도록 통합되었다는 점이다. 따라서 내 패키지가 v2를 쓰고 외부 라이브러리가 v1의 MarshalJSON을 구현했더라도, 혹은 그 반대(v1 호출자가 v2의 MarshalJSONTo 구현 타입을 직렬화)라도 완벽히 호환된다.
또한 호출자(Caller)가 타입 정의를 수정하지 않고도 특정 타입의 직렬화 방식을 외부에서 주입할 수 있는 WithMarshalers / WithUnmarshalers와 옵션 조합 패턴을 제공한다.
4. 로컬 검증 코드로 확인하는 v1 vs v2 보안·직렬화 경계
기존 v1 환경에서 통과하던 페이로드가 v2로 전환했을 때 어디서 거부되거나 값이 달라지는지 배포 전에 점검하기 위해, 네 가지 핵심 경계(잘못된 UTF-8, 중복 키 권한 덮어쓰기, 대소문자 필드 매칭, nil 컬렉션 출력)를 로컬 스크립트로 검증했다.
직접 실행해 확인한 범위
로컬 환경에서 위 네 가지 경계 시나리오를 실행해 모두 통과함을 확인했다.
- 깨진 UTF-8 바이트(
b'{"Role": "admin\xff"}'):v1모드는 대체 문자로 치환해 통과시키지만,v2모드는err_invalid_utf8로 즉시 거부한다.
- 중복 키 공격(
b'{"Role": "user", "Role": "admin"}'):v1모드는 마지막 값인"admin"을 조용히 채택하지만,v2기본 모드는err_duplicate_name:Role로 차단하며allow_dup=True(jsontext.AllowDuplicateNames(true))를 명시했을 때만 통과시킨다.
- 대소문자 불일치 키(
b'{"role": "admin"}'→ 구조체 필드Role): 태그 없이Role필드를 정의했을 때v1은 소문자"role"을 바인딩하지만,v2는 대소문자를 구분해 매칭하지 않는다(None).
nil슬라이스·맵 출력:v1은"null"을 내보내지만v2는"[]"와"{}"를 반환한다.
5. Go 1.27 encoding/json/v2 도입 전 점검 체크리스트
- 구조체
json태그의 대소문자 정확도 전수 확인:json:"..."태그를 생략했거나 JSON 키와 대소문자가 다른 필드가v1의 대소문자 무시 매칭 덕분에 우연히 동작하고 있지 않았는지 먼저 확인한다.
nil슬라이스·맵의null여부를 검사하는 스냅샷 테스트 점검: 응답 본문에서"items": null을 기대하던 기존 통합 테스트나 외부 계약이 있다면v2전환 시"items": []로 바뀌므로 테스트를 갱신하거나json.FormatNilSliceAsNull(true)옵션을 지정한다.
time.Duration필드 및 커스텀UnmarshalJSON전환: 구조체 내부에 별도 마샬러가 없는time.Duration필드가 있는지 검색하고, 대용량 중첩 JSON을 다루는 핫패스 타입은MarshalJSONTo·UnmarshalJSONFrom을 추가로 구현해 스트리밍 가속 효과를 얻는다.
참고 자료
확인 기준일: November 8, 2026
English version:
Go 1.27 encoding/json/v2 and jsontext GA: 6 Default Behavior Changes from v1 and True Streaming Migration