멀티테넌트 웹 애플리케이션을 위한 데이터베이스 아키텍처
James Reed
Infrastructure Engineer · Leapcell

멀티테넌트 데이터베이스 솔루션을 활용한 확장 가능한 웹 애플리케이션 구축
오늘날 클라우드 네이티브 시대에 소프트웨어 서비스(SaaS) 애플리케이션은 보편화되었습니다. 많은 SaaS 제품의 공통적인 특징은 멀티테넌시의 개념으로, 단일 애플리케이션 인스턴스가 여러 고객 또는 "테넌트"를 서비스하는 것입니다. 이 접근 방식은 비용 효율성, 간소화된 유지보수 및 단순화된 배포 측면에서 상당한 이점을 제공합니다. 하지만 이러한 애플리케이션을 위한 견고하고 확장 가능한 데이터베이스 아키텍처를 설계하는 것은 고유한 과제를 제시합니다. 이 글에서는 멀티테넌트 웹 애플리케이션을 위한 다양한 데이터베이스 아키텍처 패턴, 기본 원리 및 구현을 위한 실제 고려 사항을 살펴보겠습니다.
멀티테넌시의 기초 이해
아키텍처 패턴에 대해 자세히 알아보기 전에 데이터베이스 맥락에서 멀티테넌시를 둘러싼 몇 가지 핵심 개념을 명확히 해보겠습니다.
- 테넌트: 동일한 애플리케이션 인스턴스를 공유하지만 데이터가 다른 그룹과 격리되는 별개의 사용자 또는 조직 그룹입니다. 각 테넌트는 자체 전용 소프트웨어 환경을 갖춘 것처럼 작동합니다.
- 데이터 격리: 멀티테넌시의 기본 요구 사항입니다. 테넌트는 다른 테넌트의 데이터에 액세스하거나 영향을 받아서는 안 됩니다. 이는 보안, 개인 정보 보호 및 규정 준수에 중요합니다.
- 스키마: 데이터베이스의 구조로, 테이블, 열, 관계 및 데이터 유형을 정의합니다.
- 성능 격리: 한 테넌트의 작업이 다른 테넌트가 경험하는 성능에 부정적인 영향을 미치지 않도록 보장합니다. 이는 데이터 격리보다 더 어려운 경우가 많습니다.
- 맞춤 설정: 다른 테넌트에게 영향을 주지 않고 개별 테넌트에 대한 애플리케이션 또는 데이터 스키마의 측면을 조정하는 기능.
모든 멀티테넌트 데이터베이스 아키텍처의 목표는 확장성, 비용, 보안 및 운영 복잡성과 같은 요인을 고려하여 이러한 문제를 효과적으로 균형 있게 조정하는 것입니다.
여러 테넌트를 위한 아키텍처링
데이터베이스 수준에서 멀티테넌시를 처리하는 데는 세 가지 주요 아키텍처 패턴이 있으며, 각 패턴마다 장단점이 있습니다.
1. 테넌트당 별도 데이터베이스
가장 간단하고 안전한 접근 방식입니다. 각 테넌트는 자체 전용 데이터베이스 인스턴스를 갖습니다.
원리: 데이터의 완전한 물리적 분리. 각 테넌트의 데이터는 자체 격리된 데이터베이스에 상주합니다.
구현: 새 테넌트가 등록되면 해당 테넌트를 위한 새 데이터베이스가 프로비저닝됩니다. 애플리케이션은 인증된 테넌트에 따라 적절한 데이터베이스에 연결합니다.
**예시 (연결 풀을 사용한 간이 의사 코드): **
# Flask 또는 Django와 같은 웹 프레임워크에서 from flask import g, request import psycopg2 DATABASE_CONFIG = { "tenant_a": {"host": "db_a_host", "database": "tenant_a_db", "user": "user_a", "password": "password_a"}, "tenant_b": {"host": "db_b_host", "database": "tenant_b_db", "user": "user_b", "password": "password_b"}, # ... 더 많은 테넌트 } def get_tenant_db_connection(): tenant_id = request.headers.get('X-Tenant-ID') # 또는 세션, 하위 도메인 등에서 가져옴 if tenant_id not in DATABASE_CONFIG: raise Exception("잘못된 테넌트 ID") config = DATABASE_CONFIG[tenant_id] if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=config['host'], database=config['database'], user=config['user'], password=config['password'] ) return g.db_connection @app.route('/data') def get_data(): conn = get_tenant_db_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM some_table") data = cursor.fetchall() return {"data": data}
장점:
- 가장 강력한 데이터 격리: 데이터베이스 수준에서 테넌트 간 데이터 유출 위험이 없습니다.
- 간소화된 백업 및 복원: 개별 테넌트의 데이터를 독립적으로 백업 및 복원할 수 있습니다.
- 성능 격리: 하나 테넌트의 성능 문제는 다른 테넌트에 영향을 미칠 가능성이 적습니다 (별도의 데이터베이스 서버 또는 충분한 리소스 할당 가정).
- 맞춤 설정 용이: 한 테넌트에 대한 스키마 변경은 다른 테넌트에게 영향을 주지 않습니다.
- 규정 준수: 엄격한 규제 준수 요구 사항에 종종 선호되는 선택입니다.
단점:
- 가장 높은 운영 오버헤드: 수백 또는 수천 개의 별도 데이터베이스를 관리하는 것은 복잡하고 리소스 집약적일 수 있습니다 (모니터링, 패치, 업그레이드).
- 높은 인프라 비용: 각 데이터베이스 인스턴스는 리소스를 소비하여, 특히 소규모 테넌트의 경우 공유 접근 방식에 비해 잠재적으로 더 높은 비용으로 이어집니다.
- 리소스 활용 부족: 소규모 테넌트는 전용 데이터베이스 리소스를 완전히 활용하지 못할 수 있습니다.
- 복잡한 마이그레이션: 여러 데이터베이스에 걸친 스키마 마이그레이션은 조정하기 어려울 수 있습니다.
2. 테넌트당 별도 스키마
이 접근 방식에서는 모든 테넌트가 동일한 데이터베이스 서버 인스턴스를 공유하지만, 각 테넌트는 해당 데이터베이스 내에서 자체 전용 스키마를 갖습니다.
원리: 공유 물리적 데이터베이스 내에서 데이터의 논리적 분리.
구현: 새 테넌트가 프로비저닝될 때 공유 데이터베이스에 새 스키마(예: tenant_a_schema
, tenant_b_schema
)가 생성됩니다. 해당 테넌트의 모든 테이블, 뷰 등은 각 스키마 내에 생성됩니다. 애플리케이션은 현재 테넌트의 스키마로 테이블 이름을 접두사로 지정하도록 쿼리를 조정합니다.
**예시 (PostgreSQL을 사용한 간이 의사 코드): **
# 웹 프레임워크에서 from flask import g, request import psycopg2 SHARED_DB_CONFIG = { "host": "shared_db_host", "database": "multi_tenant_db", "user": "shared_user", "password": "shared_password" } def get_shared_db_connection(): if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=SHARED_DB_CONFIG['host'], database=SHARED_DB_CONFIG['database'], user=SHARED_DB_CONFIG['user'], password=SHARED_DB_CONFIG['password'] ) return g.db_connection @app.before_request def set_tenant_schema(): tenant_id = request.headers.get('X-Tenant-ID') if not tenant_id: raise Exception("테넌트 ID가 제공되지 않았습니다") conn = get_shared_db_connection() cursor = conn.cursor() # 현재 세션에 대한 검색 경로 설정 cursor.execute(f"SET search_path TO {tenant_id}_schema, public;") conn.commit() # DDL/스키마 변경 또는 단순히 세션 설정에 중요 @app.route('/data') def get_data(): conn = get_shared_db_connection() cursor = conn.cursor() # 검색 경로가 처리하므로 명시적인 스키마 접두사 없이 쿼리 cursor.execute("SELECT * FROM some_table") data = cursor.fetchall() return {"data": data}
장점:
- 좋은 데이터 격리: 강력한 논리적 격리로, 애플리케이션이 스키마 접두사 또는 검색 경로를 올바르게 사용하면 실수로 테넌트 간 데이터에 액세스하는 것을 방지합니다.
- 낮은 운영 오버헤드 (별도 데이터베이스 대비): 단일 데이터베이스 인스턴스 (모니터링, 백업, 업그레이드)를 관리하기가 더 쉽습니다.
- 더 효율적인 리소스 활용: 리소스가 테넌트 간에 풀링됩니다.
- 간편한 스키마 관리: 일반적인 스키마 변경은 한 번 적용되어 모든 테넌트에게 영향을 줄 수 있습니다 (스키마가 동일한 경우).
단점:
- 낮은 성능 격리: 단일 데이터베이스 서버는 공유 리소스를 의미합니다. "노이즈 인접 테넌트"가 다른 테넌트에게 영향을 줄 수 있습니다.
- 복잡한 백업/복원: 단일 테넌트를 복원하려면 데이터베이스 전체를 복원한 다음 테넌트의 스키마를 추출/재가져오기 해야 할 수 있습니다.
- 잠재적 스키마 드리프트: 테넌트가 사용자 정의 스키마 수정을 필요로 하면 단일 데이터베이스 내에서 고유한 스키마를 관리하는 것이 복잡해질 수 있습니다.
- 애플리케이션 계층에 대한 보안 의존성: 올바른 스키마 처리가 애플리케이션 코드에서 중요합니다. 실수는 데이터를 노출할 수 있습니다.
3. 테넌트 식별자를 사용한 공유 데이터베이스, 공유 스키마
모든 테넌트가 동일한 데이터베이스와 동일한 스키마를 공유하는 가장 리소스 효율적인 접근 방식입니다. 데이터는 테넌트별 데이터를 저장하는 모든 테이블에 "테넌트 ID" 열을 사용하여 격리됩니다.
원리: 애플리케이션 수준 필터링으로 강제되는 공유 데이터베이스 및 공유 스키마 내에서 데이터의 논리적 분리.
구현: 모든 관련 테이블에는 tenant_id
열 (또는 유사한 항목)이 포함됩니다. 모든 쿼리는 WHERE tenant_id = <current_tenant_id>
절을 포함해야 합니다.
**예시 (간이 의사 코드): **
# 웹 프레임워크에서 from flask import g, request import psycopg2 SHARED_DB_CONFIG = { "host": "shared_db_host", "database": "multi_tenant_db", "user": "shared_user", "password": "shared_password" } def get_db_connection(): if not hasattr(g, 'db_connection'): g.db_connection = psycopg2.connect( host=SHARED_DB_CONFIG['host'], database=SHARED_DB_CONFIG['database'], user=SHARED_DB_CONFIG['user'], password=SHARED_DB_CONFIG['password'] ) return g.db_connection @app.route('/data') def get_data(): conn = get_db_connection() cursor = conn.cursor() tenant_id = request.headers.get('X-Tenant-ID') if not tenant_id: raise Exception("테넌트 ID가 제공되지 않았습니다") # 중요하게도, 모든 쿼리는 tenant_id로 필터링해야 합니다 cursor.execute("SELECT * FROM some_table WHERE tenant_id = %s", (tenant_id,)) data = cursor.fetchall() return {"data": data} # 테넌트 필터링이 있는 ORM 기반 접근 방식 예시 (예: SQLAlchemy) # from sqlalchemy import create_engine, Column, Integer, String # from sqlalchemy.orm import sessionmaker, declarative_base # # Base = declarative_base() # # class Item(Base): # __tablename__ = 'items' # id = Column(Integer, primary_key=True) # tenant_id = Column(String, nullable=False) # 필수 테넌트 식별자 # name = Column(String) # # # 세션 관리 또는 ORM 쿼리 빌더에서: # # session.query(Item).filter(Item.tenant_id == current_tenant_id).all()
장점:
- 가장 낮은 운영 오버헤드: 단일 데이터베이스 서버와 단일 스키마를 관리하는 것이 가장 간단합니다.
- 가장 낮은 인프라 비용 (초기): 모든 데이터가 통합되어 리소스 활용도가 뛰어납니다.
- 가장 간단한 스키마 마이그레이션: 변경 사항을 단일 스키마에 한 번 적용합니다.
- 많은 테넌트에 대한 높은 확장성: 매우 많은 소규모 테넌트를 예상하는 애플리케이션에 이상적입니다.
단점:
- 가장 약한 데이터 격리 (애플리케이션 로직에 크게 의존): 단일 코딩 오류 또는 누락된
WHERE
절은 테넌트 간 데이터를 노출할 수 있습니다. 엄격한 테스트와 강력한 ORM 기능 또는 쿼리 가로채기가 필요합니다. - 성능 격리 없음: 대규모 테넌트가 무거운 쿼리를 수행하면 다른 모든 테넌트에게 영향을 줄 수 있습니다.
- 개별 테넌트에 대한 복잡한 백업/복원: 단일 테넌트의 데이터를 추출하려면 필터링이 필요하며, 복원 시 선택적 재삽입이 필요할 수 있습니다.
- 데이터 성장 문제 발생 가능성: 많은 테넌트의 수백만 행을 가진 단일 테이블은 올바르게 인덱싱되고 최적화되지 않으면 성능 병목 현상 (예: 인덱스 부풀림, 대규모 테이블 스캔)으로 이어질 수 있습니다.
- 제한된 맞춤 설정: 모든 테넌트는 정확히 동일한 스키마를 공유합니다. 맞춤 설정은 제네릭 필드 (예: JSONB 열)로 스키마를 크게 확장하지 않는 한 어렵거나 불가능합니다.
결론
적절한 멀티테넌트 데이터베이스 아키텍처를 선택하는 것은 SaaS 애플리케이션의 확장성, 보안, 비용 및 유지 보수성에 영향을 미치는 중요한 결정입니다. "테넌트당 별도 데이터베이스"는 가장 높은 격리와 보안을 제공하지만 운영 복잡성과 인프라 비용이 발생합니다. "테넌트 식별자를 사용한 공유 데이터베이스, 공유 스키마"는 최대 리소스 효율성과 스키마 관리의 단순성을 제공하지만 세심한 애플리케이션 수준 데이터 격리를 요구합니다. "테넌트당 별도 스키마" 패턴은 좋은 격리와 별도 데이터베이스에 비해 줄어든 운영 오버헤드를 제공하는 균형을 이룹니다. 궁극적으로 최상의 접근 방식은 애플리케이션의 특정 요구 사항, 운영 복잡성에 대한 허용 오차, 보안 요구 사항 및 예상되는 테넌트 수와 크기를 포함하여 결정됩니다.