Skip to content
포스트
JavaScript
Javascript Promise 작동 원리 및 사용 방법

Javascript Promise 작동 원리 및 사용 방법

1. Javascript Promise가 생긴 이유

JavaScript의 Promise가 등장한 이유는 주로 비동기 작업을 보다 효율적으로 다루기 위해서입니다. 이전에는 JavaScript에서 비동기 작업을 다루기 위해 주로 콜백(callbacks)이 사용되었습니다. 그러나 콜백을 중첩하거나 콜백 지옥(callback hell)이라고 불리는 코드의 복잡성과 가독성 문제가 발생했습니다. 이러한 문제를 해결하고자 Promise가 도입되었습니다.

 
// 콜백 예제
 
function fetchData(callback) {
 
  setTimeout(() => {
 
    const data = '비동기 데이터';
 
    callback(data);
 
  }, 1000);
 
}
 
function processData(data, callback) {
 
  setTimeout(() => {
 
    const processedData = data.toUpperCase();
 
    callback(processedData);
 
  }, 1000);
 
}
 
function displayData(processedData) {
 
  setTimeout(() => {
 
    console.log('처리된 데이터:', processedData);
 
  }, 1000);
 
}
 
// fetchData 함수 호출 후, 데이터를 받아오고 받은 데이터를 가공한 후에 그 결과를 출력하는 예제
 
fetchData(data => {
 
  processData(data, processedData => {
 
    displayData(processedData);
 
  });
 
});
 

위 코드에서는 fetchData 함수로 데이터를 비동기적으로 가져온 후, processData 함수로 데이터를 가공하고, 가공된 데이터를 displayData 함수를 통해 출력합니다. 이 과정에서 콜백 함수가 중첩되어 있어 코드의 가독성이 떨어지고, 디버깅이 어려워지는 것이 콜백 지옥의 특징입니다.

 
function fetchData() {
 
  return new Promise((resolve, reject) => {
 
    setTimeout(() => {
 
      const data = '비동기 데이터';
 
      resolve(data);
 
    }, 1000);
 
  });
 
}
 
function processData(data) {
 
  return new Promise((resolve, reject) => {
 
    setTimeout(() => {
 
      const processedData = data.toUpperCase();
 
      resolve(processedData);
 
    }, 1000);
 
  });
 
}
 
function displayData(processedData) {
 
  return new Promise((resolve, reject) => {
 
    setTimeout(() => {
 
      console.log('처리된 데이터:', processedData);
 
      resolve();
 
    }, 1000);
 
  });
 
}
 
// Promise 체이닝을 이용하여 데이터를 받아오고 가공한 후에 그 결과를 출력하는 예제
 
fetchData()
 
  .then(data => {
 
    return processData(data);
 
  })
 
  .then(processedData => {
 
    return displayData(processedData);
 
  })
 
  .catch(error => {
 
    console.error('에러 발생:', error);
 
  });
 

fetchData, processData, displayData 각각이 Promise를 반환하도록 변경되었습니다. 이후 fetchData().then().then().catch()과 같은 Promise 체이닝을 통해 데이터를 받아오고 가공한 후에 결과를 출력합니다. 이를 통해 콜백 지옥을 피할 수 있으며, 코드의 가독성과 유지보수성이 향상됩니다.

2. Promise 사용 방법

앞서 설명드린 것과 같이, Promise는 JavaScript에서 비동기 작업을 처리하는 방법입니다. 비동기 코드를 보다 관리하기 쉽고 읽기 쉽게 만드는 데 사용되며 Promise는 비동기 작업의 최종 완료(또는 실패)와 그 결과 값을 나타내는 객체입니다.

Promise는 다음 세 가지 상태 중 하나일 수 있습니다.

보류 중(Pending): 이행하지도, 거부하지도 않은 초기 상태.

이행됨(Fulfilled): 비동기 작업이 성공적으로 완료됨.

거부됨(Rejected): 비동기 작업이 실패함.

Promise는 콜백함수를 받는데, 첫번째 인자로 resolve 두번째 인자로 reject를 받습니다. 그 콜백 안에서 성공적으로 작업이 이행되었다면 resolve() 함수를 호출해주고, 문제가 생겼다면 reject()를 호출해주면 됩니다.

