Java

[Spring] 예제와 함께 보는 다중 데이터 소스, Multiple Data Source(예제: Redis * 2, MariaDB *2)

ride-dev 2024. 2. 23. 00:20

개요

다양한 데이터를 다루는 경우,

대규모 시스템이나 마이크로 서비스 아키텍처를 사용하는 경우에, 

다중 데이터 소스를 적용하는 것을 고려할 수 있고,

(관계형 데이터 베이스와 NoSQL 데이터 베이스를 동시에 사용해야 할 필요가 있을 수 있습니다)

다중 데이터 소스를 적용하여 애플리케이션의 성능, 확장성, 유연성 수준을 높일 수 있습니다.

 

이러한 설정은 데이터 분리, 부하 분산, 백업 및 복구 전략, 그리고 성능 최적화와 같은 여러 가지 이점을 제공합니다.

예컨대, MariaDB 인스턴스 중 하나는 읽기용으로, 다른 하나는 쓰기용으로 사용할 수 있습니다.

또한 각 도메인별로 Redis 인스턴스를 사용하여 확장성과 유연성을 높일 수 있습니다.

 

MariaDB를 읽기용과 쓰기용으로 나누는 것을 통해 부하를 분산하고 성능을 향상시킬 수 있습니다.

(대규모 데이터 처리에 유용한 전략으로 Read-Write-Splitting 이라고 합니다)

Read-Write-Splitting 전략에서 모든 쓰기 작업(INSERT, UPDATE, DELETE)은 마스터 DB에서 처리하며,

데이터 일관성과 무결성을 유지합니다.

리플리카 DB에선 읽기 작업(SELECT)을 처리하며 마스터 DB의 사본입니다.

데이터의 규모에 따라 스케일 아웃(수평적 확장)이 가능합니다.

 

물론, 관계형 데이터 베이스를 쓰기용으로 사용하고 NoSQL을 읽기용으로 사용할 수도 있습니다.

NoSQL은 일반적으로 RDBMS보다 IO속도가 빠릅니다.

(인메모리형 데이터 스토어인 Redis는 2ms 미만의 속도로 데이터를 불러올 수 있을 정도로 빠릅니다)

이 때, redis를 캐시 스토리지로 사용하는 것이 일반적이며 아주 유용합니다.

(단, 데이터 일관성을 유지하는 것이 주요 관건입니다)

 

이번 게시글에서는 Spring 프레임워크에서 Redis와 MariaDB를 사용하여 다중 데이터 소스 환경을 구축하는 방법에 대해 살펴보겠습니다.

(데이터를 다룰 때, MariaDB와 Redis 각각 어떤 데이터를 사용하고 어떤 로직을 적용하는지(예: cache-miss 전략)는 이번 게시글에서 생략하겠습니다)

 

프로젝트 생성

일단, 프로젝트를 생성합니다.

JPA, MaridDB, Redis, Lombok 등의 의존성을 설정합니다.

(Actuator는 Spring에서 제공하는 모니터링 기능에 대한 것으로, DB 연결 상태를 손쉽게 확인할 수 있습니다)

프로젝트 구조

(이 게시글에서 batch에 대한 내용은 생략하고 넘어가겠습니다 - 다른 게시글에서 다루겠습니다)

application.proeprties

logging.level.org.springframework=debug

spring.datasource.gas-station.jdbc-url=jdbc:mariadb://mariadb1:3306/gas_station
spring.datasource.gas-station.username=ride
spring.datasource.gas-station.password=mypassword

spring.datasource.member-post.jdbc-url=jdbc:mariadb://mariadb2:3306/member_post
spring.datasource.member-post.username=ride
spring.datasource.member-post.password=mypassword

#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
#spring.jpa.show-sql=true
#spring.jpa.hibernate.ddl-auto=create-drop

# spring boot env
spring.refresh-token.redis.host=localhost
spring.refresh-token.redis.port=6379


# spring boot env
spring.gas-station.redis.host=localhost
spring.gas-station.redis.port=6380

application.properties에서 구성관리를 해줍니다.

애플리케이션이 구동되면서 어떤 문제가 발생하는지 확인하기 쉽도록 logging level을 debug로 설정합니다.

 

