렌더 함수는 원래 순수해야 한다. 같은 상태와 props를 주면 같은 화면을 계산해서 돌려주고, 그 외엔 아무 데도 손대지 않는 게 이상이다. 근데 실제 앱은 서버에서 데이터를 받아오고, 문서 제목을 바꾸고, 타이머를 걸고, 이벤트를 구독한다. 이렇게 화면 계산 바깥의 세상을 건드리는 일을 사이드 이펙트(side effect, 부수 효과)라고 부른다. 이런 일은 렌더 도중에 하면 안 되고, useEffect에 담아 렌더가 끝난 뒤로 미뤄야 한다.
왜 이펙트에 담아야 하는지, 의존성 배열과 정리 함수를 어떻게 쓰는지, 그리고 초보가 꼭 한 번씩 빠지는 함정 두 개를 상황과 함께 보여주겠다.
이펙트는 렌더 결과가 아니라 렌더 뒤의 행동이다. 문서 제목을 상태에 맞춰 바꾸는 흔한 예를 보자. 이건 화면 JSX로 표현할 수 없는, 브라우저를 직접 건드리는 일이다.
function Title({ count }) {
useEffect(() => {
document.title = `알림 ${count}개`;
}, [count]);
return <p>알림 {count}개</p>;
}
count가 바뀔 때마다 이펙트가 실행돼 탭 제목을 갱신한다. 이 document.title 대입을 렌더 본문에 그냥 써버리면, 리액트가 화면을 계산하는 도중에 바깥 세상을 건드리는 꼴이라 예측이 어긋난다. 그래서 이런 건 전부 이펙트로 미룬다. 화면 그리기와 바깥 건드리기를 분리하는 것이다.
의존성 배열은 이펙트를 언제 다시 돌릴지 정한다. 배열 안의 값이 이전 렌더와 달라졌을 때만 이펙트가 다시 실행된다. 이 배열을 어떻게 쓰느냐에 따라 세 가지로 갈린다.
useEffect(fn, [a, b]); // a나 b가 바뀔 때마다
useEffect(fn, []); // 마운트 때 딱 한 번
useEffect(fn); // 매 렌더마다 (배열 자체를 뺀 경우)
세 번째, 배열을 아예 안 주면 렌더될 때마다 매번 실행된다. 이걸 모르고 배열을 빠뜨리면 뒤에 나올 무한 루프로 직행하기 쉽다. 그래서 이펙트를 쓸 땐 "이건 언제 다시 돌아야 하지?"를 먼저 정하고 그에 맞는 배열을 적는 습관이 안전하다.
이펙트 안에서 상태를 바꾸면 무한 루프에 빠진다. 이게 첫 번째 함정이고, 콘솔이 폭주하며 브라우저가 뻗기 때문에 바로 티가 난다. 배열을 안 주고 이펙트 안에서 상태를 갱신하면 이렇게 된다.
function Broken() {
const [n, setN] = useState(0);
useEffect(() => {
setN(n + 1); // 배열이 없다
});
return <p>{n}</p>;
}
흐름을 따라가 보면 답이 나온다. 렌더가 끝나면 이펙트가 돌고, 이펙트가 setN으로 상태를 바꾸니 다시 렌더된다. 배열이 없으니 렌더 뒤에 이펙트가 또 돌고, 또 상태를 바꾸고, 다시 렌더되고. 이 고리가 끝없이 돈다. 나는 초보 때 "화면 뜨자마자 카운트 한 번 올리자"는 생각으로 이걸 그대로 짰다가 노트북 팬이 돌기 시작하는 걸 봤다. 정말 한 번만 올리고 싶으면 []를 달아 마운트 때 한 번으로 못 박아야 한다.
의존성을 빼먹으면 값이 옛것에 묶여버린다. 두 번째 함정은 무한 루프와 반대로 조용해서 더 오래 헤맨다. 이펙트 안에서 쓰는 값을 배열에 안 적으면, 이펙트가 처음 만들어질 때의 낡은 값을 계속 붙들고 있게 된다.
function Counter() {
const [n, setN] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(n); // 항상 0만 찍힌다
}, 1000);
return () => clearInterval(id);
}, []); // n을 안 적었다
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
버튼을 눌러 n을 5까지 올려도, 인터벌은 계속 0만 찍는다. 배열이 []라 이펙트는 마운트 때 한 번만 만들어졌고, 그 순간의 n은 0이었다. 이펙트 안의 함수는 그 0을 기억한 채 얼어붙는다. 이걸 낡은 클로저(stale closure, 옛 값을 붙든 함수)라고 부른다. 리액트가 개발 모드에서 eslint 경고로 "이 이펙트는 n에 의존한다"고 알려주는데, 이 경고를 무시하고 배열을 비워두면 딱 이 버그를 만난다. 배열에 n을 넣거나, setN((prev) => prev + 1)처럼 이전 값을 함수로 받아 옛 값 참조 자체를 없애면 풀린다.
데이터를 받아올 땐 정리 함수로 늦은 응답을 버린다. 이펙트에서 fetch를 쓰는 건 흔한데, 여기서도 정리가 필요하다. 요청이 끝나기 전에 컴포넌트가 사라지거나 다른 요청이 시작되면, 늦게 도착한 응답이 이미 지난 화면을 덮어쓰는 사고가 난다.
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/user/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => { ignore = true; };
}, [userId]);
return <p>{user?.name}</p>;
}
userId가 바뀌면 정리 함수가 먼저 돌아 ignore를 true로 바꾸고, 이전 요청의 응답이 늦게 와도 if (!ignore)에 걸려 상태를 안 건드린다. 그다음 새 userId로 이펙트가 다시 실행된다. 이 장치가 없으면 사용자를 빠르게 넘길 때 이전 사람의 데이터가 뒤늦게 도착해 화면이 엉키는데, 재현이 어려워 원인 찾기가 고약하다.
이벤트 구독도 걸었으면 떼야 한다. window에 스크롤이나 리사이즈(resize, 창 크기 변경) 리스너를 다는 것도 대표적인 사이드 이펙트다. 붙이는 코드와 떼는 코드가 짝을 이뤄야 한다.
function Width() {
const [w, setW] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return <p>{w}px</p>;
}
정리 함수에서 removeEventListener에 넘기는 함수는 addEventListener에 넣은 것과 정확히 같은 참조여야 한다. 그래서 onResize를 이펙트 안에서 한 번 만들어 양쪽에 같이 쓴다. 익명 함수를 각각 따로 넣으면 서로 다른 함수라 리스너가 안 떼지고, 화면을 오갈수록 리스너가 쌓여 리사이즈 한 번에 콜백이 수십 번 도는 꼴이 된다. 이것도 앞의 타이머와 똑같은 "걸었으면 거둔다" 규칙이다.
이펙트는 최후의 수단이라는 감각을 갖자. 사이드 이펙트를 다루는 핵심은 세 가지다. 바깥 세상을 건드리는 일만 이펙트에 담고, 그 안에서 쓰는 값은 빠짐없이 의존성 배열에 적고, 무언가 걸었으면 정리 함수로 반드시 거둔다. 무한 루프는 요란해서 금방 잡지만 낡은 클로저는 조용해서 오래 문다. 그러니 경고를 지우지 말고 배열을 정직하게 채우는 게, 결국 새벽에 원인 모를 버그로 안 깨는 길이다.