let's go를 배워보자 4일차 - Database-Driven Responses

0

lets-go

목록 보기
4/15

Database-Driven Responses

우리의 application이 쓸모있기 위해서는 어딘가에 데이터를 저장해야한다. 그리고 해당 데이터는 유저에 의해 추가, 변경, 삭제 되고 이를 동적으로 runtime에 query할 능력도 필요하다.

우리는 RDB인 MYSQL을 사용하 예정이다. 어떻게 golang에서 mysql에 connection을 연결하고, 재사용 가능한 database logic을 만드며, SQL injection을 막을 수 있는 지 확인해보자.

1. Setting Up MySQL

가장 처음은 MYSQL을 설치하도록 하자.

  • mac
brew install mysql
  • linux
sudo apt install mysql-server

설치하면서 root user에 대한 password를 설정해야하는 데 반드시 기억해야한다.

mysql이 설치되면 root 유저로 terminal에 접속할 수 있다.

sudo mysql
[sudo] 암호:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 14
Server version: 5.7.40-0ubuntu0.18.04.1 (Ubuntu)

Copyright (c) 2000, 2022, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

만약 연결이 안된다면, 다음의 명령어로 설치할 때 입력했던 password를 입력해주는 방법이 있다.

mysql -u root -p
Enter password:
mysql>

연결이 완료되었다면, 우리의 project에 쓰일 모든 데이터를 저장하기 위해서 database를 만들도록 하자.

-- Create a new UTF-8 `snippetbox` database.
CREATE DATABASE snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Switch to using the `snippetbox` database.
USE snippetbox;

database는 만들었고, 이제 여기에 table(relation)을 만들도록 하자.

-- Create a `snippets` table.
CREATE TABLE snippets (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(100) NOT NULL,
    content TEXT NOT NULL,
    created DATETIME NOT NULL,
    expires DATETIME NOT NULL
);
-- Add an index on the created column.
CREATE INDEX idx_snippets_created ON snippets(created);

snippets 테이블의 각 record(row)는 id 필드를 가져, 테이블 내에서 서로 다른 record와 구분할 수 있다.(하나의 식별자 역할이다.) 짧은 text인 title도 가지고, 내용 자체는 contetn 필드안에 저장되어진다. 우리는 또한, 특정한 metadata인 createdexpires 시간을 가지도록 한다.

몇가지 데이터들을 snippets table에 넣어보자.

