단어장 리팩터링 - 1

2023년 12월 9일

intro

이전에 만들었던 단어장 토이 프로젝트를 봤을 때 비효율적인 부분이 많다고 생각하여 리팩터링 하려고 한다. 현재 프로젝트는 무분별하게 pinia store를 이용하고 있다고 생각한다. 불필요한 pinia의 사용을 줄이고 Vue의 props와 emits를 이용해 코드를 리팩터링 하는 것이 목표다.
우선 단어장의 단어를 클릭했을 때 단어의 디테일을 모달 창으로 띄우는 코드의 리팩터링이다.

리팩터링 전의 코드에서는 WordList.vue에서 WordCard.vue에 단어의 정보들을 전달하고(prop), 해당 단어를 클릭하면, 커스텀 이벤트 @wordDetailWordList.vue에서 감지해, pinia 저장소에 해당 단어를 전달해 모달을 여는 방식이었다.
이 방식에서 pinia 저장소를 사용하여 오히려 더 복잡해졌다고 생각한다. pinia 저장소를 사용하지 않고, WordCard.vue에서 prop으로 전달받은 단어의 정보들을 이용해 단어의 세부 정보 모달을 직접 띄우는 방식으로 변경하였다.

리팩터링 후 코드

WordCardV2.vue

components/WordCardV2.vue
<script setup>
import { Icon } from '@iconify/vue';
import { useMainStore } from '../stores/Main';
import { ref, computed } from 'vue';
import ModalWordDetailV2 from './ModalWordDetailV2.vue';

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

const mainStore = useMainStore();
const means = computed(() => props.means.split(','));
const openWordDetail = ref(false);

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

function deleteWord() {
  toggleDetailOpen();
  mainStore.wordDelete(props.word);
}

function checkBtnClick() {
  mainStore.wordCheck(props.word, !props.check, props.index);
}
</script>

<template>
  <div class="flex flex-col">
    <div class="flex gap-x-1">
      <button>
        <Icon
          v-if="props.check === false"
          class="flex items-center"
          @click="checkBtnClick"
          icon="carbon:checkbox"
          width="37"
          height="37"
        ></Icon>
        <Icon
          v-else
          class="flex items-center"
          @click="checkBtnClick"
          icon="carbon:checkbox-checked"
          width="37"
          height="37"
        ></Icon>
      </button>
      <span
        class="card_word"
        :class="{ wordcheck_active: props.check }"
        @click="toggleDetailOpen"
        >{{ props.word }}</span
      >
    </div>
    <div :class="{ wordcheck_active: props.check }">
      <li v-for="mean in means" :key="mean" class="card_content">
        {{ mean }}
      </li>
    </div>
    <Teleport to="body">
      <ModalWordDetailV2
        v-if="openWordDetail"
        :word="props.word"
        :means="means"
        :time="props.time"
        @close="toggleDetailOpen"
        @delete="deleteWord"
      />
    </Teleport>
  </div>
</template>

<style scoped>
.wordcheck_active {
  @apply opacity-60 line-through decoration-2;
}
</style>

WordCard.vue를 리팩터링한 코드

  • ModalWordDetail에 전달하기 위해 props에 단어가 추가된 시간 정보인 time을 추가
  • props의 속성 중 means만 문자열 배열로 조작되어야 해서 computed를 이용했고 나머지 속성들은 조작 필요 없어 단순한 값 그대로 사용하기에 ref로 감싸지 않았다. props는 부모 속성이 업데이트되면 자식으로 흐르기 때문에 그대로 사용한다.
  • 변수 openWordDetail은 단어를 클릭했을 때 모달 창을 열거나 닫기 위한 상태 변수다.
  • 함수 toggleDetailOpen은 모달 오픈 상태인 openWordDetail의 값을 반전시켜 단어를 클릭하면 모달을 화면에 띄우고, 모달을 종료하는 명령이 오면 toggleDetailOpen을 호출해 모달을 닫는다.
  • 커스텀 이벤트 close: 자식 컴포넌트인 모달 창에서 close 버튼이나 나가기 아이콘 눌렀을 때 발생하는 이벤트를 감지하면, toggleDetailOpen을 호출해 모달을 닫는다.
  • 커스텀 이벤트 delete: 자식 컴포넌트인 모달 창에서 delete 버튼을 눌렀을 때 동작하는 이벤트를 감지해 mainStore에서 단어 삭제 기능을 호출한다.

단어 삭제 명령을 해당 컴포넌트에서 호출하도록 했다. 해당 단어 컴포넌트의 모달에서 직접 삭제 명령을 호출하는 것보다 모달에서의 삭제 이벤트 발생을 감지해 WordCard.vue에서 삭제 책임을 갖는게 더 적합하다고 생각했다.

WordList.vue

components/WordList.vue
<template>
  <!-- ... -->
    <WordCardV2
      :word="word.word"
      :means="word.means"
      :check="word.check"
      :time="word.time"
      :index="index"
    />
  <!-- ... -->
<template>

커스텀 이벤트 trashCanWordDetail를 제거했기에 WordCard 컴포넌트의 이벤트 감지 또한 제거한다. 또한 WordCard에서 means를 computed 이용해 조작하기에 그대로 전달한다.

components/ModalWordDetailV2.vue
<script setup>
import TheModal from './TheModal.vue';

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

const emits = defineEmits(['close', 'delete']);

function closeDetailWord() {
  emits('close');
}

function deleteWord() {
  emits('delete');
}
</script>

<template>
  <TheModal @clickCloseIcon="closeDetailWord">
    <template #word>
      {{ props.word }}
    </template>
    <template #means>
      <li v-for="mean in props.means" :key="mean" class="modal_means">
        {{ mean }}
      </li>
    </template>
    <template #footer>
      <div class="flex justify-end modal_timetext">{{ props.time }}</div>
      <div class="flex gap-x-4">
        <button
          @click="closeDetailWord"
          class="modal_btn bg-neutral-400 hover:bg-neutral-500"
        >
          cancel
        </button>
        <button
          @click="deleteWord"
          class="modal_btn bg-rose-400 hover:bg-rose-600"
        >
          delete
        </button>
      </div>
    </template>
  </TheModal>
</template>

이전에는 pinia store인 detailWordStore에서 단어의 정보들을 받아 화면에 화면에 렌더링 했지만, 중앙 스토어 이용은 불필요하다고 생각했기에 제거했다.
리팩터링 후, 해당 Modal에서는 props을 전달받은 값들을 화면에 렌더링하고, emits를 통해 커스텀 이벤트를 선언하여 부모 컴포넌트인 WordCard에서 이벤트 발생 감지에 따라 특정 기능들을 동작하게 했다.

ModalWordDetailV2.vue의 뼈대인 TheModal 컴포넌트에서도 같은 방식으로 커스텀 이벤트 clickCloseIcon을 이용해 모달 닫기 명령을 TheModal => ModalWordDetailV2 => WordCardV2로 이동해 모달을 닫는다.

쓰레기통 단어인 WordTrashCan에 관련된 컴포넌트들도 같은 방식으로 리팩터링했다.

마무리

propsemits을 통해 추가적인 흐름이 생겼지만, WordCard.vue가 특정 단어에 대한 책임을 담당한다. 이러한 구조는 pinia 스토어를 사용한 방식보다 훨씬 직관적이고 간단하다고 생각한다.

이다음에는 단어를 입력했을 때 나오는 모달들에 대한 코드들을 리팩터링 할 예정이다.


댓글