CALLBACK / PROMISE / ASYNC

기록일기📫·2021년 2월 4일
8

Javascript 개념정리

목록 보기
13/15
post-thumbnail

이번 포스팅에서는 callback, promise, async&await의 특징과 차이점에 대해 알아보자.

지금까지 큰 생각 없이 그렇구나! 하고 사용하고 있었는데, 이번 포스팅을 통해 각각의 특징에 대해 짚어보고 이해하는 시간을 가져보고자 한다.


비동기 처리

자바스크립트에서 비동기 처리란, 특정 코드의 연산이 끝날 때까지 코드의 실행을 멈추지 않고 순차적으로 다음 코드를 먼저 실행하는 자바스크립트의 특성을 의미한다.

비동기 처리의 가장 흔한 예시로는 서버와의 통신을 꼽을 수 있다. 자바스크립트는 서버와 통신을 할 때 서버에 요청을 한 후 응답이 올 때까지 다른 일을 우선적으로 처리한다.

서버와의 통신을 비동기로 처리하는 이유는 싱글 스레드 언어인 자바스크립트의 특성상 한번에 한가지 일 밖에 처리하지 못하기 때문이다. 자바스크립트는 효율적인 실행을 위해 데이터가 준비되는 동안 다른 일을 우선적으로 처리하며 서버와의 통신을 비동기적으로 진행하게 된다.


비동기 처리 방식

그러면 앞서 비동기 통신이 무엇인지 알아 보았으니, 지금부터 비동기 처리를 하기 위한 방식을 하나씩 살펴보도록 하자.

자바스크립트에서 비동기 처리를 진행하는 방식은 CALLBACK, PROMISE, ASYNC 총 세가지가 있다.


CALLBACK

콜백 방식은 PROMISE와 ASYNC가 나오기 전에 비동기 함수에서 실행 순서가 보장되어야 하는 일들을 처리하기 위해 사용했던 방식으로, 함수 안에 함수를 인자로 전달하며 전에 실행한 함수의 결과를 가지고 다음 함수를 처리하는 형식이다.

콜백 패턴을 이용하면 함수 안에 계속 함수를 전달하게 되므로 자연스럽게 들여쓰기 라인이 계속 형성된다.

이렇게 콜백 함수에 인자로 콜백 함수를 전달하는 과정이 반복되다 보면 코드의 들여쓰기 수준이 감당하기 힘들정도로 깊어지게 된다. 이런 형식의 코드를 콜백 지옥이라고 부르기도 한다.

콜백 지옥은 Promise와 async 등장 전에 주로 이벤트 처리나 서버 통신과 같은 작업을 수행하는 과정에서 등장하곤 했는데, 굉장히 가독성이 떨어지고 수정이 어렵다.


PROMISE

이러한 콜백지옥 문제를 해결하기 위한 방법으로 ES6에서 PROMISE가 등장하게 되었다!

Promise란?

Promise는 ES6에서 도입된 ECMAScript 사양에 정의되어있는 표준 빌트인 객체이다.

Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인자로 전달받는데, 이 콜백 함수는 resolve와 reject 함수를 인수로 전달받는다.

const promise = new Promise((resolve,reject) => {
  if(/*비동기 처리 성공 */) resolve('비동기 처리 실행');
  else reject('failure reason');
});

위 코드에서 확인할 수 있듯, promise 생성자 함수는 인수로 전달받은 콜백 함수 내부에서 비동기 처리를 수행한다. 이때 비동기 처리가 성공하면 콜백 함수의 인자로 전달받은 resolve 함수를 호출하고, 비동기 처리가 실패하면 reject 함수를 호출한다.

Promise state

MDN에 따르면, 프로미스는 아래와 같이 현재 비동기 처리가 어떻게 진행되고 있는지를 나타내는 상태 정보를 갖는다.

각각의 상태에 대해 살펴보면 다음과 같다.

pending : 비동기 처리가 아직 수행되지 않은상태
fullfill : 비동기 처리가 수행된 상태(성공)
reject : 비동기 처리가 수행된 상태(실패)

즉, 초기 상태인 pending 상태에서 비동기 처리 수행 결과에 따라 fulfill, reject 상태로 바뀌게 된다.

프로미스는 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 선택적으로 호출된다. 이때 후속 처리 메서드의 콜백 함수에 프로미스의 처리 결과가 인수로 전달된다.

