프론트엔드/고찰하기

Javascript의 비동기 프로그래밍 - (1) 콜백, 뭐라고 생각하세요?

lerrybe 2023. 9. 14. 05:18

오늘은 자바스크립트의 비동기 프로그래밍에 대해 알아보겠습니다.

이 글은 이벤트 루프의 동작, 호출 스택, 메모리 힙, 태스크 큐 등에 대한 이해를 기반으로 작성되었습니다.

 

들어가며

자바스크립트는 기본적으로 싱글 스레드입니다. 이러한 동기적 처리는 한 작업이 완료될 때까지 다음 작업을 실행하지 않습니다. 동시에 하나의 작업만 처리한다는 뜻입니다.

 

우리는 여기서 하나의 작업이 다른 작업들의 병목현상이 될 수 있다는 문제를 느끼게 됩니다. 예를 들어, 웹 페이지에서 서버로부터 데이터를 가져오는 동안 사용자 인터페이스가 응답하지 않는 상황이 발생할 수도 있죠. 자바스크립트 엔진’만’ 살펴보면 단일 호출 스택을 사용하기 때문에 충분히 우려되는 문제입니다.

 

그러나 브라우저에서 자바스크립트 코드가 실행될 때 우려했던 문제는 크게 발생하지 않습니다. 이벤트가 일어날 때 다른 작업을 하기도하고, 네트워크 응답을 기다리는 사이에 이벤트 리스너가 실행되기도 합니다. 화면도 잘 보이고요. 왜 그럴까요? 바로 이벤트 루프(event loop) 시스템이 있기 때문입니다.

 

브라우저는 이벤트 루프를 이용해서 비동기 방식으로 자바스크립트 코드에서의 동시성을 지원합니다. 비동기란 특정 로직의 실행이 끝날 때까지 기다려주지 않고 나머지 코드를 먼저 실행하는 것입니다. 이 시스템을 통해 작업들의 실행 순서를 효과적으로 제어할 수 있게 되었습니다. 비동기 프로그래밍은 웹 애플리케이션에서 중요한 역할을 합니다. 대기 시간을 최소화하고, 사용자 인터페이스를 블록시키지 않으며, 병렬로 처리해 (또는 처리되는 것처럼 순서를 조정하여) 시간을 절약하고 효율적인 동작을 가능하게 합니다. 오래 걸리는 작업을 미뤄 웹에서의 사용자 경험, 성능 등을 보완할 수 있게 되는 것입니다.

 

그렇게 나중에 실행되는 비동기 함수를 통해 얻어진 결과물 즉, 데이터를 우리는 핸들링 하고 싶을 텐데요, 그것을 위해 고안된 방식이 바로 콜백(callback), 프로미스(promise), async/await 입니다.

 


Callback (콜백)

Callback은 나중에 호출된다는 의미입니다. 즉, 콜백함수는 나중에 호출되는 함수 정도로 정리할 수 있습니다.

 

엇, 그런데 ‘나중에 호출된다’는 게 뭘까요? 예제를 봅시다.

function processArray(arr, callback) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i]);
  }
}

// 콜백 함수 정의
function printElement(element) {
  console.log(element);
}

const myArray = [1, 2, 3, 4, 5];

// processArray 함수를 호출하면서 콜백 함수를 전달
processArray(myArray, printElement);

예를 들어, 배열의 각 요소에 대한 작업을 수행하고 싶다고 가정해봅시다. 이때 콜백 함수를 사용하여 각 요소에 대한 작업을 정의하고, 배열의 각 요소에 대해 이 콜백 함수를 호출할 수 있습니다.

 

위의 예제에서, processArray 함수는 배열과 콜백 함수를 인자로 받습니다. 그리고 배열의 각 요소에 대해 콜백 함수를 호출합니다. 이렇게 하면 printElement 함수가 배열의 각 요소에 대해 호출되어 해당 요소가 콘솔에 출력됩니다. 이 예제에서 printElement 함수는 콜백함수로서 전달되었으며, 나중에 processArray 함수 내에서 필요한 시점에 호출됩니다. 이렇게 '나중에 호출될 수 있도록' 함수를 파라미터로 넘겨주는 것을 콜백함수라고 합니다. 

 

어떻게 함수를 '값'의 자리인 파라미터에 넘겨줄 수 있는 걸까요?

