GQL & TypeORM

류연찬·2023년 5월 20일
0

GraphQL

목록 보기
13/17

CRU API 구현

이번에는 TypeORMGraphQL을 사용해 CRU API를 구현하겠습니다.

이전에는 실제 데이터와 연결만 진행했지만 이번에는 데이터베이스에 저장하고 저장한 데이터를 읽어와서 수정까지 진행해보겠습니다.

ProductCategory Create

16-03-mysql-many-to-many 폴더를 복사해 사본을 만들고 폴더명을 17-01-category-crud-create 으로 변경해주세요.

productCategory.entity.ts 파일을 수정해주겠습니다.

import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
@ObjectType()
export class ProductCategory {
  @PrimaryGeneratedColumn('uuid')
  @Field(() => String)
  id: string;

  @Column({ unique: true })
  @Field(() => String)
  name: string;
}

@ObjectType, @Field(() => String) 을 추가했습니다.

데코레이커는 graphql에게 알려줍니다.

@Column({ unique: true }) 옵션값으을 추가해 유일한 값으로 저장합니다.

src/apis/productCategoryproductCategory.service.ts 파일을 생성해주세요.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProductCategory } from './entities/productCategory.entity';
import { Repository } from 'typeorm';

@Injectable()
export class ProductCategoryService {
  constructor(
    @InjectRepository(ProductCategory)
    private readonly productCategory: Repository<ProductCategory>,
  ) {}

  async create({ name }) {
    return await this.productCategory.save({ name });
  }
}

ProductCategoryService 클래스의 생성자에서 레포지토리 의존관계를 주입해줍니다.

@InjectRepository 를 사용해서 생성자를 주입합니다.

private 으로 생성자를 선언하게 되면 인스턴스 생성이 불가능합니다.

즉 외부에서 접근이 불가능해집니다.

readonly 를 통해 생성자를 선언하면 선언 당시 또는 생성자에서 초기화된 후 값이 변경되지 않습니다.

Repository<ProductCategory>ProductCategory 테이블과 비즈니스 로직을 연동시켜준다고 생각하면 됩니다.

src/apis/productCategoryproductCategory.resolver.ts 파일을 생성해주세요.

import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { ProductCategoryService } from './productCategory.service';
import { ProductCategory } from './entities/productCategory.entity';

@Resolver()
export class ProductCategoryResolver {
  constructor(
    private readonly productCategoryService: ProductCategoryService,
  ) {}

  @Mutation(() => ProductCategory)
  async createProductCategory(@Args('name') name: string) {
    return await this.productCategoryService.create({ name });
  }
}

ProductCategoryResolver 클래스의 생성자에서 비즈니스 로직을 의존관계를 주입해주었습니다.

graphql의 @Mutation 을 사용해서 graphql의 결과 타입을 알려줍니다.

graphql의 @Args 을 사용해서 graphql의 인자 타입을 알려줍니다.

의존성 주입을 해 놓은 비즈니스 로직의 create 메서드를 연결시켜주었습니다.

src/apis/productCategoryproductCategory.module.ts 파일을 생성해주세요.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductCategory } from './entities/productCategory.entity';
import { ProductCategoryResolver } from './productCategory.resolver';
import { ProductCategoryService } from './productCategory.service';

@Module({
  imports: [TypeOrmModule.forFeature([ProductCategory])],
  providers: [ProductCategoryResolver, ProductCategoryService],
})
export class ProductCategoryModule {}

imports 옵션에 TypeOrmModule.forFeature([ProductCategory]) 을 추가해주세요.

만약 import 하지 않으면 typeorm을 이용해 데이터베이스와 연동이 안됩니다.

providers 옵션은 ProductCategoryResolver, ProductCategoryService 를 추가해주세요.

app.module.ts 에서 imports 옵션에 ProductCategoryModule 을 추가해주세요.

import { Module } from '@nestjs/common';
import { ApolloDriverConfig, ApolloDriver } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { BoardModule } from './apis/board/board.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductCategoryModule } from './apis/productCategory/productCategory.module';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: 'src/commons/graphql/schema.gql',
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'database',
      port: 3306, 
      username: 'root',
      password: '개인이 설정한 비밀번호',
      database: 'myproject',
      entities: [__dirname + '/apis/**/*.entity.*'],
      synchronize: true,
      logging: true,
    }),
    BoardModule,
    ProductCategoryModule, // 추가
  ],
})
export class AppModule {}

