튜토리얼: Go와 Gin으로 RESTful API 개발하기

튜토리얼: Go와 Gin으로 RESTful API 개발하기

이 튜토리얼은 Go와 Gin 웹 프레임워크(Gin)로 RESTful 웹 서비스 API를 작성하는 기본기를 다룹니다. Go와 그 도구에 대한 기본적인 친숙함이 있다면 가장 많은 걸 얻어갈 수 있어요. Go가 처음이라면 Tutorial: Get started with Go부터 훑어보는 걸 권장합니다.

Gin은 웹 서비스뿐 아니라 웹 애플리케이션을 만들 때 수반되는 많은 코딩 작업을 단순화해 줘요. 이 튜토리얼에서는 Gin으로 요청을 라우팅하고, 요청 세부 사항을 꺼내고, 응답용 JSON을 직렬화하는 법을 배웁니다. 결과물로 두 개의 엔드포인트를 가진 RESTful API 서버를 만들게 되는데, 예시 프로젝트는 빈티지 재즈 레코드에 관한 데이터 저장소랍니다.

출처: Go 공식 문서

이 튜토리얼은 다음 섹션들로 구성됩니다.

  • API 엔드포인트 설계하기
  • 코드 폴더 만들기
  • 데이터 만들기
  • 모든 항목을 반환하는 핸들러 작성하기
  • 새 항목을 추가하는 핸들러 작성하기
  • 특정 항목을 반환하는 핸들러 작성하기

준비 사항 (Prerequisites)

  • Go. 최신 버전의 Go를 쓰는 걸 권장합니다. 설치 방법은 Installing Go를 참고하세요.
  • 코드를 편집할 도구. 아무 텍스트 에디터나 잘 작동해요.
  • 명령 터미널. Go는 Linux와 Mac의 어떤 터미널에서든 잘 작동하며, Windows에서는 PowerShell이나 cmd에서 잘 돌아갑니다.
  • curl 도구. Linux와 Mac에는 보통 이미 설치되어 있어요. Windows에서는 Windows 10 Insider 빌드 17063 이상에 포함되어 있습니다. 그보다 이전 Windows 버전이라면 설치가 필요할 수 있어요. 자세한 내용은 Tar and Curl Come to Windows를 참고하세요.

API 엔드포인트 설계하기

빈티지 레코드를 판매하는 상점(vinyl)에 접근을 제공하는 API를 만들 거예요. 그러려면 클라이언트가 사용자용 앨범을 가져오고 추가할 수 있는 엔드포인트를 제공해야 합니다.

API를 개발할 때는 보통 엔드포인트 설계부터 시작해요. 엔드포인트가 이해하기 쉬우면 API 사용자도 성공할 가능성이 높아지거든요. 이 튜토리얼에서 만들 엔드포인트는 다음과 같아요.

엔드포인트 동작
/albums GET – 모든 앨범 목록을 JSON으로 반환
/albums POST – JSON으로 보낸 요청 데이터에서 새 앨범 추가
/albums/:id GET – ID로 앨범을 찾아 그 데이터를 JSON으로 반환

코드 폴더 만들기

시작하려면 작성할 코드를 위한 프로젝트를 만듭니다.

명령 프롬프트를 열고 홈 디렉터리로 이동하세요. Linux나 Mac에서는:

$ cd

Windows에서는:

C:\> cd %HOMEPATH%

명령 프롬프트에서 web-service-gin이라는 코드 디렉터리를 만들고 이동합니다.

$ mkdir web-service-gin
$ cd web-service-gin

의존성을 관리할 모듈을 만듭니다. go mod init 명령을 실행하면서 코드가 놓일 모듈의 경로를 인자로 줍니다.

$ go mod init example/web-service-gin
go: creating new go.mod: module example/web-service-gin

이 명령은 go.mod 파일을 만들고, 앞으로 추가할 의존성이 추적을 위해 그곳에 나열됩니다. 모듈 이름을 모듈 경로로 짓는 법에 대해 더 알아보려면 Managing dependencies 문서를 참고하세요.