resolve인자로 데이터를 넘겨줄 수 있으며, reject에는 주로 Error객체를 넘깁니다.

 
// Promise를 생성하는 함수
 
function fetchData() {
 
  return new Promise((resolve, reject) => {
 
    // 비동기 작업을 수행
 
    setTimeout(() => {
 
      const success = Math.random() < 0.5; // 무작위로 성공 또는 실패 여부 결정
 
      if (success) {
 
        // 성공 시 resolve 호출
 
        const data = '비동기 데이터';
 
        resolve(data);
 
      } else {
 
        // 실패 시 reject 호출
 
        const error = new Error('데이터를 불러오는 데 실패했습니다.');
 
        reject(error);
 
      }
 
    }, 1000); // 1초 후에 작업을 완료
 
  });
 
}
 

만약 resolve 되었다면, Promise는 .then()구문을 통해 이어서 다른작업을 진행할 수 있습니다.

.then()인자로 다시 콜백 함수를 받고, 콜백함수 인자는 resolve에 매개변수로 넘겨주었던 데이터가 넘어옵니다.

 
// fetchData 함수를 호출하여 Promise를 이용한 비동기 작업 수행
 
fetchData()
 
  .then(data => {
 
    // 성공적으로 데이터를 받아왔을 때 실행되는 콜백 함수
 
    console.log('데이터를 받았습니다:', data);
 
  })
 
  .catch(error => {
 
    // 데이터를 받아오는 데 실패했을 때 실행되는 콜백 함수
 
    console.error('에러 발생:', error.message);
 
  });
 

3. Promise의 작동 원리

Promise의 작동 원리를 이해하기 위해서는 자바스크립트의 Event Loop와 함께 고려해야 합니다.

3-1. Javascript Event Loop


Javascript를 공부하다보면 싱글 스레드 언어라는 말을 들어본적이 있을겁니다. 싱글스레드라 한번에 하나의 작업만 수행이 가능하다는 말이죠. 그런데 웹 어플리케이션을 보다보면 동시에 여러가지 실행을 하는것을 알 수 있습니다.

예를들면, 브라우저에서 파일을 다운로드 받으면서 다른페이지로 이동을 한다거나, 사용자가 온라인 동영상을 시청하는 동안 웹 애플리케이션은 사용자가 다른 페이지로 이동하는 것을 허용합니다. 동영상은 백그라운드에서 계속 스트리밍되며, 사용자가 페이지를 이동하더라도 시청이 계속됩니다.

우리가 배운대로라면 브라우저는 파일을 전부 다운받기 전까지 페이지 이동도 못하고 대기해야할겁니다.

그렇기 때문에 파일 다운이나, 네트워크 요청, 타이머, 애니메이션 등 이렇게 시간이 소요되고 반복적인 작업들은 자바스크립트 엔진이 아닌, 브라우저 내부 멀티 스레드인 Web API에서 비동기 + 논블로킹으로 처리됩니다.

비동기 + 논블러킹은 메인 스레드가 작업을 다른곳에 요청하여 대신 실행하고, 그 작업이 끝이 나면 이벤트나 콜백함수를 받아 결과를 실행하는 방식을 말합니다.

이벤트 루프는 브라우저 동작을 제어하는 관리자 라고 생각하면 됩니다.

이벤트 루프는 싱글 스레드인 자바스크립트의 작업을 멀티 스레드로 돌려 동시에 작업을 처리시키게 하거나, 브라우저 내부의 Call Stack, Callback Queue, Web API 등의 요소등을 모니터링하면서 비동기적으로 실행되는 작업들을 관리하고, 순서대로 처리하는등 프로그램의 실행 흐름을 제어합니다.

Javascript-event-loop.png

3-2. Task Queue, MicroTask Queue


Task Queue 와 Microtask Queue는 Web API가 수행한 비동기 함수를 넘겨받아 Event Loop가 해당 함수를 Call Stack에 넘겨줄 때까지 비동기 함수들을 쌓아놓는 곳입니다.

그 중 Promise객체의 콜백이 쌓이는 곳이 바로 MicroTask Queue 이고 task queue 즉 MacroTask Queue보다 먼저 실행이 됩니다.

