본문 바로가기
서비스 웹 서비스 개발 앱 개발 AI 알고리즘 개발 AI 컨설팅 AI 마케팅 프로젝트 인사이트 회사소개 고객게시판
SaaS 아키텍처 10 MIN READ

한 SaaS에 여러 고객을 담아도 안전할까? 멀티테넌트 데이터 격리 설계

멀티테넌트 SaaS에서 tenant_id 조건 하나의 누락은 다른 고객의 데이터 노출로 이어질 수 있습니다. 공유 테이블·스키마·전용 DB 선택부터 RLS, 캐시·파일·큐 격리와 테스트까지 정리합니다.

Category
SaaS 아키텍처
Published
Read
10분
Author
Nature Fractal
한 SaaS에 여러 고객을 담아도 안전할까? 멀티테넌트 데이터 격리 설계 대표 이미지

B2B SaaS 하나에 수백 개 회사가 가입했다고 생각해 보겠습니다. 모든 고객이 같은 애플리케이션을 사용하지만 주문, 문서, 직원과 결제 정보는 절대 섞여서는 안 됩니다. 그런데 목록 API의 SQL에서 tenant_id 조건 하나가 빠지거나 캐시 키에 테넌트가 포함되지 않으면 다른 회사의 데이터가 화면에 나타날 수 있습니다.

멀티테넌시(Multi-tenancy)는 하나의 시스템이 여러 고객 조직, 즉 테넌트를 서비스하면서 데이터와 설정, 사용량을 논리적으로 분리하는 구조입니다. 인프라를 공유해 비용과 운영 효율을 얻는 대신, 고객 간 경계를 애플리케이션과 데이터 계층이 일관되게 지켜야 합니다.

좋은 멀티테넌트 설계는 데이터베이스에 tenant_id를 추가하는 데서 끝나지 않습니다. 인증, 캐시, 메시지 큐, 파일, 검색 인덱스, 로그, 백업과 고객 삭제까지 모든 경계에 같은 테넌트 문맥이 전달되어야 합니다. 이번 글에서는 격리 모델 선택부터 운영과 테스트까지 살펴보겠습니다.

테넌트와 사용자는 같은 개념이 아니다

사용자는 사람 또는 서비스 계정이고 테넌트는 데이터와 계약의 소유 조직입니다. 한 사용자가 여러 회사 워크스페이스에 속할 수 있고, 같은 회사 안에서도 관리자와 일반 직원의 권한이 다를 수 있습니다. 따라서 user_id만으로 데이터 범위를 결정하면 안 됩니다.

요청마다 현재 tenant_id와 user_id, 역할, 권한을 함께 결정해야 합니다. 사용자가 화면에서 테넌트를 전환할 때는 해당 테넌트의 구성원인지 서버가 검증하고 새로운 문맥을 발급합니다. 클라이언트가 보낸 tenant_id를 그대로 신뢰하지 않습니다.

인증은 “누구인가”를 확인하고, 테넌트 멤버십은 “어느 조직에 속하는가”, 권한은 “그 조직 안에서 무엇을 할 수 있는가”를 결정합니다. 이 세 단계를 분리하면 경계가 명확해집니다.

격리는 데이터만의 문제가 아니다

멀티테넌트 격리에는 여러 차원이 있습니다.

  • 데이터 격리: 다른 테넌트의 행, 파일, 검색 결과를 읽거나 변경할 수 없어야 합니다.
  • 성능 격리: 한 고객의 대량 작업이 다른 고객의 응답 시간을 무너뜨리지 않아야 합니다.
  • 보안 격리: 암호화 키, 비밀과 네트워크 접근 범위를 고객 정책에 맞게 나눕니다.
  • 운영 격리: 특정 고객만 복구·삭제·이전하거나 규제 지역에 배치할 수 있어야 합니다.
  • 장애 격리: 한 테넌트의 잘못된 설정과 데이터가 전체 서비스를 중단시키지 않아야 합니다.

