diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index 1cb05589..f4e2c03f 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -6,12 +6,14 @@ import AuthCallbackPage from '@/pages/AuthCallback' import WorkspacePage from '@/pages/Workspace' import InterviewSetupPage from '@/pages/InterviewSetup' import InterviewSessionPage from '@/pages/InterviewSession' +import PracticePage from '@/pages/Practice' import SessionFeedbackPage from '@/pages/SessionFeedback' import SharedFeedbackPage from '@/pages/SharedFeedback' export const router = createBrowserRouter([ { path: '/', element: }, { path: '/login', element: }, + { path: '/practice/:track', element: }, { path: '/share/:token', element: }, { path: '/auth/callback', element: }, { diff --git a/frontend/src/domain/practice/index.ts b/frontend/src/domain/practice/index.ts new file mode 100644 index 00000000..db26c2b9 --- /dev/null +++ b/frontend/src/domain/practice/index.ts @@ -0,0 +1,9 @@ +export type { + PracticeTrack, + QuestionBank, + PracticeQuestion, + RawSubject, + RawCategory, + RawQuestion, +} from './model/types' +export { selectQuestions } from './lib/selectQuestions' diff --git a/frontend/src/domain/practice/lib/selectQuestions.test.ts b/frontend/src/domain/practice/lib/selectQuestions.test.ts new file mode 100644 index 00000000..c490eff3 --- /dev/null +++ b/frontend/src/domain/practice/lib/selectQuestions.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import type { QuestionBank } from '../model/types' +import { selectQuestions } from './selectQuestions' + +const q = (id: string) => ({ id, question: `Q-${id}`, answer: `A-${id}` }) + +function bank(spec: Record>): QuestionBank { + return { + version: '1.0', + title: 'test', + description: 'test', + subjects: Object.entries(spec).map(([sid, cats]) => ({ + id: sid, + name: sid, + categories: Object.entries(cats).map(([cid, n]) => ({ + id: cid, + name: cid, + questions: Array.from({ length: n }, (_, i) => q(`${cid}-${i}`)), + })), + })), + } +} + +// 결정적 RNG: 시퀀스를 순환하며 반환. +function seq(values: number[]): () => number { + let i = 0 + return () => values[i++ % values.length] +} + +describe('selectQuestions', () => { + it('요청 개수만큼 중복 없이 반환한다', () => { + const b = bank({ s1: { c1: 5, c2: 5 }, s2: { c3: 5 } }) + const picked = selectQuestions(b, 6, seq([0.1, 0.5, 0.9, 0.3, 0.7])) + expect(picked).toHaveLength(6) + expect(new Set(picked.map((p) => p.id)).size).toBe(6) + }) + + it('풀보다 많이 요청하면 풀 크기로 제한된다', () => { + const b = bank({ s1: { c1: 2 } }) + expect(selectQuestions(b, 10)).toHaveLength(2) + }) + + it('과목/카테고리 메타데이터를 보존한다', () => { + const b = bank({ Frontend: { html: 1 } }) + const [only] = selectQuestions(b, 1) + expect(only).toMatchObject({ subject: 'Frontend', category: 'html', id: 'html-0' }) + }) + + it('rng=0 이면 가중치가 가장 큰 앞쪽 과목의 첫 질문이 먼저 나온다', () => { + const b = bank({ s1: { c1: 3 }, s2: { c2: 3 }, s3: { c3: 3 } }) + // 누적 가중치 스캔에서 r<=0 즉시 첫 항목 선택 → 첫 과목 c1 의 첫 질문. + const [first] = selectQuestions(b, 1, () => 0) + expect(first.subject).toBe('s1') + }) + + it('빈 은행에서는 빈 배열을 반환한다', () => { + expect(selectQuestions(bank({}), 5)).toEqual([]) + }) +}) diff --git a/frontend/src/domain/practice/lib/selectQuestions.ts b/frontend/src/domain/practice/lib/selectQuestions.ts new file mode 100644 index 00000000..9a23768b --- /dev/null +++ b/frontend/src/domain/practice/lib/selectQuestions.ts @@ -0,0 +1,82 @@ +import type { PracticeQuestion, QuestionBank } from '../model/types' + +// 각 질문 JSON 은 과목(subject)·카테고리·질문 순서가 모두 "빈출 → 마이너" 로 +// 정렬되어 있다. 따라서 앞쪽 과목/질문일수록 더 자주 출제되도록 가중치를 준다. +// +// - subjectWeight: 앞선 과목일수록 큰 선형 가중치 (N, N-1, … , 1). +// - positionWeight: 카테고리 내 뒤쪽 질문일수록 완만하게 감소. +// - categoryPenalty: 한 카테고리에서 뽑을 때마다 같은 카테고리의 남은 가중치를 +// 줄여, 특정 영역에 쏠리지 않고 카테고리가 고르게 분배되도록 한다. +const POSITION_DECAY = 0.15 +const CATEGORY_PENALTY = 0.25 + +interface PoolEntry { + question: PracticeQuestion + categoryId: string + weight: number +} + +function buildPool(bank: QuestionBank): PoolEntry[] { + const pool: PoolEntry[] = [] + const subjectCount = bank.subjects.length + + bank.subjects.forEach((subject, si) => { + const subjectWeight = subjectCount - si + subject.categories.forEach((category) => { + category.questions.forEach((q, qi) => { + pool.push({ + question: { + id: q.id, + question: q.question, + answer: q.answer, + subject: subject.name, + category: category.name, + }, + categoryId: category.id, + weight: subjectWeight * (1 / (1 + qi * POSITION_DECAY)), + }) + }) + }) + }) + + return pool +} + +function pickWeightedIndex(pool: PoolEntry[], rng: () => number): number { + const total = pool.reduce((sum, e) => sum + e.weight, 0) + if (total <= 0) return Math.floor(rng() * pool.length) + + let r = rng() * total + for (let i = 0; i < pool.length; i++) { + r -= pool[i].weight + if (r <= 0) return i + } + return pool.length - 1 +} + +/** + * 질문 은행에서 가중 무작위로 `count` 개의 질문을 (중복 없이) 선택한다. + * 앞선 과목/질문에 가중치를 두되 카테고리가 한쪽으로 쏠리지 않게 분배한다. + * + * @param rng 0 이상 1 미만 난수 생성기 (테스트에서 주입 가능, 기본 Math.random) + */ +export function selectQuestions( + bank: QuestionBank, + count: number, + rng: () => number = Math.random, +): PracticeQuestion[] { + const pool = buildPool(bank) + const target = Math.min(count, pool.length) + const picked: PracticeQuestion[] = [] + + for (let k = 0; k < target; k++) { + const idx = pickWeightedIndex(pool, rng) + const [chosen] = pool.splice(idx, 1) + picked.push(chosen.question) + for (const entry of pool) { + if (entry.categoryId === chosen.categoryId) entry.weight *= CATEGORY_PENALTY + } + } + + return picked +} diff --git a/frontend/src/domain/practice/model/types.ts b/frontend/src/domain/practice/model/types.ts new file mode 100644 index 00000000..9cd01499 --- /dev/null +++ b/frontend/src/domain/practice/model/types.ts @@ -0,0 +1,38 @@ +// 정적(서버리스) 연습 면접 도메인 모델. +// public/data/*.json 의 스키마(subjects → categories → questions)를 그대로 반영한다. + +export type PracticeTrack = 'frontend' | 'backend' | 'cs' + +export interface RawQuestion { + id: string + question: string + answer: string +} + +export interface RawCategory { + id: string + name: string + questions: RawQuestion[] +} + +export interface RawSubject { + id: string + name: string + categories: RawCategory[] +} + +export interface QuestionBank { + version: string + title: string + description: string + subjects: RawSubject[] +} + +// 면접에서 출제되는 단일 질문. 어느 과목/카테고리에서 왔는지 함께 보존한다. +export interface PracticeQuestion { + id: string + question: string + answer: string + subject: string + category: string +} diff --git a/frontend/src/features/practice/api/loadQuestionBank.ts b/frontend/src/features/practice/api/loadQuestionBank.ts new file mode 100644 index 00000000..8fcad379 --- /dev/null +++ b/frontend/src/features/practice/api/loadQuestionBank.ts @@ -0,0 +1,16 @@ +import type { PracticeTrack, QuestionBank } from '@/domain/practice' + +// 서버 비용을 피하기 위해 질문 은행은 public/data 의 정적 JSON 으로 제공된다. +const TRACK_FILE: Record = { + frontend: '/data/frontend-interview-questions.json', + backend: '/data/backend-interview-questions.json', + cs: '/data/cs_interview_questions.json', +} + +export async function loadQuestionBank(track: PracticeTrack): Promise { + const res = await fetch(TRACK_FILE[track]) + if (!res.ok) { + throw new Error(`질문 데이터를 불러오지 못했습니다 (${res.status})`) + } + return (await res.json()) as QuestionBank +} diff --git a/frontend/src/features/practice/index.ts b/frontend/src/features/practice/index.ts new file mode 100644 index 00000000..74576caa --- /dev/null +++ b/frontend/src/features/practice/index.ts @@ -0,0 +1,2 @@ +export { PracticeRunner } from './ui/PracticeRunner' +export { TrackPicker } from './ui/TrackPicker' diff --git a/frontend/src/features/practice/model/usePracticeSession.ts b/frontend/src/features/practice/model/usePracticeSession.ts new file mode 100644 index 00000000..7d2cb53d --- /dev/null +++ b/frontend/src/features/practice/model/usePracticeSession.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { selectQuestions } from '@/domain/practice' +import type { PracticeTrack } from '@/domain/practice' +import { loadQuestionBank } from '../api/loadQuestionBank' + +const DEFAULT_COUNT = 8 + +export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT_COUNT) { + const bankQuery = useQuery({ + queryKey: ['practice-bank', track], + queryFn: () => loadQuestionBank(track), + staleTime: Infinity, + }) + + // seed 가 바뀌면 같은 은행에서 질문을 다시 뽑는다(다시 풀기). + const [seed, setSeed] = useState(0) + const [index, setIndex] = useState(0) + const [revealed, setRevealed] = useState(false) + const [answers, setAnswers] = useState>({}) + + const questions = useMemo(() => { + if (!bankQuery.data) return [] + return selectQuestions(bankQuery.data, count) + // seed 를 의존성에 포함해 "다시 풀기" 시 새 표본을 뽑는다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bankQuery.data, count, seed]) + + const total = questions.length + const current = questions[index] + const isLast = index >= total - 1 + const done = total > 0 && index >= total + + const reveal = useCallback(() => setRevealed(true), []) + + const next = useCallback(() => { + setRevealed(false) + setIndex((i) => i + 1) + }, []) + + const setAnswer = useCallback((id: string, value: string) => { + setAnswers((prev) => ({ ...prev, [id]: value })) + }, []) + + const restart = useCallback(() => { + setIndex(0) + setRevealed(false) + setAnswers({}) + setSeed((s) => s + 1) + }, []) + + return { + bankTitle: bankQuery.data?.title, + isLoading: bankQuery.isLoading, + isError: bankQuery.isError, + error: bankQuery.error as Error | undefined, + refetch: bankQuery.refetch, + questions, + current, + index, + total, + isLast, + done, + revealed, + answers, + reveal, + next, + setAnswer, + restart, + } +} diff --git a/frontend/src/features/practice/ui/PracticeRunner.tsx b/frontend/src/features/practice/ui/PracticeRunner.tsx new file mode 100644 index 00000000..ad7f19a7 --- /dev/null +++ b/frontend/src/features/practice/ui/PracticeRunner.tsx @@ -0,0 +1,131 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@/shared/ui/Button' +import { Spinner } from '@/shared/ui/Spinner' +import { StatusBadge } from '@/shared/ui' +import { TextArea } from '@/shared/ui/TextArea' +import type { PracticeTrack } from '@/domain/practice' +import { usePracticeSession } from '../model/usePracticeSession' + +const TRACK_LABEL: Record = { + frontend: '프론트엔드 직무 면접', + backend: '백엔드 직무 면접', + cs: 'CS 전공 지식 면접', +} + +export function PracticeRunner({ track }: { track: PracticeTrack }) { + const navigate = useNavigate() + const { + isLoading, + isError, + error, + refetch, + current, + index, + total, + isLast, + done, + revealed, + answers, + reveal, + next, + setAnswer, + restart, + } = usePracticeSession(track) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || total === 0) { + return ( +
+

+ {error?.message ?? '질문을 불러오지 못했습니다.'} +

+ +
+ ) + } + + if (done) { + return ( +
+
+

면접을 마쳤습니다 🎉

+

+ 총 {total}개의 {TRACK_LABEL[track]} 질문을 연습했습니다. +

+
+
+ + +
+
+ ) + } + + return ( +
+
+
+

{TRACK_LABEL[track]}

+

+ 질문 {index + 1} / {total} +

+
+ +
+ +
+
+
+ {current.subject} + {current.category} +
+ +

{current.question}

+ +
+ +