[웹 개발 기초 자바스크립트] 17. Callbacks, Promise 그리고 Async/Await

Shy·2023년 9월 1일
0

NodeJS(Express&Next.js)

목록 보기
19/39

자바스크립트는 싱글스레드

그래서 하나의 일을 할 때 하나밖에 못하는데 그 하나가 오래 걸리는 일이면, 다른 작업들은 그 하나의 일이 끝날때 까지 기다려야 한다.

→ 이러한 문제점을 해결하기 위해서 비동기로 작업을 수행한다.

만약 비동기 요청이 여러 개 있을 때 하나의 요청이 다른 요청의 결과에 의존한다면?

비동기 요청이 여러 개 있고 하나의 요청이 다른 요청의 결과에 의존할 때, 아래와 같은 몇 가지 방법을 사용할 수 있다.

  1. Callback 중첩 (Callback Hell)
    가장 기본적인 방법은 콜백 함수를 중첩하는 것이다. 하지만 이 방법은 코드가 복잡해지고 유지보수가 어려워질 수 있다.
firstAsyncFunction(param1, function(result1) {
  secondAsyncFunction(result1, function(result2) {
    thirdAsyncFunction(result2, function(result3) {
      // ...
    });
  });
});
  1. Promise 사용
    Promise를 사용하면 비동기 코드를 좀 더 깔끔하게 작성할 수 있다.
firstAsyncFunction(param1)
  .then(result1 => secondAsyncFunction(result1))
  .then(result2 => thirdAsyncFunction(result2))
  .then(result3 => {
    // ...
  })
  .catch(error => {
    // 에러 처리
  });
  1. Async/Await 사용
    async/await를 사용하면 비동기 코드를 마치 동기 코드처럼 작성할 수 있다.
async function runAsyncTasks() {
  try {
    const result1 = await firstAsyncFunction(param1);
    const result2 = await secondAsyncFunction(result1);
    const result3 = await thirdAsyncFunction(result2);
    // ...
  } catch (error) {
    // 에러 처리
  }
}
  1. Promise.all
    모든 비동기 요청이 동시에 시작할 수 있고, 결과가 모두 필요한 경우 Promise.all을 사용할 수 있다. 하지만 이 경우에는 하나의 요청이 다른 요청의 결과에 의존하지 않아야 한다.
Promise.all([firstAsyncFunction(param1), secondAsyncFunction(param2)])
  .then(([result1, result2]) => {
    // ...
  })
  .catch(error => {
    // 에러 처리
  });

여기서 중요한 것은 선택한 방법이 애플리케이션의 요구 사항과 잘 맞아야 한다는 것이다.

Callbacks

콜백은 자바스크립트에서 비동기 처리를 다루는 가장 기본적인 방법 중 하나이다. 함수를 파라미터로 다른 함수에 전달하고, 특정 이벤트가 발생하거나 작업이 완료되면 해당 함수를 호출하는 방식이다.

function doSomething(callback) {
  // 작업을 수행
  callback('작업 결과');
}

doSomething(function(result) {
  console.log('콜백 실행:', result);
});

콜백의 문제점

  • 콜백 헬(Callback Hell): 여러 개의 콜백 함수가 중첩되어 가독성이 떨어지고, 에러 처리가 어려워지는 문제
  • 에러 처리가 복잡: 각 콜백마다 에러 처리 로직을 따로 추가해야 함

예제 코드 분석

코드의 구성 요소

  1. doSomething 함수: 이 함수는 하나의 인자 callback을 받아서 내부에서 작업을 수행한다. 작업이 완료되면 callback 함수를 '작업 결과'라는 문자열을 인자로 주고 호출한다.
function doSomething(callback) {
  // 작업을 수행
  callback('작업 결과');
}
  1. 콜백 함수의 사용: doSomething 함수를 호출할 때 익명 함수를 콜백으로 전달한다. 이 익명 함수는 result라는 인자를 받아 콘솔에 로그를 출력한다.
