들어가며
사이드 프로젝트에 해외 결제를 붙이고 싶다는 생각, 한 번쯤 해보셨을 겁니다.
그런데 막상 시작하려고 하면 벽이 느껴집니다. “결제 서버를 따로 운영해야 한다”, “보안이 복잡하다”, “사업자가 있어야 한다”… 이런 이야기들이 먼저 떠오르죠.
실제로는 그렇지 않았습니다.
저는 최근 PayPal 결제 연동을 PoC(Proof of Concept)로 진행했는데, 서버 한 대 없이 Cloudflare 무료 플랜만으로 결제 버튼이 동작하는 페이지를 만들 수 있었습니다. 프론트엔드와 백엔드를 하나의 프로젝트 안에서 관리하고, 배포까지 무료로 끝냈습니다.
이 글에서는 그 과정을 처음부터 끝까지 공유합니다. 특히 공식 문서에는 나오지 않는 한국 개발자만 겪는 문제와 삽질 경험도 함께 담았습니다.

기술 스택
| 영역 | 기술 | 선택 이유 |
|---|---|---|
| 프론트엔드 | Vite + React | 빠른 개발 환경, HMR |
| 결제 SDK | @paypal/react-paypal-js | PayPal 공식 React 바인딩 |
| 백엔드 | Cloudflare Pages Functions | 서버리스, 무료, 프론트와 동일 프로젝트 |
| 배포 | Cloudflare Pages | 무료 HTTPS, 자동 배포 |
이 글에서 다루는 범위
- PayPal Sandbox 환경에서의 결제 PoC
- 프론트엔드 결제 버튼 → 백엔드 주문 생성/캡처 → 결과 확인의 전체 흐름
- 로컬 개발 환경 구성 및 Cloudflare 배포
- 실전 트러블슈팅 3건
프로덕션 수준의 보안(Webhook 검증, CSRF 방어 등)은 이 글의 범위를 벗어나며, 후속 글에서 다룰 예정입니다.
아키텍처 개요
전체 결제 흐름은 다음과 같습니다.
핵심은 프론트엔드가 직접 PayPal API를 호출하지 않는다는 것입니다. Client Secret이 노출되면 안 되기 때문에, 백엔드(Pages Functions)가 PayPal API와 통신하고 프론트엔드에는 Order ID만 전달합니다.
프로젝트 구조
paypal-poc/
├── functions/ ← 백엔드 (Cloudflare Pages Functions)
│ └── api/
│ ├── create-order.js ← POST /api/create-order
│ └── capture-order.js ← POST /api/capture-order
├── src/ ← 프론트엔드 (React)
│ ├── App.jsx ← PayPal 결제 버튼 컴포넌트
│ ├── App.css
│ └── index.css
├── .env.local ← 프론트엔드 환경변수 (Client ID)
├── .dev.vars ← 백엔드 환경변수 (Client ID + Secret)
└── vite.config.js ← /api/* 프록시 설정 포함
functions/ 디렉토리에 파일을 넣으면 Cloudflare가 자동으로 API 엔드포인트로 인식합니다. functions/api/create-order.js는 곧 POST /api/create-order가 됩니다. 별도의 라우팅 설정이 필요 없습니다.

백엔드 — Cloudflare Pages Functions
Cloudflare Pages Functions는 Edge Runtime에서 동작합니다. Express.js나 axios 같은 Node.js 전용 모듈은 사용할 수 없고, 브라우저와 동일한 표준 fetch API로 외부 API를 호출해야 합니다.
주문 생성 (create-order.js)
이 함수는 두 가지 일을 합니다.
- PayPal OAuth 2.0 Access Token 발급 — Client ID와 Secret으로
client_credentials방식의 토큰을 발급받습니다. - Order 생성 — 발급받은 토큰으로 PayPal REST API v2에 주문을 생성하고, 생성된 Order ID를 프론트엔드에 반환합니다.
Edge Runtime에서도 btoa() 함수가 지원되므로, Basic Auth 인코딩에 별도 라이브러리가 필요 없습니다. 환경변수는 context.env 객체에서 접근합니다.
결제 캡처 (capture-order.js)
사용자가 PayPal 팝업에서 결제를 승인하면, 프론트엔드가 Order ID를 이 함수에 전달합니다. 이 함수는 다시 Access Token을 발급받은 후 해당 Order를 캡처(결제 확정)합니다.
여기서 중요한 점은 Access Token을 매 요청마다 새로 발급받는다는 것입니다. PoC에서는 이 방식이 가장 단순하지만, 프로덕션에서는 토큰 캐싱을 고려할 수 있습니다.


프론트엔드 — React + PayPal JS SDK
PayPal은 공식 React 라이브러리 @paypal/react-paypal-js를 제공합니다. 이 라이브러리는 두 가지 핵심 컴포넌트로 구성됩니다.
PayPalScriptProvider— PayPal JS SDK 스크립트를 로드하는 래퍼. Client ID, 통화, intent 등을 설정합니다.PayPalButtons— 실제 결제 버튼을 렌더링합니다.createOrder와onApprove콜백을 통해 결제 흐름을 제어합니다.
결제 흐름
createOrder콜백 → 백엔드/api/create-order를 호출해 Order ID를 받아옵니다- PayPal SDK가 팝업을 띄워 사용자 로그인/승인을 진행합니다
onApprove콜백 → 백엔드/api/capture-order를 호출해 결제를 확정합니다- 결과를 화면에 표시합니다
추가로, SDK 로딩 상태를 usePayPalScriptReducer 훅으로 감지하여 로딩 중/로드 실패/성공 상태를 화면에 표시하도록 구현했습니다. 이 부분은 뒤의 트러블슈팅 섹션에서 자세히 다룹니다.

환경 설정 — 로컬 & 배포
PayPal Sandbox 앱 생성
PayPal Developer Dashboard에서 Sandbox 앱을 생성하면 Client ID와 Secret을 발급받을 수 있습니다.
로컬 개발 환경
환경변수는 프론트엔드용과 백엔드용 두 곳에 각각 설정해야 합니다.
| 파일 | 용도 | 접근 방법 | 포함할 값 |
|---|---|---|---|
.env.local | 프론트엔드 | import.meta.env.VITE_* | Client ID만 |
.dev.vars | 백엔드 (wrangler) | context.env.* | Client ID + Secret + Mode |
Vite는 VITE_ 접두사가 붙은 환경변수만 클라이언트 번들에 노출합니다. Secret은 절대 프론트엔드에 넣으면 안 되며, .dev.vars에만 설정합니다.
주의: 값 앞뒤에 따옴표를 넣지 마세요.
PAYPAL_CLIENT_ID=AeXXX...처럼 그대로 넣어야 합니다.
로컬 실행
npm run dev:api
이 한 줄로 Vite 프론트엔드와 Pages Functions 백엔드가 동시에 실행됩니다. wrangler pages dev가 Vite를 자식 프로세스로 실행하며, http://localhost:8788에서 모든 것이 동작합니다.
Cloudflare 배포 환경
Cloudflare에 배포할 때는 대시보드에서 환경변수를 설정합니다. Secret은 반드시 Encrypt 옵션을 켜서 저장하세요.
배포 자체는 한 줄입니다.
npm run build
npx wrangler pages deploy ./dist
트러블슈팅 — 실제로 겪은 문제들
이 섹션이 이 글의 핵심입니다. 공식 문서에는 나오지 않는, 직접 부딪혀봐야 알 수 있는 문제들을 정리합니다.
1. 결제 버튼이 렌더링되지 않는 문제
서버를 실행하고 페이지를 열었는데, 결제 버튼이 보이지 않았습니다. 에러 메시지도 없이 빈 화면만 나왔습니다.
원인: .env.local에 VITE_PAYPAL_CLIENT_ID를 설정하지 않으면 PayPal JS SDK가 로드에 실패합니다. 문제는 SDK가 실패해도 아무런 에러를 표시하지 않는다는 것입니다.
해결: usePayPalScriptReducer 훅으로 SDK의 로딩 상태(isPending, isRejected, isResolved)를 감지하고, 각 상태에 맞는 메시지를 화면에 표시하도록 개선했습니다. Client ID가 비어있으면 설정 방법을 안내하는 메시지도 추가했습니다.
교훈: PayPal SDK는 로드 실패를 조용히 삼킵니다. 반드시 로딩 상태를 명시적으로 관리하세요.

2. 한국 PayPal 계정 간 결제 불가
결제 버튼을 누르고 PayPal 팝업에서 로그인까지 마쳤는데, 빨간 에러 배너가 떴습니다.
“죄송합니다. 한국에 등록된 PayPal 계정 간에는 결제 대금을 보내거나 받는 것이 허용되지 않습니다.”
원인: PayPal은 한국 계정끼리의 거래를 허용하지 않습니다. Sandbox 테스트 계정의 판매자(Business)와 구매자(Personal) 계정이 모두 한국으로 설정되어 있어서 발생한 문제였습니다.
해결: PayPal Developer Dashboard → Sandbox → Accounts에서 국가를 United States로 설정한 Personal(Buyer) 테스트 계정을 새로 생성했습니다. 이 계정으로 결제 팝업에 로그인하면 정상적으로 결제가 진행됩니다.
교훈: 한국 PayPal 계정은 해외 결제 수신과 해외 구매만 가능합니다. Sandbox 테스트 시 구매자 계정의 국가를 한국 외 국가로 설정해야 합니다. 실제 서비스에서는 해외 구매자가 결제하는 구조이므로 이 제한은 Sandbox 테스트에서만 문제가 됩니다.
3. Headless Linux 서버에서 외부 브라우저 접속 불가
GUI가 없는 Linux 서버에서 개발 서버를 실행하고, 외부 PC 브라우저에서 접속하려 했으나 연결이 거부되었습니다.
원인: Vite와 wrangler 모두 기본적으로 localhost(127.0.0.1)에만 바인딩합니다. 같은 머신이 아니면 접속할 수 없습니다.
해결: Vite 설정에 host: "0.0.0.0"을 추가하고, wrangler 실행 시 --ip 0.0.0.0 옵션을 넣었습니다. 이후 http://<서버IP>:8788로 접속하면 됩니다.
교훈: GUI 없는 서버에서 웹 개발을 할 때는 항상 바인딩 주소를 0.0.0.0으로 설정하고, 방화벽에서 해당 포트를 열어야 합니다.
Cloudflare 무료 플랜으로 충분한가?
결론부터 말하면, PoC는 물론이고 소규모 서비스까지 무료 플랜으로 충분합니다.
| 항목 | Free Plan 제한 | PayPal PoC 영향 |
|---|---|---|
| Functions 요청 수 | 100,000 req/day | ✅ 충분 |
| Functions CPU 시간 | 10ms/req | ✅ 문제없음 (아래 설명) |
| Functions 메모리 | 128 MB | ✅ 충분 |
| 빌드 | 500 builds/month | ✅ 충분 |
| 대역폭 | 무제한 | ✅ 제한 없음 |
| 커스텀 도메인 | 무제한 | ✅ 가능 |
가장 걱정되는 부분은 10ms CPU 시간 제한일 텐데, 이것은 “CPU가 실제로 일하는 시간”만 측정합니다. fetch()로 PayPal API를 호출하고 응답을 기다리는 네트워크 I/O 시간은 CPU 시간에 포함되지 않습니다. 실제로 PayPal API 호출이 수백ms가 걸려도 CPU 시간은 1~2ms 수준입니다.
유료 플랜이 필요해지는 시나리오는 KV 스토리지로 토큰 캐싱을 하거나, D1 데이터베이스로 결제 내역을 관리하거나, 일 10만 건을 넘는 트래픽이 발생하는 경우입니다. 개인 프로젝트 수준에서는 사실상 도달하기 어려운 수치입니다.
마무리
이 글에서 다룬 내용을 정리합니다.
| 파일 | 역할 |
|---|---|
functions/api/create-order.js | PayPal 주문 생성 (OAuth + REST API v2) |
functions/api/capture-order.js | PayPal 결제 캡처 |
src/App.jsx | PayPal 결제 버튼 + SDK 상태 관리 + 결과 표시 |
.env.local | 프론트엔드 Client ID |
.dev.vars | 백엔드 Client ID + Secret |
프로덕션 적용 시 추가 고려사항
이 글은 PoC 수준의 구현입니다. 실제 서비스에 적용할 때는 다음 사항을 반드시 추가해야 합니다.
- Webhook 결제 검증 — 클라이언트 응답만으로는 결제 완료를 신뢰할 수 없습니다. PayPal Webhook을 통해 서버-서버 간 결제 완료를 확인해야 합니다.
- 결제 금액 서버사이드 검증 — 프론트엔드에서 전달하는 금액을 그대로 신뢰하면 안 됩니다. 서버에서 상품 정보를 기반으로 금액을 재계산해야 합니다.
- 에러 재시도 & 멱등성 — 네트워크 오류로 Capture 요청이 중복 실행되는 경우를 대비해 PayPal의 Idempotency Key를 사용해야 합니다.
- CSRF 보호 — API 엔드포인트에 CSRF 토큰 검증을 추가해야 합니다.
이 주제들은 후속 글에서 다룰 예정입니다. 개인 개발자가 결제 연동이라는 벽 앞에서 멈추지 않기를 바라며 이 글을 마칩니다. 서버도, 비용도, 회사도 필요 없습니다. 시작해 보세요.