티스토리 뷰

카드결제 기능을 구현하면서 배운 내용입니다. 단순히 결제를 하는 것은 쉬웠지만, 결제 외에 할일이 많았어요. 

그 과정에서 배운 것들을 정리합니다.

<Script src="https://cdn.iamport.kr/v1/iamport.js"></Script>

Nextjs 14버전을 사용하므로 루트레이아웃에 포트원 라이브러리를 로드할 script를 추가합니다.

스크립트를 추가하면 window객체에 IMP 인스턴스를 사용할 수 있습니다.

const { IMP } = window;
IMP.init(가맹점 식별코드);
IMP.request_pay(data, callback);

가맹점 식별코드는 포트원 콘솔에서 받을 수 있습니다. 공식문서 참고!

버튼의 클릭이벤트에 위 코드를 실행하면 결제창이 나오고 결제까지 할 수 있습니다.

단, 결제창을 클라이언트에서 호출하고 결제도 클라이언트 측에서 iframe을 통해 완료하기 때문에

악의적인 유저가 클라이언트의 스크립트를 수정하여 금액을 위변조하여 결제를 요청할 수 있다고 합니다.

자세한 내용은 이 부분을 참고!

 

그래서 단순히 결제창을 호출하고 결제하는게 끝이 아니라 결제 전, 후로 서버에서 결제정보 위변조 여부를 검증해야 합니다.

일단 프론트엔드에서 결제창을 띄워주기 전에 사전검증을 해야 합니다.

포트원 api에 unique 한 merchant_uid와 가장 중요한 금액을 나타내는 amount를 보내주면 db에 사전검증 데이터가 저장됩니다.

async getPortOneAccessToken() {
    const result = await axios.post('https://api.iamport.kr/users/getToken', {
      imp_key: this.configService.get(IMP_KEY),
      imp_secret: this.configService.get(IMP_SECRET),
    });
    return result.data.response.access_token;
  }

async executePaymentPreparation({
    merchant_uid,
    amount,
  }: {
    merchant_uid: string;
    amount: number;
  }) {
    try {
      const impAccessToken = await this.getPortOneAccessToken();
      const { data } = await axios({
        url: 'https://api.iamport.kr/payments/prepare',
        method: 'post',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${impAccessToken}`,
        },
        data: {
          merchant_uid,
          amount,
        },
      });

      return data.response.merchant_uid;
    } catch (err) {
      throw new BadRequestException('사전인증에 실패했습니다.');
    }
  }

import 엔드포인트에 페이로드를 보내면 되는데요, 토큰을 첨부하지 않으면 401 에러가 발생합니다. /payments/prepare 엔드포인트를 호출하기 전에 토큰을 얻어서 첨부해 주세요. imp_key와 imp_secret 은 포트원 콘솔에서 발급받습니다.

 

이제 백엔드 api를 만들어서 executePaymentPreparation을 호출하고 반환하는 merchant_uid를 프론트엔드로 보내줍니다.

// bids.service.ts

async prepareBid(postId: number, userId: number, body: PrepareBidDto) {
    const { name, phone, bidPrice } = body;
    const status = BidStatus.READY;
	
    ... 유효성 검사
    
    const uniqueMerchantUid = `mid_${uuidv4()}`;

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

    try {
      // portone api에 사전인증 요청
      const preparedMerchantUid = await this.executePaymentPreparation({
        merchant_uid: uniqueMerchantUid,
        amount: bidPrice,
      });

      // 사전인증 성공시 입찰정보 저장
      await this.bidsRepository.save( ... );
      await queryRunner.commitTransaction();

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

유효성 검사를 통과하면 executePaymentPreparation을 호출하여 포트원 db에 사전결제 정보를 저장했습니다. 사전결제 api호출 시 body로 전달받은 amount가 저장되겠죠? 이 부분이 성공적으로 통과하면 우리 db에도 입찰정보를 저장하겠습니다. status라는 필드를 만들어서 이넘 타입의 BidStatus.READY로 저장하겠습니다.

 

여기까지 성공하면 포트원 db에 merchant_uid, amount가 저장될 것이고 우리 db에도 status가 ready상태인 입찰정보가 저장됩니다.

프론트엔드에 merchant_uid를 반환하여 IMP인스턴스를 실행해 보겠습니다.

const onClickPayment = async (formdata: FormData) => {
    if (!window.IMP) return;
    
    ...유효성 검사!

    const preparedMerchantUid = await prepareBidSA({
      postId: productId,
      data: {
        bidPrice: Number(bidPrice),
        name: String(name),
        phone: String(phone),
      },
    });

    const { IMP } = window;
    IMP.init(process.env.NEXT_PUBLIC_IMPORT_STORE_CODE as string); // 가맹점 식별코드

    const data: RequestPay = {
      merchant_uid: preparedMerchantUid, // 주문번호
      amount: +bidPrice, // 결제금액
      ....
    };

    IMP.request_pay(data, callback);
  };

사전검증 api호출을 서버액션으로 만들어서 서버에서 호출하겠습니다. 

prepareBidSA 함수를 호출하면 백엔드에서 위 일련의 과정을 성공적으로 마치고 돌려주는 merchant_uid를 preparedMerchnatUid라는 변수에 담아서 사용하겠습니다.

IMP.requert_pay에 보내는 페이로드에 merchart_uid에 위 값을 넘겨주고 amount도 입력받은 그대로 넘겨줍니다.

바로 이 부분 때문에 사전검증을 해야 하는데요, 아래와 같이 수정하고 테스트해 보면 에러가 발생하면서 결제창을 띄우지 않습니다.

const data: RequestPay = {
      merchant_uid: preparedMerchantUid,
      amount: 1, // 결제금액
      ...
    };

자바스크립트에 중단점을 찍어서 amount에 들어갈 숫자를 변경하면 위와 같이 보내게 된다고 합니다. 이렇게 요청해 보면 에러가 발생하면서 결제창이 뜨지 않습니다. merchant_uid를 가지고 사전검증 때 포트원 db에 입력했던 amount와 비교한 것입니다.

사전검증은 성공했습니다. 이번엔 사후검증입니다. 

결제 완료 후 실행되는 callback함수에서 인자로 받는 response의 정보를 가지고 사후검증 api를 호출하면 됩니다.

 

 async executePaymentConfirmation({ imp_uid }: { imp_uid: string }) {
    try {
      const impAccessToken = await this.getPortOneAccessToken();
      const getPaymentData = await axios({
        url: `https://api.iamport.kr/payments/${imp_uid}`,
        method: 'get',
        headers: { Authorization: impAccessToken },
      });
      return getPaymentData.data.response;
    } catch (err) {
      throw new BadRequestException('사후인증에 실패했습니다.');
    }
  }

 