yarn start:dev 를 입력해 서버를 실행시켜주세요.

그리고 https://localhost:3000/graphql 에 접속해서 api를 요청해보세요.

데이터가 성공적으로 저장이 된 모습을 확인할 수 있습니다.

Product Create

17-01-category-crud-create 폴더를 복사하여 사본을 만들고 폴더명을 17-02-product-crud-create 으로 변경해주세요.

이번에는 카테고리에 해당하는 상품을 등록해보겠습니다.

// product.entity.ts

import { Field, Int, ObjectType } from '@nestjs/graphql';
import { ProductCategory } from 'src/apis/productCategory/entities/productCategory.entity';
import { ProductSaleslocation } from 'src/apis/productSaleslocation/entities/productSaleslocation.entity';
import { ProductTag } from 'src/apis/productTag/entities/productTag.entity';
import { User } from 'src/apis/user/entities/user.entity';
import {
  Column,
  Entity,
  JoinColumn,
  JoinTable,
  ManyToMany,
  ManyToOne,
  OneToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
@ObjectType()
export class Product {
  @PrimaryGeneratedColumn('uuid')
  @Field(() => String)
  id: string;

  @Column()
  @Field(() => String)
  name: string;

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

  @Column()
  @Field(() => Int)
  price: number;

  @Column({ default: false })
  @Field(() => Boolean)
  isSoldout: boolean;

  @JoinColumn()
  @OneToOne(() => ProductSaleslocation)
  @Field(() => ProductSaleslocation)
  productSaleslocation: ProductSaleslocation;

  @ManyToOne(() => User)
  @Field(() => User)
  user: User;

  @ManyToOne(() => ProductCategory)
  @Field(() => ProductCategory)
  productCategory: ProductCategory;

  @JoinTable()
  @ManyToMany(() => ProductTag, (productTag) => productTag.products)
  @Field(() => [ProductTag])
  productTags: ProductTag[];
}

먼저 graphql에 @ObjectType(), @Field 추가해주세요.

isSoldout 컬럼 에 기본 고정 값을 false로 지정하기 위해서 @column({ default: false }) 추가해주세요.

// productSaleslocation.entity.ts

import { Field, Float, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
@ObjectType()
export class ProductSaleslocation {
  @PrimaryGeneratedColumn('uuid')
  @Field(() => String)
  id: string;

  @Column()
  @Field(() => String)
  address: string;

  @Column()
  @Field(() => Float)
  lat: number;

  @Column()
  @Field(() => Float)
  lng: number;

  @Column({ type: 'timestamp' })
  @Field(() => Date)
  meetingTime: Date;
}
// productTag.entity.ts

import { Field, ObjectType } from '@nestjs/graphql';
import { Product } from 'src/apis/product/entities/product.entity';
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
@ObjectType()
export class ProductTag {
  @PrimaryGeneratedColumn('uuid')
  @Field(() => String)
  id: string;

  @Column()
  @Field(() => String)
  name: string;

  @ManyToMany(() => Product, (product) => product.productTags)
  @Field(() => [Product])
  products: Product[];
}
// user.entity.ts

import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
@ObjectType()
export class User {
  @PrimaryGeneratedColumn('uuid')
  @Field(() => String)
  id: string;

  @Column()
  @Field(() => String)
  email: string;

  @Column()
  @Field(() => String)
  password: string;
}

모든 entity 에 다음과 같이 수정해주세요.


이전에 데이터 전송 객체. 즉, 네트워크 간에 데이터를 어떤 식으로 보낼지를 정의한 객체를 정의하기 위해서 DTO를 적용했습니다.

이번에는 미리 DTO를 정의해서 리졸버와 서비스에 적용하겠습니다.

src/apis/product/dtocreateProduct.input.ts 파일을 생성해주세요.

import { Field, InputType, Int } from '@nestjs/graphql';

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

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

  @Field(() => Int)
  price: number;
}

src/apis/productproduct.service.ts 파일을 생성해주세요.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';
import { Repository } from 'typeorm';
import { CreateProductInput } from './dto/createProduct.input';

interface ICreate {
  createProductInput: CreateProductInput;
}

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  async create({ createProductInput }: ICreate) {
    // 1. 하나씩 나열하기
    // const product = await this.productRepository.save({
    //   name: createProductInput.name,
    //   description: createProductInput.description,
    //   price: createProductInput.price,
    // });

    // 2. 스프레드 연산자 이용하기
    const product = await this.productRepository.save({
      ...createProductInput,
    });

    return product;
  }
}

