
Vue.js – Vite 프로젝트를 Nuxt로 전환하기
최근 이 웹사이트를 Vite 기반의 CSR(Client-Side Rendering) 방식 SPA(Single Page Application)에서 Nuxt를 이용한 SSR(Server-Side Rendering) 애플리케이션으로 전환했습니다. 이번 글에서는 전환 과정에서 겪었던 문제와 해결 과정을 기록하고자 합니다. Vite 프로젝트에서 Nuxt로 전환을 고민하고 있다면 조금이나마 참고가 되기를 바랍니다.
CSR에서 SSR로
기존 블로그는 프론트엔드를 Vue.js와 Vite로 제작한 SPA로 구성하고, 백엔드는 Axum(Rust) 서버로 서비스하고 있었습니다. Vite로 빌드한 정적 파일과, API를 통해 가져오는 데이터 모두 Axum 서버에서 제공하는 구조였습니다. 하지만 SPA에서 다음과 같은 한계를 경험했습니다:
초기 로딩 이슈: SPA 특성상 페이지 로딩 시에 내용이 비어있는 HTML에 JavaScript를 실행시켜 렌더링하다 보니, 비어있는 요소들에 내용이 채워지는 과정에서 화면이 자주 업데이트 되거나 UI가 순간이동 하는 문제가 있었습니다. 로딩이 늦어지면 사용자가 비어있는 레이아웃 틀만 보고있는 채로 있는 경우도 있었습니다.
검색 엔진 최적화: CSR의 가장 큰 단점은 SEO(Search Engine Optimization) 측면에서 약점이 있다는 점입니다. 구글봇처럼 JavaScript를 실행할 수 있는 고급 봇은 어느정도 영향이 적지만, 빈 기본 페이지만 볼 수 있는 대부분의 봇과 크롤러에게는 콘텐츠를 제대로 노출할 수 없습니다.
이러한 이유로 SSR 프레임워크인 Nuxt 도입을 결정했습니다. 서버에서 미리 렌더링된 페이지를 제공하는 방식을 통해 문제점을 개선하고, Nuxt가 제공하는 페이지 간 트랜지션 지원을 사용해서 사용자 경험을 더욱 부드럽게 만들고자 했습니다. Vue 기반이니 기존 코드를 크게 변경하지 않아도 될 거라 기대했습니다.
Nuxt 프로젝트 생성 및 폴더 구조 변결
먼저 Nuxt 공식 가이드에 따라 프로젝트를 생성했습니다.
npm create nuxt@latest
이후 Nuxt의 디렉토리 구조에 맞춰 파일들을 재배치했습니다. Nuxt는 파일 위치에 따라 페이지 라우팅이나 서버 라우팅 경로가 결정되기 때문에 더욱 엄격한 디렉토리 구조를 따라야 합니다.
Vite 프로젝트 구조
client/
├── public/...
├── src/
│ ├── assets/...
│ ├── components/...
│ ├── pages/
│ │ ├── Error.vue
│ │ └── ...
│ ├── scripts/... # vue-router 경로, pinia store, 타입 파일들
│ ├── styles/...
│ ├── App.vue # 최상위 앱 컴포넌트
│ └── main.ts # createApp으로 App.vue를 마운트, 라우터/스토어 연결
├── index.html # 애플리케이션 진입 HTML, Vue 앱이 마운트될 div 등을 정의
└── vite.config.js # Vite 설정
Nuxt 프로젝트 구조
client/
├── assets/... # style 파일들도 함께 통합
├── components/...
├── layouts/ # 레이아웃 별도 분리
│ └── default.vue
├── pages/...
├── public/...
├── scripts/...
├── server/ # 웹서버 Endpoint 및 Hook 정의
│ └── middleware/...
├── app.vue # 애플리케이션 진입 컴포넌트
├── error.vue # 에러 페이지
└── nuxt.config.ts # Nuxt 설정
src 디렉토리 안에 있던 디렉토리들을 프로젝트 루트로 이동시키고, 요구사항에 맞춰 파일들을 적당한 위치로 이동시켜 줍니다. 변경된 경로에 맞춰 파일 내부의 import구문들을 수정한 후 App.vue파일에 선언했던 레이아웃을 별도로 추출해서 layouts/Default.vue로 옮겨줍니다.
가장 눈에 띄는 변화는 index.html과 main.ts가 사라졌다는 점이었습니다. Nuxt에서는 페이지 메타데이터를 nuxt.config.ts 파일이나 useHead Composable 함수로 정의하기 때문에 index.html을 사용하지 않습니다. 또한 createApp으로 애플리케이션을 시작하거나 app.use()로 모듈을 직접 연결하지 않고, nuxt.config.ts를 통해 앱에서 사용할 수 있도록 연결하기 때문에 main.ts를 사용하지 않습니다.
export default defineNuxtConfig({
...
app: {
head: {
title: 'houu.dev',
htmlAttrs: {
lang: 'ko',
},
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
},
},
...
modules: [
'@nuxt/eslint',
],
eslint: {
config: {
stylistic: {
quotes: 'single'
}
}
}
});
Vite 프로젝트에서는 경로와 컴포넌트를 매핑한 routes를 직접 정의해서 main.ts에서 app과 vue-router를 연결해서 사용했으나, Nuxt 프로젝트에서는 pages/에 들어있는 .vue파일들이 자동으로 route로 설정되기 때문에 수동으로 routes를 정의할 필요가 없습니다. 레이아웃을 변경하거나 자동으로 정의되는 경로를 바꾸는 등 route의 메타데이터를 설정할 땐 definePageMeta와 같은 컴파일러 매크로로 정의할 수 있습니다.
definePageMeta({
layout: 'default',
path: '/post/:id',
});
Routes 정보를 페이지 내에서 사용할 땐 vue-router의 Router를 이용해 가져올 수 있습니다.
// Vite
import routes from './scripts/routes.js';
// Nuxt
import { useRouter } from 'vue-router';
const ROUTER = useRouter();
const ROUTES = ROUTER.getRoutes();
결론적으로, 프로젝트 구조와 메타 정보 처리 방식이 크게 바뀌었습니다. 덕분에 이제 페이지 파일만 추가하면 자동으로 라우트가 생기고, SEO 메타 설정도 각 페이지 파일 안에서 처리할 수 있어 편리했지만, 한편으로는 기존에 명시적으로 모든 것을 통제하던 방식에서 벗어나 Nuxt만의 방식에 익숙해져야 하는 과정이 필요했습니다.
서버 설정
Nuxt는 웹페이지를 사용자에게 전송하기 위해 Nitro라는 자체제작 서버를 사용합니다. 그런데 서버 설정같은 부분에서 아직 성숙하지 못하다고 느껴지는 부분이 있었습니다.
웹서버의 요청을 WAS(Web Application Server)로 넘겨주기 위해 아래와 같이 프록시 설정을 해주었습니다. 하위 요청까지 모두 프록시로 처리하기 위해서 개발용 설정은 **로 끝낼 필요가 없지만 배포용 설정에는 반드시 **로 끝내는 처리가 필요합니다.
// nuxt.config.ts
export default defineNuxtConfig({
...
nitro: {
devProxy: {
'/api': {
target: 'http://localhost:7878/api',
},
},
routeRules: {
'/api/**': {
proxy: 'http://localhost:7878/api/**'
},
}
},
...
});
또한 파일 구조를 바탕으로 프로젝트를 구성해야 하기 때문에 개별 핸들러 별로 파일을 잘게잘게 쪼개는 사용법은 코드 관리에서 다소 까다로울 수 있는 사용법이라고 느껴졌습니다.
여러 요청을 한번에 묶어서 정의할 수 있는 Catch-all route로 서버 코드를 어느정도 묶어서 정의할 수 있지만, root path의 요청을 받을 수 없는 문제가 있습니다. 예를 들어, /api/images/cat.png, /api/images/dog.png 이런 하위 경로가 있는 요청은 Nuxt의 server/api/iamges/[...].ts 파일에 정의한 핸들러에서 모두 받을 수 있지만 /api/images 요청을 처리하려면 server/api/images/ 디렉터리 안에 index.ts 라는 별도의 파일로만 처리할 수 있습니다.
client/
├── ...
├── server/
│ ├── api/
│ │ ├── images/
│ │ │ ├── index.ts # /api/images
│ │ │ └── [...].ts # /api/images/{image}
│ │ └── test.post.ts # POST /api/test
│ ├── middleware/...
│ └── tsconfig.json
└── ...
데이터 가져오기
기존 프로젝트에서는 런타임 built-in함수인 fetch를 사용했는데, SSR을 적용하기 위해선 Nuxt의 함수인 useFetch와 같은 함수를 사용해야 합니다. useFetch는 Response 객체를 리턴하는 fetch와 리턴 타입이 다르기 때문에 Nuxt의 공식 문서를 읽고 코드를 수정했습니다.
특히 reactivity를 살리기 위해 useFetch가 만들어낸 Ref 객체를 그대로 사용해야 SSR이 제대로 작동하게 만들 수 있습니다. 그래서 가급적 WAS에서 별도의 가공이 필요없는 데이터 형태로 반환하도록 수정하고, 클라이언트에서 추가 가공이 필요할 땐 computed를 활용해서 변환을 수행하도록 수정을 진행했습니다.
interface Post { ... }
let { data, refresh } = await useFetch<Array<Partial<Post>>>('/api/post');
let list = computed(() => data.value.map((item: Partial<Post>) => { ... }));
useFetch는 top-level에서 사용해야 하기 때문에 onResponse같은 훅을 통해 변수에 접근할 때 hoisting에 의존하고 있지 않은지도 점검해주어야 할 요소였습니다.
나머지 이슈들
초기 렌더링 때 글자나 요소들이 깜빡이는 문제가 있었는데 다행히 빌드 버전에서만 발생하는 문제였습니다. 그래도 폰트 용량이 커서 글자가 늦게 뜨는 문제가 있어서 CSS font-display 프로퍼티를 통해 폰트가 로딩되기 전에도 글자를 볼 수 있도록 완화해 주었습니다.
@font-face {
...
font-display: swap;
}
WebAssembly를 사용하는 부분에서도 문제가 있었습니다. WASM은 사용 전 initialization을 해줘야 하는데, 한 번만 호출해야 하는 초기화를 서버의 렌더링 과정에서 한번, 클라이언트의 hydration 과정에서 또 한번 호출해서 문제가 발생했습니다. 이 문제는 사용자 클라이언트에서만 렌더링 되도록 import.meta.client 플래그를 통해 안전하게 한번만 초기화될 수 있도록 수정해주었습니다.
let isWasmInitialized = ref<boolean>(false);
if (import.meta.client) {
init().then(() => { isWasmInitialized.value = true; });
}
그 외엔 3.16.0 버전을 사용하면서 v-for 디렉티브로 리스트를 렌더링할 수 없는 버그가 있었지만 다행히 전환 작업 도중 3.16.1 버전이 나오면서 작업을 이어갈 수 있었던 이슈도 있었습니다.
마치며
사실 Nuxt를 사용하고자 몇번 시도해 본 적이 있었는데, 그 때마다 해결하기 힘든 문제에 부딪혀 사용에 실패했던 경험이 있습니다. 그러나 이번에는 마주치는 문제들을 다양한 시도와 에러 추적으로 차근차근 해결해 나가면서 전환을 성공적으로 완료할 수 있었습니다.
빌드하는 시간과 배포 코드의 양이 상당히 늘어나긴 했지만 페이지가 안정적으로 렌더링 되고 페이지 전환이 부드럽게 되는 것을 보니 만족스럽습니다. 특히 <NuxtLoadingIndicator>와 같은 컴포넌트로 페이지 이동 사이에 로딩바까지 추가하니 정말 매끄럽게 동작하는 사이트가 된것 같아 만족감이 더 큽니다. 앞으로는 pageTransition을 이용한 페이지 전환 효과를 도입하거나, Nuxt의 모듈 생태계를 활용해서 사이트 기능과 경험을 더 개선하려고 합니다.
새로운 기술 스택으로의 전환은 그리 간단한 일은 아니지만, 저처럼 사용자 경험을 한단계 더 끌어올리고 싶은 개발자들에게 참고가 되었다면 좋겠습니다.