[주문 결제 #2] toss-payments 결제 플로우를 적용해보아요 (NestJS)

DatQueue·2024년 2월 11일
0
post-thumbnail

🧃 토스 페이먼츠 문서는 친절하다.


> 요청 - 인증 - 승인 flow

기획의 요구에 따라 사용할 pg사로 "토스 페이먼츠"를 채택하게 되었고, 결제 플로우에 대해 아무것도 아는 것이 없는 나는 개발자 문서를 보기 시작하였다.

결제 연동, 에러 코드, 리다이렉트 처리 등등 필수적으로 봐야할 다양한 문서들이 상세하고 친절하게 존재하였지만 그 중 가장 오랫동안 들여다본 문서는 "결제 흐름" 파트였다. 해당 문서의 내용이 사실 상 결제 로직을 설계하는데 있어 가장 핵심이였고, 이를 이해하지 못한다면 디테일한 요소들은 아무런 의미가 없었다.

tosspayments 개발자 문서 - 결제 흐름 이해하기 ✔

물론, 토스 페이먼츠를 사용함으로써 해당 문서만 보는 것보다 포트 원, kg 이니시스 등 여러 pg사 문서를 함께 보는 것이 도움이 되었다. pg사 마다 조금씩의 플로우 차이는 존재하지만, 결제 api를 연동하고 클라이언트/서버에선 각각 어떤식의 처리와 소통을 해야하는지 대부분 비슷한 흐름속에서 설명한다.


다시 돌아와 위의 링크를 눌러보면 가장 먼저 마주하게 될 구문과 그림이 있다.

요청 - 인증 - 승인 으로 이어지는 위 플로우에 대해 간단하게 정리하자면 아래와 같다. 이미 개발자 문서에 자세히 나와있으므로 여기 작성하는 것은 생략하도록 하겠다.

  • 요청 : 구매자가 상품 또는 서비스를 구매하기 위해 주문서에 결제 요청 정보(상품 정보, 결제 금액 등)를 입력하고 결제 요청을 하는 단계

  • 인증 : 카드사가 요청받은 결제 정보, 즉 고객의 신용카드 정보와 결제 금액을 확인해서 이 거래가 유효하며 결제를 허용해도 되는지 확인하는 과정

  • 승인 : 바로 인증된 결제를 카드사에 승인해달라고 요청하는 과정


위의 세 단계를 보고 가장 궁금했던 점은 "인증과 승인의 분리"였고 이는 역시 문서에 잘 설명이 되어있었다.

토스페이먼츠 역시 다른 pg사들과 마찬가지로 "웹훅(Webhook)"을 제공한다. 어떻게 보면 결제 연동에 있어 가장 중요한 기능이기도 한 "웹훅"을 메인 플로우로 두지 않고 인증과 승인을 분리한 해당 플로우는 생각보다 매력적이었다. (모든 이유는 개발자 문서에 잘 나와있습니다)

실제로 여러 서비스 애플리케이션을 보면 인증요청과 결제(승인)요청을 분리한 케이스를 볼 수 있다. 유저에겐 이를 노출하지 않고 한 번에 클라이언트 서버와 리소스 서버만의 통신으로 진행되기도 하고 유저에게 이를 직접 (버튼 클릭 등의 이벤트) 맡기기도 한다.

이번 프로젝트에선 서비스의 특징및 유저 경험 등을 고려해 전자의 방법을 택하였다.

또한 "웹훅"이 결제 진행의 메인 플로우에 사용되지 않을 뿐, 웹훅 역시 추후 "주문 누락 배치 프로세스"를 구축할 때 사용하게 끔 하였다. 금전이 흘러가는 기능 구현인 만큼 두 방법 모두를 사용할 필요가 있었다.


> 클라이언트 / 서버 역할에 따른 전체 흐름

요청 - 인증 - 승인 플로우에 맞게 전체 로직을 구상한다고 하면, 클라이언트와 애플리케이션 서버가 각 부분에서 어떠한 역할을 하는지를 정리하는 과정이 필요하다.

서버 개발자, 클라이언트 개발자 상관없이 각 파트의 역할과 책임 그리고 api 소통을 정리함으로써 더 생산적인 로직 설계에 들어갈 수 있다.

일단 "실 주문"은 미루고, 결제 과정에 대해서만 생각하자. 그 이유는 아마 다음 포스팅 정도에서 중요하게 언급되지 않을까 싶다.



  1. 클라이언트는 사용자가 입력한 정보를 가지고 결제하기 버튼을 누른 뒤 서버에 "가주문 테이블 생성" API를 호출

  2. 서버는 입력받은 데이터를 토대로 가주문 테이블을 생성 후 클라이언트가 필요로 하는 일련의 데이터 응답

  3. 클라이언트는 반환받은 값을 가지고 이어서 requestPayment({ 결제 정보 }) 함수를 통해 토스페이먼츠에 결제창 호출을 수행

  4. 결제창에서 구매자는 일련의 절차 후 최종적으로 결제 완료 수행. 이 후, 토스페이먼츠는 결제의 성공 여부 및 관련 파라미터를 callback url로 리다이렉트 ( + 가주문 데이터와의 정합성 체크)

  5. 서버(애플리케이션 서버)는 성공/실패에 따라 적절한 controller로 응답을 받음
    성공적으로 결제가 이루어졌다면 애플리케이션 서버에서 토스페이먼츠 서버에게 "최종 결제 승인 요청"을 보냄. (이 때, 실패하게 되면 클라이언트에게 실패 사유의 에러를 리턴하고 종료)

  6. 토스페이먼츠는 해당 요청을 검증하고 정상적으로 애플리케이션 서버에 반환. 서버도 해당 데이터 확인 후 잘 래핑해 클라이언트에 반환.

  7. 클라이언트는 애플리케이션 서버로 부터 결제 승인이 완료되었다는 메시지 ({ status: "DONE" })를 받게 되면 그때부터 실 주문 API 시작.


마지막 7번단계를 제외한 1 ~ 6 단계를 이번 포스팅에서 다뤄보고자 한다. 그럼 이제 본격적 코드 레벨로 들어가보자.


🧃 로직 설계 with Nestjs (+React)


> 1) 가주문 테이블 생성

