jomoo.dev 리팩터링- 1

2023년 12월 25일

intro

이전 단어장 프로젝트의 리팩터링을 해보니, 리팩터링에 재미가 붙어 jomoo.dev 프로젝트도 리팩터링하려 한다. 이전에 기능 구현만을 우선했기에 대충 훑어만 봐도 리팩터링할게 많아 보인다.
일단 pinia store의 관련 코드부터 리팩터링하려 한다.

이전 pinia store 구조

structure_before

어떤 카테고리를 띄우는지 나타내는 상태와, 모달 상태를 다루는 mainState.js와 작성한 포스트들의 상태를 관리하는 postData.js 두 개의 저장소가 있다. 저장소의 구조와 이름 등을 변경하려 한다.
일단 mainState.js부터 리팩터링 하겠다.

mainState.js

store/mainState.js
import { defineStore } from 'pinia';

export const useMainStateStore = defineStore('mainState', {
  state: () => ({
    defaultLayoutIdx: 0,
    modalCheck: false,
  }),
});

리팩터링 전 mainState 저장소
해당 프로젝트에 pinia store는 option store의 형태다. pinia를 몇 번 사용하다 보니, option store보다는 setup store 형태가 더 사용하기 편했다. setup store가 vue의 composition api와 script setup 방식과 유사해 이질감이 적기 때문인 거 같다.
따라서 pinia store의 문법을 setup store로 변경했다.

또한, 현재 해당 스토어에서는 상태만을 저장하고, 스토어의 상태를 외부에서 다루게 한다.
외부에서는 스토어의 상태를 함수 호출만을 이용해 간접적으로 변경시키고, 스토어 내부에서 직접 상태를 변경하는 것이 상태에 대한 책임을 집중시키는 것이 예측 가능성 및 유지 보수성도 향상된다고 생각한다. 따라서 상태에 대한 조작을 스토어에서 하도록 변경했다.

마지막으로, 카테고리 상태와 modal 상태는 서로 다른 기능이기 때문에 기능에 따라 스토어를 분리하는 게 적합하다고 생각한다. 카테고리와 관련된 스토어를 categoriesStore.js로, modal과 관련된 기능은 컴포저블 함수 useCategoriesMenuControls.js로 분리했다.

default.vue

layouts/default.vue
<template>
  <div class="max-h-full">
    <div class="fixed top-0 z-20 w-full bg-white">
      <header class="border-b-[1px] border-b-gray-300">
        <div class="mx-auto max-w-7xl px-6 sm:px-4 lg:px-8 relative w-full">
          <nav
            class="grid grid-cols-6 min-h-16 max-h-20 items-center h-16 lg:h-20 justify-center"
          >
            <div
              class="col-span-1 flex justify-start cursor-pointer md:hidden"
              @click="hiddenMenuOperation"
            >
              <ListIcon />
            </div>
            <div
              class="flex justify-center md:justify-start col-span-4 md:col-span-1"
            >
              <div class="text-2xl font-extrabold text-stone-800">
                <NuxtLink to="/" @click="home()">JOMOO.DEV</NuxtLink>
              </div>
            </div>
            <ul
              class="hidden md:flex justify-center gap-x-10 col-span-4 font-semibold"
            >
              <li>
                <NuxtLink to="/note/programmers">
                  <div
                    class="flex p-1.5 text-zinc-600 border-b-[2px] border-b-white hover:text-emerald-500"
                    :class="mainStore.defaultLayoutIdx === 1 ? 'link' : ''"
                    @click="menuSelectNote()"
                  >
                    Note
                  </div>
                </NuxtLink>
              </li>
              <li>
                <NuxtLink to="/projects/vocabularynote">
                  <div
                    class="flex p-1.5 text-zinc-600 border-b-[2px] border-b-white hover:text-emerald-500"
                    :class="mainStore.defaultLayoutIdx === 2 ? 'link' : ''"
                    @click="menuSelectProjects()"
                  >
                    Projects
                  </div>
                </NuxtLink>
              </li>
              <li>
                <div class="p-1.5 cursor-not-allowed text-zinc-600">etc</div>
              </li>
            </ul>
          </nav>
        </div>
      </header>
    </div>
    <div class="mt-16 pt-2.5 md:mt-32">
      <slot />
    </div>
    <Teleport to="body">
      <div
        v-if="mainStore.modalCheck === true"
        class="fixed inset-0 z-[999] mt-16 w-full bg-white"
      >
        <div class="flex pt-2.5">
          <ul
            class="flex-col px-4 justify-center gap-x-10 col-span-4 font-semibold"
          >
            <li>
              <NuxtLink to="/note/programmers">
                <div
                  class="flex p-1.5 text-zinc-600 hover:text-emerald-500"
                  :class="mainStore.defaultLayoutIdx === 1 ? 'link_md' : ''"
                  @click="menuSelectNote()"
                >
                  Note
                </div>
              </NuxtLink>
            </li>
            <li>
              <NuxtLink to="/projects/vocabularynote">
                <div
                  class="flex p-1.5 text-zinc-600 hover:text-emerald-500"
                  :class="mainStore.defaultLayoutIdx === 2 ? 'link_md' : ''"
                  @click="menuSelectProjects()"
                >
                  Projects
                </div>
              </NuxtLink>
            </li>
            <li class="p-1.5 cursor-not-allowed text-zinc-600">etc</li>
          </ul>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { useMainStateStore } from '~~/store/mainState';

const mainStore = useMainStateStore();
const route = useRoute();
const routes = route.path.split('/');

if (routes[1] === 'note') {
  mainStore.defaultLayoutIdx = 1;
} else if (routes[1] === 'projects') {
  mainStore.defaultLayoutIdx = 2;
}

function overflowYRemove() {
  mainStore.modalCheck = false;
  if (process.client) {
    document.body.classList.remove('overflow-y-hidden');
  }
}

function home() {
  mainStore.defaultLayoutIdx = 0;
  overflowYRemove();
}

function menuSelectNote() {
  mainStore.defaultLayoutIdx = 1;
  overflowYRemove();
}

function menuSelectProjects() {
  mainStore.defaultLayoutIdx = 2;
  overflowYRemove();
}

function hiddenMenuOperation() {
  if (mainStore.modalCheck === true) {
    overflowYRemove();
  } else {
    mainStore.modalCheck = true;
    if (process.client) {
      document.body.classList.add('overflow-y-hidden');
    }
  }
}
</script>
<style scoped>
.link {
  border-bottom-width: 2px;
  --tw-border-opacity: 1;
  border-bottom-color: rgb(5 150 105 / var(--tw-border-opacity));
  color: #047857;
}
.link_md {
  color: #047857;
}
</style>

리팩터링 전, 기본 레이아웃 컴포넌트인 default.vue
해당 컴포넌트 파일의 코드가 길어 읽기 싫기에, 분리할 수 있는 기능들을 분리하려 한다. 가볍게 코드를 봤을 때, 화면 크기가 작을 때(width가 768px 보다 작을 때) 렌더링되는 <Teleport>로 감싸진 부분은 따로 분리하기 쉬울 거 같다.
또, script setup 내 함수들을 컴포저블 함수로 분리할 수 있을 거 같다.

리팩터링 후 코드

이번 리팩터링에 있어 가장 어려웠던 점은 기능들을 분리했을 때 파일, 함수, 변수들의 이름을 적절하게 짓는 것이었다. 처음에 이름을 대충 짓고, 코딩할 때는 괜찮았는데 다음날이 되니 이름들이 비슷하고 헷갈려 시간을 많이 허비했다. 다이어그램을 통해 확실하게 이름을 정하고 코딩을 진행하여 쓸데없는 시간 소비를 줄여야겠다.

