Devin.KR

Go · 심화

동시성과 서버 설계로 깊어지는 Go

encoding/json 깊이 보기 - 태그·사용자 정의 변환·스트리밍

구조체 태그(omitempty·string), MarshalJSON·UnmarshalJSON 구현, json.Decoder 스트리밍과 DisallowUnknownFields, 숫자 정밀도(UseNumber), time.Time 직렬화

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 net/http 로 라우팅과 미들웨어, 종료 처리를 갖춘 서버를 만들었다. 서버가 주고받는 데이터는 대부분 JSON 이다. 기본서에서는 구조체를 json.Marshal 로 바꾸고 json.Unmarshal 로 되돌리는 정도까지만 다뤘다. 이 장에서는 같은 패키지가 실제 중계 서비스에서 어떤 문제를 만드는지 보고, 그 문제를 푸는 도구를 하나씩 쓴다. 구조체 태그, 사용자 정의 변환, 스트리밍 디코딩, 숫자 정밀도, 시간 값 처리가 그 도구다.

  • 구조체 태그 omitempty, omitzero, string, - 의 동작과 한계를 설명할 수 있다.
  • MarshalJSON 과 UnmarshalJSON 을 올바른 수신자로 구현하고, 무한 재귀를 피할 수 있다.
  • json.Decoder 로 값이 이어진 스트림을 하나씩 읽고, DisallowUnknownFields 로 엄격한 입력 검사를 할 수 있다.
  • UseNumber 와 ,string 태그로 큰 정수가 깨지지 않게 다룰 수 있다.
  • time.Time 이 JSON 에서 어떤 형식으로 나오고 들어오는지 알고, 생략 규칙을 올바르게 적용할 수 있다.

문제 상황

중계 서비스에는 가게 쪽 시스템이 주문을 JSON 으로 보낸다. 운영을 시작하고 몇 주가 지나면 다음과 같은 일이 생긴다.

  • 배달원이 아직 정해지지 않은 주문인데 응답에 "courier":"" 가 들어가서, 앱이 빈 이름의 배달원이 있다고 해석한다.
  • 배정 시각이 없는 주문의 응답에 "assigned_at":"0001-01-01T00:00:00Z" 가 찍힌다.
  • 주문 번호가 16자리를 넘기 시작하자, 자바스크립트로 만든 점주용 화면에서 번호의 끝자리가 바뀌어 보인다.
  • 상태는 코드 안에서 정수 상수인데 외부에는 "assigned" 같은 이름으로 내보내야 한다.
  • 가게 시스템이 몰래 coupon 필드를 추가해 보냈는데 서버는 조용히 무시했고, 한 달 뒤에야 정산이 어긋난 것을 알았다.
  • 주문 수천 건이 줄마다 하나씩 붙은 파일을 한 번에 읽어 들였다가 메모리가 급히 늘었다.

모두 encoding/json 의 기본 동작을 모르고 쓸 때 생기는 문제다. 아래에서 원인을 하나씩 확인하고 고친다.

구조체 태그와 사용자 정의 변환

필드 하나가 JSON 이 되는 순서

인코더는 구조체 필드마다 먼저 태그를 보고 출력할지 결정한다. 출력하기로 했다면 그 타입이 json.Marshaler 를 구현했는지 확인한다. 구현했으면 그 메서드의 결과를 쓰고, 구현하지 않았다면 타입별 기본 규칙을 적용한다. 아래 그림은 이 순서를 나타낸다.

필드는 태그 검사, Marshaler 확인, 기본 규칙 순서로 JSON 값이 된다.

태그 옵션

구조체 태그(struct tag)는 필드 뒤의 백틱 문자열이다. json 키 안에서 쉼표로 이름과 옵션을 구분한다.

자주 쓰는 json 태그 옵션과 동작
태그효과대상주의
json:"name"JSON 키 이름 지정모든 필드생략하면 필드 이름을 그대로 쓴다
json:"-"입출력에서 제외모든 필드내부 전용 값에 쓴다
,omitempty비어 있으면 생략수, 불리언, 문자열, 슬라이스, 맵, 포인터, 인터페이스구조체 값은 비어 있다고 보지 않는다
,omitzero제로값이면 생략모든 타입Go 1.24 에서 추가됐다. IsZero 메서드가 있으면 그 결과를 쓴다
,string값을 문자열로 감싸 입출력정수, 부동소수, 불리언, 문자열입력도 따옴표로 감싸야 한다

