이 글에서는 내가 사이드 프로젝트를 진행하면서 멀티 테넌시를 도입한 과정을 기록해보겠다.
멀티 테넌시란 간단하게 우리가 한 개의 서비스에서 여러 개의 DB를 동적으로 사용하고 싶을 때 사용할 수 있는 기술이다. (정확한 정의는 여기에서)
나는 여러 학교를 대상으로 하는 서비스 한개를 만들고 있었다.
이런 경우에는 DB를 학교별로 분리해 데이터를 완전히 격리하는 것이 보안적으로도, 로직적으로도 좋았다.
그래서 DB를 분리할 방법을 찾아보았고, 멀티 테넌시 기술이 제격이라는 것을 알게 되었다.
이번 글에서는 MainDb(고정적으로 사용, user 정보 저장)와 TenantDb(요청에 따라 매번 다른 Db를 사용, 학교의 과목 정보가 저장)가 각각 따로 존재하면서, 모든 TenantDb는 같은 Entity 구조를 가지는 아키텍쳐로 구성할 것이다.
멀티 테넌시를 우리 서비스에 도입하기 위해선, MainDbConfig, TenantDbConfig, RoutingDataSource, TenantContext 등의 파일이 새로 필요하다. 전체적인 파일 구조는 아래를 참고하자.
- config
- MainDbConfig.java
- TenantDbConfig.java
- context
- TenantContext.java
- domain
- main
- ... MainDb의 Entity들
- tenant
- ... TenantDb의 Entity들
- repository
- main
- ... MainDb의 Repository들
- tenant
- ... TenantDb의 Repository들
- tenant
- SchoolRoutingDataSource.java
- TenantConnectionInfo.java
- TenantUtil.java
Spring에서는 이런 멀티 테넌시 기술을 쉽게 적용할 수 있도록 만들어둔 클래스들이 있다.
대표적으로 AbstractRoutingDataSource 클래스를 이용해 현재 연결할 DataSource의 Key를 수정할 수 있다.
이렇게 수정한 키는 우리가 TenantDbConfig를 정의해서 DataSource를 동적으로 변경할 수 있게 해준다.
SchoolRoutingDataSource.java
public class SchoolRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
if (tenantId == null) {
throw new IllegalStateException("[🛑 오류] 현재 요청에 schoolId가 설정되지 않았습니다.");
}
return tenantId;
}
}
위의 SchoolRoutingDataSource.java 파일에서는 TenantContext에서 tenantId를 받아온다. 이 TenantContext에는 jwtFilter를 거치면서 jwt에 포함되어 있는 tenantId가 저장된다.
TenantContext.java
public class TenantContext {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CONTEXT.set(tenantId);
}
public static String getTenantId() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
TenantDbConfig.java (제일 중요한거)
@Configuration
@EnableJpaRepositories(
basePackages = "your.project.repository.tenant",
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager"
)
@RequiredArgsConstructor
public class TenantDbConfig {
private final TenantUtil tenantUtil;
@Bean
public DataSource tenantDataSource() throws SQLException {
SchoolRoutingDataSource routingDataSource = new SchoolRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
List<TenantConnectionInfo> tenants = tenantUtil.loadTenants(); // school1, school2, ...
for (TenantConnectionInfo tenantInfo : tenants) {
targetDataSources.put(tenantInfo.getTenantId(), buildDs(tenantInfo.getDbUrl(), tenantInfo.getDbUsername(), tenantInfo.getDbPassword()));
}
routingDataSource.setTargetDataSources(targetDataSources);
return routingDataSource;
}
@Bean
public LocalContainerEntityManagerFactoryBean tenantEntityManagerFactory(
@Qualifier("tenantDataSource") DataSource ds) {
return createEntityManagerFactory(ds, "your.project.domain.tenant");
}
@Bean
public PlatformTransactionManager tenantTransactionManager(
@Qualifier("tenantEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
private DataSource buildDs(String url, String username, String password) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
return ds;
}
private LocalContainerEntityManagerFactoryBean createEntityManagerFactory(DataSource ds, String pkg) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(ds);
emf.setPackagesToScan(pkg);
emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
emf.setJpaPropertyMap(Map.of(
"hibernate.physical_naming_strategy", "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"
));
Properties props = new Properties();
props.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
emf.setJpaProperties(props);
return emf;
}
}
위 TenantDbConfig.java는 실제로 DataSource를 동적으로 바꾸는 핵심 파일이다.
tenantDataSource 메서드에서는 현재 요청에 해당하는 TenantDataSource를 반환한다.
자세하게는, TenantUtil에서 TenantDb 목록을 받아오고, 각 Tenant들을 targetDataSources에 추가한다.
이후 SchoolRoutingDataSource의 tenantId를 토대로 targetDataSources에서 tenantId에 해당하는 DataSource를 찾아 최종적으로 반환하게 된다.
다른 메서드들은 Tenant DB에 해당하는 domain과 repository의 경로를 지정하고 있다. (이렇게 지정함으로써 Spring은 MainDb와 TenantDb를 따로 인식하여 충돌이 발생하지 않는다.)
MainDbConfig.java
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "your.project.repository.main",
entityManagerFactoryRef = "mainEntityManagerFactory",
transactionManagerRef = "mainTransactionManager"
)
public class MainDbConfig {
@Value("${spring.datasource.main.url}")
private String mainDbUrl;
@Value("${spring.datasource.main.username}")
private String mainDbUsername;
@Value("${spring.datasource.main.password}")
private String mainDbPassword;
@Bean
public DataSource mainDataSource() {
return DataSourceBuilder.create()
.url(mainDbUrl)
.username(mainDbUsername)
.password(mainDbPassword)
.build();
}
@Bean
public LocalContainerEntityManagerFactoryBean mainEntityManagerFactory(
@Qualifier("mainDataSource") DataSource ds) {
return createEntityManagerFactory(ds, "your.project.domain.main");
}
@Bean
public PlatformTransactionManager mainTransactionManager(
@Qualifier("mainEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
private LocalContainerEntityManagerFactoryBean createEntityManagerFactory(DataSource ds, String pkg) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(ds);
emf.setPackagesToScan(pkg);
emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
emf.setJpaPropertyMap(Map.of(
"hibernate.physical_naming_strategy", "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"
));
return emf;
}
}
MainDbConfig.java 파일에서는 고정적으로 사용되는 MainDb에 대한 DataSource, Entity/Repository 경로를 지정한다. 고정된 DataSource이기 때문에 env 파일에서 db 정보를 불러온다.
보통 이런 MainDb에는 공통적으로 사용되는 데이터인 유저 정보와 같은 것들이 저장된다.
TenantUtil.java
@Component
public class TenantUtil {
@Value("${spring.datasource.main.url}")
private String mainDbUrl;
@Value("${spring.datasource.main.username}")
private String mainDbUsername;
@Value("${spring.datasource.main.password}")
private String mainDbPassword;
public List<TenantConnectionInfo> loadTenants() throws SQLException {
Connection conn = DriverManager.getConnection(mainDbUrl, mainDbUsername, mainDbPassword);
ResultSet rs = conn.createStatement().executeQuery("SELECT tenant_id, db_url, db_username, db_password FROM school");
List<TenantConnectionInfo> tenants = new ArrayList<>();
while (rs.next()) {
tenants.add(new TenantConnectionInfo(
rs.getString("tenant_id"),
rs.getString("db_url"),
rs.getString("db_username"),
rs.getString("db_password")
));
}
return tenants;
}
}
위 TenantUtil.java 파일은 존재하는 TenantDb의 목록을 가져오는 용도로 사용된다.
나같은 경우, MainDb에 TenantDb의 정보들을 저장하여 관리하고 있었으므로, MainDb에 접속해 TenantDb의 정보들을 가져오고 반환하는 식으로 코드를 작성하였다.
TenantConnectionInfo.java
@Getter
public class TenantConnectionInfo {
private final String tenantId;
private final String dbUrl;
private final String dbUsername;
private final String dbPassword;
public TenantConnectionInfo(String tenantId, String dbUrl, String dbUsername, String dbPassword) {
this.tenantId = tenantId;
this.dbUrl = dbUrl;
this.dbUsername = dbUsername;
this.dbPassword = dbPassword;
}
@Override
public String toString() {
return "TenantConnectionInfo{" +
"tenantId='" + tenantId + '\'' +
", dbUrl='" + dbUrl + '\'' +
", dbUsername='" + dbUsername + '\'' +
'}';
}
}
위 TenantConnectionInfo.java 파일은 TenantDb의 정보를 저장하기 위해 만든 간단한 클래스다. 보이는 것처럼 tenantId와 dbUrl 등 db를 식별하고 접속하기 위한 정보를 포함하고 있다.
이런 식으로 기본적인 멀티 테넌시 아키텍쳐의 기반을 설계할 수 있다.
이런 식으로 구조를 설계하면, 우리는 각 Service에서 TenantDb에 접근했을 때 사용자의 tenantId에 해당하는 TenantDb를 이용할 수 있다.
참고로 서비스에서 Transactional을 적용시킬 땐 아래와 같이 적용시켜야 한다.
@Service
@Transactional(transactionManager="mainTransactionManager")
@RequiredArgsConstructor
public class SchoolService {
우리가 위에서 MainDbConfig와 TenantDbConfig를 정의하면서 transactionMangerRef를 설정했을 것이다. 그 설정한 값을 @Transaction 어노테이션의 transactoinManager란에 적어주면 된다.
이렇게 하면 Spring이 어떤 Db에 Transactional을 적용해야 하는지 몰라서 오류가 발생하는 일이 없어진다.
단, 이렇게 하면 한 Service가 MainDb와 TenantDb를 동시에 접속하지는 못한다. 그래서 파일을 잘 분리해 DI를 이용해서 코드를 작성해야 할 것이다.
만약 이 글을 따라서 만들어보고 싶다면, 더 나은 구조가 있을 것이니 계속 고민해보면서 더 나은 구조를 찾는 것이 좋을 것이다.
하지만 일단 전체적인 멀티 테넌시의 구조는 알 수 있을 것이라고 생각한다.
Http Request -> Jwt Filter (jwt에서 tenantId를 추출, context에 저장) -> TenantDbConfig -> RoutingDataSource -> TenantDb 설정
단순 기록용으로 쓴 글이라서 도움이 될진 모르겠지만, 사이드 프로젝트를 하면서 적용해본 멀티 테넌시를 간단하게 정리해보았습니다.