- 현재 사내 서버에서는 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 {
@ConfigurationProperties(prefix = "spring.datasource.writer.hikari")
@Bean(name = "writerDataSource")
public DataSource writerDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@ConfigurationProperties(prefix = "spring.datasource.reader.hikari")
@Bean(name = "readerDataSource")
public DataSource readerDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@DependsOn({"writerDataSource", "readerDataSource"})
@Bean
public DataSource routingDataSource(
@Qualifier("writerDataSource") DataSource writer,
@Qualifier("readerDataSource") DataSource reader) {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("writer", writer);
dataSourceMap.put("reader", reader);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(writer);
return routingDataSource;
}
@DependsOn("routingDataSource")
@Primary
@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 {
@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() {
Member member = Member.of("name", "email");
Long id = memberService.save(member);
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 {
@Before("@annotation(com.example.demo.config.aop.WriteToReaderDB)")
public void setReaderDataSource() {
DataSourceContextHolder.setDataSourceKey("reader");
}
@After("@annotation(com.example.demo.config.aop.WriteToReaderDB)")
public void clearDataSource() {
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() {
Member member = Member.of("name", "email");
Long id = memberService.save(member);
assertThatThrownBy(() -> {
memberService.findById(id);
}).isInstanceOf(IllegalArgumentException.class);
}
@DisplayName("메서드에 @WriteToReaderDB 어노테이션이 붙으면 readerDB로 라우팅되어야 한다.")
@Test
void writeToReaderDbTest() {
Member member = Member.of("name", "email");
Long id = memberService.saveToReaderDB(member);
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