Nest.js MSA에서의 Exception Handling

소포카·2022년 12월 22일
1

FanUP

목록 보기
4/4
post-thumbnail

FanUP은 비대면 팬미팅 플랫폼입니다.
해당 시리즈는 프로젝트를 진행하며 겪은 설계 고민, 트러블 슈팅 과정에 대해 다룹니다.

Github: https://github.com/boostcampwm-2022/web03-FanUP

배경

현재 FanUP의 아키텍쳐는 Gateway 패턴을 적용한 MSA입니다. 자세한 아키텍처는 아래 사진을 참고해주세요.

문제의 시작

Client: 서버야 이거 해줘~
Server: { status: 500, message: “Server Internal Error” }
Client: 뭐가 잘못된거야..?

클라이언트와 서버가 REST API 방식으로 통신하는 것과는 다르게 서버 내부에서 Gateway와 각 마이크로서비스끼리는 RPC 방식으로 통신을 합니다. 모든 요청이 정상적이라면 문제가 없겠지만 마이크로서비스의 비지니스 로직을 구현하는 과정에서 예외 처리를 할 때 문제가 발생하게 됩니다.

마이크로서비스에서 throw Error()를 하게 되면 해당 에러는 Rpc Exception 형태로 API Gateway로 전달됩니다. 이 때, RPC Exception은 status code를 가지고 있지 않으며 API Gateway에 RPC Exception Filter를 적용하지 않아 Client에게 에러가 발생한 상황을 { status: 500, message: “Server Internal Error” } 형식으로만 전달한다는 문제가 있습니다.

이를 해결하기 위해 진성님이 Exception Filter를 커스텀하여 적용하는 과정을 거쳤습니다.

💡 진성님의 트러블슈팅 과정이 궁금하다면?
Nest.js에서 API 라이프 사이클을 적절하게 사용해보자 - 1탄 고민의 흔적
Nest.js에서 API 라이프 사이클을 적절하게 사용해보자 - 2탄 실행의 흔적

문제 발생 예시

  • 마이크로서비스의 AuthService.ts 일부
public async login(loginDto: RequestLoginDto): Promise<LoginResponse> {
  const { provider, accessToken } = loginDto;

let userInfo: UserInfo;
try {
  if (provider === 'google') {
    userInfo = await this.getGoogleProfile(accessToken);
  } else if (provider === 'kakao') {
    userInfo = await this.getKakaoProfile(accessToken);
  } else throw new Error();
} catch (err) {
  throw new CustomRpcException(
    err.message ? 'Invalid AccessToken' : 'Invalid Provider',
    HttpStatus.BAD_REQUEST,
  );
}
// ...
}
  • 예상 응답
{
  "statusCode": 400,
  "message": "Invalid Provider"
}
  • 실제 응답

일단 해결은 했는데..

진성님의 활약으로 이제 클라이언트도 어느 부분에서 에러가 발생했는지를 확인할 수 있게 되었습니다. 또한 마이크로서비스의 응답 형식을 통일하여 클라이언트가 데이터를 받기 용이해졌습니다. 아래는 통일된 마이크로서비스의 응답 형식입니다.

interface CustomRes<T> {
  status: number;
  data: T;
  message: string;
}

하지만 API Gateway에 존재하는 모든 controller-service에 pipe(err => of(err)) 구문을 사용하여 마이크로서비스에서 발생한 에러를 잡아야하는 번거로움이 존재했습니다.

무엇보다 가장 큰 문제는 에러가 발생한 상황임에도 불구하고, 클라이언트에게는 status code200번대로 전달되어 catch(err)를 사용하지 못하고, 직접 response.data.status를 확인하여 에러 핸들링을 해야한다는 점이었습니다.

문제 발생 예시

  • CustomExceptionFilter.ts
@Catch(CustomRpcException)
export class AllRPCExceptionFilter implements RpcExceptionFilter<CustomRpcException>
{
  catch(exception: CustomRpcException, host: ArgumentsHost): Observable<any> {
    return throwError(() => {
      return {
        message: exception.message,
        status: exception.status,
        data: null,
      };
    });
  }
}
  • Auth 마이크로서비스의 AuthController.ts
@UseFilters(new AllRPCExceptionFilter()) // Custom Filter를 적용
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @MessagePattern({ cmd: 'login' })
  async login(@Payload() loginDto: LoginDto): Promise<any> {
    return this.authService.login(loginDto);
  }
  // ...
}
  • API Gateway의 AuthController.ts
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('/login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
  // ...
}
  • API Gateway의 AuthService.ts
@Injectable()
export class AuthService {
  constructor(
  @Inject(MICRO_SERVICES.AUTH.NAME)  // Auth Microservice 주입
   private readonly authClient: ClientProxy, 
  ) {}

  public login(loginDto: LoginDto) {
    return this.authClient
      .send({ cmd: 'login' }, loginDto)
      .pipe(catchError((err) => of(err)));  // 마이크로서비스의 Custom Filter가 던진 에러를 다시 받아서 처리
  }
}
  • 예상 응답
{
  "statusCode": 400,
  "message": "Invalid Provider"
}
  • 실제 응답 (POST 요청은 자동으로 201 코드를 리턴)
    에러가 발생한 상황임에도 불구하고 http status code200번대인 모습을 확인할 수 있습니다.

