diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md
index 35269fab..5514c845 100644
--- a/frontend/src/features/CLAUDE.md
+++ b/frontend/src/features/CLAUDE.md
@@ -80,7 +80,7 @@ features/{X} → pages/*, app/* ✗
| `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 |
| `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 |
| `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 |
-| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 |
+| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 + 복습 드릴 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 |
| `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 |
| `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 |
diff --git a/frontend/src/features/interview/ui/BookmarkDrill.test.tsx b/frontend/src/features/interview/ui/BookmarkDrill.test.tsx
new file mode 100644
index 00000000..d8e97b09
--- /dev/null
+++ b/frontend/src/features/interview/ui/BookmarkDrill.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { BookmarkDrill } from './BookmarkDrill'
+import type { BookmarkedQuestion } from '../api/bookmarkApi'
+
+const items: BookmarkedQuestion[] = [
+ {
+ messageId: 100,
+ sessionId: 7,
+ sessionTitle: '백엔드 모의면접',
+ category: 'CS_FUNDAMENTAL',
+ question: 'ACID 를 설명해 주세요.',
+ expectedSignal: '4대 속성을 예시와 함께',
+ myAnswer: '원자성만 말했습니다',
+ modelAnswer: '원자성·일관성·격리성·지속성',
+ coachingComment: '나머지도 짚어보세요',
+ createdAt: '2026-08-18T00:00:00Z',
+ },
+ {
+ messageId: 101,
+ sessionId: 7,
+ sessionTitle: '백엔드 모의면접',
+ category: 'TECH_CHOICE',
+ question: '왜 Kafka 를 골랐나요?',
+ createdAt: '2026-08-18T00:00:00Z',
+ },
+]
+
+beforeEach(() => window.localStorage.clear())
+
+describe('BookmarkDrill', () => {
+ // 드릴의 핵심 — 먼저 답해 보고 그 다음에 정답을 본다.
+ it('정답 확인 전에는 모범 답안이 보이지 않는다', async () => {
+ render()
+
+ expect(screen.getByText('ACID 를 설명해 주세요.')).toBeInTheDocument()
+ expect(screen.queryByText('원자성·일관성·격리성·지속성')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
+
+ expect(screen.getByText('원자성·일관성·격리성·지속성')).toBeInTheDocument()
+ expect(screen.getByText('원자성만 말했습니다')).toBeInTheDocument()
+ })
+
+ it('다음 질문으로 넘어가면 정답이 다시 가려진다', async () => {
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
+ await userEvent.click(screen.getByRole('button', { name: '다음 질문' }))
+
+ expect(screen.getByText('왜 Kafka 를 골랐나요?')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '정답 확인' })).toBeInTheDocument()
+ })
+
+ // 기록이 없는 질문도 드릴에 포함된다 — 펼쳤을 때 이유를 알려준다.
+ it('복습 재료가 없는 질문은 이유를 보여준다', async () => {
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
+
+ expect(
+ screen.getByText('이 질문에는 아직 답변·피드백 기록이 없어요.'),
+ ).toBeInTheDocument()
+ })
+
+ it('마지막 질문을 마치면 완료 화면을 보여준다', async () => {
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: '정답 확인' }))
+ await userEvent.click(screen.getByRole('button', { name: '복습 마치기' }))
+
+ expect(screen.getByText('복습을 마쳤습니다')).toBeInTheDocument()
+ })
+
+ it('목록으로 나갈 수 있다', async () => {
+ const onExit = vi.fn()
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: '목록으로' }))
+
+ expect(onExit).toHaveBeenCalled()
+ })
+
+ // 적어둔 답은 다시 들어와도 남아 있어야 한다.
+ it('적은 답변이 재진입 후에도 남는다', async () => {
+ const { unmount } = render()
+ await userEvent.type(screen.getByLabelText(/다시 답해 보기/), '지금이라면 이렇게')
+ unmount()
+
+ render()
+
+ expect(screen.getByLabelText(/다시 답해 보기/)).toHaveValue('지금이라면 이렇게')
+ })
+})
diff --git a/frontend/src/features/interview/ui/BookmarkDrill.tsx b/frontend/src/features/interview/ui/BookmarkDrill.tsx
new file mode 100644
index 00000000..4563cd85
--- /dev/null
+++ b/frontend/src/features/interview/ui/BookmarkDrill.tsx
@@ -0,0 +1,145 @@
+import { useMemo } from 'react'
+import { Button } from '@/shared/ui/Button'
+import { TextArea } from '@/shared/ui/TextArea'
+import { Eyebrow, StatusBadge } from '@/shared/ui'
+import { useQuestionRunner } from '@/shared/hooks'
+import { categoryLabel } from '../lib/categoryLabel'
+import type { BookmarkedQuestion } from '../api/bookmarkApi'
+
+const STORAGE_KEY = 'stackup:bookmark-drill-answers'
+
+/**
+ * 오답노트 복습 드릴 — 한 문제씩 다시 답해 보고 모범 답안과 비교한다.
+ * 연습 면접과 같은 상태 기계(`useQuestionRunner`)를 쓴다.
+ */
+export function BookmarkDrill({
+ items,
+ onExit,
+}: {
+ items: BookmarkedQuestion[]
+ onExit: () => void
+}) {
+ const ids = useMemo(() => items.map((i) => String(i.messageId)), [items])
+ const { index, total, isLast, done, revealed, answers, reveal, next, setAnswer, reset } =
+ useQuestionRunner(ids, STORAGE_KEY)
+
+ const current = items[index]
+
+ if (done || !current) {
+ return (
+
+
+ 복습을 마쳤습니다
+
+
+ 담아둔 질문 {total}개를 모두 다시 풀었어요.
+
+
+
+
+
+
+ )
+ }
+
+ const id = String(current.messageId)
+ const label = categoryLabel(current.category)
+
+ return (
+
+
+
+ {index + 1} / {total}
+
+
+
+
+
+ {label && {label}}
+ {current.sessionTitle && (
+
+ {current.sessionTitle}
+
+ )}
+
+
+
+ {current.question}
+
+ {current.expectedSignal && (
+
평가 관점: {current.expectedSignal}
+ )}
+
+
+
+
+
+ {revealed && (
+
+ {current.myAnswer && (
+
+ )}
+ {current.modelAnswer &&
}
+ {current.coachingComment && (
+
+ )}
+ {!current.myAnswer && !current.modelAnswer && !current.coachingComment && (
+
+ 이 질문에는 아직 답변·피드백 기록이 없어요.
+
+ )}
+
+ )}
+
+
+ {revealed ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function Panel({
+ title,
+ body,
+ tone = 'strong',
+}: {
+ title: string
+ body: string
+ tone?: 'strong' | 'muted'
+}) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/interview/ui/BookmarkList.tsx b/frontend/src/features/interview/ui/BookmarkList.tsx
index ac4be2c0..e4b12ebc 100644
--- a/frontend/src/features/interview/ui/BookmarkList.tsx
+++ b/frontend/src/features/interview/ui/BookmarkList.tsx
@@ -1,12 +1,15 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { EmptyState, ListSkeleton, QueryError, StatusBadge } from '@/shared/ui'
+import { Button } from '@/shared/ui/Button'
import { categoryLabel } from '../lib/categoryLabel'
import { useBookmarks, useSetQuestionBookmark } from '../model/useBookmarks'
import type { BookmarkedQuestion } from '../api/bookmarkApi'
+import { BookmarkDrill } from './BookmarkDrill'
export function BookmarkList() {
const { data = [], isPending, isError, refetch } = useBookmarks()
+ const [drilling, setDrilling] = useState(false)
if (isPending) {
return
@@ -25,12 +28,24 @@ export function BookmarkList() {
)
}
+ if (drilling) {
+ return setDrilling(false)} />
+ }
+
return (
-
- {data.map((item) => (
-
- ))}
-
+
+
+
담아둔 질문 {data.length}개
+
+
+
+ {data.map((item) => (
+
+ ))}
+
+
)
}
diff --git a/frontend/src/features/practice/model/usePracticeSession.ts b/frontend/src/features/practice/model/usePracticeSession.ts
index 7d2cb53d..a97dcea7 100644
--- a/frontend/src/features/practice/model/usePracticeSession.ts
+++ b/frontend/src/features/practice/model/usePracticeSession.ts
@@ -3,9 +3,13 @@ import { useQuery } from '@tanstack/react-query'
import { selectQuestions } from '@/domain/practice'
import type { PracticeTrack } from '@/domain/practice'
import { loadQuestionBank } from '../api/loadQuestionBank'
+import { useQuestionRunner } from '@/shared/hooks'
const DEFAULT_COUNT = 8
+// 트랙별로 답변 메모를 따로 보관한다. 프론트 로컬 전용이라 서버 계약과 무관.
+const storageKeyFor = (track: PracticeTrack) => `stackup:practice-answers:${track}`
+
export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT_COUNT) {
const bankQuery = useQuery({
queryKey: ['practice-bank', track],
@@ -15,9 +19,6 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT
// 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 []
@@ -26,28 +27,13 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT
// 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 questionIds = useMemo(() => questions.map((q) => q.id), [questions])
+ const runner = useQuestionRunner(questionIds, storageKeyFor(track))
const restart = useCallback(() => {
- setIndex(0)
- setRevealed(false)
- setAnswers({})
+ runner.reset()
setSeed((s) => s + 1)
- }, [])
+ }, [runner])
return {
bankTitle: bankQuery.data?.title,
@@ -56,16 +42,16 @@ export function usePracticeSession(track: PracticeTrack, count: number = DEFAULT
error: bankQuery.error as Error | undefined,
refetch: bankQuery.refetch,
questions,
- current,
- index,
- total,
- isLast,
- done,
- revealed,
- answers,
- reveal,
- next,
- setAnswer,
+ current: questions[runner.index],
+ index: runner.index,
+ total: runner.total,
+ isLast: runner.isLast,
+ done: runner.done,
+ revealed: runner.revealed,
+ answers: runner.answers,
+ reveal: runner.reveal,
+ next: runner.next,
+ setAnswer: runner.setAnswer,
restart,
}
}
diff --git a/frontend/src/shared/CLAUDE.md b/frontend/src/shared/CLAUDE.md
index aae9c40d..594bd28c 100644
--- a/frontend/src/shared/CLAUDE.md
+++ b/frontend/src/shared/CLAUDE.md
@@ -78,8 +78,15 @@ shared/api/
## 6. shared/hooks (도메인 비종속만)
-권장 후보:
+현재 구현:
- `useEventStream(url, options)` — SSE 추상화 (재연결, 폴링 fallback)
+- `useAnalysisProgress`, `useCopyToClipboard`
+- `useQuestionRunner(questionIds, storageKey?)` — 질문을 한 개씩 넘기며 답을 적고 정답을 확인하는
+ 드릴 상태 기계. **연습 면접과 오답노트가 함께 쓰므로 여기 있다** — features 끼리는 서로
+ import 할 수 없다(FSD §3). 질문 id 목록만 받고 질문을 어디서 얻는지는 모른다(도메인 비종속).
+ `storageKey` 를 주면 답변 메모를 localStorage 에 남긴다(새로고침 유실 방지).
+
+권장 후보:
- `useDebounce(value, ms)`
- `useThrottle(callback, ms)`
- `useMediaQuery(query)`
diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts
index 57706383..3c23ce38 100644
--- a/frontend/src/shared/hooks/index.ts
+++ b/frontend/src/shared/hooks/index.ts
@@ -2,3 +2,4 @@ export { useEventStream } from './useEventStream'
export { useAnalysisProgress, analysisProgress } from './useAnalysisProgress'
export type { AnalysisProgress } from './useAnalysisProgress'
export { useCopyToClipboard } from './useCopyToClipboard'
+export { useQuestionRunner } from './useQuestionRunner'
diff --git a/frontend/src/shared/hooks/useQuestionRunner.test.ts b/frontend/src/shared/hooks/useQuestionRunner.test.ts
new file mode 100644
index 00000000..5c2ce303
--- /dev/null
+++ b/frontend/src/shared/hooks/useQuestionRunner.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useQuestionRunner } from './useQuestionRunner'
+
+const KEY = 'test:answers'
+
+beforeEach(() => window.localStorage.clear())
+
+describe('useQuestionRunner', () => {
+ it('질문을 순서대로 넘기고 마지막을 알려준다', () => {
+ const { result } = renderHook(() => useQuestionRunner(['a', 'b']))
+
+ expect(result.current.currentId).toBe('a')
+ expect(result.current.isLast).toBe(false)
+
+ act(() => result.current.next())
+ expect(result.current.currentId).toBe('b')
+ expect(result.current.isLast).toBe(true)
+ expect(result.current.done).toBe(false)
+
+ act(() => result.current.next())
+ expect(result.current.done).toBe(true)
+ })
+
+ // 정답을 본 상태가 다음 질문으로 새면 곧바로 답이 보인다.
+ it('다음 질문으로 넘어가면 정답 공개가 닫힌다', () => {
+ const { result } = renderHook(() => useQuestionRunner(['a', 'b']))
+
+ act(() => result.current.reveal())
+ expect(result.current.revealed).toBe(true)
+
+ act(() => result.current.next())
+ expect(result.current.revealed).toBe(false)
+ })
+
+ // A-7: 새로고침하면 사용자가 적은 메모가 통째로 사라지던 문제.
+ it('답변 메모를 저장하고 다시 불러온다', () => {
+ const first = renderHook(() => useQuestionRunner(['a'], KEY))
+ act(() => first.result.current.setAnswer('a', '내가 적은 답'))
+ first.unmount()
+
+ const second = renderHook(() => useQuestionRunner(['a'], KEY))
+ expect(second.result.current.answers.a).toBe('내가 적은 답')
+ })
+
+ it('storageKey 가 없으면 저장하지 않는다', () => {
+ const { result } = renderHook(() => useQuestionRunner(['a']))
+
+ act(() => result.current.setAnswer('a', 'x'))
+
+ expect(window.localStorage.length).toBe(0)
+ })
+
+ it('처음부터 다시 하면 저장된 답변도 지운다', () => {
+ const { result } = renderHook(() => useQuestionRunner(['a', 'b'], KEY))
+ act(() => result.current.setAnswer('a', 'x'))
+ act(() => result.current.next())
+
+ act(() => result.current.reset())
+
+ expect(result.current.index).toBe(0)
+ expect(result.current.answers).toEqual({})
+ // 저장은 이펙트가 단독으로 책임진다 — 비운 상태가 그대로 반영된다.
+ expect(window.localStorage.getItem(KEY)).toBe('{}')
+ })
+
+ // 다른 코드가 같은 키를 썼거나 형식이 바뀐 경우 — 드릴이 죽으면 안 된다.
+ it('저장된 값이 망가져 있으면 빈 상태로 시작한다', () => {
+ window.localStorage.setItem(KEY, 'not json')
+ expect(renderHook(() => useQuestionRunner(['a'], KEY)).result.current.answers).toEqual({})
+
+ window.localStorage.setItem(KEY, '[1,2,3]')
+ expect(renderHook(() => useQuestionRunner(['a'], KEY)).result.current.answers).toEqual({})
+
+ window.localStorage.setItem(KEY, '{"a":"ok","b":42}')
+ expect(renderHook(() => useQuestionRunner(['a'], KEY)).result.current.answers).toEqual({
+ a: 'ok',
+ })
+ })
+
+ // 오답노트에서 항목을 빼면 목록이 짧아진다 — index 가 그대로면 빈 화면이 된다.
+ it('질문 목록이 짧아지면 범위 안으로 접힌다', () => {
+ const { result, rerender } = renderHook(({ ids }) => useQuestionRunner(ids), {
+ initialProps: { ids: ['a', 'b', 'c'] },
+ })
+
+ act(() => result.current.next())
+ act(() => result.current.next())
+ expect(result.current.currentId).toBe('c')
+
+ rerender({ ids: ['a'] })
+
+ expect(result.current.index).toBe(1)
+ expect(result.current.done).toBe(true)
+ })
+})
diff --git a/frontend/src/shared/hooks/useQuestionRunner.ts b/frontend/src/shared/hooks/useQuestionRunner.ts
new file mode 100644
index 00000000..93f7fbfa
--- /dev/null
+++ b/frontend/src/shared/hooks/useQuestionRunner.ts
@@ -0,0 +1,89 @@
+import { useCallback, useEffect, useState } from 'react'
+
+/**
+ * 질문을 한 개씩 넘기며 답을 적고 정답을 확인하는 드릴의 상태 기계.
+ *
+ * 연습 면접(정적 질문 은행)과 오답노트(북마크한 면접 질문)가 같은 흐름을 쓰므로
+ * 질문 목록을 어디서 얻는지와 분리했다. 두 feature 가 공유하므로 shared 에 둔다 —
+ * features 끼리는 서로 import 할 수 없다(FSD).
+ *
+ * @param questionIds 진행할 질문 id 목록. 순서가 곧 출제 순서다.
+ * @param storageKey 주면 답변 메모를 localStorage 에 남긴다. 새로고침·이탈해도 살아남는다.
+ */
+export function useQuestionRunner(questionIds: string[], storageKey?: string) {
+ const [index, setIndex] = useState(0)
+ const [revealed, setRevealed] = useState(false)
+ const [answers, setAnswers] = useState>(() =>
+ readStored(storageKey),
+ )
+
+ // 답변 메모는 사용자가 직접 쓴 것이라 잃으면 손실이 크다(질문 목록은 다시 만들면 그만).
+ // 쓰기 실패(용량 초과·프라이빗 모드)는 무시한다 — 드릴 자체가 멈추면 안 된다.
+ useEffect(() => {
+ if (!storageKey) return
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(answers))
+ } catch {
+ /* 저장 실패는 조용히 넘긴다 */
+ }
+ }, [storageKey, answers])
+
+ const total = questionIds.length
+ // 질문 목록이 짧아졌는데 index 가 그대로면 빈 화면이 된다(오답노트에서 항목을 빼는 경우).
+ // 이펙트로 되돌리면 한 프레임 깜빡이므로 렌더 중에 파생시킨다.
+ const safeIndex = Math.min(index, total)
+ const currentId = questionIds[safeIndex]
+ const isLast = safeIndex >= total - 1
+ const done = total > 0 && safeIndex >= 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 }))
+ }, [])
+
+ // 저장은 아래 이펙트 하나가 책임진다. 여기서 removeItem 을 해도 answers 변경으로
+ // 이펙트가 곧바로 "{}" 를 다시 써서 무의미하다(읽을 때 빈 맵과 부재는 같다).
+ const reset = useCallback(() => {
+ setIndex(0)
+ setRevealed(false)
+ setAnswers({})
+ }, [])
+
+ return {
+ index: safeIndex,
+ currentId,
+ total,
+ isLast,
+ done,
+ revealed,
+ answers,
+ reveal,
+ next,
+ setAnswer,
+ reset,
+ }
+}
+
+function readStored(storageKey?: string): Record {
+ if (!storageKey) return {}
+ try {
+ const raw = window.localStorage.getItem(storageKey)
+ if (!raw) return {}
+ const parsed: unknown = JSON.parse(raw)
+ // 남의 키를 덮어쓰거나 형식이 바뀐 경우를 대비해 문자열 맵만 받아들인다.
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+ return Object.fromEntries(
+ Object.entries(parsed as Record).filter(
+ ([, v]) => typeof v === 'string',
+ ),
+ ) as Record
+ } catch {
+ return {}
+ }
+}