๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
LoaderQuery๋Š” 1:N, N:M ๊ด€๊ณ„์˜ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๋กœ๋”ฉํ•˜๋Š” ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค.

LoaderQuery ๊ฐœ์š”

1:N ๊ด€๊ณ„

๋ถ€๋ชจ โ†’ ์ž์‹ ์—ฌ๋Ÿฌ ๊ฐœHasMany

N:M ๊ด€๊ณ„

์–‘๋ฐฉํ–ฅ ๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„ManyToMany

N+1 ํ•ด๊ฒฐ

ํšจ์œจ์ ์ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉBatch Loading

์ค‘์ฒฉ Loader

์žฌ๊ท€์  ๊ด€๊ณ„ ๋กœ๋”ฉNested Relations

LoaderQuery vs SubsetQuery

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

์ค‘์ฒฉ LoaderQuery ๊ตฌ์กฐ
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 ๋ฌธ์ œ์™€ 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",
      }),
}

๋‹ค์Œ ๋‹จ๊ณ„