API Pagination 전략 (Offset, Cursor, 성능최적화)

API 개발 문서를 보면 "Pagination을 적용하면 성능이 좋아집니다"라는 설명을 쉽게 찾을 수 있습니다. 저도 처음에는 그냥 그런가 보다 하고 넘어갔는데, 막상 대량의 로그 데이터를 다루는 API를 운영하면서 생각이 완전히 바뀌었습니다. Pagination은 단순히 성능을 높이는 도구가 아니라, 시스템 전체의 안정성과 사용자 경험을 동시에 결정하는 핵심 설계 요소였습니다. 일반적으로 알려진 장점만큼이나 실무에서 부딪히는 제약도 분명히 존재했고, 그 균형을 맞추는 과정이 결코 쉽지 않았습니다.

대량 데이터 처리와 응답 속도 안정화

API에서 수만 건의 데이터를 한 번에 전달하려고 하면 어떤 일이 벌어질까요? 서버는 모든 데이터를 메모리에 올려야 하고, 네트워크는 거대한 응답 패킷을 전송해야 하며, 클라이언트는 이 모든 걸 받아서 처리해야 합니다. 결과적으로 응답 시간이 수십 초까지 늘어나고, 최악의 경우 타임아웃이 발생합니다. Pagination은 이런 문제를 근본적으로 해결합니다. 데이터를 일정한 단위로 쪼개서 제공하기 때문에 서버는 매번 처리해야 할 데이터 양이 일정하게 유지되고, 응답 속도도 안정적으로 관리할 수 있습니다.

제가 운영했던 서비스에서는 초기에 로그 조회 API가 Pagination 없이 구현되어 있었습니다. 데이터가 적을 때는 문제가 없었지만, 시간이 지나면서 로그가 누적되자 응답 시간이 점점 길어졌습니다. 특히 특정 시간대에 여러 클라이언트가 동시에 요청을 보내면 서버 CPU 사용률이 급증하면서 전체 서비스가 느려지는 현상까지 발생했습니다. Pagination을 도입한 후에는 한 번에 100건씩만 조회하도록 제한했고, 응답 시간이 평균 3초에서 0.5초 이하로 줄어들었습니다. 서버 부하도 눈에 띄게 감소했습니다.

다만 여기서 중요한 건 페이지당 데이터 개수를 어떻게 설정하느냐입니다. 너무 적게 설정하면 클라이언트가 반복 요청을 많이 해야 하고, 너무 많이 설정하면 Pagination의 의미가 퇴색됩니다. 일반적으로 사용자 목록은 20~50건, 검색 결과는 10~20건, 로그 데이터는 100~200건 정도가 적절하다고 알려져 있지만, 이건 데이터 구조와 네트워크 환경에 따라 완전히 달라집니다. 실제로 저는 여러 번의 테스트를 거쳐서 최적값을 찾아야 했습니다.

Offset 방식과 Cursor 방식의 실무적 차이

Pagination을 구현하는 방식은 크게 두 가지입니다. Offset 기반 방식과 Cursor 기반 방식입니다. Offset 방식은 "몇 번째 데이터부터 몇 개를 가져올지"를 지정하는 방식으로, 구현이 직관적이고 간단합니다. 예를 들어 페이지 2를 요청하면 offset=20, limit=10 같은 파라미터를 보내면 됩니다. 반면 Cursor 방식은 마지막으로 조회한 데이터의 고유 식별자를 기준으로 다음 데이터를 가져오는 방식입니다. 예를 들어 마지막 항목의 ID가 1234라면, 그 다음 요청에서는 cursor=1234를 보내서 그 이후 데이터를 조회합니다.

처음에 저는 Offset 방식으로 구현했습니다. 코드가 단순했고, 프론트엔드 개발자들도 쉽게 이해할 수 있었습니다. 하지만 데이터가 수십만 건으로 늘어나면서 문제가 드러났습니다. Offset이 클수록 데이터베이스가 건너뛰어야 할 행이 많아지면서 조회 속도가 급격히 느려진 것입니다. 특히 페이지 100번대를 조회할 때는 응답 시간이 수 초까지 늘어났습니다. 데이터베이스 인덱스를 최적화해도 근본적인 한계가 있었습니다.

