오랜만에 블로그 포스팅을 써보는 것 같다. 조금 바빴다는 핑계로..ㅎㅎ 이제는 다시 포스팅을 조금씩 써보려고 한다.
오늘은 지금까지 애를 많이 먹었던 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
처음 입문해서 모델을 생성해야 하는 분들은 해당 링크를 참고하면 좋을 것 같다. 여기서 추가적으로 필자가 설명해보자면..
위의 블로그를 참고해서 잘 모델을 생성하면 다음과 같은 화면을 볼 수 있을 것이다.
여기서 우리는 Azure OpenAI Studio로 이동을 클릭해서 Studio로 들어가고, 배포 탭으로 들어가서 모델 배포 -> 기본 모델 배포를 클릭해보자.
그러면 모델을 선택할 수 있는 화면이 나온다. 필자는 GPT-4o 를 선택했다.
선택하고 나면 아래 화면을 볼 수 있다.
배포 유형은 Azure의 공식문서에 잘 설명이 되어있으니 참고해주시고, 필자는 표준 유형을 선택했다.
그리고 여기서 한가지 중요한점은 초반 무료 계정인 개발자분의 화면은 분당 토큰 속도 제한이 1K까지로 제한이 되어있을 것이다.
이는 무료계정이기 때문에 그렇다. 필자는 1K 이상의 토큰을 사용하기 때문에 해당 부분을 해결해야 했다.
해결하지 못하면 아래 사진과 같이 에러가 발생하는데 해당 에러를 마주쳤다면 아래 내용을 참고해서 해결해보자!
이제 설명할 내용은 100% 정확한 정보는 아니니까 참고해주길 바란다.
결론은 무료계정에서 종량제 계정으로 업그레이드를 해야한다. 계정을 업그레이드하면 필자가 올린 위 사진과 같이 1K 이상으로 설정할 수 있다. 종량제 계정으로 업그레이드를 하면 돈이 나가지 않냐? 돈 나간다. 그렇지만 우리는 처음 계정을 생성하면 200달러의 크레딧을 받기 때문에 해당 크레딧을 먼저 사용하고 나서 돈이 청구가 되니까 참고해주길 바란다.
위의 문서만 참고해도 충분히 코드를 작성할 수 있다. 해당 문서에는 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을 위의 코드에 삽입하여 실행을 시키면 다음 에러가 발생할 것이다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
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이 안되는데에 민감한 것 같다 ㅋㅋ
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
[NestJS] Request에 user 정보를 넣으실 때 any 타입으로 하시는 분들 많으시죠?
Framework/NestJS2024. 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 이라는 것을 명시해줬다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
최근에 몸이 너무 안좋고 무기력해서 아예 1주일 넘게 쉬어버렸다. 여러분 모두 감기조심... 자세조심.. 허리조심 하시길..!
그래서 그런지 오랜만에 블로그 포스팅을 시작으로 다시 열심히 준비해서 내년 초반기에 꼭 취업을 해보려고 한다!
Nest에서 400번대 에러, 정확히는 Class-Validator의 에러 메세지는 배열로 나오는 것을 알고 계실 것이다. 그런데 프로젝트에서도 에러코드 + 단일 메세지 조합으로 Response를 달라고 하시는 개발자분이 계시고, 커뮤니티에서도 몇몇 분이 질문으로 올려주시더라.
그런데 구글링을 몇개 해봤는데 그런 자료들이 없는 것 같아서.. 필자가 글을 한번 올려볼까 한다.
참고로 아래 작성되는 방법은 필자의 뇌피셜로 구현을 한 것이라서,, 부정확할 수도 있다는 점 참고해주었으면 좋을 것 같고, 잘못된 부분이 있다면 많이많이 피드백을 해주시면 좋을 것 같다.
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);
}
}
/**
* @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을 통해 에러 기본 골격에 세팅을 해준다.
그러면 이제 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에 에러 내용이 포함되어 있다.
매개변수로 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하게 적용해주면 된다.
참고로, 재귀함수를 호출하는 과정에서 코드를 개선할 수 있을 것 같다고 생각이 드는데.. 일단 필자의 머리로는 이것이 최선인 것 같다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
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버전 부터는 우선 차단부터 시킨다고 한다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
최근에 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) 등을 설치할 때 사용한다.
- 지역설치는 보통 프로젝트에서 사용할 라이브러리를 설치하기 위해 사용한다. 일반적으로는 지역설치를 사용하자!
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
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 라이브러리를 적용하는구나 알고 써야한다고 생각되어서 한번 정리해봤다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.
결론은 객체지향 적으로 설계 (캡슐화) 하기 위해서 Exclude와 Expose를 사용하게 되었다. 참고로 일반적인 경우에는 User 테이블의 password와 같은 칼럼을 숨기기 위해 사용하곤 한다.
어떤 이슈가 있었냐?
먼저, Class-transformer의 exclude와 expose를 사용하기 때문에 main.ts 부분에 Global 적으로 ClassSerializeInterceptor를 등록했다. 그렇게 되면 Response로 반환될 때 Serialize 과정을 거쳐서 원하는 결괏값을 얻을 수 있다.
이후 나는 Caching을 적용하고 싶었다. 원래는 Controller의 Method 단에 @UseInterceptor(CacheInterceptor)와 CacheKey, CacheTTL 데코레이터를 적용하고 싶었지만, 필자는 각 사용자의 데이터를 저장하고 싶어서 CacheKey의 userId를 넣고 싶었다.
하지만, @CacheKey 데코레이터에는 그렇게 하지 못한다. 코드를 보자. ---- (1)
데코레이터를 통해 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는 사용하지 않을 것이기 때문이다.
바로 app.module.ts에 전역으로 Provider에 등록해 주는 것이다. 참고로 CustomCacheV2 Interceptor는 필자가 커스텀 한 CacheInterceptor인데 이것은 다음 포스팅에서 기본 Nest의 CacheInterceptor를 분석하는 것과 어떻게 커스텀했는지에 대해서 알아보려고 한다.
위의 사진은 main.ts 파일이다. 맨 밑의 줄을 보면 GlobalInterceptor로 ClassSerializerInterceptor를 등록해 줬다.
여기서 알아버린 사실은 app.module.ts에 등록한 provider가 main.ts에 등록한 provider 보다 우선이라는 것이다!
후기
내가 정확하게 인지한 부분인지는 모르겠다. 그래서 블로그를 통해서 혹시나 보시는 분들에게 지적을 받고 싶다.
커뮤니티 쪽에서도 여쭤보고 틀린 정보가 있다면 수정을 해보도록 하겠다.
어쨌든 문제는 해결했으니 끝!
#### 2023.11.18 후기 추가
- 지금 위와 같은 방법으로 하니 GET 이외의 메서드에서 캐시를 삭제하는 과정에서 많은 이슈가 발생하고 있다. 따라서, 위의 방법은 일단 보류하면서 추후에 해결하면 링크를 달아두도록 하려고 한다.
안녕하세요! 현재 NodeJS로 백엔드 취업을 희망하고 있는 이강욱이라고 합니다. 최대한 블로그 포스팅을 보시는 분들에게 자세한 정보와 즐거움을 드리기 위해 노력하며 포스팅을 쓰고 있습니다 ㅎㅎ
읽어보시고 문제가 있거나 잘못되었다고 생각되는 부분들은 언제든 댓글로 작성해주시면 최대한 빠르게 조치하겠습니다!
감사합니다.