/po-fe-12th-w3

[원티드 프리온보딩 인턴십 | 프론트엔드 12차] 3주차 개인 과제 레포지토리입니다.

Primary LanguageTypeScript

원티드 프리온보딩 12th 3주차 개인 과제

원티드 프리온보딩 12th 3주차에 진행된 개인 과제입니다.

본 과제는 한국임상정보의 검색 기능을 구현하는 것이 목표입니다.

🧑🏻‍💻 프로젝트 정보

실행 방법

git clone https://github.com/devseop/po-fe-12th-w3
npm install
npm start

프로젝트 구조

src
 ┣ api
 ┃ ┗ api.ts
 ┣ components
 ┃ ┣ DeleteButton.tsx
 ┃ ┣ HighlitedKeyword.tsx
 ┃ ┣ SearchBar.tsx
 ┃ ┗ SearchKeywordList.tsx
 ┣ constants
 ┃ ┗ constant.ts
 ┣ context
 ┃ ┗ searchContext.tsx
 ┣ hooks
 ┃ ┣ useDebounce.ts
 ┃ ┗ useInput.ts
 ┣ pages
 ┃ ┗ SearchSection.tsx
 ┣ types
 ┃ ┗ type.ts
 ┣ utils
 ┃ ┗ cache.ts
 ┣ App.tsx
 ┣ index.tsx
 ┗ style.css

사용 라이브러리

"dependencies": {
    "@emotion/styled": "^11.11.0",
    "axios": "^1.4.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-icons": "^4.10.1",
    "react-router-dom": "^6.15.0",
    "react-scripts": "5.0.1",
    "typescript": "^4.9.5",
  },

"devDependencies": {
    "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
    "@typescript-eslint/parser": "^5.62.0",
    "concurrently": "^8.2.1",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "husky": "^8.0.3",
    "json-server": "^0.17.3",
    "lint-staged": "^14.0.1",
    "prettier": "^3.0.2"
  },

📝 구현 내용

search_sick

- 질환명 검색시 API 호출 통해서 검색어 추천 기능 구현
  - 검색어가 없을 시 “검색어 없음” 표출

- API 호출별로 로컬 캐싱 구현
  - 캐싱 기능을 제공하는 라이브러리 사용 금지(React-Query 등)
  - expire time을 구현
    
- 입력마다 API 호출하지 않도록 API 호출 횟수를 줄이는 전략 수립 및 실행
    
- API를 호출할 때 마다 `console.info("calling api")` 출력을 통해 콘솔창에서 API 호출 횟수 확인이 가능하도록 설정

- 키보드만으로 추천 검색어들로 이동 가능하도록 구현

✅ Assignment 1

  1. API 호출을 통한 검색어 추천 기능 구현

axios.create()를 사용하여 api라는 axios 인스턴스를 생성합니다.
이 인스턴스를 통해 기본 URL(BASE_URL)을 설정하고, 이후에 이 인스턴스를 사용하여 모든 API 요청을 보냅니다.
이렇게 코드를 작성하여 중복된 URL을 사용하지 않고 효율적으로 코드를 관리합니다.

  1. 검색어가 없을 시 “검색어 없음” 표출

Context API와 useReducer()를 이용해 전역 상태로 데이터를 관리합니다.
저장한 상태값(sickList)의 1번째 인덱스가 undefined인 경우 "검색어 없음"이 표출됩니다.

✅ Assignment 2

  • API 호출별로 로컬 캐싱 구현
  • expire time 구현 (선택)

입력한 검색어에 따른 결과를 따로 저장하고 추후 재사용한다는 것에 초점을 맞춰 localStorage를 이용해 구현했으나,
추후 팀과제 중 localStorage의 저장 용량 이슈에 대해 알게 되어 cache API를 사용하여 리팩토링을 했습니다.
키 생성, 데이터 및 만료 시간 저장, 캐시에서 데이터를 가져오기, 삭제 등 기능별로 함수를 구분하여 구현했습니다.
이 때 만료시간에 사용되는 값은 상수화하여 관리가 용이하도록 했습니다.

코드 보기
export const generateCacheKey = (url: string, params: IParams = {}) => {
const sortedParams = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&');
return `${url}?${sortedParams}`;
};
/** 로컬 캐시에 데이터와 만료 시간을 저장하는 함수 */
export const setCacheWithExpiration = async (cacheName: string, key: string, data: ISick[]) => {
const currentTime = new Date().getTime();
/** 현재 시간에 만료 시간을 더하여 유효 시간을 설정 */
const expirationTime = currentTime + EXPIREATION_MINUTE * 60 * 1000; // 유효시간 30분 제한
try {
const cache = await caches.open(cacheName);
const cacheData = {
data,
expirationTime,
};
await cache.put(key, new Response(JSON.stringify(cacheData)));
} catch (err) {
console.error(err);
}
};
/** 로컬 캐시에서 데이터를 가져오는 함수 */
export const getCache = async (cacheName: string, key: string) => {
try {
const cache = await caches.open(cacheName);
const res = await caches.match(key);
if (res) {
const cacheData = await res.json();
if (cacheData.expirationTime && new Date().getTime() > cacheData.expirationTime) {
await cache.delete(key); // 캐시가 만료되었으면 삭제
return null;
}
return cacheData.data;
}
return null;
} catch (err) {
console.error(err);
return null;
}
};
/** 로컬 캐시에서 데이터를 삭제하는 함수 */
export const removeCache = async (key: string) => {
try {
const cache = await caches.open('my-cache');
await cache.delete(key);
} catch (error) {
console.error('Error deleting cached data:', error);
}
};

