[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 2_ 주문처리와 결제기능 구현) #2

DatQueue·2023년 6월 29일
0
post-thumbnail

시작하기에 앞서

바로 직전 포스팅에서 우리가 수행하고자 하는 주문처리과정을 위한 "데이터베이스 설계"를 수행하였다.


Part 1_ DB 설계 (링크참조)


이번 포스팅에선 설계한 테이블 구조와 필드 값들을 바탕으로 주문/결제 로직을 수행해보기로 한다.


💢 사전 로직 설계하기 (주문 로직 전)

주문 로직을 수행하는데 있어 테이블 설계도 마찬가지이지만 주문 로직을 설계하는 과정에 "주문(Order)"에 해당하는 서비스 레이어만 필요하진 않을 것이다.

  1. 먼저 관리자(admin) 권한의 상품(product)이 생성되어있어야 할 것이고
  2. 해당 상품은 판매 대리인(ambassador) 개개인의 link에 포함되어야 할 것이다.
  3. 이러한 link 정보를 바탕으로 주문 로직(order & orderItem)을 작성할 수 있고
  4. 최종 결제 및 결제 확인 절차를 밟을 수 있다.

위의 프로세스를 바탕으로 서비스 레이어를 작성해보자.


> AbstractService

해당 서비스 레이어는 주문 과정에 직접적으로 필요한 유의미한 부분은 아니지만, userService, orderService, productService ... 이러한 모든 서비스 레이어에 거의 "공통적으로" 요구되는 "CRUD"를 추상화시키기 위해 작성하게 되었다.

굳이, 한번 더 각각의 엔티티 레포지터리에 접근해 crud를 수행하지 않아도 될 것이다.

// abstract.service.ts

import { Repository } from "typeorm";

export abstract class AbstractService {
  protected constructor(
    protected readonly repository: Repository<any>
  ) {}

  async save(options) {
    return await this.repository.save(options);
  }

  async find(options) {
    return this.repository.find(options);
  }

  async findOne(options) {
    return this.repository.findOne(options);
  }

  async update(id: number, options) {
    return this.repository.update(id, options);
  }

  async delete(id: number) {
    return this.repository.delete(id);
  }

  async createQueryBuilder(alias: string) {
    return this.repository.createQueryBuilder(alias);
  }
}

> Product Layer

✔ ProductService

// product.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { ProductRepository } from './repository/product.repository';
import { AbstractService } from '../shared/abstract.service';
import { ProductUpdateDto } from './model/product-update.dto';
import { Product } from './model/product.entity';

@Injectable()
export class ProductService extends AbstractService{
  constructor(
    private readonly productRepository: ProductRepository,
  ) {
    super(productRepository);
  }

  async findProductById(id: number) {
    return await this.findOne({
      where: {
        id: id,
      },
    });
  }

  async updateProductInfo(id: number, data: ProductUpdateDto): Promise<Product> {
    const product = await this.findProductById(id);

    if (!product) {
      throw new NotFoundException(`해당 ${id} 의 유저정보는 존재하지 않습니다.`);
    }

    await this.update(id, data);

    const updatedProduct = await this.findProductById(id);
    return updatedProduct;
  }
}

✔ ProductController

// product.controller.ts

import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards, UseInterceptors } from '@nestjs/common';
import { ProductService } from './product.service';
import { ProductCreateDto } from './model/product-create.dto';
import { ProductUpdateDto } from './model/product-update.dto';
import { JwtAccessAuthGuard } from '../auth/utils/guard/jwt-access.guard';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
import { ProductCacheService } from './product-cache.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Request } from 'express';

@Controller()
export class ProductController {
  constructor(
    private readonly productService: ProductService,
    private readonly productCacheService: ProductCacheService,
    private eventEmitter: EventEmitter2,
  ) {}
  
  // no-caching
  @UseGuards(JwtAccessAuthGuard)
  @Get('admin/products')
  async all() {
    return await this.productService.find({});
  }

  @UseGuards(JwtAccessAuthGuard)
  @Post('admin/products')
  async create(
    @Body() productCreateDto: ProductCreateDto,
  ) {
    const product = await this.productService.save(productCreateDto);
    this.eventEmitter.emit('product_updated');
    return product;
  }

  @UseGuards(JwtAccessAuthGuard)
  @Get('admin/products/:id')
  async get(@Param('id') id: number) {
    return await this.productService.findProductById(id);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Put('admin/products/:id')
  async update(
    @Param('id') id: number,
    @Body() productUpdateDto: ProductUpdateDto, 
  ) {
    this.eventEmitter.emit('product_updated');
    
    return this.productService.updateProductInfo(id, productUpdateDto);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Delete('admin/products/:id')
  async delete(@Param('id') id: number) {
    const response = await this.productService.delete(id); 
    this.eventEmitter.emit('product_updated');
    return response;
  }

  @CacheKey('products_cache')
  @CacheTTL(30 * 60) // 30 minutes
  @UseInterceptors(CacheInterceptor)
  @Get('ambassador/products/findAll')
  async findAllWithCache() {
    return this.productService.find({});
  }
}

간단히 product의 생성/조회/수정/삭제(crud)를 위한 핸들러 함수이고, 해당 함수를 실행하는데 있어, adminambassador 경로에 따라 접근 권한을 차등하게 부여하기 위해 @UseGuards(JwtAccessAuthGuard)를 해당 라우트 핸들러 함수에 주입하였다.


※ JwtAccessAuthGuard 알아보기

Implementing Scopes for Multiple Routes _ JWT Guard ✔


참고로 위 컨트롤러에 작성된 마지막 메서드인 findAllWithCache는 지금 당장 설명하진 않겠다. 간단히 말하자면 상품 목록 조회를 캐싱처리한 것이고, 이는 레디스를 통해 구현되었다.

하지만, 조회 시에 이미 상품 데이터가 캐싱처리 되었기 때문에 업데이트 로직에서 업데이트를 한다 한들 이는 조회 응답시에 반영되지 않는다.

이를 위해서 우린 "조회(Read)"아닌 나머지 로직(Create, Update, Delete)에 대해서 캐시 무효화 처리를 해줄 필요가 있다. 컨트롤러 단에서 바로 cachemanager를 사용하여 delete 해줄 수도 있겠지만, 아래와 같이 EventEmitter를 통해 이벤트를 던짐으로써 처리한다.

this.eventEmitter.emit('product_updated');

product_updated를 던져준 EventEmitter에 대한 구현부는 따로 생략하겠다. 현재 주제에선 조금 벗어나므로, 바로 다음 포스팅에서 다뤄보도록 하겠다.


link layer에선 어떠한 로직을 다루고 처리해야할까?

이전 포스팅의 테이블 설계부에서도 언급하였다시피 link판매 대리인(ambassador)과 주문을 이어주기 위한 계층이다. 먼저 product를 바탕으로 link를 생성하는 부분이 필요할 것이고, 추후 완료된 주문에 대한 정보 (주문 완료 상품 갯수, 판매 대리인 수익...)에 대한 응답을 던져줘야할 부분또한 필요할 것이다.


✔ LinkService

전체 로직은 아래와 같다.

// link.service.ts

import { Injectable } from '@nestjs/common';
import { AbstractService } from '../shared/abstract.service';
import { LinkRepository } from './repository/link.repository';
import { User } from '../user/model/user.entity';
import { ProductRepository } from '../product/repository/product.repository';
import { LinkProductRepository } from './repository/link-product.repository';
import { In } from 'typeorm';
import { Product } from '../product/model/product.entity';
import { LinkProduct } from './model/link-product.entity';
import { Link } from './model/link.entity';
import { Order } from '../order/model/order.entity';

@Injectable()
export class LinkService extends AbstractService {
  constructor(
    private readonly linkRepository: LinkRepository,
    private readonly productRepository: ProductRepository,
    private readonly linkProductRepository: LinkProductRepository
  ) {
    super(linkRepository);
  }

  async findLinkByUserId(id: number) {
    return await this.find({
      where: {
        user: id,
      },
      relations: ['orders'],
    });
  }

  async createLink(user: User, products: number[]) {
    const newLink: Link =  await this.save({  
      code: Math.random().toString(36).substring(6),
      user,
      linkProducts: products.map(id => ({ product: { id }}))
    });

    const currentProducts: Product[] = await this.productRepository.find({
      where: {
        id: In(products),
      },
      relations: ['linkProducts']
    });

    const newLinkProducts: LinkProduct[] = currentProducts
      .map(product => {
        const lp = new LinkProduct();
        lp.link = newLink;
        lp.product = product;
        return lp;
      })

    await this.linkProductRepository.save(newLinkProducts);

    return newLink;
  }

  async stats(user: User) {
    const links: Link[] = await this.find({
      user,
      relations: ['orders', 'orders.order_items']
    });

    return links.map(link => {
      const completedOrders: Order[] = link.orders.filter(o => o.complete);

      return {
        code: link.code,
        count: completedOrders.length,
        revenue: completedOrders.reduce((s: number, o: Order) => s + o.ambassador_revenue, 0),
      }
    })
  }

  async findLinkByCode(code: string, relations: string[]) {
    const link = await this.findOne({
      where: {
        code: code,
      },
      relations: relations,
    })
    return link;
  }
}

그럼 몇개의 중요 메서드에 대해 알아보자.

먼저, link의 생성부이다.

  // 매개변수의 `products`는 number[]로써 이는 product의 pk(`id`)값이 될 것이다.
  // 이러한 product id값은 POST 요청시 전문에 실을 요청객체이다.
  async createLink(user: User, products: number[]) {
    // 생성될 link의 정보를 저장한다.
    const newLink: Link =  await this.save({
      // 길이가 6인 랜덤한 문자열을 생성 (고유값을 가지게끔한다)
      code: Math.random().toString(36).substring(6),
      // 컨트롤러에서 받아오게 될 해당 user는 요청 프로세스의 유저 객체가 될 것이다.
      // 또한 해당 유저 객체는 쿠키를 통해 인증 검증이 완료된 유저여야 할 것이다.
      user,
      // 중간 매핑 테이블 `linkProduct`에서 product의 `id`값을 받아올 수 있다.
      linkProducts: products.map(id => ({ product: { id }}))
    });
    
    // 요청시에 전문에 실은 products id값의 배열을 통해서 product 데이터 자체를 가져올 수 있다.
    const currentProducts: Product[] = await this.productRepository.find({
      where: {
        id: In(products),
      },
      relations: ['linkProducts']
    });
    
    // 위에서 구한 `currentProducts`를 바탕으로 각 product를 받아올 수 있고, 
    // 앞서 생성한 새로운 `newLink`와 `product`를 통해 중간 매핑 엔티티 `LinkProduct`를 정의할 수 있다.
    const newLinkProducts: LinkProduct[] = currentProducts
      .map(product => {
        const lp = new LinkProduct();
        lp.link = newLink;
        lp.product = product;
        return lp;
      })
    
    // 생성한 `newLinkProducts`를 레포지터리에 접근해 저장한다.
    await this.linkProductRepository.save(newLinkProducts);

    return newLink;
  }

다음으로는 주문 완료 후, 요청 프로세스의 유저에 따른 링크 정보를 응답해주는 로직이다. 통계라고 생각하면 좋을 것이다.

  async stats(user: User) {
    const links: Link[] = await this.find({
      user,
      // relations 조건으로 orders 테이블 자체뿐 아니라, order_itmes 테이블도 불러와야한다.
      relations: ['orders', 'orders.order_items']
    });
    
    if (links) {
      throw new NotFoundException(`Cannot find ${links} with this ${user}`);
    }
    
    // 각각의 link에 대한 응답처리
    return links.map(link => {
      // 주문이 완료된 건에 대해 처리
      const completedOrders: Order[] = link.orders.filter(o => o.complete);

      return {
        code: link.code,
        count: completedOrders.length,
        revenue: completedOrders.reduce((s: number, o: Order) => s + o.ambassador_revenue, 0),
      }
    })
  }

✔ LinkController

ProductController와 마찬가지로 adminambassador가 포함된 엔드포인트의 핸들러 함수에는 인증 가드인 JwtAccessAuthGuard를 주입한다.

// link.controller.ts

import { Body, ClassSerializerInterceptor, Controller, Get, Param, Post, Req, UseGuards, UseInterceptors } from '@nestjs/common';
import { LinkService } from './link.service';
import { JwtAccessAuthGuard } from '../auth/utils/guard/jwt-access.guard';
import { AuthService } from '../auth/auth.service';
import { Request } from 'express';
import { User } from '../user/model/user.entity';

@UseInterceptors(ClassSerializerInterceptor)
@Controller()
export class LinkController {
  constructor(
    private readonly linkService: LinkService,
    private authService: AuthService,
  ) {}
  
  @UseGuards(JwtAccessAuthGuard)
  @Get('admin/users/:id/links')
  async all(@Param('id') id: number) {
    return this.linkService.findLinkByUserId(id)
  }

  @UseGuards(JwtAccessAuthGuard)
  @Post('ambassador/links')
  async create(
    @Body('products') products: number[],
    @Req() req: Request,
  ) {
    // `authService`를 통해 인증 검증이 된 유저를 불러온다. 이는 쿠키를 통해 접근한다.
    const user: User = await this.authService.findUserByAuthenticate(req);

    return await this.linkService.createLink(user, products);
  }

  @UseGuards(JwtAccessAuthGuard)
  @Get('ambassador/stats')
  async stats(
    @Req() req: Request
  ) {     
    // `authService`를 통해 인증 검증이 된 유저를 불러온다. 이는 쿠키를 통해 접근한다.
    const user: User = await this.authService.findUserByAuthenticate(req);
    return await this.linkService.stats(user);
  }

  @Get('checkout/links/:code')
  async link(@Param('code') code: string) {
    return this.linkService.findLinkByCode(code, ['user', 'linkProducts', 'linkProducts.product']);
  }
}

  // auth.service.ts

  async findUserByAuthenticate(req: Request, relations?: string[]): Promise<User> {
    const cookie = req.cookies['access_token_1'];
    const { id } = await this.jwtService.verifyAsync(cookie);
    const user = await this.userService.findUserById(id, relations);
    return user;
  }

✔ LinkModule

// link.module.ts

import { Module } from '@nestjs/common';
import { LinkController } from './link.controller';
import { LinkService } from './link.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Link } from './model/link.entity';
import { TypeOrmExModule } from '../common/repository-module/typeorm-ex.decorator';
import { LinkRepository } from './repository/link.repository';
import { LinkProductRepository } from './repository/link-product.repository';
import { LinkProductService } from './link-product.service';
import { SharedModule } from '../shared/shared.module';
import { AuthModule } from '../auth/auth.module';
import { ProductModule } from '../product/product.module';
import { ProductRepository } from '../product/repository/product.repository';

@Module({
  imports: [
    TypeOrmModule.forFeature([Link, LinkModule]),
    TypeOrmExModule.forCustomRepository([LinkRepository, LinkProductRepository, ProductRepository]),
    SharedModule,
    AuthModule,
    ProductModule,
  ],
  controllers: [LinkController],
  providers: [LinkService, LinkProductService],
  exports: [LinkService],
})
export class LinkModule {}


💢 주문 로직 설계 with Stripe(결제모듈)

어쩌면 가장 중요할 수 있는 Order 즉, 주문 로직이다. 주문 파트에는 주문 목록 생성뿐 아니라 결제까지 처리되도록 구현해보았다. OrderItem을 불러오는 것 뿐만 아니라, Product, User, Link 등 여러 구현부와 커뮤니케이션이 되어야할 것이다. 그리고 이러한 과정을 최종적으로 "결제"에 담아낼 수 있어야 한다.

> Create Order with Transaction(트랜잭션)

✔ OrderService - 주문 생성(createOrder)

// order.service.ts

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AbstractService } from '../shared/abstract.service';
import { OrderRepository } from './repository/order.repository';
import { LinkService } from '../link/link.service';
import { CreateOrderDto } from './model/create-order.dto';
import { Link } from '../link/model/link.entity';
import { Order } from './model/order.entity';
import { ProductService } from '../product/product.service';
import { OrderItem } from './model/order-item.entity';
import { Product } from '../product/model/product.entity';
import { DataSource } from 'typeorm';
import { StripeService } from './stripe.service';

@Injectable()
export class OrderService extends AbstractService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly linkService: LinkService,
    private readonly productService: ProductService,
    private readonly stripeService: StripeService,
    private dataSource: DataSource,
  ) {
    super(orderRepository);
  }

  async createOrder(createOrderDto: CreateOrderDto) {
    
    // (1)
    const link: Link = await this.linkService.findOne({
      where: {
        code: createOrderDto.code,
      },
      relations: ['user'],
    });

    if (!link) {
      throw new BadRequestException('Invalid link!');
    }
    
    const queryRunner = this.dataSource.createQueryRunner();
    
    // (2)
    try {
      await queryRunner.connect();
      await queryRunner.startTransaction();
      
      // (3)
      const newOrder = new Order();
      newOrder.user_id = link.user.id;
      newOrder.ambassador_email = link.user.email;
      newOrder.first_name = createOrderDto.first_name;
      newOrder.last_name = createOrderDto.last_name;
      newOrder.email = createOrderDto.email;
      newOrder.address = createOrderDto.address;
      newOrder.country = createOrderDto.country;
      newOrder.city = createOrderDto.city;
      newOrder.zip = createOrderDto.zip;
      newOrder.code = createOrderDto.code;

      const order: Order = await queryRunner.manager.save(newOrder);

      const line_items = [];
      
      for (let productsDetail of createOrderDto.products) {
        // (4)
        const product: Product = await this.productService.findOne({
          where: {
            id: productsDetail.products_id,
          },
        });
        
        // (5)
        const orderItem = new OrderItem();
        orderItem.order = order;
        orderItem.product_title = product.title;
        orderItem.price = product.price;
        orderItem.quantity = productsDetail.quantity;
        orderItem.ambassador_revenue = 0.1 * product.price * productsDetail.quantity;
        orderItem.admin_revenue = 0.9 * product.price * productsDetail.quantity;

        await queryRunner.manager.save(orderItem);
        
        // (6)
        line_items.push({
          price_data: {
            currency: 'usd',
            product_data: {
              name: product.title,
              description: product.description,
              images: [product.image],
            },
            unit_amount: 100 * product.price,
          },
          quantity: productsDetail.quantity,
        });
      } 
      
      // (7)
      const source = await this.stripeService.createCheckoutSession(line_items);

      order.transaction_id = source['id'];

      await queryRunner.manager.save(order);
      
      // (8)
      await queryRunner.commitTransaction();

      return source;
    
    // (9)
    } catch (e) {
      console.error(e);
      await queryRunner.rollbackTransaction();

      throw new BadRequestException();
    } finally {
      await queryRunner.release();
    }
  }
 
 }

주문 생성부의 전체 로직은 위와 같다. 물론 더 깔끔한 코드가 있고 개선시킬 수 있는 부분이 있을 것이다.

몇 가지 코드를 분석하기 전 createOrder() 메서드의 실행 흐름에 대해 알아보자. 주문 생성로직은 트랜잭션(transaction)의 흐름하에 진행된다.



주문 생성 로직에 트랜잭션을 사용한 이유는?

트랜잭션을 사용하는 이유는 일반적으로 데이터 일관성과 안전성을 보장하고 예외 상황에 대비하여 롤백을 수행하기 위해서이다. 이를 통해 데이터베이스 상태를 일관되고 안정적으로 유지할 수 있다.

주문 생성 로직에는 단순히 orders 테이블만 관여하는것이 아닌, 관련된 여러 테이블이 사용되고 업데이트 된다. 이를 일관성있게 유지하는 작업은 굉장히 중요하다.

우린 nestjs에서 제공하는 Datasource를 통해 트랜잭션을 실행할 수 있고 이에 따라 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장받을 수 있다.



주문 생성 로직(createOrder()) 플로우 알아보기

  1. 앞서 link생성을 통해 발급받은 고유 코드(code)를 주문 요청 시 입력함으로써 해당 코드에 대한 link 객체를 불러온다. 만약 link가 존재하지 않을 경우의 에러처리를 진행해준다.
  2. queryRunner를 통해 트랜잭션을 실행한다(아래에서 자세히 설명)
  3. 요청 시 바디(전문)에 실게되는 값과 link를 통해 받아온 값들을 일치시키는 과정을 통하여 새로운 주문(order)객체를 생성한다.
  4. link마다 존재하는 개별적 product 객체를 받아온다. (요청 products로부터 product 추출)
  5. 받아온 개별적 product 데이터를 토대로 주문 상품에 대한 객체를 정의및 생성한다.
  6. 사용하게되는 결제 모듈인 "Stripe"에서 제시하는 규칙에 맞게 아이템 리스트를 생성한다. 이는 꼭 "line_items"란 이름을 가져야한다.
  7. 다음으로 StripeService에서 생성한 "결제 세션"을 바탕으로 세션 객체(source로 받아옴)를 불러온 후, 해당 세션 아이디값(식별자)을 주문 트랜잭션 아이디값으로써 저장한다.
  8. 이렇게 주문 생성 로직을 수행한 후 성공시에(try문) 해당 작업을 커밋 처리한다.
  9. 트랜잭션 실패 시에 롤백처리를 하고, 트랜잭션이 끝날경우 데이터베이스 리소스를 해제한다.

