
데이터베이스 이중화(Database Redundancy)란 단순히 데이터를 여러 서버에 분산 저장하는 것을 넘어, 시스템의 안정성과 가용성을 극대화하기 위하여 도입하는 기술이다. 비록 누군가는 이중화가 오버엔지니어링으로 평가될 수 있으나, 본 프로젝트에서는 다음과 같은 이유로 이중화 기술을 도입하였다.
실무 경험 쌓기
실무에서 대다수 기업이 채택하는 데이터베이스 이중화 아키텍처를 직접 경험함으로써, 실제 운영 환경에서의 문제 해결 능력과 시스템 설계 역량을 강화하고자 하였다.
서비스 안정성 및 가용성 향상
단일 데이터베이스 서버에 장애가 발생할 경우 전체 시스템에 치명적인 영향을 미칠 우려가 있으므로, 이중화를 통해 읽기와 쓰기 작업을 분리하고 백업 및 복구 시간을 단축하여 시스템의 안정성과 서비스 가용성을 높이고자 하였다.
확장성 및 성능 최적화
읽기 작업은 Slave 데이터베이스에서, 쓰기 작업은 Master 데이터베이스에서 처리함으로써 서버 부하를 분산시켜 전체 시스템의 성능을 최적화할 수 있다. 또한, 향후 트래픽이 증가할 경우 추가적인 Slave 노드 도입을 통해 수평 확장이 용이하도록 하였다.
MariaDB는 다양한 이중화 구성 방식을 선택할 수 있다. 본 프로젝트에서는 Master-Slave 구조를 채택하였다. Master-Master, Galera Cluster, Shared-Nothing 등등 여러 구조가 있었지만 Master-Slave 구조는 가장 보편적이면서 운영이 단순하다는 장점을 가지고 있었다. 또한, 우리 프로젝트에는 쓰기 트래픽 보다 읽기 트래픽이 많았기 때문에 읽기 부하 분산을 하기 위해 Master-Slave 구조를 선택하게 되었다.

