본문 바로가기
Framework/Spring Boot

[Spring Boot] DB Replication (Master-Slave) - AbstractRoutingDataSource 라우팅을 통한 읽기/쓰기 분리(Read/Write Splitting) 마스터 아키텍처 완벽 구현 가이드

반응형

우리는 지금까지 단 하나의 물리적인 데이터베이스(DB) 인스턴스만을 바라보고 스프링 부트 코딩을 해왔습니다. 트래픽이 적은 토이 프로젝트나 B2B 인트라넷 서비스일 때는 아무런 문제가 발생하지 않습니다. 하지만 서비스가 폭발적으로 성장하여 동시 접속자가 1만 명, 10만 명을 돌파하는 순간, 단일 DB는 엄청난 비명을 지르며 서버가 통째로 뻗어버리게 됩니다.

백엔드 개발자가 아무리 애플리케이션 단(WAS)에서 Redis 로컬 캐싱(Cache)을 바르고 JPQL 쿼리를 미친 듯이 튜닝해도, 본질적으로 수만 건의 SELECT 조회 요청과 수천 건의 INSERT/UPDATE 쓰기 요청이 단 하나의 DB 하드웨어 자원(CPU 연산, Disk I/O, 메모리 버퍼)을 갉아먹으면 병목(Bottleneck) 현상은 물리적으로 피할 수 없습니다. 이 병목을 인프라 레벨에서 근원적으로 부숴버리기 위해 실무 백엔드 아키텍처에서 가장 먼저, 그리고 필수적으로 도입하는 개념이 바로 DB Replication(복제)과 Read/Write Splitting(읽기/쓰기 분리)입니다. 통계적으로 일반적인 커머스 웹 서비스의 트래픽 비율은 조회(Read)가 무려 80~90%, 생성/수정(Write)이 10~20%를 차지합니다. 그렇다면 쓰기 전용 DB(Master) 단 한 대와, 읽기 전용 DB(Slave) 여러 대를 병렬로 깔아두고, 스프링 부트가 알아서 똑똑하게 쿼리를 분배하게 만들면 어떨까요?

이번 56단계 포스팅에서는 트래픽 10배 확장의 핵심 마법, 스프링의 AbstractRoutingDataSource를 활용한 완벽한 읽기/쓰기 분리 라우팅 아키텍처와 치명적 부작용인 복제 지연(Replication Lag) 해결책에 대해 상세히 알아보겠습니다.

1. DB Replication의 물리적 원리: Master가 쓰면 Slave가 훔쳐 적는다

스프링 부트 단에서 자바 코드를 짜기 전에, 백엔드 엔지니어라면 인프라 단에서 DB가 어떻게 복제되는지 그 물리적 원리를 정확히 이해해야 합니다. 실무에서 가장 많이 쓰는 MySQL이나 MariaDB를 예로 들면, DB 엔진 내부에는 바이너리 로그(Binary Log, 일명 Binlog)라는 핵심 파일이 존재합니다.

스프링 부트의 AbstractRoutingDataSource를 이용한 Master-Slave DB 읽기/쓰기 분리 라우팅 아키텍처 및 Binlog 동기화 원리 다이어그램

DB 클러스터링을 구성하면, 모든 INSERT, UPDATE, DELETE 작업(Write 트랜잭션)은 무조건 Master(Primary) DB 한 곳에서만 일어납니다. Master DB는 자신이 변경된 모든 데이터 내역을 디스크의 Binlog라는 파일에 순차적으로(Sequential) 기록합니다. 그러면 Slave(Replica) DB들이 백그라운드 I/O 쓰레드(I/O Thread)를 미친 듯이 돌려서 Master의 Binlog를 실시간으로 복사(Replication) 해옵니다. 그리고 복사해 온 로그를 자신의 릴레이 로그(Relay Log) 파일에 옮겨 적은 뒤, 내장된 SQL 쓰레드(SQL Thread)가 그 쿼리들을 자기 자신(Slave DB)에게 똑같이 실행(재생)하여 Master와 완벽히 동일한 데이터를 유지(동기화)하게 됩니다. 이 복제 작업은 비동기(Asynchronous)로 아주 빠르게 일어나므로, Slave DB들은 거의 실시간으로 Master DB와 똑같은 데이터를 가지게 됩니다. 이렇게 붕어빵처럼 찍어낸 Slave DB 인스턴스를 2대, 3대, 혹은 그 이상 병렬로 배치하면, 우리는 무한대에 가까운 조회(SELECT) 트래픽 처리량(Scale-out)을 확보할 수 있습니다.