doSomething(function(result) {
  console.log('콜백 실행:', result);
});

코드의 실행 흐름은 다음과 같다

  1. doSomething 함수가 호출되면서 콜백 함수 (function(result) { console.log('콜백 실행:', result); })가 인자로 전달된다.
  2. doSomething 함수 내에서 작업을 수행한다. (여기서는 작업을 수행하는 부분이 생략되어 있다.)
  3. 작업이 완료되면 전달받은 콜백 함수를 호출하고 '작업 결과'라는 문자열을 인자로 넣어준다.
  4. 콜백 함수가 실행되면서 '콜백 실행: 작업 결과'라는 문자열이 콘솔에 출력된다.

이렇게 콜백 함수를 사용하면 함수의 작업이 완료된 후에 어떤 작업을 할 것인지를 유연하게 설정할 수 있다.

예제1

function firstFunction(parameters, callback) {
  //do something
  const response1 = request(`http://abc.com?id=${parameters.id}`);
  callback(response1);
}

function secondFunction(response1, callback) {
  const response2 = request('http://bcd.com', response1);
  callback()
}

firstFunction(para, function(response1) {
  secondFunction(response1, function() {
    thirdFunction(para, function() {
      //...
    })
  })
})

예제1 코드분석

해당 코드는 콜백 패턴을 사용하여 비동기 작업을 처리하는 예시이다. 각 함수(firstFunction, secondFunction, thirdFunction)는 어떤 작업을 수행한 후에 콜백 함수를 호출한다. 여기서는 간단한 설명을 위해 request 함수를 사용하고 있다고 가정하겠습니다.

함수 설명

  1. firstFunction: 첫 번째 함수는 parameters라는 객체를 인자로 받아서 어떤 작업을 수행합니다. 작업이 끝나면 callback을 호출하며, 이 때 response1를 인자로 전달한다.
  2. secondFunction: 두 번째 함수는 response1을 인자로 받아 다른 작업을 수행합니다. 작업이 끝나면 또 다른 callback을 호출한다

콜백 중첩 (Callback Hell)
코드의 마지막 부분에서 firstFunction을 호출하고, 그 콜백 내에서 secondFunction을 호출하고 있습니다. 이런 패턴을 콜백 중첩이라고 하며, "콜백 지옥(Callback Hell)"으로도 알려져 있다. 이는 코드가 복잡해지고 유지보수가 어려워질 수 있는 문제점을 가지고 있다.

주의 사항
secondFunction에서 callback을 호출할 때 인자가 없다. 이는 실제 코드에서 문제가 될 수 있다.

Promises

프로미스는 비동기 작업을 더 쉽게 처리할 수 있는 객체이다. 프로미스는 'pending', 'fulfilled', 'rejected' 등 세 가지 상태를 가진다.

  • pending: 초기 상태, 아직 결과가 결정되지 않음
  • fulfilled: 작업이 성공적으로 완료됨
  • rejected: 작업이 실패함

Promise 객체는 new 키워드와 생성자를 사용햐 만든다. 생성자는 매개변수로 "실행 함수"를 받는다. 이 함수는 매개 변수로 두 가지 함수를 받아야 하는데, 첫 번째 함수(resolve)는 비동기 작업을 성공적으로 완료해 결과를 값으로 반환할 때 호출해야 하고, 두 번째 함수(reject)는 작업이 실패하여 오류의 원인을 반환할 때 호출하면 된다. 두 번째 함수는 주로 오류 객체를 받는다.

//예시 코드
const myFirstPromise = new Promise((resolve, reject) => {
  // do something asynchronous which eventually calls either:
  //
  //   resolve(someValue)       // fulfilled
  // or
  //   reject("failure reason") // rejected
});
const myPromise = new Promise((resolve, reject) => {
  if (/* 조건 */) {
    resolve('성공');
  } else {
    reject('실패');
  }
});

