Apollo Plugin에 대하여

Gunwoo Kim
FRIP
Published in
10 min readAug 17, 2021

예시를 통해 알아보는 Apollo Plugin

Photo by NASA on Unsplash

Apollo Plugin

기능

Apollo Plugin의 기능을 한 마디로 정리하면 다음과 같다.

Apollo Server의 시작/종료 혹은 GraphQL 요청과 응답 사이의 생애주기에 따라 추가적인 작업을 수행할 수 있다.

그렇다면 해당 생애주기에는 어떤 것들이 있는지 살펴보자.

Apollo Server와 GraphQL의 생애주기

Apollo Server와 GraphQL 요청의 생애주기를 축약하면 다음과 같다.

서버의 시작과 종료 사이에 요청이 들어오며, 각 요청은 parsing, validation, execution의 과정을 거쳐 응답을 클라이언트에게 전달한다. Apollo 에서는 이러한 생애주기 마다 이벤트를 발생 시키고 플러그인에 정의된 함수를 호출한다. 예를 들면, 서버가 시작되기 전에는 serverWillStart 를, 요청이 들어왔을 때는 requestDidStart 를 실행하는 식이다.

사용 방법

아주 간단한 예시를 통해 Apollo Plugin의 사용방법을 알아보자. 서버가 시작할 때마다 로그를 찍으려면 어떻게 해야 할까?

위 플러그인의 작동 과정은 다음과 같다.

  1. Apollo Server의 시작을 위한 코드가 실행될 때 plugins 내의 각 plugin의 serverWillStart함수를 실행시킨다.
...this.plugins.map((plugin) =>              
plugin.serverWillStart && plugin.serverWillStart(service), ),
...

2. myPlugin에 정의해둔 serverWillStart 함수가 실행되고 log가 찍힌다.

Server starting up!

예시 코드를 통해 알아보는 각 생애주기 별 이벤트

이제 각각의 이벤트에 대해 자세히 알아보자. 예시에 사용되는 Apollo Server의 구성은 다음과 같이 설정하였다.

알아두면 좋은 것들:

1. 각 이벤트마다 사용할 수 있는 정보의 정도가 다르다. 예를 들면 didResolveOperation이 실행된 시점에는 파싱된 쿼리(AST node)를 사용할 수 있지만, requestDidStart가 실행된 시점에서는 쿼리가 파싱되기 전이기 때문에 해당 정보를 사용할 수 없다.

2. 모든 이벤트를 알 필요는 없다. 아래의 설명을 보고 필요한 이벤트만 사용하여 플러그인을 구성하면 된다.

Apollo Server의 생애주기에 따른 이벤트

serverWillStart
말 그대로 서버가 시작될 때 실행된다. 아래처럼 async함수를 실행한다면, 해당 async 함수가 끝날 때 까지 Apollo Server의 시작이 지연된다. 또한 해당 PromiseReject된다면 서버가 시작되지 않기 때문에 serverWillStartasync로 돌린다면 예외 처리에 주의가 필요하다.

즉 위와 같은 상황에서는 Apollo Server가 실행되지 않는다. 따라서 serverWillStartasync로 돌리는 경우에는 아래처럼 예외처리를 확실히 해줘야 한다.

serverWillStop

SIGINT 신호가 발생되거나 ApolloServer.stop()을 호출하는 등 서버가 종료될 때 실행된다. serverWillStart의 리턴값으로 구현할 수 있기 때문에 아래처럼 사용할 수 있다. 아래처럼 Background Task를 종료하는 데에 유용하다

GraphQL Request의 생애주기에 따른 이벤트

Automatic persisted queries 사용 유무에 따라

Authomatic persisted queries를 사용할 경우 apollo client에서는 query string이 아닌 query hash만을 server에 보내고 server에서는 persistedQueryCache를 통해 query를 찾아낸다. (만일 처음 보내는 query인 경우 server에서는 error를 포함한 응답을 보내고 client는 query string과 hash를 함께 재전송한다). 이를 통해 HTTP요청이 짧아지며 POST가 아닌 GET을 사용할 수 있기 때문에 CDN을 통한 캐싱이 가능해지는 장점이 있다.

requestDidStart

서버에 요청이 들어오고 가장 먼저 호출된다. 요청이 아직 parsing되기 전이기 때문에, 순수한 스트링 상태의 query가 제공된다. 다만, 클라이언트에서 automatic persisted query를 사용할 경우 클라이언트에서 query의 hash 값만을 보내기 때문에, query는 null 값이다. 나머지 생애주기에 해당하는 이벤트들은 requestDidStart의 리턴값으로 구현할 수 있다.

didResolveSource

requestDidStart와 거의 동일하다. 다만, automatic persisted query를 사용할 경우 persistedQueryCache에서 찾은 query가 source에 등록된다는 점이 requestDidStart와의 차이점이다.

automatic persisted query를 사용하지 않았을 때 query의 실행결과는 다음과 같다.

