본문으로 건너뛰기

GORM 실전 주의점

이 챕터에서 다루는 것

GORM으로 실제 서비스를 만들 때 부딪히는 것들이다. 트랜잭션 경계를 어디에 어떻게 긋는지, 훅을 왜 쓰지 말아야 하는지, 스키마를 어떻게 관리하는지.

훅 절은 특히 주의해서 읽기 바란다. 문서에 안 적힌 동작을 실측으로 확인한 내용이 있다.

db.Transaction

11-2에서 Begin/Commit/Rollback을 직접 다뤘다. GORM은 그것을 클로저로 감싼 형태를 제공하고, 실무에서는 이쪽만 쓰면 된다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop.go
// PlaceOrder는 재고 차감과 주문 생성을 한 트랜잭션으로 묶는다.
//
// db.Transaction은 콜백이 nil을 돌려주면 커밋하고, 에러를 돌려주거나
// 패닉하면 롤백한다. 직접 Begin/Commit을 쓰는 것보다 커밋을 빠뜨릴 여지가 없다.
//
// 콜백 안에서는 반드시 인자로 받은 tx를 써야 한다. 바깥의 s.db를 쓰면
// 그 쿼리는 트랜잭션 밖에서 실행되어 롤백되지 않는다. 가장 흔한 사고다.
func (s *Shop) PlaceOrder(ctx context.Context, buyer string, lines []Line) (Order, error) {
var out Order

err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order := Order{Buyer: buyer, Status: "placed"}
if err := gorm.G[Order](tx).Create(ctx, &order); err != nil {
return fmt.Errorf("주문 생성: %w", err)
}

콜백이 nil을 돌려주면 커밋, 에러를 돌려주면 롤백이다. 패닉도 롤백 후 재전파된다. Commit을 빠뜨릴 방법이 없다는 것이 직접 쓰는 것보다 나은 점이다.

tx를 인자로 받는 형태라는 것이 중요하다. 콜백 안에서 바깥의 s.db를 쓰면 그 쿼리만 다른 커넥션으로 나가서 롤백되지 않는다. 컴파일도 되고 테스트도 통과하는데 운영에서만 데이터가 어긋난다. 11-7에서 이 실수를 타입으로 막는 방법을 다룬다.

재고 차감은 읽고-비교하고-쓰기 대신 조건부 UPDATE 한 방으로 한다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop.go
// 재고 차감은 조건부 UPDATE 한 방으로 한다. 읽어서 비교한 뒤
// 쓰면 그 사이에 다른 주문이 끼어들 수 있다(11-2의 갱신 분실).
n, err := gorm.G[Product](tx).
Where("id = ? AND stock >= ?", l.ProductID, l.Quantity).
Update(ctx, "stock", gorm.Expr("stock - ?", l.Quantity))
if err != nil {
return fmt.Errorf("재고 차감: %w", err)
}

gorm.Expr은 값이 아니라 SQL 표현식을 그대로 넣는다. WHERE stock >= ?가 조건이자 검증이다. rowsAffected가 0이면 재고가 부족했거나 상품이 없었던 것이고, 둘 중 어느 쪽인지는 그때 다시 확인한다.

== 중간 실패는 전부 되돌린다 ==
에러: shop: 주문: shop: 재고 부족: id=2, 9개 요청
ErrOutOfStock: true
1번 상품 재고: 3 → 3 (되돌아왔다)
주문 수: 1건 (실패한 주문은 남지 않는다)

두 번째 줄에서 실패했는데 첫 번째 줄의 재고 차감과 이미 만든 주문 행까지 전부 사라졌다.

:::warning 트랜잭션 안에 넣으면 안 되는 것

  • 외부 API 호출. 커넥션이 그 시간만큼 묶이고, 롤백해도 호출은 되돌아가지 않는다.
  • 메일 발송, 메시지 큐 publish. 같은 이유다. 커밋 후에 한다.
  • 긴 계산. DB 커넥션을 잡은 채 CPU를 돌리는 것은 낭비다.
  • 사용자 입력 대기. 말할 것도 없다.

원칙은 "트랜잭션은 짧게, DB 작업만"이다. 커밋 후에 해야 하는 일이 있다면 트랜잭션 밖으로 빼고, 그것이 실패했을 때의 보상은 별도로 설계한다. :::

훅 — 쓰지 마라

GORM은 모델에 BeforeSave, AfterCreate 같은 메서드를 정의하면 자동으로 불러 준다. 편해 보이고, 실제로 많은 프로젝트가 쓴다. 그리고 그 프로젝트들이 나중에 후회한다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop.go
// Coupon은 BeforeSave 훅이 붙은 모델이다.
//
// 코드를 항상 대문자로 저장하겠다는, 겉보기에 무해한 정규화 훅이다.
// 이 훅 하나가 어떤 부작용을 만드는지는 shop_test.go가 고정하고 있다.
type Coupon struct {
ID uint `gorm:"primaryKey"`
Code string `gorm:"size:20;uniqueIndex;not null"`
Percent int `gorm:"not null"`
}

// BeforeSave는 저장 직전에 코드를 대문자로 바꾼다.
func (c *Coupon) BeforeSave(*gorm.DB) error {
c.Code = strings.ToUpper(c.Code)
return nil
}

무해해 보인다. 생성 경로에서는 잘 돈다.

생성 시 훅은 돈다: 입력 "spring10" → 저장 "SPRING10"

문제 1 — 훅이 제네릭 갱신을 통째로 깨뜨린다

제네릭 API로 갱신: invalid value, should be pointer to struct or slice
gorm.ErrInvalidValue: true
체인 API로 갱신: 성공

GORM v1.31.2에서, 모델에 BeforeSave·BeforeUpdate·AfterSave·AfterUpdate 중 하나라도 있으면 제네릭 API의 모든 갱신 경로가 런타임에 실패한다. Updates도, Update도, Set(...).Update도 전부 gorm.ErrInvalidValue다.

원인은 GORM 내부에 있다. callbacks/callmethod.go가 훅을 부르려면 주소를 얻을 수 있는 값이 필요한데, 제네릭 API는 T를 값으로 넘긴다.

// gorm.io/gorm@v1.31.2 callbacks/callmethod.go
case reflect.Struct:
if db.Statement.ReflectValue.CanAddr() {
fc(db.Statement.ReflectValue.Addr().Interface(), tx)
} else {
db.AddError(gorm.ErrInvalidValue)
}

업스트림 이슈는 go-gorm/gorm#7713 ("Generics API: Updates ( ctx, t ) and Hook cannot be used together")이다. 2026-02-25에 열려 2026-07-22에 닫혔지만, 그 수정은 v1.31.2(2026-06-25) 이후에 들어갔으므로 현재 최신 안정 버전에서는 여전히 재현된다. 예제 테스트가 그것을 못 박고 있다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop_test.go
// TestGenericUpdateBreaksOnHookedModel은 GORM v1.31.2의 실제 동작을 고정한다.
//
// 모델에 BeforeSave/BeforeUpdate/AfterSave/AfterUpdate 중 하나라도 있으면
// 제네릭 API의 모든 갱신 경로가 런타임에 실패한다. 컴파일은 통과한다.
// 업스트림 이슈 go-gorm/gorm#7713. 이 테스트가 실패하기 시작하면
// 고쳐진 버전이 나온 것이다.
func TestGenericUpdateBreaksOnHookedModel(t *testing.T) {
s, _ := newShop(t)

if _, err := s.AddCoupon(t.Context(), "spring10", 10); err != nil {
t.Fatalf("AddCoupon: %v", err)
}

err := s.UpdateCouponGeneric(t.Context(), 1, 20)
if !errors.Is(err, gorm.ErrInvalidValue) {
t.Fatalf("gorm.ErrInvalidValue를 기대했으나 %v", err)
}

여기서 배울 것은 "이 버전에 버그가 있다"가 아니다. 훅을 하나 추가하면 그 모델을 쓰는 모든 갱신 호출부가 런타임에 깨지는데, 컴파일러는 아무 말도 하지 않는다는 것이다. 훅은 호출부에서 보이지 않는 곳에서 동작을 바꾸고, 그 결합이 이런 식으로 드러난다.

문제 2 — 훅으로 불변식을 지킬 수 없다

"코드는 항상 대문자"라는 규칙을 훅으로 강제하려 했다. 우회로는 항상 있다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop.go
// RenameCouponRaw는 SQL을 직접 쏜다. 훅은 돌지 않는다.
//
// 훅으로 불변식을 지키려는 설계가 왜 위험한지가 여기 있다. 훅을 우회하는
// 경로는 항상 존재하고(원시 SQL, 다른 서비스, DBA의 수동 UPDATE),
// 그 경로로 들어온 데이터는 규칙을 지키지 않는다.
func (s *Shop) RenameCouponRaw(ctx context.Context, id uint, code string) error {
err := s.db.WithContext(ctx).
Exec(`UPDATE coupons SET code = ? WHERE id = ?`, code, id).Error
if err != nil {
return fmt.Errorf("shop: 쿠폰 코드 변경: %w", err)
}
return nil
}
원시 SQL로 갱신 후 코드: "summer20" (훅이 돌지 않았다)

원시 SQL, 배치 잡, 다른 언어로 짠 서비스, DBA의 수동 UPDATE — 훅을 거치지 않는 경로는 얼마든지 있다. 훅으로 지킨 규칙은 일부 경로에서만 지켜지는 규칙이고, 그것은 규칙이 아니다.

진짜 불변식은 DB 제약(CHECK, UNIQUE, NOT NULL, 외래 키)이나 도메인 타입의 생성자로 지킨다. 파트 4에서 "생성자를 통해서만 만들 수 있는 타입"을 다룬 이유가 이것이다.

문제 3 — 롤백되지 않는 부수 효과

AfterCreate 훅에서 메일을 보내거나 이벤트를 publish하는 코드를 종종 본다. 그 훅은 트랜잭션 안에서 실행된다. 트랜잭션이 롤백되면 DB는 되돌아가지만 메일은 이미 나갔다. 사용자는 "가입 완료" 메일을 받았는데 계정이 없다.

:::tip 훅 대신 무엇을 쓰는가

  • 정규화 → 도메인 타입의 생성자에서. NewCouponCode(s string) (CouponCode, error)
  • 불변식 → DB 제약. CHECK (code = upper(code))
  • 타임스탬프 → GORM이 이미 해 준다(CreatedAt/UpdatedAt). 그 이상은 서비스 계층에서.
  • 부수 효과 → 서비스 계층에서, 커밋 후에 명시적으로.

공통점은 전부 호출부에서 보인다는 것이다. :::

생성된 SQL을 확인하는 방법

ORM을 쓰는 이상 "내가 쓰지 않은 SQL"이 실행된다. 그것을 보는 수단을 처음부터 갖춰 두는 것이 이 챕터 전체에서 가장 실용적인 조언이다.

개발 중에는 로거를 켠다. GORM 기본 로거는 모든 쿼리를 찍는다. 시간이 포함되므로 출력이 매번 달라지지만, 개발 중에는 그게 오히려 유용하다.

운영에서는 느린 쿼리만. logger.New(writer, logger.Config{SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn}). log/slog에 붙이려면 logger.NewSlogLogger가 있다(파트 12에서 다룬다).

테스트에서는 개수를 센다. 11-4의 sqllog.Recorder가 그것이다. N+1 회귀를 잡는 유일하게 확실한 방법이다.

실행 없이 SQL만 보고 싶으면 DryRun.

stmt := db.Session(&gorm.Session{DryRun: true}).Where("id = ?", 3).Find(&[]Author{}).Statement
fmt.Println(stmt.SQL.String(), stmt.Vars)
// SELECT * FROM `authors` WHERE id = ? [3]

DryRun에서는 값이 플레이스홀더로 남아 있다. 로그에 찍히는 SQL은 GORM이 값을 끼워 넣어 읽기 좋게 만든 것이고, 드라이버에 실제로 가는 것은 플레이스홀더 버전이라는 점을 기억해 두자. 로그만 보고 "GORM이 값을 문자열로 붙이는구나"라고 오해하기 쉽다.

스키마 마이그레이션은 분리한다

AutoMigrate는 컬럼을 지우지 못하고 타입 변경이 불안정하다(11-3). 운영 스키마는 버전이 매겨진 SQL 파일로 관리한다. Go 생태계에서 가장 널리 쓰이는 것이 golang-migrate다.

파일은 짝으로 만든다.

examples/11-database-and-orm/05-gorm-in-practice/migrations/000002_add_product_sku.up.sql
ALTER TABLE products ADD COLUMN sku TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX idx_products_sku ON products(sku) WHERE sku <> '';
examples/11-database-and-orm/05-gorm-in-practice/migrations/000002_add_product_sku.down.sql
DROP INDEX idx_products_sku;
ALTER TABLE products DROP COLUMN sku;

실행한다.

go install -tags sqlite github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
migrate -path migrations -database "sqlite://$DIR/shop.db" up
1/u create_products (827.958µs)
2/u add_product_sku (1.679916ms)

괄호 안의 시간은 실행마다 다르다. 되돌리기도 된다.

migrate -path migrations -database "sqlite://$DIR/shop.db" down 1
2/d add_product_sku (1.159583ms)

도구는 schema_migrations 테이블에 현재 버전을 기록한다. 위 실행 직후 그 테이블에는 (1, 0)이 들어 있고, products의 컬럼은 id, name, stock, price로 돌아가 있다.

:::note -versiondev라고 나온다 go install로 소스에서 빌드하면 버전 문자열이 주입되지 않아 dev가 찍힌다. 모듈 버전은 명령에 적은 v4.19.1이 맞다. 릴리스 바이너리를 받으면 제대로 나온다. :::

마이그레이션 규칙

  • 적용한 마이그레이션은 고치지 않는다. 이미 운영에 나간 파일을 수정하면 서버마다 스키마가 달라진다. 새 파일을 추가한다.
  • down을 실제로 써 보고 커밋한다. 안 써 본 down은 거의 항상 틀렸다.
  • 파괴적 변경은 여러 단계로 나눈다. 컬럼 이름 변경은 (1) 새 컬럼 추가 (2) 양쪽에 쓰는 코드 배포 (3) 데이터 복사 (4) 새 컬럼만 읽는 코드 배포 (5) 옛 컬럼 삭제. 코드 배포와 스키마 변경이 동시에 일어날 수 없기 때문이다.
  • NOT NULL 컬럼 추가에는 DEFAULT가 필요하다. 기존 행에 넣을 값이 있어야 한다.
  • GORM 모델과 마이그레이션이 어긋날 수 있다. CI에서 마이그레이션을 적용한 DB에 AutoMigrate를 DryRun으로 돌려 차이가 나오는지 확인하는 방법이 있다.

성능 함정 목록

지금까지 나온 것과 아직 안 나온 것을 모아 둔다.

N+1 (11-4). 쿼리 개수를 세는 테스트로 막는다.

페이지네이션 없는 Preload (11-4). IN 절이 부모 행 수만큼 커진다.

SELECT *. GORM의 기본이다. 큰 TEXT/BLOB 컬럼이 있는 테이블에서는 목록 조회가 필요 없는 데이터를 잔뜩 실어 온다. Select("id, title")로 줄인다.

Count와 목록을 따로 부르는 페이지네이션. 쿼리가 두 배다. 첫 페이지에서만 세거나, 커서 기반 페이지네이션으로 바꾸면 COUNT가 필요 없어진다.

인덱스 없는 WHERE. ORM은 인덱스를 만들어 주지 않는다(태그로 지시한 것 빼고). EXPLAIN을 붙여 확인하는 습관이 필요하다.

소프트 삭제와 인덱스 (11-3). 모든 쿼리에 deleted_at IS NULL이 붙으므로 복합 인덱스에 그 컬럼을 포함해야 한다.

PrepareStmt를 켜지 않음. gorm.Config{PrepareStmt: true}는 준비된 문장을 캐시한다. 같은 쿼리를 반복하는 서비스에서 파싱 비용을 아낀다. 단, 커넥션마다 캐시가 생기므로 메모리를 쓰고, DDL이 바뀌면 무효화 문제가 있다.

기본 트랜잭션. GORM은 Create/Update/Delete를 기본적으로 트랜잭션으로 감싼다. 단일 문장에는 불필요한 오버헤드라 SkipDefaultTransaction: true로 끌 수 있다. 끄기 전에 정말로 단일 문장만 쓰는지 확인해야 한다 — 연관 데이터를 함께 저장하는 Create는 여러 문장이다.

한 주문에 5쿼리. 예제의 PlaceOrder가 줄 하나짜리 주문에 5번 쿼리한다. 줄이 늘면 줄마다 3번씩 늘어난다. "트랜잭션으로 묶었으니 한 번"이 아니다. 테스트로 개수를 못 박아 두면 나중에 누가 한 줄 추가했을 때 보인다.

examples/11-database-and-orm/05-gorm-in-practice/shop/shop_test.go
// TestOrderQueryCount는 주문 한 건이 쿼리를 몇 번 쓰는지 고정한다.
//
// 줄 하나짜리 주문에 5번이다. 줄이 늘면 줄마다 3번씩 늘어난다.
// "트랜잭션으로 묶었으니 한 번"이 아니라는 것을 눈으로 확인해 둔다.
func TestOrderQueryCount(t *testing.T) {
s, rec := newShop(t)

rec.Reset()
if _, err := s.PlaceOrder(t.Context(), "sohee", []shop.Line{{ProductID: 1, Quantity: 1}}); err != nil {
t.Fatalf("PlaceOrder: %v", err)
}
== 주문 한 건이 쓰는 쿼리 ==
PlaceOrder(줄 1개) — 쿼리 5개
INSERT INTO `orders` (`buyer`,`total`,`status`) VALUES ("sohee",0,"placed") RETURNING `id`
UPDATE `products` SET `stock`=stock - 2 WHERE id = 1 AND stock >= 2
SELECT * FROM `products` WHERE id = 1 ORDER BY `products`.`id` LIMIT 1
INSERT INTO `order_items` (`order_id`,`product_id`,`quantity`,`price`) VALUES (1,1,2,89000) RETURNING `id`
UPDATE `orders` SET `total`=178000 WHERE id = 1

정리

  • db.Transaction을 쓴다. 커밋을 빠뜨릴 수 없고, 패닉도 롤백된다.
  • 콜백 안에서는 tx만 쓴다. 바깥 db를 쓰면 그 쿼리는 롤백되지 않는다.
  • 트랜잭션 안에 외부 호출·메일·긴 계산을 넣지 않는다.
  • 훅은 쓰지 않는다. v1.31.2에서는 제네릭 갱신을 통째로 깨뜨리고(#7713), 우회 경로가 항상 있어서 불변식을 지키지 못하며, 부수 효과는 롤백되지 않는다.
  • 생성된 SQL을 보는 수단을 먼저 갖춘다. 개발은 로거, 테스트는 쿼리 개수, 확인만 필요하면 DryRun.
  • 운영 스키마는 golang-migrate 같은 버전 관리 도구로. AutoMigrate는 개발용이다.
  • 적용된 마이그레이션은 고치지 않고, down은 실제로 써 보고 커밋한다.
  • 성능 문제는 대부분 쿼리 개수 문제다. 세면 보인다.

연습문제

  1. PlaceOrder의 콜백 안에서 tx 대신 s.db를 쓰도록 한 줄만 바꿔 보자. TestPlaceOrderRollsBackEverything이 실패하는가? 실패한다면 어느 단언에서인가? 이 실수를 컴파일 타임에 막으려면 시그니처가 어떻게 생겨야 하는가? (11-7이 답 하나를 보여 준다)

  2. CouponBeforeSave 훅을 지우고, 대신 CouponCode라는 타입과 NewCouponCode(string) (CouponCode, error) 생성자를 만들어 보자. 정규화가 어디서 일어나게 되는가? 원시 SQL로 우회하는 경로는 이제 무엇이 막는가?

  3. migrationsordersorder_items를 추가하는 마이그레이션을 써 보자. up을 적용한 뒤 down으로 되돌리고, 다시 up을 적용해 보자. SQLite에서 DROP COLUMN이 언제부터 지원되는지, 지원되지 않는 DB에서는 down을 어떻게 써야 하는지 찾아보자. (힌트: 테이블 재생성)