안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

자바스크립트 (JavaScript)

[JavaScript] 비동기 프로그래밍 (콜백 함수, Promise)

피그브라더 2020. 7. 28. 10:55

1. 비동기 프로그래밍 (Asynchronous Programming)

동기적(Synchronous) 실행은 코드가 작성된 순서대로 실행되는 것이라면, 비동기적(Asynchronous) 실행은 코드가 작성된 순서와 무관하게 실행되는 것을 말한다. 동기적 실행은 어떠한 작업이 완료되기 전까지 반드시 기다려야 하지만, 비동기적 실행은 그 작업이 완료되기 전까지 다른 작업을 수행할 수 있다는 특징이 있다. 비동기 프로그래밍은 프로그램의 성능을 획기적으로 향상하는 한편, 코드가 작성된 순서대로 실행되는 것이 아니기 때문에 많은 프로그래머들이 디버깅에 어려움을 겪곤 한다. 그래서 많은 프로그래밍 언어들은 비동기 프로그래밍의 편의를 돕기 위해 여러 기능들을 제공하곤 한다. 이번 포스팅에서는 JavaScript가 제공하는 기능들을 살펴볼 것이다.

 

이미지 출처 : https://gaenglovesdev.github.io/til/web/2019-02-01-javascript-Asynchronous-programming/

 

브라우저의 JavaScript 런타임은 JavaScript 엔진, 웹 API, 태스크 큐, 이벤트 루프의 협력을 통해 비동기 프로그래밍을 지원한다. 일반적으로 브라우저 환경에서의 비동기 프로그래밍은 네트워크 통신과 같이 시간이 꽤 걸리는 작업들을 브라우저의 웹 API에 위임해두고 그동안 다른 동작들을 수행하기 위한 용도로 활용된다. JavaScript 런타임에서 비동기 작업을 처리하는 원리에 대해서는 여기를 참조하기 바란다.

 

2. 콜백 함수 (Callback Function)

2-1. 개념

콜백 함수(Callback Function)란 비동기 작업이 완료된 뒤에 실행되도록 하는 함수를 의미한다. JavaScript 엔진은 비동기 작업의 처리를 웹 API에게 위임할 때 해당 비동기 작업이 완료되면 실행되기를 바라는 콜백 함수도 함께 넘겨준다. 이를 위해 웹 API에 비동기 작업의 처리를 요청하는 함수들을 호출할 때는 반드시 콜백 함수도 인자로 함께 넘겨주게 된다. JavaScript에서 콜백 함수를 인자로 넘겨주면서 비동기 작업의 처리를 요청하는 코드의 예시로는 다음과 같은 것들이 있다.

 

  • setTimeout() 함수 : 특정 시간이 경과하면 인자로 넘긴 콜백 함수가 호출되도록 웹 API에 요청한다.
  • Ajax 요청 : 요청에 대한 응답이 날아오면 인자로 넘긴 콜백 함수가 호출되도록 웹 API에 요청한다.
  • 이벤트 핸들러 등록 : 해당 이벤트의 발생이 감지되면 인자로 넘긴 콜백 함수가 호출되도록 웹 API에 요청한다.
setTimeout(function () {
    console.log('Hello World');
}, 1000);

$.get('https://xyz.com/students/0', function (response) {
    console.log(response);
});

document.getElementById('btn').addEventListener('click', function () {
    console.log('Clicked!');
});

 

2-2. 동작 원리

위와 같이 비동기 작업의 처리를 웹 API에 위임하고 나면 해당 위임 함수들은 스택에서 즉시 팝이 된다. 이후 웹 API는 위임받은 비동기 작업들을 처리하게 되고, 특정 콜백 함수가 실행되어야 하는 순간이 되면 그것을 태스크 큐에 집어넣는 역할을 수행한다. 그러다가 호출할 함수가 더 이상 존재하지 않아 스택이 비게 되면, 이벤트 루프에 의해 태스크 큐에서 대기 중인 콜백 함수가 스택으로 푸시되어 실행이 개시된다. 이것이 콜백 함수의 동작 원리에 대한 개략적인 설명이며, 자세한 내용은 여기를 참조하기 바란다.

 

