상관없는 기능까지 같이 느려질 때
100억 건 히스토리 조회가 20초씩 걸리더니 로그인·수강신청까지 같이 멈췄다. 원인은 Full Scan이 DB 버퍼 풀을 통째로 밀어낸 것. 캐싱이 안 먹힌 이유와 생성시간 Range 파티셔닝으로 20초를 1초로 만든 과정.
상관없는 기능까지 같이 느려질 때
상황
교육 플랫폼 차세대 LMS 백엔드를 개발하면서 10년 넘은 레거시를 넘겨받았다. 학습 히스토리가 누적 100억 건을 넘는 테이블이 있었고, 장애 패턴이 이상했다.
학생별 수업 이력 조회가 20초씩 걸리는 거야 쿼리가 느리겠거니 했다. 문제는 피크타임마다 이력 조회랑 상관없는 기능들, 로그인·컨텐츠 재생·수강신청까지 같이 프리징이 났다는 거다. 콜센터로 VOC가 반복해서 들어왔고 원인을 못 짚고 있었다.
코드엔 답이 없었다: 메트릭이 가리킨 범인
처음엔 히스토리 쿼리를 의심했다. 학생 한 명 이력을 뽑으면 수년치 데이터가 딸려 나왔고, 조회가 사실상 테이블 전체를 훑는 Full Scan으로 돌았다. 여기까지는 예상한 그림이었다.
안 풀리는 건 다음이었다. 히스토리가 느린 건 알겠는데 왜 로그인·수강신청까지 같이 느려지냐. 기능상 접점이 없었다. 로그인 코드, 수강신청 코드만 한참 봤는데 거기엔 답이 없었다.
코드 말고 DB 서버 메트릭을 봤다. 실서비스 기능들과 히스토리 테이블이 같은 DB를 쓰고 있었고, 히스토리 Full Scan이 돌 때마다 버퍼 풀을 통째로 잡아먹고 있었다. 버퍼 풀은 자주 쓰는 데이터를 메모리에 올려두는 공간인데, Full Scan이 수십억 건 규모 테이블을 훑으면서 실서비스가 캐싱해둔 데이터를 다 밀어냈다. 그래서 로그인·수강신청 쿼리는 멀쩡한데 캐시 미스가 나고 디스크 I/O가 튀면서 같이 느려졌다.

기능상 접점이 0인 두 기능이 같이 느려진 건, 코드가 엮여서가 아니라 그 밑의 메모리를 같이 쓰고 있었기 때문이다.
캐싱은 답이 아니었다
"조회 결과를 Redis에 캐싱하면 되지 않냐"는 제안이 나왔다. 이 워크로드에선 안 먹혔다.
캐시는 같은 키를 여러 번 읽을 때 이득인데, 이력 조회는 학생·기간 조합으로 조회 키가 잘게 흩어져 있어서 같은 키가 다시 들어올 확률이 낮았다. 게다가 학생들이 보는 건 대부분 방금 쌓인 신규 데이터라, 캐시에 올려둔 결과가 금방 낡았다. hit율이 안 나오는 자리에 캐시를 얹으면 메모리만 먹는 계층이 하나 늘어날 뿐이다.
테이블을 시간으로 쪼갰다: 생성시간 Range 파티셔닝
문제를 다시 보면, 쿼리가 느린 게 아니라 읽는 범위가 잘못돼 있었다. 이력 조회는 항상 기간 조건을 달고 들어온다. 최근 한 달, 이번 학기, 특정 연도. 그런데 테이블이 통짜라 어떤 기간을 조회하든 100억 건 전체가 스캔 후보였다.
그래서 테이블을 생성시간(연/월) 기준 Range 파티셔닝으로 분할했다. 쿼리가 기간 조건을 포함하면 옵티마이저가 그 범위에 해당하는 파티션만 읽는다(range pruning). 100억 건 테이블이 논리적으로는 하나지만, 실제 스캔은 조회 기간에 걸린 파티션 몇 개로 좁혀진다. 버퍼 풀에 올라오는 것도 그 파티션 분량뿐이라, 실서비스 데이터를 밀어내던 압력 자체가 사라진다.

여기서 함정이 하나 있다. 날짜 함수를 씌워 RANGE (YEAR(created_at) * 100 + MONTH(created_at))처럼 파티션을 정의하면, 정작 WHERE created_at >= '...' 같은 평범한 날짜 조건에서 프루닝이 안 먹고 전체 파티션을 다 훑는다. 옵티마이저가 컬럼에 건 조건과 함수로 만든 파티션 식을 연결하지 못하기 때문이다. 날짜 컬럼을 그대로 받는 RANGE COLUMNS(created_at)를 써야 프루닝이 산다.
ALTER TABLE learning_history
PARTITION BY RANGE COLUMNS(created_at) (
PARTITION p202301 VALUES LESS THAN ('2023-02-01'),
PARTITION p202302 VALUES LESS THAN ('2023-03-01'),
-- ...
PARTITION p_future VALUES LESS THAN (MAXVALUE)
);적용 후에는 EXPLAIN PARTITIONS로 쿼리가 실제로 어느 파티션만 읽는지 확인하는 습관을 들였다. 파티션을 나눠놓고 프루닝이 안 먹으면, 쪼개기 전과 똑같이 전체를 읽으면서 관리 비용만 늘어난 셈이 되기 때문이다.
결과
이력 조회가 20초 이상에서 1초 이하로 떨어졌다(95%↓). Full Scan이 버퍼 풀을 휩쓸던 게 사라지니 프리징도 함께 사라졌고, 반복되던 관련 VOC가 0이 됐다.
체감상 컸던 건 이력 조회 속도가 아니라 실서비스 안정화였다. 상관없어 보이던 로그인·수강신청이 같이 안정화되는 걸 보고, 버퍼 풀 경합이 진짜 원인이었다는 게 확실해졌다.
마치며
느리다고 무조건 테이블을 쪼개야 하는 건 아니다. 이 케이스는 조회가 항상 기간 조건을 달고 오는데 테이블은 통짜라는 어긋남이 분명했고, 캐시가 안 먹히는 워크로드라는 것까지 확인한 뒤에 꺼낸 카드였다. 파티셔닝은 쿼리의 접근 패턴과 테이블 구조를 한 세트로 봐야 효과가 난다.
제일 기억에 남는 건 따로 있다. 히스토리 테이블을 쪼갰더니 손도 안 댄 로그인이랑 수강신청이 같이 멀쩡해졌다. "상관없는 기능"이 같이 나았다는 것 자체가, 진짜 원인이 그 둘 사이가 아니라 그 밑에 깔린 버퍼 풀이었다는 증거였다. 로그인 코드를 아무리 들여다봤어도 답이 없던 이유다.