[Node.js] 비동기 개념에 익숙해지기

개요

C++를 공부하면 포인터에서 숨이 턱 막히듯이, 자바스크립트를 공부하다 보면 넘어야 할 산이 비동기라는 개념입니다. 이 비동기라는 개념이 좀 잡혀있어야, express 라는 웹서버 모듈에서 핸들러를 어떻게 다루어야 할지 감이 오고, Promise 를 어떻게 사용해야 할 지도 조금 감이 생기지 않을까 싶습니다.

이 글은 이벤트 루프 등에 대해서 자세히 다루지 않습니다. 비동기, 논블로킹 IO 등 개념에 대한 설명도 하지 않습니다. 그냥 느낌만 설명하려고 노력했습니다.

Node.js 와 브라우저는 자바스크립트를 구동하는 방식이 큰 차이는 없습니다. 다만 아래 코드는 모두 Node.js, MacOS 환경에서 실행됩니다.

목표

비동기라는 개념과 익숙해지기


비동기

asynchronous 의 뜻은 실시간이 아니라는 뜻입니다. sync 는 실시간이라는 뜻인가요? 네. 넷플릭스 영화를 보는데 동영상과 소리가 타이밍이 안맞으면 싱크가 안맞는다는 거고, 너무 이상하겠죠? 동영상과 소리가 타이밍이 딱 맞는건 너무 당연합니다. 이 타이밍에는 빈 틈이 없습니다. 모든 것들이 순서대로 착착 잘 맞아떨어져야 합니다.

하지만 비동기에는 빈 틈이 있습니다. 비동기 코드는 작업 간 빈 틈을 허용합니다. 왜 허용을 하냐구요? 동영상은 소리와 영상이 타이밍을 잘 맞춰야 하지만, 대개 “작업”이라는 것들은 빨리 끝나면 끝날수록 좋기 때문입니다. 아래 일화를 보시죠.


요리 초보와 요리 고수

최근에 좋은 동네로 이사를 가서, 친구들에게 집으로 오라고 초대했습니다. 이제 집들이 호스트로서 요리를 만들고자 합니다. 닭볶음탕과 잡채 두 가지를 만들겠습니다. 여기서 고수와 초보의 차이점이 드러납니다.

요리 고수

  1. 먼저 닭고기에 양념을 재운다
  2. 잡채, 닭볶음탕용 야채를 썰고, 물을 올린다
  3. 당면을 삶으면서 야채를 볶는다
  4. 당면을 건지고, 삶은 물을 일부 덜어내고 그대로 닭고기와 야채 투하
  5. 도마나 칼 등을 정리한다
  6. 당면과 야채를 버무린다
  7. 등등…

요리 초보

  1. 먼저 닭고기에 양념을 재운다
  2. 닭볶음탕용 야채를 칼로 썬다
  3. 물을 올리고 끓을 때까지 좀 기다렸다가 닭고기와 야채 투하
  4. 칼과 도마를 깨끗이 씻는다
  5. 잡채용 야채를 썬다
  6. 당면을 삶기 위해 물을 올린다
  7. 등등…

요리를 할 때에는 작업 간 전환이 자유롭기 때문에 작업을 어떻게 분배하냐에 따라서 더더욱 효율적으로 요리를 끝마칠 수 있습니다. 요리를 무작정 차례대로 진행한다면, 요리 초보처럼 시간을 어마어마하게 사용하게 되겠지요. 비동기는, 물이 끓는 걸 기다리듯 “기다려야 하는 작업”이 있을 때 빛을 발합니다.

우리는 사람이 한 명이어도 동시에 여러 작업을 할 수 있다는 걸 알고 있습니다. 우리의 자바스크립트도 싱글 스레드입니다.

—-

비동기 작업은 어떻게 코딩할까?

기다려야 하는 작업은 비동기로 처리하면 좋습니다. 하지만 비동기 작업은 코드를 읽기 어렵게 만듭니다. 파일을 단순히 읽어서 출력하는 프로그램이 있다고 가정하고, 이것의 동기 버전과 비동기 버전의 차이를 살펴봅시다.

먼저 동기 버전입니다.

  1. 파일 읽기를 시도합니다.
  2. 파일을 다 읽을 때까지 기다립니다.
  3. 파일의 내용을 출력합니다.
  4. 나머지 작업을 수행합니다.

이 작업을 만약 코딩하라고 하면, 좀 쉬울 것 같습니다. 왜냐하면 그냥 차례로 적으면 되기 때문입니다! 애초에 프로그램은 한줄 한줄 차례대로 작성하게 되어 있잖아요? 쌍팔년대 등장한 프로그래밍 언어부터의 국룰이니까요. 실제 코드를 작성하면 아래와 같이 됩니다.

const fs = require('fs');

const data = fs.readFileSync('/etc/hosts');

console.log(data.toString());

console.log('script end');

그런데 만약, 파일을 읽으면서 동시에 다른 작업을 하고자 한다면, 즉 비동기적으로 코딩한다고 가정하면, 조금 머리가 아픕니다. 일단 순서를 생각해봅시다.

  1. 파일 읽기를 시도합니다.
  2. 파일을 다 읽으면 파일의 내용을 출력합니다(A).
  3. 파일을 읽는 동안 다른 작업을 수행합니다(B).

문제는 파일의 내용을 출력하기(A) 전에, 다른 작업(B)이 수행될 수 있다는 겁니다. 순차적인 코딩으론 한계에 봉착하게 되었네요. 코드의 순서가 아래에서 위로는 갈 수 없기 때문이에요. 도대체 어떻게 코드로 만들 수 있을까요?

흔하고 간단하게 구현되는 방법이 있습니다. 바로 콜백 함수를 넘기는 것이죠. readFile 함수는 인자로 또 다른 함수를 받습니다. 파일 읽기가 완료된 후에 실행할 함수를 우리보고 지정하라고 하는 것이죠. 코드는 아래와 같은 모습이 됩니다.

const fs = require('fs');

fs.readFile('/etc/hosts', (err, data) => {
  console.log(data.toString());
});

console.log('script end');

fs.readFile 을 호출한다고 해서 코드가 대기되지 않습니다. 곧바로 script end 가 출력되는 걸 볼 수 있을 겁니다. 그 다음 파일의 내용이 출력됩니다.

싱글 스레드

자바스크립트는 기본적으로 싱글 스레드입니다. 위에서 요리사를 예로 들며 간단하게 언급했습니다. 사실 뭐 이미 귀에 딱지가 앉도록 들어보셨을 테죠.

자바스크립트가 싱글스레드라는 점이 시사하는 바가 있습니다. 바로 현재 작업하고 있는 것들이 정말 CPU를 빡세게 돌려야 완료되는 작업이라면, 비동기 작업은 그만큼 뒤로 밀립니다. 예를 들어 0.5초 뒤에 “완료!”라는 로그를 띄우기 위해 setTimeout을 사용하고, 그 바로 뒤에 1초 동안 빡세게 작업하는 (아래 예시 코드에서는 단순히 1초 동안 루프를 돌립니다.) 코드가 있다고 가정합니다.

setTimeout(() => {
  console.log('완료!');
}, 500);

const start = Date.now();

while (Date.now() - start < 1000) {
  // do nothing
}

console.log("1초가 지났습니다.");

결과는 어떨까요?

1초가 지났습니다.
완료!

1초 뒤에 1초가 지났습니다. 라는 로그가 뜨고, 그 뒤에 완료! 가 뜹니다. 만약 자바스크립트가 싱글 스레드가 아니라면 while 문이 돌고 있을 동안 완료! 를 출력할 수 있는 가능성이 게 있지만, 자바스크립트는 싱글스레드이기 때문에 한 번에 하나의 코드만 수행할 수 있고, 이 말인 즉슨, 현재 수행하고 있는 코드가 끝나지 않는다면 다른 비동기로 처리되는 코드들이 제때 실행되지 않는다는 것입니다!

기술적인 측면에서 본다면, 싱글 스레드는 멀티 스레드보다 조금 비효율적일 수도 있지만, 상황을 훨씬 간결하게 만들기 때문에 상대적으로 디버깅이나 유지보수가 쉽습니다. 요리를 할 때 여러 사람이 왔다갔다 하며 부딪치는 것보다 잘하는 한 사람 있는 게 더 좋을 때도 있는 것처럼요.

그렇다면 setTimeout 은 대충 어떻게 동작할까요?


setTimeout

우리가 가장 처음으로 맞닥뜨리기도 하고 비동기 코드의 대표적인 예제로 소개되는 setTimeout을 살펴봅시다.

이 함수의 동작은 사실 타이머가 아니라 스톱워치를 돌려놓는 것과 같습니다. 즉 자바스크립트 엔진이 매번 스톱워치를 확인해서 정해진 시간이 지났는지 아닌지 체크해야 합니다. 체크하는 주기는 아주 빠릅니다. 1초에 수십번, 혹은 수백번 할 수도 있습니다.

하여튼 setTimeout 을 실행하게 된다면, 무조건 스톱워치를 설정하기 때문에, 0초라고 해도 곧바로 실행하지 않고 스톱워치를 확인하는 단계까지 일단 작업을 미룹니다. 그리고 다음 코드로 넘어갑니다. 아래 예시 코드를 확인해봅시다.

setTimeout(() => {
  console.log('timeout');
}, 0);

console.log("script end");

이 코드의 결과는 다음과 같습니다.

script end
timeout

이 코드가 실제로 어떻게 동작하는지 보면 다음과 같아요.


콜백 함수(callback)

콜백이라는 뜻은 어감으로 생각합시다. 정확한 뜻은 알 필요 없습니다.

  • call : 호출한다
  • back : 뒤

그러니까 무대 밖에서 뭔가를 호출한다는 느낌이죠? setTimeout 으로 콜백 함수를 넘긴 것처럼 미리 작업의 형태를 정해놓고, 어떤 특정 조건이 완료됐을 때 그 작업을 수행하도록 하는 겁니다.

그러나 콜백 함수라고 해서 모두 비동기적으로 이루어지는 건 아닙니다. setTimeout 말고도, 배열 메소드인 filter, map 과 같이 콜백 함수를 인수로 받는 함수는 쉽게 찾아볼 수 있는데요, 얘네들은 실행 즉시 해당 함수로 작업을 전부 수행하기 때문에 synchronous 합니다.


비동기 작업의 종류

‘기다리는 작업’은 모두 비동기 작업이 될 수 있습니다. 대표적으로 아래 것들이 있을 수 있습니다.

  1. 파일 읽고 쓰기
  2. 네트워크에 요청 보내기

코드의 비동기성은 상대적이다

예를 들어, 다음 세 가지 일을 하는 프로그램을 생각해봅시다.

  1. 엑셀 파일을 읽기
  2. 해당 데이터를 이용하여 정렬하기
  3. 정렬한 내용을 파일로 쓰기

이를 도식화하면 아래와 같습니다.

엑셀 파일 읽기 작업을 완료한 후, 데이터 정렬을 마치고 다시 파일 쓰기가 시작될 것입니다. 파일 쓰기 작업은 비동기 작업입니다. 즉 비동기 코드 안에서 또 다른 비동기 흐름이 생겨날 수 있습니다. 비동기/동기는 관점의 차이입니다.

그러므로 비동기 작업이냐 아니냐를 구분짓는 건 의미가 없습니다. 우리의 궁극적 관심사는 비동기 작업 시작점에서의 두 가지 갈림길-1.기다면서 수행할 코드와 2.비동기 작업을 마치고 수행할 코드-을 멋지게 잘 작성하는 것입니다. 현재 코드가 비동기적인 흐름에 있는지 없는지는 많이 중요하지 않습니다.

