[JS] 비동기성과 콜백패턴

steven semyung oh·2023년 6월 3일
1

비동기여행

목록 보기
3/4

이 글은 YDKJS - Async & Performance의 챕터 1과 2를 간략히 요약한 것과 저의 생각 뭉텅이를 담았습니다.
비동기에 관해 명료한 설명을 보고싶으시다면 이 링크를 참고해주세요.

시간의 간극

우리가 만든 프로그램의 어느 부분은 지금 실행되고 다른 부분은 나중에 실행된다. 프로그램이 실행되는 지금나중과의 간극이 존재하기 때문에 시간의 흐름에 따라 프로그램을 조작하고 이를 코드로 표현하는 것은 많이 어려운 문제다. 지금 작동하는 작업과 나중에 작동하는 작업간의 관계를 디자인하는 것이 비동기 프로그래밍의 핵심이라고 할 수 있다.

지금의 작업과 나중의 작업

const randomNumber = fetchRandomNumber();
console.log(randomNumber);

아주 심플한 프로그램을 만들었다. 이 프로그램은 "임의의 난수를 얻어 콘솔에 띄워준다."
그러면 콘솔창에서는 실제로 결과값을 출력할까? 그에 대한 답은 fetchRandomNumber()동기적으로 처리되는 함수인지 비동기적으로 처리되는 함수인지에 따라 다르다.

  1. fetchRandomNumber() 가 동기적으로 처리되는 함수라면 콘솔창에는 난수가 출력될 것이다.
declare function fetchRandomNumber(): number
  1. fetchRandomNumber()가 비동기적으로 처리되는 함수라면 콘솔창에는 undefined 가 출력될 것이다. 비동기적으로 처리되는 함수는 지금 실행되고 지금 함수가 정의한 작업을 요청하며, 지금 실행 흐름에 맞게 종료 되지만, 함수가 요청한 작업은 보통 나중에 완료 된다. 그리고 이벤트루프의 매 Tick 마다 나중에 완료되는 작업의 결과를 통해 수행하는 작업(Micro)Task의 구성 요소로 놓고 실행한다. 다시말해 나중에 완료되는 작업의 결과를 통해 수행하는 작업지금 시점에서 등록 하면 프로그램은 나중의 흐름에 따라 프로그램을 조작할 수 있다. 지금 등록하여 나중까지 기다리는 추상적인 행동을 어떻게 표현할 수 있을까? 가장 대표적인 방법은 함수를 전달하는 것이다. 이러한 표현방식을 사용한 패턴을 CFS Style이라고 불렸으며, 지금은 간단하게 콜백 패턴 이라고 사용한다.

    type Callback = (randomNumber: number) => void;
     declare function fetchRandomNumber(callback: Callback): void;
    
     fetchRandomNumber(
       (randomNumber) => {
         console.log(randomNumber);
       }
     );

비동기성으로 마주할 수 있는 현상

사전 지식: 완전 실행

위키피디아에 따르면 프로그래밍 언어는 구문과 실행 모델로 구성된다. 실행 모델은 작업의 한 단위가 실행되기 위한 방식을 규정한다. 프로그래밍은 작성된 코드에 실행 모델을 적용하는 것이다. 완전 실행은 실행 모델의 한 종류로 코드의 특정 부분이 실행이 완료될 때 까지 방해를 받지 않는다. 작업이 일단 실행되면 끝날 때까지 다른 작업에 방해를 받지 않고 끝까지 실행된다.

자바스크립트는 완전 실행 모델을 적용한 언어다. 실행 컨텍스트와 이벤트 루프가 완전 실행 모델을 반영한다. 활성화된 실행 컨텍스트는 관련 코드(함수)의 실행이 종료되기 전까지는 pop되지 않는다. 작업은 더이상 나눌 수 없기 때문에 원자적이다.

function getNemyungsFavoriteFood() {
  console.log("hamburger");
}

function getNemyungsFavoriteDrink() {
  console.log("coke (not pepsi)");
}

getNemyungsFavoriteFood();
getNemyungsFavoriteDrink();

일단 getNemyungsFavoriteFood() 가 실행되면 이 함수 전체 코드가 실행되고 나서야 getNemyungsFavoriteDrink() 의 함수가 실행된다. 완전 실행 모델 덕분에 프로그램의 실행 흐름이 결정적(Deterministic)이며 추론하기 쉬워진다.

1. 경합 조건(Race Condition)

declare function randomFetch(callback: () => void): void;

let a = 1;
let b = 1;

function add() {
  a++;
  b += a;
}

function multiply() {
  a *= 10;
  b *= a * 2;
}

randomFetch(add);
randomFetch(multipy);

이 프로그램의 실행 결과로 ab 각각은 어떤 값을 바인딩할까? 이에 대한 답은 모른다. 자바스크립트는 완전 실행 모델에서 작동하는 언어이기 때문에 먼저 호출된 함수에 의해 답이 달라지기 때문이다. add() 가 먼저 실행된다면 ab 는 각각 23을 바인딩하지만 multiply()가 먼저 실행된다면 ab 는 각각 1131이 바인딩된다. 이처럼 Task 의 순서는 비결정적이다. 그리고 이러한 상황을 경합 조건(race condition)이 발생한다고 표현한다.

2. 동시성과 인터리빙

