권중운
Portfolio

Vue2에서 Vue3로 옮기며 같이 정리한 것들

2025.03.2117분 읽기

SI 회사에 다닐 때 사내에서 계속 복제해 쓰는 Vue2 어드민 템플릿이 있었다. 원본 템플릿을 꾸준히 관리하는 흐름은 거의 없었다. 프로젝트가 1, 2, 3, 4 순서로 진행되면 1에서 쓰던 것을 복제해 2를 시작하고, 2에서 바꾼 것을 다시 복제해 3을 시작하는 식이었다.

처음에는 빠른 착수에 유리해 보였다. 하지만 프로젝트가 쌓일수록 비효율이 커졌다. 버그 수정은 원본으로 돌아가지 않았고, 로그인, 메뉴, 목록 조회, 검색 폼 같은 공통 코드가 프로젝트마다 조금씩 다른 모습으로 갈라졌다. 템플릿은 Vue2 기반이었고, 팀 안에 Vue를 깊게 알고 방향을 잡아줄 사람이 많지 않았다. 새 프로젝트를 시작할 때마다 같은 문제를 다시 만지고 있었다.

그래서 내가 먼저 정리하기로 했다. 목표는 이미 끝난 프로젝트를 전부 갈아엎는 것이 아니었다. 다음 프로젝트가 또 직전 프로젝트의 임시 수정본을 복제해서 시작하지 않도록, Vue3 기준의 새 어드민 템플릿을 만들고 유지보수할 수 있는 공통 구조를 잡는 일이었다.

Vue2에서 Vue3로 올릴 때 처음 든 생각은 “문법만 바꾸면 되지 않을까”였다. 실제로는 그렇지 않았다. 오래된 화면은 대부분 잘 동작하고 있었지만, 그 동작을 유지하는 코드의 책임은 꽤 많이 섞여 있었다. 목록 조회, 페이지네이션, 검색 폼, 인증 상태, 라우터 접근 제어가 컴포넌트 안에서 같이 움직였다.

그래서 목표를 버전 업 하나로 잡지 않았다. 화면은 그대로 살리되, 반복되는 로직을 먼저 꺼내고 Vue3에서 자연스럽게 다룰 수 있는 단위로 다시 묶었다. @vue/compat 같은 호환 빌드는 경고를 확인하는 데 도움이 되지만, 경고가 사라졌다고 구조가 좋아지는 것은 아니다. 마이그레이션의 본문은 결국 “어떤 책임을 공통 템플릿 안에 남기고, 어떤 책임을 화면 밖으로 뺄 것인가”였다.

실제로 같이 손댄 경계

Vue 생태계를 다시 보는 계기가 됐다

처음에는 Vue2 코드를 Vue3 문법으로 바꾸는 일처럼 봤다. 그런데 공부할수록 단순히 dataref로 바꾸는 문제가 아니라는 쪽으로 생각이 바뀌었다. Vue 생태계가 새 프로젝트를 어떤 방향으로 밀고 있는지 확인해야 했다.

create-vue로 새 프로젝트를 만들어보고, Vite, Vue Router, Pinia, vue-tsc, Vue Devtools를 직접 세팅했다. 그러면서 Vue 쪽 메인스트림이 어디로 가는지 조금 더 선명해졌다. 빌드는 Vite 중심으로 가볍게 가져가고, 라우팅은 Vue Router 4의 메타와 가드를 기준으로 잡고, 상태 관리는 Vuex보다 Pinia의 setup store 방식으로 가는 흐름이었다. TypeScript도 “나중에 붙이는 옵션”이 아니라 템플릿 설계 단계에서 같이 고려해야 했다.

이 과정이 없었다면 마이그레이션은 기존 Vue2 코드의 모양을 Vue3 문법으로만 옮기는 데서 끝났을 것이다. 공식 라이브러리들을 하나씩 붙여보면서, 새 템플릿은 “지금 당장 돌아가는 사본”이 아니라 “다음 프로젝트에서 팀이 학습하고 확장할 기준점”이어야 한다고 생각하게 됐다.

컴포넌트가 너무 많은 일을 하고 있었다

Vue2 코드에서 가장 자주 보인 모양은 Options API 자체가 문제가 아니라, 화면 컴포넌트가 모든 흐름을 직접 들고 있는 구조였다.

export default {
  data() {
    return {
      query: { keyword: "", status: "" },
      page: 1,
      size: 10,
      rows: [],
      total: 0,
      loading: false,
    };
  },
  methods: {
    async fetchList() {
      this.loading = true;
      const res = await api.fetchUsers({
        ...this.query,
        page: this.page,
        size: this.size,
      });
      this.rows = res.items;
      this.total = res.total;
      this.loading = false;
    },
  },
};

