본문 바로가기
JavaScript & TypeScript

비동기 처리: 콜백, 프로미스, 그리고 async/await (TypeScript)

by 대박플머 2024. 10. 22.

비동기 처리(asynchronous processing)는 자바스크립트 및 TypeScript에서 필수적인 개념으로, 시간 소요가 긴 작업(예: 네트워크 요청, 파일 읽기 등)을 처리할 때 사용됩니다. 프로그램이 해당 작업을 기다리지 않고 다른 작업을 수행할 수 있게 함으로써 효율성을 높입니다. 이번 글에서는 TypeScript를 사용한 비동기 처리 방식으로 콜백, 프로미스, 그리고 async/await에 대해 설명하겠습니다.


1. 콜백 (Callback)

1.1 콜백의 정의

콜백은 비동기 작업이 완료된 후 실행할 함수를 다른 함수에 전달하는 방식입니다. 이 방식은 비동기 작업의 완료 여부에 따라 호출되는 함수를 지정합니다.

1.2 콜백의 예시 (TypeScript)

import { readFile } from "fs";

readFile("example.txt", "utf8", (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

위 예시는 Node.js에서 파일을 비동기적으로 읽고, 작업이 완료되면 콜백 함수가 호출됩니다. 이 경우 프로그램은 파일을 읽는 동안 다른 작업을 수행할 수 있습니다.

  1. 단일 비동기 작업 처리: 파일 읽기, 네트워크 요청 등 단일 비동기 작업을 처리할 때 사용합니다.
  2. 에러 처리: 첫 번째 매개변수로 에러 객체를 받아 에러 처리를 할 수 있습니다.
  3. 간단한 비동기 로직: 복잡하지 않은 비동기 로직을 구현할 때 사용합니다.
  4. 이벤트 기반 프로그래밍: 이벤트 리스너와 같이 특정 이벤트 발생 시 실행될 로직을 정의할 때 사용합니다.
  5. 라이브러리 호환성: 콜백을 사용하는 오래된 라이브러리와 함께 작업할 때 유용합니다.

단, 여러 비동기 작업을 연속적으로 처리해야 하는 경우 콜백 지옥을 피하기 위해 프로미스나 async/await를 사용하는 것이 좋습니다.

1.3 콜백 지옥 (Callback Hell)

콜백 방식은 간단하지만, 중첩된 콜백이 많아지면 코드가 복잡해지고 유지보수가 어려워집니다. 이를 흔히 "콜백 지옥"이라고 부릅니다.

doSomething((result: any) => {
  doSomethingElse(result, (newResult: any) => {
    doThirdThing(newResult, (finalResult: any) => {
      console.log("Final result: " + finalResult);
    });
  });
});

위 코드는 콜백이 중첩되어 복잡해지며, 가독성이 떨어집니다. 이러한 콜백 지옥은 다음과 같은 상황에서 특히 문제가 될 수 있습니다:

  1. 복잡한 비동기 워크플로우: 여러 단계의 비동기 작업이 순차적으로 실행되어야 하는 경우
  2. API 호출 체인: 여러 API를 연속적으로 호출해야 하는 경우
  3. 데이터베이스 작업: 여러 쿼리를 순차적으로 실행해야 하는 경우
  4. 파일 시스템 작업: 여러 파일을 순차적으로 읽거나 쓰는 경우
  5. 애니메이션 시퀀스: 여러 애니메이션을 순차적으로 실행해야 하는 경우

이러한 상황에서 콜백 지옥을 피하고 코드의 가독성과 유지보수성을 높이기 위해 프로미스(Promise)나 async/await와 같은 더 현대적인 비동기 처리 방식을 사용하는 것이 좋습니다.

1.4 콜백의 장단점

콜백은 비동기 프로그래밍에서 널리 사용되는 패턴이지만, 장단점이 있습니다.

1.4.1 장점

  1. 간단성: 구현이 간단하고 직관적입니다.
  2. 유연성: 다양한 상황에 적용할 수 있습니다.
  3. 비동기 처리: 비동기 작업을 효과적으로 처리할 수 있습니다.
  4. 이벤트 기반 프로그래밍: 이벤트 리스너 등에 적합합니다.

1.4.2 단점

  1. 콜백 지옥: 중첩된 콜백으로 인해 코드가 복잡해질 수 있습니다.
  2. 에러 처리의 어려움: 여러 콜백에 걸친 에러 처리가 복잡할 수 있습니다.
  3. 제어 흐름 예측 어려움: 복잡한 비동기 로직에서 실행 순서를 예측하기 어려울 수 있습니다.
  4. 디버깅의 어려움: 콜백 체인에서 문제를 찾기 어려울 수 있습니다.

이러한 장단점을 고려하여 적절한 상황에 콜백을 사용하는 것이 중요합니다.


2. 프로미스 (Promise)

2.1 프로미스의 정의

프로미스는 비동기 작업의 결과를 나타내는 객체로, pending, fulfilled, rejected 세 가지 상태를 가집니다. 프로미스는 콜백보다 구조화된 방법으로 비동기 작업을 처리합니다. 다음은 프로미스의 세 가지 상태에 대한 상세한 예제입니다:

2.1.1 Pending (대기 중)
const pendingPromise = new Promise((resolve, reject) => {
  // 비동기 작업이 아직 완료되지 않음
});
console.log(pendingPromise); // Promise {<pending>}
2.1.2. Fulfilled (이행됨)
const fulfilledPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("성공!");
  }, 1000);
});
fulfilledPromise.then((result) => console.log(result)); // 1초 후 "성공!" 출력
2.1.3. Rejected (거부됨)
const rejectedPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject(new Error("실패!"));
  }, 1000);
});
rejectedPromise.catch((error) => console.error(error)); // 1초 후 Error: 실패! 출력

