React

useAsync 파해치기

한우코딩 2025. 3. 18. 16:53

useRef 파해치기 와 이어집니다

useAsync 훅 확인하기

useAsync 훅 분석

import { useEffect, useRef, useState } from 'react'

export function useAsync<T>(
  func: () => Promise<T> | undefined | null,
  deps = [],
) {
  const ref = useRef(func)
  ref.current = func
  const [value, setValue] = useState<T>()

  useEffect(() => {
    ref.current()?.then(setValue)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  return value
}

이 코드의 핵심 동작을 단계별로 보면

1. ref 객체 생성과 최신 함수 참조 유지

const ref = useRef(func)
ref.current = func

이 두 줄이 이 훅에서 가장 중요한 부분.

  • 첫 번째 줄(const ref = useRef(func))은 초기 렌더링 시 mountRef() 함수를 통해 { current: func } 형태의 ref 객체를 생성합니다.

  • 두 번째 줄(ref.current = func)은 매 렌더링마다 ref.current를 최신 func 함수로 업데이트합니다. 이것은 컴포넌트가 리렌더링될 때마다 실행됩니다.

2. useEffect에서의 ref 사용

useEffect(() => {
  ref.current()?.then(setValue)
}, deps)

useEffectdeps 배열의 값이 변경될 때만 실행됩니다. 그러나 중요한 점은 ref.current를 통해 항상 최신 함수를 참조한다는 것입니다.

왜 useRef를 사용했는가?

이 코드에서 useRef를 사용한 두 가지 주요 이유를 이해하기 위해 React의 내부 동작을 생각해봅시다

1. 최신 함수 참조 유지

먼저 일반적인 클로저 문제를 살펴보겠습니다. React에서 함수형 컴포넌트는 매 렌더링마다 새로 실행됩니다. 그리고 useEffect의 콜백은 해당 렌더링 시점의 값들을 클로저로 캡처합니다.

// 클로저 문제가 있는 예시
useEffect(() => {
  func()?.then(setValue) // 항상 첫 렌더링 시점의 func를 참조
}, [deps]) // func를 의존성 배열에 넣지 않음

이 경우, useEffect 내부의 func는 항상 첫 렌더링 시점의 함수를 참조합니다. 만약 부모 컴포넌트가 이 함수를 새로 만들어 전달한다면, deps가 변경되어 useEffect가 재실행되더라도 여전히 오래된 함수를 사용하게 됩니다.

useRef를 사용하면 이 문제를 해결할 수 있습니다:

  1. ref.current = func는 매 렌더링마다 최신 함수로 업데이트됩니다.
  2. useEffect 내부에서는 ref.current를 통해 항상 최신 함수를 참조합니다.

2. 리렌더링 방지

만약 funcuseEffect의 의존성 배열에 넣는다면:

useEffect(() => {
  func()?.then(setValue)
}, [func, ...otherDeps]) // func를 의존성 배열에 포함

이렇게 하면 func가 변경될 때마다 useEffect가 재실행됩니다. 문제는 React에서 컴포넌트가 리렌더링될 때마다 함수가 새로 생성된다는 것입니다. 이는 불필요한 useEffect 실행과 최악의 상황에서는 무한 루프를 초래할 수 있습니다.

useRef를 사용하면

  1. ref.current의 변경은 리렌더링을 트리거하지 않습니다(React의 updateRef() 함수는 기존 ref 객체를 그대로 반환).
  2. 함수가 새로 생성되더라도 ref.current에 할당하는 것은 리렌더링을 유발하지 않으므로 안전합니다.

내부 동작 상세 설명

React의 내부 구현 관점에서 볼 때

  1. 최초 렌더링 시 mountRef(func)가 호출되어 { current: func } 객체가 생성됩니다.
  2. 이후 렌더링에서는 updateRef(func)가 호출되지만, 반환값은 여전히 첫 렌더링에서 생성된 동일한 ref 객체입니다.
  3. ref.current = func 코드는 단순히 JavaScript 객체의 속성을 변경하는 것이며, React의 상태 시스템(useState, useReducer 등)을 거치지 않기 때문에 리렌더링을 유발하지 않습니다.
  4. useEffect 내부에서는 ref.current를 통해 항상 최신 함수에 접근할 수 있습니다.

정리: useRef 사용의 두 가지 이점

  1. 최신 함수 참조 ref.current는 항상 최신 함수를 가리킵니다. 이는 useEffect의 의존성 배열에 함수를 포함시키지 않으면서도 항상 최신 함수를 사용할 수 있게 합니다.

  2. 불필요한 리렌더링 방지 ref.current의 변경은 리렌더링을 유발하지 않기 때문에, 함수가 매 렌더링마다 새로 생성되더라도 안전하게 최신 참조를 유지할 수 있습니다.

이러한 방식으로 useAsync 훅은 비동기 함수를 안전하게 실행하면서, 불필요한 리렌더링과 클로저 문제를 모두 피할 수 있는 훅이었습니다

다른 방식과 예시

이해를 돕기 위해 useRef를 사용한 방식과 그렇지 않은 방식의 예시를 비교해 보겠습니다.

예시 : 데이터 가져오기

사용자 ID가 변경될 때마다 사용자 정보를 가져오는 컴포넌트를 만들어 보겠습니다.

예시 1: useRef를 사용하지 않은 방식

function UserProfile({ userId, onDataFetched }) {
  const [user, setUser] = useState(null);

  // 사용자 데이터를 가져오는 함수
  const fetchUserData = async () => {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
    onDataFetched(data); // 부모 컴포넌트에 데이터가 로드됐음을 알림
  };

  useEffect(() => {
    fetchUserData();
  }, [userId]); // fetchUserData는 의존성 배열에 포함되지 않음

  return <div>{user ? user.name : "로딩 중..."}</div>;
}

이 코드의 문제점

  1. 클로저 문제 useEffect 내부의 fetchUserData는 첫 번째 렌더링 시점의 함수를 참조합니다. 만약 onDataFetched 콜백이 변경된다면, fetchUserData 함수는 여전히 예전 onDataFetched 함수를 호출합니다.

  2. 불완전한 의존성 배열 ESLint 규칙은 fetchUserData를 의존성 배열에 포함하라고 경고할 것입니다.

예시 2: fetchUserData를 의존성 배열에 추가한 경우

function UserProfile({ userId, onDataFetched }) {
  const [user, setUser] = useState(null);

  // 사용자 데이터를 가져오는 함수
  const fetchUserData = async () => {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
    onDataFetched(data);
  };

  useEffect(() => {
    fetchUserData();
  }, [userId, fetchUserData]); // fetchUserData를 의존성 배열에 추가

  return <div>{user ? user.name : "로딩 중..."}</div>;
}

이 코드의 문제점

  1. 무한 루프 fetchUserData는 매 렌더링마다 새로 생성됩니다. 의존성 배열에 이 함수를 포함시키면, 다음과 같은 순환이 발생합니다:

    • 컴포넌트 렌더링 → 새 fetchUserData 생성 → useEffect 실행 → setUser 호출 → 컴포넌트 리렌더링 → 새 fetchUserData 생성 → ...
  2. 불필요한 API 호출 사용자 ID가 변경되지 않았음에도 불구하고 매 렌더링마다 API 호출이 발생합니다.

예시 3: useCallback으로 해결 시도

function UserProfile({ userId, onDataFetched }) {
  const [user, setUser] = useState(null);

  // useCallback으로 함수 메모이제이션
  const fetchUserData = useCallback(async () => {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
    onDataFetched(data);
  }, [userId, onDataFetched]);

  useEffect(() => {
    fetchUserData();
  }, [fetchUserData]);

  return <div>{user ? user.name : "로딩 중..."}</div>;
}

이 접근 방식의 문제점

  1. 의존성 전파 onDataFetched가 변경될 때마다 새로운 fetchUserData가 생성되고, 이로 인해 useEffect가 다시 실행됩니다. 부모 컴포넌트가 리렌더링될 때마다 새로운 onDataFetched 함수를 생성하면(인라인 함수 사용 시 흔한 패턴), 이 문제가 더 심각해집니다.

  2. 복잡성 증가 간단한 작업을 위해 useCallback을 추가로 사용해야 합니다.

예시 4: useRef를 사용한 해결책 (useAsync 훅 사용)

function useAsync(func, deps = []) {
  const ref = useRef(func);
  ref.current = func; // 최신 함수 참조 유지
  const [state, setState] = useState();

  useEffect(() => {
    ref.current()?.then(setState);
  }, deps);

  return state;
}

function UserProfile({ userId, onDataFetched }) {
  // useAsync 훅 사용
  const user = useAsync(async () => {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    onDataFetched(data);
    return data;
  }, [userId]); // onDataFetched는 의존성 배열에 포함되지 않음

  return <div>{user ? user.name : "로딩 중..."}</div>;
}

이 접근 방식의 장점

  1. 항상 최신 함수 사용 ref.current는 항상 최신 함수를 참조하므로, 최신 onDataFetched 콜백을 사용합니다.

  2. 불필요한 API 호출 방지 userId가 변경될 때만 API 호출이 발생합니다. onDataFetched가 변경되더라도 추가 API 호출은 없습니다.

  3. 의존성 배열 단순화 onDataFetched를 의존성 배열에 포함시킬 필요가 없습니다.

  4. 코드 가독성 향상 커스텀 훅으로 로직을 추상화하여 컴포넌트 코드가 더 깔끔해집니다.

실제 동작 비교

이제 실제로 어떻게 동작하는지 시간에 따른 시퀀스로 비교해 보겠습니다:

부모 컴포넌트가 리렌더링되어 새 onDataFetched 함수가 생성됨

useRef를 사용하지 않은 접근법 (예시 1)

  1. 부모 컴포넌트 리렌더링
  2. onDataFetched 함수 생성 및 자식 컴포넌트로 전달
  3. 자식 컴포넌트 리렌더링
  4. fetchUserData 함수 생성
  5. useEffect는 재실행되지 않음 (의존성 배열에 userId만 포함됨)
  6. 데이터 로드 완료 후 이전 onDataFetched 함수 호출 (클로저 문제)

useRef를 사용한 접근법 (예시 4)

  1. 부모 컴포넌트 리렌더링
  2. onDataFetched 함수 생성 및 자식 컴포넌트로 전달
  3. 자식 컴포넌트 리렌더링
  4. 새 비동기 함수 생성
  5. ref.current가 새 함수로 업데이트됨
  6. useEffect는 재실행되지 않음 (의존성 배열에 userId만 포함됨)
  7. 기존 비동기 작업이 완료되면 ref.current()를 통해 최신 함수 호출

이 비교를 통해 useRef를 사용한 접근법이 클로저 문제를 어떻게 해결고 불필요한 리렌더링과 API 호출을 방지하는지 알 수 있을겁니다.

정리

useRef를 사용한 접근법은 다음과 같은 상황에서 유용할듯 합니다

  1. 비동기 작업에서 항상 최신 함수나 값을 참조해야 할 때
  2. 의존성 배열에 함수를 포함시키지 않으면서도 클로저 문제를 해결하고 싶을 때
  3. 불필요한 리렌더링이나 API 호출을 방지하고 싶을 때