thumbnail

서론.

가끔식 에러하나에 많은 시간을 투자하게되는 힘든일이 발생합니다.
오늘이 바로 그날이었습니다..

<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 내부에 entityManagernull이어서 발생하는 에러입니다.

<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>

ItemStreamopen() 메소드가 정의되어있습니다.
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, writerItemStream 인지를 검증합니다.
(!!!) 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>

아니 근데 왜안걸릴까요?
다음 상황에서, 자손 클래스로 생성 했다면,
instanceOftrue를 반환하는게 맞습니다.

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로 감싸버린 겁니다.
ItemReaderproxy 클래스임 확인할 수 있습니다.

<br>

spring-bootCGLIB 프록시를 사용합니다.
ItemReader를 상속받는 Proxy 하위클래스를 정의합니다.
Proxy의 인스턴스를 생성합니다.그리고 reader를 대신하여 사용합니다.

Proxytarget이라는 멤버변수를 가지고 있습니다.
여기서 targetreader 입니다.
그리고 target의 메소드를 모두 복사하여 가지고 있습니다.
Proxy# a() 메소드를 호출하면,
target# a() 메소드를 포워딩 함으로써, Proxy를 구현합니다.

ItemReader <|-- JpaItemPagingReader
ItemReader <|-- Proxy
class Proxy{
    ItemReader targetClass;
}

<br>

핵심은, ItemReader을 상속받는 Proxy의 인스턴스를 새로 생성하여 사용하는 것입니다.
더 이상 참조하는 대상이
JpaItemPagingReader 클래스의 인스턴스가 아니게 되는 것을 말합니다.

따라서, 기존 계층 관계는 무너지게됩니다.
reader instanceOf JpaItemPagingReaderfalse를 반환하게 됩니다.

<br>

결과적으로는 reader instanseOf ItemStreamfalse를 반환합니다.

그리고, stepBuilder는 생각합니다.

ItemStream이 아니네?? 패스다 요놈아!

불쌍한 readeropen() 초기화가 되지 않습니다..

<br>

@Bean 등록시, proxy를 안쓰는 방법도 있습니다.
다음과 같이 config해주면 됩니다.

@Configuration(proxyBeanMethods = false)

해당 설정 시, proxy를 사용하지 않습니다.
따라서, reader instanceOf JpaItemPagingReadertrue를 반환 합니다.
다만, 이 방법을 메소드를 호출 할때마다 @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을 반합니다.
stepstreams 멤버변수를 주입합니다. //step#setStreams()

	public TaskletStep build() {
        //..
		step.setStreams(streams.toArray(new ItemStream[0])); // !!
        //..
		return step;
	}

<br>

- TaskletStep.java
step# setStreams()를 따라가보겠습니다. 여정이 거의 끝나갑니다.
this.streamfor문을 돌며 streams을 등록합니다.
this.streamCompositeItemStream 의 인스턴스 입니다.

    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()이 호출되면,
멤버변수 streamsfor문을 돌며, 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>

끝.

확실히 따라가면서, 원리를 이해하니, 이해가 박힙니다..
읽어주셔서 감사합니다 :)

CommentCount 0
이전 댓글 보기
등록
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
TOP