Front-end Developer

0%

자바스크립트의 비동기성

자바스립트는 동기적이다. 동기적이라는 말은 자바스크립트가 싱글 스레드이기 때문에 한 번에 한 가지 task만 수행할 수 있다는 의미이다. 즉, 어떤 task1이 실행되면 이 task의 처리가 끝나기 전까지 다음 task인 task2는 실행되지 못하고, task1이 끝날 때까지 기다려야 한다. 그런데 브라우저 환경에서 task가 동기적으로 차리되면 예를 들어 서버에 요청한 후 어떤 결과값을 받아서 화면에 렌더링하는 task가 있다고 했을 때, 서버에서 요청이 언제 돌아올지 모르는 상태로 마냥 기다려야 하는데, 이 사이에 사용자가 보는 웹 브라우저 화면은 빈 화면인 상태 또는 task가 수행되기 이전의 상태에 머물러 있게 된다. 이런 문제 때문에 브라우저 환경에서 자바스크립트는 비동기적으로 실행된다. 정확히 말하자면 자바스크립트가 비동기적인 것이 아니라 브라우저가 비동기 처리를 이용하여 task를 마치 동시에 처리되는 것처럼 처리하고, 이러한 비동기 처리를 돕는 것은 이벤트 루프이다.

그렇다면 자바스크립트에서 말하는 비동기 프로그래밍의 개념에 대해 조금 더 상세히 살펴보겠다.

비동기 프로그래밍의 핵심은 지금에 해당하는 부분, 나중에 해당하는 부분 사이의 관계 또는 간극이다.

비동기성을 이해하기 위한 배경 지식

자바스크립트에서 프로그램은 여러 개의 chunk로 구성된다.

  1. 지금 실행 중인 프로그램 chunk
  2. 나중에 실행 할 프로그램 chunk

여기서 나중지금의 직후가 아니며, 지금 끝낼 수 없는 작업은 비동기적으로 처리되기 때문에 프로그램을 중단하지 않는다. AJAX를 예로 들자면 이 함수는 비동기적으로 어떠한 task를 지금 요청하고 나중에 결과를 받고, 지금부터 나중까지 기다리는 최적의 방법은 콜백 함수를 이용하는 것이다.

이벤트 루프

앞서 말했지만 자바스크립트는 동기적이라서 주어진 요청(task)가 주어지면 주어진대로 순서대로 처리할 뿐이다. 자바스크립트 엔진은 웹 브라우저와 같은 호스팅 환경에서 실행되는데, 이벤트 루프가 여러 프로그램 chunk를 시간에 따라 매 순간 한 번씩 엔진을 실행시키도록 한다. 즉 자바스크립트 엔진이 동기적으로 코드를 실행할 때 AJAX, 콜백함수와 같이 비동기 처리 함수가 있다면 브라우저 환경이 이벤트 루프를 통해 실행을 스케줄링 하는 것이다. 이 스케줄링이 비동기적으로 처리되기 때문에 자바스크립트 자체는 동기적이지만 브라우저 환경에서의 실행 스케줄링은 비동기적이라고 할 수 있다. 구현방식을 단순화해서 구현하면 다음과 같다.

while문의 무한 루프에서 매 순회를 틱이라 하는데, 한 번 틱이 발생할 때, 큐에 있는 이벤트(콜백 함수)를 선입 선출하여 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//eventLoop는 큐(선입, 선출) 역할을 하는 배열
var eventLoop = [];
var event;

//'무한'실행
while (true) {
//'틱' 발생 (틱: 이벤트 루프를 한 차례 순회하는 것)
if (eventLoop.length > 0) {
//큐에 있는 다음 이벤트 조회
event = eventLoop.shift();
//이제 다음 이벤트 실행
try {
event();
} catch (err) {
reportError(err);
}
}
}

싱글 스레드 vs 병렬 스레드

자바스크립트의 비동기 처리에 대해서 설명할 때 종종 병렬과 혼용하여 사용하는데, 둘은 완전히 다른 개념이다.

  • 비동기 : 지금나중 사이의 간극에 관함
  • 병렬: 동시에 일어나는 일에 관함

자바스크립트는 하나의 프로그램에서 여러 스레드를 처리하는 병렬 시스템이 아니라 절대로 스레드 간에 데이터를 공유하지 않는 단일 스레드 환경이다. 그리고 단일 스레드이기 때문에 완전-실행(Run-to-Completion)된다. 즉 함수가 2개 있다면 첫 번째 함수의 전체 코드가 다 실행된 후, 다음 함수가 실행된다. 그리고 두 함수가 있다면 이 함수는 순서에 따른 비결정성을 가진다. 이를 경합 조건(Race Condition)이라 한다.

