본문 바로가기
Frontend/JavaScript

[JavaScript] 제너레이터와 async/await

by 모너아링 2023. 2. 16.

제너레이터와 async/await

[Javascript] 비동기, Promise, async, await 확실하게 이해하기

제너레이터란?

  • 코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 특수 함수

제너레이터와 일반 함수의 차이

  1. 제너레이터 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다.
  2. 제너레이터 함수는 함수 호출자와 함수의 상태를 주고받을 수 있다.
  3. 제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.

제너레이터 함수의 정의

  • function* 키워드로 선언
  • 하나 이상의 yield 표현식을 포함
// 제너레이터 함수 선언문
function* genDecFunc() {
    yield 1;
}

//제너레이터 함수 표현식
const genExpFunc = function* () {
    yield 1;
};

//제너레이터 메서드
const obj = {
    * genObjMethod() {
        yield 1;
    }
}

// 제너레이터 클래스 메서드
class MyClass {
    * genClsMethod() {
        yield 1;
    }
}
  • * 의 위치는 function 키워드와 함수 이름 사이라면 상관 없음
function* genFunc() { yield 1; } => 권장

function * genFunc() { yield 1; }

function *genFunc() { yield 1; }

function*genFunc() { yield 1; }
  • 화살표 함수로 정의 불가
const genArrowFunc = * () => {
    yield 1;
};
  • new 연산자와 함께 생성자 함수로 호출 불가
function* genFunc() {
    yield 1;
}

new genFunc(); // TypeError

제너레이터 객체

  • 제너레이터 객체를 생성해 변환

제너레이터 객체는 이터러블이면서 이터레이터

// 제너레이터 함수
function* genFunc() {
    yield 1;
    yield 2;
    yield 3;
}

// 제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.
const generator = genFunc();

// 제너레이터 객체는 이터러블이면서 동시에 이터레이터
// 이터러블은 Symbol.iterator 메서드를 직접 구현하거나 프로토타입 체인을 통해 상속받은 객체
console.log(Symbol.iterator in generator); // true
// 이터레이터는 next 메서드를 갖는다.
console.log('next' in generator); // true

제너레이터 객체가 갖는 메서드

  • next , return , throw

next

  • 제너레이터 함수의 yield 표현식까지 코드 블록을 실행하고 yield 된 값을 value 프로퍼티 값으로, falsedone 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환

return

  • 인수로 전달받은 값을 value 프로퍼티 값으로, truedone 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환

throw

  • 인수로 전달받은 에러를 발생시키고 undefinedvalue 프로퍼티 값으로, truedone 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환
function* genFunc() {
    try {
        yield 1;
        yield 2;
        yield 3;
    } catch(e) {
        console.error(e);
    }
}

const generator = genFunc();

console.log(generator.next()); // {value: 1, done: false}
console.log(generator.return('End!')); // {value: "End!", done: true}

console.log(generator.throw('Error'!)); // {value: undefined, done: true}

제너레이터의 일시 중지와 재개

  • 제너레이터는 yield 키워드와 next 메서드를 통해 실행 중지했다가 필요한 시점에 재개 가능
  • 제너레이터는 함수 호출자에게 제어권 양도 ⇒ 필요한 시점에 함수 실행 재개할 수 있는 이유
  • 제너레이터는 yield 표현식까지만 실행

yield

  • 제너레이터 함수의 실행을 중지시키거나 yield 키워드 뒤에 오는 표현식의 평가 결과를 제너레이터 함수 호출자에게 반환
//제너레이터 함수
function* genFunc() {
    yield 1;
    yield 2;
    yield 3;
}

const generator = genFunc();

