JSON Formatter Go — MarshalIndent() 가이드

·Systems Engineer·검토자Tobias Müller·게시일

무료 JSON 포맷터을 브라우저에서 직접 사용하세요 — 설치 불필요.

JSON 포맷터 온라인으로 사용하기 →

Go 마이크로서비스 작업 중 API 응답이나 설정 파일을 살펴봐야 할 때, 압축된 JSON은 첫 번째 장벽이 됩니다——수백 개의 중첩 필드가 한 줄에 몰려 있으면 한눈에 파악하기가 거의 불가능합니다. Go에서 JSON을 포맷하려면 표준 라이브러리가 필요한 모든 것을 제공합니다: json.MarshalIndent encoding/json 에 내장되어 있고, 모든 Go 설치에 함께 제공되며, 서드파티 의존성이 전혀 없습니다. 이 가이드는 전체 내용을 다룹니다: struct tags, 커스텀 MarshalJSON() 구현, 원시 바이트를 재포맷하는 json.Indent,json.Decoder 로 대용량 파일 스트리밍, 고처리량 경로에서 go-json 을 쓸 시점, 그리고 터미널에서 빠르게 포맷하는 CLI 원라이너까지. 모든 예제는 Go 1.21+ 기준입니다.

  • json.MarshalIndent(v, "", "\t")는 표준 라이브러리——제로 의존성, 모든 Go 설치에 포함됨.
  • struct tags json:"field_name,omitempty"로 직렬화 키를 제어하고 zero value 필드를 출력에서 생략할 수 있음.
  • 임의 타입에 MarshalJSON()을 구현하면 해당 타입의 JSON 표현을 완전히 제어할 수 있음.
  • json.Indent()는 struct 재파싱 없이 이미 마샬링된 []byte를 재포맷——원시 바이트에 더 빠름.
  • 대용량 파일(>100 MB)의 경우: Token()을 사용한 json.Decoder로 전체를 메모리에 로드하지 않고 스트리밍.
  • go-json은 encoding/json의 드롭인 대체품으로 고처리량 API에서 3~5배 빠름.

JSON 포맷팅이란?

JSON 포맷팅(pretty-printing이라고도 함)은 압축된 JSON 문자열을 일관된 들여쓰기와 줄바꿈이 있는 사람이 읽기 쉬운 레이아웃으로 변환합니다. 기저 데이터는 동일하며, 변하는 것은 공백뿐입니다. 압축 JSON은 바이트 수가 중요한 네트워크 전송에 최적이고, 포맷된 JSON은 디버깅, 코드 리뷰, 로그 검사, 설정 파일 작성에 최적입니다. Go의 encoding/json 패키지는 단일 함수 호출로 두 가지 모드를 모두 처리합니다—— json.Marshal json.MarshalIndent 사이를 전환하는 것만으로 압축과 들여쓰기 출력을 자유롭게 선택할 수 있습니다.

Before · json
After · json
{"service":"payments","port":8443,"workers":4}
{
	"service": "payments",
	"port": 8443,
	"workers": 4
}

json.MarshalIndent() — 표준 라이브러리 방식

json.MarshalIndent 는 Go 표준 라이브러리의 일부인 encoding/json 패키지에 있으며, go get 이 필요 없습니다. 함수 시그니처는 MarshalIndent(v any, prefix, indent string) ([]byte, error)prefix 문자열은 모든 출력 줄의 앞에 추가되고(거의 항상 비워둠), indent 는 중첩 수준마다 한 번씩 반복됩니다. 탭에는 "\t"를, 2칸 공백에는 " " 를 전달하세요.

Go — 최소 동작 예제
package main

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

type ServiceConfig struct {
	Host       string   `json:"host"`
	Port       int      `json:"port"`
	Workers    int      `json:"workers"`
	TLSEnabled bool     `json:"tls_enabled"`
	AllowedIPs []string `json:"allowed_ips"`
}

func main() {
	cfg := ServiceConfig{
		Host:       "payments-api.internal",
		Port:       8443,
		Workers:    8,
		TLSEnabled: true,
		AllowedIPs: []string{"10.0.0.0/8", "172.16.0.0/12"},
	}

	data, err := json.MarshalIndent(cfg, "", "	")
	if err != nil {
		log.Fatalf("marshal config: %v", err)
	}
	fmt.Println(string(data))
}
// {
// 	"host": "payments-api.internal",
// 	"port": 8443,
// 	"workers": 8,
// 	"tls_enabled": true,
// 	"allowed_ips": [
// 		"10.0.0.0/8",
// 		"172.16.0.0/12"
// 	]
// }

탭과 공백 중 무엇을 선택할지는 주로 팀의 관례에 달려 있습니다. gofmt (Go 소스 코드를 포맷하는 도구)가 탭을 사용하기 때문에 많은 Go 프로젝트가 탭을 선호합니다. JSON이 JavaScript나 Python 소비자를 위한 경우에는 2칸이나 4칸 공백 들여쓰기가 일반적입니다. 같은 struct를 두 가지 들여쓰기 스타일로 나란히 비교한 예를 보여드립니다:

Go — 탭 vs 공백
// 탭 들여쓰기——Go 네이티브 툴링에서 선호
data, _ := json.MarshalIndent(cfg, "", "	")
// {
// 	"host": "payments-api.internal",
// 	"port": 8443
// }

// 2칸 공백 들여쓰기——JS/Python 소비자 API에서 일반적
data, _ = json.MarshalIndent(cfg, "", "  ")
// {
//   "host": "payments-api.internal",
//   "port": 8443
// }

// 4칸 공백 들여쓰기
data, _ = json.MarshalIndent(cfg, "", "    ")
// {
//     "host": "payments-api.internal",
//     "port": 8443
// }
참고:네트워크 페이로드, 캐시 값, 바이너리 크기가 중요한 경로 등 압축 출력이 필요할 때는 json.Marshal(v) 를 사용하세요. 동일한 값 인자를 받고 동일한 에러 시맨틱을 가지지만, 공백 없이 한 줄 JSON을 생성합니다.

Struct Tags — 필드명과 Omitempty 제어

Go struct tags는 필드 선언 뒤에 오는 문자열 리터럴로,encoding/json에 각 필드를 어떻게 직렬화할지 알려줍니다. 세 가지 주요 디렉티브가 있습니다: json:"name" 은 출력에서 필드를 리네임하고, omitempty 는 필드가 해당 타입의 zero value를 가질 때 생략하며, json:"-" 는 필드를 완전히 제외합니다——비밀번호, 내부 식별자, 서비스 경계를 절대 벗어나선 안 되는 필드에 유용합니다.

Go — API 응답을 위한 struct tags
type UserProfile struct {
	ID          string  `json:"id"`
	Email       string  `json:"email"`
	DisplayName string  `json:"display_name,omitempty"`  // 빈 문자열이면 생략
	AvatarURL   *string `json:"avatar_url,omitempty"`    // nil 포인터면 생략
	IsAdmin     bool    `json:"is_admin,omitempty"`      // false면 생략
	passwordHash string                                   // 익스포트 안됨——자동 제외
}

// 모든 선택적 필드가 채워진 사용자
full := UserProfile{
	ID: "usr_7b3c", Email: "ops@example.com",
	DisplayName: "김민준", IsAdmin: true,
}
// {
//   "id": "usr_7b3c",
//   "email": "ops@example.com",
//   "display_name": "김민준",
//   "is_admin": true
// }

// 선택적 필드가 없는 사용자——완전히 생략됨
minimal := UserProfile{ID: "usr_2a91", Email: "dev@example.com"}
// {
//   "id": "usr_2a91",
//   "email": "dev@example.com"
// }

json:"-" 태그는 값에 관계없이 무조건적으로 제외해야 하는 필드에 적합합니다——보통 시크릿, 내부 추적 필드, 또는 메모리에서는 올바르지만 외부 시스템에 직렬화되어선 안 되는 데이터입니다.

Go — 민감한 필드 제외
type AuthToken struct {
	TokenID      string `json:"token_id"`
	Subject      string `json:"sub"`
	IssuedAt     int64  `json:"iat"`
	ExpiresAt    int64  `json:"exp"`
	SigningKey    []byte `json:"-"`   // 절대 직렬화되지 않음
	RefreshToken string `json:"-"`   // 절대 직렬화되지 않음
}

tok := AuthToken{
	TokenID: "tok_8f2a", Subject: "usr_7b3c",
	IssuedAt: 1741614120, ExpiresAt: 1741617720,
	SigningKey: []byte("secret"), RefreshToken: "rt_9e4f",
}
data, _ := json.MarshalIndent(tok, "", "  ")
// {
//   "token_id": "tok_8f2a",
//   "sub": "usr_7b3c",
//   "iat": 1741614120,
//   "exp": 1741617720
// }
// SigningKey와 RefreshToken은 절대 나타나지 않음
참고:익스포트되지 않은(소문자로 시작하는)struct 필드는 태그에 관계없이 항상 encoding/json 에 의해 제외됩니다. 익스포트되지 않은 필드에 json:"-" 를 추가할 필요가 없습니다——제외는 자동이며 재정의할 수 없습니다.

커스텀 MarshalJSON() — 비표준 타입 처리

모든 Go 타입은 MarshalJSON() ([]byte, error) 메서드를 정의하여 json.Marshaler 인터페이스를 구현할 수 있습니다. encoding/json 이 그러한 타입을 만나면 기본 리플렉션 기반 마샬링 대신 해당 메서드를 호출합니다. 이것은 특정 wire 표현이 필요한 도메인 타입——통화 값, 상태 열거형, 커스텀 시간 형식, 저장 방식과 직렬화 방식이 다른 타입——에 대한 Go의 표준 패턴입니다.

커스텀 타입——Money(원 단위 변환)

Go — Money를 위한 커스텀 MarshalJSON
package main

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

type Money struct {
	Amount   int64  // 부동소수점 오차를 피하기 위해 전 단위로 저장
	Currency string
}

func (m Money) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Amount   float64 `json:"amount"`
		Currency string  `json:"currency"`
		Display  string  `json:"display"`
	}{
		Amount:   float64(m.Amount) / 100,
		Currency: m.Currency,
		Display:  fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100),
	})
}

type Invoice struct {
	ID       string `json:"id"`
	Subtotal Money  `json:"subtotal"`
	Tax      Money  `json:"tax"`
	Total    Money  `json:"total"`
}

func main() {
	inv := Invoice{
		ID:       "inv_9a2f91bc",
		Subtotal: Money{Amount: 19900, Currency: "KRW"},
		Tax:      Money{Amount: 1592, Currency: "KRW"},
		Total:    Money{Amount: 21492, Currency: "KRW"},
	}
	data, err := json.MarshalIndent(inv, "", "  ")
	if err != nil {
		log.Fatalf("marshal invoice: %v", err)
	}
	fmt.Println(string(data))
}
// {
//   "id": "inv_9a2f91bc",
//   "subtotal": { "amount": 199, "currency": "KRW", "display": "KRW 199.00" },
//   "tax":      { "amount": 15.92, "currency": "KRW", "display": "KRW 15.92" },
//   "total":    { "amount": 214.92, "currency": "KRW", "display": "KRW 214.92" }
// }

상태 열거형——문자열 표현

Go — 상태 열거형을 위한 커스텀 MarshalJSON
type OrderStatus int

const (
	StatusPending OrderStatus = iota
	StatusPaid
	StatusShipped
	StatusCancelled
)

var orderStatusNames = map[OrderStatus]string{
	StatusPending:   "pending",
	StatusPaid:      "paid",
	StatusShipped:   "shipped",
	StatusCancelled: "cancelled",
}

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

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

o := Order{ID: "ord_3c7f", Status: StatusShipped}
data, _ := json.MarshalIndent(o, "", "  ")
// {
//   "id": "ord_3c7f",
//   "status": "shipped"
// }
참고:항상 MarshalJSON() UnmarshalJSON() 을 함께 구현하세요. 마샬링만 구현하면, JSON을 통한 타입 라운드트립(직렬화→저장→역직렬화)에서 구조가 조용히 손실되거나 잘못된 타입이 반환될 수 있습니다. 두 메서드가 합쳐져서 타입이 JSON 라운드트립을 견딜 수 있다는 계약을 형성합니다.

UUID——문자열로 직렬화

Go 표준 라이브러리에는 UUID 타입이 없습니다. 가장 흔한 선택은 github.com/google/uuid로, 이미 MarshalJSON() 를 구현하고 인용부호가 붙은 RFC 4122 문자열로 직렬화합니다. 원시 [16]byte 나 커스텀 ID 타입을 사용한다면, JSON 출력에 base64로 인코딩된 바이너리가 나타나지 않도록 직접 인터페이스를 구현하세요.

Go 1.21+ — UUID 직렬화
import (
    "encoding/json"
    "fmt"

    "github.com/google/uuid"
)

type AuditEvent struct {
    EventID   uuid.UUID `json:"event_id"`   // "550e8400-e29b-41d4-a716-446655440000"으로 직렬화
    SessionID uuid.UUID `json:"session_id"`
    Action    string    `json:"action"`
    ActorID   string    `json:"actor_id"`
    OccuredAt string    `json:"occurred_at"`
}

event := AuditEvent{
    EventID:   uuid.New(),
    SessionID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
    Action:    "user.password_changed",
    ActorID:   "usr_7f3a91bc",
    OccuredAt: "2026-03-10T14:22:00Z",
}

data, _ := json.MarshalIndent(event, "", "  ")
fmt.Println(string(data))
// {
//   "event_id": "a4b2c1d0-...",
//   "session_id": "550e8400-e29b-41d4-a716-446655440000",
//   "action": "user.password_changed",
//   "actor_id": "usr_7f3a91bc",
//   "occurred_at": "2026-03-10T14:22:00Z"
// }
참고:커스텀 타입 없이 UUID를 [16]byte 로 저장하면, encoding/json 은 그것을 base64 문자열——예를 들어 "VQ6EAOKbQdSnFkRmVUQAAA=="——로 인코딩합니다. 항상 적절한 UUID 타입을 사용하거나, 정식 하이픈이 붙은 문자열 형식을 출력하는 MarshalJSON() 을 구현하세요.

json.MarshalIndent() 파라미터 참고

함수 시그니처는 func MarshalIndent(v any, prefix, indent string) ([]byte, error)prefix indent 는 모두 문자열 리터럴입니다——Python의 indent=4 같은 숫자 약어는 없습니다.

파라미터
Type
설명
v
any
마샬링할 값——struct, map, slice, 또는 기본 타입
prefix
string
각 출력 줄의 앞에 추가되는 문자열(보통 "")
indent
string
각 들여쓰기 수준에 사용되는 문자열("\t" 또는 " ")

자주 쓰는 struct tag 옵션:

Tag
Effect
json:"name"
Rename field to name in JSON output
json:"name,omitempty"
Rename + omit if zero value (nil, "", 0, false)
json:"-"
Always exclude this field from JSON output
json:",string"
Encode number or bool as a JSON string value

json.Indent() — 기존 JSON 바이트 재포맷

HTTP 응답 바디, Postgres jsonb 컬럼, 또는 os.ReadFile 로 읽은 파일 등 이미 []byte 의 JSON을 가지고 있다면, pretty-print하기 전에 struct를 정의하고 언마샬할 필요가 없습니다. json.Indent 는 원시 바이트를 직접 재포맷하여 들여쓰기된 출력을 bytes.Buffer 에 씁니다.

Go — json.Indent로 원시 바이트 처리
package main

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

func main() {
	// 업스트림 서비스로부터 온 원시 JSON 페이로드 시뮬레이션
	raw := []byte(`{"trace_id":"tr_9a2f","service":"checkout","latency_ms":342,"error":null}`)

	var buf bytes.Buffer
	if err := json.Indent(&buf, raw, "", "	"); err != nil {
		log.Fatalf("indent: %v", err)
	}
	fmt.Println(buf.String())
}
// {
// 	"trace_id": "tr_9a2f",
// 	"service": "checkout",
// 	"latency_ms": 342,
// 	"error": null
// }

마이크로서비스에서 제가 자주 쓰는 패턴은 구조화 로그에 쓰기 전에 json.Indent 를 호출하는 것입니다——추가 오버헤드는 무시할 수 있지만, 인시던트 대응 시 로그 항목의 가독성이 훨씬 향상됩니다. 이 함수는 HTTP 응답 로깅, 저장된 JSON 문자열의 pretty-print, struct 정의를 사용할 수 없는 포맷-온-리드 파이프라인에 특히 유용합니다.

Go — json.Indent를 디버그 로깅에 사용
func logResponse(logger *slog.Logger, statusCode int, body []byte) {
	var pretty bytes.Buffer
	if err := json.Indent(&pretty, body, "", "  "); err != nil {
		// 바디가 유효한 JSON이 아님——원시 그대로 기록
		logger.Debug("upstream response", "status", statusCode, "body", string(body))
		return
	}
	logger.Debug("upstream response", "status", statusCode, "body", pretty.String())
}
주의:json.Indent 는 공백을 삽입하는 데 필요한 구조 이상으로 JSON을 완전히 검증하지 않습니다. 완전한 구문 검증을 위해서는 먼저 json.Valid(data) 를 호출하고, false인 경우를 처리한 후 들여쓰기를 시도하세요.

파일과 HTTP 응답에서 JSON 포맷하기

Go 서비스에서 가장 일반적인 두 가지 실제 시나리오는 디스크의 파일에서 읽은 JSON (설정 파일, 픽스처 데이터, 마이그레이션 시드)을 포맷하는 것과, 디버그 로깅이나 테스트 어서션을 위해 HTTP 응답 바디를 pretty-print하는 것입니다. 둘 다 같은 패턴을 따릅니다: 바이트를 읽고, json.Indent 를 호출하거나 언마샬하고 나서 json.MarshalIndent 를 호출한 다음, 다시 쓰거나 로그에 기록합니다.

파일 읽기 → 포맷 → 다시 쓰기

Go — JSON 파일을 인플레이스로 포맷
package main

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

func formatJSONFile(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read %s: %w", path, err)
	}

	if !json.Valid(data) {
		return fmt.Errorf("invalid JSON in %s", path)
	}

	var buf bytes.Buffer
	if err := json.Indent(&buf, data, "", "	"); err != nil {
		return fmt.Errorf("indent %s: %w", path, err)
	}

	if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

func main() {
	if err := formatJSONFile("config/database.json"); err != nil {
		log.Fatalf("format config: %v", err)
	}
	fmt.Println("config/database.json 포맷 완료")
}

HTTP 응답 → 디코드 → 디버그 로그용 Pretty-Print

Go — 디버그 로그용으로 HTTP 응답 포맷
package main

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

type HealthResponse struct {
	Status    string            `json:"status"`
	Version   string            `json:"version"`
	Checks    map[string]string `json:"checks"`
	UptimeSec int64             `json:"uptime_seconds"`
}

func main() {
	resp, err := http.Get("https://api.payments.internal/v2/health")
	if err != nil {
		log.Fatalf("health check: %v", err)
	}
	defer resp.Body.Close()

	var result HealthResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		log.Fatalf("decode health response: %v", err)
	}

	pretty, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		log.Fatalf("marshal health response: %v", err)
	}
	fmt.Printf("헬스 체크 (%d):
%s
", resp.StatusCode, string(pretty))
}
// 헬스 체크 (200):
// {
//   "status": "ok",
//   "version": "1.4.2",
//   "checks": {
//     "database": "ok",
//     "cache": "ok",
//     "queue": "degraded"
//   },
//   "uptime_seconds": 172800
// }

원시 응답 바디 → json.Indent(Struct 불필요)

Go — io.ReadAll로 원시 HTTP 바디 포맷
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

