06. 고급 쿼리 (Advanced Queries)
📋 개요
실무에서 대시보드 통계, 검색, 페이징 처리를 구현할 때 필요한 고급 기술들입니다.
📋 목차
- 📊 1. 집계와 그룹핑 (Aggregation)
- 🏎️ 2. RQB 심화: 계산된 필드 (Computed Fields)
- 🧩 3. 동적 쿼리 빌딩 (Dynamic Query Building)
- 📄 4. 페이징 (Pagination) 처리
- 🔗 레퍼런스
실무에서 대시보드 통계, 검색, 페이징 처리를 구현할 때 필요한 고급 기술들입니다.
📊 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 하나가 아니라 기준, 기간, 상태값까지 같이 정해야 한다.
커서 페이징도 기억에 남는다. 예전에는 limit과 offset이면 끝이라고 생각했는데, 커뮤니티 피드가 커질수록 뒤 페이지가 느려지고 새 글 때문에 목록이 흔들릴 수 있다는 걸 알았다.
💡 "고급 쿼리는 복잡한 문법을 쓰는 능력이 아니라, 화면의 질문을 DB가 빠르고 일관되게 답하도록 만드는 능력이다."
내일은 통계나 검색 PR을 보면 인덱스, 정렬 기준, 사용자 입력 처리, 반환 타입을 같이 확인해야겠다.
🔗 레퍼런스
다음 장: 07. Zod 통합 (Validation)