본문으로 건너뛰기

JSON 인코딩

이 챕터에서 다루는 것

json.Marshal(v) 한 줄이면 끝나는 것처럼 보이지만, 실제 API를 만들면 태그 옵션 하나가 버그가 된다. omitempty가 의미 있는 0을 지워 버리는 문제가 대표적이다.

이 챕터는 태그 옵션 전부를 한 타입에서 비교하고, omitzero가 왜 따로 생겼는지, 커스텀 마셜러를 어디에 붙여야 하는지를 다룬다.

문제 — 구조체와 JSON은 모양이 다르다

Go 구조체는 PascalCase 필드에 정적 타입이고, JSON은 snake_case 키에 값이 없을 수도 있다. 그 간극을 메우는 것이 구조체 태그다.

5-5에서 태그가 그냥 문자열이고 리플렉션으로 읽힌다는 것을 봤다. encoding/json이 그 대표적인 소비자다.

태그 옵션 한눈에 보기

한 타입 안에 옵션을 전부 넣고 실제 출력을 본다.

examples/09-standard-library/04-json-encode/event/event.go
// 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 // 비공개 필드는 태그와 무관하게 무시된다
}

TagsPayload에 값을 채우고 LabelsResolvedZero는 비워 두면 이렇게 나온다.

{
"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는 중첩 객체가 되지 않고 필드가 위로 올라온다. hostomitempty라 사라졌다.
  • resolved_empty가 남아 있다. omitempty인데도 제로 time.Time이 나왔다. 아래에서 이유를 본다.
  • retries_empty가 없다. 값이 0이었고 omitempty가 지웠다.
  • retries_ptr0으로 남았다. 포인터가 0을 가리키고 있으니 omitzero 기준으로 제로가 아니다.
  • payload"cmF3"이다. []byte는 base64로 나간다.
  • seq가 문자열이다. ,string 옵션. 9007199254740993은 IEEE 754 double로 표현할 수 없어서, JS 클라이언트가 그냥 숫자로 받으면 9007199254740992가 된다.
  • "-": ""가 있다. json:"-,"는 "이름이 -인 필드"라는 뜻이다. json:"-"(쉼표 없음)과 완전히 다르다.
  • Internalsecret은 없다. 하나는 -로 막혔고, 하나는 비공개다.

omitempty vs omitzero

omitempty의 정의는 좁다. false, 0, nil 포인터/인터페이스, 그리고 길이가 0인 배열·슬라이스·맵·문자열만 생략한다.

time.Time{}은 이 목록 어디에도 없다. 구조체이고 길이 개념이 없으니 "비었다"고 판정되지 않는다. 그래서 위 출력에 "0001-01-01T00:00:00Z"라는 쓰레기 값이 남는다.

omitzero는 다르게 판정한다. 값에 IsZero() bool 메서드가 있으면 그것을 부르고, 없으면 타입의 제로값과 비교한다. time.Time에는 IsZero가 있다.

examples/09-standard-library/04-json-encode/event/event_test.go
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는 의미 있는 0false를 지운다.

type Product struct {
Name string `json:"name"`
Discount int `json:"discount,omitempty"` // 할인 0%가 사라진다
InStock bool `json:"in_stock,omitempty"` // 품절이 사라진다
}

"할인 없음"과 "할인 정보 없음"이 구별되지 않고, "품절"이 "재고 정보 없음"이 된다. 클라이언트가 기본값을 어떻게 정하느냐에 따라 조용히 잘못된 화면이 나온다.

해결책은 둘 중 하나다.

  1. 그냥 태그를 빼서 항상 내보낸다. 대부분 이게 정답이다.
  2. 정말 "값 없음"을 표현해야 하면 포인터 + omitzero를 쓴다.
examples/09-standard-library/04-json-encode/event/event_test.go
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)
}
}
omitemptyomitzero
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 옵셔널 필드다.

임베딩

임베딩된 구조체의 필드는 최상위로 올라간다. 중첩시키고 싶으면 이름 있는 필드로 만들어야 한다.

examples/09-standard-library/04-json-encode/event/event_test.go
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을 구현한다. 열거형이 전형적이다.

examples/09-standard-library/04-json-encode/event/event.go
// 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로 감싸여 올라온다.

examples/09-standard-library/04-json-encode/event/event_test.go
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-7errors.AsType이 그대로 쓰인다.

nil과 빈 값, 그리고 맵 순서

examples/09-standard-library/04-json-encode/main.go
// 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에 직접 쓴다.

examples/09-standard-library/04-json-encode/main.go
// 기본 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>"
}

두 가지를 기억한다.

  1. MarshalEncoder 둘 다 기본적으로 <, >, &를 이스케이프한다. JSON을 HTML 안에 직접 박아도 안전하게 하려는 조치다. API 응답에서는 불필요하지만 유효한 JSON이라 문제는 없다. 끄려면 Encoder를 써야 한다 — json.Marshal에는 끄는 방법이 없다.
  2. Encode는 값마다 개행을 붙인다. 그래서 NDJSON이 그냥 만들어진다.

인코딩이 실패하는 경우

examples/09-standard-library/04-json-encode/main.go
// 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/v2GOEXPERIMENT=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는 지워 버린다.
  • omitzeroIsZero()를 본다. 제로 구조체를 지우고 빈 슬라이스는 남긴다.
  • "값 없음"을 정말 표현해야 하면 포인터 + omitzero, 채울 때는 new(expr)을 쓴다. ptr[T] 헬퍼는 더 이상 필요 없다.
  • 임베딩된 필드는 최상위로 올라간다.
  • MarshalJSON은 값 리시버, UnmarshalJSON은 포인터 리시버. 반대로 하면 조용히 호출되지 않는다.
  • nil 슬라이스는 null, 빈 슬라이스는 []. 맵 키는 정렬되어 나가므로 JSON 출력에 골든 테스트를 걸 수 있다.
  • json.Encoder는 스트리밍용이고 값마다 개행을 붙인다. HTML 이스케이프는 기본으로 켜져 있고 Encoder에서만 끌 수 있다.
  • Marshal은 실패할 수 있다 — 채널, 순환 참조, NaN/Inf, 커스텀 마셜러.

연습문제

  1. EventLabelsmap[string]string{}(빈 맵)으로 채우고 출력해 보자. omitempty가 붙은 상태에서 나오는가? 태그를 omitzero로 바꾸면 어떻게 되는가? 이 차이를 설명하는 한 문장을 써 보자.

  2. SeverityMarshalJSON을 포인터 리시버(func (s *Severity))로 바꾸고 테스트를 돌려 보자. 어떤 테스트가 깨지고 어떤 테스트가 통과하는가? json.Marshal(event.Severity(99))는 왜 더 이상 에러를 내지 않는가?

  3. EventMarshalJSON을 추가해서, Tags가 nil일 때 []로 나가고 secret의 존재 여부가 has_secret 불리언 필드로 나가게 만들어 보자. 무한 재귀를 피하려면 무엇이 필요한가? 그리고 기존 태그 옵션들은 그대로 동작하는가?