자바스크립트에서 함수는 메모리를 차지하고 있는 객체(정확히는 일급 객체)이기 때문입니다. 일급 객체는 아래와 같은 일을 할 수 있습니다. 

 

  1. 무명의 리터럴로 생성할 수 있다. 즉 런타임에 생성이 가능하다.
  2. 변수나 자료구조(객체, 배열 등)에 저장할 수 있다.
  3. 함수의 매개변수에 전달할 수 있다.
  4. 함수의 반환값으로 사용할 수 있다.

즉, 값이 될 수 있으므로 위에서 살펴본 바와 같이 파라미터에 인자로 넘겨줄 수도 있습니다. 보통 우리가 호출하는 Web API의 경우에 콜백함수 패턴이 많이 이용됩니다. map이나 forEach와 같은 고차함수는 콜백함수를 인자로 받을 수 있고, setTimeOut과 같은 Web API도, addEventListener도 콜백함수를 인자로 받습니다.

 

"콜백(callback)"이라는 용어는 "나중에 호출될 함수"라는 의미를 내포하고 있습니다. 콜백함수는 호출되는 함수에 인자로 전달되고, 호출되는 함수에 정의된 로직에 따라 적절한 시점에 콜백함수가 실행됩니다. 이때의 "적절한 시점"이 바로 "나중에"가 됩니다. 예를 들어, 웹 API로부터 데이터를 비동기적으로 요청하는 함수에서, 데이터를 성공적으로 받아왔을 때 실행되는 함수를 콜백함수로 넘길 수 있습니다. 이 콜백함수는 데이터 요청이 완료되었을 때 ("나중에") 호출되도록 로직이 작성되어 있을 것이라고 추측할 수 있습니다. 

 

 

💡 콜백함수를 이용해 응답 상태에 따라 다른 처리를 해주는 예시

아래 코드는 실제 동작하는 코드는 아니고, 비동기 작업을 수행하는 함수에 콜백함수를 전달하는 흐름을 나타낸 예시입니다. url, http 메소드, 성공 시 호출되어야 할 함수, 실패 시 호출되어야 할 함수를 인자로 전달합니다. 응답이 성공했을 때, 또는 실패했을 때에 따라 분기를 나눠 조건에 맞는 함수를 호출한다는 흐름만 봐주시면 될 것 같습니다. 

function onSuccess(data) {
  console.log(data);
}

function onError(err) {
  console.log(err.message);
}

// 비동기 함수 정의 부분
function getData(url, method, successCallback, errorCallback) {
  const xhr = new XMLHttpRequest();
  xhr.open(method, url, true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if (xhr.status >= 200 && xhr.status < 300) {
        successCallback(xhr.responseText);
      } else {
        errorCallback(new Error('failed'));
      }
    }
  };
  xhr.send();
}

// 비동기 함수 호출 부분
getData(
  'https://api.example.com/data',
  'GET',
  onSuccess,
  onError
);

console.log('병목에 걸리지 않는 부분');

 

🔑 코드 실행 흐름 살펴보기

엔진마다 차이가 있을 수 있기 때문에, 대략 흐름 정도만 살펴봅시다.

 

1) 함수 호출

  1. getData 함수를 호출합니다. 이 때 요청할 url, 메서드, 그리고 성공과 실패 시에 호출될 콜백 함수를 전달합니다.
  2. getData 함수 본문에 있는 코드들이 실행되기 시작합니다.

2) 호출된 함수 로직

  1. XMLHttpRequest를 생성해서 서버에 데이터를 요청하기 위한 이것저것 작업을 해줍니다. (xhr.open 메서드를 사용해 어떤 method로 요청할 것인지, 주소는 어떻게 되는지 등을 세팅합니다. 마지막 옵션은 비동기로 처리할건지를 boolean 값으로 전달해준 것입니다.)
  2. 상태 변경 이벤트 핸들러를 등록해줍니다. xhr.onreadystatechange = function () {  // ... }. 이 핸들러는 서버로부터 응답이 도착할 때마다 실행됩니다. 
  3. xhr.send 메서드를 호출하면 비동기 요청이 서버에 보내집니다. 

3) 비동기 흐름

  1. 비동기 요청은 백그라운드(메인 스레드와 별도로 동작하는 작업 영역)에서 처리되며, 이 때 메인 스레드는 차단되지 않습니다. (응답이 올 때까지 마냥 기다리지 않습니다.) 
  2. 이벤트 루프는 메인 스레드를 계속 모니터링 하고 있습니다.
  3. 서버에서 응답이 돌아올 때까지 대기합니다. 이 때 이벤트(onreadystatechange, 여기서는 요청한 데이터에 대한 응답)가 발생하는 시점에 상관없이 '병목에 걸리지 않는 부분'이 먼저 console에 출력됩니다. 이는 자바스크립트엔진에서 비동기 함수를 인식하고 호출하면 병목이 발생하지 않게 함수가 즉시 종료되고 그 뒤에 코드들을 실행하기 때문입니다. 여기서는 xhr.send 메소드가 비동기적으로 동작되어야 한다는 것을 엔진은 이미 알고 있습니다. (이미 약속이 되어 있으리라 추측합니다.)
  4. 이벤트가 발생하면, 즉 요청 결과가 도착하면 이벤트 핸들러 내에서 실행되는 콜백함수인 sucessCallback (요청이 성공한 경우) 또는 errorCallback은 태스크 큐에 들어갑니다. (이는 현재 콜스택에 있는 스택 프레임보다 실행의 우선순위가 낮습니다.)
  5. 호출 스택에 남아있는 스택 프레임이 없다면 태스크 큐에 있는 함수를 꺼내와 실행합니다. 이 때 실행 컨텍스트는 그 함수가 호출될 때 생성됩니다. 결과적으로 data 혹은 err.message가 콘솔에 출력됩니다.

