자바스크립트는 싱글 스레드라서 한 번에 하나의 작업만 순차적으로 수행할 수 있습니다.(동기적, Sync)
만약 앞선 작업이 시간이 정말 오래 걸린다면 끝나기 전까지는 뒤의 작업이 수행될 수 없기 때문에 정말 오랜 시간을 기다려야한다는 단점이 있습니다.
이런 상황을 위해 사용하는 것이 비동기 처리입니다.(Async)
스레드: 한 프로세스 내에서 실행되는 동작의 단위
싱글 스레드: 한 프로세스 내에서 한가지 작업을 실행하기 위해 순차적으로 실행되는 하나의 흐름
Web Browser의 경우 Javascript Engine과 Web APIs가 탑제되어 있습니다.
Javascript 코드를 한 줄씩 실행하다, 비동기 함수를 만나게 되면 비동기 작업을 Web APIs에 콜백 함수와 함께 넘겨줍니다.
Web APIs는 전달 받은 작업이 완료되면 다시 Javascript Engine에 돌려줍니다.
그럼 Javascript Engine은 돌려받은 콜백함수를 실행시킵니다.
이미지 출처: Alexander Zlatkov
자바스크립트에서 처리해야 할 작업은 call stack에 쌓입니다.
call stack에 쌓인 작업중 DOM, AJAX, Timeout 등의 작업은 API에서 해당 작업을 JavaScript 엔진의 쓰레드와는 다른 쓰레드에 위임합니다.
API로 위임될 DOM, AJAX, Timeout 등의 작업들은 callback함수를 사용합니다.
API로 위임되면 Task Queue(Callback Queue)로 해당 callback함수를 전달합니다.
전달된 callback함수는 Task Queue에 들어온 순서대로 쌓이게 됩니다.
Event Loop는 call stack이 비어있을 경우 callback을 call stack으로 보냅니다.
call stack에 쌓인 callback함수는 늦게 들어온 순서대로 처리됩니다.
API: 웹브라우저, NodeJS에서 제공하는 기능들
callback: 인자로 전달된 함수
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
아래 움짤은 위의 코드의 내부 동작입니다.
이미지 출처: Alexander Zlatkov
아래의 코드는 <script>태그를 만들고, 페이지 </head>에 <script src="…">태그를 추가합니다.
태그가 페이지에 추가되면 src에 있는 스크립트를 로딩하고 실행됩니다.
실행할 스크립트는 '/my/script.js'에 있으며 newFunction()함수를 가지고 있습니다.
따라서 loadScript()함수가 끝나면 newFunction()함수를 실행합니다.
하지만 newFunction()함수를 실행하면 오류가 발생합니다.
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
loadScript('/my/script.js');
// script.js엔 "function newFunction() {…}"이 있습니다.
newFunction(); // 함수가 존재하지 않는다는 에러가 발생합니다!
document는 DOM을 조작하는 함수이기 때문에 비동기처리됩니다.
loadScript()함수가 종료되고 newFunction()을 호출했을 때 loadScript()함수는 종료되었지만 함수 안에서 비동기 처리된 함수는 아직 처리되지 않았기 때문에 newFunction()함수를 찾을 수 없습니다.
문제는 비동기 작업들이 처리되기 전에,
비동기된 작업들이 완료된 후 실행해야 할 작업을 실행해서 생깁니다.
비동기된 함수가 모두 처리되고 난 후에 newFunction()함수를 실행하면 문제가 해결됩니다.
전의 코드와 같이 <script src="…">태그를 만듭니다.
이번엔 onload이벤트를 사용해 <script>로드가 끝나면 callback함수가 실행되도록 합니다.
callback함수는 newFunction()함수를 실행합니다.
이미 <script>가 로드 되었기 때문에 에러를 발생하지 않습니다.
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
loadScript('/my/script.js', function() {
newFunction(); // 이제 함수 호출이 제대로 동작합니다.
...
});
script가 하나일 때는 callback을 통해 에러를 일으키지 않고 실행할 수 있지만 script가 여러개 일때는 문제 생길 수 있습니다.
스크립트가 두 개이고, 첫 번째 스크립트의 로딩이 끝난 이후 두 번째 스크립트 로딩을 하고 싶다면 아래와 같이 콜백함수 안에 콜백함수를 넣어야 합니다.
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
loadScript('/my/script.js', function(script) {
alert(`${script.src}을 로딩했습니다. 이젠, 다음 스크립트를 로딩합시다.`);
loadScript('/my/script2.js', function(script) {
alert(`두 번째 스크립트를 성공적으로 로딩했습니다.`);
});
});
아직은 읽을만 하지만 이런식으로 콜백함수 안에 콜백함수들 1억개를 넣게 되면 가독성이 떨어지는 문제가 생길 수 밖에 있습니다.
Promise는 자바스크립트에 내장된 객체입니다.
Promise객체를 활용하면 callback이 가진 문제를 보완하며 비동기 호출 순서를 제어할 수 있습니다.
const promise = new Promise((resolve, reject) => {
//executor
});
프라미스 생성 코드는 resolve, reject라는 인수를 받는 콜백함수를 받습니다.
이 콜백함수를 executor라고 부릅니다.
(resolve와 reject는 자바스크립트에서 자체 제공하는 콜백이므로 사용자는 신경쓰지 않아도 됩니다.)
executor는 프라미스가 생성될때 자동으로 실행되며 원하는 작업을 실행할 수 있습니다.
그리고 처리 성공 여부에 따라 인수로 넘겨준 콜백(resolve, reject) 중 하나를 반드시 호출해야 합니다.
Promise 내부에는 state([[PromiseStatus]])와 result([[PromiseValue]])라는 프로퍼티가 있습니다.
성공 여부는 state값에 따라 결정합니다.
이미지 출처: 모던 JavaScript 튜토리얼
Promise가 생성되었을 때 state는 "pending"값을 가지고 있습니다.
생성된 Promise에서 resolve와 reject 중 하나를 반드시 호출해야 하기 때문에 한 번씩 호출해보겠습니다.
이미지 출처: Lydia Hallie
resolve를 호출 했을 때는 state가 "pending"에서 "fulfulled"로 바뀌게 됩니다.
result는 호출한 resolve의 매개변수가 됩니다.
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve("완료"), 1000);
// state: "fulfilled", result: "완료"
});
reject를 호출 했을 때는 state가 "pending"에서 "rejected"로 바뀌게 됩니다.
result는 호출한 resolve의 매개변수가 됩니다.
일반적으로 reject는 실패했을 때 사용하기 때문에 Error인스턴스를 매개변수로 사용합니다.
let promise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error("에러 발생!")), 1000);
// state: "rejected", result: "에러 발생!"
});
이미지 출처: Lydia Hallie
프라미스 객체의 state, result 프로퍼티는 내부 프로퍼티이므로 개발자가 직접 접근할 수 없습니다.
.then/ .catch/ .finally 메서드를 사용하면 접근 가능합니다.
function getImage(file) {
return new Promise((resolve, reject) => {
try {
const data = readFile(file)
resolve(data);
} catch(err) {
reject(new Error(err))
};
});
};
이미지 출처: Lydia Hallie
.then을 사용하면 resolve와 reject상태의 result 값에 접근할 수 있습니다.
then은 인수 두 개를 사용할 수 있습니다.
첫 번째 인수는 resolve호출하고 두 번째 인수는 reject를 호출합니다.
promise.then(
result => console.log(result),
error => console.log(error)
);
or
promise.then(
function(result) { },
function(error) { }
);
resolve를 호출할 때
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve("완료!"), 1000);
});
// resolve 함수는 .then의 첫 번째 함수(인수)를 실행합니다.
promise.then(
result => alert(result), // 1초 후 "완료!"를 출력
error => alert(error) // 실행되지 않음
);
reject를 호출할 때
let promise = new Promise(function(resolve, reject) {
setTimeout(() => reject(new Error("에러 발생!")), 1000);
});
// reject 함수는 .then의 두 번째 함수를 실행합니다.
promise.then(
result => alert(result), // 실행되지 않음
error => alert(error) // 1초 후 "Error: 에러 발생!"을 출력
);
then에서 reject만 다루고 싶다면 아래와 같이 null을 첫 번째 인수로 전달하면 됩니다.
.then(null, error)
catch는 위의 코드와 같지만 위의 코드보다 간결하다는 장점이 있습니다.
let promise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("에러 발생!")), 1000);
});
promise.catch(alert); // 1초 뒤 "Error: 에러 발생!" 출력
.then(f, f)와 유사하지만 다릅니다.
차이점은 아래와 같습니다.
finally 핸들러엔 인수가 없습니다. finally에선 프라미스가 이행되었는지, 거부되었는지 알 수 없습니다. finally에선 절차를 마무리하는 ‘보편적’ 동작을 수행하기 때문에 성공·실패 여부를 몰라도 됩니다.
finally 핸들러는 자동으로 다음 핸들러에 결과와 에러를 전달합니다.
.finally(() => alert("프라미스가 준비되었습니다."))
프라미스 resolve를 호출하던 reject를 호출하던 finally는 실행되며 "프라미스가 준비되었습니다."라는 알림을 실행할 것입니다.
then, catch, finally은 체이닝해서 사용할 수 있습니다.
then은 프라미스를 반환하기 때문에 그 다음 then을 호출할 수 있습니다.
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
}).then(function(result) {
alert(result); // 1
return result * 2;
}).then(function(result) {
alert(result); // 2
return result * 2;
}).then(function(result) {
alert(result); // 4
return result * 2;
});
이미지 출처: 모던 JavaScript 튜토리얼
이미지 출처: Lydia Hallie
프라미스 reject를 호출하면 state가 rejected가 되어야 하는데 fulfilled가 되는 이유를 찾지 못했습니다.
뭔가 느낌적인 느낌은 알것 같은데 설명하라고 하면 못하겠습니다.
let promise = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('에러 발생!')), 1000);
});
promise.then(
result => console.log(result),
error => console.log(error)
);

