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

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

์ค‘์ฒฉ 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",
      }),
}

์‹ค์ „ ๋ณต์žกํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค

์‹ค๋ฌด์—์„œ ์ž์ฃผ ๋งˆ์ฃผ์น˜๋Š” ๋ณต์žกํ•œ Subset ํŒจํ„ด๊ณผ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

์‹œ๋‚˜๋ฆฌ์˜ค 1: ๋™์  Subset ์„ ํƒ

์‚ฌ์šฉ์ž ๊ถŒํ•œ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ Subset์„ ์‚ฌ์šฉํ•˜๋Š” ํŒจํ„ด
class PostModelClass extends BaseModelClass {
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
  async findMany<T extends PostSubsetKey>(
    subset: T,
    params: PostListParams
  ): Promise<ListResult<PostListParams, PostSubsetMapping[T]>> {
    const context = Sonamu.getContext();

    // ์‚ฌ์šฉ์ž ๊ถŒํ•œ์— ๋”ฐ๋ผ Subset ๋™์  ๊ฒฐ์ •
    let effectiveSubset: PostSubsetKey;

    if (context.user?.role === "admin") {
      // ๊ด€๋ฆฌ์ž: ๋ชจ๋“  ์ •๋ณด ํฌํ•จ (์ž‘์„ฑ์ž ์ƒ์„ธ, ํ†ต๊ณ„ ๋“ฑ)
      effectiveSubset = "AdminList" as T;
    } else if (context.user?.role === "normal") {
      // ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž: ๊ธฐ๋ณธ ์ •๋ณด๋งŒ
      effectiveSubset = "List" as T;
    } else {
      // ๊ฒŒ์ŠคํŠธ: ๊ณต๊ฐœ ์ •๋ณด๋งŒ
      effectiveSubset = "PublicList" as T;
    }

    const { qb, onSubset } = this.getSubsetQueries(effectiveSubset);

    // ๊ถŒํ•œ๋ณ„ ์ถ”๊ฐ€ ํ•„ํ„ฐ๋ง
    if (context.user?.role !== "admin") {
      // ๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€ ์ œ์™ธ
      qb.where("posts.is_public", true);
    }

    return this.executeSubsetQuery({
      subset: effectiveSubset,
      qb,
      params,
    });
  }
}
ํ•ต์‹ฌ ํฌ์ธํŠธ:
  • ์‚ฌ์šฉ์ž Context๋ฅผ ํ™•์ธํ•˜์—ฌ ์ ์ ˆํ•œ Subset ์„ ํƒ
  • ํƒ€์ž… ๋‹จ์–ธ(as T)์œผ๋กœ ํƒ€์ž… ์•ˆ์ „์„ฑ ์œ ์ง€
  • ๊ถŒํ•œ๋ณ„ ์ถ”๊ฐ€ ํ•„ํ„ฐ๋ง ์ ์šฉ

์‹œ๋‚˜๋ฆฌ์˜ค 2: ๊นŠ์€ ์ค‘์ฒฉ ๊ด€๊ณ„ (3๋‹จ๊ณ„ ์ด์ƒ)

์—ฌ๋Ÿฌ ๋‹จ๊ณ„๋กœ ์ค‘์ฒฉ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๋กœ๋“œ
// Entity ์ •์˜: Comment Subset
export const commentSubsetQueries = {
  // ๋Œ€๋Œ“๊ธ€ ํฌํ•จ (3๋‹จ๊ณ„: Post โ†’ Comment โ†’ Reply)
  WithReplies: {
    _one: {
      id: "comments.id",
      content: "comments.content",
      created_at: "comments.created_at",
      // ์ž‘์„ฑ์ž (2๋‹จ๊ณ„)
      author__id: "users.id",
      author__username: "users.username",
    },
    _many: {
      // ๋Œ€๋Œ“๊ธ€ (3๋‹จ๊ณ„)
      replies: {
        as: "replies",
        refId: "parent_id",
        qb: (qb, fromIds) =>
          qb.table("comments")
            .whereIn("parent_id", fromIds)
            .where("deleted_at", null)
            .leftJoin("users", "comments.user_id", "users.id")
            .select({
              id: "comments.id",
              content: "comments.content",
              created_at: "comments.created_at",
              // ๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ์ž
              author__id: "users.id",
              author__username: "users.username",
              author__profile__avatar: "profiles.avatar_url",
            })
            .leftJoin("profiles", "users.id", "profiles.user_id")
            .orderBy("comments.created_at", "asc"),
      },
    },
  },
} as const;

