(5)

[NestJS + OpenAI] Microsoft Azure OpenAI 연동 방법

오랜만에 블로그 포스팅을 써보는 것 같다. 조금 바빴다는 핑계로..ㅎㅎ 이제는 다시 포스팅을 조금씩 써보려고 한다. 오늘은 지금까지 애를 많이 먹었던 NestJS 프레임워크를 활용해서 Microsoft Azure OpenAI 연동 방법과 연동 후 기대에 부응하지 못했던 점을 공유드려보려고 한다. NestJS를 가지고 Azure OpenAI를 연동하는 자료는 크게 없는 것 같아서 틈새시장을 노려보겠다 '__'1.  GPT 모델을 가지고 어떤 기능을 구현하려고 했는지?기획 유출이 될 수 있어 자세하게는 설명할 수 없지만, 민감한 사진과 프롬프트를 가지고 AI 모델에게 분석요청을 요청해서 해당 사진에 대해 분석 결과를 도출해내고, 해당 결과의 변화를 추적할 수 있는 기능을 만들고자 했다. 참고로, GPT 4모..

[NestJS] Type Safe Config Service

NestJS를 사용하면 환경변수(env)는 보통 @nestjs/config의 ConfigModule을 사용할 것이다.그렇지만 해당 라이브러리에서 제공하는 ConfigService를 기본으로 사용하면 undefined도 추론되고 해당 env에 그 key가 있는지 없는지도 파악할 수 없다. 그래서 이번 포스팅에서는 어떤 환경 변수들이 있는지 추론이 되고 undefined가 추론되지 않도록 코드를 작성해보려고 한다.필자는 해당 방법이 가독성도 좋은 것 같고 관리가 생각보다 잘 되는 것 같아서 이 방법을 계속 사용해보려고 한다. 기본적으로 ConfigService를 사용해봤다고 가정해보고 포스팅 작성을 진행해보겠다.app.config.ts 작성// src/configs/app.config.tsimport { r..

[NestJS] Request에 user 정보를 넣으실 때 any 타입으로 하시는 분들 많으시죠?

오늘은 포스팅을 2개나 작성한다..ㅎㅎ 그만큼 몰랐던 것들을 오늘은 좀 많이 알게 된 것 같아서 뿌듯해서 그런 것 같다 (사실 별건 아니지만) 이전 포스팅에서 strict 모드를 추가하면서 많은 에러가 발생했다고 언급했다. null parameter가 들어올 수 있는 가능성이 있는 코드들 등등을 수정하는 가운데 request의 user 변수의 type이 지정되지 않아서 발생한 문제가 있었다. 어떻게 request 안에 넣어놓은 user 데이터에 type을 부여할 까 고민하다가 좋은 글을 참고하게 되어서 조금 참고하고 작성해보려고 한다. 문제 상황 strict 모드에는 any 타입이 사용되는 경우 에러를 뱉는 옵션이 있다. 필자는 TypeScript로 개발을 하기 때문에 any 타입을 사용하게 되면 그 이..

[NestJS] TypeORM에서 skip과 take 버그 기록!

보통 많이 사용하는 createQueryBuilder에서 getRawMany 메서드와 getMany 메서드가 있다는 것을 Nest 개발자 분들은 알고 계실 것이다. skip과 take는 pagination 작업을 처리할 때 주로 적용하는 option이다. 하지만 skip과 take는 getRawMany에서 적용되지 않는다고 한다. TypeORM의 createQueryBuilder에서 getRawMany 메서드는 Raw Data를 반환하는데 이 때 limit와 offset을 적용시켜주어야 한다고 한다. 필자는 skip과 take이 문제가 있을지 전혀 상상하지 못하고 코드 레벨에서 실수가 있었는지 계속 봐서 시간이 많이 소요되었는데 다른 분들은 이 포스팅을 보면서 삽질을 안하셨으면 좋겠다는 생각이 든다! 좋..

[NestJS] Class-Validator 에러 메세지 커스텀하기!

최근에 몸이 너무 안좋고 무기력해서 아예 1주일 넘게 쉬어버렸다. 여러분 모두 감기조심... 자세조심.. 허리조심 하시길..! 그래서 그런지 오랜만에 블로그 포스팅을 시작으로 다시 열심히 준비해서 내년 초반기에 꼭 취업을 해보려고 한다! Nest에서 400번대 에러, 정확히는 Class-Validator의 에러 메세지는 배열로 나오는 것을 알고 계실 것이다. 그런데 프로젝트에서도 에러코드 + 단일 메세지 조합으로 Response를 달라고 하시는 개발자분이 계시고, 커뮤니티에서도 몇몇 분이 질문으로 올려주시더라. 그런데 구글링을 몇개 해봤는데 그런 자료들이 없는 것 같아서.. 필자가 글을 한번 올려볼까 한다. 참고로 아래 작성되는 방법은 필자의 뇌피셜로 구현을 한 것이라서,, 부정확할 수도 있다는 점 참고..

[NestJS + OpenAI] Microsoft Azure OpenAI 연동 방법

Framework/NestJS 2024. 11. 26. 16:26

오랜만에 블로그 포스팅을 써보는 것 같다. 조금 바빴다는 핑계로..ㅎㅎ 이제는 다시 포스팅을 조금씩 써보려고 한다.

 

