---
id: 61f2009278a5f7366f79395da59447cf
author: lemonmint
title: Go 1.25 encoding/json/v2 동작 변경점과 v1 호환성 레이어 분석
description: Go 1.25 실험 기능(goexperiment.jsonv2)으로 도입된 encoding/json/v2의 대소문자 구분 매칭, omitempty/omitzero 의미 변경, nil 슬라이스/맵 직렬화 및 옵션을 비교합니다.
language: ko
date: 2025-06-20T05:38:20.100520237Z
path: /blog/posts/go-1-25-encoding-json-v1-vs-v2-z74a36b08
category: release
---

Go의 `encoding/json` 패키지 v2는 기존 v1의 여러 단점(일관성 부족, 놀라운 동작, 성능 문제)을 개선하기 위한 새로운 구현입니다. 이는 `goexperiment.jsonv2` 빌드 태그를 통해 활성화되는 실험적인 기능입니다.

가장 중요한 점은 **v2가 활성화되면 v1은 v2 구현 위에 v1의 동작을 에뮬레이션하는 호환성 레이어로 동작한다**는 것입니다. 이는 `v2_v1.go` 파일의 `DefaultOptionsV1()` 함수를 통해 이루어집니다. 즉, v2는 v1의 동작을 완벽히 재현할 수 있는 옵션을 제공하며, 동시에 더 엄격하고 예측 가능한 새로운 기본 동작을 제시합니다.

v2의 주요 목표는 다음과 같습니다.

1. **정확성 및 예측 가능성 향상**: 기본적으로 더 엄격한 규칙(예: 대소문자 구분, 중복 키 금지)을 적용하여 예기치 않은 동작을 줄입니다.
2. **성능 개선**: 파싱 및 인코딩 엔진을 재설계하여 효율성을 높였습니다.
3. **유연성 및 제어권 확대**: 상세한 옵션 시스템을 도입하여 개발자가 JSON 처리 방식을 세밀하게 제어할 수 있도록 합니다.

---

## 주요 의미/동작 차이점

`v2_test.go` 파일을 중심으로 v1과 v2의 동작 차이를 항목별로 정리했습니다.

### 1. 필드 이름 매칭 (대소문자 구분)

- **v1 동작**: JSON 객체 멤버를 Go 구조체 필드에 언마샬링할 때, **대소문자를 무시(case-insensitive)**하여 매칭합니다. `"FirstName"`과 `"firstname"` 모두 `FirstName` 필드에 매핑됩니다.
- **v2 동작**: 기본적으로 **대소문자를 구분(case-sensitive)**하여 정확하게 일치하는 필드만 매핑합니다.
- **변경 이유**: 대소문자 무시 매칭은 예기치 않은 동작의 원인이 될 수 있고, 일치하지 않는 필드를 처리할 때 성능 저하를 유발합니다. v2는 더 명확하고 예측 가능한 동작을 기본으로 채택했습니다.
- **관련 옵션**: v2에서는 `json:"...,case:ignore"` 태그 옵션을 사용해 필드별로 대소문자 무시를 명시적으로 활성화하거나, `json.MatchCaseInsensitiveNames(true)` 옵션을 전역으로 적용할 수 있습니다.

### 2. `omitempty` 태그 옵션의 의미 변경

- **v1 동작**: Go 값의 "빈(empty) 상태"를 기준으로 필드를 생략합니다. 여기서 "빈 상태"란 `false`, `0`, `nil` 포인터/인터페이스, 길이가 0인 배열/슬라이스/맵/문자열을 의미합니다.
- **v2 동작**: 인코딩된 JSON 값의 "빈 상태"를 기준으로 필드를 생략합니다. 즉, `null`, `""`, `{}`, `[]`로 인코딩될 경우 생략됩니다.
- **변경 이유**: v1의 정의는 Go의 타입 시스템에 종속적입니다. v2는 JSON 타입 시스템을 기준으로 하여 더 일관된 동작을 제공합니다. 예를 들어 v1에서는 `bool` 타입의 `false` 값이 생략되지만, v2에서는 `false`는 비어있지 않은 JSON 값이므로 생략되지 않습니다. v2에서는 `omitzero` 옵션을 추가하여 v1의 `omitempty`가 `0`이나 `false`에 적용되던 동작을 대체할 수 있습니다.
- **관련 옵션**: v2에서 v1과 동일한 동작을 원할 경우 `json.OmitEmptyWithLegacyDefinition(true)` 옵션을 사용합니다.

