[React] JavaScript 응용_2 (Spread연산자/동기&비동기/Promise/콜백지옥/async & await/API 호출)

2023. 4. 3. 15:21개발공부 기강잡자/React | JavaScript | NodeJS

섹션 3 JavaScript 응용

<Udemy 리액트 챌린지 1주차>

이정환 강사님의 강의 '한입 크기로 잘라먹는 리액트'를 수강하고 있습니다.

Udemy에서 진행하고 있는 리액트 챌린지에 참여하며 1주차 미션으로 "섹션 3 JavaScript 응용"을 수강한 내용을 정리하고 있습니다. 👍


Spread 연산자

const cookie = {
    base : "cookie",
    madeIn : "Korea",
};
const chocochipCookie = {
    base : "cookie",
    madeIn : "Korea",
    toping : "chocochip"
};
const blueberryCookie = {
    base : "cookie",
    madeIn : "Korea",
    toping : "blueberry"
}; 
const strawberryCookie = {
    base : "cookie",
    madeIn : "Korea",
    toping : "strawberry"
};

같은 프로퍼티를 갖는 여러 종류의 Cookie 객체를 생성할 때, 똑같은 코드가 반복된다.
(중복 프로퍼티 base, madeIn를 가지고 있으며, toping value만 다름)

👉 반복되는 코드 작성으로 생산성과 코드의 가독성이 나빠진다.

 

Spread 연산자 ... 를 사용하여 개선

const cookie = {
    base : "cookie",
    madeIn : "Korea",
};
const chocochipCookie = {
    ...cookie,  // cookie의 속성 받아옴
    toping : "chocochip"
};
const blueberryCookie = {
    ...cookie,
    toping : "blueberry"
}; 
const strawberryCookie = {
    ...cookie,
    toping : "strawberry"
};

... 가 Spread 연산자이다.

...cookie, 👉 모든 OOOCookie 객체가 공통적으로 갖는 cookie의 프로퍼티 값을 받아온다

 

객체와 배열에 사용 가능

const noTopingCookies = ['촉촉한쿠키', '안촉촉한쿠키'];
const topingCookies = ['바나나쿠키', '블루베리쿠키', '딸기쿠키', '초코칩쿠키'];

// noTopingCookies + topingCookies
const allCookies = [...noTopingCookies, ...topingCookies];

동기 & 비동기

  • 동기 : 순서대로 작동
  • 비동기 : 순서대로 작동하지 않음

자바스크립트는 싱글스레드 방식으로 코드가 작성된 순서대로 작업을 처리한다.

이전 작업이 진행 중일 때는 이전 작업이 완료될 때까지 기다렸다가 다음 작업을 수행한다.

= 동기방식의 처리 (블로킹 방식)

function taskA(){
	console.log("TASK A");
}
function taskB(){
	console.log("TASK B");
}
function taskC(){
	console.log("TASK C");
}

위와 같은 함수를 taskA() → taskB() → taskC() 순서로 호출했을 때, 아래와 같이 순서대로 함수가 수행된다.

동기처리 방식의 경우, 수행해야하는 작업들이 오래 걸리는 경우 다음 작업은 계속 기다려야하는 문제가 발생한다. 그렇게 되면 전체 작업이 끝나는 시간이 느려진다.

위 처럼 taskB 의 수행 시간이 길어지는 경우 taskC는 이전 작업이 끝날 때까지 기다려야한다.

 

스레드를 여러개 사용하여 작업을 각각 실행하도록 하면 MultiThread를 사용하면 작업의 분할이 가능해진다. 그러면 전체 Task를 끝내는 시간이 짧아진다. → 그러나 JavaScript는 싱글스레드 방식을 사용한다.

 

비동기 작업

: 동기 작업의 단점을 해결하기 위한 방법으로, 하나의 스레드에 여러 개의 작업을 동시에 실행시켜 먼저 작성된 코드의 결과를 기다리지 않고 다음 코드를 바로 실행하도록 한다.

→ 비동기 작업 (논블로킹 방식)

그런데 이렇게 동시에 여러 작업을 실행시킨다면, 한 작업이 정상적으로 끝났는지, 결과값은 어떻게 확인?

→ 각 함수가 완료되면 콜백함수를 수행하도록 콜백함수를 정의한다‼️ 

