Go JSON 처리 완전 가이드

Go JSON 처리 완전 가이드: encoding/json 구조체 매핑부터 커스텀 직렬화까지

이 글에서는 Go encoding/json 패키지를 단순 사용법을 넘어 내부 동작 원리부터 실무 심화 패턴까지 체계적으로 다룹니다. JSON 태그의 모든 옵션, 커스텀 직렬화·역직렬화 구현, null과 zero value의 구분, PATCH API를 위한 선택적 필드 패턴, 스트리밍 처리 성능 최적화, 그리고 HTTP 핸들러와의 통합 패턴까지 — 실제 API 서버를 구축하는 데 필요한 JSON 처리 기술을 모두 담았습니다.


1. encoding/json 패키지의 내부 동작

json.Marshaljson.Unmarshal은 Go의 reflect 패키지를 사용해 런타임에 타입 정보를 읽어 직렬화/역직렬화를 수행합니다. 이 동작 방식을 이해하면 성능 최적화와 커스터마이징의 근거가 명확해집니다.

1-1. Marshal 처리 순서

json.Marshal(v)는 다음 순서로 처리됩니다.

1. v가 json.Marshaler 인터페이스를 구현하는가? → MarshalJSON() 호출
2. v가 encoding.TextMarshaler 인터페이스를 구현하는가? → MarshalText() 호출
3. v의 타입을 reflect로 분석하여 JSON 변환
   - struct: 필드별 태그 읽기, 공개 필드만 처리
   - slice/array: JSON 배열
   - map: JSON 객체 (키는 string 또는 TextMarshaler)
   - pointer: nil이면 null, 아니면 역참조

이 우선순위 덕분에 커스텀 직렬화 로직을 타입에 직접 붙일 수 있습니다.

1-2. 공개 필드 규칙과 태그 우선순위

type Example struct {
    Exported   string // JSON에 포함됨 (대문자 시작)
    unexported string // JSON에서 완전히 무시됨 (소문자 시작)
    Tagged     string `json:"my_tag"` // "my_tag" 키로 직렬화
    Skip       string `json:"-"`      // JSON에서 제외
    SkipKeep   string `json:"-,"`     // "-" 키로 포함 (쉼표 주의)
}

소문자로 시작하는 필드는 reflect 패키지에서 접근 자체가 불가하므로, 태그가 있어도 완전히 무시됩니다. 이것이 왜 구조체 필드를 항상 대문자로 시작해야 하는지의 이유입니다.


2. JSON 태그 전체 옵션 정리

JSON 태그는 json:"이름,옵션1,옵션2" 형식으로 쉼표 구분자를 사용합니다.

태그 형식동작
json:"name"필드를 “name” 키로 직렬화
json:""필드명(소문자 변환 없이) 그대로 사용
json:"-"JSON 처리에서 완전 제외
json:"-,"”-” 키로 직렬화 (제외 아님)
json:"name,omitempty"zero value일 때 JSON 출력에서 생략
json:"name,string"값을 JSON 문자열로 강제 변환 (숫자 → “123”)
json:",omitempty"이름은 필드명 유지, omitempty만 적용

2-1. omitempty 주의사항

omitempty는 Go의 zero value 기준으로 동작합니다.

type Response struct {
    Count   int     `json:"count,omitempty"`   // 0이면 생략
    Message string  `json:"message,omitempty"` // ""이면 생략
    Active  bool    `json:"active,omitempty"`  // false이면 생략 ⚠️
    Score   float64 `json:"score,omitempty"`   // 0.0이면 생략 ⚠️
    Tags    []string `json:"tags,omitempty"`   // nil 또는 빈 슬라이스면 생략
}

Active: falseScore: 0.0처럼 의미 있는 zero value가 생략되는 문제가 있습니다. 이 경우 포인터를 사용해야 합니다.

type Response struct {
    Active *bool    `json:"active,omitempty"`  // nil이면 생략, false면 포함
    Score  *float64 `json:"score,omitempty"`   // nil이면 생략, 0.0이면 포함
}

2-2. string 옵션: 숫자를 문자열로

JavaScript의 number 타입은 53비트 정수까지만 정확하므로, 64비트 ID는 문자열로 전송하는 것이 안전합니다.

type Post struct {
    ID        int64  `json:"id,string"`    // {"id": "9007199254740993"}
    AuthorID  int64  `json:"author_id,string"`
    Title     string `json:"title"`
    ViewCount int64  `json:"view_count,string"` // 큰 숫자도 안전
}

🛠️ Twitter(현 X)는 2010년대 초에 id 필드가 JavaScript에서 잘리는 버그를 겪은 뒤, API에 id_str 필드를 추가했습니다. Go에서는 string 태그 옵션으로 이 문제를 원천 차단할 수 있습니다.


3. null vs zero value: 포인터 패턴

API에서 “필드가 없음”과 “필드가 null”을 구분해야 하는 경우, 포인터 타입이 필수입니다.

// JSON: {"name": "홍길동"} → Age는 없음 (제공 안 됨)
// JSON: {"name": "홍길동", "age": null} → Age는 명시적으로 null
// JSON: {"name": "홍길동", "age": 0} → Age는 0

type UserUpdate struct {
    Name  *string `json:"name,omitempty"`  // nil = 업데이트 안 함
    Age   *int    `json:"age"`             // nil = null (명시적 null 허용)
    Email *string `json:"email,omitempty"`
}

func applyUpdate(current *User, update UserUpdate) {
    if update.Name != nil {
        current.Name = *update.Name // 역참조로 실제 값 추출
    }
    if update.Age != nil {
        current.Age = *update.Age
    }
    if update.Email != nil {
        current.Email = *update.Email
    }
}

이 패턴은 REST API의 PATCH 요청 처리에서 특히 중요합니다. PUT은 전체 리소스를 교체하지만, PATCH는 제공된 필드만 업데이트해야 하기 때문입니다.


4. 커스텀 직렬화: json.Marshaler / json.Unmarshaler

json.Marshalerjson.Unmarshaler 인터페이스를 구현하면 타입의 JSON 변환 로직을 완전히 제어할 수 있습니다.

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

4-1. time.Time 커스텀 포맷

time.Time의 기본 JSON 표현은 RFC 3339 형식입니다. 특정 포맷이 필요하면 래퍼 타입을 만듭니다.

// "2006-01-02" 형식의 날짜 타입
type Date struct {
    time.Time
}

func (d Date) MarshalJSON() ([]byte, error) {
    return json.Marshal(d.Format("2006-01-02"))
}

func (d *Date) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return fmt.Errorf("날짜 형식 오류 (YYYY-MM-DD 필요): %w", err)
    }
    d.Time = t
    return nil
}

type Event struct {
    Title    string `json:"title"`
    Date     Date   `json:"date"`      // "2026-01-03" 형식
    StartsAt time.Time `json:"starts_at"` // RFC 3339 기본 형식
}

4-2. enum 타입의 JSON 문자열 변환

정수 상수(iota)를 JSON에서 문자열로 표현하는 패턴입니다.

type OrderStatus int

const (
    OrderPending OrderStatus = iota
    OrderProcessing
    OrderCompleted
    OrderCanceled
)

var orderStatusNames = map[OrderStatus]string{
    OrderPending:    "pending",
    OrderProcessing: "processing",
    OrderCompleted:  "completed",
    OrderCanceled:   "canceled",
}

var orderStatusValues = map[string]OrderStatus{
    "pending":    OrderPending,
    "processing": OrderProcessing,
    "completed":  OrderCompleted,
    "canceled":   OrderCanceled,
}

func (s OrderStatus) MarshalJSON() ([]byte, error) {
    name, ok := orderStatusNames[s]
    if !ok {
        return nil, fmt.Errorf("알 수 없는 OrderStatus: %d", s)
    }
    return json.Marshal(name)
}

func (s *OrderStatus) UnmarshalJSON(data []byte) error {
    var name string
    if err := json.Unmarshal(data, &name); err != nil {
        return err
    }
    val, ok := orderStatusValues[name]
    if !ok {
        return fmt.Errorf("알 수 없는 OrderStatus 값: %q", name)
    }
    *s = val
    return nil
}

type Order struct {
    ID     int64       `json:"id"`
    Status OrderStatus `json:"status"` // 숫자 대신 "pending" 등 문자열
}

5. json.RawMessage: 지연 파싱과 다형성 처리

json.RawMessage[]byte의 별칭으로, JSON 파싱을 나중으로 미루거나 JSON을 그대로 보존할 때 사용합니다.

5-1. 타입에 따라 다른 구조체로 파싱 (다형성)

type Event struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"` // 타입에 따라 다른 구조체
}

type UserCreatedPayload struct {
    UserID int64  `json:"user_id"`
    Email  string `json:"email"`
}

type OrderPlacedPayload struct {
    OrderID int64   `json:"order_id"`
    Total   float64 `json:"total"`
}

func processEvent(data []byte) error {
    var event Event
    if err := json.Unmarshal(data, &event); err != nil {
        return fmt.Errorf("이벤트 파싱 실패: %w", err)
    }

    switch event.Type {
    case "user.created":
        var payload UserCreatedPayload
        if err := json.Unmarshal(event.Payload, &payload); err != nil {
            return fmt.Errorf("user.created 페이로드 파싱 실패: %w", err)
        }
        fmt.Printf("새 사용자: %d, %s\n", payload.UserID, payload.Email)

    case "order.placed":
        var payload OrderPlacedPayload
        if err := json.Unmarshal(event.Payload, &payload); err != nil {
            return fmt.Errorf("order.placed 페이로드 파싱 실패: %w", err)
        }
        fmt.Printf("새 주문: %d, %.2f\n", payload.OrderID, payload.Total)

    default:
        return fmt.Errorf("알 수 없는 이벤트 타입: %s", event.Type)
    }
    return nil
}

5-2. 알 수 없는 필드 보존

API 프록시처럼 JSON을 파싱 후 수정하여 다시 전달해야 할 때 유용합니다.

// 특정 필드만 수정하고 나머지는 그대로 전달
func enrichJSON(input []byte, extraField string) ([]byte, error) {
    var raw map[string]json.RawMessage
    if err := json.Unmarshal(input, &raw); err != nil {
        return nil, err
    }

    extraJSON, _ := json.Marshal(extraField)
    raw["processed_by"] = extraJSON

    return json.Marshal(raw)
}

6. json.Decoder / json.Encoder: 스트리밍 처리

json.Marshal/Unmarshal은 전체 데이터를 메모리에 올린 후 처리합니다. http.Request.Body나 대용량 파일처럼 스트림에서 직접 읽거나 써야 할 때는 json.Decoder/Encoder를 사용해야 합니다.

6-1. HTTP 핸들러에서의 표준 패턴

// JSON 요청 읽기 헬퍼
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    // 요청 크기 제한 (1 MB)
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // 정의되지 않은 필드 거부

    if err := dec.Decode(dst); err != nil {
        var syntaxErr *json.SyntaxError
        var typeErr *json.UnmarshalTypeError

        switch {
        case errors.As(err, &syntaxErr):
            return fmt.Errorf("JSON 구문 오류 (위치 %d): %w",
                syntaxErr.Offset, ErrValidationApp("body", "올바른 JSON 형식이 아닙니다"))
        case errors.As(err, &typeErr):
            return ErrValidationApp(typeErr.Field,
                fmt.Sprintf("%s 타입이어야 합니다", typeErr.Type))
        case errors.Is(err, io.EOF):
            return ErrValidationApp("body", "요청 본문이 비어 있습니다")
        case err.Error() == "http: request body too large":
            return ErrValidationApp("body", "요청 크기가 1MB를 초과합니다")
        default:
            return fmt.Errorf("readJSON: %w", err)
        }
    }

    // 중복 JSON 객체 감지
    if dec.More() {
        return ErrValidationApp("body", "요청 본문에 여러 JSON 객체가 있습니다")
    }
    return nil
}