2. Spring Boot의 라우팅 마법: AbstractRoutingDataSource

DB 인프라는 세팅이 끝났습니다. 이제 스프링 부트에게 "지금 날아가는 쿼리가 순수 조회(SELECT) 쿼리면 1번 Slave DB 커넥션으로 보내고, 데이터 변경(INSERT/UPDATE) 쿼리면 Master DB 커넥션으로 정확히 보내!"라고 가르쳐야 합니다. 과거에는 개발자가 DAO 클래스에서 쿼리마다 직접 커넥션 풀을 masterDataSource 혹은 slaveDataSource로 분기 치는 끔찍한 하드코딩을 했습니다. 하지만 스프링 프레임워크는 이를 매우 우아하게 프레임워크 레벨에서 해결할 수 있는 AbstractRoutingDataSource라는 기가 막힌 추상 클래스를 제공합니다.

💡 트랜잭션 동기화 매니저(TransactionSynchronizationManager)의 비밀 깃발

애플리케이션 개발자는 AbstractRoutingDataSource의 복잡한 내부를 몰라도 됩니다. 비즈니스 로직(Service 계층) 메서드 상단에 오직 @Transactional(readOnly = true) 애노테이션 하나만 꼼꼼하게 붙여주면 끝납니다. 스프링의 트랜잭션 AOP 인터셉터는 이 애노테이션을 읽고, 현재 쓰레드 로컬(ThreadLocal)에 바인딩된 TransactionSynchronizationManager(트랜잭션 동기화 매니저)에 "현재 이 쓰레드의 트랜잭션은 읽기 전용(ReadOnly)이다"라는 깃발(Flag)을 꽂아둡니다. AbstractRoutingDataSource를 상속받아 우리가 커스텀 구현한 라우팅 클래스는 JPA가 쿼리를 날리기 위해 커넥션을 획득하기 직전, 이 깃발을 슬쩍 확인합니다. 깃발이 true면 미리 등록해 둔 여러 Slave DataSource 중 하나(보통 라운드 로빈 방식으로 분배)의 키(Key)를 반환하고, 깃발이 false면 Master DataSource의 키를 반환하여 완벽하게 쿼리를 분기시킵니다.

// 🚨 실무 라우팅 DataSource의 핵심 구현부 (자바 코드)
public class MasterSlaveRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 현재 쓰레드의 트랜잭션이 readOnly = true 로 마킹되어 있는가?
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        
        if (isReadOnly) {
            log.debug("🚀 조회(ReadOnly) 트랜잭션 감지: Slave DB 커넥션을 반환하여 라우팅합니다.");
            return "slave"; // Slave DataSource Key 반환
        } else {
            log.debug("🔥 쓰기(Write) 트랜잭션 감지: Master DB 커넥션을 반환하여 라우팅합니다.");
            return "master"; // Master DataSource Key 반환
        }
    }
}

3. 실무 아키텍트의 치명적인 트러블슈팅: Replication Lag (복제 지연)

위의 AbstractRoutingDataSource 라우팅 코드를 훌륭하게 적용하고 프로덕션에 배포하면 끝일까요? 주니어 개발자는 트래픽이 분산되는 것을 보며 환호하겠지만, 불과 며칠 뒤 고객센터에는 "방금 회원가입을 하고 메인 화면으로 넘어왔는데, 제 닉네임이 안 뜨고 없는 회원이라고 떠요!" 혹은 "글을 작성하고 '완료' 버튼을 눌렀는데, 게시판 목록에 제 글이 안 보여요!"라는 심각한 버그 컴플레인이 폭주하기 시작할 것입니다. 이것이 바로 DB 읽기/쓰기 분리 아키텍처가 가진 가장 악랄한 부작용인 '복제 지연(Replication Lag)' 현상입니다.

