Skip to content

Commit 96916da

Browse files
committed
Optimize chapter page data fetching and progress queries
1 parent 8d878ab commit 96916da

3 files changed

Lines changed: 47 additions & 88 deletions

File tree

packages/frontend/src/features/courses/ChapterLessonsPage.tsx

Lines changed: 30 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { CourseGrid } from './components/CourseGrid';
1414
import { LessonCard } from './components/LessonCard';
1515
import { useStore } from '@/stores/store';
1616
import { ChevronLeft, BookOpen, Target, CheckCircle2, Circle, Lock } from 'lucide-react';
17-
import { useIsMobile, useLanguageCourse } from '@/hooks';
17+
import { useIsMobile, useChapter, useChapterProgress } from '@/hooks';
1818
import { getLessonFull } from '@/services/courses';
1919

2020
type LessonProgressSnapshot = {
@@ -27,14 +27,6 @@ type LessonWithProgress = {
2727
progress?: LessonProgressSnapshot | null;
2828
};
2929

30-
type ChapterWithAggregatedProgress = {
31-
progress?: {
32-
total?: number;
33-
completed?: number;
34-
percentage?: number;
35-
};
36-
};
37-
3830
// 언어별 색상 (챕터 페이지용)
3931
const getLanguageColor = (lang: string | undefined) => {
4032
switch (lang) {
@@ -59,89 +51,43 @@ export function ChapterLessonsPage() {
5951
const isMobile = useIsMobile();
6052
const locale = i18n.resolvedLanguage || i18n.language;
6153

62-
// TanStack Query: 언어 코스 데이터 + 진행 상태
63-
const { data: languageData, isLoading, isError, error } = useLanguageCourse(lang);
64-
const chapter = useMemo(
65-
() => languageData?.chapters?.find((ch) => ch.id === chapterId),
66-
[languageData, chapterId]
67-
);
54+
// TanStack Query: 챕터 단건 + 진행 상태 단건(로그인 시)
55+
const { data: chapter, isLoading, isError, error } = useChapter(chapterId);
56+
const { data: chapterProgressData } = useChapterProgress(chapterId);
6857

6958
// 구독 시스템 제거됨 - 챕터 2 이상은 로그인만 필요
7059
const accessDenied = !!chapter && !appUser && chapter.order >= 2;
7160
const accessReason = accessDenied ? t('chapter.login_required_for_chapter') : '';
7261

62+
const progressByLessonId = useMemo(() => {
63+
const map = new Map<string, LessonProgressSnapshot | null>();
64+
for (const lesson of chapterProgressData?.lessons ?? []) {
65+
map.set(lesson.id, lesson.progress ?? null);
66+
}
67+
return map;
68+
}, [chapterProgressData]);
69+
7370
const getLessonProgress = useCallback((lesson: LessonWithProgress): LessonProgressSnapshot | null => {
74-
return lesson.progress ?? null;
75-
}, []);
71+
return progressByLessonId.get(lesson.id) ?? null;
72+
}, [progressByLessonId]);
7673

77-
// 챕터 레슨 프리페치
78-
// - 1순위: 첫 미완료 레슨
79-
// - 2순위: 나머지 레슨 순서대로
80-
// - 동시성 제한: 2
81-
// - 페이지 이탈 시 중단
74+
// 챕터 레슨 프리페치 (경량)
75+
// - 첫 미완료 레슨 1개만 예열
76+
// WHY: 이전에는 레슨 전체를 순차 프리페치해서 이동 시 체감 지연을 유발함
8277
useEffect(() => {
8378
if (!chapter?.lessons || chapter.lessons.length === 0 || accessDenied) return;
8479

8580
const lessons = [...chapter.lessons].sort((a, b) => a.order - b.order);
8681
const firstIncomplete = lessons.find((l) => getLessonProgress(l as LessonWithProgress)?.status !== 'completed');
82+
const targetLessonId = firstIncomplete?.id ?? lessons[0]?.id;
83+
if (!targetLessonId) return;
8784

88-
const orderedIds: string[] = [];
89-
if (firstIncomplete) orderedIds.push(firstIncomplete.id);
90-
for (const lesson of lessons) {
91-
if (lesson.id !== firstIncomplete?.id) {
92-
orderedIds.push(lesson.id);
93-
}
94-
}
95-
96-
// 중복 방지
97-
const uniqueOrderedIds = Array.from(new Set(orderedIds));
98-
const concurrency = Math.min(2, uniqueOrderedIds.length);
99-
100-
// 저우선순위: 다음 챕터의 첫 레슨 1개
101-
const sortedChapters = [...(languageData?.chapters ?? [])].sort((a, b) => a.order - b.order);
102-
const currentChapterIndex = sortedChapters.findIndex((ch) => ch.id === chapter.id);
103-
const nextChapter = currentChapterIndex >= 0 ? sortedChapters[currentChapterIndex + 1] : undefined;
104-
const nextChapterFirstLessonId = nextChapter?.lessons?.length
105-
? [...nextChapter.lessons].sort((a, b) => a.order - b.order)[0]?.id
106-
: undefined;
107-
108-
let cancelled = false;
109-
let cursor = 0;
110-
111-
const worker = async () => {
112-
while (!cancelled && cursor < uniqueOrderedIds.length) {
113-
const index = cursor;
114-
cursor += 1;
115-
const lessonId = uniqueOrderedIds[index];
116-
if (!lessonId) continue;
117-
118-
await queryClient.prefetchQuery({
119-
queryKey: ['lesson', lessonId, locale],
120-
queryFn: () => getLessonFull(lessonId),
121-
staleTime: 5 * 60 * 1000,
122-
});
123-
}
124-
};
125-
126-
const runPrefetchQueue = async () => {
127-
await Promise.all(Array.from({ length: concurrency }, () => worker()));
128-
129-
// 현재 챕터 예열이 끝난 뒤, 다음 챕터 첫 레슨 1개만 예열
130-
if (!cancelled && nextChapterFirstLessonId && !uniqueOrderedIds.includes(nextChapterFirstLessonId)) {
131-
await queryClient.prefetchQuery({
132-
queryKey: ['lesson', nextChapterFirstLessonId, locale],
133-
queryFn: () => getLessonFull(nextChapterFirstLessonId),
134-
staleTime: 5 * 60 * 1000,
135-
});
136-
}
137-
};
138-
139-
void runPrefetchQueue();
140-
141-
return () => {
142-
cancelled = true;
143-
};
144-
}, [chapter, queryClient, accessDenied, languageData, getLessonProgress, locale]);
85+
void queryClient.prefetchQuery({
86+
queryKey: ['lesson', targetLessonId, locale],
87+
queryFn: () => getLessonFull(targetLessonId),
88+
staleTime: 5 * 60 * 1000,
89+
});
90+
}, [chapter, queryClient, accessDenied, getLessonProgress, locale]);
14591

