(14)

[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를 달라고 하시는 개발자분이 계시고, 커뮤니티에서도 몇몇 분이 질문으로 올려주시더라. 그런데 구글링을 몇개 해봤는데 그런 자료들이 없는 것 같아서.. 필자가 글을 한번 올려볼까 한다. 참고로 아래 작성되는 방법은 필자의 뇌피셜로 구현을 한 것이라서,, 부정확할 수도 있다는 점 참고..

[Node.js] npm install 에러 이슈 공유

Glimpse 프로젝트를 진행하던 중 백엔드 동료 개발자 분이 먼저 User 쪽 개발을 해주셔서 올려준 code를 pull 받고 package를 install 하려고 했는데 에러가 발생했다. 에러를 어떻게 해결했는지에 대해서 공유를 해보려고 한다. npm ERR! code ERESOLVE npm ERR! ERESOLVE could not resolve npm ERR! npm ERR! While resolving: @liaoliaots/nestjs-redis@9.0.5 npm ERR! Found: @nestjs/common@10.2.10 npm ERR! node_modules/@nestjs/common npm ERR! @nestjs/common@"^10.2.10" from the root project n..

[Node.js] npm 명령어 학습

최근에 NestJS 오픈채팅 커뮤니티에 들어갔는데 카카수님이 후배들을 양성하고 계신다. 전체적인 프로젝트를 관리해주시고, 다양한 지식을 습득할 수 있도록 GitHub Issue와 PR을 통해 도와주시는데 참고해도 괜찮다고 말씀하셔서 이렇게 하루에 한번씩 참고해서 공부해보려고 한다. npm은 node package manager의 약자로 프로젝트에 필요한 라이브러리를 다운로드 또는 관리할 수 있도록 해주는 프로그램이다. npm 설치 확인 node -v # Node 버전 확인 npm -v # npm 버전 확인 package.json 생성 npm init npm init -y # 기본값 입력 package install npm install [패키지명] npm i [패키지명] # install을 i로 축약 가..

[NestJS] 자네 혹시 helmet이 뭔지 알고 쓰나?

최근에 프로젝트를 하는 도중에 보일러 플레이트를 조금씩 만져보고 있는데.. 내가 놓치고 있던 부분이 있었다. 그냥 아무생각 없이 app.use(helmet()) 을 사용하고 있던 것이다. 정확히 helmet이 무엇이고 어떤 기능을 하는지를 놓치고 있는 것 같아서 이렇게 정리해보려고 한다. 참고로 Nest에서 helmet을 적용하는 것은 진짜 일도 아니다. 위처럼 app.use로 적용해주면 끝이다. 이 포스팅은 [Project] 프로젝트 삽잘기30 (feat helmet 구성) 글을 주로 참고했습니다! 좋은 글이니 한번씩 들어가셔서 읽어보시면 좋을 것 같습니다. [Project] 프로젝트 삽질기30 (feat helmet 구성) 들어가며 NestJS로 개발하면서, 외부로부터 오는 해커들의 공격에 어떻게 대..

[NestJS - 트러블슈팅] Cannot read properties of undefined (reading 'joinColumns')

최근에 Hard-Delete에서 Soft-Delete로 변경하면서 repository method와 entity 쪽에 전반적으로 변경이 일어났는데.. 오늘 개발한 API들을 점검하면서 글 제목과 같이 에러가 발생했다. JoinColumns는 FK를 연결할 때 쓰는건데 아무리 봐도 잘못된 부분이 없는데 어디가 문제인지 도저히 모르겠는 것이다. 위의 에러는 Soft-Delete를 하는 과정에서 발생한 이슈이다. 필자가 참여하고 있는 프로젝트에서는 Schedule과 ScheduleArea 엔티티가 각각 있다. 기존 고드를 제공해보겠다. // ScheduleEntity @OneToMany( () => ScheduleAreaEntity, (scheduleArea) => scheduleArea.scheduleId,..

[NestJS] Class-transformer의 expose와 exclude를 사용할 때 캐싱에서 발생한 이슈 + Lifecycle 분석

오늘은 약 3일 동안 개-고생을 한 것에 대해서 블로그를 써보려고 한다. 고생을 한 내용은....? 필자는 Nest에서 특별한 경우가 아니고서는 Response DTO를 만들어서 반환한다. 이때 Expose와 Exclude 메서드를 사용한다. 그리고 최근에는 Get 메서드에 대해서 캐싱을 시도해보고 있는데 이 부분에 있어서 있었던 이슈를 공유해보고자 한다. Response DTO에 Class-transformer의 Expose와 Exclude 사용 import { Exclude, Expose } from 'class-transformer'; import { MarkCategoryDto } from './mark-category.dto'; export class GetMarkCategoriesRespons..

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

[Node.js] npm install 에러 이슈 공유

Framework/Node.js 2023. 12. 7. 19:08

Glimpse 프로젝트를 진행하던 중 백엔드 동료 개발자 분이 먼저 User 쪽 개발을 해주셔서 올려준 code를 pull 받고 package를 install 하려고 했는데 에러가 발생했다. 에러를 어떻게 해결했는지에 대해서 공유를 해보려고 한다.

 

npm ERR! code ERESOLVE
npm ERR! ERESOLVE could not resolve
npm ERR! 
npm ERR! While resolving: @liaoliaots/nestjs-redis@9.0.5
npm ERR! Found: @nestjs/common@10.2.10
npm ERR! node_modules/@nestjs/common
npm ERR!   @nestjs/common@"^10.2.10" from the root project
npm ERR!   peer @nestjs/common@"^8.0.0 || ^9.0.0 || ^10.0.0" from @nestjs/config@3.1.1
npm ERR!   node_modules/@nestjs/config
npm ERR!     @nestjs/config@"^3.1.1" from the root project
npm ERR!   8 more (@nestjs/core, @nestjs/jwt, @nestjs/mapped-types, ...)
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer @nestjs/common@"^9.0.0" from @liaoliaots/nestjs-redis@9.0.5
npm ERR! node_modules/@liaoliaots/nestjs-redis
npm ERR!   @liaoliaots/nestjs-redis@"^9.0.5" from the root project
npm ERR! 
npm ERR! Conflicting peer dependency: @nestjs/common@9.4.3
npm ERR! node_modules/@nestjs/common
npm ERR!   peer @nestjs/common@"^9.0.0" from @liaoliaots/nestjs-redis@9.0.5
npm ERR!   node_modules/@liaoliaots/nestjs-redis
npm ERR!     @liaoliaots/nestjs-redis@"^9.0.5" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.

 

에러를 확인해보니 참조하는 패키지의 버전이 다르다는 것이다. 그리고 아래의 3줄을 읽어보면 참조가 꼬였다는 에러가 발생했다. 그리고 해결하려면 force 또는 legacy-peer-deps 옵션을 추가하라는 것이다.

 

<2023.12.10 에러 내용 분석 추가!>

확인해보니까 @liaoliaots/nestjs-redis 라이브러리는 현재 Nest 9버전까지만 지원을 하고 있다. 필자가 작업하는 Glimpse 프로젝트는 Nest 10버전을 사용하고 있다. 그래서 지금 에러가 발생하고 있는 것 같다. 자세한 내용을 보려면 해당 GitHub에 가서 소개글과 Issue를 확인해보면 좋을 것 같다.

 

- --force: 충돌을 우회함. 필요한 경우 패키지 의존성을 위해 추가적인 패키지를 설치한다.

- --legacy-peer-deps: 충돌을 무시함. 그냥 무시하고 설치함.

 

참고로 npm 6버전까지는 --legacy-peer-deps와 비슷한 방식으로 자동으로 설치되었는데 7버전 부터는 우선 차단부터 시킨다고 한다.

 

그래서 어떻게 해결함?

일단 Cache를 날려버린다.

npm cache clear --force

 

그리고 --legacy-peer-deps로 다시 설치한다.

npm install --legacy-peer-deps

 

그러면 정상적으로 설치되는 것을 확인할 수 있다!

'Framework > Node.js' 카테고리의 다른 글

[Node.js] npm 명령어 학습  (1) 2023.12.03
iamkanguk

iamkanguk

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

[Node.js] npm 명령어 학습

Framework/Node.js 2023. 12. 3. 05:52

최근에 NestJS 오픈채팅 커뮤니티에 들어갔는데 카카수님이 후배들을 양성하고 계신다. 전체적인 프로젝트를 관리해주시고, 다양한 지식을 습득할 수 있도록 GitHub Issue와 PR을 통해 도와주시는데 참고해도 괜찮다고 말씀하셔서 이렇게 하루에 한번씩 참고해서 공부해보려고 한다.

 

npm은 node package manager의 약자로 프로젝트에 필요한 라이브러리를 다운로드 또는 관리할 수 있도록 해주는 프로그램이다.

 

npm 설치 확인

node -v   # Node 버전 확인
npm -v   # npm 버전 확인

package.json 생성

npm init
npm init -y   # 기본값 입력

package install

npm install [패키지명]
npm i [패키지명]   # install을 i로 축약 가능하다
npm i [패키지명@버전이름]
npm i [패키지명1] [패키지명2]

npm i --save [패키지명]
npm i -S [패키지명]   # --save === -S

npm i --save-dev [패키지명]
npm i -D [패키지명]   # --save-dev === -D

npm install -g [패키지명]

 

참고) npm install 에서 --save 옵션을 입력하는 이유와 지금은 필요하지 않은 이유?

