- Published on
동적 import 에러를 새로고침으로 처리할 때 주의해야할 점
이전 글에서 SPA 환경에서 동적 import 에러를 재현하는 방법을 알아보았다. 그리고 새로고침으로 처리하여 해결하는 방법을 알아보았다. 이번 글에서는 새로고침으로 처리하는 방법에 대해 주의해야할 점을 정리해본다.
새로고침하면 메모리가 초기화된다.
vite 환경에서 원하는 chunk를 불러오지 못하면 preloadError 이벤트가 발생한다. 이 이벤트를 감지하여 새로고침을 유도할 수 있었다.
window.addEventListener('vite:preloadError', () => {
window.location.reload()
})
하지만 이 방법이 항상 올바른 애플리케이션 동작을 보장하지는 않는다.
새로고침이 되면 브라우저는 새로운 index.html을 다시 불러온다. 그리고 새로운 인덱스 JS 파일을 다시 해석하고 실행하게 된다.
이 과정에서 애플리케이션에 저장된 데이터와 상태가 초기화되는 문제가 발생할 수 있다.
특히 퍼널(Funnel) 형태의 웹 애플리케이션처럼 데이터가 여러 페이지에 걸쳐 저장되는 경우, 데이터 초기화로 앞에서 유저가 입력했던 데이터가 사라지는 문제가 발생할 수 있다.
예를 들면 유저가 회원가입 퍼널을 입력하는 시나리오를 생각해보자.
이름 -> 생년월일 -> 직업 -> 주소 -> 최종 확인 페이지로 구성되어 있다. 유저는 각각 페이지에서 데이터를 입력하고 다음 페이지로 이동한다.

