icon

안동민 개발노트

11장 : 비동기 프로그래밍

Promise와 async/await


자바스크립트는 싱글 스레드 기반 언어라 한 번에 하나의 작업만 수행합니다. 그런데 웹 애플리케이션에서는 네트워크 요청, 파일 I/O, 타이머처럼 오래 걸리거나 즉시 결과를 알 수 없는 비동기 작업이 자주 발생합니다.

이 비동기 흐름을 제대로 관리하지 못하면 UI가 멈추거나 애플리케이션 전체가 응답하지 않는 문제가 생길 수 있습니다.

과거에는 콜백 함수(Callback Function)로 비동기 작업을 처리했습니다. 하지만 콜백이 중첩되기 시작하면 이른바 콜백 지옥(Callback Hell)이 생기고, 가독성과 유지보수성이 빠르게 나빠졌습니다.

이 문제를 해결하기 위해 ES6(ECMAScript 2015)에서 Promise가 도입됐고, ES2017에서는 Promise를 더 직관적으로 다루도록 async/await 문법이 추가되었습니다.

타입스크립트는 이러한 자바스크립트의 비동기 기능을 완벽하게 지원하며, 강력한 타입 시스템을 통해 비동기 코드의 안정성을 더욱 높여줍니다.


Promise

Promise는 비동기 작업의 최종 완료(성공 또는 실패)와 그 결과 값을 나타내는 객체입니다. Promise는 다음 세 가지 상태 중 하나를 가집니다.

Pending (대기): 비동기 작업이 아직 완료되지 않은 초기 상태.

Fulfilled (이행): 비동기 작업이 성공적으로 완료된 상태. 결과 값을 반환합니다.

Rejected (거부): 비동기 작업이 실패한 상태. 오류 값을 반환합니다.

Promise는 한 번 상태가 결정되면(Fulfilled 또는 Rejected) 다시 변경될 수 없습니다.

Promise 생성 및 사용 기본 구조
// Promise 생성: Promise 생성자는 resolve와 reject 두 콜백 함수를 인자로 받습니다.
const myPromise = new Promise<string>((resolve, reject) => {
  // 비동기 작업 수행 (예: setTimeout으로 2초 후 성공)
  setTimeout(() => {
    const success = true; // 실제로는 서버 응답이나 다른 조건에 따라 결정

    if (success) {
      resolve("데이터를 성공적으로 가져왔습니다!"); // 작업 성공 시 호출
    } else {
      reject("데이터를 가져오는 데 실패했습니다."); // 작업 실패 시 호출
    }
  }, 2000);
});

// Promise 사용: .then()으로 성공 시 처리, .catch()로 실패 시 처리
myPromise
  .then((message: string) => {
    console.log("성공:", message); // '데이터를 성공적으로 가져왔습니다!'
  })
  .catch((error: string) => {
    console.error("실패:", error); // '데이터를 가져오는 데 실패했습니다.'
  });

console.log("Promise 작업 시작 (비동기적으로 실행됩니다)"); // 이 부분이 먼저 출력됩니다.

Promise 체이닝 (Chaining): .then() 메서드는 Promise를 반환하기 때문에, 여러 비동기 작업을 순차적으로 연결하여 처리할 수 있습니다. 이를 Promise 체이닝이라고 합니다. 각 .then()은 이전 Promise의 결과 값을 다음 .then()으로 전달합니다.

function fetchData(url: string): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`[1] ${url} 에서 데이터 가져오기 완료`);
      resolve(`Fetched data from ${url}`);
    }, 1000);
  });
}

function processData(data: string): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`[2] 데이터 처리 완료: ${data}`);
      resolve(`Processed: ${data.toUpperCase()}`);
    }, 1500);
  });
}

function saveData(processedData: string): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`[3] 데이터 저장 완료: ${processedData}`);
      resolve("모든 작업 완료!");
    }, 800);
  });
}

// Promise 체이닝
fetchData("api/users")
  .then((data) => processData(data)) // 첫 번째 Promise의 결과가 다음 Promise의 인자로 전달
  .then((processedData) => saveData(processedData)) // 두 번째 Promise의 결과가 다음 Promise의 인자로 전달
  .then((finalMessage) => {
    console.log(finalMessage); // '모든 작업 완료!'
  })
  .catch((error) => {
    console.error("체인 중 오류 발생:", error);
  });

console.log("비동기 체인 시작...");

Promise 체이닝은 콜백 지옥을 해결하고, 비동기 코드의 흐름을 동기 코드처럼 순차적으로 이해하기 쉽게 만듭니다.

오류 처리: Promise 체인에서 발생하는 모든 오류는 가장 가까운 .catch() 블록에서 처리할 수 있습니다. 이는 여러 비동기 단계 중 어느 곳에서든 오류가 발생했을 때 중앙에서 오류를 관리할 수 있게 해줍니다.

function mightFailFetch(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const shouldFail = Math.random() > 0.5;
      if (shouldFail) {
        reject("네트워크 오류 발생!");
      } else {
        resolve("데이터 성공적으로 가져옴.");
      }
    }, 500);
  });
}

mightFailFetch()
  .then((data) => {
    console.log("데이터:", data);
    return Promise.resolve("다음 처리"); // 성공적으로 다음 .then()으로 전달
  })
  .then((nextData) => {
    console.log("다음 처리:", nextData);
    // return Promise.reject("처리 중 의도적인 오류!"); // 여기서 오류를 발생시킬 수도 있습니다.
  })
  .catch((error) => {
    console.error("오류 발생:", error); // 체인 중 발생한 모든 오류를 여기서 처리
  })
  .finally(() => { // Promise가 성공하든 실패하든 항상 실행
    console.log("작업 종료 (finally 블록)");
  });

finally() 블록은 Promise의 성공 여부와 관계없이 항상 실행되어, 리소스 정리와 같은 작업을 수행하는 데 유용합니다.


async/await

async/await은 Promise를 기반으로 비동기 코드를 작성하는 훨씬 더 간결하고 읽기 쉬운 문법입니다. async 함수는 항상 Promise를 반환하며, await 키워드는 async 함수 내에서만 사용할 수 있습니다. await은 Promise가 Fulfilled 또는 Rejected 상태가 될 때까지 함수의 실행을 일시 중지합니다.

기본 구조
// async 함수는 항상 Promise를 반환합니다.
async function fetchUserData(): Promise<string> {
  console.log("[1] 사용자 데이터 가져오기 시작...");
  // await은 Promise가 완료될 때까지 기다립니다.
  const response = await new Promise<string>((resolve) => {
    setTimeout(() => {
      resolve("사용자 정보: Alice");
    }, 1500);
  });

  console.log("[2] 사용자 데이터 가져오기 완료:", response);
  return response;
}

async function processAndSaveUserData(): Promise<void> {
  try {
    const userData = await fetchUserData(); // fetchUserData가 완료될 때까지 기다림
    console.log("[3] 데이터 처리 중...");
    await new Promise<void>((resolve) => setTimeout(resolve, 1000)); // 처리 시간 시뮬레이션
    console.log(`[4] ${userData} 처리 완료.`);

    console.log("[5] 데이터 저장 중...");
    await new Promise<void>((resolve) => setTimeout(resolve, 800)); // 저장 시간 시뮬레이션
    console.log(`[6] ${userData} 저장 완료.`);
  } catch (error) {
    console.error("오류 발생:", error); // try-catch로 오류 처리
  } finally {
    console.log("[7] 모든 작업 완료 (async/await finally)");
  }
}

console.log("비동기 함수 호출 시작...");
processAndSaveUserData(); // async 함수를 호출하면 즉시 Promise가 반환됩니다.
console.log("비동기 함수 호출 완료 (실행은 계속됩니다)...");

async/await를 사용하면 비동기 코드가 마치 동기 코드처럼 위에서 아래로 순차적으로 실행되는 것처럼 보입니다. 이는 복잡한 비동기 로직의 가독성을 획기적으로 향상시킵니다.

오류 처리: async/await에서 오류는 try...catch 블록을 사용하여 동기 코드와 동일하게 처리할 수 있습니다. await 표현식에서 Promise가 Rejected 상태가 되면, 해당 오류는 catch 블록으로 전달됩니다.

async function riskyOperation(): Promise<string> {
  const willFail = Math.random() > 0.5;
  if (willFail) {
    throw new Error("위험한 작업 실패!"); // async 함수에서 throw는 Promise.reject()와 동일
  }
  return "위험한 작업 성공!";
}

async function performRiskyTask(): Promise<void> {
  try {
    console.log("위험한 작업 시작...");
    const result = await riskyOperation(); // 실패하면 여기서 catch 블록으로 점프
    console.log("결과:", result);
  } catch (error: any) { // 'any' 대신 'unknown'을 사용하는 것이 더 안전합니다 (TS 4.4+).
    console.error("작업 중 오류:", error.message);
  } finally {
    console.log("위험한 작업 시도 종료.");
  }
}

performRiskyTask();
performRiskyTask(); // 여러 번 실행하여 성공/실패 확인

async 함수는 항상 Promise를 반환: async 함수가 명시적으로 Promise를 반환하지 않더라도, 항상 암묵적으로 Promise를 반환합니다.

async function greet(name: string): Promise<string> {
  return `Hello, ${name}!`; // Promise.resolve('Hello, Alice!') 와 동일
}

