#2 우당탕탕 Garden 페이지 개발 기록 - 정원 오브젝트 설치와 이벤트 처리

💛 nalsae·2024년 1월 16일
2

💭 프로젝트 회고

목록 보기
6/7
post-thumbnail

👉 이전 글 보러 가기 - 설계 및 초기 맵 렌더링

😇 편집 모드, 그것이 문제로다!

 이전 글에서 초기 맵을 생성하는 것까지는 성공했다. 이제 두 가지 경우를 생각해야 한다. 바로 편집 모드일 경우와 편집 모드가 아닐 경우다. 각각의 동작 방식을 이미지로 보면 다음과 같다.

1. 편집 모드가 아닌 경우

2. 편집 모드인 경우

📌 컴포넌트 렌더링

 먼저 컴포넌트 렌더링 측면에서 살펴보자. 편집 모드가 아닌 경우에는 단순히 설치된 오브젝트만 정원 맵의 하위 요소로 렌더링하면 된다. 반면 편집 모드인 경우에는 먼저 설치 가능 여부를 표현할 투명한 1 x 1 크기의 요소를 채워넣은 뒤, 그 위에 CSS display 어트리뷰트를 absolute로 지정한 <img> 요소를 최상단 레이어로 띄워주는 방식으로 렌더링하고자 했다. 또한 정원 맵 상단에 고정되는 안내 문구와 정원 맵 하단에 고정되는 저장 및 취소 버튼까지 렌더링해야 한다. 컴포넌트 구조를 간단하게 표현하면 다음과 같다.

<GardenMap>
  // 사용자의 포인트, 편집 모드 버튼
  <GardenInfo />
  <main>
  	<div>
      // ⭐️ 편집 모드인 경우에만 렌더링할 1 x 1 투명 요소 ⭐️
      {isEditMode && <GardenSquares />}
      // ⭐️ 요청 받은 데이터를 토대로 렌더링할 설치된 오브젝트 ⭐️
      <InstalledPlants />
      // 오브젝트 클릭 시 마우스를 추적하는 임시 오브젝트
      {moveTarget && <TrackedPlant />}
    </div>
  </main>
  // 정원 맵 상단에 고정되는 편집 모드 안내 문구
  {isEditMode && <EditModeInfo />}
  // 정원 맵 하단에 고정되는 저장 및 취소 버튼
  {isEditMode && <div>...(생략)...</div>}
</GardenMap>

📌 클릭 이벤트

 그런데 편집 모드 여부는 단순히 컴포넌트 렌더링에만 관여하지 않는다. 편집 모드 여부에 따라 하위 요소를 클릭했을 때의 동작 방식이 달라져야 한다. 여기서의 하위 요소에는 <InstalledPlants/><GardenSquares/>가 해당된다. 각 하위 요소를 클릭했을 때의 동작 방식을 정리하면 다음과 같다.

👉 <InstalledPlants/> 클릭 시 동작 방식

  1. 편집 모드가 아닌 경우
    1-1. 본인의 정원 페이지인 경우
    - 식물 카드 연결 모달 렌더링
    1-2. 본인의 정원 페이지가 아닌 경우
    - 연결된 식물 카드 페이지 이동
  1. 편집 모드인 경우
    • 기존 설치된 오브젝트 배열에서 클릭한 오브젝트를 제외하여 상태 업데이트

👉 <GardenSquares/> 클릭 시 동작 방식

  1. 편집 모드가 아닌 경우
    • 애초에 렌더링되지 않으므로 해당 없음
  1. 편집 모드인 경우
    • 정원 맵에 마우스 호버 시 설치 가능 여부에 따라 <GardenSquares/> 색상 변경
    • 클릭한 오브젝트가 존재한 채로 클릭하면 기존 설치된 오브젝트 배열에서 클릭한 오브젝트의 위치 정보를 변경하여 상태 업데이트

 위와 같은 동작 방식을 구현하기 위해 <GardenSquares/><InstalledPlants/> 컴포넌트에 각각 클릭 이벤트 핸들러를 등록하는 방법도 고려해보았지만 최대 96개의 요소에 모두 이벤트 핸들러를 등록하는 것은 성능 면에서 비효율적이라고 판단했다. 따라서 상위 요소인 정원 맵 요소 자체에 이벤트를 위임했다. 그런데 이 과정에서 이벤트 핸들러에 전달하는 콜백 함수의 조건 분기 처리가 굉장히 복잡해지는 문제가 발생했다. 물론 커스텀 훅을 활용하여 이를 개선할 수는 있었지만, 각각의 요소에 이벤트 핸들러를 등록하는 방식으로 구현했다면 조건 분기 처리가 복잡하지 않았을 것 같다. 지금에서야 생각해보면 React에서 자체적으로 이미 이벤트를 root 요소에 위임하여 처리하기도 하니 불필요한 이벤트 위임이었던 것 같기도 하다. 언젠가 리팩토링을 진행하게 되면 조건 분기 처리를 간소화하는 방향으로 개선해볼 여지가 있을 것 같다. 구현한 이벤트 핸들러를 살펴보면 다음과 같다.

