고 실전 #4 DB 연동 — database/sql과 트랜잭션

5 분 소요

#3 JSON 입출력 다음, 데이터를 안에서 영속화하는 차례입니다. DB 연동.

Go의 표준은 — database/sql 패키지입니다. 다른 언어의 ORM 같은 추상화는 표준에 없습니다. SQL을 직접 다루되, 안전하게 다룰 수 있는 도구를 제공합니다.

시작 — 드라이버 등록 #

PostgreSQL 연결
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에서 연결

대표 드라이버:

  • PostgreSQLjackc/pgx
  • MySQLgo-sql-driver/mysql
  • SQLitemattn/go-sqlite3 (cgo) 또는 modernc.org/sqlite (순수 Go)

단일 행 조회 #

QueryRow
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 #

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 절을 쓰는 게 표준입니다.

PostgreSQL RETURNING
var id int
err := db.QueryRow(
	"INSERT INTO users (name) VALUES ($1) RETURNING id",
	"이도경",
).Scan(&id)

Context와 함께 #

핸들러에서 받은 context를 — DB 호출에도 전달합니다.

Context 버전
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 파싱/계획을 캐시할 수 있게.

Prepare
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

트랜잭션 격리 수준 #

isolation 지정
tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelSerializable,
	ReadOnly:  false,
})

기본값은 — DB의 default. 격리 수준이 중요한 경우(돈 이동, 재고 차감 등)는 명시적으로 설정.

NULL 처리 #

sql.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 #

sqlx의 StructScan
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 #

query.sql
-- name: GetUser :one
SELECT id, name FROM users WHERE id = $1;
sqlc generate

→ 위 SQL에 대응하는 Go 함수가 자동 생성. SQL을 그대로 쓰되 타입 안전성 확보. 고급 #7의 코드 생성 사례.

요즘 Go 백엔드에서 인기를 얻고 있습니다 — SQL을 직접 쓰면서 보일러플레이트를 줄입니다.

GORM #

GORM ORM
type User struct {
	gorm.Model
	Name  string
	Email string
}

db.Where("email = ?", "x@y.z").First(&user)

다른 언어의 ORM처럼 — 메서드 체이닝, 연관 관계 자동 처리. 편하지만 — SQL이 안 보이면 성능 문제 추적이 어려움, 마법 같은 동작이 많아 마찰. Go 커뮤니티에선 호불호가 갈리는 도구.

어느 도구를 고를까? #

상황추천
작은 서비스, SQL 수기 OKdatabase/sql
보일러플레이트만 줄이고 싶음sqlx
SQL 직접 + 타입 안전성sqlc
다른 ORM 익숙, 빠른 개발GORM

주관: 표준 → sqlxsqlc의 순서가 자연스럽습니다. GORM은 다른 컨텍스트에서 옮겨오신 분께 친근하지만 — Go의 명시적 디자인과는 다소 거리가 있어 트레이드오프.

마이그레이션 #

스키마 변경 관리는 — Go 표준에 없습니다. 보통:

  • golang-migrate/migrate — 가장 흔함
  • pressly/goose — Go 코드로 마이그레이션 작성 가능
golang-migrate
migrate -database "postgres://..." -path migrations up

migrations/0001_init.up.sql / 0001_init.down.sql 파일을 차례로 적용.

마무리 #

이번 글에서 정리한 내용:

  • database/sql — 표준, 드라이버는 별도 import
  • Query / QueryRow / Exec — 메서드 셋
  • placeholder 사용 — SQL 인젝션 방어
  • Context 버전 항상 사용 — 요청 취소 전파
  • 트랜잭션BeginTx + defer Rollback + 마지막 Commit
  • NULLsql.Null* 또는 포인터
  • 풀 설정 — MaxOpenConns, ConnMaxLifetime 등
  • 위층 도구 — sqlx (가벼움), sqlc (코드 생성), GORM (ORM)
  • 마이그레이션 — golang-migrate, goose

다음 글(#5 미들웨어)에서는 — Go의 미들웨어 패턴. http.Handler 어댑터 체인이 어떻게 인증, 로깅, 패닉 복구를 깔끔히 풀어내는지 정리합니다.

X