테넌트 격리는 공유 시스템 안에 벽을 세우는 규율로, 모든 테넌트를 다른 모든 테넌트로부터 봉인하는 통제의 집합입니다. 대부분의 설명이 뒤로 미루는 요점을 첫 문단에 두어야 합니다. 격리는 인증도 인가(권한 부여)도 아닙니다. 정상적으로 로그인하고 역할도 올바르게 부여받은 사용자라도, 쿼리 범위를 제한하는 장치가 없다면 경쟁사의 데이터를 읽을 수 있습니다. 격리는 모든 작업이 누구의 세계 안에서 일어나는지를 결정하는 세 번째 계층입니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 정의 | 어떤 테넌트도 다른 테넌트의 데이터나 리소스에 닿을 수 없다는 보장 |
| 혼동하지 말아야 할 것 | 인증(누구)과 인가(무엇). 격리는 누구의 것인가 |
| 벽을 세울 곳 | 데이터베이스 행, 캐시, 큐, 스토리지, 토큰 등 공유되는 모든 계층 |
| 실패 양상 | 범위가 제한되지 않은 쿼리, 작업, 캐시 키 하나 = 테넌트 간 유출 |
| 표준 | 데이터 계층에서 강제하고, 공격자 관점으로 테스트하며, 클라이언트가 보낸 테넌트 ID는 절대 신뢰하지 않음 |
버그, 그리고 버그를 견디는 벽
-- 격리가 견뎌야 하는 버그: 테넌트 조건을 잊은 쿼리
SELECT * FROM invoices WHERE id = $1; -- 누구의 청구서든 반환
-- 데이터 계층에 벽이 있으면 같은 버그가 아무것도 반환하지 않음:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_wall ON invoices
USING (tenant_id = current_setting('app.tenant')::uuid);
문서 데이터베이스에서는 역할과 객체별 접근 제어로 벽을 세웁니다. 테넌트가 하나의 역할이 되고, 모든 행은 쓰기 시점에 그 역할에 봉인됩니다:
// JavaScript / Node.js — Back4app JS SDK
// Provisioning a tenant boundary: a role is the tenant, membership is access
const tenantRole = new Parse.Role('tenant-acme', new Parse.ACL());
tenantRole.getUsers().add(adminUser);
await tenantRole.save();
// Every acme row from now on: readable and writable by the role only
const doc = new Parse.Object('Project', { name: 'Q3 Launch' });
const acl = new Parse.ACL();
acl.setRoleReadAccess('tenant-acme', true);
acl.setRoleWriteAccess('tenant-acme', true);
doc.setACL(acl);
await doc.save(); // Flutter / Dart — Back4app Flutter SDK
// Every tenant row: readable and writable by the tenant role only
final acl = ParseACL();
acl.setRoleReadAccess('tenant-acme', true);
acl.setRoleWriteAccess('tenant-acme', true);
final doc = ParseObject('Project')
..set('name', 'Q3 Launch')
..setACL(acl);
await doc.save(); // invisible to every other tenant, enforced server-side // iOS / Swift — Back4app Swift SDK
// Every tenant row: readable and writable by the tenant role only
var acl = ParseACL()
acl.setReadAccess(roleName: "tenant-acme", value: true)
acl.setWriteAccess(roleName: "tenant-acme", value: true)
var doc = Project()
doc.name = "Q3 Launch"
doc.ACL = acl
doc.save { _ in } // invisible to every other tenant, enforced server-side // Android / Kotlin — Back4app Android SDK
// Every tenant row: readable and writable by the tenant role only
val acl = ParseACL().apply {
setRoleReadAccess("tenant-acme", true)
setRoleWriteAccess("tenant-acme", true)
}
val doc = ParseObject("Project").apply {
put("name", "Q3 Launch")
setACL(acl)
}
doc.saveInBackground() // invisible to every other tenant, enforced server-side 격리를 강제하는 계층
각 계층은 위 계층이 놓친 것을 잡아냅니다. 토큰은 서버 측에서 테넌트 ID를 확립하고, 애플리케이션 코드는 습관적으로 범위를 제한하며, 데이터 계층은 정책으로 강제하고, 인프라는 그 밖에 공유되는 모든 것을 네임스페이스로 구분합니다. 전체 스택에 대한 표준 체크리스트는 OWASP 치트 시트입니다.
격리 vs. 인증 vs. 인가
| 답하는 질문 | 메커니즘 | 실패의 모습 |
|---|---|---|
| 당신은 누구인가?(인증) | 로그인, 세션, 토큰 | 사칭자가 들어옴 |
| 무엇을 할 수 있는가?(인가) | 역할, 권한 | 사용자가 자기 역할을 넘어섬 |
| 이것은 누구의 데이터인가?(격리) | 테넌트 범위 제한 + 데이터 계층의 벽 | 정상 사용자가 다른 테넌트의 데이터를 읽음 |
뉴스 헤드라인을 만드는 것은 세 번째 행입니다. 앞의 두 행이 정의하는 모든 테스트를 통과하기 때문입니다. 공격자는 정당하게 로그인하고 허용된 작업만 사용하면서, 빠진 벽 사이로 걸어 들어갑니다. 클라우드 시대를 대표하는 테넌트 간 취약점들(한 테넌트가 다른 테넌트의 데이터베이스 접근 권한을 얻어 낼 수 있었던 ChaosDB 계열의 연구 결과)은 모두 첫째와 둘째 행이 흠잡을 데 없던 시스템에서 일어난 세 번째 행의 실패였습니다.
유출이 실제로 일어나는 곳
- 잊힌 필터. 테넌트 범위가 빠진 쿼리 하나입니다. 벽을 개발자의 기억이 아니라 데이터 계층에 두어야 하는 이유입니다.
- IDOR. 순차적이거나 추측 가능한 ID를 테넌트 검사 없이 가져옵니다. ID 하나만 바꾸면 남의 레코드를 읽게 됩니다.
- 컨텍스트 밖 실행. 백그라운드 작업, 웹훅, 예약 작업, 데이터 내보내기가 넓은 자격 증명으로, 테넌트 컨텍스트 없이 실행됩니다.
- 범위가 제한되지 않은 공유 서비스. 캐시 키, 검색 인덱스, 파일 경로에 테넌트 접두사가 빠져 있습니다. 데이터베이스의 벽은 서 있는데 캐시로 새어 나갑니다.
- 사이드 채널 파이프라인. 분석과 보고 파이프라인이 모든 애플리케이션 검사를 우회해 데이터베이스를 직접 읽습니다.
- 클라이언트를 신뢰하는 테넌시. 테넌트 ID를 세션에서 도출하지 않고 요청 본문에서 그대로 받아들입니다. 다른 테넌트의 데이터를 가장 정중하게 나눠 주는 방법입니다.
보안 격리 vs. 노이지 네이버
같은 단어, 두 가지 문제입니다. 보안 격리는 테넌트 A가 테넌트 B의 데이터에 들어오지 못하게 합니다. 성능 격리는 테넌트 A의 대량 가져오기가 테넌트 B의 결제 지연 시간에 영향을 주지 못하게 합니다. 후자는 할당량, 속도 제한, 파티셔닝으로 해결하며 이 주제의 아키텍처 측면에서 다룹니다. 시스템은 물샐틈없이 안전하면서도 한 테넌트가 나머지의 자원을 고갈시키게 둘 수 있습니다. 둘 다에 예산을 배정하고, 할당량 논의가 보안 검토 행세를 하지 않도록 해야 합니다.
테넌트 격리 테스트
두 테넌트를 둔 공격자 관점의 점검을 CI에서 자동화합니다. 테넌트 A와 B를 만든 뒤 가능한 모든 교차를 시도합니다. 모든 엔드포인트에서 A의 세션으로 B의 객체 ID를 요청하고(IDOR 전수 점검), A의 토큰을 B의 리소스에 사용하고, 주 경로 밖의 표면(내보내기, 관리자 패널, 작업)을 각 테넌트의 컨텍스트로 실행하며, 캐시 키, 파일 URL, 검색 결과에 테넌트 범위가 빠지지 않았는지 점검합니다. 컨텍스트가 설정되지 않은 경우도 추가해야 합니다. 세션에 테넌트가 없으면 결과는 행 없음이어야 하며, 결코 모든 행이어서는 안 됩니다. CI에서 공격받아 본 적 없는 격리는 규정 준수 인증서가 붙은 가설일 뿐입니다.
일반적인 사용 사례
- B2B SaaS. 모든 워크스페이스가 테넌트이며, 격리는 모든 기능 아래에 깔린 제품의 약속입니다.
- 고객 앱을 호스팅하는 플랫폼. 두 수준의 격리가 동시에 필요합니다. 플랫폼은 앱끼리 격리하고, 각 앱은 자기 테넌트들을 격리합니다.
- 엔터프라이즈 및 규제 대상 등급. 계약상의 격리 요구를 더 강한 벽(테넌트별 키, 전용 리소스)에 대응시키며, 이를 요구하는 계정에만 적용합니다.
- 에이전시와 화이트 라벨 제품. 하나의 배포에 여러 고객 조직이 있고, 각 조직은 봉인되어 있습니다.
- 사내 멀티팀 플랫폼. 부서가 테넌트입니다. 위협 모델은 더 온건하지만 메커니즘은 똑같습니다.
격리는 어느 정도가 적절할까? 결정 매트릭스
| pool + 데이터 계층 벽을 선택해야 하는 경우… | 더 강한 분리를 선택해야 하는 경우… |
|---|---|
| 작은 테넌트가 많고 민감도가 일반적인 수준임 | 계약서에 격리 요구 사항이 명시됨 |
| 테넌트당 비용을 0에 가깝게 유지해야 함 | 테넌트의 데이터에 전용 키나 전용 리전이 필요함 |
| 벽이 강제되고(RLS/ACL) 테스트됨 | 영향 범위 논리가 밀도의 경제성보다 우선함 |
| 한 번의 업데이트 주기로 모두를 갱신해야 함 | 테넌트별 복원이 약속된 기능임 |
| 팀이 공격자 관점 테스트를 유지할 수 있음 | 감사인이 가리킬 수 있는 경계를 원함 |
솔직하게 정리하면, 강제되고 테스트된 벽을 갖춘 pool은 정당한 격리이며 업계 대부분이 이 방식으로 운영됩니다. 개별 테넌트를 분리의 사다리 위로 올리는 것은 유행 때문이 아니라 그 테넌트의 요구 사항 때문이어야 합니다.
한계와 트레이드오프
- 격리는 영원히 횡단 관심사입니다. 캐시, 큐, 내보내기, 검색 등 새 기능이 생길 때마다 테넌시 질문이 다시 제기되며, 이 규율에는 끝이 없습니다.
- 데이터 계층의 벽에는 운영상의 세부 조항이 있습니다. 세션 컨텍스트와 커넥션 풀링의 관계, 정책 우회 역할, 덤프 모드가 그렇습니다. 행 수준 보안 항목이 이를 정리합니다.
- 공유된 영향 범위는 정확성과 무관하게 남습니다. 완벽한 논리적 격리도 장애 도메인은 공유합니다. 잘못된 배포 하나가 모든 테넌트에 영향을 주며, 이를 바꾸는 것은 물리적 분리뿐입니다.
- 테스트는 예산에 잡히지 않는 비용입니다. 두 테넌트 테스트 스위트는 실제 엔지니어링 작업이며, 이를 건너뛰면 “강제됨”이 다시 “가정됨”으로 돌아갑니다.
- 더 강한 벽은 밀도를 희생합니다. 테넌트별 키, silo, 전용 리소스는 모두 공유를 매력적으로 만든 경제성을 내주는 선택입니다. 그것이 필요한 테넌트에게만 써야 합니다.
Back4app에서의 테넌트 격리
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 격리는 구조적으로 데이터 계층에서 먼저 이루어집니다. 역할이 테넌트를 정의하고, 모든 객체의 ACL이 객체를 그 역할에 봉인하며, 클래스 수준 권한이 스키마를 통제합니다. 이 모두가 SDK, REST, GraphQL, 대시보드를 가리지 않고 모든 경로에서 서버 측으로 강제되므로, 필터를 잊어버리는 버그가 끼어들 여지가 없습니다. 플랫폼 자체도 한 단계 위에서 같은 규율을 적용해 각 앱의 백엔드를 다른 모든 앱으로부터 격리하며, 행 수준 보안에 관한 엔지니어링 심층 분석에서 전체 패턴을 실제로 확인할 수 있습니다.
자주 묻는 질문
테넌트 격리란 무엇입니까?
공유 시스템에서 한 테넌트가 다른 테넌트의 데이터나 리소스에 닿지 못하게 막는 아키텍처 통제의 집합입니다. 공동 건물에서 세대 사이를 가르는 벽과 같습니다. 데이터베이스 행, 캐시, 큐, 파일 스토리지, 컴퓨팅 등 공유되는 모든 계층에 걸쳐 있으며, 각 계층에는 현재 테넌트로 범위가 제한된 자체 경계가 필요합니다.
테넌트 격리는 인증 및 인가와 어떻게 다릅니까?
사용자가 완전히 인증되고 자기 역할에 맞게 올바르게 인가되었더라도, 쿼리 범위를 제한하는 장치가 없다면 다른 테넌트의 데이터에 닿을 수 있습니다. 인증은 사용자가 누구인지 증명하고, 인가는 어떤 작업을 할 수 있는지 결정하며, 격리는 그 작업이 어느 테넌트의 세계 안에서 일어나는지 보장합니다. 격리는 별도의 계층이며, 로그인과 역할만으로 충분하다고 보는 것이 테넌트 간 버그 대부분의 근원입니다.
테넌트 간 데이터 유출은 왜 발생합니까?
원인 목록은 짧고 변하지 않습니다. 테넌트 필터가 빠진 쿼리, 테넌트 범위 제한 없이 추측 가능한 ID로 객체를 가져오는 IDOR, 테넌트 컨텍스트 밖에서 실행되는 백그라운드 작업과 웹훅과 내보내기, 테넌트 없이 키를 만든 캐시, 애플리케이션 검사를 우회하는 분석 및 보고 파이프라인, 그리고 서버 측 세션에서 도출하지 않고 클라이언트가 보낸 테넌트 식별자를 신뢰하는 경우입니다.
노이지 네이버 문제는 테넌트 격리와 같은 것입니까?
형제 관계일 뿐 같은 문제는 아닙니다. 보안 격리는 한 테넌트가 다른 테넌트의 데이터에 접근하지 못하게 막습니다. 성능 격리, 즉 노이지 네이버(noisy neighbor) 문제는 한 테넌트의 워크로드가 다른 모든 테넌트의 성능을 떨어뜨리지 못하게 막으며, 할당량, 스로틀링, 파티셔닝으로 해결합니다. 두 개념은 자주 혼동됩니다. 시스템은 완벽하게 안전하면서도 한 테넌트가 나머지의 자원을 고갈시키게 둘 수 있습니다.
규정 준수 프레임워크는 어떤 격리 모델을 요구합니까?
주요 프레임워크 중 특정 아키텍처를 의무화하는 것은 없습니다. 모두 결과 중심이며, 적절하고 입증 가능한 조치를 요구합니다. 물리적 분리는 보통 규제 기관이 아니라 엔터프라이즈 고객과 계약이 요구합니다. 감사에서 실제로 좋은 평가를 받는 것은 데이터 계층에서의 강제, 문서화된 경계, 그리고 격리가 가정이 아니라 테스트되었다는 증거입니다.
행 수준 보안은 어떻게 테넌트 격리를 강제합니까?
테넌트 경계를 테이블의 속성으로 만듭니다. 정책이 모든 쿼리를 세션의 테넌트 컨텍스트로 걸러 내므로, 필터를 잊은 쿼리는 전부가 아니라 아무것도 반환하지 않습니다. 문서 데이터베이스에서는 객체별 접근 제어, 즉 테넌트 역할을 지정한 ACL이 같은 역할을 하며 플랫폼이 모든 요청에서 이를 강제합니다. 어느 쪽이든 애플리케이션 코드가 실수해도 벽은 그대로 서 있습니다.
테넌트 격리는 어떻게 테스트합니까?
두 테넌트를 두고 공격자의 관점으로 테스트합니다. 모든 엔드포인트에서 두 테넌트의 객체 ID를 맞바꾸고(IDOR 점검), 한 테넌트의 토큰을 다른 테넌트의 리소스에 재사용하며, 내보내기, 관리자 패널, 백그라운드 작업처럼 메인 앱을 건너뛰는 경로를 실행하고, 캐시 키와 파일 URL에 테넌트 범위가 빠지지 않았는지 점검합니다. 이 부정 테스트는 CI에서 자동화해야 합니다. 테스트하지 않은 격리는 가설일 뿐입니다.
데이터베이스 외에 격리가 필요한 계층은 무엇입니까?
공유되는 모든 것입니다. 테넌트 접두사를 붙인 캐시 키, 테넌트별로 범위를 제한한 큐 토픽과 소비자 그룹, 테넌트별 접두사 아래에 두고 그에 맞는 접근 정책을 건 오브젝트 스토리지, 계약이 요구하는 경우의 테넌트별 암호화 키, 그리고 토큰에 담겨 모든 요청에서 검증되는 테넌트 클레임이 필요합니다. 데이터베이스의 벽은 필요조건이지 결코 충분조건이 아닙니다.