[NestJS] - 13. TypeORM 관계 테이블 Insert/Update

2023. 2. 23. 09:17
반응형

이전 포스팅에서는 단일 테이블의 Select, Insert, Update, Delete에 대해서 다루어 보았었는데

이번에는 Insert와 Update 시 관계 테이블 외래키와 데이터를 포함하여 TypeORM으로 작성하는 방법을 알아보도록 하겠다.

 


DTO

이번에는 상품을 등록할 때 함께 상품 거래 장소에 대한 정보, 이미 등록되어 있던 카테고리 ID, 등록할 해쉬태그들을 같이 Insert 할 것이다.

클라이언트로부터 관계 테이블들의 데이터를 받기 위해서 DTO를 수정하도록 하겠다.

 

▶ createProduct.dto.ts

export class CreateProductInput {
  @IsString()
  name: string;

  @IsString()
  description: string;

  @IsInt()
  @Min(0)
  price: number;

  @IsObject()
  productSalesLocation: ProductSalesLocationInput;

  @IsNumber()
  productCategoryId: number;

  @IsString({ each: true })
  productTags: string[];
}
  • 상품 거래장소는 상품 등록 시 함께 정보를 입력받을 것이다.
  • 상품 카테고리는 이미 만들어져 있던 카테고리의 ID를 입력받을 것이다.
  • 상품태그는 문자열의 배열로 입력받을 것이다. Validator에서 each:true를 설정하면 리스트로 인식한다.

 

 

▶ createProductSalesLocation.dto.ts

export class CreateProductSalesLocationInput {
  @IsString()
  address: string;
  @IsString()
  addressDetail: string;
  @IsDecimal()
  lat: number; //위도
  @IsDecimal()
  lng: number; //경도
  @IsDate()
  meetingTime: Date;
}
  • 상품 DTO에서 사용될 위치 정보 Input DTO를 따로 만들어주었다.

 

 

 

 

▶ updateProduct.dto.ts

export class UpdateProductInput {
  @IsNumber()
  id: number;

  @IsString()
  @IsOptional()
  name?: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsInt()
  @IsOptional()
  price?: number;

  @IsObject()
  productSalesLocation: UpdateProductSalesLocationInput;

  @IsNumber()
  productCategoryId: number;

  @IsString({ each: true })
  productTags: string[];
}

updateProductSalesLocation.dto.ts

export class UpdateProductSalesLocationInput {
  @IsString()
  @IsOptional()
  address: string;

  @IsString()
  @IsOptional()
  addressDetail: string;

  @IsDecimal()
  @IsOptional()
  lat: number; //위도

  @IsDecimal()
  @IsOptional()
  lng: number; //경도

  @IsDate()
  @IsOptional()
  meetingTime: Date;
}

CreateProductDTO와 유사하지만 필수값을 제외하고 Optional한 Input을 받아 부분적으로 업데이트 입력값을 받을 수 있도록 설계하였다.

 

 

 

 


Service와 Module

 

products.module.ts

@Module({
  imports: [
    TypeOrmModule.forFeature([Product, ProductSalesLocation, ProductTag]),
  ],
  controllers: [ProductsController],
  providers: [ProductsService],
})
export class ProductsModule {}

 products.service.ts

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
    @InjectRepository(ProductSalesLocation)
    private readonly productSalesLocationRepository: Repository<ProductSalesLocation>,
    @InjectRepository(ProductTag)
    private readonly productTagRepository: Repository<ProductTag>,
  ) {}
  //...
 }

 

Product 엔티티 뿐만 아니라 관계를 가지는 다른 엔티티 Repository가 필요하므로 모듈과 서비스에서 사용 가능하도록 주입시켜주었다.

 

 

 

 


Insert 서비스 수정

 

 

  async create(input: CreateProductInput): Promise<Product> {
    const result = await this.productRepository.save({
      ...input,
    });
    return result;
  }

기존의 데이터 Insert를 처리하는 로직이었다. 이제 여기에 관계를 가지는 다른 테이블들을 연결하여 Insert해 볼 것이다.

 

 

1. 거래장소를 해당 테이블에 Insert

async create(input: CreateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } = input;

    //위치 저장
    const location = await this.productSalesLocationRepository.save({
      ...productSalesLocation,
    });

    //...
  }
  • 우선 Input으로 들어온 DTO를 관계 테이블들의 데이터들과 본인의 Element 요소들과 구별하여 구조분해할당 시켜주었다.
  • 구조분해된 productSalesLocation 객체는 Insert해준 후 해당 엔티티 모델을 location에 담아둔다.
  • productSalesLocation에는 PK가 포함되어 있지 않으므로 save()에서 Insert로 작동한다.

 

 

 

 

