본문으로 건너뛰기

database/sql 기초

이 챕터에서 다루는 것

Go의 database/sql은 SQL을 대신 써 주지 않는다. 커넥션을 관리하고, 값을 안전하게 실어 보내고, 결과를 Go 값으로 옮기는 것까지만 한다. 그 경계가 어디인지 알아야 위에 얹는 GORM이나 sqlc가 무엇을 하고 있는지 보인다.

파트 11의 모든 예제는 SQLite를 쓴다. 서버를 띄울 필요도, Docker도, cgo도 없다. go test 한 번으로 끝난다.

문제 — 드라이버가 표준 라이브러리에 없다

database/sql을 import해도 아무 DB에도 붙을 수 없다. 이 패키지는 인터페이스만 정의하고, 실제로 프로토콜을 말하는 코드는 서드파티 드라이버가 제공한다.

이 설계 덕분에 PostgreSQL로 짠 코드를 MySQL로 옮길 때 SQL 방언을 빼면 Go 코드는 거의 그대로다. 대신 드라이버를 어떻게 "연결"하느냐는 문제가 생긴다. Go에는 런타임 플러그인이 없으므로, 드라이버는 자기 자신을 전역 레지스트리에 등록하는 방식으로 붙는다.

examples/11-database-and-orm/01-database-sql/main.go
import (
"context"
"database/sql"
"errors"
"fmt"
"log"

"example.com/database-and-orm/01-database-sql/catalog"

// 드라이버는 블랭크 import로 등록만 한다. 이 패키지의 코드는
// 드라이버 타입을 직접 참조하지 않는다.
_ "modernc.org/sqlite"
)

_는 "이 패키지의 이름은 안 쓰지만 init()은 돌려 달라"는 뜻이다. 드라이버의 init()sql.Register("sqlite", ...)를 부르고, 그때부터 sql.Open("sqlite", ...)이 동작한다. Java의 Class.forName("com.mysql.jdbc.Driver")이나 Python의 import psycopg2와 목적이 같지만, Go에서는 import 한 줄이 곧 등록이다.

:::warning 이름이 겹치면 프로세스가 죽는다 등록소는 전역이고 이름은 문자열이다. 같은 이름으로 두 번 등록하면 init() 단계에서 패닉이다. 실제로 modernc.org/sqlitegithub.com/glebarez/go-sqlite는 둘 다 "sqlite"로 등록한다. 한 바이너리가 둘 다 import하면:

go run -tags dupdriver ./01-database-sql/dupdriver
panic: sql: Register called twice for driver sqlite

goroutine 1 [running]:
database/sql.Register(...)
/usr/local/go/src/database/sql/sql.go:63 +0x120
modernc.org/sqlite.init.0()
.../modernc.org/sqlite@v1.56.0/sqlite.go:56 +0x38
exit status 2

(실제 출력에는 인자 값과 모듈 캐시 절대 경로가 찍힌다. 빌드마다 달라지는 부분이라 여기서는 줄였다.)

빌드는 성공하고 실행이 실패한다. 파트 11에서 database/sql 챕터와 GORM 챕터가 서로 다른 SQLite 드라이버를 쓰는 이유이자, 둘을 한 패키지에 섞지 않는 이유다. :::

왜 cgo 없는 드라이버인가

SQLite의 정석 Go 드라이버는 github.com/mattn/go-sqlite3인데, C 라이브러리를 cgo로 감싼 것이다. cgo를 켜면 세 가지가 따라온다. 빌드에 C 컴파일러가 필요하고, 크로스 컴파일이 갑자기 어려워지며, 첫 빌드가 느려진다.

modernc.org/sqlite는 SQLite의 C 코드를 Go로 기계 번역한 것이라 cgo가 필요 없다. CGO_ENABLED=0으로도 빌드되고, GOOS=linux go build가 맥에서 그대로 돈다. 이 강의의 예제가 아무 준비 없이 돌아가는 이유이기도 하다.

드라이버이 강의에서 쓰는 곳
SQLite (cgo 없음)modernc.org/sqlite v1.56.011-1, 11-2, 11-6
SQLite + GORM (cgo 없음)github.com/glebarez/sqlite v1.11.011-3 ~ 11-5, 11-7
PostgreSQLgithub.com/jackc/pgx/v5
MySQLgithub.com/go-sql-driver/mysql

PostgreSQL이나 MySQL로 옮길 때 바뀌는 것은 세 가지다. import하는 드라이버, DSN 문자열, 그리고 플레이스홀더 문법이다. SQLite와 MySQL은 ?, PostgreSQL은 $1, $2를 쓴다. 이 차이가 나중에 sqlx와 sqlc를 고를 때 다시 나온다(11-6).

sql.Open은 연결하지 않는다

가장 자주 오해하는 지점이다.

examples/11-database-and-orm/01-database-sql/main.go
// sql.Open은 연결하지 않는다. DSN을 파싱하고 풀 객체만 만든다.
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
log.Fatalf("sql.Open: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
log.Printf("db.Close: %v", err)
}
}()
db.SetMaxOpenConns(1)

// 실제로 붙는 것은 여기서다. 설정 오류는 Open이 아니라 Ping이 알려 준다.
if err := db.PingContext(ctx); err != nil {
log.Fatalf("Ping: %v", err)
}

sql.Open이 돌려주는 *sql.DB연결 하나가 아니라 커넥션 풀이다. 이름이 DB라서 "데이터베이스 핸들 하나"처럼 보이지만, 내부에는 유휴 커넥션 목록과 대기 큐가 있다. 결과가 세 가지다.

  • *sql.DB는 고루틴에서 동시에 써도 안전하다. 요청마다 sql.Open을 부르면 풀을 요청마다 새로 만드는 셈이라, 커넥션이 폭증하고 재사용 이득이 사라진다. 프로그램당 하나 만들어서 돌려 쓴다.
  • 비밀번호가 틀려도 Open은 성공한다. 실제 접속은 첫 쿼리나 Ping 때 일어난다. 기동 시점에 설정 오류를 알고 싶다면 PingContext를 명시적으로 부른다.
  • Close는 프로그램이 끝날 때 한 번이다. 쿼리마다 닫는 것이 아니다.

:::note :memory:는 커넥션마다 다른 DB다 SQLite의 인메모리 DB는 커넥션에 붙어 있다. 풀이 두 번째 커넥션을 열면 테이블이 하나도 없는 새 DB를 보게 된다. 예제가 SetMaxOpenConns(1)을 부르는 이유가 이것이다. 파일 DB나 PostgreSQL에서는 절대 이렇게 하면 안 된다 — 동시성이 1이 되어 버린다. 커넥션이 둘 이상 필요한 실험은 11-2에서 파일 DB로 한다. :::

Exec, QueryRow, Query

세 개뿐이다. 결과 행이 없으면 Exec, 한 행이면 QueryRow, 여러 행이면 Query다.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// Insert는 상품 하나를 넣고 부여된 id를 돌려준다.
//
// 값을 문자열로 붙이지 않고 ?로 넘긴다. 이 규칙 하나가 SQL 인젝션의
// 대부분을 막는다. 드라이버는 ?를 프로토콜 수준의 파라미터로 보내므로
// 사용자 입력이 SQL 문법으로 해석될 여지가 없다.
func (s *Store) Insert(ctx context.Context, p Product) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO products (name, price, discount_rate) VALUES (?, ?, ?)`,
p.Name, p.Price, p.DiscountRate)
if err != nil {
return 0, fmt.Errorf("catalog: 등록: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("catalog: 생성된 id 읽기: %w", err)
}
return id, nil
}

ExecContext가 돌려주는 sql.Result에는 LastInsertId()RowsAffected() 두 개가 있다. 둘 다 드라이버가 지원해야 동작하고, LastInsertId는 PostgreSQL에서 지원되지 않는다. PostgreSQL에서는 INSERT ... RETURNING id를 쓰고 QueryRow로 받는다.

:::tip 항상 Context 붙은 버전을 쓴다 Exec, Query, QueryRow, Begin은 모두 ...Context 짝이 있다. 컨텍스트 없는 쪽은 context.Background()를 넣어 부르는 것과 같다. 요청이 취소돼도 쿼리가 계속 도는 것을 막으려면 컨텍스트 버전을 써야 한다. 파트 7에서 만든 취소 사슬이 DB까지 이어지는 지점이다. :::

한 행을 읽을 때는 QueryRow가 낫다. rows.Close()를 챙길 필요가 없다.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// ByID는 한 행을 읽는다.
//
// 결과가 한 행뿐이면 QueryRow가 낫다. rows.Close를 챙길 필요가 없고,
// 행이 없으면 Scan이 sql.ErrNoRows를 준다.
func (s *Store) ByID(ctx context.Context, id int64) (Product, error) {
var p Product
err := s.db.QueryRowContext(ctx,
`SELECT id, name, price, discount_rate FROM products WHERE id = ?`, id).
Scan(&p.ID, &p.Name, &p.Price, &p.DiscountRate)
if errors.Is(err, sql.ErrNoRows) {
return Product{}, ErrNotFound
}
if err != nil {
return Product{}, fmt.Errorf("catalog: 조회: %w", err)
}
return p, nil
}

sql.ErrNoRows를 그대로 위로 흘리지 않고 도메인 에러로 바꾸는 것이 중요하다. 그렇게 하지 않으면 HTTP 핸들러가 database/sql을 import하게 되고, 저장소를 GORM으로 바꾸는 순간 핸들러가 깨진다. 파트 4에서 배운 에러 래핑을 경계에서 쓰는 것이다.

rows.Close와 rows.Err — 둘 다 필요하다

여러 행을 읽을 때는 정해진 형태가 있다.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// CheaperThan은 조건에 맞는 상품을 id 순으로 모은다.
func (s *Store) CheaperThan(ctx context.Context, max int64) ([]Product, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, price, discount_rate FROM products WHERE price < ? ORDER BY id`, max)
if err != nil {
return nil, fmt.Errorf("catalog: 목록 조회: %w", err)
}
// Close는 반드시 걸어 둔다. 빼먹으면 커넥션이 풀로 돌아오지 않는다.
defer rows.Close()

