들어가며

이마트 앱은 Vue SPA 프레임워크로 개발되어 있습니다. Vue 페이지 번들링 파일을 S3에 업로드하고, JSP에서 이를 호출해 페이지를 서빙하는 구조인데요. 네이티브 환경에서 멀티웹뷰로 운영되고 있었습니다.

문제는 페이지 이동마다 새 웹뷰를 생성하고, 새 웹뷰는 JSP를 호출하고, JSP는 S3로부터 Vue 번들 전체를 다시 로드해 단 하나의 페이지만 그린다는 점이었습니다.

[기존 멀티웹뷰 구조]

Native App
    ├── WebView 1 (홈)
    │   └── JSP → S3 (Vue 번들 전체 로드) → 홈 렌더링
    │
    ├── WebView 2 (상품상세)
    │   └── JSP → S3 (Vue 번들 전체 로드) → 상품상세 렌더링
    │
    └── WebView 3 (장바구니)
        └── JSP → S3 (Vue 번들 전체 로드) → 장바구니 렌더링

예전부터 싱글웹뷰 전환 시도가 있었지만, 세 가지 이유로 번번이 실패했습니다.

🔄

페이지 미유지

이전 페이지 상태가 유지되지 않음

📜

스크롤 초기화

이전 페이지의 스크롤 위치가 유지되지 않음

↔️

애니메이션 미동작

뒤로가기/앞으로가기 전환 애니메이션 없음

  1. 이전 페이지가 유지되지 않음
  2. 이전 페이지의 스크롤이 유지되지 않음
  3. 뒤로가기/앞으로가기 애니메이션 미동작 이번 글에서는 이 문제들을 어떻게 해결했는지 공유하려고 합니다.

1. 페이지 캐싱: keep-alive의 함정

  1. 전역 Store 의존성 발견
  2. Composable 패턴으로 인스턴스화
  3. keep-alive와 함께 캐싱 성공

첫 번째 시도와 실패

Vue의 keep-alive를 사용하면 컴포넌트를 캐싱할 수 있습니다. 간단히 적용하면 될 줄 알았습니다.

<template>
  <keep-alive>
    <router-view />
  </keep-alive>
</template>

하지만 기대와 달리 페이지 상태가 제대로 캐싱되지 않았습니다. 원인은 대부분의 비즈니스 로직이 Pinia 전역 상태에 묶여 있었기 때문입니다.

// 기존 구조: 전역 store에 모든 상태 존재
export const useProductStore = defineStore('product', {
  state: () => ({
    productData: null,      // 전역 상태
    relatedProducts: [],
    reviews: []
  }),
  actions: {
    async fetchProduct(skuCode) {
      this.productData = await productApi.getProduct(skuCode);
    }
  }
});

keep-alive는 컴포넌트의 script 내 변수와 함수만 캐싱합니다. 외부에서 참조하는 전역 상태는 캐싱 대상이 아니었습니다. 상품 A를 보다가 상품 B로 이동하면, 전역 store의 productData가 상품 B 데이터로 덮어씌워지고, 뒤로가기해도 상품 A 데이터는 이미 사라진 뒤였습니다.

해결: Composable 패턴으로 상태 인스턴스화

해결책은 전역으로 관리될 필요 없는 로직을 Composable로 옮기는 것이었습니다. 컴포넌트가 생성될 때마다 Composable의 인스턴스를 새로 생성하면, 각 컴포넌트가 독립적인 상태를 가지게 됩니다.

// composables/product.ts
export const useProductData = () => {
  // 인스턴스별 독립 상태
  const productData = ref<GeneralProductInfo | null>(null);
  const isPending = ref(false);

  const fetchProduct = async (skuCode: string) => {
    isPending.value = true;
    productData.value = await productApi.getProduct(skuCode);
    isPending.value = false;
  };

  return { productData, isPending, fetchProduct };
};

<!-- ProductView.vue -->
<script setup lang="ts">
// 컴포넌트마다 독립 인스턴스 생성
const { productData, isPending, fetchProduct } = useProductData();

onMounted(() => fetchProduct(route.query.sku_code));
</script>

이제 keep-alive로 컴포넌트가 캐싱되면, Composable 인스턴스도 함께 캐싱됩니다. 상품 A와 상품 B 페이지가 각자의 상태를 독립적으로 유지하게 되었습니다.

부가 효과: Headless UI 패턴

이 과정에서 자연스럽게 Headless UI 패턴이 적용되었습니다. 비즈니스 로직이 Composable에 응집되면서, 컴포넌트는 이벤트 전달과 UI 렌더링만 담당하게 되었습니다.