데이터 만들기

튜토리얼을 단순하게 유지하려고 데이터를 메모리에 저장할 거예요. 더 전형적인 API라면 데이터베이스와 상호작용하겠지만요. 메모리에 저장한다는 건 서버를 멈출 때마다 앨범 세트가 사라지고, 서버를 다시 시작하면 재생성된다는 뜻이에요.

코드 작성

텍스트 에디터로 web-service 디렉터리에 main.go 파일을 만듭니다. 여기에 Go 코드를 작성할 거예요. main.go 맨 위에 다음 패키지 선언을 붙여 넣습니다.

package main

라이브러리가 아닌 독립 실행 프로그램은 항상 package main에 있어요. 패키지 선언 아래에 앨범 struct 선언을 붙여 넣습니다. 앨범 데이터를 메모리에 저장하는 데 쓸 거예요.

json:"artist" 같은 struct 태그는 struct 내용이 JSON으로 직렬화될 때 필드 이름이 무엇이어야 하는지 지정합니다. 태그가 없으면 JSON이 struct의 대문자 필드 이름을 쓰게 되는데, JSON에서는 흔하지 않은 스타일이죠.

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

방금 추가한 struct 선언 아래에, 시작 데이터로 쓸 album struct 슬라이스를 붙여 넣습니다.

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

모든 항목을 반환하는 핸들러 작성하기

클라이언트가 GET /albums에 요청을 보내면, 모든 앨범을 JSON으로 반환하고 싶을 거예요. 이를 위해 두 가지를 작성합니다.

  • 응답을 준비하는 로직
  • 요청 경로를 그 로직에 매핑하는 코드

참고로 이 순서는 런타임에서 실행되는 순서와 반대예요. 우리는 먼저 의존성을 추가하고, 그다음 그 의존성을 사용하는 코드를 추가하죠.

코드 작성

앞 섹션에서 추가한 struct 코드 아래에, 앨범 목록을 가져오는 다음 코드를 붙여 넣습니다. 이 getAlbums 함수는 album struct 슬라이스에서 JSON을 만들어 응답에 써 넣어요.

// getAlbums responds with the list of all albums as JSON.
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

이 코드에서:

  • gin.Context 파라미터를 받는 getAlbums 함수를 작성합니다. 이 함수에는 어떤 이름을 붙여도 됐다는 걸 기억하세요 — Gin도 Go도 특정 함수 이름 형식을 요구하지 않아요.
  • gin.Context는 Gin에서 가장 중요한 부분입니다. 요청 세부 사항을 담고, JSON을 검증·직렬화하며, 그 외 여러 일을 해요. (이름이 비슷하지만 Go의 내장 context 패키지와는 달라요.)
  • Context.IndentedJSON을 호출해 struct를 JSON으로 직렬화하고 응답에 추가합니다.
  • 함수의 첫 인자는 클라이언트에 보내고 싶은 HTTP 상태 코드예요. 여기서는 net/http 패키지의 StatusOK 상수를 전달해 200 OK를 나타냅니다.

참고로 Context.IndentedJSONContext.JSON 호출로 바꾸면 더 간결한 JSON을 보낼 수 있어요. 실무에서는 들여쓰기된 형태가 디버깅할 때 훨씬 다루기 쉽고, 크기 차이는 보통 작습니다.

main.go 상단, albums 슬라이스 선언 바로 아래에 다음 코드를 붙여 넣어 핸들러 함수를 엔드포인트 경로에 할당합니다. 이렇게 하면 getAlbums/albums 엔드포인트 경로에 대한 요청을 처리하는 연결이 설정돼요.

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)

    router.Run("localhost:8080")
}

