Go에서 encoding/json으로 JSON 읽고 쓰기

1. 개요

JSON은 서비스 간의 가장 흔한 데이터 교환 형식입니다. Go는 표준 라이브러리의 encoding/json 패키지로 JSON을 기본 지원하므로 추가 설치가 필요하지 않습니다.

이 글은 struct를 JSON으로 변환하고 역으로 변환하는 방법을 배우게 합니다. struct tag로 필드 이름을 제어하고, 동적 JSON 처리와 대용량 데이터 스트리밍을 다룹니다. 커스텀 타입을 작성하고 테스트하는 방법을 배우게 되며, 글의 끝에서는 프로덕션에 배포할 때 피해야 할 일반적인 오류들을 다룹니다.

예제는 Go 1.24 이상을 사용합니다.

2. json.Marshal로 JSON 쓰기

고객 정보를 포함하는 주문으로 시작해봅시다:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Customer struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

type Order struct {
	ID        int      `json:"id"`
	Customer  Customer `json:"customer"`
	Items     []string `json:"items"`
	Total     float64  `json:"total"`
	Note      string   `json:"-"`
	createdBy string
}

func main() {
	order := Order{
		ID:        1001,
		Customer:  Customer{Name: "Lan"},
		Items:     []string{"Cà phê", "Bánh mì"},
		Total:     45000,
		Note:      "giao trước 9h",
		createdBy: "admin",
	}

	data, err := json.Marshal(order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

결과:

{"id":1001,"customer":{"name":"Lan"},"items":["Cà phê","Bánh mì"],"total":45000}

위 코드에서 주목할 점들입니다:

  • json.Marshal은 []byte와 error를 반환합니다. 항상 error를 확인해야 합니다.
  • json:"id" tag는 필드 이름을 JSON에서 id로 변경합니다. tag가 없으면 Go는 필드 이름 그대로 ID를 사용합니다.
  • Email이 비어있어서 omitempty 때문에 생략됩니다.
  • Note는 json:"-" tag 때문에 항상 제외됩니다.
  • createdBy는 소문자로 시작하므로 unexported입니다. encoding/json에서 이 필드를 볼 수 없습니다.
  • 베트남어 문자는 UTF-8 형식으로 유지되며 escape되지 않습니다.

읽기 쉬운 JSON이 필요한 경우, 예를 들어 로그를 작성하거나 설정 파일을 작성할 때는 json.MarshalIndent를 사용합니다:

data, _ := json.MarshalIndent(order.Customer, "", "  ")
fmt.Println(string(data))
{
  "name": "Lan"
}

3. Struct tag: 상세 출력 제어

Tag json은 json:"이름,옵션,..." 형식입니다. 자주 사용되는 옵션들입니다:

Tag 의미
json:"name" 필드 이름 변경
json:",omitempty" 필드가 zero value인 경우 제외: "", 0, false, nil, 빈 slice 또는 map
json:",omitzero" 필드가 zero value인 경우 제외, time.Time 같은 struct도 포함 (Go 1.24+)
json:",string" 숫자나 bool을 문자열로 작성
json:"-" 항상 제외

omitempty는 struct에 영향을 주지 않습니다. 아래 예제에서 차이를 확인할 수 있습니다:

type Shipment struct {
	OrderID   int64     `json:"order_id,string"`
	ShippedAt time.Time `json:"shipped_at,omitempty"`
	PaidAt    time.Time `json:"paid_at,omitzero"`
}

data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}

ShippedAt은 여전히 나타나는데, omitempty가 빈 time.Time struct를 ‘비어있는’ 것으로 간주하지 않기 때문입니다. PaidAt은 omitzero를 사용하므로 예상대로 제외됩니다. OrderID는 string 옵션 때문에 "1001" 문자열로 작성됩니다. JavaScript 클라이언트가 정확하게 64비트 정수를 읽을 수 없을 때 이 방법이 유용합니다.

4. json.Unmarshal로 JSON 읽기

반대 방향으로, 대상 변수에 포인터를 전달합니다:

input := []byte(`{
	"id": 1002,
	"Customer": {"name": "Minh", "email": "minh@example.com"},
	"items": ["Trà đào"],
	"total": 20000,
	"coupon": "SALE10"
}`)

var order Order
if err := json.Unmarshal(input, &order); err != nil {
	log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Trà đào] Total:20000 Note: createdBy:}

두 가지 기본 동작을 주목하세요:

  1. 키는 대소문자를 구분하지 않고 매칭됩니다. "Customer"는 여전히 tag customer와 매칭됩니다.
  2. "coupon" 같은 낯선 키는 조용히 무시됩니다. JSON에 없는 필드는 현재 값을 유지합니다.

주의: 포인터 대신 값을 전달하면, 예를 들어 json.Unmarshal(input, order)를 하면 json: Unmarshal(non-pointer main.Order) 오류가 발생합니다. 데이터가 어디에도 기록되지 않습니다.

