javascript - callback, promise, async & await, 비동기 활용하기

정현우·2024년 1월 29일
17
post-thumbnail

[ 글의 목적: js에서 비동기를 처리하는 방법에 대한 기록과 활용, 비동기 개념에 대해서는 다루지 않음 ]

동기 & 비동기 에 대한 개념은 python 코루틴(coroutine) - 동시성과 병렬성, 동기와 비동기 작업, blocking과 non-blocking 그리고 코루틴 (1) 글에서 다루고 있습니다!! 먼저 보고 오시는 것을 추천드립니다!!

javascript 비동기 처리

javascript 에서 "비동기" 처리를 위해 대표적으로 callback 함수를 활용한다. 하지만 callback의 depth 때문에 callback hell 과 같은 형태가 만들어지기도 하며, 이를 개선하기 위해 "간결하고 깔끔하게, 그리고 동기 처리하는 것 처럼 비동기를 처리하게" 만들기 위한 promise와 async & await를 살펴보자.

1. callback

  • callback이 뭘까? 프로그래밍에서 콜백(callback) 또는 콜백 함수(callback function)"다른 코드의 인수로서 넘겨주는 실행 가능한 코드" 를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. 쉽게 생각해보면 어떤 코드 덩어리를 어떠 순서 또는 순차적으로 실행하고 싶을 때 쓴다 고도 볼 수 있다.

  • javascript에서 함수는 "객체(object)" 로 취급한다. 따라서 함수는 함수를 인자로 받고 다른 함수를 통해 반환될 수 있다. 인자로 대입되는 함수를 콜백함수라고 부를 수 있다. 더 정확하게는 일급 객체 로 취급힌다. python - 클로저(Closure) 와 데코레이터(Decorator) 글에서 "일급 객체" 부분을 읽어보면 도움이 된다.

1) js에서 callback 을 사용하는 부분

  • 대표적으로 setTimeout(callback, delay), setInterval(callback, delay), addEventListener(event, callback), fetch(url, options), 등이 있다. setTimeout(callback, delay) 을 먼저 살펴보자.
console.log('one');
setTimeout(function() {
    console.log('two');
}, 1000); // 1초 이후 실행
console.log('three');
  • 위 함수를 실행하면 결과가 어떻게 나올까? "동기식으로 실행 되었다면" 결과는 one > 1초 기다리고 > two > three 가 나올 것 이다.

  • 왜 아래와 같이 실행되는지 이해가 안된다면, 시리즈의 앞선 js의 원리들을 살펴보는게 좋다. callback은 event loop로 갔다가 call stack에 올라가고, 그에 따라 나중에 실행된다. 특히 node의 경우, 스케쥴링의 깊은 원리는 event queue중 "Timer" queue phaser 에서 체크되기 때문에 바로 원리를 받아들이기는 난해할 수 있다.

  • setTimeout 은 첫 번째 "인자"로 callback 을 받고 있기 때문에 위에 언급한 대로, 함수를 넘길 수 있다. 그에 따라 "익명 함수" 를 만들어서 바로 넘길 수 있다. 아래와 같이 말이다.
const myFun = (fun) => { fun(); }; // 인자를 callback 함수로 받는 함수
console.log('one');
setTimeout(function() {
  	myFun(function() {
      console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
    });
}, 1000); // 1초 이후 실행
console.log('three');

  • 이제 점점 복잡해진다. setTimeout 의 callback에 바로 익명함수를 선언했고, 그 익명함수 안에 myFun 를 호출하고, 또 바로 익명함수를 만들어서 활용했다. 실제로 js & node 를 활용하다보면 위와 같은 "형태" 를 많이 마주하게 된다. 특히 DBMS를 활용한다면 말이다.

  • 만약 myFun 에서 한 번 더 myFun 를 호출하면 어떻게 될까?

const myFun = (fun) => { fun(); }; // 인자를 callback 함수로 받는 함수
console.log('one');
setTimeout(function() {
  	myFun(function() {
      console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
      myFun(function() {
    	console.log("한번 더 바로 사용하기");
      });
    });
}, 1000); // 1초 뒤 실행
console.log('three');

2) callback 지옥, 멸망의 피라미드

  • 위 예시가 벌써 아찔하다. callback 함수안에서 다른 callback을 부르고 다시 callback을 부르게 되면서 for/if 등이 없어도 depth가 깊어졌다. 굉장히 nesting 하며, 더 심해지면 작성자 외에 알아보기 굉장히 난해해 진다. 이 코드로 어떻게 협업을 하느냐 말이다!

  • 이 nesting callback 을 어떻게 해결할 수 있을까. 일단 바로 위 코드 덩어리를 "함수화" 를 통해 1차 리펙토링을 할 수 있다.
