Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Batch Performance 극한으로 끌어올리기

kakao
December 09, 2022

Batch Performance 극한으로 끌어올리기

#BatchPerformance #SpringBatch #SpringCloudDataFlow #Kubernetes #Redis #Kafka

수 천만 건 이상의 대량 배치처리를 하기 위한 최고의 노하우를 전달드리겠습니다.
카카오페이 정산플랫폼팀은 카카오페이의 빠른 성장과 더불어 늘어나는 데이터를 일괄처리하기 위하여 꾸준히 노력 하고 있습니다.
더 많은 데이터를 처리하기 위해 진화하는 서비스와 개발방식을 소개하고자 합니다.

발표자 : benny.ahn
카카오페이 정산플랫폼팀 베니입니다. 서버 Performance 개선에 관심이 많습니다.

kakao

December 09, 2022
Tweet

More Decks by kakao

Other Decks in Programming

Transcript

  1. 카카오페이 정산플랫폼팀 1억 번 데이터 처리를 위한 노력 Copyright 2022.

    Kakao Corp. All rights reserved. Redistribution or public display is not permitted without written permission from Kakao. Batch Performance 극한으로 끌어올리기 안성훈 Benny.Ahn 카카오페이 if(kakao)2022
  2. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  3. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  4. 일괄 처리, Batch Application 흠… 오후 4시에 상품 주문 배송

    정보를 고객들에게 문자로 일괄 전송해야 하는구나. 간단하네! Batch로 개발해서 16시에 스케줄을 걸어놔야지! 커머스 서비스를 개발하는 개발자 A씨 특정 시간에 많은 데이터를 일괄 처리, 서버 개발자들이 자주 애용하는 방식, 개발 부담↓
  5. 일괄 생성 DB API FILE READ CREATE WRITE 주문1 정보

    주문2 정보 주문1 정보 주문2 정보 주문자1 정보 주문자 2 정보 사용자 정보
  6. 일괄 수정 READ UPDATE WRITE 주문1 정보 주문2 정보 주문1

    정보 주문2 정보 수정된 주문1 정보 수정된 주문2 정보 배송정보
  7. 통계 DB API FILE SUM READ WRITE 주문1 정보 주문2

    정보 상품별 주문 금액 합산 주로 GroupBy
  8. 무관심한 Batch Performance 1년 뒤… 어? Batch가 아직도 안 끝났네?

    언제 이렇게 주문량이 많아졌지? 왜 이렇게 알림 발송이 느려졌지? 커머스 서비스를 개발하는 개발자 A씨 Batch 개발을 쉽게 생각하는 경향, 배포 후 관리 소홀, 배치를 지원하는 APM Tool의 부재
  9. 카카오페이 정산플랫폼팀은 얼마나 많은 데이터를 처리할까요? 2022년 현재 하루 평균

    데이터 Access 횟수는 1억 번이 넘는다. (2017년도에는 하루 평균 25만 번) 2017 2018 2019 2020 2021 2022 1시간 400시간 Batch 수행시간 1억 5천만 0
  10. 5년 전에도 1시간, 오늘도 1시간 2017 2018 2019 2020 2021

    2022 1시간 400시간 Batch 수행시간 1억 5천만 0 1시간
  11. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  12. Reader의 복잡한 조회조건 100만 100만 100만 개의 주문 정보 중

    100만 개 모두 차례대로 가져가세요~ Batch Application
  13. Reader의 복잡한 조회조건 100만 10억 Batch Application 잠시만요! 찾는 중

    입니다… 10억 개의 주문 정보 중 환불이 발생한 100만 개
  14. 읽을 때는 항상 Chunk Processing Chunk1 Chunk2 Chunk9999 Chunk10000 1000만

    개의 Item 1만 개의 chunk … … … … ………….. 1000만 개 1000개 Chunk1 Chunk2 Chunk3 Chunk4 Chunk5 24개의 Item 5개의 chunk 굳이…? 그냥 한 번에 처리하면 되는거 아니야? 1000만 개도 한 번에 처리하려고…? 불가능해!
  15. Chunk Processing의 짝꿍, Pagination Reader MySQL 36번 Page, 100개 주세요

    Limit Offset 사용 select * from orders where category = ‘BOOK' limit 3600, 100 JpaPagingItemReader RepositoryItemReader 대량 처리에 매우 부적합
  16. MySQL 쿼리와 기존 ItemReader 문제점 조회 결과: 1억 건 조회

    결과: 100건, 조회 속도: 매우 빠름 조회 결과: 100건, 조회 속도: 매우 느림 Offset이 커질수록 느려짐 Index select count(1) from orders where category = ‘BOOK' select * from orders where category = 'BOOK' limit 0, 100 select * from orders where category = 'BOOK' limit 50000000, 100 Limit Offset이 가지는 태생적인 한계
  17. ZeroOffsetItemReader Page1 Page2 Page3 Page4 Page5 24개의 Item 5개의 page

    PK : 10 order by id(PK) asc PK : 5235 PK : 76432123 PK : 967678332 select * from orders where category = 'BOOK' and id > 0 limit 0, 100 select * from orders where category = 'BOOK' and id > 967678332 limit 0, 100 select * from orders where category = 'BOOK' and id > 5235 limit 0, 100 Zero Offset
  18. ZeroOffsetItemReader Page1 Page2 Page3 Page4 Page5 24개의 Item 5개의 page

    PK : 10 order by id(PK) asc PK : 5235 PK : 76432123 PK : 967678332 select * from orders where category = 'BOOK' and id > 0 limit 0, 100 select * from orders where category = 'BOOK' and id > 967678332 limit 0, 100 select * from orders where category = 'BOOK' and id > 5235 limit 0, 100
  19. QueryDsl + ZeroOffsetItemReader QuerydslZeroOffsetItemReader( name = “orderQueryDslZeroOffsetItemReader", pageSize = 1000,

    entityManagerFactory = entityManagerFactory, idAndSort = Asc, idField = qOrder.id ) { it.from(qOrder) .innerJoin(qOrder.customer).fetchJoin() .select(qOrder) .where(qOrder.category.eq(CATEGORY.BOOK)) } QueryDSL : Query Domain Specific Language
  20. 데이터를 조금씩 가져오는 Cursor를 사용하자! Cursor 동작방식 Yes No 데이터가

    없을 때까지 반복해서 Fetch Batch Open Fetch Close Empty
  21. Cursor를 지원하는 ItemReader MySQL Cursor방식 데이터를 모두 읽고 서버에서 직접

    Cursor하는 방식 -> OOM 유발 MySQL Cursor방식 MySQL의 Cursor를 사용하여 일정 개수만큼 Fetch하는 방식 -> 안전함 근데… HQL, Native Query 사용하기 싫다… JpaCursorItemReader JdbcCursorItemReader HibernateCursorItemReader
  22. 새로운 방식의 쿼리 구현, Exposed JetBrains Kotlin 기반 ORM 프레임워크

    EXPOSED • 데이터베이스 Access 방식 
 SQL을 매핑한 DSL 방식, 경량화한 ORM DAO 방식 • 지원하는 데이터베이스 
 H2, MySQL, MariaDB, Oracle, PostgreSQL, SQL Server, SQLite • Kotlin 호환성 (자바 프로젝트는 사용 불가)
  23. Exposed DSL 도입 이유 object Orders : LongIdTable(“orders") { val

    customer = reference(“customer_id”, Customers).nullable() val name = varchar("name", 50) val price = long(“price”).default(0) val category = varchar("category", 50) val deliveryCompleted = bool(“is_delivery_completed”) } object Customers : LongIdTable("customer") { val name = varchar("name", 20) val email = varchar("email", 50) val age = long(“age”) } Kotlin 언어적 특성을 활용해서 세련되게 쿼리 구현이 가능
  24. Select Customers.slice(Customers.name) .select { age eq 25 } Insert Customers.insert

    { customers -> customers[name] = “ഘӡز" customers[email] = “@kakao.com” customers[age] = 25 } Batch Insert Customers.batchInsert(data = items) { item -> this[Customers.name] = item.name this[Customers.email] = item.email this[Customers.age] = item.age }
  25. Exposed+CursorItemReader ExposedCursorItemReader<Order>( name = "orderExposedCursorItemReader", dataSource = dataSource, fetchSize =

    5000 ) { (Orders innerJoin Customers) .slice(Orders.columns) .select { (Orders.category eq "BOOK") and (Customers.age greaterEq 11) } } 동작 방식 = JdbcCursorItemReader 쿼리 = Exposed DSL
  26. ItemReader 성능 비교 Sec 0 3,500 7,000 10만 50만 100만

    300만 290 96 50 11 266 82 47 9 6,752 842 235 18 JpaPagingItemReader QueryDslZeroOffsetItemReader ExposedCursorItemReader Chunk Size, Page Size, Fetch Size = 1,000개 / Column 개수 = 30개 (112분) (4분 26초) (4분 50초)
  27. ItemReader 성능 비교 Sec 0 3,500 7,000 10만 50만 100만

    300만 290 96 50 11 266 82 47 9 6,752 842 235 18 JpaPagingItemReader QueryDslZeroOffsetItemReader ExposedCursorItemReader Chunk Size, Page Size, Fetch Size = 1,000개 / Column 개수 = 30개 (112분) (4분 26초) (4분 50초)
  28. ItemReader 성능 비교 Sec 0 3,500 7,000 10만 50만 100만

    300만 290 96 50 11 266 82 47 9 6,752 842 235 18 JpaPagingItemReader QueryDslZeroOffsetItemReader ExposedCursorItemReader Chunk Size, Page Size, Fetch Size = 1,000개 / Column 개수 = 30개 (112분) (4분 26초) (4분 50초) 5배 13배
  29. ItemReader 성능 비교 Sec 0 3,500 7,000 10만 50만 100만

    300만 290 96 50 11 266 82 47 9 6,752 842 235 18 JpaPagingItemReader QueryDslZeroOffsetItemReader ExposedCursorItemReader Chunk Size, Page Size, Fetch Size = 1,000개 / Column 개수 = 30개 (112분) (4분 26초) (4분 50초) 8배
  30. 기존의 ItemReader RepositoryItemReader JpaPagingItemReader JdbcCursorItemReader HibernateCursorItemReader JpaCursorItemReader 쿼리 구현 방법

    Query Method QueryDSL JPQL Native Query, HQL JPQL 동작 방식 Pagination Limit Offset 구문 사용 Cursor 방식 애플리케이션에서 직접 Cursor 처리 성능 조회할 데이터가 많다면 뒷 Page로 갈수록 느려짐 Cursor 기반이므로 Fetch size와 DB설정만 제대로 세팅하면 조회 속도가 매우 빠름 성능은 매우 우수하나 OOM 유발
  31. 개선된 ItemReader QueryDslZeroOffsetItemReader ExposedCursorItemReader 쿼리 구현 방법 QueryDSL Kotlin Exposed

    동작 방식 Offset을 항상 0으로 유지 PK를 where 조건에 추가하는 방식 JdbcCursorItemReader와 동일한 방식 성능 첫 Page를 읽었을 때와 동일하게 항상 일관된 조회 성능을 가짐 Cursor 기반으로 많은 양의 데이터를 빠르게 가져오며 일관된 조회 성능을 가짐
  32. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  33. 데이터 Aggregation 처리 서버 개발자 A씨 통계 -> Batch로 개발

    -> GroupBy & Sum DB API FILE SUM READ WRITE 주문1 정보 주문2 정보 상품별 주문 금액 합산 주로 GroupBy
  34. Sum 쿼리에 의존하는 Batch 문제점 복수 개 테이블 Join &

    GroupBy -> 잘못된 실행 계획, 까다로운 쿼리 튜닝 select sum(o.amount), count(1), p.price, p.product_id, u.age from orders o inner join price p on o.price_id = p.id inner join user u on o.user_id = u.id where o.order_date = ‘2022-10-12' group by p.product_id, u.age order by p.product_id asc, u.age asc limit 0, 100;
  35. Join+GroupBy+Sum 쿼리를 사용하면… 연산 과정이 쿼리에 의존적 -> Database 부하

    증가 쿼리 튜닝을 위한 과도한 인덱스 추가 -> INSERT, UPDATE 성능 저하, 저장 용량 차지 데이터 누적 -> 데이터 중복도(카디널리티) 변경 -> 쿼리 실행 계획의 변경 -> 쿼리 튜닝 난이도
  36. GroupBy를 포기하자 쿼리는 단순하게! -> 그냥 GroupBy를 안 쓰면 되겠네

    -> 직접 Aggregation을 하면 되겠네 select sum(o.amount), count(1), p.price, p.product_id, u.age from orders o inner join price p on o.price_id = p.id inner join user u on o.user_id = u.id where o.order_date = ‘2022-10-12' group by p.product_id, u.age order by p.product_id asc, u.age asc limit 0, 100;
  37. 직접 Aggregation하자 직접 Aggregation 가능한가요? 1000만 개 데이터 50만 개

    SUM 데이터 합산 필요한 최소 공간 공간이 없어요!
  38. 새로운 Architecture, Redis를 활용한 Sum 충분한 저장공간! 빠른 연산! Redis를

    쓰자 1000만 개 데이터 50만 개 SUM 데이터 Chunk 1 Chunk 2 Chunk 3 Chunk 10000 1000개 Sum 연산 요청 Summary 1 Summary 2 Summary 500000 최종 데이터 저장소 “기존 것에 더해주세요!”
  39. Redis 도입 이유 50만 개는 쉽게 저장하는 넉넉한 메모리 In

    - Memory DB 빠른 저장 O! 영구 저장 X! Aggregation Tool로 왜 Redis? 연산 명령어 hincrby, hincrbyfloat 지원 메모리 수준에서 합산
  40. 그러나, Redis를 도입해도 해결되지 않는 문제 50만 개 SUM 데이터

    Chunk 1 Chunk 2 Chunk 3 Chunk 10000 1천 번 요청 총 1000만 번 SUM 연산 요청 1000만 번 네트워크 I/O = 1천 번 요청
  41. 그러나, Redis를 도입해도 해결되지 않는 문제 Network I/O 요청 한

    번 당 1ms * 1000만 번 = 3시간 Redis 연산 눈 깜짝할 사이에 끝나긴 하는데… 전체 성능은 오히려
  42. Redis Pipeline으로 처리하자 Redis Pipeline: 다수 command를 한 번에 묶어서

    처리 50만 개 SUM 데이터 Chunk 1 Chunk 2 Chunk 3 Chunk 10000 한 번 Sum 연산 요청 총 1만 번 SUM 연산 요청 1만 번 네트워크 I/O = Batch Application 전용 Redis Pipeline 대량 처리 라이브러리 별도 개발하여 사용 (당연히 Spring Data Redis로 불가능)
  43. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  44. 효과적인 대량 데이터 WRITE Reader와 Aggregation까지 개선 완료! 음… 그래도

    1000만 개 처리할 때는 여전히 느려… 남은 곳은 Writer! 카카오페이 정산플랫폼팀
  45. Batch에서 JPA WRITE에 대한 고찰 Batch 환경에서 JPA가 과연 잘

    맞는가? Dirty Checking과 영속성 관리 UPDATE할 때 불필요한 컬럼도 UPDATE JPA Batch Insert 지원이 어려운 부분
  46. 쓰기 지연 저장소 ID 엔터티 스냅샷 Order1 Order1 현 상태

    Order1 스냅샷 Order2 Order2 현 상태 Order2 스냅샷 Flush 엔터티&스냅샷 비교 update sql 생성 JPA 영속성 관리 Dirty Checking Dirty Checking과 영속성 관리 Detached Managed New Removed 굳이…? Batch에서는 이런 복잡한 과정들이 필요한가?
  47. Read할 때부터 Dirty Checking과 영속성 버리기 QuerydslZeroOffsetItemReader( name = "readerForShipments",

    pageSize = 1000, entityManagerFactory = entityManagerFactory, idAndSort = Asc, idField = QOrders.orders.id ) { query -> query.select( Projections.constructor( ShipmentDto::class.java, QOrders.orders.orderNumber, QUser.user.id, QUser.user.name, QUser.user.phoneNumber, QUser.user.address, ) ) .from(QOrders.orders) .innerJoin(QOrders.orders.user, QUser.user) .where(QOrders.orders.orderDate.eq(LocalDate.now())) 요점은 Dirty Checking과 영속성 관리를 쓰지 않는 것! JPA를 버리거나, 또는 Reader에서 Projections를 쓴다. 정확히 필요한 컬럼만 가져와 Fetch, Deserialize 시간 단축 영속성 관리, Dirty Checking하지 않아 성능 개선
  48. UPDATE할 때 불필요한 컬럼도 UPDATE 원하는 쿼리 update orders set

    is_completed = 1 where id = 315; 실제 동작한 쿼리 update orders set is_completed = 1, order_number = ‘23231’, price = 49000, order_date = ‘2022-01-01’ where id = 315; 왜 필요 없는 것도 UPDATE하려고 그래…
  49. UPDATE할 때 불필요한 컬럼도 UPDATE But, Dynamic UPDATEܳ ೞӝ ਤ೧

    ز੸ ௪ܻܳ ࢤࢿ য়൤۰ ࢿמ ੷ೞ 원하는 쿼리 update orders set is_completed = 1 where id = 315; 실제 동작한 쿼리 update orders set is_completed = 1, order_number = ‘23231’, price = 49000, order_date = ‘2022-01-01’ where id = 315; 잠깐! JPA에겐 dynamic UPDATE가 있다고!
  50. 그러나, JPA는 Batch Insert 지원이 어려워요 JPA에서도 Batch Insert를 지원하지만,

    ID 생성 전략을 IDENTITY로 하게 되면 JPA 사상과 맞지 않는 이유로 Batch Insert를 지원하지 않음 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
  51. Batch에서 Batch Insert 사용해야 하는 이유 Network I/O 너무 오래

    걸리고 시간이 아까워요! 데이터베이스 Insert 눈 깜짝할 사이에 끝나긴 하는데… 성능 개선을 위해서 쿼리를 모아서 처리하는 Batch Insert 필수에요!
  52. Batch에서 JPA WRITE에 대한 고찰 Batch 환경에서 JPA가 과연 잘

    맞는가? Dirty Checking과 영속성 관리 UPDATE할 때 불필요한 컬럼도 UPDATE JPA Batch Insert 지원이 어려운 부분 -> 불필요한 컬럼 UPDATE로 인한 소폭 성능 저하 -> 불필요한 check 로직으로 인한 큰 성능 저하 -> Batch Insert 불가한 경우, 매우 큰 성능 저하
  53. JPA vs JDBC Batch Insert Sec 0 3,000 6,000 10만

    100만 500만 105 21 2 140 28 3 5,452 1,163 108 JPA 단건 저장 JDBC 1,000개씩 Batch Insert JDBC 10,000개씩 Batch Insert (90분) (2분 20초) (1분 45초)
  54. JDBC Batch Insert 보통 이렇게 구현해요 sql = “update orders

    set name = ? where id = ?” pstmt = con.prepareStatement(sql); pstmt.setString(1, “ۄ੉঱”) pstmt.setLong(2, 3241) pstmt.addBatch() pstmt.setString(1, “୸ध੉”) pstmt.setLong(2, 865) pstmt.addBatch() pstmt.executeBatch() update orders set name = ‘ۄ੉঱’ where id = 3241; update orders set name = ‘୸ध੉’ where id = 865;
  55. Hello Exposed Batch Insert! “JDBC Batch Insert도 충분히 좋지만 Native

    Query를 사용하고 싶지 않아요” -> Exposed Batch Insert를 사용 Customers.batchInsert(data = items) { item -> this[Customers.name] = item.name this[Customers.email] = item.email this[Customers.age] = item.age }
  56. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리
  57. 여러분들은 어디서 어떻게 Batch를 구동하나요? crontab 실행 요청 스케줄 Batch

    관리 워크 플로우 관리 모니터링 히스토리 다들 만족하시나요?
  58. 자원관리(Resource Control)의 어려움 A Batch B Batch C Batch D

    Batch E Batch 0시 24시 배치 종료 = 자원 미사용 배치 실행 = 자원 사용
  59. 자원관리(Resource Control)의 어려움 A Batch B Batch C Batch D

    Batch E Batch 0시 24시 4개 Batch 2개 Batch 1개 Batch Idle 12시
  60. Batch 상태 파악(Monitoring)의 어려움 - Batch에서는 동작 하나하나가 매우 길다.

    - 대부분 스케줄 Tool에서 로그를 볼 수 있지만 로그 정보가 매우 빈약하다. - 서비스 상태를 로그로 판단하는 것 자체가 전혀 시각적이지 않다. [main] m.b.practice.BatchPracticeApplicationKt : Started BatchPracticeApplicationKt in 6.06 seconds (JVM running for 6.752) [main] o.s.b.c.l.support.SimpleJobLauncher : Job: [SimpleJob: [name=job]] launched with the following parameters: [{}] [main] o.s.batch.core.job.SimpleStepHandler : Executing step: [stepName] [main] o.s.batch.core.step.AbstractStep : Step: [stepName] executed in 10m15s192ms [main] o.s.b.c.l.support.SimpleJobLauncher : Job: [SimpleJob: [name=job]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 10m15s250ms 10분 동안 대체 무슨 일이…
  61. Spring Cloud Data Flow 데이터 수집, 분석, 데이터 입/출력과 같은

    데이터 파이프라인을 만들고 오케스트레이션 Stream Task(Batch) 데이터 파이프라인 종류
  62. Spring Cloud Data Flow 도입 - 다수 Batch가 상호 간섭

    없이 Running (by 컨테이너) - K8s에서 Resouce 사용과 반납을 조율 - Spring Cloud Data Flow 자체 Dashboard 제공 - 그라파나 연동 가능 K8s와 완벽한 연동으로 Batch 실행 오케스트레이션 Spring Batch 유용한 정보 시각적으로 모니터링
  63. Spring Cloud Data Flow 동작과 역할 스케줄 (cronJob) 관리 애플리케이션

    실행(배포) Workflow (pipeline) 컨트롤 K8s 모든 config 설정 가능 Batch Pod에 할당할 자원 설정 SCDF Task Batch App 2 Pod2 Batch App 3 Pod3 Batch App 1 Pod1
  64. Application Log pod 상태 모니터링 Batch 상태, 결과 모니터링 Spring

    Cloud Data Flow 동작과 역할 SCDF Task Batch App 2 Pod2 Batch App 3 Pod3 Batch App 1 Pod1
  65. Spring Cloud Data Flow Task 모니터링 (Step 누적 히스토리) 평균

    소요시간 한 번 읽을 때 소요시간 최소 최대 표준편차 Read 횟수 Write 횟수
  66. 발표에서 다루고자 하는 내용 대량 데이터 READ 데이터 Aggregation 처리

    대량 데이터 WRITE Batch 구동 환경 대량 데이터 처리 방식 총정리(발표 내용 정리)
  67. 대량 데이터 처리 방식 총정리(발표 내용 정리) 대량 데이터 READ

    - ZeroOffsetItemReader (with QueryDSL) - CursorItemReader (with Exposed) 데이터 Aggregation 처리 - 쿼리 의존도 ↓ - Redis(with Pipeline)를 통한 Aggregation 대량 데이터 WRITE - Batch Insert 사용 Batch 구동 환경 - Spring Cloud Data Flow - Batch 오케스트레이션, 모니터링과 히스토리 강화
  68. 참고 문헌 1) Spring Cloud Data Flow 소개 내용, https://www.baeldung.com/spring

    - cloud - data - flow - stream - processing 2) Spring Cloud Data Flow 소개 화면, https://dataflow.spring.io/docs/ https://dataflow.spring.io/docs/feature - guides/batch/ 3) Batch Insert 성능측정 결과, https://cheese10yun.github.io/spring - batch - batch - insert/