myPromise
  .then(result => {
    console.log('결과:', result);
  })
  .catch(error => {
    console.log('에러:', error);
  });

프로미스의 장점

  • 가독성이 높고, 콜백 헬을 해결
  • '.then()'과 '.catch()'를 통한 편리한 에러 처리
  • '.all()', '.race()' 등의 추가적인 유틸리티 메서드

예제 코드 분석

코드의 구성 요소

  1. Promise 객체 생성: new Promise((resolve, reject) => {...}) 형식으로 Promise 객체를 생성한다. resolve와 reject는 콜백 함수다. 작업이 성공하면 resolve를 호출하고, 실패하면 reject를 호출한다.
const myPromise = new Promise((resolve, reject) => {
  if (/* 조건 */) {
    resolve('성공');
  } else {
    reject('실패');
  }
});
  1. .then()과 .catch() 메서드: myPromise 객체가 성공적으로 완료되면 .then() 메서드의 콜백이 실행된다. 만약 실패하면 .catch() 메서드의 콜백이 실행된다.
myPromise
  .then(result => {
    console.log('결과:', result);
  })
  .catch(error => {
    console.log('에러:', error);
  });

코드의 실행 흐름은 다음과 같다.

  1. Promise 객체 myPromise가 생성되면서 어떠한 조건(/* 조건 */)을 검사합니다. 이 부분은 코드에 구현되어 있지 않아서 실제 조건을 확인할 수 없다.
  2. 만약 조건이 참이면 resolve('성공')을 호출하여 Promise를 성공 상태로 변경하고, .then() 메서드의 콜백 함수가 실행된다. 이 때 콜백 함수에 '성공'이라는 값이 result로 전달된다.
  3. 만약 조건이 거짓이면 reject('실패')를 호출하여 Promise를 실패 상태로 변경하고, .catch() 메서드의 콜백 함수가 실행된다. 이 때 콜백 함수에 '실패'라는 값이 error로 전달된다.

이렇게 Promise를 사용하면 비동기 작업의 성공과 실패를 더 명확하고 가독성 좋게 처리할 수 있다.

예제1

function fetchData() {
  return new Promise(function(resolve, reject) {
    // 비동기 요청, 성공했다고 가정하므로 const success= true; 를 선언
    const success = true;
    if (success) {
      resolve('성공')
    } else {
      reject('실패')
    }
  });
}

fetchData()
  .then((response) => {
    console.log(response);
  })
  .catch((error) => {
    console.error(error);
  })

해당 코드는 JavaScript의 Promise를 사용하여 비동기 작업을 처리하는 예시이다. 코드는 다음과 같은 방식으로 작동한다.

fetchData 함수

  1. fetchData 함수는 Promise 객체를 반환한다.
  2. 이 Promise 객체는 resolve와 reject라는 두 개의 콜백 함수를 인자로 받는 함수를 생성자에 전달한다.
  3. 여기서는 가정을 통해 success를 true로 설정하고 있다.
  4. success가 true이면 resolve 함수가 호출되며 '성공'이라는 문자열을 인자로 전달한다.
  5. 만약 success가 false였다면, reject 함수가 호출되며 '실패'라는 문자열을 인자로 전달할 것이다. (현재 코드에서는 항상 success가 true이므로 이 부분은 실행되지 않는다.)

.then()와 .catch()

  1. fetchData() 함수가 반환하는 Promise 객체에 .then()와 .catch() 메소드를 체이닝한다.
  2. resolve가 호출되면 .then() 안에 있는 콜백이 실행된다. 이 콜백은 resolve에서 전달된 '성공'이라는 값을 인자로 받아 콘솔에 출력한다.
  3. 만약 reject가 호출되면 .catch() 안에 있는 콜백이 실행된다. 이 콜백은 reject에서 전달된 '실패'라는 값을 인자로 받아 콘솔에 출력할 것이다. (현재 코드에서는 이 부분은 실행되지 않는다.)