4.1. 낯선 필드 거부

클라이언트로부터 요청을 받는 API에서는, 낯선 키를 무시하면 필드 이름 오타 오류를 숨길 수 있습니다. json.Decoder를 통해 엄격한 모드를 활성화할 수 있습니다:

dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()

var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"

DisallowUnknownFields는 Decoder에만 있으며, json.Unmarshal에는 동등한 옵션이 없습니다.

5. map[string]any를 사용한 동적 JSON

미리 알 수 없는 구조인 경우, 예를 들어 제3자 웹훅의 경우, map[string]any로 decode할 수 있습니다:

var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)

fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992

모든 숫자는 float64로 decode됩니다. 따라서 order_id는 정확도를 잃습니다: 원래 값의 끝은 3이지만 결과는 2입니다. 이는 매우 찾기 어려운 버그입니다.

해결책은 UseNumber를 호출하여 숫자를 json.Number 문자열로 유지하는 것입니다:

dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()

var payload map[string]any
_ = dec.Decode(&payload)

id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993

payload의 일부만 동적인 경우, json.RawMessage를 사용하여 해당 부분의 decode를 연기할 수 있습니다:

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

Type을 읽은 후, 적절한 struct와 함께 json.Unmarshal(e.Data, &order)를 호출합니다. 이 방법은 map의 각 키를 찾는 것보다 훨씬 타입 안전합니다.

6. Encoder와 Decoder로 데이터 스트리밍

Marshal과 Unmarshal은 []byte에서 작동하므로 모든 데이터가 메모리에 있어야 합니다. json.Encoder와 json.Decoder는 io.Writer와 io.Reader와 직접 작동합니다. 이들은 HTTP body, 파일 및 NDJSON 데이터(각 줄이 하나의 JSON)에 적합합니다.

func main() {
	input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)

	dec := json.NewDecoder(input)
	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)

	sum := 0.0
	for {
		var o Order
		err := dec.Decode(&o)
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			log.Fatal(err)
		}
		sum += o.Total
	}
	_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}

루프는 io.EOF를 만날 때까지 Decode를 호출하며, 각 번에 정확히 하나의 객체를 읽습니다. Encode는 각 값 뒤에 자동으로 개행 문자를 추가합니다. SetEscapeHTML(false)는 Go가 <, >, &를 \u003c, \u003e, \u0026로 변환하는 것을 방지합니다. 기본적으로 Go는 JSON을 HTML에 포함할 때 안전하도록 이 문자들을 escape합니다.

HTTP handler에서는 정확히 이 패턴을 사용합니다:

func createOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 최대 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var o Order
	if err := dec.Decode(&o); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(o)
}

http.MaxBytesReader는 body 크기를 제한합니다. 이렇게 하면 클라이언트가 거대한 payload를 보내 서버 메모리를 고갈시킬 수 없습니다.

7. 커스텀 타입

주문 상태가 code에서는 int로 저장되지만 JSON에서는 문자열로 표시되어야 한다고 가정합시다. encoding.TextMarshaler와 encoding.TextUnmarshaler를 구현합니다:

type Status int

const (
	StatusPending Status = iota
	StatusPaid
	StatusShipped
)

var statusNames = map[Status]string{
	StatusPending: "pending",
	StatusPaid:    "paid",
	StatusShipped: "shipped",
}

func (s Status) MarshalText() ([]byte, error) {
	name, ok := statusNames[s]
	if !ok {
		return nil, fmt.Errorf("status 무효: %d", s)
	}
	return []byte(name), nil
}

func (s *Status) UnmarshalText(b []byte) error {
	for k, v := range statusNames {
		if v == string(b) {
			*s = k
			return nil
		}
	}
	return fmt.Errorf("status 무효: %q", b)
}

struct에서 사용할 때:

type OrderStatus struct {
	ID     int    `json:"id"`
	Status Status `json:"status"`
}

data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}

MarshalText는 value receiver를 사용하므로 Status와 *Status 모두에서 작동합니다. UnmarshalText는 값을 수정해야 하므로 반드시 pointer receiver를 사용해야 합니다. json.Marshaler 대신 TextMarshaler를 사용하면 추가 이점이 있습니다: Status 타입을 map의 키로 사용할 수 있습니다, 예를 들어 map[Status]int

전체 객체를 제어해야 할 때는 MarshalJSON을 작성합니다. 무한 재귀 오류를 주의하세요: 내부에서 같은 타입으로 json.Marshal(o)를 호출하면 함수는 자신을 다시 호출합니다. 이를 처리하는 방법은 alias type을 사용하는 것입니다:

func (o Order) MarshalJSON() ([]byte, error) {
	type alias Order // alias는 MarshalJSON method가 없음
	return json.Marshal(struct {
		alias
		ItemCount int `json:"item_count"`
	}{alias(o), len(o.Items)})
}

