SaaS(Software as a Service)アプリケーション開発において、Spring JPAマルチテナント実装は避けて通れない課題です。テナント識別やスキーマ分離といったマルチテナントアーキテクチャの選択は、アプリケーションのスケーラビリティ、セキュリティ、運用コストに直結します。本記事では、HibernateのMultiTenantConnectionProviderとCurrentTenantIdentifierResolverを活用したマルチテナント実装を、実践的なコード例とともに解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
データベース PostgreSQL 15以上(スキーマ分離に対応)
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • PostgreSQLデータベースの構築
  • Spring Boot JPAプロジェクトの基本構成

マルチテナントアーキテクチャの3つのパターン

マルチテナントを実現するためのデータ分離アプローチには、主に以下の3つのパターンがあります。それぞれにトレードオフが存在するため、要件に応じた選択が重要です。

パターン1: データベース分離(Separate Database)

graph LR
    subgraph "アプリケーション"
        A[Spring Boot App]
    end
    subgraph "テナントA"
        DB1[(Database A)]
    end
    subgraph "テナントB"
        DB2[(Database B)]
    end
    subgraph "テナントC"
        DB3[(Database C)]
    end
    A --> DB1
    A --> DB2
    A --> DB3

各テナントが独立したデータベースインスタンスを持つパターンです。

観点 評価
データ分離 最も高いレベルの分離
セキュリティ テナント間のデータ漏洩リスクが最小
カスタマイズ性 テナント固有のスキーマ変更が容易
運用コスト 高い(DBインスタンス数に比例)
スケーラビリティ テナント単位でのスケーリングが容易

パターン2: スキーマ分離(Separate Schema)

graph LR
    subgraph "アプリケーション"
        A[Spring Boot App]
    end
    subgraph "単一データベース"
        S1[Schema: tenant_a]
        S2[Schema: tenant_b]
        S3[Schema: tenant_c]
    end
    A --> S1
    A --> S2
    A --> S3

単一のデータベースインスタンス内で、テナントごとに異なるスキーマを使用するパターンです。

観点 評価
データ分離 スキーマレベルでの論理的分離
セキュリティ 適切な権限設定が必要
カスタマイズ性 テナント固有のテーブル追加が可能
運用コスト 中程度(単一DBインスタンス)
スケーラビリティ データベース単位でのスケーリング

パターン3: カラム識別(Discriminator / Partitioned Data)

graph LR
    subgraph "アプリケーション"
        A[Spring Boot App]
    end
    subgraph "単一データベース"
        T[共通テーブル<br/>tenant_id カラム]
    end
    A --> T

すべてのテナントデータを同じテーブルに格納し、識別子カラム(discriminator)で区別するパターンです。

観点 評価
データ分離 アプリケーションレベルでの論理的分離
セキュリティ クエリレベルでの確実なフィルタリングが必須
カスタマイズ性 テナント固有のスキーマ変更は困難
運用コスト 最も低い(単一DB、単一スキーマ)
スケーラビリティ 全体的なスケーリングのみ

パターン比較サマリ

観点 データベース分離 スキーマ分離 カラム識別
分離レベル 物理的 論理的(スキーマ) 論理的(行)
初期コスト
運用コスト
テナント追加 DB作成が必要 スキーマ作成が必要 即座に対応可能
推奨用途 大規模エンタープライズ 中規模SaaS 小〜中規模SaaS

Hibernate 6.6のマルチテナンシー設計

Hibernate 6.6では、マルチテナンシーを実現するために2つの主要なSPIが提供されています。

MultiTenantConnectionProvider

テナント固有のJDBCコネクションを提供するためのインターフェースです。データベース分離やスキーマ分離パターンで使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.multitenancy.config;

import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Map;

@Component
public class SchemaBasedMultiTenantConnectionProvider
        implements MultiTenantConnectionProvider<String> {

    private final DataSource dataSource;

    public SchemaBasedMultiTenantConnectionProvider(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        // メタデータ取得などに使用されるデフォルトコネクション
        return dataSource.getConnection();
    }

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

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        Connection connection = dataSource.getConnection();
        // テナント固有のスキーマに切り替え
        connection.setSchema(tenantIdentifier);
        return connection;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) 
            throws SQLException {
        // デフォルトスキーマに戻す(オプション)
        connection.setSchema("public");
        connection.close();
    }

    @Override
    public boolean supportsAggressiveRelease() {
        return false;
    }

    @Override
    public boolean isUnwrappableAs(Class<?> unwrapType) {
        return false;
    }

    @Override
    public <T> T unwrap(Class<T> unwrapType) {
        return null;
    }
}

