Files
reloop-v2/ALGORITHM.md
reloop 420610a41d docs: ALGORITHM/README/report 최신 상태 반영
- 가격 2,900원 통일, 기능 게이트 문서화
- 코드 규모 45,641줄/71 API/15 모델/26 페이지
- 실현가능성/시장성 섹션 전면 재작성
- 2022 교육과정 프리셋, 학교 관리 4대 기능 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:39:35 +09:00

440 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ReLoop v2 복습 알고리즘 상세 설명
> 이 문서는 ReLoop v2의 복습 스케줄링이 어떻게 작동하는지를 코드 레벨에서 설명합니다.
## 요약: 두 가지 경로
ReLoop은 **두 가지 복습 스케줄링 경로**를 가지고 있고, 사용자의 행동에 따라 자동으로 갈림:
| 경로 | 언제 사용? | 핵심 아이디어 |
|------|-----------|-------------|
| **SM-2 하이브리드** | 사용자가 "어려웠어/괜찮았어/쉬웠어"를 선택했을 때 | 체감 난이도로 다음 복습 간격 결정 |
| **에빙하우스 망각곡선** | 난이도 선택 없이 결과만 기록했을 때 (legacy) | 기억 감쇄 시뮬레이션으로 복습 시점 계산 |
현재 앱의 주요 UI(복습 세션, e-book 뷰어, 처음 풀기)는 모두 `selfDifficulty`를 보내므로 **SM-2 경로가 기본**이다.
---
## 경로 1: SM-2 하이브리드 (메인)
### 1.1 개념
SM-2는 SuperMemo 2의 약자로, 1987년에 만들어진 간격 반복 알고리즘이다. 핵심 아이디어:
- 각 문제(태그)마다 **EF (Ease Factor, 쉬움 계수)**를 관리한다 (기본값 2.5)
- 쉽다고 느끼면 EF가 올라가고, 어렵다고 느끼면 내려간다
- 다음 복습 간격 = `이전 간격 × EF`
- EF가 높을수록 간격이 빠르게 벌어진다
ReLoop은 여기에 **페르소나 배율**을 곱해서 학습자 유형별로 조정한다.
### 1.2 EF (Ease Factor) 업데이트
사용자가 "어려웠어/괜찮았어/쉬웠어"를 선택할 때마다:
```
어려웠어 → EF = max(1.3, 현재EF - 0.3) // 급격히 하락
괜찮았어 → EF = 현재EF // 변동 없음
쉬웠어 → EF = max(1.3, 현재EF + 0.15) // 소폭 상승
```
- 최소 EF는 **1.3** (이 이하로 내려가지 않음)
- 초기 EF는 **2.5**
- EF는 `SkillSnapshot.easeFactor` 필드에 저장됨 (태그별로 하나)
- 기억 강도 S₀는 별도로 `SkillSnapshot.s0` (0~1)에 저장됨
**예시:**
```
시작: EF = 2.5
어려웠어: EF = 2.5 - 0.3 = 2.2
괜찮았어: EF = 2.2 (그대로)
쉬웠어: EF = 2.2 + 0.15 = 2.35
어려웠어: EF = 2.35 - 0.3 = 2.05
어려웠어: EF = 2.05 - 0.3 = 1.75
어려웠어: EF = 1.75 - 0.3 = 1.45
어려웠어: EF = max(1.3, 1.45 - 0.3) = 1.3 ← 바닥
```
### 1.3 복습 간격 계산
```
페르소나 배율 (pf):
상위권 (senior) = 1.3 → 간격이 30% 더 넓어짐
중위권 (mid) = 1.0 → 기본
하위권 (junior) = 0.8 → 간격이 20% 좁아짐
벼락치기 (crammer)= 0.6 → 간격이 40% 좁아짐
학습 강도 배율 (if):
엄격 (strict) = 0.7 → 간격이 30% 좁아짐 (더 자주 복습)
균형 (moderate) = 1.0 → 기본
여유 (relaxed) = 1.4 → 간격이 40% 넓어짐 (덜 자주 복습)
```
**어려웠어 → 무조건 1일 후** (어떤 상황이든):
```
간격 = 1일
```
**처음 푸는 문제 (iteration = 0):**
```
괜찮았어 → 간격 = round(3 × pf × if)일
쉬웠어 → 간격 = round(7 × pf × if)일
```
| 체감 난이도 | senior (×1.3) | mid (×1.0) | junior (×0.8) | crammer (×0.6) |
|------------|:---:|:---:|:---:|:---:|
| 어려웠어 | 1일 | 1일 | 1일 | 1일 |
| 괜찮았어 | 4일 | 3일 | 2일 | 2일 |
| 쉬웠어 | 9일 | 7일 | 6일 | 4일 |
**첫 번째 복습 (iteration = 1):**
```
어려웠어 → 1일
그 외 → 간격 = round(6 × pf)일
```
**두 번째 이후 복습 (iteration >= 2):**
```
어려웠어 → 1일
그 외 → 간격 = round(6 × EF^(iteration-1) × pf × if)일
```
| iteration | EF=2.5, mid(×1.0) | EF=2.5, senior(×1.3) | EF=1.5, mid(×1.0) |
|:---------:|:---:|:---:|:---:|
| 0 (처음) | 3일 (괜찮음) | 4일 | 3일 |
| 1 | 6일 | 8일 | 6일 |
| 2 | 15일 | 20일 | 9일 |
| 3 | 38일 | 49일 | 14일 |
| 4 | 60일 (cap) | 60일 (cap) | 20일 |
**모든 간격은 최소 1일, 최대 60일로 제한된다.**
### 1.4 전체 흐름 (SM-2 경로)
```
[사용자가 문제를 풀고 "어려웠어" 클릭]
1. SkillSnapshot에서 현재 EF 로드 (없으면 2.5)
2. EF 업데이트: EF = max(1.3, EF - 0.3)
3. 간격 계산: 1일 (어려웠어는 항상 1일)
4. 간격 × 페르소나 배율 (어려웠어는 1일 고정이라 배율 적용 안 됨)
5. ReviewSchedule 생성 (scheduledAt = 지금 + 1일)
6. SkillSnapshot.easeFactor에 새 EF, s0에 기억 강도 저장
[1일 후 복습 큐에 등장]
[사용자가 "쉬웠어" 클릭]
1. EF = max(1.3, 이전EF + 0.15)
2. 간격 = round(6 × EF^(1-1) × pf × if) = 6일 (mid+moderate 기준)
3. 망각곡선 보정: 6일 후 예상 P 시뮬레이션
4. P >= 30%이면 6일 유지, P < 30%이면 안전 간격으로 축소
5. 새 ReviewSchedule (scheduledAt = 보정된 간격)
[n일 후 다시 복습 큐에 등장]
... 반복 (iteration 증가 → EF 지수승 → 간격 기하급수적 확대)
```
### 1.5 망각곡선 안전장치
SM-2가 계산한 간격이 너무 길어서 그 시점에 기억이 거의 사라졌을 가능성을 방지한다:
```
1. SM-2가 간격 N일을 계산
2. 망각곡선으로 N일 후 기억 강도 시뮬레이션:
futureS = s0 × e^(-λ × N)
futureP = sigmoid(k × (futureS - D))
3. futureP < 0.3 (30%)이면:
- 정답 확률 50%가 되는 시점을 역산
- 그 시점으로 간격 축소
4. futureP >= 0.3이면: SM-2 간격 그대로 사용
```
이 보정은 **벼락치기형(λ=0.6)처럼 빠르게 잊는 사용자**에게 특히 효과적. SM-2가 6일을 계산해도 실제로 3일 만에 기억이 30% 이하로 떨어진다면 3일로 줄여준다.
---
## 경로 2: 에빙하우스 망각곡선 (Legacy)
> 이 경로는 `selfDifficulty`가 없을 때만 사용됨. 현재 UI에서는 거의 발생하지 않는다.
### 2.1 개념
에빙하우스의 망각곡선은 "시간이 지남에 따라 기억이 지수적으로 감쇄한다"는 모델:
```
S(t) = S₀ × e^(-λ × t)
```
- `S₀`: 현재 기억 강도 (0~1). 문제를 맞히면 올라가고, 틀리면 내려감
- `λ` (람다): 망각 속도. 페르소나별로 다름
- `t`: 마지막 학습으로부터 경과 일수
여기에 "정답 확률"을 시그모이드 함수로 변환:
```
P(정답) = sigmoid(k × (S(t) - D))
= 1 / (1 + e^(-k × (S(t) - D)))
```
- `k`: 시그모이드 기울기 (고정값 4.0). 클수록 S(t)의 작은 변화에도 P가 급변
- `D`: 문제 난이도 (0~1). 높을수록 어려운 문제
### 2.2 S₀ (기억 강도) 업데이트
문제를 풀 때마다 결과에 따라 S₀가 변경:
```
맞음 (correct) → 새S₀ = clamp(이전S₀ × 0.5 + 0.6, 0, 1)
부분 (partial) → 새S₀ = clamp(이전S₀ × 0.7 + 0.3, 0, 1)
틀림 (incorrect) → 새S₀ = clamp(이전S₀ × 0.5, 0, 1)
```
| 이전 S₀ | 맞음 | 부분 | 틀림 |
|:-------:|:----:|:----:|:----:|
| 0.3 (초기) | 0.75 | 0.51 | 0.15 |
| 0.5 | 0.85 | 0.65 | 0.25 |
| 0.8 | 1.0 | 0.86 | 0.40 |
### 2.3 다음 복습 시점 계산
"정답 확률이 기준선 아래로 떨어지는 시점"을 구한다:
```
기준선 (P_threshold) — 학습 강도 설정:
엄격 (strict) = 0.70 → 아직 70%일 때 미리 복습 (자주)
균형 (moderate) = 0.50 → 반반일 때 복습 (기본)
여유 (relaxed) = 0.35 → 많이 까먹은 후 복습 (드물게)
```
수학적으로 풀면:
```
P_threshold = sigmoid(k × (S₀ × e^(-λ × t) - D))
이것을 t에 대해 풀면:
t = -(1/λ) × ln((D + logit(P_threshold)/k) / S₀)
여기서 logit(p) = ln(p / (1-p))
```
### 2.4 예시 시나리오
**설정:** 중위권(mid, λ=0.2), 균형(moderate, threshold=0.5), S₀=0.75, D=0.5
```
logit(0.5) = ln(0.5/0.5) = 0
target = 0.5 + 0/4 = 0.5
t = -(1/0.2) × ln(0.5 / 0.75)
= -5 × ln(0.667)
= -5 × (-0.405)
= 2.03일 → 약 2일 후 복습
```
**같은 조건, 상위권(senior, λ=0.1):**
```
t = -(1/0.1) × ln(0.5 / 0.75)
= -10 × (-0.405)
= 4.05일 → 약 4일 후 복습 (느리게 잊으니까 더 늦게)
```
**같은 조건, 벼락치기(crammer, λ=0.6):**
```
t = -(1/0.6) × ln(0.5 / 0.75)
= -1.67 × (-0.405)
= 0.68일 → 약 16시간 후 복습 (빨리 잊으니까 빨리)
```
---
## 페르소나별 효과 비교
### SM-2 경로에서 (현재 메인)
페르소나는 **PERSONA_FACTOR 배율**로만 영향:
```
상위권 ×1.3 → 모든 간격이 30% 넓어짐
중위권 ×1.0 → 기본
하위권 ×0.8 → 모든 간격이 20% 좁아짐
벼락치기 ×0.6 → 모든 간격이 40% 좁아짐
```
학습 강도(reviewIntensity)는 **INTENSITY_SM2_FACTOR 배율**로 영향:
```
엄격 ×0.7 → 모든 간격이 30% 좁아짐 (더 자주 복습)
균형 ×1.0 → 기본
여유 ×1.4 → 모든 간격이 40% 넓어짐 (덜 자주 복습)
```
추가로 **망각곡선 안전장치**가 작동하여 SM-2 간격 후 예상 P가 30% 이하면 자동 축소.
### 망각곡선 경로에서 (legacy)
페르소나는 **PERSONA_LAMBDA (망각 속도)**로 영향:
```
상위권: λ=0.1 → 하루에 10%씩 감쇄 → 천천히 잊음 → 넓은 간격
중위권: λ=0.2 → 하루에 20%씩 감쇄 → 보통
하위권: λ=0.4 → 하루에 40%씩 감쇄 → 빨리 잊음 → 좁은 간격
벼락치기: λ=0.6 → 하루에 60%씩 감쇄 → 매우 빨리 잊음 → 매우 좁은 간격
```
학습 강도는 **P_threshold**로 영향:
```
엄격: P가 70% 아래로 떨어지기 전에 복습 → 자주 복습
균형: P가 50% 아래로 떨어지기 전에 복습 → 보통
여유: P가 35% 아래로 떨어지기 전에 복습 → 드물게 복습
```
---
## SkillSnapshot 필드 구조
`SkillSnapshot`은 태그별로 두 개의 독립적인 값을 저장한다:
| 필드 | 범위 | 의미 | 사용처 |
|------|------|------|--------|
| `s0` | 0 ~ 1.0 | 기억 강도 | 망각곡선 보정, 예상 P 계산 |
| `easeFactor` | 1.3 ~ 4.0+ (nullable) | SM-2 Ease Factor | SM-2 간격 계산 |
두 값은 **항상 동시에 업데이트**된다. SM-2 경로에서도 s0(기억 강도)를 업데이트하고, 망각곡선 보정에 사용한다. `easeFactor`가 null이면 해당 태그에서 SM-2가 아직 사용되지 않았음을 의미한다.
---
## 밀린 복습 자동 이월
복습 예정일에 안 풀면 **다음날 아침 9시(KST)로 자동 이월**:
```
복습 큐 조회 시 (GET /reviews/queue):
1. 오늘 00:00 KST 이전의 pending 상태 복습을 찾음
2. 해당 복습의 scheduledAt을 "오늘 09:00 KST"로 변경
3. 변경된 수를 rescheduledCount로 반환
```
단, 수학 과목 + 사용자 직접 등록 문제만 이월됨 (기출 문제집 문제는 제외).
---
## 페르소나 변경 시 재계산
프로필에서 페르소나나 학습 강도를 바꾸면 **모든 pending 복습 일정이 재계산**된다:
1. 해당 유저의 pending 상태 ReviewSchedule 전부 로드
2. 각 리뷰에 대해:
- SM-2 경로: 새 페르소나의 PERSONA_FACTOR로 간격 재계산 (selfDifficulty='medium' 가정)
- 망각곡선 경로: 새 λ, 새 threshold로 다음 복습 시점 재계산
3. scheduledAt 업데이트
---
## 상수 요약 (빠른 참조)
```
┌─────────────────────────────────────────────┐
│ SM-2 상수 │
├─────────────────────────────────────────────┤
│ DEFAULT_EF = 2.5 (초기 쉬움 계수) │
│ MIN_EF = 1.3 (최소 EF) │
│ MAX_INTERVAL = 60일 (최대 복습 간격) │
│ Hard EF 패널티 = -0.3 │
│ Easy EF 보상 = +0.15 │
│ │
│ 초기 간격 (iteration=0): │
│ 어려웠어 = 1일 │
│ 괜찮았어 = 3 × pf × if 일 │
│ 쉬웠어 = 7 × pf × if 일 │
│ │
│ 이후 간격: │
│ 어려웠어 = 1일 │
│ 그 외 = 6 × EF^(iter-1) × pf × if 일 │
│ │
│ 망각곡선 보정: │
│ 간격 후 예상 P < 30% → 안전 간격으로 축소 │
├─────────────────────────────────────────────┤
│ PERSONA_FACTOR (SM-2 간격 배율) │
│ 상위권 = 1.3 │
│ 중위권 = 1.0 │
│ 하위권 = 0.8 │
│ 벼락치기 = 0.6 │
│ │
│ INTENSITY_SM2_FACTOR (학습 강도 배율) │
│ 엄격 = 0.7 │
│ 균형 = 1.0 │
│ 여유 = 1.4 │
├─────────────────────────────────────────────┤
│ 망각곡선 상수 │
├─────────────────────────────────────────────┤
│ PERSONA_LAMBDA (망각 속도, day⁻¹) │
│ 상위권 = 0.1 │
│ 중위권 = 0.2 │
│ 하위권 = 0.4 │
│ 벼락치기 = 0.6 │
│ │
│ INTENSITY_THRESHOLD (복습 기준선) │
│ 엄격 = 0.70 │
│ 균형 = 0.50 │
│ 여유 = 0.35 │
│ │
│ DEFAULT_K = 4.0 (시그모이드 기울기) │
│ DEFAULT_INITIAL_S0 = 0.3 (초기 기억 강도) │
└─────────────────────────────────────────────┘
```
---
## 코드 위치
| 파일 | 역할 |
|------|------|
| `backend/src/forget/persona-forget.service.ts` | 알고리즘 엔진 (모든 수식) |
| `backend/src/study-logs/study-logs.service.ts` | 문제 풀 때 → 첫 복습 스케줄 생성 |
| `backend/src/reviews/reviews.service.ts` | 복습 제출 → 다음 복습 스케줄 생성 |
| `backend/src/me/me.service.ts` | 페르소나 변경 → 기존 스케줄 재계산 |
---
## Free/Pro 기능 게이트
| 기능 | Free | Pro / School |
|------|------|-------------|
| 과목 등록 | 3개까지 | 무제한 |
| 일일 복습 큐 | 10개 | 무제한 |
| 마스터리 경로 | 차단 (403) | 전체 접근 |
| PDF 문제집 업로드 | 월 1회 | 무제한 |
| 기출 문제집 | 샘플만 | 전체 |
게이트 구현 위치:
| 파일 | 게이트 내용 |
|------|-----------|
| `backend/src/subjects/subjects.service.ts` | Free 과목 3개 제한 |
| `backend/src/reviews/reviews.service.ts` | Free 복습 큐 10개 슬라이스 |
| `backend/src/stats/stats.service.ts` | Free 마스터리 경로 차단 |
## 2022 개정 교육과정 프리셋
과목명(예: "중1수학", "고2수학")을 입력하면 해당 학년의 단원이 태그로 자동 생성된다.
| 과목 | 단원 수 | 예시 |
|------|--------|------|
| 중1수학 | 9개 | 소인수분해, 일차방정식, 좌표평면... |
| 중2수학 | 8개 | 일차부등식, 연립일차방정식, 피타고라스... |
| 중3수학 | 7개 | 이차방정식, 이차함수, 삼각비... |
| 고1수학 | 18개 | 다항식, 복소수, 이차방정식, 집합, 함수... |
| 고2수학 | 17개 | 지수/로그, 삼각함수, 미적분, 확률통계... |
| 고3수학 | 16개 | 급수, 삼각함수 미분, 벡터, 이차곡선... |
구현: `backend/src/subjects/curriculum-presets.ts`