SaaS (서비스형 소프트웨어)
SaaS의 최신 기술 — 멀티 테넌시, 사용량 기반 과금, PLG, AI 기능 통합, 현대적 관찰 가능성, 규정 준수, 그리고 프로덕션 Software-as-a-Service 제품을 위한 보안.
지침
멀티 테넌시(Multi-Tenancy)
사용 사례에 적합한 격리 모델을 선택하세요:
| 모델 | 설명 | 사용 시기 |
|---|---|---|
| 공유 스키마 | 모든 테넌트가 테이블을 공유하며, 행은 tenant_id로 태그됨 | 초기 단계, 비용 효율적, 간단한 규정 준수 |
| 스키마-퍼-테넌트 | 테넌트당 별도의 DB 스키마 | 중간 수준의 격리, 테넌트별 마이그레이션 용이 |
| DB-퍼-테넌트 | 테넌트당 별도의 데이터베이스 | 엔터프라이즈, 엄격한 데이터 상주, 높은 규정 준수(SOC 2, HIPAA) |
| 하이브리드 | 핵심 데이터는 공유, 민감 데이터는 격리 | 혼합 등급 서비스를 제공하는 규제 산업 |
공유 스키마 테넌시 규칙:
- 모든 쿼리는 반드시
tenant_id로 필터링해야 합니다. — 개발자 규율이 아닌 기본 리포지토리 또는 ORM 미들웨어를 통해 적용하세요. - PostgreSQL **행 수준 보안(RLS)**을 두 번째 방어 계층으로 사용하세요:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid); - 모든 테넌트 범위 테이블에
tenant_id를 인덱싱하세요. - 테넌트 간 데이터 격리를 확인하는 통합 테스트를 작성하세요.
- 모든 객체 스토리지 경로 범위를 지정하세요:
s3://bucket/{tenantId}/uploads/{fileId} - 예측 가능한 순차적 ID를 절대 노출하지 마세요. — UUID(v7, 정렬 가능)를 사용하세요.
구독 및 과금(Subscription & Billing)
정액제 구독(Flat-rate subscriptions)
- 과금은 제공업체(Stripe, Paddle, LemonSqueezy)에 위임하세요. — 절대 자체적으로 결제 처리를 구현하지 마세요.
- 데이터베이스에는
customerId와subscriptionId만 저장하고, 카드 데이터는 절대 저장하지 마세요. - 웹훅을 통해 상태를 동기화하세요. — 데이터베이스를 과금 제공업체 진실의 최종적 일관성을 가진 읽기 복제본으로 취급하세요:
customer.subscription.created → 플랜 활성화 customer.subscription.updated → 업그레이드 / 다운그레이드 / 시트 변경 customer.subscription.deleted → 취소 + 유예 기간 시작 invoice.payment_failed → 연체 관리: 알림 → 제한 → 취소 - 웹훅 이벤트
id를 멱등성 키로 사용하세요. — 처리 전에 저장하고, 이미 본 경우 건너뛰세요. - 감사 및 재생을 위해 원시 웹훅 페이로드를 저장하세요.
사용량 기반 / 계량 과금(Usage-based / metered billing) (현대적 기본값)
- 사용 이벤트가 발생할 때마다 추적하세요 (API 호출, 시트, 소비된 토큰, 스토리지 GB):
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity: tokensUsed, timestamp: Math.floor(Date.now() / 1000), action: 'increment', }); - 고객에게 실시간 사용량 대시보드를 제공하세요. — 청구일의 예상치 못한 요금은 이탈을 유발합니다.
- 과금 기간별로 소프트 제한(80%에서 경고)과 하드 제한(100%에서 차단)을 구현하세요.
- 제품 주도 성장(PLG) 온램프로서 무료 티어 + 사용량 기반 업셀을 고려하세요.
제품 주도 성장 (Product-Led Growth, PLG)
- 무료 티어 / 프리미엄(Freemium) — 사용자가 결제 전에 가치를 경험하게 하세요. 유료 플랜이 명백히 가치 있도록 제한을 설정하세요.
- 가치 실현 시간(Time-to-value, TTV) — 가입부터 첫 번째 의미 있는 행동("아하 모먼트")까지의 시간을 측정하고 최소화하세요.
- 인앱 업그레이드 프롬프트 — 플랜 제한에 도달하는 순간에 상황에 맞게 업그레이드 CTA를 표시하세요. 일반 설정 페이지가 아닙니다.
- 제품 적격 리드(Product-qualified leads, PQL) — 데모 요청뿐만 아니라 제품 사용 신호를 기반으로 사용자 점수를 매겨 영업팀에 전달하세요.
- 바이럴 / 협업 기능 — 팀원 초대, 보고서 공유, 공개 페이지 — 각 행동은 배포 채널입니다.
AI 기능 통합 (AI Feature Integration)
현대 SaaS 제품은 AI 네이티브 기능을 제공할 것으로 예상됩니다. 다음 패턴을 적용하세요:
AI 어시스턴트 및 코파일럿:
- 스트리밍 응답(SSE /
ReadableStream)을 사용하여 사용자가 토큰이 도착하는 즉시 볼 수 있게 하세요. — 전체 응답을 기다리게 하지 마세요. - AI 출력물을 항상 편집 가능하게 만드세요. — 사용자는 출력물을 수정, 개선, 소유해야 합니다.
- 신뢰도 신호를 표시하고 출처(RAG)를 인용하여 신뢰를 구축하세요.
검색 증강 생성(Retrieval-Augmented Generation, RAG):
- 테넌트별 벡터 임베딩을 격리된 네임스페이스(Pinecone, pgvector, Qdrant)에 저장하세요. — 절대 테넌트 데이터를 혼합하지 마세요.
- 쓰기 시 콘텐츠를 임베드하고, 읽기 시 코사인 유사도로 쿼리하세요.
- 문서를 지능적으로 청크하세요 (512-1024 토큰, 중첩 포함). 소스 콘텐츠가 변경되면 다시 임베드하세요.
사용량 및 비용 제어:
- 테넌트별 LLM 토큰 소비를 추적하고, 그에 따라 과금 또는 속도 제한을 적용하세요.
- 결정론적 프롬프트(동일 입력 → 동일 출력)를 캐싱하여 비용을 절감하세요.
- 분류/트리아지에는 저렴한 모델을 사용하고, 생성 작업에는 최첨단 모델을 사용하세요.
데이터 프라이버시:
- 테넌트 데이터가 모델 학습에 사용되는지 여부를 명시적으로 명시하는 데이터 처리 부록(DPA)을 제공하세요.
- 옵트아웃을 제공하세요. 엔터프라이즈 고객은 이를 요구합니다.
기능 플래그 및 요금제 게이팅 (Feature Flags & Plan Gating)
- 서비스 계층에서 기능을 게이팅하세요. — 클라이언트 측 숨김은 UI 개선일 뿐 보안이 아닙니다.
- 권한 로직을 단일
can(tenant, feature)헬퍼에 중앙화하세요:function can(tenant: Tenant, feature: Feature): boolean { return tenant.plan.features.includes(feature); } - 기능 배포를 기능 출시와 분리하려면 플래그 시스템(LaunchDarkly, Unleash, GrowthBook 또는 간단한 DB 기반 테이블)을 사용하세요.
- 플래그 사용 사례: 점진적 롤아웃(테넌트 비율), 베타 프로그램, A/B 테스트, 킬 스위치.
온보딩 (Onboarding)
- TTV 최소화: 사용자를 5분 이내에 첫 번째 의미 있는 행동으로 안내하세요.
- 명확한 진행 표시기가 있는 체크리스트 기반 온보딩 흐름을 사용하세요.
- 제품이 처음 사용 시 비어 보이지 않도록 샘플/데모 데이터를 미리 채워두세요.
- 초기에는 최소한의 데이터만 수집하고, 나머지는 나중으로 미루세요.
- 시간 기반이 아닌 제품 이벤트를 기반으로 온보딩 이메일을 트리거하세요: 가입 시 환영, 24시간 후 2단계 미완료 시 알림, 첫 번째 이정표 축하.
- 대화형 제품 둘러보기(비디오 아님)를 제공하여 사용자가 직접 해보며 배우게 하세요.
관찰 가능성 및 운영 (Observability & Operations)
구조화된 로깅(Structured logging) — 모든 로그 라인에는 다음이 포함되어야 합니다:
{ "level": "info", "msg": "...", "tenantId": "...", "userId": "...", "requestId": "...", "timestamp": "..." }
OpenTelemetry를 사용한 분산 추적(Distributed tracing):
- 프레임워크 수준에서 계측하세요 (Next.js
instrumentation.ts, Express 미들웨어). - 서비스 경계 전반에 걸쳐 추적 컨텍스트를 전파하세요 (
traceparent헤더). - 선택한 백엔드(Jaeger, Honeycomb, Datadog, Grafana Tempo)로 내보내세요.
추적할 테넌트별 메트릭:
- 활성 사용자 (DAU/MAU), API 호출량, 오류율, p50/p95/p99 지연 시간, 기능 채택률, 소비된 스토리지.
알림(Alerting):
- 테넌트 수준 이상 징후에 대해 알림하세요. — 갑작스러운 오류 급증, 사용량 감소, 과금 실패.
- 티어별 SLO를 정의하세요: 무료 (최선 노력), 프로 (99.5%), 엔터프라이즈 (99.9%).
관리 도구 (내부):
- 지원을 위해 테넌트를 가장하세요 (모든 가장은 감사).
- 감사 로그 보기, 구독 관리, 기능 플래그 재정의, 재온보딩 트리거.
- 상태 대시보드 구축: 테넌트별 상태, 최근 오류, 사용량 추세.
데이터, 분석 및 규정 준수 (Data, Analytics & Compliance)
이벤트 추적(Event tracking):
- 구조화된 제품 이벤트(
user.signed_up,feature.used,subscription.upgraded)를 데이터 웨어하우스(Snowflake, BigQuery) 또는 CDP(Segment, PostHog, RudderStack)로 내보내세요. - 이러한 이벤트를 이탈 예측, 코호트 분석, PLG 점수 매기기에 사용하세요.
GDPR / 개인정보 보호(Privacy):
- 데이터 내보내기 엔드포인트와 계정 삭제 엔드포인트를 제공하세요. — GDPR에서 요구하며 엔터프라이즈 구매자가 기대합니다.
- 데이터 보존 정책을 문서화하고 자동 삭제를 구현하세요.
- 마케팅 쿠키 및 분석을 위해 동의 관리를 사용하세요.
SOC 2 / 규정 준수 위생 (엔터프라이즈 준비):
- 모든 상태 변경 사용자 행동(누가, 무엇을, 언제, 어떤 IP에서)에 대한 감사 로그를 유지하세요.
- 최소 권한 기본값으로 역할 기반 액세스 제어(RBAC)를 구현하세요.
- 모든 관리자 계정에 MFA를 적용하고 엔터프라이즈 테넌트를 위해 SSO(SAML/OIDC)를 제공하세요.
- 정기적인 종속성 취약점 스캔 및 침투 테스트를 수행하세요.
- 취약점 공개 정책을 유지하세요.
데이터 격리 및 보안 (Data Isolation & Security)
- 인증된 세션에 대해 모든 API 호출에서
tenant_id의 유효성을 검증하세요. — 요청 본문을 절대 신뢰하지 마세요. - 테넌트별로 속도 제한을 적용하여 잡음이 많은 이웃(noisy-neighbour) 효과를 방지하세요.
- 데이터베이스에 API 키를 해시하여 저장하세요 (bcrypt 또는 Argon2). 절대 평문으로 저장하지 마세요.
- HTTPS를 요구하고 긴
max-age와 함께Strict-Transport-Security를 설정하세요. - 세션 쿠키에 대해
HttpOnly; Secure; SameSite=Lax를 사용하세요.
예제 (Examples)
// RLS 컨텍스트를 사용한 테넌트 범위 리포지토리
class TenantRepository<T> {
constructor(
private readonly tenantId: string,
private readonly db: PrismaClient,
) {}
protected async withTenantContext<R>(fn: () => Promise<R>): Promise<R> {
return this.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${this.tenantId}, true)`;
return fn();
});
}
async findById(id: string): Promise<T | null> {
return this.withTenantContext(() =>
(this.db as any).findFirst({ where: { id, tenantId: this.tenantId } })
);
}
}
// 테넌트별 사용량 계량을 포함한 AI 스트리밍 엔드포인트
export async function POST(req: Request) {
const session = await getSession(req);
const { prompt } = await req.json();
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokensUsed = 0;
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
controller.enqueue(new TextEncoder().encode(text));
tokensUsed += chunk.usage?.completion_tokens ?? 0;
}
controller.close();
// 스트리밍 완료 후 사용량 계량
await meteringService.record(session.tenantId, 'ai_tokens', tokensUsed);
},
});
return new Response(readable, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
// 멱등성을 갖춘 Stripe 웹훅
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!
);
const seen = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
if (seen) return res.sendStatus(200);
await db.webhookEvent.create({ data: { stripeEventId: event.id, payload: event } });
switch (event.type) {
case 'customer.subscription.updated':
await subscriptionService.sync(event.data.object);
break;
case 'customer.subscription.deleted':
await subscriptionService.cancel(event.data.object.metadata.tenantId);
break;
}
res.sendStatus(200);
});