2026년 Supabase와 Next.js로 헤드리스 LMS 구축하기
지난 2년간 세 개의 LMS 플랫폼을 구축했습니다. 그 중 두 개는 결국 버린 기성 솔루션을 사용했습니다. 세 번째 -- 실제로 작동한 그것 -- Next.js와 Supabase로 구축한 헤드리스 아키텍처였습니다. 이것이 여러분이 구축하는 방법을 안내할 것입니다.
헤드리스 LMS의 핵심은 간단합니다. Teachable, Thinkific, 또는 Moodle 같은 상용 플랫폼은 콘텐츠 모델, 프런트엔드, 인증 시스템, 결제 흐름이 모두 함께 용접된 모놀리식 경험을 제공합니다. 역할 기반 콘텐츠 제어, AI 기반 퀴즈 생성, 화이트라벨 멀티테넌트 배포 같은 맞춤 기능이 필요한 순간, 기능을 구축하는 대신 플랫폼과 싸우게 됩니다.
Supabase가 데이터베이스, 인증, 스토리지, 실시간 구독을 처리하고 Next.js가 렌더링과 API 레이어를 처리하면, 진정으로 여러분의 것인 시스템을 얻게 됩니다. 콘텐츠 측 공급업체 종속성 없음. 학습자 경험에 대한 완전한 제어. 서버 컴포넌트에서 HTTP 왕복 없이 직접 쿼리할 수 있는 Postgres 데이터베이스.
구축해봅시다.
목차
- LMS를 헤드리스로 사용해야 하는 이유
- 아키텍처 개요
- 데이터베이스 스키마 설계
- 역할 기반 접근 제어로 Supabase 인증 설정
- 멀티테넌트 콘텐츠용 행 수준 보안
- Next.js App Router 프런트엔드 구축
- 코스 콘텐츠 렌더링 및 진행률 추적
- 실시간 기능: 토론 및 알림
- Supabase Storage를 통한 비디오 및 파일 스토리지
- 배포 및 성능 고려 사항
- FAQ

