(17)

[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..

[맵필로그] GitHub Action + Shell Script를 활용한 자동 배포 + 트러블슈팅

현재 맵필로그 개발서버를 배포할 때 다음과 같은 Flow로 배포를 했다. Local 환경에서 build build 결과물인 dist 디렉토리를 파일질라를 통해 원격서버에 이동 PM2로 동작하고 있는 프로젝트 서버 reload 하지만 어느 순간부터 너무 귀찮기 시작했고 파일질라를 사용하는 시간이 너무 아까워서 자동배포를 구현해보았다. 원래는 GitHub Actions를 활용해서 Docker + CI/CD 작업을 하려고 했는데, 1인 개발이기도 하고 아직은 그정도까지는 필요하지 않겠다고 생각이 들었다. 그리고 요즘에는 거의 Code Deploy를 사용해서 배포 자동화를 적용하는데 나는 좀 다르게 하고 싶기도 했다. 그래서 1차 개발에서는 쉘 스크립트를 활용해서 자동 배포를 구현하고 2차 개발에서는 Docke..

[맵필로그] 로그아웃 + 회원탈퇴 기능 구현 기록

오늘은 맵필로그 프로젝트에서 로그아웃과 회원탈퇴 기능을 어떻게 구현했는지에 대해서 설명해보려고 한다. 먼저, 맵필로그에서는 카카오 로그인과 애플 로그인을 제공하고 있다는 것을 먼저 말씀드립니다! 크게 어렵지 않은 내용인 것 같아서 맛있는 거 드시면서 보셔도 충분합니당 ㅎㅎ 1. 로그아웃 기능 로그아웃 기능에서는 소셜쪽으로는 크게 기능구현을 할 게 없는 것 같다. 왜냐하면 애플 로그인은 회원탈퇴 시에만 Token을 Revoke 시켜주면 되기 때문에 따로 로그아웃 쪽에서는 할 게 없다. 그리고 카카오 로그인은 공식 문서를 보면 카카오에서 제공하는 Access-Token을 만료시키는 로직이 있는데 맵필로그 프로젝트에서는 카카오에서 주는 토큰을 따로 사용하지 않기 때문에 굳이 따로 처리하지는 않았다. 애플리케이..

[맵필로그] 아주 많이 고생한 애플로그인 구현 기록..! (Passport X)

우리 맵필로그 프로젝트에서는 이제 배포를 슬슬 준비하고 있다. Android와 iOS 모두 준비하고 있는데 알아보니까 iOS 앱스토어 배포를 위해서는 로그인 기능을 제공할 경우에는 애플 소셜 로그인이 필수로 구현되어 있어야 한다고 한다. 그래서 오늘은 약 3일간 고생한 애플 로그인을 어떻게 구현했는지에 대해서 알아보려고 한다. 아마 NestJS로 구현을 하시는 분들은.. 아실 거지만 생각보다 자료가 많이 없다는 것을 느끼셨을 것이다. 그래서 필자는 Spring으로 구현한 개발자분들의 자료도 많이 참고했었다. 지금부터 쭉 설명을 해보도록 하겠다. 1. 사전 준비물 및 언급 - 애플 개발자 계정이 꼭 필요하다. 혹시나 애플 로그인 기능을 구현하고자 하는 분들은 개발자 계정을 꼭 구매하길 바란다. - 그리고 ..

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

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

[맵필로그] Strict Mode 설정 + 발생한 이슈 + tsconfig.json 일부 분석

이제 express가 아니라 NestJS를 하면서 자연스럽게 TS와 친해지려고 노력하고 있다. 최근에 Nest로 프로젝트를 하면서 tsconfig.json 파일을 만질 때가 있었는데, 정확히 이 파일이 어떤 역할을 하는지 모르고 사용을 했던 것 같다. 그리고 프로젝트를 하는 도중에 내가 작성한 소스를 한번 쭉 그냥 보고 있었는데 최대한 any 타입을 지양하고 각종 메서드나 변수 등에 타입을 꼭 달아주었다. 그런데도 몇 군데 빼먹은 곳이 있었는데 생각해보니까 얼핏 이런걸 에러로 보여주는 기능이 있는데? 라고 알고 있었는데 적용이 안되어서 내가 실수한 부분이 몇 군데 있구나 싶었다. 보니까 strict 모드가 활성화 되어있지 않은 것. 그래서 이번 포스팅에서는 strict mode를 어떻게 활성화 했는지 (..

[맵필로그 + NestJS] Access-Token과 Refresh-Token 전략 정리와 수정해야 할 부분 in NestJS? (약간 장문주의)

필자는 맵필로그라는 프로젝트에서 사용자 인증에서 JWT 토큰을 사용하기로 결정했다. 하지만 단순히 Access Token만 사용하는 것이 아닌 Refresh Token 사용을 도입을 하기로 결정했다. 각 개발자분들 마다 어떤식으로 구현을 하는지 각각 다르기 때문에 인터넷에서도 각기 다른 자료들을 볼 수 있다. 그래서 필자는 이번 포스팅에서는 해당 프로젝트에서 JWT를 왜 도입했는지, 어떤식으로 Access Token과 Refresh Token 전략을 사용했는지에 대해서 정리를 해보려고 한다. 그리고 지금부터 Access Token은 AT로, Refresh Token은 RT로 표기하는 점 참고해주세요! 또한, 이 포스팅에서는 JWT의 자세한 개념에 대해서는 언급을 하지 않을 예정이니 다른 포스팅으로 한번 ..

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

[맵필로그] GitHub Action + Shell Script를 활용한 자동 배포 + 트러블슈팅

사이드 프로젝트/맵필로그 2024. 2. 26. 16:47

현재 맵필로그 개발서버를 배포할 때 다음과 같은 Flow로 배포를 했다.

  • Local 환경에서 build
  • build 결과물인 dist 디렉토리를 파일질라를 통해 원격서버에 이동
  • PM2로 동작하고 있는 프로젝트 서버 reload

하지만 어느 순간부터 너무 귀찮기 시작했고 파일질라를 사용하는 시간이 너무 아까워서 자동배포를 구현해보았다.

원래는 GitHub Actions를 활용해서 Docker + CI/CD 작업을 하려고 했는데, 1인 개발이기도 하고 아직은 그정도까지는 필요하지 않겠다고 생각이 들었다. 그리고 요즘에는 거의 Code Deploy를 사용해서 배포 자동화를 적용하는데 나는 좀 다르게 하고 싶기도 했다. 그래서 1차 개발에서는 쉘 스크립트를 활용해서 자동 배포를 구현하고 2차 개발에서는 Docker와 CI/CD를 도입해보려고 한다.

 

참고로 원래는 shell script만 활용하려고 했는데 이것 마저도 귀찮아서 GitHub Action을 통해 특정 브랜치에 이벤트가 발생하면 자동으로 스크립트를 실행해주면 좋겠다고 생각이 들어 도입을 해보게 되었다.

 

시작 전에, 원격 서버에 npm과 pm2는 설치되어있다고 가정하고 시작하겠다.

 

1.  어떤 식으로 구현이 되는가?

 

참고로, 운영 환경에서도 동일하게 적용합니다! 그리고 지금 인프라를 배포 전에 쭉 확인하는데 여기에 Docker를 도입할 수 있습니다. Docker를 도입해도 크게 달라지는 건 없기 때문에 참고해주세요!

 

- 특정 branch에 push 또는 PR이 들어옴.

- GitHub Action에서 해당 이벤트를 확인하고 workflow 실행

- workflow를 실행 후 마지막에 deploy.sh 실행 (아래 확인해주세요!)

- deploy.sh를 실행하면 ssh 로그인을 통해 원격 서버로 접속하여 필요한 파일들 Upload 후 PM2 재시작

 

2.  서버에 자동으로 배포할 수 있는 Shell Script를 작성해보자!

(1) root에 scripts 디렉토리 생성 후 안에 deploy.sh와 원격서버의 pem 키를 넣어준다.

 

pem 키는 꼭! gitignore에 추가하는 것을 잊지 말자.  그리구 꼭 필자처럼 하지 않아도 된다. 스크립트 파일과 pem 키를 같이 두지 않아도 된다. 그냥 본인이 원하는 위치에다가 넣어주면 된다.

(2) script 파일 작성

스크립트 파일을 공유하고 하나씩 천천히 알아보자. 특별히 문법에 대해서는 설명하지 않도록 하겠다. 해당 스크립트 파일은 다른 분이 블로그에 잘 정리를 해주셔서 구현하기 쉬웠다. 필자는 정리된 블로그에서 약간 수정을 하였다. 해당 블로그는 아래 참고 자료에 올려두도록 하겠다.

#!/bin/bash

PEM_PATH=<PEM 키가 저장되어 있는 경로>
if [ ! -f $PEM_PATH ]; then
    echo "DEPLOY_FAIL: not file exist \"$PEM_PATH\""
    exit 1;
fi

ENV=<ENV 파일이 저장되어 있는 경로>
if [ ! -f $ENV ]; then
    echo "DEPLOY_FAIL: not file exist \"$ENV\""
    exit 1;
fi

# 프로젝트 빌드
npm run build

BUILD=$?
if [ ! $BUILD -eq 0 ]; then
    echo -e '\n======================================\n'
    echo 'BUILD FAILED : deploying is cancel'
    echo -e '\n======================================\n'
    exit 1;
fi

USER=ubuntu // 원격 서버에 접속하려는 유저 이름
HOST=$(grep [ENV에 작성한 원격 서버의 IP주소 KEY] $ENV | cut -d '=' -f2)
DEPLOY_PATH=<배포할 프로젝트를 저장하고자 하는 디렉토리>

SERVER=$USER@$HOST
REMOTE_PATH=$SERVER:$DEPLOY_PATH

# 원격 서버에서 기존의 배포 디렉토리 삭제 및 생성
ssh -i $PEM_PATH $SERVER "sudo rm -rf $DEPLOY_PATH/dist"
ssh -i $PEM_PATH $SERVER "sudo mkdir -p -m 777 $DEPLOY_PATH/dist"

# 로컬 머신의 파일들을 원격 서버로 전송
rsync -rltvzO -e "ssh -i $PEM_PATH" -v dist/ $REMOTE_PATH/dist
rsync -rltvzO -e "ssh -i $PEM_PATH" -v package* $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v ecosystem.config.js $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v .env $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v apple-social-login-key.p8 $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v firebase-admin.json $REMOTE_PATH

echo -e '\n======================================\n'
echo 'FILE UPLOAD DONE.'
echo -e '\n======================================\n'

# 원격 서버에서 프로젝트 의존성 설치
ssh -i $PEM_PATH $SERVER "npm --prefix $DEPLOY_PATH install"

# PM2를 사용하여 애플리케이션 실행 또는 재시작
ssh -i $PEM_PATH $SERVER "pm2 startOrReload $DEPLOY_PATH/ecosystem.config.js"

echo -e '\n======================================\n'
echo 'DEPLOY SUCCESS AND DONE.'
echo -e '\n======================================\n'

 

아마 조금 다른 부분들이 있을 것이다. 지금부터 각 줄마다 어떤 기능 + 의미가 있는지에 대해서 간략하게 설명하도록 하겠다.

  • #!/bin/bash: 해당 스크립트 파일이 Bash 쉘을 사용하여 실행되어야 한다는 것을 의미한다. 만약에 쉘을 다른 것을 쓸 경우 맞춰서 수정해주면 된다.
  • 이후 원격 서버의 PEM 키가 저장되어 있는 경로 및 .env 파일 경로 변수에 저장하고 파일이 잘 저장되어 있는지 유효성 검사
  • npm run build: 프로젝트를 빌드한다.
  • BUILD=$? + if [ ! $BUILD -eq 0 ]: 바로 위에서 실행한 프로젝트 빌드 결과를 BUILD 변수에 저장하고 빌드가 정상적으로 완료되었으면 BUILD 변수에 0을 저장할 것이다. 이후 BUILD가 0이 아닌지만 체크하면 된다. 0이 아니라는 것은 빌드에 실패했다는 의미이다.
  • 이후 원격 서버의 유저, IP주소, 디렉토리 등을 설정해준다. HOST는 원격 서버의 주소를 하드코딩이 아닌 ENV 파일에 설정한 원격 서버의 IP주소를 grep 명령어를 통해 가져온다.
  • 원격 서버에 접속을 해서 dist 디렉토리를 삭제해주고 다시 만들어준다. 새로 빌드를 해서 빌드된 파일을 전달하는 것이기 때문에 dist 디렉토리를 삭제 후 재생성 해주어야 최신 파일로 저장이 된다.
  • rsync -rltvzO -e "ssh -i $PEM_PATH" -v <옮기려고 하는 로컬 파일 경로> <원격서버에서의 옮겨진 파일 경로>
    • rsync 명령어를 통해 서버를 구동시키는데 필요한 파일들을 로컬에서 원격 서버로 옮겨준다.
  • ssh -i $PEM_PATH $SERVER "npm --prefix $DEPLOY_PATH install": 프로젝트 구동에 필요한 파일들은 전부 정상적으로 옮겼기 때문에 원격 서버에서 프로젝트 의존성 설치
  • ssh -i $PEM_PATH $SERVER "pm2 startOrReload $DEPLOY_PATH/ecosystem.config.js":  의존성 설치가 정상적으로 마무리 되었으면 생성해놓은 ecosystem.config.js를 가지고 PM2에서 startOrReload 명령어를 통해 구동

위의 과정이 수행되면 정상적으로 서버를 구동할 수 있다. 혹시 몰라 생소하신 분들이 있을 까봐 필자가 아리까리 했던 문법들도 간단하게 언급하도록 하겠다.

  • $?: 가장 최근에 실행된 명령어의 종료 상태 또는 리턴 코드를 나타낸다. 성공적으로 실행되면 0을 반환하고, 그렇지 않은 경우에는 0이 아닌 다른 값을 반환한다.
  • ssh -i <키 경로> <원격서버에 접속하려는 사용자 이름>@<원격서버의 주소> <수행 명령어>
    • ssh 명령어는 원격 서버에 접속할 때 사용하는 명령어이다. 그리고 -i 옵션은 개인 키 파일을 지정하는데 사용한다. 해당 옵션을 사용하게 되면 사용자는 특정 개인 키 파일을 지정하여 해당 키와 연결된 사용자로 원격 서버에 인증하고 접속할 수 있다.
  • rsync -rltvzO -e "ssh -i $PEM_PATH" -v
    • rsync파일과 디렉토리를 동기화하는데 주로 사용한다. 네트워크를 통해 파일을 전송할 때 유용하다.
    • -e 옵션: 원격 쉘을 사용한다는 옵션이다. 해당 옵션을 사용하면 뒤에 원격 쉘로 사용될 명령어를 작성해야 한다. 필자는 원격 서버의 PEM 키를 가지고 SSH를 통해 원격 서버와 연결한다.
    • -rltvzO 옵션: rsync에 적용할 수 있는 옵션들을 모아둔 것이다. 하나하나씩 간단하게 설명해보겠다.
      • r 옵션 (recursive):  rsync가 재귀적으로 동기화하도록 한다. 즉, 하위 디렉토리까지 복사한다는 의미이다.
      • l 옵션 (links): 심볼릭 링크 보존
      • t 옵션 (timestamp): 타임스탬프 보존
      • v 옵션 (verbose): 자세한 정보를 출력
      • z 옵션 (compress): 데이터를 네트워크를 통해 전송할 때 압축하여 전송한다
      • O 옵션 (omit-dir-times): 대상 디렉토리의 수정 시간을 설정하지 않도록 지시한다
    • rsync에 대한 자세한 설명은 해당 링크에서 참고하자! 잘 정리되어 있다.

이렇게 되면 쉘 스크립트 작성은 끝났다. 어느정도 이해하셨다고 생각하겠다. 나중에 트러블 슈팅을 언급할 때도 한번 더 보시긴 해야한다.

(3) package.json에 실행 script 작성

"scripts": { "deploy": "bash ./scripts/deploy.sh"}

 

위와 같이 작성해주시고 npm run deploy 명령어를 사용하면 된다. 마찬가지로 쉘을 어떤 것을 쓰느냐에 따라 bash 부분을 바꿔주면 된다.

3.  ecosystem.config.js 작성

module.exports = {
  apps: [
    {
      name: '[PM2 실행 애플리케이션 이름]',
      cwd: '[DEPLOY_PATH와 동일하게 작성]',
      script: './dist/main.js',
      instances: 1,
      exec_mode: 'cluster',
      kill_timeout: 5000,
      autorestart: true,
      watch: true,
    },
  ],
};

 

ecosystem.config.js에 대해서는 크게 설명하지 않도록 하겠다. pm2를 수월하게 관리하기 위해서 해당 파일을 만들어서 설정할 수 있다.

name와 cwd만 신경써서 작성해주시면 좋을듯!

 

4.  GitHub Action Workflow 작성해보자!

name: Run deploy-dev.sh on dev branch

on:
  push:
    branches: ['dev']
  pull_request:
    branches: ['dev']

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20.x]

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: npm ci
        run: npm ci

      - name: Add .env file
        run: |
          touch .env
          echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env
          (나머지 ENV 파일도~)

      - name: Add EC2 KEY
        run: |
          touch scripts/mappilogue-ec2-key.pem
          echo "${{ secrets.MAPPILOGUE_EC2_KEY_PEM }}" >> scripts/mappilogue-ec2-key.pem
          chmod 600 scripts/mappilogue-ec2-key.pem

      - name: Add Apple Social Login Key
        run: |
          touch apple-social-login-key.p8
          echo "${{ secrets.APPLE_SOCIAL_LOGIN_KEY }}" >> apple-social-login-key.p8
          chmod 400 apple-social-login-key.p8

      - name: create firebase-admin.json
        id: create-json
        uses: jsdaniell/create-json@1.1.2
        with:
          name: firebase-admin.json
          json: ${{ secrets.FIREBASE_ADMIN_JSON }}

      - name: Apply chmod 400 to firebase-admin.json
        run: chmod 400 firebase-admin.json

      - name: Add SSH key to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.HOST }} >> ~/.ssh/known_hosts

      - name: Run deploy.sh
        run: npm run deploy

 

