데이터가 많아지면 RDBMS는 어떻게 해야 할까.
NoSQL로 갈아타는 것만이 답은 아니다. RDBMS 안에서도 꺼낼 수 있는 카드가 여러 장 있다.
테이블에 데이터가 쌓일수록 전체를 스캔하는 비용이 선형으로 늘어난다. 인덱스는 특정 컬럼에 B-Tree 구조를 만들어 탐색 비용을 O(log n)으로 낮춘다.
단순해 보이지만 트레이드오프가 있다. INSERT, UPDATE, DELETE가 일어날 때마다 인덱스도 함께 갱신해야 한다. 읽기가 많고 쓰기가 적은 테이블에는 효과적이지만, 쓰기가 잦은 테이블에 인덱스를 남발하면 오히려 느려진다.
인덱스를 걸어도 데이터가 수억 건이 되면 디스크 I/O 자체가 병목이 된다. 이때는 데이터를 나누는 방법을 쓴다.
파티셔닝은 같은 서버 안에서 하나의 큰 테이블을 조건에 따라 물리적으로 쪼갠다. 날짜 기준으로 나누면 2024년 1월 데이터를 조회할 때 1월 파티션만 스캔하고 나머지는 건드리지 않는다. 이를 파티션 프루닝이라고 한다.
샤딩은 서버 자체를 늘려 데이터를 다른 서버에 분산한다. 한 서버의 한계를 넘어 수평으로 확장할 수 있다. 단, 여러 샤드에 걸친 JOIN이나 트랜잭션은 비용이 크기 때문에 shard key 설계가 중요하다.
같은 데이터를 반복해서 조회하는 요청은 DB까지 올 필요가 없다. Redis 같은 인메모리 캐시를 앞에 두면 자주 읽히는 데이터는 메모리에서 바로 반환된다.
항상 최신 데이터가 필요한 경우에는 캐시를 쓸 수 없다. 또한 캐시 미스가 동시에 폭발적으로 발생하면 DB로 요청이 한꺼번에 몰리는 Cache Stampede 문제가 생긴다. 캐시는 강력하지만 모든 상황에 적용할 수 있는 건 아니다.
캐시로도 막을 수 없는 읽기 부하가 있다. 항상 최신 데이터가 필요하거나, 캐시 미스가 계속 발생하는 경우다. 이때 DB 레벨에서 읽기를 나누는 방법이 Read Replica다.
Master 서버는 쓰기(INSERT/UPDATE/DELETE)만 처리하고, 읽기 전용 Slave 서버를 여러 대 두어 SELECT를 분산한다. Slave를 추가할수록 읽기 처리량이 선형으로 늘어난다. Master에 장애가 생기면 Slave 중 하나가 Master로 승격된다.
Read Replica를 구성할 때 복제 방식을 선택해야 한다.
비동기 복제: Master가 커밋하고 끝낸다. Slave는 나중에 따라잡는다. 쓰기 성능에 영향이 없지만, 방금 Master에 쓴 데이터가 아직 Slave에 없을 수 있다.
동기 복제(semi-sync): Master가 커밋 전에 Slave가 "받았어"라는 ACK를 보낼 때까지 기다린다. 일관성이 보장되지만, 트랜잭션마다 네트워크 왕복이 한 번 추가된다.
말로만 하면 얼마나 차이가 나는지 감이 안 와서 직접 측정해봤다.
환경 구성
Docker Compose로 MySQL 8.0 Master + Slave를 구성했다.
# docker-compose.yml
services:
mysql-master:
image: mysql:8.0
container_name: mysql-master
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: bench
ports:
- "3306:3306"
volumes:
- ./master-conf/my.cnf:/etc/mysql/conf.d/my.cnf
mysql-slave:
image: mysql:8.0
container_name: mysql-slave
environment:
MYSQL_ROOT_PASSWORD: root
volumes:
- ./slave-conf/my.cnf:/etc/mysql/conf.d/my.cnf
depends_on:
- mysql-master
Master 설정 파일에 binary log와 GTID를 활성화하고, semi-sync 플러그인을 로드했다.
# master-conf/my.cnf
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW
gtid_mode = ON
enforce-gtid-consistency = ON
plugin-load-add = semisync_master.so
Slave도 같은 방식으로 semi-sync slave 플러그인을 로드했다.
# slave-conf/my.cnf
[mysqld]
server-id = 2
relay-log = relay-log
gtid_mode = ON
enforce-gtid-consistency = ON
read_only = ON
plugin-load-add = semisync_slave.so
복제 설정
컨테이너를 띄운 뒤 Master에 복제용 유저를 만들고, Slave에 Master 정보를 등록해 복제를 시작했다.
-- Master에서
CREATE USER 'replicator'@'%' IDENTIFIED WITH mysql_native_password BY 'replicator';
GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'%';
-- Slave에서
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='mysql-master',
SOURCE_USER='replicator',
SOURCE_PASSWORD='replicator',
SOURCE_AUTO_POSITION=1;
START REPLICA;
복제가 정상적으로 연결됐는지 확인했다.
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
데이터 준비
sysbench로 테스트 테이블 4개를 각 10,000행으로 채웠다. Master에 생성하면 Slave에 자동으로 복제된다.
sysbench oltp_write_only \
--db-driver=mysql \
--mysql-host=127.0.0.1 --mysql-port=3306 \
--mysql-user=root --mysql-password=root \
--mysql-db=bench \
--tables=4 --table-size=10000 \
prepare
Slave에서 확인하면 데이터가 그대로 복제돼 있다.
SELECT COUNT(*) FROM bench.sbtest1;
-- 10000
측정
16개 스레드로 30초 동안 쓰기 부하를 걸었다. 16 threads는 동시에 16개 커넥션이 쉬지 않고 트랜잭션을 날리는 것이고, 실제 TPS는 MySQL이 얼마나 빠르게 처리하느냐에 달려 있다.
sysbench oltp_write_only \
--db-driver=mysql \
--mysql-host=127.0.0.1 --mysql-port=3306 \
--mysql-user=root --mysql-password=root \
--mysql-db=bench \
--tables=4 --table-size=10000 \
--threads=16 --time=30 \
run
비동기 복제 결과:
transactions: 73369 (2444.78 per sec.)
이제 동기 복제로 전환했다.
SET GLOBAL rpl_semi_sync_master_enabled = 1;
동기 복제 결과:
transactions: 35370 (1178.64 per sec.)
결과
| TPS | |
|---|---|
| 비동기 복제 | 2444 |
| 동기 복제 | 1178 |
같은 머신에 컨테이너로 띄운 환경임에도 비동기 복제가 동기 복제보다 107% 더 처리했다. 트랜잭션마다 Slave ACK를 기다리는 왕복 비용이 이만큼 쌓인다. 실제 운영 환경에서 Master와 Slave가 다른 데이터센터에 있다면 네트워크 지연이 더 커서 격차는 더 벌어진다.
동기 복제는 일관성을 보장하지만 쓰기 성능을 희생한다. 비동기 복제는 성능이 빠르지만 Slave가 잠깐 오래된 데이터를 보여줄 수 있다. 서비스 특성에 따라 선택해야 한다.
오래된 데이터가 계속 쌓여서 테이블이 커지는 게 문제라면, 최근 데이터만 active 테이블에 두고 나머지는 archive 테이블로 옮기는 방법도 있다. 테이블 자체가 작아지면 인덱스 효율도 올라간다. Range 파티셔닝을 쓰면 오래된 파티션을 DROP으로 한 번에 정리할 수 있어서 수억 건 DELETE보다 훨씬 빠르다.
데이터가 많아졌다면 파티셔닝이나 샤딩으로 데이터를 나누고, 조회가 많아졌다면 캐싱이나 Read Replica로 읽기를 나눈다. 어떤 한 방법이 정답은 아니고, 병목이 어디서 오는지를 먼저 파악하는 게 중요하다. 각 구간별로 쓰기가 많은지, 읽기가 많은지, 특정 데이터에 몰리는지 보고 그에 맞는 카드를 꺼내면 된다.