멀티테넌시 스위칭 Trouble Shooting

KwonKusang·2022년 7월 9일
1

1. 문제 발생☹

public class TenantContext {

		private static ThreadLocal<String> currentTenantId = ThreadLocal.withInitial(() -> DEFAULT_SCHEMA);
		
    public static void setCurrentTenantId(String tenantId) {
			currentTenantId.set(tenantId);
    }
}
  • 스키마 단위의 멀티테넌시를 사용 중 하나의 트랜잭션 안에서 다른 테넌트의 데이터를 조작해야하는 상황이 발생한다.
  • 트랜잭션을 실행하기 위해 DB 커넥션이 열리게 되고 ThreadLocal 에 저장된 테넌트를 변경하더라도 이미 실행된 커넥션의 스키마가 변경되지 않는다.
  • 커넥션 중에 스키마를 변경하는 방법을 찾지 못했다.
  • 현재는 API로 요청하여 다른 Thread를 생성한 후 다른 스키마에 커넥션을 맺고 데이터를 조회한다.
    • 즉, Thread가 다르면 조회가 가능하다는 것을 알 수 있었다.

현재 상태

  • API 요청로 응답을 받아온 후 트랜잭션에 롤백이 발생한다면 API 요청 시 새로 발생한 트랜잭션은 이미 커밋된 상태이고 롤백할 수 없다.
  • 한쪽 스키마(ats_common)는 데이터 조작을 지양하고 최대한 조회만 하는 것을 지향한다.
  • 멀티테넌시의 공용 스키마(ats_common)만 따로 분리할 계획도 갖고 있다.
  • API 요청을 위한 Controller, service… 추가 로직을 구현하는 것이 상황에 따라 생산성이 떨어질 수 있다.

2. 배경 지식 학습🤔

EntityManagerFactory

  • 생성 비용이 상당히 크기 때문에 한 개만 만들어서 Bean으로 등록 후 웹 어플리케이션에서 공유한다.
  • Thread safe하게 되어 있어서 여러 스레드가 동시에 접근해도 안전하다.

EntityManager

  • EntityManagerFactory로부터 생성된다. EntityManagerFactory.createEntityManager()
  • Non Thread safe 하여 동시에 접근하면 동시성 문제 발생하기 때문에 스레드간 공유하면 안된다.
  • 데이터베이스 연결이 필요한 시점까지 커넥션을 얻지 않는다.
    • API 요청 시 새로운 Thread가 생성되고 하나의 EntityManager를 갖는다. DB 접근이 필요한 시점이 되면 EntityManager가 커넥션 풀에서 커넥션을 가져온다.
  • 현재(멀티 테넌시)는 getAnyConnection()으로 커넥션을 가져온 후 스키마를 변경한다.
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;
    }
		...
}

Multitenancy in hibernate

Persistence Context

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

웹 애플리케이션과 영속성 관리

3. 설계⚙

방법 1. API 요청을 대신할 다른 Thread를 사용하는 방법

  • Thread마다 EntityManager 를 분리하고 각각의 커넥션을 맺는다.
  • 다른 Thread를 사용하는 방법
    • EventPublisher , 이벤트를 발생시켜 다른 스키마의 데이터를 조작한다. 하지만 결과값을 받을 수 없다. (Select 불가)
    • Async , 비동기로 실행하고 ListenableFuture와 같은 콜백 함수에서 결과 로직을 실행한다. 결과값을 사용할 수 있다. 하지만 비동기로 태스크를 실행한 후 해당 트랜잭션은 커밋된다.
    • API Request , 결과값을 받을 수 있지만 응답 후 롤백 발생 시 처리할 수 없다.

방법 2. 특정 스키마를 지정하는 EntityManager를 생성하고JPAQueyrFactory를 생성한다.

  • Querydsl에서 빈으로 등록한 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);
     }
}

문제점

  • JPARepository를 사용할 수 없다.
  • 롤백 시 반영이 안되는 것은 마찬가지이다.

