문제 상황
네비바에서 "주요현황" 탭 → 다른 탭으로 이동할 때, 화면이 넘어가지 않고 아래 오류가 콘솔에 쏟아졌다.
TypeError: Cannot read properties of null (reading 'shapeFlag')
at getNextHostNode (runtime-core.esm-bundler.js:6173)
...
at <AiDashboard>
at <AsyncComponentWrapper>
처음엔 라우터 문제인가 싶었는데, 특정 탭에서만 발생하고 빠르게 탭을 연속으로 클릭할수록 더 잘 터진다는 걸 발견했다.
왜 발생했을까?
코드 구조를 보면 탭마다 v-if로 컴포넌트를 마운트/언마운트하고 있었다.
<dashboardTab v-if="activeTab === 'dashboard'" />
<mainTab v-if="activeTab === 'main'" />
<fmsTab v-if="activeTab === 'fms'" />
v-if는 조건이 false가 되는 순간 컴포넌트를 완전히 DOM에서 제거(언마운트) 한다.
그리고 탭 전환 함수는 이렇게 생겼다.
const changeTab = (tabId) => {
activeTab.value = tabId; // 탭 즉시 전환 → 이전 컴포넌트 언마운트 시작
isLoading.value = true;
setTimeout(() => {
isLoading.value = false; // 1500ms 후 실행 예약
}, 1500);
};
원인 분석
setTimeout은 예약만 하고 넘어간다
setTimeout은 콜백을 바로 실행하지 않는다. 이벤트 루프에 예약만 해두고 현재 코드는 계속 진행된다.
console.log('1');
setTimeout(() => { console.log('3'); }, 1500);
console.log('2');
// 출력: 1 → 2 → (1500ms 후) 3
즉 탭을 전환하면 activeTab이 바뀌어 이전 컴포넌트는 언마운트되지만, 1500ms 후 실행될 타이머는 이미 예약이 된 상태다.
타이머가 쌓이면 터진다
빠르게 탭을 연속으로 클릭하면:
1. "대시보드" 클릭 → Timer#1 생성 (1500ms 후 실행 예약)
2. 500ms 후 "주요현황" 클릭 → Timer#2 생성
↓
1500ms 후 Timer#1 완료 → 이미 언마운트된 컴포넌트에 접근
2000ms 후 Timer#2 완료 → 또 접근
타이머가 중복으로 쌓이면서, 이미 사라진 컴포넌트에 상태 업데이트가 날아가는 것. Vue 내부에서 null이 된 vnode에 접근하려다 터진 오류였다.
버그가 발생한 흐름
1. "주요현황" 탭 진입 → mainTab 컴포넌트 마운트
2. 빠르게 다른 탭 클릭
3. activeTab 변경 → mainTab 언마운트 시작
4. 동시에 Timer#1이 아직 살아있음
5. 1500ms 후 Timer#1 완료
→ isLoading.value = false 실행 시도
→ 이미 없는 컴포넌트에 접근
6. null vnode에 접근 → shapeFlag 오류 발생
해결
디바운스 패턴을 적용해서 타이머가 중복으로 쌓이지 않도록 했다. 핵심은 새 타이머를 등록하기 전에 이전 타이머를 반드시 취소하는 것.
let loadingTimer = null; // 현재 살아있는 타이머 추적
const changeTab = (tabId) => {
if (loadingTimer) clearTimeout(loadingTimer); // 이전 타이머 취소
activeTab.value = tabId;
isLoading.value = true;
loadingTimer = setTimeout(() => {
isLoading.value = false;
loadingTimer = null;
}, 1500);
};
onBeforeUnmount(() => {
if (loadingTimer) clearTimeout(loadingTimer); // 언마운트 시에도 정리
});
1. "대시보드" 클릭 → loadingTimer = Timer#1
2. 500ms 후 "주요현황" 클릭
→ clearTimeout(Timer#1) ← 기존 타이머 취소!
→ loadingTimer = Timer#2
3. 2000ms 후 Timer#2만 완료 → 정상 동작
항상 마지막 타이머 하나만 살아있게 만드는 것이 핵심.
디바운스(Debounce)란?
이 패턴의 이름이 디바운스다.
"연속으로 호출이 와도, 마지막 호출 하나만 실행한다"
가장 친숙한 예시는 검색창
디바운스 없음
"안" 입력 → API 호출
"안녕" 입력 → API 호출
"안녕하세요" → API 호출 (총 N번 호출)
디바운스 있음 (300ms)
"안" ~ "안녕하세요" 입력 중엔 계속 타이머 리셋
마지막 입력 후 300ms 조용하면 → API 1번만 호출
lodash의 _.debounce()도 내부적으로 완전히 동일한 원리다.
디바운스 vs 쓰로틀(Throttle)
디바운스와 함께 자주 나오는 개념. 헷갈리면 이 표로 기억.
| 디바운스 | |
| 동작 | 마지막 호출만 실행 |
| 비유 | 엘리베이터 (마지막 사람 탈 때까지 대기) |
| 주요 사용처 | 검색창 입력, 탭 전환 |
같이 발견한 것 — 이벤트 리스너 누수
오류 잡다 보니 이것도 보였다.
// 익명 함수는 removeEventListener로 제거가 안 됨
onMounted(() => {
window.addEventListener('online', () => { isOffline.value = false; });
});
onBeforeUnmount(() => {
window.removeEventListener('online', () => { isOffline.value = false; }); // 제거 안 됨!
});
removeEventListener는 등록할 때와 동일한 함수 참조를 넘겨야 제거된다. 익명 함수는 호출할 때마다 새 함수가 만들어지기 때문에 참조가 달라서 제거가 안 된다.
// 함수를 변수로 분리
const handleOnline = () => { isOffline.value = false; };
const handleOffline = () => { isOffline.value = true; };
onMounted(() => {
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
});
onBeforeUnmount(() => {
window.removeEventListener('online', handleOnline); // 제대로 제거됨
window.removeEventListener('offline', handleOffline);
});
정리
| 원인 | 내용 |
| v-if 구조 | 탭 전환 시 컴포넌트를 완전히 언마운트함 |
| setTimeout 중복 | 빠른 전환 시 타이머가 쌓여 언마운트된 컴포넌트에 접근 |
| 해결 | loadingTimer 변수로 타이머를 추적, 새 호출 시 기존 것 취소 = 디바운스 |
| 보너스 | 이벤트 리스너는 변수로 분리한 함수로 등록/제거할 것 |
디바운스는 타이머를 하나만 살려두는 패턴이다. 연속 호출 상황에서 중복 실행을 막고 싶을 때 기억해두면 좋다.
'개발자로 살아남기 > 트러블슈팅' 카테고리의 다른 글
| [AIoT] 게이트웨이가 뭐길래? Level vs Edge Triggered 알람 이슈 (1) | 2026.04.21 |
|---|---|
| [Git] 브랜치 간 변동 사항을 옮길 때 주의할 점 (0) | 2026.04.06 |
| [CSS] ag-grid autoHeight vs normal 모드에서 가로 스크롤 제어하기 (0) | 2026.04.06 |
| [JS] Promise.reject()와 setTimeout (0) | 2026.03.25 |