블로그 목록으로
기술 심화

FSRS-5 간격 반복 알고리즘을 Flutter에 직접 구현한 이야기

bloomcardfsrsalgorithmspaced-repetition

SM-2를 버린 이유

BloomCard를 처음 개발할 때, 대부분의 플래시카드 개발자가 그렇듯 SM-2부터 살펴봤다. Anki의 기본 알고리즘이고, 수십 년간 검증됐고, 문서화도 잘 되어 있다. 그런데 왜 결국 FSRS-5를 직접 구현하게 됐을까?

SM-2에는 근본적인 한계가 있다. 모든 카드의 초기 ease factor를 2.5로 동일하게 설정한다. 기억 감쇠를 수학적으로 모델링하지 않고, 사용자 평가에 따라 단순 배수를 조정하는 방식이다. 결과적으로 쉬운 자료는 과다 스케줄링하고, 어려운 자료는 과소 스케줄링하는 경향이 있다. 특히 처음 몇 번의 복습에서 이 문제가 두드러진다.

FSRS(Free Spaced Repetition Scheduler)는 완전히 다른 접근을 취한다. Jarrett Ye와 open-spaced-repetition 커뮤니티가 개발한 FSRS는 DSR(Difficulty, Stability, Retrievability) 모델이라는 인간 기억의 수학적 모델 위에 구축됐다. 수백만 건의 실제 복습 로그에서 최적화된 19개 파라미터를 사용한다. 연구에 따르면 FSRS는 SM-2와 동일한 기억 유지율을 약 30% 적은 복습 횟수로 달성한다.

정원 테마 앱에서 모든 카드가 자라나는 식물인 만큼, 더 나은 스케줄링은 단순히 더 나은 학습만 의미하지 않는다. 더 자연스러운 성장 패턴을 의미한다. 카드가 적절한 타이밍에 꽃을 피우길 원했지, 임의의 스케줄에 맞춰 피우길 원하지 않았다.

상태 머신

FSRS 구현의 핵심에는 모든 카드의 생명주기를 모델링하는 4상태 머신이 있다:

enum CardState { newCard, learning, review, relearning }

전이는 이렇게 동작한다:

  • newCard: 모든 카드의 시작점. 첫 복습에서 Again을 누르면 learning으로, 다른 평가는 바로 review로 이동.
  • learning: 단기 습득 단계. 성공적인 복습 후 review로 승급.
  • review: 장기 기억 단계. 성공적인 복습은 간격을 늘리며 여기 유지. Again을 누르면 relearning으로 추락.
  • relearning: 카드가 망각된 상태. 단기 재습득 후 review로 복귀.

실제 전이 로직은 이렇게 생겼다:

if (card.state == CardState.newCard) {
  updated.difficulty = _initDifficulty(rating);
  updated.stability = _initStability(rating);
  updated.reps = 1;
  updated.lapses = rating == Rating.again ? 1 : 0;
  updated.state =
      rating == Rating.again ? CardState.learning : CardState.review;
} else {
  final elapsedDays = card.lastReview != null
      ? now.difference(card.lastReview!).inHours / 24.0
      : 0.0;
  final r = retrievability(elapsedDays, card.stability);

  updated.difficulty = _nextDifficulty(card.difficulty, rating);

  if (rating == Rating.again) {
    updated.stability =
        _nextStabilityAfterFail(card.difficulty, card.stability, r)
            .clamp(0.1, double.infinity);
    updated.lapses = card.lapses + 1;
    updated.state = CardState.relearning;
  } else {
    updated.stability = _nextStabilityAfterSuccess(
        card.difficulty, card.stability, r, rating);
    updated.reps = card.reps + 1;
    updated.state = CardState.review;
  }
}

핵심은 lapsesreps가 카드의 평생 카운터라는 점이다. lapses는 review 상태에서 Again을 눌러 카드를 잊은 횟수를, reps는 성공적인 복습 횟수를 추적한다. 이 카운터는 절대 초기화되지 않는다 -- 카드의 영구적인 이력이다.

안정성(Stability): 핵심 개념

