시작
오늘은…백엔드 api 서버에서 exception 핸들링 방법을 어떻게 해야할 지에 대한 고민의 흔적입니다…
기능 개발에 급급하다보니 종종 소홀하게 넘어간 부분들이 보일 때가 있는데, 최근 exception을 처리하는 부분이 전반적으로 좀 취약하다라는 생각이 들었습니다.
이번엔 이런 부분이 어떤 문제가 있고 어떻게 더 개선할 수 있을지에 대해 적어봤습니다.
이야기 하기 전에 전역적이고 자주 발생하는 exception 처리를 어떻게 하는지 간단하게 거치면 좋을 것 같습니다.
최소한의 에러 처리
1. Auth-Guard
많은 경우의 API들이 권한을 필요로 합니다. AuthGuard는 말 그대로 인증에 대한 방어 처리를 담당합니다. 요청이 들어오면 헤더에서 인증과 관련한 값(JWT, 쿠키, 세션 등)을 들고와서 적절한 사용자인지 판단합니다.
저희는 JWT 모듈을 사용하는데, 기본적으로 Invalid Signature(올바르지 않은 암호키가 있을 경우 에러를 반환함)나 Token Expired(토큰이 만료되어 사용할 수 없음) 등의 에러를 장착하고 있습니다.
적절한 인증 조건을 가지지 못한 경우, 실제 로직은 동작하지 않으며 클라이언트 단으로 Unauthorized(401) 에러가 반환됩니다.
가드는 어노테이션 추가로 간편하게 사용할 수 있으며, 여러 개를 만들어 변칙적으로 사용할 수도, 전역적으로 사용할 수도 있습니다.
2. Http-Filter
try~ catch를 사용하면 error를 잡아서 추가적인 행동을 하게 만들어줄 수 있습니다.
@ApiOperation({ description: '알림 설정하기' })
@Put('/')
async insertAlarmByUserUid(
@Body() request: AlarmRequest,
): Promise<void> {
try {
...
} catch (error) {
this.logger.error(error); // 에러를 로깅하고
throw new Error(error); // 떨궈버리므로 사용자에게 에러가 그대로 반환됩니다.
}
}
}
백엔드 api 서버는 외부의 의존성(데이터베이스, 캐시, 다른 API 서버, AWS 서비스 등등…)들이 맞물려 있는 상태이고, 코드로 관리할 수 없는 에러들이 발생할 수 있는 환경입니다.
중요한 건 그대로 throw new Error를 해버린 케이스입니다.
throw new Error가 뱉을 수 있는 가장 최악의 케이스는 아무래도 이후의 에러 처리가 이뤄지지 않아 에러 로그가 그대로 반환되는 케이스일 것입니다.
{
"statusCode": 500,
"message": "Cannot read property '0' of undefined."
}
에러만 보면 별 문제가 없어보이지만 프론트엔드가 이런 에러 로그를 보여주는 형태로 되어있다면 아래와 같이 응답이 노출됩니다.
이런 오류는 사용자로 하여금 사이트에 대한 신뢰감을 저하시킬 뿐더러 특히 보안에도 좋지 않습니다.
어떤 오류가 발생하는지는 곧 사이트의 허점으로 이어지기 때문입니다.
최소한 사용자는 사용자에게 필요한 정보만 받도록 하는 것이 기본적인 규칙이죠.
다행히 이런 에러를 방지하기 위해서 filter를 사용할 수 있습니다.
필터는 마지막으로 사용자에게 값이 반환되기 직전 이 데이터를 붙잡아 가공하는 역할을 담당합니다.
이제 최소한 의미불명의 에러가 반환되기 직전엔, filter가 이를 붙잡아서 사용자에게 보내선 안되는 정보를 걸러내줍니다.
결론적으로 다음과 같이 에러의 로깅 정보를 감출 수 있습니다.
default로 Internal Server Error(500)를 반환하게 되어있습니다. 서버가 예상치 못한 상황에 놓였다는 뜻입니다.
{
"statusCode": 500,
"message": "Internal Server Error",
"timestamp": "2022-07-13T07:24:46.720Z",
"path": "/api/campaign/analyzer/%EC%B0%A8%EB%B0%95"
}
이제 throw new Error에 들어가는, 의도하지 않은 에러가 발생하는 대개의 케이스에 대해서는 위 데이터가 반환될 것입니다. 사용자의 문제가 아닌 경우에 발생하는 에러이므로 별도의 공지가 필요하지 않습니다.
사용자에게 제공하기에 적합한 값이라고 볼 수 있겠습니다.
3. Class-Validator
class-validator는 타입스크립트와 함께 두루 쓰이는 npm 모듈입니다. guard와 유사한 역할인데, 클라이언트가 잘못된 request 값을 보내면 에러를 반환하는 구조입니다.
반환할 메시지를 추가할 수도 있고, 단순히 타입을 넘어서 특정 코드인지, 어떤 조건에 부합하는지, 길이나 언어 등이 맞는지 확인하는 것도 가능합니다.
타입스크립트는 런타임 시에 타입을 정적으로 막는 기능은 없습니다.
string 타입으로 명시가 되었어도 프론트엔드에서 number를 보내는 걸 막을 수는 없는 것입니다.
이런 때에 class-validator는 값을 구체적으로 제한할 수 있게 도와줍니다.
개선하기
백엔드는 적어도 이제 프론트엔드에서 말도 안되는 에러를 출력할 일도 없고, 인증 안 된 사용자가 api를 헤집고 다닐 일도 없고, 잘못된 값이 들어와도 값을 냅다 뱉는 일도 제한할 수 있습니다.
….
만 최근 모듈 설정을 고치다보니 개선점이 몇 개 있었습니다.
근본적으로는 500이 너무 많이 떨어진다! 였는데, filter만의 문제가 아니라 각 처리 방법의 문제점들이 전부 뒤엉켜 filter로 들어오고 있었기 때문입니다.
1. Class-Validator의 설정이 약함
최근에 고친 내용 중 하나인데, 기존 설정은 선택적으로 class-validator를 사용하게 되어 있었습니다. 그러니까…안 쓰고 싶으면 안 써도 됐던 거죠.
설정을 추가적으로 다닥다닥 붙이는 건 정말…귀찮은 일이기 때문에…
자연스럽게 class-validator를 안 쓰는 request들이 늘어났고, dto 설정이 점점 더 약해집니다.
그것 때문에 추가적으로 발생하는 에러들은 당연히 글로벌 필터가 안고 가게 됩니다.
예시
nest.js는 들어오는 request의 값을 한번 형변환한 후 사용합니다.
만일 Date 타입이 string 형태로 들어와도 스스로 Date 타입으로 transform해서 실제 로직에서는 Date 타입으로 사용할 수 있게 해주는 것입니다.
**@Query("startDate") startDate: Date;**
이면 startDate를 **new Date()**로 한번 감싸주는 식입니다.
startDate = "2022-04-25" 이면,
**new Date("2022-04-25")** 형태로 형변환을 진행합니다.
마찬가지로,
**@Query("userUid") userUid: number;**
이면 userUid를 **Number(userUid)**로 한번 감싸서 로직에서 사용하게 됩니다.
하지만 프론트엔드가 다음과 number타입의 userUid에 다음과 같이 요청을 보냈다고 합시다.
/api/campaign/analyzer?**userUid**=**undefined**
타입스크립트가 잡아주지 않을까…? 라고 생각할 수 있지만…
타입스크립트는 동작 시점에는 작동하지 않으므로 위 요청은 에러를 반환하지 않습니다.
위 값은 Nest.js에서 초기에 “undefined” 라는 string 값으로 인지됩니다.
그리고 Number(”undefined”) 형태로 강제 형변환을 진행합니다.
자바스크립트에서는 Number(”undefined”) = NaN 이므로 number 타입의 userUid를 기대한 다음 로직에서부터는 에러가 발생하기 시작합니다.
그리고 글로벌 필터로 굴러가 Internal Server Error를 반환하겠죠…
이런 값들은 유연한 자바스크립트로는 통제하기 쉽지 않습니다. Class Validator 모듈은 이런 경우를 최소화하기 위해 존재하며, 다소 엄격하게 적용될 필요가 있습니다.
2. 전반적인 에러 핸들링이 필요함
일부 처리가 되어있는 부분이 있기는 하지만, 전반적으로 어떤 에러가 발생할 수 있는지가 명확히 되어 있지 않습니다…
DB에 데이터 삽입을 한다면
- 삽입 전 중복 데이터가 있는지 확인해 있다면 에러를 반환한다(이 경우 400).
- 삽입이 실패했다면 삽입이 실패했다는 에러를 반환한다(이 경우 원인에 따라 다름).
- 삽입이 성공했으나 특정 조건을 부합하지 않으면 에러를 반환한다.
정도의 최소한의 핸들링이 이뤄지고 각 케이스에 따른 httpException을 반환해야 하는데 바로 throw Error(error)를 하면서 글로벌 필터로 넘어가게 되어버리는 것입니다.
이때 throw Error(error)도 직접 작성한 예외 처리가 아니라 DB 자체의 unique 설정이나 모듈의 자체적인 error에 기대는데, 구체적으로 어떤 에러에 의존하는 지 명시되지 않은 api가 종종 있습니다.
3. Internal Server Error가 너무 자주 사용됨
결론적으로, 로직 자체의 에러 핸들링이 충분히 되지 않아 글로벌 필터에서 내뱉는 Internal Server Error이 마치 아랫집 친구마냥 부지기수하게 보였습니다.
Internal Server Error를 달아둔 것은…수틀리면 이걸 쓰자는 취지보다는
- 모든 핸들링을 거치고도 더 이상 처리할 방법이 없었을 때,
- 혹은 코드에서 관리할 수 있는 범위를 넘어선 에러가 발생했을 때를 위해 마련된 최후의 보루
용도였는데…
에러 처리 자체가 잘 안 되고 있다보니 어느 순간 자연스럽게 몸과 마음을 위탁해버린 것입니다…
🥴: **어우 에러 처리 귀찮아 걍 500 떨구지 뭐~ (X)**
👽: **global 필터가 있어서 exception 형식이 일치해서 좋네. (O)**
😓: **Redis 설정이 잘못됐어서 Internal Server Error가 생겼었네..
그래도 크리티컬한 정보는 반환이 안 돼 다행이다. (O)**
😬: **엘라스틱 서치가 다운됐어!**
**-> 괜찮아~ 500 떨어지고(Axios Error) 데이터 안 오니까 실패겠지...
😶🌫️**: **중복 데이터 삽입을 시도했어...
-> 괜찮아~ 500 떨어지고(DB Error) 삽입 안 되니까 실패겠지...
😰**: **500 떨어졌는데 왜 안 되는건지 모르겠어...ㅜㅜ
-> 괜찮아~ 백엔드 로그 보면 되지!🤗**
괜찮지 않나?
하지만 생각해보면 500을 반환하는 게 큰 문제는 아닐 수도 있습니다.
적절한 메시지를 반환하지 않았다고 해서 서버가 다운되는 것도 아니고, 잘못된 로직이 수행되는 것도 아니니까요(throw error로 떨궈버리고 글로벌 필터가 처리하기 때문).
하지만 서버의 얼레벌레 에러 반환은 다음 상황을 용인하게 만듭니다.
- 프론트는 에러가 떨어질 때마다 백엔드를 통해 확인해야 합니다. 500 에러는 로그를 반환하지 않게 되어있으므로, 왜 에러가 발생했는지 확인할 방법이 없기 때문입니다.
- 프론트엔드는 백엔드의 에러에 대응할 방법이 부족해집니다. 백엔드의 에러 자체가 Internal Server Error로 뭉뚱그려져 오게 되면 세밀한 케이스에 대응하기 어려우므로 사용자에게 해결책을 제시할 수 없습니다(실패했습니다 정도밖에는..)
- 에러를 관리하고 있지 않으므로 기존 코드를 작성하던 사람이 아니면 어디에서 어떤 류의 에러가 발생하는 지 알기 힘들게 되어 유지보수가 어려워집니다.
- Http Status나 메시지를 관리하고 있지 않으므로 그 값들을 활용해 로직 작업을 진행할 수 없습니다. 또한, 500이 사용되는 범위가 너무 넓어 서버와 클라이언트 간의 문제를 구분할 수 없습니다.
- 자연스럽게 글로벌 필터가 모든 에러 객체를 관할하는 역할을 맡게 되면서 글로벌 필터의 역할이 점점 커지게 됩니다(값을 조작하거나, 로직에 관여하게 됨).
에러 관리하기
예측할 수 없는 에러는 최소화되어야 한다.
내가 처리해야 하는 exception
이제까지는 특정한 exception이 필요한 최소한의 케이스에만 다음과 같이 작업을 진행했습니다.
async signIn(request: Readonly<LoginRequest>): Promise<Payload> {
try {
const user: UserEntity = await this.userRepository.findOne({
where: { brandId: request.brandId, userId: request.id },
});
if (!user) {
throw new NotFoundUserException(); //존재하지 않는 사용자에 대한 exception
}
if (!(await compareHash(request.pwd, user.password))) {
throw new WrongPasswordException(
`아이디 또는 비밀번호를 다시 확인해 주세요. (${result}/5)`,
); // 비밀번호가 올바르지 않은 경우에 대한 exception
}
const payload: Payload = {
userUid: user.id,
userId: user.userId,
brandUid: user.brandUid,
brandId: user.brandId,
roleId: user.role,
};
return payload;
} catch (error) {
if (error instanceof WrongPasswordException) {
this.logger.error('password invalid');
throw error;
} else if (error instanceof NotFoundUserException) {
this.logger.error('User Not Exist');
throw error;
} else {
this.logger.error(error);
throw new Error(error); //예측할 수 없는 에러가 이곳에 들어갑니다.
}
}
}
내가 처리하지 않은 exception
다음과 같은 에러는 직접 로직을 안 짜도 특정 라이브러리, 혹은 자체 설정에 따라 자연스럽게 예외가 발생하게 됩니다.
- Unique 설정이 있는 DB에 중복 데이터 삽입을 시도
- 적절하지 못한 데이터 삽입/삭제 시도
- 올바르지 않은 인증 상태(JWT Invalid signature나 expired)로 접근 시도
- 올바르지 않은 데이터를 외부 api로 보냄(이 경우 axios 모듈에서 에러 발생)
그리고 냅다 throw new Error(error)를 해버리면 글로벌 필터에 걸려서 Internal Server Error가 떨어지게 됩니다.
마찬가지로 instanceof를 catch에서 사용하면 특정한 경우의 에러를 구체화할 수 있습니다.
async addUser(
insertUserRequest: InsertUserRequest,
): Promise<UserEntity> {
try {
const userExistInfo = await this.userRdbRepository.find({
userId: insertUserRequest.userId,
brandId: insertUserRequest.brandId,
});
insertUserRequest.password = await generateHash(
insertUserRequest.password,
);
const result = **await this.userRdbRepository.save(insertUserRequest);**
return await this.userRdbRepository.findOne({
where: { id: result.id },
});
} catch (error) {
this.logger.error(error);
throw new Error(error);
}
}
사용자를 추가하는 api를 만든 후 예외 처리(이미 존재하는 사용자인지 등)를 하지 않으면 위 코드의 save 부분에서 DB 에러가 발생하게 됩니다.
위 경우에는 중복된 정보가 겹쳐지면서 Unique 위반 에러가 반환될 것입니다.
단순히 throw 해버리면 Internal Server Error가 반환될 거고, 사용자는 뭐가 문제인지 알 수 없을 것입니다.
이때, 쿼리 에러를 세분화하여 특정 경우에 따른 대응을 할 수 있습니다.
async addUser(
insertUserRequest: InsertUserRequest,
): Promise<UserEntity> {
try {
insertUserRequest.password = await generateHash(
insertUserRequest.password,
);
const result = await this.userRdbRepository.save(insertUserRequest);
return await this.userRdbRepository.findOne({
where: { id: result.id },
});
} catch (error) {
if (
**error instanceof QueryFailedError &&
error.driverError.code === '23505'** // unique 위반 에러 발생 코드
) {
throw new BadRequestException('이미 존재하는 사용자입니다.');
} else {
this.logger.error(error);
throw new Error(error);
}
}
}
다음과 같이 처리하면 unique 위반 에러 코드에 대응하는 것이 가능해집니다.
다만 위 상황과 같이 삽입 시도까지 하고 나서야 에러를 반환하는 것보다는 삽입 전에 find 등의 명령어로
명령을 실행해도 괜찮을 지를 확인한 후 커스텀한 에러 객체를 떨어뜨리는 로직이 아마 조금 더 바람직하겠지만,
만일 특정 모듈이나 환경이 그 에러를 검사하는 것이 올바르다고 판단한다면 예외 처리는 그 모듈에 맡기고 catch에서 에러를 붙잡아 적절한 반환값을 빚어주는 편이 더 좋을 것입니다.
내가 처리해야만 하는 exception
특히 DB 같은 경우에는 exception을 위탁하기가 애매한 것이,
typeorm은 update나 delete 명령어가 실제로 실행되지 않았어도, 즉, 영향을 받은 컬럼이 0개여도 이를 에러로 인지하지 않기 때문입니다.
async deleteUserByUid(id: number): Promise<DeleteResult> {
try {
const result = await this.userRdbRepository.delete(id);
return result;
} catch (error) {
this.logger.error(error);
throw new Error(error);
}
}
가령, 위 로직은 별도의 예외 처리가 없고, DB 연결에 이상이 없을 경우 에러를 뱉지 않습니다.
존재하지 않는 사용자 삭제를 시도해도 에러를 뱉지 않습니다.
마찬가지로 update의 경우에도 존재하지 않는 사용자의 정보 update를 시도해도 에러를 뱉지 않습니다.
잘못된 사용자 값이거나, 들어오는 정보가 잘못된 경우가 있을 수 있으므로,
다음과 같이 결괏값을 기준으로 에러 처리를 해줘야 합니다.
async deleteUserByUid(id: number): Promise<DeleteResult> {
try {
const result = await this.userRdbRepository.delete(id);
if (result.affected < 1) {
throw new DeletedFailedException();
}
return result;
} catch (error) {
...
}
그래서 다른 모듈의 exception에 의존하더라도 가능하면 typeorm의 에러 자체에 지나치게 의존하는 것은 좋은 방식은 아니어 보입니다.
DB에서 관리하는 에러의 관점과 코드에서 관리하는 에러의 관점에는 어느 정도 차이가 있을 수밖에 없기 때문이죠…
Class Validator 역할은 더 커져야한다.
최근 class validator 설정을 좀 엄격하게 수정했습니다.
이제 모든 request dto는 반드시 class-validator의 어노테이션을 가져야 하고, 설정이 명시되지 않으면 Bad Request 에러가 발생합니다.
동시에, 외부에서 명시된 데이터 외에 다른 값을 보낸 경우 백엔드에서 읽을 수 없게 처리했습니다.
class-validator가 없으면 런타임 환경에서 값 체크가 어려워져서 사실상 타입 관리가 안 되는 것과 다름이 없기 때문에, 깜빡해서 이상한 곳에서 터지는 것보다는 차라리 귀찮더라도 명시를 해둘 수 있는 환경이 필요하다고 생각했습니다.
이때 발생하는 에러들이 http-filter에 걸리면서 대충 Bad Request Exception이라고만 떨어지는 경우가 있어 개선중입니다.
단순히 Bad Request Exception이라고 냅다 떨어지면서 요청이 실패하면 프론트엔드는 에러가 왜 발생했는지도 모를 뿐더러 처리할 방법이 없으니 단순히 에러가 발생했다 / 삽입이 실패했다 라고밖에 모를테니까요…
Global Filter의 역할은 작아야 한다.
Filter가 뱉는 Internal Server Error를 기능마냥 쓰고 있는데 고쳐나갈 필요가 있습니다…
언급했다시피 필터의 500 에러는 최후의 수단이고 반환되는 에러들은 개발자가 아는 것임과 동시에 명시되어 있어야 할 것입니다…
예상하지 못했고 처리도 안된 에러들이 부지기수하게 떨어지는 건 위험성을 안고 있으니까요….
뭐 500 떨어지면 에러는 된거지~ 하고 하는 믿음…조금은 줄여도 될 것 같습니다…
그리고 아까 전에 글로벌 필터가 모든 에러를 관리하는 입장이 되면 필터의 역할이 점점 커진다고 했는데,
가능하면 필터는 기존값을 재구성해 에러 response를 일관적으로 반환하게 해주는 역할 + 진짜진짜 최후의 수단으로 Internal Server Error 떨구는 역할 정도만 부여해주는게 가장 좋을 것 같습니다.
그 외…테스트 코드로 에러를 명시하는 건 어떨까?
모듈에서 에러 처리가 안 되어있다면…테스트 코드로 이를 명시하면 되지 않을까? 라는 생각이 문득 들기도 했는데, 일단 그다지 내키는 방법은 아닌 것 같습니다.
- 에러의 추적은 결국 테스트 코드가 모두 지게 됨
- 테스트 코드가 지속적으로 유지보수된다는 전제가 없으면 유명무실해질 수밖에 없음.
- 테스트 코드가 코드 검증보다 문서화를 위해 어거지로 짜여짐.
- 테스트 코드가 코드가 아니라 다른 모듈에서 떨어지는 에러까지 검증할 책임을 지게 됨.
끝
쫌쫌따리 리팩토링은 계속됩니다…
500이 웬만해선 안 떨어지는 api 서버가 되기를 바라는 염원을 담아…
그리고 우로보로스와 다름없이 지우고 만들고를 반복하는 any가 사라지길 바라는 마음도 담아…
'CONFERENCE' 카테고리의 다른 글
문서화에서 놓쳤던 몇 가지 부분들 (0) | 2023.08.05 |
---|---|
테스트 환경 구축과 리팩토링 (0) | 2022.09.18 |