[7회차] MyBatisPagingItemReader로 DB내용을 읽고, MyBatisItemWriter로 DB에 쓰기

아래 글은 한국 스프링 사용자 모임(KSUG)에서 진행된 스프링 배치 스터디 내용을 정리한 게시글입니다.
DEVOCEAN에 연재 중인 KIDO님의 글을 참고하여 실습한 내용을 기록했습니다.

 

원본: [SpringBatch 연재 07] MyBatisPagingItemReader로 DB내용을 읽고, MyBatisItemWriter로 DB에 쓰기

 

[SpringBatch 연재 07] MyBatisPagingItemReader로 DB내용을 읽고, MyBatisItemWriter로 DB에 쓰기

 

devocean.sk.com

 

MyBatis는 자바 기반의 SQL 매퍼 프레임워크로, 애플리케이션에서 데이터베이스와 상호작용하기 위한 도구입니다. 주로 SQL 문을 직접 작성하고 이를 자바 코드와 연결하는 데 사용됩니다.

 

JPA, Hibernate, MyBatis의 차이가 헷갈려서, 먼저 각 개념이 무엇인지 정리해보았습니다.

특징 JPA Hibernate MyBatis
쿼리 작성 JPQL (추상화된 SQL) 사용 HQL 사용 SQL 직접 작성
자동화 수준 CRUD 자동화 (Spring Data JPA) ORM으로 대부분 자동화 SQL 제어 필요
복잡한 쿼리 JPQL로 작성 가능하지만 제약 있음 HQL 또는 네이티브 SQL 사용 가능 SQL 작성으로 제약 없음
초기 설정 비교적 간단 비교적 간단 XML 매퍼 등 설정 추가 필요
유연성 엔티티 중심으로 유연성 제한 엔티티 중심 SQL 기반으로 유연함
  • JPA는 CRUD 중심 애플리케이션에 적합하며 빠른 개발 속도를 제공합니다.
  • Hibernate는 더 세밀한 제어와 확장성을 원할 때 적합합니다.
  • MyBatis는 복잡한 SQL을 제어하고 작성해야 하는 프로젝트에 적합합니다.

MyBatisItemReader

Spring Mybatis에서 제공하는 ItemReader 인터페이스를 구현하는 클래스입니다.

MyBatis의 Object Relation Mapper를 이용합니다.

장점

  1. 간편한 설정: MyBatis 쿼리 매퍼를 직접 활용하여 데이터를 읽을 수 있어 설정이 간단합니다.
  2. 쿼리 최적화: MyBatis의 다양한 기능을 통해 최적화된 쿼리를 작성할 수 있습니다.
  3. 동적 쿼리 지원: 런타임 시 조건에 따라 동적으로 쿼리를 생성할 수 있습니다.

단점

  1. MyBatis 의존성: MyBatis 라이브러리에 대한 의존성이 존재합니다.
  2. 커스터마이징 복잡: Chunk-oriented Processing 방식에 비해 커스터마이징이 더 복잡할 수 있습니다.

주요 구성 요소

  1. SqlSessionFactory: SqlSessionFactory는 아래와 같은 방법으로 설정하실 수 있습니다.
    • @Bean 어노테이션을 사용하여 직접 생성
    • Spring Batch XML 설정 파일에서 설정
    • Java 코드에서 직접 생성 적절한 설정을 통해 다양한 데이터베이스 환경에서도 데이터를 안정적으로 읽을 수 있도록 도와줍니다.
  2. MyBatisPagingItemReader는 SqlSessionFactory 객체를 통해 MyBatis와 연동됩니다.
  3. QueryId
    • 쿼리 ID는 com.example.mapper.CustomerMapper.selectCustomers와 같은 형식으로 지정됩니다.
    • 여러 테이블에서 데이터를 읽어야 할 경우, 네임스페이스를 활용하여 매퍼 파일을 구성하는 것이 좋습니다.
    • 쿼리를 참조할 때는 매퍼 파일의 네임스페이스를 반드시 확인하시기 바랍니다.
  4. MyBatisPagingItemReader의 setQueryId() 메소드를 통해 데이터를 읽을 MyBatis 쿼리 ID를 설정합니다.
  5. ParameterValues
    • JobExecutionContext의 값을 가져오는 SpEL 표현식을 사용할 수 있습니다.
    • 예: #{yesterday,jdbcType=TIMESTAMP}와 같이 매퍼 파일에서 파라미터를 지정할 수 있습니다.
    • 이때, MyBatis의 타입 핸들러가 적절히 설정되어 있다면 JodaTime 날짜 객체도 파라미터로 전달 가능합니다.
    • step 스코프를 사용하면 JobExecutionContext를 통해 SpEL 표현식을 적용할 수 있습니다.
  6. 추가적인 파라미터는 parameterValues 맵을 통해 전달 가능합니다.
  7. PageSize
    • pageSize는 MyBatisPagingItemReader가 데이터를 읽을 때 사용하는 배치 단위의 크기를 결정합니다.
    • 이를 통해 offset과 limit 기반의 페이징 처리가 이루어지며, 효율적인 데이터 처리가 가능합니다.
    • 청크 기반으로 데이터 처리가 이루어질 경우, 이 값을 적절히 설정하는 것이 중요합니다.