콜백함수

: 비동기함수의 수행여부, 결과를 확인할 수 있도록 정의함

각 task 가 끝나고 수행할 콜백함수를 정의하여 비동기함수의 수행여부와 그 결과를 알 수 있다.

만약 taskA가 끝나면, A 콜백이 수행되어 taskA 함수의 결과값인 resultA 를 매개변수로 받아서 'A 끝났습니다. 작업결과 : {resultA}'를 출력한다.

동기 방식

function taskA() {
  console.log("A 작업 끝");
}
taskA();
console.log("코드 끝");

taskA를 동기방식으로 호출하면 순서대로 A 작업 끝 → 코드 끝으로 출력된다.

 

비동기 방식

function taskA() {
  setTimeout(() => {
    console.log("A 코드 끝");
  }, 2000); // 2초 뒤에 콜백함수 수행
}
taskA();
console.log("코드 끝");

taskA를 setTimeout 함수를 사용하여 2초 뒤에 콜백 함수를 호출한다.

taskA 호출 부의 다음 코드는 taskA()가 끝나길 기다리지 않고 코드 끝을 출력한다.
비동기함수의 수행이 끝난 뒤, setTimeout의 콜백함수는 A 코드 끝을 출력한다.

 

비동기함수의 결과값 이용

function taskA(a, b, cb) { // 지역변수를 사용할 수 있도록 콜백함수 cb 선언
  setTimeout(() => {
        // 3초뒤 수행되는 부분
    const res = a + b;
    cb(res); // 콜백함수 호출
      }, 3000);// 3초 뒤에 setTimeout의 콜백함수 수행
}
taskA(3, 4, (res) => {
  console.log("A result: ", res);
}); // 콜백함수가 taskA의 결과값을 받아서 출력
console.log("코드 끝");

- taskA의 콜백함수를 함수 내부에서 지역변수로 호출할 수 있도록 taskA의 매개변수로 cb 를 선언했다.

- setTimeout을 사용하여 3초 뒤에 setTimeout의 콜백함수가 res를 구하고, taskA의 콜백함수 cb()res 값을 넘겨주면 res 값을 받은 cb()res의 값을 출력한다.

  • 출력순서 : "코드 끝" → "A result: 7"

비동기함수 2개 사용

function taskA(a, b, cb) {
  // 지역변수를 사용할 수 있도록 콜백함수 선언
  setTimeout(() => {
    const res = a + b;
    cb(res); // 콜백함수 호출
  }, 3000); // 3초 뒤에 콜백함수 수행
}
function taskB(a, cb) {
  setTimeout(() => {
    const res = a * 2;
    cb(res); // 콜백함수 호출
  }, 1000);
}

taskA(3, 4, (res) => {
  console.log("A result: ", res);
}); // 콜백함수가 taskA의 결과값을 받아서 출력
taskB(7, (res) => {
  console.log("B result: ", res);
});
console.log("코드 끝");

taskA와 taskB가 동시에 실행되지만 taskB()는 1초 뒤 콜백함수 cb(res)를 호출하고 taskA()는 3초 뒤 콜백함수 cb(res)를 호출하기 때문에 B의 결과가 A보다 먼저 출력된다.

  • 출력순서 : “코드 끝” → “B result : 14” → “A result : 7”

비동기 함수 3개 예제

function taskA(a, b, cb) {
  // 지역변수를 사용할 수 있도록 콜백함수 선언
  setTimeout(() => {
    const res = a + b;
    cb(res); // 콜백함수 호출
  }, 3000); // 3초 뒤에 콜백함수 수행
}
function taskB(a, cb) {
  setTimeout(() => {
    const res = a * 2;
    cb(res); // 콜백함수 호출
  }, 1000);
}

function taskC(a, cb) {
  setTimeout(() => {
    const res = a * -1;
    cb(res); // 콜백함수 호출
  }, 2000);
}

taskA(3, 4, (res) => {
  console.log("A result: ", res);
}); // 콜백함수가 taskA의 결과값을 받아서 출력
taskB(7, (res) => {
  console.log("B result: ", res);
});
taskC(14, (res) => {
  console.log("C result: ", res);
});
console.log("코드 끝");
  • taskB() : 1초 뒤 수행
  • taskC() : 2초 뒤 수행
  • taskA() : 3초 뒤 수행 되므로 출력 결과는 다음과 같다.

