본문으로 건너뛰기

Gin 바인딩과 검증

이 챕터에서 다루는 것

Gin의 실질적인 가치는 바인딩과 검증에 있다. JSON 본문·URI 파라미터·쿼리 스트링을 구조체 하나로 받고, 규칙을 태그로 선언한다.

그 편의가 어디서 끝나는지 — 무엇을 여전히 손으로 써야 하는지 — 를 함께 본다.

Should가 붙느냐 안 붙느냐

Gin의 바인딩 메서드는 두 계열이다.

계열실패했을 때
Bind, BindJSON, BindQuery, BindUri스스로 400을 쓰고 c.Errors에 에러를 쌓는다
ShouldBind, ShouldBindJSON, ShouldBindQuery, ShouldBindUri아무것도 안 쓰고 error만 반환한다

Bind 계열이 쓰는 응답이 어떻게 생겼는지 실제로 보면 답이 나온다.

BindJSON 실패 시 Gin이 쓰는 응답: status=400 Content-Type="" 본문 길이=0

본문이 비어 있다. 클라이언트는 무엇이 왜 틀렸는지 알 길이 없다. 9-8에서 정한 code/message/fields 형식을 지키려면 Should 계열만 쓴다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// createWithBind는 Should가 붙지 않은 BindJSON을 쓴다. 대조용이다.
// 실패하면 Gin이 알아서 400을 쓰고 c.Errors에 에러를 쌓는다.
// 응답 형식을 통제할 수 없으므로 실무에서는 거의 쓰지 않는다.
func createWithBind(c *gin.Context) {
var req CreateRequest
if err := c.BindJSON(&req); err != nil {
// 여기 도달했을 때 이미 400이 나갔다. 로깅 말고 할 일이 없다.
// BindJSON은 에러를 c.Errors에도 쌓아 둔다.
_ = c.Errors.Last()
return
}
c.JSON(http.StatusCreated, gin.H{"title": strings.TrimSpace(req.Title)})
}

:::tip 규칙 하나로 정리 Should 계열만 쓴다. Bind 계열은 프로토타입에서 잠깐 쓰거나, 남의 코드를 읽을 때 알아보기 위해 존재를 알아 두는 정도면 된다. :::

세 개의 소스, 세 개의 태그

같은 구조체에 태그를 여러 개 붙이는 것이 아니라, 소스별로 태그가 다르다.

소스태그메서드
JSON 본문json:"..."ShouldBindJSON
경로 파라미터uri:"..."ShouldBindUri
쿼리 스트링form:"..."ShouldBindQuery
폼 본문form:"..."ShouldBind (Content-Type으로 판단)
헤더header:"..."ShouldBindHeader

검증 규칙은 소스와 무관하게 binding:"..." 하나다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// CreateRequest는 POST 본문이다. 검증 규칙이 태그로 선언되어 있다.
type CreateRequest struct {
Title string `json:"title" binding:"required,notblank,maxrunes=100"`
Body string `json:"body" binding:"required,notblank"`
Priority int `json:"priority" binding:"required,oneof=1 2 3"`
Day Day `json:"day" binding:"required"`
Reviewer string `json:"reviewer" binding:"omitempty,email"`
}

notblankmaxrunes는 표준 태그가 아니다. 아래에서 직접 등록한다.

커스텀 스칼라 타입 — Day

"2026-08-12"만 받고 싶다. string으로 받아 핸들러에서 time.Parse를 부르면 그 코드가 엔드포인트마다 반복된다. 타입 하나를 만들어 해결한다.

examples/10-web-frameworks/03-gin-binding/reports/types.go
// Day는 "2026-08-12" 형식의 날짜만 받는 커스텀 스칼라 타입이다.
//
// encoding.TextUnmarshaler를 구현하면 Gin v1.12부터 URI·쿼리 바인딩에서
// 자동으로 쓰인다. JSON 본문에서도 encoding/json이 문자열 리터럴에 대해
// TextUnmarshaler를 쓰므로 같은 구현 하나로 세 자리가 전부 커버된다.
//
// time.Time을 임베딩하지 않은 것이 중요하다. 임베딩하면 time.Time의
// UnmarshalJSON이 승격되어 TextUnmarshaler보다 먼저 잡히고,
// "2026-08-12"가 RFC3339가 아니라는 이유로 디코딩이 실패한다.
type Day struct {
t time.Time
}

