BtoB SaaSを作っていると、避けて通れないのが「テナント間のデータをどう分けるか」という設計判断ですよね。この記事では3方式の比較と、Spring Boot 3.2 + Hibernate 6.4で実際に動くコードをまとめていきます。

前提環境

  • Spring Boot 3.2.x
  • Hibernate ORM 6.4.x(Spring Boot 3.2同梱バージョン)
  • Java 21
  • PostgreSQL 16

Hibernate 6.4では MultiTenantConnectionProvider<T>CurrentTenantIdentifierResolver<T> が型パラメータ化されているので、本記事のコードは <String> を前提に書いています。Spring Boot 3.0 / 3.1 同梱の Hibernate 6.1 系では一部シグネチャが異なる点に注意してください。

3つの分離方式

  • Database-per-tenant テナントごとに物理DBを丸ごと分ける
  • Schema-per-tenant 1つのDB内でスキーマだけ分ける
  • Shared-schema 全テナントで同じテーブルを共有し、tenant_id列で識別する

方式の比較

観点DatabaseSchemaShared-schema
分離強度非常に強い強い弱い(アプリ依存)
運用コスト高い低い
バックアップ単位テナント単位スキーマ単位全体一括
スケーラビリティ数十社程度数百社程度数千社可能
適性医療・金融中規模B2B大量小規模顧客

スケール数値はあくまで目安で、実際は運用体制・接続プール上限・プロビジョニングコストに依存します。コンプライアンスでデータ完全分離が必要ならDatabase、コスト最優先で大量顧客を抱えるならShared-schema、その中間がSchemaという選び方が現実的です。PoCはShared-schemaで始めて、必要になってからSchemaやDatabaseへ移行する戦略が一番楽でしょう。

Hibernateの設定

Hibernate 6では hibernate.multiTenancy プロパティで方式を指定します。Spring Boot 3.x では MultiTenantConnectionProviderCurrentTenantIdentifierResolver@Component で登録すれば HibernatePropertiesCustomizer 経由で自動検出されるので、クラス名指定は基本不要です。

spring:
  jpa:
    properties:
      hibernate:
        multiTenancy: SCHEMA

値は DATABASE / SCHEMA / DISCRIMINATOR の3択です。

MultiTenantConnectionProviderの実装

テナントごとに接続を切り替える本体です。Schema-per-tenant では SET search_path を明示的に発行する必要があります。PgConnection.setSchema() はno-opに近い実装で search_path を変更しないため、JDBCの setSchema 任せにすると分離が壊れます。

@Component
public class TenantConnectionProvider
    implements MultiTenantConnectionProvider<String> {

  private final DataSource defaultDataSource;
  private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z0-9_]+");

  public TenantConnectionProvider(DataSource defaultDataSource) {
    this.defaultDataSource = defaultDataSource;
  }

  @Override
  public Connection getAnyConnection() throws SQLException {
    return defaultDataSource.getConnection();
  }

  @Override
  public void releaseAnyConnection(Connection conn) throws SQLException {
    conn.close();
  }

  @Override
  public Connection getConnection(String tenantId) throws SQLException {
    if (!SAFE_IDENT.matcher(tenantId).matches()) {
      throw new IllegalArgumentException("invalid tenant id");
    }
    Connection conn = defaultDataSource.getConnection();
    try (Statement s = conn.createStatement()) {
      s.execute("SET search_path TO \"" + tenantId + "\"");
    }
    return conn;
  }

  @Override
  public void releaseConnection(String tenantId, Connection conn) throws SQLException {
    try (Statement s = conn.createStatement()) {
      s.execute("SET search_path TO public");
    }
    conn.close();
  }

  @Override public boolean isUnwrappableAs(Class<?> c) { return false; }
  @Override public <T> T unwrap(Class<T> c) { return null; }
}

テナントIDは識別子としてSQLに埋め込むのでバインドパラメータが使えません。SAFE_IDENT のような厳格なホワイトリストで弾いてSQLインジェクションを防ぎます。MySQLなら USE \tenant_xxx“ で同じ書き方になります。

Database-per-tenant の場合は ConcurrentHashMap<String, DataSource> をフィールドに持ち、テナント追加時に HikariDataSource を生成して put() で登録、getConnection(tenantId) では dataSources.get(tenantId).getConnection() を返す構成になります。プールサイズの設計は Spring BootでHikariCPの接続プールをチューニングする方法 を参考にしてください。

テナントIDを解決する

現在のリクエストで使うテナントIDをHibernateに伝えるのが CurrentTenantIdentifierResolver です。

@Component
public class TenantIdentifierResolver
    implements CurrentTenantIdentifierResolver<String> {

  @Override
  public String resolveCurrentTenantIdentifier() {
    String tenantId = TenantContext.getTenantId();
    if (tenantId == null) {
      throw new IllegalStateException("tenant not resolved");
    }
    return tenantId;
  }

  @Override
  public boolean validateExistingCurrentSessions() {
    return true;
  }
}

