JpaPagingItemReader 사용 시, ItemReader 타입으로 bean을 등록하지 말자.
서론.
가끔식 에러하나에 많은 시간을 투자하게되는 힘든일이 발생합니다.
오늘이 바로 그날이었습니다..
<img src="https://static.podo-dev.com/blogs/images/2020/02/27/origin/8961c68f-bc38-4020-bad4-417e9f1799c1.png" alt="base64.png" style="width:400px;">
<br>
본론.
spring-batch
테스트 코드를 짜는데, 문제가 발생합니다..
<br>
job
을 실행하니, 다음 에러를 확인했습니다.
java.lang.NullPointerException: null
at org.springframework.batch.item.database.JpaPagingItemReader.doReadPage(JpaPagingItemReader.java:192) ~[spring-batch-infrastructure-4.2.1.RELEASE.jar:4.2.1.RELEASE]
at org.springframework.batch.item.database.AbstractPagingItemReader.doRead(AbstractPagingItemReader.java:110) ~[spring-batch-infrastructure-4.2.1.RELEASE.jar:4.2.1.RELEASE]
at org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader.read(AbstractItemCountingItemStreamItemReader.java:93) ~[spring-batch-infrastructure-4.2.1.RELEASE.jar:4.2.1.RELEASE]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_211]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_211]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_211]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_211]
JpaPagingItemReader
내부에 entityManager
가 null
이어서 발생하는 에러입니다.
<br>
JpaPagingItemReader
가 내부에서
최초에 doOpen()
을 호출함으로써
entityManager
를 초기화하는데, 문제는 doOpen()
을 호출하지 않습니다.
왜 doOpen()
이 호출이 안되는지,
여러여러 찾아보니, 해당 링크에서 답을 얻었습니다.
https://jira.spring.io/si/jira.issueviews:issue-html/BATCH-2256/BATCH-2256.html
<br>
문제는 @Bean
을 등록 시에 반환 타입을 ItemReader
로 받은 것이 문제입니다.
@StepScope
@Bean
public ItemReader<? extends BlaBla> searchJobReader(EntityManagerFactory entityManagerFactory) {
return new JpaPagingItemReaderBuilder<BlaBla>().blabla();
}
<br>
ItemReader
로 상위캐스팅되어 반환 받으니,
ItemStream
의 구현체가 아니기 때문에 초기화가 안되는 것입니다.
원인은 프록시다!
관련된 딥한 내용은 하단에 정리하였습니다.
public interface ItemReader<T> {}
<br>
ItemStream
에 open()
메소드가 정의되어있습니다.
open()
메소드 JpaPagingItemReader# doOpen()
메소드를 호출합니다.
public interface ItemStream {
/**
* Open the stream for the provided {@link ExecutionContext}.
*
* @param executionContext current step's {@link org.springframework.batch.item.ExecutionContext}. Will be the
* executionContext from the last run of the step on a restart.
* @throws IllegalArgumentException if context is null
*/
void open(ExecutionContext executionContext) throws ItemStreamException; /// `doOpen()`호출, `AbstractItemCountingItemStreamItemReader`
<br>
전체적으로 그려보면 다음 구조입니다.
ItemReader <|.. ItemStreamReader
ItemStream <|.. ItemStreamReader
ItemStreamReader <|-- AbstractItemStreamItemReader
AbstractItemStreamItemReader <|-- AbstractItemCountingItemStreamItemReader
AbstractItemCountingItemStreamItemReader <|-- AbstractPagingItemReader
AbstractPagingItemReader <-- JpaPagingItemReader
interface ItemStream{
abstract open()
}
interface ItemStreamReader
interface ItemReader
abstract class AbstractItemStreamItemReader
abstract class AbstractItemCountingItemStreamItemReader{
open();
abstract doOpen();
}
abstract class AbstractPagingItemReader{
doOpen();
}
abstract class JpaPagingItemReader{
doOpen();
}
<br>
그래서 다음과 같이 반환타입을 바꾸면, 정상 작동하게 됩니다.
@StepScope
@Bean
public ItemStreamReader<? extends BlaBla> searchJobReader(EntityManagerFactory entityManagerFactory) {
return new JpaPagingItemReaderBuilder<BlaBla>().blabla();
}
public interface ItemStreamReader<T> extends ItemStream, ItemReader<T> {}
<br>
더 딥하게 따라가야 합니다.
왜(?) 라는 의문에 따라가봤습니다,
아니 상위캐스팅 됬다고 모른다고?! 이해가안되네..
<br>
jobLanucher# run()
을 호출하면,
SimpleStepBuilder# build()
가 호출됩니다
<br>
- SimpleStepBuilder.java
build()
메소드에서
registerAsStreamsAndListeners()
메소드를 호출되는것을 알 수 있습니다.
/**
* Build a step with the reader, writer, processor as provided.
*
* @see org.springframework.batch.core.step.builder.AbstractTaskletStepBuilder#build()
*/
@Override
public TaskletStep build() {
registerStepListenerAsItemListener();
registerAsStreamsAndListeners(reader, processor, writer); // !!
return super.build();
}
<br>
- SimpleStepBuilder.java
registerAsStreamsAndListeners()
메소드는
reader
, processor
, writer
가 ItemStream
인지를 검증합니다.
(!!!) ItemReader
로 반환하면, 여기서 걸리지가 않습니다??
protected void registerAsStreamsAndListeners(ItemReader<? extends I> itemReader,
ItemProcessor<? super I, ? extends O> itemProcessor, ItemWriter<? super O> itemWriter) {
for (Object itemHandler : new Object[] { itemReader, itemWriter, itemProcessor }) {
if (itemHandler instanceof ItemStream) { // ItemStream 나와라 ㅡㅡ..
stream((ItemStream) itemHandler); // 넵!
}
//..
//..
<br>
아니 근데 왜안걸릴까요?
다음 상황에서, 자손 클래스로 생성 했다면,
instanceOf
는 true
를 반환하는게 맞습니다.
Parent <|-- Child
void test(){
Parent child = new Child;
child instanceOf Parent // true (!!)
}
<br>
궁금해서 Bean
생성 메소드에서 찍어봅니다.
엥 왜 true
..?`
@StepScope
@Bean(JOB_BEAN_NAME +"StepReader")
public ItemReader<? extends Stock> searchJobReader(EntityManagerFactory entityManagerFactory) {
final ItemReader<Blabla> itemReader = new JpaPagingItemReaderBuilder<BlaBla>().blabla();
System.out.println(stockQuerydslPagingItemReader.getClass()); // JpaPagingItemReaderBuilder
System.out.println(itemReader instanceof ItemStream); // true (!)
System.out.println(itemReader instanceof ItemStreamReader); // true (!)
System.out.println(itemReader instanceof ItemReader); // true (!)
return stockQuerydslPagingItemReader;
}
<br>
당황스럽지만 침착하고, 메소드를 호출하는 stepFactory
에서 찍어봅니다
잉 왜 넌 false
..?
@Bean
public Step removeThumbnailRowStep() {
final ItemReader<? extends Blabla> reader = searchJobReader(entityManagerFactory);
System.out.println(reader.getClass()); // class com.sun.proxy.$Proxy98 (1)
System.out.println(reader instanceof ItemStream); // false (!!)
System.out.println(reader instanceof ItemStreamReader); // false (!!)
System.out.println(reader instanceof ItemReader); // true (!!)
System.out.println("#########");
}
원인은 스프링이 Bean
등록과정에서, proxy
로 감싸버린 겁니다.
ItemReader
가 proxy
클래스임 확인할 수 있습니다.
<br>
spring-boot
는 CGLIB
프록시를 사용합니다.
ItemReader
를 상속받는 Proxy
하위클래스를 정의합니다.
Proxy
의 인스턴스를 생성합니다.그리고 reader
를 대신하여 사용합니다.
Proxy
는target
이라는 멤버변수를 가지고 있습니다.
여기서target
은reader
입니다.
그리고target
의 메소드를 모두 복사하여 가지고 있습니다.
Proxy# a()
메소드를 호출하면,
target# a()
메소드를 포워딩 함으로써,Proxy
를 구현합니다.
ItemReader <|-- JpaItemPagingReader
ItemReader <|-- Proxy
class Proxy{
ItemReader targetClass;
}
<br>
핵심은, ItemReader
을 상속받는 Proxy
의 인스턴스를 새로 생성하여 사용하는 것입니다.
더 이상 참조하는 대상이
JpaItemPagingReader
클래스의 인스턴스가 아니게 되는 것을 말합니다.
따라서, 기존 계층 관계는 무너지게됩니다.
reader instanceOf JpaItemPagingReader
는 false
를 반환하게 됩니다.
<br>
결과적으로는 reader instanseOf ItemStream
은 false
를 반환합니다.
그리고, stepBuilder
는 생각합니다.
넌
ItemStream
이 아니네?? 패스다 요놈아!
불쌍한 reader
는 open()
초기화가 되지 않습니다..
<br>
@Bean
등록시, proxy
를 안쓰는 방법도 있습니다.
다음과 같이 config
해주면 됩니다.
@Configuration(proxyBeanMethods = false)
해당 설정 시, proxy
를 사용하지 않습니다.
따라서, reader instanceOf JpaItemPagingReader
는 true
를 반환 합니다.
다만, 이 방법을 메소드를 호출 할때마다 @Bean
을 생성하게됩니다.
자세한내용은 http://wonwoo.ml/index.php/post/2000 잘 정리되어있습니다!!
<br>
더 따라가보기(!)
부록.. 구질구질하게 끝까지 따라가보겠습니다..(구질구질)
<br>
- AbstractTaskletStepBuilder.java
registerAsStreamsAndListeners()
메소드가 호출하는
AbstractTaskletStepBuilder# stream()
메소드를 따라갑니다.
streams
멤버 변수에, 인자로 받은 itemStream
을 추가하는게 보입니다.
/**
* Register a stream for callbacks that manage restart data.
*
* @param stream the stream to register
* @return this for fluent chaining
*/
public AbstractTaskletStepBuilder<B> stream(ItemStream stream) {
streams.add(stream); // !!
return this;
}
<br>
- AbstractTaskletStepBuilder.java
자 이제,
AbstractTaskletStepBuilder# build()
메소드는 step
을 반합니다.
step
에 streams
멤버변수를 주입합니다. //step#setStreams()
public TaskletStep build() {
//..
step.setStreams(streams.toArray(new ItemStream[0])); // !!
//..
return step;
}
<br>
- TaskletStep.java
step# setStreams()
를 따라가보겠습니다. 여정이 거의 끝나갑니다.
this.stream
에 for문
을 돌며 streams
을 등록합니다.
this.stream
은 CompositeItemStream
의 인스턴스 입니다.
private CompositeItemStream stream = new CompositeItemStream();
public void setStreams(ItemStream[] streams) {
for (int i = 0; i < streams.length; i++) {
registerStream(streams[i]); // !!
}
}
public void registerStream(ItemStream stream) {
this.stream.register(stream); // !!
}
<br>
- AbstractStep.java
step
이 생성됬습니다.
이제 step
을 실행하게되면 step#excecute()
가 호출됩니다.
그리고 대망에 open()
메소드가 호출됩니다.
@Override
public final void execute(StepExecution stepExecution) throws JobInterruptedException,
UnexpectedJobExecutionException {
//..
open(stepExecution.getExecutionContext()); // !!
//..
}
<br>
- TaskletStep.java
open()
메소드는 CompositeItemStream# open()
를 호출합니다.
Override
protected void open(ExecutionContext ctx) throws Exception {
stream.open(ctx); // CompositeItemStream#open();
}
<br>
- CompositeItemStream.java
CompositeItemStream# open()
이 호출되면,
멤버변수 streams
의 for문
을 돌며, ItemStream# open()
호출되게 됩니다.
해당 streams
는 이전에 build()
하면서, 주입된 ItemStream
입니다.
따라서, ItemStreamReader# open()
도 호출됩니다.
@Override
public void open(ExecutionContext executionContext) throws ItemStreamException {
for (ItemStream itemStream : streams) {
itemStream.open(executionContext); //!!
}
}
<br>
- JpaPagingItemReader.java
최종적으로 구현계층에 따라,
JpaPagingItemReader# doOpen()
이 호출되면서, entityManager
가 주입됩니다
@Override
protected void doOpen() throws Exception {
super.doOpen();
entityManager = entityManagerFactory.createEntityManager(jpaPropertyMap);
if (entityManager == null) {
throw new DataAccessResourceFailureException("Unable to obtain an EntityManager");
}
// set entityManager to queryProvider, so it participates
// in JpaPagingItemReader's managed transaction
if (queryProvider != null) {
queryProvider.setEntityManager(entityManager);
}
}
<br>
끝.
확실히 따라가면서, 원리를 이해하니, 이해가 박힙니다..
읽어주셔서 감사합니다 :)