
Introduction & Requirements
밤 11시 59분에 마지막 문제를 풀었습니다. 앱에는 레슨을 완료했다는 화면이 나왔지만, 지하철이 터널에 들어가면서 서버 응답을 받지 못했습니다. 다음 날 앱을 열었을 때 스트릭이 끊어져 있다면 사용자는 학습하지 않은 사람으로 처리된 셈입니다.
반대 상황도 있습니다. 휴대전화와 태블릿에서 같은 레슨 완료 요청을 여러 번 보내면 어떻게 될까요. 서버가 요청을 받을 때마다 숫자를 올린다면 하루에 여러 날의 스트릭이 생깁니다. 기기 날짜를 과거로 바꾸는 것만으로 빈 날짜를 채울 수 있어도 기록을 신뢰하기 어렵습니다.
Duolingo의 공식 안내 「How to Use Duolingo for Language Learning」에서는 하루에 레슨을 완료하면 스트릭을 이어 갈 수 있으며, Streak Freeze는 학습을 놓친 날에도 스트릭을 보호하는 기능이라고 설명합니다. 이 시스템이 관리하는 것은 학습량 전체가 아니라, 날짜별 학습 인정과 연속성입니다.
과거 공식 제품 글 「Improving the streak」에서는 일일 목표 달성과 스트릭 연장을 분리한 변경도 설명했습니다. 따라서 스트릭을 계산할 때 XP 합계가 특정 값 이상인지부터 확인하는 설계를 당연하게 선택해서는 안 됩니다. 무엇을 학습 완료로 인정할지 제품 규칙으로 정의하고, 그 규칙을 통과한 기록을 받아야 합니다.
최근 공개 자료도 살펴볼 필요가 있습니다. Duolingo의 2026년 2분기 주주 서한에는 같은 해 6월 일회성 Streak Revival 행사가 소개되어 있습니다. 대상 사용자가 참여하고 레슨 세 개를 완료하면 과거 최장 스트릭을 복원할 수 있었던 행사입니다. 일반적인 Freeze와 별도 복원 정책을 같은 상태 변경으로 처리하기 어렵다는 것을 보여 줍니다. 이 행사를 상시 제공되는 기능으로 가정하지는 않겠습니다.
가장 단순한 구현은 사용자 테이블에 마지막 학습 날짜와 현재 스트릭 길이를 두는 것입니다. 오늘 처음 학습했고 마지막 학습일이 어제라면 숫자를 하나 올립니다. 같은 날 다시 학습했다면 그대로 둡니다.
온라인 요청만 있고 시간대가 고정되어 있다면 이 방식으로 시작할 수 있습니다. 하지만 Freeze, 여러 기기, 자정 직전 요청, 오프라인 기록, 서비스 장애 보상이 들어오면 마지막 날짜만으로는 결과를 설명할 수 없습니다. 어제 학습하지 않은 것인지, 학습했지만 아직 서버에 도착하지 않은 것인지부터 구분해야 합니다.
여기에서는 학습 완료 사실, 날짜별 판정, 현재 화면에 표시할 요약을 분리하겠습니다. 가장 어려운 상태는 이미 Freeze로 보호했거나 누락으로 판단한 날짜에 유효한 학습 기록이 나중에 도착하는 경우입니다.
2026년 9월 23일 확인한 공개 자료만으로는 현재 Duolingo의 전체 저장 구조, 시간대 변경 규칙, 오프라인 인정 기한을 확정할 수 없습니다. 아래 DB, Queue, API, 시간 정책은 공개된 제품 동작을 참고한 시스템 디자인 가정입니다. 과거에 공개된 장애 복구 사례도 현재 내부 구현 전체를 설명하는 근거로 사용하지 않습니다.
Functional Requirements
사용자는 인정 가능한 레슨을 완료하면 해당 학습일의 스트릭 조건을 충족합니다.
시스템은 같은 학습일에 여러 레슨을 완료해도 스트릭을 한 번만 증가시킵니다.
사용자는 현재 스트릭, 최장 기록, 날짜별 학습 여부, Freeze 보호 여부를 확인할 수 있습니다.
시스템은 학습하지 않은 날에 사용 가능한 Freeze가 있으면 정해진 정책에 따라 연속성을 보호합니다.
시스템은 여러 기기의 중복 요청과 늦은 동기화가 있어도 동일한 학습 사실을 한 번만 반영합니다.
사용자는 네트워크 연결이 약한 상황에서도 학습 결과를 기기에 보관하고 연결 복구 후 동기화할 수 있습니다.
운영자는 서비스 장애로 영향을 받은 기록을 근거와 함께 복구할 수 있습니다.
사용자는 학습 알림을 설정하거나 끌 수 있으며, 시스템은 이미 학습한 사용자에게 불필요한 알림을 줄입니다.
Non-Functional Requirements
정상 상태에서 스트릭 조회 API는 p95 100ms, p99 250ms를 목표로 둡니다. 실제 Duolingo의 공개 SLA가 아니라 이 설계를 위한 가정입니다.
학습 완료 API는 정상 상태에서 접수와 스트릭 반영을 포함해 p99 500ms를 목표로 둡니다. 계산이 지연되면 접수 사실을 먼저 반환하고 처리 중임을 표시합니다. 이것도 설계를 위한 가정입니다.
학습 완료 접수 API의 월간 가용성 목표는 99.95%로 가정합니다. 정상 요청 대비 성공적으로 영속화한 요청의 비율로 측정합니다.
성공으로 응답한 학습 완료 기록은 프로세스나 가용 영역 하나가 중단되어도 보존하도록 설계합니다. 다른 지역 전체로의 재해 복구는 별도 복제 지연과 복구 절차를 가집니다.
사용자 한 명의 학습 인정, Freeze 배정, 스트릭 교정은 서로 충돌하지 않도록 처리합니다. 친구 화면과 통계 화면에는 짧은 반영 지연을 허용합니다.
완료 요청의 99.9%가 접수 후 5초 이내에 스트릭 계산에 반영되도록 목표를 둡니다. 접수 성공률과 계산 완료율을 별도로 측정합니다.
임의의 클라이언트 시각, 사용자 ID, XP 값만으로 과거 학습을 인정하지 않습니다.
날짜 판정에 사용한 시간대, 정책 버전, 변경 근거를 보관하여 같은 입력으로 결과를 다시 계산할 수 있어야 합니다.
학습 기록과 기기 정보를 필요한 범위만 보관하고, 삭제 요청이 원본과 조회용 데이터에 반영되도록 관리합니다.
Out of Scope
문제 출제, 정답 채점, 학습 경로 추천 모델의 내부 구현
XP 리더보드, Friend Streak, 친구 관계 시스템
결제 처리와 앱 스토어 영수증 검증 자체
상점 가격, 구독별 혜택, Freeze 보유 한도 실험
Streak Revival 같은 캠페인의 대상 선정과 마케팅 실행
현재 스트릭 도중 사용자가 임의로 기준 시간대를 바꾸는 기능
Traffic & Capacity Assumptions
Duolingo는 2026년 2분기 일일 활성 사용자 수를 약 5,870만 명으로 공개했습니다. 아래에서는 계산을 단순하게 하기 위해 DAU 6천만 명을 가정합니다. 레슨 빈도, 요청 수, 피크 배수, 데이터 크기는 공개된 회사 지표가 아닙니다.
사용자 수와 요청 빈도
DAU를 6천만 명, 사용자당 하루 평균 레슨 완료를 3회, 스트릭 화면과 위젯 조회를 합쳐 8회로 가정하겠습니다. 하루 레슨 완료는 1억 8천만 건, 스트릭 조회는 4억 8천만 건입니다.
레슨 완료 수와 스트릭 증가 수는 다릅니다. 같은 날의 추가 레슨은 학습 기록으로 보존하지만 스트릭 증가를 반복하지 않습니다. 원본 완료 기록의 쓰기 부하와 사용자별 요약의 변경 부하를 분리해서 계산해야 합니다.
평균 QPS
하루 1억 8천만 건을 86,400초로 나누면 평균 완료 요청은 초당 약 2,083건입니다. 조회는 초당 약 5,556건입니다.
이 평균만으로 처음부터 복잡한 분산 DB가 필요하다고 결론내리지는 않겠습니다. 트랜잭션 길이, 인덱스 크기, 복제 지연, 실제 부하 시험 결과를 함께 봐야 합니다. 초기에는 단일 쓰기 DB와 읽기 캐시를 사용하는 편이 구현과 복구를 단순하게 만듭니다.
Peak QPS
피크를 평균의 8배로 가정하면 완료 요청은 초당 약 1만 6,700건, 조회는 약 4만 4,400건입니다.
사용자의 취침 시간과 현지 자정 주변에는 부하가 균등하지 않습니다. 글로벌 피크 배수 외에 지역별 분포를 별도로 측정해야 합니다. 알림 발송과 날짜 마감 작업까지 같은 시각에 집중시키면 완료 요청의 처리 지연이 커지므로, 학습 접수를 우선하고 후속 작업을 분산합니다.
이벤트 수
레슨당 완료 이벤트 한 건과 문제 응답, 화면 전환 등의 분석 이벤트 아홉 건을 가정하면 하루 18억 이벤트입니다. 평균 약 2만 800건, 8배 피크에서는 초당 약 16만 7천 건입니다.
이벤트 전부가 스트릭 DB에 들어갈 필요는 없습니다. 스트릭에는 검증된 레슨 완료만 전달합니다. 고빈도 분석 이벤트를 별도 경로로 보내면 스트릭 계산이 분석 수집 지연에 영향을 덜 받습니다.
저장 용량
레슨 완료 요약 한 건의 논리적 크기를 300바이트로 가정하면 하루 약 54GB입니다. 90일은 약 4.86TB이며, 복제본 세 개만 고려해도 약 14.6TB입니다. 인덱스, WAL, 여유 공간은 포함하지 않은 값입니다.
따라서 원본 완료 요약을 RDBMS에 무기한 보관하지 않습니다. 최근 재시도와 교정에 필요한 구간은 온라인 DB에 두고, 오래된 기록은 검증 가능한 아카이브와 계산 기준점으로 보관합니다. 날짜별 판정도 하루 최대 약 6천만 건이 추가될 수 있으므로, 전체 레슨 기록보다 작다고 해서 보관 비용을 무시하지 않습니다.
스트릭을 계산하기 전에 날짜를 정의하기
최근 24시간과 오늘은 다릅니다
월요일 밤 11시 50분에 학습하고 화요일 밤 11시 55분에 다시 학습하면 두 학습 사이에는 24시간이 넘습니다. 그래도 달력 기준으로는 이틀 연속 학습입니다. 반대로 월요일 오전과 오후에 학습했다고 이틀이 되지는 않습니다.
따라서 스트릭은 마지막 완료 후 경과 시간을 재는 타이머가 아닙니다. 사용자에게 적용되는 학습일 구간을 만들고, 그 구간에 인정 가능한 완료가 있는지 판단하는 문제입니다.
기준 시간대와 날짜 경계
이 설계에서는 계정에 저장한 IANA 시간대를 기준으로 학습일을 계산합니다. 여행 중 기기의 시간대가 바뀌어도 계정의 기준 시간대는 자동으로 바꾸지 않습니다. 앱에는 오늘의 마감 시각과 기준 시간대를 함께 보여 줍니다. 이는 구현 가능한 정책을 선택한 것이며, 현재 Duolingo의 여행 정책을 설명하는 것은 아닙니다.
예를 들어 기준 시간대가 America/Los_Angeles이면 해당 지역의 자정부터 다음 자정 직전까지를 하나의 학습일로 봅니다. 시작 시각은 포함하고 종료 시각은 제외하는 구간을 사용합니다.
starts_at <= effective_completed_at < ends_at
정확히 자정에 인정된 완료는 새 학습일에 속합니다. 자정 전에 레슨을 시작했다는 이유만으로 전날 학습으로 인정하지 않습니다. 시작만 해 놓고 다음 날 완료하는 요청을 구분하기 위해 완료 시각을 사용합니다.
일광 절약 시간제가 바뀌는 날에는 하루가 23시간 또는 25시간일 수 있습니다. 다음 마감 시각을 현재 마감에 86,400초를 더해 만들면 틀릴 수 있습니다. 시간대 규칙으로 다음 현지 날짜의 자정을 구한 뒤 UTC 구간으로 변환해야 합니다.
PostgreSQL의 timestamptz는 입력에 사용한 원래 시간대 이름을 보존하는 필드가 아닙니다. 시간의 절대값과 함께 IANA 시간대 이름, 시간대 데이터 버전, 당시 계산한 날짜 구간을 별도로 보관하겠습니다. 규칙이 바뀐 뒤 과거 기록을 다른 날짜로 재분류하는 일을 방지하기 위해서입니다.
기준 시간대 변경을 제품에 추가하려면 새 정책이 적용될 미래 경계를 정하고, 이전 구간과 새 구간 사이에 겹침이나 공백이 없게 연결해야 합니다. 이미 확정된 날짜를 새 시간대로 다시 해석하는 방식은 사용하지 않습니다.
학습한 날과 보호된 날
이 글에서는 레슨을 완료한 날짜만 스트릭 숫자를 하나 올리고, Freeze로 보호한 날짜는 기존 숫자를 유지하는 정책을 사용합니다. 보호된 날짜도 연결은 유지하지만 학습했다는 표시를 만들지는 않습니다.
예를 들어 월요일까지 10일 스트릭이었다고 하겠습니다. 화요일을 Freeze로 보호하면 숫자는 10입니다. 수요일에 레슨을 완료하면 11이 됩니다. 금요일까지 모든 날짜를 실제로 학습한 기록과, 중간에 보호된 날짜가 있는 기록은 달력에서 구분합니다.
Freeze는 이 설계에서 해당 학습일이 시작하기 전에 확보한 권리만 사용할 수 있다고 가정합니다. 뒤늦게 구입한 Freeze로 과거의 누락을 자동으로 채우지는 않습니다. 이를 허용하려면 별도의 복원 정책과 변경 근거가 필요합니다.
날짜가 끝났다고 즉시 모든 정보가 확정되지는 않습니다
학습일 종료와 늦은 기록을 받을 수 있는 기한을 구분하겠습니다. 오프라인 기록은 학습일 종료 후 36시간 이내에 서버가 영속적으로 접수한 경우까지 검토하는 정책을 가정합니다. 이 기한은 학습할 시간을 36시간 더 주는 것이 아닙니다. 실제 완료가 원래 학습일 안에 있었다는 조건은 그대로입니다.
자정에는 현재까지 받은 사실로 잠정 판정을 만듭니다. 학습 기록이 없고 Freeze가 있으면 우선 예약합니다. 인정 기한이 지났고 접수된 입력을 모두 처리했을 때 소비를 확정합니다.
Freeze도 없고 기록도 아직 확인되지 않은 날짜는 확정 전까지 확인 중으로 표시합니다. 이전 기록을 확정적으로 잃었다고 먼저 알리지 않습니다. 대신 사용자가 무엇을 기다리는지 알 수 있도록 동기화 상태를 표시합니다.
36시간이라는 선택은 여행과 약한 네트워크를 지원하지만, 누락 판정과 Freeze 소비의 확정을 늦춥니다. 오프라인 사용이 적은 제품이라면 더 짧은 기한이 합리적일 수 있습니다. 어느 정책을 선택하든 접수 기한과 화면 표시를 함께 정해야 합니다.
Core Entities & Data Modeling
먼저 정확하게 유지해야 할 데이터를 정하겠습니다. 한 레슨 완료가 두 번 인정되면 안 되고, 하나의 Freeze가 두 날짜를 동시에 보호하면 안 됩니다. 교정 전후에 어떤 사실이 바뀌었는지도 설명할 수 있어야 합니다.
반면 위젯과 친구 화면은 몇 초 전 요약을 보여 줄 수 있습니다. 이런 조회용 복사본을 원본 상태와 동일한 정합성 수준으로 운영할 필요는 없습니다.
기록을 연결하는 이름
user_id , 학습 기록과 스트릭을 소유하는 계정
session_id , 서버가 발급한 하나의 레슨 실행
completion_id , 검증을 통과해 저장된 레슨 완료
idempotency_key , 같은 완료 요청의 재시도를 연결하는 값
learning_day , 고정된 계정 시간대에서 판정한 학습 날짜
grant_id , 하루를 보호할 수 있는 개별 Freeze 권리
input_version , 사용자에게 접수한 스트릭 입력의 증가하는 버전
processed_version , 계산에 반영한 입력의 버전
snapshot_revision , 화면에 제공하는 스트릭 요약의 변경 버전
원본과 요약을 분리하는 스키마
다음은 PostgreSQL 기준의 주요 테이블 예시입니다. 레슨 답안, 인증 정보, 상점 결제 데이터는 이 스키마에 넣지 않습니다.
CREATE TABLE streak_accounts (
user_id UUID PRIMARY KEY,
time_zone TEXT NOT NULL,
policy_version INTEGER NOT NULL,
input_version BIGINT NOT NULL DEFAULT 0,
processed_version BIGINT NOT NULL DEFAULT 0,
snapshot_revision BIGINT NOT NULL DEFAULT 0,
current_count INTEGER NOT NULL DEFAULT 0,
longest_count INTEGER NOT NULL DEFAULT 0,
dirty_from DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CHECK (processed_version <= input_version),
CHECK (current_count >= 0),
CHECK (longest_count >= 0)
);
CREATE TABLE lesson_completions (
user_id UUID NOT NULL REFERENCES streak_accounts(user_id),
completion_id UUID NOT NULL,
session_id UUID NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
effective_completed_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
learning_day DATE NOT NULL,
policy_version INTEGER NOT NULL,
input_version BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (user_id, completion_id),
UNIQUE (user_id, session_id),
UNIQUE (user_id, idempotency_key),
UNIQUE (user_id, input_version)
);
CREATE TABLE streak_days (
user_id UUID NOT NULL REFERENCES streak_accounts(user_id),
learning_day DATE NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
finalize_at TIMESTAMPTZ NOT NULL,
time_zone TEXT NOT NULL,
tzdb_version TEXT NOT NULL,
policy_version INTEGER NOT NULL,
outcome TEXT NOT NULL CHECK (
outcome IN ('LEARNED', 'FROZEN', 'EXCUSED', 'MISSED')
),
settlement TEXT NOT NULL CHECK (
settlement IN ('PROVISIONAL', 'FINAL')
),
count_after INTEGER NOT NULL CHECK (count_after >= 0),
snapshot_revision BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (user_id, learning_day),
CHECK (starts_at < ends_at),
CHECK (ends_at <= finalize_at)
);
CREATE TABLE freeze_grants (
user_id UUID NOT NULL REFERENCES streak_accounts(user_id),
grant_id UUID NOT NULL,
grant_key TEXT NOT NULL,
usable_from TIMESTAMPTZ NOT NULL,
assigned_day DATE,
state TEXT NOT NULL CHECK (
state IN ('AVAILABLE', 'RESERVED', 'CONSUMED')
),
version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, grant_id),
UNIQUE (user_id, grant_key),
UNIQUE (user_id, assigned_day),
CHECK (
(state = 'AVAILABLE' AND assigned_day IS NULL)
OR
(state IN ('RESERVED', 'CONSUMED')
AND assigned_day IS NOT NULL)
)
);
CREATE TABLE outbox_events (
event_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
input_version BIGINT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
published_at TIMESTAMPTZ,
UNIQUE (aggregate_id, event_type, idempotency_key)
);
CREATE INDEX outbox_unpublished_idx
ON outbox_events (created_at)
WHERE published_at IS NULL;
session_id의 고유 제약은 클라이언트가 재시도 키를 바꿔 같은 레슨을 다시 보내는 경우도 막습니다. idempotency_key는 통신 재시도를 처리하고, session_id는 업무상 같은 레슨인지 판단합니다. 두 값의 역할이 다릅니다.
request_hash는 정규화한 요청 본문과 검증 증거의 해시입니다. 같은 키로 다른 레슨이나 다른 완료 증거를 보내면 기존 성공 응답을 그대로 돌려주지 않고 충돌로 처리합니다. 재시도마다 달라지는 서버 수신 시각은 이 해시에 넣지 않습니다.
created_at은 행이 만들어진 시각이고 updated_at은 판정이나 요약이 바뀐 시각입니다. 늦게 도착한 완료의 학습 시각과도 다릅니다. 이 시각들을 하나로 합치면 처리 지연과 실제 학습 지연을 구분할 수 없습니다.날짜 판정을 boolean으로 만들지 않은 이유도 있습니다. 학습한 날, Freeze를 사용한 날, 서비스 장애로 보호한 날, 보호되지 않은 누락은 서로 다른 결과입니다. 잠정 판정인지 확정 판정인지도 별도 필드로 관리합니다.
위 스키마 외에 Freeze 배정 변경 이력, 장애 보상, 정책 변경, 계산 기준점도 보관합니다. 운영자가 요약 숫자만 직접 수정하는 기능은 제공하지 않습니다. 수정이 필요하면 원인과 적용 범위를 가진 입력을 추가하고 다시 계산합니다.
저장 계층의 책임
RDBMS에는 최근의 검증된 완료 요약, 계정 시간 정책, 날짜별 판정, Freeze 권리와 배정, 교정 이력, Outbox를 둡니다. 고유 제약과 조건부 변경이 필요하고 사용자 단위로 함께 잠가야 하는 상태이기 때문입니다.
Event Stream에는 완료 접수와 상태 변경 이벤트를 전달합니다. 스트림의 이벤트 한 건만 보고 스트릭 숫자를 무조건 증가시키지 않습니다. 이벤트는 영속 입력을 계산하라는 신호이며, 최종 판단은 사용자별 원본 기록을 기준으로 합니다.
Object Storage에는 오래된 완료 기록, 변경 이력의 아카이브, 재계산을 위한 스냅샷을 둡니다. 쓰기 후 무결성과 조회 가능 여부를 확인한 다음 온라인 DB의 보관 구간을 줄입니다.
Cache에는 현재 스트릭 요약과 최근 달력 화면을 둡니다. snapshot_revision과 판정 기준 시각을 포함해, 오래된 응답이 새 결과를 덮어쓰지 않도록 합니다.
Analytics와 Warehouse에는 학습 복귀율, 잘못된 스트릭 단절 신고, Freeze 사용, 알림 효과를 분석할 데이터를 둡니다. 분석 쿼리가 사용자별 완료 트랜잭션의 잠금을 오래 잡지 않도록 분리합니다.
이 문제에는 Search Index, Vector Index, Graph Index, Online Feature Store가 필요하지 않습니다. 사용자 ID와 날짜로 찾는 요청이 대부분이므로 해당 저장소를 추가할 이유가 없습니다.
RDBMS에서 제외할 데이터
문제를 푸는 동안 발생한 모든 터치, 화면 체류, 정답 입력 과정을 스트릭 RDBMS에 저장하지 않습니다. 양이 많고 완료 인정 이후의 날짜 계산에 직접 필요하지 않습니다. 분석 이벤트 수집 경로를 통해 Event Stream과 Object Storage로 보냅니다.
음성 녹음, 이미지, 레슨 콘텐츠 같은 큰 파일은 Object Storage에 두고 필요한 참조만 전달합니다. 스트릭 계산은 검증된 완료 사실을 사용하며 큰 파일을 읽지 않습니다.
앱이 보여 주는 초 단위 카운트다운, 애니메이션 진행률, 네트워크 연결 상태도 서버 RDBMS에 계속 쓰지 않습니다. 서버가 제공한 마감 시각을 기준으로 기기에서 표시하고, 서버에 접속했을 때 시각 차이를 보정합니다.
오래된 원시 완료 기록은 아카이브로 옮길 수 있지만 멱등성과 복구 근거까지 함께 없애서는 안 됩니다. 보관 기간 밖의 만료된 레슨 토큰은 새 요청으로 인정하지 않고, 과거 교정은 별도의 복구 경로로 제한합니다.
API Signatures
레슨 완료 접수
POST /v1/lesson-completions
Authorization: Bearer <access-token>
Idempotency-Key: 17f77238-8bde-4dcb-aab2-8d10e63c067e
Content-Type: application/json
{
"session_id": "b95e65c7-bb66-4b10-9236-d98a375b3234",
"completion_proof": "<signed-or-verifiable-evidence>",
"client_completed_at": "2026-09-23T23:59:50-07:00"
}사용자 ID는 인증 결과에서 구합니다. 클라이언트가 보낸 시각은 참고 자료이며 곧바로 날짜 판정에 사용하지 않습니다. 서버는 세션 소유권, 완료 조건, 증거의 유효 기간을 확인해 인정 시각과 학습일을 결정합니다.
완료 기록을 영속화하고 해당 입력까지 스트릭 계산을 마친 경우 다음과 같이 응답합니다.
HTTP/1.1 201 Created
Content-Type: application/json
{
"completion_id": "c1f846dc-75de-42d6-a60c-c70f1133860f",
"receipt_status": "accepted",
"learning_day": "2026-09-23",
"input_version": 842,
"streak": {
"status": "updated",
"current_count": 61,
"today_completed": true,
"processed_version": 842,
"snapshot_revision": 190,
"day_ends_at": "2026-09-24T07:00:00Z"
}
}접수는 끝났지만 계산이 응답 대기 예산을 넘으면 202 Accepted를 반환합니다. 이때 receipt_status는 accepted, 스트릭 상태는 processing입니다. 완료 사실이 저장된 것과 화면 숫자가 갱신된 것을 혼동하지 않게 합니다.
같은 키로 동일한 요청을 재시도하면 같은 completion_id를 반환합니다. 계산이 진행되었다면 최신 처리 상태를 함께 줄 수 있으므로 응답 바이트 전체가 항상 같을 필요는 없습니다. 같은 키에 다른 본문이 오면 409 Conflict입니다.
접수 결과와 스트릭 조회
GET /v1/lesson-completions/c1f846dc-75de-42d6-a60c-c70f1133860f
Authorization: Bearer <access-token>이 API는 해당 완료의 접수 여부와 계산 반영 여부를 확인합니다. 응답을 잃은 클라이언트가 새로운 완료를 만드는 대신 기존 결과를 찾는 데 사용합니다.
GET /v1/streak?min_input_version=842
Authorization: Bearer <access-token>방금 레슨을 완료한 화면은 최소한 자신의 입력까지 반영한 요약을 요청할 수 있습니다. 캐시의 processed_version이 부족하면 primary의 상태를 확인합니다. 아직 처리하지 못했다면 오래된 숫자를 최신이라고 반환하지 않고 처리 중 상태를 제공합니다.
GET /v1/streak/days?limit=31&cursor=<opaque-cursor>
Authorization: Bearer <access-token>날짜별 달력은 날짜와 요약 버전을 포함한 불투명한 Cursor로 조회합니다. 도중에 교정으로 버전이 바뀌면 최근 구간을 새 스냅샷으로 다시 받게 합니다. 작은 달력 화면에서는 복잡한 병합보다 재조회가 단순합니다.
Freeze 권리 등록
POST /internal/v1/streak/freeze-grants
Idempotency-Key: purchase-confirmed-93f81
Content-Type: application/json
{
"user_id": "744b70c4-4dc5-4704-a99a-8cf1d33bc3b6",
"verified_purchase_id": "purchase-93f81"
}이 API는 신뢰하는 상점 서비스만 호출합니다. 스트릭 서비스는 검증된 구매를 개별 Freeze 권리로 등록하고, 동일 구매의 재전달로 권리를 추가 발급하지 않습니다. 결제 응답이 지연된 경우에는 확인 중으로 두고, 결제 성공 이벤트 또는 대사 결과로 해결합니다.
결제 시스템과 스트릭 DB 전체에 하나의 트랜잭션이 있다고 가정하지 않습니다. 결제 성공 후 권리 등록이 실패하면 같은 구매 ID로 다시 등록하며, 구매 내역과 권리 내역의 차이를 주기적으로 확인합니다.

