여러 회사를 지원하는 SaaS의 데이터는 어떻게 분리해야 할까?

한 애플리케이션을 여러 회사가 함께 사용하는 SaaS에서는 로그인보다 테넌트 경계가 더 중요하다. 사용자가 정상적으로 로그인했더라도 다른 회사의 계약, 직원, 파일, 검색 결과를 볼 수 있다면 인증은 성공했지만 데이터 격리는 실패한 것이다.

이 글에서는 서비스를 이용하는 회사를 테넌트라고 부른다. 데이터 격리의 목표는 모든 고객을 무조건 별도 서버에 넣는 것이 아니다. 고객 간 접근을 막고, 운영 비용과 복구 요구를 감당할 수 있는 수준으로 경계를 선택하는 것이다.

OWASP는 멀티테넌시의 주요 위험으로 테넌트 간 데이터 노출, 잘못된 설정, 자원 경합과 테넌트별 감사 기록 부족을 꼽는다. OWASP Multi-Tenant Security

데이터 분리 방식은 네 가지가 있다

방식 | 격리 수준 | 운영 복잡도 | 잘 맞는 상황
공유 DB, 공유 스키마 | 논리적 격리 | 낮음 | 초기 B2B SaaS, 동일한 기능과 스키마
공유 DB, 테넌트별 스키마 | 스키마 단위 격리 | 중간 이상 | 고객별 구조 차이가 작고 테넌트 수가 제한적일 때
테넌트별 DB 또는 프로젝트 | 물리적 경계가 분명함 | 높음 | 규제, 지역, 백업과 성능을 고객별로 보장해야 할 때
혼합형 | 고객 등급에 따라 다름 | 가장 높음 | 일반 고객은 공유하고 일부 고객만 전용 환경이 필요할 때

공유 스키마에서는 모든 업무 테이블에 organization_id 같은 테넌트 식별자를 둔다. 비용이 낮고 배포가 단순하지만 하나의 누락된 조건이 여러 고객에게 영향을 줄 수 있다.

테넌트별 스키마는 이름 공간을 나눌 수 있지만 테넌트가 늘 때마다 마이그레이션과 연결 관리가 어려워진다. 테넌트별 DB는 강한 경계를 제공하지만 배포, 백업, 모니터링, 비용을 고객 수만큼 관리해야 한다.

대부분의 작은 B2B SaaS는 공유 DB와 공유 스키마로 시작할 수 있다. 다만 애플리케이션의 WHERE 조건만 믿지 않고 DB의 RLS, 제약 조건과 부정 테스트를 함께 사용하는 것이 전제다.

기본 데이터 모델

최소한 다음 개념을 분리한다.

organizations: 서비스를 이용하는 회사

organization_members: 사용자와 회사의 소속, 역할, 상태

invitations: 초대받은 이메일, 대상 회사, 만료와 사용 상태

업무 테이블: organization_id를 가진 계약, 문서, 프로젝트, 결재

audit_logs: 회사별 주요 변경과 관리자 접근 기록

한 사용자가 여러 회사에 속할 수 있다는 점을 처음부터 허용하면 이메일 주소를 회사의 식별자로 잘못 사용하는 일을 줄일 수 있다. 현재 선택한 회사는 화면 상태일 뿐, 권한의 근거는 organization_members의 활성 소속이어야 한다.

테넌트 식별자를 모든 관계에 포함한다

업무 테이블에 organization_id만 추가하고 끝내면 관계를 통해 다른 회사의 데이터를 연결할 수 있다. 예를 들어 A 회사의 프로젝트에 B 회사의 고객 ID를 저장할 수 있다면 직접 조회가 막혀도 데이터 경계는 깨진다.

다음 원칙을 적용한다.

모든 테넌트 소유 테이블에 organization_id를 NOT NULL로 둔다.

회사 안에서만 고유한 값은 organization_id를 포함한 복합 UNIQUE로 만든다.

부모와 자식의 외래 키에도 organization_id를 포함해 서로 다른 회사의 행이 연결되지 않게 한다.

자주 쓰는 조회와 RLS 조건을 위해 organization_id와 주요 상태에 인덱스를 둔다.

전역 데이터와 테넌트 데이터를 이름과 스키마 수준에서 구분한다.

ID가 충분히 무작위라는 사실은 접근 제어가 아니다. 다른 회사의 ID를 추측하기 어렵더라도 DB가 권한을 확인해야 한다.

RLS를 테넌트 경계의 마지막 방어선으로 둔다

