[스프링 Cloud] Spring Cloud AWS + RDS Read Replica 연동

웹 개발/스프링 프레임워크

2022. 01. 13.

적용 코드

binchoo-spring-study/aws/rds at master · binchoo/binchoo-spring-study

개요

부하 분산 아키텍처는 단일 엔드포인트를 사용자에게 노출하고, 뒷 단의 트래픽 라우팅은 투명하게 감추는 게 이상적입니다.

아마존 관계형 DB 서비스 RDS는, 비용을 20% 가량 더 지불해 Aurora라는 래퍼를 사용하여 해당 부문을 향유할 수 있습니다.

하지만 일반적인 RDS(MySQL, PostgreSQL ...)를 사용할 경우 부하 분산 책임은 AWS가 아닌 응용 계층에 지워집니다.

이 글은 단일 DB 엔드포인트를 사용할 수 없어 응용에서 직접 엔드포인트를 분기 선택할 경우,

Spring Cloud AWS JDBC 모듈을 사례로 들어 RDS의 Read Replica와 연동하는 부분을 살펴봅니다.

RDS 요약

AWS의 관계형 DB 솔루션: Relational Database Service

  • Aurora, MySQL, PostgreSQL, MSSQL, Oracle, MariaDB 지원
  • 최대 5개의 Read Replica 인스턴스를 두어 비동기 복제 가능
  • Same AZ, Cross AZ, Cross Region 배치가 가능함
  • 프리티어 인스턴스 있음: db.t2.small

RDS Read Replica 요약

Master 인스턴스와 비동기 복제 메커니즘이 수립된 인스턴스

https://user-images.githubusercontent.com/15683098/149274041-4d19d74a-ac6c-4f2c-b140-bada39187c6d.png

  • Read Replica를 사용해 읽기 DB 부하를 분산할 수 있다.
  • 읽기 트래픽 분산의 책임은 응용에게 있다.
    • AWS는 Read Replica를 통합하는 단일 읽기 엔드포인트를 제공하지 않는다.
    • 따라서 앱이 Read Replica의 엔드포인트들을 직접 아는 상태에서, 읽기 쿼리를 적절히 분산 전송 해야 함.

응용의 책임

좀 더 자세하게 응용 계층에게 필요한 역할을 묘사하면 이렇습니다.

① Read Replica들의 엔드포인트를 알아야 합니다.

② 현재 DB로 수행하는 작업이 읽기인지 쓰기인지 판단해야 합니다.

③ 읽기 작업시 Read Replica를 적절하게 순환 선택하며 Connection을 수립해야 합니다.

Spring Cloud AWS JDBC

기본 셋업
해당 모듈을 사용하여 개발자가 하는 세팅은 단순합니다. AWS 유저 크레덴셜, DB가 있는 Region 정보, DB 식별자 및 DB 접근용 username, password 메타 정보들이 필요합니다.

<beans xmlns="<http://www.springframework.org/schema/beans>"
       xmlns:jdbc="<http://www.springframework.org/schema/cloud/aws/jdbc>"
       xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"
       xmlns:aws-context="<http://www.springframework.org/schema/cloud/aws/context>"
       xsi:schemaLocation="<http://www.springframework.org/schema/beans>
        <https://www.springframework.org/schema/beans/spring-beans.xsd>
        <http://www.springframework.org/schema/cloud/aws/context>
        <http://www.springframework.org/schema/cloud/aws/context/spring-cloud-aws-context.xsd>
        <http://www.springframework.org/schema/cloud/aws/jdbc>
        <http://www.springframework.org/schema/cloud/aws/jdbc/spring-cloud-aws-jdbc.xsd>">

    <aws-context:context-credentials>
        <aws-context:simple-credentials
                access-key="${aws.user.access-key}" secret-key="${aws.user.secret-key}" />
    </aws-context:context-credentials>

    <aws-context:context-region region="${aws.region}"/>

    <jdbc:data-source
            db-instance-identifier="${rds.instance-id}" username = "${rds.username}" password="${rds.password}"
            read-replica-support="true"/>
