서랍 인벤토리 앱 — 조건 0 촬영과 커버리지 경계

서랍·수납함에 규칙 없이 물건을 넣는 습관. 눈에 안 보여서 좋지만 찾을 때 난감하다. 조건 0 짜리 인시투 촬영으로 항상 동작하는 탐색 앱을 만들고, 아이템 DB 는 조건이 우연히 갖춰졌을 때 사진에서 파생되는 잉여로 둔다. 아직 만들지 않았고, 여기 적는 건 도착한 결론과 버린 갈래다 — 버린 이유를 지우면 6개월 뒤 같은 우회를 반복한다.

서랍을 열고 조건 없이 1~3장 찍는다. 사진은 아이템이 아니라 컨테이너에 붙는 View 이고, 재촬영은 같은 파일을 교체한다. VLM(API 직접 호출)이 사진에서 아이템 노드를 파생한다. 그래서 셔터 1회가 그 서랍의 모든 아이템 재검증이 된다. 재파생에서 사진에서 사라진 아이템은 confirmed false 면 폐기, 사람이 만진 true 면 ’최근 사진에서 안 보임’으로 표시한다. 장소·서랍·파우치·케이블은 모두 parentId 하나로 잇는 같은 노드다 — 파우치는 아이템이면서 컨테이너다. 검색 결과가 없을 때 itemsComplete 가 true 면 물건이 없다는 뜻이고, false 면 모른다는 뜻이라 사진을 본다.

∅ 모호성이 이 카테고리를 죽인다

등록률이 100% 가 아니면 검색 결과 없음이 두 가지를 동시에 뜻한다.

∅ = "DB 에 없다" ∪ "물건이 없다"

이 둘이 구분 안 되면 부정 조회의 정보량이 0 으로 수렴하고, 결국 서랍을 다 뒤집는다. 절반 등록된 DB 는 없느니만 못하다. 그래서 해법은 “정확성”이 아니라 불확실성의 명시다 — 컨테이너마다 itemsComplete 를 두어 전수 등록된 곳을 구분한다. 전수 등록이 목표가 아니라 전수 등록된 영역의 경계를 아는 것이 목표다.

입력 비용(매번·즉각·무보상)이 검색 이득(가끔·지연)을 넘는 비대칭도 실패 모드지만, “2주 쓰고 죽는다” 같은 행동 예측은 근거가 아니다. Homebox 이슈 #207 은 수천 개를 등록해 목록 페이지가 느려진 사용자의 보고다. 예측 대신 시스템 속성으로 말해야 한다.

단일 노드 트리

파우치는 서랍 안의 아이템이면서 케이블의 컨테이너다. 지퍼백도 상자 속 상자도 같다. 3-테이블(places/containers/items)은 이 관계를 표현하지 못한다.

interface Node {
  id: string
  parentId: string | null // null = 최상위(장소)
  kind: 'place' | 'container' | 'item' // UI 힌트일 뿐, 제약 아님
  alias: string // "책상 2번 서랍" — 사용자 언어 그대로
  faceViewId?: string // 컨테이너 커버로 쓸 View
  tagIds: string[] // 스키마만, 초기엔 UI 없음

  derivedFrom?: { viewId: string; blobHash: string } // 아이템 노드 전용
  confirmed: boolean // 사람이 만졌음 → 재파생이 못 덮는다
  lastSeenBlobHash?: string

  itemsComplete: boolean // 이 컨테이너가 전수 등록되었는가
  updatedAt: number
}

이동은 parentId 하나만 바꾸는 일이 된다. kind 에 “아이템은 컨테이너가 될 수 없다” 같은 제약을 넣는 순간 파우치에서 무너지므로 의미론일 뿐 구조 제약이 아니다.

Homebox 가 이 모델로 옮겨왔다. items/locations 분리로 시작해 v0.26.0(2026-06-13)에서 단일 entity 로 병합했고, /v1/items*·/v1/locations* 가 /v1/entities* 로 통째 교체되는 breaking change 였다. 제안 이슈 #251 의 문장이 이 스키마 그대로다:

use only one table for everything and add a type column (item or location for now) + a parent column.

그 이슈가 “이걸로 풀린다”고 나열한 이슈가 일곱이고, 그중 하나가 #52 Add Pictures to Locations(👍8)다 — 2025-01 에 #251 로 통합되며 닫혔다. 성숙한 OSS 가 몇 년을 돌아 도착한 지점이면 2-테이블로 출발할 이유가 없다.