// useGarden.ts

 // ⭐ 설치 불가능한 위치 정보, 설치된 오브젝트 정보를 반환하는 유틸 함수 ⭐
 const { uninstallableLocations, installedPlants } = getInitialMapInfo(plants);

 const { handlePlants } = usePlants();
 const { handleSquares } = useSquares(uninstallableLocations);

 const handleGarden = (event: React.MouseEvent<HTMLDivElement>) => {
   if (
     !(
       event.target instanceof HTMLImageElement ||
       event.target instanceof HTMLDivElement
     )
   )
     return;

   // <InstalledPlants/>를 클릭한 경우
   if (event.target instanceof HTMLImageElement) handlePlants(event);
   // <GardenSquares/>를 클릭한 경우
   if (event.target instanceof HTMLDivElement) handleSquares(event);
 };

 앞서 언급했던 조건 분기 처리의 복잡성을 해결하기 위해 커스텀 훅을 활용한 결과가 바로 상단의 코드이다. <InstalledPlants/><GardenSquares/> 컴포넌트 모두 클릭 이벤트를 위임하려다 보니, 콜백 함수 내부에서 이벤트 타깃이<img>요소인 경우와 <div>요소인 경우를 구분하여 분기 처리를 해주어야 했다. 처음 구현할 때는 커스텀 훅을 활용하지 않았기 때문에 handlePlantshandleSquares로 분리된 부분까지 하나의 함수에 존재하는, 엄청 복잡하고 비대한 함수가 되었다.


🤔 설치 불가능 여부를 어떻게 판별하지?

 클릭한 요소에 따라 조건 분기 처리하는 로직을 살펴보기 전에, getInitialMapInfo에 대해 잠깐 짚고 넘어갈 필요가 있을 것 같다. 오브젝트를 정원 맵에 설치할 수 있는지 그 여부를 파악하기 위해서는 오브젝트 설치가 불가능한 위치 정보가 필요하다.

 정원 맵에서 오브젝트 설치가 불가능한 위치는 상단 이미지와 같이 두 종류로 구분할 수 있다. 고정적으로 물이나 절벽처럼 기획할 때부터 설치할 수 없도록 상정한 위치, 이미 설치된 오브젝트 위치에 따라 가변적으로 설치가 불가능한 위치가 그것이다. 이러한 위치 정보를 담고 있는 배열이 바로 getInitialMapInfo의 반환 값인 uninstallableLocations이다.

// constants/values.ts

// 고정적으로 설치가 불가능한 위치 정보 값
export const blockedLocations = [
  { x: 3, y: 0 },
  { x: 4, y: 0 },
  { x: 1, y: 2 },
  { x: 4, y: 2 },
  // ...중략...
];
// getInitialMapInfo.ts

