Skip to content

[ICC-419] ✨ feat: 오답 모아풀기 — 폴더의 틀린 문항을 유형별 문제집으로 재출제 - #438

Merged
GulSauce merged 2 commits into
developfrom
ICC-419
Sep 9, 2026
Merged

GulSauce merged 2 commits into
developfrom
ICC-419

Conversation

@GulSauce

@GulSauce GulSauce commented Sep 9, 2026

Copy link
Copy Markdown
Member

ICC-419 오답 모아풀기 — 폴더의 틀린 문항을 유형별 문제집으로 재출제

요약

폴더 하나를 기준으로 내가 틀린 문항만 모아 유형마다 새 문제집을 만든다. 문제를 새로 생성하지 않고
틀렸던 문항을 그대로 복제하므로 AI 호출이 없고, 요청 하나로 끝난다(SSE 불필요).

만들어진 문제집은 그 시점부터 일반 문제집과 같다 — 기존 풀이·채점·해설·기록·목록·관리 경로가 코드 변경
없이 그대로 동작한다. 목록에서는 자료로 만든 문제집과 구별된다.

요구 원장은 specs/008-wrong-answer-set/spec.md, 팀 실행 계약은 같은 폴더 contract.md §확정 인터페이스다.

이 레포를 조사해서 알게 된 것 (설계의 전제)

설계 판단이 여기서 갈렸으므로 먼저 적는다.

  1. 폴더는 문제집이 아니라 풀이 기록에 붙는다quiz_folder + quiz_history.folder_id(V16).
    problem_set 에는 폴더 개념이 없다. 그래서 수집 범위가 quiz_history 한 쿼리로 닫힌다.
  2. 문제 유형은 문항이 아니라 세트 속성이다(ProblemSet.quizType). 유형별 분할은 원본 세트의
    quizType 으로 묶으면 끝이고, "한 문제집에 한 유형만"이 코드가 아니라 스키마로 보장된다.
  3. 문항별 정오답은 저장돼 있지 않다. 기록에는 답안 스냅샷과 맞힌 개수만 있고, 기록 상세는 조회할 때마다
    다시 판정한다. 그래서 수집도 기존 판정기를 그대로 재사용한다 — 새 채점 규칙을 만들지 않는다.
  4. 사용자 목록은 GET /history 하나뿐이다. 문제집 목록 API 가 없어서, 풀이 기록이 없는 문제집은
    목록에 나타나지 않는다. 유형별로 3개를 만들고 하나만 풀면 나머지 2개가 영영 접근 불가가 된다는 뜻이다.
  5. file_url 은 이미 NOT NULL DEFAULT '', session_id 는 UNIQUE 다. 자료 없는 세트를 만들기 위해
    스키마를 고칠 필요가 없고, session_id UNIQUE 는 오히려 멱등성 열쇠로 재활용할 수 있다.

변경 상세

1. 신규 엔드포인트

POST /problem-set/wrong-answers — 인증 필요, @RateLimit(WRITE).

요청

POST /problem-set/wrong-answers
Authorization: Bearer <액세스 토큰>
Idempotency-Key: 02381E17-B204-4C09-8783-93531B8242EB
Content-Type: application/json
{
  "folderId": "8Owg7XmO"
}

Idempotency-Key 는 한 번의 사용자 조작을 가리킨다. 이 값으로 세트의 session_id 를 결정론적으로 만들어
UNIQUE 제약에 태우므로, 연타·새로고침 재시도·다중 탭이 목록을 어지럽히지 않는다.

응답 (200)

{
  "createdSets": [
    {
      "problemSetId": "1OJ265nO",
      "historyId": "eVZLpGXM",
      "quizType": "MULTIPLE",
      "title": "오답 모음 · 객관식 · 09/09 10:24",
      "questionCount": 3,
      "truncated": false
    },
    {
      "problemSetId": "vO5oK15a",
      "historyId": "yMkZdGoa",
      "quizType": "REAL_BLANK",
      "title": "오답 모음 · 빈칸 직접입력 · 09/09 10:24",
      "questionCount": 2,
      "truncated": false
    },
    {
      "problemSetId": "zO01ZmwV",
      "historyId": "0M6XwAeV",
      "quizType": "OX",
      "title": "오답 모음 · OX 퀴즈 · 09/09 10:24",
      "questionCount": 2,
      "truncated": false
    }
  ],
  "excludedEssayCount": 3,
  "deletedSourceCount": 0,
  "failedTypes": [],
  "emptyReason": null
}

