⚠️ 시점 고정 초안 (2026-05-09) — Expo +
@supabase/supabase-js+ TanStack Query +supabase gen types시절. 실제 구현은 Flutter +supabase_flutter+ Riverpod이고 Edge Function도 2개(aladin-search·delete-account) 있다. 현재 DB 스키마·RPC·Edge Function의 단일 진실은../db-schema.md+lib/features/**/data/*_repository.dart.
버전: 0.1 (2026-05-09)
연계: architecture.md (시스템) · client-architecture.md (클라이언트)
| 결정 | 선택 |
|---|---|
| 데이터 호출 1차 방식 | supabase-js 직접 쿼리 + RLS |
| Postgres RPC | V1은 거의 안 씀. 복잡 JOIN은 supabase-js nested select로 |
| Edge Function | V1에는 없음 (architecture.md 7번과 동일) |
| 외부 API (알라딘) | 클라이언트에서 직접 호출 |
| TypeScript 타입 | supabase gen types로 자동 생성, 모든 API 함수에 적용 |
| Pagination | Cursor-based (created_at + id) |
| API 함수 위치 | features/<X>/api.ts 한 파일에 모음 |
| 에러 정규화 | API 함수가 throw, TanStack Query가 잡음 |
┌────────────────────────────────────────────────────────────┐
│ 클라이언트 (Expo App) │
└────────────────────────────────────────────────────────────┘
│ │ │
↓ A ↓ B ↓ C
[supabase-js] [Postgres RPC] [외부 API]
├ select / insert / (.rpc('xxx', (axios·fetch)
│ update / delete args)) ├ 알라딘
├ nested select V1 거의 X └ Naver (V1.5+)
│ ('quotes(*, books(*))')
├ rpc()
└ realtime
↓
[Supabase Postgres]
├ tables (RLS)
└ functions (security definer)
A. supabase-js 직접 쿼리: 단순 CRUD, RLS로 권한 처리. V1의 95%가 여기.
B. Postgres RPC: 한 트랜잭션에서 여러 변경, 복잡한 비즈니스 룰, 집계. V1에서 사용 후보:
add_book_with_initial_quote(book, quote) — 인용구 추가하면서 책도 서재에 자동 등록 (한 트랜잭션)get_user_stats(user_id) — 책 수·인용구 수·최근 활동 한 번에단,
add_book_with_initial_quote는 클라이언트가 두 번 insert 호출해도 정합성에 큰 문제 없음 (book이 먼저 생기고 quote가 그것을 참조). V1에서 RPC 없이 시작.
C. 외부 API: 알라딘 책 검색·메타. 클라이언트에서 직접.
npx supabase gen types typescript --project-id "<project-ref>" > lib/database.types.ts
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
);
// 도메인 타입 추출
export type Profile = Database['public']['Tables']['profiles']['Row'];
export type Book = Database['public']['Tables']['books']['Row'];
export type Quote = Database['public']['Tables']['quotes']['Row'];
export type Card = Database['public']['Tables']['cards']['Row'];
스키마 변경 시 gen types 재실행 → 컴파일 에러로 변경 영향 자동 감지.
features/auth/api.ts)import { supabase } from '@/lib/supabase';
export async function signInWithKakao() {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'kakao',
options: {
redirectTo: 'quotesapp://auth/callback',
},
});
if (error) throw error;
}
export async function signInWithEmail(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
return data.session;
}
export async function signUpWithEmail(email: string, password: string, username: string) {
const { data, error } = await supabase.auth.signUp({
email, password,
options: { data: { username } },
});
if (error) throw error;
return data.session;
}
export async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
export async function fetchProfile(userId: string) {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) throw error;
return data;
}
export async function updateProfile(userId: string, updates: Partial<Profile>) {
const { data, error } = await supabase
.from('profiles')
.update(updates)
.eq('id', userId)
.select()
.single();
if (error) throw error;
return data;
}
Profile 자동 생성: auth.users에 row 생기면 Postgres trigger로 public.profiles에 빈 row 자동 생성. 클라이언트 코드 부담 없음.
features/books/api.ts)import { supabase } from '@/lib/supabase';
import { aladinClient } from '@/lib/aladin';
// --- 외부 (알라딘) ---
export async function searchBooksOnAladin(query: string) {
return aladinClient.search(query); // 책 검색 결과 반환
}
// --- 내부 (Supabase) ---
// 알라딘 결과를 books 테이블에 넣기 (UPSERT by ISBN)
export async function upsertBookFromAladin(aladinBook: AladinBook) {
const { data, error } = await supabase
.from('books')
.upsert({
isbn: aladinBook.isbn13,
title: aladinBook.title,
author: aladinBook.author,
publisher: aladinBook.publisher,
cover_url: aladinBook.cover,
}, { onConflict: 'isbn' })
.select()
.single();
if (error) throw error;
return data;
}
export async function fetchBook(bookId: string) {
const { data, error } = await supabase
.from('books')
.select('*')
.eq('id', bookId)
.single();
if (error) throw error;
return data;
}
export async function fetchUserLibrary(userId: string) {
const { data, error } = await supabase
.from('user_books')
.select(`
status, category, added_at,
book:books(*)
`)
.eq('user_id', userId)
.order('added_at', { ascending: false });
if (error) throw error;
return data;
}
export async function addBookToLibrary(bookId: string, status: 'reading' | 'finished' | 'want_to_read' = 'reading') {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('user_books')
.insert({ user_id: user.id, book_id: bookId, status });
if (error) throw error;
}
export async function updateBookStatus(bookId: string, status: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('user_books')
.update({ status })
.eq('user_id', user.id)
.eq('book_id', bookId);
if (error) throw error;
}
aladinClient는 lib/aladin.ts에서 fetch wrapper로 구현. 키는 EXPO_PUBLIC_ALADIN_TTBKEY.
features/quotes/api.ts)import { supabase } from '@/lib/supabase';
const PAGE_SIZE = 20;
export interface TimelinePage {
quotes: QuoteWithBookAndAuthor[];
nextCursor: string | null;
}
export async function fetchTimeline(opts: { cursor?: string | null }): Promise<TimelinePage> {
let q = supabase
.from('quotes')
.select(`
*,
book:books(*),
author:profiles!quotes_user_id_fkey(id, username, display_name, avatar_url)
`)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(PAGE_SIZE);
if (opts.cursor) {
q = q.lt('created_at', opts.cursor);
}
const { data, error } = await q;
if (error) throw error;
return {
quotes: data,
nextCursor: data.length === PAGE_SIZE ? data[data.length - 1].created_at : null,
};
}
export async function fetchBookQuotes(bookId: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('quotes')
.select('*')
.eq('user_id', user.id)
.eq('book_id', bookId)
.order('page', { ascending: true, nullsFirst: false })
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
export interface QuoteInput {
bookId: string;
text: string;
page?: number;
photoUrl?: string;
tags?: string[];
visibility: 'public' | 'friends' | 'private';
}
export async function createQuote(input: QuoteInput) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('quotes')
.insert({
user_id: user.id,
book_id: input.bookId,
text: input.text,
page: input.page,
photo_url: input.photoUrl,
tags: input.tags,
visibility: input.visibility,
})
.select(`*, book:books(*)`)
.single();
if (error) throw error;
return data;
}
export async function updateQuote(quoteId: string, updates: Partial<QuoteInput>) {
const { data, error } = await supabase
.from('quotes')
.update({
text: updates.text,
page: updates.page,
tags: updates.tags,
visibility: updates.visibility,
})
.eq('id', quoteId)
.select()
.single();
if (error) throw error;
return data;
}
export async function deleteQuote(quoteId: string) {
const { error } = await supabase.from('quotes').delete().eq('id', quoteId);
if (error) throw error;
}
RLS가 권한을 처리하므로 eq('user_id', user.id)는 update·delete에서 의도 명시 차원. 권한 검증은 DB가 담당.
features/cards/api.ts)export interface CardDesign {
template: 'minimal' | 'warm' | 'mono' | 'gradient' | 'illustration';
colors: string[]; // hex codes
font: string;
spacing: number;
}
export async function saveCardDesign(quoteId: string, design: CardDesign) {
const { data, error } = await supabase
.from('cards')
.insert({ quote_id: quoteId, template: design.template, design })
.select()
.single();
if (error) throw error;
return data;
}
export async function fetchQuoteCards(quoteId: string) {
const { data, error } = await supabase
.from('cards')
.select('*')
.eq('quote_id', quoteId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
카드 PNG 자체는 클라이언트에서 합성·디바이스에 저장 → 우리 API에 안 올라감. 위 함수는 디자인 옵션만 jsonb로 저장.
features/friends/api.ts)export async function searchUsers(query: string) {
const { data, error } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url')
.or(`username.ilike.%${query}%,display_name.ilike.%${query}%`)
.eq('is_public', true)
.limit(20);
if (error) throw error;
return data;
}
export async function fetchFollowing(userId: string) {
const { data, error } = await supabase
.from('follows')
.select('following:profiles!follows_following_id_fkey(*)')
.eq('follower_id', userId);
if (error) throw error;
return data.map((r) => r.following);
}
export async function fetchFollowers(userId: string) {
const { data, error } = await supabase
.from('follows')
.select('follower:profiles!follows_follower_id_fkey(*)')
.eq('following_id', userId);
if (error) throw error;
return data.map((r) => r.follower);
}
export async function followUser(targetUserId: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('follows')
.insert({ follower_id: user.id, following_id: targetUserId });
if (error) throw error;
}
export async function unfollowUser(targetUserId: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('follows')
.delete()
.eq('follower_id', user.id)
.eq('following_id', targetUserId);
if (error) throw error;
}
Offset 방식 안 쓰는 이유: 새 인용구가 timeline 위에 추가되면 page 2를 받을 때 항목이 한 칸씩 밀려 중복 발생.
Cursor 방식:
(created_at DESC, id DESC) — id를 추가해서 동일 시각 tie-breakWHERE created_at < ${lastSeenCreatedAt}lastSeenCreatedAt만 들고 있음useInfiniteQuery와 자연스럽게 결합 (client-architecture.md 7.A 참조).
[domain, action, ...params]
| Key | 의미 |
|---|---|
['profile', userId] |
사용자 프로필 |
['userBooks', userId] |
내 서재 |
['book', bookId] |
책 단건 |
['bookSearch', query] |
알라딘 검색 결과 |
['timeline'] |
친구 timeline |
['bookQuotes', bookId] |
특정 책에 내가 모은 인용구 |
['quoteCards', quoteId] |
인용구의 저장된 카드들 |
['follows', 'following', userId] |
누구를 팔로우 |
['follows', 'followers', userId] |
누가 나를 |
무효화 규칙: mutation 성공 시 영향 받는 key 모두 invalidate.
// useCreateQuote 성공 시
queryClient.invalidateQueries({ queryKey: ['timeline'] });
queryClient.invalidateQueries({ queryKey: ['bookQuotes', newQuote.book_id] });
queryClient.invalidateQueries({ queryKey: ['userBooks'] });
// lib/aladin.ts
const ALADIN_BASE = 'https://www.aladin.co.kr/ttb/api';
const TTB_KEY = process.env.EXPO_PUBLIC_ALADIN_TTBKEY!;
export const aladinClient = {
async search(query: string) {
const url = new URL(`${ALADIN_BASE}/ItemSearch.aspx`);
url.searchParams.set('TTBKey', TTB_KEY);
url.searchParams.set('Query', query);
url.searchParams.set('QueryType', 'Title');
url.searchParams.set('SearchTarget', 'Book');
url.searchParams.set('Output', 'JS');
url.searchParams.set('Version', '20131101');
url.searchParams.set('Cover', 'Big');
url.searchParams.set('MaxResults', '20');
const res = await fetch(url.toString());
if (!res.ok) throw new AladinError(`HTTP ${res.status}`);
const json = await res.json();
return json.item.map(mapAladinItem);
},
async lookupByISBN(isbn: string) {
const url = new URL(`${ALADIN_BASE}/ItemLookUp.aspx`);
url.searchParams.set('TTBKey', TTB_KEY);
url.searchParams.set('ItemId', isbn);
url.searchParams.set('ItemIdType', 'ISBN13');
url.searchParams.set('Output', 'JS');
url.searchParams.set('Cover', 'Big');
const res = await fetch(url.toString());
if (!res.ok) throw new AladinError(`HTTP ${res.status}`);
const json = await res.json();
return json.item[0] ? mapAladinItem(json.item[0]) : null;
},
};
Search debounce: 클라이언트에서 useDebounce(query, 300)로 호출 빈도 제한. 알라딘 호출량 보호.
키 노출 우려: EXPO_PUBLIC_*는 빌드에 포함되어 디컴파일 시 노출됨. 알라딘 TTB Key는 rate-limit이 보호 수단이고 비밀이 아니므로 OK.
API 함수는 throw로 통일. TanStack Query가 받아서 UI에 전달.
// lib/errors.ts
export class ApiError extends Error {
constructor(public code: string, message: string, public cause?: unknown) {
super(message);
}
}
export class AladinError extends ApiError {
constructor(message: string, cause?: unknown) {
super('ALADIN_ERROR', message, cause);
}
}
export class AuthError extends ApiError {
constructor(message: string) {
super('AUTH_ERROR', message);
}
}
Supabase 에러는 코드별 분류 (PGRST116 = no row, 23505 = unique violation 등). 자세한 매핑은 F에서.
V1.5 이후 다음 케이스 발생 시 RPC 추가:
| 상황 | RPC 함수 후보 |
|---|---|
| 인용구 추가 시 책이 없으면 자동 등록 (한 트랜잭션) | add_quote_with_book(book_data, quote_data) |
| 사용자 통계 (책 수·인용구 수·최근 활동) | get_user_stats(user_id) |
| Trending books (최근 7일 가장 많이 인용됨) | get_trending_books(days, limit) |
| 친구 추천 (취향 유사도) | recommend_friends(user_id, limit) |
V1에서 굳이 RPC로 만들지 않는 이유: 클라이언트 두 번 호출로 동일 결과 달성, 디버깅 쉬움, 스키마와 함수 분리 비용 회피.
architecture.md 7번 동일. V1.5+ 도입 후보:
| 케이스 | 함수 | 시점 |
|---|---|---|
| 알라딘 결과 캐싱 | cached-book-search |
호출 한도 임박 시 |
| 표지 색 추출 (서버에서 미리) | extract-cover-palette |
클라이언트 부담 측정 후 |
| 푸시 알림 발송 | send-push |
V2 (FCM·APNs 키 보호) |
| 데이터 export (GDPR) | export-user-data |
사용자 요청 시 |
다음 차례: