Callback hell에 빠지지 않으려면? (Promise & async & await)

DH.J·2024년 11월 2일
1

Javascript

목록 보기
7/8

What you will learn today

  • Promise의 기본 개념
  • 비동기 처리에서 promise가 필요한 이유
  • Promise의 state 정보
  • promise의 후속 처리 메서드들
  • async & await를 사용하는 이유

✔ What is Promise?

Promise는 비동기 함수가 반환하는 객체입니다.
함수의 성공 또는 실패 상태를 알려주는 역할을 합니다.

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

Promise 객체를 생성하려면 new 연산자와 함께 빌트인 promise 객체를 생성해야 합니다.
비동기 처리가 성공하면 콜백 함수의 인수로 전달받은 resolve 함수를 호출하고, 실패하면 reject 함수를 호출합니다.

✔ 왜 Promise를 사용해야 할까?

Promise를 사용하면 비동기 처리 시점, 비동기 함수의 결과를 쉽게 확인할 수 있고 에러가 발생한 위치를 파악하기 편리합니다.

주로 Promise는 서버에서 데이터를 요청하고 받아온 데이터를 화면에 표시할 때 사용합니다.

만약 'https://jsonplaceholder.typicode.com/posts/1' 으로 요청에 대한 응답이 서버로부터 아직 오지 않은 상황에서 화면에 띄워주려고 한다면 빈 화면 혹은 에러가 발생하게 됩니다.

그래서 비동기 콜백 패턴을 사용했을 때에도, 우리는 resolvereject을 사용하여 에러 처리를 할 수 있습니다.

example code

const promiseGet = url => {
  	return new Promise((resolve, reject) => {
		const xhr = new XMLHttpRequest();
		xhr.open('GET', url);
		xhr.send();

		xhr.onlaod = () => {
			if (xhr.status === 200) {
				resolve(JSON.parse(xhr.response));
			} else {
				reject(new Error(xhr.status));
			}
		}
 	})
}

console.log(promiseGet('https://jsonplaceholder.typicode.com/posts/1'));
// pending...

비동기 함수인 promiseGet은 함수 내부에서 promise를 생성하고 반환합니다.
비동기 처리는 콜백 내부에서 실행되는데, 만약 성공하면 resolve 내에 있는 JSON.parse()를 호출하고 실패하면 rejectnew Error()가 호출됩니다.

✔ Promise의 상태 정보

프로미스는 현재 비동기 처리가 어떻게 진행되고 있는지 나타내는 state를 갖습니다.

pending: 비동기 처리가 아직 수행되지 않음 (promise생성 후 기본 상태)
fulfilled: 비동기 처리가 수행된 상태 - 성공 (resolve 함수 호출)
rejected: 비동기 처리가 수행된 상태 - 실패 (reject 함수 호출)

따라서 promise의 상태는 resolve 혹은 reject 함수를 호출하는 것으로 결정됩니다.

const fulfilled = new Promise(resolve => resolve(1));

개발자 도구에 출력해보면

Promise {<fulfilled>: 1}
 
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: 1

fulfilled된 상태를 확인해볼 수 있었다.

const rejected = new Promise((_, reject) => reject(new Error('error occurred')));
Uncaught (in promise) Error: error occurred
    at <anonymous>:1:52
    at new Promise (<anonymous>)
    at <anonymous>:1:18
[[PromiseState]]: "rejected"
[[PromiseResult]]: Error: error occurred at <anonymous>:1:52

이렇게 rejected 상태와 error메시지를 확인할 수 있었다.

✔ 후속 처리 메서드가 왜 필요할까?

만약 fulfilled 상태가 변화했음에도 이에 따른 후속처리를 하지 않는다면 어떻게 될까요?
여러가지 문제가 발생하게 됩니다.

a. 결과값 손실

function getData() {
	return Promise.resolve('중요한 데이터');
}

getData() // 아무런 값이 나오지 않는다.

b. 에러 감지 불가능

function generateError() {
	return new Promise((resolve, reject) => {
		throw new Error('에러 발생!');
	});
}

generateError() // 아무런 에러가 발생하지 않는다.

이러한 문제점들을 해결하기 위해서 후속 처리가 반드시 필요합니다.

✔ then & catch

then은 두 개의 콜백 함수를 인자로 전달받습니다.
첫 번째 콜백 함수는 fulfilled상태가 되면 호출되고, 비동기 처리 결과(res)를 인수로 전달받
습니다.
두 번째 콜백 함수는 rejected상태가 되면 호출되고, 프로미스의 에러(err)를 인수로 전달받습니다.

