단어장 리팩터링 - 2

2023년 12월 12일

intro

단어 검색 기능은 현재 WordInput 컴포넌트에서 검색 아이콘을 누르거나, enter 키를 눌러 단어를 검색할 때 pinia 저장소 InputWord.js에서 입력한 단어의 정보들을 관리했다.
이 기능에 있어 pinia를 이용한 로직은 이전 글처럼 불필요하다고 느껴 props, emits만을 이용해 리팩터링을 시도했었다.
하지만 오히려 더 복잡해졌다. 단어 검색의 경우는 normal, similar, missing 세 가지다. 일단 경우에 따라 렌더링되는 모달들을 하나의 디렉터리로 묶고 싶어 components 디렉터리에 SearchModal 디렉터리로 분리했다.
그 다음 SearchModal.vue 컴포넌트가 큰 틀로서 단어 검색 결과에 따라 SearchModalNormal.vue, SearchModalSimilar.vue, SearchModalMissing.vue 중 하나를 렌더링하는 구조로 설계했다.

검색 모달들에 필요한 기능에는 검색한 단어의 정보를 저장, 유사한 단어를 재검색, 모달 창 닫기가 있다. 단어 저장과 창 닫기까지는 propsemits를 이용해 구현해도 복잡하지 않지만 유사한 단어를 재검색하는 기능까지 구현하려니 이벤트 흐름이 복잡해졌다.

pinia를 사용하지 않는 것보다 사용하는게 장점이 더 크다고 생각이 들었다. 결국 다시 pinia를 사용하기로 했고, pinia에서 중앙으로 검색하는 단어, 모달 오픈 상태를 관리하고, 각각의 검색 결과 모달의 필요한 기능들과 컴포넌들만을 분리했다.

리팩터링 후 구조

스크린샷 2023-12-17 오후 4 43 45

해당 구조를 간단히 설명해보면, 단어 검색 모달을 모듈화한 디렉터리다. 렌더링할 component와 필요한 기능들 composables로 분리했으며, SearchModal.vue가 뼈대 역할을 한다. 컴포지션 함수 searchWord.js에서 단어를 검색하며 그 결과에 따라 SearchModal.vue

  • 검색에서 에러가 발생한 경우 SearchModalError.vue
  • 정상적인 검색인 경우 SearchModalNormal.vue
  • 검색한 단어가 없고, 유사한 단어가 있는 경우 SearchModalSimilar.vue
  • 검색한 단어가 없고, 유사한 단어도 없는 경우 SearchModalMissing.vue

를 렌더링 한다. ...Case.js 컴포지션 함수들은 각각의 경우에 사용되는 함수들이다.
searchStore는 pinia를 이용한 스토어이며, 모달 오픈상태와 검색하는 단어를 중앙에서 관리하는 역활을 한다. 또 단어 재검색을 해당 스토어에서 하도록 설계했다.

리팩터링 후 코드

searchStore.js

searchModal/composables/searchStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useSearchStore = defineStore('search', () => {
  const searchModalOpenState = ref(false);
  const targetWord = ref(null);

  function closeSearchModal() {
    searchModalOpenState.value = false;
  }

  function openSearchModal() {
    searchModalOpenState.value = true;
  }

  function setTargetWord(word) {
    targetWord.value = word;
    openSearchModal();
  }

  function searchSimilarWord(similarWord) {
    closeSearchModal();
    setTargetWord(similarWord);
  }

  return {
    targetWord,
    searchModalOpenState,
    closeSearchModal,
    openSearchModal,
    setTargetWord,
    searchSimilarWord,
  };
});

SearchModal에 관련된 메인 저장소, 이 저장소에서는 모달 열기/닫기, 검색 단어 설정과 유사한 단어로 재검색을 한다.
변수 targetWord로 SearchModal들 간에 검색 단어를 공유한다.
WordInput 컴포넌트에서 단어를 검색하면 setTargetWord 함수를 이용해 검색 단어를 설정하고, 모달을 오픈한다.
유사한 단어 클릭 시 searchSimilarWord 함수를 호출해 모달을 닫고 유사한 단어를 검색 단어로 설정해 재검색한다.

WordInputV2.vue

wordInput/components/WordInputV2.vue
<script setup>
// 생략
function checkEmptyWord(searchWord) {
  return searchWord === '';
}

function checkNotEnglishWord(searchWord) {
  const isNotEnglish = /[^a-zA-Z]/.test(searchWord);
  return isNotEnglish;
}

function searchTargetWord(targetWord) {
  searchStore.setTargetWord(targetWord);
  inputWord.value = '';
  recentWordsFocus.value = false;
}

