728x90
반응형
Read After Write Consistency(쓰기 직후 읽기 일관성)
- 1개의 DB에 부하가 몰리는 것을 방지하기 위해서 RW(Read and Write), RO(Read Only) DB를 나눠놓는다.
- 조회만 하는 API는 RO에서 처리하도록, create, update, delete가 일어나는 API는 RW에서 처리하도록 한다.
- 그리고 RW -> RO로 자동적으로 Sync가 되도록 한다. 이 때, sync에는 시간이 걸리기 때문에 클라이언트 측에서 write 직후 read를 하면 ro DB에서 생성된 데이터를 찾을 수 없어서 에러가 난다.
• ⁃ 이를 방지하기 위해서 Read/Write Pinning | Session Pinning | Master Stickness 등의 용어로 부르는, RW 디비에 일정 시간 동안 달라붙는 처리를 한다.
일단, RW, RO 디비가 있다고 가정했을 때 DataSource 설정은 아래처럼 해줄 수 있다.
@Slf4j
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
@EnableJpaRepositories(basePackages = "com.torder.ad.repository")
@Profile("!dev") // dev 프로파일이 아닐 때만 활성화
public class DataSourceConfig {
private final Environment environment;
public DataSourceConfig(Environment environment) {
this.environment = environment;
}
@Bean
public DataSourceProperties rwDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("spring.datasource.ro")
public DataSourceProperties roDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource rwDataSource() {
var ds = rwDataSourceProperties().initializeDataSourceBuilder()
.type(com.zaxxer.hikari.HikariDataSource.class).build();
ds.setPoolName("RW");
return ProxyDataSourceBuilder.create(ds)
.name("RW-DataSource") // <= 로그에 찍힐 이름
.logQueryBySlf4j(SLF4JLogLevel.INFO, "sql.rw") // <= "sql.rw" 로거로 출력
.multiline()
.countQuery() // 선택: 카운트/실행시간 통계
.build();
}
@Bean
public DataSource roDataSource() {
var ds = roDataSourceProperties().initializeDataSourceBuilder()
.type(com.zaxxer.hikari.HikariDataSource.class).build();
ds.setPoolName("RO");
return ProxyDataSourceBuilder.create(ds)
.name("RO-DataSource")
.logQueryBySlf4j(SLF4JLogLevel.INFO, "sql.ro")
.multiline()
.countQuery()
.build();
}
@Bean
@Primary
public DataSource routingDataSource(
@Qualifier("rwDataSource") DataSource rw,
@Qualifier("roDataSource") DataSource ro) {
Map<Object, Object> targets = Map.of("RW", rw, "RO", ro);
AbstractRoutingDataSource ds = new AbstractRoutingDataSource() {
@Override protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
boolean hasTransaction = TransactionSynchronizationManager.isActualTransactionActive();
boolean isSynchronizationActive = TransactionSynchronizationManager.isSynchronizationActive();
String transactionName = TransactionSynchronizationManager.getCurrentTransactionName();
String dataSourceKey;
String reason;
// 트랜잭션이 없거나 동기화가 비활성화된 경우 RO 사용
if (!hasTransaction || !isSynchronizationActive) {
dataSourceKey = "RO";
reason = hasTransaction ? "Transaction exists but no synchronization" : "No active transaction";
} else {
// 트랜잭션이 활성화되어 있고 동기화가 활성화된 경우
// readOnly 플래그에 따라 결정하되, UseCase에서 시작된 트랜잭션은 강제로 RW 사용
if (isReadOnly && (transactionName == null || !transactionName.contains("SlotManagement"))) {
dataSourceKey = "RO";
reason = "Transaction is readOnly=true";
} else {
dataSourceKey = "RW";
reason = isReadOnly ? "ReadOnly=true but UseCase transaction (forced RW)" : "Transaction is readOnly=false";
// RW 트랜잭션에서는 모든 후속 연결도 RW를 사용하도록 보장
if (isSynchronizationActive) {
log.debug("RW transaction confirmed - name: '{}', thread: {}",
transactionName, Thread.currentThread().getName());
}
}
}
// 상세 로깅 (DEBUG 레벨에서만)
if (log.isDebugEnabled()) {
log.debug("=========== DataSource Routing Decision ===========");
log.debug("DataSource Selected: {} (Reason: {})", dataSourceKey, reason);
log.debug("Transaction Status: hasTransaction={}, isReadOnly={}, synchronization={}, name='{}'",
hasTransaction, isReadOnly, isSynchronizationActive, transactionName);
log.debug("Thread: {}", Thread.currentThread().getName());
log.debug("===============================================");
} else {
// INFO 레벨에서는 간단한 로그만
log.info("DataSource Routing: {} (Transaction: {}/{})",
dataSourceKey, hasTransaction ? "Active" : "None", isReadOnly ? "RO" : "RW");
}
return dataSourceKey;
}
};
ds.setTargetDataSources(targets);
ds.setDefaultTargetDataSource(rw);
ds.afterPropertiesSet();
log.info("RoutingDataSource configured with targets: {}, defaultTarget: {}", targets.keySet(), rw.getClass().getSimpleName());
// LazyConnectionDataSourceProxy로 감싸서 Connection을 실제 사용할 때까지 지연
LazyConnectionDataSourceProxy lazyProxy = new LazyConnectionDataSourceProxy(ds);
// 최종적으로 ProxyDataSourceBuilder로 감싸서 로깅
return ProxyDataSourceBuilder.create(lazyProxy)
.name("RoutingDataSource")
.logQueryBySlf4j(SLF4JLogLevel.INFO, "sql.routing")
.multiline()
.countQuery()
.build();
}
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
@Qualifier("routingDataSource") DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.torder.ad");
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
em.setJpaVendorAdapter(vendorAdapter);
Properties properties = new Properties();
properties.setProperty("hibernate.dialect", "org.hibernate.dialect.MySQLDialect");
properties.setProperty("hibernate.hbm2ddl.auto", environment.getProperty("spring.jpa.hibernate.ddl-auto", "validate"));
properties.setProperty("hibernate.show_sql", environment.getProperty("spring.jpa.show-sql", "false"));
properties.setProperty("hibernate.format_sql", environment.getProperty("spring.jpa.properties.hibernate.format_sql", "false"));
// 네이밍 전략 설정 - camelCase를 snake_case로 변환
properties.setProperty("hibernate.physical_naming_strategy",
environment.getProperty("spring.jpa.hibernate.naming.physical-strategy",
"org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"));
properties.setProperty("hibernate.implicit_naming_strategy",
environment.getProperty("spring.jpa.hibernate.naming.implicit-strategy",
"org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy"));
em.setJpaProperties(properties);
return em;
}
@Bean
@Primary
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean("jdbcTemplate")
@Primary
public JdbcTemplate jdbcTemplate(@Qualifier("routingDataSource") DataSource dataSource) {
JdbcTemplate template = new JdbcTemplate(dataSource);
log.info("JdbcTemplate created with DataSource: {} - {}", dataSource.getClass().getSimpleName(), dataSource.toString());
return template;
}
}
그리고 RW, RO의 Consistency 유지를 위해 다음 두 가지 전략을 사용할 수 있다.
- 무조건 RW로 처리하도록 하고, 특별히 조회 수나 양이 엄청 많은 경우에는 RO로 가도록 Controller단에서부터 개별로 짠다. 클라이언트도 해당 API로 요청하도록 한다.
- Pinning 기법을 사용한다. 서버 인스턴스가 여러 대인 경우 일관성 유지를 위해 쿠키에 pinning 시간 정보를 심어줄 수 있다. API를 통해 서버에 요청이 오면, 서버에서는 RW DB만 바라보겠다는 TTL 정보를 쿠키에 심는다.
클라이언트는 set-cookie를 통해 받은 정보를 다음 요청에 그대로 전달한다. 그럼 서버는 쿠키를 파싱하여, 현재 시각대비 TTL 이전이라면 조회만 하는 요청일지라도 계속해서 RW 디비에 요청을 한다.
이렇게 하면 RW와 관련된 요청이 한번 일어나고 나서 TTL 만료 전까지는 계속해서 RW 디비에 요청하게 되므로 일관성을 유지할 수 있다. TTL이 너무 짧으면 RW -> RO 동기화 시간 전에 pinning이 만료되어 에러가 날 수 있고,
TTL이 너무 길면 무조건 RW만 호출하여 RW에 부하가 많이 걸릴 수 있다.
728x90
반응형
'Programming-[Backend] > Database' 카테고리의 다른 글
| 커넥션 풀, keep alive 및 idle connection timeout (0) | 2025.10.16 |
|---|---|
| JPA, ddl-auto, MYSQL, index 등 테이블 생성 기초 정리 (0) | 2025.01.12 |
| id 설정하기. batchInsert, UUID, GenerationType.IDENTITY (0) | 2024.12.15 |
| [TIL] TSID, ULID 를 사용해야하는가? 왜 Id값은 Long(bigint)로 하는가? ULID 사용 시 유의사항 (1) | 2024.12.07 |
| [TIL] MySQL 사용 관련 주요 팁 모음: 타입, INET_ATON, FK 물리적으로 걸지 않기 (0) | 2024.08.26 |