지난 이야기

메인 페이지와 게시물 상세 페이지에서 사용하는
두 API의 성능이 다소 낮다는 문제가 있었고,
이로 인해 페이지의 전환이 매우 느리다는 문제가 발생하였다.

이에 SWR의 캐싱을 활용하여
느린 로딩 속도를 극복하고 페이지를 빠르게 전환할 수 있었다.
그러나 두 가지의 문제가 여전히 남아 있었다.
이번 글에서는 SWR의 심화 기능을 적극적으로 활용하여
이 두 문제를 보완해보겠다.

1. 업데이트 시 깜빡임

SWR을 통해 가져오는 데이터는 업데이트가 자주 발생한다.
게시물에 좋아요를 누르거나 취소할 수 있고, 댓글을 작성할 수 있으며,
게시물을 추가, 수정, 삭제할 수 있다.

SWR은 최신 데이터로 자동 갱신을 해 주므로
데이터의 업데이트를 실질적으로 모두 반영할 수 있다.
그러나 도중에 깜빡임 현상이 발생한다는 것이 문제이다.
아래와 같이 말이다.

좋아요 클릭 시

게시물 삭제 시

사소해 보일 수 있으나, UX에 그리 좋지 못한 것은 분명하다.
SWR의 mutate 함수를 통해 캐시 데이터를 로컬에서 업데이트 해야 한다.
참고로 이 글에서 말하는 캐시 데이터는,
useSWR을 통해서 가져 온 메인페이지의 상위 5개의 게시물 데이터를 말한다.

const { data: initialPosts } = useSWR(
	    `/posts/channel/${process.env.REACT_APP_CHANNEL_ID_TOTAL}?offset=0&limit=5`,
	   );

그러나 한편으로, 이렇게 캐시 데이터를 업데이트하는 것은 로직이 꽤 복잡하다.
어떻게 보면 전역 상태를 관리한다고도 볼 수 있는데,
이미 알고 계시겠지만, 전역 상태를 업데이트하는 것은 상당히 복잡한 작업이다.
그렇기 때문에 나는 필요한 부분에 대해서만,
깜빡임 현상이 두드러지는 부분에 대해서만 적용할 생각이다.
이를 정리하면 아래와 같다.

1) 좋아요 등록 및 취소 시
좋아요 여부는 명시적으로 눈에 띄므로, 깜빡임 현상이 보기 좋지 못하다.
좋아요 클릭 시, 캐시 데이터를 업데이트한다.

2) 게시물 삭제 시
삭제된 게시물이 여전히 남아있는 것은 조금 용납하기 어렵다.
사용성이 좋지 못한 것은 물론이며
유저가 이미 삭제된 게시물과 상호작용을 한다면(좋아요 등),
예상치 못한 버그를 야기할 수 있다고 생각하기 때문이다.
게시물 삭제 시, 캐시 데이터를 업데이트한다.

그 외, 댓글을 추가 및 삭제하거나 게시물을 생성 및 수정하는 경우가 있다.
이 경우는 조금 아쉽지만, 깜빡임 현상을 용인할 생각이다.
깜빡임이 발생해도 상대적으로 눈에 덜 띄어서
용인할 수 있는 범위라고 생각하기 때문이다.

커스텀 훅으로 모듈화

본격적으로 들어가기 전에
SWR 및 mutate를 사용하기 쉽도록 커스텀 훅을 만들겠다.
다음과 같이 useSWRPostList라는 훅을 만들었다.

import useSWR from 'swr';

export const postListKey = `/posts/channel/${process.env.REACT_APP_CHANNEL_ID}?offset=0&limit=5`;

const useSWRPostList = () => {
  const { data, error, isValidating, mutate } = useSWR(postListKey);

  return {
    data,
    error,
    isValidating,
    mutate,
  };
};

export default useSWRPostList;

이렇게 모듈화를 해 놓으면 무엇이 좋을까?
아래와 같이 번거롭게 작성해야 하는 것을…

const { data: initialPosts } = useSWR(
	    `/posts/channel/${process.env.REACT_APP_CHANNEL_ID_TOTAL}?offset=0&limit=5`,
	   );

이렇게 간단하게 작성할 수 있다.

const { data: initialPosts } = useSWRPostList();

코드가 짧아지고, 매직 넘버(key)를 좀 더 편리하게 관리할 수 있다.
이것만이 전부가 아니다.
해당 key의 캐시 데이터를 mutate하는 함수를 커스텀 훅에 정의할 수 있다.
예컨대 mutateLike라는 함수를 만들 수 있을 것이다.

import useSWR from 'swr';

