2026-01-03
이 글에서는 Go encoding/json 패키지를 단순 사용법을 넘어 내부 동작 원리부터 실무 심화 패턴까지 체계적으로 다룹니다. JSON 태그의 모든 옵션, 커스텀 직렬화·역직렬화 구현, null과 zero value의 구분, PATCH API를 위한 선택적 필드 패턴, 스트리밍 처리 성능 최적화, 그리고 HTTP 핸들러와의 통합 패턴까지 — 실제 API 서버를 구축하는 데 필요한 JSON 처리 기술을 모두 담았습니다.
json.Marshal과 json.Unmarshal은 Go의 reflect 패키지를 사용해 런타임에 타입 정보를 읽어 직렬화/역직렬화를 수행합니다. 이 동작 방식을 이해하면 성능 최적화와 커스터마이징의 근거가 명확해집니다.
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, 아니면 역참조
이 우선순위 덕분에 커스텀 직렬화 로직을 타입에 직접 붙일 수 있습니다.
type Example struct {
Exported string // JSON에 포함됨 (대문자 시작)
unexported string // JSON에서 완전히 무시됨 (소문자 시작)
Tagged string `json:"my_tag"` // "my_tag" 키로 직렬화
Skip string `json:"-"` // JSON에서 제외
SkipKeep string `json:"-,"` // "-" 키로 포함 (쉼표 주의)
}
소문자로 시작하는 필드는 reflect 패키지에서 접근 자체가 불가하므로, 태그가 있어도 완전히 무시됩니다. 이것이 왜 구조체 필드를 항상 대문자로 시작해야 하는지의 이유입니다.
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만 적용 |
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: false나 Score: 0.0처럼 의미 있는 zero value가 생략되는 문제가 있습니다. 이 경우 포인터를 사용해야 합니다.
type Response struct {
Active *bool `json:"active,omitempty"` // nil이면 생략, false면 포함
Score *float64 `json:"score,omitempty"` // nil이면 생략, 0.0이면 포함
}
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태그 옵션으로 이 문제를 원천 차단할 수 있습니다.
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는 제공된 필드만 업데이트해야 하기 때문입니다.
json.Marshaler와 json.Unmarshaler 인터페이스를 구현하면 타입의 JSON 변환 로직을 완전히 제어할 수 있습니다.
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
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 기본 형식
}
정수 상수(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" 등 문자열
}
json.RawMessage는 []byte의 별칭으로, JSON 파싱을 나중으로 미루거나 JSON을 그대로 보존할 때 사용합니다.
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
}
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)
}
json.Marshal/Unmarshal은 전체 데이터를 메모리에 올린 후 처리합니다. http.Request.Body나 대용량 파일처럼 스트림에서 직접 읽거나 써야 할 때는 json.Decoder/Encoder를 사용해야 합니다.
// 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이 레코드 구분자 역할을 합니다.
수백만 건의 데이터를 한 번에 메모리에 올리지 않고 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
}
기본적으로 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": "사용자가 생성되었습니다"})
}
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": "업데이트 완료"})
}
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에 직접 출력
반복적으로 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(코드 생성 방식)을 고려하세요. 그러나 대부분의 서비스에서는 표준 패키지로 충분합니다. 벤치마크 없이 먼저 교체하는 것은 과최적화입니다.
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 중첩 없음)
단, 임베딩된 타입과 외부 타입에 같은 이름의 필드가 있으면 외부 타입이 우선합니다.
// ❌ 타입 정보 손실, 컴파일 타임 검증 불가
var data map[string]interface{}
json.Unmarshal(input, &data)
name := data["name"].(string) // 런타임 panic 위험
// ✅ 구조체 사용
var user User
json.Unmarshal(input, &user)
name := user.Name // 컴파일 타임 안전
// ❌ 에러 무시 — 빈 구조체가 그대로 처리됨
json.Unmarshal(data, &user)
// ✅ 에러 확인 필수
if err := json.Unmarshal(data, &user); err != nil {
return fmt.Errorf("사용자 파싱 실패: %w", err)
}
// ❌ 에러 처리 누락, Content-Type 설정 누락
b, _ := json.Marshal(data)
w.Write(b)
// ✅ 헬퍼 함수 사용
writeJSON(w, http.StatusOK, data)
json.Marshal/Unmarshal: reflect 기반, json.Marshaler/Unmarshaler 인터페이스로 커스텀 가능omitempty(zero value 생략), "-"(제외), string(숫자→문자열), "-,"(”-” 키로 포함)*int, *string)으로 구분. omitempty와 함께 PATCH 패턴에 활용json.RawMessage: 다형성 페이로드 지연 파싱, JSON 그대로 보존json.NewDecoder: http.Request.Body처럼 스트림에서 직접 읽기. DisallowUnknownFields()로 보안 강화json.NewEncoder: http.ResponseWriter에 직접 쓰기. SetEscapeHTML(false)로 API 서버에 최적화Optional[T] 제네릭: PATCH API에서 “필드 제공 여부”와 “값”을 분리map[string]interface{} 남용, 에러 무시, Content-Type 설정 누락다음 글에서는 Go time 패키지를 다룹니다. 시간 파싱, 포맷팅, 타임존 처리, 타이머·틱커 실무 패턴까지 살펴보겠습니다.