아임포트(Iamport)로 결제기능 구현하기 - 준비
아임포트(Iamport)로 결제기능 구현하기 - 일반결제
이번에 국내 서비스용 결제와 관련된 기능을 구현해보면서 사용해본 것이 아임포트(I'mport)다. 라라벨에는 물론 결제를 위한 Laravel Cashier 라는 패키지가 존재하고, Stripe, Paddle 과 연동할 수 있으나 국내 서비스에는 그다지 친화적이지 않은 측면이 있다. 따라서 구축하기도 쉽고 국내 서비스에 친화적인 아임포트를 사용해보기로 했다. 또 다른 국내 친화적인 서비스로는 Payple 이 있는데, 나중에 기회가 되면 써보기로 하자.
아임포트는 개발자 친화적으로 문서화가 되어있어서 개발자의 입장에서 볼 때 결제를 구현하기 상당히 수월하게 되어있다. 대표적으로 일반결제, 정기결제, 결제환불 이렇게 세가지로 되어있다. 자세한 내용을 확인하려면 아래의 문서를 참고하자. 우리가 구현할 것은 일반결제 하나 뿐이다.
아임포트
아임포트와 같은 중개자를끼면 PG사의 모듈을 직접 연계했을 때와는 결제과정이 다소 바뀌게 되는데, 그림으로 한 눈에 알 수 있다.
중개자가 있기때문에 과정이 조금은 더 복잡해질 수 있지만, 사실 아임포트를 인터페이스(Interface)의 관점으로 접근해본다면, 다양한 PG사에 연동하고 싶은 경우에 각각이 다른 구현방식을 취할 필요가 없고 개발자의 입장에서는 단순 파라매터만 바꾸는 것으로 사용할 수 있는 것이다.
이미테이션
아임포트 내부에서 하는 일을 코드로 느낌만 간략하게 살펴보면 아래와 같다. 실제로 이렇지는 않을 것이다. 그저 아임포트가 각 PG사와 중개자 역할을 한다는점에 초점을 맞춘다.
먼저, 각 KGInicis, KakaoPay
등의 PG사를 각각 만들어주자. 여기서 Context
클래스에 들어갈만한 것들은 카드, 가상계좌 등의 결제방법, 주문가격 등이 프로퍼티로 존재할 수 있다.
class Context
{
// 결제방법, 주문가격 등...
}
abstract class Pg {
/**
* Request Payment
*
* @param Context $context
* @return bool
*/
public abstract function requestPay(Context $context);
}
/**
* KG이니시스
*/
class KGInicis extends Pg
{
public function requestPay(Context $context)
{
//
}
}
/**
* 카카오페이
*/
class KakaoPay extends Pg
{
public function requestPay(Context $context)
{
//
}
}
그 다음 Iamport
클래스를 하나 만들고 생성자에 사용하고자하는 PG사에 해당하는 클래스를 주입, PG사에서 구현 중인 Pg::requestPay()
를 호출, 고유주문번호를 생성해서 반환한다. 여기까지가 대략적으로 아임포트 내부에서 발생할 수 있는 일이다.
class Iamport
{
/**
* 고유주문번호
*
* @var string $impUid
*/
private $impUid;
/**
* PG
*
* @var Pg $pg
*/
private $pg;
/**
* Create Iamport Instance
*
* @param Pg $pg
*/
public function __construct(Pg $pg)
{
$this->pg = $pg;
}
/**
* Request Payment
*
* @param Context $context
* @return string
*/
public function requestPay(Context $context)
{
if (! $this->pg->requestPay($context)) {
return '';
}
// 아임포트 고유 주문번호 생성 및 저장...
return $this->impUid;
}
}
이렇게 작성된 모듈을 클라이언트에선 어떻게 해야할까? 그저 아임포트에서 어떤 PG사와 연동할지만 넘겨주고 요청만 보내면 그만이다. 실제로 이러한 사용법은 우리가 아임포트를 클라이언트의 입장에서 사용하는 방법과 아주 유사하다고 볼 수 있다.
$iamport = new Iamport(new KGInicis());
$context = new Context();
if ($impUid = $iamport->requestPay($context)) {
//
}
라라벨에서 아임포트 연동준비하기
라라벨 8.x 에서 실제 아임포트 모듈을 사용하여 실제를 만들어보자. 라라벨 프로젝트를 만드는 것부터 시작이다.
laravel new iamport-payments
설정
아임포트에 대한 설정을 추가해주자. 아임포트 관리자 콘솔 - 내정보에서 클라이언트 키(Key)와 시크릿 키를 확인할 수 있는데, .env, app/services.php 에 값을 설정해주자.
config/services.php
아래의 설정은 정해진 설정이 아니라 임의로 추가한 것이기 때문에 필요하다면 수정할 수 있다. 이 경우 merchant_id
는 가맹점 식별코드를 말하는데 아임포트 관리자 콘솔에서 볼 수 있다. 마찬가지로 client_id, client_secret
도 확인할 수 있다. pg
의 경우 사용할 PG사의 식별 이름을 적으면 되는데, Javascript SDK 에서 IMP.requestPay()
의 명세에서 pg
항목을 보면 된다.
'iamport' => [
'merchant_id' => env('IAMPORT_MERCHANT_ID'),
'pg' => env('IAMPORT_PG'),
'client_id' => env('IAMPORT_CLIENT_ID'),
'client_secret' => env('IAMPORT_CLIENT_SECRET')
],
.env
다음과 같이 지정하고, 빈칸에는 관리자 콘솔에서 값을 보고 넣어주자. IAMPORT_PG
에 설정된 html5_inicis
는 KG이니시스를 의미한다.
IAMPORT_MERCHANT_ID=
IAMPORT_PG=html5_inicis
IAMPORT_CLIENT_ID=
IAMPORT_CLIENT_SECRET=
마이그레이션
마이그레이션 테이블은 두 개를 만든다. 예를 들어 쇼핑몰에는 장바구니, 결제 기능이 있는데 장바구니와 결제를 위한 테이블인 orders, payments
테이블을 만들어보자.
payments
payments
테이블에는 결제정보가 들어가게 되며 주문가격, 결제상태 등의 포함된다.
php artisan make:migration create_payments_table --create=payments
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePaymentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('payments', function (Blueprint $table) {
$table->uuid('merchant_uid')->primary();
$table->string('imp_uid')->unique()->nullable();
$table->unsignedBigInteger('amount')->default(0);
$table->unsignedBigInteger('cancel_amount')->default(0);
$table->string('pay_method')->default('card');
$table->string('status')->default('unpaid');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('payments');
}
}
merchant_uid
는 앱 내부에서 관리되는 고유의 값이므로 기본키로 설정한다. 값은 auto_increment
보다는 UUID 로 지정되도록 하자. imp_uid
는 결제가 완료되면 아임포트에서 날려주는 결제고유번호이다. 그 외에 amount, pay_method, status
는 각 주문가격, 결제방법, 결제상태를 의미한다. cancel_amount
는 환불된 금액을 의미한다. 이 또한 정해져있는 스키마는 아니기 때문에 다른 컬럼을 추가해도 되고 필요하지 않다면 빼도된다.
orders
주문은 장바구니와 같은 기능을 하게 될 것이며 해당 테이블에 담긴 데이터는 사용자가 구입을 위해 장바구니에 담아놓은 상태를 말한다. 아직 주문이 되지 않은 상품이 있을 수 있으므로 주문에 대한 정보는 비워둘 수 있도록 한다.
php artisan make:migration create_orders_table --create=orders
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOrdersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->increments('id');
$table->unsignedBigInteger('amount');
$table->foreignUuid('merchant_uid')
->nullable()
->constrained('payments', 'merchant_uid');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('orders');
}
}
모델
모델 또한 장바구니를 위한 Order
, 결제를 위한 Payment
, 이렇게 두 개가 존재할 수 있다.
Payment
결제를 위한 모델인 Payment
를 만들어보자.
php artisan make:model Payment
namespace App\Models;
use App\Traits\Uuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasFactory, Uuids;
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'merchant_uid';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'merchant_uid',
'imp_uid',
'amount',
'cancel_amount',
'pay_method',
'status'
];
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function orders()
{
return $this->hasMany(Order::class, 'merchant_uid');
}
}
기본키를 merchant_uid
로 설정한 것과, 자동으로 증가하지 않도록 지정한 것에 주목하자. 또한 결제는 해당 결제에 해당하는 주문이 여러개 소속된다는 의미로 관계를 설정해주자. Uuids
라는 트레이트를 사용한 것을 볼 수 있는데, 기본키인 merchant_uid
는 자동으로 Uuid
가 지정되도록 할 것이다. 따라서 이에 해당하는 트레이트를 지정한 것이며 이는 내장이 아닌 직접 만든 것이다.
namespace App\Traits;
use Illuminate\Support\Str;
trait Uuids
{
/**
* Boot function from Laravel.
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->{$model->getKeyName()})) {
$model->{$model->getKeyName()} = Str::uuid()->toString();
}
});
}
/**
* Get the value indicating whether the IDs are incrementing.
*
* @return bool
*/
public function getIncrementing()
{
return false;
}
/**
* Get the auto-incrementing key type.
*
* @return string
*/
public function getKeyType()
{
return 'string';
}
}
static::creating()
으로 모델이 생성되었을 때 발생하는 이벤트를 리스닝하고 Model::$primaryKey
에 설정한 값에 Uuid
를 생성하여 지정한다. Model::getIncrementing()
으로 기본키가 자동으로 증가하는 키가 아님을 분명히 해줄 필요가 있다.
Order
장바구니를 위한 모델인 Order
를 하나 만들자.
php artisan make:model Order
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'amount'
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function payment()
{
return $this->belongsTo(Payment::class, 'merchant_uid');
}
/**
* Not paid
*
* @param Builder $query
*
* @return Builder
*/
public function scopeUnpaid(Builder $query)
{
return $query->whereNull('merchant_uid');
}
}
하나의 주문은 하나의 결제에 소속될 수 있다. 또한 아직 주문되지 않은 결제들을 얻어내기 위해 unpaid
스코프를 지정해주는 것도 생각해볼 수 있다.
라우팅과 컨트롤러
컨트롤러 또한 두 개를 만들어야한다. 주문의 경우 리소스 컨트롤러로 지정하자.
IamportController
php artisan make:controller IamportController
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class IamportController extends Controller
{
/**
* Iamport Webhook
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function webhook(Request $request)
{
//
}
}
IamportController
에는 결제를 위한 로직이 담기게 될 것이며, 또한 메서드의 이름이 왜 webhook
인지는 다음 포스트에서 알 수 있다.
Route::post('/iamport-webhook', [\App\Http\Controllers\IamportController::class, 'webhook']);
OrderController
php artisan make:controller OrderController --resource
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class OrderController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
}
Order
의 경우 사용자가 장바구니에 담은 목록을 표시하기 위한 index
, 상품을 장바구니에 담기위한 create, store
가 있다. 일반적인 쇼핑몰에서는 create
는 필요하지 않고 상품 상세페이지가 대체하게 될 것이다.
Route::resource('orders', \App\Http\Controllers\OrderController::class)
->only(['index', 'create', 'store']);