커머스 코드 자산화 개발일지 - 6 결제 대행 서비스를 테스트 대역으로

지난 글에서 오퍼를 장바구니에 담았다. 이제는 결제다.

대부분의 온라인 상점에서 결제는 전자 지급 결제 대행 서비스Payment Gateway[1](이하 결제 대행 서비스, PG)와 연동하는 것을 의미한다.

출처 : http://internationalpaymentgateway.net/international-payment-gateway-vietnam/

출처 : http://internationalpaymentgateway.net/international-payment-gateway-vietnam/

온라인 상점이 결제를 위해 다양한 카드사나 은행과 직접 계약을 하고 각각의 시스템을 연동하는 것은 매우 비효율적인 작업이다. 결제 대행 서비스는 다양한 결제사와의 인터페이스 작업을 대신해 주고 일관된 API를 제공한다. 따라서 온라인 상점은 결제 대행 서비스하고만 계약하고 인터페이스 하면 되니 효율적이다.

아래는 웹 사이트에서 결제할 때 마주치는 결제 대행 서비스 예시 화면이다.

출처 : yes24.com

출처 : yes24.com

막상 결제 대행 서비스와 연동하려니…

당연한 얘기지만 결제 대행 서비스와 가맹점 계약이 필요하다. 하지만 현재 시점에서 실제로 비즈니스를 하는 것은 아니어서 계약을 하기에는 무리가 있다. 그렇다고 구현에서 결제 대행 서비스를 생략하자니 팥소 없는 찐빵[2] 느낌이었다. 왜냐하면 앞서 언급한 것과 같이 온라인 스토어에서 결제라는 것은 결제사와의 연동이기 때문이다.

그래서 결제 대행 서비스를 흉내 내는 테스트 대역Test Double을 만들기로 했다.

테스트의 실행 목적을 나타내기 위해 실제 컴포넌트 대신 설치하는 객체나 컴포넌트를 테스트 대역이라 한다. - xUnit 테스트 패턴, 226 쪽

결제 대행 서비스 같은 외부 시스템은 통제하기 어렵다. 그만큼 외부 시스템과 인터페이스는 테스트하기 어렵다는 말이다. 이렇다 보니 정상 흐름만을 겨우 테스트해보고 시스템을 실제 오픈한 후에 나오는 오류로 예외 흐름을 인지하고 코드를 수정하는 일이 많다.

테스트 대역을 사용함으로써 얻는 이득은 완벽하진 않지만 다양한 예외 흐름을 재현하여 테스트해 볼 수 있다는 것이다. 또한 나중에 결제 대행 서비스와 연동하게 할 때 테스트 대역만 대체하면 되기 때문에 설계의 변경을 최소화할 수 있다. 다만 테스트 대역을 만들어야 하는 부가적인 작업이 따른다.

결제 프로세스

테스트 대역을 만들기 전에 먼저 해야 하는 일은 결제 대행 서비스와의 인터페이스를 식별하고 정의하는 것이다. 결제 대행 서비스들은 API 문서를 공개하고 있다. 공개된 문서를 기반으로 웹에서 결제 절차를 일반화해 보면 아래와 같다.

image-20200331-005438

결제는 크게 3단계를 거친다.

  • 결제 준비
    • 온라인 상점은 결제 정보(상품, 금액등)와 결제 승인, 취소, 실패 시 호출할 Callback URL을 함께 ‘결제 준비 API’를 호출한다.(위 그림 2번)
    • PG는 결제 고유 번호(TID)와 함께 PG 결제 화면 URL을 반환한다.

  • 인증/결제

    • 사용자는 PG의 결제 화면에서 인증과 결제를 한다.(위 그림 4번)

  • 결제 승인

    • 사용자가 정상적으로 PG와 결제를 완료하면 PG는 ‘결제 준비 API’ 호출 시 전달했던 온라인 상점의 승인 URL을 호출한다.(위 그림 5번)
    • 온라인 스토어는 승인 정보를 검증하고 PG의 ‘결제 승인 API’를 호출한다.(위 그림 6, 7번)

결제 대행 서비스마다 결제 프로세스가 조금씩은 다르지만 내 경험에 근거하면 대동소이하다.

PaymentGateway 테스트 대역 구현

테스트 대역 구현체로 Mountebank 같은 도구를 사용할 수도 있지만 앞서 결제 프로세스를 흉내 내기에는 한계가 있다. 그래서 직접 구현하기로 했다.

1.결제 준비 API

