내용 보기

작성자

관리자 (IP : ::ffff:172.17.0.1)

날짜

2020-07-07 08:27

제목

[Node.js] 비동기 메커니즘 이해와 설계


이번 포스팅에서는 nodejs에서 가장 중요한 비동기 메커니즘에 대해 다뤄보겠습니다.


우리는 이번글을 통해 nodejs에서 비동기 형태의 코드를 작성하는 방법을 다루게 됩니다.





● 용어이해


비동기 코드를 설계하기 이전에 비동기와 동기를 명확히 이해해야 합니다.


nodejs를 접하면 많이 듣는 용어가 blocking, non-blocking, asynchronous, synchronous 입니다.


이들의 차이를 알아보겠습니다.




· 동기식(synchronous) 코드 VS 비 동기식(asynchronous) 코드

동기식과 비 동기식의 차이는 작업 연산과 / 연산 결과를 수행하는 시점의 차이가 있습니다.




여기선 수행결과를 다른 시점에 처리한다가 핵심입니다.

// 동기식 코드

function fn2() {
console.log('fn2')
}

function fn1(cb) {
console.log('fn1')
cb()
}

function main(cb){
cb(fn2)
}

main(fn1)

// 실행결과
fn1
fn2
// 비동기 코드

setTimeout(() => {
console.log('timer')
}, 0)
console.log('Wow!!')

// 실행결과
Wow!!
timer

앞의 코드는 콜백을 전달하여 비동기 코드처럼 보이지만 동기코드 입니다. 다만 함수 호출시 인자를 함수로 넘겼을 뿐입니다.




하지만, 비동기는 수행결과를 처리할 콜백을 등록한 후 즉시 처리하는 것이 아닌 다른 시점에 처리합니다. setTimeout()에 등록한 콜백은 앞의 동기코드 예제와 다르게 event loop에 존재하는 큐에 저장됩니다. 그리고 이벤트 루프가 돌면서 해당 시점이 되야 수행 결과를 실행하는 형태입니다. 호출하는 시점과 수행 결과를 처리하는 시점이 다를때 우리는 비동기라고 부릅니다.




또 다른 예를 든다면 비동기 형태로 파일을 읽는다면, fs.readFile()를 호출합니다. 이를 호출하면 libuv의 uv_io를 호출하면서 쓰레드가 생성됩니다. 그리고 fs.readFile()을 호출한 지점에서 콜백만 전달한 후 기다리지 않고 다음 코드를 실행합니다. 여기서 쓰레드는 파일을 읽은 결과를 콜백과 함께 전달하여 이벤트 루프의 특정 페이즈가 가지고 있는 큐에 등록합니다. 그리고 이벤트 루프가 돌면서 해당 페이즈에 도달시 큐에 등록된 작업 결과를 꺼내어 처리합니다. 여기서 fs.readFile()을 호출하는 시점과 그 결과를 처리하는 시점이 다르기 때문에 비동기 방식이 됩니다.




· 블록킹(blocking) VS 넌 블록킹(non-blocking)

그렇다면 블록킹/넌 블록킹 방식이란 무엇인가?


블록킹과 넌 블록킹은 동기/비동기와 유사한 개념이긴 하지만 좀 더 추상적인 개념입니다.


코드의 제어권을 가지고 있냐 없냐의 차이가 있습니다.


// 코드 1 - 코드의 제어권을 넘겨받지 않습
function main(cb){
cb(fn2)
}

main(fn1)

// 코드 2 - 코드의 제어권을 넘겨받음
setTimeout(() => {
console.log('timer')
}, 0)
console.log('Wow!!')

동기/비동기에서 해당 예제를 봤는데. 여기서도 블록킹과 넌 블록킹 개념을 알 수 있습니다.




코드1에서 main(fn1)로 호출하더라도 내부가 완전히 처리될 때까지 그 아래 코드를 실행하지 않습니다. 이 경우 블럭킹이라고 합니다. 하지만 코드 2에선 setTimeout()을 호출하고 그 아래의 console.log()를 실행합니다. 이 경우 코드의 제어권을 다시 넘겨받게 됩니다.




