
강의 PDF로 문제를 만들고 학습 계획을 짜는 앱
강의 PDF 한 편을 시험 전날까지의 풀이 계획으로
3주 남은 시험, 4개월 걸리는 알고리즘
시험을 앞둔 대학생과 수험생을 위한 학습 앱입니다. 요구사항은 크게 두 가지였어요. 강의 자료 PDF를 올리면 문제를 만들어 주는 것, 그리고 시험날까지 매일 무엇을 풀어야 하는지 정해 주는 것.
이 둘 사이에는 꽤 큰 간극이 있습니다. 앞쪽은 AI 파이프라인 문제이고, 뒤쪽은 스케줄링 문제예요. 그리고 시중의 간격 반복(spaced repetition) 도구는 대부분 뒤쪽을 풀어 주지 않습니다. Anki 계열이 쓰는 FSRS 같은 알고리즘은 '평생 기억하기'를 전제로 다음 복습일을 계산하기 때문에, 카드 하나가 잘 외워졌다 싶으면 태연하게 4개월 뒤를 다음 복습일로 잡아 버리거든요. 3주 뒤가 시험인 학생에게 그 카드는 그냥 사라진 카드입니다.
필요한 건 기억을 오래 유지하는 도구가 아니라 정해진 날짜에 정점을 찍는 도구였습니다. 저는 이 차이를 제품의 중심에 두었고, 그래서 이 앱의 거의 모든 화면에는 D-day가 붙어 있습니다.
이 글에서는 마감일이 있는 간격 반복과, 15분짜리 AI 파이프라인을 60초짜리 요청 밖으로 밀어낸 이야기만 다룹니다. RAG 검색과 AI 튜터 채팅, 인증과 타입 공유는 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

상시 서버 없이 시간 예산으로 쪼개기
서버를 상시 띄우지 않고 SST v3로 Lambda를 용도별로 쪼갰습니다
트래픽이 시험 기간에 몰리는 앱이라 평시 유휴 비용이 아까웠고, 무엇보다 작업마다 필요한 시간 예산이 달랐습니다. tRPC는 3008MB/60초, AI 배치는 arm64/900초, 이미지 변환은 2048MB로 각각 다르게 잡았어요. 하나의 서버였다면 가장 무거운 작업에 맞춰 전체를 키워야 했겠죠 인프라를 전담할 사람이 없어서 IaC를 TypeScript로 쓸 수 있어야 했습니다. libs/infrastructure 아래 파일 하나가 스택 하나에 대응하도록 나눴습니다
FSRS 계산은 전부 서버에서 합니다
복습 일정은 기기 시계에 의존하면 안 됩니다. 사용자가 시계를 당기면 오늘 치 카드가 통째로 열려 버리고, 무엇보다 D-day를 넘겨 카드를 미룰 수 있게 되면 제품의 전제가 무너집니다. ts-fsrs는 서버에만 두고, 앱은 "맞았다/틀렸다"와 체감 난이도만 올려보냅니다 오프라인 학습을 포기했습니다. 카드 한 장을 넘길 때마다 왕복이 한 번씩 생기고, 지하철에서는 이게 눈에 띕니다

60초를 기준으로 시스템 양분하기
이 시스템을 이해하는 가장 빠른 방법은 60초라는 선을 하나 긋는 것입니다. 선 위쪽에는 즉시 끝나야 하는 일들이 있습니다. 선 아래쪽에는 몇 분씩 걸리는 일들이 있습니다. 학습 세트를 만들면 tRPC Lambda는 study_set과 study_set_file 행 두 개만 만들고, 900초짜리 배치 Lambda의 Function URL에 요청을 던진 뒤 곧바로 응답합니다. 배치 쪽에서는 페이지 이미지를 하나씩 GPT에 보여 학술 자료로서의 관련도를 매기고, 관련도가 높은 것만 다시 상세 분석을 돌립니다. 그렇게 얻은 이미지 설명을 PDF 텍스트 청크에 덧붙여 임베딩을 만들고, 그 벡터 스토어를 근거로 세트 제목, 챕터 목차, 챕터별 요약, 그리고 문제 카드를 순서대로 생성합니다. 카드가 만들어지면 스케줄러가 D-day까지 날짜별로 흩뿌리고, 마지막에 is_initialized를 true로 올린 뒤 OneSignal로 푸시를 보냅니다.

마감일이 있는 간격 반복
ts-fsrs를 그대로 붙이는 겁니다. fsrs.repeat(card, now)가 돌려주는 due를 DB에 저장하고, 매일 due <= today인 카드를 꺼내 주면 끝나 보입니다. 실제로 첫 구현은 그랬어요. 그런데 사용자가 카드를 두어 번 잘 맞히면 FSRS는 다음 복습일을 몇 달 뒤로 밀어 버립니다. 시험이 3주 뒤인 사람에게 그 카드는 영원히 다시 안 나오고, 진도율은 낮은 채로 멈춰 있는데 오늘 풀 카드는 0장인 상황이 생깁니다. 반대 방향의 사고도 흔합니다. "그냥 전체 카드를 남은 날짜로 나눠서 하루치를 정하자"는 접근인데, 이건 간격 반복을 버리고 단순 분배로 돌아가는 것이라 어렵게 외운 카드와 계속 틀리는 카드가 똑같은 빈도로 나옵니다.
FSRS의 계산은 그대로 쓰되, 그 결과를 D-day라는 천장 아래로 접었습니다.
복습 한 번을 처리하는 rescheduleCard는 목표일 하루 전을 lastAllowedDate로 잡습니다. FSRS가 돌려준 due가 이 선을 넘으면 버리지 않고, 오늘부터 lastAllowedDate까지 남은 일수 안의 임의의 하루로 다시 떨어뜨립니다. 반대로 사용자가 "어려웠어"를 고르면 FSRS 기본값 대신 1~2일 뒤로 당기고, 틀리면 아예 오늘 안에 다시 나오게 due를 지금으로 되돌립니다.
카드가 처음 만들어질 때는 FlashcardScheduler.distributeInitialDueDates가 전체 기간의 70% 구간을 기준으로 하루 상한을 계산해 앞쪽에 촘촘히 배치합니다. 나중에 추가되는 카드는 distributeAdditionalFlashcards가 이미 잡힌 날짜별 카드 수를 세어 가장 한산한 날에 꽂습니다. 그리고 매일 자정 크론이 어제 예정이었는데 손도 안 댄 카드를 찾아, 남은 기간이 7일 미만이면 균등 분배로, 7일 이상이면 같은 분배 로직으로 되살립니다.
진도율의 정의도 여기에 맞췄습니다. "푼 카드 / 전체 카드"가 아니라, 각 카드의 망각 곡선을 목표일 시점으로 투영해서 회상률이 85% 이상으로 예상되는 카드만 마스터로 셉니다.
FSRS가 잘하는 일과 제품이 책임져야 할 일을 갈라놨기 때문입니다. "이 카드를 언제 다시 봐야 기억이 오래 가는가"는 알고리즘에 맡기고, "그 언제가 시험 이후면 어떻게 할 것인가"는 제품이 정합니다. 알고리즘을 고치지 않았기 때문에 안정성(stability)과 난이도(difficulty) 추정은 그대로 살아 있고, 상한만 걸었기 때문에 어떤 카드도 시험을 지나쳐 사라지지 않습니다.
진도율 정의가 특히 중요했어요. 회상률 기반으로 계산하면 "오늘 100장을 몰아서 푼 사람"의 진도율이 "매일 10장씩 열흘 푼 사람"보다 낮게 나옵니다. 벼락치기를 하면 숫자가 정직하게 덜 오릅니다. 그리고 이 정의는 사용자에게 그대로 설명할 수 있는 값이라, 앱 안에 "학습률 알아보기" 화면을 따로 만들어 회상률 구간별 의미까지 다 적어 놨습니다. 지표를 숨기지 않아도 되는 지표로 만든 셈입니다.
60초짜리 요청 안에 15분짜리 파이프라인을 넣지 않기
studySet.create mutation 안에서 다 하는 겁니다. 문제는 이 파이프라인이 100페이지짜리 자료에서 수 분에서 십수 분까지 걸린다는 겁니다. tRPC Lambda의 타임아웃은 60초이고, 그 앞의 CloudFront originReadTimeout도 60초입니다.
파일 하나를 기준으로 함수를 갈랐습니다. tRPC Lambda는 60초/3008MB, AI 배치 Lambda는 arm64/900초로 따로 정의하고, SST의 link로 배치 Lambda의 Function URL을 tRPC 쪽에 주입했습니다. 그 대신 완료를 알리는 경로를 세 겹으로 깔았습니다.
사용자가 기다리는 대상이 "응답"에서 "알림"으로 바뀌었기 때문입니다.
유저가 할 수 있는 일
레포에 남은 비밀 값과 꺼진 스위치
모든 것을 아름답게 정리해 두지는 못했습니다. 여기서는 두 가지만 적고, 소유권 검사와 타임존 처리는 따로 다룰 가치가 있어 접습니다.
첫째, 비밀 값이 레포 안에 있습니다. README에는 dev/prod 환경변수를 Infisical로 관리한다고 적혀 있지만, 실제로는 env-template.ts의 zod 스키마에 AWS 키, OpenAI 키, OneSignal REST 키, Sentry 토큰 같은 것들이 .default() 값으로 박혀 있습니다. 신규 개발자가 yarn gen:env 한 번으로 바로 돌려 볼 수 있게 하려던 편의였는데, 편의의 대가가 너무 컸습니다. 다시 만든다면 기본값 자리에는 #{FILL IN YOUR VALUE} 같은 플레이스홀더만 두고, 로컬 값은 Supabase 로컬 인스턴스가 뱉는 것만 넣겠습니다.
셋째, MVP를 잘라내는 방식이 잘못됐습니다. 유료 결제, GEM 미션, 광고 배너 같은 기능을 지우는 대신 process.env.PROJECT_ENV === 'Local' 조건으로 감쌌는데, Expo는 EXPO_PUBLIC_ 접두사가 붙은 환경변수만 번들에 인라인합니다. 즉 이 조건은 빌드된 앱에서 항상 false입니다. 의도한 대로 동작하고는 있지만 그건 우연이고, 코드를 읽는 사람은 "로컬에서는 켜지는 기능"이라고 읽게 됩니다. 죽은 코드는 죽은 코드로 표시하거나 지웠어야 합니다.














