[공부정리] Update, Delete 배치 작업 페이징이 깨지는 문제 해결

jeyong·2024년 5월 8일
0

공부 / 생각 정리  

목록 보기
70/120
post-custom-banner


이번 게시글에서는 위치추적모듈 프로젝트의 결제 처리를 위한 Batch 로직 구현 중 발견된 Reader에서의 페이징 문제에 대해 다루고자 한다. 이 문제는 데이터 처리 중 흔히 발생할 수 있어, 이번 기회에 해결 과정을 자세히 기록하려 한다.

1. 문제 파악

위 이미지에서 볼 수 있듯, Batch 작업은 10개의 데이터를 읽은 후 페이징이 깨지면서 다음 10개의 데이터 처리를 제대로 수행하지 못한다.

따라서, 11번째 데이터는 다음 Batch 실행 주기인 1분 후에 처리된다.

이와 같은 패턴은 31번 데이터에서도 반복된다. 초기 페이징이 깨지기 때문에 데이터는 또 다시 다음 주기에 처리된다.

2. 문제 해결: ZeroPaging

페이징 문제를 해결하기 위해 Read 동작을 항상 0페이지에서 시작하도록 코드를 수정하는 방법이 흔히 사용된다. 아래는 RepositoryItemReader를 기준으로 이를 구현한 CustomRepositoryItemReader이다.

@RequiredArgsConstructor
public class CustomRepositoryItemReader<T> extends RepositoryItemReader<T> {
    private PagingAndSortingRepository<?, ?> repository;
    private List<?> arguments;
    private String methodName;
    private int pageSize;
    private Sort sort;

    public CustomRepositoryItemReader(PagingAndSortingRepository<?, ?> repository, String methodName, List<?> arguments, int pageSize, Map<String, Sort.Direction> sort) {
        this.sort = convertToSort(sort);
        this.pageSize = pageSize;
        this.methodName = methodName;
        this.arguments = arguments;
        this.repository = repository;
        super.setRepository(repository);
        super.setMethodName(methodName);
        super.setArguments(arguments);
        super.setPageSize(pageSize);
        super.setSort(sort);
    }
    private Sort convertToSort(Map<String, Sort.Direction> sorts) {
        List<Sort.Order> sortValues = new ArrayList();
        Iterator var3 = sorts.entrySet().iterator();

        while(var3.hasNext()) {
            Map.Entry<String, Sort.Direction> curSort = (Map.Entry)var3.next();
            sortValues.add(new Sort.Order((Sort.Direction)curSort.getValue(), (String)curSort.getKey()));
        }

        return Sort.by(sortValues);
    }

    @Override
    protected List<T> doPageRead() throws Exception {
        Pageable pageRequest = PageRequest.of(0, this.pageSize, this.sort);
        MethodInvoker invoker = createMethodInvoker(this.repository, this.methodName);
        List<Object> parameters = new ArrayList();
        if (this.arguments != null && this.arguments.size() > 0) {
            parameters.addAll(this.arguments);
        }

        parameters.add(pageRequest);
        invoker.setArguments(parameters.toArray());
        Slice<T> curPage = (Slice)this.doInvoke(invoker);
        return curPage.getContent();
    }

    private Object doInvoke(MethodInvoker invoker) throws Exception {
        try {
            invoker.prepare();
        } catch (NoSuchMethodException | ClassNotFoundException var3) {
            ReflectiveOperationException e = var3;
            throw new DynamicMethodInvocationException(e);
        }

        try {
            return invoker.invoke();
        } catch (InvocationTargetException var4) {
            InvocationTargetException e = var4;
            if (e.getCause() instanceof Exception) {
                throw (Exception)e.getCause();
            } else {
                throw new AbstractMethodInvokingDelegator.InvocationTargetThrowableWrapper(e.getCause());
            }
        } catch (IllegalAccessException var5) {
            IllegalAccessException e = var5;
            throw new DynamicMethodInvocationException(e);
        }
    }

    private MethodInvoker createMethodInvoker(Object targetObject, String targetMethod) {
        MethodInvoker invoker = new MethodInvoker();
        invoker.setTargetObject(targetObject);
        invoker.setTargetMethod(targetMethod);
        return invoker;
    }
}

이 방식으로 doPageRead 메서드는 항상 0페이지를 읽도록 설정하여, 페이징 문제를 해결한다.

deleted_at에 pagesize 만큼 한번의 Batch안에서 수행되는 모습을 볼 수 있다.

3. 문제 점검

CustomRepositoryItemReader로의 해결책이 프로젝트의 요구사항에 완벽하게 부합했다면 좋았겠지만, 실제로는 두 가지 타입의 배치 작업을 처리해야 하는 현재 프로젝트의 구조상 적합하지 않다.

현재 프로젝트에는 두 가지 주기적인 배치 작업이 구성되어 있다. 첫 번째는 매월 실행되는 정기 결제 처리이며, 두 번째는 매일 실행되어 회원 탈퇴 처리를 담당한다. 매일 실행되는 배치는 결제 실패나 성공에 따라 회원 정보의 업데이트 또는 삭제가 반드시 이루어진다. 이 경우 페이지를 항상 0에서 시작하는 방식으로 페이징을 고정하여 사용할 수 있다.

하지만 매월 실행되는 정기 결제 배치는 결제 실패한 회원에 대해서만 특정 작업을 수행하므로, 이러한 상황에서 페이지를 0으로 고정하면 이미 처리된 데이터를 불필요하게 반복 처리하게 되어 결제가 두번 처리 될 수도 있다.

따라서, 페이지 기반 처리 방식인 PagingItemReader 대신에, 커서 기반의 접근 방식을 사용하는 CursorItemReader를 도입하는 것을 고려하고 있다. CursorItemReader는 데이터베이스 커서를 사용해 연속적으로 데이터를 읽어오므로, 한 번의 쿼리로 시작한 이후에는 데이터베이스 세션을 유지하며 필요한 데이터만 순차적으로 불러온다.

3-1. CursorItemReader?

현재 Spring Batch 5.1.1의 기준으로 사용할 수 있는 CursorItemReader는 아래와 같다.

현재 프로젝트가 JPA를 기반으로 데이터베이스에 접근하고 있으므로 JpaCursorItemReader를 사용하고 싶었지만, JpaCursorItemReader는 Iterator로 cursor로 동작하는 것처럼 흉내 내는 방식으로 구현되었기 때문에 Out of Memory를 유발할 수 있어 사용하지 않았다. HibernateCursorItemReader는 Deprecated 되어 사용하지 못하는 상황이다. 그래서 JdbcCursorItemReader를 사용해야 하는 상황이다. 하지만 JdbcCursorItemReader는 Native SQL을 직접 작성하여 구현해야 한다. 여러 가지 가능성을 따져보며 고민하던 중, CursorItemReader의 근본적인 문제점인 데이터베이스 커넥션을 물고 작업이 끝날 때까지 놓지 않는다는 문제점이 과연 현재 내 상황에 적절할까라는 생각을 해보았다.

현재 상황은 결제가 필요한 상황에서 회원의 데이터를 읽어와서 처리하는 상황이다. 만약 회원이 늘어가면 늘어갈수록 데이터베이스 커넥션을 물고 있는 상황이 많아질 텐데, CursorItemReader를 적용해도 될까라는 의문이 들었다. 또한 현재 구현하고 있는 정기 결제 기능과 회원 탈퇴 기능은 대용량 데이터도 아니고, 멀티 쓰레딩이 필요한 상황도 아니다. 하지만 성능적으로 뛰어나야 하는 상황도 아니다. 그래서 성능적인 측면을 고려해서 CursorItemReader를 고려하는 것은 아니라고 생각했고, 결국 남은 것은 구현의 편의성이라고 생각했다.

