목록을 다루는 서비스는 언젠가 반드시 같은 벽에 부딪힌다. 처음에는 데이터가 몇 건뿐이라 통째로 내려주어도 아무 문제가 없다가, 항목이 수천 수만 건으로 불어나는 순간 그 한 번의 응답이 통신을 짓누르고 화면을 얼어붙게 만든다. 나는 목록을 조각내어 조금씩 내주는 페이지네이션을 처음에는 부수적인 편의로만 여겼는데, 데이터가 커진 시스템을 몇 번 겪고 나서야 이것이 목록형 자원 설계의 뼈대라는 것을 인정하게 되었다.
이번 편에서는 큰 목록을 어떻게 나누어 내줄지를 다룬다. 앞에서 세어 건너뛰는 오프셋 방식과 마지막 지점을 기억해 이어가는 커서 방식이 각각 어떤 원리로 동작하는지, 두 방식의 장점과 한계가 어디서 갈리는지, 다음 조각으로 넘어가는 정보를 응답에 어떻게 실을지를 살펴본다. 방식을 잘못 고르면 깊은 페이지에서 급격히 느려지거나, 목록이 바뀌는 사이에 항목이 중복되고 누락되는 미묘한 사고를 만나게 된다.
목록을 통째로 내주지 않는 이유
페이지네이션의 출발점은 하나의 응답에 담을 수 있는 양에는 실질적인 한계가 있다는 사실이다. 수만 건의 목록을 한 번에 내려주면 그 데이터가 회선을 타고 오는 동안 사용자는 빈 화면을 마주하고, 도착한 뒤에도 브라우저가 그 많은 항목을 한꺼번에 그리느라 멈칫한다. 나는 이 부담을 서버가 큰 짐을 통째로 던지고 클라이언트가 그것을 받다가 휘청이는 장면으로 떠올린다.
부담은 통신량에만 있지 않다. 서버 역시 그 큰 목록을 한 번에 만들어내려면 많은 데이터를 메모리에 올려야 하고, 그 과정에서 다른 요청을 처리할 여력이 줄어든다. 한 사람의 큰 요청이 여러 사람의 작은 요청을 지연시키는 셈이다. 목록을 잘게 나누면 각 요청이 다루는 양이 작아지므로, 서버는 일정한 부담으로 꾸준히 응답할 수 있게 된다.
게다가 사용자는 대개 목록의 앞부분만 본다. 검색 결과의 첫 화면을 훑고 원하는 것을 찾으면 뒤쪽은 열어보지도 않는다. 그런데도 전체를 미리 다 내려주는 것은, 읽지도 않을 뒷부분까지 억지로 전송하는 낭비다. 필요한 만큼만 내주고 사용자가 더 원할 때 다음 조각을 주는 편이, 대다수의 실제 사용 방식에 훨씬 잘 맞는다.
페이지네이션은 이 문제에 대한 답이다. 하나의 큰 목록을 정해진 크기의 조각으로 나누고, 요청이 올 때마다 그중 한 조각씩을 내주는 것이다. 클라이언트는 첫 조각을 받아 보여주고, 사용자가 더 원하면 다음 조각을 이어서 요청한다. 이렇게 하면 어느 한 요청도 지나치게 무거워지지 않고, 전체 목록은 여러 번의 가벼운 왕복으로 나뉘어 전달된다.
이 나눔에는 크게 두 갈래의 방식이 있다. 하나는 앞에서부터 몇 개를 건너뛰고 그다음 몇 개를 달라고 하는 오프셋 방식이고, 다른 하나는 지난번에 어디까지 받았는지를 기억했다가 그 지점부터 이어 달라고 하는 커서 방식이다. 두 방식은 겉으로는 비슷해 보여도 동작 원리와 성질이 크게 달라서, 어떤 상황에 무엇을 쓰느냐가 설계의 핵심 결정이 된다.
방식이 무엇이든 페이지네이션이 이루려는 목표는 같다. 한 번에 다루는 양을 일정하게 유지하고, 사용자가 필요한 만큼만 점진적으로 받게 하며, 조각과 조각 사이에서 항목이 빠지거나 겹치지 않게 하는 것이다. 나는 어떤 방식을 검토하든 이 세 가지 목표를 기준으로 그 방식이 무엇을 잘하고 무엇을 놓치는지를 따진다. 방식의 이름보다 그것이 이 목표들을 어떻게 지키는지가 중요하다.
오프셋과 개수로 자르는 방식
가장 직관적인 방식이 앞에서 몇 개를 건너뛰고 그다음 몇 개를 가져오는 오프셋 방식이다. 클라이언트는 몇 번째부터 시작할지를 뜻하는 offset과 몇 개를 받을지를 뜻하는 limit을 함께 보낸다. 흔히 이것을 페이지 번호와 페이지 크기로 바꿔, 두 번째 페이지의 스무 개를 원한다는 뜻으로 ?page=2&size=20처럼 표현한다. 서버는 이 값을 받아 시작 위치를 계산한다.
원리는 단순하다. 전체 목록을 정해진 순서로 늘어놓고, 앞에서 지정된 개수만큼 건너뛴 뒤 그 자리에서 원하는 개수를 잘라 내주는 것이다. 페이지 번호와 크기만 주어지면 몇 개를 건너뛰어야 하는지가 곱셈으로 바로 나오므로, 클라이언트는 원하는 어느 페이지로든 곧장 뛰어들 수 있다. 다섯 번째 페이지를 달라고 하면 앞의 네 페이지 분량을 건너뛴 지점을 계산해 그 자리를 내준다.
이 방식의 큰 장점은 임의의 페이지로 바로 이동할 수 있다는 점이다. 사용자는 첫 페이지를 거치지 않고도 곧장 마지막 페이지나 가운데 어느 페이지로든 건너뛸 수 있다. 전체 항목 수를 함께 세어 알려주면 총 몇 페이지인지도 계산되어, 페이지 번호를 나열한 이동 막대를 그리기에도 좋다. 나는 관리자가 데이터를 이리저리 훑어보는 표에는 이 자유로운 점프가 특히 잘 맞는다고 본다.
그러나 이 방식은 깊은 페이지로 갈수록 느려진다. 뒤쪽 페이지를 내주려면 그 앞의 수많은 항목을 실제로 건너뛰는 작업이 필요한데, 건너뛸 항목이 많아질수록 그 비용이 커지기 때문이다. 앞의 열 개를 건너뛰는 것과 앞의 십만 개를 건너뛰는 것은 부담이 전혀 다르다. 나는 목록이 아주 큰 시스템에서 뒤 페이지가 눈에 띄게 느려지는 현상을 만났고, 원인은 바로 이 건너뛰기 비용이었다.
더 까다로운 문제는 목록이 변할 때 생긴다. 사용자가 첫 페이지를 본 사이에 목록 앞쪽에 새 항목이 하나 끼어들면, 두 번째 페이지를 요청할 때 경계가 한 칸 밀린다. 그러면 첫 페이지의 마지막 항목이 두 번째 페이지에 다시 나타나 중복되거나, 반대로 항목이 앞으로 빠지면 하나가 건너뛰어져 누락된다. 위치를 숫자로만 가리키기 때문에, 그 위치의 내용물이 바뀌면 셈이 어긋나는 것이다.
그래서 오프셋 방식은 데이터가 자주 바뀌지 않고, 임의 페이지 점프가 필요하며, 목록이 지나치게 깊지 않은 곳에 어울린다. 관리 화면의 표나 정적인 목록이 대표적이다. 반대로 실시간으로 항목이 끊임없이 추가되는 피드나, 목록이 아주 깊어 뒤까지 훑어야 하는 경우에는 앞서 말한 두 약점이 그대로 드러난다. 나는 이 방식을 고를 때 데이터가 얼마나 자주 바뀌는지부터 먼저 확인한다.
커서로 이어가는 방식
커서 방식은 위치를 숫자가 아니라 특정 항목을 가리키는 표식으로 붙잡는다. 서버는 한 조각을 내주면서, 그 조각의 마지막 항목이 어디였는지를 나타내는 cursor라는 표식을 함께 준다. 클라이언트는 다음 조각을 원할 때 그 표식을 되돌려 보내며, 거기서부터 이어 달라고 요청한다. 나는 이 표식을 책갈피에 비유한다. 어디까지 읽었는지를 끼워두고, 다음에 그 자리에서 이어 읽는 것이다.
원리의 핵심은 건너뛰지 않는다는 데 있다. 오프셋 방식이 앞에서부터 세어 건너뛰었다면, 커서 방식은 표식이 가리키는 지점을 곧바로 찾아 그다음부터 잘라 낸다. 세어 넘어가는 과정이 없으므로, 목록의 어느 깊이에서 이어가든 비용이 일정하게 유지된다. 앞이 열 개든 십만 개든 표식이 가리키는 자리를 직접 짚기 때문에, 깊은 조각에서도 첫 조각과 같은 속도로 응답할 수 있다.
이 성질 덕분에 커서 방식은 깊은 목록에서 강하다. 아무리 뒤로 이어가도 느려지지 않으니, 끝없이 이어지는 피드를 스크롤로 무한히 내려도 성능이 무너지지 않는다. 게다가 목록 앞쪽에 새 항목이 끼어들어도, 표식은 특정 항목을 직접 가리키므로 경계가 밀려 생기는 중복이나 누락에 강하다. 나는 실시간으로 항목이 쌓이는 피드에는 거의 예외 없이 이 방식을 택한다.
대신 잃는 것도 분명하다. 커서 방식은 오직 이어가기만 가능해서, 임의의 페이지로 바로 뛰어드는 일이 안 된다. 다섯 번째 조각을 곧장 달라고 할 수 없고, 앞 조각들을 차례로 거쳐야만 거기에 닿는다. 또한 전체가 몇 조각인지, 총 항목이 몇 개인지를 미리 알기도 어렵다. 그래서 페이지 번호를 죽 나열한 이동 막대를 그리기에는 맞지 않는다. 이어보기에는 강하지만 건너뛰기에는 약한 것이다.
표식 자체는 클라이언트가 그 속을 들여다볼 수 없는 불투명한 값으로 두는 것이 좋다. 표식 안에 서버가 무엇을 담아 위치를 기억하는지는 서버의 사정이고, 클라이언트는 그저 받은 표식을 그대로 돌려주기만 하면 된다. 이렇게 해두면 나중에 서버가 위치를 기억하는 방식을 바꾸더라도 클라이언트는 아무 영향을 받지 않는다. 나는 표식을 클라이언트가 해석하거나 조립하려 들지 않도록 처음부터 불투명하게 설계한다.
두 방식을 나란히 두면 선택 기준이 또렷해진다. 임의 페이지 점프와 총 개수가 필요하고 데이터가 안정적이면 오프셋이, 깊은 목록을 일정한 성능으로 이어가야 하고 데이터가 자주 바뀌면 커서가 맞다. 나는 이 둘을 대립이 아니라 서로 다른 쓰임을 가진 도구로 본다. 한쪽이 무조건 우월한 것이 아니라, 다루는 목록의 성격이 어느 쪽을 부르는가의 문제다.
이동 정보를 응답에 싣기
어떤 방식을 쓰든, 클라이언트가 다음 조각으로 어떻게 넘어갈지를 응답이 알려주어야 한다. 클라이언트가 스스로 다음 요청의 주소를 짜맞추게 두면, 그 조립 규칙이 서버와 클라이언트 양쪽에 흩어져 어긋나기 쉽다. 그래서 나는 다음 조각과 이전 조각으로 가는 정보를 서버가 응답에 담아 내려주는 편을 선호한다. 클라이언트는 그것을 그대로 따라가기만 하면 된다.
이 이동 정보를 싣는 자리는 두 곳이다. 하나는 응답의 Link 헤더에 다음과 이전으로 가는 주소를 관계 이름과 함께 담는 방식이고, 다른 하나는 응답 본문 안에 이동에 필요한 값을 함께 넣는 방식이다. 어느 쪽이든 다음이 있는지, 있다면 어디로 가야 하는지를 클라이언트가 명확히 알 수 있으면 된다. 나는 본문에 데이터와 함께 담는 편이 다루기 쉬워 자주 쓴다.
본문에 담을 때 흔히 두는 값이 다음 조각이 더 있는지를 나타내는 표시와, 있다면 다음을 가리키는 표식이다. 다음이 더 있는지를 has_next 같은 값으로 알리고, 커서 방식이라면 next_cursor에 다음 표식을 담는다. 클라이언트는 이 표시를 보고 더 불러올지 말지를 판단하고, 표식을 그대로 다음 요청에 실어 이어간다. 이렇게 하면 언제 멈춰야 하는지가 응답 자체에 분명히 드러난다.
전체 항목 수를 함께 줄지는 신중히 정해야 한다. 총 개수를 세는 일은 목록이 클수록 비싼 작업이고, 커서 방식에서는 애초에 정확한 총수를 알기 어렵다. 총 개수가 이동 막대를 그리는 데 꼭 필요하다면 오프셋 방식과 함께 제공하되, 무한 스크롤처럼 총수가 필요 없는 화면이라면 굳이 매번 세지 않는 편이 낫다. 나는 총 개수를 당연히 주는 값이 아니라 필요할 때만 계산하는 선택적인 값으로 다룬다.
페이지네이션이 제대로 동작하려면 목록의 정렬 기준이 일관되고 안정적이어야 한다. 조각을 나누는 기준이 되는 순서가 요청마다 흔들리면, 같은 항목이 다른 조각에 나타나거나 아예 빠지는 혼란이 생긴다. 특히 정렬 값이 같은 항목들이 있을 때 그 사이의 순서까지 고정해두지 않으면 경계가 불안정해진다. 나는 정렬 기준에 항상 유일하게 구분되는 값을 하나 덧붙여, 순서가 절대 흔들리지 않게 못박아 둔다.
이런 이동 정보와 정렬 규약을 응답 쪽에 모아두면, 클라이언트의 일이 단순해진다. 클라이언트는 규칙을 스스로 알 필요 없이, 서버가 준 다음 정보를 따라가며 더 있을 때까지 이어 부르기만 하면 된다. 나는 이것을 서버가 길을 안내하고 클라이언트가 그 안내를 따르는 구조로 이해한다. 길 찾는 규칙이 한곳에 모여 있으면, 나중에 방식을 바꾸더라도 고칠 자리가 서버 한 군데로 좁혀진다.
페이지네이션 설계의 기준
페이지네이션을 설계할 때 내가 가장 먼저 던지는 질문은, 이 목록이 어떤 식으로 소비되는가이다. 사용자가 아래로 계속 스크롤하며 이어보는 피드라면 커서 방식이 자연스럽고, 관리자가 특정 페이지를 콕 집어 오가는 표라면 오프셋 방식이 편하다. 소비 방식이 방식을 부르는 것이지, 유행하는 방식을 먼저 정해놓고 목록을 거기에 끼워 맞추는 것이 아니다.
다음으로 챙기는 것이 정렬의 안정성이다. 앞서 강조했듯 조각을 나누는 순서가 흔들리면 중복과 누락이 따라온다. 그래서 나는 정렬 기준을 반드시 결정적으로 고정하고, 같은 값들 사이의 순서까지 유일한 값으로 못박는다. 이 한 가지를 소홀히 하면 페이지네이션은 대체로 잘 동작하는 듯 보이다가, 데이터가 특정한 모양이 될 때만 조용히 어긋나는 성가신 결함을 남긴다.
한 조각의 크기에는 상한을 두어야 한다. 클라이언트가 크기를 정할 수 있게 열어두더라도, 터무니없이 큰 값을 요구하면 페이지네이션의 목적이 무너진다. 나는 크기를 받되 허용 범위를 정해두고, 그 범위를 넘는 요청은 상한으로 깎아 처리한다. 이렇게 해야 한 번의 요청이 다시 목록 전체를 가져가려는 시도를 막을 수 있다. 크기를 열어주는 것과 무제한으로 두는 것은 다르다.
규약의 일관성도 중요하다. 어떤 목록은 페이지 번호를, 어떤 목록은 표식을, 또 어떤 목록은 제각각의 이름을 쓴다면 그 서비스를 다루는 쪽은 목록마다 규칙을 새로 배워야 한다. 나는 페이지네이션의 매개변수 이름과 응답에 이동 정보를 담는 형태를 서비스 전체에서 하나로 통일한다. 한 번 익히면 어느 목록에서든 같은 방식으로 다룰 수 있게 하는 것이 좋은 설계다.
이 모든 규약은 문서에 분명히 적혀 있어야 한다. 어떤 매개변수로 조각을 요청하고, 응답의 어디에 다음 정보가 담기며, 크기의 상한이 얼마인지가 문서로 드러나야 클라이언트를 만드는 쪽이 헤매지 않는다. 페이지네이션은 겉보기에 사소해 보여도, 규약이 흐릿하면 목록을 다루는 모든 화면이 조금씩 어긋난다. 나는 이 규약을 목록형 자원의 계약으로 여기고 명확히 적어둔다.
정리하면 페이지네이션은 큰 목록을 일정한 크기의 조각으로 나누어 점진적으로 내주는 설계이고, 임의 점프에 강한 오프셋 방식과 깊은 이어보기에 강한 커서 방식이 서로 다른 쓰임을 가지며, 다음 조각으로 가는 정보와 안정된 정렬 기준을 갖추어야 제대로 동작한다. 목록의 소비 방식이 방식을 부른다는 원칙만 지키면 선택은 어렵지 않다. 다음 편에서는 이런 요청이 실패했을 때 그 실패를 어떻게 알려줄지, 곧 에러 응답의 설계를 다뤄 보려 한다.