Gin 시작하기
이 챕터에서 다루는 것
Gin v1.12로 라우터를 조립하고, gin.Context가 무엇을 들고 있는지 본다.
9-7의 ServeMux와 하나씩 대응시켜 가며 읽으면 빠르다.
설치
go get github.com/gin-gonic/gin@v1.12.0
v1.12는 최소 Go 버전을 1.24로 올렸다. 이 강의는 Go 1.26.5를 쓰므로 문제없다.
두 개의 타입
Gin에서 알아야 할 타입은 사실상 둘이다.
*gin.Engine— 라우터.http.Handler를 구현한다.*gin.Context— 요청 하나의 전부.http.ResponseWriter와*http.Request를 둘 다 들고 있고, 그 위에 편의 메서드가 얹혀 있다.
핸들러 시그니처는 func(*gin.Context)다. 반환값이 없다는 점이 중요하다 —
응답은 c에 쓴다. (Fiber는 error를 반환한다. 10-5에서 본다.)
엔진 만들기 — New와 Default
// NewEngine은 라우터를 조립한다.
//
// gin.New()는 미들웨어가 하나도 없는 빈 엔진이다.
// gin.Default()는 여기에 Logger와 Recovery를 미리 끼워 준다 — 편하지만
// 로그가 stdout으로 나가는 형식까지 따라오므로, 형식을 통제하려면 New가 낫다.
func NewEngine() *gin.Engine {
r := gin.New()
// 라우팅의 최소형. gin.Context 하나가 요청과 응답을 다 들고 있다.
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})
// 라우터 그룹. 접두사와 미들웨어를 묶어서 건다.
v1 := r.Group("/v1")
v1.Use(apiVersion("v1"))
{
v1.GET("/books", listBooks)
v1.GET("/books/:id", getBook)
}
// *로 시작하면 나머지 경로 전체를 잡는다. 9-7의 {path...}에 해당한다.
r.GET("/files/*path", func(c *gin.Context) {
// 앞에 슬래시가 붙어서 들어온다. 이것이 {path...}와 다른 점이다.
c.JSON(http.StatusOK, gin.H{"path": c.Param("path")})
})
return r
}
:::tip 중괄호 블록은 문법이 아니다
v1.Use(...) 뒤의 { ... }는 Go의 그냥 블록이다. Gin이 요구하는 것이 아니라,
"이 라우트들이 이 그룹에 속한다"를 눈에 보이게 하는 관례일 뿐이다. 없어도 똑같이 돈다.
:::
라우팅 표기 대응표
net/http ServeMux (9-7) | Gin |
|---|---|
mux.HandleFunc("GET /tasks", h) | r.GET("/tasks", h) |
/tasks/{id} + r.PathValue("id") | /tasks/:id + c.Param("id") |
/files/{path...} → "a/b.txt" | /files/*path → "/a/b.txt" |
/{$} (정확히 /만) | 대응 없음 |
| 후행 슬래시 307 리다이렉트 | 기본 301 리다이렉트 (RedirectTrailingSlash) |
자동 405 + Allow | HandleMethodNotAllowed = true여야 함 (기본 false) |
와일드카드가 슬래시를 포함해 들어온다는 것과, 405가 기본으로 꺼져 있다는 것 — 이 둘은 9-8 코드를 옮길 때 반드시 걸린다.
gin.Context가 대신해 주는 것
func listBooks(c *gin.Context) {
// DefaultQuery는 값이 없으면 두 번째 인자를 준다.
// r.URL.Query().Get()에 if문을 붙이던 자리다.
q := strings.ToLower(c.DefaultQuery("q", ""))
out := make([]Book, 0, len(books))
for _, b := range books {
if q == "" || strings.Contains(strings.ToLower(b.Title), q) {
out = append(out, b)
}
}
// gin.H는 map[string]any의 별칭이다. 짧게 쓰라고 있는 것뿐이다.
c.JSON(http.StatusOK, gin.H{"items": out, "count": len(out)})
}
c.JSON(status, v)가 하는 일은 세 가지다: Content-Type 설정, 상태 코드 쓰기,
v를 직렬화해 본문에 쓰기. 9-7에서 writeJSON으로 만들던 것과 같다.
자주 쓰는 응답 메서드:
| 메서드 | 하는 일 |
|---|---|
c.JSON(200, v) | JSON 직렬화. HTML 이스케이프가 켜져 있다 |
c.PureJSON(200, v) | HTML 이스케이프 없이 |
c.String(200, "%s", s) | text/plain |
c.Data(200, ct, b) | 바이트 그대로 |
c.Status(204) | 본문 없이 상태 코드만 |
c.Header(k, v) | 응답 헤더. c.JSON 앞에 불러야 한다 |
:::warning 9-7의 순서 규칙은 그대로다
c.Header는 c.Writer.Header().Set을 부를 뿐이다. c.JSON이 WriteHeader를
부르고 나면 헤더 변경은 조용히 무시된다. Gin이 이 규칙을 없애 주지는 않는다.
:::
경로 파라미터는 여전히 문자열이다
func getBook(c *gin.Context) {
// Param은 항상 string이다. 숫자 변환과 검증은 여전히 내 몫이다.
// 10-3에서 ShouldBindUri로 이 부분까지 걷어낸다.
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "id는 숫자여야 한다"})
return
}
for _, b := range books {
if b.ID == id {
c.JSON(http.StatusOK, b)
return
}
}
c.JSON(http.StatusNotFound, gin.H{"message": "없는 책이다"})
}
r.PathValue와 다를 게 없다. 프레임워크가 여기서 해 주는 것은 없다.
실행
go run ./02-gin-basics
GET /ping → 200 pong
GET /v1/books → 200 X-API-Version=v1 {"count":3,"items":[{"id":1,"title":"The Go Programming Language","author":"Donovan"},{"id":2,"title":"Go in Action","author":"Kennedy"},{"id":3,"title":"Learning Go","author":"Bodner"}]}
GET /v1/books?q=go → 200 X-API-Version=v1 {"count":3,"items":[{"id":1,"title":"The Go Programming Language","author":"Donovan"},{"id":2,"title":"Go in Action","author":"Kennedy"},{"id":3,"title":"Learning Go","author":"Bodner"}]}
GET /v1/books?q=action → 200 X-API-Version=v1 {"count":1,"items":[{"id":2,"title":"Go in Action","author":"Kennedy"}]}
GET /v1/books/1 → 200 X-API-Version=v1 {"id":1,"title":"The Go Programming Language","author":"Donovan"}
GET /v1/books/99 → 404 X-API-Version=v1 {"message":"없는 책이다"}
GET /v1/books/abc → 400 X-API-Version=v1 {"message":"id는 숫자여야 한다"}
GET /files/img/logo.png → 200 {"path":"/img/logo.png"}
GET /v1/books/ → 301 Location=/v1/books <a href="/v1/books">Moved Permanently</a>.
세 줄만 짚으면 된다.
q=go가 셋 다 걸린 것은 버그가 아니다.The **Go** Programming Language에도go가 들어 있다./files/img/logo.png의 결과가/img/logo.png다. 앞에 슬래시가 붙는다./v1/books/가 301이다.ServeMux의 307과 다르다. GET이라 차이가 안 보이지만, POST였다면 301은 메서드를 GET으로 바꿔 버렸을 것이다. Gin은 GET 외의 메서드에는 307을 쓴다.
릴리스 모드
// 릴리스 모드. 디버그 로그(라우트 목록, 경고)가 사라진다.
// 환경 변수 GIN_MODE=release로도 같은 효과를 낸다.
gin.SetMode(gin.ReleaseMode)
디버그 모드(기본)에서는 시작할 때 라우트 목록과 경고를 stdout에 찍는다.
개발 중에는 유용하지만 프로덕션 로그를 더럽히고, [GIN-debug] [WARNING] Running in "debug" mode가 계속 나온다.
:::warning gin.SetMode는 프로세스 전역이다
패키지 수준 변수를 바꾼다. 라이브러리 패키지에서 부르면 그 패키지를 쓰는 모든 코드에
영향을 준다. main이나 테스트의 TestMain에서 한 번만 부르는 것이 맞다.
이 강의의 예제 패키지들은 출력을 결정적으로 만들기 위해 예외적으로 init에서 부르고,
그 사실을 주석으로 남겨 뒀다.
:::
엔진은 http.Handler다 — 이것이 핵심이다
// TestEngineIsHTTPHandler는 Gin 엔진을 표준 서버에 그대로 꽂을 수 있음을 보인다.
// Fiber에서는 이게 안 된다(10-5).
func TestEngineIsHTTPHandler(t *testing.T) {
var h http.Handler = catalog.NewEngine()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
resp, err := srv.Client().Get(srv.URL + "/ping")
if err != nil {
t.Fatalf("요청: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
t.Errorf("본문 닫기: %v", err)
}
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("본문: %v", err)
}
if string(body) != "pong" {
t.Errorf("body = %q, want pong", body)
}
}
var h http.Handler = catalog.NewEngine()이 컴파일된다는 사실 하나로 다음이 전부 따라온다.
httptest.NewServer와httptest.NewRecorder를 그대로 쓴다.- 9-7에서 만든
func(http.Handler) http.Handler미들웨어를 바깥에 씌울 수 있다. http.Server의 타임아웃 필드를 그대로 쓴다.
그래서 서버 조립은 9-8과 완전히 같다.
// listenAndServe는 Gin 엔진을 표준 http.Server에 얹는다.
// r.Run(addr)이라는 단축 함수도 있지만, 그러면 9-7에서 배운
// 타임아웃 설정을 걸 자리가 없어진다.
func listenAndServe(r http.Handler, addr string) error {
srv := &http.Server{
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("리스닝: %w", err)
}
fmt.Printf("listening on http://%s (Ctrl+C로 종료)\n", ln.Addr())
return srv.Serve(ln)
}
테스트
http.Handler이므로 8장에서 쓰던 것이 그대로 통한다.
// do는 엔진에 요청 하나를 넣고 결과를 꺼낸다.
// gin.Engine이 http.Handler이므로 8장에서 쓰던 ResponseRecorder가 그대로 쓰인다.
func do(t *testing.T, h http.Handler, method, target string) (*httptest.ResponseRecorder, string) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
body, err := io.ReadAll(rec.Result().Body)
if err != nil {
t.Fatalf("본문 읽기: %v", err)
}
return rec, strings.TrimSpace(string(body))
}
테스트 파일에서는 TestMain으로 모드를 고정한다.
func TestMain(m *testing.M) {
// 테스트 출력에 디버그 로그가 섞이지 않게 한다.
// gin.SetMode는 프로세스 전역이므로 TestMain이 제자리다.
gin.SetMode(gin.ReleaseMode)
m.Run()
}
그룹 미들웨어가 그룹 밖으로 새지 않는지도 테스트로 못 박아 둔다.
// TestGroupMiddlewareScope는 그룹 미들웨어가 그룹 밖으로 새지 않는지 확인한다.
func TestGroupMiddlewareScope(t *testing.T) {
r := catalog.NewEngine()
if rec, _ := do(t, r, http.MethodGet, "/v1/books"); rec.Header().Get("X-API-Version") != "v1" {
t.Error("그룹 안에서 X-API-Version이 없다")
}
if rec, _ := do(t, r, http.MethodGet, "/ping"); rec.Header().Get("X-API-Version") != "" {
t.Error("그룹 밖으로 미들웨어가 샜다")
}
}
후행 슬래시 동작도 테스트로 고정한다. 프레임워크 기본값은 바뀔 수 있고, 바뀌면 테스트가 먼저 알려 준다.
// TestTrailingSlashRedirectIs301은 Gin의 기본 후행 슬래시 동작을 못 박는다.
// 9-7에서 ServeMux가 307을 내던 자리에서 Gin은 GET에 301을 낸다.
func TestTrailingSlashRedirectIs301(t *testing.T) {
r := catalog.NewEngine()
rec, _ := do(t, r, http.MethodGet, "/v1/books/")
if rec.Code != http.StatusMovedPermanently {
t.Errorf("status = %d, want 301", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/v1/books" {
t.Errorf("Location = %q, want %q", got, "/v1/books")
}
}
흔한 실수
1. gin.Default()를 쓰고 로그 형식을 통제하려 한다
Default는 Logger와 Recovery를 미리 끼운다. 형식을 바꾸려면 결국 New로 돌아와
직접 조립하게 된다. 처음부터 New가 낫다.
2. HandleMethodNotAllowed가 켜져 있다고 생각한다
기본값이 false다. ServeMux가 자동으로 주던 405가 404로 떨어진다.
9-8을 Gin으로 옮길 때 반드시 걸리는 지점이다.
3. *path에 슬래시가 안 붙는다고 생각한다
{path...}는 "a/b.txt", *path는 "/a/b.txt"다. 그대로 파일 경로로 쓰면
루트부터 찾는다. (경로 탈출 방지는 9-2의 os.Root가 답이다.)
4. c.JSON 뒤에 return을 안 쓴다
c.JSON은 함수를 끝내지 않는다. 9-7에서 http.Error 뒤에 return을 빼먹던 것과
같은 실수다. 미들웨어라면 c.Abort 계열을 써야 한다(10-4).
5. r.Run(":8080")을 프로덕션에 쓴다
http.ListenAndServe와 같은 문제다 — 타임아웃이 하나도 안 걸린 서버가 뜬다.
9-7의 이유가 그대로 적용된다.
6. 라이브러리 패키지에서 gin.SetMode를 부른다
프로세스 전역 설정을 라이브러리가 바꾼다. 결정은 main에 맡긴다.
정리
gin.Engine은http.Handler다.httptest,http.Server타임아웃, 표준 미들웨어가 전부 그대로 쓰인다. Part 10에서 Gin과 Fiber를 가르는 가장 큰 차이다.gin.New()로 시작하고 미들웨어는 직접 고른다.Default는 로그 형식까지 따라온다.- 라우팅 표기만 다르고 개념은
ServeMux와 같다. 단,*path는 슬래시를 포함하고, 405는 기본으로 꺼져 있으며, 후행 슬래시는 GET에 301이다. c.Param은 문자열이다. 변환과 검증은 아직 내 일이다 — 10-3에서 걷어낸다.gin.SetMode는 프로세스 전역이므로main이나TestMain에서만 부른다.
연습문제
-
HandleMethodNotAllowed = true로 켜고DELETE /v1/books/1을 보내 보자. 상태 코드와Allow헤더는 무엇인가? 9-7의ServeMux가 주던Allow와 비교하면 무엇이 빠져 있는가? (힌트: HEAD) -
RedirectTrailingSlash를 끄고/v1/books/를 요청하면 어떻게 되는가? 9-8은 후행 슬래시에 리다이렉트가 아니라 404를 주는 쪽을 골랐다. 두 선택의 장단점을 각각 테스트로 표현해 보자. -
/v1/books/:id와/v1/books/new를 함께 등록하면 무슨 일이 생기는가?ServeMux는 더 구체적인 패턴을 우선한다(9-7). Gin은 어떻게 하는가? 등록 순서를 바꿔 가며 확인해 보자.