이 주석의 마지막 문단은 추측이 아니라 실제로 밟은 함정이다. 처음에는 type Day struct { time.Time }로 썼고, JSON 디코딩이 이렇게 실패했다.

err=&time.ParseError{Layout:"2006-01-02T15:04:05Z07:00", Value:"2026-08-12", LayoutElem:"T", ValueElem:"", Message:""}

time.Time을 임베딩하면 그 타입의 UnmarshalJSON이 승격된다. encoding/jsonjson.Unmarshalerencoding.TextUnmarshaler보다 먼저 찾으므로, 내가 구현한 UnmarshalText는 아예 불리지 않는다. 필드를 비공개로 바꾸면 해결된다.

examples/10-web-frameworks/03-gin-binding/reports/types.go
const dayLayout = "2006-01-02"

// NewDay는 time.Time에서 Day를 만든다.
func NewDay(t time.Time) Day { return Day{t: t} }

// Time은 내부 시각을 꺼낸다.
func (d Day) Time() time.Time { return d.t }

// IsZero는 값이 채워지지 않았는지 본다. validator의 required가 이걸 본다.
func (d Day) IsZero() bool { return d.t.IsZero() }

// UnmarshalText는 encoding.TextUnmarshaler 구현이다.
// 포인터 리시버여야 한다 — 값 리시버면 파싱 결과가 버려진다.
func (d *Day) UnmarshalText(text []byte) error {
s := string(text)
t, err := time.Parse(dayLayout, s)
if err != nil {
return fmt.Errorf("날짜는 YYYY-MM-DD 형식이어야 한다: %q", s)
}
d.t = t
return nil
}

// MarshalText는 JSON 응답에서 같은 형식으로 나가게 한다.
func (d Day) MarshalText() ([]byte, error) {
return []byte(d.t.Format(dayLayout)), nil
}

URI·쿼리 바인딩은 태그 옵션이 필요하다

여기가 이 챕터에서 가장 중요한 부분이다.

JSON 본문 바인딩은 UnmarshalText를 자동으로 쓴다. encoding/json이 문자열 리터럴을 만나면 TextUnmarshaler를 찾기 때문이다. Gin이 하는 일은 없다.

URI와 쿼리 바인딩은 자동이 아니다. Gin v1.12가 추가한 것은 parser=encoding.TextUnmarshaler라는 태그 옵션이다. 인터페이스를 구현했다는 사실만으로는 잡히지 않는다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// DayParam은 URI 파라미터를 받는다.
//
// parser=encoding.TextUnmarshaler가 v1.12에서 추가된 옵션이다.
// 이걸 붙여야 Gin이 Day의 UnmarshalText를 부른다 — 인터페이스를 구현했다고
// 자동으로 잡히지는 않는다. JSON 본문 바인딩과 다른 지점이다.
type DayParam struct {
Day Day `uri:"day,parser=encoding.TextUnmarshaler" binding:"required"`
}

// SearchQuery는 쿼리 스트링을 받는다.
//
// Tags에도 같은 옵션을 붙였다. 이게 없으면 Gin은 슬라이스를 보고
// tags=a&tags=b 형태로만 받으려 하고, "a,b"를 원소 하나로 넣는다.
type SearchQuery struct {
From Day `form:"from,parser=encoding.TextUnmarshaler" binding:"required"`
To Day `form:"to,parser=encoding.TextUnmarshaler" binding:"required"`
Tags Tags `form:"tags,parser=encoding.TextUnmarshaler" binding:"omitempty"`
Order string `form:"order,default=asc" binding:"oneof=asc desc"`
}

옵션을 빼면 Gin은 구조체를 JSON으로 파싱하려 시도하고 이렇게 터진다.

invalid character '-' after top-level value

"2026-08-01"을 JSON 값으로 읽으려 한 결과다.

:::note 자동으로 잡히는 다른 인터페이스 Gin에는 binding.BindUnmarshaler라는 자체 인터페이스도 있다.

gin v1.12.0 binding/form_mapping.go (인용)
// BindUnmarshaler is the interface used to wrap the UnmarshalParam method.
type BindUnmarshaler interface {
// UnmarshalParam decodes and assigns a value from a form or query param.
UnmarshalParam(param string) error
}

이쪽은 태그 옵션 없이 자동으로 쓰인다. 다만 Gin 전용이라 JSON 본문에서는 동작하지 않는다. 한 타입으로 세 소스를 모두 커버하려면 encoding.TextUnmarshaler를 구현하고 URI·쿼리 태그에 parser= 옵션을 붙이는 편이 낫다. :::

Tags도 같은 방식으로 쉼표 구분을 처리한다.

examples/10-web-frameworks/03-gin-binding/reports/types.go
// Tags는 "a,b,c"를 슬라이스로 받는 커스텀 타입이다.
// 쿼리에서 tags=a,b,c 한 번으로 끝난다.
type Tags []string

// UnmarshalText는 쉼표로 자르고 공백을 턴다.
func (t *Tags) UnmarshalText(text []byte) error {
raw := strings.Split(string(text), ",")
out := make(Tags, 0, len(raw))
for _, s := range raw {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return fmt.Errorf("tags가 비어 있다")
}
*t = out
return nil
}

커스텀 검증자와 필드 이름

binding.Validator는 gin 패키지 수준의 전역이다. 거기에 태그를 등록한다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// registerValidators는 커스텀 태그를 등록한다.
// binding.Validator는 gin 패키지 전역이라 한 번만 등록해야 한다.
func registerValidators() {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
panic("reports: binding.Validator가 *validator.Validate가 아니다")
}

// 필드 이름을 Go 이름이 아니라 json 태그 이름으로 보고한다.
// 이걸 안 하면 응답 fields의 키가 "Title"이 된다.
v.RegisterTagNameFunc(func(f reflect.StructField) string {
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "" || name == "-" {
name, _, _ = strings.Cut(f.Tag.Get("form"), ",")
}
if name == "" || name == "-" {
return f.Name
}
return name
})

RegisterTagNameFunc이 없으면 검증 실패 응답의 키가 Go 필드 이름(Title)으로 나간다. 클라이언트가 보는 것은 JSON 키(title)여야 하므로 이 등록은 사실상 필수다.

다음이 이 챕터에서 두 번째로 중요한 부분이다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// 커스텀 구조체 타입을 validator에게 소개한다.
//
// 이게 없으면 Day에 붙인 required가 조용히 무시된다. validator는
// 구조체 필드를 만나면 그 안으로 파고들 뿐 필드 자체의 태그를 보지 않는다
// (time.Time만 예외로 특별 취급한다). RegisterCustomTypeFunc으로
// "Day는 사실 time.Time이다"라고 알려 주면 required가 살아난다.
v.RegisterCustomTypeFunc(func(field reflect.Value) any {
if d, ok := field.Interface().(Day); ok {
return d.Time()
}
return nil
}, Day{})

"조용히 무시된다"가 핵심이다. 에러도 경고도 나지 않는다. binding:"required"를 붙여 놨으니 검증된다고 믿고 있다가, 빈 날짜가 그대로 통과하는 것을 프로덕션에서 발견하게 된다. 이 강의를 쓰면서도 테스트가 먼저 잡아 줬다.

나머지 두 태그는 평범하다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
if err := v.RegisterValidation("notblank", func(fl validator.FieldLevel) bool {
return strings.TrimSpace(fl.Field().String()) != ""
}); err != nil {
panic(err)
}
if err := v.RegisterValidation("maxrunes", func(fl validator.FieldLevel) bool {
limit, err := strconv.Atoi(fl.Param())
if err != nil {
return false
}
return utf8.RuneCountInString(strings.TrimSpace(fl.Field().String())) <= limit
}); err != nil {
panic(err)
}
}

둘 다 표준 태그로는 못 하는 일을 한다.

  • required""만 막고 " "는 통과시킨다. → notblank
  • max=100은 문자열에 len()을 쓴다. 한글은 세 배로 세어진다. → maxrunes

:::warning 전역 등록의 대가 binding.Validator는 프로세스 하나에 하나다. 태그 이름은 프로세스 전체에서 유일해야 하고, 두 패키지가 같은 이름으로 다른 규칙을 등록하면 나중 것이 이긴다. 라이브러리에서 태그를 등록하는 것은 사실상 전역 네임스페이스를 오염시키는 일이다. 등록은 main이나 애플리케이션 패키지 한 곳에 모으고, sync.Once로 감싸는 편이 안전하다(10-8에서 그렇게 한다). :::

400과 422를 가른다

9-8은 400은 "못 읽겠다", 422는 "값이 틀렸다" 로 정했다. Gin에서는 이 구분을 에러 타입으로 해야 한다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// abortBindError는 "못 읽었다"(400)와 "값이 틀렸다"(422)를 나눈다.
// 이 분기가 없으면 두 경우가 같은 상태 코드로 나가 클라이언트가 구분하지 못한다.
func abortBindError(c *gin.Context, err error) {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
Code: "validation", Message: "입력이 올바르지 않다", Fields: fieldMessages(err),
})
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorBody{
Code: "bad_json", Message: "본문을 읽을 수 없다",
})
}

validator.ValidationErrors면 422, 아니면 400이다. 간단하지만 이 세 줄이 없으면 "JSON이 깨졌다"와 "제목이 비었다"가 같은 응답을 낸다.

메시지는 여전히 손으로 쓴다

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// message는 태그별 문장을 만든다. validator는 영어 기본 메시지조차 주지 않고
// "Key: 'X.Y' Error:Field validation for 'Y' failed on the 'required' tag"라는
// 디버그용 문자열만 준다. 사용자에게 보일 문장은 결국 여기서 쓴다.
func message(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "필수 항목이다"
case "notblank":
return "공백만으로는 안 된다"
case "maxrunes":
return fe.Param() + "자를 넘을 수 없다"
case "oneof":
return strings.ReplaceAll(fe.Param(), " ", ", ") + " 중 하나여야 한다"
case "email":
return "이메일 형식이 아니다"
}
return "값이 올바르지 않다"
}

필드가 아니라 태그로 분기한다는 점이 중요하다. 필드로 분기하면 필드가 늘 때마다 이 함수가 커진다. 태그로 분기하면 새 필드는 기존 태그를 재사용하므로 이 함수가 안 커진다. fe.Param()maxrunes=100100, oneof=1 2 31 2 3을 준다.

fieldMessages가 그 위를 감싼다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
// fieldMessages는 validator 에러를 필드별 한국어 메시지로 바꾼다.
func fieldMessages(err error) map[string]string {
var verrs validator.ValidationErrors
if !errors.As(err, &verrs) {
return nil
}
out := make(map[string]string, len(verrs))
for _, fe := range verrs {
out[fe.Field()] = message(fe)
}
return out
}

형식과 의미를 나눈다

태그로 표현할 수 있는 것은 한 필드의 형식뿐이다. 필드 사이의 관계는 코드다.

examples/10-web-frameworks/03-gin-binding/reports/reports.go
func search(c *gin.Context) {
var q SearchQuery
if err := c.ShouldBindQuery(&q); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorBody{
Code: "bad_query", Message: "쿼리가 올바르지 않다",
Fields: fieldMessages(err),
})
return
}
if q.To.Time().Before(q.From.Time()) {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
Code: "validation", Message: "입력이 올바르지 않다",
Fields: map[string]string{"to": "from보다 빠를 수 없다"},
})
return
}
c.JSON(http.StatusOK, gin.H{
"from": q.From, "to": q.To, "tags": q.Tags, "order": q.Order,
})
}

from > to는 각 필드만 보면 완벽히 올바른 날짜다. 그래서 바인딩은 통과하고 의미 검증에서 걸린다 — 400이 아니라 422다.

실행

go run ./03-gin-binding
POST /reports → 201 {"day":"2026-08-12","title":"주간 보고"}
POST /reports → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"body":"필수 항목이다","priority":"1, 2, 3 중 하나여야 한다","title":"필수 항목이다"}}
POST /reports → 400 {"code":"bad_json","message":"본문을 읽을 수 없다"}
POST /reports-bind → 400
GET /reports/2026-08-12 → 200 {"day":"2026-08-12","weekday":"Wednesday"}
GET /reports/12-08-2026 → 400 {"code":"bad_path","message":"day는 YYYY-MM-DD 형식이어야 한다"}
GET /search?from=2026-08-01&to=2026-08-31&tags=go,%20web → 200 {"from":"2026-08-01","order":"asc","tags":["go","web"],"to":"2026-08-31"}
GET /search?from=2026-08-01 → 400 {"code":"bad_query","message":"쿼리가 올바르지 않다","fields":{"to":"필수 항목이다"}}
GET /search?from=2026-08-31&to=2026-08-01 → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"to":"from보다 빠를 수 없다"}}

