과정을 즐기자

대량의 insert 쿼리 jdbc batch update를 사용하여 개선하기 본문

Spring Data

대량의 insert 쿼리 jdbc batch update를 사용하여 개선하기

320Hwany 2024. 4. 23. 17:52

프로젝트를 진행하면서 사용자가 시간표를 등록하면 해당 시간표 정보를 서비스의 캘린더에 반영하는 기능을 구현하였습니다.

이때 1명의 사용자만 한 학기의 시간표를 등록해도 한 번에 200, 300개의 insert 쿼리가 날라갔습니다.

만약 1000명의 사용자가 동시에 등록을 하게되면 20, 30만건의 insert 쿼리가 한 번에 날라가게 된 것입니다.

Spring Data Jpa의 save 메소드

기존에 Spring Data JPA를 사용하여 단순히 save 메소드를 호출하였는데 서비스가 점점 커질수록 이러한 방식은

문제점을 가지고 있던 것입니다. 먼저 기존의 코드를 살펴보겠습니다.

저장할 스케줄을 List로 받아서 하나 하나 save 메소드를 호출해주었습니다.

 @Transactional
 public void saveTimeTables(final List<Schedule> schedules, final long memberId) {
     schedules.stream()
             .map(schedule -> schedule.toEntity(memberId, SCHEDULE_MEETING_ID.value()))
             .forEach(scheduleRepository::save);
 }

 

개인 노트북에서 Atillery 성능 테스트 툴을 사용하여 10초 동안 1000번의 요청을 날려보았습니다.

평균적으로 응답시간이 약 2.2초 정도 나왔고 95퍼센트의 사용자는 약 2.8초이내 응답 받을 수 있는 것으로 나왔습니다.

사용자 요청은 1000개였지만 실제로 발생한 DB I/O는 약 20만건 정도 됩니다.

쿼리는 비슷하지만 컬럼값만 조금씩 다른데 불필요한 DB I/O가 많이 발생한 것입니다.

이것은 로컬 환경에서 성능 테스트를 했는데 만약 실제 운영 환경이었다면 응답시간은 더 길어질 것입니다.

jdbc batch update 로 개선

위와 같은 문제를 개선하기 위해 jdbc batch update를 사용하였습니다.

