본문으로 건너뛰기

GORM 시작하기

이 챕터에서 다루는 것

GORM은 Go에서 가장 많이 쓰이는 ORM이고, v1.30.0부터 제네릭 API가 들어왔다. 기존 체인 API와 완전히 다른 물건이라, 지금 GORM을 처음 배운다면 제네릭 쪽부터 배우는 편이 낫다. 이 챕터는 그렇게 한다. 체인 API는 "기존 코드에서 보게 될 것"으로 다룬다.

버전은 GORM v1.31.2(2026-06-25)다.

문제 — SQL을 손으로 쓰는 비용

11-1의 Scan(&p.ID, &p.Name, &p.Price, &p.DiscountRate)을 떠올려 보자. 컬럼을 하나 추가할 때마다 SELECT 목록, INSERT 목록, Scan 인자, 구조체 필드 네 군데를 고쳐야 하고, 순서가 어긋나도 컴파일러는 아무 말이 없다. 테이블이 서른 개쯤 되면 이 반복이 코드베이스의 상당 부분을 차지한다.

ORM은 이 대응 관계를 구조체 하나에 선언하게 하고 나머지를 생성한다. 대가는 "내가 쓰지 않은 SQL이 실행된다"는 것이고, 이 챕터와 다음 두 챕터의 절반은 그 SQL을 눈으로 확인하는 방법에 관한 것이다.

붙이기

go get gorm.io/gorm@v1.31.2
go get github.com/glebarez/sqlite@v1.11.0

공식 SQLite 드라이버는 gorm.io/driver/sqlite인데, _ "github.com/mattn/go-sqlite3"을 무조건 import하므로 cgo가 딸려 온다. github.com/glebarez/sqlite는 순수 Go 구현이라 그런 것이 없다. PostgreSQL과 MySQL은 공식 드라이버(gorm.io/driver/postgres, gorm.io/driver/mysql)에 cgo 문제가 없으니 그대로 쓰면 된다.

:::warning 이 드라이버는 내부적으로 다른 SQLite를 쓴다 github.com/glebarez/sqlitegithub.com/glebarez/go-sqlite를 쓰고, 이것은 modernc.org/sqlite를 포크한 것이다. 둘 다 자기를 "sqlite"라는 이름으로 등록하므로 한 바이너리에 같이 넣을 수 없다(11-1의 패닉). 그래서 이 강의는 database/sql 챕터와 GORM 챕터를 서로 다른 패키지로 나눠 두었다. :::

예제는 공통 헬퍼로 DB를 연다.

examples/11-database-and-orm/sqllog/sqllog.go
// OpenMemory는 인메모리 SQLite에 GORM을 붙인다.
//
// rec가 nil이면 로그를 남기지 않는다. 시각은 FixedClock으로 고정된다.
func OpenMemory(rec *Recorder) (*gorm.DB, error) {
cfg := &gorm.Config{
NowFunc: func() time.Time { return FixedClock },
}
if rec != nil {
cfg.Logger = rec
} else {
cfg.Logger = logger.Discard
}

db, err := gorm.Open(sqlite.Open(":memory:"), cfg)
if err != nil {
return nil, fmt.Errorf("sqllog: GORM 열기: %w", err)
}

gorm.Open은 내부적으로 sql.Open을 부르고 *sql.DB를 들고 있다. 11-2의 풀 설정은 그대로 유효하다. db.DB()로 꺼내서 설정한다.

examples/11-database-and-orm/sqllog/sqllog.go
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("sqllog: *sql.DB 꺼내기: %w", err)
}
sqlDB.SetMaxOpenConns(1)
return db, nil
}

실행되는 SQL을 보는 장치

GORM을 쓸 때 가장 먼저 갖춰야 할 것은 "지금 무슨 SQL이 나갔는가"를 보는 수단이다. 기본 로거는 이렇게 찍는다.

2026/08/12 18:37:48 /tmp/deflog/main.go:27 record not found
[0.033ms] [rows:0] SELECT * FROM `posts` WHERE id = 99 ORDER BY `posts`.`id` LIMIT 1

