프로그래밍 세계에서 '나중'은 '지금'의 직후가 아닙니다.
주제에 본격적으로 들어가기에 앞서, 우리가 비동기 요청을 주로 보낼 때 사용하는 AJAX에서 콜백 문제를 피하려고 중단적/동기적 AJAX를 사용하는 행위는 정당화할 수 없음을 앞서 밝힙니다.
function now() {
return 21;
}
function later() {
answer = answer * 2;
console.log(answer);
}
var answer = now();
setTimeout(later, 1000);
이 코드에서 나중에 실행되는 코드 (덩이)는 함수 later 안의 코드이고 그 외의 나머지 코드 (덩이)는 지금 당장 실행됩니다.
여기서 주의해야 할 점은 console.log 메서드는 브라우저 유형과 상황에 따라 출력할 데이터가 마련된 직후에도 콘솔창에 바로 표시되지 않을 수 있습니다.
var a = {
index: 1
}
console.log(a); // 1? 2?
a.index++;
자바스크립트 (이하 JS)를 비롯한 많은 프로그램에서 I/O 부분이 가장 느리고 중단이 잦기 때문입니다. 브라우저가 콘솔 I/O를 백그라운드에서 비동기적으로 처리해야 성능상 유리하기도 합니다.
의도한대로 값이 콘솔에 찍히기 않을 경우에는 이러한 I/O 비동기성이 원인일 수 있다는 점을 항상 염두에 두어야 합니다.
충격적이게도 실제로 JS에 비동기란 개념이 있었던 적은 단 한 번도 없었습니다.
JS 엔진은 그저 요청이 들어오면 프로그램을 주어진 시점에 한 덩이씩 묵묵히 실행할 뿐입니다.
여러 프로그램 덩이를 시간에 따라 매 순간 한 번씩 엔진 실행시키는 장치가 바로 '이벤트 루프'입니다.
즉, JS 엔진은 애당초 시간이라는 개념은 없었고 임의의 JS 코드 조각을 주는 대로 받아 처리하는 실행기일 뿐입니다. 실행할 이벤트가 있으면 이벤트 루프는 큐를 다 비울 때까지 실행합니다. 여기에 이벤트를 스케줄링하는 일은 언제나 엔진을 감싸고 있던 주위 환경의 몫입니다.
AJAX 요청을 예로 들어보겠습니다.
우리는 URL에 요청을 보내고 콜백 함수에 응답이 온 경우를 가정하여 이후의 로직을 작성합니다.
이렇게 코드를 짜고 요청을 하면 JS 엔진에 이렇게 이야기하는 것과 같습니다. "지금 잠깐 실행을 멈출 테니 네트워크 요청이 다 끝나서 결과 데이터가 오거든 다시 이 함수를 불러주세요."
여기서 '틱'이라는 용어를 설명하겠습니다. '틱'은 루프를 돌 때 매 순회를 뜻합니다. 틱이 발생할 때마다 이벤트 루프에서는 큐에서는 적재된 이벤트 (콜백)를 꺼내어 실행합니다.
틱을 사용하는 예로, setTimeout가 있습니다. setTimeout은 콜백을 이벤트 루프 큐에 넣지 않습니다. setTimeout은 타이머를 설정하는 함수이기 때문에 타이머가 끝나면 환경이 콜백을 이벤트 루프에 삽입한 뒤 틱에서 콜백을 꺼내어 실행합니다.
setTimeout 타이머가 항상 완벽하게 정확한 타이밍으로 작동하지 않는 게 바로 이 때문입니다. 적어도 지정한 시간 이전에 콜백이 실행되지 않을 거란 사실은 보장할 수 있지만 정확히 언제, 혹은 좀 더 시간이 경과한 이후에 실행될지는 이벤트 루프 큐의 상황에 따라 달라집니다.
'비동기'와 '병렬'은 아무렇게나 섞어 쓰는 경우가 많지만 그 의미는 완전히 다릅니다. '비동기'는 '지금'과 '나중' 사이의 간극에 관한 용어이고 '병렬'은 동시에 일어나는 일들과 관련됩니다.
이벤트 루프는 작업 단위로 나누어 차례대로 실행하지만 공유 메모리에 병렬로 접근하거나 변경할 수는 없습니다.
JS는 기본적으로 단일 스레드이지만 하나의 프로그램에서 여러 스레드를 처리하는 병렬 시스템에서는 예상치 못하는 일들이 발생할 수 있습니다.
var a = 20;
function foo() {
a = a + 1;
}
function bar() {
a = a * 2;
}
ajax(..., foo);
ajax(..., bar);
우리는 당연히 foo -> bar 순서로 실행될 것으로 생각하고 결과값을 42로 예상하지만 만약에 bar -> foo 순서면 41이 됩니다. 순서에 따라 최종 결괏값이 달라지는 것입니다.
그렇기에 같은 데이터를 공유하는 JS 이벤트의 병렬 실행 문제는 더 복잡합니다.
위의 코드에서는 a라는 메모리 공간을 공유하기 때문에 이런 일이 발생할 수 있습니다.
위와 같은 경우를 Race condition이라고 합니다. 함수 순서에 따라 결괏값을 예측할 수 없기 때문입니다.
동시성은 복수의 '프로세스'가 같은 시간 동안 동시에 실행됨을 의미하며, 각 프로세스 작업들이 병렬로 처리되는지와는 관계가 없습니다. 동시성은 '프로세스' 수준의 병행성이라 할 수 있습니다.
이벤트 루프 개념을 다시 곱씹어보면 JS는 한 번에 하나의 이벤트만 처리하므로 동시에 요청이 들어와도 어느 한 쪽이 먼저 실행되고 정확히 같은 시각에 실행되는 일은 결코 있을 수 있습니다.
그렇기 때문에 '프로세스 1'과 '프로세스 2'는 동시에 실행된다고 해도 이들을 구성하는 이벤트들은 이벤트 루프 큐에서 차례대로 실행됩니다. 여기서 인터리빙을 통해 동시에 실행되는 것처럼 보이게 되는 것입니다.
만약, 복수의 프로세스가 단계/이벤트를 동시에 인터리빙할 때 이들 프로세스 사이에 공유되는 데이터나 연관된 작업이 없다면 프로세스간 상호 작용은 사실 의미가 없습니다. 어느 프로세스가 먼저 실행될지는 알 수 없지만 적어도 서로에게 아무런 영향을 끼치지 않고 개별 작동하니 실행 순서는 문제 삼을 필요가 없기 때문입니다.
그 말은 곧, 상호 작용하는 경우에는 경합 조건이 발생하지 않도록 잘 조율해주어야 한다는 뜻이 됩니다.
var res = [];
function response(data) {
if (data.url === 'url.1') {
res[0] = data;
} else if (data.url === 'url.2') {
res[1] = data;
}
}
ajax('url.1', response);
ajax('url.2', response);
위의 코드는 url 요청하는 주소에 따라 배열의 인덱스를 못박아리므로 실행 순서가 어떻든 결괏값은 동일합니다.
이런 식으로 조건을 추가하여 순서에 종속되기 않게 로직에 디테일을 추가해주는 습관을 들이는 것이 중요합니다.
잡 큐는 ES6부터 이벤트 루프 큐에 새롭게 도입된 개념입니다. 프라미스의 비동기 작동에서 가장 많이 보게 될 큐입니다.
잡 큐는 이벤트 루프 큐에서 매 틱의 끝자락에 매달려 있는 큐라고 생각하면 이해하기 쉽습니다. 새로운 이벤트가 이벤트 루프 큐에 추가되는 게 아니라 현재 틱의 잡 큐 끝 부분에 원소가 추가됩니다.
비유하자면 이벤트 루프 큐는 마치 놀이공원에서 롤러코스터를 타고나서 한 번 더 타고 싶어서 다시 대기열의 맨 뒤에서부터 기다리는 것이고 잡 큐는 롤러코스터에서 내린 직후에 대기열의 맨 앞에서 곧바로 다시 타는 것입니다.
참고로, setTimeout은 다음 이벤트 루프에서 실행됩니다.
JS 엔진은 반드시 프로그램에 표현된 문의 순서대로 실행하지 않습니다.
var a, b;
a = 10;
b = 30;
a = a + 1;
b = b + 1;
console.log(a + b); // 42
이 코드는 비동기적인 요소가 없어서 당연히 위 -> 아래 방향으로 한 줄씩 실행될 것 같습니다.
그러나 JS 엔진은 이 코드를 컴파일한 뒤, 문 순서를 재정렬하면서 실행 시간을 줄일 여지가 없는지 확인합니다.
가령, 엔진은 이렇게 실행하면 더 빠르다고 판단할 수 있습니다.
var a, b;
a = 10;
a++;
b = 30;
b++;
console.log(a + b); // 42
어떤 경우라도 JS 엔진은 컴파일 과정에서 최종 결과가 뒤바뀌지 않도록 안전하게 최적화합니다.
JS에서 소스코드 순서와 컴파일 후 실행 순서는 사실상 아무 관련이 없습니다.