spring

[Spring Batch] 이모저모

gucoding 2025. 6. 19. 17:36

이번에 요양시설 관련한 프로젝트에 참여하게 되어 공공데이터를 다룰 상황이 생겼습니다.

예전에 간단히 학습해본 스프링배치를 통해서 파일들을 읽어보면서 학습하려고 합니다.

고유하지 않은 고유 ID

칼럼정의서에 따르면 첫번째 칼럼은 고유 ID 필드인데, 중복되어있음을 확인했습니다.

확인을 해보니, 뒷자리가 1842로 되어야 고유값인데, 갑자기 0002로 초기화된 모습입니다.

org.hibernate.exception.ConstraintViolationException: 
could not execute statement 
[Duplicate entry 'KCOCFPO23N000000001' for key 'cultural_facility.PRIMARY'] 
  • 무결성 제약조건 예외 발생

기본적으로 JdbcItemWriter는 Bulk 연산으로 처리합니다. 그래서 다른 파일들은 JdbcItemWriter를 사용해서 DB에 적재했는데요, 이 상황에서는 바로 insert 하는것이 아니라

객체로 변환 → id 필드 일부를 숫자로 변환 → 1840을 더해준후 다시 String으로 변환 → insert

하는 프로세싱 과정을 거친 후 넣어주는게 편리하다고 생각했습니다.

따라서 JpaItemWriter를 사용했고 성능을 생각해서 추가작업을 진행해야합니다. 왜냐하면 JpaItemWriter는 기본적으로 Bulk 연산을 진행하지 않습니다.

얼마 단위로 Bulk 연산할지 지정해야하고.

spring.jpa.properties.hibernate.jdbc.batch_size=500

  datasource:
    url: jdbc:mysql://localhost:3307/spring-batch?rewriteBatchedStatements=true

Mysql 기준으로 rewriteBatchedStatements 옵션을 true로 설정해야합니다.

INSERT INTO your_table (col1, col2) VALUES (val1_1, val1_2), (val2_1, val2_2), ..., (valN_1, valN_2);

이 옵션은 MySQL JDBC 드라이버에게 여러 개의 INSERT 문을 다음과 같은 형식의 단일 INSERT 문으로 "재작성"하도록 지시합니다.

@Bean
    public Job culturalFacilityJob(
            Step culturalFacilityStep
    ) {
        return new JobBuilder("culturalFacilityJob", jobRepository)
                .start(culturalFacilityStep)
                .incrementer(new RunIdIncrementer())
                .listener(jobCompletionNotificationListener)
                .build();
    }

Job에 리스너를 등록해서 시간을 측정해보았습니다.

Status: COMPLETED. Total time taken: 5 seconds 704 milliseconds

Status: COMPLETED. Total time taken: 1 seconds 645 milliseconds

쿼리를 한건씩 날리게 되면 커넥션 맺고 트랜잭션 여는 등의 과정이 반복되므로 약 9천개 정도의 데이터지만 확연한 차이가 보입니다.

Null 데이터 때문에

위도와 경도에 대한 정보를 Double로 처리하고 싶었는데 비어있는 필드 때문에 mapper 에서 예외가 발생했습니다.

public interface FieldSet {
    double readDouble(int index);
}

camel case 생각안하고 Wrapper 클래스로 처리하겠구나라고 생각해서 어디서 예외가 터졌는지 모르고 리스너로 예외를 확인해보는 계기가 된 친구라 남겨봅니다.

대부분 기본값으로 매핑해서 아쉽습니다.

외부에서 받는 데이터이다보니 항상 NULL이 들어올걸 대비해야돼서 1차적으로 읽을 때는 대부분 String으로 처리하는게 마음편한 것 같습니다.

ItemReader

