운영중인 기존 서비스에서 "사용자마다 격리된 데이터베이스 리소스를 제공해야 한다" 는 요구사항이 있었다. JPA(Hibernate) 환경에서 이것을 적용하는 방법을 소개하고자 한다.
멀티테넌시는 단일 어플리케이션 인스턴스로 여러 고객에게 서비스를 제공하는 하는 아키텍처를 말한다. SaaS(Software-as-a-Service, SaaS) 가 멀티테넌시의 대표적인 예이다.
멀티테넌시에서는 리소스를 Tenant 별로 공유(Sharing)하거나 격리(Isolating)하여으로 제공할 수 있다. 여기에서는 Database를 Tenant 마다 격리하는 방법에 대해서 알아본다.
Hibernate 에서는 두가지 방식 (Separate database, Separate Schema) 를 지원한다.


두가지 방식 모두 멀티테넌시를 지원하기 위해서 MultiTenantConnectionProvider 와 CurrentTenantIdentifierResolver 를 구현해야 한다.
본 문서에서는 테넌트마다 물리적으로 분리된 Database를 사용하는 Separate Database 방식을 사용한다.
Master/Tenant 스키마(도메인)를 분리하여 구현해야 한다.
테넌트는 공유 Database를 사용하거나 격리된 데이터 베이스를 사용 할 수 있다.

