서론
React에서 컴포넌트의 lifecycle에 맞춰 동작하는 useEffect는 매우 자주 사용되는 훅이다. 하지만 이 훅을 잘못 사용하면 예기치 않은 동작이나 성능 문제를 일으킬 수 있다. 이 글에서는 useEffect를 사용할 때 흔히 저지르는 실수들과 그 해결 방법을 다룰 것이다.
1. 객체는 참조한다
JavaScript에서 객체는 non-primitive 자료형이다. 즉, 객체의 내용이 같아도 두 객체는 서로 다른 참조 값을 가진다. 예를 들어 다음 코드를 보자.
const obj1 = { a: 1 };
const obj2 = { a: 1 };
console.log(obj1 === obj2); // false
이 두 객체는 구조가 같지만 서로 다른 참조를 갖고 있으므로 === 비교에서는 false가 나온다. 이런 특성 때문에, 객체를 useEffect의 dependency로 사용하면 문제가 생길 수 있다. 상태가 변경되면서 렌더링될 때마다 새로운 객체가 생성되기 때문에 useEffect가 의도하지 않게 계속 실행될 수 있다.
import React, { useEffect, useState } from 'react';
function Example() {
const [data, setData] = useState({ a: 1 });
useEffect(() => {
console.log('useEffect 실행');
}, [data]);
return <div>{data.a}</div>;
}
위 코드에서는 data가 객체이기 때문에, 컴포넌트가 재렌더링될 때마다 useEffect가 실행된다. 이를 방지하려면 useEffect의 dependency로 primitive 값을 사용하는 것이 좋다.
2. useEffect 내부에서 fetch를 하는 경우
useEffect 내부에서 API 요청을 처리하는 패턴은 흔하지만, 예기치 않은 side effect를 일으킬 수 있다. 예를 들어, 컴포넌트가 재렌더링되거나 unmount될 때 요청이 중복되거나 취소되지 않는 문제가 발생할 수 있다. 또한 캐시를 활용하지 않기 때문에, 컴포넌트가 언마운트된 후 다시 마운트되면 데이터를 다시 패치해야 한다. 이는 불필요한 네트워크 요청을 유발하고, 애플리케이션 성능에 영향을 줄 수 있다. 또한 race condition을 마주할 수 있는데, 네트워크 응답이 요청 순서와 다른 순서로 도착할 경우 문제가 발생할 수 있다. 이를 해결하려면 패치 중인 상태를 추적하고, 요청이 취소되거나 완료되지 않은 상태에서의 처리를 추가로 구현해야 한다.
대신, React Query 같은 라이브러리를 사용하는 것이 더 좋은 방법이다. 이 라이브러리는 데이터 페칭과 관련된 로직을 잘 관리해준다. 아래에는 react에서 data fetch를 하는 방법들을 소개한다.
3.1. useeffect 내부에서 fetch
ignore 플래그를 사용해 컴포넌트가 언마운트되었을 때 이전 요청을 무시함으로써 race condition 문제를 해결한다. react에서 data를 fetch하는 가장 기본적인 방법이다.
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true; // 컴포넌트 언마운트 시 요청 취소
};
}, [person]);
return (
<div>
{bio ? <p>{bio}</p> : <p>Loading...</p>}
</div>
);
}
3.2. 컴포넌트 외부에서 fetch
또 다른 방법으로는 데이터 패칭 로직을 컴포넌트 외부에서 선언하고, 컴포넌트 내부에서 useEffect를 사용하지 않고 직접 호출하는 방식이다. 이 방법은 컴포넌트가 초기에 마운트될때, useeffect를 쓸 경우 dependecy를 비워둘 때를 대신하여 사용할수 있다. 이 방법 useEffect가 필요하지 않으며, 초기 데이터를 컴포넌트가 렌더링되기 전에 준비할 수 있다.
// 컴포넌트 외부에서 데이터를 미리 패칭하는 함수
async function initializeData() {
const response = await fetch('https://api.example.com/data');
return await response.json();
}
// 컴포넌트 외부에서 데이터를 미리 가져오기
const data = await initializeData();
function MyComponent() {
return (
<div>
{data.map(item => (
<p key={item.id}>{item.name}</p>
))}
</div>
);
}
export default MyComponent;
3.3 React-query 사용
React Query는 데이터를 효율적으로 전역상태로 관리하는 데 매우 유용한 라이브러리다. react-query의 소개를 보면 데이터 패칭라이브러리가 아닌 전역상태 관리 라이브러리라 명시하고 있다. 그러나 비동기 데이터를 fetch하고, 관리하는데 효율적이기에 매우 효율적인 라이브러리이다. react query의 useQuery는 데이터를 가져오는 비동기 작업을 처리하고, 캐싱, 로딩 상태, 에러 처리를 자동으로 관리해준다. 또한 가져온 데이터를 selector를 통해 새로운 state를 만들지 않고 값을 변경하여 쓸 수 있기에 상태관리에도 유용하다.
import { useQuery } from 'react-query';
function FetchData() {
const { data, error, isLoading } = useQuery('fetchData', () =>
fetch('https://api.example.com/data').then((res) => res.json())
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error occurred</div>;
return <div>{JSON.stringify(data)}</div>;
}
3.4 서버 컴포넌트 사용
React팀에서 또한 권고하고 있는 방법이다. 비록 vanila react에서 구현하는데 까다로움이 있지만, react 19에서 suspense의 실험적 기능을 변경한것에 알 수 있듯이(react query팀의 반대로 철회한 것으로 알고있긴 하지만) react팀은 server component와 이를 이용한 render-and-fetch가 아닌 fetch-and-render 패턴을 권고하고 있다. 왜냐하면 서버에서 미리 데이터를 생성하기에 더 빠른 FMP를 달성할 수 있기 때문이다. 따라서 React Server Components를 사용할 경우, 서버 측에서 데이터를 미리 가져와 클라이언트에 전달할 수 있다. 이렇게 하면 데이터를 클라이언트에서 다시 가져올 필요 없이, 서버에서 렌더링한 데이터를 클라이언트에 전달한다.
// 서버 컴포넌트 예시
export default async function MyServerComponent() {
const data = await fetch('https://api.example.com/data').then(res => res.json());
return (
<div>
{data.map(item => (
<p key={item.id}>{item.name}</p>
))}
</div>
);
}
이 방식은 클라이언트 측에서 데이터 패칭과 관련된 문제를 완전히 제거하고 성능을 향상시킬 수 있다. 서버에서 데이터를 처리하고 렌더링하므로 클라이언트 측에서의 네트워크 요청을 줄인다.
4. prop이 변경될때 자식 component 초기화
prop이 변경될 때 자식 component를 초기화시키고 싶을 수 있다. 그러나 useEffect로 prop이 변경될 때 상태를 초기화하는 방식은 비효율적이다. 왜냐하면 부모 컴포넌트와와 그 자식 컴포넌트들이 먼저 오래된 값으로 렌더링된 후 다시 렌더링되기 때문이다. (effect는 rendering이후에 일어나는 일임을 기억하자)
자식 컴포넌트에 prop을 key로 전달하면, React는 서로 다른 userId를 가진 두 자식 컴포넌트를 별도의 컴포넌트로 취급하여 상태를 공유하지 않도록 처리한다.
key(여기서는 userId를 사용)를 변경할 때마다 React는 DOM을 다시 생성하고 Profile 컴포넌트와 그 자식 컴포넌트들의 상태를 모두 초기화한다.
// ❌ 틀린 방법: useEffect로 prop이 변경될 때 상태를 초기화하는 방법
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
return (
<Profile userId={userId} />
);
}
// ✅ 옳은 방법: key를 사용해 상태 자동 초기화
export default function ProfilePage({ userId }) {
return (
<Profile userId={userId} key={userId} />
);
}
function Profile({ userId }) {
const [comment, setComment] = useState('');
// 상태는 userId가 변경될 때 자동으로 초기화됩니다.
return (
<div>
{/* Profile 내용 */}
</div>
);
}
5. prop이 변경될 때 자식 컴포넌트 state 변경
prop이 변경될 때 자식 컴포넌트의 상태를 useEffect로 조정하는 방법 역시 비효율적이다. 부모 컴포넌트와 자식 컴포넌트들이 오래된 state값을 가지고 렌더링된 후, setter function이 호출되어 다시 렌더링을 발생시키기 때문이다. 이 과정은 반복되어 성능에 좋지 않은 영향을 미친다.
Effect 대신 rendering 중에 state를 직접 조정하는 것이 더 효율적이다. prop이 변경될 때 setter function을 호출하지만, React는 렌더링 도중 이를 감지하고 해당 상태 업데이트를 즉시 반영하여, 자식들이 잘못된 상태로 렌더링되지 않도록 한다.
가장 좋은 방법은 변수에 저장하여 렌더링 시점에 선택된 항목을 계산하는 방법이다. 선택된 항목의 ID만 저장하고, 렌더링 시점에 해당 ID가 리스트에 있는지 계산하여 선택된 항목을 결정하면 된다. 이렇게 하면 상태를 조정할 필요가 없어지며, items가 변경되더라도 선택된 항목을 유지하거나 없을 때만 자동으로 초기화된다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// ❌ 잘못된 방법: useEffect로 상태를 변경하는 방식
useEffect(() => {
setSelection(null);
}, [items]);
// ✅ 올바른 방법: 렌더링 중에 상태를 직접 조정하는 방식
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ✅ Best practice: selectedId를 이용해 상태 계산
const [selectedId, setSelectedId] = useState(null);
const selectionById = items.find(item => item.id === selectedId) ?? null;
// ...
}
6. useEffect 내부에 event handler 로직 사용
Effect에서 이벤트에 따른 로직을 처리하는 것은 불필요하며, 버그를 일으킬 가능성이 크다. 예를 들어, 앱이 페이지 새로고침 후에도 쇼핑 카트를 state에 의해 기억한다면, 한 번 제품을 장바구니에 추가한 후 페이지를 새로고침하면 알림이 다시 나타난다. 이는 product.isInCart가 페이지 로드 시 이미 true이기 때문에, Effect가 showNotification()을 호출하기 때문이다.
function ProductPage({ product, addToCart }) {
// ❌ 잘못된 방법: Effect에서 이벤트에 따른 로직을 처리하는 방식
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ✅ 올바른 방법: 이벤트 핸들러에서 로직을 처리하는 방식
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
아래 예시또한 마찬가지이다. useEffect내부에서 특정 이벤트에 한정된 로직을 넣으면 안된다. 위의 예시와 아래 예시에서 볼 수 있듯이, logic이 사용자와의 어떤 상호작용으로 인해 일어나는 것이라면 event handelr안에 있어야 한다. 그것이 아니라 사용자가 컴포넌트를 보게됨으로써(rendering 후) 일어난다면, effect안에 logic이 있어야 한다.
function Form() {
// ✅ 올바른 방법: 이 로직은 컴포넌트가 표시되었기 때문에 실행되어야 함
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ 올바른 방법: 이벤트와 관련된 로직은 이벤트 핸들러 안에 있어야 함
post('/api/register', { firstName, lastName });
}
// 🔴 잘못된 방법: Event logic이 Effect 안에 있음
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
7. 앱이 초기화될때 useEffect 사용
앱이 로드될 때만 한 번 실행되어야 하는 로직을 최상위 컴포넌트의 Effect에 넣고 싶을 수 있다. 그러나 일반적으로, 컴포넌트는 다시 마운트되는 상황에 대비해야 하며, 최상위 App 컴포넌트도 예외는 아니다.
function App() {
// ❌ 잘못된 방법: 딱 한번 실행되는 코드를 effect안에 넣음
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
if (typeof window !== 'undefined') {
// ✅ 올바른 방법: 앱 로드 시 한 번만 실행됨
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
하지만 이 패턴을 과도하게 사용하면 예상치 못한 동작이나 성능 저하를 초래할 수 있다. 앱 전체에서 필요한 초기화 로직은 App.js 같은 루트 컴포넌트 모듈에만 두는 것이 좋다.
8. useEffect 내부에서 외부 데이터 수동으로 subscribe
컴포넌트가 React 외부의 데이터를 구독해야 할 때가 있다. 이 데이터는 third-party library나 브라우저 API에서 제공될 수 있으며, 이러한 데이터는 React의 감지 없이 변경될 수 있기 때문에 컴포넌트가 이를 수동으로 구독해야 한다. 이런 경우 종종 Effect를 사용하게 된다.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
Effect 대신 React에서 외부 저장소에 구독하기 위해 고안된 useSyncExternalStore Hook을 사용하는 것이 더 좋다. Effect를 삭제하고 useSyncExternalStore를 사용하여 코드를 간소화할 수 있다.
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 같은 함수를 전달하는 한 React는 다시 구독하지 않음
() => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
() => true // 서버에서 값을 가져오는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
이 접근 방식은 Effect를 사용해 가변 데이터를 수동으로 React 상태와 동기화하는 것보다 오류를 방지할 수 있다. 이 코드를 개별 컴포넌트에 반복하지 않기 위해, 위의 useOnlineStatus와 같은 커스텀 Hook을 작성하는 것이 일반적이다.
Reference
https://react.dev/learn/you-might-not-need-an-effect
https://react.dev/reference/react/useEffect#fetching-data-with-effects
'React' 카테고리의 다른 글
React 19의 새로운 기능들 (1) | 2024.09.27 |
---|---|
컴포넌트 구성은 훌룡합니다 그런데 (3) | 2024.09.26 |
React Hook을 사용할 때 실수들 (1): useState (1) | 2024.09.10 |
React Hook 이해하기 (4): useMemo, useCallback (0) | 2024.09.10 |
React Hook 이해하기 (3): useRef (0) | 2024.09.10 |