Skip to content

Commit 05b391d

Browse files
PeraSitegemini-code-assist[bot]Copilot
authored
refactor: 네트워크 요청 최적화 및 Prefetch 추가 (#313) (#316)
* refactor: 영상 캐시 키와 진입 prefetch 정리 (#313) * refactor: useSlides 폴링 옵션을 분리 적용 (#313) * refactor: 대본 캐시 재사용으로 상세 요청 축소 (#313) * refactor: 공유 댓글 조회를 쿼리로 통합 (#313) * refactor: 대본 일괄수정 scripts 재요청 방지 (#313) * design: 대본 패널 active/컨트롤 스타일 정리 (#313) * test: API 최적화 회귀 가드 추가 (#313) * fix: Update src/pages/feedback/useFeedbackSlide.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * test: 401 에러 핸들러 기대값 정리 (#313) * fix: Lint 오류 수정 (#313) * Update src/pages/VideoListPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/pages/VideoListPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/hooks/queries/useScript.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: 비디오 목록 캐시 업데이트 헬퍼 추출 (#313) * fix: 공유 영상 제목 누락 시 상세 조회 보정 (#313) * refactor: 대본 저장 캐시를 프로젝트 단위로 갱신 (#313) --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 8ec4108 commit 05b391d

29 files changed

Lines changed: 932 additions & 150 deletions

src/api/errorHandler.test.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,21 @@ describe('handleApiError', () => {
3030
});
3131

3232
describe('401 handling', () => {
33-
it('calls logout and shows toast when logged in', () => {
33+
it('does not logout and falls back to generic toast when logged in', () => {
3434
// Set logged in state
3535
useAuthStore
3636
.getState()
3737
.login({ id: '1', email: 'user@google.com', sessionId: 's1' }, 'some-token');
3838

3939
handleApiError(401, '');
4040

41-
expect(useAuthStore.getState().accessToken).toBeNull();
42-
expect(showToast.error).toHaveBeenCalledWith(
43-
'로그인이 만료되었습니다.',
44-
'다시 로그인해주세요.',
45-
);
41+
expect(useAuthStore.getState().accessToken).toBe('some-token');
42+
expect(showToast.error).toHaveBeenCalledWith('요청을 처리하지 못했습니다.', '');
4643
});
4744

48-
it('does nothing when not logged in', () => {
45+
it('falls back to generic toast when not logged in', () => {
4946
handleApiError(401, '');
50-
expect(showToast.error).not.toHaveBeenCalled();
47+
expect(showToast.error).toHaveBeenCalledWith('요청을 처리하지 못했습니다.', '');
5148
});
5249
});
5350

src/api/queryClient.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ describe('queryKeys', () => {
6060
});
6161
});
6262

63+
describe('videos', () => {
64+
it('builds list prefix key with projectId', () => {
65+
expect(queryKeys.videos.listPrefix('p1')).toEqual(['videos', 'list', 'p1']);
66+
});
67+
68+
it('builds list key with params', () => {
69+
expect(
70+
queryKeys.videos.list('p1', { search: 'abc', filter: 'ready', sort: 'recent' }),
71+
).toEqual(['videos', 'list', 'p1', { search: 'abc', filter: 'ready', sort: 'recent' }]);
72+
});
73+
74+
it('builds list key without params', () => {
75+
expect(queryKeys.videos.list('p1')).toEqual(['videos', 'list', 'p1', {}]);
76+
});
77+
});
78+
6379
describe('shares', () => {
6480
it('builds content key with sessionId', () => {
6581
expect(queryKeys.shares.content('token1', 'sess1')).toEqual([

src/api/queryClient.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export const queryKeys = {
6969
all: ['scripts'] as const,
7070
detail: (slideId: string) => [...queryKeys.scripts.all, 'detail', slideId] as const,
7171
versions: (slideId: string) => [...queryKeys.scripts.all, 'versions', slideId] as const,
72-
project: (projectId: string) => [...queryKeys.scripts.all, 'project', projectId] as const,
72+
projects: () => [...queryKeys.scripts.all, 'project'] as const,
73+
project: (projectId: string) => [...queryKeys.scripts.projects(), projectId] as const,
7374
},
7475
presentations: {
7576
all: ['presentations'] as const,
@@ -87,7 +88,15 @@ export const queryKeys = {
8788
videos: {
8889
all: ['videos'] as const,
8990
lists: () => [...queryKeys.videos.all, 'list'] as const,
90-
list: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const,
91+
listPrefix: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const,
92+
list: (
93+
projectId: string,
94+
params?: {
95+
search?: string;
96+
filter?: string;
97+
sort?: string;
98+
},
99+
) => [...queryKeys.videos.listPrefix(projectId), params ?? {}] as const,
91100
details: () => [...queryKeys.videos.all, 'detail'] as const,
92101
detail: (videoId: string) => [...queryKeys.videos.details(), videoId] as const,
93102
slides: (videoId: number) => [...queryKeys.videos.all, 'slides', videoId] as const,

src/components/common/layout/Gnb.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Link, useLocation, useParams } from 'react-router-dom';
1010
import clsx from 'clsx';
1111

1212
import { TABS, getTabFromPathname, getTabPath } from '@/constants/navigation';
13+
import { useProjectEntryPrefetch } from '@/hooks/queries/useProjectEntryPrefetch';
1314

1415
export function Gnb() {
1516
const { projectId } = useParams<{ projectId: string }>();
@@ -18,6 +19,8 @@ export function Gnb() {
1819
const activeIndex = TABS.findIndex((tab) => tab.key === activeTab);
1920
const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0;
2021

22+
useProjectEntryPrefetch(projectId);
23+
2124
if (!projectId) return null;
2225

2326
return (

src/components/slide/SlideWorkspace.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
* - ScriptBox 접힘 상태를 관리하고 SlideViewer에 전달
88
* - Zustand store로 슬라이드 상태 관리
99
*/
10-
import { useEffect, useRef, useState } from 'react';
10+
import { useEffect, useMemo, useRef, useState } from 'react';
1111

1212
import { SLIDE_MAX_WIDTH } from '@/constants/layout';
1313
import { useSlideActions, useSlideId, useSlideScript } from '@/hooks';
14-
import { useScript } from '@/hooks/queries/useScript';
14+
import { useProjectScripts, useScript } from '@/hooks/queries/useScript';
1515
import { useSlideCommentsLoader } from '@/hooks/useSlideCommentsLoader';
1616
import type { SlideListItem } from '@/types/slide';
1717

@@ -28,24 +28,42 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps
2828
const { initSlide, updateScript, updateSlide } = useSlideActions();
2929
const slideId = useSlideId();
3030
const script = useSlideScript();
31-
const { data: scriptData } = useScript(slideId);
3231
const lastSyncedSlideIdRef = useRef<string>('');
3332
const lastSyncedScriptRef = useRef<string | null>(null);
3433

34+
const projectId = slide?.projectId ?? '';
35+
const currentSlideId = slide?.slideId ?? '';
36+
const { data: projectScripts } = useProjectScripts(projectId, {
37+
enabled: !!projectId,
38+
staleTime: 1000 * 60 * 10,
39+
});
40+
41+
const projectScript = useMemo(() => {
42+
if (!currentSlideId) return undefined;
43+
return projectScripts?.scripts.find((item) => item.slideId === currentSlideId)?.scriptText;
44+
}, [projectScripts, currentSlideId]);
45+
46+
const shouldFetchDetailScript = !projectScript && !slide?.script;
47+
const { data: scriptData } = useScript(currentSlideId, {
48+
enabled: shouldFetchDetailScript,
49+
staleTime: 1000 * 60 * 10,
50+
});
51+
52+
const resolvedServerScript = projectScript ?? slide?.script ?? scriptData?.scriptText ?? '';
53+
3554
useEffect(() => {
3655
if (!slide) return;
56+
const nextSlide =
57+
resolvedServerScript !== slide.script ? { ...slide, script: resolvedServerScript } : slide;
3758

3859
const isSameSlide = slide.slideId === slideId;
3960
if (isSameSlide) {
40-
updateSlide(slide);
61+
updateSlide(nextSlide);
4162
return;
4263
}
4364

44-
initSlide(slide);
45-
updateScript('');
46-
}, [slide, slideId, initSlide, updateScript, updateSlide]);
47-
48-
useSlideCommentsLoader(slide?.slideId);
65+
initSlide(nextSlide);
66+
}, [slide, slideId, initSlide, resolvedServerScript, updateSlide]);
4967

5068
useEffect(() => {
5169
if (lastSyncedSlideIdRef.current !== slideId) {
@@ -55,21 +73,23 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps
5573
}, [slideId]);
5674

5775
useEffect(() => {
58-
if (!scriptData) return;
76+
if (!slideId) return;
5977

60-
const serverScript = scriptData.scriptText;
78+
const serverScript = resolvedServerScript;
6179
const hasSyncedOnce = lastSyncedScriptRef.current !== null;
6280
const hasLocalEditAfterSync = hasSyncedOnce && script !== lastSyncedScriptRef.current;
6381

64-
// 로컬 편집이 있는 동안에는 서버 응답으로 덮어쓰지 않습니다.
82+
// 로컬 편집 중에는 서버 값으로 덮어쓰지 않습니다.
6583
if (hasLocalEditAfterSync && script !== serverScript) return;
6684

6785
if (script !== serverScript) {
6886
updateScript(serverScript);
6987
}
7088

7189
lastSyncedScriptRef.current = serverScript;
72-
}, [script, scriptData, updateScript]);
90+
}, [resolvedServerScript, script, slideId, updateScript]);
91+
92+
useSlideCommentsLoader(slide?.slideId);
7393

7494
return (
7595
<div className="relative h-full min-h-0 flex flex-col pb-[clamp(12rem,30vh,20rem)] md:pb-0">

src/components/slide/script/ScriptBoxContent.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,15 @@ export default function ScriptBoxContent() {
4040
onBlur={flushSave}
4141
placeholder="슬라이드 대본을 입력하세요..."
4242
aria-label="슬라이드 대본"
43-
className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 pr-28 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600"
43+
className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600"
4444
/>
4545

46-
<div className="absolute bottom-3 right-4">
46+
<div className="pointer-events-none absolute bottom-3 right-4 z-10">
4747
<button
4848
type="button"
4949
onClick={() => setIsSpeedModalOpen(true)}
5050
aria-label={`읽기 속도 설정 열기 (현재 예상 시간 ${estimatedDuration})`}
51-
className="inline-flex min-h-9 items-center gap-2 rounded-md bg-white px-3 py-1.5 text-gray-700 transition-colors hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
51+
className="pointer-events-auto inline-flex min-h-9 items-center gap-2 rounded-full border border-gray-200 bg-white/95 px-3 py-1.5 text-gray-700 shadow-xs backdrop-blur-sm transition-colors hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
5252
>
5353
<span aria-live="polite" aria-atomic="true" className="text-sm font-semibold leading-4">
5454
{estimatedDuration}

src/components/slide/script/ScriptReadingSpeedModal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,13 @@ export default function ScriptReadingSpeedModal({ isOpen, onClose }: ScriptReadi
111111
step={1}
112112
value={selectedSpeed}
113113
onChange={(event) => setSelectedSpeed(Number(event.target.value))}
114-
className="h-2 w-full cursor-pointer accent-main"
114+
className={clsx(
115+
'block h-2 w-full cursor-pointer appearance-none rounded-full border border-gray-200 bg-gray-100',
116+
'[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent',
117+
'[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0 [&::-moz-range-track]:bg-transparent',
118+
'[&::-webkit-slider-thumb]:-mt-1.25 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-main [&::-webkit-slider-thumb]:bg-main',
119+
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border [&::-moz-range-thumb]:border-main [&::-moz-range-thumb]:bg-main',
120+
)}
115121
/>
116122
<div className="flex items-center justify-between text-caption text-gray-600">
117123
<span>매우 느리게</span>

src/components/video/RecordingSection.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import IconArrowLeft from '@/assets/icons/icon-arrow-left.svg?react';
44
import IconArrowRight from '@/assets/icons/icon-arrow-right.svg?react';
55
import { Logo, SlideImage } from '@/components/common';
66
import { usePresentation } from '@/hooks/queries/usePresentations';
7-
import { useScript } from '@/hooks/queries/useScript';
7+
import { useProjectScripts } from '@/hooks/queries/useScript';
88
import { useSlides } from '@/hooks/queries/useSlides';
9+
import { useRecorder } from '@/hooks/useRecorder';
910

10-
import { useRecorder } from '../../hooks/useRecorder';
1111
import StopButton from './StopButton';
1212

1313
interface SlideData {
@@ -40,11 +40,32 @@ export const RecordingSection = ({
4040
const { isRecording, startRecording, stopRecording, getRecordedBlob } = useRecorder();
4141

4242
const { data: presentation } = usePresentation(projectId);
43-
const { data: slidesData } = useSlides(projectId);
44-
const slidesList = useMemo(
45-
() => slidesData?.map((slide) => ({ id: slide.slideId, url: slide.imageUrl })) ?? [],
46-
[slidesData],
43+
const { data: slidesData, isLoading: isSlidesLoading } = useSlides(projectId, {
44+
liveSync: false,
45+
});
46+
const { data: projectScripts, isLoading: isProjectScriptsLoading } = useProjectScripts(
47+
projectId,
48+
{
49+
enabled: !!projectId,
50+
staleTime: 1000 * 60 * 10,
51+
},
4752
);
53+
const slidesList = useMemo(() => {
54+
const projectScriptMap = new Map(
55+
(projectScripts?.scripts ?? []).map((scriptItem) => [
56+
String(scriptItem.slideId),
57+
scriptItem.scriptText ?? '',
58+
]),
59+
);
60+
61+
return (
62+
slidesData?.map((slide) => ({
63+
id: slide.slideId,
64+
url: slide.imageUrl,
65+
script: projectScriptMap.get(String(slide.slideId)) ?? slide.script ?? '',
66+
})) ?? []
67+
);
68+
}, [projectScripts, slidesData]);
4869
const totalPages = slidesList.length > 0 ? slidesList.length : 1;
4970

5071
const [currentPage, setCurrentPage] = useState<number>(1);
@@ -56,9 +77,10 @@ export const RecordingSection = ({
5677
const [isFinishing, setIsFinishing] = useState<boolean>(false);
5778

5879
const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
59-
60-
const currentSlideId = slidesList[currentPage - 1]?.id;
61-
const { data: scriptData } = useScript(currentSlideId ?? '');
80+
const currentSlideScript = slidesList[currentPage - 1]?.script ?? '';
81+
const isScriptLoading = isSlidesLoading || isProjectScriptsLoading;
82+
const scriptDisplayText =
83+
currentSlideScript || (isScriptLoading ? '대본 불러오는 중...' : '대본이 없습니다.');
6284

6385
// 녹화 시작 및 첫 로그 생성 함수
6486
const startRecordingWithLog = useCallback(
@@ -316,7 +338,7 @@ export const RecordingSection = ({
316338
<h3 className="text-body-s-bold text-gray-800">발표 대본</h3>
317339
</div>
318340
<div className="scrollbar-hide flex-1 overflow-y-auto text-body-m leading-normal text-black whitespace-pre-wrap">
319-
{scriptData?.scriptText || '대본이 없습니다.'}
341+
{scriptDisplayText}
320342
</div>
321343
</div>
322344

src/hooks/UseSlideSelectors.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useSlideActions,
99
useSlideComments,
1010
useSlideId,
11+
useSlideProjectId,
1112
useSlideScript,
1213
useSlideThumb,
1314
useSlideTitle,
@@ -31,6 +32,11 @@ describe('useSlideSelectors', () => {
3132
expect(result.current).toBe('');
3233
});
3334

35+
it('useSlideProjectId returns empty string', () => {
36+
const { result } = renderHook(() => useSlideProjectId());
37+
expect(result.current).toBe('');
38+
});
39+
3440
it('useSlideThumb returns empty string', () => {
3541
const { result } = renderHook(() => useSlideThumb());
3642
expect(result.current).toBe('');
@@ -70,6 +76,11 @@ describe('useSlideSelectors', () => {
7076
expect(result.current).toBe('Test Title');
7177
});
7278

79+
it('useSlideProjectId returns projectId', () => {
80+
const { result } = renderHook(() => useSlideProjectId());
81+
expect(result.current).toBe(slide.projectId);
82+
});
83+
7384
it('useSlideThumb returns imageUrl', () => {
7485
const { result } = renderHook(() => useSlideThumb());
7586
expect(result.current).toBe('https://img.url');
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { useQueryClient } from '@tanstack/react-query';
4+
5+
import { getProjectScripts } from '@/api/endpoints/scripts';
6+
import { getSlides } from '@/api/endpoints/slides';
7+
import { queryKeys } from '@/api/queryClient';
8+
9+
/**
10+
* 프로젝트 진입 시 슬라이드/대본 데이터를 한 번 미리 적재합니다.
11+
*/
12+
export function useProjectEntryPrefetch(projectId?: string) {
13+
const queryClient = useQueryClient();
14+
const prefetchedProjectIdsRef = useRef<Set<string>>(new Set());
15+
16+
useEffect(() => {
17+
if (!projectId) return;
18+
if (prefetchedProjectIdsRef.current.has(projectId)) return;
19+
20+
prefetchedProjectIdsRef.current.add(projectId);
21+
22+
void Promise.all([
23+
queryClient.prefetchQuery({
24+
queryKey: queryKeys.slides.list(projectId),
25+
queryFn: () => getSlides(projectId),
26+
}),
27+
queryClient.prefetchQuery({
28+
queryKey: queryKeys.scripts.project(projectId),
29+
queryFn: () => getProjectScripts(projectId),
30+
}),
31+
]).catch(() => {
32+
// 실패 시 재시도 가능하도록 가드를 해제합니다.
33+
prefetchedProjectIdsRef.current.delete(projectId);
34+
});
35+
}, [projectId, queryClient]);
36+
}

0 commit comments

Comments
 (0)