포트원(portone) 결제 API

전은평·2023년 4월 26일
7

이번에 과제로 외부 API인 결제솔루션 Portone(Iamport)를 이용해서 결제 API 만들기 실습을 보았다.

예전부터 배워보고 싶었던 부분이여서 설렜지만, 별도의 강의 없이 맨바닥에서 완성해보는 첫 과제여서인지, 어디서부터 어떻게 시작해야 하는지부터 감도 못잡고..힘들었다..
하지만 해당 API를 만들면서 현직에서 일하시는 개발자분들은 이런식으로 일을 하시는구나 하고 많은 걸 느낄 수 있었던 계기가 된 것 같다. 그래도 내가 작성한 코드로 실제로 결제가 이루어지고 결제취소까지 구현될 때는 나름 뿌듯하기도 했던 경험이었다.

이제 어떻게 구현했는지 기록을 하려 한다. 하지만.. 온전히 정답이라고는 할 수 없으니 흘린 건 흘려가면서 읽어주길 바란다! 아니 잘못된 부분은 따로 알려주면 더 감사...합니다 💆🏻‍♂️

일단 간단하게 결제 프로세스에 대해 알아보자.

결제 프로세스
1. 구매자가 구입할 상품에 대한 정보와 금액을 판매자에게 전달
2. 판매자는 전달받은 금액을 PG사에게 결제해줄 것을 요청
3. PG사는 요청받은 정보를 은행사에게 다시 결제 요청
4. 은행사는 요청받은 금액을 구매자의 계좌에서 출금 후 PG사로 전달
5. PG사는 판매자에게 금액을 전달 (일정량의 수수료를 제외)
6. 판매자는 금액 확인 후, 구매자에게 옷을 배송

💡 PG사 ?
: Payment Gateway 의 줄임말로,
구매자와 판매자 사이에서의 이뤄지는 결제를 안전하게 할 수 있도록 대행해주는 역할을 담당

대표적인 PG사로는 KG 이니시스, NHN, KCP, LGU+ 등이 있으며,
모바일 환경으로는 KG 모빌리언스, 다날, 카카오Pay 등이 있다.

원래는 PG사로 결제 API 요청을 해서 위의 프로세스를 처리해야 하는데, PG사에 따라서 사용하는 모듈들 또한 다르기에 만약, 사용하고있던 PG사를 다른 PG사로 옮기게 된다면 결제 연동 시스템을 다시 구축해야하는 등, 결론적으로 PG사와 직접적으로 결제연동을 한다는 것은 매우 번거롭고 수고로운 일을 해야한다는 의미이다.

복잡하고 까다로운 과정을 대신 해결해주는 결제솔루션인 결제 외부 API 를 사용하면 정말 간단하게 결제 시스템을 구현할 수 있다고 한다!

내가 이번에 사용한 결제솔루션은 제목에도 언급했듯이 포트원(portone)이다. 과거 이름은 아임포트(iamport)였다고 한다.

일단 포트원 사이트에서 회원가입을 한 후 결제연동에 들어가 어떤 결제로 테스트 할 것인지 채널을 만들어야 한다. 나는 카카오페이를 이용했다.

그리고 내 식별코드 / API Keys로 들어가면 가맹점 식별코드, REST API Key, REST API Secret번호가 발급되어있다.

이는 나중에 쓰이는 일이 있기 때문에 따로 저장을 해놓는 걸 추천!!

이제 준비단계는 끝났고, 실제 결제 연동으로 들어가보자

아래 콘솔 가이드 버튼을 클릭하면 결제연동을 어떻게 활용해야 하는지 상세하게 설명되어 있다.

아래의 그림은 포트원에서 결제 프로세스가 어떻게 이루어지는지 전반적인 흐름을 그림으로 표현해놓은 것을 가져온 것이다. 포트원을 이용하면서 느낀점이지만, 정말 상세하게 설명을 잘 해주어서 그나마 덜 힘들었던 것 같다! 포트원 최고...!!

출처:포트원

나는 아무래도 백엔드 과정을 배우고 있기 때문에 프론드 영역은 크게 건드리지 않고, HTML에서 포트원으로 결제 요청하는 API와 결제 버튼만 하나 따로 만들었다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>결제하기</title>
    <script
      type="text/javascript"
      src="https://code.jquery.com/jquery-1.12.4.min.js"
    ></script>
    <script
      type="text/javascript"
      src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"
    ></script>
    <script
      type="text/javascript"
      src="https://unpkg.com/axios/dist/axios.min.js"
    ></script>

    <script
      src="https://code.jquery.com/jquery-3.3.1.min.js"
      integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
      crossorigin="anonymous"
    ></script>
    <script>
      function mypayment() {
        const myAmount = Number(document.getElementById("amount").value);

        const IMP = window.IMP; // 생략 가능
        IMP.init("가맹점식별코드"); // Example: imp00000000
        IMP.request_pay(
          {
            // param
            pg: "kakaopay",
            pay_method: "card",
            name: "마우스",
            amount: myAmount,
            buyer_email: "gildong@gmail.com",
            buyer_name: "홍길동",
            buyer_tel: "010-4242-4242",
            buyer_addr: "서울특별시 강남구 신사동",
            buyer_postcode: "01181",
            m_redirect_url: "", // 모바일 결제후 리다이렉트될 주소!!
          },
          async (rsp) => {
            // callback
            if (rsp.success) {
              // 결제 성공시
              await axios.post(
                "http://localhost:3000/graphql",
                {
                  query: `
                      mutation {
                        buyTicket(impUid: "${rsp.imp_uid}", amount: ${rsp.paid_amount},pay_method:"${rsp.pay_method}") {
                          id
                          count
                          money
                          method
                        }
                      }
                    `,
                },
                {
                  headers: {
                    authorization:
                      "Bearer 액세스토큰",
                  },
                }
              );
            } else {
              // 결제 실패시
            }
          }
        );
      }
    </script>
  </head>
  <body>
    결제할 금액: <input type="text" id="amount" />
    <button onclick="mypayment()">결제하기</button>
  </body>
</html>

이렇게 해주면 결제요청 처리까지는 완성했다. 결제 요청이 성공하면, 그 이후에 결제처리가 된 data를 내 DB에 저장까지 완료해야 된다. 이때 아무런 검증 없이 그냥 저장해버리면 안된다!! 가맹점 내부 주문 데이터와 지불된 금액을 비교하고 저장을 해야한다. 왜냐하면 나쁜넘들이 HTML에서 금액을 조작해서 결제요청처리할 수 있기 때문이다.

포트원에서 왜 금액 위변조를 검증해야하는지 친절하게 설명해주고 있다. 하지만 처음엔 읽어봐도 이해가 완벽하게 되지 않았다. 지금은 이해했지만
쉽게 이야기하자면, 가격이 100,000원인 상품을 포트원으로 결제요청시 보내는 금액은 1,000원으로 요청해서 실제로 1,000원만 결제되었지만, 우리 백엔드로 보내는 금액은 100,000원으로 보내서 데이터베이스에는 100,000원으로 저장 되는 것이다.

결제시 나는 그래서 결제금액 위변조 검증을 하고, 중복적으로 결제되는 건 아닌지 체크를 한 뒤에 결제 테이블에 데이터를 저장했다.

참고로 나는 영화관 서비스를 바탕으로 서버를 구축하는 것이라 ticketing이 결제 테이블이라 생각하면 된다.

<TicketingResolver.ts>

@Resolver()
export class TicketingResolver {
  constructor(
    private readonly ticketingService: TicketingService, //
  ) {}
  
  // 티켓 구매
  @UseGuards(GqlAuthGuard('access'))
  @Mutation(() => Ticketing)
  buyTicket(
    @Args('impUid') impUid: string,
    @Args({ name: 'amount', type: () => Int }) money: number,
    @Args('pay_method') method: string,
    @Context() context: IContext,
  ): Promise<Ticketing> {
    const user = context.req.user;
    return this.ticketingService.buyTicket({ impUid, money, method, user });
  }

