encoding/json 깊이 보기 - 태그·사용자 정의 변환·스트리밍
이 장에서 배우는 것
앞 장에서 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 를 구현했는지 확인한다. 구현했으면 그 메서드의 결과를 쓰고, 구현하지 않았다면 타입별 기본 규칙을 적용한다. 아래 그림은 이 순서를 나타낸다.
태그 옵션
구조체 태그(struct tag)는 필드 뒤의 백틱 문자열이다. 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 함수에는 옵션이 없고 디코더에만 있다.
자바스크립트 클라이언트가 읽는 값이라면 서버가 정확히 보내도 클라이언트의 수 타입이 같은 한계를 가진다. 이때는 ,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 없음 |
| 제로 시각이 나온다 | omitzero | Go 1.24 이상 | 첫 번째 출력에 assigned_at 없음 |
| 식별자 끝자리가 바뀐다 | ,string, UseNumber | float64 는 2^53 까지만 정확 | …992 와 …993 비교 |
| 상태를 이름으로 주고받는다 | Marshal·UnmarshalJSON | 값 수신자와 포인터 수신자 | status 가 "assigned" |
| 알 수 없는 필드가 통과한다 | DisallowUnknownFields | 디코더 옵션 | json: unknown field |
| 연속된 값을 읽어야 한다 | json.Decoder | io.EOF 까지 반복 | #1 부터 #4 까지 순서대로 |
세부 동작은 encoding/json 공식 문서에서 확인할 수 있다.
연습 문제
Fee int `json:"fee,string,omitempty"`필드가 있다.Fee가 3500 일 때와 0 일 때 각각 어떻게 출력되는가?- 배달원 이름을
Courier string `json:"courier,omitempty"`로 선언했더니 이름이 빈 문자열로 확정된 경우와 미배정인 경우를 JSON 에서 구별할 수 없다. 어떻게 바꾸겠는가? - 예약 배송일을
"2026-03-15"처럼 날짜만 주고받으려 한다.time.Time을 기반으로 한Day타입을 정의하고 두 메서드를 구현하라. json.Unmarshal([]byte(`{"a":1}{"b":2}`), &v)는 오류가 난다. 이 입력을 모두 읽으려면 어떻게 해야 하는가?
정답과 해설
- 3500 이면
"fee":"3500"으로 숫자가 따옴표에 싸여 나간다. 0 이면omitempty에 의해 키 자체가 생략된다. 태그 옵션은 함께 적용되고, 생략 판정이 먼저 이루어진다. 0원이라는 사실을 전달해야 하는 필드라면omitempty를 빼야 한다. - 필드를
*string으로 바꾼다. nil 은 미배정이라 생략되고, 빈 문자열을 가리키는 포인터는"courier":""로 나간다.omitempty는 포인터가 nil 인지만 본다. - 다음처럼 정의한다.
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를 건드리지 않는다. Unmarshal은 전체가 JSON 값 하나여야 한다.json.NewDecoder로 디코더를 만들고Decode를io.EOF가 나올 때까지 반복한다. 값이 줄 단위로 나뉘어 있지 않아도 디코더는 값의 경계를 스스로 찾으므로 두 객체를 차례로 읽을 수 있다.