[JS] 비동기, promise, async/await 정리

2021. 7. 7. 08:29개발공부/자바스크립트

동기 vs 비동기

✨ 동기: Synchronous
코드가 작성된 순서대로 실행된다.
✨ 비동기: Asynchronous
먼저 실행 된 코드의 작업이 끝나기 전 다음 코드의 실행이 가능하다.

 

일상생활에 적용한 동기, 비동기 예시를 보면,

🍒 오늘할일을 동기적으로 처리하면 계획한 시간안에 못끝낼 확률이 커보인다.

왜냐하면, 빨래가 다 될때까지 기다렸다가 휴대폰 수리도 다 될때까지 기다렸다가 자바스크립트 공부를 시작해야하기때문이다.

반면, 비동기적으로 처리한다면 빨래 돌아가는 동안 공부를 마친 후 휴대폰 수리를 맡겨두고 수리가 되는 동안 밍밍이 산책을 다녀올 수 있다.

 

아래는 비동기작업의 대표적인 예시 setTimeout()을 사용한 코드이다.

setTimeout은 브라우저에서 제공하는 웹API로 지정한 시간이 지나면 콜백함수를 전달하는 것.

function first() {
	setTimeout(() => {
  	console.log("The First function has been called.")
  }, 1000)
}

function second() {
	setTimeout(() => {
  	console.log("The Second function has been called.")
  }, 500)
}

first()
second()

first함수가 호출되면 1초가 지난 후 실행 완료되고, second함수는 first함수보다 뒤에 호출되었지만 0.5초 후에 실행 완료되면서 first함수보다 먼저 결과가 출력된다.

The Second function has been called.
The First function has been called.

 

그런데 여기서 질문은,

자바스크립트는 싱글스레드인데, 어떻게 쓰레드나 프로세스가 여럿이 돌고있는 비동기 작업이 가능하단거지?

자바스크립트 엔진은 단일 호출스텍을 가지고있으며, 다른 함수가 종료되기 전까지 다른 작업 실행이 불가능하다. 하지만, 자바스크립트는 자바스크립트 엔진으로만 돌아가는것이 아니다.

 

자바스크립트 엔진 밖에서는 Web API, Task Queue(=Callback Queue), Event Loop과 같은 자바스크립트 실행에 관여하는 요소들이 존재한다. 비동기 작업시 각 요소들의 역할은 이 블로그에서 아주 잘 설명해주고있다.

 

콜백 함수

비동기적 프로그래밍이 뭔지 이제 이해했으니, 자바스크립트의 비동기성을 표현하는 가장 일반적인 기법 callback 함수에대해 알아보자.
콜백함수는 말그대로 전달한 함수를 나중에 전달해주는 개념이다.

 

그러면 콜백은 무조건 비동기적으로 작동할까? 그렇지는않다!

 

우선 동기적으로 실행 된 콜백함수를 살펴보면,

➡️ printRightnow함수는 호이스팅되면서 위로 올라가고 1 출력 → setTimeout 브라우저 요청 보냄 →3 출력 → printRightnow 함수 호출 → 호이스팅 된 함수 프린트 → 그동안 1초가 지났더니 setTimeout api는 이제야 실행

 

반면 비동기 콜백

➡️ 동기 콜백과 마찬가지로 함수의 선언은 호이스팅되서 위로 올라가고, 1 출력 → setTimeout 브라우저 요청 보냄 →3 출력 → printLater 함수 브라우저 요청 보냄 → 그동안 1초가 지났더니 setTimeout api 실행 2 출력 -> 그동안 2초 지난 후 콜백함수 printLater 실행

 

이런 콜백함수를 nesting하여 반복해서 불렀을 경우를 콜백지옥이라고하는데, 예시는 아래와 같다.

예시 코드 출처: 드림코딩 by엘리

class UserStorage {
    loginUser(id, password, onSuccess, onError) {
        setTimeout(() => {
        // 만약 아이디와 패스워드가 아래와 일치한다면
            if (
                (id === 'hello' && password === 'jenna') ||
                (id === 'bye' && password === 'jamie')
    ) {
    // onSuccess라는 콜백을 불러 id를 전달해준다.
        onSuccess(id);
            } else {
            // 그렇지않다면 onError콜백을 불러준다.
         onError(new Error('user not found'));
            }
        }, 2000);
    }

    getRoles(user, onSuccess, onError) {
        getTimeout(() => {
            if (user === 'jenna') {
                onSuccess({ name: 'jenna', state: 'vip' });
        } 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,
    // 정상 유저라면, 유저스토리지에서 getRoles를 받고 다시 userWithRole과 error일때의 콜백함수들을 또 받는다.
    user => {
        userStorage.getRoles(
            user,
            userWithRole => {
                alert(
                    `Hello ${userWithRole.name}, you are our ${userWithRole.state} guest`)
        }, error => {
            console.log(error);
        }
    );
    },
    error => {
        console.log(error);
    }
);

이 코드를 보면 loginUser 콜백함수안에 user 안에 userWithRole안에 error까지 이것이 콜백지옥이다.

이처럼 콜백지옥의 문제점은

  1. 가독성이 떨어진다. 어디서 어떤식으로 연결되어있는지 한눈에 보기 힘들다.
  2. 에러가 발생하거나, 디버깅이 필요한 경우에도 어려워진다.
  3. 유지보수가 힘들다.

이런 콜백지옥에 빠지는 것을 피하기위해 promise 그리고 ES7에서 도입된 ansyc, await을 공부해보아야한다.

Promise

Promise는 자바스크립트에서 제공하는 간편한 비동기 처리를 위한 object이다.

정해진 장시간의 기능을 수행 후 정상적으로 수행되었다면 성공의 메세지와 함께 처리 된 결과값을 전달해주고, 그렇지않다면 에러를 전달해준다.

// Producer
// 2초 정도 다른 작업을 수행하다가 resolve라는 콜백함수 호출하여 jenna라는 값을 전달하는 promise
const promise = new Promise((resolve, reject) => {
    // resolve: 기능이 정상 수행되었을때
    // reject: 기능이 정상 수행 되지않았을때
    setTimeout(() => {
        resolve('jenna');
        // 에러가 발생했다면
        // reject(new Error('no network'));
    }, 2000);
})

큰 파일을 읽어올때와 같이 더 많은 시간이 걸리는 경우 동기적 수행은 도움이 되지않기때문에, 보통 Promise안에는 heavy한 작업들을 넣어준다. ex) 네트워크 통신, 파일 읽기 등

*promise가 생성되는 순간 안의 executor콜백함수가 자동적으로 실행되는 점 유의하자

이제 이 producer를 이용하는 consumer을 만들어보자.

consumer은 then, catch, finally를 사용하여 값을 받아올 수 있다.

// consumer
// promise가 잘 수행되면 value(jenna)값을 받아와서 원하는 기능 수행
promise.then((value) => {
    console.log(value);
})
// 실패했다면 .catch
.catch(error => {
    console.log(error);
})
// finally는 성공하든 실패하든 항상 실행
.finally(() => {
    console.log('whenever');
})

그럼 이제 콜백 지옥의 코드를 promise를 사용해 바꿔보자.

class UserStorage {
    loginUser(id, password) {
        return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (
                (id === 'hello' && password === 'jenna') ||
                (id === 'bye' && password === 'jamie')
    ) {
        resolve(id);
            } else {
         reject(new Error('user not found'));
            }
        }, 2000);
    }}

    getRoles(user) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                if (user === 'jenna') {
                    resolve({ name: 'jenna', state: 'vip' });
                } 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(`Hello ${user.name}, you are our ${user.state} guest`));
    .catch(console.log);

이렇게 promise를 사용해 훨씬 깔끔한 코드가 가능해졌다.

체이닝이 계속되는 promise를 깔끔하게 구현할 수 있는 async, await는 어떤 것일까?

 

ansyc, await

function fetchUser() {
    // network request
    return 'jenna';
}

const user = fetchUser();
console.log(user);

위와 같은 코드에서 만약 (사용자의 데이터를 받아오는)네트워크 요청이 10초가 걸린다고 가정했을때, 비동기적 처리를 우리가 해주지않는다면 자바스크립트 엔진은 자동적으로 동기적으로 처리를 할것이다. 그렇다면 네트워크 요청이 완료되는 10초간 유저의 화면에선 아무 반응없이 빈 화면으로 마냥 기다려야한다.