만약 각각의 페이지 컴포넌트를 동적 import 한다면 다음과 같이 코드를 작성할 수 있다. 페이지 각각을 Code Splitting 했기 때문에 각 페이지 Route를 이동할 때 Chunk를 불러오게 된다.
// 이름 입력 페이지
const Name = lazy(() => import('@/pages/signup/name'))
// 생년월일 입력 페이지
const Birth = lazy(() => import('@/pages/signup/birth'))
// 직업 입력 페이지
const Job = lazy(() => import('@/pages/signup/job'))
// 주소 입력 페이지
const Address = lazy(() => import('@/pages/signup/address'))
// 최종 확인 페이지
const Confirm = lazy(() => import('@/pages/signup/confirm'))
그리고 유저가 입력하게도되는 데이터들을 전역 스토어 라이브러리 zustand를 사용해서 관리해보자. 아래와 같이 전역 스토어를 생성하고 각 페이지 컴포넌트에서 핸들링한다.
// store.ts
export const useFunnelStore = create((set) => ({
name: '',
job: '',
address: '',
birthDate: '',
setName: (name: string) => set({ name }),
setJob: (job: string) => set({ job }),
setAddress: (address: string) => set({ address }),
setBirthDate: (birthDate: string) => set({ birthDate }),
}))
// Name.tsx
export default function Name() {
const setName = useFunnelStore((state) => state.setName)
return <></>
}
유저가 퍼널을 진행하는 중에 새로운 배포가 이루어지면
유저가 이름과 생년월일을 입력하고 직업 입력 페이지로 이동하게 되면 아래와 같은 상태를 가지게 된다.
{
name: '홍길동',
birthDate: '1990-01-01',
job: '',
address: '',
}
그런데 이때 새로운 배포가 이루어지면서 번들 hash가 변경되었다면 어떻게 될까?
유저가 직업 -> 주소 페이지로 이동하게 되는 순간 새로고침이 발생하게 되고 새로운 애플리케이션이 시작되면서 기존에 유저가 입력했던 메모리의 데이터가 초기화되게 된다. 유저는 정상적으로 애플리케이션을 사용했지만 이후 동작들이 예러가 발생하게 된다.
{
name: '',
birthDate: '',
job: '',
address: '',
}
위에서 새로운 배포라는 의미가 반드시 이동하려고 하는 페이지가 변경되어야 하는 것은 아니다. vite가 사용하는 rollup은 chunk hash를 생성할 때 파일 내용뿐만 아니라 파일과 파일간의 종속성도 고려한다. 따라서 전혀다른 페이지 컴포넌트의 내용이 변경되어도 직업 입력 페이지의 hash값이 변경될 수도 있다. 따라서 hash값의 변경은 꽤 빈번하게 발생할 수 있다.
메모리 초기화 문제를 해결하는 방법
크게 두가지 정도의 방법이 있을 것 같다. 하나는 퍼널을 구성할 때 동적 import가 일어나지 않게 하는 것이고 또 하나는 새로고침이 일어나더라도 메모리 데이터를 보존시키는 방법이다.
- 퍼널을 구성할 때 동적 import가 일어나지 않게 하는 방법
- 새로고침이 일어나더라도 메모리 데이터를 보존시키는 방법
퍼널을 구성할 때 동적 import가 일어나지 않게 하는 방법은 하나의 chunk로 번들링하는 것이다. 웹팩에서는 Magic Comment를 사용해서 하나의 chunk로 번들링할 수 있다.
const Name = lazy(() => import(/* webpackChunkName: "signup" */ '@/pages/signup/name'))
const Birth = lazy(() => import(/* webpackChunkName: "signup" */ '@/pages/signup/birth'))
const Job = lazy(() => import(/* webpackChunkName: "signup" */ '@/pages/signup/job'))
const Address = lazy(() => import(/* webpackChunkName: "signup" */ '@/pages/signup/address'))
const Confirm = lazy(() => import(/* webpackChunkName: "signup" */ '@/pages/signup/confirm'))
vite에서는 해당 기능은 지원하지 않는 것으로 보인다. 따라서 build option인 manualChunks를 사용해서 하나의 chunk로 번들링할 수 있다.
// 파일명 접두사를 모두 Signup으로 변경
// Name.tsx -> SignupName.tsx
// Birth.tsx -> SignupBirth.tsx
// Job.tsx -> SignupJob.tsx
// Address.tsx -> SignupAddress.tsx
// Confirm.tsx -> SignupConfirm.tsx
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('Signup')) {
return 'signup'
}
},
},
},
},
})
이렇게 하면 퍼널을 구성하는 페이지들만 하나의 chunk로 번들링되어 새로운 배포가 일어나도 퍼널을 진행할 때 새로고침이 일어나지 않게 된다. 왜냐하면 유저의 행동이 하나의 번들 chunk 내에서만 동작할 수 있기 때문이다.
또다른 방법은 하나의 chunk 파일에서 자바스크립트로 화면만 변경하여 여러 페이지처럼 보이게 하는 방법이다. 이런 식으로 step 상태를 두어 페이지를 구성하면 하나의 chunk 파일에서 여러 페이지를 구성하게 된다. 비슷한 라이브러리로 use-funnel이 있다.
import Name from '@/pages/signup/name'
import Birth from '@/pages/signup/birth'
import Job from '@/pages/signup/job'
export default function Signup() {
const [step, setStep] = useState(0)
return (
<>
{step === 0 && <Name />}
{step === 1 && <Birth />}
{step === 2 && <Job />}
</>
)
}
// App.tsx
const Signup = lazy(() => import('@/pages/signup'))
두번째 방법은 새로고침이 일어나더라도 메모리 데이터를 보존시키는 방법이다. 이 방법은 상태를 메모리 데이터에 의존하지 않고 브라우저의 Storage를 사용하거나 URL을 통해 데이터를 저장하는 방법이다. 이렇게 되면 애플리케이션이 새로고침되더라도 기존의 데이터를 유지할 수 있게 된다.
zustand에서는 간단하게 sessionStorage를 사용해서 데이터를 저장할 수 있다.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export const useBearStore = create()(
persist((set, get) => ({ name: '', setName: (name: string) => set({ name }) }), {
name: 'signup',
storage: createJSONStorage(() => sessionStorage),
})
)
마무리
이번 글에서는 SPA에서 발생하는 동적 import 에러를 새로고침으로 처리할 때 주의해야할 점을 정리해보았다.
- 동적 import 에러를 새로고침으로 처리하면 애플리케이션 데이터가 초기화되기 떄문에 주의해야한다.
- 불필요한 Chunk를 생성하지 않고 하나의 Chunk로 번들링할 수 있다.
- 새로고침이 일어나더라도 메모리 데이터를 보존시키는 방법이 있다.