/client

OneDayPiece_client

Primary LanguageJavaScript

๐Ÿ’ŽHarupiece๐Ÿ’Ž

๊ฑด๊ฐ• ์ฑŒ๋ฆฐ์ง€ ํ”Œ๋žซํผ, ํ•˜๋ฃจ์กฐ๊ฐ

๐Ÿ’Ž ํ•˜๋ฃจ์กฐ๊ฐ ๊ตฌ๊ฒฝํ•˜๊ธฐ

ํ•˜๋ฃจ์กฐ๊ฐ ์†Œ๊ฐœ

  • "์ฝ”๋กœ๋‚˜ ๋•Œ๋ฌธ์— ๋Š˜ ๋ฌด๊ธฐ๋ ฅํ•œ ๋‚˜, ์ฒด๋ ฅ๊ณผ ๊ฑด๊ฐ•์„ ์ƒ๊ฐํ•ด์„œ ๋ญ”๊ฐ€๋Š” ํ•ด์•ผ ํ•  ๊ฒƒ ๊ฐ™์€๋ฐ.. ์˜์ง€๋ฐ•์•ฝ? ์ž‘์‹ฌ์‚ผ์ผ?"
  • " ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ŠคํŠธ๋ ˆ์Šค๋Š” ๋œ ๋ฐ›์œผ๋ฉด์„œ, ๊ฑด๊ฐ•ํ•œ ์Šต๊ด€์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์„๊นŒ?"
  • ํ˜น์‹œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ด์•ผ๊ธฐ๋Š” ์•„๋‹Œ๊ฐ€์š”? ์ด๋ฅผ ์œ„ํ•ด ๊ธฐํšํ•œ ๊ฒƒ์ด ๋ฐ”๋กœ ๋ฐ”๋กœ !!
  • ๊ฑด๊ฐ•์ฑŒ๋ฆฐ์ง€ ํ”Œ๋žซํผ ํ•˜๋ฃจ์กฐ๊ฐ์ž…๋‹ˆ๋‹ค!!

โœจ ์ฑŒ๋ฆฐ์ง€๋ฅผ ํ†ตํ•ด ์›ํ•˜๋Š” ๋ชฉํ‘œ์— ํ•œ ๋ฐœ์ž๊ตญ ๋” ๋‹ค๊ฐ€๊ฐˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

์ž์‹ ์ด ์›ํ•˜๋Š” ๋ชฉํ‘œ์— ๋งž๋Š” ์ฑŒ๋ฆฐ์ง€๋ฅผ ์‹ ์ฒญํ•˜๊ณ , ์„œ๋กœ๋ฅผ ์‘์›ํ•˜๋‹ค๋ณด๋ฉด ์–ด๋Š์ƒˆ ๋ชฉํ‘œ ๋‹ฌ์„ฑ!

๐Ÿ“… ๊ธฐ๊ฐ„์„ ์„ค์ •ํ•˜๊ณ  ์›ํ•˜๋Š” ์ฑŒ๋ฆฐ์ง€๋ฅผ ๋งŒ๋“ค์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๋ณธ์ธ์ด ์›ํ•˜๋Š” ์ฑŒ๋ฆฐ์ง€๊ฐ€ ์—†๊ฑฐ๋‚˜ ๊ธฐ๊ฐ„์ด ์•ˆ ๋งž๋Š”๋‹ค๋ฉด ์ฑŒ๋ฆฐ์ง€๋ฅผ ๋งŒ๋“ค์–ด ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค ๊ธฐ๊ฐ„์„ ์งง๊ฒŒ ์ง„ํ–‰ํ•˜์—ฌ ์„ฑ๊ณตํ•˜๋Š” ์Šต๊ด€๋„ ๊ธฐ๋ฅผ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๐Ÿ’– ์‘์›ํ•˜๊ธฐ๋ฅผ ํ†ตํ•ด ๋‹ค๋ฅธ ์ฑŒ๋ฆฐ์ €๋“ค์—๊ฒŒ ํž˜์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๋‹ค๋ฅธ ์œ ์ €์˜ ์‘์›์„ ํ™•์ธํ•˜๋ฉฐ ๋‚˜๋„ ํ• ์ˆ˜ ์žˆ๋‹ค ๋ผ๋Š” ์ž์‹ ๊ฐ์„ ์–ป๊ณ  ์„œ๋กœ ์‘์›ํ•˜์—ฌ ๋ชฉํ‘œ๋ฅผ ๋‹ฌ์„ฑํ• ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๐Ÿ“ง ์ฑ„ํŒ…์„ ํ†ตํ•ด ์†Œํ†ต ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