save 옵션은 package.json의 dependency 항목에 모듈을 추가한다는 의미이다. 참고로 package.json에서 우리는 프로젝트가 사용하는 모듈들의 의존성을 관리할 수 있는 것이다.

 

Node 프로젝트를 해보면, node_modules 라는 디렉토리를 볼 수 있는데 우리가 형상관리를 할 때 node_modules를 보통은 .gitignore에 적용하곤 한다. 그 이유는 폴더의 크기도 크고, package.json 파일만 있으면 자동으로 모듈들을 다운로드 받을 수 있기 때문이다.

 

그리고, npm 5버전 부터는 save 옵션을 기본으로 적용할 수 있게 되었기 때문에 save 옵션을 적용하지 않아도 dependencies에 항목을 추가할 수 있다.

 

그리고 save에 추가로 적용할 수 있는 옵션이 있다.

- -P 또는 --save-prod: package.json의 dependencies에 패키지를 등록한다. (이게 기본!)

- -D 또는 --save-dev: package.json의 devDependencies에 패키지를 등록한다.

- -O 또는 --save-optional: package.json의 optionalDependencies에 패키지를 등록한다.

- --no-save: dependencies에 패키지를 등록하지 않는다.

 

위에서 devDependencies는 애플리케이션 동작과는 직접적인 연관은 없다. 단지 차이점은 개발할 때 필요한 라이브러리이기 때문에 배포할 때 포함을 시키는지 안시키는지에 대한 차이이다.

