[Nest.js] Nest.js에서 Firestore 커스텀 모듈 만들어서 활용하기

resilient

·

2022. 4. 3. 22:57

728x90
반응형

요즘 회사 프로젝트에서 Firestore를 사용하고 있습니다. GCP 생태계에서 개발을 하고 있기 때문인데요.

Nest.js에서 Firestore을 사용하다 보니 구글에서 제공해주는 sdk를 사용하고 있지만 한계에 부딪혔습니다. 바로 하나의 서비스에서 여러 개의 Firestore 프로젝트를 가져와서 연결을 해줘야 하는 것이었습니다.

열심히 찾아봤지만 multiple instance를 single project에서 사용할 수 없다는 결과만 나왔죠..

Firebase(Firestore) doesn't support multiple instance in a single project.
 

How to create multi environment DB's with Firestore

I've been looking at how to create multiple Firestore instances in Firebase, I need different Db's for prod, staging and development. I read the documentation and seems that I only need to modify t...

stackoverflow.com

 

하지만! 오랜 고민 끝에 저희 팀 리더분이 제시해주신 로직으로 구현을 시도했고, 결과는 성공적이었습니다. 그 과정을 정리해보고자 합니다.

 

0. 하나의 프로젝트에서 하나의 Firestore 인스턴스를 사용하는 경우

 

하나의 Firestore 인스턴스를 사용하는 경우는 간단합니다. Nest.js 아키텍처에 맞게 Module을 먼저 만들어주고, configService DI해준 뒤, Service 레이어에서 사용하면 되죠.

 

아래 코드들을 보면서 설명을 해보겠습니다.

 

먼저 가장 중요한 firestore 패키지부터 먼저 설치해야겠죠? 아래와 같이 yarn 패키지 관리자로 패키지를 설치해줍니다.

yarn add --save @google-cloud/firestore

 

 

// firestore.provider.ts
export const FirestoreDatabaseProvider = 'firestoredb';
export const FirestoreOptionsProvider = 'firestoreOptions'
export const FirestoreCollectionProviders: string[] = [];

 

먼저 firestore.provider.ts라는 파일을 만들고 위와 같이 코드를 작성해줍니다. 파일명 그대로 provider을 설정해주는 파일인데, 여기서 중요한 부분은 FirestoreCollectionProviders입니다. Array안에 컬렉션들을 넣어주면 Array안에 있는 컬렉션들을 불러온 뒤, 매핑해서 FirestoreDatabaseProvider에 의존성을 주입해주는 역할을 하죠.

 

 

import { Module, DynamicModule } from '@nestjs/common';
import { Firestore, Settings } from '@google-cloud/firestore';
import {
  FirestoreDatabaseProvider,
  FirestoreOptionsProvider,
  FirestoreCollectionProviders,
} from './firestore.providers';

type FirestoreModuleOptions = {
  imports: any[];
  useFactory: (...args: any[]) => Settings;
  inject: any[];
};

@Module({})
export class FirestoreModule {
  static forRoot(options: FirestoreModuleOptions): DynamicModule {
		const optionsProvider = {
		  provide: FirestoreOptionsProvider,
		  useFactory: options.useFactory,
		  inject: options.inject,
		};
		const dbProvider = {
		  provide: FirestoreDatabaseProvider,
		  useFactory: (config) => new Firestore(config),
		  inject: [FirestoreOptionsProvider],
		};
    const collectionProviders = FirestoreCollectionProviders.map(providerName => ({
      provide: providerName,
      useFactory: (db) => db.collection(providerName),
      inject: [FirestoreDatabaseProvider],
    }));
    return {
      global: true,
      module: FirestoreModule,
      imports: options.imports,
      providers: [optionsProvider, dbProvider, ...collectionProviders],
      exports: [dbProvider, ...collectionProviders],
   };
  }
}

 

그다음에는 FirestoreModule을 만들어줍니다. 먼저 forRoot라는 메서드를 만들어줍니다. 코드에서 많이 보이는 useFactory는 Provider가 가지고 있는 데이터들을 반환해주는 역할을 합니다. forRoot 메소드 안에 있는 OptionsProvider에서 inject 부분을 보면 options.inject라고 되어있는데요. ConfigService에서  필요로 하는 의존성 주입 옵션이라고 생각하면 됩니다.

 

dbProvider을 보겠습니다. 여기서 중요한데요. useFactory에서 config 설정에 맞춰서 new Firestore()을 통해 새로운 인스턴스를 반환해주는 것을 볼 수 있습니다. 이 부분이 바로 Firestore인스턴스를 만들어서 사용하게 해주는 아주 중요한 부분이라고 볼 수 있죠.

 

FirestoreModule에 있는 이 부분으로 인해 인스턴스를 하나밖에 생성할 수 없게 되고, 결과적으로는 이 부분을 우회(?) 하는 방법을 통해서 여러 개의 인스턴스를 사용할 수 있게 합니다. 

 

자 그럼 이제 Nest.js에서 모든 의존성 주입을 담당하고 있는 app.module.ts로 가서 ConfigService 설정을 해주겠습니다.

 

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FirestoreModule } from './firestore/firestore.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    FirestoreModule.forRoot({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        keyFilename: configService.get<string>('SA_KEY'),
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

먼저, SA_KEY를 통해서 configService에 있는 정보를 가져오는데요. configService는. env 환경변수 파일에서 데이터를 추출할 수 있게 해 줍니다.. env안에 있는 SA_KEY = xxx라는 값을 가져오게 되는 것이죠.

 

설정은 모두 끝났습니다. 직접 Firestore와 연동을 한 뒤, 컬렉션들을 CRUD 해보는 코드를 작성해보겠습니다.

 

