database/sql — Go 표준 데이터베이스 인터페이스
Go의 database/sql 패키지는 관계형 데이터베이스를 다루기 위한 표준 인터페이스 를 제공합니다. 특정 DB 엔진에 종속되지 않고, 드라이버를 교체하는 것만으로 MySQL, PostgreSQL, SQLite 등 다양한 데이터베이스를 동일한 코드로 사용할 수 있습니다.
database/sql 패키지 개요
database/sql은 데이터베이스 연결, 쿼리, 트랜잭션을 위한 범용 인터페이스입니다. 실제 DB와의 통신은 드라이버 가 담당하며, 드라이버는 _ import로 등록합니다.
sql.DB (커넥션 풀)
├── sql.Conn (단일 커넥션)
├── sql.Tx (트랜잭션)
└── sql.Stmt (Prepared Statement)
주요 드라이버
| DB | 드라이버 패키지 |
|---|---|
| PostgreSQL | github.com/lib/pq 또는 github.com/jackc/pgx |
| MySQL | github.com/go-sql-driver/mysql |
| SQLite | github.com/mattn/go-sqlite3 |
환경 설정 및 드라이버 등록
# PostgreSQL 드라이버 설치
go get github.com/lib/pq
# MySQL 드라이버 설치
go get github.com/go-sql-driver/mysql
# SQLite 드라이버 설치 (CGO 필요)
go get github.com/mattn/go-sqlite3
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // PostgreSQL 드라이버 등록 (부작용만 필요)
)
func main() {
// DSN(Data Source Name) 형식
// "host=localhost port=5432 user=myuser password=mypass dbname=mydb sslmode=disable"
dsn := "host=localhost port=5432 user=myuser password=mypass dbname=mydb sslmode=disable"
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal("드라이버 파싱 실패:", err)
}
defer db.Close()
// sql.Open은 실제 연결을 하지 않음. Ping으로 연결 확인
if err := db.Ping(); err != nil {
log.Fatal("DB 연결 실패:", err)
}
fmt.Println("DB 연결 성공!")
}
sql.Open()은 연결을 즉시 열지 않습니다. 첫 쿼리 또는Ping()을 호출할 때 실제 연결이 이루어집니다.
커넥션 풀 설정
sql.DB는 내부적으로 커넥션 풀을 관리합니다. 올바른 설정이 성능에 큰 영향을 줍니다.
func setupDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("드라이버 파싱: %w", err)
}
// 최대 오픈 커넥션 수 (기본값: 0 = 무제한)
db.SetMaxOpenConns(25)
// 최대 유휴(idle) 커넥션 수 (기본값: 2)
db.SetMaxIdleConns(25)
// 커넥션 최대 수명 (기본값: 0 = 무제한)
db.SetConnMaxLifetime(5 * time.Minute)
// 유휴 커넥션 최대 유지 시간
db.SetConnMaxIdleTime(10 * time.Minute)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("DB 연결: %w", err)
}
return db, nil
}
테이블 생성 예제
실습을 위한 users 테이블을 생성합니다.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
age INT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CRUD 기본 예제
Create — 데이터 삽입
package main
import (
"context"
"database/sql"
"fmt"
"time"
)
type User struct {
ID int
Name string
Email string
Age int
CreatedAt time.Time
}
// ExecContext: 결과 행이 없는 쿼리(INSERT, UPDATE, DELETE)
func createUser(ctx context.Context, db *sql.DB, name, email string, age int) (int64, error) {
query := `INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id`
var id int64
err := db.QueryRowContext(ctx, query, name, email, age).Scan(&id)
if err != nil {
return 0, fmt.Errorf("사용자 생성 실패: %w", err)
}
return id, nil
}
// MySQL이라면 LastInsertId 사용
func createUserMySQL(ctx context.Context, db *sql.DB, name, email string, age int) (int64, error) {
query := `INSERT INTO users (name, email, age) VALUES (?, ?, ?)`
result, err := db.ExecContext(ctx, query, name, email, age)
if err != nil {
return 0, fmt.Errorf("사용자 생성 실패: %w", err)
}
return result.LastInsertId()
}
Read — 단일 행 조회
func getUserByID(ctx context.Context, db *sql.DB, id int) (*User, error) {
query := `SELECT id, name, email, age, created_at FROM users WHERE id = $1`
user := &User{}
err := db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Name,
&user.Email,
&user.Age,
&user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil // 레코드 없음 (에러가 아님)
}
if err != nil {
return nil, fmt.Errorf("사용자 조회 실패: %w", err)
}
return user, nil
}
Read — 다중 행 조회
func getAllUsers(ctx context.Context, db *sql.DB) ([]User, error) {
query := `SELECT id, name, email, age, created_at FROM users ORDER BY id`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("전체 사용자 조회 실패: %w", err)
}
defer rows.Close() // 반드시 닫아야 커넥션이 풀로 반환됨
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("행 스캔 실패: %w", err)
}
users = append(users, u)
}
// rows.Next() 루프 후 에러 확인 필수
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("행 순회 에러: %w", err)
}
return users, nil
}
Update — 데이터 수정
func updateUserAge(ctx context.Context, db *sql.DB, id, age int) (int64, error) {
query := `UPDATE users SET age = $1 WHERE id = $2`
result, err := db.ExecContext(ctx, query, age, id)
if err != nil {
return 0, fmt.Errorf("나이 수정 실패: %w", err)
}
affected, _ := result.RowsAffected()
return affected, nil
}
Delete — 데이터 삭제
func deleteUser(ctx context.Context, db *sql.DB, id int) error {
query := `DELETE FROM users WHERE id = $1`
result, err := db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("사용자 삭제 실패: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("사용자 ID %d를 찾을 수 없음", id)
}
return nil
}
Prepared Statement
반복 실행되는 쿼리는 Prepared Statement로 사전 컴파일하여 성능과 보안을 높입니다.
func bulkInsertUsers(ctx context.Context, db *sql.DB, users []User) error {
// 쿼리를 한 번만 파싱하고 재사용
stmt, err := db.PrepareContext(ctx, `INSERT INTO users (name, email, age) VALUES ($1, $2, $3)`)
if err != nil {
return fmt.Errorf("PreparedStatement 생성 실패: %w", err)
}
defer stmt.Close()
for _, u := range users {
if _, err := stmt.ExecContext(ctx, u.Name, u.Email, u.Age); err != nil {
return fmt.Errorf("사용자 %s 삽입 실패: %w", u.Name, err)
}
}
return nil
}
트랜잭션
여러 쿼리를 원자적으로 실행할 때 트랜잭션을 사용합니다.
func transferPoints(ctx context.Context, db *sql.DB, fromID, toID, points int) error {
// 트랜잭션 시작
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable, // 격리 수준 설정
ReadOnly: false,
})
if err != nil {
return fmt.Errorf("트랜잭션 시작 실패: %w", err)
}
// defer로 롤백 보장 (Commit 이후에는 에러 발생, 무시됨)
defer tx.Rollback()
// 1. 출처 계정에서 포인트 차감
_, err = tx.ExecContext(ctx,
`UPDATE accounts SET points = points - $1 WHERE id = $2`,
points, fromID,
)
if err != nil {
return fmt.Errorf("포인트 차감 실패: %w", err)
}
// 2. 목적지 계정에 포인트 추가
_, err = tx.ExecContext(ctx,
`UPDATE accounts SET points = points + $1 WHERE id = $2`,
points, toID,
)
if err != nil {
return fmt.Errorf("포인트 추가 실패: %w", err)
}
// 두 쿼리 모두 성공 시 커밋
if err := tx.Commit(); err != nil {
return fmt.Errorf("트랜잭션 커밋 실패: %w", err)
}
return nil
}
NULL 처리 — sql.NullString, sql.NullInt64
DB 컬럼이 NULL을 허용하는 경우 sql.Null* 타입을 사용합니다.
type Product struct {
ID int
Name string
Description sql.NullString // NULL 가능한 컬럼
Price sql.NullFloat64
}
func getProduct(ctx context.Context, db *sql.DB, id int) (*Product, error) {
query := `SELECT id, name, description, price FROM products WHERE id = $1`
p := &Product{}
err := db.QueryRowContext(ctx, query, id).Scan(
&p.ID, &p.Name, &p.Description, &p.Price,
)
if err != nil {
return nil, err
}
// NULL 여부 확인
if p.Description.Valid {
fmt.Println("설명:", p.Description.String)
} else {
fmt.Println("설명 없음")
}
return p, nil
}
완전한 실전 예제 — Repository 패턴
package userrepo
import (
"context"
"database/sql"
"fmt"
"time"
)
type User struct {
ID int
Name string
Email string
Age int
CreatedAt time.Time
}
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, name, email string, age int) (*User, error) {
query := `INSERT INTO users (name, email, age) VALUES ($1, $2, $3)
RETURNING id, name, email, age, created_at`
u := &User{}
err := r.db.QueryRowContext(ctx, query, name, email, age).
Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
query := `SELECT id, name, email, age, created_at FROM users WHERE id = $1`
u := &User{}
err := r.db.QueryRowContext(ctx, query, id).
Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find user by id: %w", err)
}
return u, nil
}
func (r *UserRepository) FindAll(ctx context.Context) ([]User, error) {
query := `SELECT id, name, email, age, created_at FROM users ORDER BY id`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("find all users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
func (r *UserRepository) Update(ctx context.Context, id int, name string, age int) error {
query := `UPDATE users SET name = $1, age = $2 WHERE id = $3`
result, err := r.db.ExecContext(ctx, query, name, age, id)
if err != nil {
return fmt.Errorf("update user: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("user %d not found", id)
}
return nil
}
func (r *UserRepository) Delete(ctx context.Context, id int) error {
query := `DELETE FROM users WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("user %d not found", id)
}
return nil
}
핵심 정리
| 함수 | 용도 |
|---|---|
db.QueryRowContext | 단일 행 반환 쿼리 |
db.QueryContext | 다중 행 반환 쿼리 |
db.ExecContext | 결과 행 없는 쿼리 (INSERT/UPDATE/DELETE) |
db.PrepareContext | 재사용 쿼리 사전 컴파일 |
db.BeginTx | 트랜잭션 시작 |
sql.ErrNoRows | 결과 없음 센티넬 에러 |
rows.Close()는 항상 defer로 호출할 것rows.Err()는 루프 종료 후 반드시 확인할 것- 파라미터 바인딩(
$1,?)으로 SQL Injection을 방지할 것