[모두의 랜덤 디펜스] Spring Batch Processor에서 발생하는 N+1문제 삽질하기

2024. 4. 5. 17:42Backend/Spring

 

혹시나 정답을 알고 계신 분이 있으시다면

제가 한 분석에 관해 피드백 주시면 정말 감사할 것 같습니다 ㅜㅜ

 

default_batch_fetch_size옵션이 들어가있음에도 불구하고 @OneToMany에 대해 N+1문제가 발생하는 상황입니다.

 

다른 List<Problem> 내 다른 필드 ManyToOne 의 경우 batch_fetch옵션이 잘 작동했지만, List<Problem> 내 OneToMany에 관해서만 문제가 발생했습니다.

 

 

문제 상황

Spring Batch에선 보통 chunk단위로 트랜잭션을 관리합니다.

 

트랜잭션 내임에도 불구하고 

 

AbstractPagingItemReader를 직접 상속하여 만든

PagingCollectionItemReader에서 찾은 Item을, Processor에서 LAZY 로딩 된 필드에 접근하면

아래와 같이 N+1문제가 발생하는 것을 확인할 수 있었습니다.

 

테스트를 위해 확인해보면 Processor에 로그를 찍어봤습니다.

이렇게 N+1문제가 발생합니다.

 

default_batch_fetch_size옵션이 제대로 적용된게 아니지 않을까 생각하기에는 또, Problem 내 다른 필드(Algorithm)는 제대로 접근이 됩니다,,

 

JpaPagingItemReader의 내용을 참고했고, Item반환 부분에 collection으로 묶어주는 로직만 추가했기 때문에 아무런 문제가 없을 것으로 생각했지만, N+1 문제가 발생하여 당황했습니다..

 

 

그래서 검색하다보니 관련 내용을 찾아볼 수 있었습니다.

https://jojoldu.tistory.com/414

 

Spring Batch JPA에서 N+1 문제 해결

안녕하세요? 이번 시간엔 Spring batch에서 N+1 문제 해결을 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와

jojoldu.tistory.com

 

위 블로그에 따르면 다른 Reader(HibernateCursorItemReader 등등) 의 경우는 트랜잭션 범위를 chunk에 따라 관리하는데

 

JpaPagingItemReader에서는 따로 다음 코드와 같이 Reader에서 트랜잭션을 관리하고 있었습니다.

 

hibernate.default_batch_fetch_size와 트랜잭션

hibernate.default_batch_fetch_size는 Hibernate가 연관된 컬렉션 또는 엔티티를 로딩할 때 일괄적으로 처리할 수 있는 양을 정의합니다.

이 설정은 Hibernate 세션 내에서 유효하고, 트랜잭션 범위 내에서 연관된 엔티티를 로딩할 때 작동합니다.

 

그래서 N+1이 발생했던 이유는 기존 Spring Batch의 chunk단위 트랜잭션이 시작된 데에서(1),

 

doReadPage내 트랜잭션이 다시 시작(2) 됐고

 

(2) 에서 엔티티 조회를 진행한 뒤,  ReadPage이후 tx.commit하게 되어 트랜잭션이 처리돼서 문제가 발생했다고 합니다.

 

 

위의 상황을 테스트코드로 재현하고 싶어 테스트를 작성해보았습니다.

 

하지만 위 테스트 코드를 실행해보면 중첩된 트랜잭션 내에서 찾고 끝난 트랜잭션을 그 부모 트랜잭션에서 조회하려하면 N+1문제가 발생해야하지만, 전혀 발생하지 않았습니다.

 

분명 같은 상황이라고 생각했지만 서로 다른 결과가 나왔습니다.

 

 

제가 테스트코드를 잘못 작성했나? 라는 생각도 들었지만 일단은 결론 내린 원인은

 

Transaction을 시작할 때 기존 chunk에서 시작된 트랜잭션에 참여했고,

