1. 문제
약 루틴을 30개를 등록한다고 했을 때, 아래의 쿼리가 발생한다.
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
Hibernate: insert into routine_medicine (dose,is_taken,medicine_id,nickname,routine_id) values (?,?,?,?,?)
2. 문제 로직
RoutineMedicine 엔티티 리스트를 모아서 한번에 저장하기 위해 아래와 같이 로직을 작성하였다.
리스트를 모두 돌고 saveAll을 통해 한번에 저장한다면 insert 쿼리가 한번만 발생할 것이라고 예상했으나 틀렸다.
for (LocalDate localDate : routineDates) {
for (UserScheduleEntity userScheduleEntity : userScheduleEntities) {
quantity += dose;
if (quantity > routineRegisterRequest.getTotalQuantity()) break;
// routine entity 가 존재한다면 가져오기 아니면 생성하기
RoutineEntity routineEntity=routineService.getRoutineByUserScheduleAndTakeDate(userEntity, userScheduleEntity, localDate);
RoutineMedicineEntity routineMedicineEntity=RoutineMedicineEntity.builder()
.nickname(nickname)
.isTaken(false)
.dose(dose)
.routine(routineEntity)
.medicineId(medicineDocument.getId())
.build()
;
routineMedicineEntities.add(routineMedicineEntity);
}
}
routineMedicineService.saveAll(routineMedicineEntities);
3. 문제 분석
따라서 하이버네이트가 JPA내부에 쿼리들을 저장하고 한번에 Insert요청할 수 있도록 아래와 같이 설정을 추가하였다. 설정대로라면 30개의 쿼리가 쌓인 순간 하이버네이트는 데이터베이스에 Insert요청을 한다.
jpa:
hibernate:
ddl-auto: validate
show-sql: true
properties:
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
hibernate.hibernate.default_schema: public
hibernate.hibernate.jdbc.batch_size: 30
hibernate.order_inserts: true
hibernate.order_updates: true
하지만 설정을 하였음에도, 여전히 RoutineMedicine Insert요청을 엔티티 개수만큼 요청하였다.
왜 배치 사이즈를 설정하였음에도 문제가 여전히 발생할까?
4. 해답
문제의 원인은 JPA의 작동방식과 ID 생성 전략에 있었다.
- JPA는 데이터베이스에 실제로 쿼리를 날리기 전에 캐싱하는 영속성 컨텍스트를 활용한다.
- ID생성 전략 중 하나인 IDENTITY는 ID생성을 데이터베이스에 위임한다.
현재 RoutineMedicine 엔티티의 ID 생성전략은 IDENTITY를 사용하고 있었다.
즉 데이터베이스에 ID를 요청해야지 영속성 컨텍스트에 해당 내용을 저장할 수 있다.
요청 흐름
본격적으로 분석하기 전에 알아야할 점이 있다.
save, saveAll, update함수는 데이터베이스에 바로 요청하는 것이 아니라
영속성컨텍스트라는 스프링 애플리케이션의 캐시 데이터베이스에 요청하는 것이다.
그 이후, 트랜잭션 종료 시점에 flush → commit 순으로 데이터베이스에 실제로 저장된다.
위 그림의 흐름 정리는 아래와 같다.
- 먼저 saveAll 함수를 통해 영속성 컨텍스트에 엔티티 리스트 저장을 요청한다.
- 엔티티의 ID식별자가 없기 때문에, 개별 Insert를 통하여 DB로부터 ID값을 받아온다.
- ID가 할당된 엔티티를 영속성컨텍스트에 저장한다.
- 이후, flush를 통해 영속성 컨텍스트의 버퍼가 비워지게 되면 데이터베이스에 실제로 저장된다.
🛠 참고: flush() vs commit() 차이점
개념 flush() commit()
역할 | 변경 내용을 DB에 반영 | 트랜잭션을 종료하고 변경 내용을 확정 |
트랜잭션 종료 여부 | ❌ 종료되지 않음 | ✅ 트랜잭션 종료됨 |
롤백 가능 여부 | ✅ 가능 | ❌ 불가능 |
실행 시점 | commit() 호출 전, JPQL 실행 전 등 | flush() 실행 후 |
사용 목적 | 변경된 데이터를 미리 DB에 반영 (예: JPQL 실행 전) | 데이터 변경을 확정 |
flush를 실행하면 영속성 컨텍스트에 저장된 내용은 초기화된다.
해결방법
ID값을 할당 받기 위해서 개별적으로 insert 요청을 하기 때문에, ID를 미리 할당 받아 스프링 애플리케이션 단에서 관리하면 된다.
현재 개발 중인 시스템에서 PostgreSQL 데이터베이스를 사용하고 있다.
PostgreSQL의 경우 시퀀스 기능을 제공해주기 때문에, 엔티티 ID생성 전략 중 SEQUENCE 사용이 가능하다.
먼저 사용하고 있던 Sequence의 increment 속성을 변경하였다.
엔티티 객체의 increment 수와 데이터베이스의 실제 시퀀스의 설정 값이 일치해야한다.
alter sequence routine_medicine_id_seq
increment by 30;
그 다음으로 Entity 설정을 수정하였다.
실제 시퀀스의 increment 수와 동일하게 allocationSize를 설정하였다.
@Entity
@Table(name = "routine_medicine")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@SequenceGenerator(
name = "routine_medicine_seq",
sequenceName = "routine_medicine_id_seq",
allocationSize = 30
)
public class RoutineMedicineEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "routine_medicine_seq")
private Long id;
5. 테스트
배치 쿼리 설정이 잘 적용되었는지 해당 로직을 요청하였다.
하지만 이전과 동일하게 insert 로그가 여러번 발생하였다.
이 현상에 대해서 찾아본 결과, insert로그는 실제로 실행되는 로그가 아닌, hibernate에서 생성한 쿼리로, 배치작업시 이 쿼리들을 모아서 한번에 전송한다고 한다.
따라서 성능테스트, 저수준의 로그로 배치 여부를 판단해야한다고 하였다.
나는 hibernate의 generate_statistics 옵션을 통하여 쿼리를 분석하였다.
아래는 application.yaml 파일의 일부이다.
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
default_schema: public
jdbc.batch_size: 30
order_inserts: true
order_updates: true
show_sql: true
generate_statistics: true # 배치 실행 횟수
쿼리를 실행한 결과 아래와 같은 매트릭 정보가 출력되었다.
2025-03-18 01:16:42.709 [http-nio-8080-exec-3] INFO o.h.e.i.StatisticalLoggingSessionEventListener - Session Metrics {
13950875 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
682545 nanoseconds spent preparing 34 JDBC statements;
176425040 nanoseconds spent executing 33 JDBC statements;
7376375 nanoseconds spent executing 1 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
12743334 nanoseconds spent executing 1 flushes (flushing a total of 64 entities and 33 collections);
2156207 nanoseconds spent executing 31 pre-partial-flushes;
2425748 nanoseconds spent executing 31 partial-flushes (flushing a total of 556 entities and 556 collections)
}
7376375 nanoseconds spent executing 1 JDBC batches;
이 텍스트 출력을 통해 한건의 배치 작업이 실행된 것을 확인할 수 있었다.
6. 결론
문제를 찾고 쿼리를 최적화하는 과정에서, JPA와 영속성 컨텍스트의 작동 방식에 대해서 조금이나 이해할 수 있었다. JPA는 데이터베이스의 부하를 최소화하기 위해 여러 기능을 제공하며, 이 기능들을 통해 유사 캐시 데이터베이스의 역할을 한다는 것을 깨닫게 되었다.
'JPA' 카테고리의 다른 글
[JPA] Fetch Join과 Pageable을 함께 사용할 수 없는 이유와 해결 방법 정리 (0) | 2025.04.04 |
---|---|
[JPA] 배치를 이용한 쿼리 최적화(2) (0) | 2025.03.18 |
[JPA] 배치를 이용한 쿼리 최적화 (1) (0) | 2025.03.18 |
[JPA] FETCH JOIN ORDER문 사용 (0) | 2025.03.17 |