console.log(generator.next()); // {value: 1, done: false}
console.log(generator.next()); // {value: 2, done: false}
console.log(generator.next()); // {value: 3, done: false}
console.log(generator.next()); // {value: undefined, done: true}
  • 첫 번째 next 메서드 호출
    • 첫 번째 yield 표현식까지 실행되고 중지
    • next 메서드는 이터레이터 리절트 객체를 반환
    • value 프로퍼티에 yield 된 값 1이 할당
    • 아직 함수가 끝까지 실행되지 않았으므로 done의 값에는 false 가 할당
  • 두 번째 next 메서드 호출
    • 두 번째 yield 표현식까지 실행되고 중지
    • next 메서드는 이터레이터 리절트 객체를 반환
    • value 프로퍼티에 yield 된 값 2이 할당
    • 아직 함수가 끝까지 실행되지 않았으므로 done의 값에는 false 가 할당
  • 세 번째 next 메서드 호출
    • 세 번째 yield 표현식까지 실행되고 중지
    • next 메서드는 이터레이터 리절트 객체를 반환
    • value 프로퍼티에 yield 된 값 3이 할당
    • 아직 함수가 끝까지 실행되지 않았으므로 done의 값에는 false 가 할당
  • 네 번째 next 메서드 호출
    • 남은 yield 표현식이 없으므로 제너레이터 함수의 마지막까지 실행
    • next 메서드는 이터레이터 리절트 객체를 반환
    • value 프로퍼티에 undefined이 할당
    • 함수가 끝까지 실행되었으므로 done의 값에는 true 가 할당

제너레이터에 인수 전달

  • 제너레이터 객체의 next 메서드에 전달한 인수는 제너레이터 함수의 yield 표현식을 할당받는 변수에 할당 ⇒ 제너레이터 객체의 next 메서드에 인수 전달 가능
function* genFunc() {
    const x = yield 1;
    const y = yield (x + 10);
    return x + y;
}

const generator = genFunc(0);

let res = generator.next();
console.log(res); // {value: 1, done: false}

res = generator.next(10);
console.log(res); // {value: 20, done: false)

res = generator.next(20);
console.log(res); // {value: 30, done: true}
  • 첫 번째 next 메서드 호출
    • 처음 호출하는 next 메서드에는 인수를 전달하지 않는다. ⇒ 전달되면 무시
    • yield 1 표현식까지 실행되고 일시중지
    • value 프로퍼티에는 yield 된 값 1이 전달
    • 함수가 아직 끝나지 않았으므로 done 값은 false
  • 두 번째 next 메서드 호출
    • next 메서드에 인수로 전달한 10은 genFunc 함수의 x 변수에 할당 ⇒ const x = yield 1 은 두 번째 next 메서드를 호출했을 때 완료
    • yield (x + 10) 표현식까지 실행되고 일시중지
    • value 프로퍼티에는 yield 된 값 20이 전달
    • 아직 함수가 끝까지 실행되지 않았으므로 done의 값에는 false 가 할당
  • 세 번째 next 메서드 호출
    • next 메서드에 인수로 전달한 20은 genFunc 함수의 y 변수에 할당
      const y = yield (x + 10) 은 두 번째 next 메서드를 호출했을 때 완료
    • yield (x + 10) 표현식까지 실행되고 일시중지
    • 반환값 x + yvalue 프로퍼티에 할당
    • 제너레이터는 값을 반환할 필요가 없고 return 은 종료의 의미로만 사용
    • value 프로퍼티에는 yield 된 값 30이 전달
    • 함수가 끝까지 실행되었으므로 done의 값에는 true 가 할당

제너레이터의 활용

이터러블의 구현

  • 제너레이터 함수를 사용하여 보다 간단한 이터러블을 구현할 수 있다.
//무한 이터러블을 생성하는 함수
const infiniteFibonacci = (function () {
    let [pre, cur] = [0, 1];

    return {
        [Symbol.iterator]() { return this; },
        next() {
            [pre, cur] = [cur, pre + cur];
            // 무한 이터러블이므로 done 프로퍼티 생량
            return { value: cur };
        }
    }
}());
for (const num of infiniteF) {
    if (num > 1000) break;
    console.log(num); // 1 2 3 5 8 ... 2584 4181 6765
}
//제너레이터 함수를 이용한 이터러블
const infitFibo = (function* () {
    let [pre, cur] = [0, 1];
    while (true) {
        [pre, cur] = [cur, cur + pre];
        yield cur; // value 프로퍼티 값으로 cur 할당
    }
}());

for (const num of infitFibo){
    if(num>1000) break;
    console.log(num)
}