코드 하나하나에 대한 세세한 설명은 생략하고 꼭 알고 넘어가야할 부분 및 위의 createOrder() 메서드에서 생략된 구현 코드에 대해 알아보자.


✔ CreateOrderDto

해당 부분은 Request Dto로써 주문 생성시의 요청 객체 형식을 제약한다.

// create-order.dto.ts

import { IsArray, IsEmail, IsNotEmpty, IsString } from "class-validator";

export class CreateOrderDto {

  @IsNotEmpty()
  @IsString()
  first_name: string;

  @IsNotEmpty()
  @IsString()
  last_name: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @IsString()
  address: string;

  @IsNotEmpty()
  @IsString()
  country: string;

  @IsNotEmpty()
  @IsString()
  city: string;

  @IsNotEmpty()
  @IsString()
  zip: string;

  @IsNotEmpty()
  @IsString()
  code: string;

  @IsNotEmpty()
  @IsArray()
  products: {
    products_id: number;
    quantity: number;
  }[]
}

createOrder() 내부에서 새로운 Order 객체를 생성할 시에 사용한 것을 앞서 확인할 수 있었다.

      const newOrder = new Order();
      newOrder.user_id = link.user.id;
      newOrder.ambassador_email = link.user.email;
      newOrder.first_name = createOrderDto.first_name;
      newOrder.last_name = createOrderDto.last_name;
      newOrder.email = createOrderDto.email;
      newOrder.address = createOrderDto.address;
      newOrder.country = createOrderDto.country;
      newOrder.city = createOrderDto.city;
      newOrder.zip = createOrderDto.zip;
      newOrder.code = createOrderDto.code;

