Improving the code from the official Go RESTful API tutorial

Ben Hoyt

공식 Go RESTful API 튜토리얼 코드 개선하기

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

요약: 이 글은 공식 Go 튜토리얼 “Developing a RESTful API with Go and Gin”의 코드를 재구현한 과정을 설명한다. 내 버전은 몇 가지 기능을 추가하고, 일부 문제를 수정했으며, 테스트를 추가했고, Go 표준 라이브러리만 사용한다.

최근에 새로운 Tutorial: Developing a RESTful API with Go and Gin을 읽었는데, Go의 훌륭한 다른 문서들에 비해 몇 가지 품질 문제가 있는 듯했고, 공식 문서에서 표준 라이브러리를 소개하기보다 서드파티 라이브러리인 Gin을 사용한다는 점이 의아했다.

그래서 표준 라이브러리만으로 코드를 다시 작성하고, 락(lock) 누락으로 인한 동시성 문제를 수정하기로 했다. 또 입력값 검증이나 더 나은 오류 처리 같은 기능도 추가했는데, 이런 기능이야말로 ‘진짜’ 웹 서비스라면 당연히 포함되어야 한다고 생각한다.

다시 작성한 뒤 이러한 문제들에 대해 질문을 올렸다 Go 개발 메일링 리스트인 golang-dev에 올렸고, Go의 테크 리드인 Russ Cox가 이렇게 답했다:

이 튜토리얼은 Go의 서드파티 패키지 생태계를 활용하는 몇몇 튜토리얼 중 첫 번째였습니다. 널리 사용되고 일반적인 사용 사례를 단순화하는 패키지를 소개하려는 의도였습니다.

나름 일리가 있다고 생각한다, 특히 이제 Go 모듈이 있으니 더욱 그렇다. 내가 발견한 구체적인 문제들을 언급하자 그는 튜토리얼을 그대로 두는 편이 낫다고 했다. 그의 답변은 도움이 되었다:

이 모든 내용은 이 특정 튜토리얼의 범위를 벗어난다고 생각합니다. 실제 시스템에서는 애초에 인메모리 데이터베이스를 사용하지 않을 것이므로, 인메모리 데이터베이스에 락이 없다는 점은 크게 문제되지 않습니다. 앨범 검증 등도 마찬가지입니다. 튜토리얼의 목표는 짧고 좁게, 특정 아이디어(이 경우에는 RESTful JSON 기반 API)를 설명하는 데 집중하는 것입니다. 실제 시스템에 존재할 모든 입력 검증, 인증 및 기타 복잡한 사항들을 의도적으로 생략한 것입니다. 말씀하신 내용들은 모두 강조할 만한 좋은 지적이며, 블로그 글을 작성하는 데 시간을 내주셔서 감사하지만, 이 특정 튜토리얼에 추가한다면 좁은 초점에서 벗어나게 될 것입니다.

어느 정도 타당한 말이다. 하지만 여전히 튜토리얼에는 버그가 없어야 한다고 생각한다. 그래서 동시성 문제는 수정하거나, 최소한 단순화를 위해 생략했다는 점을 명시했으면 좋겠다. 많은 초보자가 예제 코드를 그대로 복사해 배우는 만큼, 중요한 세부 사항을 대충 넘기는 것은 위험하다. 따라서 이런 코드에서는 더 좋은 선례를 남겨야 한다고 생각한다.

아래에서 내 버전에서 개선한 내용을 설명한다. 전체 소스는 GitHub의 benhoyt/web-service-stdlib에서 확인할 수 있다.

개선 사항

원본 코드에서 발견해 내 버전에서 변경하거나 개선한 내용은 다음과 같다(아래 더 자세한 섹션으로 연결된다):

  • 표준 라이브러리. 앞서 언급했듯 원본은 Gin 웹 프레임워크를 사용한다. 내 버전은 표준 라이브러리 패키지만 사용한다.
  • 검증. 원본은 “새 앨범 생성” 입력에 대해 아무런 검증도 하지 않는다(JSON 여부 확인 외에는). 그래서 빈 ID나 음수 가격 등으로 앨범을 쉽게 추가할 수 있다. 내 버전에서는 기본적인 검증을 추가하고, 클라이언트가 파싱할 수 있는 검증 오류를 반환하도록 했다.
  • 고유한 앨범 ID. 기존 코드는 중복된 ID로도 앨범을 아무 문제 없이 추가하고, /albums/:id 엔드포인트는 그중 첫 번째 앨범을 반환한다. 이는 문제가 있어 보인다. 아마도 PUT /albums/:id로 해당 ID의 앨범을 업데이트하거나, 그대로 POST /albums를 사용하되 중복 시 오류를 반환해야 할 것이다 — 나는 후자를 선택했다.
  • 동시성. 원본 코드의 전역 albums 슬라이스는 락 없이 읽고 쓰이므로 웹 서비스를 동시에 접근할 수 없고, 시도하면 패닉이 발생한다. 이는 단순한 예제이고 실제 데이터베이스라면 이런 문제가 없겠지만, 간단한 뮤텍스 락을 추가하는 것만으로도 안전하게 만들 수 있다. 내 버전에서는 락을 추가하고, 레이스가 없음을 보장하는 테스트도 추가했다.
  • 10진수 통화. 앨범의 Price 필드는 float64 타입이다. 통화 값에 이진 부동소수점을 사용하는 것은 좋지 않다. 이진 부동소수점은 10진수 소수(센트)를 정확히 표현할 수 없고, 연산 시 반올림 오류가 발생할 수 있기 때문이다. 나는 Price 필드를 정수형 센트(고정 소수점)로 변경했다.
  • JSON 오류. Gin 라우터의 기본 Not Found 오류는 Content-Type: text/plain을 반환하므로, 이런 오류는 JSON 대신 텍스트로 반환된다. 반면 getAlbumByID에서 명시적으로 반환하는 Not Found는 JSON을 반환한다. 마찬가지로 Gin의 BindJSON은 잘못된 입력을 받아도 JSON 오류를 반환하지 않는다. 내 버전에서는 모든 오류를 JSON으로 반환한다.
  • 메서드를 찾을 수 없음. Gin은(적어도 기본 설정에서는) URL은 유효하지만 메서드를 찾을 수 없을 때 405 Method Not Allowed 대신 404 Not Found를 반환한다. 나는 이런 경우 표준 405 상태를 반환하도록 수정했다.
  • 테스팅. 원본에는 테스트가 없다. 튜토리얼의 목적이 아니니 괜찮다. 하지만 Go의 httptest 라이브러리를 이용하면 HTTP 핸들러 테스트는 매우 쉽다. 나는 오류 케이스를 포함해 모든 기능에 대한 테스트를 추가했다.
  • 데이터베이스 인터페이스. 데이터베이스 메서드에 대해 명시적인 인터페이스를 사용했다(ErrDoesNotExist 같은 정의된 오류를 반환할 수 있다). 원본과 유사한 인메모리 구현도 함께 제공한다.
  • 관심사 분리. 원본에서는 “데이터베이스” 코드가 핸들러 코드와 뒤섞여 있었다. 데이터베이스 인터페이스를 사용한 덕분에 내 버전에서는 데이터베이스 코드가 HTTP 핸들러 코드와 완전히 분리되어, 오류 처리 같은 부분을 테스트하거나 필요할 때 실제 데이터베이스로 교체하기가 더 쉽다.

내 버전은 코드 양이 훨씬 많다(50줄 대신 약 300줄, 테스트 코드도 약 300줄). 하지만 이는 대부분 추가 기능 때문이다. 내 버전이 더 견고하고 유지보수하기 쉬운 코드를 보여준다고 생각한다.

이제 각 항목을 조금 더 자세히 살펴보자.

표준 라이브러리

Gin은 URL 라우팅(URL 파라미터 포함)과 몇 가지 JSON 마샬링 함수를 제공한다. 내 버전에서는 간단한 라우팅 코드를 직접 작성하고, 커스텀 JSON 헬퍼 두 개를 추가했다.

다른 글에서 Go의 다양한 HTTP 라우팅 접근법에 대해 자세히 다룬 적이 있지만, 여기서는 라우트가 매우 단순하므로 정규식 switch 방식의 간소화된 버전을 사용했고, /albums/:id 라우트를 파싱하기 위해 정규식을 사용했다. 그래서 여기서는 표준 라이브러리의 http.ServeMux조차 필요하지 않다.

/albums/:id 라우트는 정규식 없이도 꽤 간단하게 처리할 수 있지만, 엣지 케이스를 처리할 때는 정규식을 쓰는 편이 조금 더 간단하다. ID가 최소 한 글자 이상이고 슬래시를 포함하지 않는지 검사하는 식이다.

