모바일 우선 교육 기술: 오프라인 우선 아키텍처
개발도상국 학생의 67%가 교육 콘텐츠에 접근합니다. 스마트폰을 통해서만, 종종 불안정한 2G/3G 연결을 사용하거나 모두 결석. 선진국에서도 통근자, 농촌 학생 WiFi 연결이 좋지 않은 곳에서 공부하는 사람들도 같은 어려움에 직면합니다. 지속적인 연결이 필요한 EdTech 플랫폼과 청중의 상당 부분을 제외합니다.
L'오프라인 우선 아키텍처 전통적인 패러다임을 뒤집는다. "기본적으로 온라인, 예외적으로 오프라인" 대신 "기본적으로 오프라인, 동기화를 위해 온라인". 이 앱은 인터넷이 없어도 항상 즉시 작동합니다. 연결이 가능해지면 백그라운드에서 데이터를 동기화합니다. 2025년 연구에 따르면 오프라인 우선 앱에는 참여도 40% 증가 e 이탈률 25% 감소 네트워크 우선 앱과 비교됩니다.
이 문서에서는 프로그레시브 웹 앱(PWA)의 전체 아키텍처를 구축합니다. 오프라인 우선 교육 기술: 캐싱을 위한 서비스 워커, 로컬 스토리지를 위한 IndexedDB, 충돌 해결 및 콘텐츠 프리페칭을 통한 동기화 전략 지속적인 학습을 위해 지능적입니다.
이 기사에서 배울 내용
- 서비스 워커를 사용한 캐싱 전략: 캐시 우선, 네트워크 우선, 재검증 중 오래된 것
- Dexie.js를 사용한 클라이언트 측 구조적 저장소용 IndexedDB
- 네트워크/스토리지에서 UI를 분리하는 리포지토리 패턴
- 안정적인 동기화를 위한 Background Sync API
- 오프라인 편집을 위한 충돌 해결
- 지능적인 이력서 기반 콘텐츠 프리페칭
- 오프라인 진행 상황 추적: xAPI 명령문 대기열
- 학습 알림 푸시 알림
1. 오프라인 우선 아키텍처: 리포지토리 패턴
오프라인 우선의 기본 원칙은 패턴 저장소: UI 구성 요소는 네트워크와 직접 통신하지 않습니다. 그들은 A와 이야기를 나눕니다. 저장소 캐싱 로직을 투명하게 관리합니다. 로컬 캐시에서 먼저 읽은 후(즉시 응답) 서버와 동기화합니다. 백그라운드에서(자동 업데이트) 사용자는 네트워크를 기다리지 않습니다.
// src/offline/course-repository.ts
import Dexie, { Table } from 'dexie';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface CachedCourse {
id: string;
tenantId: string;
title: string;
description: string;
lessons: CachedLesson[];
totalLessons: number;
cachedAt: number;
syncVersion: number;
offlineAvailable: boolean;
}
export interface CachedLesson {
id: string;
courseId: string;
title: string;
content: string; // HTML del contenuto
videoUrl?: string;
duration: number; // secondi
cachedAt: number;
completed: boolean;
progress: number; // 0-100
syncStatus: 'synced' | 'pending' | 'conflict';
}
export interface OfflineProgressRecord {
id?: number; // Auto-increment
lessonId: string;
courseId: string;
studentId: string;
progress: number;
completed: boolean;
timeSpent: number;
answers: Record<string, any>;
timestamp: number;
syncStatus: 'pending' | 'synced' | 'failed';
xapiStatement?: object; // Statement xAPI pronto da inviare
}
export class EdTechDatabase extends Dexie {
courses!: Table<CachedCourse, string>;
lessons!: Table<CachedLesson, string>;
progress!: Table<OfflineProgressRecord, number>;
assets!: Table<{ id: string; blob: Blob; cachedAt: number }, string>;
constructor() {
super('EdTechOfflineDB');
this.version(1).stores({
courses: 'id, tenantId, cachedAt, offlineAvailable',
lessons: 'id, courseId, cachedAt, syncStatus',
progress: '++id, lessonId, courseId, studentId, syncStatus, timestamp',
assets: 'id, cachedAt',
});
}
}
@Injectable({ providedIn: 'root' })
export class CourseRepository {
private db = new EdTechDatabase();
constructor(private http: HttpClient) {}
/**
* Ottieni i dettagli di un corso.
* Strategia: stale-while-revalidate
* 1. Ritorna subito i dati dalla cache locale (se disponibili)
* 2. In background, aggiorna la cache dal server
*/
async getCourse(courseId: string): Promise<CachedCourse | null> {
// Lettura immediata dalla cache
const cached = await this.db.courses.get(courseId);
// Revalidazione in background (non blocca l'UI)
this.revalidateCourse(courseId).catch(() => {}); // Fire-and-forget
return cached ?? null;
}
async getLessons(courseId: string): Promise<CachedLesson[]> {
const cached = await this.db.lessons
.where('courseId')
.equals(courseId)
.toArray();
if (cached.length > 0) {
this.revalidateLessons(courseId).catch(() => {});
return cached;
}
// Cache miss: fetch dal network
try {
const lessons = await firstValueFrom(
this.http.get<CachedLesson[]>(`/api/courses/${courseId}/lessons`)
);
await this.cacheLessons(lessons);
return lessons;
} catch {
return []; // Offline e cache vuota
}
}
async saveProgress(record: Omit<OfflineProgressRecord, 'id' | 'syncStatus'>): Promise<void> {
const fullRecord: OfflineProgressRecord = {
...record,
syncStatus: 'pending',
timestamp: Date.now(),
};
await this.db.progress.add(fullRecord);
// Aggiorna anche la cache della lezione
await this.db.lessons.update(record.lessonId, {
progress: record.progress,
completed: record.completed,
syncStatus: 'pending',
});
// Tenta sync immediato se online
if (navigator.onLine) {
await this.syncPendingProgress();
}
// Se offline, il Service Worker gestira il background sync
}
async syncPendingProgress(): Promise<void> {
const pending = await this.db.progress
.where('syncStatus')
.equals('pending')
.toArray();
for (const record of pending) {
try {
await firstValueFrom(
this.http.post('/api/progress', record)
);
await this.db.progress.update(record.id!, { syncStatus: 'synced' });
} catch (error) {
await this.db.progress.update(record.id!, { syncStatus: 'failed' });
}
}
}
private async revalidateCourse(courseId: string): Promise<void> {
if (!navigator.onLine) return;
try {
const course = await firstValueFrom(
this.http.get<CachedCourse>(`/api/courses/${courseId}`)
);
await this.db.courses.put({ ...course, cachedAt: Date.now() });
} catch {
// Silently fail: la cache e ancora valida
}
}
private async revalidateLessons(courseId: string): Promise<void> {
if (!navigator.onLine) return;
try {
const lessons = await firstValueFrom(
this.http.get<CachedLesson[]>(`/api/courses/${courseId}/lessons`)
);
await this.cacheLessons(lessons);
} catch {}
}
private async cacheLessons(lessons: CachedLesson[]): Promise<void> {
const now = Date.now();
await this.db.lessons.bulkPut(
lessons.map(l => ({ ...l, cachedAt: now, syncStatus: 'synced' as const }))
);
}
}
2. 서비스 워커: 캐싱 전략
서비스 워커는 오프라인 우선의 핵심입니다. 모든 HTTP 요청을 가로챕니다. 그리고 응답 방법(캐시, 네트워크 또는 조합)을 결정합니다. EdTech 플랫폼의 경우 다양한 자산 유형에 대해 다양한 전략을 사용합니다.
// src/service-worker.ts (Workbox-based)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
// Pre-cache: shell applicazione (HTML, CSS, JS critici)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// === STRATEGIE PER TIPO DI RISORSA ===
// 1. Contenuto statico (immagini, font, icone): Cache-First con TTL lungo
registerRoute(
({ request }) => request.destination === 'image' || request.destination === 'font',
new CacheFirst({
cacheName: 'edtech-static-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 giorni
],
}),
);
// 2. Video educativi: Cache-First per contenuto già scaricato
registerRoute(
({ url }) => url.pathname.startsWith('/api/assets/video/'),
new CacheFirst({
cacheName: 'edtech-videos-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200, 206] }), // Supporta range requests
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 }), // 7 giorni
],
}),
);
// 3. API dati corso: Stale-While-Revalidate
// Risposta istantanea dalla cache, aggiornamento silenzioso in background
registerRoute(
({ url }) => url.pathname.startsWith('/api/courses/') && !url.pathname.includes('/progress'),
new StaleWhileRevalidate({
cacheName: 'edtech-api-courses-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 }), // 1 ora
],
}),
);
// 4. API profilo studente e progressi: Network-First
// Critico avere dati aggiornati, ma fallback su cache se offline
registerRoute(
({ url }) => url.pathname.startsWith('/api/students/') || url.pathname.startsWith('/api/progress'),
new NetworkFirst({
cacheName: 'edtech-api-student-v1',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }), // 24 ore
],
}),
);
// 5. POST di progressi: Background Sync per affidabilità
const bgSyncPlugin = new BackgroundSyncPlugin('edtech-progress-sync', {
maxRetentionTime: 7 * 24 * 60, // Ritenta per 7 giorni (in minuti)
});
registerRoute(
({ url, request }) => url.pathname.startsWith('/api/progress') && request.method === 'POST',
new NetworkOnly({ plugins: [bgSyncPlugin] }),
'POST',
);
// 6. Navigazione SPA: Fallback su index.html se offline
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: 'edtech-shell-v1',
networkTimeoutSeconds: 3,
}),
),
);
3. 지능형 콘텐츠 프리페칭
오프라인 요청에 응답하는 것만으로는 충분하지 않습니다. 우리는 이를 예상해야 합니다. 학생이 해당 과정의 3과를 시청하고 있다면 아마도 다음을 원할 것입니다. 곧 4과를 참조하세요. 백그라운드에서 지능형 프리페칭 다운로드 연결이 가능할 때 학생이 좋아할 만한 콘텐츠, 불필요한 데이터를 소비하지 않고.
// src/offline/prefetch.service.ts
import { Injectable, Inject } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
interface PrefetchConfig {
maxLessonsAhead: number; // Quante lezioni avanzate pre-fetch
maxVideosAhead: number; // Video riservano più spazio
minConnectionType: string; // '4g', '3g' - tipo minimo connessione
maxStorageUsage: number; // MB massimi per pre-fetch
}
@Injectable({ providedIn: 'root' })
export class ContentPrefetchService {
private config: PrefetchConfig = {
maxLessonsAhead: 3,
maxVideosAhead: 1,
minConnectionType: '3g',
maxStorageUsage: 200, // 200 MB
};
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object,
) {}
async prefetchNextLessons(
courseId: string,
currentLessonId: string,
allLessonIds: string[],
): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
// Verifica connessione e storage disponibile
if (!this.shouldPrefetch()) return;
const currentIdx = allLessonIds.indexOf(currentLessonId);
if (currentIdx === -1) return;
const toFetch = allLessonIds
.slice(currentIdx + 1, currentIdx + 1 + this.config.maxLessonsAhead);
for (const lessonId of toFetch) {
await this.prefetchLesson(lessonId).catch(() => {});
}
}
private async prefetchLesson(lessonId: string): Promise<void> {
// Usa cache della fetch API direttamente per il pre-fetching
const cache = await caches.open('edtech-api-courses-v1');
const url = `/api/lessons/${lessonId}`;
const existing = await cache.match(url);
if (existing) return; // Già in cache
try {
const response = await fetch(url);
if (response.ok) {
await cache.put(url, response.clone());
const lesson = await response.json();
if (lesson.videoUrl && this.config.maxVideosAhead > 0) {
await this.prefetchVideo(lesson.videoUrl);
}
}
} catch {} // Silently fail se offline
}
private async prefetchVideo(videoUrl: string): Promise<void> {
const cache = await caches.open('edtech-videos-v1');
const existing = await cache.match(videoUrl);
if (existing) return;
// Pre-fetch solo i primi 5MB del video (segmento iniziale per playback immediato)
const response = await fetch(videoUrl, {
headers: { Range: 'bytes=0-5242880' }, // 5MB
});
if (response.ok || response.status === 206) {
await cache.put(videoUrl, response);
}
}
private shouldPrefetch(): boolean {
const connection = (navigator as any).connection;
if (!connection) return navigator.onLine;
if (connection.saveData) return false; // Rispetta "risparmio dati"
if (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g') return false;
return navigator.onLine;
}
async getStorageUsage(): Promise<{ used: number; quota: number }> {
if (!('storage' in navigator)) return { used: 0, quota: 0 };
const estimate = await navigator.storage.estimate();
return {
used: Math.round((estimate.usage ?? 0) / 1024 / 1024),
quota: Math.round((estimate.quota ?? 0) / 1024 / 1024),
};
}
async clearOldCache(maxAgeDays: number = 7): Promise<void> {
const db = new EdTechDatabase();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
await db.lessons.where('cachedAt').below(cutoff).delete();
await db.courses.where('cachedAt').below(cutoff).delete();
}
}
4. 오프라인 편집을 위한 충돌 해결
학생이 오프라인으로 진행 상황을 변경한 후 다시 로그인하면 서버의 데이터와 충돌이 있을 수 있습니다(예: 교사가 과정 진행 재설정). 우리는 전략을 실행합니다 삼방향 병합: 지역 주, 주를 비교해보자 서버의 연결이 끊어지기 전에 알려진 일반적인 상태입니다.
# server/conflict_resolution.py
from dataclasses import dataclass
from typing import Optional, Dict, Any
from enum import Enum
from datetime import datetime
class ConflictStrategy(Enum):
LOCAL_WINS = "local_wins" # Il progresso locale ha priorità
SERVER_WINS = "server_wins" # Il dato del server ha priorità
MERGE = "merge" # Merge intelligente (prendi il max)
MANUAL = "manual" # Richiedi risoluzione manuale
@dataclass
class ProgressVersion:
progress: float # 0.0 - 1.0
completed: bool
time_spent: int # secondi
answers: Dict[str, Any]
timestamp: datetime
version: int # Numero di versione per optimistic concurrency
@dataclass
class ConflictResult:
strategy_used: ConflictStrategy
resolved_progress: ProgressVersion
conflict_detected: bool
details: str
class ProgressConflictResolver:
"""
Risolve conflitti tra progressi offline e dati del server.
Implementa three-way merge: local, server, base (stato comune).
"""
def resolve(
self,
local: ProgressVersion,
server: ProgressVersion,
base: Optional[ProgressVersion] = None,
) -> ConflictResult:
# Nessun conflitto: stesso timestamp o stessa versione
if local.version == server.version:
return ConflictResult(
strategy_used=ConflictStrategy.MERGE,
resolved_progress=local,
conflict_detected=False,
details="No conflict: versions match",
)
# Strategia: prendi il progresso più alto (non vogliamo mai fare perdere progressi)
if local.progress >= server.progress:
winner = local
strategy = ConflictStrategy.LOCAL_WINS
details = f"Local progress {local.progress:.1%} >= server {server.progress:.1%}"
else:
winner = server
strategy = ConflictStrategy.SERVER_WINS
details = f"Server progress {server.progress:.1%} > local {local.progress:.1%}"
# Merge del tempo trascorso: somma i periodi disgiunti
merged_time = self._merge_time_spent(local, server, base)
# Merge delle risposte ai quiz: unione con preferenza per le più recenti
merged_answers = {**server.answers, **local.answers} if local.timestamp > server.timestamp else {**local.answers, **server.answers}
resolved = ProgressVersion(
progress=winner.progress,
completed=local.completed or server.completed, # Se uno dei due ha completato, completato
time_spent=merged_time,
answers=merged_answers,
timestamp=max(local.timestamp, server.timestamp),
version=max(local.version, server.version) + 1, # Nuova versione
)
return ConflictResult(
strategy_used=strategy,
resolved_progress=resolved,
conflict_detected=True,
details=details,
)
def _merge_time_spent(
self,
local: ProgressVersion,
server: ProgressVersion,
base: Optional[ProgressVersion],
) -> int:
"""Calcola il tempo totale senza doppio conteggio."""
if base is None:
# Senza base: prendi il massimo (evita sovra-conteggio)
return max(local.time_spent, server.time_spent)
# Three-way merge: aggiungi i delta relativi alla base
local_delta = max(0, local.time_spent - base.time_spent)
server_delta = max(0, server.time_spent - base.time_spent)
return base.time_spent + local_delta + server_delta
5. Studio Recall에 대한 푸시 알림
푸시 알림은 학생들에게 다음 사항을 상기시키는 데 강력합니다. 공부 행진. Web Push API는 앱이 종료된 상태에서도 동작하며, 서비스 워커를 통해. 우리는 맞춤형 알림을 기반으로 구현합니다. 학생 행동에 대해.
// src/offline/push-notifications.service.ts
import { Injectable, Inject } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PushNotificationsService {
private vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object,
) {}
async subscribe(studentId: string): Promise<boolean> {
if (!isPlatformBrowser(this.platformId)) return false;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
try {
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return false;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
});
// Salva subscription sul server
await firstValueFrom(this.http.post('/api/push/subscribe', {
studentId,
subscription: subscription.toJSON(),
}));
return true;
} catch (error) {
console.error('Push subscription failed:', error);
return false;
}
}
async unsubscribe(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await firstValueFrom(this.http.delete('/api/push/subscribe'));
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
}
}
피해야 할 안티패턴
- 모든 것을 무차별적으로 캐시합니다. 암호화 및 GDPR 위험 없이 민감한 데이터(투표, 개인 데이터)를 캐싱합니다. 민감한 데이터에는 Credentials API를 사용하거나 캐싱하기 전에 암호화하세요.
- 버전 관리가 없는 서비스 워커: 버전이 지정되지 않은 캐시는 배포 후에도 이전 콘텐츠를 계속 제공합니다. 버전이 지정된 캐시 이름과 cleanupOutdatedCaches()를 사용하세요.
- 2G에서의 공격적인 프리페치: 프리페치는 학생 데이터를 소비합니다. navigator.connection.validType을 확인하고 saveData를 존중하세요.
- 충돌 해결 없음: 병합 전략이 없으면 동기화 시 오프라인 진행 상황이 손실되거나 덮어쓰여집니다. 항상 최소한 "최대한의 진전"을 구현하십시오.
- 동기 작업이 포함된 IndexedDB: IndexedDB는 비동기식입니다. 콜백 지옥을 피하고 오류를 올바르게 처리하려면 Dexie.js를 사용하세요.
- 오프라인에서 테스트되지 않은 서비스 워커: 배포하기 전에 항상 Chrome DevTools > 네트워크 > 오프라인으로 테스트하세요. 오프라인 엣지 케이스는 프로덕션 환경에서 디버그하기 어렵습니다.
결론 및 다음 단계
우리는 모바일 교육 기술 플랫폼을 위한 완전한 오프라인 우선 아키텍처를 구축했습니다. 스토리지/네트워크에서 UI를 분리하는 리포지토리 패턴, 전략이 포함된 서비스 워커 리소스 종류별로 차별화된 캐싱, 이를 존중하는 지능형 프리페칭 연결 유형, 오프라인 진행을 위한 충돌 해결 및 푸시 알림 연구 회상을 위해.
그 결과, 관계없이 모든 학생에게 적합한 플랫폼이 탄생했습니다. 연결 품질: 광섬유에서 간헐적인 2G까지, 통과 신호 없는 지하철. 배움은 결코 멈추지 않습니다.
시리즈의 마지막 기사에서 우리는 콘텐츠 관리 SCORM을 사용한 다중 테넌트: 구조화, 버전화, 배포 방법 수천 개의 조직으로 확장되는 eLearning 콘텐츠 패키지입니다.
EdTech 엔지니어링 시리즈
- 확장 가능한 LMS 아키텍처: 다중 테넌트 패턴
- 적응형 학습 알고리즘: 이론에서 생산까지
- 교육용 비디오 스트리밍: WebRTC, HLS, DASH
- AI 감독 시스템: 컴퓨터 비전을 통한 개인정보 보호 우선
- LLM을 통한 맞춤형 교사: 지식 기반 구축을 위한 RAG
- 게임화 엔진: 아키텍처 및 상태 머신
- 학습 분석: xAPI 및 Kafka를 사용한 데이터 파이프라인
- EdTech의 실시간 협업: CRDT 및 WebSocket
- 모바일 우선 교육 기술: 오프라인 우선 아키텍처(이 문서)
- 다중 테넌트 콘텐츠 관리: 버전 관리 및 SCORM







