[GraphQL] DataLoader

본 포스트는 공식 레퍼런스를 참고해 GraphQL을 공부하며 직접 작성한 가이드 입니다.
본 포스트는 2021년 7월 최신 버전인 v16.2를 기준으로 작성되어 있습니다.

DataLoader

우리는 GraphQLResolver를 소개하는 포스트에서 LocalContext를 사용해 요청을 최적화하는 방법을 확인했었다. 다만 LocalContext는 요청에대한 리졸버 전역에서 공유하는 객체라 모든 리졸버에서 사용하기엔 어려운 부분이 있을 수 있다. 이런 방법을 위해 DataLoader를 통해 요청에 대한 Async Bulk Get을 이용해 캐싱처리하고 필요에 따라 데이터를 꺼내 바인딩을 할 수 있다. 무슨말인지 쉽게 이해하기 위해 GraphQL Pagination의 요청을 재사용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 웹툰 페이지네이션
query GET_WEBTOONS_PAGINATION($episodeNos: [NonNegativeInt!]!) {
webtoons(first: 1) {
edges {
cursor
node {
id
title
webToonType
episodes(episodeNos: $episodeNos) {
episodeNo
title
releaseDate
}
}
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
}
}
1
2
2021-07-31 17:09:34.278  INFO 22598 --- [nio-8080-exec-8] c.s.g.resolver.webtoon.EpisodeResolver   : request episode data for webToon id : a434aaa4-05da-4747-be9c-ea60dc7a8dd8
2021-07-31 17:09:34.278 INFO 22598 --- [nio-8080-exec-8] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2, 3

하나의 웹툰 요청에 있어서 에피소드를 찾기 위한 호출이 한번 call되었음을 볼 수 있다. 만약 다수의 웹툰 요청을 한다면 어떻게 될까? 요청수를 3으로 바꾸고 요청해보자.

1
2
3
4
5
6
2021-07-31 17:11:12.640  INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver   : request episode data for webToon id : a434aaa4-05da-4747-be9c-ea60dc7a8dd8
2021-07-31 17:11:12.640 INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2, 3
2021-07-31 17:11:12.641 INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver : request episode data for webToon id : 7c38418d-3d43-4f10-b163-66784ea89609
2021-07-31 17:11:12.641 INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2, 3
2021-07-31 17:11:12.642 INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver : request episode data for webToon id : 53c4891a-7825-4f40-a715-e9cecedd6a86
2021-07-31 17:11:12.642 INFO 22598 --- [nio-8080-exec-9] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2, 3

로그에서 볼 수 있듯이 서로 다른 웹툰에 있어서 에피소드를 찾는 콜이 3번이 호출되었다. 즉 이 코드는 N + 1 문제를 야기하는 코드인것이다. 이제 이 코드를 DataLoader를 사용해 최적화 하도록 리팩토링해보자.

먼저 DataLoader를 생성하고 이를 DataLoaderRegistry에 등록해준다.

1
2
3
4
5
6
7
8
9
private DataLoader<Pair<UUID, List<Integer>>, List<Episode>> createEpisodeDataLoader() {
return DataLoader.newMappedDataLoader(pairs ->
CompletableFuture.supplyAsync(() -> {
log.info("Episode DataLoading of WebtoonIds For: {}", pairs.stream().map(Pair::getFirst).map(UUID::toString).collect(Collectors.joining(", ")));

return episodeBO.getWebtoonsEpisodesFrom(pairs);
}, episodeTaskExecutor)
);
}
1
2
3
4
5
6
7
public DataLoaderRegistry create() {
DataLoaderRegistry registry = new DataLoaderRegistry();

registry.register(EPISODE_DATA_LOADER, createEpisodeDataLoader());

return registry;
}

그리고 DataLoaderRegistryGraphQLServletContext에 등록해준다.

1
2
3
4
5
6
DefaultGraphQLServletContext graphQLServletContext = DefaultGraphQLServletContext
.createServletContext()
.with(httpServletRequest)
.with(httpServletResponse)
.with(dataLoaderRegistryFactory.create())
.build();

그리고 EpisodeResolverCompletableFuture를 반환하도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@Component
@RequiredArgsConstructor
public class EpisodeResolver implements GraphQLResolver<WebToon> {
private final EpisodeBO episodeBO;

public CompletableFuture<List<Episode>> episodes(WebToon webToon, List<Integer> episodeNos, DataFetchingEnvironment env) {
log.info("request episode data for webToon id : {}", webToon.getId());
log.info("episode no of requests : {}", episodeNos.stream().map(Object::toString).collect(Collectors.joining(", ")));

DataLoader<Pair<UUID, List<Integer>>, List<Episode>> dataLoader = env.getDataLoader(DataLoaderRegistryFactory.EPISODE_DATA_LOADER);

return dataLoader.load(new Pair<>(webToon.getId(), episodeNos));
}
}

DataLoader에 요청한 정보들이 캐싱되어 있다가 요청을 처리할 때 Bulk Get으로 EpisodeBO에 요청해 결과를 매핑해주는 원리이다. 이제 준비가 되었으니 요청을 보내보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 웹툰 페이지네이션
query GET_WEBTOONS_PAGINATION {
webtoons(first: 2) {
edges {
cursor
node {
id
title
webToonType
episodes(episodeNos: [1, 2]) {
episodeNo
title
releaseDate
}
}
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
}
}
1
2
3
4
5
2021-07-31 17:41:40.674  INFO 22801 --- [nio-8080-exec-6] c.s.g.resolver.webtoon.EpisodeResolver   : request episode data for webToon id : 34d501bb-fb93-4a6f-8b53-7d185e517847
2021-07-31 17:41:40.675 INFO 22801 --- [nio-8080-exec-6] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2
2021-07-31 17:41:40.675 INFO 22801 --- [nio-8080-exec-6] c.s.g.resolver.webtoon.EpisodeResolver : request episode data for webToon id : c0db0e15-d890-4d55-8294-439d2d359639
2021-07-31 17:41:40.675 INFO 22801 --- [nio-8080-exec-6] c.s.g.resolver.webtoon.EpisodeResolver : episode no of requests : 1, 2
2021-07-31 17:41:40.676 INFO 22801 --- [eTaskExecutor-1] c.s.g.d.DataLoaderRegistryFactory : DataLoading of WebtoonIds For: 34d501bb-fb93-4a6f-8b53-7d185e517847, c0db0e15-d890-4d55-8294-439d2d359639

실제로 로그를 확인하면 DataLoader에 요청에 대한 정보가 여러건이 캐싱되고 한번의 Bulk Get으로 정보를 가져오는걸 확인할 수 있다. 하지만 코드가 뭔가 보기 좋지 않다. Pair.class로 정보를 넘기고 있기 때문이다. 만약 넘겨야 할 데이터가 더 커지면 어떻게될까? 매번 넘겨줄 데이터를 위한 클래스를 만들어줄 수 없는 노릇이다. 이제 이를 개선해보자. 이를 위해 MappedBatchLoaderWithContext를 사용하자.

1
2
3
4
5
6
7
8
9
10
@SuppressWarnings("unsupported")
private DataLoader<UUID, List<Episode>> createEpisodeDataLoader() {

return DataLoader.newMappedDataLoader((webtoonIds, env) ->
CompletableFuture.supplyAsync(() -> {
log.info("Episode DataLoading of WebtoonIds For: {}", webtoonIds.stream().map(UUID::toString).collect(Collectors.joining(", ")));
return episodeBO.getWebtoonsEpisodesFrom((Map) env.getKeyContexts());
}, episodeTaskExecutor)
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@Component
@RequiredArgsConstructor
public class EpisodeResolver implements GraphQLResolver<WebToon> {
private final EpisodeBO episodeBO;

public CompletableFuture<List<Episode>> episodes(WebToon webToon, List<Integer> episodeNos, DataFetchingEnvironment env) {
log.info("request episode data for webToon id : {}", webToon.getId());
log.info("episode no of requests : {}", episodeNos.stream().map(Object::toString).collect(Collectors.joining(", ")));

DataLoader<UUID, List<Episode>> dataLoader = env.getDataLoader(DataLoaderRegistryFactory.EPISODE_DATA_LOADER);

return dataLoader.load(webToon.getId(), episodeNos);
}
}

코드를 살펴보면 dataLoader.load 뒤에 키 컨텍스트인 episodeNos를 넘겨주어서 DataLoader에서 BatchLoaderEnvironment 에서 키 컨텍스트가 같이 들어간 맵을 가져오면 된다. 물론 요청에 대한 응답도 잘 도착한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
"data": {
"webtoons": {
"edges": [
{
"cursor": "ODI1NjkxY2QtNDYwNC00YTNlLWJjOWItMzUzM2VmZWY2ZmRj",
"node": {
"id": "825691cd-4604-4a3e-bc9b-3533efef6fdc",
"title": "여신강림",
"webToonType": "Originals",
"episodes": [
{
"episodeNo": "1",
"title": "1 화",
"releaseDate": "2021.07.31"
},
{
"episodeNo": "2",
"title": "2 화",
"releaseDate": "2021.07.31"
}
]
}
},
{
"cursor": "ZmUzZmYyYzktMjI1OS00MGIxLTkwY2UtYzBmNjRlOGRjMTFh",
"node": {
"id": "fe3ff2c9-2259-40b1-90ce-c0f64e8dc11a",
"title": "스퍼맨",
"webToonType": "Originals",
"episodes": [
{
"episodeNo": "1",
"title": "1 화",
"releaseDate": "2021.07.31"
},
{
"episodeNo": "2",
"title": "2 화",
"releaseDate": "2021.07.31"
}
]
}
}
],
"pageInfo": {
"hasPreviousPage": false,
"hasNextPage": true,
"startCursor": "ODI1NjkxY2QtNDYwNC00YTNlLWJjOWItMzUzM2VmZWY2ZmRj",
"endCursor": "ZmUzZmYyYzktMjI1OS00MGIxLTkwY2UtYzBmNjRlOGRjMTFh"
}
}
}
}

Repository

모든 가이드의 예제 코드는 SongHayoung/springboot-graphql-tutorial에서 확인할 수 있습니다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2021/07/31/GraphQL/graphql-13/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.