GraphQL) Relay는 어떤 문제를 해결해줄까?

2ast·2023년 8월 12일
2

Relay란

RelayMeta(facebook)에서 개발한 React용 GraphQL 데이터 관리 라이브러리이다. meta에서 개발한 만큼 React와 궁합이 좋고, 개발자 친화적이다. 러닝커브는 다소 높지만 그만큼 높은 사용성을 제공한다고 알려져있다.

이 글에서는 Relay의 세부적인 환경설정이나 사용법에 집중하기 보다는, 기존 client side data fetch에 어떤 문제가 있었는지, 그리고 Relay는 그 문제를 어떻게 해결했는지를 중점으로 알아보겠다.

Data Fetching Issue

react에서 relay를 사용하지 않고 데이터를 요청하는 과정을 생각해보자. 일반적으로 useEffect를 이용해 data를 state에 할당하는 방식으로 렌더링을 진행할 것이다.

const App = () =>{
	const [data,setData] = useState()

	useEffect(()=>{
		fetchData().then(response=>setData(response))
	})

	if(!data){
		return <Loading/>
	}

  return <Page data={data}/>

}

얼핏 자연스러워 보이는 이 코드에는 크게 두 가지 문제점이 있다.

Life Cycle

우리가 data 요청에 사용하는 useEffect는 functional component에서 react의 life cycle에 개입하기 위해 사용된다. 그리고, data fetch가 일어나는 시점은 life cycle의 componentDidMount에 해당한다. 아래 그림에서 볼 수 있듯이 componentDidMount는 모든 렌더링이 끝난 뒤 가장 마지막에 발생하는 이벤트임을 알 수 있다. 이처럼 data fetch의 시점이 상대적으로 뒤로 밀리기 때문에 그만큼 화면 렌더링에서 손해를 본다.

Waterfall

사실 이 방식의 가장 큰 문제는 바로 waterfall 이슈다. 위 코드를 보면 response가 오기 전까지 data state는 undefined값을 가지고 있고, 그동안 예외 페이지로써 Loading component가 보여지고 있음을 볼 수 있다. 그런데 만약 App이 렌더링하고 있는 Page 컴포넌트 안쪽에서 또다른 api call이 이루어지고 있다면 어떻게 될까?

const Page = (props) =>{
	const [pageData,setPageData] = useState()

	useEffect(()=>{
		fetchPageData().then(response=>setData(response))
	})

	if(!pageData){
		return <Loading/>
	}

  return <div>
		<Content pageData={pageData}/>
		<OtherComponent data={props.data}/>
	</div>

}

const App = () =>{
	const [data,setData] = useState()

	useEffect(()=>{
		fetchData().then(response=>setData(response))
	})

	if(!data){
		return <Loading/>
	}

  return <Page data={data}/>

}

App의 dataFetch가 끝나기 전에는 Page가 렌더링되지 못하고, 따라서 fetchPageData도 실행되지 못한다. 그리고 App의 data가 할당된 뒤에야 Page가 렌더링되고 그 이후 componentDidMount phase에서 fetchPageData가 실행될 수 있게된다.

지금은 depth가 1인 케이스지만, 복잡한 프로덕션 코드에서 이런 케이스가 여러번에 걸쳐 발생한다면 어떻게 될까? 앞선 요청의 response가 끝난 뒤에 다음 data를 요청하는 과정이 직렬로 동작하면서 화면이 한번에 렌더링되는 것이 아니라 순차적으로 렌더링될 것이다. 이런 현상을 마치 폭포수처럼 순차적으로 연쇄발생한다고 하여 waterfall이라고 부른다. waterfall은 안좋은 사용자 경험을 제공하기 때문에 클라이언트 개발자가 반드시 방지해야하는 현상이라고 할 수 있다.

Race Condition

일반적인 케이스는 아니지만 이렇게 단순한 data fetch 프로세스는 흔히 경쟁상태라고 부르는 이슈를 유발할 수 있다.

