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 기반 코드를 더 직관적이고 동기 코드처럼 읽히게 만들어 가독성과 유지보수성을 높입니다.
타입스크립트는 이러한 비동기 패턴에 타입 안전성을 더해 개발자가 더 견고한 애플리케이션을 만들 수 있도록 돕습니다.