export const postListKey = `/posts/channel/${process.env.REACT_APP_CHANNEL_ID}?offset=0&limit=5`;

const useSWRPostList = () => {
  const { data, error, isValidating, mutate } = useSWR(postListKey)

  const mutateLike = () => {
    // 좋아요(like) 등록 및 삭제 시 캐시 업데이트 로직 구현
  };

  return {
    data,
    error,
    isValidating,
    mutate,
    mutateLike, // 내보내기
  };
};

말했듯이, 캐시의 업데이트 로직은 꽤 복잡하기 때문에
이렇게 커스텀 훅으로 분리해 관리하는 것이 효과적인 방식이라고 생각한다.
사용하는 측에서는 아래와 같이 편리하게 사용할 수 있다.

const { mutateLike } = useSWRPostList();

mutate를 통한 캐시 제어

그럼 본격적으로 캐시 업데이트 로직을 작성해보도록 하자.
먼저 mutateLike 함수이다.
함수의 스펙은 아래와 같다.

mutateLike: (type, index, like)  void

1) type
두 가지 타입이 있다.
좋아요를 등록한 경우(LIKE), 좋아요를 취소한 경우(DISLIKE)

2) index
게시물의 인덱스 번호다.
index가 5 미만인 경우만, 즉 상위 5개의 게시물에 대해서만
mutate 함수를 호출할 계획이며, 전달받은 이 index를 통해서
어떤 게시물을 업데이트해야 하는지 알 수 있다.

3) like
like에 대한 정보를 담은 객체이며, 서버로부터 응답받는다.
구현에 있어 중요한 사항은 아니지만, 아래와 같은 정보들이 담겨 있다.

  {
    "_id": String,
    "user": String, 
    "post": String,
    "createdAt": String,
    "updatedAt": String
  }

이제 함수를 작성해 보자.
주석을 달아 코드를 설명하였다.

import useSWR from 'swr';

export const postListKey = `/posts/channel/${process.env.REACT_APP_CHANNEL_ID}?offset=0&limit=5`;

const useSWRPostList = () => {
  const { data, error, isValidating, mutate } = useSWR(postListKey);
  const { currentUser } = useUserContext(); // context API를 통해 유저의 정보를 구한다. 

  const mutateLike = (type, index, like) => {
    if (!data) {
      return;
    }
  
    // index를 통해 업데이트해야 하는 게시물을 currentPost로 얻는다. 
    // 이 currentPost를 기반으로 updatedPost를 구한다. 
    const initialPostList = [...data];
    const currentPost = initialPostList[index];
    let updatedPost;
  
    // 게시물의 좋아요 정보(likes 배열)을 업데이트한다. 
    switch (type) {
      case 'LIKE': {
        updatedPost = {
          ...currentPost,
          likes: [...currentPost.likes, like], 
        };
        break;
      }
      case 'DISLIKE': {
        updatedPost = {
          ...currentPost,
          likes: currentPost.likes.filter(({ user }) => user !== currentUser.id),
        };
        break;
      }
    }

    // 업데이트된 데이터
    const updatedPostList = [
      ...initialPostList.slice(0, index),
      updatedPost,
      ...initialPostList.slice(index + 1),
    ];

    // mutate를 사용하여 캐시를 업데이트한다. updatedPostList를 넘겨준다. 
    // 로컬에서 이미 계산했으므로, 서버에 유효성 검증은 요청하지 않는다. 
    mutate(updatedPostList, {
      revalidate: false,
    });
  };

  return {
    data,
    error,
    isValidating,
    mutate,
    mutateLike, // 반환하기
  };
};

export default useSWRPostList;

이 mutateLike 함수를 사용해보자.
좋아요가 눌리는 곳은 PostBody라는 컴포넌트이다.
아래 하늘색 영역에 해당하는 부분이다.

PostBody에는 좋아요 로직을 처리하는 handleClickLike라는 함수가 있는데,
여기에 mutateLike를 적용해보겠다.
추가한 부분은 주석으로 표시하였다.

// PostBody.jsx
// 중략...

const { mutateLike } = useSWRPostList(); // 추가한 부분

const handleClickLike = async () => {
    if (!token) {
      setModalOn(true);
      return;
    }

    setOnHeart(!onHeart);
    if (!onHeart) {
      setHeartCount(heartCount + 1);
      if (token && postId) {
        const { data } = await setLike(token, postId);
        likeId.current = data._id;
        if (currentUser.id !== author._id) {
          await setNotification(token, LIKE, likeId.current, author._id, postId);
        }
        index < 5 && mutateLike('LIKE', index, data); // 추가한 부분 
      }
    } else {
      setHeartCount(heartCount - 1);
      if (token && likeId.current) {
        await setDisLike(token, likeId.current);
        likeId.current = '';
        index < 5 && mutateLike('DISLIKE', index); // 추가한 부분
      }
    }
  };

