useEffect에 대해서 한번 더 공부를 해보고자 쓰는 글이다.
사실 리액트를 배울 때나 nextjs를 배울 때나 많이 사용하고는 있지만 다시 한번 내가 왜 쓰고 있는지 그리고 깊게 한 번 더 공부해보기 위해 이 글을 쓴다.
리액트에서 훅이란 무엇일까?
리액트에서 “훅(Hook)”이라고 하면, 함수형 컴포넌트에서도 상태(state)와 생명주기(lifecycle) 기능을 다룰 수 있도록 도와주는 기능을 말한다.
이 말이 나한텐 너무 어려웠는데, 이를 이해하려면 리액트가 어떻게 업데이트 해왔는지 역사를 알면 조금 이해가 쉬워진다.
hook은 리액트 버전 16.8부터 리액트 요소로 2019년도에 새로 추가가 되었다. 그럼 이전에는 어떻게 상태관리를 해줬던걸까?
정답은 클래스형 컴포넌트에서 다음과 같은 생명주기 메서드 componentDidMount
, componentDidUpdate
, componentWillUnmount
를 사용해가면서 상태관리를 처리했었다.
useEffect를 배울 때, 마운트, 업데이트, 언마운트 이게 무슨 말이야 했었는데, useEffect가 도입되기 이전에는 각 생명주기 메서드 3개를 이용해서 마운트, 업데이트, 언마운트 시점에 실행할 코드를 각각 작성해야했다는 것이다. 지금은 useEffect 하나로 하나의 함수 내에서 관리할 수 있고 아주 편리해졌죠?
리액트 훅을 처음 배울 때는 정말 단순하게 각 훅이 무슨 일을 하는지 키워드 처럼 외우고 다녔다. 하지만 코드를 쓰면 쓸수록 그런 단순한 정의만으로 정의할 수 없다는 걸 느끼고 있다.
이번 글에서는 useEffect를 중점적으로 쓸 예정이기 때문에 우선 useEffect에 대해서도 정의해보자.
useEffect란 뭘까?
useEffect란 컴포넌트가 렌더링된 이후에 수행해야하는 부수적인 작업을 처리한다. 이 문장을 들었을 때 이해가 안간다면 컴포넌트 내부의 2가지 로직 유형(렌더링 코드, 이벤트 핸들러)에 대해서 먼저 알아야한다.
import React from "react";
function Greeting({ name }) {
return <h1>안녕하세요, {name}님!</h1>;
}
export default Greeting;
렌더링 코드는 컴포넌트가 실행될 때 return을 만나기 전까지 실행되는 코드로, 여기서는 Greeting({ name })
이 해당된다. 이 코드는 props와 state를 받아 JSX로 변환하여 화면을 그리는 역할만 해야 한다.
리액트는 언제든지 컴포넌트를 다시 실행할 수 있다. 그 이유는 리액트가 선언형 UI를 기반으로 동작하기 때문이다. 상태가 변경되면 어떤 부분이 바뀌었는지 확인하기 위해 컴포넌트를 다시 실행한 후, 변경된 부분만 찾아서 업데이트한다.
이런 방식 때문에, 만약 API 호출 같은 작업을 하면 불필요하게 여러 번 실행될 수 있다. 리액트는 상태 변화를 감지하고 UI를 업데이트하는데, 변경된 부분만 업데이트하려면 일단 컴포넌트를 다시 실행해봐야 하기 때문이다.
따라서 렌더링 코드는 순수 함수처럼 동작해야 한다.
즉, 같은 입력이 주어지면 항상 같은 결과를 반환해야 하며, 외부 상태를 변경하는 작업은 useEffect 같은 훅에서 처리해야 한다. 그래야 불필요한 재실행을 방지하고, 원하는 타이밍에 원하는 로직만 실행할 수 있다.
import React, { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 상태 변경 (부수 효과 포함)
};
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={handleClick}>증가</button>
</div>
);
}
export default Counter;
이벤트 핸들러는 컴포넌트 내부에서 함수 형태로 선언되며, 특정 이벤트(예: 버튼 클릭, 입력, 마우스 이동 등)가 발생했을 때 실행되는 함수다. 단순한 값 계산뿐만 아니라 서버 요청, 로컬 스토리지 저장 같은 부수 효과(Side Effect) 도 포함할 수 있다.
즉, 사용자의 특정 행동이 있을 때만 실행되는 추가적인 동작을 처리하는 역할을 한다.
리액트에서 부수 효과(Side Effect) 란 컴포넌트가 화면을 그리는 것(렌더링)과 직접적으로 관련이 없는 작업을 의미한다.
예를 들어, 버튼을 화면에 표시하거나 텍스트를 출력하는 것은 렌더링의 일부다.
하지만 서버에서 데이터를 가져오거나, 로컬 스토리지에 값을 저장하는 작업은 렌더링과 관계없는 동작이며, 이런 작업이 바로 부수 효과다.
예를 들어, 챗봇 페이지에 처음 접속하면 채팅 서버와 연결해야 한다고 가정해보자.
이처럼 외부 시스템과의 상호작용이 필요한 작업은 렌더링할 때마다 실행되면 안 되며, useEffect
같은 훅을 이용해 따로 처리해야 한다.
여기까지 읽었다면 어느 정도 감이 올 거다. 하지만 여기서 끝나면 안 된다.
모든 부수 효과를 무조건 useEffect에서 처리해야 할까?
그렇지 않다..!🙅♀️ 모든 부수 효과가 useEffect가 필요한 것은 아니다.
이제, 어떤 경우에는 useEffect가 필요 없고, 어떤 경우에는 꼭 써야 하는지를 더 자세히 알아보자.
지금까지의 내용을 한 줄로 요약하면 다음과 같다.
useEffect는 렌더링과 직접적인 관련이 없는 작업을 렌더링 후에 실행하기 위해 사용된다.
렌더링 과정에서는 JSX 변환만 이루어져야 하며, API 요청, 이벤트 리스너 등록, DOM 조작 같은 부수 효과는 useEffect에서 실행하여 렌더링과 분리해야 한다.
우선 Effect를 작성하기 위해서는 다음과 같은 3단계를 따라야한다.
useEffect 선언하기, useEffect 의존성 지정 해주기, 필요한 경우 클린업 함수 추가하기다.
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 이곳의 코드는 *모든* 렌더링 후에 실행된다.
});
return <div />;
}
이렇게 코드를 작성하면 컴포넌트가 렌더링 될 때 마다 다음 순서대로 동작한다.
즉, Effect는 화면이 이미 업데이트된 뒤에야 실행된다.
UI 반영이 끝난 뒤 DOM을 직접 조작하거나, 외부 API와 동기화하는 로직을 적절한 시점에 실행하고 싶을 때 꼭 필요한 훅이다.
function VideoPlayer({ src, isPlaying }) {
// useRef 훅으로 video DOM 요소에 접근
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
위 코드를 보면, isPlaying 값에 따라 video DOM 요소에 play() 혹은 pause()를 호출해주고 있다.
핵심은 이 DOM 조작을 렌더링 로직과 분리하여, 렌더링 과정에서는 단순히 ref만 설정하고, 실제 동작은 Effect 내부에서 실행한다는 점이다.
만약 렌더링 중에 ref.current.play() 같은 DOM 조작을 해버리면, React가 아직 DOM 요소를 준비하지 않았거나, 순수 계산 과정에서 부수 효과를 일으키게 되어 오류가 날 수도 있다.
따라서 DOM을 수정하는 로직은 반드시 Effect로 묶어서 처리해야 한다.
useEffect는 기본적으로 모든 렌더링 후에 실행된다. 하지만 이 동작이 항상 바람직한 것은 아니다.
예를 들어
이러한 문제를 해결하려면, useEffect에 의존성 배열(dependency array)을 명시해야 한다.
의존성 배열을 활용하면 특정 값이 변경될 때만 useEffect가 실행되도록 제어할 수 있다.
useEffect(() => {
// ...이 내부의 로직
}, []);
useEffect의 동작 방식은 의존성 배열을 어떻게 설정하느냐에 따라 달라진다.
의존성 배열이 없는 경우, 즉 useEffect(() => { ... })처럼 배열을 아예 전달하지 않으면 모든 렌더링 후에 실행된다. 컴포넌트가 업데이트될 때마다 계속 실행되므로, 불필요한 연산이 많아질 수 있다.
빈 배열 []
을 전달하면 컴포넌트가 처음 마운트될 때 단 한 번만 실행된다.
하지만 개발 모드(Strict Mode)에서는 마운트 시 두 번 실행될 수도 있다. 이는 리액트가 useEffect의 동작을 더 안전하게 만들기 위해 의도적으로 적용한 동작이다.
만약 특정 값이 변경될 때만 useEffect가 실행되도록 하고 싶다면, 의존성 배열을 [isPlaying, somethingElse]
처럼 설정하면 된다. 이렇게 하면 배열에 포함된 값이 바뀔 때만 useEffect가 실행되고, 다른 상태가 변하더라도 실행되지 않는다.
또한, React는 useEffect 내부에서 사용되는 변수를 자동으로 추적하는데, 만약 의존성 배열에 포함해야 할 변수가 빠지면 React Hook useEffect has a missing dependency
라는 경고 메시지를 띄운다. 이 경고는 예상치 못한 버그를 방지하기 위한 것이다.
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
위 코드에서 isPlaying을 의존성 배열에 명시했기 때문에, isPlaying 값이 바뀔 때만 play() 혹은 pause()가 실행된다.
즉, 검색어 입력 같은 다른 상태가 변경되더라도 이 useEffect는 다시 실행되지 않는다.
이렇게 하면 불필요한 동작을 방지하고, 원하는 조건에서만 useEffect가 실행되도록 제어할 수 있다.
useEffect를 사용할 때 서버 연결, 타이머 등록 같은 외부 리소스를 활용하는 경우, 이를 적절히 정리(cleanup)하지 않으면 메모리 누수, 중복 연결 같은 문제가 발생할 수 있다.
예를 들어, 채팅 서버에 연결하는 ChatRoom 컴포넌트를 살펴보자.
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
위 코드에서 의존성 배열 []
을 사용했기 때문에, connect()는 컴포넌트가 처음 마운트될 때 한 번만 실행된다.
하지만 사용자가 페이지를 떠나거나 컴포넌트가 언마운트될 때 연결을 끊어주지 않으면, 서버 자원이 계속 사용된 채로 남게 된다.
이런 문제를 방지하려면, Effect 내부에서 클린업 함수를 반환해야 한다.
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
useEffect 내부에서 함수를 반환하면, 리액트는 컴포넌트가 언마운트되거나 Effect가 다시 실행되기 직전에 이 함수를 호출하여 정리 작업을 수행한다.
이렇게 하면 연결 → 언마운트 시 연결 해제가 하나의 사이클로 처리되므로, 리소스를 낭비하지 않을 수 있다.
리액트 개발 모드(Strict Mode) 에서는 컴포넌트의 마운트 → 언마운트 → 다시 마운트 과정을 한 번 더 실행한다.
이렇게 의도적으로 두 번 테스트하는 이유는, 클린업 로직이 제대로 구현되지 않았을 경우 이를 감지하고 경고를 띄우기 위해서다.
다만, 실제 배포 환경에서는 한 번만 마운트되므로, Strict Mode에서 발생하는 이중 마운트 현상은 신경 쓰지 않아도 된다.
리액트에서 useEffect는 렌더링과 직접적인 관련이 없는 작업을 처리할 때 사용해야 한다.
예를 들어, 서버에서 데이터를 가져오거나, 브라우저 API(localStorage, document.title)와 동기화하는 경우에는 useEffect가 필요하다.
하지만 단순히 state를 기반으로 다른 state를 업데이트하거나, 사용자 이벤트에 따라 작업을 실행하는 경우에는 useEffect 없이도 해결할 수 있다.
// ❌ 불필요한 패턴 (Effect 사용)
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
이렇게 하면 firstName이나 lastName이 변경될 때마다 useEffect가 실행되고, 불필요하게 setState를 호출하게 된다.
// ✅ 더 간단한 방법 (Effect 없이)
const fullName = firstName + ' ' + lastName;
이렇게 하면 firstName이나 lastName이 변경될 때마다 fullName도 자동으로 바뀌기 때문에 추가적인 useState나 useEffect가 필요 없다.
// ❌ 불필요한 패턴 (Effect 사용)
useEffect(() => {
if (clicked) {
post('/api/buy');
}
}, [clicked]);
이렇게 하면 clicked 상태가 바뀔 때마다 useEffect가 실행되면서 API 요청이 발생한다.
하지만 이건 굳이 useEffect를 사용할 필요가 없다.
// ✅ 더 간단한 방법 (이벤트 핸들러 사용)
function handleClick() {
post('/api/buy');
}
그냥 이벤트가 발생했을 때 바로 실행하는 게 더 자연스럽고 간결하다.
굳이 useEffect를 쓰지 않아도 된다.
useEffect는 리액트 컴포넌트가 화면을 그리는 것과 직접 관련이 없는 작업을 처리할 때 사용한다.
즉, 리액트 내부가 아니라, 바깥의 시스템(서버, 브라우저 API 등)과 연결해야 할 때 필요하다.
예를 들어,
이런 경우에는 useEffect를 사용해서 렌더링 이후에 실행되도록 설정해야 한다.
또한, 서버에서 데이터를 가져올 때는 경쟁 조건 문제에 주의해야 한다. 이전 요청보다 새로운 요청이 먼저 도착하면 데이터가 꼬일 수도 있다.
이를 방지하려면, useEffect에서 이전 요청을 무시하는 정리(cleanup) 함수를 설정하는 것이 좋다.
어떤 state가 바뀔 때 다른 state도 업데이트해야 한다면, 먼저 "이걸 굳이 state로 관리해야 할까?" 생각해보자!
// 예제 (불필요한 useEffect 없이 처리)
const fullName = firstName + " " + lastName;
위처럼 하면 state 업데이트 없이도 자동으로 반영되기 때문에 더 깔끔하고 성능도 좋다.
어떤 연산이 렌더링될 때마다 실행되는데, 계산 비용이 크다면?
이럴 때는 useEffect가 아니라 useMemo를 사용하면 된다.
// ✅ 예제 (useMemo를 활용한 최적화)
const expensiveValue = useMemo(() => computeExpensiveValue(data), [data]);
이렇게 하면 data가 변경될 때만 computeExpensiveValue()가 실행되므로 불필요한 연산을 줄일 수 있다.
예를 들어, 프로필 페이지에서 userId가 바뀔 때 내부 댓글 상태를 초기화해야 한다면, 컴포넌트에 key={userId}를 추가하면 된다.
React는 key가 변경되면 새로운 컴포넌트로 간주하고, 내부 state를 처음부터 다시 설정한다.
// 예제 (key를 사용한 자동 초기화)
<UserProfile key={userId} userId={userId} />
이렇게 하면 userId가 바뀔 때마다 컴포넌트가 초기화된다.
props가 바뀔 때 특정 state만 초기화하고 싶다면, 굳이 useEffect를 사용할 필요 없이 렌더링 중에 조건문을 사용하면 된다.
// ✅예제 (useEffect 없이 조건부 업데이트 적용)
if (prevUserId !== userId) {
setComments([]);
}
이렇게 하면 불필요한 렌더링 없이 바로 업데이트할 수 있다.
이벤트 핸들러는 사용자의 직접적인 액션이 발생했을 때 실행하는 로직을 처리하는 역할을 한다.
예를 들어, 버튼을 클릭하거나 입력 필드에 값을 입력하는 경우가 이에 해당한다.
// ✅ 이벤트 핸들러 예제
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 클릭할 때마다 count 증가
};
return <button onClick={handleClick}>클릭 {count}번</button>;
}
위 코드처럼 사용자의 액션(클릭)이 트리거인 경우에는 onClick 이벤트 핸들러에서 바로 처리하는 것이 적절하다.
반면, useEffect는 컴포넌트가 마운트되거나 언마운트될 때, 혹은 특정 상태가 변경될 때 실행해야 하는 로직을 처리하는 용도로 사용된다.
예를 들어, 컴포넌트가 처음 나타났을 때 Google Analytics 로그를 남기거나, 특정 브라우저 API를 구독해야 하는 경우 useEffect를 사용한다.
// ✅ Effect 예제 (마운트될 때 실행)
useEffect(() => {
console.log("컴포넌트가 처음 렌더링됨!");
return () => {
console.log("컴포넌트가 사라질 때 실행됨!");
};
}, []);
위 코드에서 useEffect는 컴포넌트가 처음 마운트될 때 실행되고, 컴포넌트가 언마운트될 때 정리(cleanup) 작업을 수행한다.
즉, 사용자의 액션이 트리거인 경우에는 이벤트 핸들러를 사용하고, 컴포넌트가 등장하거나 사라지는 것이 트리거라면 useEffect를 사용하는 것이 적절하다.
useEffect는 컴포넌트의 렌더링과 직접 관련이 없는 부수 효과(Side Effect)를 처리하는 데 사용된다.
하지만 모든 부수 효과에 useEffect가 필요한 것은 아니며, 단순한 값 변환이나 이벤트 핸들링은 렌더링 중에 처리하는 것이 더 적절하다. useEffect는 외부 시스템과의 동기화(데이터 요청, 구독, 타이머 설정 등)가 필요할 때만 신중하게 사용해야 한다.
이렇게 한번 다시 리액트 공식 문서를 읽어가면서 정리해보았다. 더 공부할 게 산더미긴 하지만 힘!
참고 사이트들
https://witch.work/ko/posts/react-useeffect-usage
https://ko.legacy.reactjs.org/docs/hooks-effect.html
https://ko.react.dev/learn/synchronizing-with-effects
https://ko.react.dev/reference/react/useEffect#connecting-to-an-external-system
https://ko.react.dev/learn/you-might-not-need-an-effect