Develop#33
Conversation
[app] 실수로 디벨롭 제외하고 메인에 머지함
[widgets] 문제 풀이·마이페이지 UI를 정리하고 학급 분기를 수정합니다
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request introduces several features and improvements, including an updated classroom viewer role check for teachers and admins, category tags for problems, a new profile image edit dialog with dynamic tier progress tracking, and problem list filters. The reviewer provided valuable feedback on improving performance and accessibility: replacing useMemo with useEffect for safe object URL creation to prevent memory leaks, moving static filter arrays outside of components to avoid re-creation on render, and replacing useRouter with Next.js <Link> components for better SEO and accessibility.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const previewUrl = useMemo(() => { | ||
| if (!selectedFile) return profile.profileImageUrl; | ||
| return URL.createObjectURL(selectedFile); | ||
| }, [profile.profileImageUrl, selectedFile]); | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| if (selectedFile && previewUrl.startsWith("blob:")) { | ||
| URL.revokeObjectURL(previewUrl); | ||
| } | ||
| }; | ||
| }, [previewUrl, selectedFile]); |
There was a problem hiding this comment.
useMemo 내부에서 URL.createObjectURL을 호출하여 side-effect를 발생하는 것은 React의 렌더링 원칙에 어긋나며, 메모리 누수(Memory Leak)를 유발할 수 있는 안티패턴입니다.
React의 Concurrent Mode나 Strict Mode, 혹은 Suspense 등으로 인해 컴포넌트가 렌더링만 되고 커밋(Commit)되지 않은 채 버려질 경우, useMemo에서 생성된 Object URL은 useEffect가 실행되지 않아 URL.revokeObjectURL로 해제되지 않고 메모리에 영구히 남게 됩니다.
렌더링 단계(Render Phase)를 순수하게 유지하고 안전하게 메모리를 관리하기 위해, previewUrl을 상태(State)로 관리하고 useEffect 내부에서 Object URL을 생성 및 해제하도록 수정하는 것을 권장합니다.
const [previewUrl, setPreviewUrl] = useState<string>(profile.profileImageUrl);
useEffect(() => {
if (!selectedFile) {
setPreviewUrl(profile.profileImageUrl);
return;
}
const objectUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [selectedFile, profile.profileImageUrl]);
| function ProblemSelectFilters({ | ||
| selectedProblemStatus, | ||
| selectedLanguage, | ||
| onSelectProblemStatus, | ||
| onSelectLanguage, | ||
| }: { | ||
| selectedProblemStatus: string; | ||
| selectedLanguage: string; | ||
| onSelectProblemStatus: (problemStatus: string) => void; | ||
| onSelectLanguage: (language: string) => void; | ||
| }) { | ||
| const levels = ["전체"]; | ||
| const problemStatuses = ["전체"]; | ||
| const languages = ["전체"]; | ||
|
|
||
| return ( | ||
| <div className="border-line bg-surface grid gap-3 rounded-lg border p-2 md:grid-cols-2 xl:grid-cols-3"> | ||
| <FilterSelect | ||
| label="난이도" | ||
| value={levels[0] ?? "전체"} | ||
| options={levels} | ||
| onChange={() => undefined} | ||
| /> | ||
| <FilterSelect | ||
| label="문제 상태" | ||
| value={selectedProblemStatus} | ||
| options={problemStatuses} | ||
| onChange={onSelectProblemStatus} | ||
| /> | ||
| <FilterSelect | ||
| label="언어" | ||
| value={selectedLanguage} | ||
| options={languages} | ||
| onChange={onSelectLanguage} | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
levels, problemStatuses, languages와 같은 정적 배열 상수가 컴포넌트 내부에서 선언되어 있어, 컴포넌트가 렌더링될 때마다 매번 새로운 배열 객체가 생성됩니다.
이러한 정적 데이터는 컴포넌트 외부로 분리하여 선언함으로써 불필요한 메모리 할당과 가비지 컬렉션(GC) 부담을 줄이는 것이 좋습니다. 또한 LEVELS[0] ?? "전체"와 같이 항상 값이 존재하는 정적 배열의 첫 번째 요소에 대한 불필요한 null 병합 연산자도 제거하여 코드를 단순화할 수 있습니다.
const LEVELS = ["전체"];
const PROBLEM_STATUSES = ["전체"];
const LANGUAGES = ["전체"];
function ProblemSelectFilters({
selectedProblemStatus,
selectedLanguage,
onSelectProblemStatus,
onSelectLanguage,
}: {
selectedProblemStatus: string;
selectedLanguage: string;
onSelectProblemStatus: (problemStatus: string) => void;
onSelectLanguage: (language: string) => void;
}) {
return (
<div className="border-line bg-surface grid gap-3 rounded-lg border p-2 md:grid-cols-2 xl:grid-cols-3">
<FilterSelect
label="난이도"
value={LEVELS[0]}
options={LEVELS}
onChange={() => undefined}
/>
<FilterSelect
label="문제 상태"
value={selectedProblemStatus}
options={PROBLEM_STATUSES}
onChange={onSelectProblemStatus}
/>
<FilterSelect
label="언어"
value={selectedLanguage}
options={LANGUAGES}
onChange={onSelectLanguage}
/>
</div>
);
}
| import { useRouter } from "next/navigation"; | ||
| import { useSuspenseQuery } from "@tanstack/react-query"; | ||
| import { problemKeys } from "@/entities/problem/api/problem-keys"; | ||
| import { EditIcon } from "@/shared/assets/EditIcon"; | ||
| import { useWarmPythonRuntime } from "@/shared/lib/pyodide/warm-runtime"; | ||
| import { ChevronLeftIcon } from "@/shared/assets/ChevronLeftIcon"; | ||
| import { QueryBoundary, type QueryErrorFallbackProps } from "@/shared/ui/query-boundary"; |
There was a problem hiding this comment.
단순 페이지 이동을 위해 useRouter를 사용하는 대신, Next.js의 <Link> 컴포넌트를 사용하는 것이 웹 접근성(SEO, 스크린 리더 지원) 및 사용자 경험(새 탭에서 열기 등) 측면에서 훨씬 좋습니다.
이에 따라 불필요한 useRouter 임포트를 제거하고 next/link에서 Link를 임포트하도록 수정합니다.
| import { useRouter } from "next/navigation"; | |
| import { useSuspenseQuery } from "@tanstack/react-query"; | |
| import { problemKeys } from "@/entities/problem/api/problem-keys"; | |
| import { EditIcon } from "@/shared/assets/EditIcon"; | |
| import { useWarmPythonRuntime } from "@/shared/lib/pyodide/warm-runtime"; | |
| import { ChevronLeftIcon } from "@/shared/assets/ChevronLeftIcon"; | |
| import { QueryBoundary, type QueryErrorFallbackProps } from "@/shared/ui/query-boundary"; | |
| import Link from "next/link"; | |
| import { useSuspenseQuery } from "@tanstack/react-query"; | |
| import { problemKeys } from "@/entities/problem/api/problem-keys"; | |
| import { EditIcon } from "@/shared/assets/EditIcon"; | |
| import { useWarmPythonRuntime } from "@/shared/lib/pyodide/warm-runtime"; | |
| import { ChevronLeftIcon } from "@/shared/assets/ChevronLeftIcon"; | |
| import { QueryBoundary, type QueryErrorFallbackProps } from "@/shared/ui/query-boundary"; |
| const router = useRouter(); | ||
| useWarmPythonRuntime(); | ||
|
|
||
| return ( | ||
| <main className="mx-auto w-full max-w-[1440px] space-y-4 px-4 py-6 sm:px-8 xl:px-10"> | ||
| <button type="button" className="text-muted flex items-center gap-1 text-base font-medium"> | ||
| <button | ||
| type="button" | ||
| onClick={() => router.push("/")} | ||
| className="text-muted flex cursor-pointer items-center gap-1 text-base font-medium" | ||
| > | ||
| <ChevronLeftIcon className="size-5" /> | ||
| 문제 목록 | ||
| </button> |
There was a problem hiding this comment.
<button> 태그와 router.push를 이용한 네비게이션 대신, 시맨틱한 <a> 태그로 렌더링되는 <Link> 컴포넌트를 사용하여 웹 표준과 접근성을 준수하도록 변경합니다.
useWarmPythonRuntime();
return (
<main className="mx-auto w-full max-w-[1440px] space-y-4 px-4 py-6 sm:px-8 xl:px-10">
<Link
href="/"
className="text-muted flex cursor-pointer items-center gap-1 text-base font-medium"
>
<ChevronLeftIcon className="size-5" />
문제 목록
</Link>
No description provided.