[Springboot] Multi DataSource(readerDB, writerDB) 설정하고 라우팅하기

winluck·2024년 9월 5일
0

Springboot

목록 보기
17/18
  • 현재 사내 서버에서는 AWS AuroraDB를 활용한 ReaderDB/WriterDB를 설정하고 비즈니스 로직에서 DB에 대한 접근을 제어하고 있습니다.
  • Springboot으로 마이그레이션 도중 ReaderDB/WriterDB에 대한 접근 제어 방법을 기록하였습니다.

application.yml

spring:
  datasource:
    writer:
      hikari:
        jdbc-url: jdbc:mysql://localhost:3308/hello?useSSL=false&allowPublicKeyRetrieval=true
        username: root
        password: 1234
        driver-class-name: com.mysql.cj.jdbc.Driver

    reader:
      hikari:
        jdbc-url: jdbc:mysql://localhost:3306/hello?useSSL=false&allowPublicKeyRetrieval=true
        username: root
        password: 1234
        driver-class-name: com.mysql.cj.jdbc.Driver
  • Hikari는 Java 기반의 데이터베이스 커넥션 풀 라이브러리로, Springboot에서 성능과 가벼운 메모리라는 이점을 얻기 위해 사용하였습니다.
  • 기본적으로 writerDB, readerDB 2개의 데이터베이스를 운용한다고 가정하였습니다.

DataSourceConfig

@Configuration
public class DataSourceConfig {

