JSON 인코딩
이 챕터에서 다루는 것
json.Marshal(v) 한 줄이면 끝나는 것처럼 보이지만, 실제 API를 만들면 태그
옵션 하나가 버그가 된다. omitempty가 의미 있는 0을 지워 버리는 문제가
대표적이다.
이 챕터는 태그 옵션 전부를 한 타입에서 비교하고, omitzero가 왜 따로 생겼는지,
커스텀 마셜러를 어디에 붙여야 하는지를 다룬다.
문제 — 구조체와 JSON은 모양이 다르다
Go 구조체는 PascalCase 필드에 정적 타입이고, JSON은 snake_case 키에
값이 없을 수도 있다. 그 간극을 메우는 것이 구조체 태그다.
5-5에서 태그가 그냥
문자열이고 리플렉션으로 읽힌다는 것을 봤다. encoding/json이 그 대표적인
소비자다.
태그 옵션 한눈에 보기
한 타입 안에 옵션을 전부 넣고 실제 출력을 본다.
// Event는 태그 옵션의 차이를 한 타입 안에서 비교하려고 만든 것이다.
type Event struct {
Meta // 임베딩: service와 host가 최상위 필드로 나간다
ID string `json:"id"`
Message string `json:"message"`
Sev Severity `json:"severity"`
At time.Time `json:"at"`
// omitempty는 time.Time의 제로값을 지우지 못한다. 구조체이기 때문이다.
ResolvedEmpty time.Time `json:"resolved_empty,omitempty"`
// omitzero는 IsZero()를 부르므로 제로 시각을 지운다.
ResolvedZero time.Time `json:"resolved_zero,omitzero"`
// 0이 의미 있는 값일 때 omitempty는 그것을 지워 버린다.
RetriesEmpty int `json:"retries_empty,omitempty"`
// 포인터 + omitzero면 "0회"와 "정보 없음"이 구분된다.
RetriesPtr *int `json:"retries_ptr,omitzero"`
Tags []string `json:"tags,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Payload []byte `json:"payload,omitempty"` // []byte는 base64로 나간다
// 숫자를 문자열로 감싼다. 큰 정수를 다루는 JS 클라이언트용.
Seq int64 `json:"seq,string"`
Internal string `json:"-"` // 절대 나가지 않는다
Dash string `json:"-,"` // 필드 이름이 진짜 "-"인 경우
secret string // 비공개 필드는 태그와 무관하게 무시된다
}
Tags와 Payload에 값을 채우고 Labels와 ResolvedZero는 비워 두면
이렇게 나온다.
{
"service": "checkout",
"id": "evt-1",
"message": "결제 \u003c실패\u003e \u0026 재시도",
"severity": "error",
"at": "2026-08-11T17:05:03Z",
"resolved_empty": "0001-01-01T00:00:00Z",
"retries_ptr": 0,
"tags": [
"payment",
"retry"
],
"payload": "cmF3",
"seq": "9007199254740993",
"-": ""
}
한 줄씩 읽어 볼 값어치가 있다.
service가 최상위에 있다. 임베딩된Meta는 중첩 객체가 되지 않고 필드가 위로 올라온다.host는omitempty라 사라졌다.resolved_empty가 남아 있다.omitempty인데도 제로time.Time이 나왔다. 아래에서 이유를 본다.retries_empty가 없다. 값이0이었고omitempty가 지웠다.retries_ptr은0으로 남았다. 포인터가0을 가리키고 있으니omitzero기준으로 제로가 아니다.payload가"cmF3"이다.[]byte는 base64로 나간다.seq가 문자열이다.,string옵션.9007199254740993은 IEEE 754 double로 표현할 수 없어서, JS 클라이언트가 그냥 숫자로 받으면9007199254740992가 된다."-": ""가 있다.json:"-,"는 "이름이-인 필드"라는 뜻이다.json:"-"(쉼표 없음)과 완전히 다르다.Internal과secret은 없다. 하나는-로 막혔고, 하나는 비공개다.
omitempty vs omitzero
omitempty의 정의는 좁다. false, 0, nil 포인터/인터페이스, 그리고 길이가
0인 배열·슬라이스·맵·문자열만 생략한다.
time.Time{}은 이 목록 어디에도 없다. 구조체이고 길이 개념이 없으니
"비었다"고 판정되지 않는다. 그래서 위 출력에 "0001-01-01T00:00:00Z"라는
쓰레기 값이 남는다.
omitzero는 다르게 판정한다. 값에 IsZero() bool 메서드가 있으면 그것을
부르고, 없으면 타입의 제로값과 비교한다. time.Time에는 IsZero가 있다.
func TestOmitemptyDoesNotOmitZeroTime(t *testing.T) {
data, err := json.Marshal(sample())
if err != nil {
t.Fatalf("Marshal() 에러 = %v", err)
}
s := string(data)
if !strings.Contains(s, `"resolved_empty"`) {
t.Error("omitempty가 제로 time.Time을 지웠다 — 지우지 못하는 게 정상이다")
}
if strings.Contains(s, `"resolved_zero"`) {
t.Error("omitzero가 제로 time.Time을 남겼다")
}
}
반대 방향의 함정이 더 위험하다. omitempty는 의미 있는 0과 false를
지운다.
type Product struct {
Name string `json:"name"`
Discount int `json:"discount,omitempty"` // 할인 0%가 사라진다
InStock bool `json:"in_stock,omitempty"` // 품절이 사라진다
}
"할인 없음"과 "할인 정보 없음"이 구별되지 않고, "품절"이 "재고 정보 없음"이 된다. 클라이언트가 기본값을 어떻게 정하느냐에 따라 조용히 잘못된 화면이 나온다.
해결책은 둘 중 하나다.
- 그냥 태그를 빼서 항상 내보낸다. 대부분 이게 정답이다.
- 정말 "값 없음"을 표현해야 하면 포인터 +
omitzero를 쓴다.
func TestOmitemptyEatsMeaningfulZero(t *testing.T) {
e := sample()
e.RetriesEmpty = 0
e.RetriesPtr = new(0) // Go 1.26: new가 식을 받는다
data, err := json.Marshal(e)
if err != nil {
t.Fatalf("Marshal() 에러 = %v", err)
}
s := string(data)
if strings.Contains(s, `"retries_empty"`) {
t.Error("omitempty는 0을 지운다 — 지워야 정상이다")
}
if !strings.Contains(s, `"retries_ptr":0`) {
t.Errorf("포인터 필드로 0이 보존되어야 한다: %s", s)
}
}
omitempty | omitzero | |
|---|---|---|
0, false, "" | 생략 | 생략 |
nil 포인터/맵/슬라이스 | 생략 | 생략 |
길이 0인 슬라이스 []T{} | 생략 | 남김 |
제로 구조체 (time.Time{}) | 남김 | 생략 |
IsZero()가 true인 값 | 남김 | 생략 |
[]T{} 행을 눈여겨본다. omitempty는 빈 슬라이스를 지우지만 omitzero는
남긴다 — 빈 슬라이스는 nil이 아니므로 제로값이 아니기 때문이다.
API가 "items": []를 보장해야 한다면 이 차이가 중요하다.
new(expr) — 포인터 필드를 채우는 방법
옵셔널 필드를 포인터로 만들면 값을 채울 때 주소가 필요하다. 리터럴에는
&를 붙일 수 없어서, 오랫동안 다들 이런 헬퍼를 들고 다녔다.
func ptr[T any](v T) *T { return &v } // 이제 필요 없다
e.RetriesPtr = ptr(0)
Go 1.26에서 new가 타입뿐 아니라 식도 받게 되면서 헬퍼가 사라졌다.
e.RetriesPtr = new(0) // *int
p := Person{Age: new(yearsSince(born))}
3-5에서 본 그 기능이고, 릴리스 노트가 동기로 든 사례가 정확히 이 JSON 옵셔널 필드다.
임베딩
임베딩된 구조체의 필드는 최상위로 올라간다. 중첩시키고 싶으면 이름 있는 필드로 만들어야 한다.
func TestEmbeddedFieldsArePromoted(t *testing.T) {
data, err := json.Marshal(sample())
if err != nil {
t.Fatalf("Marshal() 에러 = %v", err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Unmarshal() 에러 = %v", err)
}
if _, ok := m["service"]; !ok {
t.Errorf("임베딩된 service가 최상위에 없다: %s", data)
}
if _, ok := m["Meta"]; ok {
t.Error("임베딩은 중첩 객체를 만들지 않는다")
}
}
임베딩된 필드에 json:"meta" 태그를 붙이면 반대로 중첩 객체가 된다.
그리고 임베딩과 바깥 구조체에 같은 JSON 이름이 있으면
4-2의 깊이 규칙이 적용되어
얕은 쪽이 이긴다. 같은 깊이에 둘이면 양쪽 다 사라진다.
커스텀 마셜러
내부 표현과 외부 표현이 다를 때 MarshalJSON을 구현한다. 열거형이 전형적이다.
// MarshalJSON은 값 리시버다. Severity 값을 그대로 담아도, 포인터로 담아도
// 호출된다. 포인터 리시버로 만들면 값으로 담긴 필드에서는 호출되지 않는다.
func (s Severity) MarshalJSON() ([]byte, error) {
name, ok := severityNames[s]
if !ok {
return nil, fmt.Errorf("event: 알 수 없는 Severity %d", int(s))
}
return json.Marshal(name)
}
func (s *Severity) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err != nil {
return fmt.Errorf("event: Severity는 문자열이어야 한다: %w", err)
}
for k, v := range severityNames {
if v == name {
*s = k
return nil
}
}
return fmt.Errorf("event: 알 수 없는 Severity %q", name)
}
:::warning 리시버 종류가 결과를 바꾼다
MarshalJSON은 값 리시버로, UnmarshalJSON은 포인터 리시버로 쓴다.
MarshalJSON을 포인터 리시버로 만들면 Event.Sev 같은 값 필드를 인코딩할 때
호출되지 않는다. json.Marshal이 그 필드의 주소를 못 얻기 때문이다. 에러도
안 나고 그냥 기본 인코딩(숫자)이 나간다. 찾기 힘든 버그다.
UnmarshalJSON은 값을 바꿔야 하니 반드시 포인터 리시버다.
:::
마셜러가 낸 에러는 *json.MarshalerError로 감싸여 올라온다.
func TestSeverityMarshalRejectsUnknown(t *testing.T) {
_, err := json.Marshal(event.Severity(99))
if err == nil {
t.Fatal("알 수 없는 Severity는 에러여야 한다")
}
// Marshal은 사용자 마셜러의 에러를 *json.MarshalerError로 감싼다.
if _, ok := errors.AsType[*json.MarshalerError](err); !ok {
t.Errorf("err 타입 = %T, want *json.MarshalerError", err)
}
}
4-7의 errors.AsType이
그대로 쓰인다.
nil과 빈 값, 그리고 맵 순서
// demoNilVsEmpty는 nil 슬라이스와 빈 슬라이스가 다르게 나간다는 것이다.
func demoNilVsEmpty() error {
type box struct {
Nil []string `json:"nil"`
Empty []string `json:"empty"`
NilM map[string]int `json:"nil_map"`
EmpM map[string]int `json:"empty_map"`
Ptr *string `json:"ptr"`
Iface any `json:"iface"`
Deep map[string][]byte `json:"deep"`
}
b := box{
Empty: []string{},
EmpM: map[string]int{},
Deep: map[string][]byte{"k": []byte("hi")},
}
data, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("Marshal: %w", err)
}
fmt.Printf("nil vs 빈 값: %s\n\n", data)
return nil
}
nil vs 빈 값: {"nil":null,"empty":[],"nil_map":null,"empty_map":{},"ptr":null,"iface":null,"deep":{"k":"aGk="}}
nil 슬라이스는 null, 빈 슬라이스는 []. 클라이언트가
for (const x of data.items)를 돌리는데 null이 오면 터진다.
목록 API는 항상 make([]T, 0)으로 시작한다. 9-8이 그렇게 한다.
맵은 3-3에서 본 대로 순회 순서가 무작위지만,
encoding/json은 키를 정렬해서 내보낸다.
맵 1회차: {"alpha":2,"bravo":4,"mike":3,"zulu":1}
맵 2회차: {"alpha":2,"bravo":4,"mike":3,"zulu":1}
맵 3회차: {"alpha":2,"bravo":4,"mike":3,"zulu":1}
덕분에 JSON 출력에 골든 파일 테스트를 걸 수 있다. 8-4의 패턴이 JSON에도 통한다.
json.Encoder — 스트리밍과 HTML 이스케이프
json.Marshal은 전체를 []byte로 만든다. 큰 응답이나 NDJSON 스트림에는
json.Encoder를 쓴다. io.Writer에 직접 쓴다.
// 기본 Encoder는 HTML 특수문자를 이스케이프한다.
enc := json.NewEncoder(os.Stdout)
fmt.Println("Encoder 기본 (HTML 이스케이프 켜짐):")
for _, e := range events {
if err := enc.Encode(e.Message); err != nil {
return fmt.Errorf("Encode: %w", err)
}
}
fmt.Println("SetEscapeHTML(false) + SetIndent:")
enc = json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
for _, e := range events {
if err := enc.Encode(map[string]string{"id": e.ID, "message": e.Message}); err != nil {
return fmt.Errorf("Encode: %w", err)
}
}
Encoder 기본 (HTML 이스케이프 켜짐):
"첫째"
"둘째 \u003cb\u003e"
SetEscapeHTML(false) + SetIndent:
{
"id": "a",
"message": "첫째"
}
{
"id": "b",
"message": "둘째 <b>"
}
두 가지를 기억한다.
Marshal과Encoder둘 다 기본적으로<,>,&를 이스케이프한다. JSON을 HTML 안에 직접 박아도 안전하게 하려는 조치다. API 응답에서는 불필요하지만 유효한 JSON이라 문제는 없다. 끄려면Encoder를 써야 한다 —json.Marshal에는 끄는 방법이 없다.Encode는 값마다 개행을 붙인다. 그래서 NDJSON이 그냥 만들어진다.
인코딩이 실패하는 경우
// demoErrors는 인코딩이 실패하는 경우들이다.
func demoErrors() error {
// 1. 채널과 함수는 인코딩할 수 없다.
_, err := json.Marshal(struct {
C chan int `json:"c"`
}{})
fmt.Printf("채널 필드: %v\n", err)
// 2. 순환 참조는 무한 루프가 아니라 에러다.
type node struct {
Next *node `json:"next"`
}
n := &node{}
n.Next = n
_, err = json.Marshal(n)
fmt.Printf("순환 참조: %v\n", err)
// 3. NaN과 Inf는 JSON 숫자가 아니다.
_, err = json.Marshal(map[string]float64{"x": mustInf()})
fmt.Printf("Inf: %v\n", err)
// 4. 커스텀 마셜러가 낸 에러
_, err = json.Marshal(event.Severity(99))
fmt.Printf("커스텀 마셜러: %v\n", err)
return nil
}
채널 필드: json: unsupported type: chan int
순환 참조: json: unsupported value: encountered a cycle via *main.node
Inf: json: unsupported value: +Inf
커스텀 마셜러: json: error calling MarshalJSON for type event.Severity: event: 알 수 없는 Severity 99
json.Marshal의 에러를 무시하지 않는다. float64 계산 결과가 NaN이
되는 것은 흔한 일이고, 그때 응답 전체가 실패한다. 9-7에서 이 성질 때문에
"버퍼에 먼저 만들고 성공하면 내보낸다"는 패턴을 쓴다.
흔한 실수
1. 필드를 소문자로 두고 왜 안 나오는지 찾는다
encoding/json은 리플렉션을 쓰고, 리플렉션은 비공개 필드에 접근할 수 없다.
JSON에 나가야 하는 필드는 대문자로 시작한다. 태그는 이름만 바꿀 뿐이다.
2. omitempty를 습관적으로 붙인다
의미 있는 0, false, "", []가 사라진다. 붙이기 전에
"이 필드의 제로값이 정보인가?"를 묻는다.
3. omitempty가 제로 구조체를 지울 거라고 생각한다
time.Time{}이 "0001-01-01T00:00:00Z"로 나간다. omitzero를 쓴다.
4. MarshalJSON을 포인터 리시버로 만든다
값 필드에서 호출되지 않는다. 에러 없이 기본 인코딩이 나간다.
5. MarshalJSON 안에서 자기 타입을 json.Marshal한다
func (e Event) MarshalJSON() ([]byte, error) {
return json.Marshal(e) // 무한 재귀 → 스택 오버플로
}
type alias Event처럼 메서드가 없는 별칭 타입을 만들어 우회한다.
func (e Event) MarshalJSON() ([]byte, error) {
type plain Event // 메서드를 상속하지 않는다
return json.Marshal(plain(e))
}
6. nil 슬라이스를 목록 응답으로 내보낸다
null이 나간다. make([]T, 0)으로 시작한다.
7. int64를 그대로 JS 클라이언트에 보낸다
2^53을 넘는 정수는 JS에서 정밀도를 잃는다. ,string 옵션이나 문자열 필드를 쓴다.
:::note encoding/json/v2는 아직 실험 단계다
Go 1.26에도 encoding/json/v2는 GOEXPERIMENT=jsonv2가 있어야 쓸 수 있다.
이 플래그를 켜면 encoding/json의 구현까지 v2 엔진으로 바뀌고
encoding/json/jsontext가 노출된다. Go 1.27에서 기본이 될 방향으로 가고
있지만, 지금 프로덕션 코드를 v2로 쓰면 안 된다.
이 챕터가 가르친 것은 전부 지금의 encoding/json이고, v2에서도 대부분 그대로
통한다. 9-5 끝에서 v2가 무엇을 바꾸는지 한 번 더 짚는다.
:::
정리
- 태그 옵션: 이름,
omitempty,omitzero,-,-,,,string. 비공개 필드는 태그와 무관하게 나가지 않는다. omitempty는 좁게 정의된 "빈 값"만 지운다. 제로 구조체(time.Time{})는 못 지우고, 의미 있는0·false는 지워 버린다.omitzero는IsZero()를 본다. 제로 구조체를 지우고 빈 슬라이스는 남긴다.- "값 없음"을 정말 표현해야 하면 포인터 +
omitzero, 채울 때는new(expr)을 쓴다.ptr[T]헬퍼는 더 이상 필요 없다. - 임베딩된 필드는 최상위로 올라간다.
MarshalJSON은 값 리시버,UnmarshalJSON은 포인터 리시버. 반대로 하면 조용히 호출되지 않는다.- nil 슬라이스는
null, 빈 슬라이스는[]. 맵 키는 정렬되어 나가므로 JSON 출력에 골든 테스트를 걸 수 있다. json.Encoder는 스트리밍용이고 값마다 개행을 붙인다. HTML 이스케이프는 기본으로 켜져 있고Encoder에서만 끌 수 있다.Marshal은 실패할 수 있다 — 채널, 순환 참조,NaN/Inf, 커스텀 마셜러.
연습문제
-
Event에Labels를map[string]string{}(빈 맵)으로 채우고 출력해 보자.omitempty가 붙은 상태에서 나오는가? 태그를omitzero로 바꾸면 어떻게 되는가? 이 차이를 설명하는 한 문장을 써 보자. -
Severity의MarshalJSON을 포인터 리시버(func (s *Severity))로 바꾸고 테스트를 돌려 보자. 어떤 테스트가 깨지고 어떤 테스트가 통과하는가?json.Marshal(event.Severity(99))는 왜 더 이상 에러를 내지 않는가? -
Event에MarshalJSON을 추가해서,Tags가 nil일 때[]로 나가고secret의 존재 여부가has_secret불리언 필드로 나가게 만들어 보자. 무한 재귀를 피하려면 무엇이 필요한가? 그리고 기존 태그 옵션들은 그대로 동작하는가?