Skip to content

feat: 오답노트 — 질문 북마크 + 모아보기 (B-4) - #178

Merged
i3months merged 4 commits into
devfrom
feature/question-bookmarks
Aug 19, 2026
Merged

feat: 오답노트 — 질문 북마크 + 모아보기 (B-4)#178
i3months merged 4 commits into
devfrom
feature/question-bookmarks

Conversation

@i3months

Copy link
Copy Markdown
Member

변경 사항

감사 백로그 B-4. 다시 볼 질문을 표시해 두고 나중에 모아 복습한다.

  • PUT /api/sessions/{sid}/messages/{mid}/bookmark — 표시/해제
  • GET /api/users/me/bookmarks — 모아보기
  • 워크스페이스에 '오답노트' 메뉴 + /workspace/bookmarks

설계 판단

질문(INTERVIEWER)에만 걸 수 있다 (MESSAGE_NOT_BOOKMARKABLE, 422). 답변을 표시해 봐야 복습할 게 없다.

토글이 아니라 명시적 상태를 받는다. {"bookmarked": true|false}. 토글이면 재전송·더블클릭이 상태를 뒤집어 놓는다. 같은 요청이 두 번 와도 결과가 같다.

목록은 질문 + 그때 내 답변 + 모범답안/코칭을 한 묶음으로 반환한다. 질문만 모아 봐야 "내가 뭘 틀렸는지"를 알 수 없다. 답변은 findByParentMessage_IdIn 으로 한 번에 받아 매핑한다 — 질문마다 조회하면 N+1 이다.

별도 테이블을 만들지 않았다. V26 으로 interview_messages.bookmarked + 부분 인덱스(WHERE bookmarked = TRUE). 북마크의 수명은 질문의 수명과 같다 — 세션을 지우면 그 질문도 사라지는 게 맞고, 목록 쿼리가 session.deleted = false 로 거른다.

엔티티 변경 메서드 이름은 applyBookmark 다. setBookmarked 로 두면 ArchUnit 의 "엔티티 public setter 금지" 룰에 걸린다.

UX 판단

라이브 면접 중에는 별을 띄우지 않는다. 답변에 집중해야 할 때 끼어드는 조작이라, 종료 세션 기록(InterviewTranscript)에서만 켠다(bookmarkable prop).

모범 답안은 접혀 있다. 먼저 스스로 떠올려 보고 펼치는 게 복습의 핵심이다. 펼치기 전에는 질문과 평가 관점만 보인다.

답변·피드백 기록이 없는 질문(답변 전에 담았거나 피드백 생성 전)은 빈 아코디언 대신 이유를 적어 준다.

practice 러너 재사용에 대해

원래 백로그에는 "practice 러너 재사용"이라고 적었지만, 실제로 보니 PracticeRunner 는 정적 QuestionBank 를 로드하는 usePracticeSession(track) 에 묶인 전체 화면 레이아웃(헤더의 나가기, 푸터)이었다. 워크스페이스 안에 끼워 넣으려면 상태 훅과 표현을 갈라내는 리팩터가 필요한데, 그 리팩터를 이 PR 에 섞으면 연습 모드까지 회귀 위험을 지게 된다.

대신 목록에서 모범 답안을 접어 두는 것으로 같은 효과(회상 → 확인)를 냈다. 한 문제씩 넘기는 드릴이 필요하면 러너 상태 훅(useQuestionRunner) 추출을 별도 PR 로 하는 게 안전하다.

테스트

레이어 신규 내용
backend QuestionBookmarkServiceTest (8) 표시, 멱등, 답변 표시 거부, 타인 세션 거부, 다른 세션의 메시지 id 끼워넣기 거부, 답변·코칭 결합, 답변 없는 질문도 노출, 북마크 0건일 때 답변 조회 스킵
frontend BookmarkList.test.tsx (5) 빈 상태, 모범답안 접힘→펼침, 복습 재료 없을 때 안내, 출처 면접 링크, 빼기 인자
frontend QuestionBubble.test.tsx (3) 기본은 별 미노출, 담기/빼기 인자

