본문 바로가기

Multi Tenancy

[Spring Boot] 스키마 기반 멀티테넌시 구현 (2/2)

728x90

이전글에 이어서 진행하도록 하겠습니다.

 

 

@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {

    static final String DEFAULT_TENANT = "default";

    @Override
    public String resolveCurrentTenantIdentifier() {
        return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .filter(Predicate.not(authentication -> authentication instanceof AnonymousAuthenticationToken))
                .map(Principal::getName)
                .orElse(DEFAULT_TENANT);
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

username을 통해 어떤 테넌트를 사용할 지 결정해주는 클래스입니다.

인증 정보가 없다면 default라는 스키마를 사용하게 됩니다.

 

 

@Component
public class TenantConnectionProvider implements MultiTenantConnectionProvider {

    private DataSource datasource;

    public TenantConnectionProvider(DataSource dataSource) {
        this.datasource = dataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        return datasource.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        final Connection connection = getAnyConnection();
        connection.createStatement()
                .execute(String.format("SET SCHEMA \"%s\";", tenantIdentifier));
        return connection;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
        connection.createStatement()
                .execute(String.format("SET SCHEMA \"%s\";", TenantIdentifierResolver.DEFAULT_TENANT));
        releaseAnyConnection(connection);
    }

    @Override
    public boolean supportsAggressiveRelease() {
        return false;
    }

    @Override
    public boolean isUnwrappableAs(Class unwrapType) {
        return false;
    }

    @Override
    public <T> T unwrap(Class<T> unwrapType) {
        return null;
    }
}

실제로 DB에 SET SCHEMA 명령을 내려주는 클래스입니다.

 

 

@Configuration
public class HibernateConfig {

    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        return new HibernateJpaVendorAdapter();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, JpaProperties jpaProperties,
            MultiTenantConnectionProvider multiTenantConnectionProvider, CurrentTenantIdentifierResolver tenantIdentifierResolver) {

        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan(MultiTenancyApplication.class.getPackage().getName());
        em.setJpaVendorAdapter(jpaVendorAdapter());

        Map<String, Object> jpaPropertiesMap = new HashMap<>(jpaProperties.getProperties());
        jpaPropertiesMap.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
        jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
        jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifierResolver);
        em.setJpaPropertyMap(jpaPropertiesMap);

        return em;
    }
}

하이버네이트 설정을 위한 클래스입니다. 앞서 생성한 Resolver, Provider 클래스가 사용됩니다.

 

 

@Configuration
public class FlywayConfig {

    @Bean
    public Flyway flyway(DataSource dataSource) {
        Flyway flyway = Flyway.configure()
                .locations("db/migration/default")
                .dataSource(dataSource)
                .schemas(TenantIdentifierResolver.DEFAULT_TENANT)
                .load();
        flyway.migrate();
        return flyway;
    }

    @Bean
    CommandLineRunner commandLineRunner(UserRepository repository, DataSource dataSource) {
        return args -> {
            repository.findAll().forEach(user -> {
                String tenant = user.getUsername();
                Flyway flyway = Flyway.configure()
                        .locations("db/migration/tenants")
                        .dataSource(dataSource)
                        .schemas(tenant)
                        .load();
                flyway.migrate();
            });
        };
    }
}

Flyway라는 마이그레이션 도구를 위한 설정 클래스입니다.

예를들면 철수 스키마에 Note라는 테이블이 있고, 영희 스키마에 Note라는 테이블이 있을때

Note 테이블을 수정한다면, 모든 스키마에 반영을 해주어야합니다.

이때 필요한것이 마이그레이션 도구입니다.

 

 

-- db/migration/default/V1__init_schema.sql
CREATE TABLE user
(
    id                 BIGINT AUTO_INCREMENT,
    username           VARCHAR(255) UNIQUE,
    password           VARCHAR(255)
);
-- db/migration/tenants/V1__init_schema.sql
CREATE TABLE note
(
    id                 BIGINT AUTO_INCREMENT,
    text               TEXT
);

resources 폴더 아래 db/migration/default 경로에 해당 이름으로 SQL 파일을 추가합니다.

default와 tenants로 폴더가 나뉘어져 있는데, default는 마스터, tenants는 자식 스키마로 생각하시면 됩니다.

파일 네이밍이 특이한데요 __ 앞에 V1 부분은 버전을 나타냅니다.

추후에 SQL 파일 수정 필요시 V1 파일을 수정하는것이 아닌 V2, V3 등으로 버전이 다른 SQL 파일을 추가해주면 됩니다.

 

 

@Component
public class TenantService {

    private DataSource dataSource;

    public TenantService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void initDatabase(String schema) {
        Flyway flyway = Flyway.configure()
                .locations("db/migration/tenants")
                .dataSource(dataSource)
                .schemas(schema)
                .load();
        flyway.migrate();
    }
}

새로 추가한 사용자 이름으로 스키마를 생성하기 위한 서비스도 구현합니다.

여기까지 멀티테넌시에 필요한 구현은 모두 끝났습니다.

curl을 통해 잘 동작하는지 확인해보도록 하겠습니다.

 

> curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"john\",\"password\":\"password\"}" http://localhost:8080/users
{"id":1,"username":"john"}
> curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"jane\",\"password\":\"qwerty123\"}" http://localhost:8080/users
{"id":2,"username":"jane"}

위 처럼 사용자를 생성합니다. 사용자는 default 스키마에 있기 때문에, 같은 테이블에서 생성된 걸 확인할 수 있습니다.

 

 

> curl -u john:password -X POST -H "Content-Type: application/json" -d "{\"text\":\"Hello from John!\"}" http://localhost:8080/notes
{"id":1,"text":"Hello from John!"}
> curl -u jane:qwerty123 -X POST -H "Content-Type: application/json" -d "{\"text\":\"Hello from Jane!\"}" http://localhost:8080/notes
{"id":1,"text":"Hello from Jane!"}

이번엔 생성한 사용자의 인증정보를 이용하여 각 스키마에 Note 엔티티를 추가해보았습니다.

사용자를 추가할때와 다른점은, 각 스키마에 생성되었기 때문에 id가 모두 1로 출력되는 것 입니다.

 

 

> curl -u jane:qwerty123 http://localhost:8080/notes
[{"id":1,"text":"Hello from Jane!"}]

jane의 Note 목록을 보면 아이템이 1개만 들어있는것을 통해 위 내용을 다시한번 확인할 수 있습니다.

 

 

사실 따라해보면 굉장히 쉬운 내용이지만, 한글로 된 자료가 많지 않아 초기 도입 시 어려움이 있었습니다.

궁금하신 내용이 있으시면 댓글로 남겨주세요. 고맙습니다.

 

 

ref : https://sultanov.dev/blog/schema-based-multi-tenancy-with-spring-data/

728x90

'Multi Tenancy' 카테고리의 다른 글

[Spring Boot] 스키마 기반 멀티테넌시 구현 (1/2)  (0) 2022.08.09