Menu

React useEffect 훅: 의존성 배열, 클린업, 예제

useEffect는 React가 화면을 업데이트한 뒤에 코드를 실행하므로, 컴포넌트가 타이머, 구독, 이벤트 리스너처럼 React 바깥의 무언가와 동기화할 수 있습니다. 의존성 배열의 세 가지 형태, 클린업, 무한 루프, 그리고 이펙트가 아예 필요 없는 경우를 배웁니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

useEffect는 React가 컴포넌트를 렌더링하고 화면을 업데이트한 뒤에 코드 조각을 실행합니다. 타이머, 네트워크 연결, 이벤트 리스너처럼 React 바깥의 무언가와 컴포넌트를 동기화할 때 사용하며, 언제 다시 실행할지는 의존성 배열로 제어합니다.

미리보기 아래의 콘솔을 열어 보세요. 이펙트는 컴포넌트가 나타날 때 한 번 로그를 남기고, 그 뒤 클릭할 때마다 다시 남깁니다. [count]를 []로 바꾸고 다시 클릭해 보세요. 첫 로그 이후 이펙트는 조용합니다.

문법

useEffect(() => {
    // setup: runs after the render is on the screen
    return () => {
        // cleanup: runs before the next setup, and on unmount
    };
}, [dependencies]);
  • 첫 번째 인자는 setup 함수입니다. 클린업 함수를 반환하거나 아무것도 반환하지 않을 수 있습니다.
  • 두 번째 인자는 의존성 배열입니다. setup이 읽는 컴포넌트의 모든 값(props, state, 그리고 그것들로 계산한 모든 것)을 넣습니다.
  • 다른 모든 훅처럼 useEffect는 컴포넌트의 최상위에서 호출하세요.

이펙트는 렌더링 후에 실행됩니다

렌더링은 화면에 무엇을 보여 줄지 알아내기 위해 React가 컴포넌트 함수를 호출하는 것입니다. 이펙트는 그 작업이 끝나고 DOM이 업데이트될 때까지 기다리므로, 이펙트 안에서는 페이지가 이미 새 값을 보여 주고 있습니다.

클릭할 때마다 렌더링이 먼저, 이펙트가 두 번째로 로그를 남기고, 이펙트는 React가 방금 쓴 버튼 텍스트를 읽습니다. 그래서 DOM 읽기, 타이머 시작, 서버 연결은 컴포넌트 본문이 아니라 이펙트에 속합니다. 본문은 화면에 아무것도 없는 렌더링 중에 실행되고, React가 예상보다 자주 호출할 수도 있습니다.

이펙트는 보통 브라우저가 화면을 그린 뒤에 실행됩니다. 그리기 전에 DOM을 측정해야 하는 이펙트라면 항상 그리기 전에 실행되는 useLayoutEffect를 대신 사용하세요.

의존성 배열: 세 가지 형태

의존성 배열은 이펙트가 언제 다시 실행될지 결정합니다. 형태는 세 가지이며, 이 블록은 세 가지를 모두 사용하므로 차이를 직접 볼 수 있습니다.

처음 로드되면 세 이펙트가 모두 로그를 남깁니다. a + 1을 클릭하면 두 개가 실행됩니다. 배열이 없는 이펙트와 a에 의존하는 이펙트입니다. b + 1을 클릭하면 []나 [a] 안의 어떤 것도 바뀌지 않았으므로 배열이 없는 이펙트만 실행됩니다.

의존성 배열이펙트가 실행되는 시점
없음렌더링할 때마다
[]첫 렌더링 후 한 번(마운트 시)
[a, b]첫 렌더링 후, 그리고 a나 b가 바뀔 때마다

React는 Object.is로 각 의존성을 이전 값과 비교합니다. 숫자, 문자열, 불리언은 값으로 비교합니다. 객체, 배열, 함수는 동일성으로 비교하며, 이것이 매우 중요합니다(아래 참고).

이펙트를 덜 실행하려고 배열에서 값을 빼지 마세요. 이펙트가 이전 렌더링의 값을 계속 쓰게 됩니다. react-hooks/exhaustive-deps 린트 규칙이 이를 잡아 주며, Vite의 React 템플릿과 Next.js의 ESLint 설정에 포함된 ESLint 구성의 일부입니다.

클린업

많은 이펙트는 연결, 구독, 타이머처럼 나중에 멈춰야 하는 무언가를 시작합니다. 이펙트에서 함수를 반환하면 React는 두 시점에 그 함수를 호출합니다. 새 의존성으로 이펙트가 다시 실행되기 직전, 그리고 컴포넌트가 화면에서 사라질 때(언마운트)입니다.