omitempty 가 말하는 비어 있음은 값이 0, 빈 문자열, 길이 0인 슬라이스나 맵, nil 포인터라는 뜻이다. time.Time 은 구조체이므로 제로 시각이어도 비어 있다고 판정되지 않는다. 앞의 문제 상황에서 0001-01-01 이 찍힌 이유가 이것이다. 이런 필드에는 omitzero 를 쓴다. time.Time 은 IsZero 메서드를 가지고 있어서 제로 시각일 때 정확히 생략된다.

빈 값과 값 없음을 구별해야 하면 포인터를 쓴다. 배달원 이름이 *string 이면 nil 은 미배정이고, 가리키는 값이 빈 문자열이면 이름이 비어 있는 배정이다. omitempty 는 nil 포인터만 생략하므로 두 경우가 JSON 에서 구별된다.

인코더는 nil 슬라이스를 null 로, 빈 슬라이스를 [] 로 쓴다. 클라이언트가 항상 배열을 기대하면 서버 쪽에서 빈 슬라이스로 초기화해 두는 편이 안전하다.

MarshalJSON 과 UnmarshalJSON

내부에서는 정수 상수인 상태를 외부에는 이름으로 내보내려면 타입이 직접 변환을 정의해야 한다. MarshalJSON() ([]byte, error) 는 값을 JSON 조각으로 바꾸고, UnmarshalJSON([]byte) error 는 JSON 조각을 값으로 바꾼다. 수신자는 다르게 잡는다. 읽기만 하는 MarshalJSON 은 값 수신자로, 값을 채우는 UnmarshalJSON 은 포인터 수신자로 둔다. 인코딩 쪽을 포인터 수신자로 만들면 주소를 얻을 수 없는 값(맵 원소, 함수 반환값, 구조체를 값으로 넘긴 경우)에서는 메서드가 호출되지 않는다. 이 오류는 뒤의 실수 항목에서 다시 본다.

UnmarshalJSON 안에서 오류를 돌려주면 그 오류가 Decode 나 Unmarshal 의 반환값이 된다. 잘못된 상태 이름을 받아들이지 않고 명시적으로 거절하는 자리가 여기다.

time.Time 직렬화

time.Time 은 이미 MarshalJSON 과 UnmarshalJSON 을 구현하고 있다. 출력은 RFC 3339 형식이고 시간대 오프셋이 그대로 남는다. +09:00 으로 만든 시각은 같은 문자열로 나간다. 입력은 이 형식만 받는다. 2026-03-14 18:30 같은 공백 구분 표기는 오류가 된다. 파싱된 값은 입력의 오프셋을 가진 시각이므로, 서로 다른 오프셋으로 표현된 같은 순간은 == 가 아니라 Equal 로 비교한다. 화면 출력이나 저장 전에 UTC() 로 맞추면 형식이 일정해진다.

날짜만 쓰는 필드처럼 형식이 다르면 별도의 타입을 정의하고 위의 두 메서드를 구현한다. 이 장의 연습 문제에서 해 본다.

스트리밍과 숫자 정밀도

Decoder 로 값을 하나씩 읽기

json.Unmarshal 은 바이트 슬라이스 전체를 메모리에 올려 놓고 하나의 값으로 해석한다. 값 사이에 공백이나 개행만 있는 연속된 JSON 값(줄 단위 JSON 파일, 이벤트 스트림)에는 쓸 수 없다. 두 번째 값에서 오류가 난다. json.NewDecoder(r) 로 만든 디코더는 Decode 를 부를 때마다 값 하나를 읽고, 입력이 끝나면 io.EOF 를 돌려준다. 읽는 즉시 처리하고 버리면 파일이 커져도 메모리는 값 하나 분량만 쓴다. HTTP 요청 본문도 io.Reader 이므로 같은 방식으로 읽는다.