방법3. 커넥션 핸들링 방식 변경

  • 멀티 테넌시를 설정하는 EntityManagerFactory 의 프로퍼티에 커넥션 핸들링 방식을 추가한다.
  • 기본 설정은 커넥션이 필요할 때 즉시 획득하고 세션(Hibernate Session?)이 종료될때까지 유지된다.
  • PhysicalConnectionHandlingMode.DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION) 변경
    • 트랜잭션 종료 후 바로 커넥션을 반납한다.
    • 컨트롤러 계층에서 트랜잭션을 분리하여 사용하면 핸들링이 가능하다.
      • 이 방법은 OSIV 설정이 OFF 상태라면 방식 변경 없이 컨트롤러에서 트랜잭션 분리가 가능하다. 반드시 OSIV 설정 확인!

PhysicalConnectionHandlingMode (Hibernate JavaDocs)

방법 4. EntityManagerFactory 분리

  1. @EntityScan, setPackagesToScan 패키지 별로 분리한다.
  • 공용 스키마(ats_common)는 common, core 모듈을 스캔한 EntityManagerFactory를 생성한다.
  • 테넌트 스키마는 tenant, core 모듈을 스캔하여 생성한다.
  • PlatformTransactionManager를 생성한다. 이후 @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);
	}
}
  • 커스텀 레포지토리를 생성할 때 QuerydslRepositorySupport를 상속하여 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()
        );
    }
  • 이전에 생성한 PlatformTransactionManage를 @Transactional 에 지정하면 해당 EntityManagerFactory 로 트랜잭션을 생성할 수 있다. (Service 계층)
@Transactional(value = "commonTransactionManager", readOnly = true)
@Service
@RequiredArgsConstructor
public class TenantService {
}

문제점

  • @EnableJpaRepositories 의 basePackages를 기준으로 스캔된 레포지토리들이 JPARespository를 사용할 수 있게 된다.
  • 하지만 도메인 모델 패턴을 따르기 때문에 Common과 Tenant 스키마에 대한 레포지토리가 혼용되어 있다. (대규모 리팩토링 필요하고 현재 프로젝트에 혼선을 발생시킬 위험성이 높다.)
  • Common과 Tenant 모두에 필요한 Core 모듈이 존재함으로서 빈 등록이 중복이 발생할 수 있다.

방법 5. 엔티티별 스키마 지정 @Table(catalog = "ats_common")

  • MySQL은 데이터베이스라는 개념이 존재하지 않는다. 동의어 취급 (데이터베이스 == 스키마 == 네임스페이스) 실제로 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;
}

Database Schema and Catalog

지정 테넌트 Test

  • 테넌트 스키마(SJP)로 커넥션을 맺는다.
  • 스키마가 지정된 Tenant 엔티티는 Common 스키마로 정상적으로 조회한다.
  • 커넥션 시 SJP 스키마로 세팅되었으므로 User 엔티티(지정x)는 정상적으로 조회한다.
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 스키마가 지정된 모습

Rollback Test

  • (문제 해결)서로 다른 스키마를 사용하더라도 롤백이 정상적으로 발생한다.
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개)의 리팩토링이 필요하다.

문제 해결(인줄 알았다…)😎

방법 6. Hibernate Interceptor + 엔티티별 스키마 지정(방법 5)

  • Dynamic Entity Schema
@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);
    }
}
  • EntityManagerFactory의 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=?

Example!!

  • 필요할 때 테넌트 변경해서 사용! 생산성 UP!!!
  • 하나의 트랜잭션에서 여러 개의 스키마 조작 가능
  • 롤백 발생 시 동기화 가능
  • 일부 추가 구현으로 높은 생산성
@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")공용 스키마의 엔티티는 애노테이션을 추가한다. 공용 스키마와 테넌트 스키마 사이에 공통되는 엔티티가 없어져서 영속성 컨텍스트가 겹칠 상황이 발생하지 않는다. 테넌트 사이의 변경은 스키마 지정이 안돼서 현재처럼 스키마 변경이 불가하다.

감사합니다.

profile
안녕하세요! 백엔드 개발자 권구상입니다.

2개의 댓글

comment-user-thumbnail
2024년 4월 24일

혹시 해당글 깃에 올라가져있나요..?

1개의 답글