Batch Insert로 API 응답 속도 175배 개선하기

2025. 3. 3. 21:56·삽질로그

 

1. 들어가며

지난 글에서 예약 슬롯을 생성하는 API에서 발생한 성능 문제점을 분석했다.

🔗 이전 글: 2025.03.03 - [삽질로그] - 내 API 응답시간은 왜 이렇게 오래 걸릴까?

 

 

내 API 응답시간은 왜 이렇게 오래 걸릴까?

1. 들어가며최근 스타트업에서 사업장의 운영시간에 따라 구장의 예약 슬롯을 한 시간 단위로 생성하는 API를 만들게 되었다.이 기능은 다음과 같은 경우에 실행된다.✅ 구장 생성 시✅ 사업장

mingking2.tistory.com

 

주요 문제는 다음과 같았다.

✅ 중복 검사로 인한 N+1 문제 → existsReservationSlot()을 개별적으로 호출
✅ 개별적인 Insert 실행 → saveAll()이 여러 번 실행되어 트랜잭션 오버헤드 증가
✅ 개별적인 Delete 실행 → deleteById()를 반복 호출하여 성능 저하

 

🚨 즉, 데이터 처리가 많아질수록 응답 시간이 증가하는 문제가 있었다.
이를 해결하기 위해 Batch Insert를 적용해 보았다.

 


2. Batch Insert란?

Batch Insert는 여러 개의 INSERT 쿼리를 한 번의 요청으로 실행하는 방식이다.
이 방식은 개별적으로 INSERT를 실행하는 것보다 DB 부하를 줄이고 성능을 최적화할 수 있다.

 

기존 방식 (개별 INSERT 진행)

INSERT INTO reservation_slot (field_id, start_at, end_at, price) VALUES (1, '2024-02-08 09:00', '2024-02-08 10:00', 10000);
INSERT INTO reservation_slot (field_id, start_at, end_at, price) VALUES (1, '2024-02-08 10:00', '2024-02-08 11:00', 10000);
INSERT INTO reservation_slot (field_id, start_at, end_at, price) VALUES (1, '2024-02-08 11:00', '2024-02-08 12:00', 10000);
  • SQL이 여러 번 실행되어 트랜잭션 오버헤드가 증가한다.

 

Batch Insert 방식 (한 번에 실행)

INSERT INTO reservation_slot (field_id, start_at, end_at, price) VALUES
(1, '2024-02-08 09:00', '2024-02-08 10:00', 10000),
(1, '2024-02-08 10:00', '2024-02-08 11:00', 10000),
(1, '2024-02-08 11:00', '2024-02-08 12:00', 10000);
  • 한 번의 요청으로 여러 데이터를 저장하여 성능 개선

 

하지만 현재 상황에서 Batch Insert를 적용할 수 없다.

 

Identitiy 전략으로는 Batch Insert가 불가능한 이유

현재 ReservationSlot 엔티티는 IDENTITY 전략을 사용하고 있어서 Batch Insert를 적용할 수 없는 상태이다.

그 이유는 IDENTITY 전략이 데이터베이스의 AUTO_INCREMENT를 사용하여 ID를 생성하기 때문이다.

 

  • IDENTITY는 AUTO_INCREMENT를 사용하기 때문에 Batch Insert가 불가능
    • saveAll()을 사용할 경우, 각 INSERT마다 ID를 즉시 조회해야 하기 때문에 개별 실행된다.
    • Batch Insert를 적용하려면 ID를 미리 할당할 수 있는 전략(SEQUENCE)을 사용해야 하지만, 변경이 어려운 상황이다.
  • 이미 서비스에 적용된 테이블이라 쉽게 변경이 불가능
    • 기존 데이터가 존재하는 운영 환경에서 기본 키 전략을 변경하려면 마이그레이션이 필요하다.
    • IDENTITY -> SEQUENCE 또는 TABLE 전략으로 변경하려면 데이터베이스 스키마 변경이 필요하고 이에 따른 리스크가 크다.
이를 해결하려면 JPA 대신 JdbcTemplate.batchUpdate()를 사용하여 Batch Insert를 수행해야 한다.

3. Batch Insert 적용 방법

기존 코드 문제점

if (!validSlots.isEmpty()) {
    reservationSlotRepository.saveAll(validSlots); // 개별적인 saveAll 호출
}
  • saveAll()이 여러 번 실행되며, 개별적인 트랜잭션이 발생
  • 하루 24개 슬롯 × 30일 → saveAll() 30번 호출

 

JdbcTemplate.batchUpdate() 적용

