기록

무한 스크롤에서 만난 클로저, 상태 업데이트

als982001 2023. 7. 23. 21:12

 클로저에 대해서는 몇 번 들어서 알고는 있었다. 하지만 이런 식으로 클로저라는 개념을 다시 만나게 될 것이라 생각지도 못했다. 트위터를 클론코딩하는 개인 프로젝트를 진행하던 중, 의도한 것과 다르게 동작해서 골치가 아픈 상황이었다.

 

// Twits.tsx
import styles from "./Twits.module.css";
import { useEffect, useRef, useState } from "react";
import Twit from "./Twit";
import useGetTwits from "@/Hooks/useGetTwits";

const options = {
  threshold: 0.5,
};

export default function Twits() {
  const { twits, isLoading, moreLoading, getAdditionalTwits } = useGetTwits();

  const bottomRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(getAdditionalTwits, options);
    observer.observe(bottomRef.current as any);

    return () => {
      observer.unobserve(bottomRef.current as any);
    };
  }, []);

  return (
    <>
      {isLoading ? (
        <h1>Loading...</h1>
      ) : (
        twits.map((twit, index) => (
          <Twit key={twit.id + String(index)} twit={twit} />
        ))
      )}
      <section
        ref={bottomRef}
        style={{
          width: "100%",
          height: "100px",
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          backgroundColor: "pink",
        }}
      >
        더 보기
      </section>
    </>
  );
}

우선, Twits.tsx 파일이다. 이 파일은 트위터 메인 화면에서 여러 트윗들을 보여주기 위한 코드이다. "더 보기"라는 문자열을 children으로 갖는 section이 노출될 때마다, getAdditionalTwits라는 콜백 함수를 실행한다. 그리고 함수 getAdditionalTwits가 담겨 있는 useGetTwits는 다음과 같다.

 

import { useEffect, useRef, useState } from "react";

interface ITwit {
  createdDate: string;
  email: string;
  imageUrl: string;
  twit: string;
  id: string;
  author: string;
}

