단어장 리팩터링 - 4

2023년 12월 18일

intro

이전 글에서 리팩터링을 마무리하려 했지만, 단어를 입력해 검색하는 컴포넌트인 WordInputV2.vue와 단어 검색 모달 디렉터리가 분리된 게 어색하다고 느껴져 만족스럽지 못했다. 해당 부분이 계속 생각이 나 결국 관련 기능들을 리팩터링 하려 한다.
이번 리팩터링에서는 단어 검색에 관한 컴포넌트와 함수들의 디렉터리 구조와 이름을 변경하는 것이 목표다.

리팩터링 전 디렉터리 구조

directory_before

검색 모달에 관련된 searchModal 디렉터리와 단어 검색창 컴포넌트 WordInputV2.vue가 현재 분리된 구조다. WordInputV2.vue에서 searchModal 디렉터리의 searchStore.js를 이용해 단어를 검색하고 있다. 디렉터리 밖의 store(searchStore)를 이용해도 좋지만 같은 기능을 하는데 한 곳에 있는 게 더 좋을 거 같다.

리팩터링 후 디렉터리 구조

directory_after

처음에는 위 내용과 같이 검색 관련 기능을 한 곳에 배치하여 리팩터링을 시도했었다. 하지만 검색과 입력은 다른 기능이라고 생각이 들었고 분리하는 게 더 좋은 거 같았다. 따라서 검색은 이전 searchModal 디렉터리에서 담당하고, 입력은 wordInput 디렉터리를 만들어, 해당 디렉터리에서 담당하도록 했다.

이전 searchModal 디렉터리는 search로 이름을 변경했다. 검색 모달 기능만이 아닌 검색 관련 기능을 갖기에 searchModal 보다 search가 더 적합하다고 생각한다.
또한, 이전 SearchModal.vueSearchContainer.vue로 이름을 변경했다. 해당 컴포넌트는 어떤 검색 모달을 띄울지 결정하는 컴포넌트이기에 container라는 이름이 더 적합한 거 같다.
그 외에는 composables 디렉터리에서 각 검색 경우에 사용하는 함수들을 searchCase.js에 몰아넣었으며, searchStore의 기능을 입력과 검색으로 분리했다. searchStore에서는 검색 관련 기능을 담당한다.

분리한 입력 관련 기능을 wordInput 디렉터리를 추가했다. WordInput.vue는 이전 WordInputV2.vue와 같은 기능을 하지만 WordInputV2.vue에 있던 기능들을 composables 디렉터리에 기능별로 분리했다. 이전 WordInputV2.vue는 여러 기능을 한 파일에 위치시켰기에 코드가 너무 길어 읽기 싫었기 때문이다.

검색 기능 search 디렉터리

searchStore.js

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

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

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

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

  return {
    searchModalOpenState,
    closeSearchModal,
    openSearchModal,
  };
});

검색 모달 상태를 관리하는 pinia store
이전에는 검색 모달의 상태와 검색 단어의 상태를 관리했다. 해당 스토어에서는 검색 모달의 상태만을 관리하는 게 적합하다고 생각해 분리했다.

SearchContainer.vue

search/components/SearchContainer.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 MODAL_CASE from '../constant';
import { storeToRefs } from 'pinia';
import { useTargetWordStore } from '../../wordInput/composables/targetWordStore';

const targetWordStore = useTargetWordStore();

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

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

리팩터링 후, SearchModal 들의 컨테이너 컴포넌트
이전에는 검색 단어인 targetWord를 pinia store를 이용해 모달들 간 공유했었다. 큰 문제가 없는 방식이지만 pinia store의 호출을 줄이고 부모 자식 컴포넌트 간의 연결을 늘리는 게 더 좋다고 생각했다. 컴포넌트 간 계층이 얕기에, 후자의 경우가 예측하기 더 쉽다.