한 화면만 보면 읽을 만하다. 문제는 같은 코드가 사용자 목록, 상품 목록, 권한 목록에 거의 같은 형태로 반복된다는 점이었다. 검색 조건만 다르고, 응답 모양만 조금 다르고, 실패 메시지만 다를 뿐 조회 흐름은 비슷했다. 이 반복이 템플릿에 남은 채 프로젝트마다 복제되면, 다음 프로젝트에서도 다시 같은 코드를 고치게 된다.

Vue3로 옮기면서 이 반복을 composable로 빼기 시작했다.

import { reactive } from "vue";
 
type Pageable = {
  page: number;
  size: number;
};
 
export function useTableView<TItem>({
  createQuery,
  listApi,
  toRequest,
  toTable,
}: {
  createQuery: () => Record<string, unknown>;
  listApi: (request: unknown) => Promise<unknown>;
  toRequest: (page: Pageable, query: Record<string, unknown>) => unknown;
  toTable: (response: unknown) => { list: TItem[]; total: number };
}) {
  const table = reactive({ list: [] as TItem[], total: 0, loading: false });
  const query = reactive(createQuery());
  const page = reactive<Pageable>({ page: 1, size: 10 });
 
  async function fetchList() {
    table.loading = true;
    try {
      const response = await listApi(toRequest(page, query));
      const { list, total } = toTable(response);
      table.list = list;
      table.total = total;
    } finally {
      table.loading = false;
    }
  }
 
  return { table, query, page, fetchList };
}

이 코드는 Vue3라서 가능한 마법은 아니다. 하지만 Composition API로 옮기니 “목록 화면이 공통으로 갖는 상태”와 “화면마다 달라지는 변환 함수”를 같은 파일 안에서 타입으로 묶기 쉬워졌다. 컴포넌트는 테이블을 그리는 일에 가까워지고, 조회 정책은 composable의 이름으로 남았다.

옮기기 전

  • 화면마다 data, methods, watch가 비슷하게 반복된다.
  • 조회 실패, 로딩, 페이지 변경 규칙이 호출부마다 조금씩 달라진다.
  • 타입은 API 응답을 받은 뒤에야 흐릿하게 맞춰진다.

옮긴 뒤

  • 목록 조회 흐름은 composable 이름으로 모인다.
  • 화면은 query, page, table을 가져다 쓴다.
  • 응답 변환부에서 리스트 타입을 한 번에 잡는다.

상태 관리는 Vuex 교체보다 경계 정리가 먼저였다

상태 관리는 Pinia로 옮겼다. 여기서도 핵심은 라이브러리 이름이 아니었다. 예전에는 전역 상태에 무엇을 넣어야 하는지 애매했다. 로그인 토큰, 아이디 저장 여부, 사이드바 접힘 상태처럼 화면 여러 곳에서 쓰는 값과 특정 폼 안에서만 쓰는 값이 같은 무게로 다뤄졌다.

Pinia setup store로 옮기면서 전역에 둘 값을 더 작게 잡았다.

import { computed } from "vue";
import { useStorage } from "@vueuse/core";
import { defineStore } from "pinia";
 
export const useAuthStore = defineStore("auth", () => {
  const token = useStorage("access-token", "");
  const rememberInfo = useStorage("remember-info", { id: "" });
 
  const isAuthenticated = computed(() => Boolean(token.value));
  const hasRememberInfo = computed(() => Boolean(rememberInfo.value.id));
 
  function updateToken(nextToken: string) {
    token.value = nextToken;
  }
 
  function updateRememberInfo(nextInfo: { id: string }) {
    rememberInfo.value = nextInfo;
  }
 
  return {
    isAuthenticated,
    rememberInfo,
    hasRememberInfo,
    updateToken,
    updateRememberInfo,
  };
});

토큰을 저장하는 방식, 로그인 여부를 계산하는 방식, 저장된 아이디가 있는지 확인하는 방식이 한곳에 들어왔다. 로그인 화면은 login()을 호출하고, 라우터 가드는 isAuthenticated만 본다. “어디서 localStorage를 읽는가”가 코드베이스 전체에 퍼지지 않는 것만으로도 디버깅 범위가 줄었다.

반대로 폼 입력값은 전역으로 올리지 않았다. 생성 화면과 수정 화면에서만 필요한 값은 useForm 같은 얇은 composable로 충분했다. Vue3로 옮기는 김에 모든 상태를 Pinia로 모으면 편해 보이지만, 그건 전역 상태가 커지는 다른 문제를 만든다.

라우터는 메뉴와 권한의 기준이 됐다

