그동안 개발을 하면서 API 통신을 할 때 async-await을 습관적으로 써왔다. 얼마 전에도 깊은 생각 없이 async-await를 쓰다가 지금 내가 이걸 왜 여기에 쓰고 있는 건지 설명할 수 있을까? 라는 생각이 들었고 이 참에 비동기 처리를 톺아봐야겠다고 생각했다.
자바스크립트는 기본적으로 동기적이다.
이는 호이스팅이 된 이후 우리가 작성한 코드가 순서대로 하나하나씩 실행이 된다는 뜻인데, 그러면 또 호이스팅은 무엇이냐!
var 변수, function 선언 등이 자동적으로 코드 가장 상단으로 끌어올려지는 것을 말한다.
호이스팅은 아래에서 코드로 보면 조금 더 잘 이해할 수 있다.
하지만 모든 코드가 동기적으로 발생한다면 어떤 일이 일어날까?
어떠한 함수가 실행을 완료할 때까지 다른 함수를 실행하지 못한다면 모든 일을 처리하는데 시간이 너무나 오래 걸릴 것이다.
예를 들어 화면의 배너이미지를 불러오는 함수가 선언되어 있고, 그 아래 화면의 기타 이미지를 불러오는 함수가 선언되어 있다고 해보자.
만약 동기적으로 이 두 함수를 처리한다면 배너이미지가 올 때까지 기다렸다가, 그제서야 기타 이미지를 불러올 수 있다.
하지만 비동기적으로 처리가 된다면 배너 이미지를 불러오는 동안 기타 이미지도 불러올 수 있어서 화면의 모든 이미지를 보여주는데 걸리는 시간이 훨씬 단축될 것이다.
이와 같은 이유로 비동기처리가 필요하고, 우리는 그 방법에 대해서 알아볼 것이다.
콜백함수란, 다른 코드의 인자로서 넘져주는 실행 가능한 코드로, 어떤 이벤트가 발생하거나 특정 시점에 도달하면 시스템에서 실행하는 함수이다
console.log(1);
setTimeout(() => {
//여기서는 console.log가 콜백 함수
console.log(2);
}, 1000);
console.log(3);
위 코드의 출력 결과는 1 3 2
이다.
1이 출력되고, 2를 출력하기 위해 1초를 기다리는 사이에 3이 먼저 출력이 되고, 1초가 지나고 나서 2가 출력이 된다.
이것이 비동기적인 작동인데, 코드 순서는 분명 1 2 3
이지만 출력은 순서와는 다르게 1 3 2
로 나오게 된다.
콜백함수도 두 가지로 나눌 수 있다.
console.log(1);
setTimeout(() => {
console.log(2);
}, 1000);
console.log(3);
function printImmediately(print) {
//print가 콜백
print();
}
printImmediately(() => {
console.log("synchronous callback");
});
위 코드의 출력 결과는 1 3 synchronous callback 2
가 된다.
해당 코드가 어떻게 작동하였냐면,
function printImmediately(print) {
//print가 콜백
print();
}
console.log(1);
setTimeout(() => {
console.log(2);
}, 1000);
console.log(3);
printImmediately(() => {
console.log("synchronous callback");
});
함수 선언이 코드 상단으로 호이스팅되어 위 코드와 같이 작동하게 되어 1 3 synchronous callback 2 가 출력이 되는 것이다.
console.log(1);
setTimeout(() => {
console.log(2);
}, 1000);
console.log(3);
function printWithDelay(print, timeout) {
setTimeout(print, timeout);
}
printWithDelay(() => console.log("async callback"), 2000);
다음과 같이 비동기적으로 작업을 처리하는 setTimeOut이 콜백함수로 들어가게 되면 위와 같은 호이스팅 과정을 거쳐서 1 3 2 async callback
이 출력되게 된다.
1을 출력하고 1초를 기다리는 동안 3이 출력이 되고 2초를 기다리는 동안 기존의 1초가 지나 2가 출력되고 그 후 2초가 모두 지나 async callback이 출력되는 것이다.
위와 같은 콜백 함수는 콜백 지옥이라는 것을 초래할 수 있는데 밑에서 코드로 다시 보자.
유저에게 id, password를 받아 로그인을 하고, 로그인이 성공적으로 되면 해당 유저가 가지고 있는 role을 받아오는 api 통신을 한다고 해보자.
콜백함수만을 이용한다면 아래와 같이 코드를 짤 수 있다.
class UserStorage {
loginUser(id, password, onSuccess, onError) {
setTimeout(() => {
//로그인하는데 2초가 걸림
if (
(id === "Heather" && password === "hi") ||
(id === "Joey" && password === "hello")
) {
onSuccess(id);
} else {
onError(new Error("not found"));
}
}, 2000);
}
//사용자의 역할을 따로 네트워크 요청을 해서 받아와야하는 나쁜 백엔드라고 가정
getRoles(user, onSuccess, onError) {
setTimeout(() => {
//역할 받는데 1초가 걸림
if (id === "Heather") {
onSuccess({ name: "Heather", role: "admin" });
} else {
onError(new Error("no access"));
}
}, 1000);
}
}
const userStorage = new UserStorage();
const id = prompt("enter your id");
const pwd = prompt("enter your password");
userStorage.loginUser(
id,
pwd,
(user) => {
userStorage.getRoles(
user,
(userWithRole) => {
//로그인이 성공적이고, role을 잘 받아왔을 때만 실행이 되는 코드
alert(
`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`
);
},
(error) => {
console.log(error);
}
);
},
(error) => {
console.log(error);
}
);
클래스에서 정의한 콜백 함수 안에 또 콜백 함수가 들어가면서 가독성이 상당히 떨어지고, 비즈니스 로직을 한 번에 보기가 굉장히 어려워진다.
사실 위에 코드를 쓰면서도 헷갈렸다.
이러한 상황을 콜백 지옥이라고 부르고, 우리는 이와 같은 상황을 방지하기 위해 아래에서 이야기할 Promise와 async-await을 쓸 수 있다.
Promise는 자바스크립트의 내장 객체로, 비동기 처리를 할 때 콜백함수 대신 유용하게 쓸 수 있다.
const promise = new Promise((resolve, reject) => {
//헤비한 일 (네트워크 통신) -> 동기적으로 처리하게 되면 그 동안 다른 코드가 실행하지 못해서
//무거운 일을 할 때는 비동기적으로 처리를 해주면 좋다!
console.log("무거운 일");
setTimeout(() => {
resolve("유저 아이디");
//reject(new Error("no network"));
}, 2000);
});
위 코드를 실행하면 바로 콘솔에 무거운 일
이 찍히는데 우리는 그저 프로미스 객체에 대한 변수만 선언해주었을 뿐인데 어떻게 바로 console.log가 작동했을까?
그 이뉴는 바로 Promise를 만드는 순간 바로 executer(프로미스의 인자로 전달한 콜백함수)가 바로 실행되기 때문이다.
즉, 프로미스가 만들어지는 순간 네트워크 통신이 가능해지는 것이다.
사용자가 요청했을 경우에만 네트워크 통신을 해야한다면 이런 식으로 작성하면 안되겠지?
하지만 Promise를 만듦과 동시에 executer가 실행된다는 사실을 간과하면 불필요하게 무거운 네트워크 통신이 생길 수 있기 때문에 유의해야한다.
promise
.then((val) => {
//val : resolve('유저 아이디'); 를 통해 '유저 아이디'가 val로 들어오게 된다
console.log(val);
})
.catch((error) => {
console.log(error);
})
.finally(() => {//실패했을 때도, 성공했을 때도 모두 실행됨
console.log("무조건 실행됨");
});
위에서 만든 프로미스는 다음과 같이 사용할 수 있는데 then, catch, finally를 이용해서 promise 객체로부터 값을 받아올 수 있다.
then의 경우에는 resolve가 호출되어 pending에서 fulfilled 상태가 된 promise 반환값만 받기 때문에 reject가 호출된 경우를 잡지 못해 이 경우 콘솔에서 Uncaught Error
를 볼 수 있다.
이와 같은 경우에는 catch를 이용해서 해당 에러를 잡을 수 있는데, then은 다시 프로미스를 리턴하기 때문에 해당 프로미스에 catch를 달아 체이닝을 통해 에러를 잡을 수 있게 되는 것이다.
finally는 fulfilled이든, rejected이든 모든 상태에서 콜백함수를 실행한다.
프로미스 체이닝을 통해 순차적으로 처리해야하는 작업을 실행할 수도 있다.
const fetchNumber = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 1000);
});
fetchNumber
.then((num) => num * 2) //2
.then((num) => num * 3) //6
.then((num) => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(num - 1), 1000); //5
});
})
.then((num) => console.log(num)); //5
위와 같이 then을 여러개 이어 붙여서 순차적으로 작업을 수행할 수 있다.
이와 같은 작업이 가능한 이유는 Promise가 Promise와 Promise를 통해 리턴된 값 모두를 반환할 수 있기 때문에 이와 같은 Promise, 혹은 값을 이용해서 그 다음 then을 실행하기 때문이다.
동기, 비동기와 호이스팅 ,,, 정말 항상 헷갈리는 것 같아요 ㅠㅅㅠ 깔끔한 정리 덕분에 잘 이해했습니다! ㅎㅎ