말로 설명하면 조금 어렵다. 코드를 보면서 이해해 보자!

Promise.then


new Promise((resolve,reject) => resolve('fulfilled'))
  .then(v => console.log(v), e=> console.error(e)); // fulfilled

new Promise((resolve, reject) => reject(new Error('rejected')))
  .then(v => console.log(v), e=> console.error(e)); // Error : rejected

위 코드를 보면, then에 resolve와 reject에 전달된 인자가 그대로 전달되는 것을 알 수 있다. then 메서드는 두개의 콜백 함수를 인수로 전달받는다.

첫 번째 콜백 함수는 프로미스가 fulfilled 상태가 되면 호출되고, 프로미스의 비동기 처리 결과를 인수로 전달받는다.

두 번째 콜백 함수는 프로미스가 rejected 상태가 되면 호출된다. 이때 콜백 함수는 프로미스의 에러를 인수로 전달받는다.

또한 then 메서드는 언제나 프로미스를 반환한다. 만약 then 메서드의 콜백 함수가 프로미스를 반환하면 그 프로미스를 그대로 반환하고, 콜백 함수가 프로미스가 아닌 값을 반환하면 그 값을 암묵적으로 resolve 또는 reject하여 프로미스를 생성해 반환한다.

promise.catch, finally

promise.then과 비슷하게 catch와 finally가 있다. then과 달리 catch 메서드는 프로미스가 reject 된 경우만 호출된다.

반면에 finally 메서드는 프로미스의 성공 또는 실패와 상관 없이 무조건 한 번 호출된다.
finally는 프로미스의 상태와 상관 없이 공통적으로 수행해야 할 내용이 있을때 유용하게 사용 할 수 있다.

promise chaining

앞서 언급했듯, then과 catch는 언제나 promise를 반환하기 때문에 then과 catch, finally를 적절하게 이용해서 promise chaining을 통해 콜백 지옥을 해결 할 수 있다. 이 부분은 밑에서 더 자세히 살펴보도록 하겠다.


ASYNC & AWAIT

앞서 살펴본 Promise는 체이닝을 통해 후속 처리를 하므로 콜백 패턴에서 발생하던 콜백 헬이 발생하지 않았다. 하지만 Promise도 결국 콜백 패턴을 사용하므로 콜백 함수를 사용하지 않는 것은 아니다. 콜백 패턴은 가독성이 좋지 않다.

그래서 이 문제를 해결하기 위해, ES8에서는 async/await가 도입되었다. async/await도 사실 Promise를 기반으로 동작한다.

하지만 async/await를 사용하면 Promise의 then/catch/finally를 이용해서 비동기 처리를 콜백 패턴을 이용 할 필요 없이, 마치 동기처럼 Promise를 사용할 수 있다.

async 키워드

async 함수는 async 키워드를 사용해 정의할 수 있다. 또한 async 함수는 언제나 promise를 반환한다.

async 함수를 선언하는 예제를 몇가지 살펴보자.


// async 함수 선언문
async function foo(n){ return n; }
foo(1).then(v => console.log(v)); // 1

// async 함수 표현식
const bar = async function(n) { return n;}
bar(1).then(v => console.log(v)); // 1

// async 화살표 함수

const baz = async (n) => (n);
baz(1).then(v => console.log(v)); // 1

await 키워드

await는 async를 동기처럼 이용하게 해주는 핵심이다. await 키워드는 promise가 settled 상태가 될 때까지 대기하다가, promise가 완료 되어 settled 상태가 되면 다시 재개된다.

주의해야할 점은 await 또한 promise 기반으로 동작 하므로, await는 반드시 promise 앞에서 사용해야 한다는 것이다.

다음 예제를 통해 살펴보자!


async function test(){
    await foo(1, 2000)
    await foo(2, 500)
    await foo(3, 1000)
}

const foo = (num, sec) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(num);
            resolve("async test.");
        }, sec);
    });
}

test() // 1,2,3

callback -> promise -> async/await

지금까지 callback, promise, async/await에 대해 알아보았다.

마지막으로 CALLBACK으로 작성된 코드를 PROMISE와 ASYNC/AWAIT로 직접 바꿔 보며 얼마나 간결해지는지 살펴보도록 하자!