정리하면, 코드1 main함수를 호출할 때 main 함수는 코드의 제어권을 유지하고 있습니다.

코드 2는 setTimeout을 호출할 때 해당 함수는 코드의 제어권을 넘겨주게 됩니다. 코드의 제어권이 넘어가면서 해당 줄에서 멈추지 않고 그 아래의 코드를 실행할 수 있게 됩니다.




비동기와 넌 블록킹

동기와 블럭킹이 비슷한 형태로 동작하지만 이런 미세한 차이를 이해해야 nodejs를 보다 깊이있게 이해할 수 있습니다.




● 비동기 만들기

이제 비동기 형태로 동작하는 함수를 만들어 보도록 하겠습니다. 우리는 이제 다음과 같은 형태로 코드를 작성하더라도 비동기 형태로 동작하지 않는다는것을 알 수 있습니다.

// 동기식 코드
function fn2() {
console.log('fn2')
}

function fn1(cb) {
console.log('fn1')
cb()
}

function main(cb){
cb(fn2)
}

main(fn1)

비동기 형태로 동작하는 코드를 만들어보기 전에 왜 비동기 형태로 코드를 만들어야 하는지 궁금하실겁니다.




· 비동기 코드를 작성해야 하는 이유


우리가 비동기 코드를 작성해야 하는 이유는 2가지가 있습니다.




▶ 코드 실행 시점을 늦춰주기 위해 사용




첫 번째는 코드 실행 시점을 늦춰주기 위해서 입니다. 왜 코드의 실행을 늦춰야 할까요? 일반적으로 사용하는 경우가 setTimeout(), setInterval()을 사용하는 경우입니다. 이 경우가 아니더라도 다음과 같은 경우가 있습니다.

// 즉시 호출
const EventEmitter = require('events');
const util = require('util');

function MyEmitter1() {
EventEmitter.call(this);
this.emit('event'); // 즉시 이벤트를 발생하는 경우. 이 경우 on('event') 이벤트에 콜백이 등록이 안되있음
}
util.inherits(MyEmitter1, EventEmitter);

const myEmitter1 = new MyEmitter1();
myEmitter1.on('event', () => {
console.log('an event occurred!');
});
// 한 템포 늦춘경우
function MyEmitter2() {
EventEmitter.call(this);
process.nextTick(() => { // 이벤트 발생 시점을 한 템포 늦춘경우. 템포를 늦춰줌으로 on('event')에 콜백이 등록되기를 기다림
this.emit('event');
})
}
util.inherits(MyEmitter2, EventEmitter);

// 코드가 생성자 아래에 on 메서드가 호출될 수 밖에 없음
const myEmitter2 = new MyEmitter2();
myEmitter2.on('event', () => {
console.log('an event occurred!');
});

이 두 코드의 차이는 emit() 호출 시점입니다. MyEmitter2()의 경우 nextTick()을 통해 nextTickQueue에 콜백을 등록한 후 이벤트 루프가 해당 큐의 작업을 꺼내기를 기다리는 반면에 MyEmitter1()은 즉시 이벤트를 발생합니다.




여기서 왜 process.nextTick()으로 기다려줘야 하는지는 그 아래의 코드들을 보면 이해할 수 있습니다. new MyEmitter1()과 new MyEmitter2()를 호출할 때 아직 on('event')로 이벤트가 등록되기 전 입니다. 그렇기 때문에 템포를 늦춰줌으로써 이벤트 리스터가 등록되기를 기다리는 것 입니다.




process.nextTick()은 이처럼 생성자가 호출 시 이벤트가 아직 등록되기 전에 이벤트 리스너가 등록된 후 emit하고 싶을때 사용하게 됩니다. timer를 거는 것 보다 훨씬 효율적인 코드가 됩니다. process.nextTick(() => {}) 대신 setTimeout(() => {}, 0) 형태로 작성해도 되지만 nextTIck()은 비교 연산이 없기 때문에 좀 더 효율적이라고 할 수 있습니다.




