From 6323e7307cbd85fcde398fc697868802bd18f40d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Thu, 4 Jun 2026 01:12:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(practice):=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EA=B0=80=EC=A4=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 비용 없이 정적 JSON 질문 은행에서 면접 문항을 뽑기 위한 순수 선택 로직. 앞선 과목·질문일수록 가중치를 크게 주고, 카테고리당 패널티로 영역 쏠림을 막아 고르게 분배한다. 단위 테스트 포함. Co-Authored-By: Claude Opus 4.8 --- frontend/src/domain/practice/index.ts | 9 ++ .../practice/lib/selectQuestions.test.ts | 59 +++++++++++++ .../domain/practice/lib/selectQuestions.ts | 82 +++++++++++++++++++ frontend/src/domain/practice/model/types.ts | 38 +++++++++ 4 files changed, 188 insertions(+) create mode 100644 frontend/src/domain/practice/index.ts create mode 100644 frontend/src/domain/practice/lib/selectQuestions.test.ts create mode 100644 frontend/src/domain/practice/lib/selectQuestions.ts create mode 100644 frontend/src/domain/practice/model/types.ts 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 +} From 606ac8a0da8521b7896a1fd00711284914c6889e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Thu, 4 Jun 2026 01:13:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(practice):=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=EC=97=B0=EC=8A=B5=20=EB=A9=B4=EC=A0=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public/data 정적 JSON 로더, 진행 상태 훅(usePracticeSession), 면접 진행 UI(PracticeRunner), 직무 선택 UI(TrackPicker). 답변 메모 후 모범 답안 비교 흐름. Co-Authored-By: Claude Opus 4.8 --- .../features/practice/api/loadQuestionBank.ts | 16 +++ frontend/src/features/practice/index.ts | 2 + .../practice/model/usePracticeSession.ts | 71 ++++++++++ .../features/practice/ui/PracticeRunner.tsx | 131 ++++++++++++++++++ .../src/features/practice/ui/TrackPicker.tsx | 40 ++++++ 5 files changed, 260 insertions(+) create mode 100644 frontend/src/features/practice/api/loadQuestionBank.ts create mode 100644 frontend/src/features/practice/index.ts create mode 100644 frontend/src/features/practice/model/usePracticeSession.ts create mode 100644 frontend/src/features/practice/ui/PracticeRunner.tsx create mode 100644 frontend/src/features/practice/ui/TrackPicker.tsx 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}

+ +
+ +