포켓몬 약점 계산기 (Poke-match-type)
- 프론트엔드 : React, TypeScript
- 팀 : 1인 개발
- 깃허브 :https://github.com/changchangwoo/POKE-MATCH-TYPE
- 배포주소 : https://poke-match-type.site
- 코드 리팩토링
- 기능 추가
- 상성 퀴즈, 상성 테이블 페이지 추가
- 다국어 및 글로벌 테마
- 메가 진화 및 폼 체인지 검색
const { data: typeData, isLoading, error } = useFetchDetailType(typeNo);
useEffect(() => {
if (!typeData || isLoading || error) return;
const fetchData = async () => {
let result = await getDetailType(typeData);
if (selectedAbility && selectedAbility !== "") {
getAddAbility(result, selectedAbility);
}
let groupResult = await getGroupType(result);
setTypeRelations(groupResult);
};
fetchData();
}, [MatchTypes, selectedAbility, typeData, isLoading, error]);
const TypeCheckwithCharacter = ({ types, selectedAbility, setSelectedAbility}: TypeCheckProps) => {}
API 데이터를 처리하는 로직들은 모두 tanstackQuery 내부에서 동작하여 반환하도록 수정하였고, 컴포넌트의 단일 책임 원칙을 수행하도록 변경하였다.
또한 정적 텍스트들은 전부 상수화 하였으며 이를 통해 추후 다국어 지원에 있어서 통합적인 관리가 가능해졌다.
퀴즈 설정 로직
- 난수를 통해 랜덤 유형 접근
- 포켓몬 도감에서 도감 번호 랜덤 추출 후 API 요청
- 발췌한 포켓몬 타입에 맞춘 약점 계산 로직 동작으로 타입별 상성 리스트 생성
- 나올 수 있는 질문 배수를 (x0 —- x4) 중 랜덤으로 추출 후 정답 설정
- 설정된 질문 배수를 제외한 나머지 배수에서 랜덤으로 추출 후 보기 설정
유형1 | 유형2 | 유형3 |
---|---|---|
![]() 주어진 포켓몬의 특정 배수에 데미지를 가하는 공격 타입 고르기 |
![]() 부등호 방향에 적합한 blank 안에 들어갈 타입 고르기 |
![]() 타입 공격에 대해 방어 타입을 가진 포켓몬의 피해량 고르기 |
/* Quiz.tsx */
<div css={quizContainer}>
<h1>{text.QUIZ.TITLE}</h1>
{(() => {
switch (section) {
case 0:
return <QuizReady setSection={setSection} />;
case 1:
return <QuizIntro setSection={setSection} />;
case 2:
return (
<QuizMain setSection={setSection} />
);
case 3:
return <QuizEnd progressArr={progressArr} setSection={setSection} setProgressArr={setProgressArr}/>;
default:
return <div>{text.QUIZ.ERROR}</div>;
}
})()}
</div>
/* QuizMain.tsx */
<div css={matchCardContainer}>
{(() => {
switch (quizType) {
case 0:
return (
<QuizType0_damageEffectiveness /> // +props ...
);
case 1:
return (
<QuizType1_quizTypeInference /> // +props ...
);
case 2:
return (
<QuizType2_typeDescription /> // +props ...
);
default:
return <div>{text.QUIZ.ERROR}</div>;
}
})()}
{isNext && (
<button css={nextButton} onClick={handleNextButton}>
{text.QUIZ.NEXT}
</button>
)}
</div>
{alertType && <QuizAlert quizType={alertType} answerText={answerText} />}
UseEffect 내부 비동기 함수의 비동기 경쟁 조건 이슈
/* useGetDetailPokemonForQuiz.ts */ useEffect(() => { let isCancelled = false; const useFetchDetailPokemonQuiz = async (name: string = "") => { /* ... */ const fetchDatas = await fetchDetailPokemon(String(randomNum)); // 포켓몬 정보 데이터 요청 const fetchDetailTypeData = await fetchDetailType(typeNo); // 포켓몬 타입 데이터 요청 const circulateTypeData = await getDetailType(fetchDetailTypeData); // 요청한 타입 데이터를 바탕으로 상성 계산 let groupResult = await getGroupType(circulateTypeData); // 상성 계산 데이터 그룹화 if (isCancelled) return; // 언마운트 되어진다면 상태 변경 취소 setAnswerIdx(shuffleResult.findIndex((item) => item.no === correct.no)); setGroupResult(groupResult); setMatchDatas(matchDatas); setQuizNum(quizNum); setQuetstionArr(shuffleResult); }; return () => { isCancelled = true; }; // 클린업 함수 }, [progress]);
- 퀴즈 데이터는 매 문제마다 새롭게 요청되어야 하기 때문에tanstack-query가 아닌
useEffect
안에서 비동기 요청을 수행했다.- 하지만 의존을 가진 progress가 업데이트 되기 전, 이전 값을 기준으로 한 비동기 요청이 먼저 처리되는 경우가 발생했고
- 이로인해 이전 값을 기반으로 퀴즈가 생성되는 비동기 경쟁 조건 문제에 빠졌다.
⇒ 상태를 기준으로 동작하는 비동기 로직에서는 항상 컴포넌트 언마운트 여부나 상태의 최신성을 고려한 cleanup
처리가 필요하다는 것을 다시 한번 상기시킬 수 있었다.
다국어 | 테마 |
---|---|
![]() |
![]() |
/* App.tsx */
<ThemeContext.Provider value={{ theme, setTheme }}>
<Navigation />
</ThemeContext.Provider>
/* globalStyles.ts */
const themes: Record<TTheme, TThemeStyles> = {
light: {
point: "#DE7038",
/* 라이트모드 색상 값*/
},
dark: {
point: "#AEC6B5",
/* 다크모드 색상 값 */
},
};
export const globalStyles = (themeMode: keyof typeof themes = "light") => {
const theme = themes[themeMode];
return css`
:root {
/* 컬러 */
/* --point: #eb9191; */
--point: ${theme.point}; // 포인트 색
--primary: ${theme.primary}; // 배경 바탕
--background: ${theme.background}; // 디폴트(화이트)
--text: ${theme.text}; // 디폴트(블랙)
--border: ${theme.border}; // border 배경
--highlight: ${theme.highlight}; // 하이라이트 색상
--skeleton: ${theme.skeleton}; // 스켈레톤 색상
/*...*/
/* Navigation.tsx > SettingModal.tsx > Theme.tsx*/
const { theme, setTheme } = useContext(ThemeContext);
const handleThemeBtn = (
e: React.MouseEvent<HTMLButtonElement>,
data: TThemeData
) => {
e.stopPropagation();
setTheme(data);
localStorage.setItem(
"theme",
JSON.stringify({
...data,
})
);
};
{/* 네비게이션 내 버튼으로 Theme context 변경 */}
Theme
를 활용함에 있어 globalStyle로 정의한 css의 var만 바꾸도록 설정하니, ThemeContext Proivder의 범위를 설정 페이지가 있는 네비게이션으로만 한정할 수 있었다.
- 메가 진화, 거다이 맥스, 리젼 폼등 폼 네이밍이 명확히 고정된 경우 우선 필터링
- 이후 양 데이터에서 남은 폼 개수 비교
- 개수 일치시 매핑 수행, 그렇지 않으면 is_visible을 통해 데이터 숨김
{
"no": 487,
"name": {
"kor": "기라티나(어나더폼)",
"eng": "giratina"
},
"varieties": {
"kor": ["기라티나(오리진폼)"],
"eng": ["Giratina (Origin Forme)"]
}
},
/* 데이터 형식 */
"varieties": [
{
"is_default": false,
"pokemon": {
"name": "charizard-mega-x",
"url": "https://pokeapi.co/api/v2/pokemon/10034/"
}
}
]
/* 고정 폼 매핑 */
export const getSpeciesTranslate = (
name: string,
language: TLanguageType
): string => {
if (/-mega-x$/.test(name)) return speciesData["megaX"][language]
if (/-mega-y$/.test(name)) return speciesData["megaY"][language]
if (/-mega$/.test(name)) return speciesData["mega"][language]
if (/-gmax$/.test(name)) return speciesData["gmax"][language]
if (/-galar$/.test(name)) return speciesData["galar"][language]
if (/-alola$/.test(name)) return speciesData["alola"][language]
if (/-hisui$/.test(name)) return speciesData["hisui"][language]
if (/-paldea$/.test(name)) return speciesData["paldea"][language]
if (/-hoenn$/.test(name)) return speciesData["hoenn"][language]
if (/-sinnoh$/.test(name)) return speciesData["sinnoh"][language]
if (/-unova$/.test(name)) return speciesData["unova"][language]
if (/-kalos$/.test(name)) return speciesData["kalos"][language]
if (/-primal$/.test(name)) return speciesData["primal"][language]
if (name === "default") return speciesData["default"][language]
return name;
};
export const getFilterFixVarieties = (
pokeDexHash: Map<number, IPokeDex>,
no: string,
fetchVarietiesData: any,
language: TLanguageType
) => {
const pokeDexData = pokeDexHash.get(Number(no));
const cloneFetchVarietiesData = JSON.parse(
JSON.stringify(fetchVarietiesData)
);
const filterfetchVarieties = getFilterfetchVarieties(
cloneFetchVarietiesData,
language
);
// pokeAPI에서 추출한 폼 개수
const filterPokeDexVarieties = getFilterPokeDexVarieties(
language,
pokeDexData
);
// 포켓몬 도감 에서 추출한 폼 개수
const originData = fetchVarietiesData;
// 두 배열의 개수가 일치하는 경우
if (filterPokeDexVarieties?.length === filterfetchVarieties.length - 1) {
// pokeAPI에는 기본값도 개수로 들어가 있기에 1제거
filterPokeDexVarieties?.forEach((el) => {
if (el.name === "더미") {
originData[el.idx].is_visible = false;
return;
}
originData[el.idx].pokemon.name = el.name;
});
}
// 두 배열의 개수가 일치하지 않는 경우 (= 폼 데이터 삭제)
// 값은 전부 그대로 유지하면서 isVisibile만 false로 변경
else {
filterfetchVarieties.forEach((el) => {
if (el.idx === 0) return; // 기본형은 제외
originData[el.idx].is_visible = false;
});
}
return originData;
};
- 코드가 너무 불안전하다. 폼 순서 일치나 네이밍 규칙 같은 ‘암묵적 신뢰’ 위에 로직을 올렸다.
- pokeAPI에 너무 종속되어있었다. 다른 API를 찾아 볼 순 없었을까?
- 아니면, 오히려 요청을 더 많이보내더라도 pokeAPI로서만 처리하는게 맞았을까?
- 사실 AI가 있기에 1000개 가량의 데이터는 정적으로 채우는것이 훨~씬 빨랐을텐데 굳이 코드로 접근해야했을까?
초기 구현은 효과는 굉장했다! 포켓몬 약점 계산기 만들기 에서 확인 가능합니다.
와아 창우님 너무 멋져요!!