헷갈리는 동기, 비동기 개념을 정리하는 포스팅 (2)
첫번째 포스팅에서는, callback의 개념과 사용 방법에 대해 다뤘다.
(콜백 지옥을 통해 화살표 함수의 사용법까지 매운맛으로 이해해보았다.)
이전 포스팅🔗 : https://woojin.tistory.com/27
이번 포스팅은, 비동기 처리를 위한 객체 Promise에 대해
無의 상태로 돌아가서 공부하고 정리해보고자 한다.
첫째로는, Promise의 사용법에 대해서 자세히 다룰 것이고
둘째로는, 비동기 처리에서 Promise를 사용하는 방식, 이유에 대해 고찰해보고자 한다.
(유튜브 채널 '드림코딩'의 영상 코드를 토대로 공부했다.)
Promise란?
정의와 상태에 대해서는 다른 좋은 아티클이 많아 짚고만 넘어가자.
Promise는 비동기 처리를 위한 객체로,
resolve와 reject라는 두 개의 함수를 인자로 전달받는다.
Promise 객체가 호출되면 대기(Pending)상태,
Promise 내부 로직을 성공하면 이행(Fulfilled), 실패하면 실패(Rejected)상태가 된다.
// 1. Producer
// when new Promise is created, the executor runs automatically.
// Promise를 정의
const testPromise = new Promise((resolve, reject) => {
// doing some heavy work (network, read files)
console.log('doing something...');
setTimeout(() => {
resolve('woojin');
// reject(new Error('no network'));
}, 500);
});
// Promise를 반환
const testPromise = () => new Promise((resolve, reject) => {
// doing some heavy work (network, read files)
console.log('doing something...');
setTimeout(() => {
resolve('woojin');
// reject(new Error('no network'));
}, 500);
});
사용은 위와 같이 하는데, resolve가 성공적으로 실행되면 'woojin'을 반환한다.
실패해서 reject가 실행되면 'no network'라는 메세지를 가진 Error 객체가 반환된다.
(위의 두가지 방법이 모두 사용 가능하다.)
반환된 객체의 사용 방법은 아래와 같다.
// 2. Consumers: then, catch, finally
testPromise
.then(name => console.log(name))
.catch(error => console.log(error));
testPromise
.then(console.log)
.catch(console.log);
.then : 함수 하나를 인자로 받고, resolve 값을 가져와서 함수에 집어 넣는다.
(함수를 인자로 받는다 -> 해당 함수를 callback이라고 볼 수 있음)
.catch : 함수 하나를 인자로 받고, reject 값을 가져와서 함수에 집어 넣는다.
위 코드블록에서 위에 있는 testPromise
반대로 생각하면,
resolve가 실행되면(Promise가 성공하면) .then을 사용할 수 있고
reject가 실행되면(Promise가 실패하면) .catch를 사용할 수 있다.
여기서 유의할 점은,
then과 catch는 'woojin'이라는 문자열을 사용하지 않는다.
'woojin'이라는 문자열을 반환하는 Promise객체를 전달받아서 사용하는 것이고, 또다시 Promise객체를 반환한다.
이것은 Promise Chaining이라는 활용으로 이어진다.
아래에서 살펴보자.
(일단 여기까지는 비동기랑 Promise랑 뭔 상관인지 잘 모를 것이다. 나는 그랬다.)
Promise Chaining
then이랑 catch가 Promise 객체를 반환한다고 위에서 언급했다.
이게 뭔 소린지 사실 직관적으로 이해하기는 어려울 것이고,
아래의 활용을 잘 살펴보면 이해가 될 것이다.
const fetchNumber = new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1000);
});
fetchNumber
.then(num => num * 2)
.then(num => num * 3)
.then(num => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(num - 1), 1000);
});
})
.then(num => console.log(num));
fetchNumber는 호출 시, 1초 후 resolve(1)을 반환하는 Promise 객체이다.
fetchNumber가 호출되고, 정상적으로 내부의 setTimeout이 작동했다면
.then 내부에 있는 'num => num * 2' 라는 함수에 1이 전달된다.
'num => num * 2' 라는 함수가 정상적으로 작동했다면, resolve(2)를 반환할 것이고
.then(num => num * 3) 을 통해서 불러올 수 있다.
그리고 이것이 정상적으로 작동했다면 resolve(6)을 반환하는데,
그 다음 .then을 통해서 resolve(6)을 불러온 후,
'num을 받아 Promise ~~~ 를 반환하는 함수'에 넣는다.
정상적으로 작동한다면, 6을 받아 1초 후 resolve(5)를 반환할 것이다.
그 후, resolve(5)가
.then(num => console.log(num)) 으로 전달되어
5가 출력된다.
이런 방식을 Promise Chaining이라고 한다.
지난 포스팅에서 다룬 callback만을 사용했다면 굉장히 복잡했을 코드가,
여러 함수를 연쇄적으로 호출했음에도 아주 간단해졌다.
Error Handling
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('닭'), 1000);
});
const getEgg = hen =>
new Promise((resolve, reject) => {
// setTimeout(() => resolve(`${hen} => 달걀`), 1000);
setTimeout(() => reject(new Error(`${hen} => 달걀`)), 1000);
});
const cook = egg =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => 프라이`), 1000);
});
getHen()//
.then(hen => getEgg(hen))
.then(egg => cook(egg))
.then(meal => console.log(meal))
.catch(error => console.log(error));
getHen()이 호출되었다.
setTimeout이 정상적으로 작동한다면, resolve('닭')이 .then을 통해 'hen => getEgg(hen)' 함수로 들어간다.
'닭'을 받아 getEgg 내부 로직을 실행하는데,
reject가 실행되었다! 에러가 발생한 것이다.
그렇다면 resolve는 실행되지 않고, 'reject(new Error(`${hen} => 달걀`)) 이 반환될 것이다.
하지만 이는 그 다음 .then(egg ...)로 전달되지 않는다.
.then은 resolve를 받아오는 함수이기 때문이다.
순서와 관계없이, 마지막에 있는 .catch로 전달되어 `${hen} => 달걀`에 해당하는 '닭 => 달걀'이 출력된다.
getHen()//
.then(hen => getEgg(hen))
.catch(error => {
return '밥';
})
.then(egg => cook(egg))
.then(meal => console.log(meal))
.catch(error => console.log(error));
위처럼 에러가 발생한 지점에서 .catch를 실행할 수 있다.
getEgg에서 에러가 발생했으니 .catch가 실행되는데,
.catch 내부에는 '밥'을 반환하는 함수가 들어 있다.
결국 앞에서 실행된 로직과 관계 없이 '밥'이 반환된다.
여기서 유의할 점은!
.then과 .catch는 Promise 객체를 반환한다고 했다.
.catch 내부에서 '밥'을 반환하는 함수가 정상 작동되었으니,
이는 resolve('밥')의 형태로 전달되고, 바로 아래에 있는 .then(egg => cook(egg))에 전달된다!
callback지옥과 비교
// Callback Hell example
class UserStorage {
loginUser(id, password, onSuccess, onError) {
setTimeout(() => {
if (
(id === 'woojin' && password === 'hustle') ||
(id === 'money' && password === 'many')
) {
onSuccess(id);
} else {
onError(new Error('not found'));
console.log(typeof Error);
}
}, 2000);
}
getRoles(user, onSuccess, onError) {
setTimeout(() => {
if (user === 'woojin') {
onSuccess({ name: 'woojin', role: 'admin' });
} else {
onError(new Error('no access'));
}
}, 1000);
}
}
const userStorage = new UserStorage();
const id = 'woojin';
const password = 'hustle1';
userStorage.loginUser(
id,
password,
user => {
userStorage.getRoles(
user,
userWithRole => {
console.log(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
},
error => {
console.log(error);
}
);
},
error => {
console.log(error);
}
);
지난 포스팅에서 작성한 callback hell 코드이다.
Promise를 활용하면 아래와 같이 간결하게 작성할 수 있다.
// Callback Hell example
class UserStorage {
loginUser(id, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (
(id === 'woojin' && password === 'hustle') ||
(id === 'money' && password === 'many')
) {
resolve(id);
} else {
reject(new Error('not found'));
}
}, 2000);
});
}
getRoles(user) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (user === 'woojin') {
resolve({ name: 'woojin', role: 'admin' });
} else {
reject(new Error('no access'));
}
}, 1000);
});
}
}
const userStorage = new UserStorage();
const id = 'money';
const password = 'many';
userStorage.loginUser(id, password)
.then(id => userStorage.getRoles(id))
.then(value => console.log(value))
.catch(error => console.log(error));
// userStorage.loginUser(
// id,
// password,
// user => {
// userStorage.getRoles(
// user,
// userWithRole => {
// console.log(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
// },
// error => {
// console.log(error);
// }
// );
// },
// error => {
// console.log(error);
// }
// );
여기서 집중할 부분은 하단에 Promise 객체를 호출하는 부분이다.
그 아래에 있는 주석은 callback hell에서 사용했던 코드인데,
Promise chaining으로 간결하게 작성되었다!
즉, Promise를 통해 callback hell에서 벗어날 수 있었다.
그런데, 이게 비동기랑 도대체 무슨 상관일까?
비동기와 Promise
Promise의 사용에 대한 수많은 아티클을 보며 사용하는 방법에 대해서 감을 잡을 수 있었다.
그러나 Promise와 비동기 사이에 도대체 무슨 관계가 있는지 정확하게 이해가 되지 않았다.
그래서, 나름대로의 고민을 통해 내린 결론을 적어보려고 한다.
우선 비동기처리와 callback의 관계부터 살펴보자.
비동기처리라 함은,
A();
B();
와 같은 순서로 코드가 작성되었을 때,
A와 B의 처리 시간에 따라
A보다 B의 결과가 먼저 반환될 수도 있는 처리 방식을 의미한다.
그런데, B가 A보다 먼저 처리된다는 게 말이 되나?
그렇다 말이 안된다.
코드 순서상 A가 호출된 다음 B가 호출되는 게 맞으니까 당연히 A가 처리되고 B가 처리된다. (JS는 싱글 스레드 언어이므로 한 번에 한 작업을 실행한다.)
그렇다면, 비동기 처리라는 것은 B가 먼저 처리되는 것이 아니라,
B의 내부 로직이 A의 내부 로직보다 먼저 처리되는 것이라고 이해해야 한다.
이는 CPU 자원을 분할해서, 메인 스레드에서는 A를 호출하고 B를 호출하면서,
백그라운드에서 A의 내부로직과 B의 내부로직을 처리한다고 이해하면 될 것 같다.
따라서,
A(C) {
C;
}
B(D) {
D;
}
(A, B, C, D는 모두 함수)
이처럼, A를 호출하면 백그라운드에서 C가 실행되고,
B를 호출하면 백그라운드에서 D가 실행되고,
C, D의 처리 시간에 따라 코드 순서와 관계 없이 값을 반환하거나 함수를 처리하는 것이다.
이러한 비동기 처리를 위해 함수의 인자로 함수를 집어넣어 호출하는 callback이 필요했던 것이다.
그런데, callback은 매우 복잡한 코드를 낳는다.
지난 포스팅에서 쉽게 살펴볼 수 있었다(위에도 코드가 첨부되어 있다.)
이를 Promise로 해결했는데,
그렇다면 Promise에서는 어떻게 비동기 처리를 구현할까?
const fetchReturn = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1500);
});
const getHen = () =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('닭'), 1000);
});
const getEgg = hen =>
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(`${hen} => 달걀`)), 1000);
});
fetchReturn()
.then(num => console.log(num));
getHen()
.then(hen => getEgg(hen))
.then(egg => console.log(egg));
getHen()
.then(hen => console.log(hen));
위 코드를 통해 간단한 비동기 코드를 이해해보자ㅏ.
정의는 건너뛰고 아래 실행 부분을 살펴보면,
1) fetchReturn은 1.5초 후 1을 출력한다.
2) (첫번째)getHen은 1초 후 '닭'을 전달하고, getEgg는 1초 후 '닭 => 달걀'을 출력한다.
(즉, 2초 후 '닭 => 달걀'을 출력한다.)
3) (두번째)getHen은 1초 후 '닭'을 출력한다.
Promise는 호출되는 순간 내부 로직을 백그라운드에서 실행시킨다고 이해했다.
그렇기에, 코드 순서가 아닌 처리 시간 순서대로
닭
1
닭 => 달걀
이 출력된다고 이해했다.
즉, .then, .catch는 비동기를 이해하기 위함이 아니라
Promise의 작동 방식을 이해하기 위해서 공부한 것이고
Promise는 그 자체로 호출되면서부터 그 내부 로직이 비동기처리된다고 이해했다.
(then, catch만 죽어라 공부해도 비동기랑 연관성이 이해가 되지 않았더라는..)
Typescript 코드로 변환
위에서 사용한 코드를 TS로 변환해보았다.
// Promise is a JavaScript object for asynchronous operation.
// State: pending -> fulfilled or rejected
// Producer vs Consumer
// 1. Producer
// when new Promise is created, the executor runs automatically.
const testPromise = new Promise((resolve, reject) => {
// doing some heavy work (network, read files)
console.log('doing something...');
setTimeout(() => {
resolve('woojin');
// reject(new Error('no network'));
}, 500);
});
// 2. Consumers: then, catch, finally
testPromise
.then(console.log)
.catch(console.log);
// 3. Promise chaining
const fetchNumber: Promise<number> = new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1000);
});
const fetchReturn = (): Promise<number> => new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1500);
});
fetchNumber
.then(num => num * 2)
.then(num => num * 3)
.then(num => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(num - 1), 1000);
});
})
.then(num => console.log(num));
// fetchReturn()
// .then(num => num * 2)
// .then(num => num * 3)
// .then(num => {
// return new Promise((resolve, reject) => {
// setTimeout(() => resolve(num - 1), 1000);
// });
// })
// .then(num => console.log(num));
// 4. Error Handling
const getHen = (): Promise<string> =>
new Promise((resolve, reject) => {
setTimeout(() => resolve('닭'), 1000);
});
const getEgg = (hen: string): Promise<string> =>
new Promise((resolve, reject) => {
// setTimeout(() => resolve(`${hen} => 달걀`), 1000);
setTimeout(() => reject(new Error(`${hen} => 달걀`)), 1000);
});
const cook = (egg: string): Promise<string> =>
new Promise((resolve, reject) => {
setTimeout(() => resolve(`${egg} => 프라이`), 1000);
});
getHen()//
.then(hen => getEgg(hen))
.catch(error => {
return '밥';
})
.then(egg => cook(egg))
.then(meal => console.log(meal))
.catch(error => console.log(error));
// Callback Hell example
interface role {
name: string;
role: string;
}
const admin: role = {
name: 'woojin',
role: 'admin',
}
class UserStorage {
loginUser(id: string, password: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (
(id === 'woojin' && password === 'hustle') ||
(id === 'money' && password === 'many')
) {
resolve(id);
} else {
reject(new Error('not found'));
}
}, 2000);
});
}
getRoles(user: string): Promise<role> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (user === 'woojin') {
// resolve({ name: 'woojin', role: 'admin' });
resolve(admin)
} else {
reject(new Error('no access'));
}
}, 1000);
});
}
}
const userStorage = new UserStorage();
const id = 'woojin';
const password = 'hustle';
userStorage.loginUser(id, password)
.then(id => userStorage.getRoles(id))
.then(value => console.log(value))
.catch(error => console.log(error));
// userStorage.loginUser(
// id,
// password,
// user => {
// userStorage.getRoles(
// user,
// userWithRole => {
// console.log(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
// },
// error => {
// console.log(error);
// }
// );
// },
// error => {
// console.log(error);
// }
// );
다음 포스팅에서는 비동기 처리의 끝판왕,
async, await에 대해서 다뤄보겠다!