안녕하세요 약쏙에서 서버 개발을 하고 있는 노을입니다 :)
약쏙은 복약 일정을 등록하고 알림을 받을 수 있는 서비스입니다.
이 글에서는 약쏙에서 선택한 스케줄 생성 전략과 batch insert에 대한 내용을 담았습니다.
약쏙 기획은 복약 종료일이 없을 수도 있다는 요구사항이 있었습니다. 이 요구사항에 맞춰 제가 고안한 방식은 다음과 같습니다.
1번 방식은 종료나 수정, 삭제 시 대량의 데이터 변경이 발생할 수 있고,
불필요하게 많은 데이터가 DB에 저장될 수 있다는 단점이 있었습니다.
그리고 유저는 끝없이 반복되는 일정을 보길 원했을텐데, UX적으로도 별로라고 생각했습니다.
이에 따라 최종적으로 2번 전략을 선택했습니다.
그래서 스케줄러로 0시에 당일 일정을 db에 밀어넣고 있습니다.
@Transactional
public void generateTodaySchedules() {
LocalDateTime currentDateTime = LocalDateTime.now();
List<MedicationSchedule> schedules = medicationScheduleGenerator.generateAllTodaySchedules(currentDateTime);
medicationScheduleRepository.saveAll(schedules);
}
처음에는 이렇게 saveAll()로 저장하고 있었는데, 사실 이 saveAll()
는 쿼리가 이렇게 생길 것이라고 예상했습니다.
insert into member (name, nickname, ...)
values (name1, nickname1, ...),
(name2, nickname2, ...),
(name3, nickname3, ...);
하지만 벌크 인서트가 아닌, row 단위로 개별 insert가 발생했습니다:
insert into member (name, nickname, ...)
values (name1, nickname1, ...);
insert into member (name, nickname, ...)
values (name2, nickname2, ...);
insert into member (name, nickname, ...)
values (name3, nickname3, ...);
그래서 yml에서 batch size를 지정해 성능을 개선하고자 했습니다. 하지만 JPA + Auto Increment에서 batch size가 작동하지 않는다는 사실을 알게되었습니다.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 100
JPA에서는 엔티티를 persist
하거나 saveAll()
할 때, DB로부터 생성된 ID 값을 즉시 받아와야 하는 경우가 많습니다.
특히 @GeneratedValue(strategy = GenerationType.IDENTITY)
(즉, Auto Increment)는 다음과 같은 제약이 있습니다:
따라서 Identity 전략은 insert 결과가 있어야 다음 작업이 가능하므로 batch insert가 어렵습니다.
@Repository
public class MedicationScheduleJdbcRepository {
private final JdbcTemplate jdbcTemplate;
private static final String INSERT_SQL = """
INSERT INTO medication_schedule (scheduled_date, scheduled_time, is_taken, medication_id)
VALUES (?, ?, ?, ?)
""";
public void batchInsert(List<MedicationSchedule> schedules) {
jdbcTemplate.batchUpdate(
INSERT_SQL,
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
MedicationSchedule schedule = schedules.get(i);
ps.setObject(1, schedule.getScheduledDate());
ps.setObject(2, schedule.getScheduledTime());
ps.setBoolean(3, schedule.isTaken());
ps.setLong(4, schedule.getMedicationId());
}
@Override
public int getBatchSize() {
return schedules.size();
}
}
);
}
}
Spring Data JDBC에서는 JdbcTemplate
또는 NamedParameterJdbcTemplate
의 batchUpdate()
를 활용해 실제 batch insert를 할 수 있습니다.
📌 Auto Increment 컬럼 처리
ID
를 받아야 하는 경우, 이를 수동으로 처리하거나 별도 전략이 필요합니다.📌 영속성 컨텍스트와 무관
batchUpdate()
로 저장된 데이터는 JPA의 EntityManager
또는 영속성 컨텍스트에 등록되지 않습니다.JPA의 변경 감지(dirty checking)
, 1차 캐시
, 지연 로딩
, flush()
등 JPA의 부가 기능을 사용할 수 없습니다.약쏙은 당일 스케줄만 저장하면 되는 구조였기 때문에
배치 insert의 성능 개선 효과를 확인하기 위해, 테스트 코드로 30,000건의 복약 일정을 생성해봤습니다.
@Test
void 배치_스케줄_생성_성능_테스트() {
User user = createTestUser();
LocalDate today = LocalDate.now();
DayOfWeek todayDayOfWeek = today.getDayOfWeek();
List<Medication> medications = createMedicationList(user, today.minusDays(1), today.plusDays(1), todayDayOfWeek, 30000);
medicationRepository.saveAll(medications);
long start = System.currentTimeMillis();
medicationScheduleJob.runToday();
long end = System.currentTimeMillis();
log.info("배치 작업 소요 시간: {} ms", (end - start));
log.info("생성된 스케줄 수: {}", medicationScheduleRepository.count());
}
항목 | 🧩 JPA saveAll() | 🚀 Spring JDBC batchUpdate() | ✅ 효과 |
---|---|---|---|
총 처리 시간 | 4,759ms | 1,510ms | 약 68% 시간 단축 |
초당 처리 건수 (RPS) | 6,308건/sec | 19,868건/sec | 3배 이상 향상 |
트랜잭션 수 | 30,000 | 1 | DB 부하 최소화 |