배경
입사 후 처음 맡게 된 임무는, 기존 Delphi로 개발되어 있던 시스템을 Spring 기반으로 마이그레이션 하는 일이었습니다.
이 작업을 시작하기에 앞서, 먼저 회사가 사용하는 DB 구조를 파악해야 했고, 그 구조에 맞춰 멀티 테넌시 환경을 지원하는 DataSource 라우팅 라이브러리를 직접 개발해야 했습니다.
아래에 회사의 DB 구조를 간략히 소개하겠습니다.
DB 구조
• 사용 중인 데이터베이스:
→ Firebird 2.1.4, 파일 기반의 오래된 DBMS
• 업체별 데이터 관리 방식:
→ 새로운 업체가 추가될 때마다 기존 DB 스키마를 복제하여 업체 전용 DB를 따로 운영하는 구조
즉, 하나의 시스템에서 여러 개의 DB 인스턴스를 동적으로 선택해서 접속해야 하는 구조였습니다.
DB 접근 방식
이런 구조에서 사용자별로 데이터를 가져오기 위해, 다음과 같은 흐름으로 DB에 접근하고 있었습니다.
• 사용자 식별
→ Token을 통해 현재 사용자를 식별합니다.
• 업체 전용 DB 접속 정보 조회
→ 사용자 정보에 따라 해당 사용자가 속한 업체의 DB 접속 주소를 Server 테이블에서 조회합니다.
• DB 연결
→ 조회한 접속 주소를 기반으로 DB에 연결해 데이터를 처리합니다.
구현하며 가장 신경 쓴 점
• 팀원들이 쉽게 사용할 수 있어야 한다는 점
→ 자바 개발자가 저 혼자이기 때문에, 다른 팀원들도 복잡한 설정 없이 직관적으로 사용할 수 있는 구조로 만드는 것이 중요했습니다.
• 기존 운영 방식과 크게 벗어나지 않아야 한다는 점
→ 새로운 시스템을 도입한다고 해도, 운영 인력이 많지 않은 상황에서 완전히 다른 방식으로 전환하는 것은 부담이 될 수 있었습니다.
그래서 되도록 기존 프로세스와 유사한 형태로 개발하려고 했습니다.
처음 떠올린 아이디어: 컨테이너 단위 멀티 테넌시
초기에 고민했던 방식은, 업체별로 컨테이너를 따로 운영하는 구조였습니다.
즉, 업체마다 독립된 컨테이너를 띄우고, 각 컨테이너는 각자의 DB에 연결되는 형태입니다.
✅ 이 방식의 장점
• JPA 설정이 간단합니다.
→ 단일 스키마/단일 DB 기준으로 설정할 수 있어, 멀티 테넌시를 위한 복잡한 동적 DataSource 설정이 필요 없습니다.
• 장애 격리가 가능합니다.
→ 컨테이너가 분리되어 있기 때문에, 특정 업체의 장애가 다른 업체에 영향을 주지 않습니다.
❌현실적으로 어려웠던 이유
기존에 운영 방식은 업체별 DB 접속 주소를 관리자가 직접 관리 페이지를 통해 관리하고 있었습니다.
이 구조는 이미 운영팀이 익숙하게 사용하는 방식이기 때문에, 컨테이너 단위로 완전히 분리하는 아키텍처는 현실적으로 적용하기 어렵다고 판단했습니다.
또한 "기존 운영 방식과 크게 벗어나지 않아야 한다는 점" 측면에서 본다면 다른 방법을 고민해야 했습니다.
앞서 설명한 배경을 바탕으로, 실제로 어떤 방식으로 멀티 테넌시를 구현했는지, 그리고 어떻게 구조를 설계했는지를 정리해 보겠습니다.
초기 설계 방식
@Service
class OrderService(
private val entityManagerService: EntityManagerService
) {
fun getOrders(userInfo: UserInfo) {
entityManagerService
.getEntityManager(userInfo, DataSourceType.ORDER)
.use { em ->
// em을 통한 DB 작업...
}
}
}
이 방식은 매우 직관적이고 명시적인 코드 흐름이라는 장점이 있었습니다.
EntityManagerService에서 사용자 정보와 DB 유형을 전달받아 직접 EntityManager를 얻고, 해당 객체로 작업을 수행하는 구조입니다.
❌ 이 방식의 단점
• use 블록, (자바는 try-with-resources) 구문이 반복되면서 보일러 플레이트 코드가 발생
• 트랜잭션을 직접 관리해야 하므로 코드가 복잡해짐
• Spring Data JPA를 사용할 수 없음
→ Spring Data JPA의 JpaRepository는 기본적으로 @PersistenceContext를 통해 EntityManager를 주입받아 내부적으로 처리하기 때문에 일반적인 방법으론 사용할 수 없음
앞서 설명한 단점을 보완하기 위해, AbstractRoutingDataSource를 활용한 동적 DataSource 라우팅 구조를 도입했습니다.
그럼 먼저, 개선된 코드부터 살펴보겠습니다.
@Service
class OrderService(
private val entityRepository: EntityRepository
) {
@DynamicDataSource(DataSourceType.ORDER)
@Transactional(readOnly = true)
fun getOrderEntity() {
val entity = entityRepository.findById(1L).orElseThrow()
// 로직...
}
}
이제는 @DynamicDataSource 애너테이션만 붙이면, 해당 메서드에서 사용할 DB(DataSourceType)를 자동으로 설정할 수 있습니다.
✅ 개선된 코드의 장점
• Spring의 @Transactional을 그대로 사용할 수 있습니다.
→ 더 이상 EntityManager를 수동으로 제어하지 않아도 되고, 트랜잭션도 Spring이 알아서 처리해 줍니다.
• JPA Repository 구조를 그대로 활용할 수 있습니다.
• DB 접속 정보를 메서드 인자로 전달할 필요가 없습니다.
→ UserInfo 같은 정보를 서비스 계층에 넘기지 않아도 되므로 코드가 더 깔끔해집니다.
이제 전체적인 코드를 설명해 드리겠습니다.
보여드리는 코드는 간단하게 만든 예제이므로 참고하여 주시기 바랍니다.
TenantContextHolder
object TenantContextHolder {
private val contextHolder = ThreadLocal<DataSourceType>()
fun setTenant(tenant: DataSourceType) {
contextHolder.set(tenant)
}
fun getTenant(): DataSourceType? {
return contextHolder.get()
}
fun clear() {
contextHolder.remove()
}
}
이 객체는 현재 요청이 어떤 테넌트(DB)를 사용해야 하는지 저장하는 역할을 합니다.
내부적으로 ThreadLocal을 사용하여, 멀티스레드 환경에서도 요청 간 혼선을 방지합니다.
만약 추가로 사용자 정보를 식별해야 할 값이 있다면 ThreadLocal에 저장해서 활용할 수 있습니다.
DataSourceConfig
@Configuration
class DataSourceConfig(
private val dataBaseService: DataBaseService
) {
/**
* Default DataSource
*/
@Primary
@Bean
fun defaultDataSource(): DataSource {
val dataSource = HikariDataSource()
dataSource.jdbcUrl = "jdbc:postgresql://localhost:5430/default_db"
dataSource.username = "test_user"
dataSource.password = "test1234"
return dataSource
}
/**
* - 위에서 만든 defaultDataSource 를 생성자 주입하여 MultiTenantDataSource 객체를 스프링 빈으로 등록.
*/
@Bean
fun multiTenantDataSource(defaultDataSource: DataSource): DataSource {
return MultiTenantDataSource(defaultDataSource, dataBaseService)
}
@Bean
fun entityManagerFactory(
builder: EntityManagerFactoryBuilder,
@Qualifier("multiTenantDataSource") multiTenantDs: DataSource
): LocalContainerEntityManagerFactoryBean {
return builder
.dataSource(multiTenantDs)
.packages("algo.dynamicdatasource.test")
.persistenceUnit("multiTenantPU")
.build()
}
@Bean
fun transactionManager(
entityManagerFactory: EntityManagerFactory
): PlatformTransactionManager {
return JpaTransactionManager(entityManagerFactory)
}
• defaultDataSource(): 기본 DB 연결 정보 설정
• multiTenantDataSource(): 실제 요청마다 적절한 DB로 라우팅 할 커스텀 DataSource 등록
• entityManagerFactory(): 커스텀 DataSource를 기반으로 JPA 설정 구성
• transactionManager(): 트랜잭션 설정 (Spring @Transactional 지원)
DataBaseService
@Service
class DataBaseService {
@DynamicDataSource(DataSourceType.DEFAULT)
@Transactional(readOnly = true)
fun getConnectionInfo(dataSourceType: DataSourceType) =
when (dataSourceType) {
DataSourceType.ORDER -> Triple(
"jdbc:postgresql://localhost:5430/order_db",
"test_user",
"test1234"
)
DataSourceType.PAYMENT -> Triple(
"jdbc:postgresql://localhost:5429/payment_db",
"test_user",
"test1234"
)
else -> Triple(
"jdbc:postgresql://localhost:5428/default_db",
"test_user",
"test1234"
)
}
}
데이터베이스 접속 정보를 가져오는 Service 예제입니다.
MultiTenantDataSource
class MultiTenantDataSource(
private val defaultDataSource: DataSource,
private val dataBaseService: DataBaseService
) : AbstractRoutingDataSource() {
private val dataSourceMap: MutableMap<DataSourceType, DataSource> = mutableMapOf()
init {
setDefaultTargetDataSource(defaultDataSource)
// serverInfoDataSource 를 캐싱
dataSourceMap[DataSourceType.DEFAULT] = defaultDataSource
setTargetDataSources(mutableMapOf<Any, Any>())
super.afterPropertiesSet()
}
override fun determineCurrentLookupKey(): Any? {
val tenant = TenantContextHolder.getTenant()
// tenant 가 null 이면 null 반환 → AbstractRoutingDataSource 는 기본 target 을 fallback
return tenant?.name
}
override fun determineTargetDataSource(): DataSource {
// Spring 시작 시 기본 DataSource 를 serverInfoDataSource 로 사용
val tenant = TenantContextHolder.getTenant() ?: return defaultDataSource
// 캐싱된 DataSource 가 있는지 확인
dataSourceMap[tenant]?.let {
return it
}
// 캐싱된 DataSource 가 없으면 새로 생성 후 캐싱
return createAndRegisterDataSource(tenant)
}
private fun createAndRegisterDataSource(dataSourceType: DataSourceType): DataSource {
val connectionInfo = dataBaseService.getConnectionInfo(dataSourceType)
val dataSource = HikariDataSource()
dataSource.jdbcUrl = connectionInfo.first
dataSource.username = connectionInfo.second
dataSource.password = connectionInfo.third
dataSourceMap[dataSourceType] = dataSource
return dataSource
}
}
이 클래스는 AbstractRoutingDataSource를 상속받아, 요청마다 적절한 DataSource로 자동 라우팅해주는 역할을 합니다.
• determineCurrentLookupKey():DB 커넥션 요청 시 가장 먼저 호출되는 메서드로, 현재 요청이 어떤 테넌트(DB)를 사용해야 하는지 TenantContextHolder를 통해 판단
• determineTargetDataSource(): determineCurrentLookupKey()에서 반환된 키 값을 기반으로, 실제 사용할 DataSource 객체를 반환하는 메서드입니다.
이 두 메서드는 동적 멀티 테넌시에서 DB 라우팅의 핵심 로직을 담당합니다.
• determineCurrentLookupKey()는 "어떤 DB를 써야 하는가?"를 판단하고,
• determineTargetDataSource()는 “그 DB를 어떻게 연결할 것인가?”를 실행합니다.
DynamicDataSource
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DynamicDataSource(
val value: DataSourceType = DataSourceType.DEFAULT
)
MultiTenantAspect
@Aspect
@Component
@Order(1)
class MultiTenantAspect {
@Around(value = "@annotation(dataSource)", argNames = "pjp,dataSource")
fun aroundDynamicDataSource(pjp: ProceedingJoinPoint, dataSource: DynamicDataSource): Any? {
return try {
TenantContextHolder.setTenant(dataSource.value)
pjp.proceed()
} finally {
TenantContextHolder.clear()
}
}
}
• @DynamicDataSource가 붙은 메서드를 실행하기 전 → TenantContextHolder에 테넌트 정보 저장
• 메서드 실행 후에는 → ThreadLocal을 clear() 해서 누수 방지
동작 테스트
@Service
class TestService(
private val entityRepository: EntityRepository
) {
@DynamicDataSource(DataSourceType.ORDER)
@Transactional(readOnly = true)
fun getOrderEntity() {
val entity = entityRepository.findById(1L).orElseThrow()
println(entity.name)
}
@DynamicDataSource(DataSourceType.PAYMENT)
@Transactional(readOnly = true)
fun getPaymentEntity() {
val entity = entityRepository.findById(1L).orElseThrow()
println(entity.name)
}
@DynamicDataSource(DataSourceType.DEFAULT)
@Transactional(readOnly = true)
fun getDefaultEntity() {
val entity = entityRepository.findById(1L).orElseThrow()
println(entity.name)
}
테스트해보겠습니다.
@SpringBootTest
class DynamicDataSourceTest : FreeSpec() {
@Autowired
lateinit var testService: TestService
init {
"Multi-tenant test" - {
testService.getOrderEntity()
testService.getPaymentEntity()
testService.getDefaultEntity()
}
}
}
ORDER와 PAYMENT 테넌트는 처음 요청 시점에 MultiTenantDataSource에 의해 동적으로 생성되었으며, 이 과정에서 HikariDataSource가 초기화되고 커넥션 풀을 구성하는 로그가 출력됩니다. 반면, DEFAULT 테넌트는 Spring 부팅 시점에 setDefaultTargetDataSource를 통해 이미 캐싱되었기 때문에 추가 연결 없이 바로 사용되었습니다.
로그를 더 자세히 찍어본 후 분석해 보겠습니다.
삽질 : AbstractRoutingDataSource가 라우팅 되지 않았던 이유
😵 문제 상황
AbstractRoutingDataSource를 활용해 메서드마다 다른 DB를 사용하도록 설정했는데,
이상하게 처음 연결된 DataSource는 잘 작동하지만, 이후 다른 DataSource로 변경되지 않는 문제가 발생했습니다.
처음엔 “아, 트랜잭션이 완전히 종료되지 않아서 그런가?”라는 생각이 들었습니다.
트랜잭션이 끝나지 않으면 커넥션도 닫히지 않고 이전 커넥션이 재사용되면서 라우팅도 발생하지 않는 게 아닐까? 하고 말이죠.
하지만 트랜잭션도 정상적으로 종료되고 있는 상태였습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
새로운 트랜잭션을 열어봐도 마찬가지였습니다.
🔍 OSIV 활성화 로그 발견
라는 로그를 확인했습니다... 하하하하
🔎 OSIV란?
OSIV(Open Session In View)는 영속성 컨텍스트를 프레젠테이션(뷰와 컨트롤러) 레이어까지 열어두는 기능입니다.
기본적으로 Spring Boot에서는 open-in-view: true가 설정되어 있어서, DB 트랜잭션이 끝나더라도 EntityManager가 요청 전체 생명주기 동안 살아 있게 됩니다
여기서 문제가 발생합니다.
한 번 생성된 영속성 컨텍스트는 컨트롤러부터 레포지토리 계층까지 전 계층에서 계속 유지되며, 이로 인해 이미 확보된 커넥션이 계속 재사용되게 됩니다.
결과적으로, Spring은 새 커넥션을 요청하지 않게 되고, 따라서 AbstractRoutingDataSource가 determineTargetDataSource()를 호출할 기회조차 갖지 못하는 상황이 발생합니다.
OSIV 비활성화로 해결
spring:
jpa:
open-in-view: false
OSIV(Open Session In View)를 비활성화하면, 영속성 컨텍스트의 생명주기가 트랜잭션 범위로 제한됩니다.
즉, 서비스 메서드 단위로 트랜잭션이 시작되고 종료될 때마다 새로운 영속성 컨텍스트가 생성되며, 매번 새로운 DB 커넥션도 획득하게 됩니다.
이제 트랜잭션이 시작될 때마다 커넥션이 새롭게 열리기 때문에, AbstractRoutingDataSource는 매번 determineTargetDataSource()를 호출하여 적절한 DataSource를 동적으로 선택할 수 있게 됩니다.
회사에 적응하느라 한동안 블로그를 쉬었는데, 오랜만에 다시 정리해 보니
공부도 되고, 한층 더 개념이 명확해진 느낌이다.
뿌듯하다.
'개발' 카테고리의 다른 글
TDD 도입하고 개발해보며 체득하기2 (0) | 2024.07.20 |
---|---|
TDD 도입하고 개발해보며 체득하기 (0) | 2024.07.14 |
Google Cloud SQL 로 MySQL 배포하기 (0) | 2024.06.23 |
[Spring] enum, class에서 @JsonValue 사용법과 자바의 직렬화 (0) | 2024.06.04 |
Postman으로 STOMP 테스트 하기 (0) | 2024.04.12 |