🐞

[디버깅 사례 공유] 잘못된 IndexedDB 사용으로 인한 무한 Suspense 문제 해결

태그
디버깅프론트엔드경험공유
최종 편집
Feb 11, 2024 12:10 AM
발행일
December 5, 2022
🆕
블로그를 stdy.blog로 이전했습니다. 새 블로그에 어떤 글들이 올라올지 궁금하시면 Upcoming Posts를 참고해주세요. 🙂

MediaCAT 프론트엔드에 몇 주 전부터 (로컬스토리지 대신) IndexedDB를 많이 사용하기 시작했다. 신기술 도입에는 언제나 장애가 따라온다. 도입 이후 맞닥뜨려 해결하기 쉽지 않았던 문제를 디버깅한 경험을 정리해봤다.

3줄 요약

  1. 현장 보존은 디버깅에 굉장히 중요하다. 재현되는 환경을 보유했다면 건드리지 말자.
  2. IndexedDB를 사용할 때, 세션을 종료해야만 커넥션이 닫히는 형태로 구현했다면 반드시 blockedversionchange 이벤트 핸들러에서 커넥션을 닫아줘야 한다.
  3. (React 18 이상 기준) API를 호출하는 것 외에도 suspended 상태를 유발할 수 있는 코드를 작성하고 있다면 무한 Suspense에 빠지지 않게 조심해야 한다. 우리는 2번의 처리에 더해, 이유를 막론하고 동일한 Suspense fallback이 10초 이상 렌더링되고 있다면 에러를 던지도록 구현을 변경했다.

배경

며칠 전, 팀 동료가 우리 제품 MediaCAT의 특정 페이지(Create New Task)에 진입시 더이상 진행되지 않고 이렇게 멈춰있는 이슈를 제보했다.

image

이전에 다른 동료가 제보했던 이슈와 동일했다. 그때와 마찬가지로 로컬 개발 환경에서는 재현되지 않고, 제보자가 페이지를 새로고침하거나 로그아웃/로그인 이후에도 현상이 해결되지 않았다. 크롬의 시크릿 모드나 게스트 모드에서는 문제가 없는 점 또한 지난번과 같았다.

지난번에는 이것저것 시도해보다가 우연히 해결됐기에 원인을 파악해볼 기회가 없었는데, 그때를 교훈삼아 제보자분께 아무것도 건드리지 말라고 부탁드린 후 탐색을 시작했다. 해결 과정이 꽤 유의미했기에 전체 과정을 되짚어보며 기록을 남긴다.

현상 이해: 로딩 스피너의 의미

MediaCAT은 NextJS로 개발했고, 여기저기서 React 18의 Suspense 를 적극적으로 쓰고 있다. 구조는 대략 이러하다.

Layout 렌더링 구조
// _app.page.tsx
export default function App({ Component, ...pageProps }: AppProps & {
  Layout?: React.FC<{ children: React.ReactNode }>;
}) {
  const Layout = Component.Layout ?? SidebarLayout;
  return (
    <ErrorBoundary {...}>
	    <Suspense fallback={null}>
	      <Layout>
	        <Component {...pageProps} />
	      </Layout>
	    </Suspense>
    </ErrorBoundary />
  );
}

// src/pages/tasks/sync/new/index.page.tsx
NewSyncPage.Layout = ({ children }: { children: React.ReactNode }) => {
  return (
    <Suspense fallback={<CreateNewTaskForm.Loading />}>
      {children}
    </Suspense>
  );
};

Create New Task 페이지에서 보이는 현상, 즉 상단 네비게이션과 함께 돌고 있는 로딩 스피너는 <CreateNewTaskForm.Loading /> 으로 렌더링한 컴포넌트였다. 이는 NewSyncPage 의 메인 컴포넌트를 (Layout의 children을) 렌더링하는 도중에 suspend되었다는 뜻이었다.

React 18 이전에는, Suspense fallback은 dynamic import된 컴포넌트가 아직 로딩되지 않았을 때만 발동했지만 이제는 suspense를 유발하는 수단이 여럿이다(e.g., Suspense in Data Frameworks). MediaCAT은 API 호출 및 글로벌 캐시 레이어로서 react-query를 사용했고, default로 suspense 옵션을 켜두었다. 다행히(?) startTransition 등 다른 Suspense 유발 기능은 사용하지 않고 있었으므로, 이 현상은 NextJS에서 Chunk를 불러오는 과정, 또는 react-query 를 사용하는 과정에서 발생한 것일 터였다.