추가 구성 요소

  1. SkippableItemReader
    • 데이터 읽기 과정에서 오류가 발생했을 경우, 해당 Item을 건너뛰도록 설정할 수 있습니다.
    • 이를 통해 프로세스의 중단 없이 처리가 지속되도록 유연성을 제공합니다.
  2. ReadListener
    • 데이터 읽기 시작, 종료, 오류 발생 등의 이벤트를 처리할 수 있습니다.
    • 예를 들어, 읽기 과정 중 로그를 남기거나 특정 작업을 트리거하는 데 유용하게 활용됩니다.
  3. SaveStateCallback
    • 잡이 중단되었을 때 현재 상태를 저장하여, 이후 재시작 시 이어서 처리할 수 있도록 도와줍니다.

실습

1. build.gradle 에 MyBatis 의존성 추가

// MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.1'

2. customer.xml 파일 생성

resources 폴더 아래에 생성해야 하며, 저는 resources/xml_mybatis 폴더에 만들어 두었습니다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!--namespace: XML Mapper 파일의 식별자, 쿼리들을 그룹화 해서 모아놓은 이름 공간-->
<mapper namespace="batch_sample.jobs">

    <resultMap id="customerResult" type="com.example.batch_sample.jobs.task06.Customer">
        <result property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="age" column="age"/>
        <result property="gender" column="gender"/>
    </resultMap>
    
    <!-- resultMap: 결과로 반환할 결과맵. db column과 java field 이름 매핑   -->
    <select id="selectCustomers" resultMap="customerResult">
--         쿼리 지정
        SELECT id, name, age, gender
        FROM customer
                 LIMIT #{_skiprows}, #{_pagesize}
--         _skiprows: 오프셋. 쿼리 별과에서 얼마나 스킵할지 지정. pageSize를 지정했다면 자동으로 계산 됨
--         _pagesize: 한 번에 가져올 페이지 지정
    </select>
</mapper>

application.yaml 파일에 만든 xml 파일의 위치를 설정합니다.

mybatis:
  mapper-locations: classpath:/xml_mybatis/customer.xml

3. 전체 코드

@Slf4j
@Configuration
public class MyBatisReaderJobConfig {
    /**
     * CHUNK 크기를 지정한다.
     */
    public static final int CHUNK_SIZE = 2;
    public static final String ENCODING = "UTF-8";
    public static final String MYBATIS_CHUNK_JOB = "MYBATIS_CHUNK_JOB";

    @Autowired
    DataSource dataSource;

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    /**
     * DB 쿼리 결과를 읽을 수 있도록 ItemReader 를 반환한다.
     * @return MyBatisPagingItemReader
     */
    @Bean
    public MyBatisPagingItemReader<Customer> myBatisItemReader() throws Exception {

        return new MyBatisPagingItemReaderBuilder<Customer>()
                .sqlSessionFactory(sqlSessionFactory) // 세션 팩토리 지정
                .pageSize(CHUNK_SIZE) // 페이징 단위 지정
                .queryId("batch_sample.jobs.selectCustomers") // 네임스페이스 + SQL ID
                .build();
    }

    @Bean
    public FlatFileItemWriter<Customer> task07customerCursorFlatFileItemWriter() {
        return new FlatFileItemWriterBuilder<Customer>()
                .name("customerCursorFlatFileItemWriter")
                .resource(new FileSystemResource("./output/task07_customer_new_v4.csv"))
                .encoding(ENCODING)
                .delimited().delimiter("\\t")
                .names("Name", "Age", "Gender")
                .build();
    }

