대규모 서비스 개발시에 가장 기본적으로 하는 튜닝은 바로 데이터베이스에서 Write와 Read DB를 Replication(리플리케이션)하고 쓰기 작업은 Master(Write)로 보내고 읽기 작업은 Slave(Read)로 보내어 부하를 분산 시키는 것이다.
특히 대부분의 서비스는 읽기가 압도적으로 많기 때문에 Slave는 여러 대를 두어 읽기 부하를 분산 시킨다.
그런데 또 하나 기억해야 할 것이 Replication은 비록 짧더라도 시차를 두고 이루어 지는 것이다.
따라서 정합성이 굉장히 중요한 데이터는 비록 읽기 작업이라 하더라도 Slave에서 읽지 않고 Master에서 읽어야만 하는 경우도 있다.
그렇다면 Java 애플리케이션은 어떻게 Master/Slave로 가는 쿼리를 분기 처리해야 하는 것일까?
가장 쉽게 생각나는 방법은 커넥션 풀(Connection Pool, DataSource)을 master와 slave용으로 따로 만들고 쿼리를 만들 때 비록 같은 쿼리라도 서로 다른 DS를 바라보는 두 벌의 쿼리를 만들어주는 방식이다.
즉, 프로그래머가 계속해서 두 개의 데이터 소스를 인지해가며 코드를 작성하는 방식이다.
물론 이 방법을 추천하고 싶어서 글을 쓰는 것은 아니다. 이 방법은 버리자.
내가 아는 바 저렇게 하지 않고 자연스럽게 Master/Slave 분기처리를 하는 4 가지 정도의 방법이 있는데, 그 중에 실제로 소개하고 싶은 것은 3, 4 번째이다. 시간 없으면 3, 4번을 읽기를 바란다.
1. DB Proxy 서버를 이용한다.
MySQL Proxy이나 MaxScale 같은 프록시 서버를 사용하는 방법이 있다(MySql 외에도 다른 데이터베이스도 Proxy 서버가 있다). 이런 프록시 서버들은 쿼리를 분석하여 select는 slave로 그 외의 업데이트는 master로 자동으로 보내준다. 문제는 select 더라도 master로 보낼 때 인데, 프록시 서버 자체에 스크립트 언어로 분기 처리를 해주는 것이 있다.
보통은 PHP 같은 동적 언어 계통에서 많이 사용하는 방법 같다.
일단 나는 프록시를 이런 분기 처리 용도로 사용해 본 적이 없어서 뭐라 말하기 힘들고, 분기 처리를 애플리케이션 단에서 조정하지 않고 관련 로직이 애플리케이션과 Proxy 서버로 분산 되는 문제가 있을 것으로 보여서 배재한다.
3, 4 번은 순수 Java 코드로 분기 처리하는 방법을 소개하는 것이다.
◆ 2, 3, 4를 가기 전에 알아보는 Java의 특징
Java의 JDBC 커넥션 객체에는 Connection.setReadOnly(true|false) 라는 메소드가 존재한다.
즉, Java 의 JDBC 에는 Read/Write를 분기할 수 있는 단초가 이미 들어있는 것이다.
현재 Java의 주류 프레임워크인 Spring Framework을 사용하여 트랜잭션을 관리하면@Transactional(readOnly=true|false)
를 통해 현재 트랜잭션의 readOnly 상태를 설정할 수 있으며, 이 Spring의 트랜잭션 설정은 연쇄적으로 커넥션 객체의 setReadOnly
메소드를 호출하기도 한다.
바로 이 점이 Java 애플리케이션에서 외부 Proxy 서버에 의존하지 않고 애플리케이션 코드를 통해 Master/Slave 분기 처리를 할 수 있는 단초가 된다.
앞으로보게 될 2, 3, 4번 해결책은 Spring 사용시 다음과 같은 방식만으로 Master/Slave 분기를 할 수 있게 해준다.
@Transactional(readOnly = true)
public User findByIdRead(Integer id) {
return userRepository.findById(id);
}@Transactional(readOnly = false)
public User findByIdWrite(Integer id) {
return userRepository.findById(id);
}
위에서 볼 때 두 메소드는 동일한 repository의 메소드를 호출하지만 서로 다른 DB를 자연스럽게 보게 되는 것이다.
위 코드는 다소 인위적인 것이다. 서비스는 보통 여러 개의 리포지토리 메소드를 한 트랜잭션으로 묶기 때문이다.
아래에서 소개할 2, 3, 4 번은 프로그래머 입장에서 봤을 때 데이터소스는 신경쓰지 않고 오로지 Transaction의 속성 만을 신경 써가며 작성하는 방법이다. 즉, 프로그래머는 실제 설정은 두 개로 되어 있더라도 데이터소스가 한 개뿐이라고 생각하고 코드를 짜면 된다. 또한 Hibernate나 MyBatis 등의 영속 계층 프레임워크도 데이터소스가 2개라는 사실은 전혀 인지하지 않고 하나의 데이터소스로 간주하고 설정을 하게 된다.
Connection의 readOnly는 기본 설정값이 false이다.
2. MySQL Replication JDBC Driver 사용하기
MySQL에는 Replication JDBC 드라이버가 존재한다. 이를 이용하면 Connection.setReadOnly(true|false)
호출만으로 Master/Slave 분기 처리가 된다.
실전 환경에서 사용해보았는데, 몇 가지 문제가 발생했다. 하지만 모두 해결 가능했으며, 크게 치명적인 문제는 아니었다.
그래도 가능하면 3, 4번 해결책을 사용하는 것이 좋겠다.
왜냐면, 이 방식은 MySQL 외에는 적용되지 않는다. Replication JDBC 드라이버를 제공해주는 DB는 그리 많지 않다(사실 나는 MySQL 밖에 못봤다).
그리고 굳이 더 나은 방식이 있는데 안 좋은 방식을 사용할 필요는 없기 때문이다.
3. Spring LazyConnectionDataSourceProxy + AbstractRoutingDataSource
Spring에 있는 AbstractRoutingDataSource 는 여러개의 데이터소스를 하나로 묶고 자동으로 분기처리를 해주는 Spring 기본 클래스이다. 많은 사람들이 Master/Slave 분기 처리를 할 때 이것을 사용해 스프링의 현재 트랜잭션 속성을 읽어오는 TransactionSynchronizationManager와 조합하여 분기처리 하려고 시도하는데 이건 실패다(http://stackoverflow.com/에 보면 이거 왜 안되냐는 질문이 좀 있다).
ReplicationRoutingDataSource.java에 그 구현이 있는데, 너무도 간단하다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String dataSourceType =
TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? “read” : “write”;
return dataSourceType;
}
}
왜 안되냐면, TransactionSynchronizationManager 가 비록 @Transactional
로 선언된 현재 쓰레드의 트랜잭션 상태를 읽어오는게 가능하더라도 동기화(synchronation)시점과 Connection 객체를 가져오는 시점에 문제가 있기 때문이다.
Spring은 @Transactional
을 만나면 다음 순서로 일을 처리한다.
TransactionManager 선별 -> DataSource에서 Connection 획득 -> Transaction 동기화(Synchronization)
여기서 보면 트랜잭션 동기화를 마친 뒤에 ReplicationRoutingDataSource.java에서 커넥션을 획득해야만 이게 올바로 동작하는데 그 순서가 뒤바뀌어 있기 때문이다.
나는 여기까지 보고 포기하고 바로 4번 방식을 구현하였는데, 우리팀의 이은호님이 아주 심플한 아이디어로 이 문제를 해결 했다. 코드 한 줄 없이!
뭐냐면 저 ReplicationRoutingDataSource.java를 LazyConnectionDataSoruceProxy로 감싸주기만 하면 되는 것이다.
원래 LazyConnectionDataSoruceProxy는 실질적인 쿼리 실행 여부와 상관없이 트랜잭션이 걸리면 무조건 Connection 객체를 확보하는 Spring의 단점을 보완하여 트랜잭션 시작시에 Connection Proxy 객체를 리턴하고 실제로 쿼리가 발생할 때 데이터소스에서 getConnection()
을 호출하는 역할을 하는 것이다.
이걸 적용하면 작동 순서가 이렇게 된다.
TransactionManager 선별 -> LazyConnectionDataSourceProxy에서 Connection Proxy 객체 획득 -> Transaction 동기화(Synchronization) -> 실제 쿼리 호출시에ReplicationRoutingDataSource.getConnection()/determineCurrentLookupKey()
호출
이렇게 하여 깔끔하게 Spring의 트랜잭션과 어울리는 Replication Routing DataSource가 만들어지게 된다.
실제 설정은 다음과 같이 된다. 좀 더 자세한 것은 WithRoutingDataSourceConfig.java를 보자.
@Bean public DataSource writeDataSource() { return 쓰기 DataSource; }
@Bean public DataSource readDataSource() { return 읽기 DataSource; }
@Bean
public DataSource routingDataSource(DataSource writeDataSource, DataSource readDataSource) {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();Map<Object, Object> dataSourceMap = new HashMap<Object, Object>();
dataSourceMap.put(“write”, writeDataSource);
dataSourceMap.put(“read”, readDataSource);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(writeDataSource);return routingDataSource;
}@Bean
public DataSource dataSource(DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
TransactionManager
나 영속 계층 프레임워크는 dataSource
이것만 바라보게 해야한다. writeDataSource, readDatasource, routingDataSource
는 설정 속에만 존재할 뿐 영속 계층 프레임워크들에게는 그 존재를 모르게 해야한다.
4. LazyReplicationConnectionDataSourceProxy – Spring이 아니어도!
3번 방식은 단점이 하나 있는데, Spring을 사용하지 않고 프로그램을 짤 때는 사용할 수 없다는 것이다.
현재 Java 계의 산업 표준인 Spring이긴 하지만 어찌 모두다 Spring만 사용하리오.
그런데, 내가 이걸 만든 것은 Spring을 안 사용할 때 대비한 것은 아니고, 3번에 대한 아이디어가 없었기 때문이다… ^^;
Spring의 LazyConnectionDataSoruceProxy의 코드를 보면서 이 클래스는 하나의 DataSource로만 프록시를 하지만 이를 write/read 두개의 데이터소스를 받아서 프록싱 하도록 수정하였다.
전체 코드는 LazyReplicationConnectionDataSourceProxy.java에 있다.
클래스 단 한 개라서, 필요하면 그냥 소스를 복사해서 자기 프로젝트에 넣고 사용하면 된다.
실제 코드를 Spring의 것과 비교해 보면 거의 차이가 안 난다.
이 클래스는 기본적으로 Spring의 LazyConnectionDataSoruceProxy 완전히 동일하게 작동하지만setReadOnly(true|false)
로 지정된 값에 따라 두 데이터소스 중에 적합한 곳으로 분기하여 실제 커넥션을 획득하여 리턴한다.
Spring 프로젝트에서의 설정은 다음과 같은 형태가 된다.
@Bean public DataSource writeDataSource() { return 쓰기 DataSource; }
@Bean public DataSource readDataSource() { return 읽기 DataSource; }
@Bean
public DataSource dataSource(DataSource writeDataSource, DataSource readDataSource) {
return new LazyReplicationConnectionDataSourceProxy(writeDataSource, readDataSource);
}
설정이 무척 간단해졌다.
TransactionManager
나 영속 계층 프레임워크는 dataSource
이것만 바라보게 해야한다. writeDataSource, readDatasource
는 설정 속에만 존재할 뿐 영속 계층 프레임워크들에게는 그 존재를 모르게 해야한다.
앞서 말했듯이 이 코드는 전혀 Spring에 의존적이지 않으면서 Java의 표준 API를 따르고 있다.
따라서 Spring의 @Transactional
과 함께 사용해도 되고, 아니면 그냥 일반 Java 코드에서 아래처럼Connection.setReadOnly(true|false)
를 호출하여 사용하면 된다.
Connection connection = dataSource.getConnection();
connection.setReadOnly(false);
// 쓰기 DB관련 작업
connection.close();// 절대 앞서 획득한 커넥션을 재사용하지 말 것
Connection connection = dataSource.getConnection();
connection.setReadOnly(true);
// 읽기 DB관련 작업
connection.close();
◆ 결론 및 주의할 점
1. Spring을 사용한다면 3번 LazyConnectionDataSourceProxy + AbstractRoutingDataSource 방식을 권장한다.
2. Spring을 사용하지 않는다면 4번 LazyReplicationConnectionDataSourceProxy.java 소스를 복사하여 사용한다. 이는 한가지 예외를 제외하고는 Spring 과도 잘 작동한다.
3. 4번 LazyReplicationConnectionDataSourceProxy.java는 Spring 4.0.x 이하 + JPA 조합으로 사용할 경우 작동하지 않는다. 이유는 일종의 Spring JPA TransactionManager의 의도적 setReadOnly
회피 때문인데 4.1 부터는 JPA에서도 setReadOnly
를 올바르게 호출해줘서 괜찮다.
4. 절대로 한번 읽어들인 커넥션을 readOnly 설정을 바꿔서 재활용하면 안된다. 일단 실제 커넥션을 획득하면 중간에 속성을 바꿔도 다른 커넥션을 새로 맺지 않는다. Spring은 propagation이 REQUIRES_NEW
일 경우 비록 동일 DataSource에서 커넥션을 가져오더라도 새로운 커넥션을 맺기 때문에 아무 문제 없이 작동한다. 하지만 propagation이 REQUIRED
일 경우에는 새로운 트랜잭션을 생성하지도 않고 새로운 설정을 적용하지도 않으므로 주의해야 한다.
5. 이에 관한 모든 소스와 Spring의 Transaction에서의 예제를 모두 만들어서 올려 두었다. replication-datasource 프로젝트를 보면되며 그 중에서도 AbstractReplicationDataSourceIntegrationTest.java가 3, 4번 모두에 대한 Spring Transaction 테스트이다.
PS>
일반적으로 Slave는 여러대로 구성한다. 그렇다면 Slave DB 정보를 여러개 받아서 부하 분산 처리하게 수정하고 싶은 욕구를 느낄 듯 한데, 그러지 말길 권한다.
실제 데이터베이스의 물리적 정보를 애플리케이션에 넣게 되면 여러 서버들 중 한 대가 고장났을 경우 애플리케이션이 그대로 죽어버린다. 동종의 물리적 데이터베이스에 대한 Load Balancing 은 애플리케이션에서 하지 말고 중간에 L4, L7, LVS, Proxy Server 등의 계층을 두어서 중앙 집중 조정하게 해야한다. 여기서는 단지 부하 분산만 할 뿐 조건에 따른 분기 처리는 하지 않는다.
사정상 이런 분기 처리가 힘들면 MySQL의 경우에는 JDBC에서 Load Blanace를 지원하니 알아보도록 한다.
그래도 replication-datasource에 다중 Slave의 부하 분산 기능을 넣고 싶다면 일부 서버가 죽었을 때의 처리에 대해 매우 깊이 고민해야 한다.
마지막으로, 4번 방법은 실전에서 사용해본 적 없다!