본문 바로가기
2026년 5월 4일3분 읽기

Drizzle 2.0에서 Prisma의 include처럼 한 번에 관계를 가져오는데 N+1이 발생합니다

김
김영삼
조회 605댓글 3

Prisma 6 → Drizzle 2.0으로 옮긴 지 한 달째인데 운영 모니터링에서 N+1이 잡혔습니다. 분명히 with로 관계 가져왔는데 쿼리가 N+1로 쪼개져 나가요.

환경: Drizzle 2.0.3, PostgreSQL 18, Node 24

코드:

// schema.ts
export const posts = pgTable('posts', { ... })
export const tags = pgTable('tags', { ... })
export const postTags = pgTable('post_tags', { ... })

export const postsRelations = relations(posts, ({ many }) => ({
  postTags: many(postTags),
}))
export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, { fields: [postTags.postId], references: [posts.id] }),
  tag: one(tags, { fields: [postTags.tagId], references: [tags.id] }),
}))

// 쿼리
const result = await db.query.posts.findMany({
  with: { postTags: { with: { tag: true } } },
  limit: 50,
})

실제 나가는 쿼리 (pg_stat_statements):

SELECT * FROM posts LIMIT 50;                            -- 1번
SELECT * FROM post_tags WHERE post_id = $1;              -- 50번 반복
SELECT * FROM tags WHERE id = $1;                        -- 평균 3번씩 × 50 = 150번

총 200쿼리가 나가서 페이지 응답이 1.4초로 느립니다. Prisma의 include는 IN 절로 묶어서 3쿼리로 끝나던 게 왜 Drizzle에선 다른가요?

댓글 3

김김영삼6개월 전

Drizzle 1.x까지 알려진 한계였는데 2.0에서 거의 해결됐습니다. N+1이 발생하는 건 보통 두 가지 케이스 중 하나입니다.


케이스 ① — relations 정의가 같은 파일에 모여있지 않음


Drizzle은 relations을 분석해서 한 번에 LATERAL JOIN으로 SQL을 생성합니다. 그런데 postsRelations·postTagsRelations·tagsRelations 세 개가 모두 동일한 import 그래프에 있어야 합니다. 파일을 쪼개두고 일부만 import하면 Drizzle이 모르는 관계로 처리해서 fallback 모드로 떨어집니다.


해결: schema/index.ts에서 모든 테이블·relations을 re-export하고, drizzle({ schema }) 호출 시 한 번에 넘기세요.


// schema/index.ts
export * from './posts'
export * from './tags'
export * from './postTags'

// db.ts
import * as schema from './schema'
export const db = drizzle(pool, { schema })

케이스 ② — tagsRelations이 누락


질문 코드를 보면 postTagsRelations는 있지만 tagsRelations가 없네요. tag → postTags 역방향이 정의 안 되어 있어도 Drizzle이 "이건 단방향" 으로 처리되는데, with 절의 최종 leaf인 tag가 매번 별도 SELECT로 빠지는 케이스가 이때 발생합니다.


// 추가 필요
export const tagsRelations = relations(tags, ({ many }) => ({
postTags: many(postTags),
}))

이 두 가지 다 적용 후 다시 EXPLAIN 보면 LATERAL JOIN 1쿼리로 합쳐집니다. 실제 SQL은 이런 모양:


SELECT posts.*, json_agg(...) AS post_tags
FROM posts
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('tag', t.*)) AS ...
FROM post_tags pt
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = posts.id
) ON true
GROUP BY posts.id
LIMIT 50;

추가 — orderBy/limit 조합 함정


관계 안에 orderBy나 limit이 있으면 (예: "각 post의 최근 댓글 3개") Drizzle은 LATERAL을 못 만들고 다시 N+1로 떨어집니다. 이 경우는 별도 쿼리를 두 번 날리고 코드에서 group by 하는 게 더 빠릅니다.

데데이터사이언스현6개월 전

한 가지 더 — N+1을 의도적으로 두 쿼리로 푸는 패턴도 자주 씁니다. 큰 테이블 두 개를 LATERAL로 묶으면 인덱스를 못 타는 케이스가 있어서요.


// 1) post 목록만
const ps = await db.select().from(posts).limit(50)
const ids = ps.map(p => p.id)

// 2) 관련 tag만 한 번에
const tags = await db.select()
.from(postTags)
.leftJoin(tagsTable, eq(postTags.tagId, tagsTable.id))
.where(inArray(postTags.postId, ids))

// 3) 코드에서 group
const byPost = Map.groupBy(tags, t => t.postId)
return ps.map(p => ({ ...p, tags: byPost.get(p.id) ?? [] }))

총 2쿼리. 인덱스가 post_tags(post_id)에 잘 잡혀 있다면 위의 LATERAL JOIN보다 30~40% 빠른 경우가 많아요.


그리고 EXPLAIN ANALYZE 결과에서 Seq Scan이 보이면 post_tags에 인덱스 빠진 거니 그것부터 확인하세요. Drizzle은 인덱스 정의도 schema에 적어둘 수 있습니다 — index('idx_post_tags_post').on(postTags.postId).

DDrizzle옮긴이(guest)6개월 전
OP입니다. 두 분 답변 다 적용해보니 정확히 케이스 ②(tagsRelations 누락)였습니다. relations 파일 분리하면서 빼먹은 거더라고요. 추가 후 SQL 2쿼리로 줄었고 P95가 1.4s → 180ms 됐습니다.
두 번째 답변의 inArray + group 패턴도 다음에 더 큰 테이블에 적용해보겠습니다. 감사합니다!
Ctrl+Enter로 등록