또 다른 변경 점은 vue의 watch를 사용하지 않고 pinia의 storeToRefs를 이용했다는 점이다. storeToRefs는 vue의 composables 함수처럼 store의 상태를 반응형 ref로 이용할 수 있게 해준다.
storeToRefs는 이전 코드에서 watch를 이용해 pinia store의 targetWord의 변경에 반응해, 새로 검색하는 기능을 더 간단하고 직관적으로 해줬다.

SearchModalSimilar.vue

search/components/SearchModalSimilar.vue
<script setup>
import TheModal from '../../TheModal.vue';
import { useSimilarCase } from '../composables/searchCase';
import { useSearchStore } from '../composables/searchStore';
import { useTargetWordStore } from '../../wordInput/composables/targetWordStore';
import { onKeyStroke } from '@vueuse/core';

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

const searchStore = useSearchStore();
const targetWordStore = useTargetWordStore();

const { similarWords } = useSimilarCase(props.searchData);

function clickSimilarWord(similarWord) {
  targetWordStore.setTargetWord(similarWord);
}

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

onKeyStroke(['Enter'], () => closeModal());
</script>

<template>
  <TheModal @click-close-icon="closeModal">
    <template #word> {{ props.word }}와 유사한 단어 </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>

리팩터링 후, 검색 결과가 유사한 단어인 경우의 모달 컴포넌트
유사한 단어를 클릭했을 때 새로운 pinia store인 targetWordStore의 setTargetWord를 호출한다. 이전 searchStore의 있던 검색할 단어(targetWord) = 입력한 단어 관련 기능들을 분리해, 검색과 입력에 대한 책임을 나눴다.

리팩터링하면서 새롭게 알게 된 점이 있다. 이전 코드에서는 유사한 단어를 클릭했을 때 searchModal의 상태를 false로 바꿔 모달을 닫은 뒤, 검색할 단어를 설정하고, searchModal의 상태를 true로 바꿨었다. 하지만 해당 과정은 필요 없는 과정이었다.

단어 검색은 SearchContainer.vue에서 컴포저블 함수 useSearchWord를 이용해, targetWordStore의 targetWord의 상태에 반응하여 검색하고 있다.
유사한 단어를 클릭하면 targetWord의 값을 변경한다. targetWord의 값이 변경되었기에 useSearchWord에서는 단어를 검색한다. 이때 SearchContainer.vue에서의 반응형 상태 dataerror는 값이 모두 없기에 어떤 검색 모달도 화면에 렌더링하지 않는다. 즉 모달이 닫힌다.
검색이 완료되면 data의 값이 존재하므로 nomalCase의 검색 모달을 화면에 렌더링한다.

이때 searchStore의 searchModalOpenState의 값은 계속 true로 검색 모달을 화면에 렌더링하도록 지시하고 있다. SearchContainer.vue에서 각 경우에 따라 모달 컴포넌트를 검색 결과인 dataerrorv-if로 이용해 렌더링하기 때문에 검색 결과가 바뀌며 마치 모달이 종료되는 거처럼 화면에서 모달이 사라진다. 다시 말해, 유사한 단어 검색에서 searchStore의 모달 오픈 상태를 변경할 필요가 없는 것이다.

검색 모달의 종료는

  1. 종료 버튼 클릭
  2. 종료 아이콘 버튼 클릭
  3. 단어 저장 버튼 클릭
  4. ok 버튼 클릭
  5. esc 버튼

의 경우만 해당하고 그 외의 경우인 유사한 단어 클릭은 모달 컴포넌트를 전환하는 것이다. 또한 검색 모달의 오픈은 오직 WordInput.vue에서만 한다.


그 외 search 디렉터리는 이전과 큰 차이 없다.

입력 기능 wordInput 디렉터리

이전 WordInputV2.vue 컴포넌트의 코드를 components와 composabels로 분리한 디렉터리

focusInput.js

wordInput/composables/focusInput.js
import { onMounted, onUnmounted } from 'vue';