촬영은 조건 0 이 기본

앱이 촬영 조건을 요구하면 안 된다. 온보딩에 “서랍을 비우세요”가 있으면 안 된다. 조건이 얕은 모드가 기본이고, 무거운 모드는 조건이 우연히 갖춰졌을 때만 노출한다.

모드 요구 조건 셔터 occlusion 위치
인시투 다중샷 없음 1~3 휘저어 해소 항상, 기본
플랫레이 서랍 비움 + 평면 + 조명 1 없음 조건 생긴 날만
아이템 단위 위 + N회 N 없음 채택 안 함

서랍 열고 찰칵, 손으로 휘젓고 찰칵. 뒤적임 자체가 de-occlusion 이다 — 물건이 움직이면 밑에 깔린 게 드러난다. 언제든 중단할 수 있고(2장이어도 1장보다 낫다) 각 장은 그냥 View 다. 대가는 좌표 상실인데, 잡동사니 서랍은 애초에 공간 기억이 안 붙는 대상이라 잃는 게 적다.

플랫레이는 이사·대청소처럼 조건이 우연히 갖춰진 날만 [전수 등록] 버튼으로 노출하고, 누르면 그 컨테이너가 itemsComplete: true 가 된다.

아이템 단위 촬영은 채택하지 않았다. 물건을 꺼내지 않고 개별 촬영할 방법이 없어 플랫레이의 모든 조건을 요구하면서 셔터를 N배로 얹는다. 결정적으로 재감사 비용이 최초 등록과 같아진다 — 그러면 사진이 굳고, 굳은 사진은 탐색 전용 앱에서 유일한 진실이 낡았다는 뜻이다.

사진 = View, append 가 아니라 replace

snapshots: string[] 이면 재촬영이 append 가 되고 같은 서랍 사진 5장 중 뭐가 현재인지 알 수 없게 된다(Sortly 는 아이템·폴더당 8장 상한으로 증상만 막는다).

interface View {
  id: string
  nodeId: string
  label: string // "1"/"2"/"3" 또는 "인시투"/"전수"
  shotAt: number // lastVerifiedAt 을 이 필드가 흡수
  // uri 는 파생: `${documentDirectory}views/${id}.jpg`
}

Node 는 parentId 로 자기 자신을 가리키는 단일 트리다(null 이면 최상위 장소). kind 는 UI 힌트일 뿐 제약이 아니다. View 는 nodeId 로 Node 에 붙고(한 노드에 여럿), Node 는 faceViewId 로 그중 하나를 커버로, 아이템이면 derivedFrom(viewId·blobHash)으로 자기를 파생한 View 를 가리킨다. confirmed 는 사람이 만졌다는 뜻이라 재파생이 덮지 못한다. View 의 uri 는 저장하지 않고 id 로 조합하며, 그 경로의 blob 은 재촬영 때 덮어쓴다. Tag 는 mergedInto 로 자기 자신을 가리켜 병합을 비파괴로 하고, Node 는 tagIds 로 원본 Tag 를 유지한다 — 초기엔 스키마만 있고 UI 는 없다.

View 는 지속되고 blob 만 교체된다. 재촬영 = 같은 파일 덮어쓰기. shotAt 이 신선도인데, 노드 전체가 아니라 “이 층 사진이 3개월 전”이 맞는 단위다. 이력이 필요하면 나중에 얹되 현재 상태 조회에 이력이 섞이면 안 된다.

여기가 이 설계의 수확이다. lastVerifiedAt 은 갱신 수단이 사람의 성실성뿐이라 계속 문제였는데, 인시투 사진 한 장 재촬영이 그 서랍 전체의 전수 감사다.

셔터 1회 → 서랍 1개의 모든 아이템 재검증

사진이 아이템이 아니라 컨테이너에 붙기 때문에 가능하다. Shelf.nu 는 감사에 QR 스캔을 요구하고, Sortly 는 이동을 사람이 로깅해야 한다. 컨테이너 스냅샷은 아이템 DB 의 대안이 아니라 아이템 DB 를 안 썩게 하는 메커니즘이었다.

아이템 DB 는 사진에서 파생된다

