Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/practice/:track', element: <PracticePage /> },
{ path: '/share/:token', element: <SharedFeedbackPage /> },
{ path: '/auth/callback', element: <AuthCallbackPage /> },
{
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/domain/practice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type {
PracticeTrack,
QuestionBank,
PracticeQuestion,
RawSubject,
RawCategory,
RawQuestion,
} from './model/types'
export { selectQuestions } from './lib/selectQuestions'
59 changes: 59 additions & 0 deletions frontend/src/domain/practice/lib/selectQuestions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, number>>): 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([])
})
})
82 changes: 82 additions & 0 deletions frontend/src/domain/practice/lib/selectQuestions.ts
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions frontend/src/domain/practice/model/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions frontend/src/features/practice/api/loadQuestionBank.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { PracticeTrack, QuestionBank } from '@/domain/practice'

// 서버 비용을 피하기 위해 질문 은행은 public/data 의 정적 JSON 으로 제공된다.
const TRACK_FILE: Record<PracticeTrack, string> = {
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<QuestionBank> {
const res = await fetch(TRACK_FILE[track])
if (!res.ok) {
throw new Error(`질문 데이터를 불러오지 못했습니다 (${res.status})`)
}
return (await res.json()) as QuestionBank
}
2 changes: 2 additions & 0 deletions frontend/src/features/practice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { PracticeRunner } from './ui/PracticeRunner'
export { TrackPicker } from './ui/TrackPicker'
71 changes: 71 additions & 0 deletions frontend/src/features/practice/model/usePracticeSession.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>({})

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,
}
}
Loading