From 9b854fa2ad38cbdba36face55bedb451f0bafaa7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 28 May 2026 04:49:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(filter):=20GraphQL=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=95=84=ED=84=B0=20(P1-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQL 컨텍스트의 에러에 extensions 을 부착해 FE 에서 분기/추적 가능하게 한다. 협의 #4 (비파괴 확장) 대상. - 신규 GraphQLExceptionFilter (helper class) - format(exception, host) → GraphQLError with extensions - extensions: code / statusCode / requestId / operation / fieldName - HTTP status → GraphQL code 매핑 (mapStatusToCode) - 400 BAD_USER_INPUT / 401 UNAUTHENTICATED / 403 FORBIDDEN - 404 NOT_FOUND / 그 외 INTERNAL_SERVER_ERROR - txError 로 구조화 로그(LogContext.GRAPHQL) 기록 - HttpExceptionFilter: graphql context 시 GraphQLExceptionFilter 에 위임 - NestJS 글로벌 필터는 host type 별 1 회만 매칭되므로 단일 진입점에서 분기 - main.ts: GraphQLExceptionFilter 인스턴스를 HttpExceptionFilter 에 주입 FE 영향: 비파괴 확장. 기존 errors[].message 는 그대로, extensions 필드만 추가. --- .../filters/global-exception.filter.spec.ts | 41 ++-- src/global/filters/global-exception.filter.ts | 18 +- .../filters/graphql-exception.filter.spec.ts | 183 ++++++++++++++++++ .../filters/graphql-exception.filter.ts | 82 ++++++++ src/main.ts | 8 +- 5 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 src/global/filters/graphql-exception.filter.spec.ts create mode 100644 src/global/filters/graphql-exception.filter.ts diff --git a/src/global/filters/global-exception.filter.spec.ts b/src/global/filters/global-exception.filter.spec.ts index 1d7ec50..ac99d3d 100644 --- a/src/global/filters/global-exception.filter.spec.ts +++ b/src/global/filters/global-exception.filter.spec.ts @@ -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', () => ({ @@ -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이면 에러 응답을 반환한다', () => { @@ -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(); diff --git a/src/global/filters/global-exception.filter.ts b/src/global/filters/global-exception.filter.ts index be6f37d..808b6f0 100644 --- a/src/global/filters/global-exception.filter.ts +++ b/src/global/filters/global-exception.filter.ts @@ -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 { @@ -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() === 'graphql') { + return this.gqlFilter.format(exception, host); + } + if (host.getType() !== 'http') { super.catch(exception, host); return; diff --git a/src/global/filters/graphql-exception.filter.spec.ts b/src/global/filters/graphql-exception.filter.spec.ts new file mode 100644 index 0000000..97afc75 --- /dev/null +++ b/src/global/filters/graphql-exception.filter.spec.ts @@ -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 = {}, +): 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' }), + }), + ); + }); + }); +}); diff --git a/src/global/filters/graphql-exception.filter.ts b/src/global/filters/graphql-exception.filter.ts new file mode 100644 index 0000000..57d7dd8 --- /dev/null +++ b/src/global/filters/graphql-exception.filter.ts @@ -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 = { + [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(); + 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, + }); + + return new GraphQLError(message, { + extensions: { + code: mapStatusToCode(status), + statusCode: status, + requestId, + operation: info.operation.operation, + fieldName: info.fieldName, + }, + }); + } +} diff --git a/src/main.ts b/src/main.ts index 94163da..9936e37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import cookieParser from 'cookie-parser'; import { AppModule } from '@/app.module'; import { HttpExceptionFilter } from '@/global/filters/global-exception.filter'; +import { GraphQLExceptionFilter } from '@/global/filters/graphql-exception.filter'; import { ApiResponseInterceptor } from '@/global/interceptors/api-response.interceptor'; import { GqlLoggingInterceptor } from '@/global/interceptors/gql-logging.interceptor'; import { HttpLoggingInterceptor } from '@/global/interceptors/http-logging.interceptor'; @@ -78,8 +79,13 @@ async function bootstrap(): Promise { new ApiResponseInterceptor(new Set(['/health', '/health/profiles'])), ); + const gqlExceptionFilter = new GraphQLExceptionFilter(logger); app.useGlobalFilters( - new HttpExceptionFilter(httpAdapterHost.httpAdapter, logger), + new HttpExceptionFilter( + httpAdapterHost.httpAdapter, + logger, + gqlExceptionFilter, + ), ); // 임시 해제