springboot

[SpringBoot] Spring Batch로 csv파일을 db에 저장해보자

inhooo00 2025. 3. 8. 04:44

📍개요

https://inhooo00.tistory.com/entry/SpringBoot-Google-Places-API-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0

 

[SpringBoot] Google Places API 사용해보기

📍개요프론트에게 좌표를 전달하기 위해서, 구글 API를 사용해서 주소값을 좌표값으로 변환하는 로직을 구현하고자 한다.코드로 구현하기 전 Places API를 호출해보겠다.  📍API 키 만들기https://d

inhooo00.tistory.com

 

이전 글에서 Google places API를 통해 특정 장소의 다양한 정보들을 받아올 수 있었다.
이를 파이썬 코드를 이용해서 추출한 뒤 (이 방법은 추후에..),
맛집 리스트를 공공 데이터 포털에서 가지고 와서 좌표와 맛집 사진 등의 데이터를 csv파일로 정리했다.
이제 이 파일을 db에 올리고 조회하는 기능을 구현하고자 한다.

 

 

 

📍Spring Batch가 뭐죠

Spring Batch대량의 데이터 처리를 효율적으로 처리할 수 있도록 돕는 Spring 기반의 배치 처리 프레임워크다.

일전에 했던 프로젝트에서는 단순히 데이터를 읽고 DB에 저장했었는데, 그 데이터는 소량이었다. 반면 이번 대량의 데이터일 경우 메모리 부족이나 성능 저하 문제가 일어나는 상황을 고려해서 Spring Batch를 선택했다.

배치 작업은 단순하게 일괄 처리 작업을 의미한다. 대량의 데이터 뿐만 아니라 CSV 파일을 주기적으로 DB에 저장해야하는 나의 목적과 매우 적절하다.

Spring Batch 주요 개념으로 Job (작업)과 Step (단계)이 있다. 

기본적으로 한 Job은 여러 개의 Step으로 구성되어 있다.

 

이런 방식으로 진행된다.

 

‼️단순히 데이터를 읽고 DB에 저장 vs Spring Batch

내가 생각한 가장 큰 차이점은 트랜잭션 처리 유무인 것 같다.
단순히 데이터를 읽고 DB에 저장했을 때는 중간에 끊기거나 Out Of Memory가 발생하면 일부 데이터만 저장되고 나머지는 손실된다. 
반면에 Spring Batch는 Chunk 단위로 트랜잭션이 진행되기 때문에 Chuck가 실패해도 다음 실행 시 이어서 처리가 가능하다. 
이러한 안정성이 Spring Batch를 사용하는 가장 큰 이유인 것 같다.

 

 

 

📍환경설정

// spring batch
implementation "org.springframework.boot:spring-boot-starter-batch"

먼저 batch를 사용하기 때문에 의존성을 추가해준다.

spring:
  batch:
    job:
      enabled: true
    jdbc:
      initialize-schema: always

또 배치 작업을 실행하고, 필요한 테이블을 자동으로 생성하기 위해 해당 설정도 필요하다. (yml 파일 기준)

 

 

 

📍PlaceCsvData

@Getter
@Setter
public class PlaceCsvData {

    private String province; // 지역

    private String city; // 시군

    private String category; // 음식 카테고리 (한식, 중식, 양식, 일식, ALL)

    private String businessName; // 가게 이름

    private String contactNumber; // 전화번호

    private String address; //주소

    private String menu1; // 첫 번째 메뉴 이름

    private String price1; // 첫 번째 메뉴 가격

    private String menu2; // 첫 번째 메뉴 이름

    private String price2; // 두 번째 메뉴 가격

    private String placeId; // 가게 아이디

    private String periods; // 운영 디테일 시간

    private String weekdayDescriptions; // 운영 시간

    private String photoUrls; // 가게 사진

    private String latitude; // 위도 ex) 37

    private String longitude; // 경도 ex) 127

    public static List<String> getFieldNames() {
        Field[] declaredFields = PlaceCsvData.class.getDeclaredFields();
        List<String> result = new ArrayList<>();
        for (Field declaredField : declaredFields) {
            result.add(declaredField.getName());
        }

        return result;
    }
}