✅ Assignment 3

입력마다 API 호출하지 않도록 API 호출 횟수를 줄이는 전략 수립 및 실행

검색창에서 발생하는 이벤트는 ‘텍스트를 입력’하는 것이고, 입력한 텍스트에 대한 결과를 요청(query)하여 검색창 하단에 보여줄 것입니다.
검색창에서 질환을 입력시에 키보드 이벤트가 발생할 때마다가 API를 호출하므로 이는 호출 횟수의 증가뿐만 아니라 비용 또한 상승하게 됩니다.
따라서 디바운싱을 이용해 이벤트를 몇 번이나 발생시키든 이벤트를 실행하지 않고 일정한 시간이 지난 뒤에 API가 호출되도록 제어합니다. 저는 valuedelay를 인자로 받아 디바운싱된 value를 반환하도록 custom hook으로 구현했습니다.

코드 보기
export const useDebounce = <T>(value: T, delay = DEBOUNCE_DELAY_TIEM) => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeOut = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeOut);
};
}, [value, delay]);
return debouncedValue;
};

✅ Assignment 4

API를 호출할 때 마다 console.info("calling api") 출력을 통해 콘솔창에서 API 호출 횟수 확인이 가능하도록 설정

API 호출 / 호출 실패 / 로컬 캐싱 사용의 목적에 따라 다른 콜솔이 호출됩니다. 실질적으로 API 호출을 요청하는 역할을 담당하는 api/ts 파일에 작성했습니다.

코드 보기
export const fetchSickList = async (query: string): Promise<ISick[]> => {
const encodedQuery = encodeURIComponent(query);
const cacheName = `${query}_cache`;
/** 캐시 키 생성 */
const cacheKey = generateCacheKey(`/${URL_HOST}`, { q: encodedQuery });
/** 캐싱된 데이터를 가져오기 */
const cachedData = await getCache(cacheName, cacheKey);
if (cachedData) {
console.info('📦 Using cached data');
return cachedData;
}
try {
const res = await api.get(`/${URL_HOST}?q=${encodedQuery}`);
console.info('✅ Calling API');
const data = res.data;
setCacheWithExpiration(cacheName, cacheKey, data);
return data;
} catch (error) {
console.error('❌ API request failed', error);
throw error;
}
};

✅ Assignment 5

키보드만으로 추천 검색어들로 이동 가능하도록 구현

keydown 이벤트로 키보드의 방향키를 눌렀을 때 리스트 사이를 이동합니다.. 별도의 selectedIndex상태값을 두어 selectedIndex와 검색어 아이템의 인덱스가 같을 경우, ref.current.focus()로 해당 아이템을 포커싱하여 강조하는 스타일링을 구현했습니다.

코드 보기
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const selectListItemByKeyArrow = (
e: React.KeyboardEvent<HTMLInputElement> | React.KeyboardEvent<HTMLLIElement>,
) => {
if (e.nativeEvent.isComposing) return;
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
const lastIndex = sickList.length - 1;
setSelectedIndex((prev) => (prev < lastIndex ? prev + 1 : 0));
break;
}
case 'ArrowUp': {
e.preventDefault();
const lastIndex = sickList.length - 1;
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : lastIndex));
break;
}
default:
break;
}
};
return (
<Container>
<Header>{headerText}</Header>
<SearchBar
setSelectedIndex={setSelectedIndex}
selectListItemByKeyArrow={selectListItemByKeyArrow}
/>
<SearchKeywordList
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
selectListItemByKeyArrow={selectListItemByKeyArrow}
/>
</Container>
);
};

🫱🏻‍🫲🏿 Commit Convention

커밋 컨벤션과 브랜치 전략은 1주차 팀 과제 진행시 결정된 팀 컨벤션을 따랐습니다.

e.g. FEAT: 로그인 유효성 검증 기능 구현

태그 설명 (한국어로만 작성하기)
FEAT: 새로운 기능 추가 (변수명 변경 포함)
FIX: 버그 해결
DESIGN: CSS 등 사용자 UI 디자인 변경
STYLE: 코드 포맷 변경, 세미 콜론 누락, 코드 수정이 없는 경우
REFACTOR: 프로덕션 코드 리팩토링
COMMENT: 필요한 주석 추가 및 변경
DOCS: 문서를 수정한 경우
CHORE: 빌드 테스크 업데이트, 패키지 매니저 설정(프로덕션 코드 변경 X)
RENAME: 파일 혹은 폴더명을 수정하거나 옮기는 작업
REMOVE: 파일을 삭제하는 작업만 수행한 경우
INIT: 초기 커밋을 진행한 경우