React 컴포넌트 안의 코드는 크게 두 종류로 나눠서 생각할 수 있다.
1. 렌더링 코드: props와 state를 기반으로 JSX를 계산하는 코드
2. 이벤트 핸들러: 특정 사용자 행동에 반응해서 실행되는 코드
렌더링 코드는 화면에 무엇을 보여줄지 계산한다. 이 코드는 수학 공식처럼 동작해야 한다. 같은 props와 state가 들어오면 같은 JSX를 반환해야 하고, 계산하는 도중에 컴포넌트 바깥의 값을 바꾸면 안 된다.
예를 들어, 이런 코드는 렌더링 코드이다.
function Greeting({ name }) {
return <h1>Hello!, {name}</h1>;
}
name이 같다면 결과도 같다. 이 컴포넌트는 JSX를 계산해서 반환할 뿐, 브라우저의 DOM을 직접 바꾸거나 네트워크 요청을 보내거나 타이머를 만들지 않는다.
반면 이벤트 핸들러는 사용자의 특정 행동에 반응한다. 대표적으로 버튼 클릭, 폼 제출 등이 있다.
function Form() {
function handleClick() {
console.log('Hello!');
}
return <button onClick={handleClick}>Submit</button>;
}
위 코드는 단순히 JSX를 계산해서 반환하지 않는다. 사용자가 Submit 버튼을 클릭 시 로그를 남길 수 있다.
렌더링 코드는 결과를 계산하는 코드여야 하며, 이벤트 핸들러는 무언가를 하는 코드이다. 이 때, '무언가를 하는 코드'에는 보통 부수 효과(Side Effect)가 포함된다.
부수 효과, 즉 Side Effect란 무엇일까?
위에서 알 수 있듯이 단순히 수학 공식처럼 계산을 하는 것이 아니라, 함수 바깥에 영향을 줄 수 있는 효과를 말한다.
예를 들어, 다음과 같은 작업들은 모두 부수 효과이다.
- document.title 변경
- DOM 직접 수정
- 서버에 요청
- 타이머 시작
- 이벤트 리스너 등록
...
이런 작업들은 함수 내부에서 단순 계산으로 작동하지 않는다. 모두 React 컴포넌트 바깥 세계에 영향을 준다.
예를 들어 다음 코드는 JSX를 반환하지만, 동시에 브라우저의 title도 변경한다.
function App() {
document.title = 'Home';
return <h1>Home</h1>;
}
이 컴포넌트는 같은 JSX를 반환하더라도 순수한 렌더링 코드라고 보기 어렵다. document.title이라는 외부 상태를 렌더링 도중에 바꾸고 있기 때문이다.
렌더링은 “화면에 무엇을 보여줄지 계산하는 단계”이다. React는 컴포넌트 함수를 실행해서 JSX를 계산하고, 그 결과를 바탕으로 실제 DOM에 반영할 내용을 결정한다.
즉, 렌더링 코드는 다음과 같은 형태에 가까워야 한다.
function App({ title }) {
return <h1>{title}</h1>;
}
이 코드는 title이라는 입력을 받아 JSX를 계산한다. 같은 title이 들어오면 같은 JSX를 반환하고, 함수 바깥의 세계를 바꾸지 않는다.
문제는 React에서 렌더링이 반드시 한 번만 실행된다고 보장되지 않는다는 점이다. React는 필요하다면 컴포넌트 함수를 다시 실행할 수 있다. 부모가 다시 렌더링될 수도 있고, state가 바뀔 수도 있고, 개발 모드에서는 렌더링이 두 번 일어날 수도 있다.
function App() {
document.title = 'Home';
return <h1>Home</h1>;
}
1-1에서 설명한 코드를 다시 가져왔다.
이 코드는 JSX를 계산하는 동시에 브라우저의 document.title을 변경한다. React가 이 컴포넌트를 여러 번 렌더링하면 document.title을 변경하는 작업도 여러 번 실행된다.
'타이틀 바꾸는 것 정도가 무슨 문제냐?' 라고 할 수도 있다. 하지만 서버 요청이라면?
function ProductPage({ productId }) {
fetch(`/api/products/${productId}`);
return <h1>Product</h1>;
}
이 코드는 렌더링될 때마다 서버 요청을 보낸다. React가 같은 컴포넌트를 여러 번 렌더링하면 요청도 여러 번 나간다. 문제는 렌더링 중에 이미 실행된 요청은 되돌릴 수 없다는 점이다.
예를 들어서, 다음과 같은 상황이 있을 수 있다.
- productId = 1로 렌더링된다.
- 렌더링 중 fetch('/api/products/1') 요청이 나간다.
- productId = 2로 업데이트되어 다시 렌더링된다.
- productId = 1 렌더링 결과는 화면에 반영되지 않는다.
- 하지만 1번 상품 요청은 이미 서버로 전송되었다.
React 입장에서는 첫 번째 렌더링을 버릴 수 있어야 한다. 하지만 렌더링 중 실행된 사이드 이펙트는 이미 외부 세계에 영향을 줬기 때문에 React가 제어할 수 없다.
그래서 렌더링 코드에서는 외부 세계를 변경하면 안 된다!! 렌더링은 언제든 다시 실행될 수 있고, 경우에 따라 결과가 버려질 수도 있어야 한다. 그래야 React가 안전하게 렌더링을 반복할 수 있다는 점이 보장된다.
하지만 실제 웹 서비스에는 외부 세계에 필연적으로 연결되어야 하는 작업이 많다. 채팅 서버에 연결하거나, 특정 값에 따라 구독을 시작하거나, 현재 상태에 맞춰 브라우저 API를 동기화해야 하는 일들이 자주 발생한다.
하지만 또 렌더링 중에는 Side Effect를 실행하면 안 된다. 이렇게 리액트는 모순에 빠져버리게 된다.
그렇다면 도대체 렌더링 결과에 따라 외부 시스템과 맞춰야 하는 작업은 어디에서 처리해야 할까?
이때 외부 시스템과의 동기화를 위해 사용하는 리액트의 탈출구가 바로 useEffect이다!!
useEffect는 렌더링 결과에 따라 외부 시스템과 동기화해야 하는 작업을 렌더링 이후에 실행할 수 있게 해준다.
이 과정을 이해하려면 React의 렌더링 흐름을 잠깐 볼 필요가 있다.

