Simple Lists: a tiny to-do list app written the old-school way (server-side Go, no JS)

Ben Hoyt

Simple Lists: 옛날 방식 그대로 만든 초소형 할 일 목록 앱 (서버 사이드 Go, JS 없음)

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

요약: 이 글에서는 Go로 만든 아주 작은 할 일 목록 웹 앱인 Simple Lists를 왜, 어떻게 만들었는지 설명한다. 옛날 방식 그대로 만들었다. 서버에서 HTML을 렌더링하고, HTML 폼으로 평범한 GETPOST만 사용하며, JavaScript는 전혀 쓰지 않는다.

한동안 또 작은 사이드 프로젝트를 해보고 싶었다. 만드는 즐거움 자체를 위한 창작과 코딩 말이다. 그리고 나 자신이나 가족에게 쓸모 있을 만한 걸 만들고 싶어서 작은 할 일 목록 앱을 만들었다. 세상에 할 일 목록 앱이 수천 개 있다는 건 알지만, 나는 그냥 만드는 걸 좋아한다. 특히 스몰 웹의 원칙에 맞는 것이라면 더 그렇다.

Go가 설치되어 있다면, 앱을 로컬에서 아주 쉽게 실행할 수 있다(처음 다운로드하고 빌드하는 데 몇 초 정도 걸린다):

$ git clone https://github.com/benhoyt/simplelists.git
Cloning into 'simplelists'...
...
$ cd simplelists
$ go run .
2021/09/28 20:51:14 listening on http://localhost:8080

소스 코드는 아래에서 더 자세히 설명하겠다. 자, 시작해 보자!

기능

Simple Lists 스크린샷

Simple Lists의 기능 목록은 길지 않다:

  • 새 목록을 만들 수 있다. 각 목록은 URL에 무작위 ID를 가지므로 준 프라이빗(semi-private)하다. URL을 아는 사람만 해당 목록에 접근할 수 있다.
  • 목록에 항목을 추가할 수 있다.
  • 항목을 완료 처리해 줄을 그을 수 있다(그리고 취소할 수도 있다).
  • 목록에서 항목을 삭제할 수 있다.
  • 휴대폰에서도 잘 동작한다.
  • 크로스 사이트 스크립팅(XSS)과 크로스 사이트 요청 위조(CSRF)에 안전하다.

선택 기능(개인적으로 운영하는 인스턴스에서 사용 중이다):

  • 서버를 SIMPLELISTS_LISTS 환경 변수를 1로 설정해 실행하면 홈페이지에 목록들의 목록이 표시되고, 목록을 삭제할 수 있다.
  • 서버를 SIMPLELISTS_USERNAMESIMPLELISTS_PASSHASH 환경 변수를 설정해 실행하면 사이트에 접근하려면 사용자 이름과 비밀번호로 로그인해야 한다.(개인용이라 사용자는 한 명뿐이다.)

단순함을 유지하기 위해 Simple Lists가 하지 않는 것들은 다음과 같다:

  • 목록에서 항목 순서를 바꿀 수 없다.
  • 항목을 수정할 수 없다. 그냥 삭제하고 다시 추가하면 된다.
  • 화려한 색상, 이미지, 스타일링은 없다.

말했듯이, Simple Lists다. 하지만 나에게는 충분히 쓸 만하다!

JS 없음(Node.js 말고!)

사실 처음에는 조금 더 매끄럽게 만들어 보려고 클라이언트 쪽 JavaScript를 조금 뿌려서 시작했다. 하지만 JavaScript보다 Go로 프로그래밍하는 걸 더 좋아한다는 점을 차치하더라도, 클라이언트 스크립트가 전혀 필요 없다는 걸 깨달았다. 그냥 작고 빠르게 만들면 되고, 페이지 새로고침 몇 번 정도는 아무 문제도 되지 않는다.

그래서 있던 약간의 JavaScript도 걷어내고, 할 일 목록 데이터베이스의 상태를 바꾸는 데는 평범한 HTML 폼과 HTTP POST 메서드만 사용했다. 이 방식의 장점은 Go만 쓰고 약간의 HTML을 곁들이면 된다는 것이다. JavaScript 코드는 한 줄도 없고, Babel이나 Webpack 같은 화려한 빌드 도구도 필요 없다.

지금까지 상호작용을 위해 클라이언트 쪽 JavaScript만 써 왔다면, 이게 어떻게 동작하는지 간단히 살펴보자.

각 목록 항목(inside a <ul> 순서 없는 목록 안)에는 다음과 같은 HTML을 사용한다:

<li style="margin: 0.7em 0">
 <form style="display: inline;" action="/update-done"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <input type="hidden" name="done" value="on">
  <button id="done-{{ .ID }}" style="width: 1.7em">&nbsp;</button>
  <label for="done-{{ .ID }}">{{ .Description }}</label>
 </form>
 <form style="display: inline;" action="/delete-item"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <button style="padding: 0 0.5em; border: none; background: none;
                 color: #ccc" title="Delete Item">✕</button>
 </form>
</li>

여기 뭔가 많이 들어 있는 것처럼 보이지만 어렵지 않다. 각 항목마다 폼이 두 개 있다. 하나는 항목의 “done” 플래그를 업데이트(줄 긋기)하기 위한 것이고, 다른 하나는 항목을 삭제하기 위한 것이다.

HTML 폼이 꼭 텍스트 입력이나 파일 업로드 필드가 있는 무거운 UI일 필요는 없다. 숨겨진 데이터 필드 몇 개와 버튼만으로 구성될 수도 있다. delete-item 폼에서 보듯이 버튼은 스타일을 입힐 수 있다. 여기서는 예쁜 ✕ 아이콘처럼 보이도록 했다.

첫 번째 폼을 보면 버튼에 id 속성을 사용한 것을 알 수 있다. 이는 <label>for 속성과 연결되어 있어, 항목 설명 텍스트가 “완료 처리” 버튼의 레이블 역할을 한다. 목록 항목의 레이블 어디든 클릭하거나 탭하면 브라우저가 대신 버튼을 클릭해 항목에 줄을 그어 완료 처리한다. 브라우저는 JavaScript 없이도 많은 일을 할 수 있다!

Go를 선택한 이유

나는 Go 표준 라이브러리가 얼마나 잘 설계되어 있는지 자주 감탄한다. 특히 내가 잘 아는 다른 언어인 Python과 비교하면 더욱 그렇다. 이 서버가 사용하는 의존성은 SQLite 데이터베이스 드라이버와, bcrypt 비밀번호 해시 함수 몇 개를 위한 준 표준 라이브러리인 golang.org/x/crypto/bcrypt 패키지뿐이다.

다른 프로젝트들에서는 유명한 mattn/go-sqlite3 SQLite 드라이버를 사용하며 잘 동작한다. 하지만 이번 프로젝트에서는 modernc.org/sqlite 드라이버를 써 보고 싶었다. 꽤 대단한 엔지니어링의 결과물이다. SQLite의 C 소스 코드를 Go로 트랜스파일해 만든 순수 Go 포팅으로(CGo 바인딩 없음), 생성된 Go 코드는 보기에는 물론 끔찍하지만 잘 동작하고 C 컴파일러가 필요 없다는 장점이 있다. Windows나 크로스 컴파일 시에 유용하다.

Go 코드를 관용적인 방식으로 작성하려고 노력했다. 예를 들어, panic을 쓰고 싶은 유혹이 드는 곳에서도(데이터베이스 코드처럼, 프로세스 내에서 실행되는 SQLite 쿼리는 내가 쿼리를 잘못 작성했을 때만 실패해야 하는 것 아닌가?) 항상 에러를 반환하도록 했다.