// JSON 응답 쓰기 헬퍼
func writeJSON(w http.ResponseWriter, status int, data any) error {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    enc := json.NewEncoder(w)
    enc.SetEscapeHTML(false) // "<", ">" HTML 이스케이프 비활성화 (API 서버에서 불필요)
    return enc.Encode(data)
}

🛠️ json.NewEncoder(w).Encode(data)는 출력 끝에 자동으로 \n을 추가합니다. json.Marshal은 추가하지 않습니다. HTTP API에서는 \n 유무가 클라이언트에 영향을 주지 않으므로 어느 쪽이든 괜찮지만, 스트리밍 NDJSON(Newline Delimited JSON)에서는 \n이 레코드 구분자 역할을 합니다.

6-2. 대용량 JSON 배열 스트리밍

수백만 건의 데이터를 한 번에 메모리에 올리지 않고 JSON 배열로 스트리밍 출력하는 패턴입니다.

func streamUsers(w http.ResponseWriter, r *http.Request) error {
    w.Header().Set("Content-Type", "application/json")
    enc := json.NewEncoder(w)

    // JSON 배열 시작
    if _, err := fmt.Fprint(w, "["); err != nil {
        return err
    }

    rows, err := db.QueryContext(r.Context(), "SELECT id, name, email FROM users ORDER BY id")
    if err != nil {
        return fmt.Errorf("DB 쿼리 실패: %w", err)
    }
    defer rows.Close()

    first := true
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
            return fmt.Errorf("DB 스캔 실패: %w", err)
        }

        if !first {
            fmt.Fprint(w, ",")
        }
        if err := enc.Encode(u); err != nil {
            return err
        }
        first = false
    }

    // JSON 배열 종료
    _, err = fmt.Fprint(w, "]")
    return err
}

7. DisallowUnknownFields와 입력 검증

기본적으로 json.Unmarshal은 구조체에 정의되지 않은 JSON 필드를 무시합니다. API 보안을 강화하려면 DisallowUnknownFields()를 활성화합니다.

type CreateUserRequest struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

func handleCreateUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // "role", "admin" 같은 필드 거부

    if err := dec.Decode(&req); err != nil {
        // "json: unknown field \"role\"" 에러 처리
        var fieldErr *json.UnmarshalTypeError
        if errors.As(err, &fieldErr) {
            return ErrValidationApp(fieldErr.Field, "허용되지 않는 필드입니다")
        }
        return ErrValidationApp("body", err.Error())
    }

    // 추가 검증
    if req.Name == "" {
        return ErrValidationApp("name", "이름은 필수입니다")
    }
    if !strings.Contains(req.Email, "@") {
        return ErrValidationApp("email", "올바른 이메일 형식이 아닙니다")
    }
    if len(req.Password) < 8 {
        return ErrValidationApp("password", "비밀번호는 8자 이상이어야 합니다")
    }

    // ... 생성 처리
    return writeJSON(w, http.StatusCreated, map[string]string{"message": "사용자가 생성되었습니다"})
}

8. PATCH API를 위한 선택적 업데이트 패턴

REST API에서 PATCH는 일부 필드만 업데이트합니다. 세 가지 상태(제공 안 됨 / null / 값 있음)를 구분해야 합니다.

// 옵션 타입: 제공 여부를 구분
type Optional[T any] struct {
    Value   T
    Present bool
}

func (o *Optional[T]) UnmarshalJSON(data []byte) error {
    o.Present = true
    return json.Unmarshal(data, &o.Value)
}

type UpdateUserRequest struct {
    Name  Optional[string] `json:"name"`
    Email Optional[string] `json:"email"`
    Age   Optional[*int]   `json:"age"` // null 허용
}