LMS를 헤드리스로 사용해야 하는 이유
기존 LMS 플랫폼은 현대 웹을 위해 구축되지 않았습니다. 규정 준수 확인 상자를 원하는 IT 부서를 위해 구축되었습니다. 사용자 경험이 이를 반영합니다.
헤드리스 LMS는 콘텐츠 관리와 비즈니스 로직을 프레젠테이션 레이어에서 분리합니다. 이는 다음을 의미합니다.
- 프런트엔드는 무엇이든 될 수 있습니다. Next.js 웹 앱, React Native 모바일 앱, 마이크로 레슨을 제공하는 Slack 봇. 동일한 API가 모두 서빙합니다.
- 콘텐츠 모델은 여러분의 것입니다. 어떤 것이 "코스"인지 정의합니다. 아마도 마크다운 레슨의 시리즈일 것입니다. 아마도 포함된 대화형 연습이 있는 비디오 모듈의 모음일 것입니다. Moodle이 생각하는 코스의 범위에 제한되지 않습니다.
- 데이터를 제어합니다. 학습자 진행률, 평가 결과, 참여도 지표 -- 모두 Postgres 데이터베이스에 있습니다. 공급업체 대시보드에서 CSV를 내보낼 필요가 없습니다.
트레이드오프는 명백합니다. 더 많이 직접 구축해야 합니다. 하지만 Supabase가 인증, 스토리지, 실시간 및 데이터베이스 레이어를 처리하면, 실제로 작성하는 맞춤 코드의 양은 놀랍도록 적습니다.
아키텍처 개요
우리가 구축하고 있는 것의 높은 수준의 아키텍처입니다.
┌─────────────────────────────────────────────┐
│ Next.js 15 │
│ (App Router + RSC) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Server │ │ Server │ │ Client │ │
│ │Components│ │ Actions │ │Components │ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ Supabase SSR Client │
└──────────────────────┬───────────────────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌────▼────┐ ┌─────▼────┐ ┌────▼─────┐
│Supabase │ │Supabase │ │Supabase │
│ Auth │ │ Database │ │ Storage │
│ │ │(Postgres)│ │ (S3) │
└─────────┘ └──────────┘ └──────────┘
│
┌─────▼─────┐
│ Realtime │
│ (WebSocket)│
└───────────┘
주요 아키텍처 결정:
| 결정 | 선택 | 근거 |
|---|---|---|
| 렌더링 | 서버 컴포넌트 + ISR | 코스 카탈로그 페이지는 대부분 정적입니다. 학습자 대시보드는 신선한 데이터가 필요합니다 |
| 인증 | PKCE를 사용한 Supabase 인증 | 내장 RLS 통합, 별도의 인증 서비스 없음 |
| 데이터베이스 | Supabase Postgres | 서버 컴포넌트에서 직접 쿼리, ORM 필요 없음 |
| 파일 스토리지 | Supabase Storage | 비디오 콘텐츠용 서명된 URL, RLS 게이트 접근 |
| 실시간 | Supabase Realtime | 토론 포럼, 실시간 진행률 업데이트 |
| 결제 | Stripe(웹훅을 통해) | Supabase Edge Functions는 웹훅 처리 |
| 콘텐츠 형식 | DB에 저장된 MDX | React 컴포넌트에서 렌더링되는 구조화된 콘텐츠 |
Next.js 15의 서버 컴포넌트는 서버 클라이언트를 사용하여 Supabase를 직접 쿼리할 수 있습니다 -- API 경로 필요 없음, 직렬화 경계 없음. 이것은 모든 페이지 로드에서 코스 데이터, 사용자 진행률, 등록 상태를 가져오는 LMS에서 큰 승리입니다.
데이터베이스 스키마 설계
스키마는 모든 LMS의 백본입니다. 나중에 몇 주를 절약하기 위해 일찍 올바르게 하면 충분한 반복을 거쳤습니다. 다음은 멀티테넌트 코스, 구조화된 모듈, 진행률 추적 및 평가를 처리하는 스키마입니다.
-- Organizations (for multi-tenant / white-label)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User profiles extending Supabase auth
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'learner' CHECK (role IN ('admin', 'instructor', 'learner')),
organization_id UUID REFERENCES organizations(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Courses
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
instructor_id UUID REFERENCES profiles(id),
title TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
thumbnail_url TEXT,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
price_cents INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, slug)
);
-- Modules (sections within a course)
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Lessons (individual content units)
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID REFERENCES modules(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('video', 'text', 'quiz', 'assignment')),
content JSONB NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER,
is_free_preview BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enrollments
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
stripe_payment_id TEXT,
UNIQUE(user_id, course_id)
);
-- Progress tracking
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
progress_pct SMALLINT DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, lesson_id)
);
-- Quiz attempts
CREATE TABLE quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
answers JSONB NOT NULL,
score SMALLINT,
passed BOOLEAN,
attempted_at TIMESTAMPTZ DEFAULT NOW()
);
몇 가지 설계 노트:
- lessons의
content열은 JSONB입니다. 이것은 의도적입니다. 비디오 레슨은{"video_url": "...", "transcript": "..."}를 저장합니다. 텍스트 레슨은{"mdx": "..."}를 저장합니다. 퀴즈는{"questions": [...]}를 저장합니다. 이것은 모든 콘텐츠 타입에 대한 별도 테이블 필요 없이 구조화된 데이터를 제공합니다. - modules와 lessons의
sort_order. 드래그 앤 드롭 재정렬은 모든 코스 빌더에서 필수입니다. 정수 정렬 순서가 이를 간단하게 만듭니다. - courses의
metadataJSONB. 이것은 탈출 해치입니다. SEO 필드, 맞춤 브랜딩, 기능 플래그 -- 스키마 마이그레이션 없이 모두 여기에 넣으세요.