package delete

npm uninstall [패키지명]
npm uninstall -g [패키지명]

package update

npm update [패키지명]

start command

npm start   # package.json의 scripts에 있는 start 명령어를 실행
npm run [script-name]   # 그 이외의 scripts를 실행한다

이 외의 다른 명령어들

npm root   # node_modules의 위치를 알려준다.
npm root -g   # global의 node_modules 위치 알려준다.

npm list   # 패키지 리스트 조회
npm ls   # npm list === npm ls
npm list(ls) -g   # 전역으로 설치된 패키지 리스트 조회
npm list(ls) --depth   # depth를 따로 적용시켜줄 수 있음, depth가 0이면 전역 모듈만 조회 가능

전역설치와 지역설치의 차이

- 전역설치는 시스템상에서 해당 라이브러리의 명령어를 인식하게끔 설정하기 위해 사용한다. 주로 CLI나 Demon (background-program) 등을 설치할 때 사용한다.

- 지역설치보통 프로젝트에서 사용할 라이브러리를 설치하기 위해 사용한다. 일반적으로는 지역설치를 사용하자!

Quiz: 아래 3개의 script 차이점은?

npm install package   # package 설치 (--save-prod)
npm install --save-dev package   # package 설치 (개발종속성)
npm install -g package   # package 전역설치

 

 

'Framework > Node.js' 카테고리의 다른 글

[Node.js] npm install 에러 이슈 공유  (1) 2023.12.07
iamkanguk

iamkanguk

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

[NestJS] 자네 혹시 helmet이 뭔지 알고 쓰나?

Framework/NestJS 2023. 11. 29. 16:03

최근에 프로젝트를 하는 도중에 보일러 플레이트를 조금씩 만져보고 있는데.. 내가 놓치고 있던 부분이 있었다.

그냥 아무생각 없이 app.use(helmet()) 을 사용하고 있던 것이다.

 

정확히 helmet이 무엇이고 어떤 기능을 하는지를 놓치고 있는 것 같아서 이렇게 정리해보려고 한다.

참고로 Nest에서 helmet을 적용하는 것은 진짜 일도 아니다. 위처럼 app.use로 적용해주면 끝이다.

 

 

이 포스팅은 [Project] 프로젝트 삽잘기30 (feat helmet 구성) 글을 주로 참고했습니다! 좋은 글이니 한번씩 들어가셔서 읽어보시면 좋을 것 같습니다.
 

[Project] 프로젝트 삽질기30 (feat helmet 구성)

들어가며 NestJS로 개발하면서, 외부로부터 오는 해커들의 공격에 어떻게 대응할 수 있을지 고민하곤 했습니다. 그때 helmet이라는 라이브러리를 알 수 있었습니다. NestJS에서 helmet을 보다 잘 활용

overcome-the-limits.tistory.com

 

Helmet 이란?

Helmet은 웹서버를 외부의 공격으로부터 보호해주는 대표적인 Node 보안 모듈이다. Helmet 모듈은 다양한 HTTP Header를 자동으로 설정해줘서 서버 애플리케이션의 보안을 강화해준다.