실제 출력에는 ANSI 색상 이스케이프가 섞여 있다. 위에서는 읽기 좋게 걷어냈다.

[0.033ms]가 문제다. 실행 시간이 들어 있어서 출력이 매번 달라진다. 강의 예제는 결과가 항상 같아야 하므로, 시간을 버리고 SQL만 모으는 로거를 직접 구현해 쓴다. 부수 효과가 더 중요하다 — 쿼리 개수를 셀 수 있게 된다.

examples/11-database-and-orm/sqllog/sqllog.go
// Trace는 GORM이 쿼리 하나를 끝낼 때마다 불린다.
//
// begin은 시작 시각이다. 기본 로거는 time.Since(begin)을 찍지만 여기서는
// 버린다. 그래야 출력이 결정적이다.
func (r *Recorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()

r.mu.Lock()
defer r.mu.Unlock()
r.sqls = append(r.sqls, sql)
}

logger.Interface는 메서드 다섯 개뿐이라 구현이 짧다. 실제 서비스에서는 logger.NewSlogLoggerlog/slog에 붙이는 것이 보통이다(파트 12).

모델

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// Post는 GORM이 관리하는 모델이다.
//
// 태그가 없으면 GORM은 필드 이름을 스네이크 케이스로 바꿔 컬럼으로 쓰고,
// 타입 이름을 복수형으로 바꿔 테이블 이름으로 쓴다. Post → posts,
// AuthorName → author_name. 규칙을 외우기보다 AutoMigrate가 만든 DDL을
// 한 번 찍어 보는 편이 빠르다.
type Post struct {
// gorm.Model을 임베드하면 ID/CreatedAt/UpdatedAt/DeletedAt이 딸려 온다.
// DeletedAt이 들어오는 순간 이 모델은 소프트 삭제 대상이 된다.
gorm.Model

Title string `gorm:"size:200;not null;index"`
Body string `gorm:"type:text"`
Author string `gorm:"size:80;not null"`
Views int `gorm:"default:0"`

// Draft는 unique 제약이 아니라 부분 인덱스가 필요한 경우를 위해 남겨 둔다.
Draft bool
}

자주 쓰는 태그는 열 개 남짓이다. primaryKey, size:N, type:..., not null, default:..., index, uniqueIndex, column:..., -(무시), autoIncrement. 전부 외울 필요는 없고, AutoMigrate가 만든 DDL을 찍어서 원하는 모양인지 확인하는 편이 빠르다.

== AutoMigrate가 만든 것 ==
CREATE TABLE `posts` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`title` text NOT NULL,`body` text,`author` text NOT NULL,`views` integer DEFAULT 0,`draft` numeric)
CREATE INDEX `idx_posts_title` ON `posts`(`title`)
CREATE INDEX `idx_posts_deleted_at` ON `posts`(`deleted_at`)
CREATE TABLE `tags` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL)
CREATE UNIQUE INDEX `idx_tags_name` ON `tags`(`name`)

size:200이 SQLite에서는 그냥 text가 되었다. SQLite에 길이 제한이 없기 때문이다. 같은 모델을 PostgreSQL에 올리면 varchar(200)이 된다. 모델 태그는 DB에 따라 다르게 해석된다는 것을 기억해 두면 좋다.

gorm.Model은 규약이 아니다

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// Tag는 gorm.Model을 쓰지 않는 모델이다.
//
// 소프트 삭제도, 자동 타임스탬프도 필요 없을 때는 임베드하지 않는 것이 낫다.
// gorm.Model은 편의 기능이지 규약이 아니다.
type Tag struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:40;uniqueIndex;not null"`
}

gorm.Model을 습관적으로 붙이면 deleted_at 컬럼과 인덱스가 따라오고, 이후 모든 조회에 WHERE deleted_at IS NULL이 붙는다. 진짜로 소프트 삭제가 필요한 테이블에만 쓴다.

AutoMigrate의 한계

