ASP.NET Core와 SaasKit를 활용한 Citus 기반 다중 테넌트 애플리케이션 구축

다중 테넌시 아키텍처 설계 개요

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 환경에서도 유사한 패턴을 적용할 수 있습니다.

태그: ASP.NET Core SaasKit citus PostgreSQL Multitenancy

7월 23일 10:56에 게시됨