리팩토링: 코드 최적화

This commit is contained in:
2026-05-25 18:42:13 +09:00
parent 5ee33bf0d3
commit d56cada1ec
11 changed files with 215 additions and 175 deletions
+11
View File
@@ -52,6 +52,8 @@ lib/
redis.ts # Redis Client 싱글턴 redis.ts # Redis Client 싱글턴
store/ store/
gameStore.ts # Zustand 전역 게임 상태 gameStore.ts # Zustand 전역 게임 상태
types/
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
prisma/ prisma/
schema.prisma # MariaDB 모델 정의 schema.prisma # MariaDB 모델 정의
.gitea/workflows/deploy.yml # CI/CD 워크플로우 .gitea/workflows/deploy.yml # CI/CD 워크플로우
@@ -76,6 +78,14 @@ prisma/
- 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다. - 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다.
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다. - 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
### 컴포넌트 배치
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약 UI는 `components/game/` 하위에 둔다.
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
- 라우트별 조립 컴포넌트(`PlayClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
### API / 백엔드 ### API / 백엔드
@@ -90,6 +100,7 @@ prisma/
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다. - 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
- 미지수의 계수는 항상 정수이어야 한다. - 미지수의 계수는 항상 정수이어야 한다.
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(13), 미지수(없음/x/x,y/x,y,z). - 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(13), 미지수(없음/x/x,y/x,y,z).
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
--- ---
+27 -104
View File
@@ -1,16 +1,20 @@
"use client"; "use client";
import Link from "next/link"; import { useEffect, useState } from "react";
import { FormEvent, useEffect, useState } from "react"; import type { FormEvent } from "react";
import AnswerForm from "@/components/game/AnswerForm";
import FeedbackPanel from "@/components/game/FeedbackPanel";
import ScoreSummary from "@/components/game/ScoreSummary";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
function parseAnswer(value: string) { function parseAnswer(value: string) {
if (value.trim() === "") return null; const trimmed = value.trim();
const parsed = Number.parseInt(value, 10); if (!/^-?\d+$/.test(trimmed)) return null;
return Number.isNaN(parsed) ? null : parsed;
return Number(trimmed);
} }
export default function PlayClient() { export default function PlayClient() {
@@ -76,37 +80,11 @@ export default function PlayClient() {
if (isComplete) { if (isComplete) {
return ( return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950"> <ScoreSummary
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8"> correctCount={correctCount}
<div className="space-y-3"> totalCount={totalCount}
<p className="text-sm font-semibold text-emerald-700"> onRestart={resetSession}
/>
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={resetSession}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
); );
} }
@@ -151,78 +129,23 @@ export default function PlayClient() {
</div> </div>
</div> </div>
<form <AnswerForm
answerA={answerA}
answerB={answerB}
disabled={submitted}
error={inputError}
onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB}
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerA}
onChange={(event) => setAnswerA(event.target.value)}
disabled={submitted}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/> />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerB}
onChange={(event) => setAnswerB(event.target.value)}
disabled={submitted}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/>
</label>
</div>
{inputError ? (
<p className="mt-3 text-sm font-semibold text-red-700">
{inputError}
</p>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={submitted}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</form>
{currentResult ? ( {currentResult ? (
<div <FeedbackPanel
className={`rounded-md p-6 shadow-sm ring-1 ${ result={currentResult}
currentResult.correct answerLatex={currentProblem.latexAfter}
? "bg-emerald-50 text-emerald-950 ring-emerald-200" isLastProblem={isLastProblem}
: "bg-red-50 text-red-950 ring-red-200" onNext={nextProblem}
}`} />
>
<p className="text-lg font-bold">
{currentResult.correct ? "정답입니다." : "오답입니다."}
</p>
<div className="mt-3 text-xl font-semibold">
<KatexRenderer latex={currentProblem.latexAfter} />
</div>
<button
type="button"
onClick={nextProblem}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
) : null} ) : null}
</section> </section>
</main> </main>
+67
View File
@@ -0,0 +1,67 @@
"use client";
import type { FormEvent } from "react";
interface AnswerFormProps {
answerA: string;
answerB: string;
disabled: boolean;
error: string;
onAnswerAChange: (value: string) => void;
onAnswerBChange: (value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export default function AnswerForm({
answerA,
answerB,
disabled,
error,
onAnswerAChange,
onAnswerBChange,
onSubmit,
}: AnswerFormProps) {
return (
<form
onSubmit={onSubmit}
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerA}
onChange={(event) => onAnswerAChange(event.target.value)}
disabled={disabled}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/>
</label>
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<input
value={answerB}
onChange={(event) => onAnswerBChange(event.target.value)}
disabled={disabled}
inputMode="numeric"
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
/>
</label>
</div>
{error ? (
<p className="mt-3 text-sm font-semibold text-red-700">{error}</p>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={disabled}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
>
</button>
</div>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { GradeResult } from "@/lib/engine/types";
interface FeedbackPanelProps {
result: GradeResult;
answerLatex: string;
isLastProblem: boolean;
onNext: () => void;
}
export default function FeedbackPanel({
result,
answerLatex,
isLastProblem,
onNext,
}: FeedbackPanelProps) {
return (
<div
className={`rounded-md p-6 shadow-sm ring-1 ${
result.correct
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
: "bg-red-50 text-red-950 ring-red-200"
}`}
>
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<div className="mt-3 text-xl font-semibold">
<KatexRenderer latex={answerLatex} />
</div>
<button
type="button"
onClick={onNext}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
interface ScoreSummaryProps {
correctCount: number;
totalCount: number;
onRestart: () => void;
}
export default function ScoreSummary({
correctCount,
totalCount,
onRestart,
}: ScoreSummaryProps) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-3">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={onRestart}
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
+8 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { BlockMath, InlineMath } from "react-katex"; import { renderToString } from "katex";
interface KatexRendererProps { interface KatexRendererProps {
latex: string; latex: string;
@@ -11,5 +11,11 @@ export default function KatexRenderer({
latex, latex,
displayMode = false, displayMode = false,
}: KatexRendererProps) { }: KatexRendererProps) {
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />; const html = renderToString(latex, {
displayMode,
throwOnError: false,
});
const Tag = displayMode ? "div" : "span";
return <Tag dangerouslySetInnerHTML={{ __html: html }} />;
} }
+9 -2
View File
@@ -25,7 +25,12 @@ function formatLatexTermPair(first: number, second: number) {
return `${first} ${operator} ${Math.abs(second)}`; return `${first} ${operator} ${Math.abs(second)}`;
} }
function createProblem({ multiplier, a, operator, b }: ProblemSeed) { function createProblem({
multiplier,
a,
operator,
b,
}: ProblemSeed): ParenthesisSignProblem {
const distributedA = multiplier * a; const distributedA = multiplier * a;
const distributedB = operator === "-" ? multiplier * -b : multiplier * b; const distributedB = operator === "-" ? multiplier * -b : multiplier * b;
const key = `${multiplier}:${a}:${operator}:${b}`; const key = `${multiplier}:${a}:${operator}:${b}`;
@@ -65,6 +70,8 @@ function createAllProblemSeeds() {
return seeds; return seeds;
} }
const ALL_PROBLEM_SEEDS = createAllProblemSeeds();
function shuffle<T>(items: T[]) { function shuffle<T>(items: T[]) {
const shuffled = [...items]; const shuffled = [...items];
@@ -103,7 +110,7 @@ export function generateSession(count = 10): ParenthesisSignProblem[] {
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`); throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
} }
return shuffle(createAllProblemSeeds()).slice(0, count).map(createProblem); return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem);
} }
export function gradeAnswer( export function gradeAnswer(
-1
View File
@@ -14,7 +14,6 @@
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-katex": "^3.1.0",
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
-23
View File
@@ -20,9 +20,6 @@ importers:
react-dom: react-dom:
specifier: ^18 specifier: ^18
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-katex:
specifier: ^3.1.0
version: 3.1.0(prop-types@15.8.1)(react@18.3.1)
zustand: zustand:
specifier: ^5.0.13 specifier: ^5.0.13
version: 5.0.13(@types/react@18.3.29)(react@18.3.1) version: 5.0.13(@types/react@18.3.29)(react@18.3.1)
@@ -1339,10 +1336,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
katex@0.16.47:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
katex@0.17.0: katex@0.17.0:
resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
hasBin: true hasBin: true
@@ -1714,12 +1707,6 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-katex@3.1.0:
resolution: {integrity: sha512-At9uLOkC75gwn2N+ZXc5HD8TlATsB+3Hkp9OGs6uA8tM3dwZ3Wljn74Bk3JyHFPgSnesY/EMrIAB1WJwqZqejA==}
peerDependencies:
prop-types: ^15.8.1
react: '>=15.3.2 <20'
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3552,10 +3539,6 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
katex@0.16.47:
dependencies:
commander: 8.3.0
katex@0.17.0: katex@0.17.0:
dependencies: dependencies:
commander: 8.3.0 commander: 8.3.0
@@ -3884,12 +3867,6 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-katex@3.1.0(prop-types@15.8.1)(react@18.3.1):
dependencies:
katex: 0.16.47
prop-types: 15.8.1
react: 18.3.1
react@18.3.1: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
+1 -32
View File
@@ -10,7 +10,7 @@ import type {
UserAnswer, UserAnswer,
} from "@/lib/engine/types"; } from "@/lib/engine/types";
interface GameSession { export interface GameSession {
problems: ParenthesisSignProblem[]; problems: ParenthesisSignProblem[];
currentIndex: number; currentIndex: number;
results: (GradeResult | null)[]; results: (GradeResult | null)[];
@@ -22,10 +22,6 @@ interface GameState {
submitAnswer: (answer: UserAnswer) => void; submitAnswer: (answer: UserAnswer) => void;
nextProblem: () => void; nextProblem: () => void;
resetSession: () => void; resetSession: () => void;
currentProblem: () => ParenthesisSignProblem | null;
currentResult: () => GradeResult | null;
isComplete: () => boolean;
score: () => { correct: number; total: number };
} }
const DEFAULT_SESSION_COUNT = 10; const DEFAULT_SESSION_COUNT = 10;
@@ -79,31 +75,4 @@ export const useGameStore = create<GameState>((set, get) => ({
resetSession: () => { resetSession: () => {
set({ session: createSession() }); set({ session: createSession() });
}, },
currentProblem: () => {
const { session } = get();
if (!session) return null;
return session.problems[session.currentIndex] ?? null;
},
currentResult: () => {
const { session } = get();
if (!session) return null;
return session.results[session.currentIndex] ?? null;
},
isComplete: () => {
const { session } = get();
return Boolean(session && session.currentIndex >= session.problems.length);
},
score: () => {
const { session } = get();
if (!session) return { correct: 0, total: 0 };
return {
correct: session.results.filter((result) => result?.correct).length,
total: session.problems.length,
};
},
})); }));
-10
View File
@@ -1,10 +0,0 @@
declare module "react-katex" {
import type { ComponentType } from "react";
interface MathComponentProps {
math: string;
}
export const InlineMath: ComponentType<MathComponentProps>;
export const BlockMath: ComponentType<MathComponentProps>;
}