정리해보면, 코드의 호출상 어쩔수 없이 코드의 흐름과 다르게 작성될 때가 있음 이때 코드의 실행 시점을 한 템포 늦춰줌으로써 아래 코드가 완전히 실행된 후 실행되도록 늦춰주고 싶을때 사용합니다.




▶ 처리 속도 때문에 사용


두 번째로 우리가 일반적으로 알고 있는 이유입니다. 작업 처리속도가 매우 높기 때문에 동기형태로 동작하면 다른 코드를 실행할 수 없기 때문입니다.




동기 형태로 동작하면 정말로 다른 코드를 실행할 수 없을까요?? 직접 확인해보도록 하겠습니다.

const http = require('http');
http.createServer((request, response) => {
console.log('server start!');
response.statusCode = 200;
response.setHeader('Content-Type', 'text/plain');
response.write('hi\n');
response.end('the end!');
while (true) {

}
}).listen(8080);

해당 코드를 실행한 후 localhost:8080으로 접속해보세요. 그리고 브라우저를 하나 더 키고 또 접속하면 어떤 반응을 하는지 확인해보세요. 서버가 반응을 하지 않을 겁니다. 그 이유는 while(true){}에서 블락이 되서 더 이상 코드를 실행할 수 없는 상태가 됩니다. 여기서 while()과 같은 역할이 동기/비동기 작업인 파일읽기/네트워크작업/소켓작업/디비작업 등입니다. 즉, 동기/비동기 적업을 libuv는 내부적으로 커널 API를 이용하거나 쓰레드를 이용하는 겁니다. 다른 쪽으로 작업을 떠 넘기고 이를 완료하면 콜백을 등록하는 형태인거죠. nodejs는 작업 결과를 실행하는 콜백만 간결하게 처리하는 역할을 합니다.

const crypto = require('crypto');

const start = Date.now();
crypto.pbkdf2( 'a', 'b', 100000, 512, 'sha512', () => {
console.log( '1:', Date.now() - start );
});
crypto.pbkdf2( 'a', 'b', 100000, 512, 'sha512', () => {
console.log( '2:', Date.now() - start );
});

console.log('Wow!!!')

바로 이 코드가 비동기 코드 형태로 작성해야 하는 이유를 보여줍니다. 여기서 Wow!!가 crypto 콜백으로 등록한 함수보다 먼저 실행합니다. 그 이유는 crypto 함수를 호출하면 libuv에게 작업을 위임하고 작업 완료시 콜백을 등록합니다. 작업 위임만 하고 제어권을 돌려받고 console.log()를 실행합니다. 동기 형태로 블락킹이 되면 Wow!!가 crypto보다 늦게 호출합니다.




여기서 중요한 점은 단순히 Wow!!가 빠르게 호출한다가 아니라 늦게 처리되는 작업을 다른 시스템에게 넘겨줌으로써 외부의 작업을 받을 수 있다는 점입니다.




다시한번 말하지만 단순히 넌 블록킹이 되면서 아래의 코드를 빠르게 실행한다가 아니라 작업을 넘겨줌으로써 외부로부터 요청을 받을 수 있게 됩니다.

crypto.pbkdf2( 'a', 'b', 100000, 512, 'sha512', () => {
console.log( '2:', Date.now() - start );
});

만약 이 코드가 동기로 동작 한다면 해당 코드실행이 완료될 때까지 약 1초가량 다른 작업 처리가 불가능 합니다.




libuv로 넘어갈 때 동기형태의 코드는 쓰레드풀을 이용하는데 대기중인 쓰레드가 없는경우 쓰레드를 할당받기 위해 기다려야 하기 때문에 딜레이가 걸리는 건 마찬가지 입니다.




여기서 nodejs가 디비의 요청이 많거나 파일로 부터 데이터를 자주 읽고 쓴는게 부적합하도 하는 이유가 이들은 libuv로 작업이 넘어갈 때 동기작업(파일처리/디비처리는 동기작업으로 처리됨)은 쓰레드 풀에서 쓰레드를 할당 받아서 사용하게 됩니다. 하지만 nodejs는 쓰레드풀이 제한적이므로 쓰레드를 이용하기 위해 딜레이가 걸릴 수 있기 떄문에 그렇습니다. 물론 UV_THREADPOOL_SIZE조절하여 쓰레드 생성 가능 갯수를 늘리거나 줄일 수 있습니다.




uv를 직접 호출하기 위해선 javascript로는 불가능 하고 cpp를 이용해야 합니다.


https://nodejs.org/api/addons.html#addons_callbacks

node-gyp를 이용하여 cpp로 작성한 코드를 js가 실행할 수 있도록 빌드해줍니다. 여기선 node-gyp를 이용하는 방법이나 cpp로 직접 작성하지 않고 깃허브에 올라온 간단한 예제를 실행해봄으로써 이해를 돕겠습니다.




앞의 링크는 uv에 등록하는 부분은 아니고 javascript에서 콜백 함수를 넘기면 해당 함수를 cpp에서 처리하는 방법입니다.


# node-gyp 설치
$ npm install -g node-gyp
# 샘플코드 설치
$ git clone https://github.com/paulhauner/example-async-node-addon.git
$ cd async-addon

# 빌드준비 & 빌드
$ node-gyp configure
$ node-gyp build

# 실행
$ cd ..
$ node index.js
const addon = require('./async-addon/build/Release/asyncAddon');

setInterval(function() {
console.log('Another operation');
}, 500);

addon.doTask(function(data) {
console.log(data);
process.exit();
});
console.log('Async task started.')

# 실행결과

Async task started.
Another operation
Another operation
Another operation
Another operation
Another operation
Async task processed.

앞에서 node-gyp로 빌드하면 해당 디렉터리 기준으로 build/release/머시기로 js에서 호출가능한 형태로 생성합니다.


이 코드가 정말 libuv의 uv_io 쓰레드 풀에서 동작하는지 확인해 보도록 하죠

process.env.UV_THREADPOOL_SIZE = 2;

const addon = require('./async-addon/build/Release/asyncAddon');

// setInterval(function() {
// console.log('Another operation');
// }, 500);

addon.doTask(function(data) {
console.log(1)
console.log(data);
});
addon.doTask(function(data) {
console.log(2)
console.log(data);
});
addon.doTask(function(data) {
console.log(3)
console.log(data);
});
addon.doTask(function(data) {
console.log(4)
console.log(data);
});
addon.doTask(function(data) {
console.log(5)
console.log(data);
});
console.log('Async task started.')

해당 코드는 쓰레드풀의 갯수를 2개로 제한하고 실행합니다. 실행해보면 2개씩 끊어서 실행되는 것을 확인할 수 있습니다.




nodejs에서 작업처리 효율을 위해 쓰레드를 이용해야 하는데 process.nextTick()은 딜레이의 용도이니 제외대상이고 cpp로 작성한 파일을 node-gyp로 빌드하는 방법밖에 없는건가요?라고 질문하실 수 있는데...

네 그렇습니다.


이 글을 본 여러분은 지금 당장 cpp 공부와 v8 공부를 해야합니다


진심으로 여러분에게 사죄합니다.

제가 또 공부할 거리를 던져주었네요 ㅎㅎ




라고 끝내면 섭섭하니 앞에서 node-gyp로 빌드한 cpp 코드를 아주 간략하게 훑어보겠습니다.




· async-addon.cc 기본분석

void init(Local<Object> exports) {
NODE_SET_METHOD(exports, "doTask", DoTaskAsync);
}

