들어가며
사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. API를 개발하면서, 이유 모를 문제 때문에 API가 제대로 동작하지 않는 문제가 발생했습니다. API를 개발하면서, 에러를 처리하는 로직을 전혀 구성해두지 않았기에 생겼던 문제였습니다. 그럼 NestJS에서 에러를 핸들링하는 로직은 어떻게 작성해야 할지 궁금했습니다. 이 글은 NestJS에서 Exception을 처리하는 부분에 대한 노력의 흔적이 담겼습니다.
무지성 try-catch 활용하기
이미 개발해둔 API에서 문제가 생겼지만, 왜 문제가 생겼는지 로깅도 제대로 되지 않는 것을 보면서 API에서 에러 처리를 제대로 하지 않고 있다는 것을 깨달았습니다. 일단 에러 처리를 하기 위해 많이 사용하는 try-catch를 사용해야겠다고 생각했습니다.
try {
await this.checkLimitFavoriteSummoner(userDto.userId);
await this.saveRedisSummonerRecord(favoriteSummonerDto);
await this.saveSummonerRecord(favoriteSummonerDto);
await this.saveFavoriteSummoner(favoriteSummonerDto, userDto);
await this.restoreFavoriteSummoner(favoriteSummonerDto, userDto);
} catch (error) {
throw error;
}
try-catch를 사용하는 과정에서, try-catch를 무지성으로 활용했습니다. 예를 들어, 위와 같은 로직 전부에 try-catch를 걸어서 Exception을 핸들링했습니다. 개발자라면, 어떤 로직에 어떤 에러가 생길 수 있는지 최대한 예측할 줄 알아야 한다고 생각합니다. 위와 같이 핸들링을 해보니, 어떤 로직에서 어떻게 문제가 생길 수 있는지를 추적하기 어려워진다는 단점이 있었습니다. 예를 들어 만약 DB를 다루는 로직이라면, 의도한 예외 처리 로직이 실행되지 않아도, DB가 연결이 되어 있지 않거나 하는 다양한 문제가 발생할 수 있는데, 어디서 어떻게 문제가 생길지 모른다는 단점이 있었습니다.
try {
await this.favoriteSummonerApiService.createFavoriteSummoner(
userDto,
favoriteSummonerDto,
);
return ResponseEntity.CREATED_WITH(
'소환사 즐겨찾기 추가에 성공했습니다.',
);
} catch (error) {
this.logger.error(`dto = ${JSON.stringify(favoriteSummonerDto)}`, error);
if (error.status === ResponseStatus.FORBIDDEN) {
throw new ForbiddenException('즐겨찾기 한도가 초과되었습니다.');
}
throw new InternalServerErrorException();
}
try-catch를 무지성으로 사용한 것에서 더 나아가서, 함수 내부에서 익셉션을 던진 다음, 콜러에서 캐치를 해서 리턴 값을 받아서 처리하는 패턴을 사용하고 있었습니다. 이렇게 되면 문제가 비즈니스 로직에서 생긴 문제를 컨트롤러까지 가져와서 처리하는 것이기 때문에 계층 간의 역할이 명확해지지 않는다는 문제가 발생했습니다. 그럼 try-catch를 어떻게 활용해야 하는 것인지 궁금증이 생겼습니다.
try-catch 잘 활용하기
그동안 습관처럼 async 함수를 사용할 때 try-catch를 사용하고 있었습니다. 만약 try-catch 없이 async 함수를 사용하면 어떤 차이가 있을까요? 이에 대해 알아보겠습니다.
const makeError = async () => { throw new Error('에러 클래스에 의한 에러') }
const withTryCatch = async () => {
try {
console.log('try-catch 를 사용한 async')
const result = await makeError();
console.log('withTryCatch - 에러가 발생하는 위치 아래에 있는 코드 (실행되면 안됨)');
return result;
} catch (err) {
throw err;
}
}
const withoutTryCatch = async () => {
console.log('try-catch 없는 async')
const result = await makeError();
console.log('withoutTryCatch - 에러가 발생하는 위치 아래에 있는 코드 (실행되면 안됨)');
return result;
}
withTryCatch()
.then(res => {
console.log('withTryCatch - 성공결과', res)
}).catch(err => {
console.log('withTryCatch - 실패결과', err.message)
});
withoutTryCatch()
.then(res => {
console.log('withoutTryCatch - 성공결과', res)
})
.catch(err => {
console.log('withoutTryCatch - 실패결과', err.message)
})
위의 코드를 사용하면, 에러가 발생하는 코드 다음에 있는 코드들은 실행되지 않았습니다.
그리고 발생한 에러는 Promise.reject 처리되어 상위 컨텍스트에서 비동기 에러로 처리됩니다. 즉 상위 컨텍스트로 에러를 전파하기 위해 async 함수의 내부를 try-catch로 묶을 필요는 없습니다. 하지만 NestJS를 개발할 때, 서비스 레이어에서 비즈니스 로직을 작성할 때는 try-catch문을 일정 부분 사용해야 합니다. 왜냐하면 비동기 코드의 성공, 실패 유무에 상관없이 정상적인 응답을 내주어야 하기 때문입니다. 만약 요청을 처리할 수 없더라도, 정상적인 응답을 전해야 하기 때문에, try-catch문을 활용하는 것이 좋습니다.
그럼 try-catch를 할 때 어떻게 해야 제대로 활용할 수 있는 것이며, exception 처리를 어떻게 해야 하는지에 대해 자세히 고민해볼 필요가 있습니다. 제가 개발하면서 에러가 났던 경우의 대부분은 외부 디펜던시를 활용할 때 발생했습니다. 하지만 에러 처리할 것이 없는데도 불구하고 모든 로직을 try-catch를 해버리면, exception이 발생해도, 어디서 어떻게 exception이 발생하는지 알기도 어려울뿐더러, 로깅도 제대로 할 수 없다는 단점이 있습니다. 이를 위해 최대한 exception 처리할 수 있는 부분은 처리하고, 만약 도저히 생각하지 못한 경우의 exception을 처리하기 위해 try-catch를 활용하는 것이 개인적으론 좋다고 생각합니다.
에러 다시 던지기
만약 예기치 않은 에러가 try {...} 블록 안에서 발생할 수도 있습니다. 정의되지 않은 변수 사용 등의 프로그래밍 에러가 발생할 가능성은 항상 있습니다.
let json = '{ "age": 30 }'; // 불완전한 데이터
try {
user = JSON.parse(json); // <-- user 앞에 let을 붙이는 걸 잊었네요.
// ...
} catch(err) {
alert("JSON Error: " + err); // JSON Error: ReferenceError: user is not defined
// (실제론 JSON Error가 아닙니다.)
}
위에선 '불완전한 데이터’를 다루려는 목적으로 try.. catch를 썼습니다. 그런데 catch는 원래 try 블록에서 발생한 모든 에러를 잡으려는 목적으로 만들어졌습니다. 그런데 위 예시에서 catch는 예상치 못한 에러를 잡아내 주긴 했지만, 에러 종류와 관계없이 "JSON Error" 메시지를 보여줍니다. 이렇게 에러 종류와 관계없이 동일한 방식으로 에러를 처리하는 것은 디버깅을 어렵게 만들기 때문에 좋지 않습니다. 이런 문제를 피하고자 ‘다시 던지기(rethrowing)’ 기술을 사용합니다. 규칙은 간단합니다. catch는 알고 있는 에러만 처리하고 나머지는 ‘다시 던져야’ 합니다. ‘다시 던지기’ 기술을 더 자세히 설명하겠습니다.
- catch가 모든 에러를 받습니다.
- catch(err) {...} 블록 안에서 에러 객체 err를 분석합니다.
- 에러 처리 방법을 알지 못하면 throw err를 합니다.
보통 에러 타입을 instanceof 명령어로 체크합니다.
try {
user = { /*...*/ };
} catch(err) {
if (err instanceof ReferenceError) {
alert('ReferenceError'); // 정의되지 않은 변수에 접근하여 'ReferenceError' 발생
}
}
err.name 프로퍼티로 에러 클래스 이름을 알 수도 있습니다. 기본형 에러는 모두 err.name 프로퍼티를 가집니다. 또는 err.constructor.name를 사용할 수도 있습니다. 에러를 다시 던져서 catch 블록에선 SyntaxError만 처리되도록 해보겠습니다.
let json = '{ "age": 30 }'; // 불완전한 데이터
try {
let user = JSON.parse(json);
if (!user.name) {
throw new SyntaxError("불완전한 데이터: 이름 없음");
}
blabla(); // 예상치 못한 에러
alert( user.name );
} catch(e) {
if (e instanceof SyntaxError) {
alert( "JSON Error: " + e.message );
} else {
throw e; // 에러 다시 던지기 (*)
}
}
catch 블록 안의 (*)로 표시한 줄에서 다시 던져진(rethrow) 에러는 try.. catch ‘밖으로 던져집니다’. 이때 바깥에 try.. catch가 있다면 여기서 에러를 잡습니다. 이렇게 하면 catch 블록에선 어떻게 다룰지 알고 있는 에러만 처리하고, 알 수 없는 에러는 ‘건너뛸 수’ 있습니다. 이제 try.. catch를 하나 더 만들어, 다시 던져진 예상치 못한 에러를 처리해 보겠습니다.
function readData() {
let json = '{ "age": 30 }';
try {
// ...
blabla(); // 에러!
} catch (e) {
// ...
if (!(e instanceof SyntaxError)) {
throw e; // 알 수 없는 에러 다시 던지기
}
}
}
try {
readData();
} catch (e) {
alert( "External catch got: " + e ); // 에러를 잡음
}
readData는 SyntaxError만 처리할 수 있지만, 함수 바깥의 try.. catch에서는 예상치 못한 에러도 처리할 수 있게 되었습니다. 지금까지의 과정을 통해, 외부 디펜던시를 사용하는 경우의 에러 처리를 하기 위해, 개발자의 실수를 조금이라도 줄이기 위해서는 try-catch의 에러 처리를 보다 잘해야 한다는 것을 알 수 있었습니다.
try-catch를 잘 설정하더라도, 전혀 예상하지 못한 에러가 나타날 수 있는데, 이런 경우를 위해 ExceptionFilter를 전역으로 설정한다면 서버를 중단 없이 진행할 수 있습니다. 만약 ExceptionFilter를 어떻게 적용해야 하는지 알고 싶다면 다음의 링크를 참고해주세요.
마치며
앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다.
참고 및 출처
'Project > 서버 개발' 카테고리의 다른 글
[Project] 프로젝트 삽질기24 (feat Sentry Slack 연동) (0) | 2022.04.29 |
---|---|
[Project] 프로젝트 삽질기23 (feat Enum) (0) | 2022.04.22 |
[Project] 프로젝트 삽질기21 (feat 모노레포) (0) | 2022.04.19 |
[Project] 프로젝트 삽질기20 (feat Node 버전 관리) (0) | 2022.04.18 |
[Project] 프로젝트 삽질기19 (feat if else 리팩터링) (0) | 2022.04.13 |