    /**
     * yml 파일에서 `spring.datasource.writer.hikari` prefix 설정을 바탕으로
     * `writerDataSource` 빈을 생성합니다. 이 데이터 소스는 "writerDB" 역할을 합니다.
     */
    @ConfigurationProperties(prefix = "spring.datasource.writer.hikari")
    @Bean(name = "writerDataSource")
    public DataSource writerDataSource() {
        // DataSourceBuilder를 사용해 HikariDataSource 타입의 데이터 소스를 생성합니다.
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    /**
     * yml 파일에서 `spring.datasource.reader.hikari` prefix 설정을 바탕으로
     * `readerDataSource` 빈을 생성합니다. 이 데이터 소스는 "readerDB" 역할을 합니다.
     */
    @ConfigurationProperties(prefix = "spring.datasource.reader.hikari")
    @Bean(name = "readerDataSource")
    public DataSource readerDataSource() {
        // DataSourceBuilder를 사용해 HikariDataSource 타입의 데이터 소스를 생성합니다.
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    /**
     * `writerDataSource`와 `readerDataSource`가 생성된 이후에 이 메서드를 실행하도록 지정
     * 주어진 "writer"와 "reader" 데이터 소스를 기준으로 동적으로 데이터 라우팅
     * `ReplicationRoutingDataSource` 빈을 생성합니다.
     * 
     * @param writer 쓰기 전용 데이터 소스
     * @param reader 읽기 전용 데이터 소스
     * @return 동적으로 읽기/쓰기 데이터 소스를 라우팅하는 데이터 소스
     */
    @DependsOn({"writerDataSource", "readerDataSource"})
    @Bean
    public DataSource routingDataSource(
            @Qualifier("writerDataSource") DataSource writer,
            @Qualifier("readerDataSource") DataSource reader) {
            
        // `ReplicationRoutingDataSource`는 읽기/쓰기 작업에 따라 데이터 소스를 동적으로 라우팅
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();

        dataSourceMap.put("writer", writer);  // 쓰기 전용 데이터 소스
        dataSourceMap.put("reader", reader);  // 읽기 전용 데이터 소스

        routingDataSource.setTargetDataSources(dataSourceMap);
        // 기본 데이터 소스는 "writer"
        routingDataSource.setDefaultTargetDataSource(writer);
        return routingDataSource;
    }

    /**
     * `routingDataSource`가 생성된 이후에 이 메서드를 실행하도록 지정
     * `LazyConnectionDataSourceProxy`는 실제 연결을 사용하는 시점에 데이터베이스 연결을 지연 생성
     * 성능 최적화를 도모하고 트랜잭션 관리에서 데이터 소스의 사용을 효율적으로 처리 가능
     * 
     * @param routingDataSource 라우팅 가능한 데이터 소스
     * @return 지연된 연결 처리를 지원하는 데이터 소스 프록시
     */
    @DependsOn("routingDataSource")
    @Primary  // 이 빈을 기본 `DataSource`로 설정
    @Bean
    public DataSource dataSource(DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

1. @Transactional 기반 접근 제어

  • @Transactional 메서드 내부 생성/수정/삭제는 WriterDB로 라우팅되어 실행됩니다.
  • @Transactional(readOnly = true) 메서드 내부 조회는 ReaderDB로 라우팅되어 실행됩니다.
  • 장점: 핵심적인 논리적 단위인 트랜잭션 기반 간단한 라우팅 실현 가능
  • 단점: Express.js에 일부 존재하는 “readerDB에 대한 write 연산” 등의 세밀한 제어 불가능

ReplicationRoutingDataSource

public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    // 현재 트랜잭션이 읽기 전용인 경우 Reader DB, 그렇지 않으면 Writer DB로 라우팅
    @Override
    protected Object determineCurrentLookupKey() {
        return isCurrentTransactionReadOnly() ? "reader" : "writer";
    }
}

MemberService

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public Long save(Member member) {
        return memberRepository.save(member).getId();
    }

    @Transactional(readOnly = true)
    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 회원이 없습니다. id=" + id));
    }
}

MemberServiceTest

@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @DisplayName("메서드의 @Transactional 옵션에 따라 DB 라우팅이 다르게 이루어져야 한다.")
    @Test
    void dbReplicationTest() {
        // @Transactional -> writerDB로 라우팅하여 데이터 생성
        Member member = Member.of("name", "email");
        Long id = memberService.save(member);

        // @Transactional(readOnly = true) -> readerDB로 라우팅하여 데이터 조회
        assertThatThrownBy(() -> {
            memberService.findById(id);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}
  • save()는 writerDB에서 이루어지지만, findById는 (readOnly=true) 옵션으로 인해 readerDB에서 수행될 것이기에 Exception이 발생해야 합니다.
  • 물론 실제 AuroraDB에선 동기화가 이루어질 것이기에 이러한 일은 발생하지 않습니다. 이 테스트는 라우팅 자체가 정상적으로 이루어지는지 확인할 목적입니다.

  • 실제 테스트 결과 Exception이 발생했기에 트랜잭션 옵션에 따라 올바르게 라우팅되는 것이 검증되었습니다.

2. AOP 기반 접근 제어

  • 1번에서 일부 보완점을 추가하여, Spring AOP를 바탕으로 특정 어노테이션이 붙은 메서드는 DB 라우팅 대상을 readerDB로 변경하는 전략입니다.
  • 이를 통해 readerDB에도 write 연산이 가능해집니다.
  • 장점: AOP 기반으로 필요 시 메서드 단위로 라우팅 대상을 변경할 수 있기에 더욱 유연한 제어가 가능
  • 단점: DB 엔드포인트가 늘어나면 관리가 어려워지며, AOP 도입으로 인해 난해한 코드 증가

DataSourceContextHolder

@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class DataSourceContextHolder {

    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

    public static void setDataSourceKey(String key) {
        log.info("Switch DataSource to {}", key);
        CONTEXT.set(key);
    }

    public static String getDataSourceKey() {
        return CONTEXT.get();
    }

    public static void clearDataSourceKey() {
        CONTEXT.remove();
    }
}

WriteToReaderDB

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WriteToReaderDB {
}

DataSourceAspect

@Aspect
@Component
public class DataSourceAspect {

    // @WriteToReaderDB 애노테이션이 붙은 메서드가 실행되기 전에 reader 데이터 소스를 사용하도록 설정
    @Before("@annotation(com.example.demo.config.aop.WriteToReaderDB)")
    public void setReaderDataSource() {
        DataSourceContextHolder.setDataSourceKey("reader");
    }

    // @WriteToReaderDB 애노테이션이 붙은 메서드가 실행된 후에 데이터 소스를 초기화
    @After("@annotation(com.example.demo.config.aop.WriteToReaderDB)")
    public void clearDataSource() {
        DataSourceContextHolder.clearDataSourceKey();
    }
}

위 코드에선 치명적인 문제점이 하나 있습니다.
이는 메서드 실행 도중 예외가 발생할 때 After가 실행되지 않아 초기화 작업이 누락될 수 있습니다.
아래와 같은 형태로 바꿀 수 있습니다.

@Aspect
@Component
public class DataSourceAspect {

    @Around("@annotation(com.example.demo.config.aop.WriteToReaderDB)")
    public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            DataSourceContextHolder.setDataSourceKey("reader");
            return joinPoint.proceed(); // 메서드 실행
        } catch (Exception ex) {
            throw ex;
        } finally {
            DataSourceContextHolder.clearDataSourceKey(); // 예외 및 정상 종료 후 데이터 소스 키 제거
        }
    }
}

ReplicationRoutingDataSource

public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        // 컨텍스트에 데이터 소스 키가 설정되어 있으면 그 값을 사용
        String dataSourceKey = DataSourceContextHolder.getDataSourceKey();
        if (dataSourceKey != null) {
            return dataSourceKey;
        }

