Vue.js 기반 관리자 패널에서 탭 네비게이션 구현 및 라우트 캐싱 전략

Vue.js와 Element UI를 기반으로 한 관리자 대시보드에서 탭 기반 네비게이션(TagsView)을 구현할 때, 사용자가 방문한 페이지를 시각적으로 유지하고 컴포넌트 상태를 효율적으로 캐싱하는 것은 중요한 요구사항이다. 이 기능은 주로 keep-alive와 Vue Router의 조합을 통해 실현되며, Vuex를 활용해 상태를 중앙에서 관리한다.

visitedViews 와 cachedViews: 두 가지 핵심 상태

탭 시스템은 다음 두 가지 배열을 중심으로 동작한다:

  • visitedViews: 사용자가 실제로 방문한 라우트 정보를 저장하며, UI 상에서 표시되는 탭 목록에 해당한다.
  • cachedViews: keep-alive에 의해 캐싱된 컴포넌트 이름 목록이다. 라우트 설정에서 meta.noCache: true로 지정하면 이 배열에 포함되지 않는다.

keep-alive는 내부적으로 include 속성을 통해 컴포넌트 이름 기준으로 캐싱 여부를 판단하므로, 라우트 정의 시 name 속성과 실제 뷰 컴포넌트의 name 옵션이 일치해야 한다. 불일치 시 캐싱이 동작하지 않거나 예기치 않은 재귀 참조가 발생할 수 있다.

동적 경로 캐싱 문제 및 해결 방안

예를 들어 /article/1, /article/2처럼 동일한 컴포넌트를 공유하는 동적 세그먼트 라우트는 모두 같은 컴포넌트 이름을 가지므로, keep-alive는 이를 하나의 인스턴스로 취급하게 된다. 이 경우 다음과 같은 접근이 필요하다:

  1. include 제거 후 전체 캐싱 적용: <keep-alive>include 속성을 제거하면 모든 라우트 컴포넌트가 캐싱된다. 단점은 특정 탭만 제거하는 것이 어렵고, 최대 캐시 수(max) 외에는 제어가 불가능하다는 점이다.
  2. 브라우저 스토리지 활용: localStoragesessionStorage를 사용하여 데이터 상태를 수동으로 저장하고 복원하는 방식. 라우트 진입 시 이전 상태를 불러오는 로직을 개발자가 직접 구현해야 한다.

ScrollPane 컴포넌트: 가로 스크롤 가능한 컨테이너

여러 탭이 생성되어 화면 너비를 초과할 경우, 마우스 휠 이벤트로 가로 스크롤이 가능하도록 해야 한다. 아래는 el-scrollbar를 기반으로 한 스크롤 패널 구성 예시이다.

<template>
  <el-scrollbar
    ref="scrollContainer"
    :vertical="false"
    class="scroll-container"
    @wheel.native.prevent="handleWheel"
  >
    <slot />
  </el-scrollbar>
</template>

<script>
export default {
  name: 'HorizontalScroller',
  computed: {
    scrollElement() {
      return this.$refs.scrollContainer.$refs.wrap;
    }
  },
  methods: {
    handleWheel(event) {
      const delta = event.wheelDelta || -event.deltaY * 40;
      const container = this.scrollElement;
      container.scrollLeft += delta / 4;
    },
    scrollToActive(targetTag) {
      const container = this.$refs.scrollContainer.$el;
      const containerWidth = container.offsetWidth;
      const wrapper = this.scrollElement;
      const tags = this.$parent.$refs.tabRefs;

      if (!tags.length) return;

      const first = tags[0];
      const last = tags[tags.length - 1];

      if (targetTag === first) {
        wrapper.scrollLeft = 0;
      } else if (targetTag === last) {
        wrapper.scrollLeft = wrapper.scrollWidth - containerWidth;
      } else {
        const index = tags.findIndex(t => t === targetTag);
        const prev = tags[index - 1];
        const next = tags[index + 1];
        const spacing = 4;

        if (next) {
          const rightEdge = next.$el.offsetLeft + next.$el.offsetWidth + spacing;
          if (rightEdge > wrapper.scrollLeft + containerWidth) {
            wrapper.scrollLeft = rightEdge - containerWidth;
          }
        }

        if (prev) {
          const leftEdge = prev.$el.offsetLeft - spacing;
          if (leftEdge < wrapper.scrollLeft) {
            wrapper.scrollLeft = leftEdge;
          }
        }
      }
    }
  }
};
</script>
  

