2일차에 멀티테넌시를 구축했습니다. 67일차에 다시 구축했습니다
모든 테이블에 org_id를 추가하면 완벽한 멀티테넌시가 될 거라 생각했습니다. 그런데 보안 감사에서 에이전시가 SME 테이블에 쓰기 작업을 하고 있다는 걸 발견했습니다—버그가 아니라 설계 자체의 문제였습니다.
2025년 10월 27일 오후 11시 47분. STRAŦUM에 대해 일상적인 보안 감사라고 생각했던 작업을 실행하고 있었습니다. 몇 주 동안 모든 것이 잘 작동하고 있었습니다—SME는 자체 데이터를, 에이전시는 자체 데이터를 가지고 있었고, 멀티테넌시는 견고했습니다.
커피는 식었습니다. 감사 스크립트가 로그를 처리하고 있었습니다. 그때 이것을 보았습니다: 에이전시가 SME 테이블에 쓰기 작업을 하고 있었습니다.
버그를 통해서가 아닙니다. 보안 취약점을 통해서가 아닙니다. 아키텍처 자체를 통해서였습니다.
두 달 전, 저는 2일차부터 멀티테넌트 아키텍처를 구축하기로 결정했습니다. AI 에이전트 하나만 작동하는 솔로 창업자에게는 대담한 결정이었습니다. 모든 테이블에 org\_id를 추가하고, RLS 정책을 작성하고, SME와 에이전시를 위한 별도의 라우팅을 구축했습니다. 잘 작동했습니다—SME는 캠페인을, 에이전시는 클라이언트를 가지고 있었고, 데이터는 올바른 곳으로 흐르고 있었습니다.
적어도 그렇게 생각했습니다.
아마 20분 정도 스키마를 쳐다보며 앉아 있었습니다. 어떻게 이걸 놓쳤을까? 멀티테넌트 아키텍처를 구축하고, 83개의 RLS 정책을 작성하고, SME와 에이전시 계정 모두로 테스트하는 데 몇 주를 보냈습니다. 모든 것이 작동했습니다. 하지만 "작동한다"와 "올바르다"는 같은 것이 아닙니다.
이런 종류의 버그는 소프트웨어를 만들어야 하는지 자체를 의심하게 만듭니다. 오타가 아니기 때문입니다. 놓친 엣지 케이스가 아닙니다. 아키텍처적 미숙함입니다.
저는 전형적인 실수를 저질렀습니다: org\_id 필터링만으로 멀티테넌트 격리가 충분하다고 가정한 것입니다. 충분하지 않았습니다.
이것은 진정한 멀티테넌트 격리가 모든 테이블에 org\_id를 추가하는 것 이상을 필요로 한다는 것을 발견한 이야기—그리고 48시간에 걸친 33개의 마이그레이션으로 마침내 해결한 이야기입니다.
---
> **참고**: 이 게시물의 SQL 예제는 보안을 위해 일반화된 스키마와 테이블 이름(tenant\_b, workspace\_entities, entity\_data)을 사용합니다. 특정 명명 규칙에 관계없이 개념은 동일합니다.
---
문제: 모든 테넌트가 동일하지 않다
제가 처음에 구축한 것은 다음과 같습니다:
```sql
-- 브랜드 가이드라인 테이블 (SME와 에이전시가 공유)
CREATE TABLE brand_guidelines (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
name TEXT,
guidelines JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS 정책 (안전해 보임)
CREATE POLICY brand_guidelines_org_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
이것은 SME에게 완벽하게 작동합니다. 각 조직은 자체 브랜드 가이드라인을 가지고 있습니다. Row-Level Security가 서로의 데이터를 볼 수 없도록 보장합니다. (적어도, 그렇게 생각했습니다. 곧 더 자세히 설명하겠습니다.)
하지만 에이전시는 다릅니다.
에이전시는 하나의 브랜드 가이드라인 세트만 가지고 있지 않습니다. 클라이언트당 하나씩 가지고 있습니다:
- 클라이언트 A의 브랜드 가이드라인 (생생한 색상 팔레트, 대담한 타이포그래피, 혁신 중심 메시지)
- 클라이언트 B의 브랜드 가이드라인 (차분한 색상 팔레트, 미니멀 디자인, 품질 중심 포지셔닝)
같은 에이전시, 다른 클라이언트, 완전히 다른 브랜드.
단순한 해결책 (제가 처음에 구축한 것):
```sql
-- 공유 테이블에 entity_id 추가
ALTER TABLE brand_guidelines ADD COLUMN entity_id UUID;
-- RLS 정책 업데이트
CREATE POLICY brand_guidelines_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (
organization_id = get_user_org_id() AND
(entity_id IS NULL OR entity_id = get_user_entity_id())
);
```
문제: 이것은 두 가지 다른 데이터 모델을 가진 테이블을 만들었습니다:
```
SME 행: organization_id='org-123', entity_id=NULL, guidelines=\{...\}
에이전시 행: organization_id='org-456', entity_id='entity-a', guidelines=\{...\}
에이전시 행: organization_id='org-456', entity_id='entity-b', guidelines=\{...\}
```
쿼리는 복잡한 NULL 처리로 엉망이 되었고, 모든 기능에 애플리케이션 코드에서 "SME면 이렇게, 에이전시면 저렇게" 로직이 필요했습니다.
그리고 네, 이것들이 더 깊은 문제의 증상이라는 것을 깨닫기 전에 이 모든 것을 작성했습니다. 몇 주간의 작업, 모두 같은 결론을 가리키고 있었습니다: 아키텍처적으로 막다른 골목에 몰렸다는 것입니다.
모든 기능에 커스텀 로직이 필요했습니다: "SME면 이렇게 하세요. 에이전시면 저렇게 하세요."
더 나쁜 것은, 아키텍처가 잘못된 가정을 하고 있었다는 것입니다:
- 에이전시가 entity\_id=NULL로 쓰면 SME 데이터를 오염시킬 수 있었습니다
- SME는 하위 계정을 원하더라도 하위 엔터티를 가질 수 없었습니다
- 스키마는 nullable 컬럼으로 "스위스 치즈"가 되었습니다
이것은 멀티테넌트 아키텍처가 아니었습니다. 이것은 하나의 테이블이 두 가지 다른 데이터 모델을 서비스하려고 하는 것이었습니다.
---
깨달음: 다른 테넌트에는 다른 스키마가 필요하다
10월 말쯤, 저는 진실을 깨달았습니다: SME와 에이전시는 같은 데이터 모델을 공유하지 않습니다.
SME 데이터 모델:
```
organization → campaigns → agent_outputs
```
에이전시 데이터 모델:
```
organization → workspace_entities → campaigns → agent_outputs
↓
entity_data (예: 브랜드 가이드라인, 페르소나)
```
에이전시에는 SME에게 없는 전체 레이어(workspace entities)가 있습니다. 또한 SME 세계에서는 존재하지 않아야 하는 엔터티별 인텔리전스가 있습니다.
해결책: 별도의 데이터베이스 스키마.
```sql
-- SME 테이블 (public 스키마)
public.brand_guidelines
public.campaigns
public.outputs
-- 에이전시 테이블 (tenant_b 스키마)
tenant_b.workspace_entities
tenant_b.entity_data -- 브랜드 가이드라인, 페르소나 등 포함
tenant_b.campaigns
tenant_b.outputs
```
이제 SME와 에이전시는 완전히 다른 테이블을 가집니다. 공유 스키마 없음. nullable entity\_id 오염 없음. "SME면 이렇게, 에이전시면 저렇게" 로직 없음.
---
이것이 중요한 이유: 스키마 라우팅의 비즈니스 케이스
기술적 구현에 들어가기 전에, 이 아키텍처 결정이 "더 깔끔한 코드"를 넘어 왜 중요한지 이야기해 봅시다.
성장을 위한 미래 대비 (아마도)
스키마 라우팅은 오늘의 문제를 해결하는 것만이 아닙니다. 아직 예측할 수도 없는 기회를 위해 문을 열어두는 것입니다.
저는 아직 15명의 사용자로 비공개 알파 중입니다. 기업 고객이 없습니다. GDPR 변호사와 상담한 적도 없습니다. 하지만 STRAŦUM이 성장하면 스키마 라우팅이 가능하게 할 수 있는 것들은 다음과 같습니다:
국제 확장:
- EU로 확장할 경우: 별도의 스키마로 데이터 거주지를 가능하게 할 수 있음 (EU 서버의 eu\_agency 스키마에 EU 클라이언트 데이터)
- 삭제 권리가 더 간단해짐: 혼합 테이블을 필터링하는 것이 아니라 하나의 스키마를 쿼리
- 감사 추적: "클라이언트 X의 모든 데이터를 보여주세요" = 하나의 스키마 쿼리
컴플라이언스 대화:
- 누군가 결국 "데이터 격리를 어떻게 보장하나요?"라고 물을 때
- org\_id 필터링의 경우: "Row-Level Security 정책을 사용합니다" (모호하고, 검증하기 어려움)
- 스키마 라우팅의 경우: "각 클라이언트의 데이터는 별도의 데이터베이스 스키마에 있습니다" (구체적이고, 감사 가능)
- 이것이 중요한지 아직 모릅니다. 하지만 그런 대화를 하게 된다면 중요할 수 있습니다.
솔직한 진실:
저는 지금 HIPAA나 SOC 2 컴플라이언스를 위해 구축하고 있지 않습니다. 더 나은 마케팅 전략이 필요한 SME와 소규모 에이전시를 위해 구축하고 있습니다.
하지만 스키마 라우팅은 언젠가 누군가 "헬스케어 클라이언트를 처리할 수 있나요?" 또는 "데이터 거주지를 지원하나요?"라고 물으면, "네, 아키텍처를 보여드리겠습니다"라고 답할 수 있다는 것을 의미합니다. "먼저 모든 것을 다시 구축해야 합니다" 대신에.
단점 (솔직하게)
스키마 라우팅이 장점만 있는 것은 아닙니다. 실제로 비용이 드는 것들은 다음과 같습니다:
개발 복잡성:
- 모든 WRITE 작업에 라우터 함수가 필요
- 모든 READ 작업에 보안 뷰가 필요
- 테스트에 SME와 에이전시 경로 모두 필요
- Claude Code와 함께: 저녁 시간에 2일간의 집중 작업 (2025년 10월 27-29일)
- AI 도구 없이: 몇 주가 걸렸을 것
마이그레이션 위험:
- 33개의 순차적 마이그레이션 = 오타의 33번의 기회
- 하나의 잘못된 ALTER TABLE = 프로덕션 데이터 손상
- 프로덕션에 적용하기 전에 스테이징에서 각 마이그레이션을 3번씩 실행해야 했음
- 불안감은 실제였습니다
쿼리 성능 오버헤드:
- UNION ALL을 사용하는 뷰 = 약간 느린 읽기
- 라우터 함수 = 쓰기 시 추가 함수 호출
- RLS + 뷰 = 더 복잡한 쿼리 계획
- (실제로는: 아직 느려짐을 느끼지 못했지만, 알파 사용자도 15명뿐입니다)
운영 복잡성:
- 스키마 마이그레이션이 이제 2개 이상의 스키마에 영향 (public + agency)
- 데이터베이스 백업에 스키마 인식 복원 필요
- 모니터링 쿼리가 여러 스키마를 확인해야 함
- 이것은 결국 문제가 될 것이지만, 언제인지 모를 뿐입니다
왜 그래도 이 트레이드오프를 선택했는가
옵션 가치는 엄청날 수 있습니다. 또는 전혀 중요하지 않을 수도 있습니다.
스키마 라우팅은 제가 통과하고 싶은지도 확실하지 않은 문을 열어둡니다:
- 화이트 레이블 파트너십: 파트너에게 자체 스키마를 주고, UI를 리브랜딩할 수 있음
- 리셀러 기회: 에이전시가 입증 가능한 데이터 격리로 재판매할 수 있음
- 다른 가격 티어: "프리미엄" 고객에게 전용 스키마를 줄 수 있음
- 지리적 확장: EU 스키마, US 스키마, APAC 스키마 - 같은 코드베이스
여기에 있는 것은: 저는 비공개 알파 중입니다. 이것들 중 어떤 것이 중요할지 모릅니다. 화이트 레이블 요청을 받지 못할 수도 있습니다. 지리적 확장이 몇 년 뒤일 수도 있습니다. 비즈니스 전체가 피봇하고 이 중 아무것도 관련 없을 수도 있습니다.
하지만 제가 아는 것: 스키마 라우팅으로 이 옵션들이 존재합니다. org\_id 필터링으로는 대부분 완전한 재작성이 필요할 것입니다.
그것이 제가 한 베팅입니다: 지금 2일 더 투자하여 (Claude Code와 함께) 나중에 옵션을 열어두기.
올바른 베팅인가요? 1년 후에 물어보세요.
---
아키텍처: 스키마 라우팅
패턴 1: 스키마별 테이블
일부 테이블은 하나의 테넌트 유형에만 존재합니다:
```sql
-- 특수 테넌트 스키마
CREATE SCHEMA tenant_b;
-- 워크스페이스 엔터티 (이 테넌트 유형에 특화)
CREATE TABLE tenant_b.workspace_entities (
id UUID PRIMARY KEY,
organization_id UUID,
name TEXT,
metadata JSONB
);
-- 엔터티별 데이터
CREATE TABLE tenant_b.entity_data (
id UUID PRIMARY KEY,
organization_id UUID,
entity_id UUID REFERENCES tenant_b.workspace_entities(id),
data_type TEXT,
content JSONB
);
```
SME는 이 테이블에 절대 접근하지 않습니다. public 스키마에 존재하지 않습니다.
패턴 2: 데이터베이스 라우터 함수
올바른 스키마에 어떻게 쓸까요? **라우터 함수**.
개념은 다음과 같습니다 (간소화):
```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER -- 상승된 권한으로 실행
AS $$
BEGIN
-- 단계 1: 조직 유형 감지
SELECT type INTO org_type FROM organizations WHERE id = p_org_id;
-- 단계 2: 유형에 따라 올바른 스키마로 라우팅
IF org_type = 'TENANT_B' THEN
INSERT INTO tenant_b.entity_data (...) VALUES (...);
ELSE
INSERT INTO public.brand_guidelines (...) VALUES (...);
END IF;
RETURN result;
END;
$$;
```
작동 방식:
1. 조직 유형 감지: organizations 테이블을 쿼리하여 테넌트 유형 결정
2. 올바른 스키마로 라우팅: 유형에 따라 적절한 스키마에 쓰기
3. 결과 반환: 디버깅을 위해 어떤 스키마가 사용되었는지 포함
애플리케이션 코드 (모든 테넌트 유형에 동일):
```typescript
// 라우터 함수를 호출하기만 하면 됨 - 테넌트별 로직 없음
const result = await supabase.rpc('save_resource_routed', \{
p_org_id: orgId,
p_entity_id: entityId, // 단순 테넌트의 경우 null
p_data: { ... \}
});
```
애플리케이션 코드에 if/else 없음. 데이터베이스가 라우팅을 처리합니다.
첫 번째 라우터 함수를 작성하는 데 4시간이 걸렸습니다. 왜 작동하지 않는지 디버깅하는 데? 6시간 더. 문제? EXECUTE 권한을 부여하는 것을 잊었습니다. 전형적인 솔로 창업자 에너지: 아키텍처적 뛰어남, 권한 누락. :P
패턴 3: 읽기를 위한 Security-Invoker 뷰
쓰기는 라우터 함수를 사용합니다. 읽기는 **뷰**를 사용합니다.
```sql
-- 두 스키마를 결합하는 통합 뷰
CREATE VIEW resources_unified
WITH (security_invoker = on) -- RLS 정책을 존중
AS
SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
FROM public.brand_guidelines
UNION ALL
SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
FROM tenant_b.entity_data
WHERE data_type = 'brand_guidelines';
```
애플리케이션 코드 (통합 읽기):
```typescript
// 리소스 읽기 (모든 테넌트 유형에 작동)
const \{ data \} = await supabase
.from('resources_unified')
.select('*')
.eq('organization_id', orgId);
// RLS 정책이 소스 스키마에 관계없이 올바르게 필터링
```
핵심 세부사항: WITH (security\_invoker = on)은 RLS 정책이 적용되도록 보장합니다. 이것 없이는 뷰가 RLS를 우회합니다 (보안 재앙).
---
마이그레이션: 48시간 동안 33개의 마이그레이션
스키마 라우팅을 추가하는 것은 단일 마이그레이션이 아니었습니다. 여정이었습니다.
재미있는 게 뭔지 아시나요? 하나라도 오타가 있으면 프로덕션 데이터가 손상된다는 것을 알면서 33개의 데이터베이스 마이그레이션을 연속으로 작성하는 것. 사실, "재미있다"는 적절한 단어가 아닙니다. "무서운"이 더 정확합니다. 프로덕션에 적용하기 전에 스테이징에서 각 마이그레이션을 세 번씩 실행했습니다.
2025년 10월 27-29일: 완전한 스키마 라우팅을 위한 33개의 순차적 마이그레이션.
마이그레이션 단계:
1. 특수 스키마 생성 - 적절한 권한으로 tenant\_b 스키마 설정
2. 스키마별 테이블 생성 - 새 스키마에 필요한 테이블 미러링
3. 라우터 함수 구축 - 라우팅이 필요한 각 리소스 유형별 하나씩
4. 보안 뷰 생성 - 읽기를 위한 UNION ALL 통합 뷰
5. RLS 정책 업데이트 - 두 스키마 모두 적절한 격리 보장
6. 데이터 마이그레이션 - 기존 데이터를 올바른 스키마로 이동
7. 애플리케이션 업데이트 - 직접 쿼리에서 라우터 함수/뷰로 전환
총 노력: 33개의 마이그레이션, Claude Code와 함께 2일, 100% 가치가 있었습니다.
---
결과: 진정한 멀티테넌트 격리
이전 (org_id가 있는 공유 테이블)
데이터 모델:
```sql
public.brand_guidelines (organization_id, entity_id, guidelines)
```
문제점:
- ❌ 한 테넌트 유형에 대한 Nullable entity\_id (데이터 모델 혼란)
- ❌ NULL 처리가 있는 복잡한 쿼리
- ❌ 애플리케이션 로직: if (tenantTypeA) \{ ... \} else \{ ... \}
- ❌ 교차 오염 위험
이후 (스키마 라우팅)
데이터 모델:
```sql
public.brand_guidelines (organization_id, guidelines) -- 테넌트 유형 A
tenant_b.entity_data (organization_id, entity_id, data) -- 테넌트 유형 B
```
장점:
- ✅ 깔끔한 데이터 모델 (nullable 외래 키 없음)
- ✅ 복잡한 NULL 처리 없는 간단한 쿼리
- ✅ 애플리케이션 if/else 없음 (데이터베이스가 라우팅 처리)
- ✅ 교차 스키마 오염이 불가능 (물리적으로 분리)
보안 개선
이전: nullable 컬럼과 공유 테이블로 인한 교차 오염 위험
이후: 라우터 함수가 조직 유형에 따라 자동으로 쓰기를 올바른 스키마로 지시합니다. 물리적 스키마 분리로 교차 오염이 불가능합니다.
격리 수준: 데이터베이스가 강제하는 분리. 애플리케이션 수준 검사가 아닙니다.
---
언제 스키마 라우팅을 사용하고 언제 org_id 필터링을 사용할까
모든 멀티테넌트 앱에 스키마 라우팅이 필요한 것은 아닙니다. 이 의사결정 트리를 사용하세요:
org\_id 필터링을 사용하세요 (더 간단) 다음의 경우:
✅ 모든 테넌트가 같은 데이터 모델을 가진 경우 (예: 할 일 앱)
✅ 계층적 테넌시가 없는 경우 (조직 내 하위 엔터티 없음)
✅ 간단한 쿼리 (WHERE org\_id = X가 모든 곳에서 작동)
✅ B2C 또는 소규모 B2B (기업 판매 없음, 컴플라이언스 요구사항 없음)
✅ MVP 속도가 중요한 경우 (몇 달이 아닌 몇 주 안에 시장에 출시)
비즈니스 논리: 프로덕트-마켓 핏을 검증하는 것이지, Fortune 500 컴플라이언스 요구사항을 위해 구축하는 것이 아닙니다. 빠르게 출시하고, 기업 고객이 생기면 나중에 리팩토링하세요.
예시 모든 조직이 자체 프로젝트를 관리하는 프로젝트 관리 도구.
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
org_id UUID, -- 간단한 필터링
name TEXT
);
```
스키마 라우팅을 사용하세요 (더 복잡) 다음의 경우:
✅ 다른 테넌트 유형에 다른 데이터 모델이 필요한 경우 (유형 A vs 유형 B vs 유형 C)
✅ 계층적 테넌시 (조직 → workspace_entities → 하위 엔터티)
✅ 기업 판매가 로드맵에 있는 경우 (Fortune 500, 헬스케어, 금융, 정부)
✅ 규제 컴플라이언스가 필요한 경우 (GDPR, HIPAA, SOC 2, FedRAMP)
✅ 화이트 레이블 또는 리셀러 가능성 (파트너에게 완전한 데이터 격리 필요)
✅ 국제 확장 계획 (데이터 거주지 요구사항)
비즈니스 논리: 기업 고객을 타겟으로 한다면, "데이터 격리"는 보안 설문지의 체크박스가 됩니다. 스키마 라우팅으로 자신 있게 답할 수 있습니다. 행 수준 필터링은 애매하게 답하게 됩니다.
예시: 일부 테넌트가 직접 고객과 근본적으로 다른 계층적 워크스페이스 구조를 가진 플랫폼.
비용/효과 (제 경험):
- 스키마 라우팅 비용: Claude Code와 함께 2일 (AI 지원 없이는 몇 주가 걸렸을 것)
- 잠재적 이점: 더 깔끔한 아키텍처, 컴플라이언스 준비, 파트너십 옵션
- 실제 이점: 알 수 없음 - 아직 비공개 알파 중
- 손익분기점: 스키마 라우팅이 다른 방법으로는 불가능했을 하나의 문이라도 열어준다면, 투자 대비 효과가 있음
진짜 질문: 시장 출시 속도를 최적화하고 있습니까, 아니면 선택지를 최적화하고 있습니까? 둘 다 유효합니다. 저는 선택지를 선택했습니다.
---
대안적 격리 전략
스키마 라우팅이 유일한 접근 방식은 아닙니다. 스펙트럼은 다음과 같습니다:
레벨 1: 별도 데이터베이스 (가장 높은 격리)
```
database_tenant_1
database_tenant_2
database_tenant_3
```
장점:
- ✅ 완전한 물리적 격리
- ✅ 테넌트별 백업
- ✅ 독립적 스케일링
- ✅ 규제 컴플라이언스 (데이터 거주지)
단점:
- ❌ 높은 운영 복잡성 (N개의 데이터베이스 관리)
- ❌ 비쌈 (테넌트당 데이터베이스 인스턴스)
- ❌ 교차 테넌트 쿼리 불가능
- ❌ 모든 데이터베이스에 걸친 스키마 마이그레이션
사용 사례: 규제 요구사항이 있는 기업 SaaS, 고가치 고객 ($10k+/월).
레벨 2: 별도 스키마 (강력한 격리)
```
database
├── schema_tenant_1
├── schema_tenant_2
└── public (공유 테이블)
```
장점:
- ✅ 강력한 논리적 격리
- ✅ 공유 인프라 (하나의 데이터베이스)
- ✅ 스키마 수준 권한
- ✅ 테넌트 유형별 다른 데이터 모델
단점:
- ❌ 행 수준보다 더 복잡
- ❌ 라우터 함수 필요
- ❌ 마이그레이션 복잡성 (N개의 스키마)
사용 사례: 다른 고객 티어가 있는 B2B SaaS (STRAŦUM의 접근 방식).
레벨 3: RLS를 사용한 행 수준 필터링 (중간 격리)
```
database
└── public
└── table (org_id 컬럼 포함)
```
장점:
- ✅ 구현이 간단
- ✅ 쉬운 마이그레이션 (하나의 스키마)
- ✅ 교차 테넌트 분석 가능
- ✅ Postgres RLS가 격리를 강제
단점:
- ❌ 모든 테넌트가 같은 데이터 모델을 공유
- ❌ RLS 성능 오버헤드
- ❌ RLS 잘못된 설정 위험
사용 사례: 균일한 데이터 모델의 B2B SaaS (프로젝트 관리, CRM).
---
구현 체크리스트: 스키마 라우팅
스키마 라우팅을 구현하는 경우, 이 체크리스트를 사용하세요:
1단계: 스키마 설계
- [ ] 테넌트 유형 구분자 생성 (organizations.type)
- [ ] 스키마별 테이블 설계 (무엇이 어디에 속하는가?)
- [ ] 특수 스키마 생성: CREATE SCHEMA tenant\_b;
- [ ] 특수 스키마에 필요한 테이블 미러링
- [ ] 어떤 테이블이 어떤 스키마에 있는지 문서화
2단계: 라우터 함수
- [ ] 각 리소스 유형에 대한 라우터 함수 작성
- [ ] 상승된 권한을 위해 SECURITY DEFINER 사용
- [ ] 멀티 스키마 접근을 위해 search\_path = public, tenant\_b 설정
- [ ] 테넌트 유형 감지 처리: SELECT type FROM organizations
- [ ] 디버깅을 위한 스키마 정보 반환
- [ ] authenticated 역할에 EXECUTE 권한 부여
3단계: 보안 뷰
- [ ] 읽기를 위한 통합 뷰 생성 (스키마 간 UNION ALL)
- [ ] RLS 적용을 위해 WITH (security\_invoker = on) 사용
- [ ] 디버깅을 위한 source\_schema 컬럼 추가
- [ ] 뷰에서 RLS 정책이 작동하는지 테스트
- [ ] authenticated 역할에 SELECT 권한 부여
4단계: 애플리케이션 통합
- [ ] 쓰기를 라우터 함수로 업데이트: supabase.rpc('save\_resource\_routed', ...)
- [ ] 읽기를 뷰로 업데이트: supabase.from('resource\_unified').select()
- [ ] 애플리케이션 수준 if/else 로직 제거
- [ ] 테넌트 유형 A 흐름 테스트 (public에 쓰기)
- [ ] 테넌트 유형 B 흐름 테스트 (tenant\_b에 쓰기)
- [ ] 데이터 격리 검증 (엔터티 A ≠ 엔터티 B)
5단계: 마이그레이션 & 테스트
- [ ] 마이그레이션 스크립트 작성 (전체 커버리지를 위해 30개 이상)
- [ ] 실제와 유사한 데이터로 스테이징에서 테스트
- [ ] 보안 감사 실행 (교차 스키마 누출?)
- [ ] 부하 테스트 (RLS + 뷰 성능)
- [ ] 프로덕션에서 모니터링 (느린 쿼리?)
---
교훈
1. org_id는 필요하지만 충분하지 않다
모든 테이블에 org\_id를 추가하면 행 수준 필터링을 얻습니다. 하지만 다른 테넌트 유형이 다른 데이터 모델을 필요로 하면, 스키마 라우팅이 필요합니다.
교훈: "멀티테넌트"는 이분법이 아닙니다. 격리에는 레벨이 있습니다. 저는 현재 15명의 사용자에게 엄밀히 필요한 것보다 더 높은 레벨을 선택했습니다. 그것이 현명했는지 아니면 추가 작업이었는지는 시간이 말해줄 것입니다.
2. 애플리케이션 로직 → 데이터베이스 로직
애플리케이션의 모든 if (tenantType === 'TYPE\_B')는 코드 스멜입니다. 라우터 함수로 테넌트 인식 로직을 데이터베이스로 이동하세요.
교훈: 데이터베이스 함수는 작성하기 더 어렵지만 감사하기 더 쉬울 수 있습니다. 기업 고객이 "데이터 격리를 증명하세요"라고 하면, 저장 프로시저를 가리킬 수 있습니다. 하지만 지금은 가설적입니다.
3. 뷰 + RLS = 통합 읽기
여러 스키마에서 읽는 것은 복잡합니다. 뷰 + security\_invoker = on이 적절한 격리와 함께 통합 읽기를 제공합니다.
교훈: 뷰는 언젠가 컴플라이언스를 더 쉽게 만들 수 있는 추상화 레이어를 만듭니다. 또는 필요하지 않은 복잡성만 추가할 수도 있습니다. 두고 봐야 합니다.
4. Security Definer 함수는 강력하다
SECURITY DEFINER는 RLS 정책을 존중하면서도 상승된 권한으로 함수를 실행하게 합니다. 라우터 함수에 필수적입니다.
5. 마이그레이션은 가치가 있다 (아마도)
스키마 라우팅을 위한 33개의 마이그레이션은 많이 느껴졌습니다. 하지만 결과는? 깔끔한 아키텍처, 진정한 격리, 교차 테넌트 버그 제로.
교훈: 기술 부채에는 대가가 있습니다. 저는 유료 고객이 있을 때까지 기다리는 대신 15명의 알파 사용자가 있을 때 일찍 지불하기로 했습니다. 올바른 결정이었나요? 걱정할 고객 100명이 생기면 알게 될 것입니다.
6. 아키텍처 결함은 코드 버그보다 더 아프다
오후 11시 47분에 null pointer exception을 발견하는 것? 짜증납니다. 전체 멀티테넌트 아키텍처가 근본적으로 잘못되었다는 것을 발견하는 것? 밤새 뒤척이게 만드는 종류의 발견입니다.
하지만 제가 배운 것: 아키텍처 실수는 고칠 수 있습니다. 비용이 많이 들고, 시간이 많이 걸립니다. 하지만 "한 테넌트 유형이 실수로 다른 테넌트의 데이터를 오염시킬 수 있는" 상태에서 "우회가 불가능한 데이터베이스 강제 격리"로 갔습니다.
일찍 고치고, 올바르게 고치면, 더 잘 잘 수 있습니다.
7. 아키텍처는 전략일 수 있다 (또는 과도한 엔지니어링일 수도)
스키마 라우팅 결정은 단지 "깔끔한 코드"에 관한 것이 아니었습니다. 미래 옵션을 열어두는 것이었습니다—화이트 레이블 파트너십, 기업 판매, 국제 확장.
하지만 솔직한 진실: 저는 15명의 사용자로 비공개 알파 중입니다. 기업 고객이 찾아오지 않습니다. GDPR 질문을 단 하나도 받지 못했습니다. 제가 상상하는 파트너십은 실현되지 않을 수도 있습니다.
기술적 결정을 한다고 생각했습니다. 비즈니스 전략 결정을 한 것일 수도 있습니다. 아니면 데이터베이스 아키텍처가 흥미로워서 과도하게 엔지니어링한 것일 수도 있습니다. :)
버그가 아니라 설계 실수인 아키텍처 결함을 발견한 적이 있으신가요? 재구축을 어떻게 처리하셨나요—점진적으로 수정하셨나요, 아니면 저처럼 전부 뜯어내셨나요?
감사합니다,
Chandler
STRAŦUM 아키텍처 시리즈: 이것은 멀티테넌시 여정의 2부입니다. 2일차에 멀티테넌시 구축하기에서 시작했습니다. 스키마 재구축 후, 잃어버린 내비게이션 컨텍스트로 인한 31개의 빈 화면을 발견했고 데이터베이스가 정확하지만 296배 느리다는 것을 발견했습니다.
---
복잡한 격리가 필요한 멀티테넌트 SaaS를 구축하고 계신가요? STRAŦUM은 스키마 라우팅을 사용하여 다른 테넌트 유형에 진정한 데이터 격리를 제공합니다. https://stratum.chandlernguyen.com/request-invitation에서 알파 액세스를 요청하세요.
---
*"멀티테넌트"에는 많은 격리 레벨이 있다는 것을 여전히 배우고 있습니다. 여전히 자정에 RLS 정책을 디버깅하고 있습니다. 여전히 2일차 아키텍처 결정에 의문을 품고 있습니다 (하지만 이전보다는 덜). 더 많은 데이터베이스 모험은 https://www.chandlernguyen.com/ 에서.
---





