CLIR scans food labels and answers "safe or not" against your allergy and dietary profile. Every scan used to trigger a paid model ladder. The obvious fix is a cache. The problem is that the obvious cache cannot work.
CLIR는 식품 라벨을 스캔해서 내 알레르기·식이 프로필 기준으로 먹어도 되는지 알려줘요. 원래는 스캔할 때마다 유료 모델 호출이 나갔어요. 당연한 해법은 캐시죠. 문제는, 그 당연한 캐시가 여기서는 통하지 않는다는 거예요.
A cache needs a stable key. But the input here is a photo, and two photos of the same label are never the same bytes: a slightly different angle, different lighting, a different crop. Hash the image bytes and you get a brand-new key on every scan, so the hit rate of an exact-match cache is effectively zero by construction.
캐시에는 안정적인 키가 필요해요. 그런데 여기 입력은 사진이고, 같은 라벨을 찍은 사진 두 장은 바이트 단위로 절대 같지 않아요. 각도가 조금 다르고, 조명이 다르고, 잘리는 범위가 달라요. 이미지 바이트를 해시하면 스캔할 때마다 새 키가 나와요. 단순 일치 캐시의 적중률은 구조적으로 0에 수렴해요.
The cost side is real: a cold scan walks a 4-stage fallback ladder (gpt-4o-mini, gpt-4o, Google Vision plus gpt-4.1-mini) under a 30-second budget. Up to 3 OpenAI calls and 1 Vision call, paid, per scan. At any realistic scale that breaks the cost model.
비용은 진짜 문제예요. 캐시가 비어 있으면 스캔 하나가 4단계 폴백 사다리(gpt-4o-mini, gpt-4o, Google Vision + gpt-4.1-mini)를 최대 30초 예산 안에서 타요. 스캔 한 번에 유료 호출이 OpenAI 최대 3번, Vision 1번이에요. 규모가 조금만 커져도 비용 구조가 무너져요.
The fix is to stop hashing bytes and start hashing appearance. A perceptual hash (pHash) maps visually similar images to matching keys, so a repeat scan of the same label lands on the same cache entry even though the photo is new. The extracted result lives in Redis with a 90-day TTL.
해법은 바이트가 아니라 생김새를 해시하는 거예요. perceptual hash(pHash)는 눈으로 봤을 때 비슷한 이미지를 같은 키로 보내줘요. 그래서 새로 찍은 사진이어도, 같은 라벨을 다시 스캔하면 같은 캐시 항목에 도착해요. 추출 결과는 Redis에 90일 TTL로 저장해요.
The detail I care most about: cache keys are namespaced by schema version and prompt version. When the extraction prompt or the result schema changes, I don't flush anything. Old entries simply stop matching and expire on their own, while new scans populate the new namespace. Invalidation becomes a non-event.
제일 아끼는 디테일은 이거예요. 캐시 키에 스키마 버전과 프롬프트 버전을 함께 넣었어요. 추출 프롬프트나 결과 스키마가 바뀌면, 아무것도 지울 필요가 없어요. 옛 항목은 그냥 더 이상 매칭되지 않다가 스스로 만료되고, 새 스캔이 새 네임스페이스를 채워요. 캐시 무효화가 사건이 아니게 돼요.
| Before전 | After후 | |
|---|---|---|
| Paid API calls유료 API 호출 | up to 4최대 4 3 OpenAI + 1 Vision | 0 |
| Lookup조회 | 4-stage model ladder, 30s budget모델 4단계, 최대 30초 | one Redis readRedis 조회 1번 |
What the table says is a mechanism fact: on a cache hit, a repeat scan collapses from the paid ladder to a single Redis read. The cache runs in production today, and every scan records its own latency, cost, and cache-hit telemetry, so the hit rate is measurable. I still don't quote one, because I haven't aggregated a production window I'd stand behind, and a number without that scope would be worse than none. The same discipline applies to the sibling guard on the community feed, where TTL jitter, stale-while-revalidate, and a distributed lock keep a burst of N concurrent requests from triggering N recomputes of the 7-day aggregation: I describe the mechanism, not an uptime story.
위 표가 말하는 건 메커니즘 사실이에요. 캐시에 맞으면, 중복 스캔이 유료 사다리 대신 Redis 조회 1번으로 끝난다는 것. 이 캐시는 지금 프로덕션에서 돌고 있고, 스캔마다 지연·비용·캐시 적중 텔레메트리를 남겨요. 그래서 적중률은 잴 수 있어요. 그래도 인용하지 않아요. 자신 있게 말할 만한 프로덕션 구간을 아직 집계하지 않았거든요. 범위 없는 숫자는 없느니만 못해요. 커뮤니티 피드를 지키는 형제 장치도 같은 기준으로 말해요. TTL jitter, stale-while-revalidate, 분산 락으로 요청 N개가 한꺼번에 몰려도 7일 집계 재계산이 N번 일어나지 않게 했다는 메커니즘까지만요.
CLIR is live on the App Store; I own its backend on a 4-person team. Screenshots and the before/after table are on the project card.
CLIR는 App Store에서 서비스 중이고, 4인 팀에서 백엔드를 맡고 있어요. 스크린샷과 전/후 표는 프로젝트 카드에 있어요.