어떤 앱인가
동네 길고양이 사진을 찍으면 "이 아이, 혹시 이미 우리 동네 도감에 있는 애 아닐까?"를 찾아주고, 이웃과 함께 목격 기록을 쌓는 모바일 웹 앱이다. PWA(Progressive Web App — 홈 화면에 설치해 앱처럼 쓰는 웹)로 만들었다.
핵심 결정은 하나다. 고양이를 알아보는 AI 추론을 서버가 아니라 사용자의 브라우저 안에서 돌린다(이걸 온디바이스 AI라고 부른다). 이렇게 하면:
- 서버 추론 비용이 0원이다. GPU 서버 없이 무료 티어 Postgres만으로 운영된다. 서버가 하는 일은 "비슷한 벡터 찾기"라는 가벼운 검색뿐이다.
- 사진 원본이 아니라 임베딩 벡터만 서버로 간다. 임베딩(embedding)이란 AI 모델이 이미지를 요약한 숫자 목록(여기서는 384개의 숫자)이다. 이 숫자만으로는 원본 사진을 복원할 수 없어서 프라이버시에 유리하다.
- 모델은 브라우저 캐시에 남는다. 처음 한 번 약 22MB를 내려받으면, 재방문 때는 다운로드 없이 바로 인식이 시작된다.