2. 새로운 등록 태그들은 해당 테이블에 Insert한 후, 상품 테이블에 연관된 태그 엔티티 리스트 얻어오기

async create(input: CreateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } = input;
    
    // 위치 저장...
    
    //태그 등록 
    const productTagList = await Promise.all(
      productTags.map((el) => {
        return new Promise(async (resolve, reject) => {
          const tagName = el.replace('#', '');
          const existTag = await this.productTagRepository.findOne({
            name: tagName,
          });
          if (existTag) {
            resolve(existTag);
          } else {
            const newTag = await this.productTagRepository.save({
              name: tagName,
            });
            resolve(newTag);
          }
        });
      }),
    );
    
    //...
  }
  • 구조분해된 productTags는 String 배열이다. 이 배열을 모두 ProductTag 테이블에 Insert해줄 것이다.
  • DB에 Insert하는 과정은 비동기 처리가 필요하다. 하나하나 처리하면 처리 속도가 생기므로 병렬적으로 처리하기 위해 Promise.all을 사용하였다.
  • productsTags를 순회하며, Promise 배열을 생성한다. 해당 Promise 배열들이 모두 처리되었을 때 productTagList에 연관된 태그 엔티티 리스트가 담긴다.
  • #을 생략시켜 저장하고 해당 결과 엔티티를 Resolve 시킨다., 만약 동일한 이름의 태그가 존재한다면 Insert하지 않은 채로 존재하는 엔티티를 Resolve시킨다.
  • 비동기 함수의 병렬적 처리에 대해서는 아래의 포스팅을 참고하면 좋을 것 같다.
  • https://sjh9708.tistory.com/25 
 

[JS] 반복문 안에서의 비동기 함수 사용

자바스크립트로 프로그래밍을 하다 보면 비동기 함수를 종종 반복문을 돌려야 할 상황이 있다. 내가 많이 사용하는 경우 중 하나는 데이터베이스에 Object 객체 리스트를 동시에 Insert하는 경우가

sjh9708.tistory.com

 

 

 

 

3. 관계 테이블 데이터(거래장소, 등록태그, 카테고리)를 포함하여 상품 테이블에 Insert

async create(input: CreateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } = input;
    
    // 위치 저장...
    
    // 태그 등록...

    const result = await this.productRepository.save({
      ...product,
      productSalesLocation: location,
      productCategory: {
        id: productCategoryId,
      },
      productTags: productTagList,
    });
    return result;
  }
  • 스프레드 연산자로 위에서 각각 만들어둔 Object를 조립하여 Insert한다.
  • ...product는 Product의 컬럼들이다.
  • productSalesLocation에는 위치 저장 후 반환받은 엔티티를 작성한다.
  • productCategory에는 입력받은 카테고리 ID를 작성한다.
  • productTags에는 태그들을 생성한 후 반환받은 등록되어야 하는 태그 엔티티 리스트를 작성한다.
  • productSalesLocation도 카테고리처럼 id만 인수로 넘겨도 Insert되지만 반환받는 객체에 사용자의 Input 정보를 포함시키기 위해서 모델 자체를 넘겨주었다.

 

 

 


Update 서비스 수정

 

마찬가지로 Update하는 로직도 수정해보겠다.

 

  async modify(input: UpdateProductInput): Promise<Product> {
  
    const product = await this.productRepository.findOne({
      where: { id: input.id },
    });
    
    const newProduct = {
      ...product, //기존 Object
      ...input, //해당 부분 갱신. (포함된 ID로 Select한 이후 Update)
    };

    
    const result = await this.productRepository.save(newProduct);
    return result;
  }

기존의 업데이트 로직이다.

 

 

1. 거래장소 Update

async modify(input: UpdateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } =
      input;

    const exist = await this.productRepository.findOne({
      where: { id: product.id },
      relations: ['productSalesLocation'],
    });

    //위치 갱신
    const location = await this.productSalesLocationRepository.save({
      ...productSalesLocation,
      id: exist.productSalesLocation.id,
    });

    //...
  }
  • DTO로 거래장소의 ID를 받지 않고 있으므로, 기존 Product 테이블에서 Join하여 ProductSalesLocation 테이블을 함께 Select해주어 거래장소 ID를 찾았다.
  • 상품의 거래장소 FK -> 거래장소 PK이다. PK를 알았으므로 장소 정보를 업데이트해준다.
  • 업데이트 이후 거래장소 엔티티를 반환받아 location에 담아둔다.

 

 

 

2. 새로운 등록 태그들은 해당 테이블에 Insert한 후, 상품 테이블에 연관된 태그 엔티티 리스트 얻어오기