-- Add some dummy records (which we'll use in the next couple of chapters).
INSERT INTO snippets (title, content, created, expires) VALUES (
    'An old silent pond',
    'An old silent pond...\nA frog jumps into the pond,\nsplash! Silence again.\n\n– Matsuo Bashō',
    UTC_TIMESTAMP(),
    DATE_ADD(UTC_TIMESTAMP(), INTERVAL 365 DAY)
);
INSERT INTO snippets (title, content, created, expires) VALUES (
    'Over the wintry forest',
    'Over the wintry\nforest, winds howl in rage\nwith no leaves to blow.\n\n– Natsume Soseki',
    UTC_TIMESTAMP(),
    DATE_ADD(UTC_TIMESTAMP(), INTERVAL 365 DAY)
);
INSERT INTO snippets (title, content, created, expires) VALUES (
    'First autumn morning',
    'First autumn morning\nthe mirror I stare into\nshows my father''s face.\n\n– Murakami Kijo',
    UTC_TIMESTAMP(),
    DATE_ADD(UTC_TIMESTAMP(), INTERVAL 7 DAY)
);

이들은 dummy records로 사용된다.

2. Creating a New User

보안 관점에서 볼 때 root 유저로 MYSQL에 접근하는 것은 좋지 않다. 때문에 제한된 권한을 갖는 유저를 하나 만들어서 database에 접근할 수 있도록 하는 것이 좋다.

다음의 명령어로 유저를 하나 만들자

CREATE USER 'web'@'localhost';
GRANT SELECT, INSERT, UPDATE ON snippetbox.* TO 'web'@'localhost';
-- Important: Make sure to swap 'pass' with a password of your own choosing.
ALTER USER 'web'@'localhost' IDENTIFIED BY 'pass';

userweb@localhost를 하나 만들고, 권한으로 SELECT, INSERT, UPDATE를 주었다. 또한, 비밀번호를 pass로 설정하였다.

mysql -D snippetbox -u web -p
Enter password:
mysql>

로그인 하고, 쿼리 문을 작성하자.

mysql> select id, title, expires from snippets
    -> ;
+----+------------------------+---------------------+
| id | title                  | expires             |
+----+------------------------+---------------------+
|  1 | An old silent pond     | 2023-01-24 04:38:48 |
|  2 | Over the wintry forest | 2023-01-24 04:38:53 |
|  3 | First autumn morning   | 2022-01-31 04:39:02 |
|  4 | O snail                | 2022-01-31 09:08:23 |
+----+------------------------+---------------------+
4 rows in set (0.00 sec)

select문이 문제없이 실행된 것을 확인할 수 있다.

mysql> drop table snippets;
ERROR 1142 (42000): DROP command denied to user 'web'@'localhost' for table 'snippets'

그러나 drop문은 실행되지 않는다. 이는 권한이 없기 때문이다.

3. Installing a Database Driver

go web application으로부터 MYSQL을 사용하기 위해서는 database driver를 설치해야한다. 이는 기본적으로 middleman과 같이 동작하며, go과 mysql 사이의 commands를 변환해준다. 우리는 go-sql-driver/mysql driver를 사용할 것이다.

go get으로 설치해보도록 하자.

go get github.com/go-sql-driver/mysql@v1

잘 설치되었다면 go.mod에서 require 라인에 새로운 패키지 path가 추가된 것을 확인할 수 있을 것이다.

  • go.mod
require github.com/go-sql-driver/mysql v1.7.0 // indirect

또한, 새로운 파일인 go.sum 파일이 생성되었음을 확인할 수 있을 것이다.

go.sum 파일은 암호화된 checksums를 포함하는데 이는 필요한 패키지들의 내용을 나타낸다. go.mod와 달리 go.sum은 인간이 읽기 어렵게 되어있으며, 개발자가 이를 의도적으로 변경할 일도 없다.

  • go mod verify를 실행하면 go.sum에 있는 entries들과 내 컴퓨터에 설치된 패키지의 체크섬을 검증하기 시작한다. 따라서 이를 통해 내 패키지가 수정되지 않았음을 확인할 수 있다.

  • 만약, 누군가 프로젝트의 모든 의존성들을 설치해야한다고 한다면 이는 go mod download를 통해서 다운받을 수 있다. 만약 다운받은 의존성과 파일의 checksum이 다르다면 이는 에러를 발생시킬 것이다.

4. Creating a Database Connection Pool

이제 mysql도 준비되었고, driver도 준비되었으니 web application과 연결시켜보자.

연결하기 위해서는 sql.Open 함수가 필요하다. 다음과 같다.

// The sql.Open() function initializes a new sql.DB object, which is essentially a
// pool of database connections.
db, err := sql.Open("mysql", "web:pass@/snippetbox?parseTime=true")
if err != nil {
...
}
  1. sql.Open의 파라미터의 첫번째는 driver name이고 두 번째는 data source name(DSN)이다. DSN은 어떻게 나의 database에 연결할 지를 서술한다.
  2. DSN은 어떤 database와 driver를 쓸 지에 따라서 다르다. 이에 대한 정보는 특정 driver에 대한 정보를 확인하면 된다.
  3. DSN의 parseTime=truedriver-specific한 파라미터로 우리의 driver가 SQL TIMEDATE을 go의 time.Time 객체들로 변경할 때 사용한다.
  4. sql.Open() 함수는 sql.DB 객체를 반환한다. 이는 database connection이 아니라 여러 개의 connection들이 있는 pool이다. 이는 이해에 있어 중요한 차이점을 가지는데, go는 이러한 connection들을 필요한 만큼 관리한다. driver를 통해 database에 대한 connection들을 자동으로 열고, 닫고하는 것이다.
  5. connection pool은 concurrent한 접근에 대해서 안전하다. 그래서 web application에서 handler들에 대해서 안전하게 사용할 수 있다.
  6. connection pool을 main에 호출하여 long-lived하게 만든다. 이는 각 handler마다 짧게 sql.Open으로 connection pool을 여는 것과 다르다. 메모리의 낭비와 네트워크 자원들을 줄이기 위해서 long-lived하게 사용한다는 것이다.

5. Usage in our Web Application

이제 우리의 코드에 sql.Open()을 사용해보도록 하자.

  • main.go
package main

import (
	"database/sql"
	"flag" // New import
	"log"
	"net/http"
	"os"

	_ "github.com/go-sql-driver/mysql"
)

type application struct {
	errorLog *log.Logger
	infoLog  *log.Logger
}

func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	app := &application{
		errorLog: errorLog,
		infoLog:  infoLog,
	}

	mux := app.routes()
	srv := &http.Server{
		Addr:     *addr,
		ErrorLog: errorLog,
		Handler:  mux,
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServe()
	errorLog.Fatal(err)
}

func openDB(dsn string) (*sql.DB, error) {
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}
	if err = db.Ping(); err != nil {
		return nil, err
	}
	return db, nil
}

몇 가지 부분을 살펴보자.

  1. import path 부분에 재밌는 것이 있다. _ "github.com/go-sql-driver/mysql"를 보면 _로 패키지를 받고 있다. 이는 해당 패키지를 우리의 프로젝트에서 사용하지 않을 것이라는 말이다. 그러나 우리는 해당 driver의 init() 함수 부분이 필요하다. init()함수는 패키지를 import하면 자동으로 실행되는 함수인데, 이 부분에서 database/sql 패키지와 우리의 driver 그 자체가 자신을 등록하기 때문이다. database/sql은 공식적으로 제공되는 패키지인데 여기서 특정 database에 대한 driver를 등록하면 해당 database에 대해 등록한 driver로 접근할 수 있게 된다.
  • github.com/go-sql-driver/mysql의 driver.go의 init()함수 부분
func init() {
	sql.Register("mysql", &MySQLDriver{})
}