var out []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.DiscountRate); err != nil {
return nil, fmt.Errorf("catalog: 행 스캔: %w", err)
}
out = append(out, p)
}
// rows.Next가 false를 준 이유가 "끝"인지 "에러"인지는 rows.Err만 안다.
// 이 한 줄을 빼면 네트워크가 끊긴 것을 정상 종료로 착각한다.
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("catalog: 순회 중단: %w", err)
}
return out, nil
}

두 줄이 초심자에게 잘 안 보인다.

defer rows.Close()rows가 살아 있는 동안 커넥션 하나가 묶여 있다. 닫지 않으면 그 커넥션은 영영 풀로 돌아오지 않는다. 이런 코드가 몇 군데 있으면 SetMaxOpenConns만큼 누적된 뒤 모든 쿼리가 멈춘다. 원인을 찾기 가장 어려운 종류의 장애다. 다만 rows.Next()가 끝까지 돌아 false를 주면 rows는 자동으로 닫히므로, defer는 중간에 return하는 경로를 위한 보험이다. 그리고 그 경로는 항상 생긴다.

rows.Err()rows.Next()bool 하나만 준다. "행이 없다"와 "읽는 도중 커넥션이 끊겼다"가 둘 다 false다. rows.Err()를 확인하지 않으면 절반만 읽은 결과를 완전한 결과로 착각한다. 파트 9-1에서 io.Readern > 0 && err == io.EOF를 다룰 때와 같은 종류의 함정이다. 에러를 물어봐야 알려 준다.

:::danger Scan의 인자 순서는 컴파일러가 검사하지 않는다 Scan(&p.ID, &p.Name, ...)SELECT 목록의 순서와 정확히 맞아야 한다. nameprice처럼 타입이 다르면 런타임 에러라도 나지만, string 컬럼 둘의 순서를 바꾸면 아무 에러 없이 값만 뒤바뀐다. 컬럼을 추가할 때마다 모든 Scan 호출을 손으로 고쳐야 한다는 것이 database/sql을 직접 쓰는 가장 큰 비용이고, sqlx와 sqlc가 각각 다른 방식으로 없애려는 것이 바로 이 비용이다. :::

플레이스홀더 — 이스케이프가 아니다

SQL 인젝션 대책을 "따옴표를 이스케이프한다"로 이해하고 있다면 고쳐야 한다. 플레이스홀더는 이스케이프가 아니라 분리다. SQL 문장과 값이 서로 다른 경로로 DB에 도착하므로, 값이 무슨 글자를 담고 있든 문법으로 해석될 기회가 없다.

같은 검색을 두 방식으로 만들어 비교해 보자.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// SearchUnsafe는 일부러 취약하게 만든 검색이다. 절대 따라 쓰지 마라.
// 11-1 본문에서 인젝션이 실제로 통하는 것을 보여 주는 용도로만 존재한다.
func (s *Store) SearchUnsafe(ctx context.Context, name string) ([]Product, error) {
q := `SELECT id, name, price, discount_rate FROM products WHERE name = '` + name + `' ORDER BY id`
return s.query(ctx, q)
}

