개인 프로젝트1 - 포켓몬 도감

이지영·2025년 1월 18일
post-thumbnail

📌 프로젝트 소개

작업기간

  • 초기 개발: 2024.08.16 ~ 24.08.27
  • 리팩토링: 2025.01.18 ~ 25.01.19

프로젝트 링크

🔗 깃허브

🔗 배포링크

🌟 프로젝트 구조

세 가지 브랜치로 나누어 작업

1. Props-drilling: 컴포넌트 계층 구조를 최대한 깊게 만들어 props drilling의 불편함 체감

2. Context: Context API를 활용한 리팩토링

3. Main: 최종 리팩토링 결과물

랜딩페이지

react-router-dom을 이용하여 홈페이지에서 도감 페이지로 이동
image

📱 페이지 구성

1. 랜딩페이지

  • react-router-dom을 활용한 라우팅 구현

도감페이지

  • 대시보드: 선택한 6마리 포켓몬 표시
  • 포켓몬 리스트: 클릭으로 대시보드에 추가 가능
  • 알림 기능: 중복/최대 개수 초과 시 알림

디테일 페이지

  • QueryString으로 포켓몬 ID 전달
  • 상세 정보 표시

[기본화면]

image

[카드 추가했을 때 화면]

image

포켓몬 디테일 페이지

  • 개별 포켓몬 카드를 클릭하면 포켓몬의 상세 정보를 보여주는 페이지로 이동
  • QueryString으로 포켓몬 ID전달하고, 해당 ID로 MOCK_DATA에서 데이터를 찾아 디테일 표시

위의 형태의 간단한 페이지이다.
구성은 랜딩페이지, 도감페이지, 디테일 페이지이며

각 페이지별 구현사항을 정리해보겠다.
참고로 Props drilling을 Context Api로 리팩토링한 결과물만 정리해보겠다.

[아쉬운 점]

전체적으로 구조 부분에서 리팩토링 해야할 부분이 많아보였다.

1. 라우터에 context가 전부 때려박혀있어서 가독성 저하

- 수정전

  const Router = () => {
  const [pokeData, setPokeData] = useState([]);
  const [selectedPoke, setSelectedPoke] = useState([]);
  const mockData = MOCK_DATA;

  // DashBoard.jsx 부분의 포켓볼 이미지 상태
  const [ball, setBall] = useState([
    { img: "https://react-6-pokemon.vercel.app/assets/pokeball-13iwdk7Y.png" },
    ...... 생략
  ]);

  // 삭제 기능
  const delCard = (id) => {
    confirm("삭제하시겠습니까?");
    const deletedItem = selectedPoke.filter((item) => id !== item.id);
    setSelectedPoke(deletedItem);
  };

  // 추가 기능
  const addCard = (i) => {
    const currentCard = pokeData[i].id;
    if (selectedPoke.some((item) => item.id == currentCard)) {
      alert("이미 등록된 카드입니다.");
    } else {
      setSelectedPoke((prev) => [...prev, pokeData[i]]);
      alert("추가 하시겠습니까?");
    }
  };
	
  return (
    <Context.Provider
      value={{
        pokeData,
        setPokeData, 
        ...생략
      }}
    >
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dex" element={<Dex />} />
          <Route path="/pokemon-detail" element={<Detail />} />
        </Routes>
      </BrowserRouter>
    </Context.Provider>
  );
};

export default Router;

- 수정후 - Router.jsx파일과 Context.jsx를 분리


// Router.jsx파일
const Router = () => {
  return (
    <PokeDataProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dex" element={<Dex />} />
          <Route path="/pokemon-detail" element={<Detail />} />
        </Routes>
      </BrowserRouter>
    </PokeDataProvider>
  );
};

// Context.jsx파일
export const PokeDataContext = createContext(null);

const initialSelectedPokes = Array(6)
  .fill()
  .map((item) => ({ id: uuidv4(), img_url: POKEBALL_IMG_URL }));

export const PokeDataProvider = ({ children }) => {
  const [pokeData, setPokeData] = useState(MOCK_DATA);
  const [selectedPoke, setSelectedPoke] = useState(initialSelectedPokes);
  // 추가
  const onAdd = (pokemon) => {
    const emptyIndex = selectedPoke.findIndex(
      (item) => item.img_url === POKEBALL_IMG_URL
    );
    const isDuplicate = selectedPoke.some((item) => item.id === pokemon.id);
    if (isDuplicate) {
      alert("이미 선택된 포켓몬입니다.");
      return;
    }
    if (emptyIndex == -1) {
      alert("6개 이상 추가할 수 없습니다.");
      return;
    } else {
      const addConfirm = confirm("추가하시겠습니까?");
      if (addConfirm) {
        const newSelectedPoke = [...selectedPoke];
        newSelectedPoke[emptyIndex] = pokemon;
        setSelectedPoke(newSelectedPoke);
      }
    }
  };

  const onDelete = (id) => {
    const confrimDelete = confirm("정말 삭제하시겠습니까?");
    if (confrimDelete) {
      const deletedPoke = selectedPoke.map((item) => {
        if (item.id === id) {
          return { img_url: POKEBALL_IMG_URL };
        }
        return item;
      });
      setSelectedPoke(deletedPoke);
    }
  };

  const value = {
    pokeData,
    selectedPoke,
    onAdd,
    onDelete,
  };
  return (
    <PokeDataContext.Provider value={value}>
      {children}
    </PokeDataContext.Provider>
  );
};

