우아한형제들 「Spring Batch와 Querydsl」 리뷰 — offset을 버린 Reader가 21분을 4분으로 만든 이유, 그리고 그 Reader가 답하지 않는 두 가지
엔지니어링 요약
Problem
정산 배치는 수십만~수백만 건을 읽어 처리한다. Spring Batch의 페이징 Reader는 뒤로 갈수록 느려지고, Querydsl용 Reader는 공식으로 없어 매번 상속해 만들어야 했다. 우아한형제들 정산시스템팀이 2020년에 그 둘을 한 번에 푼 글이다.
Decision
원문의 두 Reader(QuerydslPagingItemReader, QuerydslNoOffsetPagingItemReader)의 동작 원리와 실측(21분→4분 36초, 55분→2분 27초)을 옮기고, 실무 배치 시리즈의 청크 전환·Heap Dump 경험과 ParityPay의 커서 페이지네이션 실측에 대조했다.
Result
offset 제거의 효과는 ParityPay에서도 같은 모양으로 재현됐다(거래내역 110만 건에서 깊이 무관 0.06~0.09ms). 원문이 답하지 않는 것은 둘이다. 읽은 행을 같은 배치가 갱신할 때 offset 페이징이 행을 건너뛰는 문제는 no-offset이 부수적으로 해결하지만 원문은 성능만 말하고, Reader의 페이지 크기와 영속성 컨텍스트의 관계는 Reader가 아니라 Chunk와 flush·clear가 정한다.
원문: Spring Batch와 Querydsl — 우아한형제들 기술블로그, 이동욱(정산시스템팀), 2020-02-05
정산 배치를 다뤄 본 사람이면 이 글을 한 번은 읽었을 것이다. 나도 대량 배치 시리즈를 쓰던 시기에 읽었고, 그때는 “Querydsl Reader를 이렇게 만드는구나”로 끝났다. 지금 다시 읽는 이유는 그 뒤에 두 가지 경험이 생겼기 때문이다. Tasklet 하나가 수십만 건을 한 트랜잭션에 처리하다 OOM이 난 것을 Heap Dump로 영속성 컨텍스트에서 찾은 일, 그리고 ParityPay에서 거래내역 110만 건을 커서 페이지네이션으로 읽어 깊이와 무관한 응답 시간을 실측한 일이다. 원문의 결론이 그 둘과 어떻게 만나는지 보고 싶었다.
원문이 말하는 것
두 문제에서 출발한다. 첫째, Spring Batch는 Querydsl용 ItemReader를 제공하지 않아 정산팀은 AbstractPagingItemReader를 Job마다 상속해 구현했고, 신규 입사자마다 그 방법을 설명해야 했다. 둘째, MySQL의 offset 페이징은 뒤로 갈수록 느려진다. 100만 번째 행을 읽으려면 앞의 100만 행을 훑어야 한다.
첫 문제의 답이 QuerydslPagingItemReader다. JpaPagingItemReader의 구조를 그대로 두고 JPQL 자리에 Querydsl 쿼리를 람다(Function<JPAQueryFactory, JPAQuery<T>>)로 받는다. 페이징 로직은 부모가 갖고, Job은 쿼리만 쓴다.
둘째 문제의 답이 QuerydslNoOffsetPagingItemReader다. offset을 쓰지 않는다. 첫 페이지는 정렬 기준 컬럼의 max()/min()으로 시작점을 잡고, 그 뒤로는 마지막으로 읽은 ID를 기억해 WHERE id < 마지막ID (또는 >)로 다음 페이지를 읽는다. 어느 페이지든 인덱스에서 바로 시작한다. 실측은 이렇다.
| 배치 | 데이터 | offset 페이징 | no-offset |
|---|---|---|---|
| 첫 번째 | 869,000건 | 21분 | 4분 36초 |
| 두 번째 | 1,189,000건 | 55분 | 2분 27초 |
마지막 페이지 읽기 시간은 2.4초에서 0.03초로. 주의사항도 적혀 있다. 단순한 정렬 조건에만 맞고, 특정 Spring Boot 버전에서만 검증했으며, 운영 적용 전 테스트가 필수라는 것.
같은 곳: offset을 버리면 깊이가 사라진다
ParityPay에서 같은 실험을 다른 자리에서 했다. 거래내역 프로젝션을 커서(마지막 항목의 시각과 ID) 기반으로 읽는 API를 110만 건 위에서 쟀고, 첫 페이지든 마지막 페이지든 0.06~0.09ms로 깊이와 무관했다(reports/11 M-008). 필터로 버린 행이 0이라는 것이 핵심이었다. offset 페이징은 “N행을 읽어서 앞의 N−k행을 버린다”이고, 커서 페이징은 “인덱스에서 k행만 읽는다”이다. 원문의 21분과 4분 36초의 차이가 그 버린 행의 비용이다.
원문은 정산 배치의 Reader에서, ParityPay는 조회 API에서 같은 결론에 닿았다. 문제의 모양이 같기 때문이다. 한 번에 다 못 읽는 데이터를 여러 번에 나눠 읽을 때, 각 번의 시작점을 “몇 번째”로 정하면 매번 앞을 세야 하고, “어디부터”로 정하면 세지 않는다.
원문이 답하지 않는 것 1: 읽으면서 갱신할 때
정산 배치는 읽은 행을 갱신하는 경우가 많다. “미정산” 행을 읽어 “정산 완료”로 바꾸는 식이다. 이때 offset 페이징에는 성능과 별개의 문제가 있다. 첫 페이지 100건을 읽어 갱신하면 그 100건은 더 이상 조회 조건(미정산)에 맞지 않고, 두 번째 페이지를 offset 100으로 읽으면 원래 두 번째 페이지였던 100건을 건너뛴다. 페이지마다 절반이 빠진다.
Spring Batch에서 이 문제를 아는 사람은 offset을 늘리지 않거나(항상 첫 페이지를 읽음) 정렬 키로 커서를 잡는다. 원문의 no-offset Reader는 후자를 하므로 이 문제를 부수적으로 해결한다. 마지막 ID 이후를 읽으므로 앞 페이지의 갱신이 다음 페이지의 시작점을 바꾸지 않는다. 그런데 원문은 이것을 성능 개선으로만 설명하고 정확성 문제는 언급하지 않는다. 정산팀이 몰랐을 리는 없고 글의 범위 밖이었을 것이다. 내 입장에서는 이 Reader를 고르는 첫 번째 이유가 성능이 아니라 이것이다. 대량 배치 2부에서 “전체 삭제 후 전체 등록” 구조의 위험을 적었는데, 부분 갱신으로 바꾸는 순간 페이징의 정확성이 새 문제가 되고, 커서 페이징이 그 답이다.
원문이 답하지 않는 것 2: Reader의 페이지 크기와 메모리
원문의 Reader는 페이지 단위로 EntityManager에서 읽는다. 페이지 크기가 1,000이면 1,000개의 엔티티가 영속성 컨텍스트에 들어온다. 그 엔티티들이 언제 빠지는가는 Reader가 아니라 Chunk의 트랜잭션 경계가 정한다. 원문도 “페이지 단위 트랜잭션은 관리하지 않고 Chunk 단위는 보장된다”고 적어 뒀다.
이것이 내가 Heap Dump 글에서 만난 문제의 반대편이다. 그 배치는 Tasklet 하나가 수십만 건을 한 트랜잭션에 처리해서 영속성 컨텍스트가 트랜잭션 종료까지 전부를 기억했고, 힙이 모자란 것이 아니라 구조가 힙을 놓아주지 않았다. Chunk로 바꿔 일정 건수마다 커밋하고 컨텍스트를 비우자 3.8GB가 1.6GB가 됐다. 원문의 Reader를 썼더라도 Chunk 크기를 잘못 잡으면 같은 일이 난다. Reader는 “어떻게 읽는가”를 정하고, 메모리는 “언제 놓는가”가 정한다. 두 글을 합치면 정산 배치의 읽기 쪽 규칙이 완성된다. 커서로 읽고, Chunk로 놓는다.
한 가지 더. 원문의 첫 페이지는 max()/min() 집계로 시작점을 잡는다. 인덱스가 있으면 O(1)이지만, 조회 조건이 인덱스 첫 컬럼과 다르면 이 집계가 전체를 훑는다. ParityPay에서 불변조건 지표가 스크레이프마다 원장 전체를 집계하다 200만 건에서 4.5초가 된 것(4편 결함 G)과 같은 자리다. 원문이 “단순한 정렬 조건에만 맞다”고 한 주의사항의 실제 내용이 이것이라고 읽는다.
원문이 잘한 것
수치를 실었다. 21분 → 4분 36초, 55분 → 2분 27초, 마지막 페이지 2.4초 → 0.03초. 2020년 기술 블로그 글 중 Reader 구현을 설명하면서 전후 실측을 표로 낸 글은 많지 않다. 그리고 “꼭 테스트를 거친 후에 쓰라”고 적었다. 라이브러리로 공개하면서 한계(단순 정렬만, 특정 버전만)를 먼저 적은 것이 이 글이 6년 뒤에도 읽히는 이유다.
반복 코드를 “행사 코드”라고 부르며 신규 입사자가 올 때마다 설명해야 했다는 동기도 좋다. 성능보다 이 동기가 먼저 나온다. 정산팀의 문제는 “느리다”가 아니라 “매번 같은 것을 다시 만든다”였고, 그것을 풀다가 성능도 풀린 순서다.
가져갈 것
- 여러 번에 나눠 읽을 때 시작점은 “몇 번째”가 아니라 “어디부터”로 잡는다. 배치 Reader든 조회 API든 같다.
- 읽으면서 갱신하는 배치에서 offset 페이징은 느린 것이 아니라 틀린다. 커서 페이징의 첫 번째 이유는 정확성이다.
- Reader는 읽는 법을, Chunk는 놓는 법을 정한다. 둘 중 하나만 고치면 다른 쪽에서 터진다.
- 첫 페이지의
max()/min()은 인덱스가 없으면 전체 스캔이다. “단순한 정렬만”이라는 주의사항은 이 뜻이다.
댓글
아직 댓글이 없습니다