FC 온라인 올인원 파트너 PWA 해피파(HAFIPA) 개발 기록 — 2026.10.07
스택: React 19 + TypeScript + Vite + Tailwind / Cloudflare Pages · Workers · D1 · KV · Cron Triggers (전부 무료 플랜)
오늘은 "돌아가긴 하는데 사실은 틀린" 부분을 하나씩 걷어낸 날이었다. 계산기는 근사치가 아니라 넥슨 공식 규칙으로 다시 만들었고, 비어 있던 쿠폰 데이터는 자동 수집 + 관리자 승인 파이프라인으로 채웠고, MVP에서 빠져 있던 "알림"을 웹 푸시로 붙였다. 중간중간 무료 플랜 한도와 싸운 이야기도 같이 정리한다.
넥슨 Open API(와 크롤링 데이터)는 30일 이내 갱신 의무가 있다. 선수 8.8만 명의 상세 스탯을 cron으로 재크롤링하고 있었는데, 어제 수정 배포 후 "약 2.5일이면 30일 초과분이 해소된다"고 추정했었다.
오늘 원격 D1을 다시 재보니 그 추정이 틀렸다.
해결: 무료 플랜은 Worker 호출 한 번당 외부 요청(subrequest)이 50개로 묶여 있다. 그런데 이 한도는 호출마다 따로라서, cron을 하나 더 추가하면 처리량이 그대로 2배가 된다.
# wrangler-sync.toml
[triggers]
crons = ["*/5 * * * *", "2-59/5 * * * *"] # 두 번째는 따라잡기용 임시 cron
이렇게 하면 10/9~10에 0이 된다. 다 따라잡으면 임시 cron은 제거할 예정. (참고로 cron 변경은 배포 후 실제 반영까지 약 30분 걸렸다. 배포 직후 로그가 안 보여도 당황하지 말 것.)
배운 점:
synced_at을 epoch ms 정수로 저장해 놓고datetime('now','-30 days')문자열로 비교하면 전부 "초과"로 나온다. 단위부터 확인하자.
선수정보 탭을 점검하다가 검색 비용을 재봤다.
player_name LIKE '%검색어%'는 인덱스를 못 탄다.ㅅ → 소 → 손 → 손ㅎ → 손흐 …)마다 요청이 나가고 있었다. 검색 몇십 번이면 D1 일일 읽기 한도(500만)를 태울 수 있는 상황.1차 수정 — 프론트에서 요청 자체를 줄이기
// 끝이 낱자모(조합 중)면 직전 검색어를 유지 → 요청 안 보냄
const TRAILING_JAMO = /[ㄱ-ㆎ]$/;
// 300ms 디바운스 + 2글자 이상만 검색
Playwright로 "손흥민"을 6단계로 쪼개 입력해 봤더니 검색 요청은 딱 1건만 나갔다. 서버(Worker)에도 2글자 미만은 빈 배열을 돌려주게 막았다.
2차 수정 — Elasticsearch 대신 D1 FTS5
처음엔 Elasticsearch를 붙이자는 얘기가 나왔지만, Cloudflare 안에서 돌릴 수 없고(유료 클러스터 + 동기화 파이프라인 추가) 데이터도 카드 8.8만 장 / 이름 4.8만 개 수준이라 과하다고 판단했다. 대신 D1에 내장된 SQLite FTS5를 썼다.
trigram 토크나이저 → 이름 어디든 부분 일치 ("음바페" → "킬리안 음바페")unicode61 단어 색인 앞부분 일치 ("홀란" → "엘링 홀란")AFTER UPDATE OF player_name으로만 → 능력치만 바꾸는 재크롤링 UPDATE에는 색인 쓰기가 안 생긴다운영 데이터 88,246명을 로컬 D1에 복사해서 검증했다.
색인 생성(8.8만 × 2)이 D1 쓰기 한도를 꽤 쓰기 때문에, 임시 cron을 걷어낸 뒤 조용한 날 적용할 예정이다. FTS 테이블이 없으면 자동으로 LIKE로 폴백하게 짜 둬서 API는 먼저 배포해도 안전하다.
솔직히 기존 계산기는 다 근사치였다.
| 계산기 | 기존 | 실제 |
|---|---|---|
| 수수료 | 판매가의 5% | 기본 40%, PC방 30% + TOP CLASS 20% 할인, 쿠폰 별도 |
| 강화 | 임의의 확률표 | 공식 1~13강 확률 × 부스트 게이지, 실패 시 복구 확률표 |
| 집중훈련 | "OVR 1당 재료 5명" | 포지션별 능력치 가중치로 OVR 계산 |
넥슨에 공식 "이적시장 수수료 계산기" 페이지가 있어서, 번들 JS를 열어 계산 로직을 확인했다.
예: 1억에 PC방+TOP+쿠폰 30% → 수수료 800만, 실수령 9,200만.
공식 공지에서 단계별 성공 확률(100/81/64/50/26/15/7/5/4/3/2/1%)과 누적 OVR, 그리고 실패 시 복구 확률표(이미지)를 가져왔다. 예를 들어 6→7강에서 실패하면 1강 10%, 2강 32%, 3강 36%, 4강 22%로 떨어진다. 실패하면 아래로 떨어지기 때문에 "평균 몇 번 눌러야 하나"는 단순 1/p가 아니다.
등급을 상태로 보는 흡수 마르코프 체인으로 풀었다.
visits = e_from · (I − Q)⁻¹ // 각 등급에서 시도하게 될 기대 횟수
가우스 소거로 풀고, 분포 DP로 "50%/90% 확률로 성공하는 시도 횟수"도 구했다. 손계산(1→3강 2.469회)과 몬테카를로 20만 회(1→5강 12.00 vs 11.98)로 교차 검증.
이후 동일 선수 재료(복구 확률 ② — 공지에 링크된 상세 페이지의 HTML 표 77행)와 크리티컬(+2), BP 등급 보호(8~12강, 보호 확률만큼 등급 유지, 동일 선수와 병행 불가)까지 넣었다. 등급마다 "재료만 / 동일 선수 / BP 보호"가 자동으로 나뉘도록 전이 함수를 일반화했고, 독립적으로 짠 몬테카를로와 다시 맞춰봤다.
8→13강을 재료만으로 계산하면 평균 3,600억 회가 나온다. 수학적으로는 맞지만 화면에 그대로 띄우면 혼란스러워서, 10만 회 이상은 "사실상 불가" + 동일 선수/BP 보호 안내로 바꿨다.
제대로 하려면 선수별 세부 능력치와 포지션 가중치가 필요하다. 잘못된 숫자를 보여주느니 "준비 중"으로 두고, 데이터부터 모으기로 했다 (5번에서 계속).
MVP 핵심 기능이 "쿠폰 복사 + 넥슨 등록 페이지 이동 + 만료 알림"인데, 운영 DB에 있던 쿠폰 3개는 샘플로 넣은 가짜 코드였고 그마저 9월에 다 만료돼 있었다.
FC 온라인 인벤 "팁과 노하우" 게시판에는 운영진 공지로 만들어진 '쿠폰' 말머리가 있어서, 생방송·이벤트 쿠폰이 거의 매일 올라온다. robots.txt에서도 해당 경로는 막혀 있지 않았다.
다만 그대로 자동 게시하기엔 문제가 많았다.
쿠폰:우리같이우루과이전보자, K리그 : 보름달보다골빛나는FC3Q, 한 글에 여러 개, 본문에만 있음)그래서 "후보 수집 + 관리자 승인" 구조로 만들었다.
#admin, 토큰 인증)에서 인게임 확인 후 승인한 것만 앱에 노출/api/coupons는 KV 캐시, 승인하면 즉시 무효화코드 추출은 "제목은 토큰 단위, 본문은 한 줄이 통째로 하나의 토큰인 줄만"이라는 단순한 규칙으로도 최근 글 50개에서 꽤 잘 뽑혔다. 오탐("우루과이전쿠폰" 같은 것)은 승인 단계에서 거르면 된다. 운영 배포 후 첫 실행에서 실제 후보 2개가 들어왔다.
트러블슈팅: TS 블록 주석 안에
Cron */30이라고 썼더니*/에서 주석이 닫혀서 컴파일 에러. 크론 표현식을 주석에 쓸 땐 조심.
관리자 토큰은openssl rand | tee 파일 | wrangler secret put으로 만들어서, 값이 대화 로그나 화면에 남지 않게 했다.
데이터센터 응답 HTML을 뜯어보니 세부 능력치 34종이 다 들어 있었고, 더 좋은 건 이 부분이었다.
<li class="ab" data-positon=",20,21,22,24,25,26,">
<div class="txt">슛 파워</div>
<div class="value">127</div>
</li>
data-positon이 이 능력치가 어느 포지션의 OVR에 반영되는지 알려준다 (넥슨 포지션 ID: 25=ST, 21=CF …). 가중치 값 자체는 없지만, 반영 여부를 알면 수천 장의 카드 데이터로 회귀분석을 해서 가중치를 추정할 수 있다.
마침 30일 의무 재크롤링이 진행 중이라, 오늘 크롤러를 확장해 두면 남은 7.2만 명(전체의 82%)이 추가 비용 없이 이번 주기에 채워진다. 34종을 고정 순서의 숫자 배열로 저장해서 선수당 +130바이트 정도로 끝냈고, 파싱이 실패해도 기존 필드 저장은 막지 않게 방어적으로 짰다. 배포 직후 크롤링된 90명 모두 34종이 정상 저장됐다.
MVP의 "만료 알림"이 화면 표시뿐이었던 걸 진짜 푸시로 바꿨다.
web-push는 Workers에서 안 돌아서, Web Crypto 기반 @block65/webcrypto-web-push(RFC 8291 aes128gcm + RFC 8292 VAPID, 애플 포함) 사용무료 플랜 제약과의 싸움
* * * * *로 바꾸고, 안에서 "매분 발송 / 30분마다 크롤링 / 매일 00:00 UTC 만료 알림"으로 분기했다.const minute = new Date(event.scheduledTime).getUTCMinutes();
if (minute === 0 && hour === 0) await enqueueExpiryReminder(env.DB);
if (minute % 30 === 0) await crawlInvenCoupons(env);
await sendCampaignBatch(env, minute % 30 === 0 ? 30 : 40);
검증
"갱신시간 알리미"는 선수별 "2시간 거래량"이 바뀌는 순간을 감지하는 설계였는데, 조사해 보니
/market/price는 존재하지 않는 주소였다)그래서 "현재가가 바뀌는 순간 = 갱신"으로 볼 수 있는지 인기 선수 4명을 5분 간격으로 폴링하는 실험을 돌리는 중이다. 2시간 동안 한 명만 한 번 바뀌어서, 데이터센터가 일정 주기로 한꺼번에 갱신하는 게 아닌지 더 길게 관찰해 보기로 했다.
wrangler whoami부터./mnt/c에서는 Vite HMR이 파일 변경을 못 잡는다 (inotify 미지원) → 수정 후 dev 서버 재시작.apt-get download libnspr4 libnss3 libasound2t64 후 압축만 풀어 LD_LIBRARY_PATH로 연결.channel: 'chromium'(새 헤드리스)로 바꿔야 권한 흐름 테스트 가능.오늘 커밋 12개. 하루 종일 "그럴듯한 숫자"를 "맞는 숫자"로 바꾸는 작업이었다.
Data based on NEXON Open API. 해피파는 넥슨과 무관한 비공식 팬 서비스입니다.