Nest.js - N:M 관계 등록 API

Temporary·2024년 9월 18일
0

Nods.js

목록 보기
31/39

N : M 관계 Create

N : M 관계의 테이블에 데이터를 등록해보자

10-07-typeorm-crud-many-to-one 폴더를 복사 붙여넣기 하여 10-08-typeorm-crud-many-to-many 폴더로 이름을 변경하고

yarn install 로 모듈을 설치해준다.

상품을 등록할 때 상품에 해당하는 태그들이 존재한다. (상품 - 전자제품, 휴대용 등)

따라서, 이제는 상품 등록 시 상품 내용, 상품 판매 위치, 상품 카테고리와 더불어 상품 태그를 추가해 상품을 등록해줄 것이다.

상품 카테고리처럼 미리 카테고리를 등록한 다음 상품 등록 시 id 값을 넣어주는 것이 아니라

상품 판매 위치처럼 상품을 등록할 때 같이 만들어 준다.

두 개의 차이에 대해 명확히 구분할 필요가 있다.

10-08-typeorm-crud-many-to-manysrcapisproductsdtocreate-product.input.ts 파일을 다음과 같이 수정해준다.

// create-product.input.ts

import { Field, InputType, Int } from '@nestjs/graphql';
import { Min } from 'class-validator';
import { ProductSaleslocationInput } from 'src/apis/productsSaleslocation/dto/productSaleslocation.input';

@InputType()
export class CreateProductInput {
  @Field(() => String)
  name: string;

  @Field(() => String)
  description: string;

  @Min(0)
  @Field(() => Int)
  price: number;

  @Field(() => ProductSaleslocationInput)
  productSaleslocation: ProductSaleslocationInput;

  @Field(() => String)
  productCategoryId: string
	
  // 추가된 다대다 인풋 정보
  @Field(() => [String])
  productTags: string[]
}
  • 상품에는 여러태그가 있기 때문에 배열 타입으로 작성해준다.
    • graphql type : [String] 배열로 지정

    • typescript type : string[] 배열로 지정

      ⇒ 플레이그라운드에서 데이터를 받아올 때, ["#전자제품", "#수원", "#컴퓨터"] 으로 받아 올 수 있다.

  • product 테이블이 생성되면서 연결되어 있는 테이블도 생성 가능하다.

srcapisproductsproducts.service.ts 파일의 create를 다음과 같이 수정한다.

// products.service.ts

import {
  HttpException,
  HttpStatus,
  Injectable,
  UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProductsSaleslocationsService } from '../productsSaleslocations/productsSaleslocations.service';
import { ProductsTagsService } from '../productsTags/productsTags.service';
import { Product } from './entities/product.entity';
import {
  IProductsServiceCheckSoldout,
  IProductsServiceCreate,
  IProductsServiceDelete,
  IProductsServiceFindOne,
  IProductsServiceUpdate,
} from './interfaces/products-service.interface';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productsRepository: Repository<Product>, //

    private readonly productsSaleslocationService: ProductsSaleslocationsService,

    private readonly productsTagsService: ProductsTagsService, // 로직 설명 3
  ) {}


// ...생략

