내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2021-01-14 08:34

제목

[Node.js] callbaack, promise, async/await를 활용한 비동기 처리 기법


● 콜백(callback)

비동기를 처리하는 기법중 가장 단순하고 효율이 좋은 방법은 콜백을 이용하는 것 입니다. 여기서 효율이란 컴퓨터 입장에서 가장 적합한 방식을 의미합니다. 여기서 왜 콜백이 효율이 가장 좋은지는 뒤에서 Promise를 다룰 때 알아보겠습니다.

· 1급시민

콜백은 JavaScript의 특징중 하나인 함수는 1급 시민으로 취급하기 때문에 가능한 방법입니다.

1급 함수이기 때문에 1급 객체 조건을 충족하며, 1급 객체 이기 때문에 1급 시민 자격을 얻을 수 있는 조건을 만족할 수 있습니다.

물론 C나 CPP에서도 함수를 전달하는 방법이 있긴 합니다.

우리는 다음과 같은 특징을 가진 녀석을 1급 시민, 1급 객체, 1급 함수라고 합니다.

1급시민 1. 변수에 할당가능 2. 매개변수, 파라미터로 사용가능 3. 반환값으로 사용가능 1급객체 1. 1급시민이면서 객체인 것 1급함수 1. 1급시민 조건을 만족하는 함수

여기서 한가지 짚고 넘어가야 할 부분은 자바스크립트는 함수는 객체입니다.

dd




생성자인 constructor를 보면 Object()를 기반으로 생성된 것을 확인할 수 있습니다.

콜백은 바로 이러한 특성을 이용하여 너에게 일을 시킬테니 일을 마치면 실행할 함수를 전달하는 행위를 의미합니다.

· 사용방법

const fs = require('fs') fs.readFile('./test.txt', (err, data) => { console.log(data.toString()) })

앞의 코드는 fs(File System) 모듈을 이용하여 test.txt의 내용을 출력하는 코드입니다. readFile()은 첫 번째 인자로 대상이 되는 파일, 두 번째 인자로 성공 유/무에 상관없이 호출되는 콜백 함수를 전달합니다.

일반 적으로 콜백 함수는 첫 번째 인자로 에러값, 두 번째 인자로 결과값을 반환합니다. 여기서 이해하기 힘들 수 있습니다. 콜백함수가 어떻게 돌아가는지 이해를 해보겠습니다.

function sum(a, b, cb) { try { cb(null, a + b) } catch (err) { cb('에러발생', null) } } sum(1, 2, (err, result) => { console.log(err) console.log(result) })

콜백 함수의 작동 원리를 이해하기 위해선 앞의 코드를 이해할 수 있으면 충분합니다. 콜백함수를 전달한다는 것은 정의된 함수를 전달하여 전달받은 메서드에서 해당 함수를 실행합니다.

그리고 여기서 한 가지 짚고 넘어갈 부분은 바로 콜백 함수를 이용한다고 해서 반드시 비동기는 아닙니다. 콜백의 정확한 의미는 실행가능한 코드를 의미합니다. 이 부분은 눈을감고 곰곰히 생각해보면 좋을 것 같습니다.


· 콜백 단점 & 해결

콜백의 단점을 설명하기 전에 다음 코드를 실행해보도록 하겠습니다.

const fs = require('fs') fs.readFile('./test.txt', (err, data) => { console.log(1) console.log(data.toString()) }) fs.readFile('./test.txt', (err, data) => { console.log(2) console.log(data.toString()) }) fs.readFile('./test.txt',(err,data) => { console.log(3) console.log(data.toString()) }) console.log('test')

여러분들은 앞의 코드실행 결과를 예측할 수 있나요?

1 test.txt 내용 2 test.txt 내용 3 test.txt 내용 test

으로 예측할 수 있지만 아무 누구도 해당 코드의 실행 결과를 예측할 수 없습니다. 그 이유는 fs를 호출하는 순간 libuv는 쓰레드를 이용하여 병렬적으로 실행한 후 콜백을 이벤트 루프에 동작하기 때문에 어느 fs.readFile이 먼저 실행된다고 장담할 수 없습니다.

만약, 첫 번째 readFile은 100Gb 파일을 읽고 세 번째 readFile에서 1Kb를 읽는다면 libuv에 존재하는 쓰레드는 1Kb를 먼저읽고 해당 콜백을 이벤트 루프에 등록하기 때문에 세 번째 readFile의 콜백을 먼저 실행합니다.

하지만 코드를 작성하다보면 동기적으로 동작하기를 원합니다.

const fs = require('fs') fs.readFile('./test.txt', (err, data) => { console.log(1) console.log(data.toString()) fs.readFile('./test.txt', (err, data) => { console.log(2) console.log(data.toString()) }) fs.readFile('./test.txt', (err, data) => { console.log(3) console.log(data.toString()) console.log('test') }) })

해당 코드는 순서의 보장을 명확히 받을 수 있습니다.