그래도 나는 Go의 장황한 에러 처리를 그다지 좋아하지 않는다. 특히 HTTP 요청 핸들러에서는 return이 별도 줄에 있어, 코드 한 줄에 에러 처리가 네 줄이다. 코드를 이해하기 어렵게 만들지는 않지만 약간의 잡음을 더한다. 예를 들어 server.go의 다음 핸들러 코드를 비교해 보자:

list, err := s.model.GetList(id)
if err != nil {
    s.internalError(w, "fetching list", err)
    return
}
if list == nil {
    http.NotFound(w, r)
    return
}

Python이라면 데이터베이스 에러는 그냥 예외를 발생시키고 웹 프레임워크가 이를 잡아(그리고 Internal Server Error를 반환) 처리했을 것이고, not-found 검사는 다음과 같이 좀 더 간결했을 것이다:

lst = self.model.get_list(list_id)
if lst is None:
    raise HTTPStatus(NotFound)

Go에서도 panic을 이용해 이렇게 할 수는 있지만 관용적인 범위를 한참 벗어난다. Go의 에러 처리는 원래 간결하지 않으며, 빨리 그걸 신경 쓰지 않게 되는 게 좋다.

단순한 ServeMux 라우팅

Go에서 HTTP 라우팅의 다양한 접근법에 대해 글을 많이 써 왔고, 이번에도 regex table 방식이나 chi 라우터를 쓸 작정이었다.

하지만 웹 앱을 처음부터 만들다 보니 URL 구조를 완전히 통제할 수 있어서 단순화하기로 했다. REST 같은 URL 구조에 너무 익숙해져서 이게 좀 이상하게 느껴졌지만, 실용적으로 생각하면 DELETE /lists/{list-id}/items/{item-id}가 본문에 목록과 항목 ID를 담아 POST /delete-item을 하는 것보다 뭐가 더 나은가? 그리고 JavaScript를 쓰지 않는데 JSON 기반 API가 왜 필요한가?

이렇게 정하고 나니 Go에 내장된 단순한 http.ServeMux 타입으로 URL을 라우팅할 수 있었다. 문제를 설계 단계에서 없애 버려 단순화하고 의존성을 줄인 셈이다.

ServeMux에서 약간 이상한 점은 루트 패턴 "/"이 슬래시로 끝나는 모든 패턴처럼 / 자체는 물론 그 하위의 모든 경로와 매칭된다는 것이다. 그래서 이를 명시적으로 체크해야 한다. 앱의 전체 라우팅 코드는 다음과 같다:

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path == "/" { // because "/" pattern matches /*
        s.home(w, r)
    } else {
        http.NotFound(w, r)
    }
})
mux.HandleFunc("/sign-in", csrf(s.signIn))
mux.HandleFunc("/sign-out", s.signedIn(csrf(s.signOut)))
mux.HandleFunc("/lists/", s.signedIn(s.showList))
mux.HandleFunc("/create-list", s.signedIn(csrf(s.createList)))
mux.HandleFunc("/delete-list", s.signedIn(csrf(s.deleteList)))
mux.HandleFunc("/add-item", s.signedIn(csrf(s.addItem)))
mux.HandleFunc("/update-done", s.signedIn(csrf(s.updateDone)))
mux.HandleFunc("/delete-item", s.signedIn(csrf(s.deleteItem)))

signedIn 함수는 사용자 이름/비밀번호 인증을 추가하는 미들웨어 래퍼다. 기본적으로(그리고 데모 사이트에서는) 사용자 이름 인증은 꺼져 있고 isSignedIn은 항상 true를 반환한다:

func (s *Server) signedIn(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !s.isSignedIn(r) {
            location := "/?return-url=" + url.QueryEscape(r.URL.Path)
            http.Redirect(w, r, location, http.StatusFound)
            return
        }
        h(w, r)
    }
}