### 3. `string` 태그 옵션의 동작 변경

- **v1 동작**: 숫자, 불리언, 문자열 타입 필드에 적용됩니다. 해당 값을 JSON 문자열 안에 다시 인코딩합니다 (예: `int(42)` -> `"42"`). 복합 타입(슬라이스, 맵 등) 내부의 값에는 재귀적으로 적용되지 않습니다.
- **v2 동작**: **숫자 타입에만 적용**되며, 재귀적으로 적용됩니다. 즉, `[]int`와 같은 슬라이스 내부의 숫자들도 모두 JSON 문자열로 인코딩됩니다.
- **변경 이유**: `string` 옵션의 주된 용도는 64비트 정수의 정밀도 손실을 막기 위해 숫자를 문자열로 표현하는 것입니다. v1의 동작은 제한적이고 일관성이 부족했습니다. v2는 이 핵심 용도에 집중하고 동작을 재귀적으로 확장하여 더 유용하게 만들었습니다.
- **관련 옵션**: `json.StringifyWithLegacySemantics(true)` 옵션으로 v1의 동작을 흉내 낼 수 있습니다.

### 4. `nil` 슬라이스 및 맵 마샬링

- **v1 동작**: `nil` 슬라이스와 `nil` 맵은 `null`로 마샬링됩니다.
- **v2 동작**: 기본적으로 `nil` 슬라이스는 `[]`(빈 배열), `nil` 맵은 `{}`(빈 객체)로 마샬링됩니다.
- **변경 이유**: `nil`은 Go 언어의 구현 세부사항이며, 언어에 구애받지 않는 JSON 형식에 이를 노출하는 것은 바람직하지 않습니다. 빈 컬렉션을 나타내는 `[]`나 `{}`가 더 보편적인 표현입니다.
- **관련 옵션**: v2에서 `json.FormatNilSliceAsNull(true)` 또는 `json.FormatNilMapAsNull(true)` 옵션을 통해 v1처럼 `null`로 마샬링할 수 있습니다.

### 5. 배열 언마샬링

- **v1 동작**: Go 배열(`[N]T`)로 언마샬링할 때, JSON 배열의 길이가 Go 배열의 길이와 달라도 오류를 발생시키지 않습니다. 길이가 짧으면 남는 공간은 제로 값으로 채우고, 길면 초과분은 버립니다.
- **v2 동작**: JSON 배열의 길이가 Go 배열의 길이와 **정확히 일치해야 합니다.** 그렇지 않으면 오류가 발생합니다.
- **변경 이유**: Go에서 고정 크기 배열은 그 길이가 중요한 의미를 갖는 경우가 많습니다. v1의 동작은 데이터의 소리 없는 손실을 유발할 수 있습니다. v2는 더 엄격한 규칙으로 정확성을 높였습니다.
- **관련 옵션**: `json.UnmarshalArrayFromAnyLength(true)` 옵션으로 v1의 동작을 흉내 낼 수 있습니다.

### 6. `time.Duration` 처리

- **v1 동작**: `time.Duration`은 내부적으로 `int64`로 취급되어 나노초 단위의 JSON 숫자로 인코딩됩니다.
- **v2 동작**: `time.Duration.String()` 메서드를 사용하여 `"1h2m3s"`와 같은 형식의 JSON 문자열로 인코딩됩니다.
- **변경 이유**: 숫자 나노초는 가독성이 떨어지며, `time.Duration`의 표준 문자열 표현이 더 유용합니다.
- **관련 옵션**: `json:",format:nano"` 태그 옵션이나 `json.FormatTimeWithLegacySemantics(true)` 옵션을 통해 v1의 동작을 사용할 수 있습니다.

### 7. 유효하지 않은 UTF-8 처리

