• GraphQL

Relay Connection에 totalCount는 포함되어야 할까?

필터링을 고려하지 않는 하위항목 수는 그냥 `post { repliesCount }` 처럼 구현하는 것이 더 편리합니다. 고급 필터링이 필요하면 그 때 커넥션 totalCount를 구현하면 됩니다.

2019. 11. 11

커넥션 내 totalCount

현재 커넥션이 페이지네이션하고 있는 전체 항목의 갯수는 유용한 정보입니다.

fragment FeedDetailReplies_feed on Feed {
  id
  allReplies: replies { totalCount }
  replies(first: $count, after: $cursor, tag: $tag) @connection(key: "FeedDetailReplies_replies") {
    totalCount # 현재 태그 조건에 매치한 댓글의 갯수
    edges{
      cursor
      node {
        ...Reply_reply
      }
    }
  }
}

Spec 관점에서 보기

Relay Connection Spec 에는 totalCount 자체가 포함되기 어렵게 되어 있습니다.

커넥션 엣지가 0개일 때 커넥션 자체가 null로 전달되지 않으면 스펙을 지킬 수 없게 되어 있는데, 그 경우 항목이 0개인 커넥션은 메타데이터를 반환하지 못하게 됩니다. 클라이언트는 엣지 0개인 커넥션의 메타데이터를 적절하게 추론할 수도 있지만 서버가 명시적으로 zero-value를 제공하는 것이 더 편리합니다.

한편 이 totalCount 필드는 GraphQL 문서에 소개되어 있기도 하고 다른 구현에서도 많이 등장하는 필드입니다. Facebook, GitHub 등 많은 커넥션 구현체들이 totalCount 필드를 구현해서 쓰고 있습니다.

GraphQL: A query language for APIs.

Frontend 관점에서는 alias가 (거의) 강제된다

커넥션 메타데이터는 아래와 같은 방식으로 사용될 수 있습니다. 아래는 피드 하위의 댓글들의 수를 포함하는 프래그먼트 정의입니다.

export default createFragmentContainer(FeedHeader, {
  feed: graphql`
    fragment FeedHeader_feed on Feed {
      id
      author { username }
      replies { totalCount }
    }
  `,
})

그런데 보통은 이런 프래그먼트는 페이지네이션이 적용된 다른 프래그먼트와 함께 쿼리되곤 합니다.

export default createPaginationContainer(FeedDetailReplies, {
  feed: graphql`
    fragment FeedDetailReplies_feed on Feed
    @argumentDefinitions(
      count: {type: "Int", defaultValue: 20}
      cursor: {type: "String"}
    ) {
      id
      replies(first: $count, after: $cursor) @connection(key: "FeedDetailReplies_replies") {
        edges{
          cursor
          node {
            ...Reply_reply
          }
        }
      }
    }
  `
}, {
  // ...
})

위 두 fragment는 아래처럼 쿼리에 함께 쓰입니다.

query SomeQuery($id: ID!) {
  node(id: $id) {
    ...FeedHeader_feed
    ...FeedDetailReplies_feed
  }
}

하지만 이 경우 invalid 한 GraphQL 쿼리가 됩니다. Fragment를 펼쳐보면 문제가 잘 보입니다.

query SomeQuery($id: ID!) {
  node(id: $id) {
    # ...FeedHeader_feed
    replies { totalCount }
    # ...
    # ...FeedDetailReplies_feed
    replies(first: $count, after: $cursor) @connection(key: "FeedDetailReplies_replies") {
    # ...
  }
}

위 코드처럼 다른 인자를 가지고 있는 replies 필드 셀렉션의 이름이 중복되기 때문입니다.

전체를 쿼리하는 커넥션 필드를 allReplies처럼 alias를 부여하면 해결됩니다.

export default createFragmentContainer(FeedHeader, {
  feed: graphql`
    fragment FeedHeader_feed on Feed {
      id
      author { username }
      allReplies: replies { totalCount } # 변경
    }
  `,
})

Backend 관점에서는 리졸버가 살짝 복잡해진다

SomeQuery를 서버에서 실행할 때는 어떨까요?

query SomeQuery($id: ID!) {
  node(id: $id) {
    # ...FeedHeader_feed
    allReplies: replies { totalCount }
    # ...
    # ...FeedDetailReplies_feed
    replies(first: $count, after: $cursor) @connection(key: "FeedDetailReplies_replies") {
    # ...
  }
}

커넥션에 메타데이터 필드를 포함시킬 때, 페이지네이션 된 데이터를 가져오는 시점은 edges, pageInfo를 호출하는 때여야 합니다. 위의 allReplies에서 totalCount 필드를 호출할 때 백엔드가 전체 항목을 데이터베이스에서 쿼리하는 것은 낭비가 됩니다.

또한, 서비스에서는 커넥션이 가져올 수 있는 최대 엣지 count를 제약할 때가 많습니다. 그런 제약이 걸려 있다고 하더라도 totalCount 처럼 메타데이터의 쿼리는 허용해야 하는데 이 때 리졸버의 구조가 복잡해지는 단점이 있었습니다.

메타데이터를 고려하지 않았을 때 count가 너무 큰 경우 replies 필드 리졸버가 바로 거절해버렸을 것입니다. 하지만 메타데이터가 있으면 혹시 이 쿼리가 totalCount를 가져오고자 하는 쿼리일 수도 있으니 edges로 검사를 미루게 됩니다.

그래서?

구현하는 서비스의 클라이언트가 필터된 항목의 갯수를 가져와야 한다면 ReplyConnection.totalCount를 구현합니다. 꼭 그럴 필요가 없다면 그냥 Post.repliesCount로 구현합니다.

나중에 필요에 의해 ReplyConnection.totalCount를 구현해서 Post.repliesCount가 동시에 있다고 하더라도 문제되지 않습니다.