์ฑ„ํŒ…์„ ํ†ตํ•ด ๋ชฉํ‘œ ๋‹ฌ์„ฑ์— ๊ด€ํ•œ ๊ฟ€ํŒ์ด๋‚˜ ์ž”์ž”ํ•œ ์†Œํ†ต์œผ๋กœ ์นœํ•ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๐Ÿ’Ž ์กฐ๊ฐ์„ ๋ชจ์•„ ์„ฑ์ทจ๊ฐ์„ ๋‹ฌ์„ฑํ•˜์„ธ์š”

๋‹ค๋ฅธ ์œ ์ €๋ฅผ ์‘์›ํ•˜๊ฑฐ๋‚˜ ์ธ์ฆ์ƒท์„ ์˜ฌ๋ฆฌ๋ฉด ์กฐ๊ฐ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
์ผ์ • ์กฐ๊ฐ์„ ๋ชจ์œผ๋ฉด ๊ตฌ์Šฌ๋กœ ๋ณ€๊ฒฝ๋˜๋ฉฐ, ๊ตฌ์Šฌ์„ ๋ชจ์œผ๋Š” ์žฌ๋ฏธ์— ์ฑŒ๋ฆฐ์ง€๋ฅผ ๋” ์—ด์‹ฌํžˆ ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค

๊ฐœ์š”

โฐ๊ฐœ๋ฐœ๊ธฐ๊ฐ„

2021๋…„ 07์›” 23์ผ ~ 2021๋…„ 08์›” 31์ผ

โš™ ๊ธฐ์ˆ ์Šคํƒ

Language : JavaScript

AWS : Amazon S3

Front : React , Redux , styled-components , axios

๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง ํŒ€์›

Front-end: ๊น€ํƒœํ˜„ ์ •๋ฏผ์ฃผ ํŽธ์›์ค€

Back-end: ๊น€์„ ์šฉ ๊น€์ง„ํƒœ ๋ฐ•์—ฐ์šฐ ์ตœ์™•๊ทœ

Dedigner: ์•ˆ์ง€ํ˜œ ์œ ์ˆ˜๋นˆ

๐Ÿ“บ ์œ ํŠœ๋ธŒ ๋งํฌ

๐Ÿ“š ๋ฐฑ์—”๋“œ Repository

๐Ÿ“ ํŒ€ ๋…ธ์…˜

๐Ÿ› ์ฃผ์š”๊ธฐ๋Šฅ

๋žœ๋”ฉ ํŽ˜์ด์ง€

landingPage

์ฑŒ๋ฆฐ์ง€ ๊ฐœ์„คํ•˜๊ธฐ

CreateChallenge

์ฑŒ๋ฆฐ์ง€ ์ฐธ์—ฌํ•˜๊ธฐ

JoinChallenge

์ฑŒ๋ฆฐ์ง€ ์ธ์ฆํ•˜๊ธฐ

CertificationChallenge

์ฑŒ๋ฆฐ์ง€ ๊ฒ€์ƒ‰ํ•˜๊ธฐ

SearchChallenge

๐Ÿš€ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…

๐Ÿ”๊ฒ€์ƒ‰

์ฒ˜์Œ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•  ๋‹น์‹œ ์ „์ฒด ์ฑŒ๋ฆฐ์ง€๊ฐ€ ๋งค์šฐ ์ ์—ˆ๊ธฐ์—

์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ชจ๋“  ์ฑŒ๋ฆฐ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ๋’ค ํ”„๋ก ํŠธ์—์„œ ๋ˆŒ๋ ค์ง„ ํƒœ๊ทธ์— ๋งž๊ฒŒ ํ•„ํ„ฐ๋ง ํ•ด์ฃผ๋Š” ํ•จ์ˆ˜๋ฅผ ๊ตฌํ˜„

// ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ฐ›์•„์˜ค๋Š” challenges์™€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ˆ„๋ฅธ ํƒœ๊ทธ ๊ฐ’ filters๋ฅผ ๋น„๊ตํ•˜๋Š” ํ•จ์ˆ˜
const multiPropsFilter = (challenges, filters) => {
  const filterKeys = Object.keys(filters);
  return challenges.search.filter((challenge) => {
    return filterKeys.every((key) => {
      if (!filters[key].length) return true;
      if (Array.isArray(challenge[key])) {
        return challenge[key].some((keyEle) => filters[key].includes(keyEle));
      }
      return filters[key].includes(challenge[key]);
    });
  });
};
const searchProducts = () => {
  const filteredProducts = multiPropsFilter(searchList, filteredCollected());
  return filteredProducts?.filter((product) => {
    return product;
  });
};