- **v1 동작**: 마샬링/언마샬링 시 문자열 내에 유효하지 않은 UTF-8 바이트가 있으면 유니코드 대체 문자(`\uFFFD`)로 **조용히 교체**합니다.
- **v2 동작**: 기본적으로 유효하지 않은 UTF-8을 만나면 **오류를 반환**합니다.
- **변경 이유**: 데이터의 소리 없는 손상을 방지하고, 더 엄격한 JSON 표준(RFC 7493)을 따르기 위함입니다.
- **관련 옵션**: `jsontext.AllowInvalidUTF8(true)` 옵션을 통해 v1의 동작을 흉내 낼 수 있습니다.

### 8. 중복된 객체 멤버 이름 처리

- **v1 동작**: JSON 객체 내에 동일한 이름의 멤버가 중복되어 나타나는 것을 허용합니다. 마지막에 나타난 값으로 덮어씁니다.
- **v2 동작**: 기본적으로 중복된 멤버 이름이 있으면 **오류를 반환**합니다.
- **변경 이유**: RFC 8259 표준은 중복 이름의 동작을 정의하지 않아 구현마다 다르게 동작할 수 있습니다. 이는 보안 취약점의 원인이 될 수 있습니다. v2는 이를 명시적으로 거부하여 정확성과 보안을 높였습니다.
- **관련 옵션**: `jsontext.AllowDuplicateNames(true)` 옵션으로 v1의 동작을 흉내 낼 수 있습니다.

---

## 구현 및 아키텍처 차이점

- **v1**: `decode.go`의 `decodeState`와 `scanner.go`의 수동으로 작성된 상태 머신(state machine)에 크게 의존합니다. 이는 파싱 로직과 의미 분석이 강하게 결합된 모놀리식 구조입니다.
- **v2**: 아키텍처가 더 모듈화되었습니다.
  - **`encoding/json/jsontext`**: 저수준의 고성능 JSON 토크나이저(`Decoder`)와 인코더(`Encoder`)를 제공합니다. 이 패키지는 JSON의 구문(syntax)적인 측면에만 집중합니다.
  - **`encoding/json/v2`**: `jsontext`를 기반으로 Go 타입과 JSON 값 사이의 의미(semantic) 변환을 처리합니다.
  - 이러한 분리를 통해 구문 분석과 의미 분석이 분리되어 코드의 명확성과 성능이 향상되었습니다.

---

## v2의 새로운 API 및 기능

v2는 `json.Options` 시스템을 통해 매우 유연한 제어 기능을 제공합니다.

- **`json.Options`**: 마샬링/언마샬링 동작을 변경하는 옵션들의 집합입니다.
- **`json.JoinOptions(...)`**: 여러 옵션을 하나로 병합합니다.
- **`WithMarshalers` / `WithUnmarshalers`**: `Marshaler`/`Unmarshaler` 인터페이스를 구현하지 않고도 특정 타입에 대한 직렬화/역직렬화 로직을 주입할 수 있는 강력한 기능입니다. 이는 외부 패키지의 타입을 처리할 때 특히 유용합니다.
- **새로운 옵션들**: `RejectUnknownMembers`, `Deterministic(false)`, `FormatNilSliceAsNull` 등 v1에서는 불가능했던 다양한 동작 제어가 가능해졌습니다.

---

## 결론

`encoding/json` v2는 v1의 경험을 바탕으로 **정확성, 성능, 유연성**을 크게 향상시킨 현대적인 구현입니다. 기본 동작이 더 엄격해졌지만, 정교한 `Options` 시스템을 통해 v1의 모든 동작을 완벽하게 지원하므로 기존 코드와의 호환성을 유지하면서 점진적으로 v2의 장점을 도입할 수 있습니다.

- **새로운 프로젝트**라면 v2를 기본으로 사용하는 것이 좋습니다.
- **기존 프로젝트**는 `jsonv1`을 그대로 사용하거나, `jsonv2`로 마이그레이션하면서 `DefaultOptionsV1()`을 통해 점진적으로 v2의 엄격한 동작을 도입하는 전략을 사용할 수 있습니다.