동시성

복수의 프로세스가 같은 시간 동안 동시에 실행된다. 앞서 말했듯이 자바스크립트는 병렬 스레드가 아니므로 여기서 말하는 동시성은 각 프로세스 작업(개별 프로세스의 스레드)이 병렬로 처리되는지와 관계된 것이 아닌, 프로세스 수준(작업 수준)의 병행성을 말한다. 단 프로세스 1과 프로세스 2가 있다면 이들은 동시에 실행되지만 이들을 구성하는 이벤트들은 이벤트 루프 큐에서 차례대로 실행된다.

상호작용 vs 비상호 작용

1
2
3
4
5
6
7
8
9
10
11
12
13
var res = {};

function foo(results) {
res.foo = results;
}

function bar(results) {
res.bar = results;
}

//ajax()는 라이브러리에 있는 임의의 AJAX 함수
ajax('http://some.url.1', foo);
ajax('http://some.url.2', bar);

위 코드에서 복수의 프로세스 foo(), bar()가 동시에 실행될 때, 누가 먼저 실행될 지는 알 수 없지만 이들 프로세스 사이에 연관된 작업이 없기 때문에 프로세스간 상호작용이 일어나지 않는다. 이런 경우에 실행 순서가 문제되지 않는다. 즉 경합 조건이 문제시 되지 않는다. 하지만 상호 작용하는 상황이라면 이야기는 달라진다.

1
2
3
4
5
6
7
8
9
10
11
var res = [];

function response(data) {
res.push(data);
}

//ajax()는 라이브러리에 있는 임의의 AJAX 함수
//1️⃣
ajax('http://some.url.1', response);
//2️⃣
ajax('http://some.url.2', response);

위의 코드는 상호 작용이 발생하는 프로세스이다. 1️⃣과 2️⃣의 프로세스는 모두 AJAX 응답에 대한 처리를 하는 response() 함수를 호출하기 때문에 선발 순으로 처리된다. 이 코드가 프로그램 개발자가 ‘http://some.url.1'의 결과를 res[0]에, ‘http://some.url.2',의 결과를 res[1]에 넣고자 의도한 코드라고 했을 때, 의도한 대로 동작할 수 있지만 어느쪽 URL의 응답이 먼저 도착할지 보장되지 않기 때문에 만약 예측한 대로 응답이 도착하지 않을 경우 결과가 뒤집힐 수 있다. 이런 경합 조건을 해결하려면 상호 작용의 순서를 잘 조정해야 한다. 특히 동시 프로세스들이 스코프나 DOM을 통해 간접적으로 상호작용하기 때문에 순서 조정이 아주 중요하다. DOM을 조작하는 코드의 경우 순서 조정이 제대로 이루어지지 않으면 처리가 덜 된 DOM 요소를 화면에 보여주게 될 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var res = [];

function response(data) {
if (data.url == 'http://some.url.1') {
res[0] = data;
} else if (data.url == 'http://some.url.2') {
res[1] = data;
}
}

//ajax()는 라이브러리에 있는 임의의 AJAX 함수
//1️⃣
ajax('http://some.url.1', response);
//2️⃣
ajax('http://some.url.2', response);

위와 같이 작성하게 되면 어느 쪽에 응답이 먼저 오더라도 data.url을 보고 해당하는 결과를 res[0] 또는 res[1]에 담을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var a, b;

function foo(x) {
a = x * 2;
if (a && b) {
baz();
}
}

function bar(y) {
b = y * 2;
if (a && b) {
baz();
}
}

function baz() {
console.log(a + b);
}

//ajax()는 라이브러리에 있는 임의의 AJAX 함수
//1️⃣
ajax('http://some.url.1', foo);
//2️⃣
ajax('http://some.url.2', bar);

위의 코드에서 if (a && b) { baz() }의 조건은 관문(Gate)라 부른다. a와 b 둘 중 누가 먼저 도착할지 알 수 없지만 반드시 둘 다 도착한 다음에 baz()함수가 호출이 되는, 즉 관문이 열리기 때문이다. 이 코드가 존재하지 않다면 baz() 함수가 초기에는 a,b가 undefined인 상태에서도 호출이 되어 제대로 작동하지 않는다. 이와 다르게 둘 다 도착했을 때가 아닌, 둘 중 하나만 도착했을 때 상호 작용하도록 하는 코드도 있다. 이는 관문(Gate)보다 걸쇠(Latch)로 불리고, 선착순 한 명만 이기는 형태이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var a;