특별하게 GitHub Action Workflow를 작성하는 방법에 대해서는 설명하지 않도록 하겠다. 너무 다른 분들이 잘 정리를 해주셔서 참고할 사이트가 많다고 생각한다.

 

GitHub Action의 Secrets에 env 파일에 저장된 값 + key file들 값도 저장해준다. 그리고 필요한 파일들을 touch + echo 를 통해 만들어주고 chmod를 통해 권한을 설정했다.

 

중간에 create firebase-admin.json 부분 블록이 조금 생소하실 분들도 있을 수 있는데 아래 트러블 슈팅 2번을 확인해주면 좋을 것 같다.

이후 마찬가지로 권한 부여를 했으며

 

GitHub Action이 해당 클라이언트이기 때문에 known_hosts를 추가해주었다. 이것도 아래 트러블 슈팅 1번에서 확인하자!

 

위의 작업들이 완료되면 package.json에 작성했던 script를 실행시켜준다. 정상적으로 작성했다면 원격 서버에 정상적으로 배포가 된 것을 확인할 수 있을 것이다.

 

5.  겪은 트러블슈팅

내 개발 편하자고 도입했다가 내 허리와 엉덩이를 아작시킨 놈이었다. 다른 분들을 위해 내가 겪은 에러에 대해서 공유하고 피해를 보지 않도록 도와드리고 싶다.

