* 이 글은 A complete guide to useEffect 를 번역 및 요약하였습니다.
11. 의존성을 솔직하게 적는 방법 (2) - 이펙트가 자급자족 하도록 하자
이펙트의 의존성에 있는 count를 제거해 봅시다.
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
여기서 생각을 해보죠? 무엇 때문에 deps의 count를 쓰고 있나요? 오직 setCount를 위해 사용하고 있습니다. 그렇다면 스코프안에서 count를 사용하지 않으면 되겠죠. 이전 상태를 기준으로 상태 값을 변경하고 싶을 때는 setState의 함수에서 변경을 하면 됩니다.
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
count는 setCount(count+1)이라고 썼기 때문에 이펙트 안에서 필요한 의존성이었습니다. 우리가 원했던 건 단지 count를 count+1로 변환하여 리액트에게 알려주는 것 뿐이었습니다. 하지만 리액트는 이미 현재의 count 값을 알고 있죠. 그렇기에 setState의 함수 내에서 변경을 함으로써 "리액트 너가 가지고 있는 count에서 1을 더해서 업데이트 해줘"라고 알려줄 수 있는 것이죠.
중요한 점은 필요없는 의존성을 제거하고 문제를 해결 했다는 점입니다. 이제 이펙트는 더 이상 랜더링 스코프에서 count 값을 읽어 들이지 않습니다.
이펙트가 한번이 실행되었다고 하더라도, 인터벌 콜백은 인터벌이 실행될 때마다 c => c+1이라는 우리가 원하는 업데이트를 진행합니다.
12. 액션을 업데이트로부터 분리하기
이미 문제 해결을 했더라도, 항상 더 나은 방법을 생각해보아야 합니다. setCount(c => c+1)이 과연 가장 좋은 방법일까요? 결론을 말씀드리면 제한적인 해결 방법입니다. 예를 들어 서로에게 의존하는 두 상태가 있을 때, prop 기반으로 다음 상태를 계산을 해야 할때 등등, 여러 상황에 유연하게 대처하지 못합니다.
이에 대한 해결책으로 useReducer를 소개해드립니다.
이전의 예제를 조금만 바꿔보죠. count와 step 두 가지 상태의 값은 서로에게 의존합니다. 인터벌은 step 입력 값에 따라 count 값을 더 한다고 가정 해보겠습니다.
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
);
}
step이 이펙트의 의존성 배열에 들어가 있는 걸 확인하실 수 있습니다. step이 이펙트 안에서 사용되고 있기 때문이죠. 위 예제 코드는 잘 돌아갑니다. 하지만 step이 변경되면 인터벌을 다시 시작하겠죠.
좋지 않은 패턴입니다. 이펙트의 의존성 배열에서 step을 제거하기 위해서는 어떻게 해야 할까요?
어떤 변수가 다른 상태 변수 값과 연관이 된다면, 두 상태 변수 모두 useReducer로 교체를 해야 합니다.
리듀서는 컴포넌트에서 일어나는 액션, 그리고 액션에 대한 반응으로 상태가 어떻게 업데이트 되어야 할 지를 분리합니다.
이펙트 안에서 step의 의존성을 dispatch로 바꾸겠습니다.
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
여기서 꼭 알아두셔야 할 점이 있습니다. 도대체 어떤게 좋을까요? 리액트는 컴포넌트가 유지되는 한 dispatch 함수가 항상 같다는 것을 보장합니다. 즉, 이펙트의 의존성이 항상 같기 때문에 이펙트는 한번만 호출 됩니다. step이 변경된다고 하더라도, 인터벌이 다시 시작 되지 않죠. (리액트는 dispatch, setState, useRef 함수가 항상 같다는 것을 보장합니다. 의존성 배열 작성 유무도 본인들 마음입니다.)
dispatch는 무슨 일이 일어 났는지 알려주는 정보를 인코딩하는 액션입니다. 이펙트는 어떻게 상태를 업데이트 할 것인지에 대한 신경을 쓰지 않아도 되고, 단지 무슨 일이 일어 났는지만을 알려줍니다. 액션을 보내면 업데이트 로직을 모아 둔 리듀서에서 처리를 하게 됩니다.
const initialState = {
count: 0,
step: 1,
};
function reducer(state, action) {
const { count, step } = state;
if (action.type === 'tick') {
return { count: count + step, step };
} else if (action.type === 'step') {
return { count, step: action.step };
} else {
throw new Error();
}
}
13. 왜 useReducer가 Hooks의 왕인가?
이번에는 다른 예시를 들어보겠습니다. 다음 상태를 계산하기 위해 props가 필요하다면 어떻게 해야 할까요? 아래 코드를 보시죠.
function Parent() {
return <Counter step={1} />
}
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === 'tick') {
return state + step;
} else {
throw new Error();
}
}
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
prop인 step의 값이 1일 때 리듀서를 컴포넌트 안에 정의하여 떨어지는 prop값을 읽게 하면 문제는 해결됩니다.
이 경우 또한 랜더링 간 dispatch의 동일성은 여전히 보장됩니다.
리듀서는 업데이트 로직과 액션에 대한 이후 행동을 서술하는 것을 분리 할 수 있수 있도록 만듭니다. 또한 이펙트의 불 필요한 의존성을 제거하여 자주 실행되는 것을 피할 수 있도록 도와줍니다.
14. 함수를 이펙트 안으로 옮기기
흔한 실수 중 하나가 함수는 의존성에 포함되면 안되는 것입니다.
function SearchResults() {
const [data, setData] = useState({ hits: [] });
async function fetchData() {
const result = await axios(
'https://hn.algolia.com/api/v1/search?query=react',
);
setData(result.data);
}
useEffect(() => {
fetchData();
}, []); // 이거 괜찮은가?
// ...
위 코드는 동작합니다. 하지만 유연하지 않죠. 만약 각 함수가 더 커져서 코드를 아래와 같은 방식으로 나누었다고 생각해 보겠습니다.
function SearchResults() {
// 이 함수가 길다고 상상해 봅시다
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
// 이 함수도 길다고 상상해 봅시다
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
그리고 나뉜 여러 함수들 중 하나가 state나 prop을 사용한다고 가정해봅시다.
function SearchResults() {
const [query, setQuery] = useState('react');
// 이 함수가 길다고 상상해 봅시다
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
// 이 함수가 길다고 상상해 봅시다
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
만약 이펙트에 deps의 query를 추가하는 것을 깜빡했다면, prop과 state의 변화에 따른 동기화는 실패 할 것입니다.
이러한 문제를 해결하기 위해서는 오로지 이펙트 안에서 쓰는 함수라면, 그 함수를 이펙트 안으로 옮겨야 합니다.
function SearchResults() {
// ...
useEffect(() => {
// 아까의 함수들을 안으로 옮겼어요!
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, []); // ✅ Deps는 OK
// ...
}
이펙트에서만 실행되어야 하는 함수들은 이펙트에서 처리하는 것이 좋습니다. 컴포넌트 범위 바깥에 있는 그 어떠한 것도 의존하고 있지 않은 경우에는 더더욱 말이죠.
혹시 state의 query 값을 사용할 경우에도 이펙트 안에 함수만 고치면 된다는 것을 쉽게 알 수 있습니다. deps의 의존성도 말이죠.
function SearchResults() {
const [query, setQuery] = useState('react');
useEffect(() => {
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, [query]); // ✅ Deps는 OK
// ...
}
useEffect가 만들어진 디자인은 데이터 흐름의 변화를 알아차리고 이펙트가 어떻게 동기화해야할지 선언하는 것이 주 목적입니다.
eslint를 사용하면 손쉽게 이러한 목적을 쉽게 달성할 수 있죠. eslint-plugin-react-hooks 플러그인의 exhaustive-deps 툴을 사용하면, 어떤 의존성이 빠져 있는지 제안을 받을 수 있습니다.
15. 이 함수는 이펙트에 넣으면 안되요
컴포넌트 안에 정의된 함수는 매 랜더링마다 바뀐다는 걸 알고 계시나요? 1편에서 이야기 해주듯, 모든 랜더링은 고유에 이펙트를 가져갑니다.
이로 인해 한가지 문제가 발생합니다. 예를 들어 두 이펙트가 getFetchUrl을 호출한다고 해보겠습니다.
function SearchResults() {
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // 🔴 빠진 dep: getFetchUrl
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // 🔴 빠진 dep: getFetchUrl
// ...
}
deps에 getFetchUrl을 넣어 보면 어떻게 될까요? 너무 자주 바뀌어 버립니다.
function SearchResults() {
// 🔴 매번 랜더링마다 모든 이펙트를 다시 실행한다
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다
// ...
}
둘 중에 나은 해결책은 첫 번째 예시인 getFetchUrl 함수를 의존성 배열에서 빼는 것입니다. 하지만 이것도 임시적인 해결책에 불가합니다. 언젠가 이펙트에 의해 다루어질 필요가 있는 데이터의 흐름을 쫓아가지 못한다는 단점이 생깁니다.
이럴 때 두 가지 간단한 해결책이 있습니다.
첫 번째는 함수가 컴포넌트 스코프 안의 어떠한 것도 사용하지 않는다면, 컴포넌트 외부로 끌어올려두고 이펜트 안에서 자유롭게 사용하면 됩니다.
// ✅ 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // ✅ Deps는 OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // ✅ Deps는 OK
// ...
}
getFetchUrl 함수는 랜더링 스코프에 포함되어 있지 않습니다. 또한 데이터 흐름에 아무런 영향을 받지 않기 때문에 이펙트 deps에 명시할 필요도 없습니다.
두 번째 방법은 useCallback을 사용하는 것입니다.
function SearchResults() {
// ✅ 여기 정의된 deps가 같다면 항등성을 유지한다
const getFetchUrl = useCallback((query) => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // ✅ 콜백의 deps는 OK
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
// ...
}
useCallback은 함수 자체가 필요할 때만 바뀔 수 있도록 만드는 것입니다. 현재 getFetchUrl 함수는 'react', 'redux'라는 값에 대한 오로지 2가지 검색 결과를 보여주고 있습니다. 만약 유저가 입력을 해서 state인 query 값을 받는다면, 인자가 아닌 getFetchUrl은 지역 상태로부터 검색 결과를 보여주게 됩니다.
이때 useCallback의 deps 의존성 배열이 빠져 있다는 사실을 손쉽게 파악할 수 있습니다.
function SearchResults() {
const [query, setQuery] = useState('react');
const getFetchUrl = useCallback(() => { // No query argument
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // 🔴 빠진 의존성: query
// ...
}
이제 빠진 의존성에 query를 포함한다면, query가 바뀔 때마다 함수는 다시 실행 될 것입니다.
function SearchResults() {
const [query, setQuery] = useState('react');
// ✅ query가 바뀔 때까지 항등성을 유지한다
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]); // ✅ 콜백 deps는 OK
useEffect(() => {
const url = getFetchUrl();
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
// ...
}
useCallback으로 인해 query가 같다면, getFetchUrl 함수도 같을 것이며, 이펙트는 다시 실행 되지 않을 것입니다. 만약 query가 다르다면, getFetchUrl 함수도 다를 것이며, 이펙트는 다시 실행 되겠죠.
단지 데이터 흐름을 이용하여 동기화에 대한 개념을 적용시킨 것입니다. state가 아닌 prop으로 내려오는 것 또한 같습니다.
function Parent() {
const [query, setQuery] = useState('react');
// ✅ query가 바뀔 때까지 항등성을 유지한다
const fetchData = useCallback(() => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
// ... 데이터를 불러와서 리턴한다 ...
}, [query]); // ✅ 콜백 deps는 OK
return <Child fetchData={fetchData} />
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ 이펙트 deps는 OK
// ...
}
fetchData는 오로지 Parent의 query의 상태가 다를때에만 변하기 떄문에, Child 컴포넌트는 필요한 데이터가 아니라면 다시 fetching 하지 않습니다.
16. 함수도 데이터 흐름의 일부일까요?
지금까지 말씀드린 패턴은 클래스 컴포넌트에서 사용할 수 없습니다. 이펙트와 라이프사이클의 패러다임은 다르다는 것을 여실히 보여줍니다. 클래스 컴포넌트에서 쓸수 있도록 치환해보겠습니다.
class Parent extends Component {
state = {
query: 'react'
};
fetchData = () => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
// ... 데이터를 불러와서 무언가를 한다 ...
};
render() {
return <Child fetchData={this.fetchData} />;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
render() {
// ...
}
}
위 코드에서는 mount가 될 때는 잘 동작합니다. 허나 update가 될 때는 원하는 대로 동작하지 않습니다.
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
// 🔴 이 조건문은 절대 참이 될 수 없다
if (this.props.fetchData !== prevProps.fetchData) {
this.props.fetchData();
}
}
render() {
// ...
}
}
여기서 fetchData는 클래스 메서드입니다. state가 바뀌었다고 해서 fetchData 메서드는 달라지지 않습니다. 항상 같기 때문에 다시 데이터를 페칭하지 않겠죠. 그렇다면 조건문을 제거 해볼까요?
componentDidUpdate(prevProps) {
this.props.fetchData();
}
이렇게 하면 매번 다시 랜더링 할 때마다 데이터를 호출하겠죠. query값이 바뀌지도 않았는데도 말이죠.
이를 해결하기 위한 방법은 child 컴포넌트가 사용하지도 않는 query를 prop으로 넘기는 것입니다.
query가 바뀔 때마다 다시 데이터를 불러옵니다.
class Parent extends Component {
state = {
query: 'react'
};
fetchData = () => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
// ... 데이터를 불러와서 무언가를 한다 ...
};
render() {
return <Child fetchData={this.fetchData} query={this.state.query} />;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.props.fetchData();
}
}
render() {
// ...
}
}
불 필요한 prop을 내려보내면서 부모 컴포넌트의 캡슐화를 깨트리는 일은 클래스 컴포넌트에서는 흔한 상황이었습니다. 클래스 컴포넌트에서, fetchData 함수는 prop 자체는 실제로 데이터 흐름에서 차지하는 부분이 없습니다. 그러므로 함수만 필요할 때도 차이를 비교하기 위해 온갖 다른 데이터를 전달하는 불 필요한 상황들이 발생했습니다. 부모 컴포넌트에서 내려온 함수 fetchData 가 어떤 상태에 기대고 있는지, 아니면 다른 이유가 있는지 알 수가 없습니다.
useCallback을 사용하면, 함수는 명백하게 데이터 흐름에 포함됩니다. 함수의 입력값이 바뀌면 함수 자체가 바뀌고, 만약 그렇지 않다면 같은 함수로 남아 있겠죠. 이와 비슷하게 useMemo 또한 복잡한 객체에 대해 같은 방식의 해결책을 제공합니다.
function ColorPicker() {
// color가 진짜로 바뀌지 않는 한
// Child의 얕은 props 비교를 깨트리지 않는다
const [color, setColor] = useState('pink');
const style = useMemo(() => ({ color }), [color]);
return <Child style={style} />;
}
그렇다고 해서 useCallback을 남발해서는 안됩니다. useCallback은 함수가 자손 컴포넌트로 전달되어 이펙트 안에서 호출되는 경우 가장 유용합니다. 또는 자손 컴포넌트의 메모이제이션을 깨뜨리지 않기 위해 사용되기도 합니다. 그러나 함수를 자손으로 내려보내는 것보다 가장 좋은 방법은 Custom hook을 사용하는 것입니다.
16. Race Condition에 대해
아래는 클래스 컴포넌트에서 데이터를 불러오는 예제입니다.
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
컴포넌트가 업데이트 되는 상황을 다루지 않았군요. 다시 아래의 코드를 보시죠.
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
자! 이제 동작은 잘 될까요? 여기에도 버그가 존재합니다. 데이터를 페칭하는 순서를 보장할 수 없습니다. 만약 { id: 10 } 으로 데이터를 요청하고 { id: 20 }으로 바꾸었다면, { id: 20 }의 요청이 먼저 시작됩니다. 결과적으로 먼저 시작된 요청이 더 늦게 끝나서 잘못된 상태를 덮어 씌울 수 있습니다.
이를 Race Condtion(경쟁 상태)라 불립니다. 보통 비동기 호출의 결과에서 해당 이슈들이 발생하죠. (async / await)
이러한 문제를 이펙트가 직접적으로 해결 해 주지는 않습니다. 그래도 async 함수를 이펙트에 직접적으로 전달하면 경고창이 생기기는 합니다.
가장 좋은 방법은 취소 기능을 지원하는 것입니다. 그러면 클린업 함수에서 바로 비동기 함수를 취소하면 되겠죠.
그러한 상황이 되지 않는다면, boolean 값으로 흐름이 멈춰야 하는 상황을 조절하는 것입니다.
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) {
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
// ...
}
17. 진입 장벽을 더 높여보자
useEffect의 개념으로 생각하면, 모든 것들은 기본적으로 동기화됩니다. 모든 사이드 이펙트들은 데이터 흐름의 일부입니다.
useEffect를 다루기 위한 초기비용은 높지만, 현재 그렇게 많이 사용되지는 않을 것입니다. 아마 데이터 페칭할 때 가장 많이 사용되겠죠.
장기적인 관점에서는 데이터를 불러오는 로직을 해결하기 위한 Suspense가 도입될 예정입니다. 서드 파트 라이브러리들이 비동기적인 행위(코드, 데이터, 이미지 등등)가 준비 될 때까지 랜더링을 잠시 미룰 수 있도록 리액트에게 요청할 수 있습니다.
Suspense가 데이터를 불러오는 경우를 더 많이 커버하게 되면, useEffect는 더욱 로우 레벨로 내려가 props와 state의 동기화가 꼭 필요할 때만 사용하는 도구가 될 것입니다.
그 때까지 Custom Hook을 재 사용 될 수 있도록 만들어 데이터 페칭 로직을 다루는 것이 가장 확실한 방법이라 생각됩니다.
긴 글 읽어 주셔서 감사합니다.
useEffect 완벽 가이드 1편이 궁금하시다면?
< 참고자료 >
[사이트] #A complete Guide to useEffect
A complete Guide to useEffect end
'Language & Framework & Library > React' 카테고리의 다른 글
React Hooks의 커다란 빙산 (6) | 2023.11.17 |
---|---|
Function component와 Class component는 어떻게 다를까? (1) | 2023.11.04 |
useEffect 완벽 가이드 1편 (1) | 2023.10.29 |
Design Patterns - Compound component pattern (0) | 2023.10.21 |
Re-rendering과 memoization (2) | 2022.03.13 |