Jay's Cookbook
Menu
  • Tags
  • Categories
  • Projects
Computer Science
OS
Network
Data Structure
Algorithm
Language
Code Architecture
Python
Javascript
Typescript
Java
Backend
Backend Theory
TypeORM
Node.js
NestJS
FastAPI
Frontend
HTML/CSS
React
Next.js
Data Engineering
DE Theory
MySQL
MongoDB
Elastic
Redis
Kafka
Spark
Airflow
AI
Basic
Pytorch
NLP
Computer Vision
Data Analytics
Statistics
Pandas
Matplotlib
DevOps
Git
Docker
Kubernetes
AWS
[NestJS] 검색
backend
NestJS

[NestJS] 검색

Jay Kim
Jay Kim 02 Oct 2024
[NestJS] CRUD [NestJS] 파일 업로드

Table of Contents

  • 엘라스틱서치 모듈 설치
  • 엘라스틱서치 사용
  • MySQL의 데이터 생성/수정/삭제가 발생할 때 Elasticsearch 동기화 시키기

엘라스틱서치 모듈 설치

npm i @nestjs/elasticsearch
// src/search/decorators/search.module.ts

import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import {ElasticsearchModule} from "@nestjs/elasticsearch";
import {ProductModule} from "../product/product.module";
import {EventEmitterModule} from "@nestjs/event-emitter";
import {SearchEventService} from "./search.event.listener";

@Module({
  imports: [
    ElasticsearchModule.register({
      node: 'http://localhost:9200'
    }),
    EventEmitterModule.forRoot(),
    ProductModule
  ],
  controllers: [SearchController],
  providers: [SearchService, SearchEventService]
})
export class SearchModule {}

엘라스틱서치 사용

  • 인덱스 없으면 생성
  • 초기에 MySQL에 저장된 데이터 인덱싱할 때 search/init-indexing API 실행
  • search API 로 통합 검색
// src/search/decorators/search.controller.ts

import {Controller, Get, Query, UseInterceptors} from '@nestjs/common';
import {SearchService} from "./search.service";
import SearchEventInterceptor from "./interceptors/search.event.interceptor";

@UseInterceptors(SearchEventInterceptor)
@Controller('search')
export class SearchController {
  constructor(private readonly searchService: SearchService) {}

  @Get('init-indexing')
  async indexAllProducts() {
    await this.searchService.initIndexAllProducts()
  }

  @Get()
  async search(@Query() query: string) {
    return await this.searchService.searchProducts(query)
  }
}
// src/search/decorators/search.service.ts

import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import {ElasticsearchService} from '@nestjs/elasticsearch';
import {ProductEntity} from "../product/entities/product.entity";
import {InjectRepository} from "@nestjs/typeorm";
import {Repository} from "typeorm";

@Injectable()
export class SearchService implements OnApplicationBootstrap {
  constructor(
    private readonly  esService: ElasticsearchService,
    @InjectRepository(ProductEntity) private readonly productRepo: Repository<ProductEntity>,
  ) {}

  onApplicationBootstrap() {
    const indexObjArr = [
      {
        indexName: "train.ecommerce.products",
        indexSetting: {
          index: "train.ecommerce.products",
          settings: {
            number_of_replicas: 3,
            index: {
              analysis: {
                tokenizer: {
                  korean_nori_tokenizer: {
                    type: "nori_tokenizer",
                    decompound_mode: "mixed"
                  },
                  korean_ngram_tokenizer: {
                    type: "ngram",
                    token_chars: ["letter"],
                    min_gram: 2,
                    max_gram: 3
                  }
                },
                filter: {
                  stopwords: {
                    type: "stop",
                    stopwords: " "
                  }
                },
                analyzer: {
                  korean_nori_analyzer: {
                    type: "custom",
                    tokenizer: "korean_nori_tokenizer",
                    filter: ["lowercase", "stop", "trim", "stopwords", "nori_part_of_speech"],
                    char_filter: ["html_strip"]
                  },
                  korean_ngram_analyzer: {
                    type: "custom",
                    tokenizer: "korean_ngram_tokenizer",
                    filter: ["lowercase", "stop", "trim", "stopwords",  "nori_part_of_speech"],
                    char_filter: ["html_strip"]
                  }
                }
              }
            }
          },
          mappings: {
            properties: {
              name: {
                type: "text",
                copy_to: "unified_field"
              },
              description: {
                type: "text",
                copy_to: "unified_field"
              },
              category: {
                type: "text",
                copy_to: "unified_field"
              },
              brand: {
                type: "text",
                copy_to: "unified_field"
              },
              "unified_field": {
                type: "text",
                analyzer: "standard",
                search_analyzer: "standard",
                fields: {
                  nori: {
                    type: "text",
                    analyzer: "korean_nori_analyzer",
                    search_analyzer: "korean_nori_analyzer"
                  },
                  ngram: {
                    type: "text",
                    analyzer: "korean_ngram_analyzer",
                    search_analyzer: "korean_ngram_analyzer"
                  }
                }
              }
            }
          }
        }
      },
    ]
    for (let index of indexObjArr) {
      this.indexInit(index)
    }
  }

  async indexInit(indexObj: object) {
    const index = await this.esService.indices.exists({ index: indexObj["indexName"] })
    if (!index) {
      await this.esService.indices.create(indexObj["indexSetting"])
      console.log('인덱스 추가')
    }
  }