카테고리 상태 관련 리팩터링 코드

constants/categories.js

constants/categories.js
export const HOME = 'home';
export const NOTE = 'note';
export const PROJECTS = 'projects';

categoriesStore.js

store/categoriesStore.js
import { defineStore } from 'pinia';
import { HOME, PROJECTS, NOTE } from '../constants/categories';

export const useCategoriesStore = defineStore('categories', () => {
  const categoriesState = ref(HOME);

  const isSelectedNote = computed(() => categoriesState.value === NOTE);
  const isSelectedProjects = computed(() => categoriesState.value === PROJECTS);
  const activeCategories = computed(() => {
    return [
      { active: isSelectedNote.value, category: NOTE, path: '/note/programmers' },
      { active: isSelectedProjects.value, category: PROJECTS, path: '/projects/vocabularynote' },
    ];
  });

  function selectHome() {
    categoriesState.value = HOME;
  }

  function selectNote() {
    categoriesState.value = NOTE;
  }

  function selectProject() {
    categoriesState.value = PROJECTS;
  }

  return {
    categoriesState,
    isSelectedNote,
    isSelectedProjects,
    activeCategories,
    selectHome,
    selectNote,
    selectProject,
  };
});

middleware/categories.global.js

middleware/categories.global.js
import { useCategoriesStore } from '~~/store/categoriesStore';
import { NOTE, PROJECTS } from '../constants/categories';

export default defineNuxtRouteMiddleware((to) => {
  const categoriesStore = useCategoriesStore();
  const { selectHome, selectNote, selectProject } = categoriesStore;
  const category = to.path.split('/')[1];

  if (category === '') {
    selectHome();
  } else if (category === NOTE) {
    selectNote();
  } else if (category === PROJECTS) {
    selectProject();
  }
});

불필요한 중복 코드를 없애기 위해, 카테고리를 하나의 상수 파일로 분리해 여러 곳에서 사용할 수 있도록 변경했다. 또한, categoriesStore의 계산된 속성 activeCategories를 이용해 html 코드에서 중복을 줄였다.
위 pinia 스토어(categoriesStore.js)는 전역적으로, 현재 카테고리의 상태를 관리한다.

categories.global.js middleware는 해당 프로젝트에 새롭게 추가한 기능으로, 특정 경로로 이동하기 전에 코드가 실행되는 nuxt의 디렉터리다.
리팩터링 처음에 현재 경로에 따라 카테고리의 상태를 정하는 기능을 어디에 둬야 좋을지 고민이 많았다. 이전처럼 default.vue에서 해당 기능을 하기에는 분리된 컴포넌트와의 전달 흐름이 생겨 끌리지 않았고, 컴포저블 함수로 분리하는 것도 같은 맥락으로 마음에 들지 않았다.
그러다가 nuxt middleware 디렉터리가 생각이 났다. 경로에 따라 상태를 정하는 로직이기에 적합하다고 생각해 추가해보니, 컴포넌트 간의 추가적인 흐름 없이 필요한 로직을 가장 깔끔하게 해결할 수 있는 좋은 방법이었다.

위 middleware 파일은 글로벌 미들웨어로 모든 경로 이동에서 실행된다. 이동할 경로 to에서 category를 분리해 category에 따라 categoriesStore의 함수를 호출해 카테고리 상태를 변경한다.


image

위 다이어그램은, 지금까지의 리팩터링 후 실행 흐름이다. 이번 리팩터링부터는 실행 흐름과 변수, 함수, 파일 등의 이름을 파악하기 위해 간단한 다이어그램을 그리면서 하려고한다.

다이어그램을 설명해보면,

  1. middleware인 categories.global.js가 경로가 변경될 때마다 이동할 경로에 따라 categoriesStore의 함수를 호출한다.
  2. categoriesStore에서 이동할 경로에 따라, 현재 선택한 카테고리의 상태를 변경한다.
  3. 레이아웃 컴포넌트인 default.vue에서 categoriesStore에 저장된 상태를 이용해 필요한 작업을 한다.