async create({
    createProductInput,
  }: IProductsServiceCreate): Promise<Product> {
    // 2. 상품과 상품거래위치를 같이 등록하는 경우
  	// 추가된 부분 - 상품 태그를 분리하여 할당
    const { productSaleslocation, productCategoryId, productTags, ...product } =
      createProductInput; // 로직 설명 1

    // 2-1) 상품거래위치 등록
    const result = await this.productsSaleslocationService.create({
      ...productSaleslocation, // 서비스를 타고 가는 이유는...?(레파지토리에 직접 접근하면 안될까...?) => 검증을 서비스에서 진행하기 때문
    }); // 로직 설명 2

    // 추가된 부분 - 상품태그 등록
    // productTags가 ["#전자제품", "#영등포", "#컴퓨터"]와 같은 패턴으로 가정
    const tagNames = productTags.map((el) => el.replace('#', '')); // ["전자제품", "영등포", "컴퓨터"] // 로직 설명 4
    const prevTags = await this.productsTagsService.findByNames({ tagNames }); // 로직 설명 5

    const temp = []; 
    tagNames.forEach((el) => {
      const exists = prevTags.find((prevEl) => el === prevEl.name);
      if (!exists) temp.push({ name: el });
    }); // 로직 설명 6
    const newTags = await this.productsTagsService.bulkInsert({ names: temp }); // 로직 설명 7


    const tags = [...prevTags, ...newTags.identifiers]; // newTags.identifiers  =>  등록된 id 배열. ex, [{ id: aaa }, { id: qqq }, ...]
    // 1. 실무에서 반드시 for문 써야하는 경우가 아니면, for문 잘 안 씀 => map, forEach 사용
    // 2. for안에서 await를 사용하지 않음 => 안티패턴 => Promise.all 사용
    // 3. DB에 동일한 패턴 데이터를 반복적으로 등록하지 않음(네트워크 왔다갔다 비효율) => bulk-insert 사용

    // 2-3) 상품 등록
    const result2 = await this.productsRepository.save({
      ...product,
      productSaleslocation: result, // result 통째로 넣기 vs id만 빼서 넣기
      productCategory: {
        id: productCategoryId,
        // 만약에, name 까지 받고싶으면 2가지 방법?
        //   1) createProductInput에서 카테고리 name도 받아오기
        //   2) productCategoryId를 사용해서 카테고리 name을 조회하기
      },
      productTags: tags,

      // 하나하나 직접 나열하는 방식
      // name: product.name,
      // description: product.description,
      // price: product.price,
      // productSaleslocation: {
      //   id: result.id,
      // },
    }); // 로직 설명 8

    // 최종 결과 돌려주기
    return result2; // 로직 설명 9
  }

// ...생략

로직의 순서를 설명하면

  1. 구조분해할당을 통해 createProductInput 에서 productSaleslocation, productCategoryId, productTags를 분리해 주고, 나머지 컬럼들rest 파라미터를 통해 product로 저장시켰다.

  2. productsSaleslocationService에서 DB에 저장한 데이터를 result 에 할당했다.

  3. ProductsTagsService의존성 주입시켜 줬다. (ProductsTagsService 에 대한 소스 코드와 설명은 아래를 참고. )

    • service.ts 파일에만 작성한 것이 아니라, module.ts 파일에 ProductTag 엔티티 파일과 ProductsTagsService 서비스 파일을 추가로 주입해야한다.
  4. 태그를 등록할 때, 태그 앞에 존재하는 ‘#’을 제거(replace)한 태그들을 tagNames 변수에 할당한다.

    • ‘#’을 제거 하기 위해 map 메서드를 사용

      ( for 문 대신에 동시에 처리 가능한 map, forEach 사용 가능 : 반복문 내 await 가 존재하면 for문 보다 빠른 장점 존재 )

  5. 이때, 이미 등록된 태그인지 확인해 보기 위해 productsTagsServicefindByNames 함수를 통해 조회한 값을 prevTags 변수에 할당해준다.

    • findByNames 함수 실행 시, 해당 데이터가 조회된다면 이미 등록된 태그이기 때문에, 조회된 데이터가 prevTags 변수에 할당되는 것이다. ( findByNames 함수는 아래 설명을 참고 )
  6. 그리고 아직 등록되지 않은 태그라면, temp 라는 빈 배열에 push 시켜준다.

    • 이미 등록되지 않는 태그 임을 확인하기 위해, 입력한 태그와 prevTags 변수 내에 존재하는 태그를 비교하여 등록된 태그라면 exists 변수에 할당해 준다.
    • 따라서, exists가 아니라면 등록되지 않은 태그 임을 확인한 것이므로, temp 배열로 push 하는 것이다.
  7. 이렇게 확인된 등록되지 않은 새 태그들은 productsTagsServicebulkInsert 함수를 사용하여 ProductTag 테이블에 저장한다. ( bulkInsert 함수는 아래를 참고 )

  8. 방금 등록/조회한 태그들이 할당된 prevTags 와 newTags 들을 tags로 할당하여 productTag에 포함시켜서 최종적으로 product를 등록한다.

    • 등록한 product 데이터를 result2 변수에 할당한다.
  9. 등록된 상품 객체를 result2 변수에 담아서 브라우저에 다시 전달해주기 위해서는 함수가 실행되는 products.resolver.ts 파일로 돌아가야 하기때문에 return을 사용해주었다.

