저희 Knoticle 서비스의 서재페이지에 진입했을 때 서버로부터 요청해오는 데이터는 3가지로, 유저의 프로필 정보, 유저가 엮은 책, 유저가 북마크 한 책입니다.
리팩토링 작업 전에는 서재페이지 컴포넌트에서 이 데이터를 모두 받아오고 전역상태에 저장한 뒤, 탭을 바꿀 때마다 상태에 저장된 값을 사용해서 렌더링하였습니다.
그러다보니 버그가 발생했는데, 유저 서재페이지의 엮은 책 탭에서 유저가 책 목록 중 하나를 북마크한 다음, 북마크 한 책 탭을 눌러도 해당 책이 목록에 포함되지 않는다는 것이었습니다. 즉, 렌더링할 때 클라이언트 측 상태를 사용하였고, 이 상태가 적절히 업데이트되지 않는다는 점에서 생기는 문제였습니다.
이에 따라 북마크를 누를 경우 전역 상태를 같이 업데이트 해주는 로직이 useBookmark 훅에 추가되었습니다.
하지만 이렇듯 클라이언트 측 상태를 관리하고 업데이트하는 작업이 불필요하다고 느껴졌고, 더 좋은 방법이 있을거라고 생각되어 개선을 시도하게 되었습니다.
이 방법보다는 서재페이지에서 탭을 바꿀 때 데이터를 새로 받아오는 편이 낫다고 판단하였고, 그로 인해 서버에 가는 부담도 크지 않을 거라 생각하였습니다.
따라서 엮은 책, 북마크한 책 탭에 해당하는 각 탭 컴포넌트를 만들고, 각 탭에서 데이터를 받아오는 것으로 변경하였습니다. 탭을 바꿔 새로운 탭이 렌더링되어야 하면, 탭 컴포넌트 내 데이터를 받아오는 코드가 실행되기 때문에 데이터가 갱신되는 것입니다.
다만 이 경우 북마크한 책 탭에서 북마크를 해제하더라도 그 상태가 반영되지 않습니다. 즉, 북마크 아이콘 자체는 해제된 것으로 보이지만, 그 책이 리스트에서 바로 삭제되지는 않는 것이죠. 탭을 바꿀 때에만 데이터가 갱신되기 때문입니다. 하지만 오히려 사용자가 실수로 북마크 해제 버튼을 눌렀을 때 리스트에 남아있으므로 다시 북마크 할 수 있다는 점에서 사용성에 더 좋다고 판단하였습니다.
그렇게 해서 개선된 코드입니다.
서재페이지 리팩토링 작업 by dahyeon405 · Pull Request #7 · dahyeon405/knoticle
하지만…
문제가 하나 더 있었습니다.
서재페이지를 다시 살펴보면, 프로필을 수정할 수 있는 버튼이 있습니다.
또한 엮은 책 리스트에는 저자의 이름이 들어가있는데요, 만약 이름을 dahyeon → dahyeons로 수정하면 아래 엮은 책 저자 이름도 dahyeons로 변경되어야 합니다.
하지만 현재 코드에서는 이름이 변경되었을 때 책 목록을 다시 갱신해주는 작업을 해주고 있지 않기 때문에 변경 전의 이름이 그대로 표시되는 상황이 발생했습니다.
이 버그는 이름이 변경되었는지 여부를 감지하고 변경되었다면 데이터를 다시 받아오는 코드를 추가해주면 해결할 수 있는 문제였습니다.
하지만.. 더 좋은 방법은 없을까요? 이와 관련된 라이브러리로는 React query가 있다는 것을 알고만 있었는데, 이 기회에 정확히 어떤 역할을 하는 라이브러리인지, 도입할 만 한지 알아보고 싶었습니다.
React Query는 React Application에서 서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트 하는 작업을 도와주는 라이브러리입니다.
React query 적용기 글들을 읽으면서(참고 자료에 링크), 크게 다음과 같은 장점이 있다고 생각하였습니다.
React query가 제공하는 기능
도입 이유
첫 번째 이유만 있었다면 해당 기능을 직접 구현했겠지만, 두 번째, 세 번째 이유까지 더하니 번들사이즈를 고려하더라도 도입할 만하다는 결론을 내리게 되었습니다.
위 기능을 해주는 것은 React query 외에도 Next.js를 개발한 팀에서 만든 SWR가 있습니다.
아래의 링크들을 훑어보면서 SWR을 사용할 지 React query를 사용할 지 고민해보았는데요,
React Query vs SWR
React Query vs SWR
결론적으로는 아래의 이유로 React query를 사용하기로 결정했습니다.
npm trends를 보면 React query의 npm 다운로드 수가 더 높습니다.
React query를 사용하면 SWR보다 쉽게 페이지네이션이나 무한스크롤을 구현할 수 있습니다.. 이전 페이지 데이터를 핸들링하는 프로퍼티를 제공하기 때문입니다.
→ 현재 검색페이지에서 무한스크롤이 구현되어 있는데, React query를 사용하여 개선해볼 수 있을 것 같았습니다.
React query는 캐싱을 컨트롤 할 수 있는 더 많은 기능을 제공합니다.
Next.js를 사용한다면, 아래와 같이 _app.tsx 파일을 세팅해주어야 합니다.
// _app.jsx
import { Hydrate, QueryClient, QueryClientProvider } from 'react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
Knoticle의 서재페이지는 (내 서재페이지에 들어간다면) 내가 북마크한 책을 볼 수 있고, 엮은 책을 편집할 수 있는 ‘마이페이지’와 같은 기능을 제공합니다. 다른 사람의 서재페이지에도 들어갈 수 있지만, 이 때는 수정을 할 수는 없습니다.
원래는 다른 사람의 페이지와 마이페이지를 모두 하나의 페이지 컴포넌트에서 렌더링하고 있었습니다. 수정과 관련된 버튼들은 각각의 컴포넌트에서 로그인 된 유저와 현재 서재페이지의 유저가 일치하는지 비교해서, 일치할 경우 나타나도록 구현되어 있었습니다.
하지만 내 서재페이지로 들어갔을 때 보이는 내가 엮은 책과, 내가 북마크한 책에만 React query를 적용하고 싶었으므로, ‘다른 사람의 서재페이지’와 ‘내 서재페이지’를 분리하기로 결정했습니다.
이렇게 분리하니 각 컴포넌트에서 유저가 일치하는지 검증할 필요없이 처음 서재페이지에 진입했을 때만 검증해주면 된다는 장점이 있었고, 로직이 분리되니 알아보기도 편했습니다.
{signInStatus.nickname === curUserProfile.nickname ? (
<MyStudy curUserProfile={curUserProfile} setCurUserProfile={setCurUserProfile} />
) : (
<OtherStudy curUserProfile={userProfile} />)}
동시에 버그도 해결되었는데, 프로필을 변경할 시 curUserProfile
이 변경되므로 MyStudy
컴포넌트가 새로 렌더링되면서 자동으로 데이터도 갱신되도록 하였습니다.
사실 초기 아이디어는 프로필이 변경되었을 때 react query의 기능을 이용해서 데이터(엮은 책, 북마크한 책)을 갱신하도록 하는 것이었으나, 다른 사람의 서재페이지와 내 서재페이지 컴포넌트를 분리함에 따라 위 로직으로 해결하게 되었습니다.
처음 접근한 방법에서는 데이터 갱신을 위해 엮은 책, 북마크 한 책 탭을 각기 다른 컴포넌트로 분리하였지만, 그 이유가 아니라면 나눌 필요가 없다고 생각하여 다시 합쳤습니다. 여기에 아래와 같이 useQuery를 적용하였습니다.
const { data: knottedBookList } = useQuery('userKnottedBookList', () =>
getUserKnottedBooksApi(curUserProfile.nickname)
);
const { data: bookmarkedBookList } = useQuery('userBookmarkedBookList', () =>
getUserBookmarkedBooksApi(curUserProfile.nickname)
);
invalidateQueries
를 사용하여 탭을 바꿀 때 데이터가 갱신되도록 변경하였습니다.
useEffect(() => {
if (tabStatus === 'bookmarked') queryClient.invalidateQueries('userBookmarkedBookList');
if (tabStatus === 'knotted') queryClient.invalidateQueries('userKnottedBookList');
}, [tabStatus]);
위 코드에서 React query는 다음과 같은 방식으로 동작합니다.
useQuery
와 ‘userBookmarkedBookList’ 키를 갖고 있는 useQuery
가 처음 실행되고, 캐시된 데이터가 없기 때문에 네트워크 요청을 보내 데이터를 가져옵니다.staleTime
이 지나면 훅은 스스로를(해당 키를 가지고 있는 데이터를) stale
하다고 표기합니다. staleTime
의 기본값은 0입니다.invalidateQueries
에 의해 invalidate되면 해당 데이터가 stale
하다고 표기됩니다.useQuery
를 통해 렌더링되고 있는 데이터라면, 백그라운드에서 refetching
이 일어납니다.refetching
된 데이터와 캐시된 데이터가 서로 다르다면 그 때 UI를 변경합니다.useQuery
가 사용된 컴포넌트가 언마운트되고, 설정한 cacheTime
이 지나면 garbage collect 됩니다. cacheTime
의 기본값은 5분입니다.Knoticle의 메인페이지에는 아래와 같이 가장 인기 있는 책과 새로 엮은 책을 보여주고 있습니다.
위 데이터를 받아오는 부분을 react query를 사용하여 아래와 같이 개선해보고자 하였습니다.
staleTime
지정하기: 메인페이지에서 다른 페이지로 이동했다가 다시 돌아왔을 때, 지정한 시간이 지나기 전에 돌아오면 새로 불러오지 않고 이전에 불러온 데이터를 사용하게 하고 싶었습니다.staleTime
의 기본값은 앞서 언급했다시피 0으로 설정되어있는데요, 따라서 컴포넌트가 다시 마운트되면 우선은 캐시된 데이터를 제공하되, 백그라운드에서 무조건 refetch가 일어나게 됩니다.
staleTime
을 지정하면 해당 시간이 지나기 전에 컴포넌트가 다시 마운트되면 요청을 보내지 않고 캐시에 있는 데이터를 사용하도록 할 수 있습니다.
아래와 useQuery의 옵션을 통해 개별적으로 지정해줄 수 있습니다.
const { data: newestBookList, isLoading: isNewBookListLoading } = useQuery(
'orderedBookList',
() => getOrderedBookListApi('newest'),
{ staleTime: 20000 }
);
적용 결과,
staleTime
내에 돌아오면 캐시된 데이터를 보게 되므로 데이터를 기다리는 시간이 줄어듭니다.유저가 어플리케이션을 나간 후 (background로 옮겨진 후) 데이터가 stale 상태가 됐다면, react query는 백그라운드에서 데이터를 refetch 시켜 새로운 데이터로 갱신해줍니다. 디폴트로 설정되어있기 때문에, 따로 설정해줄 필요가 없습니다.
옵션을 끄는 방법:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
React query 공식문서: Overview