약쏙의 알림 스케줄 생성 전략과 batch insert

노을·2025년 7월 28일
1
post-thumbnail

안녕하세요 약쏙에서 서버 개발을 하고 있는 노을입니다 :)
약쏙은 복약 일정을 등록하고 알림을 받을 수 있는 서비스입니다.
이 글에서는 약쏙에서 선택한 스케줄 생성 전략과 batch insert에 대한 내용을 담았습니다.


🧠 약쏙의 요구사항과 스케줄 생성 전략

약쏙 기획은 복약 종료일이 없을 수도 있다는 요구사항이 있었습니다. 이 요구사항에 맞춰 제가 고안한 방식은 다음과 같습니다.

  1. 시작일~종료일까지 전체 일정 insert, 종료일이 없는 경우엔 n년치만 insert
    → 사용자는 n년 이후의 일정은 볼 수 없음
  2. 오늘 이전 일정만 DB에 저장, 미래 일정은 요청 시 DTO를 통해 on the fly로 생성

1번 방식은 종료나 수정, 삭제 시 대량의 데이터 변경이 발생할 수 있고,
불필요하게 많은 데이터가 DB에 저장될 수 있다는 단점이 있었습니다.
그리고 유저는 끝없이 반복되는 일정을 보길 원했을텐데, UX적으로도 별로라고 생각했습니다.

이에 따라 최종적으로 2번 전략을 선택했습니다.

  • 약쏙에서는 복약 처리는 오직 당일만 가능하므로
  • 미래 일정은 실제 insert 없이도 DTO로 가상 생성 가능
  • 따라서 PK가 없어도 무방했고, 데이터 무결성에도 영향을 주지 않았습니다.

⚙️ batch insert

그래서 스케줄러로 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 + Auto Increment로 batch 처리 안되는 이유

JPA에서는 엔티티를 persist하거나 saveAll()할 때, DB로부터 생성된 ID 값을 즉시 받아와야 하는 경우가 많습니다.

특히 @GeneratedValue(strategy = GenerationType.IDENTITY) (즉, Auto Increment)는 다음과 같은 제약이 있습니다:

  1. Insert 후 즉시 PK 조회 필요
    • DB에 insert한 직후, JPA는 생성된 ID 값을 객체에 할당해야 하므로, insert → fetch를 즉시 해야 합니다.
  2. 결과적으로 insert 문이 한 줄씩 나감
    • 배치로 처리하지 못하고 한 건씩 insert → ID 받아오기 → 다음 건 insert 순으로 처리

따라서 Identity 전략은 insert 결과가 있어야 다음 작업이 가능하므로 batch insert가 어렵습니다.



🔄 Spring Data JDBC의 batchUpdate에 대해서…

@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 또는 NamedParameterJdbcTemplatebatchUpdate()를 활용해 실제 batch insert를 할 수 있습니다.



📌 batchUpdate를 사용할 때 고려해야 하는 점

📌 Auto Increment 컬럼 처리

  • 배치로 insert하면서도 ID를 받아야 하는 경우, 이를 수동으로 처리하거나 별도 전략이 필요합니다.

📌 영속성 컨텍스트와 무관

  • batchUpdate()로 저장된 데이터는 JPA의 EntityManager 또는 영속성 컨텍스트에 등록되지 않습니다.
  • 따라서 JPA의 변경 감지(dirty checking), 1차 캐시, 지연 로딩, flush() 등 JPA의 부가 기능을 사용할 수 없습니다.
  • 즉, JPA 관점에서 보면 이 데이터는 "외부에서 직접 insert된 비관리 상태"라고 볼 수 있습니다.

약쏙은 당일 스케줄만 저장하면 되는 구조였기 때문에

  • ID를 바로 받아올 필요도 없고
  • 동일 트랜잭션에서 수정될 일도 없었습니다.



🧪 성능 테스트

배치 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,759ms1,510ms약 68% 시간 단축
초당 처리 건수 (RPS)6,308건/sec19,868건/sec3배 이상 향상
트랜잭션 수30,0001DB 부하 최소화



🍀 마무리

  • 현재는 모든 데이터를 한 번에 insert하고 있지만, 데이터가 점점 많아지면 메모리 부하가 발생할 수 있으므로 batch 처리 단위(batch size)를 조절하는 전략도 함께 고민해야 할 것 같습니다.
  • 종료, 수정, 삭제 시 대량의 데이터 변경이 일어날 수 있다는 점 때문에 약쏙은 오늘 이전 일정만 DB에 저장하고, 미래 일정은 요청 시 DTO로 on the fly 생성하는 방식을 택했는데요.
    불필요한 데이터 저장을 피할 수 있었지만 조회 로직이 매우(!) 복잡해졌고 스케줄러 기반 insert는 테스트가 어렵고, 더미 데이터를 넣는 작업도 번거로웠습니다.
    그래서 1번을 선택해서 삭제와 수정 속도를 높이는 방향으로 개발했으면 어땠을까 하는 생각이 들었습니다. 만약 미래 일정도 복약 처리할 수 있는 기획으로 바뀐다면 1번으로 방법을 전환해야 할 것 같습니다.
profile
진짜를 알면 곁가지를 몰라도 된다

0개의 댓글