멀티테넌시 스키마 분리 전략

2025. 6. 28. 00:53Spring

MSA 구조 설계 중 사용자 DB 관리에 사용된 방식으로 하나의 소프트웨어 인스턴스가 여러 사용자 그룹 또는 테넌트를 지원하도록 설계된 아키텍처입니다. 쉽게 말해 여러 사용자가 동일한 소프트웨어와 인프라를 공유하면서도 서로의 데이터나 설정을 간섭받지 않고 독립적으로 서비스를 이용할 수 있는 구조입니다.

 

다수의 이용자들을 하나의 애플리케이션 서비스를 제공하고 사용자별 데이터를 노출되지 않도록 완전 분리함. 

 

아키텍처 전체 흐름

  1. 클라이언트 요청 수신
  2. 필터/인터셉터에서 헤더에서 테넌트 식별자 추출(GateWay 서버에서 request에 추가되어 전달됨)
  3. Spring JPA 호출 시
    • DynamicDataSourceRouter가 TenantContext로부터 lookup key(테넌트 ID) 결정
    • 기존에 생성된 DataSource 반환 또는 신규 생성(createAndMigrate)
  4. Hibernate
    • CurrentTenantIdentifierResolverImpl로부터 현재 테넌트 ID 획득
    • SchemaMultiTenantConnectionProvider로부터 해당 스키마로 SET SCHEMA 처리된 커넥션 제공
    • 실제 SQL 실행
  5. 응답 완료 
    • TenantContext.clear

 

핵심 컴포넌트

  • TenantContext
  • CurrentTenantIdentifierResolverImpl
  • SchemaMultiTenantConnectionProvider
  • DynamicDataSourceRouter
  • HibernateConfig
  • DataSourceConfig

Flyway 마이그레이션

  • Flyway로 classpath:db/migration에 정의된 DDL 스크립트를
  • 방금 생성한 tenantId 스키마에 자동 실행
  • 테이블, 인덱스 등 초기 구조를 갖추도록 합니다.

기존 hibernate ddl을 이용하면 서버가 실행될 때만 DB table을 실행하기에 별도의 flyway에서 db 마이그래이션을 진행해 tenant-id가 주입되었을 때 동적 생성이 가능하도록 하였다

 

TenantContext & TenantInterceptor
public class TenantContext {

    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    public static void setTenant(String tenant) {
        CURRENT.set(tenant);
    }

    public static String getTenant() {
        return CURRENT.get();
    }

    public static void clear() {
        CURRENT.remove();
    }

}
@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String tenantId = request.getHeader("X-Tenant-ID");
        if (!StringUtils.hasText(tenantId)) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing X-Tenant-ID");
            return false;
        }
        TenantContext.setTenant(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req,
                                HttpServletResponse res,
                                Object handler,
                                Exception ex) {
        TenantContext.clear();
    }
}

 

  1. Http 응답에서 Tenant에 해당하는 헤더를 추출 후 TenantContext.setTenant를 통해 ThreadLocal에 저장
  2. 작업이 끝나면 TenantContext.clear() 작업 종료 
CurrentTenantIdentifierResolverImpl
@Component
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.getTenant();
        return (tenant != null && !tenant.isEmpty()) ? tenant : "public";
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

 

Hibernate의 다중 테넌시 단계에서 현재 테넌트 식별자를 호출

  1. TenantContext에서 ID 획득
  2. 값이 없다면 기본값 PUBLIC 사용
  3. 식별자 변경을 허용 validateExistingCurrentSessions = true
    • 동적 테넌시를 통해 요청별 스키마가 변경 가능해야 함
SchemaMultiTenantConnectionProvider
@Component
@ManagedResource(objectName="com.msa:type=MultiTenant,bean=SchemaProvider")
public class SchemaMultiTenantConnectionProvider implements MultiTenantConnectionProvider {

    private final DataSource defaultDataSource;
    private final Set<String> initializedTenants = ConcurrentHashMap.newKeySet();
    private static final String DEFAULT_SCHEMA = "PUBLIC";
    
