Post

Cursor-based RepositoryItemReader 구현과 case 정리

Cursor-based RepositoryItemReader 구현과 case 정리

RepositorySeekMethodItemReader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
open class RepositorySeekMethodItemReader<T, E>(  
    chunkSize: Int,  
    initialCursor: E,  
    private val cursorGetter: (T) -> E,  
    private val readBlock: (Int, Int, E) -> List<T>,  
) : AbstractPagingItemReader<T>() {  
    private var cursor = initialCursor  
  
    init {  
        super.setPageSize(chunkSize)  
    }  
    override fun doReadPage() {  
        results = readBlock(cursor, pageSize)  
        cursor = results.last().let(cursorGetter)  
    }  
    override fun doJumpToPage(itemIndex: Int) {}  
}
1
2
3
4
5
6
7
8
// usage
fun reader() = RepositorySeekMethodItemReader(  
    chunkSize = CHUNK_SIZE,  
    initialCursor = 0,  
    cursorGetter = SmsMessage::sequence,  
) { cursor, pageSize ->  
    smsMessageRepository.findAllByCondition1(condition1, cursor, pageSize).toList()  
}

pitfall

initialCursor = 0이면 첫 쿼리가 매우 느릴 수 있다.

  • 위 예시에서 PK는 sequence이고, 그래서 cursorGetter를 sequence로 설정했고, initialCursor은 0으로 설정했다.
  • 검색 조건이 sequence, condition1 이기 때문에, 인덱스도 이렇게 걸려 있을 것이다. (또는 sequence에만)
  • initialCursor = 0이기 때문에, 맨 앞에서 부터 읽기 시작한다. 즉, index full scan이 뜬다.
  • 만약 쿼리 결과로 잡혀야 하는 row의 sequence가 맨 뒤쪽에 있고(보통 그렇다), table size가 매우 크다면, 첫 번째 쿼리가 매우 느릴 수 있다.
    • 두 번째 쿼리 부터는, cursor를 저장해두고 읽기 때문에 문제가 없다.
  • 첫 쿼리 속도 때문에 이 방법이 Offset-based pagination에 비해서 항상 우월한 방법은 아니다. (다만 대부분의 상황에서 우월한 것은 맞다)
  • 상황에 따라 Offset-based가 더 나을 수도, Cursor-based가 더 나을 수도 있어 상황에 맞게 사용해야 한다.
    • 별도 쿼리로 적절한 initialCursor를 빠르게 알아낼 수 있는지 여부 등

cursor 대상 컬럼이 unique 하지 않은 경우

[!warning] 기본적으로 Seek Method는 대상 컬럼이 unique 하다는 전제로 사용해야 한다.

  • 불러온 item을 모두 delete, update하는 경우, 대상 컬럼이 unique 하지 않아도 문제가 발생하지 않는다.
  • 하지만 모두 delete, update 할거라면, 굳이 Seek Method 쓸 필요 없이 하단의 RepositoryPagingItemReader에 Page 0으로 고정하고 쓰는게 더 나아보인다.

cursor 대상 컬럼이 unique 한 경우

복합키(Composite Key)에 대한 Seek Method

[!warning] 복합키에 대한 Seek Method는 불가능하다. (생각해보면 알 수 있다)

Paging이 틀어지면서 중복, 누락 발생 케이스를 생각해보면…

불러온 item을 delete, update 하지 않는 경우

  • Seek Method 말고, 일반적인 Paging 사용하면 된다.

불러온 item을 일부만 delete, update 하는 경우

  • 중복, 누락 없으려면 반드시 CursorItemReader를 써야 한다.
    • 여기서 Cursor는 DB가 관리해주는 Cursor를 의미함
  • DB에 세션 잡고 Cursor 열어둔 채로 ResultSet.next() 해서 하나씩 읽어오는 방식으로 진행

불러온 item을 모두 delete, update 하는 경우

  • Seek Method 사용하지 않고, 항상 Page를 0으로 고정한 일반적인 Paging을 사용하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
open class RepositoryPagingItemReader<T>(  
    chunkSize: Int,  
    private val readBlock: (Int, Int) -> List<T>,  
) : AbstractPagingItemReader<T>() {  
    init {  
        super.setPageSize(chunkSize)  
    }  
    override fun doReadPage() {  
        results = readBlock(page, pageSize)  
    }  
    override fun doJumpToPage(itemIndex: Int) {}  
}
1
2
3
4
5
6
// usage
fun reader() = RepositoryPagingItemReader(  
    chunkSize = CHUNK_SIZE
) { _, pageSize ->  
    smsMessageRepository.findAllByBlaBla(PageRequest.of(0, pageSize)).toList()  
}

참고

  • https://tech.kakaopay.com/post/ifkakao2022-batch-performance-read/
This post is licensed under CC BY 4.0 by the author.