> 💡 Stripe 결제 모듈을 사용해보자 (nestjs)

✔ Stripe는 무엇인가?

흔히, 결제 시스템 구축을 위해 많은 경우에 (저도 정확히 모릅니다...) "아임포트"를 통한 "Paypal(페이팔)" 결제 시스템을 사용한다. Stripe는 비교적? 최근에 만들어진 결제 시스템으로 국내는 아니지만 해외에선 Paypal과 더불어 많이 사용하는 추세에 있다.

현재 진행하는 이 간단한 프로젝트에서 어떤 결제모듈을 쓰는가가 중요하진 않을 것이고, 조금 더 간단하게 접근할 수 있는 결제 모듈을 사용하기로 했다. 한국 계좌에 대한 지원을 해주고 있지 않기 때문에 국내에서 실 사용에 대한 문제는 여전하지만 (실제로 stripe-developer 페이지에서 일련의 등록 과정에서 해외계좌및 카드만을 요구하는 것을 확인할 수 있었다.) 개발과정에선 확실히 간편한 로직으로 구현할 수 있다 생각이 든다. 또한, 개발과정에서 대시보드를 지원해주기 때문에(타 결제사도 지원해주는지 사실 모른다) 에러 및,요청-응답 객체의 테스트또한 손 쉽게 확인할 수 있었다.