어떤 모델이 “가장 안전하다”는 단일 답은 없습니다. 필요한 격리 수준, 고객 수, 데이터 규모, 비용, 규제와 운영 자동화 능력을 함께 판단해야 합니다.

공유 테이블, 별도 스키마, 전용 DB의 차이

대표적인 데이터 배치 모델은 세 가지입니다.

하나의 공유 테이블, 테넌트별 별도 스키마, 테넌트별 전용 데이터베이스 격리 모델 비교
왼쪽에서 오른쪽으로 갈수록 물리적 격리는 강해지지만 프로비저닝·마이그레이션·운영 대상 수도 증가합니다.

공유 DB·공유 테이블은 모든 테넌트의 행을 같은 테이블에 저장하고 tenant_id로 구분합니다. 고객 수가 많아도 스키마와 연결 풀이 단순하고 자원 효율이 높습니다. 반면 조건 누락의 영향이 크고 개별 고객 복구와 데이터 이동이 어렵습니다.

공유 DB·테넌트별 스키마는 한 DB 인스턴스 안에서 스키마를 나눕니다. 이름 공간과 일부 권한을 분리할 수 있지만 테넌트 수만큼 마이그레이션 대상이 늘고 연결의 search_path 관리가 복잡해집니다. 수천 개 스키마에서는 메타데이터와 운영 도구의 한계도 확인해야 합니다.

테넌트별 전용 DB는 강한 데이터·백업·성능 격리를 제공합니다. 대형 고객의 규제와 전용 암호화 키, 개별 복구에 유리합니다. 그러나 프로비저닝, 연결 라우팅, 패치와 마이그레이션을 자동화하지 않으면 운영 비용이 급격히 커집니다.

실무에서는 모든 고객을 한 방식으로만 담지 않는 하이브리드 모델도 많습니다. 소규모 고객은 공유 샤드에 두고, 큰 고객이나 규제 대상은 전용 DB로 이동합니다. 처음부터 tenant placement를 논리 계층으로 감싸면 저장 위치가 바뀌어도 애플리케이션을 크게 수정하지 않을 수 있습니다.

공유 테이블에서는 tenant_id가 모든 키의 일부다

공유 테이블을 선택했다면 업무 데이터의 모든 행에 null이 아닌 tenant_id가 있어야 합니다. 외래키도 단순히 project_id만 참조하지 않고 (tenant_id, project_id) 조합을 사용하면 다른 테넌트의 부모 행을 잘못 연결하는 것을 DB가 막을 수 있습니다.

PRIMARY KEY (tenant_id, id)
FOREIGN KEY (tenant_id, project_id)
  REFERENCES projects (tenant_id, id)

이메일이 테넌트 안에서만 고유하다면 unique 제약도 (tenant_id, email)이어야 합니다. 반대로 전역 고유성이 필요한 도메인은 명확히 구분합니다. 인덱스는 자주 조회하는 조건에 tenant_id를 앞부분에 포함해 한 테넌트의 범위를 빠르게 좁히되 실제 쿼리와 데이터 분포로 실행 계획을 확인합니다.

tenant_id가 없는 전역 테이블도 있을 수 있습니다. 국가 코드와 공통 상품 카탈로그처럼 정말 모든 고객이 공유하는 데이터인지 명시하고, 테넌트 테이블과 섞이지 않게 네이밍과 접근 계층을 구분합니다.

요청의 tenant context는 인증된 경계에서 만든다

HTTP 헤더의 X-Tenant-Id나 URL의 tenant slug는 사용자가 바꿀 수 있는 입력입니다. 서버는 인증된 주체가 요청한 테넌트에 속하는지 확인한 뒤 내부 TenantContext를 생성합니다. 이후 애플리케이션 서비스와 데이터 접근 계층은 이 문맥을 명시적으로 받아야 합니다.

