온라인 결제를 받는 개발자라면 한 번쯤 PayPal을 통해 결제를 처리한 경험이 있을 것이다. 다만 대부분의 개발자들은 결제 후 거래 확인 방법이 명확하지 않아서, 인터넷에서 가져온 코드를 그냥 복사해 붙인 채로 운영하고 있다. 심지어 IPN 핸들러가 제대로 작동하지 않을 때 원인을 파악하지 못해 결제 검증이 누락되거나, 중복 결제 처리 같은 실수를 범하곤 한다. 이번에는 PayPal IPN이 정확히 뭔지, 왜 필요한지, 어떻게 구현하는지 실무 관점에서 완벽하게 정리해서 소개하겠다.

 

1단계: PayPal IPN 기초 이해하기
PayPal IPN(Instant Payment Notification)은 PayPal에서 결제 완료 후 당신의 서버에 즉시 알림을 보내주는 메커니즘이다. 사용자가 PayPal 결제 창에서 "승인하기" 버튼을 누르면, PayPal은 거래 정보(거래 ID, 금액, 구매자 이메일 등)를 POST 방식으로 당신이 설정한 "IPN 리스너 URL"로 전송한다. 당신의 서버는 이 데이터를 받아서 검증한 후, 고객의 상품 제공이나 구독 활성화 같은 업무 로직을 처리한다.

일반적인 결제 흐름은 다음과 같다:
1) 사용자가 결제 양식을 작성하고 PayPal로 리다이렉트
2) PayPal에서 거래 승인
3) PayPal이 당신의 IPN 리스너에 POST 데이터 전송
4) 당신의 서버가 PayPal에 다시 검증 요청("verify" 과정)
5) PayPal이 "VERIFIED" 또는 "INVALID" 응답
6) 당신의 로직이 거래 기록 저장, 이메일 발송 등 처리

 

2단계: PayPal 샌드박스 환경 설정

실제 돈을 다루기 전에 PayPal 개발자 계정에서 테스트 환경을 구성해야 한다.

1) PayPal 개발자 센터 접속
developer.paypal.com 에 로그인하고, "Accounts" 섹션으로 이동한다. 거기서 테스트용 판매자 계정(Merchant)과 구매자 계정(Personal)이 자동으로 생성되어 있다. 이 계정들을 이용해 실제 돈 없이 거래를 시뮬레이션할 수 있다.

2) IPN 웹훅 설정
"Accounts" 탭에서 테스트 판매자 계정을 선택하고, "IPN settings"로 이동한다. 거기에 당신의 서버에서 IPN을 수신할 URL을 등록한다. 예를 들어 https://your-domain.com/ipn-handler.php 형태다. 로컬 개발 환경에서 테스트할 때는 ngrok 같은 터널링 도구를 사용해서 임시 공개 URL을 만들 수 있다.

 

3단계: IPN 핸들러 구현하기

✗ 잘못된 방식 - PayPal 검증 없이 바로 처리

<?php
// IPN 데이터를 받아서 바로 데이터베이스에 저장 (위험!)
$txn_id = $_POST['txn_id'];
$mc_gross = $_POST['mc_gross'];
$receiver_email = $_POST['receiver_email'];

// PayPal 검증 없이 바로 주문 처리
$mysqli = new mysqli('localhost', 'user', 'pass', 'shop');
$mysqli->query("INSERT INTO orders (txn_id, amount, status) VALUES ('$txn_id', $mc_gross, 'paid')");
echo "Order processed";
?>

이 방식은 매우 위험하다. 누구든지 위조된 POST 요청을 당신의 IPN 핸들러로 보내서 거짓 주문을 생성할 수 있기 때문이다. PayPal은 반드시 검증해야 한다.

✓ 올바른 방식 - PayPal 검증 후 처리

<?php
session_start();
error_reporting(E_ALL);
ini_set('display_errors', 0);

// IPN 로그를 남기는 것이 문제 추적에 도움이 됨
$ipn_log_file = '/var/log/paypal_ipn.log';

// 1단계: POST 데이터 수집
$raw_post = file_get_contents('php://input');
$post_data = $_POST;

file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] RAW POST: " . $raw_post . PHP_EOL, FILE_APPEND);

