GraphQL.js 리졸버의 마지막 인자는 info입니다. info는 현재 처리 중인 질의에 대한 정보가 들어가 있습니다. 보통은 리졸버 구현에 info가 필요하지 않지만 최적화나 복잡한 연결을 위해서는 info의 내용이 필요합니다.

variableValues

variableValues 속성에는 실행시 준 변수 값이 들어있습니다. 그런데 변수를 사용한 경우에만 값이 있기 때문에 생각보다 쓸 만한 곳은 없습니다. 아래 예에서 query도 필드의 인자이지만 variableValues에는 포함되지 않습니다.

import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql, GraphQLResolveInfo } from 'graphql';

const type_defs = `
type User {
  id: ID!
  name: String!
}

type Query {
  users(query: String, length: Int): [User!]!
}`;

const users = [
  { id: '1', name: 'Francisco' },
  { id: '2', name: 'Alexander' },
];

const resolvers = {
  Query: {
    users: (_source: unknown, _args: unknown, _context: any, info: GraphQLResolveInfo) => {
      console.log(info.variableValues); // { length: 5 }
      return users;
    },
  },
  User: {
    name: (source: any, _args: unknown, _context: any, info: GraphQLResolveInfo) => {
      console.log(info.variableValues); // { length: 5 }
      return source.name;
    },
  },
};

const schema = makeExecutableSchema({ typeDefs: type_defs, resolvers });

(async () => {
  await graphql({
    schema,
    source: `query($length: Int) { users(query: "H", length: $length) { id name } }`,
    variableValues: { length: 5 },
  });
})();

GraphQL Java에서는 DataFetchingEnvironment.getVariables()로 얻을 수 있습니다.

path, fieldName, parentType, returnType

path을 통해 현재 리졸버가 어떤 경로에 의해 실행됐는지 알 수 있습니다.

import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql, GraphQLResolveInfo } from 'graphql';

const type_defs = `
type Name {
  first: String!
  last: String!
}

type User {
  id: ID!
  name: Name!
}

type Query {
  users: [User!]!
}`;

const users = [
  { id: '1', name: { first: 'John', last: 'Doe' } },
  { id: '2', name: { first: 'Michael', last: 'Frank' } },
];

const resolvers = {
  Query: {
    users: (_source: unknown, _args: unknown, _context: any, info: GraphQLResolveInfo) => {
      console.log(JSON.stringify(info.path, null, 2));
      return users;
    },
  },
  User: {
    name: (source: any, _args: unknown, _context: any, info: GraphQLResolveInfo) => {
      console.log(JSON.stringify(info.path, null, 2));
      return source.name;
    },
  },
  Name: {
    first: (source: any, _args: unknown, _context: any, info: GraphQLResolveInfo) => {
      console.log(JSON.stringify(info.path, null, 2));
      return source.first;
    },
  },
};

const schema = makeExecutableSchema({ typeDefs: type_defs, resolvers });

(async () => {
  await graphql({ schema, source: `query { users { id name { first last } } }` });
})();

다음과 같은 구조의 데이터입니다. users는 배열이기 때문에 중간에 ‘0’이 키로 존재합니다.

{ "key": "users", "typename": "Query" }

{
  "prev": {
    "prev": { "key": "users", "typename": "Query" },
    "key": 0
  },
  "key": "name", "typename": "User"
}

{
  "prev": {
    "prev": {
      "prev": { "key": "users", "typename": "Query" },
      "key": 0
    },
    "key": "name", "typename": "User"
  },
  "key": "first", "typename": "Name"
}

fieldName, parentType, returnType에는 다음 스키마에 해당하는 타입이 들어가 있습니다.

users, Query, [User!]!
name, User, Name!
first, Name, String!

GraphQL Java에서는 DataFetchingEnvironment의 getExecutionStepInfo().getPath(), getField().getName(), getParentType(), getExecutionStepInfo().getType()으로 얻을 수 있습니다.