모달(카테고리 메뉴)관련 코드

useCategoriesMenuControls.js

composables/useCategoriesMenuControls.js
function useCategoriesMenuControls(targetSize) {
  const categoriesMenuOpenState = ref(false);
  const { isSreenUnderTargetSize } = useCheckScreenSize(targetSize);

  const isCategoriesMenuOpenOnTargetSize = computed(
    () => categoriesMenuOpenState.value && isSreenUnderTargetSize.value,
  );

  function openCategoriesMenu() {
    categoriesMenuOpenState.value = true;
  }

  function closeCategoriesMenu() {
    categoriesMenuOpenState.value = false;
  }

  function toggleCategoriesMenu() {
    categoriesMenuOpenState.value = !categoriesMenuOpenState.value;
  }

  return {
    categoriesMenuOpenState,
    isCategoriesMenuOpenOnTargetSize,
    openCategoriesMenu,
    closeCategoriesMenu,
    toggleCategoriesMenu,
  };
}

export default useCategoriesMenuControls;

이전 mainState 스토어에 있던 모달 상태를 컴포저블 함수로 분리한 코드다. 모달보다 categoriesMenu로 부르는 게 더 적합하다고 생각해 이름을 지었다.
해당 컴포저블 함수 주로 default.vue에서 사용하며, default.vue의 자식 컴포넌트(CategoriesMenu.vue)에서는 창을 닫는 기능만을 필요로 한다.
상태 공유 및 복잡한 흐름도 필요하지 않기에, 컴포저블 함수로 분리했으며, provide, inject을 통해 자식 컴포넌트에 closeCategoriesMenu()만을 주입해(inject) categoriesMenu를 닫을 수 있게 했다.

해당 컴포저블 함수에서 새롭게 추가한 또다른 컴포저블 함수 useCheckScreenSizeisSreenUnderTargetSize 상태를 얻어, categoriesMenuOpenState와 함께 이용하여, isCategoriesMenuOpenOnTargetSize을 상태 값을 계산한다. 해당 상태 값에 따라 categoriesMenu를 렌더링할지 결정한다. useCheckScreenSize 함수의 자세한 내용은 아래에서 설명하겠다.

CategoriesMenu.vue

components/CategoriesMenu.vue
<script setup>
import { storeToRefs } from 'pinia';
import { useCategoriesStore } from '~~/store/categoriesStore';

const categoriesStore = useCategoriesStore();
const { activeCategories } = storeToRefs(categoriesStore);
const closeCategoriesMenu = inject('closeCategoriesMenu');

onMounted(() => document.documentElement.classList.add('overflow-y-hidden'));
onUnmounted(() => document.documentElement.classList.remove('overflow-y-hidden'));
</script>

<template>
  <div class="fixed inset-0 z-[999] top-[65px] pt-3 w-full bg-white">
    <nav class="flex flex-col px-4 gap-y-px col-span-4 font-semibold">
      <div v-for="{ path, active, category } in activeCategories" :key="category" class="flex">
        <NuxtLink
          class="link"
          :to="path"
          :class="{ link: path, link_active: active }"
          @click="closeCategoriesMenu"
        >
          {{ category }}
        </NuxtLink>
      </div>
      <div class="flex p-1.5 cursor-not-allowed text-zinc-600 select-none">etc</div>
    </nav>
  </div>
</template>

<style scoped>
.link {
  @apply flex p-1.5 text-zinc-600 hover:text-emerald-500 select-none;
}
.link_active {
  color: #047857;
}
</style>