PostgreSQL RLS는 일반 권한에 더해 사용자별로 조회와 변경 가능한 행을 제한한다. RLS를 활성화한 테이블에 적용 가능한 정책이 없으면 기본적으로 행 접근이 거부된다. 다만 테이블 소유자와 BYPASSRLS 권한을 가진 역할은 일반적으로 정책을 우회하므로 운영 코드의 DB 역할도 확인해야 한다. PostgreSQL Row Security Policies

정책은 대략 다음 질문에 답해야 한다.

SELECT: 현재 사용자가 이 organization_id의 활성 구성원인가?

INSERT: 새 행의 organization_id가 사용자의 소속 회사인가?

UPDATE: 기존 행을 볼 수 있고 변경 후에도 같은 회사에 남는가?

DELETE: 해당 회사에서 삭제 역할을 가진 사용자인가?

조회 조건만 두고 INSERT와 UPDATE의 WITH CHECK를 빠뜨리면 사용자가 다른 회사 ID로 새 행을 만들거나 소유 회사를 바꾸는 문제가 생길 수 있다. 명령별로 정책을 나누고 읽기와 쓰기 요구를 따로 시험하는 편이 명확하다.

Supabase는 사용자에게 노출되는 테이블에 RLS를 적용하고 필요한 최소 권한만 허용하도록 안내한다. 정책에서 자주 확인하는 열에는 인덱스를 두고, 복잡한 멤버십 조회가 병목이 되면 검증된 security definer 함수를 비공개 스키마에 두는 방식을 검토할 수 있다. Supabase RLS 가이드

security definer 함수는 생성자의 권한으로 실행되므로 작은 권한 확인 기능으로 제한하고 search_path와 실행 권한을 명시한다. API에 노출된 스키마에 두지 않는다.

service role은 RLS의 예외가 아니라 별도 위험이다

Supabase의 secret 및 service role 키는 RLS를 우회한다. 브라우저에 노출해서는 안 되고, 서버에서도 일반 사용자 요청을 처리하는 클라이언트와 분리한다. Supabase service role과 RLS

백그라운드 워커가 service role을 사용한다면 작업 메시지에 organization_id와 작업 ID를 넣고, DB에 기록된 작업과 회사가 일치하는지 다시 확인한다. 로그, 캐시, 생성된 파일에도 같은 테넌트 문맥을 유지한다. service role로 실행된다는 이유로 요청에서 받은 organization_id를 그대로 신뢰하면 안 된다.

파일도 DB와 같은 경계를 사용한다

DB 행만 분리하고 첨부 파일을 공개 버킷에 두면 격리가 완성되지 않는다. 객체 키는 organizations/회사ID/무작위파일ID처럼 회사 경계를 포함하고, 원본 파일명은 메타데이터로 보관한다.

Supabase Storage는 storage.objects 테이블의 RLS 정책으로 업로드, 조회, 수정, 삭제를 제한한다. service key는 Storage의 RLS도 전부 우회하므로 서버에서만 사용해야 한다. Supabase Storage Access Control

비공개 문서는 짧게 유효한 서명 URL로 제공하고, 사용자가 URL을 요청할 때마다 현재 회사 소속과 파일의 organization_id를 확인한다. 파일 이동, 삭제, 검사 상태는 DB 메타데이터와 함께 관리한다.

캐시, 검색과 실시간 기능도 테넌트별로 나눈다

데이터 유출은 DB 쿼리 밖에서도 발생한다.

캐시 키에 organization_id를 포함한다.

검색과 벡터 검색의 모든 쿼리에 테넌트 필터를 강제한다.

실시간 채널은 회사 ID를 포함한 비공개 채널로 만들고 구독 권한을 확인한다.

파일 변환 결과와 내보내기 파일의 저장 경로를 회사별로 나눈다.

오류 로그와 분석 도구에서 고객 데이터를 가리고 테넌트별 열람 권한을 제한한다.

이메일과 webhook 작업이 어느 회사의 설정을 사용할지 작업 생성 시 확정한다.

캐시 적중률을 높이려고 회사 식별자를 빼거나, 관리자 검색 기능에서 테넌트 조건을 선택 사항으로 만들면 우회 경로가 생긴다.

초대, 이동과 퇴사 상태를 명확히 한다

초대 토큰은 대상 회사, 초대 역할, 만료 시각, 사용 시각을 가진다. 한 번 사용한 토큰은 다시 쓸 수 없게 하고, 이미 다른 회사에 가입한 사용자도 새 회사의 멤버로 추가될 수 있게 한다.

구성원 상태는 초대됨, 활성, 정지, 탈퇴처럼 구분한다. 정지나 탈퇴 즉시 새 요청이 거부돼야 하고, 긴 세션과 실시간 연결도 적절한 시간 안에 권한 변경을 반영해야 한다. 소유 문서, 예약 작업과 승인 대기 건은 새 담당자에게 이전한다.