(1) GitHub Action을 실행하면서 "Host key verification failed" 에러 발생

 

해당 문제는 GitHub Action에서 배포를 하려는 원격 서버의 SSH 호스트 키를 인식하지 못해서 발생한 문제이다. SSH를 통한 첫 번째 접속에서 호스트 키가 ~/.ssh/known_hosts 파일에 없을 때 발생한다. 따라서 필자는 다음과 같이 worflow에 추가했다.

- name: Add SSH key to known_hosts
  run: |
   mkdir -p ~/.ssh
   ssh-keyscan -H ${{ secrets.HOST }} >> ~/.ssh/known_hosts

 

ssh-keyscan은 SSH 서버의 공개 키를 스캔하고 수집하는 명령어다. 일반적으로 원격 호스트의 SSH 키를 신뢰할 수 있는지 확인하거나 known_hosts 파일에 추가하여 미래의 SSH 연결 시 호스트 키 검증 경고를 방지하기 위해 사용한다.

 

그리고 -H 옵션은 ssh-keyscan의 출력으로 나오는 호스트 키를 해시 형태로 변환한다는 의미이다. 보안 강화를 위한 하나의 방법인데 known_hosts 파일에 저장된 호스트 이름이나 IP 주소를 감춰서 정보를 파악하기 어렵게 만든다.

 

마지막으로 known_hosts는 클라이언트 노드에서 SSH를 사용하여 특정 서버의 노드로 접속하려고 할 때 서버 노드의 공개키가 클라이언트 노드의 known_hosts 파일에 저장된다. 그래서 우리가 로컬에서 원격 서버로 SSH 접속을 할 때 known_hosts 파일을 보면 뭐라뭐라 저장되는 것이다.

 

GitHub Action Workflow에 해당 내용을 추가한 이유는 GitHub Action Runner가 Client인데 known_host가 없기 때문이다.

 

(2) GitHub Action의 secret에 json을 저장했을 때 발생하는 이슈

위의 workflow 파일에서 필자는 firebase-admin.json 이라는 파일을 secret에 저장했다. 이후 workflow에서 firebase-admin.json파일을 secret에 저장되어 있는 값을 가져와서 생성하려고 했는데 json 파일의 큰 따옴표가 다 사라지고 plain text 처럼 생성되어 에러가 발생했었다.

 

조금 찾아보니까 GitHub Action 에서는 json 데이터를 secret에 저장하지 말라고 되어있다.

To help ensure that GitHub redacts your secret in logs, avoid using structured data as the values of secrets. For example, avoid creating secrets that contain JSON or encoded Git blobs.

 

그래서 필자는 jsdaniell/create-json 라이브러리를 이용해서 해결하였다.

- name: create firebase-admin.json
  id: create-json
  uses: jsdaniell/create-json@1.1.2
  with:
    name: firebase-admin.json
    json: ${{ secrets.FIREBASE_ADMIN_JSON }}

 

하지만 GitHub Action에서는 json 파일을 저장하지 말라고 권고하기 때문에 이 방법은 급하신 분들에게만 추천한다. 필자는 일단 이렇게 적용해놓기는 했는데 다른 방법을 조금 더 생각해봐야 할 것 같다.

(3) rsync failed to [set permissions / set times] on operation not permitted

필자가 참고한 블로그 글과 비교해보면 필자의 스크립트 파일에서는 rsync -rltvzO 식으로 사용하는데 블로그 글에서는 rsync -avz 라고 작성되어 있다. 블로그 글을 참고해서 진행해보면 permissions / times 에러가 발생했다.

 

rsync 옵션을 avz에서 rltvzO로 바꾸니까 해당 문제는 해결이 되었다. 위에서 문법 설명을 해놨으니 참고하면 되겠다.

참고로 rsync에서 a 옵션은 Archive 모드라고 하는데, -rlgptgoD 옵션을 적용한 것과 같다.

 

  • time 이슈: rsync가 대상 시스템에서 파일 또는 디렉토리의 수정 시간을 변경하는데 필요한 권한이 없을 때 발생한다. 필자는 -O 옵션을 추가하면서 rsync가 디렉토리의 수정 시간을 변경하지 않도록 했다.
  • permission 이슈: rsync가 대상 시스템에서 파일 또는 디렉토리의 권한을 설정하려고 할 때 필요한 권한이 없을 때 발생한 이슈이다. -a를 사용하면 파일 권한, 소유자, 타임스탬프 등을 보존하는데 때로는 일부를 설정하는데 필요한 권한이 없어서 에러가 발생할 수 있다. 그래서 -a 대신에 필요한 부분만 옵션을 넣어서 사용한 것이다. 이 때 rsync 옵션에서 권한에 관련된 옵션은 제거하고 넣었다.

필자는 일단 이렇게 했는데 권한적으로 문제는 없다고 판단되어서 Fix 했다. 하지만 조금 더 엄격한 권한이 필요하다고 하다면 해당 해결방법 보다는 다른 해결법을 추천드린다. 하지만 자료는 별로 없다... ㅠ

(4) rsync: connection unexpectedly closed (0 bytes received so far) [sender]

해당 에러는 rsync를 활용해서 파일 전송 과정에서 연결이 예상치 못하게 끊겼다는 것이다. 보통 네트워크 문제, 권한 문제 혹은 원격 서버의 문제로 발생하는데 필자의 경우 위의 에러들이 발생하면서 생긴 에러여서 위의 트러블슈팅들을 해결하니 자동적으로 해당 메세지가 사라졌다.

(5) [sender] expand file_list pointer array to 1024 bytes, did not move

해당 에러는 아무리 봐도 무슨 에러인지 모르겠다.. 뭘 1024 바이트까지 확장하려고 했는데 잘 안된 것 같다라고 말하는데 지금 현재 배포는 잘 되고 있는 상황이다. 조금 더 알아보고 블로그 수정을 하도록 하겠다.

 

6.  후기

조금 시간이 오래 걸렸고 막 고도화 된 작업은 사실 아니다. 그래도 내가 평소 귀찮아했고 불편했던 작업들을 이렇게 자동으로 되게끔 해결을 하니까 뭔가 뿌듯하다. 하지만 이거 에러들 막 해결하고 뭐하고 뭐하느라 쓸데없는 커밋도 많이 했고 다른 작업들도 못했던 것 같다 ㅠ

언넝 블로그 글 마무리하고 다시 이력서쓰로...


참고 자료

- https://develop-const.tistory.com/m/43 (필자가 제일 먼저 참고한 블로그입니다! 좋은 자료 감사합니다.)

 

aws 스크립트 사용해 nest js 자동배포하기

기존에 ssh로 접속해 Git clone 받고 build 하고 install하고 pm2 start 하는 과정이 매우 번거롭게 느껴졌을텐데요. 이와 같이 반복되는 작업을 자동화하는 것은 정말로 효율적이며 시간을 절약할 수 있

develop-const.tistory.com

 

- https://velog.io/@godkimchichi/Github-Actions-secret%EC%97%90-json-%EB%84%A3%EA%B3%A0-%EC%8B%B6%EC%9D%84-%EB%95%8C

 

[Github Actions] secret에 json 넣고 싶을 때



velog.io

 

- https://gomu92.tistory.com/15

 

[llinux] ".ssh/known_hosts"?

-"~/.ssh/known_hosts"란? 클라이언트 노드에서 ssh를 사용하여 서버 노드로 접속할 때, 서버 노드의 공개 키가 클라이언트 노드의 "~/.ssh/known_hosts" 파일에 저장됨. known_hosts 파일에 서버 노드의 공개 키

gomu92.tistory.com

 

 

 

- https://inpa.tistory.com/entry/node-%F0%9F%93%9A-PM2-%EB%AA%A8%EB%93%88-%EC%82%AC%EC%9A%A9%EB%B2%95-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EC%84%9C%EB%B9%84%EC%8A%A4

 

[NODE] 📚 PM2 모듈 사용법 - 클러스터 / 무중단 서비스

Node.js 싱글 스레드 Node.js는 Chrome의 V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임(runtime)으로 ‘Event Driven’, ‘Non-Blocking I/O’ 모델을 사용해 가볍고 성능이 뛰어나 높은 평가를 받고 있

inpa.tistory.com

 

iamkanguk

iamkanguk

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

[맵필로그] 로그아웃 + 회원탈퇴 기능 구현 기록

사이드 프로젝트/맵필로그 2024. 2. 14. 22:02

오늘은 맵필로그 프로젝트에서 로그아웃과 회원탈퇴 기능을 어떻게 구현했는지에 대해서 설명해보려고 한다.

먼저, 맵필로그에서는 카카오 로그인과 애플 로그인을 제공하고 있다는 것을 먼저 말씀드립니다!

 

크게 어렵지 않은 내용인 것 같아서 맛있는 거 드시면서 보셔도 충분합니당 ㅎㅎ

 

1.   로그아웃 기능

로그아웃 기능에서는 소셜쪽으로는 크게 기능구현을 할 게 없는 것 같다. 왜냐하면 애플 로그인은 회원탈퇴 시에만 Token을 Revoke 시켜주면 되기 때문에 따로 로그아웃 쪽에서는 할 게 없다. 그리고 카카오 로그인은 공식 문서를 보면 카카오에서 제공하는 Access-Token을 만료시키는 로직이 있는데 맵필로그 프로젝트에서는 카카오에서 주는 토큰을 따로 사용하지 않기 때문에 굳이 따로 처리하지는 않았다.

 