CurrentTenantIdentifierResolver

現在のリクエストコンテキストからテナント識別子を解決するためのインターフェースです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.multitenancy.config;

import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;

@Component
public class TenantIdentifierResolver 
        implements CurrentTenantIdentifierResolver<String> {

    private static final String DEFAULT_TENANT = "public";

    @Override
    public String resolveCurrentTenantIdentifier() {
        // TenantContextからテナントIDを取得
        String tenantId = TenantContext.getCurrentTenant();
        return tenantId != null ? tenantId : DEFAULT_TENANT;
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        // 既存セッションのテナント識別子を検証するかどうか
        return true;
    }
}

スレッドローカルによるテナント情報管理

マルチスレッド環境でテナント情報を安全に管理するために、ThreadLocalを活用したテナントコンテキストを実装します。

TenantContextの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.multitenancy.context;

public final class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    private TenantContext() {
        // ユーティリティクラスのためインスタンス化を禁止
    }

    public static void setCurrentTenant(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getCurrentTenant() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

テナント解決フィルタの実装

HTTPリクエストからテナント情報を抽出し、TenantContextに設定するフィルタを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.multitenancy.filter;

import com.example.multitenancy.context.TenantContext;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter implements Filter {

    private static final String TENANT_HEADER = "X-Tenant-ID";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        
        try {
            String tenantId = extractTenantId(httpRequest);
            TenantContext.setCurrentTenant(tenantId);
            chain.doFilter(request, response);
        } finally {
            // メモリリーク防止のため必ずクリア
            TenantContext.clear();
        }
    }

    private String extractTenantId(HttpServletRequest request) {
        // 優先順位: ヘッダー > サブドメイン > デフォルト
        String tenantId = request.getHeader(TENANT_HEADER);
        
        if (tenantId == null || tenantId.isBlank()) {
            tenantId = extractFromSubdomain(request);
        }
        
        if (tenantId == null || tenantId.isBlank()) {
            tenantId = "public";
        }
        
        return tenantId;
    }

    private String extractFromSubdomain(HttpServletRequest request) {
        String host = request.getServerName();
        // tenant-a.example.com -> tenant-a
        if (host != null && host.contains(".")) {
            return host.split("\\.")[0];
        }
        return null;
    }
}

スキーマ分離パターンの完全実装

ここでは、スキーマ分離パターンを使用したマルチテナント実装の完全なコード例を示します。

Hibernateの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.multitenancy.config;

import org.hibernate.cfg.AvailableSettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
public class HibernateMultiTenantConfig {

    private final MultiTenantConnectionProvider<String> multiTenantConnectionProvider;
    private final CurrentTenantIdentifierResolver<String> tenantIdentifierResolver;

    public HibernateMultiTenantConfig(
            MultiTenantConnectionProvider<String> multiTenantConnectionProvider,
            CurrentTenantIdentifierResolver<String> tenantIdentifierResolver) {
        this.multiTenantConnectionProvider = multiTenantConnectionProvider;
        this.tenantIdentifierResolver = tenantIdentifierResolver;
    }

    @Bean
    public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
        return hibernateProperties -> {
            hibernateProperties.put(
                AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER,
                multiTenantConnectionProvider
            );
            hibernateProperties.put(
                AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER,
                tenantIdentifierResolver
            );
        };
    }
}

application.ymlの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/multitenancy_db
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
        show_sql: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

テナントスキーマの初期化

