본문 바로가기
작업 목록

JPA, Spring MVC를 이용한 동적 스키마 변경

by ms727 2025. 2. 21.

개요

회사에서 신규 프로젝트에 대한 환경셋팅중에 선임님이 이런 질문을 하셨다.

"JPA를 쓸건데, 동적으로 스키마 변경이 불가능한걸로 알고 있어요. JPA를 빼야할까요?"

우리는 보안적인 이유로 고객사마다 스키마를 다르게 쓰고 있다.
기존 프로젝트에서는 JPA를쓰지않다보니 요청마다 동적으로 스키마를 교체하여 쿼리를 보냈었다.

JPA를 쓰면 동적으로 스키마를 교체 못하는가? 라는 의문을 나한테 주시고 휴가를 가셨다.

먼저, 2가지 방법을 찾아서 진행했었다.

  • 첫 번째 방법은 요청 들어올때 Filter부에서 고객사에 대한 정보를 파싱해서 해당 정보를 가지고 connection 정보를 생성하여 연결하고 끊는 방식이다. Spring MVC의 Thread Per Request 방식을 이용해 동시성 문제를 해결하기도 하였다.
  • 두 번째 방법은 JPA가 쿼리를 보낼때 이를 가로채 스키마 부분만 변경한다.

첫 번째 방법의 단점은 커넥션 맺고 끊는데에 불필요한 리소스가 들거라 생각했다. 비용도 있을 것이고..

그래서 두 번째 방법을 고안했다.

셋팅

정말 간단하게 셋팅합니다. 아키텍처, 계층구조 고려하지 않았습니다. 참고부탁드립니다.

  • Spring Boot: 3.4.2
  • Spring MVC
  • Spring JPA
  • mysql
  • 데이터베이스 스키마 2개 (company1, company2)
    • domain 테이블 (id, subject column)
    • 데이터 각 2개
      • INSERT INTO company1.domain (id, subject) VALUES('1', 'company1 Data1');
      • INSERT INTO company1.domain (id, subject) VALUES('2', 'company1 Data2');
      • INSERT INTO company2.domain (id, subject) VALUES('1', 'company2 Data1');
      • INSERT INTO company2.domain (id, subject) VALUES('2', 'company2 Data2');

요구사항

  • api: /{company}/{id}
    • company1/1 -> Return company1 Data1
    • company2/1 -> Return company2 Data1

application.properties

데이터베이스가 master-slave형식으로 되어있다는 가정하에 진행합니다.

spring.datasource.local.master.jdbc-url=jdbc:mysql://localhost:13306/?allowPublicKeyRetrieval=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&useLegacyDatetimeCode=false&serverTimezone=UTC
spring.datasource.local.master.username=root
spring.datasource.local.slave.jdbc-url=jdbc:mysql://localhost:13306/?allowPublicKeyRetrieval=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&useLegacyDatetimeCode=false&serverTimezone=UTC
spring.datasource.local.slave.username=root
spring.datasource.password=kms1234

각각 master-slave에 대한 커넥션 정보를 넣어줍니다.

TestController

@RestController
public class TestController {

    private final TestRepository testRepository;

    public TestController(TestRepository testRepository) {
        this.testRepository = testRepository;
    }

    @GetMapping("/{company}/{id}")
    public String test(@PathVariable String company, @PathVariable String id) {
        return testRepository.findById(id).getSubject();
    }
}

이 예제에서 company 파라미터는 쓰이지 않지만 명시적으로 흐름을 나타내기 위해 적었습니다.

TestRepository

public interface TestRepository extends JpaRepository<Domain, Long> {

    Domain findById(String id);
}

JPA의 기본기능만을 사용하기 위해 작성하였습니다.

Domain

@Entity
@Table(catalog = "#schema#")
public class Domain {
    @Id
    private String id;

    private String subject;

    //getter, setter..
}

여기서 catalog는 데이터베이스에서 말하는 카탈로그가 맞다. 스키마로도 불리며 이 값을 고정값인 #schema#를 넣어서 향후 쿼리 수행시에 이 값을 요구사항에 맞게 바꿔치기할 것입니다.

이제 스프링 부트가 정상적으로 뜨기 위해 DataBase Connection 설정을 해줘야합니다.

DataSourceConfig

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = {"com.example.demo"})
public class DataSourceConfig {


    @Value("${spring.datasource.password}")
    private String dbPassword;

