golang 백엔드 개발을 해보자 2일차 - sqlc, CRUD

3

Golang_Backend

목록 보기
2/2

1. Generate CRUD golang code from SQL

1. CRUD

CRUD는 약자로 ,create, read, update, delete를 의미한다. 이는 DB에 있는 정보를 저장하고 삭제하고 변경, 읽기 작업을 하는 메서드를 말하며, 이를 어플리케이션에서 조작할 수 있도록 API를 구축하는 것을 말한다.

  1. Create : 새로운 레코드를 데이터 베이스에 추가한다.
  2. Read : 데이터 베이스에서 레코드를 선택하거나 검색한다.
  3. Update : 데이터 베이스의 일부 레코드를 변경한다
  4. Delete : 데이터 베이스에서 일부 레코드를 삭제한다.

golang에서 CRDU를 구현하는 방법은 여러가지가 있다.

  1. database/sql 패키지 사용
    첫번째는 저수준 표준 라이브러리인 database/sql 패키지를 사용하는 것이다. 이 방식은 sql 쿼리문을 직접 작성하고, scan한 다음 변수에 직접 맵핑하는 방식이다. 매우 빠르고, 직관적이라는 장점이 있는 대신, 매우 지루하며 실수하기 쉽고 쿼리문의 오류가 런타임에 발생한다.

  2. GORM
    golang의 고급 개체 관계 매핑 라이브러리인 Gorm을 사용하는 것이다. 모든 CRUD 작업에 대한 코드가 이미 구현되어 있기 때문에 사용하기 편하고, 코드에서 발생하는 실수를 잡기에 좋다. 단점은 gorm에서 제공하는 함수를 사용하여 쿼리를 작성하는 방법을 배워야 한다는 것이다. 또한, 표준 라이브러리 보다 3~5배 속도가 느리다.

  3. SQLX
    GORM보다는 어렵지만, database/sql 패키지보다는 쉬운 특성을 가지고 있다. 속도도 준수하며 사용하기 쉽다. database/sql이 가진 단점인 맵핑에서의 어려움을 쿼리 텍스트 또는 구조체 태그를 통해 수행하여 문제를 해결하였다. 그러나 여전히 관리해야하는 코드가 길고, 런타임에 에러가 발생하여 에러 확인이 어렵다.

  4. SQLC
    가장 괜찮은 대안으로, 쿼리문을 작성하기만해도 자동으로 golang CRUD code를 작성해준다. 이 때문에 database/sql만큼 속도가 빠르고 또한, sqlc는 만들어진 쿼리문을 분석하여 오류를 포착한다. 만약 오류가 포착되면 CRUD code를 만들지 않는다. 가장 치명적인 단점은 postgres에서만 지원한다는 점이다. (2020/07/13일 기준)

2. SQLC

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
...
  1. compile : 정적 분석으로 sql문이 잘못되었는 가 확인한다.
  2. generate : sql로 부터 golnag 코드를 작성한다.
  3. init : 빈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

다음의 설정을 정리해보면 다음과 같다.

  1. name : 생성될 golang 파일의 패키지를 어떤 것으로 선택할 지 의미한다. 여러개가 있어도 된다.
  2. path : 생성할 golang 코드 파일을 어디에 저장할 지 선택한다.
  3. queries: sql 쿼리 파일을 찾을 위치를 sqlc에 알려주는 옵션이다.
  4. schema: 데이터베이스 스키마 또는 마이그레이션 파일이 포함된 폴더를 가리킨다.
  5. emit_json_tags : sqlc가 생성된 구조체에 json태그를 추가하고 싶을 때 true로 설정한다.
  6. emit_prepared_queries : sqlc에게 준비된 명령문과 함께 작동하는 코드를 생성하도록 지시한다. 즉, 최적화 작업이므로 false설정하도록 한다.

아직 쿼리가 없기 때문에 sqlc generate 커맨더를 쳐도 실행되지 않는다. 이를위해 쿼리문을 작성하도록 하자.

project folder의 db/query로 가서, account.sql 파일을 만들자

3. Create

  • /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

하나하나 확인해보자

  1. 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.gosqlc.yaml에서 schema로 적어준 부분에 해당하는 것이다. 즉, Db 스키마를 토대로 go에서의 구조체로 만든 것이다.

json관련 옵션을 켜둔 configuration을 사용하였기 때문에 모두 json표시가 되어있다.

  1. db.go
// 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메서드도 제공한다.

  1. account.sql.go
// 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 파일의 쿼리문이 적용되어 코드가 만들어진 것을 볼 수 있다.

CreateAccountParamsCreateAccount함수에 넣어줄 파라미터이다. 우리가 위에서 선택한 owner, balance, currency가 있는 것을 확인할 수 있다.

CreateAccountQueries의 메서드로 구현되어있고 내부적으로 database/sql 라이브러리를 사용하는 것을 확인할 수 있다.

지금은 에러가 발생할텐데, 이는 모듈을 설정해주지 않아서 라이브러리들이 어디에 있는 지 가져오지 못해서 그렇다.

project폴더 root로 가서 다음의 명령어를 치자

go mod init <module_name>
go mod tidy

이제 모듈을 정해주었으니 에러가 발생하지 않을 것이다.

참고로 현재 폴더의 구조는 다음과 같이 되어있어야 한다.

중요한 것은 sqlc에서는 잘못된 sql문을 만들지 않도록 정적 분석을 하도록 도와준다. 즉, 잘못된 sql문에 대해서는 코드를 생성하지 않는다는 것이다.

그리고 생성된 파일에 대해서는 수정해서는 안된다. 우리가 sqlc generate할 때마다 이 파일들은 계속 재생성되는 것이기 때문에 변경사항들이 사라지게된다.

4. Read

이제 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에 따른 튜플을 한 개 반환하는 반면,ListAccountaccounts테이블의 모든 튜플을 반환하지만 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 코드를 만들어보자

5. Update

먼저 update sql문 코드를 추가해주도록 하자, 업데이트만 실행하고 반환값이 없는 것과, 업데이트한 튜플을 반환하도록하는 방법으로도 구현이 가능하다.

먼저 업데이트만하는 방법이다.

  • db/query/account.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가 잘 생성되었는 지 확인해보도록 하자

  • db/sqlc/account.sql.go
... 

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
}

6. Delete

이제는 계정 삭제를 만들어보도록 하자

  • db/query/account.sql
...

-- name: DeleteAccount :exec
DELETE FROM accounts
WHERE id = $1;

삭제는 더욱 간단하다. :exec으로 만들어서 실행 후 따로 반환하는 것이 없도록 하자.

make sqlc
  • db/sqlc/account.sql.go
...
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 코드를 만들어 사용하면 된다.

1개의 댓글

comment-user-thumbnail
2025년 5월 26일

이젠 mysql도 지원해요. sqlc는 goat입니다

답글 달기