서론
"useEffect에 대해 설명해주세요"라는 질문을 받으면 어떤 대답을 해야 할까요?
리액트 공식 문서에 따르면 effect는 함수 컴포넌트와 외부 시스템과의 동기화를 목적으로 만들어졌다고 합니다.
여기서 외부 시스템이란 DB,네트워크, 브라우저 DOM등이 될 수 있습니다.
react 앱을 개발 서버에서 실행하게 되면 useEffect가 의존성 배열과 상관없이 두번 실행되는 것을 경험하게 됩니다.
이 부분 역시 동기화라는 키워드와 연결됩니다. 즉 이팩트 함수와 클린업 함수를 통해 subscrib와 unsubscrib가 올바르게 동작하는지 확인하기 위한 처리인 것이죠. 이제 부터 몇 가지 사례를 통해 불필요한 useEffect에 대해 알아보겠습니다.
1. 파생 상태 처리
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 중복 상태임으로 effect를 통한 처리가 불필요합니다.
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
어떤 state의 변화에 따라 새로운 state를 업데이트해야 하는 경우가 생길 수 있습니다.
이런 경우를 흔히 파생 상태라고 부르는데요. 이런 경우는 말 그대로 하나의 상태에서 파생된 상태임으로 useEffect를 통한 처리는 부적절합니다. 필요 이상으로 복잡하며 위 예시에서 useEffect는 렌더 이후 실행되기 때문에 firstName과 lastName이 업데이트 됐지만 fullName이 업데이트 되지 않은 렌더 시점이 존재하게 됩니다. 즉 firstName+lastName === fullName가 아닌 시점이 발생하게 되는데요. 이는 예측하기 어려운 부분이고 이로 인해 새로운 버그가 생길 수 있습니다.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 렌더 단계에서 파생 상태를 계산합니다.
const fullName = firstName + ' ' + lastName;
// ...
}
이렇게 props이나 state를 기반으로 계산할 수 있는 파생 상태의 처리는 렌더 단계에서 계산하는 게 좋습니다.
이제는 firstName+lastName !== fullName 시점이 사라졌습니다!
만약 렌더 단계에서 비용이 큰 연산을 해야하는 경우라면 useMemo를 고려할 수 있습니다.
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ getFilteredTodos의 실행 횟수를 최적화할 수 있습니다.
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
2. Prop이 변경될 때 컴포넌트를 초기화해야 하는 경우
props이 변경될 때 컴포넌트의 내부 상태를 초기화해야 하는 경우가 있습니다.
예를들어 user에 따른 댓글을 보여줄 때 userId가 변경될 때 댓글을 초기화해야 한다고 가정해보겠습니다.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 userId가 변경된 경우 댓글을 초기화한다.
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
위 코드의 문제 역시 부자연스러운 렌더 흐름에 있습니다. 예를 들어 userId가 1에서 2로 변경됐다고 가정할 때
ProfilePage는 userId:2면서 userId:1의 댓글이 보여지는 렌더가 존재하게 됩니다(useEffect는 렌더링 이후 실행되니까요!)
또 comment외에 다른 상태들이 존재한다면 어떻게 해야할까요? 매번 useEffect안에 넣어주어야 할까요?
이것 보다 훨씬 단순하고 명료하게 문제를 해결하는 방법이 있습니다.
바로 key prop을 이용하는 방법입니다. key가 달라졌음으로 react는 이전 상태를 공유할 수 없는 새로운 컴포넌트가 선언됐다 판단하고 처음부터 컴포넌트를 mount시킵니다.
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ key가 변경될 때 모든 상태는 초기화됩니다.
const [comment, setComment] = useState('');
// ...
}
3. prop이 변경될 때 일부 state를 업데이트하는 경우
앞서 컴포넌트 초기화의 경우에 대해 알아봤습니다.
그렇다면 일부 상태를 업데이트해야 하는 경우는 어떨까요? 이번엔 정말로 useEffect를 사용할 때일까요?
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 items가 변경됐을 때 선택된 요소를 초기화한다.
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
위 코드 역시 지금까지 줄곧 이야기했던 오래된 상태를 참조하고 계단식 업데이트를 야기하는 문제가 남아있습니다
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 렌더링 도중 상태 업데이트를 요청합니다.
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
이를 해결하기 위해 아래처럼 코드를 수정해볼 수 있습니다.
이전 상태와의 변경을 추적하며 selection 상태를 업데이트 하는 코드입니다. 리액트는 아직 자식을 렌더링하지 않거나 DOM을 업데이트하지 않았으므로 오래된 상태(selection)이 노출되지 않습니다.
하지만 이 방법은 렌더를 트리거하는 특정 조건을 부여하기 때문에 컴포넌트의 흐름을 예측하기 어렵게 만듭니다.
이런 인지 부하를 낮추려면 렌더흐름에서 계산되도록 유도해야 합니다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 렌더링중 selection 요소를 탐색
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
개선된 코드를 보면 렌더링 도중에 selection을 바로 계산하는 것을 볼 수 있습니다.
이로써 컴포넌트 흐름을 복잡하게 만드는 컴포넌트 내부에 조건문을 없앨 수 있습니다.
4. 이벤트 핸들러간 로직을 공유해야 하는 경우
다음으로 살펴볼 상황은 이벤트 핸들러의 로직을 useEffect로 공통화를 시도하는 경우입니다.
상품페이지에서 결제하기 버튼과 구매하기 버튼이 있고 각 버튼을 클릭했을 때 notification을 보여줘야 하는 기능이 있다고 가정해보겠습니다.
function ProductPage({ product, addToCart }) {
// 🔴 상품이 카트에 들어간 경우 알림을 보여준다.
useEffect(() => {
if (product.isInCart) {
showNotification(`${product.name}이 장바구니에 추가됐습니다`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
이 역시 언뜻 그럴싸해보입니다. 위 코드에는 어떤 문제가 있을까요?
만약 제품을 장바구니에 추가한 뒤 이 페이지를 새로고침을 하게 되면 어떤 일이 생길까요?(혹은 재진입하는 경우)
제품이 장바구니에 추가됐다는 알림이 뜨게 될 것입니다. 즉 알림이 보여지는 시점이 이벤트의 발생 시점이 아닌 컴포넌트의 렌더시점으로 변경되면서 오류를 야기했습니다. useEffect가 처리하기에 적절한 위치가 아니라는 뜻이 됩니다.
어떤 로직이 이벤트 핸들러와 useEffect중에 어디에 위치해야 하는지 헷갈린다면 로직의 실행 시점에 대해서 고민해봐야 합니다. 이벤트 트리거 시점에 발생해야 하는지? 아니면 컴포넌트 렌더, 상태 업데이트 시점에 발생해야 하는지 말이죠.
이 예에서는 컴포넌트의 렌더 시점이 아닌 이벤트 트리거 시점, 즉 인터렉션 시점에 발생해야 함으로 이벤트 핸들러가 더 나은 위치입니다. 로직을 공통화하는 목적은 아래와 같이 처리할 수 있습니다.
function ProductPage({ product, addToCart }) {
// ✅ 장바구니 추가와 알림을 공통화
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
5. 계산 체인
hook의 탄생 이래로 가장 끔찍한 경우입니다.
각 effect가 상태 업데이트를 트리거하고 이로 인해 다른 effect가 트리거 되는 것이죠.
이는 effect가 증가함에 따라 빠른 속도로 복잡도가 높아지게 만드는 구조입니다.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 각 effect가 연쇄적으로 트리거되고 있습니다.
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
구체적 문제에 대해 알아보겠습니다.
우선 여러번의 렌더링을 유발합니다. 최악의 경우 setCard→ render → setGoldCardCount→ render → setRound→ render → setIsGameOver→ render의 흐름으로 세 번의 렌더링이 발생합니다.
두번째로 각 effect는 서로 의존적 관계로 코드가 경직되어 있습니다.
이런 구조는 새로운 변경사항이 발생했을 때 모든 코드에 영향을 주기 때문에 추적과 관리를 어렵게 만듭니다.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 파생 상태를 분리합니다.
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 발생 시점에 상태를 업데이트합니다.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
이 문제는 위와 같이 개선될 수 있습니다.
우선 round에 의한 파생 상태였던 isGameOver가 렌더 과정에서 계산되도록 변경되었습니다.
또 모든 상태가 업데이트의 출발점이 되는 이벤트 핸들러 내부로 이동했습니다.
이로써 불필요하게 발생하던 렌더링과 effect 체인으로 인한 복잡도가 개선되었습니다.
6. 부모 컴포넌트에서 전달받은 콜백을 실행하는 경우
컴포넌트를 설계하다보면 부모 컴포넌트와의 인터렉션을 위해 콜백을 전달받는 경우가 있습니다.
Toggle을 예로 들어보겠습니다. isOn이 변경되는 인터렉션이 여러번인 경우 useEffect로 isOn의 변경을 구독하고 싶을 수 있습니다.
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 콜백실행의 시점차가 발생합니다.
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
이 역시 지금까지 살펴보았던 예제들과 같은 문제가 발생합니다. 즉 상태 변경 시점과 콜백호출시점이 달라지게 되는 문제입니다. Toggle의 isOn업데이트 -> Toggle이 onChange실행 -> 부모 컴포넌트 리렌더 의 흐름을 따라가게 됩니다.
여기서 inOn의 업데이트와 onChange의 실행은 하나의 렌더 과정에서 처리되는 게 바람직합니다.
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ Good: 모든 업데이트를 하나의 이벤트 내에서 처리합니다.
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
위와 같이 수정하는 것이 더 낫습니다. 상태 업데이트와 콜백의 호출시점이 하나의 렌더 과정에 실행되도록 변경되었습니다. updateToggle을 각각의 inOn을 변경하는 이벤트에서 호출하면 됩니다.
결론
지금까지 6가지 사례를 통해 useEffect의 잘못된 사용에 대해 알아봤습니다.
useEffect는 순수한 컴포넌트와 외부 시스템의 동기화를 목적으로 만들어졌습니다. 이것이 개발모드에서 effect가 2번 실행되며 클린업의 동작을 테스트하는 이유입니다. useEffect를 사용할 때는 동기화 대상과 동기화 대상에 대한 cleanup 시점 그리고 동기화 처리가 아닌 이벤트 발생 시점에 처리되어야 할 로직이 아닌지 생각해봐야 합니다.
참고
'react' 카테고리의 다른 글
[설계] 격리된 컴포넌트간의 통신 설계 (feat. Eventbus) (0) | 2024.06.28 |
---|---|
[Nextjs] next13에서 react-query의 필요성 (0) | 2024.06.27 |
[설계] 의존성 역전으로 변경에 유연한 설계하기 (0) | 2024.06.26 |
[설계] Compound Pattern으로 변경에 유연한 컴포넌트 설계하기 (0) | 2024.06.24 |
[설계] 대규모 React앱의 Multi Tab 통신 (0) | 2024.06.20 |