// src/projects/document/apisitedocument

export class ApiSiteDocument {
  static collectionName = 'site-configurations';

  authApiKey: string;
  loginUi:{
    footer: string;
    showGoogleLogin: boolean;
    title: string;
  };
  projectId: string;

}

 

위와 같이 먼저 Firestore의 컬렉션 이름을 static으로 선언해준 뒤, Document에 넣을 데이터들의 타입들을 지정해줍니다. 지정이 끝났으면 위에서 살짝 언급했듯이, firestore.providers.ts 파일에 들어가서 FirestoreCollectionProviders Array에 collectionName이 담기도록 아래와 같이 코드를 작성해줍니다.

 

// firestore.providers.ts
import { ApiSiteDocument } from "src/projects/documents/sites.document";

export const FirestoreDatabaseProvider = 'firestoredb';
export const FirestoreOptionsProvider = 'firestoreOptions'
export const FirestoreCollectionProviders: string[] = [
    ApiSiteDocument.collectionName
];

 

여기까지 했으면, 이제 진짜 Firestore와의 연동은 끝나고 Service 로직만 구현하면 됩니다!

 

@Injectable()
export class ProjectsService {
  private logger: Logger = new Logger(ProjectsService.name);

  constructor(
    @Inject(ApiSiteDocument.collectionName)
    private apiSiteCollection: CollectionReference<ApiSiteDocument>,

    @Inject(AppFirestoreManager)
    private appFireStoreManager: AppFirestoreManager
  ) { }

  // projectId 생성 (아마도 이 api를 직접쓸일은 없지 않을까...)
  async createProject(object: CreateProjectDto): Promise<ApiSiteDocument> {

    const { authApiKey, loginUi, projectId } = object;
    const docRef = this.apiSiteCollection.doc();
    await docRef.set({
      authApiKey,
      loginUi,
      projectId
    });
    const apiSiteDoc = await docRef.get();
    const apiSite = apiSiteDoc.data();
    return apiSite;
  }

  // project안의 모든 문서 불러오기
  async getAllProjects(): Promise<ApiSiteDocument[]> {
    const snapshot = await this.apiSiteCollection.get();
    console.log("snapshot:", snapshot);
    const apiSite: ApiSiteDocument[] = [];
    snapshot.forEach(doc => apiSite.push(doc.data()));
    return apiSite;
  }
}

 

위와 같이 서비스 로직을 구현한 뒤, 서버에 요청을 해보면 정상적으로 Firestore안에 있는 데이터들이 return되는 것을 확인 할 수 있습니다.

 

 

1. 이제 하나의 프로젝트에서 여러 개의 Firestore 인스턴스를 사용하는 경우를 살펴보겠습니다.

 

방법은 간단한데요. multiple-firestore.ts 라는 파일을 만들고 아래와 같이 코드를 작성해보겠습니다.

 

import { Injectable } from "@nestjs/common";
import { Firestore } from "@google-cloud/firestore";

@Injectable()
export class AppFirestoreManager {
    private readonly firestoreInstances: Map<string, Firestore> = new Map();
    public constructor() { }

    getFirestoreInstance(projectId: string) {
        if (!this.firestoreInstances.has(projectId)) {
            const newFirestoreInstance = new Firestore({
                projectId: projectId
            })
            this.firestoreInstances.set(projectId, newFirestoreInstance);
        }
        return this.firestoreInstances.get(projectId);
    }
}

 

설명을 해보자면, AppFirestoreManager를 통해 firestoreInstances들이 담긴 해시맵을 관리할 수 있게 했습니다.

 

getFirestoreInstance 메서드에서 projectId를 받으면 해당 proejctId가 setting option으로 적용된 새로운 Firestore의 인스턴스가 만들어지게 되면 proejctId가 key에 담기고 만들어진 Firestore의 인스턴스가 value에 담겨서 거대한 Map안에 차곡차곡 쌓이게 됩니다.

 

그리고 해당 proejctId로 get을 했을 때, 이미 proejectId가 key로 있는 Firestore의 인스턴스가 있다면 반환해주는 하나의 모듈을 만든 것입니다. 

 

위 모듈을 사용해서 구현하는 방법으로 하나의 서비스에서 여러 개의 프로젝트가 가지고 있는 Firestore을 사용할 수 있게 했습니다.

 

// AppFireStoreManager로 각 프로젝트 불러오기

  async getProjectByFs(projectId: string) {
    const instance = this.appFireStoreManager.getFirestoreInstance(`${projectId}`);
    console.log(instance);
    const ref = instance.collection("site-configurations") as CollectionReference<ApiSiteDocument>;
    const snapshot = await ref.get();
    const apiSite: ApiSiteDocument[] = [];
    snapshot.forEach(doc => apiSite.push(doc.data()));
    return apiSite;
  }

 

 

2. 정리

 

프로젝트를 진행하던 도중 꽤나 중요했던 부분이었는데, 해결방법이 마땅히 떠오르지 않아 진척이 없던 도중 팀 리더분이 로직을 제시해주셨고, 해당 로직을 Nest.js안에서 작동할 수 있게 구현해봤습니다.

 

다행히, 원하던 대로 구현돼서 프로젝트에 다시 박차를 가할 수 있게 되었고 과정을 기록하고 싶어서 자세하게 정리해봤습니다. 앞으로도 프로젝트를 하면서 벽에 부딪혔을 때, 포기하지 않고 해결하는 과정을 자세하게 기록하고, 학습하면서 확실하게 공부를 해야겠다는 생각을 다시 한번 하게 했던 값진 경험이었습니다.

 

감사합니다.

반응형