본문 바로가기
JavaScript,TypeScript/JavaScript

JavaScript가 비동기를 다루는 방법

by 그냥하는거지뭐~ 2024. 4. 5.
목차
- 프롤로그
- 자바스크립트의 실행 모델
- Callback: 비동기 처리의 기본
- Promise: 비동기 처리의 진화
- async/await: 비동기 처리의 현대적 접근
- 병렬 비동기 작업 처리 
- 이벤트 루프와 비동기 프로그래밍의 깊은 이해
- Web Workers와 멀티 스레딩
- 결론

프롤로그

요즘 시간이 남을 때마다 기술면접에 대한 답변을 써보고 있는데 내가 얼마나 이론이 부족한 채로 코드만 치고 있었는지를 체감하고 있다.. 프로젝트를 하며 async/await를 수백, 수천번 썼지만 "Promise가 뭐예요?", "promise와 async/await의 차이가 뭐죠?"와 같은 질문에 대답이 술술 나오지 않는 나 자신을 보고 약간의 충격에 빠졌다. 잘 알지도 못하면서 별생각 없이 기계처럼 쓰고 있었구나.. 이번 기회에 비동기와 짱친을 먹어보도록 하겠다. 
 
공부하기 전 내가 알고 있는 비동기는 이게 다다. 

자바스크립트는 싱글 스레드 기반 언어이다. 이 말은 코드가 실행되는 도중에 시간이 오래 걸리는 작업을 마주치면 나머지 부분은 그 작업이 끝날 때까지 기다려야 한다는 것을 뜻한다. 이는 사용자 경험을 심각하게 훼손한다. 비동기 처리를 하면 api 요청과 같은 시간이 걸리는 작업은 백그라운드에서 실행하고, 메인 스레드는 UI 업데이트, 사용자 입력과 같은 다른 작업을 계속 처리할 수 있다. 

 
이 글을 마무리하기 전에 다시 답변을 적어보고 얼마나 깊이가 다른지 비교해 보도록 하겠다. 


자바스크립트의 실행 모델

#싱글스레드 #이벤트루프 #비동기콜백
#호출 스택(Call Stack) #이벤트 큐(Event Queue) #백그라운드태스크
 
자바스크립트는 이벤트 루프를 메인스레드로 사용하는 싱글스레드 언어로, 한 시점에 하나의 작업만 수행할 수 있다. 그렇다면 비동기 처리는 어떻게 할까? api 호출, 타이머와 같은 오래 걸리는 작업들은 JS 엔진에서 이루어지는 것이 아니라 웹 브라우저나 node.js 같은 멀티스레드 환경에서 이루어진다. 

 

자바스크립트 엔진이 코드를 실행하고 데이터를 관리하는 방식을 이해하려면 먼저 Stack, Heap, Queue의 개념을 알아야 한다. 

Stack: 함수의 호출은 Frame 스택을 형성하며, 그림에서도 유추할 수 있듯 후입선출(LIFO, Last In First Out) 방식으로 동작하는 데이터 구조
Heap: 힙은 단순히 메모리의 큰 (그리고 대부분 구조화되지 않은) 영역을 지칭하며, 객체가 여기에 해당된다. 
Queue: JS 런타임은 메시지 큐, 즉 처리할 메시지의 대기열을 사용하며, 각각의 메시지에는 메시지를 처리하기 위한 함수가 연결되어 있다. 이벤트 루프는 선입선출(FIFO, First In First Out) 방식으로 오래된 메시지부터 처리한다. 

 

브라우저의 내부 구성도