이 코드에서:

  • Default로 Gin 라우터를 초기화합니다.
  • GET 함수로 GET HTTP 메서드와 /albums 경로를 핸들러 함수에 연결합니다. 참고로 getAlbums라는 함수 이름을 전달하고 있어요. 이는 함수의 결과를 전달하는 것과 달라요 — 결과를 전달하려면 getAlbums()(괄호가 붙은)를 넘겨야 하죠.
  • Run 함수로 라우터를 http.Server에 붙이고 서버를 시작합니다.

main.go 상단, 패키지 선언 바로 아래에 방금 작성한 코드를 지원하는 데 필요한 패키지를 import 합니다. 파일의 첫 줄들은 이렇게 보여야 해요.

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

main.go를 저장합니다.

코드 실행

Gin 모듈을 의존성으로 추적하기 시작합니다. 커맨드 라인에서 go get으로 github.com/gin-gonic/gin 모듈을 당신 모듈의 의존성으로 추가하세요. 점(.) 인자는 "현재 디렉터리의 코드에 대한 의존성을 가져와라"는 뜻이에요.

$ go get .
go get: added github.com/gin-gonic/gin v1.7.2

Go는 이전 단계에서 추가한 import 선언을 충족시키기 위해 이 의존성을 해석해 내려받았습니다.

main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행합니다. 점(.) 인자는 "현재 디렉터리의 코드를 실행하라"는 뜻이에요.

$ go run .

코드가 실행되면 요청을 보낼 수 있는 HTTP 서버가 하나 돌고 있는 상태예요. 새 커맨드 라인 창에서 curl로 실행 중인 웹 서비스에 요청을 보내보세요.

$ curl http://localhost:8080/albums

명령은 서비스를 시드한 데이터를 보여줘야 합니다.

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        }
]

API 하나를 시작했어요! 다음 섹션에서는 항목을 추가하는 POST 요청을 처리하는 코드로 엔드포인트를 하나 더 만듭니다.

새 항목을 추가하는 핸들러 작성하기

클라이언트가 POST /albums에 요청을 보내면, 요청 본문에 담긴 앨범을 기존 앨범 데이터에 추가하고 싶을 거예요. 이를 위해 두 가지를 작성합니다.

  • 새 앨범을 기존 목록에 추가하는 로직
  • POST 요청을 그 로직으로 라우팅하는 약간의 코드

코드 작성

앨범 데이터를 앨범 목록에 추가하는 코드를 더합니다. import 문 뒤 아무 곳에나 다음 코드를 붙여 넣어도 돼요. (파일 끝이 이 코드를 넣기 좋은 위치지만, Go는 함수 선언 순서를 강제하지 않아요.)

// postAlbums adds an album from JSON received in the request body.
func postAlbums(c *gin.Context) {
    var newAlbum album

    // Call BindJSON to bind the received JSON to
    // newAlbum.
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // Add the new album to the slice.
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

이 코드에서:

  • Context.BindJSON으로 요청 본문을 newAlbum에 바인딩합니다.
  • JSON에서 초기화한 album struct를 albums 슬라이스에 append 합니다.
  • 응답에 201 상태 코드와 함께, 추가한 앨범을 나타내는 JSON을 더합니다.

main 함수가 다음처럼 router.POST 함수를 포함하도록 바꿉니다.

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

이 코드에서:

  • /albums 경로의 POST 메서드를 postAlbums 함수에 연결합니다.
  • Gin에서는 핸들러를 HTTP 메서드-경로 조합과 연결할 수 있어요. 이렇게 하면 단일 경로로 보내진 요청을 클라이언트가 쓰는 메서드에 따라 따로 라우팅할 수 있죠.

코드 실행

이전 섹션에서 서버가 아직 돌고 있다면 멈춥니다. main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행해요.

$ go run .

다른 커맨드 라인 창에서 curl로 실행 중인 웹 서비스에 요청을 보냅니다.

$ curl http://localhost:8080/albums \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"id": "4","title": "The Modern Sound of Betty Carter","artist": "Betty Carter","price": 49.99}'

명령은 추가된 앨범의 헤더와 JSON을 보여줘야 해요.

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Wed, 02 Jun 2021 00:34:12 GMT
Content-Length: 116

{
    "id": "4",
    "title": "The Modern Sound of Betty Carter",
    "artist": "Betty Carter",
    "price": 49.99
}

이전 섹션에서처럼 curl로 전체 앨범 목록을 가져와 새 앨범이 추가됐는지 확인할 수 있어요.

$ curl http://localhost:8080/albums \
    --header "Content-Type: application/json" \
    --request "GET"

명령은 앨범 목록을 보여줘야 합니다.

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        },
        {
                "id": "4",
                "title": "The Modern Sound of Betty Carter",
                "artist": "Betty Carter",
                "price": 49.99
        }
]