Product Entity 를 비즈니스 로직에 의존성 주입을 해주었습니다.

타입스크립트의 인터페이스를 사용해 미리 정의해 놓은 createProductInput 으로 인자로 받은 객체의 타입의 유효성을 검사합니다.

데이터베이스에 저장하는 방법은 하나씩 나열하는 방법을 사용할 수도 있지만 스프레드 연산자를 사용해서 객체를 펼쳐서 데이터를 저장했습니다.

src/apis/productproduct.resolver.ts 파일을 생성해주세요.

import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { ProductService } from './product.service';
import { Product } from './entities/product.entity';
import { CreateProductInput } from './dto/createProduct.input';

@Resolver()
export class ProductResolver {
  constructor(private readonly productService: ProductService) {}

  @Mutation(() => Product)
  createProduct(
    @Args('createProductInput') createProductInput: CreateProductInput,
  ) {
    return this.productService.create({ createProductInput });
  }
}

라우트를 핸들링하는 리졸버에도 @Args 로 전달받은 데이터를 객체의 요소 하나하나의 타입을 createProductInput 을 통해서 검사를 합니다.

productService 를 의존성 주입해서 create 메서드를 사용합니다.

src/apis/productproduct.module.ts 파일을 생성해주세요.

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';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  providers: [ProductResolver, ProductService],
})
export class ProductModule {}

app.module.ts 에서 imports 옵션에 ProductModule 을 추가해주세요.

import { Module } from '@nestjs/common';
import { ApolloDriverConfig, ApolloDriver } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { BoardModule } from './apis/board/board.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductCategoryModule } from './apis/productCategory/productCategory.module';
import { ProductModule } from './apis/product/product.module';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: 'src/commons/graphql/schema.gql',
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '개인이 설정한 비밀번호',
      database: 'myproject',
      entities: [__dirname + '/apis/**/*.entity.*'],
      synchronize: true,
      logging: true,
    }),
    BoardModule,
    ProductCategoryModule,
    ProductModule,
  ],
})
export class AppModule {}

yarn start:dev 를 입력해 서버를 실행시켜주세요.

그리고 https://localhost:3000/graphql 에 접속해서 api를 요청해보세요.

데이터가 성공적으로 저장이 된 모습을 확인할 수 있습니다.

Product Read

17-02-product-crud-create 폴더를 복사하여 사본을 만들고 폴더명을 17-03-product-crud-read 로 변경해주세요.

이번에는 데이터를 조회하는 로직을 만들어보겠습니다.

일단 데이터 목록을 모두 조회하는 로직을 먼저 만들어보겠습니다.

// product.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';
import { Repository } from 'typeorm';
import { CreateProductInput } from './dto/createProduct.input';

interface ICreate {
  createProductInput: CreateProductInput;
}

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  // 추가
  async findAll() {
    const products = await this.productRepository.find();
    return products;
  }

  async create({ createProductInput }: ICreate) {
    const product = await this.productRepository.save({
      ...createProductInput,
    });

    return product;
  }
}

다음과 같이 TypeOrm의 find 메서드를 사용해서 product 테이블에 존재하는 모든 데이터를 조회하는 비즈니스 로직을 추가했습니다.

반환값은 배열안에 객체의 형태로 반환됩니다.

// product.resolver.ts

import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { ProductService } from './product.service';
import { Product } from './entities/product.entity';
import { CreateProductInput } from './dto/createProduct.input';

@Resolver()
export class ProductResolver {
  constructor(private readonly productService: ProductService) {}

  // 추가
  @Query(() => [Product])
  findAll() {
    return this.productService.findAll();
  }

  @Mutation(() => Product)
  createProduct(
    @Args('createProductInput') createProductInput: CreateProductInput,
  ) {
    return this.productService.create({ createProductInput });
  }
}