// helmet install
npm install helmet

 

참고로 helmet은 express의 미들웨어 모듈이고 여러 미들웨어 모듈을 합쳐놓은 미들웨어 패키지 모듈이다. 다시 말해서, express 기반 애플리케이션에서 HTTP Response Header를 설정하는 여러 개의 작은 미들웨어 함수 유형 모음이라고 생각하면 된다.

 

// main.ts
import helmet from 'helmet'

...
app.use(helmet());
 
// 다양한 옵션들을 적용할 때 사용할 수 있는 기능들
app.use(helmet.contentSecurityPolicy());
app.use(helmet.crossOriginEmbedderPolicy());
app.use(helmet.crossOriginOpenerPolicy());
app.use(helmet.crossOriginResourcePolicy());
app.use(helmet.dnsPrefetchControl());
app.use(helmet.expectCt());
app.use(helmet.frameguard());
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts());
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.originAgentCluster());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy());
app.use(helmet.xssFilter());

 

Helmet의 기본설정과 추가설정

기본 설정

(1) dnsPrefetchControl

도메인이 미리 로딩되는 Prefetch에 대해 컨트롤하기 위해 X-DNS-Prefetch-Control 헤더를 설정한다. 대부분의 브라우저에서는 성능을 향상시키기 위해 페이지의 링크에 대한 DNS Record를 미리 추출한다. 그렇게 되면 사용자가 링크를 클릭할 때 대상에 대한 IP가 이미 알려져 있다는 것이다.

 

이로 인해서 DNS Service가 과도하게 사용될 수 있고, 개인정보 보호문제 등이 야기될 수 있다. 따라서 보안 요구가 높은 경우에는 성능 저하를 감수하면서 DNS Prefetch를 비활성화 시킬 수 있다.

 

(2) frameguard

X-Frame-Options 헤더를 설정해서 Click-jacking 공격을 방지한다. Click-jacking은 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 하여 속이는 해킹 기법이다. 속이기 위해서 보이지 않는 레이어에 보이지 않는 버튼을 만드는 방법이 있다.

 

(3) hidePoweredBy

응답 헤더에 있는 X-Powered-By에 서버 소프트웨어가 표기되는데 이를 숨겨준다. 이 정보는 악의적으로 활용될 가능성이 높기 때문에 helmet을 통해서 제거해 주는 것이 좋다.

 

참고로 필자는 main.ts 부분에 app.disable('x-powered-by') 라고 프로젝트 시작 전에 선언해주는 편이었다. 하지만 helmet 라이브러리를 적용하면서 이 부분은 제거해도 괜찮을 것 같다.

 

helmet을 적용하지 않았을 때의 Response Header

 

실제로 Postman을 가지고 테스트 해봤을 때 helmet 라이브러리를 사용하지 않은경우 X-Powered-By가 노출되는 것을 확인할 수 있었다. 이제 helmet 라이브러리를 적용해보고 스샷을 찍어보겠다.

 

helmet을 적용하고 난 후의 Response Header

 

Helmet 라이브러리를 적용하니까 이전과 다르게 뭔가 여러개가 빡빡 나오는 것을 볼 수 있다. 하지만 확실한건 x-powered-by 노출을 막아준다는 것을 확인할 수 있다.

 

(4) hsts

Strict-Transport-Security 헤더를 설정해준다. 이 헤더는 서버에 대한 안전한 연결을 적용시켜주는 헤더이다. 안전한 연결이라 함은 SSL 또는 TLS를 통한 HTTP 연결을 말한다. 그래서 브라우저에게 HTTPS 만을 통해 특정 사이트에 Access 할 수 있도록 요청한다.

 

브라우저는 Default로 보통 HTTP로 먼저 접속을 시도하게 되는데 이 때 HTTPS를 지원하는 사이트였다면 301/302 Redirect로 HTTP 응답을 해서 HTTPS롤 적용해서 사이트에 접근시켜줍니다.

 

하지만 해커가 중간에 Proxy 서버를 세팅함으로써 본인과 해커 사이에서는 HTTP 통신을 하고, 해커와 웹사이트와는 HTTPS 통신을 하게 된다면 사용자의 개인정보가 HTTP 통신을 통해 해커에게 전달되는 현상이 야기될 수 있습니다.

 

위의 시나리오를 SSL Stripping 이라고 하며, 이런 공격을 방지하기 위해 HSTS를 설정한다고 합니다.

 

(5) ieNoOpen

X-Download-Options 헤더를 설정해서 IE8 (Internet Explorer) 이상에서만 사용할 수 있도록 설정한다.

 

(6) noSniff