[레이어 분리]

Component (UI + 이벤트만)
    ↓
Composable (비즈니스 로직)
    ↓
API Layer (HTTP 통신)

비즈니스 로직이 한 곳에 모여 있으니, 컴포넌트를 빠르게 교체할 수 있고 다른 컴포넌트에서도 쉽게 재사용할 수 있게 되었습니다.


2. fullPath 기반 캐싱 키

문제: 같은 컴포넌트, 다른 페이지

일반상품상세 페이지를 예로 들면, /webapp/product/view?sku_code=1과 /webapp/product/view?sku_code=2는 같은 ProductView 컴포넌트를 사용하지만 서로 다른 페이지입니다.

기본 keep-alive는 컴포넌트 단위로 캐싱하기 때문에, 두 페이지를 구분하지 못했습니다.

<!-- 문제: ProductView 컴포넌트 1개만 캐싱됨 -->
<keep-alive>
  <router-view />
</keep-alive>

해결: VNode 동적 생성

fullPath를 기준으로 캐싱 단위를 설정하고, Vue의 h 함수로 가상 DOM 노드(VNode)를 동적 생성했습니다.

// 캐시 키 생성
const generateCacheKey = (route: RouteLocationNormalized) => {
  return route.fullPath;  // /webapp/product/view?sku_code=1
};

<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedPages">
      <component
        :is="wrapComponent(Component, route)"
        :key="route.fullPath"
      />
    </keep-alive>
  </router-view>
</template>

<script setup lang="ts">
const wrapComponent = (Component, route) => {
  return h(Component, { key: route.fullPath });
};
</script>

이제 sku_code=1과 sku_code=2가 별도의 캐시 항목으로 관리됩니다.


3. Props Drilling 해결: Context 패턴

문제: 깊어지는 Props 전달

전역 상태를 Composable로 옮기면서 새로운 문제가 생겼습니다. 최상위 컴포넌트에서 생성한 인스턴스를 자식 컴포넌트까지 props로 전달해야 했습니다.

<!-- Props Drilling 지옥 -->
<ProductView>
  <ProductHeader :product="product" />
  <ProductContent :product="product">
    <ProductInfo :product="product">
      <ProductPrice :product="product" />  <!-- 4단계... -->
    </ProductInfo>
  </ProductContent>
</ProductView>

해결: provide/inject 기반 Context

Vue의 provide/inject를 활용한 Context 패턴으로 해결했습니다.

// context/product.ts
import { provide, inject, InjectionKey } from 'vue';
import { useProductData } from '@/composables/product';

type ProductContextType = ReturnType<typeof useProductData>;
const ProductContextKey: InjectionKey<ProductContextType> = Symbol('ProductContext');

// Provider: 페이지 컴포넌트에서 호출
export const provideProductContext = () => {
  const result = useProductData();
  provide(ProductContextKey, result);
  return result;
};

// Consumer: 자식 컴포넌트에서 호출
export const useProductContext = () => {
  const context = inject(ProductContextKey);
  if (!context) {
    throw new Error('ProductContext 범위 밖에서 호출되었습니다.');
  }
  return context;
};

<!-- ProductView.vue (최상위) -->
<script setup lang="ts">
const { productData, fetchProduct } = provideProductContext();
</script>

<!-- ProductPrice.vue (깊은 자식) -->
<script setup lang="ts">
// props 없이 바로 접근
const { productData } = useProductContext();
</script>

페이지별로 Context를 끊어 사용하기 때문에, keep-alive에서도 페이지별로 독립적인 상태가 유지됩니다.


4. 스크롤 위치 복원

스크롤 복원은 Vue Router의 scrollBehavior로 간단히 해결했습니다.

// router/index.ts
const router = createRouter({
  history: createWebHistory(),
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      // 뒤로가기/앞으로가기: 저장된 위치로 복원
      return savedPosition;
    }
    // 새 페이지: 최상단
    return { top: 0 };
  }
});


5. 페이지 방향 감지와 캐시 관리

문제: 언제 캐시를 지워야 하는가

캐싱이 잘 되는 것도 중요하지만, 적절히 캐시를 제거하는 것도 중요했습니다.

  • 뒤로가기 시: 현재 페이지 캐시 제거
  • 이전에 캐싱된 페이지를 새로 진입할 경우: 기존 캐시 제거 후 리마운트 문제는 뒤로가기 액션이 다양하다는 점이었습니다. 헤더의 백버튼, 네이티브 뒤로가기 버튼, 스와이프 제스처 등 OS별로 방법이 달랐습니다.

해결: history.state.position 활용

