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()
}
}
데이터 조회 방식에 대해서는 많은 자료가 있기 때문에 간단히만 설명하고 넘어가겠습니다. 아직 이 개념을 모른다면 아래 포스팅들을 참조해 주세요.
- Pagination and Streaming Data within a Distributed Database
- Efficient Data Pagination Keyset vs. Offset
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. 정리
읽기 상태를 데이터베이스가 유지하면 정렬은 한 번이지만 커넥션을 오래 점유하고 복구가 어렵습니다. 반면 애플리케이션이 유지하면 커넥션 점유는 짧고 복구가 가능하지만 쿼리를 반복 실행해야 합니다. 자신의 상황에 맞는 적절한 방식을 선택합시다.