비동기 처리

  • 프로미스의 후속 처리 메서드 then, catch, finally 없이 비동기 처리 결과를 반환하도록 구현할 수 있다.
const { response } = require('express');
const fetch = require('node-fetch');

const async = generatorFunc => {
    const generator = generatorFunc(); // 2

    const onResolved = arg => {
        const result = generator.next(arg); // 5 

        return result.done 
            ?result.value : 
            result.value.then(res => onResolved(res)); // 7 

    }
    return onResolved; // 3
}

(async(function * fetchTodo(){ // 1 
    const url = 'https://jsonplaceholder.typicode.com/post/1';

    const res = yield fetch(url); // 6
    const todo = yield response.json(); // 8
    console.log(todo);
})()) // 4
  • 1
  • async 함수가 호출되면(1) 인수로 전달받은 제러네이터 함수 fetchTodo를 호출해서 제너레이터 객체를 생성(2)하고 onResolved 함수를 반환(3)한다. onResolved 함수는 상위 스코프의 generator 변수를 기억하는 클로저이다. async 함수가 반환한 onResolved 함수를 즉시 호출(4)해 (2)에서 생성한 제너레이터 객체의 next 메서드를 처음 호출(5)한다
  • 2
  • next 메서드가 처음 호출(5)되면 제너레이터 함수 fetchTodo의 첫번째 yield(6)이 실행된다. 이때 next 메서드가 반환한 이터레이터 리절트 객체의 done 프로퍼티 값이 false, 즉 아직 함수가 끝까지 실행되지 않았다면 이터레이터 리절트 객체의 value 프로퍼티 값, 즉 첫번째 yieldfetch 함수가 반환한 프로미스가 resolveResponse객체를 onResolved함수에 전달하면서 재귀호출(7)한다
  • 3
  • onResolved함수에 인수로 전달된 Response 객체를 next 메서드에 인수로 전달하면서 next 메서드를 두번째 호출(5)한다. 이때 next 메서드에 인수로 전달한 Response 객체는 제너레이터 함수 fetchTodoresponse 변수(6)에 할당되고 제너레이터 함수 fetchTodo의 두번째 yield(8)까지 실행된다.
  • 4
  • next 메서드가 반환한 이터레이터 리절트 객체의 done 프로퍼티 값이 false. 즉 아직 제너레이터 함수 fetchTodo가 끝까지 실행되지 않았다면 이터레이터 리절트 객체의 value 프로퍼티 값, 즉 두번째 yieldresponse.json 메서드가 반환한 프로미스가 resolvetodo 객체를 onResolved 함수에 인수로 전달하면서 재귀호출(7)한다.
  • 5
  • onResolved 함수에 인수로 전달된 todo 객체를 next 메서드에 인수로 전달하면서 next 메서드를 세번째로 호출(5)한다. 이때 next 메서드에 인수로 전달한 todo 객체는 제너레이터 함수 fetchTodotodo변수(8)에 할당되고 제너레이터 함수 fetchTodo가 끝까지 실행된다.
  • 6
  • next메서드가 반환된 이터레이터 리절트 객체의 done 프로퍼티 값이 true, 즉 제너레이터 함수 fetchTodo가 끝까지 실행되었다면 이터레이터 리절트 객체의 value 프로퍼티 값, 즉 제너레이터 함수 fetchTodo 의 반환값인 undefined를 그대로 반환(9)하고 처리를 종료한다.

46.6 async / await

  • 제너레이터보다 간단하고 가독성 좋게 비동기 처리를 동기처럼 동작하도록 구현하기 위하여 만들어짐
  • 프로미스를 기반으로 동작
  • 프로미스의 then/catch/finally 후속 처리 메서드에 콜백 함수를 전달해서 비동기 처리 결과를 후속 처리할 필요 없이 마치 동기 처리처럼 프로미스를 사용 가능

async

  • async 키워드를 function 앞에 사용해 정의하며 언제나 프로미스 반환
  • 프로미스를 반환하는 비동기적인 함수를 만들어주는 키워드
  • 프로미스가 아닌 값을 반환하더라도 resolved promise 로 값을 감싸 프로미스 형태로 반환
async function f() {
  return 1;
}

f().then(alert); // 1
  • 함수 호출 시 result1 인 이행 프로미스가 반환