사후검증도 포트원 api를 호출하면 됩니다. 이때 쿼리스트링으로 imp_uid를 보냅니다. 이게 뭐냐 하면 프론트엔드에서 결제가 완료된 직후 콜백함수의 response로 받게 되는 첫 번째 인자에 들어있는 값입니다. 그니까 포트원에서 실제 결제 건을 토대로 생성한 uid겠죠? 

 

async completeBid(postId: number, body: CompleteBidDto) {
    const { merchant_uid, imp_uid } = body;
    // 사전검증에서 저장했던 입찰내역을 가져온다.
    const preparedBid = await this.bidsRepository.findOne({
      where: { merchantUid: merchant_uid },
    });

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

    try {
      // portone api에 사후인증 요청
      const confirmedBid = await this.executePaymentConfirmation({ imp_uid });

      // 사후인증 성공시 입찰정보 저장
      if (confirmedBid.amount !== preparedBid.bidPrice) {
        await this.changeBidStatus(preparedBid, BidStatus.FAIL);
      }
      await this.changeBidStatus(preparedBid, BidStatus.SUCCESS);

      // post의 입찰가를 업데이트
      const targetPost = await this.postsService.getDetailPost(postId);
      targetPost.bidPrice = confirmedBid.amount;
      await this.postsService.updatePost(postId, targetPost);

      await queryRunner.commitTransaction();
      return { message: 'success' };
    } catch (err) {
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }

 

executePaymentConfirmation메서드를 호출하기 위해 프론트에서 결제 완료 후 받은 imp_uid를 바디에 받아옵니다. 그리고 이 값으로 포트원 api를 호출하여 실제 결제된 데이터의 amount를 가져옵니다. 이것을 사전검증 때 우리 db에 저장했던 입찰정보의 pirce와 비교합니다. 그러면 사전검증, 실제결제된 값, 사후검증의 모든 데이터가 일치함을 알게 됩니다.

저는 사전, 사후 검증 시 위변조가 발견되면 에러를 발생시키는 것 외에도 이 일련의 과정을 기록하기 위해 bids엔티디에 status필드를 만들어 놨었습니다. 이제 검증이 완료되었으니 changeBidStatus메서드를 호출해서 SUCCESS로 값을 변경해 줍니다.

만약 위변조가 발견되면 이 부분에서 에러를 반환합니다.

그리고 사후검증까지 끝났으니 해당 post의 현재 입찰가를 수정해 주고 저장합니다.

 

executePaymentPreparation과 executePaymentConfirmation에서 transaction을 사용했습니다.

여러 요청들이 하나의 api에서 실행되는데, 하나라도 실패했을 경우 모두 롤백시키기 위해서 사용했습니다.

사후검증을 완료하고 데이터 업데이트까지 하고 나면 프론트엔드로 res를 보냅니다.

프론트에서는 이 응답을 받아서 확인하도록 비동기처리를 해야 합니다.

 

const callback = (response: RequestPayResponse) => {
    const { imp_uid, success, error_msg, merchant_uid } = response;

    if (!success) {
      toast.error(`결제에 실패했습니다. ${error_msg}`);
      return;
    }

    completeBidSA({
      postId: productId,
      data: { imp_uid: String(imp_uid), merchant_uid },
    })
      .then(() => {
        toast.success(`${productName} 입찰에 성공했습니다.`);
        router.replace('/');
      })
      .catch(() => {
        toast.error('입찰에 실패했습니다. 고객센터로 문의해주세요.');
      });
  };

사전검증때와 마찬가지로 사후검증 api를 서버액션으로 만들어서 호출했습니다. 그리고 then, catch 문에서 결과에 따라 로직까지 만들어 주었습니다. 

 

위 코드는 전부 pc환경에서의 코드입니다. 모바일 기기에서는 결제모듈을 iframe방식으로 띄우는 게 아니라 새로운 url로 redirect 시켜서 결제모듈을 보여주고, 성공 시에도 개발자가 입력한 url로 redirect를 해줍니다. imp_uid 같은 것들은 쿼리스트링에 담아줍니다. 그래서 모바일 환경에서 결제 시 처리해 줄 page와 ui가 필요하지만 전체적인 로직은 동일합니다.

해당사항은 공식문서를 참고해 주세요.

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함