운영자 접근에는 이유와 만료가 필요하다

고객 지원을 위해 모든 회사 데이터를 볼 수 있는 영구 관리자 계정을 만드는 것은 편리하지만 위험하다. 운영자 접근은 필요한 회사와 기능에만, 정해진 시간 동안 허용하고 사유와 승인자를 기록한다.

사용자 대신 화면을 보는 기능은 현재 운영자와 대상 사용자, 시작과 종료 시각을 명확히 표시한다. 이 상태에서 이루어진 조회, 변경, 파일 다운로드를 감사 로그에 남기고 고객 사용자의 행위와 구분한다.

공유 DB에서는 고객 한 곳만 복원하기 어렵다

공유 DB 백업은 전체 서비스를 복구하기에는 효율적이지만 특정 회사의 행만 과거 시점으로 되돌리는 일은 복잡하다. 관련 테이블, 파일, 감사 기록의 시점을 맞춰야 하고 다른 회사의 변경은 유지해야 하기 때문이다.

중요 고객에게 회사별 복구가 필요하다면 테넌트 단위 논리 내보내기와 재가져오기 절차를 별도로 설계하고 시험한다. 고객별 보존, 내보내기, 계약 종료 후 삭제 요구가 강하다면 전용 DB나 프로젝트가 더 적합할 수 있다. 사내 시스템 백업과 복구 설계

반드시 실행할 테넌트 격리 테스트

테넌트 A와 B, 두 회사의 사용자와 데이터를 동시에 만든다. 성공해야 하는 시험보다 실패해야 하는 시험이 중요하다.

A 사용자가 B의 행을 ID로 직접 조회할 수 없는가?

A 사용자가 B의 organization_id로 INSERT하거나 UPDATE할 수 없는가?

A의 자식 행에 B의 부모 ID를 연결할 수 없는가?

A 사용자가 B의 파일 목록, 다운로드 URL, 삭제 API에 접근할 수 없는가?

검색, 내보내기, 실시간 채널과 알림에 B의 데이터가 섞이지 않는가?

초대 취소와 구성원 정지 직후 접근이 차단되는가?

service role을 사용하는 워커가 잘못된 회사의 작업을 처리하지 않는가?

새 테이블을 추가했을 때 RLS와 organization_id 누락을 자동 검증하는가?

브라우저 화면만 시험하지 않고 REST, RPC, Storage API를 직접 호출해 우회 경로를 확인한다. 테이블 소유자나 service role로 실행한 테스트는 일반 사용자에게 적용되는 RLS 검증을 대신하지 못한다.

언제 전용 DB로 분리할까?

다음 신호가 겹치면 일부 고객을 전용 환경으로 옮기는 혼합형을 검토한다.

고객별 백업, 복구 시점과 데이터 삭제를 독립적으로 보장해야 한다.

특정 지역에 데이터를 보관해야 한다.

한 고객의 부하가 다른 고객의 응답 시간에 영향을 준다.

고객별 확장 기능이나 DB 버전이 필요하다.

계약상 강한 물리적 격리와 전용 암호화 키가 필요하다.

전용 DB는 보안 정책을 자동으로 해결하지 않는다. 배포 누락, 오래된 스키마, 백업 실패를 고객 수만큼 관리해야 한다. 공유형에서 전용형으로 이동할 가능성이 있다면 전역 ID, organization_id, 데이터 내보내기 형식을 처음부터 유지한다.

듀오랩스가 보는 현실적인 기본안

초기 B2B SaaS에는 공유 DB와 공유 스키마, 모든 업무 행의 organization_id, DB RLS, 복합 제약 조건, 비공개 파일 정책을 기본안으로 삼을 수 있다. 여기에 테넌트 간 부정 테스트, service role 분리, 테넌트별 감사 로그와 사용량 측정을 반드시 포함한다.

고객 수가 늘면 먼저 DB를 나누기보다 느린 정책과 인덱스, 자원 사용량, 고객별 복구 요구를 관측한다. 물리적 격리가 필요한 고객만 전용 환경으로 옮길 수 있게 경계를 유지하면 초기 운영 비용과 장기 확장성을 함께 관리할 수 있다.

멀티테넌트 데이터 격리는 organization_id 열 하나의 문제가 아니다. DB 행, 파일, 캐시, 검색, 실시간 채널, 워커, 로그와 운영자 접근에서 같은 회사 경계를 반복해서 확인하는 설계 원칙이다.