N+1

Settings

TypeDefs

const typeDefs = `#graphql
    type User {
        id: Int!,
        name: String!,
    }

    type Post {
        id: Int!,
        boardId: Int!,
        user: User!,
    }

    type Board {
        id: Int!,
        posts: [Post],
    }

    type Query {
        board(id: Int!): Board
    }
`;

Resolvers

const resolvers = {
  Query: {
    board: (_, { id }) => Board.findOne({ where: id }),
  },
	Board: {
    posts: board => Post.findAll({ where: { boardId: board.id }}),
  },
  Post: {
    user: post => User.findOne({ where : { id: post.userId }}),
  },
};

간단하게 타입과 리졸버를 구현했습니다.

Query

query boards {
  board(id: 1) {
    id
    posts {
      id
    }
  }
}

위 요청을 진행하면, Board 하위의 posts 요청만 수행되어 데이터베이스로 단일 쿼리가 들어가기 때문에 아직까진 N+1 문제가 발생하지 않습니다.

(물론 id 필드만 요청해도 모든 필드를 SELECT 하기 때문에 Overfetching 이 발생하긴 하지만,, 지금의 예제에서 표현하고자하는 문제가 아니기에 넘어가겠습니다)

무슨 문제인가?

query boards {
  board(id: 1) {
    id
    posts {
      id
      user {
        id
      }
    }
  }
}

posts 하위 필드에 user.id를 추가했습니다.

posts가 resolve되면, 그 다음 하위의 user가 resolve됩니다.

제가 이해했던 내용대로라면, posts 하위부터는 가져오는 post 수의 +1 만큼의 호출이 발생한다는 것이었습니다.

Executing (default): SELECT "id", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Boards" AS "Board" WHERE "Board"."id" = 1 LIMIT 1;
1 Executing (default): SELECT "id", "board_id" AS "boardId", "user_id" AS "userId", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Posts" AS "Post" WHERE "Post"."board_id" = 1;
2 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
3 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 2;
4 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 3;
5 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
6 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 5;
7 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 2;
8 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 6;
9 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
10 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
11 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
12 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
13 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;
14 Executing (default): SELECT "id", "name", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Users" AS "User" WHERE "User"."id" = 1;

불러오려던 post의 개수는 총 13개였기에 14번(posts.length + 1)의 호출이 발생했습니다.

왜 이것이 문제일까?

라는 고민에 있어서는 아래의 이슈가 떠오릅니다.

  1. 데이터베이스의 쿼리 과부하

    N+1 문제는 보통 데이터베이스의 정보를 가져오는 과정에서 발생합니다. 각각의 데이터 항목에 대해 별도의 쿼리를 실행하는 경우 데이터베이스에 대한 부하가 크게 증가하게 됩니다. 1개의 쿼리로 모든 데이터를 가져오는 비용보다, N개의 쿼리로 각각의 데이터를 가져오는 비용이 더 큽니다.

  2. 네트워크 비용 증가

    위 쿼리 부하와 마찬가지로 각각의 쿼리는 네트워크를 통해 요청됩니다. 마찬가지로, 1개의 쿼리를 요청하는 비용보다, 여러번의 쿼리에 대해 요청하는 비용이 더 크다고 생각됩니다.

  3. 응답 시간 증가

    위 두 문제를 통해 데이터 전송, 네트워크 통신 과정에서 응답 시간이 늘어나게 됩니다. 이는 곧 사용자 경험에서 부정적인 영향을 미칠 수 있죠.

어떻게 해결할 것인가?

첫 번째로,

finder 내 include 옵션을 주어 Post 테이블의 데이터가 Eager하게 오도록 처리했습니다.

