DB 트래픽 분산을 위한 Master-Slave 이중화
기존 아키텍처(단일 DB + Redis 캐시)의 문제점 분석
기존 시스템은 단일 DB, Redis를 캐시로 사용하는 아키텍처로 구성되어 있습니다.
이 구조는 DB의 부하를 줄여주는 일반적인 캐싱 전략이지만, 서비스 안정성에 대해 고려해 보면서 다음과 같은 잠재적인 문제가 내포하고 있음을 고민할 수 있습니다.
SPOF(Single Point of Failure) 문제
Redis 장애 시: 캐시가 받던 모든 부하가 DB로 직접 전달
만약 Redis에 장애가 발생하면 캐시가 처리하던 모든 요청이 그대로 DB로 향하게 되어 DB에 과부하를 유발
DB 장애 시: 서비스 전체 마비
장애 전파 위험
Redis 다운 -> DB 과부화 -> 연쇄 장애 가능성
복구 과정에서까지의 추가 성능 저하
단순한 사이드 프로젝트이기 때문에 기존 구조가 심각한 문제를 야기하지 않았지만 서비스가 성장할수록 이러한 위험 요소들은 더 큰 영향을 줄 수 있습니다.
시스템 안정성과 가용성을 높이기 위해 데이터베이스 이중화(복제, Replication) 도입을 고민하면서, 이 결정이 정말 올바른지 확인하기 위해 두 아키텍처를 대상으로 부하 테스트를 진행하고 성능을 직접 비교를 진행하고자 합니다.
데이터베이스 복제(Replication) 선택
MySQL Replication Architecture
운영하는 서비스에 사용자가 많아지면 이것에 비례해서 트래픽이 증가하면서 트랜잭션을 처리하는 작업이 많아질 것입니다.
이러한 OLTP 특징을 가진 서비스에서 DB의 부하를 최소화하여 장애를 예방하고, 고가용성을 확보해 보다 안전적인 서비스를 고려한다면 도입을 고민해 볼 수 있습니다.
Database Replication Architecture테스트를 통한 비교
먼저 복제 구조를 도입하는 기대 효과는 다음과 같습니다.
읽기/쓰기 부하 분산을 통한 성능 향상
복제본을 통한 백업 및 장애 복구 용이성
SPOF 제거 (Source DB 장애 시 Replica 승격 가능)
대부분의 웹 서비스는 새로운 데이터를 생성하는(Write) 작업보다 기존 데이터를 조회(Read)하는 작업이 훨씬 빈번하게 발생한다고 생각합니다. 이러한 특성을 반영하여, 유사한 테스트 시나리오를 구성하기 위해 읽기, 쓰기 요청의 비율을 8:2로 설정했습니다.
읽기 요청(80%): 브랜드 조회, 리뷰 조회, 상세 페이지 등
쓰기 요청(20%): 매거진 업로드, 리뷰 작성 등
두 아키텍처 모두 동일한 조건의 부하를 발생시켜 성능을 측정한 결과는 다음과 같습니다.
총 요청
5,275
6,859
평균 응답
190 ms
114 ms
95%
790 ms
474 ms
99%
1,300 ms
780 ms
Max
2,575 ms
1,545 ms
RPS
87.9
104.3
단일 DB + 캐시: 순간적인 성능, 그러나 불안정성
기존 구조는 특정 구간에서 높은 TPS를 보였지만, 최대 응답 시간이 길게 나타나 트래픽 급증 시 성능 저하 가능성을 보였습니다.
p95, 99 응답 시간이 길다는 것은 부하가 몰릴 때 일부 사용자는 심각한 지연을 겪고 있음을 의미하기 때문입니다.
DB 복제 구조: 일관된 성능
복제 구조는 평균 응답 시간을 40% 단축시키고, 전체 처리량을 30% 향상시키는 결과를 보였습니다.
무엇보다 중요한 것은 p95, 99 응답 시간을 포함한 모든 응답 지표에서 일관된 성능을 유지했다는 점입니다. 이는 읽기 요청을 Replica로 분산시켜 시스템 전반적으로 안정적으로 트래픽을 처리하고 있음을 보여준다고 생각합니다.
제가 진행한 프로젝트는 특정 시간대에 트래픽을 유발하는 이벤트성 서비스를 제공하지 않기 때문에 순간적인 최고 성능보다 다양한 상황 속에서도 사용자에게 일관된 성능을 제공하는 안정성에 초점을 맞추는 것을 목표로 했습니다.
테스트 결과, 복제 구조는 더 높은 처리량과 일관된 응답 시간을 제공했고, 복제본을 통한 백업과 장애 복구를 용이하게 하는 이점까지 제공합니다.
Master/Slave 장애 대응과 라우팅
MySQL Orchestrator로 High Availability(HA) 구축JPA의 write/read 분리
아래 애플리케이션 설정처럼 다중 데이터 소스를 설정하여 읽기/쓰기용 데이터 소스를 구분하여 요청을 처리하고,
slave에 read 작업을 라우팅 하기 위해서 @Transactional(readOnly =true)을, master에 wrtie 작업을 라우팅 하기 위해서는 @Transactional를 명시합니다. 추가로, AbstractRoutingDataSource를 구현하여 트랜잭션의 readOnly 속성에 따라 읽기/쓰기로 라우팅이 가능해집니다.
하지만 auto failover 활성화 시, master로 승격하는 slave로 향하도록 라우팅하는 구현이 추가로 필요하게 되며 이는 구현을 어렵게 만들 수 있습니다.
ProxySQL의 write/read 분리
ProxySQL호스트 그룹(hostgroup)을 값으로 설정하여 write/raed DB 서버를 구성하고 SQL 패턴으로 query rule을 설정하여 라우팅한다. 추가로 포트 기반 라우팅도 설정이 가능합니다.
ProxySQL은 애플리케이션 레벨에서 별도의 구현 없이 DB 레벨에서 write/read 라우팅이 가능하지만, 애플리케이션에서 라우팅 하려면 구현 복잡도가 증가하게 됩니다.
구현
Application Layer
DB Proxy Layer
관리
애플리케이션과 함께 관리
추가적인 미들웨어 관리 필요
유연성
세밀한 제어 가능
애플리케이션과 독립적이므로 코드 작성 불필요
확장성
동작에 맞도록 별도의 구현 필요
DB 구성 변경으로 확장 가능
애플리케이션 설정 (중요하지 않음)
Routing DataSource 설정
여러 개의 DataSource를 하나로 묶는 AbstractRoutingDataSource 추상 클래스는 DataSource에 대한 Routing을 결정하는 클래스입니다.
데이터 소스 선택, 로드 밸런싱, 장애 조치를 지원
여기서 장애 조치는 데이터 소스 장애 발생 시 대체 데이터 소스로 자동 전환
아래와 같은 필드가 존재하는데, <String, Datasource>로 활용해서 key 값을 통해서 datasource를 식별합니다.
그럼 요청에 대한 key는 어떻게 찾아갈까..? 바로 우리가 구현 해줘야하는 LookUpkey를 통해서 찾아갑니다.
determineCurrentLookupKey() 메서드는 현재 요청에 대해서 키 값을 결정합니다.
determineTargetDataSource() 메서드의 흐름
currentLookUpKey()를 가져와서 값을 체크
값이 있다면 해당 dataSource를 사용
타겟이 없다면 기본 defaultTargetDataSource을 사용
그래도 없으면 Exception()이 발생
그림으로 보면 다음과 같다.

