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๋ ์ฌ๊ท์ ์ผ๋ก ์ค์ฒฉํ ์ ์์ต๋๋ค:
// 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",
}),
}
์ค์ ๋ณต์กํ ์๋๋ฆฌ์ค
์ค๋ฌด์์ ์์ฃผ ๋ง์ฃผ์น๋ ๋ณต์กํ 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๊ฐ๋ก ์๋ฃ | ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ ๋ง์ | ํฐ ํธ๋ฆฌ |
| ๊น์ด ์ ํ | ์ฑ๋ฅ ์์ธก ๊ฐ๋ฅ | ์ ์ฒด ํธ๋ฆฌ ๋ชป ๋ด | ๋งค์ฐ ๊น์ ํธ๋ฆฌ |
์ํ ์ฐธ์กฐ ์ฃผ์์ฌํญ:
- ๋ฌดํ ๋ฃจํ ๋ฐฉ์ง: ์ต๋ ๊น์ด ์ ํ ํ์
- ์ํ ๋ฐ์ดํฐ: A โ B โ A ๊ฐ์ ์๋ชป๋ ๋ฐ์ดํฐ ๊ฒ์ฆ
- ์ฑ๋ฅ: ๊น์ด๊ฐ ๊น์ด์ง์๋ก ์ฌ๊ท ํธ์ถ ์ฆ๊ฐ
- ๋ฉ๋ชจ๋ฆฌ: ํฐ ํธ๋ฆฌ๋ ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋ ์ฃผ์
์๋๋ฆฌ์ค 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:
- ์ ํ์ ํํฐ: undefined ์ฒดํฌ๋ก ์กฐ๊ฑด๋ถ ์ ์ฉ
- ํ์
์์ ์ฑ: TypeScript๋ก ํํฐ ํ๋ผ๋ฏธํฐ ์ ์
- SQL Injection ๋ฐฉ์ง: ํ๋ผ๋ฏธํฐ ๋ฐ์ธ๋ฉ ์ฌ์ฉ
- ์ฑ๋ฅ ์ต์ ํ: ์ธ๋ฑ์ค๊ฐ ์๋ ์ปฌ๋ผ ์ฐ์ ํํฐ๋ง
๋ค์ ๋จ๊ณ