---
type: note
kind: Plan
title: 서랍 인벤토리 앱 — 조건 0 촬영과 커버리지 경계
description: 부분 DB 가 거짓말하지 않으려면 전수 등록이 아니라 전수 등록된 경계를 알아야 한다. 아직 구상.
tags: ['idea', 'inventory', 'react-native', 'database-design', 'llm']
status: release
ctime: 2026-09-02
mtime: 2026-10-02
generated: { by: claude/opus-5, at: 2026-09-02T00:00:00Z }
sources:
  - id: homebox-issue-251
    resource: https://github.com/sysadminsmedia/homebox/issues/251
    title: 'Merge Locations/Items Into a Single "Entity" type'
  - id: homebox-issue-52
    resource: https://github.com/sysadminsmedia/homebox/issues/52
    title: 'Add Pictures to Locations'
  - id: homebox-issue-207
    resource: https://github.com/sysadminsmedia/homebox/issues/207
    title: 'large number of items cause performance issues'
  - id: homebox-v0-26-0
    resource: https://homebox.software/en/changelog/version/v0-26-0/
    title: 'Homebox v0.26.0 — the entity merge'
  - id: sortly-interior-designers
    resource: https://www.sortly.com/blog/4-ways-interior-designers-use-sortly/
    title: '4 Ways Interior Designers Use Sortly'
  - id: snapfind
    resource: https://www.snapfind.app/
    title: 'SnapFind — AI Home Inventory App'
  - id: shelf-nu-audit
    resource: https://www.shelf.nu/solutions/mobile-asset-auditing
    title: 'Shelf — Mobile Asset Audit Software'
---

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

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

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

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

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

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

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

## 단일 노드 트리

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

```ts
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](https://github.com/sysadminsmedia/homebox/issues/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](https://github.com/sysadminsmedia/homebox/issues/52)(👍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장 상한으로 증상만 막는다).

```ts
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 는 없다.](/svg/drawer_inventory_model.svg)

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

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

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

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

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

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

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

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

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

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

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

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

## 스택 — Expo/Android, 로컬 우선

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

```ts
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

```text
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 재현율** — 내 서랍 사진으로 실측하기 전까지 파이프라인 전체가 가설이다.
