graphql 에서 그냥 무작정으로 resolveField 를 사용하면 N+1 문제가 발생할 수 있는 경우가 있다.
여기서 N+1 문제란,
예를 들어, 채팅방에 여러 사람이 있는데 여러 사람의 정보를 긁어오기 위해 채팅방은 1개의 정보만 조회하지만, 그 후에 해당 채팅방의 사람 수(N) 만큼 정보를 조회해야 하는 경우를 뜻 한다.
그래서 @ResolveField
를 잘~ 써야 하는데,
만약에 한 쿼리에서 join을 통해서 데이터를 가져오면 되지 않을까? 싶지만, 그렇게 되면 client 의 요청에서는 over fetching 은 일어나지 않지만, back단에서 over fetching 이 일어난다. client에서는 원하지 않을 수 있는 데이터를 back 단에서 미리 조인을 걸어서 데이터를 가져오기 때문에 그렇다.
즉, join 을 사용하지 않고, @ResolveField 를 영리하게 사용을 하려면 dataloader
를 사용해야 하는데, dataloader 에 대해서 좀 자세하게 알아보자
dataloader 는 데이터를 조회하는데 있어서 발생하는 N+1 문제를 batching
, caching
을 통해서 1+1 로 해결해주는 모듈이다.
N번 실행하지 않기 위해서 여러 개의 key값을 배열로 받아서 해당 key 값의 value 를 매핑한 promise array object 을 생성한다. 그리고 각각의 value 들을 반환한다. 즉, promise array object 전체를 반환하는 것이 아닌, 각 value 들을 각각 반환한다.
아래는 dataloader 공식 git repository 에서 가져온 글이다.
A batch loading function accepts an Array of keys, and returns a Promise which resolves to an Array of values.
Then load individual values from the loader. DataLoader will coalesce all individual loads which occur within a single frame of execution (a single tick of the event loop) and then call your batch function with all requested keys.
여기서 지켜야 할 점들이 있다.
이 부분에 대해서는 아래 예시에서 확인해보겠다.
dataloader 는 key 를 바탕으로 캐싱처리를 해준다.
.load()
를 통해서 캐싱처리를 해준다고 생각해준다.
주어진 key 값을 기반으로 캐싱으로 하기 때문에, 반복된 요청으로 부터 빠른 반환 시간을 보일 수 있지만, 다른 요청으로 부터 캐싱된 데이터의 원본이 수정이 되더라도, 반복된 요청으로 부터 동일한 값이 반환될 수 밖에 없다. 그렇기 때문에 요청이 올때 마다 dataloader instance 를 생성해주는 것을 권장한다.
const result = await this.chatLoader.findByUserId.load(room.roomId);
console.log(this.chatLoader.findByUserId);
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(3) {
4 => Promise { [Array] },
5 => Promise { [Array] },
6 => Promise { [Array] }
},
_batch: {
hasDispatched: true,
keys: [ 4, 5, 6 ],
callbacks: [ [Object], [Object], [Object] ]
},
name: null
}
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(3) {
4 => Promise { [Array] },
5 => Promise { [Array] },
6 => Promise { [Array] }
},
_batch: {
hasDispatched: true,
keys: [ 4, 5, 6 ],
callbacks: [ [Object], [Object], [Object] ]
},
name: null
}
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(3) {
4 => Promise { [Array] },
5 => Promise { [Array] },
6 => Promise { [Array] }
},
_batch: {
hasDispatched: true,
keys: [ 4, 5, 6 ],
callbacks: [ [Object], [Object], [Object] ]
},
name: null
}
위 결과를 보면, dataloder 는 자체적으로 cacheMap
을 가지고 있어 key 값을 바탕으로 캐시를 적용하는 것을 알 수 있다.
room.roomId 가 4,5,6이 들어갈 수 있는데, 이를 키값으로 가지고 있고, 또한 value 값을 promise 배열 객체로 가지고 있는 것을 알 수 있다.
즉, 4,5,6 의 value 를 모두 resolve 하고 나서의 결과가 콘솔로 찍히는 것을 볼 수 있다.
dataloader 는 결국, 들어오는 모든 값들을 resolve 하고 난 결과를 뱉어내고, 이를 호출하는 부분에서 pending 상태로 대기하다가 resolve 되고 나서 결과를 받아볼 수 있다.
@Injectable({ scope: Scope.REQUEST })
export class ChatLoader {
findByUserId = new DataLoader<number, UserModel[]>(
async (roomIds: number[]) => {
// 여기 있는 roomIds 에 있는 아이템들이 key 가 된다.
... context
{ cache: false },
);
}
@ResolveField('users', () => [UserModel])
async getUsers(@Parent() room: RoomModel): Promise<UserModel[]> {
try {
await this.chatLoader.findByUserId.clear(room.roomId);
await this.chatLoader.findByUserId.clearAll(room.roomId);
} catch (error) {
this.chatLoader.findByUserId.clear(room.roomId);
}
}
// chat.resolver.ts
@Query(() => [RoomModel])
async getRoomInfo(
@Args('roomId', { type: () => Int }) roomId: number,
): Promise<RoomModel[]> {
return [
{
roomId: roomId + 1,
},
{
roomId: roomId + 2,
},
{
roomId: roomId + 3,
},
];
}
@ResolveField('users', () => [UserModel])
async getUsers(@Parent() room: RoomModel): Promise<UserModel[]> {
try {
const result = await this.chatLoader.findByUserId.load(room.roomId);
console.log(result);
return result;
} catch (error) {
this.chatLoader.findByUserId.clear(room.roomId);
}
}
// chat.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class ChatLoader {
findByUserId = new DataLoader<number, UserModel[]>(
async (roomIds: number[]) => {
// 여기 있는 roomIds 에 있는 아이템들이 key 가 된다.
const userModels = {
4: [{ id: '4' }, { id: '8' }, { id: '12' }],
5: [{ id: '5' }, { id: '10' }, { id: '15' }],
6: [{ id: '6' }, { id: '12' }, { id: '18' }],
};
const userModelGroup: { [key: number]: UserModel[] } = {};
for (const roomId of roomIds) {
if (!userModelGroup[roomId]) {
userModelGroup[roomId] = [];
}
userModelGroup[roomId] = userModels[roomId] ?? [];
}
const result = roomIds.map((roomId: number) => userModelGroup[roomId]);
console.log(result);
return result;
},
{ cache: true },
);
}
// user.resolver.ts
@ResolveReference()
resolveReference(reference: { __typename: string; id: string }): UserModel {
return {
id: reference.id,
name: `${reference.id} + name`,
};
}
채팅방 4,5,6 에 속해있는 유저의 정보를 조회한다고 가정을 해보자.
resolveField 를 통해서 채팅방 하나에 속해있는 유저들의 정보를 가져와야 하는데, dataloader 를 사용하지 않는다고 하면, resolveField에서
roomId:4
에 속한 유저들의 정보를 가져오고,
roomId:5
에 속한 유저들의 정보를 가져오고,
roomId:6
에 속한 유저들의 정보를 가져오고...
N+1 문제가 발생할 수 있다.
그래서 @ResolveField 쿼리가 3번 호출되더라도, 실제 디비에서 데이터를 조회하는 로직은 한번만 할 수 있도록 dataloader 를 사용해보자.
await this.chatLoader.findByUserId.load(room.roomId)
여기서 실제 인자값으로는 roomId 가 한개씩 들어가지만, dataloader 의 batching 특징으로 인해
async (roomIds: number[])
으로 [4,5,6]
의 채팅방id 들의 배열이 들어간다.
그로인해,
채팅방에 속한 유저들의 정보가 다음과 같다고 가정을 해보자
채팅방 4에 속한 유저들은 4, 8, 12
채팅방 5에 속한 유저들은 5, 10, 15
채팅방 6에 속한 유저들은 6, 12, 18
const userModels = {
4: [{ id: '4' }, { id: '8' }, { id: '12' }],
5: [{ id: '5' }, { id: '10' }, { id: '15' }],
6: [{ id: '6' }, { id: '12' }, { id: '18' }],
};
dataloader 를 통한 결과 값은 아래와 같다.
const result = roomIds.map((roomId: number) => userModelGroup[roomId]);
console.log(result);
return result;
[
[ { id: '4' }, { id: '8' }, { id: '12' } ],
[ { id: '5' }, { id: '10' }, { id: '15' } ],
[ { id: '6' }, { id: '12' }, { id: '18' } ]
]
하지만 해당 dataloader 를 호출한 부분에서 console을 찍어보면 다음과 같다.
const result = await this.chatLoader.findByUserId.load(room.roomId);
console.log(result);
[ { id: '4' }, { id: '8' }, { id: '12' } ]
[ { id: '5' }, { id: '10' }, { id: '15' } ]
[ { id: '6' }, { id: '12' }, { id: '18' } ]
🔥🔥🔥
이걸 보면 결국 dataloader 에서 디비에서 조회는 한번만 해서 배열의 결과를 반환하지만, 호출부에서는 인자값으로 room.roomId
처럼 한개씩만 보냈기 때문에, 결과도 채팅방 하나의 속해있는 유저 정보를 조회하고 이를 @ResolveReference() 에서 수신해서 쿼리를 실행하는 것을 알 수 있다.
그래서 dataloader 의 키값이랑 반환값의 사이즈가 같아야 하고, 순서가 보장되어야 한다는 것을 여기서 알 수 있다
🔥🔥🔥
{
"data": {
"getRoomInfo": [
{
"roomId": 4,
"users": [
{
"id": "4",
"name": "4 + name"
},
{
"id": "8",
"name": "8 + name"
},
{
"id": "12",
"name": "12 + name"
}
]
},
{
"roomId": 5,
"users": [
{
"id": "5",
"name": "5 + name"
},
{
"id": "10",
"name": "10 + name"
},
{
"id": "15",
"name": "15 + name"
}
]
},
{
"roomId": 6,
"users": [
{
"id": "6",
"name": "6 + name"
},
{
"id": "12",
"name": "12 + name"
},
{
"id": "18",
"name": "18 + name"
}
]
}
]
}
}
https://github.com/graphql/dataloader
https://y0c.github.io/2019/11/24/graphql-query-optimize-with-dataloader/
사진: Unsplash의Zac Edmonds