자바스크립트의 기본 실행 방식은 동기 실행(synchronous)다.
여기서 동기 실행은 코드가 작성된 순서대로 하나씩 실행되는 방식을
말한다.
즉 구조는 이렇게 생각하면 된다.
앞에 있는 작업이 끝나야 다음 코드가 실행된다.
console.log("1번 작업");
console.log("2번 작업");
console.log("3번 작업");
코드의 작성 순서는 실행 순서다.
이것이 바로 동기 실행의 가장 큰 특징이다.
코드를 읽는 순서 그대로 실행되기 때문에 이해하기 쉽다
step1();
step2();
step3();
이걸 보면
순서가 100% 보장된다.
코드가 순서대로 실행되기 때문에
문제가 생겨도 어디서 문제가 생겼는지 찾기 쉽다.
문제는 시간이 오래 걸리는 작업이 등장할 때 발생한다.
웹에서는 이런 작업이 굉장히 많은데, 예를 들어
이런 작업들은 몇 ms ~ 몇 초가 걸릴 수 있다.
예를 들어 서버 데이터를 가져온다고 해보자.
const data = fetch("https://api.example.com/data");
console.log(data);
만약 이런 작업이 동기 방식으로 실행된다면
API 요청을 보낸 뒤 응답이 올 때까지 기다려야 한다.
그동안 다른 코드는 실행되지 못한다.
즉 이런 상태가 된다.
이처럼 하나의 작업이 전체 실행 흐름을 막아버리는 상황을
블로킹(blocking)이라고 한다.
웹은 항상 사용자와 상호작용해야 하는 환경이다.
이런 것들이 동시에 발생하는데
동기 방식만 사용하면 브라우저가 계속 멈추는 상황이 발생한다.
그래서 자바스크립트는
시간이 오래 걸리는 작업은 따로 처리하고 끝나고 알려주는
비동기(asynchronous) 방식을 사용한다.
그렇다면 여기서 하나의 질문이 생긴다.
자바스크립트는 한 번에 하나만 실행한다면서
어떻게 비동기가 가능한 걸까?
자바스크립트는 싱글 스레드 언어다.
여기서 스레드는 쉽게 말해 작업을 처리하는 흐름이라고 볼 수 있다.
즉 싱글 스레드는 한 번에 하나의 작업만 실행할 수 있다는 의미다.
예를 들어 이런 코드가 있다고 해보자.
function step1() {
console.log("step1");
}
function step2() {
console.log("step2");
}
function step3() {
console.log("step3");
}
step1();
step2();
step3();
실행 순서는 항상 아래와 같다.
왜냐면 자바스크립트는 동시에 여러 작업을 실행하지 못하기 때문이다.
앞에 있는 작업이 끝나야 다음 작업이 실행된다.
그렇다면 앞서 언급했듯이 이런 의문이 생긴다.
자바스크립트는 한 번에 하나만 실행한다면서
어떻게 비동기가 가능한 걸까?
이걸 이해하려면 자바스크립트 실행 구조를 알아야 한다.
Call Stack은 현재 실행 중인 함수들이 쌓이는 공간이다.
여기서 스택은 "Last In, First Out" 구조를 갖는다.
쉽게 말하면 나중에 들어온 것이 먼저 나가는 구조다.
예를 들어 이런 코드가 있다.
function first() {
second();
}
function second() {
console.log("hello");
}
first();
실행 흐름은 다음과 같다.
그리고 실행이 끝나면 역순으로 빠져나온다.
Call Stack은 항상
함수가 들어오고 실행된 뒤 빠져나가는 구조로 동작한다.
여기까지는 모두 동기적인 실행 구조다.
그런데 자바스크립트에서는
이 흐름을 깨는 것처럼 보이는 코드가 등장한다.
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
실행 결과는 1 → 3 → 2를 갖는다.
코드를 보면 1 → 2 → 3 순서로 실행될 것 같은데
실제로는 3이 먼저 실행된다.
왜 이런 일이 발생하는지,
여기서 등장하는 게 Web API다.
setTimeout 같은 기능은
자바스크립트 엔진이 직접 처리하는 게 아니다.
브라우저가 대신 처리한다.
흐름은 다음과 같이 진행된다.
즉 자바스크립트는
"이거 오래 걸리는 작업이니, 브라우저가 대신 하고 끝나면 알려줘"
라고 말하고 있는 것이다.
타이머가 끝나면 브라우저는 콜백 함수를 Queue에 넣는다.
이 공간을 Callback Queue라고 한다.
Callback Queue
console.log("2")
근데 여기서 중요한 점이 하나 있는데,
콜백 함수는 바로 실행되지 못한다.
왜냐면 자바스크립트는 한 번에 하나만 실행하기 때문이다.
그래서 대기하고 있다.
여기서 등장하는 게 Event Loop다.
Event Loop는 다음 과정을 반복한다.
그래서 아까 코드가 이런 식으로 실행됐던 것이다.
그래서 결과가 1 → 3 → 2가 된다.
콜백 함수는 다른 함수에 전달되는 함수다.
즉 함수가 값처럼 전달되는 상황이라고 보면 된다.
예를 들어 이런 코드가 있다.
function greet(name) {
console.log("Hello " + name);
}
function processUser(callback) {
const user = "Alice";
callback(user);
}
processUser(greet);
실행 흐름을 보면 이렇게 된다.
즉 greet함수는 직접 호출된 것이 아니라
다른 함수에 전달된 뒤 실행된 것이다.
이게 바로 콜백 함수다.
콜백에서 가장 중요한 개념은 제어권 전달이다.
일반 함수는 우리가 직접 실행한다.
greet("Alice");
하지만 콜백 함수는 우리가 실행하지 않는다.
processUser(greet);
여기서는 processUser가 언제 실행할지 결정한다.
즉 함수를 넘겨주면
실행 타이밍은 다른 함수가 결정한다.
이렇게 실행 제어가 다른 함수로 넘어가는 것을
제어권이 넘어갔다고 말한다.
비동기에서는 작업이 언제 끝날지 모른다.
예를 들어 이런 작업들이 있다.
이런 작업들은 몇 ms ~ 몇 초 걸릴 수도 있다.
그래서 자바스크립트는
"이 작업 끝나면 이 함수 실행해줘"
라고 생각한다.
이런 코드를 예로 들 수 있다.
setTimeout(() => {
console.log("2초 후 실행");
}, 2000);
여기서
() => {
console.log("2초 후 실행");
};
이 함수가 바로 콜백 함수다.
즉 "2초 타이머 끝나면 이 함수 실행" 이라는 의미를 담는다.
비동기 작업은 보통 결과를 콜백으로 전달한다.
function getData(callback) {
const data = "server data";
callback(data);
}
getData(function (result) {
console.log(result);
});
실행 흐름은 이렇게 된다.
그래서 비동기 코드에서는
작업 실행 → 작업 완료 → 콜백 실행
이런 구조가 자주 나타난다.
하지만 비동기 작업이 여러 개 이어지기 시작하면
코드는 점점 복잡해진다.
비동기 작업은 앞서 꾸준히 언급했듯이 언제 끝날지 알 수 없다.
예를 들어 이런 작업들이 있다고 생각해보자.
이 작업들은 각각 서버 요청이기 때문에
완료되는 시간이 서로 다를 수 있다.
하지만 실제 프로그램에서는 이런 식으로 순서가 필요할 때가 많다.
즉 앞 작업이 끝난 뒤 다음 작업이 실행되어야 한다.
그래서 보통 이렇게 코드를 작성하게 된다.
콜백을 사용하면 작업이 끝난 뒤 다음 작업을 실행할 수 있다.
예를 들어 이런 코드다.
getUser(function (user) {
getPosts(user.id, function (posts) {
getComments(posts[0].id, function (comments) {
console.log(comments);
});
});
});
실행 흐름은 이렇게 된다.
즉 작업이 끝날 때마다 다음 작업을 콜백 안에서 실행하는 구조다.
문제는 작업이 많아질 때 발생한다.
콜백 안에 또 콜백이 들어가고
그 안에 또 콜백이 들어간다.
작업이 하나라면 괜찮지만,
작업이 계속 이어지기 시작하면 코드 구조가 점점 복잡해진다.
코드는 이렇게 변한다.
getUser(function (user) {
getPosts(user.id, function (posts) {
getComments(posts[0].id, function (comments) {
getLikes(comments[0].id, function (likes) {
getNotifications(likes.userId, function (notifications) {
console.log(notifications);
});
});
});
});
});
코드를 보면 구조가 계속 오른쪽으로 밀리는 특징을 확인할 수 있다.
그래서 이 구조를 피라미드 형태라고 부르기도 한다.
콜백
⎿ 콜백
⎿ 콜백
⎿ 콜백
⎿ 콜백
이렇게 코드가 점점 깊어지는 구조를
Callback Hell (콜백 지옥)이라고 한다.
콜백 지옥이 발생하면 여러 문제가 생긴다.
코드가 오른쪽으로 계속 밀려서
읽기가 매우 어려워진다.
콜백 구조가 깊어지면
어떤 작업이 언제 실행되는지 파악하기 어렵다.
비동기 콜백이 많아질수록
에러 처리 구조도 복잡해진다.
그래서 자바스크립트에서는
이 문제를 해결하기 위한 새로운 방식이 등장한다.
콜백 지옥이 발생하는 이유는
비동기 작업의 흐름을 콜백으로 계속 연결해야 하기 때문이다.
즉 작업이 많아질수록 코드는 이렇게 된다.
getUser(function (user) {
getPosts(user.id, function (posts) {
getComments(posts[0].id, function (comments) {
console.log(comments);
});
});
});
코드가 계속 오른쪽으로 밀리는 구조가 된다.
그래서 자바스크립트에서는
이 문제를 해결하기 위해 Promise라는 개념이 등장했다.
Promise는 비동기 작업의 결과를 표현하는 객체다.
즉 "미래에 완료될 작업"을 객체 형태로 표현하는 방식이다.
Promise는 항상 세 가지 상태 중 하나를 가진다.
아직 작업이 완료되지 않은 상태
작업이 성공적으로 완료된 상태
작업이 실패한 상태
즉 Promise는 Pending 상태에서 Fulfilled, 또는 Rejected 상태로 변경된다.
Promise 내부에서는 resolve 함수와 reject 함수를 사용한다.
예를 들어 이런 코드다.
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("작업 성공");
} else {
reject("작업 실패");
}
});
여기서 resolve는 작업 성공을,
reject는 작업 실패를 의미한다.
Promise는 작업이 끝났을 때 실행할 함수를 연결할 수 있다.
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error);
})
.finally(() => {
console.log("작업 완료");
});
각각의 역할은 다음과 같다.
Promise의 가장 큰 장점은
비동기 작업을 순서대로 연결할 수 있다는 점이다.
getUser()
.then((user) => {
return getPosts(user.id);
})
.then((posts) => {
return getComments(posts[0].id);
})
.then((comments) => {
console.log(comments);
})
.catch((error) => {
console.error(error);
});
이 코드는 아까 콜백 코드와 같은 작업을 한다.
하지만 코드 구조는 오른쪽으로 밀리는 구조에서
왼쪽 정렬 상태를 유지한다.
그래서 Promise는
콜백 지옥을 해결하는 방법으로 등장했다.
getUser(function (err, user) {
if (err) {
console.error(err);
return;
}
getPosts(user.id, function (err, posts) {
if (err) {
console.error(err);
return;
}
getComments(posts[0].id, function (err, comments) {
if (err) {
console.error(err);
return;
}
console.log(comments);
});
});
});
여기서 특징이 하나 보인다.
if (err)
if (err)
if (err)
에러 처리가 계속 반복된다.
그런데 Promise에서는 이런식으로 바뀐다.
getUser()
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error(error));
여기서는 catch 하나로 에러를 처리할 수 있다.
즉 콜백은 에러 처리를 반복하고,
Promise는 에러 처리를 중앙화 하는 차이가 생긴다.
콜백 코드는 읽을 때 위에서 안으로 파고드는 느낌을 갖는 반면,
Promise는 위에서 아래로 흐르는 느낌을 갖는다.
즉 콜백은 구조를 따라 읽어야 하고,
Promise는 흐름을 따라 읽으면 되는 차이점도 있다.
이러한 차이를 정리하면
Promise의 장점은 다음과 같다.
하지만 Promise 역시 완벽한 해결책은 아니었다.
Promise는 콜백 지옥을 해결해 주었다.
비동기 작업을 체이닝 방식으로 연결할 수 있었기 때문이다.
getUser()
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error(error));
콜백처럼 코드가 오른쪽으로 밀리지는 않는다.
하지만 Promise에도 아쉬운 점은 있었다.
.then()이 계속 이어지면 가독성이 떨어진다.그래서 등장한 문법이 async/await이다.
async는 비동기 함수를 선언할 때 사용하는 키워드다.
async function example() {
return "hello";
}
여기서 중요한 특징이 하나 있다.
async 함수는 항상 Promise를 반환한다.
예를 들어
async function example() {
return "hello";
}
이 코드는 실제로는 이렇게 동작한다.
function example() {
return Promise.resolve("hello");
}
즉 async 함수는 내부적으로 Promise를 반환하는 함수다.
await는 Promise가 완료될 때까지 기다리는 키워드다.
예를 들어 Promise 코드가 이렇게 있다고 해보자.
getUser()
.then((user) => getPosts(user.id))
.then((posts) => console.log(posts));
이 코드를 async/await으로 바꾸면 이렇게 된다.
async function run() {
const user = await getUser();
const posts = await getPosts(user.id);
console.log(posts);
}
코드를 보면 비동기 코드인데 동기 코드처럼 읽히는 특징이 있다.
이게 바로 async/await의 가장 큰 장점이다.
Promise에서는 보통 이렇게 에러를 처리했다.
getUser()
.then((user) => getPosts(user.id))
.catch((error) => console.error(error));
하지만 async/await에서는 try/catch를 사용할 수 있다.
async function loadData() {
try {
const user = await getUser();
const posts = await getPosts(user.id);
console.log(posts);
} catch (error) {
console.error(error);
}
}
그래서 에러 처리도 동기 코드처럼 작성할 수 있다.
await는 기본적으로 순차 실행을 만든다.
하지만 작업을 동시에 실행하고 싶다면,
Promise.all을 사용할 수 있다.
const [user, posts] = await Promise.all([getUser(), getPosts()]);
이렇게 하면 두 작업이 동시에 실행된다.
자바스크립트의 기본 실행 방식은 동기 실행이다.
즉 코드는 위에서 아래로 순서대로 실행된다.
하지만 웹 환경에서는
처럼 시간이 오래 걸리는 작업이 자주 발생한다.
이런 작업이 동기 방식으로 실행되면
하나의 작업이 전체 실행 흐름을 멈추게 된다.
그래서 자바스크립트는
Event Loop 구조를 통해 비동기 작업의 실행 흐름을 관리한다.
이 과정에서 등장한 흐름은 다음과 같다.
콜백은 비동기 작업이 끝난 뒤
다음 작업을 실행하기 위한 방법이었지만
콜백이 중첩되면서 콜백 지옥 문제가 발생했다.
이 문제를 해결하기 위해
비동기 작업의 결과를 객체로 표현하는 Promise가 등장했다.
그리고 Promise를 더 읽기 쉽고 직관적으로 작성하기 위해
async/await 문법이 추가되었다.
그래서 현재 자바스크립트 비동기 코드는 보통 다음 방식으로 작성된다.
Promise + async/await
이는 비동기 코드를 동기 코드처럼 읽을 수 있게 만드는 방식이다.
현재 대부분의 자바스크립트 비동기 코드는
Promise와 async/await를 중심으로 작성된다.