다국어 지원을 위한 타이틀 변환 유틸리티

메뉴나 탭에 표시되는 제목은 다국어(i18n) 처리가 필요하다. 다음 유틸 함수는 라우트 메타의 타이틀 키를 기반으로 번역된 문자열을 반환한다.

// @/utils/i18n.js
export function translateRouteTitle(key) {
  if (!key) return '';
  const hasTranslation = this.$te(`route.${key}`);
  return hasTranslation ? this.$t(`route.${key}`) : key;
}
  

Vuex를 통한 탭 상태 관리

탭 목록과 캐싱 상태는 중앙 집중식으로 관리되어야 하며, Vuex 모듈을 통해 구현한다. 주요 액션은 탭 추가, 제거, 갱신 등을 포함한다.

// store/modules/tabManager.js
const state = {
  visitedTabs: [],
  cachedComponents: []
};

const mutations = {
  APPEND_TAB(state, route) {
    if (!state.visitedTabs.some(tab => tab.path === route.path)) {
      state.visitedTabs.push({
        ...route,
        title: route.meta.title || '무제목'
      });
    }
  },
  CACHE_COMPONENT(state, route) {
    if (!route.meta.noCache && !state.cachedComponents.includes(route.name)) {
      state.cachedComponents.push(route.name);
    }
  },
  REMOVE_TAB(state, target) {
    const index = state.visitedTabs.findIndex(tab => tab.path === target.path);
    if (index > -1) {
      state.visitedTabs.splice(index, 1);
    }
  },
  UNCACHE_COMPONENT(state, route) {
    const i = state.cachedComponents.indexOf(route.name);
    if (i > -1) {
      state.cachedComponents.splice(i, 1);
    }
  },
  CLEAR_OTHERS(state, current) {
    state.visitedTabs = state.visitedTabs.filter(
      tab => tab.meta.preserve || tab.path === current.path
    );
    state.cachedComponents = state.cachedComponents.includes(current.name)
      ? [current.name]
      : [];
  },
  CLEAR_ALL(state) {
    const preserved = state.visitedTabs.filter(tab => tab.meta.preserve);
    state.visitedTabs = preserved;
    state.cachedComponents = [];
  }
};

const actions = {
  addTab({ commit }, route) {
    commit('APPEND_TAB', route);
    commit('CACHE_COMPONENT', route);
  },
  removeTab({ commit, state }, route) {
    return new Promise(resolve => {
      commit('REMOVE_TAB', route);
      commit('UNCACHE_COMPONENT', route);
      resolve({ tabs: [...state.visitedTabs] });
    });
  },
  refreshTab({ dispatch }, route) {
    return dispatch('removeTab', route).then(() => {
      this.$nextTick(() => {
        this.$router.replace(`/redirect${route.fullPath}`);
      });
    });
  }
};

export default { namespaced: true, state, mutations, actions };
  

TagsView 컴포넌트 구현

실제 탭 리스트는 router-link를 사용해 라우트 이동을 처리하며, 마우스 우클릭 시 컨텍스트 메뉴를 표시한다. 고정 탭(affix/preserve)은 닫기 기능에서 제외된다.

