우여곡절 블로그 개발 3

2022.11.13

graphql logo

이번엔 graphql 이해하기

이전부터 graphql 이라는 개념을 들어만 봤지 실제로 써본적은 없었다.
하지만 평소 restAPI로 프로젝트를 진행하다 underFetchingoverFetching 문제를 여러번 겪으며 graphql을 도입하면 어떨까 라는 생각을 자주 했었고 이번 블로그 제작에 연습삼아 적용해 보았고 그 후기를 써본다.


UnderFetching 과 OverFetching


rest와 graphql을 비교할때 항상 나오는 문제다.
근데 나는 실제로 이 두가지 문제를 프론트엔드 작업중 자주 겪어봤던 상황이었다.

  • UnderFetching 필요한 정보를 한번에 가져오지 못하는 경우다.
    예를들어 한 페이지에서 로그인한 사용자의 정보와 포스트 목록을 보여줘야한다.
    이때 포스트 목록과 사용자 정보가 필요해서 api 요청이 필요한데 각각 /posts/user 엔드포인트로 따로 요청을 하여 받아오고
    프론트페이지를 렌더링하게 된다. 페이지에 필요한 정보가 많을수록 요청하는 엔드포인트도 많아지고 코드를짜는것도 여러모로 귀찮아진다.
  • OverFetching 필요없는 정보까지 받아오게 되는 경우다.
    예를들어 헤더 컴포넌트에 현재 로그인한 사용자의 이름과 등급을 표시해야하는데 유저의 정보를 보내주는 /user 엔드포인트는
    이메일부터 전화번호,가입일 등등 사용자의 모든 정보를 제공하여 필요없는 정보까지 받게된다.

위의 두 문제가 동시에 발생하기도 했다.
내가 작업하던 프론트엔드는 레이아웃 Header 컴포넌트에선 로그인한 사용자의 이름과 등급을 보여줘야하고
페이지내용은 프로젝트 목록을 보여줘야했다. Header 컴포넌트에선 /user api를 요청하면서 OverFetching이 발생했고
프로젝트 목록을 위해 /projects api를 요청하는것 자체가 해당 페이지에서 UnderFetching 문제가 발생하는거였다.


그래서 graphql은 이 문제를 어떻게 해결하는데?


graphql 구조


내가 이해한 graphql의 동작방식이 옳다면 간단히 비유해서 restAPI는 자판기에 비유할수있고 graphql은 카운터 직원에 비유할수있다.
햄버거를 사고 싶은데 햄버거 자판기(restAPI) 를 통해 산다면 내가 여러개의 햄버거를 사고싶다면 일일이 버튼을 눌러서 주문을 해야한다.
또, 나는 햄버거의 패티만 필요하지만 자판기에서는 반드시 하나의 완성된 햄버거만 내보내고 있다.
그에 비해 카운터의 직원(graphql) 이 있다면 내가 원하는 주문을 한번에 할수있다.
원하는 햄버거 여러개를 주문하든, 햄버거에 패티는 빼고 피클로 채워달라는등 모든 주문을 한번에 정의해서 넘겨주면 graphql은 그에 맞는 결과를 넘겨준다.


Apollo-server로 graphql 백엔드 구현하기


graphql을 시작하기 위해 검색을 하던중 Apollo 라는 패키지를 발견하였다.
graphql 서버와 클라이언트쪽 구현에 큰 노력이 필요하지 않고 각종 편리한 기능들이 많아보였으며 무엇보다 사용자가 많고 문서도 많아서 선택하게 되었다.


먼저 백엔드에 graphql 서버를 실행시켜보자. 아예 백엔드 서비스와는 독립적인 graphql 서버도 있지만 간단하게 express 나 koa 같은 프레임워크에 미들웨어로 적용하는 방법도 있다. 아래의 패키지를 사용하여 graphql 서버를 실행시켜보자.


typeDefs 와 resolver


graphql은 모든 데이터 구조를 스키마로 정의한다. sql의 테이블 스키마와 똑같은 개념으로 아래와같은 형태로 작성한다.

type post{
    title:String!
    body:String!
}

스키마를 정의했으면 이 스키마를 처리해주는 resolver 함수가 필요하다.
resolver란 스키마 형식대로 데이터를 반환하는 함수이다 예를들면 위의 post 스키마의 resolver는 title 과 body 필드를 가진 객체를 반환하는 함수여야한다.


const postResolver=async (parent,args,context,info)=>{   
    //이 resolver 내부에서 실제 데이터를 가져오는 일을한다
    //apollo server에서는 dataSource 라는 클래스를 사용하여 데이터를 관리하는걸 권장하지만
    //바로 db 와 연결하거나 다른 api로 부터 데이터를 가져오는것도 얼마든지 가능하다
    const post=await postDB.find()
    return {
        title:post.title,
        body:post.body
    }
}

