요기요 자동 주문 구현 정리
yogiyo-cash-backend 의 1초결제 자동 주문 시스템 — 토큰 영속 + Playwright 백그라운드 갱신 + 1초결제 / PG follow 흐름.
현재 동작
- 사용자 앱 → 백엔드
POST /orders호출 → 백엔드가 1초결제(YGYPAY) 자동 결제 완료까지 수행 - 토큰 자동 영속 + JWT exp 검증 — 서버 재시작 후에도 토큰 살아있음
- Playwright 가 daemon thread 로 매 30분 yogiyo 웹 자동 로그인 → 토큰 갱신 → session.json 저장
- 1초결제 실패 시 (purchase_id 누락) 자동으로 PG chain follow → 결제 완료
- "테스트 주문" 모드 — submit 직후 cancel-order 자동 호출 (실제 결제 발생 후 즉시 환불)
시스템 아키텍처
토큰 우선순위 + JWT 검증
YogiyoZeroPayClient.refresh_access_token() 는 다음 순서로 유효 토큰 탐색:
| 순위 | 위치 | 특징 |
|---|---|---|
| 1 | 메모리 self._access_token | 같은 인스턴스 재호출 시 즉시 사용 |
| 2 | Django cache yogiyo:access_token | locmem — 같은 프로세스 lifecycle 동안 유지 |
| 3 | ~/.config/yogiyo-replay/session.json | 파일 영속 — 서버 재시작 후에도 복원 |
| 4 | settings.YGY_ACCESS_TOKEN (.env) | 부트스트랩 — 첫 토큰 (수동 입력) |
| fallback | POST authyo/auth/refresh | 위 모두 만료 시 → 새 토큰 발급 |
JWT exp 검증
def _is_jwt_valid(token: str, margin_sec: int = 60) -> bool:
"""JWT exp claim 검증. margin_sec 이내 만료 예정이면 무효."""
parts = token.split('.')
payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
exp = payload.get('exp')
return (time.time() + margin_sec) < int(exp)
모든 토큰 사용 전 호출. 만료 60초 전부터 무효 처리 (호출 중 만료 방지).
session.json 영속
위치: ~/.config/yogiyo-replay/session.json
{
"customer_id": "708686411",
"access_token": "<RS256, 매 갱신 시 새로>",
"refresh_token": "<HS512, rotation 후 새로>"
}
매번 refresh 응답 받을 때 자동 저장 (_save_session). 서버 재시작 → cache 비움 → session.json 에서 로드 → 유효 토큰 확보.
Playwright 백그라운드 자동 갱신
yogiyo 의 refresh_token rotation 정책으로 백엔드 단독 갱신이 어려운 문제를 우회:
- headless Chromium 시작 (
browser-state.jsoncookies 로드) www.yogiyo.co.kr/mobile/진입- 로그인된 상태면 yogiyo JS 가 자동 refresh 호출 — 응답 가로채기
- 로그인 안 됨 →
.env의YGY_LOGIN_EMAIL/PASSWORD자동 입력 → 로그인 - 토큰 응답에서
sub_id검증 — 운영 계정 (YGY_CUSTOMER_ID) 과 일치할 때만 저장 session.json+browser-state.json영속
Daemon thread 시작 — orders/apps.py
def ready(self):
if not getattr(settings, 'YOGIYO_TOKEN_REFRESHER_ENABLED', True):
return
_start_token_refresher() # threading.Thread, daemon=True
def loop():
while True:
refresh_tokens_via_browser(manual=False, timeout_sec=45)
time.sleep(INTERVAL) # 기본 1800초 (30분)
sub_id 검증 — 게스트 토큰 필터링
yogiyo 가 페이지 진입 시 자동 발급하는 게스트 토큰 (sub_id 다름, user_id 키 누락) 을 운영 토큰과 구별:
def on_response(response):
data = response.json()
for tok in (data.get('access_token'), data.get('refresh_token')):
if tok:
sub_id = _jwt_sub_id(tok)
if sub_id != settings.YGY_CUSTOMER_ID:
logger.info('게스트 토큰 무시 (sub_id=%s)', sub_id)
return
# 운영 계정 토큰만 captured 에 저장
Step 1 — refresh (토큰 갱신)
입력: session.json 또는 .env 의 refresh_token. Bearer 헤더에 (만료된) access_token 또는 refresh_token 사용 — 형식만 검증.
응답: 새 access_token (RS256, 2시간) + 새 refresh_token (HS512, 60일) → cache + session.json 저장
Step 2 — customer-info
응답 키: customer_id, user_id, email, phone, nickname, socials, agreement
customer_id (NOT id). submit body 의 customer.id 는 이 값을 매핑.Step 3 — addresses (location_token 발급)
핵심 발견 (캡처 분석): 이 endpoint 응답에 location_token 과 adiyo_address 의 source 가 모두 포함.
응답 키: address_id, point{lat,lng}, law{sido,sigugun,dongmyun,...}, road, admin, detail, location_token, selected_at
가장 최근 selected_at 의 주소가 활성 배송지. 이 주소의 point.lat/lng 가 cart/submit URL 의 lat/lng 와 정확히 일치해야 함 (frontend 의 라운드된 좌표 35.21 사용 시 거부).
Step 4 — vendor-info
api.yogiyo.co.kr/order/checkout/vendors/{id} 는 500 반환 — 캡처에 없는 endpoint. 캡처 검증된 frontyo 사용.응답: id, name, franchise (null 또는 {id, ...})
submit body 의 vendor 는 단순 {vendor_id, franchise_id} — franchise=None 이면 franchise_id: null 로 키 포함.
Step 5 — card-info (1초결제 카드 토큰)
payo/v2/payments/card-info 가 아님. 실제 모바일 앱 endpoint 는 www.yogiyo.co.kr/api/v1/ygypay/cards/.{
"cards": [{
"token": "<wpayToken, base64+padding>", // ygypay_info.token 으로 사용
"code": "06", // 카드사 코드 (payo_service=YGYPAY_06)
"name": "국민카드",
"number": "4579-73**-****-7048"
}],
"registered": true
}
토큰은 카드 1회 등록 + 1회 결제 후 영구 사용 (NOTES.md: "3회 결제에 동일").
Step 6 — create-cart
Body
{
"lat": 35.2085501,
"lng": 126.83933026,
"yotimedeal_offer_id": null,
"products": [{
"vendor_product_id": "1668541520",
"vendor_category_id": "19185660",
"quantity": 3,
"option_sections": []
}],
"subscription_type": null,
"coupon": null,
"referral": null
}
응답: customer_cart.uuid + customer_cart.signature + order_info.full_price.
Step 7 — read-cart
핵심: 응답의 order_info.full_price 가 submit body 의 payment.total 과 정확히 일치해야 함. frontend 계산한 order.total_price 가 아닌 cart 응답값 사용 — yogiyo 가 엄격 검증 (payment_price_not_match 400).
Step 8 — submit ⭐ (실제 결제)
Body 전체 (캡처 검증된 정확한 형식)
{
"customer_id": 708686411,
"customer": {
"id": 708686411,
"authyo-token": "<RS256 = access_token>", // ⚠ 키 이름에 대시 (-)
"location_token": "<HS256, lat/lng 인코딩>", // Step 3 addresses 응답
"lat": 35.2085501, "lng": 126.83933026,
"address": { street_name, road_name, alias_type, alias_text, street_number, display_address_long },
"address_detail": { sido, gugun, admin_dong, dong, bunji, road, building, ri },
"adiyo_address": { detail, law{...ri:''}, admin{...ri:''}, road{...ri:''}, point, location_token },
"isLogin": true,
"email": "...", "phone": "...",
"phone_uuid": "<= device_id>",
"advertisement_id": "<uuid4>",
"subscription_type": null,
"limit_ad_tracking": false,
"app_tracking_transparency": null
},
"delivery_info": {
email, street_number, phone,
"use_safety_number": true,
"extra_comments": [], // 리스트!
"comment_to_vendor": "",
"comment_to_rider": "문 앞에",
"is_cutlery_requested": false,
"is_contactless_delivery": true,
"expected_takeout_time": ""
},
"payment": {
"payo_service": "YGYPAY_06", // YGYPAY_{card_code}
"checkout_method_type": "YGYPAY",
"service": "ygypay",
"user_benefits": { subscription, point },
"total": 14100, // ⚠ cart_verify.order_info.full_price 와 일치
"cash_receipt_info": null,
"ygypay_info": { "token": "<Step 5 card_token>", installment_plan: 0 }
},
"agreements": { efinance_agreement: true, tos: true, ... },
"serving_type": "vd",
"cart_uuid": "<Step 6 uuid>-b24b", // snake_case (NOT uuid)
"cart_signature": "<Step 6 signature>", // snake_case
"vendor": { "vendor_id": 1525424, "franchise_id": null },
"configuration": { feature_configuration: { ...7개 boolean: true } },
"referral": "",
"build_type": ""
}
응답 — zero payment 성공 (purchase_id 포함)
{
"purchase_id": 147296710660,
"order_number": "F2605121430JXXX4B",
"ygy_order_id": 1459590930
}
응답 — 일반 결제 (purchase_id 누락 → Step 9 진행)
{
"payment_no": "20260512162715093276",
"order_number": "F2605121627JZE4B",
"payment_url": "https://payo.yogiyo.co.kr/v2/payments/{payment_no}/checkout"
}
purchase_id 있으면 결제 완료. 없으면 (일반 결제 흐름) Step 9 PG follow chain 자동 진행.Step 9 — PG follow chain (옵션)
submit 응답에 purchase_id 가 없을 때만 동작. YogiyoZeroPayClient.follow_pg_chain() 가 자동 처리:
payment_url (GET, payo /checkout)
↓ HTML 응답의 form A 자동 submit
wpay.inicis.com/ygypay/u/v2/payreqauth (POST)
↓ HTML 응답의 form B 자동 submit
wpay.inicis.com/ygypay/u/payreqauthAction (POST)
↓ HTTP redirect chain
orderyo.yogiyo.co.kr/callback/payment/result/
↓ HTML 응답에서 정규식 추출
{order_number, purchase_id, ygy_order_id}
매 step 에서 <form> 의 action / method / input 필드를 정규식으로 추출 → urlencoded POST. JS 실행 / device fingerprint 검증 없음.
Step 10 — cancel (옵션, "테스트 주문" 모드)
submit 직후 yogiyo 내부 commit 지연으로 404 발생 가능. 백엔드가 자동으로 5번 재시도 (0.5초 → 2.5초 간격).
for attempt in range(5):
try:
client.cancel_order(submit['order_number'])
break
except YogiyoZeroPayError as e:
if '404' in str(e):
time.sleep(0.5 + attempt * 0.5)
continue
raise
코드 파일 매핑
| 파일 | 역할 |
|---|---|
orders/services/yogiyo_pay_client.py | 1초결제 client. 토큰 영속 + JWT 검증 + 9개 endpoint + PG follow + JWT 검증 헬퍼. |
orders/services/yogiyo_browser_auth.py | Playwright 백그라운드 토큰 갱신. 자동 로그인 + sub_id 검증 + session.json 영속. |
orders/services/auto_order.py | 주문 오케스트레이션 — addresses 호출, customer/delivery_info 빌드, submit, PG follow, cancel. |
orders/management/commands/refresh_yogiyo_tokens.py | manage.py refresh_yogiyo_tokens [--manual] 명령. |
orders/apps.py | Django startup 시 daemon thread 시작 — 매 30분 토큰 갱신. |
orders/views.py | POST /orders + POST /orders/{id}/cancel/ action. |
orders/models.py | Order 모델 — yogiyo_order_number, yogiyo_purchase_id, auto_cancel, auto_cancelled_at 추가. |
orders/serializers.py | OrderCreateSerializer 에 auto_cancel 필드. |
restaurants/services/yogiyo_search.py | 매장 검색 (옵션) — discovery/search/all. |
.env 변수 가이드
# ─── 1초결제 자동 주문 (코어) ───
YOGIYO_AUTO_ORDER_ENABLED=True
YGY_CUSTOMER_ID=708686411
YGY_REFRESH_TOKEN=<부트스트랩용, 한 번만 유효한 토큰>
YGY_ACCESS_TOKEN=<선택, refresh 우회용>
YGY_DEVICE_ID=00000000-63f7-c1f9-0000-000000000000
YGY_PHONE_UUID=00000000-63f7-c1f9-0000-000000000000
YGY_CARD_CODE=06
YGY_CARD_TOKEN=<Step 5 응답의 cards[i].token>
# ─── Playwright 백그라운드 갱신 ───
YGY_LOGIN_EMAIL=<yogiyo 웹 로그인 이메일>
YGY_LOGIN_PASSWORD=<yogiyo 웹 로그인 패스워드>
YOGIYO_TOKEN_REFRESHER_ENABLED=True
YOGIYO_TOKEN_REFRESH_INTERVAL_SEC=1800 # 30분
진단 / 트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
refresh 실패 400 "expired or malformed token" | refresh_token 이 yogiyo 측에서 rotation/reuse 무효화 | Playwright 자동 갱신 활성화. session.json 삭제 + 재시작. |
submit 실패 500 (HTML) | body 형식 문제 (키 누락/타입 mismatch) | %TEMP%/yogiyo-last-submit-body.json 과 submit-body-msc.json diff 비교. |
payment_price_not_match 400 | payment.total 이 cart 의 실제 금액과 다름 | auto_order.py 에서 actual_total 사용 (cart_verify.order_info.full_price). |
vendor-info 500 | 잘못된 endpoint 사용 | frontyo/v1/aggregation/shops/{id} 사용 (NOT order/checkout/vendors). |
cancel-order 404 | submit 직후 yogiyo 내부 commit 지연 | 자동 5번 재시도 (코드에 내장). |
| 새 토큰 sub_id mismatch (게스트) | Playwright 가 로그인 안 된 상태에서 게스트 토큰 가로챔 | sub_id 검증 자동 (코드에 내장). .env 자격증명 확인. |
submit 응답에 purchase_id 없음 | 일반 결제 흐름 (매장이 zero payment 미지원 등) | follow_pg_chain 자동 진행 (코드에 내장). |
참조
- index.html — 결제 흐름 전체 분석 (Step 1~5 + PG chain)
- index2.html — DB 스키마
- api-replay.md — 재현 클라이언트 가이드
android-re/tools/scripts/replay_client.py— 1초결제 reference 구현C:\Users\ASH\AppData\Local\Temp\yogiyo\submit-body-msc.json— 실제 submit body 캡처본