밤 10시만 되면 서비스가 느려졌다

English

200만 유저가 쓰는 음성 앱의 백엔드를 맡고 있었습니다. 해외 유저가 많아서 피크가 한국 시간 밤 10시부터 새벽 2시였습니다.

그 시간대에 간헐적으로 서비스 전체가 느려졌습니다. 특정 기능이 아니라 전체였습니다. 지표를 보면 DB 커넥션 풀이 말라 있었습니다.

풀이 마르는 건 증상입니다

커넥션 풀은 요청이 빌려 쓰는 커넥션 묶음입니다. 요청이 하나 들어오면 커넥션을 하나 빌리고, 쿼리가 끝나면 반납합니다.

그러니까 쿼리가 느리면 커넥션이 오래 묶입니다. 반납이 안 되니 풀이 마르고, 새로 들어온 요청은 커넥션을 못 얻고 줄을 섭니다. 이때부터는 느린 쿼리와 아무 상관 없는 요청까지 같이 느려집니다.

인덱스 없음풀스캔커넥션 오래 점유풀 고갈전체 지연
느린 쿼리 하나가 서비스 전체를 세우는 경로

풀이 마른 건 결과였습니다. 시작점은 다른 데 있었습니다.

원인은 인덱스였다

가장 흔한 원인은 인덱스 누락이었습니다. 여러 개발자가 기능을 붙이면서 새 테이블과 칼럼을 만드는데, 조회 조건에 인덱스가 안 붙은 채로 배포되는 경우가 반복됐습니다.

인덱스가 없으면 풀스캔을 돕니다. 데이터가 적을 땐 티가 안 나다가, 테이블이 커지고 트래픽이 몰리는 시간대가 되면 그때 드러납니다. 피크에만 터진 이유가 여기 있었습니다.

우선순위는 느림 곱하기 빈도

RDS Performance Insights로 시작했습니다. 다만 대시보드가 보여주는 top 쿼리를 그대로 순서대로 잡지는 않았습니다.

총 DB 시간에 얼마나 기여하는지로 우선순위를 정했습니다. 한 번에 3초 걸리지만 하루에 열 번 도는 쿼리보다, 0.2초짜리가 초당 수백 번 도는 쪽이 DB를 훨씬 많이 먹습니다. 느림과 빈도를 곱해야 실제 부하가 보입니다.

거기서 나온 쿼리를 EXPLAIN으로 하나씩 열었습니다. 실행계획에서 볼 건 단순합니다. typeALL이면 풀스캔이고, key가 비어 있으면 인덱스를 안 탄 겁니다. 스캔한 rows 수가 실제 결과 수와 크게 차이 나면 조건이 인덱스를 제대로 못 쓰고 있는 것이고요.

인덱스를 추가하거나 쿼리를 다시 설계했습니다. 반복해서 나가는데 최신성이 필요 없는 조회는 Redis로 캐싱했습니다.

읽기를 리플리카로 옮길 때 조심할 것

읽기 부하 일부를 read replica로 분리했습니다. 다만 전부는 아닙니다.

리플리카에는 복제 지연이 있습니다. 방금 쓴 값을 바로 읽어야 하는 경로를 리플리카로 보내면, 유저는 자기가 방금 한 행동이 반영 안 된 화면을 봅니다. 최신성이 필요한 읽기는 프라이머리에 남겼습니다. 어디까지가 "지연돼도 괜찮은 읽기"인지 판단하는 게 이 작업의 실제 내용이었습니다.

대시보드가 못 보는 쿼리

Performance Insights는 자주 호출되는 쿼리를 잘 잡습니다. 집계 기반이라서요. 그런데 딱 한 번 튀는 쿼리나, 조용히 느린 쿼리는 안 뜹니다. 평균에 묻힙니다.

그래서 직접 만들었습니다. 응답 시간과 응답 크기를 로깅하고, 임계치를 넘으면 알림이 오도록 슬로우 쿼리 알림을 붙였습니다.

응답 크기를 같이 본 게 도움이 됐습니다. 쿼리 자체는 빠른데 결과를 과하게 들고 오는 경우가 있습니다. DB 시간으로는 안 잡히지만 메모리와 네트워크를 먹고, 결국 응답을 느리게 만듭니다.

이렇게 해서 운영 중에 몇 개를 찾아 고쳤습니다. 도구가 못 보는 구간이 있으면 그 구간을 볼 수 있는 걸 직접 만드는 수밖에 없습니다.

풀을 키우면 더 느려집니다

풀이 마르니까 풀을 키우면 되지 않냐는 생각이 자연스럽게 듭니다. 반대입니다.

커넥션은 통로가 아니라 자원입니다. MySQL은 커넥션마다 스레드를 하나 물리고 버퍼를 붙입니다. 커넥션이 수백 수천이 되면 DB가 쿼리를 처리하는 대신 스레드를 스케줄링하고 컨텍스트를 스위칭하는 데 시간을 씁니다. 락 경합도 같이 늘어납니다.

그래서 풀에는 적정 크기가 있습니다. 키운다고 좋아지지 않습니다. 진짜 해결은 쿼리를 빠르게 만들어서 커넥션을 빨리 돌려주는 것입니다. 자세한 건 커넥션은 공짜가 아니다에 따로 정리했습니다.

결과

DB CPU가 40%에서 18%로 떨어졌습니다. 사용률도 50%대에서 20% 아래로 내려갔고, 피크 시간대 커넥션 풀 고갈이 사라졌습니다. 전부 무중단으로 진행했습니다.

지금이라면

세 가지를 다르게 하겠습니다.

프로세스를 고치는 게 제일 큰 레버입니다. 쿼리를 하나씩 잡은 건 이미 생긴 문제를 치운 것이지, 다시 안 생기게 한 게 아닙니다. 마이그레이션과 PR에서 인덱스를 확인하는 절차가 있었다면 애초에 쌓이지 않았을 문제였습니다. 원인은 기술이 아니라 일하는 방식이었습니다.

커넥션 자체를 흡수하는 층을 둡니다. RDS Proxy나 ProxySQL을 앞에 두면 앱 인스턴스가 몇 대로 늘든 실제 DB 커넥션 수를 눌러둘 수 있습니다. 스파이크성 피크에 풀이 마르는 문제의 교과서적인 해법인데, 당시엔 검토하지 않았습니다.

사각지대를 손이 아니라 도구로 메웁니다. 응답 시간을 직접 로깅한 건 그때로선 맞는 선택이었지만, APM을 붙였으면 요청 단위 트레이싱으로 같은 걸 자동으로 잡았을 겁니다. Performance Insights가 DB 쪽 시야라면 APM은 앱 쪽 시야고, 둘은 서로를 대체하지 않습니다.