단일 소프트웨어 인스턴스로 서로 다른 여러 사용자 그룹에 서비스를 제공할 수 있는 소프트웨어 아키텍처
Database per Multi Tenancy에 대해 구성 되었습니다.
또한 테넌트에 따라 변경되는 DataSource 설정 정보는 관리 DB에서 테이블로 관리되는 것을 사용했습니다.
Spring Data JPA를 사용하여 DataSource를 구성하기 때문에 hibernate에서 제공하는 멀티 테넌시 기능을 사용합니다.
MultiTenantConnectionProvider
AbstractDataSourceBasedMultiTenantConnectionProviderImpl 를 상속하여 사용한다.필수 구현 메서드
selectAnyDataSource()테넌트 ID에 해당하는 DataSource가 없는 경우 기본적으로 제공하는 DataSource를 전달한다.selectDataSource()테넌트 ID에 해당하는 DataSource를 전달한다.CurrentTenantIdentifierResolver
CurrentSessionContext와 org.hibernate.SessionFactory.getCurrentSession()에 현재 테넌트 ID를 식별하는 기능을 제공하는 인터페이스필수 구현 메서드
resolveCurrentTenantIdentifier()테넌트 ID를 확인하여 반환한다.ThreadLocal을 통해 현재 선택된 테넌트 ID를 관리한다.
public class TenantContext {
private static ThreadLocal<String> currentTenant = new InheritableThreadLocal<>();
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void setCurrentTenant(String tenant) {
currentTenant.set(tenant);
}
public static void clear() {
currentTenant.set(null);
}
}
요청이 들어오면 요청 헤더를 통해 TenantContext에 테넌트 ID를 추가하고 요청이 끝나면 삭제한다.
@Component
public class TenantIdRequestInterceptor implements AsyncHandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object object) throws Exception {
String requestURI = request.getRequestURI();
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId == null || !StringUtils.hasText(tenantId)) {
throw new IllegalArgumentException("X-Tenant-ID not present in the Request Header");
}
TenantContext.setCurrentTenant(tenantId);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
TenantContext.clear();
}
}
@Configuration
@RequiredArgsConstructor
public class WebConfiguration implements WebMvcConfigurer {
private final TenantIdRequestInterceptor tenantIdRequestInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantIdRequestInterceptor);
}
}DataSource를 빈 등록을 관리한다.
@Configuration
@EnableConfigurationProperties(ResourceProperties.class)
@RequiredArgsConstructor
public class DataSourceConfiguration {
private final ResourceProperties resourceProperties;
@Bean
@Primary
public DataSourceProperties dataSourceProperties() {
DataSourceProperties properties = new DataSourceProperties();
properties.setUrl(resourceProperties.getUrl());
properties.setUsername(resourceProperties.getUsername());
properties.setPassword(resourceProperties.getPassword());
return properties;
}
@Bean
public DataSource resourceDataSource() {
HikariDataSource dataSource = dataSourceProperties().initializeDataSourceBuilder()
.type(HikariDataSource.class)
.url(resourceProperties.getUrl())
.username(resourceProperties.getUsername())
.password(resourceProperties.getPassword())
.driverClassName(resourceProperties.getDriverClassName())
.build();
dataSource.setPoolName("resourceDataSource");
return dataSource;
}
}
@Getter
@Setter
@ConfigurationProperties("multi-tenancy.resource.datasource")
public class ResourceProperties {
private String url;
private String username;
private String password;
private String driverClassName;
}multi-tenancy:
resource:
datasource:
url: jdbc:mysql://127.0.0.1:3307/master?rewriteBatchedStatements=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.DriverdataSourceProperties() → 테넌트 ID 따라 DataSource를 생성할 때 사용되는 기본 DataSource Connection 설정 정보
resourceDataSource() → 테넌트 관리 DB에 연결되는 DataSource Connection 설정 정보
관리 DB DataSource Connection에 JPA 설정을 추가한다.
@Configuration
@ComponentScan({"com.gaegul.multitenancy.config"})
@EnableJpaRepositories(
basePackages = {"com.gaegul.multitenancy.resource"},
entityManagerFactoryRef = "resourceEntityManagerFactory",
transactionManagerRef = "resourceTransactionManager"
)
@RequiredArgsConstructor
public class ResourcePersistenceConfiguration {
private final ConfigurableListableBeanFactory beanFactory;
private final JpaProperties jpaProperties;
@Bean
public LocalContainerEntityManagerFactoryBean resourceEntityManagerFactory(@Qualifier("resourceDataSource") DataSource dataSource) {
LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setPersistenceUnitName("resource-persistence-unit");
entityManagerFactoryBean.setPackagesToScan("com.gaegul.multitenancy.resource");
entityManagerFactoryBean.setDataSource(dataSource);
entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
entityManagerFactoryBean.setJpaPropertyMap(properties());
return entityManagerFactoryBean;
}
private Map<String, Object> properties() {
Map<String, Object> properties = new HashMap<>(this.jpaProperties.getProperties());
properties.put(AvailableSettings.PHYSICAL_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy");
properties.put(AvailableSettings.IMPLICIT_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy");
properties.put(AvailableSettings.BEAN_CONTAINER, new SpringBeanContainer(this.beanFactory));
properties.put(AvailableSettings.DIALECT, this.jpaProperties.getDatabasePlatform());
properties.put(AvailableSettings.SHOW_SQL, this.jpaProperties.isShowSql());
return properties;
}
@Bean
public JpaTransactionManager resourceTransactionManager(@Qualifier("resourceEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(entityManagerFactory);
return transactionManager;
}
}
TenantContext에서 관리하고 있는 현재 테넌트ID를 확인하고 DB Session으로 전달한다.
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
@Value("${multi-tenancy.default-tenant-id}")
private String defaultTenantId;
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContext.getCurrentTenant();
if (StringUtils.hasText(tenantId)) {
return tenantId;
} else {
return defaultTenantId;
}
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
}
TenantContext에서 관리 중인 테넌트 ID가 없으면 application.yml의 설정되어 있는 기본 테넌트 ID를 전달한다.
테넌트 ID에 따라 DataSource Connection을 연결할 수 있도록 한다.
@Slf4j
@Component
@EnableConfigurationProperties(CacheProperties.class)
@RequiredArgsConstructor
public class TenantConnectionProvider extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
private static final String TENANT_POOL_NAME_SUFFIX = "DataSource";
private final DataSource resourceDataSource;
private final DataSourceProperties resourceDataSourceProperties;
private final ResourceRepository resourceRepository;
private final CacheProperties cacheProperties;
private LoadingCache<String, DataSource> tenantDataSources;
@PostConstruct
private void createCache() {
tenantDataSources = CacheBuilder.newBuilder()
.maximumSize(cacheProperties.getMaximumSize())
.expireAfterAccess(cacheProperties.getExpireAfterAccess(), cacheProperties.getTimeUnit())
.removalListener((RemovalListener<String, DataSource>) removal -> {
HikariDataSource ds = (HikariDataSource) removal.getValue();
ds.close();
})
.build(new CacheLoader<>() {
public DataSource load(String key) {
ResourceEntity resource = resourceRepository.findById(key)
.orElseThrow(() -> new NoSuchElementException("No such tenant: " + key));
return createAndConfigureDataSource(resource);
}
});
}
@Override
protected DataSource selectAnyDataSource() {
return resourceDataSource;
}
@Override
protected DataSource selectDataSource(String tenantIdentifier) {
try {
return tenantDataSources.get(tenantIdentifier);
} catch (ExecutionException e) {
throw new RuntimeException("Failed to load DataSource for tenant: " + tenantIdentifier);
}
}
private DataSource createAndConfigureDataSource(ResourceEntity resource) {
HikariDataSource ds = resourceDataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
ds.setUsername(resource.getUsername());
ds.setPassword(resource.getPassword());
ds.setJdbcUrl(resource.getUrl());
ds.setDriverClassName(DriverType.valueOf(resource.getDatabaseVersion()).getDriverClassName());
ds.setPoolName(resource.getTenantId() + TENANT_POOL_NAME_SUFFIX);
return ds;
}
}
캐시 전략 → CacheBuilder
Google Guava 라이브러리의 CacheBuilder를 사용하여 테넌트 ID에 해당하는 DataSource를 캐싱한다.
테넌트 ID에 해당하는 DataSource를 생성한다.테넌트 ID에 따라 변경되는 DataSource의 JPA 설정을 추가한다.
@Configuration
@ComponentScan({"com.gaegul.multitenancy.config"})
@EnableJpaRepositories(
basePackages = {"com.gaegul.**.repository"},
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager"
)
@RequiredArgsConstructor
public class TenantPersistenceConfiguration {
private final ConfigurableListableBeanFactory beanFactory;
private final JpaProperties jpaProperties;
@Value("${multi-tenancy.jpa.domain-package}")
private String domainPackage;
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean tenantEntityManagerFactory(@Qualifier("tenantConnectionProvider") MultiTenantConnectionProvider connectionProvider,
@Qualifier("tenantIdentifierResolver") CurrentTenantIdentifierResolver tenantResolver) {
LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setPersistenceUnitName("tenant-persistence-unit");
entityManagerFactoryBean.setPackagesToScan(domainPackage);
entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
entityManagerFactoryBean.setJpaPropertyMap(properties(connectionProvider, tenantResolver));
return entityManagerFactoryBean;
}
private Map<String, Object> properties(MultiTenantConnectionProvider connectionProvider, CurrentTenantIdentifierResolver tenantResolver) {
Map<String, Object> properties = new HashMap<>(this.jpaProperties.getProperties());
properties.put(AvailableSettings.PHYSICAL_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy");
properties.put(AvailableSettings.IMPLICIT_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy");
properties.put(AvailableSettings.BEAN_CONTAINER, new SpringBeanContainer(this.beanFactory));
properties.put(AvailableSettings.DIALECT, this.jpaProperties.getDatabasePlatform());
properties.put(AvailableSettings.SHOW_SQL, this.jpaProperties.isShowSql());
properties.put(AvailableSettings.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
properties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, connectionProvider);
properties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantResolver);
return properties;
}
@Bean
@Primary
public JpaTransactionManager tenantTransactionManager(@Qualifier("tenantEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
JpaTransactionManager tenantTransactionManager = new JpaTransactionManager();
tenantTransactionManager.setEntityManagerFactory(entityManagerFactory);
return tenantTransactionManager;
}
}
JPA 프로퍼티 설정에 Multi Tenant 관련 설정 추가
MultiTenantConnectionProvider, CurrentTenantIdentifierResolver 구현체 추가MultiTenancyStrategy.DATABASEMultiTenancyStrategy