3, 5, 7번째 로직 순서에 해당하는 ProductsTagsService 함수를 만들어 보자

srcapisproductsTags 폴더 내에 productsTags.service.ts 파일을 만들어 준다.

// productsTags.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, InsertResult, Repository } from 'typeorm';
import { ProductTag } from './entities/productTag.entity';
import {
  IProductsTagsServiceBulkInsert,
  IProductsTagsServiceFindByName,
} from './interfaces/products-tags-service.interface';

@Injectable()
export class ProductsTagsService {
  constructor(
    @InjectRepository(ProductTag)
    private readonly productsTagsRepository: Repository<ProductTag>,
  ) {}

  findByNames({
    tagNames,
  }: IProductsTagsServiceFindByName): Promise<ProductTag[]> {
    return this.productsTagsRepository.find({
      where: { name: In([...tagNames]) },
    });
  }

  bulkInsert({ names }: IProductsTagsServiceBulkInsert): Promise<InsertResult> {
    return this.productsTagsRepository.insert([...names]); // bulk-insert는 save()로 불가능
  }
}
  • Repository 타입의 ProductTag 를 의존성주입 했다.
    • service.ts 파일에만 작성한다고 끝나는 게 아니라, module.ts 파일에 ProductTag 엔티티 파일을 추가로 주입해야 한다.
  • findByNames 함수 : ProductTag 테이블에서(productsTagsRepository) tagNames을 조회(.find)한다.
    • tagNames 는 products.service.ts 파일에서 넘어온 매개변수
    • return을 통해 조회된 값이 products.service.ts 파일로 전달된다.
  • bulkInsert 함수 : 동일한 패턴을 가진 데이터를 저장할 때는 bulkInsert 를 사용하는 것이 효율적이다.
    • names 는 products.service.ts 파일에서 넘어온 매개변수 name이 합쳐진 배열이다.
    • bulk-insert의 데이터 저장은 각각 저장해주는 save()로는 불가능하다.
    • return을 통해 저장된 값이 products.service.ts 파일로 전달되게 된다.
    • bulkInsert 함수에 대한 리턴 타입은 Promise<InsertResult> 로,
      저장이 완료될 때까지 기다려 줘야 하므로 Promise를 사용하였으며
      typeorm에서 제공해주는 insert query 결과물에 대한 return 타입인 InsertResult 을 사용했다.

Bulk Insert
만약 .save 를 통해 INSERT 한다면, 쿼리가 하나하나 실행 되기 때문에 저장하는 데이터 갯수만큼 네트워크를 왔다갔다 해야한다.
하지만, bulkInsert 는 한 번에 모아서 데이터를 추가하는 역할을 하기에 함수 실행 시간이 단축되며, 통신 비용이 줄어드는 장점이 존재하여 다량의 데이터를 처리할 때 효과적이다.

srcapisproductsTagsinterfacesproducts-tags-service.interface.ts 파일에 IProductsTagsServiceFindByNameIProductsTagsServiceBulkInsert를 만들어 준다.

// products-tags-service.interface.ts

export interface IProductsTagsServiceFindByName {
  tagNames: string[];
}

export interface IProductsTagsServiceBulkInsert {
  names: {
    name: string;
  }[];
}

마지막으로 product에서 ProductTag을 사용하기 위해서

srcapisproductsproducts.module.ts 파일에 ProductTag 엔티티와 ProductsTagsService 서비스를 추가해준다.

// products.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductSaleslocation } from '../productsSaleslocations/entities/productSaleslocation.entity';
import { ProductsSaleslocationsService } from '../productsSaleslocations/productsSaleslocations.service';
import { ProductTag } from '../productsTags/entities/productTag.entity';
import { ProductsTagsService } from '../productsTags/productsTags.service';
import { Product } from './entities/product.entity';
import { ProductsResolver } from './products.resolver';
import { ProductsService } from './products.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Product, //
      ProductSaleslocation,
      ProductTag, // 추가된 부분 
    ]),
  ],

  providers: [
    ProductsResolver, 
    ProductsService,
    ProductsSaleslocationsService,
    ProductsTagsService, // 추가된 부분
  ],
})
export class ProductsModule {}
  • ProductTag 를 추가해야지 해당 repository 에 접근이 가능하다.

yarn start:dev 를 입력해 서버를 실행해주고

DBeaver를 실행해서 생성된 테이블을 한번 확인해보면

두 테이블을 매핑해주는 product_product_tags_product_tag 테이블이 생성된걸 확인할 수 있다.

해당 테이블 엔티티를 따로 작성하지 않았는데 테이블이 생성된 것이다.

이전에, 엔티티를 통해 N:M 관계를 설정하기 위해서 만들기 위해 다음과 같이 연결해 해두었다.

따라서 productproduct_tag의 N:M 관계를 설정해 놓았기 때문에 NestJS는 자동으로 1:N & 1:M의 중간 테이블을 생성해준것이다.

엔티티의 관계도는 다음과 같다.

이제 product 테이블에 데이터를 생성하면서 다른 테이블도 연결하여 등록해 보자

http://localhost:3000/graphql 에 접속해서 플레이그라운드에서 api 요청한다.

다음과 같이 createProduct 에 요청을 보내 상품을 등록했다면

Dbeaver를 실행해서 상품이 잘 등록되었는지 확인해보자

상품 태그의 테이블에 데이터가 잘 등록되었는지도 확인하자

끝으로, 상품과 상품 태그의 중간 테이블에 데이터가 잘 생성되었는지도 확인해보자



N : M 관계 Read ( Fetch )

srcapisproductsentitiesproducts.service.ts 파일의 findAll(), find()를 수정해준다.

// products.service.ts

import {
  HttpException,
  HttpStatus,
  Injectable,
  UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProductsSaleslocationsService } from '../productsSaleslocations/productsSaleslocations.service';
import { ProductsTagsService } from '../productsTags/productsTags.service';
import { Product } from './entities/product.entity';
import {
  IProductsServiceCheckSoldout,
  IProductsServiceCreate,
  IProductsServiceDelete,
  IProductsServiceFindOne,
  IProductsServiceUpdate,
} from './interfaces/products-service.interface';


// ...생략

	findAll(): Promise<Product[]> {
    return this.productsRepository.find({
      relations: ['productSaleslocation', 'productCategory'],
    });
  }

  findOne({ productId }: IProductsServiceFindOne): Promise<Product> {
    return this.productsRepository.findOne({
      where: { id: productId },
      relations: ['productSaleslocation', 'productCategory'],
    });
  }


// ...생략
  • findAll() : 상품 목록의 모든 데이터를 조회한다.
    • relations : 배열 안에 넣어서 설정해주면 관련되어 있는 테이블의 데이터까지 조회 가능.
  • find() : productId에 해당하는 상품만 조회한다.
    • where : { id: productId } 라는 조건을 통해 해당하는 상품을 찾음.
    • relations: 배열 안에 넣어서 설정해 주면 조건에 해당하는 상품과 관련되어 있는 테이블의 데이터까지 모두 조회 가능.

srcapisproductsinterfacesproducts-service.interface.ts 파일에 IProductsServiceFindOne 를 만들어준다.

// products-service.interface.ts
// ...생략

// 추가
export interface IProductsServiceFindOne {
  productId: string;
}

http://localhost:3000/graphql 에 접속해서 플레이그라운드에서 api 요청해보자.

앞에서 등록한 상품에 대해서 조회할 때 연관되어 있는 테이블의 데이터까지 조회된다면 성공이다.

profile
Temporary Acoount

0개의 댓글