본문 바로가기
React.js

[심층분석] useEffect & dependency array (feat. API race condition, useEffect 클린업 코드)

by 찬찬2 2022. 10. 12.
const App = () => {
    cosnt { data } = useFetch({ url: "/jack.json" });
    
    console.log("App rendering");

    return (
        <div>{ JSON.stringify(data) }</div>
    );
}

↑ App 컴포넌는 useFetch 커스텀훅으로 받아온 data를 보여주고 있습니다.

 

export const useFetch = (options) => {
    const [ data, setData ] = useState(null);

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        fetch(option.url)
            .then(resp => resp.json())
            .then(json => setData(json));
    });

    return { data };
}

↑ useFetch는 커스텀훅으로써 fetch API를 통해 받아온 데이터를 반환해주고 있습니다.

 

 

 

↑ 코드실행 시 외와 같이 App 컴포넌트가 무한 렌더링되는 상황을 볼 수 있다.

 

uesEffect 무한루프 원인

- 함수형 컴포넌트가 렌더링된다는 것은 함수가 호출되어 실행되는 것과 같다.

- 리액트는 state, props가 업데이트될때 컴포넌트를 렌더링한다.

- 의존성 배열이 없다.

 

위 코드의 실행과정을 살펴보면...

1. App 컴포넌트 렌더링 → useFetch 실행  → fetch 함수 호출 → 최초 결과값은 null → App 컴포넌트에  data 변수에 결과값 할당. 그리고 나머지 코드실행 → fetch 함수에서 promise 결과값 도착 → useFetch 안에 있는 state 업데이트 → App 컴포넌트 호출 → useFetch 실행 ...

 

결국 커스텀훅의 state값이 변경됨에 따라 커스텀훅을 사용하는 컴포넌트가 반복 호출되는 현상이다. 그리고 useEffect에는 의존성 배열이 없어 비교하는 과정없이 무조건 실행된 것이다.

 

export const useFetch = (options) => {
    const [ data, setData ] = useState(null);

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        fetch(option.url)
        .then(resp => resp.json())
        .then(json => setData(json));
            
    }, []); // 두번째 인자로 빈배열 추가

    return { data };
}

 

의존성 배열(Dependency array)

- useEffect는 클래스형 컴포넌트에서 볼 수 있는 componentDidMount, componentDidUpdate, componentWillMount 등 라이프 싸이클을 함수형 컴포넌트에서도 똑같이 사용될 수 있도록 개발된 훅이다.

이때 useEffect의 두번째 인자로 배열이 들어갈 수 있는데 이를 의존성 배열이라고 한다.

- Dependency란 사전적의미로 "의존"이다. 즉, useEffect는 무엇인가에 의존한다는 것이다.

- 배열안에는 useEffect가 의존할 대상(element)을 넣는다. 그리고 useEffect는 의존할 대상을 캐싱하고 useEffect가 실행될때 마다(컴포넌트가 렌더링될때 마다) 그 대상의 이전 상태와 이후 상태를 비교하고 상태가 다를 경우 useEffect 첫번째 콜백함수를 실행한다. 이때 비교는 얕은 비교를 한다. (Shallow comparison | 원시값과 객체의 다른 비교방식 | 참고링크)

 


 

useEffect 사용상황 CASE#1

const App = () => {
    const [ url, setUrl ] = useState(null)
    cosnt { data } = useFetch({ url });
    
    console.log("App rendering");

    return (
    	<>
            <div>{ JSON.stringify(data) }</div>
            <button onClick={() => setUrl("/jack.json")}>jack</button>
            <button onClick={() => setUrl("/sally.json")}>sally</button>
        </>
    );
}
export const useFetch = (options) => {
    const [ data, setData ] = useState(null);

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        fetch(option.url)
        .then(resp => resp.json())
        .then(json => setData(json));
        
    }, [options]);

    return { data };
}

위 코드는 jack, sally 버튼을 클릭 시 useFetch를 통해 jack.json, sally.json의 데이터를 가져오기 위함이다.

 

위 코드의 실행결과

 

하지만 위와 같이 다시 무한루프가 발생한다. 그 이유는 useEffect의 의존성 배열의 요소인 options 때문이다.

options는 App 컴포넌트에서 객체로 전달된다. 비록 url의 값이 "/jack.json"이어도 객채이기 때문에 원시값처럼 비교하는 것과는 다르게 객체의 참조값(메모리의 주소)을 비교하게 된것이다. 즉, useEffect의 입장에서는 같은 값이 아니라는 판단을 하고 블로그글 첫번째 상황이 반복되는 것이다.

 

해결방법#1: useMemo

const App = () => {
    const [ url, setUrl ] = useState(null)
    const myOptions = useMemo(() => ({ url }), [url]);
    cosnt { data } = useFetch(myOptions);
    
    console.log("App rendering");

    return (
    	<>
            <div>{ JSON.stringify(data) }</div>
            <button onClick={() => setUrl("/jack.json")}>jack</button>
            <button onClick={() => setUrl("/sally.json")}>sally</button>
        </>
    );
}

 

바로  useMemo 훅을 사용하는 것이다. useMemo는 값을 메모리에 저장시켜 주기때문에 useEffect가 비교대상을 비교할때 정확하고 똑같은 메모리 주소를 참조하여 비교하기 때문에 정상적으로 작동되는 것을 볼 수 있다.

 

해결방법#2: 비교대상을 원시값으로 전달하기

const App = () => {
    const [ url, setUrl ] = useState(null)
    cosnt { data } = useFetch({ url });
    
    console.log("App rendering");

    return (
    	<>
            <div>{ JSON.stringify(data) }</div>
            <button onClick={() => setUrl("/jack.json")}>jack</button>
            <button onClick={() => setUrl("/sally.json")}>sally</button>
        </>
    );
}
export const useFetch = (options) => {
    const [ data, setData ] = useState(null);

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        if(option.url){
            fetch(option.url)
            .then(resp => resp.json())
            .then(json => setData(json));
        }

    }, [option.url]); // string

    return { data };
}

 


 

useEffect 사용상황 CASE#2

const App = () => {
    const [ url, setUrl ] = useState(null)
    cosnt { data } = useFetch({
    	url,
        onSuccess: () => console.log("success")
    });
    
    console.log("App rendering");

    return (
    	<>
            <div>{ JSON.stringify(data) }</div>
            <button onClick={() => setUrl("/jack.json")}>jack</button>
            <button onClick={() => setUrl("/sally.json")}>sally</button>
        </>
    );
}
export const useFetch = (options) => {
    const [ data, setData ] = useState(null);

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        if(option.url){
            fetch(option.url)
            .then(resp => resp.json())
            .then(json => {
                options?.onSuccess();
                setData(json);
             });
        }

    }, [option.url, option.onSuccess]); // string, 함수

    return { data };
}

 

위 코드의 실행결과

이유는 useEffect 사용상황 CASE#1과 같다.

 

해결방법#1: useRef

export const useFetch = (options) => {
    const [ data, setData ] = useState(null);
    
    const savedOnSuccess = useRef(options.onSuccess); // 함수를 useRef 초기값으로 넣는다

    useEffect(() => {
    	console.log("useFetch useEffect");
        
        if(option.url){
            fetch(option.url)
            .then(resp => resp.json())
            .then(json => {
                savedOnSuccess.current?.(json);
                setData(json);
             });
        }

    }, [option.url]); // string

    return { data };
}

 

해결방법#2: 커스텀훅 + useLayoutEffect

- 매개변수가 함수일 것을 대비해 전용 콜백 커스텀훅을 만든다.

const useCallbackRef = (callback) => {
    const callbackRef = useRef(callback);
    
    useLayoutEffect(() => {
        callbackRef.current = callback;
    }, [callback]);
    
    return callbackRef;
};

export const useFetch = (options) => {
    const [data, setData] = useState(null);

    const savedOnSuccess = useCallbackRef(options.onSuccess);

    useEffect(() => {
        console.log("useFetch useEffect ");

        if (options.url) {
            let isCancelled = false;

            fetch(options.url)
            .then((response) => response.json())
            .then((json) => {
                if (!isCancelled) {
                    savedOnSuccess.current?.(json);
                    setData(json);
                }
            });

            return () => {
                isCancelled = true;
            };
        }
    }, [options.url]);

    return {
        data,
    };
};

 

중요!!

만약 사용자가 jack.json을 호출하고  아직 jack.json의 결과값을 가져오지 않은 상태에서 sally.json을 호출할 경우 에러가 발생한다. 이러한 API 호출간 race 현상을 막기 위해 useEffect의 클린업 작업이 필요하다.

 

https://youtu.be/dH6i3GurZW8

댓글