// 2단계: PayPal 검증 요청 준비
// 반드시 cmd=_notify-validate를 추가해서 PayPal에 검증 요청
$verify_data = 'cmd=_notify-validate';
foreach ($post_data as $key => $value) {
    $verify_data .= '&' . urlencode($key) . '=' . urlencode($value);
}

// 3단계: PayPal 검증 서버로 요청
// 샌드박스: https://www.sandbox.paypal.com/cgi-bin/webscr
// 라이브: https://www.paypal.com/cgi-bin/webscr
$paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';

$ch = curl_init($paypal_url);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $verify_data);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_FORBID_REUSE, 1);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_USERAGENT, 'PHP-IPN-Verify/1.0');

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] PAYPAL RESPONSE: " . $response . PHP_EOL, FILE_APPEND);

// 4단계: PayPal 응답 확인
if ($http_code != 200 || strpos($response, 'VERIFIED') === false) {
    file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] VERIFICATION FAILEDn", FILE_APPEND);
    http_response_code(403);
    exit('Verification failed');
}

// 5단계: 거래 정보 추출
$txn_id = isset($post_data['txn_id']) ? $post_data['txn_id'] : '';
$mc_gross = isset($post_data['mc_gross']) ? floatval($post_data['mc_gross']) : 0;
$receiver_email = isset($post_data['receiver_email']) ? $post_data['receiver_email'] : '';
$payment_status = isset($post_data['payment_status']) ? $post_data['payment_status'] : '';
$custom = isset($post_data['custom']) ? $post_data['custom'] : '';
$payer_email = isset($post_data['payer_email']) ? $post_data['payer_email'] : '';

file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] TXN_ID: $txn_id, STATUS: $payment_status, AMOUNT: $mc_grossn", FILE_APPEND);

// 6단계: 비즈니스 검증
// 당신의 PayPal 수신 계정 이메일과 비교
if ($receiver_email !== 'your-paypal-email@example.com') {
    file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] RECEIVER EMAIL MISMATCHn", FILE_APPEND);
    http_response_code(403);
    exit('Receiver email mismatch');
}

// 7단계: 중복 거래 체크
// 같은 txn_id가 이미 처리됐는지 확인
$mysqli = new mysqli('localhost', 'user', 'pass', 'shop');
$result = $mysqli->query("SELECT id FROM orders WHERE txn_id = '" . $mysqli->real_escape_string($txn_id) . "'");
if ($result->num_rows > 0) {
    file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] DUPLICATE TXN_IDn", FILE_APPEND);
    // 중복이지만 성공 응답을 반환 (PayPal이 재전송하지 않도록)
    http_response_code(200);
    exit('Duplicate');
}

// 8단계: 결제 상태에 따른 처리
if ($payment_status === 'Completed') {
    // 거래 기록 저장
    $stmt = $mysqli->prepare("INSERT INTO orders (txn_id, amount, payer_email, custom_field, status, created_at) VALUES (?, ?, ?, ?, 'paid', NOW())");
    $stmt->bind_param('sdss', $txn_id, $mc_gross, $payer_email, $custom);
    if ($stmt->execute()) {
        file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] ORDER RECORDED SUCCESSn", FILE_APPEND);
        // 여기서 이메일 발송, 상품 활성화 등의 로직 추가
    } else {
        file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] ORDER INSERT FAILED: " . $stmt->error . PHP_EOL, FILE_APPEND);
    }
    $stmt->close();
} elseif ($payment_status === 'Pending') {
    // 대기 상태 (예: 신용카드 승인 대기)
    $mysqli->query("INSERT INTO orders (txn_id, amount, payer_email, custom_field, status, created_at) VALUES ('" . $mysqli->real_escape_string($txn_id) . "', $mc_gross, '" . $mysqli->real_escape_string($payer_email) . "', '" . $mysqli->real_escape_string($custom) . "', 'pending', NOW())");
} elseif ($payment_status === 'Failed' || $payment_status === 'Denied') {
    // 결제 실패
    file_put_contents($ipn_log_file, "[" . date('Y-m-d H:i:s') . "] PAYMENT FAILEDn", FILE_APPEND);
}

$mysqli->close();

// 9단계: PayPal에 응답 (200 OK 반환)
http_response_code(200);
exit('OK');
?>