greet("Alice").then(message => console.log(message)); // Hello, Alice!

async function throwError(): Promise<never> { // 'never'는 이 함수가 값을 반환하지 않고 항상 예외를 던짐을 의미
  throw new Error("Something went wrong!"); // Promise.reject(new Error("...")) 와 동일
}

throwError().catch(error => console.error(error.message)); // Something went wrong!

Promise와 async/await의 타입스크립트 적용

타입스크립트는 Promise와 async/await의 타입 추론을 매우 잘 지원합니다.

  • Promise 타입: Promise<T> 형태로 Promise가 성공적으로 이행될 때 반환할 값의 타입을 명시합니다.
    interface User {
      id: number;
      name: string;
    }
    
    function getUserById(id: number): Promise<User> {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({ id, name: `User-${id}` });
        }, 500);
      });
    }
    
    // TypeScript는 then 블록 내의 user가 User 타입임을 자동으로 추론합니다.
    getUserById(1)
      .then(user => {
        console.log(user.name); // 자동 완성 지원
        // console.log(user.email); // Error: 'email' 속성이 'User' 형식에 없습니다.
      });
  • async 함수 반환 타입: async 함수는 항상 Promise<T> 타입을 반환하므로, 명시적으로 반환 타입을 Promise<T>로 선언하는 것이 좋습니다. 그렇지 않으면 TypeScript가 함수의 실제 반환 값(T)을 기반으로 Promise<T>를 추론합니다.
    async function fetchProduct(productId: number): Promise<{ id: number; name: string }> {
      const response = await fetch(`/api/products/${productId}`);
      const data = await response.json();
      return data; // data는 { id: number; name: string } 타입으로 추론됨
    }
    
    async function displayProduct(id: number): Promise<void> { // Promise<void> 반환
      try {
        const product = await fetchProduct(id); // product는 { id: number; name: string } 타입
        console.log(`Product: ${product.name}`);
      } catch (error) {
        console.error("Failed to fetch product:", error);
      }
    }
    
    displayProduct(123);
  • await의 타입 추론: await 키워드 뒤에 오는 Promise의 제네릭 타입은 await 표현식의 결과 타입으로 정확하게 추론됩니다.

비동기 작업의 병렬 처리: Promise.all

여러 비동기 작업을 동시에 시작하고, 모든 작업이 완료될 때까지 기다려야 할 때 Promise.all을 사용합니다. 하나라도 실패하면 전체 Promise.all이 Rejected 상태가 됩니다.

async function fetchPost(id: number): Promise<any> {
  console.log(`Fetching post ${id}...`);
  return new Promise(resolve => setTimeout(() => resolve({ id, title: `Post ${id} Title` }), 1000));
}

async function fetchComments(postId: number): Promise<any> {
  console.log(`Fetching comments for post ${postId}...`);
  return new Promise(resolve => setTimeout(() => resolve([{ id: 1, text: "Comment 1" }, { id: 2, text: "Comment 2" }]), 1500));
}

async function loadPostAndComments(postId: number): Promise<void> {
  try {
    console.log("Loading post and comments concurrently...");
    // 두 Promise를 동시에 시작하고 모두 완료될 때까지 기다립니다.
    const [post, comments] = await Promise.all([
      fetchPost(postId),
      fetchComments(postId)
    ]);

    console.log("\n--- All data loaded ---");
    console.log("Post:", post);
    console.log("Comments:", comments);
  } catch (error) {
    console.error("Error loading data:", error);
  }
}

loadPostAndComments(1);

Promise.all 외에도 다음과 같은 유틸리티 메서드들이 있습니다.

  • Promise.race: 여러 Promise 중 가장 먼저 완료되는 Promise의 결과(또는 오류)를 반환합니다.
  • Promise.allSettled (ES2020): 모든 Promise가 성공하거나 실패하더라도 기다리며, 각각의 결과를 객체 배열로 반환합니다. (일부 성공, 일부 실패인 경우 유용)
  • Promise.any (ES2021): 여러 Promise 중 가장 먼저 성공하는 Promise의 결과를 반환합니다. 모든 Promise가 실패해야만 Rejected 상태가 됩니다.

Promise와 async/await은 자바스크립트 및 타입스크립트에서 비동기 프로그래밍을 다루는 현대 표준 방식입니다.

Promise는 비동기 작업의 결과를 추상화하고 체이닝으로 콜백 지옥을 완화하며, async/await은 Promise 기반 코드를 더 직관적이고 동기 코드처럼 읽히게 만들어 가독성과 유지보수성을 높입니다.

타입스크립트는 이러한 비동기 패턴에 타입 안전성을 더해 개발자가 더 견고한 애플리케이션을 만들 수 있도록 돕습니다.

목차