NestJS: typeerror: do not know how to serialize a bigint

Postgres의 BIGSERIAL 타입을 Prisma로 가져오면 BigInt 타입이 됩니다. 이걸 #Nest.JS 컨트롤러에서 그대로 반환하면 이런 오류를 보게 되죠.

typeerror: do not know how to serialize a bigint

JSON.stringify() 함수에서 왠지 bigint 타입을 지원하지 않기 때문에 발생하는 문제라고 하는데, bigint를 반환하기 전에 string 같은 걸로 바꿔 주면 간단히 해결되겠지만 그렇다고 해서 bigint가 들어가는 모든 모델에 대해 타입 변환 함수 같은 걸 작성해 주기는 귀찮죠.

그런고로 오늘의 #뚝딱뚝딱 은 응답 DTO의 모든 bigint를 알아서 string으로 바꿔 주는 방법입니다. 더 나은 해결책이 있을 것 같기는 한데, 저는 이렇게 해결했어요.

Interceptor가 뭐예요?

인터셉터는 NestJS 애플리케이션에서 요청과 응답을 가로채거나 변형할 수 있도록 하는 클래스로, @Injectable() 데코레이터를 사용해야 하며 NestInterceptor 인터페이스를 구현해야 합니다.

NestInterceptor 인터페이스를 구현하려면 intercept(context: ExecutionContext, next: CallHandler) 함수를 구현해야 하는데, 우리는 CallHandler만 쓸 거예요. CallHandler.handle()을 써서 루트 핸들러를 호출할 수 있고, 그 결과를 pipe()로 조작할 수 있습니다. 그러니까 우리는 루트 핸들러가 실행되어 나온 결과에 bigint가 있으면 모조리 string으로 바꿔 주는 인터셉터를 구현하면 되는 거죠.

ExecutionContext에 대해서는 문서를 참조합시다.

bigint 잡기

그냥 객체의 모든 키를 재귀적으로 순회하면서 typeof data[key] === 'bigint'data[key] = data[key].toString()을 실행해 줍시다.

convertBigIntToString(data: any): any {
// 배열 타입이라면...
  if (Array.isArray(data)) {
// 배열 안의 모든 요소에 대해 실행
    return data.map((item) => this.convertBigIntToString(item));
  } else if (data !== null && typeof data === 'object') {
// 오브젝트 안의 모든 필드에 대해...
    Object.keys(data).forEach((key) => {
// 필드의 타입이 bigint라면...
      if (typeof data[key] === 'bigint') {
// string으로 바꿔 주기
        data[key] = data[key].toString();
// 필드의 타입이 object라면...
      } else if (typeof data[key] === 'object') {
// nested object에 대해 같은 작업 반복
        convertBigIntToString(data[key]);
      }
    });
  }
  return data;
}

인터셉터 구현하기

next.handle().pipe()에서 pipe()에 들어가는 인자는 rxjs.OperatorFunction인데, rxjsObservable을 인자로 받는 함수입니다. 여기서 Observable로는 출력 스트림이 들어갑니다. 자세한 설명은 접어두고, 우리는 핸들러의 결과에 convertBigIntToString()을 실행하면 되니까 rxjs/operators에서 map 함수를 가져옵시다.

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class BigIntInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(map((data) => this.convertBigIntToString(data)));
  }

  private convertBigIntToString(data: any): any {
    if (Array.isArray(data)) {
      return data.map((item) => this.convertBigIntToString(item));
    } else if (data !== null && typeof data === 'object') {
      Object.keys(data).forEach((key) => {
        if (typeof data[key] === 'bigint') {
          data[key] = data[key].toString();
        } else if (typeof data[key] === 'object') {
          this.convertBigIntToString(data[key]);
        }
      });
    }
    return data;
  }
}

인터셉터 적용하기

@UseInterceptors() 데코레이터를 사용해서 컨트롤러나 메소드에 Interceptor를 적용할 수 있습니다.

// hello.dto.ts
// bigint가 request에 있는 경우 ValidationPipe({transform: true})를 사용하기만 해도 됩니다.
export class HelloRequestDto {
  @ApiProperty()
  hello: bigint;
}

// bigint가 response에 있는 경우 JSON.serialize()에서 bigint를 처리하지 못하므로 인터셉터가 필요합니다.
export class HelloResponseDto {
  @ApiProperty()
  goodbye: bigint;
}

// hello.controller.ts
@Get('hello')
@UseInterceptors(BigIntInterceptor)
helloController(
  @Query() body: HelloRequestDto
): HelloResponseDto {
  return ({ goodbye: body.hello })
}

app.useGlobalInterceptors()를 쓰면 전역 범위에 적용할 수도 있습니다.

// main.ts
async function bootstrap() {
  /* ... */
  app.useGlobalInterceptors(
    new BigIntInterceptor(),
  );
  /* ... */
}

이제 bigint가 포함된 요청을 전송하면 결과가 string으로 변환되어 날아오는 것을 확인할 수 있습니다.


Dani Soohan Park (@heartade)

Follow this blog at Fediverse: @heartade@blog.heartade.dev

Follow my shorter shoutouts at Fediverse: @heartade@social.silicon.moe

Follow me at Bluesky: @heartade.dev