DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for How to use GraphQL and React Query with GraphQL Code Generator (based on Next.Js)
soom
soom

Posted on

How to use GraphQL and React Query with GraphQL Code Generator (based on Next.Js)

Abstract

GraphQL. 기쑴의 REST API 호좜 방식을 λ„˜μ–΄ schemaλ₯Ό μ΄μš©ν•΄ 마치 DBλ₯Ό λ‹€λ£¨λŠ” sqlκ³Ό 같이 데이터 ν˜ΈμΆœμ„ λ‹€λ£°μˆ˜ μžˆλŠ” μƒˆλ‘œμš΄ κ°œλ….

GQL은 이미 개발자라면 μ΅μˆ™ν•œ μš©μ–΄κ°€ λ˜λ²„λ Έμ§€λ§Œ 아직도 ν˜„μž¬ μ§„ν–‰ν˜•μ΄λ©° 이번 ν¬μŠ€νŒ…μ—λŠ” Query μ†”λ£¨μ…˜μΈ React Query와 기쑴의 GQL의 pain point 쀑 ν•˜λ‚˜μΈ Typeκ³Ό Schema 관리, λΆˆν•„μš”ν•œ 반볡적인 μ½”λ“œ μž‘μ„±μ„ μžλ™μœΌλ‘œ ν•΄κ²°ν•΄μ£ΌλŠ” GraphQL Code Generatorλ₯Ό μ΄μš©ν•΄ GQL을 μ§κ΄€μ μœΌλ‘œ κ΄€λ¦¬ν•˜λŠ” 방법을 μ†Œκ°œν•˜κ³ μžν•œλ‹€.

GQL에 λŒ€ν•œ λ‚΄μš©μ€ ν•˜κΈ° μ°Έμ‘°
GQL μ΄λž€?: https://tech.kakao.com/2019/08/01/graphql-basic/


Getting Started

μ›ν•˜λŠ” ν”„λ‘œμ νŠΈ 폴더에 Next.Js TypeScript ν”„λ‘œμ νŠΈλ₯Ό 생성

Terminal
pnpm create next-app . --typescript  
Enter fullscreen mode Exit fullscreen mode

React Query둜 GQLλ₯Ό μ‚¬μš©ν•˜κ³ μž ν•„μš”ν•œ νŒ¨ν‚€μ§€λ₯Ό μ„€μΉ˜

Note

졜근 React QueryλŠ” νŒ¨ν‚€μ§€ λͺ…이 TanStack Query 큰 μΉ΄ν…Œκ³ λ¦¬λ‘œ λ¬Άμ˜€λŠ”λ° μΆ”ν›„ Sevelte Query λ“± λ‹€μ–‘ν•œ ν”Œλž«νΌμ„ 지원할 μ˜ˆμ •μ΄λΌ ν•œλ‹€.

Terminal
pnpm add -S @tanstack/react-query graphql graphql-request

pnpm add -D @tanstack/react-query-devtools
Enter fullscreen mode Exit fullscreen mode

ν™˜κ²½ λ³€μˆ˜λ„ μ‚¬μš©ν•  μ˜ˆμ •μ΄κΈ°μ— dotenv νŒ¨ν‚€μ§€λ„ μ„€μΉ˜

Terminal
pnpm add -S dotenv
Enter fullscreen mode Exit fullscreen mode

.env.local 을 μƒμ„±ν•œλ’€ API URL 을 등둝

GraphQL API μ£Όμ†ŒλŠ” Fake GraphQL을 μ œκ³΅ν•˜λŠ” GraphQLZeroλ₯Ό μ΄μš©ν•˜μ˜€λ‹€.

GraphQLZero Link: https://graphqlzero.almansi.me/

.env.local
NEXT_PUBLIC_GRAPHQL_URL=https://graphqlzero.almansi.me/api
Enter fullscreen mode Exit fullscreen mode

env ν•­λͺ©μ΄ Type Error 에 μž‘νžˆμ§€ μ•Šλ„λ‘ next-constants.d.ts νŒŒμΌμ„ μƒμ„±ν•˜κ³  default type으둜 λ³€μˆ˜ μ„ μ–Έ

next-constants.d.ts
declare namespace NodeJS {
    export interface ProcessEnv {
        NEXT_PUBLIC_GRAPHQL_URL: string;
    }
}
Enter fullscreen mode Exit fullscreen mode

