자바스크립트는 비동기 처리를 위한 패턴으로 전통적으로 콜백 함수를 사용해왔다. 그러나 이 콜백 패턴은 몇 가지 심각한 단점을 가지고 있는데, 가장 대표적인 것이 콜백 헬로 인한 코드 가독성 저하다. 또한 비동기 처리 중에 발생하는 에러를 처리하기가 매우 까다롭고, 여러 개의 비동기 작업을 동시에 혹은 순차적으로 처리하는 데에도 한계가 있었다.
이러한 문제들을 해결하기 위해 ES6에서는 프로미스(Promise)라는 새로운 패턴을 도입했다. 프로미스는 비동기 처리 시점을 명확하게 표현할 수 있게 해주며, 콜백 패턴이 가진 단점들을 효과적으로 보완했다. 특히 프로미스는 비동기 처리 상태와 결과를 객체로 관리하면서, 메서드 체이닝을 통한 콜백 헬 해결, 효과적인 에러 처리, 여러 비동기 처리의 조율 등을 가능하게 했다.
비동기 처리를 위한 콜백 패턴의 단점
콜백 헬
비동기 함수는 그 특성상 함수 내부의 비동기 코드가 완료되기를 기다리지 않고 즉시 종료된다. 이는 비동기 함수의 실행 순서가 보장되지 않는다는 것을 의미한다.
예를 들어 XMLHttpRequest를 사용한 HTTP 통신을 수행하는 다음과 같은 코드를 살펴보자.
const get = url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
// 이 반환값은 의미가 없다
return JSON.parse(xhr.response);
}
console.error(`${xhr.status}`);
};
};
const response = get('https://api.example.com/data');
console.log(response); // undefined가 출력된다
이 코드가 의도한 대로 동작하지 않는 이유는 비동기 처리의 동작 방식 때문이다. 실제 실행 순서는 다음과 같다.
- get 함수가 호출되면 함수의 실행 컨텍스트가 생성되어 콜 스택에 푸시된다.
- xhr.open과 xhr.send가 실행되고, onload 이벤트 핸들러가 등록된다.
- get 함수는 암묵적으로 undefined를 반환하며 종료되고, 실행 컨텍스트가 콜 스택에서 제거된다.
- console.log가 실행되어 undefined를 출력한다.
- 이후 서버로부터 응답이 도착하면 xhr 객체에서 load 이벤트가 발생한다.
- 이벤트 핸들러는 태스크 큐에 저장되었다가, 콜 스택이 비어있을 때 이벤트 루프에 의해 콜 스택으로 이동되어 실행된다.
이러한 문제를 해결하기 위해 개발자들은 콜백 함수를 사용하기 시작했다. 비동기 작업의 결과를 처리할 콜백 함수를 매개변수로 전달하는 방식이다.
const get = (url, successCallback, failureCallback) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
successCallback(JSON.parse(xhr.response));
} else {
failureCallback(xhr.status);
}
};
};
이 방식은 단순한 비동기 처리에는 잘 동작하지만, 비동기 처리가 연속적으로 이어지는 경우 심각한 문제를 일으킨다. 예를 들어 서버로부터 데이터를 받아와서 그 결과를 기반으로 다시 다른 요청을 해야 하는 경우, 다음과 같은 중첩된 콜백 구조가 만들어진다.
get('/user', (userData) => {
get(`/posts/${userData.id}`, (postData) => {
get(`/comments/${postData.id}`, (commentData) => {
get(`/likes/${commentData.id}`, (likeData) => {
// 데이터 처리 로직
console.log(likeData);
}, error => console.error(error));
}, error => console.error(error));
}, error => console.error(error));
}, error => console.error(error));
이러한 구조를 콜백 헬이라고 부른다. 콜백 헬은 다음과 같은 심각한 문제를 초래한다.
- 코드의 가독성이 현저히 떨어져 유지보수가 어려워진다.
- 각 콜백 함수마다 에러 처리를 반복적으로 해야 한다.
- 실행 흐름을 파악하기 어려워 디버깅이 복잡해진다.
- 코드의 들여쓰기 수준이 깊어져 코드의 구조를 이해하기 어려워진다.
에러 처리의 한계
콜백 패턴이 가진 가장 심각한 문제점은 에러 처리의 한계다. 자바스크립트에서는 일반적으로 try...catch 문을 사용해 에러를 처리하지만, 비동기 함수의 콜백 함수에서 발생한 에러는 try...catch 문으로 잡아낼 수 없다.
다음 예제를 보자.
try {
setTimeout(() => {
throw new Error('Error!');
}, 1000);
} catch (e) {
// 에러가 캐치되지 않는다
console.log('캐치한 에러:', e);
}
이 코드에서 에러가 캐치되지 않는 이유는 비동기 함수의 동작 방식과 관련이 있다. 그 동작 과정을 자세히 살펴보면 다음과 같다.
- setTimeout 함수가 호출되면 setTimeout의 실행 컨텍스트가 생성되어 콜 스택에 푸시된다.
- setTimeout은 비동기 함수이므로 콜백 함수의 실행을 기다리지 않고 즉시 종료되며, 실행 컨텍스트가 콜 스택에서 제거된다.
- 1초 후 타이머가 만료되면 setTimeout의 콜백 함수가 태스크 큐로 푸시된다.
- 이벤트 루프는 콜 스택이 비었을 때 태스크 큐에서 콜백 함수를 가져와 실행한다.
- 이때 콜백 함수는 이미 try...catch 블록을 벗어난 상태에서 실행된다.
에러는 호출자 방향으로 전파되는데, setTimeout의 콜백 함수를 실행하는 시점에는 이미 해당 콜백 함수를 호출한 setTimeout 함수가 콜 스택에서 제거된 상태다. 따라서 콜백 함수의 호출자가 setTimeout 함수가 아니게 되어, 에러가 적절히 전파되지 않는다.
프로미스의 생성
프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체다. ECMAScript 사양에 정의된 표준 빌트인 객체로서, new 연산자와 함께 Promise 생성자 함수를 호출하여 생성할 수 있다.
const promise = new Promise((resolve, reject) => {
// 비동기 작업 수행
if (/* 비동기 처리 성공 */) {
resolve('성공 결과');
} else {
reject(new Error('실패 사유'));
}
});
Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수(executor 함수라고 부른다)를 인수로 전달받는다. 이 콜백 함수는 resolve와 reject 함수를 매개변수로 받는데, 각각 다음과 같은 역할을 한다.
- resolve 함수: 비동기 처리가 성공했을 때 호출하며, 비동기 처리 결과를 인수로 전달한다.
- reject 함수: 비동기 처리가 실패했을 때 호출하며, 에러 객체를 인수로 전달한다.
프로미스는 비동기 처리 상태를 나타내는 상태 정보를 가진다.
- pending(대기) 상태: 프로미스가 생성된 직후의 초기 상태다. 비동기 처리가 아직 수행되지 않은 상태를 나타낸다.
- fulfilled(이행) 상태: resolve 함수가 호출되어 비동기 처리가 성공적으로 완료된 상태다.
- rejected(거부) 상태: reject 함수가 호출되어 비동기 처리가 실패한 상태다.
fulfilled 상태나 rejected 상태를 합쳐서 settled(처리됨) 상태라고 한다. settled 상태가 되면 더 이상 다른 상태로 변화할 수 없다. 이는 프로미스의 상태 변경이 단방향으로만 일어난다는 것을 의미한다.
다음은 XMLHttpRequest를 사용하는 get 함수를 프로미스를 반환하는 형태로 구현한 예시다.
const promiseGet = url => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
// 성공적으로 응답을 받으면 resolve 함수를 호출한다
resolve(JSON.parse(xhr.response));
} else {
// 에러 발생시 reject 함수를 호출한다
reject(new Error(xhr.status));
}
};
});
};
이 예시에서 promiseGet 함수는 프로미스를 생성하고 반환한다. 비동기 처리는 Promise 생성자 함수가 인수로 전달받은 콜백 함수 내부에서 수행된다. 서버의 응답이 성공적으로 도착하면 resolve 함수를 호출하고, 에러가 발생하면 reject 함수를 호출한다.
프로미스는 비동기 처리 상태뿐만 아니라 비동기 처리 결과도 상태로 갖는다. 예를 들어 다음과 같은 프로미스를 생성해보자.
// 비동기 처리 성공
const fulfilled = new Promise(resolve => resolve(1));
// 비동기 처리 실패
const rejected = new Promise((_, reject) => reject(new Error('error occurred')));
fulfilled를 콘솔에 출력해보면 프로미스의 상태를 나타내는 [[PromiseState]] 내부 슬롯의 값이 'fulfilled'이고, 처리 결과를 나타내는 [[PromiseResult]] 내부 슬롯의 값이 1임을 확인할 수 있다. 반면 rejected의 경우 [[PromiseState]]는 'rejected'이고 [[PromiseResult]]는 Error 객체를 값으로 갖는다.
이처럼 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체로서, 다음과 같은 장점을 제공한다.
- 비동기 처리 진행 상태를 쉽게 확인할 수 있다.
- 비동기 처리 결과를 일관된 방식으로 처리할 수 있다.
- 여러 개의 비동기 처리를 쉽게 조합할 수 있다.
- 콜백 헬을 해결하고 가독성 있는 코드를 작성할 수 있다.
- 체계적인 에러 처리가 가능하다.
프로미스는 후속 처리 메서드인 then, catch, finally를 통해 비동기 처리 결과를 전달받아 처리할 수 있다. 이는 콜백 패턴에서 발생하는 콜백 헬이나 에러 처리의 한계를 효과적으로 해결해준다. 또한 Promise.all, Promise.race 등의 정적 메서드를 통해 여러 프로미스를 조합하여 사용할 수 있어, 복잡한 비동기 처리를 보다 효율적으로 구현할 수 있다.
프로미스의 후속 처리 메서드
프로미스는 비동기 작업의 상태 변화를 추적하고 그에 따른 후속 작업을 진행하기 위한 메서드들을 제공한다. 비동기 작업이 성공적으로 완료되어 fulfilled 상태가 되었을 때는 그 결과값을 가지고 다음 작업을 수행해야 하고, 작업이 실패하여 rejected 상태가 되었을 때는 적절한 에러 처리가 필요하다. 이러한 다양한 상황에 대응하기 위해 프로미스는 then, catch, finally라는 세 가지 핵심적인 후속 처리 메서드를 제공한다.
프로미스의 후속 처리 메서드들은 몇 가지 중요한 특징을 가지고 있다. 우선, 이 메서드들은 모두 프로미스 객체를 반환한다. 이는 메서드 체이닝을 가능하게 하는 핵심적인 특징이다. 또한, 이 메서드들은 비동기적으로 동작하므로 호출 즉시 실행 컨텍스트에서 제거되어 다른 코드의 실행을 방해하지 않는다.
Promise.prototype.then
then 메서드는 프로미스 후속 처리 메서드 중에서 가장 기본이 되는 메서드다. 이 메서드는 두 개의 콜백 함수를 인수로 받아들이는데, 각각 성공과 실패 상황에서 실행될 함수들이다. 첫 번째 콜백 함수는 프로미스가 성공적으로 fulfilled 상태가 되었을 때 호출되며, 이때 프로미스의 비동기 처리 결과가 이 함수의 인수로 전달된다. 두 번째 콜백 함수는 프로미스가 rejected 상태가 되었을 때 호출되며, 프로미스의 에러 객체가 이 함수의 인수로 전달된다.
const promise = new Promise((resolve, reject) => {
// 비동기 작업 수행
const data = fetchDataFromServer();
if (data) {
resolve(data); // 성공시 데이터와 함께 resolve 호출
} else {
reject(new Error('데이터 획득 실패')); // 실패시 에러와 함께 reject 호출
}
});
promise.then(
result => {
console.log('성공:', result); // 성공시 실행되는 콜백
},
error => {
console.error('실패:', error); // 실패시 실행되는 콜백
}
);
then 메서드는 매우 유연한 동작 방식을 가지고 있다. then 메서드 내의 콜백 함수가 프로미스를 반환하면 그 프로미스를 그대로 반환하고, 일반 값을 반환하면 그 값을 resolve하는 새로운 프로미스를 생성하여 반환한다. 이러한 특성은 프로미스 체이닝을 가능하게 하는 핵심 메커니즘이다.
promiseGet('https://api.example.com/user')
.then(user => {
// 일반 값 반환: 자동으로 Promise.resolve로 래핑된다
return user.id;
})
.then(userId => {
// 새로운 프로미스 반환
return promiseGet(`https://api.example.com/posts/${userId}`);
})
.then(posts => {
console.log(posts);
});
예를 들어, 위와 같은 체이닝이 가능하다.
Promise.prototype.catch
catch 메서드는 프로미스에서 발생한 에러를 처리하기 위한 전용 메서드다. 한 개의 콜백 함수만을 인수로 받으며, 이 콜백 함수는 프로미스가 rejected 상태가 되었을 때만 호출된다. catch 메서드는 내부적으로 then(undefined, onRejected)를 호출하는 것과 동일한 동작을 수행하지만, 에러 처리에 특화된 메서드로서 더 명확한 의도를 표현할 수 있다.
const promiseGet = url => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.response));
} else {
reject(new Error(`HTTP 에러! 상태: ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error('네트워크 에러!'));
};
});
};
// catch를 사용한 에러 처리
promiseGet('https://api.example.com/data')
.then(data => console.log(data))
.catch(error => {
// 네트워크 에러, HTTP 에러, 그리고 then 내부에서 발생한 에러까지 모두 캐치한다
console.error('에러 발생:', error);
// 에러 복구 로직을 여기에 작성할 수 있다
return fallbackData; // 대체 데이터 반환
});
catch 메서드도 then 메서드와 마찬가지로 항상 프로미스를 반환한다. 만약 catch의 콜백 함수가 새로운 값을 반환하면, 그 값을 resolve하는 새로운 프로미스가 생성되어 반환된다. 이는 에러 상황에서 복구하여 정상적인 처리 흐름으로 돌아갈 수 있게 해준다.
Promise.prototype.finally
finally 메서드는 프로미스의 성공 또는 실패와 관계없이 무조건 실행되어야 하는 코드를 정의할 때 사용한다. 이 메서드는 한 개의 콜백 함수를 인수로 받으며, 이 콜백 함수는 프로미스의 성공/실패 여부와 상관없이 반드시 한 번 호출된다. 주로 로딩 스피너를 제거하거나, 사용한 리소스를 정리하는 등의 작업에 사용된다.
const loadData = url => {
// 로딩 스피너 표시
showLoadingSpinner();
return promiseGet(url)
.then(response => {
// 데이터 처리
processData(response);
})
.catch(error => {
// 에러 처리
handleError(error);
})
.finally(() => {
// 성공/실패 상관없이 로딩 스피너를 제거한다
hideLoadingSpinner();
});
};
finally 메서드도 프로미스를 반환한다. finally의 콜백 함수가 값을 반환하더라도 이 값은 무시되며, finally 이전의 프로미스 상태가 그대로 다음 체인으로 전달된다. 이는 finally가 프로미스 체인의 상태나 결과값에 영향을 주지 않아야 한다는 설계 의도를 반영한 것이다.
프로미스의 에러 처리
프로미스는 기존 콜백 패턴의 가장 큰 문제점 중 하나였던 에러 처리의 어려움을 효과적으로 해결한다. 프로미스는 에러를 처리하는 방법으로 두 가지를 제공하는데, then 메서드의 두 번째 콜백 함수를 사용하는 방법과 catch 메서드를 사용하는 방법이 있다.
const wrongUrl = 'https://api.example.com/wrongpath';
// 방법 1: then의 두 번째 콜백 함수로 처리
promiseGet(wrongUrl).then(
result => console.log(result),
error => console.error('에러 발생:', error)
);
// 방법 2: catch 메서드로 처리
promiseGet(wrongUrl)
.then(result => console.log(result))
.catch(error => console.error('에러 발생:', error));
두 가지 방법 중에서는 catch 메서드를 사용하는 것이 더 권장된다. 그 이유는 여러 가지가 있는데, 첫째로 then 메서드의 두 번째 콜백 함수는 첫 번째 콜백 함수에서 발생한 에러를 잡지 못한다.
다음 예제를 보자.
promiseGet('https://api.example.com/data')
.then(
result => {
// 여기서 발생한 에러는 두 번째 콜백에서 잡지 못한다
throw new Error('then 내부에서 발생한 에러');
},
error => {
// 이 콜백은 promiseGet 함수 내부에서 발생한 에러만 잡을 수 있다
console.error('이 콜백은 위의 에러를 캐치하지 못한다:', error);
}
);
반면에 catch 메서드를 사용하면 비동기 처리에서 발생한 에러뿐만 아니라 then 메서드 내부에서 발생한 에러까지 모두 캐치할 수 있다.
promiseGet('https://api.example.com/data')
.then(result => {
// 여기서 발생한 에러도 catch에서 잡을 수 있다
throw new Error('then 내부에서 발생한 에러');
})
.catch(error => {
// promiseGet과 then 내부에서 발생한 모든 에러를 캐치할 수 있다
console.error('모든 에러를 캐치:', error);
// 에러 복구 처리를 할 수 있다
return { recovered: true };
})
.then(result => {
// catch에서 반환한 값을 받아 처리할 수 있다
console.log('복구된 결과:', result);
});
또한 catch 메서드를 사용하면 코드의 가독성이 더 좋아지고, 에러 처리 로직을 한 곳에 모을 수 있다는 장점이 있다. 이는 유지보수성을 높이고 코드의 의도를 더 명확하게 전달할 수 있게 해준다.
프로미스 체이닝
프로미스 체이닝은 프로미스의 가장 강력한 특징 중 하나로, 비동기 작업을 순차적으로 처리할 수 있게 해준다. then, catch, finally와 같은 프로미스의 후속 처리 메서드들은 모두 새로운 프로미스를 반환하기 때문에, 이들을 연속적으로 호출하여 체인처럼 연결할 수 있다.
const url = 'https://api.example.com';
promiseGet(`${url}/posts/1`)
.then(post => {
console.log('첫 번째 then:', post);
// post.userId를 사용해 해당 사용자의 정보를 요청
return promiseGet(`${url}/users/${post.userId}`);
})
.then(user => {
console.log('두 번째 then:', user);
// user.companyId를 사용해 해당 회사의 정보를 요청
return promiseGet(`${url}/companies/${user.companyId}`);
})
.then(company => {
console.log('세 번째 then:', company);
})
.catch(error => {
// 체인 어디에서든 발생한 에러를 처리
console.error('체인 중 발생한 에러:', error);
});
이러한 프로미스 체이닝에서 후속 처리 메서드의 동작 방식을 자세히 이해하는 것이 중요하다.
- then 메서드의 경우:
- 콜백 함수가 프로미스를 반환하면 그 프로미스를 그대로 반환한다.
- 콜백 함수가 값을 반환하면 그 값을 resolve하는 새로운 프로미스를 생성하여 반환한다.
- 콜백 함수가 에러를 throw하면 그 에러를 reject하는 새로운 프로미스를 반환한다.
- catch 메서드의 경우:
- 에러 처리 후 새로운 값을 반환하면 그 값을 resolve하는 프로미스를 반환한다.
- 이를 통해 에러 복구 후 정상적인 처리 흐름으로 복귀할 수 있다.
- finally 메서드의 경우:
- 콜백의 반환값은 무시되며, 이전 프로미스의 결과를 그대로 전달한다.
- 에러가 발생하면 에러를 그대로 전파한다.
프로미스 체이닝은 콜백 헬을 효과적으로 해결하지만, 여전히 콜백 함수를 사용한다는 한계가 있다. 이러한 한계는 ES8에서 도입된 async/await를 사용하여 더욱 개선할 수 있다.
const url = 'https://api.example.com';
async function getData() {
try {
const post = await promiseGet(`${url}/posts/1`);
console.log('게시물:', post);
const user = await promiseGet(`${url}/users/${post.userId}`);
console.log('사용자:', user);
const company = await promiseGet(`${url}/companies/${user.companyId}`);
console.log('회사:', company);
} catch (error) {
console.error('에러 발생:', error);
}
}
getData();
async/await를 사용하면 프로미스의 후속 처리 메서드 없이도 마치 동기 코드처럼 비동기 처리를 구현할 수 있다. 이는 코드의 가독성을 크게 향상시키고, try/catch 문을 사용한 에러 처리도 더욱 직관적으로 할 수 있게 해준다.
프로미스의 정적 메서드
프로미스는 일반적으로 생성자 함수로 사용되지만, 자바스크립트에서 함수도 객체이므로 자체적인 메서드를 가질 수 있다. Promise는 개발자들의 다양한 비동기 처리 요구사항을 충족시키기 위해 여러 가지 정적 메서드들을 제공한다. 이러한 정적 메서드들은 각각의 상황에 맞는 최적의 비동기 처리 방법을 제공한다.
Promise.resolve와 Promise.reject
Promise.resolve와 Promise.reject는 이미 존재하는 값을 가지고 프로미스 인스턴스를 생성하기 위한 정적 메서드다. 이 메서드들은 비동기적인 계산이나 처리가 필요하지 않은 상황에서도 프로미스 기반의 코드를 일관성 있게 작성할 수 있게 해준다. 특히 어떤 함수가 동기적으로 동작할 때는 일반값을 반환하고, 비동기적으로 동작할 때는 프로미스를 반환하는 경우, 반환값을 일관되게 프로미스로 만들어야 할 때 유용하다.
// 일반적인 프로미스 생성
const normalPromise = new Promise(resolve => resolve([1, 2, 3]));
// Promise.resolve를 사용한 더 간단한 생성
const resolvedPromise = Promise.resolve([1, 2, 3]);
// 두 방식은 완전히 동일한 결과를 만든다
normalPromise.then(console.log); // [1, 2, 3]
resolvedPromise.then(console.log); // [1, 2, 3]
Promise.resolve는 다음과 같은 특징을 가진다.
- 전달받은 값을 resolve하는 프로미스를 생성한다.
- 이미 프로미스인 값을 전달받으면 그대로 반환한다.
- 프로미스 체이닝에서 중간에 동기적인 값을 프로미스로 변환할 때 자주 사용된다.
Promise.reject 역시 비슷한 방식으로 동작하지만, 항상 실패한 프로미스를 생성한다.
// 일반적인 방식의 프로미스 생성
const normalRejectedPromise = new Promise((_, reject) =>
reject(new Error('오류 발생!')));
// Promise.reject를 사용한 더 간단한 생성
const rejectedPromise = Promise.reject(new Error('오류 발생!'));
// 에러 처리는 동일하게 동작한다
normalRejectedPromise.catch(error => console.log(error.message)); // "오류 발생!"
rejectedPromise.catch(error => console.log(error.message)); // "오류 발생!"
Promise.reject는 다음과 같은 특징을 가진다.
- 전달받은 값을 reject하는 프로미스를 생성한다.
- 주로 프로미스 체이닝 중에 의도적으로 에러를 발생시켜야 할 때 사용한다.
- 디버깅이나 테스트 시에 에러 상황을 시뮬레이션할 때 유용하다.
Promise.all
Promise.all은 여러 개의 비동기 작업을 병렬로 처리하고 그 결과를 한 번에 받아야 할 때 사용하는 메서드다. 이 메서드는 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 받아서, 모든 프로미스가 성공적으로 완료되었을 때 그 결과값들을 배열로 반환한다. 예를 들어, 여러 API에서 데이터를 동시에 가져와야 하거나, 여러 파일을 동시에 읽어야 하는 경우에 매우 유용하게 사용할 수 있다.
// 3초 후에 1을 반환하는 프로미스
const requestData1 = () =>
new Promise(resolve => setTimeout(() => resolve(1), 3000));
// 2초 후에 2를 반환하는 프로미스
const requestData2 = () =>
new Promise(resolve => setTimeout(() => resolve(2), 2000));
// 1초 후에 3을 반환하는 프로미스
const requestData3 = () =>
new Promise(resolve => setTimeout(() => resolve(3), 1000));
// 순차적 처리의 경우
console.time('순차 처리');
requestData1()
.then(data1 => {
console.log(data1);
return requestData2();
})
.then(data2 => {
console.log(data2);
return requestData3();
})
.then(data3 => {
console.log(data3);
console.timeEnd('순차 처리'); // 약 6초 소요
});
// 병렬 처리의 경우
console.time('병렬 처리');
Promise.all([requestData1(), requestData2(), requestData3()])
.then(values => {
console.log(values); // [1, 2, 3]
console.timeEnd('병렬 처리'); // 약 3초 소요
});
위 예제를 통해 Promise.all의 동작을 자세히 살펴보자.
이러한 Promise.all은 다음과 같은 중요한 특징들을 가진다.
1. 병렬 처리와 성능
- 모든 프로미스가 동시에 실행된다.
- 실행 시간은 가장 오래 걸리는 프로미스의 실행 시간과 비슷하다.
- 순차 처리에 비해 총 실행 시간이 크게 단축될 수 있다.
2. 결과값의 순서 보장
- 각 프로미스의 처리 완료 순서와 관계없이, 인수로 전달된 순서대로 결과값이 배열에 저장된다.
- 먼저 완료된 프로미스의 결과가 나중에 완료된 프로미스의 순서보다 앞서지 않는다.
3. 에러 처리
Promise.all([
Promise.reject(new Error('첫 번째 에러')),
new Promise(resolve => setTimeout(() => resolve(1), 2000)),
Promise.reject(new Error('두 번째 에러'))
])
.then(values => {
console.log('이 코드는 실행되지 않는다.');
})
.catch(error => {
console.log(error.message); // '첫 번째 에러'
// 가장 먼저 발생한 에러만 캐치된다
});
4. 프로미스가 아닌 요소 처리
Promise.all([
1, // Promise.resolve(1)로 변환
Promise.resolve(2), // 그대로 사용
Promise.reject(new Error()) // 그대로 사용
])
.then(values => {
console.log(values); // 실행되지 않음 (reject 때문에)
})
.catch(error => {
console.log('에러 발생');
});
Promise.race
Promise.race는 프로미스를 요소로 갖는 이터러블을 인수로 전달받아 가장 먼저 settled 상태가 된 프로미스의 결과를 반환하는 메서드다. race라는 이름이 의미하는 것처럼 여러 프로미스 중에서 가장 빨리 처리된 프로미스의 결과만을 반환하고 나머지 프로미스의 결과는 무시한다.
// 실제 사용 예시: 타임아웃 처리
const fetchWithTimeout = (url, timeout = 3000) => {
// 실제 데이터를 가져오는 프로미스
const fetchPromise = fetch(url).then(response => {
if (!response.ok) {
throw new Error(`HTTP 에러! 상태: ${response.status}`);
}
return response.json();
});
// 타임아웃을 처리하는 프로미스
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('요청 시간 초과!')), timeout);
});
// 둘 중 하나라도 먼저 완료되면 그 결과를 반환
return Promise.race([fetchPromise, timeoutPromise]);
};
// 사용 예시
fetchWithTimeout('https://api.example.com/data', 5000)
.then(data => console.log('데이터 수신 성공:', data))
.catch(error => console.log('에러 발생:', error.message));
그리고 이러한 Promise.race의 동작 방식과 특징을 자세히 살펴보자.
1. 가장 빠른 응답만 처리
Promise.race([
new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 3초
new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2초
new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 1초
])
.then(value => console.log(value)); // 3 (1초 후에 출력)
2. 실패한 케이스 처리
Promise.race([
new Promise((_, reject) => setTimeout(() => reject(new Error('Error 1')), 3000)),
new Promise(resolve => setTimeout(() => resolve('성공!'), 2000)),
new Promise((_, reject) => setTimeout(() => reject(new Error('Error 3')), 1000))
])
.then(value => console.log('성공:', value))
.catch(error => console.log('실패:', error.message)); // 실패: Error 3 (1초 후 출력)
Promise.allSettled
Promise.allSettled는 ES2020에서 도입된 메서드로, 모든 프로미스가 settled 상태(fulfilled 또는 rejected)가 될 때까지 기다린 후 각 프로미스의 처리 결과를 모아서 반환한다. 이 메서드는 여러 비동기 작업의 성공/실패 여부와 관계없이 모든 결과를 한번에 확인해야 할 때 특히 유용하다.
const apis = [
'https://api.example.com/data1',
'https://api.example.com/data2',
'https://api.example.com/data3'
];
const fetchData = url =>
fetch(url)
.then(response => response.json())
.catch(error => error);
Promise.allSettled(apis.map(url => fetchData(url)))
.then(results => {
const succeeded = results.filter(result => result.status === 'fulfilled');
const failed = results.filter(result => result.status === 'rejected');
console.log(`성공한 요청: ${succeeded.length}`);
console.log(`실패한 요청: ${failed.length}`);
succeeded.forEach(({value}, index) => {
console.log(`성공한 데이터 ${index + 1}:`, value);
});
failed.forEach(({reason}, index) => {
console.log(`실패한 이유 ${index + 1}:`, reason);
});
});
Promise.allSettled가 반환하는 결과의 형태는 아래와 같다.
[
{status: "fulfilled", value: /*성공 결과값*/},
{status: "rejected", reason: /*실패 이유*/},
{status: "fulfilled", value: /*성공 결과값*/}
]
이 메서드는 Promise.all과는 달리 어떤 프로미스가 실패하더라도 모든 프로미스의 처리가 완료될 때까지 기다린다. 따라서 일부 작업이 실패하더라도 다른 작업의 결과는 정상적으로 확인할 수 있어, 더 유연한 에러 처리가 가능하다.
마이크로태스크 큐
자바스크립트의 이벤트 루프 시스템에서는 비동기 작업들의 실행 순서를 관리하기 위해 여러 종류의 큐를 사용한다. 그 중에서도 가장 중요한 두 가지는 태스크 큐(Task Queue)와 마이크로태스크 큐(Microtask Queue)다. 이들은 각각 다른 종류의 비동기 작업을 처리하며, 실행 우선순위에도 차이가 있다.
setTimeout(() => console.log(1), 0);
Promise.resolve()
.then(() => console.log(2))
.then(() => console.log(3));
// 실제 출력 순서: 2 -> 3 -> 1
위 코드에서 setTimeout의 지연 시간이 0밀리초로 설정되었음에도 불구하고, Promise의 then 핸들러가 먼저 실행되는 것을 볼 수 있다.
이는 자바스크립트 엔진의 비동기 작업 처리 메커니즘 때문인데, 실제 실행 과정은 다음과 같이 진행된다.
- 코드 실행이 시작되면, setTimeout 콜백은 태스크 큐에 등록된다.
- Promise.resolve()의 then 핸들러들은 마이크로태스크 큐에 등록된다.
- 이벤트 루프는 현재 실행 중인 코드(메인 스크립트)가 완료되기를 기다린다.
- 메인 스크립트 실행이 완료되면, 이벤트 루프는 먼저 마이크로태스크 큐를 확인한다.
- 마이크로태스크 큐에 있는 작업들(2와 3을 출력하는 then 핸들러들)이 순차적으로 실행된다.
- 마이크로태스크 큐가 비워진 후에야 태스크 큐의 작업(1을 출력하는 setTimeout 콜백)이 실행된다.
여기서 중요한 점은 마이크로태스크 큐가 태스크 큐보다 높은 우선순위를 가진다는 것이다. 이벤트 루프는 마이크로태스크 큐가 완전히 비워질 때까지 태스크 큐의 작업을 처리하지 않는다. 이것은 프로미스 체이닝에서 연속된 then 핸들러들이 다른 비동기 작업에 의해 중단되지 않고 연속적으로 실행되는 것을 보장한다.
마이크로태스크 큐에 들어가는 작업들:
- Promise의 then, catch, finally 핸들러
- queueMicrotask()로 등록한 콜백
- process.nextTick (Node.js 환경)
태스크 큐에 들어가는 작업들:
- setTimeout, setInterval의 콜백
- 이벤트 핸들러
- setImmediate (Node.js 환경)
- requestAnimationFrame (브라우저 환경)
fetch
fetch API는 HTTP 요청을 보내고 응답을 받기 위한 현대적인 인터페이스다. XMLHttpRequest의 후속 버전으로 등장했으며, 프로미스를 기반으로 설계되어 비동기 처리를 더욱 간편하게 만들었다. fetch 함수는 기본적으로 다음과 같은 구조로 사용한다.
const promise = fetch(url [, options]);
fetch 함수를 호출하면 HTTP 응답을 나타내는 Response 객체를 래핑한 프로미스가 반환된다. 이 Response 객체는 HTTP 응답에 관한 다양한 정보와 응답 데이터를 처리하기 위한 메서드들을 포함하고 있다.
fetch의 가장 중요한 특징 중 하나는 에러 처리 방식이다. fetch는 대부분의 HTTP 에러 상황에서도 프로미스를 reject하지 않는다. 404나 500과 같은 HTTP 에러가 발생해도 Response 객체를 정상적으로 resolve하며, 단지 response.ok 속성이 false로 설정될 뿐이다. 오직 네트워크 장애나 CORS 에러처럼 요청 자체가 실패한 경우에만 프로미스가 reject된다. 따라서 다음과 아래와 같은 방식으로 에러를 처리해야 한다.
fetch('https://api.example.com/data')
.then(response => {
// HTTP 에러 체크
if (!response.ok) {
throw new Error(`HTTP 에러! 상태: ${response.status}`);
}
// 응답 본문을 JSON으로 파싱
return response.json();
})
.then(data => {
// 파싱된 데이터 처리
console.log('성공:', data);
})
.catch(error => {
// 네트워크 에러나 파싱 에러,
// 또는 위에서 직접 throw한 HTTP 에러 처리
console.error('에러 발생:', error);
});
fetch를 사용한 다양한 HTTP 요청 방식의 실제 예시인 아래 코드를 살펴보자.
// HTTP 요청 유틸리티 객체
const httpClient = {
get(url) {
return fetch(url).then(this._handleResponse);
},
post(url, data) {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 필요한 경우 인증 헤더 추가
// 'Authorization': 'Bearer ' + token
},
body: JSON.stringify(data)
}).then(this._handleResponse);
},
put(url, data) {
return fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
}).then(this._handleResponse);
},
delete(url) {
return fetch(url, {
method: 'DELETE'
}).then(this._handleResponse);
},
_handleResponse(response) {
if (!response.ok) {
// HTTP 에러 상세 정보 포함
return response.text()
.then(errorText => {
throw new Error(`${response.status} ${response.statusText}: ${errorText}`);
});
}
// 응답 헤더의 content-type을 확인하여 적절한 파싱 방법 선택
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text();
}
};
이 httpClient를 실제로 사용하는 예시를 보면 다음과 같다.
// 사용자 정보 조회
httpClient.get('https://api.example.com/users/1')
.then(user => {
console.log('사용자 정보:', user);
// 이 사용자의 게시글 목록 조회
return httpClient.get(`https://api.example.com/users/${user.id}/posts`);
})
.then(posts => {
console.log('사용자의 게시글:', posts);
})
.catch(error => {
console.error('API 요청 실패:', error);
});
// 새로운 게시글 작성
const newPost = {
title: '새 글',
content: '게시글 내용',
userId: 1
};
httpClient.post('https://api.example.com/posts', newPost)
.then(createdPost => {
console.log('작성된 게시글:', createdPost);
})
.catch(error => {
console.error('게시글 작성 실패:', error);
});
fetch API는 다양한 옵션을 제공하여 세밀한 요청 제어가 가능하다.
fetch(url, {
method: 'POST', // HTTP 메서드
headers: { // 요청 헤더
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
body: JSON.stringify(data), // 요청 본문
mode: 'cors', // CORS 모드
credentials: 'include', // 쿠키 포함 여부
cache: 'no-cache', // 캐시 전략
redirect: 'follow', // 리다이렉트 처리 방식
referrer: 'no-referrer' // 리퍼러 정책
});
axios
fetch와 함께 많이 사용되는 axios의 사용 예시와 특징을 자세히 살펴보자. axios는 fetch보다 더 풍부한 기능을 제공하며, 특히 에러 처리가 더 직관적이다. 기본적인 사용 방법은 다음과 같다.
// axios 기본 설정
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000, // 5초 후 타임아웃
headers: {
'Content-Type': 'application/json',
// 공통으로 사용할 헤더 설정
'Authorization': `Bearer ${token}`
}
});
// 요청 인터셉터 - 모든 요청 전에 실행
api.interceptors.request.use(
config => {
// 요청 보내기 전 수행할 작업
console.log('요청 시작:', config.url);
return config;
},
error => {
return Promise.reject(error);
}
);
// 응답 인터셉터 - 모든 응답에 대해 실행
api.interceptors.response.use(
response => {
// 응답 데이터 가공
console.log('응답 완료:', response.config.url);
return response;
},
error => {
// 오류 응답 처리
if (error.response.status === 401) {
// 인증 오류 처리
return refreshTokenAndRetry(error);
}
return Promise.reject(error);
}
);
axios를 사용한 다양한 HTTP 요청 예시는 아래와 같다.
// GET 요청
async function getUsers() {
try {
const response = await api.get('/users', {
params: {
page: 1,
limit: 10
}
});
console.log('사용자 목록:', response.data);
} catch (error) {
console.error('사용자 조회 실패:', error.response.data);
}
}
// POST 요청
async function createUser(userData) {
try {
const response = await api.post('/users', userData);
console.log('생성된 사용자:', response.data);
} catch (error) {
console.error('사용자 생성 실패:', error.response.data);
}
}
// PUT 요청
async function updateUser(userId, userData) {
try {
const response = await api.put(`/users/${userId}`, userData);
console.log('수정된 사용자:', response.data);
} catch (error) {
console.error('사용자 수정 실패:', error.response.data);
}
}
// DELETE 요청
async function deleteUser(userId) {
try {
await api.delete(`/users/${userId}`);
console.log('사용자 삭제 완료');
} catch (error) {
console.error('사용자 삭제 실패:', error.response.data);
}
}
파일 업로드와 진행률 모니터링은 아래와 같다.
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: progressEvent => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
console.log('업로드 진행률:', percentCompleted + '%');
}
});
console.log('업로드 완료:', response.data);
} catch (error) {
console.error('업로드 실패:', error);
}
}
여러 개의 동시 요청 처리는 아래와 같다.
// 여러 요청 동시 실행
async function getMultipleData() {
try {
const [users, posts, comments] = await Promise.all([
api.get('/users'),
api.get('/posts'),
api.get('/comments')
]);
return {
users: users.data,
posts: posts.data,
comments: comments.data
};
} catch (error) {
console.error('데이터 조회 실패:', error);
}
}
// 요청 취소 기능
const controller = new AbortController();
api.get('/longProcess', {
signal: controller.signal
}).catch(error => {
if (axios.isCancel(error)) {
console.log('요청이 취소됨:', error.message);
}
});
// 3초 후 요청 취소
setTimeout(() => {
controller.abort();
}, 3000);
이처럼 axios는 fetch보다 더 많은 기능을 제공하며, 특히 에러 처리와 요청/응답 인터셉터, 진행률 모니터링, 요청 취소 등의 기능이 매우 유용하다. 또한 Promise 기반으로 작동하면서도 async/await와 함께 사용하기 매우 편리하다.
fetch vs axios
이러한 fetch의 한계와 axios와 같은 대안 라이브러리의 장점을 비교해보면...
- fetch의 한계:
- HTTP 에러를 자동으로 감지하지 않는다.
- JSON 응답을 자동으로 파싱하지 않는다.
- 요청 취소 기능이 기본으로 없다.
- 진행 상태 모니터링이 쉽지 않다.
- 타임아웃 설정이 없다.
- axios의 장점:
- HTTP 에러를 자동으로 감지하고 reject한다.
- JSON 데이터를 자동으로 변환한다.
- 요청과 응답을 가로채서 변형할 수 있는 인터셉터를 제공한다.
- 요청 취소와 타임아웃 설정이 가능하다.
- 노드 환경에서도 동일하게 사용할 수 있다.
- 업로드 진행률을 쉽게 모니터링할 수 있다.
그러나 fetch도 브라우저 내장 API이기 때문에 별도의 설치가 필요 없고, 최신 브라우저에서 잘 지원되며, 웹 표준이라는 장점이 있다. 프로젝트의 요구사항과 개발 환경에 따라 적절한 도구를 선택하면 된다.
요약
비동기 처리를 위한 콜백 패턴의 문제점
- 콜백 헬
- 비동기 함수의 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당할 수 없음
- 여러 개의 비동기 함수를 순차적으로 호출할 때, 콜백 함수 내에서 또 다른 콜백 함수를 호출하는 형식이 반복되어 코드의 가독성이 떨어짐
- 에러 처리의 한계
- 콜백 함수를 호출한 것은 비동기 함수가 아니기 때문에, try...catch 문으로 에러를 캐치할 수 없음
프로미스의 생성
const promise = new Promise((resolve, reject) => { if (비동기 처리 성공) { resolve('result'); } else { reject('failure reason'); } });
- 프로미스는 비동기 처리 상태와 처리 결과를 관리하는 객체
- Promise 생성자 함수를 new 연산자와 함께 호출하여 생성
- 비동기 처리를 수행할 콜백 함수(executor)를 인수로 전달받음
- executor 함수는 resolve와 reject 함수를 매개변수로 받음
프로미스의 상태
- pending(대기): 비동기 처리가 아직 수행되지 않은 초기 상태
- fulfilled(이행): 비동기 처리가 성공적으로 완료된 상태
- rejected(거부): 비동기 처리가 실패한 상태
- settled(처리됨): fulfilled 또는 rejected 상태
프로미스를 사용한 비동기 함수 구현 예시
const promiseGet = url => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.response));
} else {
reject(new Error(xhr.status));
}
};
});
};
프로미스의 후속 처리 메서드
- then
- 두 개의 콜백 함수를 인수로 받음 (성공 시 호출될 onFulfilled와 실패 시 호출될 onRejected)
- 항상 프로미스를 반환
- 콜백 함수가 프로미스를 반환하면 그 프로미스를 그대로 반환하고, 그렇지 않으면 콜백의 반환값을 resolve 또는 reject하는 새로운 프로미스를 생성하여 반환
- catch
- 한 개의 콜백 함수(onRejected)를 인수로 받음
- 프로미스가 rejected 상태인 경우에만 호출됨
- then(undefined, onRejected)을 호출하는 것과 동일
- finally
- 한 개의 콜백 함수를 인수로 받음
- 프로미스의 성공/실패 여부와 상관없이 무조건 한 번 호출됨
- 프로미스 체인의 마지막에 사용되며, 성공/실패 후 반드시 실행되어야 하는 처리를 수행
프로미스 체이닝
promiseGet('url1')
.then(result1 => {
console.log(result1);
return promiseGet(url2/${result1.id});
})
.then(result2 => {
console.log(result2);
})
.catch(error => {
console.error(error);
});
- 후속 처리 메서드는 언제나 프로미스를 반환하므로, 연속적으로 호출 가능(체이닝)
- 콜백 헬을 해결하고 가독성을 향상시킴
프로미스의 에러 처리
- then 메서드의 두 번째 콜백 함수로 처리
- then 메서드에 onRejected 함수를 등록
- catch 메서드로 처리
- catch 메서드에 onRejected 함수를 등록
- then 메서드의 onRejected보다 더 많은 에러를 캐치할 수 있음
프로미스의 정적 메서드
- Promise.resolve / Promise.reject
- 존재하는 값을 래핑하여 프로미스를 생성하기 위해 사용
- 프로미스 기반 코드를 일관성 있게 유지할 때 유용
- Promise.all
- 여러 개의 비동기 처리를 모두 병렬 처리할 때 사용
- 프로미스 배열 등의 이터러블을 인수로 받아 그 결과를 배열로 반환하는 새로운 프로미스를 반환
- 인수로 전달된 배열의 모든 프로미스가 fulfilled 상태가 되면 resolve된 처리 결과를 배열에 저장해 새로운 프로미스를 반환
- 배열의 프로미스 중 하나라도 rejected 상태가 되면, 가장 먼저 rejected 상태가 된 프로미스의 에러를 reject하는 새로운 프로미스를 즉시 반환
- Promise.race
- 여러 개의 비동기 처리 중에서 가장 먼저 처리된 것의 결과를 resolve하는 프로미스를 반환
- 프로미스가 rejected 상태가 되면 에러를 reject하는 새로운 프로미스를 즉시 반환
- Promise.allSettled
- 모든 프로미스가 settled 상태(fulfilled 또는 rejected)가 될 때까지 기다림
- 처리 결과를 배열로 반환
- 프로미스의 성공/실패 여부와 상관없이 모든 프로미스의 처리 결과를 받아 처리할 때 유용
마이크로태스크 큐
- 프로미스의 후속 처리 메서드의 콜백 함수는 마이크로태스크 큐에 저장됨
- 마이크로태스크 큐는 태스크 큐보다 우선순위가 높음
- 이벤트 루프는 콜 스택이 비면 먼저 마이크로태스크 큐에서 대기하고 있는 함수를 가져와 실행
- 마이크로태스크 큐가 비면 태스크 큐에서 대기하고 있는 함수를 가져와 실행
fetch
const promise = fetch(url [, options])
- XMLHttpRequest 이후에 등장한 HTTP 요청을 위한 새로운 Web API
- 프로미스를 기반으로 구성되어 있어 프로미스의 후속 처리 메서드를 사용할 수 있음
- 대부분의 브라우저에서 지원하며, 구형 브라우저를 위한 폴리필도 존재
fetch의 에러 처리
fetch('url')
.then(response => {
if (!response.ok) {
throw new Error(HTTP error! status: ${response.status});
}
return response.json();
})
.then(data => console.log(data))
.catch(error => console.error(error));
- 404 Not Found나 500 Internal Server Error와 같은 HTTP 에러가 발생해도 에러를 reject하지 않고, response.ok 상태를 false로 설정
- 오직 네트워크 장애나 요청이 완료되지 못한 경우에만 프로미스를 reject
axios
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 1000,
headers: {'X-Custom-Header': 'value'}
});
api.get('/user?id=1')
.then(response => {
console.log(response.data);
})
.catch(error => {
console.error(error);
});
- 브라우저와 Node.js에서 모두 사용 가능한 HTTP 클라이언트 라이브러리
- XMLHttpRequest 객체 기반이지만, 프로미스를 사용하여 더욱 편리한 API 제공
- 요청과 응답을 JSON 형태로 자동 변환
- 요청 객체와 응답 객체의 변형, 요청 취소 등의 기능 지원
fetch vs axios
- fetch는 브라우저 내장 API, axios는 써드파티 라이브러리
- fetch는 요청을 중단시킬 수 있는 기본적인 방법 제공하지 않음, axios는 요청 취소 기능 지원
- fetch는 timeout 처리를 위한 API 제공하지 않음, axios는 timeout 옵션으로 요청 타임아웃 처리 가능
- fetch는 XSRF 보호를 위한 쿠키 처리 방법이 까다로움, axios는 client side에서 쿠키 사용 가능
- fetch는 upload progress에 대한 기본 메커니즘이 없음, axios는 onUploadProgress 핸들러로 처리 가능
예상문제 [🔥]
https://github.com/junh0328/prepare_frontend_interview?tab=readme-ov-file
콜백이란 뭐라고 생각하나요? 🔥
콜백은 다른 함수에 인자로 전달되는 함수를 말합니다. 콜백을 넘겨받은 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 나중에 실행할 수도 있습니다.
JavaScript에서 콜백은 보통 비동기 작업이 완료된 후에 실행되어야 할 코드를 구현하는 데 사용됩니다. 예를 들어, setTimeout이나 addEventListener와 같은 함수는 콜백 함수를 인자로 받아서 일정 시간이 경과하거나 이벤트가 발생했을 때 해당 콜백 함수를 실행합니다.
콜백 함수는 비동기 처리를 위한 전통적인 방법이었지만, 콜백 지옥이라고 불리는 가독성 문제나 에러 처리의 어려움 등의 단점이 있습니다. 이를 해결하기 위해 ES6에서는 Promise가 도입되었고, 더 나아가 async/await 문법이 추가되었습니다.
프로미스가 뭔가요? 🔥
프로미스는 자바스크립트의 비동기 처리에 사용되는 객체입니다. 주로 서버에서 데이터를 받아오거나 파일을 읽어들이는 등의 비동기 작업을 처리할 때 사용됩니다.
프로미스는 세 가지 상태를 가질 수 있습니다. 처음에는 Pending(대기) 상태이며, 작업이 완료되면 Fulfilled(이행) 상태가 되고, 에러가 발생하면 Rejected(거부) 상태가 됩니다.
프로미스는 then과 catch 메서드를 통해 결과값이나 에러를 처리할 수 있습니다. then은 프로미스가 Fulfilled 상태가 되었을 때 호출되며, catch는 Rejected 상태가 되었을 때 호출됩니다.
또한 then을 연속적으로 호출하는 것으로 비동기 작업을 순차적으로 수행할 수 있습니다. 이를 프로미스 체이닝이라고 하며, 콜백 지옥을 해결하는 방법 중 하나입니다.
프로미스는 비동기 작업을 더 쉽고 간결하게 처리할 수 있게 해주며, 에러 처리도 try/catch와 비슷한 방식으로 할 수 있어 더 직관적입니다.
프로미스는 다음과 같은 방식으로 생성할 수 있습니다.
const promise = new Promise((resolve, reject) => {
// 비동기 작업을 수행하고, 완료되면 resolve를, 에러가 발생하면 reject를 호출한다.
});
그리고 프로미스의 결과는 다음과 같이 처리할 수 있습니다.
promise
.then(result => {
// 작업이 성공적으로 완료되면 이 블록이 실행된다.
})
.catch(error => {
// 작업 중 에러가 발생하면 이 블록이 실행된다.
})
.finally(() => {
// 성공, 실패 여부와 상관없이 항상 실행된다.
});
프로미스는 비동기 작업을 좀 더 편리하고 가독성 있게 처리할 수 있게 해주는 강력한 도구입니다. 모던 자바스크립트에서는 프로미스와 async/await이 비동기 처리의 기본으로 자리잡았습니다.
프로미스 생성 방법
프로미스는 Promise 생성자 함수를 new 연산자와 함께 호출하면 생성할 수 있습니다. Promise 생성자는 executor라는 함수를 인자로 받는데, 이 함수는 resolve와 reject라는 두 개의 함수를 인자로 받습니다.
const promise = new Promise((resolve, reject) => {
if (/* 비동기 처리 성공 */) {
resolve('result');
} else { /* 비동기 처리 실패 */
reject('failure reason');
}
});
executor 함수 내부에서는 비동기 작업을 수행합니다. 만약 이 작업이 성공적으로 완료되면 resolve 함수를 호출하고, 에러가 발생하면 reject 함수를 호출합니다.
resolve 함수는 비동기 작업의 결과를 인자로 받고, reject 함수는 에러의 원인을 인자로 받습니다. 이 함수들이 호출되면 프로미스의 상태가 Pending에서 Fulfilled 또는 Rejected로 변경됩니다.
프로미스의 상태를 나타내는 것은 어떤 것들이 있나요? 🔥
프로미스는 다음의 세 가지 상태를 가질 수 있습니다.
첫 번째, Pending(대기, 펜딩)은 프로미스가 생성되고 아직 작업이 완료되지 않은 초기 상태입니다. promise 객체를 생성할 때의 executor 함수가 실행 중인 상태라고 할 수 있습니다. 프로미스가 resolve나 reject 함수를 호출하기 전까지는 이 상태로 유지됩니다.
두 번째, Fulfilled(이행, 풀필드)은 비동기 작업이 성공적으로 완료된 상태입니다. executor 함수 내에서 resolve 함수를 호출하면 프로미스는 Fulfilled 상태가 됩니다. 이 상태가 되면 then 메서드로 등록한 콜백 함수가 호출됩니다.
세 번째, Rejected(거부, 리젝티드)는 비동기 작업이 실패한 상태입니다. executor 함수 내에서 reject 함수를 호출하거나, 예외가 발생하면 프로미스는 Rejected 상태가 됩니다. 이 때 catch 메서드에 전달한 콜백 함수가 실행됩니다.
한 번 Fulfilled 또는 Rejected 상태가 된 프로미스는 그 상태와 결과를 유지합니다. 이를 '프로미스가 settled 되었다'고 표현하며, Pending 상태로 돌아갈 수 없습니다. 그리고 settled 상태가 되면 그 결과값 또한 더 이상 변하지 않고 계속해서 같은 결과값을 반환합니다.
프로미스의 상태는 내부 프로퍼티인 [[PromiseState]]에 저장되며, 프로미스의 결과값은 [[PromiseResult]]에 저장됩니다. 이 값들은 프로미스의 메서드인 then, catch, finally를 통해 접근할 수 있습니다.
프로미스 빌트인 객체가 제공하는 정적 메서드에 대해 알고 있나요? 🔥
Promise 객체는 다음과 같은 정적 메서드를 제공합니다.
- Promise.resolve(value): 주어진 값으로 이행(Fulfilled)하는 새로운 프로미스를 생성합니다. 만약 value가 프로미스 객체라면 그 객체를 그대로 반환합니다.
- Promise.reject(reason): 주어진 이유(reason)로 Rejected 상태의 프로미스를 생성합니다.
- Promise.all(iterable): 여러 개의 프로미스를 병렬로 처리할 때 사용합니다. 전달된 모든 프로미스가 Fulfilled 상태가 되면 그 결과값들을 배열에 저장해 새로운 프로미스를 Fulfilled 상태로 반환합니다. 만약 하나라도 Rejected 상태가 되면 그 즉시 Rejected 상태의 프로미스를 반환합니다.
- Promise.race(iterable): 여러 개의 프로미스 중에서 가장 먼저 fulfilled 상태가 된 프로미스의 결과값을 새로운 프로미스의 결과값으로 반환합니다. 하나의 프로미스라도 rejected 상태가 되면 그 즉시 rejected 상태의 프로미스를 반환합니다.
- Promise.allSettled(iterable) (ES2020): 주어진 이터러블의 모든 프로미스가 Settled(이행 또는 거부)될 때까지 기다리고, 그 결과를 배열로 반환하는 새로운 프로미스를 반환합니다. 각 결과 객체는 status 속성('fulfilled' 또는 'rejected')과 value 또는 reason 속성을 가집니다.
'🧱 프론트엔드 주제 > JavaScript' 카테고리의 다른 글
[모던 자바스크립트 Deep Dive] 47장 - 에러 처리 (0) | 2024.11.30 |
---|---|
[모던 자바스크립트 Deep Dive] 46장 - 제너레이터와 async/await (1) | 2024.11.30 |
[모던 자바스크립트 Deep Dive] 44장 - REST API (2) | 2024.11.24 |
[모던 자바스크립트 Deep Dive] 43장 - Ajax (1) | 2024.11.19 |
[모던 자바스크립트 Deep Dive] 42장 - 비동기 프로그래밍 (0) | 2024.11.12 |