public class TenantContext {
private static ThreadLocal<String> currentTenantId = ThreadLocal.withInitial(() -> DEFAULT_SCHEMA);
public static void setCurrentTenantId(String tenantId) {
currentTenantId.set(tenantId);
}
}
ThreadLocal
에 저장된 테넌트를 변경하더라도 이미 실행된 커넥션의 스키마가 변경되지 않는다.EntityManagerFactory
EntityManager
public class MultiTenantConnectionProviderImpl implements MultiTenantConnectionProvider {
...
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
String tenantId = TenantContext.getCurrentTenantId();
Connection connection = getAnyConnection();
connection.setSchema(tenantId);
connection.createStatement()
.execute(String.format("use `%s`;", tenantId));
return connection;
}
...
}
Persistence Context
스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.
EntityManager
를 분리하고 각각의 커넥션을 맺는다.EventPublisher
, 이벤트를 발생시켜 다른 스키마의 데이터를 조작한다. 하지만 결과값을 받을 수 없다. (Select 불가)Async
, 비동기로 실행하고 ListenableFuture와 같은 콜백 함수에서 결과 로직을 실행한다. 결과값을 사용할 수 있다. 하지만 비동기로 태스크를 실행한 후 해당 트랜잭션은 커밋된다.API Request
, 결과값을 받을 수 있지만 응답 후 롤백 발생 시 처리할 수 없다.jpaQueryFactoryCommon
을 주입 받는다.@Configuration
public class JpaQueryFactoryConfig {
@Bean(name = "jpaQueryFactoryCommon")
JPAQueryFactory jpaQueryFactoryCommon(EntityManagerFactory entityManagerFactory) {
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
entityManager.createNativeQuery(String.format("use `%s`;", TENANT_SCHEMA)).executeUpdate();
entityManager.getTransaction().commit();
return new JPAQueryFactory(entityManager);
}
}
EntityManagerFactory
의 프로퍼티에 커넥션 핸들링 방식을 추가한다.PhysicalConnectionHandlingMode.DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION)
변경PhysicalConnectionHandlingMode (Hibernate JavaDocs)
@EntityScan
, setPackagesToScan
패키지 별로 분리한다. EntityManagerFactory
를 생성한다.@Transactional
에서 사용한다.// Tenant
@Configuration
@EntityScan(basePackages = {"com.jainwon.ats.tenant", "com.jainwon.ats.core"})
@EnableJpaRepositories(basePackages = {"com.jainwon.ats.domain"},
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager"
)
public class HibernateConfig {
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Primary
@Bean(name = "tenantEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource,
MultiTenantConnectionProvider multiTenantConnectionProviderImpl,
CurrentTenantIdentifierResolver currentTenantIdentifierResolverImpl,
ConfigurableListableBeanFactory beanFactory) {
Map<String, Object> properties = new HashMap<>();
properties.put(AvailableSettings.HBM2DDL_AUTO, "none");
properties.put(AvailableSettings.FORMAT_SQL, "true");
properties.put(AvailableSettings.PHYSICAL_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy");
properties.put(AvailableSettings.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
properties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProviderImpl);
properties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolverImpl);
properties.put(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, 500);
properties.put(AvailableSettings.BEAN_CONTAINER, new SpringBeanContainer(beanFactory));
LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
localContainerEntityManagerFactoryBean.setDataSource(dataSource);
localContainerEntityManagerFactoryBean.setPackagesToScan("com.jainwon.ats.tenant", "com.jainwon.ats.core");
localContainerEntityManagerFactoryBean.setJpaVendorAdapter(jpaVendorAdapter());
localContainerEntityManagerFactoryBean.setJpaPropertyMap(properties);
localContainerEntityManagerFactoryBean.setPersistenceUnitName("tenantEntityManager");
return localContainerEntityManagerFactoryBean;
}
@Primary
@Bean(name = "tenantTransactionManager")
public PlatformTransactionManager transactionManager(@Qualifier("tenantEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
// Common
@Configuration
@EntityScan(basePackages = {"com.jainwon.ats.common", "com.jainwon.ats.core"})
@EnableJpaRepositories(
basePackages = {"com.jainwon.ats.common"},
entityManagerFactoryRef = "commonEntityManagerFactory",
transactionManagerRef = "commonTransactionManager"
)
public class CommonHibernateConfig {
@Bean(name = "commonEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource,
JpaVendorAdapter jpaVendorAdapter,
ConfigurableListableBeanFactory beanFactory) {
Map<String, Object> properties = new HashMap<>();
properties.put(AvailableSettings.HBM2DDL_AUTO, "none");
properties.put(AvailableSettings.FORMAT_SQL, "true");
properties.put(AvailableSettings.PHYSICAL_NAMING_STRATEGY, "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy");
properties.put(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, 500);
properties.put(AvailableSettings.BEAN_CONTAINER, new SpringBeanContainer(beanFactory));
LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
localContainerEntityManagerFactoryBean.setDataSource(dataSource);
localContainerEntityManagerFactoryBean.setPackagesToScan("com.jainwon.ats.common", "com.jainwon.ats.core");
localContainerEntityManagerFactoryBean.setJpaVendorAdapter(jpaVendorAdapter);
localContainerEntityManagerFactoryBean.setJpaPropertyMap(properties);
localContainerEntityManagerFactoryBean.setPersistenceUnitName("commonEntityManager");
return localContainerEntityManagerFactoryBean;
}
@Bean(name = "commonTransactionManager")
public PlatformTransactionManager transactionManager(@Qualifier("commonEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
EntityManager
를 주입 받고 JpaQueryFactory
를 빈으로 등록한다.@Configuration
public class JpaQueryFactoryConfig {
@PersistenceContext(unitName = "tenantEntityManager")
private EntityManager tenantEntityManager;
@PersistenceContext(unitName = "commonEntityManager")
private EntityManager commonEntityManager;
@Primary
@Bean
JPAQueryFactory tenantJpaQueryFactory() {
return new JPAQueryFactory(tenantEntityManager);
}
@Bean
JPAQueryFactory commonJpaQueryFactory() {
return new JPAQueryFactory(commonEntityManager);
}
}
JPAQueryFactory
객체가 사용할 EntityManager
를 등록한다.@Repository
public abstract class CommonQuerydslRepositorySupport extends QuerydslRepositorySupport {
/**
* Creates a new {@link QuerydslRepositorySupport} instance for the given domain type.
*
* @param domainClass must not be {@literal null}.
*/
public CommonQuerydslRepositorySupport(Class<?> domainClass) {
super(domainClass);
}
@Override
@PersistenceContext(unitName = "commonEntityManager")
public void setEntityManager(EntityManager entityManager) {
super.setEntityManager(entityManager);
}
}
public class TenantRepositoryImpl extends CommonQuerydslRepositorySupport implements TenantRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
public TenantRepositoryImpl(@Qualifier("commonJpaQueryFactory") JPAQueryFactory jpaQueryFactory) {
super(Tenant.class);
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Optional<Tenant> findByFixedOrUpdatableDomain(String domain) {
return Optional.ofNullable(
jpaQueryFactory.selectFrom(tenant)
.where(tenant.fixedDomain.eq(domain).or(tenant.updatableDomain.eq(domain)))
.fetchFirst()
);
}
@Transactional
에 지정하면 해당 EntityManagerFactory
로 트랜잭션을 생성할 수 있다. (Service 계층)@Transactional(value = "commonTransactionManager", readOnly = true)
@Service
@RequiredArgsConstructor
public class TenantService {
}
@EnableJpaRepositories
의 basePackages를 기준으로 스캔된 레포지토리들이 JPARespository를 사용할 수 있게 된다.@Table(catalog = "ats_common")
show databases;
show schemas;
명령어 등이 같다.ats_common
스키마와 같이 단 하나만 존재하는 스키마라면 엔티티에 특정 스키마를 지정할 수 있다.@Entity
@Table(name = "tenant", catalog = "common")
@Data
public class Tenant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tenant_id")
private Long id;
private String privateKey;
}
public void changeConnectionTest() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createNativeQuery(String.format("use `%s`;", "sjp")).executeUpdate(); // 스키마 변경
em.getTransaction().commit();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
String testName = "0709:0116";
Tenant tenant = em.find(Tenant.class, 1L); // 지정된 스키마 common으로 조회
tenant.setPrivateKey(testName);
User user = em.find(User.class, 65L); // 커넥션이 연결된 시점의 스키마 sjp로 조회
user.setName(testName);
em.persist(user);
em.persist(tenant);
transaction.commit();
em.close();
}
Hibernate:
/* select
user
from
User user */ select
user0_.uid as uid1_4_,
user0_.create_date as create_d2_4_,
user0_.username as username3_4_,
user0_.password as password4_4_,
user0_.phone as phone5_4_,
user0_.team as team6_4_,
user0_.userid as userid7_4_
from
users user0_ // 커넥션 맺어졌을 때 디폴트 스키마
Hibernate:
/* select
generatedAlias0
from
Tenant as generatedAlias0 */ select
tenant0_.tenant_id as tenant_i1_0_,
tenant0_.private_key as private_2_0_
from
common.tenant tenant0_ // common 스키마가 지정된 모습
public void rollbackTest() {
EntityTransaction transaction = em.getTransaction();
transaction.begin();
String testName = "0709:0126";
Tenant tenant = em.find(Tenant.class, 1L);
tenant.setPrivateKey(testName);
User user = em.find(User.class, 65L);
user.setName(testName);
em.persist(tenant);
em.persist(user);
transaction.rollback();
em.close();
}
ats_common
을 지정할 경우 tenant에서 사용 불가하다. Core 모듈 엔티티(25개)의 리팩토링이 필요하다.@Entity
@Table(name = "tenant", catalog = "##schema##") // 스키마 지정
@Data
public class Tenant {
...
}
public class HibernateInspector implements StatementInspector {
@Override
public String inspect(String sql) {
String schema = TenantContext.getCurrentTenantId(); // ThreadLocal에 저장된 스키마명 가져옴
return sql.replaceAll("##schema##", schema);
}
}
STATEMENT_INSPECTOR
프로퍼티에 HibernateInspector 인스펙터 등록@Bean(name = "entityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(...) {
Map<String, Object> properties = new HashMap<>();
...
// Inspector 프로퍼티 등록
properties.put(AvailableSettings.STATEMENT_INSPECTOR, HibernateInspector.class);
LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
localContainerEntityManagerFactoryBean.setJpaPropertyMap(properties);
...
return localContainerEntityManagerFactoryBean;
}
@Table(catalog = "##schema##")
추가Hibernate:
select
tenant0_.tenant_id as tenant_i1_0_0_,
tenant0_.private_key as private_2_0_0_
from
common.tenant tenant0_ // Schema 지정됨
where
tenant0_.tenant_id=?
@Transactional
public void select(String privateKey) {
// 테넌트 변경
TenantContext.setDefault();
List<Tenant> tenantList = tenantRepository.findAll();
// 테넌트 변경
TenantContext.setCurrentTenantId(privateKey);
List<Position> positionList = positionRepository.findAll();
}
EntityManager 는 영속성 컨텍스트를 관리하면서 엔티티와 ID로 관리하기 때문에 스키마 변경에 대한 정보를 알지 못한다. 때문에 다른 테넌트(스키마) 사이에서 같은 엔티티와 같은 PK 값을 사용한다면 영속성 컨텍스트가 공유되어 1차 캐시로부터 데이터가 전달된다.
TenantContext에서 테넌트 정보를 변경할 때 영속성 컨텍스트를 바꿔주는 추가 작업이 구현되어야 한다.
@Component
public class TenantContext {
private static final String DEFAULT_SCHEMA = "ats_common";
private static EntityManager em;
@PersistenceContext
private EntityManager entityManager;
@PostConstruct
public void init() {
em = entityManager;
}
private static ThreadLocal<String> currentTenantId = ThreadLocal.withInitial(() -> DEFAULT_SCHEMA);
public static String getCurrentTenantId() {
return currentTenantId.get();
}
public static void setCurrentTenantId(String tenantId) {
currentTenantId.set(tenantId);
em.flush();
em.clear();
}
public static void setDefault() {
setCurrentTenantId(DEFAULT_SCHEMA);
}
}
현재 상태를 유지하면서 해당 스레드에서 파생된 EntityManager를 주입 받으려면 위와 같이 처리되어야 한다. @PostConstruct 를 사용한 이유는 TenantContext를 static으로 유지하기 위함이다.
또는 TenantContext 자체를 빈으로 주입 받아 사용하도록 변경이 필요하다.
다중 스키마를 사용하면서 사이드 이펙트를 최대한 줄이려면 확실하게 Datasource 또는 EntityManagerFactory를 분리하는 것이 Best. 현재 프로젝트에선 공용 스키마를 위한 EntityManagerFactory 추가 구현이 좋겠지만 공용 스키마의 Repository를 따로 분리하는 작업이 필요하다. 즉,@EnableJpaRepositories(basePackages = {}) 분리
다른 방법으로는 공용 스키마와 테넌트 스키마 모두에 포함되는 Core 모듈에 속한 엔티티들을 따로 생성 한다. (모듈이 달라도 같은 이름의 엔티티는 생성 불가하다.) @Table(catalog="common")공용 스키마의 엔티티는 애노테이션을 추가한다. 공용 스키마와 테넌트 스키마 사이에 공통되는 엔티티가 없어져서 영속성 컨텍스트가 겹칠 상황이 발생하지 않는다. 테넌트 사이의 변경은 스키마 지정이 안돼서 현재처럼 스키마 변경이 불가하다.
감사합니다.
혹시 해당글 깃에 올라가져있나요..?