CRUD는 약자로 ,create, read, update, delete를 의미한다. 이는 DB에 있는 정보를 저장하고 삭제하고 변경, 읽기 작업을 하는 메서드를 말하며, 이를 어플리케이션에서 조작할 수 있도록 API를 구축하는 것을 말한다.
golang에서 CRDU
를 구현하는 방법은 여러가지가 있다.
database/sql 패키지 사용
첫번째는 저수준 표준 라이브러리인 database/sql
패키지를 사용하는 것이다. 이 방식은 sql 쿼리문을 직접 작성하고, scan한 다음 변수에 직접 맵핑하는 방식이다. 매우 빠르고, 직관적이라는 장점이 있는 대신, 매우 지루하며 실수하기 쉽고 쿼리문의 오류가 런타임에 발생한다.
GORM
golang의 고급 개체 관계 매핑 라이브러리인 Gorm
을 사용하는 것이다. 모든 CRUD 작업에 대한 코드가 이미 구현되어 있기 때문에 사용하기 편하고, 코드에서 발생하는 실수를 잡기에 좋다. 단점은 gorm에서 제공하는 함수를 사용하여 쿼리를 작성하는 방법을 배워야 한다는 것이다. 또한, 표준 라이브러리 보다 3~5배 속도가 느리다.
SQLX
GORM
보다는 어렵지만, database/sql
패키지보다는 쉬운 특성을 가지고 있다. 속도도 준수하며 사용하기 쉽다. database/sql
이 가진 단점인 맵핑에서의 어려움을 쿼리 텍스트 또는 구조체 태그를 통해 수행하여 문제를 해결하였다. 그러나 여전히 관리해야하는 코드가 길고, 런타임에 에러가 발생하여 에러 확인이 어렵다.
SQLC
가장 괜찮은 대안으로, 쿼리문을 작성하기만해도 자동으로 golang CRUD
code를 작성해준다. 이 때문에 database/sql
만큼 속도가 빠르고 또한, sqlc
는 만들어진 쿼리문을 분석하여 오류를 포착한다. 만약 오류가 포착되면 CRUD
code를 만들지 않는다. 가장 치명적인 단점은 postgres
에서만 지원한다는 점이다. (2020/07/13일 기준)
https://docs.sqlc.dev/en/latest/overview/install.html
다음의 docs로 가면 설치를 진행할 수 있다. mac의 경우는 다음과 같다.
brew install sqlc
설치가 완료된 후에는 sqlc help
를 치면 커맨드 설명이 나온다.
Available Commands:
compile Statically check SQL for syntax and type errors
completion Generate the autocompletion script for the specified shell
generate Generate Go code from SQL
help Help about any command
init Create an empty sqlc.yaml settings file
version Print the sqlc version number
...
sqlc.yaml
설정 파일을 만든다.이제 프로젝트 폴더로 가서 설정 파일을 만들어보자
sqlc init
완료되면 다음과 같은 sqlc.yaml
파일이 만들어진 것을 확인할 수 있다.
version: "1"
packages: []
이제 설정 파일을 채워보도록 하자
github에서 tag로 1.4
를 선택한 다음 setting부분을 가져오자
version: "1"
packages:
- name: "db"
path: "./db/sqlc"
queries: "./db/query/"
schema: "./db/migration/"
engine: "postgresql"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: false
emit_exact_table_names: false
다음의 설정을 정리해보면 다음과 같다.
name
: 생성될 golang
파일의 패키지를 어떤 것으로 선택할 지 의미한다. 여러개가 있어도 된다.path
: 생성할 golang 코드 파일을 어디에 저장할 지 선택한다.queries
: sql 쿼리 파일을 찾을 위치를 sqlc
에 알려주는 옵션이다.schema
: 데이터베이스 스키마 또는 마이그레이션 파일이 포함된 폴더를 가리킨다.emit_json_tags
: sqlc가 생성된 구조체에 json
태그를 추가하고 싶을 때 true
로 설정한다.emit_prepared_queries
: sqlc에게 준비된 명령문과 함께 작동하는 코드를 생성하도록 지시한다. 즉, 최적화 작업이므로 false
설정하도록 한다.아직 쿼리가 없기 때문에 sqlc generate
커맨더를 쳐도 실행되지 않는다. 이를위해 쿼리문을 작성하도록 하자.
project folder의 db/query
로 가서, account.sql
파일을 만들자
/db/query/account.sql
-- name: CreateAccount :one
INSERT INTO accounts (
owner,
balance,
currency
) VALUES (
$1, $2, $3
) RETURNING *;
다음의 sql문으로 golang
DB 함수를 만들 수 있다.
함수의 인자로 owner, balance, currency
를 받고 accounts
테이블에 값을 넣어주겠다는 의미이다. 또, return
값으로는 새로운 tuple이 가진 column들을 모두 반환하겠다는 것이다. 즉,id, owner, balacne, currency, create_at
을 모두 반환하겠다는 것이다. id, create_at
을 함수의 매개변수로 넘기지 않은 이유는 db에서 자동으로 생성되도록 만들었기 때문이다.
참고로 주석부분도 굉장히 중요하다. -- name: CreateAccount :one
는 만들어질 golang
함수의 이름이 CreateAccount
이고 반환되는 튜플은 하나라는 의미이다.
이제 Makefile
로 들어가서 sqlc generate
명령어를 쉽게 사용하도록 만들자.
sqlc:
sqlc generate
.PHONY: postgres createdb dropdb migrationup migrationdown sqlc
코드를 생성해보자
make sqlc
이제 db/sqlc
폴더로 가면 다음의 3개의 파일이 있는 것을 확인할 수 있을 것이다.
db/sqlc/
- account.sql.go
- db.go
- models.go
하나하나 확인해보자
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"time"
)
type Account struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Currency string `json:"currency"`
Balance int64 `json:"balance"`
CreatedAt time.Time `json:"created_at"`
}
type Entry struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
// negative or positive
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
type Transfer struct {
ID int64 `json:"id"`
FromAccountID int64 `json:"from_account_id"`
ToAccountID int64 `json:"to_account_id"`
// negative or positive
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
models.go
는 sqlc.yaml
에서 schema
로 적어준 부분에 해당하는 것이다. 즉, Db 스키마를 토대로 go에서의 구조체로 만든 것이다.
json
관련 옵션을 켜둔 configuration
을 사용하였기 때문에 모두 json
표시가 되어있다.
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
이 파일에는 DBTX interface
가 있다. 이는 database/sql
에 있는 sql.DB, sql.Tx
객체가 가지고 있는 4가지 공통 메서드를 정의한다. 즉, DBTX interface
를 이용하여 sql.DB, sql.Tx
객체를 받을 수 있다는 것이다.
이를 통해 db 또는 트랜잭션을 자연스럽게 사용할 수 있다. New
함수는 db를 받아 단일 쿼리를 실행하거나, 트랜잭션을 통해 여러 쿼리 집합을 실행할 수 있다.
또한 쿼리 인스턴스가 트랜잭션과 연결하는 WithTx
메서드도 제공한다.
// Code generated by sqlc. DO NOT EDIT.
// source: account.sql
package db
import (
"context"
)
const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts (
owner,
balance,
currency
) VALUES (
$1, $2, $3
) RETURNING id, owner, currency, balance, created_at
`
type CreateAccountParams struct {
Owner string `json:"owner"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
}
func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, createAccount, arg.Owner, arg.Balance, arg.Currency)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Currency,
&i.Balance,
&i.CreatedAt,
)
return i, err
}
account.sql.go
파일에는 우리가 방금만든 db/query/account.sql
파일의 쿼리문이 적용되어 코드가 만들어진 것을 볼 수 있다.
CreateAccountParams
는 CreateAccount
함수에 넣어줄 파라미터이다. 우리가 위에서 선택한 owner, balance, currency
가 있는 것을 확인할 수 있다.
CreateAccount
는 Queries
의 메서드로 구현되어있고 내부적으로 database/sql
라이브러리를 사용하는 것을 확인할 수 있다.
지금은 에러가 발생할텐데, 이는 모듈을 설정해주지 않아서 라이브러리들이 어디에 있는 지 가져오지 못해서 그렇다.
project폴더 root로 가서 다음의 명령어를 치자
go mod init <module_name>
go mod tidy
이제 모듈을 정해주었으니 에러가 발생하지 않을 것이다.
참고로 현재 폴더의 구조는 다음과 같이 되어있어야 한다.
중요한 것은
sqlc
에서는 잘못된sql문
을 만들지 않도록 정적 분석을 하도록 도와준다. 즉, 잘못된sql문
에 대해서는 코드를 생성하지 않는다는 것이다.
그리고 생성된 파일에 대해서는 수정해서는 안된다. 우리가 sqlc generate
할 때마다 이 파일들은 계속 재생성되는 것이기 때문에 변경사항들이 사라지게된다.
이제 account
를 id에 따라 또는 모든 데이터를 가져오는 READ
메서드를 만들어보자
db/query/account.sql
...
-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;
-- name: ListAccount :many
SELECT * FROM accounts
ORDER BY id
LIMIT $1
OFFSET $2;
위의 코드를 추가하도록 하자
GetAccount
는 id에 따른 튜플을 한 개 반환하는 반면,ListAccount
는 accounts
테이블의 모든 튜플을 반환하지만 id별로 정렬하고, 인자를 받아 limit과 offset을 정하도록 하였다.
이제 go code를 생성해주도록 하자
make sqlc
db/sqlc/account.sql.go
파일을 확인하면 코드가 추가된 것을 확인할 수 있다.
const getAccount = `-- name: GetAccount :one
SELECT id, owner, currency, balance, created_at FROM accounts
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetAccount(ctx context.Context, id int64) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccount, id)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Currency,
&i.Balance,
&i.CreatedAt,
)
return i, err
}
const listAccount = `-- name: ListAccount :many
SELECT id, owner, currency, balance, created_at FROM accounts
ORDER BY id
LIMIT $1
OFFSET $2
`
type ListAccountParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListAccount(ctx context.Context, arg ListAccountParams) ([]Account, error) {
rows, err := q.db.QueryContext(ctx, listAccount, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Account
for rows.Next() {
var i Account
if err := rows.Scan(
&i.ID,
&i.Owner,
&i.Currency,
&i.Balance,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
하나하나 설명하진 않겠다. 맨 처음에 만든 것처럼 우리가 원했던대로 코드가 생성되었음을 확인할 수 있다. 이제 update
코드를 만들어보자
먼저 update sql문 코드를 추가해주도록 하자, 업데이트만 실행하고 반환값이 없는 것과, 업데이트한 튜플을 반환하도록하는 방법으로도 구현이 가능하다.
먼저 업데이트만하는 방법이다.
...
-- name: UpdateAccount :exec
UPDATE accounts
SET balance = $2
WHERE id = $1;
:exec
은 실행만하고 반환값은 없다는 의미이다. 해당 id
에 맞는 튜플에 잔액인 balance
를 변경하도록 하겠다는 것이다.
그러나, 이는 업데이트가 적용되었는 지 확인하기 어려움으로 다음과 같이 변경하도록 하자
...
-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING *;
make sqlc
를 하여 go code가 잘 생성되었는 지 확인해보도록 하자
...
const updateAccount = `-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING id, owner, currency, balance, created_at
`
type UpdateAccountParams struct {
ID int64 `json:"id"`
Balance int64 `json:"balance"`
}
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, updateAccount, arg.ID, arg.Balance)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Currency,
&i.Balance,
&i.CreatedAt,
)
return i, err
}
이제는 계정 삭제를 만들어보도록 하자
...
-- name: DeleteAccount :exec
DELETE FROM accounts
WHERE id = $1;
삭제는 더욱 간단하다. :exec
으로 만들어서 실행 후 따로 반환하는 것이 없도록 하자.
make sqlc
...
const deleteAccount = `-- name: DeleteAccount :exec
DELETE FROM accounts
WHERE id = $1
`
func (q *Queries) DeleteAccount(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteAccount, id)
return err
}
...
delete code도 문제없이 생성된 것을 확인할 수 있다.
이를 기반으로 다른 테이블의 CRUD 코드를 만들어 사용하면 된다.
이젠 mysql도 지원해요. sqlc는 goat입니다