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.DriverHikari는 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");
    }
}