X-Content-Type-Options 헤더를 설정해서 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 브라우저의 MIME Sniffing을 방지한다. MIME은 Multipurpose Internet Mail Extensions의 약자로 클라이언트에게 전송된 문서의 다양성을 알려주기 위한 포맷이다. 참고로 브라우저는 리소르를 내려받을 때 MIME 타입을 보고 동작하기 때문에 정확한 설정이 중요하다.

 

MIME Sniffing은 브라우저가 특정 파일을 읽을 때 파일의 실제 내용과 Content-Type에 설정된 내용이 다르면 파일로부터 형식을 추측하여 실행하는 것인데 공격자에게 악용이 될 가능성이 있다고 한다.

 

(7) xssFilter

X-XSS-Protection 헤더를 0으로 설정하면서 XSS 공격 스크립트를 비활성화해서 예방할 수 있다.

 

추가설정

(1) contentSecurityPolicy (CSP)

콘텐츠 보안 정책 설정 및 구성을 통해 의도하지 않은 내용이 페이지에 삽입되는 것을 방지해준다. 이 헤더를 설정해주면서 XSS나 Cross-site injection을 방지해준다. 참고로, 다른 사이트의 script를 불러오는 것도 막기 때문에 helmet 적용 전 별도로 설정이 필요하다.

 

(2) crossOriginEmbedderPolicy (COEP)

Cross-Origin-Embedder-Policy 헤더를 require-corp로 설정한다. COEP는 교차 출처 삽입 정책이라고도 부르며, 원본 간 리소스를 문서에 포함하도록 해주는 헤더이다. require-corp 옵션 또한 다른 옵션들에 대해서는 링크를 확인해보면 더욱 자세하게 알 수 있다.

 

(3) crossOriginOpenerPolicy (COOP)

Cross-Origin-Opener-Policy 헤더를 설정한다. COOP는 top-level 문서가 cross-origin 상태의 문서와 browsing context group을 공유하지 못하도록 제한해주는 헤더이다.

 

(4) crossOriginResourcePolicy (CORP)

Cross-Origin-Resource-Policy 헤더를 설정한다. CORP는 교차 출처 리소스 정책이라고도 부르며, 브라우저가 지정된 리소스에 대한 출처 또는 사이트 간 요청을 차단하지 않는다는 요청을 하는 헤더이다.

 

(5) exprectCt

Expect-CT 헤더를 설정해서 SSL 인증서 오발급을 예방해준다.

 

(6) originAgentCluster

Origin-Agent-Cluster 헤더를 설정해서 Origin 간 문서를 별도 Agent Cluster로 분리한다.

 

(7) permittedCrossDomainPolicies

X-Permitted-Cross-Domain-Policies 헤더를 설정해서 Cross-Domain-Content-Policy를 설정한다. 이 헤더는 일부 클라이언트에 도메인 간 콘텐츠 로드에 대한 도메인 정책을 처리한다.

 

(8) referrerPolicy

참조 referrer 헤더를 숨겨준다.

 

후기

이렇게 helmet에 대해서 조금 자세하게 알아봤다. 모든 옵션들을 필히 알아야 하는 건 아니지만, 그래도 어떤 기능들이 있구나, 이래서 helmet 라이브러리를 적용하는구나 알고 써야한다고 생각되어서 한번 정리해봤다.

 

좋은 글을 제공해주신 분들에게 너무 감사합니다. 덕분에 잘 참고했습니다!


참고 자료

- https://overcome-the-limits.tistory.com/743

 

[Project] 프로젝트 삽질기30 (feat helmet 구성)

들어가며 NestJS로 개발하면서, 외부로부터 오는 해커들의 공격에 어떻게 대응할 수 있을지 고민하곤 했습니다. 그때 helmet이라는 라이브러리를 알 수 있었습니다. NestJS에서 helmet을 보다 잘 활용

overcome-the-limits.tistory.com

- https://docs.nestjs.com/security/helmet

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

- https://4rgos.tistory.com/1

 

XSS(Cross Site Scripting) 공격이란?

XSS 란? 웹 해킹 공격 중 XSS라는 공격 기법이 있다. Cross Site Scripting의 약자로 CSS라고 하는 것이 맞지만 이미 CSS가 Cascading Style Sheets의 약어로 사용되고 있어 XSS라 한다. XSS는 게시판이나 웹 메일 등

4rgos.tistory.com

- https://inpa.tistory.com/767

 

[NODE / 보안] 📚 helmet 모듈 사용법 - 웹 보안은 내가 👮

helmet 모듈 helmet 모듈은 서버에서 다양한 HTTP 헤더를 자동 설정을 통해 서버 어플리케이션의 보안을 강화해주는 대표적인 노드 보안 모듈이다. 헬멧을 써서 내 머리를 보호하듯이, 내 웹서버를