backend test 통과(ArchUnit 포함) · frontend vitest 98/98 · eslint 0 · tsc · build.

영향 범위

  • DB 마이그레이션: 있음 (V26 — 컬럼 추가 + 부분 인덱스. 기존 행은 FALSE)
  • API contract 변경: 추가만 (엔드포인트 2개, MessageResponse.bookmarked)
  • 환경변수: 없음
  • 신규 에러코드: MESSAGE_NOT_FOUND(404), MESSAGE_NOT_BOOKMARKABLE(422)

리뷰어 체크포인트

  • 세션을 지우면 그 질문의 북마크도 사라진다 — 의도한 동작이다. "질문은 남기고 싶다"면 별도 테이블로 복사해야 하는데, 그건 출처를 잃은 질문이 된다고 봤다
  • 라이브 중 별을 숨긴 판단. "그 순간 담고 싶다"는 요구가 있으면 되돌리기 쉽다

다시 볼 질문을 표시해 두고 나중에 모아 복습한다.

- `PUT /api/sessions/{sid}/messages/{mid}/bookmark` — 표시/해제
- `GET /api/users/me/bookmarks` — 모아보기

## 설계 판단

**질문(INTERVIEWER)에만 걸 수 있다** (`MESSAGE_NOT_BOOKMARKABLE`, 422). 답변을 표시해 봐야
복습할 게 없다.

**토글이 아니라 명시적 상태를 받는다.** 토글이면 재전송·더블클릭이 상태를 뒤집어 놓는다.
같은 요청이 두 번 와도 결과가 같다.

**목록은 질문 + 그때 내 답변 + 모범답안/코칭을 한 묶음으로** 반환한다. 질문만 모아 봐야
"내가 뭘 틀렸는지"를 알 수 없다. 답변은 `findByParentMessage_IdIn` 으로 한 번에 받아
매핑한다 — 질문마다 조회하면 N+1 이다.

**별도 테이블을 만들지 않았다.** V26 으로 `interview_messages.bookmarked` + 부분 인덱스
(`WHERE bookmarked = TRUE`). 북마크의 수명은 질문의 수명과 같다 — 세션을 지우면 그 질문도
사라지는 게 맞다(목록 쿼리가 `session.deleted = false` 로 거른다).

엔티티 변경 메서드는 `applyBookmark` 다. `setBookmarked` 로 두면 ArchUnit 의
"엔티티 public setter 금지" 룰에 걸린다.

`QuestionBookmarkController` 는 URL 이 `/api/users/me/*` 지만 `UserStatsController` 와 같은
이유로 session 슬라이스에 둔다(user → session 직접 의존 회피).

## 테스트

`QuestionBookmarkServiceTest` 신설 (8) — 표시, 멱등, 답변 표시 거부, 타인 세션 거부,
**다른 세션의 메시지 id 끼워넣기 거부**, 답변·코칭 결합, 답변 없는 질문도 목록에 노출,
북마크 0건일 때 답변 조회 스킵.
- 종료 세션 기록(`InterviewTranscript`)의 질문 버블에 별 버튼. **라이브 중엔 안 띄운다** —
  답변에 집중해야 할 때 끼어드는 조작이다(`bookmarkable` prop 으로 게이팅)
- 워크스페이스에 '오답노트' 메뉴 + `/workspace/bookmarks`
- **모범 답안은 접혀 있다.** 먼저 스스로 떠올려 보고 펼치는 게 복습의 핵심이라,
  펼치기 전에는 질문과 평가 관점만 보인다
- 답변·피드백 기록이 없는 질문(답변 전에 담았거나 피드백 전)은 빈 아코디언 대신
  이유를 적어 준다
- 담기/빼기 모두 명시적 상태를 보낸다(서버 계약과 동일 — 재전송이 상태를 뒤집지 않게)

