비동기 프로그래밍
비동기 프로그래밍 탄생 배경
- CPU와 I/O 속도의 차이
- CPU는 매우 빠르게 연산을 수행하지만, 디스크, 네트워크, DB 등 I/O 작업은 상대적으로 느림.
- 동기(Synchronous) 방식으로 실행하면 I/O 대기 시간 동안 CPU가 낭비됨.
- 멀티태스킹과 병렬 처리의 필요성
- 하나의 요청이 완료될 때까지 기다리면 다른 작업을 동시에 처리할 수 없음.
- 여러 작업을 동시에 실행하기 위해 스레드 기반의 멀티태스킹이 등장.
- 하지만 스레드 생성 비용이 높고, 컨텍스트 스위칭이 성능 저하를 초래함.
- 이벤트 기반 & 논블로킹 방식 등장
- 효율적인 리소스 사용을 위해 이벤트 루프(Event Loop)와 콜백 기반의 비동기 처리가 개발됨.
- 대표적으로 Node.js, JavaScript Promise, Java의 CompletableFuture, Python의 asyncio 등이 있음.
- 이를 통해 CPU는 블로킹 없이 다른 작업을 수행하며 I/O 작업을 기다릴 수 있음.
- 현대 소프트웨어에서의 필수 요소
- 네트워크 요청, 데이터베이스 조회, 파일 입출력 등 비효율적인 블로킹을 제거.
- 마이크로서비스, 실시간 데이터 처리, 대규모 시스템(예: 웹 서버, 메시지 큐)에서 비동기 방식이 필수.
프로미스 패턴
- 비동기 코드에서 콜백 지옥(Callback Hell) 문제 해결
- 기존에는 콜백 함수를 중첩하여 사용 → 코드 가독성이 떨어짐.
- Promise는 비동기 작업을 더 구조적으로 관리할 수 있도록 설계됨.
- Promise의 특징
- new Promise((resolve, reject) => { ... }) 형태로 생성.
- 3가지 상태:
- pending (대기 중) → 초기 상태.
- fulfilled (성공) → resolve() 호출 시.
- rejected (실패) → reject() 호출 시.
- .then() → 성공 시 실행할 콜백을 등록.
- .catch() → 실패 시 실행할 콜백을 등록.
- .finally() → 성공/실패와 관계없이 실행할 콜백을 등록.
// 1. Promise 생성
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5; // 랜덤 성공/실패 시뮬레이션
if (success) {
resolve("✅ 데이터 로딩 성공!");
} else {
reject("❌ 데이터 로딩 실패!");
}
}, 1000); // 1초 후 실행
});
}
// 2. Promise 사용
fetchData()
.then((result) => {
console.log(result); // 성공 시 실행
})
.then((result) => {
console.log(result); // 이렇게 체이닝을 걸어서 처리를 할 수 있다.
})
.catch((error) => {
console.error(error); // 실패 시 실행
})
.finally(() => {
console.log("🎯 작업 완료!"); // 성공/실패 여부와 관계없이 실행
});
프로미스 메모이제이션 예제 코드
// 📌 캐시 저장소 (키: URL, 값: Promise)
const cache = new Map();
// ✅ 메모이제이션된 fetch 함수
function fetchData(url) {
// 1. 캐시에 존재하면 기존 Promise 반환 (중복 요청 방지)
if (cache.has(url)) {
console.log("📌 캐시에서 데이터 반환:", url);
return cache.get(url);
}
// 2. 새로운 Promise 생성 및 저장
console.log("🔄 새로운 데이터 요청:", url);
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.3; // 70% 성공 확률
if (success) {
const data = `✅ 서버 응답: ${url}`;
cache.set(url, Promise.resolve(data)); // 성공한 데이터 캐싱
resolve(data);
} else {
reject("❌ 서버 요청 실패!");
}
}, 1000); // 1초 후 실행
});
cache.set(url, promise); // 요청 중인 Promise도 캐싱 (중복 요청 방지)
return promise;
}
// ✅ 메모이제이션된 API 호출
fetchData("https://api.example.com/data")
.then((data) => console.log(data))
.catch((error) => console.error(error));
// 1초 후 다시 같은 URL 요청 → 캐시에서 반환됨
setTimeout(() => {
fetchData("https://api.example.com/data")
.then((data) => console.log(data))
.catch((error) => console.error(error));
}, 1500);
// 2초 후 새로운 URL 요청 → 새로운 데이터 요청 발생
setTimeout(() => {
fetchData("https://api.example.com/other")
.then((data) => console.log(data))
.catch((error) => console.error(error));
}, 2000);
async/await의 탄생
- ES2017(ES8)에서 async/await가 도입되면서, 비동기 코드를 동기 코드처럼 작성할 수 있게 되었습니다.
- async/await는 내부적으로 Promise를 사용하지만, 코드 가독성과 유지보수성을 크게 향상시키는 방식입니다.
async/await 기본 개념
- async 함수
- 함수 선언 앞에 async 키워드를 붙이면, 해당 함수는 항상 Promise를 반환합니다.
- 함수 내부에서 return문으로 반환한 값은 자동으로 resolve되고,
- 함수 내부에서 예외(throw)가 발생하면 자동으로 reject됩니다.
async 함수 내부에서는 await 키워드를 사용할 수 있습니다.
- await 키워드
- await는 Promise가 처리될 때까지(fulfill 또는 reject) 기다리는 역할을 합니다.
- await 구문이 완료되면, 해당 Promise의 결과 값(resolve 값)을 반환받을 수 있습니다.
- await는 async 함수 내부에서만 사용 가능합니다. (전역 스코프나 일반 함수에서는 사용 불가능)
- 프로세스 흐름
- async 함수는 내부에 await가 있더라도 논블로킹(Non-blocking)으로 작동합니다.
- 한 줄씩 동기 코드처럼 보이지만, 실제로는 이벤트 루프에서 Promise 상태를 확인하며,
- 다른 작업(콜 스택)이 진행될 수 있게 해줍니다.
예시 코드
3.1 기본 사용 예시
// (1) Promise를 반환하는 함수
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("✅ 데이터 로딩 성공");
} else {
reject("❌ 데이터 로딩 실패");
}
}, 1000);
});
}
// (2) async/await 사용
async function main() {
try {
console.log("데이터 요청 중...");
const result = await fetchData(); // fetchData()의 resolve가 완료될 때까지 대기
console.log("결과:", result);
} catch (error) {
console.error("에러:", error);
} finally {
console.log("비동기 작업 완료!");
}
}
main();
동작 흐름
- main() 함수가 호출됨 → 내부에서 fetchData()를 호출.
- await fetchData() 구문에서 Promise가 이행(resolve)되거나 거부(reject)될 때까지 비동기로 기다림.
- try/catch 구문을 사용해 resolve 결과(result)를 받거나, reject 상황을 에러로 잡아냄.
- finally 블록은 성공/실패와 관계없이 마지막에 무조건 실행됨.
결과 예시:
데이터 요청 중...
결과: ✅ 데이터 로딩 성공
비동기 작업 완료!
또는
데이터 요청 중...
에러: ❌ 데이터 로딩 실패
비동기 작업 완료!
연속된 비동기 작업
async/await를 사용하면 서로 의존성이 있는 연속 비동기 작업을 마치 동기 코드처럼 순차적으로 표현할 수 있습니다.
async function processData() {
try {
const data1 = await fetchData1();
console.log("data1:", data1);
const data2 = await fetchData2(data1);
console.log("data2:", data2);
const finalResult = await fetchData3(data2);
console.log("finalResult:", finalResult);
} catch (error) {
console.error("에러 발생:", error);
}
}
processData();
fetchData2()는 data1의 결과를 이용해야 하므로,
await fetchData1()을 마친 후에야 호출할 수 있습니다.
async/await 덕분에 개발자가 동기 코드를 작성하듯 직관적으로 순서를 명시할 수 있습니다.
3.3 병렬 실행
- await는 직렬로 코드가 실행되기 때문에, 서로 의존하지 않는 비동기 작업이라면 동시에(병렬) 실행하는 것이 성능적으로 유리합니다.
직렬 실행 (비효율적)
// 서로 의존하지 않는 두 작업 dataA, dataB를 순차로 실행
async function getDataSerial() {
const dataA = await fetchDataA(); // 1초 소요
const dataB = await fetchDataB(); // 1초 소요
return { dataA, dataB }; // 총 2초 소요
}
병렬 실행 (효율적)
// 동시에 Promise 생성 후, 둘 다 완료되길 기다림
async function getDataParallel() {
const promiseA = fetchDataA(); // 1초
const promiseB = fetchDataB(); // 1초
// 두 Promise가 동시에 진행 (실제로는 1초 내에 모두 끝날 수 있음)
const [dataA, dataB] = await Promise.all([promiseA, promiseB]);
return { dataA, dataB };
}
Promise.all() 또는 Promise.allSettled() 등을 활용하면, 서로 의존 관계가 없는 비동기 작업을 동시에 시작하고, 그 결과를 한꺼번에 받아올 수 있습니다.
예외 처리
- try/catch
- await로 받은 Promise가 reject되면, 예외 처리가 발생(throw)한 것과 동일하게 동작합니다.
- try/catch 블록 안에서 .catch() 없이도 예외를 처리할 수 있습니다.
- 전역 에러 처리
- async 함수 밖으로 예외가 전파되면, 전역에서 처리해야 할 수 있습니다.
- Node.js 환경에선 process.on('unhandledRejection', callback)으로,
- 브라우저 환경에선 window.addEventListener('unhandledrejection', callback) 등으로 처리할 수 있습니다.
- .catch() 체인
- await 다음에 직접 .catch()를 사용할 수도 있지만, 주로 try/catch 구문이 많이 쓰입니다.
5. async/await와 이벤트 루프
- 이벤트 루프(Event Loop)
- JavaScript의 비동기 모델을 지원하는 핵심 메커니즘.
- 콜 스택(Call Stack), 태스크 큐(Task Queue), 마이크로태스크 큐(Microtask Queue) 등을 통해 동시성을 흉내냄.
- await 시 이벤트 루프 동작
- await promise를 만나면, 해당 async 함수의 실행이 일시 중단(suspended)됨.
- 그동안 이벤트 루프는 다른 태스크를 계속 처리할 수 있음.
- promise가 resolve 또는 reject 되면, 마이크로태스크 큐에 콜백이 들어가고, 이후에 함수 실행이 재개됩니다.
- 비블로킹(Non-blocking)
- await는 JS 메인 스레드를 전부 막는 게 아닌, 해당 함수의 실행 흐름만 중단하고,
- 다른 이벤트/콜백은 처리 가능하도록 비동기적인 흐름을 유지합니다.
주의사항 & 팁
- async 함수는 자동으로 Promise를 반환
return 값은 resolve 값이 되며, throw는 reject 처리가 됩니다.
- 외부 API 병렬 호출 시, 가급적 Promise.all() 사용
의존 관계가 없는 요청을 await로 직렬화하면 불필요한 성능 손실이 발생할 수 있음.
- 오래 걸리는 동작 중 예외 처리
네트워크 요청, 파일 읽기/쓰기 중 예외가 발생할 경우,
try/catch 안에서 reject 처리하는 로직을 주의 깊게 작성해야 합니다.
- async/await 대신 .then()을 사용할 수 있음
async/await가 가독성을 높여주지만, 기존 Promise 체인 형태로도 동일한 기능을 구현할 수 있습니다.
규모가 큰 프로젝트에선 혼합 사용도 가능하나, 일관성 있게 작성하는 편이 좋습니다.
- 오류/예외가 나면 디버깅
async 함수 내부에서 throw된 에러는 호출부에서 await 시점에 받을 수 있습니다.
디버깅 시 console.log 혹은 브라우저 개발자도구/Node 디버거를 활용하세요.
- forEach 내부에서 await 사용 X
JavaScript의 Array.prototype.forEach()는 await를 제대로 지원하지 않습니다.
대신 for..of를 사용하면 순차적인 비동기 처리가 가능합니다.
JS의 비동기 실행 원리
JS는 콜스택, 마이크로태스크 큐, 매크로태스크큐 라는 3가지 주요구조를 사용해서 실행순서를 제어한다.
콜스택
- JavaScript의 동기 코드(synchronous code)를 위에서 아래로 순차적으로 실행하는 공간입니다.
- LIFO(Last In, First Out) 구조를 가집니다.
- 함수가 호출되면 스택에 push되고, 실행이 끝나면 pop됩니다.
마이크로태스크 큐
- Promise의 .then(), .catch(), .finally()와 process.nextTick() 등의 비동기 작업이 대기하는 공간입니다.
- 콜 스택이 비워지면, 이벤트 루프는 마이크로태스크 큐를 먼저 확인하고 실행합니다.
- FIFO(First In, First Out) 구조를 가집니다.
특징
- 마이크로태스크는 콜 스택이 비워질 때 실행됩니다.
- Promise.then(), queueMicrotask(), process.nextTick()이 여기에 등록됩니다.
- 마이크로태스크 큐는 매크로태스크 큐보다 항상 먼저 실행됩니다.
매크로태스크 큐
- setTimeout(), setInterval(), setImmediate(), I/O 이벤트 등 비동기 타이머 및 이벤트 핸들러가 대기하는 공간입니다.
- 콜 스택이 비어있고 마이크로태스크 큐의 모든 작업이 끝난 후에 실행됩니다.
- FIFO(First In, First Out) 구조를 가집니다.
특징
- 매크로태스크 큐는 마이크로태스크 큐 이후에 실행됩니다.
- setTimeout(fn, 0)도 최소한 1ms 이후에 실행됩니다.
- 이벤트 루프는 마이크로태스크 큐 → 매크로태스크 큐 순서로 작업을 실행합니다.
이벤트 루프(Event Loop) 동작 흐름
- 콜 스택 실행 (동기 코드 처리)
- 마이크로태스크 큐 실행 (Promise 콜백 등)
- 매크로태스크 큐 실행 (setTimeout, setInterval 등)
- 다시 1단계로 돌아감
- 실행 우선순위
- 콜 스택 > 마이크로태스크 큐 > 매크로태스크 큐
비동기 처리 문제
console.log('A');
async function foo() {
console.log('B');
await Promise.resolve('C').then(value => {
console.log(value);
});
console.log('D');
}
foo().then(() => {
console.log('E');
});
new Promise((resolve, reject) => {
console.log('F');
setTimeout(() => {
console.log('G');
resolve('H');
}, 0);
}).then(val => {
console.log(val);
});
Promise.resolve().then(() => console.log('I'));
console.log('J');
- 정답
A → B → F → J → C → I → D → E → G → H
- 해석
- 메인 스레드(동기 코드): Call Stack
- console.log('A') → A 출력
- foo() 호출 → 함수 실행 시작
- console.log('B') → B 출력
- await Promise.resolve('C') → Promise는 바로 이행(resolve)되지만, .then() 콜백은 마이크로태스크 큐로 이동
- 다음 코드로 진행
- new Promise 생성 → console.log('F') 출력
- setTimeout 등록 → setTimeout의 콜백(G, H)은 매크로태스크 큐에 등록
- Promise.resolve().then() 실행 → .then() 콜백은 마이크로태스크 큐로 이동
- console.log('J') → J 출력
- 이시점 출력
- A B F J
- 마이크로태스크 큐(Microtask Queue) 실행
- Promise.resolve('C').then()
- Promise.resolve().then(() => console.log('I'))
- await 구문 이후 코드(console.log('D'))
- foo()의 .then(() => console.log('E'))
- 실행 순서:
- console.log('C') → C 출력
- console.log('I') → I 출력
- console.log('D') (await 뒤의 코드) → D 출력
- **console.log('E') (foo의 .then) → E 출력
- 이시점 출력
- A B F J C I D E
- 매크로태스크 큐(MacroTask Queue) 실행
- console.log('G') → G 출력
- resolve('H') 호출 → then() 콜백이 마이크로태스크 큐로 이동
- 마이크로태스크 큐에서 console.log('H') 실행 → H 출력
- 메인 스레드(동기 코드): Call Stack
'JavaScript' 카테고리의 다른 글
자바스크립트 기본개념(기본 문법, 내장객체,이벤트 리스너,호이스팅,콜백, 비동기 프로그래밍) (0) | 2021.07.13 |
---|