공공데이터 활용(2)

개나뇽·2025년 1월 14일

서론

서울-경기 지역의 카페 데이터를 효율적으로 처리하기 위해 공공데이터를 활용한 경험을 공유합니다. 데이터 정제와 효율적인 삽입 방식 개선을 통해 성능을 크게 향상시켰습니다.

문제 상황

  • 서울-경기 소재의 카페 데이터를 얻기 위해 공공데이터를 활용했습니다.
  • 서울권 소상공인 데이터 약 43만 건을 정제하였고, 카페 데이터를 RDB에 삽입하는 데 7시간 반이 소요되었습니다.
  • 기존 코드의 가독성이 좋지 않았습니다.

접근 방법

  • 파일 정제: 원본 파일을 정제하여 필요한 데이터만 포함된 CSV 파일을 생성합니다.
  • 람다와 스트림: 기존의 가독성이 떨어지는 코드를 람다와 스트림을 이용해 리팩토링합니다.

해결 과정

파일정제

Before

public class CsvFileReader {
    private final CafeRepository cafeRepository;
    public String PATH = PATH_ROOT + "/cafe_list_seoul_202409.csv";
    
    @Transactional
    @EventListener(ApplicationReadyEvent.class)
    public void readCsv() {
        CSVReader reader = null;
        try {
            reader = new CSVReader(new FileReader(PATH));
            String[] headers = reader.readNext(); // 헤더를 읽음
            List<String[]> rows = reader.readAll();
            List<Cafe> cafeList = new ArrayList<>();
            for (int i = 0; i < rows.size(); i++) {
                if ("카페".equals(rows.get(i)[8])) {
                    Cafe cafe = convertCsvToCafe(rows.get(i));
                    cafeList.add(cafe);
                }
            }
            cafeRepository.saveAll(cafeList);
        } catch (FileNotFoundException | CsvValidationException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (CsvException e) {
            throw new RuntimeException(e);
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
    public Cafe convertCsvToCafe(String[] row) {
        String name = ""; // [1] + [2]
        String roadNamedAddress = ""; //[31] + [30] + [35] + [36]
        String zipCode = ""; // [33]
        double longitude = 0; // [37]
        double latitude = 0; // [38]
        for (int i = 1; i < row.length; i++) {
            name = row[1] + " " + row[2];
            roadNamedAddress = row[31] + " " + row[30];
            zipCode = row[33];
            longitude = Double.valueOf(row[37]);
            latitude = Double.valueOf(row[38]);
        }
        return Cafe.builder()
            .name(name)
            .roadNamedAddress(roadNamedAddress)
            .zipCode(zipCode)
            .longitude(longitude)
            .latitude(latitude)
            .build();
    }
}
  • 기존 로직은 원본 파일을 CSVReader로 읽어 8번쨰 속성에서 "카페"와 일치하는 행을 찾습니다.
  • 조건문에서 True인 행에 대해서 convertCsvToCafe 메서드를 호출해 속성을 조합해 Cafe 객체를 생성합니다.
  • convertCsvToCafe의 결과로 생성된 Cafe 객체를 List 타입의 cafeList에 담아 RDB에 저장합니다.

After

 @Transactional
 public void rewriteCSV() {
      try {
          CSVReader reader = new CSVReader(new FileReader(PATH));
          CSVWriter writer = new CSVWriter(new FileWriter(PATH_ROOT + "/cafe_list_seoul.csv"));

          String[] headers = {"이름", "도로명주소", "우편번호", "경도", "위도"};
          writer.writeNext(headers);

          List<String[]> cafeInfos = rewriteCafeInfos(reader);
          writer.writeAll(cafeInfos);

          writer.close();
          reader.close();

      } catch (IOException | CsvException e) {
          log.error(e.getMessage());
          throw new GlobalException(ErrorCode.CSV_REFACTOR_ERROR);
      }
  }

  /*
   * 원본 CSV 파일에서 필요 데이터만 추출 및 정제하는 메서드
   * 필요 데이터(이름, 도로명 주소, 우편 번호, 경도, 위도
   */
  private List<String[]> rewriteCafeInfos(CSVReader reader) throws IOException, CsvException {
      return reader.readAll().stream()
          .filter(row -> "카페".equals(row[8]))
          .map(row -> new String[]{
              row[1] + " " + row[2],
              row[30] + " " + row[31],
              row[33],
              row[37],
              row[38]
          })
          .toList();
  }
  • rewriteCSV 과정을 통해 원본 파일을 정제하여 필요 데이터만 가진 CSV 파일을 생성합니다. 이를 통해서 데이터 처리 시간을 단축시켰습니다.
  • 람다와 스트림을 이용해 리팩토링을 진행했습니다.

BulkInsert

Before

cafeRepository.saveAll(cafeList);
  • 기존의 로직에서는 JPA에서 제공하는 saveAll() 메서드를 이용해 RDB에 저장했습니다.
  • saveAll은 save()와 달리 insert 쿼리가 하나씩 발생하는게 아닌 쿼리를 모았다가 한번에 보낼것이라 생각했습니다.
  • 예상과 달리 saveAll() 메서드 실행시 발생하는 쿼리는 save()와 동일하게 발생했습니다.
  • 조사 결과 saveAll()은 결국 내부에서 save() 메서드를 호출하는 것을 알게되었습니다.
  • bulkinsert를 위해서는 별도의 조치가 필요하다는 것을 알았습니다.

After

@Repository
@RequiredArgsConstructor
public class CafeJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void bulkInsert(List<Cafe> cafeList) {
        String sql = "insert into Cafe(name, road_named_address, zip_code,longitude,latitude) values(?,?,?,?,?)";
        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Cafe cafe = cafeList.get(i);
                ps.setString(1, cafe.getName());
                ps.setString(2, cafe.getRoadNamedAddress());
                ps.setString(3, cafe.getZipCode());
                ps.setDouble(4, cafe.getLongitude());
                ps.setDouble(5, cafe.getLatitude());
            }

            @Override
            public int getBatchSize() {
                return cafeList.size();
            }
        });
    }
}
------------------------
cafeJdbcRepository.bulkInsert(cafeList);
  • 결론적으로는 jdbcTemplate를 이용해 BulkInsert를 구현해 사용했습니다.
  • BulkInsert에 관련해서는 글이 길어지므로 따로 다루겠습니다.

결과

Before

  • 43만 건의 데이터를 읽어 20454건의 카페 데이터를 저장하는 데 약 7시간 30분 소요되었습니다.

After

  • 원본 파일을 정제한 결과 생성에 약 4초가 소요되었습니다.
  • 20454건의 데이터 저장에 약 99 밀리초가 소요되었습니다.
profile
정신차려 이 각박한 세상속에서!!!

0개의 댓글