const myFun = (fun) => { fun(); };
const myFun2 = () => {
    console.log("사용자 정의 함수 인자를 callback을 받게 하고 바로 사용하기");
    myFun(function () {
        console.log("한번 더 바로 사용하기");
    });
};

console.log('one');
setTimeout(function () {
    myFun(myFun2);
}, 1000); // 1초 뒤 실행
console.log('three');
  • 한 차례 나아졌지만, 함수 분리를 계속하다보면 아이러니하게 단일 함수 자체가 복잡해지며, 함수의 재사용성은 떨어지고 그에 따라 클린하지 못하게 된다.

2. Promise

  • ES2015, ES6 에 등장한 객체다. 사실 js에서 ES6는 모던한 js의 모습을 제대로 보여준 표준안이다. javascript & node - 웹과 브라우저 역사 그리고 javascript 탄생과 특징 이 글에서 이크마스크립트(ES)에 대한 정보를 이해할 수 있다. 여담으로 ES2015, ES6의 표준안을 보면 변수(let, const), module, 화살표 함수(this binding), 템플릿 리터럴 등 알아두면 좋은 표준이 매무 많다.

1) promise 객체에 대해

  • 단어 뜻 그대로 "약속" 을 의미한다. 비동기적으로 실행되는 코드 흐름에서 특정 이벤트 (성공, 실패 등) 에 따라 그 결과를 전달하겠다는 약속을 의미한다.

  • 위에서 살펴본 "callback 지옥, 멸망의 피라미드" 을 보완할 수 있으며, 비동기로 처리하는 시점을 명확히 할 수 있다. Promise 는 객체 이기 때문에 Promise "생성자 함수"를 통해 "인스턴스"가 만들어지고, 생성자 함수 내에 전달 되는 함수를 실행자(executor) 라 한다.

  • promise는 new Promise 로 instance를 만들때 바로 executor가 자동으로 실행된다!

  • Promise 를 생성할 때, 비동기적 작업이 "성공" 혹은 "실패" 했으냐에 따라 다른 결과를 전달할 수 있게 하는 "매개변수"로 resolve()reject() 라는 callback 함수를 가진다.

  • 그렇기 때문에 "상태" 에 따라 "어떤 매개변수 함수" 를 전달하는지, 그리고 promise를 만드는 "producer", 그 promise를 활용하는 "consumer" 에 대한 이해가 필요하다. 나아가 async & await 역시 promise object를 활용하는 것이라, js 비동기 핸들링을 위해 promise의 이해가 필수다.

  • 아래는 아주 간단한 프로미스 생성의 예제이다.

let a = 1;
console.log("프로그램 코드 시작");
const myPromise = new Promise((resolve, reject) => {
    console.log("프로미스 생성!");
    if (a === 1) resolve(a);
    else reject(new Error("a가 1이 아닙니다!"));
});
myPromise.then(value => {
    console.log(value);
});
console.log("프로그램 코드 끝");

2) state, 상태

  • 기본적으로 아래 3가지 상태를 가진다.
  1. 대기(pending): 이행하지도, 거부하지도 않은 초기 상태.
  2. 이행(fulfilled): 연산이 성공적으로 완료됨.
  3. 거부(rejected): 연산이 실패함.

  • new Promise 로 인스턴스를 만들자마자 "executor가 자동으로 실행" 된다고 했다. 그렇기 때문에 생성과 동시에 "pending" 상태라고 보면 된다. 아래 코드를 잠깐 살펴보자!
console.log("코드 시작");
let a = 1;
const myPromise = new Promise((resolve, reject) => {
    // 여기에 비동기 함수를 실행하면 된다.
    setTimeout(() => {
        if (a === 1) {
            a += 1;
            // resolve는 fulfilled 된 상태
            resolve(a);
        }
        // reject는 rejected 된 상태
        else reject(new Error("a는 1이 아닙니다."));
    }, 2000);
});

myPromise
    .then(value => {
        console.log(value);
    })
    .catch(err => {
        console.error(err);
    })
    // 성공 실패 모두 실행하는 finally
    .finally(() => {
        console.log("모든 작업 끝!");
    })
console.log("코드 끝");
  • 위 코드의 실행결과는 아래와 같다.
