Skip to content

페이지네이션 개선기: Todo 리스트로 알아보는 효율적인 데이터 조회 방법

Published:

들어가며

대부분의 개발자들이 한 번쯤은 구현해봤을 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 동작을 살펴보면 여러 가지 성능 문제가 드러납니다:

  1. 전체 테이블 스캔

    • DB는 OFFSET 10000을 처리하기 위해 처음부터 10,020개의 행을 순차적으로 읽어야 합니다
    • created_at에 인덱스가 있더라도, 10,020개의 인덱스 엔트리를 스캔해야 합니다
    • 이는 OFFSET 값이 커질수록 불필요한 I/O가 선형적으로 증가함을 의미합니다
  2. 버퍼 풀 효율성 저하

    • MySQL의 경우, InnoDB 버퍼 풀에서 이미 읽은 10,000개의 레코드를 모두 버퍼링해야 합니다
    • 이는 메모리 사용량을 증가시키고 버퍼 풀의 효율성을 떨어뜨립니다
    • 특히 높은 OFFSET 값으로 자주 접근하는 경우, 버퍼 풀 pollution이 발생할 수 있습니다
  3. 실행 계획의 비효율성

    EXPLAIN SELECT * FROM todo ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

    실행 계획을 보면:

    • 인덱스를 사용하더라도 ‘Using filesort’ 발생
    • rows 추정치가 OFFSET + LIMIT 만큼 표시됨
    • Extra 컬럼에서 대량의 임시 테이블 사용 가능성 확인

여기서 ‘Using filesort’는 MySQL이 원하는 순서로 결과를 정렬하기 위해 추가적인 정렬 작업을 수행함을 의미합니다:

  1. 메모리 사용

    • MySQL은 정렬을 위해 sort_buffer_size 설정값 만큼의 메모리를 할당
    • 정렬할 데이터가 sort_buffer_size를 초과하면 디스크를 사용
    • 이는 임시 파일 생성과 디스크 I/O를 발생시킴
  2. 정렬 알고리즘

    • 단일 패스(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>
}

이러한 최적화 기법들은 각각 장단점이 있습니다:

  1. 카운트 쿼리 최적화

    • 장점: 전체 카운트 연산 부하 감소
    • 단점: 캐시 정확도 저하 가능성
  2. 인덱스 범위 스캔

    • 장점: 불필요한 레코드 스캔 감소
    • 단점: 특정 정렬 조건에서만 효과적
  3. No COUNT 최적화

    • 장점: 카운트 쿼리 제거로 인한 성능 향상
    • 단점: 전체 페이지 수 제공 불가
  4. 서브쿼리 최적화

    • 장점: 대용량 데이터에서도 안정적인 성능
    • 단점: 쿼리 복잡도 증가
  5. 복합 인덱스

    • 장점: 특정 조건에서 매우 효율적
    • 단점: 인덱스 크기 증가, 제한적인 사용성

하지만 이러한 최적화에도 불구하고, 데이터가 매우 큰 경우에는 여전히 성능 문제가 발생할 수 있습니다. 이는 오프셋 방식의 근본적인 한계 때문입니다.

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
        )
    }
}

이 방식의 장점은 다음과 같습니다:

  1. 성능이 일정함 (데이터 크기와 무관)
  2. 데이터 중복/누락 없음
  3. 실시간 데이터 추가에 강함

4. 성능 비교와 분석

오프셋 기반과 커서 기반 페이지네이션의 성능 차이는 각 방식의 근본적인 동작 원리에서 발생합니다.

4.1 데이터 접근 패턴의 차이

오프셋 기반

커서 기반

4.2 시스템 리소스 사용

오프셋 기반

  1. 메모리 사용

    • OFFSET + LIMIT 만큼의 레코드를 버퍼에 유지
    • 정렬이 필요한 경우 큰 크기의 정렬 버퍼 필요
    • 임시 테이블 생성 가능성 높음
  2. 디스크 I/O

    • 페이지 번호가 증가할수록 더 많은 인덱스 엔트리를 스캔
    • 스캔한 모든 인덱스 엔트리에 대해 실제 데이터를 읽어야 함
    • 예: OFFSET 10000인 경우, 10020개의 인덱스 엔트리를 스캔하고 그에 따른 실제 데이터 블록을 읽어야 함

커서 기반

  1. 메모리 사용

    • 항상 일정한 크기의 버퍼만 필요
    • 정렬된 인덱스 활용으로 추가 정렬 작업 불필요
    • 임시 테이블 생성 가능성 낮음
  2. 디스크 I/O

    • 이전 커서 위치에서부터 LIMIT 개수만큼만 스캔
    • 예: LIMIT 20인 경우, 20개의 인덱스 엔트리만 스캔하고 그에 대한 실제 데이터만 읽으면 됨
    • 결과적으로 페이지 위치와 관계없이 항상 동일한 수의 인덱스 엔트리와 데이터 블록만 읽음

4.3 동시성 처리 특성

오프셋 기반

커서 기반

4.4 확장성(Scalability) 특성

오프셋 기반

커서 기반

이러한 원리적인 차이로 인해, 특히 대규모 데이터셋이나 높은 동시성이 요구되는 환경에서는 커서 기반 방식이 더 안정적인 성능을 제공할 수 있습니다.

5. 페이지네이션 트레이드 오프

성능과 UX의 균형

데이터 일관성과 성능

마치며

페이지네이션은 단순해 보이지만, 확장성과 성능을 모두 고려해야 하는 중요한 기능입니다. 특히 데이터가 증가할수록 초기 설계의 중요성이 더욱 부각됩니다. 이번 Todo 리스트 예제를 통해 실제 프로덕션 환경에서 고려해야 할 다양한 측면들을 살펴보았습니다.

커서 기반 페이지네이션으로의 전환이 모든 상황에서 최선은 아닐 수 있습니다. 하지만 데이터 증가에 따른 성능 저하를 고민하고 계시다면, 이 글이 좋은 참고자료가 되길 바랍니다.


다음 글
객체지향 설계의 본질: 변화에 대응하는 코드 작성하기