GORM 쿼리와 관계
이 챕터에서 다루는 것
관계를 선언하고, 관계를 따라 데이터를 가져오고, 그 과정에서 쿼리가 몇 번 나가는지를 세어 본다. N+1은 "느려진다"는 막연한 경고로 배우면 실전에서 못 알아본다. 숫자로 보면 다르다.
관계 선언
// Author는 저자다. Books는 has many 관계다.
//
// GORM은 Book 쪽에 AuthorID라는 필드가 있다는 사실만으로 관계를 알아낸다.
// 규칙은 "상대 타입 이름 + ID"다. 다른 이름을 쓰려면 foreignKey 태그로 알려 준다.
type Author struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:80;not null"`
Books []Book
Profile *Profile // has one
}
// Profile은 저자당 하나다. has one 관계다.
type Profile struct {
ID uint `gorm:"primaryKey"`
AuthorID uint `gorm:"not null;uniqueIndex"`
Bio string `gorm:"size:200"`
}
// Book은 책이다. Author에 belongs to, Tags에 many2many다.
type Book struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:200;not null"`
Year int `gorm:"not null"`
AuthorID uint `gorm:"not null;index"`
Author *Author
Tags []Tag `gorm:"many2many:book_tags"`
}
네 가지 관계가 다 들어 있다.
| 관계 | 어떻게 알아보는가 | 외래 키가 있는 쪽 |
|---|---|---|
| has many | Books []Book + Book.AuthorID | Book |
| has one | Profile *Profile + Profile.AuthorID | Profile |
| belongs to | Book.Author *Author + Book.AuthorID | Book |
| many2many | Tags []Tag + many2many:book_tags 태그 | 조인 테이블 |
has one과 has many, belongs to의 구분이 헷갈린다면 외래 키가 어느 테이블에
있는지만 보면 된다. Author.Books는 상대 테이블에 author_id가 있으므로
has many, Book.Author는 자기 테이블에 author_id가 있으므로 belongs to다.
같은 하나의 외래 키를 양쪽에서 부르는 이름이 다를 뿐이다.
many2many:book_tags는 조인 테이블 이름이다. GORM이 book_tags(book_id, tag_id)를
직접 만든다. 조인 테이블에 추가 컬럼(가입 시각, 순서 같은 것)이 필요해지면 이
방식으로는 표현할 수 없고, 조인 테이블을 하나의 모델로 승격시켜야 한다.
조건 빌더
// Search는 조건을 순서대로 붙여 쿼리를 만든다.
//
// 제네릭 API의 체이닝은 매번 새 값을 돌려주므로 중간 값을 재사용해도
// 조건이 섞이지 않는다. 체인 API에서 *gorm.DB를 변수에 담아 돌려 쓰다가
// WHERE가 누적되는 사고가 이 API에서는 구조적으로 일어나지 않는다.
func (r *Repo) Search(ctx context.Context, f Filter) ([]Book, error) {
q := gorm.G[Book](r.db).Order("id")
if f.AuthorID != 0 {
q = q.Where("author_id = ?", f.AuthorID)
}
if f.MinYear != 0 {
q = q.Where("year >= ?", f.MinYear)
}
if f.MaxYear != 0 {
q = q.Where("year <= ?", f.MaxYear)
}
if f.Limit > 0 {
q = q.Limit(f.Limit)
}
books, err := q.Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: 검색: %w", err)
}
return books, nil
}
Where를 여러 번 부르면 AND로 이어진다. Or, Not도 같은 식이다.
생성되는 SQL은 이렇다.
Search — 쿼리 1개
SELECT * FROM `books` WHERE year >= 2021 ORDER BY id LIMIT 3
11-3에서 말한 조건 오염이 정말 없는지 테스트로 확인해 둔다.
// TestFilterReuseDoesNotLeakConditions는 제네릭 API가 조건을 누적하지
// 않는다는 것을 확인한다. 체인 API에서 *gorm.DB를 재사용할 때 나는 버그다.
func TestFilterReuseDoesNotLeakConditions(t *testing.T) {
r, _ := newRepo(t)
seed(t, r)
if _, err := r.Search(t.Context(), library.Filter{AuthorID: 1}); err != nil {
t.Fatalf("Search: %v", err)
}
after, err := r.Search(t.Context(), library.Filter{})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(after) != 6 {
t.Errorf("앞선 조건이 남았다: %d권", len(after))
}
}
N+1 — 세어 보자
관계를 따라가는 가장 자연스러운 코드가 가장 흔한 성능 문제를 만든다.
// AuthorsNPlusOne은 저자를 읽고 저자마다 책을 따로 읽는다.
//
// 이 코드가 나쁜 이유는 느려서가 아니라 저자 수에 비례해 쿼리가 늘기
// 때문이다. 개발 DB에 저자가 셋이면 4번, 운영 DB에 3000명이면 3001번이다.
func (r *Repo) AuthorsNPlusOne(ctx context.Context) ([]Author, error) {
authors, err := gorm.G[Author](r.db).Order("id").Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: 저자 목록: %w", err)
}
for i := range authors {
books, err := gorm.G[Book](r.db).
Where("author_id = ?", authors[i].ID).
Order("id").
Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: %d번 저자의 책: %w", authors[i].ID, err)
}
authors[i].Books = books
}
return authors, nil
}
Preload는 같은 결과를 두 번의 쿼리로 얻는다.
// AuthorsPreloaded는 같은 결과를 두 번의 쿼리로 얻는다.
//
// GORM은 먼저 저자를 읽고, 모아 둔 id로 IN 절 한 방을 날린다.
// 저자가 3명이든 3000명이든 쿼리는 2개다.
func (r *Repo) AuthorsPreloaded(ctx context.Context) ([]Author, error) {
authors, err := gorm.G[Author](r.db).
Preload("Books", func(b gorm.PreloadBuilder) error {
b.Order("id")
return nil
}).
Order("id").
Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: 저자+책: %w", err)
}
return authors, nil
}
로거가 실제 쿼리를 세 준다.
== N+1 ==
저자마다 따로 조회 — 쿼리 4개
SELECT * FROM `authors` ORDER BY id
SELECT * FROM `books` WHERE author_id = 1 ORDER BY id
SELECT * FROM `books` WHERE author_id = 2 ORDER BY id
SELECT * FROM `books` WHERE author_id = 3 ORDER BY id
Preload — 쿼리 2개
SELECT * FROM `books` WHERE `books`.`author_id` IN (1,2,3) ORDER BY id
SELECT * FROM `authors` ORDER BY id
저자 3명: 4쿼리 → 2쿼리
4에서 2는 별것 아니게 보인다. 요점은 배수가 아니라 기울기다. 저자가 3000명이면 왼쪽은 3001쿼리, 오른쪽은 여전히 2쿼리다. 개발 DB에서는 절대 드러나지 않고 운영에서만 드러나는 종류의 문제라, 로컬에서 쿼리 개수를 세는 습관이 유일한 방어다.
테스트로 못 박아 두면 회귀도 잡힌다.
// 저자 3명 → 1 + 3 = 4번
if naiveCount != 4 {
t.Errorf("N+1은 4쿼리여야 한다: %d (%v)", naiveCount, rec.SQL())
}
// Preload는 저자 수와 무관하게 2번
if fastCount != 2 {
t.Errorf("Preload는 2쿼리여야 한다: %d", fastCount)
}
:::note 로그 순서가 뒤집혀 보인다
Preload의 출력에서 books 쿼리가 authors보다 먼저 찍혔다. 실제 실행 순서는
반대인데, 로거의 Trace가 쿼리 완료 시점에 불리고 preload는 바깥 쿼리의
콜백 안에서 끝나기 때문이다. 개수를 세는 데는 지장이 없다.
:::
Preload는 관계마다 쿼리 하나
Preload가 마법이 아니라는 것을 알아야 쿼리 수를 미리 셀 수 있다.
// AuthorsWithProfileAndTags는 중첩 Preload를 보여 준다.
//
// 관계 경로를 점으로 잇는다. 깊이가 늘어날수록 쿼리도 하나씩 늘어난다.
// Preload는 조인이 아니라 "관계마다 IN 쿼리 한 번"이라는 것을 기억하면
// 쿼리 수를 미리 셀 수 있다.
func (r *Repo) AuthorsWithProfileAndTags(ctx context.Context) ([]Author, error) {
authors, err := gorm.G[Author](r.db).
Preload("Profile", nil).
Preload("Books", func(b gorm.PreloadBuilder) error {
b.Order("id")
return nil
}).
Preload("Books.Tags", nil).
Order("id").
Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: 저자 전체: %w", err)
}
return authors, nil
}
== 중첩 Preload ==
김소희 / 소개="김소희의 소개" / 첫 책=김소희의 책 1 / 태그=고전
Profile + Books + Books.Tags — 쿼리 5개
SELECT * FROM `book_tags` WHERE `book_tags`.`book_id` IN (1,2,3,4,5,6)
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2)
SELECT * FROM `books` WHERE `books`.`author_id` IN (1,2,3) ORDER BY id
SELECT * FROM `profiles` WHERE `profiles`.`author_id` IN (1,2,3)
SELECT * FROM `authors` ORDER BY id
다섯 개다. authors 1 + profiles 1 + books 1 + many2many 관계 2(조인 테이블과 tags). 관계 하나에 쿼리 하나, many2many는 둘이라는 규칙만 알면 코드를 보고 셀 수 있다. Preload를 다섯 개 붙이면 쿼리가 여섯 개다.
:::warning IN 절이 커지면 그것대로 문제다
Preload는 부모의 id를 전부 모아 IN (...)에 넣는다. 부모가 10만 건이면 IN 절에
10만 개가 들어간다. PostgreSQL은 견디지만 파라미터 개수 상한이 있는 DB도 있고,
쿼리 계획이 나빠지기도 한다. 목록 API에는 반드시 페이지네이션을 걸고, 그다음에
Preload한다. 순서가 반대면 안 된다.
:::
Preload와 Joins의 역할이 다르다
이 구분이 GORM에서 두 번째로 흔한 혼란이다.
- Preload는 가져오기다. 결과 집합은 그대로 두고 관계 데이터를 채운다.
- Joins는 거르기다. 관계를 조건으로 써서 결과 집합 자체를 줄인다.
"저자 이름이 박민준인 책"을 찾을 때 Preload는 아무 도움이 안 된다. Preload의
Where는 두 번째 쿼리에 붙으므로 첫 번째 쿼리의 결과를 줄이지 못한다.
// BooksByAuthorName은 조인으로 걸러낸다.
//
// Preload는 "가져오기"이고 Joins는 "거르기"다. 저자 이름으로 책을 거를
// 때는 Preload가 아무 도움이 되지 않는다. Preload의 WHERE는 두 번째
// 쿼리에 붙기 때문에 첫 번째 쿼리의 결과 집합을 줄이지 못한다.
func (r *Repo) BooksByAuthorName(ctx context.Context, name string) ([]Book, error) {
books, err := gorm.G[Book](r.db).
Joins(clause.Has("Author"), func(db gorm.JoinBuilder, _ clause.Table, _ clause.Table) error {
db.Where("name = ?", name)
return nil
}).
Order("books.id").
Find(ctx)
if err != nil {
return nil, fmt.Errorf("library: 저자 이름으로 검색: %w", err)
}
return books, nil
}
== Joins로 거르기 ==
id=3 박민준의 책 1
id=4 박민준의 책 2
Joins — 쿼리 1개
SELECT `books`.`id`,`books`.`title`,`books`.`year`,`books`.`author_id`,`Author`.`id` AS `Author__id`,`Author`.`name` AS `Author__name` FROM `books` INNER JOIN `authors` `Author` ON `books`.`author_id` = `Author`.`id` AND name = "박민준" ORDER BY books.id
쿼리 하나이고, 관계 컬럼을 Author__name 같은 별칭으로 함께 가져와 Book.Author에
채워 준다. clause.Has("Author")가 관계 이름으로 조인 대상을 지정하는 방식이다.
그럼 항상 Joins가 나은가? 아니다. has many에 조인을 걸면 부모 행이 자식 수만큼 중복된다. 저자 3명 × 책 2권이면 6행이 오고, GORM이 그것을 다시 3개로 합쳐야 한다. 데이터 양에 따라 Preload의 두 쿼리가 더 싸다. 규칙은 이렇다.
- has one / belongs to를 함께 가져올 때 → Joins가 대개 낫다(중복이 없다)
- has many / many2many를 함께 가져올 때 → Preload가 대개 낫다
- 관계를 조건으로 쓸 때 → 선택의 여지 없이 Joins다
집계는 DTO로 받는다
화면에 필요한 모양이 엔티티와 다를 때가 많다. 엔티티에 BookCount 필드를
억지로 넣지 말고 전용 구조체를 만든다.
// AuthorSummary는 테이블과 1:1로 대응하지 않는 결과다.
//
// 화면에 필요한 모양이 엔티티와 다를 때 엔티티를 억지로 늘리지 말고
// 이런 구조체를 따로 두는 편이 낫다.
type AuthorSummary struct {
AuthorID uint `gorm:"column:author_id"`
Name string `gorm:"column:name"`
BookCount int `gorm:"column:book_count"`
LastYear int `gorm:"column:last_year"`
}
// Summaries는 집계 결과를 DTO로 받는다.
//
// 여기서는 제네릭 API의 Joins를 쓰지 않는다. clause.Has로 관계 조인을
// 걸면 GORM이 Select에 조인된 테이블의 컬럼(`Books__id` 등)을 자동으로
// 덧붙이는데, 그러면 GROUP BY에 없는 컬럼이 SELECT에 들어간다.
// SQLite는 통과시키지만 PostgreSQL은 거절한다.
//
// 집계처럼 "모델을 읽는 것이 아니라 표를 만드는" 쿼리는 관계 매핑을
// 거치지 말고 조인을 직접 쓰는 편이 안전하고 읽기도 쉽다.
func (r *Repo) Summaries(ctx context.Context) ([]AuthorSummary, error) {
var out []AuthorSummary
err := r.db.WithContext(ctx).
Table("authors").
Select("authors.id AS author_id, authors.name AS name, " +
"COUNT(books.id) AS book_count, COALESCE(MAX(books.year), 0) AS last_year").
Joins("JOIN books ON books.author_id = authors.id").
Group("authors.id, authors.name").
Order("authors.id").
Scan(&out).Error
if err != nil {
return nil, fmt.Errorf("library: 요약: %w", err)
}
return out, nil
}
Scan은 Find와 다르다. 모델을 거치지 않으므로 소프트 삭제 조건도, 관계 처리도
끼어들지 않는다. 임의의 SQL 결과를 임의의 구조체에 담는 용도다.
주석에 적힌 함정은 실제로 만난 것이다. 처음에는 Joins(clause.Has("Books"), nil)로
썼는데, GORM이 Books__id, Books__title 같은 컬럼을 SELECT에 자동으로 덧붙였다.
SQLite는 그냥 통과시켰지만 PostgreSQL이라면 column "Books.id" must appear in the GROUP BY clause로 거절한다. 한 DB에서 되는 것이 다른 DB에서 된다는 보장은 없다.
배치
// AddBooks는 여러 권을 나눠서 넣는다.
//
// 한 번에 다 넣으면 SQL이 길어져 DB가 거절하거나 락을 오래 잡는다.
// CreateInBatches는 batchSize마다 INSERT를 끊어 준다.
func (r *Repo) AddBooks(ctx context.Context, books []Book, batchSize int) error {
if err := gorm.G[Book](r.db).CreateInBatches(ctx, &books, batchSize); err != nil {
return fmt.Errorf("library: 일괄 등록: %w", err)
}
return nil
}
읽을 때도 마찬가지다. 100만 행을 Find로 받으면 그대로 힙에 올라간다.
// WalkBooks는 전부 메모리에 올리지 않고 덩어리로 순회한다.
//
// 100만 행을 Find로 받으면 그대로 힙에 올라간다. FindInBatches는
// batchSize만큼만 들고 콜백을 부른다.
func (r *Repo) WalkBooks(ctx context.Context, batchSize int, fn func(batch []Book, n int) error) error {
err := gorm.G[Book](r.db).
Order("id").
FindInBatches(ctx, batchSize, func(data []Book, batch int) error {
return fn(data, batch)
})
if err != nil {
return fmt.Errorf("library: 배치 순회: %w", err)
}
return nil
}
== 배치 ==
10건을 4씩 끊어 INSERT 3번
FindInBatches(8)로 순회한 덩어리 크기: [8 8]
10건을 4씩 끊으면 4+4+2로 INSERT 3번이다. FindInBatches(8)은 16권을 8씩 두 번에
나눠 콜백을 부른다.
:::tip 진짜 스트리밍이 필요하면 Rows()
FindInBatches도 배치 하나는 통째로 메모리에 올린다. 한 행씩 처리하며 상수
메모리를 유지해야 한다면 Rows(ctx)로 *sql.Rows를 받아 11-1처럼 직접 돌린다.
그때 rows.Close()와 rows.Err()가 다시 내 책임이 된다.
:::
흔한 실수
목록 API에서 관계를 루프로 채운다. N+1. 쿼리 개수를 세는 테스트를 하나 두면 회귀도 막힌다.
페이지네이션 없이 Preload한다. IN 절이 부모 행 수만큼 커진다.
관계를 조건으로 쓰려고 Preload에 Where를 건다. 두 번째 쿼리에만 붙어서
결과가 줄지 않는다. Joins를 쓴다.
has many에 Joins를 걸고 중복을 의아해한다. 조인의 정의상 그렇다.
집계 쿼리를 관계 매핑으로 만든다. GORM이 SELECT에 컬럼을 덧붙여
GROUP BY가 깨진다. Table + 직접 조인이 안전하다.
대량 INSERT를 한 문장으로 만든다. SQL 길이 상한과 파라미터 개수 상한에 걸린다.
정리
- 관계는 외래 키가 어느 테이블에 있는지로 구분한다. has many/has one은 상대 쪽, belongs to는 자기 쪽.
- N+1은 세면 보인다. 로거로 쿼리 개수를 세고, 테스트로 못 박는다.
- Preload는 관계마다 쿼리 하나(many2many는 둘). 코드를 보고 미리 셀 수 있다.
- Preload는 가져오기, Joins는 거르기. 관계를 조건으로 쓰려면 Joins뿐이다.
- has one/belongs to는 Joins, has many/many2many는 Preload가 대개 낫다.
- 집계는
Table+ 직접 조인 +Scan. 관계 매핑을 거치면 이식성이 깨진다. - 한 DB에서 되는 SQL이 다른 DB에서 된다는 보장은 없다.
- 대량 작업은
CreateInBatches와FindInBatches로 끊는다.
연습문제
-
Search에TagName string조건을 추가해 보자. 태그는 many2many다.Joins로 할 수 있는가? 결과에 중복이 생기는가, 생긴다면 왜이고 어떻게 없애는가? (힌트:Distinct, 또는 서브쿼리) -
AuthorsPreloaded에Limit(2)를 붙여 보자. 생성되는 두 쿼리가 각각 어떻게 바뀌는가?Preload의LimitPerRecord(1)은 무엇을 하는가 — 그리고 그것이 왜Limit으로는 표현할 수 없는 일인가? -
쿼리 개수를 세는 테스트 헬퍼를 만들어 보자.
assertQueryCount(t, rec, 2, func(){...})같은 모양이면 좋겠다. 이 헬퍼를 이 챕터의 모든 조회에 붙이면, 앞으로 누가 Preload를 지우거나 관계를 하나 더 붙였을 때 무엇이 먼저 알려 주는가?