Skip to content

Tags

On this page

JavaScript 비동기 프로그래밍

처음엔 "순차적 프로그래밍"이라는 것이 있었다. 그 시절 컴퓨터는 자동화된 계산기에 불과해서 컴퓨터에게 데이터와 루틴을 제공하면 한 번에 하나씩 순서대로 실행해서 결과를 만들고 종료되었다.

순차적 언어는 입출력을 블록(block) 방식으로 처리한다. 프로그램이 파일을 읽거나 네트워크에서 데이터를 가져오려고 하면 데이터를 다 가져올 때까지 프로그램은 실행을 멈춘다. 포트란 같은 언어에서는 이런 방식이 합리적이었을 것이다(프로그램이 카드 리더에서 데이터를 전부 읽어올 때까지는 할 일이 없기 때문에). 하지만 자바스크립트는 그렇지 않다. 탄생 목적 자체가 사용자와의 상호 작용이었기 때문에 더 나은 모델을 따르고 있다.

순차적 프로그래밍은 컴퓨터가 사용자 또는 컴퓨터와 상호 작용을 하면서 무너지기 시작했다. 동시에 여러 가지 일을 할 수 있는 동시성 프로그래밍이 필요해졌다.

스레드

스레드는 가장 오래된 동시성 기법 중 하나로 지금도 널리 사용된다. 스레드는 실제 또는 가상의 CPU로 메모리를 공유하면서 동시에 실행된다. 순수 함수와 잘 맞다. 함수가 순수하지 않으면 스레드에서 멍청한 일을 벌일 수 있다.

여러 개의 스레드가 코드를 동시에 실행되도록 해주는 언어는 Java, C++ 등이 있다. 여러 개의 코드가 상태에 접근해서 변경할 수 있을 때 그 상태를 유지하고 보호하는 것은 어려운 문제. 스레드 기반 소프트웨어에서 잦은 버그의 원인이 된다. 스레드 간 경쟁으로 발생할 수 있는 위험성은 상호 배제1로 줄일 수 있다.
이에 반해 자바스크립트는 동시성을 더 나은 방법으로 구현할 수 있다.

비동기 프로그래밍

비동기 함수(asynchronous function)은 호출하면 즉각 반환한다. 작업이 끝나고 결과물은 콜백 함수나 메시지 전송을 통해서 결국 전달되지만, 즉각 반환되는 값에는 진짜 결과 값이 없다.

비동기 프로그래밍은 애플리케이션에서 스레드를 사용하지 않고도 많은 일을 처리할 수 있게 해준다.

비동기 프로그래밍은 두 가지 아이디어에 근간이 있다. 콜백 함수와 프로세싱 루프

콜백 함수

콜백 함수는 시작하거나 특정 활동을 지켜보는 함수에 인자로 전달된다. 웹 브라우저에서 콜백 함수는 해당 DOM 노드의 특정 속성에 할당되는 식으로 연결된다.

my_node.onclick = callback function;
my_node.addEventListener("click", callback function, false);

사용자가 지정된 DOM 노드를 클릭하면 콜백 함수가 호출되서 동작한다.

프로세싱 루프

프로세싱 루프는 이벤트 루프 또는 메시지 루프라고 한다.
프로세싱 루프는 큐에서 가장 높은 우선순위를 가지는 이벤트/메시지를 가져와서 해당 이벤트나 메시지를 처리하도록 등록된 콜백 함수를 호출해 준다. 그리고 콜백 함수가 작업을 완료하면 반환한다. 그래서 콜백 함수는 스레드처럼 메모리 잠금이나 상호 배제가 필요 없다. 그리고 방해받지 않기 때문에 경쟁이 일어날 일도 없다.

콜백 함수가 끝이 나면 프로세싱 루프는 큐에서 그다음 이벤트나 메시지를 꺼내와서 등록된 콜백 함수를 호출하고, 이 과정을 반복한다.

프로세싱 루프가 관리하는 큐를 이벤트 큐 혹은 메시지 큐라고 한다. 들어오는 이벤트/메시지를 저장하고, 이 이벤트/메시지에 응답하기 위해 콜백 함수가 호출될 수 있다. 스레드를 사용하는 시스템의 경우엔 한 스레드에서 예외가 발생하면 그 스레드의 스택을 되감는다. 그렇게 되면 그 스레드의 상태는 연관된 다른 스레드의 상태들과 일치하지 않게 되고, 다른 스레드에 문제가 생길 수 있다. 그에 비해서 자바스크립트는 하나의 스레드만 사용한다. 스레드의 상태 값은 대부분 스택이 아닌 해당 함수의 클로저에 저장되어 예외가 발생해도 동작이 계속 진행될 수 있다.

턴의 법칙