fieldNodes

fieldNodes에는 현재 실행중인 리졸버가 반환해야 하는 필드 정보가 들어가 있습니다. (GraphQL Java에는 getField()로 얻을 수 있습니다.) 쿼리 최적화등에 사용할 수 있어서 가장 많이 쓰는 속성입니다.

위 예에서 각 리졸버에 각각 users { id name { first last } }, name { first last }, first라는 값이 들어온다고 보면 됩니다. GraphQL.js의 내부 구조체를 그대로 주기 때문에 직접 쓰기에는 어렵습니다. 그래서 카카오스타일에서는 @croquiscom/crary-graphql이라는 자체 라이브러리를 만들어 사용하고 있습니다.

getFieldList

getFieldList(info)를 실행하면 현 리졸버가 반환해야 할 필드 목록을 반환합니다. 위 예에서는 [ 'id', 'name.first', 'name.last' ], [ 'first', 'last' ], []가 실행 결과가 됩니다. 이를 활용해 클라이언트가 요청한 필드만 처리하는 최적화를 할 수 있습니다.

중첩된 필드는 . 으로 묶여 1차원 배열로 반환하는데, 카카오스타일에서 쓰는 ORM인 CORMO에서 객체 컬럼이 있을 때 사용하는 select 구문의 인자로 그대로 쓸 수 있는 형태를 가지고 있습니다.

class Name {
  @cormo.Column({ type: String, required: true })
  public first: string;

  @cormo.Column({ type: String, required: true })
  public last: string;
}

@cormo.Model()
class User extends cormo.BaseModel {
  id: number;

  @cormo.ObjectColumn(Name)
  public name: Name;
}

const records = await User.where().select(getFieldList(info));

{ users { id name { first } } }와 같이 일부 필드만 요청한 경우 위 코드는 SELECT id, name_first FROM users와 같이 요청한 컬럼만 DB에서 가져오는 최적화된 쿼리로 변환됩니다.

getFieldList1st

getFieldList1st는 getFieldList와 유사하지만 첫번째 깊이의 필드만 반환합니다. 즉 위 예에서는 [ 'id', 'name' ], [ 'first', 'last' ], [] 를 반환합니다.

args 아티클에 있는 total_count, item_list 예에서 getFieldList1st(info).includes('item_list') 조건문을 써서 item_list를 요청한 경우에만 DB에서 목록을 가져오는 최적화를 할 수 있습니다.

addArgumentToInfo, addFieldToInfo, removeArgumentFromInfo, removeFieldFromInfo

카카오스타일에서는 API gateway를 두고 있고, 거기서 GraphQL을 각 마이크로서비스로 분배합니다. 이 때 클라이언트 요청을 일부 변형한 후에 마이크로서비스로 전달할 필요가 있어서 만든 유틸리티입니다. info에 필드를 추가/삭제하거나 인자를 변형할 수 있습니다.

마이크로서비스에서 계정 정보를 반환하는 Query.user(id: ID!) API를 제공한다고 가정해 봅시다. 하지만 API gateway에서는 이 API를 그대로 쓰는게 아니라 현재 로그인한 사용자의 정보를 반환하는 Query.user 형태로 클라이언트에 노출하고 있습니다. 로그인한 사용자 정보는 API gateway가 알고 있고, 마이크로서비스 API 호출시 이를 인자에 추가해줘야 합니다. 이 로직을 다음과 같이 구현하고 있습니다. (hookResolver는 기존 리졸버의 동작 앞뒤에 다른 동작을 추가할 수 있는 유틸리티입니다.)

hookResolver(schema.getQueryType()!.getFields().user, async (source, args, context, info, resolve) => {
  const user_id: string | undefined = context.session.login_user_id;
  if (!user_id) {
    return null;
  }
  info = addArgumentToInfo(info, 'id', user_id, new GraphQLNonNull(GraphQLID));
  // 또는 info = info.addArgument('id', user_id, new GraphQLNonNull(GraphQLID));
  return await resolve(source, args, context, info);
});