모을 오답이 없거나, 상한에 걸려 일부만 담기거나, 일부 유형이 실패해도 전부 200 이다. 사용자에게 알려야 할
상태이지 요청의 실패가 아니고, 프론트 공통 오류 처리가 401 외 모든 에러에 자동 토스트를 띄우기 때문에
오류로 내리면 상황에 맞는 안내를 할 수 없다. 진짜 오류만 4xx·5xx 로 간다.

// 404 — 없는 폴더 / 남의 폴더
{ "code": "FOLDER_NOT_FOUND", "message": "폴더를 찾을 수 없습니다." }
// 400 — folderId 누락
{ "message": "folderId가 존재하지 않습니다." }
// 200 — 폴더에 푼 기록이 없음
{ "createdSets": [], "excludedEssayCount": 0, "deletedSourceCount": 0,
  "failedTypes": [], "emptyReason": "NO_HISTORY" }

emptyReason 은 만들어진 문제집이 하나도 없을 때만 채워지고 우선순위는
NO_HISTORY > ESSAY_ONLY > SOURCE_DELETED > ALL_CORRECT 다.
excludedEssayCount 는 이와 독립으로 항상 채워, 세트가 만들어진 경우에도 서술형 제외를 안내할 수 있게 한다.

2. 생성된 실제 DB ROW

아래는 위 요청 하나로 실제로 저장된 행이다(로컬 격리 DB 실측, 값 가공 없음).

problem_set — 유형별로 3행이 만들어진다

id title user_id quiz_type total_quiz_count session_id file_url origin source_folder_id generation_status
900022 오답 모음 · 객관식 · 09/09 10:24 e2e-008-user MULTIPLE 3 wa-02381E17-…-MULTIPLE (빈 문자열) WRONG_ANSWER 900001 COMPLETED
900023 오답 모음 · 빈칸 직접입력 · 09/09 10:24 e2e-008-user REAL_BLANK 2 wa-02381E17-…-REAL_BLANK (빈 문자열) WRONG_ANSWER 900001 COMPLETED
900024 오답 모음 · OX 퀴즈 · 09/09 10:24 e2e-008-user OX 2 wa-02381E17-…-OX (빈 문자열) WRONG_ANSWER 900001 COMPLETED

generation_status 가 처음부터 COMPLETED 라 응답 직후 바로 풀 수 있고, file_url 은 컬럼 기본값인 빈
문자열이라 스키마를 건드리지 않았다. source_folder_id 는 모아온 폴더다.

problem — 틀렸던 문항만, 번호만 다시 매겨 복제된다

원본 세트 900001(객관식 5문항) 중 사용자가 틀린 것은 1·3·5번이었다. 그 셋만 담겼다.

problem_set_id number title explanation_content referenced_pages origin_problem_set_id origin_number
900022 1 900001-1번 문항 지문 1번 해설 [] 900001 1
900022 2 900001-3번 문항 지문 3번 해설 [] 900001 3
900022 3 900001-5번 문항 지문 5번 해설 [] 900001 5

다른 두 유형도 같다 — OX 는 원본 900002 의 틀린 2·4번이, 빈칸 직접입력은 원본 900003 의 틀린 1·2번이 담겼다.