앱의 경우는 다를 수 있겠지만 어떠한 이유에서라도 "가주문 테이블"을 미리 생성하는 작업은 필요하다. 결제 전 미리 서버로 보낸 결제 관련 데이터는 정합성 체킹및 로깅을 위한 선수 작업이라 할 수 있다.

또한, 결제에 필요한 orderId(토스 페이먼츠에서 결제에 사용하게 될 주문 ID)의 생성을 클라이언트가 아닌 서버에서 진행해, 가주문 생성 시 응답으로 넘겨주도록 한다.


temp_orders

가주문 테이블인 만큼 간단한 정보만 기입해준다.

// tempOrders.entity.ts
@Entity()
export class TempOrders {
  @PrimaryColumn({ type: 'varchar', length: 25, comment: '== orderNumber' })
  tempOrderId: string;

  @Column({ type: 'varchar', length: 50 })
  orderName: string;

  @Column({ type: 'int' })
  pointAmount: number;

  @Column({ type: 'mediumint' })
  couponAmount: number;

  @Column({ type: 'int' })
  totalAmount: number;

  @BeforeInsert()
  generateTempOrderId() {
    // 무작위 문자열을 생성하는 함수
    function generateRandomOrderId(length: number): string {
      const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=';
      let randomString = '';
      for (let i = 0; i < length; i++) {
        const randomIndex = Math.floor(Math.random() * charset.length);
        randomString += charset.charAt(randomIndex);
      }
      return randomString;
    }

    // 생성된 무작위 문자열을 tempOrderId에 할당
    this.tempOrderId = generateRandomOrderId(25);
  }
}

nanoid와 같은 라이브러리를 사용하여도 상관이 없을 듯하다. 어떤 방법이 되었든 토스페이먼츠에서 제시하는 orderId 형식 제한에 맞추어 temp_orders 테이블 pk 값을 생성해준다.

typeorm에서 제공하는 @BeforeInsert()를 통해 sql의 trigger와 같은 기능을 구현해 낼 수 있다.


클라이언트에게 가주문 테이블 생성 시 pk 값을 제외한 테이블을 이루는 4가지 데이터를 요청 필드로 받고, 응답 시 trigger를 통해 생성 된 랜덤한 orderId값을 리턴한다.

그럼, 아래와 같이 가주문 테이블을 생성 해 보자.

5,000원 주문 금액에 2,000원 포인트를 사용하여 총 결제 금액 3,000원을 가주문 테이블에 저장한다.


> (+) FrontEnd Test

당시 앱 클라이언트보다 서버 구현의 진행도가 빨랐고, 주문 결제의 플로우를 스스로 설계해야하는 상황이었다. 이로 인해 프론트단 구현이 없는 서버만의 API 설계가 쉽지 않았고, 이에 따라 그나마 할 수 있는 스택인 리액트를 이용해 웹 단으로 결제창 페이지를 띄워보기로 하였다. (당시는 플러터 또한 웹뷰를 사용하고 있었다)

리액트로 결제창을 연동하는 과정은 공식문서 혹은 벨로그 토스페이먼츠 문서에서 쉽게 찾아볼 수 있다.

결제창 연동하기 - 개발자센터

// Checkout.tsx

  const handlePayment = async () => {
    const paymentWidget = paymentWidgetRef.current;

    try {
      // ------ '결제하기' 버튼 누르면 결제창 띄우기 ------
      // https://docs.tosspayments.com/reference/widget-sdk#requestpayment결제-정보
      await paymentWidget?.requestPayment({
        orderId: "w-SivxVx_1CBquDv9H6YPAWGe",
        orderName: "떡볶이",
        customerName: "남토스",
        customerEmail: "customer123@gmail.com",
        successUrl: `${window.location.origin}/success`,
        failUrl: `${window.location.origin}/fail`,
      });
    } catch (err) {
      console.error(err);
    }
  };

프론트 단에서 핵심은 위의 handlePayment() 함수 내부에 구현된 requestPayemnt()이다.

앞서 가주문 테이블 생성 시 랜덤으로 만든 문자열 orderId를 서버로부터 받은 후, 위와 같이 해당 필드에 넣어준다. 추가적으로 알아볼 프로퍼티론 결제 인증 성공 여부에 따른 successUrl, failUrl이 될 수 있겠다.


결제창 페이지
(실제 연동 시엔 카드 결제, 간편 결제 만을 다룹니다)


> 2) 결제 요청 -> 인증

간단히 toss pay를 사용해 결제를 진행해보자. 가주문 테이블 생성 과 일치하게 끔, 총 결제 금액(3,000)과 할인 포인트(2,000)원을 동일하게 적용한다.

위와 같이 각 사용 카드사 혹은 간편 결제사의 UI에 따라 결제 요청을 수행할 수 있게 된다. 그 후, 결제 요청이 "성공" 하였을 시 우리가 앞서 프론트 단에서 설정한 callback url에 따라 "success url"로 이동하게 된다.

// Success.tsx
export function SuccessPage() {
  const [searchParams] = useSearchParams();
  console.log(searchParams, "success");
  // 서버로 승인 요청

  return (
    <>
      <h1>결제 성공</h1>
      <div>{`주문 아이디: ${searchParams.get("orderId")}`}</div>
      <div>{`결제 금액: ${Number(
        searchParams.get("amount")
      ).toLocaleString()}`}</div>
    </>
  );
}

