DB Read/Write 분리

리본24·2025년 2월 5일

Spring

목록 보기
6/7
post-thumbnail

1. DB Read/Write 분리 개념

1.1 Primary-Secondary 구조 이해

1. 개념

Primary-Secondary 구조는 데이터베이스 성능을 최적화하기 위해 읽기(Read)와 쓰기(Write) 작업을 분리하는 아키텍처입니다.

  • Primary 노드: 데이터를 쓰기 전용으로 처리하며, 모든 데이터 변경 작업(INSERT, UPDATE, DELETE)을 담당합니다.
  • Secondary 노드: 데이터를 읽기 전용으로 처리하며, Primary로부터 복제된 데이터를 사용합니다.
  • 데이터 동기화: Primary에서 변경된 데이터가 Secondary로 실시간 또는 일정 주기로 복제됩니다.

2. 작동 원리

  1. 쓰기 작업은 Primary 노드에서 실행되고, 결과가 Secondary 노드로 복제됩니다.
  2. 읽기 작업은 Secondary 노드에서 실행되어 Primary의 부하를 분산합니다.
  3. 시스템은 읽기 요청을 Secondary로, 쓰기 요청을 Primary로 분배합니다.

3. 장점

  1. 성능 향상: 읽기 작업이 Secondary로 분산되어 Primary의 부하를 줄이고, 읽기 요청이 많은 시스템에서 성능이 개선됩니다.
  2. 확장성: Secondary 노드를 추가하여 읽기 성능을 수평적으로 확장할 수 있습니다.
  3. 고가용성: Primary 노드가 장애를 일으키더라도, 읽기 작업은 Secondary에서 지속적으로 수행 가능합니다.

4. 단점

  1. 데이터 동기화 지연:
    • Primary에서 Secondary로 데이터가 복제되는 데 시간이 걸리므로, 최신 데이터가 Secondary에 반영되지 않을 수 있습니다.
    • 실시간 일관성이 필요한 작업에서는 문제가 될 수 있습니다.
  2. 복잡성 증가:
    • Primary와 Secondary 간의 데이터 복제를 설정하고 관리하는 데 추가적인 비용과 노력이 필요합니다.
  3. 쓰기 성능 한계:
    • 모든 쓰기 작업은 Primary에서만 수행되므로, 쓰기 작업이 많을 경우 Primary가 병목이 될 수 있습니다.

1.2 Read/Write 트랜잭션 분리 필요성

1. 읽기 작업

읽기 요청은 Secondary 노드에서 처리하여 성능을 최적화할 수 있습니다.

  • 이점:
    • 읽기 요청이 많은 애플리케이션에서 Secondary 노드를 활용하면 성능이 크게 향상됩니다.
    • Primary에 집중되는 부하를 줄이고, 쓰기 성능을 확보할 수 있습니다.
  • 적용 방식:
    • Spring의 @Transactional(readOnly = true)를 활용하여 읽기 전용 트랜잭션을 설정합니다.
    • Secondary 노드에서 데이터를 조회하여 작업을 수행합니다.
  • 예제:
    @Transactional(readOnly = true)
    public List<Order> fetchOrders() {
        return orderRepository.findAll();
    }

2. 쓰기 작업

쓰기 요청은 Primary 노드에서 처리하여 데이터의 무결성을 보장해야 합니다.

  • 이점:
    • 쓰기 작업을 Primary에서만 처리하므로 데이터 변경 작업의 일관성과 안정성이 확보됩니다.
    • 데이터 손상이나 충돌을 방지할 수 있습니다.
  • 적용 방식:
    • Spring의 @Transactional을 활용하여 쓰기 작업이 Primary 노드에서 수행되도록 설정합니다.
    • 트랜잭션을 사용해 작업의 원자성을 보장합니다.
  • 예제:
    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }

3. Read/Write 분리가 필요한 이유

  1. 성능 최적화:
    • 읽기와 쓰기를 분리하여 각 작업의 효율성을 극대화할 수 있습니다.
    • Secondary 노드가 읽기 작업을 처리하므로 Primary 노드의 쓰기 성능이 보장됩니다.
  2. 확장성 확보:
    • Secondary 노드를 추가하여 읽기 요청을 분산할 수 있어 시스템 확장성이 향상됩니다.
    • 쓰기 요청이 많아지는 경우 Primary 노드의 성능을 보조할 수 있습니다.
  3. 비용 효율성:
    • 읽기와 쓰기 작업을 분리하면 고성능 장비를 필요한 작업에만 집중적으로 사용하여 리소스 활용도를 높일 수 있습니다.

Primary-Secondary 구조를 활용한 Read/Write 분리는 데이터베이스 성능을 최적화하고 시스템 확장성을 확보할 수 있는 효과적인 방법입니다. 이를 통해 읽기 작업이 많은 환경에서도 데이터 일관성과 성능을 동시에 유지할 수 있습니다. 데이터 동기화 지연과 같은 단점은 애플리케이션 설계 단계에서 적절한 전략을 통해 해결해야 합니다.


