![[실전 DB 09] COUNT의 함정, 페이지네이션 재설계](https://img.thenullpage.com/posts/5701/5701_2_f4c87e.webp)
인덱스로 무거운 쿼리를 대부분 잡았는데, 여전히 읽기의 큰 조각을 차지하는 게 남아 있었다. 페이지네이션의 전체 개수 세기, 즉 카운트였다. 목록을 페이지로 나누려고 전체가 몇 개인지 세는데, 이 세기가 매번 전체를 훑는다. 인덱스로 목록 자체는 가볍게 만들었는데, 그 옆에서 개수를 세느라 또 한 번 전체를 훑고 있었던 것이다. 이번 편은 이 카운트의 함정과 페이지네이션을 재설계하는 이야기다.
이건 인덱스로 안 풀리는 구조적 문제다. 세는 것 자체가 전체를 봐야 하는 작업이라, 인덱스를 아무리 잘 걸어도 근본이 안 바뀐다. 접근 자체를 바꿔야 한다.
1. 페이지네이션이 카운트를 부른다
목록을 페이지로 나눠 보여줄 때, 흔한 방식은 몇 번째부터 몇 개를 가져오는 것이다. 두 번째 페이지면 앞의 것들을 건너뛰고 그다음 것들을 가져온다. 그리고 전체 페이지가 몇 개인지 화면에 보여주려고 전체 개수를 센다. 페이지 번호를 매기려면 전체가 몇 개인지 알아야 하기 때문이다.
문제는 이 전체 개수 세기다. 조건에 맞는 행이 정확히 몇 개인지 세려면 그 행들을 다 훑어야 한다. 목록을 보여줄 때마다 이 세기가 전체를 한 번 훑는 셈이다. 목록 자체를 가져오는 것과 별개로, 개수를 세느라 또 전체를 훑는 것이다. 그러니 목록 한 번 보여주는 데 읽기가 배로 든다.
내가 만든 사이트에서도 이 카운트가 읽기의 큰 부분을 차지했다. 목록 쿼리는 인덱스로 최적화했는데, 그 바로 옆의 개수 세기는 여전히 전체를 훑고 있었다. 측정해 보니 개수 세기 하나가 목록 쿼리보다 더 많이 훑는 경우도 있었다. 목록을 인덱스로 최적화한 효과가 이 카운트 때문에 절반으로 깎이고 있었던 것이다.
더 얄궂은 건 이 세기가 사용자에게 거의 쓸모없는 정보를 위한 것이라는 점이다. 전체가 정확히 몇천 몇백 개라는 숫자를, 사용자는 사실 눈여겨보지도 않는다. 그 별것 아닌 숫자 하나를 정확히 대려고 매번 전체를 훑고 있었다. 비용은 큰데 얻는 가치는 작은, 전형적으로 재검토해야 할 지점이었다.
한 가지 더 짚을 게 있다. 이 개수 세기는 화면의 페이지 번호를 위한 곁가지 작업이라, 목록을 보여주는 본래 목적과는 별개다. 정작 사용자가 보려는 건 목록의 내용인데, 그 옆에서 번호를 매기려고 더 큰 비용이 드는 셈이다. 본래 목적보다 곁가지가 더 비싸다면, 그 곁가지를 정말 유지할지 다시 볼 때가 된 것이다.
2. 건너뛰기 방식의 또 다른 문제
개수 세기 외에도 이 방식엔 문제가 있다. 뒤쪽 페이지로 갈수록 느려진다는 것이다. 백 번째 페이지를 보려면 앞의 아흔아홉 페이지 분량을 건너뛰어야 하는데, 이 건너뛰기가 그 앞의 것들을 하나씩 세며 지나가는 방식이다. 뒤로 갈수록 건너뛸 게 많아 점점 느려진다.
사람 사용자는 대개 첫 몇 페이지만 보니 이 문제가 잘 안 드러난다. 그런데 봇은 다르다. 봇은 뒤쪽 깊은 페이지까지 기계적으로 파고든다. 그래서 봇이 깊은 페이지를 긁을 때마다 그 앞의 방대한 분량을 건너뛰며 훑는다. 앞서 다룬 봇의 무한 페이지 문제가 이 건너뛰기 비용과 겹치면 폭발적으로 심각해진다.
내 경우 이걸 로그에서 확인했다. 사람은 거의 안 가는 수십 번째, 수백 번째 페이지 요청이 봇에서 꾸준히 들어왔고, 그 요청 하나하나가 앞부분을 통째로 훑고 있었다. 사람이 안 보는 페이지를 위해 데이터베이스가 가장 무겁게 일하는 역설이었다. 봇을 막는 것과 별개로, 방식 자체가 뒤쪽에 취약했다.
그래서 건너뛰기 방식은 두 가지 비용을 동시에 안는다. 매번 전체를 세는 개수 세기와, 뒤쪽 페이지의 건너뛰기 비용이다. 둘 다 전체나 그 앞을 훑는 방식이라 근본적으로 무겁다. 이 방식을 유지하면서 잔가지만 다듬는 최적화는 한계가 뚜렷하다. 접근 자체를 바꿔야 근본이 풀린다.
이 문제를 처음엔 잘 몰랐던 이유도 여기 있다. 나 자신은 개발하면서 첫 페이지 근처만 보니, 뒤쪽이 느리다는 걸 체감할 일이 없었다. 느린 건 사람이 안 가는 깊은 페이지였고, 거기를 파고드는 건 봇뿐이었다. 그래서 측정하기 전까지는 이 비용이 어디서 나는지조차 몰랐다. 안 보이는 곳에서 조용히 새고 있었던 것이다.
3. 개수 세기를 없애거나 캐시한다
첫 번째 해법은 개수 세기 자체를 다루는 것이다. 개수가 꼭 정확해야 하는지 되물어 보면, 대부분은 그렇지 않다. 목록에 총 몇 개라고 정확한 숫자를 보여줄 필요가 없는 경우가 많다. 그렇다면 개수 세기를 아예 없애거나, 대략의 값으로 근사할 수 있다.
또는 개수를 캐시한다. 전체 개수는 짧은 사이에 크게 안 변하니, 한 번 세서 저장해두고 그 잠깐 동안 재사용한다. 목록을 보여줄 때마다 새로 세는 대신, 저장된 개수를 읽는 것이다. 앞선 시리즈에서 다룬 캐시가 여기에도 그대로 적용된다. 자주 세지만 자주 안 바뀌는 값이라 캐시 효과가 유난히 크다.
내 경우 개수를 캐시하는 것부터 적용했다. 정확한 개수가 꼭 필요한 게 아니라, 잠깐 전의 개수여도 사용자에겐 차이가 없었다. 개수를 캐시하니 목록을 보여줄 때마다 전체를 세던 게 사라졌다. 읽기의 큰 조각이 통째로 줄었다. 화면은 하나도 안 건드리고 뒤에서만 바꾼 것이라 위험도 거의 없는, 저위험이면서 즉효인 최적화였다.
여기서 한 가지 감각을 얻었다. 최적화는 늘 더 빠른 쿼리를 짜는 것만이 아니라, 그 쿼리가 정말 필요한지 되묻는 것이기도 하다는 점이다. 꼭 필요한 줄 알았던 정확한 개수가 사실은 없어도 되는 것이었다. 일을 더 빨리 하는 것보다 일을 아예 안 하는 것이 언제나 더 빠르다.
캐시한 개수에도 짧은 유효 시간을 준다. 그 사이 새 글이 몇 개 더 생겨도 화면의 총 개수가 조금 뒤처질 뿐, 사용자는 알아채지 못한다. 정확성을 조금 양보하고 큰 비용을 던 것이다. 어느 데이터가 이런 양보를 허용하고 어느 데이터가 정확해야 하는지 가려내는 눈이 최적화의 중요한 감각이다.
4. 근본 해법은 커서 방식
더 근본적인 해법은 페이지네이션 방식 자체를 바꾸는 것이다. 건너뛰기 방식 대신 커서 방식, 즉 마지막으로 본 지점을 기준으로 그다음을 가져오는 방식이다. 이건 개수 세기도 건너뛰기도 한꺼번에 없앤다. 다음 편에서 따로 자세히 다룰 만큼 중요한 재설계다.
커서 방식은 몇 번째부터가 아니라 이 지점 이후를 가져온다. 마지막으로 본 글의 시각 이후 글들을 가져오는 식이다. 이러면 전체를 세거나 앞을 건너뛸 필요가 없다. 인덱스로 그 지점을 바로 찾아 그다음을 읽는다. 그래서 뒤쪽 페이지도 앞쪽만큼 빠르다. 뒤로 갈수록 느려지던 문제가 사라지는 것이다.
다만 커서 방식은 사용자 화면의 페이지 번호를 바꿔야 할 수 있다. 몇 페이지 몇 페이지 하는 번호 대신 더보기나 무한 스크롤로 가는 경우가 많기 때문이다. 화면을 손봐야 하니 개수 캐시보다는 부담이 크다. 그래서 저위험 해법을 먼저 적용하고, 커서 방식은 화면까지 손볼 여유가 있을 때 하는 단계적 접근이 현실적이다.
그래서 두 해법은 대립하지 않고 이어진다. 개수 캐시로 세기 비용을 먼저 없애고, 커서 방식으로 건너뛰기 비용을 나중에 없애는 것이다. 둘을 다 적용하면 페이지네이션의 두 가지 비용이 모두 사라진다. 순서만 다를 뿐, 종착지는 개수도 안 세고 앞도 안 건너뛰는 가벼운 목록이다.
5. 단계적으로 접근한다
페이지네이션 재설계는 단계적으로 접근하는 게 안전하다. 1단계로 개수 세기를 캐시하거나 없애 즉효를 본다. 이건 화면을 안 건드리고 뒤에서만 바꾸니 위험이 낮다. 2단계로 여유가 되면 커서 방식으로 전환해 건너뛰기 비용까지 없앤다. 이건 화면 변경이 따르니 신중히 준비해서 한다.
이 단계적 접근이 최적화의 지혜다. 큰 재설계를 한 번에 하려다 위험을 감수하기보다, 저위험 즉효부터 하고 근본 해법은 여유 있게 준비하는 것이다. 내 경우 개수 캐시만으로도 읽기가 크게 줄었고, 커서 전환은 그 위에 얹는 추가 개선이었다. 급한 불부터 끄고 근본을 다지는 순서가 사고를 줄인다.
돌아보면 이 편의 교훈은 무거운 걸 더 빨리 하려 애쓰기 전에, 그게 꼭 필요한지 먼저 묻는 것이었다. 정확한 전체 개수라는 당연해 보이던 요구를 의심하니 큰 비용이 통째로 사라졌다. 최적화는 답을 빨리 구하는 기술이기도 하지만, 애초에 안 물어도 될 질문을 걷어내는 기술이기도 하다.
정리하면 페이지네이션의 개수 세기와 건너뛰기는 전체를 훑는 무거운 작업이니, 개수를 캐시하거나 없애는 저위험 해법을 먼저 적용하고 커서 방식으로 근본 재설계를 단계적으로 한다. 다음 편에서는 그 근본 해법인 커서 페이지네이션이 어떻게 동작하고 왜 효율적인지를 자세히 다룬다.