async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1
  • 명시적으로 프로미스 반환
// 함수 선언문
async function foo(n) {return n;}
foo(1).then(v => console.log(v)) //1

// async 함수 표현식
const bar = async function(n) {return n};
bar(2).then(v => console.log(v)); //2

// async 화살표 함수
const baz = async n => n;
baz(3).then(v=>console.log(v)) //3

// async 메서드
const obj = {
    async foo(n) {return n;}
};
obj.foo(4).then(v => console.log(v));

// async 클래스 메서드
class MyClass {
    async bar(n) {return n;}
}
const myClass = new MyClass();
myClass.bar(5).then(v => console.log(v)) //5
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

const getDog = async () => {
    await sleep(1000);
    return '댕댕';
}

async function process() {
    const dog = await getDog();
    console.log(typeof getDog());
    console.log(typeof dog);
    console.log(dog);
}

process();

/*
object
string
댕댕
*/
  • async 함수는 반환하는 값은 Promise 객체 ⇒ object
  • await 함수가 반환하는 값은 원래의 값의 형태 ⇒ string

await

  • 프로미스가 settled 상태(비동기 처리가 수행된 상태)가 될 때까지 대기하다가 settled상태가 되면 프로미스가 resolve한 처리 결과를 반환
  • 반드시 async 함수 내부에서 사용해야 함
  • promise.then 보다 더 깔끔하게 프로미스의 result 값을 얻을 수 있도록 하는 문법
async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });

  let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)

  console.log(result); // "완료!"
}

f();
  • 함수를 호출하고, 본문이 실행되는 도중에 (*) 표시 줄에서 실행이 중단되었다가 프로미스가 처리되면 실행이 재개
  • 이때 프로미스 객체의 result 값이 변수 result 에 할당
const getGitUserName = async id => {
    const res = await fetch(`https://api.github.com/users/${id}`); ✔️ (1)
    const name = await res.json();  ✔️ (2)
    console.log(name);
};

getGitUserName('ungmo2');
  • (1)의 fetch 함수가 수행한 HTTP 요청에 대한 서버의 응답이 도착해서 fetch 함수가 반환한 프로미스가 settled 상태가 될 때까지 (1)은 대기
  • 이후 ****프로미스가 settled 상태가 되면 프로미스가 resolve한 처리 결과가 res변수에 할당된다.
async function foo() {
    const a = await new Promise(resolve => setTimeout(()=>resolve(1), 3000));
    const b = await new Promise(resolve => setTimeout(()=>resolve(2), 2000));
    const c = await new Promise(resolve => setTimeout(()=>resolve(3), 1000));

    console.log([a,b,c]); //[ 1, 2, 3 ]
}

foo(); // 약 6초 소요된다
  • 프로미스 각각 3, 2, 1초 후 settled 상태가 되므로 총 소요시간은 3 + 2 + 1 = 6초이다.
async function foo() {
    const res = await Promise.all([
        new Promise(resolve => setTimeout(()=>resolve(1), 3000)),
        new Promise(resolve => setTimeout(()=>resolve(2), 2000)),
        new Promise(resolve => setTimeout(()=>resolve(3), 1000))
    ])

    console.log(res); //[ 1, 2, 3 ]
}

foo(); // 약 3초 소요된다
  • 비동기적 처리를 하여 개별적으로 처리하므로 1, 2, 3초 중 가장 오래 걸리는 3초가 총 소요시간이 된다.
async function bar() {
    const a = await new Promise(resolve => setTimeout(()=>resolve(n), 3000));
    const b = await new Promise(resolve => setTimeout(()=>resolve(a+1), 2000));
    const c = await new Promise(resolve => setTimeout(()=>resolve(b+1), 1000))
    console.log([a,b,c]); //[ 1, 2, 3 ]
}

bar(1); // 약 6초 소요된다
  • 다음의 bar 함수는 앞선 비동기 처리의 결과를 가지고 다음 비동기 처리를 수행해야 한다.
  • 따라서 비동기 처리의 처리 순서가 보장되어야 하므로 모든 프로미스에 await 키워드를 써서 순차적으로 처리할 수 밖에 없다.

