๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
N+1 ์ฟผ๋ฆฌ ๋ฌธ์ œ๋ฅผ ์ดํ•ดํ•˜๊ณ  Sonamu์˜ Subset๊ณผ Relation ๊ธฐ๋Šฅ์œผ๋กœ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹ค๋ฃน๋‹ˆ๋‹ค.

N+1 ๋ฌธ์ œ๋ž€?

N+1 ๋ฌธ์ œ๋Š” 1๊ฐœ์˜ ๋ฉ”์ธ ์ฟผ๋ฆฌ ํ›„, N๊ฐœ์˜ ์ถ”๊ฐ€ ์ฟผ๋ฆฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ์„ฑ๋Šฅ ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค.

๋ฌธ์ œ ์˜ˆ์‹œ

// โŒ N+1 ๋ฌธ์ œ ๋ฐœ์ƒ
async function getUsersWithOrders() {
  // 1. ์‚ฌ์šฉ์ž ์กฐํšŒ (1๋ฒˆ ์ฟผ๋ฆฌ)
  const users = await UserModel.getPuri().select("id", "name");  // 100๋ช…์˜ ์‚ฌ์šฉ์ž
  
  // 2. ๊ฐ ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ์กฐํšŒ (N๋ฒˆ ์ฟผ๋ฆฌ)
  for (const user of users) {
    const orders = await OrderModel.getPuri()
      .select("*")
      .where({ user_id: user.id });
    
    user.orders = orders;  // 100๋ฒˆ ์ฟผ๋ฆฌ!
  }
  
  return users;
}

// ์ด ์ฟผ๋ฆฌ: 1 + 100 = 101๋ฒˆ
์„ฑ๋Šฅ ์˜ํ–ฅ:
  • 100๋ช… ์‚ฌ์šฉ์ž โ†’ 101๋ฒˆ ์ฟผ๋ฆฌ
  • 1000๋ช… ์‚ฌ์šฉ์ž โ†’ 1001๋ฒˆ ์ฟผ๋ฆฌ
  • ๋„คํŠธ์›Œํฌ ์™•๋ณต ์‹œ๊ฐ„ ๋ˆ„์ 
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ถ€ํ•˜ ์ฆ๊ฐ€

Sonamu์˜ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

Sonamu๋Š” Subset์„ ํ†ตํ•ด N+1 ๋ฌธ์ œ๋ฅผ ์ž๋™์œผ๋กœ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค.

1. Subset ์ •์˜

Entity์—์„œ Subset์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:
// user.entity.json
{
  "name": "User",
  "properties": [
    {
      "name": "id",
      "type": "int",
      "isPrimary": true
    },
    {
      "name": "name",
      "type": "varchar"
    }
  ],
  "subsets": {
    "A": {
      "fields": ["id", "name"]
    },
    "WithOrders": {
      "fields": ["id", "name"],
      "relations": [
        {
          "name": "orders",
          "subset": "A"
        }
      ]
    }
  }
}

2. Subset ์‚ฌ์šฉ

// โœ… Subset์œผ๋กœ N+1 ํ•ด๊ฒฐ
async function getUsersWithOrders() {
  // getSubsetQueries๋กœ ์ฟผ๋ฆฌ ๋นŒ๋” ์–ป๊ธฐ
  const { qb } = UserModel.getSubsetQueries("WithOrders");
  
  // ์ž๋™์œผ๋กœ ์ตœ์ ํ™”๋œ ์ฟผ๋ฆฌ ์‹คํ–‰
  const result = await UserModel.executeSubsetQuery({
    subset: "WithOrders",
    qb,
    params: { num: 100, page: 1 }
  });
  
  // result.rows[0].orders๋Š” ์ด๋ฏธ ๋กœ๋“œ๋จ
  // ์ถ”๊ฐ€ ์ฟผ๋ฆฌ ์—†์Œ!
  
  return result.rows;
}

// ์ด ์ฟผ๋ฆฌ: 2๋ฒˆ (users 1๋ฒˆ + orders 1๋ฒˆ)
๋‚ด๋ถ€ ๋™์ž‘:
  1. ์‚ฌ์šฉ์ž ์กฐํšŒ (1๋ฒˆ)
  2. ๋ชจ๋“  ์‚ฌ์šฉ์ž ID๋กœ ์ฃผ๋ฌธ ์ผ๊ด„ ์กฐํšŒ (1๋ฒˆ)
  3. ๋ฉ”๋ชจ๋ฆฌ์—์„œ ์กฐ์ธ

Subset ํ™œ์šฉ ํŒจํ„ด

๋‹จ์ผ Relation

// post.entity.json
{
  "name": "Post",
  "subsets": {
    "WithAuthor": {
      "fields": ["id", "title", "content"],
      "relations": [
        {
          "name": "author",
          "subset": "A"
        }
      ]
    }
  }
}
// ๊ฒŒ์‹œ๊ธ€๊ณผ ์ž‘์„ฑ์ž ์กฐํšŒ
const { qb } = PostModel.getSubsetQueries("WithAuthor");
const result = await PostModel.executeSubsetQuery({
  subset: "WithAuthor",
  qb,
  params: { num: 20, page: 1 }
});

result.rows.forEach(post => {
  console.log(`${post.title} by ${post.author.name}`);
});

๋‹ค์ค‘ Relations

// order.entity.json
{
  "name": "Order",
  "subsets": {
    "Detail": {
      "fields": ["id", "total", "status"],
      "relations": [
        {
          "name": "user",
          "subset": "A"
        },
        {
          "name": "items",
          "subset": "A"
        },
        {
          "name": "payment",
          "subset": "A"
        }
      ]
    }
  }
}
// ์ฃผ๋ฌธ + ์‚ฌ์šฉ์ž + ์•„์ดํ…œ + ๊ฒฐ์ œ ์ •๋ณด
const { qb } = OrderModel.getSubsetQueries("Detail");
const result = await OrderModel.executeSubsetQuery({
  subset: "Detail",
  qb: qb.where({ status: "completed" }),
  params: { num: 20, page: 1 }
});

// ๋ชจ๋“  ๊ด€๊ณ„๊ฐ€ ์ด๋ฏธ ๋กœ๋“œ๋จ
result.rows.forEach(order => {
  console.log(`Order ${order.id}`);
  console.log(`User: ${order.user.name}`);
  console.log(`Items: ${order.items.length}`);
  console.log(`Payment: ${order.payment.method}`);
});

์ค‘์ฒฉ Relations

// comment.entity.json
{
  "name": "Comment",
  "subsets": {
    "WithAll": {
      "fields": ["id", "content", "created_at"],
      "relations": [
        {
          "name": "author",
          "subset": "A"
        },
        {
          "name": "post",
          "subset": "WithAuthor"  // ์ค‘์ฒฉ: ๊ฒŒ์‹œ๊ธ€ + ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž
        }
      ]
    }
  }
}
// ๋Œ“๊ธ€ + ๋Œ“๊ธ€ ์ž‘์„ฑ์ž + ๊ฒŒ์‹œ๊ธ€ + ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž
const { qb } = CommentModel.getSubsetQueries("WithAll");
const result = await CommentModel.executeSubsetQuery({
  subset: "WithAll",
  qb,
  params: { num: 20, page: 1 }
});

result.rows.forEach(comment => {
  console.log(`${comment.author.name}: ${comment.content}`);
  console.log(`  on "${comment.post.title}" by ${comment.post.author.name}`);
});

// ์ฟผ๋ฆฌ 4๋ฒˆ๋งŒ ์‹คํ–‰:
// 1. comments
// 2. comment authors
// 3. posts
// 4. post authors

whereIn์„ ํ™œ์šฉํ•œ ์ˆ˜๋™ ํ•ด๊ฒฐ

Subset์ด ์—†๋Š” ๊ฒฝ์šฐ whereIn์œผ๋กœ ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
// โŒ N+1 ๋ฌธ์ œ
async function getUsersWithOrders() {
  const users = await UserModel.getPuri().select("id", "name");
  
  for (const user of users) {
    user.orders = await OrderModel.getPuri()
      .select("*")
      .where({ user_id: user.id });
  }
  
  return users;
}

// โœ… whereIn์œผ๋กœ ์ตœ์ ํ™”
async function getUsersWithOrders() {
  // 1. ์‚ฌ์šฉ์ž ์กฐํšŒ
  const users = await UserModel.getPuri().select("id", "name");
  
  const userIds = users.map(u => u.id);
  
  // 2. ๋ชจ๋“  ์ฃผ๋ฌธ ํ•œ ๋ฒˆ์— ์กฐํšŒ
  const orders = await OrderModel.getPuri()
    .select("*")
    .whereIn("user_id", userIds);
  
  // 3. ๋ฉ”๋ชจ๋ฆฌ์—์„œ ๋งคํ•‘
  const ordersByUserId = orders.reduce((acc, order) => {
    if (!acc[order.user_id]) acc[order.user_id] = [];
    acc[order.user_id].push(order);
    return acc;
  }, {} as Record<number, Order[]>);
  
  users.forEach(user => {
    user.orders = ordersByUserId[user.id] || [];
  });
  
  return users;
}

