본문 바로가기

Programming/React, Angular, GraphQL

Apollo GraphQL custom plugin - 나만의 로그 만들기

import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
  typeDefs,
  resolvers,
  // 여기!
  plugins: [loggerPlugin, errorHandlingPlugin],
});

클라이언트로부터 받는 모든 GraphQL 요청들에 대해서 로그를 남겨 모니터링해야하는 일이 있었다. 현재 Apollo GraphQL을 사용하고 있는데 공식 문서를 찾아보던중 custom plugin을 직접 만들어서 GraphQL의 라이프 사이클의 각 단계마다 원하는 동작을 추가할 수 있다는 것을 알게 되었다. 그래서 이번 글에서는 Apollo GraphQL custom plugin에 대한 글이다.

 

Plugin의 기본 구조

Plugin은 이벤트들에 응답하는 하나 이상의 함수들이 정의된 Javascript 객체이다. 먼저 가장 기본적인 틀을 확인해보자.

const myPlugin = {
  async serverWillStart() {
    console.log('Server starting up!');
  },
};

serverWillStart 이벤트 발생 시, 'Server starting up!' 이라는 로그를 출력해주는 플러그인이다.

interface MyContext {
  token: string;
}

export default function (): ApolloServerPlugin<MyContext> {
  return {
    async requestDidStart({ contextValue }) {
      console.log(contextValue.token);
    },
  };
}

만약, Request의 contextValue 값을 사용해야된다면 위 코드와 같이 서버에서 정의하고있는 Context 타입을 명시해주면 된다. ApolloServerPlugin<MyContext> 

 

*GraphQL의 contextValue에 대해 궁금하다면 해당 공식 문서를 확인해보자.

 https://www.apollographql.com/docs/apollo-server/data/context/

 

Context and contextValue

Sharing information and request details throughout your server

www.apollographql.com

Request Life Cycle Event

그렇다면, GraphQL request lifecycle에는 어떤것들이 있는지 한 번 알아보자.

먼저 플러그인에 requestDidStart 함수를 무조건 호출해주어야 한다. requestDidStart()는 Apollo Server가 GraphQL 요청을 받을때마다 '가장 먼저' 실행된다.

const myPlugin = {
  async requestDidStart(requestContext) {
    console.log('Request started!');

    return {
      async parsingDidStart(requestContext) {
        console.log('Parsing started!');
      },

      async validationDidStart(requestContext) {
        console.log('Validation started!');
      },
    };
  },
};

 

requestDidStart()는 request lifecycle 이벤트에 응답하는 함수들을 객체로 반환할 수 있다.

 

Request Lifecycle event flow

'Success'로 이어질 수 있는 모든 이벤트들은 모두 error가 발생할 수 있다. 만약 error가 발생하면 didEncounterErrors() 이벤트가 발생하고, Success 경로의 나머지 event들은 실행되지 않는다.

requestDidStart

서버에 요청이 들어오고 가장 먼저 호출된다. 아직 파싱되기 전이므로 string 상태의 query가 제공된다. 

didResolveSource

Apollo Server가 수행할 작업에 대해서 들어오는 작업에 대한 String-representation을 결정한 이후에 호출된다. 만약 클라이언트로부터 query 값에 String이 넘어오지 않았다면, Automatic persisted query를 사용하는 경우이다. 이때는 persistedQueryCache에서 찾은 query를 source로 등록하게된다.

parsingDidStart

Apollo Server에 요청된 query를 AST Node로 파싱하기전에 호출된다.

validationDidStart

요청된 query가 GraphQL Schema와 일치하는지 유효성 검사를 하기전에 호출되는 event이다. parsingDidStart와 마찬가지로 request document가 cache에 존재할 경우 발생하지 않는다. (Apollo Server cache에는 유효한 document만 캐싱된다.)

document AST는 이 단계에서 보장된다.

didResolveOperation

operation AST와 operationName이 확정된 직후에 호출된다. 하지만, resolver가 실행된 시점은 아니다. 만약 이 hook에서 GraphQLError를 던지게 될 경우, 500 상태코드 에러를 클라이언트에 전송하게 된다. (만약 다른 상태 코드를 정의해두었다면 해당 코드가 전송된다.) 이 hook은 추가 유효성 검사를 하기 좋다. operation에 대해서 파싱되었고, 유효성이 검증된 이후 단계이기 때문이다.

responseForOperation

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

executionDidStart

document AST를 통해 GraphQL 쿼리가 실행하기 시작할 때마다 호출된다. executionDidEnd, willResolveField 메소드를 포함한 객체를 반환할 수 있다.

willResolveField

Resolver를 통해 각 필드가 실행될 때마다 호출된다. source, args, contextValue, info 네개의 파라미터들을 사용할 수 있다. 실행 결과에 따라 선택적으로 end hook 함수들을 반환할 수 있다. resolver가 완전히 실행 완료되면 end hook이 호출된다.

willSendResponse

Apollo Server가 operation에 대한 응답을 전송하려고 할 때 호출된다. 가장 최종 단계이다.

End hooks

  • parsingDidStart
  • validationDidStart
  • willResolveField

위 세 개의 이벤트에 한해서 선택적으로 해당 lifecycle 단계가 끝난 후에 호출되는 함수를 반환할 수 있다.

const myPlugin = {
  async requestDidStart() {
    return {
      async parsingDidStart() {
        return async (err) => {
          if (err) {
            console.error(err);
          }
        };
      },
      async validationDidStart() {
        return async (errs) => {
          if (errs) {
            errs.forEach((err) => console.error(err));
          }
        };
      },
      async executionDidStart() {
        return {
          async executionDidEnd(err) {
            if (err) {
              console.error(err);
            }
          },
        };
      },
    };
  },
};

 

executionDidStart() hook은 executionDidEnd() 을 포함한 객체를 반환한다. 그 이유는 반환된 객체들도 willResolveField에 포함될 수 있기 때문이다. 이벤트 핸들러처럼 end hooks들은 willResolveFileds()를 제외하고는 모두 비동기 함수들이다.

 

Plugin

Request Life Cycle에 대해서 간단하게 알아봤으니 직접 플러그인을 만들어보자.

const LoggerPlugin: ApolloServerPlugin = {
    serverWillStart() {
    	console.log('Server started');
    }
    requestDidStart(requestContext) {
    	const { request: { operationName, variables } } = requestContext;
        console.log('Request start');
        console.log(`operationName: ${operationName}`);
        console.log(`variables: ${variables}`);
        
        return {
        	willSendResponse: ({ errors }) => {
        		console.log('Request end');
		       	console.log(`errors: !!errors`);
            }
        }
    }
}

 

serverWillStart() 이벤트로 서버가 시작됐음을 확인하고, requestDidStart()를 통해서 매 요청의 operationName과 variables를 출력한다.

query GetUser($id: ID!) {
  getUser(id: $id) {
    id
    name
    email
  }
}

 

예를 들어, GetUser 쿼리 요청이 들어왔고 varaibles는 { id: "123" } 이와 같고, 오류는 발생하지 않았다면 로그는 아래와 같이 출력될 것이다.

Request start
operationName: GetUser
variables: {"id":"123"}
Request end
errors: false

 

그렇다면 오류가 발생했을 때 조금 더 자세한 로그를 확인해보기 위해서 ErrorHandlingPlugin을 만들어보자.

const ErrorHandlerPlugin: ApolloServerPlugin = {
  requestDidStart({ queryHash, operationName }) {
    return {
      didEncounterErrors: ({ errors }) =>
        errors.forEach((error) => {
            console.log("Error encountered")
            console.log(queryHash)
            console.log(operationName)
            console.log(error)
          });
        }),
    };
  },
};

 

didEncounterErrors 이벤트를 통해서 발생한 error들에 대해서 출력해보자.

query GetUser($id: ID!) {
  getUser(id: $id) {
    id
    name
    email
    nonExistentField  # 존재하지 않는 필드
  }
}
Error encountered
queryHash 예제
GetUser
{
  "message": "Cannot query field 'nonExistentField' on type 'User'.",
  "locations": [
    {
      "line": 6,
      "column": 5
    }
  ],
  "path": [
    "getUser",
    "nonExistentField"
  ]
}

 

Plugin 사용하기

plugin을 선언한 이후에, ApolloServer constructor의 plugin 옵션에 넘겨주기만 하면 된다.

import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [loggerPlugin, errorHandlingPlugin],
});

 

위에 보면 Request Lifecycle에서 발생할 수 있는 모든 이벤트에 대해 설명했지만 막상 plugin 예제에서는 모든 이벤트를 사용하지는 않았다. 각자 원하는 로그 기록 시점, 형태에 따라서 활용할 수 있는 이벤트 훅들이 많기 때문에 적재적소에 가져다가 사용해보면 좋을 것 같다.

반응형