Relay with Next.js: Data Masking

June 23, 2021 ·   11 mins to read

에서 이어지는 글입니다.

Notice

이 글은 독자가 GraphQL과 React에 대한 기본적인 이해가 있다고 가정한다.

1. Data Masking

Relay의 핵심 컨셉 중 하나인 Data Masking에 대해 먼저 알아보면 좋을 것 같다. Relay docs (opens new window)에 보면 잘 설명되어 있다.

Refers to the idea that a component should not be able to access any data it does declare in its fragment or query, even inadvertently.

Component가 query 혹은 fragment에 선언한 데이터에 부주의하게 접근하면 안 된다는 뜻이다. 즉, component에서 특정한 data에 접근하고 싶을 경우 query, fragment를 통해 명시적으로 선언해야 한다.

Thinking in Relay: Data Masking (opens new window)을 보면 예제와 함께 더 자세히 설명되어있다.

const storyQuery = graphql`
  query StoryQuery($storyID: ID!) {
    story(id: $storyID) {
      title
      author {
        ...AuthorDetails_author
      }
    }
  }
`

function Story({ storyId }) {
  const { story } = useLazyLoadQuery(storyQuery, storyId)

  return (
    <>
      <Heading>{story?.title}</Heading>
      {story?.author && <AuthorDetails author={story.author} />}
    </>
  )
}

<AuthorDetails /> 내부에서 선언한 AuthorDetails_author fragment는 <Story />를 통해 한 번에 fetch 된다. 이때 <Story /><AuthorDetails />이 child component임에도, AuthorDetails_author에 선언되어있는 field에 접근하지 못한다. 이렇게 하면 각 component 간 data가 섞이는 것을 방지할 수 있고, 이를 통해 component를 확실히 분리하여 책임을 명확히 할 수 있게 된다.

또한 <Stroy />에서 실수로 graphql field를 삭제하더라도 child component가 동작하지 않는 문제를 방지할 수 있다. 예를 들어 다음과 같이 코드가 선언되어 있으면,

const storyQuery = graphql`
  query StoryQuery($storyID: ID!) {
    story(id: $storyID) {
      title
      author {
        name
        email
        phone
      }
    }
  }
`

function Story({ storyId }) {
  const { story } = useLazyLoadQuery(storyQuery, storyId)

  return (
    <>
      <Heading>{story?.title}</Heading>
      {story?.author && <AuthorDetails author={story.author} />}
    </>
  )
}

<AuthorDetails />author 객체를 한 번에 props로 내려받기 때문에 storyQuery를 수정하기 위해서는 <AuthorDetails />내부에서 어떤 data field가 쓰이는지를 일일이 살펴보아야 하며, 무심결에 name 같은 field를 삭제하는 순간 <AuthorDetails />가 오동작할 가능성이 있다. 이런 오류가 발생할 가능성을 compile 단계에서 검증하기 때문에 개발자들이 실수할 여지를 줄여준다.

2. Relay hooks

읽기전에
  • 현재 Relay는 render-as-you-fetch (opens new window) 개념을 충실히 재현하고 있기 때문에, react-relay에서 제공하는 hooks들은 React.Suspense와 함께 사용해야 한다. 그러나 React.Suspense가 SSR을 아직 지원하지 않기 때문에(얼른 18버전을 릴리즈해줘!) Next.js에서 사용하기 애매한 부분이 있다고 판단했다.
  • 한재엽님의 글 (opens new window)에서 힌트를 얻고자 하였으나, build 된 결과물을 보면 SSG의 이점을 얻지 못할 거라 생각하였다(물론 글쓴이의 무지로 인한 결과일 가능성도 있다).
  • 그렇기 때문에 전통적인 방법인 fetch-on-render를 이용하기 위해 relay-hooks (opens new window)라는 별도의 library를 설치하여 진행하였다.
  • 혹시나, 이 방법이 잘못되었다면 언제든 메일(lucas.han.public@gmail.com)이나 댓글을 통해 꼭 수정요청을 해주셨으면 좋겠다.