의심: ChunkLoadError?

먼저 Chunk를 제대로 불러오고 있는지 확인해봤다. document에서 오래된 js 파일을 불러오고 있을 가능성이 있다고 생각했다.

  • 그런데 글을 작성하는 시점에 찬찬히 보니, 같은 파일 안에 있는 NewSyncPage.Layout은 잘 불러왔으므로 파일의 dynamic import 과정에서 생긴 문제는 아닐 것이 뻔했는데 이때는 시야가 좁아져서 떠올리지 못했다.
  • 지난번 제보 때 Chunk 문제일 거라고 지레짐작하며 이것저것 찾아봤었기에, NextJS에서 ChunkLoadError가 발생하더라도 ErrorBoundary에 잡히지 않는다는 건 알고 있었다. (이 때 Webpack과 NextJS 소스 코드 레벨로 상당히 깊이 봤었는데 그 경험은 따로 글로 정리해볼 예정이다)

우선 동료의 컴퓨터 화면을 Zoom으로 함께 보면서 원격 제어 권한을 얻었다. 예상과 달리, 404 에러가 하나도 없었고 Chunk 문제일 거라는 의심이 약해졌다. 개발자 도구에서 Disable cache 를 켜고 새로고침하면 확인이 되었겠지만, 그랬다가 자칫 지난번처럼 문제가 해결되어버리면 큰일이니 그건 하지 않았다.

노가다할 각오를 하고, 동료의 컴퓨터에서 html 파일과 Chunk js 파일들을 모두 다운받았다. 문제가 없는 내 컴퓨터에서도 마찬가지로 html 파일과 Chunk js 파일을 다운받아서 각각을 git diff로 비교했다. 결과는, 두 컴퓨터에서 받은 파일들의 내용이 완벽히 똑같다는 것이었다. minify 되어있으니 다른 점이 있어도 표시되기 어려우리라 생각해서 미리 모든 파일을 prettier 적용 후 비교했는데 괜한 짓을 했다.

문제 확인: Pending IndexedDB

혼란에 빠진 나는 다시 Zoom을 켜고 동료의 컴퓨터에서 탐색을 시작했다. 아무리 살펴봐도 네트워크 요청도 똑같고, js를 비롯한 파일들도 같았다. 다른 점이라고는 성능 최적화를 위해 추가해둔 코드 때문에 localStorage에 저장된 id들을 이용하여 prefetch하는 API 요청 뿐이었다.

노이즈를 제거하기 위해 localStorage를 비우고 새로고침. 결과는 똑같았다. 그런데 IndexedDB를 삭제하려고 가보니 기이한 현상이 보였다.

문제 상태의 IndexedDB
문제 상태의 IndexedDB
정상 상태의 IndexedDB
정상 상태의 IndexedDB

원래는 우측 스크린샷처럼 IndexedDB의 내용이 보이면서 삭제와 새로고침이 가능해야 했는데, 좌측처럼 ObjectStore의 이름만 보이고 내용이 보이지 않았다. 아직 근본 원인은 모르겠지만 이것 때문이리라는 감이 확 왔다. “IndexedDB pending” 같은 키워드로 검색해보고 몇가지 힌트를 얻어 두 컴퓨터의 콘솔에서 테스트를 해봤다.

콘솔 출력 결과
// 문제 컴퓨터
> db = window.indexedDB.open('MediaCAT', 6);

IDBOpenDBRequest {
  error: [예외: DOMException: Failed to read the 'error' property from 'IDBRequest': The request has not finished. at IDBOpenDBRequest.invokeGetter (<anonymous>:3:28)]
	onblocked: null
	onerror: null
	onsuccess: null
	onupgradeneeded: null
	readyState: "pending"
	result: (...)
	source: null
	transaction: null
	[[Prototype]]: IDBOpenDBRequest
}

// 정상 컴퓨터
> db = window.indexedDB.open('MediaCAT', 6);

IDBOpenDBRequest {
  error: null
	onblocked: null
	onerror: null
	onsuccess: null
	onupgradeneeded: null
	readyState: "done"
	result: (...)
	source: null
	transaction: null
	[[Prototype]]: IDBOpenDBRequest
}

