반응형
250x250
Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

샘오리_개발노트

Callback에 대한 의문과 Promise 와 Async/Await 를 쓰는 이유 (feat.비동기vs동기) 본문

개발자 전향 프로젝트

Callback에 대한 의문과 Promise 와 Async/Await 를 쓰는 이유 (feat.비동기vs동기)

샘오리 2024. 1. 30. 17:53
728x90
반응형

콜백이 무엇이고, 콜백을 사용해야하는 이유가 무엇인가?

 

콜백함수는 한마디로 비동기 작업을 처리할 때 순서를 보장해주는 함수이다.

 

자바스크립트에서 비동기 작업을 해야할 때가 생긴다.

그럴 때 제일 중요한 것은 순서이다.

 

예를 들어 토스트에 잼을 발라서 먹으려고 하는데

집에 잼은 있는데 빵이 없다.

 

그래서 이상적인 순서가 아래와 같다면 

마트에서 빵을 사고 -> 빵을 굽고 -> 빵에 잼을 바르기

 

위 순서가 지켜져야지만 원하는 결과를 얻을 수 있을 것이다.

컴파일러는 기본적으로 위에서 아래로 코드를 읽고 컴파일하지만

비동기 작업은 순서가 보장되지 않기 때문이다.

그러면 그 순서를 보장해줘야 하는데 그때 쓰는게 콜백이다.

 

위 내용을 콜백함수를 쓰는 코드로 바꾸면 아래와 같을 것이다.

function getBread(bread, callback) {
  callback(bread);
}
function toastBread(bread, callback) {
  callback(bread);
}
function applyJam(bread, callback) {
  callback(bread);
}
let bread = "식빵";
getBread =
  (bread,
  function (bread) {
    console.log(bread + " 사오기 완료");
    toastBread(bread, function (toastedBread) {
      console.log(bread + " 굽기 완료");
      applyJam(toastedBread, function () {
        console.log(bread + "에 잼 바르기 완료");
        console.log(bread + " 토스트 준비 완료");
      });
    });
  });

 

위 코드는 콜백함수의 중첩이 있지만 짧고 간단해서 가독성이 크게 떨어지지 않으니 간략하게 설명하면

getBread라는 비동기 함수가 parameter 를 전달받고 그 다음 실행할 콜백함수에게 전달해주고 그 콜백함수는 그 다음 실행할 콜백함수에게 전달해주고 하다가 마지막 단계에서 완료했다는 로그를 찍는 코드이다.

(콜백함수에서 각각 굽고, 잼을 바르는 행위는 생략)

 

그러면 두번째 의문이 생기게 된다.

 

그럼 왜 비동기를 쓰는걸까? 동기를 쓰면 순서를 보장해주는데..

왜 굳이 콜백까지 써가면서 비동기 작업을 하는걸까..?

 

그건 개발자의 의도가

순서를 보장하려고 하는 작업을 제외한

나머지는 계속 진행되기를 바라기 때문이다.

 

쉽게 말하면 

마트에서 빵을 사고 -> 빵을 굽고 -> 빵에 잼을 바르기


이 순서에서

동기 작업을 하게 되면 마지막 단계인 빵에 잼을 바르는 것이 완전히 끝나기 전까지는

다른 것들도 같이 멈춘다.

 

우리가 보는 웹사이트들은 어떠한 작업이 진행되고 있다고 해서 다른 것들이 다 멈추지 않는다.

물론 특정 사이트의 경우 그렇게 구현했을 수도 있지만 일반적인 화면의 경우 사용자의 입장에서는 어색할 것이다.

 

한마디로 비동기로 하자는 것은

여행을 갔을 때 같이 간 사람이랑 분업을 해서 

내가 먹을 거를 준비할 때 다른 사람은 청소를 한다든지 해서

독립적으로 행동하자는 것이다.

 

그러니 의도와 상황에 맞게 동기/비동기 작업을 하면 되는 것인데

비동기 작업을 해야하는데 때때로 순서를 보장해줘야 하는 순간이 오니

그 순간 만큼은 콜백함수를 사용해서 순서를 보장해주는 것이다.

 

그러면 콜백 사용하면 되는데 또 왜 Promise니 Async/Await 같은 것을 쓰는걸까?

한마디로 말하자면 가독성 때문이다. 콜백 함수가 중첩되게 되면 읽기가 어렵다.

읽기 어려우면 읽기 싫어진다. 읽기 싫어지면? 디버깅이나 유지보수하기가 어려워진다.

자기가 짠 코드도 한달 뒤에 보면 뭔 소린가 싶은데 남이 내 코드를 디버깅하고 유지보수해야하는데

가독성 떨어지게 나만 알아볼 수 있게 짜면 어떻게 된다? 욕 먹는다.

그리고 꼭 해야하는 예외처리? 그건 이 복잡한 콜백함수를 더 난해하게 만들기만 할 뿐이다.

콜백함수의 중첩이 콜백지옥으로 불리는 이유이기도 하다.

 

그러면

Promise니 Async/Await 같은 것들은 

뭐가 다른걸까?

 

콜백함수의 문제점을 보완하기 위해 만들어진 만큼 당연히 가독성이 좋다.

가독성이 좋으니 디버깅이나 유지보수가 편하다.

에러 핸들링은 덤이다.

 

function getBread(bread, callback) {
  callback(bread);
}
function toastBread(bread, callback) {
  callback(bread);
}
function applyJam(bread, callback) {
  callback(bread);
}
let bread = "식빵";
getBread =
  (bread,
  function (bread) {
    console.log(bread + " 사오기 완료");
    toastBread(bread, function (toastedBread) {
      console.log(bread + " 굽기 완료");
      applyJam(toastedBread, function () {
        console.log(bread + "에 잼 바르기 완료");
        console.log(bread + " 토스트 준비 완료");
      });
    });
  });

 

이제 위에 이 코드를 Promise나 Async/Await 로 바꾼다면 어떻게 되는지 보자.

 

먼저 Promise를 사용하기 위해서는 Promise 객체를 만들어 줘야한다.

 

객체를 만드는 법은 크게 두가지 이다.

//1. 객체 생성 후 변수에 삽입, 이후 변수를 가져다 사용 
const promise = new Promise((resolve, reject) => {
    ...
});

//ex)
promise.then ~~



//2. 함수 return을 Promise로 주는 방법, 이후 함수를 호출해서 사용
function 함수명(매개변수){
    return new Promise((resolve, reject) => {
    ...
    })
}

//ex)
함수명() ~~

 

그리고 Promise는 resolve와 reject가 있는데

이를 통해 조건에 부합하면 resolve, 부합하지 않으면 reject를 할 수 있다.

reject를 줘서 예외처리를 할 수 있다.

 

아래는 콜백함수로 구현된 코드를 promise를 사용하는 코드로 바꾼 예시이다.

참고로 이렇게 then을 사슬처럼 묶어주는 방식을 Promise chaining이라고 한다.

const promise = new Promise((resolve, reject) => {
  let bread = "식빵";
  if (bread) {
    resolve(bread);
  }
  let err = "빵이 없음";
  reject(err);
});

promise
  .then(function getBread(bread) {
    console.log(bread + " 사오기 완료");
    return bread
  })
  .then(function toastBread(bread) {
    console.log(bread + " 굽기 완료");
    return bread
  })
  .then(function applyJam(bread) {
    console.log(bread + "에 잼 바르기 완료");
    return bread
  })
  .then((bread) => {
    console.log(bread + " 토스트 준비 완료");
  })
  .catch((error) => {
    console.log(error);
  });

 

아래는 함수를 호출할 때 return 값으로 Promise 객체를 선언한 방식의 예시이기도 하다.

만약 각각 해야하는 단계의 함수명을 명시적으로 써주지 않아도 된다면 아래와 같이 생략할 수도 있다. 

 

function makeToast(bread){
    return new Promise((resolve, reject) => {
        if (bread) {
          resolve(bread);
        }
        let err = "빵이 없음";
        reject(err);
      });
}

let bread = "식빵";
makeToast(bread)
  .then(bread => {
    console.log(bread + " 사오기 완료");
    return bread
  })
  .then(bread => {
    console.log(bread + " 굽기 완료");
    return bread
  })
  .then(bread => {
    console.log(bread + "에 잼 바르기 완료");
    return bread
  })
  .then(bread => {
    console.log(bread + " 토스트 준비 완료");
  })
  .catch(error => {
    console.log(error);
  });

 

그럼 Promise 를 쓰면 되지 왜 또 Async/Await가 나온것일까?

먼저 두개는 별개가 아니다. 콜백을 쉽게 쓰기 위해 Promise가 나온 것 처럼, 

Promise를 쉽게 쓰기 위해 promise의 단점들을 보완해서 async/await가 나왔다.

그러나 무조건 갈아타야하는 것은 아니다.

언제나 새로운 무언가를 적용하고 바꿀 때는 기회비용을 고려해야하며 섞어서 쓰는 것도 방법이다.

 

차이점은 아래에 소개할테니 원하는 대로 골라쓰면 되겠다.

//PROMISE
function makeToast(bread){
    return new Promise((resolve, reject) => {
        if (bread) {
          resolve(bread);
        }
        let err = "빵이 없음";
        reject(err);
      });
}
makeToast().catch((err) => {
    console.error(err)
});

//ASYNC,AWAIT
function checkBread(bread) {
  if (bread) {
    return bread;
  } else {
    let err = "빵이 없음";
    throw err
  }
}
async function makeToast(bread) {
  try {
    await checkBread(bread);
  } catch (e) {
    console.error(e);
  }
}
makeToast();

 

 

첫째는 예외처리이다.

 

Promise의 경우 자체적으로 있는 resolve, reject를 사용해서

조건에 부합하면 resolve, 그렇지 않을 경우 reject를 해서 그 reject안에 들어올경우 에러라고 간주한다.

에러라고 간주하기에 catch에서 바로 감지할 수가 있다.

 

async/await의 경우에는 try catch문을 사용해야하며

try catch문을 사용하려면 반드시 await 함수를 사용해서 동기식으로 바꿔야한다.

그리고 reject가 없어서 에러를 발생하기 위한 코드가 들어가야한다.

커스텀 에러인 경우 위와같이 throw를 날릴 수 있겠다.

 

둘째는 코드 스타일이다.

 

두번째는 async/await의 경우 return new Promise((resolve, reject) => 가 없는걸 볼 수 있는데 이는 

promise를 리턴하지만 async/await 가 더 간략하고 가독성 좋은 코드를 짜기위해

생략했다고 볼 수 있다.

사실상 promise이기에 당연히 then과 catch를 사용해서 promise와 같이 사용할 수 있다는 것이다.

 

그럼 async/await도 then을 연속으로하는 chaining이 가능하다는건데 큰 차이가 없지 않나? 문제를 보완하기 위해 만들어졌다는데 새로운 방법은 없나? 싶을 수 있다.

 

그럴줄 알고 준비했다. async/await만의 방법은 아래와 같다.

  async function 빵사기() { 
      console.log('빵을 샀다');      
  } 
  
  async function 빵굽기(userData) { 
      console.log('빵을 구웠다');       
  } 
  
  async function 잼바르기(userData) { 
      console.log('잼을 발랐다');      
  } 
  async function 한번에 실행() { 
    const 사온빵 = await 빵사기(); 
    const 구운빵 = await 빵굽기(사온빵); 
    await 잼바르기(구운빵); 
      
    console.log('모든 작업 완료'); 
  } 
  
  한번에 실행(); //

 

then을 이어붙이는 방법과의 차이를 느끼겠다면 성공이다.

몰라도 괜찮다. 바로 알려주자면 바로 코드 확장성이다.

 

지금 당장은 코드가 짧고 간단하니

promise의 문법과 같이 then으로 chaining을 해도 코드를 짜기 쉽고 가독성도 좋을 것이다.

 

하지만 미래에 비즈니스의 요구사항이 바뀌거나 더 많은 chaining을 해야할때가 온다면??

then을 이어붙이는 것은 가독성이 떨어질 수 있다.

 

이럴 때 추가해야하는 async 함수를 선언해주고 그 함수를

await로 순서를 보장해주기만 하면 되니까 얼마든지 수정이나 추가를 할 수 있으며

가독성 또한 좋을 수 밖에 없다.   

 

그래서 나의 의견은 기존 레거시 코드가 callback으로 되어있다면

최대한 promise 나 async/await로 바꾸는 것을 추천하고

 

promise로 되어있다면 기회비용을 고려해서 섞어서 쓰거나 

async/await로 완전히 갈아탈만하다고 생각한다.

728x90
반응형
Comments