관리자 화면에서는 메뉴와 권한이 자주 같이 움직인다. Vue2 코드에서는 사이드바 메뉴 배열과 라우터 설정, 접근 제어 조건이 따로 있는 경우가 많았다. 경로를 하나 바꾸면 세 군데를 같이 확인해야 했다.

Vue Router 4로 옮기면서 라우트 메타를 더 적극적으로 썼다. 라우터가 화면의 진입점이라면, 메뉴에 보일지와 어떤 권한이 필요한지도 그 근처에 두는 편이 낫다고 봤다.

export const routes = [
  {
    path: "/contents",
    name: "contents",
    component: ContentsLayout,
    meta: { requiresAuth: true },
    children: [
      {
        path: "users",
        name: "users",
        component: UsersPage,
        meta: {
          menu: { label: "Users", order: 10 },
          roles: ["admin"],
        },
      },
    ],
  },
];

사이드바는 라우터 설정을 읽어 메뉴를 만들고, 가드는 같은 메타를 보고 접근을 막는다. 이렇게 해두면 “보이는 메뉴”와 “들어갈 수 있는 화면”이 서로 다른 규칙을 갖는 일을 줄일 수 있다.

script setup은 줄 수보다 노출 범위를 줄였다

마이그레이션 중간에 모든 컴포넌트를 억지로 <script setup>으로 바꾸지는 않았다. 화면을 건드릴 이유가 있을 때만 옮겼다. 그래도 새로 만드는 컴포넌트는 대부분 <script setup lang="ts">를 기본으로 잡았다.

특히 공통 UI에서는 props, emit, model을 타입으로 바로 드러낼 수 있는 점이 좋았다.

<script setup lang="ts" generic="TOption, TValue = TOption">
const props = defineProps<{
  options: TOption[];
  optionLabel: (option: TOption) => string;
  optionValue: (option: TOption) => TValue;
}>();
 
const model = defineModel<TValue>({ required: true });
const emit = defineEmits<{
  change: [value: TValue];
}>();
 
function select(option: TOption) {
  const value = props.optionValue(option);
  model.value = value;
  emit("change", value);
}
</script>

이런 컴포넌트는 사용하는 쪽에서 옵션 값의 타입을 잃지 않는다. Vue2에서 mixin이나 느슨한 props로 재사용하던 패턴보다 추적하기 쉬웠다. 줄 수가 줄어서 좋은 게 아니라, 컴포넌트 바깥으로 노출되는 계약이 파일 위쪽에서 바로 보이는 점이 좋았다.

한 번에 다 옮기지 않았다

가장 조심한 부분은 마이그레이션 단위를 작게 잡는 일이었다. 전체 화면을 한 번에 Composition API로 바꾸면 리뷰하기 어렵고, 회귀가 났을 때 원인을 찾기 어렵다. 특히 사내 템플릿은 여러 프로젝트의 출발점이 되기 때문에, 한 번 잘못 잡은 구조가 다음 프로젝트로 그대로 퍼질 수 있었다. 그래서 먼저 앱 부트스트랩과 라우터, store를 Vue3 기준으로 맞추고, 그 다음 반복이 큰 화면부터 composable을 뺐다.

실제로 나눈 순서
  1. 1앱 진입점과 플러그인 정리
  2. 2라우터와 전역 상태 전환
  3. 3목록/폼 반복 로직 분리
  4. 4공통 UI를 script setup으로 정리
  5. 5타입체크와 빌드로 회귀 확인

이 순서가 완벽한 정답은 아니다. 다만 SI 프로젝트에서는 “새 문법으로 바꿨다”보다 “다음 프로젝트가 무엇을 복제하게 되는가”가 더 중요했다. 코드가 예뻐졌는지는 나중 문제고, 프로젝트마다 반복되던 목록, 권한, 저장 흐름을 템플릿의 기준으로 다시 잡아야 했다.

결국 Vue2에서 Vue3로 옮기며 얻은 가장 큰 이득은 Composition API 자체가 아니었다. 화면 안에 숨어 있던 반복을 이름 붙일 수 있게 된 것, 전역 상태와 지역 상태의 경계를 다시 정한 것, 라우터를 메뉴와 권한의 기준으로 삼은 것이 더 오래 남았다. 무엇보다 다음 프로젝트가 직전 프로젝트의 사본이 아니라 정리된 템플릿에서 시작할 수 있게 된 점이 컸다.

마이그레이션은 버전 숫자를 바꾸는 작업처럼 시작했지만, 끝나고 보니 코드베이스를 다시 읽는 작업에 가까웠다. Vue3는 그 정리를 강제로 하게 만든 계기였고, 진짜 개선은 그 계기를 놓치지 않은 데 있었다.