// Model ์‚ฌ์šฉ
class PostModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findByIdWithComments(id: number): Promise<Post> {
    // Post โ†’ Comment โ†’ Reply โ†’ Author๊นŒ์ง€ ํ•œ ๋ฒˆ์— ๋กœ๋“œ
    const post = await this.findById("DetailWithComments", id);

    // ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผ
    post.comments?.forEach((comment) => {
      console.log(comment.author?.username); // ๋Œ“๊ธ€ ์ž‘์„ฑ์ž

      comment.replies?.forEach((reply) => {
        console.log(reply.author?.username); // ๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ์ž
        console.log(reply.author?.profile?.avatar); // ํ”„๋กœํ•„ ์ด๋ฏธ์ง€
      });
    });

    return post;
  }
}
์„ฑ๋Šฅ ๊ณ ๋ ค์‚ฌํ•ญ:
  • 3๋‹จ๊ณ„ ์ด์ƒ ์ค‘์ฒฉ ์‹œ ์ฟผ๋ฆฌ ์ˆ˜ ์ฆ๊ฐ€ (N+1 ๋ฌธ์ œ ์ฃผ์˜)
  • ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ๊นŠ์€ ์ค‘์ฒฉ ์‚ฌ์šฉ
  • ํŽ˜์ด์ง€๋„ค์ด์…˜๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์–‘ ์ œํ•œ

์‹œ๋‚˜๋ฆฌ์˜ค 3: ์กฐ๊ฑด๋ถ€ ์ค‘์ฒฉ ๋กœ๋”ฉ

ํŠน์ • ์กฐ๊ฑด์ผ ๋•Œ๋งŒ ์ค‘์ฒฉ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œ
class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findManyWithOptionalPosts<T extends UserSubsetKey>(
    subset: T,
    params: UserListParams,
    includePosts: boolean = false
  ): Promise<ListResult<UserListParams, UserSubsetMapping[T]>> {
    const { qb, onSubset } = this.getSubsetQueries(subset);

    // ๊ธฐ๋ณธ ์ฟผ๋ฆฌ ๊ตฌ์„ฑ
    qb.where("users.deleted_at", null);

    // ์กฐ๊ฑด๋ถ€ ์ค‘์ฒฉ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ
    if (includePosts) {
      onSubset({
        List: () => {
          // List Subset์— ๊ฒŒ์‹œ๊ธ€ ๋™์  ์ถ”๊ฐ€
          qb.onExecute(async (rows) => {
            const userIds = rows.map((row) => row.id);

            // ๊ฒŒ์‹œ๊ธ€ ๋ณ„๋„ ์กฐํšŒ
            const posts = await this.getPuri("r")
              .table("posts")
              .whereIn("user_id", userIds)
              .where("is_public", true)
              .select({
                id: "id",
                user_id: "user_id",
                title: "title",
                created_at: "created_at",
              })
              .orderBy("created_at", "desc");

            // ์‚ฌ์šฉ์ž๋ณ„๋กœ ๊ฒŒ์‹œ๊ธ€ ๊ทธ๋ฃนํ™”
            const postsByUser = posts.reduce((acc, post) => {
              if (!acc[post.user_id]) {
                acc[post.user_id] = [];
              }
              acc[post.user_id].push(post);
              return acc;
            }, {} as Record<number, typeof posts>);

            // ๊ฒฐ๊ณผ์— ๊ฒŒ์‹œ๊ธ€ ์ถ”๊ฐ€
            return rows.map((row) => ({
              ...row,
              posts: postsByUser[row.id] || [],
            }));
          });
        },
      });
    }

    return this.executeSubsetQuery({ subset, qb, params });
  }
}
ํ™œ์šฉ ์‚ฌ๋ก€:
  • ๋ชจ๋ฐ”์ผ vs ์›น์—์„œ ๋‹ค๋ฅธ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
  • ์‚ฌ์šฉ์ž ์„ค์ •์— ๋”ฐ๋ฅธ ์„ ํƒ์  ๋ฐ์ดํ„ฐ ๋กœ๋“œ
  • ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ํ•„์š”์‹œ์—๋งŒ ๋กœ๋“œ