내 코드는 적절한 405 Method Not Allowed 처리를 포함해 HTTP 메서드도 처리한다. 전체 라우팅 코드는 다음과 같다:

// Regex to match "/albums/:id" (id must be one or more non-slash chars).
var reAlbumsID = regexp.MustCompile(`^/albums/([^/]+)$`)

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    path := r.URL.Path
    s.log.Printf("%s %s", r.Method, path)

    var id string

    switch {
    case path == "/albums":
        switch r.Method {
        case "GET":
            s.getAlbums(w, r)
        case "POST":
            s.addAlbum(w, r)
        default:
            w.Header().Set("Allow", "GET, POST")
            s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
        }

    case match(path, reAlbumsID, &id):
        switch r.Method {
        case "GET":
            s.getAlbumByID(w, r, id)
        default:
            w.Header().Set("Allow", "GET")
            s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
        }

    default:
        s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
    }
}

다소 장황하지만 매우 명확하고 명시적이며, 오류를 JSON으로 반환하고 405를 올바르게 반환하도록 서드파티 라우터를 설정해야 하는 수고를 덜 수 있다.

Gin이 코드를 줄여준 또 다른 부분은 각각 JSON을 마샬링하고 언마샬링하는 IndentedJSONBindJSON 헬퍼였다. 다행히 표준 encoding/json 패키지만으로도 JSON을 다루는 것은 매우 쉽다. 이를 감싸고 오류 처리를 수행하는 작은 헬퍼 함수 두 개를 작성했다:

// writeJSON marshals v to JSON and writes it to the response, handling
// errors as appropriate. It also sets the Content-Type header to
// "application/json".
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    b, err := json.MarshalIndent(v, "", "    ")
    if err != nil {
        s.log.Printf("error marshaling JSON: %v", err)
        http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError)
        return
    }
    w.WriteHeader(status)
    _, err = w.Write(b)
    if err != nil {
        // Very unlikely to happen, but log any error (not much more we can do)
        s.log.Printf("error writing JSON: %v", err)
    }
}

// readJSON reads the request body and unmarshals it from JSON, handling
// errors as appropriate. It returns true on success; the caller should
// return from the handler early if it returns false.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
    b, err := io.ReadAll(r.Body)
    if err != nil {
        s.log.Printf("error reading JSON body: %v", err)
        s.jsonError(w, http.StatusInternalServerError, ErrorInternal, nil)
        return false
    }
    err = json.Unmarshal(b, v)
    if err != nil {
        data := map[string]interface{}{"message": err.Error()}
        s.jsonError(w, http.StatusBadRequest, ErrorMalformedJSON, data)
        return false
    }
    return true
}

json.Encoder를 사용해 응답에 직접 스트리밍할 수도 있었다. 하지만 오류 처리가 다소 까다롭다. JSON 마샬링 오류가 발생했는데 Encoder.Encode가 이미 응답에 무언가를 쓴 상태라면, 200이 아닌 HTTP 상태를 반환할 수 없기 때문이다. Album의 경우 구조체가 매우 단순하므로 오류가 발생할 가능성은 낮고(불가능하다고 할 수도 있다) 일반적인 경우에도 JSON 인코딩은 오류를 반환할 수 있으므로, 먼저 구조체를 []byte로 마샬링했다.

마찬가지로 언마샬링할 때는 json.Decoder를 사용해 요청 본문에서 직접 읽을 수 있다. 하지만 이는 실제로는 스트림용으로 설계된 것이다.

Internal Server Error에 대해서는 err 값을 로깅하고 있다는 점에 주목하자. 여기에는 민감한(혹은 너무 많은) 정보가 담길 수 있으므로 응답에 포함하는 대신 로그로 남기는 것이다.

검증

사용자 입력을 항상 검증하는 것은 웹 보안, 나아가 모든 소프트웨어의 가장 기본적인 규칙 중 하나다. 검증이 없으면 서비스 사용자는 ID나 제목, 아티스트 이름이 없거나 가격이 음수(또는 터무니없이 큰)인 앨범을 추가할 수 있다.

앨범 추가 엔드포인트에 몇 줄의 검증 코드를 추가하고, 클라이언트가 유용한 오류 메시지를 표시할 수 있도록 검증 오류를 구조화된 방식으로 반환하도록 했다. 전체 검증 코드는 다음과 같다:

// Validate the input and build a map of validation issues
type validationIssue struct {
    Error   string `json:"error"`
    Message string `json:"message,omitempty"`
}
issues := make(map[string]interface{})
if album.ID == "" {
    issues["id"] = validationIssue{"required", ""}
}
if album.Title == "" {
    issues["title"] = validationIssue{"required", ""}
}
if album.Artist == "" {
    issues["artist"] = validationIssue{"required", ""}
}
if album.Price < 0 || album.Price >= 100000 {
    issues["price"] = validationIssue{"out-of-range",
        "price must be between 0 and $1000"}
}
if len(issues) > 0 {
    s.jsonError(w, http.StatusBadRequest, ErrorValidation, issues)
    return
}

여기서는 0원이 “가격 없음”, 즉 무료이거나 해당 없음(예: 가정용 카탈로그)을 의미한다고 보고 0원을 허용했다.

도메인 특화 언어를 갖춘 프레임워크가 필요한 것이 아니라, 필요한 내용을 확인하는 단순한 if 문이면 충분하다. (필드 이름을 키로 하는) 문제들의 맵을 만들고, 검증 문제가 있으면 이를 JSON 오류에 담아 호출자에게 반환한다. 검증 오류 응답은 다음과 같이 생겼다:

$ curl http://localhost:8080/albums -d '{"price":-1}'
{
    "status": 400,
    "error": "validation",
    "data": {
        "artist": {
            "error": "required"
        },
        "id": {
            "error": "required"
        },
        "price": {
            "error": "out-of-range",
            "message": "price must be between 0 and $1000"
        },
        "title": {
            "error": "required"
        }
    }
}

더 큰 웹 서비스라면 이를 조금 더 표준화하고, 적절히 구조체에 Validate() map[string]ValidationIssue 메서드를 추가할 것이다. 한편으로는 같은 구조체라도 맥락에 따라 다른 검증을 원할 때도 있으므로, 어쩌면 이렇게 단순하게 유지하는 접근법도 충분히 괜찮다.

고유한 앨범 ID

앞서 언급했듯 원본 코드는 중복된 ID로 앨범을 추가해도 오류를 반환하지 않는다. 예를 들면 다음과 같다:

$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums
[
    ...
    {
        "id": "foo",
        "title": "",
        "artist": "",
        "price": 0
    },
    {
        "id": "foo",
        "title": "",
        "artist": "",
        "price": 0
    }
]

나는 “데이터베이스”가 이미 존재하는 ID를 거부하도록 수정했다. AddAlbum 데이터베이스 메서드는 이 경우 ErrAlreadyExists를 반환하고, 핸들러 코드는 그 오류를 확인해 409 Conflict로 응답한다:

// Database method:
func (d *MemoryDatabase) AddAlbum(album Album) error {
    d.lock.Lock()
    defer d.lock.Unlock()

    if _, ok := d.albums[album.ID]; ok {
        return ErrAlreadyExists
    }
    d.albums[album.ID] = album
    return nil
}

// Handler error checking:
func (s *Server) addAlbum(w http.ResponseWriter, r *http.Request) {
    // ... JSON parsing and validation ...

    err := s.db.AddAlbum(album)
    if errors.Is(err, ErrAlreadyExists) {
        s.jsonError(w, http.StatusConflict, ErrorAlreadyExists, nil)
        return
    } else if err != nil {
        s.log.Printf("error adding album ID %q: %v", album.ID, err)
        s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
        return
    }

    s.writeJSON(w, http.StatusCreated, album)
}

업데이트: 한 댓글 작성자가 지적했듯이, 사용자가 ID를 직접 지정하기보다 데이터베이스가 고유한 앨범 ID를 생성하도록 하는 편이 더 좋을 것이다.

동시성

원본 코드는 누군가 앨범을 POST하는 동안 GET 엔드포인트에 접근하면 데이터 레이스가 발생한다. 물론 SQL 데이터베이스를 사용하면 이런 문제는 해결된다. 그런 데이터베이스는 자체적으로 동시성을 보장하기 때문이다. 하지만 인메모리 구조에 접근할 때 뮤텍스 락을 추가하는 것은 어렵지 않다.

여기서는 앨범이 추가되는 것보다 조회되는 빈도가 훨씬 높을 것이므로 sync.RWMutex를 사용했다. 그래서 읽기 부분에는 RLock/RUnlock 호출을, 쓰기 부분에는 Lock/Unlock을 추가했다.

