PostgreSQL용 Go ORM 비교: GORM vs Ent vs Bun vs sqlc
Go의 ORM을 코드로 실전으로 살펴보기
GO에서 가장 널리 쓰이는 ORM은 GORM, Ent, Bun, sqlc입니다. 순수 GO로 구현한 CRUD 작업 예제를 함께 비교해 보겠습니다.

TL;DR
- GORM: 기능과 편의성이 뛰어나며 가장 빠르게 제품을 출시(“just ship”)할 수 있지만, 런타임 오버헤드가 비교적 큽니다.
- Ent: 스키마를 코드로 작성하여 타입 안전성이 높은 API를 생성합니다. 대규모 코드베이스와 리팩터링에 적합합니다.
- Bun: 가볍고 SQL을 최우선으로 고려한 쿼리 빌더/ORM입니다. Postgres 기능이 우수하고 설계가 명확하여 빠릅니다.
- sqlc(ORM이라기보다는 여전히): SQL을 작성하면 타입 안전한 Go 코드가 생성됩니다. 최적의 원시 성능과 컨트롤을 제공하며, 런타임 마법이 없습니다.
선택 기준 및 간단 비교
저의 기준은 다음과 같습니다:
- 성능: 지연 시간/처리량, 회피 가능한 오버헤드, 배치 작업.
- DX(개발자 경험): 학습 곡선, 타입 안전성, 디버깅 용이성, 코드 생성 마찰.
- 생태계: 문서, 예제, 활동성, 통합(마이그레이션, 트레이싱).
- 기능 세트: 관계, 즉시 로딩(eager loading), 마이그레이션, 훅, 원시 SQL 탈출구.
| 도구 | 패러다임 | 타입 안전성 | 관계 | 마이그레이션 | 원시 SQL 용이성 | 일반적인 사용 사례 |
|---|---|---|---|---|---|---|
| GORM | 액티브 레코드 스타일 ORM | 중간 (런타임) | 예 (태그, Preload/Joins) | 자동 마이그레이션 (선택적) | db.Raw(...) |
빠른 출시, 풍부한 기능, 정형화된 CRUD 앱 |
| Ent | 스키마 → 코드 생성 → 플루언트 API | 높음 (컴파일 타임) | 1급 시민 (엣지) | 생성된 SQL (별도 단계) | entsql, 사용자 정의 SQL |
대규모 코드베이스, 리팩터링 중심 팀, 엄격한 타이핑 |
| Bun | SQL 우선 쿼리 빌더/ORM | 중간~높음 | 명시적 (Relation) |
별도 마이그레이션 패키지 | 자연스러움 (빌더 + 원시) | 성능 중심 서비스, Postgres 기능 |
| sqlc | SQL → 코드 생성 함수 (ORM 아님) | 높음 (컴파일 타임) | SQL 조인을 통해 | 외부 도구 (예: golang-migrate) | 그 자체로 SQL | 최대 컨트롤 & 속도; DBA 친화적 팀 |
예제를 통한 CRUD
설정 (PostgreSQL)
pgx 또는 해당 도구의 네이티브 PG 드라이버를 사용하세요. DSN 예시:
export DATABASE_URL='postgres://user:pass@localhost:5432/app?sslmode=disable'
임포트 (모든 ORM 공통)
go 코드 예제가 포함된 각 파일의 시작부에 다음을 추가하세요:
import (
"context"
"os"
)
간단한 users 테이블을 모델링하겠습니다:
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
GORM
초기화
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
ID int64 `gorm:"primaryKey"`
Name string
Email string `gorm:"uniqueIndex"`
}
func newGorm() (*gorm.DB, error) {
dsn := os.Getenv("DATABASE_URL")
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
}
// 자동 마이그레이션 (선택적; 프로덕션에서는 주의해야 함)
func migrate(db *gorm.DB) error { return db.AutoMigrate(&User{}) }
CRUD
func gormCRUD(ctx context.Context, db *gorm.DB) error {
// 생성 (Create)
u := User{Name: "Alice", Email: "alice@example.com"}
if err := db.WithContext(ctx).Create(&u).Error; err != nil { return err }
// 조회 (Read)
var got User
if err := db.WithContext(ctx).First(&got, u.ID).Error; err != nil { return err }
// 수정 (Update)
if err := db.WithContext(ctx).Model(&got).
Update("email", "alice+1@example.com").Error; err != nil { return err }
// 삭제 (Delete)
if err := db.WithContext(ctx).Delete(&User{}, got.ID).Error; err != nil { return err }
return nil
}
참고
- 구조체 태그 +
Preload/Joins를 통한 관계 처리. - 트랜잭션 헬퍼:
db.Transaction(func(tx *gorm.DB) error { ... }).
Ent
스키마 정의 (ent/schema/user.go 내):
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
type User struct {
ent.Schema
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int64("id").Unique().Immutable(),
field.String("name"),
field.String("email").Unique(),
}
}
코드 생성
go run entgo.io/ent/cmd/ent generate ./ent/schema
초기화
import (
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql"
_ "github.com/jackc/pgx/v5/stdlib"
"your/module/ent"
)
func newEnt() (*ent.Client, error) {
dsn := os.Getenv("DATABASE_URL")
drv, err := sql.Open(dialect.Postgres, dsn)
if err != nil { return nil, err }
return ent.NewClient(ent.Driver(drv)), nil
}
CRUD
func entCRUD(ctx context.Context, client *ent.Client) error {
// 생성 (Create)
u, err := client.User.Create().
SetName("Alice").
SetEmail("alice@example.com").
Save(ctx)
if err != nil { return err }
// 조회 (Read)
got, err := client.User.Get(ctx, u.ID)
if err != nil { return err }
// 수정 (Update)
if _, err := client.User.UpdateOneID(got.ID).
SetEmail("alice+1@example.com").
Save(ctx); err != nil { return err }
// 삭제 (Delete)
if err := client.User.DeleteOneID(got.ID).Exec(ctx); err != nil { return err }
return nil
}
참고
- 끝-to-end(엔드투엔드) 강한 타이핑; 관계는 엣지(edges)로 처리.
- 생성된 마이그레이션 또는 원하는 마이그레이션 도구를 사용.
Bun
초기화
import (
"database/sql"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
_ "github.com/jackc/pgx/v5/stdlib"
)
type User struct {
bun.BaseModel `bun:"table:users"`
ID int64 `bun:",pk,autoincrement"`
Name string `bun:",notnull"`
Email string `bun:",unique,notnull"`
}
func newBun() (*bun.DB, error) {
dsn := os.Getenv("DATABASE_URL")
sqldb, err := sql.Open("pgx", dsn)
if err != nil { return nil, err }
return bun.NewDB(sqldb, pgdialect.New()), nil
}
CRUD
func bunCRUD(ctx context.Context, db *bun.DB) error {
// 생성 (Create)
u := &User{Name: "Alice", Email: "alice@example.com"}
if _, err := db.NewInsert().Model(u).Exec(ctx); err != nil { return err }
// 조회 (Read)
var got User
if err := db.NewSelect().Model(&got).
Where("id = ?", u.ID).
Scan(ctx); err != nil { return err }
// 수정 (Update)
if _, err := db.NewUpdate().Model(&got).
Set("email = ?", "alice+1@example.com").
WherePK().
Exec(ctx); err != nil { return err }
// 삭제 (Delete)
if _, err := db.NewDelete().Model(&got).WherePK().Exec(ctx); err != nil { return err }
return nil
}
참고
.Relation("...")을 사용한 명시적 조인/즉시 로딩.- 마이그레이션을 위한 별도
bun/migrate패키지.
sqlc
sqlc는 기술적으로 ORM이 아닙니다. SQL을 작성하면 타입이 안전한 Go 메서드를 생성합니다.
sqlc.yaml
version: "2"
sql:
- engine: postgresql
queries: db/queries
schema: db/migrations
gen:
go:
package: db
out: internal/db
sql_package: "database/sql" # 또는 "github.com/jackc/pgx/v5"
쿼리 (db/queries/users.sql)
-- name: CreateUser :one
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, name, email;
-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;
-- name: UpdateUserEmail :one
UPDATE users SET email = $2 WHERE id = $1
RETURNING id, name, email;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
생성
sqlc generate
사용법
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
"your/module/internal/db"
)
func sqlcCRUD(ctx context.Context) error {
dsn := os.Getenv("DATABASE_URL")
sqldb, err := sql.Open("pgx", dsn)
if err != nil { return err }
q := db.New(sqldb)
// 생성 (Create)
u, err := q.CreateUser(ctx, db.CreateUserParams{
Name: "Alice", Email: "alice@example.com",
})
if err != nil { return err }
// 조회 (Read)
got, err := q.GetUser(ctx, u.ID)
if err != nil { return err }
// 수정 (Update)
up, err := q.UpdateUserEmail(ctx, db.UpdateUserEmailParams{
ID: got.ID, Email: "alice+1@example.com",
})
if err != nil { return err }
// 삭제 (Delete)
if err := q.DeleteUser(ctx, up.ID); err != nil { return err }
return nil
}
참고
- 마이그레이션을 직접 가져와야 합니다 (예:
golang-migrate). - 동적 쿼리의 경우: 여러 SQL 변형을 작성하거나 작은 빌더와 결합하여 사용하세요.
성능 참고 사항
- GORM: 편리하지만 리플렉션/추상화 오버헤드를 추가합니다. 일반적인 CRUD에는 적합하지만, N+1 쿼리에 주의해야 합니다 (
Joins또는 선택적Preload권장). - Ent: 생성된 코드는 리플렉션을 피하므로 복잡한 스키마에 적합합니다. 런타임 마법이 많은 무거운 ORM보다 종종 빠릅니다.
- Bun:
database/sql위에 얇게 구현되어 빠르고 명시적이며, 배치 작업과 대용량 결과 세트에 우수합니다. - sqlc: 컴파일 타임 안전성과 함께 원시 SQL 성능에 가깝습니다.
일반적인 팁
- 드라이버로는 pgx (v5)를 사용하고 context를 모든 곳에 사용하세요.
- 높은 처리량을 위해 배칭(
COPY, 다중 행INSERT)을 선호하세요. - SQL 프로파일링:
EXPLAIN ANALYZE, 인덱스, 커버링 인덱스 사용, 불필요한 왕복 회피. - 커넥션 재사용; 워크로드에 따라 풀 크기 조정.
개발자 경험(DX) 및 생태계
- GORM: 가장 큰 커뮤니티, 풍부한 예제/플러그인; 고급 패턴에 대한 학습 곡선은 가파름.
- Ent: 훌륭한 문서; 코드 생성 단계가 주된 사고 방식 전환점; 리팩터링에 매우 적합.
- Bun: 가독적이고 예측 가능한 쿼리; 작지만 활동적인 커뮤니티; 뛰어난 Postgres 편의 기능.
- sqlc: 최소한의 런타임 의존성; 마이그레이션 도구와 CI와 잘 통합; SQL에 익숙한 팀에게 우수.
기능 하이라이트
- 관계 & 즉시 로딩: 모두 관계를 처리하며, GORM(태그 +
Preload/Joins), Ent(엣지 +.With...()), Bun(Relation(...)), sqlc(조인 직접 작성)로 구현. - 마이그레이션: GORM(자동 마이그레이션; 프로덕션 주의), Ent(생성/차분 SQL), Bun(
bun/migrate), sqlc(외부 도구). - 훅/확장성: GORM(콜백/플러그인), Ent(훅/미들웨어 + 템플릿/코드 생성), Bun(미들웨어 유사 쿼리 훅, 쉬운 원시 SQL), sqlc(애플리케이션 레이어에서 구성).
- JSON/배열 (Postgres): Bun과 GORM에 훌륭한 헬퍼가 있으며; Ent/sqlc는 사용자 정의 타입 또는 SQL을 통해 처리.
네 도구 모두에 대한 가로지르는 한 가지 참고 사항: Go 1.27은 이제 encoding/json을 v2 엔진으로 뒷받침합니다. 이는 동작을 유지하지만 일부 에러 텍스트를 변경하며, 명시적 v2 API 하에서는 nil-slice 및 맵 키 출력 방식이 변경됩니다. 모델이 JSON을 왕복하는 경우, 업그레이드 전에 골든 테스트 픽스처에 대해 Go 1.27 업그레이드 가이드에 명시된 사항을 확인해야 합니다.
언제 무엇을 선택해야 하는가
- GORM 선택: 최대 편의성, 풍부한 기능, 정형화된 CRUD 서비스를 위한 빠른 프로토타이핑을 원한다면.
- Ent 선택: 컴파일 타임 안전성, 명시적 스키마, 대규모 팀에서의 장기적 유지보수성을 중시한다면.
- Bun 선택: 성능과 명시적인 SQL 형태 쿼리를 원하되, 도움이 되는 부분은 ORM의 편의성을 원한다면.
- sqlc 선택: (당신과 팀이) 타입이 안전한 Go 바인딩과 제로 런타임 오버헤드를 가진 순수 SQL을 선호한다면. sqlc는 또한 Go의 CQRS 아키텍처의 읽기 모델(read model) 측면에도 자연스러운 선택지로, 쿼리가 도메인 엔티티가 아닌 호출자에 맞게 형성되며 명시적 SQL을 통해 투영(projection)에 대한 완전한 컨트롤을 제공합니다.
아직 이 ORM 선택을 통합 스타일과 서비스 경계와 함께 저울질하고 있다면, 이 애플리케이션 아키텍처 개요가 결정에 더 넓은 프로덕션 컨텍스트를 부여하는 데 도움이 됩니다.
로컬 PostgreSQL을 위한 최소 docker-compose.yml
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d app"]
interval: 5s
timeout: 3s
retries: 5