AutoMigrate더하기만 한다. 테이블이 없으면 만들고, 컬럼이 없으면 추가하고, 인덱스가 없으면 만든다. 하지만:

  • 모델에서 지운 컬럼을 DB에서 지우지 않는다
  • 컬럼 타입 변경을 안정적으로 처리하지 못한다(DB에 따라 다르다)
  • 컬럼 이름 변경은 "옛 컬럼 유지 + 새 컬럼 추가"가 된다
  • 데이터 이관은 당연히 못 한다

개발 중에는 편리하지만 운영 배포에 쓰면 안 된다. 11-5에서 golang-migrate로 분리한다.

제네릭 API

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// Create는 글 하나를 저장한다.
//
// 제네릭 API는 첫 인자로 context.Context를 요구한다. 체인 API처럼
// WithContext를 따로 붙일 필요가 없고, 빼먹을 수도 없다.
func (r *Repo) Create(ctx context.Context, p *Post) error {
if err := gorm.G[Post](r.db).Create(ctx, p); err != nil {
return fmt.Errorf("blog: 생성: %w", err)
}
return nil
}

gorm.G[Post](db)Post 전용 쿼리 빌더를 만든다. 이 API가 해결하는 것이 둘 있다.

타입 안전성. 체인 API의 db.First(&user)any를 받는다. db.Model(&User{}).Find(&[]Post{})처럼 모델과 결과 타입이 어긋나도 컴파일된다. 제네릭 API에서는 gorm.G[Post](db).Find(ctx)[]Post를 돌려주므로 어긋날 수가 없다.

조건 오염. 체인 API의 가장 흔한 운영 버그다.

// 체인 API에서 하면 안 되는 것
q := db.Model(&Post{})
if author != "" {
q = q.Where("author = ?", author)
}
adminList := q.Find(&all) // 여기까지는 괜찮은데
userList := q.Where("draft = ?", false).Find(&pub) // q에 조건이 누적된다

*gorm.DB 값을 변수에 담아 두 번 쓰면 조건이 쌓인다. db.Session(&gorm.Session{})으로 끊어 줘야 하는데, 그 사실을 모르면 "왜 두 번째 쿼리에 이상한 WHERE가 붙지" 하고 한나절을 보낸다. 제네릭 API는 매번 새 값을 돌려주므로 이 문제가 구조적으로 없다. 11-4에서 테스트로 확인한다.

FindFirst의 차이는 database/sqlQueryQueryRow와 같다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// ByAuthor는 작성자의 글을 id 순으로 모은다.
//
// First와 달리 Find는 결과가 없어도 에러가 아니다. 빈 슬라이스와 nil을 준다.
func (r *Repo) ByAuthor(ctx context.Context, author string) ([]Post, error) {
posts, err := gorm.G[Post](r.db).
Where("author = ?", author).
Order("id").
Find(ctx)
if err != nil {
return nil, fmt.Errorf("blog: 목록: %w", err)
}
return posts, nil
}

First는 행이 없으면 gorm.ErrRecordNotFound다. 11-1에서 sql.ErrNoRows를 도메인 에러로 바꿨듯이 여기서도 경계에서 바꾼다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// ByID는 하나를 찾는다.
func (r *Repo) ByID(ctx context.Context, id uint) (Post, error) {
p, err := gorm.G[Post](r.db).Where("id = ?", id).First(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return Post{}, fmt.Errorf("%w: id=%d", ErrNotFound, id)
}
if err != nil {
return Post{}, fmt.Errorf("blog: 조회: %w", err)
}
return p, nil
}

Updates의 제로값 함정

GORM에서 가장 자주, 그리고 가장 조용히 당하는 것이다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// Retitle은 구조체로 갱신한다.
//
// Updates에 넘긴 구조체는 제로값 필드를 무시한다. 그래서 "제목을 빈
// 문자열로 바꾸기"는 이 방식으로 표현할 수 없다. 그 경우는 Update를 쓴다.
func (r *Repo) Retitle(ctx context.Context, id uint, title string) (int, error) {
n, err := gorm.G[Post](r.db).Where("id = ?", id).Updates(ctx, Post{Title: title})
if err != nil {
return 0, fmt.Errorf("blog: 제목 변경: %w", err)
}
return n, nil
}

