[Next.js] INP를 넘어 UX까지 두마리 토끼 잡기 🐰

xoxristine·2024년 4월 11일
3

REINPUT

목록 보기
3/3
post-thumbnail

이전 포스트에서 검색 페이지의 INP 지표를 개선하기 위해 useTransition으로 입력 state를 변경하는 함수의 우선순위를 낮게 지정해 presentation delay를 줄였다.
그리고 isPending을 사용해서 pending 상태일 때 로딩 화면을 보여주는 것으로 INP 지표를 개선했다.

하지만 이로 인한 문제가 하나 있었는데..

❓ 이전 데이터가 보이는 문제

입력이 들어오면 로딩 화면 ➡️ 새로운 결과 리스트 순서로 보여줘야 하는데 로딩 화면 ➡️ 이전 결과 리스트 ➡️ 새로운 결과 리스트를 보여주는 문제가 있었다.

문제가 되는 동작 화면은 다음과 같다.

  • 문제가 되는 코드
const [isPending, startTransition] = useTransition();
const typingKeyword = (inputValue: string) => {
    startTransition(() => {
      setKeyword(inputValue);
    });
 };

return (
  <ResultSection>
    {!isPending ? (
      searchData.map((value, i) =>
        $isSmall ? (
          <InsightCard key={i} insightData={value} />
        ) : (
          <SummaryInsightCard
            key={i}
            favicon="/svg/insight-favicon.svg"
            insightData={value}
          />
        )
      )
    ) : (
      <Image
        src={defaultImage}
        alt="default"
        width={400}
        height={400}
        className="thumbnail"
      />
    )}
  </ResultSection>
)

🔎 문제 원인 찾기

1. isPending

키워드 state가 변경되는 시점과 관련된 문제일까 싶어서 isPending이 false가 된 순간 state가 이미 변경되었음이 확실한지 테스트 해봤다.

useEffect(() => {
    console.log(keyword, isPending);
  }, [keyword, isPending]);
  • 예상 결과
    예상대로 동작했다면 a를 입력했을 때 아래 표와 같이 state가 먼저 바뀌고, isPending이 false로 바뀌어야한다.
stateisPending
jtrue
jafalse
  • 실제 결과

예상 결과와 일치하게 나왔다.
isPending이 false로 바뀌었을 때 state도 새로운 키워드로 변경되는 것으로 보아 두 변경 시점이 일치하기 때문에 문제의 원인은 API로 받아오는 데이터가 변경되는 시점에 있는 것으로 판단했다.
다시 말해 데이터 fetching에 소요되는 시간으로 인해 이전 결과가 먼저 보여진 이후에 새로운 결과가 보여지는 것이라고 생각했다.

2. isSuccess

따라서 mutation으로 인한 데이터 변경을 기다리기 위해 useMutation의 isSuccess를 사용해보았다.

const FolderSearch: NextPage = ({}) => {
  //생략

  return (
    <>
      {!isPending && isSuccess ? (
        data?.map((value, i) =>
          $isSmall ? (
            <InsightCard key={i} insightData={value} />
          ) : (
            <SummaryInsightCard
              key={i}
              favicon="/svg/insight-favicon.svg"
              insightData={value}
            />
          )
        )
      ) : (
        <Image
          src={defaultImage}
          alt="default"
          width={400}
          height={400}
          className="thumbnail"
        />
      )}
    </>
  );
};

코드를 수정했으나 아무런 변화가 없었다.
isPendingfalse로 바뀐 직후에는 아직 mutation이 진행되지 않아 isSuccess에 이전 mutation에 대한 true 값이 남아있었다. 이로 인해 이전 결과 화면이 다시 한 번 화면에 보이게 되고, 새로운 mutation이 시작되고 나서야 isSuccessfalse로 바뀌어서 로딩 이미지가 또 나타난다. 잠시 후 데이터를 성공적으로 받아오고 나서 isSuccess가 다시 true로 바뀌고, 그제서야 새로운 결과 화면이 보여진다.

  • 변화 순서
isPendingisSuccess화면 상태
truetrue로딩 화면
falsetrue예전 결과 화면
falsefalse로딩 화면
falsetrue새로운 결과 화면

isPending, isSuccess가 바뀌었을 때 콘솔을 확인해보니 위 표와 같게 나왔고, 두 상태로는 문제를 개선할 수 없음을 확신하게 되었다.

대신 isPending이 false로 바뀌었을 때는 새로운 결과 데이터가 반영이 안되어있다는게 문제였고, 이는 mutation이 비동기로 작동하기 때문임을 확신하게 되었다.

이를 통해 문제 원인을 아래와 같이 정리할 수 있었다.

❗️ 문제 원인 정리

현재 키워드가 바뀌었을 때의 동작 순서는 아래와 같다.

  1. startTransition 호출
  2. isPending true로 변경
  3. setKeyword 호출
  4. isPending false로 변경 & state 변경
  5. isPending false로 바뀌어서 예전 결과 리스트 보여짐
  6. mutate 호출돼서 새로운 data 받아옴
  7. 새로운 리스트 화면 보이게 됨

mutation이 비동기로 동작하기 때문에 새로운 검색 결과를 받아오기 전에 [5번] isPending이 false로 바뀌어 이전 검색 결과를 먼저 보여주기 때문에 발생한 문제였다.

🔖 시도한 방법

1. setResult 적용

useEffect를 사용해서 isSuccess가 true로 바뀌고, API로 새로운 데이터를 받아왔을 때만 result를 업데이트하도록 했다.