let promise = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('에러 발생!')), 1000);
});
promise.then((result, error) => {
console.log(result)
console.log(error)
});

let promise = new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('에러 발생!')), 1000);
});
promise.then(null, error => {
console.log(error)
});

아마도 promise의 setTimeout이 해결되지 못한 채 출력을 해서 그런것이 아닌가 생각합니다.
근데 그럼 왜 pending이 아닌 fulfilled가 출력이 되는지는 모르겠습니다.
답 Promise의 상태가 fulfilled로 표시된 점이 의아할 수 있지만, 실제로 이 상태가 변경된 것이 아니라 브라우저의 콘솔에서 Promise의 상태를 시각화하는 방식 때문입니다.
브라우저 콘솔에서는 Promise 객체의 상태가 즉각적으로 반영되지 않을 때가 있습니다. 즉, Promise가 생성될 당시에는 pending 상태였으나, setTimeout에 의해 reject가 호출된 후에는 상태가 rejected로 변경됩니다. 다만 콘솔에서는 Promise가 즉시 평가되지 않고 이후에 변경된 상태를 보여주기도 합니다. 이로 인해 PromiseState가 fulfilled처럼 잘못 보이는 상황이 발생할 수 있습니다.
따라서 이 문제는 코드의 로직에 문제가 있는 것이 아니라 콘솔에서 Promise 객체 상태를 갱신하는 방식의 시차에 의한 것으로 이해하시면 됩니다.
답: Promise의 상태가 rejected가 아니라 fulfilled로 보이는 점은 당황스러울 수 있습니다. 이 현상은 브라우저 콘솔이 Promise의 상태를 실시간으로 정확히 반영하지 못해 발생할 수 있는 현상입니다.
기본적으로 Promise가 reject로 호출되면 PromiseState가 rejected가 되어야 정상입니다. 그러나 일부 브라우저 콘솔에서는 Promise 객체의 상태가 변화하는 시점을 정확하게 추적하지 못해 fulfilled로 잘못 표시되는 경우가 있습니다. 이는 실제로 코드에 문제가 있는 것은 아니며, 브라우저 콘솔의 상태 표시 오류로 간주할 수 있습니다.
이 문제는 특정 브라우저나 개발자 도구 버전에서 발생할 수 있는 버그로 알려져 있으며, 코드의 정상적인 동작에는 영향을 미치지 않습니다. Promise의 catch 핸들러가 정상적으로 작동하고 reject의 결과가 제대로 처리된다면 이 출력 오류는 무시해도 괜찮습니다.
결론: 찝찝하다.
프라미스도 비동기를 제어하기 좋지만 조금 더 간편하게 동기식으로 작성하는 것처럼 느낄 수 있게 도와주는 api가 있습니다.(syntactic sugar)
async는 함수 앞에 async를 붙이면 사용할 수 있으며 async를 붙인 함수는 Promise를 반환합니다.
async function f() {
return 1;
}
f().then(console.log()); // 1
// f함수가 Promise를 반환하기 때문에 .then메서드를 사용할 수 있습니다.
명시적으로 프라미스를 반환하는 것도 가능합니다.
위의 코드와 차이점은 없습니다.
async function f() {
return Promise.resolve(1);
}
f().then(alert); // 1
이미지 출처: Lydia Hallie
await는 async가 붙은 함수 안에서만 동작하며, resolve된 Promise를 반환할 때까지 기다립니다.
async와 await를 사용한 비동기 처리 코드를 보겠습니다.
console.log('Before function!')이 Call Stack에서 실행되고 로그에 찍힙니다.
myFunc()함수가 실행되면 Call Stack에서 실행됩니다.
myFunc()함수 내부의 console.log('In function!')이 Call Stack에 쌓이고 실행되고 출력됩니다.
myFunc()함수 내부의 one()함수가 실행되며 Call Stack에 쌓입니다.
one()함수 내부의 Promise가 Call Stack에 쌓이고 resolve를 호출하여 PromiseValus에 'One!'을 가진 Promise를 반환합니다.
myFunc()함수는 await를 사용하였기 때문에 Microtask Queue로 이동합니다.
console.log('After function!')이 Call Stack에서 실행되고 출력됩니다.
Call Stack이 비어있기 때문에 Microtask Queue에 있는 myFunc()함수가 Call Stack에서 실행됩니다.
console.log(res)의 "One!"이 출력됩니다.
이미지 출처: 모던 JavaScript 튜토리얼
function delay(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms));
}
}
function getApple() {
return delay(3000)
.then(() => 'apple')
};
function getBanana() {
return delay(3000)
.then(() => 'banana')
};
async function getApple() {
await delay(3000);
return 'apple';
};
async function getBanana() {
await delay(3000);
return 'banana';
};
function pickFruits() {
return getApple().then(apple => {
return getBanana().then(banana => {
return `${apple} + ${banana}`
});
});
};
pickFruits().then(console.log);
async function pickFruits() {
const apple = await getApple();
const banana = await getBanana();
return `${apple} + ${banana}`
};
pickFruits().then(console.log);
promise를 그대로 return 하는 중간 함수
간단하게 then으로 처리 가능한 경우
원래부터 promise를 반환하는 함수
비동기 통신인 fetch 함수를 사용하면서 render함수 호출 직전까지 async/await 가 사용됐습니다.
document.querySelector('#selector')?.addEventListener('change', handleChange)
async function fetchProduct(id) {
const res = await fetch(`https://api.escuelajs.co/api/v1/products/${id}`)
const json = await res.json()
return json
}
async function getProduct(id) {
// (...) 사전준비코드 생략
const json = await fetchProduct(id)
return json;
}
async function handleChange(e) {
const id = e.target.value
const product = await getProduct(id)
renderProduct(product)
}
async/await 불필요한 경우에 맞춰 정리했습니다.
document.querySelector('#selector')?.addEventListener('change', handleChange)
// fetch는 Promise를 반환 fetchProduct도 Promise를 그래도 반환하면 됨
// .then을 사용해도 가독성에 문제가 없음
const fetchProduct = id => await fetch(`https://api.escuelajs.co/api/v1/products/${id}`).then(res => res.json())
// fetchProduct의 반환값은 이미 Promise
// getProduct의 반환도 Promise 이기 때문에
// async/await으로 Promise를 만들어 줄 필요가 없음
function getProduct(id) {
// (...) 사전준비코드 생략
const json = fetchProduct(id)
return json;
}
async function handleChange(e) {
const id = e.target.value
const product = await getProduct(id)
renderProduct(product)
}
어디선가 최소 한 번은 async/await를 사용하는게 좋지만 중간 과정에 있는 함수 전부에서 사용해야 하는 것은 아닙니다.