위와 같은 success page가 렌더링되며 동시에 callback url로 아래와 같은 정보를 받게 된다.

http://localhost:5173/success?paymentType=NORMAL
	&orderId=w-SivxVx_1CBquDv9H6YPAWGe
	&paymentKey=WjDM1PvGzZ0RnYX2w532*****************KBaOo47
	&amount=3000

success url로 넘어오는 쿼리 파라미터는 총 4가지로, 아래와 같다.

  • paymentKey: 결제를 식별하는 키 값이다. 해당 값은 토스 페이먼츠에서 발급하며 결제 승인, 조회, 취소등의 전반적 운영에 필요한 필수 값이다.

  • orderId: 주문 ID, 결제 요청에 보내는 값이다.

  • amount: 결제 금액이다. 결제 요청에 보낸 결제 금액과 같은지 반드시 확인해볼 필요가 있다. 클라이언트에서 결제 금액을 조작해 승인하는 행위를 방지할 수 있으며, 만약 데이터 불일치시 클라이언트에서 이에 대한 조치를 취해줄 필요가 있다.

  • paymentType: 결제 유형이다. NORMAL, BRANDPAY, KEYIN 중 하나이며, 우리의 경우 "일반 결제"에 해당하는 NORMAL 타입만을 다루게 된다.


여기서 paymentType을 제외한 나머지 3가지 필드를 필수적으로 서버에 넘겨주어야 한다. <요청 - 인증> 까진 서버의 개입이 없었다면 이제부턴 애플리케이션 서버의 역할이 중요시 수행된다.

클라이언트로부터 받은 위의 3가지 필드를 pg사(토스 페이먼츠)로 보내주어 "결제 승인"을 처리할 수 있다.


> 3) 결제 승인 (application server to pg server)

결제 승인 과정 또한 개발자 문서에 상세히 나와 있다.

API & SDK 결제 승인

어려운 것은 없다, express.js로 만들어진 좋은 예시 코드가 나와있으니 충분히 Nestjs에 반영할 수 있다.


curl --request POST \
  --url https://api.tosspayments.com/v1/payments/confirm \
  --header 'Authorization: Basic dGVzdF9za196WExrS0V5cE5BcldtbzUwblgzbG1lYXhZRzVSOg==' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: SAAABPQbcqjEXiDL' \ (멱등키 추가)
  --data '{"paymentKey":"5zJ4xY7m0kODnyRpQWGrN2xqGlNvLrKwv1M9ENjbeoPaZdL6","orderId":"a4CWyWY5m89PNh7xJwhk1","amount":15000}'

위를 베이스로 하여 header에 "멱등키(Idempotency-Key)"를 추가하도록 해준다.

(멱등키 생성 및 생성 이유에 대한 자세한 내용은 개발자 문서를 참고하기 바랍니다)

멱등키 - 개발자 문서


결제 승인 로직은 비즈니스 로직인 만큼 서비스 레이어에서 수행하도록 하며 전체 코드는 아래와 같다.