이 코드의 핵심 포인트:

  • PayPal 검증 필수: 받은 데이터를 PayPal에 다시 보내서 검증 (cmd=_notify-validate 파라미터 중요)
  • 중복 처리 방지: txn_id로 같은 거래가 이미 저장됐는지 체크
  • 수신 계정 검증: receiver_email이 당신의 계정과 일치하는지 확인 (피싱 방지)
  • 로깅: 문제 추적을 위해 모든 IPN 수신 기록을 파일에 남김
  • payment_status 체크: "Completed" 이외의 상태도 처리

 

4단계: 결제 버튼과 IPN 연결

HTML 결제 양식에서 PayPal 버튼을 생성할 때, "notify_url" 파라미터로 당신의 IPN 핸들러를 등록해야 한다.

<form action="https://www.sandbox.paypal.com/cgi-bin/webscr" method="post">
  <input type="hidden" name="cmd" value="_xclick">
  <input type="hidden" name="business" value="your-paypal-email@example.com">
  <input type="hidden" name="item_name" value="Premium Subscription">
  <input type="hidden" name="item_number" value="PREM-001">
  <input type="hidden" name="amount" value="29.99">
  <input type="hidden" name="currency_code" value="USD">
  <!-- 중요: 결제 완료 후 리다이렉트 -->
  <input type="hidden" name="return" value="https://your-domain.com/payment-success.php">
  <input type="hidden" name="cancel_return" value="https://your-domain.com/payment-cancel.php">
  <!-- 중요: IPN 핸들러 URL -->
  <input type="hidden" name="notify_url" value="https://your-domain.com/ipn-handler.php">
  <!-- 주문 ID 등 당신의 데이터 -->
  <input type="hidden" name="custom" value="order_12345">
  <button type="submit">PayPal로 결제하기</button>
</form>

"custom" 파라미터는 당신의 주문 시스템과 PayPal의 거래를 연결하는 핵심이다. 거래 후 IPN에서 이 값을 받아서 어느 주문인지 파악할 수 있다.

 

5단계: 주의사항과 흔한 실수
실수 결과 해결방법
IPN 검증 생략 위조된 결제로 손실 반드시 cmd=_notify-validate로 PayPal 검증
중복 거래 미체크 같은 주문 여러 번 처리 txn_id로 중복 확인 후 처리
IPN 타임아웃 PayPal이 재전송 계속 시도 curl 타임아웃을 30초 이상 설정
에러 발생 시 HTTP 500 반환 PayPal이 거래 상태 불명확 검증 실패해도 200 OK만 반환, 로그에 기록
"Pending" 상태 무시 환불 후에도 상품 제공됨 "Completed"만 상품 제공, 나머지는 대기 처리
Receiver email 검증 생략 다른 계정으로 결제 위장 receiver_email과 당신의 계정 비교

 

6단계: 라이브 환경으로 전환하기

샌드박스에서 충분히 테스트한 후 라이브 환경으로 옮길 때는 다음 두 줄만 바꾸면 된다:

<?php
// 변경 전 (샌드박스)
$paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';

// 변경 후 (라이브)
$paypal_url = 'https://www.paypal.com/cgi-bin/webscr';
?>

HTML 결제 양식의 form action도 마찬가지:

<!-- 샌드박스 -->
<form action="https://www.sandbox.paypal.com/cgi-bin/webscr" method="post">

<!-- 라이브 -->
<form action="https://www.paypal.com/cgi-bin/webscr" method="post">

또한 라이브 PayPal 계정의 이메일로 IPN 리스너를 다시 등록해야 한다.

 

마무리: PayPal IPN으로 안전한 결제 처리

PayPal IPN은 웹 결제 시스템에서 거래를 신뢰할 수 있게 만드는 핵심 메커니즘이다. cmd=_notify-validate 검증, 중복 체크, receiver_email 비교, payment_status 구분 처리 이 네 가지가 모여야 완전한 결제 시스템이 된다는 점을 잊지 말자. 이 글의 "올바른 방식" 코드를 참고해 당신의 프로젝트에 적용하면, 위조된 거래나 중복 처리로 인한 손실을 완벽히 방지할 수 있을 것이다.