이전 default.vue의 Teleport 이후 부분을 분리한 컴포넌트
해당 컴포넌트는 모바일 사이즈(width: 768 미만)일 때 카테고리 메뉴를 보여주는 컴포넌트다.
이전 default.vue에서 모바일 사이즈 카테고리 메뉴를 렌더링할 때, overflowYRemove()hiddenMenuOperation() 함수들을 통해 카테고리 메뉴 창을 화면에 고정했었다. 해당 기능을 CategoriesMenu.vue가 마운트될 때 document의 클래스에 overflow-y-hidden을 직접 추가하고, 컴포넌트가 언마운트될 때 지우도록 분리했다.
default.vue에서 버튼을 클릭하여 CategoriesMenu.vue를 렌더링할 때 해당 로직을 실행하는 것보다, CategoriesMenu.vue에서 해당 기능에 대한 책임을 갖고 실행하는 게 훨씬 깔끔하고 이해하기 쉬운 거 같다.

그 외에는 categoriesStoreactiveCategoriesv-for를 이용해 카테고리를 렌더링해, html 코드를 줄였고, 메뉴(<NuxtLink>)를 클릭할 때 주입받은 closeCategoriesMenu를 실행하여, categoriesMenuOpenStatefalse로 변경시켜 해당 컴포넌트를 dom에서 제거되게 한다.

default.vue

layouts/default.vue
<script setup>
import { storeToRefs } from 'pinia';
import { useCategoriesStore } from '~~/store/categoriesStore';

const TARGET_SIZE = 768;
const categoriesStore = useCategoriesStore();
const { activeCategories } = storeToRefs(categoriesStore);
const { isCategoriesMenuOpenOnTargetSize, toggleCategoriesMenu, closeCategoriesMenu } =
  useCategoriesMenuControls(TARGET_SIZE);

provide('closeCategoriesMenu', closeCategoriesMenu);
</script>

<template>
  <div>
    <div class="sticky top-0 z-20 w-full bg-white">
      <header class="border-b-[1px] border-b-gray-300">
        <nav
          class="grid grid-cols-6 min-h-16 max-h-20 items-center h-16 lg:h-20 justify-center mx-auto max-w-7xl px-6 sm:px-4 lg:px-8 relative w-full"
        >
          <div class="col-span-1 flex justify-start md:hidden">
            <div class="cursor-pointer" @click="toggleCategoriesMenu">
              <ListIcon />
            </div>
          </div>
          <div class="flex justify-center md:justify-start col-span-4 md:col-span-1">
            <NuxtLink
              to="/"
              class="select-none text-2xl font-extrabold text-stone-800"
              @click="closeCategoriesMenu"
            >
              JOMOO.DEV
            </NuxtLink>
          </div>
          <ul class="hidden md:flex px-4 justify-center gap-x-10 col-span-4 font-semibold">
            <li v-for="{ path, active, category } in activeCategories" :key="category" class="p-1">
              <NuxtLink :to="path" class="link" :class="{ link_active: active }">
                {{ category }}
              </NuxtLink>
            </li>
            <li class="p-1">
              <span class="flex p-1.5 cursor-not-allowed text-zinc-600 select-none">etc</span>
            </li>
          </ul>
        </nav>
      </header>
    </div>
    <main class="pt-5 md:pt-8 min-h-[calc(100vh-65px)]">
      <slot />
    </main>
    <footer class="mt-16 h-10 border-t-[1px] border-t-gray-300">
      <div></div>
    </footer>
    <Teleport to="body">
      <CategoriesMenu v-if="isCategoriesMenuOpenOnTargetSize" />
    </Teleport>
  </div>
</template>

<style scoped>
.link {
  @apply flex p-1.5 text-zinc-600 hover:text-emerald-500 select-none;
}
.link_active {
  border-bottom-width: 2px;
  padding-bottom: 4px;
  --tw-border-opacity: 1;
  border-bottom-color: rgb(5 150 105 / var(--tw-border-opacity));
  color: #047857;
}
</style>