    public SchemaMultiTenantConnectionProvider(@Qualifier("defaultDataSource") DataSource defaultDataSource) {
        this.defaultDataSource = defaultDataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        return defaultDataSource.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(Object tenantIdentifier) throws SQLException {
        Connection connection = getAnyConnection();
        String tenant = tenantIdentifier.toString().toUpperCase();

        if (DEFAULT_SCHEMA.equals(tenant)) {
            try (Statement stmt = connection.createStatement()) {
                stmt.execute("SET SCHEMA " + DEFAULT_SCHEMA);
                log.info("Switched to default schema: {}", DEFAULT_SCHEMA);
            }
            return connection;
        }

        log.info("tenantIdentifier {}", tenantIdentifier.toString().toUpperCase());
        try (Statement statement = connection.createStatement()) {
            // 동적 테넌트 스키마 생성 (없다면 생성)
            statement.execute("CREATE SCHEMA IF NOT EXISTS " + tenant);
        }

        if (initializedTenants.add(tenant)) {
            Flyway.configure()
                    .dataSource(defaultDataSource)
                    .schemas(tenant)
                    .locations("classpath:db/migration")
                    .baselineOnMigrate(true)
                    .load()
                    .migrate();
            log.info("Flyway migration applied for tenant schema: {}", tenant);
        }

        // 스키마 전환
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("SET SCHEMA " + tenant);
            log.info("Switched to tenant schema: {}", tenant);
        }

        return connection;
    }
}

//releaseConnection, supportsAggressiveRelease, isUnwrappableAs, unwrap 생략

 

Hibernate가 사용하는 커넥션을 테넌트별 스키마로 전환

  1. 기본 DS에서 커넥션을 획득 
  2. SET SCHEMA를 이용해 스키마 실행
  3. 쿼리 실행 후 다시 기본 스키마로 이동
DynamicDataSourceRouter
@Component
public class DynamicDataSourceRouter extends AbstractRoutingDataSource {

    private final DataSource defaultDs;
    private final Map<String, DataSource> tenantDsMap = new ConcurrentHashMap();
    
    public DynamicDataSourceRouter(@Qualifier("defaultDataSource") DataSource defaultDs) {
        this.defaultDs = defaultDs;
        super.setDefaultTargetDataSource(defaultDs);
        super.setTargetDataSources(new HashMap<>());
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenant();
    }
    
    @Override
    protected DataSource determineTargetDataSource() {
        String tenantId = (String) determineCurrentLookupKey();

        if (tenantId == null || tenantId.isEmpty()) {
            return defaultDs;
        }

        return tenantDsMap.computeIfAbsent(tenantId, this::createAndRegisterTenantDs);
    }

    private synchronized DataSource createAndRegisterTenantDs(String tenantId) {
        // 스키마 생성
        try (Connection conn = defaultDs.getConnection();
             Statement stmt = conn.createStatement()) {
            stmt.execute("CREATE SCHEMA IF NOT EXISTS " + tenantId);
        } catch (SQLException e) {
            throw new IllegalStateException("Failed to create schema: " + tenantId, e);
        }

        // 새로운 DataSource 설정
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:h2:tcp://localhost/~/userdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE");
        ds.setUsername("sa");
        ds.setPassword("");
        ds.setConnectionInitSql("SET SCHEMA " + tenantId);

        Flyway.configure()
                .dataSource(ds)
                .schemas(tenantId)
                .locations("classpath:db/migration")
                .baselineOnMigrate(true)
                .load()
                .migrate();

        // 라우팅 맵 업데이트
        Map<Object, Object> targetDataSources = new HashMap<>(this.tenantDsMap);
        targetDataSources.put(tenantId, ds);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();

        return ds;
    }
}

 

테넌트 ID를 조회해 해당 ID에 스키마 접속 후 DataSource가 생성되었는지 확인하고 없다면 createAndRegisterTenantDs를 이용해 새로 생성해 등록 후 반환 

 

멀티테넌시 구조로 사용하는 경우에는 setJdbcUrl의 연결 db 부분을 yml 파일이나 별도로 저장해 가변적용을 해주면 멀티 모듈로도 사용가능하다.