getData에 해당하는 함수가 setTimeout, fetch와 같이 우리가 알고 있는 비동기적인 작업을 수행하는 함수라고 생각하면 될 듯 합니다. 대략 이런 방식으로 웹 브라우저는 JavaScript의 싱글 스레드 조건에서도 다양한 웹 API를 통해 비동기 작업을 처리할 수 있게 해줍니다. 웹 브라우저 외부의 웹 API가 실질적인 비동기 작업을 처리하며, 작업이 완료되면 결과를 JavaScript의 실행 환경으로 다시 전달하는 것입니다. 물론, 여기서 설명한 것은 웹 브라우저의 동작을 많이 단순화한 것입니다. 실제로는 웹 브라우저의 내부 동작이 훨씬 복잡하며, 브라우저마다 다양한 웹 API와 최적화 기법들이 포함되어 있다고 해 조금씩은 다를거라 생각합니다.

 


콜백함수 방식의 단점

이런 콜백함수 방식은 단점이 있습니다. 많이 들어보셨겠지만 '콜백지옥'이 만들어진다는 것입니다. 

 

fs.readFile('file1.txt', 'utf8', function (err, data1) {
  if (err) {
    console.error(err);
    return;
  }
  fs.readFile('file2.txt', 'utf8', function (err, data2) {
    if (err) {
      console.error(err);
      return;
    }
    fs.readFile('file3.txt', 'utf8', function (err, data3) {
      if (err) {
        console.error(err);
        return;
      }
      // 파일1, 파일2, 파일3의 데이터를 이용한 작업 수행
      console.log(data1 + data2 + data3);
    });
  });
});

위 예제와 같이, 얻어온 데이터를 이용하기 위해서는 내부에 함수를 중첩, 그 안에서 데이터를 이용한 작업을 해줘야 합니다.

 

그런데 그냥 동기적으로 아래와 같이 사용하면 반환값을 캐치할 수 없는 걸까요? 

다들 프로미스async/await가 머리 속에 있어서 이상하지 않게 보이는 코드겠지만, 잠깐 머리 속에서 지우고 콜백만을 생각해봅시다.

// 비동기 함수 A
const response = A('https://example.com/api/1');
console.log(response);  // undefined

 

실행 과정을 러프하게 살펴봅시다.

 

  1. 비동기 함수 A (=== 비동기로 동작하는 코드를 포함한 함수 A)가 대충 네트워크 요청을 통해 데이터를 얻어오고, 이를 리턴해주는 함수라고 생각해봅시다. (프로미스를 반환하지 않는 상황) A가 호출되면 함수 코드를 평가하는 과정에서 A 함수의 실행 컨텍스트가 생성되고, 실행 컨텍스트 스택 (콜 스택)에 푸시 됩니다.
  2. 보통 비동기 함수에서 전달된 콜백함수는 이벤트 핸들러에 바인딩 되는 경우가 많습니다. 예를 들어 load 이벤트에 특정 핸들러가 연결되어 있다고 가정해봅시다. load 이벤트가 발생하면 이벤트 핸들러는 태스크 큐에 저장되어 대기하다가, 콜 스택이 비면 이벤트 루프에 의해 콜 스택으로 푸시되어 실행됩니다. (이벤트 핸들러도 함수이므로 이벤트 핸들러의 평가 - 이벤트 핸들러의 실행 컨텍스트 생성 - 콜 스택에 푸시 - 이벤트 핸들러 실행과정을 거칩니다.)
  3. 따라서 이벤트 핸들러가 실행되는 시점에서는 콜 스택이 빈 상태여야 하므로 console.log(response)는 이미 종료된 이후입니다. console.log(response)가 몇 번 반복되든 상관없이 저 상황에서 response는 undefined가 출력됩니다. 이벤트 핸들러에서 상위 스코프의 변수에 서버의 응답 결과를 할당하기 이전에 response를 먼저 출력하고 싶어하기 때문입니다. 

 

