06. 고급 쿼리 (Advanced Queries)

2026년 4월 30일 수정됨

📋 개요

실무에서 대시보드 통계, 검색, 페이징 처리를 구현할 때 필요한 고급 기술들입니다.

📋 목차

실무에서 대시보드 통계, 검색, 페이징 처리를 구현할 때 필요한 고급 기술들입니다.


📊 1. 집계와 그룹핑 (Aggregation)

count, sum, avg 등의 함수를 사용합니다.

import { count, sum, avg, desc } from 'drizzle-orm';
 
const result = await db
  .select({
    userId: posts.authorId,
    // count()는 문자열로 반환될 수 있어 mapWith(Number)로 변환 추천
    postCount: count(posts.id).mapWith(Number),
    totalViews: sum(posts.views).mapWith(Number),
  })
  .from(posts)
  .groupBy(posts.authorId) // 사용자별로 묶기
  .having(({ postCount }) => gt(postCount, 5)) // (Optional) 글 5개 이상 쓴 사람만
  .orderBy(({ postCount }) => desc(postCount));

🏎️ 2. RQB 심화: 계산된 필드 (Computed Fields)

Relational Query Builder(db.query...)를 쓰면서, SQL로 계산한 값도 객체에 포함시키고 싶다면 extras를 씁니다.

const usersWithStats = await db.query.users.findMany({
  extras: {
    // 1. 소문자 이름 (SQL 함수 실행)
    lowerName: sql<string>`lower(${users.name})`.as('lower_name'),
 
    // 2. 전체 게시글 수 (서브쿼리)
    totalPosts: sql<number>`(
      SELECT count(*) FROM ${posts} WHERE ${posts.authorId} = ${users.id}
    )`.as('total_posts'),
  },
  with: {
    profile: true, // 기존 관계도 같이 가져옴
  },
});
 
console.log(usersWithStats[0].totalPosts); // 사용 가능!

🧩 3. 동적 쿼리 빌딩 (Dynamic Query Building)

검색 필터가 있을 수도 있고 없을 수도 있을 때(Optional Filters), 배열을 활용해 깔끔하게 짭니다.

import { sql } from 'drizzle-orm';
 
async function searchUsers(keyword?: string, role?: string) {
  const filters = [];
 
  // 조건이 있을 때만 필터 추가
  if (keyword) {
    filters.push(or(
      ilike(users.name, `%${keyword}%`),
      ilike(users.email, `%${keyword}%`)
    ));
  }
 
  if (role) {
    filters.push(eq(users.role, role));
  }
 
  // 활성 사용자만
  filters.push(eq(users.isActive, true));
 
  return db.select()
    .from(users)
    .where(and(...filters)); // and() 안에 펼쳐 넣기
}

📄 4. 페이징 (Pagination) 처리

Offset Based (기본)

"1페이지, 2페이지..." 직관적이지만 뒤로 갈수록 느려짐.

const page = 1;
const limit = 10;
 
await db.query.users.findMany({
  limit: limit,
  offset: (page - 1) * limit,
  orderBy: desc(users.createdAt),
});

Cursor Based (무한 스크롤, 고성능)

"마지막으로 본 항목 다음부터..." 대용량 데이터에 필수.

const lastSeenId = 100; // 프론트에서 받은 마지막 아이템 ID
const lastSeenDate = '2023-01-01...';
 
await db.query.users.findMany({
  limit: 10,
  where: (users, { and, lt }) => and(
    // (생성일 < 마지막날짜) OR (생성일 = 마지막날짜 AND ID < 마지막ID)
    // 복합 인덱스 (createdAt, id) 태우면 엄청 빠름 ⚡️
    lt(users.createdAt, new Date(lastSeenDate)),
  ),
  orderBy: desc(users.createdAt),
});

📝 마무리 퀴즈

Q1. 대시보드 통계를 만들 때 count, sum, groupBy 결과를 그대로 믿기 전에 무엇을 확인해야 하나요?

정답: 집계 기준 컬럼, 필터 조건, 반환 타입이 화면의 의미와 일치하는지 확인해야 합니다.

💡 상세 해설: 신고글 수인지 승인된 글 수인지, 전체 기간인지 최근 7일인지에 따라 같은 count도 다른 의미가 됩니다. DB 드라이버에 따라 집계 결과가 문자열처럼 올 수 있어 타입 처리도 확인해야 합니다.

Q2. 무한 스크롤에서 offset pagination보다 cursor pagination이 유리한 이유는 무엇인가요?

정답: 뒤 페이지로 갈수록 건너뛰는 비용이 커지는 offset과 달리, cursor는 마지막 기준값 이후를 이어 조회해 큰 목록에서 더 안정적입니다.

💡 상세 해설: cursor 방식은 createdAt, id 같은 정렬 기준과 인덱스를 함께 설계해야 효과가 납니다. 계속 새 글이 들어오는 화면에서는 중복과 누락 위험도 함께 고려해야 합니다.

Q3. 영철이의 테스트 타임: 검색 조건이 있을 때만 where를 붙이는 동적 쿼리를 만들고 싶습니다. 가장 조심해야 할 점은 무엇인가요?

정답: 사용자 입력을 SQL 문자열로 직접 이어 붙이지 말고 Drizzle의 조건 빌더와 파라미터 바인딩 흐름을 유지해야 합니다.

💡 상세 해설: 동적 쿼리는 조건 조합이 늘어나기 쉬워 SQL injection과 타입 깨짐이 동시에 생길 수 있습니다. and, or, eq, ilike 같은 연산자로 조건 배열을 구성하면 안전성과 가독성을 지킬 수 있습니다.

🐣 영철이의 퇴근 일기

오늘은 고급 쿼리가 멋진 SQL 테크닉이 아니라 제품 질문을 숫자와 목록으로 번역하는 일이라는 걸 배웠다. 영수 님이 "이번 주 활성 스터디가 늘었나요?"라고 물으면 count 하나가 아니라 기준, 기간, 상태값까지 같이 정해야 한다.

커서 페이징도 기억에 남는다. 예전에는 limitoffset이면 끝이라고 생각했는데, 커뮤니티 피드가 커질수록 뒤 페이지가 느려지고 새 글 때문에 목록이 흔들릴 수 있다는 걸 알았다.

💡 "고급 쿼리는 복잡한 문법을 쓰는 능력이 아니라, 화면의 질문을 DB가 빠르고 일관되게 답하도록 만드는 능력이다."

내일은 통계나 검색 PR을 보면 인덱스, 정렬 기준, 사용자 입력 처리, 반환 타입을 같이 확인해야겠다.

🔗 레퍼런스

다음 장: 07. Zod 통합 (Validation)