수명 종료와 정리
1. 왜 정리가 필요한가
컴포넌트나 함수는 끝날 때가 되면 저절로 사라집니다. 렌더링이 끝나면 컴포넌트가 사라지고, 함수는 실행을 마치면 그 안의 변수까지 함께 사라집니다.
그런데 listener, timer, observer는 다르게 움직입니다. addEventListener(type, callback)이나 setInterval(callback, ms)에 넘긴 콜백 함수는, 함수 안의 변수처럼 내가 계속 들고 있는 게 아닙니다. 브라우저에게 넘겨버립니다.
브라우저는 이 콜백을 자기 목록에 적어 둡니다. 이벤트 리스너는 리스너 목록에, 타이머는 타이머 큐에 저장됩니다. 이 목록은 내 함수와 상관없이 따로 돌아갑니다. 정해둔 시간이 되거나 클릭 같은 이벤트가 일어나면, 브라우저는 이 목록을 보고 적혀 있는 콜백을 실행합니다.
콜백을 넘긴 함수가 이미 끝나서 사라졌어도 마찬가지입니다. 브라우저는 그걸 알 길이 없으니, 목록에 적힌 콜백을 그대로 실행합니다.
말로는 아직 감이 안 올 수 있으니, 1초마다 현재 시각을 보여주는 컴포넌트로 직접 확인해 봅시다.
import { useEffect, useState } from "react";
export default function LiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
setInterval(() => {
setNow(new Date());
}, 1000);
// setInterval이 돌려준 id를 어디에도 저장하지 않았습니다.
}, []);
return <time>{now.toLocaleTimeString()}</time>;
}
이 컴포넌트가 화면에서 사라져도(unmount) 타이머는 멈추지 않습니다. 타이머를 켠 건 LiveClock이지만, 타이머가 실제로 돌아가는 곳은 브라우저의 타이머 큐이고, 이 큐는 컴포넌트가 사라지든 말든 신경 쓰지 않기 때문입니다.
이렇게 "등록한 코드"보다 "등록된 대상"이 더 오래 살아남으면, 실제로 다음과 같은 문제가 생깁니다.
- 메모리 누수: 사라진 컴포넌트를 참조하는 콜백이 타이머 큐나 리스너 목록에 남아 있으면, 가비지 컬렉터는 이 컴포넌트를 메모리에서 지우지 못합니다. 컴포넌트가 들고 있던 데이터까지 같이 못 지웁니다.
- 좀비 콜백: 위
LiveClock예시에서 컴포넌트가 사라진 뒤에도 타이머 콜백은 계속 실행되면서, 이미 사라진 컴포넌트의setNow를 계속 호출합니다. React는 이 상황을 보고 "이미 사라진 컴포넌트의 state를 바꾸려 한다"는 경고를 띄웁니다. - 중복 등록: props가 바뀌어서 같은 effect가 다시 실행될 때 cleanup이 없으면, 이전 타이머는 그대로 남은 채 새 타이머가 또 생깁니다. 그러면 1초에 한 번 실행돼야 할 콜백이 두 번, 세 번씩 실행됩니다.
말로 설명하는 것보다, 메모리 누수가 실제로 눌어붙어 있는 걸 직접 보는 게 더 확실합니다. 아래에서 "버그 위젯"을 서너 개 추가한 다음, "화면에서 전부 제거"를 눌러 보세요. 화면에 보이는 위젯은 0개가 되지만, 그 아래 로그는 멈추지 않고 계속 쌓입니다. 이미 지운 위젯의 타이머가 여전히 돌고 있다는 뜻입니다. 똑같이 "정상 위젯"으로 해보면, 제거하는 순간 그 위젯의 로그도 함께 멈춥니다.
화면에 보이는 위젯 0개 / 지금도 돌아가는 타이머 0개
아직 로그가 없습니다.
정리하면 컴포넌트(코드)의 수명과 타이머·리스너(등록된 대상)의 수명은 서로 다른 시계로 흘러갑니다.
| 수명이 끝나는 방식 | 끝나는 시점 | |
|---|---|---|
| 코드 (컴포넌트, 함수 실행) | 자동으로 끝남 | 렌더링이 끝나거나 함수가 리턴할 때 |
| 등록된 대상 (리스너, 타이머, 옵저버) | 누군가 해제 함수를 직접 불러야 끝남 | 해제 함수를 호출하는 시점 |
코드는 자동으로 끝나지만, 등록된 대상은 누군가 나서서 해제해주지 않으면 끝나지 않습니다. 이 글에서 다룰 listener, timer, observer, AbortController는 결국 "누가 그 해제를 불러줄 것인가"에 대한 서로 다른 답입니다.
2. Listener
이벤트 리스너도 1번에서 본 것과 똑같습니다. addEventListener로 등록한 콜백은 브라우저의 리스너 목록에 들어가고, 해제하기 전까지 계속 그 자리에 남아 있습니다. 해제하는 방법은 removeEventListener입니다.
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
// 할 일이 끝나면 같은 핸들러로 해제합니다
window.removeEventListener("resize", handleResize);
여기서 중요한 포인트가 하나 있습니다. removeEventListener는 "같은 타입"과 "같은 함수"를 보고 어떤 리스너를 지울지 찾습니다. 함수를 변수에 담지 않고 바로 등록하면, 해제할 때 아무리 똑같이 생긴 함수를 다시 만들어 넘겨도 지워지지 않습니다.
window.addEventListener("resize", () => {
console.log(window.innerWidth);
});
// 코드는 위와 똑같아 보이지만, 메모리 상으로는 전혀 다른 함수입니다. 해제되지 않습니다.
window.removeEventListener("resize", () => {
console.log(window.innerWidth);
});
두 화살표 함수는 코드만 같을 뿐, 서로 다른 함수입니다. removeEventListener는 "이렇게 생긴 함수를 찾아서 지워줘"가 아니라 "내가 전에 등록했던 바로 그 함수를 지워줘"로 동작합니다. 그러니 나중에 해제할 생각이라면, 처음 등록할 때부터 함수를 변수나 이름 붙인 함수로 만들어 두고 그걸 그대로 등록·해제에 써야 합니다.
React에서는 useEffect로 묶습니다
React 컴포넌트 안에서는 useEffect의 cleanup 함수로 등록과 해제를 한 쌍으로 묶어 둡니다.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
effect 함수 안에서 만든 handleResize를 cleanup 함수가 클로저로 그대로 기억하고 있다가, 등록할 때 썼던 바로 그 함수를 해제에도 넘깁니다. 등록과 해제가 같은 스코프에 붙어 있으니, "등록은 했는데 해제할 함수를 못 찾는" 일이 생기지 않습니다.
3. Timer
타이머도 리스너와 같은 구조입니다. setTimeout이나 setInterval을 호출하면 콜백이 브라우저의 타이머 큐로 넘어가고, 해제하기 전까지 거기서 계속 돌아갑니다. 해제하는 방법은 clearTimeout과 clearInterval입니다.
const id = setInterval(() => {
console.log("tick");
}, 1000);
// 할 일이 끝나면 꺼줍니다
clearInterval(id);
여기서 리스너와 다른 점이 하나 있습니다. removeEventListener는 등록할 때 쓴 함수를 그대로 다시 넘겨서 해제했지만, clearInterval은 함수가 아니라 setInterval이 돌려준 id(숫자)로 해제할 대상을 찾습니다. 그러니 이 id를 변수에 담아두지 않으면, 나중에 그 타이머를 끌 방법이 없습니다. 1번에서 본 LiveClock 예시가 바로 이 문제였습니다. id를 어디에도 저장하지 않아서, 컴포넌트가 사라진 뒤에도 끌 수 없었던 것입니다.
useEffect(() => {
const id = setInterval(() => {
setNow(new Date());
}, 1000);
return () => {
clearInterval(id);
};
}, []);
id를 변수에 담아 cleanup에서 그대로 넘기면, 컴포넌트가 사라질 때 타이머도 같이 꺼집니다.
deps가 바뀔 때 생기는 흔한 버그
실무에서는 타이머가 어떤 값에 의존하는 경우가 많습니다. 몇 초마다 갱신할지를 props로 받는 경우를 생각해 봅시다.
function Ticker({ intervalMs }: { intervalMs: number }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, intervalMs);
// cleanup을 깜빡했습니다
}, [intervalMs]);
return <span>{count}</span>;
}
intervalMs가 바뀔 때마다 effect가 다시 실행되면서 새 타이머가 하나씩 더 생깁니다. 이전 타이머의 id는 effect가 다시 실행되는 순간 클로저 밖으로 사라져서, 더는 끌 방법이 없습니다. intervalMs를 세 번 바꾸면 타이머 네 개가 동시에 count를 올리고 있는 셈입니다. cleanup에서 clearInterval(id)를 호출해야, 새 타이머를 걸기 전에 이전 타이머가 먼저 꺼집니다.
클로저는 오래된 값을 기억합니다
타이머 콜백은 클로저라서, 등록되는 순간의 변수 값을 그대로 기억합니다. effect가 다시 실행되지 않는 한, 그 콜백은 계속 같은 값만 보고 있습니다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 항상 0만 찍힙니다
}, 1000);
return () => clearInterval(id);
}, []); // count가 deps에 없습니다
return <button onClick={() => setCount(count + 1)}>+1</button>;
}
버튼을 눌러 화면의 count는 올라가지만, 콜백이 찍는 값은 계속 0입니다. effect가 처음 한 번만 실행됐기 때문에, 타이머 콜백은 그때 만들어질 당시의 count(0)만 기억하고 있는 것입니다. 이런 현상을 "stale closure(오래된 클로저)" 문제라고 부릅니다.
해결 방법은 간단합니다. count를 deps에 넣으면 됩니다.
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 이제 최신 값을 찍습니다
}, 1000);
return () => clearInterval(id);
}, [count]);
count가 바뀔 때마다 cleanup이 이전 타이머를 지우고, 그 시점의 count를 기억하는 새 타이머가 다시 걸립니다. 그래서 타이머 콜백은 항상 최신 count를 보게 됩니다. stale closure도 결국 바로 앞에서 본 중복 등록 문제와 똑같이, "deps가 바뀌면 cleanup하고 다시 등록한다"는 cleanup 패턴으로 풀립니다.
말로만 보면 둘의 차이가 잘 안 느껴질 수 있으니, 두 버전을 나란히 놓고 직접 눌러 보겠습니다. 왼쪽은 console.log(count) 자리를 화면에 그대로 옮긴 버그 버전이고, 오른쪽은 deps에 count를 넣은 버전입니다. +1을 몇 번 누르고 1초씩 기다려 보면, "타이머가 보는 값"이 왼쪽에서는 멈춰 있고 오른쪽에서는 따라오는 걸 볼 수 있습니다.
버그 있음 — deps: []
count0
타이머가 보는 값0
고친 버전 — deps: [count]
count0
타이머가 보는 값0
4. Observer
Observer도 같은 패턴입니다. observe()로 등록하고, 해제는 disconnect()로 합니다. 다만 리스너·타이머와 다른 점이 하나 있습니다. 리스너와 타이머는 등록 한 번에 대상도 하나지만, Observer는 인스턴스 하나에 관찰 대상을 여러 개 붙일 수 있습니다.
실무에서 자주 쓰는 무한 스크롤을 예로 들어 보겠습니다. 목록 맨 아래에 있는 "더 보기" 지점이 화면에 들어오면 다음 페이지를 불러옵니다.
function InfiniteList({ onLoadMore }: { onLoadMore: () => void }) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onLoadMore();
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [onLoadMore]);
return <div ref={sentinelRef} />;
}
여기서는 관찰 대상이 sentinel 노드 하나뿐이라서, cleanup에서 disconnect()를 불러도 아무 문제가 없습니다. disconnect()는 이 observer 인스턴스가 관찰하던 대상을 전부 한 번에 끊어버립니다.
관찰 대상이 여러 개일 때
같은 observer 인스턴스로 여러 노드를 관찰하는 경우도 있습니다. 목록의 카드마다 화면에 보이는지를 각각 추적해서, 보이는 카드에만 애니메이션을 재생하는 경우를 생각해 봅시다.
function CardGrid({ cards }: { cards: Card[] }) {
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle("visible", entry.isIntersecting);
});
});
const elements = document.querySelectorAll("[data-card]");
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [cards]);
// ...
}
effect가 끝날 때 전부 한꺼번에 끊을 거라면 여기서도 disconnect() 하나로 충분합니다. 하지만 카드 하나가 목록에서 빠질 때 그 카드만 관찰을 멈추고 싶다면, 전체를 끊는 disconnect()로는 안 됩니다. 그 카드 하나만 빼는 unobserve(element)를 써야 합니다.
observer.unobserve(removedCardElement);
unobserve는 관찰 목록에서 노드 하나만 빼고, 나머지 노드는 그대로 관찰을 이어갑니다. disconnect는 인스턴스 전체를 끊어버려서, 그 뒤로 새 노드를 추가해도 다시 observe()를 호출하기 전까지는 아무것도 보지 않습니다. 리스너나 타이머는 "등록 하나에 대상 하나"라서 이런 구분이 필요 없었지만, Observer는 "인스턴스 하나에 대상 여러 개"인 구조라서 전체 해제와 일부 해제를 나눠서 다뤄야 합니다.
5. AbortController
지금까지 본 세 가지는 해제하는 방법이 전부 다릅니다. 리스너는 removeEventListener, 타이머는 clearTimeout/clearInterval, Observer는 disconnect()를 씁니다. fetch는 한술 더 떠서, 보낸 요청을 취소하는 방법 자체가 따로 없습니다.
AbortController는 이렇게 제각각인 해제 방법을 하나로 묶어줍니다. controller.signal을 여러 API에 똑같이 넘겨두면, controller.abort() 한 번으로 거기에 연결된 모든 것이 한꺼번에 취소됩니다.
const controller = new AbortController();
fetch("/api/user", { signal: controller.signal });
window.addEventListener("resize", handleResize, { signal: controller.signal });
// 둘 다 한 번에 취소됩니다
controller.abort();
fetch와 addEventListener는 둘 다 옵션으로 signal을 받습니다. 이 signal이 "abort" 상태가 되면 fetch는 요청을 중단하고, 리스너는 자동으로 해제됩니다. removeEventListener를 따로 안 불러도 됩니다. signal 하나로 등록과 해제가 한 세트로 묶여 있는 셈입니다.
signal을 직접 받지 않는 API
문제는 setInterval이나 Observer처럼 signal 옵션 자체를 모르는 API입니다. 이런 건 signal이 abort될 때 실행할 콜백을 직접 연결해 줘야 합니다. signal도 EventTarget이라서, abort 이벤트를 리스너로 등록할 수 있습니다.
const controller = new AbortController();
const id = setInterval(() => {
console.log("tick");
}, 1000);
controller.signal.addEventListener("abort", () => {
clearInterval(id);
});
controller.abort(); // abort 이벤트가 발생하고, 위 리스너가 clearInterval을 호출합니다
여러 리소스를 한 번에 묶기
AbortController가 진짜 쓸모 있는 지점은, 한 effect 안에서 여러 종류의 리소스를 동시에 정리해야 할 때입니다.
useEffect(() => {
const controller = new AbortController();
fetch("/api/user", { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
window.addEventListener("resize", handleResize, { signal: controller.signal });
const id = setInterval(() => {
console.log("tick");
}, 1000);
controller.signal.addEventListener("abort", () => clearInterval(id));
return () => controller.abort();
}, []);
cleanup 함수는 controller.abort() 한 줄이 전부입니다. fetch, 리스너, 타이머마다 다른 해제 함수를 따로 부르지 않고도, signal로 엮어둔 모든 걸 한 번에 끊을 수 있습니다. 2~4번에서 본 "등록-해제 쌍"은 그대로 남아 있지만, 그 해제들을 하나의 신호로 모아둔 것이 AbortController입니다.
취소 이유 전달하기
controller.abort()는 인자로 취소 이유를 받을 수 있습니다.
controller.abort(new Error("사용자가 페이지를 벗어남"));
이 값은 signal을 쓰는 쪽에서 signal.reason으로 읽을 수 있고, fetch가 reject하는 에러도 이 reason이 됩니다. "누가, 왜 취소했는지"를 남기고 싶을 때 씁니다.
6. 한눈에 보는 표
| API | 등록 | 해제 | 해제 안 하면 |
|---|---|---|---|
| Listener | addEventListener | removeEventListener | 참조 유지로 인한 메모리 누수, 중복 핸들러 |
| Timer | setTimeout/setInterval | clearTimeout/clearInterval | 죽은 범위를 참조하는 콜백 실행, stale 클로저 |
| Observer | observe() | unobserve()/disconnect() | 사라진 노드를 계속 감시, 콜백 누적 |
| AbortController | signal 전달 | controller.abort() | (signal을 넘긴 대상들이) 개별 해제 API만큼만 안전 |
7. 퀴즈로 확인하기
퀴즈 1 (Listener). teardown()을 호출하면 scroll 리스너가 해제될까요?
function setup() {
window.addEventListener("scroll", () => {
console.log("scrolled");
});
}
function teardown() {
window.removeEventListener("scroll", () => {
console.log("scrolled");
});
}
setup();
teardown();
정답 보기
해제되지 않습니다.
setup과 teardown은 둘 다 화살표 함수를 새로 만들어서 넘깁니다. 코드는 똑같이 생겼지만, removeEventListener가 찾는 건 "등록할 때 넘긴 바로 그 함수"입니다. teardown의 화살표 함수는 setup의 화살표 함수와 다른 함수이므로, 해제 대상을 찾지 못하고 아무 일도 하지 않습니다. 해제하려면 두 함수가 같은 함수를 참조해야 하므로, 핸들러를 변수나 모듈 스코프의 이름 있는 함수로 빼서 setup과 teardown이 같은 참조를 쓰게 해야 합니다.
퀴즈 2 (Timer). id가 1 → 2 → 3으로 세 번 바뀐 뒤, 1초마다 콘솔에는 몇 줄이 찍히고 있을까요?
function Ticker({ id }: { id: number }) {
useEffect(() => {
setInterval(() => {
console.log(`${id}번 틱`);
}, 1000);
}, [id]);
return null;
}
정답 보기
3줄입니다. ("1번 틱", "2번 틱", "3번 틱"이 1초마다 한 번씩)
id가 바뀔 때마다 effect가 다시 실행되면서 새 타이머가 하나씩 생깁니다. 그런데 이 effect는 cleanup 함수를 반환하지 않으므로, 이전 타이머는 지워지지 않고 계속 돌아갑니다. id가 세 번 바뀌면 타이머 세 개가 각자 1초마다 멈추지 않고 계속 실행되는 상태가 됩니다. 고치려면 return () => clearInterval(timer)를 추가해서, id가 바뀔 때마다 이전 타이머부터 지워야 합니다.
퀴즈 3 (AbortController). 검색창에 "a"를 입력한 직후 "ap"까지 빠르게 입력했습니다. "a"로 보낸 요청의 응답이 "ap"로 보낸 요청의 응답보다 늦게 도착해도, 이 코드는 화면에 "ap" 검색 결과를 그대로 유지합니다. 왜 그럴까요?
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults);
return () => controller.abort();
}, [query]);
정답 보기
query가 "a"에서 "ap"로 바뀌는 순간, React는 새 effect를 실행하기 전에 이전 effect의 cleanup을 먼저 호출합니다. 즉 "ap"로 요청을 보내기 전에 "a" 요청에 쓰인 controller.abort()가 먼저 실행됩니다.
abort()가 호출되면 "a" 요청의 fetch 프라미스는 실패(reject) 상태가 되고, 그 뒤에 연결된 .then(res => res.json()).then(setResults)는 아예 실행되지 않습니다. 그래서 "a" 요청의 응답이 나중에 도착하더라도 setResults를 호출하지 못하고, 화면에는 "ap" 요청으로 받은 최신 결과만 남습니다. signal을 넘기지 않았다면 두 요청이 모두 끝까지 실행되어, 더 늦게 도착한 응답이 화면을 덮어써 버리는 경쟁 상태(race condition)가 생길 수 있습니다.