React useEffect 의 dependency array

simple하지만 monster가 될때도 있는 useEffect

UseEffect는 가끔 사람을 골탕먹인다. 그리고 이런 sideEffect에 대해 제대로 이해하지 않고 사용하다보면 나중엔 디버깅을 하기 참 곤란해진다. 이번 기회에 useEffect의 동작에 대한 제대로된 멘탈 모델을 가져보자.

TL;DR
- useEffect는 기본적으로 매 렌더링 마다 실행된다.
- dependency array에 primitive types를 넣으면 값이 변경될 때 마다 실행된다.
- dependency array에 object를 넣으면 object의 reference가 변경될 때 마다 실행된다.
- dependency array에 object를 넣고, object의 값이 변경될 때 마다 실행시키기를 원한다면, use-deep-compare-effectuseDeepCompareEffect 를 useEffect 대신에 사용하자.

1. 언제 실행되는걸까

const App = () => {
useEffect(() => {
});
return <div id="root">Hi</div>
}

useEffect rendering이 된 이후 실행된다. 화면에 Hi 라는 문구가 쓰여지고 나서 useEffect 가 실행되는데, 아시다 시피 useEffect의 첫번째 인자는 callback 함수이고, 두번째 인자가 depedency arry이다.

useEffect(callback, dependencyArray)

아래와 같이 dependency array를 넘겨주지 않으면 매번 rendering 이후에 callback 함수가 실행되고, 이게 기본적은 useEffect 가 동작되는 원리이다.

useEffect(() => console.log("매 rendering 마다 실행"));

2. 조건부로 실행시키기

dependency array를 통해서 useEffect 는 조건부로 callback 함수를 실행시킬 수 있게 된다. 대충 알기로는 dependency array가 변경될 때 callback 함수가 실행된다.

아래 코드는 count가 변경될 때 마다 callback 함수가 실행되는 useEffect 이다.

const count = 1;
useEffect(() => {
console.log("count가 변경되면 실행됩니다.", count);
}, [count]);

여기에 useState hook이 같이 사용되게 되면서 state가 변경될 때 callback을 실행하게 하는 가장 일반적인 useEffect 사용 시나리오가 만들어 지게 된다.

const [count, setCount] = useState(0);useEffect(() => {
console.log("버튼을 누르면 count 상태가 변경되어서 callback이 실행됩니다.")
}, [count]);
return (
<div>
<button onClick={() => setCount(prevCount => prevCount+1)}>버튼
</button>
</div>
)

그럼 props이 변경될 때마다 callback을 실행하게 useEffect 를 사용할 수 있을까? 바로 떠오르는 생각으로는 destructuring한 props를 dependency array에 넣어주면 된다고 생각할 수 있다. 바로 아래처럼.

const useForm = ({schema}) => {  useEffect(() => {
console.log("이렇게 구현을 하면 어떻게 될까?")
}, [schema]);
}

dependency array에 추가되는게 primitive types인 경우 아래 코드는 원하는대로 동작한다. Primitive type인 경우 값을 바로 비교하기 때문에, prop의 값이 변경될때 useEffect 의 callback이 실행되게 된다.

const useForm = ({count} : {count: number) => {
useEffect(() => {
console.log("count 값이 변할 때 callback이 불립니다.);
}, [count]);
}

하지만 dependency array에 object나 array가 들어가면 매 rendering 마다 callback이 실행되게 된다. 아래 코드를 보자.

const useForm = ({schema} : {schema: Object}) => {   useEffect(() => {
console.log("schema가 변경될 때 실행시키고 싶지만, 현실은 매 rendering 마다 실행됩니다.")
}, [schema]);}const App = () => {
const [count, setCount] = useState(0);
const form = useForm({schema: {name: "required"}});return (
<div>
<button onClick={() => setCount(prevCount => prevCount+1)}>
버튼A
</button>
</div>
}

버튼A를 누르면
- count 상태가 변경되면서 rendering 발생
- useForm 함수가 호출
- useEffect 의 dependency array에 따라 조건부로 callback을 호출

하지만 schema는 rendering이 될때마다 새롭게 {name: "required"} 라는 값을 가진 새로운 object가 인자로 주어지고, useEffect 에서는 이 object의 refernece가 이전에 주어진 object의 reference가 같은지를 확인한다. 새로 만들어진 object는 값이 같더라도 새로운 reference를 가지기 때문에 callback은 매번 실행된다.

해결책 같아 보이는 후보들

이제 reference가 변하지 않으면 될것 같은 느낌이 든다. useState를 이용해서 코드를 변경해보자.

const useForm = ({schema} : {schema: Object}) => {   useEffect(() => {
console.log("schema 값이 변하지 않아도 실행됩니다.")
}, [schema]);
}
const App = () => {
const [schema, setSchema] = useState(0);
const form = useForm({schema});return (
<div>
<button onClick={() => setSchema(schema)}>
버튼A
</button>
</div>
}

버튼 A를 누르면 schema값은 변하지 않는다. 하지만 useEffect 의 callback은 실행된다. 버튼A를 누르면서 schema는 값은 같지만 새로운 reference를 가진 object를 반환받는다. reference가 다르니 callback이 실행된다.

사실 저런 코드는 실제로 만들어질 가능성은 낮지만, 우리가 원하지 않는 동작을 야기시킨다. 저런게 디버깅 하기도 어렵다.

그리고 이 방법은 useForm 자체의 독립성에도 문제가 생긴다. 혹시라도 다른 개발자가 schema를 useState 가 아닌 변수를 인자로 넘긴다면 어김없이 callback이 실행된다.

useEffect(() => {
console.log("schema에 key가 추가되면 실행되지 않습니다.")
}, [...Object.keys(schema)]);
}

Object.keys 를 이용해 schema의 key값을 array로 만들고, 이걸 spread operator로 던저준다. schema의 key값이 변하지 않으면 callback이 실행되지 않고, key값이 변하면 callback이 실행될것 처럼 보인다.

useEffect의 dependency array 비교하는 코드를 가져와보자.


for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;

여기서 `false`가 반환되면 useEffect의 callback이 실행되고, `true`가 반환되면 callback이 실행되지 않는다. prevDeps와 nextDeps 중 길이가 짧은 것을 기준으로 한다. 따라서 object에 key값이 하나 추가되면, 이전 object의 key값들만 비교하기 때문에 callback 함수가 실행되지 않는다.

2번째 line의 is는 Object.is 함수이다. Object.is 에 객체를 넣으면 두 객체가 같은 객체인지를 확인하지 같은 값을 가진 객체인지를 확인하지 않는다. 즉 reference만 체크한다는 것이다.

var a = {name: "Sg"}Object.is(a,a) // true
Object.is(a, {name: "Sg"}) // false
var b = a;Object.is(a,b) // true

하지만 key값을 하나의 string으로 합쳐서 dependency array에 넣으면 어떨까? 언제나 dependency array는 하나의 string이 들어가므로, 이전값과 비교를 할 수 있고, string간의 비교이기 때문에 값으로 비교 할 수 있다.

useEffect(() => {
console.log("약간 꼼수 같지만, 원하는 바를 이루다!");
}, [Object.keys(schema).join()]

Props 변경시 callback을 실행시키는 올바른 해결책

정확히 우리가 원하는 동작은, prop의 값이 변경되었을 때 callback함수를 실행시키는 것이다. 그럼 prop의 값을 비교하도록 useEffect를 수정해야 된다.

import isEqual from "lodash-es"const useForm = ({schema}) => {
cosnt prevSchema = useRef();
useEffect(() => {
console.log("여기는 매번 렌더링 마다 실행됩니다.")

if(schema && !isEqual(schema, prevSchema.current)) {
console.log("schema 값이 변경될 때 실행됩니다.")
prevSchema.current = schema;
}
});
}

이전 schema는 prevSchema 에 저장해두고, lodashisEqual 을 이용해서 deep comparision(깊은 비교) 통해 값을 확인한다.

이제 useForm 을 어디서 사용하던지 독립적으로 schema가 변경될 때 마다 callback을 실행시킬 수 있다.

use-deep-compare-effect

위에 작성한 코드와 동일한 기능을 제공하는 package가 있다. use-deep-compare-effect 이고, react-testing-library 로 유명한 Kent C. Dodds 가 만들었다.

import useDeepCompareEffect from "use-deep-compare-effect"const useForm = ({schema}) => {
useDeepCompareEffect(() => {
console.log("깊은 비교로 object를 비교한다. schema 값이 변할 때 실행된다.");
}, [schema]);
}

useDeepCompareEffect의 코드를 보면 아래와 같다.

function useDeepCompareMemoize(value) {  
const ref = React.useRef()
if (!deepEqual(value, ref.current)) {
ref.current = value
}
return ref.current
}

결국 이전값을 useRef로 저장하고, deep comparision으로 object의 값을 비교한다.

useEffect 너무 편리하지만, 정확히 알자

React는 community도 크고, 좋은 best practice들도 많다. 그래서 조금만 검색해도 stack overflow에서 solution code를 찾을 수도 있다. useEffect도 그렇다. 하지만 정확히 알지 못하면 실제 서비스에서 원인모를 버그로 고생할수도 있으니, 역시 내가 어떤 도구를 쓰고 있는지는 정확히 알아두는게 좋다. :)

Entrepreneur

Entrepreneur