Spring Data JPA 멀티 테넌시

Lim MyeongSeop·2022년 5월 28일
2

멀티테넌시란


단일 소프트웨어 인스턴스로 서로 다른 여러 사용자 그룹에 서비스를 제공할 수 있는 소프트웨어 아키텍처

  • 클라우드 컴퓨팅에서는 서로 다른 고객이 서버 리소스를 나누어 사용하는 공유 호스팅을 멀티테넌시라고 부르기도 합니다.
  • 멀티테넌트 애플리케이션은 애플리케이션의 모양과 느낌을 사용자 정의하거나, 테넌트가 사용자를 위한 특정한 액세스 제어 권한 및 제한을 지정하도록 허용하는 등 일반적으로 테넌트 수준의 사용자 정의를 포함하고 있습니다.

Spring Data JPA 멀티 테넌시 적용해보기

Database per Multi Tenancy에 대해 구성 되었습니다.

또한 테넌트에 따라 변경되는 DataSource 설정 정보는 관리 DB에서 테이블로 관리되는 것을 사용했습니다.


기술 스택

  • Spring Boot 2.6.7
    • Web
    • JPA
  • MySql
  • guava 29.0-jre

멀티 테넌시 서포트 인터페이스

Spring Data JPA를 사용하여 DataSource를 구성하기 때문에 hibernate에서 제공하는 멀티 테넌시 기능을 사용합니다.

MultiTenantConnectionProvider

  • 멀티 테넌시를 지원하는 어플리케이션에서 선택된 테넌트를 연결할 수 있는 기능을 제공하는 인터페이스
  • 추상 구현체인 AbstractDataSourceBasedMultiTenantConnectionProviderImpl 를 상속하여 사용한다.

필수 구현 메서드

  • selectAnyDataSource()
    • 요청된 테넌트 ID에 해당하는 DataSource가 없는 경우 기본적으로 제공하는 DataSource를 전달한다.
  • selectDataSource()
    • 요청된 테넌트 ID에 해당하는 DataSource를 전달한다.

CurrentTenantIdentifierResolver

  • CurrentSessionContextorg.hibernate.SessionFactory.getCurrentSession()에 현재 테넌트 ID를 식별하는 기능을 제공하는 인터페이스
  • 콜백 형식으로 동작한다.

필수 구현 메서드

  • resolveCurrentTenantIdentifier()
    • 현재 테넌트 ID를 확인하여 반환한다.

TenantContext

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);
    }
}

TenantIdRequestInterceptor

요청이 들어오면 요청 헤더를 통해 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();
    }
}
  • @Component를 통해 빈으로 등록된 인터셉터를 WebMvcConfigurer를 구현하는 설정 클래스에서 등록해준다.
    @Configuration
    @RequiredArgsConstructor
    public class WebConfiguration implements WebMvcConfigurer {
    
        private final TenantIdRequestInterceptor tenantIdRequestInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(tenantIdRequestInterceptor);
        }
    
    }

DataSourceConfiguration

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;
    }
}
  • 기본 설정은 application.yml에서 관리되고 관리 DB 정보는 프로퍼티를 읽어 클래스로 선언하여 사용한다.
    @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 설정 정보

ResourcePersistenceConfiguration

관리 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;
    }
}

TenantIdentifierResolver

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를 전달한다.

TenantConnectionProvider

테넌트 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를 캐싱한다.

  • 컴파일 시점에 DataSource를 생성하지 않고 런타임 시점에 요청이 들어오면 테넌트 ID에 해당하는 DataSource를 생성한다.

TenantPersistenceConfiguration

테넌트 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
      • DATABASE → 물리적으로 DB가 분리된 상태의 전략
      • SCHEMA → 논리적으로 스키마 정보가 분리된 상태의 전략
      • DISCRIMINATOR → 테이블 컬럼을 통해 데이터 정보를 분리한 상태 전략
      • NONE → 설정하지 않는다.
profile
조금 더 좋은 코드를 위해 고민해봅니다.

0개의 댓글