// 호출 시 설치된 오브젝트 정보를 인수로 전달
const getUninstallableLocations = (installedPlants: PlantObj[]) =>
  // 설치된 오브젝트의 위치가 결국 가변적으로 설치가 불가능한 위치
  installedPlants.reduce(
    (locations, { productName, location }) => {
      // ⭐ 1. 2 x 2 크기인 경우 주변 4칸까지 설치가 불가능 ⭐
      if (productName.startsWith('building')) {
        return [
          ...locations,
          { x: location.x + 1, y: location.y },
          { x: location.x, y: location.y + 1 },
          { x: location.x + 1, y: location.y + 1 },
          { x: location.x, y: location.y },
        ];
      }
	  
      // 2. 1 x 1 크기인 경우 해당하는 1칸만 설치가 불가능
      return [...locations, { x: location.x, y: location.y }];
    },
    // ⭐ 초기 값은 고정적으로 설치가 불가능한 위치 ⭐
    [...blockedLocations],
  );

 상단 코드를 보면 알 수 있듯이 미리 선언한 blockedLocations, 즉 고정적으로 설치가 불가능한 위치를 토대로 설치된 오브젝트 정보를 담은 installedPlants 배열을 순회하면서 설치된 오브젝트의 위치 정보를 추가하여 반환하는 방식으로 구현했다. 다만 이때 주의할 점이 있다. 정원에 설치되는 오브젝트는 모두 동일한 크기가 아니다. 상단 이미지의 파란색 영역을 주목하면 1 x 1 크기의 오브젝트도 있지만 2 x 2 크기의 오브젝트도 존재하는 것을 알 수 있다. 따라서 만약 오브젝트가 2 x 2 크기라면 해당 위치를 기준으로 4칸까지 설치가 불가능한 위치로 추가했다. 이렇게 생성한 uninstallableLocations<GardenSquares/> 컴포넌트의 클릭 이벤트 분기 처리에 중요한 역할을 하게 되는데, 이는 조금 뒤에서 살펴보겠다.


👆 <InstalledPlants/> 클릭 처리

 그럼 먼저 <InstalledPlants/> 컴포넌트를 클릭하는 경우를 살펴보자. 앞서 정리한 동작 방식을 코드로 구현하면 다음과 같다.

// usePlants.ts

const usePlants = () => {
  const router = useRouter();
  const { id } = useParams(); // 사용자별 고유의 ID 값

  const {
    isEditMode,
    plants,
    moveTarget,
    setPlants,
    observeMoveTarget,
    observeInfoTarget,
  } = useGardenStore(); // Garden 페이지 관련 상태를 저장하는 Store
  const { changeType, open } = useModalStore(); // Modal 관련 상태를 저장하는 Store
  const { userId } = useUserStore(); // 로그인한 사용자 관련 상태를 저장하는 Store

  const handlePlants = (event: React.MouseEvent<HTMLDivElement>) => {
    if (event.target instanceof HTMLImageElement) {
      // 🚨 이미 오브젝트를 클릭한 상태에서 다른 오브젝트를 클릭하는 경우 예외 처리 🚨
      if (moveTarget) return;

      const targetId = event.target.dataset.plantId;

      // ⭐️ 1. 편집 모드가 아닌 경우 ⭐️
      if (!isEditMode) {
        const selectedPlant = plants.find(
          (plant) => Number(targetId) === plant.plantObjId,
        );

        // 1-1. 자기 자신의 정원 페이지인 경우
        // : 로그인 시 저장된 ID 값과 URL의 path parameter ID 값이 같은 경우 
        if (userId === id) {
          selectedPlant && observeInfoTarget(selectedPlant);

          // 연결된 식물 카드 여부에 따라 렌더링할 Modal 타입 변경
          selectedPlant?.leafDto
            ? changeType('leafExist')
            : changeType('noLeafExist');

          // Modal 렌더링
          open();
        }
        
        // 1-2. 다른 사용자의 정원 페이지인 경우
        // : 로그인 시 저장된 ID 값과 URL의 path parameter ID 값이 다른 경우
        if (userId !== id) {
          const leafId = selectedPlant?.leafDto?.id;
		  
          // 연결된 식물 카드 페이지로 라우팅
          leafId && router.push(`/leaf/${id}/${leafId}`);
        }
      }

      // ⭐️ 2. 편집 모드인 경우 ⭐️
      if (isEditMode) {
        const newPlants = plants.map((plant) => {
          if (plant.plantObjId !== Number(targetId)) return plant;
			
          const plantSize =
            plant.leafDto && plant.leafDto.journalCount >= 10 ? 'lg' : 'sm';
          const imageSize = plant.productName.startsWith('building')
            ? 'lg'
            : 'sm';
		  
          // 마우스 호버 시 <TrackedPlants/> 컴포넌트를 렌더링하기 위해 moveTarget 추적
          observeMoveTarget({ ...plant, plantSize, imageSize });

          return {
            ...plant,
            // 일시적으로 클릭한 오브젝트 삭제
            location: { ...plant.location, isInstalled: false },
          };
        });

        // 프론트 단에서 설치된 오브젝트 데이터 갱신
        setPlants(newPlants);
      }
    }
  };

  return { handlePlants };
};

 handlePlants의 내부 로직을 풀어서 설명해보자면, 편집 모드가 아닌 경우 사용자 본인의 정원 페이지인지 그 여부를 판단하기 위해 로그인 시 Store에 저장되는 사용자 고유의 ID 값과 URL의 path parameter를 비교한다. 그 결과 값에 따라 식물 카드 관련 Modal 컴포넌트를 렌더링하거나, 연결된 식물 카드 페이지로 라우팅되도록 처리했다. 한 가지 특징적인 부분이 있다면 Modal을 렌더링하기 전에 Modal 타입을 변경한다는 점인데, 이는 추후 Modal 관련 Store의 리팩토링 과정을 담은 게시글에서 자세히 다룰 예정이다. 편집 모드인 경우에는 추가적인 분기 처리 없이 클릭한 오브젝트를 제외하여 설치된 오브젝트 배열의 상태를 업데이트한다. 다만 마우스 호버 시 렌더링되는 <TrackedPlants/> 컴포넌트 렌더링을 위해 observeMoveTarget 메서드를 호출하여 클릭한 오브젝트의 정보를 담고 있는 moveTarget 객체를 생성한 후 저장해야 한다.


