sqlc를 사용하여 Go에서 타입 안전한 SQL
Min-jun Kim
Dev Intern · Leapcell

소개
Go 언어의 database/sql
표준 라이브러리에서 제공하는 인터페이스는 비교적 저수준입니다. 따라서 많은 양의 반복 코드를 작성해야 합니다. 이렇게 상당한 양의 상용구 코드는 작성하기 번거로울 뿐만 아니라 오류가 발생하기 쉽습니다. 때로는 필드 유형을 수정하면 여러 위치에서 변경해야 할 수도 있습니다. 새 필드를 추가하면 이전에 select *
쿼리 문이 사용된 위치도 수정해야 합니다. 누락 사항이 있으면 런타임 중에 패닉이 발생할 수 있습니다. ORM 라이브러리를 사용하더라도 이러한 문제를 완전히 해결할 수 없습니다! 바로 sqlc가 필요한 이유입니다! sqlc는 우리가 작성하는 SQL 문을 기반으로 타입 안전하고 관용적인 Go 인터페이스 코드를 생성할 수 있으며, 우리는 이러한 메서드를 호출하기만 하면 됩니다.
빠른 시작
설치
먼저 sqlc를 설치합니다.
$ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
물론 해당 데이터베이스 드라이버도 필요합니다.
$ go get github.com/lib/pq $ go get github.com/go-sql-driver/mysql
SQL 문 작성
테이블 생성 문을 작성합니다. schema.sql
파일에 다음 내용을 작성합니다.
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, bio TEXT );
쿼리 문을 작성합니다. query.sql
파일에 다음 내용을 작성합니다.
-- name: GetUser :one SELECT * FROM users WHERE id = $1 LIMIT 1; -- name: ListUsers :many SELECT * FROM users ORDER BY name; -- name: CreateUser :exec INSERT INTO users ( name, bio ) VALUES ( $1, $2 ) RETURNING *; -- name: DeleteUser :exec DELETE FROM users WHERE id = $1;
sqlc는 PostgreSQL을 지원합니다. sqlc는 작은 구성 파일 sqlc.yaml
만 필요합니다.
version: "1" packages: - name: "db" path: "./db" queries: "./query.sql" schema: "./schema.sql"
구성 설명:
version
: 버전.packages
:name
: 생성된 패키지 이름.path
: 생성된 파일의 경로.queries
: 쿼리 SQL 파일.schema
: 테이블 생성 SQL 파일.
Go 코드 생성
다음 명령을 실행하여 해당 Go 코드를 생성합니다.
sqlc generate
sqlc는 동일한 디렉터리에 데이터베이스 작업 코드를 생성합니다. 디렉터리 구조는 다음과 같습니다.
db
├── db.go
├── models.go
└── query.sql.go
sqlc는 schema.sql
및 query.sql
에 따라 모델 객체 구조를 생성합니다.
// models.go type User struct { ID int64 Name string Bio sql.NullString }
그리고 작업 인터페이스:
// query.sql.go func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) func (q *Queries) DeleteUser(ctx context.Context, id int64) error func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) func (q *Queries) ListUsers(ctx context.Context) ([]User, error)
여기서 Queries
는 sqlc로 캡슐화된 구조체입니다.
사용 예시
package main import ( "database/sql" "fmt" "log" _ "github.com/lib/pq" "golang.org/x/net/context" "github.com/leapcell/examples/sqlc" ) func main() { pq, err := sql.Open("postgres", "dbname=sqlc sslmode=disable") if err != nil { log.Fatal(err) } queries := db.New(pq) users, err := queries.ListUsers(context.Background()) if err != nil { log.Fatal("ListUsers error:", err) } fmt.Println(users) insertedUser, err := queries.CreateUser(context.Background(), db.CreateUserParams{ Name: "Rob Pike", Bio: sql.NullString{String: "Co-author of The Go Programming Language", Valid: true}, }) if err != nil { log.Fatal("CreateUser error:", err) } fmt.Println(insertedUser) fetchedUser, err := queries.GetUser(context.Background(), insertedUser.ID) if err != nil { log.Fatal("GetUser error:", err) } fmt.Println(fetchedUser) err = queries.DeleteUser(context.Background(), insertedUser.ID) if err != nil { log.Fatal("DeleteUser error:", err) } }
생성된 코드는 db
패키지 ( packages.name
옵션으로 지정됨)에 있습니다. 먼저 db.New()
를 호출하고 sql.Open()
의 반환 값 sql.DB
를 매개 변수로 전달하여 Queries
객체를 가져옵니다. users
테이블에 대한 모든 작업은 이 객체의 메서드를 통해 완료해야합니다.
PostgreSQL 시작, 데이터베이스 및 테이블 생성
위의 프로그램을 실행하려면 PostgreSQL을 시작하고 데이터베이스와 테이블을 생성해야합니다.
$ createdb sqlc $ psql -f schema.sql -d sqlc
첫 번째 명령은 sqlc
라는 데이터베이스를 생성하고 두 번째 명령은 sqlc
데이터베이스에서 schema.sql
파일의 명령문을 실행합니다. 즉, 테이블을 만듭니다.
프로그램 실행
$ go run .
실행 결과 예시:
[]
{1 Rob Pike {Co-author of The Go Programming Language true}}
코드 생성
SQL 문 자체 외에도 sqlc는 SQL 문을 작성할 때 생성된 프로그램에 대한 몇 가지 기본 정보를 주석 형태로 제공해야합니다. 구문은 다음과 같습니다.
-- name: <name> <cmd>
name
은 생성 된 메서드의 이름입니다 (예 : 위의 CreateUser
, ListUsers
, GetUser
, DeleteUser
등). cmd
는 다음 값을 가질 수 있습니다.
:one
: SQL 문이 하나의 객체를 반환하고 생성된 메서드의 반환 값은(객체 유형, 오류)
이며 객체 유형은 테이블 이름에서 파생될 수 있음을 나타냅니다.:many
: SQL 문이 여러 객체를 반환하고 생성된 메서드의 반환 값은([]객체 유형, 오류)
임을 나타냅니다.:exec
: SQL 문이 객체를 반환하지 않고error
만 반환함을 나타냅니다.:execrows
: SQL 문이 영향을받는 행 수를 반환해야 함을 나타냅니다.
:one
예시
-- name: GetUser :one SELECT id, name, bio FROM users WHERE id = $1 LIMIT 1
주석의 --name
은 GetUser
메서드를 생성하도록 지시합니다. 테이블 이름에서 파생됨, 반환 값의 기본 유형은 User
입니다. :one
은 하나의 객체만 반환됨을 나타냅니다. 따라서 최종 반환 값은 (User, error)
입니다.
// db/query.sql.go const getUser = `-- name: GetUser :one SELECT id, name, bio FROM users WHERE id = $1 LIMIT 1 ` func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { row := q.db.QueryRowContext(ctx, getUser, id) var i User err := row.Scan(&i.ID, &i.Name, &i.Bio) return i, err }
:many
예시
-- name: ListUsers :many SELECT * FROM users ORDER BY name;
주석의 --name
은 ListUsers
메서드를 생성하도록 지시합니다. 테이블 이름 users
에서 파생됨, 반환 값의 기본 유형은 User
입니다. :many
는 객체 슬라이스가 반환됨을 나타냅니다. 따라서 최종 반환 값은 ([]User, error)
입니다.
// db/query.sql.go const listUsers = `-- name: ListUsers :many SELECT id, name, bio FROM users ORDER BY name ` func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { rows, err := q.db.QueryContext(ctx, listUsers) if err != nil { return nil, err } defer rows.Close() var items []User for rows.Next() { var i User if err := rows.Scan(&i.ID, &i.Name, &i.Bio); 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 }
여기서 주목해야 할 세부 사항이 있습니다. select *
를 사용하더라도 생성된 코드의 SQL 문은 특정 필드로 다시 작성됩니다.
SELECT id, name, bio FROM users ORDER BY name
이러한 방식으로 나중에 필드를 추가하거나 삭제해야하는 경우 sqlc
명령을 실행하는 한이 SQL 문과 ListUsers()
메서드를 일관성있게 유지할 수 있는데 이는 매우 편리합니다!
:exec
예시
-- name: DeleteUser :exec DELETE FROM users WHERE id = $1
주석의 --name
은 DeleteUser
메서드를 생성하도록 지시합니다. 테이블 이름 users
에서 파생됨, 반환 값의 기본 유형은 User
입니다. :exec
는 객체가 반환되지 않음을 나타냅니다. 따라서 최종 반환 값은 error
입니다.
// db/query.sql.go const deleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = $1 ` func (q *Queries) DeleteUser(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deleteUser, id) return err }
:execrows
예시
-- name: DeleteUserN :execrows DELETE FROM users WHERE id = $1
주석의 --name
은 DeleteUserN
메서드를 생성하도록 지시합니다. 테이블 이름 users
에서 파생됨, 반환 값의 기본 유형은 User
입니다. :exec
는 영향을받는 행 수 (즉, 삭제 된 행 수)가 반환됨을 나타냅니다. 따라서 최종 반환 값은 (int64, error)
입니다.
// db/query.sql.go const deleteUserN = `-- name: DeleteUserN :execrows DELETE FROM users WHERE id = $1 ` func (q *Queries) DeleteUserN(ctx context.Context, id int64) (int64, error) { result, err := q.db.ExecContext(ctx, deleteUserN, id) if err != nil { return 0, err } return result.RowsAffected() }
아무리 복잡한 SQL이 작성되어도 위의 규칙을 따릅니다. SQL 문을 작성할 때 추가 주석 행을 추가하기만하면 sqlc는 우리를 위해 관용적 인 SQL 작업 메서드를 생성 할 수 있습니다. 생성된 코드는 우리가 직접 작성한 코드와 다르지 않으며 오류 처리가 매우 완벽하고 손으로 작성하는 번거로움과 오류를 피할 수 있습니다.
모델 객체
sqlc는 모든 테이블 생성 문에 해당 모델 구조를 생성합니다. 구조 이름은 테이블 이름의 단수 형태이며 첫 글자는 대문자입니다. 예를 들어:
CREATE TABLE users ( id SERIAL PRIMARY KEY, name text NOT NULL );
해당 구조를 생성합니다.
type User struct { ID int Name string }
또한 sqlc는 ALTER TABLE
문을 구문 분석 할 수 있으며 최종 테이블 구조에 따라 모델 객체의 구조를 생성합니다. 예를 들어:
CREATE TABLE users ( id SERIAL PRIMARY KEY, birth_year int NOT NULL ); ALTER TABLE users ADD COLUMN bio text NOT NULL; ALTER TABLE users DROP COLUMN birth_year; ALTER TABLE users RENAME TO writers;
위의 SQL 문에서 테이블이 생성될 때 두 개의 열 id
와 birth_year
가 있습니다. 첫 번째 ALTER TABLE
문은 열 bio
를 추가하고 두 번째는 birth_year
열을 삭제하고 세 번째는 테이블 이름을 users
에서 writers
로 변경합니다. sqlc는 최종 테이블 이름 writers
와 테이블의 열 id
및 bio
에 따라 코드를 생성합니다.
package db type Writer struct { ID int Bio string }
구성 필드
sqlc.yaml
파일에서 다른 구성 필드를 설정할 수도 있습니다.
emit_json_tags
기본값은 false
입니다. 이 필드를 true
로 설정하면 생성된 모델 객체 구조에 JSON 태그를 추가할 수 있습니다. 예를 들어:
CREATE TABLE users ( id SERIAL PRIMARY KEY, created_at timestamp NOT NULL );
생성 됩니다:
package db import ( "time" ) type User struct { ID int `json:"id"` CreatedAt time.Time `json:"created_at"` }
emit_prepared_queries
기본값은 false
입니다. 이 필드를 true
로 설정하면 SQL에 해당하는 준비된 명령문이 생성됩니다. 예를 들어 빠른 시작 예제에서 이 옵션을 설정하면 최종 생성 된 구조체 Queries
는 SQL에 해당하는 모든 준비된 명령문 객체를 추가합니다.
type Queries struct { db DBTX tx *sql.Tx createUserStmt *sql.Stmt deleteUserStmt *sql.Stmt getUserStmt *sql.Stmt listUsersStmt *sql.Stmt }
그리고 Prepare()
메서드:
func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error if q.createUserStmt, err = db.PrepareContext(ctx, createUser); err != nil { return nil, fmt.Errorf("error preparing query CreateUser: %w", err) } if q.deleteUserStmt, err = db.PrepareContext(ctx, deleteUser); err != nil { return nil, fmt.Errorf("error preparing query DeleteUser: %w", err) } if q.getUserStmt, err = db.PrepareContext(ctx, getUser); err != nil { return nil, fmt.Errorf("error preparing query GetUser: %w", err) } if q.listUsersStmt, err = db.PrepareContext(ctx, listUsers); err != nil { return nil, fmt.Errorf("error preparing query ListUsers: %w", err) } return &q, nil }
다른 생성된 메서드는 SQL 문을 직접 사용하는 대신 이러한 객체를 모두 사용합니다.
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { row := q.queryRow(ctx, q.createUserStmt, createUser, arg.Name, arg.Bio) var i User err := row.Scan(&i.ID, &i.Name, &i.Bio) return i, err }
프로그램 초기화 중에 이 Prepare()
메서드를 호출해야합니다.
emit_interface
기본값은 false
입니다. 이 필드를 true
로 설정하면 쿼리 구조에 대한 인터페이스가 생성됩니다. 예를 들어 빠른 시작 예제에서 이 옵션을 설정하면 최종 생성된 코드에 추가 파일 querier.go
가 있습니다.
// db/querier.go type Querier interface { CreateUser(ctx context.Context, arg CreateUserParams) (User, error) DeleteUser(ctx context.Context, id int64) error DeleteUserN(ctx context.Context, id int64) (int64, error) GetUser(ctx context.Context, id int64) (User, error) ListUsers(ctx context.Context) ([]User, error) }
결론
sqlc는 여전히 몇 가지 불완전한 부분이 있지만 Go에서 데이터베이스 코드 작성의 복잡성을 크게 단순화하고 코딩 효율성을 높이며 오류 발생 가능성을 줄일 수 있습니다. PostgreSQL을 사용하는 분들에게는 사용해 볼 것을 적극 권장합니다!
참고 자료
Leapcell: Golang 호스팅을 위한 차세대 서버리스 플랫폼
마지막으로 Go 서비스를 배포하는 데 가장 적합한 플랫폼을 추천합니다: Leapcell
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하십시오.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청이나 요금이 없습니다.
3. 최고의 비용 효율성
- 유휴 요금없이 사용한만큼 지불.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성과 높은 성능
- 쉬운 고 동시성을 처리하기 위해 자동 확장.
- 제로 운영 오버헤드 — 구축에 집중하십시오.
Leapcell 트위터: https://x.com/LeapcellHQ