function foo(x) {
if (!a) {
a = x * 2;
baz();
}
}

function for(y) {
if (!a) {
a = x / 2;
baz();
}
}

function baz() {
console.log(a);
}

//ajax()는 라이브러리에 있는 임의의 AJAX 함수
//1️⃣
ajax('http://some.url.1', foo);
//2️⃣
ajax('http://some.url.2', bar);

위의 코드에서 if (!a) {}와 같은 코드를 걸쇠(Latch)라 부른다. foo()나 bar() 둘 중 첫 번째 실행된 함수만이 이 조건을 통과하고 늦게 실행된 함수 호출은 무시되기 때문이다.


콜백

콜백은 백그라운드에서 코드 실행을 시작할 함수를 호출할 때 인수로 지정된 함수이다. 백그라운드 코드 실행이 끝나면 콜백 함수를 호출해서 작업이 완료되었음을 아리거나 다음 작업을 실행하게 할 수 있다.

1
2
3
4
5
6
7
btn.addEventListener('click', () => {
alert('You clicked me!');

let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});

위의 함수에서 콜백은 addEventListener()의 click 옆의 두 번째 매개변수이다. 이벤트가 실행 될 때 이 콜백 함수가 호출된다. callback 함수를 다른 함수의 인수로 전달할 때, 함수의 참조를 인수로 전달할 뿐이지 즉시 실행되지 않고, 함수의 body에서 “called back”된다. 즉 정의된 함수는 때가 되면 callback 함수를 실행하는 역할을 한다. 또한 콜백은 자바스크립트에서 비동기성을 표현하고 관리하는 가장 일반적인 기법이자 가장 기본적인 비동기 패턴이다.

콜백의 실체

연속성

비동기 코드 작성의 어려움은 콜백 함수의 연속성과 인간 두뇌의 연속성의 개념이 다르기 때문이다.

1
2
3
4
5
6
//A
ajax("...", function(..){
//c
});

//B

위와 같은 코드가 있다고 했을 때, 지금에 해당하는 전반부 코드(A,B)가 실행되면 비결정적(indeterminate) 시간 동안 중지되고 언젠가 AJAX 호출이 끝날 때 중지되기 이전 위치로 다시 돌아와서 나머지 후반부(C)프로그램이 이어진다. 즉 콜백 함수는 프로그램의 연속성을 감싼(캡슐화)한 장치이다. 그런데 이러한 코드는 순차적으로 task를 처리하는 두뇌의 처리방식과 다른 순서를 같기 때문에 추론이 어렵다. 즉 위와 같은 코드를 보면 두뇌는 A -> C -> B의 순서대로 작성된 코드를 읽어내려가며 추론하려 할 것이다. 하지만 실질적인 코드는 A -> B -> C의 순서대로 실행하기 때문에 이러한 괴리감 때문에 비동기 처리를 이해하기 어렵게 만든다.

콜백지옥

비동기 처리를 위해 콜백을 중첩 또는 연속해서 사용되면 아래와 같은 콜백 지옥이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
listen('click', function handler(evt) {
setTimeout(function request() {
ajax('http://some.url.1', function response(text) {
if (text == 'hello') {
handler();
} else if (text == 'world') {
request();
}
});
}, 500);
});

이러한 콜백 지옥으로 거론되는 첫 번째 문제는 콜백을 연속해서 쓰다보니 점점 들여쓰기가 되면서 가독성이 떨어져서 유지 보수를 어렵게 하는 것이다. 하지만 들여쓰기로 인한 가독성 저하보다 코드를 너무 복잡하게 만든다는 점이 더 큰 문제이다. 중접된 콜백으로 인한 가독성 저하를 막기 위해 콜백 작성시 조건을 작성해서 좀 더 가독성을 높일 수 있는데, 이러한 하드 코딩은 사실 코드를 더 복잡하게 만들고, 코드가 복잡해지는 것이야말로 유지보수를 어렵게 만든다. 즉 콜백의 단계별 맞닥뜨릴 수 있는 경우의 수를 분기처리해 줄 수 있는데, 분기처리를 해야하는 가능한 경우의 수를 나열하다보면 코드가 방대해지고, 작성된 구문이 모든 경우의 수를 커버하라는 보장도 없다.