위는 리액트 렌더링 사이클을 보여주는 그림이다. 복잡해보이지만 우리가 알아야 할 것은 맨 아래 Render -> Commit -> Paint 과정이다.
간단하게만 설명해보자면, 리액트에서 렌더링은 다음과 같은 과정을 거친다.
Render: 컴포넌트 함수를 실행해 JSX를 계산
Commit: 계산된 결과를 실제 DOM에 반영
Paint: 브라우저가 변경된 DOM을 바탕으로 화면을 그림
Render 단계에서는 아직 실제 DOM이 변경되지 않는다. React는 이 단계에서 “무엇을 보여줄지”를 계산한다.
Commit 단계에서는 React가 계산한 결과를 실제 DOM에 반영한다. 이 시점부터 DOM 노드가 문서에 존재하고 ref도 연결된다.
그 후 브라우저는 변경된 DOM을 바탕으로 화면을 그린다. (Paint 단계)
❗️ 리액트 렌더링 vs 브라우저 렌더링
리액트 렌더링은 컴포넌트 함수를 실행해 “어떤 UI가 되어야 하는지” 계산하는 과정이다.
반면 브라우저 렌더링은 리액트 DOM에 반영한 결과를 바탕으로 실제 화면의 픽셀을 그리는 과정이다.따라서 이 글에서 말하는 “렌더링”은 기본적으로 리액트 렌더링을 의미한다. 브라우저가 화면을 그리는 과정은 혼동을 줄이기 위해 공식 문서에서 표현한 것처럼 “paint”라고 구분해서 부르겠다.
useEffect는 Paint 이후에 실행된다. 그래서 렌더링 계산을 방해하지 않고, 렌더링 결과에 맞춰 외부 시스템과 동기화할 수 있다.
useEffect는 React 16.8에서 Hooks와 함께 등장했다. 그 이전 클래스 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount 같은 생명주기 메서드를 사용해 외부 시스템과의 동기화를 처리했다.
하지만 하나의 동기화 작업이 여러 생명주기 메서드에 흩어지기 쉬웠다.
예를 들어 채팅방에 연결하는 로직은 처음 나타났을 때도 필요하고, 방이 바뀌었을 때도 필요하고, 사라질 때는 정리해야 한다.
useEffect는 이 흐름을 “동기화 시작”과 “동기화 중지”라는 하나의 사이클로 묶어 표현할 수 있게 해준다.
내장 훅인 useEffect가 등장하기 전에는 사이드 이펙트를 생명 주기 함수라는 일정한 사이클로 관리했다.
class ChatRoom extends React.Component {
componentDidMount() {
// 처음 화면에 나타났을 때
this.connect();
}
componentDidUpdate(prevProps) {
// 업데이트되었을 때
if (prevProps.roomId !== this.props.roomId) {
this.disconnect();
this.connect();
}
}
componentWillUnmount() {
// 화면에서 사라질 때
this.disconnect();
}
connect() { } // 채팅 서버 연결
disconnect() { } // 채팅 서버 연결 해제
render() { return <h1>Chat room</h1> }
}
componentDidMount, componentDidUpdate, componentWillUnmount 모두 생명 주기 함수이다. 클래스 컴포넌트 내에서는 마운트/업데이트/언마운트 시 생명주기 함수를 호출할 수 있다.
위 예시에서 우리가 하고 싶은 일은 사실 단순하다.
현재 roomId에 맞는 채팅방에 연결하고, 더 이상 그 방이 아니면 연결을 끊는다.
하지만 이 구조는 props와 state에 대응해야 하는 상황일 때 복잡도를 높힌다.
실제로 props와 state는 계속 변경되기 때문에, 마운트/업데이트/언마운트 각 시점마다 로직을 분산해서 작성해야 하므로 복잡도가 매우 높아진다.
처음 연결 → componentDidMount
roomId가 바뀌었을 때 재연결 → componentDidUpdate
마지막 연결 해제 → componentWillUnmount
특히 componentDidUpdate에서는 이전 props와 현재 props를 직접 비교해야 하는 번거로움도 존재했다.
즉 클래스 컴포넌트에서는 하나의 동기화 작업을 마운트, 업데이트, 언마운트 시점에 맞춰 나누어 관리해야 했다.
useEffect를 사용하면 위 작업을 다음처럼 작성할 수 있다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>Chat room</h1>;
}
이 코드에서 중요한 것은 useEffect가 단순히 componentDidMount, componentDidUpdate, componentWillUnmount를 하나로 합친 문법이라는 점이 아니다.
더 중요한 관점은 useEffect는 특정 값에 맞춰 외부 시스템과의 동기화를 시작하고, 더 이상 유효하지 않으면 그 동기화를 중지한다는 점이다.
흐름을 보자면,
- 컴포넌트 마운트 시 연결 시도
- 의존성 배열 값을 통해 값 변경 시 연결 해제(클린업) 후 새로은 effect 실행
- 컴포넌트 언마운트 시 연결 해제(클린업)
더 자세하게 보자면,
roomId = general
→ general 방 연결 시작
roomId = travel
→ general 방 연결 해제
→ travel 방 연결 시작
roomId = music
→ travel 방 연결 해제
→ music 방 연결 시작
컴포넌트 언마운트
→ music 방 연결 해제
이렇게 보면 Effect는 컴포넌트의 시점(마운트, 업데이트, 언마운트)보다 동기화의 시작과 중지에 더 가깝다.
그래서 useEffect를 잘 이해하기 위해서는 다음과 같은 질문을 고려할 필요가 있다.
이 Effect는 무엇과 동기화하고 있는가?
이 동기화는 언제 시작되어야 하는가?
이 동기화는 언제 중지되어야 하는가?
그래서 useEffect는 클래스 컴포넌트의 생명주기 메서드를 단순히 대체한 것이라기보다, 외부 시스템과의 동기화를 더 직접적으로 표현하는 방식이라고 볼 수 있다.
3-2 코드 예시에서 roomId가 변경되었을 때 기존의 방을 연결 해제하고, 새로운 방에 연결을 시작한다고 했다.
이 때, 기존의 방을 연결 해제하는 과정이 바로 클린업이다.
클린업은 외부 시스템과의 동기화를 중지하는 역할을 한다. 3-2 코드 예시를 보자.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>Chat room</h1>;
}
위 코드에서 클린 업은 바로 이 부분이다.
return () => {
connection.disconnect();
};
useEffect 안에서는 함수를 반환할 수 있다. 리액트는 이 반환 함수를 cleanup 함수로 사용한다.
그럼 이 cleanup은 왜 필요할까?
클린업이 필요한 이유는, 위에서도 알 수 있듯이 바깥 세계와의 동기화를 중지할 수 있어야 하기 때문이다.
React는 컴포넌트 렌더링과 state 업데이트는 관리할 수 있지만, Effect 안에서 직접 시작한 외부 작업까지 자동으로 정리해주지는 않는다.
예를 들어 다음과 같은 작업들은 React 바깥의 세계에 속한다.
- 채팅 서버 연결
- 타이머 시작
- 이벤트 리스너 등록
- 외부 store 구독
- 비동기 요청 결과 처리
이런 작업을 시작했다면, 더 이상 필요하지 않을 때 중지하는 방법도 함께 정의해야 한다.
cleanup이 실행되는 시점은 크게 두 가지이다.
- 컴포넌트가 언마운트될 때
- 의존성이 바뀌어 Effect가 다시 실행되기 직전
여기서 중요한 점은 cleanup이 언마운트 때만 실행되는 것이 아니라는 점이다.
예를 들어 roomId가 바뀌면, 새 Effect를 실행하기 전에 이전 Effect의 cleanup을 먼저 실행한다.
roomId = general
→ general 방 연결 시작
roomId = travel
→ general 방 연결 해제
→ travel 방 연결 시작
roomId = music
→ travel 방 연결 해제
→ music 방 연결 시작
컴포넌트 언마운트
→ music 방 연결 해제
앞에서 Effect를 컴포넌트 생명주기 이벤트로만 보면 헷갈릴 수 있다고 했다. 이 지점에서 그 이유가 드러난다.
컴포넌트는 계속 화면에 남아 있을 수 있다. 하지만 Effect가 동기화하고 있던 대상은 바뀔 수 있다.
ChatRoom 컴포넌트는 그대로 있어도 roomId가 바뀌면 기존 채팅방과의 동기화는 중지하고, 새로운 채팅방과 다시 동기화해야 한다.
그래서 Effect는 “마운트/업데이트/언마운트”보다 “동기화 시작/중지”의 관점으로 보는 것이 더 적절하다.
정리가 필요한 작업은 채팅 연결뿐만이 아니다. Effect에서 외부 작업을 시작했다면 대부분 그에 대응하는 cleanup이 필요하다.
useEffect(() => {
const intervalId = setInterval(() => {
console.log('tick');
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
setInterval은 일정 시간마다 콜백을 계속 실행한다. 컴포넌트가 사라진 뒤에도 interval이 계속 실행되지 않도록 clearInterval로 정리해야 한다.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
이벤트 리스너를 등록했다면 cleanup에서 같은 리스너를 제거해야 한다. 제거하지 않으면 컴포넌트가 사라진 뒤에도 이벤트 핸들러가 계속 실행될 수 있다.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
외부 서버나 웹 소켓 등에 연결했다면, 더 이상 해당 연결이 필요하지 않을 때 연결을 해제해야 한다.
setInterval → clearInterval
addEventListener → removeEventListener
connect → disconnect
정리해보자면, cleanup은 Effect가 시작한 일을 되돌리거나 중지하는 코드이다.
Effect가 외부 시스템과 동기화를 시작한다면, cleanup은 그 동기화를 중지한다!!
우리는 4에서 클린업이 필요한 이유에 대해서 알아봤다. 위에서 든 예시들에서만 클린업이 필요한 것이 아니다. 비동기 작업에서도 cleanup은 중요하다.
특히 fetch처럼 응답 순서를 보장할 수 없는 작업에서는 이전 요청의 응답이 나중에 도착해 최신 상태를 덮어쓰는 문제가 생길 수 있다. 이런 문제를 Race Condition이라고 부른다.
예를 들어, 사용자 프로필을 불러오는 컴포넌트가 있다고 해보자.
function Profile({ userId }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
setProfile(null);
fetchProfile(userId).then(result => {
setProfile(result);
});
}, [userId]);
if (profile === null) {
return <p>Loading...</p>;
}
return <h1>{profile.name}</h1>;
}
겉보기에는 문제가 없어 보인다. userId가 바뀔 때마다 새 프로필을 요청하고, 응답이 오면 profile state를 업데이트한다.
하지만 사용자가 빠르게 userId를 바꾼다면?
1. userId = 1
→ 1번 사용자 요청 시작
2. userId = 2
→ 2번 사용자 요청 시작
3. 2번 사용자 응답이 먼저 도착
→ profile = 2번 사용자
4. 1번 사용자 응답이 나중에 도착
→ profile = 1번 사용자로 덮어씀
위 flow를 시각화해보자.

현재 화면은 userId = 2를 가리키고 있는데, 늦게 도착한 1번 응답이 state를 덮어써버린다.
문제는 요청을 보낸 순서와 응답이 도착하는 순서가 항상 같지 않다는 점이다.
먼저 보낸 요청이 항상 먼저 끝난다는 보장은 없다!
따라서 비동기 Effect에서는 이 응답이 아직 유효한 응답인지를 확인해야 한다.
위 문제를 해결하는 가장 단순한 방법 중 하나는 Effect 안에 ignore 플래그를 두는 것이다.
function Profile({ userId }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
let ignore = false;
setProfile(null);
fetchProfile(userId).then(result => {
if (!ignore) {
setProfile(result);
}
});
return () => {
ignore = true;
};
}, [userId]);
if (profile === null) {
return <p>Loading...</p>;
}
return <h1>{profile.name}</h1>;
}
여기서 중요한 점은 ignore가 Effect 실행마다 새로 만들어지는 지역 변수라는 점이다.
userId가 빠르게 바뀌면 흐름은 다음과 같다.
userId = 1
→ Effect #1 실행
→ ignore #1 = false
→ 1번 사용자 요청 시작
userId = 2
→ Effect #1 cleanup 실행
→ ignore #1 = true
→ Effect #2 실행
→ ignore #2 = false
→ 2번 사용자 요청 시작
위 흐름에서 1번 사용자 요청이 도착한다고 해도, 이미 ignore #1 = true이기 때문에, 실제로 setProfile이 호출되지 않는다.
즉 cleanup에서 이전 Effect의 ignore를 true로 바꾸면, 이전 요청의 결과가 나중에 도착하더라도 state에 반영되지 않는다.
중요한 것은, 여기서 ignore는 공유 변수이면 안 된다는 점이다. 각 Effect 실행마다 자기만의 ignore를 가져야 한다.
// 잘못된 예시
let ignore = false;
function Profile({ userId }) {
useEffect(() => {
ignore = false;
fetchProfile(userId).then(result => {
if (!ignore) {
setProfile(result);
}
});
return () => {
ignore = true;
};
}, [userId]);
}
이렇게 공유하면 새 Effect가 실행될 때 ignore가 다시 false가 된다. 그러면 오래된 요청도 다시 통과할 수 있다.
ignore 플래그의 목적은 최신 요청을 표시하는 것이 아니라, 이전 Effect의 결과를 무효화하는 것이다.
이전 Effect cleanup
→ 이전 요청의 ignore만 true로 변경
→ 이전 응답은 state 반영 불가
ignore 플래그가 요청을 '무시'하는 방식이라면, AbortController는 요청 자체를 취소하는 방식이다.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/profile/${userId}`, {
signal: controller.signal,
})
.then(response => response.json())
.then(result => {
setProfile(result);
})
.catch(error => {
if (error.name !== 'AbortError') {
throw error;
}
});
return () => {
controller.abort();
};
}, [userId]);
cleanup에서 controller.abort()를 호출하면, 아직 진행 중인 fetch 요청을 취소할 수 있다.
정리하면 다음과 같다.
Race Condition 해결 방식 2가지
이전 Effect의 결과를 무시하고 싶은 경우
→ ignore 플래그
요청 자체를 취소하고 싶은 경우
→ AbortController
useEffect의 두 번째 인자로 전달하는 배열을 dependency array, 즉 의존성 배열이라고 부른다.
useEffect(() => {
// Effect
}, [roomId]);
의존성 배열은 Effect가 어떤 값의 변화에 반응해서 다시 동기화되어야 하는지를 나타낸다.
의존성 배열은 작성 방식에 따라 크게 세 가지 형태로 사용된다.
useEffect(() => {
console.log('effect 실행');
});
의존성 배열을 아예 선언하지 않으면 Effect는 매 렌더링 이후 실행된다.
렌더링
→ Effect 실행
다시 렌더링
→ Effect 다시 실행
또 다시 렌더링
→ Effect 다시 실행
이 방식은 렌더링이 일어날 때마다 외부 시스템과 다시 동기화해야 하는 경우에만 적합하다. 하지만 대부분의 경우에는 너무 자주 실행될 수 있다.
예를 들어 다음 코드는 좋지 않다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
});
return <h1>{roomId} room</h1>;
}
이 코드는 컴포넌트가 렌더링될 때마다 기존 연결을 끊고 다시 연결한다. roomId가 바뀌지 않았는데도 부모 리렌더링이나 다른 state 변경만으로 재연결이 발생할 수 있다.
useEffect(() => {
console.log('effect 실행');
}, []);
빈 배열을 전달하면 Effect는 컴포넌트가 처음 마운트된 이후 한 번 실행된다. 이후 리렌더링이 발생해도 다시 실행되지 않는다.
마운트
→ Effect 실행
리렌더링
→ Effect 실행 안 함
언마운트
→ cleanup 실행
예를 들어 컴포넌트가 화면에 나타났을 때 한 번 이벤트 리스너를 등록하고, 사라질 때 제거하는 경우에 사용할 수 있다.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
하지만 빈 배열은 “Effect 안에서 아무 값도 읽지 않는다”는 뜻이 아니다. 정확히는 “이 Effect가 반응해야 할 반응형 값이 없다”는 뜻이다.
따라서 Effect 안에서 props나 state를 읽고 있다면 린트 에러가 발생한다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // 린트 에러 발생!
return <h1>{roomId} room</h1>;
}
위 코드는 처음 roomId로만 연결된다. 이후 roomId가 바뀌어도 Effect는 다시 실행되지 않으므로 새 채팅방에 연결되지 않는 문제점이 생긴다.
useEffect(() => {
console.log('roomId changed:', roomId);
}, [roomId]);
의존성 배열에 값을 전달하면 Effect는 처음 마운트된 이후 한 번 실행되고, 이후 해당 값이 이전 렌더링과 달라졌을 때 다시 실행된다.
마운트
→ Effect 실행
roomId 변경 없음
→ Effect 실행 안 함
roomId 변경
→ 이전 cleanup 실행
→ 새 Effect 실행
채팅방 연결 예시는 이 방식이 적합하다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>{roomId} room</h1>;
}
이 Effect는 roomId에 맞는 채팅방과 동기화한다. 따라서 roomId가 바뀌면 기존 채팅방 연결을 끊고 새 채팅방에 연결해야 한다.
린트 에러에 고통받고 싶지 않다면, 먼저 반응형 값이 무엇인지 이해해야 한다.
React에서 반응형 값은 렌더링이 다시 일어날 때 바뀔 수 있고, 그 변화에 React가 반응할 수 있는 값이다.
대표적인 반응형 값은 다음과 같다.
props
state
context
컴포넌트 본문에서 계산한 변수
예를 들어, 다음 코드를 보자.
function ChatRoom({ roomId, selectedServerUrl }) {
const settings = useContext(SettingsContext);
const serverUrl =
selectedServerUrl ?? settings.defaultServerUrl;
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
return <h1>{roomId} room</h1>;
}
여기서 반응형 값은 다음과 같다.
roomId
selectedServerUrl
settings
serverUrl
roomId와 selectedServerUrl은 props이므로 반응형 값이다. settings는 context에서 읽은 값이므로 반응형 값이다.
serverUrl은 state나 prop이 아니라 컴포넌트 본문에서 선언한 일반 변수이다. 하지만 selectedServerUrl과 settings.defaultServerUrl을 기반으로 계산된다. 컴포넌트가 다시 렌더링되면 다시 계산되고, 그 결과가 달라질 수 있다. 그래서 serverUrl도 반응형 값이다.
Effect 안에서는 serverUrl과 roomId를 읽고 있다.
const connection = createConnection(serverUrl, roomId);
따라서 둘 다 의존성 배열에 포함되어야 한다.
}, [serverUrl, roomId]);
컴포넌트 안에서 선언한 값은 const든 let이든 렌더링마다 다시 만들어지거나 다시 계산될 수 있다. 그래서 Effect 안에서 사용한다면 의존성 배열에 포함해야 하는지 검토해야 한다.
반대로 컴포넌트 바깥의 상수는 반응형 값이 아니다.
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>{roomId} room</h1>;
}
위 코드에서는 serverUrl은 컴포넌트 바깥(전역 변수)에 있다. 컴포넌트가 다시 렌더링되어도 다시 계산되지 않고, 값도 바뀌지 않는다. 따라서 의존성 배열에 넣을 필요가 없다.
정리하면 다음과 같다.
컴포넌트 안에서 props/state/context로부터 계산되는 값
→ 반응형 값
컴포넌트 바깥의 고정된 상수
→ 반응형 값 아님
의존성 배열에서 객체, 배열, 함수는 특히 주의해야 한다. JavaScript에서 객체, 배열, 함수는 값의 내용이 같아 보여도 새로 만들어지면 다른 참조로 비교된다.
function ChatRoom({ roomId }) {
const options = {
serverUrl: 'https://localhost:1234',
roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => {
connection.disconnect();
};
}, [options]);
return <h1>{roomId} room</h1>;
}
options는 컴포넌트가 렌더링될 때마다 새 객체로 만들어진다.
렌더링 #1 → options 객체 A
렌더링 #2 → options 객체 B
렌더링 #3 → options 객체 C
객체 안의 값이 같더라도, 참조는 달라지게 된다.
{} === {} // false
따라서 options를 의존성 배열에 넣으면 렌더링마다 Effect가 다시 실행될 수 있다. 채팅 연결처럼 비용이 큰 작업이라면 불필요한 재연결이 발생할 수 있다.
이런 문제를 방지하기 위해서, 객체를 Effect 안으로 옮기는 편이 더 안전하다.
function ChatRoom({ roomId }) {
useEffect(() => {
const options = {
serverUrl: 'https://localhost:1234',
roomId,
};
const connection = createConnection(options);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>{roomId} room</h1>;
}
이제 의존성 배열에는 실제 동기화 조건인 roomId만 남는다.
function ChatRoom({ roomId }) {
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId,
};
}
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => {
connection.disconnect();
};
}, [createOptions]);
return <h1>{roomId} room</h1>;
}
createOptions 함수도 렌더링마다 새로 만들어진다. 따라서 의존성 배열에 넣으면 Effect가 매번 다시 실행될 수 있다.
이 경우에도 함수를 Effect 안으로 옮기는 것이 더 낫다.
function ChatRoom({ roomId }) {
useEffect(() => {
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId,
};
}
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>{roomId} room</h1>;
}
우리는 앞에서 Effect 안에서 읽는 반응형 값은 의존성 배열에 포함해야 한다고 했다. 그런데 어떤 값은 최신으로 읽어야 하지만, 그 값이 바뀌었다고 Effect 전체를 다시 실행할 필요는 없다.
이럴 때 무작정 의존성을 빼면 stale closure 문제가 생길 수 있다. 반대로 모든 값을 의존성 배열에 넣으면 외부 시스템과의 동기화가 너무 자주 다시 일어날 수 있다.
이 문제는 Effect 안에 서로 다른 성격의 코드가 섞여 있을 때 자주 발생한다.
예를 들어 채팅방에 연결하고, 새 메시지가 오면 현재 테마에 맞춰 알림을 보여주는 컴포넌트가 있다고 해보자.
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', message => {
showNotification(message, theme);
});
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, theme]);
return <h1>{roomId} room</h1>;
}
이 코드는 에러는 없지만 문제점이 있다.
theme가 바뀌면 Effect가 다시 실행된다. 그러면 기존 채팅 연결을 끊고 다시 연결한다.
이건 우리가 의도한 행동이 아니다. 테마가 변경되었을 때 채팅 연결이 끊어지는 상황은 명백히 잘못된 상황이다.
채팅방 연결과 해제 동기화를 결정하는 값은 roomId이다. 따라서 theme가 채팅 연결 Effect를 다시 실행시키는 구조를 분리해야 한다.
하지만 theme를 의존성 배열에서 그냥 제거하면, Effect 안에서 반응형 값을 읽고도 의존성에 배열에 넣지 않은 상태가 된다. 따라서 린트 에러가 발생하게 된다.
두 반응형 값의 역할을 정리해보자.
roomId
→ 외부 시스템과의 동기화 조건
→ 바뀌면 Effect를 다시 실행해야 함
theme
→ 메시지가 왔을 때 알림을 보여주기 위해 필요한 최신 값
→ 바뀌어도 채팅 연결을 다시 만들 필요는 없음
theme는 최신 값을 추적해야 하지만, Effect를 실행시키는 원인이 되면 안된다는게 중요한 점이다.
이럴 때 사용할 수 있는 Hook이 useEffectEvent이다.
useEffectEvent는 Effect 안에서 호출할 수 있는 이벤트 함수를 만든다. 이 함수 안에서는 최신 props와 state를 읽을 수 있다.
하지만 EffectEvent 안에서 읽은 값은 해당 Effect의 dependency가 되지 않는다.
위 코드를 useEffectEvent로 분리하면 다음과 같다.
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onMessage = useEffectEvent(message => {
showNotification(message, theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', message => {
onMessage(message);
});
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <h1>{roomId} room</h1>;
}
이제 채팅 연결 Effect의 의존성 배열에는 roomId만 남는다.
onMessage는 메시지가 도착한 시점의 최신 theme를 읽지만, theme 변경만으로 채팅 연결 Effect가 다시 실행되지는 않는다.
공식 문서의 표현을 빌리면, useEffectEvent는 Effect 안의 반응형 로직과 비반응형 로직을 분리할 때 사용한다.

공식 문서의 useEffectEvent 항목
useEffectEvent를 사용할 때 주의할 점이 있다. useEffectEvent는 의존성 배열을 피하기 위한 지름길이 아니다.
예를 들어 다음 코드는 잘못된 사용이다.
function ChatRoom({ roomId }) {
const connect = useEffectEvent(() => {
const connection = createConnection(roomId);
connection.connect();
return connection;
});
useEffect(() => {
const connection = connect();
return () => {
connection.disconnect();
};
}, []);
}
이 코드는 Effect가 반응해야 하는 값인 roomId를 useEffectEvent 안으로 숨겼다. 그래서 Effect의 의존성 배열은 빈 배열이 되었다.
이 코드는 roomId가 바뀌어도 Effect가 다시 실행되지 않는다.
하지만 roomId는 채팅방 연결/해제를 결정하는 중요한 값이다. roomId가 바뀌면 기존 방 연결을 끊고 새 방에 연결해야 한다. 즉 roomId는 Effect가 반응해야 하는 값이다.
올바른 기준은 다음과 같다.
이 값이 바뀌면 외부 시스템과 다시 동기화해야 하는가?
→ dependency에 있어야 한다.
이 값은 이벤트가 발생했을 때 최신으로 읽기만 하면 되는가?
→ useEffectEvent로 분리할 수 있다.
❗️ useEffectEvent는 비반응형 로직을 분리할 때만 사용하자!
useEffect를 잘 쓰는 것만큼 중요한 것은, Effect가 필요하지 않은 상황을 구분하는 것이다.
Effect는 외부 시스템과 동기화하기 위한 도구이다. 따라서 단순히 값을 계산하거나, 사용자 이벤트에 반응하는 로직까지 Effect로 옮길 필요는 없다.
Effect가 필요하지 않은 코드를 Effect로 작성하면 오히려 코드가 복잡해지고, 불필요한 렌더링이 발생하며, 중간에 잘못된 상태가 생길 수 있다.
props나 state로부터 계산할 수 있는 값은 Effect와 state를 사용하지 않아도 된다.
예를 들어 이름과 성을 합쳐 전체 이름을 보여주는 컴포넌트가 있다고 해보자.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <h1>{fullName}</h1>;
}
이 코드는 동작하지만 좋은 구조는 아니다.
fullName은 이미 firstName과 lastName으로 계산할 수 있는 값이다. 그런데 별도의 state로 저장하고, Effect에서 다시 업데이트하고 있다.
이렇게 작성하면 흐름이 불필요하게 길어진다.
firstName 또는 lastName 변경
→ 렌더링
→ 이전 fullName으로 화면 표시
→ Effect 실행
→ setFullName 실행
→ 다시 렌더링
→ 새 fullName 표시
즉 한 번 더 렌더링이 발생한다.
이 경우에는 렌더링 중에 바로 계산하면 된다.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName;
return <h1>{fullName}</h1>;
}
이제 fullName은 state가 아니다. 렌더링 중 계산되는 값이다.
firstName 또는 lastName 변경
→ 렌더링
→ fullName 계산
→ 화면 표시
더 단순하고, 중간에 오래된 fullName이 표시되는 상태도 없다.
목록 필터링의 경우도 마찬가지다.
function TodoList({ todos, filter }) {
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
return <List items={visibleTodos} />;
}
visibleTodos는 todos와 filter로 계산할 수 있다. 따라서 Effect로 state를 갱신할 필요가 없다.
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
return <List items={visibleTodos} />;
}
❗️ 렌더링 중 계산이 비싼 작업이여도 괜찮은 이유
React 컴파일러는 비용이 많이 드는 계산을 자동으로 메모이제이션할 수 있어, 많은 경우 수동으로 useMemo를 사용할 필요가 없다!
props가 변경될 때, 컴포넌트 내부 state를 초기화하고 싶을 때가 있다.
예를 들어 프로필 페이지에 댓글 입력창이 있다고 해보자. 프로필 페이지에서는 userId에 따라서 댓글을 보관하는 로직이 존재한다.
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// Effect에서 prop 변경 시 state 초기화
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
이 코드는 userId가 바뀌면 comment를 비운다. 하지만 한 가지 문제가 존재한다.
useEffect는 렌더링 이후에 실행된다. 따라서 userId가 바뀐 직후에는 새 사용자 화면이 이전 사용자의 comment 값을 가진 상태로 한 번 렌더링될 수 있다.
userId = 1
→ comment = "hello"
userId = 2
→ 먼저 user-b 화면이 "hello" comment와 함께 렌더링
→ Effect 실행
→ setComment('')
→ 다시 렌더링
→ comment 비워짐
이건 우리가 의도한 상황이 아니다. 잠깐이라도 새 사용자 프로필에 이전 사용자의 입력값이 남아 있는 상태가 생긴다. 또한 초기화해야 할 state가 많아질수록 Effect 안에서 하나하나 비워줘야 한다.
이럴 때는 key를 사용할 수 있다.
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState('');
// ...
}
React는 같은 위치에 같은 컴포넌트가 렌더링되면 state를 보존한다. 하지만 key가 바뀌면 React는 이전 컴포넌트와 새로운 컴포넌트를 다른 컴포넌트로 취급한다.
key = user_1
→ user_1용 Profile state 생성
key = user_2
→ user_1용 Profile 제거
→ user_2용 Profile 새로 생성
key를 넣음으로써 각 프로필의 comment state는 useEffect로 초기화하는 것이 아니라, 처음부터 빈 상태로 존재한다.
서버 요청이라고 해서 항상 Effect에 있어야 하는 것은 아니다.
중요한 기준은 “이 요청이 왜 발생해야 하는가?”이다.
컴포넌트가 화면에 나타났기 때문에 발생해야 하는 요청인가?
→ Effect 고려
사용자가 특정 행동을 했기 때문에 발생해야 하는 요청인가?
→ 이벤트 핸들러
예를 들어 사용자가 회원가입 폼을 제출했을 때 POST 요청을 보내는 경우를 보자.
function RegisterForm({ firstName, lastName }) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
post('/api/register', {
firstName,
lastName,
});
}
}, [submitted, firstName, lastName]);
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
);
}
이 코드는 동작할 수 있지만 좋은 구조는 아니다.
회원가입 요청은 컴포넌트가 렌더링되었기 때문에 발생해야 하는 것이 아니다. 사용자가 폼을 제출해야만 발생해야 한다.
그렇다면 POST 요청은 Effect가 아니라 이벤트 핸들러 안에 있어야 한다.
function RegisterForm({ firstName, lastName }) {
function handleSubmit(e) {
e.preventDefault();
post('/api/register', {
firstName,
lastName,
});
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
);
}
handleSubmit() 메서드로 분리 후, 폼 제출 onSubmit에 연결했다.
정리해보자면,
GET 요청이라서 Effect, POST 요청이라서 이벤트 핸들러
→ 이렇게 단순히 나눌 수는 없다.
요청의 원인이 렌더링 결과인가?
→ Effect
요청의 원인이 사용자 행동인가?
→ 이벤트 핸들러
하지만 일반적으로 폼 제출, 결제, 장바구니 추가, 좋아요 클릭처럼 사용자의 명확한 행동으로 발생하는 POST 요청은 이벤트 핸들러에 두는 것이 적절한 방법이다.
useEffect를 이해할 때 가장 중요한 기준은 “언제 실행되는가”보다 “무엇과 동기화하는가”이다.
useEffect는 단순히 렌더링 이후에 코드를 실행하는 도구가 아니다. 렌더링 결과에 맞춰 React 바깥의 외부 시스템과 동기화하기 위한 탈출구이다.
그래서 Effect를 작성하기 전에는 먼저 다음 질문들을 던져야 한다.
이 코드는 외부 시스템과 동기화하는 코드인가?
만약 props나 state로 계산할 수 있는 값이라면 Effect가 필요하지 않다. 렌더링 중에 계산하면 된다.
사용자가 버튼을 클릭하거나 폼을 제출했기 때문에 실행되어야 하는 로직이라면 Effect가 아니라 이벤트 핸들러에 있어야 한다.
이 동기화는 언제 시작되고, 언제 중지되어야 하는가?
Effect 안에서는 동기화를 시작하고, cleanup에서는 그 동기화를 중지한다.
cleanup은 언마운트 때만 실행되는 것이 아니다. 의존성이 바뀌어 새 Effect가 실행되기 전에도 이전 cleanup이 먼저 실행된다. 그래서 Effect는 마운트/업데이트/언마운트보다 “시작/중지 사이클”로 이해하는 편이 더 자연스럽다.
이 값이 바뀌면 기존 동기화가 더 이상 유효한가?
Effect 안에서 읽는 반응형 값은 의존성에 포함해야 한다. 하지만 모든 값을 무작정 넣는 것이 아니라, Effect 안의 로직이 섞여 있지는 않은지 봐야 한다. 최신 값은 필요하지만 Effect 전체를 다시 실행할 필요가 없는 로직은 useEffectEvent로 분리할 수 있다.
정리하면 useEffect를 사용할 때 기준은 다음과 같다.
1. 외부 시스템과 동기화하는 코드인가?
2. 렌더링 중 계산할 수 있는 값은 아닌가?
3. 사용자 이벤트에 있어야 하는 로직은 아닌가?
4. 이 Effect는 언제 시작되고 언제 중지되어야 하는가?
useEffect는 많이 쓴다고 좋은 코드가 되는 도구가 아니다. 오히려 잘 쓰기 위해서는 “언제 쓰지 않을지”를 먼저 판단해야 한다.
useEffect는 상태 계산 도구가 아니라, React 바깥의 세계와 현재 렌더링 결과를 동기화하는 도구이다!
이펙트로 동기화하기
Effect가 필요하지 않은 경우
React Effect의 생명주기
Effect에서 이벤트 분리하기
Effect의 의존성 제거하기