// SearchSafe는 같은 검색을 플레이스홀더로 한다.
func (s *Store) SearchSafe(ctx context.Context, name string) ([]Product, error) {
q := `SELECT id, name, price, discount_rate FROM products WHERE name = ? ORDER BY id`
return s.query(ctx, q, name)
}

입력으로 ' OR 1=1 --를 준다. 앞쪽 함수에서는 이 문자열이 SQL 문법의 일부가 되어 WHERE name = '' OR 1=1 --'이 되고, 조건이 항상 참이 되면서 테이블 전체가 나온다.

== SQL 인젝션 ==
입력: ' OR 1=1 --
문자열 연결 → 3건 (테이블 전체가 새어 나갔다)
플레이스홀더 → 0건 (그런 이름의 상품이 없다)

뒤쪽은 ' OR 1=1 --라는 이름을 가진 상품을 찾는다. 그런 상품이 없으니 0건이다. 정확히 우리가 원하는 동작이다.

:::warning 플레이스홀더로 대체할 수 없는 것 플레이스홀더는 자리에만 쓸 수 있다. 테이블 이름, 컬럼 이름, ORDER BY의 방향, LIMIT 앞의 키워드는 값이 아니라 문법이다.

// 이건 동작하지 않는다 — 컬럼 이름은 값이 아니다
db.Query(`SELECT * FROM products ORDER BY ?`, userInput)

정렬 컬럼을 사용자가 고르게 하려면 허용 목록을 두는 수밖에 없다. map[string]string{"price": "price", "name": "name"} 같은 것으로 입력을 실제 컬럼 이름으로 번역하고, 없으면 거절한다. 문자열을 검사해서 통과시키는 방식은 언제나 우회당한다. :::

NULL

Go의 제로값과 SQL의 NULL은 다른 개념이다. int 컬럼이 NULL이면 0이 아니라 "값이 없음"이고, Scanint에 그것을 넣을 방법이 없어 실패한다.

NULL을 float64로 스캔: sql: Scan error on column index 0, name "discount_rate": converting NULL to float64 is unsupported

방법은 두 가지다. 포인터를 쓰거나 **sql.Null[T]**를 쓴다.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// Product는 products 한 행이다.
//
// DiscountRate가 *float64인 이유는 NULL을 표현해야 하기 때문이다.
// float64로 두면 NULL과 0.0이 같은 값이 되어 버린다.
type Product struct {
ID int64
Name string
Price int64
DiscountRate *float64
}

포인터는 JSON으로 그대로 내보낼 때 편하다(9-4에서 new(expr)로 다룬 그 "0과 미설정을 구분하기" 문제와 같다). 대신 쓸 때마다 nil 검사가 필요하다.

sql.Null[T]는 제네릭이라 어떤 타입에도 쓸 수 있다. 예전 코드에서 보이는 sql.NullString, sql.NullInt64는 제네릭 이전의 형태로, 지금도 동작하지만 새로 쓸 이유는 없다.

examples/11-database-and-orm/01-database-sql/catalog/catalog.go
// AverageDiscount는 집계 결과가 NULL일 수 있는 경우를 보여 준다.
//
// 행이 하나도 없으면 AVG는 NULL을 준다. 받는 쪽이 float64면 스캔이 실패한다.
// sql.Null[float64]는 "값이 있었는지"를 Valid로 알려 준다.
func (s *Store) AverageDiscount(ctx context.Context) (sql.Null[float64], error) {
var avg sql.Null[float64]
err := s.db.QueryRowContext(ctx,
`SELECT AVG(discount_rate) FROM products WHERE discount_rate IS NOT NULL`).Scan(&avg)
if err != nil {
return sql.Null[float64]{}, fmt.Errorf("catalog: 평균 할인율: %w", err)
}
return avg, nil
}

집계 함수는 특히 조심해야 한다. COUNT는 행이 없어도 0을 주지만 AVG, SUM, MAX, MINNULL을 준다. SUMint64로 받는 코드는 데이터가 있는 동안 잘 돌다가, 조건에 맞는 행이 하나도 없는 날 처음 터진다.