function useFocusInput(target, selector) {
  const EVENT = 'click';

  function outFocusInput(e) {
    if (!e.target.closest(selector)) {
      target.value = false;
    }
  }

  onMounted(() => {
    window.addEventListener(EVENT, outFocusInput);
  });

  onUnmounted(() => {
    window.removeEventListener(EVENT, outFocusInput);
  });
}

export default useFocusInput;

WordInput 컴포넌트의 외부 요소를 클릭하면 최근 검색한 단어 컴포넌트를 화면에 보이지 않도록 하는 기능을 컴포저블 함수로 분리했다. 클릭한 요소의 조상 요소 중 매개변수 selector와 일치하는 요소가 있는지 찾아서 없으면, 매개변수 target의 값을 false로 변경한다.

inputClasses.js

wordInput/composables/inputClasses.js
import { computed, toValue } from 'vue';

const INPUT_WORD = '단어를 입력해주세요';
const CAN_NOT_INPUT_WORD = '단어를 입력할 수 없습니다';

function useInputClasses(mode) {
  const bgColor = computed(() => (toValue(mode) ? 'bg-white' : 'bg-[#cbd5e1]'));
  const placeHolderText = computed(() =>
    toValue(mode) ? INPUT_WORD : CAN_NOT_INPUT_WORD,
  );

  return {
    bgColor,
    placeHolderText,
  };
}

export default useInputClasses;

WordInput 컴포넌트에서 화면 상태에 따라 변경되는 computed 값들(input 창의 배경색, input 창의 placeholder 값)을 컴포저블 함수로 분리했다.

targetWordStore.js

wordInput/composables/targetWordStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useTargetWordStore = defineStore('targetWord', () => {
  const targetWord = ref(null);

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

  return {
    targetWord,
    setTargetWord,
  };
});

검색할 단어(입력 단어)를 관리하는 pinia 스토어
WordInput 컴포넌트에서 단어를 입력하거나, 검색 결과가 similarCase일 때 재검색할 때 해당 스토어의 setTargetWord 함수를 이용해 targetWord를 설정한다.
검색 모달에서는 targetWord를 이용해 단어를 검색한다.

toggleTagDisabled.js

wordInput/composables/toggleTagDisabled.js
import { toValue, watchEffect } from 'vue';

function useToggleTagsDisabled(elementRefs, booleanValue) {
  const setTargetTag = (elementRef) => {
    if (elementRef.value) {
      elementRef.value.disabled = toValue(booleanValue);
    }
  };

  watchEffect(() => elementRefs.forEach(setTargetTag));
}

export default useToggleTagsDisabled;

조건에 따라 input 요소와 버튼을 사용하지 못하게 하는 기능을 분리한 컴포저블 함수
매개변수로 요소를 참조하는 ref 배열 elementRefs를 받아 disabled 속성의 값을 매개변수 booleanValue로 변경한다.
if (elementRef.value)문은 요소를 참조하는 ref가 아직 요소를 참조하지 않은 경우가 있기 때문이다. vue의 ref로 템플릿 참조는 컴포넌트가 마운트된 이후에만 접근할 수 있기 때문이다. 처음에는 ref에 null 값이 있다. 따라서 해당 조건문을 통해 요소를 참조한 경우(컴포넌트가 마운트된 이후)에만 disabled 속성값을 변경한다.

validateInputWord.js

wordInput/composables/validateInputWord.js
import { toValue } from 'vue';

function useValidateInputWord(inputWord) {
  const word = toValue(inputWord);

  const checkEmpty = (word) => word === '';
  const checkNotEnglish = (word) => /[^a-zA-Z]/.test(word);

  return checkEmpty(word) || checkNotEnglish(word);
}

export default useValidateInputWord;

입력한 단어를 targetWord로 설정하기 전에 검사하는 로직을 분리한 컴포저블 함수

WordInput.vue

