๐Ÿ’ป์›น(Web)/React

[React]์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ, ๊ตฌ๊ธ€ ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„

stonesy 2023. 6. 15. 14:12
728x90

https://github.com/seoyoung927/social_login_ex

 

GitHub - seoyoung927/social_login_ex: social login with kakao and google

social login with kakao and google. Contribute to seoyoung927/social_login_ex development by creating an account on GitHub.

github.com

 

์บก์Šคํ†ค ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด์„œ ์†Œ์…œ ๋กœ๊ทธ์ธ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๊ณต๋ถ€ํ•ด๋ณด์•˜๋‹ค. ์—ฌ๋Ÿฌ ๊ฐ€์ง€ ์ด์œ ๋กœ ๊ฒฐ๊ตญ ์†Œ์…œ ๋กœ๊ทธ์ธ ๋ฐฉ์‹์ด ์•„๋‹Œ ์•„์ด๋””/๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฐฉ์‹์˜ ๋กœ๊ทธ์ธ์œผ๋กœ ์บก์Šคํ†ค ํ”„๋กœ์ ํŠธ ๊ฐœ๋ฐœ์„ ์ง„ํ–‰ํ•˜์˜€์ง€๋งŒ, ๊ณต๋ถ€ํ•œ ๋‚ด์šฉ์„ ๊ฐ„๋‹จํžˆ ์ •๋ฆฌํ•ด๋ณด์•˜๋‹ค.

 

์†Œ์…œ ๋กœ๊ทธ์ธ์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ณผ์ •์€ ์ผ๋ฐ˜์ ์œผ๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

1. ์ธ๊ฐ€ ์ฝ”๋“œ ๋ฐ›๊ธฐ

ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์นด์นด์˜คํ†ก, ๊ตฌ๊ธ€ ๋“ฑ์œผ๋กœ ์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ์š”์ฒญํ•œ๋‹ค. 

2. ํ† ํฐ ๋ฐ›๊ธฐ

์–ป์€ ์ธ๊ฐ€ ์ฝ”๋“œ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์–ป๊ธฐ ์œ„ํ•œ access_token์„ ๋ฐœ๊ธ‰๋ฐ›๋Š”๋‹ค.

์ด๋•Œ, ๋ฐฑ์—”๋“œ์™€์˜ ํ˜‘์˜๋ฅผ ํ†ตํ•ด ์ธ๊ฐ€ ์ฝ”๋“œ๊นŒ์ง€๋งŒ ๋ฐœ๊ธ‰ ๋ฐ›์•„ ์ „๋‹ฌํ•˜๊ฑฐ๋‚˜ ํ˜น์€ ํ† ํฐ๊นŒ์ง€ ๋ฐœ๊ธ‰ ๋ฐ›์•„ ์ „๋‹ฌํ•œ๋‹ค.

3. ์‚ฌ์šฉ์ž ์ •๋ณด ์–ป๊ธฐ ๋ฐ ๋กœ์ง ์ฒ˜๋ฆฌ

์œ„์—์„œ ์–ป์€ access_token์„ ๋ฐ”ํƒ•์œผ๋กœ ์นด์นด์˜คํ†ก, ๊ตฌ๊ธ€์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค. ํ•ด๋‹น ์ •๋ณด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ํ”„๋กœ์ ํŠธ์—์„œ ํšŒ์›๊ฐ€์ž…์„ ์ง„ํ–‰ํ•˜๊ฑฐ๋‚˜ ๋กœ๊ทธ์ธ ํ›„ ํ”„๋กœ์ ํŠธ ์ „์šฉ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•œ๋‹ค.

 

๐Ÿ”Ž์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„

์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ๊ณต์‹ ๋ฌธ์„œ์— ์ž์„ธํžˆ ๋‚˜์™€์žˆ๋‹ค.

REST API | Kakao Developers ๋ฌธ์„œ

 

Kakao Developers

์นด์นด์˜ค API๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋‹ค์–‘ํ•œ ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐœ๋ฐœํ•ด๋ณด์„ธ์š”. ์นด์นด์˜ค ๋กœ๊ทธ์ธ, ๋ฉ”์‹œ์ง€ ๋ณด๋‚ด๊ธฐ, ์นœ๊ตฌ API, ์ธ๊ณต์ง€๋Šฅ API ๋“ฑ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

developers.kakao.com

 

(1) ํ‚ค ๋ฐœ๊ธ‰ ๋ฐ›๊ธฐ

์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„์„ ์œ„ํ•ด์„œ๋Š” ๊ฐ€์žฅ ๋จผ์ € ์นด์นด์˜คํ†ก์—์„œ REST API KEY๋ฅผ ๋ฐœ๊ธ‰๋ฐ›์•„์•ผ ํ•œ๋‹ค.

๋‹ค์Œ ๋งํฌ์—์„œ ๋‚ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋“ฑ๋กํ•˜์—ฌ REST_API_KEY๋ฅผ ๋ฐœ๊ธ‰ ๋ฐ›๊ณ , REDIRECT_URI๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋‹ค. REDIRECT_URI๋ž€ ์ดํ›„ ์‘๋‹ต์„ ๋ฐ›๊ธฐ ์œ„ํ•œ URI์ด๋‹ค. ์ธ๊ฐ€์ฝ”๋“œ ๋ฐœ๊ธ‰์„ ์œ„ํ•ด ์นด์นด์˜คํ†ก์— ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด ์œ„์— ์„ค์ •ํ•œ REDIRECT_URI๋กœ ์‘๋‹ต์„ ๋ฐ›๊ฒŒ ๋œ๋‹ค.

https://developers.kakao.com/

 

Kakao Developers

์นด์นด์˜ค API๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋‹ค์–‘ํ•œ ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐœ๋ฐœํ•ด๋ณด์„ธ์š”. ์นด์นด์˜ค ๋กœ๊ทธ์ธ, ๋ฉ”์‹œ์ง€ ๋ณด๋‚ด๊ธฐ, ์นœ๊ตฌ API, ์ธ๊ณต์ง€๋Šฅ API ๋“ฑ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

developers.kakao.com

๋‚˜๋„ ์œ„ ๋งํฌ์—์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋‚ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋“ฑ๋กํ–ˆ๋‹ค.

์šฐ์„ ์€ ์บก์Šคํ†ค ํ”„๋กœ์ ํŠธ๋กœ React-Spring ๊ธฐ๋ฐ˜์˜ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐœ๋ฐœํ•˜๊ณ  ์žˆ์œผ๋ฏ€๋กœ, ํ”Œ๋žซํผ>web์— ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์„ค์ •ํ•ด์ฃผ์—ˆ๋‹ค.

 

(2) ์ธ๊ฐ€ ์ฝ”๋“œ ๋ฐ›๊ธฐ

์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ๋ฐ›์•„์˜ค์ž.

https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}

์œ„์˜ ์˜ˆ์‹œ ์ฝ”๋“œ์—์„œ๋„ ์•Œ ์ˆ˜ ์žˆ๋“ฏ์ด ๋จผ์ € REST_API_KEY๋ฅผ ๋ฐœ๊ธ‰ ๋ฐ›๊ณ , REDIRECT_URI ๋“ฑ์„ ๋จผ์ € ์„ค์ •ํ•ด์•ผ ํ•œ๋‹ค. (1)๋‹จ๊ณ„์—์„œ ๋ฐœ๊ธ‰๋ฐ›์€ REST_API_KEY์™€ REDIRECT_URI๋ฅผ ์ด์šฉํ•˜์ž.

์•„๋ž˜๋Š” ์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ๋ฐœ๊ธ‰๋ฐ›๋Š” ๊ณผ์ •์„ ๊ตฌํ˜„ํ•œ React ์ฝ”๋“œ์ด๋‹ค.(.env๋ฅผ ์ด์šฉํ•˜์˜€๋‹ค.)

import styles from "./LoginPage.module.css";

function LoginPage(){
    const KAKAO_REST_API_KEY = process.env.REACT_APP_KAKAO_REST_API_KEY;
    const KAKAO_REDIRECT_URI = process.env.REACT_APP_KAKAO_REDIRECT_URI;

    const onKakaoSocialLogin = () => {
        window.location.href=`https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URI}`;
    }

    return <div className={styles.container}>
        <div className={styles.button__wrapper}>
            <button onClick={onKakaoSocialLogin}>
            ์นด์นด์˜คํ†ก ๋กœ๊ทธ์ธ
            </button>
        </div>
    </div>
}

export default LoginPage;

REDIRECT_URI์—์„œ ๋ฐ›์€ ์ธ๊ฐ€ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

*์ฐธ๊ณ 

์„œ๋น„์Šค์—์„œ ํ•„์š”ํ•œ ๋™์˜ํ•ญ๋ชฉ์„ ๋”ฐ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์ด scope๋ฅผ ํ†ตํ•ด ๋”ฐ๋กœ ์„ค์ •ํ•˜๋ฉด ๋กœ๊ทธ์ธ์‹œ ๋™์˜ํ™”๋ฉด์ด ๋‚˜์˜ค๊ฒŒ ๋œ๋‹ค.

https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URI}&scope=profile_nickname,profile_image,account_email,gender

 

(3) ํ† ํฐ ๋ฐ›๊ธฐ