doReadPage내부의 tx.commit()이 chunk 트랜잭션까지 영향을 미쳤다? 

 

이렇게 결론지었습니다,, 이 부분에 대해선 확신이 안 서네요ㅜ

아마 Propagation.REQUIRES_NEW에서 진행된 트랜잭션은 독립적으로 완료됐기 때문에 부모 트랜잭션에 영향을 끼치지 않았기 때문에 문제가 없던 것 같습니다.

 

 

그래서 그 doReadPage내의 transaction 시작 코드를 제거 해주고 테스트해본 결과

 

이렇게 트랜잭션을 시작하는 코드를 제거해주면

 

이렇게 N+1문제가 해결됐습니다..

 

 

JpaPagingItemReader 살펴보기

사실 제가 만든 AbstractPagingItemReader를 상속한 PagingCollectionItemReader는 JpaPagingItemReader의 거의 모든 로직과 같습니다.

위와 같이 JpaPagingItemReader에 문제가 됐던 Transaction코드가 남아있습니다,,

 

이렇게 충돌하는데 대체 왜 트랜잭션이 생기는거지? 싶어서 GPT한테 물어본 결과는 다음과 같습니다.

 

1. 별도의 트랜잭션 경계 설정: JpaPagingItemReader 내에서 별도의 트랜잭션을 사용하는 것은 페이지를 읽는 동안에만 트랜잭션을 제한하려는 의도일 수 있습니다. 즉, 페이지를 읽는 도중 발생할 수 있는 문제로부터 청크 처리 트랜잭션을 격리하고자 하는 것입니다.

 

2. 컨텍스트의 관리: entityManager.flush()와 entityManager.clear()를 호출함으로써, JpaPagingItemReader는 영속성 컨텍스트의 상태를 명시적으로 관리합니다. flush()는 영속성 컨텍스트의 변경사항을 데이터베이스에 반영하고, clear()는 컨텍스트를 비워서 메모리를 관리하고 이후 처리에 영향을 미치지 않도록 합니다.

 

2번의 이유는 굉장히 납득이 가고

 

1번의 이유라면 entityManager.getTransaction()이 기존 트랜잭션에 참여해버려서 생기는 오류가 아닐까? 생각했습니다.

 

그래서 어차피 직접 구현한 코드다 보니 Transactional 어노테이션을 사용해보기로 했습니다.

 

 

이렇게 @Transactional(propagation = Propagation.REQUIRES_NEW)을 사용하니 문제가 발생하지 않았습니다...

 

독립적인 트랜잭션만 커밋돼서 기존 chunk의 트랜잭션에는 문제를 끼치지 않습니다..

 

 

결국 문제는 tx.commit()때문에 발생했던 문제인 것 같습니다...

그러면 기존 JpaPagingItemReader에서 트랜잭션을 시작할 때, Spring 어노테이션 말고 REQUIRES_NEW와 같은 트랜잭션을 시작하면 해결되지 않을까 생각헀습니다.

 

그러고나서 생각해보는데, 만약 tx.commit() 때문에 트랜잭션이 아예 커밋돼버린거면

 

Processor에서 접근할 때 아예 LazyInitializationException이 발생했어야 했지 않을까? 라는 생각이 들었는데, 어차피 LazyInitializationException은 트랜잭션과 무관하게 영속 컨텍스트와 관련이 있으니 문제가 생기지 않을 것 같습니다.

 

다시 결론 내려보면

1. em.getTransaction() 후 tx.begin()한 트랜잭션(chunk 트랜잭션에 참여)에서

2. tx.commit하면 트랜잭션 세션이 닫히고,

3. 영속 컨텍스트는 살아있어서 LazyInitializationException는 안 발생했고,

4. N+1문제만 발생한 거 같습니다....

 

 

 

결국 기존 트랜잭션 관련 코드를 지우고, GPT답변의 1번의 이유로 @Transactional(propagation = Propagation.REQUIRES_NEW)를 추가하여 해결했습니다.