더 흥미로운 점은 락이 없을 때 Go의 레이스 디텍터에서 실패하는 테스트를 추가했다는 것이다. 이를 확인하려면 락과 언락 호출을 주석 처리하고 go test -race를 실행해 보자.

이 테스트는 여러 고루틴을 실행하고, 각 고루틴이 읽기·쓰기를 포함해 세 엔드포인트를 모두 호출한다:

func TestConcurrentRequests(t *testing.T) {
    server := newTestServer()
    for i := 0; i < 100; i++ {
        go func(i int) {
            result := serve(t, server, newRequest(t, "GET", "/albums", nil))
            ensureStatus(t, result, http.StatusOK)

            albumID := "c" + strconv.Itoa(i)
            body := `{"id": "` + albumID + `", "title": "T", "artist": "A"}`
            result = serve(t, server, newRequest(t, "POST", "/albums", strings.NewReader(body)))
            ensureStatus(t, result, http.StatusCreated)

            result = serve(t, server, newRequest(t, "GET", "/albums/"+albumID, nil))
            ensureStatus(t, result, http.StatusOK)
        }(i)
    }
}

10진수 통화

통화 값을 저장하고 다룰 때 이진 부동소수점을 사용하는 것은 일반적으로 좋지 않은 생각이다. 10진수 소수(센트)를 정확히 저장할 수 없고, 값을 연산할수록 오류가 누적되기 때문이다.

이를 수정하기 위해 앨범의 Price 필드를 float64에서 int로 변경해 정수형 센트를 정확히 저장할 수 있도록 했다. 이는 통화 값을 정확하게 저장하는 일반적인 방법 중 하나다. 다른 방법으로는 shopspring/decimal 같은 10진수 연산 라이브러리를 사용하는 것이 있다.

JSON 오류

웹 서비스가 Not Found 같은 오류조차 항상 JSON으로 반환하면 API 클라이언트 입장에서 더 편하다. 그러면 클라이언트는 항상 응답을 JSON으로 디코딩하는 단일 코드 경로를 가질 수 있다.

내 버전에서는 위에서 언급한 writeJSON 헬퍼를 호출하는 작은 jsonError 헬퍼 함수를 사용해 항상 오류를 JSON으로 반환하도록 했다:

// jsonError writes a structured error as JSON to the response, with
// optional structured data in the "data" field.
func (s *Server) jsonError(w http.ResponseWriter, status int,
        error string, data map[string]interface{}) {
    response := struct {
        Status int                    `json:"status"`
        Error  string                 `json:"error"`
        Data   map[string]interface{} `json:"data,omitempty"`
    }{
        Status: status,
        Error:  error,
        Data:   data,
    }
    s.writeJSON(w, status, response)
}

보통 “data” 필드는 비어 있지만, Bad Request 오류의 경우 호출자가 무엇을 잘못했는지에 대해 조금 더 많은 정보를 제공하는 것이 유용하다(예: 위에서 본 검증 코드).

Error 필드는 ErrorValidation 같은 JSON 오류 코드를 위한 여러 정의된 상수 중 하나다.

메서드를 찾을 수 없음

아주 간단한 부분이지만, Gin은(튜토리얼 코드에서 사용한 기본 설정에서는) URL은 유효하지만 메서드를 찾을 수 없을 때 405 Method Not Allowed 대신 404 Not Found를 반환한다.

라우팅 코드에서 보았듯 나는 이런 경우 HTTP 405 상태를 반환하도록 변경했다.

테스팅

서버에 대한 많은 테스트를 추가했다. 이 테스트들은 모든 엔드포인트는 물론 오류 동작, 검증 문제 등을 테스트한다.

테스트 커버리지(go test -coverprofile 기준)는 보여주듯 최소한의 main 함수와 실제로는 거의 발생하지 않는 writeJSON 오류 처리의 테스트하기 어려운 부분을 제외한 모든 코드를 테스트했음을 보여준다. 일반적으로 100% 테스트 커버리지를 목표로 하는 것이 합리적이라고 생각하지는 않지만, 여기서는 이렇게 많은 부분을 쉽게 커버할 수 있다는 점이 좋았다.

테스트는 모두 같은 기본 패턴을 따른다. 테스트 서버를 만들고, httptest.ResponseRecorder에 대해 하나 이상의 요청을 실행한 뒤, 응답이 올바른지 — 상태 코드와 JSON 데이터가 맞는지 — 확인한다.

새 요청을 만들거나, 단일 요청을 서빙하거나, JSON 응답을 언마샬링하는 등의 작업을 위한 몇 가지 테스트 헬퍼(T.Helper로 표시)를 구현했다. 각각 몇 줄에 불과하지만 테스트의 보일러플레이트를 상당히 줄이는 데 도움이 된다.

다음은 ensureStatus 헬퍼와 함께 작성한 예제 테스트다:

func TestGetAlbums(t *testing.T) {
    server := newTestServer()
    result := serve(t, server, newRequest(t, "GET", "/albums", nil))
    ensureStatus(t, result, http.StatusOK)

    var got []testAlbum
    unmarshalResponse(t, result, &got)
    want := []testAlbum{
        {ID: "a1", Title: "9th Symphony", Artist: "Beethoven", Price: 795},
        {ID: "a2", Title: "Hey Jude", Artist: "The Beatles", Price: 2000},
    }
    if !reflect.DeepEqual(got, want) {
        t.Fatalf("bad response: got vs want:\n%#v\n%#v", got, want)
    }
}

func ensureStatus(t *testing.T, response *http.Response, want int) {
    t.Helper()
    if response.StatusCode != want {
        t.Fatalf("bad status code: got %d, want %d", response.StatusCode, want)
    }
}

서버가 사용하는 MemoryDatabase 구현을 별도로 테스트하지 않았다는 점에 유의하자. 대신 그 기능은 전체 서버 테스트의 일부로 테스트된다. 가능하다면 인메모리 fake를 사용하고 “mock” 호출을 기록하는 번거로움을 피하는 것이 더 간단하고 덜 깨지기 쉬운 테스트 작성 방법이다.

이 테스트들에서 흥미로운 몇 가지를 더 소개하면 다음과 같다:

  • 테이블 기반 서브 테스트의 예: TestGetAlbum.
  • 위에서 언급한 동시성 테스트: TestConcurrentRequests.
  • errorDatabase mock을 사용해 핸들러가 데이터베이스 오류 시 500 Internal Server Error를 올바르게 반환하는지 테스트: TestDatabaseErrors.

데이터베이스 인터페이스

Go 인터페이스는 강력하면서도 다소 독특하다. 다양한 접근 메서드를 가진 데이터베이스 구조체 같은 구체 타입을 구현할 수 있고, 구현체가 무언가를 구현하거나 상속한다는 것을 명시할 필요가 없다. 그냥 코드를 작성하면 된다.

그러면 데이터베이스를 사용하는 쪽, 이 경우 Server가 자신이 필요로 하는 메서드만으로 이루어진 인터페이스를 정의한다(이는 구현체의 메서드 중 일부일 수 있다). 우리 경우에는 다음과 같이 생겼다:

// Server is the album HTTP server.
type Server struct {
    db  Database
    log *log.Logger
}

// Database is the interface used by the server to load and store albums.
type Database interface {
    // GetAlbums returns a copy of all albums, sorted by ID.
    GetAlbums() ([]Album, error)

    // GetAlbumsByID returns a single album by ID, or ErrDoesNotExist if
    // an album with that ID does not exist.
    GetAlbumByID(id string) (Album, error)

    // AddAlbum adds a single album, or ErrAlreadyExists if an album with
    // the given ID already exists.
    AddAlbum(album Album) error
}

var (
    ErrDoesNotExist  = errors.New("does not exist")
    ErrAlreadyExists = errors.New("already exists")
)

보시다시피 ServerDatabase를 가지고 있는데, 이는 내가 정의한 MemoryDatabase 구현처럼 인메모리일 수도 있고, 디스크에 있을 수도 있으며, 외부 SQL 데이터베이스를 사용할 수도 있다. 혹은 데이터베이스 오류 처리를 테스트하기 위해 TestDatabaseErrors에서 사용한 것처럼 항상 오류를 반환하는 errorDatabase일 수도 있다.

좋은 인터페이스를 정의하는 데는 어느 정도 API 설계가 필요하다. 나는 처음에 error 반환값 없이 시작했고, AddAlbum 함수는 “실제로 추가했는가?”를 나타내는 불리언을 반환했다. 하지만 실제 데이터베이스는 오류를 반환해야 하므로 처음부터 제대로 된 오류 처리를 갖추는 편이 낫다.