์‹œ๋‚˜๋ฆฌ์˜ค 4: N+1 ๋ฌธ์ œ ํ•ด๊ฒฐ

์—ฌ๋Ÿฌ ๊ด€๊ณ„๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๋กœ๋“œํ•˜์—ฌ N+1 ๋ฌธ์ œ ๋ฐฉ์ง€
// โŒ ๋‚˜์œ ์˜ˆ: N+1 ๋ฌธ์ œ ๋ฐœ์ƒ
class PostModelClass extends BaseModelClass {
  async getPostsWithAuthorsBad(): Promise<PostWithAuthor[]> {
    const posts = await this.getPuri("r")
      .table("posts")
      .select({ id: "id", title: "title", user_id: "user_id" });

    // ๊ฐ ๊ฒŒ์‹œ๊ธ€๋งˆ๋‹ค ์ž‘์„ฑ์ž ์กฐํšŒ โ†’ N+1 ๋ฌธ์ œ!
    const results = [];
    for (const post of posts) {
      const author = await UserModel.findById("Simple", post.user_id);
      results.push({ ...post, author });
    }

    return results;
  }
}

// โœ… ์ข‹์€ ์˜ˆ: Subset์œผ๋กœ ํ•œ ๋ฒˆ์— ๋กœ๋“œ
class PostModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getPostsWithAuthorsGood(): Promise<PostSubsetMapping["WithAuthor"][]> {
    // Subset ์ •์˜์— author ๊ด€๊ณ„ ํฌํ•จ
    const { rows } = await this.findMany("WithAuthor", { num: 100 });

    // 2๊ฐœ์˜ ์ฟผ๋ฆฌ๋กœ ์™„๋ฃŒ:
    // 1. SELECT * FROM posts ...
    // 2. SELECT * FROM users WHERE id IN (...)

    return rows;
  }
}

// Subset ์ •์˜
export const postSubsetQueries = {
  WithAuthor: {
    _one: {
      id: "posts.id",
      title: "posts.title",
      content: "posts.content",
      // ์ž‘์„ฑ์ž ์ •๋ณด
      author__id: "users.id",
      author__username: "users.username",
      author__email: "users.email",
    },
  },
} as const;
N+1 ๋ฌธ์ œ ๋น„๊ต:
๋ฐฉ์‹์ฟผ๋ฆฌ ์ˆ˜์„ฑ๋Šฅ๋ฌธ์ œ์ 
๋‚˜์œ ์˜ˆ (๋ฐ˜๋ณต๋ฌธ ์กฐํšŒ)N+1๊ฐœ (๊ฒŒ์‹œ๊ธ€ 100๊ฐœ โ†’ 101๊ฐœ ์ฟผ๋ฆฌ)๋งค์šฐ ๋А๋ฆผDB ๋ถ€ํ•˜ ์ฆ๊ฐ€
์ข‹์€ ์˜ˆ (Subset)2๊ฐœ (posts + users)๋น ๋ฆ„์—†์Œ

์‹œ๋‚˜๋ฆฌ์˜ค 5: ์ˆœํ™˜ ์ฐธ์กฐ ์ฒ˜๋ฆฌ (์ž๊ธฐ ์ฐธ์กฐ)

๋Œ“๊ธ€ ๋Œ€๋Œ“๊ธ€, ์กฐ์ง๋„ ๋“ฑ ์ž๊ธฐ ์ฐธ์กฐ ๊ด€๊ณ„ ์ฒ˜๋ฆฌ
// Entity: ์กฐ์ง๋„ (์ƒ์‚ฌ-๋ถ€ํ•˜ ๊ด€๊ณ„)
export const employeeSubsetQueries = {
  // ์ง์† ๋ถ€ํ•˜ ํฌํ•จ
  WithDirectReports: {
    _one: {
      id: "employees.id",
      username: "employees.username",
      position: "employees.position",
      manager_id: "employees.manager_id",
    },
    _many: {
      // ์ง์† ๋ถ€ํ•˜๋“ค (1๋‹จ๊ณ„๋งŒ)
      directReports: {
        as: "directReports",
        refId: "manager_id",
        qb: (qb, fromIds) =>
          qb.table("employees")
            .whereIn("manager_id", fromIds)
            .where("deleted_at", null)
            .select({
              id: "id",
              username: "username",
              position: "position",
            })
            .orderBy("position", "asc"),
      },
    },
  },
} as const;