코드 시작
코드 끝
2
모든 작업 끝!
  1. new Promise 에서 pending 되어
  2. myPromise.then... 부분의 비동기적으로 실행으로 때문에 console.log("코드 끝"); 가 먼저 나오고,
  3. 2초 뒤 fulfilled 되어 resolve(a); 에 의해서 "a" 값이 "return" 되고,
  4. 그에 따라 then 에 의해 console.log(value); // 2 이 출력되며
  5. 마지막으로 console.log("모든 작업 끝!"); 가 출력이 된다.
  • a를 2로 바꿔버리면? 아래와 같이 reject(new Error("a는 1이 아닙니다.")); 가 되며, "Error object" 를 catch(err... 에서 받아서 이렇게 출력이 된다. 결과에서 알 수 있듯이, finally 는 성공 실패 모두 실행하는 라인이다!
코드 시작
코드 끝
Error: a는 1이 아닙니다.
    at Timeout._onTimeout (/Users/nuung/Desktop/Outsourcing/spartacodingclub/promise.js:24:21)
    at listOnTimeout (node:internal/timers:569:17)
    at process.processTimers (node:internal/timers:512:7)
모든 작업 끝!
  • 결국 new Proimse 로 만들어진 instance는 pending 단계에 있고, 이 단계에서 넘어가고 상태처리를 위해 then(), catch(), finally() 메서드를 제공 한다고 보면 된다.

Producer & Consumer

  • promise를 활용할때 위 두 개의 단어로 입장을 분리를 하곤 한다.

  • producernew Proimse 로 instance를 만들고 어떤 작업을 수행할지, 어떤 액션을 "만들어" 낼지 resolve & reject로 구현하고

  • consumerthen(), catch(), finally() 메서드를 활용해 producer 가 만든 promise를 활용(소비)한다.

3) promise chaning

  • 하나의 promise instance로 promise가 제공하는 메서드(then(), catch(), finally()) 를 활용해 method chaning을 할 수 있다.

  • 위 코드에서 producer 부분 외에 consumer 부분만 아래와 같이 바꿔서 실행해보자!

myPromise
    .then(value => {
        console.log(value);
        return value;
    })
    .then(value => {
        console.log(value * value);
        return value * value;
    })
    .then(value => {
        console.log(value * value);
        return value * value;
    })
    .then(value => {
        console.log(value * value);
    })
    .catch(err => {
        console.error(err);
    })
    // 성공 실패 모두 실행하는 finally
    .finally(() => {
        console.log("모든 작업 끝!");
    })
  • 실행 결과는 아래와 같다. 위에서 살펴본 바와 같이 resolve(a); 가 a를 return하는 것을 의미하고, then(value)... 에서 그 return 값을 value 라는 변수로 받아서, then chaning을 통해 계속 return을 반복할 수 있다.
코드 시작
코드 끝
2
4
16
256
모든 작업 끝!
  • 그리고 then 에서 당연하게 한 번 더 new Promise 로 instance를 만들어서 다시 그 다음 then으로도 받을 수 있다.
myPromise
    .then(value => {
        console.log(value);
        return value;
    })
    .then(value => {
        console.log(value * value);
        return value * value;
    })
    .then(value => {
        console.log(value * value);
        return value * value;
    })
    .then(value => {
        return new Promise((resolve, reject) => {
            // 여기에 비동기 함수를 실행하면 된다.
            setTimeout(() => {
                resolve(value * value);
            }, 2000);
        });
    })
    .then(value => {
        console.log(value);
    })
    .catch(err => {
        console.error(err);
    })
    // 성공 실패 모두 실행하는 finally
    .finally(() => {
        console.log("모든 작업 끝!");
    })
console.log("코드 끝");
  • 실행 결과는 같지만, return new Promise 이후의 then 에서 2초의 대기시간이 생긴다. 이제 위 callback hell의 예제를 promise를 활용한 형태로 바꿔보자!
const myFun = () => {
    return new Promise((resolve, reject) => {
        resolve("myFun 실행!");
    });
};

console.log('one');
setTimeout(() => {
    myFun()
        .then(value => console.log(value))
        .then(value => myFun().then(value => console.log(`${value}, 한번 더 실행`)));
}, 1000);
console.log('three');
  • 이렇게 바꿀 수 있다. (바꿀 수 있는 여러방법 중 하나) 여전히 만족스럽지는 못하다. 뭔가 더 동기식 "처럼" 코딩하는 방법이 있으면 훨씬 좋을 것 같다.