오늘은 지금까지 애를 많이 먹었던 NestJS 프레임워크를 활용해서 Microsoft Azure OpenAI 연동 방법과 연동 후 기대에 부응하지 못했던 점을 공유드려보려고 한다. NestJS를 가지고 Azure OpenAI를 연동하는 자료는 크게 없는 것 같아서 틈새시장을 노려보겠다 '__'

1.  GPT 모델을 가지고 어떤 기능을 구현하려고 했는지?

기획 유출이 될 수 있어 자세하게는 설명할 수 없지만, 민감한 사진과 프롬프트를 가지고 AI 모델에게 분석요청을 요청해서 해당 사진에 대해 분석 결과를 도출해내고, 해당 결과의 변화를 추적할 수 있는 기능을 만들고자 했다.

 

참고로, GPT 4모델 이상부터는 Vision 기능이 있어서 모델이 이미지를 입력받고 이에 대한 프롬프트에 응답을 할 수 있다.

대표적으로는 GPT-4o, GPT-4o-mini, GPT-4-turbo 등이 있다고 한다.

 

2.  OpenAI (ChatGPT) vs Microsoft Azure OpenAI => Azure OpenAI

- OpenAI API : https://platform.openai.com/docs/api-reference/chat/create

- Microsoft Azure OpenAI : https://learn.microsoft.com/ko-kr/azure/ai-services/openai/concepts/models?tabs=python-secure%2Cglobal-standard%2Cstandard-chat-completions#gpt-4o-and-gpt-4-turbo

 

처음 접하는 개발자 분들은 왜 이걸 고민해? 굳이 Azure 사용해야해? 라고 생각할 수도 있을 것 같다. 필자도 그랬다.

필자의 팀은 Microsoft Azure에서 제공하는 OpenAI 모델을 선택했다. "민감한 사진"을 다룬다는 점을 감안해줬으면 좋겠다. 

  • OpenAI에서 제공하는 API는 외부 서드파티 API라고 한다면 Azure는 하나의 클라우드 플랫폼 서비스이기 때문에 보다 더 보안 및 프라이버시를 중시한다.
  • Azure는 의료 데이터라고 한다면 HIPAA(Health Insurance Portability and Accountability Act)와 같은 의료 데이터 보호 규정을 준수할 수 있는 옵션을 제공한다.

결국 보안이라는 측면에서는 민감한 데이터를 다루는 우리 팀에게는 Azure가 훨씬 적합하다고 생각했기 때문에 해당 플랫폼을 선택하게 되었다.

 

3.  Microsoft Azure OpenAI에서 모델을 구축하는 방법?

https://elsboo.tistory.com/52

 

Azure OpenAI 사용법 튜토리얼

MicroSoft 의 Azure OpenAI 를 처음 접하는 입장에서 바로 따라할 수 있는 튜토리얼이 구글링으로 잘 나오지 않아 써보는 글이다. Azure OpenAI 는 OpenAI 의 chatGPT 모델을 API 나 라이브러리 형태로 제공하는

elsboo.tistory.com

처음 입문해서 모델을 생성해야 하는 분들은 해당 링크를 참고하면 좋을 것 같다. 여기서 추가적으로 필자가 설명해보자면..

위의 블로그를 참고해서 잘 모델을 생성하면 다음과 같은 화면을 볼 수 있을 것이다.

 

여기서 우리는 Azure OpenAI Studio로 이동을 클릭해서 Studio로 들어가고, 배포 탭으로 들어가서 모델 배포 -> 기본 모델 배포를 클릭해보자.

 

그러면 모델을 선택할 수 있는 화면이 나온다. 필자는 GPT-4o 를 선택했다.

선택하고 나면 아래 화면을 볼 수 있다.

 

배포 유형은 Azure의 공식문서에 잘 설명이 되어있으니 참고해주시고, 필자는 표준 유형을 선택했다.

그리고 여기서 한가지 중요한점은 초반 무료 계정인 개발자분의 화면은 분당 토큰 속도 제한이 1K까지로 제한이 되어있을 것이다.

이는 무료계정이기 때문에 그렇다. 필자는 1K 이상의 토큰을 사용하기 때문에 해당 부분을 해결해야 했다.

 

해결하지 못하면 아래 사진과 같이 에러가 발생하는데 해당 에러를 마주쳤다면 아래 내용을 참고해서 해결해보자!

 

이제 설명할 내용은 100% 정확한 정보는 아니니까 참고해주길 바란다.

 

결론은 무료계정에서 종량제 계정으로 업그레이드를 해야한다. 계정을 업그레이드하면 필자가 올린 위 사진과 같이 1K 이상으로 설정할 수 있다. 종량제 계정으로 업그레이드를 하면 돈이 나가지 않냐? 돈 나간다. 그렇지만 우리는 처음 계정을 생성하면 200달러의 크레딧을 받기 때문에 해당 크레딧을 먼저 사용하고 나서 돈이 청구가 되니까 참고해주길 바란다.

 

해당 링크들은 가격정책, 업그레이드, 사용량 관련 문서이니 참고해주길 바란다.

- https://azure.microsoft.com/ko-kr/pricing/details/cognitive-services/openai-service/

- https://learn.microsoft.com/ko-kr/azure/cost-management-billing/manage/upgrade-azure-subscription

- https://learn.microsoft.com/ko-kr/azure/ai-services/openai/quotas-limits

 

위의 내용을 따라서 GPT 모델도 배포를 했으면 왼쪽에 플레이그라운드 -> 채팅 탭으로 들어가서 코드보기를 눌러보자.

 

코드보기를 보면 API_KEY와 CURL 형식의 요청 예시가 있는데 해당 코드를 참고하면 도움이 될 것이다.

여기까지 왔으면 우리는 이제 NestJS를 활용해서 간단하게 요청-응답 구조를 구현해보자.

 

4.  NestJS를 활용한 Azure OpenAI 활용

NestJS를 어느정도 활용할줄 안다고 가정하고 포스팅 작성 하겠습니다.
그렇게 어렵지는 않습니다.

 

- https://learn.microsoft.com/ko-kr/azure/ai-services/openai/supported-languages?tabs=dotnet-secure%2Csecure%2Cpython-secure%2Ccommand&pivots=programming-language-javascript

 

Azure OpenAI Service 지원 프로그래밍 언어 - Azure AI services

Azure OpenAI에 대한 프로그래밍 언어 지원.

learn.microsoft.com

 

위의 문서만 참고해도 충분히 코드를 작성할 수 있다. 해당 문서에는 API_TOKEN 방식과 Microsoft Entra ID 방식을 소개하고 있는데 API_TOKEN 모드는 보안상 프로덕션 모드에서는 크게 추천하지는 않는다고 한다. 하지만 필자는 현재 개발 모드이기 때문에 API_TOKEN방식으로도 충분할 것 같아서 API_TOKEN 방식을 선택했다.

(1) Package Install

openai에서 AzureOpenAI 생성자를 지원해주는것 같다.

npm i openai

(2) Service source code

import { AzureOpenAI } from 'openai';

@Injectable()
export class OpenAIService {
	private client: AzureOpenAI;
    
    constructor() {
    	this.client = new AzureOpenAI({
        	deployment: '<사용하려는 모델>',
            apiVersion: '<OpenAI API VERSION>',
            endpoint: '<Azure OpenAI Studio에 있는 endpoint 주소>',
            apiKey: '<Azure OpenAI API KEY>'
        });
    }
    
    public async test(): Promise<void> {
    	const response = await this.client.chat.completions.create({
        	model: 'gpt-4o-2024-05-13',
            messages: [
            	{
                	role: 'user',
                    content: [
                    	{ type: 'text', text: '안녕하세요! JavaScript의 ES6에 대해서 설명해주세요.'},
                        // 만약 이미지를 추가로 넣어야 한다면?
                        // { type: 'image_url', image_url: { url: '<image url>', detail: 'auto' }}
                    ]
                }
            ],
            temperature: 0   // 낮으면 낮을수록 모델의 응답 결정도가 높아짐.
        });
        
        console.log(response);
        console.log(response.choices[0].message.content);
    }
}

 

JavaScript의 ES6에 대해 물어봤더니 정상적으로 답변을 받았다.

 

5.  필자는 민감한 사진을 핸들링한다고 했다. 어떻게 해결하는지?

아마 민감한 사진이 담겨있는 사진의 URL을 위의 코드에 삽입하여 실행을 시키면 다음 에러가 발생할 것이다.

 

확인해보니 Azure 모델에서 처리하고 있는 콘텐츠 정책이 있는 것 같았다.

https://learn.microsoft.com/ko-kr/azure/ai-services/openai/concepts/content-filter?tabs=warning%2Cuser-prompt%2Cpython-new#image-content

 

Azure OpenAI Service 콘텐츠 필터링 - Azure OpenAI

Azure AI 서비스에서 Azure OpenAI의 콘텐츠 필터링 기능에 대해 알아봅니다.

learn.microsoft.com

 

동의를 받고 수집한 사진이긴 하지만 Model의 레벨에서는 자동적으로 콘텐츠를 분석하고 막는 것 같았다.

현재는 해결을 위해 노력을 하는 단계인데 공식문서를 조금 더 꼼꼼하게 읽어보니 콘텐츠 필터를 신청할 수 있다고 한다.

 

 

필자와 같은 데이터를 다루는 개발자 분이라면 해당 문서 참고해서 콘텐츠 필터 신청을 통해 정상적인 결과를 받을 수 있으면 좋겠다.


이번 블로그 포스팅이 길이가 막 그렇게 길지는 않지만 해당 내용은 약 1주일 정도 삽질을 하고 나서 정리한 글이다.

생각보다 Azure OpenAI에 대한 자료가 너무 없어서, 많이 헤맸던 것 같다. 역시나 공식문서를 더더욱 꼼꼼히 읽어봐야 할 것 같다.

나와 같이 어려움을 겪는 사람들이 해당 포스팅을 보고 더욱 빠르고 정확하게 Azure OpenAI를 연동할 수 있으면 좋겠다!

 

iamkanguk

iamkanguk

안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ 읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다! 감사합니다.

[NestJS] Type Safe Config Service

Framework/NestJS 2024. 11. 3. 20:51

NestJS를 사용하면 환경변수(env)는 보통 @nestjs/config의 ConfigModule을 사용할 것이다.

그렇지만 해당 라이브러리에서 제공하는 ConfigService를 기본으로 사용하면 undefined도 추론되고 해당 env에 그 key가 있는지 없는지도 파악할 수 없다.

 

