LoaderQuery 개요
1:N 관계
부모 → 자식 여러 개HasMany
N:M 관계
양방향 다대다 관계ManyToMany
N+1 해결
효율적인 데이터 로딩Batch Loading
중첩 Loader
재귀적 관계 로딩Nested Relations
LoaderQuery vs SubsetQuery

SubsetQuery (JOIN)
OneToOne 또는 BelongsTo 관계에 적합:복사
// SubsetQuery: LEFT JOIN으로 처리
{
"subsets": {
"P": [
"id",
"username",
"employee.salary", // ← OneToOne
"employee.department.name" // ← BelongsTo
]
}
}
// SQL: LEFT JOIN employees, LEFT JOIN departments
// 결과: 1행당 1개의 employee
LoaderQuery (Separate Query)
HasMany 또는 ManyToMany 관계에 적합:복사
// LoaderQuery: 별도 쿼리로 처리
{
loaders: [
{
as: "posts",
refId: "user_id",
qb: (qb, fromIds) =>
qb.table("posts")
.whereIn("user_id", fromIds)
}
]
}
// SQL: SELECT * FROM posts WHERE user_id IN (1, 2, 3)
// 결과: 1명당 N개의 posts 배열
LoaderQuery 정의
LoaderQuery는subset-loaders.ts 파일에서 정의합니다:
복사
// src/application/user/user.subset-loaders.ts
import type { SubsetLoaderMap } from "sonamu";
export const UserSubsetLoaders: SubsetLoaderMap<"User"> = {
// Subset A에 posts 로더 추가
A: [
{
as: "posts", // 결과 필드명
refId: "user_id", // 참조 필드 (posts.user_id)
qb: (qb, fromIds) =>
qb.table("posts")
.whereIn("user_id", fromIds)
.select({
id: "id",
title: "title",
created_at: "created_at",
})
.orderBy("created_at", "desc"),
},
],
};
LoaderQuery는 자동으로 Batch Loading을 수행하여 N+1 문제를 해결합니다.
1:N 관계 (HasMany)
부서 → 직원들
복사
// Department Entity
export const DepartmentSubsetLoaders: SubsetLoaderMap<"Department"> = {
P: [
{
as: "employees",
refId: "department_id",
qb: (qb, fromIds) =>
qb.table("employees")
.whereIn("department_id", fromIds)
.join("users", "employees.user_id", "users.id")
.select({
id: "employees.id",
employee_number: "employees.employee_number",
username: "users.username",
salary: "employees.salary",
})
.orderBy("employees.employee_number", "asc"),
},
],
};
복사
const dept = await DepartmentModel.findById(1, ["P"]);
// 타입:
// {
// id: number;
// name: string;
// employees: Array<{
// id: number;
// employee_number: string;
// username: string;
// salary: string;
// }>;
// }
console.log(`${dept.name} 부서 직원 수: ${dept.employees.length}`);
dept.employees.forEach(emp => {
console.log(`- ${emp.username} (${emp.employee_number})`);
});
N:M 관계 (ManyToMany)
프로젝트 ↔ 직원
복사
// Project Entity
export const ProjectSubsetLoaders: SubsetLoaderMap<"Project"> = {
P: [
{
as: "members",
refId: "project_id",
qb: (qb, fromIds) =>
qb.table("projects__employees")
.whereIn("project_id", fromIds)
.join("employees", "projects__employees.employee_id", "employees.id")
.join("users", "employees.user_id", "users.id")
.select({
id: "employees.id",
username: "users.username",
employee_number: "employees.employee_number",
role: "projects__employees.role", // 중간 테이블 필드
}),
},
],
};
복사
const project = await ProjectModel.findById(1, ["P"]);
// 타입:
// {
// id: number;
// name: string;
// members: Array<{
// id: number;
// username: string;
// employee_number: string;
// role: string; // 중간 테이블의 role
// }>;
// }
console.log(`프로젝트: ${project.name}`);
console.log(`참여자 ${project.members.length}명:`);
project.members.forEach(member => {
console.log(`- ${member.username} (${member.role})`);
});
중첩 LoaderQuery

