Yeonguk Lee
CLIR · backend note · 2026-07-10CLIR · 백엔드 노트 · 2026-07-10

Caching scans that never
hash the same twice.
같은 라벨인데,
해시는 매번 달라요.

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는 식품 라벨을 스캔해서 내 알레르기·식이 프로필 기준으로 먹어도 되는지 알려줘요. 원래는 스캔할 때마다 유료 모델 호출이 나갔어요. 당연한 해법은 캐시죠. 문제는, 그 당연한 캐시가 여기서는 통하지 않는다는 거예요.

01

Why exact-match caching fails here단순 일치 캐시가 실패하는 이유

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번이에요. 규모가 조금만 커져도 비용 구조가 무너져요.

02

Perceptual hash: key by what the image looks likeperceptual hash: 어떻게 생겼는지로 키를 만들기

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.

제일 아끼는 디테일은 이거예요. 캐시 키에 스키마 버전과 프롬프트 버전을 함께 넣었어요. 추출 프롬프트나 결과 스키마가 바뀌면, 아무것도 지울 필요가 없어요. 옛 항목은 그냥 더 이상 매칭되지 않다가 스스로 만료되고, 새 스캔이 새 네임스페이스를 채워요. 캐시 무효화가 사건이 아니게 돼요.

Repeat-scan path · call counts by mechanism, not measured hit-rates중복 스캔 경로 · 호출 횟수 기준, 적중률 실측 아님
BeforeAfter
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번
03

The honest scope정직한 범위

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인 팀에서 백엔드를 맡고 있어요. 스크린샷과 전/후 표는 프로젝트 카드에 있어요.

poiurity.com · iamlyg9667@gmail.com · github.com/Poiurity