mysql database에 driver를 등록하는 것이다. 이를 위해 driver를 import를 해주지만 우리의 프로젝트에서 직접적으로는 사용하지는 않겠다고 _ 표시해주는 것이다. 그래서 해당 패키지의 import 부분을 _로 채우는 것이다. 이는 go sql driver의 일반적인 패턴이다.

  1. sql.Open() 함수는 실질적으로 어떠한 connection들을 생성하지 않는다. 미래에 사용할 connection pool만을 초기화한다. 실제 database에 대한 connection들은 나중에 처음으로 필요할 때 만들어진다. 때문에 우리는 단순히 sql.Open()만으로 connection이 완료되었다고 보기힘들다. 그래서 db.Ping()을 통해서 connection이 문제없이 만들어졌는 지 확인하는 것이다.

  2. defer db.Close()는 의미가 없어보일 지도 모른다. 왜냐하면 errorLog.Fatal()이나 crtl+c와 같이 프로그램이 즉시 종료되는 경우는 defer에 있는 함수가 절대 실행되지 않기 때문이다. 그러나 db.Close()는 매우 좋은 버릇이며 나중에 graceful shutdown을 할 때 큰 도움이 된다.

6. Testing a Connection

서버를 구동시켜보자, 예상되는 결과는 connection pool이 만들어지고 db.Ping()을 통해서 에러 없이 database에 대한 connection을 만들 것이다.

go run ./...
INFO    2022/12/12 16:06:37 Starting server on :4000

이렇게 실행되면 성공한 것이고, 만약 "Access denied..."가 나오면 실패한 것이다.

go run ./cmd/web
ERROR 2018/09/07 13:55:51 main.go:44: Error 1045: Access denied for user 'web'@'localhost' (using password: YES)
exit status 1

다음의 에러가 나온다면 연결에 실패한 경우이다. 이때에는 DSN에 username, password가 틀렸는 지, 권한이 잘못 부여되었는 지, MySQL 인스턴스가 표준 설정인지를 확인해야한다.

7. Designing a Database Model

model이든 DAO이든 DTO든 중요한 것은 우리의 database와 작동하는 code를 캡슐화하여 우리의 application의 다른 부분과 분리된다는 것이다.

지금은 간단한 database model을 만들고 dummy data를 반환하게 만들자. pkg 폴더를 만들고 여기에 modelsmodels/mysql 폴더들을 만들자.

mkdir -p pkg/models/mysql
touch pkg/models/models.go
touch pkg/models/mysql/snippets.go

디렉터리 구조를 확인하면 다음과 같다.

tree pkg/
pkg/
└── models
    ├── models.go
    └── mysql
        └── snippets.go

2 directories, 2 files

pkg directory는 부차적이고 특정 application과 관련없는 코드를 다루는 디렉토리이다. 이는 잠재적으로 재사용이 가능하며, 미래의 다른 application들에 의해 사용될 수 있는 database 모델이 조건에 맞도록 한다.

우리의 database 모델이 사용하고 반환할 상위 레벨 data-type을 정의하기위해 pkg/models/models.go 파일을 사용함으로서 시작해보자. pkg/models/models.go에 다음의 코드를 넣자.

  • pkg/models/models.go
package models

import (
	"errors"
	"time"
)

var ErrNoRecord = errors.New("models: no matching record found")

type Snippet struct {
	ID      int
	Tiitle  string
	Content string
	Created time.Time
	Expires time.Time
}

Snippet 구조체는 database에 있는 데이터의 구조와 닮았다. 이제 SnippetModel 타입을 만들도록 하고, Snippet 데이터가 있는 database에 접근하고 조작하는 코드를 만들어보자.

  • pkg/models/mysql/snippets.go
package mysql

import (
	"database/sql"

	"github.com/gyu-young-park/snippetbox/pkg/models"
)

type SnippetModel struct {
	DB *sql.DB
}

func (m *SnippetModel) Insert(title, content, expires string) (int, error) {
	return 0, nil
}

func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
	return nil, nil
}

func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
	return nil, nil
}

SnippetModel는 하나의 DAO 역할을 하고, models.Snippet는 DTO 역할을 하는 것이다. 따라서 models.Snippet는 database 데이터의 형식을 닮았고, SnippetModel은 이들에 접근할 수 있는 특정 메서드들을 정립하는 것이다.

8. Using the SnippetModel

이 모델을 우리의 handler들에서 사용하기 위해서는 우리는 새로운 SnippetModel 구조체를 main()에 만들어야 한다. 그리고 이를 application 구조체를 통해 의존성을 전달해야한다.


// Add a snippets field to the application struct. This will allow us to
// make the SnippetModel object available to our handlers.
type application struct {
	errorLog *log.Logger
	infoLog  *log.Logger
	snippets *mysql.SnippetModel
}

func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()
	// Initialize a mysql.SnippetModel instance and add it to the application
	// dependencies.
	app := &application{
		errorLog: errorLog,
		infoLog:  infoLog,
		snippets: &mysql.SnippetModel{DB: db},
	}

	mux := app.routes()
	srv := &http.Server{
		Addr:     *addr,
		ErrorLog: errorLog,
		Handler:  mux,
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServe()
	errorLog.Fatal(err)
}

