본문 바로가기
Javascript

Javascript의 callback과 promise

by jewook3617 2021. 5. 19.

javascript는 single thread에서 동작하는 언어입니다. single thread에서 동작한다는 것은 한 번에 한 가지 일만 처리할 수 있다는 것을 의미합니다. 하지만 javascript가 처리하는 브라우저의 이벤트들은 한 번에 한 가지씩만 발생하지 않기 때문에 single thread로 비동기적으로 발생하는 이벤트를 처리하기 위한 메커니즘이 필요했습니다. 그래서 등장한 것이 callback 함수입니다.

Callback

javascript에서는 비동기 함수를 호출할 때 callback 함수를 같이 넘겨줍니다. javascript는 비동기 함수가 종료되면 같이 넘어온 callback 함수를 실행합니다.

setTimeout(() => console.log('Hello, my name is jony.'), 3000);

// 3초 후
// Hello, my name is jony.

위 코드는 비동기 함수인 setTimeout 함수가 종료되면 같이 넘어간 () => console.log('Hello, my name is jony.') 함수를 실행하는 코드입니다. setTimeout 함수가 3초간 대기한 후에 종료되면 callback 함수가 실행되면서 Hello, my name is jony. 가 출력됩니다.

하지만 callback 방식은 순차적으로 진행해야 하는 비동기 함수를 다루기 어렵다는 단점이 있습니다. 예를 들어, 3초간 대기한 후 Hello를 출력하고 다시 2초간 대기한 후 my name is를 출력하고 다시 5초간 대기한 후 jony를 출력해야 한다고 가정해보겠습니다.

setTimeout(() => console.log("Hello"), 3000);
setTimeout(() => console.log("my name is"), 2000);
setTimeout(() => console.log("jony"), 5000);

// 2초 후
// my name is

// 다시 1초 후
// Hello

// 다시 2초 후
// jony

얼핏 봤을 때 위 코드를 실행시키면 기대한대로 동작할 것처럼 보입니다. 하지만 javascript는 비동기 함수가 종료될 때까지 기다려주지 않습니다. 위 코드가 실행되면 거의 동시에 타이머 카운트가 시작됩니다. 그리고 타이머가 종료되면 callback 함수가 실행되기 때문에 지연시간이 짧은 순서대로 출력이 됩니다. javascript의 동작에 대해서는 이 글에 정리해놨습니다.

그럼 비동기 함수를 순차적으로 호출하려면 어떻게 해야할까요?

setTimeout(() => {
  console.log("Hello");
  setTimeout(() => {
    console.log("my name is");
    setTimeout(() => {
      console.log("jony");
    }, 5000);
  }, 2000);
}, 3000);

// 3초 후에
// Hello

// 다시 2초 후에
// my name is

// 다시 5초 후에
// jony

비동기 함수를 순차적으로 호출하기 위해서는 위 코드처럼 callback 함수 안에서 비동기 함수를 호출해야 합니다. 그래야 첫 번째 setTimeout() 함수가 종료되었을 때 두 번째 setTimeout() 함수가 호출되고 두 번째 setTimeout() 함수가 종료되었을 때 세 번째 setTimeout() 함수가 호출됩니다. 이런 식으로 여러 개의 callback이 중첩되면 코드를 읽기 힘들어지며 관리가 어렵게 됩니다. 그래서 이런 코드를 콜백 지옥(callback hell)이라고 부릅니다.

이런 callback의 문제점을 해결하기 위해 등장한 것이 promise입니다.

Promise

promise를 간단히 정의하면 비동기 함수의 실행 종료를 알려주는 객체입니다.

비동기 함수를 실행할 때 callback 함수를 넘겨주는 것이 javascript에게 "비동기 함수가 종료되면 내가 넘긴 callback 함수를 실행해줘."라고 부탁하는 것이라면 promise 객체를 사용하는 것은 "비동기 함수가 종료되면 나에게 알려주기만 해. 그다음은 내가 알아서 할게."라고 하는 것과 같습니다.

