마운트되는 각 JavaScript 뷰에 새 AbortController를 제공하고, 해당 컨트롤러의 신호를 사용해 리스너를 등록한 다음 언마운트할 때 컨트롤러를 중단하세요. 이 튜토리얼에서는 간단한 브라우저 뷰를 만들고 Mac의 Chrome에서 마운트를 반복할 때 이를 확인하는 방법을 보여 줍니다.
뷰를 다시 열 때 이벤트 핸들러가 누적되는 이유는 무엇인가요?
뷰는 처음 열었을 때는 올바르게 작동하다가 다시 열면 두 번씩 반응할 수 있습니다. 흔한 원인은 마운트할 때마다 새 콜백을 만들면서 이전 등록을 제거하지 않는 설정 코드입니다. 소스 텍스트가 같더라도 각각 따로 생성된 함수는 서로 다른 리스너 객체입니다. MDN의 addEventListener() 문서에서 이 콜백 식별 규칙을 설명합니다.
DOM은 같은 대상에 이벤트 유형, 콜백, 캡처 값이 동일한 등록이 중복되는 것을 방지합니다. 하지만 설정할 때마다 새 콜백을 만드는 경우에는 마운트를 반복해도 이 보호 기능이 문제를 해결하지 못합니다. 또한 표준에 따르면 리스너에 전달된 신호를 중단하면 해당 리스너가 제거됩니다. 이 규칙을 바탕으로 실용적인 설계를 할 수 있습니다. 마운트된 한 기간 동안의 모든 네이티브 이벤트 리스너를 컨트롤러 하나가 관리하게 하는 것입니다. DOM 표준: EventTarget.
이 튜토리얼에서 독자는 프레임워크 없이 일반 JavaScript 카운터 뷰를 열고 닫았다가 다시 열어도 버튼이나 창 핸들러가 추가되지 않도록 하는 것이 목표입니다. 예제에서는 임의의 레이블을 사용하고, 프레임워크가 필요하지 않으며, 마운트 사이에도 카운터 값을 유지합니다.
뷰가 따라야 할 수명 주기 계약은 무엇인가요?
설정을 구현하기 전에 수명 주기 메서드를 반복 호출하면 어떻게 동작할지 정하세요. 다음 계약을 따르면 실수로 반복 호출해도 문제가 없고, 정리 작업을 누가 담당하는지 명확해집니다.
| 작업 | 필요한 동작 |
|---|---|
최초 mount() |
뷰를 연결하고 리스너를 한 세트 등록합니다. |
마운트된 상태에서 mount() 재호출 |
아무것도 변경하지 않고 반환합니다. |
unmount() |
마운트된 기간의 리스너를 제거한 다음 뷰를 분리합니다. |
unmount() 재호출 |
아무것도 변경하지 않고 반환합니다. |
언마운트 후 mount() |
새 컨트롤러를 사용해 새 리스너 세트를 등록하고 카운터는 유지합니다. |
destroy() |
영구적으로 언마운트합니다. 이후 마운트하면 오류가 발생합니다. |
이는 애플리케이션 설계 선택입니다. 브라우저는 이벤트를 등록하고 제거하는 기능을 제공하며, 뷰가 수명 주기 메서드와 호출 순서를 정합니다. 더 큰 애플리케이션에서는 mount()을 호출하는 컴포넌트나 라우터가 이에 대응하는 unmount()도 호출하도록 해야 합니다.
새 컨트롤러를 사용해야 하는 이유는 중단된 신호를 다시 활성화할 수 없기 때문입니다. addEventListener()에 이미 중단된 신호를 전달하면 DOM 등록 알고리즘은 리스너를 추가하지 않고 반환합니다. 따라서 이전 컨트롤러를 재사용하면 뷰를 다시 열어도 컨트롤이 작동하지 않을 수 있습니다. DOM 표준: 이벤트 리스너 추가.
Mac의 Chrome에서 완전한 예제 만들기
새 Chrome 탭을 열고 주소 표시줄에 about:blank를 입력한 다음 Return 키를 누릅니다. 페이지에 포커스가 있는 상태에서 Command + Option + J를 눌러 Console을 엽니다. Google은 Chrome DevTools 키보드 단축키 참조에서 Mac용 단축키를 설명합니다.
아래 전체 프로그램을 Console에서 실행합니다. JavaScript로 인터페이스를 만들기 때문에 문서 마크업이나 패키지 설치, 빌드 단계가 필요하지 않습니다. Google의 Console에서 JavaScript 실행 튜토리얼에 나와 있듯이 Chrome 콘솔에서 JavaScript를 실행하고 현재 페이지의 DOM을 변경할 수 있습니다.
(() => {
// Clean up the previous demo if this entire program is run again.
window.lifecycleDemo?.destroy();
const host = document.body;
const root = document.createElement("section");
const button = document.createElement("button");
const output = document.createElement("p");
button.type = "button";
button.textContent = "Add one";
root.append(button, output);
let controller = null;
let destroyed = false;
let clicks = 0;
let resizes = 0;
function render() {
output.textContent =
`Clicks: ${clicks}; resize events: ${resizes}`;
}
function mount() {
if (destroyed) {
throw new Error("This view has been destroyed.");
}
if (controller !== null) return;
const next = new AbortController();
const options = { signal: next.signal };
try {
button.addEventListener("click", () => {
clicks += 1;
render();
}, options);
window.addEventListener("resize", () => {
resizes += 1;
render();
}, options);
render();
host.append(root);
controller = next;
} catch (error) {
// Roll back any registrations made before setup failed.
next.abort();
root.remove();
throw error;
}
}
function unmount() {
if (controller === null) return;
controller.abort();
controller = null;
root.remove();
}
function destroy() {
unmount();
destroyed = true;
}
window.lifecycleDemo = {
mount,
unmount,
destroy,
button,
get counts() {
return { clicks, resizes };
}
};
mount();
})();
이제 페이지에 Add one 버튼과 카운터 줄이 표시됩니다. 버튼을 클릭하면 클릭 횟수가 1 증가해야 합니다. Chrome 창 크기를 조정하면 크기 조정 이벤트 횟수가 바뀌어야 합니다. 실제 크기 조정 한 번으로 여러 이벤트가 발생할 수 있으므로 이 값은 마우스 동작 횟수가 아니라 이벤트 횟수입니다.
버튼 리스너와 창 리스너는 같은 신호를 공유합니다. 언마운트는 루트를 분리하기 전에 해당 컨트롤러를 중단합니다. 다음 마운트에서는 또 다른 컨트롤러와 콜백 세트가 생성됩니다. 카운터는 뷰의 클로저에 남아 있으므로 다시 열어도 값이 유지됩니다. 프로그램 전체를 종료하고 다시 실행하면 새 인스턴스가 시작됩니다.
설정 블록은 부분 실패도 처리합니다. 리스너를 등록한 뒤 설정 중 오류가 발생하면 catch 블록이 새 컨트롤러를 중단하고 루트를 분리한 다음 오류를 다시 던집니다. 따라서 마운트가 실패했을 때 설정의 일부가 조용히 활성 상태로 남는 일을 방지합니다.
마운트를 반복할 때 정리를 어떻게 확인하나요?
먼저 수명 주기를 직접 확인합니다. 메인 프로그램을 설치한 다음 아래 순서를 실행합니다. 뷰가 사라지고, 언마운트를 반복해도 추가 변화가 없으며, 다시 열면 이전 카운터 값과 함께 복원됩니다.
window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();
그런 다음 같은 콘솔에서 아래 전체 검사를 실행합니다. 이 검사는 위의 메인 프로그램에 의존합니다. 각 주기에서 설정과 정리를 일부러 두 번 호출하고, 마운트된 동안 각 대상에 이벤트를 하나씩 디스패치한 다음 언마운트된 동안 같은 이벤트를 디스패치합니다. 예상값과 다르면 즉시 오류가 발생합니다.
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
function check(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
for (let cycle = 0; cycle < 20; cycle += 1) {
view.mount();
view.mount();
const before = view.counts;
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, before.clicks + 1, "Mounted click");
check(view.counts.resizes, before.resizes + 1, "Mounted resize");
view.unmount();
view.unmount();
const stopped = view.counts;
// The button reference remains available while detached.
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, stopped.clicks, "Unmounted click");
check(view.counts.resizes, stopped.resizes, "Unmounted resize");
}
view.mount();
console.log("Passed 20 mount/unmount cycles.");
})();
합성 크기 조정 이벤트를 사용하면 창을 움직이지 않고도 예상되는 변화를 일정하게 만들 수 있습니다. 이 예제에서는 다시 마운트할 수 있도록 버튼을 의도적으로 보존하므로 분리된 버튼 검사도 유용합니다. 두 검사 모두 통과해야 합니다. 화면에 보이는 버튼만 테스트하면 창 리스너를 검사하지 못합니다. 테스트는 각 카운터를 20씩 증가시키고 뷰를 마운트된 상태로 마칩니다.
직접 확인하려면 Chrome은 DevTools 콘솔에 getEventListeners(object)를 제공합니다. 이 함수는 지정한 객체에 등록된 리스너를 이벤트 유형별로 그룹화해 반환합니다. DevTools 전용 유틸리티이므로 애플리케이션 코드에는 넣지 마세요. Chrome Console Utilities API 참조.
메인 프로그램을 실행한 뒤 아래 검사 순서로 정리 전후의 등록 상태를 비교합니다. 빈 데모 페이지에서는 마운트된 동안 버튼 클릭 리스너 하나와 창 크기 조정 리스너 하나가 표시되고, 언마운트된 동안에는 각각 0개가 표시되어야 합니다.
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
view.mount();
console.log("Mounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.unmount();
console.log("Unmounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.mount();
})();
기존 애플리케이션 페이지에서는 다른 코드가 창 리스너를 관리할 수 있습니다. 전체 개수가 0이 되어야 한다고 가정하지 말고 전후 차이를 비교하고 콜백을 살펴보세요.
명시적인 제거는 언제 사용해야 하나요?
콜백을 이미 저장하는 기존 코드와 통합할 때나 필요한 환경에서 리스너의 signal 옵션을 지원하지 않을 때 removeEventListener()을 사용하세요. 정리할 때 원래 대상, 이벤트 유형, 콜백 참조, 캡처 값을 그대로 유지해야 합니다. MDN 문서에 따르면 캡처 값은 일치해야 하지만 passive 같은 옵션은 제거 시 일치 여부를 결정하지 않습니다. MDN의 removeEventListener() 참조 문서.
예를 들어 설정에 bind()를 사용하는 경우 바인딩된 콜백을 한 번만 저장하세요. 정리할 때 bind()을 다시 호출하면 다른 함수 참조가 만들어집니다. 마찬가지로 제거 호출에서 같은 화살표 함수를 다시 작성해도 이전에 등록한 함수를 되찾을 수 없습니다. 명시적인 정리 코드를 설정 코드 옆에 두어 두 부분을 함께 유지 관리하세요.
컨트롤러 패턴은 그 신호에 등록된 네이티브 리스너를 처리합니다. 뷰에서 타이머, 옵저버, 라이브러리 구독도 생성한다면 해당 리소스에 문서화된 정리 작업을 같은 언마운트 경계에 배치하세요. 네이티브 리스너에 신호를 전달해도 관련 없는 리소스의 정리까지 처리되지는 않습니다.
콜백이 계속 중복되면 무엇을 확인해야 하나요?
여러 수명 주기 메서드를 한꺼번에 변경하지 말고 증상에 따라 조사 범위를 좁히세요.
| 증상 | 다음 확인 항목 |
|---|---|
| 한 번 클릭했는데 두 번 증가함 | 두 번째 등록 경로나 실행 중인 뷰 인스턴스가 하나 더 있는지 확인합니다. |
| 다시 열면 컨트롤이 응답하지 않음 | 설정에서 중단된 신호를 재사용했는지 확인합니다. |
| 언마운트 후에도 값이 바뀜 | 모든 이벤트 대상과 각 등록에 의도한 신호가 전달되었는지 확인합니다. |
| 명시적으로 제거해도 변화가 없음 | 대상, 이벤트 유형, 콜백 식별자, 캡처 값이 일치하는지 비교합니다. |
| 첫 동작은 되지만 이후 동작은 안 됨 | 리스너에 once: true가 사용되었는지 확인합니다. |
once 옵션은 첫 호출 후 리스너를 제거합니다. 실제로 한 번만 사용하는 상호작용에는 적합할 수 있지만 뷰의 마운트 기간을 나타내지는 않습니다. 언마운트 전에 이벤트가 발생하지 않을 수도 있기 때문입니다. MDN 문서의 addEventListener() 옵션 참조에서 once과 신호를 사용한 제거를 모두 설명합니다.
데모를 마치면 window.lifecycleDemo.destroy()를 실행해 뷰를 분리하고 리스너를 제거하세요. 직접 만든 뷰에도 같은 실용적인 계약을 적용하세요. 설정은 한 번만 활성화하고, 정리는 반복 호출할 수 있게 하며, 다시 열 때 새 등록을 만들고, 화면에 보이는 컨트롤과 수명이 긴 이벤트 대상을 모두 테스트하세요.
출처
- DOM 표준 — WHATWG.
- EventTarget: addEventListener() 메서드 — MDN Web Docs.
- EventTarget: removeEventListener() 메서드 — MDN Web Docs.
- 키보드 단축키 — Chrome for Developers, Google.
- Console에서 JavaScript 실행 — Chrome for Developers, Google.
- Console Utilities API 참조 — Chrome for Developers, Google.