inpa.tistory.com

 

iamkanguk

iamkanguk

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

[NestJS - 트러블슈팅] Cannot read properties of undefined (reading 'joinColumns')

Framework/NestJS 2023. 11. 23. 19:28

최근에 Hard-Delete에서 Soft-Delete로 변경하면서 repository method와 entity 쪽에 전반적으로 변경이 일어났는데..

오늘 개발한 API들을 점검하면서 글 제목과 같이 에러가 발생했다. JoinColumns는 FK를 연결할 때 쓰는건데 아무리 봐도 잘못된 부분이 없는데 어디가 문제인지 도저히 모르겠는 것이다.

 

위의 에러는 Soft-Delete를 하는 과정에서 발생한 이슈이다.

 

필자가 참여하고 있는 프로젝트에서는 Schedule과 ScheduleArea 엔티티가 각각 있다. 기존 고드를 제공해보겠다.

 

// ScheduleEntity
@OneToMany(
    () => ScheduleAreaEntity,
    (scheduleArea) => scheduleArea.scheduleId,
    {
      cascade: true,
    },
)
scheduleAreas: ScheduleAreaEntity[];

// ScheduleAreaEntity
@ManyToOne(() => ScheduleEntity, (schedule) => schedule.scheduleAreas, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
})
@JoinColumn({ name: 'scheduleId', referencedColumnName: 'id' })
schedule: ScheduleEntity;

 

위의 코드는 Entity 끼리 연관관계를 맺어주는 부분이라고 보면 된다. 딱 보면 어디가 문제가 있어보이는가? 왜 나만 안보일까...?

 

문제점을 찾음!

문제점은 바로 ScheduleEntity 부분쪽에 (scheduleArea) => scheduleArea.scheduleId 부분이다.

왜냐면 나는 ScheduleAreaEntity 쪽에는 schedule 이라고 FK 연결 설정을 해놨는데, 필자는 JoinColumn의 name 부분에 scheduleId라고 설정을 해놔서 scheduleArea.scheduleId로 접근을 한 것 같다.

 

// ScheduleEntity
@OneToMany(
    () => ScheduleAreaEntity,
    (scheduleArea) => scheduleArea.schedule,
    {
      cascade: true,
    },
)
scheduleAreas: ScheduleAreaEntity[];

// ScheduleAreaEntity
@ManyToOne(() => ScheduleEntity, (schedule) => schedule.scheduleAreas, {
    onDelete: 'CASCADE',
    onUpdate: 'CASCADE',
})
@JoinColumn({ name: 'scheduleId', referencedColumnName: 'id' })
schedule: ScheduleEntity;

 

그래서 위와 같이 코드를 수정하면 정상적으로 작동을 하는 것을 확인할 수 있다!

다시 한번 꼼꼼하게 살펴보자!

iamkanguk

iamkanguk

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

[NestJS] Class-transformer의 expose와 exclude를 사용할 때 캐싱에서 발생한 이슈 + Lifecycle 분석

Framework/NestJS 2023. 11. 15. 18:03

오늘은 약 3일 동안 개-고생을 한 것에 대해서 블로그를 써보려고 한다. 고생을 한 내용은....?

 

필자는 Nest에서 특별한 경우가 아니고서는 Response DTO를 만들어서 반환한다. 이때 Expose와 Exclude 메서드를 사용한다.

그리고 최근에는 Get 메서드에 대해서 캐싱을 시도해보고 있는데 이 부분에 있어서 있었던 이슈를 공유해보고자 한다.

 

Response DTO에 Class-transformer의 Expose와 Exclude 사용

import { Exclude, Expose } from 'class-transformer';
import { MarkCategoryDto } from './mark-category.dto';

export class GetMarkCategoriesResponseDto {
  @Exclude() private readonly _totalCategoryMarkCount: number;
  @Exclude() private readonly _markCategories: MarkCategoryDto[];

  private constructor(
    totalCategoryMarkCount: number,
    markCategories: MarkCategoryDto[],
  ) {
    this._totalCategoryMarkCount = totalCategoryMarkCount;
    this._markCategories = markCategories;
  }

  @Expose()
  get totalCategoryMarkCount(): number {
    return this._totalCategoryMarkCount;
  }

  @Expose()
  get markCategories(): MarkCategoryDto[] {
    return this._markCategories;
  }

  static from(
    totalCategoryMarkCount: number,
    markCategories: MarkCategoryDto[],
  ): GetMarkCategoriesResponseDto {
    return new GetMarkCategoriesResponseDto(
      totalCategoryMarkCount,
      markCategories,
    );
  }
}

 

일단 나는 이전 포스팅에서 정적 팩토리 메서드에 대해서 정리했기 때문에 혹시나 이 글을 보시는 분들은 참고해 주시면 좋을 것 같다. 

 

[NestJS] ResponseEntity와 정적 팩토리 메서드

필자는 Nest를 개발할 때 Service와 Repository를 거쳐온 결괏값을 Client로 반환할 때 Response DTO 객체로 만들어서 Controller 계층으로 넘기고 Controller에서 클라이언트로 결괏값을 반환할 때 ResponseEntity 객

dev-iamkanguk.tistory.com

 

결론은 객체지향 적으로 설계 (캡슐화) 하기 위해서 Exclude와 Expose를 사용하게 되었다. 참고로 일반적인 경우에는 User 테이블의 password와 같은 칼럼을 숨기기 위해 사용하곤 한다.

 

어떤 이슈가 있었냐?

먼저, Class-transformer의 exclude와 expose를 사용하기 때문에 main.ts 부분에 Global 적으로 ClassSerializeInterceptor를 등록했다. 그렇게 되면 Response로 반환될 때 Serialize 과정을 거쳐서 원하는 결괏값을 얻을 수 있다.

 

{
    "isSuccess": true,
    "statusCode": 200,
    "result": {
        "totalCategoryMarkCount": 19,
        "markCategories": [
            {
                "id": 18,
                "title": "영화보기",
                "isMarkedInMap": "INACTIVE",
                "markCount": 3
            },
            { ... },
        ]
    }
}

 

 

이후 나는 Caching을 적용하고 싶었다. 원래는 Controller의 Method 단에 @UseInterceptor(CacheInterceptor)와 CacheKey, CacheTTL 데코레이터를 적용하고 싶었지만, 필자는 각 사용자의 데이터를 저장하고 싶어서 CacheKey의 userId를 넣고 싶었다.

 

하지만, @CacheKey 데코레이터에는 그렇게 하지 못한다. 코드를 보자.   ---- (1)

  @Get()
  @HttpCode(HttpStatus.OK)
  @UseInterceptor(CacheInterceptor)
  @CacheKey('<여기에 넣어야해!!>')
  @CacheTTL(0)
  async getMarkCategories(
    @UserId() userId: number,
  ): Promise<ResponseEntity<GetMarkCategoriesResponseDto>> {
    const result = await this.markCategoryService.findMarkCategories(userId);
    return ResponseEntity.OK_WITH(HttpStatus.OK, result);
  }

 

필자는 key를 'mark_categories_userId_${uesrId}'와 같이 하고 싶다. 그렇지만 @CacheKey에 userId를 포함시키고 싶은데 그렇게 하지 못한다. (혹시나.... 아시는 분들은 댓글로 알려주시면 너무너무 감사하겠습니다!)

 

그래서 필자는 다음과 같이 코드를 수정해 봤다.    ---- (2)

 

  @Get()
  @HttpCode(HttpStatus.OK)
  async getMarkCategories(
    @UserId() userId: number,
  ): Promise<ResponseEntity<GetMarkCategoriesResponseDto>> {
    const redisKey = this.markCategoryHelper.setMarkCategoriesRedisKey(userId);
    const checkInRedisResult = await this.customCacheService.getValue(redisKey);

    if (isDefined(checkInRedisResult)) {
      return ResponseEntity.OK_WITH(
        HttpStatus.OK,
        checkInRedisResult as GetMarkCategoriesResponseDto,
      );
    }

    const result = await this.markCategoryService.findMarkCategories(userId);
    await this.customCacheService.setValueWithTTL(
      redisKey,
      result,
      CACHE_PERSISTANT_TTL,
    );

    return ResponseEntity.OK_WITH(HttpStatus.OK, result);
  }

 

데코레이터를 통해 Caching을 진행할 순 없다고 판단되어서 Controller 단에서 Redis에 해당 Key로 저장된 Value가 있는지 확인하고 있는 경우에는 Service로 넘어가지 않고 바로 Return 하고, 그렇지 않은 경우에는 Service로 넘어가서 비즈니스 로직을 거쳐서 Return 하게 작성해 봤다.

 

위와 같이 작성하게 되면 일단 유동적으로 CacheKey를 설정할 수 있긴 하다. 하지만 여기서 문제점이 있으니....

만약, API를 요청했을 때 Redis에 저장되어 있는 값이 없으면 Service로 넘어가서 로직들을 처리하고 result 변수에 값이 할당되어 있을 것이다. 그리고 위의 코드를 보면 ResponseEntity로 클라이언트에게 반환하기 전에 Redis에 key-value 형태로 저장을 한다. 하지만 Redis에 저장할 때는 위의 json 형태로 저장되지 않는다. 

