
엘리스트랙 1차 프로젝트 당시 진행했던 바닐라JS로 쇼핑몰 구축 프로젝트에서 네비게이션 중 글로벌 검색 기능 및 최근 검색어 기능을 구현했었다.
검색 페이지 자체는 다른 팀원분께서 만들어두신 것이 있어서 검색 기능은 금방 구현하였으나 이번 프로젝트에서 로컬스토리지를 활용해 보고 싶었기 때문에! 코치님께 추천받은 최근 검색어 기능을 추가해 보기로했다.
이 기능은 최종 프로젝트 발표일 하루 전 저녁에 사용성 부분에서 포인트가 있었으면 좋겠다는 피드백을 받고 급하게 구현한 기능이라 모든 것이 하드코딩 되어있었다. 때문에 코드도 길고.. 가독성도 좋지 않은 일회성 코드들로 도배되어 버렸다..😭
이번 리팩토링에서는 컴포넌트들의 로직에 대한 개선보다는 하드코딩 된 함수들을 상수화, 유틸화, api로 만들어 추후 재사용하기 용이하도록 개선해보았다.
//Search.js
//텍스트로 검색하고 검색어를 최근 검색어에 저장하는 기능
function searchByTerm(event) {
event.preventDefault();
let inputValue;
if (event.target.tagName === 'LI') {
inputValue = event.target.firstChild.textContent;
if (inputValue !== '') {
window.location.href = `/products/?team=all&category=all&sort=recent&search=${inputValue}`;
}
} else {
const input = document.querySelector('.search-content');
inputValue = input.value;
if (inputValue !== '') {
window.location.href = `/products/?team=all&category=all&sort=recent&search=${inputValue}`;
let recentSearches =
JSON.parse(localStorage.getItem('recentSearches')) || [];
recentSearches.unshift(inputValue);
recentSearches = Array.from(new Set(recentSearches));
localStorage.setItem('recentSearches', JSON.stringify(recentSearches));
}
}
}
//최근 검색어를 삭제하는 기능
function deleteSearchTerm(event) {
...
let recentSearches = JSON.parse(localStorage.getItem('recentSearches')) || [];
recentSearches = recentSearches.filter(term => term !== termToDelete);
localStorage.setItem('recentSearches', JSON.stringify(recentSearches));
renderRecentTerms(event);
}
//전체 최근 검색어를 삭제하는 기능
function deleteAllTerms() {
localStorage.setItem('recentSearches', JSON.stringify(''));
}
//최근 검색어 컨테이너 외부를 클릭할 시 돔요소가 삭제되는 기능
function removeRecentTermsContainer(event) {
...
}
//검색어 폼을 클릭할 시 최근 검색어 컨테이너를 렌더링 하는 기능
function renderRecentTerms(event) {
...
//최근 검색어가 있다면 렌더링되는 조건
const recentSearches =
JSON.parse(localStorage.getItem('recentSearches')) || [];
if (recentSearches.length > 0) {
//최근 검색어 컨테이너 관련 돔 요소들을 그리는 코드
...
});
...
}
}
//검색 컴포넌트를 렌더링하는 기능
export function SearchBox(target) {
...
}
//constants.js
export const ROUTES = {
HOME: '/',
LOGIN: '/login',
LOGIN_AUTH: '/login/auth',
FIND_PASSWORD: '/find-password',
FIND_PASSWORD_RESET: '/find-password/reset',
SIGNUP: '/signup',
PRODUCTS: '/products',
PRODUCT_DETAIL: '/products/:id',
CART: '/cart',
ORDER: '/order',
ORDER_COMPLETE: '/order/complete',
LOGIN: '/login',
...
};
//utils.js
function generateUrl(path, params = {}, method = 'pathVariable') {
let url = '';
if (method === 'pathVariable') {
//1)
url = path;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, value);
}
} else if (method === 'queryString') {
//2)
const urlObject = new URL(path, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
urlObject.searchParams.set(key, value);
});
url = urlObject.toString();
}
return url;
}
//3)
export function navigateTo(path, params = {}, method = 'pathVariable') {
const url = generateUrl(path, params, method);
window.location.href = url;
}
1) generateUrl() 함수에서 method는 URL을 생성하는 방법을 지정하는 매개변수다. 'pathVariable'으로 설정된 경우 path 문자열에 존재하는 :key 형태의 템플릿 변수를 params 객체에서 해당 key의 값으로 치환하여 URL을 생성한다.
generateUrl('/user/:id', { id: '123' })가 호출되면 /user/123이 반환된다.
2)method 매개변수가 'queryString'인 경우, path 문자열에 쿼리스트링을 포함할 수 있다. params 객체에 있는 키-값 쌍은 쿼리스트링으로 변환되어 URL에 추가된다. 이때 new URL() 생성자를 사용하여 URL 객체를 생성한 뒤, searchParams 프로퍼티를 사용하여 URL 쿼리스트링을 설정한다.
generateUrl('/users', { page: 1, limit: 10 })가 호출되면 현재 도메인의 /users?page=1&limit=10 URL이 반환된다.
3) navigateTo() 함수에서는 generateUrl() 함수를 호출하여 생성된 URL로 브라우저를 이동시키기 위해 window.location.href를 변경한다.
//utils.js
export function getItem(key) {
return JSON.parse(localStorage.getItem(key));
}
export function setItem(key, value) {
return localStorage.setItem(key, JSON.stringify(value));
}
setItem('recentSearches', '['유니폼', '머리띠']') 함수를 사용하여 name이라는 키에 John이라는 값을 저장하면, getItem('recentSearches') 함수를 사용하여 해당 값을 다시 불러올 수 있다.
//searchAPI.js
import { getItem, setItem } from '/js/utils.js';
//1)
const RECENT_SEARCHES = 'recentSearches';
//2)
export function getRecentSearches() {
return getItem(RECENT_SEARCHES) ?? [];
}
//3)
export function createRecentSearch(inputValue) {
const newRecentSearches = Array.from(
new Set([inputValue, ...getRecentSearches()])
);
setItem(RECENT_SEARCHES, newRecentSearches);
}
//4)
export function deleteRecentSearch(termToDelete) {
const newRecentSearches = [...getRecentSearches()].filter(
(term) => term !== termToDelete
);
setItem(RECENT_SEARCHES, newRecentSearches);
}
//5)
export function deleteAllRecentSearches() {
setItem(RECENT_SEARCHES, '');
}
1) 검색어를 저장할 localStorage 키 값 상수
2) getItem(RECENT_SEARCHES) 유틸 함수를 이용하며, 만약 해당 키로 저장된 값이 없다면 빈 배열을 반환한다.
3) 새로운 검색어를 localStorage에 추가함. 현재 localStorage에 저장된 검색어와 중복되는 값이 있다면 제외한다. setItem(RECENT_SEARCHES, newRecentSearches) 유틸 함수를 이용해 저장한다.
4) localStorage에 저장된 검색어 중, termToDelete 매개변수로 전달된 검색어를 삭제한다. filter() 함수를 이용해 termToDelete와 같지 않은 검색어만 필터링하고 setItem(RECENT_SEARCHES, newRecentSearches) 유틸함수를 이용해 저장한다.
5) localStorage에 저장된 모든 검색어를 삭제한다. setItem(RECENT_SEARCHES, '') 유틸 함수를 이용해 저장한다.
//Search.js
import { ROUTES } from '/js/constants.js';
import { navigateTo } from '/js/utils.js';
import {
getRecentSearches,
createRecentSearch,
deleteRecentSearch,
deleteAllRecentSearches,
} from '/js/api/searchAPI.js';
//텍스트로 검색하고 검색어를 최근 검색어에 저장하는 기능
function searchByTerm(event) {
...
//중복 코드 개선 및 상수 URL과 생성/연결 util 사용
if (inputValue !== '') {
navigateTo(
ROUTES.PRODUCTS,
{
team: 'all',
category: 'all',
sort: 'recent',
search: inputValue,
},
'queryString'
);
//최근 검색어 저장 api 사용
createRecentSearch(inputValue);
}
}
//최근 검색어를 삭제하는 기능
function deleteSearchTerm(event) {
...
//최근 검색어 삭제 api 사용
deleteRecentSearch(termToDelete);
renderRecentTerms(event);
}
//최근 검색어 컨테이너 외부를 클릭할 시 돔요소가 삭제되는 기능
function removeRecentTermsContainer(event) {
...
}
//검색어 폼을 클릭할 시 최근 검색어 컨테이너를 렌더링 하는 기능
function renderRecentTerms(event) {
//최근 검색어 불러오기 api 사용
const recentSearches = getRecentSearches();
if (recentSearches.length > 0) {
//최근 검색어가 있다면 렌더링되는 조건
...
//전체 최근검색어 삭제 api 사용
deleteAllTermsButton.addEventListener('click', deleteAllRecentSearches);
...
}
...
}
//검색 컴포넌트를 렌더링하는 기능
export function SearchBox(target) {
...
}
코드를 추상화함으로써 확실히 코드가 간결해지고 읽기가 쉬워졌다!!!
이번 리팩토링을하면서 처음엔 코치님께서 리뷰 주신 URL상수화, URL생성 유틸화 정도만 진행하고 컴포넌트 생성 로직을 수정하려했는데 수정하면서 localStorage관련 함수도 묶게되었고, 포스팅하는 중간중간 묶을 수 있는 부분이 보여서 생각보다 이번 리팩토링에 시간을 많이 할애했다. 그만큼 이번 리팩토링을 통해 배운 것이 많았고 다시 한 번 빠른 기능구현만이 중요한게 아니란걸 느꼈다. (그래도 피드백 주신 부분 외에 리팩토링할 부분을 척척 찾아냈다는게 좀 성장한 것 같아서 뿌듯하다 ㅎㅎ)
이전에 개발을 모르던 PM시절엔 한 부분만 수정했는데 다른 페이지도 바뀌고 난리가나는지 이해가 안갔는데, 이런 부분이 부족해서 그랬던거였구나.. 너무 뒤늦게 깨달아버렸다. 아직 상수화한 변수, 유틸화한 함수들을 하드코딩한 다른 페이지에도 리팩토링하며 적용시키는 작업을 마무리하는 작업이 남았다. 이 작업이 끝나야지 비로소 이번 리팩토링이 유의미해지겠지...
이번 리팩토링으로 프로젝트에서 어떤 메서드나 함수들이 전역적으로 사용되는지 어느정도 파악했고, 상수화해야하는 변수도 기준이 모호했는데 갈피를 잡은 것 같다. 다음 프로젝트부터는 리팩토링하면서 코드를 수정할 생각을 하지말고 초장부터 자주 쓰는 내용들은 상수화, 유틸화하여 작업해야겠다.
다음 포스팅에서는 최근 검색 컴포넌트들을 렌더링 하는 조건을 효율적인 방법으로 변경해보고, dom선택과 컴포넌트화를 명확하게 해보려 한다.