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",
}),
}