func openDB(dsn string) (*sql.DB, error) {
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}
	if err = db.Ping(); err != nil {
		return nil, err
	}
	return db, nil
}

applicationSnippetModel 구조체를 만들고 db를 적재해주었다. 이제 우리의 handler들은 application을 통해서 SnippetModel에 접근할 수 있는 것이다.

이렇게 DTO, DAO를 나누어 코딩하는 것이 무엇이 좋은가?? 다음의 장점을 얻을 수 있다.

  1. concerns(관심사)의 깔끔한 분리가 있다. 우리의 database 로직은 우리의 핸들러들에 묶여 있지 않다. 핸들러는 HTTP stuff에 책임이 제한되어 있다. http stuff라 하면 validating request, writing responses 같은 게 있다. 이는 훨씬 더 코드를 보기 좋게만들고 집중하게 만들면 미래에 유닛 테스트하기 좋다.
  2. SnippetModel 구조체를 만듬으로서, 그리고 이에 대한 method들을 구현함으로서 우리의 model을 단일화 할 수 있고, 정갈하게 object를 캡슐화할 수 있었다. SnippetModel 구조체는 우리가 쉽게 초기화할 수 있며, 우리의 handler에 dependency로서 전달할 수 있다. 이는 유지 보수에 좋으며 테스트하기 좋은 코드를 만든다.
  3. model의 action들이 object(SnippetModel)의 method로 정의되어 있기 때문에, interface를 만들고 단위 테스트를 위해 mock을 사용할 수 있게 되었다.
  4. 단지 command-line flag를 사용함으로서, 우리는 어떤 database가 runtime에 쓰였냐에 따른 전체적인 제어를 할 수 있다.
  5. 마지막으로 만약 프로젝트가 여러 개의 back ends를 가지면 디렉터리 구조가 멋지게 확장할 수 있다. 가령 만약 개발자의 일부 데이터가 Redis에 있다면 modelspkg/models/redis package를 만들어주면된다.

9. Executing SQL Statements

SnippetModel.Insert() 메서드를 업데이트해보자, 이 메서드는 snippets 테이블에 새로운 recod를 만들어주며 새로운 record에 대한 id 값을 반환해준다.

실행하기 이전에 실행하려는 SQL query문을 살펴보자.

INSERT INTO snippets (title, content, created, expires)
VALUES(?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))

? 문자열은 placeholder parameters를 나타낸다. 이는 database에 삽입하고 싶은 데이터에 대한 정보를 나타낸다. 우리가 ?에 사용하려는 데이터는 user의 input으로 들어오기 때문에 신뢰할 수가 없다. 때문에 placeholder parameters을 사용하는 것이 sql query문안에 데이터를 그대로 삽입하는 것보다 훨씬 더 안전하고 좋은 방법이다.

go는 database query들을 실행하기 위한 3가지 다른 방식을 제공한다.

  1. DB.Query(): SELECT 쿼리들에 사용되며 여러 개의 row들을 반환한다.
  2. DB.QueryRow(): SELECT 쿼리들에 사용되며 하나의 row만들 반환한다.
  3. DB.Exec(): row들을 반환하지 않는 statement에 사용된다. 가령 INSERT, DELETE가 있다.

따라서, 우리의 경우에는 가장 좋은 방법은 DB.Exec()이다. 이를 사용하여 SnippetModel.Insert() 메서드에 어떻게 사용하는 지 보자. 이에 대해서는 추후에 자세히 이야기하도록 하자.

pkg/models/mysql/snippets.go 파일을 다음과 같이 업데이트 하자.

  • pkg/models/mysql/snippets.go
func (m *SnippetModel) Insert(title, content, expires string) (int, error) {
	// Write the SQL statement we want to execute. I've split it over two lines
	// for readability (which is why it's surrounded with backquotes instead
	// of normal double quotes).
	stmt := `INSERT INTO snippets (title, content, created, expires)
			VALUES(?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))`
	// Use the Exec() method on the embedded connection pool to execute the
	// statement. The first parameter is the SQL statement, followed by the
	// title, content and expiry values for the placeholder parameters. This
	// method returns a sql.Result object, which contains some basic
	// information about what happened when the statement was executed.
	result, err := m.DB.Exec(stmt, title, content, expires)
	if err != nil {
		return 0, err
	}
	// Use the LastInsertId() method on the result object to get the ID of our
	// newly inserted record in the snippets table.
	id, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}
	// The ID returned has the type int64, so we convert it to an int type
	// before returning.
	return int(id), nil
}

DB.Exec()함수에 의해 반환된 sql.Result인터페이스에 대해 알아보자. 이는 두 가지 메서드들을 반환한다.

  1. LastInsertId(): 명령(sql query)에 의한 응답으로 database에서 만들어진 int64값을 반환한다. 전형적으로 이는 새로운 row를 추가했을 때 "auto increment" 컬럼으로부터 오는데, 우리의 경우 id가 만들어질 것이다.

  2. RowsAffected(): statement에 의해 영향을 받은 row들의 숫자를 반환한다.(int64)

