Spring Data JPA and Multitenancy

Jihwan Kim·2023년 3월 13일

Overview

운영중인 기존 서비스에서 "사용자마다 격리된 데이터베이스 리소스를 제공해야 한다" 는 요구사항이 있었다. JPA(Hibernate) 환경에서 이것을 적용하는 방법을 소개하고자 한다.

Multi-Tenancy 란?

멀티테넌시는 단일 어플리케이션 인스턴스로 여러 고객에게 서비스를 제공하는 하는 아키텍처를 말한다. SaaS(Software-as-a-Service, SaaS) 가 멀티테넌시의 대표적인 예이다.
멀티테넌시에서는 리소스를 Tenant 별로 공유(Sharing)하거나 격리(Isolating)하여으로 제공할 수 있다. 여기에서는 Database를 Tenant 마다 격리하는 방법에 대해서 알아본다.

Multi-Tenancy in Hibernate

Hibernate 에서는 두가지 방식 (Separate database, Separate Schema) 를 지원한다.

  • Separate Database - Database per Tenant
    Seperate Database
  • Separate Schema - Schema per Tenant
    Seperate Schema

두가지 방식 모두 멀티테넌시를 지원하기 위해서 MultiTenantConnectionProvider 와 CurrentTenantIdentifierResolver 를 구현해야 한다.

  • MultiTenantConnectionProvider
    • 테넌트 마다 지정된 DB 커넥션을 제공
    • hibernate.multi_tenant_connection_provider 프로퍼티로 지정
    • 지정하지 않으면 DataSourceBasedMultiTenantConnectionProviderImpl를 사용
  • CurrentTenantIdentifierResolver
    • 테넌트 식별자를 확인
    • hibernate.tenant_identifier_resolver 로 지정

본 문서에서는 테넌트마다 물리적으로 분리된 Database를 사용하는 Separate Database 방식을 사용한다.

구현하기

Master/Tenant 스키마(도메인)를 분리하여 구현해야 한다.

  • Master
    • 서비스에 공통으로 필요한 데이터
    • Tenant
  • Tenant
    • 테넌트마다 분리 보관해야 하는 데이터
    • Company, User

테넌트는 공유 Database를 사용하거나 격리된 데이터 베이스를 사용 할 수 있다.

  • Shared Tenant
  • Isolated Tenant
    멀티테넌시

멀티테넌시를 구현하기 위해서는 다음과 같은 작업이 필요하다.

  • Master/Tenant Database 구현
  • Tenant 식별하기
  • Tenant Database 생성 및 업데이트

Master 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 Database

CurrentTenantIdentifierResolver, MultiTenantConnectionProvider

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

}

Tenant DataSource Caching

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

TenantDatabaseConfig

앞에서 구현한 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);
  }

}

Tenant 식별하기

테넌트 Database를 접속하기 위해서는 CurrentTenantIdentifierResolver 가 tenatId를 식별할 수 있도록 TenantContext 에 tenantId를 셋팅해야 한다. 테넌트 Database에 접속하는 상황에 따라 tenantId를 결정하는 방식이 달라진다. 서비스의 경우 로그인한 사용자의 UserContext나 Token 에서 tenantId 를 얻을 수 있을 것이다. 관리자 시스템의 경우 접근하고자 하는 테넌트 정보에 따라 각각 tenantId를 얻어야 한다. 그리고 클라이언트가 tenantId를 가지고 있다면 요청 헤더에 tenantId 를 보내도록 하여 tenatId를 식별할 수 있다.

  • 요청헤더로 TenantContext 구성하기
@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");
    }

  }
}
  • 접근하고자하는 테넌트 정보로 Tenant 구성하기
    Controller 에서 tenantId 로 사용될 파라미터를 지정하고 동적으로 해당 값을 가지고 Tenant Context 를 구성한다.

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 Database 생성 및 업데이트

테넌트 추가시 현재 버전의 Tenant Domain 으로 부터 Database를 생성해야 한다. 도메인(스키마)이 업데이트 된 경우에 기존 테넌들의 Database를 업데이트 해야 한다. 스키마 업데이트는 Flyway를 사용한다.

Tenant Entity로 부터 Database 생성하기

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

OSIV를 사용하는 경우에 요청이 시작되었을때 Open된 EntityManager는 중간에 Tenant Context를 변경하여도 EntityManager가 변경되지 않기 때문에 요청시 테넌트를 특정할 수 없는 경우에는 OSIV를 사용하면 안된다.

부가적으로 필요한 테이블

서비스에 따라서 추가로 다음과 같은 테이블이 필요할 수 있다.

  • 테넌트 식별 테이블
    로그인한 사용자가 접근하는 경우에는 로그인정보로 테넌트를 식별할 수 있는데, 로그인하지 않고 접근하는 경우에 테넌트를 식별하기 위한 부가적인 테이블들을 추가로 만들어야 한다.
  • 테넌트 관리 테이블
    관리자 시스템에서 각 테넌트를 관리하는 기능을 제공한다면 이를 위해 필요한 관리용 테이블들이 추가되어야 한다.
  • 통계 테이블
    통계 데이터 제공을 위한 테이블들이 추가되어야 한다.

이 테이블들은 Master DB에 위치한다. 각 격리된 테넌트 DB에서 데이터가 변경시에 Master DB의 테넌트 식별 테이블, 관리 테이블, 통계 테이블의 데이터도 같이 갱신해줘야 한다.

Master/Tenant 분리 기준

Master에 둘 수 있는 테이블의 제한이 특별히 없다면, 통합 관리되어야 하는 데이터들(User, License 등)은 Master(공통 서비스) 에 두는 것이 좋다. 그러면 별도로 관리용 테이블이 필요가 없기 때문에 구현이 간단해진다.

Conclusion

전체 코드는 여기에...

References

Hibernate Guide

profile
Just Do It

0개의 댓글