// Model: ์ „์ฒด ์กฐ์ง๋„ ํŠธ๋ฆฌ ๊ตฌ์„ฑ
class EmployeeModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getOrganizationTree(
    rootId: number,
    maxDepth: number = 3
  ): Promise<EmployeeTreeNode> {
    // ์žฌ๊ท€์ ์œผ๋กœ ์กฐ์ง๋„ ๊ตฌ์„ฑ
    return this.buildTreeRecursive(rootId, 0, maxDepth);
  }

  private async buildTreeRecursive(
    employeeId: number,
    currentDepth: number,
    maxDepth: number
  ): Promise<EmployeeTreeNode> {
    // ์ตœ๋Œ€ ๊นŠ์ด ์ œํ•œ
    if (currentDepth >= maxDepth) {
      const employee = await this.findById("Simple", employeeId);
      return { ...employee, children: [] };
    }

    // ์ง์† ๋ถ€ํ•˜ ํฌํ•จ ์กฐํšŒ
    const employee = await this.findById("WithDirectReports", employeeId);

    // ๊ฐ ๋ถ€ํ•˜์— ๋Œ€ํ•ด ์žฌ๊ท€ ํ˜ธ์ถœ
    const children = await Promise.all(
      (employee.directReports || []).map((report) =>
        this.buildTreeRecursive(report.id, currentDepth + 1, maxDepth)
      )
    );

    return {
      ...employee,
      children,
      depth: currentDepth,
    };
  }

  @api({ httpMethod: "GET" })
  async getOrganizationTreeOptimized(
    rootId: number
  ): Promise<EmployeeTreeNode> {
    // ์ตœ์ ํ™”: ํ•œ ๋ฒˆ์— ๋ชจ๋“  ์ง์› ์กฐํšŒ ํ›„ ๋ฉ”๋ชจ๋ฆฌ์—์„œ ํŠธ๋ฆฌ ๊ตฌ์„ฑ
    const allEmployees = await this.findMany("WithDirectReports", {
      num: 10000,
    });

    // Map์œผ๋กœ ๋น ๋ฅธ ์กฐํšŒ
    const employeeMap = new Map(
      allEmployees.rows.map((emp) => [emp.id, emp])
    );

    // ํŠธ๋ฆฌ ๊ตฌ์„ฑ
    return this.buildTreeFromMap(rootId, employeeMap);
  }

  private buildTreeFromMap(
    employeeId: number,
    employeeMap: Map<number, Employee>
  ): EmployeeTreeNode {
    const employee = employeeMap.get(employeeId);
    if (!employee) {
      throw new NotFoundError(`์ง์› ${employeeId}๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค`);
    }

    // ์ง์† ๋ถ€ํ•˜ ์ฐพ๊ธฐ
    const children = Array.from(employeeMap.values())
      .filter((emp) => emp.manager_id === employeeId)
      .map((emp) => this.buildTreeFromMap(emp.id, employeeMap));

    return {
      ...employee,
      children,
    };
  }
}

interface EmployeeTreeNode extends Employee {
  children: EmployeeTreeNode[];
  depth?: number;
}
์ž๊ธฐ ์ฐธ์กฐ ํŒจํ„ด ๋น„๊ต:
๋ฐฉ์‹์žฅ์ ๋‹จ์ ์‚ฌ์šฉ ์‹œ์ 
์žฌ๊ท€ ์กฐํšŒ๊ฐ„๋‹จํ•œ ๊ตฌํ˜„์ฟผ๋ฆฌ ์ˆ˜ ๋งŽ์Œ (๊นŠ์ด๋งŒํผ)์ž‘์€ ํŠธ๋ฆฌ
์ผ๊ด„ ์กฐํšŒ + ๋ฉ”๋ชจ๋ฆฌ ๊ตฌ์„ฑ์ฟผ๋ฆฌ 1๊ฐœ๋กœ ์™„๋ฃŒ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ ๋งŽ์Œํฐ ํŠธ๋ฆฌ
๊นŠ์ด ์ œํ•œ์„ฑ๋Šฅ ์˜ˆ์ธก ๊ฐ€๋Šฅ์ „์ฒด ํŠธ๋ฆฌ ๋ชป ๋ด„๋งค์šฐ ๊นŠ์€ ํŠธ๋ฆฌ
์ˆœํ™˜ ์ฐธ์กฐ ์ฃผ์˜์‚ฌํ•ญ:
  1. ๋ฌดํ•œ ๋ฃจํ”„ ๋ฐฉ์ง€: ์ตœ๋Œ€ ๊นŠ์ด ์ œํ•œ ํ•„์ˆ˜
  2. ์ˆœํ™˜ ๋ฐ์ดํ„ฐ: A โ†’ B โ†’ A ๊ฐ™์€ ์ž˜๋ชป๋œ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ
  3. ์„ฑ๋Šฅ: ๊นŠ์ด๊ฐ€ ๊นŠ์–ด์งˆ์ˆ˜๋ก ์žฌ๊ท€ ํ˜ธ์ถœ ์ฆ๊ฐ€
  4. ๋ฉ”๋ชจ๋ฆฌ: ํฐ ํŠธ๋ฆฌ๋Š” ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ฃผ์˜

