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에서 성능과 가벼운 메모리라는 이점을 얻기 위해 사용하였습니다.@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);
}
}
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
// 현재 트랜잭션이 읽기 전용인 경우 Reader DB, 그렇지 않으면 Writer DB로 라우팅
@Override
protected Object determineCurrentLookupKey() {
return isCurrentTransactionReadOnly() ? "reader" : "writer";
}
}
@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));
}
}
@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);
}
}
@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();
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WriteToReaderDB {
}
@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(); // 예외 및 정상 종료 후 데이터 소스 키 제거
}
}
}
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 컨텍스트에 데이터 소스 키가 설정되어 있으면 그 값을 사용
String dataSourceKey = DataSourceContextHolder.getDataSourceKey();
if (dataSourceKey != null) {
return dataSourceKey;
}
// 그렇지 않으면 트랜잭션 읽기 전용 여부에 따라 기본적으로 라우팅
return isCurrentTransactionReadOnly() ? "reader" : "writer";
}
}
@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();
}
}
@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");
}
}