다음으로 에러 핸들링을 시도해보면 다음과 같습니다.

const fs = require('fs') fs.readFile('./test.txt', (err, data) => { if (err) { consolee.log('에러 핸들링') return } console.log(1) console.log(data.toString()) fs.readFile('./test.txt', (err, data) => { if (err) { consolee.log('에러 핸들링') return } console.log(2) console.log(data.toString()) }) fs.readFile('./test.txt', (err, data) => { if (err) { consolee.log('에러 핸들링') return } console.log(3) console.log(data.toString()) console.log('test') }) })

이렇게 코드를 작성하면.... 깊이가 깊어지는 단점이 있습니다. 이러한 현상을 콜백지옥(callback hell) 이라고 표현합니다.

해당 부분은 다음과 같이 해결할 수 있습니다.

const fs = require('fs') const thirdFs = () => { fs.readFile('./test.txt', (err, data) => { console.log(3) console.log(data.toString()) console.log('test') }) } const secondFs=()=>{ fs.readFile('./test.txt', (err, data) => { console.log(2) console.log(data.toString()) thirdFs() }) } const firstFs = () => { fs.readFile('./test.txt', (err, data) => { console.log(1) console.log(data.toString()) secondFs() }) } firstFs()

손이 많이가는 방법이지만... 그래도 깊이가 깊어지는 현상은 해결했습니다.

그렇지만 해당 방식을 선호하진 않습니다.

● Promise

두 번째 방법으로 가장 많이사용 방법은 바로 Promise 객체를 이용합니다. Promise는 패턴이며 Promise로 만들어진 객체를 이용합니다. Promisepending, resolve, reject를 상태로 응답을 지연시켜 동시에 실행되는 것을 제어하기 위해 사용하는 패턴입니다.

Promise의 사용법을 알아보겠습니다.

· 사용법

Promise를 사용하기 위해서는 Promise는 객체라는 점 입니다. 객체이기 때문에 new 키워드를 이용합니다.

let p = new Promise() console.log(p)

해당 코드를 실행하면 에러가 발생합니다. 여기서 에러가 상당히 중요한 정보를 알려줍니다.

let p = new Promise() ^ TypeError: Promise resolver undefined is not a function at new Promise (<anonymous>)

여기서 핵심 키워드는 resolver 입니다.

Promise는 콜백 함수를 받는 객체입니다. 콜백 함수는 resolver와 reject 함수를 전달받습니다.

let p = new Promise((resolve, reject) => { }) console.log(p)

Promise가 전달받는 콜백함수의 전달 인자는 함수라는 점 입니다. 콜백함수 안에 우리가 동작하고 싶은 코드를 작성합니다. 해당 코드가 비동기여도 됩니다.

여기서 부터 집중!!!!

resolve는 성공

reject는 실패를 의미하는 함수입니다.

resolve(): 성공 reject(): 실패

resolve는 번역을 하면 풀어준다는 의미이며 사실 이 의미가 정확하긴 하지만 일단은 성공의 의미로 사용하겠습니다.

앞의 코드를 실행하면 p는 다음과 같이 출력합니다.

Promise { <pending> }

Promise 객체는 생성과 동시에 pending 상태를 가집니다. 여기서 resolve나 reject가 호출됨에 따라 상태를 바꾸게 됩니다.

resolve 상태일 땐 then을 호출하게 됩니다.

reject 상태는 catch를 호출합니다.

resolve() 호출 시 then 호출 reject() 호출 시 catch 호출
let p = new Promise((resolve, reject) => { setTimeout(() => { resolve('hello world') }, 3000) }) p.then((data) => { console.log(data) }).catch((err) => { console.log(err) })

여기서 setTimeout을 이용하여 resolve를 호출합니다. resolve는 then을 호출하기 때문에 (data) => {console.log(data)}를 호출합니다.

만약 reject("hell world")를 호출한다면 then의 콜백이 아닌 catch의 콜백을 호출합니다.

Promise는 다음과 같이 작성할 수도 있습니다.

let p = new Promise((resolve, reject) => { setTimeout(() => { resolve('hello world') }, 3000) }) p.then((data) => { console.log(1) console.log(data) return p }).then((data) => { console.log(2) console.log(data) return p }).then((data) => { console.log(3) console.log(data) return p }).then((data) => { console.log(4) console.log(data) }).catch((err) => { console.log(err) })

then은 Promise 객체를 응답할 수 있습니다.

해당 방식은 콜백을 사용한 방식보다는 깊이가 깊지는 않지만 에러 핸들링의 이슈는 여전히 남아있습니다.

앞의코드처럼 catch로 묶을경우 then에서 에러가 발생하면 가장 가까운 catch가 실행됩니다.

다른 방법으론 then의 두 번째 인자로 reject 호출시 실행하는 콜백 함수를 전달할 수 있습니다. 이 방법은 해당 then에 대해서만 에러를 처리합니다.

