들어가며
대부분의 개발자들이 한 번쯤은 구현해봤을 Todo 리스트. 단순해 보이는 이 기능도 데이터가 수만 건으로 늘어나면 어떻게 될까요? 오늘은 Todo 리스트 애플리케이션을 예시로, 페이지네이션 구현 방식의 발전 과정을 실제 코드와 함께 살펴보겠습니다.
1. 가장 흔한 구현: 오프셋 페이지네이션
먼저 가장 일반적인 오프셋 기반 페이지네이션의 구현을 살펴보겠습니다.
1.1 데이터 모델
@Entity
data class Todo(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val title: String,
val content: String,
val status: TodoStatus,
@CreatedDate
val createdAt: LocalDateTime = LocalDateTime.now()
)
enum class TodoStatus {
TODO, IN_PROGRESS, DONE
}
1.2 기본적인 오프셋 페이지네이션 구현
@Service
class TodoService(
private val todoRepository: TodoRepository
) {
fun getTodos(page: Int, size: Int): Page<Todo> {
val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending())
return todoRepository.findAll(pageable)
}
}
@RestController
@RequestMapping("/api/todos")
class TodoController(
private val todoService: TodoService
) {
@GetMapping
fun getTodos(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ResponseEntity<Page<TodoResponse>> {
val todos = todoService.getTodos(page, size)
return ResponseEntity.ok(todos)
}
}
이 구현의 문제점은 데이터가 많아질수록 뚜렷하게 나타납니다.
SELECT * FROM todo
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;
이 쿼리의 실제 DB 동작을 살펴보면 여러 가지 성능 문제가 드러납니다:
-
전체 테이블 스캔
- DB는 OFFSET 10000을 처리하기 위해 처음부터 10,020개의 행을 순차적으로 읽어야 합니다
- created_at에 인덱스가 있더라도, 10,020개의 인덱스 엔트리를 스캔해야 합니다
- 이는 OFFSET 값이 커질수록 불필요한 I/O가 선형적으로 증가함을 의미합니다
-
버퍼 풀 효율성 저하
- MySQL의 경우, InnoDB 버퍼 풀에서 이미 읽은 10,000개의 레코드를 모두 버퍼링해야 합니다
- 이는 메모리 사용량을 증가시키고 버퍼 풀의 효율성을 떨어뜨립니다
- 특히 높은 OFFSET 값으로 자주 접근하는 경우, 버퍼 풀 pollution이 발생할 수 있습니다
-
실행 계획의 비효율성
EXPLAIN SELECT * FROM todo ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
실행 계획을 보면:
- 인덱스를 사용하더라도 ‘Using filesort’ 발생
- rows 추정치가 OFFSET + LIMIT 만큼 표시됨
- Extra 컬럼에서 대량의 임시 테이블 사용 가능성 확인
여기서 ‘Using filesort’는 MySQL이 원하는 순서로 결과를 정렬하기 위해 추가적인 정렬 작업을 수행함을 의미합니다:
-
메모리 사용
- MySQL은 정렬을 위해 sort_buffer_size 설정값 만큼의 메모리를 할당
- 정렬할 데이터가 sort_buffer_size를 초과하면 디스크를 사용
- 이는 임시 파일 생성과 디스크 I/O를 발생시킴
-
정렬 알고리즘
- 단일 패스(Single-pass): 전체 행을 sort buffer에 로드
- 투 패스(Two-pass): 정렬 키와 행 포인터만 정렬 후 다시 데이터를 읽음
- 데이터량이 많을수록 투 패스 방식이 사용될 가능성이 높아짐
이러한 filesort 작업은 OFFSET이 클수록 더 많은 리소스를 소비하게 됩니다.
이러한 이유로 OFFSET이 증가할수록 쿼리 실행 시간은 선형적으로 증가하며, 특히 동시 접속자가 많은 서비스에서는 DB 부하가 급격히 증가할 수 있습니다.
2. 오프셋 페이지네이션 최적화 기법들
오프셋 방식을 완전히 교체하기 전에 시도해볼 수 있는 다양한 최적화 방법들을 살펴보겠습니다.
2.1 카운트 쿼리 최적화
@Service
class TodoService(
private val todoRepository: TodoRepository,
private val cacheManager: CacheManager
) {
@Cacheable(value = ["todo-count"])
fun getTotalCount(): Long {
return todoRepository.count()
}
fun getTodos(page: Int, size: Int): PageResponse<Todo> {
val totalCount = getTotalCount()
val todos = todoRepository.findAllByOrderByCreatedAtDesc(
PageRequest.of(page, size)
)
return PageResponse(
content = todos.content,
totalPages = ceil(totalCount.toDouble() / size).toInt(),
currentPage = page,
hasNext = todos.hasNext()
)
}
}
2.2 인덱스를 활용한 범위 스캔
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
// ID 기반 범위 쿼리로 변경
@Query("""
SELECT t FROM Todo t
WHERE t.id <= (SELECT t2.id FROM Todo t2 ORDER BY t2.id DESC LIMIT 1 OFFSET :offset)
ORDER BY t.id DESC
LIMIT :limit
""")
fun findAllWithIdRange(
@Param("offset") offset: Int,
@Param("limit") limit: Int
): List<Todo>
}
2.3 No COUNT 최적화
data class NoCountPageResponse<T>(
val content: List<T>,
val hasNext: Boolean,
val currentPage: Int
)
@Service
class TodoService(
private val todoRepository: TodoRepository
) {
fun getTodosNoCount(page: Int, size: Int): NoCountPageResponse<Todo> {
// size + 1개를 조회하여 다음 페이지 존재 여부 확인
val todos = todoRepository.findAllByOrderByCreatedAtDesc(
PageRequest.of(page, size + 1)
)
val hasNext = todos.size > size
val content = if (hasNext) todos.dropLast(1) else todos
return NoCountPageResponse(
content = content,
hasNext = hasNext,
currentPage = page
)
}
}
2.4 서브쿼리를 활용한 성능 개선
@Query("""
WITH offset_todo AS (
SELECT id
FROM todo
ORDER BY created_at DESC
LIMIT 1 OFFSET :offset
)
SELECT t.*
FROM todo t, offset_todo ot
WHERE t.created_at >= (
SELECT created_at
FROM todo
WHERE id = ot.id
)
ORDER BY t.created_at DESC
LIMIT :limit
""")
fun findAllWithOptimizedOffset(
@Param("offset") offset: Int,
@Param("limit") limit: Int
): List<Todo>
2.5 복합 인덱스 활용
@Entity
@Table(indexes = [
Index(name = "idx_status_created_at",
columnList = "status,created_at")
])
data class Todo(
// ... 기존 필드들
)
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
// 복합 인덱스를 활용한 쿼리
fun findByStatusOrderByCreatedAtDesc(
status: TodoStatus,
pageable: Pageable
): Page<Todo>
}
이러한 최적화 기법들은 각각 장단점이 있습니다:
-
카운트 쿼리 최적화
- 장점: 전체 카운트 연산 부하 감소
- 단점: 캐시 정확도 저하 가능성
-
인덱스 범위 스캔
- 장점: 불필요한 레코드 스캔 감소
- 단점: 특정 정렬 조건에서만 효과적
-
No COUNT 최적화
- 장점: 카운트 쿼리 제거로 인한 성능 향상
- 단점: 전체 페이지 수 제공 불가
-
서브쿼리 최적화
- 장점: 대용량 데이터에서도 안정적인 성능
- 단점: 쿼리 복잡도 증가
-
복합 인덱스
- 장점: 특정 조건에서 매우 효율적
- 단점: 인덱스 크기 증가, 제한적인 사용성
하지만 이러한 최적화에도 불구하고, 데이터가 매우 큰 경우에는 여전히 성능 문제가 발생할 수 있습니다. 이는 오프셋 방식의 근본적인 한계 때문입니다.
3. 커서 기반 페이지네이션으로의 전환
이제 커서 기반 페이지네이션을 구현해보겠습니다.
data class CursorRequest(
val size: Int,
val cursor: LocalDateTime? = null
)
data class CursorResponse<T>(
val content: List<T>,
val cursor: LocalDateTime?,
val hasNext: Boolean
)
@Service
class TodoService(
private val todoRepository: TodoRepository
) {
fun getTodosWithCursor(request: CursorRequest): CursorResponse<Todo> {
val todos = if (request.cursor != null) {
todoRepository.findByCreatedAtLessThanOrderByCreatedAtDesc(
request.cursor,
PageRequest.of(0, request.size + 1)
)
} else {
todoRepository.findAllByOrderByCreatedAtDesc(
PageRequest.of(0, request.size + 1)
)
}
val hasNext = todos.size > request.size
val content = if (hasNext) todos.dropLast(1) else todos
return CursorResponse(
content = content,
cursor = content.lastOrNull()?.createdAt,
hasNext = hasNext
)
}
}
이 방식의 장점은 다음과 같습니다:
- 성능이 일정함 (데이터 크기와 무관)
- 데이터 중복/누락 없음
- 실시간 데이터 추가에 강함
4. 성능 비교와 분석
오프셋 기반과 커서 기반 페이지네이션의 성능 차이는 각 방식의 근본적인 동작 원리에서 발생합니다.
4.1 데이터 접근 패턴의 차이
오프셋 기반
- 매 요청마다 처음부터 OFFSET + LIMIT 만큼의 레코드를 순차적으로 스캔
- OFFSET이 클수록 스캔해야 하는 레코드 수가 선형적으로 증가
- 인덱스를 사용하더라도 건너뛰어야 할 레코드를 모두 읽어야 함
커서 기반
- 이전 결과의 마지막 레코드를 기준으로 그 다음 LIMIT 개수만큼만 조회
- 페이지 위치와 관계없이 항상 동일한 수의 레코드만 스캔
- 인덱스를 통한 범위 스캔으로 효율적인 데이터 접근
4.2 시스템 리소스 사용
오프셋 기반
-
메모리 사용
- OFFSET + LIMIT 만큼의 레코드를 버퍼에 유지
- 정렬이 필요한 경우 큰 크기의 정렬 버퍼 필요
- 임시 테이블 생성 가능성 높음
-
디스크 I/O
- 페이지 번호가 증가할수록 더 많은 인덱스 엔트리를 스캔
- 스캔한 모든 인덱스 엔트리에 대해 실제 데이터를 읽어야 함
- 예: OFFSET 10000인 경우, 10020개의 인덱스 엔트리를 스캔하고 그에 따른 실제 데이터 블록을 읽어야 함
커서 기반
-
메모리 사용
- 항상 일정한 크기의 버퍼만 필요
- 정렬된 인덱스 활용으로 추가 정렬 작업 불필요
- 임시 테이블 생성 가능성 낮음
-
디스크 I/O
- 이전 커서 위치에서부터 LIMIT 개수만큼만 스캔
- 예: LIMIT 20인 경우, 20개의 인덱스 엔트리만 스캔하고 그에 대한 실제 데이터만 읽으면 됨
- 결과적으로 페이지 위치와 관계없이 항상 동일한 수의 인덱스 엔트리와 데이터 블록만 읽음
4.3 동시성 처리 특성
오프셋 기반
- 긴 시간 동안 많은 레코드를 스캔하면서 다른 트랜잭션과의 충돌 가능성 증가
- 데이터 변경이 빈번한 경우 페이지 간 데이터 중복이나 누락 발생 가능
- 테이블 락이나 갭 락의 영향을 받기 쉬움
커서 기반
- 최소한의 레코드만 접근하여 락 경합 최소화
- 데이터 변경이 있어도 커서 기준으로 일관된 결과 제공
- 짧은 시간 내에 트랜잭션 완료 가능
4.4 확장성(Scalability) 특성
오프셋 기반
- 데이터 증가에 따라 성능이 선형적으로 저하
- 페이지 번호가 클수록 더 많은 시스템 리소스 필요
- 캐시 효율성이 떨어짐
커서 기반
- 데이터 크기와 무관하게 일정한 성능 유지
- 시스템 리소스 사용량 예측 가능
- 캐시 효율적 활용 가능
이러한 원리적인 차이로 인해, 특히 대규모 데이터셋이나 높은 동시성이 요구되는 환경에서는 커서 기반 방식이 더 안정적인 성능을 제공할 수 있습니다.
5. 페이지네이션 트레이드 오프
성능과 UX의 균형
- 전통적 페이지네이션: 전체 데이터 파악과 직접 이동이 가능하지만 성능 저하
- 무한 스크롤: 성능은 좋지만 현재 위치 파악이 어렵고 공유가 힘듦
- 최적의 방법은 서비스의 성격과 사용자 패턴에 따라 다름
- 예: 검색 결과는 전통적 방식이, SNS 피드는 무한 스크롤이 더 적합
데이터 일관성과 성능
- 오프셋 방식: 데이터 추가/삭제 시 중복이나 누락 발생 가능
- 커서 방식: 일관성은 보장되나 구현이 복잡하고 이전 페이지 구현이 까다로움
- 데이터의 실시간성이 중요한 경우 커서 방식이 더 유리
마치며
페이지네이션은 단순해 보이지만, 확장성과 성능을 모두 고려해야 하는 중요한 기능입니다. 특히 데이터가 증가할수록 초기 설계의 중요성이 더욱 부각됩니다. 이번 Todo 리스트 예제를 통해 실제 프로덕션 환경에서 고려해야 할 다양한 측면들을 살펴보았습니다.
커서 기반 페이지네이션으로의 전환이 모든 상황에서 최선은 아닐 수 있습니다. 하지만 데이터 증가에 따른 성능 저하를 고민하고 계시다면, 이 글이 좋은 참고자료가 되길 바랍니다.