포스트

우아한형제들 「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()은 인덱스가 없으면 전체 스캔이다. “단순한 정렬만”이라는 주의사항은 이 뜻이다.
시리즈

빅테크 기술 블로그 리뷰

31편 중 13편

  1. 1 카카오 「실시간 메시징 시스템 개발기」(3편) 리뷰 — Redis에 몰린 부하를 서버로 옮기고, 그 서버를 pprof로 세 번 깎은 이야기
  2. 2 카카오 「추가배포 없이 API의 case 통일시키기」 리뷰 — 받는 쪽이 자기 케이스에 맞춰 알아서 읽게 하면 배포 순서가 사라진다
  3. 3 카카오 「MySQL DATETIME, TIMESTAMP 데이터 타입에 대한 분석」 리뷰 — 바이트 단위 저장 구조부터 아직 안 고쳐진 Y2K38까지
  4. 4 네이버 D2 「6개월 만에 연간 수십조를 처리하는 DB CDC 복제 도구 무중단/무장애 교체하기」 리뷰 — 복제·검증·복구를 셋으로 나누고, 옛 도구와 새 도구를 서로 모르게 같이 돌린 전환
  5. 5 카카오 「MySQL ALTER DDL 수행 방식에 대한 이해」 리뷰 — Copy·In-Place·Instant는 '무엇을 복사하느냐'보다 '언제 Exclusive 메타 락을 잡느냐'로 구분된다
  6. 6 카카오 「MySQL Orchestrator 기반의 새로운 HA 표준 개발기」 리뷰 — 10년 멈춘 Perl 도구를 떠나 Raft 클러스터로, 그리고 slave_net_timeout 한 줄
  7. 7 카카오 「MySQL Ver. 8.0 New Feature: Instant DDL Algorithm에 대한 이해」 리뷰 — 컬럼을 0.01초에 추가하는 대가는 '읽을 때마다 버전을 대조하는 것'이다
  8. 8 네이버 D2 「CDC 복제 이후 오라클이 느려졌다? child cursor 폭증이 만든 예상치 못한 문제」 리뷰 — 같은 SQL인데 바인딩 타입이 다르면 Oracle은 다른 쿼리로 본다
  9. 9 카카오 「MySQL InnoDB Log에 대한 이해 - (1)」 리뷰 — 트랜잭션 하나가 어떻게 MTR 여러 개로 쪼개져 Redo Log Buffer에 들어가는가
  10. 10 카카오 「PostgreSQL to ES: Kafka Connect CDC 파이프라인」 1·2편 리뷰 — 변경이 없어서 디스크가 차고, LSN이 사라져서 스냅샷을 다시 짜야 했던 CDC의 실제 운영 비용
  11. 11 LINE 「기획서 없이 내재화하기: 검증 로직으로 동일함을 증명하다」 리뷰 — 블랙박스는 입력과 출력만 정의하면 통계로 같음을 증명할 수 있다
  12. 12 뱅크샐러드 「게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)」 리뷰 — 같은 WHERE version 조건인데, 충돌한 요청을 어떻게 하는가에서 갈리는 두 설계
  13. 13 우아한형제들 「Spring Batch와 Querydsl」 리뷰 — offset을 버린 Reader가 21분을 4분으로 만든 이유, 그리고 그 Reader가 답하지 않는 두 가지
  14. 14 네이버 D2 「테스트는 어떻게 좋은 코드를 만드는가(feat. 험블 객체 패턴)」 리뷰 — 목이 많아지는 것은 테스트의 문제가 아니라 설계의 신호
  15. 15 당근 「QR을 찍으면 무슨 일이 벌어질까? 당근페이 현장 결제의 모든 것」 리뷰 — 카드망을 빌려 7주 만에 낸 결제와, 그 글이 다루지 않은 승인 응답이 사라지는 순간
  16. 16 네이버 D2 「스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기」 리뷰 — 두 DB에 동시에 쓰되 한쪽 실패는 무시하고, 읽기 트래픽을 복제해 성능을 재고, 6개월간 불일치를 0으로 만든 과정
  17. 17 야놀자 「RESTful API validation 자동화 하기」 리뷰 — 인터페이스 하나에서 검증·문서·타입을 뽑는 이유, 그리고 자동화가 없으면 누락이 필연이라는 문장
  18. 18 카카오 「MySQL Json 데이터 타입의 저장 구조와 성능 비교」 리뷰 — 통째로 넣고 통째로 꺼내면 TEXT, 키로 파고들면 JSON
  19. 19 LINE 「도메인에 의존하지 않는 채팅 플랫폼은 어떻게 만들었을까?」 리뷰 — 사용자를 모르는 채팅 플랫폼, 웹으로 만든 클라이언트, SOFT STOP으로 갈아 끼우는 챗봇 시나리오
  20. 20 카카오페이 「MSA 환경에서 네트워크 예외를 잘 다루는 방법」 리뷰 — Unknown을 타입으로 만든 글과, Unknown을 상태로 저장한 프로젝트가 갈리는 지점
  21. 21 카카오 「메시징 서버의 스트레스 테스트 노하우와 AI가 덜어 준 부분」 리뷰 — 지표를 네 층으로 내려가 읽는 법, 그리고 LLM에게 맡긴 것과 맡기지 못한 것
  22. 22 네이버 D2 「일 3,000만 건의 네이버페이 주문 메시지를 처리하는 Kafka 시스템의 무중단 전환 사례」 리뷰 — 두 벌로 발행해 대조한 검증기와, 발행 제어 키를 파티션 키와 같게 둔 이유
  23. 23 카카오 「잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정」 리뷰 — 벤더가 8ms 만에 리포트를 보냈고, 우리는 101ms짜리 트랜잭션 안에 있었다
  24. 24 컬리 「컬리의 입고 시스템이 외부 인입 데이터를 안전하게 동기화하는 방법」 리뷰 — 145회 재시도가 맞는 도메인과 재시도를 금지한 도메인, 그리고 발행부와 수신부가 각자 책임지는 구조
  25. 25 네이버 D2 「@RequestCache: HTTP 요청 범위 캐싱을 위한 커스텀 애너테이션 개발기」 리뷰 — 캐시의 수명을 '요청 하나'로 맞추면 TTL 고민이 사라진다, 그리고 @RequestScope가 안 되는 이유
  26. 26 무신사 「Kafka와 Strimzi를 이용하여 6개의 도메인을 하나의 도메인으로 합쳐보았습니다」 리뷰 — 배치 없이 CDC와 Kafka Streams로 옮긴 결정, 그리고 통합 모델이 원본과 같다는 것을 누가 확인하는가
  27. 27 쿠팡 「대용량 트래픽 처리를 위한 쿠팡의 백엔드 전략」 리뷰 — 캐시 두 겹과 '분 단위 99.99% 동일'이라는 문장, 그리고 그 0.01%를 누가 어떻게 세는가
  28. 28 LINE 「LINE에서 Kafka를 사용하는 방법 - 1편」 리뷰 — 초당 4GB 클러스터가 바이트가 아니라 요청 수를 제한하는 이유, 그리고 2,000틱짜리 파이프라인에서 같은 것을 본 기록
  29. 29 카카오 「MySQL 인증 플러그인 caching_sha2_password에 대한 이해」 리뷰 — 비밀번호 해시가 바뀌는 것보다 '평문이 서버까지 가야 한다'는 점이 전환의 진짜 비용
  30. 30 LINE 「초당 100만 건, LINE 앱에 Apache Kafka 종단 간 암호화 적용기」 리뷰 — 인터셉터와 시리얼라이저만으로 브로커에 평문을 남기지 않는 법
  31. 31 카카오뱅크 「하루 N억 건의 알림 시스템 구축기 (1)」 리뷰 — P99의 80%가 대기였다는 진단, Age 기반 Work-Stealing, 그리고 큐를 나누는 순간 순서를 잃는 문제
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다