본문 바로가기

교육

"destroy is not a function" 오류의 정체와 해결 전략

반응형

1. 증상 재현: 오류 메시지 분석

📌 실제 오류 메시지

TypeError: destroy is not a function
    at safelyCallDestroy (react-dom.development.js:14601:12)
    at commitHookEffectListUnmount (react-dom.development.js:14667:9)
    at commitPassiveEffectDurations (react-dom.development.js:14810:11)

 

재현 시나리오

  1. React 17에서 잘 동작하던 코드
  2. npm install react@18 react-dom@18 실행
  3. 개발 서버 재시작 → 첫 렌더링 시 오류 발생
  4. 오류 위치: useEffect의 클린업 함수 또는 외부 라이브러리 통합 지점

2. 근본 원인: React 18의 StrictMode 변화

📌 React 17 vs React 18 StrictMode

 
React 17
React 18
개발 모드 동작
컴포넌트 1회 마운트
의도적 이중 마운트
목적
기본 검사
의도치 않은 사이드 이펙트 탐지
useEffect 동작
mount → unmount
mount → unmount → mount

핵심: React 18은 의도적으로 unmount → remount를 실행해 클린업 로직의 안정성을 검증합니다.

3. 주요 원인 3가지

❌ 원인 1: 클린업 함수 누락

// ❌ React 17에서는 동작했지만, React 18에서 오류
useEffect(() => {
  const timer = setInterval(() => {
    console.log('타이머 실행');
  }, 1000);
  // ⚠️ clearInterval 누락!
}, []);

❌ 원인 2: 클린업 함수가 함수가 아님

// ❌ 반환값이 함수가 아닌 경우
useEffect(() => {
  const subscription = api.subscribe();
  return subscription.unsubscribe; // ⚠️ bind 누락!
}, []);

❌ 원인 3: 의존성 배열 오류

// ❌ 의존성 배열 누락 → stale closure
useEffect(() => {
  const handleResize = () => {
    console.log(size); // stale size
  };
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []); // ⚠️ size 누락!

 

4. 해결 전략: 3단계 디버깅

🔍 단계 1: 오류 위치 식별

  1. 콘솔 오류에서 파일:라인 번호 확인
  2. 해당 useEffect 또는 외부 라이브러리 통합 지점 찾기
  3. 의존성 배열클린업 반환값 점검

🔍 단계 2: 클린업 함수 검증

useEffect(() => {
  // 사이드 이펙트 실행
  const cleanup = () => {
    // 클린업 로직
  };
  
  console.log('클린업 함수 타입:', typeof cleanup); // ✅ "function" 확인
  return cleanup;
}, []);

단계 3: StrictMode 일시 비활성화 (디버깅용)

// src/main.tsx
createRoot(document.getElementById('root')!).render(
  // <React.StrictMode> ← 주석 처리
    <App />
  // </React.StrictMode>
);

 

→ 오류 사라지면 100% StrictMode 관련 문제

5. 해결 방법 4가지 (실전 예제)

해결법 1: 클린업 함수 반드시 반환

// ✅ 정상 코드
useEffect(() => {
  const timer = setInterval(() => {
    console.log('타이머 실행');
  }, 1000);

  // ✅ 반드시 함수 반환
  return () => {
    clearInterval(timer);
  };
}, []);

 

해결법 2: bind()로 this 고정

// ✅ 외부 라이브러리 통합
useEffect(() => {
  const chart = new Chart(canvasRef.current, config);
  
  // ✅ bind()로 this 고정
  return chart.destroy.bind(chart);
  
  // 또는 화살표 함수로 래핑
  // return () => chart.destroy();
}, []);

 

해결법 3: 의존성 배열 완전 명시

// ✅ 의존성 누락 방지
useEffect(() => {
  const handleResize = () => {
    console.log('현재 크기:', width, height);
  };
  
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [width, height]); // ✅ 모든 의존성 명시

 

해결법 4: 옵셔널 체이닝 + 타입 가드

// ✅ 외부 라이브러리 안전 처리
useEffect(() => {
  let instance: Chart | null = null;
  
  if (canvasRef.current) {
    instance = new Chart(canvasRef.current, config);
  }

  return () => {
    // ✅ 안전한 클린업
    if (instance?.destroy) {
      instance.destroy();
    }
  };
}, [config]);

 

6. 실전 사례 3가지

사례 1: MobX Reaction 클린업

// ❌ 오류 발생 코드
useEffect(() => {
  return reaction(
    () => store.user,
    (user) => console.log('사용자 변경:', user)
  );
}, [store]);

// ✅ 해결 코드
useEffect(() => {
  const dispose = reaction(
    () => store.user,
    (user) => console.log('사용자 변경:', user)
  );
  
  return () => dispose(); // ✅ 함수 실행
}, [store]);

 

사례 2: WebSocket 연결 관리

// ❌ 오류 발생 코드
useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  return ws.close; // ⚠️ bind 누락!
}, []);