이러한 상태 변화를 통해 프로미스는 비동기 작업의 완료 또는 실패를 효과적으로 처리할 수 있습니다.

2.2 프로미스의 예시 (TypeScript)

const promise = new Promise<string>((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("작업 성공");
  } else {
    reject("작업 실패");
  }
});

promise
  .then((result) => {
    console.log(result); // '작업 성공' 출력
  })
  .catch((error) => {
    console.error(error); // '작업 실패' 출력
  });

프로미스는 thencatch 메서드를 사용해 성공과 실패를 처리하며, 콜백 방식보다 가독성이 뛰어납니다.

2.3 프로미스 체이닝 (Promise Chaining)

여러 비동기 작업을 순차적으로 처리할 때 프로미스 체이닝을 사용하면 중첩 문제를 해결할 수 있습니다.

function doSomething(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve("첫 번째 작업 완료"), 1000);
  });
}

function doSomethingElse(result: string): Promise<string> {
  return new Promise((resolve) => {
    console.log("두 번째 작업 시작:", result);
    setTimeout(() => resolve(result + ", 두 번째 작업 완료"), 1000);
  });
}

function doThirdThing(result: string): Promise<string> {
  return new Promise((resolve) => {
    console.log("세 번째 작업 시작:", result);
    setTimeout(() => resolve(result + ", 세 번째 작업 완료"), 1000);
  });
}

doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log("최종 결과: " + finalResult);
  })
  .catch((error) => {
    console.error(error);
  });

이 코드의 출력 결과는 다음과 같습니다:

$ node promise-chaining.ts
두 번째 작업 시작: 첫 번째 작업 완료
세 번째 작업 시작: 첫 번째 작업 완료, 두 번째 작업 완료
최종 결과: 첫 번째 작업 완료, 두 번째 작업 완료, 세 번째 작업 완료
$

프로미스 체이닝을 사용하면 논리적 흐름을 유지하면서 비동기 작업을 순차적으로 처리할 수 있습니다.


3. async/await

3.1 async/await의 정의

async/await는 프로미스를 기반으로 하여 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 문법입니다. async 키워드를 사용하면 함수는 자동으로 프로미스를 반환하고, await 키워드는 해당 프로미스가 해결될 때까지 기다립니다.

3.2 async/await의 예시 (TypeScript)

async function fetchData(): Promise<void> {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

fetchData();

위 예시에서 await 키워드를 사용해 비동기 작업의 완료를 기다리고, 그 결과를 바로 사용할 수 있습니다. 코드가 매우 간결하고 가독성이 좋아집니다.

3.3 async/await의 장점

  1. 가독성: 비동기 코드를 동기 코드처럼 작성할 수 있어 가독성이 뛰어납니다.
  2. 에러 처리: try/catch 구문을 사용하여 에러를 직관적으로 처리할 수 있습니다.
  3. 코드 유지보수성: 복잡한 비동기 로직도 간단하게 작성할 수 있습니다.

3.4 async/await의 병렬 처리

여러 비동기 작업을 병렬로 처리할 때는 Promise.all을 활용할 수 있습니다.

async function fetchMultipleData(): Promise<void> {
  try {
    const [data1, data2] = await Promise.all([
      fetch("https://api.example.com/data1"),
      fetch("https://api.example.com/data2"),
    ]);

    const result1 = await data1.json();
    const result2 = await data2.json();
    console.log(result1, result2);
  } catch (error) {
    console.error(error);
  }
}

fetchMultipleData();

Promise.all은 여러 비동기 작업을 병렬로 실행하고, 모든 작업이 완료되면 결과를 반환합니다.


4. 콜백, 프로미스, async/await 비교

특성 콜백 프로미스 async/await
가독성 낮음 중간 높음
에러 처리 비직관적 (콜백 내에서 처리) catch로 처리 try/catch로 처리
코드 중첩 문제 콜백 지옥 발생 가능 체이닝으로 해결 가능 동기 코드처럼 처리 가능
사용 시기 초기 비동기 처리 방식 ES6에서 도입 ES8에서 도입

결론

TypeScript에서 비동기 처리는 콜백에서 시작해, 프로미스와 async/await으로 발전했습니다. 각 방식은 서로 다른 시나리오에 적합하며, 특히 async/await는 비동기 코드를 동기식으로 처리할 수 있어 가독성과 유지보수성에서 매우 유리합니다. 상황에 맞는 비동기 처리 방식을 선택하여 효율적으로 코드를 작성하는 것이 중요합니다.