// ์ฟผ๋ฆฌ 2๋ฒˆ: users + orders

DataLoader ํŒจํ„ด

๋ฐ˜๋ณต์ ์ธ ์กฐํšŒ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ DataLoader ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:
class OrderModelClass extends BaseModel {
  // ์‚ฌ์šฉ์ž๋ณ„ ์ฃผ๋ฌธ ๋กœ๋”
  private userOrdersLoader = new DataLoader(async (userIds: number[]) => {
    const orders = await this.getPuri()
      .select("*")
      .whereIn("user_id", userIds);
    
    // user_id๋ณ„๋กœ ๊ทธ๋ฃนํ™”
    const grouped = userIds.map(userId => 
      orders.filter(o => o.user_id === userId)
    );
    
    return grouped;
  });
  
  async getOrdersByUserId(userId: number): Promise<Order[]> {
    return this.userOrdersLoader.load(userId);
  }
}
// ์‚ฌ์šฉ
async function processUsers(users: User[]) {
  for (const user of users) {
    // DataLoader๊ฐ€ ์ž๋™์œผ๋กœ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
    const orders = await OrderModel.getOrdersByUserId(user.id);
    console.log(`${user.name}: ${orders.length} orders`);
  }
}

// ๋‚ด๋ถ€์ ์œผ๋กœ whereIn์œผ๋กœ ํ•œ ๋ฒˆ์— ์กฐํšŒ

์‹ค์ „ ์˜ˆ์ œ

API ์—”๋“œํฌ์ธํŠธ ์ตœ์ ํ™”

class PostModelClass extends BaseModel {
  // โŒ N+1 ๋ฐœ์ƒ
  @api({ httpMethod: "GET" })
  async listPostsSlow() {
    const posts = await this.getPuri().select("id", "title", "content");
    
    // N+1 ๋ฐœ์ƒ!
    for (const post of posts) {
      post.author = await UserModel.findById(post.author_id);
      post.comments = await CommentModel.getPuri().where({ post_id: post.id });
    }
    
    return posts;
  }
  
  // โœ… Subset์œผ๋กœ ์ตœ์ ํ™”
  @api({ httpMethod: "GET" })
  async listPostsFast() {
    const { qb } = this.getSubsetQueries("WithAuthorAndComments");
    
    return this.executeSubsetQuery({
      subset: "WithAuthorAndComments",
      qb: qb.orderBy("created_at", "desc"),
      params: { num: 20, page: 1 }
    });
  }
}

๋Œ€์‹œ๋ณด๋“œ ๋ฐ์ดํ„ฐ

class DashboardService {
  // โŒ N+1 ๋ฐœ์ƒ
  async getDashboardSlow() {
    const users = await UserModel.getPuri().select("id", "name");
    
    const stats = [];
    for (const user of users) {
      const orderCount = await OrderModel.getPuri()
        .select({ count: Puri.count() })
        .where({ user_id: user.id });
      
      const totalSpent = await OrderModel.getPuri()
        .select({ total: Puri.sum("total") })
        .where({ user_id: user.id });
      
      stats.push({
        user: user.name,
        orderCount: orderCount[0].count,
        totalSpent: totalSpent[0].total
      });
    }
    
    return stats;
  }
  
  // โœ… JOIN์œผ๋กœ ์ตœ์ ํ™”
  async getDashboardFast() {
    return UserModel.getPuri()
      .select(
        "users.id",
        "users.name",
        knex.raw("COUNT(orders.id) as order_count"),
        knex.raw("COALESCE(SUM(orders.total), 0) as total_spent")
      )
      .leftJoin("orders", "orders.user_id", "users.id")
      .groupBy("users.id");
  }
}

N+1 ๊ฐ์ง€ํ•˜๊ธฐ

์ฟผ๋ฆฌ ๋กœ๊น…

// sonamu.config.ts
export default {
  database: {
    defaultOptions: {
      debug: true  // ๋ชจ๋“  ์ฟผ๋ฆฌ ์ถœ๋ ฅ
    }
  }
} satisfies SonamuConfig;
์‹คํ–‰ ํ›„ ์ฝ˜์†”์„ ํ™•์ธํ•˜์„ธ์š”:
SELECT * FROM users;
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
SELECT * FROM orders WHERE user_id = 3;
...
๋ฐ˜๋ณต์ ์ธ ๋น„์Šทํ•œ ์ฟผ๋ฆฌ๊ฐ€ ๋ณด์ด๋ฉด N+1 ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค!

ํ…Œ์ŠคํŠธ๋กœ ๊ฐ์ง€