  // 티켓 구매 취소
  @UseGuards(GqlAuthGuard('access'))
  @Mutation(() => Ticketing)
  cancelTicket(
    @Args('impUid') impUid: string,
    @Args({ name: 'amount', type: () => Int }) money: number,
    @Args('pay_method') method: string,
    @Context() context: IContext,
  ): Promise<Ticketing> {
    const user = context.req.user;
    return this.ticketingService.cancelTicket({ impUid, money, method, user });
  }
}

티켓 구매와 티켓 취소 API를 ticketing resolver에 작성했다.
그럼 일단 티켓 구매단계 먼저하고 있으니, ticketingServicebuyTikcet으로 가보자
<TicketingService.ts>

  async buyTicket({
    impUid,
    money,
    method,
    user: _user,
  }: ITicketingServiceBuyTicket): Promise<Ticketing> {
  
    // 아임포트 토큰 발급 및 결제 검증
    await this.iamportService.checkPaid({ impUid, money });
    
    // 중복 결제시
    await this.checkDuplication({ impUid });
    
    // user 테이블에서 유저데이터 찾아서 ticketing 테이블에 등록
    return this.ticketing({
      impUid,
      money,
      method,
      user: _user,
    });
    
    
    // 중복 결제 검증 함수
    async checkDuplication({ impUid }) {
    const result = await this.findOneByImpUid({ impUid });
    if (result) throw new ConflictException('이미 등록된 결제 아이디입니다.');
  }

위에서 언급했듯이 일단 결제금액 검증(checkPaid)을 먼저한 뒤, 중복 결제는 아닌지 체크(checkDuplication)를 했다.

결제 금액 검증을 위해선 포트원 사이트에서 토큰을 발급받고, 발급받은 토큰과 아임포트 구매 ID를 통해 포트원에 결제 등록된 금액에 대한 정보를 받을 수 있었다.

참고 주소:
토큰 발급 관련 API : https://portone.gitbook.io/docs/api/rest-api-access-token#undefined
결제내역 단건 조회 API : https://portone.gitbook.io/docs/api/api-1/api-1

<IamportService.ts>

@Injectable()
export class IamportService {
  
  // import에서 토큰 발급
  async getToken() {
    try {
      const result = await axios.post('https://api.iamport.kr/users/getToken', {
        imp_key: process.env.IMP_KEY,
        imp_secret: process.env.IMP_SECRET,
      });
      return result.data.response.access_token;
    } catch (error) {
      throw new HttpException(
        error.response.data.message,
        error.response.status,
      );
    }
  }
  
  // 결제 금액 비교 (포트원의 실제 결제된 금액과 내 데이터베이스에 저장할 금액)
  async checkPaid({ impUid, money }) {
    try {
      const token = await this.getToken();
      const result = await axios.get(
        `https://api.iamport.kr/payments/${impUid}`,
        { headers: { Authorization: token } },
      );
      if (money !== result.data.response.amount)
        throw new UnprocessableEntityException('잘못된 결제 정보입니다.');
    } catch (error) {
      throw new UnprocessableEntityException('존재하지 않는 결제정보입니다.');
    }
  }

checkpaid에서 토큰을 발급(getToken) 받고, 그 토큰을 이용해 아임포트로 결제관련 정보를 요청한다.

발급시에는 imp_key와 secret이 필요한데 이건 제일 처음에 따로 저장해놓는 것을 추천했던 그 내용을 입력해 주면 된다.

결제관련 정보를 요청할 때에는 impUid라고 가맹점 식별코드를 입력하면 된다.
그럼 reponse에 다양한 정보들이 data에 담겨서 올텐데, 거기서 나는 amount(=결제금액)에 대한 정보만 필요했기에 그것만 가져와서 DB상에 저장하게 될 금액과 포트원에 결제 요청된 금액을 비교하고, 차이가 있다면 오류를 던져주고, 없다면 DB에 저장하는 식으로 결제 부분을 마무리했다.


이번엔 결제 취소에 대해서 알아보자

참고자료
결제취소 API :https://portone.gitbook.io/docs/api/api-1/api

<TicketingResolver.ts>

  @UseGuards(GqlAuthGuard('access'))
  @Mutation(() => Ticketing)
  cancelTicket(
    @Args('impUid') impUid: string,
    @Args({ name: 'amount', type: () => Int }) money: number,
    @Args('pay_method') method: string,
    @Context() context: IContext,
  ): Promise<Ticketing> {
    const user = context.req.user;
    return this.ticketingService.cancelTicket({ impUid, money, method, user });
  }

ticketingResolver에서 결제 취소 부분만 떼어서 왔다.

<TicketingService.ts>

  async cancelTicket({
    impUid,
    money,
    method,
    user: _user,
  }): Promise<Ticketing> {
  
    // 이미 결제 취소된 건 아닌지 체크
    await this.checkAlreadyCanceled({ impUid });

  	// 지불한 금액과 취소하려는 금액 체크
    await this.checkMoney({ money, impUid });
	
    // 포트원(아임포트)에 결제 취소 요청
    await this.iamportService.cancel({ impUid });
	
    // 결제 데이터를 지우는 것이 아니라 추가적으로 저장
    return this.ticketing({
      impUid,
      money: -money,
      method,
      user: _user,
      status: TICKETING_STATUS_ENUM.CANCEL,
    });
  }
  
  	 // 이미 결제 취소된 건 아닌지 체크하는 함수
    async checkAlreadyCanceled({ impUid }) {
    const hasTable = await this.ticketingRepository.findOne({
      where: { impUid, status: TICKETING_STATUS_ENUM.CANCEL },
    });
    if (hasTable) {
      throw new UnprocessableEntityException('이미 취소된 거래입니다');
    }
  }    
    // 지불한 금액과 취소하려는 금액 체크하는 함수
    async checkMoney({ money, impUid }) {
    const paidMoney = await this.findAllByImpUid({ impUid });
    if (paidMoney[0].money !== money)
      throw new UnprocessableEntityException('결제 취소금액을 확인해주세요');
  }

마찬가지로 ticketingService에서 결제 취소 부분(cancelTicket)만 가져왔다.
여기도 마찬가지로 검증을 한 뒤에 결제 취소 요청을 진행했다.

내가 한 검증은 1) 이미 결제 취소된 건인지 체크 , 2) 지불한 금액과 취소하려는 금액 체크 , 이 2가지를 검증했다.
1) 이미 취소된 금액이 있으면 오류를 던져주고 없을시에는 다음 검증으로 넘어가게 코드를 작성했고 2) 지불한 금액과 취소하는 금액이 다를 경우 오류를 던져주었고, 이를 통과한다면 포트원에 결제 취소 요청을 진행되게 코드를 작성했다.