그래서 이번 포스팅에서는 어떤 환경 변수들이 있는지 추론이 되고 undefined가 추론되지 않도록 코드를 작성해보려고 한다.

필자는 해당 방법이 가독성도 좋은 것 같고 관리가 생각보다 잘 되는 것 같아서 이 방법을 계속 사용해보려고 한다.

 

기본적으로 ConfigService를 사용해봤다고 가정해보고 포스팅 작성을 진행해보겠다.

app.config.ts 작성

// src/configs/app.config.ts

import { registerAs } from '@nestjs/config';
import { getEnv } from 'src/common/utils/config.util';

export default registerAs('app', () => ({
  //==============================
  // SWAGGER CONFIGS
  //==============================
  SWAGGER: {
    USERNAME: getEnv('SWAGGER_DOCS_USERNAME'),
    PASSWORD: getEnv('SWAGGER_DOCS_PASSWORD'),
  }
}));

 

위와 같이 코드를 작성하면 섹션별로 나눌 수 있어서 어떤 환경변수들이 있는지 확인하기 굉장히 쉬워진다.

getEnv 함수는 이후에 설명하도록 하겠다. 굉장히 간단하다.

Root Module에 ConfigModule을 등록해보자.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import appConfig from './configs/app.config';
import { AuthModule } from './apis/auth/auth.module';
import { AdminModule } from './apis/admin/admin.module';
import { validationSchema } from '@configs/validation-schema';
import { UserModule } from '@apis/user/user.module';
import { CronModule } from '@libs/cron/cron.module';
import { HospitalModule } from '@apis/hospital/hospital.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: `src/configs/envs/.env.${process.env.NODE_ENV}`,
      isGlobal: true,
      load: [appConfig],
      validationSchema: validationSchema,
    })
	// ...
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

 

- load 부분에 위에서 registerAs로 선언한 config를 넣어주면 된다. 필자는 그냥 appConfig 하나로 관리해버리는데 더 나누고 싶으면 나눠도 괜찮다.

getEnv 함수 설명

/**
 * Get Environment variables by key name
 *
 * @param key
 * @returns
 */
export const getEnv = (key: string): string => {
  return process.env[key] ?? '';
};

 

진짜 별거 없다. 그냥 process.env에서 가져오도록 했고 undefined로 추론되는 것을 막기 위해 Nullish Operator를 통해 없는 경우에는 빈 문자열을 할당하도록 했다.

환경변수 사용하는 법 (Dependency Injection)

@Injectable()
export class UserService {
  constructor(
    @Inject(appConfig.KEY) private readonly config: AppConfigType,
    private readonly prisma: PrismaService,
    private readonly awsS3Service: AwsS3Service
  ) {}
  
  public async test() {
     const { ENV1, ENV2 } = this.config.AWS;
  }
}

 

위와 같이 굉장히 쉽게 사용이 가능하다. 그리고 process.env나 configService를 사용할 때는 Type Checking이 안되었을 것이다.

하지만 우리는 app.config.ts 파일에서 섹션별로 선언을 해줬기 때문에 타입이 정의되어서 Type Checking이 가능하다.

그래서 IDE 레벨에서 자동으로 추천해주기도 한다.

다른 모듈에서 Config를 import 해야하는 경우

import appConfig from '@configs/app.config';
import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CustomRedisService } from './custom-redis.service';
import { getAppConfig } from 'src/common/utils/config.util';

@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule.forFeature(appConfig)],
      inject: [ConfigService],
      // TODO: nestjs-redis 라이브러리 버전 체크 및 Downgrade 필요할수도?
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-expect-error
      useFactory: (configService: ConfigService): RedisModuleOptions => {
        const config = getAppConfig(configService);
        const { HOST, PORT, PASSWORD } = config.REDIS;

        return {
          config: {
            host: HOST,
            port: +PORT,
            password: PASSWORD,
          },
          readyLog: true,
          errorLog: true,
        };
      },
    }),
  ],
  providers: [CustomRedisService],
  exports: [CustomRedisService],
})
export class CustomRedisModule {}

 

해당 소스코드는 Redis Module을 작성한 코드이다.

forFeature를 통해 ConfigModule에 설정 파일을 등록하는 것이다. 그러면 configService로 접근을 할 수가 있다는 것이다.

그리고 필자는 getAppConfig라는 유틸 함수를 따로 만들어서 관리를 했다.

getAppConfig 메서드과 AppConfigType

/**
 * Get App Config.
 * If you use useFactory in module level, you can use this function with applying
 * return type.
 *
 * @param configService
 * @returns
 */
export const getAppConfig = (configService: ConfigService): AppConfigType => {
  const config = configService.get('app');

  if (!config) {
    throw new InternalServerErrorException('앱 설정에 문제가 있습니다.');
  }

  return config;
};

// AppConfigType?
type AppConfigType = ConfigType<typeof appConfig>;

 

만약에 저렇게 유틸함수로 빼지 않으면 Dynamic Module 등록할 때 저 로직들을 계속 작성해주어야 한다.

중복되는 부분을 제거하기 위해서 유틸 함수로 뺐다고 생각하자.

그리고 AppConfigType은 위와 같이 작성해주면 된다.


이렇게 Config를 Type-Safe하게 관리할 수 있도록 해봤다.

아무래도 TS를 사용하다 보니까 Type Checking이 안되는데에 민감한 것 같다 ㅋㅋ

