Spring Multi-Tenancy 격리 전략

Multi-Tenancy란? SaaS의 핵심 아키텍처

Multi-Tenancy는 하나의 애플리케이션 인스턴스가 여러 테넌트(고객)의 데이터를 격리하여 서비스하는 아키텍처다. SaaS 서비스의 필수 요소이며, 테넌트 간 데이터 유출을 방지하면서도 인프라 비용을 절감할 수 있다.

Spring Boot에서 Multi-Tenancy를 구현하는 세 가지 전략과 각각의 트레이드오프, 그리고 Hibernate 6의 네이티브 멀티테넌시 지원, 동적 DataSource 라우팅, 테넌트 컨텍스트 전파까지 실전 수준으로 다룬다.

세 가지 격리 전략 비교

전략 격리 수준 비용 적합한 상황
Database per Tenant 최고 (완전 분리) 높음 규제 산업, 대형 고객
Schema per Tenant 높음 (스키마 분리) 중간 중규모 SaaS, PostgreSQL
Shared Table (Discriminator) 낮음 (컬럼 분리) 최저 소규모 SaaS, 빠른 개발

테넌트 컨텍스트: ThreadLocal 기반 전파

모든 전략에 공통으로 필요한 것은 현재 요청의 테넌트를 식별하고 전파하는 메커니즘이다.

// TenantContext — ThreadLocal 기반
public class TenantContext {

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

    public static String getCurrentTenant() {
        return CURRENT_TENANT.get();
    }

    public static void setCurrentTenant(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}
// TenantFilter — HTTP 헤더에서 테넌트 추출
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String tenantId = resolveTenant(request);
            if (tenantId == null) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST,
                    "X-Tenant-ID header is required");
                return;
            }
            TenantContext.setCurrentTenant(tenantId);
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear(); // 메모리 누수 방지 필수
        }
    }

    private String resolveTenant(HttpServletRequest request) {
        // 우선순위: 헤더 > JWT 클레임 > 서브도메인
        String tenant = request.getHeader("X-Tenant-ID");
        if (tenant == null) {
            tenant = extractFromJwt(request);
        }
        if (tenant == null) {
            tenant = extractFromSubdomain(request);
        }
        return tenant;
    }

    private String extractFromSubdomain(HttpServletRequest request) {
        String host = request.getServerName();
        // acme.app.example.com → acme
        if (host != null && host.contains(".")) {
            return host.split("\.")[0];
        }
        return null;
    }

    private String extractFromJwt(HttpServletRequest request) {
        // SecurityContext에서 JWT 클레임 추출
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwt) {
            return jwt.getToken().getClaimAsString("tenant_id");
        }
        return null;
    }
}

전략 1: Database per Tenant (동적 DataSource)

테넌트마다 별도 데이터베이스를 사용한다. Spring의 AbstractRoutingDataSource로 요청 시 동적으로 DataSource를 전환한다.

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }
}

@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource dataSource(TenantDataSourceProperties properties) {
        TenantRoutingDataSource routingDs = new TenantRoutingDataSource();

        Map<Object, Object> targetDataSources = new HashMap<>();
        properties.getTenants().forEach((tenantId, config) -> {
            HikariDataSource ds = new HikariDataSource();
            ds.setJdbcUrl(config.getUrl());
            ds.setUsername(config.getUsername());
            ds.setPassword(config.getPassword());
            ds.setMaximumPoolSize(config.getPoolSize());
            targetDataSources.put(tenantId, ds);
        });

        routingDs.setTargetDataSources(targetDataSources);
        routingDs.setDefaultTargetDataSource(targetDataSources.get("default"));
        return routingDs;
    }
}
# application.yml
tenant:
  tenants:
    acme:
      url: jdbc:postgresql://db1:5432/acme_db
      username: acme_user
      password: ${ACME_DB_PASS}
      pool-size: 10
    globex:
      url: jdbc:postgresql://db2:5432/globex_db
      username: globex_user
      password: ${GLOBEX_DB_PASS}
      pool-size: 10

동적 테넌트 추가: 런타임 DataSource 등록

새 테넌트가 가입할 때마다 설정 파일을 수정하는 것은 비현실적이다. 런타임에 DataSource를 동적으로 추가하는 방법:

@Service
@RequiredArgsConstructor
public class TenantManagementService {

    private final TenantRoutingDataSource routingDataSource;
    private final TenantRepository tenantRepository;

    @Transactional
    public void provisionTenant(String tenantId, TenantDbConfig config) {
        // 1. 데이터베이스 생성 (관리 DB에서)
        createDatabase(tenantId);

        // 2. 스키마 마이그레이션 실행
        runFlywayMigration(config);

        // 3. 라우팅 DataSource에 동적 추가
        HikariDataSource newDs = createHikariDataSource(config);
        Map<Object, Object> current = new HashMap<>(
            routingDataSource.getResolvedDataSources()
        );
        current.put(tenantId, newDs);
        routingDataSource.setTargetDataSources(current);
        routingDataSource.afterPropertiesSet(); // 재초기화

        // 4. 테넌트 메타데이터 저장
        tenantRepository.save(new Tenant(tenantId, config.getUrl()));
    }

