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를 훨씬 더 직관적이고 동기적인 코드처럼 보이게 하여 비동기 로직의 가독성과 유지보수성을 극대화합니다. 타입스크립트는 이러한 비동기 패턴에 강력한 타입 안전성을 부여하여 개발자가 더 견고한 애플리케이션을 만들 수 있도록 돕습니다.