Home Streaming vs Keyset Paging
Post
Cancel

Streaming vs Keyset Paging


Streaming과 Keyset Paging의 선택 기준을 어떻게 세울지 고민하게 됐고, 생각을 정리하기 위해 글을 작성하게 되었습니다.




1. 대량 데이터를 읽는 두 가지 방식


데이터를 대량으로 읽을 때는 읽기 상태를 어디에서 관리하는지 에 따라 접근 방식이 달라집니다. 크게 데이터 소스가 읽기 상태를 유지하는 방식과, 애플리케이션이 쿼리 조건을 통해 읽기 상태를 재구성하는 두 가지 방식이 있습니다.

OFFSET 기반 페이징 방식은 페이지가 뒤로 갈수록 성능이 저하되므로 생략하겠습니다.



1-1. Streaming

쿼리를 한 번 실행한 뒤, 데이터베이스가 결과를 한 행씩 클라이언트로 흘려보내고, 애플리케이션은 이를 순차적으로 소비합니다. 애플리케이션은 다음 레코드를 요청할 뿐이며, 읽기 상태는 데이터 소스가 관리합니다. 조회가 끝날 때까지 ResultSet과 커넥션이 유지됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun findOrdersByStream(
    from: LocalDateTime,
    to: LocalDateTime,
    consumer: (Order) -> Unit
) {
    val sql = """
        SELECT id, shop_id, created_at, amount
        FROM orders
        WHERE created_at BETWEEN ? AND ?
        ORDER BY created_at ASC
    """.trimIndent()

    jdbcTemplate.query({ con ->
        con.prepareStatement(sql, TYPE_FORWARD_ONLY, CONCUR_READ_ONLY).apply {
            setObject(1, from)
            setObject(2, to)
            fetchSize = Integer.MIN_VALUE
        }
    }) { rs ->
        consumer(rs.toOrder())
    }
}




1-2. Keyset Paging

쿼리를 한 번만 실행하는 것이 아니라, 마지막으로 조회한 값을 기준으로 조건을 변경해 여러 번 쿼리를 실행합니다. 데이터베이스는 이전 읽기 위치를 유지하지 않으며, 읽기 상태는 애플리케이션이 마지막 값을 기억해 다음 조회 조건에 반영합니다. 각 조회는 독립적으로 수행되며, 쿼리가 끝날 때마다 ResultSet과 커넥션은 반환됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun findOrdersAfter(lastId: Long?, size: Int): List<Order> {
    val sql = """
        SELECT id, shop_id, created_at, amount
        FROM orders
        ${if (lastId != null) "WHERE id > ?" else ""}
        ORDER BY id ASC
        LIMIT ?
    """.trimIndent()

    return jdbcTemplate.query(sql, { ps ->
        var idx = 1
        if (lastId != null) ps.setLong(idx++, lastId)
        ps.setInt(idx, size)
    }) { rs, _ ->
        rs.toOrder()
    }
}




데이터 조회 방식에 대해서는 많은 자료가 있기 때문에 간단히만 설명하고 넘어가겠습니다. 아직 이 개념을 모른다면 아래 포스팅들을 참조해 주세요.





2. 어떤 방식을 언제 선택해야 할까?


Streaming은 정렬 비용이 많이 들고, 복합 조건이 많으며, 별도 배치 서버에서 단독 실행할 때 적합합니다. 혹은 XLSX 다운로드처럼 응답 스트림에 바로 흘려보내는 작업에도 유리합니다. Keyset Paging은 PK 기반 정렬이 가능하고, 장애 복구가 필요하며, 공용 커넥션 풀 환경에서 실행되는 작업에 적합합니다. 장시간 실행 배치라면 운영 안정성 측면에서 더 안전한 선택입니다.



2-1. 장애 복구가 필요한가

중간에 애플리케이션이 종료되었을 때, 이전 처리 지점부터 반드시 재시작해야 한다면 Keyset Paging이 더 적절합니다. 마지막으로 처리한 ID를 저장해두면 해당 지점부터 다시 조회할 수 있기 때문입니다.

1
2
3
// 장애 발생 시, 마지막 처리 ID부터 재시작
val lastProcessedId = checkpointStore.load() // 저장해둔 마지막 ID
val orders = findOrdersAfter(lastProcessedId, 1_000)



반대로 처음부터 다시 처리해도 무방한 작업이라면 Streaming도 충분히 고려할 수 있습니다. Streaming은 하나의 쿼리 실행 컨텍스트 안에서 결과를 순차적으로 소비하는 구조이기 때문에 중간 지점부터 재시작하기가 어렵습니다. 항상 처음부터 작업을 시작해야 하죠.