14692
// hover 시 해당 레슨 프리페치
14793
const handlePrefetch = useCallback((lessonId: string) => {
@@ -159,15 +105,14 @@ export function ChapterLessonsPage() {
159105
}
160106
}, [chapter, setPageTitle, lang]);
161107

162-
// 진행률: 백엔드에서 계산된 chapter.progress 단일 소스 사용
108+
// 진행률: 로그인 사용자는 /chapters/:id/progress 값 사용, 비로그인은 0%
163109
const chapterProgress = useMemo(() => {
164110
if (!chapter) return { total: 0, completed: 0, percentage: 0 };
165-
const chapterWithProgress = chapter as typeof chapter & ChapterWithAggregatedProgress;
166-
const total = chapterWithProgress.progress?.total ?? chapter.lessons?.length ?? 0;
167-
const completed = chapterWithProgress.progress?.completed ?? 0;
168-
const percentage = chapterWithProgress.progress?.percentage ?? (total > 0 ? Math.round((completed / total) * 100) : 0);
111+
const total = chapterProgressData?.totalCount ?? chapter.lessons?.length ?? 0;
112+
const completed = chapterProgressData?.completedCount ?? 0;
113+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
169114
return { total, completed, percentage };
170-
}, [chapter]);
115+
}, [chapter, chapterProgressData]);
171116

172117
const totalLessons = chapterProgress.total;
173118
const completedLessons = chapterProgress.completed;

packages/frontend/src/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
export { useEnterKey } from './useEnterKey';
66
export { useTheme } from './useTheme';
77
export { useIsMobile } from './useIsMobile';
8-
export { useLanguageCourse, useChapter, useUserProgress, useLesson } from './useCourses';
8+
export { useLanguageCourse, useChapter, useChapterProgress, useUserProgress, useLesson } from './useCourses';

packages/frontend/src/hooks/useCourses.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
import { useQuery } from '@tanstack/react-query';
1111
import i18n from 'i18next';
12-
import { getLanguageWithChapters, getChapterWithLessons, getUserProgress, getLessonFull } from '@/services/courses';
12+
import { getLanguageWithChapters, getChapterWithLessons, getChapterProgress, getUserProgress, getLessonFull } from '@/services/courses';
1313
import { useStore } from '@/stores/store';
14-
import type { ChapterWithLessons, Language, UserProgress, LessonFull } from '@/types';
14+
import type { ChapterWithLessons, ChapterWithProgress, Language, UserProgress, LessonFull } from '@/types';
1515

1616
/**
1717
* 언어 코스 데이터 조회 (챕터 + 진행률 포함)
@@ -60,6 +60,20 @@ export function useChapter(chapterId: string | undefined) {
6060
});
6161
}
6262

63+
/**
64+
* 챕터 진행 상태 조회 (로그인 사용자 전용)
65+
*/
66+
export function useChapterProgress(chapterId: string | undefined) {
67+
const appUser = useStore((state) => state.appUser);
68+
69+
return useQuery<ChapterWithProgress>({
70+
queryKey: ['chapter-progress', chapterId, appUser?.id],
71+
queryFn: () => getChapterProgress(chapterId!),
72+
enabled: !!chapterId && !!appUser,
73+
staleTime: 60 * 1000,
74+
});
75+
}
76+
6377
/**
6478
* 사용자 진행 상태 조회
6579
*

0 commit comments

Comments
 (0)