ํ•˜์ง€๋งŒ ์ ์  ์ฑŒ๋ฆฐ์ง€๊ฐ€ ๋งŽ์•„์งˆ์ˆ˜๋ก ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๋Š”๊ฒŒ ๋น„ํšจ์œจ์ ์ด๋ผ๊ณ  ํŒ๋‹จํ•˜์— ํƒœ๊ทธ๋ฅผ ๋ˆ„๋ฅธ ๋’ค ๊ฒ€์ƒ‰ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅผ๋•Œ ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณ€๊ฒฝ ์‹œ๋„

// ์‚ฌ์šฉ์ž๊ฐ€ ๋ˆ„๋ฅธ ํƒœ๊ทธ ๊ฐ’์„ collectedTrueKeys์— ๋‹ด์•„ ์„œ๋ฒ„๋กœ ์ „์†ก
 const collectedTrueKeys = {
      categoryName: "",
      tags: "",
      challengeProgress: "",
    };
    const { categoryName, tags, progress } = searchState.passingTags;
    for (let categoryKey in categoryName) {
      if (categoryName[categoryKey])
        collectedTrueKeys.categoryName = categoryKey;
    }
    for (let tagKey in tags) {
      if (tags[tagKey]) collectedTrueKeys.tags = tagKey;
    }
    for (let progressKey in progress) {
      if (progress[progressKey]) collectedTrueKeys.progress = progressKey;
    }
    return collectedTrueKeys;
  };
  const filter = () => {
    dispatch(searchAll.searchFilterDB(filteredCollected()));
  };

๋งŒ๋“ค๋ฉด์„œ๋„ ํƒœ๊ทธ๋ฅผ ๋ˆ„๋ฅผ๋•Œ ๋ฐ”๋กœ ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ์‹์ด ๋” ํŽธํ•˜์ง€์•Š์„๊นŒํ–ˆ์—ˆ๋Š”๋ฐ ์•„๋‹ˆ๋‚˜ ๋‹ค๋ฅผ๊นŒ ์œ ์ € ํ”ผ๋“œ๋ฐฑ์œผ๋กœ ๋“ค์–ด์™€์„œ ๋ฐ”๋กœ ์ˆ˜์ • ์‹œ๋„ ๊ธฐ์กด์—๋Š” ๊ฒ€์ƒ‰ํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ํ†ตํ•ด api๋ฅผ ํ˜ธ์ถœํ–ˆ์œผ๋‚˜ ํƒœ๊ทธ์˜ ์ƒํƒœ๊ฐ’์„ ๋ฐ”๊พธ๋Š” ํ•จ์ˆ˜(allFilterClickListener)์•ˆ์—์„œ ์‹คํ–‰์‹œํ‚ค๋ คํ•˜๋‹ˆ ํƒœ๊ทธ์˜ ์ƒํƒœ๊ฐ’์ด ๋ณ€ํ•˜๊ธฐ์ „์— api๋ฅผ ํ˜ธ์ถœํ•ด์„œ ์‹คํŒจ

const allFilterClickListener = (e, filterProp) => {
    let name = e.target.textContent;
    if (name === "๊ธˆ์—ฐ๊ธˆ์ฃผ") {
      name = "NODRINKNOSMOKE";
    } else if (name === "์šด๋™") {
      name = "EXERCISE";
    } else if (name === "์ƒํ™œ์ฑŒ๋ฆฐ์ง€") {
      name = "LIVINGHABITS";
    }
      ...

      else {
      name = e.target.textContent;
    }
    setSearchState({
      passingTags: {
        ...searchState.passingTags,
        [filterProp]: {
          [name]: !searchState.passingTags[filterProp][name],
        },
      },
    });
  };

  useEffect(() => {
    if (keyWord === "ALL") {
      dispatch(searchActions.searchFilterDB(filteredCategory(), keyWord));
    } else {
      return dispatch(
        searchActions.searchFilterDB(filteredCategory(), keyWord)
      );
    }
  }, [dispatch, filteredCategory, keyWord, searchState]);

useEffect์„ ํ™œ์šฉํ•˜์—ฌ ํƒœ๊ทธ๋ฅผ ๋ˆŒ๋Ÿฌ ์ƒํƒœ๊ฐ’์ด ๋ฐ”๋€”๋•Œ๋งˆ๋‹ค ๋ฐ”๋กœ api๋ฅผ ํ˜ธ์ถœ์‹œํ‚ค๋Š” ๋ฐฉ์‹์œผ๋กœ ํ•ด๊ฒฐ

