[주문 결제 #4] 주문 API의 설계 과정을 소개드립니다.

DatQueue·2024년 2월 25일
1
post-thumbnail

🧃 최선의 프로세스를 찾아서


앞선 포스팅들을 통해 pg사(toss payments)를 통한 결제 플로우를 구축 해 보았으며 그에 대한 예외 처리까지 알아보았다.

순수 결제 플로우에 해당하는 "요청 - 인증 - 승인" 과정에 문제가 발생하였다면 (외부 사정으로 인한 이슈만 없다면) 자연스래 결제가 취소 처리 될 것이며, 만약 승인 과정이 성공적으로 끝나게 된다면 본격적인 "주문" 로직이 실행 되게 끔 로직을 구상하였다.

조금 더 직관적으로 말하자면, "결제 승인 API"가 끝난 후 "전체 주문 트랜잭션 API"의 실행을 이루는 것이다.


> "왜" 결제 승인 API와 주문 API를 분리하게 되었는가 (Lock Timeout)

결제및 주문 플로우를 구상하기 시작한 당시의 머릿속엔 "결제와 주문은 하나다" 라는 문장이 고정으로 박혀 있었다.

"만약 주문이 취소된다면 결제도 취소 되어야하니까... 무조건 결제와 주문은 한 트랜잭션 안에서 진행되어야 할 거야...! 결제는 매우 중요하니까!"


한 번 위의 생각에 따라 결제/주문 프로세스를 그려보자.

언뜻 보기엔 깔끔해보인다.

하지만 위의 프로세스는 정말 큰 문제를 야기할 수 있다. 바로 "Timeout Exceeded"를 터뜨릴 수 있다는 것이다.

결제 모듈은 "외부 모듈"이다. 별도의 서드파티(third-party)와 api 소통을 하게 되는 과정은(특히 결제 연동의 경우) 정말 다양한 이유로 지연을 발생시킬 수 있다.

일반적으로 Lock timeout이 발생하는 이유는 단일 트랜잭션의 수행 시간 증가에 있다.

현재 프로젝트에서 사용중인 RDBMS인 MySQL 기준으로 생각해보자. default isolation-level은 "REPEATABLE READ"이다.

REPEATABLE READ는 트랜잭션의 첫 조회시 해당 데이터에 Shared Lock을 걸고 데이터의 snapshot을 생성한다. 이후 동일 트랜잭션 내의 조회(select)는 snapshot에서 읽게 되는 구조이다.

SELECT를 할 때 snapshot을 생성한다는 것은 READ COMMITTED 역시 동일하지만 REPEATABLE READ와는 조금 방식이 다르다. 아무튼 중요한 것은 해당 내용이 아니라 "트랜잭션의 SELECT 시점에서 snapshot이 구축된다" 는 것이다.

이 snapshot 생성 시간 동안 lock이 걸리게 되고, 만약 트랜잭션 내부의 결제 모듈로 인해 특정 조회에 따른 snapshot 생성 시간이 길어지게 되면 timeout exceeded를 초래하게 된다.


자, 그럼 트랜잭션의 lock timeout 초과를 막을 수 있는 방법은 무엇일까?

  1. db level에서 timeout 시간 조정
  2. db level에서의 isolation level 수정
  3. code level에서의 수정 (적절한 트랜잭션 구축)

대표적으로 위의 방법을 수행 할 수 있게 된다.

1번 방법의 경우 간단한 쿼리문을 통해 현재 rdbms에 부여된 timeout 시간을 확인한 뒤 조정하면 된다.

2번 방법은 단순하게 결정할 문제는 아니었다. 물론 애플리케이션 서버 개발자인 본인의 입장에서 일 수도 있다. isolation level을 수정하는 방법보다 주어진 rdbms의 default isolation level(REPEATABLE READ)을 그대로 유지하되, 최대한 코드 레벨에서의 수정을 통해 이를 해결함이 적절하지 않을까 판단하였다.

그리고 이 방법으로 타이틀 제목에서 나왔듯이 "결제모듈 연동과 주문 트랜잭션의 분리"를 택하게 되었다.

결제 연동을 주문 트랜잭션과 분리시켜 트랜잭션을 최대한 짧게 가져가고 트랜잭션 내부 오류로 인한 롤백 시, 결제 취소를 찌르는 구조이다.

아래에서 조금 더 자세히 알아보자.


> 구체적 프로세스 진행 과정

앞선 내용을 토대로 수정된 프로세스를 그려보자.

이전 포스팅에서 결제 api만 따로 생성한 이유도 이에 있었다. 결제 모듈 연동을 통해 결제 승인을 성공하게 된다면(status=DONE) 그에 따라 주문 트랜잭션을 시작하게 되는 것이다.


이전 포스팅 _toss payments 결제 연동 ✔


즉, 유저는 결제 완료 후 자연스래 주문 진행까지 수행할 수 있지만 실은 클라이언트에서 2개의 api 요청을 날리게끔 하는 것이다.

온전히 주문 트랜잭션만을 수행하는 api가 존재함으로써 최대한의 트랜잭션 lock timeout 문제를 줄일 수 있게 된다.

주문 트랜잭션 내부 수행 로직은 크게 아래와 같다.


  1. 트랜잭션 시작

  2. 주문서 생성 및 주문 메뉴 생성

  3. 결제 시 사용한 쿠폰 삭제 및 포인트 차감

  4. 쿠폰 및 포인트 내역(로그) 생성

  5. 재고 차감 ⚠

  6. 장바구니 비우기

  7. 알림 토큰(디바이스 토큰) 테이블 조회

  8. 트랜잭션 커밋

  9. 트랜잭션 실패 시 롤백


그럼 위의 로직을 담은(호출하게 되는) 서비스 클래스를 정의해보자.

(각각의 세부 수행 로직은 추후 이어지는 포스팅에서 따로 다룰 예정입니다)


✔ OrderService

// order.service.ts

export class OrderService implements OrderUseCase {

  constructor(
    private readonly orderRepository: OrderDrivenPort,
    private readonly menuStockHandleRepository: MenuStockHandlerDrivenPort,
    private readonly orderCouponsHandleRepository: OrderCouponsHandlerDrivenPort,
    private readonly orderPointsHandleRepository: OrderPointsHandleDrivenPort,
    private readonly emptyCartMenusRepository: EmptyCartMenusDrivenPort,  
    private readonly tempOrdersRepository: PaymentDrivenPort,
    private readonly userPointsRepository: UserPointDrivenPort,
    private readonly storeRepository: StoreDrivenPort,
    private readonly notificationRepository: NotificationDrivenPort,
  ) {}

  public async processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise<NotificationPayloadModel> {

    // get temp-orders data for validation check!
    const tempOrdersData = await this.tempOrdersRepository.getTempOrdersData(orderReqCommand.orderNumber);

    const { couponAmount, pointAmount } = tempOrdersData;

    // coupon amount valid check!
    if (couponAmount !== orderReqCommand.useCouponAmount) {
      throw new CouponAmountMissMatchException();
    }

    // point amount valid check!
    if (pointAmount !== orderReqCommand.usePointAmount) {
      throw new PointAmountMissMatchException();
    }

    const queryRunner = dataSource.createQueryRunner();
  
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 주문 생성 (주문, 주문상세, 주문메뉴, 주문메뉴옵션 엔티티 생성)
      await this.orderRepository.createOrders(userId, orderReqCommand, queryRunner);
      
      // 메뉴 수량 차감
      await this.menuStockHandleRepository.decreaseMenuStock(orderReqCommand, queryRunner);

      // 회원 쿠폰 삭제
      await this.orderCouponsHandleRepository.deleteUserCoupons(orderReqCommand, queryRunner);

      // 쿠폰 사용 내역 생성 (사용한 쿠폰이 하나라도 존재할 경우)
      if (orderReqCommand.userMereCouponId || orderReqCommand.userStoreCouponId) {
        await this.orderCouponsHandleRepository.insertUseCouponsLog(
          orderReqCommand.userMereCouponId,
          orderReqCommand.userStoreCouponId,
          orderReqCommand.orderNumber,
          queryRunner
        );
      }

      // 회원 포인트 차감
      if (orderReqCommand.usePointAmount > 0) {
        await this.orderPointsHandleRepository.decreaseUserPoints(userId, orderReqCommand, queryRunner);
      }
      
      // 회원 포인트 내역 생성 (포인트 금액이 0이상일 경우)
      if (orderReqCommand.usePointAmount > 0) {
        const { remainPoint} = await this.userPointsRepository.getUserRemainPoint(userId);
        await this.orderPointsHandleRepository.insertUserPointsLog(userId, orderReqCommand.storeId, orderReqCommand.usePointAmount, 1, remainPoint, queryRunner);
      }
      
      // 장바구니 비우기
      await this.emptyCartMenusRepository.emptyCartMenus(orderReqCommand, queryRunner);

      // notification
      const storeName = await this.storeRepository.findStoreName(orderReqCommand.storeId);
      const notificationTitle = "주문";
      const notificationBody = `[${storeName}] 결제가 완료되었어요. 사장님이 주문을 곧 접수할 예정이에요.`;
      
      const notificationTokenEntity = await this.notificationRepository.getExistsDeviceToken(userId);
      
      await queryRunner.commitTransaction();

      return new NotificationPayloadModel(notificationTitle, notificationBody, notificationTokenEntity);

    } catch (err) {
      await queryRunner.rollbackTransaction();      
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}

실제로 트랜잭션 시 롤백과 더하여 "결제 취소"를 시켜주는 행위, 주문 성공 혹은 실패 시 유저에게 "푸시 알림"을 던지는 행위는 별도의 도메인이다.

즉, 엄밀히 말하면 "공통 도메인 유스케이스"라 할 수 있고 현재 프로젝트의 아키텍처 특성 상 주문 도메인의 컨트롤러 내부에 유스케이스로써 불러오는 구조로 수행된다.


✔ OrderProcessController

// order-process.controller.ts

@ApiTags('order')
@ApiBearerAuth('access-token')
@ErrorResponse(HttpStatus.UNAUTHORIZED, [
  AuthErrorsDefine['0007']
])
@UseGuards(JwtAccessAuthGuard)
@Controller('order')
export class OrderProcessController {
  private readonly cancelReason_case1 = UnCatchedMsg.UNCATCHED_MSG;
  private readonly cancelReason_case2 = UnCatchedMsg.CUSTOMER_CHANGE_OF_MIND;

  constructor(
    @Inject(OrderPaymentUseCaseSymbol)
    private readonly orderPaymentUseCase: OrderPaymentUseCase,
    @Inject(OrderUseCaseSymbol)
    private readonly orderUseCase: OrderUseCase,
    @Inject(FCMCommonInPortSymbol)
    private readonly fcmCommonUseCase: FCMCommonInPort,
  ) {}
  
  @ApiOperation({
    summary: '주문 트랜잭션 api',
    description: 'pg사 결제 승인 완료에 따른 자체 서비스 주문 트랜잭션 시작'
  })
  @ApiBody({
    type: OrderReqDto,
  })
  @ApiCreatedResponse({
    status: 201,
    description: '주문 생성'
  })
  @ErrorResponse(HttpStatus.BAD_REQUEST, [
    PaymentErrorsDefine['1130'],
    OrderErrorsDefine['3101'],
    OrderErrorsDefine['3102'],
    OrderErrorsDefine['4500'],
    OrderErrorsDefine['4501'],
    OrderErrorsDefine['4502'],
    OrderErrorsDefine['4503'],
  ])
  @Post('/start-transaction')
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) {
    const userId = request.userId;
    const orderReqCommand = OrdersMapper.mapToCommand(orderReqDto);

    try {
      const { notificationTitle, notificationBody, notificationTokenEntity } = await this.orderUseCase.processOrderTransaction(userId, orderReqCommand);
      // send-notification
      if (notificationTokenEntity && notificationTokenEntity.notificationToken) {
        
        await this.fcmCommonUseCase.sendTokenToFirebase(
          notificationTokenEntity.notificationTokenId,
          notificationTokenEntity.notificationToken,
          notificationTitle,
          notificationBody,
          'ic_order',
          10,
        );
      }  
    } catch (err) {
      // 중요!! 결제 취소 api(application server to pg server) 호출
      await this.orderPaymentUseCase.cancelPayments(orderReqCommand.paymentKey, this.cancelReason_case1);
      throw err;
    }
  }

위와 같은 식이다. 서비스 레이어끼리의 의존성 주입을 지양하는 아키텍처를 추구하였고, 이에 따라 최상단의 컨트롤러 레이어에서 주문 시 부가적으로 수행되는 결제(Payment) 및 알림(Notification using FCM) 유스케이스(추상회된 인터페이스)를 불러오게 되었다.

즉, 추후 보여지겠지만 OrderProcessService에서 모든 주문 트랜잭션을 수행한 뒤 리턴값으로 void가 아닌 위의 코드에서 보는 것과 같이 푸시 알림 발송에 필요한 정보들(TokenEntity, Title, Body)을 넘겨준다.

export class NotificationPayloadModel {
  notificationTitle: string;
  notificationBody: string;
  notificationTokenEntity: NotificationTokenResModel;

  constructor(notificationTitle: string, notificationBody: string, notificationTokenEntity: NotificationTokenResModel) {
    this.notificationTitle = notificationTitle;
    this.notificationBody = notificationBody;
    this.notificationTokenEntity = notificationTokenEntity;
  }
}

> 결제 취소 구현

마지막으로 확인하게 될 부분이 바로 라우트 핸들러 함수의 catch 문 내부에서 수행되는 "결제 취소"이다.

// order-payment.service.ts

export class OrderPaymentService implements OrderPaymentUseCase {
  private readonly tossUrl = 'https://api.tosspayments.com/v1/payments/';
  private readonly secretKey = process.env.TOSS_TEST_SECRET_KEY;

  constructor(
    private readonly paymentRepository: PaymentDrivenPort,
  ) {}
  
  // ... 생략

  public async cancelPayments(paymentKey: string, cancelReason: string): Promise<void> {
    const idempotency = uuid_v4();

    try {
      await axios.post(
        `${this.tossUrl}/${paymentKey}/cancel`,
        {
          cancelReason,
        },
        {
          headers: {
            Authorization: `Basic ${Buffer.from(`${this.secretKey}:`).toString('base64')}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': `${idempotency}`,
          },
          data: {
            cancelReason: cancelReason,
          },
        });
    } catch (err) {
      throw err;
    }
  }
}

서비스 레이어의 try...catch 내부에서 throw err를 통해 상위 레이어로 에러를 던지게 되고, 최종 주문 프로세스의 catch 문 내부에서 수행된 결제 취소 api(애플리케이션 서버 -> pg 서버)에서 발생하게 된 에러는 커스텀 익셉션 필터가 맡게 된다.

@UseFilters(TossPaymentsCancelFilter)

커스텀 익셉션 필터의 코드는 아래와 같다. 바로 이전의 포스팅에서 다루었던 "결제 승인에 따른 에러(TossPaymentsConfirmFilter)" 와 동일한 형식이다.

결제 승인시 에러가 발생할 수 있는 것 처럼 결제 취소시에도 거의 대부분 비슷한 이유로 에러가 발생할 수 있으며 서버 측에선 이에 대한 예외 처리를 해 줄 필요가 있다.

(구현에 대한 자세한 내용은 이전 포스팅을 참조 바랍니다 ⬇⬇)

이전 포스팅 _pg사의 에러는 어떻게 핸들링할까? ✔


✔ TossPaymentsCancelFilter

@Catch(AxiosError)
export class TossPaymentsCancelFilter implements ExceptionFilter {

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const curr_timestamp: string = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul"});

    const ex = handlingException(exception);

    let responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;

    switch (exception.response.status) {
      case 400:
        responseStatus = HttpStatus.BAD_REQUEST;
        break;
      case 401:
        responseStatus = HttpStatus.UNAUTHORIZED;
        break;
      case 403:
        responseStatus = HttpStatus.FORBIDDEN;
        break;
      case 404:
        responseStatus = HttpStatus.NOT_FOUND;
        break;
      default:
        responseStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    }
    
    response.status(responseStatus).json({
      errorCode: ex.code,
      status: responseStatus,
      msg: ex.message,
      timestamp: curr_timestamp, 
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: string;
  message: string;
}

const handlingException = (err: AxiosError): ExceptionStatus => {
  const code = err.response.data['code'];

  if (!!code) {
    return Object.keys(cancelErrCodeMessageObject).includes(code) 
      ? cancelErrCodeMessageObject[code] 
      : { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 취소 실패 :) 고객센터 문의 바람" }
  }

  return { code: UnCatchedExceptionErrCodeEnum.UNCATCHED, message: "알 수 없는 오류로 인한 결제 취소 실패 :) 고객센터 문의 바람" };
}

✔ cancelErrCodeMessageObject


✔ 예외 시 응답

다음 포스팅 예고

이어질 다음 포스팅들에선 위의 주문 트랜잭션 내부 로직 구현중 일부 중요하다고 판단하는 내용에 대해 추가적인 지식 공유를 해보고자 한다.

꼭 단순 코드 레벨의 내용이 아니더라도 주문 관련 도메인을 관리하게 되면서 부가적으로 처리해줘야 할 여러 세부요소(로깅, 테이블 설계 등)등에 대해서도 얘기해보면 좋을 것 같다.

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

0개의 댓글