Call Stack: JS 엔진이 현재 실행 중인 코드를 추적하는 메모리 구조로, 싱글스레드인 JS는 하나의 call stack을 가진다. 실행 파일이 런타임에 호출되면 스택에 추가되고, 값을 반환하거나 실행을 마치면 호출 스택에서 제거된다.
Event Queue(Callback Queue): HTTP responses, 타이머와 같은 비동기 이벤트가 발생하면 이벤트 큐에 배치된다. 
Web APIs: 브라우저에서 제공하는 API 모음으로 멀티스레드로 구현되어 있다. 각 API마다 스레드가 할당되어 있어 동시에 여러 작업들을 수행할 수 있다. (AJAX 호출, 타이머 함수 - 동기 / DOM 조작, Console API - 비동기) Web APIs는 작업이 끝나면 Event queue로 푸시한다. 

 
이벤트룹은 call stack과 event queue를 지속적으로 모니터링하는 관리자이다. Call stack이 비어있으면, 즉 실행 중인 코드가 없으면 이벤트룹은 event queue에서 가장 오래된 이벤트를 가져와 해당 콜백 함수를 call stack에 push 한다. 이벤트룹은 비동기 작업이 메인스레드를 방해하지 않도록 하여 다른 작업이 동시에 일어날 수 있도록 하는 것이다. 이러한 것을 Non-blocking이라고 한다. 또한 이벤트룹은 큐에 이벤트가 있거나 pending 상태의 callback이 있는 한 계속 돌아가며, 모든 이벤트를 다 처리하면 동작을 멈춘다. (Run to completion)
 
우선은 이 정도만 알고 넘어가자. 


Callback: 비동기 처리의 기본

#Callback함수 #Callback지옥
 
Callback 함수는 파라미터로 함수 객체를 전달해서 호출 함수 내에서 전달받은 함수를 실행하는 것을 말한다. 익명 함수, 화살표 함수, 또는 함수의 이름을 넘겨주는 방식으로 사용한다. 코드를 통해 명시적으로 호출하는 것이 아니라, 함수를 등록해 놓은 후 어떤 이벤트가 발생했거나 특정 시점에 도달했을 때 호출된다. 
 
Callback 함수로 어떻게 비동기 작업을 수행하는지 다음 링크에 잘 정리되어 있으니 읽어보자. 

 

자바스크립트 비동기 처리와 콜백 함수

(중급) 중급 자바스크립트 개발자가 되기 위한 자바스크립트 비동기 처리와 콜백 함수 이해하기. 콜백 지옥과 해결 방법 등

joshua1988.github.io

 
정리하면, callback 함수는 비동기를 구현하기 위한 하나의 방법으로, 중첩하여 사용하는 경우 callback 지옥에 빠질 수가 있다. Callback 지옥을 해결하는 방법이 바로 다음에 소개할 promise와 async/await이다. 


Promise: 비동기 처리의 진화

#Promise #then #catch #finally #Promise체이닝
 
Promise는 비동기 처리를 객체로 접근한다. 세 가지 상태(pending: 대기, fulfilled: 성공, rejected: 실패)를 가지며, 이 상태는 비동기 작업의 진행 과정을 명확하게 나타낸다. 또한 promise 객체는 비동기 작업의 결과물을 내부적으로 캡슐화하여 작업이 끝난 후에 결과값을 조작할 수 있게 한다. 

출처: MDN

 
 
사용 방법에 대해선 다음 링크를 참고하자. 

 

프라미스

ko.javascript.info

 
 
then() 메서드가 호출되면 새로운 프로미스 객체가 반환되는데, 이걸 이용해서 여러 개의 프로미스를 연결해서 사용할 수 있다. 이걸 promise chaining이라고 한다. 
 

function fetchData(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 예시를 위한 비동기 작업 모방
      console.log("데이터를 불러오는 중...");
      resolve("데이터 불러오기 성공!");
    }, 1000);
  });
}

fetchData("https://api.example.com")
  .then((data) => {
    console.log(data); // "데이터 불러오기 성공!"
    return fetchData("https://api.example.com/more");
  })
  .then((moreData) => {
    console.log(moreData); // 두 번째 데이터 불러오기 성공!
  })
  .catch((error) => {
    console.error("에러 발생:", error);
  })
  .finally(() => {
    console.log("작업 완료");
  });

 
 
다음과 같은 콜백 지옥을 

getData(function(a) {
    getMoreData(a, function(b) {
        getMoreData(b, function(c) {
            getMoreData(c, function(d) {
                console.log(d);
            });
        });
    });
});

 
다음과 같은 promise chaining으로 훨씬 가독성이 좋은 형태로 바꿀 수 있다. 

getData()
    .then(a => getMoreData(a))
    .then(b => getMoreData(b))
    .then(c => getMoreData(c))
    .then(d => console.log(d))
    .catch(error => console.error(error)); // 오류 처리

async/await: 비동기 처리의 현대적 접근

#async/await #try/catch 
 
async/await를 사용하면 promise보다 훨씬 더 간결하게 비동기 처리를 할 수 있다. 사용 방법은 아래 링크를 참고하자. 

 

async와 await

ko.javascript.info

 
Promise에서 promise chaining으로 콜백 지옥을 해결한 예시를 async/await를 사용한 코드로 바꿔보았다. Promise보다 훨씬 가독성 좋고 직관적인 것을 확인할 수 있다. 

async function asyncCall() {
    try {
        const a = await getData();
        const b = await getMoreData(a);
        const c = await getMoreData(b);
        const d = await getMoreData(c);
        console.log(d);
    } catch (error) {
        console.error(error); // 오류 처리
    }
}

asyncCall();

 
정리하면, async/await는 promise를 쉽게 다룰 수 있게 해 준다. async 키워드를 통해 함수가 암시적으로 promise를 반환하도록 하며, 
await는 promise가 resolved 또는 rejected 될 때까지 일시중지하도록 한다. 
 


병렬 비동기 작업 처리

예를 들어 서버에서 여러 이미지 리소스를 불러와야 한다고 가정해 보자. 한 이미지를 불러오기까지 2초가 걸린다고 할 때, 10개를 불러오려면 20초가 소요된다. JS는 멀티스레드 환경도 쓸 수 있다던데 한 번에 처리할 수는 없을까? 그럼 10개, 100개를 불러온다고 해도 그중 가장 오래 걸리는 단일 요청의 시간이 곧 전체 시간이 될 것이다. 이때 사용하는 것이 병렬 비동기 작업 처리이다. 
 

1. Promise.all()

Promise.all()은 요청 중 하나라도 reject 되거나 에러가 발생하면 모든 promise들이 reject 된다. 즉 이행된 promise를 포함한 다른 promise의 결과도 무시된다. fetch를 사용해 호출을 여러 개 수행하면, 하나가 실패하더라도 호출은 계속 일어난다. 처리는 되지만 Promise.all은 결과들은 무시한다. 

Promise.all([
  new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
  new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
  new Promise(resolve => setTimeout(() => resolve(3), 1000))  // 3
]).then(alert); // promise 전체가 처리되면 1, 2, 3이 반환된다. 각 promise는 배열을 구성하는 요소가 된다

 
async/await와 promise.all을 사용해서 병렬로 처리해 보자. 

async function fetchMultipleUrls(urls) {
  try {
    // Promise.all()을 사용하여 여러 Promise를 병렬로 실행
    const results = await Promise.all(urls.map(url => fetch(url).then(res => res.json())));
    
    // 모든 Promise가 성공적으로 이행되면, 결과를 처리
    console.log(results);
  } catch (error) {
    // 에러 처리
    console.error("An error occurred:", error);
  }
}

const urls = [
  'https://api.example.com/data1',
  'https://api.example.com/data2',
  'https://api.example.com/data3'
];

fetchMultipleUrls(urls);

 

2. Promise.race()

Promise.race는 가장 먼저 처리되는 promise의 결과를 반환한다. 

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error("에러 발생!")), 2000)),
  new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000))
]).then(alert); // 1

두 번째 promise에서 에러가 발생했어도 첫 번째 promise가 제일 먼저 끝났기 때문에 1이 반환되는 것이다. 이걸 어떻게 활용할까? 
 
비동기 작업에 대한 타임아웃을 설정할 때 사용할 수 있다. 비동기 작업이 지정된 시간 내에 완료되지 않으면 타임아웃 오류를 반환하는 것이다. 

const fetchDataWithTimeout = (url, timeout = 1000) => {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Request timed out')), timeout)
  );

  return Promise.race([fetchPromise, timeoutPromise]);
};

fetchDataWithTimeout('https://api.example.com/data')
  .then(response => console.log(response))
  .catch(error => console.error(error));

 
