상관없는 기능까지 같이 느려질 때
상관없는 기능까지 같이 느려질 때
첫 직장에서 10년 넘게 굴러온 교육 플랫폼의 차세대 버전을 맡았다. 넘겨받은 것 중에 학습 히스토리 테이블이 있었는데, 누적이 100억 건을 넘겼다. 솔직히 그땐 그 숫자가 어떤 의미인지 감도 없었다.
학생별 수업 이력 조회가 20초씩 걸렸다. 왜 이렇게 느린지 아무도 짚지 못하고 있었다. 평소에도 느리다는 VOC가 꾸준히 들어오긴 했는데, 어느 순간부터 결이 달라졌다. 이제는 못 참겠다는 거다. 특히 아침마다 같은 VOC가 반복됐다. 선생님이 수업을 시작해야 하는데 시간표 조회가 안 뜬다는 거였다. 시간표는 이력 조회랑 아무 상관이 없는 기능인데 그랬다.
확인해보니 이력 조회가 돌 때마다 DB에 부하가 발생했고, 같은 DB에 트랜잭션이 묶여 있는 API들이 전부 마비되고 있었다.
스카우터가 가리킨 곳
처음엔 느려진 API들의 코드를 하나씩 열어봤는데 거기엔 답이 없었다. 그래서 스카우터(APM 툴)로 요청이 실제로 어디서 시간을 쓰는지 봤다. 느려진 API들의 병목 지점이 전부 한 곳으로 수렴했다. 히스토리 테이블을 조회하는 쿼리였다.
실행 계획을 떠보니 사실상 테이블 전체를 훑는 Full Scan이었다. 학생 한 명 이력을 뽑는데 수년치 데이터가 딸려 나왔으니까. 문제는 이 Full Scan이 저 혼자만 느린 게 아니라는 거였다. 수십억 건을 훑으면서 DB 버퍼 풀(자주 쓰는 데이터를 올려두는 메모리)을 통째로 밀어냈고, 그 자리에 있던 실서비스 데이터가 쫓겨났다. 시간표·로그인 쿼리는 코드가 멀쩡한데도 캐시 미스가 나며 디스크 I/O로 떨어졌고, 히스토리 DB에 트랜잭션이 묶인 API들은 줄줄이 블로킹됐다. 상관없는 기능까지 같이 느려진 이유가 이거였다.

인덱스도 캐시도 아니었다
처음 꺼낸 카드는 인덱스였다. 생성시간으로 인덱스를 잡아봤는데 기대만큼 빨라지지 않았다. 100억 건 앞에서는 인덱스를 타고도 읽어야 하는 양 자체가 너무 많았다.
다음엔 Redis 캐시를 얹어봤다. 이것도 효과가 미미했다. 이력 조회는 학생·기간·과목 조합으로 조회 조건이 너무 많아서 같은 키가 다시 들어올 확률이 낮았고, hit율이 바닥이었다. 캐시는 같은 걸 여러 번 읽어야 이득인데 여긴 그런 워크로드가 아니었다.
테이블을 물리적으로 쪼갰다
방법을 찾다가 파티셔닝이라는 걸 알게 됐다. 다시 보니 이력 조회는 항상 기간 조건을 달고 들어왔다. 최근 한 달, 이번 학기, 특정 연도. 그런데 테이블이 통짜라 어떤 기간을 조회하든 100억 건 전체가 스캔 후보였다.
그래서 히스토리 테이블을 생성시간 기준으로 물리적으로 쪼갰다. history_2020, history_2021, history_2022 같은 식으로 연도별 테이블을 따로 두고, API로 들어온 날짜를 보고 레포지토리에서 어느 테이블을 읽을지 분기하게 했다. 이런 걸 어댑터 패턴이라고 부르는지는 모르겠는데, 결과적으로 레포지토리 앞에서 날짜에 따라 조회 대상을 갈아끼우는 구조가 됐다.
이러면 최근 한 달을 조회할 때 100억 건 전체가 아니라 해당 연도 테이블 하나만 스캔 후보가 된다. 버퍼 풀에 올라오는 것도 그 분량뿐이라, 실서비스 데이터를 밀어내던 압력 자체가 사라진다.
결과, 그리고 남은 것
이력 조회가 20초 이상에서 1초 이하로 떨어졌다(95%↓). 프리징이 사라졌고, 아침마다 오던 시간표 VOC도 끊겼다. 제일 신기했던 건 손도 안 댄 로그인·수강신청이 같이 멀쩡해진 거였다. 진짜 원인이 기능들 사이가 아니라 그 밑에 깔린 DB 하나였다는 증거다.
이 사건이 남긴 건 수치보다 시각이다. 처음엔 인덱스 튜닝이나 캐싱 같은 논리 계층의 기술로만 풀려고 했다. 그게 세련된 방법이라고 생각했던 것 같다. 그런데 정작 문제를 끝낸 건 데이터를 물리적으로 나눈다는 단순한 발상이었다. 논리로 안 풀리면 물리로 푼다는 선택지를 이때 처음 몸으로 배웠고, 그 뒤로 문제를 보는 폭이 한 뼘 넓어졌다.