자바스크립트는 기본적으로 동기적으로 동작한다. 그런데 자바스크립트를 통해 개발을 하다보면 비동기 처리라는 용어를 쉽게 접할 수 있다.
비동기 처리가 도대체 뭘까? 용어의 정의를 통해 알아보자.
동기적 (synchronous) : 코드가 한 줄씩 순서대로 실행
비동기적 (asynchronous) : 코드가 순서대로 실행되지 않고, 언제 코드가 실행될지 예측할 수 없음
예제 코드를 통해 알아보자.
console.log('1');
console.log('2');
console.log('3');
위의 예제 코드를 실행하면 1 2 3 이 순서대로 실행된다. 이렇게 실행되는 것이 동기적으로 실행되는 것이다.
그렇다면 다시 아래 예제를 보자.
console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
위의 예제 코드를 실행하면 1 2 3 이 순서대로 실행되지 않고, 1 3 2 순서대로 실행된다.
이렇듯 실행 순서를 예측할 수 없게 동작하는 것을 비동기적
으로 실행된다고 한다.
자바스크립트에서는 이렇게 비동기적으로 동작하는 여러 요소가 있는데 대표적으로 Ajax, 이벤트 리스너, setTimeout 등이 있고, 리액트에서 자주 쓰이는 setState 도 비동기적으로 동작한다. (setState의 내부 로직을 살펴보면 이벤트 리스너와 관련이 있음)
그렇다면 자바스크립트의 일부 요소는 왜 비동기적으로 동작할까?
위에서 사용한 예제 코드를 다시 가져와보자
console.log('1');
setTimeout(() => console.log('2'), 10000); // delay를 10초로 늘려보았다.
console.log('3');
setTimeout 함수의 delay 시간을 10초로 늘려보았다. 그러면 1 3 이 출력된 후 10초 후에 2가 출력된다.
근데 setTimeout 함수가 비동기적으로 동작하지 않는다고 생각해보면 1이 먼저 출력되고 10초 후에 2가 출력되고, 그 후에 3이 출력된다.
너무 비효율적
이다. 만약에 Ajax가 동기적으로 실행된다고 생각해보면 서버에 데이터를 요청하고 다시 응답을 받기까지 아무 코드도 실행되지 않을 것이다. (브라우저 잠깐 마비됨...)
이벤트 리스너면 더 최악이다. 온클릭 이벤트가 동기적으로 실행된다고 생각하면 클릭하기 전에 다른 코드가 실행이 안될 것이다.
다시 말해서 언제 끝날지 모르는 코드를 비동기적으로 처리해주는게 훨씬 더 효율적
이기 때문에 비동기적으로 처리해주는 것이다.
이렇듯 보다 효율적으로 동작하기 위한 비동기적인 처리의 동작 원리는 무엇일까?
여기서부터는 자바스크립트 엔진과 웹 브라우저 동작 원리를 같이 알 필요가 있다. (최대한 간단히 비동기 처리를 이해할 정도로만 설명해보겠다.)
비동기적 동작의 원리를 위해하기 위해서는 몇 가지 개념에 대해 짚고 넘어가야 한다.
우선 자바스크립트는 싱글 스레드 방식으로 동작한다는 것이다.
스레드
는 프로세스 내에서 실행되는 흐름의 단위이며, 스레드는 각자의 스택을 가진다.
멀티 스레드인 경우 스레드의 수 만큼 각각의 스택이 있지만, 자바스크립트는 싱글 스레드이기 때문에 하나의 스택을 가진다.
스택(stack)
은 자바스크립트에서 실행해야하는 함수들을 순차적으로 담아 처리하는 공간이며, 후입선출(LIFO)의 특징이 있다.
자바스크립트는 스택이 하나이기 때문에 기본적으로 한 번에 코드 하나씩 처리된다.
메모리 할당이 일어나는 메모리 힙이라는 것도 있는데 이는 우선 설명에서 제하겠다.
또 콜백 큐, 이벤트 루프라는 개념도 비동기적 처리와 연관이 있다.
큐(queue)
는 대기소라는 뜻을 가지고 있는데 콜백 큐는 콜백 함수를 보관하며, 스택과는 반대로 선입선출(FIFO)되는 자료구조이다.
자바스크립트는 기본적으로 하나의 스택에서 코드 하나씩 처리되지만, 웹 브라우저에서 제공되는 Web API
경우 조금 다르다.
Web API(DOM, Ajax, setTimeout, 이벤트 리스너 등)는 자바스크립트 엔진의 스레드에서 실행되는 것이 아니라 다른 스레드를 쓴다.
즉, 자바스크립트는 싱글 스레드이지만 브라우저에 의해 멀티 스레드처럼 동작할 때가 있다.
그림을 통해 알아보자.
스택
에서는 한 번에 한 줄 씩 코드가 실행되는데, 먼저 console.log('1'); 을 실행하고, 이를 스택에서 제거한다. (1 출력됨)
setTimeout() 함수는 Web API 이기 때문에 자바스크립트 엔진에서 처리하는 것이 아니라 Web API
에서 처리를 하게 된다.
스택
은 console.log('3'); 을 실행하고, 이를 스택
에서 제거하는데 (3 출력됨), 이와 동시에 Web API
에서 setTimeout() 함수 처리가 진행된다.
setTimeout() 함수가 처리 완료되면 (정해진 시간이 다되면), Web API는 setTimeout() 함수의 콜백 함수를 콜백 큐
로 보낸다.
(Ajax의 경우 네트워크 통신이 완료되면 콜백 함수를 콜백 큐로 보내고, 이벤트 리스너는 이벤트가 실행되는 조건에 콜백 함수를 큐로 보낸다.)
콜백 큐
로 보내진 콜백 함수는 잠시 대기하게 되는데, 이 때 이벤트 루프
가 스택이 비었으면 콜백 큐
에 있는 콜백 함수를 스택으로 올린다.
스택
으로 보내진 콜백 함수( console.log('2'); )는 곧바로 실행된 후에 스택
에서 제거된다. (2 출력됨)
이벤트 루프는 스택이 비었으면 콜백 큐에 콜백 함수가 있는지 확인하고 보내는 작업을 '계속'하기 때문에 루프라고 함
이러한 과정을 거쳐서 setTimeout() 함수는 비동기적으로 동작하게 된다. 다른 Web API도 같은 과정을 거쳐 비동기적으로 동작한다.
자바스크립트에서 비동기를 왜 쓰는지, 비동기가 어떻게 동작하는지 알아봤다.
그런데 비동기 요소들은 가끔 예상치 못한 오류를 일으키곤 한다. 언제 실행될지 예측할 수 없어서 개발자의 의도와 코드 실행의 순서가 다를 수 있기 때문이다.
가령 Ajax를 사용해서 네트워크 통신을 통해 얻을 수 있는 response 객체의 data 라는 값이 있다고 가정해보자.
const getData = () => {
const response = Ajax로 데이터 받아오기();
if(response.data){
console.log("성공");
} else {
console.log("실패");
}
};
이 때, 위의 코드를 실행하면 무엇을 출력할지 예측할 수 있을까? if 문이 실행되는 시점에 response 값을 받아왔을지 받아오는 중인지 모르기 때문에 예측할 수 없다.
따라서, 비동기 요소들을 내가 의도한 순서대로 처리해야 될 필요가 발생할 수 있는데, 이 때 사용되는 것이 바로 콜백 함수
, Promise 객체
, async/await
이다.
콜백 함수
는 다른 함수의 인자로써 넘겨진 후 특정 이벤트에 의해 호출되는 함수를 뜻한다.
setTimeout(() => {
console.log('1');
console.log('2');
console.log('3');
, 1000);
아까 위의 예제 코드를 이런 식으로 변형한다고 가정해본다면 1초후에 1 2 3 순서대로 출력될 것이다.
모든 코드를 setTimeout() 함수의 콜백 함수로 포함시켰기 때문이다.
이렇듯 비동기적 함수에서의 콜백 함수는 백그라운드 코드 실행이 끝나면 호출되어, 다음 작업을 실행하게 할 수 있다.
위의 getData 함수를 콜백 함수를 이용해서 아래처럼 작성하면 데이터를 받아오는 함수의 실행이 끝나면 콜백 함수가 동작할 것이다.
const getData = () => {
Ajax로 데이터 받아오기(() => {
if(데이터받아오기 성공){
console.log("성공");
} else {
console.log("실패");
}
});
};
비동기적 함수를 내가 원하는 순서대로 처리하고 싶으면 비동기적 함수가 실행된 후에 실행되기를 원하는 함수
를 비동기 함수의 콜백 함수
로 넘기면 된다.
(모든 콜백 함수가 비동기적 처리를 위한 것은 아니다.)
이렇게 콜백 함수를 통해 내가 원하는 순서대로 비동기 처리를 할 수 있는데, 콜백 함수를 너무 남발하다보면 콜백 지옥이라는 것을 경험하게 된다.
setTimeout(() => {
console.log(1); // 1초 뒤에 1 출력
setTimeout(() => {
console.log(2); // 1 출력되면 다시 1초 뒤에 2 출력
setTimeout(() => {
console.log(3); // 2 출력되면 다시 1초 뒤에 3 출력
}, 1000)
}, 1000)
}, 1000)
위의 코드를 실행하면 1초 간격으로 1 2 3 이 출력된다. 콜백 함수 3개가 중첩되어 있는데 3개를 중첩한 것만으로 가독성이 현저하게 떨어지게 된다.
가독성이 떨어지면 로직을 이해하기도 힘들뿐더러, 에러나 디버깅 작업도 굉장히 힘들다.
Promise
는 자바스크립트에서 제공하는 비동기를 간편하게 처리할수 있도록 도와주는 객체로, 비동기 처리 상태와 처리 결과를 관리한다.
비동기적 기능을 수행하고 나서 정상적으로 기능이 수행됐다면 성공 메시지와 처리된 값을 전달하고, 예상치 못한 문제가 발생하면 에러를 전달한다.
Promise 가 콜백 함수에 비해 가지는 장점
은 다음과 같다.
Promise 생성자 함수를 이용해서 Promise 객체를 만들 수 있다.
const getData = new Promise((resolve, reject) => {
Ajax로 데이터 받아오기(() => {
if(데이터받아오기 성공){
resolve('성공');
} else {
reject('실패');
}
});
});
위와 같이 Promise 생성자의 콜백 함수는 두 개의 함수를 매개 변수를 가지는데, 첫 번째가 resolve
, 두 번째가 reject
함수이다.
resolve
는 비동기 작업을 성공적으로 완료되면 그 결과를 인수로 전달받으면서 호출되고, reject
는 비동기 처리가 실패하면 에러를 인수로 전달받으면서 호출된다.
프로미스 객체는 또한 비동기 처리가 어떻게 진행되고 있는지 나타내는 상태(state) 정보를 갖는다.
<pending>
: 비동기 처리 진행 중 / <fulfiled>
: 비동기 처리 성공한 상태 / <rejected>
: 비동기 처리 실패한 상태
프로미스는 처음 생성되면 pending 상태이며, 비동기 처리를 성공하면 그 결과와 함께 resolve 함수를 호출하면서 프로미스 상태를 fulfiled로 변경하고,
실패하면 Error 결과와 함께 reject 함수를 호출하면서 프로미스 상태를 rejected로 변경한다. 이런 과정으로 프로미스 객체는 비동기 처리 결과와 상태를 가진다.
생성된 Promise 객체
는 .then / .catch / .finally 라는 후속 처리 메서드를 가지는데, 프로미스 비동기 처리 상태가 변하면 후속 처리 메서드가 선택적으로 호출된다.
.then
은 프로미스 기능이 원할히 이루어졌을 때 호출 (then으로도 에러 처리를 할 수 있으나 성공했을 때만 사용하는 거 권장),
.catch
는 에러가 발생했을 때 호출,
.finally
는 기능이 성공하던 실패하던 무조건 한 번 호출된다.
이 때 알아두어야할 특징은 후속 처리 메서드는 프로미스를 반환한다. 그래서 아래 예제처럼 후속 처리 메서드를 연속적으로 쓸 수 있는데 이를 프로미스 체이닝이라고 한다.
getData
.then((value) => { // 프로미스가 resolve 상태인 경우 실행
console.log(value); // 성공
})
.catch(error => { // 프로미스에서 reject를 리턴했을 때 에러를 출력하지 않고 catch 블록 실행
console.log(error); // 실패
})
.finally(() => { // 성공하던 실패하던 무조건 마지막에 호출
console.log('finally'); // finally
});
Web API 중 http 요청 기능을 제공하는 fetch
는 프로미스 기능을 지원한다.
fetch 함수는 http 응답을 나타내는 response 객체를 래핑한 Promise 객체를 반환한다. 따라서 위에서 설명한 프로미스 후속 처리 메서드를 통해 처리할 수 있다.
const getData = () => {
fetch('http://example.com/movies.json')
.then((response) => response.json())
.then((data) => console.log(data));
};
위의 예제처럼 프로미스 체이닝
으로 처리할 수도 있고 필요한 경우 .catch를 이용해 에러 처리도 할 수 있어 간편하다.
이 글에선 fetch가 프로미스와 연관이 있다는 것만 간단하게 소개하고, 나중에 혹시 axios와 fetch를 비교해보는 글을 쓰게 된다면 그 때 자세한 사용법을 써보도록 하겠다.
참고 : https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch
ES8에서 도입된 async / await은 프로미스를 더 가독성 좋게, 비동기 처리를 동기 처리처럼 동작하도록 구현한다.
프로미스 체이닝을 통해서 프로미스 후속 처리를 계속 진행하다보면 가독성이 조금 떨어질 수 있는데, 이 때 async / await 을 통해 조금 더 간편하게 사용할 수 있다.
함수 앞에 async 키워드를 붙여 만들 수 있는 async 함수는 언제나 프로미스를 반환한다. (함수선언문 앞, 함수표현식 앞, 화살표 함수 앞 모두 사용 가능)
const getData = async () => {
const response = Ajax로 데이터 받아오기();
return response;
};
// 이제 getData를 프로미스처럼 사용할 수 있다.
getData
.then((response) => response.json())
.then((data) => console.log(data));
await
은 프로미스가 비동기 처리가 수행될 때까지 기다리다가 프로미스의 처리 결과를 반환한다.
await 키워드는 반드시 async 함수 내부에서 사용해야 하며, 반드시 프로미스 앞에서 사용해야 한다.
const getData = async () => {
const response = Ajax로 데이터 받아오기();
return response;
};
const Data출력 = async () => {
const {data} = await getData(); // 여기서 getData가 처리될 때까지 잠시 대기
console.log(data);
};
Data출력();
위의 getData 를 실행하면 await 키워드는 getData 함수가 처리될 때까지 대기했다가 처리가 끝나면 data를 할당한다.
이렇듯 await 키워드를 사용하면 프로미스 상태가 변환되는 시간동안 대기하게 되므로 사용할 때 주의가 필요하다.
위에서 설명한 fetch 예제를 async / await 을 통해 변경하면 다음과 같다.
const getData = async () => {
const response = await fetch('http://example.com/movies.json');
const { data } = await response.json();
console.log(data);
};
async / await 에서의 에러처리는 try ... catch 문
을 이용해서 할 수 있다.
const getData = async () => {
try {
const response = await fetch('http://example.com/movies.json');
const { data } = await response.json();
console.log(data);
} catch(error) {
console.log(error)
}
};
자바스크립트에서의 비동기 개념은 아주 많이 쓰이고 중요한 개념이므로 꼭 정리하고 가는 것이 좋다!
되도록 비동기 개념에 대해서 쉽게 이해할 수 있는 방향으로 글을 썼는데,
보다 자세한 내용을 따로 공부해보는 것을 추천드립니다.
특히 콜백 함수, 프로미스, async/await 진짜 진짜 중요함