func handlePatchUser(w http.ResponseWriter, r *http.Request) error {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

    var req UpdateUserRequest
    if err := readJSON(w, r, &req); err != nil {
        return err
    }

    // Present가 true인 필드만 업데이트
    updates := map[string]any{}
    if req.Name.Present {
        updates["name"] = req.Name.Value
    }
    if req.Email.Present {
        updates["email"] = req.Email.Value
    }
    if req.Age.Present {
        updates["age"] = req.Age.Value // *int, nil이면 NULL
    }

    if len(updates) == 0 {
        return ErrValidationApp("body", "업데이트할 필드가 없습니다")
    }

    if err := userService.PartialUpdate(r.Context(), id, updates); err != nil {
        return err
    }

    return writeJSON(w, http.StatusOK, map[string]string{"message": "업데이트 완료"})
}

9. Marshal/Unmarshal 성능 비교와 최적화

9-1. bytes.Buffer vs io.Writer

json.Marshal은 내부적으로 bytes.Buffer를 사용하고 결과를 []byte로 반환합니다. HTTP 응답처럼 io.Writer에 직접 쓸 수 있다면 json.NewEncoder를 사용해 중간 버퍼를 줄일 수 있습니다.

// ❌ 불필요한 중간 복사
b, err := json.Marshal(data)    // []byte 생성
w.Write(b)                      // 복사

// ✅ 직접 쓰기
json.NewEncoder(w).Encode(data) // w에 직접 출력

9-2. 구조체 재사용과 sync.Pool

반복적으로 JSON을 처리하는 고성능 핸들러에서는 sync.Pool로 버퍼를 재사용할 수 있습니다.

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func marshalWithPool(v any) ([]byte, error) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    enc := json.NewEncoder(buf)
    enc.SetEscapeHTML(false)
    if err := enc.Encode(v); err != nil {
        return nil, err
    }

    // Encode는 \n을 추가하므로 제거
    result := make([]byte, buf.Len()-1)
    copy(result, buf.Bytes())
    return result, nil
}

🛠️ 표준 encoding/json보다 높은 성능이 필요하다면 github.com/bytedance/sonic(reflection-free, SIMD 최적화) 또는 github.com/mailru/easyjson(코드 생성 방식)을 고려하세요. 그러나 대부분의 서비스에서는 표준 패키지로 충분합니다. 벤치마크 없이 먼저 교체하는 것은 과최적화입니다.


10. 중첩 구조체 임베딩과 JSON 평탄화

Go의 구조체 임베딩을 활용하면 JSON 출력에서 중첩 없이 필드를 평탄화할 수 있습니다.

type Timestamps struct {
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt time.Time  `json:"updated_at"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

type User struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
    Timestamps         // 임베딩: JSON에 created_at, updated_at가 평탄하게 포함됨
}

// 결과 JSON:
// {
//   "id": 1,
//   "name": "홍길동",
//   "email": "hong@example.com",
//   "created_at": "...",
//   "updated_at": "..."
// }
// (timestamps 중첩 없음)

단, 임베딩된 타입과 외부 타입에 같은 이름의 필드가 있으면 외부 타입이 우선합니다.


11. JSON 처리 안티패턴

안티패턴 1: map[string]interface{} 남용

// ❌ 타입 정보 손실, 컴파일 타임 검증 불가
var data map[string]interface{}
json.Unmarshal(input, &data)
name := data["name"].(string) // 런타임 panic 위험

// ✅ 구조체 사용
var user User
json.Unmarshal(input, &user)
name := user.Name // 컴파일 타임 안전

안티패턴 2: 에러 무시

// ❌ 에러 무시 — 빈 구조체가 그대로 처리됨
json.Unmarshal(data, &user)

// ✅ 에러 확인 필수
if err := json.Unmarshal(data, &user); err != nil {
    return fmt.Errorf("사용자 파싱 실패: %w", err)
}

안티패턴 3: HTTP 핸들러에서 json.Marshal → w.Write

// ❌ 에러 처리 누락, Content-Type 설정 누락
b, _ := json.Marshal(data)
w.Write(b)

// ✅ 헬퍼 함수 사용
writeJSON(w, http.StatusOK, data)

핵심 요약

다음 글에서는 Go time 패키지를 다룹니다. 시간 파싱, 포맷팅, 타임존 처리, 타이머·틱커 실무 패턴까지 살펴보겠습니다.