jomoo.dev 컴포넌트 디렉터리 리팩터링

2024년 1월 13일

intro

이번 컴포넌트 디렉터리 리팩터링으로 jomoo.dev 프로젝트의 리팩터링을 마치려 한다. 컴포넌트 디렉터리의 리팩터링은 복잡한 컴포넌트가 없어 큰 어려움 없어 보인다. 컴포넌트나 함수의 이름을 좀 더 적절하게 변경하거나, 가독성 향상을 위한 코드의 구조 변경을 중점으로 리팩터링하겠다.

리팩터링 전 components 디렉터리

before_directory

리팩터링 전 components 디렉터리, 어떻게 리팩터링할지 간단하게 설명하겠다.
우선 components/content 디렉터리에 있는 ProseCode.vue 컴포넌트는 nuxt-content의 내장된 Prose component를 덮어쓰기 위한 컴포넌트다. ProseCode.vue는 작성한 글(content)에서 코드 블록을 다루는 컴포넌트로, 코드 블록 안에 있는 코드 복사 기능을 추가하기 위해 생성했던 컴포넌트다. 해당 컴포넌트는 변경할 부분이 없다.

CategoriesMenu.vue 컴포너트는 jomoo.dev 리팩터링-1에서 추가했던 컴포넌트로 화면 넓이가 md 사이즈보다(768px미만) 작을 때 category를 보여주는 컴포넌트다. 이 컴포넌트 또한 변경할 부분은 없어 보인다.

ContentCard.vue 컴포넌트는 category/detail/index 페이지에서 detail 관련 글들을 카드 형태로 보여주기 위해 사용되는 컴포넌트다.
IndexContentCard.vue 컴포넌트는 메인 페이지에서 작성한 글들을 카드 형태로 보여주기 위해 사용되는 컴포넌트다.
일단 ContentCard.vueIndexContentCard.vue의 어디서 사용되는지 이름이 헷갈린다. 컴포넌트 이름을 좀 더 직관적으로 변경하겠다.

listIcon.vue md 사이즈보다 작을 때 기본 레이아웃 default.vue에서 사용하는 아이콘 컴포넌트다. 컴포넌트의 이름이 파스칼 케이스가 아니고, 현재 다른 아이콘들은 nuxt-icon을 이용하고 있다.
listIcon.vue는 nuxt-icon을 이용하기 전에 만들었던 컴포넌트다. 꼭 필요한 아이콘 컴포넌트가 아니기에, 해당 컴포넌트 파일은 삭제하고 nuxt-icon을 이용하겠다.

NoteSideBar.vue, ProjectsSideBar.vue 컴포넌트들은 sideBar.vue에서 현재 경로에 따라 동적으로 사용되는 컴포넌트들이다. 두 개의 컴포넌트를 사용할 필요없이 현재 경로에 맞게 데이터만 변경하는 식으로 리팩터링 하는 게 좋을 거 같다.

PostMoveCard.vue는 category/detail/post 페이지에서 이전 글이나 다음 글에 대한 정보를 보여주면서, 보여주는 글에 대한 링크 역할을 하는 컴포넌트다. 이전 글 jomoo.dev 리팩터링-3에서 함께 리팩터링했기에 컴포넌트의 이름만 변경하려 한다.

ContendCard.vueIndexContentCard.vue 리팩터링

처음에는 ContendCard.vueIndexContentCard.vue 컴포넌트의 이름만 직관적으로 변경하려 했었다. 하지만 두 컴포넌트가 렌더링하는 정보는 같기에 하나의 컴포넌트로 통합하기로 했다.

리팩터링 결과 PostCard.vue

constants/postCardSize.js
export const POST_CARD_SIZE = {
  small: 'small',
  wide: 'wide',
};
components/PostCard.vue
<script setup>
import { POST_CARD_SIZE } from '~/constants/postCardSize';

const props = defineProps({
  path: {
    type: String,
    required: true,
  },

  title: {
    type: String,
    required: true,
  },

  description: {
    type: String,
    required: true,
  },

  date: {
    type: String,
    required: true,
  },

  size: {
    type: String,
    required: true,
  },
});

const isPostCardSizeWide = computed(() => props.size === POST_CARD_SIZE.wide);

const containerClass = computed(() => {
  if (isPostCardSizeWide.value) {
    return 'border-b mb-2 max-h-[150px] min-h-[150px] px-1';
  }
  return 'border-2 border-stone-500 hover:border-4 hover:px-[18px] hover:py-[10px] min-h-[16rem] h-64 max-h-64 rounded-xl px-5';
});

const titleClass = computed(() => (isPostCardSizeWide.value ? 'md:text-3xl' : 'py-2.5'));

const descriptionClass = computed(() => {
  if (isPostCardSizeWide.value) {
    return 'text-lg truncate py-2.5';
  }
  return 'text-sm md:text-base';
});
</script>