한번 다른 분들도 적용해보면 좋을 것 같다!

참고자료

- https://suyeonme.tistory.com/109

 

[Nest.js] Type-safe하게 ConfigService로 환경변수 관리하기

ConfigService를 사용하지 않는 경우 일반적으로 환경변수에 접근하는 경우. process.env로 접근하게 됩니다. 이렇게 접근하는 경우 환경변수를 사용하는데는 문제가 없지만 어떤 환경변수가 사용가능

suyeonme.tistory.com

 

iamkanguk

iamkanguk

안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ 읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다! 감사합니다.

[NestJS] Request에 user 정보를 넣으실 때 any 타입으로 하시는 분들 많으시죠?

Framework/NestJS 2024. 1. 23. 05:30

오늘은 포스팅을 2개나 작성한다..ㅎㅎ 그만큼 몰랐던 것들을 오늘은 좀 많이 알게 된 것 같아서 뿌듯해서 그런 것 같다 (사실 별건 아니지만)

 

이전 포스팅에서 strict 모드를 추가하면서 많은 에러가 발생했다고 언급했다. null parameter가 들어올 수 있는 가능성이 있는 코드들 등등을 수정하는 가운데 request의 user 변수의 type이 지정되지 않아서 발생한 문제가 있었다.

 

어떻게 request 안에 넣어놓은 user 데이터에 type을 부여할 까 고민하다가 좋은 글을 참고하게 되어서 조금 참고하고 작성해보려고 한다.

 

문제 상황

 

strict 모드에는 any 타입이 사용되는 경우 에러를 뱉는 옵션이 있다. 필자는 TypeScript로 개발을 하기 때문에 any 타입을 사용하게 되면 그 이점이 사라지기 때문에 진짜 웬만하면 any 타입을 절대 사용하지 않으려고 한다.

 

key의 req 파라미터를 보면 타입이 Request이다. 이 Request는 express 라이브러리에서 제공하는 타입이다. 그리고 함수 내부에서 userId를 req 변수의 user에서 id를 가져오는 부분이 있는데 이 부분이 지금 문제다!

 

req의 user가 any 타입이라는 것이다. 우리는 any 타입을 지양하고 있기 때문에 req.user 변수에 custom type을 설정해주어야 한다.

 

해결 방법

물론 이거보다 더 좋은 방법은 있을 것이지만 필자가 판단했을 땐 해당 방법도 따로 문제가 없어보이고 간단하기 때문에 선택했다.

// request-with-user.type.ts

import { Request } from 'express';
import { DecodedUserToken } from 'src/modules/api/user/types';

// Request에 user를 넣을 때 custom-type을 지정해주는 인터페이스
export interface IRequestWithUserType extends Request {
  user: DecodedUserToken;
}

 

필자는 types 디렉토리에 해당 파일을 생성했다. 하나의 인터페이스를 만들었는데 우리는 Request에 접근을 하고싶기 때문에 extends를 통해 Request를 적용시켰다. 그리고 우리는 결국 로그인 또는 회원가입 과정을 거치고 나서 request 변수에 user 라는 key로 사용자 정보를 request에 넣을 것이기 때문에 인터페이스 내부에 user라는 키를 추가했다.

 

그리고 DecodedUserToken은 필자가 설계한 User Entity에서 민감한 정보를 제외한 나머지 Property들의 Interface다.

 

 

그리고 위의 사진과 같이 req 변수의 타입을 Request가 아닌 위에서 생성한 IRequestWithUserType을 통해 req.user의 타입이 DecodedUserToken 이라는 것을 명시해줬다.


- https://webaura.tistory.com/entry/NodeJS-TypeScript-%EC%82%AC%EC%9A%A9-%EC%8B%9C-Request-CustomType

 

[NodeJS] TypeScript 사용 시 Request CustomType

코드가 잘 보이지 않으신다면 우측 하단의 달 모양을 눌러주시기 바랍니다. 최근 토이 프로젝트를 TypeScript로 진행하며 문제가 생긴 부분이 한 가지 있었습니다. 원래 JavaScript 를 이용해서 JWT 로

webaura.tistory.com

 

iamkanguk

iamkanguk

안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ 읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다! 감사합니다.

[NestJS] TypeORM에서 skip과 take 버그 기록!

Framework/NestJS 2023. 12. 20. 06:14

보통 많이 사용하는 createQueryBuilder에서 getRawMany 메서드와 getMany 메서드가 있다는 것을 Nest 개발자 분들은 알고 계실 것이다. skip과 take는 pagination 작업을 처리할 때 주로 적용하는 option이다.

 

하지만 skip과 take는 getRawMany에서 적용되지 않는다고 한다. TypeORM의 createQueryBuilder에서 getRawMany 메서드는 Raw Data를 반환하는데 이 때 limit와 offset을 적용시켜주어야 한다고 한다.

 

필자는 skip과 take이 문제가 있을지 전혀 상상하지 못하고 코드 레벨에서 실수가 있었는지 계속 봐서 시간이 많이 소요되었는데 다른 분들은 이 포스팅을 보면서 삽질을 안하셨으면 좋겠다는 생각이 든다!

 

좋은 자료를 공유해주신 포시코딩님 감사합니다 :)


참고자료

- https://4sii.tistory.com/663

 

[NestJS] TypeORM take, skip 버그