csrf 함수는 크로스 사이트 요청 위조로부터 보호하는 미들웨어 래퍼다. 주어진 핸들러를 감싸 HTTP 메서드가 POST인지, 그리고 csrf-token 쿠키의 CSRF 토큰이 csrf-token 폼 필드의 토큰과 일치하는지 확인한다. 이를 통해 폼 액션은 Simple Lists 자체 페이지에서만 제출될 수 있다:

func csrf(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "POST" {
            w.Header().Set("Allow", "POST")
            http.Error(w, "405 method not allowed",
                http.StatusMethodNotAllowed)
            return
        }
        token := r.FormValue("csrf-token")
        cookie, err := r.Cookie("csrf-token")
        if err != nil || token != cookie.Value {
            http.Error(w, "invalid CSRF token or cookie",
                http.StatusBadRequest)
            return
        }
        h(w, r)
    }
}

데이터베이스 처리

데이터베이스 선택은 고민할 것도 없었다. 오래전부터 SQLite를 좋아했고, 이 정도 규모의 프로젝트에는 딱 맞는다. 더 큰 프로젝트라면 고려할 유일한 대안은 PostgreSQL이다. 신뢰할 수 있고 정말 마음에 들기 때문이다(빠르고 문서화가 잘 되어 있으며 JSON 지원 같은 훌륭한 기능을 갖추고 있다).

의존성을 최소화하려고 하고(그리고 대부분의 ORM을 별로 좋아하지 않기 때문에) 이번 프로젝트에서는 표준 라이브러리의 database/sql 패키지만 사용하기로 했다. sqlx 사용도 잠깐 고려했다. 구조체와 슬라이스 처리는 마음에 들지만, 이렇게 작은 데이터베이스 모델에서는 큰 도움이 되지 않는다.

어떤 모습인지 감을 잡을 수 있도록 db.go의 함수 두 개를 예로 들어 보자. GetList는 단일 목록을 가져오고, getListItems 헬퍼는 해당 목록의 모든 항목을 가져온다:

// GetList fetches one list and returns it, or nil if not found.
func (m *SQLModel) GetList(id string) (*List, error) {
    row := m.db.QueryRow(`
        SELECT id, name
        FROM lists
        WHERE id = ? AND time_deleted IS NULL
        `, id)
    var list List
    err := row.Scan(&list.ID, &list.Name)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    list.Items, err = m.getListItems(id)
    return &list, err
}

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    rows, err := m.db.Query(`
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []*Item
    for rows.Next() {
        var item Item
        err = rows.Scan(&item.ID, &item.Description, &item.Done)
        if err != nil {
            return nil, err
        }
        items = append(items, &item)
    }
    return items, rows.Err()
}

보다시피 QueryRow는 테이블에서 단일 행을 가져오는 데 사용되며 사용법이 간단하다. Query는 여러 행을 가져오는데 조금 더 번거롭다. 행을 직접 순회하면서 각 행을 Scan하고 스캔 에러를 확인한 뒤 스캔한 항목을 슬라이스에 추가해야 한다. 그리고 rows 객체를 Close하는 것도 잊지 말아야 한다. 이를 단순화할 방법을 찾아봤지만, 순수 database/sql만으로는 리플렉션을 쓰지 않고는 이 정도가 최선이다(더 좋은 방법을 안다면 알려 달라).

의존성을 최소화하는 건 좋지만 분명 트레이드오프가 있다. 쿼리를 많이 해야 한다면 앞서 언급한 sqlx 라이브러리를 추천한다. sqlx를 쓰면 getListItems 헬퍼에서 반복과 Scan 상용구를 모두 없앨 수 있다:

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    var items []*Item
    err := m.db.Select(&items, `
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    return items, err
}

반면, 변경(mutation) 쿼리를 실행하는 건 database/sql로 간단하다. 행을 스캔할 필요가 없기 때문이다. 아래는 목록에서 항목을 삭제하는 방법이다(나는 보통 나중에 복구할 수 있도록 소프트 삭제를 선호한다):