💡 지금부터 설명하는 내용은 엘리님의 강의를 보고 정리한 내용입니다. 영상은 여기서 보실 수 있습니다.

다음 예제 코드를 보자.

callback 방식

class UserStorage {
  loginUser = (id, pw, onSuccess, onError) => {
    setTimeout(() => {
      if (id === "seeh" && password === "seeh") onSuccess(id);
      else onError(new Error("not found"));
    }, 2000);
  };

  getRoles = (id, onSuccess, onError) => {
    setTimeout(() => {
      if (id === "seeh") onSuccess({ name: id, role: "admin" });
      else onError(new Error("no access"));
    }, 1000);
  };
}

const userStorage = new UserStorage();
const id = prompt("enter your id");
const password = prompt("enter your password");

userStorage.loginUser(
  id,
  password,
  (user) => {
    userStorage.getRoles(
      user,
      (userWithRole) => {
        alert(`hi ${userWithRole.name}! You have a ${userWithRole.role} role.`);
      },
      (error) => {
        console.error(error);
      }
    );
  },
  (error) => {
    console.error(error);
  }
);

서버에서 데이터를 가져오는 상황을 시뮬레이션 한 코드이다. 실제로 서버통신을 할 수 없기 때문에 UserStorage라는 class를 통해 서버로부터 데이터를 받아오는 것처럼 꾸몄다.

간단하게 설명하면 class 내에 loginUser 함수는 유저 정보를 담아 서버로 로그인 요청을 보내는 함수이고, getRoles는 로그인 후 서버에 해당 id의 role을 포함한 계정 정보 요청을 보내는 함수이다.

코드를 보면 userStorage.loginUser 함수를 호출하고 반환 받은 결과를 매개변수로 해서 userStorage.getRoles 함수를 호출하고, 이름과 role을 반환하게끔 한다.

잘 작동 하지만 콜백 함수가 많아 전반적으로 코드가 굉장히 길고 가독성이 좋지 않다. (읽기가 싫다)

promise

이제 위 코드를 promise를 도입하여 수정해보자!

class UserStorage {
  loginUser = (id, pw) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id === "seeh" && password === "seeh") resolve(id);
        else reject(new Error("not found"));
      }, 2000);
    });
  };

  getRoles = (id) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id === "seeh") resolve({ name: id, role: "admin" });
        else reject(new Error("no access"));
      }, 1000);
    });
  };
}

const userStorage = new UserStorage();
const id = prompt("enter your id");
const password = prompt("enter your password");

userStorage //
  .loginUser(id, password)
  .then(userStorage.getRoles)
  .then((user) => alert(`hi ${user.name}! You have a ${user.role} role.`))
  .catch(console.log);

콜백 형식의 코드를 promise를 사용하여 바꿔보았다. 인자를 전달하는 부분도 깔끔해졌고, 무엇보다도 then과 catch를 이용하여 가독성이 훨씬 좋은 코드로 변경할 수 있다.

async/await

마지막으로, 위 코드에 async/await을 적용해보자!

class UserStorage {
  loginUser = (id, pw) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id === "seeh" && password === "seeh") resolve(id);
        else reject(new Error("not found"));
      }, 2000);
    });
  };

  getRoles = (id) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id === "seeh") resolve({ name: id, role: "admin" });
        else reject(new Error("no access"));
      }, 1000);
    });
  };

  getUserWithRole = async (id, pw) => {
    const login = await this.loginUser(id, pw);
    const user = await this.getRoles(id);
    return user;
  };
}

const userStorage = new UserStorage();
const id = prompt("enter your id");
const password = prompt("enter your password");

userStorage //
  .getUserWithRole(id, password)
  .then((user) => alert(`hi ${user.name}! You have a ${user.role} role.`))
  .catch(console.log);

class 내부에서 await를 사용함으로써 login부터 role을 가져오는 과정까지, 조금 더 직관적으로 된 코드를 작성할 수 있었다!

처음 콜백 패턴의 코드를 다시 보면, 훨씬 읽기 좋은 코드가 된 것을 확인해 볼 수 있다! 끝!


마치며

항상 미루던 callback, promise, async/await에 대해 정리해 보았다. 재밌었따!

2개의 댓글

comment-user-thumbnail
2021년 2월 5일

👍

1개의 답글