FSRS가 SM-2보다 우수한 이유를 하나만 꼽으라면, 안정성(Stability) 개념이다. DSR 모델에서 안정성은 카드를 기억할 확률이 90%(또는 설정한 목표 유지율)로 떨어지기까지의 일수를 나타낸다.

안정성이 10인 카드는: 오늘 복습하면 10일 후에도 90% 확률로 기억한다는 뜻이다. 안정성이 60이면 약 두 달간 기억할 수 있다.

이것은 망각 곡선으로 모델링된다:

double retrievability(double elapsedDays, double stability) {
  if (stability <= 0) return 0;
  return pow(1 + 19 / 81 * elapsedDays / stability, -0.5).toDouble();
}

이 수식은 망각의 거듭제곱 법칙(power law of forgetting)에서 나왔다. 상수 19/81-0.5elapsedDaysstability와 같을 때 retrievability가 정확히 0.9(90%)가 되도록 FSRS 모델에서 도출된 값이다. 하나의 수식으로 기억이 시간에 따라 감쇠하는 본질을 포착하는, 우아한 모델이다.

초기 안정성

새 카드의 초기 안정성은 첫 복습 결과에 따라 결정된다:

static const List<double> defaultParams = [
  0.40255, 1.18385, 3.173, 15.69105, // w0-w3: Again/Hard/Good/Easy 초기 안정성
  // ...
];

double _initStability(Rating rating) {
  return params[rating.index];
}

새 카드를 Again으로 평가하면 안정성 0.4일(약 10시간)을 받는다. Easy로 평가하면 15.7일부터 시작한다. 이 값들은 임의가 아니다 -- FSRS-5의 19개 파라미터 중 처음 4개로, 수백만 건의 실제 사용자 데이터에서 최적화된 것이다.

성공 후 안정성 계산

카드를 성공적으로 기억했을 때, 새 안정성은 현재 난이도, 안정성, 복습 시점의 기억 인출 확률, 그리고 Hard/Easy 여부를 모두 고려해서 계산된다:

double _nextStabilityAfterSuccess(
    double d, double s, double r, Rating rating) {
  final hardPenalty = (rating == Rating.hard) ? params[14] : 1.0;
  final easyBonus = (rating == Rating.easy) ? params[15] : 1.0;
  return s *
      (1 +
          exp(params[6]) *
              (11 - d) *
              pow(s, -params[7]) *
              (exp((1 - r) * params[8]) - 1) *
              hardPenalty *
              easyBonus);
}

이 수식을 분해해 보겠다. 현재 안정성에 성장 계수를 곱하는 구조다. 성장 계수는 다음에 의존한다:

  • (11 - d): 쉬운 카드(낮은 난이도)일수록 안정성이 빠르게 증가. 난이도 범위가 1-10이므로 이 항은 1~10 사이.
  • pow(s, -params[7]): 안정성이 이미 높은 카드는 더 천천히 성장. 간격이 길어질수록 수확 체감 -- 안정성이 폭발적으로 증가하는 것을 방지.
  • (exp((1 - r) * params[8]) - 1): 목표 유지율에서 멀수록 안정성 증가폭이 커짐. 잊기 직전(낮은 retrievability)에 복습하면 아직 잘 기억하는 상태에서 복습하는 것보다 더 큰 부스트를 받는다.
  • hardPenalty / easyBonus: 평가 보정. Hard는 페널티(w14 = 2.27), Easy는 보너스(w15 = 0.23).

실패 후 안정성 계산

카드를 잊었을 때(Again 평가), 안정성은 급격히 하락한다:

double _nextStabilityAfterFail(double d, double s, double r) {
  return params[9] *
      pow(d, -params[10]) *
      (pow(s + 1, params[11]) - 1) *
      exp((1 - r) * params[12]);
}

이것은 "안정성에 어떤 비율을 곱하는" 단순한 방식이 아니라 완전히 별도의 수식이다. 망각이 기억을 완전히 지우지 않는다는 사실을 모델링한다 -- 망각된 카드에도 부분적인 기억 흔적이 남는다. 새 안정성은 훨씬 낮지만 0은 아니며, 망각 후에도 남아있는 부분 기억을 반영한다.

안정성에서 복습 간격으로

간격 계산은 안정성을 구체적인 "다음 복습까지 며칠"로 변환한다:

double nextInterval(double stability) {
  return (stability / (19 / 81) * (pow(desiredRetention, -2.0) - 1))
      .clamp(1.0, 36500.0);
}

목표 유지율 0.9에서 이 수식은 대략 stability * 1.0으로 단순화된다 -- 간격이 안정성 값과 거의 같다. 하지만 desiredRetention 파라미터에 주목해야 한다. 95% 유지율을 원하면 간격이 짧아지고, 85%로 괜찮다면 길어진다. 이 조정 가능성은 SM-2에서 기본 제공되지 않는 기능이다.

간격은 1일에서 100년(36,500일) 사이로 클램핑된다. Again 평가의 경우 이를 완전히 우회하고 고정 10분 재시도를 사용한다:

final interval = rating == Rating.again
    ? 0.00694 // ~10분
    : nextInterval(updated.stability);
updated.due = now.add(Duration(minutes: (interval * 24 * 60).round()));

0.00694는 일 단위로 표현한 10분이다(10 / 1440). 카드를 잊었을 때 다음 날까지 기다리지 않고 10분 후에 다시 도전할 수 있다. 학습 세션의 흐름을 유지하기 위한 설계다.

정원: 안정성을 성장으로 매핑하기

여기서 BloomCard가 재미있어진다. "안정성: 23.5일"이나 "다음 복습: 3월 15일" 같은 생소한 숫자 대신, FSRS 시스템 전체를 정원 메타포로 매핑했다. 모든 카드는 식물이고, 성장 단계는 안정성 값에 직접 연동된다:

static GrowthStage getGrowthStage(double stability) {
  if (stability < 5) return GrowthStage.seed;
  if (stability < 20) return GrowthStage.sprout;
  if (stability < 60) return GrowthStage.bud;
  return GrowthStage.bloom;
}

이 임계값은 무작위가 아니다. 의미 있는 이정표가 되도록 선택했다:

  • 씨앗 Seed (안정성 < 5): 방금 심은 카드. 연약해서 며칠 복습을 빠뜨리면 시든다. 1~5일 간격으로 복습하는 초기 습득 단계.
  • 새싹 Sprout (안정성 5-19): 카드가 뿌리를 내리는 중. 일주일~2주 정도는 복습 없이 버틸 수 있다. 실질적인 장기 기억을 형성하는 단계.
  • 꽃봉오리 Bud (안정성 20-59): 탄탄한 지식. 월간 복습 간격. 숙달에 근접한 상태.
  • 만개 Bloom (안정성 60 이상): 완전히 꽃을 피운 상태. 장기 기억에 깊이 각인됐다. 2개월 이상의 복습 간격. 카드가 만개하면 컬렉션에 고유한 꽃 품종이 해금된다 -- 4개 등급에 걸쳐 21종의 꽃이 있다.

사용자가 간격 반복 이론을 전혀 몰라도 직관적으로 이해할 수 있는 진행 체계다. "씨앗에 물을 줘야 해요"가 "안정성 5 미만인 카드를 복습하세요"보다 훨씬 와닿는다.

정원 화면은 모든 카드를 생성일 역순(최신이 상단)으로 정렬된 그리드에 식물로 표시한다. 각 카드는 성장 단계에 맞는 아이콘으로 렌더링되며, 앱 어디에서도 이모지를 사용하지 않고 전부 CustomPainter 클래스로 직접 그린다. 7일 이내에 생성된 카드에는 초록색 "NEW" 배지가 붙는다.

5가지 학습 모드, 하나의 알고리즘

BloomCard는 5가지 학습 모드를 제공하지만, 모두 같은 FSRS 엔진으로 흘러간다. 모드는 카드와 어떻게 상호작용하는지를 결정하고, 사용자가 부여한 평가가 FSRS 업데이트를 결정한다.

일반 모드 (Normal)

클래식한 플래시카드 경험. 앞면을 보고, 뒤집어서 뒷면을 확인한 후, 자기 평가: Again, Hard, Good, Easy. 각 평가는 FSRS의 Rating enum에 직접 매핑되어 review() 호출을 트리거한다.

되짚기 모드 (Reverse)

카드의 뒷면을 먼저 보고 앞면을 맞추는 방식. FSRS 처리는 동일하다 -- reversed=true 플래그는 UI 표시에만 영향을 주고 알고리즘에는 영향이 없다. 언어 학습에서 인식(recognition)과 산출(production) 양쪽을 연습하고 싶을 때 유용하다.

타이핑 모드 (Typing)

여기서부터 흥미로워진다. 자기 평가 대신 답을 직접 타이핑한다. 앱은 입력값과 정답을 레벤슈타인 거리(Levenshtein distance) -- 한 문자열을 다른 문자열로 변환하는 데 필요한 최소 편집(삽입, 삭제, 치환) 횟수 -- 를 사용해서 비교한다.

레벤슈타인 거리가 2 이하이면 정답으로 인정한다. 흔한 오타를 처리하기 위해서다: "accmoodation"은 "accommodation"과 거리가 2라서 통과하고, "accomodation"은 거리 1이니 당연히 통과한다. 임계값 2는 사소한 실수에 관대하면서도 실질적인 지식을 요구하는 균형점이다.

타이핑 결과는 FSRS 평가로 매핑된다: 정답은 Good, 오답은 Again. 자기 평가의 주관성을 제거한다 -- 알거나 모르거나 둘 중 하나다.

퀴즈 모드 (Quiz)

같은 덱의 다른 카드에서 오답 보기를 뽑아 4지선다를 구성한다. 카드가 4장 미만인 덱에서는 사용 불가(오답 보기가 부족하므로). 정답은 Good, 오답은 Again으로 매핑.

인터리빙 모드 (Interleaving)

학습 과학 관점에서 가장 흥미로운 모드다. 모든 덱에서 복습 예정인 카드를 가져와 섞은 뒤, 덱 이름 배지를 달아서 제시한다.

인터리빙은 수십 년의 연구로 뒷받침된다. 여러 주제의 연습을 섞으면 한 주제를 몰아서 공부하는 것(blocking)보다 장기 기억 유지와 전이(transfer)가 향상된다는 것이 밝혀져 있다. FSRS 처리는 동일하다 -- 각 카드는 여전히 자체 파라미터에 따라 업데이트된다.

XP 레이어

FSRS 위에 게이미피케이션 레이어를 추가했다. 각 평가마다 XP를 획득한다:

| 평가 | XP | |------|-----| | Again | 5 | | Hard | 10 | | Good | 15 | | Easy | 20 |

카드를 틀려도(Again) 5 XP를 받는다. 나타난 것 자체가 의미 있으니까. 레벨 공식은 floor(sqrt(xp / 100)) + 1로, 초반 레벨은 빨리 오르지만 높은 레벨일수록 점진적으로 더 많은 XP가 필요한 부드러운 곡선을 만든다. 레벨 2에 300 XP, 레벨 5에 2,400 XP, 레벨 10에 9,900 XP가 필요하다.

이 XP 시스템은 FSRS 스케줄링과 의도적으로 분리되어 있다. XP는 동기 부여 장치이고, FSRS는 과학이다. 서로 간섭하지 않는다.

트레이드오프: 간소화 vs 전체 FSRS-5

내가 무엇을 잘라냈는지 투명하게 밝히겠다. 전체 FSRS-5 명세에는 내가 구현하지 않은 여러 기능이 포함되어 있다.

유지한 것:

  • 19개 기본 파라미터 전체 (w0-w18)
  • 완전한 DSR 모델 (Difficulty, Stability, Retrievability)
  • 거듭제곱 법칙 망각 곡선
  • 성공/실패 시 별도의 안정성 업데이트 수식
  • 난이도 평균 회귀
  • Hard 페널티와 Easy 보너스 보정

