React 환경에서의 Stale Closures

reactjavascripthooksclosuresperformance
By sko X opus 4.19/19/20253 min read

Stale closures는 React hooks를 사용하는 개발자들이 직면하는 가장 미묘하면서도 널리 퍼진 과제 중 하나입니다. 이 현상은 함수가 주변 스코프에서 오래된 변수에 대한 참조를 캡처하고 유지하여, 업데이트가 발생한 후에도 컴포넌트가 구식 상태나 props 값으로 작동하게 합니다. Stale closures를 이해하려면 JavaScript의 클로저 메커니즘과 React의 독특한 렌더링 모델을 모두 이해해야 합니다.

클로저가 React의 렌더링 사이클과 만날 때

JavaScript 클로저는 함수가 외부 스코프의 변수에 대한 접근을 유지할 때 형성됩니다. MDN의 정의에 따르면, 클로저는 "함수와 그것을 둘러싼 상태에 대한 참조가 함께 번들된 조합"입니다. 이 JavaScript의 기본 개념은 렉시컬 스코핑을 통해 작동하며, 변수 접근은 실행되는 위치가 아닌 소스 코드에서 변수가 선언된 위치에 의해 결정됩니다.

React의 함수형 컴포넌트는 이 메커니즘에 독특한 변형을 추가합니다. 컴포넌트가 렌더링될 때마다 React는 전체 컴포넌트 함수를 처음부터 끝까지 실행하여, useState의 상태를 포함한 모든 변수에 대해 새로운 상수를 생성합니다. 이는 const [count, setCount] = useState(0)이 가변 참조가 아닌, 렌더링마다 완전히 새로운 변수 바인딩인 새로운 count 상수를 생성한다는 것을 의미합니다.

function Counter() {
    const [count, setCount] = useState(0);

    // 이 함수는 렌더링마다 재생성된다
    const handleClick = () => {
        // 이 클로저는 이 특정 렌더링의 'count'를 캡처한다
        setTimeout(() => alert(count), 3000);
    };

    return <button onClick={handleClick}>Count: {count}</button>;
}

이 예제에서 count가 5일 때 버튼을 클릭하고 알림이 나타나기 전에 카운터를 빠르게 여러 번 증가시키면, 알림은 여전히 "5"를 표시합니다 - 클로저가 생성되었을 때 존재했던 값입니다. 이것이 stale closure 문제의 본질을 포착합니다.

가장 일반적인 stale closure 시나리오

비동기 작업을 포함하는 이벤트 핸들러

이벤트 핸들러와 비동기 작업의 조합은 가장 빈번한 stale closure 문제를 만듭니다. 개발자가 이벤트 핸들러 내에서 setTimeout, setInterval, 또는 promise를 사용할 때, 콜백은 생성된 시점의 상태 값을 캡처합니다:

function DelayedCount() {
    const [count, setCount] = useState(0);

    function handleClickAsync() {
        setTimeout(() => {
            setCount(count + 1); // ❌ stale한 count 값을 사용
        }, 1000);
    }

    return (
        <div>
            {count}
            <button onClick={handleClickAsync}>Increase async</button>
        </div>
    );
}

이 버튼을 빠르게 여러 번 클릭해도 예상되는 count가 아닌 1만 증가합니다. 모든 setTimeout 콜백이 동일한 초기 count 값을 캡처하기 때문입니다. 해결책은 함수형 업데이트를 사용하는 것입니다:

setTimeout(() => {
    setCount(prevCount => prevCount + 1); // ✅ 항상 최신 값을 가져옴
}, 1000);

누락된 의존성이 있는 UseEffect

빈 의존성 배열이나 불완전한 의존성 배열을 가진 이펙트는 새로운 변수를 캡처하기 위해 업데이트되지 않는 클로저를 생성합니다. 이 패턴은 이펙트 재실행을 피해 성능을 최적화하려는 개발자들에게 자주 나타납니다:

function WatchCount() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            console.log(`Count is: ${count}`); // ❌ 항상 0을 로그
        }, 2000);

        return () => clearInterval(id);
    }, []); // 빈 의존성 배열이 stale closure를 일으킴

    return (
        <div>
            {count}
            <button onClick={() => setCount(count + 1)}>Increase</button>
        </div>
    );
}

interval은 항상 "Count is: 0"을 로그합니다. 클로저가 초기 렌더의 count 값을 캡처하고 절대 업데이트되지 않기 때문입니다. React의 exhaustive-deps ESLint 규칙은 캡처된 모든 변수가 의존성으로 선언되도록 보장하여 이러한 문제를 방지하기 위해 특별히 존재합니다.

콜백이 있는 커스텀 훅

콜백을 반환하는 커스텀 훅은 현재 의존성으로 적절히 메모이제이션되지 않을 때 특히 미묘한 stale closure 버그를 만들 수 있습니다:

export const useMembers = () => {
    const { data, loading, error } = useQuery(getSpeakersGql);
    const members = data?.nodes || [];

    // ❌ 이 콜백은 data에 대한 stale closure를 가질 수 있음
    const getMember = useCallback(
        (id) => {
            console.log('data', data); // undefined를 로그할 수 있음
            return members.find((member) => member.id === id);
        },
        [] // 의존성 누락!
    );

    return { getMember };
};

함수형 컴포넌트가 특히 취약한 이유

클래스 컴포넌트와 함수형 컴포넌트의 차이는 왜 stale closures가 현대 React에서 특히 문제가 되는지를 보여줍니다. 클래스 컴포넌트는 가변 this 참조를 사용하기 때문에 stale closures에 대체로 면역입니다 - this.state는 항상 현재 상태를 가리킵니다. 메서드는 프로토타입에 정의되어 렌더마다 재생성되지 않습니다.

// 클래스 컴포넌트 - stale closure 문제 없음
class Counter extends Component {
    handleClick = () => {
        setTimeout(() => {
            alert(this.state.count); // 항상 현재 값
        }, 3000);
    }
}

// 함수형 컴포넌트 - stale closure가 발생하기 쉬움
function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = () => {
        setTimeout(() => {
            alert(count); // 생성 시점의 값을 캡처
        }, 3000);
    };
}

이 차이는 근본적인 트레이드오프를 나타냅니다. 클래스 컴포넌트의 가변 상태는 stale closures를 방지하지만 상태 변경 후 비동기 작업이 완료될 때 예기치 않은 동작을 일으킬 수 있습니다. 함수형 컴포넌트의 렌더 내 불변 상태는 일관성을 제공하지만 신중한 의존성 관리가 필요합니다.

Dan Abramov의 연구에 따르면 이들은 서로 다른 멘탈 모델을 나타냅니다. 클래스 모델은 메서드가 가변 인스턴스 상태에서 작동하는 "인스턴스 기반" 사고를 사용합니다. 함수형 모델은 각 렌더가 특정 값을 캡처하는 "값 기반" 사고를 사용합니다 - 그 순간 컴포넌트 상태의 스냅샷을 찍는 것과 같습니다.

솔루션 툴킷 마스터하기

UseRef를 탈출구로 활용

useRef 훅은 렌더 간에 지속되는 가변 참조를 제공하여, 재렌더를 트리거하지 않고 현재 값에 접근하는 강력한 솔루션을 제공합니다:

function WatchCount() {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);

    // ref를 상태와 동기화
    useEffect(() => {
        countRef.current = count;
    }, [count]);

    useEffect(() => {
        const id = setInterval(() => {
            console.log(`Count is: ${countRef.current}`); // 항상 현재 값!
        }, 1000);

        return () => clearInterval(id);
    }, []); // ref를 사용하면 빈 deps 배열이 안전

    return (
        <div>
            {count}
            <button onClick={() => setCount(count + 1)}>Increase</button>
        </div>
    );
}

이 패턴이 작동하는 이유는 ref가 .current 프로퍼티가 가변인 안정적인 컨테이너를 제공하기 때문입니다. ref 객체 자체는 렌더 간에 일정하게 유지되지만, 그 current 프로퍼티는 항상 최신 값을 유지하도록 업데이트할 수 있습니다.

함수형 상태 업데이트

React의 setState 함수는 현재 상태를 인수로 받는 업데이터 함수를 받아들여, 클로저에서 상태 값을 캡처할 필요를 없앱니다:

function MultiStateExample() {
    const [count, setCount] = useState(0);
    const [multiplier, setMultiplier] = useState(2);

    const handleComplexUpdate = useCallback(() => {
        // 두 업데이트 모두 stale closures를 피하기 위해 함수형 사용
        setCount(prev => prev + 1);
        setMultiplier(prev => prev * 2);
    }, []); // 함수형 업데이트로 의존성 불필요

    return (
        <div>
            <p>Count: {count}</p>
            <p>Multiplier: {multiplier}</p>
            <button onClick={handleComplexUpdate}>Update Both</button>
        </div>
    );
}

이 접근법이 작동하는 이유는 React가 업데이터 함수가 실행될 때 클로저가 생성된 시점과 관계없이 현재 상태 값을 받도록 보장하기 때문입니다.

복잡한 상태 로직을 위한 UseReducer

useReducer 훅은 dispatch 메커니즘을 통해 자연스럽게 stale closures를 피하는 강력한 대안을 제공합니다:

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return { ...state, count: state.count + state.step };
        case 'setStep':
            return { ...state, step: action.step };
        default:
            return state;
    }
}

function Counter() {
    const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

    useEffect(() => {
        const id = setInterval(() => {
            dispatch({ type: 'increment' }); // stale closure 문제 없음!
        }, 1000);

        return () => clearInterval(id);
    }, []); // useReducer로 빈 deps 배열이 안전

    return (
        <div>
            <p>Count: {state.count}</p>
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
        </div>
    );
}

dispatch 함수는 컴포넌트의 수명 동안 안정적인 아이덴티티를 가지며, reducer는 액션을 처리할 때 항상 현재 상태를 받아 효과적으로 stale closure 우려를 제거합니다.

적절한 의존성 배열