TID를 생성하여 결제 요청 정보와 함께 인메모리 Map을 저장소에 저장하고 결제 화면 URL을 생성하여 반환한다.

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
31
32
33
34
35
36
37
38
39
40
41
42
import (
  "github.com/labstack/echo"
  uuid "github.com/satori/go.uuid"
    //...
)
var (
  tradeStore map[string]interface{}
)
func init() {
  if tradeStore == nil {
      tradeStore = map[string]interface{}{}
  }
}
type PaymentGatewayController struct {
}
func (pg PaymentGatewayController) Init(g *echo.Group) {
  g.POST("/prepare", pg.PreparePayment)
  // ...
}
func (PaymentGatewayController) PreparePayment(ctx echo.Context) error {
  preparePaymentRequest := map[string]interface{}{}
  if err := ctx.Bind(&preparePaymentRequest); err != nil {
      return ctx.JSON(http.StatusBadRequest, dto.ApiError{
          Message: err.Error(),
      })
  }
  trade := map[string]interface{}{}
  tid := generateTID()
  trade["tid"] = tid
  trade["req"] = preparePaymentRequest
  saveTradeToInMemory(tid, trade)
  response := map[string]interface{}{}
  response["tid"] = tid
  response["next_redirect_url"] = fmt.Sprintf("%s/pg/%s/window", config.Config.ServiceUrl, tid)
  return ctx.JSON(http.StatusOK, response)
}
func generateTID() string {
  return uuid.Must(uuid.NewV4(), nil).String()
}
func saveTradeToInMemory(tid string, trade map[string]interface{}) {
  tradeStore[tid] = trade
}

2.인증/결제 화면

사용자 결제 화면은 인증 과정을 생략하고 단순하게 결제 정보를 표시해 주는 것으로 구성했다.

1
2
3
4
5
6
7
8
9
10
11
type PaymentGatewayController struct {
}
func (pg PaymentGatewayController) Init(g *echo.Group) {
  g.GET("/:tid/window", pg.ViewPaymentWindow)
  // ...
}
func (PaymentGatewayController) ViewPaymentWindow(ctx echo.Context) error {
  tid := ctx.Param("tid")
  trade := findTradeFromInMemory(tid)
  return ctx.Render(http.StatusOK, "payment_window", trade)
}

image-20200330-084645

위 화면에서 ‘결제하기’를 클릭하면 테스트 대역의 ‘/pay’ URL을 호출하는데 여기서 승인 정보를 만들고 callback URL로 호출한다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
type PaymentGatewayController struct {
}
func (pg PaymentGatewayController) Init(g *echo.Group) {
  g.POST("/pay", pg.Pay)
  // ...
}
func (PaymentGatewayController) Pay(ctx echo.Context) error {
  tid := ctx.FormValue("tid")
  trade := findTradeFromInMemory(tid)
  tradeReq := trade["req"].(map[string]interface{})
  paid := fmt.Sprintf(`{
      "tid": "%s",
      "oid": "%s",
      "payment_method_type": "CARD",
      "itemName": "%s",
      "quantity": %v,
      "amount": {
          "total": %v,
          "tax_free": 0,
          "vat": %v,
          "discount": 0,
          "point": 0
      },
      "cardUnfo": {
          "interestFreeInstall": "N",
          "bin": "621640",
          "cardType": "체크",
          "cardMid": "123456789",
          "approvedId": "12345678",
          "installMonth": "00",
          "purchaseCorp": "비씨카드",
          "purchaseCorpCode": "01",
          "issuerCorp": "수협카드",
          "issuerCorpCode": "13"
      }
  }`, tid, tradeReq["oid"], tradeReq["itemName"], tradeReq["quantity"], tradeReq["totalAmount"], tradeReq["vatAmount"])
  response, err := http.Post(tradeReq["approvalUrl"].(string), "application/json", bytes.NewBuffer([]byte(paid)))
  if err != nil {
      log.Errorf("pay approvalUrl call error - The HTTP request failed with error %s\n", err.Error())
      return err
  }
  defer response.Body.Close()
  if response.StatusCode != http.StatusOK {
      log.Errorf("pay approvalUrl call error - The HTTP request failed with error %s\n", response.StatusCode)
      return errors.New("pay approvalUrl call error")
  }
  return ctx.Render(http.StatusOK, "payment_result", nil)
}

3.결제 승인 API

결제 승인 API는 단순히 로그만 남기고 성공을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type PaymentGatewayController struct {
}
func (pg PaymentGatewayController) Init(g *echo.Group) {
  g.POST("/approve", pg.ApprovePayment)
  // ...
}
func (PaymentGatewayController) ApprovePayment(ctx echo.Context) error {
  var approvalInformation = map[string]interface{}{}
  if err := ctx.Bind(&approvalInformation); err != nil {
      return ctx.JSON(http.StatusBadRequest, dto.ApiError{
          Message: err.Error(),
      })
  }
  log.Infof("tid : %s Approved...\n", approvalInformation["tid"].(string))
  return ctx.JSON(http.StatusOK, nil)
}

마치며

지금까지 결제 대행 서비스를 흉내 내는 테스트 대역을 만들었다. 다음은 만들어진 테스트 대역과 연동하여 결제를 하고 주문하는 과정을 다룰 예정이다.

주석

[1] '전자 지급 결제 대행 서비스'라는 말은 한글 위키피디아에서 빌려 왔다.

https://ko.wikipedia.org/wiki/전자지급결제대행서비스

[2] 처음에 ‘앙꼬 없는 찐빵'이라고 썼다가 국립국어원에서 ‘앙꼬’라는 일본어 보다 우리말인 '팥소’로 순화해서 사용하도록 하고 있어 고쳤다.

https://www.hankookilbo.com/News/Read/201910200723085246




Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.