티스토리 뷰

spring batch 에서 chunk oriented 방식을 이용해 로직을 작성하다보면 reader-writer 쌍이 필요해진다. 이때 JPA 를 사용하고 있다면 JpaPagingItemReader 와 JpaItemWriter 를 많이 이용하게 될텐데 이때 writer 에서 저장하는게 신규 엔티티라면 문제가 없지만 기존 엔티티의 업데이트라면 원치않는 select 쿼리가 날아가는걸 경험할 수 있다.

 

public Step chunkStep1() {                          
    return stepBuilderFactory.get("chunkStep1")      
            .<Person, Person>chunk(500)              
            .reader(chunkReader())     
            .processor(chunkProcessor())
            .writer(chunkWriter())                  
            .build();                               
}                                                   

@Bean                                                          
public ItemReader<Person> chunkReader() {                      
    return new JpaPagingItemReaderBuilder<Person>()            
            .saveState(false)                                  
            .entityManagerFactory(entityManagerFactory)        
            .queryString("SELECT p FROM Person p")             
            .pageSize(500)                                     
            .build();                                          
}         

@Bean                                                    
public ItemProcessor<Person, Person> chunkProcessor() {  
    return p -> {                                      
        p.ageUp();                                       
        return p;                                        
    }                                                    
}                                                        
                                                               
@Bean                                                          
public ItemWriter<Person> chunkWriter() {                      
    return new JpaItemWriterBuilder<Person>()                  
            .entityManagerFactory(entityManagerFactory)        
            .build();                                          
}                                                              

위 코드는 기존 DB 에 존재하는 person 을 조회하여 한 살씩 나이를 증가시키고 다시 저장하는 배치 로직이다.

위 코드의 예상 로직은 다음과 같다.

 

paging select 쿼리 -> 건별로 process 적용 -> update 쿼리

 

하지만 예상과는 다른 한 종류의 쿼리가 더 날아게된다.

Hibernate: 
    /* load com.batch.jpa.person.Person */ select
        person0_.id as id1_0_0_,
        person0_.age as age2_0_0_,
        person0_.name as name3_0_0_ 
    from
        person person0_ 
    where
        person0_.id=?

paging 외 id 로 단건 조회하는 쿼리가 row 갯수만큼 날아가는 것이다.

 

이 이슈는 JpaItemWriter 와 JPA EntityManager 의 합작품인데 JpaItemWriter 의 내부 구현은 아래와 같이 되어있다.

usePersist 가 false 가 되면 EntityManager 의 merge 를 호출하게되는데 이 merge 메서드의 동작 방식은 id 필드가 채워져있는 경우 select 을 호출하여 DB 존재 유무를 판별하고, 그 결과를 이용하여 insert/update 를 구분하게된다.

 

참고로 100% 새로운 엔티티라서 insert 를 호출해야하는 경우엔 usePersist 속성을 true 로 만들어주면 된다. 이 속성을 설정하는 법은 아래와 같다.

@Bean                                                       
public ItemWriter<Person> chunkWriter() {                   
    return new JpaItemWriterBuilder<Person>()               
            .entityManagerFactory(entityManagerFactory)     
            .usePersist(true)                               
            .build();                                       
}                                                           

다시 본론으로 돌아와서 우리의 person 은 DB 에서 조회한 값이므로 id 필드가 당연히 채워져있고, 그렇기때문에 select-update 쿼리가 날아가게 되는 것이다. JPA 는 기본적으로 dirty check 기능을 지원하므로 이 기능을 사용한다면 굳이 merge 를 호출하지 않아도 될 것이라고 생각했다.

 

@Bean                                     
public ItemWriter<Person> chunkWriter() { 
    return persons -> { };                
}                                         

사실 dirty check 는 JPA 사용자가 직접 뭔가를 호출해서 처리하는 기능이 아니므로 writer 에서 아무것도 안하면 되지않을까 라고 생각하고 위와 같은 빈 구현을 사용했는데, 생각대로 작동하는걸 확인했다.

 

class JpaDirtyCheckWriter<T> implements ItemWriter<T> {
    @Override
    public void write(List<? extends T> items) throws Exception {

    }
}

@Bean                                      
public ItemWriter<Person> chunkWriter() {  
    return new JpaDirtyCheckWriter<>();    
}                                          

이처럼 구현 클래스를 만들어놓으면 의미전달도 코드로 잘 표현할 수 있을 것 같다.

 

# 2021.08.07 추가

댓글로 페이지 갯수와 실제 dirty check 로 인한 업데이트 간에 상관관계에 대한 이슈를 알려주셨다. 해당 이슈의 정확한 문제는 마지막 페이지에 있는 엔티티에 대해서 업데이트를 하지 않는 것이다. 그 이유는 JpaPaigingItemReader 의 구현을 보면 알 수 있는데 doReadPage() 메서드를 확인해보면 아래처럼 오버라이딩을 하고있다.

@Override                                                                                          
@SuppressWarnings("unchecked")                                                                     
protected void doReadPage() {                                                                      
                                                                                                   
	EntityTransaction tx = null;                                                                   
	                                                                                               
	if (transacted) {                                                                              
		tx = entityManager.getTransaction();                                                       
		tx.begin();                                                                                
		                                                                                           
		entityManager.flush();                                                                     
		entityManager.clear();                                                                     
	}//end if                                                                                      
                                                                                                   
	// 중략... 이 지점에서 실제 paging select 쿼리를 날림                                                                                 
	                                                                                               
	if (!transacted) {                                                                             
		List<T> queryResult = query.getResultList();                                               
		for (T entity : queryResult) {                                                             
			entityManager.detach(entity);                                                          
			results.add(entity);                                                                   
		}//end if                                                                                  
	} else {                                                                                       
		results.addAll(query.getResultList());                                                     
		tx.commit();                                                                               
	}//end if                                                                                      
}

dirty checking 으로 인한 업데이트 쿼리는 EntityManager 에서 flush() 를 호출하는 시점에 이루어지는데, 위 구현부를 보면 실제 select 쿼리를 날리기 전에 flush() 를 호출하고 있다. 원인은 이것때문인데 총 페이지가 2개 라고 가정할시

  • flush() 호출(조회한적이 없으므로 지연쿼리가 날아갈 대상 엔티티가 없음)
  • 1번째 조회
  • flush() 호출(1번째 조회시 변경된 엔티티들에 대해 업데이트 쿼리 실행) 
  • 2번째 조회
  • 종료

위와 같은 흐름으로 진행되기때문에 2번째 조회에서 변경된 엔티티들은 flush() 가 호출되지 못하는 것이다. 참고로 2번째 페이지가 페이지 총 갯수에 딱 맞는 row 가 조회되게되면 2번째 페이지도 업데이트가 잘 되는걸 볼 수 있는데 이건 마지막 페이지에 대한 조건문같은게 있는건 아니고 해당 페이지가 마지막인지 아닌지 알 수 없기때문에 3번째 조회 쿼리가 날아가기 때문에 flush()가 호출될 수 있는 것이다.

 

해당 문제는 JpaPagingItemReader 가 아닌 RepositoryItemReader 를 사용해서 해결할 수 있었다. 참고로 RepositoryItemReader 는 직접 EntityManager 에 대한 의존성을 갖지 않기 때문에 상위(아마도 step)에서 트랜잭션을 관리할 것으로 보인다.

 

정리하면 batch 에서 JPA 를 사용하고, dirty checking 도 사용해야 한다면 JpaPagingItemReader 대신에 RepositoryItemReader 를 사용하자. 그리고 가능하다면 batch 에서는 JPA 말고 다른 대안을 찾는게 좋을 것 같다.(spring-data-jdbc 라던가)

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함