async modify(input: UpdateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } =
      input;

    const exist = await this.productRepository.findOne({
      where: { id: product.id },
      relations: ['productSalesLocation'],
    });

    //위치 갱신..
    
    //태그 등록
    const productTagList = await Promise.all(
      productTags.map((el) => {
        return new Promise(async (resolve, reject) => {
          const tagName = el.replace('#', '');
          const existTag = await this.productTagRepository.findOne({
            name: tagName,
          });
          if (existTag) {
            resolve(existTag);
          } else {
            const newTag = await this.productTagRepository.save({
              name: tagName,
            });
            resolve(newTag);
          }
        });
      }),
    );

    //...
  }
  • 여기는 Insert 부분과 별 다를바가 없다. 왜냐하면 이 로직의 목적이 태그 테이블에 없는 친구면 추가해주고, 수정하는 것 없이 연관된 태그 엔티티들을 전부 가져오는 것에 불구하기 때문이다.
  • 상품태그는 다른 상품에서 사용하고 있을 수 있으므로 수정하지 않는다.

 

 

 

3. 관계 테이블 데이터(거래장소, 등록태그, 카테고리)를 포함하여 상품 테이블에 Update

async modify(input: UpdateProductInput): Promise<Product> {
    const { productSalesLocation, productCategoryId, productTags, ...product } =
      input;

    const exist = await this.productRepository.findOne({
      where: { id: product.id },
      relations: ['productSalesLocation'],
    });

    //위치 갱신 ..

    //태그 등록 ..


    const newProduct = await this.productRepository.save({
      ...exist,
      ...product,
      productSalesLocation: location,
      productCategory: {
        id: productCategoryId,
      },
      productTags: productTagList,
    });

    const result = await this.productRepository.save(newProduct);
    return result;
  }
  • 마찬가지로 위에서 각각 만들어둔 Object를 조립하여 Insert한다.
  • ...product는 Product의 수정된 컬럼들이다.
  • productSalesLocation에는 위치 수정 후 반환받은 엔티티를 작성한다.
  • productCategory에는 입력받은 카테고리 ID를 작성한다.
  • productTags에는 태그들을 생성한 후 반환받은 변경된 태그 엔티티 리스트를 작성한다.(태그 자체가 변경된 것이 아닌 목록이 바뀌었을 뿐이다.)

 


결과 확인

 

Postman을 통해 요청을 날려보았다.

 

 

FK가 잘 연결되어 있는 것을 확인하였다.

 

 

관계 테이블에도 데이터가 잘 들어와 있다.

 

N:M 관계 테이블도 잘 매칭되고 있다.

 

 

업데이트 요청도 날려보았다.

 

 

변경이 잘 되었다.

 

 

관계 테이블도 잘 변경되었다.

 

 

 

 

 

 


문제점

잘 작동하는 것 같지만 문제가 있다. 저 함수 안의 로직들이 모두 성공하면 좋겠지만, 어떤 로직은 성공하고, 어떤 로직은 실패하게 되면 데이터의 일관성과 무결성이 깨지게 된다. 카테고리는 추가되었는데 상품 추가가 실패하여 1:1 관계가 깨질 수도 있다.

 

차라리 실패할거면 모두 실패하는 것이 낫다.

 

이를 위해 도입된 개념이 Transaction인데 하나라도 실패하면 지금까지 실행했던 SQL문을 모두 롤백시켜버리는 것이다. Transaction의 사용법과 Transaction-ACID에 대해서도 조만간 다루어보려고 한다.

 

 

 

우선 다음 포스팅에서는 TypeORM으로 Join문을 사용해보는 것을 포스팅해보겠다.

 

 

 

 

 

 


 

 

해당 강의를 들으면서 학습한 내용을 바탕으로 저만의 프로젝트를 만드는 과정을 기록하여 남기는 것을 목표로 하고 있습니다.

주관적인 생각이 들어가 있을 수 있으므로 혹시 틀린 내용이 있다면 피드백 부탁드립니다.

https://www.inflearn.com/course/%EB%B6%80%ED%8A%B8%EC%BA%A0%ED%94%84-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B3%A0%EB%86%8D%EC%B6%95-%EC%BD%94%EC%8A%A4/dashboard

 

[인프런x코드캠프] 부트캠프에서 만든 고농축 백엔드 코스 - 인프런 | 강의

코딩과 사랑에 빠져버린 8년차 풀스택 개발자 Captain의 사심이 가득 담긴 커리큘럼이에요. 백엔드의 모든 것을 다 알려주고 싶은 Captain의 마음이 녹아있죠! 이 강의를 듣다보면 '이렇게까지 알려

www.inflearn.com

반응형

BELATED ARTICLES

more