애플리케이션 단에서는 다음과 같이 처리했다.

  • 사용자 DB에서 FCM Token을 NULL 처리한다.
  • 필자의 경우 Access-Token + Refresh-Token을 사용하고 있는데, Redis에 저장되어 있는 Refresh-Token을 삭제해준다.
  • 그리고 Access-Token BlackList 기능을 사용하는데, Access-Token을 BlackList에 추가한다.

(1) FCM Token NULL 처리

이거는 사실 프로젝트의 성격상 다를 수 있는데 필자는 로그아웃을 하게 되면 알림을 유저에게 제공하지 않아야겠다고 생각해서 FCM Token을 NULL 처리 했다. 이거는 프로젝트 성격에 따라 처리하면 될 것 같다. 단순히 DB에서 NULL로 UPDATE만 해주면 되기 때문에 따로 코드 설명은 하지 않도록 하겠다.

(2) Redis에 저장되어 있는 Refresh-Token 삭제

로그아웃을 하게 되면 사용자 입장에서는 다시 로그인을 해야한다. 현재 맵필로그 쪽에서는 Refresh-Token을 통해 토큰을 재발급하는 로직이 있기 때문에 Redis에 저장되어 있는 Refresh-Token을 삭제해야 한다. 그러면 토큰을 재발급 받을 수 없고 새로 로그인을 해야한다.

그리고 아마 클라이언트 쪽에서는 저장하고 있던 Access-Token을 삭제할 것이다.

 

마찬가지로 Cache에서 단순히 삭제하는 것이기 때문에 코드 설명은 하지 않겠다.

(3) Access-Token BlackList

 

위의 코드는 필자가 Header에 담아서 보내주는 Access-Token을 받아서, 만료시간까지 포함해서 Controller에서 받아올 수 있도록 하는 Custom Decorator이다. BlackList를 적용한 이유는, 로그아웃을 하게 되면 Access-Token을 무효화 시켜주어야 하기 때문이다. 누군가 탈취하면 쓸 수 있기 때문!

 

당연히 이전에 사용자가 사용한 토큰은 무효화 시켜야 하는데 따로 Revoke 하는 기능은 제공되고 있지 않아서 Access Token을 BlackList에 저장시켜주고, 사용자 인증할 때 BlackList에 해당 키가 있는지 확인하고 없으면 인증을 통과한다. 이 때 Redis에 저장할 때 TTL은 Access-Token의 남은 만료시간(remainExpireTime)이다.

 

이렇게 기능을 구현하면 사용자에 대한 로그아웃은 어느정도 처리가 된 것 같다. 다음으로는 회원탈퇴 기능을 설명해보겠다!

 

2.  회원탈퇴 기능

 

위의 사진은 회원탈퇴 API의 Service 코드이다. 핵심인 부분만 조금 설명을 드려보도록 하겠다. 회원탈퇴 Flow는 다음과 같다.

  • 사용자 테이블에서 deletedAt 추가
  • 사용자 회원탈퇴 사유 테이블에 ROW 추가
  • Refresh-Token Redis에서 삭제 + Access-Token BlackList 추가
  • S3에서 사용자 프로필 이미지 삭제
  • (애플 로그인 유저의 경우) Revoke Token + (카카오 로그인 유저는 어떻게?)
  • 어제의 회원탈퇴 유저들을 파악하고 관련된 데이터를 삭제처리함 with CronJob

위의 기능들 중에서 설명이 조금 필요한 부분들을 설명드리도록 하겠다. 3번은 아까 로그아웃에서 설명했으니 생략!

 

(1) S3에서 사용자 프로필 이미지 삭제

 

사실 S3에서 굳이 삭제를 시켜줘야하나 싶긴 하다. 바로바로 삭제하지 않고 나중에 애플리케이션 쪽에서 사용하지 않는 이미지들을 한번에 다 가져와서 삭제처리 해줘도 될 것 같다. 일단 지금은 DB에 S3에 저장된 이미지의 key도 저장하는데 그걸 가져와서 이미지를 삭제처리 해준다. MulterBuilder 같은 경우에는 카카수님 블로그를 참고했으니 확인하면 좋을 것 같다!

 

(2) 애플 로그인 유저의 경우 Token Revoke + 카카오 로그인 유저는 어떻게?

 

먼저 시작전에 애플로그인 구현 기록에 대해서 보고 싶으시면 해당 포스팅을 확인하시면 좋을 것 같다! client_id와 같은 요소들은 따로 설명하지는 않겠다.

 

맵필로그에서는 애플 로그인할 때 애플에서 제공하는 refresh_token을 사용자 DB에 저장한다. 이 refresh_token을 가지고 애플 서버에 revoke 요청을 한다. 애플에서 제공하는 refresh_token은 유효시간이 없기 때문에 DB에 저장하고 사용할 수 있다.

 

그리고 카카오 로그인의 경우에는 Kakao Developers에 연결 끊기(unlink)가 있다. 이 부분은 좀 애매한 부분이 있다.

카카오 로그인에서 제공하는 access_token과 refresh_token은 모두 만료시간이 있다. access_token이 만료된 경우 refresh_token을 통해 갱신을 시켜주고 사용해야 한다.

 

그런데 맵필로그에서는 카카오에서 제공하는 토큰을 저장하지는 않는다. 물론 서버에서 저장해서 처리할 수 있지만, 이거는 클라이언트 분들에게 부탁을 드렸다. 클라이언트에서 로컬 저장소에 카카오 로그인을 할 때 token들을 저장하고, 앱단에서 전부 처리하기로 결정했다.

 

서버에서 처리하는게 맞긴 한 것 같은데 싶지만... 어쨌든 일단 위와 같이 구현했다!

 

(3) 어제의 회원탈퇴 유저 파악 후 관련 데이터 삭제 - CronJob

 

사실 CronJob으로 빼지 않고 사이드프로젝트 단계이기 때문에 회원탈퇴 API에서 전부 처리해도 괜찮긴 하다. 하지만 그냥 단순하게 경험으로 CronJob으로 빼서 처리를 해보고 싶다고 생각되어서 한번 해봤다.

 

매일 오전 5시에 Cron이 돌도록 설정을 했다. 그리고 어제 탈퇴한 사용자들 리스트를 가져온다.

그다음에 DB에 사용자가 생성한 Data들을 전부 불러와서 deletedAt을 추가해준다.

 

이렇게 필자처럼 Cron으로 빼서 하는 것도 하나의 경험이 될 수 있겠다 생각한다!


이렇게 로그아웃과 회원탈퇴 기능에 대해서 간단히 알아보았다. 크게 어려운 로직은 없던 것 같다. 다음에는 또 많이 고민한 로직에 대해서 설명하는 포스팅을 작성해보도록 하겠다!

 

iamkanguk

iamkanguk

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

[맵필로그] 아주 많이 고생한 애플로그인 구현 기록..! (Passport X)

사이드 프로젝트/맵필로그 2024. 2. 6. 14:27

우리 맵필로그 프로젝트에서는 이제 배포를 슬슬 준비하고 있다. Android와 iOS 모두 준비하고 있는데 알아보니까 iOS 앱스토어 배포를 위해서는 로그인 기능을 제공할 경우에는 애플 소셜 로그인이 필수로 구현되어 있어야 한다고 한다.

 

그래서 오늘은 약 3일간 고생한 애플 로그인을 어떻게 구현했는지에 대해서 알아보려고 한다. 아마 NestJS로 구현을 하시는 분들은.. 아실 거지만 생각보다 자료가 많이 없다는 것을 느끼셨을 것이다. 그래서 필자는 Spring으로 구현한 개발자분들의 자료도 많이 참고했었다.

 

지금부터 쭉 설명을 해보도록 하겠다.

 

1.  사전 준비물 및 언급

- 애플 개발자 계정이 꼭 필요하다. 혹시나 애플 로그인 기능을 구현하고자 하는 분들은 개발자 계정을 꼭 구매하길 바란다.

- 그리고 이 포스팅은 Passport 라이브러리를 사용하지 않는다. 필자는 웹사이트가 아닌 애플리케이션 배포를 목적으로 하기 때문에 Passport 라이브러리는 부적절하다고 판단했다.

- 또한 Redirect 개념이 없다. 웹사이트를 상대로 구현하는 것이 아니기 때문이다.

- 그리고 필자는 사용자 이름이 프로젝트에서 필요없기 때문에 따로 받아오지 않는다.

 

2.  애플 개발자 계정 설정

(1) 애플 개발자 사이트에 들어가서 Account로 들어간다.

(2) 식별자(Identifier)로 들어가준다.

(3) 식별자로 들어가서 파란색 플러스 버튼을 눌러주고 App ID를 선택해준다.

(4) 이후 App을 선택해주고 Description과 Bundle ID를 적어준다. 그리고 밑에서 Sign In With Apple을 클릭하고 Continue를 눌러준다.

Description은 AppID에 대한 설명을 입력하는 것이니 별로 중요하지 않다. Bundle ID는 보통 도메인의 역순을 사용한다고 한다. 사진에 보면 예시가 쪼꼬맣게 써있으니 참고해서 얼추 쓰면 된다.

 

여기까지 하면 일단 App ID는 세팅이 완료된 것이다.

(5) Service ID를 생성하라는 게시물이 많은데, 필자는 앱 배포를 목표로 하고있기 때문에 굳이 생성하지 않아도 된다. Service ID는 웹사이트를 기획할 때 생성해주면 된다.

(6) 메뉴로 돌아와서 Keys로 들어오고 마찬가지로 파란색 플러스 버튼을 눌러준다.

(7) 키 이름을 입력해주고 Sign in With Apple을 선택해준다. 그리고 옆에 Configure을 눌러주고 위에서 만든 App ID를 선택한다.

(8) 이후 저장하게 되면 새로운 키 ID가 생성되고 키를 다운로드 할 수 있다. 생성된 키는 다시 다운로드가 안되니까 잘 보관해두자.

 

이제 어느정도 세팅은 끝났다. 지금부터는 로그인 과정에 대해서 설명하고, 코드레벨로 넘어가보도록 하겠다.

 

3.  로그인 과정

사실 다른 소셜 로그인 보다 애플로그인은 많이 어려운 편에 속하는 것 같다. 그래도 최대한 간단하게 설명을 드려보도록 하겠다.

다른 개발자분이 해당 방식을 잘 설명해주셔서 나름 이해가 편했던 것 같다. 아래 참고링크에도 올려놓을테니 한번 들어가보시는 것도 좋을 것 같다.

Flow는 위의 그림과 아주 동일하다. 간단하게 중요한 것만 찝어내면..

  1. App에서 맵필로그 서버로 authorization code를 보내준다. 아마 App에서는 Apple로 부터 authCodeString, identifyTokenString, useridentifier를 받아오는 것으로 알고 있다.
  2. 맵필로그 서버에서는 App에서 보내온 authorization code(authCodeString)를 가지고 Apple 서버로 요청을 보내서 검증을 한다. 검증을 하게 되면 애플에서 access token, refresh_token, id_token 과 그 외의 정보들을 제공해준다.
  3. id_token을 까보면 그 안에 sub, email, 만료 정보 등이 있다. 우리는 이걸 가지고 이제 App과 맵필로그 서버 사이에서 회원가입 및 로그인 처리를 해주면 된다.

 

사실 Flow를 보면 그리 어렵지는 않다. 이런 과정들을 알기 까지 조금 힘들었던 것 같고, 코드적인 부분에서 많이 헤맸던 것 같다.

지금부터는 차근차근 코드를 설명해보려고 한다.

4.  코드 및 구현 과정 설명

(1) ENV 파일에 저장해야 할 것들

사실 코드보다 더 머리가 아픈게 ENV에 저장해야 할 키들이다. 진짜 너무너무 헷갈렸다. 어떤 자료는 이거, 다른 자료는 이거 각 블로그마다 설명하는게 달라서 어떤 걸 사용해야 할지 막막했는데 이제서야 정리가 된 것 같다.

  • KEY_ID: 위에서 만든 key로 들어가면 Key ID를 볼 수 있다. (Certificates, Identifiers & Profiles -> keys)
  • TEAM_ID: Identifiers에서 만들어놓은 거 들어가면 App ID Prefix를 볼 수 있다. 그게 Team ID다.
  • CLIENT_ID: 제일 골치아팠던 놈. 그냥 결론만 말한다. 나처럼 기능을 구현할 예정인 개발자라면 TEAM_ID 확인하는 곳 바로 밑에보면 Bundle ID라고 있다. 그걸 저장하자!!

(2) Secret Key (인증키) 생성하는 함수

Client Secret을 생성하는 자료는 애플 개발자 공식 문서에 있으니까 참고하면 좋을 것 같다.

 

- Header

 

  • alg(algorithm): 토큰 서명 알고리즘을 의미하는데 ES256을 사용하면 된다.
  • kid(keyid): KEY_ID를 의미한다.

- Payload

  • iss(issuer): TEAM_ID를 의미한다.
  • iat: 토큰 생성시간
  • exp(expiresIn): 토큰 만료시간
  • aud(audience): client_secret 유효성 검사 서버. https://appleid.apple.com을 사용한다.
  • sub(subject): CLIENT_ID를 의미한다.
/**
   * @summary 애플 Secret Key를 생성하는 함수
   * @author  Jason
   * @returns { string }
   */
  generateAppleClientSecret(): string {
    try {
      const applePrivateKey = fs.readFileSync(
        'apple-social-login-key.p8',
        'utf8',
      );

      return jwt.sign({}, applePrivateKey, {
        algorithm: 'ES256',
        expiresIn: 3600,
        audience: 'https://appleid.apple.com',
        issuer: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_TEAM_ID,
        ), // TEAM_ID ==> APP ID PREFIX
        subject: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_CLIENT_ID,
        ), // CLIENT_ID ==> APP BUNDLE ID
        keyid: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_ID,
        ), // APP KEY ID
      });
    } catch (err) {
      this.logger.error(`[generateAppleClientSecret] SecretKey 생성 오류`);
      throw new InternalServerErrorException(
        InternalServerExceptionCode.CreateAppleSecretKeyError,
      );
    }

 

먼저, try-catch문을 최대한 쓰려고 했다. 왜냐면 애플 로그인에서는 예상치 못하게 에러가 많이 발생하는 것 같았다. 그래서 에러마다 처리를 하기는 어려우니까 catch 문에서 에러를 받게되면 서버 내부 에러로 처리하게 했다.

 

인증키를 생성하는 이유는 나중에 애플 서버에 요청을 할 때 필요하기 때문이다. 먼저, 아까 위에서 세팅할 때 저장했던 p8 파일을 readFileSync를 통해 불러와준다. 그리고 sign을 통해 jwt 키를 만들어줘야 한다. 이 때 위에서 두껍게 되어있는 부분을 sign에 넣어줘야 한다.

 

(3) (2)에서 만든 Client_Secret + authorization_code를 가지고 Apple에 요청해서 데이터 받기

export interface IVerifyAppleAuthCode {
  access_token: string;
  refresh_token: string;
  id_token: string;
  token_type: string;
  expires_in: number;
}

/**
   * @summary 애플 authorization_code를 가지고 Apple 서버에 검증을 요청하고 나온 결과 반환
   * @author  Jason
   * @param   { string } code
   * @returns { Promise<IVerifyAppleAuthCode> }
   */
  async validateAppleSocialAccessToken(
    code: string,
  ): Promise<IVerifyAppleAuthCode> {
    try {
      return (
        await axios.post(
          'https://appleid.apple.com/auth/token',
          querystring.stringify({
            grant_type: 'authorization_code',
            code: code,
            client_secret: this.generateAppleClientSecret(),
            client_id: this.customConfigService.get<string>(
              ENVIRONMENT_KEY.APPLE_KEY_CLIENT_ID,
            ),
          }),
          {
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
            },
          },
        )
      ).data as IVerifyAppleAuthCode;
    } catch (err) {
      this.logger.error(
        '[validateSocialAccessToken] 애플 로그인 처리 중 에러 발생.',
      );
      throw new UnauthorizedException(
        UserExceptionCode.AppleSocialLoginTokenError,
      );
    }
  }

 

https://appleid.apple.com/auth/token으로 요청을 한다. 이 때 Content-Type은 application/x-www-form-urlencoded를 필수로 설정을 해야하고, body를 URL Encoding을 필수로 해야하기 때문에 필자는 querystring 라이브러리의 stringify를 사용했다.

 

그렇게 axios 요청을 해서 나온 결과값에서 data 부분만 우리는 가져오면 된다. 그러면 IVerifyAppleAuthCode 인터페이스 처럼 결과값이 나온다. 우리는 여기서 id_token만 사용하면 된다.

 

여기까지 하면 사실 작업은 거의 끝났다. 아래에 나머지 설명을 조금 더 하고 마무리 하겠다.

 

(4) id_token decode

  • id_token을 jwt.decode 하게 되면 다음과 같은 정보를 받게 된다.

 

우리는 여기서 sub와 email만 추출하면 된다. sub는 소셜 로그인 고유값이기 때문에 DB에 저장해서 사용자 식별자로 사용하면 된다. 그리고 email은 애플 로그인 이메일인데 애플 로그인 할 때 이메일을 가릴 수 있는걸로 아는데 이 때 email은 애플 쪽에서 private 하게 랜덤으로 만들어서 제공해준다. 

 

사실 우리 서비스에서 email도 크게 중요하지는 않기 때문에 중요하게 판단하지 않아도 괜찮겠다고 생각했다. 

 

(5) 주의 사항

4번 단계까지 마무리하면 이제 애플로그인은 어느정도 마무리 되었다고 할 수 있다. 이제 프로젝트 서버에서 JWT를 발급해주기만 하면 마무리가 된다. 이제는 이 글을 읽으시는 분들은 직접 데이터를 가공해서 작업할 수 있다고 생각한다.

 

한 가지 주의사항이 있다. 필자는 처음 개발할 때 클라이언트에서 제공받은 authorization_code(authCodeString)을 가지고 애플 서버에 요청(위에 validateAppleSocialAccessToken 메서드)을 보내는 걸 2번했었다. 

 

이전에 작성한 코드 스타일을 맞추기 위해서 그랬던 것 같다. 그리고 크게 성능에 영향을 미치지 않을 것이라고 판단했다. 하지만 카카오에서는 크게 문제가 없지만 애플에서는 이를 막고 있었다.

 

클라이언트의 authorization_code를 가지고 Apple에 요청을 다시 또 보내게 되면 그 code는 다시 또 사용할 수 없다. 그러면 에러에서 already used 라고 에러가 발생하게 된다.

 

그래서 우리는 무조건 처음 Apple 서버로의 요청을 통해 결과값을 받으면 처음 단계에서 무조건 저장을 해놓고 사용을 해야한다. 요청 이후 또 요청을 하게 되면 에러가 마구마구 발생할 것이니 이는 꼭 주의해야 한다.