React의 exhaustive-deps 규칙을 엄격히 따르면 대부분의 stale closure 문제를 방지할 수 있습니다. 이는 시간이 지남에 따라 변하고 이펙트에서 사용되는 컴포넌트 스코프의 모든 값을 포함하는 것을 의미합니다:

function ChatRoom({ roomId }) {
    const [message, setMessage] = useState('');
    const [serverUrl, setServerUrl] = useState('localhost:1234');

    useEffect(() => {
        const connection = createConnection(serverUrl, roomId);
        connection.connect();

        return () => connection.disconnect();
    }, [serverUrl, roomId]); // ✅ 모든 의존성 포함

    const handleSendMessage = useCallback(() => {
        sendMessage(message);
        setMessage('');
    }, [message]); // ✅ message 의존성 포함

    return (
        <div>
            <input value={message} onChange={e => setMessage(e.target.value)} />
            <button onClick={handleSendMessage}>Send</button>
        </div>
    );
}

새로운 useEvent 패턴

React의 실험적 useEvent 훅(아직 개발 중)은 안정적인 함수 아이덴티티를 제공하면서 최신 props와 상태를 읽어 많은 stale closure 문제를 해결할 것을 약속합니다:

import { experimental_useEffectEvent as useEffectEvent } from 'react';

function Page({ url, shoppingCart }) {
    const onVisit = useEffectEvent((visitedUrl) => {
        // deps에 포함하지 않고도 최신 shoppingCart 읽기 가능
        logVisit(visitedUrl, shoppingCart.length);
    });

    useEffect(() => {
        onVisit(url);
    }, [url]); // 의존성에는 url만, shoppingCart는 제외

    return <div>Page content</div>;
}

클로저 안전 React 코드를 위한 모범 사례

엄격한 ESLint 규칙 활성화. 개발 중 문제를 포착하기 위해 exhaustive-deps 규칙을 "warn"이 아닌 "error"로 설정하세요:

{
    "rules": {
        "react-hooks/exhaustive-deps": "error"
    }
}

이전 값을 기반으로 상태를 업데이트할 때 함수형 업데이트를 기본값으로. 이 패턴은 최소한의 오버헤드로 stale closure 버그의 전체 클래스를 제거합니다.

전략적으로 ref 사용 - 재렌더를 트리거하지 않고 콜백에서 접근해야 하는 값, 특히 타사 라이브러리나 브라우저 API와 통합할 때.

컴포넌트 상태가 복잡해지거나 여러 관련 업데이트가 포함되면 일찍 useReducer를 고려. dispatch 패턴은 코드 구성을 개선하면서 자연스럽게 stale closures를 피합니다.

의존성으로 사용되는 경우 컴포넌트 본문이 아닌 이펙트 내부에서 객체 생성. 이렇게 하면 새로운 값을 보장하면서 불필요한 이펙트 재실행을 방지합니다.

더 깊은 의미 이해하기

React의 Fiber 아키텍처는 2단계 조정 프로세스를 통해 클로저 동작에 추가적인 복잡성을 더합니다. 렌더 단계는 비동기적으로 Fiber 트리를 구축하고 중단될 수 있지만, 커밋 단계는 동기적으로 실행됩니다. 이는 렌더 중에 생성된 클로저가 잠재적으로 stale한 참조로 커밋 중에 실행될 수 있음을 의미합니다.

이펙트 실행 타이밍은 문제를 더욱 복잡하게 만듭니다. useEffect 콜백은 React가 DOM을 업데이트하고 브라우저가 페인트한 후에 실행됩니다. 이 비동기 실행은 이펙트가 실행되기 전에 여러 렌더가 발생하더라도 이펙트가 항상 생성된 렌더를 참조한다는 것을 의미합니다.

결론

React의 Stale closures는 JavaScript의 렉시컬 스코핑과 React의 함수형 컴포넌트 모델의 교차점에서 발생합니다. 이들은 진정한 도전을 나타내지만, 그 근본 원인을 이해하고 솔루션 패턴을 마스터하면 신비한 버그에서 예측 가능하고 관리 가능한 우려로 변환됩니다.

핵심 통찰력은 각 렌더가 클로저가 캡처하는 값의 스냅샷을 생성한다는 것입니다. 이 "스냅샷" 동작은 일관성을 제공하지만 개발자가 클로저를 재생성하여 새로운 값을 캡처해야 할 때를 명시적으로 관리해야 합니다. 적절한 의존성 관리, ref의 전략적 사용, 함수형 업데이트, useEvent와 같은 새로운 패턴을 통해 개발자는 성능이 뛰어나고 stale closure 버그가 없는 React 애플리케이션을 작성할 수 있습니다.

exhaustive-deps ESLint 규칙과 useEvent와 같은 기능에 대한 지속적인 작업과 같은 도구에 대한 React 팀의 투자는 클로저 관리를 더 직관적으로 만들려는 그들의 약속을 보여줍니다. 생태계가 계속 성숙해짐에 따라 이러한 패턴은 더 간소화될 가능성이 있지만, 클로저가 React의 렌더링 사이클과 상호작용하는 방식에 대한 근본적인 이해는 React 개발자에게 필수 지식으로 남을 것입니다.