최근에 이런 Article을 읽은 적이 있다.
리액트 문서에서 제공하는 새로운 9가지 지침서라는 글이다.
해당 문서의 7번째 항목에 이런 내용이 있다.
When you need to fetch data, prefer using a library over useEffect
데이터 패칭을useEffect
에서 호출하지 말고react-query
같은 데이터 패칭 라이브러리를 사용하라는 내용이다.
음,, 사실 당연하게도 react-query
를 사용하면서 프로젝트를 진행 중이다.
그래서 만약에 누군가가 왜 useEffect
를 사용하지 말아야 하죠? 라고 물어본다면 아마
reactStrictMode
에서 useEffect가 두 번 호출되고 의존성 배열 때문에 의도치 않은 결과값을 리턴할 수 있으니깐 그렇죠..?
라고 뭉뚱그려 대답할 것 같다.
하지만 나, 이런 건 데이터를 기반하며 논리적인 개발자인 내가 허용할 수 없다.
정확히 왜 그래야 하는지 한번 useEffect
를 먼저 뜯어보자.
useEffect
심층 해부해보기useEffect
를 설명할 때 다들 클래스 컴포넌트의 라이프 사이클을 대체한다고 말해주는 것 같다.
예를 들어, 클래스 컴포넌트의 라이프 사이클 중 마운트, 업데이트, 언마운트를 대체할 수 있다.
첫번째 인자로 콜백 함수를 받고, 두번째 인자로 의존성 배열을 받게 된다.
componentDidMount
와 유사하게 컴포넌트가 처음으로 렌더링될 때 한 번 실행componentDidUpdate
와 유사하게 특정 상태나 프롭스가 변경될 때마다 실행componentWillUnmount
와 유사하게 컴포넌트가 소멸되기 전에 실행여기서 궁금한 게 useEffect
는 어떻게 의존성 배열이 변경된 것을 알고 콜백 함수를 실행하는 것일까?
한가지 알아야하는 것은 함수형 컴포넌트는 리렌더링이 진행될 때 매번 함수를 새로 그리며 실행하여 렌더링을 수행한다는 것이다.
즉, useEffect
가 의존성 배열이 변경된 것을 감지하는 원리는 다음과 같다.
useEffect
안에 있는 콜백 함수가 새로 생성useEffect
의 의존성 배열과 현재 렌더링에서 생성된 useEffect
의 의존성 배열을 비교import { useEffect, useState } from 'react';
function ExampleComponent() {
const [count, setCount] = useState(0);
const [dependency, setDependency] = useState(0);
useEffect(() => {
console.log('Effect 실행 !!');
}, [dependency]);
return (
<div>
<p>숫자: {count}</p>
<p>의존성: {dependency}</p>
<button onClick={() => setCount(count + 1)}>숫자 변경</button>
<button onClick={() => setDependency(dependency + 1)}>의존성 변경</button>
</div>
);
}
export default ExampleComponent;
즉, useEffect
는 데이터 바인딩, 옵저버 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니다. 또한 클래스 컴포넌트의 라이프 사이클도 완벽히 대체하는 것도 아니다.
단지, 리렌더링될 때마다 의존성에 있는 값을 비교하고 변경점이 있을 시 콜백 함수를 실행시키는
함수 컴포넌트에서의 부수 효과(side effects)를 수행할 수 있게 해주는 평범한 훅이라고 할 수 있다.
컴포넌트가 언마운트되기 전이나 업데이트 직전에 실행: componentWillUnmount
와 유사하게 컴포넌트가 소멸되기 전에 실행한다고 위에서 언급하였는데, 어떻게 하는 걸까?
import React, { useEffect, useState } from 'react';
function TimerComponent() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// 컴포넌트가 마운트될 때 타이머 시작
const timerId = setInterval(() => {
setSeconds((prevSeconds) => prevSeconds + 1);
}, 1000);
// clean-up 함수를 반환하여 컴포넌트가 언마운트되기 전에 타이머 중지
return () => {
clearInterval(timerId);
console.log('Timer stopped before unmounting');
};
}, []); // 빈 배열을 전달하여 컴포넌트가 마운트될 때만 실행
return (
<div>
<p>{seconds} 초</p>
</div>
);
}
export default TimerComponent;
useEffect
클린업 함수는 생명주기 메서드의 언마운트 개념과 차이가 있다.
언마운트는 컴포넌트가 DOM에서 사라지는 것을 의미하는데, 클린업 함수는 언마운트보다는 함수형 컴포넌트가 리렌더링되고 의존성 배열의 변화가 생겼을 때 단지, 청소해 주는 개념으로 보는 게 더 정확하다.
예를 들어 다음과 같은 코드가 있다고 해보자.
import React, { useEffect, useState } from 'react';
function ExampleComponent() {
const [data, setData] = useState(null);
const [id, setId] = useState(0);
useEffect(() => {
// 컴포넌트가 마운트될 때 데이터 로딩
fetchData(id);
}, [id]); // 빈 배열을 전달하여 컴포넌트가 마운트될 때만 실행
const fetchData = async (id) => {
// 데이터를 가져오는 비동기 작업 수행
try {
// 예시: API 호출
const response = await fetch(`https://example.com/todos/${id}`);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Error fetching data:', error);
}
};
return (
<div>
{data ? (
<div>
<p>Data: {data.title}</p>
<input type="button" onClick={() => setId(1)} value="id 변경" />
</div>
) : (
<p>Loading...</p>
)}
</div>
);
}
export default ExampleComponent;
코드를 간략하게 설명하면 다음과 같다.
마운트 시 데이터 로딩: useEffect
의 첫 번째 매개변수로 전달된 함수는 컴포넌트가 마운트될 때 실행. fetchData
함수를 호출하여 비동기적으로 데이터를 가져와서 상태인 data
에 값을 할당한다.
비동기 데이터 로딩: fetchData
함수는 fetch를 사용하여 예시 API에서 데이터를 가져오고, response.json()
을 통해 JSON 형태로 변환한다. 성공적으로 데이터를 가져오면 setData
를 사용하여 컴포넌트의 상태를 업데이트한다.
로딩 상태 표시: 데이터가 로딩 중인 동안은 "Loading..."이라는 문구가 출력된다.
위의 코드는 왜 문제가 될까??
왜냐하면 콜백 함수로 들어온 비동기 함수의 응답 속도에 따라 최종 결과가 이상하게 나타날 수 있기 때문이다.
예를 들어 이전 비동기 함수가 100s 이상 걸리는 와중에 id가 바꿨다고 가정해보자.
그렇다면 useEffect
가 다시 실행될 거고 이 때는 데이터 패칭이 1s 걸렸다고 하자.
이러면 의도한 결과가 제대로 표시되지 않을 것이고 이를 경쟁 상태
라고 한다.
경쟁 상태(Race Condition)은 보다 일반적으로 여러 프로세스나 스레드가 공유된 자원에 동시에 접근하거나 수정하려고 할 때 발생하는 상태를 나타낸다. 이는 예상치 못한 결과를 가져올 수 있는데, 여러 사용자의 입력이나 다수의 프로세스/스레드가 동시에 공유 자원에 영향을 주면서 일어나기도 한다.
여러 사용자의 입력이 결과에 영향을 주는 상황에서, 경쟁 상태는 다양한 형태로 나타날 수 있다.
예를 들어, 두 사용자가 동시에 같은 데이터를 수정하거나, 두 이벤트 핸들러가 동시에 동작하여 예상치 못한 상태를 만들어낼 수 있다.
간단한 예시로, 두 사용자가 동시에 같은 데이터를 증가시키는 상황을 살펴보자.
let sharedValue = 0;
function increaseSharedValue() {
// 공유 변수에 1을 더함
sharedValue += 1;
console.log(`Current value: ${sharedValue}`);
}
// 두 사용자가 동시에 이벤트를 발생시키는 상황
setTimeout(increaseSharedValue, 1000); // 입력 1
setTimeout(increaseSharedValue, 1000); // 입력 2
위의 코드에서 increaseSharedValue
함수는 공유 변수 sharedValue
에 1을 더하는데, 입력 1과 2에 의해 두번 실행된다. 이 경우, 두 이벤트 핸들러가 동시에 실행되면서 공유 변수에 대한 경쟁 상태가 발생하고, 최종 결과가 예상치 못한 값이 될 수 있다.
이를 해결하려면 적절한 동기화 매커니즘을 사용하거나 락(Lock) 등을 활용하여 경쟁 상태를 방지해 안전한 동시 접근을 보장해야 한다.
예를 들어 위의 예제를 다음과 같이 수정할 수 있다.
import { useEffect, useState } from 'react';
function ExampleComponent() {
const [data, setData] = useState(null);
const [id, setId] = useState(0);
// 락을 통해 동기화를 유지할 변수
const lock = { isLocked: false };
useEffect(() => {
// 컴포넌트가 마운트될 때 데이터 로딩
fetchData(id);
// clean-up 함수를 반환하여 컴포넌트가 언마운트되기 전에 실행
return () => {
// 락 해제
lock.isLocked = false;
console.log('클린업');
};
}, [id]); // id가 변경될 때마다 실행
const fetchData = async (id) => {
// 락이 걸려있는 경우 무시
if (lock.isLocked) {
console.log('락이 걸려있어서 무시');
return;
}
// 락 설정
lock.isLocked = true;
// 데이터를 가져오는 비동기 작업 수행
try {
// 예시: API 호출
const response = await fetch(`https://example.com/todos/${id}`);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
// 락 해제
lock.isLocked = false;
}
};
return (
<div>
{data ? (
<div>
<p>Data: {data.title}</p>
<input type="button" onClick={() => setId(1)} value="id 변경" />
</div>
) : (
<p>Loading...</p>
)}
</div>
);
}
export default ExampleComponent;
딱봐도 복잡하다..
만약에 react-query
를 사용하면 라이브러리에서 제공해주는 다양한 기능을 사용할 수 있을 뿐만 아니라 코드가 더 줄어들어 직관적이게 변경이 가능하다.
import { useQuery } from '@tanstack/react-query';
function ExampleComponent() {
const { data, isLoading } = useQuery(['todos', id], () =>
fetchData(id)
);
const fetchData = async (id) => {
try {
// API 호출
const response = await fetch(`https://example.com/todos/${id}`);
const result = await response.json();
return result;
} catch (error) {
throw new Error(`Error fetching data: ${error}`);
}
};
const handleButtonClick = () => {
// id 변경
// useQuery는 자동으로 캐시를 활용하여 데이터를 가져옴
// 변경된 id에 대한 데이터가 없으면 다시 데이터를 가져옴
setId(1);
};
return (
<div>
{isLoading ? (
<p>Loading...</p>
) : (
<div>
<p>Data: {data?.title}</p>
<button onClick={handleButtonClick}>id 변경</button>
</div>
)}
</div>
);
}
export default ExampleComponent;
사실 어렴풋이 알고 있는 내용이긴 한데 정리하면서 리마인드 시키니깐 또 새롭게 다가온다.
이렇게 하나씩 정리해 나가야 좀 더 깊은 이해를 할 수 있지 않을까 싶다.
끝 .. !!