스프링의 Auto Configuration에선 다중 데이터 소스를 기본적으로 지원하지 않기 때문에,

여러 내용을 수동으로 설정해줍니다.

(url, username, password, jpa, hibernate, host, port)

SpringApplication( RedisReactiveAutoConfiguration 사용한다면 생략)

package com.ride.multipledatasourcebatch;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration;

@SpringBootApplication(
        exclude = {
                RedisReactiveAutoConfiguration.class}
)
public class MultipleDataSourceBatchApplication {

    public static void main(String[] args) {
        SpringApplication.run(MultipleDataSourceBatchApplication.class, args);
    }
}

스프링 프로젝트를 생성하면, 자동으로 생성되는 최상단 class입니다.

여기서 RedisReactiveAutoConfiguration을 자동으로 생성하지 않도록 합니다.

(RedisReactiveAutoConfiguration은 Spring Data Redis의 반응형 기능을 쉽고 빠르게 사용할 수 있게 해주며,

Spring Boot 애플리케이션에서 Redis를 이용한 비동기 및 이벤트 기반 프로그래밍을,

간단하게 구현할 수 있도록 도와줍니다,

이 예제에선 수동 설정을 하며, RedisReactiveAutoConfiguration을 정의하지 않을 것이기에 이렇게 설정합니다)

Entiy, Repository

Entity와 Repository의 내용이 크게 중요하진 않기 때문에 아주 간략하게 작성하겠습니다.

(구조를 이렇게 만든 이유는, 다양한 경우를 상정했기 때문입니다)

package com.ride.multipledatasourcebatch.maria1;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter @Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class GasStation {
    @Id
    @GeneratedValue
    private Long id;
    private String content;
}
package com.ride.multipledatasourcebatch.maria1;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface GasStationRepository extends JpaRepository<GasStation, Long> {
}
package com.ride.multipledatasourcebatch.redis1;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

import java.io.Serializable;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash(value = "refreshToken", timeToLive = 7 * 24 * 60 * 60 * 1000) // 7일
public class Redis1 implements Serializable {

    @Id
    private String token;
    @Indexed
    private String email;

    @Builder
    public Redis1(String token, String email) {
        this.token = token;
        this.email = email;
    }
}
package com.ride.multipledatasourcebatch.redis1;

import org.springframework.data.repository.CrudRepository;

public interface Redis1Repository extends CrudRepository<Redis1 ,String> {
}

Redis를 사용하는 것과, MariaDB를 사용할 때, 2가지를 주의합니다.

@Id 참조

Redis - import org.springframework.data.annotation.Id;

MariaDB -  import jakarta.persistence.Id;

Repository

Redis - CrudRepository

MariaDB -  JpaRepository

Redis 참고사항

특정 필드에 @Indexed 를 사용하여 Index를 직접 지정해야 그 필드로 조회하는 과정이 간편해집니다.

또한, TTL을 설정하여 데이터가 존재할 수 있는 시간을 지정합니다.

이를 통해 데이터의 일관성 및 유효성을 관리합니다.

DataSourceConfig

이제 각각의 DataSource에 대한 Config 파일을 작성하겠습니다.

MariaConfig

package com.ride.multipledatasourcebatch;

import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
        basePackages = "com.ride.multipledatasourcebatch.maria1",
        transactionManagerRef = "gasStationTransactionManager",
        entityManagerFactoryRef = "gasStationEntityManagerFactory")
@EntityScan("com.ride.multipledatasourcebatch.maria1")
public class Maria1Config {

