Skip to content

[영상] 프로미스는 콜백지옥을 위함이 아니다

기본 개념

프로미스란, 비동기 작업에 대한 결과(성공 혹은 실패)에 대해 알려줄 것이라는 약속을 의미하는 객체입니다.



JS 프로미스 만든 구글 개발자가 말하는 프로미스의 진짜 의미는? [Youtube 🎥] JS 프로미스 만든 구글 개발자가 말하는 프로미스의 진짜 의미는?


콜백 패턴

비동기 흐름을 제어하는 가장 전통적인 방법은 콜백 함수를 이용하는 것이고, 우리가 앞서 비동기를 소개하며 사용했던 패턴은 모두 콜백 함수 패턴입니다. 조금 더 기술적인 용어를 사용하자면, continuation passing style이라고도 합니다.

function doSomethingAsync(callback) {
setTimeout(function () {
callback();
}, 1000);
}
doSomethingAsync(function done1() {
console.log("done async work!");
});
doSomethingAsync(function done2() {
alert("done async work!!");
});

비동기의 흐름을 이해하는 학습의 목적으로는 위와 같이 원초적인 콜백 함수를 이용한 처리 방법이 가장 최선이라고 생각하지만, 현업에서는 불편한 점이 많고 이보다 더 나은 해결책들이 존재합니다.



프로미스 기본 사용법

프로미스는 기본적으로 아래와 같은 형태의 객체입니다.

const promise = new Promise();

프로미스 생성자 함수는 함수를 인자로 받습니다.

const promise = new Promise(() => {
// something..
});

프로미스 생성자 함수에 인자로 들어간 함수는 일반적으로 resolve, reject라고 부르는 2개의 매개 변수를 사용할 수 있습니다. resolvereject 또한 함수입니다.

const promise = new Promise((resolve, reject) => {
// 이 곳에 비동기 로직을 작성합니다.
});


프로미스 생성자 함수에 인자로 들어간 함수 내부에서 우리는 비동기 작업을 하고, 비동기 작업이 성공하면 resolve를 실행해야 하고, 실패하면 reject를 실행해야 합니다. 일반적으로 단일 프로미스 객체 내부에서는 1가지의 비동기 작업만을 수행합니다.

const promise = new Promise((resolve, reject) => {
// 이 곳에 비동기 로직을 작성합니다.
setTimeout(() => {
resolve();
}, 5000);
});

위와 같이 생성한 프로미스 객체는 미래에 맞이할 성공 혹은 실패에 대한 결과값을 반드시 알려주겠다는 약속을 의미합니다.



모든 프로미스 객체는 다음 세 가지 상태 중 하나의 상태를 가지며, 한 번이라도 결과를 맞이한 프로미스는 초기 상태로 돌아갈 수 없습니다.

  • Pending: 아직 결과가 정해지지 않은 상태
  • Fulfilled: 성공한 상태
  • Rejected: 실패한 상태


프로미스 메소드

프로미스 객체는 여러 가지 메소드가 있지만, .then, .catch를 가장 자주 사용합니다.

Promise - JavaScript | MDN

const promise = new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve(xhr.responseText);
}
};
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
});
// 비동기 작업이 성공한다면, 화살표 함수가 호출됩니다.
promise.then((data) => {
console.log("Promise success!", data);
});
// 혹시라도 비동기 작업이 실패한다면, 화살표 함수가 호출됩니다.
promise.catch((err) => {
console.log("Promise Failed!", err);
});


프로미스의 then, catch 메소드는 아래와 같이 연결하여 사용할 수 있습니다. 즉, Chaining이 가능합니다.

const promise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve(xhr.responseText);
}
};
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
});
promise
.then((data) => {
console.log("Promise success!", data);
})
.catch((err) => {
console.log("Promise Failed!", err);
});


여러 가지 단계를 아래와 같이 차례대로 연결할 수도 있습니다.

const promise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve(xhr.responseText);
}
};
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
});
promise
.then((data) => {
console.log("Promise success!", JSON.parse(data)[0].text);
return 1;
})
.then((one) => {
console.log("I am one.");
return one + 1;
})
.then((two) => {
console.log("I am two.", two);
})
.catch((err) => {
console.log("Promise Failed!", err);
});


실행 순서

아래 예시 코드의 콘솔 출력문의 순서를 잘 살펴보세요.

  • 숫자 순번으로 출력됩니다.
  • 0~3까지는 동기 방식으로 호출됩니다.
  • 4~7까지는 비동기 방식으로 호출됩니다.
