버그 제보

팀원으로부터 SWR로 인해 버그가 발생했다는 제보가 들어왔다.
useSWRInfinite를 사용해 무한스크롤을 구현하였는데,
특정 포스트에 좋아요를 누른 뒤,
잠시 다른 페이지로 이동했다가 다시 돌아오면,
조금 전에 누른 좋아요가 반영되지 않는다는 제보였다.
아래 영상을 참고해주시기 바란다.

그런데 흥미로운 것은,
다른 페이지로 또 한 번 이동했다가 다시 돌아오면
이번에는 좋아요가 정상적으로 반영된다는 것이다.
즉 첫 번째로 돌아올 때는 반영이 안 되고,
두 번째로 돌아올 때는 제대로 반영된다.

어째서 이런 일이 발생하는 것일까?

원인 분석

이번 팀 프로젝트에서 SWR의 도입을 주장한 것은 나였기에,
나에게도 책임이 없다고는 말할 수 없었다.
(물론, 팀원이 나를 탓한 것은 아니지만 말이다)
그래서 나는 원인 분석에 나섰다.

우선, SWR의 동작 원리를 다시 살펴보자.

  1. 캐시된 데이터가 있다면 먼저 가져온다.
  2. 서버에서 데이터의 업데이트 여부를 확인한다.
  3. 업데이트가 발생했다면 최신 데이터를 다시 가져온다.

로그를 찍어보면 이것은 정상적으로 이루어지고 있는 것으로 보인다.

첫 번째 포스트(이하 ReviewFeed)에 좋아요를 누른 뒤,
페이지를 나갔다가 다시 들어왔다고 가정해보자.

Untitled

로그를 찍어보자.
빨간색은 캐시된 데이터이고, 파란색은 최신 데이터이다.

Untitled

빨간색에서는 isLiked가 false이다. 예전 데이터(캐시)이다.
파란색에서는 isLiked가 true이다. 최신 데이터이다.

Untitled 2

Untitled 3

이와 같이 SWR은 정상적으로 데이터를 가져오고 있다.
즉, SWR이 버그의 원인은 아니라는 의미이다.
그렇다면 왜 해당 ReviewFeed에는 반영되지 않은 것일까?

일단 컴포넌트의 구조를 분석해보자.
현재 CommunityPage에 여러 개의 ReviewFeed가 속해 있는 구조이다.
이를 그림으로 나타내면 아래와 같다.

Untitled 4

코드의 핵심적인 부분은 아래와 같다.

// CommunityPage
// (중략)...
  {feeds.map((feed) => {
        const { reviewId, user } = feed;
        return (
          <ReviewFeed
            key={reviewId}
            feed={feed}
            isMyFeed={userId === user.userId}
            onDeleteButtonClick={handleDeleteButtonClick}
          />
      );
  })}

데이터가 담긴 feed 객체를 props로 넘겨주고 있다.
ReviewFeed에서는 이 feed를 구조 분해 할당하여 사용한다.

특히 isLiked와 likeCount에 주목해야 한다.
여기가 버그가 발생한 지점이기 때문이다.

isLiked와 likeCount를 useState를 통해 상태로 관리하면서
하위 컴포넌트인 InfoGroup에 props로 넘겨주는 방식을 취하고 있다.

// ReviewFeed
const ReviewFeed = ({ feed, isMyFeed, onDeleteButtonClick }: ReviewFeedProps) => {
  // (중략)...
	const {
	    exhibition,
	    reviewId,
	    isLiked,
	    likeCount,
	    title,
	    content,
	    createdAt,
	    user,
	    commentCount,
	    photos,
	  } = feed;

	const [isLikeFeed, setIsLikedFeed] = useState(isLiked);
	const [feedLikeCount, setFeedLikeCount] = useState(likeCount);

  // (중략)...

return (
    <>
       <InfoGroup
            isLiked={isLikeFeed}
            likeCount={feedLikeCount}
        />
    </>
  )
}

구조 분석은 끝났다.
그렇다면 isLiked와 likeCount가 변경되었음에도
해당 ReviewFeed가 리렌더링을 하지 않은 이유는 무엇일까?

가설1

처음에 들었던 생각은 이것이다.
SWR의 캐시 데이터, 최신 데이터의 feed 객체의 참조값은 동일하다.
만약 그렇다면, 객체의 속성(isLiked, likeCount)은 바뀌었으나
객체의 참조값이 동일하므로 ReviewFeed는 리렌더링을 하지 않았다”