이를 promise를 사용해 비동기적 처리를 해준다면,

function fetchUser() {
    // network request
    return new Promise((resolve, reject) => {
        rersolve('jenna');
    })
}

const user = fetchUser();
user.then(console.log);
console.log(user);

그리고 더 간단하게 async를 사용한다면,

async function fetchUser() {
    // network request
        return 'jenna';
}

const user = fetchUser();
user.then(console.log);
console.log(user);

async를 함수 앞에 써주면 안의 코드가 자동으로 promise로 바뀌는것이다.

await은 async가 붙은 함수안에서만 사용가능하다.

// delay라는 함수는 정해진 ms가 지나면 resolve를 호출하는 promise를 리턴한다. 
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 위의 delay함수와 async, await을 사용해 3초가 지난 후 Hi를 반환하는 간단한 함수를 만들 수 있다. 
async function sayHi() {
    await delay(3000)
    return "Hi";
}

// 만약 async, await이 아닌 promise를 사용한다면 아래처럼 체이닝을 해줘야한다.
function sayHi() {
	return delay(3000)
    .then(() => "Hi");

 

내일 자스 스터디에서 내가 했던 프로젝트에선 비동기처리를 어떻게 했었는지 예제를 가져오기로했다.

이참에 프로젝트 전체 코드도 정리해보며 비동기 예시를 가져와보았다✨

 

1️⃣ 아래는 '게시글 삭제하기' 구현에서 서버와의 통신을위한 코드이다.

// 게시글 삭제하기
const deletePostDB = (id) => {
    return function(dispatch, getState, {history}){
        const token = localStorage.getItem("token")
        axios
          .delete(`http://3.36.67.251:8080/board/mating/` + `${id}`, {
            headers: {
              authorization: `Bearer ${token}`,
            },
          })
          .then(() => {
            history.push("/");
          })
          .catch((err) => {
            if (err.response.status === 403) {
              swal(
                "로그인 시간이 만료되었습니다. 다시 로그인해주세요🙏"
              );
              history.replace("/");
            }
          });
    }
}

➡️ url에 delete요청을 보내 axios 함수를 통해 promise객체를 만들어주었다. url 요청이 성공적으로 수행되었다면 then()내부의 콜백을 실행한다. 

 

2️⃣ 검색 기능 구현에서 사용한 async, await 예시이다.

useEffect(() => {
    // async와 await를 이용하여 검색을 할 때 동기적일 수 있도록 함.
    const search = async (param) => {
      try {
        setLoading(true);
        const response = await axios({
          method: "get",
          url: `http://3.36.67.251:8080/board/search?Keyword=` + `${id}`,
        });
        setApi(response.data.board);
      } catch (e) {
        setError(e);
        swal(error);
      }
      setLoading(false);
    };
    search();
  }, [id]);

 

비동기 관련 면접 질문

  • Promise vs Callback vs async/await
    자바스크립트에서 비동기처리를 위해 사용되는 패턴인데 콜백은 중첩함수를 사용해 콜백지옥이 발생할 수 있다. 콜백지옥이 발생하면 에러가 발생하거나 디버깅이 필요할때 힘들어지고 유지보수가 어려워진다. 이를 보완하기위해 Promise가 사용된다. Promise 생성자 함수를 통해 인스턴스화하며, 비동기 처리 성공 시 resolve메소드를 호출 해 결과를 전달하고 실패 시 reject메소드를 호출 해 에러메세지를 전달한다. 후속 처리 메소드에는 then, catch, finally가 있는데 메소드 체이닝을 통해 콜백 지옥 문제를 해결할 수 있다.
    하지만 Promise도 마찬가지로 체이닝이 계속되면서 then()지옥이 생길수있는 단점이있다. 이를 방지하기위해 async / await을 사용한다. Async는 동기식 코드를 짜듯이 비동기식 코드를 짤 수 있다는 장점이 있다. await는 async에서 사용하는 키워드로 await 뒤에 오는 Promise가 결과값을 가질 때까지 함수를 잠시 멈추고, Promise의 결과값이 반환되면 연산을 다시 진행한다. 비동기식으로 작동하기 때문에 해당 함수가 잠시 멈췄을 때 다른 작업을 처리할 수 있다.