디코더가 한 번 읽은 값은 해석에 실패해도 입력에서 소비된다. 값 하나가 의미상 잘못된 경우(알 수 없는 상태 이름, 타입 불일치)에는 오류를 기록하고 다음 값으로 넘어갈 수 있다. 반면 *json.SyntaxError 는 입력의 구조가 깨졌다는 뜻이라 이어 읽을 기준점이 없으므로 중단해야 한다.

DisallowUnknownFields 를 켜면 대상 구조체에 없는 키를 만났을 때 json: unknown field "coupon" 형태의 오류가 난다. 기본 동작은 조용한 무시다. 외부 시스템이 보내는 입력을 받는 API 에서는 철자 오류나 새 필드를 일찍 발견하는 데 도움이 된다. 다만 필드를 추가하는 쪽이 먼저 배포되면 요청이 거절되므로, 호환 정책을 정한 뒤 켠다.

HTTP 본문에서는 값 하나 뒤에 다른 내용이 붙어 있는지도 확인하는 편이 좋다. 첫 Decode 뒤에 한 번 더 Decode 를 불러 io.EOF 가 나오는지 본다. 본문 크기는 http.MaxBytesReader 로 제한해 둔다.

큰 정수와 UseNumber

JSON 은 숫자의 크기와 정밀도를 규정하지 않는다. Go 는 any 로 디코딩할 때 모든 숫자를 float64 로 바꾼다. float64 는 2^53 까지만 정수를 정확히 표현하므로, 그보다 큰 주문 번호는 가까운 표현 가능한 값으로 바뀐다. 구조체 필드가 int64 라면 이런 손실은 없다. 문제가 되는 곳은 map[string]any 로 받아 필드를 열어 보지 않고 그대로 다른 곳에 전달하는 코드다.

디코더에 UseNumber 를 지정하면 숫자가 json.Number(문자열 기반 타입)로 담긴다. 원문 그대로 보관되므로 다시 인코딩해도 값이 같고, 필요할 때 Int64() 나 Float64() 로 변환한다. UseNumber 는 Unmarshal 함수에는 옵션이 없고 디코더에만 있다.

기본 설정은 큰 정수를 float64 로 바꾸며 끝자리를 잃고, UseNumber 는 문자열로 보관해 값을 지킨다.

자바스크립트 클라이언트가 읽는 값이라면 서버가 정확히 보내도 클라이언트의 수 타입이 같은 한계를 가진다. 이때는 ,string 태그로 번호를 문자열로 내보낸다. 이 태그를 붙인 필드는 입력에서도 따옴표로 감싼 숫자만 받는다.

숫자를 다루는 방법별 비교
방법JSON 에서의 모양정밀도쓰는 곳
int64 필드숫자Go 안에서는 정확서버가 내용을 해석할 때
int64 + ,string따옴표로 감싼 숫자클라이언트까지 정확식별자를 브라우저로 보낼 때
any (기본)숫자2^53 초과 시 손실작은 수만 오가는 임시 코드
any + UseNumber숫자원문 보존필드를 열지 않고 전달할 때

완성 코드

하나의 main.go 로 인코딩, 스트림 디코딩, 숫자 정밀도, HTTP 핸들러를 차례로 확인한다. 핸들러는 포트를 열지 않고 httptest 로 호출한다.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strconv"
	"strings"
	"time"
)

func check(err error) {
	if err == nil {
		return
	}
	fmt.Println("error:", err)
	os.Exit(1)
}

type Status int

const (
	StatusReceived Status = iota + 1
	StatusAssigned
	StatusDelivered
)

var statusNames = map[Status]string{
	StatusReceived:  "received",
	StatusAssigned:  "assigned",
	StatusDelivered: "delivered",
}

func (s Status) MarshalJSON() ([]byte, error) {
	name, ok := statusNames[s]
	if ok {
		return json.Marshal(name)
	}
	return nil, fmt.Errorf("status: unknown value %d", int(s))
}