여러 서버 중에서 가장 빠른 응답을 제공하는 서버의 데이터만 필요한 경우에도 유용하다. 혹은 하나라도 성공하면 그 결과를 사용하려고 할 때 Promise.race()를 사용해서 에러 핸들링을 할 수도 있다. 

Promise.race([
  fetch('https://api.example1.com/data'),
  fetch('https://api.example2.com/data'),
  fetch('https://api.example3.com/data')
])
.then(response => console.log(response))
.catch(error => console.error(error));

 


이벤트 루프와 비동기 프로그래밍의 깊은 이해

비동기 작업을 처리하는 세 가지 방법을 알았으니, 이벤트 룹이 어떻게 동작하는지 더 상세하게 알아보도록 하자. 앞서 알아본 event queue는 사실 Microtask queue와 Task queue로 나뉜다.

Microtask Queue: Promise와 같은 비동기 작업의 callback들이 대기하는 곳. call stack이 비워진 후, 즉 현재 실행 중인 스택이 완전히 비워진 직후에 바로 실행된다. 
Task Queue(Macrotask Queue): setTimeout, setInterval 등의 비동기 API 콜백, I/O 작업의 콜백 등이 대기하는 곳. 각 이벤트 룹의 사이클마다 하나씩 실행된다. 

 

 

 
Example 1)

다음 코드가 있을 때, 이벤트 룹이 어떻게 동작하는지 알아보자. 

console.log('스크립트 시작');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

console.log('스크립트 끝');

 
1. console.log('스크립트 시작') call stack에 푸시, 실행되어 콘솔에 출력. 실행 완료되면 call stack에서 제거
2. setTimeout call stack에 푸시, Web API에서 처리, 0ms 후 task queue에 추가
3. Promise.resolve() call stack에 푸시, 실행. .then()에 제공된 콜백 함수는 microtask queue에 추가
4. 직후 두 번째 .then() 호출도 microtask queue에 추가. 
5. console.log('스크립트 끝') call stack에 푸시, 실행되어 콘솔에 출력. 실행 완료되면 call stack에서 제거
6. call stack이 비워지면 이벤트 룹은 microtask queue 확인, promise1의 콜백 함수가 call stack으로 이동하여 실행, call stack에서 제거
7. promise2의 콜백 함수도 같은 방식으로 실행
8. microtask queue가 비워지면 이벤트 룹은 task queue 확인, 2에서 추가된 callback 함수가 call stack으로 이동, 실행 
 
즉, 콘솔에는 다음과 같이 찍힌다. 

스크립트 시작
스크립트 끝
promise1
promise2
setTimeout

여기서 알 수 있는 사실은 microtask queue가 task queue보다 우선순위가 높다는 것이다. 그리고 microtask queue는 FIFO 방식으로 실행되며, queue가 비워질 때까지 모든 작업을 한 번에 수행한다. Task queue는 한 번에 하나의 작업만 수행한다. 
 

Example 2)

async/await가 포함된 예시를 보자. 

console.log('스크립트 시작');

async function asyncFunc() {
  console.log('async 함수 시작');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('async 함수 끝');
}

asyncFunc();

setTimeout(() => {
  console.log('setTimeout');
}, 0);

console.log('스크립트 끝');

 
1. console.log('스크립트 시작') call stack에 푸시, 실행되어 콘솔에 출력. 실행 완료되면 call stack에서 제거 
2. asyncFunc() call stack에 푸시, console.log('async 함수 시작') call stack에 푸시, 실행되어 콘솔에 출력, 제거
3. await을 만남. Web API에서 setTimeout 실행 (1000ms)
4. setTimeout call stack에 푸시. 0초 후 task queue에 추가
5. console.log('스크립트 끝') call stack에 푸시, 실행되어 콘솔에 출력, 제거
6. task queue에 있는 setTimeout의 콜백 함수가 call stack으로 이동되어 console.log('setTimeout') 실행, 제거
7. 3단계에서 실행되었던 Promise가 1초 후 resolve 되어 이를 task queue에 푸시, asyncFunc() 함수는 microtask queue에 푸시
8. microtask queue의 console.log('async 함수 끝')이 call stack으로 이동, 실행, 제거
 