저번 예시에서는 Next.js의 getStaticProps에서 return 하는 값들을 사용했지만, fragment와 Relay의 기능을 좀 더 사용하기 위해서는 hooks를 사용해야 한다. Relay hooks를 사용하면 Relay Store와 효율적인 연동을 할 수 있게 되는 장점도 있다. 우선 위에 쓰여 있듯 relay-hooks를 설치한다.

$ yarn add relay-hooks

그다음, _app.tsx를 수정한다.

  // <PROJECT_ROOT>/pages/_app.tsx
  import '../styles/globals.css'
- import { RelayEnvironmentProvider } from 'react-relay/hooks'
+ import { RelayEnvironmentProvider } from 'relay-hooks'
  import { useEnvironment } from '../relay/relayEnvironment'

  import type { AppProps } from 'next/app'

  function App({ Component, pageProps }: AppProps) {
    const environment = useEnvironment(pageProps.initialRecords)
    return (
      <RelayEnvironmentProvider environment={environment}>
        <Component {...pageProps} />
      </RelayEnvironmentProvider>
    )
  }

  export default App

_app.tsx를 수정했다면 index.tsx의 코드 역시 수정하면 된다.

  import { fetchQuery, graphql } from 'react-relay'
+ import { useQuery } from 'relay-hooks'
  import { initEnvironment } from '../relay/RelayEnvironment'

- import type { InferGetStaticPropsType } from 'next'
  import type { pages_index_MarketplaceListings_Query } from '../__generated__/pages_index_MarketplaceListings_Query.graphql'

  const query = graphql`
    query pages_index_MarketplaceListings_Query($first: Int) {
      marketplaceListings(first: $first) {
        edges {
          node {
            id
            app {
              name
            }
            fullDescription
          }
        }
      }
    }
  `