✔ 프로젝트에서 어떻게 사용했는가? with NestJS

우리가 수행중인 nestjs 프로젝트에선 어떻게 해당 Stripe 모듈을 사용할 수 있을까?

먼저, Stripe를 설치해주어야한다. 아래의 npm 사이트를 통해 진행할 수 있다.


nestjs-stripe npm

npm install --save nestjs-stripe


하지만!!! 위의 페이지대로 현재 nestjs에서 버전에 적용할 시 제대로 수행되지 않을 것이다. 아래는 위의 npm에서 제시하는 모듈 작성법이다. apiKey는 동일하게 stripe developer 사이트에서 받아올 수 있지만, apiVersion과 같은 경우는 **'2020-08-27'아닌, '2022-11-15'** 로 작성되어야한다.

import { Module } from '@nestjs-common';
import { StripeModule } from 'nestjs-stripe';

@Module({
  imports: [
    StripeModule.forRoot({
      apiKey: 'my_secret_key',
      apiVersion: '2020-08-27',
    }),
  ],
})
export class AppModule {}

// order.module.ts

import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from './model/order.entity';
import { TypeOrmExModule } from '../common/repository-module/typeorm-ex.decorator';
import { OrderRepository } from './repository/order.repository';
import { OrderItem } from './model/order-item.entity';
import { OrderItemRepository } from './repository/order-item.repository';
import { OrderItemService } from './order-item.service';
import { LinkModule } from '../link/link.module';
import { ProductModule } from '../product/product.module';
import { StripeModule } from 'nestjs-stripe';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OrderListener } from './listeners/order.listener';
import { StripeService } from './stripe.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([Order, OrderItem]),
    TypeOrmExModule.forCustomRepository([OrderRepository, OrderItemRepository]),
    LinkModule,
    ProductModule,
    StripeModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        // apiKey는 stripe-developer 사이트에서 발급받을 수 있습니다.
        // test 단계시라면 계좌 등록을 요구하지 않고 발급받을 수 있는 임시 키를 사용하시기 바랍니다.
        apiKey: configService.get<string>('STRIPE_KEY'),
        // apiVersion 날짜 문자를 꼭 아래와 같이 작성하세요.
        apiVersion: '2022-11-15'
      }),
      inject: [ConfigService]
    }),
  ],
  controllers: [OrderController],
  providers: [OrderService, OrderItemService, OrderListener, StripeService]
})
export class OrderModule {}