초기에 “정확한 아이템 DB(비용 높음) vs. 스냅샷 사진첩(비용 0)“으로 갈랐는데 틀린 이분법이다. SnapFind 가 반증한다 — 통·서랍·선반을 찍으면 AI 가 아이템을 식별하고 라벨을 붙인다(다만 컨테이너에 QR 라벨을 함께 쓴다).

사진      = 캡처 표면
아이템 DB = 파생 산출물
VLM       = 그 사이의 변환

타이핑 비용을 피하는 법은 “아이템을 포기하기”가 아니라 타이핑을 기계에 넘기기다.

재파생에서 유일하게 어려운 건 provenance 다. confirmed: false 인데 새 스냅샷에 없으면 조용히 폐기하고, confirmed: true 면 폐기하지 않고 “최근 사진에서 안 보임” 플래그를 단다. 사람이 고친 이름을 재파생이 덮어쓰지 않는다.

VLM 실행 위치는 무서버 전제를 재검토하게 만든다. 온디바이스는 번들 크기·속도로 현실성이 낮고, API 직접 호출이면 사진은 로컬에 두고 추론만 왕복하므로 스토리지 서버는 여전히 불필요하다(키를 앱에 박는 게 대가). 단일 사용자·본인 기기·본인 키면 실용적이다.

모델 선택 전에 측정한다. 내 서랍 사진 1장을 후보 모델 2~3개에 던져 재현율을 센다(내용물을 아는 상태에서 몇 개 맞히는지). 여기서 안 나오면 파이프라인 전체가 무의미하다. 스펙시트가 아니라 실측이 결정한다.

검색은 아이템 레코드가 텍스트로 존재하는 순간 SQLite FTS5 로 충분하다 — 초기의 임베딩·타일링 논의는 “아이템 레코드가 없다”는 잘못된 전제의 산물이었다. 단 “탐색만”은 스키마 결정이 아니라 UI 결정이다. 검색창을 안 만들어도 레코드는 쌓아둔다(없으면 소급 생성 불가 — 사진이 지워졌거나 서랍이 바뀐다).

스택 — Expo/Android, 로컬 우선

RN 을 고르는 유일한 근거는 렌더링이다. 캡처는 PWA 도 되지만 이 앱의 전부가 고해상도 이미지 그리드 + 핀치줌이고 그게 RN 의 최대 약점(이미지 메모리)이라 제어권이 필요하다. 기본 Image 대신 expo-image, 그리드는 썸네일만, 줌은 Reanimated.

const photo = await camera.takePictureAsync()
// photo.uri → cacheDirectory/... ← OS 가 저장공간 압박 시 조용히 비운다
await FileSystem.moveAsync({
  from: photo.uri,
  to: `${FileSystem.documentDirectory}views/${viewId}.jpg`,
})
  • 캡처 직후 즉시 이동. 이걸 놓치면 몇 주 뒤 사진이 사라지고, 그게 원래 문제(“못 찾음”)로 그대로 되돌아간다.
  • URI 를 DB 에 저장하지 않는다. 앱 업데이트로 컨테이너 경로가 바뀌면 절대경로가 깨진다. id 에서 매번 조합한다.
  • 저장은 AsyncStorage 에 { nodes, views } JSON 통째로. 노드 1,000개를 넘으면 SQLite 로 이전한다.
  • 백업이 0 이다. Android Auto Backup 은 앱당 25MB 라 원본 사진은 사실상 안 들어가고, documentDirectory 삭제 = 전부 소멸. 탈출구는 사진 + meta.json 을 StorageAccessFramework 로 내보내는 함수 하나(20~30줄)다. 넣지 않으면 재설치 한 번에 사라진다는 걸 알고 시작할 것.

합성(몽타주)은 저장 포맷이 아니라 export 포맷

여러 사진을 한 장으로 합성해 지도 타일처럼 탐색하려는 아이디어는 기술적으로 가능하지만, 합성 순간 개별 접근·재배열·추가·삭제를 비가역적으로 잃는다(레이아웃 엔진이 공짜로 주는 것). 배치가 임의라 실제 서랍 좌표와 무관하므로 “지도”의 공간 기억도 안 붙는다.

타일링 직관 자체는 맞고 대상이 틀렸다 — 고해상도 서랍 사진 1장에 딥줌을 걸면 그게 진짜 지도다(실제 좌표 유지, 셔터 1회, 합성 불필요).