간소화한 것:

  • 사용자별 파라미터 최적화 없음. 전체 FSRS 명세에는 각 사용자의 복습 이력을 기반으로 19개 파라미터를 조정하는 머신러닝 옵티마이저가 포함되어 있다. 나는 모든 사용자에게 기본 파라미터를 사용한다. 이것이 가장 큰 정확도 희생이다 -- 개인화된 파라미터는 개별 사용자의 스케줄링을 10-15% 개선할 수 있다.
  • 퍼지 팩터 없음. 전체 FSRS는 "복습 뭉침(review bunching)" -- 많은 카드가 같은 날 복습 예정이 되는 현상 -- 을 방지하기 위해 간격에 작은 무작위 변동을 추가한다. 나는 결정론적으로 스케줄링한다.
  • 단기 스케줄링 하위 상태 없음. 전체 명세에는 설정 가능한 단계 시퀀스(Anki의 "1m 10m" 학습 단계 같은)를 가진 Learning/Relearning 하위 상태의 세밀한 처리가 있다. 나는 Again에 일률적으로 10분 재시도를 사용한다.
  • 당일 복습 처리 없음. 하루에 같은 카드를 여러 번 복습하면 내 구현은 각 복습을 독립적으로 처리한다. 전체 명세에는 당일 내 복습에 대한 특수 로직이 있다.

트레이드오프 결과: 개인화된 파라미터를 갖춘 전체 FSRS-5 구현 대비 약 10%의 정확도 감소로 추정한다. 대신 엔진은 약 180줄의 Dart 코드다 -- ML 의존성 없음, 최적화 패스 없음, 학습 데이터 수집 없음. 어떤 기기에서든 마이크로초 단위로 실행된다.

대부분의 사용자가 1,000장 미만의 카드를 가진 소비자용 플래시카드 앱에서, 이 트레이드오프는 충분히 가치 있다. 기본 파라미터 자체가 평균적인 학습자에게 이미 우수하고, 단순함은 곧 적은 버그, 쉬운 테스트, 예측 가능한 동작을 의미한다.

엔진 테스트

FSRS 엔진은 외부 의존성이 없는 순수 클래스이므로 테스트가 간단하다. 입력(카드, 평가, 타임스탬프)을 받아 결정론적 출력(업데이트된 카드와 새 복습일)을 생성한다. 모킹 불필요, 데이터베이스 세팅 불필요, UI 프레임워크 불필요.

상태 전이를 철저하게 테스트한다: 새 카드가 learning vs review로 전이하는 것, review 카드가 relearning으로 떨어지는 것, 여러 번의 성공적 복습에 걸친 안정성 성장, 시간에 따른 난이도 수렴. 망각 곡선 수식도 자체 테스트가 있어서, 경과일이 안정성과 같을 때 retrievability가 정확히 0.9인지 검증한다.

돌아보며

FSRS 엔진을 직접 구현한 것은 BloomCard에서 내린 최고의 결정 중 하나였다. 스케줄링 로직에 대한 완전한 통제권을 얻었고, 이것은 정원 메타포가 작동하는 데 필수적이었다 -- 안정성을 성장 단계에 직접 매핑해야 했고, 그 매핑이 자연스럽게 느껴져야 했다.

플래시카드 앱을 만들며 어떤 알고리즘을 사용할지 고민하고 있다면, 내 권장사항은 이렇다: 빠른 프로토타입이 필요하면 SM-2를 쓰자. 스케줄링 품질을 신경 쓰고 며칠의 구현 시간을 투자할 의향이 있다면 FSRS를 선택하자. 기본 파라미터만으로도 SM-2보다 나은 스케줄링을 제공하고, 수학적 모델이 더 깔끔하고 원칙적이다.

내 FSRS 엔진의 전체 소스는 약 180줄의 Dart 코드다. 정원 성장 매핑이 약 50줄을 추가한다. 이 둘이 합쳐져 사용자에게 마법 같은 경험을 만든다 -- 딱 맞는 속도로 꽃으로 자라나는 카드, 실제 학습 진행 상황을 반영하는 정원.

이 성장 시스템과 BrainFit의 행성 진화 시스템을 비교한 글은 여기에서 볼 수 있다. 서로 다른 메타포(정원 vs 행성)가 학습 과정에 어떻게 다른 정서적 연결을 만드는지 탐구한 글이다.


BloomCard는 Flutter로 만든 정원 테마 플래시카드 앱입니다. 간격 반복, FSRS 알고리즘, 또는 학습 도구 개발에 관심이 있으시다면 편하게 연락주세요.

FSRS-5 간격 반복 알고리즘을 Flutter에 직접 구현한 이야기