:::tip 스키마에서 NULL을 줄이는 편이 낫다 가장 좋은 대응은 애초에 NULL 가능 컬럼을 줄이는 것이다. NOT NULL DEFAULT 0으로 정의할 수 있는 컬럼이라면 그렇게 한다. 정말로 "값이 없음"이 도메인적으로 의미 있는 컬럼에만 NULL을 허용하고, 그때는 Go 쪽도 포인터나 sql.Null[T]로 그 의미를 드러낸다. 11-6에서 sqlc가 스키마의 NOT NULL 여부만 보고 Go 타입을 string으로 할지 sql.NullString으로 할지 정하는 것을 보게 된다. :::

전체 실행

go run ./01-database-sql
== 연결 ==
Ping 성공, 드라이버: sqlite

== 등록 ==
id=1 name=키보드
id=2 name=마우스
id=3 name=모니터

== QueryRow ==
id=2 name=마우스 price=32000 discount=NULL
없는 id: catalog: 상품 없음 | ErrNotFound: true

== Query ==
id=1 name=키보드 price=89000 discount=0.10
id=2 name=마우스 price=32000 discount=NULL

== SQL 인젝션 ==
입력: ' OR 1=1 --
문자열 연결 → 3건 (테이블 전체가 새어 나갔다)
플레이스홀더 → 0건 (그런 이름의 상품이 없다)

== NULL ==
평균 할인율: valid=true value=0.10
NULL을 float64로 스캔: sql: Scan error on column index 0, name "discount_rate": converting NULL to float64 is unsupported

흔한 실수

sql.Open을 요청마다 부른다. 풀을 요청마다 만드는 것이다. 프로그램당 하나 만들어 주입한다.

rows.Close()를 빼먹는다. 커넥션이 새고, 부하가 올라간 뒤에야 증상이 나온다. defererr 검사 다음 줄에 둔다. rows가 nil인데 Close를 부르면 패닉이다.

rows.Err()를 빼먹는다. 부분 결과를 완전한 결과로 착각한다.

sql.ErrNoRows를 위로 흘린다. 상위 계층이 database/sql에 묶인다. 경계에서 도메인 에러로 바꾼다.

NULL 컬럼을 비포인터로 받는다. 데이터가 없는 날 처음 터진다.

Scan 인자 순서와 SELECT 순서가 어긋난다. 타입이 같으면 조용히 값이 섞인다. SELECT * 대신 컬럼을 명시하면 최소한 컬럼 추가에 안전해진다.

정리

  • database/sql은 인터페이스만 있고 드라이버는 서드파티다. 블랭크 import가 전역 등록소에 드라이버를 꽂는다. 이름이 겹치면 init()에서 패닉이다.
  • *sql.DB는 커넥션 하나가 아니라 풀이다. 동시에 써도 안전하고, 프로그램당 하나다.
  • sql.Open은 연결하지 않는다. 접속 오류는 Ping이나 첫 쿼리가 알려 준다.
  • rows.Close()rows.Err()는 둘 다 필요하다. 하나는 커넥션 누수를, 다른 하나는 조용한 부분 결과를 막는다.
  • 플레이스홀더는 이스케이프가 아니라 분리다. 값 자리에만 쓸 수 있고, 컬럼 이름에는 허용 목록이 답이다.
  • NULL은 제로값이 아니다. 포인터나 sql.Null[T]로 받는다. 집계 함수는 결과가 없을 때 NULL이다.
  • cgo 없는 드라이버를 고르면 크로스 컴파일과 빌드가 단순해진다.

연습문제

  1. CheaperThan에서 defer rows.Close()를 지우고, SetMaxOpenConns(1)인 채로 중간에 return하는 경로를 타게 만들어 보자. 다음 쿼리는 어떻게 되는가? db.Stats()의 어느 필드가 그 상태를 보여 주는가? (힌트: 컨텍스트에 타임아웃을 걸어 두지 않으면 영원히 기다린다)

  2. ORDER BY의 컬럼을 사용자가 고를 수 있게 만들어 보자. 플레이스홀더는 쓸 수 없다. 허용 목록을 어디에 두어야 컬럼이 추가될 때 빠뜨리지 않겠는가? 잘못된 입력에 기본값을 쓰는 것과 에러를 주는 것 중 어느 쪽이 나은가, 그리고 그 판단의 근거는 무엇인가?

  3. productsNOT NULL 없는 description TEXT 컬럼을 추가하고, 검색 결과를 JSON으로 내보내 보자. *string으로 받았을 때와 sql.Null[string]으로 받았을 때 JSON 출력이 어떻게 다른가? (힌트: sql.Null[T]json.Marshaler가 아니다)