문제점 개선하기

지금까지 마이크로서비스에서 발생한 에러를 API Gateway로 전달하고, 이를 클라이언트에게 전달하기 위해 시도한 과정에 대해 알아보았습니다. 이미 해결했거나 이제 해결해야할 문제는 다음과 같습니다.

  1. 마이크로서비스에서 발생한 에러 내용을 클라이언트에게 전달 - 해결

  2. 마이크로서비스에서 에러가 발생한다면 클라이언트에게 응답할 때, http status code를 적절하게 전달

  3. API Gateway 단에서 일일이 마이크로서비스가 던진 에러를 핸들링 하는게 아니라 일괄적으로 처리

Custom Exception Filter 수정하기

기존 throwError() 내부에서 다시 CustomRes 형태로 응답하던 코드를 바로 CustomRpcException을 반환하도록 수정했습니다.

@Catch(CustomRpcException)
export default class CustomRpcExceptionFilter implements RpcExceptionFilter<CustomRpcException>
{
  catch(
    exception: CustomRpcException,
    host: ArgumentsHost,
  ): Observable<CustomRpcException> {
		/* 기존 코드
		return throwError(() => {
      return {
        message: exception.message,
        status: exception.status,
        data: null,
      };
    });
		*/
    return throwError(() => exception);
  }
}

마이크로서비스에 Exception Filter 적용하기

CustomRpcExceptionRpcException을 상속받았기 때문에 앞서 만든 Custom Exception Filter를 적용해줘야 CustomRpcException 형태로 API Gateway로 전달됩니다.

필터를 적용하지 않는다면 RpcException 형태로 전달되기 때문에 아래 설명할 Global Filter에서 status를 읽을 때 에러가 발생합니다.

아래 코드는 Auth 마이크로서비스의 main.ts입니다.

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(...);
	// ...
  app.useGlobalFilters(new CustomRpcExceptionFilter());
	// ...
}
bootstrap();

Global Filter 작성하기

기존 API Gateway에서 일일이 마이크로서비스가 던진 Exception을 핸들링하는게 아니라 일괄적으로 처리할 수 있도록 Custom Filter를 만들었습니다. 또한 Exception이 발생한 상황에서도 API Gateway가 클라이언트에게 마이크로서비스에서 지정한 http status code를 넘길 수 있도록 했습니다.

아래 코드는 스택오버플로우를 참고하였습니다.

@Catch()
export class AllGlobalExceptionsFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: ICustomRpcException, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost;
    const ctx = host.switchToHttp();

    const httpStatus = exception.status
      ? exception.status
      : HttpStatus.INTERNAL_SERVER_ERROR;

    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
      message: exception.message,
    };

    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}

API Gateway에 Global Filter 적용하기

기존 API Gateway의 Service 레이어에서 일일이 마이크로서비스가 던진 Exception을 핸들링하던 코드를 삭제하고, Controller 레이어에 Global Filter를 적용하였습니다.

  • API Gateway의 AuthService.ts 수정
@Injectable()
export class AuthService {
  constructor(
  @Inject(MICRO_SERVICES.AUTH.NAME)
   private readonly authClient: ClientProxy,
  ) {}
  public login(loginDto: LoginDto) {
    return this.authClient
      .send({ cmd: 'login' }, loginDto)
    //  .pipe(catchError((err) => of(err)));  Exception을 받아서 처리하는 부분 삭제
  }
  • API Gateway의 AuthController.ts에 Global Filter 적용
@UseFilters(AllGlobalExceptionsFilter)  // Global Filter를 적용한 모습
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('/login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
  // ...
}
  • 예상 응답
{
  "statusCode": 400,
  "message": "Invalid Provider"
}
  • 실제 응답
    발생한 에러 메시지와 http status code 모두 제대로 응답하는 모습을 확인할 수 있습니다.

마치며

해당 이슈를 다루는 내내 ‘개발 기간이 얼마 남지않은 상황에서 비지니스 로직 외적인 부분에 너무 많은 시간을 소모하는건 아닐까?’ 라는 생각을 했습니다. 하지만 클라이언트와 통신하는 과정에 있어 올바른 status code를 응답하는 부분은 꼭 필요하다고 생각했습니다. 또한 API Gateway는 클라이언트로부터 들어온 요청을 각각의 마이크로서비스에게 라우팅하는 것이 주 역할인데 여기에 에러를 핸들링하는 로직이 포함되는 것은 적절하지 않다는 판단을 내렸습니다.

이번 경험을 통해 Nest.js의 라이프 사이클을 깊이있게 다룰 수 있었습니다. 또한 RpcExceptionHttp Response 형태로 바꾸는 것 뿐만 아니라 마이크로서비스끼리 통신을 하는 경우에도 Custom Filter를 활용할 수 있을 것입니다.

profile
https://github.com/sophoca

0개의 댓글