validateExistingCurrentSessions()true にすると、既存セッションが返される際にリゾルバが再度呼ばれ、テナントが変わっていれば例外になります。プールから使い回されたセッションが別テナントの状態を持ち越す事故を防げます。

ThreadLocalでコンテキストを持つ

public final class TenantContext {
  private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
  public static void setTenantId(String id) { CURRENT.set(id); }
  public static String getTenantId() { return CURRENT.get(); }
  public static void clear() { CURRENT.remove(); }
}

clear() を忘れるとスレッドプールで使い回されたスレッドが別テナントのIDを持ち越し、データ漏洩につながります。これは絶対に避けたい事故なので、後述のFilterで必ず finally 句から呼び出します。

HTTPヘッダー・JWTからテナントを抽出するFilter

OncePerRequestFilter でリクエストごとに解決します。JwtAuthenticationToken を参照したいので、このFilterは SecurityFilterChain の後段、つまり認証処理が完了したあとに動く位置に配置します(addFilterAfter(filter, BearerTokenAuthenticationFilter.class) のように登録)。

@Component
public class TenantResolutionFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    try {
      String tenantId = resolve(request);
      if (tenantId == null) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Tenant required");
        return;
      }
      TenantContext.setTenantId(tenantId);
      MDC.put("tenantId", tenantId);
      chain.doFilter(request, response);
    } finally {
      TenantContext.clear();
      MDC.remove("tenantId");
    }
  }

  private String resolve(HttpServletRequest req) {
    String header = req.getHeader("X-Tenant-ID");
    if (header != null) return header;
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth instanceof JwtAuthenticationToken jwt) {
      return jwt.getToken().getClaimAsString("tid");
    }
    return null;
  }

  @Override
  protected boolean shouldNotFilter(HttpServletRequest req) {
    return req.getRequestURI().startsWith("/actuator");
  }
}

MDCに tenantId を入れておけば、Logbackのパターンに %X{tenantId} を仕込むだけで全ログにテナントが出るようになります。JWT認証側の組み立ては Spring Security + JWTで認証APIを実装する方法 にまとめているので、合わせて読むと理解が早いです。

@Asyncで別スレッドに切り替わる問題

ThreadLocalの最大の落とし穴がこれですね。TaskDecorator で伝播させましょう。

public class TenantAwareTaskDecorator implements TaskDecorator {
  @Override
  public Runnable decorate(Runnable runnable) {
    String tenantId = TenantContext.getTenantId();
    return () -> {
      try {
        TenantContext.setTenantId(tenantId);
        runnable.run();
      } finally {
        TenantContext.clear();
      }
    };
  }
}

Executor側に登録しないと動かないので、設定クラスを忘れずに。

@Configuration
@EnableAsync
public class AsyncConfig {
  @Bean
  public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setTaskDecorator(new TenantAwareTaskDecorator());
    executor.initialize();
    return executor;
  }
}

Shared-schema方式の行レベル分離

tenant_id 列で識別する方式なら、Hibernateの @FilterDef を使うとクエリへの条件付与を自動化できます。下のサンプルは Hibernate 6.4 で動作確認済みです。

@Entity
@FilterDef(
  name = "tenantFilter",
  parameters = {@ParamDef(name = "tenantId", type = String.class)}
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
  @Id @GeneratedValue private Long id;
  private String tenantId;
  private BigDecimal amount;
}

セッション開始時に有効化します。

Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
       .setParameter("tenantId", TenantContext.getTenantId());

ただし ネイティブクエリには効きません@Query(nativeQuery = true) を書く時は WHERE tenant_id = :tenantId を自分で付ける運用が必須です。エンティティ設計の基本は JPAエンティティリレーションのマッピング を参照してください。

PostgreSQL RLS との違いも押さえておきましょう。@Filter はアプリ層でクエリを書き換える方式なので、ネイティブSQLやDB直接アクセスを使われるとすり抜けます。一方 RLS は DB 層で強制されるため、どんな経路でも分離が効くかわりに、運用とパフォーマンス調整が DBA 領域に寄ります。アプリ完結で済ませたいなら @Filter、絶対に漏らしたくないなら RLS、というのが大まかな住み分けです。

マイグレーション

Database-per-tenant なら各 DataSource に対して Flyway を順次実行、Schema-per-tenant なら Flyway.configure().schemas("tenant_xyz").load().migrate() をスキーマごとに回します。新規テナント作成時に自動実行する流れは Spring BootでFlywayを使ったデータベースマイグレーション でまとめています。

まとめ

マルチテナント設計は最初の選定が肝心です。コンプライアンス要件と顧客数規模から方式を決め、MultiTenantConnectionProviderCurrentTenantIdentifierResolver・テナント解決Filterの3点セットで実装する流れを押さえれば、まずは動く基盤が組めます。あとは MDC・TaskDecorator・@FilterDef の落とし穴を一つずつ潰していけば、本番運用に耐える形に育てられるはずです。