자바스크립트는 나중 어느 시점에 처리될 작업을 완전-실행하여 요청한다. 나중 시점에 처리되는 작업은 이벤트루프 바깥에서 실행된다. 왜냐하면 이 작업을 처리하는 주체는 호스팅 환경이기 때문이다. 게다가 호스팅 환경은 다른 원천(TaskSource)을 가진 작업들을 동시에 실행한다.

하지만 이벤트 루프는 완전 실행 모델을 반영하기 때문에 다른 원천들에서 실행되는 나중에 처리되는 작업들이 동시에 처리된다고 하더라도 그 이후의 Task는 한 번에 하나씩만 처리한다. 여러 개 중 하나만 처리되는 것이며 어떤것을 처리할지는 브라우저의 구현 사항에 따라 다르다. (우리가 모른다.) 근데 처리하는 속도가 굉장히 빨라서 동시에 되는 것처럼 보이는 것일뿐이다. 여러 개의 작업을 번갈아가면서 실행시키는 것을 인터리빙(InterLeaving)이라 한다.

여기서 말하는 동시성은 공학적인 레벨의 동시성이라기 보다는, 논리적인 작업이 동시에 이루어진다는 개념의 뉘앙스를 가진다.

호스팅 환경에서 실행되는 작업들에 대해서 우리가 결정적으로 순서를 지을 수 있을까? 호스팅 환경에서 동시에 돌아가는 작업들은 경합 조건을 만나기 정말 쉽다. 이벤트루프는 인터리빙을 하기 때문이다. 동시성의 문제를 조정해주기 위한 테크닉은 여러 가지가 있다.

콜백 패턴이 왜 어려운가?

1. 우리 마음이 생각하는 흐름이 콜백 패턴의 흐름과 다르기 때문이다.

콜백 패턴으로 비동기성을 표현하는 방식은 동기적인 두뇌의 사고 흐름과 맞지 않다. 프로그래밍을 할 때 두뇌의 사고 흐름은 선형적이다. 개발자는 이 코드가 할 일을 계획하고 코드를 작성한다.

function swap<T>(array: T[], left: number, right: number) {
  const temp = array[left];
  array[left] = array[right];
  array[right] = temp;
}

swap()을 읽을 때에도 우리의 사고 흐름은 선형적이다. 코드를 한 줄씩 읽어가면서 줄이 가지는 의미를 파악하고 그 의미를 토대로 다음 줄의 의미를 읽어가면서 실행 결과를 예측한다.

그러나 콜백 패턴으로 비동기 코드로 작성할 때는 선형적인 흐름으로 표현할 수 없다. 우리는 할 일이 끝난 다음에 다음 일을 생각하지 않는가? 콜백 패턴은 할 일을 시작할 때 나중의 일을 등록하는 방법이다. 이러한 패턴으로 쓴 코드를 읽는 것도 마찬가지다. 우리는 단계별로 끊어 생각하는 경향이 있는데, 우리 손에 들려진 콜백 함수는 동기에서 비동기로 전환된 이후 단계별로 나타내기가 쉽지 않다.

type Callback = () => void;
declare function doA(callback: Callback): void;
declare function doB(): void;
declare function doC(callback: Callback): void;
declare function doD(): void;
declare function doE(): void;
declare function doF(): void;

// index.ts
doA(() => {
	doB();
  	doC(() => {
      doD();
    });
    doE();
})
doF();

이 프로그램의 실행 순서는 doA()doC() 가 비동기 코드인지 동기적인 코드인지에 따라 달라진다. 두 개의 함수가 모두 비동기 코드라면 우리의 시선은 위에서 아래로, 다시 위에서 아래로를 반복하면서 코드의 흐름을 파악할 것이다. 정말 피곤하지 않은가? 더욱 안타까운 건 이 코드는 doB()doE() 에서 에러가 발생할 때의 상황을 대비하지 않았다. 그리고 doA()doC() 에서 나중에 처리될 코드에서 에러가 났을 때의 상황을 대비하지도 않았다. 이러한 상황을 모두 대비하여 리팩토링을 했을 때, 그 결과로 보여지는 코드는 우리의 땀과 눈물로 만들어진 소중한 것이겠지만 추가적인 에러가 나지 않기를 바라면서 기도메타로 돌아가는 지옥이 될 것이다.

2. 제어가 역전되기 때문이다.

콜백 패턴은 나중에 처리가 된 일 이후에 할 일을 지금 등록하는 패턴이다. 우리는 지금 비동기 코드를 실행하지만 우리가 등록한 콜백함수는 실행한 코드 어딘가에서 나중에 다시 호출한다. 우리가 작성한 프로그램인데 실행 흐름은 다른 API에 의존하는 이런 상황을 제어의 역전(Inversion of control) 이라고 한다. 우리는 프로그램의 제어권을 암시적으로 다른 곳에 넘겨주어야만 한다.

믿음 메타로 API가 잘 돌아가기를 바라면서 제품을 만들 수 있을까? API를 만든 사람도 사람인지라 버그를 만들 수 있지 않겠는가. 따라서 우리는 "믿는다. 그러나 확인은 하겠다"의 마인드로 프로그래밍을 해야한다.

중요한 사실은 우리가 제어할 수 있는 코드에 포함된 비동기 함수 호출에 대해서도 같은 원리를 적용해야 한다는 것이다. 결국 매 번 비동기적으로 콜백 함수에 반복적인 보일러플레이트를 넣는 식으로 손수 필요한 장치를 만들어야 한다.

profile
네명입니다

0개의 댓글