지금까지 이렇게 시간을 아주 많이.... 투자한 애플로그인을 마무리했다. 카카오 로그인은 Kakao Developers가 너무너무 잘 작성되어 있고 자료도 많아서 구현하는데 어려움이 많이 없었다.

 

하지만 애플 로그인 같은 경우에는 문서도 친절하지 않고 Nest로 되어있는 자료들이 정말 많지 않아서 힘들었다. Stack Overflow에도 질문을 했는데 답변도 안달리고,,,ㅎ

 

어쨌든 필자가 하는 방법이 정확하지는 않을수도 있지만 그래도 이 글을 통해 다른 개발자 분들에게 조금이나마 도움이 되었으면 한다.

오늘은 나름 편하게 잠을 잘 수 있을 듯 하며.... ㅂ2

 

참고자료

- https://blog.devgenius.io/how-to-implement-apple-login-with-nestjs-in-seconds-b88f05abe847

 

How to implement Apple Login with Nestjs in seconds

Nestjs is a framework for building efficient, scalable Node.js server-side applications. It uses modern JavaScript, is built with…

blog.devgenius.io

- https://tlog.tammolo.com/posts/apple-login-02

 

Apple Login for Web (하) - Node + React 구현 - Tlog

리액트 + Node 서버에서 애플 로그인 (OAuth, React)

tlog.tammolo.com

- https://shxrecord.tistory.com/289

 

[Spring Boot]애플 로그인 구현

앱스토어 배포시에 애플 로그인이 필요하다는 말에 개발을 하게 됐었는데 구현이 다른 소셜 로그인에 비해 꽤나 복잡했었습니다. 언젠가 또 개발할 일이 있지 않을까라는 생각에 기록을 남겨봅

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

[맵필로그] Strict Mode 설정 + 발생한 이슈 + tsconfig.json 일부 분석

사이드 프로젝트/맵필로그 2024. 1. 22. 19:03

이제 express가 아니라 NestJS를 하면서 자연스럽게 TS와 친해지려고 노력하고 있다. 최근에 Nest로 프로젝트를 하면서 tsconfig.json 파일을 만질 때가 있었는데, 정확히 이 파일이 어떤 역할을 하는지 모르고 사용을 했던 것 같다.

 

그리고 프로젝트를 하는 도중에 내가 작성한 소스를 한번 쭉 그냥 보고 있었는데 최대한 any 타입을 지양하고 각종 메서드나 변수 등에 타입을 꼭 달아주었다. 그런데도 몇 군데 빼먹은 곳이 있었는데 생각해보니까 얼핏 이런걸 에러로 보여주는 기능이 있는데? 라고 알고 있었는데 적용이 안되어서 내가 실수한 부분이 몇 군데 있구나 싶었다.

 

보니까 strict 모드가 활성화 되어있지 않은 것. 그래서 이번 포스팅에서는 strict mode를 어떻게 활성화 했는지 (아주간단), strict mode를 키면서 에러 난거 하나 설명하기, 마지막으로 tsconfig.json 파일을 간단하게? 옵션 설명을 하는 글을 적어보려고 한다.

 

1.  Strict 모드 활성화 (strict: true)

Strict 모드는 tsconfig.json 파일에서 설정할 수 있다. Strict 모드는 모든 엄격한 유형 검사 옵션을 의미하는데 이 옵션을 사용하지 않는 것은 TS를 쓰지 않는 것이라고 할 수 있다. (왜 지금까지 안쓰고 있었을까...!)

 

{
  "compilerOptions": {
     "strict": true
  }
}

 

이 strict만 true로 설정하면 strict mode family 프로퍼티들이 전부 true로 설정된다. 하지만.. 안그래도 지금 이거 strict를 true로 설정하니까 각종 빨간줄이 엄청 많이 나오는데.. 하나씩 해결하기 넘 귀찮다...

 

만약에 이런 빨간줄이 귀찮다 ==> 선택적으로 몇 개는 false로 설정하면 될 것 같다.

 

  1. alwaysStrict --> 엄격모드에서 구문 분석 후 각 소스 파일에 "use strict" 코드 삽입
  2. strictNullChecks --> null 및 undefined 값에 대한 유형을 조정하는 옵션
  3. strictBindCallApply --> bind, call, apply 사용할 때 더욱 엄격하게 검사하는 옵션
  4. strictFunctionTypes --> 엄격한 함수 유형검사 사용
  5. strictPropertyInitialization --> 정의되지 않은 클래스의 속성이 생성자에서 초기화되었는지 확인하는 옵션
  6. noImplictAny --> 명시적이지 않은 any 타입이 지정될 경우 에러 발생
  7. noImplictThis --> 암시적 any 유형이 있는 this 표현식에서 오류 발생

위 7개가 프로퍼티들이다. 각 옵션에 대해서 설명은 다른 블로그를 통해 확인하시면 좋을 것 같다!

 

2.  Strict mode를 적용하면서 발생한 이슈 하나 공유!

has no initializer and is not definitely assigned in the constructor

 

타입 선언 후 초기값을 설정하지 않아서 생긴 문제이다. 이 문제는 어떻게 해결할 수 있을까?

 

  • strictPropertyInitialization을 false로 설정한다.
  • 확정 할당 어선셜 연산자 사용 -> !와 ?는 TypeScript에서 확정 할당 어선셜이라고 부른다. !는 컴파일러에게 선언은 하지 않았지만 무조건 값이 있다고 생각하자 라는 뜻이다. 그리고 ?는 필수가 아닌 속성이라고 알려주는 것이다.
  • 초기값 선언하기

 

3.  tsconfig.json 일부 분석

내가 궁금했던 옵션들 + 자주 적용한다는 옵션 위주로 간단하게 설명을 해보려고 한다. 두고두고 까먹을때마다 보려고 하니 참고해주길!

(1) skipLibCheck

선언 파일 유형 검사를 스킵할지에 대한 여부를 설정할 수 있는 옵션이다. 당연히 타입스크립트를 쓰니까 체킹을 해야하지 않아? 라고 생각할 수 있지만 만약에 프로젝트 규모가 크다면 상당한 시간이 소모될 수 있다. 그래서 이 옵션을 true로 설정하여 파일 타입 체크를 생략해서 컴파일 시간을 줄여줄 수 있다.

(2) declaration

이 옵션을 true로 설정하면 TS 파일을 JS 파일로 컴파일하는 과정에서 JS파일과 함께 d.ts 파일이 생성되게 한다.

보통 객체랑 함수를 쓰다보면 타입을 커스텀하는 경우가 생길텐데 이 때 타입들을 ts파일에 넣어도 되지만 용도가 다르기 때문에 분리하는 것이 좋다. 이럴 때 d.ts 파일을 만들어서 따로 타입만 관리하는데 사용되는 옵션이다.

(3) removeComments

컴파일 시 타입스크립트 소스의 주석을 모두 제거하는 것을 설정하는 옵션이다.

(4) experimentalDecorators / emitDecoratorMetadata

타입스크립트의 @Decorator를 사용하기 위해서는 true로 적용해야 작동한다.

(5) forceConsistentCasingInFileNames

파일의 이름을 대소문자 판별하게 하는 옵션이다. 프로그래밍 세계에서는 같은 알파벳이라도 대소문자를 모두 구분하기 때문에 가능한 true로 설정해서 사용하는 것이 좋다.

(6) resolveJsonModule

확장자가 .json인 모듈의 import를 허용하는 설정이다. 예를 들어 { name: "홍길동" } 이라는 JSON 데이터가 저장된 test.json이 있다고 가정해보자. TS파일에서 " import settings from './test.json'; " 으로 import를 하려고 하면 에러가 발생한다. 이 때 이 옵션을 true로 설정하게 되면 import가 가능해진다.

(7) sourceMap

컴파일 된 파일 디렉터리에 .js.map 파일이 만들어진다. 이 파일은 변환된 js 코드가 ts의 어디에 해당하는지 알려준다. 보통 디버깅에서 많이 사용한다고 한다.

 

실제로 타입스크립트 프로젝트를 배포하고 브라우저에서 개발자 모드를 이용해서 source 탭에서 보면 브라우저는 js만 인식하지만 매핑 파일에 의해 ts 파일을 인식하는 것을 볼 수 있다고 한다.

(8) noFallthroughCasesInSwitch

switch 문이 이상하면 에러를 내주는 옵션이다. 예를 들어서 switch 문에서 비어있지 않은 case라면 반드시 break 또는 return으로 case를 종료시키도록 에러를 내준다. 우리는 이를 통해서 의도치 않은 false through case에 의한 버그를 예방할 수 있다.

(9) baseUrl / paths

import 구문의 모듈 해석 시에 기준이 되는 경로를 지정한다.

노드 패키지는 따로 경로 없이 import를 하고 직접 만든 소스파일은 경로를 적어준다. 참고로 노드 패키지는 node_modules가 보통 프로젝트 최상단 경로에 있기 때문에 자동으로 인식을 해줘서 따로 경로를 적지 않아도 된다.

 

보통 상대경로로 import를 하는데 만약에 다른 경로에 파일을 만들어서 동일한 모듈을 import 하려고 한다면 위치 기준점이 달라져서 번거로울 수 있다. 그래서 우리는 baseUrl과 path 속성을 통해 절대경로로 import 한다.

 

baseUrl 속성에는 기본 경로로 설정해주고 아래에 paths 속성에 대해 절대경로를 지정하고 싶은 경로들을 지정해주면 된다.