--------requestDidStart--------
query : {
movies {
title
}
}
source : undefined
queryHash : undefined
--------didResolveSource--------
query : {
movies {
title
}
}
source : {
movies {
title
}
}
queryHash : 1c4cd9836cb262d1bc5758fa9fb822faecbcb04ed829a52e46f2673fea445622

하지만 automatic persisted query을 사용할 경우 아래와 같이 query의 값이 존재하지 않는다.

--------requestDidStart--------
query : undefined
source : undefined
queryHash : undefined
--------didResolveSource--------
query : undefined
source : {
movies {
title
}
}
queryHash : 1c4cd9836cb262d1bc5758fa9fb822faecbcb04ed829a52e46f2673fea445622

DocumentStore의 유무에 따라

DocumentStore는 parsing, validation된 query를 저장해 놓는 cache이고 apolloServer의 default 설정으로 존재한다. 만일 서버에 요청된 query가 캐싱된 경우 아래 event는 실행되지 않는다.

parsingDidStart

요청된 query를 AST Node로 parsing하기 ‘전’에 호출된다. 아직 query는 string 상태로 존재한다.

validationDidStart

요청된 query가 유효한지(schema에 맞는지) validation 하기 ‘전’에 호출된다. 이 이벤트가 실행된 시점에서 이미 query는 parsing되어 document에 저장되어 있는 상태이고 아직 validation은 진행되지 않았다.

Document 확정 이후

didResolveOperation

operation AST와 operationName이 확정된 직후 호출된다. 아직 실제로 GraphQL 쿼리가 실행된 상황은 아니다.

responseForOperation

GraphQL 쿼리가 실행되기 직전에 호출된다. 만일 null이 아닌 값을 리턴하면 willSendResponse를 제외한 아래 이벤트들은 실행되지 않고 responseForOperation에서 리턴한 값 그대로 클라이언트에게 전달된다.

responseForOperation이 null을 리턴하는 경우

executionDidStart

Document AST를 통해 QraphQL 쿼리가 실행하기 ‘시작할 때마다’ 호출된다. executionDidEndwillResolveField 를 리턴할 수 있다.

willResolveField

Resolver를 통해 각 필드가 실행될 때마다 호출된다. Resolver에서 사용하는 4가지 parameter source(parent), args, contextinfo를 사용할 수 있고, Resolver 실행 결과에 따른 함수(*end hook)을 리턴할 수 있다.

executionDidEnd

말 그대로 Resolver의 실행이 끝났을 때 호출된다.

예시를 통해 알아보자.

만약 요청한 쿼리가 아래와 같다면

query {
movie(id:1) {
id
title
rating
}
}

console에 찍히는 결과는 다음과 같다.

--------executionDidStart--------
--------willResolveField--------
Field Query.movie took 110855ns
It returned {"id":1,"title":"Green Lantern","rating":5.8}
--------willResolveField--------
Field Movie.id took 46865ns
It returned 1
--------willResolveField--------
Field Movie.title took 7187ns
It returned "Green Lantern"
--------willResolveField--------
Field Movie.rating took 10635ns
It returned 5.8
--------executionDidEnd--------

Error가 발생하는 경우

didEncounterErrors

GraphQL 요청의 parsing, validation, execution 과정에서 에러가 발생했을 때 호출된다. 어느 시점에서 에러가 발생 되었는지에 관계없이 호출된 뒤에는 willSendResponse가 실행된다.

예를 들어, 아래와 같은 플러그인이 있다고 하자.

만약 클라이언트가 실수하여 아래와 같은 쿼리를 보냈을 때

query {
movie(id:1) {
titl
score
}
}

validation 과정에서 에러가 발생하며 validationDidStart 다음 이벤트인 executionDidStart는 실행되지 않는다. 실제 console에 찍히는 결과는 다음과 같다.

--------parsingDidStart--------
--------validationDidStart--------
--------didEncounterErrors--------
[
'Cannot query field "titl" on type "Movie". Did you mean "title"?',
'Cannot query field "score" on type "Movie".'
]
--------willSendResponse--------

마치며

Apollo plugin과 더불어 Apollo Server의 코드까지 일부 다루다 보니 글의 길이가 적잖이 길어진 감이 있다. 다만 앞서 말한 것처럼 Apollo plugin의 모든 함수를 알 필요는 없다. 만약 요청에 따른 응답 혹은 에러의 로그를 찍고 싶다면 아래와 같이 플러그인을 구성하면 된다.

이 경우 당신에게 필요한 이벤트는 3개 뿐이다.

하지만 당신의 코드는 언제든 고도화 될 수 있다. 어떤 생애주기의 이벤트를 사용하는 것이 가장 효율적인지 고민할 수도 있고 플러그인이 생각했던 대로 동작하지 않을 수도 있다. 그때 이 글이 당신에게 도움이 되길 바라며 글을 마친다.

--

--

Gunwoo Kim
FRIP
Writer for

KAIST CS undergraduate Software Engineer of Toss