안동민 개발노트 아이콘

안동민 개발노트

11장 : 비동기 프로그래밍

Promise와 async/await

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

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

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

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

타입스크립트는 자바스크립트의 비동기 기능을 타입 검사와 함께 사용할 수 있게 해주며, Promise 결과 타입과 오류 경로를 더 명확히 표현하도록 돕습니다.


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를 사용하면 Promise 체인보다 실행 순서와 예외 처리 위치를 한 블록 안에서 읽기 쉽습니다. 다만 병렬 실행이 필요한 작업까지 순차 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 상태를 다루지만, 오류를 모으는 위치와 코드를 읽는 방식이 다릅니다. 아래 비교 보드는 .then(), .catch(), .finally() 흐름과 await, try/catch/finally 흐름을 나란히 놓고, 타입스크립트에서 unknown 오류를 좁히는 습관까지 함께 정리합니다.

이 비교를 기억해 두면 체인 끝에 .catch()를 둘지, await가 있는 구간을 try/catch/finally로 감쌀지 판단하기 쉽습니다.


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 도구는 이름이 비슷해도 실패 처리와 반환 값의 의미가 다릅니다. 아래 보드처럼 "전부 필요", "가장 빠른 값", "모든 결과 기록", "가장 빠른 성공" 중 무엇을 원하는지 먼저 정하면 API 선택이 단순해집니다.

비동기 코드를 타입 관점에서 읽을 때는 Promise<T>가 어디에서 만들어지고, await 이후 어떤 타입으로 풀리며, 실패 경로의 오류 값이 어떻게 좁혀지는지 함께 추적해야 합니다.


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

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

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

아래 다이어그램은 Promise<T>가 만들어지고 await으로 풀리며 병렬 조합으로 이어지는 타입 추적 흐름을 정리합니다.

이어지는 다이어그램은 Promise 체인과 async/await가 책임 경계와 실행 흐름을 어떻게 바꾸는지 정리합니다.

정리할 때는 단일 결과, 병렬 조합, 오류 경로, 취소 경계를 나누어 보고, 각 경계에서 반환 타입과 실패 처리 방식이 드러나는지 확인하면 됩니다.