TransactionSynchronizationManager는 무엇을 할까?
스프링에서 제공하는 클래스로, 트랜잭션 동기화 관리를 한다.
트랜잭션 시작 및 종료 시점을 감지하고 이벤트 발생
트랜잭션에 참여하는 리소스 등록 및 관리
스레드 간 트랜잭션 상태 전파
주요 기능:
isActualTransactionActive() : 현재 트랜잭션이 활성화 된지 확인
initSynchronization(): 트랜잭션 동기화를 위한 준비를 수행
triggerAfterCompletion(int): 트랜잭션 완료 후 실행해야 하는 작업을 등록
isCurrentTransactionReadOnly() : 현재 트랜잭션이 읽기 전용인지 확인
트랜잭션 동기화 과정
비즈니스 로직(
dao.create()) 에서 Connection 생성생성한 커넥션을 트랜잭션 동기화 저장소(TransactionSynchronizations)에 저장 후, setAutoCommit(false)를 통해 트랜잭션을 시작한다.
dao의 create() 메소드를 호출
JdbcTemplate의 메소드를 통해 트랜잭션 동기화 저장소에 같은 트랜잭션이 있는지 확인하고 있으면 가져옴.
Connection을 이용해서 PreparedStatement를 만들어 insert SQL 실행
만약 비즈니스 로직에 dao.create()가 한번 더 있었으면, 동기화 저장소에서 connection을 가져옴
트랜잭션 내의 모든 작업이 끝나면 service에서 connection을 commit 하고 종료함.
트랜잭션 동기화 저장소는 완료한 Connection 제거
프록시 객체와 지연 로딩 (LazyConnectionDataSourceProxy)
복제 구조에 맞춰 트랜잭션이 생기면 read는 replication 구현을 진행했지만 예상 다른 결과가 나타났습니다.
모든 요청이 master db로 라우팅 되었는데, 그 이유는 스프링이 트랜잭션을 시작할 때 DataSource를 정하고 해당DataSource로 트랜잭션 내의 모든 쿼리를 수행합니다.
LazyConnectionDataSourceProxy란?
실제 connection을 지연되게 가져오는 프록시라고 한다. 또 read-only에 대해 특별한 지원도 있다고 합니다.
LazyConnectionDataSourceProxy는 커넥션을 가지고 있지 않다가 실제 쿼리를 날리려고 할 때, 필요한 커넥션을 점유한다고 한다. 따라서 지금과 같은 Multi Datasource 환경에서 해당 클래스를 사용하면 원하는 dataSource로 분기할 수 있다. 즉, 요청마다 커넥션을 바로 생성하지 않고 프록시 커넥션을 반환합니다.
이 프록시 커넥션은 커넥션 풀에서 커넥션을 획득하고, 트랜잭션이 시작하면 꺼내가서 사용합니다.
작업이 완료되면 다시 커넥션 풀에 반환합니다.
위 문제 말고도 여러 단점들이 존재하는데, LazyConnectionDataSourceProxy를 사용하면 발생한 문제점과 그 외에 단점들을 해결하는 데 이점이 있다고 합니다.
사용법도 크게 어렵지 않았습니다.
DataSource 설정


설정 결과, write/read 작업이 master/slave DB에서 처리되는 것을 확인할 수 있었습니다.
Last updated