jomoo.dev 페이지 디렉터리 리팩터링

2024년 1월 10일

intro

이번 리팩터링은 papes directory 관련 코드들을 리팩터링하려 한다.

before_pages

현재 pages directory 구조, detail(programmers, js, jomoodev 등) 항목 당 [post].vue 페이지와 index.vue 페이지가 있다. category에 따른 detail sideBar와 detail에 따른 post(작성한 글)만 다르고, 나머지는 중복되는 코드다.
따라서 현재 route에 따라 sideBar와 post를 가지고 오는 형식으로 변경하려 한다.

리팩터링 후 pages 디렉터리

after_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

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.paramscategorydetail을 이용해 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

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

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

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.postDirectionv-if를 이용해, 값이 -1인 경우 이전 글, 아닌 경우 다음 글 이동 관련 정보를 렌더링하고 있다.

HTML 코드에서 중복 코드가 너무 많으며, props.postDirection-1인 경우가 이전 글임을 한눈에 알아보기 어렵다.
또한, postStore를 이용해 이동할 글에 대한 정보를 갖고 오고 있다. postStore를 완전히 제거할 예정이므로, props로 필요한 정보를 받으려 한다.

components/PostMoveCard.vue
<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.pathprops.title을 추가했다.
props 데이터만으로 필요한 기능을 모두 수행할 수 있다. 따라서 이동할 글에 대한 모든 정보가 필요 없기에 반응형 변수 pageData도 제거했다.

글 이동 관련 상수를 import 하여 명확하게 이전이나 다음 글을 가리킬지 나타낸다.
전달받은 이동 방향(props.direction)으로 필요한 값들을 계산된 속성 computed로 구했으며, 이를 이용해 클래스 바인딩으로 중복 코드를 제거했다.

새로운 문제

리팩터링 중 새로운 문제가 발생했다.
md 사이즈(768px)보다 작을 때 detail 항목들을 CategoriesMenu 컴포넌트를 이용해 렌더링하고 있다. 다른 detail 항목을 클릭하면 해당 detail index 페이지로 이동하며, CategoriesMenu가 제거된다. 하지만, CategoriesMenu가 먼저 사라지고 detail index 페이지가 렌더링 되는 현상이 발생했다. 2024-01-115 43 54-ezgif com-video-to-gif-converter

CategoriesMenu 컴포넌트가 먼저 사라지는 게 아닌, detail index 페이지가 변경된 뒤, CategoriesMenu 컴포넌트가 사라지도록 순서가 보장되길 원한다.

문제 해결

문제의 원인은 [category]/[detail]/index.vue의 setup 단계에서 useAsyncData를 통해 작성한 글들을 비동기적으로 가지고 오기 때문에 순서가 보장되지 않고, CategoriesMenu 컴포넌트가 먼저 제거되고, 비동기적으로 작성한 글들을 가지고 오는 작업이 끝난 뒤 페이지가 렌더링 되는 것이었다.
따라서 CategoriesMenu에서 detail 항목을 클릭해도 컴포넌트를 제거하지 않고, [category]/[detail]/index.vue에서 useAsyncData를 마친 뒤, onMounted 훅에서 CategoriesMenu를 제거하면 원하는 순서가 보장된다.

components/CategoriesMenu.vue 수정 부분

components/CategoriesMenu.vue
<!-- 변경 전 -->
<NuxtLink
  class="link"
  :to="path"
  :class="{ link: path, link_active: active }"
  @click="closeCategoriesMenu"
>
  {{ category }}
</NuxtLink>
components/CategoriesMenu.vue
<!-- 변경 후 -->
<NuxtLink class="link" :to="path" :class="{ link: path, link_active: active }">
  {{ category }}
</NuxtLink>

CategoriesMenu 컴포넌트에서 <NuxtLink> 컴포넌트 클릭 이벤트 @click="closeCategoriesMenu"를 제거해 해당 컴포넌트에서 컴포넌트를 제거하지 않도록 한다.

pages/[category]/[detail]/index.vue 수정 부분

pages/[category]/[detail]/index.vue
<script setup>
// ...
const closeCategoriesMenu = inject('closeCategoriesMenu');

onMounted(() => closeCategoriesMenu());
// ...
</script>

index 페이지에서 closeCategoriesMenu를 주입 받아(inject), useAsyncData가 완료된 이후인 omMounted 훅에서 실행하여 원하는 순서를 보장한다.

다이어그램

image 리팩터링 후 페이지 디렉터리 관련 구조를 나타내는 다이어그램
default.vue(기본 레이아웃)에서 시작해 Nuxt pages와 하위 컴포넌트 CategoriesMenu로 흐르며, Nuxt pages에서 현재 route에 따라 어떤 페이지를 렌더링할지 동적으로 결정한다.
category/detail의 페이지들은 sideBar.vue 컴포넌트를 이용해 현재 route에 따라 sideBar.vue 레이아웃에서 NoteSideBar.vueProjectsSideBar.vue 중 하나의 렌더링을 동적으로 결정한다.

category/detail/index.vue 페이지는 default.vue에서 제공한 CategoriesMenu.vue 컴포넌트 렌더링을 제거하는 함수 closeCategoriesMenu를 주입 받아, onMounted 훅에서 사용해 CategoriesMenu.vue 컴포넌트를 지운다.

category/detail/post.vue 페이지는 현재 route에 따라 동적으로 작성한 글을 렌더링하며, 현재 글에서 이전 글에 대한 카드와 다음 글에 대한 카드를 렌더링한다.

마무리

이것으로 pages directory 관련 리팩터링을 마치겠다. 그 많던 페이지 파일들을 제거해 디렉터리가 가벼워졌다. 또, 나중에 새로운 category와 detail에 대한 글을 작성해도 페이지를 추가할 필요가 없어 확장성이 향상되었다.

리팩터링에서 새로운 문제가 발생했지만, 원인을 금방 찾을 수 있었다. 그만큼 관련 내용에 대한 이해가 늘었다고 좋게 생각하려 한다!


댓글