JSON 디코딩
이 챕터에서 다루는 것
디코딩은 인코딩의 역방향이 아니다. 인코딩은 우리가 가진 값을 내보내는 일이고, 디코딩은 남이 보낸 것을 믿지 않고 읽는 일이다.
그래서 문제가 다르다. 필드가 빠졌는지, 오타가 났는지, 타입이 다른지, 스키마 자체가 값에 따라 달라지는지를 다뤄야 한다.
문제 — "안 왔다"와 "0이 왔다"
설정을 부분 수정하는 PATCH 요청을 받는다고 하자.
type Settings struct {
Theme string `json:"theme"`
FontSize int `json:"font_size"`
Telemetry bool `json:"telemetry"`
}
이 구조체에 {"telemetry": false}를 디코딩하면 Telemetry가 false가 된다.
그런데 {}를 디코딩해도 false다. "텔레메트리를 꺼 달라"와 "텔레메트리는
건드리지 마라"가 구별되지 않는다.
json.Unmarshal은 JSON에 없는 필드를 건드리지 않는다. 이 성질을 이용하려면
"건드리지 않았다"를 볼 수 있는 타입이어야 하고, 그게 포인터다.
포인터로 미지정을 구분한다
// Patch는 부분 수정 요청이다. 모든 필드가 포인터다.
// nil이면 "언급하지 않음", 값이 있으면 "이 값으로 바꿔라"다.
type Patch struct {
Theme *string `json:"theme"`
FontSize *int `json:"font_size"`
Telemetry *bool `json:"telemetry"`
}
적용은 nil 검사다.
// Apply는 언급된 필드만 바꾼 새 Settings를 돌려준다.
func Apply(cur Settings, p Patch) Settings {
if p.Theme != nil {
cur.Theme = *p.Theme
}
if p.FontSize != nil {
cur.FontSize = *p.FontSize
}
if p.Telemetry != nil {
cur.Telemetry = *p.Telemetry
}
return cur
}
실제 동작을 보면 의도한 대로다.
{} → {Theme:dark FontSize:14 Telemetry:true} 변경필드=[]
{"telemetry":false} → {Theme:dark FontSize:14 Telemetry:false} 변경필드=[telemetry]
{"theme":null} → {Theme:dark FontSize:14 Telemetry:true} 변경필드=[]
{"theme":"light","font_size":16} → {Theme:light FontSize:16 Telemetry:true} 변경필드=[theme font_size]
{"themee":"light"} → settings: 패치 파싱: json: unknown field "themee"
세 번째 줄을 보자. 명시적 null은 포인터를 nil로 남긴다. 즉 {"theme":null}과
{}가 같은 결과다. JSON Merge Patch 규격에서는 null이 "이 필드를 지워라"를
뜻하는데, 그 의미가 필요하면 json.RawMessage나 3상태 타입을 따로 만들어야 한다.
:::tip 언제 포인터를 쓰는가 모든 구조체를 포인터투성이로 만들 필요는 없다. PATCH 요청 본문처럼 "부분"이 개념적으로 존재하는 곳에서만 쓴다. 응답이나 전체 교체(PUT) 본문에는 필요 없다. 9-8의 API가 정확히 그렇게 나눠 놓았다. :::
DisallowUnknownFields — 오타를 잡는다
기본 동작은 모르는 필드를 조용히 버리는 것이다. 클라이언트가 themee라고
오타를 내면 서버는 아무 말 없이 무시하고, 클라이언트는 "왜 안 바뀌지"를
한 시간 동안 찾는다.
json.Unmarshal에는 이걸 켜는 방법이 없다. Decoder를 써야 한다.
// ParsePatch는 요청 본문을 Patch로 읽는다. 모르는 필드는 거부한다.
func ParsePatch(body []byte) (Patch, error) {
var p Patch
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return Patch{}, fmt.Errorf("settings: 패치 파싱: %w", err)
}
if dec.More() {
return Patch{}, fmt.Errorf("settings: 본문에 JSON 값이 두 개 이상 있다")
}
return p, nil
}
dec.More() 검사를 잊으면 안 된다. Decoder.Decode는 스트림에서 값 하나만
읽고 멈춘다. {"theme":"a"}{"theme":"b"}를 넘기면 첫 번째만 읽고 성공한다.
json.Unmarshal은 이걸 에러로 잡아 주지만 Decoder는 아니다.
:::warning 공개 API에 켤지 신중히 정한다
DisallowUnknownFields는 클라이언트가 새 필드를 먼저 보내기 시작하면
전부 400으로 깨진다. 내부 서비스나 설정 파일 파싱에는 좋고,
버전이 다른 클라이언트가 붙는 공개 API에는 위험하다.
:::
any로 받으면 무엇이 들어오는가
스키마를 모르면 map[string]any로 받게 된다. 그때 실제로 들어오는 Go 타입이
정해져 있다.
// demoAnyTypes는 any로 받았을 때 실제로 들어오는 Go 타입이다.
func demoAnyTypes() error {
const raw = `{"n":42,"big":9007199254740993,"s":"x","b":true,"nul":null,"arr":[1,2],"obj":{"k":1}}`
var v map[string]any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return fmt.Errorf("Unmarshal: %w", err)
}
for _, k := range []string{"n", "big", "s", "b", "nul", "arr", "obj"} {
fmt.Printf("%-4s %-24T %v\n", k, v[k], v[k])
}
// 큰 정수는 float64를 거치면서 정밀도를 잃는다. UseNumber로 막는다.
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
var v2 map[string]any
if err := dec.Decode(&v2); err != nil {
return fmt.Errorf("Decode: %w", err)
}
fmt.Printf("UseNumber big: %-16T %v\n\n", v2["big"], v2["big"])
return nil
}
n float64 42
big float64 9.007199254740992e+15
s string x
b bool true
nul <nil> <nil>
arr []interface {} [1 2]
obj map[string]interface {} map[k:1]
UseNumber big: json.Number 9007199254740993
| JSON | Go (any) |
|---|---|
| 숫자 | float64 |
| 문자열 | string |
| 참/거짓 | bool |
null | nil |
| 배열 | []any |
| 객체 | map[string]any |
모든 숫자가 float64다. 9007199254740993이 9007199254740992가 된 것을
보라 — 2^53을 넘는 정수는 조용히 값이 바뀐다. ID, 타임스탬프(나노초),
금액을 이렇게 받으면 안 된다.
Decoder.UseNumber()를 켜면 숫자가 json.Number(문자열의 별칭)로 들어와
Int64()나 String()으로 원본 그대로 꺼낼 수 있다.
디코딩 에러의 종류
에러를 하나로 뭉뚱그리지 말고 타입으로 구분하면, 사용자에게 훨씬 나은 메시지를 줄 수 있다. 9-8에서 이걸 그대로 쓴다.
inputs := []string{
`{"name":"a","age":"서른"}`, // 타입 불일치
`{"name":"a",`, // 문법 오류
`{"name":"a","age":30}`, // 정상
}
for _, in := range inputs {
var u user
err := json.Unmarshal([]byte(in), &u)
switch te, se := new(json.UnmarshalTypeError), new(json.SyntaxError); {
case err == nil:
fmt.Printf("정상: %+v\n", u)
case errors.As(err, &te):
fmt.Printf("타입 불일치: 필드 %q에 %s가 왔는데 %s가 필요하다 (offset %d)\n",
te.Field, te.Value, te.Type, te.Offset)
case errors.As(err, &se):
fmt.Printf("문법 오류: %v (offset %d)\n", se, se.Offset)
default:
fmt.Printf("기타: %v\n", err)
}
}
타입 불일치: 필드 "age"에 string가 왔는데 int가 필요하다 (offset 26)
문법 오류: unexpected end of JSON input (offset 12)
정상: {Name:a Age:30}
포인터 아님: json: Unmarshal(non-pointer main.user)
| 타입 | 언제 |
|---|---|
*json.SyntaxError | JSON 자체가 깨졌다 |
*json.UnmarshalTypeError | 값 타입이 안 맞는다. Field, Value, Type, Offset을 담고 있다 |
*json.InvalidUnmarshalError | 대상이 포인터가 아니거나 nil이다 — 이건 클라이언트 잘못이 아니라 내 버그다 |
io.ErrUnexpectedEOF | 본문이 중간에 끊겼다 |
io.EOF | 본문이 아예 비었다 |
마지막 줄의 json: Unmarshal(non-pointer main.user)는 호출자의 버그다.
json.Unmarshal은 값을 채워 넣어야 하므로 포인터가 필요하다.
다행히 go vet이 이 실수를 컴파일 전에 잡아 준다.
:::note 위 예제가 굳이 우회한 이유
예제 코드는 var target any = u를 거쳐 넘긴다. 직접 json.Unmarshal(b, u)라고
쓰면 go vet이 call of Unmarshal passes non-pointer as second argument로
빌드를 막기 때문이다. 실제 코드에서는 vet이 먼저 막아 준다는 뜻이기도 하다.
:::
필드 매칭은 대소문자를 구분하지 않는다
// demoCaseInsensitive는 필드 매칭이 대소문자를 구분하지 않는다는 것이다.
func demoCaseInsensitive() error {
type user struct {
UserName string `json:"user_name"`
}
for _, in := range []string{`{"user_name":"a"}`, `{"USER_NAME":"b"}`, `{"UserName":"c"}`, `{"username":"d"}`} {
var u user
if err := json.Unmarshal([]byte(in), &u); err != nil {
return fmt.Errorf("Unmarshal: %w", err)
}
fmt.Printf("%-20s → UserName=%q\n", in, u.UserName)
}
fmt.Println()
return nil
}
{"user_name":"a"} → UserName="a"
{"USER_NAME":"b"} → UserName="b"
{"UserName":"c"} → UserName=""
{"username":"d"} → UserName=""
정확한 규칙은 이렇다. 태그가 있으면 태그 이름과 대소문자 무시 비교, 없으면
Go 필드 이름과 대소문자 무시 비교. 태그가 user_name이니 USER_NAME은
맞고 UserName은 안 맞는다 — 밑줄 위치가 다르기 때문이다.
이 관대함이 DisallowUnknownFields와 겹치면 헷갈린다. UserName은
"모르는 필드"로 거부되지만 USER_NAME은 조용히 통과한다.
동적 스키마 — json.RawMessage
type 필드에 따라 data의 모양이 달라지는 메시지는 흔하다.
두 번 파싱하지 않고 처리하는 방법이 json.RawMessage다.
[]byte의 별칭인데, 디코딩할 때는 원본 바이트를 그대로 담고 인코딩할 때는
그 바이트를 그대로 내보낸다.
// Envelope는 겉봉투다. Data는 아직 해석하지 않는다.
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
// Decode는 봉투를 열고 type에 맞는 구체 타입으로 해석한다.
func Decode(data []byte) (Message, error) {
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("inbox: 봉투 파싱: %w", err)
}
switch env.Type {
case "text":
var m Text
if err := strictUnmarshal(env.Data, &m); err != nil {
return nil, fmt.Errorf("inbox: text 본문: %w", err)
}
return m, nil
case "image":
var m Image
if err := strictUnmarshal(env.Data, &m); err != nil {
return nil, fmt.Errorf("inbox: image 본문: %w", err)
}
return m, nil
case "":
return nil, fmt.Errorf("inbox: type 필드가 없다")
default:
return nil, fmt.Errorf("inbox: 알 수 없는 type %q", env.Type)
}
}
kind=text {Body:안녕}
kind=image {URL:/a.png Width:8 Height:4}
Decode 에러: inbox: 알 수 없는 type "video"
map[string]any로 받아서 필드를 하나씩 꺼내는 방식과 비교하면 차이가 분명하다.
RawMessage 방식은 구체 타입으로 한 번에 해석되고, 타입 검사와 필드 검증을
encoding/json이 대신해 준다. any 방식은 모든 접근이 타입 단언이라
실수하기 쉽다.
디코딩 실패 케이스도 테이블로 덮어 둔다.
func TestDecodeErrors(t *testing.T) {
tests := []struct {
name string
input string
wantSub string
}{
{"타입 없음", `{"data":{}}`, "type 필드가 없다"},
{"모르는 타입", `{"type":"video","data":{}}`, `알 수 없는 type "video"`},
{"모르는 필드", `{"type":"text","data":{"body":"x","extra":1}}`, "unknown field"},
{"본문 타입 불일치", `{"type":"image","data":{"width":"넓음"}}`, "cannot unmarshal"},
{"깨진 JSON", `{"type":`, "봉투 파싱"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := inbox.Decode([]byte(tt.input))
if err == nil {
t.Fatal("에러를 기대했다")
}
if !strings.Contains(err.Error(), tt.wantSub) {
t.Errorf("err = %q, want %q 포함", err, tt.wantSub)
}
})
}
}
스트리밍 디코딩
전체를 메모리에 올리지 않고 값을 하나씩 읽는 두 가지 형태다.
dec := json.NewDecoder(strings.NewReader(ndjson))
for {
var r row
err := dec.Decode(&r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("스트림 디코드: %w", err)
}
fmt.Printf("NDJSON row: %+v\n", r)
}
// 거대한 배열도 토큰 단위로 열어 원소를 하나씩 읽을 수 있다.
const arr = `[{"id":10},{"id":20},{"id":30}]`
dec = json.NewDecoder(strings.NewReader(arr))
if _, err := dec.Token(); err != nil { // 여는 대괄호
return fmt.Errorf("Token: %w", err)
}
for dec.More() {
var r row
if err := dec.Decode(&r); err != nil {
return fmt.Errorf("배열 원소: %w", err)
}
fmt.Printf("배열 원소: %+v\n", r)
}
if _, err := dec.Token(); err != nil { // 닫는 대괄호
return fmt.Errorf("Token: %w", err)
}
NDJSON row: {ID:1 Name:a}
NDJSON row: {ID:2 Name:b}
NDJSON row: {ID:3 Name:c}
배열 원소: {ID:10 Name:}
배열 원소: {ID:20 Name:}
배열 원소: {ID:30 Name:}
- NDJSON은 그냥
Decode를 반복하면 된다. 끝은io.EOF다. - 거대한 배열은
Token()으로 여는 대괄호를 소비하고,More()로 원소가 남았는지 물으면서Decode한다. 1GB짜리 배열도 원소 하나 크기의 메모리만 쓴다.
9-1에서 말한 "전체를 메모리에 올리지 않는 사고방식"이 JSON에도 그대로 있다.
재사용 시 슬라이스가 지워지지 않는다
Unmarshal은 대상을 초기화하지 않는다. 같은 변수를 재사용하면 이전 값이 남는다.
var u User
json.Unmarshal([]byte(`{"tags":["a","b","c"]}`), &u) // Tags = [a b c]
json.Unmarshal([]byte(`{"tags":["x"]}`), &u) // Tags = [x] — 길이는 줄지만
json.Unmarshal([]byte(`{"name":"n"}`), &u) // Tags = [x] — 그대로 남는다
맵도 마찬가지로 병합된다. 루프 안에서는 매번 var u User로 새로 선언한다.
위의 NDJSON 예제가 루프 안에 var r row를 둔 이유다.
흔한 실수
1. 포인터를 안 넘긴다
json.Unmarshal(b, v). go vet이 잡아 주지만, any를 거치면 못 잡는다.
2. 미지정과 제로값을 구분하지 못한다
PATCH 본문은 포인터 필드로 만든다.
3. Decoder.Decode 뒤에 More()를 확인하지 않는다
값 하나만 읽고 뒤의 쓰레기를 무시한다.
4. any로 받은 숫자를 int로 단언한다
id := v["id"].(int) // panic — 실제 타입은 float64다
float64로 받거나 UseNumber()를 쓴다.
5. 큰 정수를 any로 받는다
2^53을 넘으면 정밀도를 잃는다. 조용히 값이 바뀐다.
6. 루프 안에서 같은 변수에 계속 디코딩한다
슬라이스와 맵의 이전 내용이 남는다.
7. DisallowUnknownFields를 공개 API에 무심코 켠다
클라이언트가 새 필드를 보내기 시작하면 전부 400이 된다.
8. 사용자 입력에 크기 제한을 안 건다
json.NewDecoder(r.Body).Decode(&v)는 본문 크기만큼 메모리를 먹는다.
9-8에서 http.MaxBytesReader로 막는다.
:::note encoding/json/v2 — 아직 실험 단계
Go 1.26에서도 encoding/json/v2는 GOEXPERIMENT=jsonv2 없이는 쓸 수 없다.
플래그를 켜면 encoding/json의 구현이 v2 엔진으로 바뀌고
저수준 토큰 API인 encoding/json/jsontext가 노출된다.
Go 1.27에서 기본이 되는 방향이지만(GOEXPERIMENT=nojsonv2가 탈출구),
지금은 아니다.
v2가 손보려는 것 중 이 챕터와 겹치는 항목이 몇 개 있다.
- 대소문자 무시 매칭이 기본에서 빠진다. 위에서 본
USER_NAME이 통과하는 동작이 v1의 하위 호환 부담이다. nil슬라이스와 맵이null이 아니라[]/{}로 나간다 (선택 가능).- 옵션이 함수 인자로 전달되어,
DisallowUnknownFields같은 설정에Decoder를 거치지 않아도 된다. - 성능이 크게 개선된다. 특히 스트리밍 경로.
지금 배워야 할 것은 v1이다. 이 챕터의 내용은 v2에서도 대부분 그대로 통하고, 바뀌는 지점은 위 목록 정도다. :::
정리
Unmarshal은 JSON에 없는 필드를 건드리지 않는다. 그 성질을 이용해 포인터 필드로 "미지정"과 "제로값"을 구분한다. 명시적null도 nil이 된다.DisallowUnknownFields는Decoder에만 있다. 오타를 잡아 주지만 공개 API에서는 호환성을 깬다.Decode뒤More()확인을 잊지 않는다.- **
any로 받은 숫자는 전부float64**이고 2^53을 넘으면 값이 바뀐다.UseNumber()로json.Number를 받는다. - 에러는 타입으로 구분한다:
*json.SyntaxError,*json.UnmarshalTypeError(Field/Value/Type/Offset),*json.InvalidUnmarshalError,io.ErrUnexpectedEOF. - 필드 매칭은 대소문자를 구분하지 않는다. 태그가 있으면 태그 이름 기준이다.
json.RawMessage는 "나중에 해석"이다. 동적 스키마를 두 번 파싱하지 않고 구체 타입으로 처리하게 해 준다.Decoder는 스트리밍이다. NDJSON은Decode반복, 거대한 배열은Token()+More().- 디코딩 대상 변수를 재사용하지 않는다. 슬라이스와 맵의 잔여물이 남는다.
encoding/json/v2는 1.26에서 아직 실험 단계다. v1을 쓴다.
연습문제
-
Patch에 "이 필드를 지워라"라는 세 번째 상태를 추가해 보자.{"theme": null}이 "테마를 기본값으로 되돌려라"를 뜻하게 하려면*string으로는 부족하다.json.RawMessage를 쓰거나UnmarshalJSON을 직접 구현하는 두 방법을 각각 시도해 보고, 어느 쪽이 사용하는 쪽 코드를 더 단순하게 만드는지 판단해 보자. -
inbox.Decode는 새 메시지 타입을 추가할 때마다switch에 case를 늘려야 한다.map[string]func() Message레지스트리로 바꿔 보자. 타입 안전성을 얼마나 잃는가? 그 대가로 무엇을 얻는가? -
100만 개짜리 JSON 배열이 담긴 파일에서
"status":"error"인 원소만 세는 프로그램을 써 보자.Token()+More()방식과json.Unmarshal로 전부 읽는 방식의 메모리 사용량을 비교하려면 무엇을 측정해야 하는가? (힌트:runtime.ReadMemStats, 또는 8-5의-benchmem.)