즉, 콘솔은 다음과 같이 찍힌다. 

스크립트 시작
async 함수 시작
스크립트 끝
setTimeout
async 함수 끝

 
async/await 함수에서는 await을 만나기 전에는 동기적으로 실행되다가 await을 만나면 작업을 중지하고 microtask queue로 이동한다. 


 
정리하면, 
- Event loop 우선순위 : Microtask Queue > Task Queue
- Microtask queue는 한 번에 다 실행, Task Queue는 한 사이클에 하나씩 실행
- async/await 함수에서는 await를 만나면 microtask queue로 이동


Web Workers와 멀티 스레딩

setTimeout이 동작하는 순서를 다시 한 번 짚어보자. 

setTimeout(() => {
  console.log('Hello');
}, 2000);

setTimeout이 call stack에서 Web API로 이동한다. Web API는 2000ms동안 setTimeout의 콜백 함수를 대기시킨다. 지연 시간이 완료되면, setTimeout의 콜백 함수는 task queue로 이동한다. call stack이 비어있고, task queue에 작업이 있을 경우, 이벤트 룹은 task queue의 첫 번째 작업을 call stack으로 이동시킨다. 
 
자, 그러면 타이머와 같이 정확한 시간이 동작해야 하는 프로그램이 있다고 해보자. 1초 간격으로 정확하게 시간이 찍혀야 하는데, call stack에 이벤트가 정말 많이 쌓여있을 경우, call stack이 비워지기 전까지 task queue에 대기 중인 setTimeout의 콜백 함수는 call stack으로 이동하지 못한다. 그래서 정확하지 않은 타이머가 만들어질 수도 있다. 이때 사용하면 좋은 것이 Web Workers이다. 
 
 

1. Web Workers란? 

Web Workers are a simple means for web content to run scripts in background threads. 즉, 웹 애플리케이션의 메인 실행 스레드와 별개로 백그라운드에서 스크립트를 실행할 수 있게 해 주어, 성능 저하 없이 복잡한 연산을 가능하게 하는 기술이다. 
 

2. Web Workers와 메인 스레드 간 통신 방법

Web Workers와 메인 스레드 간의 통신 방법을 알아보자. 
 
1. 메시지 전송

  • 메인 스레드 → 워커: 메인 스레드는 워커에게 postMessage() 메서드를 사용하여 메시지를 전송한다. (객체, 문자열, 또는 어떤 데이터든 가능)
  • 워커 → 메인 스레드: 워커도 postMessage() 메서드를 사용하여 메인 스레드로 메시지를 보낸다. 워커 내부에서 이 메소드를 호출하면 메인 스레드의 워커 인스턴스에 연결된 이벤트 리스너가 메시지를 받게 된다.

2. 메시지 수신

  • 메인 스레드에서 워커의 메시지 수신: 메인 스레드는 워커 인스턴스에 onmessage 이벤트 핸들러를 등록하여 워커로부터 오는 메시지를 수신한다.
  • 워커에서 메인 스레드의 메시지 수신: 워커 내부에서는 onmessage 이벤트 핸들러를 사용하여 메인 스레드로부터 오는 메시지를 수신한다.

 

3. 정밀한 시계 구현 

Web workers로 1초마다 갱신되는 보다 정확한 시계를 만드는 방법은 웹 워커에게 오래 걸리는 작업을 시키고, 메인 스크립트에서는 setInterval을 사용하여 1초마다 현재 시간을 화면에 업데이트하는 로직을 작성하는 식으로 구현할 수 있다. 

// index.html

<body>
    <h1>현재 시간: <span id="clock">로딩 중...</span></h1>
    <script>
        // 워커 생성
        var worker = new Worker('heavyTaskWorker.js');

        // 시계 업데이트 함수
        function updateClock() {
            const now = new Date();
            document.getElementById('clock').textContent = now.toTimeString().split(' ')[0];
        }

        // 1초마다 시계 업데이트
        setInterval(updateClock, 1000);

        // 오래 걸리는 작업 시작
        worker.postMessage({ command: 'startHeavyTask' });

        // 워커로부터 메시지 수신
        worker.onmessage = function(e) {
            console.log('백그라운드 작업 완료:', e.data);
        };
    </script>
