Spring 멀티 테넌시 설계

멀티 테넌시란?

멀티 테넌시(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 격리로 전환하는 것이 현실적인 접근이다.

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