이번엔 Delete를 해보겠습니다.
17-05-product-crud-update-with-error
폴더를 복사해 사본을 만들고 폴더명을 18-01-product-crud-delete
로 변경해줍니다.
먼저 상품 삭제를 위한 비즈니스 로직을 작성하겠습니다.
product.service.ts
파일에 로직을 추가하겠습니다.
async delete({ productId }: { productId: string }) {
const result = await this.productRepository.delete({ id: productId });
return result.affected ? true : false;
}
그리고 product.resolver.ts
파일에 상품을 삭제하는 API deleteProduct
를 추가해주세요.
@Mutation(() => Boolean)
async deleteProduct(@Args('productId') productId: string) {
return this.productService.delete({ productId });
}
로컬 환경에서 해보겠습니다.
터미널에서 해당 폴더 위치로 이동한 후, yarn start:dev
로 서버를 실행시킵니다.
그리고 http://localhost:3000/graphql 에 접속해 삭제 API를 요청해보겠습니다.
삭제를 하기 위해 먼저 상품을 몇개 준비해주세요.
이제 에어컨
을 삭제해보겠습니다.
DBeaver에서 확인해보니 에어컨
상품이 삭제되었습니다.
위에서 해본 것처럼 삭제를 한다면, 데이터 베이스에서 완전히 사라져버렸습니다. (물리 삭제)
그런데 삭제를 하고 보니 데이터를 잘못 삭제해서 복구하고 싶을 수도 있고, 어떤걸 삭제헸는지 다시 보고싶을 수도 있습니다.
그래서 실무에서는 아예 데이터를 날려버리는 방법 대신, 삭제 여부를 나타내는 속성을 추가해 가짜로 삭제하는 방법을 많이 사용합니다.
이런 것을 두고 논리 삭제( soft delete
) 라고 부릅니다.
이런 방식으로 삭제를 하기 위해서 typeORM에서 제공해주는 soft delete
와 soft remove
함수가 있습니다.
그전에 직접 먼저 구현해보겠습니다.
첫번쨰 방법은 product
에 isDeleted
라는 속성을 추가로 만들어서, 삭제 여부를 알 수 있게 하는 것 입니다.
그리고 데이터를 조회할 떄는 isDeleted
가 false
인 것만 가져오면 됩니다.
서버를 종료하고 product.entity.ts
파일에 isDeleted
를 추가합니다.
@Column({ default: false })
@Field(() => Boolean)
isDeleted: boolean;
product.service.ts
파일에서 delete
함수 실행 시 isDeleted
를 true
로 업데이트 하도록 고쳐줍니다.
async delete({ productId }: { productId: string }) {
// 1. 진짜 삭제
// const result = await this.productRepository.delete({ id: productId });
// 2. 소프트 삭제(직접 구현) - isDeleted
const product = await this.productRepository.findOne({
where: { id: productId },
});
product.isDeleted = true;
const result = await this.productRepository.save(product);
return result.isDeleted ? true : false;
}
DBeaver에서 product 데이터들을 모두 지워줍니다.
서버를 다시 띄워주고 키보드 상품을 2개 추가한 상태에서 삭제를 해보겠습니다.
기본값으로는 false
가 들어가는데 mysql에서는 0으로 보입니다.
여기서 키보드 1 상품을 삭제하면 isDeleted
가 0에서 1로 변경됩니다. (false -> true)
데이터가 삭제되었는지와 함께 언제 삭제되었는지도 중요하기 때문에 삭제된 시간을 동시에 알 수 있도록 deletedAt
컬럼을 만들어 시간을 기록해보겠습니다.
기본값은 따로 정하지 않았기 때문에 처음에는 null
입니다.
따라서 조회할 때는 deletedAt
이 null
이 아닌 것만 찾아와야 합니다.
서버를 종료하고 product.entity.ts
파일에 deletedAt
을 추가해줍니다.
@Column({ type: 'timestamp', nullable: true })
@Field(() => Date, { nullable: true })
deletedAt: Date;
product.service.ts
파일에서 delete
함수 실행 시 deletedAt
을 현재 시간으로 업데이트하도록 고쳐줍니다.
async delete({ productId }: { productId: string }) {
// 1. 진짜 삭제
// const result = await this.productRepository.delete({ id: productId });
// return result.affected ? true : false;
// 2. 소프트 삭제(직접 구현) - isDeleted
// const product = await this.productRepository.findOne({
// where: { id: productId },
// });
// product.isDeleted = true;
// const result = await this.productRepository.save(product);
// return result.isDeleted ? true : false;
// 3. 소프트 삭제(직접 구현) - deletedAt
const product = await this.productRepository.findOne({
where: { id: productId },
});
product.deletedAt = new Date();
const result = await this.productRepository.save(product);
return result.deletedAt ? true : false;
}
DBeaver에서 product 데이터들을 모두 지워줍니다.
서버를 다시 띄워주고 플레이그라운드에서 상품을 추가하면 deletedAt
이 null
인 상태로 데이터가 추가됩니다.
플레이그라운드에서 deleteProduct
를 요청한 후, 다시 확인해보면 삭제했을 때의 시간이 저장되어있습니다.
typeORM에서 제공해주는 함수 중 softRemove
를 사용해보겠습니다.
product.entity.ts
파일에 @DeleteDateColumn
을 추가해줍니다.
// @Column({ default: false })
// @Field(() => Boolean)
// isDeleted: boolean;
// @Column({ type: 'timestamp', nullable: true })
// @Field(() => Date, { nullable: true })
// deletedAt: Date;
@DeleteDateColumn()
deletedAt?: Date;
@DeleteDateColumn
에서 옵션을 따로 적어주지 않으면 기본값으로 삭제된 시간을 datetime
타입으로 저장합니다.
직접 구현했을때와 다르게, 데이터를 조회할 때 조건을 주지 않아도 삭제 되지 않은 데이터만 조회됩니다.
product.service.ts
파일에서 softRemove
로 삭제해줍니다.
async delete({ productId }: { productId: string }) {
// 1. 진짜 삭제
// const result = await this.productRepository.delete({ id: productId });
// return result.affected ? true : false;
// 2. 소프트 삭제(직접 구현) - 1
// const product = await this.productRepository.findOne({
// where: { id: productId },
// });
// product.isDeleted = true;
// const result = await this.productRepository.save(product);
// return result.isDeleted ? true : false;
// 3. 소프트 삭제(직접 구현) - 2
// const product = await this.productRepository.findOne({
// where: { id: productId },
// });
// product.deletedAt = new Date();
// const result = await this.productRepository.save(product);
// return result.deletedAt ? true : false;
// 3. 소프트 삭제(TypeORM 자체 제공)
const result = await this.productRepository.softRemove({ id: productId });
return result.deletedAt ? true : false;
}
직접 API로 테스트를 해보면, 상품을 createProduct
로 추가하고, deletedProduct
로 삭제하면 deletedAt
에 삭제한 시간이 저장됩니다.
그리고 find
로 별다른 조건을 주지 않고 조회해도, 삭제된 상품은 나오지 않습니다.
마찬가지로 typeORM에서 지원해주는 또 다른 함수 softDelete
를 사용해보겠습니다.
entity 부분은 그대로 두고 product.service.ts
파일을 수정합니다.
async delete({ productId }: { productId: string }) {
// 1. 진짜 삭제
// const result = await this.productRepository.delete({ id: productId });
// return result.affected ? true : false;
// 2. 소프트 삭제(직접 구현) - 1
// const product = await this.productRepository.findOne({
// where: { id: productId },
// });
// product.isDeleted = true;
// const result = await this.productRepository.save(product);
// return result.isDeleted ? true : false;
// 3. 소프트 삭제(직접 구현) - 2
// const product = await this.productRepository.findOne({
// where: { id: productId },
// });
// product.deletedAt = new Date();
// const result = await this.productRepository.save(product);
// return result.deletedAt ? true : false;
// 3. 소프트 삭제(TypeORM 자체 제공) - 1
// const result = await this.productRepository.softRemove({ id: productId });
// return result.deletedAt ? true : false;
// 4. 소프트 삭제(TypeORM 자체 제공) - 2
const result = await this.productRepository.softDelete({ id: productId }); // 모든 조건 삭제 가능
return result.affected ? true : false;
}
softDelete
는 softRemove
와 다르게 여러 행(Row)을 삭제할 수 있습니다.
그래서 result에 affected
값이 들어있는데도 이는 삭제된 행의 갯수가 숫자로 담겨있습니다.
플레이 그라운드에서 테스트해보면, sofeRemove
와 같은 결과를 얻을 수 있습니다.
1:1 관계인 하나의 테이블에 데이터가 생성되면 관계있는 다른 테이블에도 데이터가 생성되어야합니다.
18-01-product-crud-delete
폴더를 복사해 사본을 만들고 폴더명을 18-02-product-crud-create-one-to-one
으로 변경합니다.
주소와 상품의 관계는 1:1 입니다.
즉, 상품을 create 할 때, 주소도 생성되어야 합니다.
src/apis/productSaleslocation
폴더에 dto
폴더를 만들고 안에 productSaleslocation.input.ts
파일을 생성합니다.
그 안에 ProductSaelslocaionInput
클래스를 작성합니다.
import { InputType, OmitType } from '@nestjs/graphql';
import { ProductSaleslocation } from '../entities/productSaleslocation.entity';
@InputType()
export class ProductSaleslocationInput extends OmitType(
ProductSaleslocation,
['id'],
InputType,
) {
// @Field(() => String)
// address: string;
// 위처럼 모두 적어야하지만, PickType 또는 OmitType을 활용하여 타입 재사용
}
src/apis/product/dto
폴더 안에 있는 createProduct.input.ts
파일에 productSaleslocation
을 추가해줍니다.
@Field(() => ProductSaleslocationInput)
productSaleslocation: ProductSaleslocationInput;
클라이언트에서 createProduct
API를 요청했을 때
product
에서 productSaleslocaion
을 사용하기 위해서 product.module.ts
에서 파일에 PRoductSaleslocation
을 추가해줍니다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';
import { ProductResolver } from './product.resolver';
import { ProductService } from './product.service';
import { ProductSaleslocation } from '../productSaleslocation/entities/productSaleslocation.entity';
@Module({
imports: [TypeOrmModule.forFeature([Product, ProductSaleslocation])],
providers: [ProductResolver, ProductService],
})
export class ProductModule {}
product.service.ts
파일에서도 추가해줍니다.
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
@InjectRepository(ProductSaleslocation)
private readonly productSaleslocationRepository: Repository<ProductSaleslocation>,
) {}
// ... 기존 코드
}
동일한 파일에서 create
함수를 수정합니다.
기존에는 product 테이블에만 데이터를 등록했지만 이제는 다른 테이블도 연결해줍니다.
async create({ createProductInput }: ICreate) {
// 스프레드 연산자 사용하기
// const product = await this.productRepository.save({
// ...createProductInput,
// });
// return product;
// 다른 테이블도 연결하여 등록하기
const { productSaleslocation, ...product } = createProductInput;
const result1 = await this.productSaleslocationRepository.save({
...productSaleslocation,
});
const result2 = await this.productRepository.save({
...product,
productSaleslocation: result1,
});
return result2;
}
서비를 띄운 후, 플레이그라운드에서 createProduct
API를 요청해보면 다음과 같이 DB에 저장되는 것을 확인할 수 있습니다.
이제 추가된 데이터를 조회해보겠습니다.
18-02-product-crud-delete-one-to-one
폴더를 복사하여 사본을 만들고 폴더명을 18-03-product-crud-read-one-to-one
으로 변경합니다.
product.service.ts
파일에서 findAll
함수와 findOne
함수를 수정합니다.
DB에서 상품 테이블의 데이터를 조회할 때, relations
옵션을 추가해 주어 연관된 다른 테이블( productSaleslocation
)의 데이터도 함께 찾아도록 하겠습니다.
async findAll() {
const products = await this.productRepository.find({
relations: ['productSaleslocation'],
});
return products;
}
async findOne({ productId }: { productId: string }) {
const product = await this.productRepository.findOne({
where: { id: productId },
relations: ['productSaleslocation'],
});
return product;
}
터미널에서 해당 폴더로 이동 후 yarn start:dev
로 서버를 띄워줍니다.
그리고 플레이그라운드에서 createProduct
후 findAll
을 해보면 위치 정보까지 잘 가져오는 것을 확인할 수 있습니다.
18-03-product-crud-read-one-to-one
폴더를 복사하여 사본을 만들고 폴더명을 18-04-product-crud-read-many-to-one
으로 변경해줍니다.
카테고리의 상품은 1:N 관계입니다.
상품 데이터를 추가할 때, 카테고리도 새로 추가되는 것은 아닙니다.
이미 등록되어 있는 카테고리를 찾아서 상품에 넣어주기만 하면 됩니다.
src/apis/product/dto
에 위치한 createProduct.ts
파일에 productCategoryId
도 추가합니다.
@Field(() => String)
productCategoryId: string;
product.service.ts
파일에 ProductCategory
를 주입해줍니다.
@InjectRepository(ProductCategory)
private readonly productCategoryRepository: Repository<ProductCategory>,
같은 파일에서 create
함수를 수정해줍니다.
async create({ createProductInput }: ICreate) {
const { productSaleslocation, productCategoryId, ...product } =
createProductInput;
const result1 = await this.productSaleslocationRepository.save({
...productSaleslocation,
});
const result2 = await this.productCategoryRepository.findOne({
where: { id: productCategoryId },
});
const result3 = await this.productRepository.save({
...product,
productSaleslocation: result1,
productCategory: result2,
});
return result3;
}
조회할 때 카테고리 데이터도 같이 조회하기 위해 relations
옵션에 productCategory
를 추가해줍니다.
async findAll() {
const products = await this.productRepository.find({
relations: ['productSaleslocation', 'productCategory'],
});
return products;
}
async findOne({ productId }: { productId: string }) {
const product = await this.productRepository.findOne({
where: { id: productId },
relations: ['productSaleslocation', 'productCategory'],
});
return product;
}
product.module.ts
에도 ProductCategory
를 추가합니다.
@Module({
imports: [
TypeOrmModule.forFeature([Product, ProductSaleslocation, ProductCategory]),
],
providers: [ProductResolver, ProductService],
})
export class ProductModule {}
로컬에 서버를 띄운 후, 플레이그라운드에서 카테고리를 먼저 만들겠습니다.
만들어진 카테고리 아이디를 가지고, 상품을 생성하겠습니다.
findAll
API를 요청하면, 카테고리도 잘 가져오는 것을 확인할 수 있습니다.
서로 엮여있는 관계에서는 한 테이블에서 데이터를 삭제하면 연관된 테이블의 데이터도 같이 삭제해줘야 합니다.
예를 들어 상품의 카테고리르 삭제하면, 그 카테고리의 상품들이 모두 같이 삭제되어야 합니다.
18-04-product-crud-read-many-to-one
폴더를 복사한 후 사본을 만들고 폴더명을 18-05-product-crud-delete-cascade
으로 변경해주세요.
카테고리를 삭제하는 API가 src/apis/productCategory
폴더 안에 있습니다.
// productCategory.service.ts
async delete({ productCategoryId }: { productCategoryId: string }) {
const result = await this.productCategory.delete({ id: productCategoryId });
return result.affected ? true : false;
}
// productCategory.resolver.ts
@Mutation(() => Boolean)
async deleteProductCategory(
@Args('productCategoryId') productCategoryId: string,
) {
return await this.productCategoryService.delete({ productCategoryId });
}
product.entity.ts
파일에서 CASCADE
옵션을 추가해줍니다.
@ManyToOne(() => ProductCategory, { onDelete: 'CASCADE' })
@Field(() => ProductCategory)
productCategory: ProductCategory;
이제 카테고리를 삭제하면 해당 카테고리 안에 상품들도 함께 삭제됩니다.