프로세싱 루프의 한 반복은 턴이라고 불린다. 게임에 규칙이 있듯이 비동기 모델에도 규칙이 있는데 이것을 바로 턴의 법칙(Law of Turns)라고 한다. 턴의 법칙은 프로세싱 루프에서 호출하는 콜백 함수, 그리고 콜백 함수가 직, 간접적으로 호출하는 모든 함수에 적용된다.

  • 함수는 절대 어떤 일이 일어나기를 바라는 마냥 기다려서는 안 된다.
  • 함수는 절대 메인 스레드를 블록해서는 안 된다.
  • 웹 브라우저의 경우 함수는 alert같은 함수를 써서는 안 된다.
  • Node.js에서 악의적인 -Sync라는 접미사를 붙인 함수를 사용해서는 안 된다.
  • 작업을 끝내는 데 오랜 시간이 걸리는 함수를 호출해서도 안 된다.

턴의 법칙을 위반하면 높은 성능을 자랑하는 비동기 시스템이 아주 낮은 성능을 보일 것이다. 단순히 현재 콜백을 지연하는 것 뿐만 아니라 큐에 있는 모든 것을 지연시키게 된다. 지연들은 누적이 되고 큐에 이벤트/메시지가 점점 더 쌓이게 되면서 시스템이 느려질 것이다.

자바스크립트와 비동기 프로그래밍

자바스크립트에서 함수를 호출하면 함수 코드가 평가되어 함수 실행 컨텍스트가 생성된다. 함수 실행 컨텍스트는 실행 컨텍스트 스택(이하 콜 스택)에 푸시되고 함수 코드가 실행된다. 함수 코드의 실행이 종료되면 함수 실행 컨텍스트는 콜 스택에서 팝되어 제거된다.

자바스크립트 엔진은 단 하나의 콜 스택을 가진다. 그러므로 함수를 실행할 수 있는 창구가 단 하나이며, 동시에 2개 이상의 함수를 실행할 수 없다. 콜 스택의 최상위 요소인 "실행 중인 컨텍스트"를 제외한 나머지 모든 실행 컨텍스트는 모두 실행 대기 중인 태스크(task)다. 대기 중인 태스크들은 현재 실행 중인 실행 컨텍스트가 콜 스택에서 제거되면 실행되기 시작한다.
이처럼 자바스크립트 엔진은 한 번에 하나의 태스크만 실행할 수 있는 싱글 스레드(Single Thread) 방식으로 동작한다. 그래서 처리에 시간이 걸리는 태스크를 실행할 경우 블로킹(작업 중단)이 발생한다.
실행 중인 태스크가 종료할 때까지 다음에 실행될 태스크가 대기하는 방식은 동기처리이다. 이에 반해 현재 실행 중인 태스크가 종료되지 않은 상태여도 다음 태스크를 곧바로 실행하는 방식을 비동기처리라고 한다.
타이머 함수, HTTP 요청(Ajax), 이벤트 핸들러는 비동기 처리 방식으로 동작한다.

이벤트 루프와 태스크 큐

자바스크립트는 앞서 설명한 것처럼 싱글 스레드로 동작하지만 브라우저의 동작을 살펴보면 태스크들이 동시에 처리되는 것처럼 느껴질 때가 있다.
예를 들어, 웹 브라우저는 애니메이션 효과를 보여주면서 마우스 입력도 받아서 처리하고, 서버로부터 데이터를 가져오면서 렌더링하기도 하고, Node.js 웹 서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 어떻게 스레드가 하나인데 이런 일이 가능할까?

자바스크립트의 동시성을 지원하는 것이 바로 이벤트 루프(Event loop)다. 브라우저에 내장되어 있는 기능 중 하나이다.
브라우저 환경은 태스크 큐와 이벤트 루프를 제공한다.

  • 태스크 큐(프로세싱 루프 - 이벤트 큐)

    • 타이머 함수 같은 비동기 함수의 콜백 함수 또는 이벤트 핸들러가 일시적으로 보관되는 장소
    • 자바스크립트 프로그램과 메인 스레드 간에 통신하는 방법이 바로 이 태스크 큐이다.
    • 프로미스 후속 처리 메서드의 콜백 함수가 일시적으로 보관되는 마이크로태스크 큐도 있다.
  • 이벤트 루프

    • 콜 스택에 현재 실행 중인 실행 컨텍스트가 있는지, 그리고 태스크 큐에 대기 중인 함수(콜백 함수, 이벤트 핸들러 등)이 있는지 반복해서 확인한다.
    • 만약 콜 스택이 비어 있고 태스크 큐에 대기 중인 함수가 있다면 이벤트 루프는 순차적으로 태스크 큐에 대기 중인 함수를 콜 스택으로 이동시킨다. 콜 스택으로 이동한 함수는 실행된다.

이벤트 모델

let button = document.getElementById("btn");
button.onclick = function(event) {
console.log("clicked");
}

위 코드에서 console.log("clicked");button이 클릭될 때까지 실행되지 않을 것이다. button이 클릭되면 onclick에 할당한 함수는 태스크 큐의 뒤에 추가되고, 추가된 함수 앞에 다른 작업들이 모두 완료된 뒤 실행될 것이다.
간단한 인터랙션을 위한 이벤트는 잘 동작하지만, 연결된 여러 개의 비동기 호출이 있다면 각 이벤트에 대한 이벤트 타깃을 추적해야 하기 때문에 복잡할 수 있다. 그리고 처음 이벤트가 발생하기 전에 이벤트 핸들러가 적절히 모두 추가되었는지도 보장할 필요가 있다. 그러므로 간단한 기능에는 유용하지만 더 복잡한 요구사항이 있다면 유연하지 않다.