    @Primary
    @Bean(name = "local-datasource")
    public DataSource routingDataSource() {
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("master", testMasterDataSource());
        dataSourceMap.put("slave", testSlaveDataSource());

        var routingDataSource = new ReplicationRoutingDataSource();
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(testMasterDataSource());
        return routingDataSource;
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.local.master")
    public DataSource testMasterDataSource() {
        return createDataSource();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.local.slave")
    public DataSource testSlaveDataSource() {
        return createDataSource();
    }

    private DataSource createDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .password(
                        dbPassword)
                .build();
    }
}

설정 자체는 Spring 및 JPA의 내용이고 이 부분이 핵심까지는 아니므로 설명하지 않겠습니다. 초기에 DB Connection할 때의 셋팅입니다.

여기서 필요한 ReplicationRoutingDataSource 소스를 확인해봅니다.

public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master";
    }
}

✅ AbstractRoutingDataSource

  • Spring에서 제공하는 동적 데이터소스 라우팅을 위한 추상 클래스입니다.
  • determineCurrentLookupKey() 메서드를 오버라이드하여 현재 어떤 데이터소스를 사용할지 결정할 수 있습니다.
  • setTargetDataSources()에 등록된 데이터소스 목록에서 키(lookup key)에 따라 적절한 데이터소스를 반환할 수 있습니다.

위 코드를 통해 트랙잰션이 ReadOnly(@Transactional(readOnly = true)이면 slave 데이터소스를 사용하고, 아닐 경우 master 데이터소스를 사용합니다.

여기까지하면 Spring Boot를 띄우는데에는 문제가 없습니다.

이제 추가로 동적으로 스키마를 변경해서 쿼리를 날려보기 위한 전처리 작업을 진행합니다.

Filter

요청을 받았을 경우 요청 Url을 기준으로 스키마를 나눠야하므로 다음과 같이 구성합니다.

@Configuration
public class DatabaseRoutingFilter implements WebFilter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        // 요청 URI에서 DB 선택
        String requestURI = ((HttpServletRequest) request).getRequestURI();
        String dbKey = determineDatabase(requestURI);
        DataSourceContextHolder.setDataSource(dbKey);

        // 요청 처리 후 데이터소스 초기화
        try {
            chain.doFilter(request, response);
        } finally {
            DataSourceContextHolder.clearDataSource();
        }
    }

    private String determineDatabase(String requestURI) {
        return requestURI.split("/")[1];
    }
}

제가 구성한 예제에서는 정말 간단하게 하기 위해 '/'값을 기준으로 나누고 그 2번째 값을 파싱해서 해당 string을 스키마로 교체합니다.

즉, localhost:8080/comapny1/1에서 '/'로 나눈 두 번째값인 company1을 가져다 쓸 겁니다.

이제 JPA가 쿼리를 보낼때 이를 가로채서 DataSourceContextHolder에 있는 값으로 스키마를 변경할 겁니다.

@Configuration
public class JpaConfig {

    @Bean
    public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
        return properties -> properties.put(AvailableSettings.STATEMENT_INSPECTOR, new SchemaInspector());
    }
}

public class SchemaInspector implements StatementInspector {

    @Override
    public String inspect(String sql) {
        String dbSchema = DataSourceContextHolder.getDataSource();
        return sql.replaceAll("#schema#", dbSchema);
    }
}

코드가 어렵지는 않습니다. StatementInspector는 쿼리를 수행하기 전 전처리작업을 진행할 수 있게 도와주는 인터페이스라고 생각하면 편합니다.

결과

이제 boot를 띄우고 curl을 보내 테스트해봅니다.

> curl --location 'localhost:8080/company1/1'
company1 Data%    
> curl --location 'localhost:8080/company2/1'
company2 Data1%                                                                                 
> curl --location 'localhost:8080/company2/2'
company2 Data2%   

아주 잘 적용된 것을 볼 수 있습니다.

결론

JPA를 쓰면 동적스키마 교체가 어렵다 -> 어렵긴하지만 가능은 하다!

작업을하다보니 Spring MVC가 아닌, Spring WebFlux 기준으로 다시 프로세스를 구성해야했다..

이는 추후 기회가 되면 작성해보도록 하겠다. 애초에 이 본문글이 Spring MVC 특징인 Thread Per Request을 이용한 방식이다보니 이를 그대로 WebFlux에 적용하기엔 무리가 있어보인다.