👆 <GardenSquares/> 클릭 처리

 다음으로 <GardenSquares/> 컴포넌트를 클릭하는 경우를 살펴보자. 마찬가지로 앞서 정리한 동작 방식을 구현한 코드이다.

// useSquares.ts

// 호출 시 오브젝트를 설치할 수 없는 위치 정보를 담은 배열을 인수로 전달
const useSquares = (uninstallableLocations: { x: number; y: number }[]) => {
  const { isEditMode, plants, moveTarget, setPlants, unobserve } =
    useGardenStore(); // Garden 페이지 관련 상태를 저장하는 Store

  const handleSquares = (event: React.MouseEvent<HTMLDivElement>) => {
    if (event.target instanceof HTMLDivElement) {
       // 🚨 설치 불가능한 위치를 클릭한 경우 예외 처리 🚨
      if (event.target.dataset.installable === 'false') return;
      // 🚨 편집 모드이면서 동시에 오브젝트를 클릭한 상태가 아니라면 예외 처리 🚨
      if (!(isEditMode && moveTarget)) return;

      // 클릭한 곳의 위치 정보 (좌표 값) 가져오기
      const x = Number(event.target.dataset.positionX);
      const y = Number(event.target.dataset.positionY);
	  
      // ⭐ 2 x 2 크기의 오브젝트인 경우 주변 4칸까지 설치가 가능한지 판단 ⭐
      // 🚨 주변 4칸 중 설치가 불가능한 위치가 있다면 예외 처리 🚨
      if (
        moveTarget.imageSize === 'lg' &&
        !uninstallableLocations.every((position) =>
          getInstallable(x, y, position, 'lg'),
        )
      )
        return;

      // 모든 예외 처리를 통과한, 즉 설치가 가능한 경우
      // : 클릭한 오브젝트의 위치 정보를 갱신하여 새로운 배열 생성
      const newPlants = plants.map((plant) => {
        if (moveTarget.plantObjId !== plant.plantObjId) return plant;

        return {
          ...plant,
          location: {
            ...plant.location,
            isInstalled: true,
            x,
            y,
          },
        };
      });
	  
      // 프론트 단에서 설치된 오브젝트 데이터 갱신
      setPlants(newPlants);
	  
      // 위치 이동을 마쳤으니 추적하고 있었던 moveTarget 해제
      unobserve();
    }
  };

  return { handleSquares };
};

 handleSquares의 경우 애초에 편집 모드가 아니라면 렌더링되지 않는 <GardenSquares/> 컴포넌트에서 사용할 콜백 함수이므로 별다른 분기 처리의 필요는 없다. 다만 예외 처리는 좀 까다롭게 해주어야 한다. 먼저 편집 모드가 아니면서 moveTarget이 존재하지 않는 경우에 클릭하더라도 아무 일이 일어나서는 안 된다. 또한 사용자가 설치 불가능한 위치를 클릭하는 경우 역시 아무 일도 일어나면 안 된다. 이를 판별하기 위해 dataset 속성과 앞서 살펴보았던 유틸 함수 getInitialMapInfo의 반환 값 uninstallableLocations을 사용했다. 여기서 컴포넌트 렌더링 시 dataset 속성을 어떻게 등록했는지 잠깐 살펴보면 다음과 같다.