์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ๋ฐ›์œผ๋ฉด ํ•ด๋‹น ์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ์ด์šฉํ•ด ์นด์นด์˜คํ†ก์—์„œ ํ† ํฐ์„ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ๋‹ค. ํ† ํฐ๊นŒ์ง€ ๋ฐ›์•„์„œ ๋ฐฑ์—”๋“œ์— ์ „๋‹ฌํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ณ  ๋‹ค์Œ์„ ๊ตฌํ˜„ํ•˜์˜€๋‹ค.

https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&code=${code}
import styles from "./KakaoOAuth2RedirectPage.module.css";
import { useEffect } from "react";

function KakaoOAuth2RedirectPage() {
  // 1. ์ธ๊ฐ€์ฝ”๋“œ
  const code = new URL(window.location.href).searchParams.get("code");
  // 2. access Token ์š”์ฒญ
  const getToken = async (code: string) => {
    const KAKAO_REST_API_KEY = process.env.REACT_APP_KAKAO_REST_API_KEY;
    const KAKAO_REDIRECT_URI = process.env.REACT_APP_KAKAO_REDIRECT_URI;

    const response = await fetch(`https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URI}&code=${code}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
      },
    });
    return response.json();
  }

  useEffect(() => {
    if (code) {
      getToken(code).then((res) => {
        console.log(res.access_token);
      })
    }
  }, []);

  return <div className={styles.container} >
    <div className={styles.spinner} />
  </div>

}

export default KakaoOAuth2RedirectPage;

 

 

๐Ÿ”Ž๊ตฌ๊ธ€ ์†Œ์…œ ๋กœ๊ทธ์ธ

์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ๊ณผ ๊ธฐ๋ณธ์ ์ธ ํ๋ฆ„์€ ๊ฐ™๋‹ค.

https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow?hl=ko 

 

ํด๋ผ์ด์–ธํŠธ ์ธก ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์šฉ OAuth 2.0  |  Authorization  |  Google for Developers

์ด ํŽ˜์ด์ง€๋Š” Cloud Translation API๋ฅผ ํ†ตํ•ด ๋ฒˆ์—ญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Switch to English ์˜๊ฒฌ ๋ณด๋‚ด๊ธฐ ํด๋ผ์ด์–ธํŠธ ์ธก ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์šฉ OAuth 2.0 ์ปฌ๋ ‰์…˜์„ ์‚ฌ์šฉํ•ด ์ •๋ฆฌํ•˜๊ธฐ ๋‚ด ํ™˜๊ฒฝ์„ค์ •์„ ๊ธฐ์ค€์œผ๋กœ ์ฝ˜ํ…์ธ ๋ฅผ ์ €์žฅํ•˜

developers.google.com

 

(1) ํ‚ค ๋ฐœ๊ธ‰ ๋ฐ›๊ธฐ

๋จผ์ € ํ‚ค๋ฅผ ๋ฐœ๊ธ‰๋ฐ›๋Š”๋‹ค. ํ‚ค๋Š” google cloud console์—์„œ ๋ฐœ๊ธ‰๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค.

https://console.cloud.google.com/

 

Google ํด๋ผ์šฐ๋“œ ํ”Œ๋žซํผ

๋กœ๊ทธ์ธ Google ํด๋ผ์šฐ๋“œ ํ”Œ๋žซํผ์œผ๋กœ ์ด๋™

accounts.google.com

์นด์นด์˜คํ†ก๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ study๋ผ๋Š” ์ด๋ฆ„์˜ ํ”„๋กœ์ ํŠธ๋ฅผ ๋“ฑ๋กํ•˜์˜€๋‹ค. 

์นด์นด์˜คํ†ก์—์„œ REST_API_KEY๋ฅผ ๋ฐœ๊ธ‰๋ฐ›์•˜๋‹ค๋ฉด, ๊ตฌ๊ธ€์€ CLIENT_ID๋ฅผ ๋ฐœ๊ธ‰๋ฐ›๋Š”๋‹ค. API ๋ฐ ์„œ๋น„์Šค>์‚ฌ์šฉ์ž ์ธ์ฆ ์ •๋ณด ์šฐ์ธก์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ํด๋ผ์ด์–ธํŠธ ID๋ฅผ ๋ณต์‚ฌํ•ด .env ํŒŒ์ผ์— ์ €์žฅํ•œ๋‹ค. 

์ถ”๊ฐ€์ ์œผ๋กœ SECRET_KEY๋„ .env ํŒŒ์ผ์— ์ ๋Š”๋‹ค. ์ดํ›„ access_token ๋ฐœ๊ธ‰ ์‹œ ํ•„์š”ํ•˜๋‹ค.

๊ทธ๋ฆฌ๊ณ  ์นด์นด์˜คํ†ก ๊ตฌํ˜„ ๋•Œ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์„ค์ •ํ•œ๋‹ค.

์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„๊ณผ ๋‹ค๋ฅธ ์ ์€ OAuth ๋™์˜ ํ™”๋ฉด์—์„œ ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ์ž๋ฅผ ๋“ฑ๋กํ•ด์•ผํ•œ๋‹ค๋Š” ์ ์ด๋‹ค. ๋กœ๊ทธ์ธ์„ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์‚ฌ์šฉ์ž๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋˜๋Š”๋ฐ, ๊ฒŒ์‹œ ์ƒํƒœ๊ฐ€ "ํ…Œ์ŠคํŠธ ์ค‘"์ผ ๋•Œ๋Š” ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ์ž๋งŒ ์ ‘์†ํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํ•œ๋‹ค. ํ…Œ์ŠคํŠธํ•  ์ž์‹ ์˜ google ๊ณ„์ •์„ ๋“ฑ๋กํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

 

(2) ์ธ๊ฐ€ ์ฝ”๋“œ ๋ฐ›๊ธฐ

์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ๋ฐ›์•„์˜ค์ž. ์นด์นด์˜คํ†ก ๊ณต์‹ ๋ฌธ์„œ๋ณด๋‹ค ์ฝ๊ธฐ ์–ด๋ ค์šด ๊ฒƒ ๊ฐ™์ง€๋งŒ.. ์ˆœ์„œ๋Œ€๋กœ ํ•˜๋ฉด ๋˜๊ธด ํ•œ๋‹ค.

https://accounts.google.com/o/oauth2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${GOOGLE_REDIRECT_URI}&response_type=code&scope=openid email profile

์•„๋ž˜๋Š” ์ธ๊ฐ€ ์ฝ”๋“œ๋ฅผ ๋ฐœ๊ธ‰๋ฐ›๋Š” ๊ณผ์ •์„ ๊ตฌํ˜„ํ•œ React ์ฝ”๋“œ์ด๋‹ค.

import styles from "./LoginPage.module.css";

function LoginPage() {
    const KAKAO_REST_API_KEY = process.env.REACT_APP_KAKAO_REST_API_KEY;
    const KAKAO_REDIRECT_URI = process.env.REACT_APP_KAKAO_REDIRECT_URI;
    const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID;
    const GOOGLE_REDIRECT_URI = process.env.REACT_APP_GOOGLE_REDIRECT_URI;

    const onKakaoSocialLogin = () => {
        window.location.href = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_REDIRECT_URI}`;
    }
    const onGoogleSocialLogin = () => {
        window.location.href = `https://accounts.google.com/o/oauth2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${GOOGLE_REDIRECT_URI}&response_type=code&scope=openid email profile`;
    }


    return <div className={styles.container}>
        <div className={styles.button__wrapper}>
            <button
                className={styles.button__kakao__login} onClick={onKakaoSocialLogin}>
                <img
                    className={styles.button__image}
                    src={`${process.env.PUBLIC_URL}/images/kakao_login_large_narrow.png`} />
            </button>
        </div>
        <div className={styles.button__wrapper}>
            <button
                className={styles.button__google__login} onClick={onGoogleSocialLogin}>
                ๊ตฌ๊ธ€ ์†Œ์…œ ๋กœ๊ทธ์ธ
            </button>
        </div>
    </div>
}

export default LoginPage;

 

(3) ํ† ํฐ ๋ฐ›๊ธฐ

https://oauth2.googleapis.com/token?grant_type=authorization_code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&client_secret=${SECRET_KEY}&code=${code}

React๋กœ ๊ตฌํ˜„ํ•œ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

import { useEffect } from "react";
import styles from "./GoogleOAuth2RedirectPage.module.css";

function GoogleOAuth2RedirectPage() {
    // 1. ์ธ๊ฐ€์ฝ”๋“œ
    const code = new URL(window.location.href).searchParams.get("code");
    // 2. access Token ์š”์ฒญ
    const getToken = async (code: string) => {
        const REST_API_KEY = process.env.REACT_APP_GOOGLE_CLIENT_ID;
        const REDIRECT_URI = process.env.REACT_APP_GOOGLE_REDIRECT_URI;
        const SECRET_KEY = process.env.REACT_APP_GOOGLE_SECRET_KEY;
        const response = await fetch(`https://oauth2.googleapis.com/token?grant_type=authorization_code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&client_secret=${SECRET_KEY}&code=${code}`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
            },
        });
        return response.json();
    }

    useEffect(() => {
        if (code) {
          getToken(code).then((res) => {
            console.log(res.access_token);
          })
        }
      }, [code]);
    
      return <div className={styles.container} >
        <div className={styles.spinner} />
      </div>
}

export default GoogleOAuth2RedirectPage;

 

 

๐Ÿ”Ž์ฐธ๊ณ 

REST-API ํ™œ์šฉํ•œ ์นด์นด์˜ค ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„(feat. React) (tistory.com)

https://hymndev.tistory.com/72

https://notspoon.tistory.com/47

 

 

728x90