Gin 기초 #3 요청 바인딩과 검증

5 분 소요

지난 글에서 라우팅을 다뤘습니다. 이번 글은 들어오는 요청 데이터를 Go 구조체로 받는 바인딩과, 그 값이 올바른지 확인하는 검증을 정리합니다. POST/PUT 요청을 제대로 처리하려면 반드시 거쳐야 하는 단계입니다.

  • #1 시작과 첫 서버
  • #2 라우팅과 핸들러
  • #3 요청 바인딩과 검증 ← 이번 글
  • #4 응답 처리 — JSON, 상태 코드, 에러
  • #5 미들웨어
  • #6 데이터베이스 연동 (GORM)
  • #7 프로젝트 구조와 미니 REST API

바인딩이란? #

클라이언트가 보낸 JSON 본문이나 폼 데이터를, 미리 정의한 Go 구조체에 채워 넣는 작업을 바인딩이라고 합니다. 필드를 하나씩 꺼내 변환하는 대신, 구조체 하나로 한 번에 받습니다.

구조체 정의
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

json:"name" 태그는 JSON의 어떤 키를 어떤 필드에 매핑할지 지정합니다. JSON 키가 name이면 Name 필드에 들어갑니다.

JSON 본문 바인딩 — ShouldBindJSON #

c.ShouldBindJSON이 요청 본문을 읽어 구조체에 채웁니다.

JSON 바인딩
func createUser(c *gin.Context) {
	var req CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"received": req})
}
요청
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"커티스","email":"a@b.com","age":30}'
# {"received":{"name":"커티스","email":"a@b.com","age":30}}

구조체의 포인터를 넘긴 점에 주목하세요. 바인딩은 구조체의 값을 채워야 하므로 &req로 주소를 전달합니다. 본문 형식이 잘못됐거나 타입이 맞지 않으면 에러가 돌아오고, 위 코드는 400으로 응답한 뒤 return으로 처리를 끝냅니다.

검증 — binding 태그 #

바인딩만으로는 “값이 들어왔는지”, “형식이 맞는지"까지 확인하지 못합니다. Gin은 구조체 태그로 검증 규칙을 선언하는 기능을 내장하고 있습니다. binding 태그에 규칙을 적습니다.

검증 규칙 추가
type CreateUserRequest struct {
	Name  string `json:"name" binding:"required"`
	Email string `json:"email" binding:"required,email"`
	Age   int    `json:"age" binding:"gte=0,lte=130"`
}
  • required — 값이 반드시 있어야 함
  • email — 이메일 형식이어야 함
  • gte=0,lte=130 — 0 이상 130 이하

이제 규칙에 어긋나는 요청은 ShouldBindJSON 단계에서 에러가 됩니다.

검증 실패 요청
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email","age":200}'
# 400, 검증 에러 메시지

자주 쓰는 규칙을 몇 가지 더 정리합니다.

태그의미
required값이 비어 있으면 안 됨
email이메일 형식
min=3 / max=20문자열 길이 또는 숫자 크기 범위
gte=0 / lte=100이상 / 이하
oneof=admin user나열된 값 중 하나
len=10정확한 길이

검증 엔진은 go-playground/validator이며, 더 많은 규칙은 해당 문서에서 확인할 수 있습니다.

ShouldBind vs Bind #

Gin의 바인딩 함수는 두 계열로 나뉩니다.

  • ShouldBindJSON 계열 — 에러를 돌려주기만 합니다. 응답은 직접 작성합니다.
  • BindJSON 계열 — 에러가 나면 자동으로 400과 함께 요청을 중단(abort)합니다.
두 방식 비교
// 직접 에러 처리 — 권장
if err := c.ShouldBindJSON(&req); err != nil {
	c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
	return
}

// 자동 400 — 응답 형식을 통제하기 어려움
if err := c.BindJSON(&req); err != nil {
	return // Gin이 이미 400 응답을 보냄
}

API의 에러 응답 형식을 일관되게 통제하려면 ShouldBind 계열을 쓰는 편이 낫습니다. 이 시리즈는 ShouldBind를 기본으로 씁니다.

쿼리와 경로도 바인딩 #

본문뿐 아니라 쿼리 스트링과 경로 파라미터도 구조체로 받을 수 있습니다. 파라미터가 많을 때 핸들러가 깔끔해집니다.

쿼리 바인딩
type ListQuery struct {
	Page int    `form:"page,default=1" binding:"gte=1"`
	Size int    `form:"size,default=20" binding:"gte=1,lte=100"`
	Sort string `form:"sort"`
}

func listUsers(c *gin.Context) {
	var q ListQuery
	if err := c.ShouldBindQuery(&q); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"query": q})
}

쿼리는 form 태그로 키를 지정하고, default=로 기본값을 줄 수 있습니다. 경로 파라미터는 uri 태그와 c.ShouldBindUri를 씁니다.

경로 바인딩
type UserURI struct {
	ID int `uri:"id" binding:"required"`
}

func getUser(c *gin.Context) {
	var u UserURI
	if err := c.ShouldBindUri(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"userID": u.ID})
}

폼 데이터 바인딩 #

application/x-www-form-urlencodedmultipart/form-data 폼도 같은 방식으로 받습니다. form 태그를 쓰고 c.ShouldBind를 호출하면 Gin이 Content-Type을 보고 알맞게 처리합니다.

폼 바인딩
type LoginForm struct {
	Username string `form:"username" binding:"required"`
	Password string `form:"password" binding:"required"`
}

func login(c *gin.Context) {
	var form LoginForm
	if err := c.ShouldBind(&form); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"user": form.Username})
}

검증 에러를 다듬어 응답하기 #

err.Error()를 그대로 내보내면 메시지가 장황하고 사용자가 읽기 어렵습니다. 어떤 필드가 왜 실패했는지 정리해서 돌려주면 클라이언트가 처리하기 좋습니다.

필드별 에러 정리
import "github.com/go-playground/validator/v10"

if err := c.ShouldBindJSON(&req); err != nil {
	var ve validator.ValidationErrors
	if errors.As(err, &ve) {
		out := make(map[string]string, len(ve))
		for _, fe := range ve {
			out[fe.Field()] = fe.Tag() // 예: "Email": "email"
		}
		c.JSON(http.StatusBadRequest, gin.H{"errors": out})
		return
	}
	c.JSON(http.StatusBadRequest, gin.H{"error": "잘못된 요청"})
	return
}

에러 메시지를 더 정교하게 다듬는 작업은 중급 시리즈의 에러 처리 편에서 이어가겠습니다.

마무리 #

이번 글에서 정리한 내용입니다.

  • 바인딩은 요청 데이터를 Go 구조체에 채우는 작업, json/form/uri 태그로 매핑
  • 본문은 c.ShouldBindJSON, 쿼리는 c.ShouldBindQuery, 경로는 c.ShouldBindUri
  • 구조체 포인터(&req)를 넘겨야 값이 채워짐
  • binding 태그로 검증 규칙 선언, required/email/gte/oneof
  • ShouldBind 계열은 에러만 반환, Bind 계열은 자동 400, 통제력은 ShouldBind가 큼
  • 검증 에러는 validator.ValidationErrors로 필드별로 정리해 응답 가능

다음 글(#4 응답 처리)에서는 JSON 외의 다양한 응답 형식과 상태 코드, 그리고 일관된 에러 응답을 만드는 법을 정리하겠습니다.

X