일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- iterator
- 가비지 컬렉션
- Locking Read
- Lock
- gc
- Di
- iterable
- 동시성
- CAS
- 스프링
- 동시성 문제
- foreach
- MVCC
- 백엔드
- 자바
- java
- Synchronized
- db
- text
- reflection
- Varchar
- jpa
- MySQL
- Atomic Type
- 데이터 타입
- 가비지 컬렉터
- Today
- Total
과정을 즐기자
대량의 insert 쿼리 jdbc batch update를 사용하여 개선하기 본문
프로젝트를 진행하면서 사용자가 시간표를 등록하면 해당 시간표 정보를 서비스의 캘린더에 반영하는 기능을 구현하였습니다.
이때 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가 많이 발생한 것입니다.
이것은 로컬 환경에서 성능 테스트를 했는데 만약 실제 운영 환경이었다면 응답시간은 더 길어질 것입니다.
📘 Spring Data JPA의 saveAll() 메소드
Spring Data JPA는 saveAll() 메소드도 지원합니다.
saveAll() 메소드는 bulk로 처리하지 않을까 했지만 모두 단건으로 쿼리가 실행되었습니다.
내부 코드를 살펴 보겠습니다.
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList();
Iterator var4 = entities.iterator();
while(var4.hasNext()) {
S entity = (Object)var4.next();
result.add(this.save(entity));
}
return result;
}
saveAll() 메소드도 내부적으로는 save() 메소드를 사용합니다.
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return this.entityManager.merge(entity);
}
}
public boolean isNew(T entity) {
ID id = (ID)this.getId(entity);
Class<ID> idType = this.getIdType();
if (!idType.isPrimitive()) {
return id == null;
} else if (id instanceof Number) {
Number n = (Number)id;
return n.longValue() == 0L;
} else {
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
}
이때 id 값이 없으면 isNew() 메소드를 통해 새로운 엔티티인지 확인합니다.
id는 PK를 뜻하며 PK가 있어야지만 persist() 메소드를 실행할 수 있는 것입니다.
이때 id는 MySQL auto increment 전략으로 DB에서 직접 관리가 되었기 때문에 매번 단건 쿼리가 실행되는 것입니다.
JPA의 쓰기지연을 하려면 id 값을 알아야 하는데 auto increment 전략은 DB에서 정하기 때문입니다.
따라서 saveAll() 메소드 사용으로 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 조절의 장점이 더 큰지
아니면 스케줄링 처리할 때 드는 리소스 증가의 단점이 더 큰지를 보고 상황에 따라 선택하자.
'Spring Data' 카테고리의 다른 글
JPA 양방향 매핑 사용하는게 맞을까? (0) | 2023.05.17 |
---|---|
Spring Data JPA, Querydsl 사용하여 조회 기능 구현하기 (0) | 2023.03.17 |
OSIV 설정을 False로 변경하기위한 리팩토링 과정 (0) | 2023.03.12 |
JPA 지연 로딩 전략 사용시 Json 반환이 안될 때, N+1 문제 해결 (0) | 2023.01.29 |
JPA 변경감지 적용이 안될 때 (0) | 2023.01.02 |