Gin 기초 #2 라우팅과 핸들러
지난 글에서 첫 Gin 서버를 띄웠습니다. 이번 글은 라우팅의 기본기를 정리합니다. HTTP 메서드별 라우트 등록, 경로 파라미터와 쿼리 스트링 읽기, 그리고 라우터 그룹으로 엔드포인트를 묶는 법까지 다룹니다.
- #1 시작과 첫 서버
- #2 라우팅과 핸들러 ← 이번 글
- #3 요청 바인딩과 검증
- #4 응답 처리 — JSON, 상태 코드, 에러
- #5 미들웨어
- #6 데이터베이스 연동 (GORM)
- #7 프로젝트 구조와 미니 REST API
HTTP 메서드별 라우트 등록 #
Gin은 메서드마다 대응하는 함수를 제공합니다. 같은 경로라도 메서드가 다르면 서로 다른 핸들러를 등록할 수 있습니다.
r := gin.Default()
r.GET("/users", listUsers)
r.POST("/users", createUser)
r.GET("/users/:id", getUser)
r.PUT("/users/:id", updateUser)
r.PATCH("/users/:id", patchUser)
r.DELETE("/users/:id", deleteUser)이렇게 메서드와 경로의 조합으로 엔드포인트를 정의합니다. REST API의 기본 골격이 그대로 드러납니다. 핸들러를 익명 함수가 아니라 이름 있는 함수로 분리한 점에 주목하세요. 엔드포인트가 늘어나면 핸들러를 함수로 빼는 편이 훨씬 읽기 좋습니다.
경로 파라미터 — :이름
#
경로의 일부를 변수로 받으려면 콜론을 붙입니다. c.Param으로 값을 읽습니다.
r.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"userID": id})
})curl http://localhost:8080/users/42
# {"userID":"42"}c.Param이 돌려주는 값은 항상 문자열입니다. 숫자로 다뤄야 한다면 strconv.Atoi 등으로 직접 변환해야 합니다.
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "id는 숫자여야 합니다"})
return
}파라미터는 여러 개를 쓸 수도 있습니다.
r.GET("/users/:id/posts/:postID", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"userID": c.Param("id"),
"postID": c.Param("postID"),
})
})와일드카드 — *이름
#
경로의 나머지 전체를 받고 싶을 때는 별표를 씁니다. 파일 경로처럼 슬래시가 여러 개 들어가는 값을 받을 때 유용합니다.
r.GET("/files/*filepath", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"path": c.Param("filepath")})
})curl http://localhost:8080/files/images/logo.png
# {"path":"/images/logo.png"}와일드카드로 받은 값은 맨 앞에 슬래시가 붙어 있습니다.
쿼리 스트링 — ?key=value
#
URL 뒤에 붙는 쿼리 스트링은 c.Query로 읽습니다.
r.GET("/search", func(c *gin.Context) {
keyword := c.Query("q")
c.JSON(http.StatusOK, gin.H{"keyword": keyword})
})curl "http://localhost:8080/search?q=golang"
# {"keyword":"golang"}값이 없을 때 기본값을 주고 싶으면 c.DefaultQuery를 씁니다. 페이지네이션 파라미터에 자주 쓰는 방식입니다.
page := c.DefaultQuery("page", "1")
size := c.DefaultQuery("size", "20")c.Query는 값이 없으면 빈 문자열을 돌려줍니다. 값의 존재 여부 자체를 알아야 한다면 c.GetQuery를 쓰면 됩니다.
value, ok := c.GetQuery("q")
if !ok {
// q 파라미터 자체가 없음
}같은 키가 여러 번 올 때는 c.QueryArray로 배열을 받습니다.
// /filter?tag=go&tag=web
tags := c.QueryArray("tag") // ["go", "web"]라우터 그룹 — 공통 prefix 묶기 #
API 버전이나 리소스별로 경로의 앞부분이 반복됩니다. 라우터 그룹으로 공통 prefix를 한 번만 적습니다.
r := gin.Default()
v1 := r.Group("/api/v1")
{
v1.GET("/users", listUsers)
v1.POST("/users", createUser)
v1.GET("/users/:id", getUser)
}위 코드는 /api/v1/users 같은 경로를 만듭니다. 그룹을 감싼 중괄호는 문법적으로 필수는 아니지만, 어떤 라우트가 그룹에 속하는지 시각적으로 묶어 주는 관례입니다.
그룹은 중첩할 수도 있습니다.
v1 := r.Group("/api/v1")
{
users := v1.Group("/users")
{
users.GET("", listUsers) // /api/v1/users
users.GET("/:id", getUser) // /api/v1/users/:id
}
}그룹은 단순히 경로를 묶는 데서 끝나지 않습니다. 그룹 단위로 미들웨어를 걸 수 있어서, 예를 들어 인증이 필요한 라우트만 한 그룹에 모으는 식으로 활용합니다. 이 부분은 #5 미들웨어에서 다루겠습니다.
핸들러를 함수로 분리하기 #
핸들러를 라우트 등록부에 인라인으로 다 적으면 main 함수가 금방 비대해집니다. 핸들러는 이름 있는 함수로 분리하는 편이 좋습니다.
func getUser(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"userID": id})
}
func main() {
r := gin.Default()
r.GET("/users/:id", getUser)
r.Run()
}핸들러의 타입은 gin.HandlerFunc, 즉 func(c *gin.Context)입니다. 이 형태만 지키면 어디에 정의하든 라우트에 등록할 수 있습니다. 파일을 나누는 본격적인 구조는 #7 프로젝트 구조에서 정리하겠습니다.
매칭되지 않는 경로 — NoRoute #
등록되지 않은 경로로 요청이 오면 Gin은 기본 404를 돌려줍니다. 응답 형식을 직접 정하고 싶으면 NoRoute 핸들러를 등록합니다.
r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "경로를 찾을 수 없습니다"})
})API 전체가 일관된 JSON 에러 형식을 갖도록 맞출 때 유용합니다.
마무리 #
이번 글에서 정리한 내용입니다.
- 메서드별로
r.GET,r.POST,r.PUT,r.DELETE등으로 라우트를 등록 - 경로 파라미터는
:이름으로 선언하고c.Param으로 읽음, 값은 항상 문자열 - 경로 나머지 전체는 와일드카드
*이름으로 받음 - 쿼리 스트링은
c.Query, 기본값은c.DefaultQuery, 배열은c.QueryArray r.Group으로 공통 prefix를 묶고, 중첩과 그룹별 미들웨어가 가능- 핸들러는
func(c *gin.Context)형태의 이름 있는 함수로 분리 - 매칭 안 되는 경로는
NoRoute로 응답 형식을 통제
다음 글(#3 요청 바인딩과 검증)에서는 요청 본문을 구조체로 받는 바인딩과, validator 태그로 입력을 검증하는 법을 정리하겠습니다.