const Page = (props) =>{
	const [data,setData] = useState('')
	
	const requestData = (id) =>{
		fetchData({id}).then(response=>setData(response))
	}

  return <div>
		<button onClick=(()=>requestData(1))/>
		<button onClick=(()=>requestData(2))/>
		<button onClick=(()=>requestData(3))/>
		<span>{data.name}</span>
	</div>

}

위와같이 어떤 버튼을 누르냐에 따라 요청이 달라지고, 그로인해 화면에 표시되는 정보가 달라지는 케이스를 생각해보자. 무작위로 버튼을 눌렀을 때 화면에 표시되는 데이터가 과연 마지막에 누른 버튼에서 비롯된 데이터라고 확신할 수 있을까? 이처럼 병렬로 여러개의 요청을 보냈을 때 어떤 요청의 응답이 가장 먼저 도착할 것인지 확신할 수 없기 때문에, 사용자가 기대하는 정보와 화면에 표시되는 정보가 불일치할 우려가 생기는 케이스를 경쟁상태라고 부른다.

Solution

Pull up Fetch To Root

먼저 waterfall loading 문제를 해결하는 가장 간단한 방법은 데이터 요청을 모두 최상단으로 끌어오는 것이다.

const App = () =>{
	const [data,setData] = useState()
	const [pageData,setPageData] = useState()

	useEffect(()=>{
		Promise.all([fetchData(),fetchPageData()])
			.then(response=>setData(response[0]))
		  .then(response=>setPageData(response[1]))
	})

	if(!data){
		return <Loading/>
	}

  return <Page data={data} pageData={pageData}/>

}

다만 이 방법의 문제는 모든 data fetch가 최상단에서 진행됨에 따라 실제 데이터를 소비하는 컴포넌트까지 props를 전달해주어야하는데, 그 과정이 전적으로 탑다운으로 진행되기 때문에 실제 데이터와 컴포넌트의 의존성을 파악하기 힘들다는 점이 있다. 내가 서버에서 받아오는 “name”이라는 값을 어느 컴포넌트가 소비하고 있는지 파악하기 위해서는 data의 props drilling을 하나하나 추적해봐야한다.

useFragment

여기서 바로 relay의 꽃 useFragment가 등장한다. useFragment는 실제 데이터를 소비하는 컴포넌트에서 필요한 데이터만을 fragment로 ‘선언’해서 사용할 수 있게 해준다.

function NameComponent(props: Props) {
  const data = useFragment(
    graphql`
      fragment UserComponent_name on User {
        name
      }
    `,
    props.queryRef.user,
  );

	return <div>
	{data.name}
	</div>
}
function AgeComponent(props: Props) {
  const data = useFragment(
    graphql`
      fragment UserComponent_age on User {
        age
      }
    `,
    props.queryRef.user,
  );

	return <div>
	{data.age}
	</div>
}

const MyPage = () =>{
	const queryRef = useLazyLoadQuery(graphql`
		query AppQuery(){
			user{
				...UserComponent_name
				...UserComponent_age
			}
		}
	`)

	return <div>
		<NameComponent queryRef={queryRef}/>
		<AgeComponent queryRef={queryRef}/>
	</div>
}

실제 데이터를 소비하는 NameComponent와 AgeComponent에서 각각 필요한 데이터를 fragment로 선언하고, 프로젝트 상단에서 이를 import하여 query를 날리는 방식인 것이다. 이때 query의 결과로 반환된 queryRef를 다시 props로 NameComponent와 AgeComponent에 내려주면, useFramgent를 사용해서 필요한 data만 필터링해서 사용할 수 있다.

relay를 이렇듯 페이지에서 사용하는 데이터들을 최상단으로 끌어올린 뒤 하나로 취합하여 쿼리 횟수를 획기적으로 줄이면서도 데이터를 실제 소비하는 주체 컴포넌트와 데이터의 의존성을 명확하게 파악할 수 있도록 구성되어 있다.

usePreloadedQuery