비ꡐλ₯Ό μœ„ν•œ 기쑴의 react query + gql 방식을 κ΅¬ν˜„

react query 섀정을 μœ„ν•΄ _app.tsx 을 λ‹€μŒκ³Ό 같이 μˆ˜μ •

Note

  • SSR이기에 SEO와 UXλ₯Ό μ΅œμ ν™” ν•˜κΈ°μœ„ν•΄ Hydration Stateλ₯Ό μ„€μ •
  • Hydration κ°œλ…μ€ ν•˜κΈ° 링크 μ°Έμ‘°
  • pageProps.dehydratedState λŠ” Serverside Props μ—μ„œ μ΄μš©ν•  μ˜ˆμ •
  • refetchλ₯Ό μ΅œμ†Œν™”ν•΄μ„œ UX μ΅œμ ν™”
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            refetchOnMount: false,
            refetchOnWindowFocus: false,
            refetchOnReconnect: false,
        },
    },
});
Enter fullscreen mode Exit fullscreen mode
pages/_app.tsx
import '../styles/globals.css';

import type { AppProps } from 'next/app';

import { Hydrate, QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

export const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            refetchOnMount: false,
            refetchOnWindowFocus: false,
            refetchOnReconnect: false,
        },
    },
});

function MyApp({ Component, pageProps }: AppProps) {
    return (
        <QueryClientProvider client={queryClient}>
            <Hydrate state={pageProps.dehydratedState}>
                <Component {...pageProps} />;
                <ReactQueryDevtools initialIsOpen />
            </Hydrate>
        </QueryClientProvider>
    );
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

GraphQlZero의 album Resolver Queryλ₯Ό react query둜 μš”μ²­

legacy.tsx νŒŒμΌμ„ μƒμ„±ν•˜κ³  μ‹€μ œλ‘œ 데이터가 λ“€μ–΄μ˜€λŠ” 것을 ν™•μΈν•œλ‹€.

Note

  • type μ΄λ‚˜ schemaλŠ” GraphQlZero의 Docsλ₯Ό μ°Έκ³ ν•΄μ„œ μ›ν•˜λŠ” λ°μ΄ν„°λ§Œ μž„μ˜λ‘œ μž‘μ„±
interface AlbumQuery {
    album: {
        id: string;
        title: string;
        user: {
            id: string;
            name: string;
            username: string;
            email: string;
            company: {
                name: string;
                bs: string;
            };
        };
        photos: {
            data: {
                id: string;
                title: string;
                url: string;
            };
        };
    };
}

const albumQueryDocument = gql`
    query album($id: ID!) {
        album(id: $id) {
            id
            title
            user {
                id
                name
                username
                email
                company {
                    name
                    bs
                }
            }
            photos {
                data {
                    id
                    title
                    url
                }
            }
        }
    }
`;
Enter fullscreen mode Exit fullscreen mode
  • getStaticPropsλ₯Ό μ΄μš©ν•΄ Serverμ—μ„œ 미리 μΊμ‹œλœ dehydratedStateλ₯Ό λ‚΄λ €μ€€λ‹€.
export const getStaticProps = async () => {
    await queryClient.prefetchQuery(['album'], useAlbumFetcher);

    return {
        props: {
            dehydratedState: dehydrate(queryClient),
        },
    };
};
Enter fullscreen mode Exit fullscreen mode
pages/legacy.tsx
import type { NextPage } from 'next';

import { useQuery, dehydrate } from '@tanstack/react-query';
import { request, gql } from 'graphql-request';
import { queryClient } from './_app';

interface AlbumQuery {
    album: {
        id: string;
        title: string;
        user: {
            id: string;
            name: string;
            username: string;
            email: string;
            company: {
                name: string;
                bs: string;
            };
        };
        photos: {
            data: {
                id: string;
                title: string;
                url: string;
            };
        };
    };
}

const albumQueryDocument = gql`
    query album($id: ID!) {
        album(id: $id) {
            id
            title
            user {
                id
                name
                username
                email
                company {
                    name
                    bs
                }
            }
            photos {
                data {
                    id
                    title
                    url
                }
            }
        }
    }
`;

const useAlbumFetcher = async () =>
    await request<AlbumQuery, { id: string }>(
        process.env.NEXT_PUBLIC_GRAPHQL_URL,
        albumQueryDocument,
        {
            id: '2',
        }
    );

export const getStaticProps = async () => {
    await queryClient.prefetchQuery(['album'], useAlbumFetcher);

    return {
        props: {
            dehydratedState: dehydrate(queryClient),
        },
    };
};

const Legacy: NextPage = () => {
    const { data } = useQuery<AlbumQuery>(['album'], useAlbumFetcher);
    const { album } = data!;

    return (
        <>
            <header style={{ textAlign: 'center' }}>
                <h1>Hello GraphQL + React Query !</h1>
            </header>
            <hr />
            <main>
                <p style={{ textAlign: 'center', color: 'grey' }}>{JSON.stringify(album)}</p>
            </main>
        </>
    );
};

export default Legacy;
Enter fullscreen mode Exit fullscreen mode
preview

Image description


μ—¬κΈ°κΉŒμ§€κ°€ 기쑴의 gql + react query 방식을 톡해 데이터λ₯Ό λ°›μ•„μ˜€λŠ” 과정이닀.

ν•˜μ§€λ§Œ 이런 λ°©μ‹μ—λŠ” Pain Pointκ°€ μ‘΄μž¬ν•œλ‹€.

  • schema 에 λŒ€μ‘ν•˜λŠ” Type을 직접 μž‘μ„±
  • schema 변경이 μžˆλ‹€λ©΄ Type μ—­μ‹œ Docs λ₯Ό ν™•μΈν•œ ν›„ 직접 λ³€κ²½ ν•„μš”
  • μš”μ²­ν• λ•Œλ§ˆλ‹€ 반볡적인 μ½”λ“œ 반볡 μž‘μ„±

이런 Pain Pointλ₯Ό μžλ™μœΌλ‘œ ν•΄κ²°ν•΄μ£ΌλŠ” 것이 GraphQL Code Generator λ‹€.

GQL Code Generator κ΄€λ ¨ νŒ¨ν‚€μ§€λ₯Ό μ„€μΉ˜

Terminal
# code generator core νŒ¨ν‚€μ§€
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations

# code generator react query κ΄€λ ¨ νŒ¨ν‚€μ§€
pnpm add -D @graphql-codegen/typescript-react-query @graphql-codegen/typescript-graphql-request

# code generator yaml loader νŒ¨ν‚€μ§€
pnpm add -D yaml-loader
Enter fullscreen mode Exit fullscreen mode

codegen.yml νŒŒμΌμ„ μƒμ„±ν•œ λ’€ μ„€μ •κ°’ μž…λ ₯

Note

  • schema λŠ” graphql 폴더 μ•ˆμ— [filename].graphql둜 κ΄€λ¦¬ν•˜κΈ°λ‘œ ν•œλ‹€. (tsλ‚˜ λ‹€λ₯Έ ν™•μž₯μžλ„ κ°€λŠ₯)
  • documentsμ—λŠ” gql schema 파일 ν˜•μ‹κ³Ό μœ„μΉ˜λ₯Ό μ„€μ •ν•΄μ€€λ‹€.
documents: 'graphql/**/!(*.generated).{graphql,ts}'
Enter fullscreen mode Exit fullscreen mode
  • schema λŠ” GQL URL μœ„μΉ˜. μ—¬κΈ°μ„œλŠ” ν™˜κ²½ λ³€μˆ˜λ‘œ 관리 ν•˜κΈ°λ•Œλ¬Έμ— λ‹€μŒκ³Ό 같이 μž‘μ„±
schema: ${NEXT_PUBLIC_GRAPHQL_URL}
Enter fullscreen mode Exit fullscreen mode
  • μ€‘μš” μ˜΅μ…˜λ“€μ€ λ‹€μŒκ³Ό κ°™λ‹€. λ‚˜λ¨Έμ§€ λ‚΄μš©λ“€μ€ ν•˜κΈ° λ§ν¬μ—μ„œ 확인: https://www.graphql-code-generator.com/plugins/typescript/typescript-react-query
    • exposeFetcher: GetStaticProps, GetServerSideProps 에 prefetch둜 μ‚¬μš©ν•  query fetcher ν•¨μˆ˜λ₯Ό export
    • exposeQueryKey: react quey의 query key 도 export
    • fetcher : fetcher둜 μ‚¬μš©ν•  λͺ¨λ“ˆ. μ—¬κΈ°μ„œλŠ” 기쑴에 μ‚¬μš©ν–ˆλ˜ graphql-request μ‚¬μš©ν•œλ‹€.
codegen.yml
documents: 'graphql/**/!(*.generated).{graphql,ts}'
schema: ${NEXT_PUBLIC_GRAPHQL_URL}
require:
  - ts-node/register
generates:
  graphql/generated.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-query
    config:
      interfacePrefix: 'I'
      typesPrefix: 'I'
      skipTypename: true
      declarationKind: 'interface'
      noNamespaces: true
      pureMagicComment: true
      exposeQueryKeys: true
      exposeFetcher: true
      withHooks: true
      fetcher: graphql-request
Enter fullscreen mode Exit fullscreen mode

pakage.json 에 graphql-codegen scriptμΆ”κ°€

package.json
{
    ...
    "scripts": {
        ...
        "generate:gql": "graphql-codegen --require dotenv/config --config codegen.yml dotenv_config_path=.env.local"
    },
    ...
}

Enter fullscreen mode Exit fullscreen mode

graphql 폴더λ₯Ό μƒμ„±ν•œλ’€ μš”μ²­ν•  schema νŒŒμΌμ„ μž‘μ„±
μ—¬κΈ°μ„œλŠ” album κ΄€λ ¨ schemaλ₯Ό album.graphql에 μž‘μ„±

graphql/album.graphql
query album($id: ID!) {
    album(id: $id) {
        id
        title
        user {
            id
            name
            username
            email
            company {
                name
                bs
            }
        }
        photos {
            data {
                id
                title
                url
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

μ—¬κΈ°κΉŒμ§€ μ§„ν–‰ν–ˆλ‹€λ©΄ λͺ¨λ“  μ€€λΉ„λ₯Ό μ™„λ£Œν•œ μƒνƒœ
ν˜„μž¬ 파일 κ΅¬μ‘°λŠ” λ‹€μŒκ³Ό κ°™λ‹€.

structure
.
β”œβ”€β”€ graphql/
β”‚   └── album.graphql
β”œβ”€β”€ pages/
β”‚   β”œβ”€β”€ _app.tsx
β”‚   β”œβ”€β”€ index.tsx
β”‚   └── legacy.tsx
β”œβ”€β”€ ...  
β”œβ”€β”€ codegen.yml
β”œβ”€β”€ next-constants.d.ts
β”œβ”€β”€ package.json
└── ...
Enter fullscreen mode Exit fullscreen mode

이제 GraphQL Generatorλ₯Ό μ‚¬μš©ν•΄ Type, Method λ₯Ό μžλ™ 생성이 κ°€λŠ₯

scriptλ₯Ό μ‹€ν–‰ν•˜λ©΄ graphql 폴더 μ•ˆμ— generated.tsκ°€ 생성

이 νŒŒμΌμ•ˆμ—λŠ” schema에 λŒ€μ‘ν•˜λŠ” Query Function, Type 듀이 μžλ™μœΌλ‘œ μƒμ„±λ˜μ–΄ μžˆλŠ” 것을 확인할 수 μžˆλ‹€. (파일 λ‚΄μš©μ€ μƒλž΅)

Terminal
pnpm generate:gql

βœ” Parse Configuration
βœ” Generate outputs
Enter fullscreen mode Exit fullscreen mode

μžλ™μƒμ„±λœ Query Method와 Type을 톡해 보닀 μ‰½κ²Œ album의 데이터듀을 ν˜ΈμΆœν•΄λ³΄μž

pagesν΄λ”μ•ˆμ— new.tsx νŒŒμΌμ„ λ‹€μŒκ³Ό 같이 μž‘μ„±

legacy.tsx와 μ •ν™•νžˆ λ™μΌν•œ κΈ°λŠ₯을 ν•˜λŠ” νŽ˜μ΄μ§€μ΄λ‹€.

Note

  • useQuery κ΄€λ ¨ μ½”λ“œκ°€ μžλ™ μƒμ„±λœ useAlbumQuery ν•œμ€„λ‘œ λŒ€μ²΄
const { data } = useAlbumQuery(gqlClient, { id: '3' });
Enter fullscreen mode Exit fullscreen mode
  • prefetchQuery μ—μ„œ key, fetcher μ½”λ“œλ₯Ό 직접 μž‘μ„±ν•  ν•„μš”μ—†μ΄ useAlbumQuery.getKey(), useAlbumQuery.fetcher() 둜 λŒ€μ²΄
await queryClient.prefetchQuery(
        useAlbumQuery.getKey({ id: '3' }),
        useAlbumQuery.fetcher(gqlClient, { id: '3' })
    );

Enter fullscreen mode Exit fullscreen mode
  • Type은 μžλ™ μ„ μ–Έλ˜μ–΄ μ—°κ²°λ˜μ–΄μžˆκΈ° λ•Œλ¬Έμ— λ”°λ‘œ μž‘μ„± ν•„μš” λΆˆκ°€
  • 이후 server spec λ³€κ²½μœΌλ‘œ μΈν•œ schema λ³€κ²½μ‹œ code generate λͺ…λ Ή ν•œμ€„λ‘œ 반볡적인 type 맀칭, μž¬μž‘μ„± 과정을 μƒλž΅ν•  수 μžˆλ‹€.
pages/new.tsx
import type { NextPage } from 'next';

import { dehydrate } from '@tanstack/react-query';
import { GraphQLClient } from 'graphql-request';
import { useAlbumQuery } from '../graphql/generated';
import { queryClient } from './_app';

const gqlClient = new GraphQLClient(process.env.NEXT_PUBLIC_GRAPHQL_URL);

export const getStaticProps = async () => {
    await queryClient.prefetchQuery(
        useAlbumQuery.getKey({ id: '2' }),
        useAlbumQuery.fetcher(gqlClient, { id: '2' })
    );

    return {
        props: {
            dehydratedState: dehydrate(queryClient),
        },
    };
};

const New: NextPage = () => {
    const { data } = useAlbumQuery(gqlClient, { id: '2' });

    const { album } = data!;

    return (
        <>
            <header style={{ textAlign: 'center' }}>
                <h1>Hello GraphQL + React Query !</h1>
            </header>
            <hr />
            <main>
                <p style={{ textAlign: 'center', color: 'grey' }}>{JSON.stringify(album)}</p>
            </main>
        </>
    );
};

export default New;
Enter fullscreen mode Exit fullscreen mode
preview

Image description


βž• Stackblitz Sample


Conclusion

λ³Έ ν¬μŠ€νŒ…μ—μ„œλŠ” GraphQL Code Generator λ₯Ό 톡해 Server Spec λ³€κ²½ν• λ•Œλ§ˆλ‹€ schema λ³€κ²½λΏλ§Œ μ•„λ‹ˆλΌ type κΉŒμ§€ μž¬μž‘μ„±μ„ ν•΄μ•Όν•˜λŠ” GQL의 Pain Point λ₯Ό ν•΄κ²°ν•˜λŠ” 방법을 μ†Œκ°œν•˜μ˜€λ‹€. μΆ”κ°€μ μœΌλ‘œ SSR을 톡해 데이터 κ΄€λ ¨ν•˜μ—¬ hydrationν•˜λŠ” technique도 같이 μ†Œκ°œν•˜μ˜€λ‹€.

ν˜„μž¬κΉŒμ§€λ„ μ£Όλ₯˜λŠ” REST API 이닀. ν•˜μ§€λ§Œ 큰 λ³€ν™”κ°€ μ—†λŠ” REST API와 달리 GQLμ—μ„œλŠ” μ—¬λŸ¬κ°€μ§€ κΈ°λŠ₯이 κΎΈμ€€νžˆ μ†Œκ°œλ˜κ³  λ°œμ „ν•˜κ³  μžˆλ‹€. 특히 Backend 와 Frontend μ‚¬μ΄μ˜ Communication Gap 을 μ€„μ—¬μ£ΌλŠ” λ°©ν–₯으둜 GQL은 κΎΈμ€€νžˆ λ°œμ „ν•˜κ³  있으며 μ΄λŠ” μ‹€λ¬΄μ˜ 인적 λΉ„μš©κ³Όλ„ μ§μ ‘μ μœΌλ‘œ μ—°κ²°λ˜λŠ” λ°©ν–₯이닀.

μ΄λŠ” 개발자라면 GQL에 κ΄€ν•΄ μ•žμœΌλ‘œλ„ κΎΈμ€€νžˆ 관심을 κ°€μ§ˆλ§Œν•œ μΆ©λΆ„ν•œ μ΄μœ κ°€ 될것이닀.

GQL Code Generator 에 λŒ€ν•΄ μžμ„Έν•œ λ‚΄μš© ν•˜κΈ° λ§ν¬μ—μ„œ 확인할 수 μžˆλ‹€.
Link: https://www.graphql-code-generator.com/

Top comments (0)

🌚 Life is too short to browse without dark mode