LifeSports Application(ReactNative & Nest.js) - 16. payment-service

yellow_note·2021년 10월 14일
0

#1 payment-service

payment-service는 대관 결제 시 필요한 서비스입니다. 대관 버튼을 클릭하고 대관 비용을 지불하기 위해 필요한 서비스이죠. 흐름을 살펴보겠습니다.

1) 사용자가 대관 이벤트를 발생시킵니다.
2) 우선 대관 관련 데이터를 PENDING 상태로 생성합니다.
3) 데이터가 생성되면 결제 창으로 이동하게 되고 사용자는 결제 이벤트를 발생시킵니다.
4) 결제에 관한 데이터를 생성합니다.
5) 결제가 완료되었음을 알리는 메시지를 메시지 큐로 보냅니다.
6) rental-service에서는 큐를 구독하고 있다가 메시지를 받습니다.
7) 만일 에러 메시지라면 대관 데이터를 삭제하고, 그렇지 않다면 상태를 BEING으로 변경합니다.
8) 이에 대한 결과를 사용자에게 응답합니다.

이러한 흐름대로 구현을 진행하도록 하겠습니다.

#2 프로젝트 생성

nest new payment-service
nest generate module payment
nest generate service payment

마이크로 서비스를 위해 아래의 패키지를 설치하겠습니다.

npm i --save @nestjs/microservices
  • ./src/main.ts
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const microservice = app.connectMicroservice({
    transport: Transport.TCP
  });

  app.enableCors();

  await app.startAllMicroservices();
  await app.listen(7800);
}
bootstrap();

우선 컨트롤러를 작성하고 에러 코드들을 지워나가도록 하겠습니다.

  • ./src/app.controller.ts
import { Body, Controller, Get, HttpStatus, Param, Post } from "@nestjs/common";
import { Builder } from "builder-pattern";
import { statusConstants } from "./constants/status.constants";
import { PaymentDto } from "./dto/payment.dto";
import { PaymentService } from "./payment/payment.service";
import { RequestPayment } from "./vo/request.payment";
import { ResponsePayment } from "./vo/response.payment";

@Controller('payment-service')
export class AppController {
    constructor(private readonly paymentService: PaymentService) {}

    @Post('payment')
    public async payment(@Body() vo: RequestPayment): Promise<any> {
        try {
            const result: any = await this.paymentService.create(Builder(PaymentDto).paymentName(vo.paymentName)
                                                                                    .rentalId(vo.rentalId)
                                                                                    .payer(vo.payer)
                                                                                    .price(vo.price)
                                                                                    .build());

            if(result.status === statusConstants.ERROR) {
                return await Object.assign({
                    status: HttpStatus.INTERNAL_SERVER_ERROR,
                    payload: null,
                    message: "Error message: " + result.message,
                });
            }

            return await Object.assign({
                status: HttpStatus.OK,
                payload: Builder(ResponsePayment).paymentName(result.payload.paymentName)
                                                 .payer(result.payload.payer)
                                                 .rentalId(result.payload.rentalId)
                                                 .paymentId(result.payload.paymentId)
                                                 .price(result.payload.price)
                                                 .createdAt(result.payload.createdAt)
                                                 .build(),
                message: "Complete payment!"
            });
        } catch(err) {
            return await Object.assign({
                status: HttpStatus.BAD_REQUEST,
                payload: null,
                message: "Error message: " + err
            });
        }
    }

    @Get('payment/:paymentId')
    public async getPayment(@Param('paymentId') paymentId: string): Promise<any> {
        try {
            const result: any = this.paymentService.getPayment(paymentId);

            if(result.status === statusConstants.ERROR) {
                return await Object.assign({
                    status: HttpStatus.INTERNAL_SERVER_ERROR,
                    payload: null,
                    message: result.message,
                });
            }

            return await Object.assign({
                status: HttpStatus.OK,
                payload: Builder(ResponsePayment).paymentName(result.payload.paymentName)
                                                 .payer(result.payload.payer)
                                                 .rentalId(result.payload.rentalId)
                                                 .paymentId(result.payload.paymentId)
                                                 .price(result.payload.price)
                                                 .createdAt(result.payload.createdAt)
                                                 .build(),
                message: "Get data by rentalId",
            });
        } catch(err) {
            return await Object.assign({
                status: HttpStatus.BAD_REQUEST,
                payload: null,
                message: "Error message: " + err,
            });
        }
    }
}

1) payment: 결제를 진행하는 메서드입니다.

2) getPayment: 대관번호를 이용하여 결제 정보를 불러오는 메서드입니다.

다음의 패키지들을 설치하도록 하겠습니다.

npm install --save class-validator class-transformer mongoose @nestjs/mongoose builder-pattern
  • ./src/dto/payment.dto.ts
import { IsNumber, IsString } from "class-validator"

export class PaymentDto {
    @IsString()
    paymentName: string;

    @IsString()
    payer: string;

    @IsString()
    rentalId: string;

    @IsString()
    paymentId: string;

    @IsNumber()
    price: number;

    @IsString()
    createdAt: string;                                  
}
  • ./src/constants/status.constants.ts
export const statusConstants = {
    SUCCESS: "SUCCESS",
    ERROR: "ERROR",
};
  • ./src/vo/response.payment.ts
export class ResponsePayment {
    paymentName: string;

    payer: string;

    rentalId: string;

    paymentId: string;

    price: number;

    createdAt: string;                                  
}
  • ./src/schema/payment.schema.ts
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { SchemaTypes, Types } from "mongoose";

export type PaymentDocument = Payment & Document;

@Schema()
export class Payment {
    @Prop({ type: SchemaTypes.ObjectId, ref: Payment.name})
    _id: Types.ObjectId;
    
    @Prop({ required: true })
    paymentName: string;

    @Prop({ required: true })
    payer: string;

    @Prop({ required: true })
    rentalId: string;

    @Prop({ required: true })
    paymentId: string;

    @Prop({ required: true })
    price: number;

    @Prop({ required: true })
    createdAt: string;
}

export const PaymentSchema = SchemaFactory.createForClass(Payment);

vo, dto, schema를 완성했습니다. 스키마의 속성명은 다음과 같습니다.
1) paymentName: 결제명
2) payer: 결제자
3) rentalId: 대관번호
4) paymentId: 결제번호
5) price: 결제액
6) createdAt: 결제날짜

schema를 작성했으니 mongoose모듈을 임포트하도록 하겠습니다.

  • ./src/app.modules.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PaymentModule } from './payment/payment.module';