46.6.3 에러 처리

  • 에러는 호출자 방향으로 전파 ⇒ 콜 스택의 아래 방향(실행 중인 실행 컨텍스트가 푸시되기 직전에 푸시된 실행 컨텍스트 방향)으로 전파
  • 하지만 비동기 함수의 콜백 함수를 호출한 것은 비동기 함수가 아니기 때문에 try...catch 문 을 사용해 에러 캐치 불가
try{
    setTimeout(()=>{ throw new Error('Error!');}, 1000);
} catch(e){
    console.error(e);
}
  • async/await 에서의 에러 처리는 try...cath 문 사용 가능⇒ 콜백 함수를 인수로 전달받는 비동기 함수와는 달리 프로미스를 반환하는 비동기 함수는 명시적으로 호출할 수 있기 때문
  • why?
const fetch = require('node-fetch');
const foo = async () => {
    try{
        const wrongUrl = 'https://잘못된url';

        const response = await fetch(wrongUrl);
        const data = await response.json();
        console.log(data);
    } catch(err){
        console.error(err); // TypeError
    }
};

foo();
  • async 함수 내에서 catch문을 사용해서 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject하는 프로미스를 반환한다.

Promise.prototype.catch 후속 처리 메서드 사용

const fetch = require('node-fetch');

const foo = async() => {
    const wrongUrl = 'https://잘못된url';

    const response = await fetch(wrongUrl);
    const data = await response.json();
    return data;
};

foo()
    .then(console.log)
    .catch(console.error) // TyepError : Failed to fetch

비동기 함수와 Promise와 async/await

//비동기 함수
timer(1000, function() {
    console.log('작업1');
    timer(1000, function() {
        console.log('작업2');
        timer(1000, function() {
            console.log('작업3');
        });
    });
});
//작업1 -> 작업2 -> 작업3 (1초 간격으로)
  • 비동기 함수 사용 시 콜백 함수를 이용하여 비동기적으로 처리
//Promise 활용
timer(1000)
    .then(function() {
        console.log('작업');
        return timer(1000);
    })
    .then(function() {
        console.log('작업');
        return timer(1000);
    }
    .then(function() {
        console.log('작업');
    }
  • 프로미스를 리턴하면 리턴 값인 프로미스의 then 을 이용하여 연속적인 비동기적으로 작업 가능
//async,await
async function run() {
    await timer(1000)

    console.log('작업');
    await timer(1000)

    console.log('작업');
    await timer(1000)

    console.log('작업');
}
run();
  • await 키워드를 이용하여 동기적인 것처럼 보이는 비동기적 작업을 수행

async/await 이용하기

console.log('start');
timer(1000).then(function(time) {
    console.log('time:'+time);
    return timer(time+1000);
}).then(function(time) {
    console.log('time:'+time);
    return timer(time+1000);
}).then(function(time) {
    console.log('time:'+time);
    console.log('end');
});

async function run() {
        console.log('start');
        var time = await timer(1000); // 위 코드의 첫 번째 then 내부의 첫 번째 파라미터를 반환
        console.log('time:'+time); // settled 상태
        time = await timer(time+1000);
        console.log('time:'+time); // settled 상태
        time = await timer(time+1000);
        console.log('time:'+time); // settled 상태
        console.log('end');
}
run();

/*
start
time:1000
time:2000
time:3000
end
*/

Promise.all

  • 여러 비동기 동작을 한꺼번에 기다림
// 0 ~ 9번 직원의 나이 평균을 구하는 함수
function setTimeoutPromise(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), ms);
  });
}

async function fetchAge(id) {
  await setTimeoutPromise(1000);
  console.log(`${id} 사원 데이터 받아오기 완료!`);
  return parseInt(Math.random() * 20, 10) + 25;
}

async function startAsyncJobs() {
  let ages = [];
  for (let i = 0; i < 10; i++) {
    let age = await fetchAge(i);
    ages.push(age);
  }

  console.log(
    `평균 나이는? ==> ${
      ages.reduce((prev, current) => prev + current, 0) / ages.length
    }`
  );
}

