React state는 페이지 밖을 모른다

성태경·3일 전


이번 큐시즘 밋업 프로젝트 끼움을 진행하면서 경험 추가 플로우를 만들면서 PDF 파일과 Notion 페이지를 함께 분석해야 하는 상황이 있었다.

사용자는 자료를 추가할 수 있고, 자료 종류는 크게 두 가지였다.

  • PDF 파일
  • Notion 페이지

처음에는 단순했다. PDF를 업로드하면 React state에 File 객체를 저장하고, Notion 페이지를 선택하면 선택한 페이지 정보를 state에 저장했다. 이후 다음 단계로 넘어갈 때 PDF와 Notion 데이터를 분석 API로 보내면 됐다.

그런데 특정 흐름에서 문제가 생겼다.


문제 상황

문제가 발생한 흐름은 다음과 같았다.

1. PDF 파일 업로드
2. 자료 목록에 PDF 표시
3. 다시 자료 추가하기
4. Notion 연동 진행
5. Notion OAuth 페이지로 이동
6. /experience/add 페이지로 복귀
7. 기존에 업로드했던 PDF가 사라짐

사용자 입장에서는 분명 PDF를 올려두고 Notion만 추가로 연결했을 뿐인데, 돌아와 보니 PDF가 없어지는 상황이었다.

처음에는 단순한 state 업데이트 문제라고 생각했다. 어딘가에서 materials 배열을 덮어쓰고 있거나, Notion 페이지를 추가하면서 PDF 자료를 필터링하는 로직이 잘못된 줄 알았다.

하지만 원인은 React 코드의 단순한 상태 변경 버그가 아니었다.


원인은 OAuth 플로우의 페이지 생명주기였다

PDF 파일은 브라우저의 File 객체다. 그리고 이 File 객체는 React state 안에 저장되어 있었다.

interface PdfMaterial {
  id: string;
  type: 'pdf';
  file: File;
  name: string;
  size: number;
}

일반적인 페이지 내부 이동이나 모달 전환에서는 문제가 없다. 컴포넌트가 살아 있는 동안에는 React state도 유지되기 때문이다.

하지만 Notion OAuth는 다르다.

Notion 연동을 시작하면 현재 페이지 안에서 상태만 바뀌는 것이 아니라, 외부 Notion 인증 페이지로 이동한다. 인증을 마치면 다시 /experience/add로 돌아온다.

이 과정에서 기존 페이지는 한 번 사라졌다가 다시 만들어진다. 즉, React component state가 초기화된다.

React state에 File 저장
→ Notion OAuth로 외부 이동
→ 기존 페이지 생명주기 종료
→ /experience/add 재진입
→ React state 초기화
→ File 객체 유실

결국 문제의 핵심은 이것이었다.

React state는 현재 페이지 생명주기 안에서만 유지된다.
OAuth처럼 페이지를 벗어나는 흐름에서는 state가 유지된다고 기대하면 안 된다.


localStorage로 해결할 수는 없을까?

가장 먼저 떠올릴 수 있는 방법은 localStoragesessionStorage다.

하지만 여기에는 문제가 있다. PDF는 문자열이 아니라 File 객체다. localStoragesessionStorage는 기본적으로 문자열 저장소이기 때문에 File 객체를 그대로 저장할 수 없다.

물론 base64로 변환해서 저장하는 방법도 있다. 하지만 이 방식은 마음에 들지 않았다.

  • 파일 크기가 커질수록 저장 용량 부담이 커진다
  • base64 변환 비용이 발생한다
  • 브라우저 storage quota에 쉽게 걸릴 수 있다
  • 다시 File 형태로 복구하는 과정도 번거롭다

이 문제는 단순한 폼 입력값 보존이 아니라, 실제 파일 데이터를 임시로 보존해야 하는 문제였다.


고려한 해결책들

몇 가지 방법을 비교해봤다.

방법장점단점
Notion을 먼저 연결하게 강제구현이 가장 단순함PDF를 먼저 올릴 수 있는 기존 UX와 충돌
localStorage / sessionStorage사용이 쉬움File 객체 저장에 부적합
백엔드 임시 업로드안정적임시 파일 API, 만료 정책, 삭제 정책이 필요함
IndexedDBFile/Blob 저장 가능, 프론트에서 해결 가능코드가 조금 복잡해짐

이 케이스에서는 IndexedDB가 가장 적합했다.

백엔드에서 임시 업로드 API를 새로 만들 수도 있었지만, 문제는 "분석 전까지 잠깐 PDF를 보존하는 것"이었다. 서버에 저장할 정도로 긴 생명주기가 필요한 데이터는 아니었다. 반대로 localStorageFile 저장소로 쓰기엔 맞지 않았다.

IndexedDB는 브라우저에서 구조화된 데이터를 저장할 수 있고, Blob이나 File 객체도 저장할 수 있다. OAuth를 다녀온 뒤에도 같은 브라우저 안에서는 데이터를 다시 꺼낼 수 있다.


IndexedDB에 PDF 임시 저장하기

PDF 자료를 저장하기 위한 유틸을 따로 만들었다.

const DB_NAME = 'kkium-experience-add';
const STORE_NAME = 'pdf-draft';
const PDF_DRAFT_KEY = 'selected-pdf';

저장할 데이터는 PDF 분석에 다시 필요한 최소 정보만 담았다.

interface StoredPdfDraft {
  id: string;
  file: File;
  name: string;
  size: number;
}

자료 추가 모달에서 저장 버튼을 누르면 React state를 업데이트하는 동시에 IndexedDB에도 저장했다.

export async function saveExperienceAddPdfDraft(pdfMaterial: PdfMaterial) {
  const draft: StoredPdfDraft = {
    id: pdfMaterial.id,
    file: pdfMaterial.file,
    name: pdfMaterial.name,
    size: pdfMaterial.size,
  };

  await runStoreTransaction('readwrite', (store) => store.put(draft, PDF_DRAFT_KEY));
}

여기서 중요한 점은 file 자체를 저장한다는 것이다. base64 문자열로 바꾸지 않고, File 객체를 IndexedDB에 그대로 보관한다.


OAuth 복귀 후 PDF 복구하기

/experience/add 페이지에 다시 진입했을 때 IndexedDB에 저장된 PDF가 있는지 확인한다.

useEffect(() => {
  const restorePdfDraft = async () => {
    try {
      const pdfMaterial = await getExperienceAddPdfDraft();

      if (!pdfMaterial) return;

      setMaterials((currentMaterials) => {
        const hasPdf = currentMaterials.some((material) => material.type === 'pdf');

        if (hasPdf) return currentMaterials;

        return [pdfMaterial, ...currentMaterials];
      });
    } catch (error) {
      console.warn('PDF 임시 저장 데이터를 복구하지 못했습니다.', error);
    }
  };

  void restorePdfDraft();
}, []);

복구할 때는 이미 PDF가 state에 있는지도 확인했다. 불필요하게 중복 추가되는 상황을 막기 위해서다.

이렇게 하면 Notion OAuth를 다녀온 뒤에도 기존 PDF 자료가 다시 자료 목록에 나타난다.

임시 데이터는 언제 지워야 할까?

IndexedDB에 저장하는 것만큼 중요한 게 정리하는 시점이다. 임시 저장 데이터는 계속 남아 있으면 안 된다. 사용자가 예전에 올린 PDF가 다음 경험 추가 플로우에 섞이면 더 큰 문제가 된다.

그래서 다음 상황에서 IndexedDB 데이터를 삭제하도록 했다.

  • 사용자가 자료 목록에서 PDF를 직접 삭제한 경우
  • 자료 추가 모달에서 PDF를 제거한 채로 저장한 경우
  • PDF 분석이 완료된 경우
  • PDF + Notion 통합 분석이 완료된 경우

자료 목록에서 PDF를 제거할 때는 IndexedDB 데이터도 함께 지운다.

if (removedMaterial?.type === 'pdf') {
  void clearExperienceAddPdfDraft().catch((error: unknown) => {
    console.warn('PDF 임시 저장 데이터를 삭제하지 못했습니다.', error);
  });
}

모달에서 PDF 없이 저장하는 경우에도 마찬가지다.

if (pdfMaterial) {
  void saveExperienceAddPdfDraft(pdfMaterial).catch(...);
} else {
  void clearExperienceAddPdfDraft().catch(...);
}

분석 API 호출이 성공한 뒤에도 임시 데이터를 삭제한다.

const analyzeResponse = await analyzePdfMutation.mutateAsync(pdfMaterial.file);

applyAnalyzeResponse(analyzeResponse);

void clearExperienceAddPdfDraft().catch((error: unknown) => {
  console.warn('PDF 임시 저장 데이터를 삭제하지 못했습니다.', error);
});

IndexedDB는 영구 저장소가 아니라 "OAuth를 다녀오는 동안만 필요한 임시 저장소"로 사용했다.


마무리

이번 문제는 단순히 "PDF가 사라지는 버그"가 아니었다. 정확히는 페이지 생명주기를 잘못 가정한 문제였다.

React state는 강력하지만, 현재 페이지가 살아 있는 동안에만 의미가 있다. OAuth, 결제, 외부 인증처럼 페이지를 벗어나는 흐름에서는 state가 유지되지 않는다고 생각하고 설계해야 한다.

특히 File 객체는 일반적인 문자열 form state와 다르게 다뤄야 한다.

  • React state에만 저장하면 페이지 이탈 시 사라진다
  • localStorage에 그대로 저장할 수 없다
  • base64 변환은 비용과 용량 문제가 있다
  • 백엔드 임시 업로드는 안정적이지만 작업 범위가 커질 수 있다
  • IndexedDB는 브라우저 안에서 File/Blob을 임시 보존하기에 적합하다

이런 상황에서 핵심은 "어떤 상태가 페이지 생명주기 밖에서도 유지되어야 하는가?"를 먼저 구분하는 것이다. 이번 케이스에서 PDF 파일은 OAuth 이후에도 유지되어야 하는 데이터였고, IndexedDB에 임시로 보존했다가 복귀 후 다시 state로 복구하는 방식으로 문제를 해결했다.

0개의 댓글