아임포트(Iamport)로 결제기능 구현하기 - 일반결제
이전 포스트에 이어서 라라벨에 아임포트를 연동해보자. 마이그레이션, 모델, 컨트롤러가 준비되었다면 이제 해야 할 일은 아임포트의 개발가이드를 보면서 코드를 구현하는 일 뿐이다. 개발가이드에서 일반결제 부분에 보면 Node.js 를 사용한 구현 방법이 나와있는데, 이를 PHP로 표현하는 것은 어려운 일이 아니다.
장바구니
장바구니에 해당하는 Order
를 만들고(Create), 읽는(Read)코드를 작성해보자. 수정과 삭제는 주제를 벗어나므로 구현하지 않는다.
쓰기
쓰기는 컨트롤러에서 OrderController::create(), OrderController::store()
에서 진행한다. OrderController::create()
에는 사용자에게 보여지는 뷰를 반환하면 되고, OrderController::store()
에는 리소스를 실제로 생성하는 행위를 해주면 된다.
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('orders.create');
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$request->validate([
'amount' => 'required|numeric|min:100'
]);
Order::create([
'amount' => $request->amount
]);
return redirect()->route('orders.index');
}
}
OrderController::create()
에서 orders.create
뷰를 반환한다. 즉, resources/views/orders/create.blade.php 를 이야기한다. OrderController::store()
에서는 상품의 가격을 받아오고 주문을 저장하는게 전부다. 끝나면 주문목록이 있는 orders.index
라우트로 이동한다.
뷰
쓰기에서 뷰는 orders.create
만 신경쓰면 된다. 이상해보이겠지만, 이 부분은 일반적인 쇼핑몰이라면 상품상세페이지로 대체된다. 이 코드는 단순히 100원의 가격을 가진 무언가를 장바구니에 담으라는 이야기다.
<form action="{{ route('orders.store') }}" method="POST">
@csrf
<input type="number" name="amount" value="{{ old('amount', 100) }}">
<button type="submit">장바구니에 담기</button>
</form>
KG이니시스에서 카드결제를 이용할 때 최소결제 금액이 100원이라는 점을 잊지말자.
읽기
읽는 것은 주문 목록을 표시하는 것이 전부다. 그를 위해서는 아직 결제가 처리되지 않은(Unpaid) 주문의 목록을 전부 표시해주면 된다. 또한 merchant_uid, amount
의 경우 결제할 때 반드시 필요한 정보이기 때문에 전달해야한다. merchant_uid
는 서버에서 주문에 대한 유일한 값이어야하며 같은 값으로 주문된 경우 이미 처리된 것으로 간주하여 에러를 발생시킨다. 실제로 결제가 진행되면 merchant_uid
가 payments
테이블에 기본 키로 입력된다.
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Support\Str;
class OrderController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$orders = Order::unpaid()->get();
$unpaidPayment = Payment::create([
'amount' => $orders->reduce(function ($amount, $order) {
return $amount += $order->amount;
})
]);
return view('orders.index', [
'orders' => $orders,
'merchant_uid' => $unpaidPayment->merchant_uid,
'amount' => $unpaidPayment->amount
]);
}
}
주문번호(merchant_uid) 생성하기
IMP.request_pay를 호출하기 전에 서버에서 데이터베이스에 주문 레코드를 생성하여 해당 레코드의 주문번호를 param.merchant_uid 에 지정하기를 권장합니다. 결제 프로세스 완료 후 해당 주문번호를 서버에서 조회하여 결제 위변조 여부를 검증하는데 필요합니다.
뷰
orders.index
뷰를 살펴보자. 단순하게 주문목록을 표기하고 결제하기 버튼만이 있을 뿐이다. 이 버튼의 동작에 대해서는 바로 아래에서 다룬다.
<ul>
@foreach ($orders as $order)
<li>{{ $order->amount }}</li>
@endforeach
</ul>
<button type="button" id="payment">결제하기</button>
아임포트 서버에 결제요청 보내기
결제는 꽤 단순하게 처리될 수 있는데, 위에서 만든 결제하기 버튼을 누르면 KG이니시스의 결제화면이 나타나도록 만들어보자. 결제의 구현에 대해서는 아임포트의 개발가이드에서 일반결제를 참고한다.
결제화면을 띄우기 위해서는 아임포트의 Javascript SDK 를 참고해야 할 필요가 있다. 핵심 함수는 IMP.request_pay()
이다. IMP.init()
으로 초기화를 시켜주고 요청해보자. 호출에 요구되는 파라매터 목록은 Javascript SDK 에서 request_pay
에 나와있으니 참고하자.
<!-- jQuery -->
<script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js" ></script>
<!-- iamport.payment.js -->
<script type="text/javascript" src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"></script>
<script>
IMP.init("{{ config('services.iamport.merchant_id') }}");
$('#payment').click(() => {
IMP.request_pay({
pg: '{{ config('services.iamport.pg') }}',
pay_method: 'card',
merchant_uid: '{{ $merchant_uid }}',
name: '테스트',
amount: {{ $amount }}, // 최소금액
buyer_email: 'pronist@naver.com',
buyer_name: '정상우'
}, rsp => {
if (! rsp.success) alert(rsp.error_msg)
})
})
</script>
이제 결제하기 버튼을 누르면 PG사에 해당하는 결제화면이 나타난다. 결제의 성공실패 여부에 관계없이 두 번째 매개변수로 설정한 콜백이 호출된다. 성공여부는 rsp.success
에서 분기할 수 있다. 결제에 성공한 경우 서버에 반영을 해야한다. 그럼 어떻게 해야할까? 사실 그냥 AJAX 를 요청하면 그만이다. 예를 들면 아래와 같다.
$.ajax('/iamport-webhook', {
method: 'POST',
data: {
imp_uid: rsp.imp_uid,
merchant_uid: rsp.merchant_uid
},
success: data => {}
})
그러나 우리는 이 방법이 아닌 아임포트에서 제공하는 웹훅(Webhook)으로 처리해보고자한다. 아임포트 Webhook 을 사용하면 사용자가 브라우저에서 결제시 아임포트 서버에서 직접 우리 서버로 요청을 보내게 된다. 웹훅을 사용하면 클라이언트 측 네트워크 문제로 결제만 처리되고 서버에 적용이 안 되는 경우를 방지할 수 있다.
아임포트 Webhook
관리자 콘솔 - 시스템 설정 - 웹훅(Notification)설정에서 할 수 있는데, 아임포트에서는 다음과 같이 요청을 보낸다. imp_uid, merchant_uid
가 가장 중요한 정보임을 잊지말자.
curl -H "Content-Type: application/json" -X POST -d '{ "imp_uid": "imp_1234567890", "merchant_uid": "order_id_8237352", "status": "paid" }' { NotificationURL }
로컬 서버에서 웹훅 테스트를 해보려면 다른 방법이 필요한데, 아임포트 문서에도 나와있듯이 ngrok 를 사용하는 것이다.
$ ngrok http localhost:8000
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Sangwoo Jeong (Plan: Free)
Version 2.3.40
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://9ff1-183-91-206-236.ngrok.io -> http://localhost:8000
Forwarding https://9ff1-183-91-206-236.ngrok.io -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
6 0 0.00 0.00 4.19 5.19
이렇게 처리하고 https://9ff1-183-91-206-236.ngrok.io 로 접속하면 올바른 화면이 나와야 한다. 하지만 문제가 발생했다면 AuthToken
을 설정해야 한다. https://dashboard.ngrok.com/get-started/your-authtoken
에 진입하고 지시순서에 따르자.
ngrok authtoken <authtoken>
자 이제 다시 관리자 콘솔로가서 설정해보자.
CSRF
라라벨에서는 요청시 CSRF TOKEN 이 포함되는 것이 기본이다. 그러나 웹훅은 CSRF TOKEN 을 포함하지 않는다. 따라서 제외시켜줄 필요가 있다. App\Http\Middleware\VerifyCsrfToken::$except
에서 설정하자.
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
'/iamport-webhook'
];
}
결제상태에 따라 서버에서 처리하기
이제 우리 서버에 결제 상태에 따라 처리만 해주면 된다. 장바구니 담은 상품을 결제하게되면 Payment
를 생성하고 해당 주문들은 생성된 Payment
의 merchant_uid
를 가지고 있도록 해보자.
검증하기
하지만 그 전에 결제를 검증해야한다. 자바스크립트 코드는 조작하기 아주 쉽기때문에 사용자가 실제로 결제한 금액과 결제된 금액에 상응하는 서버의 처리가 달라서는 안 되기 때문이다. 따라서 이 경우에는 아임포트 API 를 통해 결제된 정보를 얻어온 다음 실제 결제금액과 서버에 저장된 결제된 금액을 대조해야 한다. 두 과정을 거쳐야하는데, 하나는 엑세스 토큰(Access Token)을 얻어오는 것이고 또 하나는 해당 엑세스토큰을 가지고 아임포트 API 에 요청하여 결제정보를 얻어오는 것이다.
Iamport
App\Traits\Iamport
를 하나 만들고 엑세스 토큰을 얻어오는 것과 결제정보를 얻어오는 것을 래핑해보자. Iamport::getAccessToken()
으로는 엑세스 토큰을 얻어오고 Iamport::getPayment()
에서는 결제정보를 얻어온다.
namespace App\Traits;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Http;
trait Iamport
{
/**
* Get AccessToken
*
* @return string|false
*/
private function getAccessToken()
{
$response = Http::iamport()->post('users/getToken', [
'imp_key' => config('services.iamport.client_id'),
'imp_secret' => config('services.iamport.client_secret')
]);
if ($response->ok()) {
return $response->object()->response->access_token;
}
return false;
}
/**
* Get Payment Information
*
* @param string $accessToken
* @param string $impUid
* @return mixed|false
*/
private function getPayment(string $accessToken, string $impUid)
{
$response = Http::iamport()->withToken($accessToken)->get("payments/{$impUid}");
if ($response->ok()) {
return $response->object()->response;
}
return false;
}
}
Http 파사드를 사용하면 Http 요청을 손쉽게 처리할 수 있으며 Http::iamport()
처럼 매크로(Macro)를 만들어내려면 App\Providers\AppServiceProvider::boot()
에서 등록을 해야한다.
namespace App\Providers;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Http::macro('iamport', function () {
return Http::baseUrl('https://api.iamport.kr');
});
}
}
IamportController
이제 IamportController::webhook()
에서 결제를 처리하되, 검증을 위한 메서드인 IamportController::verify()
를 하나 만들자. 결제정보를 얻어와서 서버에서 결제되어야 했던 정보와 비교하고 정상적인 처리라면 결제정보를 반환하자. 만들어둔 Iamport
트레이트를 포함하는 것을 잊지말자.
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Payment;
use App\Traits\Iamport;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class IamportController extends Controller
{
use Iamport;
/**
* Verify payment
*
* @param string $impUid
* @param int $amountToBePaid
* @return mixed|false
*/
private function verify(string $impUid, int $amountToBePaid)
{
$accessToken = $this->getAccessToken();
$data = $this->getPayment($accessToken, $impUid);
if ($amountToBePaid !== $data->amount) {
return false;
}
return $data;
}
}
이제 IamportController::webhook()
에서 실제로 결제를 처리해보자. 코드가 제법 길지만 차근차근 살펴보면 별거 아니다.
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Payment;
use App\Traits\Iamport;
use Illuminate\Http\Request;
class IamportController extends Controller
{
use Iamport;
/**
* Iamport Webhook
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function webhook(Request $request)
{
$request->validate([
'merchant_uid' => 'required',
'imp_uid' => 'required'
]);
$payment = Payment::findOrFail($request->merchant_uid);
if ($data = $this->verify($request->imp_uid, $payment->amount)) {
$payment->update([
'imp_uid' => $request->imp_uid,
'amount' => $data->amount,
'cancel_amount' => $data->cancel_amount,
'pay_method' => $data->pay_method,
'status' => $data->status
]);
switch ($data->status) {
// 가상계좌가 발급되었을 때
// case 'ready':
// 결제가 승인되었을 때, 예약결제가 시도되었을 때, 가상계좌에 결제 금액이 입금되었을 때
case 'paid':
return $this->paid($payment);
// 예약결제가 시도되었을 때
case 'failed':
return $this->failed();
// 관리자 콘솔에서 환불되었을 때
case 'cancelled':
return $this->cancelled($payment, $data);
}
}
return response()->json([
'status' => 'forgery', 'message' => __('iamport.forgery')
], 400);
}
/**
* Verify
*
* @param string $impUid
* @param int $amountToBePaid
* @return mixed|false
*/
private function verify(string $impUid, int $amountToBePaid)
{
//
}
/**
* Paid
*
* @param Payment $payment
* @return \Illuminate\Http\JsonResponse
*/
private function paid(Payment $payment)
{
$payment->orders()->saveMany(Order::unpaid()->get());
return response()->json([
'status' => 'paid', 'message' => __('iamport.paid')
]);
}
/**
* Cancelled
*
* @param Payment $payment
* @return \Illuminate\Http\JsonResponse
*/
private function cancelled(Payment $payment, $data)
{
// 관리자 콘솔에서 부분환불 되었을 때
if ($payment->amount > $data->cancel_amount) {
return response()->json([
'status' => 'uncancelled', 'message' => __('iamport.uncancelled')
]);
}
$payment->orders()->delete();
return response()->json([
'status' => 'cancelled', 'message' => __('iamport.cancelled')
]);
}
/**
* Failed
*
* @return \Illuminate\Http\JsonResponse
*/
private function failed()
{
return response()->json([
'status' => 'failed', 'message' => __('iamport.failed')
]);
}
}
먼저 검증을 처리하기 위해 IamportController::verify()
를 호출한다. 서버에서 결제가 되어야하는 금액과 실제금액을 비교해야하는데, 실제 결제된 금액은 아임포트 API 로 얻어왔으니 서버에서 결제가 되길 바라는 금액만 얻어오면 된다. 결제를 하기 전에 미리 저장해둔 것이 있으니 얻어오면 된다.
결제가 잘 처리되었으면 status
에 결제 상태가 기재되는데, ready, paid
등 여러 상태가 존재한다. 아임포트 가이드북에 따르면 다음과 같다.
- 결제가 승인되었을 때(모든 결제 수단) - (status : paid)
- 가상계좌가 발급되었을 때 - (status : ready)
- 가상계좌에 결제 금액이 입금되었을 때 - (status : paid)
- 예약결제가 시도되었을 때 - (status : paid or failed)
- 관리자 콘솔에서 환불되었을 때 - (status : cancelled)
이에 따라 switch
문을 구성할 수 있고, paid
가 결제가 승인되었을 때를 의미하므로 paid
상태에서 결제를 저장하고 주문에 대해서도 결제처리를 해주면 된다. 이 구현은 관리자 콘솔에서 환불되었을 때를 포함하여 구현한다. 사용자가 아임포트 서버에 요청하여 부분환불을 할 수 있게하려면 다른 구현이 필요하다.