func (s *Status) UnmarshalJSON(data []byte) error {
	var name string
	err := json.Unmarshal(data, &name)
	if err == nil {
		for v, n := range statusNames {
			if n == name {
				*s = v
				return nil
			}
		}
		return fmt.Errorf("status: unknown name %q", name)
	}
	return fmt.Errorf("status: string expected: %w", err)
}

type Item struct {
	Name string `json:"name"`
	Qty  int    `json:"qty"`
}

type Order struct {
	ID         int64     `json:"id,string"`
	Store      string    `json:"store"`
	Memo       string    `json:"memo,omitempty"`
	Courier    *string   `json:"courier,omitempty"`
	Items      []Item    `json:"items"`
	Status     Status    `json:"status"`
	PlacedAt   time.Time `json:"placed_at"`
	AssignedAt time.Time `json:"assigned_at,omitzero"`
	Internal   string    `json:"-"`
}

func demoEncode() {
	kst := time.FixedZone("KST", 9*60*60)
	o := Order{
		ID:       9007199254740993,
		Store:    "골목분식",
		Items:    []Item{{Name: "떡볶이", Qty: 2}},
		Status:   StatusReceived,
		PlacedAt: time.Date(2026, 3, 14, 18, 30, 0, 0, kst),
		Internal: "비밀",
	}
	b, err := json.Marshal(o)
	check(err)
	fmt.Println(string(b))

	courier := "민수"
	o.Courier = &courier
	o.Memo = "문 앞에 두기"
	o.Status = StatusAssigned
	o.AssignedAt = o.PlacedAt.Add(4 * time.Minute)
	b, err = json.Marshal(o)
	check(err)
	fmt.Println(string(b))
}

const orderStream = `{"id":"101","store":"골목분식","items":[{"name":"김밥","qty":3}],"status":"received","placed_at":"2026-03-14T09:30:00Z"}
{"id":"102","store":"한일약국","items":[],"status":"assigned","courier":"지수","placed_at":"2026-03-14T18:31:00+09:00"}
{"id":"103","store":"꽃집","items":[],"status":"lost","placed_at":"2026-03-14T18:32:00+09:00"}
{"id":"104","store":"꽃집","coupon":"X","items":[],"status":"received","placed_at":"2026-03-14T18:33:00+09:00"}
`

func demoStream() {
	dec := json.NewDecoder(strings.NewReader(orderStream))
	dec.DisallowUnknownFields()
	for n := 1; ; n++ {
		var o Order
		err := dec.Decode(&o)
		var syn *json.SyntaxError
		switch {
		case err == io.EOF:
			return
		case errors.As(err, &syn):
			fmt.Println("stream broken:", err)
			return
		case err == nil:
			fmt.Printf("#%d id=%d store=%s status=%s at=%s\n",
				n, o.ID, o.Store, statusNames[o.Status],
				o.PlacedAt.UTC().Format(time.RFC3339))
		default:
			fmt.Printf("#%d skip: %v\n", n, err)
		}
	}
}

func demoNumber() {
	const doc = `{"order_id":9007199254740993,"fee":3500}`

	var plain map[string]any
	check(json.Unmarshal([]byte(doc), &plain))
	f := plain["order_id"].(float64)
	fmt.Println("float64:", strconv.FormatFloat(f, 'f', -1, 64))

	dec := json.NewDecoder(strings.NewReader(doc))
	dec.UseNumber()
	var exact map[string]any
	check(dec.Decode(&exact))
	n := exact["order_id"].(json.Number)
	id, err := n.Int64()
	check(err)
	fmt.Println("json.Number:", n.String(), id)

	b, err := json.Marshal(plain)
	check(err)
	fmt.Println("다시 쓰기(float64):", string(b))
	b, err = json.Marshal(exact)
	check(err)
	fmt.Println("다시 쓰기(Number):", string(b))
}

func decodeStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	err := dec.Decode(v)
	if err == nil {
		if dec.Decode(&struct{}{}) == io.EOF {
			return nil
		}
		return errors.New("trailing data after JSON value")
	}
	return err
}

func writeJSON(w http.ResponseWriter, code int, v any) {
	body, err := json.Marshal(v)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	_, _ = w.Write(body)
}

func createOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	var o Order
	err := decodeStrict(r.Body, &o)
	if err == nil {
		o.Status = StatusReceived
		writeJSON(w, http.StatusCreated, o)
		return
	}
	writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}

func demoHandler() {
	bodies := []string{
		`{"id":"201","store":"골목분식","items":[{"name":"순대","qty":1}],"placed_at":"2026-03-14T18:40:00+09:00"}`,
		`{"id":"202","store":"꽃집","coupon":"X"}`,
		`{"id":"203","store":"꽃집","items":[],"placed_at":"2026-03-14T18:41:00+09:00"} {}`,
	}
	for _, body := range bodies {
		req := httptest.NewRequest(http.MethodPost, "/orders", strings.NewReader(body))
		rec := httptest.NewRecorder()
		createOrder(rec, req)
		fmt.Println(rec.Code, rec.Body.String())
	}
}

func main() {
	fmt.Println("== 1. 인코딩 ==")
	demoEncode()
	fmt.Println("== 2. 스트림 디코딩 ==")
	demoStream()
	fmt.Println("== 3. 숫자 정밀도 ==")
	demoNumber()
	fmt.Println("== 4. 핸들러 ==")
	demoHandler()
}

줄별 해설

Status 타입

statusNames 맵 하나가 두 방향의 변환 기준이다. MarshalJSON 은 값 수신자라서 Order 를 값으로 넘겨도 호출된다. 등록되지 않은 값이면 오류를 돌려주어 잘못된 숫자가 외부로 나가지 않게 한다. UnmarshalJSON 은 포인터 수신자이며, 먼저 문자열로 풀어 본 뒤 이름을 찾는다. 이때 json.Unmarshal 의 대상은 Status 가 아니라 string 이므로 같은 메서드가 다시 호출되지 않는다. 맵을 순회해 이름을 찾지만 이름이 서로 다르므로 결과는 항상 같다.

Order 태그

ID 의 ,string 은 번호를 따옴표로 감싼다. Memo 는 빈 문자열이면, Courier 는 nil 이면 생략된다. AssignedAt 은 구조체이므로 omitempty 가 아니라 omitzero 를 쓴다. Internal 은 - 로 입출력에서 빠진다. 출력에 "비밀"이 나타나지 않는 것으로 확인된다.

demoEncode

첫 번째 출력에는 memo, courier, assigned_at 이 없다. 같은 값에 배달원, 메모, 배정 시각을 채운 두 번째 출력에서 세 키가 나타나고 status 가 assigned 로 바뀐다. placed_at 의 +09:00 오프셋이 보존되며, 한글은 이스케이프 없이 그대로 나간다. 인코더가 이스케이프하는 문자는 <, >, & 등 일부뿐이다.

demoStream

orderStream 에는 JSON 객체 네 개가 줄마다 하나씩 있다. 디코더는 같은 Order 변수 선언을 반복마다 새로 만들어 이전 값이 남지 않게 한다. switch 는 입력 끝, 구문 오류, 성공, 그 밖의 오류 순으로 가른다. 1번은 UTC(Z) 시각이고, 2번의 18:31+09:00 은 같은 날 09:31Z 로 출력된다. 3번은 UnmarshalJSON 이 거절하고, 4번은 DisallowUnknownFields 때문에 거절된다. 두 경우 모두 값 하나가 소비되었으므로 루프는 이어서 입력 끝까지 읽는다.

demoNumber

숫자 9007199254740993 은 2^53 + 1 이어서 float64 로 정확히 표현되지 않고 2^53 으로 반올림된다. strconv.FormatFloat 에 'f' 와 정밀도 -1 을 주면 지수 표기 없이 최단 자릿수로 출력한다. UseNumber 로 읽은 쪽은 원문이 유지되어 Int64() 도 정확하다. 맵을 다시 인코딩하면 키는 정렬되어 나가고, 앞쪽은 이미 손실된 값이 나가며 뒤쪽은 원래 값이 나간다.

decodeStrict 와 핸들러