2-3. 문제점

콜백 함수는 어느 프로그래밍 언어에서든지 비동기 프로그래밍을 실현하는 아주 기본적인 방법이라고 할 수 있다. 그러나 콜백 함수만으로 비동기 프로그래밍을 하기에는 불편한 점이 꽤 있다. 다음과 같은 두 가지의 문제점을 가지고 있기 때문이다.

 

  • 콜백 지옥 (Callback Hell) : 콜백 함수는 해당 비동기 작업의 처리 결과에 해당하는 데이터를 가지고 또 다른 비동기 작업의 처리를 다시 요청할 수도 있다. 물론 이때도 해당 비동기 작업이 완료되면 실행할 또 다른 콜백 함수를 인자로 넘겨줘야 한다. 만약 이러한 패턴이 반복되면 괄호들이 여러 개 중첩되어 코드의 가독성이 현저히 떨어질 것이다. 이러한 현상을 콜백 지옥이라고 부른다.
  • 예외 처리의 복잡성 : 콜백 함수 내부에서 예외가 발생하면 콜백 함수를 감싸는 try catch 문에서는 그 예외를 감지할 수 없다. 콜백 함수는 나중에 이벤트 루프에 의해 태스크 큐에서 꺼내진 뒤 스택으로 푸시되어 실행이 개시되는데, 그때는 이미 해당 try catch 블락을 벗어나 있는 상태이기 때문이다. 그래서 제대로 예외 처리를 해주려면 콜백 함수 내부와 콜백 함수 외부 모두에 try catch 문을 작성해줘야 하는데, 이는 상당히 불편할뿐더러 가독성도 떨어지고 콜백 지옥까지 빠지면 예외 처리가 사실상 불가능에 가까워진다.

 

3. Promise (ES6 ~)

3-1. 개념

Promise 객체는 ES6(= ES2015) 버전의 JavaScript부터 새롭게 추가된 것으로, 위에서 언급한 콜백 함수의 문제점들(콜백 지옥, 예외 처리의 복잡성 등)을 해결하고 조금 더 편리하게 비동기 프로그래밍을 할 수 있도록 돕기 위해 등장하였다. Promise 객체는 이름 그대로 '어떠한 작업을 수행할 것을 약속하고 그 작업의 진행 상태결괏값 저장하고 있는' 객체를 의미한다. 그 작업이라는 것은 Promise 객체가 생성된 직후에 바로 실행이 되는데, 이는 결괏값을 얻을 때까지 기다리는 동기적 작업일 수도 있고 결괏값을 얻을 때까지 기다리지 않고 다른 작업을 수행할 수 있는 비동기 작업일 수도 있다. 그 작업의 진행 상태에 따라 Promise 객체는 다음과 같이 세 가지 상태 중 하나에 놓이게 된다. Promise 객체를 생성한 직후의 초기 상태는 Pending 상태이다.

 

  • Pending 상태 : 아직 아무 작업도 수행하지 않은 상태로, 결괏값이 세팅되어 있지 않다.
  • Fulfilled 상태 : 약속한 작업이 성공적으로 이행된 상태로, 결괏값이 그 이행 결괏값(Fulfillment Value)으로 세팅된다.
  • Rejected 상태 : 약속한 작업의 이행이 특정 이유로 거부된 상태로, 결괏값이 그 거부 이유(Rejection Reason)로 세팅된다.

 

3-2. Promise 객체의 생성

