원칙 — 명확하고 구체적인 지시, 그리고 모델에게 생각할 시간 주기.

전술 — 구분자로 입력 경계 표시(```, “”“, < >, <tag> </tag>, :), 구조화된 출력 요청(JSON·HTML), 조건 충족 여부를 모델에게 확인시키기, few-shot 으로 스타일 고정.


https://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/

#267

에이전트 역할 분리 — 입력을 좁혀 성공 기준을 가른다

언제: 실패를 기계적으로 열거할 수 있고, 그걸 반복해서 고쳐야 할 때.

같은 모델이라도 입력이 다르면 무엇을 성공으로 보는지가 갈린다. 구현자는 원본과 자기 추론을 받아 머지되기를 원하고, 리뷰어는 diff 만 받고 이 코드는 틀렸다고 가정하라는 지시를 받아 버그를 찾기를 원한다. 그래서 입력 자체를 잘라야 한다. 64개 병렬은 결과지 원인이 아니다 — 일을 성립시킨 건 컨텍스트 비대칭, 기계적으로 생성되는 큐, 언어를 바꿔도 통하는 합격 판정 기준(테스트 오라클) 셋이다. 판정 기준이 없으면 여기서 멈춘다: Bun 의 Rust 포팅에는 4,174개 파일 6만여 개 expect() 의 TypeScript 테스트가 있었고 스킵·삭제가 0개였다. 없으면 리뷰어를 둘 붙여도 검증되지 않은 코드를 빠르게 생산할 뿐이라, 실패를 열거 가능한 상태(타입 에러 목록·스냅샷·시각 회귀)를 먼저 만든다.

한꺼번에 세우지 않는다. 1 adversarial-reviewer 하나만 만들어 memory: project 로 몇 주 돌린다 — 도구는 Read·Grep·Glob 뿐, diff 하나를 받아 틀린 이유만 찾고 최소 3개의 실패 시나리오를 낸다. 2 쌓인 지적 패턴이 거절 규칙 목록이 된다: 한 문단짜리 주석으로 우회를 정당화하면 코드가 틀린 것, 스텁·TODO·조건부 비활성화로 컴파일만 통과시킨 건 미완성, 테스트를 고쳐 통과시킨 변경은 무조건 거절. 3 그 뒤에 porter(구현, isolation: worktree)와 fixer(지적만 반영, 새 기능 금지)를 붙인다. porter 의 변경은 .work/<id>.diff 로 나가고 리뷰어는 diff 경로와 원본 경로만 받는다. 큐는 세션 밖에서 돈다 — 실패 목록을 파일로 만들고 claude -p 를 반복한다.

확인: 리뷰어가 tsc·eslint가 통과시키는 지점을 잡고 있는가. 그게 이 역할의 존재 가치 전부다 — effect 의존성의 의미 변화, 이벤트 핸들러의 eager/lazy, ??와 ||의 단축평가 차이, 리렌더 타이밍, 정리 함수 누락. 린터가 잡을 걸 잡고 있으면 프롬프트가 헐거운 것이다.

컨텍스트 비대칭은 저절로 유지되지 않는다 — 위임 프롬프트를 부모가 쓰므로 그냥 두면 구현 근거를 요약해 리뷰어에게 넘긴다. .work/<id>.diff 를 경유시키고 리뷰어에게는 diff 경로와 원본 경로만 준다는 걸 CLAUDE.md 규약으로 박는다. 역할 분리를 프롬프트로 하면 무너지니 리뷰어 tools 에서 Edit·Write 를 빼 권한으로 막는다. 같은 모델 둘은 실패 상관관계가 높아 리뷰어를 둘 둘 거면 하나는 model 을 다르게 준다. -p(비대화형)에서는 fork mode 가 꺼져 subagent 가 포그라운드로 도는 경우가 있어 병렬은 셸에서 잡는다. 병목은 모델이 아니라 디스크 IOPS 였다 — 느린 grep 하나가 디스크를 몇 분씩 얼렸다.

#579

표 중심 개발 — 게임 밸런스를 코드가 아니라 데이터로

언제 — 밸런스를 바꾸려는데 어디를 고쳐야 할지 몰라 매번 코드를 만질 때. 또는 LLM 이 콘텐츠를 대량으로 뽑아줬는데 손 검수가 불가능할 때. 워크드 예제는 무역 항해 게임(항구·교역품·발견물).

기반 네 단계는 순서대로다: 01 숫자를 Shape·Knob·Content 로 가르고, 02 Knob 표를 만들고 리터럴 금지 린트로 강제하고, 03 표는 ID 로 참조하고 파생값을 저장하지 않으며, 04 검증기가 표 확장 속도의 상한을 정한다. 측정 인프라 세 단계도 순서대로다: 05 시드 고정 RNG, 06 UI 없는 헤드리스 시뮬, 07 Random·Greedy·Optimizer 봇 셋의 결과가 갈려야 한다. 지표 넷은 순서 무관: 08 유의미한 선택지가 3 아래면 플레이어는 관객, 09 행동 분포 엔트로피가 1 bit 아래로 떨어지는 고착 시점, 10 무한 차익은 음수 사이클로 찾아 validate 에 넣고, 11 20 시드 중앙값 기준선과 비교해 15% 넘는 변화만 본다. 이것들이 표 수정 → validate → sim과 봇 → 지표 diff → 다시 표 수정의 루프를 닫고, 한 바퀴가 몇 분 안에 돌아야 한다. 언제든 쓰는 둘: 12 콘텐츠를 채우기 전에 24시간 안에 버릴 Shape 스파이크로 곡선 모양을 보고, 13 콘텐츠 대량 생산은 validate 통과를 완료 조건으로 건다.

진단 역인덱스

증상 의심 레시피
밸런스 고치려니 코드를 만진다 층 분리 실패 01, 02
튜닝 표는 있는데 아무도 안 만진다 측정 수단 없음 06, 11
후반이 지루하다 선택지 폭 붕괴 08, 09
돈이 갑자기 무한대가 된다 차익 사이클 10
표 고칠 때마다 뭔가 깨진다 파생값 중복 저장 03
LLM 데이터 오류를 못 찾는다 검증기 없이 생산 04, 13
시뮬 결과가 매번 다르다 RNG 미주입 05
좋아졌는지 나빠졌는지 모른다 기준선 없음 11
표는 완벽한데 재미가 없다 Shape 문제 12

01. 튜닝 가능 표면 파악

숫자 리터럴을 전수 조사한 뒤 하나씩 묻는다. 인스턴스마다 다른 값이면 Content — 그 개념의 표로. 아니면 0이나 무한대로 만들었을 때 다른 장르가 되는지 묻는다. 그렇다면 Shape — 코드에 남기고 docs/shape.md 에 적는다(최대 함대 4 는 1 이면 다른 게임). 아니면 Knob — data/tuning.ts 로(순풍 배수 1.3 은 0 이어도 항해 게임). docs/shape.md 자체가 산출물이다 — 없으면 표로 못 바꾸는 축을 몇 주간 튜닝한다. 애매하면 Shape 로 둔다: Shape 를 Knob 으로 내리는 건 쉽지만, Knob 으로 뺀 게 Shape 였다는 걸 알면 표 전체가 무의미해진 상태다.

rg -n --type ts '(?<![\w.])[0-9]+(\.[0-9]+)?(?![\w])' src/ \
  | rg -v 'test|spec|\.d\.ts' | rg -v '\b(0|1|-1|2)\b'

확인 — docs/shape.md 에 Shape 목록이 있다.

02·03. Knob 표 · 스키마 · 리터럴 금지 린트

Knob 은 data/tuning.ts 한 객체에 모은다. Content 표 PORTS 는 문자열 id 로 쓰고, 거기서 id 리터럴 유니온(PortId)을 타입으로 뽑아 ROUTES 같은 다른 표의 참조를 제약한다 — 없는 id 를 참조하면 타입 에러. 초기화에서 한 번 id → 인덱스 Map 을 만들고 런타임은 인덱스를 쓴다 — 표 중간에 행을 넣어도 안 깨진다. 좌표에서 계산되는 거리 같은 파생값은 표에 넣지 않고, 덮어써야 하면 이름에 드러낸다. 코드의 숫자 리터럴은 린트로 막고 data/ 는 예외다. 표 값을 지역 변수로 복사해 거기에 곱하면 린트가 못 잡으니 리뷰로 본다.

// eslint.config.js — data/ 하위는 예외
'no-magic-numbers': ['error', { ignore: [0, 1, -1], ignoreArrayIndexes: true, enforceConst: true }]

확인 — 아무 함수에 * 1.5 를 넣으면 CI 가 빨개진다. 표 중간에 행을 넣어도 아무것도 안 깨진다.

04. 검증기

모든 표가 validate 를 거친다. 규칙은 여섯: ID 유일성, 좌표 충돌, 해로 연결성(시작 항구에서 BFS 로 다 닿는가), 발견물 도달 가능성, 범위(basePrice > 0), 진행 가능성(초기 자금으로 흑자 경로가 하나는 있는가). 하나라도 어기면 exit 1. 확인은 일부러 깨 보는 것 — 항구를 육지 안쪽으로 옮기면 unreachable-port 가 떠야 하고, 모든 규칙은 한 번은 실패해 봐야 한다. 표를 추가하는 PR 에는 validate 규칙이 같이 온다.

확인 — 항구를 육지 안쪽으로 옮기면 unreachable-port 가 뜬다. 모든 규칙은 한 번은 실패해 봤다.

05·06. 시드 고정 RNG · 헤드리스 시뮬

헤드리스 시뮬은 UI 없이 한 턴을 돈다: state 에서 legalActions 로 합법 행동을 뽑고, bot 이 하나를 고르고, applyAction 과 advanceTurn 이 다음 state 를 만든다. 매 턴 gold·action·port·choiceCount 를 trace 에 남긴다. 난수는 전역 난수 대신 시드를 주입한 생성기 하나가 state 와 함께 다니고, 항해·시세·이벤트 스트림을 나눠 두면 한쪽을 고쳐도 다른 쪽 난수열이 안 밀린다. legalActions·applyAction 이 UI 와 분리된 순수 함수여야 이 루프를 만들 수 있다. 같은 시드면 같은 해시가 나와야 한다. 창발적 문제는 코드를 읽어서 안 나오고 수십 년치를 돌려야 나온다.

확인 — rg 'Math\.random' src/ 0건, 같은 시드 두 번의 결과 해시가 같다. 표 수정 → validate → sim → 지표 diff 한 바퀴가 몇 분 안이다.

07. 봇 3종

Random 은 합법 행동 중 균등 추출이라 여기서 안 죽으면 긴장감이 없다. Greedy 는 1스텝 기대이익 최대라 여기서 이기면 전략 깊이가 없다. Optimizer 는 빔서치나 3~5턴 룩어헤드로 지배 전략을 찾는다. Random 과 Greedy 가 비슷하면 플레이어 선택이 결과에 영향을 못 주고, Greedy 와 Optimizer 가 비슷하면 앞을 내다볼 이유가 없다. 셋의 결과가 뚜렷이 갈려야 한다. Optimizer 를 너무 강하게 만들면 게임이 아니라 봇을 튜닝하게 된다.

확인 — 세 봇의 결과가 뚜렷이 갈린다.

08·09. 선택지 폭 · 고착 시점

왼쪽: 매 턴 유의미한 행동 수(최선 대비 meaningfulRatio 이상 나오는 행동)를 그린다. 3 아래로 내려간 지점부터 플레이어는 관객이다. 세 시드의 곡선이 비슷하면 구조 문제, 제각각이면 초기 조건 문제. 오른쪽: Greedy 봇의 action·port 분포 엔트로피를 100턴 창으로 재고 1 bit 아래로 떨어진 턴이 고착 시점이다. 고착 자체보다 그때까지 본 콘텐츠 비율이 문제다 — 발견물 60% 에서 고착되면 나머지 40% 는 아무도 안 본다. 총 턴의 20% 지점에서 고착되면 게임의 80% 가 반복이다. 선택지 판정에 쓰는 estimateGain 은 Greedy 봇과 공유하므로 봇이 멍청하면 지표도 멍청하다. 실측 그래프가 아니라 개념도다.

확인 — 시드 셋의 선택지 곡선이 비슷하면 구조 문제, 제각각이면 초기 조건 문제.

10. 무한 차익 검출

항구를 꼭짓점, 교역 왕복을 간선으로 두면 무한 차익은 한 바퀴 돌았을 때 수익률을 곱한 값이 1 보다 큰 고리다. 간선 가중치를 -log(수익률) 로 바꾸면 곱이 1 보다 크다는 건 합이 0 보다 작다는 것이 되어, 음수 사이클을 찾는 Bellman-Ford 로 풀린다. 수익률에는 항해 일수·보급 소모·위험도를 반영해야 실제로는 손해인 루트가 잡히지 않는다. validate 에 넣되 완전 제거가 아니라 임계(예: 왕복 수익률 1.5배 초과)만 실패시킨다.

확인 — validate 에 들어가 있어 항구·교역품을 추가할 때마다 자동으로 걸린다.

11. 회귀 기준선

시드 20개로 시뮬을 돌려 지표마다 중앙값을 낸다. 커밋된 metrics/baseline.json 과 비교해 15% 넘게 변한 지표만 표시한다 — 예: lockInTurn 1840 → 620 (-66.3%), 의도한 변경인가. 기준선 갱신은 의도적으로만 한다. 자동 갱신하면 서서히 나빠지는 걸 못 본다. 단일 시드면 분산이 변화로 오인된다.

확인 — 튜닝 값 하나를 명백히 나쁜 방향으로 바꾸면 diff 에 잡힌다.

12·13. Shape 스파이크 · 콘텐츠 대량 생산

Shape 는 표로 못 고치므로 콘텐츠를 채우기 전에 스파이크로 본다: 항구 2개, 교역품 3종, 발견물 5개, UI 없이 sim 만, 24시간 안에 버릴 수 있어야 한다. 볼 것은 재미가 아니라 곡선 모양 — 선택지 폭이 0 으로 수렴하지 않는가, 자금 곡선이 지수가 아닌가, 봇 셋이 갈리는가. 통과하면 콘텐츠를 채우고, 못 하면 채우지 않는다. 대량 생산은 LLM 작업 단위를 표 하나 + 불변식 + 지표 하나로 두고, 완료 조건을 validate 통과로 건다. 그냥 두면 LLM 은 숫자를 코드에 박고, 02 의 린트가 유일하게 믿을 만한 방어선이다. CLAUDE.md 에는 숫자 리터럴 금지, validate 통과 의무, 파생값 저장 금지, docs/shape.md 항목은 먼저 묻기를 넣는다.

확인 — 스파이크(항구 2개)를 통과하기 전에는 콘텐츠를 채우지 않는다. 대량 생산은 validate 통과로 끝난다.

#585

서랍 인벤토리 앱 — 조건 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