decodeStrict 는 미지의 필드를 거절하고, 값 뒤에 무언가 더 있으면 오류로 본다. 두 번째 Decode 가 io.EOF 를 돌려주면 뒤에 남은 것이 공백뿐이라는 뜻이다. createOrder 는 본문을 1MiB 로 제한하고, 성공하면 서버가 상태를 received 로 정해 201 로 응답한다. 응답은 json.Marshal 으로 먼저 만든 뒤 헤더와 함께 쓴다. 이렇게 하면 인코딩에 실패했을 때 이미 상태 코드를 보낸 뒤라 되돌릴 수 없는 일이 생기지 않는다. demoHandler 는 httptest.NewRecorder 로 응답을 받아 출력한다.

실행 결과

$ go run main.go
== 1. 인코딩 ==
{"id":"9007199254740993","store":"골목분식","items":[{"name":"떡볶이","qty":2}],"status":"received","placed_at":"2026-03-14T18:30:00+09:00"}
{"id":"9007199254740993","store":"골목분식","memo":"문 앞에 두기","courier":"민수","items":[{"name":"떡볶이","qty":2}],"status":"assigned","placed_at":"2026-03-14T18:30:00+09:00","assigned_at":"2026-03-14T18:34:00+09:00"}
== 2. 스트림 디코딩 ==
#1 id=101 store=골목분식 status=received at=2026-03-14T09:30:00Z
#2 id=102 store=한일약국 status=assigned at=2026-03-14T09:31:00Z
#3 skip: status: unknown name "lost"
#4 skip: json: unknown field "coupon"
== 3. 숫자 정밀도 ==
float64: 9007199254740992
json.Number: 9007199254740993 9007199254740993
다시 쓰기(float64): {"fee":3500,"order_id":9007199254740992}
다시 쓰기(Number): {"fee":3500,"order_id":9007199254740993}
== 4. 핸들러 ==
201 {"id":"201","store":"골목분식","items":[{"name":"순대","qty":1}],"status":"received","placed_at":"2026-03-14T18:40:00+09:00"}
400 {"error":"json: unknown field \"coupon\""}
400 {"error":"trailing data after JSON value"}

실무에서 자주 틀리는 것

시각 필드에 omitempty 를 붙인다

틀린 코드는 다음과 같다.

AssignedAt time.Time `json:"assigned_at,omitempty"`

구조체는 omitempty 의 비어 있음 판정 대상이 아니므로 미배정 주문에도 "assigned_at":"0001-01-01T00:00:00Z" 가 나간다. 고친 코드는 다음과 같다.

AssignedAt time.Time `json:"assigned_at,omitzero"`

Go 1.24 이전 버전과 호환해야 하면 *time.Time 에 omitempty 를 쓴다.

MarshalJSON 을 포인터 수신자로 만든다

틀린 코드는 다음과 같다.

func (s *Status) MarshalJSON() ([]byte, error) {
	return json.Marshal(statusNames[*s])
}

json.Marshal(o) 처럼 구조체를 값으로 넘기면 필드의 주소를 얻을 수 없어 이 메서드가 호출되지 않고, 상태가 1 같은 숫자로 나간다. 포인터로 넘길 때만 이름이 나오므로 호출 방식에 따라 결과가 달라지는 찾기 어려운 버그가 된다. 고친 코드는 다음과 같다.

func (s Status) MarshalJSON() ([]byte, error) {
	return json.Marshal(statusNames[s])
}

UnmarshalJSON 안에서 자기 타입으로 다시 Unmarshal 한다

틀린 코드는 다음과 같다.

func (e *Event) UnmarshalJSON(data []byte) error {
	err := json.Unmarshal(data, e)
	// 검증 로직 ...
	return err
}

e 의 타입이 *Event 이므로 Unmarshal 이 같은 메서드를 다시 부르고, 호출이 끝없이 이어져 스택이 넘친다. 같은 필드를 가지되 메서드가 없는 새 타입으로 우회한다.

func (e *Event) UnmarshalJSON(data []byte) error {
	type plain Event
	var p plain
	err := json.Unmarshal(data, &p)
	if err == nil {
		*e = Event(p)
	}
	return err
}

any 로 받은 숫자를 그대로 다시 내보낸다