Promise 객체는 다음과 같이 생성한다. 생성자에 전달하는 함수는 인자로 resolve() 함수와 reject() 함수를 전달받으며, Promise 객체가 생성된 직후에 즉시 실행된다. 이 함수 안에 정의되는 로직이 곧 해당 Promise 객체가 수행하기로 약속한 작업에 해당하는 것이며, 일반적으로는 setTimeout() 함수나 ajax 요청과 같이 비동기적으로 실행되는 코드가 이곳에 위치한다. 만약 그 작업이 성공적으로 이행되었다면 이행 결괏값을 인자로 넘기며 resolve() 함수를 호출해야 하고, 그렇지 않다면 거부 이유를 인자로 넘기며 reject() 함수를 호출해야 한다.

let p = new Promise(function (resolve, reject) {
    $.get('https://xyz.com/students/1', function (response) {
        if (response.success) {
            resolve('성공하였습니다.');  // 이행 결괏값 : '성공하였습니다.'
        }
        else {
            reject('권한이 없습니다.');  // 거부 이유 : '권한이 없습니다.'
        }
    });
});

 

그런데 왜 저래야 하는 것일까? 저렇게 인자로 넘기는 이행 결괏값이나 거부 이유를 어디선가 쓸 것이기 때문일 것이다. 곧 알게 되겠지만 이것이 Promise 객체를 쓰는 이유와 직결된다. 우선 하나씩 천천히 따라가 보자. 특정 Promise 객체를 대상으로 resolve() 함수 혹은 reject() 함수를 호출하는 것은 다음과 같은 의미를 지닌다.

 

  • resolve(x) 함수 호출 : 해당 Promise 객체의 상태는 Fulfilled로 변하고 결괏값은 x로 세팅된다. 이후 해당 Promise 객체의 Fulfillment 작업 큐에서 대기 중인 콜백 함수들을 비동기적으로 실행한다(= 태스크 큐에 집어넣는다). 쉬운 표현을 위해, 이제부터는 이러한 일이 일어나는 것을 "Promise 객체가 이행되었다"라고 표현하는 것으로 하자.
  • reject(e) 함수 호출 : 해당 Promise 객체의 상태는 Rejected로 변하고 결괏값은 e로 세팅된다. 이후 해당 Promise 객체의 Rejection 작업 큐에서 대기 중인 콜백 함수들을 비동기적으로 실행한다(= 태스크 큐에 집어넣는다). 쉬운 표현을 위해, 이제부터는 이러한 일이 일어나는 것을 "Promise 객체가 거부되었다"라고 표현하는 것으로 하자.

 

3-3. then() 함수와 catch() 함수

그렇다면 Fulfillment 작업 큐와 Rejection 작업 큐란 무엇인 걸까? (이는 Promise 객체의 실제 내부 구현과는 다를 수 있다. 그러나 동작 원리를 이해하는 데 있어서는 문제가 없을 것이다. 세부 구현 사항은 직접 찾아보기 바란다.) 그것은 바로 then(onFulfillment, onRejection) 혹은 catch(onRejection) 함수의 호출에 의해 등록되는 콜백 함수들의 목록을 의미한다. 다음 코드를 살펴보자.

 

let p = new Promise(function (resolve, reject) {
    $.get('https://xyz.com/students/1', function (response) {
        if (response.success) {
            resolve('성공하였습니다.');  // 이행 결괏값 : '성공하였습니다.'
        }
        else {
            reject('권한이 없습니다.');  // 거부 이유 : '권한이 없습니다.'
        }
    });
});

let newP = p.then(
    function (fulfillmentValue) {
        console.log(fulfillmentValue);  // 출력 결과 : '성공하였습니다.'
    },
    function (rejectionReason) {
        console.log(rejectionReason);  // 출력 결과 : '권한이 없습니다.'
    }
);

 

then(onFulfillment, onRejection) 혹은 catch(onRejection) 함수를 호출할 때 인자로 전달하는 함수의 역할은 다음과 같다. onFulfillment() 함수는 Fulfillment 작업 큐에 등록되는 콜백 함수로, 해당 Promise 객체가 이행될 때 실행이 되고 그 이행 결괏값을 인자로 전달받아서 동작한다. 반면에 onRejection() 함수는 Rejection 작업 큐에 등록되는 콜백 함수로, 해당 Promise 객체가 거부될 때 실행이 되고 그 거부 이유를 인자로 전달받아서 동작한다.

 