2. 카드 컴포넌트 공통화

수정 전에는 대시보드 카드 컴포넌트 따로, 리스트 카드 컴포넌트 카드 따로 였다.

- 수정전

	// 대시보드의 카드 부분 
	<div className="dashboard">
        <h2>나만의 포켓몬</h2>
        <ul>
          {selectedPoke.slice(0, 6).map((item, index) => {
            return (
              <li key={index} className="selected">
                <img src={item.img_url} alt="" />
                <p>{item.korean_name}</p>
                <p>NO.0{item.id}</p>
                <button
                  onClick={() => {
                    delCard(item.id);
                  }}
                >
                  삭제
                </button>
              </li>
            );
          })}
          {ball.map((item, index) => {
            return (
              <li key={index} className="pokeball_img">
                <img src={item.img} alt="" />
              </li>
            );
          })}
        </ul>
      </div>

	// 포켓몬 리스트 카드 부분
	<ul>
      {mockData.map((item, i) => {
        return (
          <li key={i} onClick={() => nav(`/pokemon-detail?id=${item.id}`)}>
            <p>{item.korean_name}</p>
            <img src={item.img_url} alt="" />

            <p>NO.0{item.id}</p>
            <button
              onClick={(event) => {
                // 이벤트 전파 방지
                event.stopPropagation();
                addCard(i);
              }}
            >
              추가
            </button>
          </li>
        );
      })}
    </ul>
  );
};

- 수정후 (공통 컴포넌트로 분리하여 재사용성 높임)

조건에 따라 아이디를 노출하거나, 조건에 따라 버튼 텍스트를 추가 또는 삭제로 보일 수 있도록했다.

const PoketmonCard = ({ id, korean_name, img_url, onClick, buttonText }) => {
  const nav = useNavigate();
  return (
    <li
      className="pokemonCard_wrapper"
      onClick={() => nav(`/pokemon-detail?id=${id}`)}
    >
      <p>{korean_name}</p>
      <img src={img_url} alt={`${korean_name} 이미지`} />
      {img_url === POKEBALL_IMG_URL ? null : <p>NO.0{id}</p>}
      {buttonText && img_url !== POKEBALL_IMG_URL && (
        <button onClick={onClick}>{buttonText}</button>
      )}
    </li>
  );
};

3. 성능이슈

Detail.jsx에서 해당하는 아이디 하나만 필요한데 filter메서드를 사용했다.이 방법은 모든 요소를 탐색하게 되어 성능이 떨어질 수 있다.

- 수정전:

const location = useLocation();
  const pokeId = parseInt(location.search.slice(4));
  const mockData = MOCK_DATA;
  const filterData = mockData.filter((item) => item.id === pokeId);

- 수정후:

const Detail = () => {
  const location = useLocation();
  const queryParams = new URLSearchParams(location.search);
  const paramsId = queryParams.get("id");

  const { pokeData } = useContext(PokeDataContext);
  // find 메서드로 변경
  const currentPokemon = pokeData.find(
    (item) => String(item.id) === String(paramsId)
  );
	// 포켓몬을 찾을 수 없을때 추가
  if (!currentPokemon) {
    return <p>포켓몬을 찾을 수 없습니다.</p>;
  }
  const { korean_name, img_url, types, description } = currentPokemon;

  return (
	// 생략
  );
};

주요 변경사항 요약

1. 쿼리 파라미터 처리:

URLSearchParams를 사용하여 쿼리 파라미터에서 id 값 가져오는 형태로 변경

2. 데이터 검색 방식 변경

filter메서드 대신find메서드를 사용하여 조건에 맞는 첫 번째 요소만 검색하도록 변경

3. 포캣몬 존재 여부 확인:

currentPokemon이 존재하지 않을 경우 사용자에게 포켓몬을 찾을 수 없다는 메시지를 표시

등등 수정한 부분들은 main브랜치에 올려두었다.

🔧 트러블슈팅

1. 배포 오류

[원인]

detail , Detail 파일 경로 설정 잘못됨

error during build:
Could not resolve "../pages/detail" from "src/Shared/Router.jsx"
file: /vercel/path0/src/Shared/Router.jsx
at getRollupError (file:///vercel/path0/node_modules/rollup/dist/es/shared/parseAst.js:392:41)
at error (file:///vercel/path0/node_modules/rollup/dist/es/shared/parseAst.js:388:42)
at ModuleLoader.handleInvalidResolvedId (file:///vercel/path0/node_modules/rollup/dist/es/shared/node-entry.js:19234:24)
at file:///vercel/path0/node_modules/rollup/dist/es/shared/node-entry.js:19194:26
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Command "yarn run build" exited with 1

[해결]

detail로 경로 잘못 설정되어있는 부분 Detail으로 변경

2. 에러

[원인]

uuid 의존성 설치되지 않음

[vite]: Rollup could not resolve "uuid" from "/vercel/path0/src/Shared/Context.jsx".
This is unexpected and could lead to your application being broken at runtime.
To explicitly externalize this module, add the following:

[해결]

npm i 하여 의존성 설치

수정해주었더니 정상적으로 배포가 되었다.

느낀점:

불과 5개월 전 작업했던 코드들이였는데 리팩토링할게 너무 많은데,
다음 5개월 후 다시 봤을때는 또 얼마나 부족해보일지 무섭다
최대한 강의를 듣고 기본을 잡으며 끊임없이 매일 노력해서 부족한것을 채워나가야겠다.

0개의 댓글