다음 섹션에서는 특정 항목에 대한 GET을 처리하는 코드를 추가합니다.

특정 항목을 반환하는 핸들러 작성하기

클라이언트가 GET /albums/[id]에 요청을 보내면, id 경로 파라미터와 일치하는 ID의 앨범을 반환하고 싶을 거예요. 이를 위해:

  • 요청한 앨범을 가져오는 로직을 추가한다.
  • 경로를 그 로직에 매핑한다.

코드 작성

이전 섹션에서 추가한 postAlbums 함수 아래에, 특정 앨범을 가져오는 다음 코드를 붙여 넣습니다. 이 getAlbumByID 함수는 요청 경로에서 ID를 추출한 뒤 일치하는 앨범을 찾아요.

// getAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that album as a response.
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // Loop over the list of albums, looking for
    // an album whose ID value matches the parameter.
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

이 코드에서:

  • Context.Param으로 URL에서 id 경로 파라미터를 가져옵니다. 이 핸들러를 경로에 매핑할 때 경로에 파라미터용 플레이스홀더를 넣을 거예요.
  • 슬라이스의 album struct를 반복하면서, ID 필드 값이 id 파라미터 값과 일치하는 걸 찾습니다. 찾으면 그 album struct를 JSON으로 직렬화하고 200 OK HTTP 코드로 응답으로 반환하죠.
  • 앞서 말했듯이 실무 서비스라면 이 조회에 데이터베이스 쿼리를 쓸 겁니다.
  • 앨범을 찾지 못하면 http.StatusNotFound로 HTTP 404 오류를 반환합니다.

마지막으로 main이 경로가 이제 /albums/:idrouter.GET 호출을 새로 포함하도록 바꿉니다. 다음 예시처럼요.

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

이 코드에서:

  • /albums/:id 경로를 getAlbumByID 함수에 연결합니다. Gin에서 경로의 항목 앞에 붙은 콜론(:)은 그 항목이 경로 파라미터라는 뜻이에요.

코드 실행

이전 섹션에서 서버가 아직 돌고 있다면 멈춥니다. main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행해 서버를 시작해요.

$ go run .

다른 커맨드 라인 창에서 curl로 실행 중인 웹 서비스에 요청을 보냅니다.

$ curl http://localhost:8080/albums/2

명령은 사용한 ID의 앨범 JSON을 보여줘야 해요. 앨범을 찾지 못하면 오류 메시지가 든 JSON을 받습니다.

{
        "id": "2",
        "title": "Jeru",
        "artist": "Gerry Mulligan",
        "price": 17.99
}

결론 (Conclusion)

축하합니다! Go와 Gin으로 간단한 RESTful 웹 서비스를 작성했습니다. 다음으로 추천하는 주제들:

완성된 코드 (Completed code)

이 섹션은 이 튜토리얼로 만든 애플리케이션의 코드를 담고 있어요.

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

// getAlbums responds with the list of all albums as JSON.
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

// postAlbums adds an album from JSON received in the request body.
func postAlbums(c *gin.Context) {
    var newAlbum album

    // Call BindJSON to bind the received JSON to
    // newAlbum.
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // Add the new album to the slice.
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

// getAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that album as a response.
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // Loop through the list of albums, looking for
    // an album whose ID value matches the parameter.
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

더 알아보기 (Learn more)