결국 Cursor 기반 방식으로 전환했습니다. 마지막 조회 항목의 ID와 생성 시간을 조합해서 cursor 값으로 사용했고, 데이터베이스 쿼리는 "WHERE id > :cursor_id ORDER BY id ASC LIMIT 100" 같은 형태로 바뀌었습니다. 이 방식은 offset이 아무리 커져도 성능이 일정하게 유지되었습니다. 다만 클라이언트 입장에서는 구현이 복잡해졌습니다. 특정 페이지로 바로 이동하는 기능을 구현하기 어려워졌고, 이전 페이지로 돌아가는 로직도 별도로 처리해야 했습니다. 그래서 API 문서에 예제 코드를 추가하고, 프론트엔드 팀과 여러 차례 논의를 거쳐 구현 방법을 공유했습니다.

  1. Offset 방식: 구현 간단, 페이지 이동 자유로움, 대량 데이터 환경에서 성능 저하
  2. Cursor 방식: 성능 안정적, 실시간 데이터 변경에 강함, 클라이언트 구현 복잡도 증가
  3. Keyset Pagination: Cursor의 변형으로 복합 키 사용, 정렬 기준 다양화 가능

데이터 접근 제약과 클라이언트 구현 부담

Pagination의 가장 큰 단점은 전체 데이터를 한눈에 볼 수 없다는 점입니다. 사용자가 전체 데이터 현황을 파악하려면 모든 페이지를 순회해야 하고, 이 과정에서 네트워크 요청이 반복됩니다. 특히 데이터 분석이나 백업 작업처럼 전체 데이터 접근이 필요한 경우에는 Pagination이 오히려 비효율적일 수 있습니다. 제가 만난 일부 클라이언트 개발자들은 "차라리 전체 데이터를 한 번에 받는 옵션을 주면 안 되냐"는 요청을 하기도 했습니다.

또한 클라이언트 측에서는 페이지 관리 로직을 구현해야 합니다. 다음 페이지를 요청하기 위한 파라미터 관리, 이전 페이지로 돌아가는 기능, 여러 페이지 데이터를 하나로 병합하는 처리 등이 모두 추가 작업입니다. 특히 무한 스크롤 UI를 구현할 때는 스크롤 이벤트와 API 요청을 동기화해야 하고, 중복 요청을 방지하는 로직도 필요합니다. 솔직히 이건 예상보다 복잡한 작업이었고, 초기 개발 단계에서 버그가 자주 발생했습니다.

그래서 API를 설계할 때 두 가지를 함께 고려해야 합니다. 첫째, 일반적인 조회에는 Pagination을 적용하되 관리자용 내부 API에는 전체 조회 옵션을 별도로 제공했습니다. 물론 이 경우에도 최대 조회 건수를 제한해서 시스템 부하를 방지했습니다. 둘째, 클라이언트 개발자들이 쉽게 사용할 수 있도록 공통 라이브러리를 제공했습니다. Pagination 처리를 자동화해주는 헬퍼 함수를 만들어서 반복 코드를 줄여줬고, 이를 통해 개발 부담을 상당히 낮출 수 있었습니다. 최근에는 GraphQL의 Relay 스타일 커넥션이나 REST API의 HAL 같은 표준화된 형식도 등장했는데, 새로운 프로젝트에서는 이런 방식을 적용해볼 계획입니다(출처: Relay 공식 문서).

결국 Pagination 전략은 성능과 사용성 사이의 균형을 찾는 과정입니다. 서버 입장에서는 분명히 효율적인 선택이지만, 클라이언트 입장에서는 추가 작업이 필요합니다. 이 경험을 통해 API 설계가 단순히 기술적 구현만이 아니라 사용하는 사람의 관점까지 고려해야 한다는 점을 배웠습니다. Offset과 Cursor 중 어떤 방식을 선택할지, 페이지당 몇 건을 제공할지, 전체 조회 옵션을 제공할지 여부는 모두 서비스 특성과 데이터 규모에 따라 달라집니다. 중요한 건 선택의 근거를 명확히 하고, 그에 따른 제약을 클라이언트와 충분히 공유하는 것입니다.

댓글

이 블로그의 인기 게시물

HTTP 메서드의 필요성 (GET과 POST, PUT과 DELETE, API 보안)

API 없는 세상의 불편함 (로그인 연동, 서비스 구조, 디지털 인프라)

API 이해하기 (서비스 연결, 시스템 협력, 디지털 구조)