이렇게 모듈단에서 기본적인 설정이 끝났으면 앞서 우리가 확인한 orderService 레이어에서 Stripe 모듈을 사용할 수 있다.

      // ....       

	  const line_items = [];

      for (let productsDetail of createOrderDto.products) {
        
        // ....

        line_items.push({
          price_data: {
            currency: 'usd',
            product_data: {
              name: product.title,
              description: product.description,
              images: [product.image],
            },
            unit_amount: 100 * product.price,
          },
          quantity: productsDetail.quantity,
        });
      } 

위에서 보듯이 line_items라는 빈 배열을 선언한 뒤 해당 빈 배열에 product를 통해 받아온 데이터를 특정한 형식에 맞춰서 삽입해주는 것을 볼 수 있다.

line_item는 추후 "결제 세션" 생성에 사용되는데 이때 정확히 "line_items"라는 이름으로 들어가야한다. 이는 "stripe" 자체에서 제시하는 이름이므로 꼭 line_items라는 명칭으로 사용해야한다.


※ Stripe 문서 참조

[Stripe] create_checkout session-line_itmes - 링크


또한, nestjs-stripe 모듈의 버전 업그레이드 이후로 기존과는 다른 형식으로 line_items를 채워가야한다. 위에서 사용된 price_data, product_data등과 같은 일종의 형식을 지켜야한다는 것이다. 이 역시 위의🔼 링크에 제시되어 있다. (꼭 참조바랍니다)


주문 생성 과정중 마지막으로 알아봐야할 부분이 바로 위에서도 언급하였던 "결제 세션 생성부"이다.

이는 주문을 생성한 주문 건에 관해 transaction_id를 받게끔 한다.

// order.service.ts _ createOrder() 구현부 중

const source = await this.stripeService.createCheckoutSession(line_items);
      
order.transaction_id = source['id'];

createOrder() 내부에서 위와 같이 StripeService의 createCheckoutSession()메서드를 통해 결제 세션 데이터를 받아오고, 이를 source라는 객체에 담는다.

그 후, 우린 source 객체를 통해 고유의 id값을 받아올 수 있고 이를 주문 건의 transaction_id라 명명할 수 있다. 이것은 단순히 주문 시 생성되는 의미없는 PK 아이디값과 다르다. 우린 이 값을 통해 주문 생성 후 "주문 완료"를 수행할 수 있게 된다.

그럼 결제 세션 생성부가 작성된 createCheckoutSession() 메서드를 확인해보자.

// stripe.service.ts

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectStripe } from "nestjs-stripe";
import Stripe from 'stripe';

@Injectable()
export class StripeService {
  constructor(
    @InjectStripe() private readonly stripeClient: Stripe,
    private configService: ConfigService,
  ) {}

  async createCheckoutSession(line_items: any[]): Promise<any> {
    const session = await this.stripeClient.checkout.sessions.create({
      payment_method_types: ['card'],
      // line_items가 사용됨
      line_items,
      mode: 'payment',
      success_url: `${this.configService.get<string>('CHECKOUT_URL')}/success?source={CHECK_SESSION_ID}`,
      cancel_url: `${this.configService.get<string>('CHECKOUT_URL')}/error`,
    });

    return session;
  }
}