상술했듯이 인덱스가 5 미만인 경우에 대해서만
즉 상위 5개의 게시물에 대해서만 mutateLike를 호출한다.
mutateLike를 호출하면서 type, index, like 등의 인자들을 넘겨주었다.

다음으로, 게시물이 삭제되었을 때를 처리하는
mutateDeletion 함수를 작성해보자.
이 역시 마찬가지로 useSWRPostList에 선언해서 사용할 수 있다.

// useSWRPostLike.js 
// 중략...

const mutateDeletion = (postId) => {
    const updatedPostList = data.filter(({ _id }) => _id !== postId);
    mutate(updatedPostList, {
      revalidate: true,
    });
  };

return {
  data,
  error,
  isValidating,
  mutate,
  mutateLike, 
  mutateDeletion // 반환하기
};

상위 5개의 게시물 리스트 중 삭제된 게시물을 제외시킨다.
역시 mutate로 캐시 데이터를 업데이트하는데,
이번에는 서버로 유효성 검증 요청을 보낸다.
왜냐하면, 캐시 데이터의 게시물이 4개로 줄어들었기 때문이다.

캐시를 로컬에서 즉시 업데이트하여 삭제한 것을 반영해주되,
서버에 요청을 보내어 5개의 게시물을 새롭게 불러온다.

mutateDeletion을 적용할 곳은 PostHeader라는 컴포넌트다.
주황색으로 표기된 영역이다.

이 PostHeader에는 게시물 삭제 로직을 처리하는 handleDelete라는 함수가 있는데,
여기에 mutateDeletion을 적용해보겠다.

// PostHeader.jsx
// 중략...

const { mutateDeletion } = useSWRPostList();

const handleDelete = async () => {
    setIsModal(false);
    if (localToken && postId) {
      await onDeletePost(postId);
      navigate(-1);
      index < 5 && mutateDeletion(postId); // 추가한 부분
    }
  };

역시 인덱스가 5 미만인 경우에만 mutateDeletion을 호출한다.
자! 이제 결과물을 보도록 하자. 깜빡임 현상이 사라졌을까?

좋아요 클릭 및 게시물 삭제 시
기존 데이터가 남아있어 깜빡거리던 현상이 사라지고
업데이트 사항이 즉시 반영되는 것을 볼 수 있다.
캐시를 로컬에서 즉시 업데이트하였기 때문이다.
이는 소소하지만 UX의 향상이라고 생각한다.

한편, 게시물을 생성할 때에도 캐시를 제어하는 로직을 구현하였으나,
글이 너무 길어질 듯 하여 여기서는 다루지 않겠다.

2. 첫 진입 시 캐싱 불가

두 번째 문제로, 처음 들어간 페이지는
캐싱이 되지 않은 상태이므로 여전히 속도가 느리다는 문제가 있었다.
특히 메인 페이지에서 상세 페이지로 진입할 때가 그렇다.

아래 영상을 참고해주시기 바란다.

그리고 하나 더, 상세 페이지에서는 여전히 깜빡임 현상이 나타난다.
왜냐하면, 상세 페이지의 캐시를 업데이트하는 로직은 구현하지 않았기 때문이다.
이를 따로 만들어도 되겠지만,
이번에는 SWR의 프리패칭을 적용하여
느린 로딩 속도와 깜빡임 현상을 한꺼번에 보완해보고자 한다.

데이터 프리패칭

프리패칭은 사용자가 게시물을 클릭하기 전,
해당 게시물의 데이터를 미리 로드함으로써 속도를 높이는 기술이다.
현재 유저는 게시물의 이미지를 클릭하여 상세 페이지로 이동한다.
그렇다면, 유저가 이미지에 마우스를 올려놓을 때
(아래 이미지의 파란색 영역에 마우스를 올려놓을 때)
데이터를 미리 로드한다면, 화면을 즉시 전환할 수 있지 않을까?

SWR에서 제공하는 프리패칭을 사용한다면 이것은 가능한 일이다.
그렇지만 동시에,
프리패칭으로 인한 부작용 및 비용을 고려하지 않을 수 없다.

예를 들어, 유저가 스크롤을 내리면서
게시물 이미지에 마우스를 스치기만 했는데도 데이터를 로드한다고 해 보자.
10개의 게시물을 내리는 동안, API 호출이 10번 발생할 것이다.
즉, 불필요한 API 요청이 과도하게 발생할 수 있으며,
이는 서버에 과부하를 야기할 것이다. 이것은 큰 문제이다.