</body>
// heavyTaskWorker.js

// 메인 스크립트로부터 메시지 수신
onmessage = function(e) {
    if (e.data.command === 'startHeavyTask') {
        // 오래 걸리는 작업 시뮬레이션
        let result = performHeavyTask();
        // 작업 완료 후 메인 스크립트로 결과 전송
        postMessage(result);
    }
};

function performHeavyTask() {
    // 오래 걸리는 작업 구현
    // 임시로 5초 대기하는 작업으로 구현함 
    let start = Date.now();
    while (Date.now() < start + 5000); // 5초 대기

    return '오래 걸리는 작업 완료';
}

 
메인 페이지에서는 웹 워커를 생성하고, setInterval을 사용하여 1초마다 현재 시간을 화면에 업데이트한다. 동시에 웹 워커에게 오래 걸리는 작업을 시작하라는 메시지를 전송한다. 웹 워커는 오래 걸리는 작업을 백그라운드에서 처리하고 작업이 완료되면 그 결과를 메인 스크립트로 전송한다. 즉 메인 스레드는 웹 워커가 백그라운드 작업을 수행하는 동안에도 멈추지 않고 시계를 업데이트할 수 있으므로, UI 업데이트가 훨씬 부드럽고 성능적으로도 훨씬 좋은 코드인 것이다. 
 


결론

JavaScript에서 비동기 작업을 어떻게 처리하는지 어느 정도 감은 오는 것 같다. 프롤로그에서 얄팍한 지식으로 비동기에 대해 설명했었는데, 공부하고 난 지금 이 시점에서 내가 아는 비동기에 대해 정리해 보는 것으로 마무리하겠다. 

자바스크립트는 싱글 스레드 언어여서 기본적으로는 동기로 작동하지만, 이벤트 루프 덕분에 메인 스레드 이외에도 웹 브라우저나 Node.js 같은 멀티스레드 환경에서 비동기 처리를 수행할 수 있다. Call stack에서는 현재 진행 중인 작업들이 추적된다. setTimeout, fetch API와 같은 비동기 작업은 Web API에서 처리되는데, 작업이 마무리되면 콜백 함수가 task queue나 microtask queue로 이동한다. Call stack이 비어있으면 이벤트 루프는 microtask queue에 있는 모든 작업을 call stack으로 하나씩 옮겨 우선 처리하고, 이후에 task queue에서 작업을 하나씩 call stack으로 옮겨 실행한다. 이 과정은 call stack이 완전히 빌 때까지 계속되며, 이 메커니즘 덕분에 JavaScript는 메인 스레드를 블로킹하지 않고 비동기 작업을 수행하는 것이다. 비동기 작업을 구현하려면 callback함수를 사용하거나 Promise 또는 async/await를 사용할 수 있다. 

 
+ 이것도 한번 읽어보자!

 

(번역) 자바스크립트의 await 사건의 지평선

블랙홀의 주변에는 중력을 벗어나기 위해 필요한 속도가 광속을 초과하는 경계가 있습니다. 이를 사건의 지평선이라고 합니다. 자바스크립트의 Promise 주위에도 이와 비슷한 경계가 있는데요,

velog.io


Reference
Event Loop
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop
- https://medium.com/@kamaleshs48/event-loop-in-javascript-c332b0f81b1e
- https://gruuuuu.github.io/javascript/async-js/
 
Callback
- https://joshua1988.github.io/web-development/javascript/javascript-asynchronous-operation/
 
Promise
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
- https://ko.javascript.info/promise-basics
- https://ko.javascript.info/promise-chaining
- https://ko.javascript.info/promise-api
 
Workers
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
https://dawan0111.github.io/javascript/%EC%9B%B9%20%EC%9B%8C%EC%BB%A4(Web%20worker)%EB%A1%9C%20DOM%20%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%20%EA%B0%9C%EC%84%A0/
 
Special thanks to ChatGPT4♡

'JavaScript,TypeScript > JavaScript' 카테고리의 다른 글

Module System  (4) 2024.08.20