export default function useGetTwits() {
  const [twits, setTwits] = useState<ITwit[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const moreLoading = useRef(true);
  
  // 생략

  const getAdditionalTwits = async () => {
    console.log("가져오기 시작!!!");

    if (moreLoading.current === false) {
      moreLoading.current = true;

      const additionalTwits = await (
        await fetch("http://localhost:3000/api/twits", {
          method: "GET",
        })
      ).json();

      const allTwits = [...twits, ...additionalTwits];

      setTwits(allTwits);

      moreLoading.current = false;
    }
  };

  useEffect(() => {
    console.log(twits);
  }, [twits]);

  return { twits, isLoading, moreLoading, getAdditionalTwits };
}

getAdditionalTwits 함수의 흐름은 다음과 같다.

  1. moreLoading의 값이 false일 경우에만 동작한다. (moreLoading은 추가적인 트윗을 가져오는 중일 때 true라는 값을 가진다.)
  2. http://localhost:3000/api/twits에 GET 요청을 보내어 추가적인 트윗을 받아 이를 json을 통해 배열로 만든다.
  3. 이를 기존 twits에 추가한다. twits는 화면에 렌더링할 트윗들이 담겨진 state이다.
  4. moreLoading의 값을 false로 변경한다.

이 때, 제대로 추가가 되었는지 확인하기 위해 useEffect를 통해 twits가 변경될 때마다 출력했다. 하지만 twits는 아래 움짤과 같이 제대로 변경되지 않았다.

스크롤을 끝까지 내릴 경우 전술했던 section이 보이면서 추가 트윗을 받아오고 모든 트윗이 담긴 배열에 추가되어야 한다. 하지만 보다시피 twits의 값은 변경되지 않았다. 구글링을 해봐도 도저히 알 수가 없어서 chatGPT에게 물어보았다. 그랬더니 이 현상은 클로저와 관련이 있다고 했다.

네, 좋습니다. 이 문제를 이해하는 데 도움이 될 것이라고 생각하는 몇 가지 중요한 개념에 대해 설명하겠습니다.
클로저(closure): 클로저는 자바스크립트에서 함수와 그 함수가 선언된 어휘적 환경(Lexical environment)의 조합입니다. 함수가 선언된 시점의 환경에 있는 모든 변수를 "기억"하고, 이 변수들에 접근할 수 있습니다. getAdditionalTwits 함수가 처음 호출될 때 twits의 초기 값을 "기억"하는 것은 이런 클로저의 특성 때문입니다.
React 훅: React 훅은 컴포넌트가 렌더링될 때마다 새로 호출되고, 그 과정에서 새로운 클로저를 생성합니다. 즉, useEffect의 콜백 함수나 getAdditionalTwits와 같은 커스텀 훅 안의 함수는 컴포넌트가 리렌더링될 때마다 새로운 버전이 생성됩니다.
그런데, getAdditionalTwits는 IntersectionObserver의 콜백으로 사용되고 있으며, 이 콜백은 컴포넌트가 렌더링될 때 한 번만 등록됩니다. 따라서 이 콜백 함수는 등록된 시점의 클로저를 계속 유지하게 되고, 그로 인해 초기의 twits 값을 계속 "기억"하게 됩니다.
setTwits를 함수를 전달하는 방식으로 호출하는 것과 직접적으로 상태 값을 전달하는 것 사이의 차이는 이렇습니다:
setTwits(allTwits): 여기서 allTwits는 getAdditionalTwits 함수가 실행될 때의 twits 값을 기반으로 계산됩니다. 그런데, 이 getAdditionalTwits 함수는 IntersectionObserver의 콜백으로 사용되므로, 초기의 twits 값을 계속 기억하고 있습니다. 따라서 allTwits는 항상 초기 twits 값에 새로운 트윗을 추가한 값이 됩니다.setTwits(prev => { ... }): 이 경우, setTwits 함수는 이전 상태 값을 인자로 받는 업데이트 함수를 받습니다. React는 이 함수를 호출할 때 최신 상태 값을 제공하므로, prev는 항상 최신 ` 

// 네트워크 에러로 답변이 중단되어 답변을 다시 생성했습니다.

 먼저 클로저에 대한 질문에 대해 답변하겠습니다. 클로저는 자바스크립트에서 함수와 그 함수가 선언된 렉시컬 스코프(lexical scope)를 결합한 특별한 개념입니다. 함수가 선언될 때 그 위치의 스코프에 있는 모든 변수에 대한 참조를 포함하게 되는데, 이것이 클로저입니다. 즉, 함수는 자신이 생성될 때의 환경을 "기억"하게 됩니다.
 여기서 주요한 것은, 클로저가 변수의 현재 상태가 아닌 그 시점의 상태를 "기억"한다는 점입니다. 이는 getAdditionalTwits 함수에서 발생한 문제와 직접적으로 연관되어 있습니다. 이 함수가 선언되었을 때의 twits 배열의 상태를 "기억"하게 되는데, 이후에 twits의 상태가 변하더라도 이 함수는 여전히 초기의 twits 상태를 참조하게 됩니다.
두 번째 질문에 대한 답변은 다음과 같습니다.
setTwits(allTwits)와 setTwits(prev => [...prev, ...additionalTwits]) 둘 다 상태를 업데이트하긴 하지만, 이 둘은 한 가지 중요한 차이가 있습니다.
 setTwits(allTwits) : 이 경우 allTwits는 이미 계산된 값입니다. 즉, allTwits가 계산될 때, twits는 그 시점의 상태를 가지고 있습니다. 만약 다른 곳에서 twits 상태가 변경되었다면 그 변경사항은 allTwits에 반영되지 않습니다.
 setTwits(prev => [...prev, ...additionalTwits]) : 이 경우 함수를 상태 업데이트 함수에 전달하고 있습니다. 이 함수는 React에 의해 호출되며, prev 파라미터로 항상 최신 상태 값을 받게 됩니다. 이렇게 하면, setTwits가 호출될 때마다 항상 최신 상태를 반영할 수 있게 됩니다.
 따라서, setTwits(prev => [...prev, ...additionalTwits])의 형태를 사용하면 상태 업데이트가 항상 최신 상태를 바탕으로 이루어지게 되며, 이는 클로저로 인한 문제를 방지하게 됩니다.

 간단히 말하자면, getAdditionalTwits 함수는 IntersectionObserver의 콜백함수로 이용된다. 이 때, getAdditionalTwits는 초기 twits의 값을 기억한 상태로 클로저를 유지하게 된다. 그렇기에 아래의 코드에서 twits는 항상 초기 twits의 값을 가져 내가 의도한대로 동작하지 않았다는 것이다.

const allTwits = [...twits, ...additionalTwits]; // twits는 초기값임
setTwits(allTwits);

 

그렇기에 twits를 변경하는 부분을 아래처럼 바꿔줘야 한다.

const getAdditionalTwits = async () => {
	console.log("가져오기 시작!!!");

    if (moreLoading.current === false) {
      moreLoading.current = true;

      const additionalTwits = await (
        await fetch("http://localhost:3000/api/twits", {
          method: "GET",
        })
      ).json();
	
	// 변경된 부분
      setTwits((prev) => {
        const allTwits = [...prev, ...additionalTwits];
        return allTwits;
      });

      moreLoading.current = false;
    }
};

setTwits 부분을 변경해주었다. 변경 전에는 기존에 기억하고 있던 twits를 이용해 allTwits를 만들고, 이를 twits에 다시 반영한다. 그렇기에 값이 변하지를 않았다. 하지만 바뀐 setTwits는 최신 상태 값을 이용해 계산을 하게 된다. 이에 대한 결과는 다음과 같다.