그러나, then 메서드의 두 번째 콜백함수는 첫 번째 콜백함수에서 발생한 에러를 캐치하지 못하고 코드가 복잡해져서 가독성이 좋지 않아 잘 사용되지 않습니다.

따라서 then 메서드를 호출한 이후에 catch 메서드를 호출하면 비동기 처리에서 발생한 에러 + then 메서드 내부에서 발생한 에러 모두 캐치할 수 있습니다.

catch는 한 개의 콜백 함수를 인자로 전달받는데, 상태가 rejected인 경우에만 콜백 함수가 호출됩니다.

이제 이전의 예제 코드의 문제를 thencatch를 이용해서 해결해 봅시다.

example a

function getData() {
	return Promise.resolve('중요한 데이터');
}

getData().then(data => console.log(data)); // "중요한 데이터"

example b

function generateError() {
	return new Promise((resolve, reject) => {
		throw new Error('에러 발생!');
	});
}

generateError()
    .then(res => console.log(res))
    .catch(err => console.error(err)); // error발생!

마지막으로 finally는 프로미스의 fulfilledrejected에 상관없이 무조건 한 번 호출됩니다.

example c

const promiseGet = url => {
  	return new Promise((resolve, reject) => {
		const xhr = new XMLHttpRequest();
		xhr.open('GET', url);
		xhr.send();

		xhr.onload = () => {
			if (xhr.status === 200) {
				resolve(JSON.parse(xhr.response));
			} else {
				reject(new Error(xhr.status));
			}
		};
		
		xhr.onerror = () => {
			reject(new Error('Network Error'));
		};
 	});
};

promiseGet('https://jsonplaceholder.typicode.com/posts/1')
	.then(res => console.log(res))
	.catch(err => console.error(err))
	.finally(() => console.log('bye!'))

이러한 결과값이 나오게 됩니다.

// In console:
{
  userId: 1,
  id: 1,
  title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
"bye!"

✔ Promise Chaining

callback hell을 해결하기 위해서 then -> then -> catch 순서로 후속 처리 메서드를 호출해봅시다.

const url = 'https://jsonplaceholder.typicode.com/posts/1'

promiseGet(`${url}/post/1`)
	.then(({ userId }) => promisGet(`${url}/users/${userId}`))
	.then(userInfo => console.log(userInfo))
	.catch(err => console.error(err))

then: promiseGet함수가 반환한 resolve한 값 (userId)
then: 첫 번째 then 메서드가 반환한 resolve한 값 (userInfo)
catch: promiseGet함수 또는 이전 then 메서드가 반환한 reject한 값(err)

이렇게 프로미스 체이닝을 통해 비동기 처리 결과를 전달받아 후속 처리를 하므로 콜백 헬이 발생하지 않습니다.

다만, 프로미스도 콜백 패턴을 사용하기 때문에 가독성이 썩 좋은 편은 아닙니다.

✔ then & catch를 이용한 후속 처리의 문제점

일단, then & catch 으로 후속 처리한 콜백 패턴 코드를 봅시다.

getData()
    .then(data => {
        return processData(data)
            .then(processedData => {
                return furtherProcess(processedData)
                    .then(result => {
                        console.log(result);
                    });
            });
    })
    .catch(error => console.error(error));

🤔어떤가요? 저는 이 코드가 가독성이 좋아 보이진 않네요

then & catch를 이용한 프로미스 체이닝이 깊어지면 발생하는 문제점들이 무엇이 있을까요?

1. callback hell과 비슷한 가독성의 문제

위 코드를 보면 callback hell처럼 들여쓰기가 깊어지면서 가독성이 현저하게 떨어지는 것을 볼 수 있었습니다.

2. 에러 처리의 범위 특정이 어려움

getUserData()
    .then(user => {
        // 🤔여기서 에러 발생 가능
        return getUserPosts(user);
    })
    .then(posts => {
        // 🤔여기서도 에러 발생 가능
        return processUserPosts(posts);
    })
    .catch(error => {
        // 🤔어디서 발생한 에러인지 구분하기 어려움
        console.error(error);
    });

3. 동기 & 비동기 코드의 순서 파악이 어려움

let result;

getData()
    .then(data => {
        result = data;  // 비동기
        processResult(); // 동기
    });

processOtherData();    // 실행 순서 예측이 어려움

4. 조건문 분기 처리의 복잡성

getData()
    .then(data => {
        if (data.needsMoreProcessing) {
            return processMore(data)
                .then(processedData => {
                    // 추가 처리
                });
        } else {
            return data;
        }
    })
    .then(result => {
        // 복잡한 분기 처리로 인해 코드 이해가 어려워짐
    });

이런, callback hell을 해결하기 위해 도입한 후속 처리 메서드가 또다른 문제를 발생시켜버렸네요..
하나의 문제를 해결하는 것은 마치 간단해 보이지만, 가끔은 예상치 못한 또다른 문제를 발생시키곤 합니다.

✔ async & await

promise chaining의 단점을 해결하기 위해, asyncawait를 이용해서 프로미스의 후속 처리 메서드 없이 마치 동기적으로 처리하는 것처럼 promise 값을 반환할 수 있습니다.

예시를 통해 확인해봅시다.

async function fetchTodo() {
	const url = 'https://jsonplaceholder.typicode.com/todos/1';
	const res = await fetch(url);
	const todo = await res.json();
	console.log(todo)
}

fetchTodo()

다음과 같은 결과를 얻을 수 있습니다.

{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }

fetchTodo함수 앞에 async를 붙이면, "이 함수는 비동기 함수이고 promise를 반환한다!" 라고 말하는 것과 같습니다.

그리고 promise를 반환하는 fetch함수 앞에 await를 붙이면, "해당 Promise의 상태 (fulfilled or rejected)가 바뀔 때까지 기다려!" 라고 말하는 것과 같습니다.

Promise의 상태가 바뀌면 그때 resolve한 처리 결과를 반환합니다.

await.then()과 같은 역할을 수행하지만, 콜백 함수를 등록할 필요가 없어 가독성이 좋다는 장점이 있습니다. 또한 반드시 async함수 내부에서 사용해야 합니다.

다음 커피를 주문하는 코드를 통해 자세히 살펴봅시다.

async function orderCoffeeAndDessert() {
    try {  
        const coffee = await orderCoffee();
        console.log("커피 주문 완료:", coffee);
        
    } catch (error) {
        console.log("주문 중 오류 발생:", error);
    }
}

// Promise를 반환하는 비동기 함수
async function orderCoffee() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("커피가 준비되었습니다!");
        }, 1500);
    });
}

orderCoffeeAndDessert();

orderCoffeeAndDessert함수와 orderCoffee함수 앞에 async를 붙여서 비동기 함수이고 promise를 반환한다는 것을 알 수 있습니다.

const coffee = await orderCoffee();

orderCoffee 함수는 프로미스 반환하는 비동기 함수입니다.
awiat를 앞에 붙이면 프로미스의 상태가 변할 때까지 기다립니다. (fulfilled or rejected)
Promise의 상태가 바뀌면 coffee변수에 프로미스 값이 할당되고, 다음 코드가 실행됩니다.

핵심은 비동기 작업을 동기적으로 바꾸는 것에 있습니다.

또다른 유저의 데이터를 get하는 예시 코드를 봅시다.

  1. Promise Chaining 방식
function getUser() {
	fetch('https://api.example.com/user')
		.then(res => res.json())
		.then(data => console.log(data))
		.catch(err => console.error(err))
}

이 코드를 async와 await를 사용해서 바꿔볼까요

  1. async & await 방식
async function getUser() {
    try {
        const response = await fetch('https://api.example.com/user');
        const userData = await response.json(); 
        console.log(userData);
    } catch (error) {
        console.log(error);
    }
}

fetch를 이용해서 HTTP요청을 보내고, Promise의 상태가 바뀔 때까지 기다립니다.
그리고 완료되면 다음 코드를 실행합니다.
이후, response를 JSON 변환이 완료될 때까지 기다립니다.
변환된 JSON데이터가 저장되고 console.log가 실행됩니다.

이때, 에러 처리는 try catch 문을 이용해 에러를 캐치하면 좋습니다.
만약 async함수 내에서 catch를 이용해 에러 처리를 하지 않으면 async함수는 발생한 에러를 reject하는 프로미스를 반환합니다.

✔ Reference

  1. https://www.tosspayments.com/blog/articles/dev-8?utm_source=velog&utm_medium=blog&utm_campaign=dev-9
  2. https://velog.io/@tosspayments/%EC%98%88%EC%A0%9C%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-awaitasync-%EB%AC%B8%EB%B2%95
  3. Modern javascript deep dive
profile
평생 질문하며 살고 싶습니다.

0개의 댓글