๐Ÿชrefresh token

๋กœ๊ทธ์ธ์‹œ ๋ฐ›์•„์˜ค๋Š” accessToken์ด ๋งŒ๋ฃŒ๋˜์—ˆ์„๋•Œ ๊ฐ™์ด ๋ฐ›์•„์˜จ refreshToken์„ ์„œ๋ฒ„์— ์ „์†กํ•˜๊ณ  ์ƒˆ๋กœ์šด accessToken๊ณผ refreshToken์„ ๊ฐ€์ ธ์™€ ์ฟ ํ‚ค์— ์ €์žฅํ•˜๋Š” ๋ฐฉ์‹์„ ๊ตฌํ˜„

instance.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const {
      config,
      response: { status },
    } = error;
    if (status === 401) {
      if (error.response.data.message === "TokenExpiredError") {
        const originalRequest = config;
        const refresh_token = getCookie("refreshToken");
        const token = getCookie("token");
        const { data } = await instance.post(`api/member/reissue`, {
          accessToken: token,
          refreshToken: refresh_token,
        });
        const { accessToken, refreshToken } = data;
        const accessCookie = { name: "token", value: accessToken };
        const refreshCookie = { name: "refreshToken", value: refreshToken };
        await multiCookie(accessCookie, refreshCookie);
        instance.defaults.headers.common[
          "Authorization"
        ] = `Bearer ${accessToken}`;
        originalRequest.headers.common[
          "Authorization"
        ] = `Bearer ${accessToken}`;
        return instance(originalRequest);
      }
    }
  }
);
  1. 401์—๋Ÿฌ๋ฅผ interceptor๋ฅผ ํ™œ์šฉํ•ดํ•˜์—ฌ ์ฒ˜๋ฆฌํ–ˆ์œผ๋‚˜ ๊ธฐ์กด์— ์—๋Ÿฌ๊ฐ€ ๋‚œ api์š”์ฒญ์„ ๋‹ค์‹œ ํ•˜๋Š” ๋กœ์ง์ด ์—†์—ˆ๊ณ ,
  2. ํ† ํฐ์ด ๋งŒ๋ฃŒ๋œ ์ƒํƒœ๋กœ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ api๋ฅผ ๋™์‹œ ํ˜ธ์ถœํ• ๋•Œ ํ˜ธ์ถœ๋œ ๋ชจ๋“  api๋งˆ๋‹ค ์œ„์— ๋กœ์ง์ด ์‹คํ–‰๋˜์–ด ํ•œ๋ฒˆ์— ํ† ํฐ ๊ต์ฒด๊ฐ€ ์—ฌ๋Ÿฌ๋ฒˆ ์ผ์–ด๋‚จ
  3. ๋˜ํ•œ ๋กœ๊ทธ์ธ์‹œ ๋ฐœ์ƒํ•˜๋Š” ์˜ค๋ฅ˜(์•„์ด๋”” ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ ์˜ค๋ฅ˜ ๋“ฑ) ๋˜ํ•œ 401์—๋Ÿฌ์—ฌ์„œ ํ•ด๋‹น ์˜ค๋ฅ˜ ๋ฐœ์ƒ์‹œ ๊ธฐ์กด์— ์ •ํ•ด๋‘” ์—๋Ÿฌ์ฒ˜๋ฆฌ๊ฐ€ ๋™์ž‘ํ•˜์ง€ ์•Š์Œ.
let isTokenRefreshing = false;
let refreshSubscribers = [];
const onTokenRefreshed = (accessToken) => {
  refreshSubscribers.map((callback, idx) => {
    return callback(accessToken);
  });
};
const addRefreshSubscriber = (callback) => {
  refreshSubscribers.push(callback);
};
...
instance.interceptors.response.use(
  (res) => {
    return res;
  },
  async (err) => {
    const originalConfig = err.config;
    if (originalConfig.url !== "api/member/login" && err.response) {
      if (err.response.status === 401 && !originalConfig._retry) {
        originalConfig._retry = true;
        if (!isTokenRefreshing) {
          isTokenRefreshing = true;
          const rs = await refreshTokens();
          const { accessToken, refreshToken } = rs.data;
          setCookie("token", accessToken);
          setCookie("refreshToken", refreshToken);
          isTokenRefreshing = false;
          instance.defaults.headers.common.Authorization = ` Bearer ${accessToken}`;
          originalConfig.headers.Authorization = `Bearer ${accessToken}`;
          onTokenRefreshed(accessToken);
          refreshSubscribers = [];
          return instance(originalConfig);
        }
        const retryOriginalRequest = new Promise((resolve) => {
          addRefreshSubscriber((accessToken) => {
            originalConfig.headers.Authorization = "Bearer " + accessToken;
            resolve(instance(originalConfig));
          });
        });
        return retryOriginalRequest;
      }
      if (err.response.status === 403 && err.response.data) {
        return Promise.reject(err.response.data);
      }
    }
    return Promise.reject(err);
  }
);