@Query 를 사용해서 반환값을 [product] 를 적용해서 배열 안에 객체의 타입으로 지정했습니다.

yarn start:dev 를 입력해 서버를 실행시키고 http://localhost:3000/graphql 에 접속해서 요청을 해보겠습니다.

데이터가 배열안의 객체 형태로 잘 반환됩니다.

이번에는 조건에 해당하는 데이터만 조회해보겠습니다.

// product.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';
import { Repository } from 'typeorm';
import { CreateProductInput } from './dto/createProduct.input';

interface ICreate {
  createProductInput: CreateProductInput;
}

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  async findAll() {
    const products = await this.productRepository.find();
    return products;
  }

  // 추가
  async findOne({ productId }: { productId: string }) {
    const product = await this.productRepository.findOneBy({ id: productId });
    return product;
  }

  async create({ createProductInput }: ICreate) {
    const product = await this.productRepository.save({
      ...createProductInput,
    });

    return product;
  }
}

다음과 같이 TypeOrm의 findOneBy 메서드를 사용해서 product 테이블에 매개변수로 받은 productId 에 해당하는 상품을 조회하는 비즈니스 로직을 추가합니다.

// product.resolver.ts

import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { ProductService } from './product.service';
import { Product } from './entities/product.entity';
import { CreateProductInput } from './dto/createProduct.input';

@Resolver()
export class ProductResolver {
  constructor(private readonly productService: ProductService) {}

  @Query(() => [Product])
  findAll() {
    return this.productService.findAll();
  }

  // 추가
  @Query(() => Product)
  findOne(@Args('productId') productId: string) {
    return this.productService.findOne({ productId });
  }

  @Mutation(() => Product)
  createProduct(
    @Args('createProductInput') createProductInput: CreateProductInput,
  ) {
    return this.productService.create({ createProductInput });
  }
}

@Query 를 사용해서 반환값을 product 를 지정해서 product 엔티티의 객체가 반환됩니다.

http://localhost:3000/graphql 에 접속해서 상품목록 데이터를 조회했을때 하나의 데이터 id 값을 복사해주세요.

그리고 복사한 id 값을 가지고 productId 값에 넣어서 데이터를 조회합니다.



Product Update

17-03-product-crud-read 폴더를 복사해서 사본을 만든 후 폴더명을 17-04-product-crud-update 로 변경합니다.

src/apis/product/dto 폴더에 updateProduct.input.ts 파일을 만들어주세요.

// updateProduct.input.ts

import { Field, InputType, Int } from '@nestjs/graphql';

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

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

  @Field(() => Int)
  price: number;
}

위와 같이 DTO를 작성해 수정시 필요한 데이터와 데이터 타입을 다음과 같이 작성해주세요.

// updateProduct.input.ts

import { InputType, PartialType } from '@nestjs/graphql';
import { CreateProductInput } from './createProduct.input';

@InputType()
export class UpdateProductInput extends PartialType(CreateProductInput) {}

// 1. PartialType - 만들어둔 dto 가져와서 사용(전부다 필수 요소 아님)
// export class UpdateProductInput extends PartialType(CreateProductInput) {}

// 2. PickType - 필수 요소만
// export class UpdateProductInput extends PickType(
//   Product,
//   ['description'],
//   InputType,
// ) {}

// 3. OmitType - 제거 요소
// export class UpdateProductInput extends OmitType(
//   Product,
//   ['description'],
//   InputType,
// ) {}

DTO를 위와 같이 작성할 수 있습니다.

Product 엔티티에 필요한 컬럼을 하나하나를 가져와서 사용하는 방법입니다.

번거롭게 다시 한번 나열해서 작성할 필요가 없습니다.

// product.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from './entities/product.entity';
import { Repository } from 'typeorm';
import { CreateProductInput } from './dto/createProduct.input';
import { UpdateProductInput } from './dto/updateProduct.input';

interface ICreate {
  createProductInput: CreateProductInput;
}