물론 이를 위해서는 멱등성이 보장되어야 하며, 작업 처리 시간은 매 번 처음부터겠죠? 🤔




2-2. 정렬 비용이 반복되어도 괜찮은가

복합 조건 정렬이나 범위 조건으로 인해 filesort가 발생하는 경우, Keyset Paging은 페이지 수만큼 정렬을 반복합니다. 물론 ORDER BY 칼럼이 인덱스를 정확히 타고 있다면 filesort가 발생하지 않습니다.

1
2
3
4
5
-- Keyset Paging: 정렬이 페이지마다 반복
SELECT * FROM orders
WHERE created_at > ? AND status IN ('PAID', 'PARTIAL')
ORDER BY created_at ASC, id ASC
LIMIT 1000;




매 번 정렬을 하기 때문에 데이터가 많고 페이지가 많을수록 이 비용은 커집니다.

1
2
3
4
5
6
7
8
9
10
11
[ App ]
   │
   ▼
request page1 (lastId = null)
   │
   ▼
[ DB ]
   └─ sort   # 1회차 정렬
        │
        ▼
     return 1000 rows
1
2
3
4
5
6
7
8
9
10
11
[ App ]
   │
   ▼
request page2 (lastId = 1000)
   │
   ▼
[ DB ]
   └─ sort   # 2회차 정렬
        │
        ▼
     return 1000 rows
1
2
3
4
5
6
7
8
9
10
11
[ App ]
   │
   ▼
request page3 (lastId = 2000)
   │
   ▼
[ DB ]
   └─ sort   # 3회차 정렬
        │
        ▼
     return 1000 rows




반면 Streaming은 쿼리를 한 번만 실행하므로 정렬도 한 번만 수행합니다. 정렬 비용이 병목이라면 Streaming이 더 좋은 선택일 수 있겠죠.

1
2
3
4
5
6
7
8
SELECT 
    *
FROM 
    orders
WHERE 
    created_at BETWEEN '2026-01-01' AND '2026-01-31'
ORDER BY 
    created_at ASC;




최초 한 번만 정렬한 후, 데이터를 찾아오기 때문입니다.

1
2
3
4
5
6
7
[ DB ]
  └─ sort   # 최초 1회 정렬 후 데이터 fetch
       └─ row-by-row fetch
            └─ fetch row1
            └─ fetch row2
            └─ fetch row3
            ...




2-3. 커넥션을 오래 점유해도 되는가

반면 Keyset Paging은 페이지 단위로 커넥션을 사용하고 즉시 반환합니다. 장시간 실행 작업이거나 공용 환경이라면 운영 안정성 측면에서 더 안전합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        page1
          ↓
    get connection
          ↓
        query
          ↓
   return connection
        
        ......
        
        pagen
          ↓
    get connection
          ↓
        query
          ↓
    return connection




Streaming은 ResultSet이 닫힐 때까지 커넥션을 반환하지 않습니다. 공용 API 서버처럼 여러 요청이 같은 커넥션 풀을 사용하는 환경에서는 위험할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 [ Connection Pool ]
         │
         ▼
   get connection
         │
         ▼
     stream rows
         │
         ▼
     stream rows
         │
         ▼
       ......
         │
         ▼
       close → return connection




2-4. 조회 중 데이터 변경을 허용할 수 있는가

Keyset Paging은 페이지마다 새 쿼리를 실행하므로, 중간에 데이터가 변경되면 결과가 달라질 수 있습니다. 따라서 처리 중 데이터 변경을 허용할 수 있는지도 고려해야 합니다. 반면 Streaming은 하나의 쿼리 실행 시점의 결과를 그대로 끝까지 읽습니다.

물론 Streaming도 실제 일관성 수준은 데이터베이스의 트랜잭션 격리 수준에 따라 결과가 달라질 수 있습니다.





3. 정리


읽기 상태를 데이터베이스가 유지하면 정렬은 한 번이지만 커넥션을 오래 점유하고 복구가 어렵습니다. 반면 애플리케이션이 유지하면 커넥션 점유는 짧고 복구가 가능하지만 쿼리를 반복 실행해야 합니다. 자신의 상황에 맞는 적절한 방식을 선택합시다.


This post is licensed under CC BY 4.0 by the author.

Excel 다운로드 과정에서 발생한 이슈

CSV와 Excel의 차이점은 무엇일까?