promise 객체 역시 callback 함수를 넘겨줍니다. javascript에서는 함수의 파라미터로 넘겨주는 함수를 callback 함수라고 부릅니다. callback 함수가 비동기 처리를 위한 특별한 함수인 것이 아니고 비동기 처리를 위해 callback 함수를 이용하는 것입니다. 대신 promise 객체에 callback 함수를 넘겨주면 그 callback 함수의 실행이 종료되었음을 우리에게 알려줍니다.

먼저, promise의 사용 예시를 보겠습니다.

new Promise((resolve, reject) => {
    console.log('Hello');
    setTimeout(() => resolve(), 3000);
}).then(() => console.log('my name is jony.'));

// Hello

// 3초후에 
// my name is jony.

위 코드를 보면 new Promise() 문을 통해 promise 객체를 생성하고 

(resolve, reject) => {
    console.log('Hello');
    setTimeout(() => resolve(), 3000);
}

이 callback함수를 같이 넘겨줍니다.

promise 객체의 callback 함수는 resolve() 함수와 reject() 함수를 파라미터로 받아서 사용할 수 있습니다. resolve() 함수와 reject() 함수에 대해 이해하려면 promise의 상태에 대해 알아야 합니다.

promise 객체는 총 세 가지 상태를 가집니다. 

  • Pending : callback 함수가 종료되기를 기다리고 있는 상태
  • Fullfilled : callback 함수가 성공적으로 종료된 상태
  • Rejected : callback 함수 실행 도중 문제가 발생해 비정상적으로 종료된 상태

promise 객체의 callback 함수에서 resolve() 함수를 호출하면 promise 객체가 Fullfilled 상태로 바뀝니다. promise 객체의 callback 함수에서 reject() 함수를 호출하거나 에러가 발생하면 promise 객체가 Rejected 상태로 바뀝니다.

Fullfilled와 Rejected 상태를 합쳐 Settled 상태라고 부릅니다. Settled 상태는 promise 객체의 상태가 결정되었음을 의미합니다. 한 번 Settled 상태가 된 promise 객체는 상태가 변경되지 않으며 다시 Pendging 상태로 돌아갈 수도 없습니다.

여기서 주의해야 할 점은 resolve() 함수나 reject() 함수를 사용하거나 callback 함수 실행 도중 에러가 발생해야만 promise 객체의 상태가 Pending 상태에서 Settled 상태로 바뀐다는 점입니다. 함수가 종료되어도 resolve() 함수나 reject() 함수가 호출되지 않고 에러도 발생하지 않았다면 promise는 계속 Pending 상태로 남아있게 됩니다.

new Promise((resolve, reject) => {
  console.log("Hello");
  setTimeout(() => console.log("timeout finished"), 3000);
}).then(() => console.log("my name is jony."));

// Hello

// 3초 후
// timeout finished

위 코드에서는 promise 객체의 callback 함수 안에서 resolve() 함수나 reject() 함수가 호출되지 않았고 에러도 발생하지 않았습니다. 그래서 promise 객체의 callback 함수는 종료되었지만 promise 객체는 계속 Pending 상태로 남아있게 되고 then() 함수가 실행이 되지 않는 것입니다.

마찬가지로 resolve() 함수나 reject() 함수가 호출되더라도 callback 함수의 실행이 끝나지는 않습니다. resolve() 함수나 reject() 함수는 promise 객체의 상태를 결정짓는 함수이지 callback 함수의 실행을 제어하는 함수가 아닙니다.

new Promise((resolve, reject) => {
  resolve();
  console.log("Hello");
}).then(() => console.log(`my name is jony.`));

// Hello
// my name is jony.

resolve() 함수가 호출되었지만 callback 함수를 종료시키지 않기 때문에 Hello가 출력됩니다.

