티스토리 뷰

반응형

정산 시스템을 개발할 때는 1) 대용량 데이터를 메모리 누수(OOM) 없이 안전하게 읽어오는 것, 2) 정확한 금액 연산(BigDecimal), 그리고 3) 대량 데이터 일괄 처리(Bulk Update)가 매우 중요합니다.

이번 글에서는 Spring Batch의 Chunk 기반 프로세싱과 MyBatis CursorItemReader를 결합하여 대용량 정산 배치를 구현하는 방법을 코드를 통해 알아보겠습니다.

1. 개발 환경 및 사전 준비

  • Java 17+
  • Spring Boot 3.x
  • Spring Batch
  • MyBatis
  • PostgreSQL / MySQL

테스트용 테이블 스키마 (DDL)

SQL
 
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    order_no VARCHAR(50) NOT NULL,
    amount NUMERIC(15, 2) NOT NULL, -- 정밀한 금액 계산을 위한 NUMERIC 타입
    fee NUMERIC(15, 2),             -- 정산 수수료
    status VARCHAR(20) DEFAULT 'READY'
);

2. 전체 소스 코드 구현

① 정산 대상 VO (OrderVo.java)

금액 다루기에 필수적인 BigDecimal 타입을 사용합니다.

Java
 
package com.example.batch.domain;

import java.math.BigDecimal;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class OrderVo {
    private Long id;
    private String orderNo;
    private BigDecimal amount; // 주문 금액
    private BigDecimal fee;    // 계산된 수수료
}

② MyBatis Mapper 인터페이스 및 XML

데이터를 커서 방식으로 읽어올 쿼리와 1,000건씩 일괄 수정(Batch Update)할 쿼리를 작성합니다.

OrderMapper.java

Java
 
package com.example.batch.mapper;

import com.example.batch.domain.OrderVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

@Mapper
public interface OrderMapper {
    // 배치 리더용 (전체 대상 조회)
    List<OrderVo> selectOrdersForSettlement();

    // 청크 단위 일괄 업데이트용
    void updateSettlementResult(@Param("list") List<? extends OrderVo> list);
}

OrderMapper.xml

XML
 
<?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">
<mapper namespace="com.example.batch.mapper.OrderMapper">

    <!-- 커서 기반 조회를 위해 전체 목록을 조회하는 쿼리 (실제로는 페이징/조건절 튜닝 필요) -->
    <select id="selectOrdersForSettlement" resultType="com.example.batch.domain.OrderVo">
        SELECT id, order_no, amount, fee
        FROM orders
        WHERE status = 'READY'
    </select>

    <!-- 1000건씩 한 번에 업데이트하는 배치 쿼리 -->
    <update id="updateSettlementResult">
        <foreach collection="list" item="item" separator=";">
            UPDATE orders 
            SET fee = #{item.fee}, 
                status = 'COMPLETED' 
            WHERE id = #{item.id}
        </foreach>
    </update>

</mapper>

③ ItemProcessor (정산 수수료 계산 로직)

건별로 넘어오는 주문 데이터를 받아 BigDecimal을 이용해 안전하게 수수료를 계산합니다.

Java
 
package com.example.batch.processor;

import com.example.batch.domain.OrderVo;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;

@Component
public class SettlementProcessor implements ItemProcessor<OrderVo, OrderVo> {

    @Override
    public OrderVo process(OrderVo order) throws Exception {
        // 수수료 5% 계산 (원 미만 절사)
        BigDecimal fee = order.getAmount()
                              .multiply(new BigDecimal("0.05"))
                              .setScale(0, RoundingMode.FLOOR);
        
        order.setFee(fee);
        return order; // 가공된 데이터는 ItemWriter로 전달됨
    }
}

④ ItemWriter (DB 일괄 반영)

청크 단위(1,000건)로 모인 데이터를 받아 MyBatis를 통해 일괄 업데이트를 수행합니다.

Java
 
package com.example.batch.writer;