Master 데이터베이스
Master 서버는 주 데이터베이스로서 쓰기 작업(INSERT, UPDATE, DELETE 등)을 전담한다. 모든 변경 사항은 Master에서 처리한 후, Slave로 복제되어 데이터 일관성을 유지한다.
Slave 데이터베이스
Slave 서버는 Master에서 발생한 변경 사항을 실시간 혹은 주기적으로 복제 받아 주로 읽기 작업(SELECT)을 수행한다. 이로써 읽기 부하를 분산시키고, Master 장애 발생 시 보조적으로 활용할 수 있는 기반을 마련하였다.
기존 프로젝트는 단일 데이터베이스 서버가 읽기와 쓰기 작업을 모두 담당하는 구조였다. 이러한 단일 구조는 다음과 같은 한계를 가지고 있다고 생각하였다.
이전 포스팅에서 확인한 것처럼 읽기에 대한 API 요청이 쓰기 요청보다 더 많이 발생한다는 것을 대용량 트래픽 테스트를 시행하면서 알게 되었다. 따라서 나는 우리 프로젝트의 백엔드 개발자로서 읽기, 쓰기 요청을 분리할 필요가 있다고 생각을 하였고, 데이터베이스를 이중화하면서 Master-Slave를 본격적으로 도입하기 시작하였다.
나는 데이터베이스의 Master-Slave 구조 도입을 통해 다음과 같은 전략을 가졌다.
읽기와 쓰기의 분리
쿠버네티스를 활용한 배포
쿠버네티스(Kubernetes)를 활용하여 데이터베이스 서버를 파드(Pod)로 구성하였으며, 다음과 같은 방식으로 관리하였다.
아래 Yaml 코드는 spring.datasource 아래에 master와 slave 섹션을 분리하여 각각 DB 정보를 정의한 코드이다. application.yml에 아래와 같이 작성하였다.
spring:
datasource:
master:
url: ${vault.master_mariadb_url}
driver-class-name: org.mariadb.jdbc.Driver
username: ${vault.master_mariadb_username}
password: ${vault.master_mariadb_password}
slave:
url: ${vault.slave_mariadb_url}
driver-class-name: org.mariadb.jdbc.Driver
username: ${vault.slave_mariadb_username}
password: ${vault.slave_mariadb_password}
master와 slave 각각 별도의 마리아DB 접속 정보를 설정한다.MariaDbProperties위 Yaml 파일의 정보를 객체로 받아오는 클래스이다. Spring Boot의 @ConfigurationProperties를 활용하여 프로퍼티 바인딩을 수행하였다.
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.datasource")
public class MariaDbProperties {
private DataSourceProperties master;
private DataSourceProperties slave;
@Setter
@Getter
public static class DataSourceProperties {
private String url;
private String driverClassName;
private String username;
private String password;
}
}
spring.datasource.master에 해당하는 부분을 master 객체가, spring.datasource.slave는 slave 객체가 각각 매핑된다.DataSource Bean 등록: DataSourceConfig@Configuration
@Slf4j
@EnableTransactionManagement
@RequiredArgsConstructor
@EnableJpaRepositories(
basePackages = "cloud.zipbob.edgeservice.domain.member.repository",
entityManagerFactoryRef = "masterEntityManagerFactory",
transactionManagerRef = "masterTransactionManager"
)
public class DataSourceConfig {
private final MariaDbProperties mariaDbProperties;
@Bean(name = "masterDataSource")
public DataSource masterDataSource() {
log.info("Initializing Master DataSource");
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(mariaDbProperties.getMaster().getUrl());
dataSource.setDriverClassName(mariaDbProperties.getMaster().getDriverClassName());
dataSource.setUsername(mariaDbProperties.getMaster().getUsername());
dataSource.setPassword(mariaDbProperties.getMaster().getPassword());
return dataSource;
}
@Bean(name = "slaveDataSource")
public DataSource slaveDataSource() {
log.info("Initializing Slave DataSource");
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(mariaDbProperties.getSlave().getUrl());
dataSource.setDriverClassName(mariaDbProperties.getSlave().getDriverClassName());
dataSource.setUsername(mariaDbProperties.getSlave().getUsername());
dataSource.setPassword(mariaDbProperties.getSlave().getPassword());
return dataSource;
}
...
}
@Bean(name = "masterDataSource"): 마스터 DB 연결용 DataSource(HikariCP 활용)@Bean(name = "slaveDataSource"): 슬레이브 DB 연결용 DataSource(HikariCP 활용)HikariDataSource 외에 다른 커넥션 풀을 사용할 수도 있으나, HikariCP가 경량화와 높은 성능 때문에 가장 널리 활용되는 추세이므로 나 또한 HikariCP를 활용하였다.
@Bean
@Primary
public DataSource dataSource() {
log.info("Initializing Routing DataSource");
RoutingDataSource routingDataSource = new RoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave", slaveDataSource());
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(masterDataSource());
return routingDataSource;
}
RoutingDataSource(뒤에서 소개)의 targetDataSources 맵에 “master”, “slave”로 구분하여 등록한다. @Primary를 명시함으로써 다른 Bean 주입 시 기본값으로 사용되도록 지정하였다.@Bean(name = "masterEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory() {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource());
factory.setPackagesToScan("cloud.zipbob.edgeservice.domain.member");
factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
factory.setJpaPropertyMap(hibernateProperties());
return factory;
}
@Bean(name = "masterTransactionManager")
public PlatformTransactionManager masterTransactionManager(
LocalContainerEntityManagerFactoryBean masterEntityManagerFactory) {
return new JpaTransactionManager(
Objects.requireNonNull(masterEntityManagerFactory.getObject()));
}
private Map<String, Object> hibernateProperties() {
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.hbm2ddl.auto", "update");
properties.put("hibernate.show_sql", true);
properties.put("hibernate.format_sql", true);
properties.put("hibernate.use_sql_comments", true);
properties.put("hibernate.dialect", "org.hibernate.dialect.MariaDBDialect");
return properties;
}
RoutingDataSource가 주입되므로, AOP를 통한 읽기/쓰기가 동적으로 분기된다.RoutingDataSourcepublic class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceType();
}
}
AbstractRoutingDataSource를 상속받아, determineCurrentLookupKey()에서 현재 쓰레드가 어떤 DB(“master” or “slave”)를 사용해야 하는지 반환한다.DataSourceContextHolder를 통해 가져온다.DataSourceAspect@Aspect
@Component
@Slf4j
public class DataSourceAspect {
@Before("execution(* cloud.zipbob.edgeservice.domain.member.service.*.get*(..)) "
+ "|| execution(* cloud.zipbob.edgeservice.domain.member.service.*.check*(..))")
public void setReadDataSource() {
log.info("Switching to Slave DataSource");
DataSourceContextHolder.setDataSourceType("slave");
}
@Before("execution(* cloud.zipbob.edgeservice.domain.member.service.*.save*(..)) "
+ "|| execution(* cloud.zipbob.edgeservice.domain.member.service.*.update*(..)) "
+ "|| execution(* cloud.zipbob.edgeservice.domain.member.service.*.delete*(..))")
public void setWriteDataSource() {
log.info("Switching to Master DataSource");
DataSourceContextHolder.setDataSourceType("master");
}
}
@Before 어노테이션을 이용하여, 해당 메서드의 실행 직전에 마스터 또는 슬레이브로 라우팅을 변경한다. DataSourceContextHolderpublic class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDataSourceType(String dataSourceType) {
contextHolder.set(dataSourceType);
}
public static String getDataSourceType() {
return contextHolder.get();
}
public static void clearDataSourceType() {
contextHolder.remove();
}
}
contextHolder에 값을 세팅하면, RoutingDataSource에서 이를 참조하여 DB 연결을 결정한다.데이터베이스 이중화를 구현하는 과정에서 다음과 같은 사항과 최적화 전략을 반드시 고려하였으며, 특히 이중화 구조의 특성에 따라 최종 일관성(Eventual Consistency) 전략을 적용하여 읽기-쓰기 분리를 효율적으로 구현하였다.
안정적인 서비스 운영을 위하여 장애 대응 계획과 지속적인 모니터링 체계를 마련하였다. 특히 최종 일관성 기반 구조 특성상 복제 지연을 주기적으로 모니터링하고, 장애 발생 시 Master-Slave 전환에 즉각 대응할 수 있도록 하였다.
프로젝트를 통하여 단순 이론적 이해에 머무르지 않고, 실제 운영 환경에서 MariaDB 데이터베이스 이중화를 구현하고 최적화하는 과정을 경험하였다.
Master-Slave 구조를 도입하여 시스템 안정성과 확장성을 확보하였고, 최종 일관성(Eventual Consistency) 전략에 따라 일시적인 비일관성을 허용하되, 중요 시점에서는 Master DB를 통해 정합성을 보장함으로써 실무에 적합한 읽기-쓰기 분리를 실현하였다.
또한, 쿠버네티스를 통한 배포 자동화 및 실시간 모니터링 체계를 구축하여, 장애 발생 시 즉각적인 복구와 확장이 가능한 환경을 마련하였다.
이와 같은 경험은 대규모 시스템 구축 및 운영에 필요한 필수 역량을 갖추고 대규모 트래픽을 다루는 전략을 키우는 것에 많은 도움이 되었다고 생각한다.