이젠 iamport.service.ts의 cancel로 넘어가보자!

  async cancel({ impUid }) {
    try {
      const token = await this.getToken();
      const result = await axios.post(
        'https://api.iamport.kr/payments/cancel',
        { imp_uid: impUid },
        { headers: { Authorization: token } },
      );
      // if (money !== result.data.response.cancel_amount)
      //   throw new UnprocessableEntityException('취소금액이 일치하지 않습니다');
      return result.data.response.cancel_amount;
    } catch (error) {
      throw new HttpException(error.response, error.response.status);
    }
  }

마찬가지로 토큰을 발급받고, 이를 바탕으로 결제 취소 요청금액과 실제 포트원에서 취소하는 금액과 비교를 하고 일치한다면 결제 취소가 될 수 있게 작성했다!

그럼 결제 취소처리가 되고 DB에는 아까 언급했듯이 결제 테이블에서 데이터 자체를 지우는 것이 아니라 결제 상태를 취소로 바꿔서 저장을 했다.

이렇게 내가 만든 결제 연동 API는 끝이다. 물론 실무에 적용하기엔 부족한 점이 너무 많겠지만, 하나 하나씩 더 채워나간다는 느낌으로 조급해하지 않고 공부해 나가야겠다!!

👨🏻‍💻 이 글로 조금이나마 도움이 되길 바라는 마음으로 작성했으니 도움이 되셨으면 좋겠습니다!

profile
`아는 만큼 보인다` 라는 명언을 좋아합니다. 많이 배워서 많은 걸 볼 수 있는 개발자가 되고 싶습니다.

2개의 댓글

comment-user-thumbnail
2023년 4월 26일

잘 보고 갑니다 ^^

1개의 답글