바로 위 예시에는 useLazyLoadQuery를 사용했지만, 사실 relay에서는 useLazyLoadQuery 사용을 지양하고 usePreloadedQuery를 사용하라고 권장하고 있다. 그 이유는 useLazyLoadQuery의 동작 원리에 있다. useLazyLoadQuery는 호출되는 시점에 data를 query하고, pending state에 머무르는 동안 컴포넌트를 일시정지 상태로 만든다. 즉, response가 오기 전까지는 해당 컴포넌트가 렌더링되지 못하므로, 만약 children component에서 또 다른 data fetch가 필요한 경우 병렬로 처리되지 못하고 연쇄적으로 동작하는 waterfall loading issue를 유발할 수 있으며, 데이터와 무관한 컴포넌트의 렌더링까지 함께 막아버리기 때문이다.

반면 usePreloadedQuery는 query를 날리는게 아니라, queryRef를 전달받아, 이미 가져온(preloaded) 데이터를 읽어오는 역할만을 수행한다. (usePreloadedQuery또한 query가 pending 상태일 때는 렌더링을 일시 중단하지만, 일반적으로 useLazyLoadQuery보다 훨씬 앞단에서 prefetch된 data를 읽어오며, 실제 데이터를 사용하는 끝단의 컴포넌트에서 사용되기 때문에 데이터와 무관한 layout component의 렌더링은 막지 않으므로 useLazyLoadQuery대비 사용성이 우수하다.)

function NameComponent(props) {
  const data = useFragment(
    graphql`
      fragment UserComponent_name on User {
        name
      }
    `,
    props.queryRef,
  );

	return <div>
	{data.user}
	</div>
}
function AgeComponent(props) {
  const data = useFragment(
    graphql`
      fragment UserComponent_age on User {
        age
      }
    `,
    props.queryRef,
  );

	return <div>
	{data.user}
	</div>
}

const MyPage = (props) =>{

	const queryRef = usePreloadedQuery(graphql`
		query AppQuery(){
			user{
				...UserComponent_name
				...UserComponent_age
			}
		}
	`,props.preloadedQuery)

promise.resolve

	return Promise

	return <div>
		<NameComponent queryRef={queryRef}/>
		<AgeComponent queryRef={queryRef}/>
	</div>
}

const App = () =>{
	const preloadedQuery = useMemo(()=>loadQuery(AppQueryNode),[])
	
	return <MyPage preloadedQuery={preloadedQuery}/>
}

usePreloadedQuery는 말 그대로 preloaded data를 가져오기만 할뿐이고 실제 데이터 요청은 loadQuery가 수행한다. relay의 loadQuery는 비동기적으로 data를 반환하는 다른 fetch 함수들과는 다르게, data response를 기다리지 않고 즉시 queryRef를 반환한다. 이는 queryRef 자체는 데이터가 아니라 일종의 참조값이기 때문에 response를 기다릴 필요가 없기 때문이다.

여담으로 위 예시 코드에서 loadQuery를 useMemo로 감싸서 사용하고 있는데, 이는 query의 호출 시점을 앞당기기 위함이다.

기본적으로 React로 작성된 CSR 프로젝트에서는 route setting에 initial props로 loadQuery를 실행해 넘겨주기도하고, 페이지마다 code splitting이 잘 되어 있다면 컴포넌트 외부에서 호출해서 넘겨주는 방법을 채택하기도 한다.

const preloadedQuery = loadQuery(AppQueryNode)

const App = () =>{
	return <MyPage preloadedQuery={preloadedQuery}/>
}

사실 loadQuery 함수를 컴포넌트 내부에서 즉시 호출해도 캐시 정책에 따라 크리티컬한 문제는 없겠지만, render function 내부에서 loadQuery를 직접 호출하지 말라는 경고가 콘솔에 지속적으로 찍히므로 지양하는 것이 좋다. 또한 일반적인 data fetch 케이스처럼 useEffect 안쪽에서 호출해도 동작하는데는 문제 없지만 코드가 지저분해지기도 하고, 결정적으로 맨 처음 말했듯이 useEffect는 rendering 이후 호출되기 때문에 시간적으로 손해를 볼 수 있다.(render on fetch)