동네 고양이 피드 화면. 이웃이 남긴 기록이 대표 사진 한 장씩으로 쌓이고, "고양이 촬영하기"가 인식 파이프라인의 진입점이다.
인식 파이프라인 — 사진 한 장이 도감 기록이 되기까지
카메라/업로드
│
▼ ① 탐지 (브라우저)
MediaPipe EfficientDet-Lite2 ──실패시──▶ COCO-SSD(tfjs) 폴백 ──실패시──▶ 수동 크롭
│
▼ ② 임베딩 (브라우저, transformers.js · WebGPU→WASM)
DINOv3 ViT-S/16 (q8, ~22MB) CLS 토큰 → 384차원 L2 정규화 벡터
│
▼ ③ 매칭 (Supabase RPC)
지오 필터 + pgvector 코사인 정렬 → 같은 고양이? 도감에 기록
① 탐지 — 사진 어디에 고양이가 있는지 찾아 그 부분만 잘라내는 단계다. 1순위는 MediaPipe의 EfficientDet-Lite2 모델, 그게 실패하면 TensorFlow.js의 COCO-SSD 모델로 넘어가고, 그마저 안 되면 사용자가 손으로 영역을 그리는 수동 크롭이 최후의 수단이다. 어떤 기기에서도 "인식이 안 돼서 기록을 못 하는" 상황만은 만들지 않기 위한 3단 폴백이다.
② 임베딩 — 잘라낸 고양이 이미지를 DINOv3라는 비전 모델(q8 양자화로 22MB까지 줄인 버전)에 넣어 384개의 숫자로 요약한다. 실행은 transformers.js가 맡고, 기기가 지원하면 WebGPU(브라우저에서 GPU를 쓰는 표준), 아니면 WASM(브라우저에서 네이티브급 속도를 내는 실행 방식)으로 돌아간다. 벡터는 L2 정규화(길이를 1로 맞추는 것)를 해 두는데, 이렇게 하면 두 벡터의 "방향"만 비교하는 코사인 유사도 계산이 정확해진다.
③ 매칭 — 서버(Supabase의 Postgres)는 벡터 전용 확장인 pgvector로 "근처에서 목격됐고 + 벡터 방향이 가장 비슷한 고양이"를 정렬해 준다. 위치 필터는 PostGIS(지리 데이터 확장)가 맡는다.
디테일 두 가지가 사고를 막는다. 벡터마다 모델 버전을 함께 저장해서, 나중에 모델을 교체해도 옛 모델의 벡터와 새 모델의 벡터가 섞여 비교되는 일이 없다(다른 모델이 만든 벡터끼리는 비교 자체가 무의미하다). 그리고 차원이 다르거나 숫자가 깨진(NaN 등) 벡터는 DB에 닿기 전에 순수 함수 가드에서 예외로 던진다 — 잘못된 벡터 하나가 검색 결과 전체를 조용히 오염시키는 걸 막는다.
정확한 위치는 아무도 모른다
길고양이 앱의 위험한 함정: 정확한 좌표를 공개하면 학대 목적으로 악용될 수 있다. 그래서 좌표 처리는 별도의 순수 함수 패키지로 분리했고, 원본 좌표는 이 함수의 경계를 절대 넘지 않는다.
- 공개 좌표는 250m(새끼 고양이는 500m) 격자에 스냅한다. 즉 지도에 보이는 위치는 "이 격자 칸 어딘가"라는 뜻이지 실제 좌표가 아니다.
- 격자 안에서 마커를 찍는 위치는 고양이 ID로 계산한 결정적 오프셋이다. 랜덤이 아니라서 새로고침해도 같은 자리에 보이고, 여러 번 조회해서 평균을 내는 방식으로도 원본 좌표를 복원할 수 없다.
- 특별히 민감한(
sensitive) 고양이는 좌표 자체를 공개하지 않는다.
캐시 설계 — 서버가 진실이고, 나머지는 성능 레이어
이 앱의 데이터 원칙: 서버(Postgres)가 SSOT(Single Source of Truth — 유일한 진실 공급원)다. 클라이언트에 있는 모든 캐시는 "빠르게 보여주기 위한 사본"일 뿐, 진실이 아니다. 서버 조회 데이터는 탭 안의 React Query 메모리에만 두고(10초 지나면 다시 확인), 새로고침하면 메모리가 비니 서버에 다시 물어본다.
ETag로 "물어보되, 안 바뀌었으면 거의 공짜"
여기서 문제: 무료 티어로 운영하니 서버에 물어보는 비용 자체를 줄여야 한다. 그렇다고 캐시 유지 시간을 늘리면 이웃의 새 기록이 늦게 보인다. 이 딜레마를 푸는 표준 도구가 ETag다.
ETag의 기본 동작은 이렇다:
- 서버가 응답을 줄 때 "이 데이터의 버전 표식" 문자열(ETag)을 헤더에 같이 실어 보낸다.
- 브라우저는 다음 요청에
If-None-Match: <받았던 ETag>헤더를 붙인다. "저번에 이 버전 받았는데, 바뀌었나요?"라는 뜻이다. - 서버가 보기에 버전이 그대로면 본문 없이 304 Not Modified(안 바뀜)만 돌려준다. 브라우저는 갖고 있던 응답을 그대로 쓴다. 전송량이 거의 0이다.
그런데 순진하게 구현하면 함정이 있다. 보통은 "응답 본문을 다 만들어 놓고 → 해시를 계산해 → ETag와 비교"하는데, 이러면 304를 주더라도 DB 조회 비용은 그대로 든다. 절약되는 건 전송량뿐이다.
이 앱은 순서를 뒤집었다. payload를 조회하기 전에 ETag를 계산할 수 있도록, 응답 본문 대신 "마지막 활동 시각"을 버전 표식으로 쓴다:
- 고양이 상세·댓글: 그 고양이의
last_activity_at(좋아요·댓글 등 마지막 활동 시각) 하나만 가볍게 읽어 ETag를 만든다. 클라이언트가 보낸 ETag와 일치하면 무거운 join·RPC를 아예 실행하지 않고 빈 바디 304로 끝낸다. - 주변 고양이 목록: 조회 영역(bbox) 안에서 가장 최근의
last_activity_at으로 같은 판단을 한다. - 페이지 응답: 로그인 상태에 따라 내용이 달라지므로
Cookie로 캐시를 분리하고, 5분 단위 epoch를 버전으로 쓴다. 이 경로만 최대 5분의 eventual consistency(잠깐 옛 데이터가 보일 수 있음)를 의도적으로 허용했다.
정리하면: staleTime(캐시 신선 기간)을 늘려서 트래픽을 줄이는 대신, 10초 신선도라는 사용자 경험은 그대로 두고 서버 쪽 DB 비용만 깎는 선택이다.
사진 — 이미지 최적화를 직접 만든 이유
사진은 Supabase Storage에 저장되는데, 요청이 올 때마다 Storage까지 다녀오면 느리고 비싸다. 보통은 Vercel의 이미지 최적화 기능이 이 문제를 대신 풀어 준다.
하지만 이 앱은 Vercel free 플랜으로 배포한다. 유저가 적은 지금 단계에서 이미지 최적화는 비용 대비 효과가 안 맞았다. 그래서 그 자리를 직접 만든 캐시로 메웠다. 유저가 늘어 유료 플랜으로 올라가면 걷어낼 과도기 장치라는 점을 분명히 해 둔다.
직접 만든 캐시는 서버 프로세스마다 두는 LRU(Least Recently Used — 가장 오래 안 쓴 항목부터 버리는 방식, 최대 500개/50MB)다. 동작은 세 겹의 안전망으로 정리된다.
- 캐시 적중 — 이미 담아 둔 사진이면 Storage를 건너뛰고 바로 응답한다.
- 동시 요청 합치기 — 같은 사진에 요청이 한꺼번에 몰려도 Storage 조회는 한 번으로 묶는다.
- 최종 폴백 — 캐시에도 없고 Storage 읽기도 실패하면 signed URL(서명된 임시 접근 링크)로 307 리다이렉트한다. 최악의 경우에도 사진이 안 뜨는 일은 없다.
이 LRU 역시 SSOT가 아니다. 서버가 재시작되면 사라져도 그만인 성능 최적화일 뿐이다.
쓰기 — 내 행동은 즉시 보인다
좋아요를 눌렀는데 서버 응답이 올 때까지 화면이 안 바뀌면 답답하다. 그래서 낙관적 업데이트(optimistic update — 서버 응답을 기다리지 않고 화면부터 바꾸는 기법)를 쓴다:
- 버튼을 누르는 순간 캐시를 불변 패치(원본을 고치지 않고 새 사본을 만드는 방식)로 즉시 수정한다. 이 패치 로직은 순수 함수로 분리해 경계값 테스트를 먼저 썼다.
- 서버 요청이 실패하면 미리 찍어 둔 스냅샷으로 롤백한다.
- 요청이 끝나면 이 변경이 영향을 주는 화면 범위(상세만/피드까지/전체 목록)에 맞춰 캐시를 무효화한다 — 필요한 만큼만 다시 불러온다.
결과적으로 내 행동은 보고 있는 페이지에 즉시 반영되고, 남의 행동만 10초 창을 타고 들어온다.
아키텍처 — 교체 가능한 조각들
코드는 FSD(Feature-Sliced Design) 규칙을 따른다. app → views → widgets → features → entities → shared 방향으로만 참조할 수 있고, 각 조각은 index.ts로 공개한 것만 밖에 보여준다. 화면·기능·도메인이 한 방향으로만 의존하니 수정 범위가 예측 가능해진다.
데이터 접근은 repository 패턴으로 감쌌다. UI는 "고양이 저장소" 인터페이스만 알고, 실제 앱에는 HTTP 구현을 주입한다. 테스트·데모에서는 MSW(브라우저에서 API를 가짜로 응답해 주는 도구)와 IndexedDB 구현으로 바꿔 끼운다 — 덕분에 데모 모드는 Supabase 없이 전체 플로우가 돈다. 낙관적 패치, 임베딩 가드, 좌표 스냅 같은 도메인 규칙은 전부 순수 함수로 두고 테스트를 먼저 쓴다.
아직 넣지 않은 것 — 그리고 왜
- 폴링 없음. 주기적으로 서버에 물어보는 폴링은 트래픽 절감이라는 목표에 정면으로 역행한다.
- Realtime 없음. 실시간 소켓은 "더 신선하게"의 도구지 "더 싸게"의 도구가 아니다. 필요가 확인되면 그때 붙인다.
- 오프라인에서 이전 데이터 보여주기 없음. 서비스 워커는 앱 셸(껍데기 화면)만 캐싱하고, 오프라인 상태의 데이터 요청은 명시적인 오류 상태로 보여준다. 옛 데이터를 진짜처럼 보여주는 것보다 "지금 오프라인"이라고 정직하게 말하는 쪽이 "서버가 SSOT"라는 계약과 맞다.