ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Batch를 사용하며 겪은 이슈들
    Spring 2022. 2. 20. 14:50

     

    최근에 spring batch를 사용하여 작업을 할 일이 있었다. spring batch에 대해서는 대략적으로 알고 있었지만, 실제로 사용해본 것은 처음이었기에 여러 함정에 빠졌었다. 이미 유명한 내용들이지만, 나의 경험을 추가하여 정리해보고자 한다.

     

    Spring Batch란?

    우선 spring batch가 무엇인지 간단하게 알아보자. spring batch는 spring 생태계에서 제공하는 batch framework이다. batch라는 단어에서 알 수 있듯이 한 번에 대규모로 뭔가를 하는 거다.

     

    애플리케이션을 개발할 때 보통 실시간으로 들어오는 요청을 처리하는 코드를 작성하지만, 종종 이따금 한 번씩 수행되어야 하는 코드도 존재한다. 보통 이런 경우에는 대규모로 데이터를 읽어서 처리해야 한다. 조건에 해당하는 사용자에게 n분(시간, 일)마다 뭔가를 발송한다든지, 하루가 끝날 때마다 데이터를 집계하고 처리한다든지 등 요구 사항은 무궁무진하다. 이런 상황에서 편리하게 사용할 수 있는 것이 spring batch이다. 

     

    보통은 spring batch에서 데이터의 읽기와 처리 로직을 작성하고 이 작업을 trigger할 수 있는 엔드포인트를 하나 열어둔 뒤, 젠킨스 등으로 주기적으로 trigger하는 방식을 많이 사용한다. (여기서 처리란 데이터 업데이트하기, API 요청하기, 로그 남기기 등 실제로 하고 싶었던 작업을 의미한다.)

     

    PagingItemReader의 함정: 왜 처리되지 않는 데이터들이 생기지?

    여기에서 설명하는 함정은 paging 방식을 잘 이해하지 못하고 사용하면 많이들 빠지기 쉬운 함정이다. 위에서 설명했듯이 batch로 뭔가를 처리한다는 것은 크게 데이터를 읽어오는 단계와 데이터를 처리하는 단계로 나뉘는데, 읽어오는 것(reader)은 크게 cursor 방식과 paging 방식으로 나눌 수 있다.

     

    cursor 방식은 현재 row를 가리키는 cursor을 한 칸 한 칸 옮기면서 데이터를 계속해서 읽어오는 방식이다. 반면 paging 방식은 page size 단위로 데이터를 읽어오는 방식이다. 가령 page size를 20으로 결정했다면 1부터 20, 21부터 40, 41부터 60, 이런 식으로 읽어오게 된다. 담당할 구역(?)을 나눌 수 있으므로 cursor과 달리 멀티쓰레딩을 활용할 수 있다. (참고로 cursor과 paging 중에서 어떤 방식을 사용하든, chunk만큼 데이터가 쌓였을 때 writer에 넘기게 된다.) 

     

    여기서 다루는 함정은 PagingItemReader을 사용할 때 발생하는 문제이다. 실무에서는 AbstractPagingItemReader라는 추상클래스를 상속한 JpaPagingItemReader, MyBatisPagingItemReader, JdbcPagingItemReader 등을 사용하는데, 어떤 DB 기술을 사용하든 공통적으로 겪을 수 있다.

     

    reader에서는 "SELECT something FROM table WHERE blah is blahha" 구문을 실행하는데, 만약 이후 단계에서 WHERE절에 해당하는 데이터를 변경할 경우(=blahha) 문제가 생긴다. 가령 page size(조회하는 단위)와 chunk size(트랜잭션으로 묶이는 단위이자 writer에 넘기는 단위)가 20이라고 할 때, 조회하는 조건에 해당하는 데이터를 수정하는 batch를 실행한다면, 다음과 같은 상황을 겪을 수 있다.

    • 첫번째 page: 1~20은 처리됨
    • 두번째 page: 21~40은 처리되지 않음
    • 세번째 page: 41~60은 처리됨
    • 네번째 page: 61~80은 처리되지 않음
    • (하략)

     

    paging 방식을 다음과 같이 오해하고 있다면(적어도 나는 그랬다) 위의 상황이 이해되지 않을 것이다.

    • "SELECT something FROM table WHERE blah is blahha"은 한 번만 실행함. 즉 내가 읽어올 page(첫번째, 두번째...)는 처음에 전부 결정되어 있음(처음부터 잘 토막나 있음)
    • 첫번째 page 가져와서 잘 처리하고, 두번째 page 가져와서 잘 처리하고, 그러면 되는 거 아니야?

     

    하지만 실제로 paging 방식은 다음과 같이 작동한다. 

    • "SELECT something FROM table WHERE blah is blahha"를 매번 실행함
    • 매번 SELECT문을 실행한 결과에서 이번 회차에 해당하는 page를 가져옴. 즉 이번에 두번째 page를 퍼올리기로 차례라면, 방금 실행한 SELECT문 실행 결과 얻은 새로운 집합에서 순서상으로 두번째 page에 해당하는 데이터들만 가져옴.
    • 가령 21~40은 내 마음 속에서는 두번째 page였지만, 두번째로 실행한 SELECT문에서는 첫번째 page에 해당하기 때문에, 이번 회차에서는 41~60을 가져오는 것임. 내 마음 속에서 세 번째 page에 해당했던 61~80은 세번째로 실행한 SELECT문에서 두 번째 page에 해당하기 때문에 또 누락됨
    • 즉 SELECT문을 실행한 결과가 매번 달라진다면, 내가 이 batch를 실행함으로써 모조리 처리하고 싶었던 데이터 중에 뭔가는 누락될 수 있다는 뜻
    • 이 상황은 Java에서 List를 순회하는 중에 내부 요소를 수정한다면 exception이 발생하는 것에 비유할 수 있음 (교훈: 조회하는 도중에 대상을 수정하지 말 것)

     

    (참고로 이 문제에 대한 보다 친절한 설명과 가이드는 유명한 블로그에서 찾아볼 수 있다.)

     

    근데 요구사항을 충족하려면 어쩔 수 없이 조회한 대상을 수정해야 할 수도 있다. 이때는 2가지 정도의 방법을 선택할 수 있다. 

     

    첫번째, 매번 첫 page를 가져오도록 reader을 수정하자. 위에서 설명했듯이 일부 데이터가 처리되지 못하는 문제가 발생하는 것은 paging 기능이 다음과 같기 때문이다.

    • SELECT문을 매번 실행하는데
    • 가져오려는 page는 매번 하나씩 진전하고 있음 (이번엔 첫번째 page, 다음엔 두번째 page...)

     

    이 두 행동을 바꿀 수는 없는데, 두번째 행동을 무시하게 만들 수는 있다. 즉 매번 첫번째 page를 가져오도록 한다면 누락없이 데이터를 읽어올 수 있다. "WHERE blah is blahha"이라고 할 때, 앞서 처리가 되어서 더 이상 blahha가 아닌 데이터들은 어차피 결과 집합에 속해 있지도 않으니, 매번 첫번째 page를 가져오면 간단하게 해결되는 것이다. batch 처리로 인해 SELECT문의 결과 집합이 계속 작아지는 것을 활용한 것이다.

     

    이를 구현하기 위해서는 다음과 같이 override를 하면 된다. 

    WhateverPagingItemReader<Pay> reader = new WhateverPagingItemReader<Pay>() {
        @Override
        public int getPage() {
            return 0;
        }
    };

    원래는 getPage()는 AbstractPagingItemReader가 가지고 있는 메서드이고, 매번 1씩 증가시키고 있는 값인 page를 리턴하도록 되어 있다. page 대신 0을 리턴하도록 하면 매번 첫번째 page를 가져오게 된다. 

     

    MyBatisPagingItemReader의 경우 다음의 방식도 가능하다. MyBatisPagingItemReader을 사용할 때는 이 클래스에서 넣어주는 _page(page 변수값을 넣어주고 있다), _pageSize 파라미터를 이용해 직접 mapper에서 paging 로직을 구현해야 한다. 일반적으로는 _page 파라미터를 사용해서 정상적인 paging을 구현해야 하겠지만, 굳이 조회 조건 수정이 필요하다면 이때 _page 파라미터를 사용하지 않고, 매번 첫 페이지만 읽도록 SQL을 구현하면 된다. 

     

    두번째 방법은 cursor 방식을 이용하는 것이다. paging이 나에게 고통을 안겨주니 대체재를 쓰자는 방법이다. 근데 이건 그렇게 추천할 만한 방법은 아닌 것 같다. 애초에 cursor 대신 paging reader을 사용한 이유를 무시하는 것이기 때문이다. 

     

    cursor 대신 paging을 사용한 이유는 여러가지가 있을 것이다. 

    • JPA를 사용하고 있는데, JPA에는 CursorItemReader 자체가 없음
    • cursor은 connection이 길게 이어지기 때문에 안정적인 운영이 어려움 (이 부분을 자세하게 이해하는 것은 다음 기회에!)

    CursorItemReader을 사용해도 전혀 문제가 없는 케이스라면 모르겠지만, paging이 권장되는 상황이라면 첫번째 방법이 더 좋은 것 같다.

     

    갑자기 발생한 NPE 

    이건 MyBatisCursorItemReader을 사용하다가 겪은 이슈이다. batch를 실행했더니 NullPointerException이 발생했다. 다음 코드에서 cursorIterator가 null이었다.

     

    이 cursorIterator는 doRead()에 앞서서 실행되는 doOpen()에서 설정되는 필드이다. 근데 디버거를 돌려보니 doOpen()은 아예 실행이 되지 않고 있었다.

    의아해하면서 검색하다가 은혜로운 위 블로그에서 이슈를 정리해둔 포스트에서 문제의 원인을 알 수 있었다. (너무너무 감사합니다 ㅠㅠㅠ) 워낙 상세하게 잘 적어주셨고 내 문제도 정확히 저 이슈였다. 나도 리턴 타입이 MyBatisCursorItemReader이 아니라 ItemReader을 사용하고 있었고, @StepScope의 proxyMode 속성으로 인해서 리턴 객체 자체가 아니라 리턴 타입의 proxy 객체을 사용하고 있었던 것이다. proxy의 대상이 된 ItemReader 인터페이스에는 read()만 있고, open(), close()가 없기 때문에 open과 close가 필요한 reader에서는 이런 식으로 NPE가 발생하는 것이었다.

     

    그동안 사용한 MyBatisPagingItemReader은 open, close를 위해 override된 로직이 없기 때문에 ItemReader을 리턴 타입으로 사용해도 문제가 없었는데, MyBatisCursorItemReader를 사용하면서 기존 코드를 아무 생각없이 복붙하여 문제가 발생한 것이다. JpaPagingItemReader에서도 open, close에 해당하는 로직이 반드시 필요하기 때문에 문제가 발생한다.

     

    역시 제대로 알고 쓰지 않으면 문제가 발생한다는 것을 다시금 깨달았다..^^ 뭔가 급하게 마무리 짓는 느낌이지만 proxyMode며 open, close며 다음에 더 공부해보는 것으로!

     

     

     

     

     

    댓글

Designed by Tistory.