이러한 방식을 통해 비동기 작업을 더욱 쉽게 관리할 수 있다. 이 코드에서는 실제 비동기 작업을 수행하지는 않지만, 이러한 패턴은 실제 HTTP 요청, 파일 읽기 등 다양한 비동기 작업에 적용할 수 있다.

예제2

fetch('https://jsonplaceholder.typicode.com/todos/1') // 데이터 받아옴
 .then(response1 => response1.json()) // json형식으로 변환
 .then(json => console.log(json)) // console.log로 출력
 .then(() => fetch('https://jsonplaceholder.typicode.com/todos/2')) //데이터 받아옴
 .then(response2 => response2.json()) // json형식으로 변환
 .then(json2 => console.log(json2)) // console.log로 출력
 .catch((error) => {
  console.error(error);
 })
 .finally(() => {
  console.log('작업 끝!')
 })

Promise.all()

Promise.all() 메서드는 여러 개의 Promise 객체를 포함하는 배열을 입력으로 받아, 모든 Promise가 성공적으로 완료될 때까지 대기한 후에, 각각의 Promise 결과를 배열로 반환한다. 만약 하나라도 실패하면 Promise.all()도 실패하며, 첫 번째로 발생한 에러를 반환한다.

기본적인 사용 예시는 다음과 같다.

const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve(2);
const promise3 = Promise.resolve(3);

Promise.all([promise1, promise2, promise3])
  .then((values) => {
    console.log(values); // 출력: [1, 2, 3]
  })
  .catch((error) => {
    console.log(`Error: ${error}`);
  });

이 메서드는 각각의 Promise가 서로에게 의존적이지 않고 독립적으로 실행될 수 있을 때 유용하게 사용된다. 예를 들어, 여러 API에서 데이터를 동시에 가져와야 할 때 유용하게 사용할 수 있다.

const fetch1 = fetch('https://api.example.com/data1');
const fetch2 = fetch('https://api.example.com/data2');
const fetch3 = fetch('https://api.example.com/data3');

Promise.all([fetch1, fetch2, fetch3])
  .then((responses) => {
    // responses 배열에서 각 response 객체를 처리
  })
  .catch((error) => {
    console.log(`Error: ${error}`);
  });

이렇게 Promise.all()을 사용하면 모든 비동기 작업이 완료될 때까지 대기한 다음에 결과를 한 번에 처리할 수 있다.

Promise.race()

Promise.race()는 여러 개의 Promise 객체를 포함하는 배열을 입력으로 받아, 가장 먼저 완료되는 (즉, "이긴") Promise의 결과나 에러를 반환한다. 다시 말해, 여러 개의 Promise 중에서 가장 먼저 성공하거나 실패하는 Promise의 상태를 그대로 따른다.

이 메서드는 다양한 용도로 활용될 수 있다. 예를 들어, 여러 개의 비동기 작업 중 먼저 끝나는 작업만을 처리하고자 할 때, 또는 타임아웃을 설정하고자 할 때 유용하게 사용된다.

기본적인 사용 예시는 다음과 같다.

const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'two');
});

Promise.race([promise1, promise2])
  .then((value) => {
    console.log(value); // 출력: 'two'
  })
  .catch((error) => {
    console.log(`Error: ${error}`);
  });

이 예시에서 promise2가 더 빨리 완료되므로, Promise.race()의 결과는 'two'이다.

Promise.race()는 시간 제한이 있는 작업에 유용하게 사용할 수 있다. 예를 들어, 특정 시간 내에 작업이 완료되지 않으면 자동으로 실패로 처리하고자 할 때 다음과 같이 작성할 수 있다.

const fetchWithTimeout = (url, ms) => {
  const timeout = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('Request timed out'));
    }, ms);
  });
  const fetchRequest = fetch(url);
  
  return Promise.race([timeout, fetchRequest]);
};