public FlatFileItemReader<LongTermCareFacility> longTermCareItemReader() {
        DefaultLineMapper<LongTermCareFacility> lineMapper = new DefaultLineMapper<>();
        DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
        tokenizer.setNames(
                "NO",
                "평가구분",
                "장기요양기관기호",
                "장기요양기관명",
                "급여종류",
                "기관설립주체",
                "관할시도명",
                "관할시군구명",
                "평가일자",
                "최종등급",
                "평가점수",
                "기관운영",
                "환경및안전",
                "수급자권리보장",
                "급여제공과정",
                "급여제공결과"
        );
        lineMapper.setLineTokenizer(tokenizer);
        lineMapper.setFieldSetMapper(new LongTermCareFieldSetMapper());
        return new FlatFileItemReaderBuilder<LongTermCareFacility>()
                .name("longTermCareItemReader")
                .resource(new ClassPathResource("long_term_care.csv"))
                .lineMapper(lineMapper)
                .linesToSkip(1)
                .encoding("EUC-KR")
                .build();
    }

CSV 파일을 읽기 위해서 FlatItemReaer를 사용했습니다.

DelimitedLineTokenizer가 “,” 쉼표를 기준으로 분리할건데, 인덱스로 매핑됩니다.

정확하게 매핑해주기 위해서 setNames로 이름을 부여해줬습니다.

FieldSet에 매핑된 값을 커스텀한 LongTermCareFieldSetMapper에서 pojo와 매핑해줍니다.

첫번째줄은 칼럼명이므로 스킵하고 읽기 위해서 linesToSkip(1)을 적용했고

글씨가 깨져서 인코딩을 맞게 적용했습니다. 다른 파일들로 추측해보아 디폴트는 UTF-8로 보입니다.

ItemWriter

JpaItemWriter

public ItemWriter<LongTermCareFacility> longTermCareitemWriter() {
        return new JpaItemWriterBuilder<LongTermCareFacility>()
                .usePersist(true)
                .entityManagerFactory(entityManagerFactory)
                .build();
    }

엔티티를 merge로 관리할지 persist로 관리할지 옵션을 줄 수 있습니다.

1 seconds 645 milliseconds -> 6 seconds 714 milliseconds

merge로 하면 select QUERY가 먼저 호출한 후 insert 하는거에 대한 오버헤드가 발생해서 많은 속도 차이가 발생한 모습입니다. 굳이 select할 필요가 없는 상황이니 true로 지정합니다.

JdbcBatchItemWriter

@Bean
    public ItemWriter<LongTermCareFacility> longTermCareitemWriter() {
        return new JdbcBatchItemWriterBuilder<LongTermCareFacility>()
                .dataSource(dataSource)
                .sql("INSERT INTO long_term_care_facility ( " +
                        "    id, evaluation_division, name, service, establishment_type, " +
                        "    city_province_name, sigungu_name, evaluation_date, grade, total_score, " +
                        "    management_score, environment_and_safety_score, beneficiary_rights_protection_score, " +
                        "    service_provision_process_score, service_provision_result_score " +
                        ") VALUES (" +
                        "    :id, :evaluationDivision, :name, :service, :establishmentType, " +
                        "    :cityProvinceName, :sigunguName, :evaluationDate, :grade, :totalScore, " +
                        "    :managementScore, :environmentAndSafetyScore, :beneficiaryRightsProtectionScore, " +
                        "    :serviceProvisionProcessScore, :serviceProvisionResultScore" +
                        ")")
                .beanMapped()
                .build();
    }

직접 쿼리를 작성해야하니 불편하긴하다.

beanMapped 옵션을 주면 pojo 기반으로 VALUES()에 매핑해줍니다.

참고로 columnMapped 옵션을 주면 Key Value (Map)로 매핑가능합니다.

1 seconds 645 milliseconds -> 0 seconds 631 milliseconds

JpaItemWriter와 비교해봤는데요 둘 다 Bulk 연산을 적용시킨거에 비해서 유의미한 차이가 있습니다.

아무래도 객체를 매번 생성하고 넣기 때문인 것 같습니다.