{
  "compilerOptions": {
    "baseUrl": "./", // 절대 경로 모듈이 아닌, 모듈이 기본적으로 위치한 디렉토리 설정
    "paths": { // 'baseUrl'을 기준으로 상대 위치로 가져오기를 다시 매핑하는 항목 설정
      "@components/*": [
        "src/components/*" // import {} from '@components/파일' 할때 어느 경로로 찾아들어갈지 paths 설정
      ],
      "@utils/*": [
        "src/utils/*"
      ],
    },
    "outDir": "./dist", // 컴파일할때 js 파일 위치 경로 지정
  },
}

 

하지만 ts-node를 통해 소스파일을 실행하면 오류가 난다. 이유는 tsconfig.json 설정은 경로 alias만 준거지 실제 경로를 바꾼게 아니다. 그래서 tsconfig-paths와 tsc-alias 라는 모듈을 설치해야 한다.

(10) include

프로젝트에서 컴파일할 파일들을 지정하는 속성이다. 보통 와일드카드 패턴으로 지정을 해준다.

  • *: 해당 디렉토리에 있는 모든 파일
  • ?: 해당 디렉토리에 있는 파일들의 이름 중 한 글자라도 포함하면 해당
  • **: 해당 디렉토리의 하위 디렉토리의 모든 파일을 포함

참고자료

- https://velog.io/@gingaminga/%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-has-no-initializer-and-is-not-definitely-assigned-in-the-constructor

 

has no initializer and is not definitely assigned in the constructor

Typescript가 나를 괴롭히는구나..!

velog.io

- https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-tsconfigjson-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-%EC%B4%9D%EC%A0%95%EB%A6%AC#alwaysstrict

 

📘 타입스크립트 컴파일 설정 - tsconfig 옵션 총정리

타입스크립트 컴파일 설정 tsconfig.json은 타입스크립트를 자바스크립트로 변환 시키는 컴파일 설정을 한꺼번에 정의 해놓는 파일이라고 보면 된다. 프로젝트를 컴파일 하는데 필요한 루트 파일,

inpa.tistory.com

 

iamkanguk

iamkanguk

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

[맵필로그 + NestJS] Access-Token과 Refresh-Token 전략 정리와 수정해야 할 부분 in NestJS? (약간 장문주의)

사이드 프로젝트/맵필로그 2024. 1. 10. 16:48

필자는 맵필로그라는 프로젝트에서 사용자 인증에서 JWT 토큰을 사용하기로 결정했다. 하지만 단순히 Access Token만 사용하는 것이 아닌 Refresh Token 사용을 도입을 하기로 결정했다. 각 개발자분들 마다 어떤식으로 구현을 하는지 각각 다르기 때문에 인터넷에서도 각기 다른 자료들을 볼 수 있다.

 

그래서 필자는 이번 포스팅에서는 해당 프로젝트에서 JWT를 왜 도입했는지, 어떤식으로 Access Token과 Refresh Token 전략을 사용했는지에 대해서 정리를 해보려고 한다.

 

그리고 지금부터 Access Token은 AT로, Refresh Token은 RT로 표기하는 점 참고해주세요! 또한, 이 포스팅에서는 JWT의 자세한 개념에 대해서는 언급을 하지 않을 예정이니 다른 포스팅으로 한번 확인하고 들어오시면 좋을 것 같아요!

 

1.  JWT 방식을 사용한 이유는?

필자는 이전 프로젝트에서도 JWT를 사용한 경험이 있다. 그래서 이번 프로젝트에서는 쿠키 + 세션 조합을 활용한 인증 방식을 적용해볼까 초기 프로젝트에서 고민을 했었다. 하지만 해당 프로젝트는 배포를 목적으로 진행을 하고 있기 때문에 다음과 같은 이유에서 JWT 방식을 선택하게 되었다.

 

  • 모바일 어플리케이션 서비스를 제공할 예정이기 때문에 세션 보다는 JWT가 적합하다고 판단했다. 실제로 몇몇 블로그 포스팅에서 모바일 앱에서는 세션 방식을 사용할 수는 있지만 잘 사용하지는 않는다는 글을 보았다. 그에 대한 이유를 간단하게 적어보면 웹과 정보 유지 방식(쿠키 관련)이 다르고, 앱은 사용자와 1대1 매칭이 되기 때문에 정보 탈취 가능성이 적기 때에 JWT를 사용해도 괜찮다고 판단했다.
  • 세션은 사용자가 늘어나면 늘어날수록 서버 메모리에 부하가 커질 수 있다. 우리는 배포를 해서 실제 운영을 할 목적이기 때문에 비용 측면에서 장기적으로 봤을 때는 토큰 방식이 유리할 것이라고 판단했다. (서버 사양이 낮기 때문 ㅠ)
  • RT 방식은 사용해 본 적이 없기 때문에 단순히 적용해보고 싶다는 호기심 때문에 적용한 것도 있긴 하다..!

 

2.  Refresh Token을 적용한 이유와 Refresh Token 개념 간단 정리?

과거 처음 프로젝트를 진행할 때는 AT의 유효기간을 365일로 그냥 편의상 설정하고 개발을 했었다. 당연히 이렇게 적용하면 안되는 걸 알지만 그랬었던 때가 있었다. 하지만 이번에는 실제로 배포를 목표로 하기 때문에 보안적인 측면도 어느정도 고려를 해야한다고 판단되었다.

 

만약 AT만 사용한다고 가정해보면 AT는 클라이언트 쪽에서 저장이 되고, 서버에서는 누가 토큰을 보낸지 모른채 토큰만 보고 검증을 하게 되는데 만약에 토큰이 외부 공격으로 탈취가 된다면 토큰이 만료되기 전까지 외부 공격자가 접근을 할 수 있다는 문제점이 있다.

 

그래서 AT는 결국 만료시간을 짧게 부여를 해야한다. 왜냐면 토큰이 제 3자에 의해 탈취가 되더라도 만료시간이 매우 짧기 때문에 해킹에 대해서 보호받을 수 있기 때문이다. 그래서 사용자는 계속해서 AT를 발급받아야 하는데 특별한 조치가 없다고 가정한다면 사용자 측면에서 비효율적이고 불편한 경험을 하게 될 것이다. 따라서 결국에는 토큰의 만료시간을 늘려서 사용자의 이용에 불편함이 없게 해야하는데 만료시간을 늘릴 수 있는 방법이 바로 RT이다.

 

그래서 간단하게 정리해보자면!

 

  • RT를 사용한 이유실제로 배포해야 하는 프로젝트이기 때문에 AT만 사용했을 때의 보안적인 문제점을 해결하고자 도입했다.
  • AT는 클라이언트가 갖고 있는 실제 유저의 정보가 담긴 토큰이다. 서버에서 "당신 누군지 명함을 보여줘!" 라고 요청을 할 수 있는데 이 때 필요한 토큰이라고 생각하면 된다.
  • RT는 새로운 AT를 발급받기 위해 사용하는 토큰이다. 그러면 잦은 로그인/로그아웃을 피할 수 있게 된다. 보통 서버측면(대표적으로는 DB나 Redis)에 저장한다. 그리고 Payload에는 아무것도 저장하지 않아도 되고 UUID나 사용자 정보를 담아도 무방하다고 한다.

 

3.  Mappilogue에서는 어떤식으로 AT와 RT를 생성하나요? (이제부터 핵심!)

이번 문단에서는 그냥 이 프로젝트에서는 이런식으로 토큰 인증 방식을 구현했구나~ 라고만 봐주시면 좋을 것 같다. 이후 문단에서 해당 프로젝트에서 어떤 부분을 수정해야 할지도 같이 정리할 예정이다.

 

필자는 NestJS를 사용해봤다고 가정하고 설명을 진행할 계획이다. 코드가 이쁘진 않지만.. 어느정도 이해는 하실 수 있을 것이라고 생각하고 설명을 진행해보겠다!  아래 코드는 사용자의 토큰을 설정해주는 함수이다. 로그인 또는 회원가입 직후, 토큰 재발급 시 해당 메서드를 호출한다고 생각하면 된다.

/**
  * @summary 사용자 토큰 설정
  * @author  Jason
  * @param   { number } userId
  * @returns { Promise<TokenDto> }
*/
async setUserToken(userId: number): Promise<TokenDto> {
	const accessToken = this.jwtHelper.generateAccessToken(userId);
	const refreshToken = this.jwtHelper.generateRefreshToken(userId);
	await this.jwtHelper.setRefreshTokenInRedis(userId, refreshToken);
	return TokenDto.from(accessToken, refreshToken);
}

/**
  * @summary Access-Token 생성하는 함수
  * @author  Jason
  * @param   { number } userId
  * @returns { string }
*/
  generateAccessToken(userId: number): string {
    return this.jwtService.sign(
      { userId },
      {
        secret: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.ACCESS_SECRET_KEY,
        ),
        expiresIn: this.getAccessTokenExpireTime(),
        subject: TokenTypeEnum.ACCESS,
      },
    );
  }

  /**
   * @summary Refresh-Token 생성함수
   * @author  Jason
   * @param   { number } userId
   * @returns { string }
   */
  generateRefreshToken(userId: number): string {
    return this.jwtService.sign(
      { userId },
      {
        secret: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.REFRESH_SECRET_KEY,
        ),
        expiresIn: this.getRefreshTokenExpireTime(),
        subject: TokenTypeEnum.REFRESH,
      },
    );
  }

  /**
   * @summary Access-Token 만료시간 가져오는 함수
   * @author  Jason
   * @returns { string }
   */
  getAccessTokenExpireTime(): string {
    return this.customConfigService.get<string>(ENVIRONMENT_KEY.NODE_ENV) ===
      'development'
      ? '30d'
      : '1h';
  }

  /**
   * @summary Refresh-Token 만료시간 가져오는 함수
   * @author  Jason
   * @returns { string }
   */
  getRefreshTokenExpireTime(): string {
    return this.customConfigService.get<string>(ENVIRONMENT_KEY.NODE_ENV) ===
      'development'
      ? '90d'
      : '14d';
  }

(1) 유효기간

위의 코드에서 setUserToken 메서드를 제외하고 쭉 봐주시면 좋을 것 같다!

 

결론부터 말하면 AT는 1시간, RT는 2주로 설정했다. 그리고 개발 단계에서는 AT를 30일, RT를 90일로 설정했다. 그리고 jwt-sign 할 때 AT와 RT에 대한 SECRET KEY를 각각 설정해주었고 subject로는 AT인지 RT인지 구별할 수 있는 구분자를 넣었다. 추가로 AT와 RT 모두 userId만 넣어주었다. AT에는 userId가 아닌 다른 부가적인 정보들도 넣을지는 아직 제대로 결정하지 않았다.

 

RT는 AT가 만료되었을 때 사용하는 토큰이기 때문에 만료시간을 길게 잡았다. 실제로 AT는 30분-1시간, RT는 보통 2주의 시간을 설정해준다고 해서 똑같이 따라가게 되었다.

(2) RT의 저장소는 DB? Redis? + 선택한 이유

결론을 먼저 말씀드리면 필자는 Redis를 선택하게 되었다. Redis를 선택한 이유는 key-value 형태로 데이터를 저장할 수 있으며 TTL을 적용해서 DB보다 간단하게 토큰을 관리할 수 있을 것이라고 판단되어서 Redis를 선택했다. TTL은 위의 유효기간으로 설정했다.

(3) 생성결과

 

setUserToken 메서드의 결과로 Response를 제공하면 다음과 같다. AT와 RT를 제공하는 것을 확인할 수 있다.

 

<지금까지 정리>

  • 만료시간은 AT는 1시간, RT는 2주
  • RT의 저장소는 Redis
  • setUserToken 메서드가 실행될 때의 Flow는 다음과 같다.
    • AT와 RT를 새로 생성한다. (만료시간을 적용해서)
    • 새로 생성된 RT는 Redis에 사용자PK를 key로 해서 저장해준다. (ex: refresh_userId_PK)
  • RT가 Redis에 저장되었으면 새로 생성된 AT와 RT를 쌍으로 DTO를 생성해서 Return 한다.

 

4.  토큰 재발급은 어떻게 진행되는지 설명해주세요!

코드는 조금 이쁘게.. vscode의 CodeSnap으로 찍었습니다.. 보기 좋으라구...

 

  • 토큰 재발급 API는 Body로 클라이언트에게 넘겨준 RT를 받는다.
  • 이전에 RT의 Payload에 사용자PK를 넣어주었다. RT를 Decode를 해서 사용자PK를 받아오고, 사용자PK를 가지고 DB에서 사용자를 조회해서 유효한 사용자인지 확인한다. 그리고 RT와 Payload 그리고 사용자 조회 결과를 가지고 RT가 유효한지 확인한다. 왜냐면 Malformed된 RT일수도 있을 가능성이 있기 때문이다. RT가 유효한지 확인하는 조건은 다음과 같다.
    • 사용자 조회결과가 있는지
    • RT의 Payload에 userId가 있는지 + subject로 RT가 맞는지? (아까 위에서 subject로 AT인지 RT인지 구분자를 넣어준다고 언급함)
    • 당시 시점에 Redis에 저장되어 있는 RT와 동일한지 확인
    • 위의 3개 조건에 전부 OK한다면 정상 유저로 간주한다.
  • 클라이언트로 받은 토큰이 유효하지 않은 RT라면 에러를 발생시켜주고 그렇지 않다면 위의 setUserToken 메서드를 통해 새롭게 AT와 RT를 발급 + RT는 Redis에 저장해준다 => RTR (Refresh-Token-Rotation) 도입

위의 Flow가 전체적인 토큰 재발급 로직이다. 잘못된 부분이 있을 수 있다고 생각되기는 하는데 최대한 많이 고민하고 분석한 결과는 다음과 같다는 점 양해 부탁드립니다..ㅎㅎ 언제나 날카로운 피드백은 환영입니다 상처 전혀 안받습니다!!

 

<2024-02-14 추가>

현재 로그아웃 및 회원탈퇴 할 때 Access-Token을 클라이언트 쪽에서 삭제는 해주지만, 만료 전이기 때문에 사용이 가능한 이슈가 있었다. 그래서 필자는 Redis를 사용해서 Access-Token을 BlackList에 추가하는 방식을 선택했다.

 

하지만 토큰 재발급 API에는 BlackList에 추가하는 방식은 도입하지 않았다. 왜냐하면 토큰 재발급 같은 경우에는 클라이언트에서 Access-Token의 만료시간이 임박한 경우에 API를 호출하는 방식이기 때문에 Access-Token이 거의 만료된 상태이기 때문에 굳이 BlackList에 추가하지 않아도 된다고 판단했다.

 

위와 관련된 내용은 해당 링크에서 확인할 수 있다.

 

5.  RTR (Refresh-Token-Rotation) 도입 (마지막이에용)

 

만약에 RT까지 탈취당했다고 가정해보자. 그러면 어떤 상황이 발생할 수 있을까? RT는 AT를 재발급 하는 용도로 사용되기 때문에 탈취되면 만료가 될 때 까지 공격자가 계속 사용할 수 있다. 문제는 stateless한 특징 때문에 서버는 토큰이 탈취된지도 모른다. 필자는 이런 경우 때문에 RTR을 도입하게 되었다.

 

Rotation 방법은 토큰을 재발급할 때 AT만 제공하는 것이 아니라 RT까지 같이 제공하는 것이다. 즉, RT를 딱 한 번만 사용할 수 있게 만드는 것이다. 이 방식을 사용하면 이미 사용된 RT를 사용하게 되면 서비스 측에서 탈취를 확인해서 조치할 수 있다.

 

여기서 이미 사용된 RT를 사용했는지 확인하는 것은 이전 문단의 "당시 시점에 Redis에 저장되어 있는 RT와 동일한지 확인" 부분이 될 수 있을 것 같다. 왜냐면 Redis에 저장되어 있는 RT는 가장 최근의 RT이기 때문이다.

 

6.  결론

지금까지 길다면 길고? 짧다면 짧...은? 맵필로그 프로젝트에서 어떤식으로 토큰 제도를 적용했는지 쭉 설명해봤다. 최대한 자세하게 설명해보려고 노력했는데 잘 작성한지는 잘 모르겠다.

 

필자는 이 포스팅을 정리해보면서 좀 수정을 해보거나 고려해보면 좋을 부분을 조금 찾아봤다.

(1) 클라이언트에게 RT를 Response로 제공하는 것이 맞는가?

필자가 Response로 제공한 이유는 모바일 애플리케이션과 협업이기 때문이라고 할 수 있다. 모바일에서는 사실 토큰을 탈취하기 쉽지 않기 때문에 이렇게 제공을 했다고 할 수 있다. 하지만 그래도..? 안전하지는 않지 않나? 이 부분에 있어서는 조금 더 대안을 찾아보던가 해야겠다.  웹 브라우저면 RT를 Cookie에 저장해서 보내주면 그나마 더 보안적인 측면을 가져갈 수 있다고 생각하지만 모바일에서는 잘 모르겠다..ㅎ

 

근데 지금 생각해보면 모바일에서는 Cookie가 안되나? 사용할 수 있을텐뎅~ 한번 더 알아보자!

(2) 토큰 재발급 API에서 Body로 RT를 받는게 맞는지?

이것도 1번과 연결될 수 있을 것 같다. 조금 더 생각해보자!

(3) RTR의 문제점은 어떻게 해결할까?

RTR의 문제점은 공격자가 정상 유저보다 먼저 RT를 Rotation 했을 경우에 발생한다. 기존의 AT와 RT는 새로운 토큰으로 갱신되고 결국 Redis에는 공격자가 재발급 받은 RT가 저장될 것이고, 정상유저는 AT를 재발급 받으려고 해도 정상유저가 가지고 있는 RT는 Redis에 존재하지 않기 때문에 재발급을 받을 수 없다.

 

물론 재로그인을 해서 RT를 받을 수는 있지만 해커는 기존에 탈취한 RT로 재발급을 받을 수 있기 때문에....! 이 부분에 있어서도 많은 고민을 해보자! 고민이 끝도 없다.

(4) RT 유효성 검사가 실패했을 경우에 대한 대처

위의 코드를 보면 아시겠지만 필자는 단순히 에러 발생을 했다. 하지만 RT 유효성 검사가 실패한 것은 물론 개발자가 잘못 로직을 작성한 것일 수도 있지만 공격자가 악의적인 Request를 보냈을 것이라고 판단할 수 있다. 따라서 단순히 에러를 뱉는 것 보다는 해당 유저를 로그아웃 처리를 해서 토큰을 전부 무효화처리를 해줄 수 있을 것 같다. 이 부분은 한번 고려해서 적용해보려고 한다.

 

추가로 방금 참고자료 부분에서 좋은 글귀를 확인했는데, RTR에서 결국 공격자가 새로운 AT를 들고 있을 수 있는데 이 AT에 대해서 BlackList 처리를 해도 괜찮을 것 같다는 것을 봤다. 이 부분도 한번 쫌 알아보고 적용해볼 수 있으면 해보자 ㅎㅎ 끝도없넹~

 

마지막으로 필자는 이 토큰전략에 있어서 많은 시간과 고민을 투자했다. 진짜 항상 문제점이 뭐가 있을까? 고민을 했던 것 같다.

나와 같이 많은 고민이 되는 개발자분들에게 조금이나마 도움이 되었으면 한다 :)


참고자료

 

- https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

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

방명록