useEffect를 남용하지 말 것
TMT리액트의 불필요한 리렌더링을 줄이는 구체적인 이유
1. useEffect
의 남용과 경쟁 상태(Race Condition) 문제
- 문제:
useEffect
는 비동기 작업이 완료되기 전에 다른 작업이 트리거될 경우, 이전 작업이 종료된 후 상태를 덮어쓰는 경쟁 상태를 유발할 수 있습니다.- 예: 여러 API 요청 중 마지막 요청만 유효해야 하지만, 이전 요청의 응답이 더 늦게 도착하면 상태가 덮어써집니다.
- 해결법:
useEffect(() => { let ignore = false; const request = async (requestId) => { setIsLoading(true); await sleep(Math.random() * 3000); if (!ignore) { setResponse(requestId); setIsLoading(false); } }; request(counter); return () => { ignore = true; }; }, [counter]);
- 이유:
- 클린업 함수를 사용하면 이전 요청의 결과를 무시하도록 설정할 수 있습니다.
ignore
플래그를 활용해 현재 요청만 유효하게 처리하며, 이전 요청으로 인해 잘못된 상태 업데이트가 발생하지 않도록 보장합니다.
- 이유:
2. 불필요한 리렌더링 방지
- 문제:
useEffect
안에서 부모 컴포넌트의 상태를 업데이트하면, 자식의 로컬 상태 변경과 부모의 상태 변경이 상호작용하면서 추가적인 렌더링을 유발합니다.- 이는 성능 저하뿐만 아니라 코드 복잡성 증가로 이어집니다.
- 해결법:
- 이벤트 핸들러에서 상태를 직접 업데이트하도록 변경.
function handleClick() { const newValue = !isOn; setIsOn(newValue); // 자식의 상태 업데이트 onChange(newValue); // 부모의 상태 업데이트 }
- 이유:
- 이벤트 핸들러는 상태 변경의 진입점이므로, 여기에서 필요한 모든 업데이트를 처리하는 것이 직관적이고 효율적입니다.
useEffect
를 생략하면 불필요한 렌더링을 줄일 수 있습니다.
- 이벤트 핸들러에서 상태를 직접 업데이트하도록 변경.
3. 데이터 흐름 체인 유지
-
문제:
- 자식 컴포넌트에서 부모 컴포넌트로 데이터를 전달하려면, 데이터를 다시 상위 상태로 끌어올려야 합니다. 이는 복잡한 데이터 흐름과 디버깅을 어렵게 만듭니다.
- 예:
useEffect(() => { if (data) { onFetched(data); } }, [onFetched, data]);
- 자식에서 데이터를 부모로 보내는 코드는 비선언적이며, 유지보수가 어렵습니다.
-
해결법:
- 상태를 부모에서 관리하고, 자식은 데이터를 단순히 표현하도록 변경.
function Parent() { const data = useFetchData(); return <Child data={data} />; } function Child({ data }) { return <>{JSON.stringify(data)}</>; }
- 이유:
- 부모가 상태의 진실의 출처(Source of Truth) 역할을 하며, 데이터 흐름이 단방향으로 유지됩니다.
- 데이터 흐름이 명확해지므로 디버깅과 유지보수가 용이해지고, 코드 가독성이 높아집니다.
- 상태를 부모에서 관리하고, 자식은 데이터를 단순히 표현하도록 변경.
4. 앱 초기화 로직과 useEffect
대체
-
문제:
- 앱 초기화 로직을
useEffect
에 포함하면, 개발 모드에서 React Strict Mode로 인해 컴포넌트가 여러 번 마운트되면서 불필요한 실행과 클린업 함수 구현이 필요할 수 있습니다. - 이는 동시성 모드에서 컴포넌트가 다시 렌더링될 가능성이 높은 상황에서 문제가 됩니다.
- 앱 초기화 로직을
-
해결법:
- 앱 초기화 로직을
useEffect
대신 렌더링 전에 실행.if (typeof window !== "undefined") { someOneTimeLogic(); } function App() { // ... }
- 이유:
- 초기화 로직이 렌더링 이전에 실행되므로, 리렌더링이나 클린업 함수 구현 부담이 사라집니다.
- 컴포넌트 생애주기와 상관없이 앱 생애주기 단위로 로직을 처리할 수 있습니다.
- 앱 초기화 로직을
5. 언마운트된 컴포넌트에서 상태 업데이트
-
문제:
- 기존에는 언마운트된 컴포넌트에서 상태를 업데이트할 경우 경고가 발생했으나, 이를 피하려다 오히려 복잡한 로직(예:
useMountedRef
)이 추가되었습니다. - 예전 방식:
const useMountedRef = () => { const mounted = useRef(true); useEffect(() => { return () => { mounted.current = false; }; }, []); return mounted; }; if (mountedRef.current) { setLoading(false); }
- 기존에는 언마운트된 컴포넌트에서 상태를 업데이트할 경우 경고가 발생했으나, 이를 피하려다 오히려 복잡한 로직(예:
-
해결법:
- 언마운트 여부를 확인하지 않고 상태를 그대로 업데이트.
const handleDeleteBill = async (id) => { setLoading(true); await axios.delete(`/bill/${id}`); setLoading(false); };
- 이유:
- 리액트 팀은 해당 경고를 제거했으며, 상태를 업데이트한다고 해서 실제로 메모리 누수가 발생하지 않습니다.
- 복잡한 로직을 제거하면 코드가 간결해지고 유지보수성이 높아집니다.
- 미래에 리액트 상태 보존 기능을 사용해야 할 경우, 기존 복잡한 로직이 오히려 장애물이 될 수 있습니다.
- 언마운트 여부를 확인하지 않고 상태를 그대로 업데이트.
왜 이렇게 작성해야 하는가?
- 성능 최적화:
- 불필요한 렌더링이나 비동기 작업을 줄임으로써 성능을 개선합니다.
- 코드의 명확성:
- 단방향 데이터 흐름 유지로 가독성과 유지보수성이 높아집니다.
- 디버깅 용이성:
- 데이터의 흐름이 명확해지고, 상태 변경의 책임이 분리되므로 문제를 쉽게 추적할 수 있습니다.
- 미래 확장성:
- 리액트 생태계의 최신 기능(예: 상태 보존 메커니즘)을 쉽게 통합할 수 있습니다.
- 불필요한 복잡성 제거:
- 잘못된 경고를 우회하려는 복잡한 로직 대신, 리액트의 기본 동작에 따라 코드를 단순화합니다.