Toonify
인스타그램 인스타툰의 새 에피소드를 자동 감지해 푸시 알림으로 알려주는 React Native 앱. GitHub Actions 배치로 3시간마다 업데이트를 체크합니다.
Role
기획 · 설계 · 개발 (1인)
Period
2026
Stack
React Native, Expo SDK 54, Supabase, GitHub Actions, AsyncStorage, hasdata API, OCR.space API, Expo Push API
만든 이유
인스타그램 팔로우 알림은 팔로우한 모든 계정의 게시물이 섞여 오기 때문에, 좋아하는 인스타툰 새 화만 골라서 받는 방법이 없었습니다. 여러 작가를 팔로우하면 업데이트를 놓치기 쉽고, 결국 구독하던 시리즈를 잊어버리게 됩니다. 관심 작가를 등록해두면 새 에피소드가 올라올 때 알림을 받을 수 있는 앱을 만들었습니다.
주요 기능
- 인스타툰 구독 관리 — 마지막으로 읽은 화의 인스타 링크를 붙여넣으면 계정명·시리즈명·화수를 자동 파싱. 스와이프 삭제·수정 지원
- 자동 에피소드 감지 — 최신 게시물 12개를 최신순 스캔, 캡션 키워드 매칭으로 새 화 감지. 캡션에 화수가 없으면 OCR로 이미지에서 직접 인식
- 복수 화수 동시 알림 — “3화, 4화가 추가되었어요” 형식으로 한 번에 알림
- 에피소드 목록 — 화수 탭 → 해당 인스타 게시물로 이동 + 읽음 자동 처리
- 완결 자동 처리 —
완/완결패턴 감지 또는 3주 이상 새 포스트 없으면 완결 처리 - 푸시 알림 — 새 에피소드 발견 시 Expo Push API로 즉시 전송
- 라이트 / 다크 모드 — 시스템 기본값 연동 및 수동 전환
AI 협업 — 정의서 레이어로 문맥 38% 절감
문제 Claude Code는 대화가 바뀌면 이전 세션의 판단을 기억하지 못합니다. 정의서 없이 CLAUDE.md 하나에만 의존했을 때는 OTA 배포처럼 단순한 작업에도 270줄 전체를 매번 문맥으로 넘겨야 했고, 배포 기준이나 매칭 로직 같은 판단을 세션마다 새로 재해석해 결과가 달라질 위험이 있었습니다. 실제로 앱·서버 매칭 로직이 문서화되지 않은 채 각자 독립적으로 발전한 게 원인이 되어, TROUBLESHOOTING 로그 기준 연속된 버그 6건(#11~#16)이 전부 “로직 분산”이라는 같은 원인에서 나왔습니다.
분석 하나의 CLAUDE.md에 모든 규칙을 담는 방식과 태스크 유형별로 필요한 문서만 로드하는 레이어 구조를 검토했습니다. 전자는 단순 작업에도 데이터 모델·API 스키마 같은 불필요한 문맥까지 매번 전달되는 비효율이 있어, 레이어 구조를 선택했습니다.
실행 문서를 세분화할수록 관리 포인트가 늘어난다는 트레이드오프 속에서, .claude/ 아래를 태스크 라우팅(AGENTS.md, 76줄) → 핵심 지시서(CLAUDE.md, 270줄) → 도메인별 판단 기준(skills/episode-detection.md, deploy-decision.md) → 코딩 기준(docs/architecture.md, data-model.md, code-standards.md) → 구현 전 정의 템플릿(templates/) → 반복 작업 체크리스트(recipes/)까지 6개 레이어로 분리하고, 명령 유형별로 필요한 레이어만 로드하도록 라우팅했습니다.
결과 OTA 배포 실행은 168줄(76+51+41)만 읽으면 되도록 줄어 이전 대비 문맥 정밀도가 약 38%((270−168)÷270) 향상됐고, code-standards.md·architecture.md에 “매칭 로직 분산 금지”를 명시적으로 고정한 뒤로는 같은 원인의 버그가 재발하지 않았습니다.
| 이전 (CLAUDE.md 단독) | 이후 (레이어 구조) |
|---|---|
| 모든 태스크가 270줄 전체를 문맥으로 받음 → 관련 없는 정보가 섞임 | 태스크별 필요한 파일만 선택적 로드 — AGENTS.md 라우팅 |
| 배포 판단 기준이 암묵적 → 세션마다 재해석, 결과가 달라짐 | 판단 기준이 문서로 고정 — skills/deploy-decision.md |
| 새 기능 구현 전 정의 강제 없음 → 구현 중 방향 변경 발생 | 빈칸 미완성 시 구현 시작 불가 — templates/feature.md |
| 로직 재사용 규칙이 텍스트에만 존재 → 버그 반복(#11~#16) | 금지 패턴 명시 + 책임 분리 — docs/code-standards.md, architecture.md |
| 배포 체크리스트 없음 → 단계 누락 가능 | 순서대로 이행 확인 — recipes/ota-deploy.md, new-build.md |
성능 최적화 — ToonCard 리렌더 3회 → 1회
문제 툰 목록을 당겨서 새로고침하면 데이터가 바뀌지 않은 카드도 전부 리렌더링돼, 툰이 많아질수록 새로고침이 느려질 수 있는 문제가 있었습니다. 원인을 확인하려 해도 Expo Go 환경에서는 React DevTools Profiler를 쓸 수 없었습니다 — Metro Hermes 엔진과 별도 DevTools 연결이 필요하고 시뮬레이터에서만 안정적으로 동작하는데, 실제로는 npx expo start로 Expo Go에서 실행 중이라 사용할 수 없는 환경이었습니다.
분석 DevTools Profiler, console.time/console.timeEnd, 직접 만든 측정 도구를 비교했습니다. Profiler는 expo-dev-client 커스텀 빌드가 필요해 측정 하나 때문에 오버킬이었고, console.time은 누적 평균·렌더 횟수 집계가 안 되고 __DEV__ 가드를 매 호출마다 붙여야 해 코드가 지저분해졌습니다. performance.now()는 JS 표준이라 Expo Go에서도 동작하고 report()로 여러 측정값을 한 번에 집계할 수 있어, 가장 적은 비용으로 가장 많은 정보를 주는 자체 도구(perf.js)를 선택했습니다. 이 도구로 측정한 결과, HomeScreen이 새로고침 후 setToons(sorted)로 상태를 갱신할 때 메모이제이션 없는 ToonCard가 매번 전부 리렌더되고, 내부 allEpisodes()가 렌더마다 episodeHistory·unreadPosts를 합산·정렬해 새 배열을 생성하는 게 원인임을 확인했습니다.
실행 __DEV__ 가드를 파일 안에 한 번만 작성해 호출 코드는 깔끔하게 유지하고 다른 컴포넌트에도 import 한 줄로 재사용 가능하도록 perf.js를 분리했습니다. 이 도구로 원인을 특정한 뒤 ToonCard에 toon.updatedAt 비교자를 가진 React.memo를 적용하고, allEpisodes() 결과를 useMemo로 감쌌습니다.
결과 perf.js로 직접 측정한 결과, 새로고침 시 ToonCard 렌더 횟수가 툰 개수와 무관하게 1회로 고정됐습니다 — 개선 전에는 새로고침 1회당 3회 렌더(툰 10개면 30회)였지만, 개선 후에는 새로고침을 몇 번 반복해도 초기 마운트 1회뿐입니다. 다만 체감 새로고침 시간 자체는 크게 줄지 않았는데, 전체 시간의 95%를 Instagram API 호출이 차지하는 병목이라 렌더 최적화만으로는 해결되지 않았습니다.
| 항목 | 개선 전 | 개선 후 | 비고 |
|---|---|---|---|
| ToonCard 렌더 | 새로고침 1회 → 3회 | 새로고침 몇 회든 → 1회(고정) | 툰 10개 기준 30회 → 10회 |
| syncFromSupabase | 221.4ms | 평균 133.5ms | |
| checkAllToons | 23,977ms (API 실호출) | 평균 1.3ms (캐시된 케이스) | |
| getToons() 호출 | 5회 | 5회 (변경 없음) | 7ms 수준이라 최적화 대상에서 제외 |
배치 서버 없이 3시간 주기 감지
문제 3시간마다 인스타그램을 확인하고 푸시 알림을 보내는 구조가 필요했지만, 별도 서버를 운영하는 건 개인 프로젝트 규모에 유지비와 관리 부담이 컸습니다.
분석 자체 서버(EC2 등)와 GitHub Actions 스케줄 배치를 비교했습니다. 무료로 크론 실행이 가능한 GitHub Actions가 이 규모에 적합하다고 판단했습니다.
실행 GitHub Actions의 실행 시간 제한과 상태를 유지할 수 없다는 제약 속에서, Supabase에 구독 목록과 push token을 저장해 매 실행마다 필요한 상태만 읽어오는 구조로 관리했습니다. 스크립트가 인스타그램 최신 게시물을 확인하고 새 에피소드가 감지되면 Expo Push API로 알림을 전송합니다.
결과 GitHub Actions를 3시간 인터벌 배치 서버로 활용해, 별도 서버 없이 운영 비용 없이 동작하는 것을 확인했습니다.
캡션 + OCR 2단계 감지 엔진
문제 인스타툰 작가마다 화수 표기 방식이 달랐습니다. 캡션에 n화 형식으로 명시하는 경우도 있지만, 캡션에 화수가 없고 이미지 안에만 화수가 적힌 경우도 많아 캡션 매칭만으로는 감지 실패가 잦았습니다.
분석 캡션 매칭만 강화하는 방식과 OCR을 보조 수단으로 도입하는 방식을 검토했습니다. 캡션에 화수 정보 자체가 없는 케이스는 매칭 강화로 해결되지 않아, OCR 폴백을 함께 쓰는 2단계 구조를 선택했습니다.
실행 모든 게시물에 OCR을 돌리면 호출 비용이 커진다는 제약 속에서, 캡션에서 n화 / n편 / ep.n / #n 패턴을 우선 추출하고 캡션에 화수가 없을 때만 OCR.space API로 이미지에서 직접 인식하도록 순서를 설계했습니다. 앱과 GitHub Actions 서버 모두 동일한 matchingUtils.js를 공유해 감지 로직의 단일 진실 공급원을 유지했습니다.
결과 캡션 기반 우선 감지와 OCR 폴백을 결합한 2단계 감지 엔진으로 감지 실패율을 낮췄습니다.
last_post_id 기준점으로 OCR 호출 93% 절감
문제 초기 구조는 GitHub Actions가 실행될 때마다 최신 포스트 12개 전체를 처음부터 재검사했습니다. 새 포스트가 없어도 매 사이클마다 OCR을 최대 12회씩 소모했고, 툰 5개 기준 하루 약 160회의 OCR 호출이 발생했습니다.
분석 OCR 서비스 자체를 교체하는 방법(ML Kit 온디바이스 OCR도 시도했으나 인식률 한계로 철회)과 호출 횟수 자체를 줄이는 방법을 비교했습니다. 호출 횟수를 줄이는 쪽이 서비스 교체 없이 근본적으로 해결 가능하다고 판단했습니다.
실행 새 포스트를 놓치지 않아야 한다는 제약 속에서, Supabase toons 테이블에 last_post_id 컬럼을 추가해 마지막으로 처리한 포스트 이후만 처리하도록 변경했습니다. 여기에 post.id를 키로 OCR 결과를 AsyncStorage에 캐싱해 동일 포스트를 다시 인식하지 않도록 이중으로 방지했고, 첫 등록 시 기준점은 가장 오래된 포스트로 설정해 이전 화를 영원히 놓치는 버그도 방지했습니다.
결과 동일 조건에서 하루 OCR 호출이 약 12회로 줄어 93% 절감됐습니다.
앱·서버 매칭 로직 단일 소스 통일
문제 앱(check-service.js)에서 매칭 버그를 수정했는데, 서버(check-toons.js)는 수정 전 로직으로 계속 동작했습니다. 로직이 두 곳에 따로 있다 보니 오탐도 연속으로 발생했습니다 — keyWords=[]일 때 minMatch=0이 되어 모든 캡션이 매칭되거나, 캡션은 minMatch=2 기준인데 OCR은 1개만 있어도 통과해 우회 경로가 되거나, 완결 감지 시 키워드 1개만 있어도 통과하는 등 두 파일의 판단 조건이 제각각이었습니다.
분석 앱·서버 로직을 개별적으로 계속 패치하는 방식과 공통 모듈로 통합하는 방식을 검토했습니다. 개별 패치는 같은 유형의 오탐이 반복될 뿐이라고 판단해 통합을 선택했습니다.
실행 앱과 서버(GitHub Actions)가 서로 다른 런타임이라는 제약 속에서, 두 환경 모두에서 임포트 가능한 matchingUtils.js로 공통 매칭 로직을 추출해 앱과 서버 모두 이 파일을 참조하도록 통일하고, keyWords.length > 0 가드 추가와 minMatch 기준 일원화로 오탐 조건을 하나로 정리했습니다.
결과 앱에서 3편만 감지할 때 서버는 5편을 감지하던 불일치가 해소됐고, 한 곳만 수정하면 양쪽에 동시 반영되는 구조로 같은 유형의 오탐도 재발하지 않았습니다.
트러블슈팅
ML Kit 온디바이스 OCR 도입 → 인식률 한계로 철회
OCR API 한도 문제를 근본 해결하기 위해 ML Kit 온디바이스 OCR을 도입했습니다.
일반 폰트에서는 85% 이상이었지만 인스타툰 썸네일의 아트체·손글씨 폰트에서 30~40% 수준으로 떨어졌습니다. 이미지 전처리, 신뢰도 임계값 조정 등을 시도했지만 개선 폭이 10% 내외에 그쳤습니다. OCR 호출 없는 장점보다 오감지 비용이 더 컸기 때문에 ocr.space API로 되돌아가고, 대신 캐싱으로 호출 자체를 줄이는 방향으로 해결했습니다.
개발 빌드 Metro 서버 localhost 문제
EAS 개발 빌드에서 Metro 서버가 localhost:8081로 고정되어 폰에서 접근이 불가했습니다.
프로젝트 루트에 ios/android 폴더가 존재하면 Expo CLI가 로컬 네이티브 빌드 환경으로 인식해 localhost를 사용하는 것이 원인이었습니다. 두 폴더를 삭제하니 올바른 LAN IP로 URL이 생성됐습니다.