GetAlbumByIDAddAlbum에 대한 문서 주석이 앨범이 존재하지 않을 때(또는 이미 존재할 때) 반환되는 특별한 오류 값을 설명하는 방식에 주목하자. 이를 통해 핸들러가 이 오류 값을 검사하고(==errors.Is를 사용해) 호출자에게 적절한 HTTP 상태 코드를 반환할 수 있다.

더 큰 프로젝트라면 ServerDatabase는 아마 server 패키지에, MemoryDatabase는 별도의 testdb 패키지에 정의될 것이다. 단순함을 위해(이 프로젝트는 몇백 줄에 불과하므로) 모든 것을 단일 main.go 파일에 유지했다. Go에서 좋은 경험칙은 필요할 때만 코드를 패키지로 분리하라는 것이다.

데이터베이스 구현

내 데이터베이스 구현에서는 여전히 원본 튜토리얼처럼 간단한 인메모리 데이터베이스를 사용한다. 다만 이제는(위의 Database 인터페이스를 충족하기 위해) 구조체를 사용해 구현했고, 동시성 문제를 해결하기 위해 락을 추가했다. 전체 코드는 다음과 같다:

// MemoryDatabase is a Database implementation that uses a simple
// in-memory map to store the albums.
type MemoryDatabase struct {
    lock   sync.RWMutex
    albums map[string]Album
}

// NewMemoryDatabase creates a new in-memory database.
func NewMemoryDatabase() *MemoryDatabase {
    return &MemoryDatabase{albums: make(map[string]Album)}
}

func (d *MemoryDatabase) GetAlbums() ([]Album, error) {
    d.lock.RLock()
    defer d.lock.RUnlock()

    // Make a copy of the albums map (as a slice)
    albums := make([]Album, 0, len(d.albums))
    for _, album := range d.albums {
        albums = append(albums, album)
    }

    // Sort by ID so we return them in a defined order
    sort.Slice(albums, func(i, j int) bool {
        return albums[i].ID < albums[j].ID
    })
    return albums, nil
}

func (d *MemoryDatabase) GetAlbumByID(id string) (Album, error) {
    d.lock.RLock()
    defer d.lock.RUnlock()

    album, ok := d.albums[id]
    if !ok {
        return Album{}, ErrDoesNotExist
    }
    return album, nil
}

func (d *MemoryDatabase) AddAlbum(album Album) error {
    d.lock.Lock()
    defer d.lock.Unlock()

    if _, ok := d.albums[album.ID]; ok {
        return ErrAlreadyExists
    }
    d.albums[album.ID] = album
    return nil
}

뮤텍스 외에 원본 방식과 유의미하게 다른 유일한 점은 앨범을 저장할 때 슬라이스 대신 ID를 키로 하는 맵을 사용한다는 것이다. 이를 통해 ID로 조회할 때 상수 시간에 찾을 수 있다.

하지만 Go의 맵은 순회 순서가 정의되어 있지 않으므로, GetAlbums가 일관된 순서로 앨범을 반환하도록 ID 순으로 정렬했다. 원본 코드는(아마도 우연히?) 가장 오래된 것부터 최신 순으로 반환했다. 실제 데이터베이스를 사용한다면 제목 같은 사용자에게 의미 있는 기준으로 정렬하기 위해 ORDER BY 절을 사용할 것이다.

관심사 분리

이는 데이터베이스 인터페이스에서 상당히 자연스럽게 따라온다. 원본 코드에서는 JSON 마샬링 같은 HTTP 핸들러 코드가 데이터베이스 코드와 뒤섞여 있었다. 데이터베이스 인터페이스는 관심사 분리를 강제해 데이터베이스 오류 처리를 테스트하기 쉽게 만든다. 또한 때가 되면 실제 데이터베이스로 교체하는 것도 간단하게 만든다. SQLDatabase 구조체를 추가하고 그 메서드를 SQL 쿼리로 구현하기만 하면 된다.

결론

이 코드를 다시 작성하고 개선해 보는 것은 재미있는 연습이었고, 여러분도 즐기셨거나 무언가를 배우셨기를 바란다. 내 버전이 더 견고하고 유지보수하기 쉬우며, 서드파티 의존성을 학습하고 업데이트하는 데 따르는 번거로움을 피할 수 있기를 바란다.

전체 소스는 GitHub의 benhoyt/web-service-stdlib에서 확인할 수 있다.

피드백이나 내 코드 또는 이 글을 개선할 제안이 있다면 꼭 알려 주세요!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글