8개월이라는 시간동안 계속해서 정확한 비동기의 개념과 callback
함수 그리고 Promise
, async/await
에 대해 완벽히 이해를 하지 못하고 있었는데, 면접 질문에서 단골 질문으로 나오는 부분이기에 제대로 정리 및 이해하고자 글을 쓰게 되었다.
코딩이 익숙해진 지금에서야 비로소 비동기가 정확히 어떤 느낌(?)이고 개념인지 파악할 수 있었다. 천천히여도 좋으니 베이스에 있어서 정말 탄탄한 개발자로 나아가고싶다. 아자잦!!
그렇다면, 비동기 프로그램(asynchronous)이란?
👉 언제 코드가 실행될 지 예측할 수 없는 것.
(대표적인 예시로 setTimeout이 있음)
자바스크립트에서는 Promise
, async/await
문법이 나오기 이전에는 callback
을 사용하여 비동직 작업을 처리해주었다. 그러다 보니 많은 불편함이 있었는데 그 중 하나가 바로 callback 지옥이다.
예를 들어서, 코드를 작성할 때 해당 비동기 작업 내부에서 또 다른 비동기 작업,, 또 다른 비동기 작업,, 또 다른 작업,, 이런 식으로 코드를 작성하다보면 연속되는 들여쓰기에 의해 callback지옥이 탄생하게 된다..ㅎ
이런한 코드 작성은 가독성에 굉장히 좋지 않아 코드를 읽거나 해석할 때 눈이 돌아간다.
때문에 해당 불편한 사항을 개선하기 위해 나온 문법이 바로 Promise
이다.
그 전에 callback이란 그럼 무엇일까?
call-back
말 그대로 다시 불러주라는 의미처럼, 함수를 인자로 넘겨주는 부분을 뜻한다.
아래 예시에서 콜백함수는
// 지금 당장 실행하지 않고 1초가 지난 다음에 내 함수를 실행해줘 라는 의미 -> call back
setTimeout(function () {
console.log("콜백 함수 부분 체크");
}, 1000);
// 아래와 같이 arrow function을 사용하여 더 간단하게 축약하여 작성 가능
setTimeout(() => {
console.log("콜백 함수 부분 체크");
}, 1000);
파라미터로 넘겨진 익명함수 function () { ~ }
지점까지가 callback 함수인 것.
setTimeout
문법을 mdn에서 확인해보면 setTimeout(code, delay)
와 같이 적혀있는데, 위에서 delay
는 1000에 해당 하고 하나의 파라미터로 전달한 code
가 바로 우리가 전달한 callback
함수(익명함수 영역)인 것이다.
(콜백에 더 자세한 쓰임은 여기서 확인해보면 좋을 거 같다.)
그럼, callback은 항상 비동기일때만 쓰일까?
👉 답은, NO! 이다
callback도 2가지의 경우로 나뉘어진다.
// 🚀 Synchronous callback
// ❗️ 자바스크립트는 type이 아니라서 어떤 type의 callback 함수인지 예측을 할 수 없음..
function printImmediately(print) {
print();
}
printImmediately(() => console.log("동기적 콜백"));
// 🚀 Asynchronous callback
function printWithDelay(print, timeout) {
setTimeout(print, timeout);
}
printWithDelay(() => console.log("비동기적 콜백"), 1000);
간단한 예시는 이제 뒤로하고, callback만을 사용하여 작업을 수행하게 되면 어떻게 될까?..ㅎㅎ
앞서 말했던 콜백지옥이 펼쳐지게 된다. 콜백함수 안에서 다른 콜백함수를 부르고 또 부르고 반복하다보면,, 정말 가독성이 최악인 코드가 된다. (콜백 속 콜백 속 콜백 속 콜백...)
이제 콜백지옥 예제를 통해 직접 콜백지옥을 느껴보자.
// 사용자의 데이터를 백엔드에서 받아오는 작업을 코드로 작성해보는 예제
class UserStorage {
// 2가지의 API가 있다고 가정 -> 1. 사용자 로그인 2. 역할 가져오기
loginUser(id, password, onSuccess, onError) {
// 사용자의 id 와 password가 일치하여 로그인 성공 시 onSuccess, 로그인 실패 시 onError 호출하는 예제
setTimeout(() => {
if (id === "ellie" && password === "dream") || (id === "coder" && password === "academy")) {
onSuccess(id); // ⭐️ callback
} else {
onError(new Error("not found")); // ⭐️ callback
}
}, 2000);
}
getRoles(user, onSuccess, onError) {
// 사용자의 데이터를 받아서 사용자마다 가지는 admin이나 게스트라던지 그러한 역할을 서버에서 다시 받아오는 예제
// ❗️ 원래는 사용자가 로그인 하면 로그인과 관련된 정보를 백에서 한 번에 넘겨받지만 callback hell을 작성하기 위한 예시로 사용..
setTimeout(() => {
if (user === "ellie") {
onSuccess({ name: "ellie", role: "admin" }); // ⭐️ callback
} else {
onError(new Error("no access")); // ⭐️ callback
}
}, 1000);
}
// 1. 클래스를 이용하여 사용자에게 id와 password 입력 받아오기
// 2. 로그인 시도
// 3. 성공적으로 된다면 onSuccess(id)를 통해 id를 받아오는데 받아온 id를 이용해서 Roles 역할을 다시 요청하여 받아올 것임
// 4. Roles를 통해 역할을 성공적으로 받아오면 우리에게는 사용자의 이름과 역할이 들어있는 객체를 받을 수 있음. -> { name: "ellie", role: "adming" }
const userstorage = new UserStorage(); // class니까 new라는 키워드를 사용하여 만들기
const id = prompt("enter your id"); // class를 이용하여 백엔드와 통신, 사용자에게 id와 password 받아오기
const password = prompt("enter your password");
// 🚀
userstorage.loginUser(
id,
password,
(user) => {
userStorage.getRoles(
user,
(userWithRole) => {
alert(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
},
error => {
console.error(error);
}
);
},
(error) => console.error(error));
위와 같이 작성하게 되면, 문제점이 무엇일까?
바로, 콜백 체인의 문제점..!
1. 가독성이 너무 많이 떨어짐.
어디서 어떤 식으로 연결되어있는지 한 눈에 가눈하기 어렵고, 비즈니스 로직을 한 눈에 이해하기 힘듦.
2. 에러가 발생하거나 디버깅이 발생하게 되는 경우에도 굉장히 힘듦.
3. 당연히 유지보수 또한 어려움.
위와 같은 콜백지옥의 문제점을 개선하기 위해 나온 문법이 바로 Promise
이다.
Promise is a JavaScript object for asynchronous operation.
1) state: pending -> fufilled or rejected
2) producer (when new Promise is created, the executor runs automatically) vs consumers(then, catch, finally)
promise 객체는 아래와 같은 문법으로 만들 수 있다.
const promise = new Promise(function(resolve, reject) {
// executor (제작 코드)
});
new Promise
에 전달되는 함수는 executor(실행자, 실행 함수)
라고 부른다. executor
는 new Promise
가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어내는 제작 코드를 포함한다.
⭐️ executor runs auto
.. 유의 -> 선언해주자마자 바로 프로미스가 실행되어진다는 의미
// 🚀 1. Producer
// when new Promise is created, the executor runs automatically.
const promise = new Promise((resolve, reject) => {
// doing some heavy work (network, read files)
console.log("doing something...");
setTimeout(() => {
resolve("then의 인자로"); // 아무 문제 없이 성공했을 경우, then의 인자로
reject(new Error("여기에 이유를 잘 명시해서 작성해야함")); // 실패 시, 보통 Error 객체를 넘겨주는 식
// Error는 자바스크립트에서 제공하는 오브젝트 중 하나
}, 2000);
});
// 🚀 2. Consumers: then, catch, finally
// then -> 성공했을 때
// catch -> reject error가 발생했을 때
// finally -> 성공 / 실패와 상관없이 무조건 마지막에 호출
promise
.then(value => {
console.log(value);
// 여기서 value 값은 위 setTimeout 내의 resovle(value);
// resolve 내의 인자 값이다.
})
.catch(error => {
console.error(error);
// 프로미스 내에 작업 중 에러 발생 시 호출
})
.finally(() => {
console.log("finally");
// 성공 실패 여부와 상관없이 마지막에 무조건 호출
});
체이닝이 가능한 이유
프로미스의 then
과 catch
메서드를 호출하게 되면 똑같은 Promise
를 return
하기 때문에 그 return
된 똑같은 Promise
를 다시 호출할 수 있어서 then
다음으로 catch
(체이닝 메서드) 가 사용가능 한 것.
// 🚀 3. Promise chaining -> 메서드 체이닝
// 서버에서 숫자를 받아온다고 가정하면
const fetchNumber = new Promise((resolve, reject) => {
setTimteout(() => {
resolve(1);
}, 1000);
});
fetchNumber // 중괄호를 생략하고 아래와 같이 체이닝 문법을 쓸 수도 있다.
.then(num => num * 2)
.then(num => num * 3)
.then(num => { // 다른 서버로 보내서 다른 숫자로 변환된 값을 받아오는 예시
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(num - 1);
}, 1000);
});
})
.then(num => console.log(num)); // 그 다음 그 숫자를 출력해보는 예제
위 예제를 보면 알 수 있듯이 then
에서는 값을 바로 전달해도 되고, 또 다른 Promise
를 전달해도 된다.
👉 최종 console
값은 결국 5가 출력이 된다.
걸리는 시간은 두 개의 setTimeout
에서 설정한 시간에 의해 2초가 걸려야할 것이다.
// 4. 🚀 Error handling -> catch를 통해 error를 잘 처리하자
// 그렇다면 어떻게 에러핸들링을 할 수 있는지 아래 예시를 보자.
const getHen = () => { // 암탉을 받아오는 프로미스
new Promise((resolve, reject) => {
resolve("🐓");
}, 1000);
});
const getEgg = hen => {
new Promise((resolve, reject) => {
resolve(`${hen} => 🥚`);
}, 1000);
});
const cook = egg => {
new Promise((resolve, reject) => {
resolve(`${egg} => 🍳`);
}, 1000);
});
// 각각 서버에서 값을 받아왔다고 가정하고
getHen // 먼저 닭을 받아오고 나서
.then(hen => getEgg(hen)) // 닭이 받아와지면 전달 받은 닭을 이용해서 getEgg라는 함수를 호출
.then(egg => cook(egg)) // 그리고 위 동작이 정상적으로 수행이 되면 받아온 달걀을 가지고 우리가 cook 요리를 할 것임.
.then(meal => console.log(meal)); // 그리고 요리가 다 완료된 다음에는 요리된 음식을 콘솔로그에 출력하는 과정.
.catch(error => console.error(error));
// 또 위 과정을 아래와 같이 더 축약해서 작성 가능하다.
getHen
.then(getEgg)
.then(cook)
.then(console.log)
.catch(console.error);
// 🚀 Callback Hell example -> change Promise
class UserStorage {
loginUser(id, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if ((id === "ellie" && password === "dream") || (id === "coder" password === "academy")) {
resolve(id);
} else {
reject(new Error("not found"));
}
}, 2000);
});
}
// 🚀 이전에 작성한 callback hell getRoles 예시 또한 위 예시를 참고하여 직접 작성해보기
const userStorage = new UserStorage();
const id = prompt("enter your id");
const password = prompt("enter yout password");
userStorage.loginUser(id, password)
.then(userStorage.getRoles)
.then(user => alert("어쩌구 저쩌구 전하고자 하는 경고 내용.."));
.catch(console.error);
위와 같이 이쁘게 코드 작성 가능해진다는 장점..!
위에 프로미스로 깔끔하게 작성한 부분도 이제는 더 좋게 작성이 가능한데 이는 다음 async/await 정리편에서 다뤄보도록 해야지 👀
출처) 드림코딩