新しいテナントが追加された際にスキーマを自動作成するサービスを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.example.multitenancy.service;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class TenantSchemaService {

    private final JdbcTemplate jdbcTemplate;

    public TenantSchemaService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void createTenantSchema(String tenantId) {
        // スキーマ名のバリデーション(SQLインジェクション対策)
        validateSchemaName(tenantId);
        
        // スキーマ作成
        jdbcTemplate.execute(
            String.format("CREATE SCHEMA IF NOT EXISTS %s", tenantId)
        );
        
        // テーブル作成(共通スキーマのテーブル構造をコピー)
        createTables(tenantId);
    }

    public List<String> getAllTenantSchemas() {
        return jdbcTemplate.queryForList(
            "SELECT schema_name FROM information_schema.schemata " +
            "WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'public')",
            String.class
        );
    }

    @Transactional
    public void deleteTenantSchema(String tenantId) {
        validateSchemaName(tenantId);
        jdbcTemplate.execute(
            String.format("DROP SCHEMA IF EXISTS %s CASCADE", tenantId)
        );
    }

    private void validateSchemaName(String schemaName) {
        if (!schemaName.matches("^[a-z][a-z0-9_]*$")) {
            throw new IllegalArgumentException(
                "Invalid schema name: " + schemaName
            );
        }
    }

    private void createTables(String tenantId) {
        // 例: 製品テーブルの作成
        jdbcTemplate.execute(String.format("""
            CREATE TABLE IF NOT EXISTS %s.products (
                id BIGSERIAL PRIMARY KEY,
                name VARCHAR(255) NOT NULL,
                price DECIMAL(10, 2) NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """, tenantId));
    }
}

動的DataSource切り替えの設計

データベース分離パターンを採用する場合、AbstractRoutingDataSourceを使用して動的にDataSourceを切り替えます。

ルーティングDataSourceの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.example.multitenancy.config;

import com.example.multitenancy.context.TenantContext;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }
}

DataSource設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.multitenancy.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.tenant-a")
    public DataSource tenantADataSource() {
        return new HikariDataSource();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.tenant-b")
    public DataSource tenantBDataSource() {
        return new HikariDataSource();
    }

    @Bean
    @Primary
    public DataSource routingDataSource() {
        TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
        
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("tenant-a", tenantADataSource());
        targetDataSources.put("tenant-b", tenantBDataSource());
        
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(tenantADataSource());
        
        return routingDataSource;
    }
}

カラム識別パターンでの@TenantIdと@Filter活用

Hibernate 6.0以降では、@TenantIdアノテーションを使用してカラム識別パターンを簡潔に実装できます。

エンティティへの@TenantId適用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.multitenancy.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.TenantId;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @TenantId
    @Column(name = "tenant_id", nullable = false)
    private String tenantId;

    @Column(nullable = false)
    private String name;

    @Column(precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    // コンストラクタ、ゲッター、セッター
    public Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // ゲッター・セッター省略
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTenantId() { return tenantId; }
    public void setTenantId(String tenantId) { this.tenantId = tenantId; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

@TenantIdを使用することで、Hibernateは以下の動作を自動的に行います。

  • INSERT時: 現在のテナント識別子が自動的に設定される
  • SELECT時: テナント識別子による自動フィルタリングが適用される
  • UPDATE/DELETE時: テナント識別子が暗黙的にWHERE句に追加される

@Filterによる柔軟なフィルタリング

より高度な制御が必要な場合は、@Filter@FilterDefを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.multitenancy.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;

@Entity
@Table(name = "orders")
@FilterDef(
    name = "tenantFilter",
    parameters = @ParamDef(name = "tenantId", type = String.class)
)
@Filter(
    name = "tenantFilter",
    condition = "tenant_id = :tenantId"
)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "tenant_id", nullable = false)
    private String tenantId;

    @Column(name = "order_number", nullable = false)
    private String orderNumber;

    @Column(name = "total_amount", precision = 12, scale = 2)
    private java.math.BigDecimal totalAmount;

    // コンストラクタ、ゲッター、セッター省略
    public Order() {}
    
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTenantId() { return tenantId; }
    public void setTenantId(String tenantId) { this.tenantId = tenantId; }
    public String getOrderNumber() { return orderNumber; }
    public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
    public java.math.BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(java.math.BigDecimal totalAmount) { 
        this.totalAmount = totalAmount; 
    }
}

フィルタの有効化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.multitenancy.config;