title이 빈 문자열이면 어떻게 될까. GORM은 구조체를 훑으며 제로값이 아닌 필드만 SET에 넣는다. Title""이면 빠진다. 문제는 그다음이다.

== Updates의 제로값 함정 ==
rowsAffected=1 — 성공한 것처럼 보인다
실제로 나간 SQL — 쿼리 1개
UPDATE `posts` SET `updated_at`="2026-03-01 09:00:00" WHERE id = 1 AND `posts`.`deleted_at` IS NULL
제목은 그대로: "첫 글"

rowsAffected가 1이다. 호출자는 성공했다고 믿는다. 실제로는 updated_at만 갱신됐다. gorm.Model이 자동으로 붙여 준 그 컬럼 때문에 SET 절이 비지 않았고, 그래서 SQL이 나갔고, 그래서 1행이 영향을 받았다.

같은 실수를 타임스탬프 없는 모델에서 하면 증상이 다르다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// RenameTag는 같은 실수를 타임스탬프 없는 모델에서 해 본다.
//
// Tag에는 UpdatedAt이 없다. 그래서 제로값만 담긴 구조체를 넘기면 SET에
// 넣을 것이 하나도 남지 않고, GORM은 SQL을 아예 보내지 않는다.
func (r *Repo) RenameTag(ctx context.Context, id uint, name string) (int, error) {
n, err := gorm.G[Tag](r.db).Where("id = ?", id).Updates(ctx, Tag{Name: name})
if err != nil {
return 0, fmt.Errorf("blog: 태그 이름 변경: %w", err)
}
return n, nil
}

이쪽은 SQL이 아예 나가지 않고 rowsAffected=0이다. 같은 버그가 모델에 따라 "조용히 성공"과 "조용히 아무것도 안 함"으로 갈린다.

:::danger 제로값을 쓰려면 컬럼을 명시한다 Update(ctx, "컬럼", 값)은 제로값도 그대로 반영한다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// Publish는 초안 표시를 내린다.
//
// Update는 컬럼 하나를 지정해 바꾼다. 값이 제로값이어도 반영된다.
func (r *Repo) Publish(ctx context.Context, id uint) error {
n, err := gorm.G[Post](r.db).Where("id = ?", id).Update(ctx, "draft", false)
if err != nil {
return fmt.Errorf("blog: 발행: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: id=%d", ErrNotFound, id)
}
return nil
}

컬럼이 여러 개면 Set(...)clause.Assignment를 나열하거나, 체인 API의 Updates(map[string]any{...})를 쓴다. 11-7의 gormtask.Save가 전자의 예다.

rowsAffected를 확인하는 습관도 함께 들인다. 0이면 대상이 없었다는 뜻이고, 그것은 보통 404다. :::

소프트 삭제

gorm.Model을 임베드했거나 gorm.DeletedAt 타입 필드가 있으면 DeleteDELETE가 아니다.

== 소프트 삭제 ==
Delete — 쿼리 1개
UPDATE `posts` SET `deleted_at`="2026-03-01 09:00:00" WHERE id = 1 AND `posts`.`deleted_at` IS NULL
보이는 글=2, 실제 행=3

그리고 이후 모든 조회에 조건이 자동으로 붙는다.

SELECT — 쿼리 1개
SELECT * FROM `posts` WHERE author = "sohee" AND `posts`.`deleted_at` IS NULL ORDER BY id

이 두 가지가 소프트 삭제의 실제 비용이다.

  • 인덱스 설계가 달라진다. author만 걸린 인덱스는 (author, deleted_at) 복합 인덱스만 못하다. 모든 쿼리에 조건이 하나 더 붙기 때문이다.
  • UNIQUE 제약이 의도대로 안 걸린다. 이메일에 unique를 걸어 두고 소프트 삭제하면, 같은 이메일로 재가입이 안 된다. 행이 남아 있기 때문이다. 부분 인덱스 (WHERE deleted_at IS NULL)가 필요한데 DB마다 문법이 다르다.
  • 삭제된 것까지 보려면 Unscoped()가 필요하고, 이것은 체인 API에만 있다.
examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// CountIncludingDeleted는 소프트 삭제된 것까지 센다.
//
// 제네릭 API에는 Unscoped가 없다. 삭제된 행까지 봐야 할 때는 체인 API로
// 내려간다. 두 API는 같은 *gorm.DB 위에서 섞어 쓸 수 있다.
func (r *Repo) CountIncludingDeleted(ctx context.Context) (int64, error) {
var n int64
if err := r.db.WithContext(ctx).Unscoped().Model(&Post{}).Count(&n).Error; err != nil {
return 0, fmt.Errorf("blog: 전체 개수: %w", err)
}
return n, nil
}

두 API가 같은 *gorm.DB 위에서 공존한다는 것도 여기서 확인된다. 제네릭 API로 못 하는 일은 체인 API로 내려가면 되고, 마이그레이션도 한 번에 할 필요가 없다.

:::tip 소프트 삭제가 정말 필요한가 "실수로 지웠을 때 복구"가 이유라면 백업이 더 나은 답이다. "감사 기록"이 이유라면 별도의 이력 테이블이 더 정확하다. 소프트 삭제가 정말 맞는 경우는 삭제된 것도 도메인적으로 의미가 있을 때다 — 취소된 주문, 탈퇴한 회원의 통계 같은 것. 그 경우라면 deleted_at보다 status 컬럼이 의도를 더 잘 드러낸다. :::

FirstOrCreate와 Save가 없는 이유

제네릭 API에는 체인 API의 FirstOrCreateSave가 없다. 빠뜨린 것이 아니라 일부러 뺀 것이다.

FirstOrCreate는 "찾아보고 없으면 만든다"인데, 두 문장 사이에 다른 트랜잭션이 끼어들 수 있다. 동시에 두 요청이 들어오면 둘 다 "없다"를 보고 둘 다 만든다. UNIQUE 제약이 없으면 중복 행이 생기고, 있으면 한쪽이 제약 위반으로 실패한다. 어느 쪽이든 이 메서드의 이름이 약속하는 동작이 아니다.

Save는 "ID가 있으면 UPDATE, 없으면 INSERT"인데, 그 판단 기준이 구조체의 제로값이라 모호하다. ID가 0인 기존 행을 갱신할 방법이 없고, 부분 갱신인지 전체 치환인지도 불분명하다.

정답은 DB에게 맡기는 것이다.

examples/11-database-and-orm/03-gorm-basics/blog/blog.go
// EnsureTag는 없으면 만들고 있으면 찾는다.
//
// 체인 API의 FirstOrCreate가 하던 일이다. 제네릭 API에는 그 메서드가 없다.
// "조회 후 없으면 생성"은 두 문장 사이에 다른 트랜잭션이 끼어들 수 있어
// 그 자체로 경쟁 조건이기 때문이다. 정답은 DB의 UNIQUE 제약과 upsert다.
func (r *Repo) EnsureTag(ctx context.Context, name string) (Tag, error) {
// ON CONFLICT DO NOTHING으로 한 문장에 끝낸다.
if err := r.db.WithContext(ctx).
Exec(`INSERT INTO tags (name) VALUES (?) ON CONFLICT (name) DO NOTHING`, name).
Error; err != nil {
return Tag{}, fmt.Errorf("blog: 태그 upsert: %w", err)
}
t, err := gorm.G[Tag](r.db).Where("name = ?", name).First(ctx)
if err != nil {
return Tag{}, fmt.Errorf("blog: 태그 조회: %w", err)
}
return t, nil
}
== FirstOrCreate 대신 upsert ==
두 번 불러도 같은 행: id=1, id=1
EnsureTag 두 번 — 쿼리 4개
INSERT INTO tags (name) VALUES ("go") ON CONFLICT (name) DO NOTHING
SELECT * FROM `tags` WHERE name = "go" ORDER BY `tags`.`id` LIMIT 1
INSERT INTO tags (name) VALUES ("go") ON CONFLICT (name) DO NOTHING
SELECT * FROM `tags` WHERE name = "go" ORDER BY `tags`.`id` LIMIT 1