test("N+1 ๋ฌธ์ œ ํ™•์ธ", async () => {
  // ์ฟผ๋ฆฌ ์นด์šดํ„ฐ
  let queryCount = 0;
  
  const db = UserModel.getPuri().getDB();
  db.on("query", () => queryCount++);
  
  // ํ…Œ์ŠคํŠธ
  const { qb } = UserModel.getSubsetQueries("WithOrders");
  const result = await UserModel.executeSubsetQuery({
    subset: "WithOrders",
    qb: qb.limit(10),
    params: { num: 10, page: 1 }
  });
  
  // ์ฟผ๋ฆฌ ์ˆ˜ ๊ฒ€์ฆ
  expect(queryCount).toBeLessThan(5);  // N+1์ด๋ฉด 11๋ฒˆ ์ด์ƒ
  expect(result.rows).toHaveLength(10);
});

์„ฑ๋Šฅ ๋น„๊ต

N+1 ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ

100๋ช… ์‚ฌ์šฉ์ž ์กฐํšŒ:
- ์ฟผ๋ฆฌ: 101๋ฒˆ
- ์‹œ๊ฐ„: ~2000ms
- DB ์ปค๋„ฅ์…˜: 101๋ฒˆ ํš๋“/๋ฐ˜ํ™˜

Subset์œผ๋กœ ์ตœ์ ํ™”ํ•œ ๊ฒฝ์šฐ

100๋ช… ์‚ฌ์šฉ์ž ์กฐํšŒ:
- ์ฟผ๋ฆฌ: 2๋ฒˆ
- ์‹œ๊ฐ„: ~50ms
- DB ์ปค๋„ฅ์…˜: 2๋ฒˆ ํš๋“/๋ฐ˜ํ™˜
40๋ฐฐ ์ด์ƒ ์„ฑ๋Šฅ ํ–ฅ์ƒ!

Best Practices

1. ํ•ญ์ƒ Subset ๋จผ์ € ๊ณ ๋ ค

// โœ… Subset ์‚ฌ์šฉ
const { qb } = PostModel.getSubsetQueries("WithAuthor");
const result = await PostModel.executeSubsetQuery({
  subset: "WithAuthor",
  qb,
  params: { num: 20, page: 1 }
});

// โŒ ์ˆ˜๋™ ๋กœ๋”ฉ
const posts = await PostModel.getPuri();
for (const post of posts) {
  post.author = await UserModel.findById(post.author_id);
}

2. API ์‘๋‹ต์—๋Š” ํ•„์š”ํ•œ Subset ์ •์˜

{
  "name": "User",
  "subsets": {
    "ForAPI": {
      "fields": ["id", "name", "email"],
      "relations": [
        {
          "name": "profile",
          "subset": "Public"
        }
      ]
    }
  }
}

3. ๋ณต์žกํ•œ ์ฟผ๋ฆฌ๋Š” JOIN ๊ณ ๋ ค

// Subset๋ณด๋‹ค JOIN์ด ๋‚˜์€ ๊ฒฝ์šฐ:
// - ์ง‘๊ณ„ ํ•จ์ˆ˜ ํ•„์š”
// - ๋ณต์žกํ•œ ํ•„ํ„ฐ๋ง
// - ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ

4. ๊ฐœ๋ฐœ ์ค‘ ์ฟผ๋ฆฌ ๋ชจ๋‹ˆํ„ฐ๋ง

// ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋งŒ ์ฟผ๋ฆฌ ๋กœ๊น…
export default {
  database: {
    defaultOptions: {
      debug: process.env.NODE_ENV === "development"
    }
  }
} satisfies SonamuConfig;

์ฒดํฌ๋ฆฌ์ŠคํŠธ

N+1 ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด:
  • ๋ฐ˜๋ณต๋ฌธ ์•ˆ์—์„œ DB ์กฐํšŒํ•˜์ง€ ์•Š๊ธฐ
  • Subset์œผ๋กœ ๊ด€๊ณ„ ๋ฐ์ดํ„ฐ ๋ฏธ๋ฆฌ ๋กœ๋“œ
  • whereIn์œผ๋กœ ์ผ๊ด„ ์กฐํšŒ
  • JOIN์„ ํ™œ์šฉํ•œ ์ง‘๊ณ„
  • ๊ฐœ๋ฐœ ์ค‘ ์ฟผ๋ฆฌ ๋กœ๊น… ํ™œ์„ฑํ™”
  • ํ…Œ์ŠคํŠธ๋กœ ์ฟผ๋ฆฌ ์ˆ˜ ๊ฒ€์ฆ
  • API ์‘๋‹ต ์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง

๊ด€๋ จ ๋ฌธ์„œ