ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 노노그램 퍼즐 웹 서버 제작기 1: 기본 서버 만들기
    Programming/Projects 2024. 8. 27. 03:02

    웹 프레임워크 없이 순수 Go 표준 라이브러리로 HTTP 서버를 구축하는 내용을 담고 있습니다.
    Go와 PostgreSQL을 이용해 간단한 웹서버를 제작하는 여정을 기록했습니다.
    노노그램 퍼즐을 저장하기 위한 PostgreSQL 데이터베이스 설계 및 구축, 다국어 지원을 위한 스키마, TABLESAMPLE BERNOULLI를 활용한 랜덤 데이터 추출 최적화 등 처음 웹 서버를 만들어 보며 겪는 과정들을 기록해 놓았습니다.

    Intro

    최근 웹 소켓과 TCP/IP 네트워크 기초를 공부했습니다.
    공부한 내용을 적용해 볼 겸 첫 서버 프로그래밍 프로젝트를 시작해 보려고 합니다.
    예전에 C 언어 연습용으로 만들다 완성을 미뤄둔 터미널 네모로직 프로그램을 위한 웹 서버입니다.

    서버의 목적은 단순합니다. 퍼즐 데이터를 제공하는 거죠.
    /puzzles 엔드포인트로 HTTP GET 요청을 보내면 DB에서 랜덤으로 퍼즐을 골라 JSON 포맷으로 전송하는 서버입니다.

    기능이 추가될 수도 있습니다. 특정 크기나 난이도의 퍼즐을 요청하거나, 사용자가 제작한 퍼즐을 업로드하는 기능들이요.

    하지만 일단 처음 시작하는 만큼 목표를 작게 잡고자 합니다. 새 기능은 직접 써보다가 필요하면 추가하기로 하겠습니다.

    제가 쓰고 싶어서 만드는 프로그램이고, 만들면 제가 제일 열심히 쓸 테니까요.

    서버 언어 선택

    서버 개발에는 Go 언어를 사용하기로 했습니다.
    지금까지 공부는 대부분 C언어와 함께했습니다. 단순한 스크립트 작성이나 데이터 처리는 파이썬을 사용했고요.
    거기에 코딩테스트용 언어로 C++을 조금 배운 게 제가 할 줄 아는 프로그래밍 언어의 전부입니다.

    Go는 이번에 처음 사용해 보는 언어이고, 아예 새로 배워야 하는 언어죠.

    그럼에도 왜 굳이 Go를 선택했냐면, 이번 프로젝트는 웹 프레임워크 없이 만들어 보고 싶었기 때문입니다.

    사실 처음엔 자바 스프링과 파이썬의 플라스크 중에서 고민을 많이 했습니다.
    자바 스프링은 취업에 도움이 많이 된다는 얘기가 많았습니다. 늦게 개발에 뛰어든 저에게는 엄청난 강점이죠.
    한편, 플라스크는 CS50 강의에서 잠깐 다뤄본 경험이 있습니다. 파이썬 언어도 나름 익숙하고요.

    그렇게 고민하던 와중 마음을 바꿨습니다. C언어로 만들기로요.
    기초를 배우고 실습하기 위한 프로젝트라면, 웹 프레임워크 없이 개발해 보는 편이 배우는 게 더 많을 거라 생각했습니다. 기능이 많고 편리한 만큼 개발자가 직접 공부하고 구현할 부분이 줄어들 테니까요.

    문제는 시간입니다.
    제가 쓰고 싶어서 만드는 프로그램인데, 실제 사용하기까지 시간이 너무 오래 걸리는 건 또 싫었습니다.

    C언어로 서버를 만들면 개발 시간이 2배, 3배로 불어날 게 뻔했습니다.
    동시성 처리나 복잡한 문자열 파싱, 데이터베이스 연동이나 https 연결 등 구현할 것도 너무 많고요.

    그러던 와중 Go 언어를 알게 되었습니다. 문법도 쉽고, 표준 라이브러리만으로 충분히 실용적인 웹 서버 제작이 가능하다고 하더라고요.

    Go의 강력한 네트워크 라이브러리를 쓰면서 개발하는 게 처음 목표와 조금 동떨어져 보일 수는 있지만, 그래도 적당한 타협점이라고 생각해 Go를 사용하기로 했습니다.

    서버 만들어보기

    Go 언어 배워보기

    기본 문법은 Go를 향한 여행 튜토리얼을 통해 학습했습니다. 튜토리얼이 엄청 친절합니다.

    일단은 기초 자료구조까지만 열심히 따라 해보고, 이후 부분은 눈으로 쓱쓱 읽어보았습니다.

    HTTP 서버 만들어보기

    net/http 패키지 Documentation 에서 예제 코드를 찾을 수 있었습니다.

    /puzzles 요청 처리 함수를 선언, 등록한 다음 http.ListenAndServe 함수를 사용하면 끝납니다.

    package main
    
    import (
        "fmt"
        "log"
        "net/http"
    )
    
    func handlePuzzles(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "This is the puzzle data")
    }
    
    func main() {
        http.HandleFunc("/puzzles", handlePuzzles)
        log.Fatal(http.ListenAndServe(":8080", nil))
    }

    한번 테스트 해 보겠습니다.

    curl localhost:8080/puzzles
    This is the puzzle data

    잘 작동하네요.

    함수 들여다보기

    ListenAndServe(":8080", nil)에서 두 번째 인자는 무엇을 의미할까요?

    이는 요청 URL을 확인하고 대응하는 함수를 호출하는 HTTP 요청 멀티플렉서라고 합니다.

    인자로 nil을 넘겨준다는 것은 기본값인 DefaultServeMux를 사용하겠다는 의미입니다.

    http.Handlefunc는 사실 DefaultServeMuxhandlePuzzles 함수를 등록해 주고 있던 거죠.

    요약하면 사실은 이런 구조인 셈입니다.

    func main() {
        listner, err := net.Listen("tcp", ":8080")
        if err != nil {
            log.Fatal(err)
        }
    
        myMux := http.NewServeMux()
        myMux.HandleFunc("/puzzles", handlePuzzles)
    
        server := &http.Server{Handler: myMux}
        err = server.Serve(listner)
        if err != nil {
            log.Fatal(err)
        }
    }

    server.Servelistner 에서 오는 클라이언트 연결 요청을 확인하고, 연결 소켓을 만든 다음, 고루틴을 통해 연결을 동시적으로 처리합니다.
    구현 코드를 확인해 보았습니다.
    세세한 부분을 제외하면 제가 알고 있는 아래 구조랑 동일한 것 같네요.

    func (srv *http.Server) myServe(listner net.Listener) error {
        defer listner.Close()
        for {
            conn, err := listner.Accept()
            if err != nil {
                return err
            }
    
            go srv.handleConn(conn)
        }
    }

    구현 코드에서 context 메커니즘을 통해 동시성을 관리하는 원리가 궁금하긴 했지만 일단 넘어가기로 했습니다.

    퍼즐을 저장해야 해서요.

    퍼즐 데이터베이스 구축하기

    이제 퍼즐 서버에서 사용할 데이터베이스를 구축해야 합니다.

    데이터는 로직홈 사이트의 "소요선"님께서 제공 해주신 퍼즐을 사용했습니다.

    각종 어플과 웹사이트를 포함한 네모로직 퍼즐 중에서도 가장 재미있고 퀄리티 높은 퍼즐을 만드시는 분입니다.
    그래서 따로 연락을 드렸는데, 다행히 사용을 허락해 주셨습니다.

    DBMS는 PostgreSQL을 사용하기로 했습니다.

    이유는 어떤 걸 선택해야 할지 잘 몰라서입니다.
    데이터베이스 지식이 거의 없고, 써본 경험도 없으며, 서버에서 필요로 하는 데이터 구조나 접근 방식도 확신할 수 없습니다.
    마침 모르면 일단 PostgreSQL이라고 하는 글이 있어서 PostgreSQL을 선택했습니다.

    DB 스키마 구성

    기존 노노그램 클라이언트에서는 퍼즐 데이터를 아래와 같은 JSON 파일로 저장했었습니다.
    퍼즐 세트 안에 여러 퍼즐이 들어있는 구조입니다.

    {
        "format_version": "0.2.0",
        "title": "Puzzle Set for begginers",
        "description": "Collection of easy puzzles for new players.",
        "num_puzzles": 6,
        "puzzles": [
            {
                "id": 0,
                "title": "Heart",
                "author": "dvbeetle",
                "difficulty": 0,
                "rows": 5,
                "cols": 5,
                "row_clues": [
                    [1, 1],
                    [5],
                    [5],
                    [3],
                    [1]
                ],
                "col_clues": [
                    [2],
                    [4],
                    [4],
                    [4],
                    [2]
                ]
            },
            ...
    }

    서버는 퍼즐 세트는 고려하지 않고, 개별 퍼즐만 저장하고 전달하도록 하겠습니다.

    퍼즐을 데이터베이스에 저장하기 위한 스키마 작성이 필요합니다.
    구성을 어떻게 할지 조금 막막했습니다.

    일단은 위 JSON 포맷을 기반으로, 정보를 다음 세 가지 분류로 나눠보았습니다.

    1. 변경될 일이 거의 없는 정보 / 퍼즐의 정체성
    2. 변경될 수도 있는 정보
    3. 자주 변경될 법한 정보

    1번: 변경될 일이 없는 정보

    id, rows, cols 그리고 row_cluescol_clues를 1번으로 분류했습니다.
    퍼즐 크기나 힌트가 달라진다면 더 이상 같은 퍼즐이라고 볼 수 없죠.

    id는 퍼즐에 해당하는 고유 번호로 Primary Key로 두기로 합니다.

    rows, cols 는 이참에 이름을 num_rows num_cols로 더 직관적으로 바꿔주고, 정수 값으로 저장하겠습니다.

    퍼즐 힌트는 어떻게 저장해야 할까요? 처음에는 데이터 자체를 직렬화하는 방식을 생각해 보았습니다.
    아래와 같은 퍼즐 정보가 있으면

    {
        "rows": 5,
        "cols": 5,
        "row_clues": [
            [1, 1],
            [5],
            [5],
            [3],
            [1]
        ],
        "col_clues": [
            [2],
            [4],
            [4],
            [4],
            [2]
        ]
    },

    개별 숫자 간 구분은 :, 한 줄 간 구분은 %, row 힌트와 col 힌트는 |로 구분하는 방식입니다.
    1:1%5%5%3%1%|2%4%4%4%2 이런 방식으로요.

    문제는 이렇게 저장하면 서버가 위 문자열을 다시 JSON 포맷으로 바꿔줘야 한다는 점입니다.
    저장 효율은 훨씬 높겠지만, 서버에서 문자열 처리 과정이 하나 더 생기게 됩니다.
    바쁜 와중에 일을 하나 더 시키는 셈이죠.

    찾아보니 마침 PostgreSQL이 JSON을 그대로 저장하는 JSONB 타입을 지원했습니다.
    그래서 JSONB 타입을 사용하기로 했습니다.

    어떤 게 더 좋을지는 아직 모르지만, 적어도 작성할 코드는 줄어들었습니다.

    그렇게 puzzles 테이블을 아래와 같이 구성해 주었습니다.

    CREATE TABLE puzzles (
        id SERIAL PRIMARY KEY,
        num_rows INTEGER NOT NULL,
        num_cols INTEGER NOT NULL,
        clues JSONB NOT NULL
    );

    2번: 변경될 수도 있는 정보

    titleauthor는 변경될 수도 있는 정보로 보았습니다.

    퍼즐 제목은 작가 의도에 따라 변경될 수 있습니다.

    author, 작가명은 웹사이트 id처럼 고유한 값으로 볼 수도 있겠습니다. 일단은 닉네임에 더 가까운 개념처럼 생각해 보기로 했습니다.

    퍼즐 제목은 저장이 조금 까다로운 면이 있습니다.
    소요선님 퍼즐 제목의 99%가 한국어로 되어 있기 때문입니다.

    소요선님에게 퍼즐 사용 허락을 받으면서, 외국 사람들에게도 소요선님의 퍼즐을 알리고 싶다고 했었습니다.
    그러기 위해선 퍼즐 제목을 영어로 제공할 수 있어야겠죠.

    한편, 작가 의도를 전달하려면 한국어 사용자에게는 원제목을 제공하는 편이 좋습니다.

    조사 끝에 Multi-Language Database Design 라는 글을 참고해서 아래 방법을 사용하기로 했습니다.

    퍼즐 id와 함께 3글자 언어 코드를 두어서 복합 키로 사용하는 방법입니다.

    한국어 제목 영어 번역은... 수동으로 해야겠습니다. 이는 나중의 저한테 맡기죠.

    CREATE TABLE puzzle_titles (
        puzzle_id INTEGER,
        language VARCHAR(3) NOT NULL,
        title VARCHAR(255) NOT NULL,
        PRIMARY KEY (puzzle_id, language),
        FOREIGN KEY (puzzle_id) REFERENCES puzzles(id)
    );

    퍼즐 작가명 역시 별도의 테이블을 두기로 했습니다.

    CREATE TABLE authors (
        id SERIAL PRIMARY KEY,
        name VARCHAR(50) NOT NULL
    );

    차후에 유저 로그인 같은 개념을 추가한다면 바로 손봐야 할 부분이겠습니다.
    하지만 현 단계에서는 그냥 이렇게 사용하기로 했습니다.

    이후 생성 시 타임스탬프를 포함해서 puzzle_metadata 테이블을 만들었습니다.

    CREATE TABLE puzzle_metadata (
        puzzle_id INTEGER PRIMARY KEY,
        author_id INTEGER NOT NULL,
        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (puzzle_id) REFERENCES puzzles(id),
        FOREIGN KEY (author_id) REFERENCES authors(id)
    );

    테이블 이름이 metadata 인데 title이 빠져있는 게 조금 아쉽습니다. 좋은 방법을 아직 찾지 못했습니다.

    3번: 자주 변경될 법한 정보

    difficulty는 자주 변경될 법한 정보로 보았습니다.

    노노그램 클라이언트에는 퍼즐의 난이도가 있었습니다. 제가 임의로 매긴 난이도였습니다.
    하지만 800개가 넘는 퍼즐을 풀어보고 난이도를 관리하는 건 너무 힘듭니다.

    평균 퍼즐 해결 시간 같은 지표를 사용하면 난이도를 간접적으로 산출할 수 있지 않을까요?

    그래서 하나 만들어 두었습니다.

    CREATE TABLE puzzle_stats (
        puzzle_id INTEGER PRIMARY KEY,
        avg_completion_time INTERVAL,
        FOREIGN KEY (puzzle_id) REFERENCES puzzles(id)
    );

    이렇게 PostgreSQL에서 테이블을 만들고, 데이터를 넣어주었습니다.

    Go 프로그램과 데이터베이스 연결하기

    이제 go 프로그램 안에서 퍼즐 DB에 있는 데이터를 출력해 보겠습니다.
    Go wiki_SQLinterface에 예시가 잘 나와 있습니다.

    database/sql 패키지의 sql.Open 함수를 사용하겠습니다.

    사용을 위해선 원하는 데이터베이스 드라이버가 필요하다고 합니다.
    PostgreSQL 드라이버 패키지를 따로 import 해야 합니다.

    go의 패키지는 깃헙 링크로도 import 및 설치가 가능합니다. 진짜 엄청 편리합니다.
    go install github.com/lib/pq, 혹은go run 이나 go mod tidy 명령을 입력하면 알아서 패키지를 받아줍니다.

    package main
    
    import (
        "database/sql"
        "fmt"
        _ "github.com/lib/pq"
    )
    
    func main() {
        username := "test_user"
        password := "test_password17"
        host := "localhost"
        port := "5432"
        dbname := "puzzle_db"
    
        db, err := sql.Open("postgres", "postgres://"+username+":"+password+"@"+host+":"+port+"/"+dbname+"?sslmode=disable")
        if err != nil {
            fmt.Println(err)
            return
        }
        err = db.Ping()
        if err != nil {
            fmt.Println(err)
        }
        fmt.Println("Connected to the database")
    }

    sql.Open 함수는 연결 상태를 확인해 주지 않습니다. 따라서 db.Ping 함수를 통해 연결 상태를 확인해 줍니다.

    드라이버 import 앞에 _를 붙였습니다. 이는 직접 사용하지 않는 패키지를 import하면 go에서 에러를 뱉기 때문입니다.
    _를 붙이면 이를 무시하고 컴파일이 가능합니다.
    side effect만 보고 import 하는 패키지는 이렇게 사용한다고 합니다.

    row := db.QueryRow(`
    SELECT p.id, COALESCE(pt.title)
    FROM puzzles p 
    LEFT JOIN 
        puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
    LIMIT 1`)
    
    var id int
    var title string
    
    err = row.Scan(&id, &title) 
    if err != nil {
        fmt.Println(err)
        return
    }
    
    fmt.Println(id, title)

    이제 db.QueryRow로 SQL 쿼리를 작성하고 Scan으로 읽어오는 게 가능합니다.

    퍼즐을 위한 struct를 하나 만들어서

    type Puzzle struct {
        Id      int
        Title   string
        Author  string
        NumRows int
        NumCols int
        Clues   string
    }

    쿼리를 짜고 출력해 보겠습니다.

    row := db.QueryRow(`
    SELECT 
        p.id, p.num_rows, p.num_cols, p.clues,
        COALESCE(pt.title, 'Untitled') AS title,
        COALESCE(a.name, 'anonymous') AS author
    FROM puzzles p 
    LEFT JOIN 
        puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
    LEFT JOIN
        puzzle_metadata pm ON p.id = pm.puzzle_id
    LEFT JOIN
        authors a ON pm.author_id = a.id
    LIMIT 1`)
    
    var pz Puzzle
    err = row.Scan(&pz.Id, &pz.NumRows, &pz.NumCols, &pz.Clues, &pz.Title, &pz.Author)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(pz)
    {1 사랑한다는 말 soyosun 25 25 {"col_clues": [[12,
     3], [14, 1, 2], [3, 4, 4, 1, 2], [2, 3, 2], [2, 2
    ], [1, 1, 2], [1, 1, 2, 1], [2, 1, 1, 1, 1, 1], [2
    , 1, 2, 3], [3, 2], [6, 2, 1], [4, 4, 2, 1, 1], [4
    , 3, 2, 1, 1, 1, 4], [2, 2, 1, 1, 5, 6], [1, 4, 2,
     2, 4], [1, 1, 1, 5], [2, 5, 3], [3, 2, 1, 1, 2], 
    [3, 2, 1, 1, 2, 2], [3, 1, 1, 1, 2, 1, 2], [3, 1, 
    3, 2, 1, 1], [3, 1, 2, 2, 2], [4, 1, 1, 3], [6, 2,
     2, 2], [11, 2, 2]], "row_clues": [[5, 12], [5, 2,
     4, 9], [3, 1, 3, 8], [2, 1, 3, 3], [3, 1, 2, 4, 2
    ], [3, 2, 2, 2], [3, 5, 4, 1, 3, 1], [3, 1, 4, 1, 
    3, 1], [2, 3, 3, 2, 2, 1], [2, 2, 1, 2, 2, 1, 1], 
    [3, 1, 2, 1, 1], [3, 2, 1, 2], [4, 1, 1, 1, 1], [3
    , 2, 2], [1, 2, 2], [1, 1, 5], [1, 1, 3, 2, 1, 1],
     [2, 1, 3, 2, 1], [2, 2, 1], [1, 7, 2], [2, 10], [
    4, 4, 4], [12, 2], [2, 2], [2, 3]]}}

    잘 읽어 오는군요.

    이제 JSON 포맷으로 출력해 보겠습니다.

    JSON으로 출력하기

    JSON 포맷으로 출력하기 위해 encoding/json 패키지를 사용해 보겠습니다.

    json.Marshalstructmap을 넣어주면 된다고 합니다.

    json.MarshalIndent 도 있습니다. 들여쓰기를 추가해줍니다. 가독성을 위해 이 친구를 사용해 보겠습니다.

    jsonBytes, err := json.MarshalIndent(pz, "", "    ")
    if err != nil {
        fmt.Println(err)
        return
    }
    
    fmt.Println(string(jsonBytes))
    {
        "Id": 1,
        "Title": "사랑한다는 말",
        "Author": "soyosun",
        "NumRows": 25,
        "NumCols": 25,
        "Clues": "{\"col_clues\": [[12, 3], [14, 1, 2], [3, 4
    , 4, 1, 2], [2, 3, 2], [2, 2], [1, 1, 2], [1, 1, 2, 1], [
    2, 1, 1, 1, 1, 1], [2, 1, 2, 3], [3, 2], [6, 2, 1], [4, 4
    , 2, 1, 1], [4, 3, 2, 1, 1, 1, 4], [2, 2, 1, 1, 5, 6], [1
    , 4, 2, 2, 4], [1, 1, 1, 5], [2, 5, 3], [3, 2, 1, 1, 2], 
    [3, 2, 1, 1, 2, 2], [3, 1, 1, 1, 2, 1, 2], [3, 1, 3, 2, 1
    , 1], [3, 1, 2, 2, 2], [4, 1, 1, 3], [6, 2, 2, 2], [11, 2
    , 2]], \"row_clues\": [[5, 12], [5, 2, 4, 9], [3, 1, 3, 8
    ], [2, 1, 3, 3], [3, 1, 2, 4, 2], [3, 2, 2, 2], [3, 5, 4,
     1, 3, 1], [3, 1, 4, 1, 3, 1], [2, 3, 3, 2, 2, 1], [2, 2,
     1, 2, 2, 1, 1], [3, 1, 2, 1, 1], [3, 2, 1, 2], [4, 1, 1,
     1, 1], [3, 2, 2], [1, 2, 2], [1, 1, 5], [1, 1, 3, 2, 1, 
    1], [2, 1, 3, 2, 1], [2, 2, 1], [1, 7, 2], [2, 10], [4, 4
    , 4], [12, 2], [2, 2], [2, 3]]}"
    }

    출력 결과를 보니 두 가지 문제점이 보입니다.

    첫째로, Clues를 string으로 지정했더니 ""로 감싸서 출력을 해버립니다.

    json 패키지 타입 중에 RawMessage 타입이 있다고 하니 한 번 써보겠습니다.

    type Puzzle struct {
        Id      int
        Title   string
        Author  string
        NumRows int
        NumCols int
        Clues   json.RawMessage
    }
    {
        "Id": 1,
        "Title": "사랑한다는 말",
        "Author": "soyosun",
        "NumRows": 25,
        "NumCols": 25,
        "Clues": {
            "col_clues": [
                [
                    12,
                    3
                ],
                [
                    14,
                    1,
                    2
                ],
                [
                    3,
                    4,
                    4,
                    1,
                    2
                ],
                ...
            ]
        }
    }

    숫자가 한 줄씩 출력되어 쓸데없이 길어졌습니다. 그래도 문제는 해결되었네요.
    지금은 테스트용으로 json.MarshalIndent를 사용했지만, 실제로는 이렇게 출력하지 않을 테니 문제는 없습니다.

    두 번째 문제는 키값의 이름이 struct의 element 이름으로 들어가는 문제입니다.

    struct tag 기능을 이용하면 키 이름을 정해줄 수 있습니다.

    type Puzzle struct {
        Id      int             `json:"id"`
        Title   string          `json:"title"`
        Author  string          `json:"author"`
        NumRows int             `json:"num_rows"`
        NumCols int             `json:"num_cols"`
        Clues   json.RawMessage `json:"clues"`
    }

    이제 key 이름도 제대로 들어갑니다.

    {"id":1,"title":"사랑한다는 말","author":"soyosun","num_rows":25,"num_cols":25,
     "clues":{"col_clues":[[12,3],[14,1,2],[3,4,4,1,2],[2,3,2],[2,2]
    ,[1,1,2],[1,1,2,1],[2,1,1,1,1,1],[2,1,2,3],[3,2],[6,2,1],[4,4,2,1,1],[4
    ,3,2,1,1,1,4],[2,2,1,1,5,6],[1,4,2,2,4],[1,1,1,5],[2,5,3],[3,2,1,1,2],[
    3,2,1,1,2,2],[3,1,1,1,2,1,2],[3,1,3,2,1,1],[3,1,2,2,2],[4,1,1,3],[6,2,2
    ,2],[11,2,2]],"row_clues":[[5,12],[5,2,4,9],[3,1,3,8],[2,1,3,3],[3,1,2,
    4,2],[3,2,2,2],[3,5,4,1,3,1],[3,1,4,1,3,1],[2,3,3,2,2,1],[2,2,1,2,2,1,1
    ],[3,1,2,1,1],[3,2,1,2],[4,1,1,1,1],[3,2,2],[1,2,2],[1,1,5],[1,1,3,2,1,
    1],[2,1,3,2,1],[2,2,1],[1,7,2],[2,10],[4,4,4],[12,2],[2,2],[2,3]]}}

    PostgreSQL 에서 랜덤한 row 고르기

    이제 DB에서 무작위로 퍼즐을 하나 선택하는 fetchRandomPuzzle 함수를 구현하겠습니다.

    그런데 어떻게 데이터베이스에서 랜덤하게 row를 선택할 수 있을까요?

    검색 시 가장 먼저 나오는 방법은 테이블을 랜덤 정렬하고 제일 위 row를 선택하는 방식입니다.

    SELECT 
        p.id, p.num_rows, p.num_cols, p.clues,
        COALESCE(pt.title, 'Untitled') AS title,
        COALESCE(a.name, 'anonymous') AS author
    FROM puzzles p 
    LEFT JOIN 
        puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
    LEFT JOIN
        puzzle_metadata pm ON p.id = pm.puzzle_id
    LEFT JOIN
        authors a ON pm.author_id = a.id
    ORDER BY RANDOM()
    LIMIT 1;

    뭔가 요상했습니다.
    알아서 딱 랜덤한 위치 row를 쓱 꺼내오면 좋을 텐데요. 난수를 부여하고, 정렬까지 한 다음에, 골라서 선택해야 한다니요.

    찾아보니 위 방식은 데이터 수가 많아질수록 많이 느려진다고 합니다.
    그래서 여러 구현 방법이 사용되고 있었습니다.

    다른 방법을 선택해야 하나 고민하기 전에, 일단 지금 방식이 얼마나 느린 건지 판단을 해보고 싶어졌습니다.

    EXPLAIN ANALYZE를 해보면 쿼리 실행시간 분석이 가능하다고 합니다.
    psql을 켜고 해보았습니다.

    어려운 글자가 잔뜩 나왔습니다. 한번 천천히 몇 가지 읽어 보겠습니다.

    Limit  (cost=196.44..196.45 rows=1 width=878) (actual time=8.817..8.820 rows=1 loops=1)
       ->  Sort  (cost=196.44..198.74 rows=917 width=878) (actual time=8.816..8.819 rows=1 loops=1)
             Sort Key: (random())
             Sort Method: top-N heapsort  Memory: 27kB
             ->  Nested Loop Left Join  (cost=54.39..191.86 rows=917 width=878) (actual time=7.349..8.479 rows=907 loops=1)
                   ->  Hash Left Join  (cost=54.23..166.23 rows=917 width=831) (actual time=7.249..8.104 rows=907 loops=1)
                         Hash Cond: (p.id = pm.puzzle_id)
                         ->  Hash Left Join  (cost=29.23..138.81 rows=917 width=827) (actual time=6.944..7.640 rows=907 loops=1)
                               Hash Cond: (p.id = pt.puzzle_id)
                               ->  Seq Scan on puzzles p  (cost=0.00..107.17 rows=917 width=806) (actual time=0.006..0.510 rows=907 loops=1)
                               ->  Hash  (cost=18.11..18.11 rows=889 width=25) (actual time=6.908..6.909 rows=907 loops=1)
                                     Buckets: 1024  Batches: 1  Memory Usage: 61kB
                                     ->  Seq Scan on puzzle_titles pt  (cost=0.00..18.11 rows=889 width=25) (actual time=0.006..0.190 rows=907 loops=1)
                                           Filter: ((language)::text = 'ko'::text)
                         ->  Hash  (cost=13.89..13.89 rows=889 width=8) (actual time=0.277..0.277 rows=907 loops=1)
                               Buckets: 1024  Batches: 1  Memory Usage: 44kB
                               ->  Seq Scan on puzzle_metadata pm  (cost=0.00..13.89 rows=889 width=8) (actual time=0.010..0.129 rows=907 loops=1)
                   ->  Memoize  (cost=0.16..0.23 rows=1 width=122) (actual time=0.000..0.000 rows=1 loops=907)
                         Cache Key: pm.author_id
                         Cache Mode: logical
                         Hits: 906  Misses: 1  Evictions: 0  Overflows: 0  Memory Usage: 1kB
                         ->  Index Scan using authors_pkey on authors a  (cost=0.15..0.22 rows=1 width=122) (actual time=0.068..0.068 rows=1 loops=1)
                               Index Cond: (id = pm.author_id)
     Planning Time: 0.956 ms
     Execution Time: 8.898 ms
    (25 rows)

    seq scan은 테이블을 순서대로 순회하며 읽는 방식이라고 합니다.
    반대로 index scan은 인덱스를 이용한 방식이고요.

    authors 테이블은 Memoize되어 금방 처리하는데, 다른 테이블은 seq scan을 통해 조인 하고 있습니다.

    제가 생각한 것과 어딘가 다르게 작동하고 있습니다.
    랜덤 난수를 부여하고 정렬하는 작업은 그렇다 쳐도, 조인은 인덱스를 이용해서 빠르게 될 줄 알았는데요.

    퍼즐 테이블에서 무작위로 퍼즐을 하나 고르고, 고른 다음 조인을 하도록 쿼리를 수정해 보겠습니다.

    WITH selected_puzzle AS (
        SELECT id, num_rows, num_cols, clues
        FROM puzzles 
        ORDER BY RANDOM()
        LIMIT 1
    )
    
    SELECT 
        p.id, p.num_rows, p.num_cols, p.clues,
        COALESCE(pt.title, 'Untitled') AS title,
        COALESCE(a.name, 'anonymous') AS author
    FROM selected_puzzle p 
    LEFT JOIN 
        puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
    LEFT JOIN
        puzzle_metadata pm ON p.id = pm.puzzle_id
    LEFT JOIN
        authors a ON pm.author_id = a.id;

    EXPLAIN ANLALYZE 수행 결과

     Nested Loop Left Join  (cost=114.75..130.91 rows=1 width=108) (actual time=1.040..1.043 rows=1 loops=1)
       CTE selected_puzzle
         ->  Limit  (cost=114.05..114.05 rows=1 width=814) (actual time=0.950..0.951 rows=1 loops=1)
               ->  Sort  (cost=114.05..116.34 rows=917 width=814) (actual time=0.948..0.949 rows=1 loops=1)
                     Sort Key: (random())
                     Sort Method: top-N heapsort  Memory: 28kB
                     ->  Seq Scan on puzzles  (cost=0.00..109.46 rows=917 width=814) (actual time=0.022..0.408 rows=907 loops=1)
       ->  Nested Loop Left Join  (cost=0.43..8.54 rows=1 width=162) (actual time=1.013..1.015 rows=1 loops=1)
             ->  CTE Scan on selected_puzzle p  (cost=0.00..0.02 rows=1 width=44) (actual time=0.956..0.957 rows=1 loops=1)
             ->  Nested Loop Left Join  (cost=0.43..8.51 rows=1 width=122) (actual time=0.055..0.057 rows=1 loops=1)
                   ->  Index Scan using puzzle_metadata_pkey on puzzle_metadata pm  (cost=0.28..8.29 rows=1 width=8) (actual time=0.033..0.034 rows=1 loops=1)
                         Index Cond: (puzzle_id = p.id)
                   ->  Index Scan using authors_pkey on authors a  (cost=0.15..0.22 rows=1 width=122) (actual time=0.017..0.017 rows=1 loops=1)
                         Index Cond: (id = pm.author_id)
       ->  Index Scan using puzzle_titles_pkey on puzzle_titles pt  (cost=0.28..8.29 rows=1 width=25) (actual time=0.023..0.023 rows=1 loops=1)
             Index Cond: ((puzzle_id = p.id) AND ((language)::text = 'ko'::text))
     Planning Time: 0.541 ms
     Execution Time: 1.123 ms
    (18 rows)

    모두 Index scan으로 바뀌면서 8.898 ms 에서 1.123 ms 로 드라마틱하게 절감되었습니다.

    여러 번 해봐도 1 ms 전후 수행 시간을 보입니다.
    지금 단계에서 이 정도면 아주 괜찮지 않나 생각이 들었습니다.

    그래서 보통 적절한 쿼리 시간이 어느 정도인지 찾아봤는데요. 10 ms 아래면 매우 양호하다고 합니다.
    그래도 한 번 다른 방법이 얼마나 빨라지는지는 알아보고 싶었습니다.

    그래서 TABLESAMPLE BERNOULLI(percentage)를 써보기로 했습니다.
    원리를 간략히 이해한 만큼만 요약 해보면, 데이터 저장 페이지 구조를 바탕으로 데이터를 샘플링해서 데이터를 빠르게 가져올 수 있다고 합니다.
    대신 레코드 수가 아닌 퍼센티지를 기반으로 샘플링을 해야 합니다.

    데이터 수가 100개가 넘기 때문에 1%를 위 방법으로 뽑아내고, 그 안에서 무작위 퍼즐을 뽑아내겠습니다.

    WITH selected_puzzle AS (
        SELECT id, num_rows, num_cols, clues
        FROM puzzles TABLESAMPLE BERNOULLI(1)
        ORDER BY RANDOM()
        LIMIT 1
    )
    
    SELECT 
        p.id, p.num_rows, p.num_cols, p.clues,
        COALESCE(pt.title, 'Untitled') AS title,
        COALESCE(a.name, 'anonymous') AS author
    FROM selected_puzzle p 
    LEFT JOIN 
        puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
    LEFT JOIN
        puzzle_metadata pm ON p.id = pm.puzzle_id
    LEFT JOIN
        authors a ON pm.author_id = a.id;
    Nested Loop Left Join  (cost=98.86..115.02 rows=1 width=108) (actual time=0.231..0.235 rows=1 loops=1)
       CTE selected_puzzle
         ->  Limit  (cost=98.16..98.16 rows=1 width=814) (actual time=0.172..0.173 rows=1 loops=1)
               ->  Sort  (cost=98.16..98.18 rows=9 width=814) (actual time=0.171..0.171 rows=1 loops=1)
                     Sort Key: (random())
                     Sort Method: top-N heapsort  Memory: 25kB
                     ->  Sample Scan on puzzles  (cost=0.00..98.11 rows=9 width=814) (actual time=0.049..0.147 rows=9 loops=1)
                           Sampling: bernoulli ('1'::real)
       ->  Nested Loop Left Join  (cost=0.43..8.54 rows=1 width=162) (actual time=0.216..0.218 rows=1 loops=1)
             ->  CTE Scan on selected_puzzle p  (cost=0.00..0.02 rows=1 width=44) (actual time=0.178..0.179 rows=1 loops=1)
             ->  Nested Loop Left Join  (cost=0.43..8.51 rows=1 width=122) (actual time=0.036..0.038 rows=1 loops=1)
                   ->  Index Scan using puzzle_metadata_pkey on puzzle_metadata pm  (cost=0.28..8.29 rows=1 width=8) (actual time=0.026..0.028 rows=1 loops=1)
                         Index Cond: (puzzle_id = p.id)
                   ->  Index Scan using authors_pkey on authors a  (cost=0.15..0.22 rows=1 width=122) (actual time=0.007..0.007 rows=1 loops=1)
                         Index Cond: (id = pm.author_id)
       ->  Index Scan using puzzle_titles_pkey on puzzle_titles pt  (cost=0.28..8.29 rows=1 width=25) (actual time=0.013..0.013 rows=1 loops=1)
             Index Cond: ((puzzle_id = p.id) AND ((language)::text = 'ko'::text))
     Planning Time: 0.540 ms
     Execution Time: 0.323 ms
    (19 rows)

    sample scan을 사용해서 실행 시간이 0.323 ms까지 단축되었습니다. 대만족입니다.

    그렇게 만들어진 함수

    func FetchRandomPuzzle(db *sql.DB) (*Puzzle, error) {
        row := db.QueryRow(`
        WITH selected_puzzle AS (
            SELECT id, num_rows, num_cols, clues
            FROM puzzles TABLESAMPLE BERNOULLI(1)
            ORDER BY RANDOM()
            LIMIT 1
        )
    
        SELECT 
            p.id, p.num_rows, p.num_cols, p.clues,
            COALESCE(pt.title, 'Untitled') AS title,
            COALESCE(a.name, 'anonymous') AS author
        FROM selected_puzzle p 
        LEFT JOIN 
            puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
        LEFT JOIN
            puzzle_metadata pm ON p.id = pm.puzzle_id
        LEFT JOIN
            authors a ON pm.author_id = a.id
        `)
    
        var pz Puzzle
        err := row.Scan(&pz.Id, &pz.NumRows, &pz.NumCols, &pz.Clues, &pz.Title, &pz.Author)
        if err != nil {
            return nil, err
        }
    
        return &pz, nil
    }

    이제 랜덤 추출이 가능해졌습니다.

    서버 코드 작성하기

    나머지는 지금까지 알아본 내용을 바탕으로 코드를 이어 붙이기만 하면 됩니다.

    코드 구조는 유튜브 영상 How To Structure Your Golang (API) Projects!?포케로그서버 Go 코드 를 참고하며 작성했습니다.

    main.go

    package main
    
    import (
        "flag"
        "fmt"
        "log"
    
        "github.com/divingbeetle/Nonogram-terminal-server/api"
        "github.com/divingbeetle/Nonogram-terminal-server/storage"
        _ "github.com/lib/pq"
    )
    
    func main() {
        listendAddr := flag.String("listen", ":8080", "server listen address")
        dbuser := flag.String("dbuser", "test_user", "database user")
        dbpass := flag.String("dbpass", "test_password17", "database password")
        dbhost := flag.String("dbhost", "localhost", "database host")
        dbport := flag.String("dbport", "5432", "database port")
        dbname := flag.String("dbname", "puzzle_db", "database name")
        flag.Parse()
    
        err := storage.InitDB(*dbuser, *dbpass, *dbhost, *dbport, *dbname)
        if err != nil {
            log.Fatal(err)
        }
    
        server := api.NewServer(*listendAddr)
    
        fmt.Println("Server is running on", *listendAddr)
        log.Fatal(server.Start())
    }

    flag 패키지는 command-line 인자 관리를 도와주는 패키지입니다.

    그냥 필요한 값을 전부 때려 박아버렸습니다.

    storage/db.go

    package storage
    
    import (
        "database/sql"
        "github.com/divingbeetle/Nonogram-terminal-server/types"
        _ "github.com/lib/pq"
    )
    
    var db *sql.DB
    
    func InitDB(username, password, host, port, dbname string) error {
        var err error
        db, err = sql.Open("postgres", "postgres://"+username+":"+password+"@"+host+":"+port+"/"+dbname+"?sslmode=disable")
        if err != nil {
            return err
        }
    
        db.SetMaxOpenConns(20)
        db.SetMaxIdleConns(20)
    
        return db.Ping()
    }
    
    func FetchRandomPuzzle() (*types.Puzzle, error) {
        row := db.QueryRow(`
        WITH selected_puzzle AS (
            SELECT id, num_rows, num_cols, clues
            FROM puzzles TABLESAMPLE BERNOULLI(1)
            ORDER BY RANDOM()
            LIMIT 1
        )
    
        SELECT 
            p.id, p.num_rows, p.num_cols, p.clues,
            COALESCE(pt.title, 'Untitled') AS title,
            COALESCE(a.name, 'anonymous') AS author
        FROM selected_puzzle p 
        LEFT JOIN 
            puzzle_titles pt ON p.id = pt.puzzle_id AND pt.language = 'ko' 
        LEFT JOIN
            puzzle_metadata pm ON p.id = pm.puzzle_id
        LEFT JOIN
            authors a ON pm.author_id = a.id
        `)
    
        var pz types.Puzzle
        err := row.Scan(&pz.Id, &pz.NumRows, &pz.NumCols, &pz.Clues, &pz.Title, &pz.Author)
        return &pz, err
    }

    참조한 유튜브 영상은 다양한 DB 스토리지 사용을 염두에 두고 설계를 했습니다. 그래서 Go의 인터페이스 기능을 활용해서 코드를 작성하더라고요.

    저는 아직 그럴 필요를 못 느껴서, DB는 postgres만 사용하는 걸 전제로 작성했습니다.

    데이터베이스 핸들러를 global variable로 두어 사용하였습니다. 포케로그 서버에서 참조한 방식이었습니다.

    이게 되게 안 좋은 practice는 아닌가 해서 찾아봤습니다.
    Organising Database Access in Go
    비슷한 상황에서 사용 할 수 있는 여러 패턴과 각 패턴의 장단점을 잘 서술하고 있습니다.

    요약하면, 이런저런 종속성이 많지 않은 코드라면 storage에서만 접근 할 수 있는, export 하지 않은 global variable으로 코드가 깔끔해질 수 있다고 합니다.

    api/server.go

    package api
    
    import (
        "encoding/json"
        "net/http"
    
        "github.com/divingbeetle/Nonogram-terminal-server/storage"
    )
    
    type Server struct {
        listenAddr string
    }
    
    func NewServer(listenAddr string) *Server {
        return &Server{listenAddr: listenAddr}
    }
    
    func (s *Server) Start() error {
        http.HandleFunc("/puzzles", s.handlePuzzleRequest)
        return http.ListenAndServe(s.listenAddr, nil)
    }
    
    func (s *Server) handlePuzzleRequest(w http.ResponseWriter, r *http.Request) {
        pz, err := storage.FetchRandomPuzzle()
        if err != nil {
            http.Error(w, "Failed to Fetch Puzzle", http.StatusInternalServerError)
            return
        }
        err = json.NewEncoder(w).Encode(pz)
        if err != nil {
            http.Error(w, "Failed to encode Puzzle", http.StatusInternalServerError)
            return
        }
    }
    

    Outro

    2편에서는

    • 고장 난 노트북에 퍼즐 서버 올려보기
    • AWS에 퍼즐 서버 올려보기
      둘 중 하나를 하지 않을까 싶습니다.

    코드 짜고 공부하는 것보다 글 쓰고, 봤던 자료 다시 찾거나 하는 데 걸린 시간이 더 많았습니다.
    정확히 같은 이유로, 이전에 두 번 정도 블로그를 시작했다가 접었던 기억이 있습니다. (CS50나 CS:APP 예제 풀이 등)

    앞으로는 꾸준하게 계속해서 블로그 글을 올려보는 사람이 되어 보려 합니다.
    실패한다면... 굉장히 낯부끄러운 블로그의 마지막 글의 마지막 멘트가 되겠지만요.

    댓글