// ✅ 해결 코드
useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  
  return () => {
    ws.readyState === WebSocket.OPEN && ws.close();
  };
}, []);

 

사례 3: 서드파티 라이브러리 (PDF.js)

// ❌ 오류 발생 코드
useEffect(() => {
  const pdfDoc = await pdfjs.getDocument(url).promise;
  return pdfDoc.cleanup; // ⚠️ 함수가 아닐 수 있음
}, [url]);

// ✅ 해결 코드
useEffect(() => {
  let pdfDoc: PDFDocumentProxy | null = null;
  
  const loadPdf = async () => {
    pdfDoc = await pdfjs.getDocument(url).promise;
  };
  
  loadPdf();
  
  return () => {
    if (pdfDoc?.cleanup) {
      pdfDoc.cleanup();
    }
  };
}, [url]);

7. 실무 체크리스트

항목
확인 방법
✅ 모든useEffect에클린업 함수 반환
return () => { ... }존재 여부
✅ 클린업 함수가실제 함수인지
console.log(typeof cleanup)
의존성 배열 완전 명시
ESLintreact-hooks/exhaustive-deps활성화
✅ 외부 라이브러리destroy 메서드 존재 여부
if (instance?.destroy)체크
StrictMode에서 테스트
개발 서버에서 반드시 검증

 

자동 검출 스크립트

useEffect 클린업 검사 ESLint 규칙

npm install -D eslint-plugin-react-hooks

 

.eslintrc.json:

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

런타임 검사 훅

// src/hooks/useSafeEffect.ts
import { useEffect } from 'react';

export const useSafeEffect = (
  effect: () => void | (() => void),
  deps: React.DependencyList
) => {
  useEffect(() => {
    const cleanup = effect();
    
    if (cleanup && typeof cleanup !== 'function') {
      console.error('❌ 클린업 함수가 함수가 아닙니다:', cleanup);
      return;
    }
    
    return cleanup as (() => void) | undefined;
  }, deps);
};

"React 18의 StrictMode는 버그가 아니라,
당신의 코드를 더 건강하게 만들어주는 백신입니다."

  • 이 오류는 코드의 잠재적 문제를 사전에 발견하게 해줌
  • 해결 과정에서 의존성 관리, 클린업 로직에 대한 이해도 ↑
  • 결과적으로 운영 환경에서의 장애 감소로 이어짐


원인

useEffect hook 내부에는 동기 함수만 있어야 합니다. async 함수를 넣어서 promise 객체를 리턴하면 useEffect 내에서 unmount 될때 destory is not function라는 에러를 내게 돕니다. 그래서 아래와 같은 코드가 있을때 에러를 냅니다.

useEffect(async () => {
  const res = await getApi();
}, []);

해결

useEffect 내부에 promise 객체를 제거하기 위해 async를 제거하고 비동기 함수를 따로 빼내면 정상 작동합니다.

useEffect(() => {
  getApis();
}, []);

const getApis = async () => {
  const res = await getApi();
};
반응형