이처럼 콜백을 이용한 비동기 함수는 비동기 처리 결과를 외부에 반환할 수 없고, 상위 스코프의 변수에 할당할 수도 없습니다. 따라서 비동기 함수의 처리 결과(서버의 응답 등)에 대한 후속 처리는 비동기 함수 내부에서 수행해야 합니다. 이 때 해결책은 아까 살펴봤듯이 비동기 함수에  비동기 처리 결과에 대한 후속 처리를 수행하는 콜백 함수를 전달하는 것이 일반적입니다.

 

 

 

만약 비동기 작업 여러 개를 중첩해서 사용해야 한다면?

 

비동기 처리 결과에 대한 후속 처리를 수행하는 비동기 함수가 비동기 처리 결과를 가지고 또 다시 비동기 함수를 호출해야 한다면 아래와 같은 문제를 만날 수 있습니다.

 

  1. 콜백 함수가 중첩되면서 가독성과 유지 보수를 어렵게 만들 수 있습니다.
  2. 또한 비동기 작업은 실패할 경우도 있기 때문에 적절히 에러처리를 해줘야 하는데 그럴 때마다 에러 처리 로직이 반복되고, 복잡한 코드 속에서 적절히 에러를 처리하지 못할 수도 있습니다.
  3. 혹은 여러 함수를 중첩하는 과정에서 작업 간의 의존성을 관리하기 어려울 수도 있습니다.

이런 상황을 '콜백 지옥'이라고 합니다.

 

 

왜 지옥이냐구요?

asyncFunc1(param1, function (result1) {
  asyncFunc2(result1, function (result2) {
    asyncFunc3(result2, function (result3) {
      asyncFunc4(result3, function (result4) {
        asyncFunc5(result4, function (result5) {
          asyncFunc6(result5, function (result6) {
            asyncFunc7(result6, function (result7) {
              asyncFunc8(result7, function (result8) {
                asyncFunc9(result8, function (result9) {
                  asyncFunc10(result9, function (result10) {
                    // 10단계 중첩 콜백에서 처리하는 로직
                    console.log(result10);
                  });
                });
              });
            });
          });
        });
      });
    });
  });
});

이게 지옥이 아니면 무엇이란 말입니까.. 

 

 

아무튼 이러한 상황은 코드의 유지보수, 확장성을 어렵게 합니다.

그래서 탄생한 것이 바로 프로미스 (Promise), 그리고 프로미스를 동기적인 코드처럼 보이게 할 수 있는 async/await 방식 입니다. 

 

분량상 프로미스와 async/await에 대해서는 다음 포스팅을 통해 살펴볼 예정입니다.

 


마치며

이번 글은 자바스크립트의 비동기 프로그래밍 방식 중 하나인 콜백에 대해 고민해보았습니다.

 

싱글 스레드가 가진 단점을 비동기 프로그래밍 방식으로 보완하고 있다는 것, 그리고 자바스크립트는 함수를 값처럼 취급할 수 있다는 특성을 가지고 있기 때문에 콜백이란 방식을 사용할 수 있다는 게 흥미로웠습니다.

 

그냥 멀티 스레드 쓰면 되는 것 아닌가? 라는 생각이 잠깐 들었지만 사용자에게 보이는 화면을 제어하는 입장에서 스레드가 여러 개라면 동시 편집 마냥 이를 관리하는 로직이 더 복잡할 것 같았고 (충돌했을 때 어떻게 처리할 것인지, 무엇을 우선순위로 둘 것인지 등) 이벤트 루프 시스템으로 제어하는 것보다 유지비가 더 드는 것 같아 계속 싱글 스레드를 유지하고 있는 게 아닌가 하고 추측할 수 있을 것 같아요.

 

읽어주셔서 감사합니다.

 

 


참고자료

JavaScript Visualized: Event Loop

모던 자바스크립트 DeepDive 45장, 프로미스