interface IUpdate {
  productId: string;
  updateProductInput: UpdateProductInput;
}

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  async findAll() {
    const products = await this.productRepository.find();
    return products;
  }

  async findOne({ productId }: { productId: string }) {
    const product = await this.productRepository.findOneBy({ id: productId });
    return product;
  }

  async create({ createProductInput }: ICreate) {
    const product = await this.productRepository.save({
      ...createProductInput,
    });

    return product;
  }

  // 추가
  async update({ productId, updateProductInput }: IUpdate) {
    const product = await this.productRepository.findOne({
      where: { id: productId },
    });

    const newProduct = { ...product, id: productId, ...updateProductInput };
    const updateProduct = await this.productRepository.save(newProduct);
    return updateProduct;
  }
}

create와 마찬가지로 스프레드 연산자를 이용하면 하나씩 나열해서 데이터를 수정하는 방법에 비해 상당히 효율적입니다.

// product.resolver.ts

import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { ProductService } from './product.service';
import { Product } from './entities/product.entity';
import { CreateProductInput } from './dto/createProduct.input';
import { UpdateProductInput } from './dto/updateProduct.input';

@Resolver()
export class ProductResolver {
  constructor(private readonly productService: ProductService) {}

  @Query(() => [Product])
  findAll() {
    return this.productService.findAll();
  }

  @Query(() => Product)
  findOne(@Args('productId') productId: string) {
    return this.productService.findOne({ productId });
  }

  @Mutation(() => Product)
  createProduct(
    @Args('createProductInput') createProductInput: CreateProductInput,
  ) {
    return this.productService.create({ createProductInput });
  }

  // 추가
  @Mutation(() => Product)
  updateProduct(
    @Args('productId') productId: string,
    @Args('updateProductInput') updateProductInput: UpdateProductInput,
  ) {
    return this.productService.update({ productId, updateProductInput });
  }
}

@Args 를 사용해서 인자의 타입을 지정했습니다.

yarn start:dev 로 서버를 실행시키고 http://localhost:3000/graphql 에 접속해주세요.

그리고 findAll 를 모든 상품 데이터 목록을 불러오고 수정할 상품의 id를 복사해주세요.

복사한 상품 아이디 값을 다음과 같이 붙여넣기해서 데이터 수정하는 요청을 보내주세요.



Product Update With Error

만약 상품이 판매되었는지 확인하는 유효성 검사를 했을 때 이미 판매가 되었다면 상품 데이터를 수정할 필요가 없습니다.

이떄 에러를 반환하는 방법에 대해 알아보겠습니다.

17-04-product-crud-update 폴더를 복사하여 사본을 만들고 폳더명을 17-05-product-crud-update-with-error 으로 변경해주세요.

DBeaver를 통해 product 테이블에서 원하는 row의 isSoldout 컬럼 값을 1로 변경해주세요.

1은 true, 0은 false를 의미합니다.



// product.service.ts

// ... 기존 코드

// 추가
async checkIsSoldout({ productId }: { productId: string }) {
    const product = await this.productRepository.findOne({
      where: { id: productId },
    });

    // 이미 판매된 상품이면 에러처리
    if (product.isSoldout) {
      throw new UnprocessableEntityException('판매된 상품입니다.');
    }
}

checkSoldout 함수를 product.service.ts 에 추가해주세요.

checkSoldout 은 상품이 판매가 되었는지 확인하는 비즈니스 로직입니다.

만약 조회한 상품이 판매된 상품이라면 에러를 반환하면서 함수를 종료시켜야 합니다.

throw new 에러 를 사용하면 함수가 중료되면서 에러를 반환합니다.

보통 이런 로직에는 UnprocessableEntityException 을 사용하며 상태 코드는 422입니다.

// product.resolver.ts

// ... 기존 코드

@Mutation(() => Product)
async updateProduct(
    @Args('productId') productId: string,
    @Args('updateProductInput') updateProductInput: UpdateProductInput,
) {
    // 판매가 완료되었는지 확인해보기
    await this.productService.checkIsSoldout({ productId });

    // 상품 수정하기
    return this.productService.update({ productId, updateProductInput });
}

이렇게 클라이언트는 반환된 상태 코드를 보면서 어떤 문제인지 알 수 있습니다.

yarn start:dev 를 입력해 서버를 실행시켜주세요.

http://localhost:3000/graphql 에 접속해서 플레이그라운드를 실행시켜주세요.

findALl 에 요청해서 상품 테이블의 모든 데이터를 조회한 후 isSoldout 칼럼이 true 인 데이터의 id 값을 복사해주세요.

0개의 댓글