    @Bean(name = "gasStationDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.gas-station")
    public DataSource gasStationDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "gasStationEntityManagerFactoryBuilder")
    public EntityManagerFactoryBuilder gasStationEntityManagerFactoryBuilder() {
        return new EntityManagerFactoryBuilder(new HibernateJpaVendorAdapter(), new HashMap<>(), null);
    }

    @Bean(name = "gasStationEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean gasStationEntityManagerFactory(
            @Qualifier("gasStationEntityManagerFactoryBuilder") EntityManagerFactoryBuilder builder,
            @Qualifier("gasStationDataSource") DataSource dataSource) {
        Map<String, Object> jpaProperties = new HashMap<>();
        jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.MariaDBDialect");
        jpaProperties.put("hibernate.hbm2ddl.auto", "update");
        jpaProperties.put("hibernate.format_sql", true);
        jpaProperties.put("hibernate.show-sql", true);

        return builder
                .dataSource(dataSource)
                .properties(jpaProperties)
                .packages("com.ride.multipledatasourcebatch.maria1")
                .persistenceUnit("gasStation")
                .build();
    }

    @Bean(name = "gasStationTransactionManager")
    public PlatformTransactionManager gasStationTransactionManager(
            @Qualifier("gasStationEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);
        return transactionManager;
    }
}
package com.ride.multipledatasourcebatch;

import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
        basePackages = "com.ride.multipledatasourcebatch.maria2",
        transactionManagerRef = "memberPostTransactionManager",
        entityManagerFactoryRef = "memberPostEntityManagerFactory")
@EntityScan("com.ride.multipledatasourcebatch.maria2")
public class Maria2Config {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.member-post")
    public DataSource memberPostDataSource() {
        return DataSourceBuilder.create().build();
    }
    @Bean(name = "memberPostEntityManagerFactoryBuilder")
    public EntityManagerFactoryBuilder memberPostEntityManagerFactoryBuilder() {
        return new EntityManagerFactoryBuilder(new HibernateJpaVendorAdapter(), new HashMap<>(), null);
    }

    @Bean(name = "memberPostEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean memberPostEntityManagerFactory(
            @Qualifier("memberPostEntityManagerFactoryBuilder") EntityManagerFactoryBuilder builder,
            @Qualifier("memberPostDataSource") DataSource dataSource) {
        Map<String, Object> jpaProperties = new HashMap<>();
        new HashMap<>();
        jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.MariaDBDialect");
        jpaProperties.put("hibernate.hbm2ddl.auto", "update");
        jpaProperties.put("hibernate.format_sql", true);
        jpaProperties.put("hibernate.show-sql", true);

        return builder
                .dataSource(dataSource)
                .properties(jpaProperties)
                .packages("com.ride.multipledatasourcebatch.maria2")
                .persistenceUnit("memberPost")
                .build();
    }

    @Bean
    public PlatformTransactionManager memberPostTransactionManager(
            @Qualifier("memberPostEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);
        return transactionManager;
    }
}

이제 각 영역별로 나누어 설명하겠습니다.

@Configuration // 이 클래스를 Spring 구성 클래스로 선언합니다. Spring이 구성을 자동으로 탐지하고 적용할 수 있게 합니다.
@EnableTransactionManagement // Spring의 선언적 트랜잭션 관리를 활성화합니다. @Transactional 어노테이션 사용을 가능하게 합니다.
@EnableJpaRepositories(
        basePackages = "com.ride.multipledatasourcebatch.maria1", // 이 데이터 소스를 사용하는 JPA 리포지토리들이 위치한 패키지를 지정합니다.
        transactionManagerRef = "gasStationTransactionManager", // 이 데이터 소스에 대한 트랜잭션 관리자의 빈 이름을 지정합니다.
        entityManagerFactoryRef = "gasStationEntityManagerFactory") // 이 데이터 소스에 대한 EntityManagerFactory의 빈 이름을 지정합니다.
@EntityScan("com.ride.multipledatasourcebatch.maria1") // JPA 엔티티들이 위치한 패키지를 지정합니다. 이 설정으로 Spring은 해당 위치의 엔티티 클래스를 스캔합니다.
public class Maria1Config {

basePakages옵셥을 통해 Repository가 위치한 영역을 지정합니다.

넓은 영역을 지정해도 되지만, 다른 MariaDB를 사용하는 Repository와 중복되선 안됩니다.

