대략 1년 전에 회사에서 React로 짜여진 프로젝트를 리팩터하면서 공부했던 내용을 글로 다시 정리해보고자 한다.
exhaustive-deps
란 무엇인가?- hook을 이해하려면 리액트 라이프 사이클을 벗어나야 한다!
- 라이프사이클을 벗어나서
useEffect
를 다른 관점으로 바라보기
리액트로 개발 중에 이런 warning을 자주 본 적이 있다. 리액트 CRA(Create React App)
환경에서 기본적으로 설치되어있는 ESLint
플러그인이 표시해주는 warning이다. 이 React Hook useXXX has a missing dependency
라는 warning은, "hook에서 state, prop, 함수를 사용하고 있으면 의존성 배열에 넣어줘!" 라는 뜻이다.
리액트는 hook 내부에서 렌더 범위 안에 있는 값을 사용 중임에도 의도적으로 의존성 배열을 빈 배열로 두는 것을 권장하지 않는다. 하지만 warning이 알려주는대로 의존성 배열을 없애거나 필요한 의존성 배열을 추가해주면, 컴포넌트가 의도대로 동작하지 않거나 무한 루프를 경험하게 된다.
useEffect
의 경우라면, 보통 deps
를 빈 배열로 두는 이유는 맨 처음 렌더링 될 때만 이펙트를 실행하고 싶다는 의도일 것이다.useEffect
검색useEffect
를 사용해서 마운트/언마운트/업데이트시에 특정 작업을 할 수 있구나!componentWillMount
, componentDidMount
, componentDidUpdate
라는 메소드가 있구나. 이거를 hook으로 대체해서 쓰는 거였군.useEffect
사용.- deps 없음 : 렌더링마다 이펙트 실행
- deps [ ] : 마운트할 때만 이펙트 실행
- deps 추가 : 추가한 값이 변경될 때만 이펙트 실행
useEffect
와 같은 hook은 라이프 사이클 메서드가 아니다.useEffect
는 state
를 활용하여 동기적으로 부수 효과를 만들 수 있는 메커니즘이다.모든 렌더링은 고유의 prop
/ state
/ 함수
를 가지고 있다.
prop
과 state
를 보는 것이다.state
와 prop
은 상수이자 독립적인 값(특정 렌더링 시의 상태)으로 존재한다.모든 렌더링은 고유의 이펙트를 가지고 있다.
이펙트 함수 자체가 모든 렌더링마다 별도로 존재한다.
매 렌더링마다의 이펙트는 해당 렌더링에 속한 state
, prop
을 바라보고 있다.
그러면 클린업 함수(return ( ) => {...})
는 뭔가?
새로 렌더링 된 후에 클린업을 하는데도 이전
state
를 바라보고 있는 이유는 클린업 함수가 정의된 시점의 렌더링에 있던 값을 읽기 때문이다.
추가적으로 알 수 있는 함수형 컴포넌트의 장점!
클래스형 컴포넌트는
this.state
가 최신 상태를 가리키도록 변경하기 때문에 비동기 함수 안에서state
나prop
을 사용할 때 hook처럼 동작하길 원한다면(상수처럼 렌더링마다 유지되는 값으로 잡아두고 싶다면) 클로저를 사용해서 해결해야한다. 함수형 컴포넌트가 각 렌더링마다 변하지 않는 고유의 값을 가지고 있다는 점은 우리가 컴포넌트 작성할 때 좀 더 데이터의 흐름을 쉽게 생각할 수 있게 해준다.
라이프 사이클의 관점 | 동기화의 관점 | |
---|---|---|
deps 없음 | 렌더링마다 이펙트 실행 | state, prop이 변경될 때마다 실행 |
deps 빈 배열 | 마운트할 때만 이펙트 실행 | 데이터 흐름에 관여하는 어떠한 값도 사용하지 않음. 의존성 배열이 같으므로(없으므로) 이펙트 Skip |
deps 추가 | 추가한 값이 변경될 때만 실행 | 추가한 state, prop가 변경될 때마다 실행 |
리액트는 이펙트 함수를 호출해보지 않고서는 함수가 어떤 일을 하는지 알 수 없다. 따라서 deps의 존재 의미는 다음과 같다.
deps를 언제 이펙트를 다시 실행해야 할 지정할 때 쓰인다는 기존의 직관은 잠시 잊어버려야 한다.
함수형 컴포넌트로 작성시, 특정 라이프사이클에만 무언가를 실행하고 싶다는 생각 자체를 다르게 전환해야 할 필요가 있다. 참고한 레퍼런스에 의하면, 리액트에게 의존성으로 거짓말을 하면 안 된다고 한다. 즉, 이펙트 내에서 렌더 범위 안의 값을 사용하는데도 불구하고, 기존의 직관대로 처음 한 번만 이펙트를 실행하고자 deps
를 빈 배열로 두게 되면 버그가 발생할 확률이 높다.
예를 들면, 아래와 같이 Counter라는 컴포넌트를 작성했다고 치자.
function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
기존의 직관대로라면 개발자의 원래 의도는 컴포넌트를 마운트할 때 인터벌을 한 번만 설정하고 한 번만 제거하는 것이다. 그러나 deps
를 빈 배열로 설정했기 때문에 항상 같은 값의 state
, prop
을 참조하게 되고, 화면에서 count
는 계속 1로 표시된다.
이 코드를 만약에 클래스형 컴포넌트로 작성한다면, 인터벌 설정을 componentDidMount
에 넣고, 인터벌 해제를 componentWillUnmount
에 넣어주면 의도대로 동작하게 된다. hook을 라이프 사이클 관점으로 이해하면 안 되는 게 분명해 보인다.
그러면 함수형 컴포넌트에서 hook을 어떻게 사용하면 좋을까? 방법은 의존성을 솔직하게 적는 것이다.
위의 Counter 컴포넌트를 리팩터 해보자.
function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]); // deps에 count 추가
return <h1>{count}</h1>;
}
이 예제의 경우에는 count
가 변경될 때마다 인터벌이 다시 설정/해제되므로 의도와 다르게 동작할 수 있다.
a. 함수형 업데이트로 수정
function Counter() {
const [ count, setCount ] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
return () => clearInterval(id);
}, []); // deps가 빈 배열
return <h1>{count}</h1>;
}
이렇게 하면 이펙트 함수가 현재 state
를 알 필요가 없어진다. 그러나 count
값에 prop
으로 전달 받은 값으로 계산하고 싶다거나, 여러가지 상태값으로 계산해야할 때는 쓰기 어려울 것이다. 할 수 있는 일이 제한적이다.
b. useReducer 사용하기
이펙트 안에서 연관된 state
를 업데이트하거나 업데이트에 prop
도 필요한 경우 useReducer
로 업데이트 로직 분리할 수 있다.
Counter
컴포넌트를 useReducer를 사용해서 다시 리팩터 해보자.
const initialState = {
count: 0;
}
function reducer(state, action) {
if (action.type === 'tick') {
return state + step;
} else {
throw new Error();
}
}
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
이펙트 내부에서는 상태값을 알 필요 없이 dispatch
로 액션 타입만 알려주면 되고, 업데이트 로직은 리듀서에 작성한다. 다음 렌더링 중에 리듀서가 호출되면 새로운 state
, prop
이 렌더 범위 안으로 들어온다. 이때 리액트는 컴포넌트가 유지되는 한 dispatch
함수가 항상 같다는 것을 보장하므로, deps
에 dispatch
를 추가하든 말든 상관없다.
c. 이펙트 안에서만 쓰이는 함수라면 그 함수를 이펙트 안으로 옮기기
만약에 함수가 이펙트 내부 뿐만 아니라 여러군데에서 반복적으로 사용된다면 어떨까? 이럴 때도 이펙트 안으로 함수를 옮겨야 할까?
d. 컴포넌트 외부로 끌어올리거나 useCallback
으로 감싸기
이펙트 안으로 함수를 옮기고 싶지 않다면,
useCallback
으로 함수를 감싸서 함수 자체가 필요할 때만 바뀔 수 있도록 만든다.useCallback
의 의존성 배열에 들어가는 값이 같다면 해당 함수 또한 같은 함수이며, 이펙트는 다시 실행되지 않을 것이다.prop
을 내려보내는 것 또한 같은 해결책이 적용된다. useCallback
은 함수가 하위 컴포넌트로 전달되어 그 컴포넌트의 이펙트 안에서 호출되는 경우 유용하다. 혹은 하위 컴포넌트의 메모이제이션이 깨지지 않도록 방지할 때도 쓰인다.
- 컴포넌트가 첫 번째로 렌더링 할 때와 그 후에 다르게 동작하는 이펙트를 작성하는 것은
함수형 컴포넌트의 데이터 흐름을 거스르는 것이다.- 리액트는 우리가 지정한 prop, state에 따라 DOM과 동기화하려고 하기 때문이다.
- 함수형 컴포넌트는 라이프 사이클이 아니라 동기화의 관점으로 바라보아야 한다. 렌더링 시 마운트, 업데이트의 구분이 없다.
exhaustive-deps에 대한 궁금점으로 시작해서 리액트 훅의 메커니즘을 이해하게 되었다. 나는 리액트 시작을 함수형 컴포넌트로 시작했지만, 많은 설명들이 클래스형 컴포넌트의 라이프 사이클을 기준으로 쓰여있어서 훅에 대해 잘못 이해하고 쓴 적이 많았다. 리액트 개발자인 댄 아브라모프의 글이 많은 도움이 되었다.
📎 References
1. useEffect는 라이프 사이클 메소드가 아니다.
2. useEffect 완벽가이드