let p = new Promise((resolve, reject) => { setTimeout(() => { reject('hell') }, 3000) }) p.then((data) => { console.log(1) console.log(data) return p }, (err) => { console.log('err 1') console.log(err) return new Promise((resolve, reject) => {}) })

이러한 형태로 에러 핸들링이 가능합니다.

만약 Promise 객체를 좀더 확장 하기 위해선 다음과 같이 작성할 수 있습니다.

const fs = require('fs') const_p = (filename) => { return new Promise((resolve, reject) => { fs.readFile(filename, (err, data) => { if(err) reject(err) resolve(data.toString()) }) }) }

파일경로를 전달받아 파일결과를 반환하는 Promise를 반환하는 함수를 만들어 줍니다.

_p().then((result) => { console.log(1) console.log(result) return _p('file1') }).then((result)=>{ console.log(2) console.log(result) return _p('file2') }).then((result)=>{ console.log(3) console.log(result) return _p('file3') }).catch(err=>{ console.log('err 발생') console.log(err) return _p() }).then((result)=>{ console.log(4) console.log(result) })

앞의 코드처럼 n개의 then을 하나로 핸들링하기 위해 catch를 이용하여 코드를 작성할 수 있습니다. 만약 각 then마다 핸들링을 해야한다면 then의 두 번째 인자로 콜백을 전달하면 됩니다.

● async/await

Promise는 매우 훌륭한 패턴이고 오랫동안 사용된 패턴입니다. 하지만 전통적인 프로그래밍(C, Java, c# 등)에선 Promise 패턴도 어색한 방법입니다.

우리는 try ~ catch와 같은 형태로 코드가 동작되기를 원합니다. 이럴때 사용하는 것이 async/awit이며 이를 잘 이해하기 위해선 Promise를 이해해야 합니다. async는 Promise를 의미하며 await는 pending 상태를 기다리는 역할을 합니다 여기서 reject각 발생하면 에러를 발생시켜 catch 구문을 동작시킬 수 있습니다.

let p1 = new Promise((resolve, reject) => { setTimeout(() => { reject('hello world') }, 3000) }) let p2 = new Promise((resolve, reject) => { setTimeout(() => { reject('hell world') }, 3000) }) ;(async () => { try { let a = await p1; } catch(err) { console.log(err) } try { let a = await p2; } catch(err) { console.log(err) } })()

해당 코드를 실행하면 다음과 같습니다.

hello world hell world

우리가 전통적으로 작성하던 방식으로 코드를 작성할 수 있게 됩니다.

· async/await와 Promise의 조합

async/await와 Promise는 형제입니다.

const newMetaPromise = async () => { return 'new meta'; }; async function another() { try { let result = await newMetaPromise(); console.log(result); } catch (err) { console.error(err); } } another()

async를 이용하면 Promise를 훨씬 간결하게 만들어 줄 수 있습니다.

● Promise 사용 주의

· Prormise.all()

async/await가 나오면서 Promise보다 async/await 위주로 코드를 작성합니다. 하지만 Promise를 사용하는 특별한 경우가 있습니다.

Promise.all은 우리가 병렬적인 처리가 필요할 때 사용하는 매우 효과적인 방법입니다.

function getTime(){ return new Date().getTime(); } const a = (t) => { return new Promise((resolve, reject) => { setInterval( function(){ resolve(`${t}초 end`) }, t*1000 ); }) } async function p() { let t1 = getTime(); let d1 = await Promise.all([a(1), a(2)]) let t2 = getTime() console.log( (t2 - t1) / 1000) let d3 = await a(3) let t3 = getTime() console.log( (t3 - t2) / 1000) console.log(d1, d3) } p()

해당 코드는 직접 분석해보면 async/await와 Promise를 이해하는데 큰 도움이 될 것입니다.

· Promise, async/await 단점

앞서서 Promise와 async/await보다 콜백(callback)을 이용한 방법이 가장 효율적이라고 했습니다. 코드를 이해하는 입장에선 async/await가 가장 이해하기 좋으며 Promise, callback순으로 가독성이 좋습니다. 하지만 컴퓨터입장에선 callback을 좋아합니다. 그 이유는 Promise나 async/await는 generator를 이용하여 비동기 호출이 완료될때까지 강제로 코드 실행을 멈추고 있기 때문입니다.

강제로 코드를 멈춘다는 것이 큰 문제는 이벤트루프가 멈춘다는 점 입니다. 비동기의 넌블럭의 특징은 코드의 실행을 다른 컨텍스트로 넘겨서 이벤트 루프를 지속적으로 검사하여 실행하게 됩니다. 하지만 async/await는 이벤트루프를 검사해야하는데 await에서 블락킹 현상이 일어나게 됩니다. 그렇기 때문에 async/await를 남발하게 되면 어플리케이션의 성능 저하가 일어날 수 있습니다.

지금까지 ES6에서 비동기 처리기법을 알아보았습니다.



출처1

https://blog.naver.com/pjt3591oo/222204144528

출처2