유저가 게시판에 글을 작성(INSERT)을 완료하자마자, 스프링 부트 로직이 바로 다음 페이지에서 자신이 방금 쓴 글을 조회(SELECT)한다고 가정해 봅시다. INSERT 로직은 쓰기 트랜잭션이므로 Master DB에 정확히 꽂힙니다. 그 직후 페이지가 리다이렉트되며 이어지는 SELECT 요청은 읽기 트랜잭션(readOnly=true)이므로 라우팅 원리에 따라 Slave DB로 날아갑니다. 문제는 여기서 터집니다. Master DB가 자신의 변경 사항(Binlog)을 Slave DB로 전송하고 Slave가 이를 자기 디스크에 동기화하는 데에는 네트워크 비용과 디스크 I/O 비용 때문에 물리적으로 0.01초 ~ 최대 수 초(트래픽이 몰릴 경우 5초 이상)의 시간 차이(Delay)가 발생합니다. 즉, 글을 쓰자마자 0.01초 만에 Slave DB로 날아간 SELECT 쿼리는 '아직 마스터로부터 새로운 데이터 동기화가 끝나지 않은 과거 상태의 Slave 디스크'를 뒤지게 되고, 당연히 "데이터 없음(Null)"을 반환하게 되는 끔찍한 데이터 불일치(Inconsistency) 현상이 발생하는 것입니다.

🛡️ 어떻게 해결할 것인가? (실무 아키텍트의 노하우)
이 복제 지연을 아키텍처 적으로 해결하는 것이 진정한 시니어의 실력입니다. 첫째, 방금 쓴 데이터를 1~2초 내에 곧바로 다시 읽어야 하는 매우 중요한 크리티컬 패스(Critical Path, 예: 주문 완료 직후 영수증 조회) 로직이라면, 억지로라도 @Transactional(readOnly = false)를 유지하거나 별도의 Master 전용 트랜잭션을 태워서 SELECT 쿼리를 무조건 Master DB에서 읽어오도록 강제(Sticky Session 방식)해야 합니다. 둘째, 최근의 퍼블릭 클라우드, 특히 AWS Aurora DB 환경에서는 MariaDB 커넥터의 aurora-jdbc 드라이버가 제공하는 '자체 라우팅 및 복제 지연 인지 기능'을 활용하면 애플리케이션 단의 복잡한 AbstractRoutingDataSource 코드를 모두 지워버리고 JDBC 드라이버 단에서 우아하게 이 현상을 방어할 수도 있습니다. 셋째, MySQL과 애플리케이션 사이에 ProxySQL 같은 미들웨어(Middleware)를 두어 프록시 서버 단에서 정규표현식으로 쿼리를 파싱하여 분배하는 방법도 대규모 인프라에서는 각광받고 있습니다.

정리하며

이번 56단계 포스팅에서는 폭발하는 트래픽 10만 명을 서버 죽지 않고 버텨내기 위한 필수 확장 아키텍처, AbstractRoutingDataSource를 활용한 완벽한 읽기/쓰기(Read/Write Splitting) DB 라우팅 기법의 원리와 @Transactional(readOnly=true)의 숨겨진 동작 메커니즘, 그리고 실무에서 무조건 마주하게 되는 복제 지연(Replication Lag)이라는 악몽과 3가지 해결책에 대해 뼈 때리게 파헤쳐 보았습니다. 이 Master-Slave 분산 아키텍처를 프로덕션에 적용하는 순간, 여러분의 서버는 쏟아지는 조회 트래픽을 다수의 Slave DB들에게 여유롭게 흩뿌리며 유유히 살아남게 될 것입니다. 자, 그런데 데이터베이스가 단순히 동일한 데이터를 '복제'하는 수준을 넘어, 아예 '성격이 다른 두 개의 독립적인 DB(예: 레거시 Oracle DB와 신규 MySQL DB)'를 한 프로젝트에서 동시에 조작해야 한다면 어떻게 될까요? 서로 다른 DB 간에 데이터 트랜잭션 롤백(Rollback)을 묶을 수 있을까요? 다음 57단계 포스팅에서는 "다중 DB 트랜잭션의 함정: JTA 글로벌 트랜잭션(2PC)과 ChainedTransactionManager를 활용하여 서로 다른 두 개의 데이터베이스를 하나의 트랜잭션(원자성)으로 묶어버리는 극한의 튜닝 마법"에 대해 아주 치밀하게 딥다이브 해보겠습니다!

반응형