INSERT ... ON CONFLICT는 한 문장이라 원자적이다. UNIQUE 제약이 진짜 방어선이고, Go 코드의 조회는 그 뒤를 따라갈 뿐이다. GORM에는 clause.OnConflict도 있어 방언 차이를 흡수해 주지만, 여기서는 무슨 SQL이 나가는지 보이도록 직접 썼다.

:::note 왜 시간이 항상 09:00:00인가 gorm.Config.NowFunc를 고정 시계로 바꿨기 때문이다. GORM은 CreatedAt/UpdatedAt을 DB의 CURRENT_TIMESTAMP가 아니라 Go에서 만들어 INSERT에 실어 보낸다. 그래서 시계를 주입하면 타임스탬프까지 결정적으로 만들 수 있다. 파트 8~10에서 써 온 시계 주입이 DB 계층에서도 그대로 통한다는 뜻이다. 11-7에서 이 선택의 대가를 다시 이야기한다. :::

흔한 실수

Updates에 구조체를 넘기고 제로값이 반영되기를 기대한다. 안 된다. Update(ctx, "col", v)Set(...)을 쓴다.

rowsAffected를 안 본다. 갱신도 삭제도 대상이 없을 수 있다.

습관적으로 gorm.Model을 임베드한다. 원치 않는 소프트 삭제가 딸려 온다.

AutoMigrate로 운영 스키마를 관리한다. 컬럼을 지울 수 없고 타입 변경이 불안정하다.

gorm.ErrRecordNotFound를 위로 흘린다. 상위 계층이 GORM에 묶인다.

로그를 안 보고 개발한다. ORM의 절반은 "무슨 SQL이 나가는지 보는 것"이다. 로거를 먼저 붙이고 시작한다.

정리

  • 제네릭 API gorm.G[T](db)를 기본으로 쓴다. 타입이 안 어긋나고, 컨텍스트를 빼먹을 수 없고, 조건 오염이 구조적으로 없다.
  • 체인 API는 사라지지 않았다. Unscoped 같은 것은 아직 그쪽에만 있고, 둘은 같은 *gorm.DB 위에서 섞어 쓸 수 있다.
  • Updates는 제로값을 무시한다. gorm.Model이 있으면 updated_at 때문에 rowsAffected=1이 나와 성공처럼 보인다.
  • 소프트 삭제는 모든 조회에 조건을 하나 붙인다. 인덱스와 UNIQUE 제약 설계가 달라진다.
  • FirstOrCreateSave는 의미가 모호해서 제네릭 API에서 빠졌다. upsert와 명시적 갱신으로 대체한다.
  • AutoMigrate는 더하기만 한다. 개발용이다.
  • NowFunc로 시계를 주입하면 타임스탬프까지 결정적이 된다.

연습문제

  1. PostPublishedAt *time.Time을 추가하고, Publish가 그것도 채우게 해 보자. Updates로 하면 어떻게 되는가? nil이 아닌 포인터는 제로값인가? 그리고 "발행 취소"(nil로 되돌리기)는 어떤 방법으로만 가능한가?

  2. EnsureTagclause.OnConflict로 다시 써 보자 (db.Clauses(clause.OnConflict{DoNothing: true}).Create(&tag)). 생성되는 SQL이 손으로 쓴 것과 같은가? OnConflict{Columns:..., DoUpdates:...}로 "있으면 갱신"까지 하려면 무엇이 더 필요한가?

  3. 소프트 삭제된 글을 30일 뒤 진짜로 지우는 정리 작업을 만들어 보자. 제네릭 API로는 왜 못 하는가? 체인 API의 어느 메서드가 필요한가? 그리고 한 번에 수십만 행을 지우면 무슨 문제가 생기는가 (힌트: 11-2의 트랜잭션과 커넥션 점유)?