promise 객체의 callback 함수가 Settled 상태가 되면 then() 함수가 실행됩니다. 다시 제일 처음 코드를 보겠습니다.

new Promise((resolve, reject) => {
    console.log('Hello');
    setTimeout(() => resolve(), 3000);
}).then(() => console.log('my name is jony.'));

// Hello

// 3초후에 
// my name is jony.

위 코드에서는 setTimeout() 함수 안에서 resolve() 함수를 호출했기 때문에 Hello 가 출력되고 3초가 지난 후에 promise 객체가 Fullfilled 상태로 바뀝니다. promise 객체가 Fullfilled 상태로 바뀌면 then() 함수가 호출되고 my name is jony. 가 출력됩니다.

then 함수는 promise 객체가 Settled 상태가 되었을 때 그 이후의 처리를 하기 위해 callback 함수를 받습니다. then() 함수는 두 개의 callback 함수를 받을 수 있는데 첫 번째 callback 함수는 Fullfilled 상태가 되었을 때 호출되고 두 번째 callback 은 Rejected 상태가 되었을 때 호출됩니다. 그리고 resolve() 함수나 reject() 함수를 호출할 때 값을 넘겨주면 then() 함수의 callback에서 파라미터로 받아 사용할 수 있습니다.

new Promise((resolve, reject) => {
  console.log("timeout start");
  setTimeout(() => {
    const data = "jony";
    resolve(data);
    console.log("timeout finish");
  }, 3000);
}).then((resolvedData) => console.log(`resolved data: ${resolvedData}`));

// timeout start

// 3초 후
// timeout finish
// resolved data: jony

위 코드에서는 resolve() 함수에 data를 넘기고 then() 함수의 첫 번째 callback 함수에서 파라미터로 받아와 사용하고 있습니다.

new Promise((resolve, reject) => {
  console.log("timeout start");
  setTimeout(() => {
    reject(new Error("error occured"));  // 또는 throw new Error("error occured")
    console.log("timeout finish");
  }, 3000);
}).then(
  (resolvedData) => console.log(`resolved data: ${resolvedData}`),
  (err) => console.log(`rejected error: ${err}`)
);

// timeout start

// 3초 후
// timeout finish
// rejected error: Error: error occured

위 코드에서는 reject() 함수에 Error 객체를 넘기고 then() 함수의 두 번째 callback 함수에서 파라미터로 받아와 사용하고 있습니다. reject() 함수가 호출되지 않고 에러가 발생한 경우에는 에러 객체가 두 번째 callback 함수의 파라미터로 넘어갑니다. resolve() 함수는 호출되지 않으므로 then() 함수의 첫 번째 callback 함수는 실행되지 않습니다.

then() 함수 대신 catch() 함수를 이용해서 Rejected 상태가 된 promise를 핸들링할 수도 있습니다. 위 코드와 아래 코드는 동일한 동작을 하는 코드입니다. catch() 함수를 사용해 Rejected 핸들링을 하는 것이 일반적입니다.

new Promise((resolve, reject) => {
  console.log("timeout start");
  setTimeout(() => {
    reject(new Error("error occured"));  // 또는 throw new Error("error occured")
    console.log("timeout finish");
  }, 3000);
})
  .then((resolvedData) => console.log(`resolved data: ${resolvedData}`))
  .catch((err) => console.log(`rejected error: ${err}`));

promise 객체가 Rejected 상태가 되면 가장 가까운 Rejected 핸들러 함수가 호출됩니다. Rejected 핸들러란 then() 함수의 두 번째 callback 함수나 catch() 함수를 의미합니다.

new Promise(doSomething1)
  .then(doSomething2)
  .then(doSomething3)
  .catch((err) => console.log(err));

위 코드에서 doSomething1() 함수, doSomething2() 함수, doSomething3() 함수 중 어느 곳에서 reject() 함수가 호출되거나 에러가 발생하더라도 제일 마지막에 있는 catch() 함수가 호출됩니다. 그렇기 때문에 여러 개의 promise 객체를 then() 함수로 연결해도 마지막에 catch() 함수만 있으면 모든 promise 객체의 Rejected 핸들링을 할 수 있습니다.