자바스크립트 엔진이 어느곳을 거쳐 비동기 작업을 수행하는지 간단한 예제를 보겠습니다.

img.gif

  • Task1: 자바스크립트 엔진에 있는 Call Stack에 즉시 추가되는 함수입니다. 코드안에서 즉시 호출했을때 일어납니다.

  • Task2, Task3, Task4: Promise에서 then() 콜백함수 내에서 추가되는 함수입니다.

  • Task5, Task6: setTimeout 이나 set interval 등에서 추가되는 함수.

 
console.log('Start!');
 
setTimeout(() => {
 
\tconsole.log('Timeout!');
 
}, 0);
 
Promise.resolve('Promise!').then(res => console.log(res));
 
console.log('End!');
 

위와 같은 코드를 실행 하게 되면 아래 와 같이 동작하게 됩니다.

1.gif

  1. 맨 처음 라인에서 엔진은 console.log()를 마주칩니다.

  2. Call Stack에 console이 추가가되고 콘솔이 찍힌 후 Call Stack에서 제거가 됩니다.

2.gif

  1. settimeout 메서드가 call stack에 추가가 된 후 콜백은 web api에 추가가 됩니다.

3.gif

  1. 추가된 콜백은 macrotask queue에 추가가 되고 자바스크립트 엔진은 promise resolve와 then()을 call stack에 추가하고, then의 콜백함수를 microtask queue에 추가합니다.

4.gif

 
   Promise.resolve('Promise')
 
   .then(res => {
 
   \tsettimeout(()=>{console.log(res}, 0)
 
   })
 
  1. 마지막줄의 console이 call stack에 추가가 되고, 실행됩니다.

    5.gif

    microtask queue에 들어가 있던 콜백이 다시 call stack에 들어간 후 실행이 되고, 이후 macrotask queue에 있던 콜백 함수 또한 call stack에 추가된 후 실행됩니다.

6.gif

4. async와 await 사용 방법

async와 await는 프로미스를 다루는 것을 단순화하는 특별한 키워드입니다. 콜백 함수와 then 함수의 호출의 필요성을 없앱니다. try-catch 블록과도 함께 작동합니다.

promise에 then을 호출하는 대신, await 키워드를 사용하여 프로미스를 기다립니다.

이는 함수 실행을 프로미스가 이행할 때 까지 효과적으로 일시중지 합니다.

 
 //then으로 프로미스 기다리기
 
 getUsers().then(users => {
 
     console.log('Got users:', users);
 
 });
 

위 코드를 await를 사용하여 작성할 수 있습니다.

 
 //await으로 프로미스 기다리기
 
 const users = await getUsers();
 
 console.log('Got users:', users);
 
 
 

then()을 사용하던 코드도 좀 더 깔끔하게 작성할 수 있습니다.

 
 const data = await readFile('sourceData.json');
 
 const result = await processData(data);
 
 await writeFile(result, 'processedData.json');
 

await을 사용할 때마다, 기다리고 있는 프로미스가 이행될 때까지 함수의 나머지 실행이 중단됩니다. 만약 병렬로 실행되는 여러 프로미스를 기다리고 싶다면, Promise.all을 사용할 수 있습니다:

 
 //await으로 Promise.all 사용하기
 
 const users = await Promise.all([getUser(1), getUser(2), getUser(3)]);
 

await 키워드를 사용하기 위해서는 함수를 async 함수로 표시해야 합니다. 함수 앞에 async 키워드를 붙임으로써 이를 수행할 수 있습니다

 
 //함수를 async로 표시하기
 
 async function processData(sourceFile, outputFile) {
 
   const data = await readFile(sourceFile);
 
   const result = await processData(data);
 
   writeFile(result, outputFile);
 
 }
 

async 키워드를 추가하는 것은 함수에 대해 또 다른 중요한 영향을 미칩니다. Async 함수는 항상 암시적으로 프로미스를 반환합니다. async 함수에서 어떤 값을 반환하면, 실제로는 그 값을 이행하는 프로미스를 반환하게 됩니다.

마무리

현대 웹 애플리케이션 특성상 프로미스는 많이 사용되고 있고, 앞으로도 많이 사용될 것 같아 알아두면 참 좋을 것 같습니다.