이러한 생각들 속에서 이미 구현되어 있는 ItemReader 중에서 JPA 기반이고 이미 잘 구현되어 있는 NoOffset과 ZeroPaging 기반 Reader를 사용해보고자 하였고, 향로님이 만드신 QuerydslItemReader를 사용하기로 했다. 프로젝트에 Querydsl이 적용되어 있기도 해서 이미 만들어져 있는 것을 사용하기만 하면 된다.

4. 문제 해결: QuerydslItemReader

QuerydslItemReader를 사용하여 정기 결제 기능과 회원 탈퇴 기능의 Reader에서 발생하는 문제들을 해결했다. 참고로 향로님이 구현하신 QuerydslItemReader를 사용하려면 아래 의존성을 추가하여 사용하면 된다.

repositories {
    mavenCentral()
    maven { url 'https://jitpack.io' }
}

dependencies {
	 implementation 'com.github.jojoldu.spring-batch-querydsl:spring-batch-querydsl-reader:2.4.8'
 }

4-1. 회원 탈퇴

회원 탈퇴 기능은 ZeroPaging 기반의 ItemReader를 사용하면 된다. 그래서 QuerydslZeroPagingItemReader를 사용하여 구현하였다. 구현 코드는 아래와 같다.

@Bean(DELETION_READER)
@StepScope
public QuerydslZeroPagingItemReader<Member> memberItemReaderForDeletion(@Value("#{jobParameters[JobStartTime]}") LocalDateTime jobStartTime) {
    return new QuerydslZeroPagingItemReader<>(
            entityManagerFactory,
            chunkSize,
            queryFactory -> queryFactory
                    .selectFrom(member)
                        .where(member.paymentFailureBannedAt.isNull()
                        .and(member.deletionRequestedAt.loe(jobStartTime.minusDays(2)))));  
}

매일 실행되는 탈퇴 처리 배치는 결제 실패나 성공에 따라 회원 정보의 업데이트 또는 삭제가 반드시 이루어지기 때문에 정기 결제가 필요한 모든 회원에 대해서 읽어올 수 있다.

4-2. 정기결제

매월 실행되는 정기 결제 배치는 결제 실패한 회원에 대해서만 업데이트를 수행하므로, 이러한 상황에서 페이지를 0으로 고정하면 이미 처리된 데이터를 불필요하게 반복 처리하게 될 것이고, 결제가 두 번 처리될 수도 있다. 그래서 NoOffset 기반의 ItemReader를 이용하여 이 문제를 해결하였다.

NoOffsetItemReader는 직전 조회 결과의 마지막 id를 이용하여 그 다음 결과부터 조회할 수 있도록 한다. 이를 통해 읽을 페이지가 많아도 일정한 속도를 유지할 수 있도록 해준다. 마지막 id를 이용하여 그 다음 결과부터 조회할 수 있도록 하는 특성을 이용하여 정기 결제의 Reader의 문제점을 해결하였다. 구현된 코드는 아래와 같다.

@Bean(AUTOMATIC_PAYMENT_READER)
public QuerydslNoOffsetPagingItemReader<Member> memberItemReaderForAutomaticPayment() {
    QuerydslNoOffsetNumberOptions<Member, Long> options = new QuerydslNoOffsetNumberOptions<>(member.id, Expression.ASC);
    return new QuerydslNoOffsetPagingItemReader<>(
            entityManagerFactory,
            chunkSize,
            options,
            queryFactory -> queryFactory
                    .selectFrom(member)
                        .where(member.paymentFailureBannedAt.isNull()
                        .and(member.deletionRequestedAt.isNull())));
}

매월 실행되는 정기 결제 배치는 결제 실패한 회원에 대해서만 업데이트를 수행하므로 회원의 id를 기준으로 NoOffset을 적용하여 사용하였고, 이를 통해 탈퇴 처리가 필요한 모든 회원들에 대해서 읽어 올 수 있다.

profile
노를 젓다 보면 언젠가는 물이 들어오겠지.
post-custom-banner

0개의 댓글