์‹œ๋‚˜๋ฆฌ์˜ค 6: ๋™์  ํ•„ํ„ฐ๋ง with Subset

์‚ฌ์šฉ์ž ์ž…๋ ฅ์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ํ•„ํ„ฐ๋ฅผ ์ ์šฉํ•˜๋ฉด์„œ Subset ํ™œ์šฉ
interface ProductSearchParams {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  tags?: string[];
  inStock?: boolean;
  sortBy?: "price" | "popularity" | "recent";
}

class ProductModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async search<T extends ProductSubsetKey>(
    subset: T,
    searchParams: ProductSearchParams,
    listParams: ProductListParams
  ): Promise<ListResult<ProductListParams, ProductSubsetMapping[T]>> {
    const { qb, onSubset } = this.getSubsetQueries(subset);

    // ๋™์  ํ•„ํ„ฐ ์ ์šฉ
    if (searchParams.category) {
      qb.where("products.category", searchParams.category);
    }

    if (searchParams.minPrice !== undefined) {
      qb.where("products.price", ">=", searchParams.minPrice);
    }

    if (searchParams.maxPrice !== undefined) {
      qb.where("products.price", "<=", searchParams.maxPrice);
    }

    if (searchParams.tags && searchParams.tags.length > 0) {
      // PostgreSQL array contains
      qb.whereRaw("products.tags @> ?", [JSON.stringify(searchParams.tags)]);
    }

    if (searchParams.inStock === true) {
      qb.where("products.stock", ">", 0);
    }

    // ๋™์  ์ •๋ ฌ
    switch (searchParams.sortBy) {
      case "price":
        qb.orderBy("products.price", "asc");
        break;
      case "popularity":
        qb.orderBy("products.view_count", "desc");
        break;
      case "recent":
        qb.orderBy("products.created_at", "desc");
        break;
      default:
        qb.orderBy("products.id", "desc");
    }

    // Subset๋ณ„ ์ถ”๊ฐ€ ๋กœ์ง
    onSubset({
      ListWithReviews: () => {
        // ๋ฆฌ๋ทฐ ํฌํ•จ ์‹œ ํ‰๊ท  ํ‰์  ๊ณ„์‚ฐ
        qb.select({
          avg_rating: qb.raw(
            "(SELECT AVG(rating) FROM reviews WHERE product_id = products.id)"
          ),
        });
      },
    });

    return this.executeSubsetQuery({ subset, qb, params: listParams });
  }
}
๋™์  ํ•„ํ„ฐ๋ง Best Practice:
  1. ์„ ํƒ์  ํ•„ํ„ฐ: undefined ์ฒดํฌ๋กœ ์กฐ๊ฑด๋ถ€ ์ ์šฉ
  2. ํƒ€์ž… ์•ˆ์ „์„ฑ: TypeScript๋กœ ํ•„ํ„ฐ ํŒŒ๋ผ๋ฏธํ„ฐ ์ •์˜
  3. SQL Injection ๋ฐฉ์ง€: ํŒŒ๋ผ๋ฏธํ„ฐ ๋ฐ”์ธ๋”ฉ ์‚ฌ์šฉ
  4. ์„ฑ๋Šฅ ์ตœ์ ํ™”: ์ธ๋ฑ์Šค๊ฐ€ ์žˆ๋Š” ์ปฌ๋Ÿผ ์šฐ์„  ํ•„ํ„ฐ๋ง

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