하지만 그럴리가 없다.
로그를 찍어보면 캐시 데이터, 최신 데이터의 feed 객체는 참조값이 다르다.
이 둘은 명백히 다른 객체다. 이 가설은 틀렸다.

Untitled 5

const test = useRef();

useEffect(() => {
    if (feeds.length) {
      console.log(test.current === feeds[0]); // 객체의 참조값 비교: false
      test.current = feeds[0];
    }
  }, [feeds]);

가설2

“ReviewFeed는 배열의 요소이며, key={reviewId}이다.
key인 reviewId가 동일하므로 ReviewFeed는 리렌더링 되지 않았다
리액트에서 Key는 어떤 항목을 변경, 추가, 삭제할지 식별하는 데 사용된다.

배열에서 자식들이 key를 가지고 있다면,
리액트는 key를 통해 기존 트리와 이후 트리의 자식들의 일치 여부를 확인한다.

// CommunityPage
// (중략)...
  {feeds.map((feed) => {
        const { reviewId, user } = feed;
        return (
          <ReviewFeed
            key={reviewId}
            feed={feed}
            isMyFeed={userId === user.userId}
            onDeleteButtonClick={handleDeleteButtonClick}
          />
      );
  })}

하지만 key가 동일하다고 리렌더링이 아예 일어나지 않는 것은 아니다.
실제로, feed의 다른 속성들(title, content 등)이 수정될 경우에는
변경 사항이 정상적으로 반영된다.
이 가설은 틀렸다.

가설3

앞선 가설들로부터 다음의 결론을 내릴 수 있다.
“SWR이 캐시 데이터를 가져온 뒤, 이후 최신 데이터를 가져오면
ReviewFeed는 리렌더링이 된다.”

그렇다면, ReviewFeed가 리렌더링이 됨에도,
어째서 isLiked, likedCount의 변경사항은 반영되지 않는 것일까?
코드를 다시 보자.

const ReviewFeed = ({ feed, isMyFeed, onDeleteButtonClick }: ReviewFeedProps) => {
	const {
	    exhibition,
	    reviewId,
	    isLiked,
	    likeCount,
	    title,
	    content,
	    createdAt,
	    user,
	    commentCount,
	    photos,
	  } = feed;
  const [isLikeFeed, setIsLikedFeed] = useState(isLiked);
  const [feedLikeCount, setFeedLikeCount] = useState(likeCount);

	//....(중략)
}

혹시 눈치채셨는가?
isLiked와 likeCount는 useState의 초기값으로 사용되고 있다.
isLiked는 isLikeFeed의 초기값이고, likeCount는 feedLikeCount의 초기값이다.

isLiked와 likeCount의 값이 달라진다고 해서,
isLikeFeed와 feedLikeCount의 초기화가 다시 이루어지는 것은 아니다.
useState의 값 초기화는 마운트 시에만 발생하기 때문이다.
그렇기에 변경사항이 제대로 반영되지 않았다.

나의 세 번째 가설은 이것이다.
그렇다면 테스트를 해 보자.
useEffect를 사용해서 변경사항을 직접 반영해주는 것이다.
아래처럼 말이다.

useEffect(() => {
    setIsLikedFeed(isLiked);
    setFeedLikeCount(likeCount);
  }, [isLiked, likeCount]);

결과는 어떨까? 아래 영상을 참고해주시기 바란다.

버그가 해결되었다!
특정 포스트에 좋아요를 누른 후,
잠시 다른 페이지로 이동했다가 다시 돌아올 때,
조금 전에 누른 좋아요가 정상적으로 반영된다.
이 가설이 정답임을 알 수 있다.

다시 정리해 보면,

isLiked와 likeCount는 useState의 초기화로 사용되는데
이 두 값이 달라진다고 하여, 즉 리렌더링이 된다고 하여
useState의 초기화가 다시 이루어지는 것은 아니다.
그렇기에 변경사항이 반영되지 않았다.

결론

이번 버그를 추적 및 해결하면서 아래의 내용을 배울 수 있었다.

  1. SWR의 캐시 데이터를 다룰 때에는 주의해야 한다.
    최신 데이터가 정상적으로 반영되고 있는지 확인해야 한다.

  2. 리액트에서 key가 동일하다고
    리렌더링이 아예 발생하지 않는 것은 아니다.

  3. useState의 초기화는 마운트가 될 때 한 번 이루어진다.
    리렌더링 시에는 그렇지 않다.

더 알아보기

SWR의 mutate를 사용한다면 캐시 데이터를 더 효과적으로 관리할 수 있을 것이다.
이에 대해서 더 알아보자.

시작하기 - SWR
리스트와 Key - React


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