코드스테이츠 - 유어클래스 콘텐츠를 참고하여 작성하였습니다.
[Day 6]
2023년 7월 4일
📗목차
커피숍에서 커피를 주문하려고 줄을 서는 모습을 상상해보자.
커피숍 사정상, 커피를 먼저 주문한 A가 커피를 받을 때까지 줄 서 있는 B가 주문조차 할 수 없다고 하겠다. 이를 우리는 블로킹(blocking)이라 부른다. 하나의 작업이 끝날 때까지 이어지는 작업을 "막는 것"이다.
B는 A가 주문한 커피가 나오고 나서야 커피를 주문할 수 있다. A의 커피 주문 완료 시점과 B의 커피 주문 시작 시점이 같다. 이렇게 시작 시점과 완료 시점이 같은 상황을 "동기적(synchronous)이다."라고 한다.
효율적인 커피숍 운영을 위해서 아래와 같이 커피숍 주문 과정을 변경해보자.
Node.js를 만든 개발자도 위 대안이 합리적이라고 생각했다. 그래서 Node.js를 논 블로킹(non-blocking)하고 비동기적(asynchronous)으로 작동하는 런타임으로 개발하게 된다.
JavaScript의 비동기적 실행(Asynchronous execution)이라는 개념은 웹 개발에서 특히 유용하다. 특히 아래 작업은 비동기적으로 작동되어야 효율적이다.
JavaScript의 동기 처리란 '특정 코드의 실행이 완료될 때까지 기다리고 난 후 다음 코드를 수행하는 것'을 의미한다.

JavaScript의 비동기 처리는 ‘특정 코드의 실행이 완료될 때까지 기다리지 않고 다음 코드들을 수행하는 것’을 의미한다.

