리스너와 타이머 수명
130분 안팎
학습 목표
반복 이동 시 남는 자원을 추적합니다.
개념
생성한 곳에서 정리 책임을 갖습니다
목록과 상세를 여러 번 오가다가 검색 한 번에 콜백이 여러 번 실행된다면 데이터 함수보다 등록된 자원을 의심합니다. 화면이 사라졌다고 이벤트 리스너와 예약 타이머가 자동으로 모두 사라지는 것은 아닙니다. 이번 레슨은 생성·사용·정리의 수명을 한 묶음으로 만들고 반복 초기화 뒤 남는 행동이 없는지 검증합니다. 단순히 메모리 사용 숫자가 올랐다는 이유로 누수라고 단정하지 않습니다.
기존 앱은 문서당 한 번 초기화하고 render 안에는 리스너를 등록하지 않습니다. SearchForm이 submit을 담당하며 request-session은 재시도와 요청 수명을 다룹니다. results.mjs는 input·change·click·popstate·pagehide를 연결합니다. 각 자원의 소유자가 누구인지 표에 쓰면 어느 dispose가 빠졌는지 찾기 쉽습니다. scope는 이 중 상위 화면의 이벤트 등록을 모으는 도구입니다.
createScope는 on·timeout·dispose 세 동작을 제공합니다. on은 대상과 이벤트 타입과 함수 참조를 받아 등록하고 같은 조합으로 제거하는 정리 함수를 저장합니다. timeout은 예약 id를 저장하고 clearTimeout으로 정리합니다. dispose는 종료 상태로 바꾼 뒤 저장된 정리 함수를 실행하고 집합을 비웁니다. 복잡한 프레임워크 없이 책임의 경계를 코드로 확인하는 연습입니다.
removeEventListener에는 등록했던 함수 객체를 넘깁니다. 같은 내용을 가진 새 화살표 함수를 만들더라도 다른 객체여서 기존 등록을 제거하지 못합니다. 실제 앱에서 capture 옵션을 사용하는 경우 제거 시에도 해당 capture 값이 맞아야 합니다. 이 과제의 on은 기본 옵션만 지원합니다. 옵션을 추가하려면 등록·제거의 계약과 capture 반례까지 함께 확장해야 합니다.
타이머 정리는 화면 종료 뒤 불필요한 콜백이 실행되는 일을 막습니다. 예약한 함수가 자연스럽게 실행되면 정리 집합에서 그 타이머의 정리 항목을 지웁니다. 실행이 끝난 자원을 계속 목록에 쌓아 두면 화면이 오래 살아 있을 때 관리 데이터가 커집니다. 종료된 scope에는 새 작업을 등록하지 않으며 “종료된 scope” 오류로 잘못된 사용을 빠르게 드러냅니다.
dispose는 여러 번 불러도 한 번 정리한 자원을 다시 처리하지 않도록 만듭니다. pagehide 경로와 명시적 종료 경로가 겹칠 수 있기 때문입니다. closed가 참이면 바로 반환하는 방식은 이미 끝난 소유권을 표시합니다. 정리 호출 수를 무작정 줄이는 것이 아니라 첫 종료에서 실제 제거가 일어나는지를 검사합니다. starter는 이 제거를 빠뜨려 반복 검사에서 실패하도록 되어 있습니다.
이 앱에 필요 없는 주기 타이머를 추가해 성능을 측정하지 않습니다. timeout 계약은 가짜 타이머로 독립 검증하고 실제 검색 디바운스는 기존 request-control의 책임을 유지합니다. results.mjs의 상위 on만 scope.on으로 연결하고 dispose에서 scope를 정리합니다. SearchForm과 session은 각자의 dispose를 계속 호출합니다. 정리 책임을 중복 배치하거나 숨기지 않는 것이 목표입니다.
테스트는 EventTarget과 가짜 예약 Map을 사용합니다. 열 번 scope를 만들고 각 회 이벤트를 한 번 발생시킨 뒤 정리합니다. 정리 뒤 같은 이벤트를 다시 보내도 호출 수가 증가하지 않아야 하며 예약 Map의 크기는 0이어야 합니다. 실제로 100ms씩 기다리는 대신 등록·해제 상태를 직접 관찰합니다. 검사에서 만드는 타이머는 시스템 타이머가 아니어서 백그라운드 프로세스를 남기지 않습니다.
한 번만 초기화하면 누락된 제거가 드러나지 않을 수 있습니다. 반복 테스트는 재진입마다 새 함수 참조가 생기는 상황을 재현합니다. 이벤트 타입이 같아도 서로 다른 함수는 각각 등록됩니다. 정리 후 호출 수가 20처럼 예상보다 커지면 실행된 콜백이 남았는지 확인합니다. 단순히 카운터를 초기화하면 누락을 숨기므로 누적 행동을 유지한 채 소유권을 검사합니다.
콜백을 정리하는 것과 이미 시작한 비동기 작업을 취소하는 것은 다릅니다. clearTimeout은 아직 실행하지 않은 예약을 막고 AbortController는 요청 취소에 사용합니다. 이미 응답한 Promise의 후속 처리가 화면에 쓰는 것을 막으려면 종료 상태나 요청 세대 검사도 필요합니다. 기존 session의 dispose와 최신 요청 검사를 제거하지 않는 이유입니다. 리스너 수만 0이어도 늦은 화면 쓰기가 남을 수 있습니다.
order 테스트에는 예약 검색 취소·늦은 성공 폐기·pagehide 이후 리스너 제거가 있습니다. 새 scope 테스트는 그 검사를 대신하지 않습니다. 먼저 lifetime-test로 정리 도구를 좁게 확인하고 npm test로 앱 연결과 기존 비동기 계약을 확인합니다. 환경 오류로 테스트가 시작되지 않으면 자원 누수 재현이라고 적지 않습니다. 실패 메시지의 검사 이름과 실제 값으로 도달한 경계를 판별합니다.
브라우저에서는 목록·상세·복귀를 같은 횟수만큼 반복하고 Event Listener와 Memory 도구로 등록·참조를 조사합니다. 의심 노드의 유지 경로에서 어떤 객체가 참조하는지 확인합니다. 전체 힙이 증가했다는 화면 하나는 캐시·일시 객체·개발자 도구 참조 때문에 해석이 어려울 수 있습니다. 같은 준비와 반복과 정리 후 조건으로 비교하고 사용자 행동이 중복되는지도 함께 관찰합니다.
콘솔에 DOM 객체를 출력하거나 전역 변수에 담으면 조사 도구 자체가 참조를 유지할 수 있습니다. 관찰을 위해 만든 변수와 로그를 정리하고 동일한 절차를 다시 수행합니다. 스냅샷 비교는 즉시 모든 객체가 수거된다는 보장이 아닙니다. 이번 자동 검사는 이벤트 행동과 예약의 정리를 보장하는 범위이며 실제 브라우저의 메모리 누수가 전혀 없다는 주장으로 확대하지 않습니다.
화면 재사용 정책도 별도로 살펴봅니다. 앱의 pagehide가 dispose를 호출한다는 사실과 브라우저의 뒤로 가기 캐시에서 화면이 복원될 때 정상 재초기화되는지는 다른 계약입니다. 여기서는 기존 정책을 유지하며 실제 재접속·뒤로 복원은 수동 확인 항목으로 둡니다. 수명 개선을 이유로 탐색 경로의 새로운 동작을 몰래 추가하지 않고 남은 위험으로 리뷰어에게 전달합니다.
정리 함수를 실행하다 예외가 나면 뒤의 정리가 건너뛰어질 수 있습니다. 이 과제는 removeEventListener와 clearTimeout 같은 정리 동작이 예외 없이 끝나는 좁은 계약을 사용합니다. 외부 플러그인의 임의 정리 함수를 지원한다면 각각의 예외 처리와 오류 보고를 설계해야 합니다. 현재 구현을 모든 자원 관리자에 그대로 적용할 수 있다고 설명하지 않습니다.
완료 뒤에는 어떤 자원을 누가 만들고 어디서 해제하는지 말할 수 있어야 합니다. lifetime.mjs의 TODO를 채우고 반복·이중 정리·종료 후 등록·자연 완료 검사를 통과시킵니다. 이어서 기존 session과 폼과 상위 이벤트의 연결을 확인합니다. 더 읽기의 메모리 장에서는 도달 가능성과 수거 원리를 넓혀 읽고 이 실습에서는 관찰한 행동과 관찰하지 않은 메모리 범위를 구분합니다.
API와 명령 세부 확인: 이벤트 제거 API를 참고합니다.
따라하기
같은 함수 참조로 제거합니다
독립 예제를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력의 의미를 본문 계약과 비교합니다.
const target=new EventTarget();let n=0;const fn=()=>n++;target.addEventListener('search',fn);target.dispatchEvent(new Event('search'));target.removeEventListener('search',fn);target.dispatchEvent(new Event('search'));console.log(n);실행 결과
1
새 함수 참조의 실패를 재현합니다
독립 예제를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력의 의미를 본문 계약과 비교합니다.
const target=new EventTarget();let n=0;target.addEventListener('search',()=>n++);target.removeEventListener('search',()=>n++);target.dispatchEvent(new Event('search'));console.log(n);실행 결과
1
종료 시 정리를 구현합니다
lifetime.mjs의 dispose에서 저장된 모든 정리 함수를 호출합니다. results.mjs의 연결과 session·폼의 기존 정리를 유지합니다.
npm test반복 이동과 잔여 참조를 관찰합니다
외부 브라우저에서 열 번 목록·상세·복귀를 반복하고 콜백 중복과 등록·참조 유지 경로를 조사합니다. 모델 통과와 메모리 관찰을 분리해 기록합니다.
확인 문제
실습
lifetime.mjs의 dispose TODO를 구현합니다. 10회 생성·이중 정리 뒤 이벤트와 예약이 남지 않아야 합니다. 종료 후 등록 거절·자연 완료와 앱의 늦은 응답 폐기 회귀를 유지합니다.
실행 명령
npm test
기대 결과
starter는 해당 주제 assertion 실패, solution은 기존 회귀와 새 모델 검사 전부 통과
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 컴포넌트의 상태 위치를 정한 사례를 설명합니다.