Cllaude99Cllaude99

useEffect안에 비동기 코드를 적을 수 없는 이유

·9 min read·Cllaude99
ReactuseEffectAsync

React에서는 useEffect를 사용하여 컴포넌트가 마운트될 때 비동기 요청을 통해 데이터를 가져올 수 있습니다. 하지만 이때 useEffect의 콜백함수로 비동기 함수를 넣으면 에러가 발생합니다.

// 이렇게 하면 에러가 발생합니다.
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

즉, 위 코드와 같이 작성하게 되는 경우 다음과 같은 에러 메세지를 확인할 수 있습니다.

useEffect 에러 메시지

Warning: useEffect must not return anything besides a function, which is used for clean-up. It looks like you wrote useEffect(async () => ...) or returned a Promise.

해당 에러를 마주쳤을 때 "왜 useEffect의 콜백함수로 비동기 함수를 직접 사용할 수 없는 걸까?"라고 생각하며 의문이 들었습니다. 그래서 이번 글에서는 이러한 제약이 왜 존재하는지, 그리고 어떻게 해결해야 하는지 알아보겠습니다.

useEffect의 cleanup 함수

이 문제를 이해하려면 먼저 useEffect의 cleanup 메커니즘을 알아야 합니다.

useEffect는 부수 효과(side effect)를 처리하는 훅입니다. 이때 많은 부수 효과들은 정리(cleanup)가 필요합니다.

// 이벤트 리스너를 추가했다면, 제거해야 합니다
useEffect(() => {
  const handleResize = () => console.log('resized');
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize); // cleanup
  };
}, []);
// 타이머를 설정했다면, 정리해야 합니다
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);

  return () => {
    clearInterval(timer); // cleanup
  };
}, []);

useEffect의 콜백 함수는 위 코드의 예시와 같이 cleanup 함수를 반환할 수 있습니다. 이러한 cleanup 함수는 컴포넌트가 언마운트되거나 다음 effect가 실행되기 전에 호출되어 리소스를 정리하는 역할을 합니다.

비동기 함수

그렇다면 비동기 함수를 사용하면 무엇이 문제일까요?

async function example() {
  return 'hello';
}

console.log(example()); // Promise {<fulfilled>: "hello"}

async 함수는 항상 Promise를 반환합니다. 함수 내부에서 명시적으로 Promise를 반환하지 않더라도, JavaScript는 자동으로 반환값을 Promise로 감싸줍니다.

그렇다면 여기서 useEffect의 콜백으로 async 함수를 사용하면 어떻게 될까요?

useEffect(async () => {
  // ...
}, []);
// 이것은 사실상 이렇게 동작합니다
useEffect(() => {
  return Promise.resolve(undefined);
}, []);

React는 cleanup 함수로 함수를 기대하는데, Promise를 받게 됩니다. 이것이 바로 경고 메시지가 나타나는 이유입니다.

공식 문서의 설명

React 공식 문서에 따르면 이에 대해 아래와 같이 명확하게 설명하고 있습니다.

Effect의 setup 함수는 cleanup 함수를 반환하거나 아무것도 반환하지 않아야 합니다. 만약 async 함수를 Effect로 사용하면, 그 함수는 항상 Promise를 반환하게 되며, 이는 React가 cleanup 함수를 실행할 수 없게 만듭니다.

출처: React 공식 문서 - useEffect

코드 예시로 이해하기

그렇다면 코드로 이에 대한 예시를 살펴보겠습니다.

불가능한 예시

// 1. async 함수를 직접 콜백으로 사용
useEffect(async () => {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  setData(data);
}, []);
// 에러: useEffect must not return anything besides a function

// 2. cleanup과 함께 사용하려는 시도
useEffect(async () => {
  const controller = new AbortController();

  const response = await fetch('https://api.example.com/data', {
    signal: controller.signal,
  });
  const data = await response.json();
  setData(data);

  // 이 cleanup 함수는 절대 실행되지 않습니다
  return () => {
    controller.abort();
  };
}, []);

위의 두 번째 예시를 보면 cleanup 함수를 반환하는 것처럼 보입니다. 하지만 실제로 콜백함수는 async 함수이기 때문에 cleanup 함수를 감싼 Promise를 반환하게 됩니다.

// 실제로 반환되는 것
return Promise.resolve(() => {
  controller.abort();
});

그리고 React는 이 Promise를 cleanup 함수로 실행할 수 없습니다.

가능한 예시

그렇다면 어떻게 해야 할까요?

만약 비동기 작업이 필요하다면, useEffect 내부에서 별도의 async 함수를 정의하고 호출해야 합니다.

패턴 1: 즉시 실행 비동기 함수

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    setData(data);
  };

  fetchData();
}, []);

패턴 2: IIFE (즉시 실행 함수 표현식) 사용

useEffect(() => {
  (async () => {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    setData(data);
  })();
}, []);

왜 이렇게 설계되었을까?

그렇다면 React는 왜 이런 방식을 선택한 걸까요? Promise를 받아서 처리할 수도 있었을 텐데 말이죠.

동기적 cleanup의 중요성

React에서는 cleanup 함수가 동기적으로 실행되어야 합니다. 그 이유는 컴포넌트가 언마운트될 때나 다음 effect가 실행되기 전에 즉시 리소스를 정리해야 하기 때문입니다.

만약 cleanup이 비동기적이라면 어떻게 될까요?

// 만약 이런 코드가 가능했다면...
useEffect(async () => {
  const timer = setInterval(() => console.log('tick'), 1000);

  return async () => {
    await someAsyncCleanup();
    clearInterval(timer);
  };
}, []);

컴포넌트가 언마운트되어도 someAsyncCleanup()이 완료될 때까지 타이머가 계속 실행됩니다.
만약 위 코드에서 이 타이머가 상태 업데이트를 수행한다면 이미 언마운트된 컴포넌트에 대해 setState를 호출하게 되어 메모리 누수가 발생할 수 있고 결과적으로 예상치 못한 동작을 일으킬 수 있습니다.

명확한 의도 표현

또한 비동기 함수를 직접 사용할 수 없게 함으로써, 개발자가 비동기 로직과 cleanup 로직을 명확하게 분리하도록 강제합니다.

useEffect(() => {
  // 비동기 로직
  const fetchData = async () => {
    // ...
  };

  fetchData();

  // cleanup 로직 (동기적)
  return () => {
    // ...
  };
}, []);

이런 패턴은 코드의 의도를 명확하게 만들고, cleanup이 언제 어떻게 실행되는지 예측 가능하게 만듭니다.

마치며

처음에는 단순히 "왜 async를 직접 사용하면 안 되지?"라는 궁금증에서 시작했지만, 이를 통해 React의 cleanup 메커니즘과 비동기 처리의 본질을 이해하게 되었습니다. useEffect가 cleanup 함수로 함수만을 기대하는 것은 제약이 아니라, 동기적 cleanup을 보장하고 예측 가능한 코드를 작성하게 하기 위한 설계라는 것을 알게 되었습니다.

참고 자료