.getRawMany() 를 사용할 때는 take와 skip이 제대로 동작하지 않는 문제 발견. 대신 limit, offset을 사용하면 잘 작동한다. 이전에 limit, offset 대신 take, skip을 사용하라는 걸 어디서 본 거 같은데 .getMany()

4sii.tistory.com

 

iamkanguk

iamkanguk

안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ 읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다! 감사합니다.

[NestJS] Class-Validator 에러 메세지 커스텀하기!

Framework/NestJS 2023. 12. 16. 03:17

최근에 몸이 너무 안좋고 무기력해서 아예 1주일 넘게 쉬어버렸다. 여러분 모두 감기조심... 자세조심.. 허리조심 하시길..!

그래서 그런지 오랜만에 블로그 포스팅을 시작으로 다시 열심히 준비해서 내년 초반기에 꼭 취업을 해보려고 한다!

 

Nest에서 400번대 에러, 정확히는 Class-Validator의 에러 메세지는 배열로 나오는 것을 알고 계실 것이다. 그런데 프로젝트에서도 에러코드 + 단일 메세지 조합으로 Response를 달라고 하시는 개발자분이 계시고, 커뮤니티에서도 몇몇 분이 질문으로 올려주시더라.

 

그런데 구글링을 몇개 해봤는데 그런 자료들이 없는 것 같아서.. 필자가 글을 한번 올려볼까 한다.

참고로 아래 작성되는 방법은 필자의 뇌피셜로 구현을 한 것이라서,, 부정확할 수도 있다는 점 참고해주었으면 좋을 것 같고, 잘못된 부분이 있다면 많이많이 피드백을 해주시면 좋을 것 같다.

 

전체 코드

type TExceptionResponse = ExceptionCodeDto | ValidationError;
type TBadRequestException = ExceptionCodeDto & { target?: string | undefined };
type TValidationErrorContext = { [errorKey: string]: ExceptionCodeDto };

/**
 * @summary 400번 Bad Request Exception Filter
 * @author  Jason
 */
@Catch(BadRequestException)
export class HttpBadRequestExceptionFilter
  extends ExceptionResponseHelper
  implements ExceptionFilter<BadRequestException>
{
  private readonly logger = new Logger(HttpBadRequestExceptionFilter.name);

  catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const statusCode = HttpStatus.BAD_REQUEST;
    const exceptionResponse = exception.getResponse() as TExceptionResponse;

    const exceptionJson = this.generateBasicExceptionResponse(
      statusCode,
      request.url,
    );

    this.decideBadRequestExceptionByInstance(exceptionJson, exceptionResponse);
    this.logger.error(
      `[HttpBadRequestExceptionFilter - ${statusCode}] ${exceptionJson.errorCode}:${exceptionJson.message}`,
    );
    response.status(statusCode).json(exceptionJson);
  }

  /**
   * @summary ValidationError OR ExceptionCodeDto인지 확인 후 예외 결정하는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionJson
   * @param   { TExceptionResponse } exceptionResponse
   */
  decideBadRequestExceptionByInstance(
    exceptionJson: ExceptionResponseDto,
    exceptionResponse: TExceptionResponse,
  ): void {
    // Class-Validator를 통해 나온 에러 객체
    if (exceptionResponse instanceof ValidationError) {
      const validationResult = this.getExceptionObj(exceptionResponse); // 첫 에러 가져오기
      this.setBadRequestException(
        exceptionJson,
        validationResult.code,
        validationResult.message,
        validationResult.target,
      );
      return;
    }
    this.setBadRequestException(
      exceptionJson,
      exceptionResponse.code,
      exceptionResponse.message,
    );
  }

  /**
   * @summary Exception을 뱉어줄 메서드
   * @author  Jason
   * @param   { ValidationError } validationError
   * @returns { TBadRequestException }
   */
  getExceptionObj(validationError: ValidationError): TBadRequestException {
    const errorChildren = validationError.children;

    // Error Children이 없을 시 ==> 바로 반환해주면 됨
    if (isEmptyArray(errorChildren)) {
      const target = validationError.property;
      const errorContext = validationError.contexts as TValidationErrorContext;

      const key = errorContext ? Object.keys(errorContext)[0] : '';
      return {
        ...errorContext?.[key],
        target,
      } as TBadRequestException;
    }
    return this.getExceptionInChildren(errorChildren);
  }

  /**
   * @summary Children에서 Exception 추출해주는 함수
   * @author  Jason
   * @param   { ValidationError[] } errorChildren
   * @returns { TBadRequestException }
   */
  getExceptionInChildren(
    errorChildren: ValidationError[],
  ): TBadRequestException {
    const firstChildren = errorChildren[0];
    const target = firstChildren.property;

    if (firstChildren.contexts) {
      const firstContexts = firstChildren.contexts;
      const key = firstContexts ? Object.keys(firstContexts)[0] : '';
      const errorValue = firstContexts[key];
      return { ...errorValue, target } as TBadRequestException;
    }
    return !firstChildren.children.length
      ? { ...InternalServerExceptionCode.ContextNotSetting, target }
      : this.getExceptionInChildren(firstChildren.children);
  }
}

 

위의 코드는 BadRequestException 전용 Filter를 만든 파일이다.이 글을 보시는 분들은 Nest를 사용해보신 분들이라고 생각하고 글을 앞으로 작성해보도록 하겠다. 메서드 하나하나씩 천천히 분석을 해보자!

 