콜백 패턴

콜백 패턴은 비동기 코드가 특정 시점까지 실행되지 않는다는 점에서 이벤트 모델과 유사하다. 하지만 다음 코드처럼 호출할 함수가 인자로 전달되는 차이가 있다.

const { readFile } = require("fs");
// A
readFile("test.txt", function (err, contents) {
if (err) {
throw err;
}
// C
console.log(contents);
});
// B
console.log("Hello");

위 코드의 결과를 예상해볼 때, A -> C -> B 순서로 코드가 실행되어야 한다는 생가이 든다. 하지만 A->B->C 순서이다. 콜백 패턴을 사용하면 readFile()은 즉시 실행되기 시작하고 디스크로부터 파일을 읽기 시작할 때 실행을 멈춘다. 즉 readFile()이 호출된 후 console.log(contents)가 출력되기 전에 console.log("Hello")가 출력된다. 콜백 함수는 프로그램의 연속성을 감싼/캡슐화한 장치라고 할 수 있다. readFile()이 실행을 완료하면 태스크 큐의 맨 뒤에 콜백 함수와 콜백 함수의 인자를 가진 새로운 태스크가 추가된다. 그 태스크는 앞선 모든 다른 태스크 완료 후에 실행된다.

두뇌는 순차적이기 때문에 위와 같은 진행 방식에 괴리감이 느껴진다. 그리그 어그러짐이 점점 더 벌어지면서 코드는 이해하기 힘들어지고... 디버깅/유지보수가 힘들어진다.

사람은 계획을 작업할 때 순차적/동기적으로 계획한다. "이 책을 읽고 나서, 마트에 가서 장을 보고, 밥을 차려 먹을 것이다." 사람의 계획은 형태만 보면 비동기 이벤트 처럼 보이지 않는다.

// 책을 읽고
// 마트가서 장을 보고
// 밥을 차려먹는다.
z = x;
x = y;
y = z;

위 코드의 할당문 처럼 하나가 끝나야 다음 줄이 실행되는 식이다. 이렇게 동기 코드 문은 사람의 사고와 잘 어울리는데 비동기 코드는 사고의 흐름과 어울리지 않다. 이래서 비동기 코드 작성에 내가 이질감을 느낀 것 같다. 책2을 읽고나서 많은 생각이 바뀌었다. 사람은 멀티태스커가 아니라 아주 재빠르게 컨텍스트를 교환하고 있다는 점과 그를 위해 인간의 두뇌는 이벤트 루프와 큐처럼 동작한다는 사실을...
비동기 코드 작성이 어려운 이유는 인간이 비동기 흐름을 생각하고 떠올리는 일 자체가 부자연스럽다는 것!

콜백 패턴을 사용하면 여러 개의 호출 연결이 쉽기 때문에 이벤트보다 더 유연하다. 하지만 여러 개의 호출을 연결하게 되면 일명 콜백 지옥(Callback Hell)에 빠질 수 있다는 단점이 있다. 콜백 지옥은 너무 많은 콜백이 중첩되었을 때 발생한다.

콜백의 다른 문제점은 복잡한 기능을 구현할 때다. "두 개의 비동기 연산을 병렬로 실행하고 둘 다 완료되었을 때 결과를 알고 싶다.", "두 개의 비동기 연산을 동시에 실행하고 첫 번째 연산이 완료되었을 때만 결과를 알고 싶다." 등의 경우엔 여러 개의 콜백을 추적할 필요가 있다. 프로미스는 이 문제를 크게 개선한다.

reference

  • 박수현 역, 더글러스 크락포드 저, 《자바스크립트는 왜 그 모양일까?》, 인사이트, 2020년
  • 이웅모 저, 《모던 자바스크립트 Deep Dive》, 위키북스, 2020년
  • 자바스크립트와 이벤트 루프
  • 김두형·정재훈 역, 니콜라스 자카스 저, 《모던 자바스크립트》, 인사이트, 2017년
  • 이일웅 역, 카일 심슨 저, 《You Don't Know JS: this와 객체 프로토타입, 비동기와 성능》, 한빛미디어, 2017년

  1. 상호 배제는 메모리의 임계 구역을 잠그고 스레드를 차단하고, 서로 경쟁하는 코드 실행을 막는 것으로 이루어진다. 임계 구역을 잠그는 비용은 아주 비싸다. 그리고 실행이 차단된 스레드가 잠금을 해제하지 못하는 경우도 발생하는데 이를 데드락이라고 한다.
  2. 이일웅 역, 카일 심슨 저, 《You Don't Know JS: this와 객체 프로토타입, 비동기와 성능》, 한빛미디어, 2017년
Edit this page
Last updated on 8/13/2022