멀티 테넌시란?
멀티 테넌시(Multi-Tenancy)는 하나의 애플리케이션 인스턴스로 여러 고객(테넌트)을 서비스하는 아키텍처다. SaaS 제품에서 필수적이며, 각 테넌트의 데이터가 격리되면서도 인프라 비용을 공유할 수 있다. Spring에서는 AbstractRoutingDataSource를 활용해 요청마다 다른 데이터소스로 라우팅하는 방식으로 구현한다.
전략 비교: Schema vs Database vs Row
| 전략 | 격리 수준 | 비용 | 사용 사례 |
|---|---|---|---|
| Database per Tenant | 완전 격리 | 높음 | 금융, 의료 (규제 필수) |
| Schema per Tenant | 높음 | 중간 | 엔터프라이즈 SaaS |
| Row-level (Discriminator) | 논리적 | 낮음 | 스타트업, 소규모 SaaS |
테넌트 컨텍스트 관리
// ThreadLocal 기반 테넌트 컨텍스트
public final class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
// HTTP 요청에서 테넌트 식별 — 인터셉터
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 1순위: 헤더에서 추출
String tenantId = request.getHeader("X-Tenant-ID");
// 2순위: 서브도메인에서 추출
if (tenantId == null) {
String host = request.getServerName();
tenantId = host.split("\.")[0]; // acme.app.com → acme
}
// 3순위: JWT 클레임에서 추출
if (tenantId == null) {
tenantId = extractFromJwt(request);
}
if (tenantId == null) {
response.setStatus(400);
return false;
}
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
TenantContext.clear(); // 메모리 누수 방지
}
}
AbstractRoutingDataSource 구현
// 테넌트별 데이터소스 라우팅
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getTenantId();
}
}
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource(TenantDataSourceProperties properties) {
TenantRoutingDataSource routingDataSource = 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.getMaxPoolSize());
ds.setPoolName("tenant-" + tenantId);
targetDataSources.put(tenantId, ds);
});
routingDataSource.setTargetDataSources(targetDataSources);
// 기본 데이터소스 (테넌트 없을 때 fallback)
routingDataSource.setDefaultTargetDataSource(
targetDataSources.get("default"));
return routingDataSource;
}
}
// application.yml
// app:
// tenants:
// acme:
// url: jdbc:postgresql://db1:5432/acme
// username: acme_user
// password: ${ACME_DB_PASS}
// max-pool-size: 10
// globex:
// url: jdbc:postgresql://db2:5432/globex
// username: globex_user
// password: ${GLOBEX_DB_PASS}
// max-pool-size: 5
동적 테넌트 등록: 런타임 추가
// 새 테넌트 가입 시 데이터소스 동적 추가
@Service
@RequiredArgsConstructor
public class TenantProvisioningService {
private final AbstractRoutingDataSource routingDataSource;
private final Flyway flyway;
@SuppressWarnings("unchecked")
public void provisionTenant(String tenantId, TenantConfig config) {
// 1. 새 데이터소스 생성
HikariDataSource newDs = new HikariDataSource();
newDs.setJdbcUrl(config.getUrl());
newDs.setUsername(config.getUsername());
newDs.setPassword(config.getPassword());
newDs.setMaximumPoolSize(config.getMaxPoolSize());
// 2. 라우팅 데이터소스에 추가
Map<Object, Object> currentDataSources = getTargetDataSources();
currentDataSources.put(tenantId, newDs);
routingDataSource.setTargetDataSources(currentDataSources);
routingDataSource.afterPropertiesSet(); // 재초기화
// 3. 마이그레이션 실행
Flyway tenantFlyway = Flyway.configure()
.dataSource(newDs)
.locations("classpath:db/migration")
.load();
tenantFlyway.migrate();
}
}
Flyway 마이그레이션을 테넌트별로 독립 실행하면 각 테넌트 DB의 스키마 버전을 개별 관리할 수 있다.
Schema per Tenant 전략
// 단일 DB에서 스키마로 격리 — 커넥션 풀 공유 가능
public class SchemaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected DataSource determineTargetDataSource() {
DataSource ds = super.determineTargetDataSource();
String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
try (Connection conn = ds.getConnection()) {
conn.setSchema(tenantId); // PostgreSQL: SET search_path
} catch (SQLException e) {
throw new TenantSchemaException(tenantId, e);
}
}
return ds;
}
@Override
protected Object determineCurrentLookupKey() {
return "shared"; // 모든 테넌트가 같은 물리 데이터소스
}
}
// Hibernate 방식: MultiTenantConnectionProvider
@Component
public class SchemaMultiTenantProvider implements MultiTenantConnectionProvider {
private final DataSource dataSource;
@Override
public Connection getConnection(Object tenantIdentifier) throws SQLException {
Connection conn = dataSource.getConnection();
conn.createStatement().execute(
"SET search_path TO " + sanitize((String) tenantIdentifier));
return conn;
}
@Override
public void releaseConnection(Object tenantIdentifier, Connection conn)
throws SQLException {
conn.createStatement().execute("SET search_path TO public");
conn.close();
}
}
Row-Level 격리: @Filter + Discriminator
// 가장 경량 — 모든 테이블에 tenant_id 컬럼 추가
@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
private Long id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
private BigDecimal totalAmount;
private String status;
}
// 자동 필터 활성화 — Aspect
@Aspect
@Component
@RequiredArgsConstructor
public class TenantFilterAspect {
private final EntityManager entityManager;
@Before("execution(* com.app.repository.*.*(..))")
public void enableTenantFilter() {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.getTenantId());
}
}
// INSERT 시 자동 tenant_id 주입 — EntityListener
@MappedSuperclass
@EntityListeners(TenantEntityListener.class)
public abstract class TenantAwareEntity {
@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;
}
public class TenantEntityListener {
@PrePersist
public void setTenantId(TenantAwareEntity entity) {
if (entity.getTenantId() == null) {
entity.setTenantId(TenantContext.getTenantId());
}
}
}
@Async와 테넌트 전파
// 비동기 작업에서 ThreadLocal이 전파되지 않는 문제 해결
@Configuration
@EnableAsync
public class TenantAwareAsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setTaskDecorator(new TenantTaskDecorator());
executor.initialize();
return executor;
}
}
public class TenantTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
String tenantId = TenantContext.getTenantId();
return () -> {
try {
TenantContext.setTenantId(tenantId);
runnable.run();
} finally {
TenantContext.clear();
}
};
}
}
Spring Event 비동기 처리에서도 동일한 패턴이 필요하다. @Async, CompletableFuture, 스케줄러 등 스레드가 바뀌는 모든 곳에서 TaskDecorator로 테넌트를 전파해야 한다.
테스트 전략
@SpringBootTest
class MultiTenantTest {
@Autowired
private OrderRepository orderRepository;
@Test
void 테넌트_격리_확인() {
// Tenant A 데이터 생성
TenantContext.setTenantId("acme");
orderRepository.save(new Order("acme", BigDecimal.valueOf(10000)));
// Tenant B 데이터 생성
TenantContext.setTenantId("globex");
orderRepository.save(new Order("globex", BigDecimal.valueOf(20000)));
// Tenant A는 자기 데이터만 조회
TenantContext.setTenantId("acme");
List<Order> acmeOrders = orderRepository.findAll();
assertThat(acmeOrders).hasSize(1);
assertThat(acmeOrders.get(0).getTenantId()).isEqualTo("acme");
TenantContext.clear();
}
@AfterEach
void cleanup() {
TenantContext.clear();
}
}
멀티 테넌시는 SaaS의 수익 구조를 결정하는 아키텍처 결정이다. 초기에는 Row-Level로 시작하고, 규제 요구사항이나 테넌트 규모에 따라 Schema/Database 격리로 전환하는 것이 현실적인 접근이다.