BindJSON 실패 시 Gin이 쓰는 응답: status=400 Content-Type="" 본문 길이=0

세 번째 줄을 눈여겨볼 것. {"day":"2026/08/12"}422가 아니라 400이다. 날짜 형식 오류는 UnmarshalText가 반환한 에러이므로 검증이 아니라 디코딩 실패다. 검증 규칙을 타입에 넣으면 그 규칙 위반은 400 쪽으로 분류된다는 뜻이다 — 의도적인 설계 판단이어야지, 모르고 그렇게 되면 안 된다.

흔한 실수

1. Bind 계열을 쓴다

빈 본문의 400이 나가고 에러 형식을 통제할 수 없다. Should 계열만 쓴다.

2. time.Time을 임베딩한 커스텀 날짜 타입

UnmarshalJSON이 승격되어 UnmarshalText가 무시된다. 필드를 비공개로 둔다.

3. URI·쿼리에 parser=encoding.TextUnmarshaler를 빼먹는다

JSON에서는 되는데 경로에서는 안 되는 이상한 상태가 된다.

4. 커스텀 구조체 타입에 required를 붙여 놓고 안심한다

RegisterCustomTypeFunc이 없으면 조용히 무시된다.

5. omitempty로 "0은 안 된다"를 표현하려 한다

Limit int + binding:"omitempty,gt=0"에서 limit=0통과한다. validator의 omitempty는 제로값을 "없음"으로 보고 뒤 규칙을 건너뛴다. 9-5의 "제로값과 미지정을 구분하는 법"이 태그 문법에서 다시 나오는 것이다. 이런 필드는 태그를 포기하고 손으로 판다.

6. max=100으로 글자 수를 제한한다

문자열에 len()을 쓴다. 한글 100자가 300으로 세어진다. maxrunes 같은 커스텀 태그가 필요하다.

7. RegisterTagNameFunc을 안 쓴다

응답의 fields 키가 Title, Reviewer처럼 Go 필드 이름으로 나간다.

8. 검증 실패를 첫 개만 돌려준다

validator.ValidationErrors는 슬라이스다. 전부 순회해서 모아 준다. 9-8의 "왕복을 줄인다"와 같은 이유다.

정리

  • Should 계열만 쓴다. Bind 계열은 본문 없는 400을 스스로 써 버린다.
  • 소스마다 태그가 다르고(json/uri/form/header) 검증 태그는 하나(binding)다.
  • 커스텀 스칼라 타입은 encoding.TextUnmarshaler로 만든다. JSON 본문은 자동이지만 URI·쿼리는 parser=encoding.TextUnmarshaler 태그 옵션이 필요하다(v1.12).
  • time.Time을 임베딩하지 않는다. UnmarshalJSON 승격이 UnmarshalText를 가린다.
  • 커스텀 구조체 타입에는 RegisterCustomTypeFunc이 필요하다. 없으면 required가 조용히 무시된다.
  • 메시지는 태그별로 한 번 쓴다. 필드별로 쓰면 확장이 안 된다.
  • ValidationErrors면 422, 아니면 400. 이 분기가 9-8의 규약을 지켜 준다.
  • 필드 간 관계 검증은 태그로 못 한다. 바인딩 후 코드로 하고, 422로 낸다.

연습문제

  1. CreateRequestAssignees []string을 추가하고 "1명 이상 5명 이하, 각 원소는 이메일 형식"을 태그로 표현해 보자. (힌트: dive) 실패했을 때 fe.Field()는 무엇을 반환하는가? 응답의 fields 키를 assignees[0]처럼 만들려면 무엇이 더 필요한가?

  2. Priorityoneof=1 2 3 대신 Priority 타입을 만들고 encoding.TextUnmarshaler로 검증해 보자. 잘못된 값이 왔을 때 상태 코드가 422에서 400으로 바뀐다. 어느 쪽이 옳은가? 그 판단을 테스트로 못 박아 보자.

  3. SearchQueryLimit intform:"limit" binding:"omitempty,gt=0"으로 추가하고 ?limit=0을 보내 보자. 통과하는가? 통과한다면, 태그만으로 "선택 항목이지만 있으면 양수여야 한다"를 표현할 방법이 있는가? 없다면 *int로 바꾸면 되는가 — 직접 확인해 보자.