pile·
백엔드·마켓컬리마켓컬리 Hello World·

MySqlPagingQueryProvider 살펴보기

spring-batch로 배치 애플리케이션을 만들다 JdbcPagingItemReader와 MySqlPagingQueryProvider의 pagination 전략 때문에 겪은 두 이슈와 원인, 해결을 다룬다. Chunk 크기만큼만 발행되던 문제와 group by + table alias 조합에서 터진 SQL 문법 오류가 모두 "MySqlPagingQueryProvider는 offset/limit이 아니라 sort key를 where 조건에 넣어 페이지를 나눈다"는 동작에서 비롯됐다.

핵심 포인트
  • MySqlPagingQueryProvider는 offset/limit이 아니라 이전 chunk의 마지막 sort key를 기억해 그보다 큰 값만 다음 chunk로 읽는 keyset 방식으로 동작한다.
  • sort key를 created_at처럼 중복 가능한 컬럼으로 잡으면 같은 값 구간이 통째로 건너뛰어져 일부 row가 발행되지 않는다.
  • group by가 있으면 쿼리를 MAIN_QRY inline view로 감싸는데, sort key에 t1 같은 table alias를 붙이면 바깥에서 그 alias를 못 찾아 SQLSyntaxErrorException이 난다.
  • 해결은 sort key를 auto_increment PK(id)로 바꾸고, group by 케이스에선 alias 없이 컬럼명만 넣는 것.
상세 정리
  • 배경: 지류 상품권 상태 변경 이벤트를 Back Office로 보내려 Transactional Outbox 패턴을 적용하고, 실시간성이 낮아 CDC 대신 배치 polling으로 outbox 테이블을 읽었다.
  • 첫 증상: created_at을 now()로 100건씩 넣는 프로시저를 여러 번 호출해 테스트하니 한 번에 만든 100건 중 chunk 크기만큼(예: 10건)만 발행되고 나머지가 누락됐다.
  • 원인 추적: generateRemainingPagesQuery를 따라가니 remaining query가 limit만 쓰는 게 아니라 buildSortConditions로 sort key 조건을 where에 추가하고 있었다.
  • 동작 이해: 마지막으로 읽은 row의 sort key를 PagingRowMapper가 startAfterValues에 저장하고, 다음 페이지는 created_at 이 이전 마지막값보다 큰 조건으로 조회한다.
  • 왜 누락됐나: A1~A100의 created_at이 전부 동일해, A10까지 읽은 뒤 created_at 이 A10보다 큰 조건이 A11~A100을 통째로 걸러내고 B1~B10으로 건너뛴다.
  • 1차 해결: outbox의 id가 auto_increment라 순서도 보장되고 유일하므로 sort key를 id로 바꿔 해결. 공식 문서도 sort key에 unique 제약 컬럼을 쓰라고 명시한다.
  • 두 번째 이슈: AML 배치에서 파트너별 취소 금액을 group by로 집계하며 sort key에 t1.merchant_member_id처럼 alias를 붙였더니 첫 페이지만 되고 두 번째부터 SQLSyntaxErrorException.
  • 원인: group clause가 있으면 generateLimitGroupedSqlQuery가 쿼리를 MAIN_QRY inline view로 감싸고 그 바깥에 sort key 조건을 붙이는데, inline view 바깥엔 t1 alias가 없어 컬럼을 못 찾는다.
  • 2차 해결: sort key에 table alias를 빼고 컬럼명만 세팅해 해결. sortKeys 맵의 key가 조건 생성과 order by에 그대로 쓰이기 때문.
왜 읽나spring-batch의 JdbcPagingItemReader로 대량 데이터를 페이징하는 백엔드 개발자가 sort key 선택(유일성·alias)에서 데이터 유실·쿼리 오류를 피하려 할 때 참고할 실전 사례.
마켓컬리
마켓컬리 Hello World 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2