catch 메서드

catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const statusCode = HttpStatus.BAD_REQUEST;
    const exceptionResponse = exception.getResponse() as TExceptionResponse;

    const exceptionJson = this.generateBasicExceptionResponse(
      statusCode,
      request.url,
    );

    this.decideBadRequestExceptionByInstance(exceptionJson, exceptionResponse);
    this.logger.error(
      `[HttpBadRequestExceptionFilter - ${statusCode}] ${exceptionJson.errorCode}:${exceptionJson.message}`,
    );
    response.status(statusCode).json(exceptionJson);
  }

 

catch 메서드는 Exception이 발생했을 때 딱 잡는 곳이라고 생각하면 될 것 같다. 안의 코드들을 세부적으로 살펴보자면..

 

exceptionResponse 변수

getResponse()를 통해 우리는 에러 Response를 가져올 수 있다. TExceptionResponse는 ExceptionCodeDto 또는 ValidationError 타입이 될 수 있다. ValidationError는 Class-Validator를 통해 생긴 에러 객체라고 생각하면 될 것 같다. 그리고 ExceptionCodeDto는 code와 message로 구성이 되어있는데 예시를 들어주겠다.

// common.exception-code.ts
MultipartJsonFormatError: setExceptionCode(
    '8011',
    '올바른 JSON 형식으로 입력해주시고 Multipart에서 이미지를 제외한 데이터는 항상 data라는 키로 JSON 형태로 보내주세요.',
),

// main.ts
throw new BadRequestException(
        CommonExceptionCode.MultipartJsonFormatError,
);

 

필자는 저렇게 커스텀으로 에러코드를 만드는 편이다. setExceptionCode의 첫번째 요소는 커스텀 에러 코드고, 2번째 요소는 그에 해당하는 메세지이다. 이후 애플리케이션 내에서 저렇게 throw를 통해 에러를 보내야 할 경우가 생기는데 이 경우에 ExceptionCodeDto를 사용하는 것이다.

exceptionJson 변수

generateBasicExceptionResponse 메서드는 그냥 기본 에러 골격을 잡아주는 기능이라고 생각하면 된다. statusCode와 요청 경로를 넣어주면 포함시켜서 기본 에러 골격을 만들어준다.

export class ExceptionResponseDto {
  public readonly isSuccess: boolean;
  public readonly statusCode: number;
  public readonly timestamp: string;
  public readonly path: string;

  public target: string;
  public message: string;
  public errorCode: string;
  public errorStack: string;

  private constructor(statusCode: number, path: string) {
    this.isSuccess = false;
    this.statusCode = statusCode;
    this.errorCode = '';
    this.target = '';
    this.message = '';
    this.errorStack = '';
    this.timestamp = getKoreaTime();
    this.path = path;
  }

  static from(statusCode: number, path: string): ExceptionResponseDto {
    return new ExceptionResponseDto(statusCode, path);
  }
}

 

decideBadRequestExceptionByInstance 메서드 (여기서부터 시작!)

이 부분부터 핵심이라고 생각하면 될 것 같다.

/**
   * @summary ValidationError OR ExceptionCodeDto인지 확인 후 예외 결정하는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionJson
   * @param   { TExceptionResponse } exceptionResponse
   */
  decideBadRequestExceptionByInstance(
    exceptionJson: ExceptionResponseDto,
    exceptionResponse: TExceptionResponse,
  ): void {
    // Class-Validator를 통해 나온 에러 객체
    if (exceptionResponse instanceof ValidationError) {
      const validationResult = this.getExceptionObj(exceptionResponse); // 첫 에러 가져오기
      this.setBadRequestException(
        exceptionJson,
        validationResult.code,
        validationResult.message,
        validationResult.target,
      );
      return;
    }
    this.setBadRequestException(
      exceptionJson,
      exceptionResponse.code,
      exceptionResponse.message,
    );
  }

 

 

매개변수에서 exceptionJson은 아까 우리가 위에서 만든 기본 에러 골격이다. 그리고 exceptionResponse는 filter에서 catch한 에러 리스폰스이다. 그래서 우리는 로직에서 ValidationError인지 아닌지를 판단해서 exception을 다르게 한다. 보면 차이점은 target을 넣느냐마냐 + getExceptionObj 메서드의 유무이다. target을 넣은 이유는 디버깅 + 클라분들 에러가 어디서 발생하는지 확인하는 용도라고 생각하면 될 것 같다. 그리고 getExceptionObj는 추후 설명하도록 하겠다.

 

이후에 공통적으로 setBadRequestException을 통해 에러 기본 골격에 세팅을 해준다. 

/**
   * @summary BadRequestException Property를 ExceptionResponseDto에 적용시켜주는 함수
   * @author  Jason
   *
   * @param   { ExceptionResponseDto } exceptionResponse
   * @param   { string } errorCode
   * @param   { string } message
   * @param   { string | undefined } target
   */
  setBadRequestException(
    exceptionResponse: ExceptionResponseDto,
    errorCode: string,
    message: string,
    target?: string | undefined,
  ): void {
    exceptionResponse.errorCode = errorCode;
    exceptionResponse.message = message;
    exceptionResponse.target = target ?? '';
  }

 