func main() {
	resp, err := http.Get("https://api.payments.internal/v2/health")
	if err != nil {
		log.Fatalf("request: %v", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("read body: %v", err)
	}

	var buf bytes.Buffer
	if err := json.Indent(&buf, body, "", "  "); err != nil {
		log.Fatalf("indent: %v", err)
	}
	fmt.Println(buf.String())
}

Go에서 HTTP 응답의 JSON을 Pretty Print하기

위의 두 가지 접근법이 가장 일반적인 경우를 커버합니다: 타입이 있는 struct로 디코딩한 후 json.MarshalIndent 를 호출(특정 필드를 검증하거나 검사할 필요가 있을 때 최선), 또는 io.ReadAll 로 원시 바디 바이트를 읽어 직접 json.Indent 를 호출(struct 정의가 없을 때 빠른 디버그 로깅에 최선). 원시 바이트 방식은 더 단순하지만 Go 타입 안전성이나 필드 접근을 제공하지 않습니다—— 순수하게 표시 변환입니다. 전체 응답 바디가 메모리에 들어가는 한, 두 가지 방식 모두 큰 응답 바디를 올바르게 처리합니다.

Go — 두 패턴 나란히
// 패턴 A: 타입 디코드 → MarshalIndent
// 특정 필드를 검사하거나 검증해야 할 때 사용
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
pretty, _ := json.MarshalIndent(result, "", "  ")
fmt.Println(string(pretty))

// 패턴 B: 원시 바이트 → json.Indent
// 빠른 디버그 로깅에 사용——struct 정의 불필요
body, _ := io.ReadAll(resp.Body)
var buf bytes.Buffer
json.Indent(&buf, body, "", "  ")
fmt.Println(buf.String())

Go 프로젝트에서의 커맨드라인 JSON 포맷

때로는 Go 프로그램을 작성하지 않고 터미널에서 바로 JSON 페이로드를 포맷해야 합니다. 다음 원라이너들은 개발 중이나 인시던트 대응 시에 몸에 베어 있는 것들입니다.

bash — JSON을 Python 내장 포매터에 파이프
echo '{"service":"payments","port":8443,"workers":4}' | python3 -m json.tool
# {
#     "service": "payments",
#     "port": 8443,
#     "workers": 4
# }
bash — 완전한 포맷과 필터링을 위해 jq에 파이프
# 포맷만
cat api-response.json | jq .

# 중첩 필드 추출
cat api-response.json | jq '.checks.database'

# 배열 필터링
cat audit-log.json | jq '.[] | select(.severity == "error")'
bash — stdin을 통해 간단한 Go main.go에 파이프
# main.go: stdin 읽기, 포맷, stdout에 쓰기
cat <<'EOF' > /tmp/fmt.go
package main
import ("bytes";"encoding/json";"io";"os")
func main() {
    b,_:=io.ReadAll(os.Stdin)
    var buf bytes.Buffer
    json.Indent(&buf,b,"","  ")
    os.Stdout.Write(buf.Bytes())
}
EOF
echo '{"port":8080,"debug":true}' | go run /tmp/fmt.go
참고:gofmt 는 Go 소스 코드를 포맷하는 것이지 JSON이 아닙니다. JSON 파일을 gofmt 에 파이프하지 마세요——에러가 나거나 알아볼 수 없는 출력이 생성됩니다. JSON 파일에는 jq . 또는 python3 -m json.tool 를 사용하세요.

고성능 대안 — go-json

대부분의 Go 서비스에서 encoding/json 은 충분히 빠릅니다. 하지만 JSON 마샬링이 프로파일러에 나타난다면——고처리량 REST API나 매 요청마다 큰 구조화 로그 줄을 출력하는 서비스에서 흔함—— go-json 라이브러리는 동일한 API로 3~5배 빠른 드롭인 대체품입니다.

bash — go-json 설치
go get github.com/goccy/go-json
Go — import 한 줄만 바꿔 encoding/json을 go-json으로 교체
package main

import (
	// 이것을 대체:
	// "encoding/json"

	// 이것으로——동일한 API, 다른 코드 변경 불필요:
	json "github.com/goccy/go-json"

	"fmt"
	"log"
)

type AuditEvent struct {
	RequestID string `json:"request_id"`
	UserID    string `json:"user_id"`
	Action    string `json:"action"`
	ResourceID string `json:"resource_id"`
	IPAddress  string `json:"ip_address"`
	DurationMs int    `json:"duration_ms"`
}

func main() {
	event := AuditEvent{
		RequestID: "req_7d2e91", UserID: "usr_4421",
		Action: "invoice.download", ResourceID: "inv_9a2f",
		IPAddress: "203.0.113.45", DurationMs: 23,
	}
	data, err := json.MarshalIndent(event, "", "  ")
	if err != nil {
		log.Fatalf("marshal: %v", err)
	}
	fmt.Println(string(data))
	// 출력은 encoding/json과 동일——속도만 다름
}

github.com/bytedance/sonic 은 가장 빠른 Go JSON 라이브러리이지만, amd64 arm64 에서만 동작합니다(JIT 컴파일 사용). 이식 가능한 드롭인 대체품이 필요할 때는 go-json 을 사용하고, 알려진 아키텍처에서 핫 경로의 마지막 마이크로초가 필요할 때 sonic 을 고려하세요.

대용량 JSON 파일 처리

json.MarshalIndent json.Indent 는 모두 전체 페이로드가 메모리에 있어야 합니다. 데이터 내보내기, 감사 로그, Kafka 컨슈머 페이로드 등 100 MB를 초과하는 파일은 json.Decoder 를 사용해 입력을 스트리밍하고 레코드를 하나씩 처리하세요.

json.Decoder로 대용량 JSON 배열 스트리밍

Go — 메모리에 전부 로드하지 않고 대용량 JSON 배열 스트리밍 처리
package main

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

type AuditEvent struct {
	RequestID  string `json:"request_id"`
	UserID     string `json:"user_id"`
	Action     string `json:"action"`
	Severity   string `json:"severity"`
	DurationMs int    `json:"duration_ms"`
}

func processAuditLog(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open %s: %w", path, err)
	}
	defer file.Close()

	dec := json.NewDecoder(file)

	// 여는 '[' 읽기
	if _, err := dec.Token(); err != nil {
		return fmt.Errorf("read opening token: %w", err)
	}

	var processed int
	for dec.More() {
		var event AuditEvent
		if err := dec.Decode(&event); err != nil {
			return fmt.Errorf("decode event %d: %w", processed, err)
		}
		// 한 번에 하나씩 처리——메모리 사용량 일정
		if event.Severity == "error" {
			fmt.Printf("[ERROR] %s: %s (%dms)
", event.UserID, event.Action, event.DurationMs)
		}
		processed++
	}
	fmt.Printf("%d개의 감사 이벤트 처리 완료
", processed)
	return nil
}

func main() {
	if err := processAuditLog("audit-2026-03.json"); err != nil {
		log.Fatalf("process audit log: %v", err)
	}
}

NDJSON — 줄당 하나의 JSON 객체

Go — NDJSON 로그 스트림을 한 줄씩 처리
package main

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

type LogLine struct {
	Timestamp  string `json:"ts"`
	Level      string `json:"level"`
	Service    string `json:"service"`
	Message    string `json:"msg"`
	TraceID    string `json:"trace_id"`
	DurationMs int    `json:"duration_ms,omitempty"`
}

func main() {
	file, err := os.Open("service-2026-03.ndjson")
	if err != nil {
		log.Fatalf("open log: %v", err)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 줄당 1 MB

	for scanner.Scan() {
		var line LogLine
		if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
			continue // 잘못된 줄 건너뛰기
		}
		if line.Level == "error" {
			fmt.Printf("%s [%s] %s trace=%s
",
				line.Timestamp, line.Service, line.Message, line.TraceID)
		}
	}
	if err := scanner.Err(); err != nil {
		log.Fatalf("scan: %v", err)
	}
}
참고:파일이 100 MB를 초과하거나 무제한 스트림(Kafka 컨슈머, 로그 파이프라인, S3 객체 읽기)을 처리할 때 스트리밍으로 전환하세요. os.ReadFile 로 500 MB JSON 파일을 로드하면 힙에 해당 버퍼 전체가 할당되어 GC 압력을 유발하고, 메모리가 제한된 컨테이너에서 OOM을 일으킬 수 있습니다.

흔한 실수

MarshalIndent의 에러 반환값 무시하기

문제: _로 에러를 버리면 직렬화 불가능한 값(채널, 함수, 복소수)이 조용히 nil 출력을 생성하거나, 하위에서 string(nil)을 호출할 때 패닉이 발생합니다.

해결: 항상 에러를 확인하세요. 항상 성공해야 하는 타입을 마샬링한다면, 패닉하는 log.Fatalf가 조용한 데이터 손실보다 낫습니다.

Before · Go
After · Go
data, _ := json.MarshalIndent(payload, "", "  ")
fmt.Println(string(data)) // 마샬링 실패 시 빈 문자열
data, err := json.MarshalIndent(payload, "", "  ")
if err != nil {
    log.Fatalf("marshal payload: %v", err)
}
fmt.Println(string(data))
바이너리 출력에 os.Stdout.Write 대신 fmt.Println 사용

문제: fmt.Println(string(data))는 JSON 뒤에 개행 문자를 추가해서, 출력을 원시 바이트로 처리하는 파이프라인을 망칩니다——예를 들어 jq에 파이프하거나 바이너리 프로토콜에 쓸 때.

해결: 바이너리 클린 출력에는 os.Stdout.Write(data)를 사용하세요. 사람이 보기 위한 후행 개행이 필요하면 명시적으로 추가하세요.

Before · Go
After · Go
data, _ := json.MarshalIndent(cfg, "", "  ")
fmt.Println(string(data)) // 끝에 여분의 개행 추가
data, _ := json.MarshalIndent(cfg, "", "  ")
os.Stdout.Write(data)
os.Stdout.Write([]byte("
")) // 필요할 때만 명시적 개행
포인터 필드에 omitempty 빠뜨리기

문제: omitempty 없이는 nil *string이나 *int 포인터가 "field": null로 직렬화됩니다. 이는 내부 필드명을 노출하고 컨슈머 측의 엄격한 JSON 스키마 검증기를 망가뜨릴 수 있습니다.

해결: 출력에서 존재하지 않기를(null이 아니라)원하는 포인터 필드에 omitempty를 추가하세요. omitempty를 가진 nil *T는 JSON에 키를 전혀 생성하지 않습니다.

Before · Go
After · Go
type WebhookPayload struct {
    EventID   string  `json:"event_id"`
    ErrorMsg  *string `json:"error_msg"`  // nil일 때 null로 나타남
}
// {"event_id":"evt_3c7f","error_msg":null}
type WebhookPayload struct {
    EventID   string  `json:"event_id"`
    ErrorMsg  *string `json:"error_msg,omitempty"`  // nil일 때 생략됨
}
// {"event_id":"evt_3c7f"}
struct 대신 map[string]interface{} 사용

문제: map[string]any로 언마샬링하면 타입 정보가 손실되고, 수동 타입 단언이 필요하며, 비결정적인 키 순서가 생겨——JSON diff와 로그 비교가 더 어려워집니다.

해결: 적절한 json 태그를 가진 struct를 정의하세요. Struct는 타입 안전하고, 더 빠르게 마샬링되며, struct 정의와 일치하는 결정적인 필드 순서를 생성하고, 코드가 자기 문서화됩니다.

Before · Go
After · Go
var result map[string]any
json.Unmarshal(body, &result)
port := result["port"].(float64) // 타입 단언 필요, 타입이 틀리면 패닉
type ServiceStatus struct {
    Service string `json:"service"`
    Port    int    `json:"port"`
    Healthy bool   `json:"healthy"`
}
var result ServiceStatus
json.Unmarshal(body, &result)
port := result.Port // 타입 있음, 안전, 빠름

encoding/json vs 대안——빠른 비교

Method
포맷 출력
Valid JSON
커스텀 타입
Streaming
설치 필요
json.MarshalIndent
✓ MarshalJSON 경유
불필요(표준 라이브러리)
json.Indent
N/A(바이트만 처리)
불필요(표준 라이브러리)
json.Encoder
✗(컴팩트)
✓ MarshalJSON 경유
불필요(표준 라이브러리)
go-json
go get
sonic
go get(amd64/arm64)
jq (CLI)
N/A
시스템 설치

struct 정의를 제어하고 포맷된 출력이 필요한 모든 경우——설정 파일, 디버그 로깅, 테스트 픽스처, API 응답 로깅——에는 json.MarshalIndent 를 사용하세요. 이미 원시 바이트를 가지고 있고 Go 타입을 거치는 라운드트립 없이 공백만 추가하면 될 때는 json.Indent 를 사용하세요. 프로파일링에서 JSON 마샬링이 측정 가능한 병목임이 확인된 후에만 go-json 이나 sonic 으로 전환하세요——대부분의 서비스에서는 표준 라이브러리로도 충분합니다.

자주 묻는 질문

Go에서 JSON을 pretty print하려면?

encoding/json 패키지의 json.MarshalIndent(v, "", "\t")를 호출합니다——두 번째 인자는 줄별 접두사(보통 빈 문자열)이고, 세 번째 인자는 레벨별 들여쓰기입니다. 탭에는 "\t"를, 2칸 공백에는 " "를 전달하세요. 외부 라이브러리는 필요 없으며, encoding/json은 Go 표준 라이브러리에 포함되어 있습니다.

Go
package main

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

func main() {
	config := map[string]any{
		"service": "payments-api",
		"port":    8443,
		"region":  "ap-northeast-2",
	}
	data, err := json.MarshalIndent(config, "", "	")
	if err != nil {
		log.Fatalf("marshal: %v", err)
	}
	fmt.Println(string(data))
	// {
	// 	"port": 8443,
	// 	"region": "ap-northeast-2",
	// 	"service": "payments-api"
	// }
}

json.Marshal과 json.MarshalIndent의 차이점은?

json.Marshal은 공백 없이 압축된 한 줄 JSON을 생성합니다——바이트 수가 중요한 네트워크 전송에 최적입니다. json.MarshalIndent는 두 개의 추가 문자열 파라미터(prefix와 indent)를 받아 들여쓰기된 사람이 읽기 쉬운 출력을 생성합니다. 두 함수 모두 같은 값 타입을 받고 ([]byte, error)를 반환합니다. MarshalIndent의 유일한 비용은 출력 바이트가 약간 더 많아지는 것과 공백 삽입에 필요한 아주 미미한 추가 CPU 시간뿐입니다.

Go
import "encoding/json"

type HealthCheck struct {
	Status  string `json:"status"`
	Version string `json:"version"`
	Uptime  int    `json:"uptime_seconds"`
}

h := HealthCheck{Status: "ok", Version: "1.4.2", Uptime: 86400}

compact, _ := json.Marshal(h)
// {"status":"ok","version":"1.4.2","uptime_seconds":86400}

pretty, _ := json.MarshalIndent(h, "", "  ")
// {
//   "status": "ok",
//   "version": "1.4.2",
//   "uptime_seconds": 86400
// }

struct를 언마샬하지 않고 JSON []byte를 포맷하려면?

json.Indent(&buf, src, "", "\t")를 사용하세요. 이 함수는 기존 JSON []byte를 받아 들여쓰기된 버전을 bytes.Buffer에 씁니다——struct 정의 불필요, 타입 단언 불필요, Go 타입을 거치는 라운드트립도 없습니다. HTTP 응답 바디나 데이터베이스 컬럼 등 이미 원시 JSON 바이트를 가지고 있을 때 가장 빠른 옵션입니다.

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

raw := []byte(`{"endpoint":"/api/v2/invoices","page":1,"per_page":50,"total":312}`)

var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", "	"); err != nil {
	log.Fatalf("indent: %v", err)
}
fmt.Println(buf.String())
// {
// 	"endpoint": "/api/v2/invoices",
// 	"page": 1,
// 	"per_page": 50,
// 	"total": 312
// }

json.MarshalIndent가 에러를 반환하는 이유는?

값을 JSON으로 표현할 수 없을 때 encoding/json은 에러를 반환합니다. 가장 흔한 원인은: 채널, 함수, 또는 복소수 마샬링(이들은 JSON에 대응하는 표현이 없음), 에러를 반환하는 MarshalJSON()을 구현한 struct, 또는 문자열이 아닌 키를 가진 map입니다. 중요한 점은 익스포트되지 않은 필드나 nil 포인터 필드를 가진 struct 마샬링은 에러를 일으키지 않는다는 것입니다——그것들은 단순히 생략됩니다.

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

// 이것은 에러를 반환——채널은 JSON 직렬화 불가
ch := make(chan int)
_, err := json.MarshalIndent(ch, "", "	")
fmt.Println(err)
// json: unsupported type: chan int

// 이것은 문제없음——omitempty를 가진 nil 포인터 필드는 조용히 생략됨
type Profile struct {
	ID     string  `json:"id"`
	Avatar *string `json:"avatar,omitempty"`
}
p := Profile{ID: "usr_7b3c"}
data, _ := json.MarshalIndent(p, "", "  ")
// {"id": "usr_7b3c"}  — avatar 생략, 에러 없음

Go에서 JSON 출력에서 필드를 제외하려면?

세 가지 방법이 있습니다. 첫째, json:"-" struct tag를 사용——필드 값에 관계없이 항상 제외됩니다. 둘째, omitempty 사용——필드가 해당 타입의 zero value(nil 포인터, 빈 문자열, 0, false)를 가질 때만 제외됩니다. 셋째, 익스포트되지 않은(소문자로 시작하는)필드는 태그 없이 encoding/json에 의해 자동으로 제외됩니다.

Go
type PaymentMethod struct {
	ID           string  `json:"id"`
	Last4        string  `json:"last4"`
	ExpiryMonth  int     `json:"expiry_month"`
	ExpiryYear   int     `json:"expiry_year"`
	CVV          string  `json:"-"`                    // 항상 제외
	BillingName  string  `json:"billing_name,omitempty"` // 비어있으면 제외
	internalRef  string                                 // 익스포트 안됨——자동 제외
}

pm := PaymentMethod{
	ID: "pm_9f3a", Last4: "4242",
	ExpiryMonth: 12, ExpiryYear: 2028,
	CVV: "123", internalRef: "stripe:pm_9f3a",
}
data, _ := json.MarshalIndent(pm, "", "  ")
// CVV, BillingName(빈 값), internalRef는 출력에 나타나지 않음

JSON 마샬링에서 time.Time을 어떻게 처리하나요?

encoding/json은 기본적으로 time.Time을 RFC3339Nano 형식(예: "2026-03-10T14:22:00Z")으로 마샬링하며, ISO 8601 호환입니다. 레거시 API를 위한 Unix 에포크 정수나 커스텀 날짜 문자열 등 다른 형식이 필요하다면, time.Time을 임베드하고 필요한 형식을 반환하는 래퍼 타입에 MarshalJSON()을 구현하세요.

Go
import (
	"encoding/json"
	"fmt"
	"time"
)

// 기본 동작——RFC3339Nano, 커스텀 코드 불필요
type AuditEvent struct {
	Action    string    `json:"action"`
	OccurredAt time.Time `json:"occurred_at"`
}

e := AuditEvent{
	Action:    "invoice.paid",
	OccurredAt: time.Date(2026, 3, 10, 14, 22, 0, 0, time.UTC),
}
data, _ := json.MarshalIndent(e, "", "  ")
// {
//   "action": "invoice.paid",
//   "occurred_at": "2026-03-10T14:22:00Z"
// }

// 커스텀: Unix 타임스탬프(정수)
type UnixTime struct{ time.Time }

func (u UnixTime) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf("%d", u.Unix())), nil
}

관련 도구

다른 언어로도 제공됩니다:PythonJavaScriptBash
JO
James OkaforSystems Engineer

James is a systems engineer and Go enthusiast who focuses on high-performance microservices, command-line tooling, and infrastructure automation. He enjoys the simplicity and explicitness of Go and writes about building fast, reliable backend systems. When not coding he explores distributed systems concepts and contributes to open-source Go libraries.

TM
Tobias Müller기술 검토자

Tobias is a platform engineer who builds developer tooling and internal infrastructure in Go. He has authored several open-source CLI tools and contributes to the Go toolchain ecosystem. He writes about the cobra and urfave/cli frameworks, cross-platform binary distribution, configuration management, and the patterns that make Go an ideal language for building reliable, self-contained command-line utilities.