고 실전 #3 JSON 입출력과 입력 검증
#2 라우팅에서 라우트의 모양을 잡았다면 — 이번 글은 그 안쪽, 즉 데이터를 받아 처리하고 응답하는 표준 패턴을 다룹니다.
Marshal — Go → JSON #
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 #
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. 구분이 필요하면 포인터 사용.
type User struct {
Active *bool `json:"active"`
}
if u.Active != nil && *u.Active {
// 명시적으로 true가 온 경우만
}부분 업데이트(PATCH) API에서 자주 등장.
2) 알 수 없는 필드를 거부하고 싶을 때 #
기본 동작은 — 모르는 필드 무시. 명시적 거부가 필요하면:
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 작성.
커스텀 마샬 #
type Money int64 // cents
func (m Money) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}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를 분리.
// 내부 도메인
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, 트랜잭션 등 데이터 영속화의 표준을 정리합니다.