믿음성 문제

1
2
3
4
5
6
//A
ajax("...", function(..){
//c
});

//B

위와 같은 함수는 제어권을 주고받는 행위가 발생한다. A,B가 자바스크립트 메인 프로그램의 제어를 받으며 지금 실행된다면 C는 다른 프로그램(ajax() 함수)의 제어하에 나중에 실행된다. 이 제어권 교환이야말로 콜백 중심적 설계의 가장 큰 문제점이다. 위 코드에서 쓰인 ajax()처럼 콜백을 넘겨주는 코드는 개발자가 직접 제어할 수 있는 함수가 아니라 서드 파티가 제공한 유틸리티인 경우가 대부분이다. 이렇게 내가 작성하는 프로그램인데도 실행 흐름은 서드 파티에 의존해야 하는 상황을 제어의 역전이라 한다. 그래서 비동기 콜백 함수를 작성한 경우, 콜백 자체에 대해 개발자가 직접 제어할 수 없기 때문에, 콜백 호출 시 오류가 날 수 있는 상황에 대한 여러가지 보완 로직을 구현하기 마련이다. 예를 들어 콜백을 너무 일찍 부르거나 너무 늦게 부른다거나, 너무 많이 부른다거나 하는 등의 잘못 될 가능성이 있는 상황에 대한 보완 로직을 작성해 두는 것이다. 하지만 보완 로직을 작성했다고 해도 서드 파티의 처리에서 무언가 문제가 생기면 완전히 잘못 틀어질 수도 있다. 그래서 이런 믿음성의 문제를 해결하기 위해 몇 가지 콜백 체계가 존재한다.

  • 분할 콜백
  • 에러 우선 스타일
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//분할 콜백 - 한쪽은 성공, 한쪽은 실패
function success(data) {
console.log(data);
}
function failure(err) {
console.error(err);
}

ajax('http://some.url.1', succes, failure);

//에러 우선 - 성공 시 빈/falsy 객체, 실패 시 truthy/에러 객체로 세팅됨.
function response(err, data) {
//에러인가?
if (err) {
console.error(err);
}
//아니면 성공한 것
else {
console.log(data);
}
}

ajax('http://some.url.1', response);

언뜻 보면 믿음성에 대한 문제가 해결된 것 같지만 원하지 않는 반복적인 호출을 방지하거나 걸러내는 콜백 기능은 전혀 없다. 오히려 성공/에러를 동시에 받거나 전혀 받지 못하는 상황에 대해서도 고려해야 하고, 이렇게 표준적인 형태로 작성되었어도 재사용이 불가하거나 장황한 관용 코드라서 콜백을 쓸 때마다 매번 새로 타이핑을 해주어야 할 수 있다.

정리하자면 콜백이 자바스크립트의 비동기성을 표현하는 기본 단위로써 충분히 그 역할을 다 해왔지만, 점점 진화하는 비동기 프로그래밍 환경에 대응하기에는 충분하지 않다.
첫 번째 이유는 연속성의 문제로 사람의 두뇌는 순차적이고, 단일-스레드 방식으로 계획하는데 익숙하지만 콜백은 비동기 흐름을 비선형적, 비순차적으로 나타내기 때문에 그 괴리감으로 인해 구현된 코드를 사람이 이해하기가 쉽지 않다. 이렇게 추론하기 어려운 코드는 악성 버그를 품을 가능성을 내재한 코드가 된다. 두 번째, 콜백이 프로그램을 진행하기 위해 제어권을 다른 파트(e.g. 서드파티 유틸리티)로 넘겨줘야 하는데, 이렇게 제어권이 넘어가면 믿음성의 문제에 봉착해서 콜백이 잘못될 가능성에 대해 구구절절한 보완 로직을 작성하게 된다. 이렇게 작성하면 믿음성의 문제는 해결할 수 있을지라도 거칠과 유지보수가 어려운 방대하고 복잡한 코드가 되고, 실제로 100% 믿음성의 문제를 해결했다고 보기도 어렵다. 모든 케이스에 대해 보완 로직을 작성했다고 해도, 제어권이 넘어간 상태에서 발생한 일까지 대응할 수는 없기 때문이다.

결국 이렇게 제기된 문제들을 해결하려면 콜백을 능가하는 조금 더 나은 해결법이 필요하고, 그런 배경 아래 등장한 것이 프로미스이다.


References
callback
[YOU DON’T KNOW JS] 카일 심슨 지음