<template>
  <div class="tab-container">
    <horizontal-scroller ref="scroller" class="tab-wrapper">
      <router-link
        v-for="tab in visitedTabs"
        :key="tab.path"
        ref="tabRefs"
        :to="tab.fullPath"
        class="tab-item"
        :class="{ active: isActive(tab) }"
        @click.middle.native="!isPreserved(tab) && closeTab(tab)"
        @contextmenu.prevent.native="openContextMenu(tab, $event)"
      >
        {{ translateRouteTitle(tab.title) }}
        <span
          v-if="!isPreserved(tab)"
          class="close-btn"
          @click.stop="closeTab(tab)"
        />
      </router-link>
    </horizontal-scroller>

    <ul v-show="menuVisible" class="context-menu" :style="{ left, top }">
      <li @click="refreshTab(selected)">새로고침</li>
      <li v-if="!isPreserved(selected)" @click="closeTab(selected)">닫기</li>
      <li @click="closeOthers()">기타 닫기</li>
      <li @click="closeAll()">모두 닫기</li>
    </ul>
  </div>
</template>

<script>
import HorizontalScroller from './HorizontalScroller';
import { translateRouteTitle } from '@/utils/i18n';

export default {
  components: { HorizontalScroller },
  data: () => ({
    menuVisible: false,
    selected: {},
    left: 0,
    top: 0
  }),
  computed: {
    visitedTabs() {
      return this.$store.state.tabManager.visitedTabs;
    }
  },
  watch: {
    $route() {
      this.addTab();
      this.scrollToActive();
    }
  },
  mounted() {
    this.initPinnedTabs();
    this.addTab();
  },
  methods: {
    translateRouteTitle,
    isActive(tab) {
      return tab.path === this.$route.path;
    },
    isPreserved(tab) {
      return !!tab.meta.preserve;
    },
    initPinnedTabs() {
      // 초기에 항상 보여야 할 탭들을 등록
    },
    addTab() {
      const { name } = this.$route;
      if (name) {
        this.$store.dispatch('tabManager/addTab', this.$route);
      }
    },
    scrollToActive() {
      this.$nextTick(() => {
        const tabs = this.$refs.tabRefs;
        const activeTab = tabs.find(tab => tab.to.path === this.$route.path);
        if (activeTab) {
          this.$refs.scroller.scrollToActive(activeTab);
        }
      });
    },
    openContextMenu(tab, e) {
      const minWidth = 100;
      const containerRect = this.$el.getBoundingClientRect();
      const maxX = containerRect.width - minWidth;
      const clickX = e.clientX - containerRect.left;

      this.left = Math.min(clickX, maxX) + 'px';
      this.top = e.clientY + 'px';
      this.menuVisible = true;
      this.selected = tab;
    },
    closeMenu() {
      this.menuVisible = false;
    },
    closeTab(tab) {
      if (this.isActive(tab)) {
        // 다음 활성 탭으로 이동
      }
      this.$store.dispatch('tabManager/removeTab', tab);
    },
    closeOthers() {
      this.$store.dispatch('tabManager/closeOthers', this.selected);
    },
    closeAll() {
      this.$store.dispatch('tabManager/clearAll');
    },
    refreshTab(tab) {
      this.$store.dispatch('tabManager/refreshTab', tab);
    }
  },
  created() {
    document.body.addEventListener('click', () => {
      this.menuVisible = false;
    });
  }
};
</script>
  

AppMain.vue에서 keep-alive 적용

최종적으로 라우터 뷰를 캐싱하기 위해, 레이아웃 컴포넌트 내부에서 keep-alive를 사용하고 include에 캐싱 목록을 바인딩해야 한다.

<keep-alive :include="cachedComponents">
  <router-view :key="$route.fullPath" />
</keep-alive>
  

이때 :key$route.fullPath를 사용하면 쿼리 파라미터 변경 시에도 리렌더링이 발생하므로, 필요에 따라 조정할 수 있다.

태그: Vue.js Element-UI vuex keep-alive vue-router

6월 5일 23:30에 게시됨