@Module({
  imports: [
    MongooseModule.forRoot("mongodb://localhost:27017/PAYMENTSERVICE?readPreference=primary&appname=MongoDB%20Compass&directConnection=true&ssl=false"),
    PaymentModule
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • ./src/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Payment, PaymentSchema } from 'src/schema/payment.schema';
import { PaymentService } from './payment.service';

@Module({
  imports: [
    MongooseModule.forFeature([{
      name: Payment.name,
      schema: PaymentSchema,
    }]),
  ],
  providers: [PaymentService],
  exports: [PaymentService]
})
export class PaymentModule {}

mongoose 모듈을 임포트했으니 서비스를 만들어보도록 하겠습니다. 다음의 패키지를 설치하겠습니다.

npm install --save uuid
npm install -D @types/uuid
  • ./src/payment/payment.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Builder } from 'builder-pattern';
import { Model } from 'mongoose';
import { statusConstants } from 'src/constants/status.constants';
import { PaymentDto } from 'src/dto/payment.dto';
import { Payment, PaymentDocument } from 'src/schema/payment.schema';
import { v4 as uuid } from 'uuid';

@Injectable()
export class PaymentService {
    constructor(@InjectModel(Payment.name) private paymentModel: Model<PaymentDocument>) {}

    public async create(dto: PaymentDto): Promise<any> {
        try {
            const entity = await this.paymentModel.create(Builder(Payment).paymentId(uuid())
                                                                          .paymentName(dto.paymentName)
                                                                          .payer(dto.payer)
                                                                          .price(dto.price)
                                                                          .rentalId(dto.rentalId)
                                                                          .createdAt(new Date().toDateString())
                                                                          .build());

            return await Object.assign({
                status: statusConstants.SUCCESS,
                payload: Builder(PaymentDto).paymentId(entity.paymentId)
                                            .paymentName(entity.paymentName)
                                            .payer(entity.payer)
                                            .price(entity.price)
                                            .rentalId(entity.rentalId)
                                            .createdAt(entity.createdAt)
                                            .build(),
                message: "Successful transaction"
            });
        } catch(err) {
            return await Object.assign({
                status: statusConstants.ERROR,
                payload: null,
                message: "payment-service: Database error"
            });
        }
    }

    public async getPayment(paymentId: string): Promise<any> {
        try {
            const entity = await this.paymentModel.findOne({ paymentId: paymentId });

            if(!entity) {
                return await Object.assign({
                    status: statusConstants.ERROR,
                    payload: null,
                    message: "payment-service: Not exist data",
                });
            }

            return await Object.assign({
                status: statusConstants.SUCCESS,
                payload: Builder(PaymentDto).paymentId(entity.paymentId)
                                            .paymentName(entity.paymentName)
                                            .payer(entity.payer)
                                            .price(entity.price)
                                            .rentalId(entity.rentalId)
                                            .createdAt(entity.createdAt)
                                            .build(),
                message: "Successful transaction"
            });
        } catch(err) {
            return await Object.assign({
                status: statusConstants.ERROR,
                payload: null,
                message: "payment-service: Database error"
            });
        }
    }
}

payment-service에 대한 구현 코드를 맞췄습니다. 그러면 AMQP를 이용하여 메시지 큐를 생성하도록 하겠습니다.

#3 AMQP

메시지 큐를 도입을 하는데 있어 주로 쓰이는 메시지 큐는 rabbitmq, kafka, activeMQ 등이 있습니다. 물론 3가지 전부 훌륭한 메시지 큐이며 비동기, 확장성 등 다양한 장점이 존재합니다. 하지만 payment-service를 작성하면서 쓸 메시지 큐는 rabbitmq로 채택하였는데 그 이유는 다음과 같습니다.

위의 3가지를 대상으로 비교를 하자면 activeMQ는 자바 애플리케이션에서 주로 사용되기 때문에 javascript와 typescript로 작성중인 대관 애플리케이션과는 맞지 않다고 판단을 하였습니다. 나머지 2개를 비교하는 표를 살펴 보겠습니다.

표를 보면 kafka가 rabbitmq에 비해 시간 대비 메시지 처리량이 월등히 앞서는 걸 알 수 있습니다. 하지만 그럼에도 불구하고 rabbitmq 전통적으로 오래되었고, 전통적으로 많이 쓰이는 메시지 브로커입니다. 물론 메시지 유실의 위험성에 대한 문제가 제기되어왔지만 amqp cloud를 사용할 것이기 때문에 메시지 유실에 대한 위험성은 적을 것이라 생각하고 amqp를 사용하도록 하겠습니다.

amqp cloud 큐 생성은 다음의 글을 참고하겠습니다.

https://velog.io/@biuea/E-commerce-ApplicationNest-js-Microservice-6.-Microservice1

우선 payment-service를 구현하기 위해 다음의 패키지들을 설치하도록 하겠습니다.

npm i --save @nestjs/microservices
npm i --save amqplib amqp-connection-manager
  • ./src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
      transformOptions: {
        enableImplicitConversion: true,
      }
    })
  );
  
  app.enableCors();

  await app.listen(7800);
}
bootstrap();
  • ./src/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { MongooseModule } from '@nestjs/mongoose';
import { Payment, PaymentSchema } from 'src/schema/payment.schema';
import { PaymentService } from './payment.service';

@Module({
  imports: [
    MongooseModule.forFeature([{
      name: Payment.name,
      schema: PaymentSchema,
    }]),
    ClientsModule.register([{
      name: 'payment-service',
      transport: Transport.RMQ,
      options: {
        urls: ['AMQP_CLOUD_URL'],
        queue: 'paymentToRentalQueue',
        queueOptions: {
          durable: true
        }
      }
    }]),
  ],
  providers: [PaymentService],
  exports: [PaymentService]
})
export class PaymentModule {}

rabbitmq 설정을 마쳤으니 서비스를 작성하여 메시지를 발행하도록 하겠습니다.

  • ./src/payment/payment.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { InjectModel } from '@nestjs/mongoose';
import { Builder } from 'builder-pattern';
import { Model } from 'mongoose';
import { statusConstants } from 'src/constants/status.constants';
import { PaymentDto } from 'src/dto/payment.dto';
import { Payment, PaymentDocument } from 'src/schema/payment.schema';
import { v4 as uuid } from 'uuid';

@Injectable()
export class PaymentService {
    constructor(
        @InjectModel(Payment.name) private paymentModel: Model<PaymentDocument>,
        @Inject('payment-service') private readonly client: ClientProxy
    ) {}