주의할 것은 모든 driver들과 database들이 LastInsertId()RowsAffected() 메서드를 지원하지 않는다는 것이다. 가령 LastInsertId()는 PostgreSQL에서는 지원하지 않는다. 따라서 해당 메서드를 사용하려면 driver를 먼저 잘찾아보는 것이 중요하다.

10. Using the Model in Our Handlers

이제 해당 code를 어떻게 handler에서 부를 지 확인해보자. cmd/web/handlers.go 파일을 열어서 createSnippet 핸들러를 다음과 같이 변경하자.

  • cmd/web/handlers.go
func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		app.clientError(w, http.StatusMethodNotAllowed)
		return
	}

	title := "gyu"
	content := "O snail\nClimb Mount Fuji,\nBut slowly, slowly!\n\n– Kobayashi Issa"
	expires := "7"
	id, err := app.snippets.Insert(title, content, expires)
	if err != nil {
		app.serverError(w, err)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/snippet?id=%d", id), http.StatusSeeOther)
	// w.Write([]byte("Create a new snippet..."))
}

server를 시작하고 curl로 POST 메시지를 전송해보자

curl -iL -X POST http://localhost:4000/snippet/create

HTTP/1.1 303 See Other
Location: /snippet?id=5
Date: Mon, 12 Dec 2022 09:21:36 GMT
Content-Length: 0

HTTP/1.1 200 OK
Date: Mon, 12 Dec 2022 09:21:36 GMT
Content-Length: 39
Content-Type: text/plain; charset=utf-8

Display a specific snippet with ID 5...

잘 응답이 왔다. 이제 과연 SnippetModel.Insert 메서드가 잘 호출되었는 지를 확인해보도록 하자. ID가 5가 왔으니 ID 5는 무엇인지 확인해보자.

mysql> SELECT id, title, expires FROM snippets;
+----+------------------------+---------------------+
| id | title                  | expires             |
+----+------------------------+---------------------+
|  1 | An old silent pond     | 2023-01-24 04:38:48 |
|  2 | Over the wintry forest | 2023-01-24 04:38:53 |
|  3 | First autumn morning   | 2022-01-31 04:39:02 |
|  4 | O snail                | 2022-01-31 09:08:23 |
|  5 | gyu                    | 2022-12-19 09:21:36 |
+----+------------------------+---------------------+
5 rows in set (0.01 sec)

5번째에 gyu가 온 것을 확인할 수 있다.

우리는 Placeholder Parameters?을 사용하였다. 이는 우리가 statement에 넣고 싶은 데이터를 나타낸다. Placeholder Parameters를 사용하는 이유는 우리의 query(string을 직접 넣기 보다는)가 SQL injection으로부터 보호되기 위함이다.

DB.Exec() 메서드의 이면에는 아래의 3가지 단계들이 작동한다.

  1. 제공된 SQL statement를 사용하여 데이터 베이스에 대한 prepared statement를 만든다. database는 해당 statement를 파싱하고 컴파일한 다음, 이를 저장하고 실행할 준비를 한다.
  2. 두 번째 단계로, Exec()이 database에 파라미터 값들을 넘겨준다. database는 그 다음 prepared statement를 실행하고 해당 파라미터들을 사용한다. 왜냐면 파라미터들이 나중에 전달되기 때문에 statement들이 컴파일된 후, datebase는 이 파라미터들을 pure data로 다룬다. 이 데이터들은 statement의 intent(의도)를 바꾸지 못한다. 원래의 statement가 신뢰성이 없는 데이터로 부터 파생(derived)되지않는한, injection은 발생하지 않는다. 즉, 정리하면 컴파일된 statement가 있고 이에 대한 파라미터가 주어져도 이 파라미터는 컴파일된 statement의 의도,구조를 변경하지 못한다는 것이다.
  3. 이 후에 데이터 베이스에 대한 prepared statement는 종료된다.

placeholder parameter는 database마다 다를 수 있다. MySQL, SQLite의 경우는 ?이지만 PostgreSQL$1,$2, $3 ... $N이다.

11. Single-record SQL Queries

database로부터 단일 record를 selecting하는 패턴은 다소 복잡하다. SnippetModel.Get()를 수정함으로서 어떻게 특정한 ID를 가진 record를 반환하는 지 알아보자.

이번에 구동할 statement는 다음과 같다.

SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() AND id = ?

왜냐면 우리의 snippets table이 id column을 primary key로 사용하고 있기 때문에 이 query는 오직 정확히 하나의 database row 또는 0개를 반환하다. 또한, 해당 query는 expires시간을 비교하기 때문에 시간이 만료된 것은 나타나지 않는다.

pkg/models/mysql/snippets.go 파일을 열어보고, 다음의 코드를 넣어보자.