// createSquares.ts

// 정원 맵 각 위치의 설치 가능 여부 정보를 담고 있는 배열 생성
export const createSquares = (
  uninstallableLocations: { x: number; y: number }[],
) => {
  const squares = Array.from({ length: SQAURE_QUANTITY }, (_, index) => {
    // 각 위치의 좌표 값
    const x = index % GARDEN_MAP_COLUMNS;
    const y = Math.floor(index / GARDEN_MAP_COLUMNS);
    // 각 위치의 설치 가능 여부
    const installable = uninstallableLocations.every((position) =>
      getInstallable(x, y, position, 'sm'),
    );

    return {
      x,
      y,
      installable,
    };
  });

  return squares;
};
// GardenSquares.tsx

export default function GardenSquares({
  uninstallableLocations,
}: GardenSquaresProps) {
  // 오브젝트 설치가 불가능한 위치 정보를 토대로 생성한 squares 배열
  const squares = createSquares(uninstallableLocations);

  return (
    <>
      {squares.map(({ x, y, installable }, index) => {
        // 설치 불가능 여부에 따라 마우스 호버 시 조건부 스타일링 
        const squareBg = installable
          ? `hover:border-blue-30 hover:bg-blue-10 hover:bg-[url('/assets/icon/installable.svg')]`
          : `hover:border-red-50 hover:bg-red-10 hover:bg-[url('/assets/icon/uninstallable.svg')]`;

        return (
          <div
            key={index}
            // ⭐️ 렌더링 시 등록하는 dataset 속성 ⭐️
            data-position-x={x}
            data-position-y={y}
            data-installable={installable}
            className={`...중략...${squareBg}`}
          />
        );
      })}
    </>
  );
}

 위와 같이 유틸 함수 createSquares를 호출하여 각 위치의 좌표 값과 설치 가능 여부를 담은 배열을 토대로 <GardenSquares/> 컴포넌트가 렌더링되고, 렌더링 시 dataset 속성을 이용하여 좌표 값과 오브젝트 설치 가능 여부를 각 위치에 등록했다. 마우스 호버 시 오브젝트 설치 가능 여부에 따라 파란색 또는 빨간색으로 변하는 기능은 CSS를 이용하여 간단히 처리했다. TailwindCSS에서의 조건부 스타일링에 대해 따로 다른 글에서 다룰지는 모르겠지만, TailwindCSS에서 스타일링을 조건부로 처리하려면 위와 같이 완성된 형태의 문자열이어야 한다. hover:border-${COLOR_INSTALL}과 같이 템플릿 리터럴을 사용하여 클래스에 직접적으로 등록하면 정상적으로 조건부 스타일링이 되지 않는다. 이는 TailwindCSS가 스타일시트를 생성하는 과정과도 관련이 있는데, TailwindCSS에서의 조건부 스타일링에 대한 글을 작성하게 되면 다뤄보고자 한다.


🥳 오브젝트 설치 기능 구현 성공!

 여기까지 정원 맵에 설치된 오브젝트의 클릭 이벤트 분기를 어떻게 처리했는지 그 과정에 대해 다뤄보았다. 이로써 정원 맵의 기본적인 기능은 구현했다고 볼 수 있지만, 아직 갈 길이 멀다. 역시나 분량이 길어지는 관계로 다음 글에서 이어나가도록 하겠다.

👉 다음 글 보러 가기 - 마우스 추적 오브젝트

profile
𝙸'𝚖 𝚊 𝚍𝚎𝚟𝚎𝚕𝚘𝚙𝚎𝚛 𝚝𝚛𝚢𝚒𝚗𝚐 𝚝𝚘 𝚜𝚝𝚞𝚍𝚢 𝚊𝚕𝚠𝚊𝚢𝚜. 🤔

0개의 댓글