Fullstack 118

heo4·4일 전

Fullstack

목록 보기
73/74

풀스택

[해피파 개발일지] 계산기를 공식 규칙으로 갈아엎고, 쿠폰 자동 수집·웹 푸시까지 붙인 하루

FC 온라인 올인원 파트너 PWA 해피파(HAFIPA) 개발 기록 — 2026.10.07
스택: React 19 + TypeScript + Vite + Tailwind / Cloudflare Pages · Workers · D1 · KV · Cron Triggers (전부 무료 플랜)

오늘은 "돌아가긴 하는데 사실은 틀린" 부분을 하나씩 걷어낸 날이었다. 계산기는 근사치가 아니라 넥슨 공식 규칙으로 다시 만들었고, 비어 있던 쿠폰 데이터는 자동 수집 + 관리자 승인 파이프라인으로 채웠고, MVP에서 빠져 있던 "알림"을 웹 푸시로 붙였다. 중간중간 무료 플랜 한도와 싸운 이야기도 같이 정리한다.


1. 넥슨 30일 갱신 의무, 계산이 틀렸다

넥슨 Open API(와 크롤링 데이터)는 30일 이내 갱신 의무가 있다. 선수 8.8만 명의 상세 스탯을 cron으로 재크롤링하고 있었는데, 어제 수정 배포 후 "약 2.5일이면 30일 초과분이 해소된다"고 추정했었다.

오늘 원격 D1을 다시 재보니 그 추정이 틀렸다.

  • 30일 초과: 28,993 → 25,438명 (줄긴 줄었지만 생각보다 느림)
  • 원인: 처음 크롤링이 9/8~9/11에 하루 1만 명씩 몰려 있어서, 시간이 흐르면서 새로 30일을 넘는 선수도 하루 1만 명씩 생긴다. 재크롤 속도(하루 1.1~1.3만)와 거의 같아서 따라잡지를 못하는 구조였다. 기존 속도면 10/13~14에야 0.

해결: 무료 플랜은 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') 문자열로 비교하면 전부 "초과"로 나온다. 단위부터 확인하자.


2. 선수 검색 한 번에 D1 8.8만 행을 읽고 있었다

선수정보 탭을 점검하다가 검색 비용을 재봤다.

  • player_name LIKE '%검색어%'는 인덱스를 못 탄다.
  • 일치하는 선수가 없으면 검색 1회 = 88,241행 read.
  • 게다가 프론트에 디바운스가 없어서, 한글 입력 중간 상태(ㅅ → 소 → 손 → 손ㅎ → 손흐 …)마다 요청이 나가고 있었다. 검색 몇십 번이면 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를 썼다.

  • 3글자 이상: trigram 토크나이저 → 이름 어디든 부분 일치 ("음바페" → "킬리안 음바페")
  • 2글자: unicode61 단어 색인 앞부분 일치 ("홀란" → "엘링 홀란")
  • 색인 동기화 트리거는 AFTER UPDATE OF player_name으로만 → 능력치만 바꾸는 재크롤링 UPDATE에는 색인 쓰기가 안 생긴다

운영 데이터 88,246명을 로컬 D1에 복사해서 검증했다.

  • 3글자 이상 무작위 질의 397개: 기존 LIKE와 결과 100% 일치, 로컬 기준 약 27배 빠름 (3,015ms → 113ms)
  • 2글자 "E." 같은 이니셜은 마침표가 무시돼서 오탐이 생겨 → FTS로 좁힌 뒤 LIKE로 한 번 더 거르게 수정

색인 생성(8.8만 × 2)이 D1 쓰기 한도를 꽤 쓰기 때문에, 임시 cron을 걷어낸 뒤 조용한 날 적용할 예정이다. FTS 테이블이 없으면 자동으로 LIKE로 폴백하게 짜 둬서 API는 먼저 배포해도 안전하다.


3. 계산기를 "공식 규칙"으로 전면 재작성

솔직히 기존 계산기는 다 근사치였다.

계산기기존실제
수수료판매가의 5%기본 40%, PC방 30% + TOP CLASS 20% 할인, 쿠폰 별도
강화임의의 확률표공식 1~13강 확률 × 부스트 게이지, 실패 시 복구 확률표
집중훈련"OVR 1당 재료 5명"포지션별 능력치 가중치로 OVR 계산