2. Read/Write 분리 실습

2.1 DB 커넥션 설정

Primary 노드는 쓰기 작업을 처리하고, Secondary 노드는 읽기 작업을 처리하도록 데이터 소스를 설정합니다. Spring Boot의 DataSourceConfiguration에 각각의 데이터 소스를 설정합니다.

1. application.yml 설정

spring:
  datasource:
    primary:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/spring_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
        username: root
        password: root
        connectionTimeout: 30000
    secondary:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/spring_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
        username: root
        password: root
        connectionTimeout: 30000
  • Datasource 분리:
    • spring.datasource.primaryspring.datasource.secondary로 각각 Primary(쓰기 전용)와 Secondary(읽기 전용) 데이터 소스의 설정을 분리합니다.
  • Hikari 설정:
    • hikari 블록 내에 데이터 소스 연결에 필요한 필수 속성을 정의합니다.
    • driver-class-name: MySQL JDBC 드라이버 클래스를 지정합니다.
    • jdbc-url: 데이터베이스 접속 URL로, 추가 옵션(useSSL=false, serverTimezone=UTC, allowPublicKeyRetrieval=true)을 통해 SSL 사용 안함, 타임존 설정, 공개 키 검색 허용 등을 지정합니다.
    • username/ password: 데이터베이스 접속에 필요한 사용자명과 비밀번호를 설정합니다.
    • connectionTimeout: 연결 타임아웃을 30초로 설정하여, 연결 대기 시간이 너무 짧지 않도록 합니다.

2. DataSourceConfiguration 클래스

@Configuration
public class DataSourceConfiguration {

  public static final String PRIMARY_DATASOURCE = "primaryDataSource";
  public static final String SECONDARY_DATASOURCE = "secondaryDataSource";

  // Primary 데이터 소스 빈 생성 (쓰기 전용)
  @Bean(PRIMARY_DATASOURCE)
  @ConfigurationProperties(prefix = "spring.datasource.primary.hikari")
  public DataSource primaryDataSource() {
    return DataSourceBuilder.create()
        .type(HikariDataSource.class)
        .build();
  }

  // Secondary 데이터 소스 빈 생성 (읽기 전용)
  @Bean(SECONDARY_DATASOURCE)
  @ConfigurationProperties(prefix = "spring.datasource.secondary.hikari")
  public DataSource secondaryDataSource() {
    return DataSourceBuilder.create()
        .type(HikariDataSource.class)
        .build();
  }

  // 동적 라우팅 데이터 소스 빈 생성
  @Bean
  @Primary
  @DependsOn({PRIMARY_DATASOURCE, SECONDARY_DATASOURCE})
  public DataSource routingDataSource(
      @Qualifier(PRIMARY_DATASOURCE) DataSource primaryDataSource,
      @Qualifier(SECONDARY_DATASOURCE) DataSource secondaryDataSource) {

    // Custom RoutingDataSource 인스턴스 생성
    RoutingDataSource routingDataSource = new RoutingDataSource();

    // 타깃 데이터소스 매핑: 키("Primary", "Secondary")는 determineCurrentLookupKey() 메서드와 일치해야 함
    Map<Object, Object> datasourceMap = new HashMap<>();
    datasourceMap.put("primary", primaryDataSource);
    datasourceMap.put("secondary", secondaryDataSource);

    routingDataSource.setTargetDataSources(datasourceMap);
    // 기본 데이터 소스로 Primary를 지정 (트랜잭션이 읽기 전용이 아닐 경우 사용)
    routingDataSource.setDefaultTargetDataSource(primaryDataSource);
    routingDataSource.afterPropertiesSet(); // 내부 설정값 초기화

    return routingDataSource;
  }
}
  • 빈 이름 상수:
    • PRIMARY_DATASOURCESECONDARY_DATASOURCE 상수를 통해 각각의 데이터 소스 빈 이름을 정의합니다.
    • 이 이름을 이용해 다른 빈에서 @Qualifier로 주입할 때 정확하게 구분할 수 있습니다.
  • Primary 및 Secondary 데이터 소스 빈:
    • @ConfigurationProperties(prefix = "spring.datasource.primary.hikari")와 같이 YAML의 설정값이 해당 빈에 바인딩됩니다.
    • DataSourceBuilder.create().type(HikariDataSource.class).build()를 사용하여 HikariCP 기반의 데이터 소스를 생성합니다.
  • 동적 라우팅 데이터 소스 (RoutingDataSource):
    • @Primary로 지정하여, 다른 컴포넌트(JPA 등)에서 기본적으로 주입받는 데이터 소스가 되도록 합니다.
    • @DependsOn 어노테이션을 사용하여 Primary와 Secondary 데이터 소스가 먼저 생성된 후 이 빈이 생성되도록 합니다.
    • setTargetDataSources(datasourceMap)를 통해 두 데이터 소스를 키("primary", "secondary")로 매핑합니다.
    • setDefaultTargetDataSource(primaryDataSource)로 기본값을 Primary로 지정합니다.
    • afterPropertiesSet()을 호출하여 내부 설정을 초기화합니다.