1 & 2. ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ api๋ฅผ ๋™์‹œ ํ˜ธ์ถœํ•˜์—ฌ ๋ฐœ์ƒํ•œ ์—๋Ÿฌ๋“ค์„ let refreshSubscribers = []; ์•ˆ์— ๋‹ด์•„๋‘๊ณ  ์ฐจ๋ก€๋กœ ์‹คํ–‰์‹œํ‚ด์œผ๋กœ ์—๋Ÿฌ๊ฐ€ ๋‚œ api ์š”์ฒญ์„ ํ•˜๋‚˜๋งŒ ์ฒ˜๋ฆฌ ์‹คํ–‰ํ•˜๊ณ  accessToken์„ ๊ต์ฒดํ•œ ๋’ค ๋‚˜๋จธ์ง€ api ์š”์ฒญ์„ ์‹คํ–‰ํ•˜๋Š”๊ฒƒ์œผ๋กœ ํ•ด๊ฒฐ 3. ๋กœ๊ทธ์ธ์‹œ ๋ฐœ์ƒํ•˜๋Š” 401 ์—๋Ÿฌ๋Š” if (originalConfig.url !== "api/member/login" && err.response)์œผ๋กœ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•จ

๐Ÿ‘ฉ๐Ÿป ์ฑ„ํŒ… ๋ฌดํ•œ์Šคํฌ๋กค

//ChatInfinityScroll.js
const _handleScroll = _.throttle(() => {
  //๋กœ๋”ฉ์ค‘์ด๋ฉด callNext()๋ฅผ ์•ˆ๋ถ€๋ฅด๋„๋ก
  if (loading) {
    return;
  }

  if (scrollTo.current.scrollTop === 0) {
    setPrevHeight(scrollTo.current.scrollHeight);
    console.log(scrollTo.current.scrollHeight);
    callNext();
  }
}, 500);

//MessageList.js
useEffect(() => {
  if (prevHeight) {
    scrollTo.current.scrollTop = scrollTo.current.scrollHeight - prevHeight;
    console.log(prevHeight, scrollTo.current.scrollHeight);
    return setPrevHeight(null);
  } else {
    scrollTo.current.scrollTop =
      scrollTo.current.scrollHeight - scrollTo.current.clientHeight;
  }
}, [chatInfo.messages]);
  1. ์ฑ„ํŒ…๊ธฐ๋Šฅ ๊ตฌํ˜„ ์ดˆ๋ฐ˜์— ์œ ์ €๊ฐ€ ๋ฉ”์„ธ์ง€๋ฅผ ์ž…๋ ฅํ•  ๋•Œ๋งˆ๋‹ค ์Šคํฌ๋กค์„ ์ œ์ผ ํ•˜๋‹จ์— ์œ„์น˜ํ•˜๋„๋ก ์ฝ”๋“œ ๊ตฌํ˜„

  2. useEffect์˜ ์˜์กด ๋ฐฐ์—ด์„ chatInfo.messages ์„ค์ •ํ•ด์„œ InfinityScroll ํ†ตํ•ด ์ƒˆ๋กœ์šด ๋ฉ”์„ธ์ง€ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ฒฝ์šฐ๋„ chatInfo.messages๊ฐ€ ์—…๋ฐ์ดํŠธ ๋˜๋ฏ€๋กœ useEffect ์‹คํ–‰๋˜๋ฉด์„œ ์Šคํฌ๋กค ์ œ์ผ ํ•˜๋‹จ์œผ๋กœ ์ด๋™

  3. api ์š”์ฒญํ•˜๊ธฐ์ „์˜ ์Šคํฌ๋กค ์œ„์น˜๋ฅผ preHeight ๋ณ€์ˆ˜์— ํ• ๋‹น

  4. preHeight ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ์Šคํฌ๋กค์ด ํ•˜๋‹จ์œผ๋กœ ๋‚ด๋ ค๊ฐ€์ง€๊ณ  ์•Š๊ณ  ์ €์žฅ๋˜์–ด ์žˆ๋Š” ์œ„์น˜์— ์žˆ๋„๋ก useEffect ์ฝ”๋“œ ๋ณ€๊ฒฝ