합성이 유일하게 이기는 곳은 앱 밖이다: 서랍당 몽타주 1장을 인쇄해 서랍 겉면에 붙인다. 앱도 폰도 없이, 열지 않고 내용을 본다 — 원래 문제를 종이 한 장이 앱보다 직접적으로 푼다. 그러니 원본은 개별로 유지하고 몽타주는 요청 시 렌더하는 export 로 둔다. 원하는 게 “붙이는 물건”이면 이건 RN 앱보다 스크립트에 가깝다.

V0

1. 서랍 열고 찰칵. 휘젓고 찰칵. (1~3장, 조건 0)
2. 그리드 → 탭 → 핀치줌
3. itemsComplete: false (전수 등록 전까지)
4. [전수 등록] — 조건 생긴 날에만. 안 눌러도 앱은 완전하다.
5. [내보내기] — 사진 + meta.json 을 사용자 폴더로

합성 없음 · 검색창 없음 · VLM 없음(나중에 파생) · 태그 UI 없음

부분 아이템 DB 는 영구히 부분적이다(비워진 적 있는 서랍만 아이템을 갖는다). 그래도 거짓말을 안 하는 이유는 itemsComplete 하나다.

첫 스파이크는 캡처 → documentDirectory 이동 → 썸네일 → 그리드 → 핀치줌. 이게 되면 나머지는 JSON CRUD 고, 여기서 메모리·줌 성능이 안 나오면 RN 선택 자체를 다시 본다.

버린 갈래 (재방문 방지)

버린 것 이유 대체
물리 태그(NFC/QR) 부착·구매·프린트 진입장벽 얼굴 사진 + 별칭 + 플랫 그리드
3-테이블(place/container/item) 파우치·상자속상자 표현 불가 단일 노드 + parentId
materialized path 저장 동기화 시 파생값 스큐 parentId 만 정본, path 는 클라 계산
이미지 임베딩 검색 / 공간 피라미드 “아이템 레코드 없음” 오전제 레코드가 있으면 FTS5
pgvector·벡터DB 스케일 과대평가(수천 벡터면 브루트포스) 불필요
서버 동기화(Supabase·CloudKit) 단일 사용자·로컬 우선, iOS 장비 없음 AsyncStorage + SAF export
아이템 단위 촬영 재감사 비용 = 최초 등록(셔터 N) 인시투 다중샷(조건 0)
합성 = 저장/탐색 포맷 개별 접근·재배열·좌표 상실 라이브 그리드, 합성은 export 만
태그 UI(초기) 컨테이너 15개는 한 화면이라 필터 값 낮음 tagIds 는 스키마에만. 그리드가 한 화면을 넘을 때 구현
“2주 쓰고 죽는다” 행동 예측 수천 개 등록 사례가 반례(Homebox #207) 시스템 속성(∅ 모호성)으로 논증

태그를 나중에 붙일 때 병합은 비파괴여야 한다 — tags: string[] 문자열 치환은 사후 병합과 양립 불가다(비가역 + 동기화 충돌). Tag { id, label, mergedInto } 로 두고 노드는 원본 tagId 를 유지, 조회 시 별칭 체인을 정규형으로 해석한다. 규모 감각은 Sortly 가 준다 — 인테리어 고객이 평균 166 폴더·1,285 아이템을 쓴다. 태그는 그 규모에서 일한다.

VLM 이 이름을 붙이면 어휘가 기계 언어(USB-C cable)로 통일되는데 검색은 인간 언어(충전선)로 한다. 어휘 붕괴가 사라지는 게 아니라 인간↔기계 어휘 간극으로 형태가 바뀐다. 태그가 그 브리지고, 붙이는 주체가 VLM 이라 폐쇄 어휘 강제가 공짜다.

미해결 (실사용으로만 검증 가능)

  • 검색 없는 탐색 전용이 성립하는가 — 상용은 전부 검색을 전제한다. 통찰인지 누락인지 지금 판단 불가. 레코드는 쌓아두므로 소급 추가는 가능하다.
  • itemsComplete 커버리지 UX — ∅ 모호성이 실사용에서 실제로 문제가 되는지 미검증.
  • View 교체 의미론 — 스키마로 명시한 선례를 못 찾았다. SnapFind 가 동작상 가깝지만 확인한 범위에서는 층위 개념이 없다.
  • VLM 재현율 — 내 서랍 사진으로 실측하기 전까지 파이프라인 전체가 가설이다.
#596