func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
	// Write the SQL statement we want to execute. Again, I've split it over two
	// lines for readability.
	stmt := `SELECT id, title, content, created, expires FROM snippets
			WHERE expires > UTC_TIMESTAMP() AND id = ?`
	// Use the QueryRow() method on the connection pool to execute our
	// SQL statement, passing in the untrusted id variable as the value for the
	// placeholder parameter. This returns a pointer to a sql.Row object which
	// holds the result from the database.
	row := m.DB.QueryRow(stmt, id)

	// Initialize a pointer to a new zeroed Snippet struct.
	s := &models.Snippet{}

	// Use row.Scan() to copy the values from each field in sql.Row to the
	// corresponding field in the Snippet struct. Notice that the arguments
	// to row.Scan are *pointers* to the place you want to copy the data into,
	// and the number of arguments must be exactly the same as the number of
	// columns returned by your statement.
	err := row.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
	if err != nil {
		// If the query returns no rows, then row.Scan() will return a
		// sql.ErrNoRows error. We use the errors.Is() function check for that
		// error specifically, and return our own models.ErrNoRecord error
		// instead.
		if errors.Is(err, sql.ErrNoRows) {
			return nil, models.ErrNoRecord
		} else {
			return nil, err
		}
	}
	// If everything went OK then return the Snippet object.
	return s, nil
}

row := m.DB.QueryRow(stmt, id)로 statement에 대한 응답인 단일 row를 받으면 이에 대한 데이터는 row.Scan으로 할당할 수 있다.

sql.ErrNoRows를 바로 반환하지 않고 modes.ErrNoRecord를 반환했는 지에 대해서 알아보면, 이는 model을 완벽하게 캡슐화하는데 도움을 주기때문에, 우리의 application이 기저의 datastore과 관련이 없고 datastore-specific error에 의존하지 않는다.

12. Checking

row.Scan()의 이면에는 내가 설정한 driver가 자동적으로 database롷부터 얻은 raw output를 native go type으로 변경해준다. SQL과 go의 type 맵핑은 다음과 같고, 이들은 일반적으로 별다른 설정없이 잘 작동한다.

  1. CHAR,VARCHAR, TEXTstring으로
  2. BOOLEANbool으로
  3. INTintBIGINTint64
  4. DECIMALNUMERICfloat
  5. TIME, DATETIMESTAMPtime.Time으로
    우리가 사용하는 MySQL드라이버의 별난 점은 parseTime=true 파라미터를 우리의 DSN에서 사용해야 강제로 TIMEDATE 필드를 time.Time으로 변경할 수 있다는 것이다. 그렇지 않으면 이는 []bytes object를 반환한다.

이제 SnippetModel.Get()를 handler에 넣어보자.

cmd/web/handlers.go 파일을 열고, showSnippet 핸들러를 업데이트하자. 그리고 특정 record에 대한 데이터를 HTTP 응답으로 반환해주도록 하자.

  • cmd/web/handlers.go
func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get("id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}
	// Use the SnippetModel object's Get method to retrieve the data for a
	// specific record based on its ID. If no matching record is found,
	// return a 404 Not Found response.
	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}
	fmt.Fprintf(w, "%v", s)
}

서버를 구동하고 snippet?id=1로 요청을 보내보자.

curl http://10.251.72.203:4000/snippet?id=5
&{5 gyu O snail
Climb Mount Fuji,
But slowly, slowly!

– Kobayashi Issa 2022-12-12 09:21:36 +0000 UTC 2022-12-19 09:21:36 +0000 UTC}

제대로 DB에서 응답을 가져온 것을 확인할 수 있다.

id값을 달리하여 expired된 데이터나, 없는 데이터를 요청해보자. Not Found에러가 발생할 것이다.

13. Sentinel Errors and errors.Is()

errors.Is()함수는 에러가 어떤 특정한 에러인지를 식별해주는 기능을 한다.

sql.ErrNoRow는 우리가 대강 정의할 수 있는 전역 변수에 저장된 error object인 sentinel error라고 불리는 에러의 예제이다. 전형적으로 errors.New()함수를 통해서 sentinel error를 만들어낸다. standard library에서의 몇가지 예제는 io.ErrUnexpectedEOFbytes.ErrTooLarge와 우리가 만든 sentinel errormodels.ErrNoRecord에러이다.

다음과 같이 sentinel error를 체크할 수 있지만

if err == sql.ErrNoRows {
// Do something
} else {
// Do something else
}

이것보다는 errors.Is() 함수를 사용하는 것이 좋다.

if errors.Is(err, sql.ErrNoRows) {
// Do something
} else {
// Do something else
}

이는 go.1.13에서 소개된 wrap errors에 대한 기능 때문인데, 이는 기존 error에 추가적인 정보를 넣어준다. 그리고, 만약 sentinel error가 wrapped되면 첫번째 if == 문 비교는 작동되지 않는다. 왜냐면 wrapped error는 original sentinel error와 다르기 때문이다.

반면에 errors.Is() 함수는 error를 unwrapping함으로써 동작한다.

따라서, 기본적으로 go1.13 이후 또는 처음 사용하는 사람이라면 errors.Is() 함수를 사용하기를 바란다.

또 다른 함수가 있는데 errors.As()도 있다. 이는 error가 특정한 타입을 갖고 있는 지 확인할 수 있는데 이는 추후에 다시보도록 하자.

14. Multiple-record SQL Queries

이제 여러 개의 row들을 반환하는 SQL statement들을 실행해보도록 하자. sql query를 이용하여 최근에 생성된 10개의 snippet들을 반환하는 SnippetModel.Latest() 메서드를 수정함으서 여러 개의 sql row들을 받을 수 있는 지 확인해보자.