/* 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 requestTossPayment(tossPaymentReqCommand: TossPaymentReqCommand): Promise<TossPaymentsConfirmResModel> {
    const idempotency = uuid_v4();  // 멱등키 생성
    
    const { paymentKey, orderId, amount } = tossPaymentReqCommand;

    try {
      const response = await axios.post(
        `${this.tossUrl}/${paymentKey}`,
        {
          orderId,
          amount,
        },
        {
          headers: {
            Authorization: `Basic ${Buffer.from(`${this.secretKey}:`).toString('base64')}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': `${idempotency}`,  // 멱등키 추가
          },
          // 필수 승인 요청 데이터
          data: {
            paymentKey: paymentKey,
            amount: amount,
            orderId: orderId,
          }
        },
      );
	  
      // 가주문 테이블에 저장된 데이터 (orderId, totalAmount)와 승인시 내려온 데이터의 정합성 체크
      const tempOrdersData = await this.paymentRepository.getTempOrdersData(orderId);
      
      const { payment_orderId, totalAmount } = tempOrdersData;

      if (response.data.orderId !== payment_orderId) {
        throw new OrderIdMissMatchException();
      }

      if (response.data.totalAmount !== totalAmount) {
        throw new PaymentTotalAmountMissMatchException();
      }
	
      const method = response.data.method;

      let paymentMethod: string = "알 수 없음";

      if (method === "카드") {
        // 발급사 코드에 따른 발급사명 리턴 수행작업
        const issuerCode: CardIssuerCode = response.data.card.issuerCode;
        paymentMethod = CardIssuerName[issuerCode];
      }

      if (method === "간편결제") {
        paymentMethod = response.data.easyPay.provider;
      }

      if (method === "휴대폰") {
        paymentMethod = response.data.method;
      }

      return new TossPaymentsConfirmResModel(
        response.data.orderId,
        response.data.paymentKey,
        response.data.orderName,
        response.data.status,
        paymentMethod,
        response.data.approvedAt,
        response.data.totalAmount
      );
      
    } catch (err) {
      throw err;
    }
  }
}

Check Point 1

가주문 테이블과의 데이터 정합성 체킹 과정을 수행한다. 물론 결제 인증 후, 클라이언트 단에서도 1차 체킹 작업을 수행하지만 서버에서도 승인 후 성공 응답 데이터를 통해 더블 체킹을 수행해준다. 이 때, orderId, totalAmount 등에 miss-match가 일어날 경우 애플리케이션 서버에서 설정한 별도의 에러를 띄운다.

export class OrderIdMissMatchException extends HttpBaseException {
  constructor() {
    super(PaymentConfirmExceptionErrCodeEnum.ORDER_ID_MISS_MATCH, HttpStatus.BAD_REQUEST, PaymentConfirmExceptionMsgEnum.ORDER_ID_MISS_MATCH);
  }
}

export class PaymentTotalAmountMissMatchException extends HttpBaseException {
  constructor() {
    super(PaymentConfirmExceptionErrCodeEnum.PAYMENT_TOTAL_AMOUNT_MISS_MATCH, HttpStatus.BAD_GATEWAY, PaymentConfirmExceptionMsgEnum.PAYMENT_TOTAL_AMOUNT_MISS_MATCH);
  }
}

Check Point 2

클라이언트에게 결제시 사용한 카드 발급사 혹은 간편결제 수단을 알려줄 필요가 있었다. (해당 데이터를 유저에게 보여주기 위함)

결제 승인 성공 시, 응답 데이터로 위의 필드를 제공받을 수 있으며 이를 클라이언트에게 전달해주면 될 것이다.

참고로 결제 승인 성공 시 받을 수 있는 Payment 객체는 아래의 문서에서 확인할 수 있다.

코어 API _Payment 객체 - 개발자 센터


여기서 별도의 처리를 해줘야 할 포인트가 존재한다.

카드사 관련 요청 시엔 토스페이먼츠에서 제공하는 "원하는" 형식을 사용할 수 있지만, 응답시엔 항상 두 자리 "숫자" 코드로 돌아온다.

기관 코드 - 개발자 센터

물론, 이와 같은 숫자 코드를 그대로 클라이언트에게 보내 줄 수 있지만, 결국 클라이언트는 별도의 객체를 생성해 발급사명(혹은 간편 결제사명)과 매핑해주는 과정을 수행해주어야 한다.

이 과정은 서버에서 처리해주도록 하였으며 아래와 같은 객체를 생성해 줄 수 있었다. 참고로, 우린 "발급사 코드(issuerCode)"를 사용하는 것이며 이는 "매입사 코드(acquirerCode)"와 다르니 처음이라면 꼭 확인해보면 좋을 것이다.

export const CardIssuerName = {
  "3K": "기업BC",
  "46": "광주은행",
  "71": "롯데카드",
  "30": "KDB산업은행",
  "31": "BC카드",
  "51": "삼성카드",
  "38": "새마을금고",
  "41": "신한카드",
  "62": "신협",
  "36": "씨티카드",
  "33": "우리BC카드",
  "W1": "우리카드",
  "37": "우체국예금보험",
  "39": "저축은행중앙회",
  "35": "전북은행",
  "42": "제주은행",
  "15": "카카오뱅크",
  "3A": "케이뱅크",
  "24": "토스뱅크",
  "21": "하나카드",
  "61": "현대카드",
  "11": "국민카드",
  "91": "NH농협카드",
  "34": "Sh수협은행",
} as const;

export type CardIssuerCode = keyof typeof CardIssuerName;

enum 객체를 사용하려했지만 숫자로 된 문자열을 key로 두는 것에 제한이 있었고, 이에 따라 일반 객체와 as const로써 정의하였다.

간편결제 같은 경우는 data.easyPay.provider 값으로 한글 결제사명이 넘어오니 상관없다.


이렇게 결제 승인이 잘 수행된다면 클라이언트에게 최종적으로 아래와 같은 리스폰스를 전달해준다. 모든 Payment 객체를 전달해줄 필요는 없다.

export class TossPaymentsConfirmResModel {

  @ApiProperty({
    example: '2wUPgtYG**********dfRybHsxo',
    description: '결제 승인이 완료된 후 다시 리턴하는 orderId값으로, 실주문 api시 다시 요청객체로 받습니다.',
    required: true,
  })
  orderId: string;

  @ApiProperty({
    example: 'd9ojO5qEvKm**********RybneK***********Gg7pD',
    description: '결제 승인이 완료된 후 다시 리턴하는 paymentKey값으로, 실주문 api시 다시 요청객체로 받습니다.',
    required: true,
  })
  paymentKey: string;

  @ApiProperty({
    example: '대표 메뉴 1',
    description: '결제 시 대표메뉴로써 작성한 주문 명',
    required: true,
  })
  orderName: string;

  @ApiProperty({
    example: 'DONE',
    description: '결제 승인에 따른 결제 상태(DONE, CANCELED ...)',
    required: true,
  })
  status: string;

  @ApiProperty({
    example: '토스페이',
    description: '결제 승인 후 pg사로부터 받게 된 결제 수단(카드 매입사 혹은 발급사명)',
    required: true,
  })
  paymentMethod: string;

  @ApiProperty({
    example: '2023-11-30T18:34:51+09:00',
    description: '결제가 승인 된 시간',
    required: true,
  })
  approvedAt: string;

  @ApiProperty({
    example: 5000,
    description: '총 결제된 금액',
    required: true,
  })
  amount: number;

  constructor(orderId: string, paymentKey: string, orderName: string, status: string, paymentMethod: string, approvedAt: string, amount: number) {
    this.orderId = orderId;
    this.paymentKey = paymentKey;
    this.orderName = orderName;
    this.status = status;
    this.paymentMethod = paymentMethod;
    this.approvedAt = approvedAt;
    this.amount = amount;
  }
}

결제 승인 성공


다음 포스팅 예고

본격적으로 주문에 들어가기 전 "서버"에서 처리해야 할 부분이 아직 남았다.

앞서 결제 로직에서 데이터 정합성 체킹을 위해 전체 금액과 주문 ID를 비교하는 과정을 가졌다. 이렇게 "애플리케이션 서버"에서 발생한 에러는 비즈니스 로직에서 에러를 핸들링하기 편하지만 서드파티(pg사)측에서 던지게 될 에러는 어떻게 처리해 주어야 할지 고민이 되기 마련이다.

다음 포스팅에선 간단히 서드 파티 에러를 유연하게 처리하는 방법과 그에 대한 생각을 조금 공유해보고자 한다.

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

0개의 댓글