<template>
  <div
    class="cursor-pointer flex flex-col py-3"
    :class="containerClass"
    @click="navigateTo(props.path)"
  >
    <div class="text-2xl font-bold truncate" :class="titleClass">
      {{ props.title }}
    </div>
    <div class="text-zinc-600 flex-1" :class="descriptionClass">
      {{ props.description }}
    </div>
    <div class="flex text-sm text-zinc-400" :class="{ 'justify-end': !isPostCardSizeWide }">
      {{ props.date }}
    </div>
  </div>
</template>

이전 ContendCard.vueIndexContentCard.vue 컴포넌트를 하나의 컴포넌트로 합친 PostCard.vue 컴포넌트
우선 작성한 글에 대한 정보를 담는 카드이므로, ContentCard 보다는 PostCard가 더 적합하다고 생각해 이름을 변경했다.

해당 컴포넌트는 메인 페이지와 각 category detail index 페이지에서 이용하는 컴포넌트다. 두 페이지에서 렌더링할 정보는 글의 제목, 설명, 작성한 날짜로 동일하며 css 값만 차이가 있다.
css 값만 다르게 하여 하나의 컴포넌트로 사용하기 위해 PostCard 컴포넌트의 사이즈를 상수로 저장해 props.size로 전달받은 값과 비교하여 동적으로 PostCard의 css 스타일을 결정한다.

현재 PostCard의 사이즈는 smallwide 두 개다. small은 메인 페이지에서, wide는 category detail index page에서 사용하는 사이즈다.
계산된 속성 isPostCardSizeWide는 렌더링할 PostCard가 wide인지 확인한다. 해당 값을 이용해, 필요한 tailwind css class 들을 반환하여, 동적으로 클래스 바인딩을 통해 스타일링한다.

PostCard.vue에서 고민했던 점

PostCard.vue 구현에 있어 <slot>과 동적 클래스 바인딩 중 어떤 방식이 좋을까 고민이 있었다.
처음에는 위 코드처럼 클래스 바인딩을 이용해 전달받은 사이즈에 따라 동적으로 스타일링했었다. 이 방법은 PostCard 컴포넌트를 이용하는 곳에서 원하는 사이즈만 전달하면 되기에 이용하는 곳(페이지 또는 상위 컴포넌트)에서 직관적이면서 간단하게 사용할 수 있다.

다른 방법으로, PostCard 컴포넌트의 특정 부분을 <slot>으로 구분해, PostCard 컴포넌트를 이용하는 곳에서 <slot>에 태그를 넣어 사용하는 방법도 있다. 이 방법은 이용하는 곳에서 필요한 코드를 삽입하기에 컴포넌트의 재사용성이 엄청나게 향상된다. 이후 프로젝트에서 새로운 사이즈의 카드가 필요해도 필요한 곳에서 <slot> 직접 에 삽입하기에 아무 문제가 없다. 또한 nuxt-ui 프로젝트의 코드에서도 <slot>을 많이 사용하는 것을 보니 점점 이 방법이 좋아 보였다.

어떤 방법이 더 좋을까 계속 고민하다가, <slot>을 과하게 사용하면 컴포넌트의 의미가 흐려진다고 생각이 들었다. 재사용하기 위해 코드를 분리했는데 컴포넌트에서 구역만 나누고, 페이지나 상위 컴포넌트에게 나머지 일을 다 맡겨 버리면 컴포넌트가 굳이 필요할까? 복잡한 구조가 필요하거나 특정 부분만 다른 정보를 렌더링하려 할 때, <slot>을 이용하는 것이 좋다고 생각한다.

PostCard 컴포넌트는 제목(header), 내용(body), 날짜(footer) 세 개로 나뉘는 간단한 구조이며, 같은 정보만을 렌더링한다. 단지, css class만 다를 뿐이다. 따라서 <slot>으로 재사용성을 늘리는 것보다, 동적으로 클래스 바인딩을 이용해 스타일링 하는 직관적인 방법이 더 좋은 거 같다.

컴포넌트에서 과도한 재사용성은 장점만 있는 것은 아니라고 생각한다.

listIcon.vue 리팩터링

listIcon.vue 컴포넌트를 삭제하고, 대신 nuxt-icon을 이용하도록 변경했다.

default.vue

layouts/default.vue
<template>
<!-- 변경 전 -->
  <div class="cursor-pointer" @click="toggleCategoriesMenu">
    <ListIcon />
  </div>
<!--  -->
</template>
layouts/default.vue
<template>
<!-- 변경 후 -->
  <div class="cursor-pointer" @click="toggleCategoriesMenu">
    <Icon name="mingcute:menu-line" size="32" />
  </div>
<!--  -->
</template>

NoteSideBar.vueProjectsSideBar.vue 리팩터링

두 컴포넌트를 모두 지웠다.
constants/categoriesDetail 상수 파일의 고정된 정보를 sideBar.vue 레이아웃에서 사용해 현재 경로에 따라 다른 category details 항목들을 렌더링하도록 변경했다.

레이아웃 sideBar.vue 변경

변경 전

layouts/sideBar.vue
<script setup>
const ProjectsSideBar = resolveComponent('ProjectsSideBar');
const NoteSideBar = resolveComponent('NoteSideBar');

const route = useRoute();
const { category } = route.params;
const sideBar = { projects: ProjectsSideBar, note: NoteSideBar };
</script>

<template>
  <!-- 생략 -->
    <div
      class="col-span-10 md:col-span-2 flex md:sticky top-[7rem] lg:top-[8.6rem] overflow-y-auto max-h-[calc(100vh-220px)] justify-between"
    >
      <component :is="sideBar[category]" />
    </div>
  <!-- 생략  -->
</template>

변경 후

layouts/sideBar.vue
<script setup>
import { CATEGORIES_DETAILS } from '~/constants/categoriesDetail';

const route = useRoute();
const { category, detail } = route.params;

const categoryDetailItems = CATEGORIES_DETAILS[category].map((categoryDetail) => {
  const active = categoryDetail.detail === detail;
  return { ...categoryDetail, active };
});
</script>

<template>
  <!-- 생략 -->
    <div
      class="col-span-10 md:col-span-2 flex md:sticky top-[7rem] lg:top-[8.6rem] overflow-y-auto max-h-[calc(100vh-220px)] justify-between"
    >
      <nav>
        <ul class="font-bold flex gap-x-4 md:block">
          <li v-for="{ path, text, active } in categoryDetailItems" :key="path">
            <NuxtLink :to="path">
              <div
                class="hover:text-emerald-500 py-1.5"
                :class="active ? 'text-emerald-600' : 'text-black'"
              >
                {{ text }}
              </div>
            </NuxtLink>
          </li>
        </ul>
      </nav>
    </div>
  <!-- 생략 -->
</template>

이전에는 현재 경로의 category를 이용해 ProjectsSideBar.vueNoteSideBar.vue 컴포넌트를 선택해 렌더링하는 방식이었다. 리팩터링 후에는 두 개의 컴포넌트를 사용하지 않는다.

constants/categoriesDetail에 위치한 category detail 관련 정보와 현재 경로의 category, detail을 조합한 결과를 sideBar.vue 레이아웃에서 직접 렌더링하도록 변경했다.
조합한 결과인, 현재 category의 detail 관련 정보 categoryDetailItems{ path, text, active } 형식의 객체를 담고 있는 배열이다.

  • path: 해당 category detail index 페이지의 주소
  • text: category detail을 나타내는 문자
  • active: 현재 경로의 category detail가 일치하는지 확인하는 boolean 타입의 값이다.

active 속성을 이용해 현재 경로와 일치하는 detail 항목만 다른 css 클래스를 갖게 하여, 방문 중인 detail 항목을 구분한다.

리팩터링 후 components 디렉터리

  • ContentCard.vue, IndexContentCard.vue 통합 => PostCard.vue
  • listIcon.vue 삭제
  • NoteSideBar.vue, ProjectsSideBar.vue 삭제
  • PostMoveCard.vue 이름 변경 => PostNavigationCard.vue

다이어그램

image 메인 페이지 index.vue와 각 카테고리 디테일 index 페이지 category/detail.index.vue에서 PostCard.vue 컴포넌트를 사용하고 있으며, PostCard 컴포넌트의 사이즈인 small, wide를 전달해 동적으로 스타일링하고 있다.

default.vue 레이아웃에서 CategoriesMenu.vue 컴포넌트를 사용한다.

각 카테고리 디테일 포스트 페이지 category/detail/post.vue에서 주변 글에 대한 정보와 네비게이션 역할을 하는 컴포넌트 PostNavigationCard.vue를 사용하며, beforeafter를 전달해 이전 글인지 다음 글인지에 대한 방향을 정한다.

경로 이동

추가로, 경로 이동에 있어 useRouter에 이동할 경로를 push 하는 방식을 이용했었는데, nuxt 홈페이지에서 navegateTo를 이용한 방법을 권고해서 변경했다.

useRouter 관련 nuxt 홈페이지

push(): Programmatically navigate to a new URL by pushing an entry in the history stack. It is recommended to use navigateTo instead.
replace(): Programmatically navigate to a new URL by replacing the current entry in the routes history stack. It is recommended to use navigateTo instead.

마무리

리팩터링 자체는 간단했는데 '어떤 방법이 더 좋을까'에 대한 고민이 계속되어 오래 걸렸다. 어떤 방법이든 무조건 제일 좋다는 없고, 장단점이 있기에 선택하기 참 어렵다. 잘하는 사람한테 이럴 때는 어떤 방법이 더 좋을지 질문하고 싶다.

이것으로 jomoodev 블로그 프로젝트의 리팩토링을 마치겠다. 다음부터는 메인 페이지 변경과 새로운 기능을 추가하려고 한다.


댓글