단어장 리팩터링 - 3

2023년 12월 15일

intro

현재 pinia store인 Main.js에서 로컬 스토리지의 저장된 단어들의 상태와 그 단어들을 다루는 모든 기능들이 몰려 있어 가독성 및 모듈성이 떨어진다. 이것을 해결하는 것이 이번 리팩터링 목표다.

리팩터링 전 mainStore 코드

stores/mainStore.js
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
export const useMainStore = defineStore('main', () => {
  // 휴지통, 메인화면 전환 0: 메인, 1: 휴지통
  const screenTransition = ref(0);

  const localWords = useStorage('mapWords', new Map(), localStorage);
  const localTrashCan = useStorage('trashCan', new Map(), localStorage);
  const localRecentSearchWords = useStorage('recentWords', [], localStorage);

  const wordArr = ref([]);

  function getTimeAndTimestamp() {
    const date = new Date();
    const nowTime = `${date.getFullYear()}-${
      date.getMonth() + 1
    }-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
    const timestamp = date.getTime();
    return { date, nowTime, timestamp };
  }
  function getTimeAndTimestampAfterDay(day) {
    const { date, nowTime, timestamp } = getTimeAndTimestamp();
    const afterDate = new Date(date.setDate(date.getDate() + day));
    const afterTimestamp = afterDate.getTime();
    const afterTime = `${afterDate.getFullYear()}-${
      afterDate.getMonth() + 1
    }-${afterDate.getDate()} ${afterDate.getHours()}:${afterDate.getMinutes()}`;
    return { nowTime, timestamp, afterTime, afterTimestamp };
  }
  function wordArrUpdate() {
    const localWordsToArr = Array.from(localWords.value, (item) => {
      return { ...item[1] };
    });
    wordArr.value = [...localWordsToArr].reverse();
  }
  // init: localStorage에 있는 단어들 set
  function setWordDic() {
    wordArrUpdate();
    // 휴지통 갱신
    const { timestamp } = getTimeAndTimestamp();
    for (const [key, value] of localTrashCan.value) {
      if (value.afterTimestamp <= timestamp) {
        localTrashCan.value.delete(key);
      }
    }
  }
  // word add
  function wordAdd(word, means) {
    const { nowTime, timestamp } = getTimeAndTimestamp();
    const item = {
      word,
      timestamp,
      means: means.toString(),
      time: nowTime,
      check: false,
    };
    // 이미 단어가 존재하면 지웠다가 저장
    if (localWords.value.has(word)) {
      localWords.value.delete(word);
    }
    localWords.value.set(word, item);
    wordArrUpdate();
  }
  // word check
  function wordCheck(targetWord, ch, index) {
    wordArr.value[index].check = ch;
    const checkedWord = localWords.value.get(targetWord);
    checkedWord.check = ch;
    localWords.value.set(targetWord, checkedWord);
  }

  // word delete
  function wordDelete(targetWord) {
    const { nowTime, timestamp, afterTime, afterTimestamp} = getTimeAndTimestampAfterDay(14);
    const deleteWord = {
      timestamp,
      afterTimestamp,
      afterTime,
      word: targetWord,
      means: localWords.value.get(targetWord).means,
      time: nowTime,
    };
    localWords.value.delete(targetWord);
    wordArrUpdate();
    // 휴지통에 이미 존재하는 단어면 지운뒤 저장
    if (localTrashCan.value.has(targetWord)) {
      localTrashCan.value.delete(targetWord);
    }
    localTrashCan.value.set(targetWord, deleteWord);
  }
  // 화면간 단어 <-> 휴지통 교체
  function contentChange() {
    screenTransition.value = screenTransition.value === 0 ? 1 : 0;
  }
  // word detail
  function wordDetail(targetWord) {
    const { word, means, time } = localWords.value.get(targetWord);
    const detailWord = { word, means: means.split(','), time };
    return detailWord;
  }
  // trashCan word detail
  function trashCanWordDetail(targetWord) {
    const { word, means, afterTime, time } =
      localTrashCan.value.get(targetWord);
    const detailTrashCanWord = {
      means: means.split(','),
      word,
      time,
      afterTime,
    };
    return detailTrashCanWord;
  }
  // trashCan word kill
  function trashCanWordKill(targetWord) {
    localTrashCan.value.delete(targetWord);
  }
  // trashCan word restore
  function trashCanWordRestore(targetWord) {
    const { word, means } = localTrashCan.value.get(targetWord);
    wordAdd(word, means);
    trashCanWordKill(word);
  }

  // recent words update
  function recentWordUpdate(searchWord) {
    // 중복 확인
    const repeatCheckIndex = localRecentSearchWords.value.findIndex(
      (word) => word === searchWord,
    );
    const recentWords =
      repeatCheckIndex !== -1
        ? [
            ...localRecentSearchWords.value.slice(0, repeatCheckIndex),
            ...localRecentSearchWords.value.slice(repeatCheckIndex + 1),
          ]
        : [...localRecentSearchWords.value];
    recentWords.length >= 5 ? recentWords.pop() : '';

    localRecentSearchWords.value = [searchWord, ...recentWords];
  }

  // recent words delete
  function recentWordDelete(index) {
    const recentWords = [
      ...localRecentSearchWords.value.slice(0, index),
      ...localRecentSearchWords.value.slice(index + 1),
    ];
    localRecentSearchWords.value = [...recentWords];
  }

  return {
    wordArr,
    localTrashCan,
    localRecentSearchWords,
    screenTransition,
    setWordDic,
    wordAdd,
    wordCheck,
    wordDelete,
    contentChange,
    wordDetail,
    trashCanWordDetail,
    trashCanWordKill,
    trashCanWordRestore,
    recentWordUpdate,
    recentWordDelete,
  };
});

현재 mainStore에서는 화면 전환 상태, 로컬 스토리지의 단어, 로컬 스토리지의 휴지통 단어, 로컬 스토리지의 최근 검색한 단어를 관리한다. 이 네 개의 상태를 각각 분리하려 한다.

분리 후 디렉터리들은 아래와 같다.

  • 로컬 스토리지 단어 관련 코드들은 note 디렉터리
  • 로컬 스토리지 휴지통 단어 관련 코드들은 trashCan 디렉터리
  • 로컬 스토리지 최근 검색한 단어 관련 코드들은 recentSearch 디렉터리
  • 화면 전환 상태는 App.js에서 provide, inject 기능을 이용

로컬 스토리지 단어 분리

스크린샷 2023-12-17 오후 5 01 49

로컬 스토리지의 단어를 note 디렉터리로 분리한 구조

관련 기능들을 분리하는 김에 관련 컴포넌트 또한 하나의 디렉터리에 모았다.

noteStore.js

note/composables/noteStore.js
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
import { getCurrentTimeInfo } from '../../../utils/timeInfo';
import { toValue } from 'vue';

export const useNoteStore = defineStore('note', () => {
  const localNoteWords = useStorage('noteWords', new Map(), localStorage);

  function checkWordExist(targetWord) {
    if (localNoteWords.value.has(targetWord)) {
      localNoteWords.value.delete(targetWord);
    }
  }

  function addWord({ word, means }) {
    const { timeInfo, timestamp } = getCurrentTimeInfo();
    const wordInfo = {
      word,
      timeInfo,
      timestamp,
      means: toValue(means).toString(),
      check: false,
    };

    checkWordExist(word);

    localNoteWords.value.set(word, wordInfo);
  }

  function deleteWord(targetWord) {
    localNoteWords.value.delete(targetWord);
  }

  function checkWord(targetWord) {
    const wordInfo = localNoteWords.value.get(targetWord);
    wordInfo.check = !wordInfo.check;
  }

  return {
    localNoteWords,
    addWord,
    deleteWord,
    checkWord,
  };
});

로컬 스토리지 단어 관련 기능들은 담는 스토어
이전 코드에서는 로컬 스토리지 단어들의 순서를 뒤집기 위해 배열 wordArr 따로 선언하여, 로컬 스토리지 단어와 wordArr 두 개의 데이터들을 조작했다. 로컬 스토리지 단어만 조작하는 걸로 충분하기에 배열 wordArr는 선언하지 않았다. 순서를 뒤집는 로직은 단어들을 렌더링하는 컴포넌트에서 수행하는 것으로 변경했다.

이전 코드에 로컬 스토리지의 저장된 단어를 삭제하는 함수 wordDelete는 로컬 스토리지의 단어를 삭제한 후, 직접 로컬 스토리지의 휴지통을 조작했다. 리팩터링 후 코드에서는 deleteWord 함수를 이용해 단어만 삭제하고 휴지통 관련 로직은 컴포넌트에서 휴지통 스토어의 휴지통 단어 추가 기능을 호출하도록 변경했는데, 이는 각 스토어가 독립적으로 사용할 수 있도록 설계하는 게 더 좋다고 생각 했기 때문이다.

이 밖에 저장할 단어가 로컬 스토리지에 이미 저장된 단어인지 확인하는 함수를 분리한 것과 변수, 함수 이름의 가독성을 향상하기 위한 변경이 있다.

NoteWordList.vue

note/components/NoteWordList.vue
<script setup>
import { useNoteStore } from '../composables/noteStore';
import NoteWordCard from './NoteWordCard.vue';
import { computed } from 'vue';

const noteStore = useNoteStore();

const wordList = computed(() => {
  return [...noteStore.localNoteWords.values()].sort(
    (a, b) => b.timestamp - a.timestamp,
  );
});
</script>

<template>
  <NoteWordCard
    v-for="wordObj in wordList"
    class="mb-[32px]"
    :key="wordObj.word"
    :word="wordObj.word"
    :means="wordObj.means"
    :check="wordObj.check"
    :time-info="wordObj.timeInfo"
  />
</template>

이전 WordList.vue 컴포넌트는 노트 단어들과 휴지통 단어들을 v-if를 이용해 렌더링했었다. 이것을 각각의 단어들을 다루는 컴포넌트로 분리해 App.vue 에서 v-if를 이용해 렌더링하도록 변경했다.
NoteWordList.vue 컴포넌트에서는 로컬 스토리지의 노트 단어들을 computed를 이용해 가장 최근에 저장한 순서대로 정렬한 뒤, NoteWordCard.vue에 정렬된 단어들을 전달한다.

NoteWordCard.vue

note/components/NoteWordCard.vue
<script setup>
import { Icon } from '@iconify/vue';
import { ref, computed } from 'vue';
import NoteWordDetailModal from './NoteWordDetailModal.vue';
import { useNoteStore } from '../composables/noteStore';
import { useTrashCanStore } from '../../trashcan/composables/trashCanStore';

const props = defineProps({
  word: String,
  means: String,
  check: Boolean,
  timeInfo: String,
});

const noteStore = useNoteStore();
const trashCanStore = useTrashCanStore();
const means = computed(() => props.means.split(','));
const openWordDetail = ref(false);

const iconType = computed(() =>
  props.check ? 'carbon:checkbox-checked' : 'carbon:checkbox',
);

const checkWordStyle = computed(() =>
  props.check ? 'opacity-60 line-through decoration-2' : '',
);

function toggleDetailOpen() {
  openWordDetail.value = !openWordDetail.value;
}

function deleteWord() {
  toggleDetailOpen();
  noteStore.deleteWord(props.word);
  trashCanStore.addTrashCanWord({ word: props.word, means });
}

function checkBtnClick() {
  noteStore.checkWord(props.word);
}
</script>

<template>
  <div class="flex flex-col">
    <div class="flex gap-x-1">
      <button>
        <Icon
          class="flex items-center"
          @click="checkBtnClick"
          :icon="iconType"
          width="37"
          height="37"
        />
      </button>
      <span
        class="card_word"
        :class="checkWordStyle"
        @click="toggleDetailOpen"
        >{{ props.word }}</span
      >
    </div>
    <div :class="checkWordStyle">
      <li v-for="mean in means" :key="mean" class="card_content">
        {{ mean }}
      </li>
    </div>
    <Teleport to="body">
      <NoteWordDetailModal
        v-if="openWordDetail"
        :word="props.word"
        :means="means"
        :time-info="props.timeInfo"
        @close="toggleDetailOpen"
        @delete="deleteWord"
      />
    </Teleport>
  </div>
</template>

노트 단어의 정보를 받아 화면에 렌더링하는 컴포넌트.
이전 컴포넌트와 차이점은 단어 삭제를 명령하는 함수가 mainStore의 삭제 함수를 호출하기만 했다면, 리팩터링 후에는 noteStore에서 단어를 삭제하고 trashCanStore에 삭제한 단어를 추가한다는 점이다. 노트와 휴지통 관련 스토어를 분리했기 때문이다.

로컬 스토리지 휴지통 단어 분리

휴지통 관련 리팩터링은 노트와 같기에 글에서는 생략한다.

스크린샷 2023-12-17 오후 5 02 18

로컬 스토리지 최근 검색한 단어 분리

최근 검색한 단어를 나타내는 컴포넌트와 로컬 스토리지의 최근 검색한 단어의 상태와 기능을 관리하는 pinia 스토어로 구성된다.

스크린샷 2023-12-17 오후 5 02 02

recentSearchStore.js

recentSearch/composables/recentSearchStore.js
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';

export const useRecentSearchStore = defineStore('recentSearch', () => {
  const localRecentSearchWords = useStorage('recentWords', [], localStorage);

  function removeDuplication(removeIndex) {
    let removedDuplicationWords;

    if (removeIndex !== -1) {
      removedDuplicationWords = [
        ...localRecentSearchWords.value.slice(0, removeIndex),
        ...localRecentSearchWords.value.slice(removeIndex + 1),
      ];
    } else {
      removedDuplicationWords = [...localRecentSearchWords.value];
    }

    if (removedDuplicationWords.length >= 5) {
      removedDuplicationWords.pop();
    }

    return [...removedDuplicationWords];
  }

  function updateRecentSearchWords(targetWord) {
    const duplicatedWordIndex = localRecentSearchWords.value.findIndex(
      (word) => word === targetWord,
    );

    const removedDuplicationWords = removeDuplication(duplicatedWordIndex);
    localRecentSearchWords.value = [targetWord, ...removedDuplicationWords];
  }

  function deleteRecentSearchWord(targetWordIndex) {
    const removedLocalRecentWords = [
      ...localRecentSearchWords.value.slice(0, targetWordIndex),
      ...localRecentSearchWords.value.slice(targetWordIndex + 1),
    ];
    localRecentSearchWords.value = [...removedLocalRecentWords];
  }

  return {
    localRecentSearchWords,
    updateRecentSearchWords,
    deleteRecentSearchWord,
  };
});

로컬 스토리지의 저장된 단어를 가리키는 변수 localRecentSearchWords와 관련된 기능들을 담는 pinia 스토어
이전 Main.js의 코드와 큰 차이는 없다.

  • updateRecentSearchWords(targetWord): 매개변수 tagetWordlocalRecentSearchWords 맨 앞에 추가하는 함수
    localRecentSearchWords에 이미 tagetWord가 있는 경우 삭제하고 맨 앞에 추가한다.
  • deleteRecentSearchWord(targetWordIndex): 삭제할 단어의 위치를 받아 localRecentSearchWords에서 지운다.

RecentSearch.vue

recentSearch/components/RecentSearch.vue
<script setup>
import { useRecentSearchStore } from '../composables/recentSearchStore';
import { Icon } from '@iconify/vue';

const RECENT_SEARCH_WORD = '최근 검색한 단어';

const recentSearchStore = useRecentSearchStore();

const emits = defineEmits(['clickRecentWord']);

function clickDeleteButton(tagetWordIndex) {
  recentSearchStore.deleteRecentSearchWord(tagetWordIndex);
}

function clickRecentWord(targetWord) {
  emits('clickRecentWord', targetWord);
}
</script>

<template>
  <div class="absolute w-full bg-white border-[1.5px] border-black">
    <div class="text-[12px] text-slate-500 font-semibold px-2 py-0.5">
      {{ RECENT_SEARCH_WORD }}
    </div>
    <div
      v-for="(word, index) in recentSearchStore.localRecentSearchWords"
      :key="index"
      class="flex items-center justify-between px-2"
    >
      <button>
        <span @click="clickRecentWord(word)" class="text-sm">
          {{ word }}
        </span>
      </button>
      <button @click="clickDeleteButton(index)" class="flex">
        <Icon icon="ph:x" />
      </button>
    </div>
  </div>
</template>

단어 입력 창이 focus 되면 화면에 보여지는 최근 검색한 단어 창 컴포넌트
recentSearchStorelocalRecentSearchWords를 이용해 최근 검색한 단어를 화면에 렌더링하고, 단어를 클릭하면 emits를 이용해 부모 컴포넌트인WordInput에 클릭했음을 알려 해당 단어를 검색한다.
삭제 버튼을 클릭하면 recentSearchStoredeleteRecentSearchWord 함수를 호출해 저장된 최근 검색한 단어를 지운다.

화면 전환 상태 분리

mainStore에서 했던 노트와 휴지통 전환의 상태 관리를 최상위 컴포넌트 App.vue에서 provide, inject를 이용해 관리하도록 변경했다.

App.vue

App.vue
<script setup>
import TheHeader from './components/TheHeader.vue';
import NoteWordList from './components/note/components/NoteWordList.vue';
import TrashCanWordList from './components/trashCan/components/TrashCanWordList.vue';
import { ref, provide } from 'vue';

const isNoteMode = ref(true);

function changeMode() {
  isNoteMode.value = !isNoteMode.value;
}

provide('mode', {
  isNoteMode,
  changeMode,
});
</script>

<template>
  <header class="pb-16">
    <TheHeader />
  </header>
  <main class="pt-[32px] px-10 md:px-24 lg:px-32 2xl:px-80 min-h-screen">
    <NoteWordList v-if="isNoteMode" />
    <TrashCanWordList v-else />
  </main>
</template>

isNoteModetrue면 노트, false면 휴지통을 렌더링하며, changeMode 함수를 통해 노트와 휴지통을 전환한다.
처음에는 props, emits을 통해 전환을 구현하려 했지만, 화면 전환 상태는 App.vue -> TheHeader.vue -> WordInput.vue로 중첩되어 이를 prop, emits로 구현하려면 기능에 비해 흐름이 너무 복잡해진다고 생각했다.
props, emits 방식과 다르게 App.vue에서 provide를 통해 제공해 TheHeader.vueWordInput.vue에서 화면 전환 상태와 상태 변경 함수를 inject를 통해 주입해 이용하도록 했다.

TheHeader.vue

TheHeader.vue
<script setup>
import { Icon } from '@iconify/vue';
import { inject, computed } from 'vue';
import WordInputV2 from './WordInputV2.vue';

const { isNoteMode, changeMode } = inject('mode');

const iconType = computed(() => (isNoteMode.value ? 'ph:trash' : 'ph:notepad'));
</script>

<template>
  <div
    class="fixed top-0 z-20 flex items-center w-full h-16 min-h-16 max-h-16 justify-between py-2 px-2 xs:px-6 sm:px-10 md:px-24 lg:px-48 2xl:px-80 bg-[#2E4559]"
  >
    <div class="text-2xl md:text-4xl font-bold text-white">voca</div>
    <div class="relative w-3/5 md:w-1/2">
      <WordInputV2 />
    </div>
    <button @click="changeMode">
      <Icon :icon="iconType" width="34" height="34" color="#e4e4e7" />
    </button>
  </div>
</template>

inject를 통해 isNoteMode, changeMode를 주입하여 사용하도록 변경했다. isNoteMode 상태에 따라 아이콘을 반응형으로 정하고, 아이콘 버튼을 클릭하면 주입 받은 changeModa 함수를 호출해 isNoteModa의 상태를 변경한다.

provide, inject는 처음 사용해 봤는데 상당히 편리한 기능이었다.
단어장 프로젝트에서 화면 전환 기능을 구현했던 방식으로 세 가지가 있었다. 첫째 props, emits를 이용한 방식, 둘째 pinia 스토어를 이용해 화면 상태를 분리한 방식, 마지막으로 provide, inject를 이용한 방식이다.
마지막 방식 provide, inject을 사용한 방식과 다른 방식을 비교해 보면,

  1. props, emits을 이용한 방식은 컴포넌트의 계층이 깊어지면 데이터 흐름이 복잡해지며, 중복된 코드가 많아진다. props만을 이용해 하위 컴포넌트로 데이터를 전달하기만 하면 그나마 괜찮지만, emits를 통해 하위 컴포넌트에서 상위 컴포넌트로의 흐름이 생기면 너무 복잡해진다. 하지만 이 방식은 부모 자식간의 계층이 얕으면 데이터 흐름이 명시적이고 예측이 가능해, 코드를 이해하기 쉽다는 장점이 있다.
  2. pinia store를 이용해 전역적으로 관리하는 방식은 어디서나 손쉽게 이용할 수 있다는 장점이 있지만, 어디서나 쉽게 이용해 흐름을 예측하기 어렵고, 코드를 이해하기에도 어려움이 있다.
  3. provide, inject을 이용한 방식을 사용했을 때의 느낌은 1번과 2번 방식의 중간에 있는 방식같다는 것이다. 해당 방식은 부모 컴포넌트의 하위 트리에만 전달 할 수 있기에, pinia를 이용해 전역으로 관리하는 방식 보다는 덜 자유롭지만 props, emits 방식보다는 훨씬 자유롭다.
    또한, pinia를 이용한 방식보다는 데이터 흐름을 예측하기 쉽지만 props, emits 보다는 어렵다.

마무리

이번 리팩터링은 여기서 마무리하려 한다. 예상보다 리팩터링 할 게 많아 오래 걸렸다.
9개월 전 해당 프로젝트를 마무리했을 때만 해도 이렇게 미흡한 점이 많았을 줄 몰랐다. 지금 봤을 때 참 부끄러운 코드들이다. 그때보다는 실력이 향상됐기에 그렇게 느낀다고 긍정적으로 생각하는 게 좋을 거 같다.
다음 리팩터링은 해당 프로젝트의 코드가 또 부끄럽다고 느껴지면 할 예정이다.


댓글