Spring Batch에서 대용량 데이터를 처리할 때 JpaCursorItemReader에서 JpaPagingItemReader로 넘어오는 것은 흔한 수순이다.
하지만 JpaPagingItemReader를 만만하게 보고 기존 웹 애플리케이션에서 하던 것처럼 쿼리를 짰다가는, 시스템이 혼란에 빠지는 것을 목격하게 될 것이다. 오늘은 이 녀석이 숨기고 있는 치명적인 메모리 함정과 프레임워크 내부의 기이한 동작 방식까지 전부 파헤쳐본다.
1. Fetch Join과 페이징의 끔찍한 혼종 (OOM의 서막)
JPA를 좀 다뤄본 개발자라면 N+1 문제를 피하기 위해 숨 쉬듯 Fetch Join을 사용할 것이다. JpaPagingItemReader의 queryString()에도 자연스럽게 JOIN FETCH를 집어넣게 되는데, 여기서부터 비극이 시작된다.
결론부터 말하자면, JpaPagingItemReader에서 Fetch Join을 사용하면 큰 코 다친다.
왜 그런지 설명해주지. Hibernate는 컬렉션 Fetch Join과 페이징(LIMIT/OFFSET)을 함께 사용하는 것을 허용하지 않는다. 만약 이 둘을 같이 쓰면, DB 쿼리에는 페이징을 적용하지 않고 일단 조건을 만족하는 전체 데이터를 메모리에 모두 로드한 후, 애플리케이션(WAS) 단에서 페이징을 수행해 버린다.
예를 들어보자. 전체 Post 데이터가 13개이고, pageSize = 5라면 쿼리는 5개, 5개, 3개씩 총 3번 실행되어야 정상이다. 하지만 Fetch Join이 들어간 순간, 페이지를 조회할 때마다 매번 전체 데이터 13개와 연관된 Report 객체를 전부 다 메모리에 퍼 올린 뒤 거기서 5개를 잘라낸다. 데이터가 수백만 건이라면? 배치가 시작되자마자 OutOfMemory(OOM)와 함께 서버가 장렬히 전사할 것이다.
2. Fetch Join을 뺐더니 찾아온 N+1의 저주
메모리 폭발을 막기 위해 queryString()에서 Fetch Join을 제거했다 치자. 페이징은 DB 단에서 예쁘게 먹히겠지만, 이제는 N+1 쿼리라는 새로운 적이 등장한다.
Post 엔티티를 5개 읽어왔는데, Processor에서 연관된 Report를 건드리는 순간 추가 쿼리가 5번 더 나간다. 이를 해결하기 위한 정석적인 방법은 @BatchSize를 적용하여 IN 절로 연관 데이터를 한 번에 쓸어오는 것이다.
@Entity
@Table(name = "posts")
@Getter
public class Post {
@Id
private Long id;
// ...
@OneToMany(mappedBy = "post", fetch = FetchType.EAGER) // 왜 EAGER일까?
@BatchSize(size = 5) // IN 절 최적화!
private List<Report> reports = new ArrayList<>();
}
그런데 여기서 의문이 든다. 보통 JPA에서는 성능 최적화를 위해 무조건 LAZY 로딩을 권장하는데, 왜 EAGER로 바꿨을까?
3. IDE를 켜라, 범인은 내부에 있다 (doReadPage 해부)
LAZY 로딩 상태에서는 @BatchSize가 전혀 먹히지 않고 N+1이 그대로 터진다. 왜 이런 비정상적인 동작이 일어나는 걸까? 답은 JpaPagingItemReader.doReadPage() 내부 코드에 있다.
@Override
protected void doReadPage() {
EntityTransaction tx = null;
if (transacted) { // 기본값이 true다!
tx = entityManager.getTransaction();
tx.begin();
entityManager.flush();
entityManager.clear();
}
Query query = createQuery().setFirstResult(getPage() * getPageSize()).setMaxResults(getPageSize());
if (!transacted) {
// ...
} else {
results.addAll(query.getResultList()); // 데이터 조회
tx.commit(); // 곧바로 트랜잭션 종료?!
}
}
문제의 원인은 트랜잭션의 범위다. 데이터를 조회(getResultList())하자마자 냅다 tx.commit()으로 트랜잭션을 닫아버린다.
이후 ItemProcessor에서 post.getReports()를 호출해 컬렉션에 접근하려 할 때는 이미 트랜잭션이 끝난 상태다. @BatchSize는 영속성 컨텍스트와 트랜잭션이 살아있어야 동작하는데, 트랜잭션이 죽어버렸으니 IN 절 최적화가 불가능해지고 N+1 문제가 터지는 것이다. 결국 이 멍청한 구현 때문에 억지로 FetchType.EAGER를 써서 트랜잭션이 닫히기 전에 데이터를 강제로 욱여넣어야 하는 슬픈 상황이 벌어진다.
4. transacted = true의 치명적인 잠재적 위험
여기서 끝이 아니다. transacted = true (기본값)일 때 doReadPage() 상단을 다시 보자.
tx.begin();
entityManager.flush(); // 🚨 매우 위험!
entityManager.clear();
페이지를 읽기 전에 갑자기 flush()를 때린다. 이게 왜 위험할까? 만약 이전 Chunk의 ItemProcessor에서 엔티티 데이터를 조작해 놨다면? 데이터를 '읽으러' 들어온 Reader가 의도치 않게 DB에 '수정(Update)' 쿼리를 날려버릴 수 있다.
"ItemReader가 데이터를 변경까지 한다니... 이런 코드는 시스템을 혼란에 빠뜨린다."
따라서 JpaPagingItemReader를 쓸 때는 시스템의 안정성을 위해 다음과 같이 transacted 옵션을 강제로 꺼야 한다.
@Bean
public JpaPagingItemReader<Post> postBlockReader() {
return new JpaPagingItemReaderBuilder<Post>()
// ... 생략 ...
.transacted(false)
.build();
}
'Spring' 카테고리의 다른 글
| [트러블슈팅] DAU ~100, 굳이 Redis를 써야 할까? (Local Cache 도입기) (0) | 2026.02.01 |
|---|