//컴포넌트에서 직접 호출
const App = () =>{
	const preloadedQuery = loadQuery(AppQueryNode)
	return <MyPage preloadedQuery={preloadedQuery}/>
}

//useEffect 사용
const App = () =>{
	const [preloadedQuery,setPreloadedQuery] = useState()

  useEffect(()=>loadQuery(AppQueryNode),[])

	return {preloadedQuery ? <MyPage preloadedQuery={preloadedQuery}/> : null}
}

이런 이유로 나는 useMemo를 사용하는 방식을 선호한다. useMemo는 기본적으로 life cycle에 관여하지 않기 때문에 렌더링 시점에 즉시 data 요청이 가능해지며, dependency array를 활용하면 useEffect처럼 최초 1회만 호출되도록 제어할 수 있기 때문에 원하는 동작을 만들어낼 수 있기 때문이다.

const App = () =>{
	const preloadedQuery = useMemo(()=>loadQuery(AppQueryNode),[])
	
	return <MyPage preloadedQuery={preloadedQuery}/>
}

Relay + Suspense

Relay는 애초에 Suspense와 함께 사용하도록 설계된 라이브러리다. 눈썰미 좋은 사람은 눈치챘을 수도 있지만, relay에서 제공하는 hooks들은 data를 state로 반환하지 않는다. 따라서 loading state또한 없다. 만약 Loading Component를 보여주고 싶다면 Suspense를 이용하면 된다.

여기서 Suspense란 react 18의 핵심 기능 중 하나로, react단에서 컴포넌트의 rendering state를 감지하여 fallback을 노출하는 방식으로 loading에 따른 조건부 렌더링을 도와주는 컴포넌트다.

const App = () =>{
	const preloadedQuery = useMemo(()=>loadQuery(AppQueryNode),[])
	
	return <Suspense fallback={<Loading/>}>
		<MyPage preloadedQuery={preloadedQuery}/>
	</Suspense>
}

위와같이 pending state를 가질 컴포넌트를 Suspense로 감싸고, resolve되기 전까지 보여줄 컴포넌트를 fallback prop으로 넘겨주면 된다.

state를 사용하지 않고 Suspense를 이용해 loading을 처리하게 되면 코드가 굉장히 직관적이고 깔끔해지는데, 기존에 팀에서 사용하던 apollo(또는 react query)와 비교하면 더욱 극명해진다.

const App = () => {

  const {data, loading} = useQuery(APP_QUERY);
	
	if(data){
		return <Loading/>
	}

  return (
    <div>{data?.name}</div>
  );
}

data와 loading을 state형태로 가져오고 있기 때문에 응답이 오기 전까지 data는 undefined를 초기값으로 갖고 있다. 이로 인해, data는 T | undefiend의 타입을 갖게 되며, 값을 읽어오기 위해서는 옵셔널 체이닝을 적용해야하는 귀찮음이 생긴다. 또한 때에 따라 컴포넌트 내부에서 조건문을 통해 로딩 컴포넌트를 렌더링해주어야 한다. 또한 우선 초기값으로 렌더링 한 후, loading state가 바뀔때마다 다시 컴포넌트를 렌더링해야하므로 상대적으로 잦은 리렌더링이 발생한다.

반면 Suspense를 사용하면 조건문 없이 query의 state에 따라 Suspense가 보여줄 component를 제어하므로 코드가 간결해지며, 사실상 렌더링이 단방향 플로우로 진행되므로 불필요한 리렌더링을 방지할 수도 있다.

여담으로 experimental이긴 하지만 React Query의 경우 suspense 활성화 옵션이 있고, apollo도 usePreloadedQuery와 유사한 사용 패턴을 제공하는 hooks가 있으니 문서를 참고해서 테스트해봐도 괜찮지 않을까 생각한다.

Race condition 대응

