Data Loading Strategy
Updated: 2026-07-16
배경
2026-06-20에 Kakao 로그인 실패처럼 보이는 문제가 있었다. 진단 결과 인증은 성공했고, 실제 실패는 로그인 이후 앱 데이터 로딩에서 발생했다.
인증 로그:
{
event: "session:get_completed",
hasSession: true,
provider: "kakao"
}실제 실패 지점:
GET /rest/v1/session_exercises?...&session_id=in.(very many ids...)
400 Bad Request즉, 문제는 auth가 아니라 loadRemoteRows()가 4년치 세션을 한 번에 가져오고, 그 세션 id 전체를 다시 session_exercises.in(...) 조건에 넣으면서 URL이 과도하게 길어진 것이다.
PR #74에서 직접적인 장애는 완화했다.
session_exercisesrelated row 조회를 80개 id 단위로 분할exercise_setsrelated row 조회를 80개 id 단위로 분할planned_setsrelated row 조회를 80개 id 단위로 분할(당시 테이블명 — 이슈 #1215(2026-09-04) 이후 계획 세트도exercise_set_part행)
이 패치는 400 Bad Request를 막는 단기 안전장치다. 하지만 로그인 직후 4년치 raw 운동 기록을 전부 로드하는 구조 자체는 바꿔야 한다.
재발 방지 원칙
로그인 화면으로 돌아왔다고 해서 곧바로 인증 실패로 판단하지 않는다. 앱 부트스트랩은 항상 세 단계로 나눠 진단한다.
Auth
- Supabase client 생성 여부
getSession()결과- provider/user id 존재 여부
Data bootstrap
- profile 로딩
- app summary 로딩
- session summary 로딩
- session detail 로딩
- stats 로딩
UI gate
- auth 실패 화면인지
- 데이터 로딩 실패 화면인지
- 부분 데이터 로딩 중 상태인지
세션이 있는데 데이터 로딩이 실패한 경우, 앱은 로그인 화면을 보여주면 안 된다. 이 경우는 "데이터를 불러오지 못했습니다" 계열의 복구 가능한 에러 상태로 분리한다.
현재 문제
현재 앱의 초기 로딩은 loadInitialAppData() -> loadRemoteRows() 중심이다. 이 흐름은 앱에 필요한 대부분의 원천 row를 한 번에 가져온다.
대표적으로 아래 데이터가 로그인 직후 함께 로드된다.
- profile
- exercises
- exercise archetypes
- wodup exercises
- external mappings
- user exercise stats
- user exercise records
- user exercise period stats
- all completed sessions
- all session exercises
- all exercise sets
- all planned sessions
- all planned sets
- all daily conditions
소량 fixture에서는 단순하고 편하지만, 실제 사용자가 4년치 데이터를 import하면 로그인 첫 화면에 필요하지 않은 raw row까지 모두 가져오게 된다.
문제:
- 로그인 후 첫 화면 표시가 느려진다.
- raw
exercise_sets가 많아질수록 네트워크/메모리 비용이 커진다. - 특정 화면 하나의 데이터 문제가 앱 전체 진입 실패처럼 보일 수 있다.
- URL 기반 Supabase REST
.in(...)조건이 길이/조건 크기 제한에 걸릴 수 있다.
목표
초기 앱 로딩은 "앱을 열 수 있는 최소 데이터"만 가져온다.
raw session detail은 사용자가 해당 월/날짜/세션을 볼 때 가져온다.
목표 기준:
- 로그인 직후 raw 4년치
exercise_sets를 가져오지 않는다. - Home 첫 화면은 summary/stat 테이블 기반으로 렌더한다.
- Calendar는 현재 월의 session summary만 가져온다.
- Session detail은 사용자가 특정 세션을 열 때만 가져온다.
- PR/Volume 화면은 materialized stats를 우선 사용한다.
- 대량 데이터 import 이후에도 초기 로딩 쿼리 수와 row 수가 사용 기간에 비례해 폭증하지 않는다.
목표 데이터 계층
Auth
session
profile
App shell bootstrap
profile
current user settings
exercise catalog metadata needed for labels/search
dashboard summary
current month session summaries
today session details only if needed
On-demand data
selected month session summaries
selected day sessions
selected session detail
selected exercise PR history
selected exercise volume history
exercise catalog search pagesAPI 분리 전략
기존:
loadInitialAppData()
-> loadRemoteRows()
-> all sessions
-> all session_exercises
-> all exercise_sets
-> all stats목표:
loadInitialAppShellData()
-> auth session
-> profile
-> current month session summaries
-> home dashboard summary
-> PR/volume overview stats
loadCalendarMonthSummary(month)
-> month calendar cards only
loadCalendarDaySummary(date)
-> selected date totals + compact session hierarchy in one RPC
loadSessionDetail(sessionId)
-> one session hierarchy:
session
session_exercises
exercise_sets
loadPlannedSessionDetail(plannedSessionId)
-> one owner-bound plan
-> all editable planned sets (DB invariant <= 24)
loadExerciseHistory(exerciseId, page)
-> PR logs / volume logs paginated
loadExerciseCatalog({ query, page, filters })
-> searchable exercise list화면별 로딩 전략
Home
Home은 raw 세트 목록을 직접 읽지 않는다.
필요 데이터:
- profile
- 이번 주 운동 요약
- 들어올린 무게 월/분기/연 누적
- 오늘 예정 세션 summary
데이터 출처:
user_training_period_statsuser_exercise_period_statsuser_exercise_stats- month/day session summary RPC or view
- daily condition rows for visible short range
user_training_period_stats는 홈의 주간/월간/연간 전체 볼륨, 세션 수, 출석일, 운동 시간을 담당한다. user_exercise_period_stats는 종목별 볼륨/PR/상세 분포에만 사용한다. 전체 세션 수를 종목별 row에서 합산하면 한 세션이 여러 종목 수만큼 중복되므로 금지한다.
Calendar / Session List
Calendar는 현재 보이는 월만 로드한다.
필요 데이터:
- session id
- date
- status
- title
- exercise count
- set count
- volume
- source
- created_at
가져오면 안 되는 데이터:
- 전체 세트 목록
- 모든 session_exercises raw row
- 모든 exercise_sets raw row
사용자가 월을 이동하면 해당 월 summary만 새로 요청한다.
2026-07-16부터 정상 경로는 get_calendar_month_summary()와 get_calendar_day_summary()만 사용한다. 날짜 선택 시 월의 완료 세션마다 get_session_detail()를 호출하던 N+1 경로와 기존 월간 row-bag RPC는 제거했다.
user_calendar_day_summaries는 원본 세션에서 재생성 가능한 일별 read model이다.- 날짜 선택은 day RPC 한 번으로 끝난다.
- 선택일 완료 표시 계층은 세션 12개, 세션당 entry 16개, entry당 set 12개, exercise id 24개로 제한하고 텍스트/JSON도 byte·shape cap을 둔다. 합계 scalar는 제한 전 전체 원본 기준이다.
- 세션을 실제로 열 때만
get_session_detail()를 호출한다. - 월/Home/선택일 계획 카드는 세트가 없는
sets_complete:falseprojection이다. 계획을 편집할 때만get_planned_session_detail()을 호출하고sets_complete:true결과를 원본으로 쓴다. - 기존 계획 저장·삭제는 단일 상세의
updated_at을 compare-and-swap revision으로 보내며, 직접 계획 테이블 DML은 허용하지 않는다. - 저장 후 해당 date/day와 yyyy-MM/month cache만 무효화한다.
- 캐시 무효화 이전에 시작한 응답은 키별 generation 검사로 폐기한다.
- 상세 구조와 복구 절차는
docs/architecture/calendar-read-model-architecture.md를 따른다.
달력 스텝퍼의 인접 월 prefetch는 표시 월의 즉시 로드와 분리한다.
- 동일 월 요청은 대기/실행 구간 전체에서 하나의 Promise를 공유한다.
- 인접 월 prefetch는 최대 2개 월만 동시에 실행하고 나머지는 순서대로 대기한다.
- 대기 중인 월도 해당 월 카드의 로딩 상태에 포함하되, 전체 화면 로딩으로 승격하지 않는다.
- 일시적인 월 RPC 실패는 큐 안에서 한 번 재시도한다.
- 로그아웃이나 인증 사용자 전환 시 아직 시작하지 않은 월 요청은 폐기하고, 이전 인증 세대의 응답을 적용하지 않는다.
- 선택 월 이동과 기록 저장 후 강제 갱신은 큐를 기다리지 않는 즉시 로드 경로를 유지한다.
- background prefetch 응답의 React 상태 반영은 transition으로 표시해 사용자의 입력을 우선한다.
데스크톱 달력 UI는 단일 월 스텝퍼와 데이터 캐시를 분리한다.
- 전체 탐색 범위는 과거 120개월, 현재 월, 미래 18개월을 유지한다.
- DOM에는 표시 월 하나만 렌더하고, 이전/다음 스텝으로 로컬 표시 월을 이동한다.
- 표시 월과 인접한 ±1개월은 기존 keyed request queue로 미리 요청하며, 이미 신선한 캐시는 재사용한다.
- 인접 월 background prefetch는 현재 카드 전체를 가리지 않고 해당 월 키의 로딩 상태만 유지한다.
- 선택한 월의 직접 이동과 저장 후 강제 갱신은 background transition을 거치지 않는다.
- background prefetch 중인 월을 직접 선택하면 같은 요청을 재사용하되 결과 반영은 즉시 우선순위로 승격한다.
Session Detail
세션 상세는 사용자가 특정 세션을 눌렀을 때만 로드한다.
필요 데이터:
sessions where id = selectedSessionId
session_exercises where session_id = selectedSessionId
exercise_sets where session_exercise_id in selected exercise ids세션 수정/삭제/이미지 저장 같은 기능은 detail 로딩 완료 후 활성화한다.
PR
PR 화면은 raw completed sessions에서 즉석 계산하지 않는다.
필요 데이터:
- PR 카드:
user_exercise_stats - exact 1..20RM current state:
user_exercise_pr_states - strict measured PR transition history:
user_exercise_pr_events - 버전이 고정된 e1RM 원시 projection:
user_exercise_strength_observations - 일별 대표 e1RM 추이:
user_exercise_strength_daily - 현재 종목별 e1RM 기준점:
user_exercise_strength_states - 실제 수행
1..20RMPR state/event:user_exercise_pr_states,user_exercise_pr_events
user_exercise_estimated_1rm_records는 정책 v1 소급 이식 뒤 비워 두는 legacy projection이다. 화면과 신규 코드가 이 테이블을 다시 읽거나 e1RM을 measured PR로 취급해서는 안 된다. 화면에는 반드시 버전이 포함된 strength projection을 RPC로 전달한다.
상세 로그는 exercise별, page별로 로드한다.
Volume
Volume 화면은 기간 통계 테이블을 사용한다.
필요 데이터:
- day/week/month/quarter/year별 volume
- exercise별 volume
- period start/end
출처:
user_training_period_statsuser_exercise_period_stats- 필요하면
user_exercise_session_rollups를 기간별 page로 로드
Workout Start / Exercise Search
운동 시작 화면은 전체 catalog를 매번 모두 끌고 오지 않는다.
기본:
- 최근 수행한 운동 목록:
user_exercise_stats.last_trained_at desc - 즐겨찾기/자주 한 운동
- 검색어 입력 시 catalog search
검색:
- query가 없으면 top N만
- query가 있으면 server-side search
- placeholder exercise도 검색 가능해야 한다
DB/RPC 권장 구조
초기에는 Supabase client query로 나눌 수 있지만, 장기적으로는 RPC/view가 더 안정적이다.
권장 RPC/view:
get_home_dashboard(p_today)
get_calendar_month_summary(p_from, p_to) -- contract v3, exact strength aggregates
get_calendar_day_summary(p_date) -- contract v3, persisted per-set projections
get_session_detail(p_session_id) -- contract v3, persisted per-set projections
get_planned_session_detail(p_planned_session_id)
get_pr_overview(p_as_of)
get_volume_overview(p_as_of, p_contract_version) -- current frontend sends 4; one-arg v2 and selector v3 remain rollback-compatible
get_exercise_pr_detail(p_exercise_id, p_exercise_ids, p_as_of)
get_exercise_pr_detail_year(p_exercise_id, p_exercise_ids, p_year, p_as_of)
get_exercise_pr_records(
p_exercise_id, p_exercise_ids,
p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of
)
get_exercise_pr_history(
p_exercise_id, p_exercise_ids, p_year,
p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of
)화면 RPC는 user id를 파라미터로 받지 않고 auth.uid() 기준으로 소유권을 판단한다. 입력 파라미터는 p_ prefix를 쓴다. PR 종목 상세의 첫 RPC는 summary와 정확히 12주 aggregate만 반환한다. 첫 fragment가 화면에 반영된 뒤 현재 연도 aggregate, 첫 PR page, 현재 연도 history page를 별도 요청한다. 이 화면 필수 조각이 끝나고 브라우저가 한 번 더 양보한 뒤에는 available_years의 연도 aggregate를 연도별 bounded RPC로 순차 로드하고, PR 기록은 keyset page를 끝까지 순차 drain한다. 각 page마다 화면 전체를 다시 병합하지 않고 연도/PR 단위 batch가 끝날 때 한 번만 반영한다. 선택 종목이나 generation이 바뀌면 남은 background drain은 즉시 폐기한다. 연도 선택을 바꾸면 해당 연도 fragment만 우선 읽고, 10년 day/month/year 배열을 하나의 응답으로 다시 묶지 않는다. 세션 history는 10년 전체를 자동 적재하지 않고 선택한 연도만 lazy-load한다. PR과 history는 각각 3-part keyset cursor를 사용하며 history의 기본 page는 20세션, 최대는 60세션, 세션당 표시용 set token은 최대 12개다. p_as_of에는 브라우저 로컬의 오늘 날짜를 YYYY-MM-DD로 전달해 12주 창, 허용 연도, cursor 상한을 서버 timezone과 무관하게 같은 날짜에 고정한다.
초기 fragment의 freshness.stale이 true이면 화면에는 compact snapshot만 먼저 표시하고 year/records/history 요청은 보내지 않는다. pending generation의 Home → 현재 overview → 선택 상세 재조회가 끝난 뒤 fresh initial fragment가 dependent 요청을 한 번만 시작한다. 따라서 곧 폐기될 fragment를 재계산 전후로 두 번 읽지 않는다.
get_session_detail은 선택한 세션 하나만 반환한다.
{
"session": {},
"session_exercises": [],
"exercise_sets": []
}이렇게 하면 프론트엔드가 여러 REST URL을 조합하지 않아도 되고, RLS/ownership 조건도 DB 함수 내부에서 일관되게 처리할 수 있다.
RPC 성능 관측
화면 RPC는 모두 repository의 callScreenRpc(...)를 거친다. 각 호출은 최근 80개까지 debug buffer에 기록한다.
window.__liftGuildRpcDebug
sessionStorage.getItem("barbelic-rpc-debug-log")각 entry는 아래 값을 가진다.
event:screen_rpc:completed또는screen_rpc:failedrpcName:get_home_dashboard,get_calendar_month_summary,get_calendar_day_summary,get_session_detail,get_pr_overview,get_volume_overviewdurationMs: 브라우저에서 측정한 호출 시간responseBytes: JSON 응답 추정 byte 크기paramKeys: 전달한 파라미터 key 목록
콘솔 출력은 기본적으로 꺼져 있다. 디버깅할 때만 아래 중 하나를 켠다.
sessionStorage.setItem("barbelic-rpc-debug", "1")
localStorage.setItem("barbelic-rpc-debug", "1")또는 URL에 ?debug-rpc=1을 붙인다. 성능 이슈가 다시 나오면 먼저 window.__liftGuildRpcDebug에서 느린 RPC와 큰 응답을 확인한다.
인덱스 권장
대량 데이터 기준으로 아래 인덱스가 중요하다.
-- 이슈 #1215(2026-09-04) 다섯 층 기준 — 실제 인덱스명은 괄호 뒤 주석
session(user_id, date desc, created_at desc) where status = 'completed' -- session_user_completed_date_desc_idx
session(user_id, date desc, status) -- session_user_date_status_idx (계획·missed 포함)
session(group_id, date) where group_id is not null -- session_group_id_idx (그룹 운동 계획)
session_exercise(session_id) -- session_exercise_session_id_idx
session_exercise_part(session_id, position, created_at, id) -- session_exercise_part_session_card_idx
session_exercise_part(session_exercise_id) -- session_exercise_part_session_exercise_id_idx
exercise_set(session_exercise_id) -- exercise_set_session_exercise_id_idx
exercise_set_part(exercise_set_id) -- exercise_set_part_exercise_set_id_idx
exercise_set_part(session_exercise_part_id, position, created_at, id) -- exercise_set_part_main_top_card_idx (main/top 세트)
user_exercise_stats(user_id, last_trained_at desc)
user_exercise_pr_states(user_id, exercise_id, target_reps)
user_exercise_pr_events(user_id, exercise_id, target_reps, achieved_on desc, source_created_at desc, id desc)
user_exercise_period_stats(user_id, period_type, period_start)클라이언트 캐시 전략
단일 remoteStatus 대신 domain별 상태를 둔다.
authStatus
profileStatus
homeStatus
calendarStatus
sessionDetailStatus
statsStatus
catalogStatus캐시 key:
profile:{userId}
home:{userId}:{today}
monthSessions:{userId}:{yyyy-mm}
daySessions:{userId}:{yyyy-mm-dd}
sessionDetail:{sessionId}
exerciseHistory:{exerciseId}:{page}
exerciseSearch:{query}:{page}저장/삭제/import 이후에는 관련 cache만 invalidate한다.
종목 카탈로그(이슈 #1238 Phase 3)는 위 키 체계와 별도로 기기 사본 + 변경분 모델이다:
- 기기 저장소 IndexedDB
barbelic-exercise-catalog에 소유자별{ latestSeq, items }한 부. 부팅은 이 사본을 네트워크 없이 바로 싣고, 홈 응답의catalog_version(= 호출자에게 보이는 변경 로그 최신 순번)이 기기 순번보다 클 때만get_exercise_catalog_changes로 변경분을 받아 얹는다(유휴 시점). 사본이 없거나 보존 기간 밖이면get_exercise_catalog로 통째. - 무효화 단위는 "전역 버전"이 아니라 "내게 보이는 변경 1행": 남의 커스텀 종목은 내 순번을 올리지 않고, 내 커스텀 종목 생성·보관은 변경분 1행 갱신(
refreshExerciseCatalog)이다. - 정본:
docs/contracts/exercise-ref.md§2.3,docs/data/app-screen-rpc-contract.md.
예:
- 새 운동 저장: 오늘/해당 월 session summaries, selected session detail, stats invalidation
- 세션 삭제: 해당 월 summaries, stats invalidation
- Wodup import 완료: imported date range의 month summaries, stats invalidation
- 운동 매핑 변경: affected exercise stats/detail invalidation
에러 표시 정책
세션이 없을 때만 로그인 게이트를 보여준다.
authStatus = signedOut
-> LoginGate
authStatus = signedIn && bootstrap failed
-> DataErrorView
authStatus = signedIn && one panel failed
-> 해당 panel만 error/retry데이터 로딩 실패를 로그인 실패로 보이면 안 된다.
구현 순서
1. 상태 분리
remoteStatus를 auth/data status로 분리- LoginGate는 auth 실패에만 사용
- 데이터 실패용
DataErrorView추가
2. Repository API 분리
loadInitialAppData()를 얇게 만들고 shell data만 로드loadMonthSessionSummaries(month)추가loadSessionDetail(sessionId)추가loadExerciseHistory(exerciseId, page)추가
3. 화면별 on-demand wiring
- Calendar 월 변경 시 month summary 로드
- 날짜 선택 시 day summaries 로드
- 세션 클릭 시 detail 로드
- PR/Volume 상세 클릭 시 paginated history 로드
4. DB summary/RPC 도입
- session summary view/RPC 추가
- session detail RPC 추가
- Home dashboard RPC 추가
5. 초기 전체 로딩 제거
- 초기 bootstrap에서 all sessions/all sets 로드를 제거
- raw table 접근은
loadDebugSnapshotRows(...)/exportCurrentUserData(...)/loadAdminCatalogRows(...)처럼 이름에 목적이 드러나는 특수 목적 API로만 둔다.
6. 테스트 고정
필수 테스트:
- 4년치 620세션 fixture에서도 초기 로딩이 raw
exercise_sets를 조회하지 않는다. - month summary는 해당 월 범위만 조회한다.
- session detail은 선택한 session id 하나만 조회한다.
.in(...)배열 조건을 사용하는 곳은 chunk 처리된다.- auth 성공 + data 실패는 LoginGate가 아니라 DataErrorView를 렌더한다.
성능 예산
초기 앱 진입:
- raw completed session detail 로드 금지
- raw exercise set 전체 로드 금지
- 첫 화면 렌더에 필요한 query만 수행
- month summary rows는 보이는 월 기준으로 제한
Session detail:
- 한 세션 단위 raw sets만 로드
- 같은 세션 재진입 시 cache 사용
Import 이후:
- import 완료 직후 전체 앱 데이터를 다시 모두 읽지 않는다.
- affected date range와 exercise id만 invalidate/recalculate한다.
결론
PR #74는 긴 REST URL로 인한 즉시 장애를 막는 패치다. 다음 단계는 앱 데이터 로딩 모델을 "전체 raw 데이터 선로딩"에서 "summary 우선 + detail on demand"로 바꾸는 것이다.
핵심 원칙:
- 로그인은 auth만 책임진다.
- 첫 화면은 summary로 연다.
- raw sets는 사용자가 세션 상세를 열 때만 가져온다.
- PR/Volume은 materialized stats를 정본으로 사용한다.
- 대량 데이터 import 이후에도 초기 로딩 비용이 전체 기록 기간에 비례해 커지지 않게 한다.