JS Engine의 작동방식

  • HeapCall Stack 영역으로 나뉘어져 있다.
  • Heap : 변수나 상수 등 메모리 할당에 사용되는 영역
  • Call Stack : 코드의 실행에 따라서 스택을 따름

CallStack
- 동기방식으로 호출하는 경우

function one(){
    return 1;
}
function two(){
    return one() + 1;
}
function three(){
    return two() + 1;
}
console.log(three());

 

  1. 코드 실행 : 자바스크립트 코드가 실행되면 최상위 Context 인 Main Context가 CallStack에 들어옴

  2. 함수 호출
    : 호출된 함수 three()가 Call Stack에 추가
  3. 함수 수행
    : three() 함수 내에서 호출된 함수 two()가 Call Stack에 추가
  4. 함수 수행
    : two() 함수 내에서 호출된 함수 one()이 Call Stack에 추가

  5. one() 함수 수행
  6. one() 함수 수행 완료 : Call Stack에서 one() 제거
    → 종료된 함수는 바로 Call Stack에서 제거 (Stack - LIFO)

  7. two() 함수 수행 완료
    : Call Stack에서 two() 제거
  8. three() 함수 수행 완료
    : Call Stack에서 three() 제거

  9. 코드 종료
    : Call Stack에서 Main Context 제거

⇒ JavaScript는 Call Stack이 하나이므로 싱글 스레드로 동작한다!

 

CallStack
- 비동기방식으로 호출하는 경우

function asyncAdd(a, b, cb){
    setTimeout(() => {
        const res = a + b;
        cb(res);
    }, 3000);
}

asyncAdd(1, 3, (res) => {
    console.log("결과 : ", res);
});

비동기처리를 위해서 WebAPIs, Callback Queue, Event loop 가 추가가 됨

  1. 코드 실행 : Main Context가 CallStack에 들어옴

  2. asyncAdd()호출 : CallStack에 asyncAdd() 추가

  3. 비동기 함수 setTimeout() 호출 : CallStack에 setTimeout() 와 콜백함수 cb() 추가
    → 비동기함수이므로 WebAPIs 로 전달

    WebAPIs에서 3초를 기다림

  4. asyncAdd() 함수 수행완료
    : CallStack에서 제거
  5. 3초 지난 뒤 WebAPIs의 cb() 함수가 Callback Queue로 옮겨짐

    → CallStack에 Main Context 이외에 다른 함수가 남아있지 않으면 cb()Event Loop에 의해 CallStack으로 이동됨
  6. cb() 수행
  7. cb() 수행 완료
    : CallStack에서 제거
  8. 코드 종료
    : Call Stack에서 Main Context 제거

👉 엔진의 수행방식을 알면 동기코드와 비동기코드를 동시에 사용할 때 문제가 발생한 경우 해결 방안을 찾아내기 수월해진다.‼️

 

비동기 함수 3개를 순서대로 호출하는 법

function taskA(a, b, cb) {
  // 지역변수를 사용할 수 있도록 콜백함수 선언
  setTimeout(() => {
    const res = a + b;
    cb(res); // 콜백함수 호출
  }, 3000); // 3초 뒤에 콜백함수 수행
}
function taskB(a, cb) {
  setTimeout(() => {
    const res = a * 2;
    cb(res); // 콜백함수 호출
  }, 1000);
}

function taskC(a, cb) {
  setTimeout(() => {
    const res = a * -1;
    cb(res); // 콜백함수 호출
  }, 2000);
}

taskA(4, 5, (a_res) => {
  console.log("A result: ", a_res);
  taskB(a_res, (b_res) => {
    console.log("B result: ", b_res);
    taskC(b_res, (c_res) => {
      console.log("C result: ", c_res);
    });
  });
});

console.log("코드 끝");

👆 비동기 함수를 이렇게 호출하면 코드끝-A-B-C 순서로 수행할 수 있다.
→ 비동기 처리의 결과를 다음으로 호출할 비동기함수의 인자값으로 전달

 

수행결과

하지만‼️‼️‼️ 함수 호출 부의 가독성이 너무 안 좋다. 이처럼 콜백이 계속 깊어지는 현상을 콜백 지옥이라고 한다.
Promise 객체를 통해 해결할 수 있다.

 

Promise

→ 콜백지옥을 해결하기 위한 방법!

taskA(4, 5, (a_res) => {
  console.log("A result: ", a_res);
  taskB(a_res, (b_res) => {
    console.log("B result: ", b_res);
    taskC(b_res, (c_res) => {
      console.log("C result: ", c_res);
    });
  });
});

비동기 작업이 가질 수 있는 3가지 상태

  1. Pending (대기 상태) : 비동기 작업이 진행 중이거나, 실행될 수 없는 상태
  2. Fulfilled (성공) : 비동기 작업이 의도한대로 수행된 상태
  3. Rejected (실패) : 비동기 작업이 모종의 이유로 실패한 상태
    (ex : 서버가 응답을 하지 않는 경우, 시간이 너무 오래걸려서 자동으로 취소가 된 경우 등)

→ 비동기 작업은 한번 성공/실패 시 재시도 없이 그냥 그걸로 작업이 끝난다.

 

상태 전이

  • Pending → Fulfilled 로 변화 : Resolve  (해결)
  • Pending → Rejected 로 변화 : Reject (거부)

예제

  • isPositive()
    : 2초 뒤에 매개변수 number가 숫자면 양수인지 음수인지 판별해서 resolve 콜백함수로 전달하고,
    숫자가 아니면 reject 콜백함수를 호출하는 함수
function isPositive(number, resolve, reject) {
  setTimeout(() => {
    if (typeof number === "number") {
      // 성공 -> Resolve 콜백
      resolve(number >= 0 ? "양수" : "음수");
    } else {
      // 실패 -> Reject 콜백
      reject("숫자 아님");
    }
  }, 2000);
}

// 성공시키기
isPositive(
  10,
  (res) => {
    console.log("성공, 결과 : ", res);
  },
  (err) => {
    console.log("실패, 원인 : ", err);
  }
);

// 실패시키기
isPositive(
  [],
  (res) => {
    console.log("성공, 결과 : ", res);
  },
  (err) => {
    console.log("실패, 원인 : ", err);
  }
);

👉 number가 숫자인 경우 resolve(인자값) 콜백함수를 호출하고, 숫자가 아닌 경우 reject(인자값) 콜백함수로 인자값을 전달한다.
isPositive 함수를 호출할 때, resolve와 reject 콜백함수를 정의하여 콜백을 처리한다.

 

Promise 사용

function isPositiveP(number) {
  const executor = (resolve, reject)=>{ // 실행자 : 비동기작업을 실질적으로 수행해주는 함수
    setTimeout(() => {
      if (typeof number === "number") {
        // 성공 -> Resolve 콜백
        resolve(number >= 0 ? "양수" : "음수");
      } else {
        // 실패 -> Reject 콜백
        reject("숫자 아님");
      }
    }, 2000)
  }
  const asyncTask = new Promise(executor);
  return asyncTask;
}

👉 비동기 작업을 실질적으로 실행하는 실행자executor를 선언하고,

비동기 작업을 실행하기 위해서 asyncTask라는 변수에 비동기 작업 자체를 저장한다.
Promise 객체는 비동기 작업을 실질적으로 실행하는 함수 executor를 인자로 전달받는다.
전달하는 순간 executor함수가 수행된다.

→ isPositiveP 함수의 반환 Type이 Promise 인 것을 확인할 수 있음

💡어떤 함수의 Return Type이 Promise 면 비동기작업을 하는 함수라는 것을 알 수가 있다!

 

Return 된 Promise 객체 사용하기

형식 : res.then(()=>{성공한 경우 콜백함수}).catch(()=>{실패한 경우 콜백함수}) 

✔️ 비동기함수인 Promise 객체의 작업이 성공한 경우 then의 콜백함수로 처리가 되고, 실패한 경우 catch 의 콜백함수를 처리한다.

const res = isPositiveP(101);
res.then((res) => {
    console.log("작업성공 : ", res);
  })
  .catch((err) => {
    console.log("작업실패 : ", err);
  });

출력 결과 : “작업성공 : 양수”

 

콜백지옥 탈출하기

→ 비동기함수의 결과를 다음 비동기함수의 인자값으로 넘겨야하는게 반복될 경우 발생하는 상황