problem_set_id number title selections (앞부분) referenced_pages origin_problem_set_id origin_number
900023 1 900003-1번 문항 지문 [{"content":"정답1","explanation":"빈칸 해설","correct":true,"acceptedAnswers"… [] 900003 1
900023 2 900003-2번 문항 지문 [{"content":"정답2",… [] 900003 2
900024 1 900002-2번 문항 지문 [{"content":"O","explanation":"틀린 설명","correct":false,… [] 900002 2
900024 2 900002-4번 문항 지문 [{"content":"O",… [] 900002 4
  • selections 는 정답 표시·선지별 해설·빈칸 인정 표현까지 원본 그대로 복제된다. 그래서 복제된 빈칸 직접입력
    세트도 기존 서버 채점(POST /grade)이 그대로 동작한다.
  • referenced_pages 만 비워 저장한다. 해설 화면의 "참조 자료" 패널을 여는 조건이 파일 URL 이 아니라
    문항별 참조 페이지라, 그대로 복제하면 자료가 없는 문제집에서 패널이 열리며 "파일 링크가 만료되었습니다"라는
    사실과 다른 문구가 뜬다(만료된 게 아니라 애초에 자료가 없다). 응답에서 거르지 않고 저장 시점에 비운
    이유는, 응답 분기는 새 소비자가 생기면 놓치지만 저장 차단은 모든 경로에 자동으로 적용되기 때문이다.
  • origin_problem_set_id/origin_number 는 최초 조상을 가리킨다. 복제할 때 조상 값을 물려받으므로 세대가
    거듭돼도 같은 문항이 두 번 담기지 않는다.

quiz_history — 문제집과 함께 미완료 기록이 출처 폴더에 만들어진다

id user_id problem_set_id folder_id title status score completed_at
900019 e2e-008-user 900022 900001 오답 모음 · 객관식 · 09/09 10:24 INCOMPLETE NULL NULL
900020 e2e-008-user 900023 900001 오답 모음 · 빈칸 직접입력 · 09/09 10:24 INCOMPLETE NULL NULL
900021 e2e-008-user 900024 900001 오답 모음 · OX 퀴즈 · 09/09 10:24 INCOMPLETE NULL NULL

이 행이 없으면 만들어진 문제집이 목록에도 폴더에도 나타나지 않는다(위 조사 4). 폴더에 들어가야 다음
모아풀기의 수집 대상이 될 수 있다는 점도 같이 걸린다.

3. 기존 응답 변경 (전부 비파괴)

  • GET /problem-set/{id} · GET /history 목록 항목에 origin: "DOCUMENT" | "WRONG_ANSWER" 추가.
    목록에서 오답 문제집을 구별하는 근거이자, 원본 자료를 전제로 하는 진입점을 감추는 근거다.

  • GET /problem-set/{id}/regeneration-condition 은 오답 세트에서 documentAvailable: false.
    404 로 막지 않는다 — 404 면 프론트가 오류 토스트 + 홈 이동이라는 막다른 실패로 흐른다.

  • GET /history 목록의 takenAt 매핑을 완료 시각으로 옮겼다.

    WRONG_ANSWER  completed=false  takenAt=null                       오답 모음 · OX 퀴즈 · …
    DOCUMENT      completed=true   takenAt=2026-09-09T01:18:27.201Z   E2E 객관식 원본 풀이
    

    ⚠️ 리뷰어가 놀라지 않게 적어 둔다 — 기존 미완료 기록의 '완료일'이 날짜에서 빈 값(-)으로 바뀐다.
    지금까지 이 칸은 기록이 만들어진 시각을 싣고 있었다. 이번 기능이 미완료 기록을 폴더에 만들면서 아직 풀지도
    않은 문제집에 완료일이 찍히는
    문제가 생기므로, 우리 변경이 만든 거짓 표시를 함께 치웠다.
    완료된 기존 행은 백필 덕에 표시가 그대로다. 프론트는 원래 null 방어가 있어 코드 변경이 없다.

  • GET /explanation/{id}변경 없다. 해설 화면이 /problem-set 을 이미 병렬로 호출해 origin
    가지고 있고, 참조 페이지는 저장 시점에 비워지므로 응답을 건드릴 이유가 없다.

4. 마이그레이션 V21

착수 시점에 origin/* 전 원격 브랜치의 마이그레이션 파일을 전수 스캔해 최대가 V20 임을 확인하고 골랐다.

ALTER TABLE problem_set
    ADD COLUMN origin           VARCHAR(20) NOT NULL DEFAULT 'DOCUMENT',
    ADD COLUMN source_folder_id BIGINT NULL;
ALTER TABLE problem
    ADD COLUMN origin_problem_set_id BIGINT NULL,
    ADD COLUMN origin_number         INT NULL;
ALTER TABLE quiz_history
    ADD COLUMN completed_at DATETIME(6) NULL;
UPDATE quiz_history SET completed_at = created_at WHERE status = 'COMPLETED';
-- pii_classification 5행 SAFE (CI ci-pii-coverage 게이트)
  • quiz_history.completed_at 이 필요한 이유: 이 테이블은 (user_id, problem_set_id) UNIQUE 라 세트당
    행이 하나이고, 재풀이는 같은 행을 덮어쓰면서도 created_at 은 최초 시작 시각으로 남는다. 즉 기존 컬럼으로는
    "가장 최근에 틀린 순"을 표현할 수 없다. 이 정렬이 상한에서 어떤 문항이 남는지를 결정하므로 부정확한 정렬은
    결과 자체를 바꾼다. 세팅 지점은 QuizHistory.completeQuiz() 한 곳이다.
  • 기존 완료 행은 created_at 으로 백필해 화면 표시 회귀를 0으로 만들었다. 정렬은 방어적으로
    COALESCE(completed_at, created_at) 를 쓴다.
  • 전용 인덱스는 만들지 않았다. 폴더당 기록 행이 수십 수준이라 정렬 비용이 무의미하고 스키마만 늘어난다.
  • file_url·session_id 는 손대지 않았다.

5. 처리 파이프라인

수집(요청자 토큰 ∩ 폴더 소유권 확인 ∩ 끝까지 푼 기록 ∩ 답안 있음)
  → 서술형 제외
  → 원본 세트의 유형으로 분할
  → 정렬(완료 시각 내림차순, 동률이면 문항 번호 오름차순)
  → 혈통 기준 중복 제거(먼저 나온 것 = 더 최근 것을 남김)
  → 문제집당 100문항 컷
  → 0문항인 유형은 만들지 않음

순서가 규칙의 일부다. 중복 제거가 정렬보다 앞서면 같은 문항의 오래된 쪽이 남아 상한에서 최신 오답이
밀려난다. 수렴 과정에서 실제로 잡은 오류라 테스트로 고정했다.

유형 하나 = 트랜잭션 하나(REQUIRES_NEW)라, 한 유형이 실패해도 이미 만들어진 유형은 살아남고 실패한
유형은 반쯤 만들어진 채 남지 않는다. 문제집·문항·풀이 기록이 한 덩어리로 커밋된다.

서술형은 수집하지 않는다. 이 유형은 정오답이 규칙으로 갈리지 않고 AI 채점 점수만 있어서, "몇 개 틀렸다"를
세려면 없던 임계값을 정해야 한다. 그건 이번 범위가 금지한 새 채점 규칙이므로 "제외된 문항 수"만 알린다.

6. 판정기 추출

문항별 정오답 판정이 기록 상세 매퍼 안의 private 메서드로만 있었다. 수집이 같은 규칙을 써야 하므로
AnswerJudge 로 꺼내 두 경로가 한 함수를 거치게 했다. 동작은 그대로이고, 두 경로가 서로 다른 규칙으로
갈라지는 것을 막는 것이 목적이다.

테스트

./gradlew test 전체 통과(기존 스위트 회귀 없음). 새 테스트 27개 추가.

테스트 개수 고정한 것
WrongAnswerCollectorTest 12 상한 101→100컷과 안내 플래그 / 상한 미달 시 플래그 없음 / 잘릴 때 최근 것이 남는지 / 푼 시각이 동률일 때 번호로 갈리는지 / 상한이 유형마다 따로 적용되는지 / 혈통 중복 제거 / 중복 중 최근 것이 남는지(정렬·중복제거 순서 가드) / 유형별 분할 / 맞힌 문항 제외 / 미응답=오답 / 빈칸 직접입력 표기 흔들림
WrongAnswerSetServiceImplTest 10 수집 범위가 요청자·폴더·완료 기록으로만 닫히는지 / 서술형 제외와 개수 / 원본 삭제 건너뛰기 / 빈 결과 사유 4가지 / 부분 실패 격리 / 멱등
WrongAnswerSetCreationServiceImplTest (JPA) 5 지문·선지·해설·지시의 복제 동일성과 번호 재부여 / 참조 페이지 미승계 / 생성 세트 속성 / 세대를 거듭해도 혈통이 최초 조상을 가리키는지 / 같은 세션 식별자 거부

문제집당 상한·"가장 최근에 틀린 순"·중복 제거의 증명 책임은 계약상 전적으로 백엔드 테스트에 있다
(기능 E2E 는 100문항 시드를 만들지 않는다).

기능 E2E

통과했다 — 프론트 러너 기준 9/9, 마커 RESULT=PASS / EXIT=0 / 2026-09-09 10:52:58.

시드(scripts/e2e/seed-wrong-answer-set.sql)는 한 폴더에 유형이 다른 문제집 4개와 "일부만 틀린" 기록을 넣고,
폴더 밖 문제집타인 소유 문제집을 함정으로 함께 심는다. 둘 다 오답이 4개씩이라, 수집 범위가 새면
객관식 문항 수가 3이 아니라 7로 나와 범위 오염이 숫자 하나로 드러난다.

로컬 실행으로 확인한 것:

  • 기대값 그대로 — 객관식 3 / 빈칸 직접입력 2 / OX 2, 서술형 3문항 제외, 함정 두 개 모두 유출 없음
  • 같은 Idempotency-Key 로 두 번 호출해도 응답이 동일하고 문제집이 새로 만들어지지 않음
  • 만들어진 문제집이 풀이·해설·목록 경로에서 일반 문제집과 동일하게 동작
  • 세대 반복: 오답 문제집을 실제로 풀어 한 문항을 또 틀린 뒤 같은 폴더로 다시 실행해도 객관식이 3문항
    그대로다. 2세대 문항의 혈통이 전부 최초 조상을 가리켜 중복이 생기지 않는다. 아직 풀지 않은 오답 문제집은
    미완료라 수집 대상이 아니므로 자기 복제도 일어나지 않는다.
  • 빈 DB 에 V1~V21 을 새로 적용해 마이그레이션 무결성까지 함께 확인

반복 실행에서도 결과가 흔들리지 않는다. 프론트 E2E 스위트를 여러 번 돌려 같은 폴더에 오답 문제집이 30개
넘게 쌓이고 그중 일부가 실제로 풀려 완료된 상태에서도, 수집 결과가 계속 객관식 3 / 빈칸 직접입력 2 / OX 2,
서술형 3문항 제외로 유지됐다. 만들어진 오답 문제집이 폴더에 그대로 남는데도 결과가 변하지 않는 이유는 두
가지가 맞물려서다 — 아직 풀지 않은 것은 미완료라 수집 대상이 아니고, 풀어서 또 틀린 문항은 혈통이 최초
조상을 가리켜 원본과 중복 제거된다. 세대가 거듭돼도 목록이 오염되거나 문항이 중복되지 않는다는 것을 단위
테스트 밖에서 확인한 셈이다.

또한 프론트 시나리오가 오답 문제집을 풀어 제출하는 경로에서, 해당 기록에 status=COMPLETED 와 함께
completed_at 이 실제로 찍히는 것을 확인했다 — 새 컬럼이 실 흐름에서 채워진다는 증거다.

관련 제품 결정

contract.md §7.0 의 사용자 결정을 따랐다.

  1. 유형별 분할 생성 — 유형이 섞이면 유형마다 별도 문제집. 실행 전에 유형을 고르지 않는다.
  2. 서술형 제외 — 다만 제외 사실을 알려 "내가 틀린 게 누락됐다"는 혼동을 막는다.
  3. 100문항 상한은 문제집 하나당 — 한 번의 실행이 만드는 총 문항 수에는 상한이 없다.
  4. 폴더 선택 필수 — 전체 보기·미분류에서는 실행할 수 없다.
  5. 오답 문제집은 수집한 원본 폴더에 자동 소속 — 그래서 생성 시 미완료 풀이 기록을 함께 만든다.
  6. 빈칸 두 유형을 화면에서 구별 — 제목도 앱에 이미 있는 문구를 쓴다(빈칸 넣기 / 빈칸 직접입력).
  7. 상한 검증은 백엔드 테스트로만.
  8. 완료 시각 컬럼 추가 — "가장 최근에 틀린 순"을 정확히 맞추기 위해.
  9. 완료율 하락 수용 — 생성 직후 미완료 행이 통계에 그대로 들어간다. 통계 로직에 예외를 두지 않는다.

알려진 동작(결함이 아니라 합의된 것): 오답 문제집에서 다시 맞힌 문항이라도 원본 기록은 그 문항을 오답으로
유지하므로 다음 모아풀기에 계속 수집된다. 제외하려면 새 규칙이 필요해 이번 범위에서 다루지 않기로 결정됐다.

작업 브랜치

ICC-419 (base: develop)

🤖 Generated with Claude Code

GulSauce and others added 2 commits September 9, 2026 10:17
폴더 하나에서 내가 틀린 문항을 모아 유형마다 새 문제집을 만든다. 새 문제를 생성하지
않고 틀렸던 문항을 그대로 복제하므로 AI 를 거치지 않는다.

수집 범위는 quiz_history 한 쿼리(내 기록 ∩ 이 폴더 ∩ 끝까지 푼 것)로 닫는다. 정오답은
저장돼 있지 않아 저장된 답안으로 되짚는데, 기록 상세가 쓰던 판정을 AnswerJudge 로 꺼내
수집과 같은 함수를 거치게 했다 — 새 채점 규칙을 만들지 않는다. 서술형은 정오답이 규칙으로
갈리지 않아 제외하고 제외된 문항 수만 알린다.

정렬 → 중복 제거 → 상한 순서가 규칙의 일부다. 중복 제거가 앞서면 같은 문항의 오래된 쪽이
남아 상한에서 최신 오답이 밀려난다.

유형 하나가 트랜잭션 하나라, 한 유형이 실패해도 나머지는 남고 실패한 유형은 반쯤 만들어진
채 남지 않는다. session_id 를 요청 단위로 결정론 생성해 연타·재시도가 목록을 어지럽히지
않는다.

- V21: problem_set.origin·source_folder_id, problem.origin_*(재출제 혈통),
  quiz_history.completed_at(기존 완료 행은 created_at 으로 백필)
- 문제집과 함께 풀이 기록을 출처 폴더에 만든다. 이 레포에서 폴더는 문제집이 아니라 기록에
  붙고 목록도 기록을 훑으므로, 기록이 없으면 만들어진 문제집이 목록에도 폴더에도 안 뜬다.
- 원본 자료를 가리키는 페이지 번호는 물려주지 않는다. 자료가 없는 문제집에서 해설의 참조
  자료 안내가 사실과 다른 말을 하게 된다.
- 빈 결과·상한 초과·부분 실패는 200 으로 내린다. 알려야 할 상태이지 요청의 실패가 아니다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzgVQVdWdJQTp1cUvu44X
오답 모아풀기가 미완료 기록을 폴더에 만들면서, 아직 풀지도 않은 문제집이 목록 '완료일'
칸에 날짜를 표시하게 됐다. takenAt 이 기록 생성 시각을 싣고 있었기 때문이다.

완료 시각 컬럼으로 옮긴다. 기존 완료 행은 백필 값이 생성 시각이라 표시가 그대로고,
완료하지 않은 기록은 날짜 대신 비어서 내려간다.

푼 시각이 모두 같은 구간에서 무엇이 상한에 잘려나가는지도 함께 고정했다 — 컬럼이 생기기
전 기록은 백필로 전부 동률이 되고, 그때는 문항 번호만이 순서를 가른다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzgVQVdWdJQTp1cUvu44X
@GulSauce
GulSauce merged commit 318006c into develop Sep 9, 2026
3 checks passed
@GulSauce
GulSauce deleted the ICC-419 branch September 9, 2026 02:49
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