// DeleteItem (soft) deletes the given item in a list.
func (m *SQLModel) DeleteItem(listID, itemID string) error {
    _, err := m.db.Exec(`
            UPDATE items
            SET time_deleted = CURRENT_TIMESTAMP
            WHERE list_id = ? AND id = ?
        `, listID, itemID)
    return err
}

수동 의존성 주입

실제 테스트를 어떻게 구현했는지 보여주기 전에 Go 인터페이스와 의존성 주입에 대해 잠깐 이야기하고 싶다. “의존성 주입” 자체는 좋은 일이지만, 의존성 주입 프레임워크와 라이브러리는 골칫거리다. 일반적으로 런타임 리플렉션을 사용해 타입 검사를 우회하고 IDE로(혹은 눈으로) 코드를 따라가기 어렵게 만들기 때문이다! 다행히 의존성을 수동으로 연결하는 건 안전하고 쉽다.

Go에서는 인터페이스를 구현을 제공하는 패키지가 아니라 사용하는 패키지에서 정의하는 경향이 있다(그리고 인터페이스는 구현이 제공하는 메서드 중 일부만을 포함할 수도 있다).

Simple Lists 코드는 모든 것이 main이라는 하나의 패키지에 있어 매우 평면적이다. 하지만 더 큰 프로젝트에서는 어떻게 하는지 보여주기 위해 server.go에서 사용되는 곳 근처에 데이터베이스 Model 인터페이스를 정의했다. Server가 필요로 하는 인터페이스와 서버 인스턴스를 생성하는 NewServer 함수의 시그니처는 다음과 같다:

// Model is the database model interface used by the server.
type Model interface {
    GetLists() ([]*List, error)
    CreateList(name string) (string, error)
    DeleteList(id string) error
    GetList(id string) (*List, error)

    AddItem(listID, description string) (string, error)
    UpdateDone(listID, itemID string, done bool) error
    DeleteItem(listID, itemID string) error

    CreateSignIn() (string, error)
    IsSignInValid(id string) (bool, error)
    DeleteSignIn(id string) error
}

// Logger is the logger interface used by the server.
type Logger interface {
    Printf(format string, v ...interface{})
}

// NewServer creates a new server with the specified dependencies.
func NewServer(
    model Model,
    logger Logger,
    timezone string,
    username string,
    passwordHash string,
    showLists bool,
) (*Server, error) {
    ...
}

그리고 main.go에서 모든 것을 연결한다:

func main() {
    ...
    db, err := sql.Open("sqlite", *dbPath)
    exitOnError(err)
    model, err := NewSQLModel(db)
    exitOnError(err)
    server, err := NewServer(model, log.Default(), *timezone,
                             *username, passwordHash, *showLists)
    exitOnError(err)
    ...
}

내 생각에는(두 방식 다 써 봤다) main에서 모든 것을 명시적으로 연결하는 방식이 훨씬 우수하다. 평범한 Go 코드이고, 타입 체크가 되며 IDE 친화적이다.

데이터베이스 모델에 인터페이스를 사용하면 테스트용으로 목(mock)이나 페이크(fake) 데이터베이스 구현을 쉽게 만들 수 있다. 프로덕션 코드가 PostgreSQL이나 MongoDB 같은 무거운 외부 데이터베이스를 사용할 때 유용하다. 하지만 이번 경우에는 테스트에서 실제 SQLite 데이터베이스(인메모리)를 사용하므로 페이크를 작성할 필요조차 없다. 테스트에서 서버를 연결하는 방법은 다음과 같다:

db, err := sql.Open("sqlite", ":memory:")
if err != nil {
    t.Fatalf("opening database: %v", err)
}
model, err := NewSQLModel(db)
if err != nil {
    t.Fatalf("creating model: %v", err)
}
server, err := NewServer(model, nullLogger{}, "Pacific/Auckland",
                         "", "", true)