taskA(4, 5, (a_res) => {
  console.log("A result: ", a_res);
  taskB(a_res, (b_res) => {
    console.log("B result: ", b_res);
    taskC(b_res, (c_res) => {
      console.log("C result: ", c_res);
    });
  });
});

👉 Promise 객체로 바꿔서 위와 같은 콜백지옥을 해결해보자!

 

function taskA(a, b) {
  const executerA = (resolve, reject) => {
    setTimeout(() => {
      const res = a + b;
      resolve(res);
    }, 3000); // 3초 뒤에 수행
  };
  return new Promise(executerA);
}
function taskB(a) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const res = a * 2;
      resolve(res);
    }, 1000);
  });
}

function taskC(a) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const res = a * -1;
      resolve(res);
    }, 2000);
  });
}

taskA(5, 1).then((a_res) => {
  console.log("A result: ", a_res);
  taskB(a_res).then((b_res) => {
    console.log("B result: ", b_res);
    taskC(b_res).then((c_res) => {
      console.log("C result: ", c_res);
    });
  });
});
console.log("코드 끝");

Promise를 사용했지만 콜백 지옥 해결 안됨!
→ Promise 객체의 then 함수는 이렇게 사용하는게 아니다!!!⭐️

 

Then Chaining

taskA(5, 1)
  .then((a_res) => {
    console.log("A result: ", a_res);
    return taskB(a_res); // taskB를 호출하고 그 결과값을 반환
  })
  .then((b_res) => {
    console.log("B result: ", b_res);
    return taskC(b_res);
  })
  .then((c_res) => {
    console.log("C result: ", c_res);
  });

하나하나 차근차근히 보면

taskA(5, 1)

→ 이부분의 반환값은 taskA()함수에서 반환하는 Promise 객체

taskA(5, 1)
  .then((a_res) => {
    console.log("A result: ", a_res);
    return taskB(a_res); // taskB를 호출하고 그 결과값을 반환
  })

taskA()함수에서 반환하는 Promise 객체의 then 메소드의 resolve 콜백함수는 "A result: 6" 를 출력하고, taskB(a_res)를 리턴하도록 함 (taskB를 호출)
→ 그러면 이부분의 반환값은 taskB()의 리턴인 Promise객체

 

.then((b_res) => {
    console.log("B result: ", b_res);
    return taskC(b_res);
  })

 taskA()는 taskB()를 리턴했으므로 이 부분은 taskB()의 리턴인 Promise객체의 then 함수 
resolve 콜백함수에서 "B result: 12" 를 출력하고, b_res 값을 taskC()의 인자로 전달

 

  .then((c_res) => {
    console.log("C result: ", c_res);
  });

→ taskB()는 taskC()를 리턴했으므로 이 부분은 taskC()의 리턴인 Promise객체
resolve 콜백함수는 "C result: -12"을 출력하게 된다.

 

❤️‍🔥 이렇게 하면 콜백지옥이 해결된다! = then Chaining

 

// taskA 호출
const aPromise = taskA(5, 1).then((a_res) => {
  console.log("A result: ", a_res);
  return taskB(a_res); // taskB를 호출하고 그 결과값을 반환
});

console.log("----");

// 콜백함수 부분 분리 가능
aPromise
  .then((b_res) => {
    console.log("B result: ", b_res);
    return taskC(b_res);
  })
  .then((c_res) => {
    console.log("C result: ", c_res);
  });

→ 이런식으로 taskA 가 끝나고 수행되는 콜백함수 부분을 분리할 수도 있다.

 

async와 await

Async

: 비동기기술, Promise를 더욱 쉽게 다룰 수 있도록 해줌

async function helloAsync(){
  return 'hello Async';
}

function 키워드 앞에 async 를 작성하여 함수를 선언하면 Promise객체를 반환하는 비동기함수를 선언할 수 있음

console.log(helloAsync()); // Promise 객체를 출력

따라서 'hello Async'라는 문자열을 반환하는 helloAsync() 함수를 출력하면 문자열 'hello Async'가 아니라 Promise 객체를 출력함

Promise 객체가 아닌 async 함수의 리턴값을 출력하기 위해선 then을 사용하여 핸들링해야한다!

 

helloAsync().then((res) => {
  console.log(res);
});

