sqlc와 sqlx
이 챕터에서 다루는 것
GORM만 알고 있으면 "ORM을 쓸까 말까"의 이분법에 갇힌다. 실제 선택지는 스펙트럼이고,
Go에서는 그 중간 두 자리가 특히 인기가 있다. sqlx는 database/sql에 스캔 편의만
얹은 것이고, sqlc는 SQL에서 Go 코드를 생성한다.
버전은 sqlx v1.4.0, sqlc v1.31.1이다.
스펙트럼
| SQL을 누가 쓰는가 | 매핑을 누가 하는가 | 오류가 언제 잡히는가 | |
|---|---|---|---|
database/sql | 사람 | 사람(Scan) | 실행 |
| sqlx | 사람 | 라이브러리(리플렉션) | 실행 |
| sqlc | 사람 | 코드 생성기 | 생성/컴파일 |
| GORM | 라이브러리 | 라이브러리 | 실행 |
가로축은 "제어권"이 아니라 "누가 쓰느냐"다. sqlx와 sqlc는 둘 다 SQL을 사람이 쓴다. 차이는 매핑을 리플렉션으로 하느냐 생성된 코드로 하느냐, 그리고 틀렸을 때 언제 알게 되느냐다.
sqlx — Scan 나열만 없앤다
sqlx는 *sql.DB를 얇게 감싼다. 트랜잭션도, 풀 설정도, 드라이버도 전부 그대로다.
바뀌는 것은 결과를 받는 방법뿐이다.
// Author는 authors 한 행이다.
//
// db 태그가 컬럼 이름과 필드를 잇는다. 태그를 생략하면 sqlx는 필드 이름을
// 소문자로 바꿔 찾는다. AuthorID → authorid가 되어 author_id와 어긋나므로,
// 스네이크 케이스 컬럼을 쓴다면 태그를 붙이는 편이 안전하다.
type Author struct {
ID int64 `db:"id"`
Name string `db:"name"`
Bio sql.NullString `db:"bio"`
}
// GetAuthor는 한 행을 구조체에 바로 담는다.
//
// database/sql이었다면 Scan(&a.ID, &a.Name, &a.Bio)라고 써야 했다.
// 컬럼을 하나 추가하면 그 나열을 고쳐야 하고, 순서를 틀려도 컴파일은 통과한다.
func (s *Store) GetAuthor(ctx context.Context, id int64) (Author, error) {
var a Author
err := s.db.GetContext(ctx, &a, `SELECT id, name, bio FROM authors WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return Author{}, fmt.Errorf("%w: 저자 id=%d", ErrNotFound, id)
}
if err != nil {
return Author{}, fmt.Errorf("sqlxdemo: 저자 조회: %w", err)
}
return a, nil
}
Get은 한 행, Select는 여러 행이다. 11-1의 rows.Next() 루프와 rows.Err()가
통째로 사라진다. sql.ErrNoRows는 그대로 오므로 처리 방식은 같다.
조인 결과도 마찬가지로 임의의 구조체에 담는다. 테이블과 대응할 필요가 없다.
// BookWithAuthor는 조인 결과다. 테이블 하나와 대응하지 않아도 된다.
type BookWithAuthor struct {
ID int64 `db:"id"`
Title string `db:"title"`
Year int64 `db:"year"`
AuthorName string `db:"author_name"`
}
sqlx가 추가로 주는 것
이름 있는 파라미터.
// AddAuthor는 이름 있는 파라미터로 넣는다.
//
// :name, :bio는 sqlx가 구조체 필드에서 값을 찾아 채운다. 인자 순서를
// 헷갈릴 여지가 없어지는 것이 이 기능의 목적이다.
func (s *Store) AddAuthor(ctx context.Context, a NewAuthor) (int64, error) {
res, err := s.db.NamedExecContext(ctx,
`INSERT INTO authors (name, bio) VALUES (:name, :bio)`, a)
if err != nil {
return 0, fmt.Errorf("sqlxdemo: 저자 등록: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("sqlxdemo: id: %w", err)
}
return id, nil
}
IN 절 전개. database/sql의 플레이스홀더는 값 하나에 하나씩 대응하므로
슬라이스를 넣을 수 없다. 이 문제는 생각보다 자주 부딪히고, 대개 문자열 조립으로
잘못 푼다(그리고 SQL 인젝션이 생긴다).
// AuthorsIn은 IN 절에 슬라이스를 넣는 문제를 다룬다.
//
// database/sql의 플레이스홀더는 값 하나에 하나씩 대응하므로 IN (?)에
// 슬라이스를 넘길 수 없다. sqlx.In이 ?를 개수만큼 펼쳐 주고, Rebind가
// 드라이버에 맞는 문법으로 바꿔 준다.
func (s *Store) AuthorsIn(ctx context.Context, ids []int64) ([]Author, error) {
q, args, err := sqlx.In(`SELECT id, name, bio FROM authors WHERE id IN (?) ORDER BY id`, ids)
if err != nil {
return nil, fmt.Errorf("sqlxdemo: IN 전개: %w", err)
}
q = s.db.Rebind(q)
var out []Author
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("sqlxdemo: IN 조회: %w", err)
}
return out, nil
}
sqlx.In: SELECT id FROM authors WHERE id IN (?, ?, ?) / 인자 3개
Rebind가 필요한 이유는 sqlx.In이 항상 ?로 펼치기 때문이다. PostgreSQL이면
$1, $2, $3으로 바꿔야 하고, 그 변환에 드라이버 이름이 필요하다. sqlx.NewDb에
드라이버 이름을 함께 주는 이유가 이것이다.
sqlx의 실패는 런타임이다
// ScanMismatch는 구조체에 없는 컬럼을 골랐을 때 나는 에러를 그대로 돌려준다.
//
// sqlx가 실패하는 지점은 컴파일이 아니라 실행이다. 이것이 sqlc와의
// 가장 큰 차이다.
func (s *Store) ScanMismatch(ctx context.Context) error {
var out []Author
return s.db.SelectContext(ctx, &out,
`SELECT id, name, bio, 1 AS unexpected FROM authors`)
}
구조체에 없는 컬럼을 고르면: missing destination name unexpected in *[]sqlxdemo.Author
에러 메시지가 친절한 편이라 원인을 찾기는 쉽다. 하지만 그 코드가 실행되어야
알 수 있다. 컬럼 이름 오타, SELECT 목록과 구조체의 불일치, 타입 불일치가
전부 런타임 에러다. 테스트가 그 쿼리를 한 번이라도 실행하지 않으면 배포된다.
sqlc — SQL을 컴파일한다
sqlc는 접근이 다르다. 스키마와 쿼리를 읽어서 Go 코드를 생성한다. 런타임 라이브러리가 아니라 빌드 타임 도구다.
입력은 세 개다.
CREATE TABLE authors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bio TEXT
);
-- name: GetAuthor :one
SELECT * FROM authors
WHERE id = ?;
version: "2"
sql:
- engine: "sqlite"
schema: "sqlcdemo/schema.sql"
queries: "sqlcdemo/query.sql"
gen:
go:
package: "gen"
out: "sqlcdemo/gen"
emit_json_tags: false
emit_prepared_queries: false
emit_interface: true
emit_empty_slices: true
생성한다.
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
sqlcdemo/gen/에 db.go, models.go, querier.go, query.sql.go 네 파일이 생긴다.
생성된 것
type Author struct {
ID int64
Name string
Bio sql.NullString
}
bio가 sql.NullString인 것은 스키마에 NOT NULL이 없기 때문이다. name은
NOT NULL이라 string이다. 스키마가 Go 타입을 정한다. 11-1에서 "스키마에서
NULL을 줄이는 편이 낫다"고 한 이유가 여기서 구체화된다 — NOT NULL을 붙이면
Go 코드에서 .Valid 검사가 사라진다.
쿼리 함수는 이렇게 생긴다.
func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) {
row := q.db.QueryRowContext(ctx, getAuthor, id)
var i Author
err := row.Scan(&i.ID, &i.Name, &i.Bio)
return i, err
}
우리가 11-1에서 손으로 쓰던 것과 똑같다. 차이는 이것을 사람이 유지보수하지
않는다는 것뿐이다. 리플렉션도 없으므로 database/sql을 직접 쓰는 것과 성능이 같다.
조인 쿼리에는 전용 Row 타입이 생긴다.
type BooksByAuthorNameRow struct {
ID int64
Title string
Year int64
AuthorName string
}
DTO를 손으로 선언할 필요가 없고, SELECT 목록을 고치면 타입도 같이 바뀐다.
그 대신 쿼리를 고칠 때마다 generate를 다시 돌려야 한다.
실패 시점이 앞당겨진다
sqlc의 진짜 값은 여기 있다. 쿼리에 오타가 있으면 sqlc generate가 실패한다.
컬럼 이름을 틀리면 생성 단계에서 잡힌다. SELECT 목록을 바꾸면 Row 타입이 바뀌고,
그것을 쓰던 코드가 컴파일 에러가 된다.
sqlx에서 런타임 에러였던 것들이 sqlc에서는 빌드 실패다. 그리고 그 검사는 테스트 커버리지와 무관하게 모든 쿼리에 적용된다.
감싸서 쓴다
// Store는 생성된 Queries를 감싼다.
//
// gen.Queries는 얇다. 도메인 에러 변환처럼 프로젝트마다 다른 부분은
// 이렇게 한 겹 위에서 한다. 생성 코드를 손으로 고치면 다음 generate에서 사라진다.
type Store struct {
q *gen.Queries
}
생성 코드를 절대 손으로 고치지 않는다는 것이 규칙이다. 도메인 에러 변환, 로깅, 캐싱 같은 것은 감싸는 층에서 한다.
// Author는 한 명을 찾고 GORM처럼 도메인 에러로 바꾼다.
func (s *Store) Author(ctx context.Context, id int64) (gen.Author, error) {
a, err := s.q.GetAuthor(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return gen.Author{}, fmt.Errorf("%w: 저자 id=%d", ErrNotFound, id)
}
if err != nil {
return gen.Author{}, fmt.Errorf("sqlcdemo: 저자 조회: %w", err)
}
return a, nil
}
:::tip 생성물은 커밋한다
sqlcdemo/gen/을 .gitignore에 넣고 싶은 유혹이 있지만, 커밋하는 편이 낫다.
sqlc를 설치하지 않은 사람도 go build만으로 빌드할 수 있고, 코드 리뷰에서
"이 쿼리 변경이 어떤 Go 코드를 만들어 내는가"가 diff로 보인다. 대신 CI에서
sqlc generate 후 git diff --exit-code로 최신인지 확인한다.
:::
sqlc의 한계
동적 쿼리를 못 만든다. "조건이 있으면 WHERE 추가"가 안 된다. 쿼리가 SQL 파일에
고정되어 있기 때문이다. 우회로는 WHERE (@name = '' OR name = @name) 같은 트릭인데
인덱스를 못 쓰게 되기 쉽다. 검색 필터가 많은 화면에서는 결국 쿼리 빌더가 따로 필요해진다.
도구를 설치해야 한다. go run ...@version으로 쓸 수 있어 부담은 적지만,
빌드 파이프라인에 단계가 하나 는다.
스키마 파일을 최신으로 유지해야 한다. golang-migrate와 함께 쓰면
migrations/ 디렉터리를 schema로 지정할 수 있어 이 문제가 상당히 줄어든다.
DB 방언을 정확히 안다는 것이 장점이자 제약이다. PostgreSQL 지원이 가장 좋고, MySQL과 SQLite는 지원되는 문법이 좀 더 제한적이다.
무엇을 고를 것인가
기술적 우열이 아니라 프로젝트의 성격 문제다.
GORM이 맞는 경우. CRUD가 대부분인 관리 도구, 내부 백오피스, 프로토타입. 테이블 수는 많은데 쿼리는 단순한 경우. 팀에 다른 언어의 ORM 경험이 있는 경우. 관계를 따라다니는 코드가 많은 경우.
sqlc가 맞는 경우. 쿼리가 복잡한 서비스. 성능이 중요한 경로. SQL을 잘 아는 팀. 스키마가 안정적인 경우. "이 코드가 무슨 SQL을 날리는가"를 항상 알아야 하는 경우. 개인적으로 새 프로젝트에 가장 먼저 고려할 만한 선택지다.
sqlx가 맞는 경우. 이미 database/sql로 짠 코드가 있고 점진적으로 개선하려는 경우.
동적 쿼리가 많아 sqlc로는 표현이 안 되는 경우. 도구 추가 없이 지금 당장 편해지고
싶은 경우.
섞어 쓰는 것이 이상하지 않다. 실제로 흔하다. CRUD는 GORM으로, 무거운 리포트
쿼리는 sqlc나 원시 SQL로. GORM도 db.Raw(...).Scan(&dto)를 제공하므로 한 프로젝트
안에서 공존한다. 중요한 것은 경계를 리포지토리 인터페이스로 감싸 두어 위 계층이
어느 쪽인지 모르게 하는 것이다. 그것이 다음 챕터의 주제다.
:::warning "ORM은 느리다"는 잘못된 프레임이다
GORM이 만드는 SQL은 대개 사람이 쓸 법한 SQL과 비슷하다. 느려지는 원인은 리플렉션
오버헤드가 아니라 쿼리를 필요 이상으로 많이 날리는 것(N+1)과
필요 이상으로 많이 가져오는 것(SELECT *)이다. 둘 다 ORM 탓이라기보다
"무슨 SQL이 나가는지 안 보고 쓴 탓"이다. 그래서 11-3부터 계속 로거 이야기를 했다.
바꿔 말하면, sqlc로 옮긴다고 저절로 빨라지지 않는다. 빨라지는 것은 틀린 쿼리를 더 일찍 발견하기 때문이다. :::
흔한 실수
sqlx에서 db 태그를 생략한다. AuthorID가 authorid로 매핑되어
author_id를 못 찾는다. 스네이크 케이스 컬럼이면 태그를 붙인다.
sqlx.In 후 Rebind를 빼먹는다. SQLite/MySQL에서는 우연히 돌아가고
PostgreSQL에서만 깨진다.
IN 절을 문자열 조립으로 만든다. SQL 인젝션이다. sqlx.In이나 sqlc의
배열 파라미터를 쓴다.
sqlc 생성 코드를 손으로 고친다. 다음 generate에서 사라진다.
sqlc 스키마 파일이 실제 DB와 어긋난다. 마이그레이션 디렉터리를 스키마로 지정하면 대부분 해결된다.
도구 선택을 종교로 만든다. 한 프로젝트에서 섞어 써도 된다.
정리
- sqlx는
database/sql에 스캔 편의만 얹은 것이다.Get/Select, 이름 있는 파라미터,sqlx.In. SQL도 트랜잭션도 그대로다. - sqlx의 실패는 런타임이다. 컬럼 불일치는 그 쿼리가 실행돼야 드러난다.
- sqlc는 SQL에서 Go 코드를 생성한다. 스키마의
NOT NULL여부가 Go 타입을 정하고, 쿼리 오타는 생성 단계에서, 결과 타입 변경은 컴파일 단계에서 잡힌다. - sqlc 생성물은 커밋하고, 손으로 고치지 않고, CI에서 최신인지 확인한다.
- sqlc는 동적 쿼리를 못 만든다. 필터가 많은 화면에는 다른 수단이 필요하다.
- 선택 기준은 성능이 아니라 쿼리의 복잡도와 실패를 언제 알고 싶은가다.
- 섞어 쓰는 것이 정상이고, 경계는 리포지토리 인터페이스로 감싼다.
연습문제
-
query.sql에-- name: UpdateAuthorBio :exec를 추가하고generate를 돌려 보자.:one,:many,:exec,:execrows가 각각 어떤 시그니처를 만드는가? 컬럼 이름을 일부러 틀리면 어느 단계에서 무슨 메시지가 나오는가? -
같은 조회를 sqlx와 sqlc로 각각 구현하고, 컬럼을 하나 추가해 보자. 양쪽에서 각각 몇 개의 파일을 고쳐야 하는가? 고치는 것을 빠뜨렸을 때 어느 쪽이 더 일찍 알려 주는가?
-
schema경로를05-gorm-in-practice/migrations로 바꿔 보자. sqlc가.up.sql파일들을 순서대로 적용해 스키마를 재구성하는가?.down.sql은 어떻게 처리되는가? 이 구성이 "스키마 파일이 실제 DB와 어긋나는" 문제를 완전히 없애는가, 아니라면 어떤 경우에 여전히 어긋나는가?