console.log(0);
const promise = new Promise((resolve, reject) => {
console.log(1);
setTimeout(() => {
console.log(4);
resolve();
}, 0);
});
console.log(2);
promise
.then(() => {
console.log(5);
})
.then(() => {
console.log(6);
})
.finally(() => {
console.log(7);
});
console.log(3);


콜백 패턴과의 비교

콜백 함수를 이용한 패턴과 프로미스를 한번 비교하며 살펴보도록 하겠습니다.



예시 1.

우선 아래의 콜백 함수 패턴 예시를 한번 살펴보겠습니다. 비동기 작업이 완료된 시점 이후에 수행해야 하는 모든 로직은 getCatFacts 함수가 인자로 받는 화살표 함수 내부에 작성해야 합니다.

getCatFacts((facts) => {
// 데이터에 의존한 로직을 모두 이 함수 내부에 작성합니다.
console.log(facts);
});
// 비동기로 동작하는 함수입니다.
// 내용을 당장 깊이 이해하지 않아도 괜찮습니다.
function getCatFacts(callback) {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
callback(xhr.responseText);
}
};
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
}


예시 2.

위의 코드는 프로미스로 리팩토링한다면, 아래와 같이 변경할 수 있습니다.

const promiseForCatFacts = getCatFacts();
// 여전히 비동기로 동작하는 함수입니다.
// 프로미스를 이용하도록 변경했습니다.
function getCatFacts() {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
resolve(xhr.responseText);
}
};
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
});
}


지금 여러분이 본 예시는 간단하지만, 매우 의미있는 예시입니다. 우리는 프로미스라는 비동기 작업에 대한 약속을 의미하는 객체를 소유하게 됨으로써 더욱 많은 것들을 할 수 있는 자유도가 주어졌습니다.

1번 예시에서 보면, 콜백 패턴의 예시에서 비동기 작업 이후에 진행되는 모든 로직은 콜백 함수 내부에 작성되어야만 했습니다.

하지만 2번 예시에서 우리는 프로미스라는 1급 객체를 소유하게 됨으로써 그와 관련된 장점들도 모두 취하게 된 것입니다. (1급 객체의 특징에 대해 기억하시나요?)



프로미스의 장점

프로미스는 단지 콜백 지옥을 해결하기 위함이 아닙니다. 콜백 지옥에 대해 어느 정도 도움을 주는 것 또한 장점이라고 말할 수 있지만, 그것보다 더욱 깊은 의미가 있습니다. 그리고 콜백 지옥을 해결하지 못하는 경우도 많습니다.

기존의 비동기 작업 방식은 동기 작업 방식과의 연관 관계가 상당히 거리가 있었다고 한다면, 프로미스를 이용한 비동기 작업 방식은 동기 작업 방식과 매우 유사한 상응관계를 형성하도록 합니다.

무슨 뜻인지 어렵나요? 저도 그랬답니다.



1. 반환값

우선 동기 함수의 가장 중요한 특징 한 가지를 다시 한번 기억해보세요.

반환값이 있다.

위에서 보여드렸던 예시 1번의 콜백 패턴을 다시 한번 보시면 콜백 함수는 반환값을 받아서 로직을 처리하지 않는다는 것을 볼 수 있습니다. 이것은 동기 로직과 완전히 작업 흐름이 다르다는 것을 의미합니다. 작업하는 사람 입장에서 완전히 다른 2가지 방식의 흐름을 다루어야 하는 것이죠.

하지만 예시 2번의 프로미스 패턴을 보시면 프로미스 객체를 반환값으로 받아서 비동기 로직을 처리하도록 되어 있습니다. 일반적인 동기 로직과 작성 흐름이 동일합니다.

동기 흐름의 코드에서 우리는 함수 연산에 관한 결과로서 반환값을 지정할 수 있고, 그 결과를 받아 추가적인 연산을 진행할 수 있습니다. 콜백 함수를 이용한 상황에서는 비동기 함수의 반환값을 받을 수 없으므로, 동기 함수처럼 반환값을 지정해줄 수 없었습니다.

하지만 프로미스를 이용할 경우, (미래에 성공이나 실패에 관한 결과를 알려줄 것이라는) 약속을 담은 객체가 우리 손에 쥐어지기 때문에 이 프로미스 객체를 이용해 반환하거나 자유롭게 다른 함수 혹은 모듈 등으로 전달할 수 있습니다. 비동기 상황에서도 우리가 동기적 코드의 흐름과 훨씬 더 유사하게 제어할 수 있도록 개선되었습니다.