Expose와 Exclude가 처리되지 않은 상태로 Redis에 저장되는 것이다. 즉, 위의 결괏값에서 언더바(_)가 붙은 상태로 저장이 된다. 이렇게 되면 다음에 추가적으로 같은 API를 요청하게 되면 Redis를 거치게 될 텐데 위의 결괏값과 다른 언더바가 붙은 상태로 반환이 될 것이다. 이렇게 되면 클라이언트에서는 화면을 렌더링 할 수 없게 된다.

 

즉, 필자가 원하는 것은 Expose와 Exclude가 처리된 상태(SerializeInterceptor를 거친 상태)를 캐싱하고 싶다는 것이다.

 

어느 부분에서 이슈가 있었던 것 같아요?

ClassSerializeInterceptor가 CacheInterceptor보다 늦게 동작을 하기 때문이었다. Response가 흘러가는데 CacheInterceptor를 먼저 들른 격이 되는 것이다.

 

우리는 위의 내용을 구현하기 위해서는 CacheInterceptor를 ClassSerializeInterceptor보다 늦게 실행시키면 되는 것이다!

 

NestJS에서의 Life-Cycle와 위의 이슈랑 연관 짓기

1. Incoming request
2. Globally bound middleware
3. Module bound middleware
4. Global guards
5. Controller guards
6. Route guards
7. Global interceptors (pre-controller)
8. Controller interceptors (pre-controller)
9. Route interceptors (pre-controller)
10. Global pipes
11. Controller pipes
12. Route pipes
13. Route paramter pipes
14. Controller (method handler)
15. Service (if exists)
16. Route interceptor (post-request)
17. Controller interceptor (post-request)
18. Global interceptor (post-request)
19. Exception filters (route, then controller, then global)
20. Server response 

 

위의 내용들을 보면 먼저 Controller 위에 작성하는 UseInterceptor는 pre-controller 및 controller interceptor(post-request) 단계이기 때문에 Expose와 Exclude를 처리한 결괏값을 캐싱할 수는 없다. 이유는 ClassSerializeInterceptor는 18번 단계의 Global interceptor이기 때문이다. (위의 글에서 볼 수 있는 (1) 번)

 

그렇게 되면 물론 위의 코드에서 작성한 Controller에서 처리한 것도 불가능할 것이다. (위의 글에서 볼 수 있는 (2) 번)

 

우리는 결국 18번을 공략해야 할 것 같다. 19번의 Exception Filter는 사용하지 않을 것이기 때문이다.

 

결국 어떻게 해결했나?

결론은 이렇게 하면 된다.... 너무 간단했다......!

 

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { LoggerMiddleware } from './middlewares/logger.middleware';
import { PROJECT_MODULES } from './modules';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { AuthGuard } from './modules/core/auth/guards/auth.guard';
import { CustomCacheInterceptor } from './interceptors/custom-cache.interceptor';
import { CustomCacheV2Interceptor } from './interceptors/custom-cache-v2.interceptor';

@Module({
  imports: [...PROJECT_MODULES],
  controllers: [],
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: CustomCacheV2Interceptor,
    },   // 이부분!!
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

 

바로 app.module.ts에 전역으로 Provider에 등록해 주는 것이다. 참고로 CustomCacheV2 Interceptor는 필자가 커스텀 한 CacheInterceptor인데 이것은 다음 포스팅에서 기본 Nest의 CacheInterceptor를 분석하는 것과 어떻게 커스텀했는지에 대해서 알아보려고 한다.

 

 

위의 사진은 main.ts 파일이다. 맨 밑의 줄을 보면 GlobalInterceptor로 ClassSerializerInterceptor를 등록해 줬다.

여기서 알아버린 사실은 app.module.ts에 등록한 provider가 main.ts에 등록한 provider 보다 우선이라는 것이다!

 

후기

내가 정확하게 인지한 부분인지는 모르겠다. 그래서 블로그를 통해서 혹시나 보시는 분들에게 지적을 받고 싶다.

커뮤니티 쪽에서도 여쭤보고 틀린 정보가 있다면 수정을 해보도록 하겠다.

 

어쨌든 문제는 해결했으니 끝!

 

#### 2023.11.18 후기 추가

- 지금 위와 같은 방법으로 하니 GET 이외의 메서드에서 캐시를 삭제하는 과정에서 많은 이슈가 발생하고 있다. 따라서, 위의 방법은 일단 보류하면서 추후에 해결하면 링크를 달아두도록 하려고 한다.


참고자료

- https://docs.nestjs.com/fundamentals/lifecycle-events

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

iamkanguk

iamkanguk

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

방명록