지난 포스팅에서 SWR 을 사용하는 주된 이유인 캐싱에 대해 알아보았는데, 간혹 캐시 데이터가 화면을 렌더링 할 때 불편하게 작용할 때가 있다. 페이지에 따라서 캐시된 데이터가 아닌 최신의 데이터를 보여줘야 하는 경우가 있다. 이런 경우라면 지난 포스팅에서 알아보았듯 no cache, no store 설정을 통해 캐쉬화 되는 것을 막을 수 있다. 어떠한 페이지는 캐싱을 활용하는것이 훨씬 유리한 경우도 있다. 변화가 거의 없는 블로그 포스팅 글이나, 테이블의 페이지내이션 시 각 페이지별 데이터 들은 사용자가 수시로 확인하는 데이터이기 때문에, 확인할 때마다 데이터를 받아오는것은 손해라고 할 수 있겠다.
반면, 평소에는 캐싱이 유리하였다가 특정 이벤트 시 최신의 데이터가 필요할 상황이 있다. 즉, 즉각적으로 최신데이터를 반영해야 하는경우인데, 알다시피 캐시의 유효기간동안에는 서버에 요청 시 캐시 저장소에 있는 데이터를 그대로 활용하게 된다.
이런 특정한 상황이 이번 프로젝트의 Store 페이지를 다루다가 마주하게 되었다.
목록 페이지에서 좀 더 손쉽게 데이터를 삭제할 수 있도록 하기 위해서, 삭제 기능을 추가하였다. 이는 모바일이던지 데스크탑에서던지 모두 같았다. 즉 데스크탑에서는 테이블내에서 데이터를 삭제할 수 있어야 했고, 모바일 역시 카드를 삭제할 수 있어야 했다.
데이터를 삭제하는 것은 어렵지 않았다. 삭제하고자 하는 id 를 서버에 전송하여 서버에서 데이터베이스 내 같은 id 를 가지는 데이터를 삭제하면 되었는데, 문제는 이 다음이었다. 데이터를 삭제하고 나면 실제로 화면에서 삭제된 부분이 없어져야 했었다. 그래야지 실제 유저가 '아 데이터가 삭제되었구나' 라는 것을 인지할 수 있기 때문이다. 바로 렌더링이 이루어졌어야 했다.
문제는 Store 페이지에서 서버로부터 가져온 데이터들은 모두 캐시화가 되어있다. 즉 유효기간 내 데이터의 삭제가 이루어질 경우 즉각적인 반영이 아닌 기존 캐시를 그대로 랜더해서 보여줄 뿐이었다. 정리하자면 평소 캐시화된 의류데이터를 렌더하고 있었으나 데이터가 삭제되면 즉시 캐시가 최신화가 되었어야 했다.
swr 은 이러한 상황에 대처할 수 있도록 mutate 라는 메서드를 제공한다. 기존 캐시를 사용자가 의도한데로 업데이트 시킬 수 있는데, 이번 포스팅에서는 mutate 의 간단한 사용법과, 실제 프로젝트에서 어떤식으로 mutate 를 활용했는지에 대해서 다뤄보겠다.
자동으로 캐시를 갱신하는 방법
그전에 먼저 이전 포스팅을 기억해보자. swr 은 stale-while-revaildate 의 약자로서 swr 이 동작하는 메커니즘은, 캐시 유효기간이 지난 시점에서 서버에 요청이 왔을 경우, 우선은 기존 캐시 데이터를 사용하고 이후 서버에 데이터 검증을 통해 캐시를 갱신하는 과정이 일어난다. 오래된 캐시를 우선 렌더한다는 점에서 stale 이라는 표현이 쓰인것이다. 렌더된 데이터는 신선하지 않은 캐시이지만, 데이터의 변화가 있다면 지금 갱신되어있는 캐시는 신선하다(fresh). 즉, 두번째 이벤트가 발생하면 신선한 캐시를 렌더하게 될 것이다.
swr 에서는 이러한 캐시의 갱신에 대해서 자동으로 갱신하는 방법에 대해서 알려주고 있다. 공식 문서를 살펴보도록 하자.
1. 화면 내 포커스 될 시
아래 동영상을 보면 알 수 있는데, 화면 내에서 포커스가 될 시 캐시가 갱신되어 렌더된다.
위 기능은 기본적으로 활성화 되어있고, 이를 조정하고 싶다면 옵션 중 revalidateOnFocus 를 설정하면 된다.
2. 인터벌 갱신
일정 시간을 텀을 두고 매 시간마다 캐시를 최신화 시킬 수 있다. 아래 동영상으로 확인해보자
위 영상은 1초 간격으로 캐시를 갱신하도록 설정되어있다. 옵션에서는 refreshInterval 을 통해서 설정할 수 있다.
useSWR('/api/todos', fetcher, { refreshInterval: 1000 })
3. 재 연결시 캐시 갱신하기
인터넷이 오프라인이었다가 다시 연결되었을 때 캐시를 최신으로 갱신할 수 있다. 역시나 이 기능은 자동으로 활성화되어있는데 이를 수정하기 위해선 revalidateOnReconnect 옵션을 설정해 주면 된다.
이 외에도 캐시를 자동으로 갱신하는것을 막는 방법도 존재한다. 불변하는 데이터라면 불필요하게 계속 갱신할 필요가 없기 떄문이다. swr 1.0 버전부터 useSWRImmutable 훅을 제공한다. (아직 사용해보지 않아서 사용법은 공식문서에 나와있으니 참고하자.)
Mutation
자동으로 캐시를 최신화 해주는것은 참 편리한 기능이다. 이를 위한 로직을 따로 작성하지 않아도 된다는 점에서 분명 좋은데, 이것만으로는 모든 상황에 대응할 수가 없다. 당장 이번 Store 페이지에서 데이터를 삭제할 시 바로 삭제된 캐시가 반영되어야 한다. 하지만 위 자동 갱신의 경우 특정 조건이 붙기에(포커스나 일정시간의 인터벌 등) 바로 캐시를 최신화하지 않는다는 점은 여전히 한계가 있다.
이를 위해 swr 은 수동으로 캐시를 수정할 수 있는 방법을 제공해준다. 뮤테이션(Mutation) 이다.
mutate 와 useSWRMutation 을 제공하는데, 실제 프로젝트에서는 mutate 를 활용하였기에 useSWRMutation 의 사용법은 공식 문서를 참조하여 사용하면 될 것이고, 우선은 적용해봤던 mutate 의 핵심 부분을 살펴보도록 하자
https://swr.vercel.app/ko/docs/mutation
뮤테이션(Mutation) & 재검증(Revalidation) – SWR
SWR is a React Hooks library for data fetching. SWR first returns the data from cache (stale), then sends the fetch request (revalidate), and finally comes with the up-to-date data again.
swr.vercel.app
mutate 는 기본적으로 3가지의 인자를 받도록 되어있다.
mutate(key, data, option)
여기서 key 는 useSWR 에서의 key 를 나타낸다고 생각하면 된다. 즉, mutate 내 key 와 같은 key 를 가지는 useSWR 의 data 는 갱신 대상인것이다. 해당 key 를 가지는 모든 data 를 갱신하고 싶다면 원격 mutate 를 활용할 수 있겠다.
import { useSWRConfig } from "swr"
function App() {
const { mutate } = useSWRConfig()
mutate(key, data, options)
}
원격으로 mutate 를 다루려면 useSWRConfig 를 활용해야한다.
이와 달리 bound mutate 가 있는데, useSWR 로 다루고 있는 특정 key 에 한해서만 mutate 시키는 방법이다
import useSWR from 'swr'
function Profile () {
const { data, mutate } = useSWR('/api/user', fetcher)
return (
<div>
<h1>My name is {data.name}.</h1>
<button onClick={async () => {
const newName = data.name.toUpperCase()
// 데이터 업데이트
await requestUpdateUsername(newName)
// 로컬 데이터를 즉시 업데이트 하고 다시 유효성 검사(refetch)를 한다
// NOTE: key는 미리 바인딩되어있다.
mutate({ ...data, name: newName })
}}>Uppercase my name!</button>
</div>
)
}
위 예제에서는 버튼을 클릭하면 data.name 을 대문자로 변경한뒤, 실제 서버 데이터를 업데이트 한다. 이후 mutate 를 통해 변경된 데이터셋을 기존 캐시에 갱신시킨다음, 이를 렌더하기 전에 서버의 데이터 변경사항과 일치하는지 유효성 검사를 한 뒤 변경된 캐시로 렌더링을 하게 된다.
여기까지 살펴보면 mutate 는 캐시 갱신 시점을 컨트롤 하는 메서드라고 생각할 수 있다. 물론 맞는 말이다. 실제 데이터의 변경사항이 발생할 때 바로 캐시를 갱신하는 것이니 위 자동 갱신과는 차이점이 존재한다. 이 기능만으로도 충분히 위 자동갱신의 부족함을 채울 수 있지만 매번 데이터를 변경할 때마다 유효성검사를 한다는 것이 불필요하게 느껴지긴 한다.
이렇게 매번 실 서버의 데이터를 요청해서 캐시를 갱신하는것이 비효율적이라 생각한다면, 마지막 인자인 options 를 설정함으로서 또다른 mutate 의 효과적인 기능을 활용할 수 있다!
마지막 인자인 options 를 공란으로 두게 되면 기본값으로 true 가 설정이 된다. 무엇이 true 인가 생각할 수 있는데, 바로 revaildate 이다. 즉 재검증에 대한 설정값이다. 기본값은 재검증을 매번 하겠다는 의미이다. 실제로 mutate 의 인자에 data 를 기입하지 않고 key 나 bound mutate 일 경우 말그대로 데이터를 다시 재검증하게 된다.
import useSWR, { useSWRConfig } from 'swr'
function App () {
const { mutate } = useSWRConfig()
return (
<div>
<Profile />
<button onClick={() => {
// 쿠키를 만료된 것으로 설정
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
// `/api/user` 라는 키를 가진 모든 SWR에게 재검증을 지시
mutate('/api/user')
}}>
Logout
</button>
</div>
)
}
만약 mutate 에 인자 data 를 넣었을 경우 캐싱이 어떠한 흐름으로 진행이 되는 것일까? 사실 이 부분에 있어서 여러 시행 착오가 있었고, 실제 사용해본 경험을 참고하여 설명을 해보자면, 우선 mutate 에 넣어준 data 로 기존 캐시가 갱신되어 렌더링 되게 된다. 이떄 revalidata 가 true 이기 때문에 실제 서버의 데이터가 변화되었는지, 변화된 데이터의 상태에 따라 캐시를 갱신하거나 기존 캐시를 그대로 사용하게 된다.
여기서 중요한 점은 mutate 에서 data 로 캐시를 갱신할 때, 이때의 data 가 실제 서버에서 가져오는 data 와 차이가 발생하면 기존 캐시를 그대로 활용한다는 점에 있다. 혹은 서버에서 데이터에 접근할 수 없을 때에도 그러한데, 실제로 프로젝트를 진행할 때 서버의 API 를 조금 잘못 설정해서 기존 캐시를 그대로 렌더했던적이 있었다. 저장해둔 의류가 없을 시 그에대한 데이터를 클라이언트에 response 했어야 했는데 그러지 않도록 구현되어있어서, 실제 의류 데이터가 1개에서 이 1개의 데이터를 삭제했을 시, '빈페이지 입니다' 라는 페이지가 렌더되는 대신, 다시 1개의 데이터가 그대로 목록에 있는 기존의 캐시가 기반이 된 렌더링이 이루어졌다.
mutate 마다 서버에 재검증을 요청하는 상황이거나, 실 서버와의 데이터가 일치하지 않을 때 기존 캐시를 렌더한다는 점에서 분명 더 확실하게 캐시데이터를 서버와 일치시킨다는 점은 안정적이나, 즉각적으로 유저에게 변경된 화면을 보여줘야 하는 상황에서는 적합하지 않을 수 있다. 이때 options 를 false 로 설정하게 되면 재검증을 거치지 않고 일단 캐시된 데이터를 갱신하게 된다. 흐름을 살펴보면 우선 mutate 에 전달된 data 로 캐시를 갱신한다. 이후 다음에 갱신이 일어날 경우 재검증 과정을 통해서 이에 따른 캐시로 렌더하게 된다. 요점은, revalidate 가 false 라면 우선은 data 로 캐시를 갱신하여 렌더한다는 점이다.
프로젝트에 mutate 를 사용해보자
실제로 mutate 를 활용하는것은 어렵진 않았다. 변경된 사항이 적용된 data를 mutate 에 인자로 전달하면 됬고, 기본적으로 options 를 false 로 설정해놓아서, 재검증을 바로 거치는것이 아니라 data 를 그대로 캐시 갱신에 활용하도록 설정하였다. key 의 경우 bound mutate 를 활용하게 되니깐 기입할 필요는 없었다.
흐름은 다음과 같다. 데이터를 삭제할 경우 삭제한 아이템의 id 를 가져와서 캐시된 data 내 삭제된 의류의 id 를 제외한 나머지 의류 아이템으로 새롭게 data 를 생성하여 mutate 에 기입해준다.
우선은 useSWR 코드는 아래와 같다.
const store = ({ device }: StoreProps) => {
// 생략
// 데스크탑 모드에서의 데이터 페칭
const { data, error, isLoading, mutate }
= useSWR(`${backUrl}/posts/clothes/store?lastId=${lastId}&categori=${categoriName}&deviceType=${windowWidth}`, mutateFetcher);
// 모바일 모드에서의 데이터 패칭이면서, 여기서의 data 는 useSWRInfinite 의 data 로 누적데이터이다
const { items, paginationPosts, setSize, isReachedEnd, isItemsLoading, infinitiMutate }
= usePagination<ItemsArray>(categoriName, windowWidth);
usePagination 의 경우 useSWRInfinite 의 커스텀 훅으로서 커스텀 훅 내부에서는 useSWRInfinite 를 사용하며 key 는 위 useSWR 과 동일하다. (밑에서 usePagination 을 한번 간단히 살펴보자)
위 key 의 경우 여러가지 query 를 전달받는데, 일단은 query 는 굳이 신경쓰지 말고, 받아오는 data, 그리고 밑에 usePagination 의 경우 paginationPosts 부분만 신경쓰도록 하자.
백엔드 API 를 한번 살펴보고 가자면
// posts.js express
router.get("/clothes/store/", isLoggedIn, async (req, res, next) => {
try {
let where = { UserId: req.user.id };
if (req.query.categori) {
where.categori = req.query.categori;
}
// cursor 방식이라 lastId 를 전달해준다.
if (parseInt(req.query.lastId, 10)) {
where.id = { [Op.lt]: parseInt(req.query.lastId, 10) };
}
// 데이터를 9개씩 전달한다
const userClothes = await Cloth.findAll({
where,
limit: 9,
order: [["createdAt", "DESC"]],
include: [
{
model: Image,
attributes: ["id", "ClothId", "src"],
},
],
});
// 만일 의류데이터가 없다면 밑 객체를 전달해준다(아무것도 전달 안했다가 위에서 말한 오류가 발생했었음)
if (userClothes.length === 0) {
return res.status(200).json({ items: userClothes });
}
if (userClothes.length > 0) {
let nextCursor = userClothes[userClothes.length - 1].dataValues.id;
let userClothesData = userClothes.map((item) => item.dataValues);
// useSWRInfinite 에서 cursor 방식으로 누적 데이터를 쌓기 위해서 nextCursor 를 넘겨준다
let userClothesWithCursor = {
items: userClothesData,
nextCursor: nextCursor,
};
// 데스크탑 모드이면 각 페이지마다 9개의 데이터를 전달한다
if (req.query.deviceType === "desktop") {
return res.status(200).json(userClothes);
// 모바일 모드라면 처음부터 매 요청마다 9개씩 데이터를 전달한다
// 참고로 useSWRInfinite 의 경우 3페이지까지 데이터를 가져오려면 3번 요청을 하게 된다.
// 다만 기존 데이터는 캐시를 이용하기 때문에 서버요청에 그다지 손해는 없다.
} else if (req.query.deviceType === "phone") {
return res.status(200).json(userClothesWithCursor);
}
}
} catch (error) {
console.error(error);
next(error);
}
});
기본적으로 요청이 들어가면 최대 9개씩 데이터를 가져오게 된다. 데스크탑 모드와 모바일 모드에 따라서 전달되는 데이터에서 차이가 발생하는데, 데스크탑은 테이블에서 데이터를 이용하기 때문에 페이지마다 페이지에 적합한 9개의 데이터를 전달할 것이다.(userClothes)
반면 모바일 모드에서는 무한 스크롤이 적용되기 때문에, nextCursor 가 포함된 userClothesWithCursor 객체를 전달하게 된다. nextCursor 가 같이 전달되어야 useSWRInfinite 가 다음에 가져올 cursor 를 파악해서 다음 9개 데이터를 가져올 수 있게 된다. 말로만 설명하는것보다 한번 useSWRInfinite 작성 코드를 살펴보자(이번 포스팅에서는 mutate 위주라, 자세히 다룰 생각은 없었다..)
export interface SWRResult<T> {
items: T[];
nextCursor: number;
}
// 좀더 확장하려면 url 을 인자로 넘겨주는 방식으로 처리하면 된다.
export const usePagination = <T>(categoriName: string, windowWidth: string) => {
// 기존 useSWR 부분의 key 라고 보면 된다. 로직이 있어서 따로 빼놓음
const getKey = (pageIndex: number, previousPageData: SWRResult<T>) => {
// 이 부분은 마지막 데이터 전송이 끝난 다음 요청시 items 가 없을테니 null 을 return 한다.
if (previousPageData && !previousPageData.items) return null;
// 처음에는 pageIndex 가 0 이니 이에 맞는 url 를 return 한다.
if (pageIndex === 0) return `${backUrl}/posts/clothes/store?lastId=0&categori=${categoriName}&deviceType=${windowWidth}`;
// 다음부터는 cursor 를 전달하여 cursor 이후 9개의 데이터를 가져와 data 에 누적시킨다.
return `${backUrl}/posts/clothes/store?lastId=${previousPageData.nextCursor}&categori=${categoriName}&deviceType=${windowWidth}`;
};
// 그냥 data 를 쓰면 useSWR 과 겹치니 각각 변수명을 지어준다
const { data: items, error: postsError, size, setSize, isLoading: isItemsLoading, mutate: infinitiMutate } = useSWRInfinite<SWRResult<T>, Error>(getKey, mutateFetcher);
const posts = items?.map(item => item.items);
// 데이터가 없을 때 posts 가 [undefiend] 가 되기 때문에, 배열 안 undefined 를 없에기 위한 방법
const filterUndefined = posts?.filter(item => item !== undefined);
// 이부분이 핵심인데, 실제 전달되는 items 의 형태가 배열로서 되어있지 않다. 따라서 이 부분을 flat 을 통해 변경해주어야 한다
const paginationPosts = posts === undefined ? undefined : filterUndefined?.flat();
const loadingMore = posts && typeof posts[size - 1] === 'undefined';
const isEmpty = posts?.[0]?.length === 0;
const isReachedEnd = posts && posts[posts.length - 1]?.length < 9;
return {
items,
paginationPosts,
postsError,
size,
setSize,
loadingMore,
isReachedEnd,
isItemsLoading,
infinitiMutate,
};
};
거진 useSWR 과 비슷하지만, 받아오는 데이터를 누적한다는 점, 그리고 누적된 데이터 형태가 배열형태가 아니라 flat 을 통해 배열로 변경해주어야 한다는 점이 있다. 그 외 size 를 통해 pageIndex 를 설정할 수 있다는 점, 마지막 데이터에 도달했는지에 대한 isReachedEnd 등의 추가 설정을 해줄 수 있겠다.
이제 실제 store 페이지 내 mutate 가 발동될 아이템 삭제 부분을 살펴보자
const deleteItemAtTable = useCallback(
(id: number) => () => {
dispatch({
type: t.DELETE_ITEM_REQUEST,
data: { clothId: id },
});
if (Array.isArray(data)) {
let newData = [];
for (let item of data) {
if (item.id !== id) newData.push(item);
}
mutate([...newData], { revalidate: false });
}
if (Array.isArray(items)) {
let newPostitems = [];
for (let set of items) {
let newItems = { ...set };
let newPostData = set.items?.filter(item => item.id !== id);
newItems = { ...newItems, items: newPostData };
newPostitems.push(newItems);
}
infinitiMutate([...newPostitems], { revalidate: false });
}
},
[data, items, paginationPosts, windowWidth]
);
mutate 에서 유의할 점은 전달되는 data 가 실제 useSWR 에서 전달받아오는 data 와 형식이 일치해야 한다는 점이다. 한쪽은 배열인데 한쪽은 객체 이런식이면 안된다.
특히 useSWRInfinite 의 경우 렌더과정에서는 paginationPosts 를 사용하지만 mutate 에서는 flat 처리된 데이터를 사용할 수 없기 때문에(형식이 일치해야하기에) 기존 형식인 items 를 토대로 mutate 에 전달해줄 것이다.
우선 삭제될 데이터의 id 를 토대로 dispatch 하여 실제 서버에서 데이터를 삭제한다. 그러면서 데스크탑 모드일 경우 data 가 존재할 것이고, 이 data 를 newData 로 옮겨줄 것인데, 삭제된 id 를 가진 데이터는 제외하고 newData 를 생성한다. 이후 이 newData 를 mutate([...newData], false) 처리해준다.
useSWRInfinite 의 경우 조금 복잡한데, 우선 newPostItems 배열을 생성해주자. 이후 flat 되지 않은 items 를 순환돌면서 개별 데이터 set 의 실 의류 데이터 부분에 접근하여, 여기서 삭제된 id 를 제외한 나머지 의류들을 filter 해서 newPostData 로 저장한다. 좀 햇갈릴 수 있는데, 여기서 items 의 경우 각 페이지별 데이터들의 집합을 의미한다. 즉 1페이지 9개 데이터, 2페이지 9개 데이터,... 을 나타내고 있으며, set 은 이중 한 페이지를 나타낸다. 1페이지거나 2페이지,3페이지 등등 9개의 데이터를 나타낸다.여기서 만일 데이터를 삭제했다면 어느 한 페이지에서 데이터 하나가 삭제된 것이다. 이러한 삭제를 filter 로 구현하고, 다시 넣어준다. 기존 데이터를 빼와 한 데이터를 삭제하고 다시 집어 넣는다고 생각하면 될 것이다.
이렇게 mutate 의 data 를 설정해주고, options 를 false 를 하게 되면 화면에 변경된 캐시가 그대로 렌더되게 된다.
mutate 가 캐시를 즉각 갱신함을 확인할 수 있었다.
실제 데이터를 삭제할 때 위쪽 총괄 데이터(총 의류개수, 총 금액)도 같이 변경이 되고, 페이지내이션 번호도 즉각적으로 변화가 되어야 한다고 생각하였는데, 이 부분은 서버에서 전체 데이터를 가져와서 렌더하는 과정이라 구현하는 과정에서 좀 고민이 있었는데, 추후 리덕스를 활용해서 구현하는것으로 결정했었다. 이 부분은 시간이 되면 위 포스트에 추가로 첨가하도록 하던가 아니면 새로운 포스팅으로 간략하게나마 설명해보겠다.
'Practice' 카테고리의 다른 글
[Closet]Cursor-based-pagination 이 효율은 좋지만.. (0) | 2023.05.01 |
---|---|
[Closet]Sequelize custom method 을 활용한 데이터 수정 (0) | 2023.04.30 |
[Closet]React-Hook-Form의 Reset (1) | 2023.04.30 |
[Closet]이미지 파일을 Multer 와 Vision AI로 처리해보자 (0) | 2023.04.30 |
[Closet] 이미지 업로드 시 Drag and Drop을 활용해보기 (0) | 2023.04.29 |