다른 방을 골라 보세요. 콘솔에 disconnect from general이 나온 뒤 connect to music이 나옵니다. 이전 연결은 항상 새 연결이 열리기 전에 닫힙니다. Hide를 클릭하면 ChatRoom이 언마운트되었으므로 클린업이 마지막으로 한 번 실행됩니다.

여기서 connect 함수는 실제 채팅이나 WebSocket 클라이언트를 대신합니다. 어떤 것이든 형태는 같습니다. setup에서 만들고 클린업에서 닫습니다.

타이머 정리하기

이펙트에서 시작한 타이머는 클린업이 멈추지 않으면 컴포넌트가 사라진 뒤에도 계속 실행됩니다.

clearInterval(id); 줄을 지운 다음 타이머를 숨겨 보세요. 더 이상 존재하지 않는 컴포넌트에서 tick이 계속 콘솔에 들어옵니다. Show를 누를 때마다 타이머가 하나씩 더 시작됩니다.

setSeconds((s) => s + 1)을 보세요. 이펙트는 한 번만 실행되므로, 일반적인 setSeconds(seconds + 1)은 첫 렌더링의 seconds를 영원히 읽어서 1에서 멈춥니다. 업데이터 함수는 항상 최신 값을 받습니다.

이벤트 리스너 제거하기

window나 document의 이벤트 리스너도 같은 패턴을 따릅니다. addEventListener에 넘긴 것과 같은 함수를 removeEventListener에 넘기세요.

useEffect(() => {
    function handleKeyDown(e) {
        if (e.key === 'Escape') onClose();
    }
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);

무한 루프

자기가 의존하는 state 값을 설정하는 이펙트는 루프를 시작합니다. state가 바뀌고, React가 렌더링하고, 의존성이 바뀌었으니 이펙트가 실행되어 다시 state를 설정합니다.

// Never stops: every render runs the effect, every effect causes a render
useEffect(() => {
    setCount(count + 1);
});

// Same loop: count changes, so the effect runs again
useEffect(() => {
    setCount(count + 1);
}, [count]);

컴포넌트가 끝없이 렌더링되고 탭은 계속 바쁜 상태로 남습니다. React는 이 루프를 멈추지 않습니다. 개발 환경에서는 콘솔에 "Maximum update depth exceeded" 오류를 기록하고, 프로덕션에서는 아무것도 기록하지 않습니다. 이 블록은 멈춤 조건이 있는 두 번째 버전을 실행하므로, 페이지를 멈추지 않고 루프를 지켜볼 수 있습니다.

콘솔에 render 0부터 render 5까지 기록됩니다. 마운트 한 번에 렌더링이 여섯 번이고, 각각 이펙트가 시작한 것입니다. 해결 방법은 의도에 따라 다릅니다.

  • 한 번만 실행하고 싶었던 경우. []와 업데이터 형태 setCount((c) => c + 1)을 사용해서 count가 의존성이 되지 않게 하세요.
  • 다른 state로부터 값을 계산하는 경우. 아예 저장하지 마세요. 이 페이지의 마지막 섹션에서 보여 주듯이 렌더링 중에 계산하세요.
  • 사용자 행동에 반응하는 경우. 코드를 이벤트 핸들러에 두세요.

의존성으로 쓰는 객체와 함수

컴포넌트 본문에서 만든 객체, 배열, 함수는 렌더링할 때마다 새 값이므로, 그것에 의존하는 이펙트는 안의 내용이 바뀌지 않았더라도 렌더링마다 실행됩니다.

색상 토글은 검색과 아무 관련이 없는데도 토글할 때마다 search for react가 다시 기록됩니다. options 줄을 이펙트 안으로 옮기고 배열을 [query]로 바꿔 보세요. 토글은 조용해지고, input에 입력할 때만 검색이 실행됩니다.

이것이 일반적인 해결 방법입니다. 객체와 함수는 그것을 사용하는 이펙트 안에서 만들고, 그것을 만드는 원시 값에 의존하세요. 함수가 이펙트 바깥에 있어야 한다면 useCallback이 렌더링 사이에 그 함수의 동일성을 안정적으로 유지해 줍니다.

개발 환경에서 이펙트가 두 번 실행되는 이유

<StrictMode>가 켜져 있으면(새 Vite 앱은 루트를 이것으로 감싸고, Next.js App Router는 기본으로 켭니다) React는 개발 환경에서 테스트를 실행합니다. 각 컴포넌트를 마운트하고, 이펙트를 실행하고, 클린업을 실행한 뒤, 이펙트를 다시 실행합니다. 위의 채팅방이라면 개발 환경의 콘솔에는 다음과 같이 나옵니다.

connect to general
disconnect from general
connect to general

이 일은 마운트 시 한 번, 개발 환경에서만 일어납니다. 클린업이 빠진 이펙트를 드러내기 위해 존재합니다. 연속으로 두 번 실행해서 무언가 깨진다면, 사용자가 페이지를 떠났다가 돌아올 때도 똑같이 깨질 것입니다. 해결 방법은 ref로 두 번째 실행을 막는 것이 절대 아니라 클린업을 작성하는 것입니다. 이 페이지의 미리보기는 프로덕션 빌드처럼 실행되므로 여기서는 두 번 실행되는 것을 볼 수 없습니다.

이펙트가 필요 없을 수도 있습니다

이펙트는 React 바깥의 시스템과 동기화하기 위한 것입니다. 흔한 두 가지 용도는 이펙트 없이 하는 편이 낫습니다.

props나 state로 계산하는 값. 렌더링 중에 계산하세요. state에 저장하고 이펙트로 동기화하면 렌더링이 한 번 더 들고, 그 두 렌더링 중 첫 번째는 낡은 값을 DOM에 넣습니다.

클릭할 때마다 WithEffect는 두 번 렌더링되고, 첫 번째 렌더링은 여전히 이전 이름을 보여 줍니다. Computed는 올바른 값으로 한 번만 렌더링됩니다.

사용자 행동에 반응하는 코드. 폼 전송, 구매 후 토스트 표시, 클릭에 대한 분석 이벤트 전송은 이벤트 핸들러에 속합니다. 이벤트 핸들러에서는 무슨 일이 일어났는지 정확히 알 수 있지만, 이펙트는 어떤 값이 바뀌었다는 것만 압니다.

useEffectEvent

때로는 이펙트가 prop의 최신 값을 필요로 하면서도, 그 prop이 바뀔 때 다시 실행되면 안 됩니다. 채팅방에서 알림은 현재 theme을 써야 하지만, 테마를 바꿨다고 다시 연결해서는 안 됩니다. React 19.2는 이를 위해 useEffectEvent를 추가합니다.

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
    const onConnected = useEffectEvent(() => {
        showNotification('Connected!', theme); // always the latest theme
    });

    useEffect(() => {
        const connection = createConnection(roomId);
        connection.on('connected', () => onConnected());
        connection.connect();
        return () => connection.disconnect();
    }, [roomId]); // theme is not a dependency
}

