Claude Code로 안전한 멀티테넌트 SaaS 만들기: RLS, 인증, 과금, 작업, 로그
Claude Code로 tenant_id, RLS, 인증 경계, 과금 제한, 백그라운드 작업, 로그 누출 테스트를 구현합니다.
멀티테넌트 SaaS는 하나의 애플리케이션이 여러 고객 조직을 서비스하는 구조입니다. 여기서 테넌트는 고객 데이터의 경계입니다. 회사, 워크스페이스, 고객 포털, 계정 단위가 모두 테넌트가 될 수 있습니다. Claude Code는 라우트, DB 헬퍼, 작업 큐, UI를 빠르게 수정할 수 있기 때문에, 구현 전에 이 경계를 명확히 적어 두어야 합니다.
막아야 할 사고는 단순합니다. Tenant A 사용자가 Tenant B의 프로젝트, 청구 정보, 파일, 감사 로그, AI 프롬프트를 읽는 것입니다. 코드 리뷰에서where tenantId를 찾는 것만으로는 부족합니다. 안전한 설계는tenant_id를 인증, 세션, 데이터베이스, 과금, 백그라운드 작업, 검색 인덱스, 로그까지 전달하고, 애플리케이션 쿼리가 실수해도 데이터베이스가 마지막에 막도록 만듭니다.
이 글은 Claude Code를 보안 검토를 건너뛰는 도구가 아니라, 경계 조건을 함께 구현하는 도구로 사용합니다. RLS는 Row Level Security, 즉 행 단위 접근 제어입니다. PostgreSQL 공식 문서인Row Security Policies, CREATE POLICY, current_setting과set_config를 기준으로 코드를 검토하세요.
기초 흐름은 Claude Code 인증 구현, RBAC 구현, 데이터베이스 마이그레이션과 함께 보면 좋습니다. 멀티테넌시는 이 세 주제가 만나는 지점입니다.
먼저 격리 모델을 고른다
대표적인 테넌트 격리 모델은 세 가지입니다. Claude Code에게 handler를 만들라고 하기 전에 모델부터 고르세요.
| 모델 | 적합한 경우 | 장점 | 함정 |
|---|---|---|---|
| 공유 DB, 공유 schema | 초기 또는 중기 SaaS, 작은 고객이 많을 때 | 운영이 가볍고 분석이 쉽다 | tenant 조건 하나가 빠지면 바로 누출 |
| 공유 DB, 테넌트별 schema | 대형 고객 일부를 더 분리해야 할 때 | 권한과 마이그레이션을 조금 분리 가능 | schema가 많아지면 배포가 어렵다 |
| 테넌트별 DB | 금융, 의료, 강한 계약 격리 | 백업, 키, 장애 범위를 분리 가능 | 비용과 운영 복잡도가 크다 |
첫 질문은 고객이 100개, 1000개가 되었을 때 운영 가능한가입니다. 대부분의 중소 SaaS는 공유 DB와 공유 schema에서 시작하되tenant_id, RLS, 감사 로그, 복구 절차를 강하게 만드는 편이 현실적입니다. 다만 특정 엔터프라이즈 고객이 물리 분리를 요구할 수 있다면, 일부 테넌트를 별도 DB로 옮길 수 있는 길은 열어 둡니다.
세 가지 실제 사례를 생각해볼 수 있습니다. 첫째, B2B CRM입니다. 회사, 연락처, 영업 기회, 메모는 모두 테넌트 소유이고 영업 담당자는 자기 회사 데이터만 봅니다. 둘째, 에이전시 고객 포털입니다. 에이전시 직원은 여러 테넌트에 속할 수 있지만 고객 직원은 자기 포털만 봐야 합니다. 셋째, AI 기능이 있는 SaaS입니다. 프롬프트 실행 횟수, token, 업로드 파일, 저장 용량을 테넌트별로 세야 요금제 제한과 과금이 맞습니다.
첫 Claude Code 요청은 구현이 아니라 설계 리뷰여야 합니다.
이 SaaS 코드베이스를 멀티테넌트 구조로 바꾸고 싶습니다.
공유 PostgreSQL DB와 공유 schema를 전제로 합니다.
테이블을 tenant-owned, global reference, PostgreSQL RLS 필수 대상으로 분류하세요.
위험한 지름길은 금지합니다. "애플리케이션 WHERE로 충분", 전체 request body 로그, tenant_id 없는 job payload는 허용하지 않습니다.
변경 영역, DB 제약, 테스트 케이스, 마이그레이션 리스크로 나누어 출력하세요.
신뢰할 수 있는 곳에서 tenant_id를 전달한다
브라우저가 보낸x-tenant-id헤더나 URL 파라미터를 그대로 믿으면 안 됩니다. 안전한 순서는 host 또는 path에서 후보 테넌트를 찾고, 로그인 사용자가 그 테넌트의 멤버인지 서버에서 확인한 뒤, 검증된tenantId만 내부 서비스에 전달하는 것입니다.
Auth.js 공식 Extending the Session 문서는 세션에 서버 파생 필드를 추가하는 방법을 보여줍니다. 하지만 세션에 권한 정보를 많이 넣지 마세요. 세션은 사용자를 식별하고, 테넌트 membership은 서버 DB에서 확인하는 편이 역할 변경과 정지 상태를 반영하기 쉽습니다.
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
핵심은 클라이언트의 tenantId를 진실로 취급하지 않는 것입니다. 내부 API와 worker도 같은 규칙을 써야 합니다. 외부에서 온 tenant_id는 주장일 뿐이고, membership, 테넌트에 묶인 API key, 서명된 job payload로 증명해야 합니다.
PostgreSQL RLS를 마지막 방어선으로 둔다
애플리케이션 필터는 필요하지만, 필터 하나가 빠졌다고 곧바로 침해가 되면 안 됩니다. 공유 schema에서는 모든 테넌트 테이블에tenant_id, 외래 키, 테넌트 포함 인덱스, RLS policy가 필요합니다. PostgreSQL에서 테이블 owner와BYPASSRLS역할은 RLS를 우회할 수 있으므로, 애플리케이션 연결 역할이 보호 테이블을 소유하지 않도록 하고 중요한 테이블에는FORCE ROW LEVEL SECURITY를 사용합니다.
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
연결 풀에서는 tenant context가 연결에 남는 문제가 생길 수 있습니다. 그래서 트랜잭션 안에서set_config(..., true)를 호출하고, 트랜잭션이 끝나면 값이 사라지게 합니다.
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
예제 쿼리에는 일부러WHERE tenant_id = $1이 없습니다. RLS가 켜져 있으면 PostgreSQL이 보이지 않는 행을 거릅니다. 운영에서는 성능을 위해 애플리케이션 필터도 넣을 수 있지만, RLS를 대체해서는 안 됩니다.
인증, 과금, 백그라운드 작업을 같은 경계로 맞춘다
인증 경계는 “로그인했는가”가 아니라 “이 테넌트에서 무엇을 할 수 있는가”입니다. 사용자가 여러 조직에 속할 수 있으면user_id만으로 레코드를 찾는 코드는 위험합니다. 서비스 사이에는tenantId, userId, role, requestId를 함께 전달하세요.
과금도 같은 경계입니다. Stripe의usage-based billing은 사용량 보고가 핵심이지만, 애플리케이션은 먼저 올바른 테넌트 사용량을 세야 합니다. 요금제 제한은 UI 표시가 아니라 서버에서 쓰기나 AI 실행 전에 막아야 합니다.
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
백그라운드 작업은 누출이 자주 생기는 지점입니다. 큐에projectId만 넣고 worker가 나중에 전체 검색을 하게 만들지 마세요. 검증된tenantId를 payload에 넣고 worker에서도withTenant로 RLS context를 설정합니다.
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
로그는 충분히, 그러나 전부는 아니다
로그는 통제가 약한 두 번째 데이터베이스가 되기 쉽습니다. OWASP의Logging Cheat Sheet는 이벤트 데이터 정제와 민감 정보 제외를 강조합니다. 멀티테넌트 SaaS에서는tenantId, requestId, 이벤트명, actor ID, target ID가 필요하지만 Cookie, Authorization, API key, 전체 프롬프트, 전체 request body, 청구 주소는 필요하지 않습니다.
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
감사 로그는 운영 로그와 분리하세요. 감사 로그는 누가, 어느 테넌트에서, 어떤 대상에, 어떤 작업을 했는지 답해야 합니다. 규정이나 제품 요구가 없다면 변경 전후 값을 전부 복제하지 않는 편이 좋습니다.
데이터 누출을 시도하는 테스트를 쓴다
정상 흐름 테스트만으로는 부족합니다. Tenant A가 Tenant B의 ID를 넣는 경우, worker가 잘못된 payload를 받는 경우,app.tenant_id가 빠진 경우, 요금제 제한을 넘는 경우, logger에 민감 필드가 들어오는 경우를 테스트합니다.
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Claude Code에는 공격적인 완료 조건을 줍니다.
다음 케이스로 멀티테넌트 누출 테스트를 추가하세요.
1. Tenant A session이 Tenant B projectId를 사용하면 403 또는 404가 된다.
2. app.tenant_id가 없는 DB query는 0건이거나 fail closed가 된다.
3. tenantId가 없는 background job은 거부된다.
4. 요금제 제한 초과는 DB write 전에 402를 반환한다.
5. 로그에는 authorization, cookie, prompt, token, apiKey가 나오지 않는다.
각 테스트가 잡아낼 잘못된 구현을 먼저 설명하고, 수정 후 실행 명령과 결과를 보여주세요.
마이그레이션 함정을 피한다
기존 SaaS에 멀티테넌시를 나중에 넣을 때 흔한 실수는 nullabletenant_id를 추가하고 바로 배포하는 것입니다. backfill 기간에 일시적으로 NULL을 허용할 수는 있지만, 릴리스 완료 조건은NOT NULL, 외래 키, 테넌트 포함 unique constraint, 인덱스, RLS, 테스트까지입니다.
다섯 가지 함정을 특히 봐야 합니다. 첫째, global reference 데이터와 tenant-owned 데이터를 구분하지 않는 것. 국가 코드와 같은 값은 global일 수 있지만 고객 설정은 tenant 데이터입니다. 둘째, 파일 storage key에 테넌트 경계가 없는 것. 셋째, 검색 인덱스에tenant_id필터가 없는 것. 넷째, admin panel이라는 이유로 RLS를 끄는 것. support 접근은 명시적이고 감사 가능한 impersonation이어야 합니다. 다섯째, 단일 테넌트 복구를 연습하지 않는 것. 한 고객만 복구하지 못하면 사고 때 전체 고객 롤백을 고민하게 됩니다.
기존 테이블을 멀티테넌트로 전환하는 마이그레이션 계획을 작성하세요.
Phase 1: tenant_id 컬럼을 추가하고 기존 레코드 mapping을 만든다.
Phase 2: backfill SQL과 남은 NULL 검증 SQL을 작성한다.
Phase 3: NOT NULL, foreign key, tenant-aware unique constraint, index를 추가한다.
Phase 4: RLS를 활성화하고 FORCE ROW LEVEL SECURITY 대상 테이블을 제안한다.
Phase 5: API, job, log, search index의 cross-tenant leak 테스트를 만든다.
각 Phase에 rollback 조건과 verification SQL을 포함하세요.
안전한 Claude Code 프롬프트를 쓴다
프롬프트에도 테넌트 경계를 넣어야 합니다. “빨리 멀티테넌트로 바꿔줘” 또는 “관리자는 모두 볼 수 있게” 같은 요청은 위험합니다. 안전한 프롬프트는 금지 사항, 테스트, 공식 근거, 수정 범위를 포함합니다.
당신은 안전한 멀티테넌트 SaaS 동작을 구현합니다.
수정 가능 범위는 src/app, src/lib, db/migrations, tests 입니다.
필수:
- tenant_id는 서버에서 해석하고 client-provided tenant ID를 믿지 않는다.
- 모든 tenant-owned table에 PostgreSQL RLS를 사용한다.
- connection pool에서는 transaction 안에서 set_config('app.tenant_id', value, true)를 호출한다.
- tenantId를 auth, billing, jobs, logs, search indexing에 전달한다.
- cross-tenant read/write를 의도적으로 시도하는 테스트를 추가한다.
금지:
- RLS 비활성화.
- x-tenant-id 신뢰.
- Authorization, Cookie, API key, 전체 prompt 로그 기록.
- 테스트 없이 완료 처리.
최종 보고에는 변경 파일, 실행 명령, 발견한 실패 케이스, 남은 리스크를 포함하세요.
Anthropic의Claude Code Security는 권한, prompt injection, 사용자의 검토 책임을 설명합니다. 민감한 SaaS 저장소에서는 허용 명령, MCP 연결, 프로젝트 규칙을 버전 관리 안에서 검토 가능하게 두는 것이 좋습니다.
CTA와 실무 인수인계
멀티테넌시는 화려한 기능은 아니지만 B2B SaaS에서는 신뢰를 팝니다. 테넌트 데이터 격리, 감사 로그, 요금제 제한, support 접근, 단일 테넌트 복구를 설명할 수 있으면 보안 설문과 엔터프라이즈 검토가 쉬워집니다.
팀에서 Claude Code를 도입한다면 tenant 규칙을CLAUDE.md에 넣고 테스트로 고정하세요. 리뷰 때마다 네 가지를 확인합니다. tenant_id가 끝까지 전달되는가, RLS가 실수를 막는가, job이 tenant-scoped인가, 로그가 민감 정보를 피하는가. 팀 도입 지원은Claude Code training and consulting에서 시작할 수 있고, 개인 개발자는무료 치트시트로 체크리스트를 반복 가능한 프롬프트로 바꿀 수 있습니다.
실제로 시험해 본 결과
Masa가 작은 CRM 샘플에서 이 구성을 시험했을 때 첫 누출은 API route가 아니라 daily digest worker에서 나왔습니다. UI는tenantId를 넘겼지만 queue payload에는projectId만 있어서 worker가 의도와 다른 테넌트를 검색할 수 있었습니다. worker도withTenant를 통하게 만들고app.tenant_id가 없을 때의 SQL 테스트를 추가하자 문제가 재현되고 수정되었습니다. Claude Code에는 “정상 테스트를 추가해”보다 “Tenant A가 Tenant B를 읽으려는 테스트를 먼저 작성해”라고 지시하는 편이 훨씬 효과적이었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.