그러면 이제 getExceptionObj 메서드가 왜 ValidationError인 경우에만 있는지 설명하겠다. 필자가 위에서 말했듯이 ValidationError가 아닌 경우는 개발자가 직접 애플리케이션 단에서 throw를 통해 에러를 보내준 경우이다. 이 경우에는 가공할 필요가 없기 때문에 따로 전처리 과정 없이 바로 내보내줘도 된다. 그러면 이제 getExceptionObj 메서드를 살펴보자.

getExceptionObj 메서드와 getExceptionInChildren 메서드

/**
   * @summary Exception을 뱉어줄 메서드
   * @author  Jason
   * @param   { ValidationError } validationError
   * @returns { TBadRequestException }
   */
  getExceptionObj(validationError: ValidationError): TBadRequestException {
    const errorChildren = validationError.children;

    // Error Children이 없을 시 ==> 바로 반환해주면 됨
    if (isEmptyArray(errorChildren)) {
      const target = validationError.property;
      const errorContext = validationError.contexts as TValidationErrorContext;

      const key = errorContext ? Object.keys(errorContext)[0] : '';
      return {
        ...errorContext?.[key],
        target,
      } as TBadRequestException;
    }
    return this.getExceptionInChildren(errorChildren);
  }

 

먼저, TBadRequestException 타입은 { code, message, target? } 이라고 생각하자. 여기서 일단 먼저 알아둬야 할 부분은 children 부분이다. children은 class-validator에서 nested한 부분을 처리할 때 여기에 넣곤 한다. 예를 들어 보겠다.

"area": [
        {
            "date": "2023-11-22"
        }
    ],

 

위와 같은 객체가 있다고 했을 때 필자는 각 배열의 원소에 value라는 key가 있는지 확인해야 한다. 이렇게 key-value 구조에서 value가 nested할 때 children에 들어간다고 알면 되겠다.

 

이제 위 코드를 보면, children 배열이 비어있으면 따로 추가 처리 없이 반환해주게 된다. 이 때 context는 다음과 같다.

 

위 사진에서 contexts를 볼 수 있을 것이다. 보면 IsNotEmpty를 key로 code와 message가 있다. 그래서 key는 context가 있는 경우에 object.keys의 0번째 원소를 가져온다. 0번째를 가져오는 이유는 nest에서 에러 메세지는 배열로 구성되어 있는데 맨 위의 메세지를 보여주려고 하기 때문이다. 그리고 그 첫번째 key를 가지고 code와 message를 추출한다. 그리고 target까지 구성해서 return 해준다.

 

children 배열이 비어있지 않은 경우를 살펴보겠다.

 

위의 사진과는 다르게 children 객체가 들어가있다. children 객체에 데이터가 들어가있다는 것은 nested 객체에 유효성 에러가 발생했다는 뜻이고 거기에 접근해야 에러처리가 가능하다는 것이다. 그래서 children 객체에 들어가보겠다.

 

children 객체를 들어가보니 위에서 보던 ValidationError와 동일한 구조이다. 즉, children도 ValidationError 객체로 이루어져있다. 위의 사진을 보면 children 배열은 비어있고 context가 들어가있는 것을 볼 수 있다. context에 에러 내용이 포함되어 있다.

 

/**
   * @summary Children에서 Exception 추출해주는 함수
   * @author  Jason
   * @param   { ValidationError[] } errorChildren
   * @returns { TBadRequestException }
   */
  getExceptionInChildren(
    errorChildren: ValidationError[],
  ): TBadRequestException {
    const firstChildren = errorChildren[0];
    const target = firstChildren.property;

    if (firstChildren.contexts) {
      const firstContexts = firstChildren.contexts;
      const key = firstContexts ? Object.keys(firstContexts)[0] : '';
      const errorValue = firstContexts[key];
      return { ...errorValue, target } as TBadRequestException;
    }
    return !firstChildren.children.length
      ? { ...InternalServerExceptionCode.ContextNotSetting, target }
      : this.getExceptionInChildren(firstChildren.children);
  }

 

 

매개변수로 children 배열을 받으면 일단 가장 첫번째 children을 가져온다. 그리고 children 내에 context가 있는 경우에 우리가 getExceptionObj에서 처리한 것 처럼 처리한다.

 

그런데 만약에 context도 없고, children도 비어있는 배열이라면 context가 설정되어 있지 않다고 500번대 에러를 발생시킨다. 이 것은 필자가 Class-Validator쪽에 context를 지정해주지 않았기 때문이다.

 

만약 children이 있다면 context를 지정해 준 것이기 때문에 다시 재귀함수로 호출해준다. 왜냐면 children 안에 또 children이 있을 수 있기 때문이다. nested 안에 또 nested 객체가 들어갈 수 있다는 의미이기 때문에 재귀를 통해 처리하기로 했다.

 

 

위와 같이 처리하게 되면 다음과 같이 에러를 받을 수 있다.

 

 

원래 Nest의 Response와는 다르게 message를 단일로 받을 수 있게 되었다. 위 코드를 작업한 뒤 main.ts에 Filter를 Global하게 적용해주면 된다.

 

참고로, 재귀함수를 호출하는 과정에서 코드를 개선할 수 있을 것 같다고 생각이 드는데.. 일단 필자의 머리로는 이것이 최선인 것 같다.

조만간 더욱 더 깊게 분석해봐야 할 것 같다.

iamkanguk

iamkanguk

안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ 읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다! 감사합니다.

방명록