    public async create(dto: PaymentDto): Promise<any> {
        try {
            const entity = await new this.paymentModel(Builder(Payment).paymentId(uuid())
                                                                       .paymentName(dto.paymentName)
                                                                       .payer(dto.payer)
                                                                       .price(dto.price)
                                                                       .rentalId(dto.rentalId)
                                                                       .createdAt(new Date().toDateString())
                                                                       .build())
                                                                       .save();

            if(!entity) {
                this.client.emit('PAYMENT_RESPONSE', 'FAILURE');
                
                return Object.assign({
                    status: statusConstants.ERROR,
                    payload: null,
                    message: "payment-service: Not successful transaction"
                });
            }

            this.client.emit('PAYMENT_RESPONSE', entity);

            return await Object.assign({
                status: statusConstants.SUCCESS,
                payload: Builder(PaymentDto).paymentId(entity.paymentId)
                                            .paymentName(entity.paymentName)
                                            .payer(entity.payer)
                                            .price(entity.price)
                                            .rentalId(entity.rentalId)
                                            .createdAt(entity.createdAt)
                                            .build(),
                message: "Successful transaction"
            });
        } catch(err) {
            this.client.emit('PAYMENT_RESPONSE', 'FAILURE');

            return await Object.assign({
                status: statusConstants.ERROR,
                payload: null,
                message: "payment-service: Database error"
            });
        }
    }

    public async getPayment(paymentId: string): Promise<any> {
        try {
            const entity = await this.paymentModel.findOne({ paymentId: paymentId });

            if(!entity) {
                return await Object.assign({
                    status: statusConstants.ERROR,
                    payload: null,
                    message: "payment-service: Not exist data",
                });
            }

            return await Object.assign({
                status: statusConstants.SUCCESS,
                payload: Builder(PaymentDto).paymentId(entity.paymentId)
                                            .paymentName(entity.paymentName)
                                            .payer(entity.payer)
                                            .price(entity.price)
                                            .rentalId(entity.rentalId)
                                            .createdAt(entity.createdAt)
                                            .build(),
                message: "Successful transaction"
            });
        } catch(err) {
            return await Object.assign({
                status: statusConstants.ERROR,
                payload: null,
                message: "payment-service: Database error"
            });
        }
    }
}

에러가 생기는 경우에 FAILURE라는 메시지를, 정상 처리가 될 경우엔 결제 데이터를 발행하도록 하겠습니다.

#4 rental-service

npm i --save @nestjs/microservices
npm i --save amqplib amqp-connection-manager
  • ./src/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RentalModule } from './rental/rental.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/RENTALSERVICE?readPreference=primary&appname=MongoDB%20Compass&directConnection=true&ssl=false'),
    ClientsModule.register([{
      name: 'payment-service',
      transport: Transport.RMQ,
      options: {
        urls: ['AMQP_CLOUD_URL'],
        queue: 'paymentToRentalQueue',
        queueOptions: {
          durable: true,
        }
      }
    }]),
    RentalModule
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • ./src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const microservice = app.connectMicroservice({
    transport: Transport.RMQ,
    options: {
      urls: ['AMQP_CLOUD_URL'],
      queue: 'paymentToRentalQueue',
      queueOptions: {
        durable: true,
      }
    }
  });
  
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
      transformOptions: {
        enableImplicitConversion: true,
      },
    }),
  );

  await app.startAllMicroservices();
  await app.listen(7300);
}
bootstrap();

amqp등록을 했으니 controller에서 데이터를 받아보도록 하겠습니다.

  • ./src/app.controller.ts
import { Body, Controller, Delete, Get, HttpStatus, Param, Patch, Post } from "@nestjs/common";
import { statusConstants } from "./constants/status.constant";
import { RentalService } from "./rental/rental.service";
import { RequestRental } from "./vo/request.rental";
import { ResponseRental } from "./vo/response.rental";
import { Builder } from 'builder-pattern';
import { RentalDto } from "./dto/rental.dto";
import { EventPattern, MessagePattern, Payload } from "@nestjs/microservices";

@Controller('rental-service')
export class AppController {
    ...

    @EventPattern('PAYMENT_RESPONSE')
    public async responsePayment(data: any): Promise<any> {
        console.log(data);
    }
}

위의 사진들처럼 테스트가 잘 진행되는 모습을 볼 수 있습니다.

다음 포스트에서는 rental-service에서 메시지를 전달받고 정상 케이스와 오류 케이스를 나누어 대관 데이터를 처리하는 과정을 구현해보도록 하겠습니다.

0개의 댓글

관련 채용 정보