fetchWithTimeout('https://api.example.com/data', 5000)
  .then((response) => {
    // 데이터 처리
  })
  .catch((error) => {
    console.log(`Error: ${error}`);
  });

이 예시에서 fetchWithTimeout 함수는 두 개의 Promise 중 먼저 완료되는 것을 반환한다. 하나는 fetch 요청, 다른 하나는 지정된 시간(ms) 후에 자동으로 실패하는 timeout Promise입니다. 이를 통해 요청에 시간 제한을 둘 수 있다.

Callbacks, Promises 비교

  • 콜백: 간단한 비동기 작업에 적합, 에러 처리와 중첩 문제가 있음
  • 프로미스: 비동기 로직을 체이닝할 수 있어 복잡한 비동기 작업에 더 적합, 에러 처리가 더 간편

자바스크립트는 이 두 가지를 넘어 async/await를 통해 더 직관적인 비동기 처리를 가능하게 하고 있다.

Async, Await

async와 await는 자바스크립트에서 비동기 작업을 더 쉽고 가독성 있게 처리하기 위한 문법이다. 이들은 ES2017(ES8)에서 도입되었으며, 기존의 콜백 함수나 프로미스의 사용을 단순화하고 코드를 더 읽기 쉽게 만들어 준다.

async

async 키워드는 함수 앞에 사용되며, 이 함수는 항상 Promise를 반환한다. 만약 async 함수가 값을 반환하면, 이 값은 .then() 메서드를 통해 접근할 수 있는 프로미스로 감싸진다. 만약 함수에서 예외가 발생하면, 이는 .catch() 메서드를 통해 잡을 수 있는 프로미스로 반환된다.

async function foo() {
  return 'Hello';
}

foo().then(result => console.log(result));  // 출력: "Hello"

await

await 키워드는 async 함수 내부에서만 사용할 수 있으며, Promise의 완료를 기다린 후 결과값을 반환한다. await을 사용하면 비동기 코드를 마치 동기 코드처럼 작성할 수 있어 가독성이 향상된다.

async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  console.log(data);
}

주의사항

  • 'await'는 'async' 함수 내에서만 사용할 수 있다.
  • await 키워드를 통해 프로미스가 해결되길 기다리는 동안, 해당 async 함수의 실행은 일시 중지된다. 그러나 이 함수 외의 다른 코드나 이벤트 루프는 정상적으로 계속 실행된다.
  • 여러 개의 await 작업이 독립적인 경우, Promise.all()을 사용하여 병렬로 처리하는 것이 좋다.
async function fetchAllData() {
  const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
  // ...do something with data1 and data2
}

이런 식으로, async/await는 비동기 프로그래밍을 단순하고 가독성 있게 만들어 준다.

예제1

fetch('https://jsonplaceholder.typicode.com/todos/1') // 데이터 받아옴
 .then(response1 => response1.json()) // json형식으로 변환
 .then(json => console.log(json)) // console.log로 출력
 .then(() => fetch('https://jsonplaceholder.typicode.com/todos/2')) //데이터 받아옴
 .then(response2 => response2.json()) // json형식으로 변환
 .then(json2 => console.log(json2)) // console.log로 출력
 .catch((error) => {
  console.error(error);
 })
 .finally(() => {
  console.log('작업 끝!')
 })

위 코드를 아래로 변환이 가능하다.

async function makeRequests() {
  try {
    const response1 = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    const jsonResponse1 = await response1.json();
    console.log('jsonResponse1', jsonResponse1);
    
    const response2 = await fetch('https://jsonplaceholder.typicode.com/todos/2');
    const jsonResponse2 = await response2.json();
    console.log('jsonResponse2', jsonResponse2);
  } catch(error) {
    console.log(error)
  } finally {
    console.log('작업 끝!')
profile
초보개발자. 백엔드 지망. 2024년 9월 취업 예정

0개의 댓글