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 code
가 200
번대로 전달되어 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,
};
});
}
}
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);
}
// ...
}
AuthController.ts
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('/login')
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
// ...
}
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 code
는 200
번대인 모습을 확인할 수 있습니다.지금까지 마이크로서비스에서 발생한 에러를 API Gateway로 전달하고, 이를 클라이언트에게 전달하기 위해 시도한 과정에 대해 알아보았습니다. 이미 해결했거나 이제 해결해야할 문제는 다음과 같습니다.
마이크로서비스에서 발생한 에러 내용을 클라이언트에게 전달- 해결마이크로서비스에서 에러가 발생한다면 클라이언트에게 응답할 때,
http status code
를 적절하게 전달API Gateway 단에서 일일이 마이크로서비스가 던진 에러를 핸들링 하는게 아니라 일괄적으로 처리
기존 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);
}
}
CustomRpcException
은 RpcException
을 상속받았기 때문에 앞서 만든 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();
기존 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의 Service 레이어에서 일일이 마이크로서비스가 던진 Exception
을 핸들링하던 코드를 삭제하고, Controller 레이어에 Global Filter를 적용하였습니다.
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을 받아서 처리하는 부분 삭제
}
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의 라이프 사이클을 깊이있게 다룰 수 있었습니다. 또한 RpcException
을 Http Response
형태로 바꾸는 것 뿐만 아니라 마이크로서비스끼리 통신을 하는 경우에도 Custom Filter를 활용할 수 있을 것입니다.