if err != nil {
    t.Fatalf("creating server: %v", err)
}

테스트

나는 가능하면 테스트에서 실제 데이터베이스를 대상으로 실행하는 것을 선호한다. 컨테이너에서 PostgreSQL 데이터베이스를 저렴하게 띄울 수 있거나 인메모리 데이터베이스를 사용할 수 있다면, 실제 대상을 테스트하는 셈이다.

우리 경우에는 인메모리 SQLite 데이터베이스(:memory:)를 사용하므로 모든 것이 프로세스 내에서 동작하고 디스크에 닿지도 않는다. 페이크를 작성하거나 목을 사용할 필요가 없다. 테스트는 SQLite를 이용해 매우 빠르게 실행된다. 프로덕션에서 PostgreSQL을 사용한다면 테스트에서는 속도 유지를 위해 아마도 여전히 SQLite를 사용하고, 두 데이터베이스 간에 달라야 하는 쿼리만 오버라이드할 것이다.

테스트는 엔드 투 엔드에 상당히 가까워 구현이 아니라 기능을 테스트한다. 각 테스트는 Server.ServeHTTP 핸들러를 호출하고 net/http/httptest 패키지를 이용해 HTTP 응답을 기록한다.

테스트 코드 일부는 다음과 같다:

func TestServer(t *testing.T) {
    ...
    jar, err := cookiejar.New(nil)
    if err != nil {
        t.Fatalf("creating cookie jar: %v", err)
    }

    // Fetch homepage
    var csrfToken string // CSRF token stays same for entire session
    {
        recorder := serve(t, server, jar, "GET", "/", nil)

        ensureCode(t, recorder, http.StatusOK)
        forms := parseForms(t, recorder.Body.String())
        ensureInt(t, len(forms), 1)
        ensureString(t, forms[0].Action, "/create-list")
        csrfToken = forms[0].Inputs["csrf-token"]
        if csrfToken == "" {
            t.Fatal("csrf-token input not found")
        }
    }

    // Create list
    var listID string
    var listIDs []string
    {
        form := url.Values{}
        form.Set("csrf-token", csrfToken)
        form.Set("name", "Shopping List")
        recorder := serve(t, server, jar, "POST", "/create-list", form)

        ensureCode(t, recorder, http.StatusFound)
        location := recorder.Result().Header.Get("Location")
        ensureRegex(t, location, "/lists/[a-z]{10}")
        listID = location[7:]
        listIDs = append(listIDs, listID)
    }
    ...

여기서는 아마도 확장성이 떨어지는 단순화 설계를 했다. 테스트가 모두 단일 TestServer 함수 안에 순차적으로 작성되어 있고, 뒤쪽 테스트가 앞쪽 테스트에 의존한다. 테스트 실행이 워낙 빨라(go test에서 10밀리초라고 나온다) 이렇게 해도 괜찮고, 각 하위 테스트 시작 시 목록 생성 같은 공통 설정 단계를 반복하지 않아도 된다.

더 큰 코드베이스라면 설정 단계를 헬퍼 함수에 넣고 서브테스트로 각 부분을 t.Run을 이용해 실행했을 것이다.

if got != want { t.Fatalf("got %v, want %v", got, want) } 블록을 계속 반복하는 건 좀 지겹기 때문에 그런 부분을 헬퍼 함수로 빼냈다. 여러 어서션 라이브러리 중 하나를 쓸 수도 있었는데(작은 API 때문에 gocheck을 선호한다) 의존성을 끌어오지 않으려고 간단한 ensure* 헬퍼 함수 몇 개를 직접 작성하는 게 쉬웠다.

이건 JSON API가 아니므로 golang.org/x/net/html 패키지를 이용해 응답 본문의 HTML 폼을 파싱하는 parseForms 헬퍼를 작성했다. 이를 통해 CSRF 토큰 같은 다양한 필드를 추출해 나중에 사용할 수 있다.

요청을 실행하고 기록하는 serve 헬퍼 함수는 다음과 같다:

// serve records a single HTTP request and returns the response recorder.
func serve(t *testing.T, server *Server, jar http.CookieJar,
           method, path string, form url.Values,
) *httptest.ResponseRecorder {
    t.Helper()
    var body io.Reader
    if form != nil {
        body = strings.NewReader(form.Encode())
    }
    r, err := http.NewRequest(method, "http://localhost"+path, body)
    if err != nil {
        t.Fatalf("creating request: %v", err)
    }
    if form != nil {
        r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    }
    for _, c := range jar.Cookies(r.URL) {
        r.Header.Add("Cookie", c.Name+"="+c.Value)
    }
    recorder := httptest.NewRecorder()
    server.ServeHTTP(recorder, r)
    jar.SetCookies(r.URL, recorder.Result().Cookies())
    return recorder
}

net/http/cookiejar를 사용해 이전 요청에서 설정된 쿠키를 이후 요청에 전달하도록 한 점에 주목하자. CSRF 쿠키도 이렇게 자동으로 처리된다.

이 Go 테스트들은 기본적인 서버 측 기능 대부분을 테스트한다. 하지만 레이아웃과 UI는 테스트하지 않으므로, 변경 사항을 만들 때는 브라우저에서 로컬로 빠른 수동 테스트도 함께 진행한다. Simple Lists는 기능이 몇 개뿐이라 전부 확인하는 데 1분밖에 걸리지 않는다.

최소한의 HTML과 CSS

HTML 템플릿에는 Go의 html/template 패키지를 사용했다. 좀 독특한데(예를 들어 표현식 문법이 Go 표현식의 부분 집합이었으면 좋겠다) 문서를 한 번 읽고 나면 나쁘지 않다. 템플릿 문서의 대부분은 실제로 text/template 문서에 있다는 점을 참고하자.

HTML은 매우 단순하다. 페이지는 두 개뿐이다(홈페이지와 목록 페이지). html/template 패키지는 템플릿 재사용을 위한 “blocks”를 지원하지만, 두 템플릿 사이에 약간의 중복을 두는 편이 더 단순하다.

전체 템플릿 소스를 참고하자. 레이아웃이 휴대폰에서도 잘 동작하도록 하는 meta viewport 태그에 주목하자. Simple Lists는 “완전 반응형”이다!

<meta name="viewport" content="width=device-width, initial-scale=1">

HTML은 목록 요소와 버튼에 스타일을 주기 위해 소량의 CSS를 사용한다. 이 정도 크기의 앱에서는 그냥 인라인 CSS를 쓰는 게 가장 쉽다고 느꼈다. 예를 들어 delete-item 버튼의 테두리를 없애고 색상을 지정하는 식이다. 버튼 레이블에 유니코드 를 사용한 점도 참고하자. 아이콘이 뭐가 필요한가!

<button style="padding: 0 0.5em; border: none; background: none;
               color: #ccc" title="Delete Item">✕</button>

더 큰 앱을 만든다면 이런 스타일을 재사용하고 한곳에서 정의하기 쉽도록 CSS 클래스나 다른 메커니즘을 쓰고 싶을 것이다.

보안

이 앱은 보안 검토를 거치지 않았으므로 여기서는 어떤 것도 보장할 수 없지만, 나름 주의해서 만들려고 노력했다. 몇 가지 참고 사항은 다음과 같다:

  • 앞서 언급했듯이 csrf-token 쿠키와 csrf-token 폼 필드가 일치하는지 확인해 CSRF 보호를 구현한다.
  • Go의 템플릿 라이브러리를 사용하므로 크로스 사이트 스크립팅(XSS) 공격을 자동으로 방어한다.
  • Go의 database/sql 라이브러리를 파라미터화된 쿼리와 함께 사용하므로 SQL 인젝션 공격에 안전하다.
  • SQLite 자체는 매우 철저히 테스트되어 있으며, 우리가 사용하는 modernc.org/sqlite 패키지도 동일한 방대한 테스트 스위트로 테스트되었다.
  • Go의 HTTP 서버는 안전하고 프로덕션에서 단련된 것으로 알려져 있다.

사용자 이름/비밀번호 인증은 데모 사이트에서는 꺼져 있지만, 우리 가족이 사용하는 개인 인스턴스에서는 켜져 있다. 사용자 이름과 비밀번호는 하나뿐이지만 내 용도로는 충분하다.

이 모드에서는 사용자 이름은 명령줄 파라미터로 서버에 전달되고, bcrypt로 해시된 비밀번호는 환경 변수로 전달된다. 당연히 여러 사용자를 지원하려면 사용자 이름과 해시된 비밀번호를 데이터베이스에 저장해야 할 것이다. 세션, 즉 “sign ins”는 단순한 sign_ins 데이터베이스 테이블에 저장되며 90일 후에 만료된다.

예시로 signIn 요청 핸들러 코드를 아래에 보인다. 폼 필드에서 사용자 이름과 비밀번호를 가져와 bcryptCompareHashAndPassword 함수로 비밀번호를 확인하고, 로그인이 유효하면 sign_ins 행을 생성하고 sign-in 쿠키를 설정한다.

func (s *Server) signIn(w http.ResponseWriter, r *http.Request) {
    username := strings.TrimSpace(r.FormValue("username"))
    password := r.FormValue("password")
    returnURL := r.FormValue("return-url")
    if returnURL == "" {
        returnURL = "/"
    }
    if username != s.username || bcrypt.CompareHashAndPassword(
            []byte(s.passwordHash), []byte(password)) != nil {
        location := "/?error=sign-in&return-url=" +
            url.QueryEscape(returnURL)
        http.Redirect(w, r, location, http.StatusFound)
        return
    }
    id, err := s.model.CreateSignIn()
    if err != nil {
        s.internalError(w, "creating sign in", err)
        return
    }
    cookie := &http.Cookie{
        Name:     "sign-in",
        Value:    id,
        MaxAge:   90 * 24 * 60 * 60,
        Path:     "/",
        Secure:   r.URL.Scheme == "https",
        HttpOnly: true,
        SameSite: http.SameSiteStrictMode,
    }
    http.SetCookie(w, cookie)
    http.Redirect(w, r, returnURL, http.StatusFound)
}

결론

Simple Lists를 만드는 과정이 정말 즐거웠고, 이미 우리 가족의 공유 목록에도 유용하게 쓰이고 있다. 생일 및 크리스마스 목록, 볼 영화 목록 등이다. 나는 작고, 빠르고, 가볍게 유지하는 것을 좋아하는데, 여기서는 그걸 달성했다고 생각한다.

Go를 사용하는 건 정말 재미있다. 정적 타입 검사, 빠른 컴파일 시간, 훌륭한 표준 라이브러리, 포맷팅과 테스트 실행을 위한 우수한 툴링이 있다. 그리고 크로스 컴파일과 배포가 매우 쉽다. 단순히 GOOS=linux GOARCH=amd64 go build라고 입력하기만 하면 몇 초 후(심지어 macOS나 Windows에서 개발 중이라도) 프로덕션 서버에 복사할 수 있는 Linux 실행 파일이 생긴다.

JavaScript 없이도 꽤 쓸 만하게 나온 점도 마음에 든다. 요즘 HTML만으로도 할 수 있는 일이 꽤 많으니, React를 꺼내기 전에 한 번 더 생각해 보자.

이 글이 즐거웠거나 뭔가 배운 게 있었기를 바란다. 피드백이 있다면 꼭 알려 달라!

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

댓글