다중 테넌시 아키텍처 설계 개요
PostgreSQL 확장인 Citus는 수평적 확장을 통해 대규모 멀티테넌트 애플리케이션을 지원합니다. 본 문서에서는 ASP.NET Core 웹 프레임워크와 SaasKit 미들웨어를 결합하여, Citus 기반 분산 데이터베이스 환경에서 동작하는 다중 테넌트 시스템을 구현하는 방법을 설명합니다.
샘플 애플리케이션: QuestionExchange
StackOverflow의 간소화된 버전인 QuestionExchange를 예제로 사용합니다. 이 앱은 각 테넌트가 고유 도메인으로 식별되며, 질문 데이터는 테넌트 단위로 논리적으로 분리됩니다. 최종 코드는 GitHub에서 확인할 수 있습니다.
https://github.com/nbarbettini/QuestionExchange
데이터베이스 스키마 설계
기본적으로 두 개의 엔티티 테이블을 정의합니다:
tenants: 테넌트 메타정보 저장questions: 각 테넌트의 질문 데이터 보관
CREATE TABLE tenants (
id UUID PRIMARY KEY,
domain TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE questions (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
title TEXT NOT NULL,
votes INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
분산 처리를 위해 Citus 함수를 사용해 테이블을 분할합니다. 테넌트는 자체 ID 기준으로, 질문은 tenant_id 기준으로 샤딩됩니다.
SELECT create_distributed_table('tenants', 'id');
SELECT create_distributed_table('questions', 'tenant_id');
데이터 위치를 일치시키기 위해 co-location 전략을 적용하면, 조인 쿼리 성능이 향상됩니다.
초기 데이터 삽입
INSERT INTO tenants VALUES (
'c620f7ec-6b49-41e0-9913-08cfe81199af',
'bufferoverflow.local',
'Buffer Overflow',
'개발 관련 질문 커뮤니티',
NOW(), NOW()
);
INSERT INTO questions VALUES (
'347b7041-b421-4dc9-9e10-c64b8847fedf',
'c620f7ec-6b49-41e0-9913-08cfe81199af',
'ASP.NET Core 앱을 어떻게 만드나요?',
1,
NOW(), NOW()
);
ASP.NET Core 프로젝트 설정
.NET SDK 설치 후 CLI 명령어로 새 MVC 프로젝트 생성:
dotnet new mvc -o QuestionExchange
cd QuestionExchange
PostgreSQL 연동을 위해 Npgsql EF Core 패키지 추가:
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
Startup.cs 파일 내에서 데이터베이스 연결 설정:
services.AddEntityFrameworkNpgsql()
.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(Configuration.GetConnectionString("CitusDb")));
연결 문자열은 환경 변수 또는 Secret Manager를 통해 안전하게 관리해야 합니다.
엔터티 모델 및 DB 컨텍스트 정의
AppDbContext.cs 파일에 다음 클래스 작성:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> opts) : base(opts) { }
public DbSet<Tenant> Tenants { get; set; }
public DbSet<Question> Questions { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
var translator = new NpgsqlSnakeCaseNameTranslator();
foreach (var entityType in builder.Model.GetEntityTypes())
{
entityType.SetTableName(translator.TranslateMemberName(entityType.GetTableName()));
foreach (var property in entityType.GetProperties())
{
property.SetColumnName(translator.TranslateMemberName(property.GetColumnName()));
}
}
}
}
C#의 PascalCase 네이밍과 PostgreSQL의 snake_case 간 호환성을 유지하기 위해 이름 변환기를 적용합니다.
도메인 모델 클래스
Models/Tenant.cs:
public class Tenant
{
public Guid Id { get; set; }
public string Domain { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
Models/Question.cs:
public class Question
{
public Guid Id { get; set; }
public virtual Tenant Tenant { get; set; }
public string Title { get; set; }
public int Votes { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
SaasKit를 통한 테넌트 인식 구현
SaasKit는 HTTP 요청 기반 테넌트 식별을 가능하게 하는 미들웨어입니다. 설치:
dotnet add package SaasKit.Multitenancy
커스텀 테넌트 리졸버 생성:
public class HostBasedTenantResolver : MemoryCacheTenantResolver<Tenant>
{
private readonly AppDbContext _db;
public HostBasedTenantResolver(AppDbContext db, IMemoryCache cache, ILoggerFactory logFac)
: base(cache, logFac)
{
_db = db;
}
protected override async Task<TenantContext<Tenant>> ResolveAsync(HttpContext ctx)
{
var host = ctx.Request.Host.Host.ToLowerInvariant();
var tenant = await _db.Tenants.FirstOrDefaultAsync(t => t.Domain == host);
return tenant != null ? new TenantContext<Tenant>(tenant) : null;
}
protected override MemoryCacheEntryOptions CreateCacheEntryOptions() =>
new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromHours(2));
protected override string GetContextIdentifier(HttpContext ctx) =>
ctx.Request.Host.Host.ToLowerInvariant();
protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<Tenant> context) =>
new[] { context.Tenant.Domain };
}
캐싱 전략을 통해 반복적인 DB 조회를 방지하고 성능을 최적화합니다.
Startup 구성
ConfigureServices 내 등록:
services.AddMultitenancy<Tenant, HostBasedTenantResolver>();
Configure 내 미들웨어 파이프라인 등록 (순서 중요):
app.UseStaticFiles();
app.UseMultitenancy<Tenant>(); // UseMvc 전에 위치
app.UseMvc();
뷰 및 컨트롤러 통합
HomeController에서 현재 테넌트 정보 주입:
public class HomeController : Controller
{
private readonly AppDbContext _context;
private readonly Tenant _tenant;
public HomeController(AppDbContext context, Tenant tenant)
{
_context = context;
_tenant = tenant;
}
public async Task<IActionResult> Index()
{
var recentQuestions = await _context.Questions
.Where(q => q.Tenant.Id == _tenant.Id)
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync();
return View(new QuestionListViewModel { Questions = recentQuestions });
}
}
뷰 파일 Views/Home/Index.cshtml:
@inject Tenant CurrentTenant
@model QuestionListViewModel
<div class="jumbotron">
<h1>@CurrentTenant.Name에 오신 것을 환영합니다</h1>
<p>@CurrentTenant.Description</p>
</div>
<h4>최근 질문</h4>
<ul>
@foreach (var q in Model.Questions)
{
<li>@q.Title (@q.Votes 추천)</li>
}
</ul>
로컬 테스트 설정
호스트 파일에 가상 도메인 추가:
127.0.0.1 bufferoverflow.local
127.0.0.1 dboverflow.local
실행 후 http://bufferoverflow.local:5000 접속 시 해당 테넌트 데이터 확인 가능.
확장 가능성
이 구조는 PostgreSQL + Citus 기반의 고성능 다중 테넌트 아키텍처를 제공하며, 향후 Python/Django 환경에서도 유사한 패턴을 적용할 수 있습니다.