멀티테넌시를 구현하기 위해서는 다음과 같은 작업이 필요하다.
Master Database 관련 DataSource, EntityManager 를 설정한다.
MasterDatabaseConfig.java
@Configuration
@EnableJpaRepositories(basePackages = "jhkim105.tutorials.multitenancy.master.repository")
@RequiredArgsConstructor
public class MasterDatabaseConfig {
public static final String PERSISTENCE_UNIT_NAME = "master";
private static final String DOMAIN_PACKAGE = "jhkim105.tutorials.multitenancy.master.domain";
private final DataSource dataSource;
private final EntityManagerFactoryBuilder builder;
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
return builder.dataSource(dataSource)
.packages(DOMAIN_PACKAGE)
.persistenceUnit(PERSISTENCE_UNIT_NAME)
.build();
}
@Bean
@Primary
public PlatformTransactionManager transactionManager(@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
Tenant 접속정보를 저장하기 위한 Entity를 추가한다.
Tenant.java
@Entity
@Getter
@Setter
@ToString
@Audited
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(callSuper = false, of = "id")
public class Tenant {
public static final String DEFAULT_TENANT_ID = "default";
public static final String DATABASE_NAME_PREFIX = "demo_multitenancy_";
@Id
@GenericGenerator(name = "uuid", strategy = "uuid2")
@GeneratedValue(generator = "uuid")
@Column(length = 50)
private String id;
@Column(unique = true)
private String name;
private String dbName;
private String dbAddress;
private String dbUsername;
private String dbPassword;
private int maxTotal = 10;
private int maxIdle = 10;
private int minIdle = 0;
private int initialSize = 0;
@Builder
public Tenant(String name, String dbAddress, String dbUsername, String dbPassword) {
this.name = name;
this.dbName = name;
this.dbAddress = dbAddress;
this.dbUsername = dbUsername;
this.dbPassword = dbPassword;
}
@Transient
public String getJdbcUrl() {
return String.format("jdbc:mariadb://%s/%s?createDatabaseIfNotExist=true", dbAddress, getDatabaseName());
}
@Transient
public String getDatabaseName() {
return String.format("%s%s", DATABASE_NAME_PREFIX, dbName);
}
}
Tenant를 식별하고 각 테넌트 데이터베이스 연결을 위한 DataSource 를 제공하기 위해, CurrentTenantIdentifierResolver, MultiTenantConnectionProvide 를 구현해야 한다.
CurrentTenantIdentifierResolver
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContextHolder.getTenantId();
return StringUtils.hasText(tenant) ? tenant : DEFAULT_TENANT_ID;
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
TenantContext
public class TenantContext {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
contextHolder.set(tenantId);
}
public static String getTenantId() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
DataSourceBasedMultiTenantConnectionProviderImpl
@Slf4j
@RequiredArgsConstructor
public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
private static final long serialVersionUID = 2353465673130594043L;
private final BasicDataSource dataSource;
private final TenantDataSources tenantDataSources;
@Override
protected DataSource selectAnyDataSource() {
log.info("selectAnyDataSource: masterDataSource selected.");
return dataSource;
}
@Override
protected DataSource selectDataSource(String tenantId) {
if (DEFAULT_TENANT_ID.equals(tenantId)) {
log.info("MasterDataSource selected");
return dataSource;
}
BasicDataSource tenantDataSource = (BasicDataSource) tenantDataSources.get(tenantId);
log.info("Tenant DataSource selected. url: [{}], cacheSize: [{}]", tenantDataSource.getUrl(), tenantDataSources.size());
return tenantDataSource;
}
}
Database Connection 필요한 경우 생성하고, 일정시간 지나면 반환될 수 있도록 Guava Cache(https://github.com/google/guava) 를 사용하여 DataSource를 구성한다.
TenantDataSources
public class TenantDataSources implements InitializingBean {
private final TenantDataSourceCacheProperties properties;
private final BasicDataSource dataSource;
private final TenantRepository tenantRepository;
private LoadingCache<String, DataSource> caches;
@Override
public void afterPropertiesSet() {
createDataSourceCache();
}
private void createDataSourceCache() {
log.info("DataSourceCache create. maxSize: {}, expireMinutes: {}", properties.getMaxSize(), properties.getExpireMinutes());
caches = CacheBuilder.newBuilder()
.maximumSize(properties.getMaxSize())
.expireAfterAccess(properties.getExpireMinutes(), TimeUnit.MINUTES)
.removalListener((RemovalListener<String, DataSource>) removal -> {
BasicDataSource ds = (BasicDataSource) removal.getValue();
try {
ds.close();
log.info("Closed datasource(url:[{}]).", ds.getUrl());
} catch (SQLException e) {
log.warn(e.toString());
}
})
.build(new CacheLoader<>() {
public DataSource load(String key) {
Tenant tenant = tenantRepository.findById(key)
.orElseThrow(() -> new IllegalStateException(String.format("Tenant not exists. id([%s])", key)));
return createDataSource(tenant);
}
});
}
....
}
앞에서 구현한 CurrentTenantIdentifierResolver, MultiTenantConnectionProvider 를 사용하여 EntityManagerFactoryBean 을 등록한다.
@Configuration
@EnableJpaRepositories(basePackages = {"jhkim105.tutorials.multitenancy.tenant.repository"},
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager")
@Slf4j
public class TenantDatabaseConfig {
public static final String PERSISTENCE_UNIT_NAME = "tenant";
public static final String DOMAIN_PACKAGE = "jhkim105.tutorials.multitenancy.tenant.domain";
@Bean
@ConfigurationProperties(prefix = "tenant.datasource-cache")
public TenantDataSourceCacheProperties tenantDataSourceCacheProperties() {
return new TenantDataSourceCacheProperties();
}
@Bean
@DependsOn("entityManagerFactory")
public TenantDataSources tenantDataSources(TenantDataSourceCacheProperties tenantDataSourceCacheProperties,
TenantRepository tenantRepository, BasicDataSource dataSource) {
log.info("TenantDataSources create.");
return new TenantDataSources(tenantDataSourceCacheProperties, dataSource, tenantRepository);
}
@Bean
public MultiTenantConnectionProvider multiTenantConnectionProvider(BasicDataSource dataSource, TenantDataSources tenantDataSources) {
log.info("multiTenantConnectionProvider create.");
return new DataSourceBasedMultiTenantConnectionProviderImpl(dataSource, tenantDataSources);
}
@Bean
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
return new CurrentTenantIdentifierResolverImpl();
}
@Bean
public LocalContainerEntityManagerFactoryBean tenantEntityManagerFactory(
@Qualifier("multiTenantConnectionProvider") MultiTenantConnectionProvider connectionProvider,
@Qualifier("currentTenantIdentifierResolver") CurrentTenantIdentifierResolver tenantIdentifierResolver,
@Lazy JpaProperties jpaProperties) {
log.info("tenantEntityManagerFactory create.");
LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setPackagesToScan(DOMAIN_PACKAGE);
entityManagerFactoryBean.setPersistenceUnitName(PERSISTENCE_UNIT_NAME);
entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
Map<String, Object> properties = new HashMap<>();
properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, connectionProvider);
properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifierResolver);
properties.putAll(jpaProperties.getProperties());
entityManagerFactoryBean.setJpaPropertyMap(properties);
return entityManagerFactoryBean;
}
@Bean
public PlatformTransactionManager tenantTransactionManager(@Qualifier("tenantEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Bean
public JPAQueryFactory tenantQueryFactory(@Qualifier("tenantEntityManagerFactory") EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}
테넌트 Database를 접속하기 위해서는 CurrentTenantIdentifierResolver 가 tenatId를 식별할 수 있도록 TenantContext 에 tenantId를 셋팅해야 한다. 테넌트 Database에 접속하는 상황에 따라 tenantId를 결정하는 방식이 달라진다. 서비스의 경우 로그인한 사용자의 UserContext나 Token 에서 tenantId 를 얻을 수 있을 것이다. 관리자 시스템의 경우 접근하고자 하는 테넌트 정보에 따라 각각 tenantId를 얻어야 한다. 그리고 클라이언트가 tenantId를 가지고 있다면 요청 헤더에 tenantId 를 보내도록 하여 tenatId를 식별할 수 있다.
@Component
@Slf4j
public class TenantFilter extends OncePerRequestFilter {
public static final String X_TENANT_ID_HEADER = "X-Tenant-ID";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String tenantId = request.getHeader(X_TENANT_ID_HEADER);
if (StringUtils.isNotBlank(tenantId)) {
log.debug("TenantContext set, {}", tenantId);
TenantContext.setTenantId(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
log.debug("TenantContext deleted");
}
}
}
TenantSetter
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantSetter {
String key() default "";
}
TenantAspect
Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class TenantAspect {
private final TenantService tenantService;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(jhkim105.tutorials.multitenancy.tenant.context.TenantSetter)")
public Object invoke(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
TenantSetter tenantSetter = method.getAnnotation(TenantSetter.class);
String key = (String)getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), tenantSetter.key());
log.debug("key: {}", key);
Tenant tenant = tenantService.findById(key);
try {
if (tenant != null) {
TenantContext.setTenantId(tenant.getId());
log.debug("TenantContext created");
}
return joinPoint.proceed();
} finally {
TenantContext.clear();
log.debug("TenantContext deleted");
}
}
private Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@PostMapping
@TenantSetter(key = "#userCreateRequest.tenantId")
public User save(@RequestBody UserCreateRequest userCreateRequest) {
User user = User.builder()
.tenantId(userCreateRequest.tenantId)
.username(userCreateRequest.username)
.build();
return userService.create(user);
}
...
테넌트 추가시 현재 버전의 Tenant Domain 으로 부터 Database를 생성해야 한다. 도메인(스키마)이 업데이트 된 경우에 기존 테넌들의 Database를 업데이트 해야 한다. 스키마 업데이트는 Flyway를 사용한다.
TenantDatabaseHelper
public void createDatabase(Tenant tenant) {
Map<String, Object> settings = new HashMap<>();
settings.put(Environment.URL, tenant.getJdbcUrl());
settings.put(Environment.USER, tenant.getDbUsername());
settings.put(Environment.PASS, tenant.getDbPassword());
settings.put(Environment.SHOW_SQL, true);
settings.put(Environment.FORMAT_SQL, true);
settings.putAll(jpaProperties.getProperties());
StandardServiceRegistry serviceRegistry = new StandardServiceRegistryBuilder().applySettings(settings).build();
MetadataSources metadataSources = new MetadataSources(serviceRegistry);
addAttributeConverter(metadataSources);
PathMatchingResourcePatternResolver resourceLoader = new PathMatchingResourcePatternResolver();
new LocalSessionFactoryBuilder(null, resourceLoader, metadataSources)
.scanPackages(TenantDatabaseConfig.DOMAIN_PACKAGE);
Metadata metadata = metadataSources.buildMetadata();
SchemaExport schemaExport = new SchemaExport();
schemaExport.createOnly(EnumSet.of(TargetType.DATABASE), metadata);
}
Tenant Database 생성시 Flyway baseline 을 설정해주어야 한다.
TenantService
private void createDatabase(Tenant tenant) {
tenantDatabaseHelper.createDatabase(tenant);
setUpFlywayBaseline(tenant);
}
private void setUpFlywayBaseline(Tenant tenant) {
if (!tenantFlywayProperties.isMigrateOnTenantAdd()) {
return;
}
Flyway flyway = Flyway.configure()
.dataSource(tenant.getJdbcUrl(), tenant.getDbUsername(), tenant.getDbPassword())
.locations(tenantFlywayProperties.getLocations())
.baselineOnMigrate(true)
.baselineVersion(tenantFlywayProperties.getBaselineVersion())
.load();
flyway.migrate();
}
TenantDatabaseMigrator
@Slf4j
@RequiredArgsConstructor
public class TenantDatabaseMigrator {
private final TenantManager tenantManager;
private final TenantFlywayProperties tenantFlywayProperties;
void migrate() {
if (!tenantFlywayProperties.isMigrateOnServerStart()) {
return;
}
log.info("migrate tenant");
List<Tenant> tenantList = tenantManager.findAll();
tenantList.stream().forEach(this::migrate);
}
private void migrate(Tenant tenant) {
log.info("migrate for [tenantId: {}, tenantName: {}]", tenant.getId(), tenant.getName());
Flyway flyway = Flyway.configure()
.dataSource(tenant.getJdbcUrl(), tenant.getDbUsername(), tenant.getDbPassword())
.locations(tenantFlywayProperties.getLocations())
.load();
try {
flyway.migrate();
} catch(FlywayException ex) {
log.warn(String.format("Migration Error: %s, Tenant::%s", ex, tenant));
}
}
}
서버 시작시 TenantDatabaseMigrator 를 실행하도록 한다.
public class TenantDatabaseConfig implements InitializingBean {
...
@Bean(initMethod = "migrate")
public TenantDatabaseMigrator tenantDatabaseMigrator(TenantManager tenantManager, TenantFlywayProperties tenantFlywayProperties) {
return new TenantDatabaseMigrator(tenantManager, tenantFlywayProperties);
}
...
}
단일 데이터베이스로 구현된 기존 서비스에 멀티테넌시를 적용하기 위해서는 기존 스키마에서 테넌트 스키마를 분리해야 한다. 이때 Master 스키마와 연관관계가 있을 경우에는 연관관계를 없애고 필요한 경우 반정규화를 해야 한다. 이 작업과 관련해서 코드 수정이 가장 많이 발생한다. 운영중인 서비스를 수정하는 것이어서 배포시 데이터 마이그레이션 작업도 필요하다.
OSIV를 사용하는 경우에 요청이 시작되었을때 Open된 EntityManager는 중간에 Tenant Context를 변경하여도 EntityManager가 변경되지 않기 때문에 요청시 테넌트를 특정할 수 없는 경우에는 OSIV를 사용하면 안된다.
서비스에 따라서 추가로 다음과 같은 테이블이 필요할 수 있다.
이 테이블들은 Master DB에 위치한다. 각 격리된 테넌트 DB에서 데이터가 변경시에 Master DB의 테넌트 식별 테이블, 관리 테이블, 통계 테이블의 데이터도 같이 갱신해줘야 한다.
Master에 둘 수 있는 테이블의 제한이 특별히 없다면, 통합 관리되어야 하는 데이터들(User, License 등)은 Master(공통 서비스) 에 두는 것이 좋다. 그러면 별도로 관리용 테이블이 필요가 없기 때문에 구현이 간단해진다.
전체 코드는 여기에...