프로덕트를 만들 때, 프론트엔드 개발자가 최우선으로 고려하게 되는 것 중 하나가 바로 사용성이다. 좀 더 구체적으로 디자이너가 주로 UX 흐름이나 심미적인 부분에서 사용성 증진을 고민한다면, 프론트엔드 개발자는 그것을 받아 서버 데이터와 연결할 때 사용자 관점에서 어떻게 하면 더 쾌적함을 제공할 수 있을지 고민해야 한다.
회사로부터 주어진 프로젝트를 구상할 때 위와 같은 측면에서 주로 고심했던 부분은 각 화면 진입 시 초기 렌더링 방식이었다. 이전의 프로젝트 경험을 비추어 보면, 대부분 로딩 중인 화면을 표현하기 위해 별도의 상태를 선언하고 해당 상태를 보여줄 UI 코드를 작성하며 컴포넌트는 쉽게 비대해졌다. 이런 책임을 분리하기 위해 Suspense라는 솔루션이 나왔지만, 여전히 전체 화면 혹은 특정 섹션의 정가운데에 Spinner UI를 박아두는 경우가 부지기수였다.
왜 그래야만 했을까, 를 돌이켜보면 다음과 같은 이유가 있었다고 생각한다.
- 비동기 데이터를 화면에 보여주기 전까지 해당 컴포넌트와 비슷한 모양의 스켈레톤을 구현하여 제공하려면 컴포넌트 하나 당 스켈레톤 하나가 따라오니 구현에 대한 리소스가 증가한다.
- 디자인은 기획에 따라 자주 바뀌는 요인이기 때문에, 컴포넌트에 대한 디자인이 바뀌면 스켈레톤 디자인 또한 적절히 대응해주어야 하다 보니 관리 포인트가 늘어난다.
이러한 문제로 로딩을 표현하기에 어색하지 않으면서 구현에 대한 리소스가 많이 들어가지 않도록 결정한 중간책이 바로 Spinner UI였다. 하지만 이렇게 Suspense가 넓은 범위를 커버하도록 코드를 구성하게 되면 하나의 Suspense 하위 컴포넌트에서 비동기 호출이 많아질수록 waterfall 현상으로 인해 실제 데이터가 담긴 컴포넌트를 볼 수 있는 시점은 늦어진다. 그렇다고 한 개의 Suspense가 커버하는 범위를 줄이자니 스켈레톤이 아닌 이상 수많은 Spinner가 화면에 아른거릴 것이고 동일한 api를 두 번 이상 호출할 수도 있겠다는 우려가 존재했다.
로딩 중 화면과 데이터가 채워진 화면의 이질감이 덜하면서도 비동기 데이터를 화면에 그리기까지의 코드를 덜 작성하려면 어떻게 해야할까? 가 프로젝트 셋업의 핵심 질문이었고 이를 해결하기 위해 여러 아티클을 찾아보던 중 토스에서 게재한 아티클을 발견하게 되었다.
조금만 신경써서 초기 렌더링 빠르게 하기 (feat. JAM Stack)
이 글에서 영감을 받은 부분은, 'api 호출부와 api의 데이터가 필요한 컴포넌트를 최대한 가깝게 두어 그 외의 컴포넌트는 모두 레이아웃으로 구분하여 로딩 중에도 노출되게 하는 것'이었다. 동의하는 방향이었기에 실제로 구현을 해보며 코드를 작성한 방식과 더 디벨롭한 부분을 적어보려 한다.
1. 비동기 상태 관리 라이브러리인 react-query (현 tanstack-query, 이하 RQ)와 Suspense의 조합
react-query의 가장 큰 장점은 Suspense와 호환성, 별도의 캐시 레이어와 메커니즘이 내장되어 있어 사용자로 하여금 상태 관리에 대한 고민 포인트를 줄여준다는 점에 있다. 실제로 회사 내에서 Redux, RTK로 상태를 관리하는 프로젝트와 현재 단독으로 진행 중인 RQ로 상태를 관리하는 프로젝트를 병렬로 작업하고 있는데, 같은 데이터 흐름을 위해 작성해야 하는 코드의 양이 확연히 차이가 난다. 또한 비동기 상태를 모두 RQ 레이어로 이관하다 보니 클라이언트에서 실제로 들고 있어야 할 상태는 거의 없어 웬만하면 ContextAPI로 대응이 가능했다.
이 맥락에서 갑자기 react-query를 꺼낸 이유는 react-query의 캐싱 기능 덕분에 전체 컴포넌트 트리 내에서 여러 컴포넌트가 동일한 api를 호출 한다고 해도 실제 api는 한 번만 호출되어 문제를 더 쉽게 해결하도록 도와준다는 데에 있다. react-query의 refetch 조건(window focus, mount, reconnect)에 충족되지 않거나 특정 쿼리의 invalidate를 직접 해주지 않으면 비동기 요청으로 한번 받아온 데이터는 QueryCache에서 들고 있다가, 추후 요청 시 api 통신 없이 바로 캐싱된 상태를 반환하기 때문이다.
프로젝트 내에서는 하나의 쿼리 파일에 쿼리와 관련된 모든 것을 ducks pattern으로 관리한다. (api, querykey, query hook) 공식문서에서 제안하는 query-key-factory도 있긴 하지만, 쿼리와 관련된 정보를 도메인 단위가 아닌 하나의 데이터 단위로 나누어 보는 것이 관리상 용이하겠다는 판단이 들어 굳이 사용하진 않았다.
// useGetSomthing.tsx
interface GetSomethingPayload {
id: string
}
const getSomething = async ({ id }: GetSomethingPayload) => {
return client.get<Something>(`/something/id`)
}
export const SomethingQueryKey = ({ id }: GetSomethingPayload) => [...SomethingQueryKey, id] as const
const useGetSomething = (payload: GetSomethingPayload) => {
return useSuspenseQuery(SomethingQueryKey(payload), () => getSomething(payload))
}
export default useGetSomething
쿼리가 준비되었으니 이것을 사용부에서 Suspense와 함께 선언해보았다. 데이터가 필요한 최소한의 컴포넌트만 추출하여 Suspense로 감싼 뒤 fallback을 넣어준다. 이렇게 하면, Suspense 바깥에 있는 모든 컴포넌트는 데이터가 있든 없든 정적으로 렌더링 된다. fallback으로 대응해야 하는 스켈레톤의 크기가 확 작아지기 때문에 단순 문자열만으로도 표현할 수 있고 초기 로딩 중에도 layout shift를 많이 줄일 수 있다.
function Title() {
const { data } = useGetSomething()
return <h1>{data.title}</h1>
}
function Description() {
const { data } = useGetSomething()
return <div>{data.description}</div>
}
function Section() {
return (
<div>
<Suspense fallback="-">
<Title />
</Suspense>
<ComponentWithoutData />
<ComponentWithoutData2 />
<Suspense fallback="-">
<Description />
</Suspense>
</div>
)
}
하지만 여기서 마무리하기에 다소 아쉬운 부분이, 쿼리를 사용하는 부분을 최소화할수록 쿼리를 호출하는 횟수가 늘어나기 때문에 이와 관련된 코드가 늘어난다. 주로 하나의 컴포넌트 작성을 위해 Suspense + 쿼리 호출에 필요한 의존성 호출 + 쿼리 호출 + 호출한 쿼리 데이터의 가공 등이 필요한데, 이렇게 작성할 코드가 많아지다 보면 본래의 목적을 상실하고 이전으로 회귀할 수 있다.
2. Render props pattern
그래서 DX를 높이기 위한 일환으로 채택한 방법이 Render props pattern이다. 원래는 react-hook-form의 Controller 컴포넌트에서 영감을 받아 작성했는데, 쓰다 보니 원래 이런 방식을 부르는 명칭이 있다는 걸 알게 되었다. Render props pattern은 H.O.C와 유사한 목적으로 컴포넌트 로직의 재사용성을 높이기 위해 사용되는 패턴이다. 나와 같은 경우에는 렌더링 로직과 쿼리 로직을 분리하여 비동기 데이터를 제공하는 컴포넌트를 만들어 재사용성을 높이기 위한 목적으로 이러한 패턴을 차용하게 되었다.
아래와 같은 컴포넌트는 각각의 쿼리 파일 안에 위치하고 있다. 그리고 이 컴포넌트는 props로 render 로직을 받은 다음, 요청에 필요한 payload가 있다면 optional하게 받아 쿼리 데이터를 넘겨주고 이것을 Suspense로 감싸 export 한다. 또한 쿼리 호출에 필요한 의존성이 있다면 이 컴포넌트 안에서 추가를 해주면 된다.
interface SomethingRendererProps {
requestPayload: GetSomethingPayload
render: (resource: Something) => ReactNode
}
function Renderer({ requestPayload, render }: SomethingRendererProps) {
const { data: resource } = useGetSomething({
somethingId: requestPayload.somethingId,
})
return <>{render(resource)}</>
}
export const SomethingRenderer = withSuspense(Renderer)
이렇게 하면 사용처에서는 데이터를 받아 render할 것들에 대해서만 관심을 가지면 되기 때문에 코드가 많이 간결해진다.
function Section() {
return (
<div>
<SomethingRenderer
render={(data) => <h1>{data.title}</h1>}
fallback="-"
/>
<ComponentWithoutData />
<ComponentWithoutData2 />
<SomethingRenderer
render={(data) => <div>{data.description}</div>}
fallback="-"
/>
</div>
)
}
유의할 점
Render props pattern의 단점이기도 한데, 하나의 컴포넌트에서 두 개 이상의 쿼리가 필요하고 이것을 Renderer로 대응할 경우 컴포넌트의 depth가 깊어져 마치 콜백 지옥과 같은 가독성 이슈를 불러올 수 있다. 그렇기 때문에 이러한 방식은 쿼리를 사용하는 범위를 최대한 작게 가져가 하나의 Renderer만 호출하게 하고 부득이하게 두 개 이상의 쿼리가 필요하다면 useQueries를 사용하는 것이 좋겠다.
'Development' 카테고리의 다른 글
[23.07.29] 프론트엔드에서 Route를 확장성있게 관리하기 (0) | 2024.01.09 |
---|---|
[22.09.03] 실무 프로젝트에서 Virtual Scroll 활용하기 (1) | 2024.01.09 |
[22.04.21] Data Fetching in Next.js (1) | 2024.01.09 |
[22.01.06] JS의 Map vs Object (0) | 2024.01.09 |
[21.12.22] React + TypeScript + Webpack + Babel (2021) (1) | 2024.01.09 |