비동기 프로그래밍
자바스크립트에서 동기 코드는 blocking 방식으로 실행된다. 즉, 코드가 순서대로 실행된다는 뜻이다. 코드에서 다음 줄은 현재 문장의 실행이 완료된 후에만 실행될 수 있다.
반면에 비동기 코드는 nonblocking 방식으로 실행된다.
자바스크립트 엔진은 현재 실행 중인 코드가 다른 작업을 기다리는 동안 백그라운드에서 해당 비동기 코드를 실행할 수 있다.
다음 작업을 할 때 비동기가 유용하다.
- 네트워크 요청
- 데이터베이스 읽기/쓰기
- 기타 I/O (입출력) 작업
비동기와 프로미스와 같은 기능을 사용하면 비동기 코드를 마치 동기 코드처럼 작동하도록 작성할 수 있어 코드의 가독성과 이해도를 높여준다.
배경: 콜백 지옥
콜백
함수가 끝난 후 다른 함수를 호출하는 방식
콜백 함수는 다른 함수에 인자로 전달되어, 그 다른 함수의 실행이 끝난 뒤에 실행되는 함수이다.
- 문제점 : 콜백 안에 콜백에 또 들어가는 “콜백 지옥”이 발생한다.
1-1. 기본 콜백 함수
function greet(name, callback) {
console.log("안녕, " + name);
callback();
}
function sayBye() { // 콜백 함수
console.log("잘 가~");
}
greet("철수", sayBye);
// 안녕, 철수
// 잘 가~
sayBye는 greet 함수가 끝나고 실행되는 콜백 함수이다.
callback()은 sayBye()를 실행시킨 것이다.
1-2. 비동기에서 콜백 함수
function fetchData(callback) {
setTimeout(() => { // 비동기 작업
console.log("데이터를 가져왔습니다");
callback();
}, 5000);
}
fetchData(() => {
console.log("이제 데이터를 처리합니다");
});
// (5초 후)
// 데이터를 가져왔습니다.
// 이제 데이터를 처리합니다.
여기서 setTimeout()은 비동기 작업이고, 작업이 끝난 뒤 콜백 함수가 실행된다.
콜백의 문제점 : 콜백 지옥
콜백을 계속 중첩하면 코드가 점점 복잡해지고 가독성이 나빠진다.
예제 로그인 코드는 사용자 로그인 → 유저 정보 가져오기 → UI 업데이트 순으로 함수를 호출한다.
function loginUser(id, callback) {
setTimeout(() => {
console.log(`사용자 ${id} 로그인`);
callback(id);
}, 1000);
}
function getUserProfile(id, callback) {
setTimeout(() => {
console.log(`${id}의 프로필 정보 가져오기`);
callback({ id: id, name: "홍길동" });
}, 1000);
}
function updateUI(profile, callback) {
setTimeout(() => {
console.log(`UI 업데이트: ${profile.name}님 환영합니다!`);
callback();
}, 1000);
}
// 콜백 지옥
loginUser("user01", (id) => {
getUserProfile(id, (profile) => {
updateUI(profile, () => {
console.log("모든 작업 완료!🎉");
});
});
});
작동 순서
- loginUser :
- 1초 후,
- ("사용자 user01 로그인"),
- 콜백 함수로 getUserProfile 호출
- getUserProfile :
- 1초 후,
- ("user01의 프로필 정보 가져오기"),
- 콜백 함수로 updateUI 호출
- updateUI :
- 1초 후,
- ("UI 업데이트: 홍길동님 환영합니다!"),
- 콜백 함수로 ("모든 작업 완료!🎉") 실행
출력 결과
// (1초 후)
사용자 user01 로그인
// (2초 후)
user01의 프로필 정보 가져오기
// (3초 후)
UI 업데이트: 홍길동님 환영합니다!
모든 작업 완료!🎉
프로미스 패턴
미래 시점에 비동기 작업의 성공/실패를 처리한다.
.then() 과 .catch() 를 사용한다.
- 장점 : 콜백 지옥을 피할 수 있으며 콜백보다 가독성이 높고, 에러 처리도 쉽다.
프로미스는 비동기 작업의 결과를 나타내는 객체로, **대기(pending), 완료(fulfilled), 거부(rejected)**의 세가지 상태를 가질 수 있다.
프로미스는 Promise 생성자를 사용하여 만들 수 있고, 이 생성자는 함수를 인수로 받는다. 인수로 받는 그 함수는 또 resolve와 reject라는 두 인수를 받는다.
- resolve : 비동기 작업이 성공적으로 완료되었을 때 호출됨
- reject : 비동기 작업이 실패했을 때 호출된다.
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) resolve("데이터 가져옴");
else reject("에러 발생");
}, 1000);
});
}
fetchData()
.then(result => {
console.log(result); // 성공 처리
})
.catch(error => {
console.error(error); // 실패 처리
});
fetchData() 함수는 요청 결과를 나타내는 Promise 객체를 반환한다. (이 예제에서는 아니지만 예를 들면 네트워크 요청의 결과)
요청이 성공하면 Promise는 응답을 완료(fulfilled)하여 데이터를 반환하고, 실패하면 에러와 함께 거부(rejected)된다.
→ Promise 객체의 .then 및 .catch 메서드를 통해 결과를 처리할 수 있다.
프로미스 체이닝
여러 개의 프로미스를 함께 연결하여 복잡한 비동기 로직을 만들 수 있다.
→ 여러 비동기 작업을 then()으로 연속적으로 연결해서 처리하는 방법으로, 콜백 지옥처럼 중첩하지 않고, 평평하고 읽기 쉬운 코드로 만들 수 있다.
⇒ 콜백 지옥을 해결하는 핵심 기술 !
// 프로미스 체이닝
asyncFunc()
.then(result1 => {
// 첫 번째 결과 처리
return nextAsync(result1);
})
.then(result2 => {
// 두 번째 결과 처리
return anotherAsync(result2);
})
.then(result3 => {
// 최종 결과 처리
})
.catch(error => {
// 에러 처리 (중간 어디에서든 발생 가능)
});
각 .then()은 앞선 .then()의 반환 값을 받아 처리하고,
에러는 .catch() 하나로 처리할 수 있다.
로그인 콜백 예제 해결
앞선 로그인 콜백 지옥 예시를 프로미스 체이닝으로 해결해보자.
loginUser("user01")
.then(getUserProfile)
.then(updateUI)
.then(() => {
console.log("모든 작업 완료!🎉");
})
.catch((err) => {
console.error("에러 발생:", err);
});
콜백 방식보다 훨씬 가독성이 좋아졌다.
프로미스 에러 처리
.catch 메서드로 프로미스 체인의 실행 중에 발생할 수 있는 에러를 처리한다.
프로미스 병렬 처리
여러 프로미스를 동시에 실행하여 더 빠르게 처리하는 방법이다.
하나씩 기다리지 않고 동시에 처리한다.
- ex> 사용자 목록 10명 각각의 데이터를 동시에 가져오기
- ex> API 3개를 동시에 요청하기
1. Promise.all([])
모든 프로미스가 성공해야 결과를 반환하고 하나라도 실패하면 에러이다.
const p1 = fetch("/user");
const p2 = fetch("/posts");
const p3 = fetch("/comments");
Promise.all([p1, p2, p3])
.then(([res1, res2, res3]) => {
console.log("모든 요청 완료!");
})
.catch(err => {
console.error("하나라도 실패함:", err);
});
p1, p2, p3 가 동시에 실행되고, 결과는 배열로 한번에 받는다.
2. Promise.allSettled([])
성공/실패에 무관하게 모든 프로미스가 끝날 때까지 기다린다.
→ 결과를 모두 확인하고 싶을 때 사용한다.
const tasks = [
Promise.resolve("성공"),
Promise.reject("실패"),
Promise.resolve("또 성공")
];
Promise.allSettled(tasks).then(results => {
results.forEach((result, i) => {
if (result.status === "fulfilled") {
console.log(`[${i}] 결과:`, result.value);
} else {
console.log(`[${i}] 에러:`, result.reason);
}
});
});
실행 결과
가장 먼저 완료된 결과(실패): 실패1
4. Promise.any([])
성공한 프로미스 결과들 중에서 가장 먼저 성공한 결과만 받는다.
실패한 결과는 무시하고, 만약 전부 실패하면 .catch 한다.
1. 성공하는 결과가 있는 경우
const p1 = Promise.reject("실패1");
const p2 = Promise.resolve("성공1");
const p3 = Promise.reject("실패2");
const p4 = Promise.resolve("성공2");
Promise.any([p1, p2, p3, p4]).then((result) => {
console.log("성공한 것 중 제일 먼저 끝난 결과:", result);
});
// 결과
// 성공한 것 중 제일 먼저 끝난 결과: 성공1
2. 모든 결과가 실패하는 경우
const p1 = Promise.reject("실패1");
const p3 = Promise.reject("실패2");
Promise.any([p1, p3])
.then((result) => {
console.log("성공한 것 중 제일 먼저 끝난 결과:", result);
})
.catch((error) => {
console.error("모든 Promise가 실패했습니다.");
console.error(error); // AggregateError: All promises were rejected
});
// 결과
// 모든 Promise가 실패했습니다.
// [AggregateError: All promises were rejected] {
// [errors]: [ '실패1', '실패2' ]
// }
프로미스 순차 실행
Promise.resolve
Array.prototype.reduce()와 함께 자주 쓰인다.
Promise.resolve() 는 즉시 이행되는 Promise를 만들어서 초기 체인을 시작하는데 사용된다.
그리고 .reduce() 로 이전 Promise가 끝난 후 다음 작업을 연결한다.
reduce()는 배열의 모든 요소를 순서대로 처리하면서 누적 결과를 만들어내는 메서드이다.
const nums = [1, 2, 3];
function delayPrint(n) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(n);
resolve();
}, 1000);
});
}
nums.reduce((promise, n) => {
return promise.then(() => delayPrint(n));
}, Promise.resolve());
// 아래 방식과 동일함
// Promise.resolve()
// .then(() => delayPrint(1))
// .then(() => delayPrint(2))
// .then(() => delayPrint(3));
- .reduce 와 함께 쓰이는 방식
- Promise.resolve() : 처음에 바로 실행되는 Promise
- 그 다음부터 n마다 delayPrint(n)이 순서대로 호출된다.
- .then 으로 체이닝하면서 다음 작업이 이전 작업이 끝날 때까지 기다린다.
실행 결과 (1초 간격으로 출력됨)
// (1초 후)
1
// (2초 후)
2
// (3초 후)
3
프로미스 메모이제이션
캐시를 사용해서 프로미스 함수 호출의 결과 값을 저장하여 중복된 요청을 방지할 수 있다. → 성능 최적화
요청한 url이 이미 캐시에 존재한다면 캐시된 데이터가 반환된다.
만약 존재하지 않으면 새로운 요청이 발생하고, 나중에 같은 요청이 들어올 때를 대비해 캐시에 저장된다.
const cache = {};
function fetchData(key) {
if (!cache[key]) {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Fetching from server...");
resolve("데이터: " + key);
}, 1000);
});
console.log(promise); // Promise { <pending> } // 이면 아직 결과가 정해지지 않은 상태
cache[key] = promise;
}
return cache[key]; // 캐싱된 Promise 반환
}
fetchData("user123").then(console.log); // 1초 후 결과 출력
fetchData("user123").then(console.log); // 바로 결과 출력 (동일 Promise 공유)
실행 결과
Promise { <pending> }
// (1초 후)
Fetching from server...
데이터: user123
// (그 후 바로 출력)
데이터: user123
Promise { <pending> } 으로 출력되는 것은 아직 1초가 지나지 않아, 결과가 정해지지 않은 상태라는 뜻이다. 비동기 작업이 완료되지 않아(즉, resolve가 호출되지 않아) pending 상태이며, 작업이 끝나면 resolve()가 호출되어 결과를 반환한다.
첫 호출인 경우 캐시에 없으므로 1초 기다리고, resolve(”데이터: user123”) 가 되고, then(console.log)에 등록되어, Fetching from server... 와 데이터: user123 가 출력된다.
두 번째 호출인 경우 캐시에 있으므로 기다리지 않고 바로 데이터: user123 를 출력한다.
Fetching from server... 가 한번 출력 된 것을 확인하면 두 번째 호출은 캐싱된 데이터를 바로 반환했다는 것을 알 수 있다.
pending으로 결과가 정해지지 않았는데 그 다음 줄에서 어떻게 cache에 저장할 수 있는 걸까?
Promise는 비동기 작업이 진행 중일 떄 “결과를 나중에 받을 수 있도록” 약속하는 객체이다. Promise는 그 결과가 아직 결정되지 않았더라도 비동기 작업을 추적하는 객체로서 존재한다. 때문에, 비동기 작업이 아직 완료되지 않았다고 해서 그 Promise 객체를 사용할 수 없는 것은 아니다. Promise 객체는 **"상태"**만 추적하고, 결과는 추후에 resolve나 reject로 설정되는 것이다.
여기서 Promise는 아직 값을 갖고 있지는 않지만 작업이 완료될 때 결과 값을 받을 수 있는 참조를 제공하기 때문에, 값이 결정되지 않았더라도 cache에 저장되어 나중에 해당 작업이 끝났을 때 결과를 받을 수 있게 된다.
프로미스 재시도
Promise Retry.
프로미스(비동기 작업, 특히 네트워크 요청)가 실패할 때 일정 횟수만큼 다시 시도할 수 있다.
function retryPromise(fn, retries = 3, delay = 1000) {
return new Promise((resolve, reject) => {
function attempt(remaining) {
fn()
.then(resolve) // 성공하면 "성공!" 출력됨
.catch((err) => {
if (remaining === 0) {
reject(err); // 계속 끝까지 실패하는 경우 "실패!" 출력됨
} else {
// 실패했지만 앞으로 재시도 기회가 남은 경우
console.log(`재시도 중... 남은 횟수: ${remaining}`);
setTimeout(() => attempt(remaining - 1), delay);
}
});
}
attempt(retries);
});
}
// 실패할 수도 있는 비동기 함수
function fetchData() {
return new Promise((resolve, reject) => {
if (Math.random() < 0.5) {
reject("실패!");
} else {
resolve("성공!");
}
});
}
retryPromise(fetchData, 5, 500).then(console.log).catch(console.error);
실행 결과
재시도 중... 남은 횟수: 5
재시도 중... 남은 횟수: 4
성공!
- retryPromise() 호출
- fn()으로는 실패할 수도 있는 비동기 함수인 fetchData() 가 전달되고, retries = 5, delay = 100 이 저장됨
- attempt(retries); 호출. ( 즉, attempt(5) 호출 )
- fetchData() 가 호출되고 .then과 .catch 로 결과를 반환한다.
- 실패하여 .catch 가 실행된 경우, 재시도 기회가 남아있으면 delay (1초) 만큼 기다린 후에 다시 attempt()를 호출한다. ⇒ 재귀 함수
async / await 패턴
프로미스를 더 간단하게 쓰기 위한 문법적 설탕이다.
async 함수 안에서 await로 동기 코드처럼 작성할 수 있다.
- 장점 : 코드가 가장 깔끔하고, 동기 코드처럼 쓸 수 있으며, 에러 처리는 try...catch로 쉽게 할 수 있다.
async function getData() {
try {
const result = await fetchData(); // fetchData는 Promise를 반환해야 함
console.log(result);
} catch (error) {
console.error(error);
}
}
getData();
- await 키워드는 fetch 호출이 완료될 때까지 함수 실행을 일시 중지한다.
- 요청이 성공하면 try 블록에서 데이터가 콘솔에 기록되고, 실패하면 에러가 catch 블록에서 처리된다.
'[Web-Front] React > [TAVE] React Design Pattern Study' 카테고리의 다른 글
렌더링 패턴 (2) | 2025.05.15 |
---|---|
리액트 디자인 패턴 (1) | 2025.05.15 |
플라이웨이트 패턴 (1) | 2025.05.15 |
주요 기초 디자인 패턴 3가지 (1) | 2025.05.15 |