[React] useEffect 실행 순서로 인한 비동기 호출 오류

김민재·2025년 8월 13일
0

📌 상황

개인 프로젝트를 진행하던 중, 다음과 같은 에러를 마주했다. 문제가 발생한 컴포넌트는 DetailContainer.tsx , DetailBottom.tsx , PlaceInfo.tsx 이고, 부모 -> 자식 순이다.


에러 메시지


DetailContainer.tsx

import React, { useState, useEffect } from "react";
import styles from "./DetailContainer.module.scss";
import DetailTop from "./top/DetailTop";
import DetailBottom from "./bottom/DetailBottom";
import type { pfDetail } from "@/models/detail.client";

interface DetailContainerProps {
  pfId: string;
}

const DetailContainer: React.FC<DetailContainerProps> = ({ pfId }) => {
  const [detail, setDetail] = useState<pfDetail>({
    prfnm: { _text: "" },
    prfpdfrom: { _text: "" },
    prfpdto: { _text: "" },
    fcltynm: { _text: "" },
    poster: { _text: "" },
    prfstate: { _text: "" },
    prfruntime: { _text: "" },
    prfage: { _text: "" },
    pcseguidance: { _text: "" },
    mt10id: { _text: ""},
    dtguidance: { _text: "" },
    styurls: {
      styurl: { _text: "" },
    },
    relates: {
      relate: {
        relatenm: { _text: "" },
        relateurl: { _text: "" },
      },
    },
  });

  useEffect(() => {
    async function fetchDetailData() {
      try {
        const data = await fetch(`http://localhost:3000/detail/${pfId}`).then(
          (res) => res.json()
        );
        setDetail(data);
      } catch (error) {
        console.log("공연 상세 데이터 불러오기 실패", error);
      }
    }

    fetchDetailData();
  }, [pfId]);

  const {
    prfnm,
    prfpdfrom,
    prfpdto,
    fcltynm,
    poster,
    prfstate,
    prfruntime,
    prfage,
    pcseguidance,
    mt10id,
    dtguidance,
    styurls,
    relates,
  } = detail;
  const topData = {
    prfnm,
    prfpdfrom,
    prfpdto,
    fcltynm,
    poster,
    prfstate,
    prfruntime,
    prfage,
    pcseguidance,
    relates,
  };
  const bottomData = { fcltynm, dtguidance, styurls, mt10id };


  return (
    <div className={styles["detail-background"]}>
      <div className="container">
        {topData && <DetailTop data={topData} />}
        {bottomData && <DetailBottom data={bottomData} />}
      </div>
    </div>
  );
};

export default DetailContainer;

DetailBottom.tsx

import React, { useState } from "react";
import styles from "./DetailBottom.module.scss";
import type { TextNode } from "@/models/common.client";
import PerformanceInfo from "./PerformanceInfo";
import PlaceInfo from "./PlaceInfo";

interface DetailBottomPropsObj {
  fcltynm: TextNode;
  dtguidance: TextNode;
  styurls: {
    styurl: TextNode | TextNode[];
  };
  mt10id: TextNode;
}

interface DetailBottomProps {
  data: DetailBottomPropsObj;
}

const DetailBottom: React.FC<DetailBottomProps> = ({ data }) => {
  const [selectedTab, setSelectedTab] = useState("performance-info");

  const { fcltynm, dtguidance, styurls, mt10id } = data;

  const performanceInfoData = { dtguidance, styurls };
  const placeInfoData = { fcltynm, mt10id };

  console.log(placeInfoData);

  return (
    <div className={styles["bottom-info"]}>
      <div className={styles["bottom-info__header"]}>
        <div
          className={`${styles["bottom-info__tab"]} ${
            selectedTab === "performance-info" &&
            styles["bottom-info__tab--active"]
          }`}
          onClick={() => setSelectedTab("performance-info")}
        >
          공연 정보
        </div>
        <div
          className={`${styles["bottom-info__tab"]} ${
            selectedTab === "place-info" && styles["bottom-info__tab--active"]
          }`}
          onClick={() => setSelectedTab("place-info")}
        >
          장소 정보
        </div>
      </div>
      <div
        style={{
          display: selectedTab === "performance-info" ? "block" : "none",
        }}
      >
        {performanceInfoData && <PerformanceInfo data={performanceInfoData} />}
      </div>
      <div
        style={{
          display: selectedTab === "place-info" ? "block" : "none",
        }}
      >
        {placeInfoData && <PlaceInfo data={placeInfoData} />}
      </div>
    </div>
  );
};

export default DetailBottom;

PlaceInfo.tsx

import React, { useState, useEffect, useRef } from "react";
import styles from "./PlaceInfo.module.scss";
import locationIcon from "@/assets/filter/location.svg";
import type { TextNode } from "@/models/common.client";
import supabase from "@/apis/supabase-client";

interface PlaceInfoPropsObj {
  fcltynm: TextNode;
  mt10id: TextNode;
}

interface PlaceInfoProps {
  data: PlaceInfoPropsObj;
}

const PlaceInfo: React.FC<PlaceInfoProps> = ({ data }) => {
  const [address, setAddress] = useState("");
  const [hasLocation, setHasLocation] = useState(false);
  const mapRef = useRef<HTMLDivElement>(null);

  const { fcltynm, mt10id } = data;

  interface LocationType {
    address: string;
    latitude: number;
    longitude: number;
  }

  useEffect(() => {
    // DB로부터 위치 정보를 가져오는 함수
    const fetchLocationData = async (): Promise<void | LocationType> => {
      console.log("mt10id:", mt10id._text);

      const { data, error } = await supabase
        .from("concert_halls")
        .select("address, latitude, longitude")
        .eq("code", mt10id._text)
        .limit(1)
        .single();

      if (error) {
        console.log("DB concert_halls 에서 위치 데이터 가져오기 실패", error);
      } else {
        console.log("data:", data);
        return data;
      }
    };

    const drawMap = (lat: number, lng: number) => {
      const { naver } = window;
      if (mapRef.current && naver) {
        const location = new naver.maps.LatLng(lat, lng);
        const map = new naver.maps.Map(mapRef.current, {
          center: location,
          zoom: 17,
        });
        new naver.maps.Marker({
          position: location,
          map,
        });
      }
    };

    const fetchLocationAndDrawMap = async () => {
      const locationObj = await fetchLocationData();

      if (locationObj) {
        const { address, latitude, longitude } = locationObj;
        setHasLocation(true);
        setAddress(address);
        drawMap(latitude, longitude);
      } else {
        setHasLocation(false);
      }
    };

    fetchLocationAndDrawMap();
  }, [mt10id._text]);

  return (
    <div className={styles["place-info"]}>
      <section className={styles["hall"]}>
        <p className={styles["hall__title"]}>공연장 정보</p>
        <div className={styles["hall__detail"]}>
          <img className={styles["icon"]} src={locationIcon} alt="" />
          <div className={styles["hall__detail-text"]}>
            <div className={styles["hall__detail-name"]}>{fcltynm._text}</div>
            <div className={styles["hall__detail-address"]}>{address}</div>
          </div>
        </div>
      </section>
      <section className={styles["location"]}>
        <p className={styles["location__title"]}>위치</p>
        <div
          ref={mapRef}
          className={`${styles["location__map"]} ${
            !hasLocation && styles["location__map--no-location"]
          }`}
        >
          {!hasLocation && "위치 정보가 제공되지 않습니다."}
        </div>
      </section>
    </div>
  );
};

export default PlaceInfo;

DetailContainer.tsx 에서 공연 상세 데이터를 받아온 후, 해당 데이터에 포함되어 있는 공연장코드 mt10idPlaceInfo.tsx 에 전달하여 DB에 있는 위치 데이터를 가져와야 하는 상황이었다.

그런데 에러 메시지를 보면PlaceInfo.tsx 에서 mt10id 를 제대로 읽어오지 못하고 있었다.



🔎 원인

useEffect 의 실행 순서 문제였다. 나는 함수 컴포넌트의 호출 순서가 부모 -> 자식 순이기 때문에, useEffect 또한 부모에서 먼저 실행될 것이라 생각했다. 하지만 그와 반대로 useEffect 의 실행 순서는 자식 -> 부모 순이었다.

간단한 예제를 만들어 확인해보면 자식의 useEffect 가 먼저 실행된다.

예제 코드

import React, { useEffect } from "react";

const Child: React.FC = () => {
  useEffect(() => {
    console.log("Child useEffect!");
  }, []);

  console.log("Child!");

  return (
    <div></div>
  );
}

const Parent: React.FC = () => {
  useEffect(() => {
    console.log("Parent useEffect!");
  }, []);

  console.log("Parent!");

  return (
    <div>
      <Child />
    </div>
  );
};

export default Parent;

결과


부모 컴포넌트인 DetailContainer.tsx 에서 비동기 호출을 통해 받아온 데이터를 바탕으로 자식 컴포넌트인 PlaceInfo.tsx 에서 DB의 데이터를 검색하여 받아와야 하는데, 자식의 useEffect 가 먼저 실행되다 보니 mt10id 가 비어 있는 상태로 검색을 했기 때문에 에러가 발생한 것이었다.



✅ 해결 방법

구글링을 하던 중 나와 유사한 문제를 겪은 사례가 있어 참고하였다.

Suspense Lazy를 사용했다고 한다.

https://hanaindec.tistory.com/entry/%EB%A6%AC%EC%95%A1%ED%8A%B8-Appjs-useEffect-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%98%B8%EC%B6%9C%EC%88%9C%EC%84%9C-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0

profile
넓이보단 깊이있게

0개의 댓글