전역 변수나 재사용되는 스레드 로컬에 테넌트 정보를 저장하면 비동기 처리와 연결 재사용에서 이전 요청의 값이 남을 수 있습니다. 요청 범위 컨텍스트를 사용하고 작업이 끝날 때 반드시 정리하며, 백그라운드 작업에는 명시적으로 tenant_id를 전달합니다.

인증에서 생성한 테넌트 문맥이 애플리케이션, 큐, 캐시, DB, 파일과 로그 경계마다 검증되는 흐름
tenant context는 요청 시작에 한 번 붙이는 장식이 아니라 모든 시스템 경계에서 전달하고 다시 검증해야 하는 보안 문맥입니다.

다른 내부 서비스로 호출할 때도 클라이언트가 준 헤더를 단순 전달하지 않습니다. 서비스 간 인증된 토큰이나 서명된 문맥에 tenant_id와 호출 주체를 포함하고, 수신 서비스가 권한을 다시 확인합니다.

ORM 전역 필터만 믿으면 위험하다

ORM의 global query filter나 default scope는 tenant 조건 누락을 줄여줍니다. 하지만 raw SQL, 관리용 repository, bulk update, join과 테스트 설정에서 우회될 수 있습니다. 개발자가 필터 존재를 잊은 채 쿼리를 작성하면 코드 리뷰에서도 위험을 놓칠 수 있습니다.

데이터 접근 API 자체가 tenant context 없이는 테넌트 데이터를 조회할 수 없도록 설계하고, 반환 객체도 현재 tenant와 일치하는지 방어적으로 확인합니다. findById(id)보다 findById(tenantId, id)처럼 경계를 시그니처에 드러내는 편이 안전합니다.

관리자용 cross-tenant 조회는 일반 repository에서 조건을 끄는 옵션으로 제공하지 않고 별도 권한과 별도 인터페이스로 격리합니다. 우회 경로는 감사 로그와 추가 승인을 요구해야 합니다.

Row-Level Security는 강력한 두 번째 방어선이다

PostgreSQL의 Row-Level Security(RLS) 같은 기능은 현재 DB 세션의 tenant context에 따라 읽고 쓸 수 있는 행을 제한합니다. 애플리케이션 쿼리에서 조건이 빠져도 DB가 다른 테넌트 행을 반환하지 않게 할 수 있습니다.

그러나 RLS도 설정만 켠다고 끝나지 않습니다. 애플리케이션 역할이 정책을 우회할 권한을 갖지 않는지, 테이블 소유자와 관리자 역할의 동작이 무엇인지 확인합니다. INSERT와 UPDATE에는 조회 정책뿐 아니라 새 행의 tenant_id가 현재 문맥과 같은지 WITH CHECK 정책이 필요합니다.

연결 풀에서는 한 요청이 설정한 세션 변수가 다음 요청으로 누출될 수 있습니다. 트랜잭션 범위의 설정을 사용하고 commit·rollback 뒤 초기화되는지 테스트합니다. tenant context를 설정하기 전에 쿼리가 실행되지 않도록 연결 래퍼를 둡니다.

RLS는 애플리케이션 검사를 대체하는 마법이 아니라 심층 방어입니다. 명시적 필터, DB 제약과 RLS를 함께 사용하면 하나의 실수가 바로 유출로 이어지는 것을 막을 수 있습니다.

캐시 키에도 tenant_id가 반드시 들어간다

project:123이라는 캐시 키는 서로 다른 테넌트가 같은 project ID를 사용할 때 충돌합니다. tenant:{tenantId}:project:{projectId}처럼 테넌트 범위를 키에 포함하고, 사용자 권한이나 언어가 결과를 바꾸면 그것도 반영해야 합니다.

