⚠️ 시점 고정 초안 (2026-05-09) — follow 타임라인·Realtime·TanStack Query 시절 구상이 섞여 있다(아래 “V1 범위 정정” 박스 + DECISIONS 2026-05-12로 상당 부분 정정됨). 지금 동작하는 V1 동선의 단일 진실은
../app-scenarios.md+docs/design/screens/*.md. 이 문서는 초기 사고 흔적으로 보존.
버전: 0.2 (2026-05-09 — Flutter 스택 반영)
연계: architecture.md · client-architecture.md · api-design.md
스택 변경 이력: 0.1 RN+Expo+TS → 0.2 Flutter+Dart (라이브러리 표기 일괄 교체)
이 문서는 코딩 시점에 “이 플로우가 어떻게 작동해야 하는가”를 step-by-step으로 답하기 위함. 각 플로우는 화면·API·캐시·UI 상태를 동시에 추적.
⚠️ V1 범위 정정 (DECISIONS 2026-05-12 — 화면 설계 Phase B 반영). 이 문서는 0.2(2026-05-09) 시점 구상이라 follow/타임라인/Realtime이 V1처럼 적혀 있으나, V1에는 들어가지 않는다:
- Flow C(Timeline → 친구 카드 → 책 추가)·Flow E(친구 추가)·
timelineProvider·follows·useTimelineRealtime·publish to followers= 전부 V1.5. V1 코드에 넣지 말 것. follow 타임라인은 V1.5에 홈 피드(screens/home.md“내 인용 피드”)에 합쳐 진화. — 단 Flow C의 “deep link로 받은 책을 1탭으로 서재 담기” 부분만은 V1 유지(screens/deep-link-receive.md— 단톡방 1탭 공유의 받는 쪽). 받는 사람이 카드를 내 계정에 복제하는 “받은 카드 함”도 V1.5(received_cards테이블).- Flow B의
Supabase Realtime publish/invalidate(timelineProvider)= 제거. V1 카드 공유는 로컬 PNG + OS 공유 시트(screens/card-share.md). Realtime 상시 구독은 V2(DECISIONS 2026-05-10).- Flow A·B(인용 입력·카드 생성·공유)·D(책 검색·서재 추가)·F(오프라인 — V1은 경량 아웃박스, DECISIONS 2026-05-11)는 V1. 화면 단위 세부 설계는
docs/design/screens/*.md가 갱신본.- OCR: Flow B 4.3의 “iOS Live Text + 클립보드 붙여넣기”가 V1 방식으로 다시 유효(앱 내장 OCR 안 함 — DECISIONS 2026-05-11).
| # | 플로우 | 빈도 | 중요도 | V1? |
|---|---|---|---|---|
| A | 신규 가입 → 첫 인용구 저장 | 1회/사용자 | ★★★ Activation 핵심 | V1 |
| B | 인용구 추가 → 카드 → 공유 | 매일 | ★★★ 핵심 가치 | V1 (Realtime publish 부분 제거) |
| C | Timeline 진입 → 친구 카드 → 책 추가 | 매일 | ★★ 바이럴 메커닉 | V1.5 (단 deep link 받는 쪽 “1탭 서재 담기”만 V1) |
| D | 책 검색 → 서재 추가 | 주 1–2회 | ★★ | V1 |
| E | 친구 추가 (검색·카톡 매칭) | 가입 후 1주 집중 | ★ | V1.5 |
| F | 오프라인 인용구 작성 → 동기화 | 지하철 등 | ★★ 모바일 특수 | V1 (경량 아웃박스 — 완전 동기화 엔진은 V1.5) |
[User] = 사용자 행동
[App] = 클라이언트 앱
[API] = features/<X>/api.ts 함수
[Supabase] = 백엔드
[External] = 알라딘·Kakao 등 외부
목표 latency: 사용자 체감 기준
- <100ms: 즉각
- <300ms: 부드러움
- <1s: 허용 가능
- >1s: loading indicator 필수
목표: 다운로드 → 가입 → “이 앱 좀 쓸 만하네” 순간까지 5분 안에 도달.
성공 지표 (Activation): 가입 후 7일 내 인용구 3개 이상 저장 (목표 40%)
[User] 앱 다운로드 후 첫 실행
└─ App 시작
└─ [App] 세션 확인 (sessionNotifierProvider)
└─ session 없음 → go_router redirect → /auth/login
[User] login 화면 본다
└─ [App] 표시: "책귀" 로고 + 책 표지 hero + 카카오/이메일 버튼
[User] "카카오로 시작" 탭
└─ [Repository] authRepository.signInWithKakao()
└─ [App] flutter_web_auth_2.authenticate(kakaoAuthUrl)
└─ [Kakao] 사용자 인증·동의
└─ [App] redirect URL로 돌아옴 (quotesapp://auth/callback?code=...)
└─ [Supabase Auth] 세션 발급
└─ [App] sessionNotifier 자동 업데이트 (onAuthStateChange 스트림)
└─ [Supabase Trigger] auth.users INSERT → public.profiles row 자동 생성
(display_name = Kakao 닉네임, avatar_url = Kakao 프로필)
[User] go_router redirect → / 진입 (timeline 비어있음)
└─ [App] 표시: empty state
"아직 인용구가 없어요. 좋아하는 책의 한 줄을 저장해보세요."
[+ 인용구 추가] 큰 버튼 1개
[User] [+ 인용구 추가] 탭 → /quote/new로 이동
[Flow B로 분기]
User → App: 첫 실행
App → SecureStorage: 세션 조회 (Supabase 자동)
App → GoRouter: redirect → /auth/login
User → LoginScreen: "카카오로 시작" 탭
LoginScreen → flutter_web_auth_2: Kakao OAuth URL 오픈
flutter_web_auth_2 → User: 카카오 동의
flutter_web_auth_2 → LoginScreen: callback URL
LoginScreen → SupabaseAuth: exchangeCodeForSession
SupabaseAuth → Postgres: insert auth.users
Postgres → Trigger: insert public.profiles
SupabaseAuth → AuthStateStream: emit AuthState
AuthStateStream → SessionNotifier: state = session
SessionNotifier → GoRouter: redirect → /
GoRouter → TimelineScreen: render
TimelineScreen → timelineProvider: empty
TimelineScreen → User: empty state + CTA
목표: 사용자가 가장 자주 하는 행동. 3분 안에 끝나야 함. 단톡방 공유까지.
[User] (책을 읽다 좋은 구절 발견. 폰 카메라로 페이지 사진 찍음. iOS Live Text로 텍스트 복사)
└─ 우리 앱 진입
[User] tab bar의 가운데 [+] 탭
└─ context.go('/quote/new')
[User] /quote/new 화면
└─ [App] QuoteFormScreen
├─ 텍스트 영역 (자동 포커스, TextEditingController)
├─ 책 선택 영역 (비어있음)
└─ 페이지 입력 (선택)
[User] 텍스트 영역에 클립보드 텍스트 붙여넣기
└─ [App] TextEditingController가 quoteFormController state 갱신
[User] "책 선택" 탭
└─ showModalBottomSheet → BookSearchSheet
└─ [App] BookSearchSheet
├─ 검색바 (자동 포커스)
└─ 최근 본 책 (이전 검색 결과 캐시)
[User] 책 제목 입력 ("작별하지...")
└─ [App] Debouncer(milliseconds: 300)
└─ [Repository] booksRepository.searchAladin(debouncedQuery)
└─ [External Aladin] HTTPS GET ItemSearch.aspx
└─ 결과 20개 반환 (title, author, cover_url, isbn)
└─ [App] ListView.builder로 결과 렌더 (각 row: 표지 + 제목 + 저자)
[User] 검색 결과에서 "작별하지 않는다" 탭
└─ [Repository] booksRepository.upsertFromAladin(selectedBook)
└─ [Supabase] books UPSERT by ISBN → row id 반환
└─ [Repository] booksRepository.addToLibrary(bookId, status='reading')
└─ [Supabase] user_books INSERT (RLS 검증 자동)
└─ [App] Navigator.pop으로 sheet 닫고 QuoteFormScreen으로 돌아옴
└─ 책 선택 영역에 "📕 작별하지 않는다 · 한강" 표시
[User] 페이지 입력 "142"
[User] [카드 만들기 →] 탭
└─ [Controller] createQuoteController.create({ bookId, text, page, visibility: 'public' })
└─ [Supabase] quotes INSERT
└─ [Supabase Realtime] publish to followers
└─ [App] context.go('/card/${quote.id}')
└─ [Riverpod] ref.invalidate(timelineProvider), userLibraryProvider, bookQuotesProvider(bookId)
[User] /card/:quoteId 진입
└─ [App] CardEditorScreen
├─ 표지 이미지 prefetch (CachedNetworkImage / precacheImage)
├─ [paletteProvider(coverUrl)] palette_generator로 dominant 5색 추출
└─ 첫 템플릿 'minimal' + 추출 색으로 카드 미리보기 렌더 (CustomPaint)
[User] 카드 미리보기 보면서 템플릿·색·폰트 조정
└─ [App] cardEditorController.updateDesign(...)
└─ Riverpod이 의존 위젯 rebuild → CustomPaint 60fps 유지
[User] 만족스러운 결과에서 [공유하기] 탭
└─ [App] RepaintBoundary.toImage(pixelRatio: ...) → ByteData → PNG 파일 (1080×1920 또는 1080×1080)
└─ [App] share_plus.shareXFiles([XFile(localPngPath)])
└─ [OS] 시스템 share sheet
├─ 인스타 스토리
├─ 카카오톡
├─ 다운로드
└─ 기타 앱
[User] 인스타 스토리 선택 → 인스타 앱 열림 → 자동으로 카드 이미지 첨부됨
└─ (병렬) [Repository] cardRepository.save(quoteId, design) → cards 테이블 저장 (히스토리)
User → QuoteForm: 텍스트 붙여넣기
User → QuoteForm: "책 선택" 탭
QuoteForm → BookSearchSheet: open
User → BookSearchSheet: 검색어 입력
BookSearchSheet → AladinClient: search(query)
AladinClient → AladinAPI: GET ItemSearch.aspx
AladinAPI → AladinClient: 20 results
BookSearchSheet → User: 결과 리스트
User → BookSearchSheet: 책 선택
BookSearchSheet → API.upsertBook: book data
API → Supabase: books UPSERT
Supabase → API: book row
BookSearchSheet → API.addToLibrary: bookId
API → Supabase: user_books INSERT
BookSearchSheet → QuoteForm: dismiss with selected book
User → QuoteForm: [카드 만들기 →]
QuoteForm → API.createQuote: input
API → Supabase: quotes INSERT
Supabase → Realtime: publish
Supabase → API: quote with book
QuoteForm → Router: /card/{quoteId}
Router → CardEditor: render
CardEditor → CachedNetworkImage: prefetch cover
CardEditor → ColorExtractor: extract palette
ColorExtractor → CardEditor: 5 colors
CardEditor → CustomPaint: render preview
User → CardEditor: 디자인 조정
User → CardEditor: [공유하기]
CardEditor → RepaintBoundary.toImage: capture PNG bytes
RepaintBoundary → CardEditor: localFilePath
CardEditor → share_plus: shareXFiles([XFile])
share_plus → OS: share sheet
User → OS: 인스타 스토리 선택
OS → InstagramApp: open with image
CardEditor → API.saveCard: design
API → Supabase: cards INSERT (병렬)
목표: 친구가 인스타에 올린 카드 → 우리 앱 설치 → 그 책 본인 서재에 추가까지의 흐름.
[User-A] 인스타 스토리에 "책귀" 카드 + 워터마크 본다
└─ 워터마크 영역에 "책귀에서 만들었어요" 텍스트
└─ (스토리 링크 sticker가 있다면) 책귀 앱 deep link
[User-A] 앱이 없으면 → App Store/Play Store
└─ 설치 후 deep link 보존 (Universal Link / App Link, `app_links` 또는 `uni_links` 패키지)
[User-A] 첫 실행 → Flow A (가입)
└─ 가입 완료 후 deep link 처리
└─ /book/[id]?from=story 로 이동
[User-A] 책 상세 페이지
└─ [App] BookDetailScreen
├─ 책 표지 hero
├─ 제목·저자·출판사
├─ "💾 내 서재에 추가" 큰 버튼
└─ 다른 사용자가 모은 인용구 (visibility=public만, V1.5)
[User-A] [내 서재에 추가] 탭
└─ [API] addBookToLibrary(bookId, 'want_to_read')
└─ [App] toast "내 서재에 추가됐어요" + tab bar의 서재로 jump
[User-A] 홈 timeline 진입
└─ [API] fetchTimeline()
└─ [Supabase] SELECT * FROM quotes WHERE user_id IN following ORDER BY created_at DESC
└─ RLS 자동 필터 (visibility 정책)
└─ JOIN books, profiles
└─ 20개 반환
└─ [App] ListView.builder 렌더 (Flutter 기본 가상화 — 각 카드: 친구 + 인용구 + 책 정보)
└─ (병렬) [App] useTimelineRealtime 활성
[User-A] 친구 "수연"의 인용구 카드 본다
└─ 카드 하단의 책 표지 + 제목 탭
└─ Router → /book/[bookId]
└─ [Flow continues with 5.1's BookDetailScreen]
[User-A] 카드의 ❤️ 탭 (V1.5에서)
└─ [API] toggleQuoteLike(quoteId)
app_links 패키지 + Info.plist / AndroidManifest.xml 설정).Flow B의 4.1에서 이미 다룸. 단독 진입은 다음:
[User] tab bar의 [📚 서재] 탭
└─ [App] LibraryScreen
├─ 뷰 모드 토글 (격자/쌓기/책장/회전)
└─ FAB "📕 책 추가"
[User] [책 추가] 탭
└─ Router → /book/search (Flow B의 4.1.5+ 와 동일)
(이후 책 선택 → addBookToLibrary → 서재로 돌아옴)
[User] tab bar의 [👥 친구] 탭
└─ [App] FriendsScreen
├─ 검색바
├─ "📒 카톡 친구 중 사용자" 섹션 (V1.5)
└─ "✨ 추천" 섹션 (V2)
[User] 검색바에 이름 입력
└─ [API] searchUsers(query)
└─ [Supabase] SELECT FROM profiles WHERE username/display_name ILIKE
└─ [App] 결과 리스트 (각 row: 아바타·이름·통계·팔로우 버튼)
[User] [팔로우] 버튼 탭
└─ [API] followUser(targetUserId)
└─ [Supabase] follows INSERT
└─ [App] 버튼이 [팔로잉]으로 변경 (낙관적 업데이트)
└─ [Riverpod] ref.invalidate(followsProvider(myUserId)), ref.invalidate(timelineProvider)
[User] "📒 카톡 친구 찾기" 탭
└─ [App] Kakao Friends API 권한 요청
└─ 사용자 동의
└─ Kakao Friends 목록 받음 (id 만)
└─ [API] matchKakaoFriends(kakaoIds)
└─ [Supabase] SELECT FROM profiles WHERE kakao_id IN (...)
└─ [App] 매칭된 친구 표시
V1에서는 username 검색만. 카톡 매칭은 V1.5.
시나리오: 지하철에서 책 읽다 인용구 입력 → 신호 약함 → 나중에 자동 동기화.
[User] 지하철에서 [+] 탭 → /quote/new
└─ [App] 오프라인 감지: Connectivity().checkConnectivity() == ConnectivityResult.none
[User] 텍스트 입력·책 선택
└─ 책 선택 시 [Repository] booksRepository.searchAladin → 네트워크 에러
└─ [App] "오프라인 상태. 아래에 저장해서 나중에 매칭" 옵션 표시
└─ 책 입력을 임시 텍스트로 (수동 입력 모드)
[User] [저장] 탭
└─ [App] syncQueueNotifier.addPending({
text, manualBookText, page, createdAt: DateTime.now()
})
└─ shared_preferences (또는 hive)에 영속화
└─ [App] SnackBar "오프라인이에요. 연결되면 자동으로 저장돼요"
└─ [App] timeline에 임시 표시 ("동기화 대기 중" 뱃지)
(시간 경과)
[User] 지상으로 나옴 → 네트워크 복구
└─ [App] connectivity_plus stream listener: hasConnection
└─ [App] syncQueueNotifier.processPending()
└─ for each pending:
├─ [Repository] booksRepository.searchAladin(manualBookText) — 자동 매칭 시도
├─ 매칭 성공 → upsertBook + addToLibrary + createQuote
└─ 매칭 실패 → 사용자에게 알림 "이 인용구는 책 정보가 필요해요"
└─ [App] 동기화 완료된 row는 syncQueue에서 제거
└─ [Riverpod] ref.invalidate(timelineProvider), ref.invalidate(userLibraryProvider)
shared_preferences (또는 hive) + 수동 sync 함수. PowerSync는 V2.플로우별 목표:
| Action | Target | Budget |
|---|---|---|
| 앱 cold start → 첫 화면 | <2s | 1.5s 코드 + 0.5s API |
| Timeline 첫 페이지 | <800ms | 200ms 라우팅 + 600ms API |
| 책 검색 (debounced) | <500ms | 알라딘 API |
| 인용구 저장 | <300ms | Supabase INSERT |
| 카드 미리보기 렌더 | <16ms | Flutter Canvas 60fps |
| 카드 PNG export | <300ms | RepaintBoundary.toImage |
| 시스템 share sheet | <500ms | OS |
parallel-sleeping-meadow.md의 GTM 섹션 KPI를 플로우와 연결:
| KPI | 플로우 | 측정 지점 |
|---|---|---|
| Activation (D7 인용구 3+) | A → B 반복 | quote 생성 이벤트 카운트 |
| D1 Retention | A 다음날 timeline 진입 | session 시작 이벤트 |
| D7/D30 Retention | B 반복 | 마지막 quote 생성 시점 |
| Viral K-factor | C (외부 진입 → 가입) | deep link 설치 attribution |
| Card share rate | B의 공유 단계 | RepaintBoundary capture → share_plus 호출 |
| Avg quotes/WAU | B 빈도 | quotes 테이블 집계 |
PostHog에서 추적할 이벤트:
auth_signed_in (provider)quote_created (book_id, has_photo)card_shared (template, target: ‘instagram’ |
‘kakao’ | ‘download’) |
book_added (source: ‘search’ |
‘deep_link’ | ‘friend_quote’) |
friend_followed (source: ‘search’ |
‘kakao_match’ | ‘recommendation’) |
다음 차례: