| name | dream-journal-data-modeling |
| user-invocable | false |
| description | 꿈 일기 PWA를 위한 데이터 모델 설계 스킬. Dream·Interpretation·Symbol·Tag 엔티티 정의, Dexie 스키마(`++id`·`&unique`·`*multi-entry`·`[a+b] compound`), `.upgrade()` 마이그레이션, 최근 N일·태그·키워드 쿼리 패턴(IndexedDB 한계 → Fuse.js/MiniSearch 결합), Blob 첨부와 외부 스토리지 비교, 통계 모델(빈도·월별·감정 분포), 백엔드 동기화(LWW·CRDT) 선택, 꿈 내용 *민감 정보* 처리(libsodium 권장), export/import JSON dump. 짝 스킬 `frontend/indexeddb-dexie`는 Dexie 사용법 자체를 다루고, 본 스킬은 꿈 일기 도메인 데이터 모델 설계 결정을 다룬다. |
dream-journal-data-modeling — 꿈 일기 앱 데이터 모델링
소스:
검증일: 2026-05-14
짝 스킬 안내: frontend/indexeddb-dexie는 Dexie API 사용법 자체(스키마·쿼리·트랜잭션·useLiveQuery)를 다룬다. 본 스킬은 꿈 일기 도메인 데이터 모델 설계 결정(엔티티 분할·인덱스 선택·동기화 전략·민감 정보 처리)을 다룬다. 함께 참조하면 좋다.
언제 사용하나
- 꿈 일기/드림 저널 PWA 또는 로컬 우선(local-first) 앱을 처음부터 설계할 때
- 기존 꿈 일기 앱에 해몽·상징 사전·통계·동기화 기능을 추가할 때
- 꿈 내용을 어디까지 로컬로 두고 어디부터 클라우드로 보낼지 결정할 때
- 단순 메모/일기 앱에서 꿈 도메인 특화 모델(꿈꾼 날짜 vs 작성 날짜, 다중 해몽, 상징 분류)로 확장할 때
언제 사용하지 않나
- 단순 텍스트 메모 1종만 저장 → LocalStorage 또는 단일 테이블이면 충분
- 실시간 협업 핵심(공동 꿈 분석 등) → 본 스킬의 LWW 권고로는 부족. Yjs·Automerge 같은 본격 CRDT 사용
- 서버 우선 설계(꿈 내용 즉시 LLM 분석 후 폐기) → 로컬 우선 모델 불필요
1. 엔티티 정의
꿈 일기 앱의 도메인 모델은 4개 엔티티로 구성한다. 꿈 내용은 민감 정보(섹션 8 참조)임을 전제로 모델을 설계한다.
1.1 Dream (꿈)
interface Dream {
id?: number
title: string
content: string
dreamedAt: Date
createdAt: Date
updatedAt: Date
mood?: Mood
tags: string[]
imageBlobs?: Blob[]
audioBlob?: Blob
archived: boolean
syncVersion?: number
lastSyncedAt?: Date
}
type Mood =
| 'joy' | 'fear' | 'sadness' | 'anger' | 'surprise'
| 'disgust' | 'neutral' | 'mixed'
핵심 설계 결정:
| 결정 | 이유 |
|---|
dreamedAt과 createdAt 분리 | 꿈은 보통 다음 날 아침 기록. "어젯밤 꿈"을 검색하려면 꿈꾼 날 기준 인덱스가 필요 |
mood는 꿈 안의 감정 | 작성 시점 기분과 구분. 통계용 |
tags: string[] (별도 테이블 외래키 X) | multi-entry 인덱스로 충분. 빈도 통계는 Tag 테이블 보조용 |
archived 필드 | 삭제 대신 보관. [archived+dreamedAt] 복합 인덱스로 활성 꿈만 정렬 |
주의 (민감 정보): content는 평문으로 IndexedDB에 저장하면 디스크에 평문 노출된다. 동기화 안 하더라도 공유 PC·확장 프로그램 접근 위험이 있으므로 사용자 설정으로 애플리케이션 레벨 암호화 옵션을 제공하라 (섹션 8).
1.2 Interpretation (해몽)
같은 꿈에 여러 해몽이 붙을 수 있다. 1:N.
interface Interpretation {
id?: number
dreamId: number
source: InterpretationSource
content: string
prompt?: string
model?: string
createdAt: Date
pinned?: boolean
}
type InterpretationSource =
| 'traditional'
| 'jung-freud'
| 'llm'
| 'user'
왜 별도 테이블인가:
- 같은 꿈에 여러 출처 해몽을 동시 보관(전통 + LLM + 사용자 메모)
- 해몽만 삭제·재생성 가능
- LLM 호출 비용·재현성 추적(
prompt, model) 필요
1.3 Symbol (꿈 상징) — 선택적 사전
상징 사전을 자체 데이터로 운영할 때만 추가. 외부 API만 호출한다면 생략 가능.
interface Symbol {
id?: number
name: string
category: SymbolCategory
meaningTraditional?: string
meaningPsychological?: string
relatedSymbolIds?: number[]
}
type SymbolCategory =
| 'nature'
| 'animal'
| 'person'
| 'action'
| 'object'
| 'place'
| 'other'
1.4 Tag (태그) — 빈도 통계 보조
interface Tag {
id?: number
name: string
color?: string
count: number
}
왜 별도 테이블인가:
Dream.tags: string[]만으로는 빈도 집계가 O(N) 스캔 필요
Tag.count를 미리 유지하면 워드클라우드·자동완성 O(1) 접근
- 단점: Dream CRUD 시 트랜잭션으로
count 동기화 부담 → 섹션 6 참조
2. Dexie 스키마
import Dexie, { Table } from 'dexie'
export class DreamJournalDB extends Dexie {
dreams!: Table<Dream, number>
interpretations!: Table<Interpretation, number>
symbols!: Table<Symbol, number>
tags!: Table<Tag, number>
constructor() {
super('DreamJournalDB')
this.version(1).stores({
dreams: '++id, dreamedAt, createdAt, *tags, [archived+dreamedAt], mood',
interpretations: '++id, dreamId, source, createdAt, [dreamId+source]',
symbols: '++id, &name, category',
tags: '++id, &name, count',
})
}
}
export const db = new DreamJournalDB()
인덱스 선택 근거
| 인덱스 | 용도 | 비고 |
|---|
dreams.dreamedAt | "최근 30일 꿈" 범위 쿼리 | where('dreamedAt').above(...) |
dreams.*tags | 태그별 필터 | multi-entry, .distinct() 권장 |
dreams.[archived+dreamedAt] | "보관 안 한 꿈만 날짜순" | archived=false만 효율 정렬 |
dreams.mood | 감정별 통계 | where('mood').equals('fear') |
interpretations.dreamId | 꿈 1개의 모든 해몽 조회 | |
interpretations.[dreamId+source] | "이 꿈의 LLM 해몽" 효율 조회 | |
symbols.&name | 상징 이름 중복 방지 + 사전 조회 | unique |
tags.&name | 태그 이름 중복 방지 | unique |
tags.count | "많이 쓰인 태그 TOP 20" 정렬 | orderBy('count').reverse() |
인덱스 가능 타입 (Dexie 공식): number, Date, string, ArrayBuffer, TypedArray, 이들의 배열. boolean·Blob·Object·null은 인덱싱 불가.
그래서 archived: boolean은 값은 boolean이라도 인덱스 키로 쓰려면 0/1로 저장하거나 복합 인덱스에서 우회한다. 위 스키마의 [archived+dreamedAt]은 boolean을 키 일부로 쓰지만 복합 키 안에서는 정렬 가능한 타입으로 변환되어 인덱싱된다 — 동작은 하나, 정밀한 경우 archivedFlag: 0 | 1로 명시 전환하는 편이 안전하다.
소스: https://dexie.org/docs/Indexable-Type
3. 마이그레이션 패턴
Dexie의 db.version(N).upgrade()로 데이터 변환을 수행한다.
3.1 기본 패턴: 필드 추가 + 기본값 부여
this.version(1).stores({
dreams: '++id, dreamedAt, createdAt, *tags',
})
this.version(2).stores({
dreams: '++id, dreamedAt, createdAt, *tags, [archived+dreamedAt], mood',
}).upgrade((trans) => {
return trans.table('dreams').toCollection().modify((dream) => {
if (dream.archived === undefined) dream.archived = false
if (dream.mood === undefined) dream.mood = 'neutral'
})
})
3.2 엔티티 분리: 인라인 → 별도 테이블
초기에 Dream.interpretation: string 단일 필드로 시작했다가 Interpretation 테이블로 분리하는 흔한 케이스.
this.version(3).stores({
dreams: '++id, dreamedAt, createdAt, *tags, [archived+dreamedAt], mood',
interpretations: '++id, dreamId, source, createdAt',
}).upgrade(async (trans) => {
const dreams = trans.table('dreams')
const interpretations = trans.table('interpretations')
await dreams.toCollection().modify(async (dream, ref) => {
if (dream.interpretation && typeof dream.interpretation === 'string') {
await interpretations.add({
dreamId: dream.id,
source: 'user',
content: dream.interpretation,
createdAt: dream.createdAt ?? new Date(),
})
delete dream.interpretation
}
})
})
주의: modify() 콜백 안에서 외부 await은 가능하지만 다른 transaction을 새로 열면 abort된다. 같은 trans의 다른 테이블에는 접근 가능.
소스: https://dexie.org/docs/Version/Version.upgrade()
3.3 모든 버전에 모든 테이블 명시 권장
.stores()는 그 버전에서의 전체 정의다. 한 버전에서 누락하면 삭제로 간주된다. 가능하면 매 버전 모든 테이블을 적어 안전성을 확보한다.
4. 쿼리 패턴
4.1 최근 N일 꿈
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const recent = await db.dreams
.where('dreamedAt')
.above(thirtyDaysAgo)
.reverse()
.sortBy('dreamedAt')
4.2 보관 안 한 꿈만 날짜순
const active = await db.dreams
.where('[archived+dreamedAt]')
.between(
[false, Dexie.minKey],
[false, Dexie.maxKey],
true,
true
)
.reverse()
.toArray()
Dexie.minKey / Dexie.maxKey는 모든 키 범위를 의미한다. 복합 인덱스 부분 매치에 사용.
소스: https://dexie.org/docs/Indexable-Type
4.3 태그별 꿈 (multi-entry)
const water = await db.dreams
.where('tags')
.equals('물')
.distinct()
.toArray()
const dreamy = await db.dreams
.where('tags')
.anyOf(['물', '뱀', '도망'])
.distinct()
.toArray()
distinct() 권장 이유: multi-entry 인덱스에 같은 record가 여러 키로 들어가 있으면 결과에 중복으로 나올 수 있다.
소스: https://dexie.org/docs/MultiEntry-Index, https://dexie.org/docs/Collection/Collection.distinct().html
4.4 키워드 검색 — IndexedDB 한계 + 외부 검색 결합
IndexedDB는 전문 검색(full-text search)을 지원하지 않는다. where('content').startsWith('...') 는 prefix 검색만 가능하다. 단어 단위·오타 허용 검색이 필요하면 다음 라이브러리를 결합한다.
옵션 A: Fuse.js — 퍼지 검색, zero deps, ~6.5KB gzip
import Fuse from 'fuse.js'
const dreams = await db.dreams.toArray()
const fuse = new Fuse(dreams, {
keys: ['title', 'content', 'tags'],
threshold: 0.3,
includeScore: true,
})
const results = fuse.search('뱀')
옵션 B: MiniSearch — 인메모리 풀텍스트 엔진, 한국어도 token 분할 가능
import MiniSearch from 'minisearch'
const mini = new MiniSearch({
fields: ['title', 'content', 'tags'],
storeFields: ['id', 'title'],
searchOptions: { boost: { title: 2 }, fuzzy: 0.2 },
})
const dreams = await db.dreams.toArray()
mini.addAll(dreams)
const results = mini.search('뱀 도망')
비교:
| 항목 | Fuse.js | MiniSearch |
|---|
| 알고리즘 | Bitap (퍼지) | 역색인(Inverted Index) + 퍼지 옵션 |
| 인덱스 빌드 | 메모리 즉시 | 메모리, addAll로 빌드 |
| 적합 규모 | 수백~수천 문서 | 수천~수만 문서 (역색인이라 더 효율) |
| 한국어 | token 분할 약함 (음절 단위 매칭 의존) | tokenizer 커스터마이즈 가능 |
| 결과 점수 | score (0=완전일치, 1=불일치) | relevance score |
권장: 꿈 수가 수백 개 이하인 일반 사용자는 Fuse.js, 수천 개 이상 또는 한국어 형태소 분석 결합이 필요하면 MiniSearch + 커스텀 tokenizer.
인덱스 영속화: 매 검색마다 dreams.toArray() + 빌드는 비효율. 앱 초기화 시 1회 빌드하고 in-memory 캐시 유지. Dream CRUD 후 mini.add/remove/replace로 incremental 갱신.
4.5 감정 분포 통계
const allDreams = await db.dreams.toArray()
const moodCount = allDreams.reduce<Record<string, number>>((acc, d) => {
acc[d.mood ?? 'neutral'] = (acc[d.mood ?? 'neutral'] ?? 0) + 1
return acc
}, {})
대규모일 경우 별도 통계 테이블에 미리 집계하는 materialized view 패턴 고려.
상세 레퍼런스 (예제·고급 패턴·흔한 실수) → references/REFERENCE.md