PlaceCsvData 클래스는 CSV 데이터를 객체로 매핑하는 DTO (Data Transfer Object) 역할을 한다.

getFieldNames() 메서드의 역할은 Reflection API를 사용하여 클래스 필드명을 동적으로 가져오는 것인데, 그냥 set으로 처리해도 되지만 코드가 너무 지저분해질 것 같아서 이렇게 구현해보았다.

 

 

 

📍CsvReader

@Configuration
@RequiredArgsConstructor
public class CsvReader {

    @Value("${shop.csv-path}")
    private String shopCsv;

    @Bean
    public FlatFileItemReader<PlaceCsvData> csvScheduleReader() {
        FlatFileItemReader<PlaceCsvData> flatFileItemReader = new FlatFileItemReader<>();
        flatFileItemReader.setResource(new ClassPathResource(shopCsv));
        flatFileItemReader.setEncoding("UTF-8");
        flatFileItemReader.setRecordSeparatorPolicy(new DefaultRecordSeparatorPolicy());

        DefaultLineMapper<PlaceCsvData> defaultLineMapper = new DefaultLineMapper<>();

        DelimitedLineTokenizer delimitedLineTokenizer = new DelimitedLineTokenizer(";");
        delimitedLineTokenizer.setNames(PlaceCsvData.getFieldNames().toArray(String[]::new));
        defaultLineMapper.setLineTokenizer(delimitedLineTokenizer);

        BeanWrapperFieldSetMapper<PlaceCsvData> beanWrapperFieldSetMapper = new BeanWrapperFieldSetMapper<>();
        beanWrapperFieldSetMapper.setTargetType(PlaceCsvData.class);

        defaultLineMapper.setFieldSetMapper(beanWrapperFieldSetMapper);
        flatFileItemReader.setLineMapper(defaultLineMapper);

        return flatFileItemReader;
    }
}

자 여기서부터 집중! batch 기능들이 가장 많이 나오는 class다.

사전 작업이 있다. 환경변수 부분은 내가 읽고 싶은 파일의 위치를 알려준다. 이 위치를 resources 아래에 두어서 읽게해야 한다.

이런식으로..

물론 이런 방식으로 안 하고 절대 경로로 읽게해도 된다. 하지만 추후에 배포환경에서 매핑하기 번거로울 것 같기에 resources로 관리하였다.

 

  • 자 이제 코드로 돌아와서 csvScheduleReader() 메서드 안에 FlatFileItemReader부터 확인해보자.
    FlatFileItemReader는 말 그대로 파일을 읽는 객체이다.
    setResource로 어떤 파일을 읽을지, setEncoding으로 한글 깨짐을 방지, setRecordSeparatorPolicy로 파일에 개행(\n)이 들어있어도 올바르게 읽도록 설정해준다.
  • DefaultLineMapper한 줄을 읽어서 PlaceCsvData 객체로 변환하는 도구라고 생각하면 된다.
  • DelimitedLineTokenizer로 어떤 기준으로 데이터를 구분할건지 delimiter를 생성해준다. 이렇게 생성한 DelimitedLineTokenizer 객체를 통해 DefaultLineMapper가 읽는 줄을 어떤 기준으로 구분할건지 설정한다.
  • setNames(PlaceCsvData.getFieldNames().toArray(String[]::new))은 CSV 데이터의 각 컬럼을 PlaceCsvData 객체의 필드와 매칭해주는 로직이다.
서울;강남;한식;맛집1;010-1234-5678;서울 강남구 1번지;김치찌개;8000;된장찌개;9000;P001;09:00-22:00;평일 09:00-22:00;photo1.jpg;37.1234;127.5678

이런 식으로 CSV 파일에 데이터가 들어가 있다고 생각해보자. 이 데이터를 읽으면 PlaceCsvData 객체로 자동 변환해준다.

  • 다음으로 BeanWrapperFieldSetMapper를 통해서 읽은 객체를 PlaceCsvData 객체로 변환해주면 된다.
    defaultLineMapper.setFieldSetMapper(beanWrapperFieldSetMapper);를 통해 PlaceCsvData로 나누고, 
    flatFileItemReader.setLineMapper(defaultLineMapper);로 파일을 한 줄씩 읽어가면 끝!

‼️PlaceCsvData 객체의 필드와 매칭해주는 로직이 왜 두 번 일어날까? (setNames(PlaceCsvData.getFieldNames().toArray(String[]::new)과 BeanWrapperFieldSetMapper)

DelimitedLineTokenizer → 필드를 잘라서 이름 설정
BeanWrapperFieldSetMapper → 설정된 이름을 기반으로 객체 필드에 값 매핑
따라서 둘 다 필요함.

 

 

 

📍CsvScheduleWriter

@Configuration
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CsvScheduleWriter implements ItemWriter<PlaceCsvData> {

    private final PlaceRepository placeRepository;

    @Override
    @Transactional
    public void write(Chunk<? extends PlaceCsvData> chunk) {
        Chunk<Place> places = new Chunk<>();

        chunk.forEach(placeCsvData -> {
            Place place = Place.of(placeCsvData.getProvince()
                    , placeCsvData.getCity()
                    , placeCsvData.getCategory()
                    , placeCsvData.getBusinessName()
                    , placeCsvData.getContactNumber()
                    , placeCsvData.getAddress()
                    , placeCsvData.getMenu1()
                    , placeCsvData.getPrice1()
                    , placeCsvData.getMenu2()
                    , placeCsvData.getPrice2()
                    , placeCsvData.getPlaceId()
                    , placeCsvData.getPeriods()
                    , placeCsvData.getWeekdayDescriptions()
                    , placeCsvData.getPhotoUrls()
                    , placeCsvData.getLatitude()
                    , placeCsvData.getLongitude()
            );
            places.add(place);
        });
        placeRepository.saveAll(places);
    }
}

write는 read로직보다는 훨씬 간단하다.

chunk 객체를 통해 거의 진행되는데, chunk는 batch에서 제공해주는 객체로 Spring Batch에서 일정한 단위로 데이터를 처리하기 위한 덩어리이다.

PlaceCsvData를 read로 읽어왔기 때문에, 이를 가지고 Place Chunk를 만들면서 Spring Data JPA의 saveAll메서드로 저장하기만 하면 끝.

아, 당연히 알겠지만 Place 객체는 내가 따로 만든 Entity이다. 

 

 

 

📍SimpleJobConfiguration

@Slf4j
@Configuration
@RequiredArgsConstructor
public class SimpleJobConfiguration {

    private final CsvReader csvReader;
    private final CsvScheduleWriter csvScheduleWriter;

    @Bean
    public Job shopDataLoadJob(JobRepository jobRepository, Step shopDataLoadStep) {
        return new JobBuilder("placeInformationLoadJob", jobRepository)
                .start(shopDataLoadStep)
                .build();
    }

    @Bean
    public Step shopDataLoadStep(
            JobRepository jobRepository,
            PlatformTransactionManager platformTransactionManager) {
        return new StepBuilder("placeDataLoadStep", jobRepository)
                .<PlaceCsvData, PlaceCsvData>chunk(100, platformTransactionManager)
                .reader(csvReader.csvScheduleReader())
                .writer(csvScheduleWriter)
                .build();
    }
}

자 이제 마지막 config 파일이다.

우리가 앞에서 만들었던 CsvReaderCsvScheduleWriter를 사용할 차례이다.

  • shopDataLoadJob 메서드는 간단하다. JobBuilder로 job 이름과 실행 상태를 저장해주며 Step 실행을 등록해준다.
  • shopDataLoadStep 메서드도 똑같이 step 이름과 실행 상태를 저장해주고,몇 개의 chunk를 한 번에 처리할지 설정한다. (트랜잭션 단위 설정)
  • 마지막으로 CSV 파일을 읽는 csvReader와 읽은 데이터를 엔티티로 변환 후 DB에 저장하는 csvScheduleWriter를 설정해주면 끝난다.

 

 

 

📍참고

https://velog.io/@saewoo1/Spring-Boot-JPA-csv-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%9D%BD%EC%96%B4-DB%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0-Spring-Batch

 

[Spring Boot-JPA] csv 파일을 읽어 db에 저장하기- Spring Batch

기나긴 삽질의 여정

velog.io