모두 필요한 값이니 꼭 기입되어야할 것이다. 이는 stripe-api-reference에 상세히 소개되어 있으며, 에러시에 대시보드에서도 어떠한 프로퍼티가 추가로 요구되고 잘못되었는지에 대해 알려준다. (정말 좋았다)

// .env

# Stripe Options
CHECKOUT_URL=http://localhost:5000
STRIPE_KEY=sk_test_51NLiNmGcqXAnu87FXXKDn8Xbx0qURSBsk************************RfcHzDhpsMqRJheX00kta8h8i2

✔ OrderController -create

  // order.controller.ts

  @Post('checkout/orders')
  async create(
    @Body() createOrderDto: CreateOrderDto,
  ) {
    return await this.orderService.createOrder(createOrderDto);
  }

> Confirm Order (결제 주문 확인부)

마지막으로 생성할 구현부는 "결제주문 확인(Order Confirm)"에 대한 로직이다. 참고로 주문취소, 환불 등에 관한 추가적인 로직에 대해선 다루지 않겠다. 아마 Stripe 문서를 보고 충분히 할 수 있지 않을까 싶다. 추후, 추가적으로 다뤄보도록 하겠다.

그럼 결제 확인 로직에 대해 알아보자.

  // order.service.ts

  async orderConfirm(sourceId: string) {
    const order: Order = await this.findOne({
      where: {
        transaction_id: sourceId,
      },
      relations: ['order_items', 'user']
    });

    if (!order) {
      throw new NotFoundException('Order not found');
    }

    await this.update(order.id, {
      complete: true,
    });

    return {
      message: 'success',
    }
  }

간단하다. Stripe를 통한 결제 주문 확인부에서 중요한 것false였던 "complete"의 상태를 true로 변경시켜주는 것이다.

직전 주문 생성부에서 발급받은 transaction_id 즉, 결제세션 아이디를 통해 위의 작업을 수행해 줄 수 있다.

최초 클라이언트에서 서버로 Client Secret을 요청할 때 Payment IntentIncomplete상태로 시작된다. (이는 Stripe 대시보드에서 확인 가능) 또한 Client Secret Key를 제대로 발급받지 못하면, 프론트단에서 카드 정보를 입력하는 인풋 창 또한 활성화되지 않는다. 그리고 발급받은 Client Secret과 카드 정보를 통해 결제를 완료하게 되면 최초 생성된 Payment IntentComplete 상태로 변경되며 결제가 완료된다.

해당 관련 내용은 아래의 페이지에서 확인할 수 있다. ⬇⬇

링크: payment-intents/verifying-status ✔ __ stripe.com


✔ OrderController -confirm

  // order.controller.ts

  @Post('checkout/orders/confirm')
  async confirm(
    @Body('sourceId') sourceId: string,
  ) {
    return await this.orderService.orderConfirm(sourceId);
  }

생각정리 및 다음포스팅 예고

📢 여기까지이다. 이렇게 nestjs와 "Stripe"를 이용하여 간단한 결제주문 프로세스를 구축해볼 수 있었다. 앞전 포스팅에서 선작업하였던 데이터베이스 설계를 바탕으로 진행하였고, 여러 테이블간의 연관관계를 적절히 맺는 작업이 주요한 포인트였다고 생각한다. 물론, 실제 서비스에선 이보다 더 많은, 더 복잡한 테이블이 존재하고 서로 간의 연관관계를 맺을 것이다. 실제 이커머스에 사용될 수 있는 일부 로직을 간접적으로 체험해봄으로써 어떠한 연결하에 프로세스가 진행되는지를 알게 되는 유용한 시간이었다.

하지만!!! 우린 가장 중요한 부분을 수행하지 않았다. 바로 "테스트"이다. 코드로만 봐서는 제대로 된 전개순서 및 과정이 해깔릴 것이고 정말 이 코드가 유효하게 동작하는지 의문이 들 것이다.

다음 포스팅에선 이번 포스팅에서 구현한 비즈니스 로직을 바탕으로 포스트맨을 통한 테스트를 수행해보고자 한다. 또한 실제 Stripe 결제창에 우리의 주문이 반영되는지와 트랜잭션이 유효하게 동작하는지 또한 알아보고자한다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글