3. RoutingDataSource 클래스

public class RoutingDataSource extends AbstractRoutingDataSource {

  @NotNull
  @Override
  protected Object determineCurrentLookupKey() {
    // 현재 트랜잭션이 읽기 전용이면 "secondary"를, 그렇지 않으면 "primary"를 반환
    return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "secondary" : "primary";
  }
}
  • AbstractRoutingDataSource 상속:
    • RoutingDataSource는 Spring에서 제공하는 AbstractRoutingDataSource를 상속받아 구현됩니다.
    • 이 클래스는 동적 데이터 소스 라우팅 기능을 제공하며, 실행 시점에 사용할 데이터 소스 키를 결정합니다.
  • determineCurrentLookupKey() 메서드:
    • 현재 트랜잭션의 읽기 전용 상태를 TransactionSynchronizationManager.isCurrentTransactionReadOnly()로 확인합니다.
    • 만약 읽기 전용이라면 "secondary"를 반환하여 읽기 작업은 Secondary 데이터 소스를 사용하도록 합니다.
    • 그렇지 않은 경우(쓰기 작업 포함)에는 "primary"를 반환하여 Primary 데이터 소스를 사용하도록 합니다.

전체 동작 흐름

  1. 데이터 소스 설정:
    • YAML 파일에서 Primary와 Secondary 데이터 소스의 연결 정보가 설정되고, 이 정보가 각각의 데이터 소스 빈에 바인딩됩니다.
  2. 동적 라우팅 데이터 소스:
    • routingDataSource 빈은 Primary와 Secondary 데이터 소스를 매핑하는 Custom RoutingDataSource를 생성합니다.
    • 이 빈은 기본 데이터 소스(@Primary)로 등록되어 JPA 등의 컴포넌트에 주입됩니다.
  3. 실행 시 데이터 소스 선택:
    • 실제 데이터베이스 커넥션이 필요한 시점에, RoutingDataSourcedetermineCurrentLookupKey()가 호출되어 현재 트랜잭션의 읽기 전용 여부를 판단합니다.
    • 읽기 전용이면 Secondary 데이터 소스, 그렇지 않으면 Primary 데이터 소스를 사용하여 작업을 수행합니다.

2.2 Primary-Secondary 읽기/쓰기 실습

1. Primary에서 쓰기 작업

Primary 노드를 사용하여 주문 데이터를 저장합니다. Spring의 @Transactional을 사용하여 트랜잭션 처리를 추가합니다.

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}
  • 설명:
    • @Transactional은 기본적으로 읽기와 쓰기 작업을 처리하도록 설정됩니다.
    • 쓰기 작업은 항상 Primary 노드에서 처리됩니다.

2. Secondary에서 읽기 작업

Secondary 노드를 사용하여 주문 데이터를 조회합니다. @Transactional(readOnly = true)를 사용하여 읽기 전용 트랜잭션으로 설정합니다.

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public List<Order> fetchOrders() {
        return orderRepository.findAll();
    }
}
  • 설명:
    • @Transactional(readOnly = true)를 통해 읽기 작업이 Secondary 노드에서 수행되도록 설정합니다.
    • 읽기 작업은 Primary 노드의 부하를 줄이고 성능을 최적화합니다.

2.3 개선 및 주의 사항

  1. 데이터 동기화 지연 해결 방안:
    • Primary-Secondary 간의 데이터 동기화 주기를 줄여 지연 시간을 최소화합니다.
    • 일관성이 중요한 작업은 Primary에서 직접 처리합니다.
  2. 트랜잭션 설정 주의:
    • 쓰기 작업은 반드시 Primary에서 처리되도록 설정합니다.
    • @Transactional(readOnly = true)를 활용하여 불필요한 쓰기 잠금을 방지합니다.

Primary-Secondary 구조와 Read/Write 분리를 활용하여 데이터베이스 성능을 최적화할 수 있습니다. Primary에서 쓰기 작업을 처리하고 Secondary에서 읽기 작업을 분산시켜 시스템 부하를 줄이는 동시에 확장성을 높일 수 있습니다. 성능 테스트를 통해 Primary-Secondary 구조의 장점과 데이터 동기화 지연과 같은 단점을 확인하고, 이를 보완할 전략을 설계할 필요가 있습니다.

profile
기록하고 소화해보자! 소화가 안되거나 까먹으면 다시 꺼내서 보자! 오늘의 나는 어제의 나보다 강하다!

0개의 댓글