북마크는 면접 질문이라 별도 feature 슬라이스로 쪼개지 않고 `features/interview` 에 뒀다.
쪼갰다면 질문 버블의 토글이 feature 간 import 가 돼 FSD 규칙에 걸린다.

테스트: `BookmarkList.test.tsx`(5) — 빈 상태, 모범답안 접힘→펼침, 복습 재료 없을 때 안내,
출처 면접 링크, 빼기 인자. `QuestionBubble.test.tsx` 신설(3) — 기본은 별 미노출,
담기/빼기 인자. vitest 98/98 · eslint 0 · tsc · build 통과.
- `docs/database.md` — interview_messages.bookmarked(V26) + 부분 인덱스
- `backend/CLAUDE.md` — 엔드포인트·설계 판단(질문 전용, 명시적 상태, N+1 회피, 컨트롤러 위치)
- `frontend/src/features/CLAUDE.md` — interview 슬라이스 책임에 오답노트 추가와 그 이유
@i3months
i3months merged commit 30361a3 into dev Aug 19, 2026
5 checks passed
@i3months
i3months deleted the feature/question-bookmarks branch August 19, 2026 02:23
i3months added a commit to i3months/stackup that referenced this pull request Aug 28, 2026
## 드릴 상태 기계 추출 (Team-StackUp#178 에서 미뤘던 것)

`usePracticeSession` 안에 섞여 있던 "한 문제씩 넘기며 답을 적고 정답을 확인하는" 상태를
`useQuestionRunner(questionIds, storageKey?)` 로 뽑았다. 질문 id 목록만 받고 질문을 어디서
얻는지는 모른다.

**`shared/hooks` 에 뒀다.** 처음엔 `features/practice` 에 두고 오답노트에서 가져다 썼는데,
features 끼리 import 는 FSD 위반이다(§3). 도메인 타입에 의존하지 않으므로 shared 가 맞다.

`PracticeRunner` 는 손대지 않았다 — 정적 질문 은행을 쓰는 전체 화면 레이아웃 그대로고,
내부 상태만 공용 훅으로 바뀌었다.

## A-7 — 연습 모드 답변이 새로고침하면 사라지던 문제

`storageKey` 를 주면 답변 메모를 localStorage 에 남긴다. 사용자가 직접 쓴 것이라 잃으면
손실이 크다(질문 목록은 다시 뽑으면 그만). 트랙별로 키를 나눈다.

- 저장 실패(용량 초과·프라이빗 모드)는 무시한다 — 드릴이 멈추면 안 된다
- 읽을 때 형식을 검사한다. 다른 코드가 같은 키를 썼거나 형식이 바뀌어도 빈 상태로 시작할 뿐

`reset()` 에서 `removeItem` 을 호출했다가 걷어냈다 — answers 변경으로 저장 이펙트가 곧바로
`"{}"` 를 다시 써서 무의미했다(테스트가 잡았다). 저장은 이펙트 하나가 책임진다.

## 오답노트 복습 드릴

목록 위 '복습 시작' 으로 진입. 한 문제씩 다시 답해 본 뒤 **정답 확인**을 눌러야 그때 내 답변·
모범 답안·코칭이 보인다. 여기 적는 답도 localStorage 에 남는다.

기록이 없는 질문(답변 전에 담았거나 피드백 전)도 드릴에 포함하고, 펼쳤을 때 이유를 알려준다.

## 테스트

- `useQuestionRunner.test.ts` 신설 (7) — 순서·마지막·완료, 다음 질문에서 정답 재차단,
  **저장/복원**, storageKey 없으면 미저장, reset, **망가진 저장값 방어 3종**,
  질문 목록이 짧아질 때 범위 접힘(오답노트에서 항목을 빼는 경우)
- `BookmarkDrill.test.tsx` 신설 (6) — 정답 확인 전 미노출, 다음 질문에서 재차단,
  기록 없는 질문 안내, 완료 화면, 나가기, 재진입 후 답변 유지

vitest 111/111 · eslint 0 · tsc · build 통과.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant