단어장 리팩터링 - 3
intro
현재 pinia store인 Main.js
에서 로컬 스토리지의 저장된 단어들의 상태와 그 단어들을 다루는 모든 기능들이 몰려 있어 가독성 및 모듈성이 떨어진다. 이것을 해결하는 것이 이번 리팩터링 목표다.
리팩터링 전 mainStore 코드
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
기능을 이용
로컬 스토리지 단어 분리
로컬 스토리지의 단어를 note 디렉터리로 분리한 구조
관련 기능들을 분리하는 김에 관련 컴포넌트 또한 하나의 디렉터리에 모았다.
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
<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
<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
에 삭제한 단어를 추가한다는 점이다. 노트와 휴지통 관련 스토어를 분리했기 때문이다.
로컬 스토리지 휴지통 단어 분리
휴지통 관련 리팩터링은 노트와 같기에 글에서는 생략한다.
로컬 스토리지 최근 검색한 단어 분리
최근 검색한 단어를 나타내는 컴포넌트와 로컬 스토리지의 최근 검색한 단어의 상태와 기능을 관리하는 pinia 스토어로 구성된다.
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)
: 매개변수tagetWord
를localRecentSearchWords
맨 앞에 추가하는 함수localRecentSearchWords
에 이미tagetWord
가 있는 경우 삭제하고 맨 앞에 추가한다.deleteRecentSearchWord(targetWordIndex)
: 삭제할 단어의 위치를 받아localRecentSearchWords
에서 지운다.
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 되면 화면에 보여지는 최근 검색한 단어 창 컴포넌트recentSearchStore
의 localRecentSearchWords
를 이용해 최근 검색한 단어를 화면에 렌더링하고, 단어를 클릭하면 emits
를 이용해 부모 컴포넌트인WordInput
에 클릭했음을 알려 해당 단어를 검색한다.
삭제 버튼을 클릭하면 recentSearchStore
의 deleteRecentSearchWord
함수를 호출해 저장된 최근 검색한 단어를 지운다.
화면 전환 상태 분리
mainStore에서 했던 노트와 휴지통 전환의 상태 관리를 최상위 컴포넌트 App.vue
에서 provide, inject
를 이용해 관리하도록 변경했다.
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>
isNoteMode
가 true
면 노트, false
면 휴지통을 렌더링하며, changeMode
함수를 통해 노트와 휴지통을 전환한다.
처음에는 props
, emits
을 통해 전환을 구현하려 했지만, 화면 전환 상태는 App.vue
-> TheHeader.vue
-> WordInput.vue
로 중첩되어 이를 prop
, emits
로 구현하려면 기능에 비해 흐름이 너무 복잡해진다고 생각했다.props
, emits
방식과 다르게 App.vue
에서 provide
를 통해 제공해 TheHeader.vue
와 WordInput.vue
에서 화면 전환 상태와 상태 변경 함수를 inject
를 통해 주입해 이용하도록 했다.
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
을 사용한 방식과 다른 방식을 비교해 보면,
props
,emits
을 이용한 방식은 컴포넌트의 계층이 깊어지면 데이터 흐름이 복잡해지며, 중복된 코드가 많아진다.props
만을 이용해 하위 컴포넌트로 데이터를 전달하기만 하면 그나마 괜찮지만,emits
를 통해 하위 컴포넌트에서 상위 컴포넌트로의 흐름이 생기면 너무 복잡해진다. 하지만 이 방식은 부모 자식간의 계층이 얕으면 데이터 흐름이 명시적이고 예측이 가능해, 코드를 이해하기 쉽다는 장점이 있다.- pinia store를 이용해 전역적으로 관리하는 방식은 어디서나 손쉽게 이용할 수 있다는 장점이 있지만, 어디서나 쉽게 이용해 흐름을 예측하기 어렵고, 코드를 이해하기에도 어려움이 있다.
provide
,inject
을 이용한 방식을 사용했을 때의 느낌은 1번과 2번 방식의 중간에 있는 방식같다는 것이다. 해당 방식은 부모 컴포넌트의 하위 트리에만 전달 할 수 있기에, pinia를 이용해 전역으로 관리하는 방식 보다는 덜 자유롭지만props
,emits
방식보다는 훨씬 자유롭다.
또한, pinia를 이용한 방식보다는 데이터 흐름을 예측하기 쉽지만props
,emits
보다는 어렵다.
마무리
이번 리팩터링은 여기서 마무리하려 한다. 예상보다 리팩터링 할 게 많아 오래 걸렸다.
9개월 전 해당 프로젝트를 마무리했을 때만 해도 이렇게 미흡한 점이 많았을 줄 몰랐다. 지금 봤을 때 참 부끄러운 코드들이다. 그때보다는 실력이 향상됐기에 그렇게 느낀다고 긍정적으로 생각하는 게 좋을 거 같다.
다음 리팩터링은 해당 프로젝트의 코드가 또 부끄럽다고 느껴지면 할 예정이다.