태그 기반 무효화와 목록 캐시도 테넌트 경계를 가져야 합니다. 한 고객의 프로젝트 수정이 다른 고객의 캐시를 삭제하거나, 관리자 권한으로 생성한 응답이 일반 사용자에게 재사용되지 않게 합니다.

프로세스 내부 L1 캐시, CDN과 브라우저 캐시도 예외가 아닙니다. private 응답을 공용 CDN 키로 저장하거나 Host·인증 문맥을 캐시 키에서 빼면 데이터가 섞일 수 있습니다.

파일과 검색 인덱스도 독립된 데이터 저장소다

오브젝트 스토리지 경로에 tenant_id를 포함하고 서버가 권한을 확인한 뒤 정확한 경로에만 짧은 signed URL을 발급합니다. 버킷 이름이나 키가 추측하기 어렵다는 사실은 접근 제어가 아닙니다. 저장소 정책과 애플리케이션 검증을 함께 사용합니다.

검색 엔진에서는 모든 문서에 tenant_id를 색인하고 서버가 필수 필터를 강제합니다. 사용자가 보내는 검색 DSL을 그대로 전달하면 필터를 제거하거나 다른 인덱스를 조회할 수 있으므로 허용된 쿼리만 구성합니다. 고위험 환경에서는 테넌트별 인덱스나 별도 클러스터를 사용할 수 있습니다.

벡터 검색도 동일합니다. 임베딩 자체가 원문의 정보를 포함할 수 있고 metadata filter 누락으로 다른 고객 문서가 검색될 수 있습니다. RAG 응답 생성 전에 검색 결과의 tenant_id와 권한을 재검증합니다.

메시지와 백그라운드 작업에는 문맥을 복사한다

HTTP 요청이 끝난 뒤 실행되는 이메일, 보고서 생성과 데이터 동기화 작업에는 자동으로 요청 context가 남지 않습니다. 메시지 payload나 신뢰할 수 있는 envelope에 tenant_id, actor와 correlation ID를 포함합니다.

소비자는 메시지를 받자마자 테넌트가 활성 상태인지, 작업 대상이 같은 테넌트에 속하는지 확인합니다. object ID만 받아 전역 조회한 뒤 처리하면 다른 테넌트 데이터를 수정할 수 있습니다. 재시도와 DLQ에서도 테넌트 문맥을 잃지 않아야 합니다.

한 큐에 모든 테넌트를 넣을 수 있지만 특정 대형 고객의 작업이 전체 소비자를 점유할 수 있습니다. 테넌트별 동시성, 공정한 스케줄링과 중요도별 큐를 사용해 성능을 격리합니다.

Noisy Neighbor는 보안만큼 중요한 격리 문제다

한 고객이 대규모 CSV를 내보내거나 잘못된 API 루프를 실행하면 공유 CPU, DB 연결, 캐시와 큐를 소비합니다. 다른 고객이 느려지면 데이터 유출은 없어도 SaaS 신뢰가 무너집니다.

테넌트별 API rate limit, 동시 작업 수, 저장 공간과 쿼리 비용에 quota를 둡니다. 전체 시스템 상한과 테넌트 상한을 함께 두어 한 고객이 모든 여유를 사용하지 못하게 합니다. 대량 보고서는 실시간 API와 별도 작업 풀에서 처리합니다.

대형 고객이 지속적으로 공유 샤드를 압박하면 전용 DB나 별도 샤드로 이동할 수 있어야 합니다. placement map으로 tenant가 어느 데이터 위치에 있는지 관리하고 애플리케이션이 이를 통해 라우팅합니다.

암호화 키의 격리 수준도 선택한다

모든 데이터를 플랫폼 공통 키로 암호화할 수도 있고, 테넌트별 envelope encryption key를 사용할 수도 있습니다. 고객 관리 키(BYOK)나 전용 KMS 키를 요구하는 계약도 있습니다. 격리가 강할수록 키 수명 주기와 비용, 캐시 관리가 복잡해집니다.

