비동기 프로그래밍
우리는 6장 "자바스크립트 심화 I"에서 자바스크립트의 내부 동작 원리(this
, 실행 컨텍스트, 프로토타입), 그리고 코드를 구성하는 사고방식(객체 지향, 함수형 프로그래밍)을 심도 있게 학습했습니다. 특히 마지막 장에서는 비동기 처리의 기본인 콜백 함수의 개념과 콜백 헬이라는 문제점을 살펴보았습니다.
현대 웹 애플리케이션은 사용자 경험을 저해하지 않으면서 동시에 서버 통신, 파일 읽기, 타이머 설정 등 시간이 오래 걸리는 작업들을 처리해야 합니다. 자바스크립트는 '싱글 스레드(Single Thread)' 언어이므로, 이러한 작업들이 메인 스레드를Blocking(막아버려) 사용자 인터페이스가 멈추는 것을 방지하기 위해 비동기적으로 동작합니다.
7장 "자바스크립트 심화 II"에서는 자바스크립트의 핵심이자 웹 애플리케이션 개발의 꽃이라고 할 수 있는 비동기 프로그래밍에 대해 집중적으로 다룹니다. 특히 콜백 헬 문제를 해결하고 비동기 코드를 더욱 직관적으로 작성할 수 있게 해주는 Promise(프로미스) 와 async/await(어싱크/어웨이트) 문법을 깊이 있게 탐구하며, 이를 통해 여러분의 자바스크립트 스킬을 한 단계 더 끌어올릴 것입니다.
자바스크립트는 기본적으로 한 번에 하나의 작업만 처리할 수 있는 싱글 스레드(Single Thread) 언어입니다. 만약 시간이 오래 걸리는 작업(예: 서버에서 데이터 가져오기)이 동기적으로(순차적으로) 처리된다면, 그 작업이 완료될 때까지 나머지 모든 코드가 실행을 멈추고 웹 페이지는 '응답 없음' 상태가 될 것입니다.
이러한 문제를 해결하기 위해 자바스크립트는 비동기(Asynchronous) 방식으로 동작합니다. 비동기 작업은 시간이 오래 걸리는 작업을 백그라운드에 맡겨두고, 메인 스레드는 다음 코드를 계속 실행합니다. 그리고 백그라운드 작업이 완료되면, 미리 정의된 콜백 함수를 실행하여 결과를 처리합니다.
이전 장에서 콜백 함수를 통해 비동기 작업을 다루는 기초를 배웠지만, 복잡한 비동기 로직에서는 콜백 헬이라는 문제가 발생했습니다. 이제 이 문제를 깔끔하게 해결해주는 Promise
와 async/await
를 알아보겠습니다.
Promise (프로미스): 비동기 작업의 미래 값
Promise(프로미스) 는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 비동기 작업이 "성공적으로 완료될 것인지", 아니면 "실패할 것인지"에 대한 '약속' 또는 '미래의 값'을 표현합니다. 콜백 헬을 해결하고 비동기 코드를 더 선형적이고 가독성 좋게 작성할 수 있도록 도입되었습니다.
Promise의 3가지 상태
Promise 객체는 다음 세 가지 상태 중 하나를 가집니다.
pending
(대기): 비동기 작업이 아직 완료되지 않은 초기 상태.fulfilled
(이행): 비동기 작업이 성공적으로 완료되어 결과 값을 반환한 상태.rejected
(거부): 비동기 작업이 실패하여 오류 값을 반환한 상태.
pending
상태에서 fulfilled
또는 rejected
상태로 한 번만 전이(settled)되며, 일단 settled
상태가 되면 더 이상 상태가 변하지 않습니다.
Promise 생성하기
Promise 객체는 new Promise()
생성자를 통해 생성합니다. 생성자는 resolve
와 reject
두 개의 인자를 받는 executor
함수를 받습니다.
resolve(value)
: 비동기 작업이 성공했을 때 호출하여 Promise를fulfilled
상태로 만들고 결과value
를 전달합니다.reject(error)
: 비동기 작업이 실패했을 때 호출하여 Promise를rejected
상태로 만들고error
를 전달합니다.
function fetchData() {
return new Promise((resolve, reject) => {
// 비동기 작업 시뮬레이션 (예: 서버에서 데이터 가져오기)
const success = Math.random() > 0.5; // 50% 확률로 성공/실패
setTimeout(() => {
if (success) {
resolve("데이터를 성공적으로 가져왔습니다!"); // 성공 시 resolve 호출
} else {
reject("데이터 가져오기 실패!"); // 실패 시 reject 호출
}
}, 1000); // 1초 뒤에 결과 결정
});
}
// Promise 사용 예시
fetchData()
.then(message => { // Promise가 fulfilled(이행) 상태가 되면 .then() 블록이 실행됩니다.
console.log("성공:", message);
})
.catch(error => { // Promise가 rejected(거부) 상태가 되면 .catch() 블록이 실행됩니다.
console.error("오류:", error);
})
.finally(() => { // Promise의 성공/실패 여부와 상관없이 항상 실행됩니다.
console.log("작업 완료!");
});
console.log("Promise 작업 시작 (비동기)"); // 이 메시지가 먼저 출력됩니다.
Promise 체이닝 (.then()
)
Promise의 가장 큰 장점 중 하나는 .then()
메서드를 통해 여러 비동기 작업을 순차적으로 연결(체이닝)할 수 있다는 것입니다. 각 .then()
은 이전 Promise의 결과 값을 받아 다음 작업을 수행합니다.
function step1Promise() {
return new Promise(resolve => {
setTimeout(() => {
console.log("Step 1 완료");
resolve("Step1 결과"); // 다음 .then()으로 전달될 값
}, 1000);
});
}
function step2Promise(prevResult) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 2 완료: 이전 결과는 ${prevResult}`);
resolve("Step2 결과");
}, 1000);
});
}
function step3Promise(prevResult) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Step 3 완료: 이전 결과는 ${prevResult}`);
resolve("모든 단계 완료!");
}, 1000);
});
}
// Promise 체이닝으로 콜백 헬 해결
step1Promise()
.then(result1 => step2Promise(result1)) // Step1 결과 -> Step2로 전달
.then(result2 => step3Promise(result2)) // Step2 결과 -> Step3로 전달
.then(finalResult => {
console.log(finalResult); // 최종 결과 출력
})
.catch(error => { // 체인 중 어느 한 곳에서라도 오류가 발생하면 catch로 이동
console.error("오류 발생:", error);
});
console.log("Promise 체인 시작!"); // 이 메시지가 가장 먼저 출력됩니다.
.then()
메서드는 항상 새로운 Promise를 반환하므로, 이를 통해 계속해서 .then()
을 연결할 수 있습니다. 만약 .then()
내부에서 Promise를 반환하면, 다음 .then()
은 그 Promise가 settled
될 때까지 기다립니다.
Promise.all(), Promise.race()
여러 Promise를 동시에 처리해야 할 때 유용한 정적 메서드들입니다.
-
Promise.all([promise1, promise2, ...])
- 모든 Promise가 성공적으로 완료될 때까지 기다립니다.
- 모든 Promise가
fulfilled
되면, 각 Promise의 결과 값을 배열로 묶어 반환합니다. - 하나라도
rejected
되면, 즉시 해당 오류를 반환하고 나머지 Promise는 무시됩니다. (fail-fast)
const p1 = new Promise(resolve => setTimeout(() => resolve("P1 완료"), 3000)); const p2 = new Promise(resolve => setTimeout(() => resolve("P2 완료"), 1000)); const p3 = new Promise(resolve => setTimeout(() => resolve("P3 완료"), 2000)); Promise.all([p1, p2, p3]) .then(results => { console.log("모두 완료:", results); // 결과: ["P1 완료", "P2 완료", "P3 완료"] (3초 후) }) .catch(error => { console.error("하나라도 실패:", error); }); // 실패 예시 const p4 = Promise.reject("P4 실패"); Promise.all([p1, p4, p3]) // p4가 즉시 실패하므로, "P4 실패"가 출력 .then(results => console.log("모두 완료:", results)) .catch(error => console.error("하나라도 실패:", error)); // 결과: 하나라도 실패: P4 실패 (즉시)
-
Promise.race([promise1, promise2, ...])
- 가장 먼저
fulfilled
또는rejected
되는 Promise의 결과(또는 오류)를 반환합니다. - 말 그대로 여러 경주마 중 가장 먼저 결승선에 도착하는 말을 따릅니다.
const pA = new Promise(resolve => setTimeout(() => resolve("A 완료"), 3000)); const pB = new Promise(resolve => setTimeout(() => resolve("B 완료"), 1000)); const pC = new Promise((resolve, reject) => setTimeout(() => reject("C 실패"), 2000)); Promise.race([pA, pB, pC]) .then(result => { console.log("가장 빠른 결과:", result); // 결과: B 완료 (1초 후) }) .catch(error => { console.error("가장 빠른 오류:", error); }); // 실패하는 Promise가 더 빠르면 catch로 이동 Promise.race([pA, pC, pB]) .then(result => console.log("가장 빠른 결과:", result)) .catch(error => console.error("가장 빠른 오류:", error)); // 결과: 가장 빠른 오류: C 실패 (2초 후)
- 가장 먼저
async/await: 비동기 코드를 동기 코드처럼
async/await
는 ES2017(ES8)에서 도입된 문법으로, Promise를 기반으로 하면서도 비동기 코드를 마치 동기 코드처럼 읽고 쓸 수 있게 해주는 강력한 기능입니다. 콜백 헬뿐만 아니라 Promise 체이닝의 복잡성마저도 크게 줄여줍니다.
async
함수
- 함수 앞에
async
키워드를 붙이면, 해당 함수는 항상 Promise를 반환합니다. async
함수 내부에서 일반 값을return
하면, 그 값은resolve
된 Promise로 감싸져 반환됩니다.async
함수 내부에서 오류를throw
하면, 그 오류는reject
된 Promise로 감싸져 반환됩니다.
async function getGreeting() {
return "안녕하세요!"; // Promise.resolve("안녕하세요!")와 동일
}
getGreeting().then(message => console.log(message)); // 결과: 안녕하세요!
async function getError() {
throw new Error("오류 발생!"); // Promise.reject(new Error("오류 발생!"))와 동일
}
getError().catch(error => console.error(error.message)); // 결과: 오류 발생!
await
키워드
await
키워드는async
함수 내부에서만 사용할 수 있습니다.await
은 Promise 앞에 붙여서 사용하며, 해당 Promise가settled
될 때까지 함수의 실행을 일시 중지시킵니다.- Promise가
fulfilled
되면,await
표현식은 Promise의 결과 값을 반환합니다. - Promise가
rejected
되면,await
은 해당 오류를 던지며, 이는try...catch
블록으로 잡을 수 있습니다.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function processOrder() {
console.log("주문 처리 시작...");
try {
// 1. 첫 번째 비동기 작업 대기
await delay(1000); // 1초 대기
console.log("Step 1: 주문 접수 완료");
// 2. 두 번째 비동기 작업 대기
await delay(1500); // 1.5초 대기
console.log("Step 2: 상품 준비 완료");
// 3. 세 번째 비동기 작업 대기 (오류 발생 가능성)
const randomNumber = Math.random();
if (randomNumber < 0.3) {
throw new Error("배송 중 문제 발생!");
}
await delay(1000); // 1초 대기
console.log("Step 3: 배송 시작");
// 최종 결과 반환
return "주문이 성공적으로 완료되었습니다!";
} catch (error) {
console.error("주문 처리 중 오류 발생:", error.message);
throw error; // 오류를 외부로 다시 던져서 .catch()로 처리되도록 함
} finally {
console.log("주문 처리 과정 종료.");
}
}
// async 함수 호출 및 결과 처리
processOrder()
.then(message => console.log("최종 결과:", message))
.catch(err => console.error("최종 실패:", err.message));
console.log("프로세스 시작 명령 (비동기)"); // 이 메시지가 가장 먼저 출력됩니다.
async/await
를 사용하면 비동기 흐름을 마치 위에서 아래로 읽는 동기 코드처럼 작성할 수 있어, 코드의 가독성과 유지보수성이 크게 향상됩니다. try...catch
블록을 사용하여 비동기 작업에서 발생하는 오류를 쉽게 처리할 수 있다는 장점도 있습니다.
마무리하며
이번 장에서는 자바스크립트의 비동기 프로그래밍을 위한 핵심 개념인 Promise와 async/await에 대해 깊이 있게 학습했습니다.
여러분은 Promise가 비동기 작업의 미래 값을 표현하는 객체이며, pending
, fulfilled
, rejected
의 세 가지 상태를 통해 비동기 작업의 성공과 실패를 관리한다는 것을 배웠습니다. 또한, .then()
, .catch()
, .finally()
메서드를 사용하여 Promise의 결과와 오류를 처리하고, 여러 Promise를 순차적으로 연결하는 체이닝 기법과 동시에 처리하는 Promise.all()
, Promise.race()
의 유용성도 이해했습니다.
나아가, async/await
문법을 통해 Promise 기반의 비동기 코드를 마치 동기 코드처럼 직관적으로 작성할 수 있게 되었고, try...catch
를 이용한 오류 처리 방법도 살펴보았습니다.
이러한 비동기 프로그래밍 기법들은 현대 웹 애플리케이션에서 사용자 경험을 향상시키고 복잡한 작업을 효율적으로 처리하는 데 필수적입니다. 이제 여러분은 콜백 헬의 늪에서 벗어나, 더욱 견고하고 가독성 좋은 비동기 코드를 작성할 수 있는 강력한 도구를 손에 넣었습니다. 다양한 비동기 API(Fetch API를 이용한 네트워크 요청 등)를 직접 사용해보면서 Promise
와 async/await
의 진가를 경험해보시길 바랍니다.