이번에 맡은 업무에서 Spring Batch를 중점적으로 사용하고 있는데, 기존 로직을 리팩토링하면서 겪게 된 이슈를 기록해본다.
먼저 기존 로직은 아래와 같다. (코드 스니핏이고, 일부 업무와 관계된 네이밍은 모두 dummy로 치환하였다)
결국 배치로직에서 bulk로 읽어온 것을 하나씩 read/process/write 구조로 처리하고,
해당 내용이 없어지면 다음 page를 bulk로 읽어온다.
이 포스팅에 다루는 문제의 핵심은 bulk로 받아온 List의 첫 element를 마치 Queue처럼 순차적으로 빼서 쓰는 부분이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
private List<SomeEntity> someEntities = Lists.newArrayList(); @BeforeRead public void beforeRead() { if (someEntities.isEmpty()) { Page<SomeEntity> entitiesAtDb = someEntityRepository.findBySomeCondition(condition, new PageRequest(page, CHUNK_SIZE)); someEntities = new ArrayList(entitiesAtDb.getContent()); ... } } @Override public SomeEntity read() { return someEntities.isEmpty() ? null : someEntities.remove(0); } @AfterRead public void afterRead(SomeEntity item) { if (someEntities.isEmpty()) { page++; } } |
그런데 조금 불필요한 부분(redundancy)이 보이는 것 같다.
entitiesAtDb.getContent()한 내용이
List<SomeEntity>인데 왜 굳이
new ArrayList()로 래핑을 한 것일까.
1 2 3 4 5 6 |
/** * Returns the page content as {@link List}. * * @return */ List<T> getContent(); |
그래서 해당 부분을 제거하기로 결정했다.
1 2 3 4 5 6 7 |
@BeforeRead public void beforeRead() { if (someEntities.isEmpty()) { someEntities = someEntityRepository.findBySomeCondition(condition, new PageRequest(page, CHUNK_SIZE)).getContent(); ... } } |
이제 코드가 불편한 느낌이 없이 깔끔해졌다.
어? 그런데 테스트코드에서 런타임에 오류가 발생한다.
1 2 3 4 5 6 7 8 9 10 |
java.lang.UnsupportedOperationException: null at java.util.Collections$UnmodifiableList.remove(Collections.java:1317) ... at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:204) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:738) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:157) at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:99) at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:282) at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:96) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) |
new ArrayList()로 래핑해놓은 이유가 있었다.
아마도 내부 List가 immutable하게 구성되어 있나보다. 말이 나온 김에 좀더 찾아보기로 했다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public interface Page<T> extends Slice<T> { ... // Page는 Slice를 상속한다. } public interface Slice<T> extends Iterable<T> { ... // Slice는 Iterable을 상속한다. 사실상 List라고 생각할 수 있다. } public class SliceImpl<T> extends Chunk<T> { ... // Slice의 구현체는 Chunk를 상속한다. } abstract class Chunk<T> implements Slice<T>, Serializable { /* * (non-Javadoc) * @see org.springframework.data.domain.Slice#getContent() */ public List<T> getContent() { // Chunk에서 getContent()는 content를 unmodifiableList로 래핑해서 반환한다. return Collections.unmodifiableList(content); } } |
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 32 |
public class Collections { ... static class UnmodifiableList<E> extends UnmodifiableCollection<E> implements List<E> { ... public E set(int index, E element) { // 당연한 얘기겠지만, 값을 변조할 수 있는 메소드들은 모두 막혀있다. throw new UnsupportedOperationException(); } public void add(int index, E element) { throw new UnsupportedOperationException(); } public E remove(int index) { throw new UnsupportedOperationException(); } public boolean addAll(int index, Collection<? extends E> c) { throw new UnsupportedOperationException(); } @Override public void replaceAll(UnaryOperator<E> operator) { throw new UnsupportedOperationException(); } @Override public void sort(Comparator<? super E> c) { throw new UnsupportedOperationException(); } ... } ... } |
일반적으로 JPA에서는 List 엄밀히 따지면, mutableList를 반환한다.
그래서 element를 추가, 삭제할 수 있다.
어찌보면 JPA 철학상 당연히 그래야 하는지도 모른다.
다만, Paging을 사용하는 경우에는 readonly처럼 생각한 모양이다.
그래서 element를 추가, 삭제할 수 없도록 immutableList를 반환하고 있다.