useEffectEvent가 반환하는 함수는 이펙트 안에서 호출하는 용도로만 쓰세요. 다른 컴포넌트에 넘기거나 의존성 배열에 나열하지 마세요.

자주 묻는 질문

useEffect는 언제 실행되나요?

React가 컴포넌트를 렌더링하고 DOM을 업데이트한 뒤, 보통 브라우저가 화면을 그린 뒤에 실행됩니다. 의존성 배열이 없으면 렌더링마다 실행되고, []이면 첫 렌더링 후에만, [a, b]이면 첫 렌더링 후와 a나 b가 바뀐 렌더링 후에 실행됩니다.

useEffect의 클린업 함수란 무엇인가요?

이펙트에서 반환하는 함수입니다. React는 새 의존성으로 이펙트를 다시 실행하기 전에 이 함수를 호출하고, 컴포넌트가 화면에서 제거될 때 한 번 더 호출합니다. 타이머를 정리하고, 이벤트 리스너를 제거하고, 구독을 닫는 데 사용하세요.

useEffect가 두 번 실행되는 이유는 무엇인가요?

개발 환경에서 <StrictMode>는 클린업이 빠진 이펙트를 보여 주기 위해 모든 컴포넌트를 마운트하고, 이펙트를 실행하고, 정리한 뒤, 다시 실행합니다. 마운트 시 한 번, 개발 환경에서만 일어납니다. 프로덕션 빌드에서는 이펙트가 한 번 실행됩니다.

useEffect가 무한 루프를 일으키는 이유는 무엇인가요?

이펙트가 의존성 배열에도 들어 있는 state 값을 설정하기 때문입니다(또는 배열이 아예 없거나). state를 설정하면 다시 렌더링되고, 의존성이 바뀌었으므로 이펙트가 다시 실행됩니다. 업데이터 함수를 쓰고 의존성을 제거하거나, 조건을 추가하거나, 대신 렌더링 중에 값을 계산하세요.

useEffect 콜백을 async로 만들 수 있나요?

아니요. 콜백은 아무것도 반환하지 않거나 클린업 함수를 반환해야 하는데, async 함수는 프로미스를 반환합니다. 데이터 가져오기 페이지에서 보여 주듯이 이펙트 안에 async 함수를 정의하고 호출하세요.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기