    @Bean
    public Step task07customerJdbcCursorStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) throws Exception {
        log.info("------------------ Init customerJdbcCursorStep -----------------");

        return new StepBuilder("customerJdbcCursorStep", jobRepository)
                .<Customer, Customer>chunk(CHUNK_SIZE, transactionManager)
                .reader(myBatisItemReader())
                .processor(new CustomerItemProcessor())
                .writer(task07customerCursorFlatFileItemWriter())
                .build();
    }

    @Bean
    public Job task07customerJdbcCursorPagingJob(Step task07customerJdbcCursorStep, JobRepository jobRepository) {
        log.info("------------------ Init customerJdbcCursorPagingJob -----------------");
        return new JobBuilder(MYBATIS_CHUNK_JOB, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(task07customerJdbcCursorStep)
                .build();
    }
}

MyBatisItemWriter

Spring Batch에서 제공하는 ItemWriter 인터페이스를 구현한 클래스입니다.

MyBatis를 통해 데이터를 데이터베이스에 저장하는 데 사용됩니다.

구성 요소

  • SqlSessionTemplate
    • MyBatis의 SqlSession 생성을 관리하는 템플릿 객체입니다.
  • SqlSessionFactory
    • SqlSessionTemplate 생성을 위한 팩토리 객체입니다.
  • StatementId
    • 실행할 MyBatis SQL 맵퍼의 Statement ID입니다.
  • ItemToParameterConverter
    • 객체를 ParameterMap으로 변환하는 기능을 제공합니다.
구분 내용
장점 ORM 연동 MyBatis를 활용하여 다양한 데이터베이스에 데이터를 저장할 수 있습니다.
SQL 쿼리 분리 SQL 쿼리를 Java 코드에서 분리하여 관리 및 유지 보수가 편리합니다.
유연성 설정을 통해 데이터를 다양한 방식으로 저장할 수 있습니다.
단점 설정 복잡성 MyBatis 설정 및 SQL 맵퍼 작성이 다소 복잡할 수 있습니다.
데이터베이스 종속 특정 데이터베이스 환경에 의존적일 수 있습니다.
오류 가능성 설정 오류로 인해 데이터 손상이 발생할 가능성이 있습니다.

실습하기

1. Customer.xml 파일에 insertCustomers 추가하기

<insert id="insertCustomers" parameterType="com.example.batch_sample.jobs.task06.Customer">
    INSERT INTO customer2(name, age, gender) VALUES (#{name}, #{age}, #{gender});
</insert>

2. MyBatisBatchItemWriter 메서드 만들기

@Bean
public MyBatisBatchItemWriter<Customer> task07mybatisItemWriter() {
    return new MyBatisBatchItemWriterBuilder<Customer>()
            .sqlSessionFactory(sqlSessionFactory)
            .statementId("batch_sample.jobs.insertCustomers")
            .build();
}

Map으로 파라미터를 전달한다면 다음과 같이 작성할수도 있습니다.

@Bean
public MyBatisBatchItemWriter<Customer> mybatisItemWriter() {
    return new MyBatisBatchItemWriterBuilder<Customer>()
            .sqlSessionFactory(sqlSessionFactory)
            .statementId("batch_sample.jobs.insertCustomers")
           .itemToParameterConverter(item -> {
               Map<String, Object> parameter = new HashMap<>();
               parameter.put("name", item.getName());
               parameter.put("age", item.getAge());
               parameter.put("gender", item.getGender());
               return parameter;
           })
            .build();
}

해당 방법을 사용하면 객체의 필드 이름과 Mapper의 SQL 파라미터 이름이 달라도 커스터마이징이 가능해 유연성이 높습니다.

결과

mysql> select * from customer2;
+----+---------+-----+--------+
| id | name    | age | gender |
+----+---------+-----+--------+
|  1 | Alice   |  30 | F      |
|  2 | Bob     |  45 | M      |
|  3 | Charlie |  25 | M      |
|  4 | Diana   |  29 | F      |
|  5 | Evan    |  35 | M      |
|  6 | Fiona   |  40 | F      |
|  7 | George  |  55 | M      |
|  8 | Hannah  |  32 | F      |
|  9 | Alice   |  30 | F      |
| 10 | Bob     |  45 | M      |
| 11 | Charlie |  25 | M      |
| 12 | Diana   |  29 | F      |
| 13 | Evan    |  35 | M      |
| 14 | Fiona   |  40 | F      |
| 15 | George  |  55 | M      |
| 16 | Hannah  |  32 | F      |
+----+---------+-----+--------+
16 rows in set (0.00 sec)

WrapUp

  • MyBatisPagingItemReader, MyBatisBatchItemWriter를 사용 해보았습니다.

MyBatis는 처음 쓰는 거라 조금 헷갈리고 생소했지만 새로운 경험이어서 좋았습니다.