테넌트별 키는 특정 고객 데이터의 암호학적 폐기와 접근 감사에 유리합니다. 하지만 키가 비활성화됐을 때 백그라운드 작업과 백업 복구가 어떻게 동작하는지 설계해야 합니다. 키 식별자와 암호화 컨텍스트가 다른 테넌트로 바뀌지 않게 합니다.

백업과 복구 단위가 제품 약속을 결정한다

공유 DB의 전체 백업은 쉽지만 고객 한 곳의 데이터를 특정 시점으로 복구하기 어렵습니다. DB 전체를 되돌리면 다른 고객의 최신 데이터가 사라집니다. 테넌트별 복구가 계약에 포함된다면 논리 export, 변경 로그와 임시 복구 DB에서의 선택적 이관 절차가 필요합니다.

전용 DB는 고객별 point-in-time recovery가 쉽지만 수많은 DB의 백업 성공 여부와 복구 훈련을 자동화해야 합니다. 백업이 있다는 사실보다 실제 목표 시간 안에 복구하고 tenant 경계를 유지할 수 있는지가 중요합니다.

재해 복구 리전과 분석용 복제본도 데이터 거주 지역 요구를 지켜야 합니다. 원본은 유럽에 두면서 로그와 백업을 다른 지역으로 보내면 규정을 위반할 수 있습니다.

지원팀의 관리자 접근이 가장 넓은 우회로가 된다

고객 문의를 해결하기 위한 impersonation 기능은 편리하지만 일반 사용자보다 넓은 데이터 접근 경로입니다. 지원 담당자가 티켓과 고객 승인을 연결하고, 제한된 시간 동안 특정 테넌트에만 접근하도록 합니다.

누가 어떤 이유로 어느 테넌트에 들어가 어떤 민감 작업을 했는지 변경 불가능한 감사 로그를 남깁니다. 비밀번호를 공유하거나 고객 계정으로 직접 로그인하는 방식은 피합니다. 조회 전용 모드와 민감 필드 마스킹을 기본으로 두고 고위험 동작은 추가 승인을 요구합니다.

로그와 메트릭에는 식별과 노출 사이의 균형이 필요하다

장애 조사와 비용 분석을 위해 로그와 trace에 tenant identifier가 필요합니다. 그러나 고객 이름, 이메일과 민감한 업무 데이터를 그대로 넣지 않고 내부 불투명 ID를 사용합니다. 중앙 로그 시스템 자체의 접근 권한과 보존 기간도 관리합니다.

tenant_id를 메트릭 라벨에 넣으면 고객 수만큼 시계열이 늘어 고카디널리티 비용이 커질 수 있습니다. 전체와 서비스 등급별 메트릭은 일반 집계로 유지하고, 테넌트별 상세 사용량은 별도 분석 파이프라인이나 제한된 top-N으로 처리합니다.

교차 테넌트 접근 거부, RLS 정책 위반, 다른 tenant key 캐시 조회와 비정상 관리자 접근을 보안 신호로 수집하고 알림을 연결합니다.

테넌트 생명주기를 자동화한다

가입 시 DB·스키마와 기본 역할, 암호화 키, 저장소 경로와 quota를 일관되게 생성합니다. 일부만 성공했을 때 재시도하거나 안전하게 되돌릴 수 있는 상태 머신이 필요합니다.

고객 정지 시 로그인만 막고 배치 작업과 API key가 계속 작동하지 않게 모든 진입점을 차단합니다. 계약 종료 시 보존 기간, export, soft delete와 최종 물리 삭제를 추적하고 백업과 파생 데이터까지 정책을 적용합니다.

테넌트를 공유 샤드에서 전용 DB로 이동할 때는 이중 쓰기와 변경 캡처, 검증, 짧은 전환 창을 사용할 수 있습니다. 이동 중 어느 저장소가 원본인지 명확히 하고 실패 시 되돌릴 절차를 준비합니다.

