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#
- 段階的な導入: 最初はシンプルなカラム識別から始め、要件に応じてスキーマ分離に移行
- テスト戦略: テナント境界をまたぐテストケースを必ず作成
- 運用監視: テナントごとのリソース使用量を監視できる仕組みを構築
- マイグレーション: Flywayなどを使用したテナント別マイグレーション戦略を策定
マルチテナントアーキテクチャは一度決定すると変更が困難です。プロジェクト初期段階で要件を十分に分析し、適切なパターンを選択することが成功の鍵となります。
参考リンク#