모든 뒤로가기 케이스가 history.back()을 호출하고, 이때 popstate 이벤트가 발생한다는 것을 확인했습니다. history.state.position의 이동 전후 값을 비교해 방향을 감지했습니다.

// stores/modules/navigation.ts
export const useNavigationStore = defineStore('navigation', {
  state: () => ({
    direction: 'forward' as 'forward' | 'back',
    stack: [] as RouteInfo[],
    previousPosition: 0
  }),

  actions: {
    detectDirection() {
      const currentPosition = history.state?.position ?? 0;

      if (currentPosition < this.previousPosition) {
        this.direction = 'back';
        this.removeCurrentFromCache();
      } else {
        this.direction = 'forward';
      }

      this.previousPosition = currentPosition;
    },

    removeCurrentFromCache() {
      const currentPath = router.currentRoute.value.fullPath;
      this.stack = this.stack.filter(r => r.fullPath !== currentPath);
    }
  }
});

// main.ts
window.addEventListener('popstate', () => {
  navigationStore.detectDirection();
});

이 방향 정보는 추후 앞으로가기 애니메이션 적용에도 활용할 예정입니다.


6. 점진적 마이그레이션

keep-alive를 최상단에 걸면 모든 페이지가 캐싱됩니다. 하지만 모든 페이지를 한 번에 전환하기엔 리스크가 컸습니다.

route meta에 캐싱 플래그를 설정하고, keep-alive의 include 속성으로 캐싱할 페이지를 관리했습니다.

// router/routes.ts
{
  path: '/webapp/product/view',
  component: ProductView,
  meta: {
    keepAlive: true,
    cacheName: 'ProductView'
  }
},
{
  path: '/webapp/legacy/page',
  component: LegacyView,
  meta: {
    keepAlive: false  // 기존 방식 유지
  }
}

<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedPages">
      <component :is="Component" :key="getKey(route)" />
    </keep-alive>
  </router-view>
</template>

<script setup lang="ts">
const cachedPages = computed(() => {
  return router.getRoutes()
    .filter(route => route.meta.keepAlive)
    .map(route => route.meta.cacheName);
});
</script>


7. 성과

성능 개선

싱글웹뷰 전환 성능 개선 결과
항목비교값 (% 감소)
페이지 전환 시간(ms)8888 % 감소
메모리 사용량(%)7070 % 감소
지표 Before After 개선율
페이지 전환 시간 ~1,800ms ~400ms 88% 감소
Vue 번들 로드 매 페이지 최초 1회 N-1회 절감
메모리 사용량 웹뷰 × N개 웹뷰 1개 ~70% 감소

사용자 경험 개선

Before (멀티웹뷰)

장점

  • 네이티브 웹뷰 격리로 페이지 간 충돌 없음

고려할 점

  • 뒤로가기 시 상태 초기화
  • 스크롤 위치 최상단으로 리셋
  • 페이지 전환 애니메이션 없음
  • 매번 Vue 번들 전체 로드

After (싱글웹뷰 + keep-alive)

장점

  • 뒤로가기 시 상태 완벽 보존
  • 스크롤 원래 위치 복원
  • 네이티브 수준 전환 애니메이션
  • Vue 번들 최초 1회만 로드

고려할 점

  • 캐시 관리 로직 필요
항목 Before After
뒤로가기 시 상태 초기화 완벽 보존
뒤로가기 시 스크롤 최상단 원래 위치 복원
페이지 전환 애니메이션 없음 네이티브 수준

Before

After


마치며

싱글웹뷰 전환은 단순히 keep-alive 하나 붙이는 작업이 아니었습니다. 전역 상태 의존성 해소, 캐싱 키 설계, Props Drilling 해결, 방향 감지까지 여러 문제를 풀어야 했습니다.

  1. Composable 상태 인스턴스화
  2. fullPath 캐싱 키
  3. Context 패턴
  4. 스크롤 복원
  5. 방향 감지 + 캐시 관리
  6. 점진적 마이그레이션

결과적으로 페이지 전환 속도 86% 개선, 메모리 사용량 70% 감소를 달성했고, Headless UI 패턴 적용으로 코드 유지보수성도 크게 향상되었습니다.

앞으로가기 애니메이션과 나머지 페이지 마이그레이션이 남아있지만, 가장 어려운 고비는 넘긴 것 같습니다.

[최종 아키텍처]

Native App
    └── Single WebView
        └── Vue Router (SPA)
            ├── keep-alive (fullPath 기반 캐싱)
            ├── Context (페이지 스코프 상태)
            ├── scrollBehavior (스크롤 복원)
            └── Navigation Direction (방향 감지)