고 실전 #4 DB 연동 — database/sql과 트랜잭션
#3 JSON 입출력 다음, 데이터를 안에서 영속화하는 차례입니다. DB 연동.
Go의 표준은 — database/sql 패키지입니다. 다른 언어의 ORM 같은 추상화는 표준에 없습니다. SQL을 직접 다루되, 안전하게 다룰 수 있는 도구를 제공합니다.
시작 — 드라이버 등록 #
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal(err)
}
}핵심 두 가지:
_ "github.com/.../stdlib"— 빈 import로 드라이버 등록(side effect)sql.Open자체는 연결 안 함 — 첫 쿼리 또는Ping에서 연결
대표 드라이버:
- PostgreSQL —
jackc/pgx - MySQL —
go-sql-driver/mysql - SQLite —
mattn/go-sqlite3(cgo) 또는modernc.org/sqlite(순수 Go)
단일 행 조회 #
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = $1", 42).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
// 못 찾음
} else if err != nil {
// 다른 에러
}핵심:
$1(또는?, 드라이버에 따라) — placeholder. 값은 별도 인자로- **
Scan**으로 결과를 변수에 넣음 sql.ErrNoRows가 — “결과 없음"의 표준 에러
SQL 인젝션 방어 — placeholder가 표준.
fmt.Sprintf로 SQL 문자열을 만들지 마세요.
여러 행 — Query + Rows
#
rows, err := db.Query("SELECT id, name FROM users WHERE active = $1", true)
if err != nil {
return err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return err
}세 가지를 잊지 말 것:
defer rows.Close()— 안 닫으면 커넥션 누수- **
rows.Next()**로 한 행씩 - **
rows.Err()**로 마지막 검사 — 루프 중 발생한 에러 확인
Insert / Update — Exec
#
result, err := db.Exec(
"INSERT INTO users (name, email) VALUES ($1, $2)",
"이도경", "x@y.z",
)
if err != nil {
return err
}
id, _ := result.LastInsertId() // MySQL 등
affected, _ := result.RowsAffected()**LastInsertId**는 — DB에 따라 동작이 다릅니다. PostgreSQL은 — RETURNING 절을 쓰는 게 표준입니다.
var id int
err := db.QueryRow(
"INSERT INTO users (name) VALUES ($1) RETURNING id",
"이도경",
).Scan(&id)Context와 함께 #
핸들러에서 받은 context를 — DB 호출에도 전달합니다.
func GetUser(ctx context.Context, db *sql.DB, id int) (User, error) {
var u User
err := db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name)
return u, err
}QueryContext, ExecContext, QueryRowContext — 모두 ctx 받는 버전이 있습니다. 요청이 취소되면 쿼리도 취소 (드라이버가 지원하는 경우 — 대부분의 메이저 드라이버는 OK).
중급 #5 context에서 본 패턴이 — DB까지 그대로 이어집니다.
Prepared statement #
같은 쿼리를 여러 번 실행할 때 — DB가 SQL 파싱/계획을 캐시할 수 있게.
stmt, err := db.Prepare("SELECT name FROM users WHERE id = $1")
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
var name string
stmt.QueryRow(id).Scan(&name)
// ...
}다만 — Go의 database/sql은 단일 쿼리도 자동으로 prepared으로 처리합니다(드라이버에 따라). 명시적 Prepare가 필요한 경우는 — 같은 쿼리를 짧은 시간에 매우 자주 부르는 경우 정도.
트랜잭션 #
func transfer(ctx context.Context, db *sql.DB, from, to int, amount int) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // commit 안 되면 자동 rollback
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, from,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, to,
); err != nil {
return err
}
return tx.Commit()
}핵심 패턴:
defer tx.Rollback()— 함수가 commit 없이 끝나면 자동 rollback. commit 후 호출하면 무해(ErrTxDone무시).- 모든 쿼리는 —
tx.ExecContext/tx.QueryContext로
트랜잭션 격리 수준 #
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})기본값은 — DB의 default. 격리 수준이 중요한 경우(돈 이동, 재고 차감 등)는 명시적으로 설정.
NULL 처리 #
var u struct {
ID int
Email sql.NullString // NULL가능
}
err := db.QueryRow(...).Scan(&u.ID, &u.Email)
if u.Email.Valid {
fmt.Println(u.Email.String)
}DB의 NULL을 — Go의 zero value와 구분해야 할 때 sql.NullString, sql.NullInt64, sql.NullTime 사용. JSON에서 봤던 포인터 패턴과 비슷한 동기.
또는 — 포인터 (*string)도 가능하지만, sql.Null* 가 명시적이라 흔합니다.
커넥션 풀 설정 #
*sql.DB 자체가 커넥션 풀입니다. 한 인스턴스를 앱 전체에서 공유.
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)전형적 권장:
- MaxOpenConns — DB가 견딜 수 있는 한도, 보통 10~50
- MaxIdleConns — MaxOpen과 같게 두는 게 보통
- ConnMaxLifetime — 5~30 분 (load balancer 뒤에 있을 때 갱신)
- ConnMaxIdleTime — 1~10 분
자주 만나는 함정 #
1) rows.Close() 누락 — 커넥션 누수
#
rows, _ := db.Query(...)
// for 루프 중에 일찍 return → rows 안 닫힘 → 커넥션 점유
for rows.Next() {
if some_condition {
return // ✗ rows.Close 안 됨
}
}해결 — 항상 defer rows.Close() 첫 줄에.
2) Scan의 타입 불일치 #
var n int
db.QueryRow("SELECT amount FROM x").Scan(&n)
// amount가 NUMERIC이면 — drift가능, 에러 가능DB 컬럼 타입과 Go 타입을 — 정확히 매핑. 모호하면 string으로 받아 변환.
3) 트랜잭션 안에 일반 db 호출 #
tx, _ := db.BeginTx(ctx, nil)
db.Exec(...) // ✗ tx가 아닌 일반 커넥션 사용
tx.Commit()트랜잭션 안에서는 — 반드시 tx.*를 통해. 일반 db.*는 다른 커넥션에서 동작해 트랜잭션 밖.
위층 도구 #
database/sql만으로 충분한 경우도 있지만 — 종종 다음 도구들이 등장.
sqlx
#
type User struct {
ID int `db:"id"`
Name string `db:"name"`
}
var users []User
err := sqlx.SelectContext(ctx, db, &users, "SELECT id, name FROM users")표준 database/sql 위에 — struct 자동 매핑, Get / Select 헬퍼만 추가. 마찰이 가장 적은 추가 도구.
sqlc
#
-- name: GetUser :one
SELECT id, name FROM users WHERE id = $1;sqlc generate→ 위 SQL에 대응하는 Go 함수가 자동 생성. SQL을 그대로 쓰되 타입 안전성 확보. 고급 #7의 코드 생성 사례.
요즘 Go 백엔드에서 인기를 얻고 있습니다 — SQL을 직접 쓰면서 보일러플레이트를 줄입니다.
GORM #
type User struct {
gorm.Model
Name string
Email string
}
db.Where("email = ?", "x@y.z").First(&user)다른 언어의 ORM처럼 — 메서드 체이닝, 연관 관계 자동 처리. 편하지만 — SQL이 안 보이면 성능 문제 추적이 어려움, 마법 같은 동작이 많아 마찰. Go 커뮤니티에선 호불호가 갈리는 도구.
어느 도구를 고를까? #
| 상황 | 추천 |
|---|---|
| 작은 서비스, SQL 수기 OK | database/sql |
| 보일러플레이트만 줄이고 싶음 | sqlx |
| SQL 직접 + 타입 안전성 | sqlc |
| 다른 ORM 익숙, 빠른 개발 | GORM |
주관: 표준 →
sqlx→sqlc의 순서가 자연스럽습니다. GORM은 다른 컨텍스트에서 옮겨오신 분께 친근하지만 — Go의 명시적 디자인과는 다소 거리가 있어 트레이드오프.
마이그레이션 #
스키마 변경 관리는 — Go 표준에 없습니다. 보통:
golang-migrate/migrate— 가장 흔함pressly/goose— Go 코드로 마이그레이션 작성 가능
migrate -database "postgres://..." -path migrations upmigrations/0001_init.up.sql / 0001_init.down.sql 파일을 차례로 적용.
마무리 #
이번 글에서 정리한 내용:
database/sql— 표준, 드라이버는 별도 importQuery/QueryRow/Exec— 메서드 셋- placeholder 사용 — SQL 인젝션 방어
Context버전 항상 사용 — 요청 취소 전파- 트랜잭션 —
BeginTx+defer Rollback+ 마지막Commit - NULL —
sql.Null*또는 포인터 - 풀 설정 — MaxOpenConns, ConnMaxLifetime 등
- 위층 도구 —
sqlx(가벼움),sqlc(코드 생성), GORM (ORM) - 마이그레이션 —
golang-migrate,goose
다음 글(#5 미들웨어)에서는 — Go의 미들웨어 패턴. http.Handler 어댑터 체인이 어떻게 인증, 로깅, 패닉 복구를 깔끔히 풀어내는지 정리합니다.