한편, 특정 Promise 객체를 대상으로 then(onFulfillment, onRejection) 함수가 호출되는 순간 일어나는 일은 다음과 같다.

 

  • 해당 Promise 객체가 아직 Pending 상태라면, 인자로 전달받은 콜백 함수들을 앞서 말했듯 각각 적절한 작업 큐에 집어넣는다.
  • 그런데 만약 해당 Promise 객체가 이미 이행되거나 거부된 상태라면, onFulfillment() 함수 혹은 onRejection() 함수를 즉시 실행한다. 단 여기서 말하는 즉시 실행은 비동기적 실행을 의미하는 것으로, 즉시 태스크 큐에 집어넣는다는 것을 의미한다. 이러한 원리로 이미 이행되거나 거부된 Promise 객체에 대해서도 then() 함수나 catch() 함수를 호출하는 것이 가능해진다.

 

3-4. Promise 체인

여기서 중요한 개념이 하나 등장한다. 바로 then() 함수나 catch() 함수는 Pending 상태의 또 다른 Promise 객체를 생성하여 반환한다는 사실이다. 예를 들어, A라는 Promise 객체를 대상으로 then(onFulfillment, onRejection) 함수를 호출하면, A의 작업 큐에는 onFulfillment() 함수와 onRejection() 함수가 등록되고 Pending 상태의 새로운 Promise 객체 B를 생성하여 반환하게 된다. 그렇다면 새로 생성되어 반환되는 Promise 객체는 어느 시점에 어떻게 이행되거나 거부되는 것일까? 정답은 해당 Promise 객체를 생성하고 반환한 then() 함수 혹은 catch() 함수가 등록했던 콜백 함수들이 실행될 때이다. 이를 이해하기 위해, 작업 큐에 등록되어 있는 콜백 함수들이 실행되는 방식을 한 번 알아보도록 하자.

 

특정 Promise 객체가 이행되거나 거부되는 순간, 해당 Promise 객체의 작업 큐에 등록되어 있는 콜백 함수들이 실행된다. 이때 콜백 함수들은 Promise 객체에 저장되어 있는 이행 결괏값 혹은 거부 이유를 인자로 전달받아서 동작한다. 그리고 해당 콜백 함수가 무엇을 반환하느냐에 따라서 그 콜백 함수를 등록할 때 새로 생성되었던 Promise 객체의 상태와 결괏값이 변화한다(= 이행되거나 거부된다). 아까의 예시를 다시 가져오면, A의 작업 큐에 등록된 콜백 함수들이 실행되면 그 반환 값에 따라 B가 이행되거나 거부된다는 의미이다.

 

  • 특정 값을 반환할 경우 : 생성된 Promise 객체는 그 반환 값을 이행 결괏값으로 삼아서 이행된다.
  • 값을 반환하지 않을 경우 : 생성된 Promise 객체는 undefined를 이행 결괏값으로 삼아서 이행된다.
  • 콜백 함수 내에서 예외가 발생할 경우 : 생성된 Promise 객체는 그 예외를 거부 이유로 삼아서 거부된다.
  • 이미 이행된 Promise 객체를 반환할 경우 : 생성된 Promise 객체는 그 Promise 객체의 결괏값을 이행 결괏값으로 삼아서 이행된다.
  • 이미 거부된 Promise 객체를 반환할 경우 : 생성된 Promise 객체는 그 Promise 객체의 결괏값을 거부 이유로 삼아서 거부된다.
  • Pending 상태의 Promise 객체를 반환할 경우 : 생성된 Promise 객체는 그 Promise 객체의 상태와 결괏값을 따른다.