import com.example.batch.domain.OrderVo;
import com.example.batch.mapper.OrderMapper;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class SettlementWriter implements ItemWriter<OrderVo> {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    public void write(Chunk<? extends OrderVo> chunk) throws Exception {
        // 청크에 모인 데이터(예: 1000건)를 한 번에 전달하여 DB 부하 최소화
        orderMapper.updateSettlementResult(chunk.getItems());
    }
}

⑤ Spring Batch Job 설정 (SettlementBatchConfig.java)

메모리 부하를 막기 위해 MyBatis CursorItemReader를 설정하고, Chunk(1000) 단위로 작업을 조립합니다.

Java
package com.example.batch.config;

import com.example.batch.domain.OrderVo;
import com.example.batch.processor.SettlementProcessor;
import com.example.batch.writer.SettlementWriter;
import com.example.batch.mapper.OrderMapper;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.batch.MyBatisCursorItemReader;
import org.mybatis.spring.batch.builder.MyBatisCursorItemReaderBuilder;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class SettlementBatchConfig {

    /**
     * 1. Job 정의: 배치 작업의 최상단 단위로 Step들을 실행합니다.
     */
    @Bean
    public Job settlementJob(JobRepository jobRepository, Step settlementStep) {
        return new JobBuilder("settlementJob", jobRepository)
                .start(settlementStep)
                .build();
    }

    /**
     * 2. Step 정의: 실질적인 읽기 -> 가공 -> 쓰기 작업이 이루어지는 단위입니다.
     * 1000개 단위(Chunk)로 트랜잭션을 묶어서 처리합니다.
     */
    @Bean
    public Step settlementStep(JobRepository jobRepository,
                               PlatformTransactionManager transactionManager,
                               MyBatisCursorItemReader<OrderVo> settlementReader,
                               SettlementProcessor settlementProcessor,
                               SettlementWriter settlementWriter) {
        return new StepBuilder("settlementStep", jobRepository)
                .<OrderVo, OrderVo>chunk(1000, transactionManager) // 👉 1000건 단위 Chunk 처리
                .reader(settlementReader)       // DB 커서를 이용한 메모리 절약형 읽기
                .processor(settlementProcessor) // 비즈니스 로직 (수수료 계산)
                .writer(settlementWriter)       // 1000건 모아서 일괄 쓰기
                .build();
    }

    /**
     * 3. ItemReader 정의: MyBatis Cursor를 이용해 대용량 데이터를 끊어서 읽어옵니다.
     */
    @Bean
    public MyBatisCursorItemReader<OrderVo> settlementReader(SqlSessionFactory sqlSessionFactory) {
        return new MyBatisCursorItemReaderBuilder<OrderVo>()
                .sqlSessionFactory(sqlSessionFactory)
                .queryId("com.example.batch.mapper.OrderMapper.selectOrdersForSettlement")
                .build();
    }

    /**
     * 4. ItemProcessor 정의 (선택적 빈 등록)
     * 컴포넌트 스캔(@Component)을 사용하지 않고 명시적으로 Bean을 등록할 수도 있습니다.
     */
    @Bean
    public SettlementProcessor settlementProcessor() {
        return new SettlementProcessor();
    }

    /**
     * 5. ItemWriter 정의 (선택적 빈 등록)
     */
    @Bean
    public SettlementWriter settlementWriter(OrderMapper orderMapper) {
        return new SettlementWriter(orderMapper);
    }
}

3. 핵심 정리 포인트

  1. OOM(메모리 부족) 방지: 수백만 건의 데이터를 List로 한 번에 조회하지 않고, MyBatisCursorItemReader를 활용해 DB 커서를 유지한 채 한 건씩 스트리밍 방식으로 읽어옵니다.
  2. 정확한 금액 연산: 부동소수점 오차가 발생하는 double 대신 BigDecimal과 setScale(0, RoundingMode.FLOOR)를 사용해 금융 정산의 정확성을 보장했습니다.
  3. 성능 최적화 (Chunk): 1건씩 처리하고 커밋하는 것이 아니라, 1,000건을 하나의 트랜잭션 청크(Chunk)로 묶어 처리함으로써 네트워크 통신 및 DB I/O 비용을 대폭 줄였습니다.
반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/09   »
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
글 보관함