수수료 — 공식 계산기 코드를 그대로 이식

넥슨에 공식 "이적시장 수수료 계산기" 페이지가 있어서, 번들 JS를 열어 계산 로직을 확인했다.

  • 기본 수수료 = ⌊판매가 × 수량 × 40 / 100⌋
  • PC방 30%와 TOP CLASS 20%는 곱이 아니라 합산 (둘 다면 50%)
  • 쿠폰은 공통 할인과 합쳐 100%를 넘지 않게 자르고, 1장당 최대 할인 금액 상한
  • 단계마다 정수 내림, 큰 금액 때문에 BigInt 계산

예: 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 보호"가 자동으로 나뉘도록 전이 함수를 일반화했고, 독립적으로 짠 몬테카를로와 다시 맞춰봤다.

  • 동일 4강 재료 1→6강: 13.78 vs 13.77
  • BP 100% 8→10강: 45.00(이론값 45) vs 45.19

8→13강을 재료만으로 계산하면 평균 3,600억 회가 나온다. 수학적으로는 맞지만 화면에 그대로 띄우면 혼란스러워서, 10만 회 이상은 "사실상 불가" + 동일 선수/BP 보호 안내로 바꿨다.

집중훈련 — 근거 없는 계산은 일단 내렸다

제대로 하려면 선수별 세부 능력치와 포지션 가중치가 필요하다. 잘못된 숫자를 보여주느니 "준비 중"으로 두고, 데이터부터 모으기로 했다 (5번에서 계속).


4. 쿠폰: 인벤에서 후보를 모으고, 사람이 승인한다

MVP 핵심 기능이 "쿠폰 복사 + 넥슨 등록 페이지 이동 + 만료 알림"인데, 운영 DB에 있던 쿠폰 3개는 샘플로 넣은 가짜 코드였고 그마저 9월에 다 만료돼 있었다.

FC 온라인 인벤 "팁과 노하우" 게시판에는 운영진 공지로 만들어진 '쿠폰' 말머리가 있어서, 생방송·이벤트 쿠폰이 거의 매일 올라온다. robots.txt에서도 해당 경로는 막혀 있지 않았다.

다만 그대로 자동 게시하기엔 문제가 많았다.

  • 코드가 정형화돼 있지 않음 (쿠폰:우리같이우루과이전보자, K리그 : 보름달보다골빛나는FC3Q, 한 글에 여러 개, 본문에만 있음)
  • 만료일이 거의 없음 ("~23시 59분까지")
  • 넥슨 등록 페이지는 로그인이 필요해서 자동 검증 불가 (보안 원칙상 하지도 않는다)