</beans>

[spring-cloud-aws-jdbc] 모듈은 Read Replica를 분기 선택하는 과정을 투명하게 수행해 줍니다.

  • 보았듯이 개발자는 아마존 RDS로 연결되는 DataSource 빈을 손쉽게 획득합니다.
  • 빈 생성시 readReplicaSupport 옵션을 줄 경우 ReadOnlyRoutingDataSource를 얻습니다.
    • 이 빈은 Master와 Read Replica 노드로 향하는 각각의 DataSource들을 감싸고 있습니다.
  • 읽기 부하는 Connection 수준에서 분산됩니다. 쿼리 수준이 아닙니다.
    • 트랜잭션의 읽기/쓰기 여부에 따라 올바른 DataSource을 택하여 Connection을 획득합니다.
    • 읽기 트랜잭션일 경우 Read Replica 용 DataSource 중 하나를 랜덤하게 선택합니다.

How to resolve

읽기 부하를 분산하기 위해 필요한 요소들을 모듈이 어떻게 획득하는지 살펴봅니다.

1. RDS & Read Replica의 엔드포인트

→ AWS SDK, AWS 유저 크레덴셜, AWS Region, DB 인스턴스 식별자

부하 분산 시스템은 가용한 자원을 훤히 들여다 볼 수 있어야 합니다.
RDS는 AWS 서비스이며, AWS 사용자에게 할당된 자원임에 착안하면, AWS SDK를 쓰지 않을 이유가 없습니다.

모듈은 AWS SDK + 유저 크레덴셜 + DB 식별자로 연결하려는 Master와 Read Replica의 DBInstance 객체를 얻습니다.
참고로 AWS 서비스는 특정 Region에 국한되어 제공되므로 Region 정보도 필요합니다.

2. Data Sources

이제 DBInstance를 다 알기 때문에 DB의 메타정보를 획득할 수 있습니다.
여기에 DB 접근용 username/password만 있으면 DataSource를 생성 가능합니다.

모듈은 읽기 부하를 밸런싱해주는 ReadOnlyRoutingDataSource를 만듭니다. 다시 이 빈의 내부에는 Master 및 Read Replica에 대한 DataSource들이 저장됩니다.

 

AmazonRdsReadReplicaAwareDataSourceFactoryBean.createInstance()

public class AmazonRdsReadReplicaAwareDataSourceFactoryBean extends AmazonRdsDataSourceFactoryBean {

  public AmazonRdsReadReplicaAwareDataSourceFactoryBean(AmazonRDS amazonRDS, String dbInstanceIdentifier,
      String password) {
  super(amazonRDS, dbInstanceIdentifier, password);
  }

  @Override
  protected DataSource createInstance() throws Exception {
  DBInstance dbInstance = getDbInstance(getDbInstanceIdentifier());

  // If there is no read replica available, delegate to super class
  if (dbInstance.getReadReplicaDBInstanceIdentifiers().isEmpty()) {
      return super.createInstance();
  }

  HashMap<Object, Object> replicaMap = new HashMap<>(dbInstance.getReadReplicaDBInstanceIdentifiers().size());

  for (String replicaName : dbInstance.getReadReplicaDBInstanceIdentifiers()) {
      replicaMap.put(replicaName, createDataSourceInstance(replicaName));
  }

  // Create the data source
  ReadOnlyRoutingDataSource dataSource = new ReadOnlyRoutingDataSource();
  dataSource.setTargetDataSources(replicaMap);
  dataSource.setDefaultTargetDataSource(createDataSourceInstance(getDbInstanceIdentifier()));

  // Initialize the class
  dataSource.afterPropertiesSet();

  return new LazyConnectionDataSourceProxy(dataSource);
  }
  ...

3. Read Only Transaction 여부

→ TransactionSynchronizationManager 사용

모듈은 읽기 부하 분산을 Connection을 수준에서 처리합니다.