JdbcTemplate.batchUpdate()는 하나의 SQL을 여러 데이터에 대해 실행할 때 사용하는 메서드다.
내부적으로 JDBC의 batch execution 기능을 활용하여 여러 개의 INSERT 문을 한 번에 실행한다.
이를 통해 트랜잭션 오버헤드를 줄이고, SQL 실행 횟수를 최소화할 수 있다.

 

JdbcTemplate.batchUpdate()를 사용하여 Batch Insert 구현해보자!

public class CustomReservationSlotRepository {

    private final JdbcTemplate jdbcTemplate;
    private static final int BATCH_SIZE = 1000;

    public void saveAll(Set<ReservationSlot> reservationSlots) {
        List<ReservationSlot> slotList = new ArrayList<>(reservationSlots);
        int totalSize = slotList.size();

        for (int i = 0; i < totalSize; i += BATCH_SIZE) {
            int end = Math.min(i + BATCH_SIZE, totalSize);
            batchInsert(slotList.subList(i, end));
        }
    }

    private void batchInsert(List<ReservationSlot> subReservationSlots) {
        jdbcTemplate.batchUpdate(
            "INSERT INTO reservation_slot (`field_id`, `start_at`, `end_at`, `price`, `discount`, `is_reserved`, `is_closed`, `is_deleted`, `is_rainy`)"
                + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    ReservationSlot slot = subReservationSlots.get(i);
                    ps.setLong(1, slot.getField().getId());
                    ps.setObject(2, slot.getStartAt());
                    ps.setObject(3, slot.getEndAt());
                    ps.setInt(4, slot.getPrice());
                    ps.setInt(5, slot.getDiscount());
                    ps.setBoolean(6, slot.getIsReserved());
                    ps.setBoolean(7, slot.getIsClosed());
                    ps.setBoolean(8, slot.getIsDeleted());
                    ps.setBoolean(9, slot.getIsRainy());
                }

                @Override
                public int getBatchSize() {
                    return subReservationSlots.size();
                }
            });
    }
}

 

개선된 삭제 로직 (Batch Delete 적용)

기존에는 deleteById()를 개별적으로 호출하여 쿼리가 너무 많아지는 문제가 있었다.
이를 해결하기 위해 deleteAllByIdInBatch()를 활용한다.
public void deleteReservationSlotsV2(List<ReservationSlot> reservationSlots) {
    List<Long> slotIds = reservationSlots.stream()
        .map(ReservationSlot::getId)
        .toList();
	
    Map<Long, Payment> paymentMap = reservationSlotPaymentReader.readAllByReservationSlotIds(slotIds);
    ...
    
    if (!slotIds.isEmpty()) {
        reservationSlotRepository.deleteAllByIdInBatch(toDeleteIds);
    }
}
  • 추가로 Payment 역시 개별 조회가 아닌 한 번의 IN 절을 사용해 전체 조회하도록 수정했다.

4. Batch Insert 성능 비교

구장 생성 시 응답 시간

📌 테스트 조건

  • 신규 구장을 생성하면서 해당 구장의 예약 슬롯을 한 달치(30일) 생성
  • 평일 운영시간: 00:00 - 24:00 (하루 24개 슬롯 생성)
  • 주말 운영시간: 00:00 - 24:00 (하루 24개 슬롯 생성)
  • 생성되는 슬롯 개수: 24개 x 30일 = 720개

 

📌 쿼리 실행 개수 (Batch 적용 후)

  • Set<LocalDateTime> existingSlots 한 번의 SELECT 쿼리로 기존 슬롯의 시작 시간 조회
  • saveAll() -> Batch Insert 1회 실행 (Batch 크기: 1000)

 

📌 실행 결과

{
 "로그 타입" : "RESPONSE",
 "응답 메서드" : "class com.dalliza.ownerapi.domains.field.controller.FieldController.uploadField",
 "응답 데이터" : [ {
   "headers" : { },
   "body" : {
     "code" : "2000",
     "message" : "요청에 성공하였습니다.",
     "data" : {
       "field" : {
         "id" : 24
       }
     }
   },
   "statusCodeValue" : 200,
   "statusCode" : "OK"
 } ],
 "응답 시간(ms)" : 2379
}
방식 SQL 실행 횟수 트랜잭션 오버헤드 예상 실행 시간
기존 개별 saveAll() 30회 높음 44,711ms (약 44.7초)
✅ Batch Insert 적용 1회 (Batch 크기 1000) 낮음 2,379ms (약 2.4초)
  • 기존 코드 대비 응답 시간이 약 18배 감소
  • saveAll()을 Batch 처리하여 트랜잭션 오버헤드 최소화

 

