React 동작 방식을 제대로 이해하기 위해서는 useEffect Hook의 동작 방식에 대해 이해하는 것이 반드시 필요합니다. 특히 클래스 기반의 라이프사이클 메서드와 useEffect의 동작 방식이 어떻게 다른지 아는 것이 중요하다고 할 수 있습니다.
앞서 React 컴포넌트 생명주기에서 클래스 기반의 라이프사이클 메서드와 함수형 기반의 라이프사이클 메서드가 어떻게 다른지 살펴봤는데, 이번에는 useEffect에 대해 보다 심층적으로 알아보고자 합니다.
effect, 효과라는 것은 주로 데이터를 가져오고, 로컬스토리지를 읽고, 이벤트 리스너를 등록하고 해제하는 행위들을 말합니다.
useEffect 코드 블록은 비동기 작업을 명확하게 나타내는 지표입니다. useEffect없이도 비동기 코드를 작성할 수는 있지만, 이렇게 하면 복잡성이 증가하고 오류를 발생할 가능성이 높아집니다. useEffect 블록은 좀 더 의미있고 재사용이 가능한 커스텀 훅으로 뽑아낼 수도 있습니다.
그렇다면 컴포넌트 라이프사이클 안에서 useEffect는 언제 실행되는 걸까요?
useEffect로 정의된 effect들은 렌더링 이후, 그리고 모든 업데이트가 완료된 이후에 실행됩니다. 생명주기 메서드와 달리 effect는 비동기적으로 실행되기 때문에 UI를 차단하지 않습니다.
useEffect의 기본 구조는 아래와 같습니다.
useEffect(
() => {
// effect
},
// 의존성 배열(선택사항)
[
// 0개 이상의 항목
]
)
// 의존성 배열을 넣을지는 선택사항이므로 아래와 같이 실행해도 동작합니다.
// 다만 렌더링시 매번 실행됩니다.
useEffect(() => {
// effect
})
useEffect는 다음과 같은 단계들로 수행됩니다.
useEffect훅을 포함하는 함수형 컴포넌트가 처음 렌더링될 때, useEffect 블록 내부의 코드는 컴포넌트의 초기 렌더링 이후에 실행됩니다. 이는 클래스 컴포넌트의 componentDidMount와 유사합니다.
useEffect의 두 번째 인자로 의존성 배열을 전달할 수 있는데, 이 배열은 effect가 의존하는 변수나 값들을 말합니다. 이러한 변수들이 변경되면 컴포넌트가 다시 랜더링 되는데, 만약 의존성 배열이 제공되지 않는다면 effect 블록 내 코드는 렌더링 될 때마다 실행되게 됩니다.
effect내에서 선택적으로 정리 함수를 실행할 수 있습니다. 이전 React 라이프사이클 포스팅에서도 언급했던 것 처럼 이벤트 리스너를 제거하거나 타이머 정리, 웹소켓 연결 종료, 외부 라이브러리 인스턴스 정리, API요청 취소, 구독 해제 등을 구현할 수 있습니다.
cleanup 함수가 있다면, 이 함수가 실행된 후 컴포넌트가 DOM에서 언마운트됩니다.
아래 하나의 예제 코드를 보겠습니다.
function AgeCheckDemo() {
const [age, setAge] = useState(0);
const ageInputRef = useRef();
useEffect(() => {
console.log("useEffect");
document.title = age >= 18 ? "성인입니다" : "미성년자입니다";
});
const handleCheckAge = () => setAge(Number(ageInputRef.current.value));
console.log("render");
return (
<div>
<input
type="number"
ref={ageInputRef}
placeholder="나이를 입력하세요"
/>
<button onClick={handleCheckAge}>나이 확인하기</button>
</div>
);
}
이 코드는 나이를 체크해서 성인/미성년자 여부를 문서 제목으로 설정하는 코드입니다.
여기에서 useEffect 블록 내 코드는 의존성 배열이 없으므로 모든 렌더링마다 실행됩니다. 즉, 사용자가 input에 값을 입력할 때마다 콘솔에 'render'와 'useEffect'가 차례로 찍힙니다.
이 코드를 아래와 같이 변경해보면 어떻게 될까요?
function AgeCheckInfiniteLoop() {
const [age, setAge] = useState(0);
const ageInputRef = useRef();
useEffect(() => {
console.log("useEffect age check");
document.title = age >= 18 ? "성인입니다" : "미성년자입니다";
});
useEffect(() => {
console.log("useEffect local storage");
const savedAge = localStorage.getItem("savedAge");
setAge(Number(savedAge) || 0);
});
console.log("render");
const handleClick = () => {
const newAge = Number(ageInputRef.current.value);
setAge(newAge);
localStorage.setItem("savedAge", newAge);
};
return (
<div>
<input
type="number"
ref={ageInputRef}
placeholder="나이를 입력하세요"
/>
<button onClick={handleClick}>나이 확인하기</button>
</div>
);
}
이 함수의 useEffect는 age가 변경될 때마다 문서 제목을 업데이트 합니다. 두 번째 useEffect는 로컬 스토리지에서 저장된 나이를 읽어와 상태를 업데이트 하는데, 이 useEffect 역시 의존성 배열이 없기 때문에 모든 렌더링마다 실행됩니다.
이 두번째 useEffect 안에는 setAge가 있는데, 이 setAge함수가 호출될 때마다 리렌더링이 발생하고, 이는 다시 두 번째 useEffect를 실행시켜 무한 루프가 발생하게 됩니다.
이 문제를 해결하기 위해서는 두 번재 useEffect에 빈 의존성 배열을 추가해야 합니다.
useEffect(() => {
console.log("useEffect local storage");
const savedAge = localStorage.getItem("savedAge");
setAge(Number(savedAge) || 0);
}, []);
이렇게 되면 컴포넌트가 처음 마운트될 때만 로컬 스토리지를 확인하게 되어 클래스형의 componentDidMount 메서드와 유사한 역할을 합니다.
빈 의존성 배열을 추가하는 것까지는 확인했습니다만, 우리는 이 의존성 배열에 어떤 값들을 넣어야 할까요? React 문서에 따르면 렌더링 사이에 값이 변경될 수 있는 모든 컴포넌트 스코프의 값들을 포함해야 합니다. 의존성 배열에 값을 추가하면 react가 리렌더링시 이 값의 변화가 일어나지 않으면 useEffect 블록 내의 코드는 skip하도록 만듭니다.
위 코드에서는 document.title 값을 변경해주는 useEffect에 의존성 배열을 넣어주는 것이 불필요한 실행을 줄여줄 수 있겠네요.
useEffect(() => {
console.log("useEffect age check");
document.title = age >= 18 ? "성인입니다" : "미성년자입니다";
}, [age]);
// 클래스형에서 componentDidMount
componentDidUpdate(prevProps, prevState) {
if (prevState.age !== this.state.age) {
...
}
}
매 랜더링 주기 후에 해당 컴포넌트에 정의된 모든 useEffect 블록 내 코드는 그 위치에 따라 순서대로 실행됩니다.
아래 함수는 10초부터 감소하는 카운트다운 타이머 입니다.
아래 함수의 문제점은 무엇일까요?
function CountdownTimer() {
const [timeLeft, setTimeLeft] = useState(10);
useEffect(() => {
const interval = setInterval(function () {
setTimeLeft((prev) => prev - 1);
}, 1000);
}, []);
return <p>카운트다운: {timeLeft}초</p>;
}
function TimerDemoUnmount() {
const [unmount, setUnmount] = useState(false);
const renderTimer = () => !unmount && <CountdownTimer />;
return (
<div>
<button onClick={() => setUnmount(true)}>
타이머 컴포넌트 제거하기
</button>
{renderTimer()}
</div>
);
}
countdownTimer에는 매 초마다 함수를 호출하는 인터벌을 등록했습니다. 그런데 타이머 컴포넌트 제거하기 버튼을 클릭하면 이 인터벌이 해제되지 않은 채 컴포넌트가 제거됩니다. 때문에 컴포넌트가 제거된 후에도 인터벌은 여전히 활성화된 상태이며, 더 이상 존재하지도 않는 컴포넌트의 상태변수(timeLeft)를 업데이트 하려고 시도합니다.
이 경우 메모리 누수 문제가 발생하게 됩니다. 그렇다면 이는 어떻게 해결하면 될까요?
해결책은 언마운트 직전에 인터벌을 해제하는 것입니다, 이는 cleanup함수를 통해 가능하고, cleanup함수는 useEffect 내에 아래와 같이 작성할 수 있습니다.
useEffect(() => {
const interval = setInterval(function () {
setTimeLeft((prev) => prev - 1);
}, 1000);
// cleanup
return () => clearInterval(interval);
}, []);
....
}
이렇게 작성하면 빈 의존성 배열을 사용하는 방법을 사용했기 때문에 클래스형의 componentWillUnmount() 생명주기 메서드처럼 동작합니다. (역할이 유사하다는 것입니다. componentWillUnmount 메서드는 17에서 제거되었습니다.)
React서버 컴포넌트를 사용하면 서버에서 컴포넌트를 가져오고 렌더링하여 필요한 데이터와 ui부분만 클라이언트로 전송할 수 있습니다. 서버는 자바스크립트 번들이 처리되는 동안 사용자가 빈 흰색 페이지를 보는 것을 방지하기 위해 초기 HMTL을 미리 생성합니다.
문제는 서버 컴포넌트는 제렌더링을 할 수 없다는 점입니다. 그리고 useEffect나 useState는 클라이언트에서 렌더링 이후에만 실행되지만 서버 커포넌트는 이미 서버에서 렌더링이 이뤄졌기 때문에 이들을 사용할 수 없습니다.
그렇다면 useEffect는 어디서 사용할 수 있을까요? 서버 컴포넌트는 데이터를 클라이언트 컴포넌트에 props로 전달할 수 있으며, 클라이언트 컴포넌트는 useEffect를 사용하여 클라이언트 특정 동작을 처리할 수 있습니다.
아래 예시 코드에서 서버 컴포넌트인 UsersPage는 서버에서 사용자 데이터를 직접 가져옵니다. 데이터를 클라이언트 컴포넌트에 props로 전달합니다.
자식 컴포넌트인 클라이언트 컴포넌트는 useEffect를 사용해 클라이언트 측 로직 즉, 활동 시간 추적 로직을 처리합니다.
export async function getUsers(): Promise<User[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/users', {
next: { revalidate: 3600 } // 1시간마다 재검증
});
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
// 클라이언트 컴포넌트
'use client'
...
export default function UserActivity({ user }: Props) {
const [lastActivity, setLastActivity] = useState<string>('');
useEffect(() => {
// 클라이언트 측에서만 필요한 로직
// 예: 사용자의 현재 활동 시간 추적
const updateActivity = () => {
setLastActivity(new Date().toLocaleTimeString());
};
const interval = setInterval(updateActivity, 1000);
updateActivity();
return () => clearInterval(interval);
}, []);
return (
<div className="p-4 border rounded-lg mb-4">
<h3 className="font-bold">{user.name}</h3>
<p>Email: {user.email}</p>
<p>Last Activity: {lastActivity}</p>
</div>
);
}
// 서버 컴포넌트
export default async function UsersPage() {
// 서버에서 데이터 가져오기
const users = await getUsers();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">사용자 활동 모니터링</h1>
{users.map(user => (
<UserActivity key={user.id} user={user} />
))}
</div>
);
}
참고
https://blog.logrocket.com/useeffect-react-hook-complete-guide/
https://legacy.reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
https://taak-e.tistory.com/entry/useEffect-%EC%9D%98%EC%A1%B4%EC%84%B1%EB%B0%B0%EC%97%B4-%ED%99%9C%EC%9A%A9
https://www.zipy.ai/blog/useeffect-hook-guide