[NestJS 응답/요청 변환기] 내부 camelCase, 외부 snake_case 통일하기

2025. 5. 22. 13:03·Nest.js
728x90
반응형

✅ 목적

NestJS 서버에서는 내부 코드를 camelCase, 외부 응답/요청은 snake_case로 통일하기 위해 다음과 같은 구조를 적용하였다.

  • 요청(Request): snake_case → camelCase 자동 변환
  • 응답(Response): camelCase → snake_case 자동 변환
  • 모든 변환은 Interceptor, Pipe 기반으로 구성

📌 요청 변환: SnakeToCamelPipe

클라이언트에서 snake_case로 요청을 보냈을 때, 서버 내부에서는 이를 camelCase로 받아야 한다.
이를 위해 camelcase-keys를 사용한 커스텀 Pipe를 구현하였다.

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { default as camelcaseKeys } from 'camelcase-keys';

@Injectable()
export class SnakeToCamelPipe implements PipeTransform {
  async transform(value: any, metadata: ArgumentMetadata) {
    if (value && typeof value === 'object') {
      const containsFile = this.containsFileObject(value);
      if (containsFile) return value;

      // size_* 패턴 키만 별도 보존 후 camelcase-keys 실행
      const sizeKeyPattern = /^size_\d{2,3}_\d{2,3}$/;
      const preservedSizeKeys = this.extractSizeKeys(value, sizeKeyPattern);

      // 나머지 키 camelCase 변환 (size_* 키 제외)
      const camelized = camelcaseKeys(value, {
        deep: true,
        exclude: [sizeKeyPattern],
      });

      // 보존한 size_* 키를 다시 덮어씌움
      Object.assign(camelized, preservedSizeKeys);

      return camelized;
    }
    return value;
  }

  private extractSizeKeys(obj: any, pattern: RegExp) {
    const result: Record<string, any> = {};
    Object.entries(obj).forEach(([key, val]) => {
      if (pattern.test(key)) {
        result[key] = val;
      } else if (typeof val === 'object' && val !== null) {
        Object.assign(result, this.extractSizeKeys(val, pattern));
      }
    });
    return result;
  }

  private containsFileObject(obj: any, seen = new Set()): boolean {
    if (obj === null || typeof obj !== 'object') return false;
    if (seen.has(obj)) return false;
    seen.add(obj);

    if (Buffer.isBuffer(obj)) return true;

    if ('buffer' in obj && 'originalname' in obj && 'mimetype' in obj) {
      return true;
    }

    if (Array.isArray(obj)) {
      return obj.some((item) => this.containsFileObject(item, seen));
    }

    return Object.values(obj).some((val) => this.containsFileObject(val, seen));
  }
}

⚠️ size_* 예외 처리 이유

{
  "template_id": "1234",
  "size_100_120": {...},
  "size_80_150": {...}
}

자동 변환 시:

원래 키 자동 변환 결과
size_100_120 size100120
size_80_150 size80150

이로 인해 다음과 같은 문제가 발생함:

  • 클라이언트에서 기대하는 키와 실제 서버에서 처리되는 키가 달라짐
  • 직렬화/역직렬화 오류 발생
  • 차트 좌표 매핑 로직에서 key mismatch 발생

→ 정규표현식 /^size_\d{2,3}_\d{2,3}$/로 사전 필터링하여 해당 키는 보존 후 다시 병합하는 방식으로 처리


📦 응답 변환: CommonResponseInterceptor

모든 응답은 다음 구조로 통일한다:

{
  "code": 200,
  "message": "ok",
  "data": {
    "template_id": "1234",
    "created_at": "2024-05-16 14:33:00"
  }
}

이를 위해 다음과 같은 재귀 변환 함수 사용:

import * as _ from 'lodash';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';

dayjs.extend(utc);
dayjs.extend(timezone);

function keysToSnakeCase(obj: any): any {
  if (obj instanceof Date) {
    return dayjs(obj).format('YYYY-MM-DD HH:mm:ss');
  }

  if (Array.isArray(obj)) {
    return obj.map((v) => keysToSnakeCase(v));
  }

  if (obj && typeof obj === 'object') {
    return Object.keys(obj).reduce((acc, key) => {
      const snakeKey = _.snakeCase(key);
      acc[snakeKey] = keysToSnakeCase(obj[key]);
      return acc;
    }, {});
  }

  return obj;
}

Interceptor 적용 코드:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { SKIP_COMMON_RESPONSE_KEY } from '../decorator/skip-common-response.decorator';

@Injectable()
export class CommonResponseInterceptor<T> implements NestInterceptor<T, any> {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const skip = this.reflector.getAllAndOverride<boolean>(
      SKIP_COMMON_RESPONSE_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (skip) {
      return next.handle();
    }

    return next.handle().pipe(
      map((data) => ({
        code: 200,
        message: 'ok',
        data: keysToSnakeCase(data),
      })),
    );
  }
}

✅ 정리

항목 적용 방식 위치
요청 snake_case → camelCase camelcase-keys 사용 SnakeToCamelPipe
응답 camelCase → snake_case lodash.snakeCase 재귀 변환 CommonResponseInterceptor
특수 키(size_*) 예외 처리 정규표현식으로 필터 후 병합 Pipe 내부
  • 내부 로직은 camelCase로 일관되게 유지
  • 외부 통신은 snake_case로 표준화
  • 예외 키(size_100_120)는 자동 변환 시 문제가 발생하므로 수동 처리
  • Swagger에서도 예시는 snake_case로 제공하되 내부 DTO는 camelCase로 유지

📌 참고 패키지

  • camelcase-keys
  • lodash
  • dayjs

728x90
반응형

'Nest.js' 카테고리의 다른 글

NestJS 엔티티, 왜 다 public이야? – Spring 개발자의 궁금증 해결  (1) 2025.05.25
[NestJS 인증 흐름] Jwt 토큰 기반 인증 요청 흐름 정리  (1) 2025.05.22
[Nest.js] 제로베이스에서 1주일, 실전까지 반나절  (1) 2025.05.22
Nest.js 개발에 필요한 주요 CLI 명령어와 활용법  (1) 2025.05.22
Nest CLI란 무엇인가?  (0) 2025.05.22
'Nest.js' 카테고리의 다른 글
  • NestJS 엔티티, 왜 다 public이야? – Spring 개발자의 궁금증 해결
  • [NestJS 인증 흐름] Jwt 토큰 기반 인증 요청 흐름 정리
  • [Nest.js] 제로베이스에서 1주일, 실전까지 반나절
  • Nest.js 개발에 필요한 주요 CLI 명령어와 활용법
highgarden
highgarden
커밋 하나하나가 쌓여 커다란 정원이 되는 중입니다. 하루하루 정성껏 심어가는 중 https://github.com/highgarden7
  • highgarden
    커밋심는 정원
    highgarden
  • 전체
    오늘
    어제
    • 분류 전체보기 (37)
      • ai (1)
      • devops (2)
      • Nest.js (14)
      • linux (14)
      • 네트워크 (6)
      • git (0)
      • aws (0)
      • docker (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • github
  • 공지사항

  • 인기 글

  • 태그

    vercel
    nestjs
    Chat GPT
    IP
    Java
    Linux
    네트워크
    springboot
    E2E
    githib action
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
highgarden
[NestJS 응답/요청 변환기] 내부 camelCase, 외부 snake_case 통일하기
상단으로

티스토리툴바