다음의 sql문을 golang에서 실행하도록 하자.

SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() ORDER BY created DESC LIMIT 10

/pkg/models/mysql/snippets.go 파일에 다음의 코드를 추가하자.

  • snippets.go
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
	// Write the SQL statement we want to execute.
	stmt := `SELECT id, title, content, created, expires FROM snippets
			WHERE expires > UTC_TIMESTAMP() ORDER BY created DESC LIMIT 10`

	// Use the Query() method on the connection pool to execute our
	// SQL statement. This returns a sql.Rows resultset containing the result of
	// our query.
	rows, err := m.DB.Query(stmt)
	if err != nil {
		return nil, err
	}
	// We defer rows.Close() to ensure the sql.Rows resultset is
	// always properly closed before the Latest() method returns. This defer
	// statement should come *after* you check for an error from the Query()
	// method. Otherwise, if Query() returns an error, you'll get a panic
	// trying to close a nil resultset.
	defer rows.Close()

	// Initialize an empty slice to hold the models.Snippets objects.
	snippets := []*models.Snippet{}
	// Use rows.Next to iterate through the rows in the resultset. This
	// prepares the first (and then each subsequent) row to be acted on by the
	// rows.Scan() method. If iteration over all the rows completes then the
	// rows.Scan() method. If iteration over all the rows completes then the
	// resultset automatically closes itself and frees-up the underlying
	// database connection.
	for rows.Next() {
		// Create a pointer to a new zeroed Snippet struct.
		s := &models.Snippet{}
		// Use rows.Scan() to copy the values from each field in the row to the
		// new Snippet object that we created. Again, the arguments to row.Scan()
		// must be pointers to the place you want to copy the data into, and the
		// number of arguments must be exactly the same as the number of
		// columns returned by your statement.
		err = rows.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
		if err != nil {
			return nil, err
		}
		// Append it to the slice of snippets.
		snippets = append(snippets, s)
	}
	// When the rows.Next() loop has finished we call rows.Err() to retrieve any
	// error that was encountered during the iteration. It's important to
	// call this - don't assume that a successful iteration was completed
	// over the whole resultset.
	if err = rows.Err(); err != nil {
		return nil, err
	}
	// If everything went OK then return the Snippets slice.
	return snippets, nil
}

중요한 것은 defer rows.Close() 부분을 반드시해주어야 한다는 것이다. 이 부분이 없다면 계속해서 database connection을 open한 상태로 있게 된다. 따라서, close 처리가 안되면 database pool에 있는 connection들이 너무 빨리 소진되는 문제가 있다.

여러 개의 rows들을 가져오는 sql문은 QueryRow가 아니라 Query로 실행되며, 가져온 rows들은 rows.Next로 각각의 row들을 순회할 수 있다.

이후에 rows.Err() 메서드를 호출할 수 있는데,

if err = rows.Err(); err != nil {
	return nil, err
}

이는 순회가 끝난 뒤 어떠한 row에서 에러가 있었는 지를 확인하는 부분이다. 만약 모두 성공적으로 데이터를 가져왔다면 nil이 반환된다.

15. Using the Model in Our Handlers

이제 핸들러에서 SnippetModel.Latest() 메서드를 사용하여 snippet content들을 http response로 보내주도록 하자.

  • cmd/web/handlers.go
func (app *application) home(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}

	s, err := app.snippets.Latest()
	if err != nil {
		app.serverError(w, err)
		return
	}

	for _, snippet := range s {
		fmt.Fprintf(w, "%v\n", snippet)
	}
	// files := []string{
	// 	"./ui/html/home.page.tmpl",
	// 	"./ui/html/base.layout.tmpl",
	// 	"./ui/html/footer.partial.tmpl",
	// }

	// ts, err := template.ParseFiles(files...)
	// if err != nil {
	// 	app.serverError(w, err)
	// 	return
	// }
	// err = ts.Execute(w, nil)
	// if err != nil {
	// 	app.serverError(w, err)
	// }
}

이렇게 하면 다음의 결과가 나올 것이다.

curl localhost:4000

&{2 Over the wintry forest Over the wintry
forest, winds howl in rage
with no leaves to blow.

– Natsume Soseki 2022-01-24 04:38:53 +0000 UTC 2023-01-24 04:38:53 +0000 UTC}
&{1 An old silent pond An old silent pond...
A frog jumps into the pond,
splash! Silence again.

– Matsuo Bashō 2022-01-24 04:38:48 +0000 UTC 2023-01-24 04:38:48 +0000 UTC}

가장 최근에 만들어진 데이터가 2개 밖에 없어서 이것만 나온 것이다.

16. Transactions and Other Details

database/sql package는 기본적으로 go application과 sql database 세상 사이의 표준화된 interface를 제공한다.

따라서, database/sql 패키지를 사용하는 한, 우리가 작성한 go code는 기본적으로 portable(database를 바꾸어도 go code에서는 변경점이 없다는 의미)하고 어떠한 종류의 sql database이든지 간에 상관없이 잘 동작한다. 이는 우리의 application이 현재 사용하고 있는 database에 tightly하게 묶여있지 않다는 것을 의미한다. 이는 또한, 우리가 어떠한 새로운 code를 쓰는 것이 아니여도, 손쉽게 미래에 database를 바꿀 수 있는 것을 의미한다.