격리 테스트는 정상 조회 테스트와 다르다

테넌트 A가 자신의 데이터를 볼 수 있는지만 확인해서는 부족합니다. A의 토큰으로 B의 ID를 URL, 요청 본문, 검색 필터와 파일 경로에 넣었을 때 모두 거부되는지 시험해야 합니다.

  • 서로 같은 내부 ID를 가진 두 테넌트의 데이터를 생성합니다.
  • 목록·상세·수정·삭제·export 모든 API에서 교차 접근을 시도합니다.
  • raw SQL과 bulk 작업, 관리자 경로에서 tenant 필터를 확인합니다.
  • 연결 풀 재사용과 동시 요청에서 RLS context 누출을 시험합니다.
  • 캐시가 채워진 뒤 다른 테넌트가 같은 리소스 ID를 조회합니다.
  • 큐 재시도·DLQ와 예약 작업에서 tenant_id가 유지되는지 확인합니다.
  • 검색, 벡터 DB, 파일 signed URL과 CDN 캐시를 교차 테스트합니다.
  • 백업 복구와 고객 삭제 후 파생 데이터가 남지 않는지 검사합니다.

코드 리뷰 규칙과 정적 분석으로 tenant 없는 repository 호출을 막고, CI에서는 임의의 테넌트와 리소스 조합을 생성하는 property-based test를 사용할 수 있습니다. 실제 운영과 같은 권한으로 테스트해야 관리자 DB 계정 때문에 정책이 우회되지 않습니다.

멀티테넌트 설계 체크리스트

  • 사용자와 현재 테넌트, 테넌트 내 권한이 분리되어 있는가?
  • tenant context를 클라이언트 값이 아니라 인증된 멤버십에서 만드는가?
  • 모든 테넌트 테이블의 키·외래키·고유 제약에 tenant_id가 반영되는가?
  • ORM 필터 외에 DB 제약과 RLS 같은 방어선이 있는가?
  • 연결 풀, 비동기 작업과 내부 API에서 context가 안전하게 전달되는가?
  • 캐시, 검색, 파일, CDN과 로그에도 같은 경계가 적용되는가?
  • 테넌트별 rate limit, quota와 대량 작업 격리가 있는가?
  • 개별 고객의 백업·복구·이전·삭제 요구를 수행할 수 있는가?
  • 지원 관리자 접근이 제한되고 전부 감사되는가?
  • 모든 API와 저장소에 교차 테넌트 부정 테스트가 있는가?

격리는 한 번의 WHERE 조건이 아니라 시스템 전체의 불변식이다

멀티테넌트 SaaS의 효율은 자원을 공유하는 데서 나오지만 신뢰는 경계를 지키는 데서 나옵니다. 데이터베이스 모델만 안전해도 캐시 키, 파일 경로나 비동기 작업에서 문맥이 빠지면 전체 격리는 깨집니다.

핵심 원칙을 요약하면 이렇습니다. 테넌트 문맥은 인증된 경계에서 만들고, 모든 저장소와 비동기 흐름에 명시적으로 전달하며, 애플리케이션·DB·인프라의 여러 방어선으로 검증하라. 이 원칙을 자동화된 테스트와 운영 절차로 유지할 때 고객 수가 늘어도 안전하고 확장 가능한 SaaS를 만들 수 있습니다.

#멀티테넌트 #MultiTenant #SaaS #데이터격리 #TenantIsolation #RowLevelSecurity #RLS #PostgreSQL #클라우드아키텍처 #보안설계 #백엔드개발 #데이터베이스 #SaaS아키텍처

AWS Kubernetes PostgreSQL

/ 다음 단계

이런 문제, 직접 겪고 계신가요?

글로 정리한 방법을 실제 환경에 맞춰 적용하는 일까지 함께 하겠습니다.

영업일 기준 3일 이내 회신 · 상담 및 범위 견적 무료