리액트를 마지막으로 다룬지 5개월이 지났습니다. 예전에는 리액트랑 타입스크립트를 그렇게 좋아했는데 이제는 서먹한 사이가 되었습니다. 제가 마지막으로 작성했던 코드가 무엇이었나 떠올리다가 예전에 과제로 제출했던 코드가 마지막이더군요. 그 때 정말 나름 최선을 다했는데, 아무도 알아주지 않아서 매우 억울했습니다. 지금 다시까보면 제가 부족했던 점이 보이겠지요. 그래서 한 번 내 코드를 회고해보려고 합니다.
( 미국 AI 말투입니다. 곧 개발자들 사이에서 유행하게 될 말투입니다.)
기능 스코프
- 명언 리스트 페이지가 있습니다.
- 사용자가 좋아하는 명언을 발견하면 LocalStorage에 즐겨찾기와 같이 추가하거나 해제할 수 있습니다.
- 새로고침을 해도 즐겨찾기에 등록된 명언은 유지되어야 합니다.
매우 간단한 기능입니다. 아마 프론트엔드 개발자라면 누구나 한 번쯤 쉽게 구현해보고 떠올려봤을 기능입니다.
설계 아이디어
명언이라는 아이템(요소)에 집중하지 않아야겠다고 생각했습니다.
우선 로컬스토리지에 대해 간단하게 설명해보겠습니다.
로컬스토리지란 key - value
형식으로 저장하고 웹브라우저 스토리지에 저장하게 됩니다. 이는 서버와의 통신이 불필요합니다. 백엔드 서버에 저장하고 네트워크 통신으로 데이터를 가져오지 않습니다. 좀 더 나아가자면, 세션 스토리지도 있는데 로컬 스토리지는 탭을 닫아도 저장되는 영구적인 특성을 가지고 있습니다. (세션 스토리지는 탭을 닫으면 사라집니다.)
무슨 말이냐면,
- 명언을 로컬스토리지에 저장하고 제거할거야 (X)
- 저장될 내용은 명언일 수도, 좋아하는 장소일 수도, 혹은 좋아하는 사람의 프로필일 수도 있습니다. 즉, 저장할 아이템의 종류는 정해지지 않았습니다. 중요한 점은 그저 로컬스토리지를 활용해 관리하는 기능 자체를 추상화하겠다는 거야.
무엇이 필요한가??
- 로컬스토리지에 저장한다.
- 로컬스토리지에서 제거한다.
- 로컬스토리지에 존재하는가?
- 로컬스토리지에서 값을 읽어온다.
제게 필요한 동작입니다. 동작을 구현하는 것은 쉬워요. 행동을 추상화하면 오히려 코드의 구현이 깔끔해지고 쉬워지는 경험을 할 수 있습니다.
구현해보겠습니다.
로컬 스토리지에 아이템을 저장하려 할 때, 이미 동일한 아이템이 존재한다면 어떻게 해야 할까요? 대부분의 경우 중복 저장은 허용되지 않습니다. 삭제해야 한다고 떠올릴 수 있겠지만, 우린 그전에 해야할 동작이 하나 있기 때문에 중복이라는 단어를 꺼냈습니다. 중복을 확인하기 위해서는요? 아이템이 로컬스토리지에 이미 저장되어 있는지 확인해야 합니다. -> 그렇다면 **로컬스토리지에 존재하는가?**와 같은 동작이 필요합니다. 구현드가자.
isStoredItem: (key: string, id: number | string): boolean => {
try {
const items: StorageMap<any> = JSON.parse(
window.localStorage.getItem(key) || '{}'
)
return id in items
} catch (error) {
console.error(
'로컬 스토리지를 읽어오는데 실패했습니다.isStoredItem',
error
)
return false
}
},
바로 이어서 가겠습니다. 아직은 코드를 이해할 때가 아닙니다. 왜냐하면 이 코드는 제가 처음 작성하고 몇번의 시행착오와 수정을 통해 작성한 코드입니다. 코드를 처음 본 사람이 바로 이해하는 것은 매우 어렵습니다. 제 코드가 똥
이기때문입니다.
addItemToStorage: <T extends { id: number | string }>(
key: string,
item: StorageItem<T>
): void => {
try {
const items: StorageMap<T> = JSON.parse(
window.localStorage.getItem(key) || '{}'
)
items[item.id] = { ...item.data }
window.localStorage.setItem(key, JSON.stringify(items))
} catch (error) {
console.error(
'로컬 스토리지를 읽어오는데 실패했습니다.addItemToStorage',
error
)
}
},
여기서 quick 하게 짚고 넘어가야 할 것이 있습니다. 배열로 저장하지 않습니다. Object로 저장할려고 합니다. 이유가 무엇일까요?? 제 구현상 기대되는 아이템의 자료구조는 이렇습니다.
{
"2": {
"id": 2,
"author": "Abdul Kalam",
"quote": "The Bay of Bengal is hit frequently by cyclones. The months of November and May, in particular, are dangerous in this regard."
},
"3": {
"id": 3,
"author": "Abdul Kalam",
"quote": "Thinking is the capital, Enterprise is the way, Hard Work is the solution."
}
}
배열로 저장했다면
[
{
"id": 2,
"author": "Abdul Kalam",
"quote": "The Bay of Bengal is hit frequently by cyclones. The months of November and May, in particular, are dangerous in this regard."
},
{
"id": 3,
"author": "Abdul Kalam",
"quote": "Thinking is the capital, Enterprise is the way, Hard Work is the solution."
}
]
이런 구현이지 않았을까요?? 둘의 차이점은 무엇일까요?? 자료구조가 가지는 성능상의 차이가 있습니다. **우리는 아이템을 저장하기 전에 이미 저장되어 있는 아이템인가? **확인하기로 했습니다. 배열은 O(n)입니다. 객체의 in 메서드를 사용하면 O(1)으로 접근할 수 있습니다. 성능적으로 매우 이득입니다. 개이득입니다. 확실합니다.
다음은 추가했다면 삭제입니다.
removeItemFromStorage: (key: string, id: number | string): void => {
try {
const items: StorageMap<any> = JSON.parse(
window.localStorage.getItem(key) || '{}'
)
if (id in items) {
delete items[id]
window.localStorage.setItem(key, JSON.stringify(items))
}
} catch (error) {
console.error('Error removing from localStorage', error)
}
},
추가로 설명할게 없습니다. 그냥 삭제입니다. 위에 올려보시면 제게 필요한 기능은 총 4가지가 있다고 했습니다. 로컬스토리지에 어떤 값이 들어있는가? read 하는 동작은 storage에서 필수인 동작입니다. 하지만 지금 설명하지 않을게요. 글에 흐름상 뒤에 배치되어야 이해하기 쉽습니다.
우리는 지금 로컬스토리지 동작을 나름 일반화한 유틸 함수를 봤습니다. 진짜 본격적으로 커스텀훅을 만들어봐야 합니다.
useFavoriteItem
지금 보니 네이밍에 문제가 있습니다. 흠 useLocalstorageHooks? 흠 아닌가 흠 여튼 네이밍은 3점정도로 리팩토링이 필요할 것 같습니다. 실은 다시 보면서 고칠 구석이 많습니다. 밝히지 않도록 하겠습니다.
const useFavoriteItem = <T extends { id: number | string }>(
key: string,
item: T,
useFavoriteStore: () => StorageState<T>
) => {
const [isFavorite, setIsFavorite] = useState<boolean>(
localStorageUtils.isStoredItem(key, item.id)
)
const favoriteStore = useFavoriteStore()
const { loadFavorites } = favoriteStore
const toggleFavorite = () => {
if (isFavorite) {
localStorageUtils.removeItemFromStorage(key, item.id)
} else {
const storageItem: StorageItem<T> = {
id: item.id,
data: item,
}
localStorageUtils.addItemToStorage(key, storageItem)
}
setIsFavorite((prev) => !prev)
loadFavorites(key)
}
return { isFavorite, toggleFavorite }
}
export { useFavoriteItem }
제가 작성한 커스텀 훅입니다.
useFavoriteStore()
,const { loadFavorites } = favoriteStore
loadFavorites(key)
이 부분은 아직은 몰라도 됩니다. 제가 위에 언급한 리스트에 있는 코드를 무시하고 보면 그냥 쉬운 커스텀훅입니다.
이 커스텀훅은 사용자가 어떤 버튼을 클릭할 때 일어나는 액션일 것입니다. 그것을 토글
이라고 합니다. on/off
, true/false
두 가지 상태 밖에 없는 상황에서 전환되는 것을 토글이라고 한답니다.
또한 실제로 이 커스텀훅이 사용될 위치에서 상태 관리, 버튼의 액션은 프로덕트(item) 의 정보와 어떤 의존관계도 있지 않습니다. 단순히 눌렸는지 안눌렸는지가 중요할 것입니다.
여기서 사용될 것입니다.
- Button
const { isFavorite, toggleFavorite } = useFavoriteItem<QuoteCardProps>();
<Button
className="absolute right-2 top-2"
// 주석 처리
onClick={toggleFavorite}
variant="ghost"
>
{isFavorite ? (
<StarIcon fill={'#FFD700'} stroke={'#FFD700'} />
) : (
<StarIcon fill={'transparent'} className={'text-gray-400'} />
)}
</Button>
네, 버튼을 누르는 것이 중요합니다.
킥 . 포인트 어떻게 추상화할 것인가?
눈치 채셨을 것입니다. 전역 상태 관리 라이브러리를 썼어요.
아마 선배 개발자분들은 시동걸고 있다고 봅니다. 왜 썼냐? 한 마디 안하면 참을 수 없습니다.
왜 그랬을까요??
우선, 제일 빈약한 첫 번째 근거는 과제에서 페이지와 컴포넌트 설계가 전역 상태 관리를 하지 않고는 힘들게 했습니다.
한 번 생각을 해봤습니다.
-
버튼을 클릭하면 일어나는 일은, 로컬스토리지에 저장, 삭제 되는 것입니다. 근데 이게 중요할까요? 물론, 이 기능자체는 문제 없이 동작하는 것은 매우 중요합니다. 하지만 이 동작의 성공과 실패를 상태 관리할까요?? 아닙니다. 버튼을 눌렀는지, 안눌렀는지 상태만 관리하면 됩니다. 그래서 제 커스텀 훅의 위치는 별표와 같은 버튼이 있는 아주 작은 단위의 컴포넌트의 위치해있습니다. -> 대신 컴포넌트의 상태를 끌어올려야할 가능성이 높아지는 행동입니다.
-
명언 리스트 페이지와 명언 즐겨찾기 리스트 페이지가 있습니다. 명언 리스트 페이지에서 즐겨찾기를 해제한다면 로컬스토리지에선 삭제가 되는데, 리액트가 UI를 업데이트 하지 않습니다. 당연합니다. 리액트 컴포넌트 트리에서 다른 위치에서 상태이트를 업데이트 하는데 어떻게 알아챕니까??
그러면 커스텀훅의 사용위치를 옮기고 액션을 정의하고, 상태를 공유하게 합니까? 이러면 제가 커스텀훅을 추상화할 수 없습니다. 단지 명언을 로컬스토리지에 저장하는 커스텀 훅이 될 뿐입니다.
제 커스텀훅은 명언뿐만이 아니라 앞으로 즐겨찾기의 유틸이 필요한 모든 위치에서 사용되길 바랍니다. 제 구현 아이디어는 이랬습니다. tanstack query의 useQuery처럼 queryKey로 구분하고 상태를 관리하는 것입니다.
전역 스토어를 만들어주는 함수가 있으면 안될까?
해봤습니다.
export const createFavoriteStore = <T extends { id: number | string }>() =>
create<StorageState<T>>((set) => ({
favoriteItems: [],
loadFavorites: (key) => {
const items = localStorageUtils.getLocalStorage<StorageMap<T>>(key, {})
const favoriteItems = Object.entries(items).map(([id, data]) => ({
id,
data,
}))
console.log(favoriteItems)
set({ favoriteItems })
},
}))
어떻게 쓰냐고요??
export const useFavoriteQuoteStore = createFavoriteStore<{
id: string | number
quote: string
author: string
}>()
이러면 명언의 대한 전역 상태 스토어가 만들어집니다. 만약 프로필 전역 상태를 만들고 싶다면요?
export const useFavoriteQuoteStore = createFavoriteStore<{
id: string | number
userName: string
description: string
}>()
이렇게 필요한 스토어만 추가해주면 됩니다. 그리고 커스텀 훅에 적용할 수 있습니다.
다시 보는 커스텀 훅
const useFavoriteItem = <T extends { id: number | string }>(
key: string,
item: T,
useFavoriteStore: () => StorageState<T>
) => {
const [isFavorite, setIsFavorite] = useState<boolean>(
localStorageUtils.isStoredItem(key, item.id)
)
const favoriteStore = useFavoriteStore()
const { loadFavorites } = favoriteStore
const toggleFavorite = () => {
if (isFavorite) {
localStorageUtils.removeItemFromStorage(key, item.id)
} else {
const storageItem: StorageItem<T> = {
id: item.id,
data: item,
}
localStorageUtils.addItemToStorage(key, storageItem)
}
setIsFavorite((prev) => !prev)
loadFavorites(key)
}
return { isFavorite, toggleFavorite }
}
export { useFavoriteItem }
이제는 앞서 봤던 것들은 무시하고 커스텀훅이 받는 세번째 파라미터와 loadFavorites()
이 받는 key에 집중하겠습니다.
커스텀 훅은 이렇게 사용할 수 있습니다.
const { isFavorite, toggleFavorite } = useFavoriteItem<QuoteCardProps>(
'favoriteQuotes',
{
id,
author,
quote,
},
useFavoriteQuoteStore
)
커스텀 훅에서 받는 key
는 loadFavorites
의 파라미터로 전달됩니다.
전역 스토어에선 id값을 바탕으로 localStorage를 읽고요, favoriteItems
라는 key를 가지는 전역 스토어를 만들게 됩니다.
누군가는 믿지 않을 거야. 그래서 보여드리겠어요.
- 전역스토어에 스토어를 준비해야돼요. (createFavoriteStore 생략한다.)
export const useFavoriteQuoteStore = createFavoriteStore<{
id: string | number
quote: string
author: string
}>()
준비가 되었다.
- 커스텀훅에
key
를 넘겨주고 어떤 스토어를 쓸 건지 알려주면 돼. 아 그리고 커스텀 훅에 어떤 스토어를 쓸 것인지도 세번째 파라미터로 넘겨주면돼
const { isFavorite, toggleFavorite } = useFavoriteItem<QuoteCardProps>(
'favoriteQuotes',
{
id,
author,
quote,
},
useFavoriteQuoteStore
)
믿기지가 않을 것이야. 마법이야 정말.
-
favortieQuotes Store
-
test Store
커스텀훅과 유틸 함수는 건드리지 않았음
test Store를 만들기 위해서 커스텀훅에서 어떤 구현도 추가적으로 하지 않았음. 단순히 key와 스토어만 바꼈다.
const { isFavorite, toggleFavorite } = useFavoriteItem<QuoteCardProps>(
'test',
{
id,
author,
quote,
},
useTestStore
)
export const useTestStore = createFavoriteStore<{
id: string | number
quote: string
author: string
}>()
회고
솔직히 말해서, 다시 보니 수정되어야 할 부분이 많습니다. 우선 타입 설계가 견고하면서 의도한만큼 유연하지는 못합니다. 이는 시간을 가지고 리팩토링을 하면 될 것 같아요. 그러나 과제를 진행하며서 많이 배울 수 있었습니다. 또한 제가 작성한 코드가 정답이고 좋은 것인지는 모르겠습니다. 지나 가던 다른 개발자가 보면 완전 틀리게 작성했다고 할 수도 있습니다. 근데 제 생각을 코드로 구현했다는 것에 저는 의미를 부여하겠습니다. 솔직히 createStore 함수라는 패턴, 이거 좋지 않나요?? 전 아직 이런거 본 적이 없는데요. 괜찮지 않나요?? 지나가다가 누군가 보신다면 코드리뷰 정말 진지하게 부탁드립니다. 화이팅입니다.!