        // 그렇지 않으면 트랜잭션 읽기 전용 여부에 따라 기본적으로 라우팅
        return isCurrentTransactionReadOnly() ? "reader" : "writer";
    }
}

MemberService

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public Long save(Member member) {
        return memberRepository.save(member).getId();
    }

    @Transactional(readOnly = true)
    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 회원이 없습니다. id=" + id));
    }

    @WriteToReaderDB
    @Transactional
    public Long saveToReaderDB(Member member) {
        return memberRepository.save(member).getId();
    }
}

MemberServiceTest

@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @DisplayName("메서드의 @Transactional 옵션에 따라 DB 라우팅이 다르게 이루어져야 한다.")
    @Test
    void dbReplicationTest() {
        // @Transactional -> writerDB로 라우팅하여 데이터 생성
        Member member = Member.of("name", "email");
        Long id = memberService.save(member);

        // @Transactional(readOnly = true) -> readerDB로 라우팅하여 데이터 조회
        assertThatThrownBy(() -> {
            memberService.findById(id);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("메서드에 @WriteToReaderDB 어노테이션이 붙으면 readerDB로 라우팅되어야 한다.")
    @Test
    void writeToReaderDbTest() {
        // @WriteToReaderDB -> readerDB로 라우팅하여 데이터 생성
        Member member = Member.of("name", "email");
        Long id = memberService.saveToReaderDB(member);

        // @WriteToReaderDB -> readerDB로 라우팅하여 데이터 조회
        Member foundMember = memberService.findById(id);

        assertThat(foundMember.getId()).isEqualTo(id);
        assertThat(foundMember.getName()).isEqualTo("name");
        assertThat(foundMember.getEmail()).isEqualTo("email");
    }
}
  • @WriteToReaderDB가 붙은 saveToReaderDB() 메서드를 실행하면 AOP 설정으로 인해 메서드 실행 전 DataSourceContextHolder의 setDataSourceKey가 호출될 것입니다.
  • 이후 readerDB에 write 연산이 실행되어야 합니다.

  • 로그를 통해 AOP로 해당 메서드 실행 시 Context가 reader로 변경되었음을 알 수 있습니다.
  • 또한 1번과 달리 Exception이 발생하지 않았기에 생성/조회 연산이 readerDB에서 모두 실행된 것을 알 수 있습니다.

Reference

spring에서 data source routing을 통한 db read/write 요청 분산하기

Springboot Application과 RDS, Aurora DB

profile
Discover Tomorrow

0개의 댓글