확장 가능한 LMS 아키텍처: 다중 테넌트 패턴
글로벌 시장 학습 관리 시스템 (LMS)를 통과했습니다. 280억 달러 2025년에, 2030년까지 약 700억 달러의 성장이 예상됩니다. 다음과 같은 플랫폼 뒤에는 Moodle, Canvas, Docebo 및 Coursera에는 수천 개의 조직에 서비스를 제공하는 엄청난 아키텍처 문제가 숨겨져 있습니다. 동시에 데이터 격리를 보장하고 사용량이 가장 많은 동안 수평으로 확장합니다. 동시에 연결된 수백만 명의 학생에 대해 1초 미만의 대기 시간을 유지합니다.
La 다중 테넌시 그리고 이 모든 것을 가능하게 하는 아키텍처 패턴입니다. 다중 테넌트 LMS에서는 단일 애플리케이션 인스턴스는 각각 자체 사용자가 있는 여러 조직(테넌트)에 서비스를 제공합니다. 강좌, 콘텐츠, 구성을 공유하지만 기본 인프라를 공유합니다. 패턴의 선택 올바른 멀티 테넌시는 운영 비용, 보안, 성능 및 확장 능력을 결정합니다. 시스템을 다시 작성하지 않고도 테넌트를 10명에서 10,000명으로 늘릴 수 있습니다.
이 시리즈의 첫 번째 기사에서 교육기술 엔지니어링, 우리는 심층적으로 분석할 것입니다 LMS 플랫폼을 위한 멀티 테넌트 아키텍처, 데이터 격리 패턴 비교, 실시간 기능을 위한 확장 전략, 최적화된 데이터 모델 및 이벤트 기반 패턴. 각 개념에는 TypeScript 및 Python의 구체적인 코드 예제가 함께 제공됩니다.
이 기사에서 배울 내용
- 세 가지 기본 다중 테넌시 패턴 및 각 패턴을 사용하는 시기
- 수천 명의 테넌트로 확장되는 LMS 데이터 모델을 설계하는 방법
- JWT 및 RBAC를 사용한 다중 테넌트 컨텍스트의 인증 및 권한 부여
- 테넌트 해결을 위한 REST API 및 GraphQL 설계
- 실시간 알림 및 분석을 위한 이벤트 중심 아키텍처
- 엔터프라이즈 LMS 플랫폼을 마이크로서비스로 분해
- Moodle, Canvas, Blackboard 및 최신 플랫폼의 아키텍처 비교
- 트래픽이 많은 LMS를 위한 성능 벤치마크 및 캐싱 전략
EdTech 엔지니어링 시리즈 개요
| # | Articolo | 집중하다 |
|---|---|---|
| 1 | 현재 위치 - 확장 가능한 LMS 아키텍처 | LMS 플랫폼의 다중 테넌트 패턴 |
| 2 | 교육용 비디오 스트리밍 | 비디오 콘텐츠 배포 아키텍처 |
| 3 | 학습 분석 및 xAPI | 학습 데이터 수집 및 분석 |
| 4 | SCORM 및 콘텐츠 패키징 | 교육 콘텐츠 표준 |
| 5 | AI를 이용한 적응형 학습 | 교육 과정의 개인화 |
| 6 | 플랫폼의 게임화 | 배지, 리더보드 및 동기 부여 |
| 7 | 감독 및 평가 | 안전하고 사기 방지 온라인 시험 |
| 8 | 실시간 협업 | 공유 화이트보드 및 공동 편집 |
| 9 | 모바일 및 오프라인 학습 | PWA 및 오프라인 동기화 |
| 10 | 가상 교사로서의 LLM | e-러닝에 언어 모델 통합 |
학습 관리 시스템 환경
최신 LMS는 더 이상 추적 시스템을 갖춘 단순한 코스 저장소가 아닙니다. 플랫폼 오늘의 통합 적응형 비디오 스트리밍, 대화형 평가, 예측 분석, 실시간 협업 그리고 점점 더 자주, 인공지능 기반 튜터. 이러한 기능적 복잡성은 다음과 같이 해석됩니다. 아키텍처의 복잡성에 직접적으로 영향을 미칩니다.
LMS 플랫폼은 배포 모델에 따라 세 가지 매크로 범주로 나뉩니다.
- 자체 호스팅(온프레미스): 무들, 오픈 edX. 조직은 전체 인프라를 관리합니다. 최대 제어, 최대 운영 오버헤드.
- 다중 테넌트 SaaS: 캔버스 클라우드, TalentLMS, Docebo. 판매자가 모든 것을 처리합니다. 각 테넌트에는 논리적으로 격리된 자체 공간이 있습니다.
- PaaS/하이브리드: Blackboard Learn Ultra, Brightspace. 공급업체 관리형 인프라이지만 고급 사용자 정의 옵션과 프라이빗 배포가 가능합니다.
2025~2026년의 지배적인 추세는 건축입니다. 클라우드 기반 다중 테넌트, 온프레미스 솔루션의 7%에 비해 SaaS 플랫폼의 연간 성장률은 34%입니다. 이러한 변화는 운영 비용을 절감하고 출시 기간을 단축해야 하는 필요성에 의해 주도됩니다. 새로운 기능을 제공하고 소규모 엔지니어링 팀으로 점점 더 많은 조직에 서비스를 제공합니다.
다중 테넌트 LMS 플랫폼의 주요 과제
- 데이터 격리: 한 대학의 데이터는 다른 대학에서 절대로 접근할 수 없어야 합니다.
- 시끄러운 이웃: 50,000명의 학생이 있는 테넌트는 소규모 테넌트의 성능을 저하해서는 안 됩니다.
- 사용자 정의: 각 임차인은 서로 다른 브랜딩, 작업 흐름 및 통합을 원합니다.
- 규정 준수: GDPR, FERPA, SOC 2는 거주 및 데이터 처리에 대한 구체적인 보장을 요구합니다.
- 비균일 스케일링: 사용량 피크는 계절적(학기 시작, 시험)이며 임차인에 따라 다릅니다.
멀티 테넌시의 세 가지 패턴
다중 테넌트 패턴의 선택은 LMS 설계에서 가장 중요한 아키텍처 결정입니다. 세 가지 기본 접근 방식이 있으며 각각 격리, 비용 및 복잡성에 대한 프로파일이 다릅니다.
패턴 1: 테넌트당 데이터베이스(사일로 모델)
각 테넌트는 전용 데이터베이스를 받습니다. 이 접근 방식은 최고 수준의 격리를 제공합니다. 데이터가 물리적으로 분리되어 있고 한 테넌트의 성능이 다른 테넌트에 영향을 미치지 않으며 운영 백업/복원은 독립적입니다. 대규모 서비스를 제공하는 플랫폼이 선호하는 패턴입니다. 엄격한 규정 준수 요건을 갖춘 기업입니다.
- 격리: 최고. 데이터의 완전한 물리적 분리.
- 비용: 높은. 각 데이터베이스는 전용 리소스(연결, 스토리지, 백업)를 사용합니다.
- 복잡성: 높은. 스키마 마이그레이션은 각 데이터베이스에서 수행되어야 합니다.
- 확장성: 선형이지만 비싸다. 모든 새로운 테넌트 = 새로운 인프라.
- 이상적인 사용 사례: 대규모 조직이 100개 미만인 기업, FERPA/HIPAA 요구 사항.
패턴 2: 테넌트 계획(브리지 모델)
모든 테넌트는 동일한 데이터베이스 인스턴스를 공유하지만 각각 고유한 스키마(네임스페이스)를 갖습니다. 예를 들어 PostgreSQL에서 각 테넌트는 동일한 테이블을 사용하지만 데이터는 격리된 자체 스키마에서 작동합니다. 이 접근 방식은 격리와 운영 비용의 균형을 유지합니다.
- 격리: 강한. 스키마 수준에서의 논리적 분리. 명시적인 오류 없이는 교차 오염이 불가능합니다.
- 비용: 중간. 단일 DB 클러스터는 모든 테넌트를 지원합니다.
- 복잡성: 평균. 마이그레이션에는 모든 스키마에 대한 반복이 필요하지만 자동화할 수 있습니다.
- 확장성: PostgreSQL 인스턴스당 최대 500~1,000개의 스키마가 가능하며 샤딩이 필요합니다.
- 이상적인 사용 사례: 50~1,000개의 중간 규모 테넌트가 있는 SaaS에는 스키마별 사용자 지정이 필요합니다.
패턴 3: 테넌트 ID를 사용한 공유 스키마(풀 모델)
모든 테넌트가 동일한 테이블을 공유합니다. 기둥으로 단열이 보장됩니다. tenant_id
각 테이블에 존재하며 행 수준 보안 (RLS) 데이터베이스 수준에서. 그리고 패턴
가장 비용 효율적이고 운영 관리가 가장 간단하지만 규율이 필요함
애플리케이션 코드가 엄격합니다.
- 격리: 논리적. 이는 RLS 및 애플리케이션 유효성 검사에 따라 다릅니다. 코드의 버그로 인해 테넌트 간 데이터가 노출될 수 있습니다.
- 비용: 베이스. 단일 데이터베이스 인스턴스는 수천 명의 테넌트에 서비스를 제공합니다.
- 복잡성: 마이그레이션의 경우 낮음(단일 구성표), 보안의 경우 높음(각 쿼리에 테넌트 필터가 포함되어야 함)
- 확장성: 훌륭한. tenant_id 기반 샤딩(PostgreSQL용 Citus)으로 수천 개의 테넌트를 지원합니다.
- 이상적인 사용 사례: 수천 개의 중소 규모 테넌트를 갖춘 대용량 SaaS입니다.
패턴 간 비교
| 표준 | 테넌트용 데이터베이스 | 임차인 제도 | 공유 스키마 + RLS |
|---|---|---|---|
| 데이터 격리 | 물리적(최대) | 논리적(강함) | 논리(RLS) |
| 테넌트당 비용 | 높음($50-200/월) | 중간($10-50/월) | 낮음($1-10/월) |
| 스키마 마이그레이션 | N 실행(DB당 1) | N 실행(구성표당 1개) | 1회 실행 |
| 최대 권장 테넌트 | 50-200 | 500-2,000 | 10,000+ |
| 시끄러운 이웃 위험 | 아무도 | 중간(공유 I/O) | 높음(샤딩으로 완화 가능) |
| FERPA/GDPR 준수 | 훌륭한 | 좋은 | 추가 감사 필요 |
| 운영 복잡성 | 높은 | 평균 | 낮은 |
| 패턴 사용자 정의 | Totale | 체계별 | 제한됨(사용자 정의 필드) |
테넌트 해결: 미들웨어 및 전략
다중 테넌트 시스템에서 각 HTTP 요청은 올바른 테넌트와 연결되어야 합니다. 전에 모든 애플리케이션 로직의 이 프로세스를 임차인 해결, 그리고 첫 번째 아키텍처의 계층이자 보안의 중요한 지점입니다. 해결에 실패하거나 우회되는 경우 전체 고립이 무너집니다.
해결 전략
요청의 테넌트를 식별하는 네 가지 주요 전략은 다음과 같습니다.
- 하위 도메인:
mit.lms-platform.com,stanford.lms-platform.com. 가장 일반적인 방법은 와일드카드 DNS 구성이 필요합니다. - 경로 접두사:
lms-platform.com/tenants/mit/courses. 간단하지만 API 경로를 오염시킵니다. - HTTP 헤더:
X-Tenant-ID: mit-university. 기계 간 API에 유연합니다. - JWT 주장: 테넌트는 인증 토큰으로 인코딩됩니다. 안전하지만 테넌트를 변경하려면 재인증이 필요합니다.
해결 미들웨어 구현(TypeScript/Express)
다음 Express 미들웨어는 하위 도메인을 기본으로, 테넌트 레지스트리에 대한 유효성 검사를 통해 HTTP 헤더를 대체합니다.
import { Request, Response, NextFunction } from 'express';
import { LRUCache } from 'lru-cache';
// Interfaccia tenant immutabile
interface TenantConfig {
readonly id: string;
readonly slug: string;
readonly dbSchema: string;
readonly plan: 'free' | 'pro' | 'enterprise';
readonly region: 'eu-west-1' | 'us-east-1' | 'ap-southeast-1';
readonly features: ReadonlyArray<string>;
readonly maxUsers: number;
readonly isActive: boolean;
}
// Cache per evitare lookup al DB su ogni richiesta
const tenantCache = new LRUCache<string, TenantConfig>({
max: 5000,
ttl: 1000 * 60 * 5, // 5 minuti
});
// Repository per il lookup dei tenant
interface TenantRepository {
findBySlug(slug: string): Promise<TenantConfig | null>;
}
export function createTenantMiddleware(repo: TenantRepository) {
return async (req: Request, res: Response, next: NextFunction) => {
// 1. Estrai slug dal sottodominio
const host = req.hostname;
let tenantSlug = extractSubdomain(host);
// 2. Fallback: header X-Tenant-ID
if (!tenantSlug) {
tenantSlug = req.headers['x-tenant-id'] as string | undefined
?? null;
}
// 3. Validazione: slug obbligatorio
if (!tenantSlug) {
res.status(400).json({
error: 'TENANT_NOT_RESOLVED',
message: 'Unable to determine tenant from request',
});
return;
}
// 4. Lookup con cache
let config = tenantCache.get(tenantSlug) ?? null;
if (!config) {
config = await repo.findBySlug(tenantSlug);
if (config) {
tenantCache.set(tenantSlug, config);
}
}
// 5. Tenant non trovato o disattivato
if (!config || !config.isActive) {
res.status(404).json({
error: 'TENANT_NOT_FOUND',
message: `Tenant '${tenantSlug}' not found or inactive`,
});
return;
}
// 6. Inietta il contesto tenant nella request
(req as any).tenantConfig = config;
(req as any).tenantId = config.id;
next();
};
}
function extractSubdomain(host: string): string | null {
const parts = host.split('.');
// es: "mit.lms-platform.com" -> ["mit", "lms-platform", "com"]
if (parts.length >= 3) {
const subdomain = parts[0];
// Escludi subdomini di sistema
const reserved = ['www', 'api', 'admin', 'status'];
return reserved.includes(subdomain) ? null : subdomain;
}
return null;
}
Python 구현(FastAPI)
FastAPI를 사용하는 경우 다음과 같습니다. 의존성 주입 테넌트 해결을 위해:
from fastapi import Request, HTTPException, Depends
from functools import lru_cache
from dataclasses import dataclass, field
from typing import Optional
import asyncio
@dataclass(frozen=True)
class TenantConfig:
"""Configurazione immutabile del tenant."""
id: str
slug: str
db_schema: str
plan: str # 'free' | 'pro' | 'enterprise'
region: str
features: tuple[str, ...] = field(default_factory=tuple)
max_users: int = 100
is_active: bool = True
class TenantResolver:
"""Risolve il tenant dalla richiesta HTTP."""
def __init__(self, repository):
self._repo = repository
self._cache: dict[str, TenantConfig] = {}
async def resolve(self, request: Request) -> TenantConfig:
# 1. Sottodominio
slug = self._extract_subdomain(request.headers.get("host", ""))
# 2. Fallback: header
if not slug:
slug = request.headers.get("x-tenant-id")
if not slug:
raise HTTPException(
status_code=400,
detail="Unable to determine tenant from request"
)
# 3. Cache lookup
if slug in self._cache:
config = self._cache[slug]
else:
config = await self._repo.find_by_slug(slug)
if config:
self._cache[slug] = config
if not config or not config.is_active:
raise HTTPException(
status_code=404,
detail=f"Tenant '{slug}' not found or inactive"
)
return config
@staticmethod
def _extract_subdomain(host: str) -> Optional[str]:
parts = host.split(".")
if len(parts) >= 3:
subdomain = parts[0]
reserved = {"www", "api", "admin", "status"}
return None if subdomain in reserved else subdomain
return None
# Dependency injection in FastAPI
async def get_tenant(request: Request) -> TenantConfig:
resolver = request.app.state.tenant_resolver
return await resolver.resolve(request)
다중 테넌트 LMS의 데이터 모델
LMS의 데이터 모델은 사용자, 조직, 과정, 모듈, 수업, 평가, 제출, 등록, 진행 상황, 인증서. 맥락에서 다중 테넌트인 경우 각 엔터티는 소유하는 테넌트와 연결되어야 하며 쿼리는 다음과 같아야 합니다. 단일 테넌트(표준) 및 교차 테넌트(관리 보고) 작업 모두에 효율적입니다.
데이터베이스 코어 스키마
다음 SQL 스키마는 "공유 스키마 + 테넌트_ID" 패턴을 사용하는 다중 테넌트 LMS의 핵심을 나타냅니다. PostgreSQL을 다음과 함께 사용하세요. 행 수준 보안 (RLS) 격리를 보장합니다.
-- ============================================
-- TABELLA TENANT (registro delle organizzazioni)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(63) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
plan VARCHAR(20) NOT NULL DEFAULT 'free',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- ============================================
-- TABELLA UTENTI
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student',
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- ============================================
-- TABELLA CORSI
-- ============================================
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
instructor_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(500) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
max_enrollment INTEGER,
settings JSONB NOT NULL DEFAULT '{}',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- ============================================
-- MODULI E LEZIONI (struttura gerarchica)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content_type VARCHAR(30) NOT NULL, -- 'video', 'text', 'quiz', 'assignment'
content_ref TEXT, -- URL o ID del contenuto
duration_min INTEGER,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- ISCRIZIONI (enrollments)
-- ============================================
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
course_id UUID NOT NULL REFERENCES courses(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, course_id)
);
-- ============================================
-- PROGRESSI (lesson completion tracking)
-- ============================================
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
lesson_id UUID NOT NULL REFERENCES lessons(id),
status VARCHAR(20) NOT NULL DEFAULT 'not_started',
progress_pct SMALLINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, lesson_id)
);
-- ============================================
-- ASSESSMENT E SUBMISSION
-- ============================================
CREATE TABLE assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id),
title VARCHAR(500) NOT NULL,
assessment_type VARCHAR(30) NOT NULL, -- 'quiz', 'exam', 'assignment', 'peer_review'
max_score DECIMAL(6,2) NOT NULL DEFAULT 100,
passing_score DECIMAL(6,2) NOT NULL DEFAULT 60,
time_limit_min INTEGER,
max_attempts INTEGER DEFAULT 1,
questions JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
assessment_id UUID NOT NULL REFERENCES assessments(id),
user_id UUID NOT NULL REFERENCES users(id),
attempt_number SMALLINT NOT NULL DEFAULT 1,
answers JSONB NOT NULL DEFAULT '{}',
score DECIMAL(6,2),
status VARCHAR(20) NOT NULL DEFAULT 'submitted',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
graded_at TIMESTAMPTZ
);
-- ============================================
-- ROW-LEVEL SECURITY (RLS)
-- ============================================
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
-- Policy: ogni utente vede solo i dati del proprio tenant
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_courses ON courses
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_enrollments ON enrollments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- INDICI per performance multi-tenant
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_courses_tenant ON courses(tenant_id);
CREATE INDEX idx_enrollments_tenant_user ON enrollments(tenant_id, user_id);
CREATE INDEX idx_enrollments_tenant_course ON enrollments(tenant_id, course_id);
CREATE INDEX idx_lesson_progress_tenant_user ON lesson_progress(tenant_id, user_id);
CREATE INDEX idx_submissions_tenant_assessment ON submissions(tenant_id, assessment_id);
주의: 테넌트 ID가 있는 다중 열 인덱스
공유 스키마 LMS에서는 각 인덱스에는 다음이 포함되어야 합니다. tenant_id 첫 번째 열로.
에 대한 색인 (email) 테넌트가 필터링한 쿼리에는 쓸모가 없습니다. 도움이 된다 (tenant_id, email).
분산 Citus/샤딩을 사용하면 배포 열(tenant_id)가 있어야 합니다.
모든 복합 기본 키와 모든 UNIQUE 제약 조건에서.
관계 다이어그램
| 실재 | 관계 | 연결된 엔터티 | 추기경 |
|---|---|---|---|
| 거주자 | 소유하다 | 사용자, 강좌 | 1:아니오 |
| 사용자 | 당신은 가입 | 과정(등록을 통해) | N:M |
| 강의 | 포함 | 모듈 | 1:아니오 |
| 기준 치수 | 포함 | 수업 | 1:아니오 |
| 강의 | ha | 평가 | 1:아니오 |
| 사용자 | 제출하다 | 제출물(평가를 통해) | 1:아니오 |
| 사용자 | 진행 상황을 추적하다 | 수업 진행(수업을 통해) | 1:아니오 |
다중 테넌트 인증 및 승인
다중 테넌트 LMS에서는 인증을 해결해야 합니다. 동시에 두 가지 문제: 사용자의 신원을 확인하고 사용자가 속한 테넌트를 결정합니다. 권한이 추가됩니다. 세 번째 수준: 사용자가 테넌트 내에서 액세스할 수 있는 리소스입니다.
청구 테넌트가 있는 JWT
가장 널리 사용되는 접근 방식은 JSON 웹 토큰 (JWT) 테넌트에 대한 전용 청구가 있습니다. 토큰은 인증 후 발급되며, 해결에 필요한 모든 정보를 담고 있습니다. 데이터베이스를 더 이상 조회하지 않고 컨텍스트를 확인합니다.
import jwt from 'jsonwebtoken';
// Payload JWT immutabile
interface LmsTenantJwtPayload {
readonly sub: string; // User ID
readonly tenantId: string; // Tenant UUID
readonly tenantSlug: string; // Per display e logging
readonly role: 'student' | 'instructor' | 'admin' | 'super_admin';
readonly permissions: ReadonlyArray<string>;
readonly iat: number;
readonly exp: number;
}
// Generazione token con contesto tenant
function generateTenantToken(
user: { id: string; role: string },
tenant: { id: string; slug: string },
permissions: ReadonlyArray<string>
): string {
const payload: Omit<LmsTenantJwtPayload, 'iat' | 'exp'> = {
sub: user.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
role: user.role as LmsTenantJwtPayload['role'],
permissions,
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h',
issuer: 'lms-platform',
audience: tenant.slug,
});
}
// Middleware di validazione
function validateTenantToken(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'MISSING_TOKEN' });
}
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'lms-platform',
}) as LmsTenantJwtPayload;
// Verifica coerenza tenant tra URL e token
const urlTenantId = (req as any).tenantId;
if (urlTenantId && decoded.tenantId !== urlTenantId) {
return res.status(403).json({
error: 'TENANT_MISMATCH',
message: 'Token tenant does not match request tenant',
});
}
(req as any).user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
}
LMS용 RBAC 모델
LMS의 승인은 일반적으로 템플릿을 따릅니다. 역할 기반 액세스 제어 (RBAC) 네 가지 주요 역할이 있으며 각각 누적 권한이 있습니다.
| 역할 | 주요 권한 | 빗자루 |
|---|---|---|
| 학생 | 등록된 강좌 보기, 과제 제출, 성적 보기 | 본인이 등록한 강좌만 |
| 강사 | 강좌 생성/편집, 제출물 평가, 강좌 분석 보기 | 나만의 강좌만 |
| 관리자 | 사용자 관리, 테넌트 구성, 모든 강좌 보기, 보고서 내보내기 | 전체 임차인 |
| 최고 관리자 | 테넌트 관리, 청구, 기능 플래그, 테넌트 간 지원 | 모든 테넌트(플랫폼 운영자) |
다중 테넌트 LMS를 위한 API 설계
다중 테넌트 LMS의 API는 균형을 유지해야 합니다. 사용의 단순성 개발자를 위한 클라이언트(프런트엔드, 모바일, 통합) 철저한 보안 데이터 격리 중. REST와 GraphQL 사이의 선택은 주요 액세스 패턴에 따라 달라집니다.
REST API: 다중 테넌트에 대한 규칙
LMS용 REST 아키텍처에서 테넌트는 미들웨어(하위 도메인 또는 헤더)에 의해 확인됩니다. 따라서 리소스 경로에는 테넌트 ID가 포함되지 않습니다. 이는 API를 단순화하고 환경 간 이식 가능.
# Tenant risolto via sottodominio: mit.lms-api.com
# Il tenant_id e iniettato dal middleware, NON presente nei path
# === CORSI ===
GET /api/v1/courses # Lista corsi del tenant
GET /api/v1/courses/:courseId # Dettaglio corso
POST /api/v1/courses # Crea corso (instructor+)
PUT /api/v1/courses/:courseId # Aggiorna corso
DELETE /api/v1/courses/:courseId # Archivia corso (admin+)
# === ISCRIZIONI ===
POST /api/v1/courses/:courseId/enroll # Iscrivi utente corrente
GET /api/v1/enrollments # Le mie iscrizioni
GET /api/v1/courses/:courseId/students # Studenti iscritti (instructor+)
# === CONTENUTI ===
GET /api/v1/courses/:courseId/modules # Moduli del corso
GET /api/v1/modules/:moduleId/lessons # Lezioni del modulo
PUT /api/v1/lessons/:lessonId/progress # Aggiorna progresso
# === ASSESSMENT ===
GET /api/v1/courses/:courseId/assessments # Assessment del corso
POST /api/v1/assessments/:assessmentId/submissions # Sottometti risposta
GET /api/v1/submissions/:submissionId # Dettaglio submission
# === ANALYTICS (instructor/admin) ===
GET /api/v1/analytics/courses/:courseId/completion # Tassi completamento
GET /api/v1/analytics/tenant/overview # Overview tenant
GraphQL: 프런트엔드를 위한 유연한 쿼리
GraphQL은 페이지에서 자주 필요하므로 LMS 클라이언트에 특히 효과적입니다. 복잡한 집계: 등록된 과정, 진행 상황, 예정된 과정에 대한 학생 대시보드 단일 요청으로 할당 및 알림을 받을 수 있습니다.
# Il contesto tenant e iniettato automaticamente dal middleware
# Ogni resolver filtra per tenant_id dal contesto
type Query {
# Corsi disponibili per il tenant corrente
courses(
status: CourseStatus
search: String
pagination: PaginationInput
): CourseConnection!
# Dettaglio corso (solo se nel tenant corrente)
course(id: ID!): Course
# Dashboard studente: dati aggregati in una singola query
myDashboard: StudentDashboard!
# Analytics per instructor
courseAnalytics(courseId: ID!): CourseAnalytics!
}
type Course {
id: ID!
title: String!
description: String
instructor: User!
modules: [Module!]!
enrollmentCount: Int!
completionRate: Float
status: CourseStatus!
createdAt: DateTime!
}
type StudentDashboard {
enrolledCourses: [EnrolledCourseProgress!]!
upcomingAssessments: [Assessment!]!
recentActivity: [ActivityEvent!]!
overallProgress: Float!
certificatesEarned: Int!
}
type EnrolledCourseProgress {
course: Course!
progressPercent: Float!
lastAccessedAt: DateTime
nextLesson: Lesson
}
type Mutation {
enrollInCourse(courseId: ID!): Enrollment!
updateLessonProgress(lessonId: ID!, progress: Int!): LessonProgress!
submitAssessment(assessmentId: ID!, answers: JSON!): Submission!
createCourse(input: CreateCourseInput!): Course!
}
type Subscription {
# Notifiche real-time per lo studente
studentNotifications: Notification!
# Aggiornamenti live del progresso (per instructor dashboard)
courseProgressUpdates(courseId: ID!): ProgressUpdate!
}
콘텐츠 전달 및 미디어 스트리밍
LMS는 PDF 문서, 프레젠테이션, 주문형 비디오, 라이브 스트리밍, 대화형 퀴즈 및 가상 워크숍. 콘텐츠 전달 아키텍처는 다음을 보장해야 합니다. 낮은 대기 시간, 테넌트별 격리 e 접근 제어 세분화 된.
다중 테넌트 스토리지 전략
스토리지 패턴의 선택은 데이터베이스의 패턴을 반영합니다. 즉, 격리가 많을수록 비용이 더 많이 듭니다. SaaS LMS에 대한 가장 일반적인 접근 방식은 단일 S3 버킷 접두사 기반 격리 사용:
# Pattern di organizzazione S3 per LMS multi-tenant
# Bucket: lms-content-production
# Contenuti del corso
s3://lms-content/{tenant_id}/courses/{course_id}/videos/lecture-01.mp4
s3://lms-content/{tenant_id}/courses/{course_id}/documents/syllabus.pdf
s3://lms-content/{tenant_id}/courses/{course_id}/images/cover.webp
# Submission degli studenti
s3://lms-content/{tenant_id}/submissions/{assessment_id}/{user_id}/file.pdf
# Asset del tenant (logo, branding)
s3://lms-content/{tenant_id}/branding/logo.svg
s3://lms-content/{tenant_id}/branding/theme.json
# Bucket policy limita l'accesso per prefix = tenant_id
# Signed URLs con expiry di 1 ora per accesso ai contenuti
교육용 비디오를 위한 CDN 아키텍처
비디오는 최신 LMS 트래픽의 70-80%를 차지합니다. 전달 아키텍처 비디오에는 다음이 필요합니다:
- 적응형 트랜스코딩: 각 비디오는 적응형 비트 전송률 스트리밍을 위해 HLS/DASH를 사용하여 다양한 해상도(360p, 720p, 1080p)로 인코딩됩니다.
- 토큰 인증을 사용하는 CDN: 유료 콘텐츠에 대한 무단 액세스를 방지하기 위해 서명된 URL이 포함된 CloudFront 또는 Cloudflare.
- 테넌트당 에지 캐싱: TTFB를 줄이기 위해 대규모 테넌트의 콘텐츠가 PoP 에지에 사전 로드됩니다.
- 동적 워터마킹: 콘텐츠 유출을 추적하기 위한 사용자 ID가 포함된 보이지 않는 오버레이.
비디오 파이프라인 아키텍처
강사가 업로드한 비디오의 전체 흐름:
- 업로드: S3에 직접 업로드하기 위한 미리 서명된 URL(애플리케이션 서버 우회)
- S3 이벤트: Lambda/클라우드 함수 트리거
s3:ObjectCreated - 트랜스코딩: 다중 비트 전송률 HLS를 생성하기 위한 ECS/Cloud Run의 AWS MediaConvert 또는 FFmpeg
- 메타데이터: LMS 데이터베이스에 기록된 기간, 썸네일, 크기
- CDN 무효화: 소유한 테넌트를 위한 캐시 워밍
- 공고: WebSocket을 통해 강사에게 알리기 위해 게시된 이벤트
실시간 기능을 위한 이벤트 중심 아키텍처
최신 LMS에는 많은 비동기식 및 실시간 기능이 필요합니다. 투표 게시, 실시간 순위 업데이트, 대시보드에서 실시간 진행 상황 추적 강사, 다가오는 마감일에 대한 자동 알림. 이러한 요구에는 에이벤트 중심 아키텍처.
LMS용 도메인 이벤트
첫 번째 단계는 중요한 플랫폼 도메인 이벤트를 식별하는 것입니다. 모든 이벤트는 시스템에서 발생한 일을 설명하는 불변의 사실입니다.
// Evento base immutabile
interface LmsDomainEvent {
readonly eventId: string;
readonly eventType: string;
readonly tenantId: string;
readonly userId: string;
readonly timestamp: string; // ISO 8601
readonly version: number; // Schema versioning
}
// === ENROLLMENT EVENTS ===
interface StudentEnrolledEvent extends LmsDomainEvent {
readonly eventType: 'student.enrolled';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly enrollmentId: string;
};
}
// === PROGRESS EVENTS ===
interface LessonCompletedEvent extends LmsDomainEvent {
readonly eventType: 'lesson.completed';
readonly payload: {
readonly courseId: string;
readonly moduleId: string;
readonly lessonId: string;
readonly progressPercent: number; // 0-100 per il corso
};
}
// === ASSESSMENT EVENTS ===
interface AssessmentSubmittedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.submitted';
readonly payload: {
readonly assessmentId: string;
readonly submissionId: string;
readonly courseId: string;
readonly attemptNumber: number;
};
}
interface AssessmentGradedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.graded';
readonly payload: {
readonly submissionId: string;
readonly assessmentId: string;
readonly score: number;
readonly maxScore: number;
readonly passed: boolean;
};
}
// === COURSE LIFECYCLE EVENTS ===
interface CoursePublishedEvent extends LmsDomainEvent {
readonly eventType: 'course.published';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly instructorId: string;
readonly moduleCount: number;
readonly lessonCount: number;
};
}
// Event bus con partizionamento per tenant
class LmsEventBus {
constructor(private readonly kafka: KafkaProducer) {}
async publish(event: LmsDomainEvent): Promise<void> {
await this.kafka.send({
topic: `lms.events.${event.eventType.split('.')[0]}`,
messages: [{
key: event.tenantId, // Partizionamento per tenant
value: JSON.stringify(event),
headers: {
'event-type': event.eventType,
'tenant-id': event.tenantId,
'event-version': String(event.version),
},
}],
});
}
}
소비자 및 예측
이벤트는 부작용(알림, 알림, 구체화된 뷰, 분석, 이메일 일정 업데이트. 패턴 도메인당 소비자 응집력을 유지합니다.
| 이벤트 | 소비자 | 행동 |
|---|---|---|
student.enrolled | 알림 서비스 | 환영 이메일 + 푸시 알림 |
student.enrolled | Analytics서비스 | 수강신청 카운터 업데이트 |
lesson.completed | ProgressService | 전체 과정 진행 상황을 다시 계산합니다. |
lesson.completed | 게이미피케이션 서비스 | 배지 잠금 해제 여부 확인 |
assessment.graded | 알림 서비스 | 학생에게 성적을 알리다 |
assessment.graded | 인증서 서비스 | 과정이 완료되면 인증서 생성 |
course.published | 검색인덱스서비스 | 카탈로그 Elasticsearch 색인 업데이트 |
course.published | 알림 서비스 | 등록된 학생을 교사에게 알립니다. |
마이크로서비스로 분해
100,000명 이상의 동시 사용자를 지원하는 엔터프라이즈 LMS 플랫폼의 경우 모놀리스는 다음과 같습니다. 병목 현상. 마이크로서비스로 분해하면 독립적으로 확장 가능 가장 스트레스를 많이 받는 구성 요소. 핵심은 다음을 식별하는 것입니다. 제한된 컨텍스트 맞다 도메인 중심 디자인의 원칙을 따릅니다.
LMS의 제한된 컨텍스트
| 마이크로서비스 | 책임 | 데이터베이스 | 규약 |
|---|---|---|---|
| 테넌트 서비스 | 테넌트 등록, 구성, 청구, 기능 플래그 | 전용 PostgreSQL | REST + gRPC |
| 신원 서비스 | 인증, SSO(SAML/OIDC), 사용자 관리, RBAC | PostgreSQL + Redis(세션) | REST + OAuth 2.0 |
| 코스 서비스 | CRUD 과정, 모듈, 강의, 카탈로그, 검색 | PostgreSQL + 엘라스틱서치 | REST + GraphQL |
| 등록 서비스 | 등록, 진행, 완료, 인증서 | PostgreSQL + Redis(진행 캐시) | REST + Kafka 이벤트 |
| 평가 서비스 | 퀴즈 엔진, 채점, 기준표, 동료 검토 | PostgreSQL + S3(파일 제출) | REST + Kafka 이벤트 |
| 콘텐츠 서비스 | 업로드, 트랜스코딩, 스토리지, CDN, SCORM 패키징 | S3 + DynamoDB(메타데이터) | REST + S3 이벤트 |
| 알림 서비스 | 이메일, 푸시, 인앱, WebSocket, 다이제스트 예약 | 레디스 + SQS | Kafka + WebSocket 이벤트 |
| 분석 서비스 | xAPI/Caliper, 대시보드, 보고서, 데이터 내보내기 | ClickHouse + S3(데이터 레이크) | Kafka + REST 이벤트 |
서비스 간 통신
의사소통은 두 가지 기본 패턴을 따릅니다.
- 동기식(REST/gRPC): 즉각적인 대응이 필요한 작업에 적합합니다. 예: 코스 서비스는 코스를 생성하기 전에 ID 서비스를 호출하여 사용자의 권한을 확인합니다.
- 비동기식(카프카): 즉각적인 응답이 필요하지 않은 작업의 경우. 예: 학생이 수업을 마치면 알림, 분석, 게임화와 별도로 이벤트가 게시되고 소비됩니다.
황금률: API 호출보다 이벤트를 선호하세요
작업에 동기 응답이 필요하지 않은 경우 항상 이벤트를 사용하세요. 이는 서비스 간의 결합을 줄이고 탄력성을 향상시킵니다(소비자 실패 생성자를 차단하지 않음) 디버깅 및 복구를 위한 이벤트 재생을 활성화합니다. LMS에서 이상적인 비율은 약 70% 이벤트/30% 동기 호출입니다.
확장성: 캐싱, 샤딩 및 CDN
엔터프라이즈 LMS는 대학 학기 첫날, 수만 명의 학생이 동시에 로그인합니다. 온라인 시험 플랫폼 시험 세션 동안 10배의 부하를 겪습니다. 잘 설계된 확장 전략이 없으면 시스템은 가장 필요할 때 정확하게 붕괴됩니다.
다단계 캐싱 전략
| 수준 | 기술 | 카차티 데이터 | TTL |
|---|---|---|---|
| L1: 브라우저 | 서비스 워커 + 캐시 API | 정적 자산, 강의 콘텐츠(오프라인) | 24시간(버전 관리를 통한 무효화) |
| L2: CDN 엣지 | CloudFront / Cloudflare | HLS 비디오, 이미지, CSS/JS, PDF 문서 | 7일(게시 시 무효화) |
| L3: API 게이트웨이 | 레디스/바니시 | GET API 응답(강좌 카탈로그, 사용자 프로필) | 5~15분 |
| L4: 응용 | 레디스 클러스터 | 세션, 테넌트 구성, 학생 진행 상황, RBAC 권한 | 5~30분 |
| L5: 데이터베이스 | PostgreSQL 구체화된 뷰 | 집계 분석, 리더보드, 코스 통계 | 5~60분마다 새로 고침 |
PostgreSQL용 Citus를 사용한 샤딩
수백만 명의 사용자와 "공유 스키마" 패턴을 가진 LMS의 경우, 시트러스 (PostgreSQL 확장
분산 컴퓨팅용)을 사용하면 SQL 호환성을 유지하면서 여러 노드에 데이터를 배포할 수 있습니다.
다중 테넌트 LMS를 위한 자연스러운 샤딩 전략과 tenant_id.
-- Distribuire le tabelle principali per tenant_id
SELECT create_distributed_table('users', 'tenant_id');
SELECT create_distributed_table('courses', 'tenant_id');
SELECT create_distributed_table('enrollments', 'tenant_id');
SELECT create_distributed_table('lesson_progress', 'tenant_id');
SELECT create_distributed_table('submissions', 'tenant_id');
-- Tabelle di riferimento (replicate su ogni nodo)
SELECT create_reference_table('tenants');
-- Query automaticamente distribuite
-- Citus instrada le query al nodo corretto basandosi su tenant_id
SELECT c.title, COUNT(e.id) AS enrolled_students
FROM courses c
JOIN enrollments e ON c.id = e.course_id AND c.tenant_id = e.tenant_id
WHERE c.tenant_id = 'uuid-tenant-mit'
GROUP BY c.title
ORDER BY enrolled_students DESC;
-- Performance: query single-tenant eseguita su un singolo nodo
-- Nessun shuffle di dati tra nodi = latenza minima
성능 벤치마크
다음 벤치마크는 5,000명의 테넌트와 200만 명의 총 사용자가 있는 LMS를 기반으로 합니다.
PostgreSQL 16 + Citus, 노드 3개를 사용하여 AWS에 배포 r6g.2xlarge:
| 작업 | Citus 없음(단일 노드) | Citus 사용(노드 3개) | 개선 |
|---|---|---|---|
| 강좌 목록(테넌트별) | 45ms | 12ms | 3.7배 |
| 학생 대시보드 | 180ms | 35ms | 5.1배 |
| 코스 분석(집계) | 2,400ms | 320ms | 7.5배 |
| 등록 폭주(동시 1,000명) | 시간 초과(>5초) | 89ms (p99) | >56배 |
| 테넌트 간 보고(관리자) | 12,000ms | 1,800ms | 6.7배 |
아키텍처 비교: 실제 LMS 플랫폼
이론적 패턴이 실제 구현으로 어떻게 변환되는지 이해하기 위해 다음을 분석해 보겠습니다. 시장에 나와 있는 주요 LMS 플랫폼의 아키텍처.
| 플랫폼 | 건축학 | 다중 테넌시 | 기술 스택 | 확장성 |
|---|---|---|---|---|
| 무들 | 모듈형 단일체 | 테넌트별(MoodleCloud) 또는 단일 테넌트(자체 호스팅) 데이터베이스 | PHP 8, MySQL/PostgreSQL, 크론 작업 | 수직, 과도한 조정 없이 인스턴스당 최대 10,000명의 사용자로 제한됨 |
| 캔버스 LMS | 위성 서비스를 갖춘 모노리스 | 기관별로 샤드가 있는 공유 스키마(Switchman gem) | 루비 온 레일즈, PostgreSQL, Redis, S3, Consul | 샤딩을 통한 수평적, 수백만 명의 사용자에게 서비스 제공 |
| Blackboard Learn Ultra | 마이크로서비스(Java 모놀리스에서 마이그레이션) | 하이브리드: 테넌트별 데이터베이스(기업), 공유 스키마(SaaS) | Java/Spring Boot, AWS, 마이크로서비스, Kafka | 클라우드 기반, 서비스별 자동 확장 |
| edX 열기 | 서비스 지향(Django 서비스) | 조직(튜터)당 인스턴스 또는 다중 사이트 | Python/Django, MySQL, MongoDB, 셀러리, RabbitMQ | Kubernetes 기반, 서비스별 확장 |
| 도세보 | 클라우드 네이티브 SaaS | tenant_id + RLS를 사용한 공유 스키마 | 독점(Node.js/Go로 가정), AWS, AI 기반 | 3,800개 이상의 기업 고객에게 서비스를 제공하는 자동 확장 |
| 코세라 | 이벤트 중심 마이크로서비스 | 파트너별 샤딩을 갖춘 기본 다중 테넌트 | 스칼라, 파이썬, 카산드라, 카프카, 쿠버네티스 | 글로벌, 1억 3천만 명 이상의 사용자, 다중 지역 |
실제 플랫폼에서 얻은 교훈
- 캔버스: Switchman gem을 통한 샤딩은 잘 설계된 모놀리스가 마이크로서비스를 다시 작성할 필요 없이 수백만 명의 사용자로 확장될 수 있음을 보여줍니다.
- 칠판: Java 모놀리스에서 마이크로서비스로 마이그레이션하는 데는 4년 이상이 걸렸으며 상당한 투자가 필요했습니다. 교훈: 일찍 분해되거나 전혀 분해되지 않습니다.
- 강좌: Kafka를 백본으로 사용하는 이벤트 기반 아키텍처를 사용하면 기존 서비스를 수정하지 않고도 새로운 기능(AI 추천, 분석)을 추가할 수 있습니다.
- edX를 엽니다: Django 다중 서비스 접근 방식은 "모놀리식" 프레임워크를 사용하더라도 제한된 컨텍스트를 효과적으로 분리할 수 있음을 보여줍니다.
아키텍처 선택 가이드
보편적으로 가장 좋은 패턴은 없습니다. 선택은 제품 프로필에 따라 달라집니다. 예상되는 테넌트 수와 사용 가능한 엔지니어링 팀에 따라 결정됩니다. 실용적인 의사결정 트리는 다음과 같습니다.
의사결정 트리
- 2년 안에 몇 명의 임차인을 예상하십니까?
- 50개 미만, 맞춤형 계약을 맺은 모든 기업 → 테넌트당 데이터베이스
- 50-2,000, 다양한 사이즈 → 테넌트당 스키마
- 2,000개 이상, 대부분 SMB → 공유 스키마 + RLS
- 규정 준수는 얼마나 중요합니까?
- 엄격한 감사 추적을 갖춘 FERPA/HIPAA → 물리적 격리 선호(테넌트별 DB)
- 표준 GDPR → 적절한 감사를 통한 테넌트 또는 RLS 체계
- 엔지니어링 팀의 규모는 얼마나 됩니까?
- 3~5명의 개발자 → 공유 스키마(운영 오버헤드 감소)
- 10~20명의 개발자 → 테넌트 또는 경량 마이크로서비스 스키마
- 20명 이상의 개발자 → 팀 소유권으로 마이크로서비스 완성
- 마이크로서비스 또는 모놀리스?
- 동시 사용자 50,000명 미만 → 모듈형 단일체(캔버스)
- 50K-500K 동시 사용자 → 미디어 및 분석을 위한 모놀리스 + 위성 서비스
- 동시 사용자 50만 명 이상 → 이벤트 버스로 마이크로서비스 완성
결론 및 다음 단계
확장 가능한 다중 테넌트 LMS를 설계하려면 영향을 미칠 아키텍처 결정이 필요합니다. 수년 동안 제품의 성장 능력, 데이터 보안 및 운영 비용에 대해 연구했습니다. 단일 솔루션은 없습니다. 가장 성공적인 플랫폼은 아키텍처를 발전시켰습니다. 시간이 지남에 따라 공유된 패턴을 가진 단일체에서 시작하여 점차적으로 분해되는 경우가 많습니다. 가장 중요한 서비스.
이 글의 핵심 포인트:
- 고객 프로필을 기반으로 다중 테넌트 패턴을 선택하세요., 기술적 패션이 아닙니다. 공유 스키마 + RLS는 대부분의 SaaS LMS에 충분합니다.
- 임차인 해결 및 보안의 기초. 캐싱 및 엄격한 검증을 통해 강력한 미들웨어로 배포하세요.
- PostgreSQL 행 수준 보안이 최고의 동맹입니다 과도한 애플리케이션 복잡성 없이 데이터베이스 수준 격리를 제공합니다.
- 이벤트 중심 아키텍처는 필수 불가결합니다. 알림, 분석 및 최신 LMS의 모든 실시간 기능을 제공합니다.
- PostgreSQL용 Citus 단일 노드가 더 이상 충분하지 않을 때 애플리케이션 변경을 최소화하면서 수평으로 확장할 수 있습니다.
- 모듈식 모놀리스로 시작하기 규모나 팀 조직에서 필요할 때만 마이크로서비스로 분해합니다.
시리즈의 다음 기사에서는교육용 비디오 스트리밍 아키텍처, 적응형 트랜스코딩, 보호된 콘텐츠를 위한 DRM, 라이브 수업을 위한 매우 짧은 대기 시간을 해결합니다. 비디오 인프라는 LMS에서 가장 비용이 많이 들고 복잡한 구성 요소인 경우가 많으므로 분석할 가치가 있습니다. 철저한.
자세히 알아볼 수 있는 리소스
- 크런치 데이터 블로그: "멀티 테넌시를 위한 Postgres 데이터베이스 설계" - PostgreSQL을 사용한 멀티 테넌시에 대한 실무 가이드
- Citus 문서: 실제 샤딩 예제가 포함된 다중 테넌트 SaaS 튜토리얼
- 캔버스 LMS(GitHub): Switchman for Rails + PostgreSQL 샤딩을 사용한 오픈 소스 코드
- 개방형 edX 아키텍처: Open edX 서비스 아키텍처의 공식 문서
- Microsoft Azure 다중 테넌트 패턴: 멀티 테넌트 클라우드 SaaS를 위한 설계자 가이드