async 함수의 Return값은 비동기함수의 반환값인 Promise의 resolve 결과값이 된다.
→ 따라서 then()으로 핸들링할 수 있다.

 

Await

  • 예제) 3초 기다렸다가 "hello Async"를 리턴하는 함수
    1. delay(ms) : setTimeout() 함수를 사용하여 3초의 delay를 발생시키는 함수를 정의
    2. helloAsync() : delay 함수의 리턴인 Promise 객체가 resolve 된 경우 문자열 "hello Async"을 return
// ms만큼 기다렸다가 끝내는 함수 delay(ms)
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms); // ms 만큼 기다렸다가 별도의 처리 없이 resolve를 호출
  });
}

async function helloAsync() {
  // 3초 뒤 "hello Async"를 리턴
  return delay(3000).then(() => {
    return "hello Async";
  });
}

⇒ 다소 소스가 복잡하고 길다..! await 키워드를 사용하여 Promise.then을 사용하는 것보다 세련되게 소스 개선할 수 있다.! 👇👇👇👇

async function helloAsync() {
  // 3초 뒤 "hello Async"를 리턴
  await delay(3000);  // 동기적으로 호출됨
  return "hello Async";
}

await키워드를 붙이고 비동기 함수를 호출하면 동기함수처럼 동작할 수 있다.
await 키워드 뒤에 있는 함수의 동작이 끝나기 전에는 그 아래의 코드를 실행하지 않는다. (동기적으로 수행)

⭐️ async 키워드가 붙은 함수 내에서만 사용할 수 있다.

 

함수 내에서 helloAsync()await을 사용해 호출해보기

async function main() {
  const res = await helloAsync();
  console.log(res);
}

main();

await으로 호출했기 때문에 helloAsync()는 동기적으로 호출되어 res 변수에는 "hello Async"이 담긴다. await으로 비동기함수가 호출되면 Promise가 처리될 때까지 기다린 후 결과값을 반환한다.

 

API & fetch

: API 호출! → 클라이언트가 서버에게 원하는 처리, 데이터를 요청하고 응답을 받을 수 있도록 하는 인터페이스 = API

Request ↔ Response

  • ‼️ 서로 다른 시스템간의 Communication에서 서버의 응답을 언제 받을 수 있을지 모른다! ‼️ 
    → 동기적으로 처리할 수 없다..비동기 호출을 하자!

API 호출하기

  • https://jsonplaceholder.typicode.com/ : 테스트용 더미데이터 JSON을 쓸 수 있는 곳
  • fetch() : API 함수를 호출할 수 있도록 해주는 내장함수 (return Type : Promise > 비동기 호출)

then()으로 fetch Response 값을 받기

let response = fetch("https://jsonplaceholder.typicode.com/posts").then(
  (res) => {
    console.log(res);
  }
);

👉 fetch의 결과값 Response를 then을 통해 핸들링한다.

 

Response 객체: 결과 값을 담는 편지 봉투와 같은 것 ~

출력된 Response 객체를 확인하면 다음과 같다.
→ Response 객체의 json() 메소드에 우리가 원하는 응답 값인 JSON 데이터가 담겨있다.
 

JSON 데이터 확인하기

: Response로 받은 JSON을 확인하려면 Response 객체의 json() 메소드를 호출하여 가져올 수 있다.

async function getData() {
  let rawResponse = await fetch("https://jsonplaceholder.typicode.com/posts");
  let jsonResponse = await rawResponse.json();
  console.log(jsonResponse);
}

getData();

출력결과

👉 100개의 JSON 객체가 담겨있는 결과값을 확인할 수 있다.


휴 드디어 1주차 미션인 섹션 2, 3을 모두 포스팅 했다. 이렇게 시간이 많이 걸려도 되나.

하나에 너무 많은 정성을 쏟다보면 금방 지칠 수도 있을 것 같다. 적정선을 잘 파악해서 적당한 시간이 소요될 수 있도록 해야겠다.

아무튼 자바스크립트를 쭉 복습할 수 있는 시간이었고, 이제 이 내용들을 바탕으로 리액트를 배우면 될 것 같다. 두근 두근 ❤️‍🔥

🌱 블로그도 열심히 꾸려나가기 🌱

 

 

출처 : Udemy '한입 크기로 잘라먹는 리액트' (이정환)