역할 기반 접근 제어로 Supabase 인증 설정
Supabase Auth는 이메일/비밀번호, OAuth, 매직 링크를 기본 제공합니다. LMS의 경우, 일반적으로 세 가지 역할이 필요합니다. admin, instructor, learner. 트릭은 Supabase의 auth.users를 profiles 테이블과 동기화하는 것입니다.
먼저 새 사용자가 가입할 때마다 프로필을 생성하는 데이터베이스 트리거를 설정합니다.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
이제 Next.js 측에서 Supabase SSR 클라이언트를 구성합니다. 이것은 @supabase/ssr을 사용하는 2026 패턴입니다.
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component -- ignore
}
},
},
}
)
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
서버 클라이언트는 자동으로 쿠키에서 인증 토큰을 읽습니다. 서버 컴포넌트에서, 이렇게 할 수 있습니다.
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('profiles')
.select('*, organization:organizations(*)')
.eq('id', user.id)
.single()
const { data: enrollments } = await supabase
.from('enrollments')
.select('*, course:courses(*)')
.eq('user_id', user.id)
return (
<div>
<h1>Welcome back, {profile?.full_name}</h1>
{/* Render enrolled courses */}
</div>
)
}
API 경로 없음. fetch 호출 없음. 서버 컴포넌트에서 직접 데이터베이스 쿼리. 이것이 Supabase + Next.js App Router의 아키텍처 이점입니다.
멀티테넌트 콘텐츠용 행 수준 보안
RLS는 이 아키텍처를 실제로 안전하게 만드는 것입니다. 없으면, anon 키는 누구에게나 모든 것에 대한 접근을 제공합니다. RLS를 사용하면, 데이터베이스 자체가 누가 무엇을 보고 수정할 수 있는지를 강제합니다.
다음은 핵심 테이블에 대한 정책입니다.
-- Courses: anyone can read published courses, instructors can manage their own
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Published courses are visible to all" ON courses
FOR SELECT USING (status = 'published');
CREATE POLICY "Instructors manage their courses" ON courses
FOR ALL USING (
auth.uid() = instructor_id
);
CREATE POLICY "Admins manage org courses" ON courses
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
AND organization_id = courses.organization_id
)
);
-- Lessons: enrolled users can read, free previews visible to all
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Free preview lessons visible to all" ON lessons
FOR SELECT USING (is_free_preview = true);
CREATE POLICY "Enrolled users can read lessons" ON lessons
FOR SELECT USING (
EXISTS (
SELECT 1 FROM enrollments e
JOIN modules m ON m.course_id = e.course_id
WHERE e.user_id = auth.uid()
AND m.id = lessons.module_id
)
);
-- Progress: users can only see and update their own progress
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users manage own progress" ON lesson_progress
FOR ALL USING (auth.uid() = user_id);
-- Enrollments: users see their own, instructors see their course enrollments
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own enrollments" ON enrollments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Instructors see course enrollments" ON enrollments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM courses
WHERE courses.id = enrollments.course_id
AND courses.instructor_id = auth.uid()
)
);
사람들이 헷갈리는 한 가지는. SELECT의 RLS 정책은 누적입니다. 어떤 정책이라도 통과하면, 행이 표시됩니다. INSERT, UPDATE, DELETE의 경우, 적용 가능한 모든 정책이 통과해야 합니다. 접근 문제를 디버깅할 때 이를 기억하세요.
Next.js App Router 프런트엔드 구축
다음은 LMS에 잘 작동하는 경로 구조입니다.
app/
├── (marketing)/
│ ├── page.tsx # Landing page
│ ├── courses/
│ │ ├── page.tsx # Course catalog
│ │ └── [slug]/
│ │ └── page.tsx # Course detail / sales page
├── (app)/
│ ├── layout.tsx # Authenticated layout with sidebar
│ ├── dashboard/
│ │ └── page.tsx # Learner dashboard
│ ├── learn/
│ │ └── [courseSlug]/
│ │ └── [lessonId]/
│ │ └── page.tsx # Lesson viewer
│ ├── instructor/
│ │ ├── courses/
│ │ │ ├── page.tsx # Instructor course list
│ │ │ ├── new/
│ │ │ │ └── page.tsx # Create course
│ │ │ └── [id]/
│ │ │ └── edit/
│ │ │ └── page.tsx # Course editor
├── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Stripe webhook handler
경로 그룹 (marketing)과 (app)은 URL 구조에 영향을 주지 않으면서 다른 레이아웃을 사용할 수 있습니다. 마케팅 페이지는 최소한의 헤더와 바닥글을 얻습니다. app 섹션은 코스 네비게이션이 있는 전체 사이드바를 얻습니다.
무료 코스에 등록하는 서버 액션입니다.
// app/actions/enroll.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function enrollInCourse(courseId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'You must be logged in to enroll' }
}
// Check if course is free
const { data: course } = await supabase
.from('courses')
.select('price_cents')
.eq('id', courseId)
.single()
if (course?.price_cents && course.price_cents > 0) {
return { error: 'This course requires payment' }
}
const { error } = await supabase
.from('enrollments')
.insert({ user_id: user.id, course_id: courseId })
if (error) {
if (error.code === '23505') {
return { error: 'You are already enrolled in this course' }
}
return { error: error.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Next.js 15의 서버 액션은 변경 작업을 처리하는 가장 깔끔한 방법입니다. API 경로 없음, 수동 fetch 호출 없음. 폼이 기능을 호출합니다.
코스 콘텐츠 렌더링 및 진행률 추적
레슨 뷰어의 경우, 동시에 일어나는 두 가지가 필요합니다. 콘텐츠 렌더링과 진행률 추적. 다음은 제 접근 방식입니다.
// app/(app)/learn/[courseSlug]/[lessonId]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { LessonViewer } from '@/components/lesson-viewer'
import { ProgressTracker } from '@/components/progress-tracker'
export default async function LessonPage({
params,
}: {
params: Promise<{ courseSlug: string; lessonId: string }>
}) {
const { courseSlug, lessonId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: lesson } = await supabase
.from('lessons')
.select(`
*,
module:modules(
*,
course:courses(*)
)
`)
.eq('id', lessonId)
.single()
if (!lesson) redirect('/dashboard')
// Get all lessons in this course for navigation
const { data: allLessons } = await supabase
.from('lessons')
.select('id, title, sort_order, module_id, content_type')
.eq('module_id', lesson.module_id)
.order('sort_order')
// Get current progress
const { data: progress } = await supabase
.from('lesson_progress')
.select('*')
.eq('user_id', user.id)
.eq('lesson_id', lessonId)
.maybeSingle()
return (
<div className="flex h-screen">
<aside className="w-72 border-r overflow-y-auto">
{/* Course sidebar with lesson list */}
</aside>
<main className="flex-1 overflow-y-auto">
<LessonViewer
lesson={lesson}
progress={progress}
/>
<ProgressTracker
lessonId={lessonId}
initialProgress={progress?.progress_pct ?? 0}
/>
</main>
</div>
)
}
ProgressTracker는 사용자가 텍스트 콘텐츠를 스크롤하거나 비디오를 볼 때 실시간으로 진행률을 업데이트하는 클라이언트 컴포넌트입니다. 브라우저 Supabase 클라이언트를 사용하여 진행률을 실시간으로 작성합니다.
// components/progress-tracker.tsx
'use client'
import { useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useDebounce } from '@/hooks/use-debounce'
export function ProgressTracker({
lessonId,
initialProgress,
}: {
lessonId: string
initialProgress: number
}) {
const supabase = createClient()
const updateProgress = useCallback(
async (pct: number) => {
await supabase
.from('lesson_progress')
.upsert(
{
user_id: (await supabase.auth.getUser()).data.user?.id,
lesson_id: lessonId,
progress_pct: pct,
status: pct >= 100 ? 'completed' : 'in_progress',
completed_at: pct >= 100 ? new Date().toISOString() : null,
},
{ onConflict: 'user_id,lesson_id' }
)
},
[lessonId, supabase]
)
const debouncedUpdate = useDebounce(updateProgress, 2000)
useEffect(() => {
const handleScroll = () => {
const scrollPct = Math.round(
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
)
if (scrollPct > initialProgress) {
debouncedUpdate(scrollPct)
}
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [debouncedUpdate, initialProgress])
return null // This component has no UI
}
진행률 업데이트를 디바운싱하는 것은 매우 중요합니다. 모든 스크롤 이벤트에서 데이터베이스에 히트를 원하지 않습니다.
실시간 기능: 토론 및 알림
Supabase Realtime은 LMS를 정적 콘텐츠 뷰어에서 생생한 느낌의 것으로 변환합니다. 내가 항상 먼저 구축하는 두 가지 기능. 레슨 토론과 강사를 위한 진행률 알림.
// components/lesson-discussion.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function LessonDiscussion({ lessonId }: { lessonId: string }) {
const supabase = createClient()
const [messages, setMessages] = useState<any[]>([])
useEffect(() => {
// Fetch existing messages
supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('lesson_id', lessonId)
.order('created_at')
.then(({ data }) => setMessages(data ?? []))
// Subscribe to new messages
const channel = supabase
.channel(`lesson-${lessonId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'discussions',
filter: `lesson_id=eq.${lessonId}`,
},
async (payload) => {
// Fetch the full message with profile
const { data } = await supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('id', payload.new.id)
.single()
if (data) setMessages((prev) => [...prev, data])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [lessonId, supabase])
return (
<div>
{/* Render messages and input form */}
</div>
)
}
Supabase Storage를 통한 비디오 및 파일 스토리지
비디오 콘텐츠의 경우, 서명된 URL이 있는 Supabase Storage가 방법입니다. 코스 비디오용 비공개 버킷을 만들고 스토리지 레이어에서 RLS 같은 정책을 사용합니다.
-- Storage bucket policy: only enrolled users can access course videos
CREATE POLICY "Enrolled users can view course videos"
ON storage.objects FOR SELECT
USING (
bucket_id = 'course-videos'
AND EXISTS (
SELECT 1 FROM enrollments e
JOIN courses c ON c.id = e.course_id
WHERE e.user_id = auth.uid()
AND storage.filename(name) LIKE c.id || '/%'
)
);
서버 측에서, 단기 서명된 URL을 생성합니다.
const { data } = await supabase.storage
.from('course-videos')
.createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 hour
프로덕션의 경우, 이것 앞에 CDN을 놓으시길 원합니다. Supabase의 내장 CDN은 대부분의 경우를 처리합니다. 하지만 높은 트래픽 비디오 제공의 경우, Mux 또는 Cloudflare Stream 같은 서비스로 트랜스코딩을 고려하고 데이터베이스에 재생 ID만 저장합니다.
배포 및 성능 고려 사항
Supabase 무료 계층은 500MB 데이터베이스, 1GB 스토리지, 월 5만 활성 사용자를 제공합니다. 이것은 시작하기에 충분합니다. Pro 계획 월 $25는 8GB 데이터베이스, 100GB 스토리지, MAU 무제한을 제공합니다. -- 수천 명의 학습자가 있는 심각한 LMS를 처리합니다.
| Supabase 계획 | DB 스토리지 | 파일 스토리지 | MAU 제한 | 가격 |
|---|---|---|---|---|
| Free | 500MB | 1GB | 50,000 | $0 |
| Pro | 8GB | 100GB | Unlimited | $25/mo |
| Team | 8GB | 100GB | Unlimited | $599/mo |
| Enterprise | Custom | Custom | Unlimited | Custom |
Next.js 배포의 경우, Vercel은 명백한 선택입니다. -- 자동 ISR, 엣지 미들웨어, 이미지 최적화를 얻습니다. 하지만 비용에 민감하다면, Coolify 또는 Railway에서 훨씬 적게 배포할 수 있습니다.
프로덕션의 성능 팁:
- 코스 카탈로그 페이지용 ISR을 사용합니다.
revalidate: 3600은 카탈로그가 최대 시간당 한 번 재구축된다는 의미입니다. 대부분의 사용 경우에는 충분히 빠릅니다. - 레슨 뷰어용 서버 컴포넌트를 사용합니다. 초기 페이지 로드는 모든 콘텐츠로 SSR됩니다. 로딩 스피너 없음.
- 데이터베이스 인덱스를 추가합니다. 최소:
enrollments(user_id),lesson_progress(user_id, lesson_id),lessons(module_id, sort_order),courses(organization_id, slug). - 연결 풀링. 서버 측 쿼리에 Supabase의 내장 PgBouncer(풀링 연결 문자열)를 사용합니다. 마이그레이션용 직접 연결.
이와 같은 것을 구축하고 있으며 아키텍처 또는 구현에 대한 도움이 필요한 경우, 우리 팀은 Next.js 개발과 헤드리스 CMS 빌드를 정기적으로 합니다. 여러 Supabase 기반 애플리케이션을 출시했으며 날카로운 모서리가 어디인지 알고 있습니다.
FAQ
Supabase는 프로덕션 LMS의 규모를 처리할 수 있습니까?
네. Supabase는 관리 Postgres에서 실행되며, 이는 숨 쉴 틈 없이 수백만 행을 처리합니다. Pro 계획에는 PgBouncer를 통한 연결 풀링이 포함되며, 컴퓨팅을 독립적으로 확장할 수 있습니다. 참고로, Supabase는 Enterprise 계층에서 10만 개 이상의 동시 연결을 실행하는 고객을 보고합니다. 대부분의 LMS 사용 사례의 경우 -- 수만 명의 활성 학습자가 있어도 -- 월 $25의 Pro 계획이 충분 이상입니다.
Supabase 기반 LMS에서 비디오 호스팅을 어떻게 처리합니까?
Supabase Storage는 서명된 URL로 비디오 파일을 저장하고 서빙하는 데 작동하지만, 비디오 플랫폼이 아닙니다. 프로덕션 LMS 비디오의 경우, Mux(인코딩 $0.007/분, 제공 $0.00015/초) 또는 Cloudflare Stream($1/1000분 저장, $5/1000분 제공) 같은 전용 비디오 서비스를 사용하는 것을 권장합니다. 재생 ID를 Supabase 데이터베이스에 저장하고 프런트엔드에서 해당 플레이어 SDK를 사용합니다. 이것은 직접 구축하지 않고 응답형 비트레이트, 분석, DRM을 제공합니다.
2026년에 Next.js App Router는 프로덕션 LMS에 충분히 안정적입니까?
절대적으로. App Router는 Next.js 14 이후로 안정적이었으며, Next.js 15는 서버 액션과 캐싱이 있는 남은 거친 모서리를 정렬했습니다. 서버 컴포넌트는 LMS와 같은 데이터 집약적 애플리케이션에 진정한 아키텍처 이점입니다. Next.js 15의 after() API는 응답을 차단하지 않고 분석 이벤트와 진행률 업데이트를 발생시키는 데 특히 유용합니다.
코스 이수 인증서를 어떻게 구현합니까?
레슨 수준에서 lesson_progress로 이수를 추적한 다음, 코스 이수를 모든 모듈의 완료된 레슨 비율로 계산합니다. 사용자가 100%에 도달하면, 트리거 Supabase Edge Function (또는 Next.js 서버 액션) @react-pdf/renderer 같은 라이브러리를 사용하여 PDF 인증서를 생성하고 Supabase Storage에 저장합니다. 저장소 경로와 고유 검증 코드가 있는 certificates 테이블에 행을 추가합니다.
SCORM 규정 준수는 어떻습니까?
SCORM은 대부분의 현대 LMS 플랫폼이 멀어지고 있는 레거시 표준입니다. 즉, SCORM 지원이 필요하다면 -- 일반적으로 회사 규정 준수 교육의 경우 -- SCORM 패키지를 파싱하고 추출된 콘텐츠를 Supabase 데이터베이스에 저장할 수 있습니다. pipwerks-scorm-api-wrapper 같은 라이브러리가 런타임 API를 처리합니다. 가능하지만 상당한 복잡성을 추가합니다. 클라이언트가 구체적으로 요구하지 않는 한 피하겠습니다.
유료 코스에 대한 결제를 어떻게 처리합니까?
Stripe Checkout이 가장 간단한 경로입니다. 서버 액션에서 Stripe Checkout 세션을 생성하고, 사용자를 Stripe로 리다이렉트하고, Next.js API 경로 또는 Supabase Edge Function에서 checkout.session.completed 웹훅을 처리합니다. 웹훅이 등록 레코드를 생성합니다. 이것은 직접 신용 카드 데이터를 처리하지 않고 결제 논리를 간단하고 PCI 준수로 유지합니다.
이 아키텍처는 모바일 앱도 지원할 수 있습니까?
이것은 헤드리스로 가는 가장 큰 이점 중 하나입니다. Supabase 데이터베이스와 인증은 @supabase/supabase-js를 통해 React Native 앱에서 동일하게 작동합니다. 동일한 RLS 정책이 데이터를 보호합니다. 다른 UI 레이어를 구축할 뿐입니다. 우리는 API 변경 없이 동일한 Supabase 백엔드에서 웹과 모바일을 출시한 팀을 봤습니다.
퀴즈 생성 또는 콘텐츠 요약 같은 AI 기능을 어떻게 추가합니까?
레슨 콘텐츠를 구조화된 JSONB(HTML 블롭이 아님)로 저장합니다. 그 다음 레슨 콘텐츠를 LLM (OpenAI, Anthropic, 또는 자체 호스팅 모델)에 보내 퀴즈 질문, 요약, 또는 학습 가이드를 생성하는 Supabase Edge Function을 생성합니다. 생성된 콘텐츠를 JSONB 필드에 저장합니다. 우리가 초반에 설계한 구조화된 데이터 모델은 이를 간단하게 만듭니다. -- AI 시스템은 마크업을 파싱하지 않고 특정 필드를 사용할 수 있습니다. 이 종류의 작업에 관심이 있으시면, 우리 팀에 문의하세요 -- 우리는 올해 내내 콘텐츠 플랫폼에 AI 통합을 구축해왔습니다.