  • ReadOnlyRoutingDataSource가 Connection을 열 때는 현재 트랜잭션의 readOnly 여부를 살펴봅니다.
  • readOnly가 맞다면 Read Replica로 향하는 DataSource 중 하나를 랜덤 선택하고 Connection을 만듭니다.

ReadOnlyRoutingDataSource.determineCurrentLookupKey()

public class ReadOnlyRoutingDataSource extends AbstractRoutingDataSource {
  ...
  @Override
  protected Object determineCurrentLookupKey() {
  if (TransactionSynchronizationManager.isCurrentTransactionReadOnly() && !this.dataSourceKeys.isEmpty()) {
      return this.dataSourceKeys.get(getRandom(this.dataSourceKeys.size()));
  }

  return null;
  }
  ...
}

TransactionSynchronizationManager.isCurrentTransactionReadOnly() 메서드

현재 트랜잭션의 readOnly 여부를 판단할 수 있다.
단, 이미 수립된 트랜잭션에 대한 정보를 요청하는 것이라 트랜잭션 생성중에는 이용할 수 없다.

구현 애로사항: Connection 생성 시점

사실 위 시나리오에서 트랜잭션의 readOnly를 얻는 데에 애로사항이 있습니다.

Connection이 만들어지는 기본 시점 때문입니다.

 

JpaTransactionManager.doBegin()

https://user-images.githubusercontent.com/15683098/149271388-75d572a4-f041-40ec-8731-06b0f9e96311.png

JpaTransactionManager에서 Connection 생성 시점

트랜잭션 매니저는 트랜잭션을 생성하는 중에 DataSource에게 Connection을 요청합니다.
이 시점에 DataSource는 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 를 사용할 수 없습니다!

그러므로 ReadOnlyRoutingDataSource가 트랜잭션의 readOnly 여부를 알아내려면 트랜잭션 진입 이후에 커넥션 생성을 부탁받아야 합니다.

 

여기서 LazyConnectionDataSource를 이용할 수 있습니다.

 

public class AmazonRdsReadReplicaAwareDataSourceFactoryBean extends AmazonRdsDataSourceFactoryBean {
  ...
  @Override
  protected DataSource createInstance() throws Exception {
  DBInstance dbInstance = getDbInstance(getDbInstanceIdentifier());
  ...
  // Create the data source
  ReadOnlyRoutingDataSource dataSource = new ReadOnlyRoutingDataSource();
  dataSource.setTargetDataSources(replicaMap);
  dataSource.setDefaultTargetDataSource(createDataSourceInstance(getDbInstanceIdentifier()));

  // Initialize the class
  dataSource.afterPropertiesSet();

  return new LazyConnectionDataSourceProxy(dataSource); // 이 부분
  }
  ...

LazyConnectionDataSource로 ReadOnlyRoutingDataSource를 감싸자!
논리는 DataSource를 감싸 가짜 커넥션을 반환시키도록 만들자는 겁니다.
실제 Connection이 필요할 때는 감싸놓은 ReadOnlyRoutingDataSource의 .getConnection()을 호출합니다.
그리고 이 시점엔 트랜잭션의 readOnly를 판단이 가능합니다.

 

LazyConnectionDataSourceProxy.getConnection() 에서 프록시를 리턴하는 모습

 

https://user-images.githubusercontent.com/15683098/149275298-b0f93b50-b2d1-4c01-a0c2-0b6d79162432.png

 

이런 메커니즘이 적용된 ReadOnlyRoutingDataSource는 Read Replica DataSource 중 하나를 택해서 Connection을 열고 부하를 분산하게 됩니다.

의존성 방향

JpaTransactionManager → LazyTransactionManager → ReadOnlyRoutingDataSource → one of Read Replica DataSources

참고자료

spring-cloud-aws-jdbc (스프링독)

spring-cloud-aws (깃허브)

LazyConnectionDataSourceProxy란? (블로그)