정상 컴퓨터에서는 최초에 pending 상태였다가 금방 done으로 변하는데 문제 컴퓨터에서는 error가 나면서 pending 상태가 계속 유지되었다. 즉 open 이 성공하지 못했으니 개발자 도구에서도 내용이 보이지 않았던 것이었다.

Suspense도 결국 이것 때문이었다. 우리는 몇 주 전부터 (localStorage 대신) IndexedDB로 유저의 간단한 설정값을 저장하기 시작했다. Create New Task 페이지에서는 “실행이 완료되면 이메일로 알림 보내기”라는 옵션을 유저가 조정할 수 있었고, 조정한 값을 IndexedDB에 저장했다. 그런데 IndexedDB에서 값을 불러오는 건 비동기적이며, 모종의 이유로 IndexedDB에 저장하거나 불러오는 것이 실패할 수도 있으므로 베스트 프랙티스를 따라 In-memory cache 레이어를 두기로 결정했다.

react-query 는 꼭 API 호출뿐 아니라 Promise만 반환한다면 무엇이든 불러올 수 있으므로, 우리는 react-query를 캐시 레이어로 사용했다.

IndexedDB(with idb) + react-query
async function getPersistentStorage(
  key: string,
) {
  return (await dbPromise).get('persistentStorage', key);
}

async function setPersistentStorage(
  key: string,
  value: boolean,
) {
  return (await dbPromise).put('persistentStorage', value, key);
}

export function usePersistentStorage(
  key: string,
  initialValue: boolean,
) {
  const queryClient = useQueryClient();

  const setData = React.useCallback(
    (newData: PersistentStorageValues<KEY>) => {
      // set in-memory cache and sync to IDB
      queryClient.setQueryData(queryKey, newData);
      void setPersistentStorage(key, newData);
    },
    [key, queryClient],
  );

  const { data } = useQuery(
		['persistentStorage', key], 
    () => getPersistentStorage(key), 
    {
      staleTime: Infinity,
      cacheTime: Infinity,
	    onSuccess: (response) => {
	      if (response === undefined) {
	        // if the preference is not set in IDB, set it to the initial value (+ in cache)
	        setData(initialValue);
	      }
	    },
	    onError: () => {
	      // when idb get fails, we want to set the value to the initial value as in-memory cache
	      queryClient.setQueryData(queryKey, initialValue);
	    },
    });

  return [data ?? initialValue, setData] as const;
}

Create New Task 페이지에서 usePersistentStorage 를 사용해 IndexedDB에서 값을 불러오려는데, DB를 열다가 pending이 되어 getPersistentStorage(key) 가 응답을 하지 않았다. useQuery 의 기본 설정이 suspense: true였고, promise가 응답을 하지 않았으니 앱도 suspended 상태에서 멈춘 것이다.

1차 원인 해결

문제를 정확히 확인하니 원인을 찾는 건 쉬웠다. 언제 이런 pending 상태가 될 수 있는가?

우리가 사용하는 IndexedDB의 wrapper인 idb에서는 DB을 열 때 upgrade 라는 콜백을 지정할 수 있다. 이는 순수 IndexedDB의 onupgradeneeded 이벤트와 유사하다.

upgrade 콜백
const dbPromise = await openDB(name, version, {
  upgrade(db) { // ... },
});

이 콜백은 현재 브라우저에 저장된 DB 버전보다 높은 버전으로 커넥션을 맺고자 할 때 실행되고, 콜백이 완료되어야 DB가 열린다. 문제는 이 콜백이 이전 버전의 DB에 대한 커넥션이 맺혀 있을 때는 그 커넥션이 닫히기 전까지는 영원히 기다린다는 것이었다.

우리는 MediaCAT 앱이 최초 실행될 때 IndexedDB의 커넥션을 열어두고, 이는 탭이 닫히지 않는 한 유지되도록 구현했다. 따라서 유저가 다른 탭(A)에 이전 버전 IndexedDB로 배포된 MediaCAT을 열어둔 채로 새 탭(B)에서 새 IndexedDB 버전의 MediaCAT을 열면 B는 IndexedDB를 열지 못한다. 이 때 B에서 Create New Task 페이지에 가면, A가 커넥션을 닫아주지 않는 한 무한한 suspended 상태에 빠진다.

이걸 이해함으로써 로컬 개발 환경에서도 쉽게 재현이 되었다. 다행히 해결책은 간단했다. idb가 제공하는 blockedblocking 콜백을 이용하는 것이다.

  • blocked: 이전 버전의 DB가 열려있어서 내가 새 버전을 열 수 없을 때 실행된다. 순수 IndexedDB의 blocked 이벤트와 유사하다.
  • blocking: 내가 새 버전의 DB를 여는 것을 막고 있을 때 실행된다. 순수 IndexedDB의 versionchange 이벤트와 유사하다.

원래는 openDB할 때 upgrade 콜백만 썼었지만 이 두 콜백도 추가했다. 둘 다 처리는 똑같다. 현재 커넥션을 닫고, 커넥션을 닫았으면 이후 IndexedDB를 사용하는 플로우에서 우리의 의도대로 동작하지 않을 것이므로 페이지를 새로고침한다.

blocking, blocked 콜백 추가
const dbPromise = await openDB(name, version, {
  upgrade(db) { // ... },
	blocking(_currentVersion, _blockedVersion, event) {
	  try {
	    event.target.close();
	    window.location.reload();
	  } catch {} // ignore
	},
	blocked(_currentVersion, _blockedVersion, event) {
	  try {
	    event.target.close();
	    window.location.reload();
	  } catch {} // ignore
	},
});

아직 남은 일들

이렇게 해서 일단 배포를 했지만, 이걸로 모두 끝난 건 아니다. 몇 가지 고민할 게 더 남았다.

1. 기존에 문제를 겪은 유저들에게 대처하기

우리가 코드를 수정해 새로 배포하더라도, 유저가 오래된(이전 IndexedDB 버전을 사용하는) MediaCAT 탭을 닫지 않는 한 suspended 상태는 해결되지 않는다. 커넥션이 닫혀야 해결되는데, 커넥션을 닫는 코드가 그 탭에는 배포가 안 됐을 것이기 때문이다. 결국, 문제를 겪은 동료는 다른 MediaCAT이 열려 있던 탭을 직접 닫고 나서야 정상 이용을 할 수 있었다.

이건 프로그래머블하게 대처할 방법이 없어 보여서, 문의가 들어오면 “브라우저를 껐다가 켜거나, 다른 탭에 열려있는 MediaCAT을 종료해달라”고 응대하기로 결정했다.

2. 비슷한 문제를 더 일찍 인지하기

앞으로 IndexedDB에 비슷한 일이 생기면 어떻게 더 일찍(유저의 제보 전에) 인지할 수 있을까? 우선은 IndexedDB에 onError 이벤트 핸들러를 걸어뒀지만, 이건 이미 열려있는 커넥션에서 문제가 생길 때 리포트하는 용도였다. 아예 안 열리고 멈춰 있을 때는 동작하지 않는다.

그래서 IndexedDB의 저수준 API를 이용하여 추가로 테스트 커넥션을 맺어보고, 5초 뒤까지도 커넥션이 안 맺어지면 리포트하게 했다. 커넥션이 안 맺어진 상태에서 error객체에 접근하면 The request has not finished. 에러가 발생하는 것에 착안했다. 아마 이번에 추가한 blocked, blocking 콜백으로 이미 해결되었으리라 생각하지만 안전장치를 추가한 것이다.

초기화 5초 후 커넥션 테스트
const testDbRequest = indexedDB.open('MediaCAT', DB_VERSION);
setTimeout(() => {
  try {
    if (testDbRequest.error) {
      logError('Opening IndexedDB', testDbRequest.error);
    }
  } catch (e) {
    logError('Opening IndexedDB', e);
  } finally {
    testDbRequest.result?.close();
  }
}, 5000);

3. 무한 Suspended 상태를 방지하기

이유를 막론하고 앱이 무한한 suspended 상태에 빠지는 건 유저에게 무척 좋지 않은 경험이다. 네트워크에 문제가 생겼든, 이번처럼 promise 반환 중 예상치 못한 pending 상태에 빠졌든 간에 이를 인지하고 다음 액션을 할 수 있는 장치가 필요하다고 생각했다.

고민하다가, <React.Suspense /> 를 wrapping해서 Suspense fallback이 렌더링되는 코드가 10초 이상 지속되면 일부러 에러를 던져서 ErrorBoundary에 잡히게 만들기로 했다. (useEffect 안에서 발생한 에러는 기본적으로는 ErrorBoundary에 잡히지 않아서 꼼수를 써야 하는데, react-error-boundaryuseErrorHandler 를 이용하면 편해진다)

10초동안 Promise 응답이 없어서 suspend 되어있으면 유저는 이 페이지를 보게 되고, 우리도 Sentry를 통해 알게 될 것이다. 사실상 페이지를 크래시시키는 거니 유저에게 일시적으로는 안좋은 경험일 수 있겠지만, 그렇더라도 현재 이상한 상황이라는 걸 서로 빠르게 인지하는 게 더 낫다고 판단했다.

image
Suspense fallback에 타임아웃 추가
export default function Suspense({ fallback = null, ...rest }: Props) {
  return (
    <React.Suspense
      fallback={<FallbackWrapper>{fallback}</FallbackWrapper>}
      {...rest}
    />
  );
}

const SUSPENSE_TIMEOUT = 10_000; // 10 seconds

function FallbackWrapper({ children }: { children: React.ReactNode }) {
  // When children is suspended longer than SUSPENSE_TIMEOUT seconds, throw an error and report it to Sentry. Since ErrorBoundary does not catch error thrown in useEffect, use react-error-boundary's useErrorHandler instead.
  // See https://github.com/bvaughn/react-error-boundary#useerrorhandlererror-unknown
  const handleError = useErrorHandler();

  React.useEffect(() => {
    const timeout = setTimeout(() => {
      handleError(new Error('Suspense timeout'));
    }, SUSPENSE_TIMEOUT);

    return () => clearTimeout(timeout);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // eslint-disable-next-line react/jsx-no-useless-fragment
  return <>{children}</>;
}

4. IndexedDB에 문제가 있어도 잘 동작하게 하기

마지막 고민. IndexedDB는 본질적으로 캐시 레이어고, 캐시에 문제가 있다면 퍼포먼스가 떨어질지언정 이번처럼 앱의 기본 동작에 문제가 생겨서는 안 된다. 일단 커넥션이 맺어지기만 한다면 나머지는 react-query로 감싸두었기 때문에 In-memory 캐시가 그 역할을 할 것이고, 동일 세션 안에서는(즉 유저가 탭을 새로고침하거나 하지 않는 이상) 의도대로 동작할 것이다. 그렇다면 문제는 커넥션이 안 맺어져서 이번처럼 무한히 pending되어있는 상태에는 어떻게 할 것인가, 이다.

IndexedDB에 접근하는 react-query hook들에서는 supense: false 옵션을 줘서 suspend가 안 되게 하는 것도 가능하고, open이 안 되는 상황임을 감지해 IndexedDB의 값을 가져오려고 할 때 무조건 undefined 를 리턴하게 하는 것도 가능하다. 그러나 전자는 앞으로 우리가 개발하면서 suspense가 아님을 고려하여 개발해야 하니 번거롭고 후자는 코드 구조가 더 복잡해진다. 일단 2와 3을 통해 인지가 더 잘 되게 해뒀고 무한 suspend도 되지 않을테니 이 문제는 잠시 미루기로 결정했다.

맺으며

이번 사례를 정리하면서 ChunkLoadError, IndexedDB, Suspense에 대해 더 깊이 이해하게 되어 재밌었다. IndexedDB에서 커넥션 close 문제는 어딘가의 문서에 나와있을법 한데 공식문서나 web.dev 글, idb 패키지 리드미 어디에도 나와있지 않은 문제였어서 처음에는 해결이 쉽지 않았다. 역시 디버깅하는 데 재현되는 현장을 보존하는 게 가장 중요하다는 걸 느꼈다.

이를 계기로 Suspense가 오랫동안 지속되는 문제를 캐치하는 방법도 고민해보고, 방어로직을 추가할 수 있게 된 것도 유의미했다. 페이지에 따라, 유저 네트워크 환경에 따라 10초 넘게 API 응답이 안 오는 경우도 분명 있을거라 우려가 좀 되긴 하지만, 사실 10초동안 응답 안 왔으면 부끄러워하며 최적화를 추가해야 할 일이니(5초로 줄이고 싶었는데 차마 그러지 못했다) 기꺼이 받아들이련다.