틀린 코드는 다음과 같다.

var m map[string]any
json.Unmarshal(body, &m)
// m 을 수정 없이 다음 서비스로 전달

16자리가 넘는 정수 필드는 float64 를 거치며 끝자리가 바뀐 채 전달된다. 고친 코드는 다음과 같다.

dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var m map[string]any
err := dec.Decode(&m)

필드 구조를 알고 있다면 any 대신 구조체와 int64 로 받는 편이 더 낫다. 내용을 해석하지 않고 전달만 하는 코드라면 json.RawMessage 로 바이트를 그대로 들고 있는 방법도 있다.

한눈에 보기

이 장에서 다룬 문제와 해결 도구
문제도구핵심 규칙확인한 출력
값이 없는데 키가 나온다omitempty, *T구조체에는 효과가 없다첫 번째 출력에 memo 없음
제로 시각이 나온다omitzeroGo 1.24 이상첫 번째 출력에 assigned_at 없음
식별자 끝자리가 바뀐다,string, UseNumberfloat64 는 2^53 까지만 정확…992 와 …993 비교
상태를 이름으로 주고받는다Marshal·UnmarshalJSON값 수신자와 포인터 수신자status 가 "assigned"
알 수 없는 필드가 통과한다DisallowUnknownFields디코더 옵션json: unknown field
연속된 값을 읽어야 한다json.Decoderio.EOF 까지 반복#1 부터 #4 까지 순서대로

세부 동작은 encoding/json 공식 문서에서 확인할 수 있다.

연습 문제

  1. Fee int `json:"fee,string,omitempty"` 필드가 있다. Fee 가 3500 일 때와 0 일 때 각각 어떻게 출력되는가?
  2. 배달원 이름을 Courier string `json:"courier,omitempty"` 로 선언했더니 이름이 빈 문자열로 확정된 경우와 미배정인 경우를 JSON 에서 구별할 수 없다. 어떻게 바꾸겠는가?
  3. 예약 배송일을 "2026-03-15" 처럼 날짜만 주고받으려 한다. time.Time 을 기반으로 한 Day 타입을 정의하고 두 메서드를 구현하라.
  4. json.Unmarshal([]byte(`{"a":1}{"b":2}`), &v) 는 오류가 난다. 이 입력을 모두 읽으려면 어떻게 해야 하는가?

정답과 해설

  1. 3500 이면 "fee":"3500" 으로 숫자가 따옴표에 싸여 나간다. 0 이면 omitempty 에 의해 키 자체가 생략된다. 태그 옵션은 함께 적용되고, 생략 판정이 먼저 이루어진다. 0원이라는 사실을 전달해야 하는 필드라면 omitempty 를 빼야 한다.
  2. 필드를 *string 으로 바꾼다. nil 은 미배정이라 생략되고, 빈 문자열을 가리키는 포인터는 "courier":"" 로 나간다. omitempty 는 포인터가 nil 인지만 본다.
  3. 다음처럼 정의한다.
    type Day time.Time
    
    func (d Day) MarshalJSON() ([]byte, error) {
    	return json.Marshal(time.Time(d).Format(time.DateOnly))
    }
    
    func (d *Day) UnmarshalJSON(data []byte) error {
    	var s string
    	err := json.Unmarshal(data, &s)
    	if err == nil {
    		var t time.Time
    		t, err = time.Parse(time.DateOnly, s)
    		if err == nil {
    			*d = Day(t)
    		}
    	}
    	return err
    }
    
    time.Time 을 임베딩하지 않고 새 타입으로 정의한 이유는, 임베딩하면 time.Time 의 MarshalJSON 이 승격되어 내 구현 대신 쓰일 수 있기 때문이다. 해석 실패 시에는 *d 를 건드리지 않는다.
  4. Unmarshal 은 전체가 JSON 값 하나여야 한다. json.NewDecoder 로 디코더를 만들고 Decode 를 io.EOF 가 나올 때까지 반복한다. 값이 줄 단위로 나뉘어 있지 않아도 디코더는 값의 경계를 스스로 찾으므로 두 객체를 차례로 읽을 수 있다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.