├── app ├── __init__.py ├── routers │ ├── __init__.py │ ├── products.py │ ├── orders.py │ └── webhook.py ├── config.py ├── main.py ├── models.py ├── db.py └── repositories.py ├── docs └── demo.gif ├── requirements.txt ├── .env.example ├── README.md ├── static └── index.html └── .gitignore /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulee-dev/PayApp-FastAPI-Demo/HEAD/docs/demo.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulee-dev/PayApp-FastAPI-Demo/HEAD/requirements.txt -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 사용자의 판매자(로그인) ID (PAYAPP 판매관리자 좌측 상단)을 입력합니다. 2 | PAYAPP_USERID= 3 | # PAYAPP 판매관리자 - 설정 - 연동정보에서 발급 받는 연동 KEY와 연동 VALUE를 입력합니다. 4 | PAYAPP_LINKKEY= 5 | PAYAPP_LINKVAL= 6 | SHOP_NAME= 7 | BASE_URL=http://localhost:8000 -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | PAYAPP_USERID = os.getenv("PAYAPP_USERID", "판매자 아이디") 7 | PAYAPP_LINKKEY = os.getenv("PAYAPP_LINKKEY", "연동 KEY") 8 | PAYAPP_LINKVAL = os.getenv("PAYAPP_LINKVAL", "연동 VALUE") 9 | SHOP_NAME = os.getenv("SHOP_NAME", "PAYAPP DEMO SHOP") 10 | BASE_URL = os.getenv("BASE_URL", "http://localhost:8000") 11 | DB_PATH = os.getenv("DB_PATH", "app.db") 12 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.staticfiles import StaticFiles 3 | from fastapi.responses import FileResponse 4 | from .db import connect, init_db 5 | from .routers import orders, webhook, products 6 | 7 | app = FastAPI() 8 | 9 | conn = connect() 10 | init_db(conn) 11 | 12 | # 라우터 등록 13 | app.include_router(orders.router) 14 | app.include_router(webhook.router) 15 | app.include_router(products.router) 16 | 17 | # 정적 파일/루트 18 | app.mount("/static", StaticFiles(directory="static"), name="static") 19 | 20 | 21 | @app.get("/") 22 | def root(): 23 | return FileResponse("static/index.html") 24 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from pydantic import BaseModel 3 | 4 | 5 | class PayState(IntEnum): 6 | REQUEST = 1 7 | COMPLETED = 4 8 | CANCEL_REQUEST = 8 9 | CANCEL_REQUEST_ALT = 32 10 | CANCEL_APPROVED = 9 11 | CANCEL_APPROVED_ALT = 64 12 | PENDING = 10 13 | PARTIAL_CANCEL = 70 14 | PARTIAL_CANCEL_ALT = 71 15 | 16 | 17 | class CreateOrderIn(BaseModel): 18 | product_id: int 19 | 20 | 21 | class CreateOrderOut(BaseModel): 22 | order_id: str 23 | product_name: str 24 | amount: int 25 | userid: str 26 | shopname: str 27 | feedbackurl: str 28 | returnurl: str 29 | -------------------------------------------------------------------------------- /app/db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from .config import DB_PATH 3 | 4 | 5 | def connect(): 6 | conn = sqlite3.connect(DB_PATH, check_same_thread=False) 7 | conn.row_factory = sqlite3.Row 8 | return conn 9 | 10 | 11 | def init_db(conn: sqlite3.Connection): 12 | cur = conn.cursor() 13 | cur.execute(""" 14 | CREATE TABLE IF NOT EXISTS products( 15 | id INTEGER PRIMARY KEY, 16 | name TEXT NOT NULL, 17 | price INTEGER NOT NULL 18 | ) 19 | """) 20 | cur.execute(""" 21 | CREATE TABLE IF NOT EXISTS orders( 22 | id TEXT PRIMARY KEY, 23 | product_id INTEGER NOT NULL, 24 | amount INTEGER NOT NULL, 25 | status TEXT NOT NULL, 26 | created_at INTEGER NOT NULL, 27 | FOREIGN KEY(product_id) REFERENCES products(id) 28 | ) 29 | """) 30 | cur.execute("SELECT COUNT(*) AS c FROM products") 31 | if cur.fetchone()["c"] == 0: 32 | cur.execute( 33 | "INSERT INTO products(id, name, price) VALUES(1,'샘플 티셔츠',15000)" 34 | ) 35 | cur.execute("INSERT INTO products(id, name, price) VALUES(2,'샘플 모자',9000)") 36 | conn.commit() 37 | -------------------------------------------------------------------------------- /app/routers/products.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from fastapi import APIRouter, Depends, HTTPException 3 | from pydantic import BaseModel 4 | import sqlite3 5 | from ..repositories import list_products, create_product 6 | 7 | router = APIRouter() 8 | 9 | 10 | def get_conn() -> sqlite3.Connection: 11 | from ..main import conn 12 | 13 | return conn 14 | 15 | 16 | class Product(BaseModel): 17 | id: int 18 | name: str 19 | price: int 20 | 21 | 22 | class CreateProductIn(BaseModel): 23 | name: str 24 | price: int 25 | 26 | 27 | @router.get("/products", response_model=List[Product]) 28 | def get_products(conn: sqlite3.Connection = Depends(get_conn)): 29 | rows = list_products(conn) 30 | return [Product(id=r["id"], name=r["name"], price=r["price"]) for r in rows] 31 | 32 | 33 | @router.post("/products", response_model=Product) 34 | def add_product(inp: CreateProductIn, conn: sqlite3.Connection = Depends(get_conn)): 35 | if inp.price < 0: 36 | raise HTTPException(400, "가격은 0 이상이어야 합니다.") 37 | pid = create_product(conn, inp.name, inp.price) 38 | return Product(id=pid, name=inp.name, price=inp.price) 39 | -------------------------------------------------------------------------------- /app/routers/orders.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Depends 2 | from fastapi.responses import PlainTextResponse 3 | from ..models import CreateOrderIn, CreateOrderOut 4 | from ..config import PAYAPP_USERID, SHOP_NAME, BASE_URL 5 | from ..repositories import get_product, create_order, get_order 6 | import sqlite3 7 | 8 | router = APIRouter() 9 | 10 | 11 | def get_conn() -> sqlite3.Connection: 12 | from ..main import conn # 전역 커넥션 재사용 13 | 14 | return conn 15 | 16 | 17 | @router.post("/orders", response_model=CreateOrderOut) 18 | def create_order_route( 19 | inp: CreateOrderIn, conn: sqlite3.Connection = Depends(get_conn) 20 | ): 21 | p = get_product(conn, inp.product_id) 22 | if not p: 23 | raise HTTPException(404, "상품이 존재하지 않습니다.") 24 | amount = int(p["price"]) 25 | order_id = create_order(conn, p["id"], amount) 26 | return CreateOrderOut( 27 | order_id=order_id, 28 | product_name=p["name"], 29 | amount=amount, 30 | userid=PAYAPP_USERID, 31 | shopname=SHOP_NAME, 32 | feedbackurl=f"{BASE_URL}/webhook", 33 | returnurl=f"{BASE_URL}/result?order_id={order_id}", 34 | ) 35 | 36 | 37 | @router.post("/result", response_class=PlainTextResponse) 38 | def result(order_id: str, conn: sqlite3.Connection = Depends(get_conn)): 39 | row = get_order(conn, order_id) 40 | if not row: 41 | return PlainTextResponse("주문을 찾을 수 없습니다.", status_code=404) 42 | return f"[{row['id']}] 현재 상태: {row['status']} / 금액: {row['amount']}원" 43 | -------------------------------------------------------------------------------- /app/repositories.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import sqlite3 4 | 5 | 6 | def get_product(conn: sqlite3.Connection, product_id: int): 7 | cur = conn.cursor() 8 | cur.execute("SELECT id, name, price FROM products WHERE id=?", (product_id,)) 9 | return cur.fetchone() 10 | 11 | 12 | def create_order(conn: sqlite3.Connection, product_id: int, amount: int) -> str: 13 | order_id = "ORD_" + uuid.uuid4().hex[:18].upper() 14 | cur = conn.cursor() 15 | cur.execute( 16 | "INSERT INTO orders(id, product_id, amount, status, created_at) VALUES(?,?,?,?,?)", 17 | (order_id, product_id, amount, "결제대기", int(time.time())), 18 | ) 19 | conn.commit() 20 | return order_id 21 | 22 | 23 | def get_order(conn: sqlite3.Connection, order_id: str): 24 | cur = conn.cursor() 25 | cur.execute("SELECT id, amount, status FROM orders WHERE id=?", (order_id,)) 26 | return cur.fetchone() 27 | 28 | 29 | def update_order_status(conn: sqlite3.Connection, order_id: str, status: str): 30 | cur = conn.cursor() 31 | cur.execute("UPDATE orders SET status=? WHERE id=?", (status, order_id)) 32 | conn.commit() 33 | 34 | 35 | def list_products(conn: sqlite3.Connection): 36 | cur = conn.cursor() 37 | cur.execute("SELECT id, name, price FROM products ORDER BY id ASC") 38 | return cur.fetchall() 39 | 40 | 41 | def create_product(conn: sqlite3.Connection, name: str, price: int) -> int: 42 | cur = conn.cursor() 43 | cur.execute("INSERT INTO products(name, price) VALUES(?,?)", (name, price)) 44 | conn.commit() 45 | return cur.lastrowid 46 | -------------------------------------------------------------------------------- /app/routers/webhook.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | from fastapi import APIRouter, Form, Depends 3 | from fastapi.responses import PlainTextResponse 4 | from ..models import PayState 5 | from ..config import PAYAPP_USERID, PAYAPP_LINKKEY, PAYAPP_LINKVAL 6 | from ..repositories import get_order, update_order_status 7 | import sqlite3 8 | 9 | router = APIRouter() 10 | 11 | 12 | def get_conn() -> sqlite3.Connection: 13 | from ..main import conn 14 | 15 | return conn 16 | 17 | 18 | def verify_seller(userid: str, linkkey: str, linkval: str) -> bool: 19 | return ( 20 | userid == PAYAPP_USERID 21 | and linkkey == PAYAPP_LINKKEY 22 | and linkval == PAYAPP_LINKVAL 23 | ) 24 | 25 | 26 | @router.post("/webhook", response_class=PlainTextResponse) 27 | async def webhook( 28 | userid: Annotated[str, Form()], 29 | linkkey: Annotated[str, Form()], 30 | linkval: Annotated[str, Form()], 31 | price: Annotated[int, Form()], 32 | pay_state: Annotated[int, Form()], 33 | var1: Annotated[Optional[str], Form()] = None, 34 | conn: sqlite3.Connection = Depends(get_conn), 35 | ): 36 | try: 37 | if not verify_seller(userid, linkkey, linkval): 38 | print("[WEBHOOK] invalid seller") 39 | return "SUCCESS" 40 | 41 | order_id = var1 42 | if not order_id: 43 | print("[WEBHOOK] missing order_id(var1)") 44 | return "SUCCESS" 45 | 46 | order = get_order(conn, order_id) 47 | if not order: 48 | print(f"[WEBHOOK] not found order {order_id}") 49 | return "SUCCESS" 50 | 51 | if int(order["amount"]) != int(price): 52 | print(f"[WEBHOOK] amount mismatch: db={order['amount']} webhook={price}") 53 | return "SUCCESS" 54 | 55 | state = PayState(pay_state) 56 | if state == PayState.COMPLETED: 57 | update_order_status(conn, order_id, "결제완료") 58 | print(f"[WEBHOOK] 결제완료 처리: {order_id}") 59 | elif state in ( 60 | PayState.CANCEL_REQUEST, 61 | PayState.CANCEL_REQUEST_ALT, 62 | PayState.CANCEL_APPROVED, 63 | PayState.CANCEL_APPROVED_ALT, 64 | ): 65 | update_order_status(conn, order_id, "결제취소") 66 | print(f"[WEBHOOK] 결제취소 처리: {order_id}") 67 | elif state in (PayState.PARTIAL_CANCEL, PayState.PARTIAL_CANCEL_ALT): 68 | update_order_status(conn, order_id, "부분취소") 69 | print(f"[WEBHOOK] 부분취소 처리: {order_id}") 70 | elif state == PayState.PENDING: 71 | update_order_status(conn, order_id, "결제대기") 72 | print(f"[WEBHOOK] 결제대기 처리: {order_id}") 73 | else: 74 | print(f"[WEBHOOK] ignore state={state} for order={order_id}") 75 | 76 | return "SUCCESS" 77 | 78 | except Exception as e: 79 | print("[WEBHOOK][ERROR]", e) 80 | return "SUCCESS" 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PAYAPP FastAPI 데모 2 |  3 | 4 |  5 |  6 | 7 | 온라인 비즈니스를 만들기 위해 가장 중요한 요소 중 하나는 **결제 시스템**입니다. 8 | 하지만 대부분의 PG(Payment Gateway)사는 **사업자등록증이 없는 개인**의 결제를 받아주지 않습니다. 9 | 10 | 이 문제를 해결하기 위해 송금 스크래핑 방식도 고려해봤지만, 최근에 **개인 결제를 지원하는 [PAYAPP](https://www.payapp.kr/homepage/index.html)** 서비스를 찾게 되었습니다. 11 | 12 | * 가입 즉시 결제를 받을 수 있음 13 | * 정산 시 구비서류 제출 필요 14 | * **보증보험 가입 조건**: 건당 결제금액 50만원 이상 **또는** 월 매출 500만원 이상일 경우 15 | * **수수료**: 카드 4%, 계좌이체 2.3% 16 | → Toss Payments(카드 3.4%, 계좌 2.0%)보다 높지만, **가입비/관리비 0원 & 개인도 가입 가능**하다는 장점이 있습니다. 17 | 18 | 아쉬운 점은 **Toss Payments**처럼 개발자 문서가 친절하지 않다는 점입니다. 19 | 그래서 **FastAPI + SQLite + 바닐라 JS** 기반으로 PAYAPP 결제 시스템을 구현해보았습니다. 20 | 21 | 자세한 정보는 [PayApp 서비스 연동 매뉴얼](https://www.payapp.kr/dev_center/dev_center01.html)을 참고하세요. 22 | 23 | --- 24 | 25 | ## 기능 개요 26 | 27 | * **상품/주문 DB**: SQLite로 관리 28 | * **/orders API**: 서버가 상품 가격을 확정하고 주문 생성 29 | * **프론트엔드(index.html)**: 서버가 내려준 금액/주문번호를 그대로 PayApp SDK에 전달 30 | * **/webhook API**: PayApp이 결제 결과를 알려줌 → 서버에서 주문 상태 업데이트 31 | * **/result API**: 결제 결과를 조회 가능 32 | 33 | --- 34 | 35 | ## 프로젝트 구조 36 | 37 | ``` 38 | . 39 | ├─ .env.example # 환경 변수 예시 40 | ├─ static/ 41 | │ └─ index.html # 프론트엔드(바닐라 JS) 42 | └─ app/ 43 | ├─ __init__.py 44 | ├─ main.py # FastAPI 앱 엔트리포인트 45 | ├─ config.py # 환경변수 로딩 46 | ├─ db.py # DB 연결 및 초기화 47 | ├─ models.py # Pydantic 모델 & Enum 48 | ├─ repositories.py # DB CRUD 49 | └─ routers/ 50 | ├─ orders.py # 주문 생성/조회 API 51 | └─ webhook.py # PayApp Webhook API 52 | ``` 53 | 54 | --- 55 | 56 | ## 설정 57 | 58 | ### 1. PayApp 가입 및 연동 정보 확인 59 | 60 | [PayApp 가입하기](https://www.payapp.kr/homepage/index.html) 61 | 62 | * 판매관리자 → **설정 → 연동정보**에서 **연동 KEY / 연동 VALUE** 확인 63 | * `.env` 파일에 입력 64 | 65 | ### 2. 환경 변수 설정 66 | 67 | `.env.example` 파일을 참고하여 `.env` 파일을 생성하고 값을 채워주세요. 68 | 69 | ```bash 70 | PAYAPP_USERID=판매자아이디 71 | PAYAPP_LINKKEY=연동KEY 72 | PAYAPP_LINKVAL=연동VALUE 73 | SHOP_NAME=내쇼핑몰 74 | BASE_URL=http://localhost:8000 75 | ``` 76 | 77 | > 로컬에서 ngrok으로 외부 접근을 열 경우, `BASE_URL`을 ngrok 주소로 바꿔야 Webhook이 정상 수신됩니다. 78 | 79 | --- 80 | 81 | ## 설치 82 | 83 | ```bash 84 | pip install -r requirements.txt 85 | ``` 86 | 87 | --- 88 | 89 | ## 실행 90 | 91 | ```bash 92 | # 개발 서버 실행 93 | uvicorn app.main:app --reload 94 | ``` 95 | 96 | 혹은 FastAPI CLI를 사용할 수도 있습니다: 97 | 98 | ```bash 99 | fastapi dev app/main.py 100 | ``` 101 | 102 | ### ngrok을 통한 외부 노출 103 | 104 | ```bash 105 | ngrok http 8000 106 | ``` 107 | 108 | 생성된 ngrok 주소를 `.env`의 `BASE_URL`로 업데이트하세요. 109 | 110 | --- 111 | 112 | ## 테스트 방법 113 | 114 | 1. 브라우저에서 [http://localhost:8000](http://localhost:8000) 접속 115 | 2. 상품 선택 → `주문 만들기` 클릭 116 | 3. 서버가 가격을 확정하고 주문 생성 117 | 4. `결제하기` 버튼을 눌러 PayApp 결제 진행 118 | 5. 결제 완료 후 PayApp이 `/webhook`으로 결과를 전송 → 서버 DB 상태 업데이트 119 | 6. `/result?order_id=...` 경로에서 주문 상태 확인 가능 120 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |