Skip to content

Commit 473d175

Browse files
committed
Commit all pending workspace changes
1 parent 8866203 commit 473d175

114 files changed

Lines changed: 2903 additions & 1223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

context/refactoring-lesson-url.md

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
# 레슨 URL 단순화 리팩토링
2+
3+
## 목표
4+
5+
```
6+
Before: /courses/c/c-0/c-1-5
7+
After: /courses/c-1-5
8+
```
9+
10+
lessonId 자체가 언어 prefix + 챕터 번호 + 레슨 번호를 모두 포함하므로,
11+
URL에서 `:lang``:chapterId`를 제거한다.
12+
13+
---
14+
15+
## 핵심 근거
16+
17+
- `lesson.languageId` — API 응답에 이미 포함 (lang URL 파라미터 불필요)
18+
- `lesson.chapterId` — API 응답에 이미 포함 (chapterId URL 파라미터 불필요)
19+
- lessonId (`c-1-5`, `js-2-3` 등)는 전역 고유 식별자
20+
21+
---
22+
23+
## 라우팅 충돌 해결 전략
24+
25+
`/courses/:lang``/courses/:lessonId`는 동일한 1-segment 경로라 충돌 발생.
26+
27+
**해결**: React Router 라우트 순서 제어 + LessonPage 진입 시 guard
28+
29+
라우트 등록 순서:
30+
1. `courses/:lang/:chapterId` (2-segment, 기존 유지)
31+
2. `courses/:lang` (1-segment, 기존 유지)
32+
3. `courses/:lessonId` (1-segment, 신규 — 반드시 `:lang` 이후에 등록)
33+
34+
단, React Router v6는 specificity 동점 시 **선언 순서 우선**이므로
35+
`:lang`이 먼저면 `/courses/c-1-5`가 LanguageCoursePage로 잡힌다.
36+
37+
**실제 해결법**: `/courses/:lessonId` 라우트를 먼저 두되,
38+
LessonPage 내부에서 param이 알려진 언어 ID(`c`, `java`, `python`, `javascript`, `cpp`, `python-practical`)면
39+
LanguageCoursePage로 redirect.
40+
41+
```tsx
42+
// LessonPage.tsx 상단
43+
const LANGUAGE_IDS = new Set(['c', 'java', 'python', 'javascript', 'cpp', 'python-practical']);
44+
45+
export function LessonPage() {
46+
const { lessonId } = useParams<{ lessonId: string }>();
47+
const navigate = useNavigate();
48+
49+
// lessonId가 언어 ID면 언어 페이지로 redirect
50+
if (lessonId && LANGUAGE_IDS.has(lessonId)) {
51+
return <Navigate to={`/courses/${lessonId}`} replace />;
52+
// 단, 이 경우 이중 라우트 등록이 필요 없도록 아래 라우터 구조로 정리
53+
}
54+
// ...
55+
}
56+
```
57+
58+
더 깔끔한 대안: 라우터에서 `/courses/:lessonId`를 마지막에 두고,
59+
LessonPage에서 `useLessonData` 결과가 404면 언어 페이지로 redirect.
60+
61+
---
62+
63+
## 파일별 변경 사항
64+
65+
### 1. `packages/shared/src/types/course.ts`
66+
67+
`getLessonPath` 시그니처 변경:
68+
69+
```ts
70+
// Before
71+
export function getLessonPath(
72+
languageId: string,
73+
chapterId: string,
74+
lessonId: string
75+
): string {
76+
return `/courses/${languageId}/${chapterId}/${lessonId}`;
77+
}
78+
79+
// After
80+
export function getLessonPath(lessonId: string): string {
81+
return `/courses/${lessonId}`;
82+
}
83+
84+
// getChapterPath는 변경 없음 (챕터 목록 페이지 URL 유지)
85+
```
86+
87+
---
88+
89+
### 2. `packages/frontend/src/router.tsx`
90+
91+
```tsx
92+
// Before
93+
{
94+
path: 'courses/:lang/:chapterId/:lessonId',
95+
lazy: async () => {
96+
const { LessonPage } = await import('./features/courses/LessonPage');
97+
return { Component: LessonPage };
98+
}
99+
},
100+
101+
// After — 기존 3-segment 라우트 제거, 1-segment 추가
102+
// 반드시 'courses/:lang' 라우트보다 앞에 등록해야 함
103+
{
104+
path: 'courses/:lessonId',
105+
lazy: async () => {
106+
const { LessonPage } = await import('./features/courses/LessonPage');
107+
return { Component: LessonPage };
108+
}
109+
},
110+
// courses/:lang 라우트는 유지 (언어 코스 페이지)
111+
// courses/:lang/:chapterId 라우트는 유지 (챕터 목록 페이지)
112+
```
113+
114+
> **주의**: React Router v6에서 `/courses/:lessonId``/courses/:lang` 보다
115+
> 앞에 두면 `/courses/c`도 LessonPage로 잡힌다.
116+
> LessonPage 내부에서 LANGUAGE_IDS guard로 redirect 처리해야 함.
117+
118+
---
119+
120+
### 3. `packages/frontend/src/features/courses/LessonPage.tsx`
121+
122+
```tsx
123+
// Before
124+
const { lang, chapterId, lessonId } = useParams<{
125+
lang: string;
126+
chapterId: string;
127+
lessonId: string;
128+
}>();
129+
130+
const { lesson, isLoading, isError, error, nextLessonId, quiz } = useLessonData({
131+
lessonId,
132+
chapterId,
133+
lang,
134+
});
135+
136+
const languageCoursePath = `/courses/${lang}`;
137+
const nextLessonPath =
138+
nextLessonId && lesson ? `/courses/${lang}/${lesson.chapterId}/${nextLessonId}` : null;
139+
140+
// ...
141+
<LessonUnifiedView
142+
languageId={lang || 'c'}
143+
// ...
144+
/>
145+
```
146+
147+
```tsx
148+
// After
149+
const LANGUAGE_IDS = new Set(['c', 'java', 'python', 'javascript', 'cpp', 'python-practical']);
150+
151+
export function LessonPage() {
152+
const { lessonId } = useParams<{ lessonId: string }>();
153+
const navigate = useNavigate();
154+
155+
// 언어 ID가 lessonId로 잡힌 경우 redirect
156+
if (lessonId && LANGUAGE_IDS.has(lessonId)) {
157+
return <Navigate to={`/courses/${lessonId}`} replace />;
158+
}
159+
160+
const { lesson, isLoading, isError, error, nextLessonId, quiz } = useLessonData({ lessonId });
161+
162+
// lesson.languageId, lesson.chapterId를 URL params 대신 사용
163+
const lang = lesson?.languageId;
164+
const languageCoursePath = lang ? `/courses/${lang}` : '/courses';
165+
const nextLessonPath = nextLessonId ? `/courses/${nextLessonId}` : null;
166+
167+
// ...
168+
<LessonUnifiedView
169+
languageId={lang || 'c'}
170+
// ...
171+
/>
172+
}
173+
```
174+
175+
---
176+
177+
### 4. `packages/frontend/src/features/courses/hooks/useLessonData.ts`
178+
179+
```ts
180+
// Before
181+
interface UseLessonDataOptions {
182+
lessonId: string | undefined;
183+
chapterId: string | undefined;
184+
lang: string | undefined;
185+
}
186+
187+
export function useLessonData({ lessonId, chapterId, lang }: UseLessonDataOptions) {
188+
const { data: chapterData } = useChapter(chapterId);
189+
// ...
190+
useEffect(() => {
191+
if (lesson) {
192+
setPageTitle(lesson.title, lesson.description, lang as SupportedLanguage);
193+
}
194+
}, [lesson, setPageTitle, lang]);
195+
}
196+
```
197+
198+
```ts
199+
// After — lesson 로드 후 lesson.chapterId로 chapter 조회
200+
interface UseLessonDataOptions {
201+
lessonId: string | undefined;
202+
}
203+
204+
export function useLessonData({ lessonId }: UseLessonDataOptions) {
205+
const { data: lesson, isLoading, isError, error } = useLesson(lessonId);
206+
// lesson이 로드된 뒤 chapterId를 얻어 chapter 조회
207+
const { data: chapterData } = useChapter(lesson?.chapterId);
208+
209+
// lang은 lesson.languageId에서 파생
210+
useEffect(() => {
211+
if (lesson) {
212+
setPageTitle(lesson.title, lesson.description, lesson.languageId as SupportedLanguage);
213+
}
214+
}, [lesson, setPageTitle]);
215+
}
216+
```
217+
218+
> **주의**: `useChapter(lesson?.chapterId)`는 lesson 로드 전 undefined로 호출되므로
219+
> `useChapter` 내부에서 `enabled: !!chapterId` 처리가 되어 있는지 확인 필요.
220+
221+
---
222+
223+
### 5. `packages/frontend/src/features/courses/ChapterLessonsPage.tsx`
224+
225+
```tsx
226+
// Before
227+
onClick={() => navigate(`/courses/${lang}/${chapterId}/${lesson.id}`)}
228+
229+
// After
230+
onClick={() => navigate(`/courses/${lesson.id}`)}
231+
```
232+
233+
---
234+
235+
### 6. `packages/frontend/src/features/courses/components/LessonCard.tsx`
236+
237+
```tsx
238+
// Before
239+
navigate(`/courses/${languageId}/${chapterId}/${lesson.id}`);
240+
241+
// After
242+
navigate(`/courses/${lesson.id}`);
243+
244+
// 이 경우 languageId, chapterId props가 불필요해지므로 제거 가능
245+
```
246+
247+
---
248+
249+
### 7. `packages/frontend/src/layouts/Sidebar.tsx`
250+
251+
```ts
252+
// Before — 3-segment URL에서 lessonId 추출
253+
const lessonRouteMatch = location.pathname.match(/^\/courses\/[^/]+\/[^/]+\/([^/]+)$/);
254+
const lessonId = lessonRouteMatch?.[1];
255+
256+
// After — 1-segment URL에서 lessonId 추출
257+
// /courses/{lessonId} 형태 매칭 (언어 ID 제외)
258+
const lessonRouteMatch = location.pathname.match(/^\/courses\/([^/]+-\d+-\d+.*)$/);
259+
const lessonId = lessonRouteMatch?.[1];
260+
```
261+
262+
---
263+
264+
### 8. `packages/frontend/src/features/courses/LessonPage.tsx` — nextLessonPath 관련
265+
266+
`LessonCompletedView`에 전달하는 경로:
267+
268+
```tsx
269+
// Before
270+
const nextLessonPath =
271+
nextLessonId && lesson ? `/courses/${lang}/${lesson.chapterId}/${nextLessonId}` : null;
272+
273+
// After
274+
const nextLessonPath = nextLessonId ? `/courses/${nextLessonId}` : null;
275+
```
276+
277+
---
278+
279+
## 기존 URL 하위 호환 처리 (선택)
280+
281+
이미 북마크하거나 공유된 링크를 위해 redirect 추가:
282+
283+
```tsx
284+
// router.tsx에 추가
285+
{
286+
path: 'courses/:lang/:chapterId/:lessonId',
287+
element: <LessonRedirect />,
288+
},
289+
```
290+
291+
```tsx
292+
// LessonRedirect 컴포넌트
293+
function LessonRedirect() {
294+
const { lessonId } = useParams<{ lessonId: string }>();
295+
return <Navigate to={`/courses/${lessonId}`} replace />;
296+
}
297+
```
298+
299+
---
300+
301+
## 변경 파일 요약
302+
303+
| 파일 | 변경 유형 |
304+
|---|---|
305+
| `packages/shared/src/types/course.ts` | `getLessonPath` 시그니처 단순화 |
306+
| `packages/frontend/src/router.tsx` | 레슨 라우트 경로 변경 + 구 URL redirect 추가 |
307+
| `packages/frontend/src/features/courses/LessonPage.tsx` | URL params 변경, lang/nextLessonPath 파생 방식 변경 |
308+
| `packages/frontend/src/features/courses/hooks/useLessonData.ts` | chapterId/lang 파라미터 제거 |
309+
| `packages/frontend/src/features/courses/ChapterLessonsPage.tsx` | navigate() URL 단순화 |
310+
| `packages/frontend/src/features/courses/components/LessonCard.tsx` | navigate() URL 단순화, 불필요 props 제거 |
311+
| `packages/frontend/src/layouts/Sidebar.tsx` | lessonId 추출 정규식 변경 |
312+
313+
---
314+
315+
## 작업 순서
316+
317+
1. `shared/types/course.ts``getLessonPath` 변경 (빌드 에러 발생 → 다음 단계로 연쇄 수정)
318+
2. `useLessonData.ts` — chapterId/lang 파라미터 제거
319+
3. `LessonPage.tsx` — URL params 정리, guard 추가
320+
4. `router.tsx` — 라우트 변경 + redirect 라우트 추가
321+
5. `ChapterLessonsPage.tsx`, `LessonCard.tsx` — navigate() 수정
322+
6. `Sidebar.tsx` — 정규식 수정
323+
7. 브라우저에서 검증:
324+
- `/courses/c-1-5` → 레슨 정상 로드
325+
- `/courses/c` → LanguageCoursePage 정상 표시
326+
- `/courses/c/c-0` → ChapterLessonsPage 정상 표시
327+
- `/courses/c/c-0/c-1-5` (구 URL) → `/courses/c-1-5`로 redirect

0 commit comments

Comments
 (0)