function searchInputWord() {
  const searchWord = inputWord.value;

  if (checkEmptyWord(searchWord) || checkNotEnglishWord(searchWord)) {
    alertInputCondition();
    return;
  }

  searchTargetWord(searchWord);
}
// 생략
</script>

<template>
  <!-- 생략 -->
    <Teleport to="body">
      <SearchModal v-if="searchStore.searchModalOpenState" />
    </Teleport>
  </div>
</template>

Teleport를 이용해 모달을 띄우며 searchStoresearchModalOpenStatetrue일 때 화면에 모달을 렌더링한다.
WordInputV2 컴포넌트에서 단어를 검색하면 해당 컴포넌트의 searchInputWord 함수를 호출해 searchStore에서 target 단어를 설정하고 모달을 오픈한다. 최근 검색 단어를 이용한 단어 검색은 searchTargetWord 함수를 이용한다.
또한 단어에 알파벳 이외의 문자가 있는 경우도 잘못된 입력임을 알리도록 추가했다.

SearchModal.vue

searchModal/components/SearchModal.vue
<script setup>
import SearchModalNoraml from './SearchModalNoraml.vue';
import SearchModalMissing from './SearchModalMissing.vue';
import SearchModalSimilar from './SearchModalSimilar.vue';
import SearchModalError from './SearchModalError.vue';
import useSearchWord from '../composables/searchWord';
import { ref, watch } from 'vue';
import { useSearchStore } from '../composables/searchStore';
import MODAL_CASE from '../constant';

const searchStore = useSearchStore();
const searchWord = ref(searchStore.targetWord);

watch(
  () => searchStore.targetWord,
  (newTargetWord) => (searchWord.value = newTargetWord),
);

const { data, searchCase, error } = useSearchWord(searchWord);
</script>

<template>
  <div v-if="error">
    <SearchModalError />
  </div>
  <div v-else-if="data">
    <SearchModalNoraml
      v-if="searchCase === MODAL_CASE.normal"
      :searchData="data"
    />
    <SearchModalSimilar
      v-else-if="searchCase === MODAL_CASE.similar"
      :searchData="data"
    />
    <SearchModalMissing v-else />
  </div>
</template>

어떤 모달들을 화면에 띄울지 정하는 뼈대 컴포넌트
targetWordsearchStore에서 갖고 와 useSearchWord 컴포저블 함수를 이용해 어떤 모달을 띄울지 반응형으로 결정하며 검색 데이터를 얻어, 상황에 맞는 모달에 전달한다.

여기서 중요한 기능은 watchsearchStoretargetWord를 감시해 변경이 있으면 재검색하는 것이다.
이 감시자는 similarCase에서 유사한 단어를 클릭했을 때 searchStoresearchSimilarWord 함수가 호출되어 searchStore의 변경된 targetWord를 감지해, 해당 컴포넌트의 반응형 변수 searchWord의 값을 업데이트한다.
반응형 변수 searchWord가 변경되면 컴포저블 함수 useSearchWord내에 있는 감시자가 반응해 변경된 searchWord를 이용해 재검색한다. 재검색 후 해당 컴포넌트의 { 검색 데이터, 검색 상태, 에러 }를 나타내는 반응형 변수들 { data, searchCase, error }의 값들도 반응하여 변경된다.

컴포저블 함수를 처음 사용해, 틀린 부분이 있을 수 있어 vue의 공식 홈페이지 링크를 남긴다.

vue 공식 홈페이지 컴포저블

SearchModalNormal.vue

searchModal/components/SearchModalNormal.vue
<script setup>
import TheModal from '../../TheModal.vue';
import useNormalCase from '../composables/normalCase';
import { useMainStore } from '../../../stores/Main';
import { useSearchStore } from '../composables/searchStore';
import { onMounted } from 'vue';

const props = defineProps({
  searchData: Array,
});

const mainStore = useMainStore();
const searchStore = useSearchStore();
const searchWord = searchStore.targetWord;
const { means: searchWordMeas } = useNormalCase(searchWord, props.searchData);

onMounted(() => {
  addRecentSearchWord();
});

function closeModal() {
  searchStore.closeSearchModal();
}

function addWord() {
  mainStore.wordAdd(searchWord, searchWordMeas.value);
  closeModal();
}

function addRecentSearchWord() {
  mainStore.recentWordUpdate(searchWord);
}
</script>

<template>
  <TheModal @click-close-icon="closeModal">
    <template #word>
      {{ searchWord }}
    </template>
    <template #means>
      <li v-for="mean in searchWordMeas" :key="mean" class="modal_means">
        {{ mean }}
      </li>
    </template>
    <template #footer>
      <div class="flex gap-x-4 justify-end">
        <button
          @click="closeModal"
          class="modal_btn bg-neutral-400 hover:bg-neutral-500"
        >
          cancel
        </button>
        <button
          @click="addWord"
          class="modal_btn bg-emerald-400 hover:bg-emerald-600"
        >
          add
        </button>
      </div>
    </template>
  </TheModal>
</template>

정상적으로 단어가 검색되었을 때 띄우는 모달 컴포넌트
useNormalCase 함수를 이용해 검색 결과에서 단어의 뜻을 분류하고 화면에 렌더링하며 add 버튼을 클릭 시 단어를 저장한다.
단어 저장은 mainStorewordAdd 함수를 통해 저장한다. 함수 호출을 해당 컴포넌트에서 직접 호출할지, searchStore에서 호출하도록 할지 고민이 있었다.
SearchModalNormal 컴포넌트에서 직접 호출하면 searchStore에서 다른 스토어를 import 하지 않아도 되어 searchStore의 결합도가 낮다.
searchStore에서 호출하도록 코드를 짜면 searchStore에서 다른 스토어를 import 하고 이용해야 하므로 스토어 간의 결합이 생긴다. 하지만 SearchModalNormal.vue 컴포넌트에서 하나의 스토어만 호출해 위 방법보다는 해당 컴포넌트의 코드가 좀 더 직관적이다.
결론은 첫 번째 방법을 택했다. 스토어 간의 결합보단 컴포넌트에서 두 개의 스토어를 사용해 스토어를 분리하는게 확장성과 유연성 등 장점이 더 크다고 생각했다.
같은 이유로 최근 검색 단어 추가 기능인 mainStorerecentWordUpdate 함수도 해당 컴포넌트에서 호출하도록 했다.

SearchModalMissing.vue

searchModal/components/SearchModalMissing.vue
<script setup>
import TheModal from '../../TheModal.vue';
import useMissingCase from '../composables/missingCase';
import { useSearchStore } from '../composables/searchStore';

const searchStore = useSearchStore();
const { missingPhrase } = useMissingCase();

function closeModal() {
  searchStore.closeSearchModal();
}
</script>

<template>
  <TheModal @click-close-icon="closeModal">
    <template #word>
      {{ searchStore.targetWord }}
    </template>
    <template #means>
      <li
        v-for="phrase in missingPhrase"
        :key="phrase"
        class="modal_means list-none"
      >
        {{ phrase }}
      </li>
    </template>
    <template #footer>
      <div class="flex">
        <button
          @click="closeModal"
          class="modal_btn px-3.5 bg-blue-500 hover:bg-blue-600"
        >
          ok
        </button>
      </div>
    </template>
  </TheModal>
</template>

검색한 단어의 결과가 없으면서, 유사한 단어도 없으면 화면에 띄우는 모달 컴포넌트
useMissingCase 함수를 통해 얻은 문구들을 화면에 렌더링한다.

SearchModalSimilar.vue

searchModal/components/SearchModalSimilar.vue
<script setup>
import TheModal from '../../TheModal.vue';
import useSimilarCase from '../composables/similarCase';
import { useSearchStore } from '../composables/searchStore';

const props = defineProps({
  searchData: Array,
});

const searchStore = useSearchStore();
const searchWord = searchStore.targetWord;
const { similarWords } = useSimilarCase(props.searchData);

function clickSimilarWord(similarWord) {
  searchStore.searchSimilarWord(similarWord);
}

function closeModal() {
  searchStore.closeSearchModal();
}
</script>

<template>
  <TheModal @click-close-icon="closeModal">
    <template #word>
      {{ searchWord }}
    </template>
    <template #means>
      <div
        v-for="similarWord in similarWords"
        :key="similarWord"
        @click="clickSimilarWord(similarWord)"
        class="modal_means hover:text-xl cursor-pointer h-[32px] leading-[32px] hover:leading-[32px]"
      >
        {{ similarWord }}
      </div>
    </template>
    <template #footer>
      <div class="flex">
        <button
          @click="closeModal"
          class="modal_btn px-3.5 bg-blue-500 hover:bg-blue-600"
        >
          ok
        </button>
      </div>
    </template>
  </TheModal>
</template>

검색한 단어의 결과는 없고 유사한 단어가 있는 경우 화면에 띄우는 모달 컴포넌트
유사한 단어를 클릭하면 searchStoresearchSimilarWord 함수를 호출해 단어를 재검색한다.

마무리

검색 단어 모달 기능 리팩터링이 생각보다 오래 걸렸다. 이 기능에 대한 구조를 여러 번 짜보고 코드도 많이 수정했다.
pinia를 이용해 중앙 저장소로 상태를 관리할지 composables과 emits, props만을 이용해 상태를 관리할지에 관한 결정을 못 내린 것이 가장 큰 원인이었다. 특정 상황에서 특정 기능 이용을 결정하는 것은 너무 어려운 거 같다.


댓글