Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions src/global/filters/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BaseExceptionFilter, type AbstractHttpAdapter } from '@nestjs/core';
import type { Request, Response } from 'express';

import { HttpExceptionFilter } from '@/global/filters/global-exception.filter';
import { GraphQLExceptionFilter } from '@/global/filters/graphql-exception.filter';
import { CustomLoggerService } from '@/global/logger/custom-logger.service';

jest.mock('@/global/logger/logger', () => ({
Expand Down Expand Up @@ -55,12 +56,17 @@ function mockHost(req: Request, res: Response) {
describe('HttpExceptionFilter', () => {
let filter: HttpExceptionFilter;
let logger: CustomLoggerService;
let gqlFilter: GraphQLExceptionFilter;

beforeEach(() => {
logger = new CustomLoggerService();
logger.txError = jest.fn();
gqlFilter = new GraphQLExceptionFilter(logger);
gqlFilter.format = jest
.fn()
.mockReturnValue(new Error('mock graphql error'));
const adapter = {} as AbstractHttpAdapter;
filter = new HttpExceptionFilter(adapter, logger);
filter = new HttpExceptionFilter(adapter, logger, gqlFilter);
});

it('BadRequestException이면 에러 응답을 반환한다', () => {
Expand Down Expand Up @@ -149,27 +155,40 @@ describe('HttpExceptionFilter', () => {
);
});

it('GraphQL 컨텍스트(getType !== "http")에서는 BaseExceptionFilter.catch로 위임하고 로깅을 스킵한다', () => {
it('GraphQL 컨텍스트에서는 GraphQLExceptionFilter.format 에 위임하고 GraphQL 에러를 반환한다', () => {
const host = {
getType: () => 'graphql',
} as never;
const exception = new Error('gql');

const result = filter.catch(exception, host);

// 1) gqlFilter.format 에 그대로 위임
expect(gqlFilter.format).toHaveBeenCalledTimes(1);
expect(gqlFilter.format).toHaveBeenCalledWith(exception, host);
// 2) format 결과를 그대로 반환 (Apollo 가 응답에 포함)
expect(result).toBe(
(gqlFilter.format as jest.Mock).mock.results[0].value as Error,
);
// 3) HTTP 경로 side effect 없음
expect(logger.txError).not.toHaveBeenCalled();
});

it('http/graphql 외 컨텍스트는 BaseExceptionFilter.catch 로 위임한다', () => {
const superCatch = jest
.spyOn(BaseExceptionFilter.prototype, 'catch')
.mockImplementation(() => undefined);

try {
const host = {
getType: () => 'graphql',
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({}),
}),
getType: () => 'rpc',
} as never;
const exception = new Error('gql');
const exception = new Error('rpc');

filter.catch(exception, host);

// 1) super.catch로 정확히 위임됐는지 — exception/host 원본 그대로 전달
expect(superCatch).toHaveBeenCalledTimes(1);
expect(superCatch).toHaveBeenCalledWith(exception, host);
// 2) HTTP 경로의 side effect가 일어나지 않았는지
expect(gqlFilter.format).not.toHaveBeenCalled();
expect(logger.txError).not.toHaveBeenCalled();
} finally {
superCatch.mockRestore();
Expand Down
18 changes: 15 additions & 3 deletions src/global/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ArgumentsHost, BadRequestException, Catch } from '@nestjs/common';
import { AbstractHttpAdapter, BaseExceptionFilter } from '@nestjs/core';
import type { GqlContextType } from '@nestjs/graphql';
import type { Request, Response } from 'express';
import type { GraphQLError } from 'graphql';

import { resolveMessage, resolveStatus } from '@/common/helpers/error.helper';
import {
Expand All @@ -14,27 +16,37 @@ import {
formatValidationError,
isValidationErrorLike,
} from '@/common/utils/validation';
import { GraphQLExceptionFilter } from '@/global/filters/graphql-exception.filter';
import { CustomLoggerService } from '@/global/logger/custom-logger.service';
import { LogContext } from '@/global/types/log.type';
import { ApiResponseTemplate } from '@/global/types/response';

/**
* REST HTTP 요청에 대한 전역 예외 필터.
* - GraphQL 컨텍스트는 여기에서 처리하지 않고, GraphQL 에러 핸들링에 맡긴다.
* 전역 예외 필터.
* - HTTP 컨텍스트: 자체 처리 (구조화 로그 + 표준 응답 포맷)
* - GraphQL 컨텍스트: GraphQLExceptionFilter 에 위임 (extensions 부착된 GraphQLError 반환)
*
* NestJS 글로벌 필터는 host type 별로 1 회만 매칭되므로 컨텍스트별 분기는 본 필터에서 수행한다.
*/
@Catch()
export class HttpExceptionFilter extends BaseExceptionFilter {
constructor(
httpAdapter: AbstractHttpAdapter,
private readonly logger: CustomLoggerService,
private readonly gqlFilter: GraphQLExceptionFilter,
) {
super(httpAdapter);
}

/**
* 발생한 예외를 가로채어 구조화 로그를 기록하고, 표준 API 응답 포맷으로 변환한다.
* GraphQL context 인 경우 GraphQLError 를 반환해 Apollo 가 응답에 포함시키도록 한다.
*/
override catch(exception: unknown, host: ArgumentsHost): void {
override catch(exception: unknown, host: ArgumentsHost): GraphQLError | void {
if (host.getType<GqlContextType>() === 'graphql') {
return this.gqlFilter.format(exception, host);
}

if (host.getType() !== 'http') {
super.catch(exception, host);
return;
Expand Down
183 changes: 183 additions & 0 deletions src/global/filters/graphql-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { ArgumentsHost } from '@nestjs/common';
import { GraphQLError } from 'graphql';

import {
GraphQLExceptionFilter,
mapStatusToCode,
} from '@/global/filters/graphql-exception.filter';
import { CustomLoggerService } from '@/global/logger/custom-logger.service';

jest.mock('@/global/logger/logger', () => ({
customLogger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
verbose: jest.fn(),
},
}));

function mockHost(
fieldName = 'sellerMyStore',
operation: 'query' | 'mutation' = 'query',
reqHeaders: Record<string, string> = {},
): ArgumentsHost {
// GqlArgumentsHost.create(host) reads host.getArgs() — 4-tuple [root, args, context, info]
const info = {
fieldName,
operation: { operation },
path: { key: fieldName },
parentType: { toString: () => 'Query' },
};
const context = {
req: {
headers: reqHeaders,
socket: { remoteAddress: '127.0.0.1' },
},
};
return {
getType: () => 'graphql',
getArgs: () => [null, {}, context, info],
getArgByIndex: (i: number) => [null, {}, context, info][i],
switchToHttp: () => ({}),
switchToRpc: () => ({}),
switchToWs: () => ({}),
} as unknown as ArgumentsHost;
}

describe('GraphQLExceptionFilter', () => {
let filter: GraphQLExceptionFilter;
let logger: CustomLoggerService;

beforeEach(() => {
logger = new CustomLoggerService();
logger.txError = jest.fn();
filter = new GraphQLExceptionFilter(logger);
});

describe('mapStatusToCode', () => {
it.each([
[HttpStatus.BAD_REQUEST, 'BAD_USER_INPUT'],
[HttpStatus.UNAUTHORIZED, 'UNAUTHENTICATED'],
[HttpStatus.FORBIDDEN, 'FORBIDDEN'],
[HttpStatus.NOT_FOUND, 'NOT_FOUND'],
[HttpStatus.INTERNAL_SERVER_ERROR, 'INTERNAL_SERVER_ERROR'],
[418, 'INTERNAL_SERVER_ERROR'],
])('%i → %s', (status, expected) => {
expect(mapStatusToCode(status)).toBe(expected);
});
});

describe('format', () => {
it.each([
[
new BadRequestException('bad input'),
400,
'BAD_USER_INPUT',
'bad input',
],
[
new UnauthorizedException('no token'),
401,
'UNAUTHENTICATED',
'no token',
],
[new ForbiddenException('nope'), 403, 'FORBIDDEN', 'nope'],
[new NotFoundException('missing'), 404, 'NOT_FOUND', 'missing'],
])(
'%p → statusCode=%i, code=%s, message=%s',
(exception, status, code, message) => {
const host = mockHost();
const result = filter.format(exception, host);

expect(result).toBeInstanceOf(GraphQLError);
expect(result.message).toBe(message);
expect(result.extensions).toEqual(
expect.objectContaining({
code,
statusCode: status,
operation: 'query',
fieldName: 'sellerMyStore',
}),
);
},
);

it('일반 Error 는 INTERNAL_SERVER_ERROR (500) 으로 매핑된다', () => {
const host = mockHost();
const result = filter.format(new Error('boom'), host);

expect(result.extensions).toEqual(
expect.objectContaining({
code: 'INTERNAL_SERVER_ERROR',
statusCode: 500,
}),
);
});

it('Error 가 아닌 throw (예: string) 도 INTERNAL_SERVER_ERROR 로 안전하게 매핑된다', () => {
// stack 추출 분기에서 exception !instanceof Error 경로 커버
const host = mockHost();
const result = filter.format('plain string thrown', host);

expect(result.extensions).toEqual(
expect.objectContaining({
code: 'INTERNAL_SERVER_ERROR',
statusCode: 500,
}),
);
// resolveMessage 가 fallback 'Internal Server Error' 반환
expect(result.message).toBe('Internal Server Error');
});

it('extensions.requestId 에 incoming x-request-id 를 사용한다', () => {
const host = mockHost('sellerProducts', 'query', {
'x-request-id': 'req-abc-123',
});
const result = filter.format(new BadRequestException('x'), host);

expect(result.extensions?.requestId).toBe('req-abc-123');
});

it('x-request-id 가 없으면 새 requestId 가 생성된다 (UUID 형태)', () => {
const host = mockHost();
const result = filter.format(new BadRequestException('x'), host);

expect(typeof result.extensions?.requestId).toBe('string');
expect(
(result.extensions?.requestId as string).length,
).toBeGreaterThanOrEqual(8);
});

it('mutation operation 도 정확히 반영된다', () => {
const host = mockHost('sellerCreateProduct', 'mutation');
const result = filter.format(new BadRequestException('x'), host);

expect(result.extensions).toEqual(
expect.objectContaining({
operation: 'mutation',
fieldName: 'sellerCreateProduct',
}),
);
});

it('txError 로 구조화 로그를 남긴다', () => {
const host = mockHost('sellerProducts');
filter.format(new BadRequestException('bad'), host);

expect(logger.txError).toHaveBeenCalledTimes(1);
expect(logger.txError).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ statusCode: 400, message: 'bad' }),
}),
);
});
});
});
82 changes: 82 additions & 0 deletions src/global/filters/graphql-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ArgumentsHost, HttpStatus, Injectable } from '@nestjs/common';
import { GqlArgumentsHost } from '@nestjs/graphql';
import type { Request } from 'express';
import { GraphQLError, type GraphQLResolveInfo } from 'graphql';

import { resolveMessage, resolveStatus } from '@/common/helpers/error.helper';
import {
buildGraphqlRequestMeta,
calculateDuration,
ensureRequestTracking,
resolveUserId,
} from '@/common/utils/request-context';
import { CustomLoggerService } from '@/global/logger/custom-logger.service';
import { LogContext } from '@/global/types/log.type';

/**
* HTTP status code → GraphQL extensions.code 매핑.
* Apollo 권장 표준 코드를 따른다. 알 수 없는 status 는 INTERNAL_SERVER_ERROR.
*/
const STATUS_TO_CODE: Record<number, string> = {
[HttpStatus.BAD_REQUEST]: 'BAD_USER_INPUT',
[HttpStatus.UNAUTHORIZED]: 'UNAUTHENTICATED',
[HttpStatus.FORBIDDEN]: 'FORBIDDEN',
[HttpStatus.NOT_FOUND]: 'NOT_FOUND',
};

export function mapStatusToCode(status: number): string {
return STATUS_TO_CODE[status] ?? 'INTERNAL_SERVER_ERROR';
}

/**
* GraphQL 컨텍스트 전용 예외 포맷터.
*
* NestJS 글로벌 필터는 host type 별로 1 회만 매칭되므로 별도 글로벌 등록 대신
* `HttpExceptionFilter` 가 graphql context 일 때 본 클래스에 위임한다.
*
* extensions:
* - code : BAD_USER_INPUT / UNAUTHENTICATED / FORBIDDEN / NOT_FOUND / INTERNAL_SERVER_ERROR
* - statusCode : 400 / 401 / 403 / 404 / 500
* - requestId : x-request-id (트래킹용)
* - operation : query / mutation / subscription
* - fieldName : 루트 필드명
*/
@Injectable()
export class GraphQLExceptionFilter {
constructor(private readonly logger: CustomLoggerService) {}

format(exception: unknown, host: ArgumentsHost): GraphQLError {
const gqlHost = GqlArgumentsHost.create(host);
const info = gqlHost.getInfo<GraphQLResolveInfo>();
const ctx = gqlHost.getContext<{ req: Request }>();
const req = ctx.req;

const { requestId, startTime } = ensureRequestTracking(req);
const userId = resolveUserId(req);
const gqlRequest = buildGraphqlRequestMeta(info, req);

const status = resolveStatus(exception);
const message = resolveMessage(exception);
const stack = exception instanceof Error ? exception.stack : undefined;
const duration = calculateDuration(startTime);

this.logger.txError({
userId,
requestId,
request: gqlRequest,
error: { statusCode: status, message, stack },
processingTimeInMs: duration,
context: LogContext.GRAPHQL,
});
Comment on lines +63 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid double-logging GraphQL resolver errors

For GraphQL errors thrown from resolvers/services, the globally registered GqlLoggingInterceptor already records a txError in its tap.error path before rethrowing, and then this filter records another txError for the same request/error here. That means ordinary resolver failures will produce duplicate transaction-error logs with the same requestId/field, which can inflate error counts and confuse alerting; either the interceptor or this filter should own error logging for that path.

Useful? React with 👍 / 👎.


return new GraphQLError(message, {
extensions: {
code: mapStatusToCode(status),
statusCode: status,
requestId,
operation: info.operation.operation,
fieldName: info.fieldName,
},
});
}
}
Loading
Loading