database/sql이 전반적으로 잘 동작하여 sql database에 대해 standard interface를 잘 제공해주지만, 이러한 이면에는 서로 다른 driver들과 database가 동작하는 방식에 특이점들이 있다는 것은 중요하다. 따라서 언제나 새로운 driver에 대해서 이해하기 위해 docs를 읽고 사용하기 전에 어떠한 edge case가 있고 주의사항이 있는 지 아는 것은 굉장히 중요하다.

17. Managing NULL values

go에서는 database record에 있는 NULL값을 잘 처리하지 못한다.

만약 특정 row의 title column에 NULL 값이 있다고 가정하자, 이 때 rows.Scan()error를 반환한다. 왜냐면 NULLstring으로 변환할 수 없기 때문이다.

sql: Scan error on column index 1: unsupported Scan, storing driver.Value type
&lt;nil&gt; into type *string

대게 해결방법으로 stringsql.NullString 타입으로 변경한다. 그러나 가장 간단한 방법은 NULL 값을 피하도록 NOT NULL 제약사항을 넣거나, DEFAULT 값을 필요할 때마다 채워넣는 것이다.

18. Working with Transactions

Exec(), Query(), QueryRow()sql.DB pool에 있는 connection 아무거나 사용하여 실행된다. 심지어 Exec()을 두 번 연달아 실행해도 다른 connection에서 실행될 수 있다는 이야기다. 이는 같은 database connection 안에서 query문들이 실행되는 것이 보장되지 않는다는 것이다.

이는 때떄로 받아들이기 어려운데, 가령 MySQL의 LOCK TABLES 명령어는 호출된 다음 UNLOCK TABLES을 같은 connection 안에서 실행해야 하기 때문이다.

같은 connection에서의 실행을 보장하기 위해서 여러 statement(query문)을 transaction안에 넣는다. 가장 기본적인 패턴은 다음과 같다.

// We need somewhere to store the prepared statement for the lifetime of our
// web application. A neat way is to embed it alongside the connection pool.
type ExampleModel struct {
	DB         *sql.DB
	InsertStmt *sql.Stmt
}
// Create a constructor for the model, in which we set up the prepared
// statement.
func NewExampleModel(db *sql.DB) (*ExampleModel, error) {
	// Use the Prepare method to create a new prepared statement for the
	// current connection pool. This returns a sql.Stmt object which represents
	// the prepared statement.
	insertStmt, err := db.Prepare("INSERT INFO ...")
	if err != nil {
		return nil, err
	}
	// Store it in our ExampleModel object, alongside the connection pool.
	return &ExampleModel{db, insertStmt}, nil
}
// Any methods implemented against the ExampleModel object will have access to
// the prepared statement.
func (m *ExampleModel) Insert(args...) error {
	// Notice how we call Exec directly against the prepared statement, rather
	// than against the connection pool? Prepared statements also support the
	// Query and QueryRow methods.
	_, err := m.InsertStmt.Exec(args...)
	return err
}
// In the web application's main function we will need to initialize a new
// ExampleModel struct using the constructor function.
func main() {
	db, err :=sql.Open(...)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()
	// Create a new ExampleModel object, which includes the prepared statement.
	exampleModel, err := NewExampleModel(db)
	if err != nil {
		errorLog.Fatal(err)
	}
	// Defer a call to Close on the prepared statement to ensure that it is
	// properly closed before our main function terminates.
	defer exampleModel.InsertStmt.Close()
}

그러나 몇가지 걱정(주의)하야할 것이 몇가지 잇다.

prepared statement는 database connection들에 존재한다. 따라서 go가 database connection들로 이루어진 pool을 사용하기 때문에, 실제로 발생하는 일은 처음에 prepared statement(가령, sql.Stmt 객체)가 사용되면, 이것은 특정 database connection에 생성된다는 것이다. sql.Stmt 객체는 그 후에는 pool에서 어떠한 connection에 자신이 생성되었는 지를 기억하고 사용된다. 그 다음 sql.Stmt 객체는 같은 database connection에 다시 사용을 요청하게 된다. 만약 해당 connection이 닫혀있거나 사용 중이라면, 해당 statement는 다른 connection에 다시 prepared된다.

높은 부하 아래, 수많은 prepared statement가 여러 개의 connection들에 생성될 수 있다. 이는 prepared statement들이 예상했던 것보다 훨씬 더 많이 다시 prepared되고, 심지어는 server-side에서 statement를 제한할 것이다. mysql은 기본적으로 16382개의 prepared statement로 제한된다.

또한, 코드가 복잡해 지는 문제도 발생한다.

때문에 performance와 complexity 사이의 trade-off가 있을 수 밖에 없으므로, 이들 관계의 이점을 잘 계산해야 한다. 대게의 경우에는 Query(), QueryRow(), Exec() 메서드를을 사용하는 것은 prepared statement를 자체 필요없이 그대로 사용하는 것을 추천한다.

0개의 댓글