3. async & await

  • ES6에서의 promise 만으로는 비동기/동기 제어가 여전히 불편했고, 여전히 callback 지옥에서 "쉽게" 벗어날 수 없었다. 그래서 ES2017, ES8 에서 async, await 문법이 등장했다. 이 async & await를 제대로 이해하려면 무조건 callback과 promise를 알아야 한다.

1) 새로운 객체? X -> "syntactic sugar"

  • async & await를 뭔가가 새로운 object 라고 이해할 수 있지만, 여전히 promise object를 control할 뿐이고, 이런 promise 사용을 "편하게" 해주는 "syntactic sugar" 일 뿐이다.

  • new Promise 대신 함수 선언시 async 접두사를 가지며 이는 "promise object" 가 된다. 사용할때는 용도에 따라 await 를 붙일 수 도, 붙이지 않을 수 도 있다.

2) async - 비동기 함수 만들기

  • 위에서 만든 Promise를 async 를 접두사를 통해 함수를 선언해서 간단하게 위와 같이 바꿀 수 있다. "producer" 부분에서 resolve, reject 를 굳이 사용하지 않아도 되며, "consumer" 부분이 확연하게 사용하기 편해졌다는 것 을 알 수 있다.

  • 하지만 위 실행결과가 같을까? 오른쪽의 실행결과는 아래와 같다.

코드 시작
Promise { undefined }
코드 끝
2 리턴 합니다!
  • 못보던 Promise { undefined }console.log(b); 에 의해 생겼다. 앞서 언급한 것과 같이 async 는 새로운 무엇인가가 아니라 promise 를 만드는 간단한 문법이며, 이에 따라 여전히 Promise를 return 한다. 그러니 여전히 then을 사용할 수 있다.
const test = async () => { return "test"; };
const a = test();
console.log(a);
a.then((value) => console.log(value));

// 결과는 
>> Promise { 'test' }
>> test

3) await - 비동기 함수, 동기처럼 사용하기

  • 확실히 와닿게 예제를 조금 바꿔보자. setTimeout 함수로 "딜레이"를 주는 함수를 하나 먼저 만들어보자.
const wait = async (count, value) => {
    setTimeout(() => {
        console.log(value);
    }, count);
};
const a = async () => {
    await wait(1000, "a");
    return "return a";
};
const b = async () => {
    await wait(1000, "b");
    return "return b";
};
const c = async () => {
    await wait(1000, "c");
    return "return c";
};

const main = async () => {
    const result1 = a();
    const result2 = b();
    const result3 = c();
    console.log(result1, result2, result3);
}

main();
  • 위 코드의 실행 결과는 아래와 같다. a, b, c 출력만 놓고 보았을 때, 각 출력에 시간 차이가 있는가? -> "없다"
>> Promise { <pending> } Promise { <pending> } Promise { <pending> }
>> a
>> b
>> c                                                    
  • 완전한 절차식 수행 이라면 a() 호출 했을 때 return을 기다리지 않는다. 그렇다고 setTimeout 을 수행할때 까지 기다리지도 않는다. a(), b(), c() 호출 모두 " 동시에 실행되는 것 '처럼' " 보인다.

  • 그러면 await 를 모두 붙이면? return을 기다린다. 그래서 출력을 해보면 Promise 대신 해당 값의 return 값을 볼 수 있다.

// ... 생략 ...
const main = async () => {
    const result1 = await a();
    const result2 = await b();
    const result3 = await c();
    console.log(result1, result2, result3);
}

main();

// 결과는 
>> return a return b return c
>> a
>> b
>> c
  • 즉, 만약 result2 변수에서만 a() 함수의 return 값이 필수적이라면? 아래와 같이 코딩할 수 있다.
// ... 생략 ...
const main = async () => {
    const result1 = await a();
    if (result1 === "return a") {
        const result2 = b();
        console.log(result2);
    }
    const result3 = c();
}

main();
  • 예를 들어 DBMS 와 connection을 맺고 query를 던지는 부분이라면, 전체적으로 비동기로 실행하게 하되 필요할 때만 특정 유저의 정보를 가져와서 (await getUser()) 가져온 유저 정보를 바탕으로 다른 것들을 조회할 수 있다.

promise api 활용

  • MDN official DOCS 에서 볼 수 있는 Promise.all 예제를 활용하면 위 코드를 아래와 같이 변경할 수 있다.
// ... 생략 ...
const main = () => {
    return Promise.all([a(), b(), c()]).then((results => results.join(" ")));
};
main().then(console.log);

// 결과는 
>> return a return b return c
>> a
>> b
>> c

이쯤에서 다시 생각해보는 함수가 지향할 부분

coding 에는 정답은 없지만 "깔끔한 코드"는 있다고 평가된다. clean code 등을 넘어 기본적으로 "함수"가 지향하면 좋은 부분을 다시 짚어보자!

  1. 함수는 하나의 기능만 수행하는게 좋다.
  2. 함수는 명확하고 간결하게 작성하는게 좋다.
  3. 함수는 높은 응집도를 가져야 좋다. (모듈 내의 구성 요소들이 본인의 확실한 책임을 가지고, 같은 가치를 목표로하는 모듈로 뭉쳐야 좋다.)
  4. 함수는 낮은 결합도를 가져야 좋다. (다른 함수 등에 의존성을 크게 가지지 않게 해야 한다.)

마무리

  • js에서 비동기는 쉽게 표현하자면 쉽게, 깊게 표현하자면 - v8 마이크로 테스크 큐와 기본 메모리(heap)와 call stack 등 과 같이 - 어렵게 표현할 수 있다.

  • 개인적으로 Promise 는 DBMS를 활용하면서 많이 익혔다. 자연스럽게 외부 통신과 같은 N/W I/O 가 발생하는 작업을 마주해야 "비동기" 에 대한 필요를 이해하고, 활용할 수 있다고 생각한다.

  • 해당 글에서 다루는 개념을 짧게 다시 요약하면 아래와 같다.

Callback

  • 콜백은 다른 함수의 인자로 전달되는 함수
  • JavaScript에서 함수는 일급 객체로 취급되어 다른 함수에 인자로 전달될 수 있다.
  • 콜백의 사용은 비동기 작업을 다룰 때 자주 사용되지만, 중첩된 콜백으로 인해 코드의 복잡성이 증가할 수 있고 이를 '콜백 지옥'이라고 부르며, 이는 코드의 가독성과 유지 보수성을 저하시킨다.

Promise

  • ES6에서 도입된 Promise는 콜백 지옥 문제를 해결하는 데 도움이 된다.
  • Promise는 비동기 작업의 완료 또는 실패와 그 결과를 나타내는 객체
  • Promise는 대기(pending), 이행(fulfilled), 거부(rejected)의 세 가지 상태를 가지며 Promise는 메서드 체이닝을 통해 비동기 작업을 더 깔끔하게 관리할 수 있도록 도와준다.

Async & Await

  • ES8에서 도입된 async와 await는 Promise를 더욱 쉽게 사용할 수 있게 해주는 문법적 설탕(syntactic sugar)이다.
  • async 함수는 자동으로 Promise를 반환하며, await 키워드는 Promise의 해결을 기다리게 한다.
  • 이를 통해 비동기 코드를 동기 코드처럼 보이게 하여 가독성을 높일 수 있다.

그런데 왜 비동기여야 하는가?

해당 내용은 바로 앞 글 "node - 기본 동작 원리와 이벤트 루프, 브라우저를 벗어난 js 실행!" 의 "왜 싱글 쓰레드를 고집하는가?" 부분을 한 번 다시 보시는 것을 추천합니다.

js는 "싱글 스레드" 를 고집하고 있다. 시리즈에서 계속 언급하는 부분인데 "일 하는 사람이 한 명이라고 생각하면, 한번에 한 가지 작업밖에 수행하지 못한다!" 라고 비유할 수 있다.

이런 상황에서 "철저하게 절차적 수행 + 동기식" 이라면, setTimeout 과 같은 함수를 만나면 모든게 멈춘다. 특정 버튼이나 DOM에 event가 있으면 그 클릭 이벤트를 처리 완료할 때 까지 js는 멈춰버리는 것이다.

그래서 Event Loop, Task queue 등을 통해 "영혼의 최적화" 를 했다. 그 산출물, 그 결과물이 "비동기" 의 면모가 되었다고 볼 수 있다.


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

3개의 댓글

comment-user-thumbnail
2024년 2월 11일

잘 읽었습니다. 1번 콜백에서 예제 설명을 하나로 통합하시는건 어떨까 싶네요. 코드는 setTimeout인데 주석 설명은 1초 간격으로 실행됨 이고 아래 사진은 setIntervel이고 설명으로는 timeout이라고 적으셔서 헷갈리는것 같아요.

2개의 답글