GraphQL.js 리졸버의 세번째 인자는 context입니다. 이 인자는 온전히 사용자가 설정하는 것으로 매 요청마다 새로 생성되며 같은 요청을 처리하는 리졸버가 상태를 공유하기 위해 사용합니다.

다음과 같이 적당한 값을 넣어서 한번 실행해보면 동작 방식을 금방 이해할 수 있습니다.

import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql } from 'graphql';

const type_defs = `
type User {
  id: ID!
  name: String!
}

type Query {
  users: [User!]!
}`;

const users = [
  { id: '1', name: 'Francisco' },
  { id: '2', name: 'Alexander' },
];

interface Context {
  foo: number;
  bar?: number;
}

const resolvers = {
  Query: {
    users: (_source: unknown, _args: unknown, context: Context) => {
      console.log('users', context);
      context.bar = (context.bar || 0) + 1;
      return users;
    },
  },
  User: {
    name: (source: any, _args: unknown, context: Context) => {
      console.log('name', context);
      context.bar = (context.bar || 0) + 1;
      return source.name;
    },
  },
};

const schema = makeExecutableSchema({ typeDefs: type_defs, resolvers });

(async () => {
  await graphql({ schema, source: `query { users { id name } }`, contextValue: { foo: 1 } });
  await graphql({ schema, source: `query { users { id name } }`, contextValue: { foo: 100, bar: 10 } });
})();

실행 결과는 다음과 같습니다.

users { foo: 1 }
name { foo: 1, bar: 1 }
name { foo: 1, bar: 2 }
users { foo: 100, bar: 10 }
name { foo: 100, bar: 11 }
name { foo: 100, bar: 12 }

컨텍스트의 가장 일반적인 사용예는 클라이언트 요청의 부가 정보를 담는 것입니다. 요청시 준 HTTP 헤더, 로그인 상태인 경우 로그인 한 사용자 정보 같은 것들이 있습니다.

다음은 실제 클라이언트 요청을 처리하는 프레임워크인 Apollo Server에서 컨텍스트를 설정해 본 예제입니다.

import { IncomingMessage, ServerResponse } from 'http';
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

interface Context {
  req: IncomingMessage;
  res: ServerResponse;
}

const resolvers = {
  Query: {
    getUser: (_source: any, args: any, context: Context) => {
      console.log(Object.keys(context)); // [ 'req', 'res' ]
      console.log(context.req.headers); // { host: 'localhost:4000', 'content-type': 'application/json', ... }
      return users[args.id];
    }
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

(async () => {
  await startStandaloneServer<Context>(server, {
    context: async ({req, res}) => ({req, res}),
  });
})();

GraphQL Java의 경우 GraphQLContext라는 속성으로 제공합니다. DGS Framework의 경우 HTTP 요청을 처리해주는데 이를 제공하기 위해 GraphQLContext를 감싼 DgsContext를 제공합니다. println(dfe.getDgsContext().requestData) 로 내용을 출력해보면 DgsWebMvcRequestData(extensions={}, headers=[host:"localhost:8080", accept:"application/json", ...], webRequest=ServletWebRequest: uri=/graphql;client=127.0.0.1) 와 같은 결과를 볼 수 있습니다.

DataLoader와 컨텍스트

앞선 글에서 얘기했듯이 리졸버는 독립적으로 동작하는 것이 좋습니다. 그렇다보니 한번에 요청할 수 있는 것을 나눠서 요청하는 N+1 쿼리 문제가 발생합니다. 이를 해결해주는 라이브러리로 DataLoader가 있습니다. 그런데 이 DataLoader는 요청별로 생성하는 것이 좋습니다. 즉 요청별로 다른 값을 가지는 컨텍스트를 사용할 필요가 있습니다.

In many applications, a web server using DataLoader serves requests to many different users with different access permissions. It may be dangerous to use one cache across many users, and is encouraged to create a new DataLoader per request:

필요할 때 DataLoader 인스턴스를 생성하기 위해 카카오스타일에서는 컨텍스트를 활용한 다음 패턴을 주로 사용합니다.

interface Context {
  loader: { [loader_name: string]: DataLoader<any, any> | undefined };
}

// for Apollo Server
{
  context: async ({ req, res }) => ({ loader: {} }),
}

// resolver
my_field(soruce, args, context: Context) {
  const loader_name = 'MyType.my_field';
  let loader: DataLoader<string, MyFieldType> | undefined = context.loader[loader_name];
  if (!loader) {
		loader = new DataLoader<string, MyFieldType>(async (keys) => {
			// return Array<MyFieldType> for keys
		});
		if (context) {
			context.loader[loader_name] = loader;
		}
  }
	return loader.load(source.key_for_my_field);
}

참고로 GraphQL Java는 DataLoader에 대한 처리가 라이브러리 내부에 들어와 있습니다. DataLoaderRegistry 클래스를 생성해 ExecutionInput으로 주면 DataFetchingEnvironment.getDataLoader를 통해 DataLoader를 얻을 수 있습니다. DGS Framework에서는 DgsDataLoader 어노테이션을 통해 등록하면 됩니다.

GraphQL.js 리졸버의 두번째 인자는 args입니다. 해당 필드에 인자가 주어지면 그 값이 들어옵니다.

GraphQL Java에서는 DataFetchingEnvironment.getArguments 로 얻을 수 있습니다.

인자 처리하기

프로그래밍에서 함수 호출에 인자가 있는 것은 당연하기 때문에 어렵지 않은 개념일 겁니다. 다만 GraphQL에서 간과하기 쉬운 특성으로 객체 속성에도 인자를 줄 수 있다는 점이 있습니다. 이를 활용해 데이터를 서버에서 적절히 가공해 클라이언트에 주도록 할 수 있습니다.

import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql } from 'graphql';

const type_defs = `
type User {
  id: ID!
  name(length: Int): String!
}

type Query {
  users(query: String, length: Int): [User!]!
}`;

interface User {
  id: string;
  name: string;
}

const users: User[] = [
  { id: '1', name: 'Francisco' },
  { id: '2', name: 'Alexander' },
  { id: '3', name: 'Picasso' },
  { id: '4', name: 'Charles' },
  { id: '5', name: 'Frederick' },
  { id: '6', name: 'Nicholas' },
];

const resolvers = {
  Query: {
    users: (_source: unknown, args: { query?: string; length?: number }) => {
      console.log(`users args: ${JSON.stringify(args)}`);
      const filtered = args.query ? users.filter((user) => user.name.includes(args.query!)) : users;
      return args.length ? filtered.slice(0, args.length) : filtered;
    },
  },
  User: {
    name: (source: User, args: { length?: number }) => {
      console.log(`name args: ${JSON.stringify(args)}`);
      return args.length ? source.name.slice(0, args.length) : source.name;
    },
  },
};

const schema = makeExecutableSchema({ typeDefs: type_defs, resolvers });

(async () => {
  const result = await graphql({
    schema,
    source: `query {
  a: users(query: "o", length: 2) { id name }
  b: users(length: 2) { id name(length: 3) }
  c: users(query: "o", length: null) { id name(length: null) }
}`,
  });
  console.log(JSON.stringify(result, null, 2));
})();

실행 결과는 너무 당연해서 별다른 설명이 필요없습니다.

users args: {"query":"o","length":2}
name args: {}
name args: {}
users args: {"length":2}
name args: {"length":3}
name args: {"length":3}
users args: {"query":"o","length":null}
name args: {"length":null}
name args: {"length":null}
name args: {"length":null}
{
  "data": {
    "a": [
      { "id": "1", "name": "Francisco" },
      { "id": "3", "name": "Picasso" }
    ],
    "b": [
      { "id": "1", "name": "Fra" },
      { "id": "2", "name": "Ale" }
    ],
    "c": [
      { "id": "1", "name": "Francisco" },
      { "id": "3", "name": "Picasso" },
      { "id": "6", "name": "Nicholas" }
    ]
  }
}

하나 주목할 점은 nullable 인자에 null을 준 것과 아예 누락한게 다르다는 점입니다. JavaScript에서 null과 undefined는 모든 사람들에게 혼란을 주는 괴이한 스펙이지만, 가끔은 유용/필요할 때가 있습니다. 다만 GraphQL API는 모든 언어를 생각해야 하기 때문에 null과 누락이 다르다는 특성을 실제 활용한 적은 없습니다.

다음은 DGS Framework로 구현한 예입니다.

data class User(val id: String, val name: String)

val users = listOf(
    User("1", "Francisco"),
    User("2", "Alexander"),
    User("3", "Picasso"),
    User("4", "Charles"),
    User("5", "Frederick"),
    User("6", "Nicholas"),
)

@DgsComponent
class UserDataFetcher {
    @DgsQuery
    fun users(dfe: DgsDataFetchingEnvironment): List<User> {
        val query = dfe.getArgument<String?>("query")
        val length = dfe.getArgument<Int?>("length")
        println("users ${dfe.arguments} / $query / $length")
        val filtered = if (query != null) users.filter { it.name.contains(query) } else users
        return if (length != null) filtered.subList(0, length) else filtered
    }

    @DgsData(parentType = "User", field = "name")
    fun name(dfe: DgsDataFetchingEnvironment): String {
        val length = dfe.getArgument<Int?>("length")
        println("name ${dfe.arguments} / $length")
        val name = dfe.getSource<User>().name
        return if (length != null) name.substring(0, length) else name
    }
}

질의 결과는 당연히 동일하고, arguments를 한번 살펴보면 null이 들어온 경우와 값이 주어지지 않은 경우가 구분되는 것을 확인할 수 있습니다.

users {query=o, length=2} / o / 2
name {} / null
name {} / null
users {length=2} / null / 2
name {length=3} / 3
name {length=3} / 3
users {query=o, length=null} / o / null
name {length=null} / null
name {length=null} / null
name {length=null} / null

상위 필드의 인자 사용

User 타입의 필드 리졸버를 구현이 필요하다고 생각해봅시다. 그런데 이 User 객체가 getPosts → Post → author를 거쳐 온 것인지, getUser에서 온 것인지 구분할 수가 없습니다. 따라서 리졸버는 가급적 어떤 경로에서 온 것인지 몰라도 동작하도록 구현하는 것이 바람직합니다. 그런데 상위 필드의 인자를 사용한다? 얼핏 봐도 하면 안 되는 일 같지만 아쉽게도 이게 필요한 경우가 있습니다.

서버에서 리소스 목록을 반환하는 API는 기본이라고 볼 수 있죠. 그리고 이 목록이 방대하기 때문에 페이지네이션으로 가져오는 경우도 흔합니다. 페이지네이션을 보여주려면 요청한 목록 외에 전체 리소스 개수도 필요합니다. 물론 GraphQL을 사용하면 목록과 개수를 한번에 요청하는 것이 가능합니다. 흔한 요구사항인 만큼 페이스북이 처음 GraphQL을 내놓을때 Connections 라는 스펙을 Relay와 함께 제시했습니다.

이 스펙으로 포스트 목록을 요청하면 다음과 같은 형태가 됩니다.

query {
  getPosts {
    totalCount
    pageInfo {
      hasNextPage
    }
    edges {
      cursor
      node {
        id
        title
        author { name }
      }
    }
  }
}

개인적으로 이 스펙은 복잡해서 이해하기 어렵다고 느껴졌기 때문에 카카오스타일에서는 Connection, Edge, Node 대신 단순한 List, Item 개념을 사용해서 목록을 구현하고 있습니다. cursor를 위로 빼서 Edge 필요성을 없애고, hasNextPage를 next_cursor != null 로 대체했다고 보시면 됩니다.

type PostList {
  total_count: Int!
  item_list: [Post!]!
  next_cursor: String
}

type Query {
  getPosts: PostList!
}

항상 글 전체 목록만 가져올리는 없겠죠? 원하는 글 목록에 대한 조건을 추가해봅시다. 페이지네이션은 전통적인 방식인 OFFSET & LIMIT으로 구현하기로 했습니다.

type Query {
  getPosts(
		author_id: ID
		date_created_gte: Float
		date_created_lt: Float
		title_contains: String
		limit: Int
		skip: Int
	): PostList!
}

total_count, item_list를 구현할 때 위 인자를 모두 알아야 합니다. (total_count 구현시에는 limit, skip이 필요하지 않습니다) 하지만 이 리졸버에서 getPosts의 인자를 얻을 방법이 없습니다. 그렇다고 total_count, item_list에 인자를 중복 기술하는 것은 좋아보이지 않습니다.

이전 source 설명 아티클에서 말씀드린 테크닉을 사용하면 상위 인자를 얻을 수 있습니다.

const resolvers = {
  Query: {
    getPosts: (_source: unknown, args: GetPostsArgs) => ({ __args: args }),
  },
  PostList: {
    total_count: ({ __args: args }: { __args: GetPostsArgs }) => {
      // get total count with arguments
    },
    item_list: ({ __args: args }: { __args: GetPostsArgs }) => {
      // get item list with arguments
    },
  },
};

원 리졸버의 args와 헷갈리기 때문에 익숙해지려면 시간이 걸리지만 동작은 합니다. 이 테크닉을 사용하지 않으려면 getPosts에서 total_count, item_list를 모두 구해 반환하면 됩니다.

const resolvers = {
  Query: {
    getPosts: (_source: unknown, args: GetPostsArgs) => {
      const total_count = 0; // get total count with arguments
      const item_list = []; // get item list with arguments
      return { total_count, item_list };
    },
  },
};

이 경우 내가 total_count, item_list 중 하나만 요청한 경우에도 나머지를 계산해야 하는 비효율이 발생합니다. (다음에 설명할 info를 사용해 최적화할 수는 있습니다) 어느 쪽이든 그렇게 깔끔하지는 않습니다만, 개인적으로는 주로 전자의 패턴을 많이 사용하고 있습니다. (후자로 한 경우도 있습니다.) 상위 인자를 접근하는 것은 일반적으로 좋지 않은 패턴이므로 위와 같이 실질적으로 하나의 쌍으로 간주되는 경우만 사용하시는 것이 좋습니다.

GraphQL Java도 똑같이 구현할 수 있지만, 상위 필드가 반환해주는 값을 별도로 전달 받을 수 있는 localContext 라는 개념이 있어 이를 활용할 수 있습니다.

class PostDataFetcher {
    data class Post(val id: String, val title: String, val author_id: String)
    data class PostList(val total_count: Int, val item_list: List<Post>)

    @DgsQuery
    fun getPosts(dfe: DgsDataFetchingEnvironment): DataFetcherResult<PostList> {
        println("getPosts ${dfe.arguments}")
        return DataFetcherResult.newResult<PostList>()
            .data(PostList(0, emptyList()))
            .localContext(dfe.arguments)
            .build()
    }

    @DgsData(parentType = "PostList", field = "total_count")
    fun totalCount(dfe: DgsDataFetchingEnvironment): Int {
        println("totalCount ${dfe.getLocalContext<Map<String, Object>>()}")
        return 10
    }

    @DgsData(parentType = "PostList", field = "item_list")
    fun itemList(dfe: DgsDataFetchingEnvironment): List<Post> {
        println("itemList ${dfe.getLocalContext<Map<String, Object>>()}")
        return listOf(Post("1", "Post 1", "51"))
    }
}