고 실전 #3 JSON 입출력과 입력 검증

5 분 소요

#2 라우팅에서 라우트의 모양을 잡았다면 — 이번 글은 그 안쪽, 즉 데이터를 받아 처리하고 응답하는 표준 패턴을 다룹니다.

Marshal — Go → JSON #

기본 Marshal
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

u := User{ID: 1, Name: "이도경"}
b, err := json.Marshal(u)
// {"id":1,"name":"이도경"}

핵심 두 가지:

  • struct 태그json:"name"으로 키 지정
  • omitempty — zero value 면 필드 자체 생략

들여쓰기 #

b, _ := json.MarshalIndent(u, "", "  ")

응답으로 보낼 땐 보통 Marshal 그대로(트래픽), 디버깅용 출력은 MarshalIndent.

Unmarshal — JSON → Go #

기본 Unmarshal
input := []byte(`{"id":1,"name":"이도경"}`)

var u User
if err := json.Unmarshal(input, &u); err != nil {
	// ...
}

포인터를 넘기는 게 핵심. 알 수 없는 필드는 조용히 무시됩니다.

HTTP 핸들러에서 — Encoder/Decoder가 더 어울림 #

핸들러 표준 패턴
func createUser(w http.ResponseWriter, r *http.Request) {
	var u User
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	saved := saveUser(u)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(saved)
}

Decoder.Decode는 — io.Reader에서 직접 읽음(전체 bytes로 가져올 필요 없음). 메모리 효율적.

자주 만나는 함정 #

1) zero value와 “키 없음” 구분 못 함 #

type User struct {
	Active bool `json:"active"`
}

JSON에 "active":false가 와도, 키가 아예 빠진 경우도 — Go 입장에선 둘 다 false. 구분이 필요하면 포인터 사용.

nil로 부재 표현
type User struct {
	Active *bool `json:"active"`
}

if u.Active != nil && *u.Active {
	// 명시적으로 true가 온 경우만
}

부분 업데이트(PATCH) API에서 자주 등장.

2) 알 수 없는 필드를 거부하고 싶을 때 #

기본 동작은 — 모르는 필드 무시. 명시적 거부가 필요하면:

DisallowUnknownFields
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&u); err != nil {
	// ...
}

오타 찾기에 유용합니다. 특히 PATCH API에서.

3) 큰 입력 — MaxBytesReader #

요청 크기 제한
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)    // 1 MiB

var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
	// 너무 크거나 잘못된 JSON
}

#1에서 본 도구. 항상 한도를 두는 게 안전.

4) 시간 — time.Time의 기본 형식 #

type Event struct {
	At time.Time `json:"at"`
}

time.Time은 RFC 3339 ("2026-02-12T10:00:00Z")로 기본 마샬. 다른 형식이 필요하면 — 커스텀 타입 만들고 MarshalJSON/UnmarshalJSON 작성.

커스텀 마샬 #

MarshalJSON
type Money int64    // cents

func (m Money) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}
UnmarshalJSON
func (m *Money) UnmarshalJSON(b []byte) error {
	var f float64
	if err := json.Unmarshal(b, &f); err != nil {
		return err
	}
	*m = Money(f * 100)
	return nil
}

도메인 타입에 직렬화 규칙을 캡슐화하는 표준 패턴.

입력 검증 — 코드로 명시적 #

받은 데이터가 — 유효한지 검사하는 부분입니다. JSON 파싱은 형식만 체크하지, 비즈니스 규칙은 검사하지 않습니다.

단순 검증
type CreateUser struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func (c CreateUser) Validate() error {
	if c.Name == "" {
		return errors.New("name required")
	}
	if !strings.Contains(c.Email, "@") {
		return errors.New("invalid email")
	}
	if c.Age < 0 || c.Age > 150 {
		return errors.New("invalid age")
	}
	return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	var c CreateUser
	if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if err := c.Validate(); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	// ...
}

작은 API에선 — 수동 메서드가 가장 명확합니다. 매뉴얼이 부담스러워지는 시점에 도구 도입을 검토합니다.

검증 라이브러리 — go-playground/validator #

태그 기반 검증
import "github.com/go-playground/validator/v10"

type CreateUser struct {
	Name  string `json:"name" validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
	Age   int    `json:"age" validate:"gte=0,lte=150"`
}

var v = validator.New()

if err := v.Struct(c); err != nil {
	// validator.ValidationErrors로 구체적 위반 항목 추출 가능
}

장점 — 태그로 표현이 짧음. 단점 — 동작이 마법 같고, 에러 처리가 다소 번잡(여러 필드 위반의 정리). 큰 API에서 자주 채택.

응답 — 일관된 에러 모양 #

에러 응답 표준화
type ErrorResponse struct {
	Error   string            `json:"error"`
	Details map[string]string `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: msg})
}

API 사용자가 — 에러 모양을 한 번만 학습해도 되도록.

필드별 디테일
writeError(w, 400, "validation failed")
// or with details
writeError(w, 400, "validation failed",
	map[string]string{"email": "invalid format"})

Bool / Int 응답에 흔한 함정 — nil slice #

type Resp struct {
	Items []Item `json:"items"`
}

var r Resp
json.Marshal(r)
// {"items":null}

빈 슬라이스가 아니라 **null**로 직렬화. 클라이언트가 null을 처리 못 하면 깨집니다.

해결 — 명시적으로 비어 있는 슬라이스 만들기.

r.Items = []Item{}
// {"items":[]}

패턴 — DTO 분리 #

도메인 타입을 그대로 직렬화하지 말고 — API 노출용 DTO를 분리.

DTO 분리
// 내부 도메인
type User struct {
	ID           int
	Email        string
	PasswordHash string    // ← API로 노출되면 안 됨
	CreatedAt    time.Time
}

// 응답용 DTO
type UserDTO struct {
	ID    int    `json:"id"`
	Email string `json:"email"`
}

func toDTO(u User) UserDTO {
	return UserDTO{ID: u.ID, Email: u.Email}
}

json:"-"로 그 필드만 빼는 방법도 있지만 — DTO 분리가 의도가 더 명확하고, 내부 구조 변경이 API를 깨지 않게 보호.

응답 헬퍼 #

자주 쓰면 핸들러가 짧아집니다.

응답 헬퍼
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func decodeJSON(r *http.Request, v any) error {
	r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(v)
}

이 두 함수만 있으면 — 핸들러 본체가 본질만 남습니다.

함정 — 재사용 시 zero value 잔여 #

자주 만나는 함정
var u User
json.Unmarshal(input, &u)    // 일부 필드만 채움
// 다른 필드는 zero value 그대로

파싱이 누락된 필드를 비우지 않습니다 — 이전 값이 그대로 남음. 항상 새 변수에서 시작하거나, 명시적으로 u = User{}로 리셋.

큰 응답 — 스트리밍 #

큰 결과 스트리밍
func handler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	enc := json.NewEncoder(w)

	for row := range queryRows() {    // 채널/이터레이터
		if err := enc.Encode(row); err != nil {
			return
		}
	}
}

이건 JSON Lines (한 줄에 하나씩) 형식. 큰 결과를 — 메모리에 한꺼번에 안 올리고 흘려 보냅니다. ndjson으로 잘 알려진 패턴.

마무리 #

이번 글에서 정리한 내용:

  • json.Marshal / Unmarshal — 작은 데이터
  • Encoder / Decoder — 핸들러에서 자연스러운 형태
  • 태그json:"key,omitempty"
  • 포인터 필드 — zero value와 부재 구분
  • DisallowUnknownFields — 오타 잡기
  • MaxBytesReader — 항상 입력 크기 제한
  • 검증 — 핸들러 안에서 명시적 분리, 큰 API는 validator 검토
  • DTO 분리 — 내부 타입을 그대로 노출 안 함
  • 빈 slice → null 함정, 명시적 []Item{}
  • JSON Lines로 큰 응답 스트리밍

다음 글(#4 DB 연동)에서는 — database/sql, prepared statement, 트랜잭션 등 데이터 영속화의 표준을 정리합니다.

X