물론 SWR은 key가 동일할 경우 중복 요청을 막아주는 등
최적화가 되어 있지만, 그럼에도 불구하고 위와 같은 비용을 고려해서
신중하게 접근하지 않을 수 없다.
그리하여 프리패칭을 사용은 하되,
마우스를 이미지에 올린 뒤 약 1초 후에 데이터를 로드할 것이다.
1초가 지나기 전 마우스가 이미지에서 벗어나면, 데이터 로드를 중단할 것이다.

(1초라는 수치는 어디까지나 나의 추정일 뿐이다.
유저 테스트를 통해서 더 적합한 수치를 찾아낼 수도 있을 것이라 생각한다.
하지만 이것은 나의 영역이 아니므로, 여기서는 패스하도록 하겠다)

구현 로직은 생각보다 간단하다.
아래와 같이 ImageWrapper에
onMouseEnter 또는 onTouchStart가 발생하면 prefetchPostData를 호출한다.
이 함수는 1초 후 해당 게시물의 상세 조회 API를 호출하여,
캐시 데이터를 갱신한다. 캐시 데이터가 존재하지 않는다면 새롭게 생성한다.

// PostBody..jsx 
// 중략...
const prefetchTimer = useRef(null);

const prefetchPostData = () => {
    if (!isDetailPage) {
      prefetchTimer.current = setTimeout(() => {
        mutate(`/posts/${postId}`, () => swrOptions.fetcher(`/posts/${postId}`));
      }, 1000);
    }
  };

  const cancelPrefetch = () => {
    clearTimeout(prefetchTimer.current);
  };

return (
  <ImageWrapper
        onClick={navigateToDetailPage}
        isDetailPage={isDetailPage}
        onMouseEnter={prefetchPostData }
        onMouseLeave={cancelPrefetch}
        onTouchStart={prefetchPostData }
        onTouchEnd={cancelPrefetch}
      >
        <Image
          src={image ? image : IMAGE_URLS.POST_DEFAULT_IMG}
        />
  </ImageWrapper>

그런데 만일 1초가 지나기 전,
onMouseLeave 또는 onTouchEnd가 발생한다면 cancelPrefetch를 호출한다.
이 함수는 clearTimeout을 통해 타이머 함수를 해제하고
API의 호출을 중단시킨다.
구현은 여기서 완료되었다. 결과물을 보도록 하자.

게시물에 마우스를 올린 후 바로 빠져나오면 API가 호출되지 않는다.
마우스를 올리고 1초가 지나면 데이터가 프리패칭된다.
그리고 해당 게시물을 클릭하면 렌더링이 즉시 가능하다.
데이터가 미리 패칭되었으므로, 기다릴 필요가 없기 때문이다.
예전과 비교하면 페이지 첫 진입이 확실히 빨라졌음을 체감할 수 있다.
더불어 깜빡임 현상도 사라졌다.

결론 및 돌아보기

이번 글에서는 SWR의 심화 기능인 mutate와 프리패칭을 사용해보았다.
“그런데 이 두 기능을 반드시 사용해야만 하는가?” 라고 묻는다면
그렇지는 않다고 생각한다.

mutate를 활용한 캐시 업데이트를 통해 깜빡임 현상을 해결할 수 있었지만,
거기에는 만만찮은 비용이 수반되었다.
로직이 복잡했기에 커스텀 훅으로 만들어 관리해야했으며,
업데이트에 걸리는 시간 등 오버헤드를 결코 무시할 수 없었다.
깜빡임 현상이 간과할 수 있는 수준이라면
그냥 두는 것이 오히려 낫다고 생각하며,
SWR의 갱신 주기를 적절히 조절하는 것으로 충분할 것이다.

프리패칭 또한 장단점이 극명한 기능이었다.
데이터를 미리 로드함으로써 즉각적인 렌더링을 가능하게 해주었지만,
불필요한 API 요청을 과도하게 발생시켜
서버에 과부하를 야기할 수 있는 잠재적 문제가 존재하였다.
기존처럼 스켈레톤 및 스피너 UI를 사용하는 방식이
좀 더 안전하고 비용이 적다고 할 수 있으며,
프리패칭은 꼭 필요한 경우에만 조심스럽게 사용하는 것이 필요할 것 같다.

요컨대, mutate와 프리패칭은
잘 사용하면 보약이지만, 잘못 사용하면 독약이 될 수 있기에
신중하게 사용하는 것이 반드시 필요하다고 생각한다.


학습을 진행하면서 작성한 글 입니다.
정확하지 않은 지식이 있을 수 있습니다. 참고로만 읽어주시기 바랍니다.