const FolderSearch: NextPage = ({}) => {
  // 생략
  const [result, setResult] = useState<FolderSearchPostResponse>();

  useEffect(() => {
    mutate(keyword);
  }, [keyword]);

  useEffect(() => {
    isSuccess && setResult(data);
  }, [data, isSuccess]);

  const typingKeyword = (inputValue: string) => {
    startTransition(() => {
      setKeyword(inputValue);
    });
  };
  
  return (
    <>
     // 생략
          <ResultSection>
            {result?.map((value, i) =>
              $isSmall ? (
                <InsightCard key={i} insightData={value} />
              ) : (
                <SummaryInsightCard
                  key={i}
                  favicon="/svg/insight-favicon.svg"
                  insightData={value}
                />
              ),
            )}
          </ResultSection>
    </>
  );

이렇게 구현하면 예전 화면을 보여주는 깜빡임도 없고, API를 호출해서 새로운 데이터로 바뀌었을 때만 화면에 반영된다.

하지만 위 사진처럼 다시 INP 시간이 늘어났다.

그래서 혹시 그럼 로딩 아이콘이 어딘가(키워드 검색 박스쪽)에 위치한다면, 사용자 Interaction에 바로 대응하는 화면이 있으니까 INP 지표도 개선되지 않을까 시도해보았지만 개선되지 않았다. 화면 반응성은 좋았으나 특히 processing delay가 길었다.

이에 따라 내린 결론은 ResultSection 안에 있는 인사이트 리스트들이 다른 state가 바뀔 때마다 렌더링이 반복되는데, 이 비용이 많이 드는 컴포넌트 때문에 INP 시간이 늘어나니까 리렌더링을 줄여야한다는 것이었다.

이전에는 isPending을 사용해서 리렌더링이 될 때 pending 상태라면 로딩 화면을 보여줬지만, isPending은 새로운 데이터가 오기도 전에 false로 바뀌니까 더 이상 isPending을 사용할 수 없었다.

따라서 INP 시간을 줄이기 위해 ResultSection 렌더링 횟수 줄이기 & 이전 결과 화면을 보이지 않게 하기 위해 새로운 데이터를 받아왔을 때만 화면에 반영되게 하기 ➡️ useMemo 라는 결론을 내렸다.

2. useMemo 적용 (결론)

let count = 0;
let memoCount = 0;

const FolderSearch: NextPage = ({}) => {
  count++;
  console.log('rerender: ', count);
  const [keyword, setKeyword] = useState<string>('');
  const { data, mutate, isSuccess } = useSearchFolder();
  const [result, setResult] = useState<FolderSearchPostResponse>();
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    mutate(keyword);
  }, [keyword]);

  useEffect(() => {
    isSuccess && setResult(data);
  }, [data, isSuccess]);

  const typingKeyword = (inputValue: string) => {
    startTransition(() => {
      setKeyword(inputValue);
    });
  };

  const resultSection = useMemo(() => {
    memoCount++;
    console.log('memo rerendered: ', memoCount);
    return (
      <ResultSection>
        {result?.map((value, i) =>
          $isSmall ? (
            <InsightCard key={i} insightData={value} />
          ) : (
            <SummaryInsightCard
              key={i}
              favicon="/svg/insight-favicon.svg"
              insightData={value}
            />
          ),
        )}
      </ResultSection>
    );
  }, [result]);

  return (
    <>
      <NavigationLayout>
        <Header title="검색" />
        <Wrapper>
          <SearchSection>
            <SearchIcon />
            <SearchInput
              placeholder="인사이트 검색"
              autoFocus
              onChange={(e) => typingKeyword(e.target.value)}
            />
          </SearchSection>
          {resultSection}
        </Wrapper>
      </NavigationLayout>
    </>
  );
};

export default FolderSearch;

키워드를 입력했을 때 이전 결과가 깜빡이는 현상도 없어지고 자연스럽게 검색 결과를 보여준다.

그리고 INP 지표도 개선된 것을 볼 수 있었다.

왜 useMemo 사용 이전에는 리렌더링이 많이 일어났을까?

useMemo 사용 전에는 keyword가 바뀔 때마다 관련 state가 바뀌고, 그때마다 비용이 많이 드는 ResultSection 컴포넌트를 그렸지만, useMemo 사용 후
결과적으로 result 데이터가 바뀌었을 때만 비용이 많이 드는 컴포넌트를 그려주게 해서 리렌더링을 줄였다.

콘솔설명
5 -> strictmode
6 -> 키보드 입력
7 -> 키보드 입력으로 인한 setKeyword
8 -> strictmode
9 -> result 변화로 인해 재렌더링
3 -> useMemo 안에 console

리렌더링이 일어날 때마다 콘솔을 확인해보면 위 표와 같다.
그리고 useMemo를 쓰기 전, 후 비용이 많이 드는 ResultSection의 렌더링이 언제 되는지는 아래 표와 같이 정리해볼 수 있다.

ResultSection 렌더링 O/XuseMemo XuseMemo O
6 -> 키보드 입력OX
7 -> 키보드 입력으로 인한 setKeywordOX
9 -> result 변화로 인해 재렌더링OO

이렇게 INP와 UX 문제는 useTransition & useMemo로 해결하게 되었다!

🏁 결론

useTransition과 isPending을 사용해서 INP를 개선했지만 이전 결과 화면이 보이는 문제를 확인했을 때 어떻게 해결해야 할지 막막하기도 했다.
하지만 isPending, isSuccess (mutation) 의 변경 시점을 확인해나가면서 문제를 해결할 수 있는 포인트를 찾게 되었을 뿐만 아니라 이 개념들에 대해서도 확실하게 공부하게 된 좋은 기회였다고 생각한다.
INP & UX 두 마리 토끼🐰를 다 잡을 수 있어서 뿌듯하고 역시 기본이 중요하다고 느꼈다!

profile
🔥🦊

0개의 댓글