resolver에 전달된 파라미터들은 이 문서를 참고해보자. 사실 argscontext말고 나머지는 거의 쓸 일이 없을껀데 간략하게 args와 context만 설명하겠다. (사실 링크에 전부 나와있는내용임)


args쿼리의 파라미터로 전달된 값들이다. query{ user(id: "4") } 쿼리를 보냈다면 args는 {id:"4"} 객체를 받게된다
context모든 resolver 간에 공유되는 객체이다. 사용자 인증이나 db연결 정보같은 resolver에서 필요한 정보를 담는데 유용하게 쓰일수있다.

//args와 context 사용 예제
const postResolver=async (_,args,context)=>{    //args 또는 구조분해로 {postID}  같은 형태로 써도 된다.
    //context 안에 저장된 인증정보를 확인한다.
    if(!context.auth){
        throw new Error('권한 없음')
    }
    const post=await context.postDB.find(args.postID)    //마찬가지로 context안에 있는 db 커넥션을 사용한다.
    return{
        title:post.title,
        body:post.body
    }
}

apollo server 실행

위에서 작성한 typeDefs 와 resolver로 apollo server를 실행시켜보자 아래의 예제는 apollo-server-koa 패키지를 사용한 예제다.

const {ApolloServer,gql}=require('apollo-server-koa')
const postDB = require('./db/postDB.js')
const commentDB = require('./db/commnetDB.js')
const app = reqire('./app.js')

const typeDefs=gql`
    type Post{
        body:String!
        title:String!
    }
    
    type Comment{
        writer:String!
        body:String!
    }
`

const resolvers={
    Query:{        //query 라는 객체안에 스카마:함수 형태로 작성해야한다.
        Post:async ()=>{ //이때 key 값은 스키마와 서로 매칭이 되어야한다.
            ...
            return {
                body:...,
                title:...
            }
        },
        Comment:async ()=>{
            ...
            return{
                writer:...
                body:...
            }
        }
    }
}

const initGraphqlServer=async ()=>{
    const server=new ApolloServer({
        typeDefs,
        resolvers,
        context:({ctx})=>{        //모든 resolver에서 공유되는 context를 여기서 정의한다.
            /*
                파라미터로 받은 `ctx`는 koa의 그 `ctx` 객체가 맞다.
                apollo-server-express를 써본적은 없지만 express 처럼 {req,res}를 받는 예제를 본적은 있음
            */
            return{
                auth : ... //토큰같은 사용자 인증정보를 저장한다.
                postDB: postDB    //db 연결 정보도 context에 저장해두면 resolver에서 바로 꺼내서 쓸수있다.
                commentDB: commnetDB
            } 
        }
    })
    await server.start()
    //여기서 app은 express나 koa의 `app` 객체이다.
    server.applyMiddleware(app)
}    

initGraphqlServer()

Apollo-client 로 프론트엔드 구현하기

이번엔 apollo-client 패키지로 프론트엔드에서 graphql을 사용해보자.
캐싱,상태 관리등 여러모로 편한기능이 많아서 사용하는걸 추천한다.
apollo client 인스턴스를 생성하기 위해 ApolloClient.js 파일을 만들고 아래와 같이 작성한다.

import {ApolloClient,InMemoryCache} from '@apollo/client'

export default new ApolloClient({
    uri:`graphql 서버 주소`,     //뒤에 /graphql은 자동으로 붙여준다.
    cache:new InMemoryCache()
})

그리고 react 나 nextjs 의 app.js 파일로가서 ApolloProvider 컴포넌트로 감싸준다.


//nextjs의 경우
import { ApolloProvider } from '@apollo/client'
import Client from '../src/graphql/ApolloClient'    

function MyApp({ Component, pageProps }) {
  return (
    <ApolloProvider client={Client}>
      <Component {...pageProps} />
    </ApolloProvider>)
}

이렇게 설정하면 이제 리액트 내부 어디서든 graphql 쿼리를 실행하거나 이미 받아온 데이터의 캐시에 접근할수있다.
그럼 이제 실제 쿼리를 날려보자. 쿼리를 요청하는 방법은 두가지가 있는데 경우에 따라 다르게 쓸수있다.(사실 정확히 모름)

useQuery로 요청하기


import { useQuery,gql } from "@apollo/client"

const GET_DATA=gql`
    query GetSomeData{
        pizza{
            pineapple,
            banana
        }
    }
`

const SomeComponent=()=>{
    const {loading,data}=useQuery(GET_DATA)
    
    if(loading){
        return(
            <div>
             loading..
            </div>
        )
    }
    
    return(
        <div>
            {data} here
        </div>
}

useQuery를 사용하면 이렇게 비동기 적으로 쿼리 요청을 할수있다. 만약 다른 컴포넌트에서 똑같은 쿼리를 요청하더라도 InMemoryCache를 설정하였기 때문에 캐싱된 데이터를 보여줘서 로딩도 엄청 빠르다.
캐시가 어떻게 동작하는지는 나중에 알아보자 왜냐면 나도 이거때문에 문제가 생겼으니까
궁금하다면 공식문서를 읽어봐도 좋다.

Client 인스턴스로 요청하기


아까전에 ApolloClient 로 생성했던 인스턴스 자체를 사용하여 쿼리를 요청하는 방법이다.
client.query() 메소드로 직접 요청을 날릴수도있다. useQuery리액트 hook 이라는걸 제외하면 솔직히 뭐가 다른지는 모르겠다.
아무리 찾아봐도 차이점을 설명하는 답은 없어서 일단 넘어가고 어쨌든 이걸 직접 사용하는 이유는 리액트 hook을 사용할수 없을경우 대신 사용한다.
실제로 이 블로그도 next로 작성되었고 next의 서버 환경에서는(정확히는 getStaticProps 안에서) 리액트 훅을 사용할수 없어서 client.query() 를 사용하였다.

tui editor doesn't support description!

next에 apollo를 적용하기 위해 참고했던 어느 블로그의 포스트에도 이런 내용을 언급하고 있다.
(이유가 hook을 next에서 못써서인건 내가 직접 알아냈지만...)
해당 포스트는 여기를 참고 해보자.

import Client from '../src/graphql/ApolloClient' 

const QUERY=gql`
    ...
`

export async function getStaticProps(){
    const {data}=Client.query({
        query:QUERY
      })
      
    return {
        props:{data}
    }
}

사용 후기와 아직 해결하지 못한 문제

사실 이번에 graphql을 써본건 단순 한번쯤은 써보고 싶어서 시작한 맛보기 수준이었다.
그래도 rest를 사용했을때 보다는 확실히 번거러움줄어들고 뭔가 깔끔하다는 느낌이 있었다.
물론 graphql이 rest를 완전히 대체할수 없기도 하고 무조건 하나만 쓰라는 법도 없으니 적당히 섞어서 쓰는것도 나쁘지 않을듯 하다. 그리고 graphql을 쓰면서 궁금던 의문점들과 겪었던 오류가 있는데 몇가지 적어본다.

graphql은 데이터를 어떻게 관리 하나?


사실 아직도 정확한 답을 모르고있는상황이다.
graphql은 말 그대로 클라이언트와 서버 중간사이에서 DB나 아니면 다른 API를 통해 데이터를 받아오고 가공하여 클라이언트로 전달하는 역할을한다.
여기서 내가 궁금했던점은 위에서 graphql이 햄버거를 서빙해주기 위해선 결국은 db같은 곳에서 햄버거의 모든 정보를 가져와야한다는것이다.
그 말은 사용자가 햄버거 패티만 주문을 했다면 결국 주방(db 또는 백엔드 서버)에서 카운터(graphql)까지는 무조건 완성된 햄버거가 전달되지만 카운터에서는 나머지를 전부 버리고 패티만 클라이언트로 전달해주는것인가 라는 의문이 들었다.
뭐 사실 크게 문제될건 없어보이긴 하지만 어짜피 성능을 위해서 서버단에서도 캐시를 설정하는 방법이있으니

apollo client 의 캐싱 문제


post 와 post 목록 페이지에 수정사항을 적용하기 위해 ISR을 적용하고 테스트를 할때 내용이 수정되어도 반영이 되지 않던 문제가 있었다.

tui editor doesn't support description! 분명 build 결과에도 ISR이 적용되었다고 나와있었지만 포스트를 수정하고 새로고침 하여도 업데이트가 되지 않았다.
이유를 찾아보니 클라이언트에 적용한 InMemoryCache 클래스가 문제였던것이었다.
실제 nextjs는 매번 페이지를 갱신했지만 apollo client에서 캐싱된 데이터를 보냈기때문에 내용 업데이트가 안됬던 거였다.
똑같은 문제를 스택오버플로우한 블로그 포스트에서 찾을수 있었다.
해당 포스트의 내용대로 따라해서 임시로 문제가 해결되긴했지만 정확히 뭐때문에 이런 문제가 발생하는지는 아직모르겠다.
나중에 캐싱에 관한 자료를 찾아보고 해결해야할 문제다.

apollo server 의 DataSource


데이터소스 란 db나 restAPI등 아폴로 서버에서 데이터를 가져올때 사용할수 있는 클래스이다.
필수는 아니지만 아폴로 문서에서는 사용하는걸 권장하고 있다. 아직 사용해본적은 없지만 중복된 요청을 처리하거나 데이터 캐싱등 대규모 웹사이트를 구축한다면 성능을 위해서 도입을 해야할것이다. 이것도 나중에 배워야할 문제중 하나다.

Do you want something exciting?

© 2022. YSH All rights reserved.