데이터베이스
Node.js에서 데이터베이스를 다루려면 먼저 드라이버를 고르고, 커넥션 풀 라이브러리를 붙이고, 필요하면 ORM이나 query builder를 얹는다. pg, mysql2, knex, prisma, typeorm 등 선택지가 많다. Go는 database/sql이라는 표준 인터페이스가 있다. 드라이버만 바꾸면 PostgreSQL이든 MySQL이든 동일한 API로 다룬다. 커넥션 풀도 내장이다.
database/sql — 표준 인터페이스
database/sql은 데이터베이스 작업의 추상 계층이다. 실제 데이터베이스와 통신하는 것은 드라이버 패키지의 몫이다. PostgreSQL을 사용한다면:
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // PostgreSQL 드라이버 등록
)
func main() {
db, err := sql.Open("postgres", "host=localhost port=5432 user=app dbname=mydb sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal(err)
}
fmt.Println("connected")
}
_ "github.com/lib/pq"는 blank import다. 패키지의 init() 함수만 실행하여 드라이버를 database/sql에 등록한다. sql.Open은 실제로 연결을 만들지 않는다. 연결은 첫 번째 쿼리 실행 시 또는 Ping() 호출 시 생성된다.
Node.js에서 pg를 사용하는 경우:
const { Pool } = require("pg");
const pool = new Pool({
host: "localhost",
port: 5432,
user: "app",
database: "mydb",
});
const client = await pool.connect();
console.log("connected");
client.release();
Node.js에서는 Pool과 Client가 별개의 개념이다. Go에서는 sql.DB가 이미 풀이다.
커넥션 풀 — sql.DB가 풀이다
sql.DB는 단일 연결이 아니라 커넥션 풀이다. 여러 goroutine에서 동시에 사용해도 안전하다. 풀 설정은 메서드로 조정한다:
db.SetMaxOpenConns(25) // 최대 열린 연결 수
db.SetMaxIdleConns(10) // 최대 유휴 연결 수
db.SetConnMaxLifetime(5 * time.Minute) // 연결 최대 수명
db.SetConnMaxIdleTime(1 * time.Minute) // 유휴 연결 최대 시간
Node.js pg-pool 대응:
const pool = new Pool({
max: 25, // 최대 연결 수
idleTimeoutMillis: 60000,
connectionTimeoutMillis: 5000,
});
Go에서는 풀을 별도로 만들 필요가 없다. sql.Open이 반환하는 *sql.DB를 애플리케이션 전체에서 공유하면 된다. 요청마다 새로 여는 것이 아니다.
CRUD 기본: Query, QueryRow, Exec
단일 행 조회 — QueryRow
var name string
var age int
err := db.QueryRow("SELECT name, age FROM users WHERE id = $1", 42).Scan(&name, &age)
if err == sql.ErrNoRows {
fmt.Println("user not found")
} else if err != nil {
log.Fatal(err)
}
fmt.Println(name, age)
QueryRow는 결과가 없으면 Scan 시 sql.ErrNoRows를 반환한다. $1은 PostgreSQL의 placeholder 문법이다. MySQL은 ?를 사용한다.
Node.js 대응:
const { rows } = await pool.query("SELECT name, age FROM users WHERE id = $1", [42]);
if (rows.length === 0) {
console.log("user not found");
}
Node.js에서는 결과가 없어도 에러가 아니다. 빈 배열이 반환된다. Go에서는 QueryRow + Scan 패턴에서 결과 없음이 명시적 에러다.
여러 행 조회 — Query
rows, err := db.Query("SELECT id, name, age FROM users WHERE age > $1", 20)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id int
var name string
var age int
if err := rows.Scan(&id, &name, &age); err != nil {
log.Fatal(err)
}
fmt.Printf("id=%d name=%s age=%d\n", id, name, age)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
rows.Close()를 반드시 호출해야 한다. 호출하지 않으면 커넥션이 풀로 반환되지 않아 커넥션 고갈이 발생한다. defer rows.Close()가 관례다. rows.Err()는 반복 중 발생한 에러를 확인한다. 네트워크 단절 등으로 중간에 실패할 수 있다.
삽입, 수정, 삭제 — Exec
result, err := db.Exec("INSERT INTO users (name, age) VALUES ($1, $2)", "Alice", 30)
if err != nil {
log.Fatal(err)
}
rowsAffected, _ := result.RowsAffected()
fmt.Println("inserted:", rowsAffected)
Exec은 결과 행을 반환하지 않는 쿼리에 사용한다. INSERT, UPDATE, DELETE가 해당한다. RowsAffected()로 영향받은 행 수를, LastInsertId()로 마지막 삽입 ID를 얻는다. 단, LastInsertId는 드라이버에 따라 지원 여부가 다르다. PostgreSQL은 지원하지 않으며, 대신 RETURNING 절과 QueryRow를 사용한다:
var id int
err := db.QueryRow("INSERT INTO users (name, age) VALUES ($1, $2) RETURNING id", "Alice", 30).Scan(&id)
트랜잭션: sql.Tx
tx, err := db.Begin()
if err != nil {
log.Fatal(err)
}
_, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1)
if err != nil {
tx.Rollback()
log.Fatal(err)
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2)
if err != nil {
tx.Rollback()
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}
tx.Exec은 db.Exec과 동일한 API다. 차이점은 같은 트랜잭션 안에서 실행된다는 것이다. Commit() 또는 Rollback()을 호출해야 트랜잭션이 종료된다.
에러 처리에서 Rollback()을 반복 작성하는 것이 번거롭다면, defer 패턴을 사용한다:
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // Commit 후 Rollback은 no-op
_, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1)
if err != nil {
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, 2)
if err != nil {
return err
}
return tx.Commit()
defer tx.Rollback()을 먼저 걸어두면, Commit() 전에 에러로 반환되었을 때 자동으로 Rollback된다. Commit() 이후의 Rollback()은 아무 일도 하지 않는다.
Node.js 대응:
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [100, 1]);
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [100, 2]);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
Node.js에서는 BEGIN/COMMIT/ROLLBACK을 직접 SQL로 보낸다. Go에서는 Begin(), Commit(), Rollback() 메서드가 이를 추상화한다.
Prepared Statement
동일한 쿼리를 반복 실행할 때 prepared statement를 사용하면 파싱 비용을 줄일 수 있다:
stmt, err := db.Prepare("SELECT name, age FROM users WHERE id = $1")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
var name string
var age int
// 여러 번 실행
err = stmt.QueryRow(1).Scan(&name, &age)
err = stmt.QueryRow(2).Scan(&name, &age)
err = stmt.QueryRow(3).Scan(&name, &age)
db.Prepare는 SQL을 데이터베이스 서버에 미리 파싱해 둔다. 이후 stmt.QueryRow를 호출할 때는 파라미터만 전송한다. 반복 루프에서 같은 쿼리를 수천 번 실행하는 경우에 효과적이다.
실제로는 database/sql이 내부적으로 prepared statement를 캐싱하므로, 명시적으로 Prepare를 호출하지 않아도 성능 차이가 크지 않은 경우가 많다.
sql.Null 타입 — nullable 컬럼 처리
데이터베이스의 NULL은 Go의 zero value와 다르다. age 컬럼이 NULL인데 int로 Scan하면 에러가 발생한다. sql.Null* 타입을 사용한다:
var name string
var bio sql.NullString
err := db.QueryRow("SELECT name, bio FROM users WHERE id = $1", 1).Scan(&name, &bio)
if err != nil {
log.Fatal(err)
}
if bio.Valid {
fmt.Println("bio:", bio.String)
} else {
fmt.Println("bio is NULL")
}
sql.NullString은 String과 Valid 두 필드를 가진다. Valid가 false면 데이터베이스 값이 NULL이었다는 뜻이다. 같은 패턴으로 sql.NullInt64, sql.NullFloat64, sql.NullBool, sql.NullTime 등이 있다.
Go 1.22부터는 제네릭 타입 sql.Null[T]도 사용할 수 있다:
var bio sql.Null[string]
// bio.V (값), bio.Valid (null 여부)
포인터를 사용하는 방법도 있다:
var bio *string
err := db.QueryRow("SELECT bio FROM users WHERE id = $1", 1).Scan(&bio)
// bio가 nil이면 NULL, 아니면 *bio가 값
포인터 방식이 더 간결하지만, JSON 직렬화 시 null로 표현되므로 23편에서 다룬 포인터 필드의 null/부재 구분 문제가 함께 따라온다.
Node.js에서는 NULL이 자연스럽게 null로 매핑된다. 별도의 wrapper 타입이 필요 없다:
const { rows } = await pool.query("SELECT name, bio FROM users WHERE id = $1", [1]);
console.log(rows[0].bio); // null 또는 문자열
Context와 함께: QueryContext, ExecContext
22편에서 다룬 context를 데이터베이스 작업에 적용하면 타임아웃과 취소를 제어할 수 있다. Query, QueryRow, Exec 각각에 대응하는 QueryContext, QueryRowContext, ExecContext가 있다:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var name string
err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", 1).Scan(&name)
if err != nil {
// context.DeadlineExceeded면 타임아웃
log.Fatal(err)
}
HTTP 핸들러에서는 요청의 context를 전달한다. 클라이언트가 연결을 끊으면 쿼리도 취소된다:
func getUser(w http.ResponseWriter, r *http.Request) {
var name string
err := db.QueryRowContext(r.Context(), "SELECT name FROM users WHERE id = $1", 1).Scan(&name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprint(w, name)
}
트랜잭션도 context를 받는다:
tx, err := db.BeginTx(ctx, nil)
프로덕션 코드에서는 Context가 없는 Query, Exec 대신 항상 QueryContext, ExecContext를 사용하는 것이 권장된다. 타임아웃 없는 쿼리는 슬로우 쿼리가 커넥션 풀을 고갈시킬 수 있다.
ORM vs query builder vs raw SQL
Go 생태계에서는 데이터베이스 접근 방식에 대한 선택지가 몇 가지 있다.
database/sql (raw SQL)
표준 라이브러리다. SQL을 직접 작성한다. 위에서 다룬 모든 내용이 여기에 해당한다. 장점은 의존성이 없고, SQL을 완전히 제어할 수 있다는 것이다. 단점은 Scan에서 필드를 하나씩 매핑하는 것이 번거롭다는 것이다.
sqlx — database/sql의 확장
database/sql을 감싼 라이브러리다. 핵심 기능은 struct 자동 매핑이다:
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Age int `db:"age"`
}
// database/sql - 필드를 하나씩 Scan
var u User
err := db.QueryRow("SELECT id, name, age FROM users WHERE id = $1", 1).Scan(&u.ID, &u.Name, &u.Age)
// sqlx - struct로 바로 매핑
var u User
err := db.Get(&u, "SELECT id, name, age FROM users WHERE id = $1", 1)
여러 행도 슬라이스로 받을 수 있다:
var users []User
err := db.Select(&users, "SELECT id, name, age FROM users WHERE age > $1", 20)
SQL을 그대로 쓰면서 Scan의 번거로움만 해결한다. database/sql과 완전히 호환되므로 기존 코드에 점진적으로 도입할 수 있다.
sqlc — SQL에서 Go 코드 생성
SQL 파일을 작성하면 타입 안전한 Go 코드를 생성해 주는 도구다:
-- query.sql
-- name: GetUser :one
SELECT id, name, age FROM users WHERE id = $1;
-- name: ListUsers :many
SELECT id, name, age FROM users WHERE age > $1;
sqlc generate를 실행하면 다음과 같은 Go 코드가 생성된다:
// 자동 생성된 코드
func (q *Queries) GetUser(ctx context.Context, id int) (User, error) { ... }
func (q *Queries) ListUsers(ctx context.Context, age int) ([]User, error) { ... }
SQL을 직접 작성하면서도 컴파일 타임 타입 안전성을 얻는다. SQL 문법 오류도 코드 생성 시점에 잡힌다. 17편에서 다룬 코드 생성 패턴의 실전 사례다.
GORM — ORM
Go에서 가장 널리 사용되는 ORM이다:
type User struct {
ID uint `gorm:"primaryKey"`
Name string
Age int
}
// 조회
var user User
db.First(&user, 1)
// 생성
db.Create(&User{Name: "Alice", Age: 30})
// 조건 조회
var users []User
db.Where("age > ?", 20).Find(&users)
Node.js의 Prisma나 TypeORM과 비슷한 위치다. SQL을 직접 작성하지 않아도 되지만, 복잡한 쿼리에서는 ORM이 생성하는 SQL을 이해해야 한다.
어떤 것을 고를까
| 도구 | SQL 작성 | 타입 안전성 | Node.js 대응 |
|---|---|---|---|
| database/sql | 직접 | 런타임 Scan | pg, mysql2 |
| sqlx | 직접 | 런타임 (struct tag) | - |
| sqlc | 직접 | 컴파일 타임 | - |
| GORM | 자동 생성 | 런타임 (리플렉션) | Prisma, TypeORM |
Go 커뮤니티에서는 ORM보다 SQL을 직접 작성하는 쪽을 선호하는 경향이 있다. database/sql + sqlx로 시작하고, 타입 안전성이 필요하면 sqlc를 도입하는 것이 일반적인 경로다.
마이그레이션
Node.js에서 knex migrate나 Prisma migrate를 사용하는 것처럼, Go에서도 마이그레이션 도구가 필요하다. 대표적인 도구 두 가지:
- golang-migrate — SQL 파일 기반. 데이터베이스에 독립적. CLI와 라이브러리 모두 제공한다.
- goose — SQL 파일 또는 Go 코드로 마이그레이션을 작성할 수 있다.
둘 다 database/sql과 함께 동작한다. 마이그레이션 자체는 언어에 독립적인 SQL 작업이므로, 도구 선택은 취향 차이다.
database/sql은 드라이버만 교체하면 동일한 API로 모든 데이터베이스를 다룰 수 있게 한다. Scan의 번거로움은 sqlx나 sqlc로 해결할 수 있다.