사업장 운영시간 변경 시 응답 시간

📌 테스트 조건

  • 기존 예약 슬롯을 삭제한 후, 새로운 예약 슬롯을 생성
  • 평일 운영시간: 00:00 - 24:00 -> 09:00 - 21:00
  • 주말 운영시간: 00:00 - 24:00 -> 10:00 - 22:00
  • 연관된 모든 구장(3개)의 예약 슬롯을 재생성
  • 삭제된 슬롯 개수: 24개 x 30일 x 3개 구장 = 2,160개
  • 새로 생성된 슬롯 개수: 14개 x 30일 x 3개 구장 = 1,260개

 

📌 쿼리 실행 개수 (Batch 적용 후)

  • 기존 슬롯 삭제
    • readAllByReservationSlotIds() -> 한 번의 IN 절을 사용해 전체 조회
    • deleteAllByIdInBatch() -> Batch Delete 한 번 실행
  • 새로운 슬롯 생성
    • Set<LocalDateTime> existingSlots 한 번의 SELECT 쿼리로 기존 슬롯의 시작 시간 조회
    • saveAll() -> Batch Insert 1회 실행 (Batch 크기: 1000)

 

📌 실행 결과

{
 "로그 타입" : "RESPONSE",
 "응답 메서드" : "class com.dalliza.ownerapi.domains.site.controller.SiteOperateController.updateOperatingTime",
 "응답 데이터" : [ {
   "headers" : { },
   "body" : {
     "code" : "2000",
     "message" : "요청에 성공하였습니다.",
     "data" : { }
   },
   "statusCodeValue" : 200,
   "statusCode" : "OK"
 } ],
 "응답 시간(ms)" : 1677
}
방식 SQL 실행 횟수 트랜잭션 오버헤드 예상 실행 시간
기존 deleteById() 개별 실행 2,160회 높음 293,329ms (약 293.3초)
✅ Batch Delete 적용 1회 (Batch 크기 1000) 낮음 1,677ms (약 1.7초)
  • 기존 코드 대비 응답 시간이 약 175배 감소
  • saveAll()을 Batch Insert로 대체하여 SQL 실행 횟수 감소
  • deleteAllByIdInBatch() 적용으로 불필요한 개별 삭제 제거

5. 결론

  • Batch Insert & Batch Delete를 적용하여 예약 슬롯 생성 및 삭제 속도가 크게 향상됨
  • JdbcTemplate.batchUpdate()를 활용하여 트랜잭션 비용을 줄이고 대량 데이터 처리 효율 증가
  • 기존 N+1 문제를 해결하고, 개별적인 SQL 실행을 최소화하여 DB 부하를 줄임
앞으로 대량 데이터 처리 시 Batch Insert 및 Batch Delete 적극 활용해보자!

 

'삽질로그' 카테고리의 다른 글

Spring Boot 비동기 처리 = 스레드풀?  (0) 2025.03.16
여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까?  (2) 2025.03.09
RDS 보안 정책 때문에 접속 불가?  (0) 2025.03.04
복잡한 웹소켓 핸들러, Event로 깔끔하게 해결하기  (0) 2025.03.03
내 API 응답시간은 왜 이렇게 오래 걸릴까?  (0) 2025.03.03
'삽질로그' 카테고리의 다른 글
  • 여러 요청이 동시에 들어오면, Spring Boot는 어떻게 처리할까?
  • RDS 보안 정책 때문에 접속 불가?
  • 복잡한 웹소켓 핸들러, Event로 깔끔하게 해결하기
  • 내 API 응답시간은 왜 이렇게 오래 걸릴까?
mingking2
mingking2
에러와 삽질 속에서 성장하는 응애 개발자의 성장 일지
  • mingking2
    Mingking의 삽질 기록
    mingking2
  • 전체
    오늘
    어제
    • 분류 전체보기 (19)
      • 삽질로그 (10)
      • 스타트업 응애기 (1)
      • 프로젝트 도전기 (2)
      • DB 모르는 백엔드 탈출기 (5)
      • 네트워크 (1)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    DB
    연관관계
    MVC
    JPA
    server
    n:m
    batch
    @JoinColumn
    플로우차트
    Java
    servlet
    Tomcat
    spring
    CompletableFuture
    context
    orm
    Async
    RDB
    WebSocket
    Sync
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
mingking2
Batch Insert로 API 응답 속도 175배 개선하기
상단으로

티스토리툴바