단일 소프트웨어 인스턴스로 서로 다른 여러 사용자 그룹에 서비스를 제공할 수 있는 소프트웨어 아키텍처
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.Driver
dataSourceProperties()
→ 테넌트 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.DATABASE
MultiTenancyStrategy