2. 에러 핸들링

일반적인 콜백 패턴에서는 콜백 함수의 매개변수로 에러에 대한 데이터를 받고 그에 대한 존재여부를 판단하여 에러 핸들링을 수행합니다.

getCatFacts((err, facts) => {
if (err) {
// do something with error
return;
}
console.log(facts);
});
// 비동기로 동작하는 함수입니다.
// 내용을 당장 깊이 이해하지 않아도 괜찮습니다.
function getCatFacts(callback) {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
callback(null, xhr.responseText);
}
};
xhr.addEventListener("error", (err) => {
callback(err);
});
xhr.open("GET", "https://cat-fact.herokuapp.com/facts", true);
xhr.send(null);
}


동기 흐름의 에러 핸들링은 콜백 패턴과는 완전히 다릅니다.

동기적으로 실행되는 코드는 아래와 같이 try..catch 구문을 이용해 에러에 대한 대처를 할 수 있습니다. try 구문 내에서 발생하는 모든 에러는 catch 구문으로 넘겨지도록 설계되어 있습니다.

try {
console.log(1);
throw new Error();
// 위에서 오류가 발생했으므로, 실행되지 않음.
console.log(2);
} catch (err) {
// do something with error
}


프로미스 또한 위와 유사한 흐름으로 에러 핸들링 기능이 설계되어 있습니다.

const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, 0);
});
promise
.then(function done(data) {
throw new Error();
return 1;
})
.then(function handleOne(one) {
console.log("I am one.");
return one + 1;
})
.then(function handleTwo(two) {
console.log("I am two.");
})
.catch(function handleError(err) {
console.log("Promise Failed!", err);
});

현재 예시에서는 done 함수에서 고의적으로 에러를 발생시키고 있습니다. 그렇기에 handleOne, handleTwo 함수는 실행되지 않고, 곧바로 handleError 함수가 실행됩니다.

이 흐름은 다른 곳에서 에러가 발생하더라도 동일하게 적용됩니다. promise 내부 비동기 로직, done 함수, handleOne 함수, 혹은 handleTwo 함수 어디서 에러가 발생하더라도 우리 로직은 실행이 중단되고 handleError 함수가 호출됩니다.

기존 try..catch 와 유사한 이 흐름은 자바스크립트의 기본 문법이 제공하는 흐름의 에러 핸들링일 뿐 아니라 실패 로직과 성공 로직의 분리가 더욱 명확해질 수 있게 돕습니다.



3. 확장성

프로미스는 객체 지향 프로그래밍에 기반하여 만들어졌기 때문에, 아래와 같이 유연한 확장 또한 가능합니다.

아래 예시 코드는 조금 어려울 수 있으니, 참고용으로만 보세요.

// # 1 Map Implementation
const a = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 0);
});
const b = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 10);
});
const c = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(3);
}, 5);
});
Promise.prototype.map = (fn) => {
return this.then((args) => {
return args.map((val) => {
return fn(val);
});
});
};
Promise.all([a, b, c])
.map((val) => {
return val + 100;
})
.then((data) => {
console.log(data); // [101, 102, 103]
});


요약



혹시라도 프로미스에 대한 내용이 잘 이해되지 않았다면, 시간을 두고 여러번 살펴보세요. 매우 중요한 내용이며, 구직활동에서 가장 자주 언급되는 주제 중 하나입니다. 우리가 앞으로 배우게 될 async/await 이라는 문법 또한 프로미스에 기반하여 동작하는 문법이므로, 프로미스에 대한 이해는 필수적이라고 할 수 있습니다.

프로미스의 사용법을 익히는 것은 누구나 시간만 들이면 익숙해지는 부분입니다. 진정 중요한 부분은 단지 사용법을 아는 개발자를 뛰어넘어, 근본적인 이유를 파악하고 사용할 수 있는 개발자가 되도록 노력하는 것입니다.

많은 사람들이 콜백 지옥에 대한 해결책이라고 말하니 그렇게 단편적으로 받아들이고 더 이상 스스로 고민하지 않는 사람들도 매우 많습니다. 강의에서, 인터넷 블로그에서 누가 뭐라 말하더라도 반드시 스스로 다시 한번 고민하고 또 고민하는 시간을 갖도록 하세요. 제가 설명드리는 내용들 또한 마찬가지입니다.

누군가가 설명하는 내용에 의존하게 되면, 결국 쫓아가는 사람이 될 뿐입니다. 설명을 듣더라도, 반드시 다시 한번 스스로 고민하는 시간을 갖도록 하세요.