코드 가장 아래로 내리면 해당 코드가 있습니다. 해당 코드는 모듈을 가져왔을 때 외부로 노출시킬 함수를 설정하는 부분입니다. 두 번째 인자가 외부로 노출할 이름이고 세 번째 인자가 두 번째 인자로 접근할 때 실행할 코드입니다.


const addon = require('./async-addon/build/Release/asyncAddon');

addon.doTask(function(data) {
console.log(data);
});

두 번째 인자인 doTask를 통해 세 번째 인자로 전달한 DoTaskAsync()를 실행합니다.

그럼 우리는 async-addon.cc에서 DoTaskAsync()를 찾아서 분석하면 됩니다.


void DoTaskAsync(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();


Work * work = new Work();
work->request.data = work;

// args[0] is where we pick the callback function out of the JS function params.
// Because we chose args[0], we must supply the callback fn as the first parameter
Local<Function> callback = Local<Function>::Cast(args[0]);
work->callback.Reset(isolate, callback);

uv_queue_work(uv_default_loop(), &work->request, WorkAsync, WorkAsyncComplete);

args.GetReturnValue().Set(Undefined(isolate));
}

해당 함수가 받는 인자는 직감적으로 콜백 함수를 받는다라는 것을 알 수 있습니다. 바로 우리가 javascript로 호출할 때 콜백으로 등록한 함수가 됩니다.


work 객체를 이용하여 필요한 작업에 대한 내용을 전달합니다.


uv_queue_work(uv_default_loop(), &work->request, WorkAsync, WorkAsyncComplete);

를 이용하여 uv를 호출합니다. 세 번째 인자를 실행하고 네 번째 인자를 실행하게 됩니다. WorkAsync()와 WorkAsyncComplete()의 전달받는 파라미터를 보면 uv_work_t *req 입니다. &work->request를 받게 됩니다. 그리고 uv_queue_work()가 호출되자마자 반환합니다.


그럼 우리는 WorkAsync()와 WorkAsyncComplete()를 이해하면 됩니다.

static void WorkAsync(uv_work_t *req) {
Work *work = static_cast<Work *>(req->data);

sleep(3);
work->result = "Async task processed.";
}

index.js를 실행하면 딜레이가 생겼었는데 그 이유가 sleep()을 했기 때문입니다. 그리고 work 객체에서 result 속성의 값을 Async task processed로 저장합니다. 여기서 포인터를 넘겼기 때문에 해당 포인터를 쓰면 해당 값을 그대로 사용할 수 있습니다.


다음으로, WorkAsyncComplete()를 보겠습니다.

static void WorkAsyncComplete(uv_work_t *req,int status)
{
Isolate * isolate = Isolate::GetCurrent();

v8::HandleScope handleScope(isolate);

Work *work = static_cast<Work *>(req->data);

const char *result = work->result.c_str();
Local<Value> argv[1] = { String::NewFromUtf8(isolate, result) };

// https://stackoverflow.com/questions/13826803/calling-javascript-function-from-a-c-callback-in-v8/28554065#28554065
Local<Function>::New(isolate, work->callback)->Call(isolate->GetCurrentContext()->Global(), 1, argv);

work->callback.Reset();
delete work;
}

해당 함수는 WorkAsync()가 완료되면 실행합니다. 즉, 큐에 등록되는 본체입니다. 큐에선 해당 함수를 실행하게 되고 해당 함수가 실행하면서 javascript에서 등록한 콜백을 다음과 같이 호출합니다.

Local<Function>::New(isolate, work->callback)->Call(isolate->GetCurrentContext()->Global(), 1, argv);


마지막으로 work 객체에 있는 callback을 지운 후 work 객체를 지웁니다.

struct Work {
uv_work_t request;
Persistent<Function> callback;
string result;
};

work는 위와같이 생겼습니다. 객체보다는 구조체가 좀 더 정확한 표현이되겠습니다.


해당 코드들을 명확히 이해하기 위해서는 v8과 libuv 라이브러리의 API를 잘 숙지하셔야 합니다.

출처1

https://m.blog.naver.com/pjt3591oo/221978583678

출처2