const resolvers = {
  Query: {
    board: (_, { id }) => Board.findOne({ where: { id } }),
  },
  Board: {
    posts: board => Post.findAll({ where: { boardId: board.id }, include: [{ model: User }] }),
  },
  Post: {
    user: post => post.User,
  },
};
Executing (default): SELECT "id", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Boards" AS "Board" WHERE "Board"."id" = 1;
Executing (default): SELECT "Post"."id", "Post"."board_id" AS "boardId", "Post"."user_id" AS "userId", "Post"."created_at" AS "createdAt", "Post"."updated_at" AS "updatedAt", "User"."id" AS "User.id", "User"."name" AS "User.name", "User"."created_at" AS "User.createdAt", "User"."updated_at" AS "User.updatedAt" FROM "Posts" AS "Post" LEFT OUTER JOIN "Users" AS "User" ON "Post"."user_id" = "User"."id" WHERE "Post"."board_id" = 1;

Board까지 총 두 번 호출이 됐습니다!

LEFT OUTER JOIN으로 Post, User 테이블이 서로 묶이네요. N+1 문제는 해결이 되지만, 여러 결과값을 출력하다보니 또 하나의 문제를 발견했습니다.

query boards {
  board(id: 1) {
    id
    posts {
      id
    }
  }
}

posts 내의 user 필드를 제거하고 요청했습니다.

Executing (default): SELECT "id", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Boards" AS "Board" WHERE "Board"."id" = 1;
Executing (default): SELECT "Post"."id", "Post"."board_id" AS "boardId", "Post"."user_id" AS "userId", "Post"."created_at" AS "createdAt", "Post"."updated_at" AS "updatedAt", "User"."id" AS "User.id", "User"."name" AS "User.name", "User"."created_at" AS "User.createdAt", "User"."updated_at" AS "User.updatedAt" FROM "Posts" AS "Post" LEFT OUTER JOIN "Users" AS "User" ON "Post"."user_id" = "User"."id" WHERE "Post"."board_id" = 1;

전 동작과 같이 LEFT JOIN 발생으로 User 데이터를 조회하는 것은 마찬가지더군요! user 정보를 요청하지 않는 상황에서 굳이 user 테이블을 조회하며 LEFT JOIN 할 필요는 없을 것 같습니다. *이 현상을 over fetching이라고 하더군요?

두 번째로,

DataLoader를 이용한 솔루션을 찾았습니다.

DataLoader를 보았는데, Event loop를 사용하는 라이브러리더군요.

DataLoader 동작원리

  1. load() 메서드가 호출되면 클래스 내부에 getCurrentBatch(this) 함수가 실행되며 _batch 라는 이름의 private 객체를 생성합니다.
    1. 이 과정에서 _batchScheduleFn(cb())를 호출하더라구요.
  2. batch.keys 배열에는 key가 추가되고, batch.callbacks 배열에는 Promise callback(resolve, reject)을 추가합니다.
  3. load() 메서드 호출 당시, 미리 호출해두었던 _batchScheduleFn가 CallStack이 비는 시점에 내부에 있는 dispatchBatch 함수를 호출합니다.
  4. dispatchBatch 함수에서는 기존에 적재되어있던 keys와 함께 DataLoader의 batchLoadFn 을 호출합니다.

createLoader

const createLoader = (model) => new DataLoader(async (keys) => {
  const instances = await model.findAll({ where: { id: keys } });
  const instanceMap = instances.reduce((map, instance) => {
    map[instance.id] = instance;
    return map;
  }, {});
  return keys.map((key) => instanceMap[key] || null);
});
const userLoader = createLoader(User);

resolver

const resolvers = {
  Query: {
    board: (_, { id }) => Board.findOne({ where: { id } }),
  },
  Board: {
    posts: board => Post.findAll({ where: { boardId: board.id } }),
  },
  Post: {
    user: post => userLoader.load(post.userId),
  },
};

result

Executing (default): SELECT "id", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Boards" AS "Board" WHERE "Board"."id" = 1;
Executing (default): SELECT "id", "board_id" AS "boardId", "user_id" AS "userId", "created_at" AS "createdAt", "updated_at" AS "updatedAt" FROM "Posts" AS "Post" WHERE "Post"."board_id" = 1;

결과처럼 불필요한 JOIN을 진행하지 않고 필요한 쿼리로만 요청이 됐습니다!