alias는 Order와 같은 필드를 가지고 있지만 method가 없으므로 json.Marshal은 기본 동작을 사용합니다. embedded 필드는 외부 객체로 ’평탄화’되며, item_count가 추가됩니다.

8. 테스트 작성

round-trip 테스트(작성 후 다시 읽기)는 누군가 tag나 커스텀 로직을 변경할 때 오류를 잡는 데 도움이 됩니다. 파일 status_test.go:

package main

import (
	"bytes"
	"encoding/json"
	"testing"
)

func TestStatusRoundTrip(t *testing.T) {
	tests := []struct {
		status Status
		want   string
	}{
		{StatusPending, `{"id":1,"status":"pending"}`},
		{StatusPaid, `{"id":1,"status":"paid"}`},
		{StatusShipped, `{"id":1,"status":"shipped"}`},
	}

	for _, tt := range tests {
		t.Run(tt.want, func(t *testing.T) {
			data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
			if err != nil {
				t.Fatalf("marshal 오류: %v", err)
			}
			if string(data) != tt.want {
				t.Errorf("got %s, want %s", data, tt.want)
			}

			var back OrderStatus
			if err := json.Unmarshal(data, &back); err != nil {
				t.Fatalf("unmarshal 오류: %v", err)
			}
			if back.Status != tt.status {
				t.Errorf("got %v, want %v", back.Status, tt.status)
			}
		})
	}
}

func TestRejectUnknownField(t *testing.T) {
	dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
	dec.DisallowUnknownFields()

	var o OrderStatus
	if err := dec.Decode(&o); err == nil {
		t.Fatal("unknown field 오류를 기대했으나 nil을 받음")
	}
}
go test ./...
ok  	example.com/shop	0.003s

테이블 기반(table-driven) 테스트는 Go에서 일반적인 작성 방식입니다. 각 케이스는 별도의 t.Run에서 실행되므로, 한 케이스가 실패하면 어느 케이스가 잘못되었는지 즉시 알 수 있습니다.

9. 일반적인 오류

  • 필드가 JSON에 나타나지 않습니다. 거의 항상 필드 이름이 소문자(unexported)이기 때문입니다. 대문자로 변경하고 tag를 사용하여 JSON 이름을 지정하세요.

  • [] 대신 null입니다. nil slice는 null로 작성됩니다. 클라이언트가 빈 배열이 필요하면 Items: []string{}로 초기화하세요.

  • 큰 숫자의 정확도 손실. any로 decode할 때 발생합니다. 구체적인 타입의 struct(int64)를 사용하거나, UseNumber를 사용하거나, tag ,string을 사용하세요.

  • 타입 오류가 삼켜집니다. Unmarshal이 *json.UnmarshalTypeError를 만나면 다른 필드는 여전히 채워집니다. err != nil이면 그 struct을 사용하지 마세요.

  • []byte가 base64 문자열이 됩니다. 이는 의도된 동작입니다. 일반 텍스트를 원하면 string을 사용하세요.

  • time.Time은 RFC 3339입니다. dd/MM/yyyy 같은 다른 형식을 원하면 커스텀 타입을 작성해야 합니다.

  • MarshalJSON 내에서 같은 타입으로 json.Marshal을 호출합니다. 결과는 stack overflow입니다. 섹션 7처럼 alias type을 사용하세요.

참고: encoding/json은 reflection을 사용하므로 가장 빠른 선택이 아닙니다. 대부분의 API에서는 그 속도가 충분합니다. profiling에서 JSON이 병목임을 보여줄 때만 code generation 라이브러리를 고려해야 합니다. Go 1.25에도 실험 버전 encoding/json/v2가 있으며, GOEXPERIMENT=jsonv2로 활성화됩니다. 이 버전은 아직 안정적이지 않아 프로덕션에는 적합하지 않습니다.

10. 결론

이제 encoding/json의 주요 작업들을 완료했습니다:

  • 작은 데이터에는 Marshal과 Unmarshal을 사용합니다.
  • struct tag(omitempty, omitzero, string, -)를 사용하여 출력을 제어합니다.
  • DisallowUnknownFields와 UseNumber를 사용하여 데이터를 더 안전하게 읽습니다.
  • Encoder와 Decoder를 사용하여 데이터를 스트리밍합니다.
  • 커스텀 타입에는 TextMarshaler 또는 MarshalJSON을 사용합니다.

일반적인 원칙은 map[string]any 대신 명확한 타입의 struct를 우선시하는 것입니다. 그리고 항상 error를 확인합니다. 커스텀 타입에 대해 round-trip 테스트를 작성해야 합니다. 이 세 가지 습관은 Go로 작성한 서비스에서 대부분의 JSON 오류를 피하는 데 도움이 됩니다.

golangjsonencoding/json백엔드