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/sqlite는 github.com/glebarez/go-sqlite를 쓰고, 이것은
modernc.org/sqlite를 포크한 것이다. 둘 다 자기를 "sqlite"라는 이름으로
등록하므로 한 바이너리에 같이 넣을 수 없다(11-1의 패닉). 그래서 이 강의는
database/sql 챕터와 GORM 챕터를 서로 다른 패키지로 나눠 두었다.
:::
예제는 공통 헬퍼로 DB를 연다.
// 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()로 꺼내서 설정한다.
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만 모으는 로거를 직접 구현해 쓴다.
부수 효과가 더 중요하다 — 쿼리 개수를 셀 수 있게 된다.
// 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.NewSlogLogger로 log/slog에 붙이는 것이 보통이다(파트 12).
모델
// 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은 규약이 아니다
// 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
// 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에서 테스트로 확인한다.
Find와 First의 차이는 database/sql의 Query와 QueryRow와 같다.
// 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를
도메인 에러로 바꿨듯이 여기서도 경계에서 바꾼다.
// 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에서 가장 자주, 그리고 가장 조용히 당하는 것이다.
// 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행이 영향을 받았다.
같은 실수를 타임스탬프 없는 모델에서 하면 증상이 다르다.
// 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, "컬럼", 값)은 제로값도 그대로 반영한다.
// 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 타입 필드가 있으면 Delete가
DELETE가 아니다.
== 소프트 삭제 ==
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에만 있다.
// 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의 FirstOrCreate와 Save가 없다. 빠뜨린 것이 아니라
일부러 뺀 것이다.
FirstOrCreate는 "찾아보고 없으면 만든다"인데, 두 문장 사이에 다른 트랜잭션이
끼어들 수 있다. 동시에 두 요청이 들어오면 둘 다 "없다"를 보고 둘 다 만든다.
UNIQUE 제약이 없으면 중복 행이 생기고, 있으면 한쪽이 제약 위반으로 실패한다.
어느 쪽이든 이 메서드의 이름이 약속하는 동작이 아니다.
Save는 "ID가 있으면 UPDATE, 없으면 INSERT"인데, 그 판단 기준이 구조체의 제로값이라
모호하다. ID가 0인 기존 행을 갱신할 방법이 없고, 부분 갱신인지 전체 치환인지도 불분명하다.
정답은 DB에게 맡기는 것이다.
// 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 제약 설계가 달라진다.
FirstOrCreate와Save는 의미가 모호해서 제네릭 API에서 빠졌다. upsert와 명시적 갱신으로 대체한다.AutoMigrate는 더하기만 한다. 개발용이다.NowFunc로 시계를 주입하면 타임스탬프까지 결정적이 된다.
연습문제
-
Post에PublishedAt *time.Time을 추가하고,Publish가 그것도 채우게 해 보자.Updates로 하면 어떻게 되는가? nil이 아닌 포인터는 제로값인가? 그리고 "발행 취소"(nil로 되돌리기)는 어떤 방법으로만 가능한가? -
EnsureTag를clause.OnConflict로 다시 써 보자 (db.Clauses(clause.OnConflict{DoNothing: true}).Create(&tag)). 생성되는 SQL이 손으로 쓴 것과 같은가?OnConflict{Columns:..., DoUpdates:...}로 "있으면 갱신"까지 하려면 무엇이 더 필요한가? -
소프트 삭제된 글을 30일 뒤 진짜로 지우는 정리 작업을 만들어 보자. 제네릭 API로는 왜 못 하는가? 체인 API의 어느 메서드가 필요한가? 그리고 한 번에 수십만 행을 지우면 무슨 문제가 생기는가 (힌트: 11-2의 트랜잭션과 커넥션 점유)?