relay는 기본적으로 query function이 data를 반환하는게 아니라 동기적으로 queryRef를 반환하는 방식으로 동작한다고 했다. 즉, 비동기적으로 어느 요청의 응답이 먼저오는지에 의존할 필요 없이, 사용자가 마지막으로 요청한 query의 ref값을 가지고 있을 수 있다.


const Page=(queryRef)=>{
	const data = usePreloadedQuery(AppQuery,queryRef)

	return <div>{data.user.name}</div>
}

const App = ()=> {
  const [
    queryReference,
    loadQuery,
  ] = useQueryLoader(
    AppQuery,
  );
	
	const requestData = (id) =>{
		loadQuery({id})
	}

  return <div>
		<button onClick=(()=>requestData(1))/>
		<button onClick=(()=>requestData(2))/>
		<button onClick=(()=>requestData(3))/>
	  <Page queryRef={queryRef}/>
	</div>

}

Relay 사용 후기

예전부터 relay에 관심이 많아서 굉장히 많은 기대를 안고 사용을 해봤는데, 러닝커브가 확실한 만큼 만족도도 높은 라이브러리라는 평가에 공감하게 되는 것 같다. relay는 ‘쓰는 사람만 쓰는 라이브러리’라는 인상이 강해서 진입장벽이 높은편이다. 뿐만아니라 실제로 커뮤니티도 작고 문서도 불친절하고 자료도 없어서 맨땅에 헤딩하듯이 공부해야한다.(실제로 너무 답답해서 소스코드도 여러번 기웃거리기까지 했다.) 그럼에도 꾸역꾸역 하다보면 굉장히 장점이 많다는 사실을 느낄 수 있다.

당장 apollo나 react query만 봐도 useQuery, useMutation, useLazyQuery 등 기본 hook 몇개만 제공되기 때문에 러닝커브가 굉장히 낮은 편이다. 내가 query를 날리고 싶다면 생각할 필요 없이 useQuery부터 쓰면 되기 때문이다.

반면 relay는 수많은 api를 제공한다. 내가 query를 날리고 싶다면 fragment정의부터 loadQuery, usePreloadedQuery, useFragment, Suspense 등 고민하고 해야할 일들이 많다. 심지어 relay는 graphql fetch function도 개발자가 직접 정의해서 environment에 셋팅해주어야한다. 이처럼 높은 자유도와 많은 선택지를 제공함으로써 선택의 폭을 높여주고, 이해도만 높다면 apollo 대비 높은 퍼포먼스를 낼 수 있는 것이 relay의 특징이다.

캐시 쪽도 apollo와 동일하게 response를 정규화해서 관리해주기 때문에 cache 관리에 용이하고, cache를 컨트롤하는 인터페이스도 간편하게 잘 구축되어 있다. 캐시를 다루는 방법은 apollo와 굉장히 유사하면서도 차별점이 있는데, 이부분은 다음에 기회가 된다면 별도의 글로 다뤄보면 좋을 것 같다.

relay는 진짜 장단점이 극명하게 갈리는 라이브러리라는 생각이 든다. 처음에 입문할 때 처참한 수준의 정보 접근성으로 인해 한 걸음 멀어졌다가, relay만의 철학과 퍼포먼스에 공감해 한걸음 가까워지고, server component, pagination, file upload 등 벽을 만나 한걸음 멀어지고를 반복하고 있다. 과연 나는 다음 프로젝트에 apollo를 쓸까 relay를 쓸까. 나조차도 많이 궁금하다.

https://fe-developers.kakaoent.com/2021/211127-211209-suspense/

https://relay.dev/

https://github.com/relayjs/relay-examples/blob/main/issue-tracker-next-v13/src/components/MainView.tsx

https://codesandbox.io/s/relay-sandbox-nxl7i?file=/src/TodoAppEnvironment.ts:317-378

profile
React-Native 개발블로그

2개의 댓글

comment-user-thumbnail
2023년 8월 12일

많은 것을 배웠습니다, 감사합니다.

1개의 답글