> ## Documentation Index
> Fetch the complete documentation index at: https://sonamu.cartanova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# N+1 문제

> N+1 쿼리 문제 해결하기

N+1 쿼리 문제를 이해하고 Sonamu의 Subset과 Relation 기능으로 해결하는 방법을 다룹니다.

## N+1 문제란?

N+1 문제는 1개의 메인 쿼리 후, N개의 추가 쿼리가 발생하는 성능 문제입니다.

### 문제 예시

```typescript theme={null}
// ❌ N+1 문제 발생
async function getUsersWithOrders() {
  // 1. 사용자 조회 (1번 쿼리)
  const users = await UserModel.getPuri("r").table("users").select("id", "name"); // 100명의 사용자

  // 2. 각 사용자의 주문 조회 (N번 쿼리)
  for (const user of users) {
    const orders = await OrderModel.getPuri("r")
      .table("orders")
      .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을 정의합니다:

```json theme={null}
// 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 사용

```typescript theme={null}
// ✅ 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

```json theme={null}
// post.entity.json
{
  "name": "Post",
  "subsets": {
    "WithAuthor": {
      "fields": ["id", "title", "content"],
      "relations": [
        {
          "name": "author",
          "subset": "A"
        }
      ]
    }
  }
}
```

```typescript theme={null}
// 게시글과 작성자 조회
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

```json theme={null}
// order.entity.json
{
  "name": "Order",
  "subsets": {
    "Detail": {
      "fields": ["id", "total", "status"],
      "relations": [
        {
          "name": "user",
          "subset": "A"
        },
        {
          "name": "items",
          "subset": "A"
        },
        {
          "name": "payment",
          "subset": "A"
        }
      ]
    }
  }
}
```

```typescript theme={null}
// 주문 + 사용자 + 아이템 + 결제 정보
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

```json theme={null}
// comment.entity.json
{
  "name": "Comment",
  "subsets": {
    "WithAll": {
      "fields": ["id", "content", "created_at"],
      "relations": [
        {
          "name": "author",
          "subset": "A"
        },
        {
          "name": "post",
          "subset": "WithAuthor" // 중첩: 게시글 + 게시글 작성자
        }
      ]
    }
  }
}
```

```typescript theme={null}
// 댓글 + 댓글 작성자 + 게시글 + 게시글 작성자
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으로 최적화할 수 있습니다:

```typescript theme={null}
// ❌ N+1 문제
async function getUsersWithOrders() {
  const users = await UserModel.getPuri("r").table("users").select("id", "name");

  for (const user of users) {
    user.orders = await OrderModel.getPuri("r")
      .table("orders")
      .select("*")
      .where({ user_id: user.id });
  }

  return users;
}

// ✅ whereIn으로 최적화
async function getUsersWithOrders() {
  // 1. 사용자 조회
  const users = await UserModel.getPuri("r").table("users").select("id", "name");

  const userIds = users.map((u) => u.id);

  // 2. 모든 주문 한 번에 조회
  const orders = await OrderModel.getPuri("r")
    .table("orders")
    .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 패턴을 사용합니다:

```typescript theme={null}
class OrderModelClass extends BaseModelClass {
  // 사용자별 주문 로더
  private userOrdersLoader = new DataLoader(async (userIds: number[]) => {
    const orders = await this.getPuri("r").table("orders").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);
  }
}
```

```typescript theme={null}
// 사용
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 엔드포인트 최적화

```typescript theme={null}
class PostModelClass extends BaseModelClass {
  // ❌ N+1 발생
  @api({ httpMethod: "GET" })
  async listPostsSlow() {
    const posts = await this.getPuri("r").table("posts").select("id", "title", "content");

    // N+1 발생!
    for (const post of posts) {
      post.author = await UserModel.findById(post.author_id);
      post.comments = await CommentModel.getPuri("r")
        .table("comments")
        .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 },
    });
  }
}
```

### 대시보드 데이터

```typescript theme={null}
class DashboardService {
  // ❌ N+1 발생
  async getDashboardSlow() {
    const users = await UserModel.getPuri("r").table("users").select("id", "name");

    const stats = [];
    for (const user of users) {
      const orderCount = await OrderModel.getPuri("r")
        .table("orders")
        .select({ count: Puri.count() })
        .where({ user_id: user.id });

      const totalSpent = await OrderModel.getPuri("r")
        .table("orders")
        .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("r")
      .table("users")
      .select({
        id: "users.id",
        name: "users.name",
        order_count: Puri.count("orders.id"),
        total_spent: Puri.rawNumber("COALESCE(SUM(orders.total), 0)"),
      })
      .leftJoin("orders", "orders.user_id", "users.id")
      .groupBy("users.id");
  }
}
```

## N+1 감지하기

### 쿼리 로깅

```typescript theme={null}
// 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 문제입니다!

### 테스트로 감지

```typescript theme={null}
test("N+1 문제 확인", async () => {
  // 쿼리 카운터
  let queryCount = 0;

  const db = UserModel.getDB("r");
  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 먼저 고려

```typescript theme={null}
// ✅ Subset 사용
const { qb } = PostModel.getSubsetQueries("WithAuthor");
const result = await PostModel.executeSubsetQuery({
  subset: "WithAuthor",
  qb,
  params: { num: 20, page: 1 },
});

// ❌ 수동 로딩
const posts = await PostModel.getPuri("r").table("posts").select("*");
for (const post of posts) {
  post.author = await UserModel.findById(post.author_id);
}
```

### 2. API 응답에는 필요한 Subset 정의

```json theme={null}
{
  "name": "User",
  "subsets": {
    "ForAPI": {
      "fields": ["id", "name", "email"],
      "relations": [
        {
          "name": "profile",
          "subset": "Public"
        }
      ]
    }
  }
}
```

### 3. 복잡한 쿼리는 JOIN 고려

```typescript theme={null}
// Subset보다 JOIN이 나은 경우:
// - 집계 함수 필요
// - 복잡한 필터링
// - 대량 데이터
```

### 4. 개발 중 쿼리 모니터링

```typescript theme={null}
// 개발 환경에서만 쿼리 로깅
export default {
  database: {
    defaultOptions: {
      debug: process.env.NODE_ENV === "development",
    },
  },
} satisfies SonamuConfig;
```

## 체크리스트

N+1 문제를 방지하려면:

* [ ] 반복문 안에서 DB 조회하지 않기
* [ ] Subset으로 관계 데이터 미리 로드
* [ ] whereIn으로 일괄 조회
* [ ] JOIN을 활용한 집계
* [ ] 개발 중 쿼리 로깅 활성화
* [ ] 테스트로 쿼리 수 검증
* [ ] API 응답 시간 모니터링

## 관련 문서

* [Subset 정의하기](/ko/database/subsets/defining-subsets)
* [Subset 사용하기](/ko/database/subsets/using-subsets)
* [중첩 Relations](/ko/database/subsets/nested-relations)
* [쿼리 최적화](/ko/troubleshooting/performance/query-optimization)
* [캐싱 전략](/ko/troubleshooting/performance/caching-strategies)