    private void runFlywayMigration(TenantDbConfig config) {
        Flyway.configure()
            .dataSource(config.getUrl(), config.getUsername(), config.getPassword())
            .locations("classpath:db/migration/tenant")
            .load()
            .migrate();
    }
}

전략 2: Schema per Tenant (Hibernate 6)

Hibernate 6는 MultiTenancyStrategy를 네이티브로 지원한다. 하나의 데이터베이스에서 테넌트별 스키마를 분리한다.

@Configuration
public class HibernateMultiTenantConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em =
            new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.entity");

        Map<String, Object> props = new HashMap<>();
        props.put("hibernate.multiTenancy", "SCHEMA");
        props.put("hibernate.tenant_identifier_resolver",
            new CurrentTenantResolver());
        props.put("hibernate.multi_tenant_connection_provider",
            new SchemaMultiTenantConnectionProvider(dataSource));
        em.setJpaPropertyMap(props);
        return em;
    }
}

// 테넌트 식별자 리졸버
public class CurrentTenantResolver implements CurrentTenantIdentifierResolver<String> {

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

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

// 스키마 전환 커넥션 프로바이더
public class SchemaMultiTenantConnectionProvider
        implements MultiTenantConnectionProvider<String> {

    private final DataSource dataSource;

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        Connection conn = dataSource.getConnection();
        conn.setSchema(tenantIdentifier);
        return conn;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection conn)
            throws SQLException {
        conn.setSchema("public");
        conn.close();
    }
}

전략 3: Shared Table + Discriminator

가장 간단하지만 격리 수준이 낮은 방식. 모든 테이블에 tenant_id 컬럼을 추가하고 Hibernate Filter로 자동 필터링한다.

@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter",
    parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "tenant_id", nullable = false)
    private String tenantId;

    @Column(nullable = false)
    private String productName;

    @Column(nullable = false)
    private BigDecimal amount;

    @PrePersist
    public void prePersist() {
        // 저장 시 자동으로 현재 테넌트 설정
        if (this.tenantId == null) {
            this.tenantId = TenantContext.getCurrentTenant();
        }
    }
}
// AOP로 Hibernate Filter 자동 활성화
@Aspect
@Component
public class TenantFilterAspect {

    @PersistenceContext
    private EntityManager entityManager;

    @Before("execution(* com.example.repository.*.*(..))")
    public void enableTenantFilter() {
        String tenantId = TenantContext.getCurrentTenant();
        if (tenantId != null) {
            Session session = entityManager.unwrap(Session.class);
            session.enableFilter("tenantFilter")
                   .setParameter("tenantId", tenantId);
        }
    }
}

비동기·스레드 풀에서 테넌트 전파

ThreadLocal 기반이므로 @Async나 스레드 풀에서는 테넌트 컨텍스트가 소실된다. TaskDecorator로 해결한다.

public class TenantAwareTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        String tenantId = TenantContext.getCurrentTenant();
        return () -> {
            try {
                TenantContext.setCurrentTenant(tenantId);
                runnable.run();
            } finally {
                TenantContext.clear();
            }
        };
    }
}

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setTaskDecorator(new TenantAwareTaskDecorator());
        executor.initialize();
        return executor;
    }
}

테넌트별 캐시 격리

// 테넌트 인식 캐시 키 생성기
public class TenantAwareCacheKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        String tenantId = TenantContext.getCurrentTenant();
        String key = tenantId + ":" + method.getName() + ":" +
                     Arrays.stream(params).map(Object::toString)
                           .collect(Collectors.joining(","));
        return key;
    }
}

// 사용
@Cacheable(value = "orders", keyGenerator = "tenantAwareCacheKeyGenerator")
public List<Order> getOrdersByStatus(String status) {
    return orderRepository.findByStatus(status);
}

운영 주의점과 안티패턴

안티패턴 위험 해결책
TenantContext.clear() 누락 다음 요청에 이전 테넌트 데이터 노출 finally 블록에서 반드시 clear
전역 쿼리에 tenant_id 누락 전체 테넌트 데이터 유출 Hibernate Filter + 통합 테스트
마이그레이션 미동기화 테넌트별 스키마 버전 불일치 Flyway 테넌트별 순회 마이그레이션
커넥션 풀 미분리 하나의 테넌트가 전체 풀 고갈 테넌트별 풀 사이즈 제한
캐시 키에 테넌트 미포함 다른 테넌트 캐시 데이터 반환 TenantAwareCacheKeyGenerator

마무리

Spring Multi-Tenancy는 단순한 데이터 격리를 넘어 인프라 비용, 보안, 운영 복잡도의 균형을 잡는 아키텍처 결정이다. Database per Tenant은 최고의 격리를 제공하지만 비용이 높고, Shared Table은 비용 효율적이지만 보안 위험이 있다. 대부분의 SaaS에서는 Schema per Tenant이 실용적인 선택이다. Spring Flyway 마이그레이션과 함께 테넌트별 스키마를 자동 관리하고, 구조화 로깅에 tenant_id를 MDC에 추가하면 운영 가시성도 확보할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux