정상우
hELLO.
정상우
전체 방문자
347,834
오늘
919
어제
691
  • hELLO. (120)
    • 컴퓨터과학 (4)
      • 알고리즘 & 자료구조 (4)
    • 언어 & 프레임워크 (63)
      • Go (23)
      • PHP & Laravel (40)
    • 웹 (7)
    • 블록체인 (12)
      • 메인넷 (9)
      • 암호화폐 플랫폼 (3)
    • 포트폴리오 (10)
    • 칼럼 (19)
      • 에세이 (4)
      • 개발자스럽게 살기 (13)
      • 회고 (2)
    • 티스토리 (5)

블로그 메뉴

  • ⚡ 개발자 이력서
  • 🌟 깃허브
  • 💻 강의
  • ✨ 예제코드
  • ⭐ 브런치
  • 태그 클라우드
  • 방명록

공지사항

  • 2차 도메인을 설정했습니다 ✨

인기 글

  • [Laravel] 라라벨 프레임워크⋯
    2021.06.10
    [Laravel] 라라벨 프레임워크⋯
  • 'REST' 를 보다 'RESTful' 하게⋯
    2021.08.14
    'REST' 를 보다 'RESTful' 하게⋯
  • JWT(JSON Web Token)의 개념부⋯
    2021.07.29
    JWT(JSON Web Token)의 개념부⋯
  • 깃허브를 포트폴리오로 쓰려면⋯
    2021.12.25
    깃허브를 포트폴리오로 쓰려면⋯
  • 암호화폐 트레이딩 봇을 만들었⋯
    2021.05.12
    암호화폐 트레이딩 봇을 만들었⋯

태그

  • 개발
  • 개발 리뷰
  • 라라벨
  • 코딩테스트
  • Algorithm
  • 포트폴리오
  • go
  • 프로그래머스
  • php
  • 블록체인

최근 댓글

  • 공유해주셔서 감사합니다:)) 덕⋯
    wanderlust_sol
  • 감사합니다 ~~ :)
    정상우
  • 고맙습니다 :)
    정상우
  • 자료 받으면서 원래 댓글 잘 안⋯
    뷰스토리_
  • 다크모드 지원하는 스킨 찾고⋯
    PilTok

최근 글

  • 개발자와 엔지니어, 그 사이에서
    2022.05.10
    개발자와 엔지니어, 그 사이에서
  • 아임포트(Iamport)로 결제기능⋯
    2022.04.03
    아임포트(Iamport)로 결제기능⋯
  • 아임포트(Iamport)로 결제기능⋯
    2022.04.01
    아임포트(Iamport)로 결제기능⋯
  • [Laravel] 카페24 호스팅에 라⋯
    2022.03.29
    [Laravel] 카페24 호스팅에 라⋯
  • 2021년 회고―, 성찰
    2021.12.31
    2021년 회고―, 성찰

티스토리

hELLO · Designed By 정상우.
정상우

hELLO.

아임포트(Iamport)로 결제기능 구현하기 - 일반결제
웹

아임포트(Iamport)로 결제기능 구현하기 - 일반결제

2022. 4. 3. 10:45

아임포트(Iamport)로 결제기능 구현하기 - 준비

아임포트(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>

자 이제 다시 관리자 콘솔로가서 설정해보자.

관리자 콘솔 - 시스템설정 - 웹훅(Notification)설정

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 상태에서 결제를 저장하고 주문에 대해서도 결제처리를 해주면 된다. 이 구현은 관리자 콘솔에서 환불되었을 때를 포함하여 구현한다. 사용자가 아임포트 서버에 요청하여 부분환불을 할 수 있게하려면 다른 구현이 필요하다.

    '웹' 카테고리의 다른 글
    • 아임포트(Iamport)로 결제기능 구현하기 - 준비
    • 'REST' 를 보다 'RESTful' 하게 API 만들기
    • 쿠키(Cookie)와 세션(Sessions)에 대해 알아보자
    • OAuth 2.0 클라이언트 만들기(feat. 깃허브)
    결제, 라라벨, 아임포트
    정상우
    정상우
    과거의 배움으로 현재를 바꾸고 미래를 만듭니다. #25+2살 #INFJ #개발자 #브런치작가
    댓글쓰기
    다음 글
    개발자와 엔지니어, 그 사이에서
    이전 글
    아임포트(Iamport)로 결제기능 구현하기 - 준비
    • 이전
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • ···
    • 120
    • 다음

    티스토리툴바