    @Bean(name = "gasStationDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.gas-station")
    public DataSource gasStationDataSource() {
        return DataSourceBuilder.create().build();
    }

application.properties에서 spring.datasource.gas-station이라는 접두사로 정의한 내용을 바탕으로 DataSource를 정의합니다.

@Bean(name = "gasStationEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean gasStationEntityManagerFactory(
        @Qualifier("gasStationEntityManagerFactoryBuilder") EntityManagerFactoryBuilder builder,
        @Qualifier("gasStationDataSource") DataSource dataSource) {
    Map<String, Object> jpaProperties = new HashMap<>();
    jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.MariaDBDialect");
    jpaProperties.put("hibernate.hbm2ddl.auto", "update");
    jpaProperties.put("hibernate.format_sql", true);
    jpaProperties.put("hibernate.show-sql", true);

    return builder
            .dataSource(dataSource)
            .properties(jpaProperties)
            .packages("com.ride.multipledatasourcebatch.maria1")
            .persistenceUnit("gasStation")
            .build();
}

jpa, hibernate에 대한 설정을 수동으로 지정합니다.

@Bean(name = "gasStationTransactionManager")
public PlatformTransactionManager gasStationTransactionManager(
        @Qualifier("gasStationEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
    JpaTransactionManager transactionManager = new JpaTransactionManager();
    transactionManager.setEntityManagerFactory(entityManagerFactory);
    return transactionManager;
}

PlatformTransactionManager 빈을 정의합니다. 이 빈의 이름을 'gasStationTransactionManager'로 지정합니다.

 

요약하자면, application.properties에 작성한 내용을 바탕으로 DataSource를 수동으로 구성합니다.

(jpa, hibernate, entityManager 등)

RedisConfig

package com.ride.multipledatasourcebatch;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
@Configuration
@EnableRedisRepositories(
        basePackages = "com.ride.multipledatasourcebatch.redis1",
        redisTemplateRef = "Redis1Template"
)
public class Redis1Config {
    @Value("${spring.refresh-token.redis.host}")
    private String redisHost;
    @Value("${spring.refresh-token.redis.port}")
    private int redisPort;

    @Bean
    public LettuceConnectionFactory redis1ConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> Redis1Template() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redis1ConnectionFactory());
        return template;
    }
}
package com.ride.multipledatasourcebatch;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

@Configuration
@EnableRedisRepositories(
        basePackages = "com.ride.multipledatasourcebatch.redis2",
        redisTemplateRef = "Redis2Template"
)
public class Redis2Config {
    @Value("${spring.gas-station.redis.host}")
    private String redisHost;
    @Value("${spring.gas-station.redis.port}")
    private int redisPort;

    @Bean
    public LettuceConnectionFactory redis2ConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisTemplate<String, Object> Redis2Template() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redis2ConnectionFactory());
        return template;
    }
}

redis또한 MariaDB처럼 application.properties를 바탕으로 DataSource에 의존성을 주입합니다.

(Redis1의 basePakages 경로 또한, Redis2와 겹치지 않도록 주의합니다)

 

Redis를 하나만 사용한다면 RedisRepository만으로 쉽게 구성할 수 있겠지만,

Redis를 여러 개 사용할 것이기 때문에 (상대적으로)복잡한 설정이 필요했습니다.

따라서 RedisConfig 클래스를 통해, 특정 port를 의존성으로 주입받은 RedisTemplate Bean을 생성했습니다.

 

이제 각 repository는 특정 데이터베이스를 의존성으로 주입받았기 때문에 기존에 사용하던 것처럼 repository 의존성을 주입받을 수 있습니다.

 

아래는 그 예시입니다.

@Service
@RequiredArgsConstructor
public class RefreshTokenRedisService {

    private final RefreshTokenRepository refreshTokenRepository;
    
    public RefreshToken findByToken(String refreshToken) {
        return refreshTokenRepository.findById(refreshToken).orElse(null);
    }
    
    public void save(RefreshToken token) {
        refreshTokenRepository.save(token);
    }
}

 

다중 데이터 소스 환경의 구축은 복잡하고 다양한 요구 사항을 충족시켜야 합니다.

스프링에서 제공하는 auto configuration이 아닌, 수동으로 configuration을 작성해야 하는 부분이 생소할 수 있습니다.

중요한 것은 각 데이터 소스의 특성을 정확히 이해하고,

애플리케이션의 요구 사항에 맞게 적절히 통합하는 것입니다.

 

728x90