- export default function Home({
-   marketplaceListings,
- }: InferGetStaticPropsType<typeof getStaticProps>) {
+ export default function Home() {
+   const { data, error, isLoading } = useQuery(query, { first: 20 })
    return (
      <div>
 +      {isLoading ? 'Loading...' : null}
        <ul>
 -        {marketplaceListings.edges.map(({ node }) => (
+        {data?.marketplaceListings.edges.map(({ node }) => (
            <li key={node.id}>
              <div>App name: {node.app?.name}</div>
              <div>Description: {node.fullDescription}</div>
            </li>
          ))}
        </ul>
      </div>
    )
  }

  export const getStaticProps = async () => {
    const environment = initEnvironment()
    try {
      const queryProps = await fetchQuery<pages_index_MarketplaceListings_Query>(environment, query, {
        first: 20,
      }).toPromise()
      const initialRecords = environment.getStore().getSource().toJSON()

      return {
        props: {
-         ...queryProps,
          initialRecords,
        },
      }
    } catch (e) {
      console.error(e)
      throw e
    }
  }

위와같이 변경해도 문제없이 데이터를 잘 가져온다. useQuery (opens new window)는 기본적으로 store-or-network option으로 동작하기 때문에 내부의 Store에 저장된 cached data를 먼저 찾아보게 된다. 만약 cached data가 없다면 API 서버로 요청을 보내어 수신된 결과를 return 함과 동시에 Store에 저장한다. 내부 구현 (opens new window)을 보면 relay-runtimefetchQuery를 사용하여 구현되어있다.

getStaticProps 내부에서 이미 data를 fetch 하여 그 결과를 initialRecords로 return하고 있기 때문에, index.tsx에서 useQuery를 호출하는 시점에 이미 Store에 cached data가 있고, 그 data를 바탕으로 SSG 된 HTML이 렌더링 된다.

3. useFragment

위의 코드에서 아래의 부분을 <MarketPlace />로 분리한다.

<li key={node.id}>
  <div>App name: {node.app?.name}</div>
  <div>Description: {node.fullDescription}</div>
</li>

1. 전통적인 방식의 props 전달하는 방법

만약, REST API를 사용하거나 전통적인 방식으로 data를 전달하려면, 다음과 같이 수정하면 된다.

<ul>
  {data?.marketplaceListings.edges.map(({ node }) =>
    <MarketPlace
      key={node.id}
      name={node.app?.name}
      description={node.fullDescription}
    />
  )}
</ul>

2. useFragment를 사용하는 방법

<MarketPlace />에서 필요한 데이터를 명시하기 위해서 useFragment hooks를 사용할 것이고, parent component인 index.tsx의 query에 spread 하면 된다.

먼저 <MarketPlace /> component를 다음과 같이 작성한다. 아직 Relay compiler를 실행하기 전이기 때문에 type과 관련한 부분은 임시로 any로 처리해놓는다.

// MarketPlace.tsx
import { graphql, useFragment } from 'relay-hooks'

const fragment = graphql`
  fragment MarketPlace_marketPlace on MarketplaceListing {
    app {
      name
    }
    fullDescription
  }
`

interface Props {
  marketPlace: any
}

export default function MarketPlace({ marketPlace }: Props) {
  const data = useFragment(fragment, marketPlace)

  return (
    <li>
      <div>App name: {data.app?.name}</div>
      <div>Description: {data.fullDescription}</div>
    </li>
  )
}

useFragment의 첫 번째 argument는 선언한 graphql query를 할당한다. 두 번째 argument는 Store로부터 fragment data를 읽어오는 데 사용하는 object이며, fragment reference라고 부른다. 코드에서 직접 사용하는 일은 없고, 실제로 console.log를 이용해 확인해 보면 아래와 같은 data들이 object 내부에 존재한다.

fragment reference
{
  "id": "MDE4Ok1hcmtldHBsYWNlTGlzdGluZzk5MzY=",
  "__fragments": {
    "MarketPlace_marketPlace": {}
  },
  "__id": "MDE4Ok1hcmtldHBsYWNlTGlzdGluZzk5MzY=",
  "__fragmentOwner": {
    "identifier": "c4519feae0ebd876e9f2b4d2c51bf4b3{\"first\":20}",
    "node": {
      "fragment": {
        "argumentDefinitions": [
          {
            "defaultValue": null,
            "kind": "LocalArgument",
            "name": "first"
          }
        ],
        "kind": "Fragment",
        "metadata": null,
        "name": "pages_index_MarketplaceListings_Query",
        "selections": [
          {
            "alias": null,
            "args": [
              {
                "kind": "Variable",
                "name": "first",
                "variableName": "first"
              }
            ],
            "concreteType": "MarketplaceListingConnection",
            "kind": "LinkedField",
            "name": "marketplaceListings",
            "plural": false,
            "selections": [
              {
                "alias": null,
                "args": null,
                "concreteType": "MarketplaceListingEdge",
                "kind": "LinkedField",
                "name": "edges",
                "plural": true,
                "selections": [
                  {
                    "alias": null,
                    "args": null,
                    "concreteType": "MarketplaceListing",
                    "kind": "LinkedField",
                    "name": "node",
                    "plural": false,
                    "selections": [
                      {
                        "alias": null,
                        "args": null,
                        "kind": "ScalarField",
                        "name": "id",
                        "storageKey": null
                      },
                      {
                        "args": null,
                        "kind": "FragmentSpread",
                        "name": "MarketPlace_marketPlace"
                      }
                    ],
                    "storageKey": null
                  }
                ],
                "storageKey": null
              }
            ],
            "storageKey": null
          }
        ],
        "type": "Query",
        "abstractKey": null
      },
      "kind": "Request",
      "operation": {
        "argumentDefinitions": [
          {
            "defaultValue": null,
            "kind": "LocalArgument",
            "name": "first"
          }
        ],
        "kind": "Operation",
        "name": "pages_index_MarketplaceListings_Query",
        "selections": [
          {
            "alias": null,
            "args": [
              {
                "kind": "Variable",
                "name": "first",
                "variableName": "first"
              }
            ],
            "concreteType": "MarketplaceListingConnection",
            "kind": "LinkedField",
            "name": "marketplaceListings",
            "plural": false,
            "selections": [
              {
                "alias": null,
                "args": null,
                "concreteType": "MarketplaceListingEdge",
                "kind": "LinkedField",
                "name": "edges",
                "plural": true,
                "selections": [
                  {
                    "alias": null,
                    "args": null,
                    "concreteType": "MarketplaceListing",
                    "kind": "LinkedField",
                    "name": "node",
                    "plural": false,
                    "selections": [
                      {
                        "alias": null,
                        "args": null,
                        "kind": "ScalarField",
                        "name": "id",
                        "storageKey": null
                      },
                      {
                        "alias": null,
                        "args": null,
                        "concreteType": "App",
                        "kind": "LinkedField",
                        "name": "app",
                        "plural": false,
                        "selections": [
                          {
                            "alias": null,
                            "args": null,
                            "kind": "ScalarField",
                            "name": "name",
                            "storageKey": null
                          },
                          {
                            "alias": null,
                            "args": null,
                            "kind": "ScalarField",
                            "name": "id",
                            "storageKey": null
                          }
                        ],
                        "storageKey": null
                      },
                      {
                        "alias": null,
                        "args": null,
                        "kind": "ScalarField",
                        "name": "fullDescription",
                        "storageKey": null
                      }
                    ],
                    "storageKey": null
                  }
                ],
                "storageKey": null
              }
            ],
            "storageKey": null
          }
        ]
      },
      "params": {
        "cacheID": "c4519feae0ebd876e9f2b4d2c51bf4b3",
        "id": null,
        "metadata": {},
        "name": "pages_index_MarketplaceListings_Query",
        "operationKind": "query",
        "text": "query pages_index_MarketplaceListings_Query(\n  $first: Int\n) {\n  marketplaceListings(first: $first) {\n    edges {\n      node {\n        id\n        ...MarketPlace_marketPlace\n      }\n    }\n  }\n}\n\nfragment MarketPlace_marketPlace on MarketplaceListing {\n  app {\n    name\n    id\n  }\n  fullDescription\n}\n"
      },
      "hash": "90afb0994562d9e6a8aba6af1c023a5b"
    },
    "variables": {
      "first": 20
    }
  }
}

useFragment에 대한 설명은 문서 (opens new window)를 참고하면 된다.

<MarketPlace />를 전부 작성했다면, 다시 index.tsx로 돌아와서 <MarketPlace />에 선언된 fragment를 query에 spread 한다.

  const query = graphql`
    query pages_index_MarketplaceListings_Query($first: Int) {
      marketplaceListings(first: $first) {
        edges {
          node {
            id
-           app {
-             name
-           }
-           fullDescription
+           ...MarketPlace_marketPlace
          }
        }
      }
    }
  `

그리고 Relay compiler를 실행하여 런타임 아티팩트를 생성한다.

$ yarn relay

다시 <MarketPlace />로 돌아와서 생성된 아티팩트를 이용해 정확하게 type을 선언한다. Fragment referencetype을 선언 (opens new window)할 때는 <fragment_name>$key를 type import 하여 대입하면 useFragment의 return type이 알아서 잘 추론된다.

  // MarketPlace.tsx
  import { graphql, useFragment } from 'relay-hooks'

+ import type { MarketPlace_marketPlace$key } from '../__generated__/MarketPlace_marketPlace.graphql'

  const fragment = graphql`
    fragment MarketPlace_marketPlace on MarketplaceListing {
      app {
        name
      }
      fullDescription
    }
  `

  interface Props {
-   marketPlace: any
+   marketPlace: MarketPlace_marketPlace$key
  }

  export default function MarketPlace({ marketPlace }: Props) {
    const data = useFragment(fragment, marketPlace)

    return (
      <li>
        <div>App name: {data.app?.name}</div>
        <div>Description: {data.fullDescription}</div>
      </li>
    )
  }

다시 index.tsx로 돌아와서 <MarketPlace /> 부분을 아래와 같이 수정한다.

<ul>
  {data?.marketplaceListings.edges.map(({ node }) =>
    <MarketPlace
      key={node.id}
      marketPlace={node}
    />
  )}
</ul>

렌더링 된 결과를 보면 문제없이 잘 렌더링 되는 것을 확인 할 수 있다.

3. Data Masking 확인

이제 Data Masking을 확인해보면 된다. 먼저 실제 network request를 확인해보기 위해 getStaticProps를 모두 주석처리 한 다음 index.tsx 페이지에 다시 접속하거나 refresh를 한다. 개발자 도구의 network 탭에서 확인해보면 실제로 요청되는 GraphQL query는 다음과 같다.

{
  "query": "
    query pages_index_MarketplaceListings_Query($first: Int) {
      marketplaceListings(first: $first) {
        edges {
          node {
            id
            ...MarketPlace_marketPlace
          }
        }
      }
    }

    fragment MarketPlace_marketPlace on MarketplaceListing {
      app {
        name
        id
      }
      fullDescription
    }
  ",
  "variables": { "first": 20 }
}

위의 query에 대한 response는 다음과 같다.

{
  "data": {
    "marketplaceListings": {
      "edges": [
        {
          "node": {
            "id": "MDE4Ok1hcmtldHBsYWNlTGlzdGluZzEwMDA0",
            "app": {
              "name": "webext-bot",
              "id": "MDM6QXBwMTE3Mjg4"
            },
            "fullDescription": "webext-bot is an open-source Github Bot to report the web extension's size change and the web extension's version change on pull requests by adding checks as well as commenting on the pull request."
          }
        },
        ...
      ]
    }
  }
}

Request query와 response에 fullDescription이 있음이 확인되었다. 만약 전통적인 형태의 요청이라면 <MarketPlace />의 parent component인 index.tsx에서도 이 필드에 접근이 가능하겠지만 Relay의 Data Masking은 이를 허용하지 않는다. 코드 레벨은 물론 실제 response object에서조차 접근되지 않는다.

Data Masking
- Data Masking의 결과

index.tsx에서 확인해보면 query에 선언된 node.id는 문제없이 사용할 수 있다. 하지만 MarketPlace_marketPlace fragment에 선언된 node.fullDescription에 접근하려고 하면 type error가 발생하며, 강제로 any type으로 casting 하여 console.log를 이용해 field의 값을 출력하려고 해도 undefined가 출력된다.

만약 index.tsx에서도 fullDescription field에 접근하고 싶다면, query를 다음과 같이 수정하면 된다.

const query = graphql`
  query pages_index_MarketplaceListings_Query($first: Int) {
    marketplaceListings(first: $first) {
      edges {
        node {
          id
          fullDescription
          ...MarketPlace_marketPlace
        }
      }
    }
  }

4. Conclusion

useFragment를 이용하면 root component에서 실행되는 한 번의 data fetching으로 그 페이지에서 필요한 모든 data를 가져올 수 있으면서도, 동시에 각 component에 필요한 data field를 명시하여 접근을 제한할 수 있게 된다.

Relay compiler를 통해 data fetching에 대한 몇몇 실수를 막을 수 있는데, 이를테면 child component의 fragment를 spread 하는 것을 잊었다거나 필요한 data field를 명시하지 않은 경우가 있을 것 같다. 이런 경우 Relay compiler가 만들어주는 아티팩트가 사전에 경고를 통해 실수를 알려주며, 개발자들은 이 경고를 통해 의도하지 않은 버그들을 사전에 막을 수 있다.

Conformance

얼마 전 Conformance for Frameworks (opens new window)라는 글을 읽었다. Conformance란 개발자가 비즈니스 로직에 더 집중할 수 있도록 성능, 접근성, 보안 등의 세세한 부분은 여러 가지 툴(ESLint, TypeScript, webpack)이나 framework에 맡겨 높은 수준의 결과물을 일정하게 생산해내는 시스템이라고 한다.

개인적인 시선에서 Relay도 conformance를 위한 아주 훌륭한 선택이라고 생각한다. Relay compiler와 Data Masking이라는 강력한 조합이 data fetching과 관련한 개발자의 사소한 실수를 잘 막아주기 때문이다. 꼼꼼하지 못한 성격 탓이려나 전통적인 REST API를 사용할 때 흔히 저지르는 실수를, Relay를 이용해 사전에 방지하여 안심하고 data를 다룰 수 있을 거라고 생각한다.

다음 글에서는 Connection spec을 이용한 pagination에 대해 알아보고자 한다.


참고


© 2021, Built with Gatsby