startAsyncJobs();
//출력값
0 사원 데이터 받아오기 완료!
1 사원 데이터 받아오기 완료!
2 사원 데이터 받아오기 완료!
3 사원 데이터 받아오기 완료!
4 사원 데이터 받아오기 완료!
5 사원 데이터 받아오기 완료!
6 사원 데이터 받아오기 완료!
7 사원 데이터 받아오기 완료!
8 사원 데이터 받아오기 완료!
9 사원 데이터 받아오기 완료!
평균 나이는? ==> 33
  • 총 10초 걸림
  • 비동기라면 1초 걸려야 하는데 10초 걸린 이유는?

let age = await fetchAge(i);

async function startAsyncJobs() {
  let promises = [];
  for (let i = 0; i < 10; i++) {
    promises.push(fetchAge(i));
  }

  let ages = await Promise.all(promises);

  console.log(
    `평균 나이는? ==> ${
      ages.reduce((prev, current) => prev + current, 0) / ages.length
    }`
  );
}

startAsyncJobs();
  • for 문 안에서 await 를 하는 대신 Promise 를 배열에 쌓는다.
  • Promise.all 함수는 인자로 Promise 배열을 받으며 하나의 특별한 Promise를 새로 생성
  • 모든 비동기 작업이 성공했다면 resolve 호출, 하나라도 실패했다면 reject 호출
//출력값
0 사원 데이터 받아오기 완료!
1 사원 데이터 받아오기 완료!
2 사원 데이터 받아오기 완료!
3 사원 데이터 받아오기 완료!
4 사원 데이터 받아오기 완료!
5 사원 데이터 받아오기 완료!
6 사원 데이터 받아오기 완료!
7 사원 데이터 받아오기 완료!
8 사원 데이터 받아오기 완료!
9 사원 데이터 받아오기 완료!
평균 나이는? ==> 33
  • 총 1초 걸림

async/await 병렬 처리

async function getApple() {
    await delay(1000);
    return '사과';
}

async function getBanana() {
    await delay(1000);
    return '바나나';
}

async function pickFruites() {
    const apple = await getApple();
    const banana = await getBanana();
    return `${apple} + ${banana}`;
}

pickFruites().then(console.log); // 사과 + 바나나
  • 총 2초 소요
  • 하지만 getApple() 메소드와 getBanana() 메소드는 독립적이기 때문에 순차적으로 진행할 필요가 없다.
async function pickFruites() {
    const applePromise = getApple();
    const bananaPromise = getBanana();
    const apple = await applePromise;
    const banana = await bananaPromise;
    return `${apple} + ${banana}`;
}

pickFruites().then(console.log); // 사과 + 바나나
  • 총 1초 소요
  • 메소드 별 반환 값을 할당하는 변수를 생성하여 그 변수를 통해 await 를 사용하면 두 메소드를 비동기적으로 처리가 가능하여 1초 소요된다.

프로미스를 async/await 로 바꾸기

// 프로미스 사용
function loadJson(url) {
  return fetch(url)
    .then(response => {
      if (response.status == 200) {
        return response.json();
      } else {
        throw new Error(response.status);
      }
    })
}

loadJson('no-such-user.json')
  .catch(alert); // Error: 404
// async&await 사용
async function loadJson(url) { // (1)
  let response = await fetch(url); // (2)

  if (response.status == 200) {
    let json = await response.json(); // (3)
    return json;
  }

  throw new Error(response.status);
}

loadJson('no-such-user.json')
  .catch(alert); // Error: 404 (4)

1) 함수 loadJsonasync 를 붙여준다.

2) Promise 를 리턴하는 모든 비동기 함수 호출부 앞에 await 키워드가 추가

3) await를 사용해도 되지만, return response.json()를 사용해도 된다.

4) loadJson 에서 던져진 에러는 .catch 에서 처리된다.

'Frontend > JavaScript' 카테고리의 다른 글

[JavaScript] 클릭한 태그 찾기  (0) 2023.02.23
[JavaScript] javascript 주요 개념 간단 정리  (0) 2023.02.22
[JavaScript] DOM 이벤트  (0) 2023.02.16
[JavaScript] Set  (2) 2023.02.16
[JavaScript] 이터러블  (1) 2023.02.16