복사
// Department Entity
export const DepartmentSubsetLoaders: SubsetLoaderMap<"Department"> = {
P: [
{
as: "employees",
refId: "department_id",
qb: (qb, fromIds) =>
qb.table("employees")
.whereIn("department_id", fromIds)
.join("users", "employees.user_id", "users.id")
.select({
id: "employees.id",
username: "users.username",
user_id: "users.id", // ← 다음 Loader를 위한 refId
}),
// 중첩 Loader: 직원 → 프로젝트들
loaders: [
{
as: "projects",
refId: "employee_id",
qb: (qb, fromIds) =>
qb.table("projects__employees")
.whereIn("employee_id", fromIds)
.join("projects", "projects__employees.project_id", "projects.id")
.select({
id: "projects.id",
name: "projects.name",
status: "projects.status",
}),
},
],
},
],
};
복사
{
id: 1,
name: "Engineering",
employees: [
{
id: 1,
username: "john",
projects: [
{ id: 1, name: "Project A", status: "in_progress" },
{ id: 2, name: "Project B", status: "completed" }
]
},
{
id: 2,
username: "jane",
projects: [
{ id: 3, name: "Project C", status: "in_progress" }
]
}
]
}
Batch Loading 동작 원리

N+1 문제
복사
// ❌ N+1 문제 발생
const users = await UserModel.findMany({ subsetKey: "L" }); // 1 query
for (const user of users) {
const posts = await PostModel.findMany({
where: [["user_id", user.id]],
subsetKey: "L",
}); // N queries (users 수만큼)
console.log(`${user.username}: ${posts.length} posts`);
}
// 총 쿼리: 1 + N개
Batch Loading 해결
복사
// ✅ Batch Loading으로 해결
const users = await UserModel.findMany({
subsetKey: "A", // A Subset에 posts Loader 포함
});
// 쿼리 1: SELECT * FROM users
// 쿼리 2: SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...)
users.forEach(user => {
console.log(`${user.username}: ${user.posts.length} posts`);
});
// 총 쿼리: 2개 (users 수와 무관)
실전 예제
부서별 직원 목록
복사
async getDepartmentWithEmployees(deptId: number) {
const dept = await DepartmentModel.findById(deptId, ["P"]);
if (!dept) {
throw new NotFoundException("Department not found");
}
return {
id: dept.id,
name: dept.name,
employeeCount: dept.employees.length,
employees: dept.employees.map(emp => ({
id: emp.id,
name: emp.username,
number: emp.employee_number,
salary: emp.salary,
})),
};
}
프로젝트 대시보드
복사
async getProjectDashboard(projectId: number) {
const project = await ProjectModel.findById(projectId, ["Dashboard"]);
if (!project) {
throw new NotFoundException("Project not found");
}
return {
id: project.id,
name: project.name,
status: project.status,
// Members from LoaderQuery
members: project.members.map(m => ({
id: m.id,
name: m.username,
role: m.role,
})),
// Tasks from LoaderQuery
tasks: project.tasks.map(t => ({
id: t.id,
title: t.title,
status: t.status,
assignee: t.assignee_name,
})),
stats: {
memberCount: project.members.length,
taskCount: project.tasks.length,
completedTasks: project.tasks.filter(t => t.status === "completed").length,
},
};
}
사용자 활동 요약
복사
// User Subset with posts and comments
const user = await UserModel.findById(userId, ["Activity"]);
const summary = {
user: {
id: user.id,
username: user.username,
email: user.email,
},
activity: {
totalPosts: user.posts.length,
recentPosts: user.posts.slice(0, 5).map(p => ({
id: p.id,
title: p.title,
createdAt: p.created_at,
})),
totalComments: user.comments.length,
recentComments: user.comments.slice(0, 5).map(c => ({
id: c.id,
content: c.content,
postTitle: c.post_title,
createdAt: c.created_at,
})),
},
};
LoaderQuery 설계 원칙
1. 필요한 필드만 SELECT
복사
// ❌ 나쁨: 모든 필드 로딩
{
as: "employees",
refId: "department_id",
qb: (qb, fromIds) =>
qb.table("employees")
.whereIn("department_id", fromIds)
.selectAll(), // 모든 필드 (password 등 포함)
}
// ✅ 좋음: 필요한 필드만
{
as: "employees",
refId: "department_id",
qb: (qb, fromIds) =>
qb.table("employees")
.whereIn("department_id", fromIds)
.select({
id: "id",
username: "username",
employee_number: "employee_number",
}),
}
2. 정렬과 제한
복사
// 최근 5개 게시글만
{
as: "recentPosts",
refId: "user_id",
qb: (qb, fromIds) =>
qb.table("posts")
.whereIn("user_id", fromIds)
.orderBy("created_at", "desc")
.limit(5),
}
3. 조건 필터링
복사
// 완료된 프로젝트만
{
as: "completedProjects",
refId: "employee_id",
qb: (qb, fromIds) =>
qb.table("projects__employees")
.whereIn("employee_id", fromIds)
.join("projects", "projects__employees.project_id", "projects.id")
.where("projects.status", "completed")
.select({
id: "projects.id",
name: "projects.name",
}),
}