@Transactional
public void batchUpdateSchedules(final TimeTableSchedulingTask timeTableSchedulingTask) {
    scheduleRepository.batchUpdate(timeTableSchedulingTask);
}
@Override
public void batchUpdate(final TimeTableSchedulingTask timeTableSchedulingTask) {
    LocalDateTime now = LocalDateTime.now();
    List<Schedule> schedules = timeTableSchedulingTask.schedules();
    long memberId = timeTableSchedulingTask.memberId();
    String sql = "INSERT INTO schedule (member_id, meeting_id, schedule_name, day_of_week, start_date_time, end_date_time, created_at, last_modified_at)" +
            " VALUES (?, ?, ?, ?, ?, ?, ?, ?)";

    jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {

        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            Schedule schedule = schedules.get(i);
            ScheduleJpaEntity entity = schedule.toEntity(memberId, SCHEDULE_MEETING_ID.value());
            ps.setLong(1, entity.getMemberId());
            ps.setLong(2, entity.getMeetingId());
            ps.setString(3, entity.getScheduleName());
            ps.setString(4, entity.getDayOfWeek().toString());
            ps.setTimestamp(5, Timestamp.valueOf(entity.getStartDateTime()));
            ps.setTimestamp(6, Timestamp.valueOf(entity.getEndDateTime()));
            ps.setTimestamp(7, Timestamp.valueOf(now));
            ps.setTimestamp(8, Timestamp.valueOf(now));
        }

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

 

위와 같은 로직에서는 사용자 요청이 1000개일 때 DB I/O가 약 20만건 발생하는 것이 아니라 1000건만 발생합니다.

이전에는 각각의 insert 쿼리가 발생하였다면

INSERT INTO schedule (member_id, schedule_name, ...) VALUES (?, ?, ...);
INSERT INTO schedule (member_id, schedule_name, ...) VALUES (?, ?, ...);
INSERT INTO schedule (member_id, schedule_name, ...) VALUES (?, ?, ...);

 

jdbc batch update를 사용하면 하나의 insert 쿼리에 values 만 다르게 들어가게 되는 것입니다. 

INSERT INTO schedule (member_id, schedule_name, ...) 
VALUES (?, ?, ...),
VALUES (?, ?, ...),
VALUES (?, ?, ...);

 

마찬가지로 Atillery 성능 테스트 툴을 사용하여 10초 동안 1000번의 요청을 날려보았습니다.

사용자 요청 1000개에 대해 약 20만개의 로우를 생성하지만 insert 쿼리는 1000개만 생성되는 것입니다.

평균적으로 응답시간이 약 0.023초 정도 나왔고 95퍼센트의 사용자는 약 0.13초이내 응답 받을 수 있는 것으로 나왔습니다.

이전과 비교해서 95퍼센트의 사용자의 응답시간을 기준으로하면 약 2.8초에서 약 0.13초로 95% 정도의 성능 향상을 볼 수 있습니다.

서버의 메모리를 큐로 사용?

1000명의 사용자만 시간표를 등록해도 약 20만건의 로우가 생겨서 DB 서버가 다운될 수도 있다는 생각이 들어서

서버의 메모리에 큐를 하나 생성하여 시간표 등록을 하면 큐에 넣어놓고 스케줄링 처리를 하여 DB 서버에 쿼리를 날리는 방식을

생각해보았습니다. 이렇게하고 테스트를 해보기 위해 코드도 한 번 작성해보았습니다.

@Implement
public class SchedulingTaskQueue {

    private final Queue<TimeTableSchedulingTask> queue = new ConcurrentLinkedQueue<>();

    public void addTimeTables(final List<Schedule> schedules, final long memberId) {
        TimeTableSchedulingTask timeTableSchedulingTask = TimeTableSchedulingTask.of(schedules, memberId);
        queue.add(timeTableSchedulingTask);
    }

    public boolean isEmpty() {
        return queue.isEmpty();
    }

    public TimeTableSchedulingTask peek() {
        return queue.peek();
    }

    public void poll() {
        queue.poll();
    }

    public int size() {
        return queue.size();
    }
}
// 0.1초에 한번씩 실행
@Scheduled(fixedRate = 100)
public void processSchedulingTasks() {
    for (int i = 0; i < TIME_TABLE_SCHEDULING_COUNT.value() && !schedulingTaskQueue.isEmpty(); i++) {
        TimeTableSchedulingTask timeTableSchedulingTask = schedulingTaskQueue.peek();
        boolean success = false;

        try {
            scheduleAppender.batchUpdateSchedules(timeTableSchedulingTask);
            success = true;
        } catch (DataAccessException e) {
            throw new InternalServerException(TIME_TABLE_SCHEDULING_ERROR.value());

        } finally {
            if (success) {
                schedulingTaskQueue.poll();
            }
        }
    }
}

 

즉 사용자의 요청이 들어올 때 바로 DB I/O가 발생하는 것이 아니고 서버의 큐에 넣어놓아서 DB I/O를 조절할 수 있도록

한 것입니다. 아무리 많은 요청이 들어와도 시간표 등록 쿼리에 대한 DB I/O는 상한선이 있도록 만들어준 것입니다. 

하지만 사실 Spring Data JPA save 메소드로 1000번 호출하는 것과 (로우 1000개) jdbc batch update로 values가

200개 있는 insert 쿼리 1000번 날리는 것이 (로우 20만개) 어느 정도의 차이가 있는지를 확인해보고 도입을 해야겠다는 생각이

들었습니다. 현재 단순히 응답시간을 기준으로 판단했을 때는 둘이 큰 차이가 없었지만 점점 서비스가 커지면서 CPU, 메모리 리소스까지

확인해보면 다를 수도 있겠다는 생각이 들었습니다.

언제 도입할까?

그렇다면 서버의 메모리에 큐를 사용하는 것을 도입할지 말지는 다음과 같은 조건으로 판단해볼 수 있을 것 같습니다.

 

1. 트래픽이 많지 않은 경우 jdbc batch update 만으로 충분하다.

2. 트래픽이 많아지고 서버의 메모리는 충분하지만 DB 서버의 리소스가 부족하거나 API 응답시간이 느려질 때 
서버의 메모리에 큐를 사용하고 스케줄링 처리해볼 수 있을 것 같다. 이 경우에 DB I/O 조절의 장점이 더 큰지

아니면 스케줄링 처리할 때 드는 리소스 증가의 단점이 더 큰지를 보고 상황에 따라 선택하자.