Promise 체이닝

promise 객체는 체인으로 연결할 수 있다는 장점이 있습니다. 

promise 객체의 then() 함수 역시 promise 객체를 반환합니다. 그렇기 때문에 then() 함수 뒤에 계속해서 then() 함수를 이어서 사용할  수 있습니다.

new Promise((resolve, reject) => {
  console.log("timeout start");
  setTimeout(() => {
    console.log("timeout finish");
    const name = "jony";
    resolve(name);
  }, 3000);
})
  .then((name) => `my name is ${name}`)
  .then((str) => `Hello, ${str}`)
  .then((str) => console.log(str));
  
// timeout start
  
// 3초 후
// timeout finish
// Hello, my name is jony

위 코드에서 then() 함수의 callback 함수가 promise가 아닌 string을 리턴 하지만 then() 함수를 계속 연결할 수 있습니다. 그 이유는 then() 함수가 내부적으로 리턴된 값을 promise로 변환해주기 때문입니다. 위 코드와 아래 코드는 동일한 코드입니다. 

new Promise((resolve, reject) => {
  console.log("timeout start");
  setTimeout(() => {
    console.log("timeout finish");
    const name = "jony";
    resolve(name);
  }, 3000);
})
  .then((name) => new Promise((resolve) => resolve(`my name is ${name}`)))
  .then((str) => new Promise((resolve) => resolve(`Hello, ${str}`)))
  .then((str) => new Promise((resolve) => resolve(console.log(str))));

 

그럼 3초간 대기한 후 Hello를 출력하고 다시 2초간 대기한 후 my name is를 출력하고 다시 5초간 대기한 후 jony를 출력하는 코드를 promise 객체를 사용해 바꿔보겠습니다.

setTimeout(() => {
  console.log("Hello");
  setTimeout(() => {
    console.log("my name is");
    setTimeout(() => {
      console.log("jony");
    }, 5000);
  }, 2000);
}, 3000);

callback 함수를 사용하면 이런 식으로 콜백 지옥이 만들어집니다.

new Promise((resolve) => {
  setTimeout(() => {
    console.log("Hello");
    resolve();
  }, 3000);
})
  .then(
    () => new Promise((resolve) => {
        setTimeout(() => {
          console.log("my name is");
          resolve();
        }, 2000);
      })
  )
  .then(
    () => new Promise((resolve) => {
        setTimeout(() => {
          console.log("jony");
          resolve();
        }, 5000);
      })
  );

promise 객체를 사용하면 같은 동작을 하는 코드를 이렇게 바꿀 수 있습니다. promise 객체 사용으로 코드가 더 길어지기는 했지만 실행 흐름을 파악하기 더 편해졌습니다.

const hello = () =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log("Hello");
      resolve();
    }, 3000);
  });

const myNameIs = () =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log("my name is");
      resolve();
    }, 2000);
  });

const jony = () =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log("jony");
      resolve();
    }, 5000);
  });


hello().then(myNameIs).then(jony);

각각의 단계를 함수로 분리하면 관리하기가 더욱 용이해집니다.

callback 함수를 이용한 비동기 처리의 단점을 극복하기 위해 등장한 promise 객체에 대해 정리해봤습니다. 하지만 promise 객체를 이용한 비동기 처리 역시 then() 함수를 사용해야 한다는 단점이 있습니다. 이런 불편함을 해결하기 위해 등장한 것이 async / await 입니다. 

async / await 의 등장으로 직관적으로 비동기 처리를 할 수 있게 되었지만 이 역시 내부적으로 promise 객체를 사용하고 있기 때문에 promise 객체에 대해 잘 알고 넘어가는 것이 중요하다고 생각합니다. async / await에 대해서는 추후에 정리해보겠습니다.