  async searchProducts(q: string) {
    const englishPattern = /[a-zA-Z]/
    const isEnglish = englishPattern.test(q)
    const searchRequest = isEnglish ? {
      index: 'train.ecommerce.products',
      body: {
        query: {
          bool: {
            must: [
              { match: { "unified_field": q } }
            ]
          }
        }
      }
    } : {
      index: 'train.ecommerce.products',
      body: {
        query: {
          bool: {
            must: [
              { bool: { should: [
                    { match: { "unified_field": q } },
                    { match: { "unified_field.nori": q } },
                    { match: { "unified_field.ngram": q } },
                  ] } }

            ]
          }
        }
      }
    }
    const res = await this.esService.search(searchRequest)
    const hits = res.hits.hits

    return hits.map((hit) => {
      return {
        body: {
          id: hit._id,
          name: hit._source["name"],
          description: hit._source["description"],
          category: hit._source["category"],
          brand: hit._source["brand"],
        },
        score: hit._score
      }
    })

  }

  async initIndexAllProducts() {
    const products = await this.productRepo
      .createQueryBuilder('product')
      .leftJoinAndSelect('product.category', 'category')
      .leftJoinAndSelect('product.brand', 'brand')
      .getMany()

    for (const product of products) {
      await this.esService.index({
        index: 'train.ecommerce.products',
        id: product.id.toString(),
        document: {
          name: product.name,
          description: product.description,
          category: product.category.name,
          brand: product.brand.name,
        }
      })
    }
  }
}

MySQL의 데이터 생성/수정/삭제가 발생할 때 Elasticsearch 동기화 시키기

  • 비동기로 처리하기 위해 이벤트로 등록
npm i --save @nestjs/event-emitter
// src/search/decorators/search.event.listener.ts

import {Injectable} from "@nestjs/common";
import {ElasticsearchService} from "@nestjs/elasticsearch";
import {OnEvent} from "@nestjs/event-emitter";
import {ProductEntity} from "../product/entities/product.entity";

@Injectable()
export class SearchEventService {
  constructor(private readonly esService: ElasticsearchService) {}

  @OnEvent('create')
  async handleCreate(product: ProductEntity) {
    await this.esService.index({
      index: 'train.ecommerce.products',
      id: product.id.toString(),
      document: {
        name: product.name,
        description: product.description,
        category: product.category.name,
        brand: product.brand.name,
      }
    })
    return product
  }

  @OnEvent('update')
  async handleUpdate(product: Partial<ProductEntity>) {
    await this.esService.update({
      index: 'train.ecommerce.products',
      id: product.id.toString(),
      doc: product
    });
  }

  @OnEvent('delete')
  async handleDelete(id: number) {
    await this.esService.delete({
      index: 'train.ecommerce.products',
      id: id.toString(),
    });
  }
}
// src/search/interceptors/search.event.interceptor.ts

import {CallHandler, ExecutionContext, Injectable, NestInterceptor} from "@nestjs/common";
import {Observable, tap} from "rxjs";
import {EventEmitter2} from "@nestjs/event-emitter";
import {Reflector} from "@nestjs/core";

@Injectable()
export default class SearchEventInterceptor implements NestInterceptor {

  constructor(
    private readonly reflector: Reflector,
    private readonly eventEmitter: EventEmitter2
  ) {}

  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {

    const target = context.getHandler();
    const eventName = this.reflector.get<string>('event', target);

    if (!eventName) {
      return next.handle();
    }

    return next.handle().pipe(
      tap((product) => {
        this.eventEmitter.emit(eventName, product);
      }),
    );
  }
}
// src/search/decorators/search.emit.event.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const EmitEvent = (eventName: string) => SetMetadata('event', eventName);

import {EmitEvent} from "../search/decorators/search.emit.event.decorator";
import SearchEventInterceptor from "../search/interceptors/search.event.interceptor";


@Controller('products')
export class ProductController {
  constructor(private readonly productService: ProductService) {}

  @Get('query')
  async getProducts() {
    return await this.productService.getResults()
  }

  @EmitEvent('create')
  @Post()
  @UseInterceptors(
    SearchEventInterceptor,
    FilesInterceptor('files', 10, {
      storage: diskStorage({
        destination: './uploads', // 파일 저장 경로
        filename: (req, file, cb) => {
          const filename = `${Date.now()}-${file.originalname}`;
          cb(null, filename);
        },
      }),
    }),
  )
  async createProduct(
    @UploadedFiles() files: Express.Multer.File[],
    @Body(new PlainToInstancePipe(CreateProductDto)) createProductDto: CreateProductDto
  ) {
    return await this.productService.createProduct(createProductDto, files)
    // return await this.productService.createProduct(createProductDto)
  }

  @Get()
  async readProducts() {
    return await this.productService.readProducts()
  }

  @Get(':id')
  async readProduct(@Param('id') id: number) {
    await this.productService.readProduct(id)
  }

  @EmitEvent('update')
  @UseInterceptors(SearchEventInterceptor)
  @Patch(':id')
  async updateProduct(@Param('id') id: number, @Body() updateProductDto: UpdateProductDto) {
    return await this.productService.updateProduct(id, updateProductDto)
  }

  @EmitEvent('delete')
  @Delete(':id')
  @UseInterceptors(SearchEventInterceptor)
  async deleteProduct(@Param('id') id: number) {
    await this.productService.deleteProduct(id)
    return id
  }
}
[NestJS] CRUD [NestJS] 파일 업로드

You may also like

See all NestJS
14 Oct 2024 [NestJS] API 문서화
backend
NestJS

[NestJS] API 문서화

09 Oct 2024 [NestJS] SSE
backend
NestJS

[NestJS] SSE

08 Oct 2024 [NestJS] 웹 소켓
backend
NestJS

[NestJS] 웹 소켓

Jay Kim

Jay Kim

Web development, data engineering for human for the Earth. I share posts, free resources and inspiration.

Rest
Lifestyle
Hobby
Hobby
Hobby
Hobby
2025 © Jay's Cookbook. Crafted & Designed by Artem Sheludko.