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 |