리팩터링 후 default.vue, 이전에 있던 함수들을 다 분리해 훨씬 보기 좋아졌다고 생각한다.
CategoriesMenu.vue에서 카테고리를 렌더링했던 거와 같게 categoriesStore의 계산된 속성 activeCategories 이용해 카테고리들을 렌더링하며, 컴포저블 함수 useCategoriesMenuControls를 통해 CategoriesMenu.vue 렌더링을 결정한다.

해당 컴포넌트에서 새롭게 추가된 기능이 있다. 컴포저블 함수 useCheckScreenSize에 인수로 전달한 스크린 사이즈보다 작은지 확인하여, categoriesMenuOpenState와 함께 조합해 CategoriesMenu.vue를 조건부 렌더링한다.

해당 로직은 모바일 사이즈일 때 CategoriesMenu.vue가 렌더링 돼 있으면 화면 사이즈를 변경해도 CategoriesMenu.vue가 그대로 렌더링 돼 있는 걸 방지해준다.
css display: none으로 컴포넌트를 숨길 수는 있지만 CategoriesMenu.vue가 마운트될 때 document 클래스에 추가한 overflow-y-hidden이 존재하기 때문에 화면이 스크롤 되지 않는다. CategoriesMenu.vue에서 언마운트될 때 document에서 overflow-y-hidden을 지우는 로직이 있으므로, CategoriesMenu.vue가 언마운트돼야 overflow-y-hidden이 지워진다.

해당 로직을 추가해야, 모바일 사이즈일 때 CategoriesMenu.vue가 렌더링 되어 있어도, 화면 사이즈를 늘리면, CategoriesMenu.vue가 제거되고 정상적으로 화면을 스크롤 할 수 있다.

useCheckScreenSize.js

composables/useCheckScreenSize.js
function useCheckScreenSize(targetSize) {
  const isSreenUnderTargetSize = ref(null);

  function checkScreenSize() {
    isSreenUnderTargetSize.value = window.innerWidth < targetSize;
  }

  onMounted(() => {
    checkScreenSize();
    window.addEventListener('resize', checkScreenSize);
  });

  onUnmounted(() => window.removeEventListener('resize', checkScreenSize));

  return { isSreenUnderTargetSize };
}

export default useCheckScreenSize;

바로 위에서 설명한 기능을 하는 컴포저블 함수
매개변수, categoriesMenu를 렌더링할 수 있는 최대 화면 크기 조건 targetSize와 현재 화면 크기 window.innerWidth와 비교해, "현재 화면이 조건 크기(targetSize)보다 더 작은지"를 화면 크기가 변경될 때마다 확인한다.
onMounted() 훅에서 checkScreenSize 함수를 한 번 호출하는 이유는 현재 화면 사이즈에서 조건에 맞는지 확인해야 하기 때문이다. 'resize'는 말 그대로 사이즈가 변경될 때만 동작하는 이벤트다.

imageCategoriesMenu.vue와 관련 기능들이 추가된 후의 다이어그램
컴포저블 함수 useCheckScreenSize.js에서 화면 사이즈 조건을 useCategoriesMenuControls.js에 전달하여 카테고리 메뉴 오픈 상태를 계산해, default.vue에 전한다. 카테고리 오픈 상태가 true일 경우 CategoriesMenu.vue를 렌더링하며, CategoriesMenu.vue에서 categoriesStore의 상태를 이용해 카테고리를 화면에 렌더링한다.

마무리

재밌었던 리팩터링이었다. 사용하지 않았던 middleware 디렉터리도 사용해 봤으며, 기능을 분리하는 것도 이전보다 유연하게 할 수 있었다.
또한, 다이어그램을 사용해 구조와 흐름을 정하고 진행하는 것이 기억에도 오래 남고 실수도 방지할 수 있었다. 이후 코딩에서도 이용하는 것이 좋을 거 같다.
다음 리팩터링은 postData 스토어 관련 코드를 리팩터링하려 한다.


댓글