JavaScript는 싱글 스레드 기반으로 동작하는 언어이다. 따라서 동기적으로 작동하게 된다. 그러나 방금까지 JavaScript에서도 비동기 처리가 가능하다고 배웠다. 어떻게 된 걸까? 그 이유는 JavaScript가 작동하는 환경(런타임)에서 비동기 처리를 도와주기 때문에 특별한 작업 없이 비동기 처리를 할 수 있는 것이다.
일정 시간 후에 함수를 실행
setTimeout(function() {
console.log('1초 후 실행');
}, 1000);
// 123
setTimeout 타이머를 종료
const timer = setTimeout(function() {
console.log('10초 후 실행');
}, 10000);
clearTimeout(timer);
// setTimeout이 종료됨.
일정 시간의 간격을 가지고 함수를 반복적으로 실행
setInterval(function() {
console.log('1초마다 실행');
}, 1000);
/// 345
setInterval 타이머를 종료
const timer = setInterval(function () {
console.log('1초마다 실행');
}, 1000);
clearInterval(timer);
// setInterval이 종료됨.
Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있지만 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Callback Hell이 발생하는 단점이 있다.
// 터미널에 `node 파일명.js`를 입력하여 비동기 코드가 작동하는 순서를 확인해보세요. const printString = (string, callback) => { setTimeout(function () { console.log(string); callback(); }, Math.floor(Math.random() * 100) + 1); }; const printAll = () => { printString('A', () => { printString('B', () => { printString('C', () => { printString('D', () => { printString('E', () => { printString('F', () => { printString('G', () => { printString('H', () => { printString('I', () => { printString('J', () => { printString('K', () => { printString('L', () => { printString('M', () => { printString('N', () => { printString('O', () => { printString('P', () => {}); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }); }; printAll(); console.log( `아래와 같이 Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있지만 코드가 길어질 수록 복잡해지고 가독성이 낮아지는 Callback Hell이 발생하는 단점이 있습니다.` );
앞서 확인한 Callback Hell의 현상을 방지하기 위해 Promise가 사용되기 시작했다. Promise에 대해 알아보자.
비동기로 작동하는 코드를 제어할 수 있는 다른 방법은 Promise를 활용하는 방법이다. 또한 Callback Hell을 방지하는 역할도 수행한다.
Promise는 class이기 때문에 new 키워드를 통해 Promise 객체를 생성한다. 또한 Promise는 비동기 처리를 수행할 콜백 함수(executor)를 인수로 전달받는데 이 콜백 함수는 resolve, reject 함수를 인수로 전달받는다.
Promise 객체가 생성되면 executor는 자동으로 실행되고 작성했던 코드들이 작동된다. 코드가 정상적으로 처리가 되었다면 resolve 함수를 호출하고 에러가 발생했을 경우에는 reject 함수를 호출하면 된다.
let promise = new Promise((resolve, reject) => {
// 1. 정상적으로 처리되는 경우
// resolve의 인자에 값을 전달할 수 있다.
resolve(value);
// 2. 에러가 발생하는 경우
// reject의 인자에 에러메시지를 전달할 수도 있다.
reject(error);
});
[예시] 프로미스가 정상적으로 처리된 경우의 프로미스 객체
[예시] 프로미스가 에러가 발생한 경우의 프로미스 객체
new Promise가 반환하는 Promise 객체는 state, result 내부 프로퍼티를 갖는다. 하지만 직접 접근할 수 없고 .then, .catch, .finally의 메서드를 사용해야 접근이 가능하다.
state
기본 상태는 pending(대기)입니다. 비동기 처리를 수행할 콜백 함수(executor)가 성공적으로 작동했다면 fulfilled(이행)로 변경이 되고, 에러가 발생했다면 rejected(거부)가 됩니다.
Result
처음은 undefined입니다. 비동기 처리를 수행할 콜백 함수(executor)가 성공적으로 작동하여 resolve(value)가 호출되면 value로, 에러가 발생하여 reject(error)가 호출되면 error로 변한다.
Then
executor에 작성했던 코드들이 정상적으로 처리가 되었다면 resolve 함수를 호출하고 .then메서드로 접근할 수 있다.
또한 .then 안에서 리턴한 값이 Promise면 Promise의 내부 프로퍼티 result를 다음 .then의 콜백 함수의 인자로 받아오고, Promise가 아니라면 리턴한 값을 .then의 콜백 함수의 인자로 받아올 수 있다.
아래의 .then과 Promise chaining의 예시를 살펴보면서 동작 방식을 확인해보자.
let promise = new Promise((resolve, reject) => {
resolve("성공");
});
promise.then(value => {
console.log(value);
// "성공"
});
Catch
executor에 작성했던 코드들이 에러가 발생했을 경우에는 reject 함수를 호출하고 .catch 메서드로 접근할 수 있다.
let promise = new Promise(function(resolve, reject) {
reject(new Error("에러"))
});
promise.catch(error => {
console.log(error);
// Error: 에러
});
Finally
executor에 작성했던 코드들의 정상 처리 여부와 상관없이 .finally 메서드로 접근할 수 있다.
let promise = new Promise(function(resolve, reject) {
resolve("성공");
});
promise
.then(value => {
console.log(value);
// 성공
})
.catch(error => {
console.log(error);
})
.finally(() => {
console.log("성공이든 실패든 작동!");
// "성공이든 실패든 작동!"
});
Promise chaining가 필요하는 경우는 비동기 작업을 순차적으로 진행해야 하는 경우이다. Promise chaining이 가능한 이유는 .then, .catch, .finally의 메서드들은 Promise를 리턴하기 때문이다. 따라서 .then을 통해 연결할 수 있고, 에러가 발생할 경우 .catch로 처리하면 된다.
// 터미널에 `node 파일명.js`를 입력하여 비동기 코드가 작동하는 순서를 확인해보세요.
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = () => {
printString('A')
.then(() => {
return printString('B');
})
.then(() => {
return printString('C');
});
};
printAll();
console.log(
`아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있습니다!`
);
// 터미널에 'node 파일명.js'을 입력하여 .then, .catch, .finally의 작동박식을 이해해보세요!
let promise = new Promise(function (resolve, reject) {
resolve('성공');
// reject("실패");
});
promise
.then((value) => {
console.log(value);
return '성공';
})
.then((value) => {
console.log(value);
return '성공';
})
.then((value) => {
console.log(value);
return '성공';
})
.catch((error) => {
console.log(error);
return '실패';
})
.finally(() => {
console.log('성공이든 실패든 작동!');
});
const promiseOne = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('1초'), 1000));
const promiseTwo = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('2초'), 2000));
const promiseThree = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('3초'), 3000));
Promise.all()은 여러 개의 비동기 작업을 동시에 처리하고 싶을 때 사용한다.
인자로는 배열을 받는다. 해당 배열에 있는 모든 Promise에서 executor 내 작성했던 코드들이 정상적으로 처리가 되었다면 결과를 배열에 저장해 새로운 Promise를 반환해준다.
앞서 배운 Promise chaining을 사용했을 경우는 코드들이 순차적으로 동작되기 때문에 총 6초의 시간이 걸리게 되는데, 같은 코드가 중복되는 현상도 발생하게 된다.
// 기존
const result = [];
promiseOne()
.then(value => {
result.push(value);
return promiseTwo();
})
.then(value => {
result.push(value);
return promiseThree();
})
.then(value => {
result.push(value);
console.log(result);
// ['1초', '2초', '3초']
})
이러한 문제들을 Promise.all()을 통해 해결할 수 있다. Promise.all()은 비동기 작업들을 동시에 처리합니다. 따라서 3초 안에 모든 작업이 종료된다. 또한 Promise chaining로 작성한 코드보다 간결해진 것을 확인할 수 있다.
// promise.all
Promise.all([promiseOne(), promiseTwo(), promiseThree()])
.then((value) => console.log(value))
// ['1초', '2초', '3초']
.catch((err) => console.log(err));
추가적으로 Promise.all()은 인자로 받는 배열에 있는 Promise 중 하나라도 에러가 발생하면 나머지 Promise의 state와 상관없이 즉시 종료된다.
아래의 예시와 같이 1초 후에 에러가 발생하고 Error: 에러1이 반환된 후로는 더 이상 작동하지 않고 종료된다.
Promise.all([
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러1'))), 1000),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러2'))), 2000),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러3'))), 3000),
])
.then((value) => console.log(value))
.catch((err) => console.log(err));
// Error: 에러1
Promise를 통해 비동기 코드의 순서를 제어할 수 있지만 Callback 함수와 같이 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Promise Hell이 발생하는 단점이 있다. 아래의 코드를 살펴보면서 이해해보자.
// 터미널에 `node 파일명.js`를 입력하여 비동기 코드가 작동하는 순서를 확인해보세요.
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = () => {
printString('A').then((value) => {
console.log(value);
printString('B').then((value) => {
console.log(value);
printString('C').then((value) => {
console.log(value);
printString('D').then((value) => {
console.log(value);
printString('E').then((value) => {
console.log(value);
printString('F').then((value) => {
console.log(value);
printString('G').then((value) => {
console.log(value);
printString('H').then((value) => {
console.log(value);
printString('I').then((value) => {
console.log(value);
printString('J').then((value) => {
console.log(value);
printString('K').then((value) => {
console.log(value);
printString('L').then((value) => {
console.log(value);
printString('M').then((value) => {
console.log(value);
printString('N').then((value) => {
console.log(value);
printString('O').then((value) => {
console.log(value);
printString('P').then((value) => {
console.log(value);
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
};
printAll();
console.log(
`아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있지만 Callback 함수와 같이 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Promise Hell이 발생하는 단점이 있습니다.`
);
JavaScript는 ES8에서 async/await키워드를 제공하였다. 이를 통해 복잡한 Promise 코드를 간결하게 작성할 수 있게 되었다. 함수 앞에 async 키워드를 사용하고 async 함수 내에서만 await 키워드를 사용하면 된다. 이렇게 작성된 코드는 await 키워드가 작성된 코드가 동작하고 나서야 다음 순서의 코드가 동작하게 된다.
// 함수 선언식
async function funcDeclarations() {
await 작성하고자 하는 코드
...
}
// 함수 표현식
const funcExpression = async function () {
await 작성하고자 하는 코드
...
}
// 화살표 함수
const ArrowFunc = async () => {
await 작성하고자 하는 코드
...
}
아래의 코드를 살펴보면서 async/await를 이해해보자.
// 터미널에 `node 파일명.js`입력하여 비동기 코드가 작동하는 순서를 확인해보세요.
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = async () => {
await printString('A');
await printString('B');
await printString('C');
};
printAll();
console.log(
`Async/Await을 통해 Promise를 간결한 코드로 작성할 수 있게 되었습니다.`
);
브라우저에서 사용할 수 있는 비동기 흐름은 타이머 혹은 DOM 이벤트와 관련된 상황으로 다소 한정적이지만, Node.js의 경우 많은 API가 비동기로 작성되어 있다. Node.js는 "비동기 이벤트 기반 JavaScript 런타임"이다.
Node.js 모듈을 사용하는 방법을 먼저 학습하고, 이를 통해 비동기 상황을 연출하고 연습해보자.
💭 모듈이 무엇인가?
어떤 기능을 조립할 수 있는 형태로 만든 부분이다. 그중 fs(File System) 모듈은, PC의 파일을 읽거나 저장하는 등의 일을 할 수 있게 도와준다.
모든 모듈은 '모듈을 사용하기 위해 불러오는 과정'이 필요하다. 브라우저에서 다른 파일을 불러올 때에는 다음과 같이 <script> 태그를 이용했다.
<script src="불러오고싶은_스크립트.js"></script>
Node.js는 JavaScript 코드 가장 상단에 require 구문을 이용하여 다른 파일을 불러온다.
const fs = require('fs'); // 파일 시스템 모듈을 불러옵니다
const dns = require('dns'); // DNS 모듈을 불러옵니다
// 이제 fs.readFile 메서드 등을 사용할 수 있습니다!
서드 파티 모듈(3rd-party module)은 해당 프로그래밍 언어에서 공식적으로 제공하는 빌트인 모듈(built-in module)이 아닌 모든 외부 모듈을 일컫는다. 예를 들어, Node.js에서 underscore는 Node.js 공식 문서에 없는 모듈이기 때문에 서드 파티 모듈이다. underscore와 같은 서드 파티 모듈을 다운로드하기 위해서는 npm을 사용해야 한다.
터미널에서 다음과 같이 입력해 underscore를 설치할 수 있다.
npm install underscore
이제 node_modules에 underscore가 설치되었다. 이제 Node.js 내장 모듈을 사용하듯 require 구문을 통해 underscore를 사용할 수 있다.
const _ = require('underscore');
메서드 fs.readFile은 비동기적으로 파일 내용 전체를 읽으며, 이 메서드를 실행할 때에는 전달인자 세 개를 받는다.
path \<string> | \<Buffer> | \<URL> | \<integer><string>)의 타입을 받는다.다음은 'etc/passwd'라는 파일을 불러오는 예제이다.
fs.readFile('/etc/passwd', ..., ...)
options \<Object> | \<string>options는 문자열 또는 객체 형태로 받을 수 있다. 문자열로 전달할 경우 인코딩을 받는다. 밑의 예제에서는 'utf8'을 두 번째 전달인자로 받는 것을 확인할 수 있다.
// /etc/passwd 파일을 'utf8'을 사용하여 읽습니다.
fs.readFile('/etc/passwd', 'utf8', ...);
[코드] 두 번째 전달인자 options에 문자열을 전달한 경우
let options = {
encoding: 'utf8', // utf8 인코딩 방식으로 엽니다
flag: 'r' // 읽기 위해 엽니다
}
// /etc/passwd 파일을 options를 사용하여 읽습니다.
fs.readFile('/etc/passwd', options, ...)
[코드] 두 번째 전달인자 options에 객체를 전달한 경우
callback \<Function>err \<Error> | \<AggregateError>data \<string> | \<Buffer>콜백 함수에는 두 가지 매개변수가 존재하는데, 에러가 발생하지 않으면 err는 null 이 되며, data에 문자열이나 Buffer라는 객체가 전달된다. data는 파일의 내용입니다.
💭 data에는 문자열이나 Buffer 가 전달된다. 어떤 경우에 문자열로 전달되는 걸까? 힌트는, 공식 문서의 다음 내용에 나와 있다.
If no encoding is specified, then the raw buffer is returned.
→ 인코딩을 지정하지 않으면 원시 버퍼가 반환됩니다.
예제 코드를 통해 직접 코드를 작성하고 실험해 보자.
JavaScript 파일이 실행되는 디렉토리에, 적당한 텍스트 파일(test.txt)을 새롭게 생성한 후 실행해보자.
fs.readFile('test.txt', 'utf8', (err, data) => {
if (err) {
throw err; // 에러를 던집니다.
}
console.log(data);
});
비동기 요청의 가장 대표적인 사례는 단연 네트워크 요청이다. 네트워크를 통해 이루어지는 요청은 그 형태가 다양하다. 그중에서 URL로 요청하는 경우가 가장 흔한데 URL로 요청하는 것을 가능하게 해 주는 API가 바로 fetch API입니다.
흔히 볼 수 있는 사이트를 예시로 들어보겠다.
이 사이트는 시시각각 변하는 정보와, 늘 고정적인 정보가 따로 분리되어 있는 것을 확인할 수 있다. 이 중에서 최신 뉴스나 날씨/미세먼지 정보가 바로 동적으로 데이터를 받아야 하는 정보이다.
이럴 때 많은 웹사이트에서는 해당 정보만 업데이트하기 위해 요청 API를 이용하는데, 그중 대표적인 fetch API를 이용해 해당 정보를 원격 URL로부터 불러오는 경우를 설명한다.
다음은 원격 URL로부터 정보를 받아와서 특정 DOM 엘리먼트를 업데이트하는 콘셉트를 도식화한 그림입니다.
fetch API는 위와 같이, 특정 URL로부터 정보를 받아오는 역할을 한다. 이 과정이 비동기로 이루어지기 때문에, 경우에 따라 다소 시간이 걸릴 수 있다. 이렇게 시간이 소요되는 작업을 요구할 경우에는 blocking이 발생하면 안 되므로, 특정 DOM에 정보가 표시될 때까지 로딩 창을 대신 띄우는 경우도 있다.
크롬 브라우저의 새 탭을 연 후, 개발자 도구의 콘솔에 다음과 같이 입력해 보자.
let url =
"https://koreanjson.com/posts/1";
fetch(url)
.then((response) => response.json())
.then((json) => console.log(json))
.catch((error) => console.log(error));
앞서 fetch API를 통해 특정 URL로부터 정보를 받아오는 연습을 했다.
이번에는 fetch API와 비슷한 역할을 하는 라이브러리인 Axios에 대해 알아보자. Axios는 브라우저, Node.js를 위한 Promise API를 활용하는 HTTP 비동기 통신 라이브러리이다. Axios는 Fetch API보다 사용이 간편하면서 추가적인 기능들이 포함되어 있다.
| Axios | Fetch API |
|---|---|
| 써드파티 라이브러리로 설치가 필요합니다. | 빌트인 API라 별도의 설치 필요 없습니다. |
| 자동으로 JSON데이터 형식으로 변환됩니다. | .json() 메서드를 사용해야 합니다. |
Axios는 써드파티 라이브러리이기 때문에 사용하기 위해서는 설치해야 한다.
npm install axios
GET 요청
GET 요청은 일반적으로 정보를 요청하기 위해 사용되는 메서드이다. 첫 번째 인자에는 url주소가 들어간다. (url주소는 필수) 두 번째 인자에는 요청 시 사용할 수 있는 옵션들을 설정하게 된다. (옵션의 경우 필수는 아님)
axios.get("url"[,config])
아래는 fetch API와 axios GET 요청의 예시이다. 이를 통해 fetch API와 axios의 사용법과 차이점을 확인해 보자. 또한, Promise와 Async / Await의 차이점도 같이 확인해 보지.
// Promise ver
fetch('https://koreanjson.com/users/1', { method: 'GET' })
.then((response) => response.json())
.then((json) => console.log(json))
.catch((error) => console.log(error));
// Async / Await ver
// async function request() {
// const response = await fetch('https://koreanjson.com/users/1', {
// method: 'GET',
// });
// const data = await response.json();
// console.log(data);
// }
// request();
const appDiv = document.getElementById('app');
appDiv.innerHTML = `
<h1>Fetch API 😊</h1>
<h3>console 창을 확인해주세요! 👇👇👇</h3>
`;
// axios를 사용하기 위해서는 설치한 axios를 불러와야 합니다.
import axios from 'axios';
// Promise ver
axios
.get('https://koreanjson.com/users/1')
.then((response) => {
console.log(response);
const { data } = response;
console.log(data);
})
.catch((error) => console.log(error));
// Async / Await ver
// async function request() {
// const response = await axios.get('https://koreanjson.com/users/1');
// const { data } = response;
// console.log(data);
// }
// request();
const appDiv = document.getElementById('app');
appDiv.innerHTML = `
<h1>Axios ☺️</h1>
<h3>console 창을 확인해주세요! 👇👇👇</h3>
`;
POST 요청
POST 요청은 서버에게 데이터를 보내기 위해 사용되는 메서드이다. 첫 번째 인자에는 url주소가 들어간다. (url주소는 필수) 두 번째 인자에는 요청 시 보낼 데이터를 설정하게 된다. 옵션의 경우 필수는 아니지만 상황에 따라 설정해주어야 한다.
axios.post("url"[, data[, config]])
아래는 fetch API와 axios POST요청의 예시이다. 이를 통해 fetch API와 axios의 사용법과 차이점을 확인해 보자. 또한, Promise와 Async / Await의 차이점도 같이 확인해 보자.
// Promise ver
fetch('https://koreanjson.com/users', {
method: 'POST',
headers: {
// JSON의 형식으로 데이터를 보내준다고 서버에게 알려주는 역할입니다.
'Content-Type': 'application/json',
},
body: JSON.stringify({ nickName: 'ApeachIcetea', age: 20 }),
})
.then((response) => response.json())
.then((json) => console.log(json))
.catch((error) => console.log(error));
// Async / Await ver
// async function request() {
// const response = await fetch('https://koreanjson.com/users', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ nickName: 'ApeachIcetea', age: 20 }),
// });
// const data = await response.json();
// console.log(data);
// }
// request();
const appDiv = document.getElementById('app');
appDiv.innerHTML = `
<h1>Fetch API 😊</h1>
<h3>console 창을 확인해주세요! 👇👇👇</h3>
`;
// axios를 사용하기 위해서는 설치한 axios를 불러와야 합니다.
import axios from 'axios';
// Promise ver
axios
.post('https://koreanjson.com/users', { nickName: 'ApeachIcetea', age: '20' })
.then((response) => {
const { data } = response;
console.log(data);
})
.catch((error) => console.log(error));
// Async / Await ver
// async function request() {
// const response = await axios.post('https://koreanjson.com/users', {
// name: 'ApeachIcetea',
// age: '20',
// });
// const { data } = response;
// console.log(data);
// }
// request();
const appDiv = document.getElementById('app');
appDiv.innerHTML = `
<h1>Axios ☺️</h1>
<h3>console 창을 확인해주세요! 👇👇👇</h3>
`;