import com.example.multitenancy.context.TenantContext;
import jakarta.persistence.EntityManager;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.hibernate.Session;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TenantFilterAspect {

    private final EntityManager entityManager;

    public TenantFilterAspect(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Before("execution(* com.example.multitenancy.repository.*.*(..))")
    public void enableTenantFilter() {
        String tenantId = TenantContext.getCurrentTenant();
        if (tenantId != null) {
            Session session = entityManager.unwrap(Session.class);
            session.enableFilter("tenantFilter")
                   .setParameter("tenantId", tenantId);
        }
    }
}

よくある誤解とアンチパターン

マルチテナント実装において、開発者が陥りやすい誤解とアンチパターンを紹介します。

アンチパターン1: ThreadLocalのクリア忘れ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// NG: finallyブロックでクリアしていない
public void doFilter(ServletRequest request, ServletResponse response, 
                     FilterChain chain) throws IOException, ServletException {
    String tenantId = extractTenantId((HttpServletRequest) request);
    TenantContext.setCurrentTenant(tenantId);
    chain.doFilter(request, response);
    // ここでエラーが発生するとテナント情報がリークする
}

// OK: finallyブロックで確実にクリア
public void doFilter(ServletRequest request, ServletResponse response, 
                     FilterChain chain) throws IOException, ServletException {
    try {
        String tenantId = extractTenantId((HttpServletRequest) request);
        TenantContext.setCurrentTenant(tenantId);
        chain.doFilter(request, response);
    } finally {
        TenantContext.clear();
    }
}

アンチパターン2: 非同期処理でのテナントコンテキスト消失

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// NG: 非同期処理でテナント情報が引き継がれない
@Async
public void processOrder(Long orderId) {
    // TenantContext.getCurrentTenant()はnullを返す
    Order order = orderRepository.findById(orderId).orElseThrow();
}

// OK: テナント情報を引数で渡す
@Async
public void processOrder(Long orderId, String tenantId) {
    TenantContext.setCurrentTenant(tenantId);
    try {
        Order order = orderRepository.findById(orderId).orElseThrow();
    } finally {
        TenantContext.clear();
    }
}

アンチパターン3: ネイティブクエリでのフィルタリング漏れ

1
2
3
4
5
6
7
8
// NG: ネイティブクエリではテナントフィルタが適用されない
@Query(value = "SELECT * FROM products WHERE price > ?1", nativeQuery = true)
List<Product> findExpensiveProducts(BigDecimal minPrice);

// OK: 明示的にテナントIDでフィルタリング
@Query(value = "SELECT * FROM products WHERE price > ?1 AND tenant_id = ?2", 
       nativeQuery = true)
List<Product> findExpensiveProducts(BigDecimal minPrice, String tenantId);

アンチパターン4: スキーマ名のSQLインジェクション

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// NG: ユーザー入力を直接スキーマ名に使用
public void switchSchema(String userInput) {
    connection.setSchema(userInput); // SQLインジェクションの危険
}

// OK: ホワイトリスト検証を行う
public void switchSchema(String tenantId) {
    if (!allowedTenants.contains(tenantId)) {
        throw new SecurityException("Invalid tenant: " + tenantId);
    }
    connection.setSchema(tenantId);
}

実装時のベストプラクティス

マルチテナント実装を成功させるためのベストプラクティスをまとめます。

テナント識別子の設計

推奨事項 説明
不変識別子を使用 UUID等の変更不可な識別子を推奨
意味を持たせない テナント名ではなくIDを使用
命名規則を統一 tenant_xxxのような一貫した命名

セキュリティ対策

対策 実装方法
テナント検証 すべてのリクエストでテナントを検証
権限チェック テナントへのアクセス権限を確認
監査ログ テナント横断アクセスをログ記録

パフォーマンス最適化

最適化ポイント 実装方法
コネクションプール テナント別プールまたは共有プールの最適化
インデックス tenant_idカラムへのインデックス付与
クエリキャッシュ テナント別キャッシュの分離

まとめと実践Tips

Spring JPAでのマルチテナント実装について、3つのパターンと具体的な実装方法を解説しました。

選択の指針

  • 小規模SaaS・スタートアップ: カラム識別パターンで開始し、成長に応じて移行
  • 中規模SaaS: スキーマ分離パターンでバランスの取れた設計
  • 大規模エンタープライズ: データベース分離パターンで最高レベルの分離

実践Tips

  1. 段階的な導入: 最初はシンプルなカラム識別から始め、要件に応じてスキーマ分離に移行
  2. テスト戦略: テナント境界をまたぐテストケースを必ず作成
  3. 運用監視: テナントごとのリソース使用量を監視できる仕組みを構築
  4. マイグレーション: Flywayなどを使用したテナント別マイグレーション戦略を策定

マルチテナントアーキテクチャは一度決定すると変更が困難です。プロジェクト初期段階で要件を十分に分析し、適切なパターンを選択することが成功の鍵となります。

参考リンク