jomoo.dev 페이지 디렉터리 리팩터링
intro
이번 리팩터링은 papes directory 관련 코드들을 리팩터링하려 한다.
현재 pages directory 구조, detail(programmers, js, jomoodev 등) 항목 당 [post].vue
페이지와 index.vue
페이지가 있다. category에 따른 detail sideBar와 detail에 따른 post(작성한 글)만 다르고, 나머지는 중복되는 코드다.
따라서 현재 route에 따라 sideBar와 post를 가지고 오는 형식으로 변경하려 한다.
리팩터링 후 pages 디렉터리
nuxt3의 dynamic routes를 이용해 pages 디렉터리를 리팩터링한 구조
대괄호 안의 매개변수 category
, detail
, post
를 이용해 경로가 동적으로 매핑된다. 예를 들어, 현재 글이 projects에 jomoodev의 refactor-pages라는 글인 경우, category
에는 projects가 detail
에는 jomoodev, post
에는 refactor-pages가 매개변수로 전달되어, 경로가 projects/jomoodev/refactor-pages로 자동으로 매핑된다.
dynamic routes를 이용하면 하나의 page 파일에서 동적으로 경로를 결정해, 경로에 따라 데이터를 가지고 와 렌더링할 수 있다.
pages/[category]/[detail]/index.vue
<script setup>
const route = useRoute();
const { category, detail } = route.params;
const { data: posts } = await useAsyncData(
`${detail}-posts`,
() => {
return queryContent(category, detail).only(['_path', 'description', 'title', 'date']).find();
},
{
transform(posts) {
return posts.reverse();
},
},
);
useHead({
title: detail,
meta: [{ name: 'description', content: `${category} ${detail}` }],
});
</script>
<template>
<NuxtLayout name="side-bar">
<ContentCard
v-for="{ _path, description, title, date } in posts"
:key="title"
:page-link="_path"
:description="description"
:title="title"
:date="date"
/>
</NuxtLayout>
</template>
리팩터링 후 index.vue
페이지
경로 매개변수 route.params
의 category
와 detail
을 이용해 queryContent
로 detail 항목의 posts
를 가지고 오고 있다.
여기서 변경된 점은 postStore
를 이용하지 않고, index.vue
의 setup 단계에서 글을 가지고 온다는 점이다. 이전에는 app.vue
의 setup 단계에서 postStore
에 모든 글을 가지고 와 저장한 것을 이용했었다.
하지만 리팩터링을 하다 보니, 각 페이지에서 필요한 글만을 갖고 오는 게 더 좋다고 생각했다. 시작할 때 모든 글을 가지고 오는 것은 작성한 글이 많아 질수록 앱의 첫 렌더링이 오래 걸릴 것이다. 한 번에 몰아서 갖고 오지 말고, 각 페이지에서 필요한 글만을 갖고 오도록 분담시키는 것이다.
페이지에서 필요한 글들을 갖고 오기 때문에 이전보다는 렌더링이 살짝 늦어지지만, 메인 페이지에서의 첫 렌더링 속도가 더 중요하다고 생각한다.
jomoodev 프로젝트의 리팩터링을 일단락지으면 메인 페이지를 새롭게 만들려 한다. 간단하게 구상한 새로운 메인 페이지에서는 블로그에 대한 소개 글과 랜덤으로 결정한 카테고리 글들을 보여주려 한다. postStore
를 완전히 제거해 작성한 모든 글을 가지고 오는 로직을 제거할 것이다.
또한, nuxt3의 컴포저블 함수 useAsyncData
를 이용해 post를 가지고 올 때 중복으로 가져오는 것을 방지하고 있다. 서버 측 렌더링에서 비동기 데이터를 가져온 뒤, 클라이언트 측에서 다시 가져오지 않게 해준다.
${detail}-posts
를 key로, nuxt에서 데이터 요청에서 중복을 제거할 수 있도록 고유 키를 설정했으며, 또한, useAsyncData
함수의 매개변수 options에 transform
을 이용해 가지고 온 post 들을 reverse 시켰다.
pages/[category]/[detail]/[post].vue
<script setup>
import { PREVIOUS, NEXT } from '~/constants/postDirection';
const route = useRoute();
const { detail, post } = route.params;
const directions = [PREVIOUS, NEXT];
const { data: surrounds } = await useAsyncData(
`post-${post}-surrounds`,
() => {
return queryContent().only(['_path', 'title', '_dir']).findSurround(route.path);
},
{
transform: (surrounds) => {
return surrounds.map((surround) => (surround && surround._dir === detail ? surround : null));
},
},
);
</script>
<template>
<NuxtLayout name="side-bar">
<div class="prose min-w-full md:px-2">
<ContentDoc />
</div>
<div v-if="surrounds?.length" class="flex flex-col md:flex-row w-full gap-2.5 justify-between">
<div v-for="(surround, index) in surrounds" :key="surround" class="md:w-1/3">
<PostMoveCard
v-if="surround"
:direction="directions[index]"
:title="surround.title"
:path="surround._path"
/>
</div>
</div>
</NuxtLayout>
</template>
리팩터링 후 post 페이지
nuxt-content의 <ContentDoc />
컴포넌트를 통해 현재 경로에 따른 글을 렌더링하고 있다.
이전과 달라진 점은 현재 글의 이전 글과 다음 글을 참조하는 방식이다. 이전에는 현재 글이 몇 번째 글인지 확인하고, 이전 글과 다음 글이 있는지 확인한 뒤, 현재 글의 위치를 이용해 pinia store의 저장된 이전 글과 다음 글을 참조했었다. 이 방식은 깔끔하지 못해 변경하고 싶었고, nuxt-content에 queryContent().findSurround()
라는 적절한 방식이 있어 이용하기로 했다.findSurround
는 인수로 받는 route(경로) 주변의 이전 content와 다음 content를 가지고 오는 기능이다. 옵션으로 이전 및 다음 content를 몇 개 가지고 올지 정할 수 있으며 정해진 개수에 따라 조건에 부합하였을 때 content가, 아닌 경우 null이 포함된 고정된 길이의 배열을 반환받는다.
위의 index.vue
와 같이useAsyncData
를 이용해 작성한 글을 중복으로 가지고 오는 것을 방지했으며, 현재 detail과 일치하는 이전 및 다음 글만을 얻기 위해 transform
에서 가지고 온 글들(surrounds
)이 현재 경로의 detail 항목과 일치하는지 확인해 일치하지 않는 경우 null을 반환하도록 했다.queryContent().findSurround()
로 얻은 주변 글들의 배열 surrounds
를 순회하며 <PostMoveCard>
에 데이터를 전달해 렌더링할 때, 조건부 렌더링을 통해 null 값이면 <PostMoveCard>
를 렌더링하지 않는다.
추가로 <PostMoveCard>
에 전달할 데이터 형식을 변경했다. constants 디렉터리에 postDirection.js
를 생성해, 글 이동 관련 상수를 분리했으며, post 페이지와 <PostMoveCard>
에서 이용하도록 했다.
원래는 컴포넌트 리팩터링에서 모든 컴포넌트를 리팩터링하려 했지만, post 페이지와 너무 밀접해 <PostMoveCard>
컴포넌트를 이번 리팩터링에서 해버렸다.
constants/postDirection.js
export const PREVIOUS = 'previous';
export const NEXT = 'next';
export const PREVIOUS_POST_TEXT = '이전 포스트';
export const NEXT_POST_TEXT = '다음 포스트';
새롭게 추가한 작성한 글 이동 관련 상수 파일
PREVIOUS
,NEXT
: 현재 글에서 이동할 방향PREVIOUS_POST_TEXT
,NEXT_POST_TEXT
: 이동할 방향을 알리는 텍스트
components/PostMoveCard.vue
<template>
<div
class="w-full border rounded-xl py-2.5 px-3.5 cursor-pointer hover:ring-2 ring-emerald-400"
@click="movePost"
>
<div v-if="props.postDirection === -1" class="flex items-center gap-x-1.5">
<div class="md:w-2/12 flex justify-start">
<Icon name="icon-park:arrow-left" size="32" />
</div>
<div class="w-10/12">
<div class="flex justify-start text-sm font-semibold text-zinc-500">이전 포스트</div>
<div class="flex items-center">
<p class="m-0 text-lg font-bold text-zinc-700 truncate">
{{ pageData.title }}
</p>
</div>
</div>
</div>
<div v-else class="flex items-center justify-end gap-x-1.5">
<div class="w-10/12">
<div class="flex justify-end text-sm font-semibold text-zinc-500">이후 포스트</div>
<div class="flex justify-end items-center">
<p class="m-0 text-lg font-bold text-zinc-700 truncate">
{{ pageData.title }}
</p>
</div>
</div>
<div class="md:w-2/12 flex justify-end">
<Icon name="icon-park:arrow-right" size="32" />
</div>
</div>
</div>
</template>
<script setup>
import { usePostStore } from '~/store/postStore';
const props = defineProps({
postDirection: {
type: Number,
required: true,
},
pageNumber: {
type: Number,
default: 0,
},
dataKind: {
type: String,
default: 'voca',
},
});
const postStore = usePostStore();
const router = useRouter();
const pageData = ref(null);
pageData.value = postStore.pickPosts(props.dataKind)[props.pageNumber];
function movePost() {
router.push({ path: pageData.value._path });
}
</script>
리팩터링 전 PostMoveCard
컴포넌트
전달받은 props.postDirection
과 v-if
를 이용해, 값이 -1
인 경우 이전 글, 아닌 경우 다음 글 이동 관련 정보를 렌더링하고 있다.
HTML 코드에서 중복 코드가 너무 많으며, props.postDirection
가 -1
인 경우가 이전 글임을 한눈에 알아보기 어렵다.
또한, postStore
를 이용해 이동할 글에 대한 정보를 갖고 오고 있다. postStore
를 완전히 제거할 예정이므로, props로 필요한 정보를 받으려 한다.
<script setup>
import { PREVIOUS, PREVIOUS_POST_TEXT, NEXT_POST_TEXT } from '~/constants/postDirection';
const props = defineProps({
direction: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
const router = useRouter();
const iconName = computed(() =>
props.direction === PREVIOUS ? 'icon-park:arrow-left' : 'icon-park:arrow-right',
);
const directionText = computed(() =>
props.direction === PREVIOUS ? PREVIOUS_POST_TEXT : NEXT_POST_TEXT,
);
const directionJustify = computed(() =>
props.direction === PREVIOUS ? 'justify-start' : 'justify-end',
);
const directionFlexRow = computed(() =>
props.direction === PREVIOUS ? 'flex-row' : 'flex-row-reverse',
);
function movePost() {
router.push({ path: props.path });
}
</script>
<template>
<div
class="w-full border rounded-xl py-2.5 px-3.5 cursor-pointer hover:ring-2 ring-emerald-400"
@click="movePost"
>
<div class="flex items-center gap-x-5 md:gap-x-1.5" :class="directionFlexRow">
<div class="md:w-2/12 flex" :class="directionJustify">
<Icon :name="iconName" size="32" />
</div>
<div class="w-10/12">
<div class="flex text-sm font-semibold text-zinc-500" :class="directionJustify">
{{ directionText }}
</div>
<div class="flex items-center" :class="directionJustify">
<span class="text-lg font-bold text-zinc-700 truncate">{{ props.title }}</span>
</div>
</div>
</div>
</div>
</template>
리팩터링 후 PostMoveCard
컴포넌트
props 데이터 형식을 좀 더 간단하게 변경했다. postStore
를 이용하지 않기에 props.pageNumber
, props.dataKind
대신 props.path
와 props.title
을 추가했다.
props 데이터만으로 필요한 기능을 모두 수행할 수 있다. 따라서 이동할 글에 대한 모든 정보가 필요 없기에 반응형 변수 pageData
도 제거했다.
글 이동 관련 상수를 import 하여 명확하게 이전이나 다음 글을 가리킬지 나타낸다.
전달받은 이동 방향(props.direction
)으로 필요한 값들을 계산된 속성 computed로 구했으며, 이를 이용해 클래스 바인딩으로 중복 코드를 제거했다.
새로운 문제
리팩터링 중 새로운 문제가 발생했다.
md 사이즈(768px)보다 작을 때 detail 항목들을 CategoriesMenu
컴포넌트를 이용해 렌더링하고 있다. 다른 detail 항목을 클릭하면 해당 detail index 페이지로 이동하며, CategoriesMenu
가 제거된다. 하지만, CategoriesMenu
가 먼저 사라지고 detail index 페이지가 렌더링 되는 현상이 발생했다.
CategoriesMenu
컴포넌트가 먼저 사라지는 게 아닌, detail index 페이지가 변경된 뒤, CategoriesMenu
컴포넌트가 사라지도록 순서가 보장되길 원한다.
문제 해결
문제의 원인은 [category]/[detail]/index.vue
의 setup 단계에서 useAsyncData
를 통해 작성한 글들을 비동기적으로 가지고 오기 때문에 순서가 보장되지 않고, CategoriesMenu
컴포넌트가 먼저 제거되고, 비동기적으로 작성한 글들을 가지고 오는 작업이 끝난 뒤 페이지가 렌더링 되는 것이었다.
따라서 CategoriesMenu
에서 detail 항목을 클릭해도 컴포넌트를 제거하지 않고, [category]/[detail]/index.vue
에서 useAsyncData
를 마친 뒤, onMounted
훅에서 CategoriesMenu
를 제거하면 원하는 순서가 보장된다.
components/CategoriesMenu.vue
수정 부분
<!-- 변경 전 -->
<NuxtLink
class="link"
:to="path"
:class="{ link: path, link_active: active }"
@click="closeCategoriesMenu"
>
{{ category }}
</NuxtLink>
<!-- 변경 후 -->
<NuxtLink class="link" :to="path" :class="{ link: path, link_active: active }">
{{ category }}
</NuxtLink>
CategoriesMenu
컴포넌트에서 <NuxtLink>
컴포넌트 클릭 이벤트 @click="closeCategoriesMenu"
를 제거해 해당 컴포넌트에서 컴포넌트를 제거하지 않도록 한다.
pages/[category]/[detail]/index.vue
수정 부분
<script setup>
// ...
const closeCategoriesMenu = inject('closeCategoriesMenu');
onMounted(() => closeCategoriesMenu());
// ...
</script>
index 페이지에서 closeCategoriesMenu
를 주입 받아(inject
), useAsyncData
가 완료된 이후인 omMounted
훅에서 실행하여 원하는 순서를 보장한다.
다이어그램
리팩터링 후 페이지 디렉터리 관련 구조를 나타내는 다이어그램default.vue
(기본 레이아웃)에서 시작해 Nuxt pages와 하위 컴포넌트 CategoriesMenu로 흐르며, Nuxt pages에서 현재 route에 따라 어떤 페이지를 렌더링할지 동적으로 결정한다.
category/detail의 페이지들은 sideBar.vue
컴포넌트를 이용해 현재 route에 따라 sideBar.vue
레이아웃에서 NoteSideBar.vue
나 ProjectsSideBar.vue
중 하나의 렌더링을 동적으로 결정한다.
category/detail/index.vue
페이지는 default.vue
에서 제공한 CategoriesMenu.vue
컴포넌트 렌더링을 제거하는 함수 closeCategoriesMenu
를 주입 받아, onMounted 훅에서 사용해 CategoriesMenu.vue
컴포넌트를 지운다.
category/detail/post.vue
페이지는 현재 route에 따라 동적으로 작성한 글을 렌더링하며, 현재 글에서 이전 글에 대한 카드와 다음 글에 대한 카드를 렌더링한다.
마무리
이것으로 pages directory 관련 리팩터링을 마치겠다. 그 많던 페이지 파일들을 제거해 디렉터리가 가벼워졌다. 또, 나중에 새로운 category와 detail에 대한 글을 작성해도 페이지를 추가할 필요가 없어 확장성이 향상되었다.
리팩터링에서 새로운 문제가 발생했지만, 원인을 금방 찾을 수 있었다. 그만큼 관련 내용에 대한 이해가 늘었다고 좋게 생각하려 한다!