그래서 "후보 수집 + 관리자 승인" 구조로 만들었다.

  1. 크롤러 Worker가 쿠폰 말머리 목록 1페이지만 확인 → 처음 보는 글만 최대 10개 열어 코드 후보 추출 → D1 대기열(pending)
  2. 게시글 본문은 저장하지 않고 코드 + 출처 링크 + 기한 문구만 남긴다
  3. 숨겨진 관리자 화면(#admin, 토큰 인증)에서 인게임 확인 후 승인한 것만 앱에 노출
  4. /api/coupons는 KV 캐시, 승인하면 즉시 무효화

코드 추출은 "제목은 토큰 단위, 본문은 한 줄이 통째로 하나의 토큰인 줄만"이라는 단순한 규칙으로도 최근 글 50개에서 꽤 잘 뽑혔다. 오탐("우루과이전쿠폰" 같은 것)은 승인 단계에서 거르면 된다. 운영 배포 후 첫 실행에서 실제 후보 2개가 들어왔다.

트러블슈팅: TS 블록 주석 안에 Cron */30이라고 썼더니 */에서 주석이 닫혀서 컴파일 에러. 크론 표현식을 주석에 쓸 땐 조심.
관리자 토큰은 openssl rand | tee 파일 | wrangler secret put으로 만들어서, 값이 대화 로그나 화면에 남지 않게 했다.


5. 집중훈련 준비 — 크롤러가 세부 능력치 34종까지 저장

데이터센터 응답 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종이 정상 저장됐다.


6. 웹 푸시: 새 쿠폰 / 만료 임박 알림

MVP의 "만료 알림"이 화면 표시뿐이었던 걸 진짜 푸시로 바꿨다.

  • 라이브러리: Node의 web-push는 Workers에서 안 돌아서, Web Crypto 기반 @block65/webcrypto-web-push(RFC 8291 aes128gcm + RFC 8292 VAPID, 애플 포함) 사용
  • 구독 주소는 공식 푸시 서비스 도메인(구글 FCM / 모질라 / 애플 / 윈도우)만 허용 — 아무 URL이나 받으면 Worker가 임의 서버로 요청을 보내는 통로가 된다
  • 404/410 응답이 온 구독은 자동 삭제

무료 플랜 제약과의 싸움

  • 호출당 subrequest 50개 → 구독자에게 한 번에 못 보낸다. 발송 "캠페인"을 D1에 만들고 cursor를 넘기며 1분에 40명씩 보낸다.
  • 계정당 cron 트리거 5개 → 쿠폰 Worker 하나를 * * * * *로 바꾸고, 안에서 "매분 발송 / 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);

검증

  • 라이브러리가 만든 암호문을 RFC 8291대로 직접 복호화 → 원문 일치 (4096바이트 패딩 확인), VAPID JWT 서명도 공개키로 검증
  • 로컬 Worker에서 실제 FCM 서버로 발송 → 가짜 토큰이 404로 거부되고 구독이 자동 정리되는 것까지 확인
  • 헤드리스 브라우저엔 푸시 서비스가 없어서 실제 수신은 실기기에서 확인 예정
  • iOS는 홈 화면에 설치한 PWA에서만 웹 푸시가 되므로 설치 안내 문구를 넣었다

7. 덤: 갱신시간 알리미(Phase 2) 조사

"갱신시간 알리미"는 선수별 "2시간 거래량"이 바뀌는 순간을 감지하는 설계였는데, 조사해 보니

  • 넥슨 공식 Open API에는 시세/거래량 API가 아예 없고 (기존 코드가 호출하던 /market/price는 존재하지 않는 주소였다)
  • 공식 데이터센터에도 현재가와 일별 시세는 있지만 거래량·갱신 시각은 없다

그래서 "현재가가 바뀌는 순간 = 갱신"으로 볼 수 있는지 인기 선수 4명을 5분 간격으로 폴링하는 실험을 돌리는 중이다. 2시간 동안 한 명만 한 번 바뀌어서, 데이터센터가 일정 주기로 한꺼번에 갱신하는 게 아닌지 더 길게 관찰해 보기로 했다.


오늘의 트러블슈팅 모음

  • wrangler가 엉뚱한 Cloudflare 계정에 로그인 → "KV namespace not found". 브라우저에 로그인된 계정으로 OAuth가 자동 승인되는 게 원인. 배포 전에 wrangler whoami부터.
  • WSL의 /mnt/c에서는 Vite HMR이 파일 변경을 못 잡는다 (inotify 미지원) → 수정 후 dev 서버 재시작.
  • WSL에 Chromium 의존 라이브러리가 없어 Playwright 실행 실패 → sudo 없이 apt-get download libnspr4 libnss3 libasound2t64 후 압축만 풀어 LD_LIBRARY_PATH로 연결.
  • 헤드리스 전용 Chromium은 알림 권한을 항상 denied로 보고 → channel: 'chromium'(새 헤드리스)로 바꿔야 권한 흐름 테스트 가능.
  • 로컬 D1에 3.5MB SQL 파일을 한 번에 넣으면 internal error → 1만 행 단위로 분할.

다음 할 일

  • 실기기에서 웹 푸시 수신 확인
  • 10/9~10: 30일 초과 0 확인 → 임시 cron 제거
  • 그 다음 날: FTS 색인 생성 + 운영 검색 비용 측정
  • 세부 능력치가 쌓이면 포지션별 OVR 가중치 회귀분석 → 집중훈련 계산기
  • 홈 화면 설치형 앱(PWA) 경험 다듬기 — 설치 유도, 아이콘, 앱처럼 보이게

오늘 커밋 12개. 하루 종일 "그럴듯한 숫자"를 "맞는 숫자"로 바꾸는 작업이었다.

Data based on NEXON Open API. 해피파는 넥슨과 무관한 비공식 팬 서비스입니다.

0개의 댓글