wordInput/components/WordInput.vue
<script setup>
import { Icon } from '@iconify/vue';
import { ref, computed, inject } from 'vue';
import useInputClasses from '../composables/inputClasses';
import useFocusInput from '../composables/focusInput';
import useToggleTagsDisabled from '../composables/toggleTagDisabled';
import RecentSearch from '../../recentSearch/components/RecentSearch.vue';
import { useSearchStore } from '../../search/composables/searchStore';
import useValidateInputWord from '../composables/validateInputWord';
import { storeToRefs } from 'pinia';
import { useTargetWordStore } from '../composables/targetWordStore';
import SearchContainer from '../../search/components/SearchContainer.vue';

const CONTAINER_ID = 'inputContainer';
const ALERT_INPUT = '영어 단어를 입력해 주세요.';

const inputRef = ref(null);
const searchBtnRef = ref(null);

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

const searchStore = useSearchStore();
const targetWordStore = useTargetWordStore();

const { searchModalOpenState } = storeToRefs(searchStore);
const inputWord = ref('');
const recentWordsFocus = ref(false);
const isInputDisabled = computed(
  () => !isNoteMode.value || searchModalOpenState.value,
);

const { bgColor, placeHolderText } = useInputClasses(isNoteMode);
useFocusInput(recentWordsFocus, `#${CONTAINER_ID}`);
useToggleTagsDisabled([inputRef, searchBtnRef], isInputDisabled);

function focusInput() {
  inputRef.value.focus();
}

function clearInputWord() {
  inputWord.value = '';
  focusInput();
}

function alertInputCondition(e) {
  if (!e.isComposing) {
    window.alert(ALERT_INPUT);
    inputWord.value = '';
    focusInput();
  }
}

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

function handleInputValidationAndSetTargetWord(e) {
  if (useValidateInputWord(inputWord)) {
    alertInputCondition(e);
    return;
  }

  setTargetWord(inputWord.value);
}
</script>

<template>
  <div :id="CONTAINER_ID">
    <div class="flex items-center h-9 px-3 border boder-black" :class="bgColor">
      <button v-show="inputWord" @click="clearInputWord">
        <Icon icon="ph:x-bold" />
      </button>
      <input
        ref="inputRef"
        class="w-full px-2 focus:outline-0 bg-inherit"
        :placeholder="placeHolderText"
        :value="inputWord"
        @keydown.enter="handleInputValidationAndSetTargetWord"
        @keydown.esc="clearInputWord"
        @focus="recentWordsFocus = true"
        @input="(e) => (inputWord = e.target.value)"
      />
      <button ref="searchBtnRef" @click="handleInputValidationAndSetTargetWord">
        <Icon icon="ion:search" width="24" height="24" />
      </button>
    </div>
    <RecentSearch
      v-show="recentWordsFocus"
      @click-recent-word="(recentSearcWord) => setTargetWord(recentSearcWord)"
    />
  </div>
  <Teleport to="body">
    <SearchContainer v-if="searchModalOpenState" />
  </Teleport>
</template>

리팩터링 후 WordInput 컴포넌트, 컴포저블 함수로 기능들을 분리했다.
주요 기능인, setTargetWord 함수가 targetWordStore의 targetWord를 설정하며, searchStore의 검색 모달 상태를 open으로 변경시켜, 검색 모달을 렌더링한다(SearchContainer.vue).

마무리

이번 리팩터링으로 해당 프로젝트의 리팩터링을 진짜로 마무리하겠다.
리팩터링의 느낀 점은 생각보다 어렵고 생각보다 재밌다는 것이다. 리팩터링을 시작하여, 구조를 잘 때 머리 아프고 짜증도 나지만 리팩터링 후와 이전 코드를 비교해보면 개선된 점들을 볼 수 있어 성장한 거 같아 재밌었다. 다음 리팩터링은 더 잘할 수 있을 거 같다.
일단은 생각나는 것들을 모두 리팩터링 했지만, 뭔가 생각보다 금방 맘에 안 드는 점을 찾게 되어, 리팩터링 할거 같다.


댓글