[JS] Cannot read properties of null (reading 'shapeFlag') - setTimeout과 디바운스

2026. 3. 25. 11:20 · 개발자로 살아남기/트러블슈팅

문제 상황

네비바에서 "주요현황" 탭 → 다른 탭으로 이동할 때, 화면이 넘어가지 않고 아래 오류가 콘솔에 쏟아졌다.

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
'개발자로 살아남기/트러블슈팅' 카테고리의 다른 글
  • [AIoT] 게이트웨이가 뭐길래? Level vs Edge Triggered 알람 이슈
  • [Git] 브랜치 간 변동 사항을 옮길 때 주의할 점
  • [CSS] ag-grid autoHeight vs normal 모드에서 가로 스크롤 제어하기
  • [JS] Promise.reject()와 setTimeout
ming0o
ming0o
Jr. 프론트엔드 개발자의 성장(개발)일지입니다. 이전 : https://velog.io/@ming0o
  • ming0o
    주토피아
    ming0o
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Goals
        • 2026 목표 및 월간 회고
      • 개발자로 살아남기
        • 트러블슈팅
        • 코드 리뷰
        • 기술 정리
      • Dev
        • Vue
        • Node & Express
        • React Native
      • Challenge
        • KDT-핀테크 인턴십
        • 한이음 드림업
      • Learning
        • JavaScript
        • Algorithm
        • CS
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    이기고만다
    try-catch문
    지피티
    Push
    수동배포
    replace
    타입에러
    Vue
    트러블슈팅
    Router
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
ming0o
[JS] Cannot read properties of null (reading 'shapeFlag') - setTimeout과 디바운스
상단으로

티스토리툴바