회원가입 로직에서 생긴 Promise 비동기 DB 처리와 에러 처리 고민
고민
우선 채팅을 구현하기 앞서 회원가입과 로그인을 먼저 구현했다. 고민을 얘기하기 전 상황에 대해서 설명해보면 다음과 같다.
먼저 회원가입은 아래와 같은 절차로 진행된다.
- 이메일, 닉네임, 비밀번호의 형식을 확인
- 이메일, 닉네임 중복확인(DB)
- 비밀번호 해쉬화
- 새 유저 생성(DB)
- 응답
이 과정에서 이메일, 닉네임 중복확인, 비밀번호 해쉬화, 새 유저 생성은 비동기 작업으로 이루어진다. 첫 번째 고민은 비동기처리를 하나하나 순서대로 await await await으로 받아 동기와 다름없이 처리하고 싶지 않았다. 먼저 비동기 작업을 모두 프로미스로 받은 후 나중에 한 번에 await으로 받고 싶었다. 처음 생각한 방법은 이메일, 닉네임 둘 중 하나라도 중복되면 안 되기에 Promise.all을 썼다. Promise.all은 promise 리스트를 인자로 받고 하나라도 reject 되면 모든 promise를 무시하고 catch에 잡힌다.
두 번째 고민은 에러처리 였다. Promise.all로 받았을 때 에러가 발생하면(혹은 reject가 호출되면) try-catch에 잡혀 SERVER_ERROR를 응답하게 된다. 하지만 DB작업의 경우 SERVER_ERROR가 아닌 DB_ERROR를 응답하고 싶었다.
첫 번째 고민 (Promise.all vs Promise.allSettled)
우선 결론부터 말하자면 Promise.all대신 Promise.allSettled를 사용하기로 했다. 사실 이 경우에서는 Promise.all을 사용해도 결과는 같다. 하지만 후에 추가적인 작업이 있을 수 있으니 더 specific 한 방법인 allSettled를 사용했다. Promise.all과 Promise.allSettled의 사용법 비교를 위해 예시 코드를 작성했다.
const inputEmail = 'asd@gamil.com';
const inputPassword = 'myPassword123!@#';
const dbEmail = 'cloer@gamil.com';
const dbPassword = 'myPassword123!@#';
const emailErrorRate = 0.3;
const passwordErrorRate = 0.3;
const checkEamil = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < emailErrorRate) reject(new Error('DB Error About Email'));
if (inputEmail === dbEmail) resolve('Correct Email');
else resolve('Wrong Eamil');
}, 2000);
});
const checkPassword = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < passwordErrorRate) reject(new Error('DB Error About Password'));
if (inputPassword === dbPassword) resolve('Correct Password');
else resolve('Wrong Password');
}, 3000);
});
// Promise.all()
try {
console.log('-- Promise.all');
const [emailAllResult, passwordAllResult] = await Promise.all([checkEamil, checkPassword]);
console.log(emailAllResult, ' / ', passwordAllResult);
} catch (error) {
console.log('-- Promise.all error');
console.log(error);
}
// Promise.all().catch()
try {
console.log('\n-- Promise.all catch');
const result = await Promise.all([checkEamil, checkPassword]).catch((error) => {
console.log('catch Error: ' + error);
return [null, null];
});
console.log(result);
const [emailAllResult, passwordAllResult] = result;
console.log(emailAllResult, ' / ', passwordAllResult);
} catch (error) {
console.log('-- Promise.all catch error');
console.log(error);
}
// Promise.allSettled()
try {
console.log('\n-- Promise.allSettled');
const allSettledResult = await Promise.allSettled([checkEamil, checkPassword]);
console.log(allSettledResult);
allSettledResult.forEach((result) => {
if (result.status === 'rejected') console.error(result.reason);
if (result.status === 'fulfilled') console.log(result.value);
});
} catch (error) {
console.log('-- Promise.allSettled error');
console.log(error);
}
Promise.all의 경우 하나라도 reject되면 결괏값으로 reject의 인자를 던진다. 이 경우 await Promise.all을 받는 변수의 타입이 바뀔 수 있고 받아지는 값에 경우에 따라 예외처리를 해줘야 한다. 무슨 말이냐면 위 예시에서 두 promise 모두 resolve 되면 await Promise.all()의 값은 length가 2인 array다. 하지만 하나라도 reject 되면 값은 reject의 인자로 받은 에러 객체가 된다. 위 예시에서는 에러 객체 나오자마자 try-catch에 걸려 문제가 없지만 reject의 인자가 에러 객체가 아닌 경우 문제가 생긴다. 하지만 Promise.allSettled는 모두 실패하건 모두 성공하건 혹은 몇 개만 실패하건 모두 결과 값의 타입이 array로 같다. await Promise.allSetteld의 결괏값은 다음과 같다.
resolve(arg) -> { status: 'fulfilled', value: arg }
reject(err) -> { status: 'rejected', reason: err }
따라서 await Promise.allSetteld의 경우 구조분해 할당으로 결과를 받아 status 값에 따라 각각의 에러 처리를 해줄 수 있다. await Promise.alldml 경우 결과를 먼저 받고 결과의 에러 처리를 한 뒤(위 예제에서는 try-catch가 모든 걸 처리해줌) 구조 분해 할당을 해야 한다.
두 번째 고민 (하나의 try-catch구문 안에서 여러 에러 처리)
처음 작성한 코드(가장 위에 있는 스크린 숏)를 보면 어떤 에러가 나던 SERVER_ERROR를 응답한다. 나는 DB 요청(이메일, 닉네임 중복확인, 새 유저 생성)에서 에러가 발생한 경우 DB_ERROR를 응답하고 싶었다. 그래서 생각한 방법이 두 가지가 있는데 하나는 DB 요청을 보내는 함수에서 try-catch로 db에러를 걸러내는 것이고 다른 하나는 서버 에러를 잡는 try-catch문 안에 db요청에 또 try-catch로 감싸 이중 try-catch를 하는 방법이다.
DB요청 함수 안에서 try-catch를 하면 catch 부분에서 res.json을 하기 위해 res를 넘겨주거나 혹은 catch부분에서 db error 메시지를 리턴한 뒤 signUp 함수에서 DB 요청의 결괄 값을 try에서 온 것과 catch에서 온 것을 구분해야 한다. 이 방법은 DB 요청을 하나만 처리할 때는 어렵지 않게 할 수 있을 것 같은데 await Promise.All 쓰면 말이 달라진다. 또 이중 try-catch를 하게 되면 블록 레벨 스코프인 const의 사용이 어려워진다. 그리고 무엇보다 보기 안 좋다. 그래서 생각해낸 방법이 await promise.catch()다.
then, catch, finally는 프로미스(정확히 말하면 thenable객체)를 반환하기에 체이닝이 가능하다. 따라서 DB요청의 결과로 받은 promise에서 에러가 발생하면 try-catch에 잡히기 전에 .catch에 잡히게 된다. catch의 결괏값이 리턴되어 await (catch의 결과 promise)가 되고 DB 요청을 따로 에러 처리할 수 있게 된다. 코드로 살펴보자. 함수 이름을 signUp에서 createUser로 변경했다.
먼저 모든 확인과 비밀번호 해쉬화가 끝난 후 유저를 생성하는 33번째 줄을 봐보자.
const insertResult = await userModel.insertUser(email, nickname, hashedPassword).catch((error) => console.error(error));
DB작업을 실행하는 함수 insertUser는 async함수이기에 promise를 반환한다. 만약 DB작업 중 에러가 발생한다면 createUser 전체를 감싸고 있는 try-catch에 잡히기 전에 프로미스 체이닝 .catch에 잡히게 된다. error를 콘솔에 찍고 console.error 함수는 undefined를 리턴한다. catch 역시 프로미스를 리턴함으로 await의 우변은 undefined를 들고 있는 프로미스다. 이 프로미스가 await을 만나 insertResult에는 undefined가 담기게 된다. 즉 DB에러가 발생하면 insertResult는 undefined다. 이를 이용해 DB_ERROR를 응답했다.
조금 더 복잡한 19줄 Promise.allSetteld의 에러처리를 보자.
const [doesEmailExist, doesNicknameExist] = await Promise.allSettled([doesEmailExistPromise, doesNicknameExistPromise]);
if (doesEmailExist.status === 'rejected') {
console.error(doesEmailExist.reason);
return res.json(errorMessage(baseMessage.DB_ERROR));
}
if (doesNicknameExist.status === 'rejected') {
console.error(doesNicknameExist.reason);
return res.json(errorMessage(baseMessage.DB_ERROR));
}
allSettled는 .catch를 붙이지 않았다. 이유는 DB에러는 rejected 객체로 리턴된다. allSettled().catch()에서 잡히는 에러는 allSettled가 실행될 때 발생한 에러다. allSettled의 인자로 넣어준 프로미스의 에러가 아니다. 따라서 allSettled에서 에러가 발생하면 SERVER_ERROR를 응답하는 게 옳고 이는 try-catch가 해주고 있다. DB작업에서 에러가 생기던 생기지 않던 결과를 배열로 받고 각각의 프로미스의 결과(doseEmailExist와 doseNicknameExist)에 대해 에러 처리를 해준다. 배열이 길 경우 구조 분해 할당으로 받지 않고 변수로 받아서 forEach나 map, filter 등을 이용하는 게 좋아 보인다.
정리
이제 더이상 Promise.all을 쓸 일이 없어졌다. Promise.allSettled가 Promise의 super set역할을 해주기에 더 상세한 작업이 가능한 Promise.allSettled를 쓰도록 하자.
await promise.catch() 구문으로 각각의 promise에 대한 에러 처리를 try-catc를 사용하지 않고 할 수 있다. catch 또한 promise(정확히는 thenable객체)를 반환하기에 catch의 결과 역시 프로미스다. 따라서 await ( promise.catch() )로 볼 수 있고 await의 결괏값은 catch의 리턴 값이다.
'Toy Projects > cloer chat' 카테고리의 다른 글
[cloer chat] client pug에서 react로 전환 (0) | 2022.03.24 |
---|---|
[cloer chat] 데이터베이스 설계 (0) | 2022.03.14 |