Promise 생성자에게 넘기는 콜백 함수, 혹은 then() 함수나 catch() 함수에 넘기는 콜백 함수 내에서 처리되지 않는 예외가 발생하면, 생성되는 Promise 객체는 그 예외를 거부 이유로 삼아서 거부된다. 그리고 거부된 Promise 객체가 catch() 함수에 의해 처리되지 않으면 JavaScript 엔진은 그 예외를 발생시킨다.

 

드디어 Promise 객체를 사용하는 핵심적인 이유에 도달하였다. Promise 객체를 대상으로 then() 함수나 catch() 함수를 호출하면 또다시 Promise 객체가 반환되기 때문에, then() 함수나 catch() 함수를 연속적으로 사용하는 것이 가능해진다. 이것이 본래 콜백 함수만으로 비동기 프로그래밍을 할 때 겪을 수 있는 콜백 지옥의 문제점을 해결한다. 바로 다음과 같이 말이다. 훨씬 더 가독성이 좋아졌음을 알 수 있고, 심지어는 try catch 블록으로 잡히지 않던 예외도 catch() 함수를 통해 간단히 처리할 수 있게 된다.

let p = new Promise(function (resolve, reject) {
    /* 학생 정보 로드 */
    $.get('https://xyz.com/students/1', function (response) {
        if (response.success) {
            resolve(response);
        }
        else {
            reject('권한이 없습니다.');  // 거부 이유 : '권한이 없습니다.'
        }
    });
});

let newP = p
    /* 학급 정보 로드 */
    .then(function (fulfillmentValue) {
        let classId = fulfillmentValue.class_id;
        return new Promise(function (resolve, reject) {
            $.get('https://xyz.com/classes/' + classId, function (response) {
                if (response.success) {
                    resolve(response);
                }
                else {
                    reject('권한이 없습니다.');  // 거부 이유 : '권한이 없습니다.'
                }
            });
        });
    })
    
    /* 학급 정보 로드 성공 */
    .then(function (fulfillmentValue) {
        console.log(fulfillmentValue);
    })
    
    /* 학급 정보 로드 실패 */
    .catch(function (rejectionReason) {
        console.log(rejectionReason);
    });

 

3-5. 참고 : 콜백 함수의 비동기 실행

앞서 then() 함수 혹은 catch() 함수에 전달되는 콜백 함수들은 실행될 때 비동기적으로 실행된다고 하였다. 즉 동기적으로 바로 스택에 푸시되는 것이 아니라 태스크 큐에 들어간다는 것이다. 이를 한 번 코드로 직접 확인해 보자.

/* 정적 메소드 resolve()를 활용하여 이행된 Promise 객체 생성 */
let resolvedP = Promise.resolve(33);

let newP = resolvedP.then((value) => {
    console.log('이 부분은 스택이 비게 되었을 때 실행됩니다. 전달받은 값이자 반환 값은 ' + value + '입니다.');
    return value;
});

/* newP의 값을 즉시 확인합니다. */
console.log(newP);

/* setTimeout() 함수를 이용하면 함수의 실행을 스택이 비게 될 때까지 미룰 수 있습니다. */
setTimeout(() => {
    console.log(newP);
});

/*
    출력 결과
    1) Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
    2) '이 부분은 스택이 비게 되었을 때 실행됩니다. 전달받은 값이자 반환 값은 33입니다.'
    3) Promise {[[PromiseStatus]]: "resolved", [[PromiseValue]]: 33}
*/

 

이미 이행된 Promise 객체를 대상으로 then() 함수를 호출하면 전달되는 콜백 함수는 즉시 실행되지만, 동기적 방식이 아니라 비동기적 방식으로 즉시 실행된다는 것을 알 수 있다. 이 부분이 잘 이해가지 않는다면 여기를 참조하도록 하자.

 

 

 

 

 

 

본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.

https://getoutsidedoor.com/2020/03/12/promise-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EB%A9%B0-%EC%9B%90%EB%A6%AC-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0/

https://vallista.kr/2019/05/09/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-Event-Loop/

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/then