이런 관점에서 본다면, 콜백 함수는 좋은 방법이 아닙니다. 이 방법은 비동기 – 결과 – 비동기 – 결과의 의존 관계가 깊어질 수록 코드를 읽기 힘들어집니다. 아래에서 언급할 이벤트 핸들러 방법도 좋은 방법은 아닙니다. 대안은 후술합니다.


이벤트 핸들러

비동기 관련 로직을 짤 때 마찬가지로 흔한 방식이자, 콜백 함수를 쓰는 방식입니다. node.js 에서는 간단하게 테스트를 해볼 수 있습니다. 이벤트란 말 그대로 “어떤 사건이 일어났다”는 걸 의미하고, 핸들러는 해당 이벤트를 처리하기 위한 함수입니다.

Node.js 에서는 이벤트에 핸들러를 등록하기 위해 대개 on 이라는 이름의 함수를 호출합니다. 브라우저에서는 더욱 익숙한 addEventListener 를 사용합니다.

const fs = require("fs");

const stream = fs.createReadStream("/etc/hosts", { highWaterMark: 10 });

stream.on("data", (chunk) => {
  console.log("chunk: ", chunk.toString());
}); // data 이벤트가 발생하면 함수가 실행됨.

console.log("script end");

실행시킨다면 /etc/hosts 파일을 10개 문자마다 다다다닥 출력할 것입니다.

이벤트 핸들러의 방식의 간단한 특징을 꼽자면 다음과 같습니다.

  • 이벤트에는 특정한 이름이 있고(”data”) 이벤트마다 요구되는 함수의 형식이 정해져 있습니다. (chunk 인자 하나를 받는 함수)
  • 하나의 이벤트에 여러 개의 핸들러를 등록할 수 있습니다.

이벤트 핸들러를 등록하는 방식은 단순히 콜백 함수를 넘겨주는 방식보다 더 복잡합니다. 어떤 사건이 일어남과 그것이 처리되는 부분을 분리함으로써 프로그램을 한층 유연하게 만들어줄 수도 있지만, 대개 상황이 더 복잡해질 때가 많습니다.


이벤트 핸들러 방식의 단점

이벤트 핸들러 방식은 다음과 같은 단점이 있습니다.

  1. 핸들러를 등록하기 전에 이벤트가 발생한다면, 그냥 아무것도 없는 일이 됩니다. 이벤트는 실행할 핸들러 없이 끝나고, 늦게 등록된 핸들러는 이벤트를 아무것도 받지 못할 것입니다. 이는 절대 우리의 의도가 아니지요.
  2. 이벤트 핸들러간 소통이 어렵습니다. 특히 어떤 핸들러에서 에러가 발생했는데, (그래서 더이상 작업이 진행되면 안되는데) 이를 다른 핸들러에서 알아채기가 힘듭니다.
  3. 콜백 함수와 동일하게, 의존 관계가 깊어지면 코드가 중첩되어 가독성이 심각하게 떨어집니다.

이런 단점을 극복하기 위해 Promise 가 등장합니다.


Promise, async, await

Promise 는 기존 이벤트 기반 비동기 시스템을 대체하기 위해 등장했습니다. 비동기 작업을 더 세련되게 처리하겠다는 포부를 갖고 등장한 녀석입니다. 익숙해지면 정말 편리하긴 하지만, 정말 헷갈리기도 하고, 무엇보다 어떻게 적용하고 활용할 지 막막하기도 하죠. 이는 Promise 이해하기 라는 글에서 더 자세히 다루도록 하겠습니다.

마치며

비동기라는 개념과, 그것을 둘러싼 여러가지 자바스크립트의 개념들을 살펴보았습니다. 이로써 비동기에 대한 감각이 조금이나마 생겼으면 좋겠습니다.

One thought on “[Node.js] 비동기 개념에 익숙해지기

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top